From bfe5af7edcc3decc9a8c67787ae38b4aad3d2d05 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 27 May 2019 13:46:04 +0300 Subject: [PATCH] Add Matrix->WhatsApp EDU bridging --- ROADMAP.md | 7 ++- custompuppet.go | 107 +++++++++++++++++++++++++++++++++++---- puppet.go | 2 + user.go | 10 ++-- whatsapp-ext/presence.go | 18 ++----- 5 files changed, 114 insertions(+), 30 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 3d53bb5..65cc9d0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6,9 +6,9 @@ * [x] Media/files * [x] Replies * [x] Message redactions - * [ ] Presence[4] - * [ ] Typing notifications[4] - * [ ] Read receipts[4] + * [x] Presence + * [x] Typing notifications + * [x] Read receipts * [ ] Power level * [ ] Membership actions * [ ] Invite @@ -65,4 +65,3 @@ [1] May involve reverse-engineering the WhatsApp Web API and/or editing go-whatsapp [2] May already work [3] May not be possible -[4] Requires [matrix-org/synapse#2954](https://github.com/matrix-org/synapse/issues/2954) or Matrix puppeting diff --git a/custompuppet.go b/custompuppet.go index e7e68c8..5b31438 100644 --- a/custompuppet.go +++ b/custompuppet.go @@ -24,6 +24,7 @@ import ( "github.com/pkg/errors" + "github.com/Rhymen/go-whatsapp" "maunium.net/go/mautrix" "maunium.net/go/mautrix-appservice" ) @@ -77,30 +78,38 @@ func (puppet *Puppet) newCustomIntent() (*appservice.IntentAPI, error) { return ia, nil } +func (puppet *Puppet) clearCustomMXID() { + puppet.CustomMXID = "" + puppet.AccessToken = "" + puppet.customIntent = nil + puppet.customTypingIn = nil + puppet.customUser = nil +} + func (puppet *Puppet) StartCustomMXID() error { if len(puppet.CustomMXID) == 0 { + puppet.clearCustomMXID() return nil } intent, err := puppet.newCustomIntent() if err != nil { - puppet.CustomMXID = "" - puppet.AccessToken = "" + puppet.clearCustomMXID() return err } urlPath := intent.BuildURL("account", "whoami") var resp struct{ UserID string `json:"user_id"` } _, err = intent.MakeRequest("GET", urlPath, nil, &resp) if err != nil { - puppet.CustomMXID = "" - puppet.AccessToken = "" + puppet.clearCustomMXID() return err } if resp.UserID != puppet.CustomMXID { - puppet.CustomMXID = "" - puppet.AccessToken = "" + puppet.clearCustomMXID() return ErrMismatchingMXID } puppet.customIntent = intent + puppet.customTypingIn = make(map[string]bool) + puppet.customUser = puppet.bridge.GetUserByMXID(puppet.CustomMXID) puppet.startSyncing() return nil } @@ -111,6 +120,7 @@ func (puppet *Puppet) startSyncing() { } go func() { puppet.log.Debugln("Starting syncing...") + puppet.customIntent.SyncPresence = "offline" err := puppet.customIntent.Sync() if err != nil { puppet.log.Errorln("Fatal error syncing:", err) @@ -126,12 +136,91 @@ func (puppet *Puppet) stopSyncing() { } func (puppet *Puppet) ProcessResponse(resp *mautrix.RespSync, since string) error { - d, _ := json.Marshal(resp) - puppet.log.Debugln("Sync data:", string(d), since) - // TODO handle sync data + if !puppet.customUser.Connected { + return fmt.Errorf("custom user not connected to whatsapp") + } + for roomID, events := range resp.Rooms.Join { + portal := puppet.bridge.GetPortalByMXID(roomID) + if portal == nil { + continue + } + for _, event := range events.Ephemeral.Events { + switch event.Type { + case mautrix.EphemeralEventReceipt: + go puppet.handleReceiptEvent(portal, event) + case mautrix.EphemeralEventTyping: + go puppet.handleTypingEvent(portal, event) + } + } + } + for _, event := range resp.Presence.Events { + if event.Sender != puppet.CustomMXID { + continue + } + go puppet.handlePresenceEvent(event) + } return nil } +func (puppet *Puppet) handlePresenceEvent(event *mautrix.Event) { + presence := whatsapp.PresenceAvailable + if event.Content.Raw["presence"].(string) != "online" { + presence = whatsapp.PresenceUnavailable + puppet.customUser.log.Infoln("Marking offline") + } else { + puppet.customUser.log.Infoln("Marking online") + } + _, err := puppet.customUser.Conn.Presence("", presence) + if err != nil { + puppet.customUser.log.Warnln("Failed to set presence:", err) + } +} + +func (puppet *Puppet) handleReceiptEvent(portal *Portal, event *mautrix.Event) { + for eventID, rawReceipts := range event.Content.Raw { + if receipts, ok := rawReceipts.(map[string]interface{}); !ok { + continue + } else if readReceipt, ok := receipts["m.read"].(map[string]interface{}); !ok { + continue + } else if _, ok = readReceipt[puppet.CustomMXID].(map[string]interface{}); !ok { + continue + } + message := puppet.bridge.DB.Message.GetByMXID(eventID) + if message == nil { + continue + } + puppet.customUser.log.Infofln("Marking %s/%s in %s/%s as read", message.JID, message.MXID, portal.Key.JID, portal.MXID) + _, err := puppet.customUser.Conn.Read(portal.Key.JID, message.JID) + if err != nil { + puppet.customUser.log.Warnln("Error marking read:", err) + } + } +} + +func (puppet *Puppet) handleTypingEvent(portal *Portal, event *mautrix.Event) { + isTyping := false + for _, userID := range event.Content.TypingUserIDs { + if userID == puppet.CustomMXID { + isTyping = true + break + } + } + if puppet.customTypingIn[event.RoomID] != isTyping { + puppet.customTypingIn[event.RoomID] = isTyping + presence := whatsapp.PresenceComposing + if !isTyping { + puppet.customUser.log.Infofln("Marking not typing in %s/%s", portal.Key.JID, portal.MXID) + presence = whatsapp.PresencePaused + } else { + puppet.customUser.log.Infofln("Marking typing in %s/%s", portal.Key.JID, portal.MXID) + } + _, err := puppet.customUser.Conn.Presence(portal.Key.JID, presence) + if err != nil { + puppet.customUser.log.Warnln("Error setting typing:", err) + } + } +} + func (puppet *Puppet) OnFailedSync(res *mautrix.RespSync, err error) (time.Duration, error) { puppet.log.Warnln("Sync error:", err) return 10 * time.Second, nil diff --git a/puppet.go b/puppet.go index 2c3c2d1..28361b4 100644 --- a/puppet.go +++ b/puppet.go @@ -147,6 +147,8 @@ type Puppet struct { MXID types.MatrixUserID customIntent *appservice.IntentAPI + customTypingIn map[string]bool + customUser *User } func (puppet *Puppet) PhoneNumber() string { diff --git a/user.go b/user.go index 7f9e69a..586c22c 100644 --- a/user.go +++ b/user.go @@ -344,7 +344,9 @@ func (user *User) updateLastConnectionIfNecessary() { } func (user *User) HandleError(err error) { - user.log.Errorln("WhatsApp error:", err) + if err != whatsapp.ErrInvalidWsData { + user.log.Errorln("WhatsApp error:", err) + } var msg string if closed, ok := err.(*whatsapp.ErrConnectionClosed); ok { user.Connected = false @@ -464,9 +466,9 @@ func (user *User) HandleMessageRevoke(message whatsappExt.MessageRevocation) { func (user *User) HandlePresence(info whatsappExt.Presence) { puppet := user.bridge.GetPuppetByJID(info.SenderJID) switch info.Status { - case whatsappExt.PresenceUnavailable: + case whatsapp.PresenceUnavailable: _ = puppet.DefaultIntent().SetPresence("offline") - case whatsappExt.PresenceAvailable: + case whatsapp.PresenceAvailable: if len(puppet.typingIn) > 0 && puppet.typingAt+15 > time.Now().Unix() { portal := user.bridge.GetPortalByMXID(puppet.typingIn) _, _ = puppet.IntentFor(portal).UserTyping(puppet.typingIn, false, 0) @@ -475,7 +477,7 @@ func (user *User) HandlePresence(info whatsappExt.Presence) { } else { _ = puppet.DefaultIntent().SetPresence("online") } - case whatsappExt.PresenceComposing: + case whatsapp.PresenceComposing: portal := user.GetPortalByJID(info.JID) if len(puppet.typingIn) > 0 && puppet.typingAt+15 > time.Now().Unix() { if puppet.typingIn == portal.MXID { diff --git a/whatsapp-ext/presence.go b/whatsapp-ext/presence.go index 12808e2..16ec982 100644 --- a/whatsapp-ext/presence.go +++ b/whatsapp-ext/presence.go @@ -23,20 +23,12 @@ import ( "github.com/Rhymen/go-whatsapp" ) -type PresenceType string - -const ( - PresenceUnavailable PresenceType = "unavailable" - PresenceAvailable PresenceType = "available" - PresenceComposing PresenceType = "composing" -) - type Presence struct { - JID string `json:"id"` - SenderJID string `json:"participant"` - Status PresenceType `json:"type"` - Timestamp int64 `json:"t"` - Deny bool `json:"deny"` + JID string `json:"id"` + SenderJID string `json:"participant"` + Status whatsapp.Presence `json:"type"` + Timestamp int64 `json:"t"` + Deny bool `json:"deny"` } type PresenceHandler interface {