From 56850bb6983745318517d43fd33b8f5f53045189 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 22 Oct 2021 20:14:34 +0300 Subject: [PATCH] Initial switch to go.mau.fi/whatsmeow --- README.md | 9 +- bridgestate.go | 70 +- commands.go | 688 +++---- community.go | 132 -- config/bridge.go | 71 +- crypto.go | 3 +- custompuppet.go | 27 +- database/message.go | 13 +- database/portal.go | 80 +- database/puppet.go | 35 +- .../2021-10-21-add-whatsmeow-store.go | 13 + .../2021-10-21-multidevice-updates.go | 87 + database/upgrades/upgrades.go | 2 +- database/user.go | 282 ++- example-config.yaml | 20 +- formatting.go | 59 +- go.mod | 33 +- go.sum | 34 +- main.go | 51 +- matrix.go | 17 +- metrics.go | 66 +- portal.go | 1715 ++++++++--------- provisioning.go | 380 ++-- puppet.go | 151 +- user.go | 1366 ++++--------- 25 files changed, 2257 insertions(+), 3147 deletions(-) delete mode 100644 community.go create mode 100644 database/upgrades/2021-10-21-add-whatsmeow-store.go create mode 100644 database/upgrades/2021-10-21-multidevice-updates.go diff --git a/README.md b/README.md index 8942729..29fe7f2 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,10 @@ # mautrix-whatsapp -A Matrix-WhatsApp puppeting bridge based on the [Rhymen/go-whatsapp](https://github.com/Rhymen/go-whatsapp) -implementation of the [sigalor/whatsapp-web-reveng](https://github.com/sigalor/whatsapp-web-reveng) project. +A Matrix-WhatsApp puppeting bridge. ### Documentation -All setup and usage instructions are located on -[docs.mau.fi](https://docs.mau.fi/bridges/go/whatsapp/index.html). -Some quick links: +All setup and usage instructions are located on [docs.mau.fi]. Some quick links: + +[docs.mau.fi]: https://docs.mau.fi/bridges/go/whatsapp/index.html * [Bridge setup](https://docs.mau.fi/bridges/go/whatsapp/setup/index.html) (or [with Docker](https://docs.mau.fi/bridges/go/whatsapp/setup/docker.html)) diff --git a/bridgestate.go b/bridgestate.go index 5b4d3b1..668e51b 100644 --- a/bridgestate.go +++ b/bridgestate.go @@ -20,16 +20,11 @@ import ( "bytes" "context" "encoding/json" - "errors" "fmt" "io/ioutil" "net/http" - "strings" - "sync/atomic" "time" - "github.com/Rhymen/go-whatsapp" - log "maunium.net/go/maulogger/v2" "maunium.net/go/mautrix/id" @@ -94,8 +89,8 @@ type GlobalBridgeState struct { func (pong BridgeState) fill(user *User) BridgeState { if user != nil { pong.UserID = user.MXID - pong.RemoteID = strings.TrimSuffix(user.JID, whatsapp.NewUserSuffix) - pong.RemoteName = fmt.Sprintf("+%s", pong.RemoteID) + pong.RemoteID = user.JID.String() + pong.RemoteName = fmt.Sprintf("+%s", user.JID.User) } pong.Timestamp = time.Now().Unix() @@ -116,32 +111,6 @@ func (pong *BridgeState) shouldDeduplicate(newPong *BridgeState) bool { return pong.Timestamp+int64(pong.TTL/5) > time.Now().Unix() } -func (user *User) setupAdminTestHooks() { - if len(user.bridge.Config.Homeserver.StatusEndpoint) == 0 { - return - } - user.Conn.AdminTestHook = func(err error) { - if errors.Is(err, whatsapp.ErrConnectionTimeout) { - user.sendBridgeState(BridgeState{StateEvent: StateTransientDisconnect, Error: WATimeout}) - } else if errors.Is(err, whatsapp.ErrWebsocketKeepaliveFailed) { - user.sendBridgeState(BridgeState{StateEvent: StateTransientDisconnect, Error: WAServerTimeout}) - } else if errors.Is(err, whatsapp.ErrPingFalse) { - user.sendBridgeState(BridgeState{StateEvent: StateTransientDisconnect, Error: WAPingFalse}) - } else if err == nil { - user.sendBridgeState(BridgeState{StateEvent: StateConnected}) - } else { - user.sendBridgeState(BridgeState{StateEvent: StateTransientDisconnect, Error: WAPingError}) - } - } - user.Conn.CountTimeoutHook = func(wsKeepaliveErrorCount int) { - if wsKeepaliveErrorCount > 0 { - user.sendBridgeState(BridgeState{StateEvent: StateTransientDisconnect, Error: WAServerTimeout}) - } else { - user.sendBridgeState(BridgeState{StateEvent: StateTransientDisconnect, Error: WATimeout}) - } - } -} - func (bridge *Bridge) createBridgeStateRequest(ctx context.Context, state *BridgeState) (req *http.Request, err error) { var body bytes.Buffer if err = json.NewEncoder(&body).Encode(&state); err != nil { @@ -210,8 +179,6 @@ func (user *User) sendBridgeState(state BridgeState) { } } -var bridgeStatePingID uint32 = 0 - func (prov *ProvisioningAPI) BridgeStatePing(w http.ResponseWriter, r *http.Request) { if !prov.bridge.AS.CheckServerToken(w, r) { return @@ -221,37 +188,12 @@ func (prov *ProvisioningAPI) BridgeStatePing(w http.ResponseWriter, r *http.Requ var global BridgeState global.StateEvent = StateRunning var remote BridgeState - if user.Conn != nil { - if user.Conn.IsConnected() && user.Conn.IsLoggedIn() { - pingID := atomic.AddUint32(&bridgeStatePingID, 1) - user.log.Debugfln("Pinging WhatsApp mobile due to bridge status /ping API request (ID %d)", pingID) - err := user.Conn.AdminTestWithSuppress(true) - if errors.Is(r.Context().Err(), context.Canceled) { - user.log.Warnfln("Ping request %d was canceled before we responded (response was %v)", pingID, err) - user.prevBridgeStatus = nil - return - } - user.log.Debugfln("Ping %d response: %v", pingID, err) - remote.StateEvent = StateTransientDisconnect - if err == whatsapp.ErrPingFalse { - user.log.Debugln("Forwarding ping false error from provisioning API to HandleError") - go user.HandleError(err) - remote.Error = WAPingFalse - } else if errors.Is(err, whatsapp.ErrConnectionTimeout) { - remote.Error = WATimeout - } else if errors.Is(err, whatsapp.ErrWebsocketKeepaliveFailed) { - remote.Error = WAServerTimeout - } else if err != nil { - remote.Error = WAPingError - } else { - remote.StateEvent = StateConnected - } - } else if user.Conn.IsLoginInProgress() && user.Session != nil { + if user.Client != nil && user.Client.IsConnected() { + if user.Client.IsLoggedIn { + remote.StateEvent = StateConnected + } else if user.Session != nil { remote.StateEvent = StateConnecting remote.Error = WAConnecting - } else if !user.Conn.IsConnected() && user.Session != nil { - remote.StateEvent = StateBadCredentials - remote.Error = WANotConnected } // else: unconfigured } else if user.Session != nil { remote.StateEvent = StateBadCredentials diff --git a/commands.go b/commands.go index bef9bf7..244cab8 100644 --- a/commands.go +++ b/commands.go @@ -1,5 +1,5 @@ // mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge. -// Copyright (C) 2020 Tulir Asokan +// Copyright (C) 2021 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by @@ -20,12 +20,14 @@ import ( "context" "errors" "fmt" - "math" - "sort" "strconv" "strings" + "time" - "github.com/Rhymen/go-whatsapp" + "github.com/skip2/go-qrcode" + + "go.mau.fi/whatsmeow/types" + "go.mau.fi/whatsmeow/types/events" "maunium.net/go/maulogger/v2" @@ -34,8 +36,6 @@ import ( "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/format" "maunium.net/go/mautrix/id" - - "maunium.net/go/mautrix-whatsapp/database" ) type CommandHandler struct { @@ -119,8 +119,6 @@ func (handler *CommandHandler) CommandMux(ce *CommandEvent) { handler.CommandDisconnect(ce) case "ping": handler.CommandPing(ce) - case "delete-connection": - handler.CommandDeleteConnection(ce) case "delete-session": handler.CommandDeleteSession(ce) case "delete-portal": @@ -141,7 +139,7 @@ func (handler *CommandHandler) CommandMux(ce *CommandEvent) { if !ce.User.HasSession() { ce.Reply("You are not logged in. Use the `login` command to log into WhatsApp.") return - } else if !ce.User.IsConnected() { + } else if !ce.User.IsLoggedIn() { ce.Reply("You are not connected to WhatsApp. Use the `reconnect` command to reconnect.") return } @@ -149,8 +147,6 @@ func (handler *CommandHandler) CommandMux(ce *CommandEvent) { switch ce.Command { case "login-matrix": handler.CommandLoginMatrix(ce) - case "sync": - handler.CommandSync(ce) case "list": handler.CommandList(ce) case "open": @@ -226,12 +222,13 @@ func (handler *CommandHandler) CommandInviteLink(ce *CommandEvent) { return } - link, err := ce.User.Conn.GroupInviteLink(ce.Portal.Key.JID) - if err != nil { - ce.Reply("Failed to get invite link: %v", err) - return - } - ce.Reply("%s%s", inviteLinkPrefix, link) + // TODO reimplement + //link, err := ce.User.Conn.GroupInviteLink(ce.Portal.Key.JID) + //if err != nil { + // ce.Reply("Failed to get invite link: %v", err) + // return + //} + //ce.Reply("%s%s", inviteLinkPrefix, link) } const cmdJoinHelp = `join - Join a group chat with an invite link.` @@ -246,26 +243,27 @@ func (handler *CommandHandler) CommandJoin(ce *CommandEvent) { return } - jid, err := ce.User.Conn.GroupAcceptInviteCode(ce.Args[0][len(inviteLinkPrefix):]) - if err != nil { - ce.Reply("Failed to join group: %v", err) - return - } - - handler.log.Debugln("%s successfully joined group %s", ce.User.MXID, jid) - portal := handler.bridge.GetPortalByJID(database.GroupPortalKey(jid)) - if len(portal.MXID) > 0 { - portal.Sync(ce.User, whatsapp.Contact{JID: portal.Key.JID}) - ce.Reply("Successfully joined group \"%s\" and synced portal room: [%s](https://matrix.to/#/%s)", portal.Name, portal.Name, portal.MXID) - } else { - err = portal.CreateMatrixRoom(ce.User) - if err != nil { - ce.Reply("Failed to create portal room: %v", err) - return - } - - ce.Reply("Successfully joined group \"%s\" and created portal room: [%s](https://matrix.to/#/%s)", portal.Name, portal.Name, portal.MXID) - } + // TODO reimplement + //jid, err := ce.User.Conn.GroupAcceptInviteCode(ce.Args[0][len(inviteLinkPrefix):]) + //if err != nil { + // ce.Reply("Failed to join group: %v", err) + // return + //} + // + //handler.log.Debugln("%s successfully joined group %s", ce.User.MXID, jid) + //portal := handler.bridge.GetPortalByJID(database.GroupPortalKey(jid)) + //if len(portal.MXID) > 0 { + // portal.Sync(ce.User, whatsapp.Contact{JID: portal.Key.JID}) + // ce.Reply("Successfully joined group \"%s\" and synced portal room: [%s](https://matrix.to/#/%s)", portal.Name, portal.Name, portal.MXID) + //} else { + // err = portal.CreateMatrixRoom(ce.User) + // if err != nil { + // ce.Reply("Failed to create portal room: %v", err) + // return + // } + // + // ce.Reply("Successfully joined group \"%s\" and created portal room: [%s](https://matrix.to/#/%s)", portal.Name, portal.Name, portal.MXID) + //} } const cmdCreateHelp = `create - Create a group chat.` @@ -299,43 +297,44 @@ func (handler *CommandHandler) CommandCreate(ce *CommandEvent) { return } - participants := []string{ce.User.JID} + participants := []types.JID{ce.User.JID.ToNonAD()} for userID := range members.Joined { jid, ok := handler.bridge.ParsePuppetMXID(userID) - if ok && jid != ce.User.JID { + if ok && jid.User != ce.User.JID.User { participants = append(participants, jid) } } - resp, err := ce.User.Conn.CreateGroup(roomNameEvent.Name, participants) - if err != nil { - ce.Reply("Failed to create group: %v", err) - return - } - portal := handler.bridge.GetPortalByJID(database.GroupPortalKey(resp.GroupID)) - portal.roomCreateLock.Lock() - defer portal.roomCreateLock.Unlock() - if len(portal.MXID) != 0 { - portal.log.Warnln("Detected race condition in room creation") - // TODO race condition, clean up the old room - } - portal.MXID = ce.RoomID - portal.Name = roomNameEvent.Name - portal.Encrypted = encryptionEvent.Algorithm == id.AlgorithmMegolmV1 - if !portal.Encrypted && handler.bridge.Config.Bridge.Encryption.Default { - _, err = portal.MainIntent().SendStateEvent(portal.MXID, event.StateEncryption, "", &event.EncryptionEventContent{Algorithm: id.AlgorithmMegolmV1}) - if err != nil { - portal.log.Warnln("Failed to enable e2be:", err) - } - portal.Encrypted = true - } - - portal.Update() - portal.UpdateBridgeInfo() - - ce.Reply("Successfully created WhatsApp group %s", portal.Key.JID) - inCommunity := ce.User.addPortalToCommunity(portal) - ce.User.CreateUserPortal(database.PortalKeyWithMeta{PortalKey: portal.Key, InCommunity: inCommunity}) + // TODO reimplement + //resp, err := ce.User.Conn.CreateGroup(roomNameEvent.Name, participants) + //if err != nil { + // ce.Reply("Failed to create group: %v", err) + // return + //} + //portal := handler.bridge.GetPortalByJID(database.GroupPortalKey(resp.GroupID)) + //portal.roomCreateLock.Lock() + //defer portal.roomCreateLock.Unlock() + //if len(portal.MXID) != 0 { + // portal.log.Warnln("Detected race condition in room creation") + // // TODO race condition, clean up the old room + //} + //portal.MXID = ce.RoomID + //portal.Name = roomNameEvent.Name + //portal.Encrypted = encryptionEvent.Algorithm == id.AlgorithmMegolmV1 + //if !portal.Encrypted && handler.bridge.Config.Bridge.Encryption.Default { + // _, err = portal.MainIntent().SendStateEvent(portal.MXID, event.StateEncryption, "", &event.EncryptionEventContent{Algorithm: id.AlgorithmMegolmV1}) + // if err != nil { + // portal.log.Warnln("Failed to enable e2be:", err) + // } + // portal.Encrypted = true + //} + // + //portal.Update() + //portal.UpdateBridgeInfo() + // + //ce.Reply("Successfully created WhatsApp group %s", portal.Key.JID) + //inCommunity := ce.User.addPortalToCommunity(portal) + //ce.User.CreateUserPortal(database.PortalKeyWithMeta{PortalKey: portal.Key, InCommunity: inCommunity}) } const cmdSetPowerLevelHelp = `set-pl [user ID] - Change the power level in a portal room. Only for bridge admins.` @@ -382,11 +381,108 @@ const cmdLoginHelp = `login - Authenticate this Bridge as WhatsApp Web Client` // CommandLogin handles login command func (handler *CommandHandler) CommandLogin(ce *CommandEvent) { + if ce.User.Session != nil { + ce.Reply("You're already logged in") + return + } + qrChan := make(chan *events.QR, 1) + loginChan := make(chan *events.PairSuccess, 1) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go ce.User.loginQrChannel(ctx, ce, qrChan, cancel) + + ce.User.qrListener = qrChan + ce.User.loginListener = loginChan if !ce.User.Connect(true) { ce.User.log.Debugln("Connect() returned false, assuming error was logged elsewhere and canceling login.") return } - ce.User.Login(ce) + + select { + case success := <-loginChan: + ce.Reply("Successfully logged in as +%s", success.ID.User) + cancel() + case <-ctx.Done(): + ce.Reply("Login timed out") + } +} + +func (user *User) loginQrChannel(ctx context.Context, ce *CommandEvent, qrChan <-chan *events.QR, cancel func()) { + var qrEvt *events.QR + select { + case qrEvt = <-qrChan: + case <-ctx.Done(): + return + } + + bot := user.bridge.AS.BotClient() + + code := qrEvt.Codes[0] + qrEvt.Codes = qrEvt.Codes[1:] + url, ok := user.uploadQR(ce, code) + if !ok { + return + } + sendResp, err := bot.SendImage(ce.RoomID, code, url) + if err != nil { + user.log.Errorln("Failed to send QR code to user:", err) + return + } + qrEventID := sendResp.EventID + + for { + select { + case <-time.After(qrEvt.Timeout): + if len(qrEvt.Codes) == 0 { + cancel() + return + } + code, qrEvt.Codes = qrEvt.Codes[0], qrEvt.Codes[1:] + + url, ok = user.uploadQR(ce, code) + if !ok { + continue + } + _, err = bot.SendMessageEvent(ce.RoomID, event.EventMessage, &event.MessageEventContent{ + MsgType: event.MsgImage, + Body: code, + URL: url.CUString(), + NewContent: &event.MessageEventContent{ + MsgType: event.MsgImage, + Body: code, + URL: url.CUString(), + }, + RelatesTo: &event.RelatesTo{ + Type: event.RelReplace, + EventID: qrEventID, + }, + }) + if err != nil { + user.log.Errorln("Failed to send edited QR code to user:", err) + } + case <-ctx.Done(): + return + } + } +} + +func (user *User) uploadQR(ce *CommandEvent, code string) (id.ContentURI, bool) { + qrCode, err := qrcode.Encode(code, qrcode.Low, 256) + if err != nil { + user.log.Errorln("Failed to encode QR code:", err) + ce.Reply("Failed to encode QR code: %v", err) + return id.ContentURI{}, false + } + + bot := user.bridge.AS.BotClient() + + resp, err := bot.UploadBytes(qrCode, "image/png") + if err != nil { + user.log.Errorln("Failed to upload QR code:", err) + ce.Reply("Failed to upload QR code: %v", err) + return id.ContentURI{}, false + } + return resp.ContentURI, true } const cmdLogoutHelp = `logout - Logout from WhatsApp` @@ -396,7 +492,7 @@ func (handler *CommandHandler) CommandLogout(ce *CommandEvent) { if ce.User.Session == nil { ce.Reply("You're not logged in.") return - } else if !ce.User.IsConnected() { + } else if !ce.User.IsLoggedIn() { ce.Reply("You are not connected to WhatsApp. Use the `reconnect` command to reconnect, or `delete-session` to forget all login information.") return } @@ -407,17 +503,16 @@ func (handler *CommandHandler) CommandLogout(ce *CommandEvent) { ce.User.log.Warnln("Failed to logout-matrix while logging out of WhatsApp:", err) } } - err := ce.User.Conn.Logout() - if err != nil { - ce.User.log.Warnln("Error while logging out:", err) - ce.Reply("Unknown error while logging out: %v", err) - return - } + // TODO reimplement + //err := ce.User.Client.Logout() + //if err != nil { + // ce.User.log.Warnln("Error while logging out:", err) + // ce.Reply("Unknown error while logging out: %v", err) + // return + //} ce.User.removeFromJIDMap(StateLoggedOut) - // TODO this causes a foreign key violation, which should be fixed - //ce.User.JID = "" - ce.User.SetSession(nil) ce.User.DeleteConnection() + ce.User.DeleteSession() ce.Reply("Logged out successfully.") } @@ -438,21 +533,21 @@ func (handler *CommandHandler) CommandToggle(ce *CommandEvent) { return } if ce.Args[0] == "presence" || ce.Args[0] == "all" { - customPuppet.EnablePresence = !customPuppet.EnablePresence - var newPresence whatsapp.Presence - if customPuppet.EnablePresence { - newPresence = whatsapp.PresenceAvailable - ce.Reply("Enabled presence bridging") - } else { - newPresence = whatsapp.PresenceUnavailable - ce.Reply("Disabled presence bridging") - } - if ce.User.IsConnected() { - _, err := ce.User.Conn.Presence("", newPresence) - if err != nil { - ce.User.log.Warnln("Failed to set presence:", err) - } - } + //customPuppet.EnablePresence = !customPuppet.EnablePresence + //var newPresence whatsapp.Presence + //if customPuppet.EnablePresence { + // newPresence = whatsapp.PresenceAvailable + // ce.Reply("Enabled presence bridging") + //} else { + // newPresence = whatsapp.PresenceUnavailable + // ce.Reply("Disabled presence bridging") + //} + //if ce.User.IsConnected() { + // _, err := ce.User.Conn.Presence("", newPresence) + // if err != nil { + // ce.User.log.Warnln("Failed to set presence:", err) + // } + //} } if ce.Args[0] == "receipts" || ce.Args[0] == "all" { customPuppet.EnableReceipts = !customPuppet.EnableReceipts @@ -468,108 +563,82 @@ func (handler *CommandHandler) CommandToggle(ce *CommandEvent) { const cmdDeleteSessionHelp = `delete-session - Delete session information and disconnect from WhatsApp without sending a logout request` func (handler *CommandHandler) CommandDeleteSession(ce *CommandEvent) { - if ce.User.Session == nil && ce.User.Conn == nil { + if ce.User.Session == nil && ce.User.Client == nil { ce.Reply("Nothing to purge: no session information stored and no active connection.") return } - //ce.User.JID = "" ce.User.removeFromJIDMap(StateLoggedOut) - ce.User.SetSession(nil) ce.User.DeleteConnection() + ce.User.DeleteSession() ce.Reply("Session information purged") } const cmdReconnectHelp = `reconnect - Reconnect to WhatsApp` func (handler *CommandHandler) CommandReconnect(ce *CommandEvent) { - if ce.User.Conn == nil { - if ce.User.Session == nil { - ce.Reply("No existing connection and no session. Did you mean `login`?") - } else { - ce.Reply("No existing connection, creating one...") - ce.User.Connect(false) - } - return - } - - wasConnected := true - err := ce.User.Conn.Disconnect() - if err == whatsapp.ErrNotConnected { - wasConnected = false - } else if err != nil { - ce.User.log.Warnln("Error while disconnecting:", err) - } - - ctx := context.Background() - - err = ce.User.Conn.Restore(true, ctx) - if err == whatsapp.ErrInvalidSession { - if ce.User.Session != nil { - ce.User.log.Debugln("Got invalid session error when reconnecting, but user has session. Retrying using RestoreWithSession()...") - ce.User.Conn.SetSession(*ce.User.Session) - err = ce.User.Conn.Restore(true, ctx) - } else { - ce.Reply("You are not logged in.") - return - } - } else if err == whatsapp.ErrLoginInProgress { - ce.Reply("A login or reconnection is already in progress.") - return - } else if err == whatsapp.ErrAlreadyLoggedIn { - ce.Reply("You were already connected.") - return - } - if err != nil { - ce.User.log.Warnln("Error while reconnecting:", err) - ce.Reply("Unknown error while reconnecting: %v", err) - ce.User.log.Debugln("Disconnecting due to failed session restore in reconnect command...") - err = ce.User.Conn.Disconnect() - if err != nil { - ce.User.log.Errorln("Failed to disconnect after failed session restore in reconnect command:", err) - } - return - } - ce.User.ConnectionErrors = 0 - - var msg string - if wasConnected { - msg = "Reconnected successfully." - } else { - msg = "Connected successfully." - } - ce.Reply(msg) - ce.User.PostLogin() -} - -const cmdDeleteConnectionHelp = `delete-connection - Disconnect ignoring errors and delete internal connection state.` - -func (handler *CommandHandler) CommandDeleteConnection(ce *CommandEvent) { - if ce.User.Conn == nil { - ce.Reply("You don't have a WhatsApp connection.") - return - } - ce.User.DeleteConnection() - ce.Reply("Successfully disconnected. Use the `reconnect` command to reconnect.") + // TODO reimplement + //if ce.User.Client == nil { + // if ce.User.Session == nil { + // ce.Reply("No existing connection and no session. Did you mean `login`?") + // } else { + // ce.Reply("No existing connection, creating one...") + // ce.User.Connect(false) + // } + // return + //} + // + //wasConnected := true + //ce.User.Client.Disconnect() + //ctx := context.Background() + //connected := ce.User.Connect(false) + // + //err = ce.User.Conn.Restore(true, ctx) + //if err == whatsapp.ErrInvalidSession { + // if ce.User.Session != nil { + // ce.User.log.Debugln("Got invalid session error when reconnecting, but user has session. Retrying using RestoreWithSession()...") + // ce.User.Conn.SetSession(*ce.User.Session) + // err = ce.User.Conn.Restore(true, ctx) + // } else { + // ce.Reply("You are not logged in.") + // return + // } + //} else if err == whatsapp.ErrLoginInProgress { + // ce.Reply("A login or reconnection is already in progress.") + // return + //} else if err == whatsapp.ErrAlreadyLoggedIn { + // ce.Reply("You were already connected.") + // return + //} + //if err != nil { + // ce.User.log.Warnln("Error while reconnecting:", err) + // ce.Reply("Unknown error while reconnecting: %v", err) + // ce.User.log.Debugln("Disconnecting due to failed session restore in reconnect command...") + // err = ce.User.Conn.Disconnect() + // if err != nil { + // ce.User.log.Errorln("Failed to disconnect after failed session restore in reconnect command:", err) + // } + // return + //} + //ce.User.ConnectionErrors = 0 + // + //var msg string + //if wasConnected { + // msg = "Reconnected successfully." + //} else { + // msg = "Connected successfully." + //} + //ce.Reply(msg) + //ce.User.PostLogin() } const cmdDisconnectHelp = `disconnect - Disconnect from WhatsApp (without logging out)` func (handler *CommandHandler) CommandDisconnect(ce *CommandEvent) { - if ce.User.Conn == nil { + if ce.User.Client == nil { ce.Reply("You don't have a WhatsApp connection.") return } - err := ce.User.Conn.Disconnect() - if err == whatsapp.ErrNotConnected { - ce.Reply("You were not connected.") - return - } else if err != nil { - ce.User.log.Warnln("Error while disconnecting:", err) - ce.Reply("Unknown error while disconnecting: %v", err) - return - } - ce.User.bridge.Metrics.TrackConnectionState(ce.User.JID, false) - ce.User.sendBridgeState(BridgeState{StateEvent: StateBadCredentials, Error: WANotConnected}) + ce.User.DeleteConnection() ce.Reply("Successfully disconnected. Use the `reconnect` command to reconnect.") } @@ -577,21 +646,11 @@ const cmdPingHelp = `ping - Check your connection to WhatsApp.` func (handler *CommandHandler) CommandPing(ce *CommandEvent) { if ce.User.Session == nil { - if ce.User.IsLoginInProgress() { - ce.Reply("You're not logged into WhatsApp, but there's a login in progress.") - } else { - ce.Reply("You're not logged into WhatsApp.") - } - } else if ce.User.Conn == nil { + ce.Reply("You're not logged into WhatsApp.") + } else if ce.User.Client == nil || !ce.User.Client.IsConnected() { ce.Reply("You don't have a WhatsApp connection.") - } else if err := ce.User.Conn.AdminTest(); err != nil { - if ce.User.IsLoginInProgress() { - ce.Reply("Connection not OK: %v, but login in progress", err) - } else { - ce.Reply("Connection not OK: %v", err) - } } else { - ce.Reply("Connection to WhatsApp OK") + ce.Reply("Connection to WhatsApp OK (probably)") } } @@ -612,12 +671,10 @@ func (handler *CommandHandler) CommandHelp(ce *CommandEvent) { cmdPrefix + cmdDeleteSessionHelp, cmdPrefix + cmdReconnectHelp, cmdPrefix + cmdDisconnectHelp, - cmdPrefix + cmdDeleteConnectionHelp, cmdPrefix + cmdPingHelp, cmdPrefix + cmdLoginMatrixHelp, cmdPrefix + cmdLogoutMatrixHelp, cmdPrefix + cmdToggleHelp, - cmdPrefix + cmdSyncHelp, cmdPrefix + cmdListHelp, cmdPrefix + cmdOpenHelp, cmdPrefix + cmdPMHelp, @@ -630,37 +687,6 @@ func (handler *CommandHandler) CommandHelp(ce *CommandEvent) { }, "\n* ")) } -const cmdSyncHelp = `sync [--create-all] - Synchronize contacts from phone and optionally create portals for group chats.` - -// CommandSync handles sync command -func (handler *CommandHandler) CommandSync(ce *CommandEvent) { - user := ce.User - create := len(ce.Args) > 0 && ce.Args[0] == "--create-all" - - ce.Reply("Updating contact and chat list...") - handler.log.Debugln("Importing contacts of", user.MXID) - _, err := user.Conn.Contacts() - if err != nil { - user.log.Errorln("Error updating contacts:", err) - ce.Reply("Failed to sync contact list (see logs for details)") - return - } - handler.log.Debugln("Importing chats of", user.MXID) - _, err = user.Conn.Chats() - if err != nil { - user.log.Errorln("Error updating chats:", err) - ce.Reply("Failed to sync chat list (see logs for details)") - return - } - - ce.Reply("Syncing contacts...") - user.syncPuppets(nil) - ce.Reply("Syncing chats...") - user.syncPortals(nil, create) - - ce.Reply("Sync complete.") -} - const cmdDeletePortalHelp = `delete-portal - Delete the current portal. If the portal is used by other people, this is limited to bridge admins.` func (handler *CommandHandler) CommandDeletePortal(ce *CommandEvent) { @@ -670,11 +696,13 @@ func (handler *CommandHandler) CommandDeletePortal(ce *CommandEvent) { } if !ce.User.Admin { - users := ce.Portal.GetUserIDs() - if len(users) > 1 || (len(users) == 1 && users[0] != ce.User.MXID) { - ce.Reply("Only bridge admins can delete portals with other Matrix users") - return - } + // TODO reimplement + //users := ce.Portal.GetUserIDs() + //if len(users) > 1 || (len(users) == 1 && users[0] != ce.User.MXID) { + // ce.Reply("Only bridge admins can delete portals with other Matrix users") + // return + //} + return } ce.Portal.log.Infoln(ce.User.MXID, "requested deletion of portal.") @@ -687,12 +715,13 @@ const cmdDeleteAllPortalsHelp = `delete-all-portals - Delete all your portals th func (handler *CommandHandler) CommandDeleteAllPortals(ce *CommandEvent) { portals := ce.User.GetPortals() portalsToDelete := make([]*Portal, 0, len(portals)) - for _, portal := range portals { - users := portal.GetUserIDs() - if len(users) == 1 && users[0] == ce.User.MXID { - portalsToDelete = append(portalsToDelete, portal) - } - } + // TODO reimplement + //for _, portal := range portals { + // users := portal.GetUserIDs() + // if len(users) == 1 && users[0] == ce.User.MXID { + // portalsToDelete = append(portalsToDelete, portal) + // } + //} leave := func(portal *Portal) { if len(portal.MXID) > 0 { _, _ = portal.MainIntent().KickUser(portal.MXID, &mautrix.ReqKickUser{ @@ -729,21 +758,21 @@ func (handler *CommandHandler) CommandDeleteAllPortals(ce *CommandEvent) { const cmdListHelp = `list [page] [items per page] - Get a list of all contacts and groups.` -func formatContacts(contacts bool, input map[string]whatsapp.Contact) (result []string) { - for jid, contact := range input { - if strings.HasSuffix(jid, whatsapp.NewUserSuffix) != contacts { - continue - } - - if contacts { - result = append(result, fmt.Sprintf("* %s / %s - `%s`", contact.Name, contact.Notify, contact.JID[:len(contact.JID)-len(whatsapp.NewUserSuffix)])) - } else { - result = append(result, fmt.Sprintf("* %s - `%s`", contact.Name, contact.JID)) - } - } - sort.Sort(sort.StringSlice(result)) - return -} +//func formatContacts(contacts bool, input map[string]whatsapp.Contact) (result []string) { +// for jid, contact := range input { +// if strings.HasSuffix(jid, whatsapp.NewUserSuffix) != contacts { +// continue +// } +// +// if contacts { +// result = append(result, fmt.Sprintf("* %s / %s - `%s`", contact.Name, contact.Notify, contact.JID[:len(contact.JID)-len(whatsapp.NewUserSuffix)])) +// } else { +// result = append(result, fmt.Sprintf("* %s - `%s`", contact.Name, contact.JID)) +// } +// } +// sort.Sort(sort.StringSlice(result)) +// return +//} func (handler *CommandHandler) CommandList(ce *CommandEvent) { if len(ce.Args) == 0 { @@ -774,33 +803,34 @@ func (handler *CommandHandler) CommandList(ce *CommandEvent) { ce.Reply("Warning: a high number of items per page may fail to send a reply") } } - contacts := mode[0] == 'c' - typeName := "Groups" - if contacts { - typeName = "Contacts" - } - ce.User.Conn.Store.ContactsLock.RLock() - result := formatContacts(contacts, ce.User.Conn.Store.Contacts) - ce.User.Conn.Store.ContactsLock.RUnlock() - if len(result) == 0 { - ce.Reply("No %s found", strings.ToLower(typeName)) - return - } - pages := int(math.Ceil(float64(len(result)) / float64(max))) - if (page-1)*max >= len(result) { - if pages == 1 { - ce.Reply("There is only 1 page of %s", strings.ToLower(typeName)) - } else { - ce.Reply("There are only %d pages of %s", pages, strings.ToLower(typeName)) - } - return - } - lastIndex := page * max - if lastIndex > len(result) { - lastIndex = len(result) - } - result = result[(page-1)*max : lastIndex] - ce.Reply("### %s (page %d of %d)\n\n%s", typeName, page, pages, strings.Join(result, "\n")) + // TODO reimplement + //contacts := mode[0] == 'c' + //typeName := "Groups" + //if contacts { + // typeName = "Contacts" + //} + //ce.User.Conn.Store.ContactsLock.RLock() + //result := formatContacts(contacts, ce.User.Conn.Store.Contacts) + //ce.User.Conn.Store.ContactsLock.RUnlock() + //if len(result) == 0 { + // ce.Reply("No %s found", strings.ToLower(typeName)) + // return + //} + //pages := int(math.Ceil(float64(len(result)) / float64(max))) + //if (page-1)*max >= len(result) { + // if pages == 1 { + // ce.Reply("There is only 1 page of %s", strings.ToLower(typeName)) + // } else { + // ce.Reply("There are only %d pages of %s", pages, strings.ToLower(typeName)) + // } + // return + //} + //lastIndex := page * max + //if lastIndex > len(result) { + // lastIndex = len(result) + //} + //result = result[(page-1)*max : lastIndex] + //ce.Reply("### %s (page %d of %d)\n\n%s", typeName, page, pages, strings.Join(result, "\n")) } const cmdOpenHelp = `open <_group JID_> - Open a group chat portal.` @@ -811,80 +841,68 @@ func (handler *CommandHandler) CommandOpen(ce *CommandEvent) { return } - user := ce.User - jid := ce.Args[0] - - if strings.HasSuffix(jid, whatsapp.NewUserSuffix) { - ce.Reply("That looks like a user JID. Did you mean `pm %s`?", jid[:len(jid)-len(whatsapp.NewUserSuffix)]) - return - } - - user.Conn.Store.ContactsLock.RLock() - contact, ok := user.Conn.Store.Contacts[jid] - user.Conn.Store.ContactsLock.RUnlock() - if !ok { - ce.Reply("Group JID not found in contacts. Try syncing contacts with `sync` first.") - return - } - handler.log.Debugln("Importing", jid, "for", user) - portal := user.bridge.GetPortalByJID(database.GroupPortalKey(jid)) - if len(portal.MXID) > 0 { - portal.Sync(user, contact) - ce.Reply("Portal room synced.") - } else { - portal.Sync(user, contact) - ce.Reply("Portal room created.") - } - _, _ = portal.MainIntent().InviteUser(portal.MXID, &mautrix.ReqInviteUser{UserID: user.MXID}) + // TODO reimplement + //user := ce.User + //jid := ce.Args[0] + //if strings.HasSuffix(jid, whatsapp.NewUserSuffix) { + // ce.Reply("That looks like a user JID. Did you mean `pm %s`?", jid[:len(jid)-len(whatsapp.NewUserSuffix)]) + // return + //} + // + //user.Conn.Store.ContactsLock.RLock() + //contact, ok := user.Conn.Store.Contacts[jid] + //user.Conn.Store.ContactsLock.RUnlock() + //if !ok { + // ce.Reply("Group JID not found in contacts. Try syncing contacts with `sync` first.") + // return + //} + //handler.log.Debugln("Importing", jid, "for", user) + //portal := user.bridge.GetPortalByJID(database.GroupPortalKey(jid)) + //if len(portal.MXID) > 0 { + // portal.Sync(user, contact) + // ce.Reply("Portal room synced.") + //} else { + // portal.Sync(user, contact) + // ce.Reply("Portal room created.") + //} + //_, _ = portal.MainIntent().InviteUser(portal.MXID, &mautrix.ReqInviteUser{UserID: user.MXID}) } -const cmdPMHelp = `pm [--force] <_international phone number_> - Open a private chat with the given phone number.` +const cmdPMHelp = `pm <_international phone number_> - Open a private chat with the given phone number.` func (handler *CommandHandler) CommandPM(ce *CommandEvent) { if len(ce.Args) == 0 { - ce.Reply("**Usage:** `pm [--force] `") + ce.Reply("**Usage:** `pm `") return } - force := ce.Args[0] == "--force" - if force { - ce.Args = ce.Args[1:] - } - user := ce.User number := strings.Join(ce.Args, "") - if number[0] == '+' { - number = number[1:] + resp, err := ce.User.Client.IsOnWhatsApp([]string{number}) + if err != nil { + ce.Reply("Failed to check if user is on WhatsApp: %v", err) + return + } else if len(resp) == 0 { + ce.Reply("Didn't get a response to checking if the user is on WhatsApp") + return } - for _, char := range number { - if char < '0' || char > '9' { - ce.Reply("Invalid phone number.") - return - } + targetUser := resp[0] + if !targetUser.IsIn { + ce.Reply("The server said +%s is not on WhatsApp", targetUser.JID.User) + return } - jid := number + whatsapp.NewUserSuffix - handler.log.Debugln("Importing", jid, "for", user) - - user.Conn.Store.ContactsLock.RLock() - contact, ok := user.Conn.Store.Contacts[jid] - user.Conn.Store.ContactsLock.RUnlock() - if !ok { - if !force { - ce.Reply("Phone number not found in contacts. Try syncing contacts with `sync` first. " + - "To create a portal anyway, use `pm --force `.") - return - } - contact = whatsapp.Contact{JID: jid} - } - puppet := user.bridge.GetPuppetByJID(contact.JID) - puppet.Sync(user, contact) - portal := user.bridge.GetPortalByJID(database.NewPortalKey(contact.JID, user.JID)) + handler.log.Debugln("Importing", targetUser.JID, "for", user) + puppet := user.bridge.GetPuppetByJID(targetUser.JID) + puppet.SyncContact(user, true) + portal := user.GetPortalByJID(puppet.JID) if len(portal.MXID) > 0 { - var err error if !user.IsRelaybot { - err = portal.MainIntent().EnsureInvited(portal.MXID, user.MXID) + _, err = portal.MainIntent().Client.InviteUser(portal.MXID, &mautrix.ReqInviteUser{UserID: user.MXID}) + if httpErr, ok := err.(mautrix.HTTPError); ok && httpErr.RespError != nil && strings.Contains(httpErr.RespError.Err, "is already in the room") { + err = nil + } } if err != nil { portal.log.Warnfln("Failed to invite %s to portal: %v. Creating new portal", user.MXID, err) @@ -894,7 +912,7 @@ func (handler *CommandHandler) CommandPM(ce *CommandEvent) { return } } - err := portal.CreateMatrixRoom(user) + err = portal.CreateMatrixRoom(user) if err != nil { ce.Reply("Failed to create portal room: %v", err) return diff --git a/community.go b/community.go deleted file mode 100644 index 6ed97ee..0000000 --- a/community.go +++ /dev/null @@ -1,132 +0,0 @@ -// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge. -// Copyright (C) 2020 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package main - -import ( - "fmt" - "net/http" - - "maunium.net/go/mautrix" -) - -func (user *User) inviteToCommunity() { - url := user.bridge.Bot.BuildURL("groups", user.CommunityID, "admin", "users", "invite", user.MXID) - reqBody := map[string]interface{}{} - _, err := user.bridge.Bot.MakeRequest(http.MethodPut, url, &reqBody, nil) - if err != nil { - user.log.Warnfln("Failed to invite user to personal filtering community %s: %v", user.CommunityID, err) - } -} - -func (user *User) updateCommunityProfile() { - url := user.bridge.Bot.BuildURL("groups", user.CommunityID, "profile") - profileReq := struct { - Name string `json:"name"` - AvatarURL string `json:"avatar_url"` - ShortDescription string `json:"short_description"` - }{"WhatsApp", user.bridge.Config.AppService.Bot.Avatar, "Your WhatsApp bridged chats"} - _, err := user.bridge.Bot.MakeRequest(http.MethodPost, url, &profileReq, nil) - if err != nil { - user.log.Warnfln("Failed to update metadata of %s: %v", user.CommunityID, err) - } -} - -func (user *User) createCommunity() { - if user.IsRelaybot || !user.bridge.Config.Bridge.EnableCommunities() { - return - } - - localpart, server, _ := user.MXID.Parse() - community := user.bridge.Config.Bridge.FormatCommunity(localpart, server) - user.log.Debugln("Creating personal filtering community", community) - bot := user.bridge.Bot - req := struct { - Localpart string `json:"localpart"` - }{community} - resp := struct { - GroupID string `json:"group_id"` - }{} - _, err := bot.MakeRequest(http.MethodPost, bot.BuildURL("create_group"), &req, &resp) - if err != nil { - if httpErr, ok := err.(mautrix.HTTPError); ok { - if httpErr.RespError.Err != "Group already exists" { - user.log.Warnln("Server responded with error creating personal filtering community:", err) - return - } else { - user.log.Debugln("Personal filtering community", resp.GroupID, "already existed") - user.CommunityID = fmt.Sprintf("+%s:%s", req.Localpart, user.bridge.Config.Homeserver.Domain) - } - } else { - user.log.Warnln("Unknown error creating personal filtering community:", err) - return - } - } else { - user.log.Infoln("Created personal filtering community %s", resp.GroupID) - user.CommunityID = resp.GroupID - user.inviteToCommunity() - user.updateCommunityProfile() - } -} - -func (user *User) addPuppetToCommunity(puppet *Puppet) bool { - if user.IsRelaybot || len(user.CommunityID) == 0 { - return false - } - bot := user.bridge.Bot - url := bot.BuildURL("groups", user.CommunityID, "admin", "users", "invite", puppet.MXID) - blankReqBody := map[string]interface{}{} - _, err := bot.MakeRequest(http.MethodPut, url, &blankReqBody, nil) - if err != nil { - user.log.Warnfln("Failed to invite %s to %s: %v", puppet.MXID, user.CommunityID, err) - return false - } - reqBody := map[string]map[string]string{ - "m.visibility": { - "type": "private", - }, - } - url = bot.BuildURLWithQuery(mautrix.URLPath{"groups", user.CommunityID, "self", "accept_invite"}, map[string]string{ - "user_id": puppet.MXID.String(), - }) - _, err = bot.MakeRequest(http.MethodPut, url, &reqBody, nil) - if err != nil { - user.log.Warnfln("Failed to join %s as %s: %v", user.CommunityID, puppet.MXID, err) - return false - } - user.log.Debugln("Added", puppet.MXID, "to", user.CommunityID) - return true -} - -func (user *User) addPortalToCommunity(portal *Portal) bool { - if user.IsRelaybot || len(user.CommunityID) == 0 || len(portal.MXID) == 0 { - return false - } - bot := user.bridge.Bot - url := bot.BuildURL("groups", user.CommunityID, "admin", "rooms", portal.MXID) - reqBody := map[string]map[string]string{ - "m.visibility": { - "type": "private", - }, - } - _, err := bot.MakeRequest(http.MethodPut, url, &reqBody, nil) - if err != nil { - user.log.Warnfln("Failed to add %s to %s: %v", portal.MXID, user.CommunityID, err) - return false - } - user.log.Debugln("Added", portal.MXID, "to", user.CommunityID) - return true -} diff --git a/config/bridge.go b/config/bridge.go index 5790c6e..54a22f6 100644 --- a/config/bridge.go +++ b/config/bridge.go @@ -1,5 +1,5 @@ // mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge. -// Copyright (C) 2020 Tulir Asokan +// Copyright (C) 2021 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by @@ -17,12 +17,11 @@ package config import ( - "bytes" "strconv" "strings" "text/template" - "github.com/Rhymen/go-whatsapp" + "go.mau.fi/whatsmeow/types" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" @@ -31,7 +30,6 @@ import ( type BridgeConfig struct { UsernameTemplate string `yaml:"username_template"` DisplaynameTemplate string `yaml:"displayname_template"` - CommunityTemplate string `yaml:"community_template"` ConnectionTimeout int `yaml:"connection_timeout"` FetchMessageOnTimeout bool `yaml:"fetch_message_on_timeout"` @@ -100,7 +98,6 @@ type BridgeConfig struct { usernameTemplate *template.Template `yaml:"-"` displaynameTemplate *template.Template `yaml:"-"` - communityTemplate *template.Template `yaml:"-"` } func (bc *BridgeConfig) setDefaults() { @@ -156,13 +153,6 @@ func (bc *BridgeConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { return err } - if len(bc.CommunityTemplate) > 0 { - bc.communityTemplate, err = template.New("community").Parse(bc.CommunityTemplate) - if err != nil { - return err - } - } - return nil } @@ -170,44 +160,43 @@ type UsernameTemplateArgs struct { UserID id.UserID } -func (bc BridgeConfig) FormatDisplayname(contact whatsapp.Contact) (string, int8) { - var buf bytes.Buffer - if index := strings.IndexRune(contact.JID, '@'); index > 0 { - contact.JID = "+" + contact.JID[:index] - } - bc.displaynameTemplate.Execute(&buf, contact) +type legacyContactInfo struct { + types.ContactInfo + Phone string + + Notify string + VName string + Name string + Short string + JID string +} + +func (bc BridgeConfig) FormatDisplayname(jid types.JID, contact types.ContactInfo) (string, int8) { + var buf strings.Builder + _ = bc.displaynameTemplate.Execute(&buf, legacyContactInfo{ + ContactInfo: contact, + Notify: contact.PushName, + VName: contact.BusinessName, + Name: contact.FullName, + Short: contact.FirstName, + Phone: "+" + jid.User, + JID: "+" + jid.User, + }) var quality int8 switch { - case len(contact.Notify) > 0 || len(contact.VName) > 0: + case len(contact.PushName) > 0 || len(contact.BusinessName) > 0: quality = 3 - case len(contact.Name) > 0 || len(contact.Short) > 0: + case len(contact.FullName) > 0 || len(contact.FirstName) > 0: quality = 2 - case len(contact.JID) > 0: - quality = 1 default: - quality = 0 + quality = 1 } return buf.String(), quality } -func (bc BridgeConfig) FormatUsername(userID whatsapp.JID) string { - var buf bytes.Buffer - bc.usernameTemplate.Execute(&buf, userID) - return buf.String() -} - -type CommunityTemplateArgs struct { - Localpart string - Server string -} - -func (bc BridgeConfig) EnableCommunities() bool { - return bc.communityTemplate != nil -} - -func (bc BridgeConfig) FormatCommunity(localpart, server string) string { - var buf bytes.Buffer - bc.communityTemplate.Execute(&buf, CommunityTemplateArgs{localpart, server}) +func (bc BridgeConfig) FormatUsername(username string) string { + var buf strings.Builder + _ = bc.usernameTemplate.Execute(&buf, username) return buf.String() } diff --git a/crypto.go b/crypto.go index 4e41266..bf1ed4d 100644 --- a/crypto.go +++ b/crypto.go @@ -100,7 +100,8 @@ func (helper *CryptoHelper) allowKeyShare(device *crypto.DeviceIdentity, info ev return &crypto.KeyShareRejection{Code: event.RoomKeyWithheldUnavailable, Reason: "Requested room is not a portal room"} } user := helper.bridge.GetUserByMXID(device.UserID) - if !user.Admin && !user.IsInPortal(portal.Key) { + // FIXME reimplement IsInPortal + if !user.Admin /*&& !user.IsInPortal(portal.Key)*/ { helper.log.Debugfln("Rejecting key request for %s from %s/%s: user is not in portal", info.SessionID, device.UserID, device.DeviceID) return &crypto.KeyShareRejection{Code: event.RoomKeyWithheldUnauthorized, Reason: "You're not in that portal"} } diff --git a/custompuppet.go b/custompuppet.go index 286cad7..753e2ca 100644 --- a/custompuppet.go +++ b/custompuppet.go @@ -23,7 +23,7 @@ import ( "errors" "time" - "github.com/Rhymen/go-whatsapp" + "go.mau.fi/whatsmeow/types" "maunium.net/go/mautrix" "maunium.net/go/mautrix/appservice" @@ -160,7 +160,7 @@ func (puppet *Puppet) stopSyncing() { } func (puppet *Puppet) ProcessResponse(resp *mautrix.RespSync, _ string) error { - if !puppet.customUser.IsConnected() { + if !puppet.customUser.IsLoggedIn() { puppet.log.Debugln("Skipping sync processing: custom user not connected to whatsapp") return nil } @@ -200,14 +200,14 @@ func (puppet *Puppet) ProcessResponse(resp *mautrix.RespSync, _ string) error { } func (puppet *Puppet) handlePresenceEvent(event *event.Event) { - presence := whatsapp.PresenceAvailable + presence := types.PresenceAvailable if event.Content.Raw["presence"].(string) != "online" { - presence = whatsapp.PresenceUnavailable + presence = types.PresenceUnavailable puppet.customUser.log.Debugln("Marking offline") } else { puppet.customUser.log.Debugln("Marking online") } - _, err := puppet.customUser.Conn.Presence("", presence) + err := puppet.customUser.Client.SendPresence(presence) if err != nil { puppet.customUser.log.Warnln("Failed to set presence:", err) } @@ -221,11 +221,12 @@ func (puppet *Puppet) handleReceiptEvent(portal *Portal, event *event.Event) { puppet.customUser.log.Debugfln("Ignoring double puppeted read receipt %+v", event.Content.Raw) // Ignore double puppeted read receipts. } else if message := puppet.bridge.DB.Message.GetByMXID(eventID); message != nil { - puppet.customUser.log.Debugfln("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) - } + // TODO reimplement + //puppet.customUser.log.Debugfln("Marking %s/%s in %s/%s as read", message.JID, message.MXID, portal.Key.JID, portal.MXID) + //_, err := puppet.customUser.Client.Read(portal.Key.JID, message.JID) + //if err != nil { + // puppet.customUser.log.Warnln("Error marking read:", err) + //} } } } @@ -240,14 +241,14 @@ func (puppet *Puppet) handleTypingEvent(portal *Portal, evt *event.Event) { } if puppet.customTypingIn[evt.RoomID] != isTyping { puppet.customTypingIn[evt.RoomID] = isTyping - presence := whatsapp.PresenceComposing + presence := types.ChatPresenceComposing if !isTyping { puppet.customUser.log.Debugfln("Marking not typing in %s/%s", portal.Key.JID, portal.MXID) - presence = whatsapp.PresencePaused + presence = types.ChatPresencePaused } else { puppet.customUser.log.Debugfln("Marking typing in %s/%s", portal.Key.JID, portal.MXID) } - _, err := puppet.customUser.Conn.Presence(portal.Key.JID, presence) + err := puppet.customUser.Client.SendChatPresence(presence, portal.Key.JID) if err != nil { puppet.customUser.log.Warnln("Error setting typing:", err) } diff --git a/database/message.go b/database/message.go index 83ca81a..99c7fe6 100644 --- a/database/message.go +++ b/database/message.go @@ -1,5 +1,5 @@ // mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge. -// Copyright (C) 2020 Tulir Asokan +// Copyright (C) 2021 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by @@ -21,11 +21,10 @@ import ( "strings" "time" - "github.com/Rhymen/go-whatsapp" - log "maunium.net/go/maulogger/v2" - "maunium.net/go/mautrix/id" + + "go.mau.fi/whatsmeow/types" ) type MessageQuery struct { @@ -52,7 +51,7 @@ func (mq *MessageQuery) GetAll(chat PortalKey) (messages []*Message) { return } -func (mq *MessageQuery) GetByJID(chat PortalKey, jid whatsapp.MessageID) *Message { +func (mq *MessageQuery) GetByJID(chat PortalKey, jid types.MessageID) *Message { return mq.get("SELECT chat_jid, chat_receiver, jid, mxid, sender, timestamp, sent "+ "FROM message WHERE chat_jid=$1 AND chat_receiver=$2 AND jid=$3", chat.JID, chat.Receiver, jid) } @@ -90,9 +89,9 @@ type Message struct { log log.Logger Chat PortalKey - JID whatsapp.MessageID + JID types.MessageID MXID id.EventID - Sender whatsapp.JID + Sender types.JID Timestamp int64 Sent bool } diff --git a/database/portal.go b/database/portal.go index 3f26fac..74792fd 100644 --- a/database/portal.go +++ b/database/portal.go @@ -1,5 +1,5 @@ // mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge. -// Copyright (C) 2020 Tulir Asokan +// Copyright (C) 2021 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by @@ -18,42 +18,40 @@ package database import ( "database/sql" - "strings" log "maunium.net/go/maulogger/v2" - - "github.com/Rhymen/go-whatsapp" - "maunium.net/go/mautrix/id" + + "go.mau.fi/whatsmeow/types" ) type PortalKey struct { - JID whatsapp.JID - Receiver whatsapp.JID + JID types.JID + Receiver types.JID } -func GroupPortalKey(jid whatsapp.JID) PortalKey { +func GroupPortalKey(jid types.JID) PortalKey { return PortalKey{ - JID: jid, - Receiver: jid, + JID: jid.ToNonAD(), + Receiver: jid.ToNonAD(), } } -func NewPortalKey(jid, receiver whatsapp.JID) PortalKey { - if strings.HasSuffix(jid, whatsapp.GroupSuffix) { +func NewPortalKey(jid, receiver types.JID) PortalKey { + if jid.Server == types.GroupServer { receiver = jid } return PortalKey{ - JID: jid, - Receiver: receiver, + JID: jid.ToNonAD(), + Receiver: receiver.ToNonAD(), } } func (key PortalKey) String() string { if key.Receiver == key.JID { - return key.JID + return key.JID.String() } - return key.JID + "-" + key.Receiver + return key.JID.String() + "-" + key.Receiver.String() } type PortalQuery struct { @@ -80,12 +78,12 @@ func (pq *PortalQuery) GetByMXID(mxid id.RoomID) *Portal { return pq.get("SELECT * FROM portal WHERE mxid=$1", mxid) } -func (pq *PortalQuery) GetAllByJID(jid whatsapp.JID) []*Portal { +func (pq *PortalQuery) GetAllByJID(jid types.JID) []*Portal { return pq.getAll("SELECT * FROM portal WHERE jid=$1", jid) } -func (pq *PortalQuery) FindPrivateChats(receiver whatsapp.JID) []*Portal { - return pq.getAll("SELECT * FROM portal WHERE receiver=$1 AND jid LIKE '%@s.whatsapp.net'", receiver) +func (pq *PortalQuery) FindPrivateChats(receiver types.JID) []*Portal { + return pq.getAll("SELECT * FROM portal WHERE receiver='$1@s.whatsapp.net' AND jid LIKE '%@s.whatsapp.net'", receiver) } func (pq *PortalQuery) getAll(query string, args ...interface{}) (portals []*Portal) { @@ -170,25 +168,25 @@ func (portal *Portal) Delete() { } } -func (portal *Portal) GetUserIDs() []id.UserID { - rows, err := portal.db.Query(`SELECT "user".mxid FROM "user", user_portal - WHERE "user".jid=user_portal.user_jid - AND user_portal.portal_jid=$1 - AND user_portal.portal_receiver=$2`, - portal.Key.JID, portal.Key.Receiver) - if err != nil { - portal.log.Debugln("Failed to get portal user ids:", err) - return nil - } - var userIDs []id.UserID - for rows.Next() { - var userID id.UserID - err = rows.Scan(&userID) - if err != nil { - portal.log.Warnln("Failed to scan row:", err) - continue - } - userIDs = append(userIDs, userID) - } - return userIDs -} +//func (portal *Portal) GetUserIDs() []id.UserID { +// rows, err := portal.db.Query(`SELECT "user".mxid FROM "user", user_portal +// WHERE "user".jid=user_portal.user_jid +// AND user_portal.portal_jid=$1 +// AND user_portal.portal_receiver=$2`, +// portal.Key.JID, portal.Key.Receiver) +// if err != nil { +// portal.log.Debugln("Failed to get portal user ids:", err) +// return nil +// } +// var userIDs []id.UserID +// for rows.Next() { +// var userID id.UserID +// err = rows.Scan(&userID) +// if err != nil { +// portal.log.Warnln("Failed to scan row:", err) +// continue +// } +// userIDs = append(userIDs, userID) +// } +// return userIDs +//} diff --git a/database/puppet.go b/database/puppet.go index b9ba2fc..186ba67 100644 --- a/database/puppet.go +++ b/database/puppet.go @@ -1,5 +1,5 @@ // mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge. -// Copyright (C) 2020 Tulir Asokan +// Copyright (C) 2021 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by @@ -20,10 +20,9 @@ import ( "database/sql" log "maunium.net/go/maulogger/v2" - - "github.com/Rhymen/go-whatsapp" - "maunium.net/go/mautrix/id" + + "go.mau.fi/whatsmeow/types" ) type PuppetQuery struct { @@ -42,7 +41,7 @@ func (pq *PuppetQuery) New() *Puppet { } func (pq *PuppetQuery) GetAll() (puppets []*Puppet) { - rows, err := pq.db.Query("SELECT jid, avatar, avatar_url, displayname, name_quality, custom_mxid, access_token, next_batch, enable_presence, enable_receipts FROM puppet") + rows, err := pq.db.Query("SELECT username, avatar, avatar_url, displayname, name_quality, custom_mxid, access_token, next_batch, enable_presence, enable_receipts FROM puppet") if err != nil || rows == nil { return nil } @@ -53,8 +52,8 @@ func (pq *PuppetQuery) GetAll() (puppets []*Puppet) { return } -func (pq *PuppetQuery) Get(jid whatsapp.JID) *Puppet { - row := pq.db.QueryRow("SELECT jid, avatar, avatar_url, displayname, name_quality, custom_mxid, access_token, next_batch, enable_presence, enable_receipts FROM puppet WHERE jid=$1", jid) +func (pq *PuppetQuery) Get(jid types.JID) *Puppet { + row := pq.db.QueryRow("SELECT username, avatar, avatar_url, displayname, name_quality, custom_mxid, access_token, next_batch, enable_presence, enable_receipts FROM puppet WHERE username=$1", jid.User) if row == nil { return nil } @@ -62,7 +61,7 @@ func (pq *PuppetQuery) Get(jid whatsapp.JID) *Puppet { } func (pq *PuppetQuery) GetByCustomMXID(mxid id.UserID) *Puppet { - row := pq.db.QueryRow("SELECT jid, avatar, avatar_url, displayname, name_quality, custom_mxid, access_token, next_batch, enable_presence, enable_receipts FROM puppet WHERE custom_mxid=$1", mxid) + row := pq.db.QueryRow("SELECT username, avatar, avatar_url, displayname, name_quality, custom_mxid, access_token, next_batch, enable_presence, enable_receipts FROM puppet WHERE custom_mxid=$1", mxid) if row == nil { return nil } @@ -70,7 +69,7 @@ func (pq *PuppetQuery) GetByCustomMXID(mxid id.UserID) *Puppet { } func (pq *PuppetQuery) GetAllWithCustomMXID() (puppets []*Puppet) { - rows, err := pq.db.Query("SELECT jid, avatar, avatar_url, displayname, name_quality, custom_mxid, access_token, next_batch, enable_presence, enable_receipts FROM puppet WHERE custom_mxid<>''") + rows, err := pq.db.Query("SELECT username, avatar, avatar_url, displayname, name_quality, custom_mxid, access_token, next_batch, enable_presence, enable_receipts FROM puppet WHERE custom_mxid<>''") if err != nil || rows == nil { return nil } @@ -85,7 +84,7 @@ type Puppet struct { db *Database log log.Logger - JID whatsapp.JID + JID types.JID Avatar string AvatarURL id.ContentURI Displayname string @@ -102,13 +101,15 @@ func (puppet *Puppet) Scan(row Scannable) *Puppet { var displayname, avatar, avatarURL, customMXID, accessToken, nextBatch sql.NullString var quality sql.NullInt64 var enablePresence, enableReceipts sql.NullBool - err := row.Scan(&puppet.JID, &avatar, &avatarURL, &displayname, &quality, &customMXID, &accessToken, &nextBatch, &enablePresence, &enableReceipts) + var username string + err := row.Scan(&username, &avatar, &avatarURL, &displayname, &quality, &customMXID, &accessToken, &nextBatch, &enablePresence, &enableReceipts) if err != nil { if err != sql.ErrNoRows { puppet.log.Errorln("Database scan failed:", err) } return nil } + puppet.JID = types.NewJID(username, types.DefaultUserServer) puppet.Displayname = displayname.String puppet.Avatar = avatar.String puppet.AvatarURL, _ = id.ParseContentURI(avatarURL.String) @@ -122,16 +123,20 @@ func (puppet *Puppet) Scan(row Scannable) *Puppet { } func (puppet *Puppet) Insert() { - _, err := puppet.db.Exec("INSERT INTO puppet (jid, avatar, avatar_url, displayname, name_quality, custom_mxid, access_token, next_batch, enable_presence, enable_receipts) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", - puppet.JID, puppet.Avatar, puppet.AvatarURL.String(), puppet.Displayname, puppet.NameQuality, puppet.CustomMXID, puppet.AccessToken, puppet.NextBatch, puppet.EnablePresence, puppet.EnableReceipts) + if puppet.JID.Server != types.DefaultUserServer { + puppet.log.Warnfln("Not inserting %s: not a user", puppet.JID) + return + } + _, err := puppet.db.Exec("INSERT INTO puppet (username, avatar, avatar_url, displayname, name_quality, custom_mxid, access_token, next_batch, enable_presence, enable_receipts) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", + puppet.JID.User, puppet.Avatar, puppet.AvatarURL.String(), puppet.Displayname, puppet.NameQuality, puppet.CustomMXID, puppet.AccessToken, puppet.NextBatch, puppet.EnablePresence, puppet.EnableReceipts) if err != nil { puppet.log.Warnfln("Failed to insert %s: %v", puppet.JID, err) } } func (puppet *Puppet) Update() { - _, err := puppet.db.Exec("UPDATE puppet SET displayname=$1, name_quality=$2, avatar=$3, avatar_url=$4, custom_mxid=$5, access_token=$6, next_batch=$7, enable_presence=$8, enable_receipts=$9 WHERE jid=$10", - puppet.Displayname, puppet.NameQuality, puppet.Avatar, puppet.AvatarURL.String(), puppet.CustomMXID, puppet.AccessToken, puppet.NextBatch, puppet.EnablePresence, puppet.EnableReceipts, puppet.JID) + _, err := puppet.db.Exec("UPDATE puppet SET displayname=$1, name_quality=$2, avatar=$3, avatar_url=$4, custom_mxid=$5, access_token=$6, next_batch=$7, enable_presence=$8, enable_receipts=$9 WHERE username=$10", + puppet.Displayname, puppet.NameQuality, puppet.Avatar, puppet.AvatarURL.String(), puppet.CustomMXID, puppet.AccessToken, puppet.NextBatch, puppet.EnablePresence, puppet.EnableReceipts, puppet.JID.User) if err != nil { puppet.log.Warnfln("Failed to update %s->%s: %v", puppet.JID, err) } diff --git a/database/upgrades/2021-10-21-add-whatsmeow-store.go b/database/upgrades/2021-10-21-add-whatsmeow-store.go new file mode 100644 index 0000000..41d7a8e --- /dev/null +++ b/database/upgrades/2021-10-21-add-whatsmeow-store.go @@ -0,0 +1,13 @@ +package upgrades + +import ( + "database/sql" + + "go.mau.fi/whatsmeow/store/sqlstore" +) + +func init() { + upgrades[24] = upgrade{"Add whatsmeow state store", func(tx *sql.Tx, ctx context) error { + return sqlstore.Upgrades[0](tx, sqlstore.NewWithDB(ctx.db, ctx.dialect.String(), nil)) + }} +} diff --git a/database/upgrades/2021-10-21-multidevice-updates.go b/database/upgrades/2021-10-21-multidevice-updates.go new file mode 100644 index 0000000..0e4cccd --- /dev/null +++ b/database/upgrades/2021-10-21-multidevice-updates.go @@ -0,0 +1,87 @@ +package upgrades + +import ( + "database/sql" +) + +func init() { + upgrades[25] = upgrade{"Update things for multidevice", func(tx *sql.Tx, ctx context) error { + // This is probably not necessary + _, err := tx.Exec("DROP TABLE user_portal") + if err != nil { + return err + } + + // Remove invalid puppet rows + _, err = tx.Exec("DELETE FROM puppet WHERE jid LIKE '%@g.us' OR jid LIKE '%@broadcast'") + if err != nil { + return err + } + // Remove the suffix from puppets since they'll all have the same suffix + _, err = tx.Exec("UPDATE puppet SET jid=REPLACE(jid, '@s.whatsapp.net', '')") + if err != nil { + return err + } + // Rename column to correctly represent the new content + _, err = tx.Exec("ALTER TABLE puppet RENAME COLUMN jid TO username") + if err != nil { + return err + } + + if ctx.dialect == SQLite { + // Message content was removed from the main message table earlier, but the backup table still exists for SQLite + _, err = tx.Exec("DROP TABLE IF EXISTS old_message") + + _, err = tx.Exec(`ALTER TABLE "user" RENAME TO old_user`) + if err != nil { + return err + } + _, err = tx.Exec(`CREATE TABLE "user" ( + mxid TEXT PRIMARY KEY, + username TEXT UNIQUE, + agent SMALLINT, + device SMALLINT, + management_room TEXT + )`) + if err != nil { + return err + } + + // No need to copy auth data, users need to relogin anyway + _, err = tx.Exec(`INSERT INTO "user" (mxid, management_room, last_connection) SELECT mxid, management_room, last_connection FROM old_user`) + if err != nil { + return err + } + + _, err = tx.Exec("DROP TABLE old_user") + if err != nil { + return err + } + } else { + // The jid column never actually contained the full JID, so let's rename it. + _, err = tx.Exec(`ALTER TABLE "user" RENAME COLUMN jid TO username`) + if err != nil { + return err + } + + // The auth data is now in the whatsmeow_device table. + for _, column := range []string{"last_connection", "client_id", "client_token", "server_token", "enc_key", "mac_key"} { + _, err = tx.Exec(`ALTER TABLE "user" DROP COLUMN ` + column) + if err != nil { + return err + } + } + + // The whatsmeow_device table is keyed by the full JID, so we need to store the other parts of the JID here too. + _, err = tx.Exec(`ALTER TABLE "user" ADD COLUMN agent SMALLINT`) + if err != nil { + return err + } + _, err = tx.Exec(`ALTER TABLE "user" ADD COLUMN device SMALLINT`) + if err != nil { + return err + } + } + return nil + }} +} diff --git a/database/upgrades/upgrades.go b/database/upgrades/upgrades.go index 9bcc1c2..097f8e3 100644 --- a/database/upgrades/upgrades.go +++ b/database/upgrades/upgrades.go @@ -39,7 +39,7 @@ type upgrade struct { fn upgradeFunc } -const NumberOfUpgrades = 24 +const NumberOfUpgrades = 26 var upgrades [NumberOfUpgrades]upgrade diff --git a/database/user.go b/database/user.go index 1b0d9ce..a5d7b5e 100644 --- a/database/user.go +++ b/database/user.go @@ -1,5 +1,5 @@ // mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge. -// Copyright (C) 2020 Tulir Asokan +// Copyright (C) 2021 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by @@ -18,15 +18,11 @@ package database import ( "database/sql" - "fmt" - "strings" - "time" - - "github.com/Rhymen/go-whatsapp" log "maunium.net/go/maulogger/v2" - "maunium.net/go/mautrix/id" + + "go.mau.fi/whatsmeow/types" ) type UserQuery struct { @@ -42,7 +38,7 @@ func (uq *UserQuery) New() *User { } func (uq *UserQuery) GetAll() (users []*User) { - rows, err := uq.db.Query(`SELECT mxid, jid, management_room, last_connection, client_id, client_token, server_token, enc_key, mac_key FROM "user"`) + rows, err := uq.db.Query(`SELECT mxid, username, agent, device, management_room FROM "user"`) if err != nil || rows == nil { return nil } @@ -54,15 +50,15 @@ func (uq *UserQuery) GetAll() (users []*User) { } func (uq *UserQuery) GetByMXID(userID id.UserID) *User { - row := uq.db.QueryRow(`SELECT mxid, jid, management_room, last_connection, client_id, client_token, server_token, enc_key, mac_key FROM "user" WHERE mxid=$1`, userID) + row := uq.db.QueryRow(`SELECT mxid, username, agent, device, management_room FROM "user" WHERE mxid=$1`, userID) if row == nil { return nil } return uq.New().Scan(row) } -func (uq *UserQuery) GetByJID(userID whatsapp.JID) *User { - row := uq.db.QueryRow(`SELECT mxid, jid, management_room, last_connection, client_id, client_token, server_token, enc_key, mac_key FROM "user" WHERE jid=$1`, stripSuffix(userID)) +func (uq *UserQuery) GetByUsername(username string) *User { + row := uq.db.QueryRow(`SELECT mxid, username, agent, device, management_room FROM "user" WHERE username=$1`, username) if row == nil { return nil } @@ -74,185 +70,151 @@ type User struct { log log.Logger MXID id.UserID - JID whatsapp.JID + JID types.JID ManagementRoom id.RoomID - Session *whatsapp.Session - LastConnection int64 } func (user *User) Scan(row Scannable) *User { - var jid, clientID, clientToken, serverToken sql.NullString - var encKey, macKey []byte - err := row.Scan(&user.MXID, &jid, &user.ManagementRoom, &user.LastConnection, &clientID, &clientToken, &serverToken, &encKey, &macKey) + var username sql.NullString + var device, agent sql.NullByte + err := row.Scan(&user.MXID, &username, &agent, &device, &user.ManagementRoom) if err != nil { if err != sql.ErrNoRows { user.log.Errorln("Database scan failed:", err) } return nil } - if len(jid.String) > 0 && len(clientID.String) > 0 { - user.JID = jid.String + whatsapp.NewUserSuffix - user.Session = &whatsapp.Session{ - ClientID: clientID.String, - ClientToken: clientToken.String, - ServerToken: serverToken.String, - EncKey: encKey, - MacKey: macKey, - Wid: jid.String + whatsapp.OldUserSuffix, - } - } else { - user.Session = nil + if len(username.String) > 0 { + user.JID = types.NewADJID(username.String, agent.Byte, device.Byte) } return user } -func stripSuffix(jid whatsapp.JID) string { - if len(jid) == 0 { - return jid - } - - index := strings.IndexRune(jid, '@') - if index < 0 { - return jid - } - - return jid[:index] -} - -func (user *User) jidPtr() *string { - if len(user.JID) > 0 { - str := stripSuffix(user.JID) - return &str +func (user *User) usernamePtr() *string { + if !user.JID.IsEmpty() { + return &user.JID.User } return nil } -func (user *User) sessionUnptr() (sess whatsapp.Session) { - if user.Session != nil { - sess = *user.Session +func (user *User) agentPtr() *uint8 { + if !user.JID.IsEmpty() { + return &user.JID.Agent } - return + return nil +} + +func (user *User) devicePtr() *uint8 { + if !user.JID.IsEmpty() { + return &user.JID.Device + } + return nil } func (user *User) Insert() { - sess := user.sessionUnptr() - _, err := user.db.Exec(`INSERT INTO "user" (mxid, jid, management_room, last_connection, client_id, client_token, server_token, enc_key, mac_key) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, - user.MXID, user.jidPtr(), - user.ManagementRoom, user.LastConnection, - sess.ClientID, sess.ClientToken, sess.ServerToken, sess.EncKey, sess.MacKey) + _, err := user.db.Exec(`INSERT INTO "user" (mxid, username, agent, device, management_room) VALUES ($1, $2, $3, $4, $5)`, + user.MXID, user.usernamePtr(), user.agentPtr(), user.devicePtr(), user.ManagementRoom) if err != nil { user.log.Warnfln("Failed to insert %s: %v", user.MXID, err) } } -func (user *User) UpdateLastConnection() { - user.LastConnection = time.Now().Unix() - _, err := user.db.Exec(`UPDATE "user" SET last_connection=$1 WHERE mxid=$2`, - user.LastConnection, user.MXID) - if err != nil { - user.log.Warnfln("Failed to update last connection ts: %v", err) - } -} - func (user *User) Update() { - sess := user.sessionUnptr() - _, err := user.db.Exec(`UPDATE "user" SET jid=$1, management_room=$2, last_connection=$3, client_id=$4, client_token=$5, server_token=$6, enc_key=$7, mac_key=$8 WHERE mxid=$9`, - user.jidPtr(), user.ManagementRoom, user.LastConnection, - sess.ClientID, sess.ClientToken, sess.ServerToken, sess.EncKey, sess.MacKey, - user.MXID) + _, err := user.db.Exec(`UPDATE "user" SET username=$1, agent=$2, device=$3, management_room=$4 WHERE mxid=$5`, + user.usernamePtr(), user.agentPtr(), user.devicePtr(), user.ManagementRoom, user.MXID) if err != nil { user.log.Warnfln("Failed to update %s: %v", user.MXID, err) } } -type PortalKeyWithMeta struct { - PortalKey - InCommunity bool -} - -func (user *User) SetPortalKeys(newKeys []PortalKeyWithMeta) error { - tx, err := user.db.Begin() - if err != nil { - return err - } - _, err = tx.Exec("DELETE FROM user_portal WHERE user_jid=$1", user.jidPtr()) - if err != nil { - _ = tx.Rollback() - return err - } - valueStrings := make([]string, len(newKeys)) - values := make([]interface{}, len(newKeys)*4) - for i, key := range newKeys { - pos := i * 4 - valueStrings[i] = fmt.Sprintf("($%d, $%d, $%d, $%d)", pos+1, pos+2, pos+3, pos+4) - values[pos] = user.jidPtr() - values[pos+1] = key.JID - values[pos+2] = key.Receiver - values[pos+3] = key.InCommunity - } - query := fmt.Sprintf("INSERT INTO user_portal (user_jid, portal_jid, portal_receiver, in_community) VALUES %s", - strings.Join(valueStrings, ", ")) - _, err = tx.Exec(query, values...) - if err != nil { - _ = tx.Rollback() - return err - } - return tx.Commit() -} - -func (user *User) IsInPortal(key PortalKey) bool { - row := user.db.QueryRow(`SELECT EXISTS(SELECT 1 FROM user_portal WHERE user_jid=$1 AND portal_jid=$2 AND portal_receiver=$3)`, user.jidPtr(), &key.JID, &key.Receiver) - var exists bool - _ = row.Scan(&exists) - return exists -} - -func (user *User) GetPortalKeys() []PortalKey { - rows, err := user.db.Query(`SELECT portal_jid, portal_receiver FROM user_portal WHERE user_jid=$1`, user.jidPtr()) - if err != nil { - user.log.Warnln("Failed to get user portal keys:", err) - return nil - } - var keys []PortalKey - for rows.Next() { - var key PortalKey - err = rows.Scan(&key.JID, &key.Receiver) - if err != nil { - user.log.Warnln("Failed to scan row:", err) - continue - } - keys = append(keys, key) - } - return keys -} - -func (user *User) GetInCommunityMap() map[PortalKey]bool { - rows, err := user.db.Query(`SELECT portal_jid, portal_receiver, in_community FROM user_portal WHERE user_jid=$1`, user.jidPtr()) - if err != nil { - user.log.Warnln("Failed to get user portal keys:", err) - return nil - } - keys := make(map[PortalKey]bool) - for rows.Next() { - var key PortalKey - var inCommunity bool - err = rows.Scan(&key.JID, &key.Receiver, &inCommunity) - if err != nil { - user.log.Warnln("Failed to scan row:", err) - continue - } - keys[key] = inCommunity - } - return keys -} - -func (user *User) CreateUserPortal(newKey PortalKeyWithMeta) { - user.log.Debugfln("Creating new portal %s for %s", newKey.PortalKey.JID, newKey.PortalKey.Receiver) - _, err := user.db.Exec(`INSERT INTO user_portal (user_jid, portal_jid, portal_receiver, in_community) VALUES ($1, $2, $3, $4)`, - user.jidPtr(), - newKey.PortalKey.JID, newKey.PortalKey.Receiver, - newKey.InCommunity) - if err != nil { - user.log.Warnfln("Failed to insert %s: %v", user.MXID, err) - } -} +//type PortalKeyWithMeta struct { +// PortalKey +// InCommunity bool +//} +// +//func (user *User) SetPortalKeys(newKeys []PortalKeyWithMeta) error { +// tx, err := user.db.Begin() +// if err != nil { +// return err +// } +// _, err = tx.Exec("DELETE FROM user_portal WHERE user_jid=$1", user.jidPtr()) +// if err != nil { +// _ = tx.Rollback() +// return err +// } +// valueStrings := make([]string, len(newKeys)) +// values := make([]interface{}, len(newKeys)*4) +// for i, key := range newKeys { +// pos := i * 4 +// valueStrings[i] = fmt.Sprintf("($%d, $%d, $%d, $%d)", pos+1, pos+2, pos+3, pos+4) +// values[pos] = user.jidPtr() +// values[pos+1] = key.JID +// values[pos+2] = key.Receiver +// values[pos+3] = key.InCommunity +// } +// query := fmt.Sprintf("INSERT INTO user_portal (user_jid, portal_jid, portal_receiver, in_community) VALUES %s", +// strings.Join(valueStrings, ", ")) +// _, err = tx.Exec(query, values...) +// if err != nil { +// _ = tx.Rollback() +// return err +// } +// return tx.Commit() +//} +// +//func (user *User) IsInPortal(key PortalKey) bool { +// row := user.db.QueryRow(`SELECT EXISTS(SELECT 1 FROM user_portal WHERE user_jid=$1 AND portal_jid=$2 AND portal_receiver=$3)`, user.jidPtr(), &key.JID, &key.Receiver) +// var exists bool +// _ = row.Scan(&exists) +// return exists +//} +// +//func (user *User) GetPortalKeys() []PortalKey { +// rows, err := user.db.Query(`SELECT portal_jid, portal_receiver FROM user_portal WHERE user_jid=$1`, user.jidPtr()) +// if err != nil { +// user.log.Warnln("Failed to get user portal keys:", err) +// return nil +// } +// var keys []PortalKey +// for rows.Next() { +// var key PortalKey +// err = rows.Scan(&key.JID, &key.Receiver) +// if err != nil { +// user.log.Warnln("Failed to scan row:", err) +// continue +// } +// keys = append(keys, key) +// } +// return keys +//} +// +//func (user *User) GetInCommunityMap() map[PortalKey]bool { +// rows, err := user.db.Query(`SELECT portal_jid, portal_receiver, in_community FROM user_portal WHERE user_jid=$1`, user.jidPtr()) +// if err != nil { +// user.log.Warnln("Failed to get user portal keys:", err) +// return nil +// } +// keys := make(map[PortalKey]bool) +// for rows.Next() { +// var key PortalKey +// var inCommunity bool +// err = rows.Scan(&key.JID, &key.Receiver, &inCommunity) +// if err != nil { +// user.log.Warnln("Failed to scan row:", err) +// continue +// } +// keys[key] = inCommunity +// } +// return keys +//} +// +//func (user *User) CreateUserPortal(newKey PortalKeyWithMeta) { +// user.log.Debugfln("Creating new portal %s for %s", newKey.PortalKey.JID, newKey.PortalKey.Receiver) +// _, err := user.db.Exec(`INSERT INTO user_portal (user_jid, portal_jid, portal_receiver, in_community) VALUES ($1, $2, $3, $4)`, +// user.jidPtr(), +// newKey.PortalKey.JID, newKey.PortalKey.Receiver, +// newKey.InCommunity) +// if err != nil { +// user.log.Warnfln("Failed to insert %s: %v", user.MXID, err) +// } +//} diff --git a/example-config.yaml b/example-config.yaml index 840a2b8..128d2c9 100644 --- a/example-config.yaml +++ b/example-config.yaml @@ -73,21 +73,13 @@ bridge: # {{.}} is replaced with the phone number of the WhatsApp user. username_template: whatsapp_{{.}} # Displayname template for WhatsApp users. - # {{.Notify}} - nickname set by the WhatsApp user - # {{.VName}} - validated WhatsApp business name - # {{.JID}} - phone number (international format) + # {{.PushName}} - nickname set by the WhatsApp user + # {{.BusinessName}} - validated WhatsApp business name + # {{.Phone}} - phone number (international format) # The following variables are also available, but will cause problems on multi-user instances: - # {{.Name}} - display name from contact list - # {{.Short}} - short display name from contact list - displayname_template: "{{if .Notify}}{{.Notify}}{{else if .VName}}{{.VName}}{{else}}{{.JID}}{{end}} (WA)" - # Localpart template for per-user room grouping community IDs. - # On startup, the bridge will try to create these communities, add all of the specific user's - # portals to the community, and invite the Matrix user to it. - # (Note that, by default, non-admins might not have your homeserver's permission to create - # communities.) - # {{.Localpart}} is the MXID localpart and {{.Server}} is the MXID server part of the user. - # whatsapp_{{.Localpart}}={{.Server}} is a good value that should work for any user. - community_template: null + # {{.FullName}} - full name from contact list + # {{.FirstName}} - first name from contact list + displayname_template: "{{if .PushName}}{{.PushName}}{{else if .BusinessName}}{{.BusinessName}}{{else}}{{.JID}}{{end}} (WA)" # WhatsApp connection timeout in seconds. connection_timeout: 20 diff --git a/formatting.go b/formatting.go index 3b3237e..f1afac7 100644 --- a/formatting.go +++ b/formatting.go @@ -1,5 +1,5 @@ // mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge. -// Copyright (C) 2020 Tulir Asokan +// Copyright (C) 2021 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by @@ -22,7 +22,7 @@ import ( "regexp" "strings" - "github.com/Rhymen/go-whatsapp" + "go.mau.fi/whatsmeow/types" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/format" @@ -57,32 +57,22 @@ func NewFormatter(bridge *Bridge) *Formatter { if mxid[0] == '@' { puppet := bridge.GetPuppetByMXID(id.UserID(mxid)) if puppet != nil { - jids, ok := ctx[mentionedJIDsContextKey].([]whatsapp.JID) + jids, ok := ctx[mentionedJIDsContextKey].([]string) if !ok { - ctx[mentionedJIDsContextKey] = []whatsapp.JID{puppet.JID} + ctx[mentionedJIDsContextKey] = []string{puppet.JID.String()} } else { - ctx[mentionedJIDsContextKey] = append(jids, puppet.JID) + ctx[mentionedJIDsContextKey] = append(jids, puppet.JID.String()) } - return "@" + puppet.PhoneNumber() + return "@" + puppet.JID.User } } return mxid }, - BoldConverter: func(text string, _ format.Context) string { - return fmt.Sprintf("*%s*", text) - }, - ItalicConverter: func(text string, _ format.Context) string { - return fmt.Sprintf("_%s_", text) - }, - StrikethroughConverter: func(text string, _ format.Context) string { - return fmt.Sprintf("~%s~", text) - }, - MonospaceConverter: func(text string, _ format.Context) string { - return fmt.Sprintf("```%s```", text) - }, - MonospaceBlockConverter: func(text, language string, _ format.Context) string { - return fmt.Sprintf("```%s```", text) - }, + BoldConverter: func(text string, _ format.Context) string { return fmt.Sprintf("*%s*", text) }, + ItalicConverter: func(text string, _ format.Context) string { return fmt.Sprintf("_%s_", text) }, + StrikethroughConverter: func(text string, _ format.Context) string { return fmt.Sprintf("~%s~", text) }, + MonospaceConverter: func(text string, _ format.Context) string { return fmt.Sprintf("```%s```", text) }, + MonospaceBlockConverter: func(text, language string, _ format.Context) string { return fmt.Sprintf("```%s```", text) }, }, waReplString: map[*regexp.Regexp]string{ italicRegex: "$1$2$3", @@ -99,12 +89,11 @@ func NewFormatter(bridge *Bridge) *Formatter { return fmt.Sprintf("%s", str) }, } - formatter.waReplFuncText = map[*regexp.Regexp]func(string) string{ - } + formatter.waReplFuncText = map[*regexp.Regexp]func(string) string{} return formatter } -func (formatter *Formatter) getMatrixInfoByJID(jid whatsapp.JID) (mxid id.UserID, displayname string) { +func (formatter *Formatter) getMatrixInfoByJID(jid types.JID) (mxid id.UserID, displayname string) { if user := formatter.bridge.GetUserByJID(jid); user != nil { mxid = user.MXID displayname = string(user.MXID) @@ -115,7 +104,7 @@ func (formatter *Formatter) getMatrixInfoByJID(jid whatsapp.JID) (mxid id.UserID return } -func (formatter *Formatter) ParseWhatsApp(content *event.MessageEventContent, mentionedJIDs []whatsapp.JID) { +func (formatter *Formatter) ParseWhatsApp(content *event.MessageEventContent, mentionedJIDs []string) { output := html.EscapeString(content.Body) for regex, replacement := range formatter.waReplString { output = regex.ReplaceAllString(output, replacement) @@ -123,14 +112,20 @@ func (formatter *Formatter) ParseWhatsApp(content *event.MessageEventContent, me for regex, replacer := range formatter.waReplFunc { output = regex.ReplaceAllStringFunc(output, replacer) } - for _, jid := range mentionedJIDs { + for _, rawJID := range mentionedJIDs { + jid, err := types.ParseJID(rawJID) + if err != nil { + continue + } else if jid.Server == types.LegacyUserServer { + jid.Server = types.DefaultUserServer + } mxid, displayname := formatter.getMatrixInfoByJID(jid) - number := "@" + strings.Replace(jid, whatsapp.NewUserSuffix, "", 1) - output = strings.Replace(output, number, fmt.Sprintf(`%s`, mxid, displayname), -1) - content.Body = strings.Replace(content.Body, number, displayname, -1) + number := "@" + jid.User + output = strings.ReplaceAll(output, number, fmt.Sprintf(`%s`, mxid, displayname)) + content.Body = strings.ReplaceAll(content.Body, number, displayname) } if output != content.Body { - output = strings.Replace(output, "\n", "
", -1) + output = strings.ReplaceAll(output, "\n", "
") content.FormattedBody = output content.Format = event.FormatHTML for regex, replacer := range formatter.waReplFuncText { @@ -139,9 +134,9 @@ func (formatter *Formatter) ParseWhatsApp(content *event.MessageEventContent, me } } -func (formatter *Formatter) ParseMatrix(html string) (string, []whatsapp.JID) { +func (formatter *Formatter) ParseMatrix(html string) (string, []string) { ctx := make(format.Context) result := formatter.matrixHTMLParser.Parse(html, ctx) - mentionedJIDs, _ := ctx[mentionedJIDsContextKey].([]whatsapp.JID) + mentionedJIDs, _ := ctx[mentionedJIDsContextKey].([]string) return result, mentionedJIDs } diff --git a/go.mod b/go.mod index ec181cb..9de9c17 100644 --- a/go.mod +++ b/go.mod @@ -1,19 +1,40 @@ module maunium.net/go/mautrix-whatsapp -go 1.14 +go 1.17 require ( - github.com/Rhymen/go-whatsapp v0.1.0 github.com/gorilla/websocket v1.4.2 - github.com/lib/pq v1.10.2 - github.com/mattn/go-sqlite3 v1.14.8 + github.com/lib/pq v1.10.3 + github.com/mattn/go-sqlite3 v1.14.9 github.com/prometheus/client_golang v1.11.0 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e + go.mau.fi/whatsmeow v0.0.0-20211022171408-90a9b647d253 golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d + google.golang.org/protobuf v1.27.1 gopkg.in/yaml.v2 v2.4.0 maunium.net/go/mauflag v1.0.0 maunium.net/go/maulogger/v2 v2.3.0 - maunium.net/go/mautrix v0.9.27 + maunium.net/go/mautrix v0.9.29 ) -replace github.com/Rhymen/go-whatsapp => github.com/tulir/go-whatsapp v0.5.12 +require ( + filippo.io/edwards25519 v1.0.0-rc.1 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/btcsuite/btcutil v1.0.2 // indirect + github.com/cespare/xxhash/v2 v2.1.1 // indirect + github.com/golang/protobuf v1.5.0 // indirect + github.com/gorilla/mux v1.8.0 // indirect + github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect + github.com/prometheus/client_model v0.2.0 // indirect + github.com/prometheus/common v0.26.0 // indirect + github.com/prometheus/procfs v0.6.0 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/tidwall/gjson v1.6.8 // indirect + github.com/tidwall/match v1.0.3 // indirect + github.com/tidwall/pretty v1.0.2 // indirect + github.com/tidwall/sjson v1.1.5 // indirect + go.mau.fi/libsignal v0.0.0-20211016130347-464152efc488 // indirect + golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 // indirect + golang.org/x/net v0.0.0-20210226172049-e18ecbb05110 // indirect + golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 // indirect +) diff --git a/go.sum b/go.sum index e301303..e515e3f 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,6 @@ cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +filippo.io/edwards25519 v1.0.0-rc.1 h1:m0VOOB23frXZvAOK44usCgLWvtsxIoMCTBGJZlpmGfU= +filippo.io/edwards25519 v1.0.0-rc.1/go.mod h1:N1IkdkCkiLB6tki+MYJoSx2JTY9NUlxZE7eHn5EwJns= github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= @@ -44,9 +46,8 @@ github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:W github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0 h1:LUVKkCeviFUMKqHa4tXIIij/lbhnMbP7Fn5wKdKkRh4= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= -github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= @@ -77,11 +78,11 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/lib/pq v1.9.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= -github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8= -github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lib/pq v1.10.3 h1:v9QZf2Sn6AmjXtQeFpdoq/eaNtYP6IN+7lcrygsIAtg= +github.com/lib/pq v1.10.3/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= -github.com/mattn/go-sqlite3 v1.14.8 h1:gDp86IdQsN/xWjIEmr9MF6o9mpksUgh0fu+9ByFxzIU= -github.com/mattn/go-sqlite3 v1.14.8/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= +github.com/mattn/go-sqlite3 v1.14.9 h1:10HX2Td0ocZpYEjhilsuo6WWtUqttj2Kb0KtD86/KYA= +github.com/mattn/go-sqlite3 v1.14.9/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -138,16 +139,18 @@ github.com/tidwall/pretty v1.0.2 h1:Z7S3cePv9Jwm1KwS0513MRaoUe3S01WPbLNV40pwWZU= github.com/tidwall/pretty v1.0.2/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= github.com/tidwall/sjson v1.1.5 h1:wsUceI/XDyZk3J1FUvuuYlK62zJv2HO2Pzb8A5EWdUE= github.com/tidwall/sjson v1.1.5/go.mod h1:VuJzsZnTowhSxWdOgsAnb886i4AjEyTkk7tNtsL7EYE= -github.com/tulir/go-whatsapp v0.5.12 h1:JGU5yhoh+CyDcSMUilwy7FL0gFo0zqqepsHRqEjrjKc= -github.com/tulir/go-whatsapp v0.5.12/go.mod h1:7J3IIL3bEQiBJGtiZst1N4PgXHlWIartdVQLe6lcx9A= +go.mau.fi/libsignal v0.0.0-20211016130347-464152efc488 h1:dIOtV7Fl8bxdOOvBndilSmWFcufBArgq2sZJOqV3Enc= +go.mau.fi/libsignal v0.0.0-20211016130347-464152efc488/go.mod h1:3XlVlwOfp8f9Wri+C1D4ORqgUsN4ZvunJOoPjQMBhos= +go.mau.fi/whatsmeow v0.0.0-20211022171408-90a9b647d253 h1:poKOYLU6AFJF5wqq4iuV4zYvl4TUCe2D77ZMv4of36k= +go.mau.fi/whatsmeow v0.0.0-20211022171408-90a9b647d253/go.mod h1:GJl+Pfu5TEvDM+lXG/PnX9/yMf6vEMwD8HC4Nq75Vhg= golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200115085410-6d4e4cb37c7d/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= -golang.org/x/crypto v0.0.0-20210506145944-38f3c27a63bf h1:B2n+Zi5QeYRDAEodEu72OS36gmTWjgpXr2+cWcBW90o= -golang.org/x/crypto v0.0.0-20210506145944-38f3c27a63bf/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 h1:7I4JAnoQBe7ZtJcBaYHi5UtiO8tQHbUSXxL+pnGRANg= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d h1:RNPAfi2nHY7C2srAV8A49jpsYr0ADedCk1wq6fTMTvs= golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -179,8 +182,9 @@ golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40 h1:JWgyZ1qgdTaF3N3oxC+MdTV7qvEEgHo3otj+HB5CM7Q= golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 h1:SrN+KX8Art/Sf4HNj6Zcz06G7VEz+7w9tdXTPOZ7+l4= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -198,8 +202,8 @@ google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miE google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.26.0 h1:bxAC2xTBsZGibn2RTntX0oH50xLsqy1OxA9tTL3p/lk= -google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.27.1 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ= +google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= @@ -220,5 +224,5 @@ maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfk maunium.net/go/maulogger/v2 v2.2.4/go.mod h1:TYWy7wKwz/tIXTpsx8G3mZseIRiC5DoMxSZazOHy68A= maunium.net/go/maulogger/v2 v2.3.0 h1:TMCcO65fLk6+pJXo7sl38tzjzW0KBFgc6JWJMBJp4GE= maunium.net/go/maulogger/v2 v2.3.0/go.mod h1:TYWy7wKwz/tIXTpsx8G3mZseIRiC5DoMxSZazOHy68A= -maunium.net/go/mautrix v0.9.27 h1:6MV6YSCGqfw8Rb0G1PHjTOkYkTY0vcZaz6wd+U+V1Is= -maunium.net/go/mautrix v0.9.27/go.mod h1:7IzKfWvpQtN+W2Lzxc0rLvIxFM3ryKX6Ys3S/ZoWbg8= +maunium.net/go/mautrix v0.9.29 h1:qJyTSZQuogkkEFrJd+oZiTuE/6Cq7ca3wxiLYadYUoM= +maunium.net/go/mautrix v0.9.29/go.mod h1:7IzKfWvpQtN+W2Lzxc0rLvIxFM3ryKX6Ys3S/ZoWbg8= diff --git a/main.go b/main.go index ebf0b52..c09232a 100644 --- a/main.go +++ b/main.go @@ -1,5 +1,5 @@ // mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge. -// Copyright (C) 2020 Tulir Asokan +// Copyright (C) 2021 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by @@ -21,12 +21,18 @@ import ( "fmt" "os" "os/signal" + "strconv" "strings" "sync" "syscall" "time" - "github.com/Rhymen/go-whatsapp" + "google.golang.org/protobuf/proto" + + waProto "go.mau.fi/whatsmeow/binary/proto" + "go.mau.fi/whatsmeow/store" + "go.mau.fi/whatsmeow/store/sqlstore" + "go.mau.fi/whatsmeow/types" flag "maunium.net/go/mauflag" log "maunium.net/go/maulogger/v2" @@ -48,7 +54,7 @@ var ( // This is changed when making a release Version = "0.1.8" // This is filled by init() - WAVersion = "" + WAVersion = "" VersionString = "" // These are filled at build time with the -X linker flag Tag = "unknown" @@ -148,16 +154,17 @@ type Bridge struct { Relaybot *User Crypto Crypto Metrics *MetricsHandler + WAContainer *sqlstore.Container usersByMXID map[id.UserID]*User - usersByJID map[whatsapp.JID]*User + usersByUsername map[string]*User usersLock sync.Mutex managementRooms map[id.RoomID]*User managementRoomsLock sync.Mutex portalsByMXID map[id.RoomID]*Portal portalsByJID map[database.PortalKey]*Portal portalsLock sync.Mutex - puppets map[whatsapp.JID]*Puppet + puppets map[types.JID]*Puppet puppetsByCustomMXID map[id.UserID]*Puppet puppetsLock sync.Mutex } @@ -176,11 +183,11 @@ type Crypto interface { func NewBridge() *Bridge { bridge := &Bridge{ usersByMXID: make(map[id.UserID]*User), - usersByJID: make(map[whatsapp.JID]*User), + usersByUsername: make(map[string]*User), managementRooms: make(map[id.RoomID]*User), portalsByMXID: make(map[id.RoomID]*Portal), portalsByJID: make(map[database.PortalKey]*Portal), - puppets: make(map[whatsapp.JID]*Puppet), + puppets: make(map[types.JID]*Puppet), puppetsByCustomMXID: make(map[id.UserID]*Puppet), } @@ -259,6 +266,8 @@ func (bridge *Bridge) Init() { bridge.DB.SetMaxOpenConns(bridge.Config.AppService.Database.MaxOpenConns) bridge.DB.SetMaxIdleConns(bridge.Config.AppService.Database.MaxIdleConns) + bridge.WAContainer = sqlstore.NewWithDB(bridge.DB.DB, bridge.Config.AppService.Database.Type, nil) + ss := bridge.Config.AppService.Provisioning.SharedSecret if len(ss) > 0 && ss != "disable" { bridge.Provisioning = &ProvisioningAPI{bridge: bridge} @@ -271,6 +280,23 @@ func (bridge *Bridge) Init() { bridge.Formatter = NewFormatter(bridge) bridge.Crypto = NewCryptoHelper(bridge) bridge.Metrics = NewMetricsHandler(bridge.Config.Metrics.Listen, bridge.Log.Sub("Metrics"), bridge.DB) + + store.BaseClientPayload.UserAgent.OsVersion = proto.String(WAVersion) + store.BaseClientPayload.UserAgent.OsBuildNumber = proto.String(WAVersion) + store.CompanionProps.Os = proto.String(bridge.Config.WhatsApp.OSName) + versionParts := strings.Split(WAVersion, ".") + if len(versionParts) > 2 { + primary, _ := strconv.Atoi(versionParts[0]) + secondary, _ := strconv.Atoi(versionParts[1]) + tertiary, _ := strconv.Atoi(versionParts[2]) + store.CompanionProps.Version.Primary = proto.Uint32(uint32(primary)) + store.CompanionProps.Version.Secondary = proto.Uint32(uint32(secondary)) + store.CompanionProps.Version.Tertiary = proto.Uint32(uint32(tertiary)) + } + platformID, ok := waProto.CompanionProps_CompanionPropsPlatformType_value[strings.ToUpper(bridge.Config.WhatsApp.BrowserName)] + if ok { + store.CompanionProps.PlatformType = waProto.CompanionProps_CompanionPropsPlatformType(platformID).Enum() + } } func (bridge *Bridge) Start() { @@ -374,7 +400,7 @@ func (bridge *Bridge) StartUsers() { bridge.Log.Debugln("Starting users") foundAnySessions := false for _, user := range bridge.GetAllUsers() { - if user.Session != nil { + if !user.JID.IsEmpty() { foundAnySessions = true } go user.Connect(false) @@ -401,15 +427,12 @@ func (bridge *Bridge) Stop() { bridge.AS.Stop() bridge.Metrics.Stop() bridge.EventProcessor.Stop() - for _, user := range bridge.usersByJID { - if user.Conn == nil { + for _, user := range bridge.usersByUsername { + if user.Client == nil { continue } bridge.Log.Debugln("Disconnecting", user.MXID) - err := user.Conn.Disconnect() - if err != nil { - bridge.Log.Errorfln("Error while disconnecting %s: %v", user.MXID, err) - } + user.Client.Disconnect() } } diff --git a/matrix.go b/matrix.go index d0095af..01ac380 100644 --- a/matrix.go +++ b/matrix.go @@ -201,13 +201,10 @@ func (mx *MatrixHandler) createPrivatePortalFromInvite(roomID id.RoomID, inviter portal.UpdateBridgeInfo() _, _ = intent.SendNotice(roomID, "Private chat portal created") - err := portal.FillInitialHistory(inviter) - if err != nil { - portal.log.Errorln("Failed to fill history:", err) - } - - inviter.addPortalToCommunity(portal) - inviter.addPuppetToCommunity(puppet) + //err := portal.FillInitialHistory(inviter) + //if err != nil { + // portal.log.Errorln("Failed to fill history:", err) + //} } func (mx *MatrixHandler) HandlePuppetInvite(evt *event.Event, inviter *User, puppet *Puppet) { @@ -259,7 +256,7 @@ func (mx *MatrixHandler) HandleMembership(evt *event.Event) { } user := mx.bridge.GetUserByMXID(evt.Sender) - if user == nil || !user.Whitelisted || !user.IsConnected() { + if user == nil || !user.Whitelisted || !user.IsLoggedIn() { return } @@ -300,7 +297,7 @@ func (mx *MatrixHandler) HandleRoomMetadata(evt *event.Event) { } user := mx.bridge.GetUserByMXID(evt.Sender) - if user == nil || !user.Whitelisted || !user.IsConnected() { + if user == nil || !user.Whitelisted || !user.IsLoggedIn() { return } @@ -439,7 +436,7 @@ func (mx *MatrixHandler) HandleRedaction(evt *event.Event) { if !user.HasSession() { return - } else if !user.IsConnected() { + } else if !user.IsLoggedIn() { msg := format.RenderMarkdown(fmt.Sprintf("[%[1]s](https://matrix.to/#/%[1]s): \u26a0 "+ "You are not connected to WhatsApp, so your redaction was not bridged. "+ "Use `%[2]s reconnect` to reconnect.", user.MXID, mx.bridge.Config.Bridge.CommandPrefix), true, false) diff --git a/metrics.go b/metrics.go index cfd261a..6e83a82 100644 --- a/metrics.go +++ b/metrics.go @@ -1,5 +1,5 @@ // mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge. -// Copyright (C) 2020 Tulir Asokan +// Copyright (C) 2021 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by @@ -27,7 +27,7 @@ import ( "github.com/prometheus/client_golang/prometheus/promhttp" log "maunium.net/go/maulogger/v2" - "github.com/Rhymen/go-whatsapp" + "go.mau.fi/whatsmeow/types" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" @@ -58,13 +58,10 @@ type MetricsHandler struct { unencryptedGroupCount prometheus.Gauge unencryptedPrivateCount prometheus.Gauge - connected prometheus.Gauge - connectedState map[whatsapp.JID]bool - loggedIn prometheus.Gauge - loggedInState map[whatsapp.JID]bool - syncLocked prometheus.Gauge - syncLockedState map[whatsapp.JID]bool - bufferLength *prometheus.GaugeVec + connected prometheus.Gauge + connectedState map[string]bool + loggedIn prometheus.Gauge + loggedInState map[string]bool } func NewMetricsHandler(address string, log log.Logger, db *database.Database) *MetricsHandler { @@ -121,21 +118,12 @@ func NewMetricsHandler(address string, log log.Logger, db *database.Database) *M Name: "bridge_logged_in", Help: "Users logged into the bridge", }), - loggedInState: make(map[whatsapp.JID]bool), + loggedInState: make(map[string]bool), connected: promauto.NewGauge(prometheus.GaugeOpts{ Name: "bridge_connected", Help: "Bridge users connected to WhatsApp", }), - connectedState: make(map[whatsapp.JID]bool), - syncLocked: promauto.NewGauge(prometheus.GaugeOpts{ - Name: "bridge_sync_locked", - Help: "Bridge users locked in post-login sync", - }), - syncLockedState: make(map[whatsapp.JID]bool), - bufferLength: promauto.NewGaugeVec(prometheus.GaugeOpts{ - Name: "bridge_buffer_size", - Help: "Number of messages in buffer", - }, []string{"user_id"}), + connectedState: make(map[string]bool), } } @@ -154,7 +142,7 @@ func (mh *MetricsHandler) TrackMatrixEvent(eventType event.Type) func() { } } -func (mh *MetricsHandler) TrackWhatsAppMessage(timestamp uint64, messageType string) func() { +func (mh *MetricsHandler) TrackWhatsAppMessage(timestamp time.Time, messageType string) func() { if !mh.running { return noop } @@ -165,7 +153,7 @@ func (mh *MetricsHandler) TrackWhatsAppMessage(timestamp uint64, messageType str mh.whatsappMessageHandling. With(prometheus.Labels{"message_type": messageType}). Observe(duration.Seconds()) - mh.whatsappMessageAge.Observe(time.Now().Sub(time.Unix(int64(timestamp), 0)).Seconds()) + mh.whatsappMessageAge.Observe(time.Now().Sub(timestamp).Seconds()) } } @@ -176,13 +164,13 @@ func (mh *MetricsHandler) TrackDisconnection(userID id.UserID) { mh.disconnections.With(prometheus.Labels{"user_id": string(userID)}).Inc() } -func (mh *MetricsHandler) TrackLoginState(jid whatsapp.JID, loggedIn bool) { +func (mh *MetricsHandler) TrackLoginState(jid types.JID, loggedIn bool) { if !mh.running { return } - currentVal, ok := mh.loggedInState[jid] + currentVal, ok := mh.loggedInState[jid.User] if !ok || currentVal != loggedIn { - mh.loggedInState[jid] = loggedIn + mh.loggedInState[jid.User] = loggedIn if loggedIn { mh.loggedIn.Inc() } else { @@ -191,13 +179,13 @@ func (mh *MetricsHandler) TrackLoginState(jid whatsapp.JID, loggedIn bool) { } } -func (mh *MetricsHandler) TrackConnectionState(jid whatsapp.JID, connected bool) { +func (mh *MetricsHandler) TrackConnectionState(jid types.JID, connected bool) { if !mh.running { return } - currentVal, ok := mh.connectedState[jid] + currentVal, ok := mh.connectedState[jid.User] if !ok || currentVal != connected { - mh.connectedState[jid] = connected + mh.connectedState[jid.User] = connected if connected { mh.connected.Inc() } else { @@ -206,28 +194,6 @@ func (mh *MetricsHandler) TrackConnectionState(jid whatsapp.JID, connected bool) } } -func (mh *MetricsHandler) TrackSyncLock(jid whatsapp.JID, locked bool) { - if !mh.running { - return - } - currentVal, ok := mh.syncLockedState[jid] - if !ok || currentVal != locked { - mh.syncLockedState[jid] = locked - if locked { - mh.syncLocked.Inc() - } else { - mh.syncLocked.Dec() - } - } -} - -func (mh *MetricsHandler) TrackBufferLength(id id.UserID, length int) { - if !mh.running { - return - } - mh.bufferLength.With(prometheus.Labels{"user_id": string(id)}).Set(float64(length)) -} - func (mh *MetricsHandler) updateStats() { start := time.Now() var puppetCount int diff --git a/portal.go b/portal.go index a28eb47..238f8f3 100644 --- a/portal.go +++ b/portal.go @@ -1,5 +1,5 @@ // mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge. -// Copyright (C) 2020 Tulir Asokan +// Copyright (C) 2021 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by @@ -18,8 +18,8 @@ package main import ( "bytes" + "context" "encoding/gob" - "encoding/hex" "errors" "fmt" "html" @@ -29,22 +29,23 @@ import ( "image/png" "io/ioutil" "math" - "math/rand" "mime" "net/http" "os" "os/exec" "path/filepath" - "reflect" "strconv" "strings" "sync" - "sync/atomic" "time" "golang.org/x/image/webp" - "github.com/Rhymen/go-whatsapp" - waProto "github.com/Rhymen/go-whatsapp/binary/proto" + "google.golang.org/protobuf/proto" + + "go.mau.fi/whatsmeow" + waProto "go.mau.fi/whatsmeow/binary/proto" + "go.mau.fi/whatsmeow/types" + "go.mau.fi/whatsmeow/types/events" log "maunium.net/go/maulogger/v2" @@ -52,7 +53,6 @@ import ( "maunium.net/go/mautrix/appservice" "maunium.net/go/mautrix/crypto/attachment" "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/format" "maunium.net/go/mautrix/id" "maunium.net/go/mautrix/pushrules" @@ -91,7 +91,7 @@ func (bridge *Bridge) GetAllPortals() []*Portal { return bridge.dbPortalsToPortals(bridge.DB.Portal.GetAll()) } -func (bridge *Bridge) GetAllPortalsByJID(jid whatsapp.JID) []*Portal { +func (bridge *Bridge) GetAllPortalsByJID(jid types.JID) []*Portal { return bridge.dbPortalsToPortals(bridge.DB.Portal.GetAllByJID(jid)) } @@ -139,8 +139,6 @@ func (bridge *Bridge) NewManualPortal(key database.PortalKey) *Portal { bridge: bridge, log: bridge.Log.Sub(fmt.Sprintf("Portal/%s", key)), - recentlyHandled: [recentlyHandledLength]whatsapp.MessageID{}, - messages: make(chan PortalMessage, bridge.Config.Bridge.PortalMessageBuffer), } portal.Key = key @@ -154,8 +152,6 @@ func (bridge *Bridge) NewPortal(dbPortal *database.Portal) *Portal { bridge: bridge, log: bridge.Log.Sub(fmt.Sprintf("Portal/%s", dbPortal.Key)), - recentlyHandled: [recentlyHandledLength]whatsapp.MessageID{}, - messages: make(chan PortalMessage, bridge.Config.Bridge.PortalMessageBuffer), } go portal.handleMessageLoop() @@ -165,10 +161,8 @@ func (bridge *Bridge) NewPortal(dbPortal *database.Portal) *Portal { const recentlyHandledLength = 100 type PortalMessage struct { - chat string - source *User - data interface{} - timestamp uint64 + evt *events.Message + source *User } type Portal struct { @@ -180,20 +174,14 @@ type Portal struct { roomCreateLock sync.Mutex encryptLock sync.Mutex - recentlyHandled [recentlyHandledLength]whatsapp.MessageID + recentlyHandled [recentlyHandledLength]types.MessageID recentlyHandledLock sync.Mutex recentlyHandledIndex uint8 - backfillLock sync.Mutex - backfilling bool - lastMessageTs uint64 - privateChatBackfillInvitePuppet func() messages chan PortalMessage - isPrivate *bool - isBroadcast *bool hasRelaybot *bool } @@ -204,26 +192,13 @@ func (portal *Portal) syncDoublePuppetDetailsAfterCreate(source *User) { if doublePuppet == nil { return } - source.Conn.Store.ChatsLock.RLock() - chat, ok := source.Conn.Store.Chats[portal.Key.JID] - source.Conn.Store.ChatsLock.RUnlock() - if !ok { - portal.log.Debugln("Not syncing chat mute/tags with %s: chat info not found", source.MXID) - return - } - source.syncChatDoublePuppetDetails(doublePuppet, Chat{ - Chat: chat, - Portal: portal, - }, true) + source.syncChatDoublePuppetDetails(doublePuppet, portal, true) } func (portal *Portal) handleMessageLoop() { for msg := range portal.messages { if len(portal.MXID) == 0 { - if msg.timestamp+MaxMessageAgeToCreatePortal < uint64(time.Now().Unix()) { - portal.log.Debugln("Not creating portal room for incoming message: message is too old") - continue - } else if !portal.shouldCreateRoom(msg) { + if !portal.shouldCreateRoom(msg) { portal.log.Debugln("Not creating portal room for incoming message: message is not a chat message") continue } @@ -235,20 +210,32 @@ func (portal *Portal) handleMessageLoop() { } portal.syncDoublePuppetDetailsAfterCreate(msg.source) } - portal.backfillLock.Lock() + //portal.backfillLock.Lock() portal.handleMessage(msg, false) - portal.backfillLock.Unlock() + //portal.backfillLock.Unlock() } } func (portal *Portal) shouldCreateRoom(msg PortalMessage) bool { - stubMsg, ok := msg.data.(whatsapp.StubMessage) - if ok { - // This could be more specific: if someone else was added, we might not care, - // but if the local user was added, we definitely care. - return stubMsg.Type == waProto.WebMessageInfo_GROUP_PARTICIPANT_ADD || stubMsg.Type == waProto.WebMessageInfo_GROUP_PARTICIPANT_INVITE + waMsg := msg.evt.Message + supportedMessages := []interface{}{ + waMsg.Conversation, + waMsg.ExtendedTextMessage, + waMsg.ImageMessage, + waMsg.StickerMessage, + waMsg.VideoMessage, + waMsg.AudioMessage, + waMsg.VideoMessage, + waMsg.DocumentMessage, + waMsg.ContactMessage, + waMsg.LocationMessage, } - return true + for _, message := range supportedMessages { + if message != nil { + return true + } + } + return false } func (portal *Portal) handleMessage(msg PortalMessage, isBackfill bool) { @@ -258,65 +245,48 @@ func (portal *Portal) handleMessage(msg PortalMessage, isBackfill bool) { } var triedToHandle bool var trackMessageCallback func() - dataType := reflect.TypeOf(msg.data) + var typeName string if !isBackfill { - trackMessageCallback = portal.bridge.Metrics.TrackWhatsAppMessage(msg.timestamp, dataType.Name()) + trackMessageCallback = portal.bridge.Metrics.TrackWhatsAppMessage(msg.evt.Info.Timestamp, typeName) } - switch data := msg.data.(type) { - case whatsapp.TextMessage: - triedToHandle = portal.HandleTextMessage(msg.source, data) - case whatsapp.ImageMessage: - triedToHandle = portal.HandleMediaMessage(msg.source, mediaMessage{ - base: base{data.Download, data.Info, data.ContextInfo, data.Type}, - thumbnail: data.Thumbnail, - caption: data.Caption, - }) - case whatsapp.StickerMessage: - triedToHandle = portal.HandleMediaMessage(msg.source, mediaMessage{ - base: base{data.Download, data.Info, data.ContextInfo, data.Type}, - sendAsSticker: true, - }) - case whatsapp.VideoMessage: - triedToHandle = portal.HandleMediaMessage(msg.source, mediaMessage{ - base: base{data.Download, data.Info, data.ContextInfo, data.Type}, - thumbnail: data.Thumbnail, - caption: data.Caption, - length: data.Length * 1000, - }) - case whatsapp.AudioMessage: - triedToHandle = portal.HandleMediaMessage(msg.source, mediaMessage{ - base: base{data.Download, data.Info, data.ContextInfo, data.Type}, - length: data.Length * 1000, - }) - case whatsapp.DocumentMessage: - fileName := data.FileName - if len(fileName) == 0 { - fileName = data.Title - } - triedToHandle = portal.HandleMediaMessage(msg.source, mediaMessage{ - base: base{data.Download, data.Info, data.ContextInfo, data.Type}, - thumbnail: data.Thumbnail, - fileName: fileName, - }) - case whatsapp.ContactMessage: - triedToHandle = portal.HandleContactMessage(msg.source, data) - case whatsapp.LocationMessage: - triedToHandle = portal.HandleLocationMessage(msg.source, data) - case whatsapp.StubMessage: - triedToHandle = portal.HandleStubMessage(msg.source, data, isBackfill) - case whatsapp.MessageRevocation: - triedToHandle = portal.HandleMessageRevoke(msg.source, data) - case FakeMessage: - triedToHandle = portal.HandleFakeMessage(msg.source, data) + waMsg := msg.evt.Message + switch { + case waMsg.Conversation != nil || waMsg.ExtendedTextMessage != nil: + typeName = "text" + triedToHandle = portal.HandleTextMessage(msg.source, msg.evt) + case waMsg.ImageMessage != nil: + typeName = "image" + triedToHandle = portal.HandleMediaMessage(msg.source, &msg.evt.Info, waMsg.GetImageMessage()) + case waMsg.StickerMessage != nil: + typeName = "sticker" + triedToHandle = portal.HandleMediaMessage(msg.source, &msg.evt.Info, waMsg.GetStickerMessage()) + case waMsg.VideoMessage != nil: + typeName = "video" + triedToHandle = portal.HandleMediaMessage(msg.source, &msg.evt.Info, waMsg.GetVideoMessage()) + case waMsg.AudioMessage != nil: + typeName = "audio" + triedToHandle = portal.HandleMediaMessage(msg.source, &msg.evt.Info, waMsg.GetAudioMessage()) + case waMsg.DocumentMessage != nil: + typeName = "document" + triedToHandle = portal.HandleMediaMessage(msg.source, &msg.evt.Info, waMsg.GetDocumentMessage()) + case waMsg.ContactMessage != nil: + typeName = "contact" + triedToHandle = portal.HandleContactMessage(msg.source, &msg.evt.Info, waMsg.GetContactMessage()) + case waMsg.LocationMessage != nil: + typeName = "location" + triedToHandle = portal.HandleLocationMessage(msg.source, &msg.evt.Info, waMsg.GetLocationMessage()) + case waMsg.GetProtocolMessage() != nil && waMsg.GetProtocolMessage().GetType() == waProto.ProtocolMessage_REVOKE: + typeName = "revoke" + triedToHandle = portal.HandleMessageRevoke(msg.source, waMsg.GetProtocolMessage().GetKey()) default: - portal.log.Warnln("Unknown message type:", dataType) + portal.log.Warnln("Unhandled message:", msg.evt.Info, msg.evt.Message) } if triedToHandle && trackMessageCallback != nil { trackMessageCallback() } } -func (portal *Portal) isRecentlyHandled(id whatsapp.MessageID) bool { +func (portal *Portal) isRecentlyHandled(id types.MessageID) bool { start := portal.recentlyHandledIndex for i := start; i != start; i = (i - 1) % recentlyHandledLength { if portal.recentlyHandled[i] == id { @@ -326,7 +296,7 @@ func (portal *Portal) isRecentlyHandled(id whatsapp.MessageID) bool { return false } -func (portal *Portal) isDuplicate(id whatsapp.MessageID) bool { +func (portal *Portal) isDuplicate(id types.MessageID) bool { msg := portal.bridge.DB.Message.GetByJID(portal.Key, id) if msg != nil { return true @@ -338,22 +308,13 @@ func init() { gob.Register(&waProto.Message{}) } -func (portal *Portal) markHandled(source *User, message *waProto.WebMessageInfo, mxid id.EventID, isSent bool) *database.Message { +func (portal *Portal) markHandled(source *User, info *types.MessageInfo, mxid id.EventID, isSent bool) *database.Message { msg := portal.bridge.DB.Message.New() msg.Chat = portal.Key - msg.JID = message.GetKey().GetId() + msg.JID = info.ID msg.MXID = mxid - msg.Timestamp = int64(message.GetMessageTimestamp()) - if message.GetKey().GetFromMe() { - msg.Sender = source.JID - } else if portal.IsPrivateChat() { - msg.Sender = portal.Key.JID - } else { - msg.Sender = message.GetKey().GetParticipant() - if len(msg.Sender) == 0 { - msg.Sender = message.GetParticipant() - } - } + msg.Timestamp = info.Timestamp.Unix() + msg.Sender = info.Sender msg.Sent = isSent msg.Insert() @@ -365,62 +326,41 @@ func (portal *Portal) markHandled(source *User, message *waProto.WebMessageInfo, return msg } -func (portal *Portal) getMessageIntent(user *User, info whatsapp.MessageInfo) *appservice.IntentAPI { - if info.FromMe { +func (portal *Portal) getMessageIntent(user *User, info *types.MessageInfo) *appservice.IntentAPI { + if info.IsFromMe { return portal.bridge.GetPuppetByJID(user.JID).IntentFor(portal) } else if portal.IsPrivateChat() { return portal.MainIntent() - } else if len(info.SenderJid) == 0 { - if len(info.Source.GetParticipant()) != 0 { - info.SenderJid = info.Source.GetParticipant() - } else { - return nil - } } - puppet := portal.bridge.GetPuppetByJID(info.SenderJid) - puppet.SyncContactIfNecessary(user) + puppet := portal.bridge.GetPuppetByJID(info.Sender) + puppet.SyncContact(user, true) return puppet.IntentFor(portal) } -func (portal *Portal) startHandling(source *User, info whatsapp.MessageInfo, msgType string) *appservice.IntentAPI { - // TODO these should all be trace logs - if portal.lastMessageTs == 0 { - portal.log.Debugln("Fetching last message from database to get its timestamp") - lastMessage := portal.bridge.DB.Message.GetLastInChat(portal.Key) - if lastMessage != nil { - atomic.CompareAndSwapUint64(&portal.lastMessageTs, 0, uint64(lastMessage.Timestamp)) - } - } - - // If there are messages slightly older than the last message, it's possible the order is just wrong, - // so don't short-circuit and check the database for duplicates. - const timestampIgnoreFuzziness = 5 * 60 - if portal.lastMessageTs > info.Timestamp+timestampIgnoreFuzziness { - portal.log.Debugfln("Not handling %s (%s): message is >5 minutes older (%d) than last bridge message (%d)", info.Id, msgType, info.Timestamp, portal.lastMessageTs) - } else if portal.isRecentlyHandled(info.Id) { - portal.log.Debugfln("Not handling %s (%s): message was recently handled", info.Id, msgType) - } else if portal.isDuplicate(info.Id) { - portal.log.Debugfln("Not handling %s (%s): message is duplicate", info.Id, msgType) +func (portal *Portal) startHandling(source *User, info *types.MessageInfo, msgType string) *appservice.IntentAPI { + if portal.isRecentlyHandled(info.ID) { + portal.log.Debugfln("Not handling %s (%s): message was recently handled", info.ID, msgType) + } else if portal.isDuplicate(info.ID) { + portal.log.Debugfln("Not handling %s (%s): message is duplicate", info.ID, msgType) } else { - portal.lastMessageTs = info.Timestamp intent := portal.getMessageIntent(source, info) if intent != nil { - portal.log.Debugfln("Starting handling of %s (%s, ts: %d)", info.Id, msgType, info.Timestamp) + portal.log.Debugfln("Starting handling of %s (%s, ts: %d)", info.ID, msgType, info.Timestamp) } else { - portal.log.Debugfln("Not handling %s (%s): sender is not known", info.Id, msgType) + portal.log.Debugfln("Not handling %s (%s): sender is not known", info.ID, msgType) } return intent } return nil } -func (portal *Portal) finishHandling(source *User, message *waProto.WebMessageInfo, mxid id.EventID) { +func (portal *Portal) finishHandling(source *User, message *types.MessageInfo, mxid id.EventID) { portal.markHandled(source, message, mxid, true) portal.sendDeliveryReceipt(mxid) - portal.log.Debugln("Handled message", message.GetKey().GetId(), "->", mxid) + portal.log.Debugln("Handled message", message.ID, "->", mxid) } -func (portal *Portal) kickExtraUsers(participantMap map[whatsapp.JID]bool) { +func (portal *Portal) kickExtraUsers(participantMap map[types.JID]bool) { members, err := portal.MainIntent().JoinedMembers(portal.MXID) if err != nil { portal.log.Warnln("Failed to get member list:", err) @@ -443,43 +383,43 @@ func (portal *Portal) kickExtraUsers(participantMap map[whatsapp.JID]bool) { } } -func (portal *Portal) SyncBroadcastRecipients(source *User, metadata *whatsapp.BroadcastListInfo) { - participantMap := make(map[whatsapp.JID]bool) - for _, recipient := range metadata.Recipients { - participantMap[recipient.JID] = true +//func (portal *Portal) SyncBroadcastRecipients(source *User, metadata *whatsapp.BroadcastListInfo) { +// participantMap := make(map[whatsapp.JID]bool) +// for _, recipient := range metadata.Recipients { +// participantMap[recipient.JID] = true +// +// puppet := portal.bridge.GetPuppetByJID(recipient.JID) +// puppet.SyncContactIfNecessary(source) +// err := puppet.DefaultIntent().EnsureJoined(portal.MXID) +// if err != nil { +// portal.log.Warnfln("Failed to make puppet of %s join %s: %v", recipient.JID, portal.MXID, err) +// } +// } +// portal.kickExtraUsers(participantMap) +//} - puppet := portal.bridge.GetPuppetByJID(recipient.JID) - puppet.SyncContactIfNecessary(source) - err := puppet.DefaultIntent().EnsureJoined(portal.MXID) - if err != nil { - portal.log.Warnfln("Failed to make puppet of %s join %s: %v", recipient.JID, portal.MXID, err) - } - } - portal.kickExtraUsers(participantMap) -} - -func (portal *Portal) SyncParticipants(source *User, metadata *whatsapp.GroupInfo) { +func (portal *Portal) SyncParticipants(source *User, metadata *types.GroupInfo) { changed := false levels, err := portal.MainIntent().PowerLevels(portal.MXID) if err != nil { levels = portal.GetBasePowerLevels() changed = true } - participantMap := make(map[whatsapp.JID]bool) + participantMap := make(map[types.JID]bool) for _, participant := range metadata.Participants { participantMap[participant.JID] = true user := portal.bridge.GetUserByJID(participant.JID) portal.userMXIDAction(user, portal.ensureMXIDInvited) puppet := portal.bridge.GetPuppetByJID(participant.JID) - puppet.SyncContactIfNecessary(source) + puppet.SyncContact(source, true) err = puppet.IntentFor(portal).EnsureJoined(portal.MXID) if err != nil { portal.log.Warnfln("Failed to make puppet of %s join %s: %v", participant.JID, portal.MXID, err) } expectedLevel := 0 - if participant.IsSuperAdmin { + if participant.JID == metadata.OwnerJID { expectedLevel = 95 } else if participant.IsAdmin { expectedLevel = 50 @@ -498,41 +438,31 @@ func (portal *Portal) SyncParticipants(source *User, metadata *whatsapp.GroupInf portal.kickExtraUsers(participantMap) } -func (portal *Portal) UpdateAvatar(user *User, avatar *whatsapp.ProfilePicInfo, updateInfo bool) bool { - if avatar == nil || (avatar.Status == 0 && avatar.Tag != "remove" && len(avatar.URL) == 0) { - var err error - avatar, err = user.Conn.GetProfilePicThumb(portal.Key.JID) - if err != nil { - portal.log.Errorln(err) - return false +func (portal *Portal) UpdateAvatar(user *User, updateInfo bool) bool { + avatar, err := user.Client.GetProfilePictureInfo(portal.Key.JID, false) + if err != nil { + if !errors.Is(err, whatsmeow.ErrProfilePictureUnauthorized) { + portal.log.Warnln("Failed to get avatar URL:", err) } - } - - if avatar.Status == 404 { - avatar.Tag = "remove" - avatar.Status = 0 - } - if avatar.Status != 0 || portal.Avatar == avatar.Tag { return false - } - - if avatar.Tag == "remove" { + } else if avatar == nil { + if portal.Avatar == "remove" { + return false + } portal.AvatarURL = id.ContentURI{} + avatar = &types.ProfilePictureInfo{ID: "remove"} + } else if avatar.ID == portal.Avatar { + return false + } else if len(avatar.URL) == 0 { + portal.log.Warnln("Didn't get URL in response to avatar query") + return false } else { - data, err := avatar.DownloadBytes() + url, err := reuploadAvatar(portal.MainIntent(), avatar.URL) if err != nil { - portal.log.Warnln("Failed to download avatar:", err) + portal.log.Warnln("Failed to reupload avatar:", err) return false } - - mimeType := http.DetectContentType(data) - resp, err := portal.MainIntent().UploadBytes(data, mimeType) - if err != nil { - portal.log.Warnln("Failed to upload avatar:", err) - return false - } - - portal.AvatarURL = resp.ContentURI + portal.AvatarURL = url } if len(portal.MXID) > 0 { @@ -542,14 +472,14 @@ func (portal *Portal) UpdateAvatar(user *User, avatar *whatsapp.ProfilePicInfo, return false } } - portal.Avatar = avatar.Tag + portal.Avatar = avatar.ID if updateInfo { portal.UpdateBridgeInfo() } return true } -func (portal *Portal) UpdateName(name string, setBy whatsapp.JID, intent *appservice.IntentAPI, updateInfo bool) bool { +func (portal *Portal) UpdateName(name string, setBy types.JID, intent *appservice.IntentAPI, updateInfo bool) bool { if name == "" && portal.IsBroadcastList() { name = UnnamedBroadcastName } @@ -558,7 +488,7 @@ func (portal *Portal) UpdateName(name string, setBy whatsapp.JID, intent *appser portal.Name = name if intent == nil { intent = portal.MainIntent() - if len(setBy) > 0 { + if !setBy.IsEmpty() { intent = portal.bridge.GetPuppetByJID(setBy).IntentFor(portal) } } @@ -576,13 +506,13 @@ func (portal *Portal) UpdateName(name string, setBy whatsapp.JID, intent *appser return false } -func (portal *Portal) UpdateTopic(topic string, setBy whatsapp.JID, intent *appservice.IntentAPI, updateInfo bool) bool { +func (portal *Portal) UpdateTopic(topic string, setBy types.JID, intent *appservice.IntentAPI, updateInfo bool) bool { if portal.Topic != topic { portal.log.Debugfln("Updating topic %s -> %s", portal.Topic, topic) portal.Topic = topic if intent == nil { intent = portal.MainIntent() - if len(setBy) > 0 { + if !setBy.IsEmpty() { intent = portal.bridge.GetPuppetByJID(setBy).IntentFor(portal) } } @@ -605,36 +535,27 @@ func (portal *Portal) UpdateMetadata(user *User) bool { return false } else if portal.IsStatusBroadcastList() { update := false - update = portal.UpdateName(StatusBroadcastName, "", nil, false) || update - update = portal.UpdateTopic(StatusBroadcastTopic, "", nil, false) || update + update = portal.UpdateName(StatusBroadcastName, types.EmptyJID, nil, false) || update + update = portal.UpdateTopic(StatusBroadcastTopic, types.EmptyJID, nil, false) || update return update } else if portal.IsBroadcastList() { update := false - broadcastMetadata, err := user.Conn.GetBroadcastMetadata(portal.Key.JID) - if err == nil && broadcastMetadata.Status == 200 { - portal.SyncBroadcastRecipients(user, broadcastMetadata) - update = portal.UpdateName(broadcastMetadata.Name, "", nil, false) || update - } else { - user.Conn.Store.ContactsLock.RLock() - contact, _ := user.Conn.Store.Contacts[portal.Key.JID] - user.Conn.Store.ContactsLock.RUnlock() - update = portal.UpdateName(contact.Name, "", nil, false) || update - } - update = portal.UpdateTopic(BroadcastTopic, "", nil, false) || update + //broadcastMetadata, err := user.Conn.GetBroadcastMetadata(portal.Key.JID) + //if err == nil && broadcastMetadata.Status == 200 { + // portal.SyncBroadcastRecipients(user, broadcastMetadata) + // update = portal.UpdateName(broadcastMetadata.Name, "", nil, false) || update + //} else { + // user.Conn.Store.ContactsLock.RLock() + // contact, _ := user.Conn.Store.Contacts[portal.Key.JID] + // user.Conn.Store.ContactsLock.RUnlock() + // update = portal.UpdateName(contact.Name, "", nil, false) || update + //} + //update = portal.UpdateTopic(BroadcastTopic, "", nil, false) || update return update } - metadata, err := user.Conn.GetGroupMetaData(portal.Key.JID) + metadata, err := user.Client.GetGroupInfo(portal.Key.JID) if err != nil { - portal.log.Errorln(err) - return false - } - if metadata.Status != 0 { - // 401: access denied - // 404: group does (no longer) exist - // 500: ??? happens with status@broadcast - - // TODO: update the room, e.g. change priority level - // to send messages to moderator + portal.log.Errorln("Failed to get group info:", err) return false } @@ -643,7 +564,8 @@ func (portal *Portal) UpdateMetadata(user *User) bool { update = portal.UpdateName(metadata.Name, metadata.NameSetBy, nil, false) || update update = portal.UpdateTopic(metadata.Topic, metadata.TopicSetBy, nil, false) || update - portal.RestrictMessageSending(metadata.Announce) + portal.RestrictMessageSending(metadata.IsAnnounce) + portal.RestrictMetadataChanges(metadata.IsLocked) return update } @@ -678,7 +600,7 @@ func (portal *Portal) ensureUserInvited(user *User) { inviteContent := event.Content{ Parsed: &event.MemberEventContent{ Membership: event.MembershipInvite, - IsDirect: portal.IsPrivateChat(), + IsDirect: portal.IsPrivateChat(), }, Raw: map[string]interface{}{}, } @@ -702,7 +624,7 @@ func (portal *Portal) ensureUserInvited(user *User) { } } -func (portal *Portal) Sync(user *User, contact whatsapp.Contact) bool { +func (portal *Portal) Sync(user *User) bool { portal.log.Infoln("Syncing portal for", user.MXID) if user.IsRelaybot { @@ -711,9 +633,6 @@ func (portal *Portal) Sync(user *User, contact whatsapp.Contact) bool { } if len(portal.MXID) == 0 { - if !portal.IsPrivateChat() { - portal.Name = contact.Name - } err := portal.CreateMatrixRoom(user) if err != nil { portal.log.Errorln("Failed to create portal room:", err) @@ -726,7 +645,7 @@ func (portal *Portal) Sync(user *User, contact whatsapp.Contact) bool { update := false update = portal.UpdateMetadata(user) || update if !portal.IsPrivateChat() && !portal.IsBroadcastList() && portal.Avatar == "" { - update = portal.UpdateAvatar(user, nil, false) || update + update = portal.UpdateAvatar(user, false) || update } if update { portal.Update() @@ -760,35 +679,35 @@ func (portal *Portal) GetBasePowerLevels() *event.PowerLevelsEventContent { } } -func (portal *Portal) ChangeAdminStatus(jids []string, setAdmin bool) id.EventID { - levels, err := portal.MainIntent().PowerLevels(portal.MXID) - if err != nil { - levels = portal.GetBasePowerLevels() - } - newLevel := 0 - if setAdmin { - newLevel = 50 - } - changed := false - for _, jid := range jids { - puppet := portal.bridge.GetPuppetByJID(jid) - changed = levels.EnsureUserLevel(puppet.MXID, newLevel) || changed - - user := portal.bridge.GetUserByJID(jid) - if user != nil { - changed = levels.EnsureUserLevel(user.MXID, newLevel) || changed - } - } - if changed { - resp, err := portal.MainIntent().SetPowerLevels(portal.MXID, levels) - if err != nil { - portal.log.Errorln("Failed to change power levels:", err) - } else { - return resp.EventID - } - } - return "" -} +//func (portal *Portal) ChangeAdminStatus(jids []string, setAdmin bool) id.EventID { +// levels, err := portal.MainIntent().PowerLevels(portal.MXID) +// if err != nil { +// levels = portal.GetBasePowerLevels() +// } +// newLevel := 0 +// if setAdmin { +// newLevel = 50 +// } +// changed := false +// for _, jid := range jids { +// puppet := portal.bridge.GetPuppetByJID(jid) +// changed = levels.EnsureUserLevel(puppet.MXID, newLevel) || changed +// +// user := portal.bridge.GetUserByJID(jid) +// if user != nil { +// changed = levels.EnsureUserLevel(user.MXID, newLevel) || changed +// } +// } +// if changed { +// resp, err := portal.MainIntent().SetPowerLevels(portal.MXID, levels) +// if err != nil { +// portal.log.Errorln("Failed to change power levels:", err) +// } else { +// return resp.EventID +// } +// } +// return "" +//} func (portal *Portal) RestrictMessageSending(restrict bool) id.EventID { levels, err := portal.MainIntent().PowerLevels(portal.MXID) @@ -839,79 +758,79 @@ func (portal *Portal) RestrictMetadataChanges(restrict bool) id.EventID { return "" } -func (portal *Portal) BackfillHistory(user *User, lastMessageTime int64) error { - if !portal.bridge.Config.Bridge.RecoverHistory { - return nil - } - - endBackfill := portal.beginBackfill() - defer endBackfill() - - lastMessage := portal.bridge.DB.Message.GetLastInChat(portal.Key) - if lastMessage == nil { - return nil - } - if lastMessage.Timestamp >= lastMessageTime { - portal.log.Debugln("Not backfilling: no new messages") - return nil - } - - lastMessageID := lastMessage.JID - lastMessageFromMe := lastMessage.Sender == user.JID - portal.log.Infoln("Backfilling history since", lastMessageID, "for", user.MXID) - for len(lastMessageID) > 0 { - portal.log.Debugln("Fetching 50 messages of history after", lastMessageID) - resp, err := user.Conn.LoadMessagesAfter(portal.Key.JID, lastMessageID, lastMessageFromMe, 50) - if err == whatsapp.ErrServerRespondedWith404 { - portal.log.Warnln("Got 404 response trying to fetch messages to backfill. Fetching latest messages as fallback.") - resp, err = user.Conn.LoadMessagesBefore(portal.Key.JID, "", true, 50) - } - if err != nil { - return err - } - messages, ok := resp.Content.([]interface{}) - if !ok || len(messages) == 0 { - portal.log.Debugfln("Didn't get more messages to backfill (resp.Content is %T)", resp.Content) - break - } - - portal.handleHistory(user, messages) - - lastMessageProto, ok := messages[len(messages)-1].(*waProto.WebMessageInfo) - if ok { - lastMessageID = lastMessageProto.GetKey().GetId() - lastMessageFromMe = lastMessageProto.GetKey().GetFromMe() - } - } - portal.log.Infoln("Backfilling finished") - return nil -} - -func (portal *Portal) beginBackfill() func() { - portal.backfillLock.Lock() - portal.backfilling = true - var privateChatPuppetInvited bool - var privateChatPuppet *Puppet - if portal.IsPrivateChat() && portal.bridge.Config.Bridge.InviteOwnPuppetForBackfilling && portal.Key.JID != portal.Key.Receiver { - privateChatPuppet = portal.bridge.GetPuppetByJID(portal.Key.Receiver) - portal.privateChatBackfillInvitePuppet = func() { - if privateChatPuppetInvited { - return - } - privateChatPuppetInvited = true - _, _ = portal.MainIntent().InviteUser(portal.MXID, &mautrix.ReqInviteUser{UserID: privateChatPuppet.MXID}) - _ = privateChatPuppet.DefaultIntent().EnsureJoined(portal.MXID) - } - } - return func() { - portal.backfilling = false - portal.privateChatBackfillInvitePuppet = nil - portal.backfillLock.Unlock() - if privateChatPuppet != nil && privateChatPuppetInvited { - _, _ = privateChatPuppet.DefaultIntent().LeaveRoom(portal.MXID) - } - } -} +//func (portal *Portal) BackfillHistory(user *User, lastMessageTime int64) error { +// if !portal.bridge.Config.Bridge.RecoverHistory { +// return nil +// } +// +// endBackfill := portal.beginBackfill() +// defer endBackfill() +// +// lastMessage := portal.bridge.DB.Message.GetLastInChat(portal.Key) +// if lastMessage == nil { +// return nil +// } +// if lastMessage.Timestamp >= lastMessageTime { +// portal.log.Debugln("Not backfilling: no new messages") +// return nil +// } +// +// lastMessageID := lastMessage.JID +// lastMessageFromMe := lastMessage.Sender == user.JID +// portal.log.Infoln("Backfilling history since", lastMessageID, "for", user.MXID) +// for len(lastMessageID) > 0 { +// portal.log.Debugln("Fetching 50 messages of history after", lastMessageID) +// resp, err := user.Conn.LoadMessagesAfter(portal.Key.JID, lastMessageID, lastMessageFromMe, 50) +// if err == whatsapp.ErrServerRespondedWith404 { +// portal.log.Warnln("Got 404 response trying to fetch messages to backfill. Fetching latest messages as fallback.") +// resp, err = user.Conn.LoadMessagesBefore(portal.Key.JID, "", true, 50) +// } +// if err != nil { +// return err +// } +// messages, ok := resp.Content.([]interface{}) +// if !ok || len(messages) == 0 { +// portal.log.Debugfln("Didn't get more messages to backfill (resp.Content is %T)", resp.Content) +// break +// } +// +// portal.handleHistory(user, messages) +// +// lastMessageProto, ok := messages[len(messages)-1].(*waProto.WebMessageInfo) +// if ok { +// lastMessageID = lastMessageProto.GetKey().GetId() +// lastMessageFromMe = lastMessageProto.GetKey().GetFromMe() +// } +// } +// portal.log.Infoln("Backfilling finished") +// return nil +//} +// +//func (portal *Portal) beginBackfill() func() { +// portal.backfillLock.Lock() +// portal.backfilling = true +// var privateChatPuppetInvited bool +// var privateChatPuppet *Puppet +// if portal.IsPrivateChat() && portal.bridge.Config.Bridge.InviteOwnPuppetForBackfilling && portal.Key.JID != portal.Key.Receiver { +// privateChatPuppet = portal.bridge.GetPuppetByJID(portal.Key.Receiver) +// portal.privateChatBackfillInvitePuppet = func() { +// if privateChatPuppetInvited { +// return +// } +// privateChatPuppetInvited = true +// _, _ = portal.MainIntent().InviteUser(portal.MXID, &mautrix.ReqInviteUser{UserID: privateChatPuppet.MXID}) +// _ = privateChatPuppet.DefaultIntent().EnsureJoined(portal.MXID) +// } +// } +// return func() { +// portal.backfilling = false +// portal.privateChatBackfillInvitePuppet = nil +// portal.backfillLock.Unlock() +// if privateChatPuppet != nil && privateChatPuppetInvited { +// _, _ = privateChatPuppet.DefaultIntent().LeaveRoom(portal.MXID) +// } +// } +//} func (portal *Portal) disableNotifications(user *User) { if !portal.bridge.Config.Bridge.HistoryDisableNotifs { @@ -952,84 +871,84 @@ func (portal *Portal) enableNotifications(user *User) { } } -func (portal *Portal) FillInitialHistory(user *User) error { - if portal.bridge.Config.Bridge.InitialHistoryFill == 0 { - return nil - } - endBackfill := portal.beginBackfill() - defer endBackfill() - if portal.privateChatBackfillInvitePuppet != nil { - portal.privateChatBackfillInvitePuppet() - } - - n := portal.bridge.Config.Bridge.InitialHistoryFill - portal.log.Infoln("Filling initial history, maximum", n, "messages") - var messages []interface{} - before := "" - fromMe := true - chunkNum := 0 - for n > 0 { - chunkNum += 1 - count := 50 - if n < count { - count = n - } - portal.log.Debugfln("Fetching chunk %d (%d messages / %d cap) before message %s", chunkNum, count, n, before) - resp, err := user.Conn.LoadMessagesBefore(portal.Key.JID, before, fromMe, count) - if err != nil { - return err - } - chunk, ok := resp.Content.([]interface{}) - if !ok || len(chunk) == 0 { - portal.log.Infoln("Chunk empty, starting handling of loaded messages") - break - } - - messages = append(chunk, messages...) - - portal.log.Debugfln("Fetched chunk and received %d messages", len(chunk)) - - n -= len(chunk) - key := chunk[0].(*waProto.WebMessageInfo).GetKey() - before = key.GetId() - fromMe = key.GetFromMe() - if len(before) == 0 { - portal.log.Infoln("No message ID for first message, starting handling of loaded messages") - break - } - } - portal.disableNotifications(user) - portal.handleHistory(user, messages) - portal.enableNotifications(user) - portal.log.Infoln("Initial history fill complete") - return nil -} - -func (portal *Portal) handleHistory(user *User, messages []interface{}) { - portal.log.Infoln("Handling", len(messages), "messages of history") - for _, rawMessage := range messages { - message, ok := rawMessage.(*waProto.WebMessageInfo) - if !ok { - portal.log.Warnln("Unexpected non-WebMessageInfo item in history response:", rawMessage) - continue - } - data := whatsapp.ParseProtoMessage(message) - if data == nil || data == whatsapp.ErrMessageTypeNotImplemented { - st := message.GetMessageStubType() - // Ignore some types that are known to fail - if st == waProto.WebMessageInfo_CALL_MISSED_VOICE || st == waProto.WebMessageInfo_CALL_MISSED_VIDEO || - st == waProto.WebMessageInfo_CALL_MISSED_GROUP_VOICE || st == waProto.WebMessageInfo_CALL_MISSED_GROUP_VIDEO { - continue - } - portal.log.Warnln("Message", message.GetKey().GetId(), "failed to parse during backfilling") - continue - } - if portal.privateChatBackfillInvitePuppet != nil && message.GetKey().GetFromMe() && portal.IsPrivateChat() { - portal.privateChatBackfillInvitePuppet() - } - portal.handleMessage(PortalMessage{portal.Key.JID, user, data, message.GetMessageTimestamp()}, true) - } -} +//func (portal *Portal) FillInitialHistory(user *User) error { +// if portal.bridge.Config.Bridge.InitialHistoryFill == 0 { +// return nil +// } +// endBackfill := portal.beginBackfill() +// defer endBackfill() +// if portal.privateChatBackfillInvitePuppet != nil { +// portal.privateChatBackfillInvitePuppet() +// } +// +// n := portal.bridge.Config.Bridge.InitialHistoryFill +// portal.log.Infoln("Filling initial history, maximum", n, "messages") +// var messages []interface{} +// before := "" +// fromMe := true +// chunkNum := 0 +// for n > 0 { +// chunkNum += 1 +// count := 50 +// if n < count { +// count = n +// } +// portal.log.Debugfln("Fetching chunk %d (%d messages / %d cap) before message %s", chunkNum, count, n, before) +// resp, err := user.Conn.LoadMessagesBefore(portal.Key.JID, before, fromMe, count) +// if err != nil { +// return err +// } +// chunk, ok := resp.Content.([]interface{}) +// if !ok || len(chunk) == 0 { +// portal.log.Infoln("Chunk empty, starting handling of loaded messages") +// break +// } +// +// messages = append(chunk, messages...) +// +// portal.log.Debugfln("Fetched chunk and received %d messages", len(chunk)) +// +// n -= len(chunk) +// key := chunk[0].(*waProto.WebMessageInfo).GetKey() +// before = key.GetId() +// fromMe = key.GetFromMe() +// if len(before) == 0 { +// portal.log.Infoln("No message ID for first message, starting handling of loaded messages") +// break +// } +// } +// portal.disableNotifications(user) +// portal.handleHistory(user, messages) +// portal.enableNotifications(user) +// portal.log.Infoln("Initial history fill complete") +// return nil +//} +// +//func (portal *Portal) handleHistory(user *User, messages []interface{}) { +// portal.log.Infoln("Handling", len(messages), "messages of history") +// for _, rawMessage := range messages { +// message, ok := rawMessage.(*waProto.WebMessageInfo) +// if !ok { +// portal.log.Warnln("Unexpected non-WebMessageInfo item in history response:", rawMessage) +// continue +// } +// data := whatsapp.ParseProtoMessage(message) +// if data == nil || data == whatsapp.ErrMessageTypeNotImplemented { +// st := message.GetMessageStubType() +// // Ignore some types that are known to fail +// if st == waProto.WebMessageInfo_CALL_MISSED_VOICE || st == waProto.WebMessageInfo_CALL_MISSED_VIDEO || +// st == waProto.WebMessageInfo_CALL_MISSED_GROUP_VOICE || st == waProto.WebMessageInfo_CALL_MISSED_GROUP_VIDEO { +// continue +// } +// portal.log.Warnln("Message", message.GetKey().GetId(), "failed to parse during backfilling") +// continue +// } +// if portal.privateChatBackfillInvitePuppet != nil && message.GetKey().GetFromMe() && portal.IsPrivateChat() { +// portal.privateChatBackfillInvitePuppet() +// } +// portal.handleMessage(PortalMessage{portal.Key.JID, user, data, message.GetMessageTimestamp()}, true) +// } +//} type BridgeInfoSection struct { ID string `json:"id"` @@ -1062,7 +981,7 @@ func (portal *Portal) getBridgeInfo() (string, BridgeInfoContent) { ExternalURL: "https://www.whatsapp.com/", }, Channel: BridgeInfoSection{ - ID: portal.Key.JID, + ID: portal.Key.JID.String(), DisplayName: portal.Name, AvatarURL: portal.AvatarURL.CUString(), }, @@ -1102,11 +1021,11 @@ func (portal *Portal) CreateMatrixRoom(user *User) error { portal.log.Infoln("Creating Matrix room. Info source:", user.MXID) - var metadata *whatsapp.GroupInfo - var broadcastMetadata *whatsapp.BroadcastListInfo + var metadata *types.GroupInfo + //var broadcastMetadata *types.BroadcastListInfo if portal.IsPrivateChat() { puppet := portal.bridge.GetPuppetByJID(portal.Key.JID) - puppet.SyncContactIfNecessary(user) + puppet.SyncContact(user, true) if portal.bridge.Config.Bridge.PrivateChatPortalMeta { portal.Name = puppet.Displayname portal.AvatarURL = puppet.AvatarURL @@ -1123,28 +1042,30 @@ func (portal *Portal) CreateMatrixRoom(user *User) error { portal.Name = StatusBroadcastName portal.Topic = StatusBroadcastTopic } else if portal.IsBroadcastList() { - var err error - broadcastMetadata, err = user.Conn.GetBroadcastMetadata(portal.Key.JID) - if err == nil && broadcastMetadata.Status == 200 { - portal.Name = broadcastMetadata.Name - } else { - user.Conn.Store.ContactsLock.RLock() - contact, _ := user.Conn.Store.Contacts[portal.Key.JID] - user.Conn.Store.ContactsLock.RUnlock() - portal.Name = contact.Name - } - if len(portal.Name) == 0 { - portal.Name = UnnamedBroadcastName - } - portal.Topic = BroadcastTopic + //var err error + //broadcastMetadata, err = user.Conn.GetBroadcastMetadata(portal.Key.JID) + //if err == nil && broadcastMetadata.Status == 200 { + // portal.Name = broadcastMetadata.Name + //} else { + // user.Conn.Store.ContactsLock.RLock() + // contact, _ := user.Conn.Store.Contacts[portal.Key.JID] + // user.Conn.Store.ContactsLock.RUnlock() + // portal.Name = contact.Name + //} + //if len(portal.Name) == 0 { + // portal.Name = UnnamedBroadcastName + //} + //portal.Topic = BroadcastTopic + portal.log.Debugln("Broadcast list is not yet supported, not creating room after all") + return fmt.Errorf("broadcast list bridging is currently not supported") } else { var err error - metadata, err = user.Conn.GetGroupMetaData(portal.Key.JID) - if err == nil && metadata.Status == 0 { + metadata, err = user.Client.GetGroupInfo(portal.Key.JID) + if err == nil { portal.Name = metadata.Name portal.Topic = metadata.Topic } - portal.UpdateAvatar(user, nil, false) + portal.UpdateAvatar(user, false) } bridgeInfoStateKey, bridgeInfo := portal.getBridgeInfo() @@ -1218,17 +1139,18 @@ func (portal *Portal) CreateMatrixRoom(user *User) error { if metadata != nil { portal.SyncParticipants(user, metadata) - if metadata.Announce { - portal.RestrictMessageSending(metadata.Announce) + if metadata.IsAnnounce { + portal.RestrictMessageSending(metadata.IsAnnounce) + } + if metadata.IsLocked { + portal.RestrictMetadataChanges(metadata.IsLocked) } } - if broadcastMetadata != nil { - portal.SyncBroadcastRecipients(user, broadcastMetadata) - } - inCommunity := user.addPortalToCommunity(portal) + //if broadcastMetadata != nil { + // portal.SyncBroadcastRecipients(user, broadcastMetadata) + //} if portal.IsPrivateChat() && !user.IsRelaybot { puppet := user.bridge.GetPuppetByJID(portal.Key.JID) - user.addPuppetToCommunity(puppet) if portal.bridge.Config.Bridge.Encryption.Default { err = portal.bridge.Bot.EnsureJoined(portal.MXID) @@ -1240,40 +1162,34 @@ func (portal *Portal) CreateMatrixRoom(user *User) error { user.UpdateDirectChats(map[id.UserID][]id.RoomID{puppet.MXID: {portal.MXID}}) } - user.CreateUserPortal(database.PortalKeyWithMeta{PortalKey: portal.Key, InCommunity: inCommunity}) + //user.CreateUserPortal(database.PortalKeyWithMeta{PortalKey: portal.Key, InCommunity: inCommunity}) - err = portal.FillInitialHistory(user) - if err != nil { - portal.log.Errorln("Failed to fill history:", err) - } + //err = portal.FillInitialHistory(user) + //if err != nil { + // portal.log.Errorln("Failed to fill history:", err) + //} return nil } func (portal *Portal) IsPrivateChat() bool { - if portal.isPrivate == nil { - val := strings.HasSuffix(portal.Key.JID, whatsapp.NewUserSuffix) - portal.isPrivate = &val - } - return *portal.isPrivate + return portal.Key.JID.Server == types.DefaultUserServer } func (portal *Portal) IsBroadcastList() bool { - if portal.isBroadcast == nil { - val := strings.HasSuffix(portal.Key.JID, whatsapp.BroadcastSuffix) - portal.isBroadcast = &val - } - return *portal.isBroadcast + return portal.Key.JID.Server == types.BroadcastServer } func (portal *Portal) IsStatusBroadcastList() bool { - return portal.Key.JID == "status@broadcast" + return portal.Key.JID == types.StatusBroadcastJID } func (portal *Portal) HasRelaybot() bool { if portal.bridge.Relaybot == nil { return false } else if portal.hasRelaybot == nil { - val := portal.bridge.Relaybot.IsInPortal(portal.Key) + // FIXME + //val := portal.bridge.Relaybot.IsInPortal(portal.Key) + val := true portal.hasRelaybot = &val } return *portal.hasRelaybot @@ -1286,11 +1202,11 @@ func (portal *Portal) MainIntent() *appservice.IntentAPI { return portal.bridge.Bot } -func (portal *Portal) SetReply(content *event.MessageEventContent, info whatsapp.ContextInfo) { - if len(info.QuotedMessageID) == 0 { +func (portal *Portal) SetReply(content *event.MessageEventContent, replyToID types.MessageID) { + if len(replyToID) == 0 { return } - message := portal.bridge.DB.Message.GetByJID(portal.Key, info.QuotedMessageID) + message := portal.bridge.DB.Message.GetByJID(portal.Key, replyToID) if message != nil && !message.IsFakeMXID() { evt, err := portal.MainIntent().GetEvent(portal.MXID, message.MXID) if err != nil { @@ -1312,20 +1228,24 @@ func (portal *Portal) SetReply(content *event.MessageEventContent, info whatsapp return } -func (portal *Portal) HandleMessageRevoke(user *User, message whatsapp.MessageRevocation) bool { - msg := portal.bridge.DB.Message.GetByJID(portal.Key, message.Id) +func (portal *Portal) HandleMessageRevoke(user *User, key *waProto.MessageKey) bool { + msg := portal.bridge.DB.Message.GetByJID(portal.Key, key.GetId()) if msg == nil || msg.IsFakeMXID() { return false } var intent *appservice.IntentAPI - if message.FromMe { + if key.GetFromMe() { if portal.IsPrivateChat() { intent = portal.bridge.GetPuppetByJID(user.JID).CustomIntent() } else { intent = portal.bridge.GetPuppetByJID(user.JID).IntentFor(portal) } - } else if len(message.Participant) > 0 { - intent = portal.bridge.GetPuppetByJID(message.Participant).IntentFor(portal) + } else if len(key.GetParticipant()) > 0 { + jid, err := types.ParseJID(key.GetParticipant()) + if err != nil { + return false + } + intent = portal.bridge.GetPuppetByJID(jid).IntentFor(portal) } if intent == nil { intent = portal.MainIntent() @@ -1339,31 +1259,31 @@ func (portal *Portal) HandleMessageRevoke(user *User, message whatsapp.MessageRe return true } -func (portal *Portal) HandleFakeMessage(_ *User, message FakeMessage) bool { - if portal.isRecentlyHandled(message.ID) { - return false - } - - content := event.MessageEventContent{ - MsgType: event.MsgNotice, - Body: message.Text, - } - if message.Alert { - content.MsgType = event.MsgText - } - _, err := portal.sendMainIntentMessage(content) - if err != nil { - portal.log.Errorfln("Failed to handle fake message %s: %v", message.ID, err) - return true - } - - portal.recentlyHandledLock.Lock() - index := portal.recentlyHandledIndex - portal.recentlyHandledIndex = (portal.recentlyHandledIndex + 1) % recentlyHandledLength - portal.recentlyHandledLock.Unlock() - portal.recentlyHandled[index] = message.ID - return true -} +//func (portal *Portal) HandleFakeMessage(_ *User, message FakeMessage) bool { +// if portal.isRecentlyHandled(message.ID) { +// return false +// } +// +// content := event.MessageEventContent{ +// MsgType: event.MsgNotice, +// Body: message.Text, +// } +// if message.Alert { +// content.MsgType = event.MsgText +// } +// _, err := portal.sendMainIntentMessage(content) +// if err != nil { +// portal.log.Errorfln("Failed to handle fake message %s: %v", message.ID, err) +// return true +// } +// +// portal.recentlyHandledLock.Lock() +// index := portal.recentlyHandledIndex +// portal.recentlyHandledIndex = (portal.recentlyHandledIndex + 1) % recentlyHandledLength +// portal.recentlyHandledLock.Unlock() +// portal.recentlyHandled[index] = message.ID +// return true +//} func (portal *Portal) sendMainIntentMessage(content interface{}) (*mautrix.RespSendEvent, error) { return portal.sendMessage(portal.MainIntent(), event.EventMessage, content, 0) @@ -1395,119 +1315,125 @@ func (portal *Portal) sendMessage(intent *appservice.IntentAPI, eventType event. } } -func (portal *Portal) HandleTextMessage(source *User, message whatsapp.TextMessage) bool { - intent := portal.startHandling(source, message.Info, "text") +func (portal *Portal) HandleTextMessage(source *User, msg *events.Message) bool { + intent := portal.startHandling(source, &msg.Info, "text") if intent == nil { return false } content := &event.MessageEventContent{ - Body: message.Text, + Body: msg.Message.GetConversation(), MsgType: event.MsgText, } + if msg.Message.GetExtendedTextMessage() != nil { + content.Body = msg.Message.GetExtendedTextMessage().GetText() - portal.bridge.Formatter.ParseWhatsApp(content, message.ContextInfo.MentionedJID) - portal.SetReply(content, message.ContextInfo) - - resp, err := portal.sendMessage(intent, event.EventMessage, content, int64(message.Info.Timestamp*1000)) - if err != nil { - portal.log.Errorfln("Failed to handle message %s: %v", message.Info.Id, err) - } else { - portal.finishHandling(source, message.Info.Source, resp.EventID) - } - return true -} - -func (portal *Portal) HandleStubMessage(source *User, message whatsapp.StubMessage, isBackfill bool) bool { - if portal.bridge.Config.Bridge.ChatMetaSync && (!portal.IsBroadcastList() || isBackfill) { - // Chat meta sync is enabled, so we use chat update commands and full-syncs instead of message history - // However, broadcast lists don't have update commands, so we handle these if it's not a backfill - return false - } - intent := portal.startHandling(source, message.Info, fmt.Sprintf("stub %s", message.Type.String())) - if intent == nil { - return false - } - var senderJID string - if message.Info.FromMe { - senderJID = source.JID - } else { - senderJID = message.Info.SenderJid - } - var eventID id.EventID - // TODO find more real event IDs - // TODO timestamp massaging - switch message.Type { - case waProto.WebMessageInfo_GROUP_CHANGE_SUBJECT: - portal.UpdateName(message.FirstParam, "", intent, true) - case waProto.WebMessageInfo_GROUP_CHANGE_ICON: - portal.UpdateAvatar(source, nil, true) - case waProto.WebMessageInfo_GROUP_CHANGE_DESCRIPTION: - if isBackfill { - // TODO fetch topic from server + contextInfo := msg.Message.GetExtendedTextMessage().GetContextInfo() + if contextInfo != nil { + portal.bridge.Formatter.ParseWhatsApp(content, contextInfo.GetMentionedJid()) + portal.SetReply(content, contextInfo.GetStanzaId()) } - //portal.UpdateTopic(message.FirstParam, "", intent, true) - case waProto.WebMessageInfo_GROUP_CHANGE_ANNOUNCE: - eventID = portal.RestrictMessageSending(message.FirstParam == "on") - case waProto.WebMessageInfo_GROUP_CHANGE_RESTRICT: - eventID = portal.RestrictMetadataChanges(message.FirstParam == "on") - case waProto.WebMessageInfo_GROUP_PARTICIPANT_ADD, waProto.WebMessageInfo_GROUP_PARTICIPANT_INVITE, waProto.WebMessageInfo_BROADCAST_ADD: - eventID = portal.HandleWhatsAppInvite(source, senderJID, intent, message.Params) - case waProto.WebMessageInfo_GROUP_PARTICIPANT_REMOVE, waProto.WebMessageInfo_GROUP_PARTICIPANT_LEAVE, waProto.WebMessageInfo_BROADCAST_REMOVE: - portal.HandleWhatsAppKick(source, senderJID, message.Params) - case waProto.WebMessageInfo_GROUP_PARTICIPANT_PROMOTE: - eventID = portal.ChangeAdminStatus(message.Params, true) - case waProto.WebMessageInfo_GROUP_PARTICIPANT_DEMOTE: - eventID = portal.ChangeAdminStatus(message.Params, false) - default: - return false } - if len(eventID) == 0 { - eventID = id.EventID(fmt.Sprintf("net.maunium.whatsapp.fake::%s", message.Info.Id)) + + resp, err := portal.sendMessage(intent, event.EventMessage, content, msg.Info.Timestamp.Unix()*1000) + if err != nil { + portal.log.Errorfln("Failed to handle message %s: %v", msg.Info.ID, err) + } else { + portal.finishHandling(source, &msg.Info, resp.EventID) } - portal.markHandled(source, message.Info.Source, eventID, true) return true } -func (portal *Portal) HandleLocationMessage(source *User, message whatsapp.LocationMessage) bool { - intent := portal.startHandling(source, message.Info, "location") +//func (portal *Portal) HandleStubMessage(source *User, message whatsapp.StubMessage, isBackfill bool) bool { +// if portal.bridge.Config.Bridge.ChatMetaSync && (!portal.IsBroadcastList() || isBackfill) { +// // Chat meta sync is enabled, so we use chat update commands and full-syncs instead of message history +// // However, broadcast lists don't have update commands, so we handle these if it's not a backfill +// return false +// } +// intent := portal.startHandling(source, message.Info, fmt.Sprintf("stub %s", message.Type.String())) +// if intent == nil { +// return false +// } +// var senderJID string +// if message.Info.FromMe { +// senderJID = source.JID +// } else { +// senderJID = message.Info.SenderJid +// } +// var eventID id.EventID +// // TODO find more real event IDs +// // TODO timestamp massaging +// switch message.Type { +// case waProto.WebMessageInfo_GROUP_CHANGE_SUBJECT: +// portal.UpdateName(message.FirstParam, "", intent, true) +// case waProto.WebMessageInfo_GROUP_CHANGE_ICON: +// portal.UpdateAvatar(source, nil, true) +// case waProto.WebMessageInfo_GROUP_CHANGE_DESCRIPTION: +// if isBackfill { +// // TODO fetch topic from server +// } +// //portal.UpdateTopic(message.FirstParam, "", intent, true) +// case waProto.WebMessageInfo_GROUP_CHANGE_ANNOUNCE: +// eventID = portal.RestrictMessageSending(message.FirstParam == "on") +// case waProto.WebMessageInfo_GROUP_CHANGE_RESTRICT: +// eventID = portal.RestrictMetadataChanges(message.FirstParam == "on") +// case waProto.WebMessageInfo_GROUP_PARTICIPANT_ADD, waProto.WebMessageInfo_GROUP_PARTICIPANT_INVITE, waProto.WebMessageInfo_BROADCAST_ADD: +// eventID = portal.HandleWhatsAppInvite(source, senderJID, intent, message.Params) +// case waProto.WebMessageInfo_GROUP_PARTICIPANT_REMOVE, waProto.WebMessageInfo_GROUP_PARTICIPANT_LEAVE, waProto.WebMessageInfo_BROADCAST_REMOVE: +// portal.HandleWhatsAppKick(source, senderJID, message.Params) +// case waProto.WebMessageInfo_GROUP_PARTICIPANT_PROMOTE: +// eventID = portal.ChangeAdminStatus(message.Params, true) +// case waProto.WebMessageInfo_GROUP_PARTICIPANT_DEMOTE: +// eventID = portal.ChangeAdminStatus(message.Params, false) +// default: +// return false +// } +// if len(eventID) == 0 { +// eventID = id.EventID(fmt.Sprintf("net.maunium.whatsapp.fake::%s", message.Info.Id)) +// } +// portal.markHandled(source, message.Info.Source, eventID, true) +// return true +//} + +func (portal *Portal) HandleLocationMessage(source *User, info *types.MessageInfo, msg *waProto.LocationMessage) bool { + intent := portal.startHandling(source, info, "location") if intent == nil { return false } - url := message.Url + url := msg.GetUrl() if len(url) == 0 { - url = fmt.Sprintf("https://maps.google.com/?q=%.5f,%.5f", message.DegreesLatitude, message.DegreesLongitude) + url = fmt.Sprintf("https://maps.google.com/?q=%.5f,%.5f", msg.GetDegreesLatitude(), msg.GetDegreesLongitude()) } - name := message.Name + name := msg.GetName() if len(name) == 0 { latChar := 'N' - if message.DegreesLatitude < 0 { + if msg.GetDegreesLatitude() < 0 { latChar = 'S' } longChar := 'E' - if message.DegreesLongitude < 0 { + if msg.GetDegreesLongitude() < 0 { longChar = 'W' } - name = fmt.Sprintf("%.4f° %c %.4f° %c", math.Abs(message.DegreesLatitude), latChar, math.Abs(message.DegreesLongitude), longChar) + name = fmt.Sprintf("%.4f° %c %.4f° %c", math.Abs(msg.GetDegreesLatitude()), latChar, math.Abs(msg.GetDegreesLongitude()), longChar) } content := &event.MessageEventContent{ MsgType: event.MsgLocation, - Body: fmt.Sprintf("Location: %s\n%s\n%s", name, message.Address, url), + Body: fmt.Sprintf("Location: %s\n%s\n%s", name, msg.GetAddress(), url), Format: event.FormatHTML, - FormattedBody: fmt.Sprintf("Location: %s
%s", url, name, message.Address), - GeoURI: fmt.Sprintf("geo:%.5f,%.5f", message.DegreesLatitude, message.DegreesLongitude), + FormattedBody: fmt.Sprintf("Location: %s
%s", url, name, msg.GetAddress()), + GeoURI: fmt.Sprintf("geo:%.5f,%.5f", msg.GetDegreesLatitude(), msg.GetDegreesLongitude()), } - if len(message.JpegThumbnail) > 0 { - thumbnailMime := http.DetectContentType(message.JpegThumbnail) - uploadedThumbnail, _ := intent.UploadBytes(message.JpegThumbnail, thumbnailMime) + if len(msg.GetJpegThumbnail()) > 0 { + thumbnailMime := http.DetectContentType(msg.GetJpegThumbnail()) + uploadedThumbnail, _ := intent.UploadBytes(msg.GetJpegThumbnail(), thumbnailMime) if uploadedThumbnail != nil { - cfg, _, _ := image.DecodeConfig(bytes.NewReader(message.JpegThumbnail)) + cfg, _, _ := image.DecodeConfig(bytes.NewReader(msg.GetJpegThumbnail())) content.Info = &event.FileInfo{ ThumbnailInfo: &event.FileInfo{ - Size: len(message.JpegThumbnail), + Size: len(msg.GetJpegThumbnail()), Width: cfg.Width, Height: cfg.Height, MimeType: thumbnailMime, @@ -1517,31 +1443,31 @@ func (portal *Portal) HandleLocationMessage(source *User, message whatsapp.Locat } } - portal.SetReply(content, message.ContextInfo) + portal.SetReply(content, msg.GetContextInfo().GetStanzaId()) - resp, err := portal.sendMessage(intent, event.EventMessage, content, int64(message.Info.Timestamp*1000)) + resp, err := portal.sendMessage(intent, event.EventMessage, content, info.Timestamp.Unix()*1000) if err != nil { - portal.log.Errorfln("Failed to handle message %s: %v", message.Info.Id, err) + portal.log.Errorfln("Failed to handle message %s: %v", info.ID, err) } else { - portal.finishHandling(source, message.Info.Source, resp.EventID) + portal.finishHandling(source, info, resp.EventID) } return true } -func (portal *Portal) HandleContactMessage(source *User, message whatsapp.ContactMessage) bool { - intent := portal.startHandling(source, message.Info, "contact") +func (portal *Portal) HandleContactMessage(source *User, info *types.MessageInfo, msg *waProto.ContactMessage) bool { + intent := portal.startHandling(source, info, "contact") if intent == nil { return false } - fileName := fmt.Sprintf("%s.vcf", message.DisplayName) - data := []byte(message.Vcard) + fileName := fmt.Sprintf("%s.vcf", msg.GetDisplayName()) + data := []byte(msg.GetVcard()) mimeType := "text/vcard" data, uploadMimeType, file := portal.encryptFile(data, mimeType) uploadResp, err := intent.UploadBytesWithName(data, uploadMimeType, fileName) if err != nil { - portal.log.Errorfln("Failed to upload vcard of %s: %v", message.DisplayName, err) + portal.log.Errorfln("Failed to upload vcard of %s: %v", msg.GetDisplayName(), err) return true } @@ -1551,7 +1477,7 @@ func (portal *Portal) HandleContactMessage(source *User, message whatsapp.Contac File: file, Info: &event.FileInfo{ MimeType: mimeType, - Size: len(message.Vcard), + Size: len(msg.GetVcard()), }, } if content.File != nil { @@ -1560,27 +1486,27 @@ func (portal *Portal) HandleContactMessage(source *User, message whatsapp.Contac content.URL = uploadResp.ContentURI.CUString() } - portal.SetReply(content, message.ContextInfo) + portal.SetReply(content, msg.GetContextInfo().GetStanzaId()) - resp, err := portal.sendMessage(intent, event.EventMessage, content, int64(message.Info.Timestamp*1000)) + resp, err := portal.sendMessage(intent, event.EventMessage, content, info.Timestamp.Unix()*1000) if err != nil { - portal.log.Errorfln("Failed to handle message %s: %v", message.Info.Id, err) + portal.log.Errorfln("Failed to handle message %s: %v", info.ID, err) } else { - portal.finishHandling(source, message.Info.Source, resp.EventID) + portal.finishHandling(source, info, resp.EventID) } return true } -func (portal *Portal) sendMediaBridgeFailure(source *User, intent *appservice.IntentAPI, info whatsapp.MessageInfo, bridgeErr error) { - portal.log.Errorfln("Failed to bridge media for %s: %v", info.Id, bridgeErr) +func (portal *Portal) sendMediaBridgeFailure(source *User, intent *appservice.IntentAPI, info *types.MessageInfo, bridgeErr error) { + portal.log.Errorfln("Failed to bridge media for %s: %v", info.ID, bridgeErr) resp, err := portal.sendMessage(intent, event.EventMessage, &event.MessageEventContent{ MsgType: event.MsgNotice, Body: "Failed to bridge media", - }, int64(info.Timestamp*1000)) + }, info.Timestamp.Unix()*1000) if err != nil { - portal.log.Errorfln("Failed to send media download error message for %s: %v", info.Id, err) + portal.log.Errorfln("Failed to send media download error message for %s: %v", info.ID, err) } else { - portal.finishHandling(source, info.Source, resp.EventID) + portal.finishHandling(source, info, resp.EventID) } } @@ -1596,199 +1522,213 @@ func (portal *Portal) encryptFile(data []byte, mimeType string) ([]byte, string, return file.Encrypt(data), "application/octet-stream", file } -func (portal *Portal) tryKickUser(userID id.UserID, intent *appservice.IntentAPI) error { - _, err := intent.KickUser(portal.MXID, &mautrix.ReqKickUser{UserID: userID}) - if err != nil { - httpErr, ok := err.(mautrix.HTTPError) - if ok && httpErr.RespError != nil && httpErr.RespError.ErrCode == "M_FORBIDDEN" { - _, err = portal.MainIntent().KickUser(portal.MXID, &mautrix.ReqKickUser{UserID: userID}) - } - } - return err +// FIXME +//func (portal *Portal) tryKickUser(userID id.UserID, intent *appservice.IntentAPI) error { +// _, err := intent.KickUser(portal.MXID, &mautrix.ReqKickUser{UserID: userID}) +// if err != nil { +// httpErr, ok := err.(mautrix.HTTPError) +// if ok && httpErr.RespError != nil && httpErr.RespError.ErrCode == "M_FORBIDDEN" { +// _, err = portal.MainIntent().KickUser(portal.MXID, &mautrix.ReqKickUser{UserID: userID}) +// } +// } +// return err +//} +// +//func (portal *Portal) removeUser(isSameUser bool, kicker *appservice.IntentAPI, target id.UserID, targetIntent *appservice.IntentAPI) { +// if !isSameUser || targetIntent == nil { +// err := portal.tryKickUser(target, kicker) +// if err != nil { +// portal.log.Warnfln("Failed to kick %s from %s: %v", target, portal.MXID, err) +// if targetIntent != nil { +// _, _ = targetIntent.LeaveRoom(portal.MXID) +// } +// } +// } else { +// _, err := targetIntent.LeaveRoom(portal.MXID) +// if err != nil { +// portal.log.Warnfln("Failed to leave portal as %s: %v", target, err) +// _, _ = portal.MainIntent().KickUser(portal.MXID, &mautrix.ReqKickUser{UserID: target}) +// } +// } +//} +// +//func (portal *Portal) HandleWhatsAppKick(source *User, senderJID string, jids []string) { +// sender := portal.bridge.GetPuppetByJID(senderJID) +// senderIntent := sender.IntentFor(portal) +// for _, jid := range jids { +// if source != nil && source.JID == jid { +// portal.log.Debugln("Ignoring self-kick by", source.MXID) +// continue +// } +// puppet := portal.bridge.GetPuppetByJID(jid) +// portal.removeUser(puppet.JID == sender.JID, senderIntent, puppet.MXID, puppet.DefaultIntent()) +// +// if !portal.IsBroadcastList() { +// user := portal.bridge.GetUserByJID(jid) +// if user != nil { +// var customIntent *appservice.IntentAPI +// if puppet.CustomMXID == user.MXID { +// customIntent = puppet.CustomIntent() +// } +// portal.removeUser(puppet.JID == sender.JID, senderIntent, user.MXID, customIntent) +// } +// } +// } +//} +// +//func (portal *Portal) HandleWhatsAppInvite(source *User, senderJID string, intent *appservice.IntentAPI, jids []string) (evtID id.EventID) { +// if intent == nil { +// intent = portal.MainIntent() +// if senderJID != "unknown" { +// sender := portal.bridge.GetPuppetByJID(senderJID) +// intent = sender.IntentFor(portal) +// } +// } +// for _, jid := range jids { +// puppet := portal.bridge.GetPuppetByJID(jid) +// puppet.SyncContact(source, true) +// content := event.Content{ +// Parsed: event.MemberEventContent{ +// Membership: "invite", +// Displayname: puppet.Displayname, +// AvatarURL: puppet.AvatarURL.CUString(), +// }, +// Raw: map[string]interface{}{ +// "net.maunium.whatsapp.puppet": true, +// }, +// } +// resp, err := intent.SendStateEvent(portal.MXID, event.StateMember, puppet.MXID.String(), &content) +// if err != nil { +// portal.log.Warnfln("Failed to invite %s as %s: %v", puppet.MXID, intent.UserID, err) +// _ = portal.MainIntent().EnsureInvited(portal.MXID, puppet.MXID) +// } else { +// evtID = resp.EventID +// } +// err = puppet.DefaultIntent().EnsureJoined(portal.MXID) +// if err != nil { +// portal.log.Errorfln("Failed to ensure %s is joined: %v", puppet.MXID, err) +// } +// } +// return +//} + +type MediaMessage interface { + whatsmeow.DownloadableMessage + GetContextInfo() *waProto.ContextInfo + GetMimetype() string } -func (portal *Portal) removeUser(isSameUser bool, kicker *appservice.IntentAPI, target id.UserID, targetIntent *appservice.IntentAPI) { - if !isSameUser || targetIntent == nil { - err := portal.tryKickUser(target, kicker) - if err != nil { - portal.log.Warnfln("Failed to kick %s from %s: %v", target, portal.MXID, err) - if targetIntent != nil { - _, _ = targetIntent.LeaveRoom(portal.MXID) - } - } - } else { - _, err := targetIntent.LeaveRoom(portal.MXID) - if err != nil { - portal.log.Warnfln("Failed to leave portal as %s: %v", target, err) - _, _ = portal.MainIntent().KickUser(portal.MXID, &mautrix.ReqKickUser{UserID: target}) - } - } +type MediaMessageWithThumbnailAndCaption interface { + MediaMessage + GetJpegThumbnail() []byte + GetCaption() string } -func (portal *Portal) HandleWhatsAppKick(source *User, senderJID string, jids []string) { - sender := portal.bridge.GetPuppetByJID(senderJID) - senderIntent := sender.IntentFor(portal) - for _, jid := range jids { - if source != nil && source.JID == jid { - portal.log.Debugln("Ignoring self-kick by", source.MXID) - continue - } - puppet := portal.bridge.GetPuppetByJID(jid) - portal.removeUser(puppet.JID == sender.JID, senderIntent, puppet.MXID, puppet.DefaultIntent()) - - if !portal.IsBroadcastList() { - user := portal.bridge.GetUserByJID(jid) - if user != nil { - var customIntent *appservice.IntentAPI - if puppet.CustomMXID == user.MXID { - customIntent = puppet.CustomIntent() - } - portal.removeUser(puppet.JID == sender.JID, senderIntent, user.MXID, customIntent) - } - } - } +type MediaMessageWithFileName interface { + MediaMessage + GetFileName() string } -func (portal *Portal) HandleWhatsAppInvite(source *User, senderJID string, intent *appservice.IntentAPI, jids []string) (evtID id.EventID) { - if intent == nil { - intent = portal.MainIntent() - if senderJID != "unknown" { - sender := portal.bridge.GetPuppetByJID(senderJID) - intent = sender.IntentFor(portal) - } - } - for _, jid := range jids { - puppet := portal.bridge.GetPuppetByJID(jid) - puppet.SyncContactIfNecessary(source) - content := event.Content{ - Parsed: event.MemberEventContent{ - Membership: "invite", - Displayname: puppet.Displayname, - AvatarURL: puppet.AvatarURL.CUString(), - }, - Raw: map[string]interface{}{ - "net.maunium.whatsapp.puppet": true, - }, - } - resp, err := intent.SendStateEvent(portal.MXID, event.StateMember, puppet.MXID.String(), &content) - if err != nil { - portal.log.Warnfln("Failed to invite %s as %s: %v", puppet.MXID, intent.UserID, err) - _ = portal.MainIntent().EnsureInvited(portal.MXID, puppet.MXID) - } else { - evtID = resp.EventID - } - err = puppet.DefaultIntent().EnsureJoined(portal.MXID) - if err != nil { - portal.log.Errorfln("Failed to ensure %s is joined: %v", puppet.MXID, err) - } - } - return +type MediaMessageWithDuration interface { + MediaMessage + GetSeconds() uint32 } -type base struct { - download func() ([]byte, error) - info whatsapp.MessageInfo - context whatsapp.ContextInfo - mimeType string -} - -type mediaMessage struct { - base - - thumbnail []byte - caption string - fileName string - length uint32 - sendAsSticker bool -} - -func (portal *Portal) HandleMediaMessage(source *User, msg mediaMessage) bool { - intent := portal.startHandling(source, msg.info, fmt.Sprintf("media %s", msg.mimeType)) +func (portal *Portal) HandleMediaMessage(source *User, info *types.MessageInfo, msg MediaMessage) bool { + intent := portal.startHandling(source, info, fmt.Sprintf("media %s", msg.GetMimetype())) if intent == nil { return false } - data, err := msg.download() - if errors.Is(err, whatsapp.ErrMediaDownloadFailedWith404) || errors.Is(err, whatsapp.ErrMediaDownloadFailedWith410) { - portal.log.Warnfln("Failed to download media for %s: %v. Calling LoadMediaInfo and retrying download...", msg.info.Id, err) - _, err = source.Conn.LoadMediaInfo(msg.info.RemoteJid, msg.info.Id, msg.info.FromMe) - if err != nil { - portal.sendMediaBridgeFailure(source, intent, msg.info, fmt.Errorf("failed to load media info: %w", err)) - return true - } - data, err = msg.download() - } - if errors.Is(err, whatsapp.ErrNoURLPresent) { - portal.log.Debugfln("No URL present error for media message %s, ignoring...", msg.info.Id) + data, err := source.Client.Download(msg) + // TODO can these errors still be handled? + //if errors.Is(err, whatsapp.ErrMediaDownloadFailedWith404) || errors.Is(err, whatsapp.ErrMediaDownloadFailedWith410) { + // portal.log.Warnfln("Failed to download media for %s: %v. Calling LoadMediaInfo and retrying download...", msg.info.Id, err) + // _, err = source.Conn.LoadMediaInfo(msg.info.RemoteJid, msg.info.Id, msg.info.FromMe) + // if err != nil { + // portal.sendMediaBridgeFailure(source, intent, msg.info, fmt.Errorf("failed to load media info: %w", err)) + // return true + // } + // data, err = msg.download() + //} + if errors.Is(err, whatsmeow.ErrNoURLPresent) { + portal.log.Debugfln("No URL present error for media message %s, ignoring...", info.ID) return true - } else if errors.Is(err, whatsapp.ErrInvalidMediaHMAC) || errors.Is(err, whatsapp.ErrFileLengthMismatch) { - portal.log.Warnfln("Got error '%v' while downloading media in %s, but official WhatsApp clients don't seem to care, so ignoring that error and bridging file anyway", err, msg.info.Id) } else if err != nil { - portal.sendMediaBridgeFailure(source, intent, msg.info, err) + portal.sendMediaBridgeFailure(source, intent, info, err) return true } var width, height int - if strings.HasPrefix(msg.mimeType, "image/") { + if strings.HasPrefix(msg.GetMimetype(), "image/") { cfg, _, _ := image.DecodeConfig(bytes.NewReader(data)) width, height = cfg.Width, cfg.Height } - data, uploadMimeType, file := portal.encryptFile(data, msg.mimeType) + data, uploadMimeType, file := portal.encryptFile(data, msg.GetMimetype()) uploaded, err := intent.UploadBytes(data, uploadMimeType) if err != nil { if errors.Is(err, mautrix.MTooLarge) { - portal.sendMediaBridgeFailure(source, intent, msg.info, errors.New("homeserver rejected too large file")) + portal.sendMediaBridgeFailure(source, intent, info, errors.New("homeserver rejected too large file")) } else if httpErr, ok := err.(mautrix.HTTPError); ok && httpErr.IsStatus(413) { - portal.sendMediaBridgeFailure(source, intent, msg.info, errors.New("proxy rejected too large file")) + portal.sendMediaBridgeFailure(source, intent, info, errors.New("proxy rejected too large file")) } else { - portal.sendMediaBridgeFailure(source, intent, msg.info, fmt.Errorf("failed to upload media: %w", err)) + portal.sendMediaBridgeFailure(source, intent, info, fmt.Errorf("failed to upload media: %w", err)) } return true } - if msg.fileName == "" { - mimeClass := strings.Split(msg.mimeType, "/")[0] - switch mimeClass { - case "application": - msg.fileName = "file" - default: - msg.fileName = mimeClass - } - - exts, _ := mime.ExtensionsByType(msg.mimeType) - if exts != nil && len(exts) > 0 { - msg.fileName += exts[0] - } - } - content := &event.MessageEventContent{ - Body: msg.fileName, File: file, Info: &event.FileInfo{ Size: len(data), - MimeType: msg.mimeType, + MimeType: msg.GetMimetype(), Width: width, Height: height, - Duration: int(msg.length), }, } + + msgWithName, ok := msg.(MediaMessageWithFileName) + if ok && len(msgWithName.GetFileName()) > 0 { + content.Body = msgWithName.GetFileName() + } else { + mimeClass := strings.Split(msg.GetMimetype(), "/")[0] + switch mimeClass { + case "application": + content.Body = "file" + default: + content.Body = mimeClass + } + + exts, _ := mime.ExtensionsByType(msg.GetMimetype()) + if exts != nil && len(exts) > 0 { + content.Body += exts[0] + } + } + + msgWithDuration, ok := msg.(MediaMessageWithDuration) + if ok { + content.Info.Duration = int(msgWithDuration.GetSeconds()) * 1000 + } + if content.File != nil { content.File.URL = uploaded.ContentURI.CUString() } else { content.URL = uploaded.ContentURI.CUString() } - portal.SetReply(content, msg.context) + portal.SetReply(content, msg.GetContextInfo().GetStanzaId()) - if msg.thumbnail != nil && portal.bridge.Config.Bridge.WhatsappThumbnail { - thumbnailMime := http.DetectContentType(msg.thumbnail) - thumbnailCfg, _, _ := image.DecodeConfig(bytes.NewReader(msg.thumbnail)) - thumbnailSize := len(msg.thumbnail) - thumbnail, thumbnailUploadMime, thumbnailFile := portal.encryptFile(msg.thumbnail, thumbnailMime) + msgWithThumbnail, ok := msg.(MediaMessageWithThumbnailAndCaption) + if ok && msgWithThumbnail.GetJpegThumbnail() != nil && portal.bridge.Config.Bridge.WhatsappThumbnail { + thumbnailData := msgWithThumbnail.GetJpegThumbnail() + thumbnailMime := http.DetectContentType(thumbnailData) + thumbnailCfg, _, _ := image.DecodeConfig(bytes.NewReader(thumbnailData)) + thumbnailSize := len(thumbnailData) + thumbnail, thumbnailUploadMime, thumbnailFile := portal.encryptFile(thumbnailData, thumbnailMime) uploadedThumbnail, err := intent.UploadBytes(thumbnail, thumbnailUploadMime) if err != nil { - portal.log.Warnfln("Failed to upload thumbnail for %s: %v", msg.info.Id, err) + portal.log.Warnfln("Failed to upload thumbnail for %s: %v", info.ID, err) } else if uploadedThumbnail != nil { if thumbnailFile != nil { thumbnailFile.URL = uploadedThumbnail.ContentURI.CUString() @@ -1805,9 +1745,10 @@ func (portal *Portal) HandleMediaMessage(source *User, msg mediaMessage) bool { } } - switch strings.ToLower(strings.Split(msg.mimeType, "/")[0]) { + _, isSticker := msg.(*waProto.StickerMessage) + switch strings.ToLower(strings.Split(msg.GetMimetype(), "/")[0]) { case "image": - if !msg.sendAsSticker { + if !isSticker { content.MsgType = event.MsgImage } case "video": @@ -1818,42 +1759,35 @@ func (portal *Portal) HandleMediaMessage(source *User, msg mediaMessage) bool { content.MsgType = event.MsgFile } - ts := int64(msg.info.Timestamp * 1000) + ts := info.Timestamp.Unix() * 1000 eventType := event.EventMessage - if msg.sendAsSticker { + if isSticker { eventType = event.EventSticker } resp, err := portal.sendMessage(intent, eventType, content, ts) if err != nil { - portal.log.Errorfln("Failed to handle message %s: %v", msg.info.Id, err) + portal.log.Errorfln("Failed to handle message %s: %v", info.ID, err) return true } - if len(msg.caption) > 0 { + if len(msgWithThumbnail.GetCaption()) > 0 { captionContent := &event.MessageEventContent{ - Body: msg.caption, + Body: msgWithThumbnail.GetCaption(), MsgType: event.MsgNotice, } - portal.bridge.Formatter.ParseWhatsApp(captionContent, msg.context.MentionedJID) + portal.bridge.Formatter.ParseWhatsApp(captionContent, msg.GetContextInfo().GetMentionedJid()) resp, err = portal.sendMessage(intent, event.EventMessage, captionContent, ts) if err != nil { - portal.log.Warnfln("Failed to handle caption of message %s: %v", msg.info.Id, err) + portal.log.Warnfln("Failed to handle caption of message %s: %v", info.ID, err) } } - portal.finishHandling(source, msg.info.Source, resp.EventID) + portal.finishHandling(source, info, resp.EventID) return true } -func makeMessageID() *string { - b := make([]byte, 10) - rand.Read(b) - str := strings.ToUpper(hex.EncodeToString(b)) - return &str -} - func (portal *Portal) downloadThumbnail(content *event.MessageEventContent, id id.EventID) []byte { if len(content.GetInfo().ThumbnailURL) == 0 { return nil @@ -1951,9 +1885,9 @@ func (portal *Portal) convertGifToVideo(gif []byte) ([]byte, error) { return mp4, nil } -func (portal *Portal) preprocessMatrixMedia(sender *User, relaybotFormatted bool, content *event.MessageEventContent, eventID id.EventID, mediaType whatsapp.MediaType) *MediaUpload { +func (portal *Portal) preprocessMatrixMedia(sender *User, relaybotFormatted bool, content *event.MessageEventContent, eventID id.EventID, mediaType whatsmeow.MediaType) *MediaUpload { var caption string - var mentionedJIDs []whatsapp.JID + var mentionedJIDs []string if relaybotFormatted { caption, mentionedJIDs = portal.bridge.Formatter.ParseMatrix(content.FormattedBody) } @@ -1981,7 +1915,7 @@ func (portal *Portal) preprocessMatrixMedia(sender *User, relaybotFormatted bool return nil } } - if mediaType == whatsapp.MediaVideo && content.GetInfo().MimeType == "image/gif" { + if mediaType == whatsmeow.MediaVideo && content.GetInfo().MimeType == "image/gif" { data, err = portal.convertGifToVideo(data) if err != nil { portal.log.Errorfln("Failed to convert gif to mp4 in %s: %v", eventID, err) @@ -1989,7 +1923,7 @@ func (portal *Portal) preprocessMatrixMedia(sender *User, relaybotFormatted bool } content.Info.MimeType = "video/mp4" } - if mediaType == whatsapp.MediaImage && content.GetInfo().MimeType == "image/webp" { + if mediaType == whatsmeow.MediaImage && content.GetInfo().MimeType == "image/webp" { data, err = portal.convertWebPtoPNG(data) if err != nil { portal.log.Errorfln("Failed to convert webp to png in %s: %v", eventID, err) @@ -1997,40 +1931,34 @@ func (portal *Portal) preprocessMatrixMedia(sender *User, relaybotFormatted bool } content.Info.MimeType = "image/png" } - url, mediaKey, fileEncSHA256, fileSHA256, fileLength, err := sender.Conn.Upload(bytes.NewReader(data), mediaType) + uploadResp, err := sender.Client.Upload(context.Background(), data, mediaType) if err != nil { portal.log.Errorfln("Failed to upload media in %s: %v", eventID, err) return nil } return &MediaUpload{ - Caption: caption, - MentionedJIDs: mentionedJIDs, - URL: url, - MediaKey: mediaKey, - FileEncSHA256: fileEncSHA256, - FileSHA256: fileSHA256, - FileLength: fileLength, - Thumbnail: portal.downloadThumbnail(content, eventID), + UploadResponse: uploadResp, + Caption: caption, + MentionedJIDs: mentionedJIDs, + Thumbnail: portal.downloadThumbnail(content, eventID), + FileLength: len(data), } } type MediaUpload struct { + whatsmeow.UploadResponse Caption string - MentionedJIDs []whatsapp.JID - URL string - MediaKey []byte - FileEncSHA256 []byte - FileSHA256 []byte - FileLength uint64 + MentionedJIDs []string Thumbnail []byte + FileLength int } func (portal *Portal) sendMatrixConnectionError(sender *User, eventID id.EventID) bool { if !sender.HasSession() { portal.log.Debugln("Ignoring event", eventID, "from", sender.MXID, "as user has no session") return true - } else if !sender.IsConnected() { + } /*else if !sender.IsConnected() { inRoom := "" if portal.IsPrivateChat() { inRoom = " in your management room" @@ -2051,7 +1979,8 @@ func (portal *Portal) sendMatrixConnectionError(sender *User, eventID id.EventID portal.log.Errorln("Failed to send bridging failure message:", err) } return true - } + }*/ + // FIXME implement return false } @@ -2109,35 +2038,22 @@ func fallbackQuoteContent() *waProto.Message { } } -func (portal *Portal) convertMatrixMessage(sender *User, evt *event.Event) (*waProto.WebMessageInfo, *User) { +func (portal *Portal) convertMatrixMessage(sender *User, evt *event.Event) (*waProto.Message, *User) { content, ok := evt.Content.Parsed.(*event.MessageEventContent) if !ok { portal.log.Debugfln("Failed to handle event %s: unexpected parsed content type %T", evt.ID, evt.Content.Parsed) return nil, sender } - ts := uint64(evt.Timestamp / 1000) - status := waProto.WebMessageInfo_PENDING - trueVal := true - info := &waProto.WebMessageInfo{ - Key: &waProto.MessageKey{ - FromMe: &trueVal, - Id: makeMessageID(), - RemoteJid: &portal.Key.JID, - }, - MessageTimestamp: &ts, - MessageC2STimestamp: &ts, - Message: &waProto.Message{}, - Status: &status, - } - ctxInfo := &waProto.ContextInfo{} + var msg waProto.Message + var ctxInfo waProto.ContextInfo replyToID := content.GetReplyTo() if len(replyToID) > 0 { content.RemoveReplyFallback() - msg := portal.bridge.DB.Message.GetByMXID(replyToID) - if msg != nil { - ctxInfo.StanzaId = &msg.JID - ctxInfo.Participant = &msg.Sender + replyToMsg := portal.bridge.DB.Message.GetByMXID(replyToID) + if replyToMsg != nil { + ctxInfo.StanzaId = &replyToMsg.JID + ctxInfo.Participant = proto.String(replyToMsg.Sender.String()) // Using blank content here seems to work fine on all official WhatsApp apps. // Getting the content from the phone would be possible, but it's complicated. // https://github.com/mautrix/whatsapp/commit/b3312bc663772aa274cea90ffa773da2217bb5e0 @@ -2178,21 +2094,21 @@ func (portal *Portal) convertMatrixMessage(sender *User, evt *event.Event) (*waP text = "/me " + text } if ctxInfo.StanzaId != nil || ctxInfo.MentionedJid != nil { - info.Message.ExtendedTextMessage = &waProto.ExtendedTextMessage{ + msg.ExtendedTextMessage = &waProto.ExtendedTextMessage{ Text: &text, - ContextInfo: ctxInfo, + ContextInfo: &ctxInfo, } } else { - info.Message.Conversation = &text + msg.Conversation = &text } case event.MsgImage: - media := portal.preprocessMatrixMedia(sender, relaybotFormatted, content, evt.ID, whatsapp.MediaImage) + media := portal.preprocessMatrixMedia(sender, relaybotFormatted, content, evt.ID, whatsmeow.MediaImage) if media == nil { return nil, sender } ctxInfo.MentionedJid = media.MentionedJIDs - info.Message.ImageMessage = &waProto.ImageMessage{ - ContextInfo: ctxInfo, + msg.ImageMessage = &waProto.ImageMessage{ + ContextInfo: &ctxInfo, Caption: &media.Caption, JpegThumbnail: media.Thumbnail, Url: &media.URL, @@ -2200,18 +2116,18 @@ func (portal *Portal) convertMatrixMessage(sender *User, evt *event.Event) (*waP Mimetype: &content.GetInfo().MimeType, FileEncSha256: media.FileEncSHA256, FileSha256: media.FileSHA256, - FileLength: &media.FileLength, + FileLength: proto.Uint64(uint64(media.FileLength)), } case event.MsgVideo: gifPlayback := content.GetInfo().MimeType == "image/gif" - media := portal.preprocessMatrixMedia(sender, relaybotFormatted, content, evt.ID, whatsapp.MediaVideo) + media := portal.preprocessMatrixMedia(sender, relaybotFormatted, content, evt.ID, whatsmeow.MediaVideo) if media == nil { return nil, sender } duration := uint32(content.GetInfo().Duration / 1000) ctxInfo.MentionedJid = media.MentionedJIDs - info.Message.VideoMessage = &waProto.VideoMessage{ - ContextInfo: ctxInfo, + msg.VideoMessage = &waProto.VideoMessage{ + ContextInfo: &ctxInfo, Caption: &media.Caption, JpegThumbnail: media.Thumbnail, Url: &media.URL, @@ -2221,39 +2137,38 @@ func (portal *Portal) convertMatrixMessage(sender *User, evt *event.Event) (*waP Seconds: &duration, FileEncSha256: media.FileEncSHA256, FileSha256: media.FileSHA256, - FileLength: &media.FileLength, + FileLength: proto.Uint64(uint64(media.FileLength)), } case event.MsgAudio: - media := portal.preprocessMatrixMedia(sender, relaybotFormatted, content, evt.ID, whatsapp.MediaAudio) + media := portal.preprocessMatrixMedia(sender, relaybotFormatted, content, evt.ID, whatsmeow.MediaAudio) if media == nil { return nil, sender } duration := uint32(content.GetInfo().Duration / 1000) - info.Message.AudioMessage = &waProto.AudioMessage{ - ContextInfo: ctxInfo, + msg.AudioMessage = &waProto.AudioMessage{ + ContextInfo: &ctxInfo, Url: &media.URL, MediaKey: media.MediaKey, Mimetype: &content.GetInfo().MimeType, Seconds: &duration, FileEncSha256: media.FileEncSHA256, FileSha256: media.FileSHA256, - FileLength: &media.FileLength, + FileLength: proto.Uint64(uint64(media.FileLength)), } _, isMSC3245Voice := evt.Content.Raw["org.matrix.msc3245.voice"] _, isMSC2516Voice := evt.Content.Raw["org.matrix.msc2516.voice"] if isMSC3245Voice || isMSC2516Voice { - info.Message.AudioMessage.Ptt = &trueVal + msg.AudioMessage.Ptt = proto.Bool(true) // hacky hack to add the codecs param that whatsapp seems to require - mimeWithCodec := addCodecToMime(content.GetInfo().MimeType, "opus") - info.Message.AudioMessage.Mimetype = &mimeWithCodec + msg.AudioMessage.Mimetype = proto.String(addCodecToMime(content.GetInfo().MimeType, "opus")) } case event.MsgFile: - media := portal.preprocessMatrixMedia(sender, relaybotFormatted, content, evt.ID, whatsapp.MediaDocument) + media := portal.preprocessMatrixMedia(sender, relaybotFormatted, content, evt.ID, whatsmeow.MediaDocument) if media == nil { return nil, sender } - info.Message.DocumentMessage = &waProto.DocumentMessage{ - ContextInfo: ctxInfo, + msg.DocumentMessage = &waProto.DocumentMessage{ + ContextInfo: &ctxInfo, Url: &media.URL, Title: &content.Body, FileName: &content.Body, @@ -2261,7 +2176,7 @@ func (portal *Portal) convertMatrixMessage(sender *User, evt *event.Event) (*waP Mimetype: &content.GetInfo().MimeType, FileEncSha256: media.FileEncSHA256, FileSha256: media.FileSHA256, - FileLength: &media.FileLength, + FileLength: proto.Uint64(uint64(media.FileLength)), } case event.MsgLocation: lat, long, err := parseGeoURI(content.GeoURI) @@ -2269,28 +2184,17 @@ func (portal *Portal) convertMatrixMessage(sender *User, evt *event.Event) (*waP portal.log.Debugfln("Invalid geo URI on Matrix event %s: %v", evt.ID, err) return nil, sender } - info.Message.LocationMessage = &waProto.LocationMessage{ + msg.LocationMessage = &waProto.LocationMessage{ DegreesLatitude: &lat, DegreesLongitude: &long, Comment: &content.Body, - ContextInfo: ctxInfo, + ContextInfo: &ctxInfo, } default: portal.log.Debugfln("Unhandled Matrix event %s: unknown msgtype %s", evt.ID, content.MsgType) return nil, sender } - return info, sender -} - -func (portal *Portal) wasMessageSent(sender *User, id string) bool { - _, err := sender.Conn.LoadMessagesAfter(portal.Key.JID, id, true, 0) - if err != nil { - if err != whatsapp.ErrServerRespondedWith404 { - portal.log.Warnfln("Failed to check if message was bridged without response: %v", err) - } - return false - } - return true + return &msg, sender } func (portal *Portal) sendErrorMessage(message string, confirmed bool) id.EventID { @@ -2318,116 +2222,67 @@ func (portal *Portal) sendDeliveryReceipt(eventID id.EventID) { } } +func (portal *Portal) generateMessageInfo(sender *User) *types.MessageInfo { + return &types.MessageInfo{ + ID: whatsmeow.GenerateMessageID(), + Timestamp: time.Now(), + MessageSource: types.MessageSource{ + Sender: sender.JID, + Chat: portal.Key.JID, + IsFromMe: true, + IsGroup: portal.Key.JID.Server == types.GroupServer || portal.Key.JID.Server == types.BroadcastServer, + }, + } +} + func (portal *Portal) HandleMatrixMessage(sender *User, evt *event.Event) { if !portal.HasRelaybot() && - ((portal.IsPrivateChat() && sender.JID != portal.Key.Receiver) || + ((portal.IsPrivateChat() && sender.JID.User != portal.Key.Receiver.User) || portal.sendMatrixConnectionError(sender, evt.ID)) { return } portal.log.Debugfln("Received event %s", evt.ID) - info, sender := portal.convertMatrixMessage(sender, evt) - if info == nil { + msg, sender := portal.convertMatrixMessage(sender, evt) + if msg == nil { return } + info := portal.generateMessageInfo(sender) dbMsg := portal.markHandled(sender, info, evt.ID, false) - portal.sendRaw(sender, evt, info, dbMsg) -} - -func (portal *Portal) sendRaw(sender *User, evt *event.Event, info *waProto.WebMessageInfo, dbMsg *database.Message) { - portal.log.Debugln("Sending event", evt.ID, "to WhatsApp", info.Key.GetId()) - errChan := make(chan error, 1) - go sender.Conn.SendRaw(info, errChan) - - var err error - var errorEventID id.EventID - select { - case err = <-errChan: - case <-time.After(time.Duration(portal.bridge.Config.Bridge.ConnectionTimeout) * time.Second): - if portal.bridge.Config.Bridge.FetchMessageOnTimeout && portal.wasMessageSent(sender, info.Key.GetId()) { - portal.log.Debugln("Matrix event %s was bridged, but response didn't arrive within timeout") - portal.sendDeliveryReceipt(evt.ID) - } else { - portal.log.Warnfln("Response when bridging Matrix event %s is taking long to arrive", evt.ID) - errorEventID = portal.sendErrorMessage("message sending timed out", false) - } - err = <-errChan - } + portal.log.Debugln("Sending event", evt.ID, "to WhatsApp", info.ID) + err := sender.Client.SendMessage(portal.Key.JID, info.ID, msg) if err != nil { - var statusErr whatsapp.StatusResponse - errors.As(err, &statusErr) - var confirmed bool - var errMsg string - switch statusErr.Status { - case 400: - portal.log.Errorfln("400 response handling Matrix event %s: %+v", evt.ID, statusErr.Extra) - errMsg = "WhatsApp rejected the message (status code 400)." - if info.Message.ImageMessage != nil || info.Message.VideoMessage != nil || info.Message.AudioMessage != nil || info.Message.DocumentMessage != nil { - errMsg += " The attachment type you sent may be unsupported." - } - confirmed = true - case 599: - errMsg = "WhatsApp rate-limited the message (status code 599)." - default: - portal.log.Errorfln("Error handling Matrix event %s: %v", evt.ID, err) - errMsg = err.Error() - } - portal.sendErrorMessage(errMsg, confirmed) + portal.log.Errorln("Error sending message: %v", err) + portal.sendErrorMessage(err.Error(), true) } else { portal.log.Debugfln("Handled Matrix event %s", evt.ID) portal.sendDeliveryReceipt(evt.ID) dbMsg.MarkSent() } - if errorEventID != "" { - _, err = portal.MainIntent().RedactEvent(portal.MXID, errorEventID) - if err != nil { - portal.log.Warnfln("Failed to redact timeout warning message %s: %v", errorEventID, err) - } - } } func (portal *Portal) HandleMatrixRedaction(sender *User, evt *event.Event) { - if portal.IsPrivateChat() && sender.JID != portal.Key.Receiver { + if portal.IsPrivateChat() && sender.JID.User != portal.Key.Receiver.User { return } msg := portal.bridge.DB.Message.GetByMXID(evt.Redacts) - if msg == nil || msg.Sender != sender.JID { + if msg == nil || msg.Sender.User != sender.JID.User { return } - ts := uint64(evt.Timestamp / 1000) - status := waProto.WebMessageInfo_PENDING - protoMsgType := waProto.ProtocolMessage_REVOKE - fromMe := true - info := &waProto.WebMessageInfo{ - Key: &waProto.MessageKey{ - FromMe: &fromMe, - Id: makeMessageID(), - RemoteJid: &portal.Key.JID, - }, - MessageTimestamp: &ts, - Message: &waProto.Message{ - ProtocolMessage: &waProto.ProtocolMessage{ - Type: &protoMsgType, - Key: &waProto.MessageKey{ - FromMe: &fromMe, - Id: &msg.JID, - RemoteJid: &portal.Key.JID, - }, + portal.log.Debugfln("Received redaction event %s", evt.ID) + info := portal.generateMessageInfo(sender) + portal.log.Debugln("Sending redaction", evt.ID, "to WhatsApp", info.ID) + err := sender.Client.SendMessage(portal.Key.JID, info.ID, &waProto.Message{ + ProtocolMessage: &waProto.ProtocolMessage{ + Type: waProto.ProtocolMessage_REVOKE.Enum(), + Key: &waProto.MessageKey{ + FromMe: proto.Bool(true), + Id: proto.String(msg.JID), + RemoteJid: proto.String(portal.Key.JID.String()), }, }, - Status: &status, - } - errChan := make(chan error, 1) - go sender.Conn.SendRaw(info, errChan) - - var err error - select { - case err = <-errChan: - case <-time.After(time.Duration(portal.bridge.Config.Bridge.ConnectionTimeout) * time.Second): - portal.log.Warnfln("Response when bridging Matrix redaction %s is taking long to arrive", evt.ID) - err = <-errChan - } + }) if err != nil { portal.log.Errorfln("Error handling Matrix redaction %s: %v", evt.ID, err) } else { @@ -2523,12 +2378,13 @@ func (portal *Portal) HandleMatrixLeave(sender *User) { return } else if portal.bridge.Config.Bridge.BridgeMatrixLeave { // TODO should we somehow deduplicate this call if this leave was sent by the bridge? - resp, err := sender.Conn.LeaveGroup(portal.Key.JID) - if err != nil { - portal.log.Errorfln("Failed to leave group as %s: %v", sender.MXID, err) - return - } - portal.log.Infoln("Leave response:", <-resp) + // FIXME reimplement + //resp, err := sender.Client.LeaveGroup(portal.Key.JID) + //if err != nil { + // portal.log.Errorfln("Failed to leave group as %s: %v", sender.MXID, err) + // return + //} + //portal.log.Infoln("Leave response:", <-resp) } portal.CleanupIfEmpty() } @@ -2536,50 +2392,53 @@ func (portal *Portal) HandleMatrixLeave(sender *User) { func (portal *Portal) HandleMatrixKick(sender *User, evt *event.Event) { puppet := portal.bridge.GetPuppetByMXID(id.UserID(evt.GetStateKey())) if puppet != nil { - resp, err := sender.Conn.RemoveMember(portal.Key.JID, []string{puppet.JID}) - if err != nil { - portal.log.Errorfln("Failed to kick %s from group as %s: %v", puppet.JID, sender.MXID, err) - return - } - portal.log.Infoln("Kick %s response: %s", puppet.JID, <-resp) + // FIXME reimplement + //resp, err := sender.Conn.RemoveMember(portal.Key.JID, []string{puppet.JID}) + //if err != nil { + // portal.log.Errorfln("Failed to kick %s from group as %s: %v", puppet.JID, sender.MXID, err) + // return + //} + //portal.log.Infoln("Kick %s response: %s", puppet.JID, <-resp) } } func (portal *Portal) HandleMatrixInvite(sender *User, evt *event.Event) { puppet := portal.bridge.GetPuppetByMXID(id.UserID(evt.GetStateKey())) if puppet != nil { - resp, err := sender.Conn.AddMember(portal.Key.JID, []string{puppet.JID}) - if err != nil { - portal.log.Errorfln("Failed to add %s to group as %s: %v", puppet.JID, sender.MXID, err) - return - } - portal.log.Infofln("Add %s response: %s", puppet.JID, <-resp) + // FIXME reimplement + //resp, err := sender.Conn.AddMember(portal.Key.JID, []string{puppet.JID}) + //if err != nil { + // portal.log.Errorfln("Failed to add %s to group as %s: %v", puppet.JID, sender.MXID, err) + // return + //} + //portal.log.Infofln("Add %s response: %s", puppet.JID, <-resp) } } func (portal *Portal) HandleMatrixMeta(sender *User, evt *event.Event) { - var resp <-chan string var err error switch content := evt.Content.Parsed.(type) { case *event.RoomNameEventContent: if content.Name == portal.Name { return } - portal.Name = content.Name - resp, err = sender.Conn.UpdateGroupSubject(content.Name, portal.Key.JID) + // FIXME reimplement + //portal.Name = content.Name + //resp, err = sender.Conn.UpdateGroupSubject(content.Name, portal.Key.JID) case *event.TopicEventContent: if content.Topic == portal.Topic { return } - portal.Topic = content.Topic - resp, err = sender.Conn.UpdateGroupDescription(sender.JID, portal.Key.JID, content.Topic) + // FIXME reimplement + //portal.Topic = content.Topic + //resp, err = sender.Conn.UpdateGroupDescription(sender.JID, portal.Key.JID, content.Topic) case *event.RoomAvatarEventContent: return } if err != nil { portal.log.Errorln("Failed to update metadata:", err) } else { - out := <-resp - portal.log.Debugln("Successfully updated metadata:", out) + //out := <-resp + //portal.log.Debugln("Successfully updated metadata:", out) } } diff --git a/provisioning.go b/provisioning.go index deb1629..7def565 100644 --- a/provisioning.go +++ b/provisioning.go @@ -1,5 +1,5 @@ // mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge. -// Copyright (C) 2020 Tulir Asokan +// Copyright (C) 2021 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by @@ -21,7 +21,6 @@ import ( "context" "encoding/json" "errors" - "fmt" "net" "net/http" "strings" @@ -29,8 +28,6 @@ import ( "github.com/gorilla/websocket" - "github.com/Rhymen/go-whatsapp" - log "maunium.net/go/maulogger/v2" "maunium.net/go/mautrix/id" @@ -122,7 +119,7 @@ type Response struct { func (prov *ProvisioningAPI) DeleteSession(w http.ResponseWriter, r *http.Request) { user := r.Context().Value("user").(*User) - if user.Session == nil && user.Conn == nil { + if user.Session == nil && user.Client == nil { jsonResponse(w, http.StatusNotFound, Error{ Error: "Nothing to purge: no session information stored and no active connection.", ErrCode: "no session", @@ -130,13 +127,13 @@ func (prov *ProvisioningAPI) DeleteSession(w http.ResponseWriter, r *http.Reques return } user.DeleteConnection() - user.SetSession(nil) + user.DeleteSession() jsonResponse(w, http.StatusOK, Response{true, "Session information purged"}) } func (prov *ProvisioningAPI) DeleteConnection(w http.ResponseWriter, r *http.Request) { user := r.Context().Value("user").(*User) - if user.Conn == nil { + if user.Client == nil { jsonResponse(w, http.StatusNotFound, Error{ Error: "You don't have a WhatsApp connection.", ErrCode: "not connected", @@ -149,35 +146,20 @@ func (prov *ProvisioningAPI) DeleteConnection(w http.ResponseWriter, r *http.Req func (prov *ProvisioningAPI) Disconnect(w http.ResponseWriter, r *http.Request) { user := r.Context().Value("user").(*User) - if user.Conn == nil { + if user.Client == nil { jsonResponse(w, http.StatusNotFound, Error{ Error: "You don't have a WhatsApp connection.", ErrCode: "no connection", }) return } - err := user.Conn.Disconnect() - if err == whatsapp.ErrNotConnected { - jsonResponse(w, http.StatusNotFound, Error{ - Error: "You were not connected", - ErrCode: "not connected", - }) - return - } else if err != nil { - user.log.Warnln("Error while disconnecting:", err) - jsonResponse(w, http.StatusInternalServerError, Error{ - Error: fmt.Sprintf("Unknown error while disconnecting: %v", err), - ErrCode: err.Error(), - }) - return - } - user.bridge.Metrics.TrackConnectionState(user.JID, false) + user.DeleteConnection() jsonResponse(w, http.StatusOK, Response{true, "Disconnected from WhatsApp"}) } func (prov *ProvisioningAPI) Reconnect(w http.ResponseWriter, r *http.Request) { user := r.Context().Value("user").(*User) - if user.Conn == nil { + if user.Client == nil { if user.Session == nil { jsonResponse(w, http.StatusForbidden, Error{ Error: "No existing connection and no session. Please log in first.", @@ -190,68 +172,69 @@ func (prov *ProvisioningAPI) Reconnect(w http.ResponseWriter, r *http.Request) { return } - user.log.Debugln("Received /reconnect request, disconnecting") - wasConnected := true - err := user.Conn.Disconnect() - if err == whatsapp.ErrNotConnected { - wasConnected = false - } else if err != nil { - user.log.Warnln("Error while disconnecting:", err) - } - - user.log.Debugln("Restoring session for /reconnect") - err = user.Conn.Restore(true, r.Context()) - user.log.Debugfln("Restore session for /reconnect responded with %v", err) - if err == whatsapp.ErrInvalidSession { - if user.Session != nil { - user.log.Debugln("Got invalid session error when reconnecting, but user has session. Retrying using RestoreWithSession()...") - user.Conn.SetSession(*user.Session) - err = user.Conn.Restore(true, r.Context()) - } else { - jsonResponse(w, http.StatusForbidden, Error{ - Error: "You're not logged in", - ErrCode: "not logged in", - }) - return - } - } - if err == whatsapp.ErrLoginInProgress { - jsonResponse(w, http.StatusConflict, Error{ - Error: "A login or reconnection is already in progress.", - ErrCode: "login in progress", - }) - return - } else if err == whatsapp.ErrAlreadyLoggedIn { - jsonResponse(w, http.StatusConflict, Error{ - Error: "You were already connected.", - ErrCode: err.Error(), - }) - return - } - if err != nil { - user.log.Warnln("Error while reconnecting:", err) - jsonResponse(w, http.StatusInternalServerError, Error{ - Error: fmt.Sprintf("Unknown error while reconnecting: %v", err), - ErrCode: err.Error(), - }) - user.log.Debugln("Disconnecting due to failed session restore in reconnect command...") - err = user.Conn.Disconnect() - if err != nil { - user.log.Errorln("Failed to disconnect after failed session restore in reconnect command:", err) - } - return - } - user.ConnectionErrors = 0 - user.PostLogin() - - var msg string - if wasConnected { - msg = "Reconnected successfully." - } else { - msg = "Connected successfully." - } - - jsonResponse(w, http.StatusOK, Response{true, msg}) + // TODO reimplement + //user.log.Debugln("Received /reconnect request, disconnecting") + //wasConnected := true + //err := user.Conn.Disconnect() + //if err == whatsapp.ErrNotConnected { + // wasConnected = false + //} else if err != nil { + // user.log.Warnln("Error while disconnecting:", err) + //} + // + //user.log.Debugln("Restoring session for /reconnect") + //err = user.Conn.Restore(true, r.Context()) + //user.log.Debugfln("Restore session for /reconnect responded with %v", err) + //if err == whatsapp.ErrInvalidSession { + // if user.Session != nil { + // user.log.Debugln("Got invalid session error when reconnecting, but user has session. Retrying using RestoreWithSession()...") + // user.Conn.SetSession(*user.Session) + // err = user.Conn.Restore(true, r.Context()) + // } else { + // jsonResponse(w, http.StatusForbidden, Error{ + // Error: "You're not logged in", + // ErrCode: "not logged in", + // }) + // return + // } + //} + //if err == whatsapp.ErrLoginInProgress { + // jsonResponse(w, http.StatusConflict, Error{ + // Error: "A login or reconnection is already in progress.", + // ErrCode: "login in progress", + // }) + // return + //} else if err == whatsapp.ErrAlreadyLoggedIn { + // jsonResponse(w, http.StatusConflict, Error{ + // Error: "You were already connected.", + // ErrCode: err.Error(), + // }) + // return + //} + //if err != nil { + // user.log.Warnln("Error while reconnecting:", err) + // jsonResponse(w, http.StatusInternalServerError, Error{ + // Error: fmt.Sprintf("Unknown error while reconnecting: %v", err), + // ErrCode: err.Error(), + // }) + // user.log.Debugln("Disconnecting due to failed session restore in reconnect command...") + // err = user.Conn.Disconnect() + // if err != nil { + // user.log.Errorln("Failed to disconnect after failed session restore in reconnect command:", err) + // } + // return + //} + //user.ConnectionErrors = 0 + //user.PostLogin() + // + //var msg string + //if wasConnected { + // msg = "Reconnected successfully." + //} else { + // msg = "Connected successfully." + //} + // + //jsonResponse(w, http.StatusOK, Response{true, msg}) } func (prov *ProvisioningAPI) Ping(w http.ResponseWriter, r *http.Request) { @@ -259,32 +242,16 @@ func (prov *ProvisioningAPI) Ping(w http.ResponseWriter, r *http.Request) { wa := map[string]interface{}{ "has_session": user.Session != nil, "management_room": user.ManagementRoom, - "jid": user.JID, "conn": nil, - "ping": nil, } - if user.Conn != nil { + if user.JID.IsEmpty() { + wa["jid"] = user.JID.String() + } + if user.Client != nil { wa["conn"] = map[string]interface{}{ - "is_connected": user.Conn.IsConnected(), - "is_logged_in": user.Conn.IsLoggedIn(), - "is_login_in_progress": user.Conn.IsLoginInProgress(), + "is_connected": user.Client.IsConnected(), + "is_logged_in": user.Client.IsLoggedIn, } - user.log.Debugln("Pinging WhatsApp mobile due to /ping API request") - err := user.Conn.AdminTest() - var errStr string - if err == whatsapp.ErrPingFalse { - user.log.Debugln("Forwarding ping false error from provisioning API to HandleError") - go user.HandleError(err) - } - if err != nil { - errStr = err.Error() - } - wa["ping"] = map[string]interface{}{ - "ok": err == nil, - "err": errStr, - } - user.log.Debugfln("Admin test response for /ping: %v (conn: %t, login: %t, in progress: %t)", - err, user.Conn.IsConnected(), user.Conn.IsLoggedIn(), user.Conn.IsLoginInProgress()) } resp := map[string]interface{}{ "mxid": user.MXID, @@ -314,7 +281,7 @@ func (prov *ProvisioningAPI) Logout(w http.ResponseWriter, r *http.Request) { force := strings.ToLower(r.URL.Query().Get("force")) != "false" - if user.Conn == nil { + if user.Client == nil { if !force { jsonResponse(w, http.StatusNotFound, Error{ Error: "You're not connected", @@ -322,26 +289,24 @@ func (prov *ProvisioningAPI) Logout(w http.ResponseWriter, r *http.Request) { }) } } else { - err := user.Conn.Logout() - if err != nil { - user.log.Warnln("Error while logging out:", err) - if !force { - jsonResponse(w, http.StatusInternalServerError, Error{ - Error: fmt.Sprintf("Unknown error while logging out: %v", err), - ErrCode: err.Error(), - }) - return - } - } + // TODO reimplement + //err := user.Client.Logout() + //if err != nil { + // user.log.Warnln("Error while logging out:", err) + // if !force { + // jsonResponse(w, http.StatusInternalServerError, Error{ + // Error: fmt.Sprintf("Unknown error while logging out: %v", err), + // ErrCode: err.Error(), + // }) + // return + // } + //} user.DeleteConnection() } user.bridge.Metrics.TrackConnectionState(user.JID, false) user.removeFromJIDMap(StateLoggedOut) - - // TODO this causes a foreign key violation, which should be fixed - //ce.User.JID = "" - user.SetSession(nil) + user.DeleteSession() jsonResponse(w, http.StatusOK, Response{true, "Logged out successfully."}) } @@ -353,87 +318,88 @@ var upgrader = websocket.Upgrader{ } func (prov *ProvisioningAPI) Login(w http.ResponseWriter, r *http.Request) { - userID := r.URL.Query().Get("user_id") - user := prov.bridge.GetUserByMXID(id.UserID(userID)) - c, err := upgrader.Upgrade(w, r, nil) - if err != nil { - prov.log.Errorln("Failed to upgrade connection to websocket:", err) - return - } - defer c.Close() - - if !user.Connect(true) { - user.log.Debugln("Connect() returned false, assuming error was logged elsewhere and canceling login.") - _ = c.WriteJSON(Error{ - Error: "Failed to connect to WhatsApp", - ErrCode: "connection error", - }) - return - } - - qrChan := make(chan string, 3) - go func() { - for code := range qrChan { - if code == "stop" { - return - } - _ = c.WriteJSON(map[string]interface{}{ - "code": code, - }) - } - }() - - go func() { - // Read everything so SetCloseHandler() works - for { - _, _, err = c.ReadMessage() - if err != nil { - break - } - } - }() - ctx, cancel := context.WithCancel(context.Background()) - c.SetCloseHandler(func(code int, text string) error { - user.log.Debugfln("Login websocket closed (%d), cancelling login", code) - cancel() - return nil - }) - - user.log.Debugln("Starting login via provisioning API") - session, jid, err := user.Conn.Login(qrChan, ctx) - qrChan <- "stop" - if err != nil { - var msg string - if errors.Is(err, whatsapp.ErrAlreadyLoggedIn) { - msg = "You're already logged in" - } else if errors.Is(err, whatsapp.ErrLoginInProgress) { - msg = "You have a login in progress already." - } else if errors.Is(err, whatsapp.ErrLoginTimedOut) { - msg = "QR code scan timed out. Please try again." - } else if errors.Is(err, whatsapp.ErrInvalidWebsocket) { - msg = "WhatsApp connection error. Please try again." - // TODO might need to make sure it reconnects? - } else if errors.Is(err, whatsapp.ErrMultiDeviceNotSupported) { - msg = "WhatsApp multi-device is not currently supported. Please disable it and try again." - } else { - msg = fmt.Sprintf("Unknown error while logging in: %v", err) - } - user.log.Warnln("Failed to log in:", err) - _ = c.WriteJSON(Error{ - Error: msg, - ErrCode: err.Error(), - }) - return - } - user.log.Debugln("Successful login as", jid, "via provisioning API") - user.ConnectionErrors = 0 - user.JID = strings.Replace(jid, whatsapp.OldUserSuffix, whatsapp.NewUserSuffix, 1) - user.addToJIDMap() - user.SetSession(&session) - _ = c.WriteJSON(map[string]interface{}{ - "success": true, - "jid": user.JID, - }) - user.PostLogin() + // TODO reimplement + //userID := r.URL.Query().Get("user_id") + //user := prov.bridge.GetUserByMXID(id.UserID(userID)) + // + //c, err := upgrader.Upgrade(w, r, nil) + //if err != nil { + // prov.log.Errorln("Failed to upgrade connection to websocket:", err) + // return + //} + //defer c.Close() + //if !user.Connect(true) { + // user.log.Debugln("Connect() returned false, assuming error was logged elsewhere and canceling login.") + // _ = c.WriteJSON(Error{ + // Error: "Failed to connect to WhatsApp", + // ErrCode: "connection error", + // }) + // return + //} + // + //qrChan := make(chan string, 3) + //go func() { + // for code := range qrChan { + // if code == "stop" { + // return + // } + // _ = c.WriteJSON(map[string]interface{}{ + // "code": code, + // }) + // } + //}() + // + //go func() { + // // Read everything so SetCloseHandler() works + // for { + // _, _, err = c.ReadMessage() + // if err != nil { + // break + // } + // } + //}() + //ctx, cancel := context.WithCancel(context.Background()) + //c.SetCloseHandler(func(code int, text string) error { + // user.log.Debugfln("Login websocket closed (%d), cancelling login", code) + // cancel() + // return nil + //}) + // + //user.log.Debugln("Starting login via provisioning API") + //session, jid, err := user.Conn.Login(qrChan, ctx) + //qrChan <- "stop" + //if err != nil { + // var msg string + // if errors.Is(err, whatsapp.ErrAlreadyLoggedIn) { + // msg = "You're already logged in" + // } else if errors.Is(err, whatsapp.ErrLoginInProgress) { + // msg = "You have a login in progress already." + // } else if errors.Is(err, whatsapp.ErrLoginTimedOut) { + // msg = "QR code scan timed out. Please try again." + // } else if errors.Is(err, whatsapp.ErrInvalidWebsocket) { + // msg = "WhatsApp connection error. Please try again." + // // TODO might need to make sure it reconnects? + // } else if errors.Is(err, whatsapp.ErrMultiDeviceNotSupported) { + // msg = "WhatsApp multi-device is not currently supported. Please disable it and try again." + // } else { + // msg = fmt.Sprintf("Unknown error while logging in: %v", err) + // } + // user.log.Warnln("Failed to log in:", err) + // _ = c.WriteJSON(Error{ + // Error: msg, + // ErrCode: err.Error(), + // }) + // return + //} + //user.log.Debugln("Successful login as", jid, "via provisioning API") + //user.ConnectionErrors = 0 + //user.JID = strings.Replace(jid, whatsapp.OldUserSuffix, whatsapp.NewUserSuffix, 1) + //user.addToJIDMap() + //user.SetSession(&session) + //_ = c.WriteJSON(map[string]interface{}{ + // "success": true, + // "jid": user.JID, + //}) + //user.PostLogin() } diff --git a/puppet.go b/puppet.go index 557ef9a..254329d 100644 --- a/puppet.go +++ b/puppet.go @@ -1,5 +1,5 @@ // mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge. -// Copyright (C) 2020 Tulir Asokan +// Copyright (C) 2021 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by @@ -17,13 +17,15 @@ package main import ( + "errors" "fmt" + "io" "net/http" "regexp" - "strings" "sync" - "github.com/Rhymen/go-whatsapp" + "go.mau.fi/whatsmeow" + "go.mau.fi/whatsmeow/types" log "maunium.net/go/maulogger/v2" "maunium.net/go/mautrix/appservice" @@ -34,19 +36,18 @@ import ( var userIDRegex *regexp.Regexp -func (bridge *Bridge) ParsePuppetMXID(mxid id.UserID) (whatsapp.JID, bool) { +func (bridge *Bridge) ParsePuppetMXID(mxid id.UserID) (jid types.JID, ok bool) { if userIDRegex == nil { userIDRegex = regexp.MustCompile(fmt.Sprintf("^@%s:%s$", bridge.Config.Bridge.FormatUsername("([0-9]+)"), bridge.Config.Homeserver.Domain)) } match := userIDRegex.FindStringSubmatch(string(mxid)) - if match == nil || len(match) != 2 { - return "", false + if len(match) == 2 { + jid = types.NewJID(match[1], types.DefaultUserServer) + ok = true } - - jid := whatsapp.JID(match[1] + whatsapp.NewUserSuffix) - return jid, true + return } func (bridge *Bridge) GetPuppetByMXID(mxid id.UserID) *Puppet { @@ -58,7 +59,13 @@ func (bridge *Bridge) GetPuppetByMXID(mxid id.UserID) *Puppet { return bridge.GetPuppetByJID(jid) } -func (bridge *Bridge) GetPuppetByJID(jid whatsapp.JID) *Puppet { +func (bridge *Bridge) GetPuppetByJID(jid types.JID) *Puppet { + jid = jid.ToNonAD() + if jid.Server == types.LegacyUserServer { + jid.Server = types.DefaultUserServer + } else if jid.Server != types.DefaultUserServer { + return nil + } bridge.puppetsLock.Lock() defer bridge.puppetsLock.Unlock() puppet, ok := bridge.puppets[jid] @@ -123,12 +130,9 @@ func (bridge *Bridge) dbPuppetsToPuppets(dbPuppets []*database.Puppet) []*Puppet return output } -func (bridge *Bridge) FormatPuppetMXID(jid whatsapp.JID) id.UserID { +func (bridge *Bridge) FormatPuppetMXID(jid types.JID) id.UserID { return id.NewUserID( - bridge.Config.Bridge.FormatUsername( - strings.Replace( - jid, - whatsapp.NewUserSuffix, "", 1)), + bridge.Config.Bridge.FormatUsername(jid.User), bridge.Config.Homeserver.Domain) } @@ -160,13 +164,10 @@ type Puppet struct { syncLock sync.Mutex } -func (puppet *Puppet) PhoneNumber() string { - return strings.Replace(puppet.JID, whatsapp.NewUserSuffix, "", 1) -} - func (puppet *Puppet) IntentFor(portal *Portal) *appservice.IntentAPI { if (!portal.IsPrivateChat() && puppet.customIntent == nil) || - (portal.backfilling && portal.bridge.Config.Bridge.InviteOwnPuppetForBackfilling) || + // FIXME + //(portal.backfilling && portal.bridge.Config.Bridge.InviteOwnPuppetForBackfilling) || portal.Key.JID == puppet.JID { return puppet.DefaultIntent() } @@ -181,63 +182,64 @@ func (puppet *Puppet) DefaultIntent() *appservice.IntentAPI { return puppet.bridge.AS.Intent(puppet.MXID) } -func (puppet *Puppet) UpdateAvatar(source *User, avatar *whatsapp.ProfilePicInfo) bool { - if avatar == nil { - var err error - avatar, err = source.Conn.GetProfilePicThumb(puppet.JID) - if err != nil { - puppet.log.Warnln("Failed to get avatar:", err) - return false - } - } - - if avatar.Status == 404 { - avatar.Tag = "remove" - avatar.Status = 0 - } else if avatar.Status == 401 && puppet.Avatar != "unauthorized" { - puppet.Avatar = "unauthorized" - return true - } - if avatar.Status != 0 || avatar.Tag == puppet.Avatar { - return false - } - - if avatar.Tag == "remove" || len(avatar.URL) == 0 { - err := puppet.DefaultIntent().SetAvatarURL(id.ContentURI{}) - if err != nil { - puppet.log.Warnln("Failed to remove avatar:", err) - } - puppet.AvatarURL = id.ContentURI{} - puppet.Avatar = avatar.Tag - go puppet.updatePortalAvatar() - return true - } - - data, err := avatar.DownloadBytes() +func reuploadAvatar(intent *appservice.IntentAPI, url string) (id.ContentURI, error) { + getResp, err := http.DefaultClient.Get(url) if err != nil { - puppet.log.Warnln("Failed to download avatar:", err) - return false + return id.ContentURI{}, fmt.Errorf("failed to download avatar: %w", err) + } + data, err := io.ReadAll(getResp.Body) + _ = getResp.Body.Close() + if err != nil { + return id.ContentURI{}, fmt.Errorf("failed to read avatar bytes: %w", err) } mime := http.DetectContentType(data) - resp, err := puppet.DefaultIntent().UploadBytes(data, mime) + resp, err := intent.UploadBytes(data, mime) if err != nil { - puppet.log.Warnln("Failed to upload avatar:", err) + return id.ContentURI{}, fmt.Errorf("failed to upload avatar to Matrix: %w", err) + } + return resp.ContentURI, nil +} + +func (puppet *Puppet) UpdateAvatar(source *User) bool { + avatar, err := source.Client.GetProfilePictureInfo(puppet.JID, false) + if err != nil { + if !errors.Is(err, whatsmeow.ErrProfilePictureUnauthorized) { + puppet.log.Warnln("Failed to get avatar URL:", err) + } return false + } else if avatar == nil { + if puppet.Avatar == "remove" { + return false + } + puppet.AvatarURL = id.ContentURI{} + avatar = &types.ProfilePictureInfo{ID: "remove"} + } else if avatar.ID == puppet.Avatar { + return false + } else if len(avatar.URL) == 0 { + puppet.log.Warnln("Didn't get URL in response to avatar query") + return false + } else { + url, err := reuploadAvatar(puppet.DefaultIntent(), avatar.URL) + if err != nil { + puppet.log.Warnln("Failed to reupload avatar:", err) + return false + } + + puppet.AvatarURL = url } - puppet.AvatarURL = resp.ContentURI err = puppet.DefaultIntent().SetAvatarURL(puppet.AvatarURL) if err != nil { puppet.log.Warnln("Failed to set avatar:", err) } - puppet.Avatar = avatar.Tag + puppet.Avatar = avatar.ID go puppet.updatePortalAvatar() return true } -func (puppet *Puppet) UpdateName(source *User, contact whatsapp.Contact) bool { - newName, quality := puppet.bridge.Config.Bridge.FormatDisplayname(contact) +func (puppet *Puppet) UpdateName(source *User, contact types.ContactInfo) bool { + newName, quality := puppet.bridge.Config.Bridge.FormatDisplayname(puppet.JID, contact) if puppet.Displayname != newName && quality >= puppet.NameQuality { err := puppet.DefaultIntent().SetDisplayName(newName) if err == nil { @@ -288,25 +290,21 @@ func (puppet *Puppet) updatePortalName() { }) } -func (puppet *Puppet) SyncContactIfNecessary(source *User) { - if len(puppet.Displayname) > 0 { +func (puppet *Puppet) SyncContact(source *User, onlyIfNoName bool) { + if onlyIfNoName && len(puppet.Displayname) > 0 { return } - source.Conn.Store.ContactsLock.RLock() - contact, ok := source.Conn.Store.Contacts[puppet.JID] - source.Conn.Store.ContactsLock.RUnlock() - if !ok { - puppet.log.Warnfln("No contact info found through %s in SyncContactIfNecessary", source.MXID) - contact.JID = puppet.JID - // Sync anyway to set a phone number name - } else { - puppet.log.Debugfln("Syncing contact info through %s / %s because puppet has no displayname", source.MXID, source.JID) + contact, err := source.Client.Store.Contacts.GetContact(puppet.JID) + if err != nil { + puppet.log.Warnfln("Failed to get contact info through %s in SyncContact: %v", source.MXID) + } else if !contact.Found { + puppet.log.Warnfln("No contact info found through %s in SyncContact", source.MXID) } puppet.Sync(source, contact) } -func (puppet *Puppet) Sync(source *User, contact whatsapp.Contact) { +func (puppet *Puppet) Sync(source *User, contact types.ContactInfo) { puppet.syncLock.Lock() defer puppet.syncLock.Unlock() err := puppet.DefaultIntent().EnsureRegistered() @@ -314,15 +312,14 @@ func (puppet *Puppet) Sync(source *User, contact whatsapp.Contact) { puppet.log.Errorln("Failed to ensure registered:", err) } - if contact.JID == source.JID { - contact.Notify = source.pushName + if puppet.JID.User == source.JID.User { + contact.PushName = source.Client.Store.PushName } update := false update = puppet.UpdateName(source, contact) || update - // TODO figure out how to update avatars after being offline if len(puppet.Avatar) == 0 || puppet.bridge.Config.Bridge.UserAvatarSync { - update = puppet.UpdateAvatar(source, nil) || update + update = puppet.UpdateAvatar(source) || update } if update { puppet.Update() diff --git a/user.go b/user.go index 9f0e16b..d907008 100644 --- a/user.go +++ b/user.go @@ -1,5 +1,5 @@ // mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge. -// Copyright (C) 2020 Tulir Asokan +// Copyright (C) 2021 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by @@ -17,26 +17,23 @@ package main import ( - "context" "encoding/json" "errors" "fmt" "net/http" - "sort" - "strings" "sync" - "sync/atomic" "time" - "github.com/skip2/go-qrcode" log "maunium.net/go/maulogger/v2" "maunium.net/go/mautrix/appservice" "maunium.net/go/mautrix/pushrules" - "github.com/Rhymen/go-whatsapp" - waBinary "github.com/Rhymen/go-whatsapp/binary" - waProto "github.com/Rhymen/go-whatsapp/binary/proto" + "go.mau.fi/whatsmeow" + "go.mau.fi/whatsmeow/store" + "go.mau.fi/whatsmeow/types" + "go.mau.fi/whatsmeow/types/events" + waLog "go.mau.fi/whatsmeow/util/log" "maunium.net/go/mautrix" "maunium.net/go/mautrix/event" @@ -48,7 +45,8 @@ import ( type User struct { *database.User - Conn *whatsapp.Conn + Client *whatsmeow.Client + Session *store.Device bridge *Bridge log log.Logger @@ -59,27 +57,11 @@ type User struct { IsRelaybot bool - ConnectionErrors int - CommunityID string + mgmtCreateLock sync.Mutex + connLock sync.Mutex - cleanDisconnection bool - batteryWarningsSent int - lastReconnection int64 - pushName string - - chatListReceived chan struct{} - syncPortalsDone chan struct{} - - messageInput chan PortalMessage - messageOutput chan PortalMessage - - syncStart chan struct{} - syncWait sync.WaitGroup - syncing int32 - - mgmtCreateLock sync.Mutex - connLock sync.Mutex - cancelReconnect func() + qrListener chan<- *events.QR + loginListener chan<- *events.PairSuccess prevBridgeStatus *BridgeState } @@ -98,27 +80,27 @@ func (bridge *Bridge) GetUserByMXID(userID id.UserID) *User { return user } -func (bridge *Bridge) GetUserByJID(userID whatsapp.JID) *User { +func (bridge *Bridge) GetUserByJID(jid types.JID) *User { bridge.usersLock.Lock() defer bridge.usersLock.Unlock() - user, ok := bridge.usersByJID[userID] + user, ok := bridge.usersByUsername[jid.User] if !ok { - return bridge.loadDBUser(bridge.DB.User.GetByJID(userID), nil) + return bridge.loadDBUser(bridge.DB.User.GetByUsername(jid.User), nil) } return user } func (user *User) addToJIDMap() { user.bridge.usersLock.Lock() - user.bridge.usersByJID[user.JID] = user + user.bridge.usersByUsername[user.JID.User] = user user.bridge.usersLock.Unlock() } func (user *User) removeFromJIDMap(state BridgeStateEvent) { user.bridge.usersLock.Lock() - jidUser, ok := user.bridge.usersByJID[user.JID] + jidUser, ok := user.bridge.usersByUsername[user.JID.User] if ok && user == jidUser { - delete(user.bridge.usersByJID, user.JID) + delete(user.bridge.usersByUsername, user.JID.User) } user.bridge.usersLock.Unlock() user.bridge.Metrics.TrackLoginState(user.JID, false) @@ -151,8 +133,18 @@ func (bridge *Bridge) loadDBUser(dbUser *database.User, mxid *id.UserID) *User { } user := bridge.NewUser(dbUser) bridge.usersByMXID[user.MXID] = user - if len(user.JID) > 0 { - bridge.usersByJID[user.JID] = user + if !user.JID.IsEmpty() { + var err error + user.Session, err = bridge.WAContainer.GetDevice(user.JID) + if err != nil { + user.log.Errorfln("Failed to scan user's whatsapp session: %v", err) + } else if user.Session == nil { + user.log.Warnfln("Didn't find session data for %s, treating user as logged out", user.JID) + user.JID = types.EmptyJID + user.Update() + } else { + bridge.usersByUsername[user.JID.User] = user + } } if len(user.ManagementRoom) > 0 { bridge.managementRooms[user.ManagementRoom] = user @@ -161,19 +153,21 @@ func (bridge *Bridge) loadDBUser(dbUser *database.User, mxid *id.UserID) *User { } func (user *User) GetPortals() []*Portal { - keys := user.User.GetPortalKeys() - portals := make([]*Portal, len(keys)) - - user.bridge.portalsLock.Lock() - for i, key := range keys { - portal, ok := user.bridge.portalsByJID[key] - if !ok { - portal = user.bridge.loadDBPortal(user.bridge.DB.Portal.GetByJID(key), &key) - } - portals[i] = portal - } - user.bridge.portalsLock.Unlock() - return portals + // FIXME + //keys := user.User.GetPortalKeys() + //portals := make([]*Portal, len(keys)) + // + //user.bridge.portalsLock.Lock() + //for i, key := range keys { + // portal, ok := user.bridge.portalsByJID[key] + // if !ok { + // portal = user.bridge.loadDBPortal(user.bridge.DB.Portal.GetByJID(key), &key) + // } + // portals[i] = portal + //} + //user.bridge.portalsLock.Unlock() + //return portals + return nil } func (bridge *Bridge) NewUser(dbUser *database.User) *User { @@ -183,18 +177,10 @@ func (bridge *Bridge) NewUser(dbUser *database.User) *User { log: bridge.Log.Sub("User").Sub(string(dbUser.MXID)), IsRelaybot: false, - - chatListReceived: make(chan struct{}, 1), - syncPortalsDone: make(chan struct{}, 1), - syncStart: make(chan struct{}, 1), - messageInput: make(chan PortalMessage), - messageOutput: make(chan PortalMessage, bridge.Config.Bridge.UserMessageBuffer), } user.RelaybotWhitelisted = user.bridge.Config.Bridge.Permissions.IsRelaybotWhitelisted(user.MXID) user.Whitelisted = user.bridge.Config.Bridge.Permissions.IsWhitelisted(user.MXID) user.Admin = user.bridge.Config.Bridge.Permissions.IsAdmin(user.MXID) - go user.handleMessageLoop() - go user.runMessageRingBuffer() return user } @@ -230,255 +216,82 @@ func (user *User) SetManagementRoom(roomID id.RoomID) { user.Update() } -func (user *User) SetSession(session *whatsapp.Session) { - if session == nil { - user.Session = nil - user.LastConnection = 0 - } else if len(session.Wid) > 0 { - user.Session = session - } else { - return - } - user.Update() -} +type waLogger struct{ l log.Logger } + +func (w *waLogger) Debugf(msg string, args ...interface{}) { w.l.Debugfln(msg, args...) } +func (w *waLogger) Infof(msg string, args ...interface{}) { w.l.Infofln(msg, args...) } +func (w *waLogger) Warnf(msg string, args ...interface{}) { w.l.Warnfln(msg, args...) } +func (w *waLogger) Errorf(msg string, args ...interface{}) { w.l.Errorfln(msg, args...) } +func (w *waLogger) Sub(module string) waLog.Logger { return &waLogger{l: w.l.Sub(module)} } func (user *User) Connect(evenIfNoSession bool) bool { user.connLock.Lock() - if user.Conn != nil { - user.connLock.Unlock() - if user.Conn.IsConnected() { - return true - } else { - return user.RestoreSession() - } + defer user.connLock.Unlock() + if user.Client != nil { + return user.Client.IsConnected() } else if !evenIfNoSession && user.Session == nil { - user.connLock.Unlock() return false } user.log.Debugln("Connecting to WhatsApp") if user.Session != nil { user.sendBridgeState(BridgeState{StateEvent: StateConnecting, Error: WAConnecting}) } - timeout := time.Duration(user.bridge.Config.Bridge.ConnectionTimeout) - if timeout == 0 { - timeout = 20 + if user.Session == nil { + newSession := user.bridge.WAContainer.NewDevice() + newSession.Log = &waLogger{user.log.Sub("Session")} + user.Client = whatsmeow.NewClient(newSession, &waLogger{user.log.Sub("Client")}) + } else { + user.Client = whatsmeow.NewClient(user.Session, &waLogger{user.log.Sub("Client")}) } - user.Conn = whatsapp.NewConn(&whatsapp.Options{ - Timeout: timeout * time.Second, - LongClientName: user.bridge.Config.WhatsApp.OSName, - ShortClientName: user.bridge.Config.WhatsApp.BrowserName, - ClientVersion: WAVersion, - Log: user.log.Sub("Conn"), - Handler: []whatsapp.Handler{user}, - }) - user.setupAdminTestHooks() - user.connLock.Unlock() - return user.RestoreSession() + user.Client.AddEventHandler(user.HandleEvent) + err := user.Client.Connect() + if err != nil { + user.log.Warnln("Error connecting to WhatsApp:", err) + return false + } + return true } func (user *User) DeleteConnection() { user.connLock.Lock() - if user.Conn == nil { - user.connLock.Unlock() + defer user.connLock.Unlock() + if user.Client == nil { return } - err := user.Conn.Disconnect() - if err != nil && err != whatsapp.ErrNotConnected { - user.log.Warnln("Error disconnecting: %v", err) - } - user.Conn.RemoveHandlers() - user.Conn = nil + user.Client.Disconnect() + user.Client.RemoveEventHandlers() + user.Client = nil user.bridge.Metrics.TrackConnectionState(user.JID, false) user.sendBridgeState(BridgeState{StateEvent: StateBadCredentials, Error: WANotConnected}) - user.connLock.Unlock() -} - -func (user *User) RestoreSession() bool { - if user.Session != nil { - user.Conn.SetSession(*user.Session) - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) - defer cancel() - err := user.Conn.Restore(true, ctx) - if err == whatsapp.ErrAlreadyLoggedIn { - return true - } else if err != nil { - user.log.Errorln("Failed to restore session:", err) - if errors.Is(err, whatsapp.ErrUnpaired) { - user.sendMarkdownBridgeAlert("\u26a0 Failed to connect to WhatsApp: unpaired from phone. " + - "To re-pair your phone, log in again.") - user.removeFromJIDMap(StateBadCredentials) - //user.JID = "" - user.SetSession(nil) - user.DeleteConnection() - return false - } else { - user.sendBridgeState(BridgeState{StateEvent: StateBadCredentials, Error: WANotConnected}) - user.sendMarkdownBridgeAlert("\u26a0 Failed to connect to WhatsApp. Make sure WhatsApp " + - "on your phone is reachable and use `reconnect` to try connecting again.") - } - user.log.Debugln("Disconnecting due to failed session restore...") - err = user.Conn.Disconnect() - if err != nil { - user.log.Errorln("Failed to disconnect after failed session restore:", err) - } - return false - } - user.ConnectionErrors = 0 - user.log.Debugln("Session restored successfully") - user.PostLogin() - } - return true } func (user *User) HasSession() bool { return user.Session != nil } -func (user *User) IsConnected() bool { - return user.Conn != nil && user.Conn.IsConnected() && user.Conn.IsLoggedIn() -} - -func (user *User) IsLoginInProgress() bool { - return user.Conn != nil && user.Conn.IsLoginInProgress() -} - -func (user *User) loginQrChannel(ce *CommandEvent, qrChan <-chan string, eventIDChan chan<- id.EventID) { - var qrEventID id.EventID - for code := range qrChan { - if code == "stop" { - return - } - qrCode, err := qrcode.Encode(code, qrcode.Low, 256) +func (user *User) DeleteSession() { + if user.Session != nil { + err := user.Session.Delete() if err != nil { - user.log.Errorln("Failed to encode QR code:", err) - ce.Reply("Failed to encode QR code: %v", err) - return - } - - bot := user.bridge.AS.BotClient() - - resp, err := bot.UploadBytes(qrCode, "image/png") - if err != nil { - user.log.Errorln("Failed to upload QR code:", err) - ce.Reply("Failed to upload QR code: %v", err) - return - } - - if qrEventID == "" { - sendResp, err := bot.SendImage(ce.RoomID, code, resp.ContentURI) - if err != nil { - user.log.Errorln("Failed to send QR code to user:", err) - return - } - qrEventID = sendResp.EventID - eventIDChan <- qrEventID - } else { - _, err = bot.SendMessageEvent(ce.RoomID, event.EventMessage, &event.MessageEventContent{ - MsgType: event.MsgImage, - Body: code, - URL: resp.ContentURI.CUString(), - NewContent: &event.MessageEventContent{ - MsgType: event.MsgImage, - Body: code, - URL: resp.ContentURI.CUString(), - }, - RelatesTo: &event.RelatesTo{ - Type: event.RelReplace, - EventID: qrEventID, - }, - }) - if err != nil { - user.log.Errorln("Failed to send edited QR code to user:", err) - } + user.log.Warnln("Failed to delete session:", err) } + user.Session = nil + } + if !user.JID.IsEmpty() { + user.JID = types.EmptyJID + user.Update() } } -func (user *User) Login(ce *CommandEvent) { - qrChan := make(chan string, 3) - eventIDChan := make(chan id.EventID, 1) - go user.loginQrChannel(ce, qrChan, eventIDChan) - session, jid, err := user.Conn.Login(qrChan, nil) - qrChan <- "stop" - if err != nil { - var eventID id.EventID - select { - case eventID = <-eventIDChan: - default: - } - reply := event.MessageEventContent{ - MsgType: event.MsgText, - } - if err == whatsapp.ErrAlreadyLoggedIn { - reply.Body = "You're already logged in" - } else if err == whatsapp.ErrLoginInProgress { - reply.Body = "You have a login in progress already." - } else if err == whatsapp.ErrLoginTimedOut { - reply.Body = "QR code scan timed out. Please try again." - } else if errors.Is(err, whatsapp.ErrMultiDeviceNotSupported) { - reply.Body = "WhatsApp multi-device is not currently supported. Please disable it and try again." - } else { - user.log.Warnln("Failed to log in:", err) - reply.Body = fmt.Sprintf("Unknown error while logging in: %v", err) - } - msg := reply - if eventID != "" { - msg.NewContent = &reply - msg.RelatesTo = &event.RelatesTo{ - Type: event.RelReplace, - EventID: eventID, - } - } - _, _ = ce.Bot.SendMessageEvent(ce.RoomID, event.EventMessage, &msg) - return - } - // TODO there's a bit of duplication between this and the provisioning API login method - // Also between the two logout methods (commands.go and provisioning.go) - user.log.Debugln("Successful login as", jid, "via command") - user.ConnectionErrors = 0 - user.JID = strings.Replace(jid, whatsapp.OldUserSuffix, whatsapp.NewUserSuffix, 1) - user.addToJIDMap() - user.SetSession(&session) - ce.Reply("Successfully logged in, synchronizing chats...") - user.PostLogin() -} - -type Chat struct { - whatsapp.Chat - Portal *Portal - Contact whatsapp.Contact -} - -type ChatList []Chat - -func (cl ChatList) Len() int { - return len(cl) -} - -func (cl ChatList) Less(i, j int) bool { - return cl[i].LastMessageTime > cl[j].LastMessageTime -} - -func (cl ChatList) Swap(i, j int) { - cl[i], cl[j] = cl[j], cl[i] +func (user *User) IsLoggedIn() bool { + return user.Client != nil && user.Client.IsConnected() && user.Client.IsLoggedIn } func (user *User) PostLogin() { - user.sendBridgeState(BridgeState{StateEvent: StateBackfilling}) + user.sendBridgeState(BridgeState{StateEvent: StateConnected}) user.bridge.Metrics.TrackConnectionState(user.JID, true) user.bridge.Metrics.TrackLoginState(user.JID, true) - user.bridge.Metrics.TrackBufferLength(user.MXID, len(user.messageOutput)) - if !atomic.CompareAndSwapInt32(&user.syncing, 0, 1) { - // TODO we should cleanly stop the old sync and start a new one instead of not starting a new one - user.log.Warnln("There seems to be a post-sync already in progress, not starting a new one") - return - } - user.log.Debugln("Locking processing of incoming messages and starting post-login sync") - user.chatListReceived = make(chan struct{}, 1) - user.syncPortalsDone = make(chan struct{}, 1) - user.syncWait.Add(1) - user.syncStart <- struct{}{} - go user.intPostLogin() + go user.tryAutomaticDoublePuppeting() } func (user *User) tryAutomaticDoublePuppeting() { @@ -522,172 +335,84 @@ func (user *User) sendMarkdownBridgeAlert(formatString string, args ...interface } } -func (user *User) postConnPing() bool { - user.log.Debugln("Making post-connection ping") - var err error - for i := 0; ; i++ { - err = user.Conn.AdminTest() - if err == nil { - user.log.Debugln("Post-connection ping OK") - return true - } else if errors.Is(err, whatsapp.ErrConnectionTimeout) && i < 5 { - user.log.Warnfln("Post-connection ping timed out, sending new one") - } else { - break - } - } - user.log.Errorfln("Post-connection ping failed: %v. Disconnecting and then reconnecting after a second", err) - disconnectErr := user.Conn.Disconnect() - if disconnectErr != nil { - user.log.Warnln("Error while disconnecting after failed post-connection ping:", disconnectErr) - } - user.sendBridgeState(BridgeState{StateEvent: StateBadCredentials, Error: WANotConnected}) - user.bridge.Metrics.TrackDisconnection(user.MXID) - go func() { - time.Sleep(1 * time.Second) - user.tryReconnect(fmt.Sprintf("Post-connection ping failed: %v", err)) - }() - return false -} - -func (user *User) intPostLogin() { - defer atomic.StoreInt32(&user.syncing, 0) - defer user.syncWait.Done() - user.lastReconnection = time.Now().Unix() - user.createCommunity() - user.tryAutomaticDoublePuppeting() - - user.log.Debugln("Waiting for chat list receive confirmation") - select { - case <-user.chatListReceived: - user.log.Debugln("Chat list receive confirmation received in PostLogin") - case <-time.After(time.Duration(user.bridge.Config.Bridge.ChatListWait) * time.Second): - user.log.Warnln("Timed out waiting for chat list to arrive!") - user.postConnPing() - return - } - - if !user.postConnPing() { - user.log.Debugln("Post-connection ping failed, unlocking processing of incoming messages.") - return - } - - user.log.Debugln("Waiting for portal sync complete confirmation") - select { - case <-user.syncPortalsDone: - user.log.Debugln("Post-connection portal sync complete, unlocking processing of incoming messages.") - // TODO this is too short, maybe a per-portal duration? - case <-time.After(time.Duration(user.bridge.Config.Bridge.PortalSyncWait) * time.Second): - user.log.Warnln("Timed out waiting for portal sync to complete! Unlocking processing of incoming messages.") - } - user.sendBridgeState(BridgeState{StateEvent: StateConnected}) -} - -type NormalMessage interface { - GetInfo() whatsapp.MessageInfo -} - func (user *User) HandleEvent(event interface{}) { switch v := event.(type) { - case NormalMessage: - info := v.GetInfo() - user.messageInput <- PortalMessage{info.RemoteJid, user, v, info.Timestamp} - case whatsapp.MessageRevocation: - user.messageInput <- PortalMessage{v.RemoteJid, user, v, 0} - case whatsapp.StreamEvent: - user.HandleStreamEvent(v) - case []whatsapp.Chat: - user.HandleChatList(v) - case []whatsapp.Contact: - user.HandleContactList(v) - case error: - user.HandleError(v) - case whatsapp.Contact: - go user.HandleNewContact(v) - case whatsapp.BatteryMessage: - user.HandleBatteryMessage(v) - case whatsapp.CallInfo: - user.HandleCallInfo(v) - case whatsapp.PresenceEvent: - go user.HandlePresence(v) - case whatsapp.JSONMsgInfo: - go user.HandleMsgInfo(v) - case whatsapp.ReceivedMessage: - user.HandleReceivedMessage(v) - case whatsapp.ReadMessage: - user.HandleReadMessage(v) - case whatsapp.JSONCommand: - user.HandleCommand(v) - case whatsapp.ChatUpdate: - user.HandleChatUpdate(v) - case whatsapp.ConnInfo: - user.HandleConnInfo(v) - case whatsapp.MuteMessage: + case *events.LoggedOut: + go user.handleLoggedOut() + user.bridge.Metrics.TrackConnectionState(user.JID, false) + user.bridge.Metrics.TrackLoginState(user.JID, false) + case *events.Connected: + go user.sendBridgeState(BridgeState{StateEvent: StateConnected}) + user.bridge.Metrics.TrackConnectionState(user.JID, true) + user.bridge.Metrics.TrackLoginState(user.JID, true) + go func() { + err := user.Client.SendPresence(types.PresenceUnavailable) + if err != nil { + user.log.Warnln("Failed to send initial presence:", err) + } + }() + case *events.PairSuccess: + user.JID = v.ID + user.addToJIDMap() + user.Update() + user.Session = user.Client.Store + if user.loginListener != nil { + select { + case user.loginListener <- v: + return + default: + } + } + user.log.Warnln("Got pair success event, but nothing waiting for it") + case *events.QR: + if user.qrListener != nil { + select { + case user.qrListener <- v: + return + default: + } + } + user.log.Warnln("Got QR code event, but nothing waiting for it") + case *events.ConnectFailure, *events.StreamError: + go user.sendBridgeState(BridgeState{StateEvent: StateUnknownError}) + user.bridge.Metrics.TrackConnectionState(user.JID, false) + case *events.Disconnected: + go user.sendBridgeState(BridgeState{StateEvent: StateTransientDisconnect}) + user.bridge.Metrics.TrackConnectionState(user.JID, false) + case *events.Contact: + go user.syncPuppet(v.JID) + case *events.PushName: + go user.syncPuppet(v.JID) + case *events.Receipt: + go user.handleReceipt(v) + case *events.Message: + portal := user.GetPortalByJID(v.Info.Chat) + portal.messages <- PortalMessage{v, user} + case *events.Mute: portal := user.bridge.GetPortalByJID(user.PortalKey(v.JID)) if portal != nil { - go user.updateChatMute(nil, portal, v.MutedUntil) + var mutedUntil time.Time + if v.Action.GetMuted() { + mutedUntil = time.Unix(v.Action.GetMuteEndTimestamp(), 0) + } + go user.updateChatMute(nil, portal, mutedUntil) } - case whatsapp.ArchiveMessage: + case *events.Archive: portal := user.bridge.GetPortalByJID(user.PortalKey(v.JID)) if portal != nil { - go user.updateChatTag(nil, portal, user.bridge.Config.Bridge.ArchiveTag, v.IsArchived) + go user.updateChatTag(nil, portal, user.bridge.Config.Bridge.ArchiveTag, v.Action.GetArchived()) } - case whatsapp.PinMessage: + case *events.Pin: portal := user.bridge.GetPortalByJID(user.PortalKey(v.JID)) if portal != nil { - go user.updateChatTag(nil, portal, user.bridge.Config.Bridge.PinnedTag, v.IsPinned) + go user.updateChatTag(nil, portal, user.bridge.Config.Bridge.PinnedTag, v.Action.GetPinned()) } - case whatsapp.RawJSONMessage: - user.HandleJSONMessage(v) - case *waProto.WebMessageInfo: - user.updateLastConnectionIfNecessary() - // TODO trace log - //user.log.Debugfln("WebMessageInfo: %+v", v) - case *waBinary.Node: - user.log.Debugfln("Unknown binary message: %+v", v) default: user.log.Debugfln("Unknown type of event in HandleEvent: %T", v) } } -func (user *User) HandleStreamEvent(evt whatsapp.StreamEvent) { - if evt.Type == whatsapp.StreamSleep { - if user.lastReconnection+60 > time.Now().Unix() { - user.lastReconnection = 0 - user.log.Infoln("Stream went to sleep soon after reconnection, making new post-connection ping in 20 seconds") - go func() { - time.Sleep(20 * time.Second) - // TODO if this happens during the post-login sync, it can get stuck forever - // TODO check if the above is still true - user.postConnPing() - }() - } - } else { - user.log.Infofln("Stream event: %+v", evt) - } -} - -func (user *User) HandleChatList(chats []whatsapp.Chat) { - user.log.Infoln("Chat list received") - chatMap := make(map[string]whatsapp.Chat) - user.Conn.Store.ChatsLock.RLock() - for _, chat := range user.Conn.Store.Chats { - chatMap[chat.JID] = chat - } - user.Conn.Store.ChatsLock.RUnlock() - for _, chat := range chats { - chatMap[chat.JID] = chat - } - select { - case user.chatListReceived <- struct{}{}: - user.log.Debugln("Sent chat list receive confirmation from HandleChatList") - default: - user.log.Debugln("Failed to send chat list receive confirmation from HandleChatList, channel probably full") - } - go user.syncPortals(chatMap, false) -} - -func (user *User) updateChatMute(intent *appservice.IntentAPI, portal *Portal, mutedUntil int64) { +func (user *User) updateChatMute(intent *appservice.IntentAPI, portal *Portal, mutedUntil time.Time) { if len(portal.MXID) == 0 || !user.bridge.Config.Bridge.MuteBridging { return } else if intent == nil { @@ -698,7 +423,7 @@ func (user *User) updateChatMute(intent *appservice.IntentAPI, portal *Portal, m intent = doublePuppet.CustomIntent() } var err error - if mutedUntil != -1 && mutedUntil < time.Now().Unix() { + if mutedUntil.IsZero() && mutedUntil.Before(time.Now()) { user.log.Debugfln("Portal %s is muted until %d, unmuting...", portal.MXID, mutedUntil) err = intent.DeletePushRule("global", pushrules.RoomRule, string(portal.MXID)) } else { @@ -757,126 +482,94 @@ type CustomReadReceipt struct { DoublePuppet bool `json:"net.maunium.whatsapp.puppet,omitempty"` } -func (user *User) syncChatDoublePuppetDetails(doublePuppet *Puppet, chat Chat, justCreated bool) { - if doublePuppet == nil || doublePuppet.CustomIntent() == nil || len(chat.Portal.MXID) == 0 { +func (user *User) syncChatDoublePuppetDetails(doublePuppet *Puppet, portal *Portal, justCreated bool) { + if doublePuppet == nil || doublePuppet.CustomIntent() == nil || len(portal.MXID) == 0 { return } intent := doublePuppet.CustomIntent() - if chat.UnreadCount == 0 && (justCreated || !user.bridge.Config.Bridge.MarkReadOnlyOnCreate) { - lastMessage := user.bridge.DB.Message.GetLastInChatBefore(chat.Portal.Key, chat.ReceivedAt.Unix()) - if lastMessage != nil { - err := intent.MarkReadWithContent(chat.Portal.MXID, lastMessage.MXID, &CustomReadReceipt{DoublePuppet: true}) - if err != nil { - user.log.Warnfln("Failed to mark %s in %s as read after backfill: %v", lastMessage.MXID, chat.Portal.MXID, err) - } - } - } else if chat.UnreadCount == -1 { - user.log.Debugfln("Invalid unread count (missing field?) in chat info %+v", chat.Source) - } + // FIXME this might not be possible to do anymore + //if chat.UnreadCount == 0 && (justCreated || !user.bridge.Config.Bridge.MarkReadOnlyOnCreate) { + // lastMessage := user.bridge.DB.Message.GetLastInChatBefore(chat.Portal.Key, chat.ReceivedAt.Unix()) + // if lastMessage != nil { + // err := intent.MarkReadWithContent(chat.Portal.MXID, lastMessage.MXID, &CustomReadReceipt{DoublePuppet: true}) + // if err != nil { + // user.log.Warnfln("Failed to mark %s in %s as read after backfill: %v", lastMessage.MXID, chat.Portal.MXID, err) + // } + // } + //} else if chat.UnreadCount == -1 { + // user.log.Debugfln("Invalid unread count (missing field?) in chat info %+v", chat.Source) + //} if justCreated || !user.bridge.Config.Bridge.TagOnlyOnCreate { - user.updateChatMute(intent, chat.Portal, chat.MutedUntil) - user.updateChatTag(intent, chat.Portal, user.bridge.Config.Bridge.ArchiveTag, chat.IsArchived) - user.updateChatTag(intent, chat.Portal, user.bridge.Config.Bridge.PinnedTag, chat.IsPinned) - } -} - -func (user *User) syncPortal(chat Chat) { - // Don't sync unless chat meta sync is enabled or portal doesn't exist - if user.bridge.Config.Bridge.ChatMetaSync || len(chat.Portal.MXID) == 0 { - failedToCreate := chat.Portal.Sync(user, chat.Contact) - if failedToCreate { + chat, err := user.Client.Store.ChatSettings.GetChatSettings(portal.Key.JID) + if err != nil { + user.log.Warnfln("Failed to get settings of %s: %v", portal.Key.JID, err) return } - } - err := chat.Portal.BackfillHistory(user, chat.LastMessageTime) - if err != nil { - chat.Portal.log.Errorln("Error backfilling history:", err) + user.updateChatMute(intent, portal, chat.MutedUntil) + user.updateChatTag(intent, portal, user.bridge.Config.Bridge.ArchiveTag, chat.Archived) + user.updateChatTag(intent, portal, user.bridge.Config.Bridge.PinnedTag, chat.Pinned) } } -func (user *User) collectChatList(chatMap map[string]whatsapp.Chat) ChatList { - if chatMap == nil { - chatMap = user.Conn.Store.Chats - } - user.log.Infoln("Reading chat list") - chats := make(ChatList, 0, len(chatMap)) - existingKeys := user.GetInCommunityMap() - portalKeys := make([]database.PortalKeyWithMeta, 0, len(chatMap)) - for _, chat := range chatMap { - portal := user.GetPortalByJID(chat.JID) - - user.Conn.Store.ContactsLock.RLock() - contact, _ := user.Conn.Store.Contacts[chat.JID] - user.Conn.Store.ContactsLock.RUnlock() - chats = append(chats, Chat{ - Chat: chat, - Portal: portal, - Contact: contact, - }) - var inCommunity, ok bool - if inCommunity, ok = existingKeys[portal.Key]; !ok || !inCommunity { - inCommunity = user.addPortalToCommunity(portal) - if portal.IsPrivateChat() { - puppet := user.bridge.GetPuppetByJID(portal.Key.JID) - user.addPuppetToCommunity(puppet) - } - } - portalKeys = append(portalKeys, database.PortalKeyWithMeta{PortalKey: portal.Key, InCommunity: inCommunity}) - } - user.log.Infoln("Read chat list, updating user-portal mapping") - err := user.SetPortalKeys(portalKeys) - if err != nil { - user.log.Warnln("Failed to update user-portal mapping:", err) - } - sort.Sort(chats) - return chats -} - -func (user *User) syncPortals(chatMap map[string]whatsapp.Chat, createAll bool) { - // TODO use contexts instead of checking if user.Conn is the same? - connAtStart := user.Conn - - chats := user.collectChatList(chatMap) - - limit := user.bridge.Config.Bridge.InitialChatSync - if limit < 0 { - limit = len(chats) - } - if user.Conn != connAtStart { - user.log.Debugln("Connection seems to have changed before sync, cancelling") - return - } - now := time.Now().Unix() - user.log.Infoln("Syncing portals") - doublePuppet := user.bridge.GetPuppetByCustomMXID(user.MXID) - for i, chat := range chats { - if chat.LastMessageTime+user.bridge.Config.Bridge.SyncChatMaxAge < now { - break - } - create := (chat.LastMessageTime >= user.LastConnection && user.LastConnection > 0) || i < limit - if len(chat.Portal.MXID) > 0 || create || createAll { - user.log.Debugfln("Syncing chat %+v", chat.Chat.Source) - justCreated := len(chat.Portal.MXID) == 0 - user.syncPortal(chat) - user.syncChatDoublePuppetDetails(doublePuppet, chat, justCreated) - } - } - if user.Conn != connAtStart { - user.log.Debugln("Connection seems to have changed during sync, cancelling") - return - } - user.UpdateDirectChats(nil) - - user.log.Infoln("Finished syncing portals") - select { - case user.syncPortalsDone <- struct{}{}: - default: - } -} +//func (user *User) syncPortal(chat Chat) { +// // Don't sync unless chat meta sync is enabled or portal doesn't exist +// if user.bridge.Config.Bridge.ChatMetaSync || len(chat.Portal.MXID) == 0 { +// failedToCreate := chat.Portal.Sync(user, chat.Contact) +// if failedToCreate { +// return +// } +// } +// err := chat.Portal.BackfillHistory(user, chat.LastMessageTime) +// if err != nil { +// chat.Portal.log.Errorln("Error backfilling history:", err) +// } +//} +// +//func (user *User) syncPortals(chatMap map[string]whatsapp.Chat, createAll bool) { +// // TODO use contexts instead of checking if user.Conn is the same? +// connAtStart := user.Conn +// +// chats := user.collectChatList(chatMap) +// +// limit := user.bridge.Config.Bridge.InitialChatSync +// if limit < 0 { +// limit = len(chats) +// } +// if user.Conn != connAtStart { +// user.log.Debugln("Connection seems to have changed before sync, cancelling") +// return +// } +// now := time.Now().Unix() +// user.log.Infoln("Syncing portals") +// doublePuppet := user.bridge.GetPuppetByCustomMXID(user.MXID) +// for i, chat := range chats { +// if chat.LastMessageTime+user.bridge.Config.Bridge.SyncChatMaxAge < now { +// break +// } +// create := (chat.LastMessageTime >= user.LastConnection && user.LastConnection > 0) || i < limit +// if len(chat.Portal.MXID) > 0 || create || createAll { +// user.log.Debugfln("Syncing chat %+v", chat.Chat.Source) +// justCreated := len(chat.Portal.MXID) == 0 +// user.syncPortal(chat) +// user.syncChatDoublePuppetDetails(doublePuppet, chat, justCreated) +// } +// } +// if user.Conn != connAtStart { +// user.log.Debugln("Connection seems to have changed during sync, cancelling") +// return +// } +// user.UpdateDirectChats(nil) +// +// user.log.Infoln("Finished syncing portals") +// select { +// case user.syncPortalsDone <- struct{}{}: +// default: +// } +//} func (user *User) getDirectChats() map[id.UserID][]id.RoomID { res := make(map[id.UserID][]id.RoomID) - privateChats := user.bridge.DB.Portal.FindPrivateChats(user.JID) + privateChats := user.bridge.DB.Portal.FindPrivateChats(user.JID.ToNonAD()) for _, portal := range privateChats { if len(portal.MXID) > 0 { res[user.bridge.FormatPuppetMXID(portal.Key.JID)] = []id.RoomID{portal.MXID} @@ -932,362 +625,94 @@ func (user *User) UpdateDirectChats(chats map[id.UserID][]id.RoomID) { } } -func (user *User) HandleContactList(contacts []whatsapp.Contact) { - contactMap := make(map[whatsapp.JID]whatsapp.Contact) - for _, contact := range contacts { - contactMap[contact.JID] = contact - } - go user.syncPuppets(contactMap) +func (user *User) handleLoggedOut() { + user.JID = types.EmptyJID + user.Update() + user.sendMarkdownBridgeAlert("Connecting to WhatsApp failed as the device was logged out. Please link the bridge to your phone again.") + user.sendBridgeState(BridgeState{StateEvent: StateBadCredentials, Error: WANotLoggedIn}) } -func (user *User) syncPuppets(contacts map[whatsapp.JID]whatsapp.Contact) { - if contacts == nil { - contacts = user.Conn.Store.Contacts - } - - _, hasSelf := contacts[user.JID] - if !hasSelf { - contacts[user.JID] = whatsapp.Contact{ - Name: user.pushName, - Notify: user.pushName, - JID: user.JID, - } - } - - user.log.Infoln("Syncing puppet info from contacts") - for jid, contact := range contacts { - if strings.HasSuffix(jid, whatsapp.NewUserSuffix) { - puppet := user.bridge.GetPuppetByJID(contact.JID) - puppet.Sync(user, contact) - } else if strings.HasSuffix(jid, whatsapp.BroadcastSuffix) { - portal := user.GetPortalByJID(contact.JID) - portal.Sync(user, contact) - } - } - user.log.Infoln("Finished syncing puppet info from contacts") -} - -func (user *User) updateLastConnectionIfNecessary() { - if user.LastConnection+60 < time.Now().Unix() { - user.UpdateLastConnection() - } -} - -func (user *User) HandleError(err error) { - if !errors.Is(err, whatsapp.ErrInvalidWsData) { - user.log.Errorfln("WhatsApp error: %v", err) - } - if closed, ok := err.(*whatsapp.ErrConnectionClosed); ok { - if user.Session == nil { - user.log.Debugln("Websocket disconnected, but no session stored, not trying to reconnect") - return - } - user.bridge.Metrics.TrackDisconnection(user.MXID) - if closed.Code == 1000 && user.cleanDisconnection { - user.cleanDisconnection = false - if !user.bridge.Config.Bridge.AggressiveReconnect { - user.sendBridgeState(BridgeState{StateEvent: StateBadCredentials, Error: WANotConnected}) - user.bridge.Metrics.TrackConnectionState(user.JID, false) - user.log.Infoln("Clean disconnection by server") - return - } else { - user.log.Debugln("Clean disconnection by server, but aggressive reconnection is enabled") - } - } - go user.tryReconnect(fmt.Sprintf("Your WhatsApp connection was closed with websocket status code %d", closed.Code)) - } else if failed, ok := err.(*whatsapp.ErrConnectionFailed); ok { - disconnectErr := user.Conn.Disconnect() - if disconnectErr != nil { - user.log.Warnln("Failed to disconnect after connection fail:", disconnectErr) - } - user.bridge.Metrics.TrackDisconnection(user.MXID) - user.ConnectionErrors++ - go user.tryReconnect(fmt.Sprintf("Your WhatsApp connection failed: %v", failed.Err)) - } else if err == whatsapp.ErrPingFalse || err == whatsapp.ErrWebsocketKeepaliveFailed { - disconnectErr := user.Conn.Disconnect() - if disconnectErr != nil { - user.log.Warnln("Failed to disconnect after failed ping:", disconnectErr) - } - user.bridge.Metrics.TrackDisconnection(user.MXID) - user.ConnectionErrors++ - go user.tryReconnect(fmt.Sprintf("Your WhatsApp connection failed: %v", err)) - } - // Otherwise unknown error, probably mostly harmless -} - -var reconnectionID int64 - -func (user *User) tryReconnect(msg string) { - localReconID := atomic.AddInt64(&reconnectionID, 1) - rcLog := user.log.Sub(fmt.Sprintf("Reconnector-%d", localReconID)) - user.bridge.Metrics.TrackConnectionState(user.JID, false) - if user.ConnectionErrors > user.bridge.Config.Bridge.MaxConnectionAttempts { - user.sendMarkdownBridgeAlert("%s. Use the `reconnect` command to reconnect.", msg) - user.sendBridgeState(BridgeState{StateEvent: StateBadCredentials, Error: WANotConnected}) - return - } - if user.bridge.Config.Bridge.ReportConnectionRetry { - user.sendBridgeNotice("%s. Reconnecting...", msg) - // Don't want the same error to be repeated - msg = "" - } - var tries uint - var exponentialBackoff bool - baseDelay := time.Duration(user.bridge.Config.Bridge.ConnectionRetryDelay) - if baseDelay < 0 { - exponentialBackoff = true - baseDelay = -baseDelay + 1 - } - delay := baseDelay - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - user.cancelReconnect = cancel - for attemptNo := 1; user.ConnectionErrors <= user.bridge.Config.Bridge.MaxConnectionAttempts; attemptNo++ { - select { - case <-ctx.Done(): - rcLog.Debugln("tryReconnect context cancelled, aborting reconnection attempts") - return - default: - } - user.sendBridgeState(BridgeState{StateEvent: StateConnecting, Error: WAConnecting}) - rcLog.Debugfln("Starting reconnection attempt #%d", attemptNo) - err := user.Conn.Restore(true, ctx) - if err == nil { - user.ConnectionErrors = 0 - if user.bridge.Config.Bridge.ReportConnectionRetry { - user.sendBridgeNotice("Reconnected successfully") - } - rcLog.Debugln("Reconnection successful, running PostLogin and exiting loop") - user.PostLogin() - return - } else if errors.Is(err, whatsapp.ErrBadRequest) { - rcLog.Warnln("Got init 400 error when trying to reconnect, resetting connection...") - err = user.Conn.Disconnect() - if err != nil { - rcLog.Debugln("Error while disconnecting for connection reset:", err) - } - } else if errors.Is(err, whatsapp.ErrUnpaired) || errors.Is(err, whatsapp.ErrInvalidSession) { - rcLog.Errorfln("Got init %s error when trying to reconnect, not retrying", err) - user.removeFromJIDMap(StateBadCredentials) - //user.JID = "" - user.SetSession(nil) - user.DeleteConnection() - errMsg := "unpaired from phone" - if errors.Is(err, whatsapp.ErrInvalidSession) { - errMsg = "invalid session" - } - user.sendMarkdownBridgeAlert("\u26a0 Failed to reconnect to WhatsApp: %s. " + - "To re-pair your phone, log in again.", errMsg) - return - } else if errors.Is(err, whatsapp.ErrAlreadyLoggedIn) { - rcLog.Warnln("Reconnection said we're already logged in, not trying anymore") - return - } else if errors.Is(err, whatsapp.ErrLoginInProgress) { - rcLog.Warnln("Reconnection said another reconnection is in progress, hoping there's another thread trying to reconnect and not retrying here") - return - } else { - rcLog.Errorln("Error while trying to reconnect after disconnection:", err) - } - tries++ - user.ConnectionErrors++ - if user.ConnectionErrors <= user.bridge.Config.Bridge.MaxConnectionAttempts { - if exponentialBackoff { - delay = (1 << tries) + baseDelay - } - if user.bridge.Config.Bridge.ReportConnectionRetry { - user.sendBridgeNotice("Reconnection attempt failed: %v. Retrying in %d seconds...", err, delay) - } - time.Sleep(delay * time.Second) - } - } - rcLog.Debugln("Giving up trying to reconnect") - - user.sendBridgeState(BridgeState{StateEvent: StateBadCredentials, Error: WANotConnected}) - if user.bridge.Config.Bridge.ReportConnectionRetry { - user.sendMarkdownBridgeAlert("%d reconnection attempts failed. Use the `reconnect` command to try to reconnect manually.", tries) - } else { - user.sendMarkdownBridgeAlert("\u26a0 %s. Additionally, %d reconnection attempts failed. Use the `reconnect` command to try to reconnect.", msg, tries) - } -} - -func (user *User) PortalKey(jid whatsapp.JID) database.PortalKey { +func (user *User) PortalKey(jid types.JID) database.PortalKey { return database.NewPortalKey(jid, user.JID) } -func (user *User) GetPortalByJID(jid whatsapp.JID) *Portal { +func (user *User) GetPortalByJID(jid types.JID) *Portal { return user.bridge.GetPortalByJID(user.PortalKey(jid)) } -func (user *User) runMessageRingBuffer() { - for msg := range user.messageInput { - select { - case user.messageOutput <- msg: - user.bridge.Metrics.TrackBufferLength(user.MXID, len(user.messageOutput)) - default: - dropped := <-user.messageOutput - user.log.Warnln("Buffer is full, dropping message in", dropped.chat) - user.messageOutput <- msg - } - } +func (user *User) syncPuppet(jid types.JID) { + user.bridge.GetPuppetByJID(jid).SyncContact(user, false) } -func (user *User) handleMessageLoop() { - for { - select { - case msg := <-user.messageOutput: - user.bridge.Metrics.TrackBufferLength(user.MXID, len(user.messageOutput)) - user.GetPortalByJID(msg.chat).messages <- msg - case <-user.syncStart: - user.log.Debugln("Processing of incoming messages is locked") - user.bridge.Metrics.TrackSyncLock(user.JID, true) - user.syncWait.Wait() - user.bridge.Metrics.TrackSyncLock(user.JID, false) - user.log.Debugln("Processing of incoming messages unlocked") - } - } -} +//func (user *User) HandlePresence(info whatsapp.PresenceEvent) { +// puppet := user.bridge.GetPuppetByJID(info.SenderJID) +// switch info.Status { +// case whatsapp.PresenceUnavailable: +// _ = puppet.DefaultIntent().SetPresence("offline") +// 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) +// puppet.typingIn = "" +// puppet.typingAt = 0 +// } else { +// _ = puppet.DefaultIntent().SetPresence("online") +// } +// case whatsapp.PresenceComposing: +// portal := user.GetPortalByJID(info.JID) +// if len(puppet.typingIn) > 0 && puppet.typingAt+15 > time.Now().Unix() { +// if puppet.typingIn == portal.MXID { +// return +// } +// _, _ = puppet.IntentFor(portal).UserTyping(puppet.typingIn, false, 0) +// } +// if len(portal.MXID) > 0 { +// puppet.typingIn = portal.MXID +// puppet.typingAt = time.Now().Unix() +// _, _ = puppet.IntentFor(portal).UserTyping(portal.MXID, true, 15*1000) +// } +// } +//} -func (user *User) HandleNewContact(contact whatsapp.Contact) { - user.log.Debugfln("Contact message: %+v", contact) - if strings.HasSuffix(contact.JID, whatsapp.OldUserSuffix) { - contact.JID = strings.Replace(contact.JID, whatsapp.OldUserSuffix, whatsapp.NewUserSuffix, -1) - } - if strings.HasSuffix(contact.JID, whatsapp.NewUserSuffix) { - puppet := user.bridge.GetPuppetByJID(contact.JID) - puppet.UpdateName(user, contact) - } else if strings.HasSuffix(contact.JID, whatsapp.BroadcastSuffix) { - portal := user.GetPortalByJID(contact.JID) - portal.UpdateName(contact.Name, "", nil, true) - } -} - -func (user *User) HandleBatteryMessage(battery whatsapp.BatteryMessage) { - user.log.Debugfln("Battery message: %+v", battery) - var notice string - if !battery.Plugged && battery.Percentage < 15 && user.batteryWarningsSent < 1 { - notice = fmt.Sprintf("Phone battery low (%d %% remaining)", battery.Percentage) - user.batteryWarningsSent = 1 - } else if !battery.Plugged && battery.Percentage < 5 && user.batteryWarningsSent < 2 { - notice = fmt.Sprintf("Phone battery very low (%d %% remaining)", battery.Percentage) - user.batteryWarningsSent = 2 - } else if battery.Percentage > 15 || battery.Plugged { - user.batteryWarningsSent = 0 - } - if notice != "" { - go user.sendBridgeNotice("%s", notice) - } -} - -type FakeMessage struct { - Text string - ID string - Alert bool -} - -func (user *User) HandleCallInfo(info whatsapp.CallInfo) { - if info.Data != nil { +func (user *User) handleReceipt(receipt *events.Receipt) { + if receipt.Type != events.ReceiptTypeRead { return } - data := FakeMessage{ - ID: info.ID, - } - switch info.Type { - case whatsapp.CallOffer: - if !user.bridge.Config.Bridge.CallNotices.Start { - return - } - data.Text = "Incoming call" - data.Alert = true - case whatsapp.CallOfferVideo: - if !user.bridge.Config.Bridge.CallNotices.Start { - return - } - data.Text = "Incoming video call" - data.Alert = true - case whatsapp.CallTerminate: - if !user.bridge.Config.Bridge.CallNotices.End { - return - } - data.Text = "Call ended" - data.ID += "E" - default: + portal := user.GetPortalByJID(receipt.Chat) + if portal == nil || len(portal.MXID) == 0 { return } - portal := user.GetPortalByJID(info.From) - if portal != nil { - portal.messages <- PortalMessage{info.From, user, data, 0} - } -} - -func (user *User) HandlePresence(info whatsapp.PresenceEvent) { - puppet := user.bridge.GetPuppetByJID(info.SenderJID) - switch info.Status { - case whatsapp.PresenceUnavailable: - _ = puppet.DefaultIntent().SetPresence("offline") - 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) - puppet.typingIn = "" - puppet.typingAt = 0 - } else { - _ = puppet.DefaultIntent().SetPresence("online") - } - case whatsapp.PresenceComposing: - portal := user.GetPortalByJID(info.JID) - if len(puppet.typingIn) > 0 && puppet.typingAt+15 > time.Now().Unix() { - if puppet.typingIn == portal.MXID { - return - } - _, _ = puppet.IntentFor(portal).UserTyping(puppet.typingIn, false, 0) - } - if len(portal.MXID) > 0 { - puppet.typingIn = portal.MXID - puppet.typingAt = time.Now().Unix() - _, _ = puppet.IntentFor(portal).UserTyping(portal.MXID, true, 15*1000) - } - } -} - -func (user *User) HandleMsgInfo(info whatsapp.JSONMsgInfo) { - if (info.Command == whatsapp.MsgInfoCommandAck || info.Command == whatsapp.MsgInfoCommandAcks) && info.Acknowledgement == whatsapp.AckMessageRead { - portal := user.GetPortalByJID(info.ToJID) - if len(portal.MXID) == 0 { - return - } - - intent := user.bridge.GetPuppetByJID(info.SenderJID).IntentFor(portal) - for _, msgID := range info.IDs { - msg := user.bridge.DB.Message.GetByJID(portal.Key, msgID) - if msg == nil || msg.IsFakeMXID() { - continue - } - - err := intent.MarkReadWithContent(portal.MXID, msg.MXID, &CustomReadReceipt{DoublePuppet: intent.IsCustomPuppet}) - if err != nil { - user.log.Warnfln("Failed to mark message %s as read by %s: %v", msg.MXID, info.SenderJID, err) - } - } - } -} - -func (user *User) HandleReceivedMessage(received whatsapp.ReceivedMessage) { - if received.Type == "read" { - go user.markSelfRead(received.Jid, received.Index) + if receipt.IsFromMe { + user.markSelfRead(portal, receipt.MessageID) } else { - user.log.Debugfln("Unknown received message type: %+v", received) + intent := user.bridge.GetPuppetByJID(receipt.Sender).IntentFor(portal) + ok := user.markOtherRead(portal, intent, receipt.MessageID) + if !ok { + // Message not found, try any previous IDs + for i := len(receipt.PreviousIDs) - 1; i >= 0; i-- { + ok = user.markOtherRead(portal, intent, receipt.PreviousIDs[i]) + if ok { + break + } + } + } } } -func (user *User) HandleReadMessage(read whatsapp.ReadMessage) { - user.log.Debugfln("Received chat read message: %+v", read) - go user.markSelfRead(read.Jid, "") +func (user *User) markOtherRead(portal *Portal, intent *appservice.IntentAPI, messageID types.MessageID) bool { + msg := user.bridge.DB.Message.GetByJID(portal.Key, messageID) + if msg == nil || msg.IsFakeMXID() { + return false + } + + err := intent.MarkReadWithContent(portal.MXID, msg.MXID, &CustomReadReceipt{DoublePuppet: intent.IsCustomPuppet}) + if err != nil { + user.log.Warnfln("Failed to mark message %s as read by %s: %v", msg.MXID, intent.UserID, err) + } + return true } -func (user *User) markSelfRead(jid, messageID string) { - if strings.HasSuffix(jid, whatsapp.OldUserSuffix) { - jid = strings.Replace(jid, whatsapp.OldUserSuffix, whatsapp.NewUserSuffix, -1) - } +func (user *User) markSelfRead(portal *Portal, messageID types.MessageID) { puppet := user.bridge.GetPuppetByJID(user.JID) if puppet == nil { return @@ -1296,10 +721,6 @@ func (user *User) markSelfRead(jid, messageID string) { if intent == nil { return } - portal := user.GetPortalByJID(jid) - if portal == nil { - return - } var message *database.Message if messageID == "" { message = user.bridge.DB.Message.GetLastInChat(portal.Key) @@ -1316,118 +737,105 @@ func (user *User) markSelfRead(jid, messageID string) { } err := intent.MarkReadWithContent(portal.MXID, message.MXID, &CustomReadReceipt{DoublePuppet: true}) if err != nil { - user.log.Warnfln("Failed to bridge own read receipt in %s: %v", jid, err) + user.log.Warnfln("Failed to bridge own read receipt in %s: %v", portal.Key.JID, err) } } -func (user *User) HandleCommand(cmd whatsapp.JSONCommand) { - switch cmd.Type { - case whatsapp.CommandPicture: - if strings.HasSuffix(cmd.JID, whatsapp.NewUserSuffix) { - puppet := user.bridge.GetPuppetByJID(cmd.JID) - go puppet.UpdateAvatar(user, cmd.ProfilePicInfo) - } else if user.bridge.Config.Bridge.ChatMetaSync { - portal := user.GetPortalByJID(cmd.JID) - go portal.UpdateAvatar(user, cmd.ProfilePicInfo, true) - } - case whatsapp.CommandDisconnect: - if cmd.Kind == "replaced" { - user.cleanDisconnection = true - go user.sendMarkdownBridgeAlert("\u26a0 Your WhatsApp connection was closed by the server because you opened another WhatsApp Web client.\n\n" + - "Use the `reconnect` command to disconnect the other client and resume bridging.") - } else { - user.log.Warnln("Unknown kind of disconnect:", string(cmd.Raw)) - go user.sendMarkdownBridgeAlert("\u26a0 Your WhatsApp connection was closed by the server (reason code: %s).\n\n"+ - "Use the `reconnect` command to reconnect.", cmd.Kind) - } - } -} +//func (user *User) HandleCommand(cmd whatsapp.JSONCommand) { +// switch cmd.Type { +// case whatsapp.CommandPicture: +// if strings.HasSuffix(cmd.JID, whatsapp.NewUserSuffix) { +// puppet := user.bridge.GetPuppetByJID(cmd.JID) +// go puppet.UpdateAvatar(user, cmd.ProfilePicInfo) +// } else if user.bridge.Config.Bridge.ChatMetaSync { +// portal := user.GetPortalByJID(cmd.JID) +// go portal.UpdateAvatar(user, cmd.ProfilePicInfo, true) +// } +// case whatsapp.CommandDisconnect: +// if cmd.Kind == "replaced" { +// user.cleanDisconnection = true +// go user.sendMarkdownBridgeAlert("\u26a0 Your WhatsApp connection was closed by the server because you opened another WhatsApp Web client.\n\n" + +// "Use the `reconnect` command to disconnect the other client and resume bridging.") +// } else { +// user.log.Warnln("Unknown kind of disconnect:", string(cmd.Raw)) +// go user.sendMarkdownBridgeAlert("\u26a0 Your WhatsApp connection was closed by the server (reason code: %s).\n\n"+ +// "Use the `reconnect` command to reconnect.", cmd.Kind) +// } +// } +//} -func (user *User) HandleChatUpdate(cmd whatsapp.ChatUpdate) { - if cmd.Command != whatsapp.ChatUpdateCommandAction { - return - } - - portal := user.GetPortalByJID(cmd.JID) - if len(portal.MXID) == 0 { - if cmd.Data.Action == whatsapp.ChatActionIntroduce || cmd.Data.Action == whatsapp.ChatActionCreate { - go func() { - err := portal.CreateMatrixRoom(user) - if err != nil { - user.log.Errorln("Failed to create portal room after receiving join event:", err) - } - }() - } - return - } - - // These don't come down the message history :( - switch cmd.Data.Action { - case whatsapp.ChatActionAddTopic: - go portal.UpdateTopic(cmd.Data.AddTopic.Topic, cmd.Data.SenderJID, nil, true) - case whatsapp.ChatActionRemoveTopic: - go portal.UpdateTopic("", cmd.Data.SenderJID, nil, true) - case whatsapp.ChatActionRemove: - // We ignore leaving groups in the message history to avoid accidentally leaving rejoined groups, - // but if we get a real-time command that says we left, it should be safe to bridge it. - if !user.bridge.Config.Bridge.ChatMetaSync { - for _, jid := range cmd.Data.UserChange.JIDs { - if jid == user.JID { - go portal.HandleWhatsAppKick(nil, cmd.Data.SenderJID, cmd.Data.UserChange.JIDs) - break - } - } - } - } - - if !user.bridge.Config.Bridge.ChatMetaSync { - // Ignore chat update commands, we're relying on the message history. - return - } - - switch cmd.Data.Action { - case whatsapp.ChatActionNameChange: - go portal.UpdateName(cmd.Data.NameChange.Name, cmd.Data.SenderJID, nil, true) - case whatsapp.ChatActionPromote: - go portal.ChangeAdminStatus(cmd.Data.UserChange.JIDs, true) - case whatsapp.ChatActionDemote: - go portal.ChangeAdminStatus(cmd.Data.UserChange.JIDs, false) - case whatsapp.ChatActionAnnounce: - go portal.RestrictMessageSending(cmd.Data.Announce) - case whatsapp.ChatActionRestrict: - go portal.RestrictMetadataChanges(cmd.Data.Restrict) - case whatsapp.ChatActionRemove: - go portal.HandleWhatsAppKick(nil, cmd.Data.SenderJID, cmd.Data.UserChange.JIDs) - case whatsapp.ChatActionAdd: - go portal.HandleWhatsAppInvite(user, cmd.Data.SenderJID, nil, cmd.Data.UserChange.JIDs) - case whatsapp.ChatActionIntroduce: - if cmd.Data.SenderJID != "unknown" { - go portal.Sync(user, whatsapp.Contact{JID: portal.Key.JID}) - } - } -} - -func (user *User) HandleConnInfo(info whatsapp.ConnInfo) { - if user.Session != nil && info.Connected && len(info.ClientToken) > 0 { - user.log.Debugln("Received new tokens") - user.Session.ClientToken = info.ClientToken - user.Session.ServerToken = info.ServerToken - user.Session.Wid = info.WID - user.Update() - } - if len(info.PushName) > 0 { - user.pushName = info.PushName - } -} - -func (user *User) HandleJSONMessage(evt whatsapp.RawJSONMessage) { - if !json.Valid(evt.RawMessage) { - return - } - user.log.Debugfln("JSON message with tag %s: %s", evt.Tag, evt.RawMessage) - user.updateLastConnectionIfNecessary() -} +//func (user *User) HandleChatUpdate(cmd whatsapp.ChatUpdate) { +// if cmd.Command != whatsapp.ChatUpdateCommandAction { +// return +// } +// +// portal := user.GetPortalByJID(cmd.JID) +// if len(portal.MXID) == 0 { +// if cmd.Data.Action == whatsapp.ChatActionIntroduce || cmd.Data.Action == whatsapp.ChatActionCreate { +// go func() { +// err := portal.CreateMatrixRoom(user) +// if err != nil { +// user.log.Errorln("Failed to create portal room after receiving join event:", err) +// } +// }() +// } +// return +// } +// +// // These don't come down the message history :( +// switch cmd.Data.Action { +// case whatsapp.ChatActionAddTopic: +// go portal.UpdateTopic(cmd.Data.AddTopic.Topic, cmd.Data.SenderJID, nil, true) +// case whatsapp.ChatActionRemoveTopic: +// go portal.UpdateTopic("", cmd.Data.SenderJID, nil, true) +// case whatsapp.ChatActionRemove: +// // We ignore leaving groups in the message history to avoid accidentally leaving rejoined groups, +// // but if we get a real-time command that says we left, it should be safe to bridge it. +// if !user.bridge.Config.Bridge.ChatMetaSync { +// for _, jid := range cmd.Data.UserChange.JIDs { +// if jid == user.JID { +// go portal.HandleWhatsAppKick(nil, cmd.Data.SenderJID, cmd.Data.UserChange.JIDs) +// break +// } +// } +// } +// } +// +// if !user.bridge.Config.Bridge.ChatMetaSync { +// // Ignore chat update commands, we're relying on the message history. +// return +// } +// +// switch cmd.Data.Action { +// case whatsapp.ChatActionNameChange: +// go portal.UpdateName(cmd.Data.NameChange.Name, cmd.Data.SenderJID, nil, true) +// case whatsapp.ChatActionPromote: +// go portal.ChangeAdminStatus(cmd.Data.UserChange.JIDs, true) +// case whatsapp.ChatActionDemote: +// go portal.ChangeAdminStatus(cmd.Data.UserChange.JIDs, false) +// case whatsapp.ChatActionAnnounce: +// go portal.RestrictMessageSending(cmd.Data.Announce) +// case whatsapp.ChatActionRestrict: +// go portal.RestrictMetadataChanges(cmd.Data.Restrict) +// case whatsapp.ChatActionRemove: +// go portal.HandleWhatsAppKick(nil, cmd.Data.SenderJID, cmd.Data.UserChange.JIDs) +// case whatsapp.ChatActionAdd: +// go portal.HandleWhatsAppInvite(user, cmd.Data.SenderJID, nil, cmd.Data.UserChange.JIDs) +// case whatsapp.ChatActionIntroduce: +// if cmd.Data.SenderJID != "unknown" { +// go portal.Sync(user, whatsapp.Contact{JID: portal.Key.JID}) +// } +// } +//} +// +//func (user *User) HandleJSONMessage(evt whatsapp.RawJSONMessage) { +// if !json.Valid(evt.RawMessage) { +// return +// } +// user.log.Debugfln("JSON message with tag %s: %s", evt.Tag, evt.RawMessage) +// user.updateLastConnectionIfNecessary() +//} func (user *User) NeedsRelaybot(portal *Portal) bool { - return !user.HasSession() || !user.IsInPortal(portal.Key) + return !user.HasSession() // || !user.IsInPortal(portal.Key) }