From 1f87deb31758d161ec290bd29ca57357a5df9132 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 24 Aug 2018 19:46:14 +0300 Subject: [PATCH] Add WhatsApp->Matrix read receipts and phone connection notifications --- ROADMAP.md | 2 +- database/message.go | 2 +- database/puppet.go | 4 +- portal.go | 81 ++++++++++++++++++++++++------------- puppet.go | 22 +++++----- user.go | 66 ++++++++++++++++++++++++++---- whatsapp-ext/conn.go | 7 ++-- whatsapp-ext/jsonmessage.go | 8 ++-- whatsapp-ext/msginfo.go | 49 +++++++++++++++++----- whatsapp-ext/presence.go | 2 +- whatsapp-ext/props.go | 2 +- whatsapp-ext/stream.go | 2 +- 12 files changed, 178 insertions(+), 69 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 6a6510b..7ad6414 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -28,7 +28,7 @@ * [x] Avatars * [ ] Presence * [ ] Typing notifications - * [ ] Read receipts + * [x] Read receipts * [ ] Admin/superadmin status * [ ] Membership actions * [ ] Invite diff --git a/database/message.go b/database/message.go index fcd6ead..8e98d00 100644 --- a/database/message.go +++ b/database/message.go @@ -59,7 +59,7 @@ func (mq *MessageQuery) GetAll(owner types.MatrixUserID) (messages []*Message) { } func (mq *MessageQuery) GetByJID(owner types.MatrixUserID, jid types.WhatsAppMessageID) *Message { - return mq.get("SELECT * FROM message WHERE jid=?", jid) + return mq.get("SELECT * FROM message WHERE owner=? AND jid=?", owner, jid) } func (mq *MessageQuery) GetByMXID(mxid types.MatrixEventID) *Message { diff --git a/database/puppet.go b/database/puppet.go index dfb4bc0..0151f2d 100644 --- a/database/puppet.go +++ b/database/puppet.go @@ -93,7 +93,7 @@ func (puppet *Puppet) Insert() error { _, err := puppet.db.Exec("INSERT INTO puppet VALUES (?, ?, ?, ?)", puppet.JID, puppet.Receiver, puppet.Displayname, puppet.Avatar) if err != nil { - puppet.log.Errorln("Failed to insert %s->%s: %v", puppet.JID, puppet.Receiver, err) + puppet.log.Errorfln("Failed to insert %s->%s: %v", puppet.JID, puppet.Receiver, err) } return err } @@ -103,7 +103,7 @@ func (puppet *Puppet) Update() error { puppet.Displayname, puppet.Avatar, puppet.JID, puppet.Receiver) if err != nil { - puppet.log.Errorln("Failed to update %s->%s: %v", puppet.JID, puppet.Receiver, err) + puppet.log.Errorfln("Failed to update %s->%s: %v", puppet.JID, puppet.Receiver, err) } return err } diff --git a/portal.go b/portal.go index 336a086..d37b21b 100644 --- a/portal.go +++ b/portal.go @@ -17,21 +17,24 @@ package main import ( - "maunium.net/go/mautrix-whatsapp/database" - log "maunium.net/go/maulogger" - "fmt" - "maunium.net/go/mautrix-whatsapp/types" - "maunium.net/go/gomatrix" - "strings" - "maunium.net/go/mautrix-appservice" - "github.com/Rhymen/go-whatsapp" - "sync" - "net/http" - "maunium.net/go/mautrix-whatsapp/whatsapp-ext" - "mime" - "image" "bytes" + "encoding/hex" + "fmt" + "image" + "math/rand" + "mime" + "net/http" + "strings" + "sync" + + "github.com/Rhymen/go-whatsapp" + "maunium.net/go/gomatrix" "maunium.net/go/gomatrix/format" + log "maunium.net/go/maulogger" + "maunium.net/go/mautrix-appservice" + "maunium.net/go/mautrix-whatsapp/database" + "maunium.net/go/mautrix-whatsapp/types" + "maunium.net/go/mautrix-whatsapp/whatsapp-ext" ) func (user *User) GetPortalByMXID(mxid types.MatrixRoomID) *Portal { @@ -221,7 +224,7 @@ func (portal *Portal) CreateMatrixRoom() error { name := portal.Name topic := portal.Topic isPrivateChat := false - if strings.HasSuffix(portal.JID, "s.whatsapp.net") { + if strings.HasSuffix(portal.JID, whatsapp_ext.NewUserSuffix) { puppet := portal.user.GetPuppetByJID(portal.JID) name = puppet.Displayname topic = "WhatsApp private chat" @@ -244,7 +247,7 @@ func (portal *Portal) CreateMatrixRoom() error { } func (portal *Portal) IsPrivateChat() bool { - return strings.HasSuffix(portal.JID, puppetJIDStrippedSuffix) + return strings.HasSuffix(portal.JID, whatsapp_ext.NewUserSuffix) } func (portal *Portal) MainIntent() *appservice.IntentAPI { @@ -282,13 +285,18 @@ func (portal *Portal) GetMessageIntent(info whatsapp.MessageInfo) *appservice.In return puppet.Intent() } -func (portal *Portal) GetRelations(info whatsapp.MessageInfo) (reply gomatrix.RelatesTo) { +func (portal *Portal) SetReply(content *gomatrix.Content, info whatsapp.MessageInfo) { if len(info.QuotedMessageID) == 0 { return } message := portal.bridge.DB.Message.GetByJID(portal.Owner, info.QuotedMessageID) if message != nil { - reply.InReplyTo.EventID = message.MXID + event, err := portal.MainIntent().GetEvent(portal.MXID, message.MXID) + if err != nil { + portal.log.Warnln("Failed to get reply target:", err) + return + } + content.SetReply(event) } return @@ -299,18 +307,24 @@ func (portal *Portal) HandleTextMessage(message whatsapp.TextMessage) { return } - portal.CreateMatrixRoom() + err := portal.CreateMatrixRoom() + if err != nil { + portal.log.Errorln("Failed to create portal room:", err) + return + } intent := portal.GetMessageIntent(message.Info) if intent == nil { return } - resp, err := intent.SendMassagedMessageEvent(portal.MXID, gomatrix.EventMessage, gomatrix.Content{ - Body: message.Text, - MsgType: gomatrix.MsgText, - RelatesTo: portal.GetRelations(message.Info), - }, int64(message.Info.Timestamp*1000)) + content := gomatrix.Content{ + Body: message.Text, + MsgType: gomatrix.MsgText, + } + portal.SetReply(&content, message.Info) + + resp, err := intent.SendMassagedMessageEvent(portal.MXID, gomatrix.EventMessage, content, int64(message.Info.Timestamp*1000)) if err != nil { portal.log.Errorfln("Failed to handle message %s: %v", message.Info.Id, err) return @@ -324,7 +338,11 @@ func (portal *Portal) HandleMediaMessage(download func() ([]byte, error), thumbn return } - portal.CreateMatrixRoom() + err := portal.CreateMatrixRoom() + if err != nil { + portal.log.Errorln("Failed to create portal room:", err) + return + } intent := portal.GetMessageIntent(info) if intent == nil { @@ -353,12 +371,12 @@ func (portal *Portal) HandleMediaMessage(download func() ([]byte, error), thumbn content := gomatrix.Content{ Body: caption, URL: uploaded.ContentURI, - Info: gomatrix.FileInfo{ + Info: &gomatrix.FileInfo{ Size: len(data), MimeType: mimeType, }, - RelatesTo: portal.GetRelations(info), } + portal.SetReply(&content, info) if thumbnail != nil { thumbnailMime := http.DetectContentType(thumbnail) @@ -422,6 +440,12 @@ var htmlParser = format.HTMLParser{ }, } +func makeMessageID() string { + b := make([]byte, 10) + rand.Read(b) + return strings.ToUpper(hex.EncodeToString(b)) +} + func (portal *Portal) HandleMatrixMessage(evt *gomatrix.Event) { var err error switch evt.Content.MsgType { @@ -430,18 +454,21 @@ func (portal *Portal) HandleMatrixMessage(evt *gomatrix.Event) { if evt.Content.Format == gomatrix.FormatHTML { text = htmlParser.Parse(evt.Content.FormattedBody) } + id := makeMessageID() err = portal.user.Conn.Send(whatsapp.TextMessage{ Text: text, Info: whatsapp.MessageInfo{ + Id: id, RemoteJid: portal.JID, }, }) + portal.MarkHandled(id, evt.ID) default: portal.log.Debugln("Unhandled Matrix event:", evt) return } if err != nil { - portal.log.Errorln("Error handling Matrix event %s: %v", evt.ID, err) + portal.log.Errorfln("Error handling Matrix event %s: %v", evt.ID, err) } else { portal.log.Debugln("Handled Matrix event:", evt) } diff --git a/puppet.go b/puppet.go index bf9c28e..2bcd86e 100644 --- a/puppet.go +++ b/puppet.go @@ -17,18 +17,18 @@ package main import ( - "maunium.net/go/mautrix-whatsapp/database" - log "maunium.net/go/maulogger" "fmt" - "regexp" - "maunium.net/go/mautrix-whatsapp/types" - "strings" - "maunium.net/go/mautrix-appservice" - "github.com/Rhymen/go-whatsapp" "net/http" -) + "regexp" + "strings" -const puppetJIDStrippedSuffix = "@s.whatsapp.net" + "github.com/Rhymen/go-whatsapp" + log "maunium.net/go/maulogger" + "maunium.net/go/mautrix-appservice" + "maunium.net/go/mautrix-whatsapp/database" + "maunium.net/go/mautrix-whatsapp/types" + "maunium.net/go/mautrix-whatsapp/whatsapp-ext" +) func (bridge *Bridge) ParsePuppetMXID(mxid types.MatrixUserID) (types.MatrixUserID, types.WhatsAppID, bool) { userIDRegex, err := regexp.Compile(fmt.Sprintf("^@%s:%s$", @@ -47,7 +47,7 @@ func (bridge *Bridge) ParsePuppetMXID(mxid types.MatrixUserID) (types.MatrixUser receiver = strings.Replace(receiver, "=40", "@", 1) colonIndex := strings.LastIndex(receiver, "=3") receiver = receiver[:colonIndex] + ":" + receiver[colonIndex+len("=3"):] - jid := types.WhatsAppID(match[2] + puppetJIDStrippedSuffix) + jid := types.WhatsAppID(match[2] + whatsapp_ext.NewUserSuffix) return receiver, jid, true } @@ -120,7 +120,7 @@ func (user *User) NewPuppet(dbPuppet *database.Puppet) *Puppet { dbPuppet.Receiver, strings.Replace( dbPuppet.JID, - puppetJIDStrippedSuffix, "", 1)), + whatsapp_ext.NewUserSuffix, "", 1)), user.bridge.Config.Homeserver.Domain), } } diff --git a/user.go b/user.go index 0c6102b..88c09ce 100644 --- a/user.go +++ b/user.go @@ -17,14 +17,16 @@ package main import ( - "maunium.net/go/mautrix-whatsapp/database" - "github.com/Rhymen/go-whatsapp" - "time" - "github.com/skip2/go-qrcode" - log "maunium.net/go/maulogger" - "maunium.net/go/mautrix-whatsapp/types" + "fmt" "strings" "sync" + "time" + + "github.com/Rhymen/go-whatsapp" + "github.com/skip2/go-qrcode" + log "maunium.net/go/maulogger" + "maunium.net/go/mautrix-whatsapp/database" + "maunium.net/go/mautrix-whatsapp/types" "maunium.net/go/mautrix-whatsapp/whatsapp-ext" ) @@ -186,7 +188,7 @@ func (user *User) Sync() { user.log.Debugln("Syncing...") user.Conn.Contacts() for jid, contact := range user.Conn.Store.Contacts { - if strings.HasSuffix(jid, puppetJIDStrippedSuffix) { + if strings.HasSuffix(jid, whatsapp_ext.NewUserSuffix) { puppet := user.GetPuppetByJID(contact.Jid) puppet.Sync(contact) } @@ -205,6 +207,10 @@ func (user *User) HandleError(err error) { user.log.Errorln("WhatsApp error:", err) } +func (user *User) HandleJSONParseError(err error) { + user.log.Errorln("WhatsApp JSON parse error:", err) +} + func (user *User) HandleTextMessage(message whatsapp.TextMessage) { user.log.Debugln("Received text message:", message) portal := user.GetPortalByJID(message.Info.RemoteJid) @@ -231,6 +237,52 @@ func (user *User) HandleDocumentMessage(message whatsapp.DocumentMessage) { portal.HandleMediaMessage(message.Download, message.Thumbnail, message.Info, message.Type, message.Title) } +func (user *User) HandleStreamEvent(stream whatsapp_ext.StreamEvent) { + if len(user.ManagementRoom) == 0 { + return + } + switch stream.Type { + case whatsapp_ext.StreamSleep: + user.bridge.AppService.BotIntent().SendNotice(user.ManagementRoom, "WhatsApp client disconnected.") + case whatsapp_ext.StreamUpdate: + if user.Conn.Info != nil && user.Conn.Info.Phone != nil { + user.bridge.AppService.BotIntent().SendNotice(user.ManagementRoom, + fmt.Sprintf("WhatsApp v%s client connected from %s %s (OS v%s).", + user.Conn.Info.Phone.WaVersion, user.Conn.Info.Phone.DeviceManufacturer, user.Conn.Info.Phone.DeviceModel, user.Conn.Info.Phone.OsVersion)) + } + } +} + +func (user *User) HandleConnInfo(info whatsapp_ext.ConnInfo) { + if len(user.ManagementRoom) > 0 && len(info.ProtocolVersion) > 0 { + user.bridge.AppService.BotIntent().SendNotice(user.ManagementRoom, + fmt.Sprintf("WhatsApp v%s client connected from %s %s (OS v%s).", + info.Phone.WhatsAppVersion, info.Phone.DeviceManufacturer, info.Phone.DeviceModel, info.Phone.OSVersion)) + } +} + +func (user *User) HandleMsgInfo(info whatsapp_ext.MsgInfo) { + if (info.Command == whatsapp_ext.MsgInfoCommandAck || info.Command == whatsapp_ext.MsgInfoCommandAcks) && info.Acknowledgement == whatsapp_ext.AckMessageRead { + portal := user.GetPortalByJID(info.ToJID) + if len(portal.MXID) == 0 { + return + } + + intent := user.GetPuppetByJID(info.SenderJID).Intent() + user.log.Debugln(info.IDs) + for _, id := range info.IDs { + msg := user.bridge.DB.Message.GetByJID(user.ID, id) + if msg == nil { + continue + } + err := intent.MarkRead(portal.MXID, msg.MXID) + if err != nil { + user.log.Warnln("Failed to mark message %s as read by %s: %v", msg.MXID, info.SenderJID, err) + } + } + } +} + func (user *User) HandleJsonMessage(message string) { user.log.Debugln("JSON message:", message) } diff --git a/whatsapp-ext/conn.go b/whatsapp-ext/conn.go index 37c5192..52bef57 100644 --- a/whatsapp-ext/conn.go +++ b/whatsapp-ext/conn.go @@ -17,8 +17,9 @@ package whatsapp_ext import ( - "github.com/Rhymen/go-whatsapp" "encoding/json" + + "github.com/Rhymen/go-whatsapp" ) type ConnInfo struct { @@ -26,8 +27,8 @@ type ConnInfo struct { BinaryVersion int `json:"binVersion"` Phone struct { WhatsAppVersion string `json:"wa_version"` - MCC int `json:"mcc"` - MNC int `json:"mnc"` + MCC string `json:"mcc"` + MNC string `json:"mnc"` OSVersion string `json:"os_version"` DeviceManufacturer string `json:"device_manufacturer"` DeviceModel string `json:"device_model"` diff --git a/whatsapp-ext/jsonmessage.go b/whatsapp-ext/jsonmessage.go index be44375..b0c156f 100644 --- a/whatsapp-ext/jsonmessage.go +++ b/whatsapp-ext/jsonmessage.go @@ -18,6 +18,7 @@ package whatsapp_ext import ( "encoding/json" + "github.com/Rhymen/go-whatsapp" ) @@ -27,6 +28,7 @@ type JSONMessageType string const ( MessageMsgInfo JSONMessageType = "MsgInfo" + MessageMsg JSONMessageType = "Msg" MessagePresence JSONMessageType = "Presence" MessageStream JSONMessageType = "Stream" MessageConn JSONMessageType = "Conn" @@ -76,11 +78,11 @@ func (ext *ExtendedConn) HandleJsonMessage(message string) { case MessageStream: ext.handleMessageStream(msg[1:]) case MessageConn: - ext.handleMessageProps(msg[1]) + ext.handleMessageConn(msg[1]) case MessageProps: ext.handleMessageProps(msg[1]) - case MessageMsgInfo: - ext.handleMessageMsgInfo(msg[1]) + case MessageMsgInfo, MessageMsg: + ext.handleMessageMsgInfo(msgType, msg[1]) default: for _, handler := range ext.handlers { ujmHandler, ok := handler.(UnhandledJSONMessageHandler) diff --git a/whatsapp-ext/msginfo.go b/whatsapp-ext/msginfo.go index f63f454..c97288b 100644 --- a/whatsapp-ext/msginfo.go +++ b/whatsapp-ext/msginfo.go @@ -17,25 +17,49 @@ package whatsapp_ext import ( - "github.com/Rhymen/go-whatsapp" "encoding/json" "strings" + + "github.com/Rhymen/go-whatsapp" ) type MsgInfoCommand string const ( - MsgInfoCommandAcknowledge MsgInfoCommand = "ack" + MsgInfoCommandAck MsgInfoCommand = "ack" + MsgInfoCommandAcks MsgInfoCommand = "acks" ) +type Acknowledgement int + +const ( + AckMessageSent Acknowledgement = 1 + AckMessageDelivered Acknowledgement = 2 + AckMessageRead Acknowledgement = 3 +) + +type JSONStringOrArray []string + +func (jsoa *JSONStringOrArray) UnmarshalJSON(data []byte) error { + var str string + if json.Unmarshal(data, &str) == nil { + *jsoa = []string{str} + return nil + } + var strs []string + json.Unmarshal(data, &strs) + *jsoa = strs + return nil +} + type MsgInfo struct { - Command MsgInfoCommand `json:"cmd"` - ID string `json:"id"` - Acknowledgement int `json:"ack"` - MessageFromJID string `json:"from"` - SenderJID string `json:"participant"` - ToJID string `json:"to"` - Timestamp int64 `json:"t"` + Command MsgInfoCommand `json:"cmd"` + IDs JSONStringOrArray `json:"id"` + Acknowledgement Acknowledgement `json:"ack"` + MessageFromJID string `json:"from"` + SenderJID string `json:"participant"` + ToJID string `json:"to"` + Timestamp int64 `json:"t"` } type MsgInfoHandler interface { @@ -43,7 +67,7 @@ type MsgInfoHandler interface { HandleMsgInfo(MsgInfo) } -func (ext *ExtendedConn) handleMessageMsgInfo(message []byte) { +func (ext *ExtendedConn) handleMessageMsgInfo(msgType JSONMessageType, message []byte) { var event MsgInfo err := json.Unmarshal(message, &event) if err != nil { @@ -53,11 +77,14 @@ func (ext *ExtendedConn) handleMessageMsgInfo(message []byte) { event.MessageFromJID = strings.Replace(event.MessageFromJID, OldUserSuffix, NewUserSuffix, 1) event.SenderJID = strings.Replace(event.SenderJID, OldUserSuffix, NewUserSuffix, 1) event.ToJID = strings.Replace(event.ToJID, OldUserSuffix, NewUserSuffix, 1) + if msgType == MessageMsg { + event.SenderJID = event.MessageFromJID + } for _, handler := range ext.handlers { msgInfoHandler, ok := handler.(MsgInfoHandler) if !ok { continue } - msgInfoHandler.HandleMsgInfo(event) + go msgInfoHandler.HandleMsgInfo(event) } } diff --git a/whatsapp-ext/presence.go b/whatsapp-ext/presence.go index 57a6507..2659f50 100644 --- a/whatsapp-ext/presence.go +++ b/whatsapp-ext/presence.go @@ -53,6 +53,6 @@ func (ext *ExtendedConn) handleMessagePresence(message []byte) { if !ok { continue } - presenceHandler.HandlePresence(event) + go presenceHandler.HandlePresence(event) } } diff --git a/whatsapp-ext/props.go b/whatsapp-ext/props.go index ecf847a..09b3f95 100644 --- a/whatsapp-ext/props.go +++ b/whatsapp-ext/props.go @@ -62,6 +62,6 @@ func (ext *ExtendedConn) handleMessageProps(message []byte) { if !ok { continue } - protocolPropsHandler.HandleProtocolProps(event) + go protocolPropsHandler.HandleProtocolProps(event) } } diff --git a/whatsapp-ext/stream.go b/whatsapp-ext/stream.go index 5496df7..e78aacb 100644 --- a/whatsapp-ext/stream.go +++ b/whatsapp-ext/stream.go @@ -57,6 +57,6 @@ func (ext *ExtendedConn) handleMessageStream(message []json.RawMessage) { if !ok { continue } - streamHandler.HandleStreamEvent(event) + go streamHandler.HandleStreamEvent(event) } }