diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 0422d43..cab62cf 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -55,7 +55,7 @@ build docker amd64: DOCKER_ARCH: amd64 after_script: - | - if [[ "$CI_COMMIT_BRANCH" == "master" && "$CI_JOB_STATUS" == "success" ]]; then + if [[ "$CI_COMMIT_BRANCH" == "legacy" && "$CI_JOB_STATUS" == "success" ]]; then apk add --update curl jq rm -rf /var/cache/apk/* diff --git a/README.md b/README.md index 8942729..1a7b521 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 based on [whatsmeow](https://github.com/tulir/whatsmeow). ### 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/ROADMAP.md b/ROADMAP.md index 3d9f8ac..9510492 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,14 +12,14 @@ * [x] Read receipts * [ ] Power level * [ ] Membership actions - * [x] Invite + * [ ] Invite * [ ] Join - * [x] Leave - * [x] Kick + * [ ] Leave + * [ ] Kick * [ ] Room metadata changes - * [x] Name - * [ ] Avatar[1] - * [x] Topic + * [ ] Name + * [ ] Avatar + * [ ] Topic * [ ] Initial room metadata * WhatsApp → Matrix * [x] Message content @@ -32,10 +32,10 @@ * [ ] Chat types * [x] Private chat * [x] Group chat - * [ ] Broadcast list[2] + * [ ] Broadcast list * [x] Message deletions * [x] Avatars - * [x] Presence + * [ ] Presence * [x] Typing notifications * [x] Read receipts * [x] Admin/superadmin status @@ -49,8 +49,8 @@ * [x] Avatar * [x] Description * [x] Initial group metadata - * [ ] User metadata changes - * [ ] Display name[3] + * [x] User metadata changes + * [x] Display name * [x] Avatar * [x] Initial user metadata * [x] Display name @@ -63,7 +63,3 @@ * [x] Private chat creation by inviting Matrix puppet of WhatsApp user to new room * [x] Option to use own Matrix account for messages sent from WhatsApp mobile/other web clients * [x] Shared group chat portals - -[1] May involve reverse-engineering the WhatsApp Web API and/or editing go-whatsapp -[2] May already work -[3] May not be possible diff --git a/bridgestate.go b/bridgestate.go index 5b4d3b1..4f6b6eb 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" @@ -38,7 +33,6 @@ import ( type BridgeStateEvent string const ( - StateStarting BridgeStateEvent = "STARTING" StateUnconfigured BridgeStateEvent = "UNCONFIGURED" StateRunning BridgeStateEvent = "RUNNING" StateConnecting BridgeStateEvent = "CONNECTING" @@ -56,20 +50,14 @@ const ( WANotLoggedIn BridgeErrorCode = "wa-logged-out" WANotConnected BridgeErrorCode = "wa-not-connected" WAConnecting BridgeErrorCode = "wa-connecting" - WATimeout BridgeErrorCode = "wa-timeout" WAServerTimeout BridgeErrorCode = "wa-server-timeout" - WAPingFalse BridgeErrorCode = "wa-ping-false" - WAPingError BridgeErrorCode = "wa-ping-error" ) var bridgeHumanErrors = map[BridgeErrorCode]string{ WANotLoggedIn: "You're not logged into WhatsApp", WANotConnected: "You're not connected to WhatsApp", WAConnecting: "Trying to reconnect to WhatsApp. Please make sure WhatsApp is running on your phone and connected to the internet.", - WATimeout: "WhatsApp on your phone is not responding. Please make sure it is running and connected to the internet.", WAServerTimeout: "The WhatsApp web servers are not responding. The bridge will try to reconnect.", - WAPingFalse: "WhatsApp returned an error, reconnecting. Please make sure WhatsApp is running on your phone and connected to the internet.", - WAPingError: "WhatsApp returned an unknown error", } type BridgeState struct { @@ -94,8 +82,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 = fmt.Sprintf("%s_a%d_d%d", user.JID.User, user.JID.Agent, user.JID.Device) + pong.RemoteName = fmt.Sprintf("+%s", user.JID.User) } pong.Timestamp = time.Now().Unix() @@ -116,32 +104,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 +172,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 +181,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.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..741d96a 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,22 +20,21 @@ import ( "context" "errors" "fmt" - "math" - "sort" "strconv" "strings" - "github.com/Rhymen/go-whatsapp" + "github.com/skip2/go-qrcode" "maunium.net/go/maulogger/v2" + "go.mau.fi/whatsmeow" + "go.mau.fi/whatsmeow/types" + "maunium.net/go/mautrix" "maunium.net/go/mautrix/appservice" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/format" "maunium.net/go/mautrix/id" - - "maunium.net/go/mautrix-whatsapp/database" ) type CommandHandler struct { @@ -94,17 +93,11 @@ func (handler *CommandHandler) Handle(roomID id.RoomID, user *User, message stri Args: args[1:], } handler.log.Debugfln("%s sent '%s' in %s", user.MXID, message, roomID) - if roomID == handler.bridge.Config.Bridge.Relaybot.ManagementRoom { - handler.CommandRelaybot(ce) - } else { - handler.CommandMux(ce) - } + handler.CommandMux(ce) } func (handler *CommandHandler) CommandMux(ce *CommandEvent) { switch ce.Command { - case "relaybot": - handler.CommandRelaybot(ce) case "login": handler.CommandLogin(ce) case "logout-matrix": @@ -119,8 +112,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": @@ -137,20 +128,22 @@ func (handler *CommandHandler) CommandMux(ce *CommandEvent) { handler.CommandLogout(ce) case "toggle": handler.CommandToggle(ce) - case "login-matrix", "sync", "list", "open", "pm", "invite-link", "join", "create": + case "set-relay", "unset-relay", "login-matrix", "sync", "list", "open", "pm", "invite-link", "join", "create": 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 } switch ce.Command { + case "set-relay": + handler.CommandSetRelay(ce) + case "unset-relay": + handler.CommandUnsetRelay(ce) case "login-matrix": handler.CommandLoginMatrix(ce) - case "sync": - handler.CommandSync(ce) case "list": handler.CommandList(ce) case "open": @@ -180,22 +173,35 @@ func (handler *CommandHandler) CommandDiscardMegolmSession(ce *CommandEvent) { } } -func (handler *CommandHandler) CommandRelaybot(ce *CommandEvent) { - if handler.bridge.Relaybot == nil { - ce.Reply("The relaybot is disabled") - } else if !ce.User.Admin { - ce.Reply("Only admins can manage the relaybot") +const cmdSetRelayHelp = `set-relay - Relay messages in this room through your WhatsApp account.` + +func (handler *CommandHandler) CommandSetRelay(ce *CommandEvent) { + if !handler.bridge.Config.Bridge.Relay.Enabled { + ce.Reply("Relay mode is not enabled on this instance of the bridge") + } else if ce.Portal == nil { + ce.Reply("This is not a portal room") + } else if handler.bridge.Config.Bridge.Relay.AdminOnly && !ce.User.Admin { + ce.Reply("Only admins are allowed to enable relay mode on this instance of the bridge") } else { - if ce.Command == "relaybot" { - if len(ce.Args) == 0 { - ce.Reply("**Usage:** `relaybot `") - return - } - ce.Command = strings.ToLower(ce.Args[0]) - ce.Args = ce.Args[1:] - } - ce.User = handler.bridge.Relaybot - handler.CommandMux(ce) + ce.Portal.RelayUserID = ce.User.MXID + ce.Portal.Update() + ce.Reply("Messages from non-logged-in users in this room will now be bridged through your WhatsApp account") + } +} + +const cmdUnsetRelayHelp = `set-relay - Stop relaying messages in this room.` + +func (handler *CommandHandler) CommandUnsetRelay(ce *CommandEvent) { + if !handler.bridge.Config.Bridge.Relay.Enabled { + ce.Reply("Relay mode is not enabled on this instance of the bridge") + } else if ce.Portal == nil { + ce.Reply("This is not a portal room") + } else if handler.bridge.Config.Bridge.Relay.AdminOnly && !ce.User.Admin { + ce.Reply("Only admins are allowed to enable relay mode on this instance of the bridge") + } else { + ce.Portal.RelayUserID = "" + ce.Portal.Update() + ce.Reply("Messages from non-logged-in users will no longer be bridged in this room") } } @@ -226,12 +232,14 @@ 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) + ce.Reply("Not yet implemented") + // 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 +254,28 @@ 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) - } + ce.Reply("Not yet implemented") + // 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 +309,45 @@ 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}) + ce.Reply("Not yet implemented") + // 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.` @@ -378,25 +390,94 @@ func (handler *CommandHandler) CommandSetPowerLevel(ce *CommandEvent) { } } -const cmdLoginHelp = `login - Authenticate this Bridge as WhatsApp Web Client` +const cmdLoginHelp = `login - Link the bridge to your WhatsApp account as a web client` // CommandLogin handles login command func (handler *CommandHandler) CommandLogin(ce *CommandEvent) { - if !ce.User.Connect(true) { - ce.User.log.Debugln("Connect() returned false, assuming error was logged elsewhere and canceling login.") + if ce.User.Session != nil { + if ce.User.IsConnected() { + ce.Reply("You're already logged in") + } else { + ce.Reply("You're already logged in. Perhaps you wanted to `reconnect`?") + } return } - ce.User.Login(ce) + + qrChan, err := ce.User.Login(context.Background()) + if err != nil { + ce.User.log.Errorf("Failed to log in:", err) + ce.Reply("Failed to log in: %v", err) + return + } + + var qrEventID id.EventID + for item := range qrChan { + switch item { + case whatsmeow.QRChannelSuccess: + jid := ce.User.Client.Store.ID + ce.Reply("Successfully logged in as +%s (device #%d)", jid.User, jid.Device) + case whatsmeow.QRChannelTimeout: + ce.Reply("QR code timed out. Please restart the login.") + case whatsmeow.QRChannelErrUnexpectedEvent: + ce.Reply("Failed to log in: unexpected connection event from server") + case whatsmeow.QRChannelScannedWithoutMultidevice: + ce.Reply("Please enable the WhatsApp multidevice beta and scan the QR code again.") + default: + qrEventID = ce.User.sendQR(ce, string(item), qrEventID) + } + } + _, _ = ce.Bot.RedactEvent(ce.RoomID, qrEventID) } -const cmdLogoutHelp = `logout - Logout from WhatsApp` +func (user *User) sendQR(ce *CommandEvent, code string, prevEvent id.EventID) id.EventID { + url, ok := user.uploadQR(ce, code) + if !ok { + return prevEvent + } + content := event.MessageEventContent{ + MsgType: event.MsgImage, + Body: code, + URL: url.CUString(), + } + if len(prevEvent) != 0 { + content.SetEdit(prevEvent) + } + resp, err := ce.Bot.SendMessageEvent(ce.RoomID, event.EventMessage, &content) + if err != nil { + user.log.Errorln("Failed to send edited QR code to user:", err) + } else if len(prevEvent) == 0 { + prevEvent = resp.EventID + } + return prevEvent +} + +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 - Unlink the bridge from your WhatsApp account` // CommandLogout handles !logout command 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 +488,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() + 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.Session = nil 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.") } @@ -439,16 +519,16 @@ func (handler *CommandHandler) CommandToggle(ce *CommandEvent) { } if ce.Args[0] == "presence" || ce.Args[0] == "all" { customPuppet.EnablePresence = !customPuppet.EnablePresence - var newPresence whatsapp.Presence + var newPresence types.Presence if customPuppet.EnablePresence { - newPresence = whatsapp.PresenceAvailable + newPresence = types.PresenceAvailable ce.Reply("Enabled presence bridging") } else { - newPresence = whatsapp.PresenceUnavailable + newPresence = types.PresenceUnavailable ce.Reply("Disabled presence bridging") } - if ce.User.IsConnected() { - _, err := ce.User.Conn.Presence("", newPresence) + if ce.User.IsLoggedIn() { + err := ce.User.Client.SendPresence(newPresence) if err != nil { ce.User.log.Warnln("Failed to set presence:", err) } @@ -468,130 +548,96 @@ 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.") + ce.Reply("Not yet implemented") + // 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.") + ce.User.sendBridgeState(BridgeState{StateEvent: StateBadCredentials, Error: WANotConnected}) } 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)") } } @@ -600,7 +646,7 @@ const cmdHelpHelp = `help - Prints this help` // CommandHelp handles help command func (handler *CommandHandler) CommandHelp(ce *CommandEvent) { cmdPrefix := "" - if ce.User.ManagementRoom != ce.RoomID || ce.User.IsRelaybot { + if ce.User.ManagementRoom != ce.RoomID { cmdPrefix = handler.bridge.Config.Bridge.CommandPrefix + " " } @@ -612,12 +658,12 @@ func (handler *CommandHandler) CommandHelp(ce *CommandEvent) { cmdPrefix + cmdDeleteSessionHelp, cmdPrefix + cmdReconnectHelp, cmdPrefix + cmdDisconnectHelp, - cmdPrefix + cmdDeleteConnectionHelp, cmdPrefix + cmdPingHelp, + cmdPrefix + cmdSetRelayHelp, + cmdPrefix + cmdUnsetRelayHelp, cmdPrefix + cmdLoginMatrixHelp, cmdPrefix + cmdLogoutMatrixHelp, cmdPrefix + cmdToggleHelp, - cmdPrefix + cmdSyncHelp, cmdPrefix + cmdListHelp, cmdPrefix + cmdOpenHelp, cmdPrefix + cmdPMHelp, @@ -630,35 +676,23 @@ 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() +func canDeletePortal(portal *Portal, userID id.UserID) bool { + members, err := portal.MainIntent().JoinedMembers(portal.MXID) if err != nil { - user.log.Errorln("Error updating contacts:", err) - ce.Reply("Failed to sync contact list (see logs for details)") - return + portal.log.Errorfln("Failed to get joined members to check if portal can be deleted by %s: %v", userID, err) + return false } - 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 + for otherUser := range members.Joined { + _, isPuppet := portal.bridge.ParsePuppetMXID(otherUser) + if isPuppet || otherUser == portal.bridge.Bot.UserID || otherUser == userID { + continue + } + user := portal.bridge.GetUserByMXID(otherUser) + if user != nil && user.Session != nil { + return false + } } - - ce.Reply("Syncing contacts...") - user.syncPuppets(nil) - ce.Reply("Syncing chats...") - user.syncPortals(nil, create) - - ce.Reply("Sync complete.") + return true } const cmdDeletePortalHelp = `delete-portal - Delete the current portal. If the portal is used by other people, this is limited to bridge admins.` @@ -669,12 +703,9 @@ func (handler *CommandHandler) CommandDeletePortal(ce *CommandEvent) { return } - 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 - } + if !ce.User.Admin && !canDeletePortal(ce.Portal, ce.User.MXID) { + ce.Reply("Only bridge admins can delete portals with other Matrix users") + return } ce.Portal.log.Infoln(ce.User.MXID, "requested deletion of portal.") @@ -682,17 +713,23 @@ func (handler *CommandHandler) CommandDeletePortal(ce *CommandEvent) { ce.Portal.Cleanup(false) } -const cmdDeleteAllPortalsHelp = `delete-all-portals - Delete all your portals that aren't used by any other user.'` +const cmdDeleteAllPortalsHelp = `delete-all-portals - Delete all portals.` 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) + portals := handler.bridge.GetAllPortals() + var portalsToDelete []*Portal + + if ce.User.Admin { + portals = portalsToDelete + } else { + portalsToDelete = portals[:0] + for _, portal := range portals { + if canDeletePortal(portal, ce.User.MXID) { + portalsToDelete = append(portalsToDelete, portal) + } } } + leave := func(portal *Portal) { if len(portal.MXID) > 0 { _, _ = portal.MainIntent().KickUser(portal.MXID, &mautrix.ReqKickUser{ @@ -711,13 +748,12 @@ func (handler *CommandHandler) CommandDeleteAllPortals(ce *CommandEvent) { } } } - ce.Reply("Found %d portals with no other users, deleting...", len(portalsToDelete)) + ce.Reply("Found %d portals, deleting...", len(portalsToDelete)) for _, portal := range portalsToDelete { portal.Delete() leave(portal) } - ce.Reply("Finished deleting portal info. Now cleaning up rooms in background. " + - "You may already continue using the bridge. Use `sync` to recreate portals.") + ce.Reply("Finished deleting portal info. Now cleaning up rooms in background.") go func() { for _, portal := range portalsToDelete { @@ -729,21 +765,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 +810,35 @@ 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")) + ce.Reply("Not yet implemented") + // 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.` @@ -810,91 +848,75 @@ func (handler *CommandHandler) CommandOpen(ce *CommandEvent) { ce.Reply("**Usage:** `open `") return } + ce.Reply("Not yet implemented") - 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) - } - if err != nil { - portal.log.Warnfln("Failed to invite %s to portal: %v. Creating new portal", user.MXID, err) + ok := portal.ensureUserInvited(user) + if !ok { + portal.log.Warnfln("ensureUserInvited(%s) returned false, creating new portal", user.MXID) portal.MXID = "" } else { ce.Reply("You already have a private chat portal with that user at [%s](https://matrix.to/#/%s)", puppet.Displayname, portal.MXID) 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 e6667b0..bf1d367 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,18 +30,8 @@ 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"` DeliveryReceipts bool `yaml:"delivery_receipts"` - MaxConnectionAttempts int `yaml:"max_connection_attempts"` - ConnectionRetryDelay int `yaml:"connection_retry_delay"` - ReportConnectionRetry bool `yaml:"report_connection_retry"` - AggressiveReconnect bool `yaml:"aggressive_reconnect"` - ChatListWait int `yaml:"chat_list_wait"` - PortalSyncWait int `yaml:"portal_sync_wait"` - UserMessageBuffer int `yaml:"user_message_buffer"` PortalMessageBuffer int `yaml:"portal_message_buffer"` CallNotices struct { @@ -50,15 +39,13 @@ type BridgeConfig struct { End bool `yaml:"end"` } `yaml:"call_notices"` - InitialChatSync int `yaml:"initial_chat_sync_count"` - InitialHistoryFill int `yaml:"initial_history_fill_count"` - HistoryDisableNotifs bool `yaml:"initial_history_disable_notifications"` - RecoverChatSync int `yaml:"recovery_chat_sync_count"` - RecoverHistory bool `yaml:"recovery_history_backfill"` - ChatMetaSync bool `yaml:"chat_meta_sync"` - UserAvatarSync bool `yaml:"user_avatar_sync"` - BridgeMatrixLeave bool `yaml:"bridge_matrix_leave"` - SyncChatMaxAge int64 `yaml:"sync_max_chat_age"` + HistorySync struct { + CreatePortals bool `yaml:"create_portals"` + Backfill bool `yaml:"backfill"` + DoublePuppetBackfill bool `yaml:"double_puppet_backfill"` + } `yaml:"history_sync"` + UserAvatarSync bool `yaml:"user_avatar_sync"` + BridgeMatrixLeave bool `yaml:"bridge_matrix_leave"` SyncWithCustomPuppets bool `yaml:"sync_with_custom_puppets"` SyncDirectChatList bool `yaml:"sync_direct_chat_list"` @@ -66,16 +53,15 @@ type BridgeConfig struct { DefaultBridgePresence bool `yaml:"default_bridge_presence"` LoginSharedSecret string `yaml:"login_shared_secret"` - InviteOwnPuppetForBackfilling bool `yaml:"invite_own_puppet_for_backfilling"` - PrivateChatPortalMeta bool `yaml:"private_chat_portal_meta"` - BridgeNotices bool `yaml:"bridge_notices"` - ResendBridgeInfo bool `yaml:"resend_bridge_info"` - MuteBridging bool `yaml:"mute_bridging"` - ArchiveTag string `yaml:"archive_tag"` - PinnedTag string `yaml:"pinned_tag"` - TagOnlyOnCreate bool `yaml:"tag_only_on_create"` - MarkReadOnlyOnCreate bool `yaml:"mark_read_only_on_create"` - EnableStatusBroadcast bool `yaml:"enable_status_broadcast"` + PrivateChatPortalMeta bool `yaml:"private_chat_portal_meta"` + BridgeNotices bool `yaml:"bridge_notices"` + ResendBridgeInfo bool `yaml:"resend_bridge_info"` + MuteBridging bool `yaml:"mute_bridging"` + ArchiveTag string `yaml:"archive_tag"` + PinnedTag string `yaml:"pinned_tag"` + TagOnlyOnCreate bool `yaml:"tag_only_on_create"` + MarkReadOnlyOnCreate bool `yaml:"mark_read_only_on_create"` + EnableStatusBroadcast bool `yaml:"enable_status_broadcast"` WhatsappThumbnail bool `yaml:"whatsapp_thumbnail"` @@ -103,44 +89,26 @@ type BridgeConfig struct { Permissions PermissionConfig `yaml:"permissions"` - Relaybot RelaybotConfig `yaml:"relaybot"` + Relay RelaybotConfig `yaml:"relay"` usernameTemplate *template.Template `yaml:"-"` displaynameTemplate *template.Template `yaml:"-"` - communityTemplate *template.Template `yaml:"-"` } func (bc *BridgeConfig) setDefaults() { - bc.ConnectionTimeout = 20 - bc.FetchMessageOnTimeout = false - bc.DeliveryReceipts = false - bc.MaxConnectionAttempts = 3 - bc.ConnectionRetryDelay = -1 - bc.ReportConnectionRetry = true - bc.ChatListWait = 30 - bc.PortalSyncWait = 600 - bc.UserMessageBuffer = 1024 bc.PortalMessageBuffer = 128 bc.CallNotices.Start = true bc.CallNotices.End = true - bc.InitialChatSync = 10 - bc.InitialHistoryFill = 20 - bc.RecoverChatSync = -1 - bc.RecoverHistory = true - bc.ChatMetaSync = true + bc.HistorySync.CreatePortals = true bc.UserAvatarSync = true bc.BridgeMatrixLeave = true - bc.SyncChatMaxAge = 259200 bc.SyncWithCustomPuppets = true bc.DefaultBridgePresence = true bc.DefaultBridgeReceipts = true - bc.LoginSharedSecret = "" - bc.InviteOwnPuppetForBackfilling = true - bc.PrivateChatPortalMeta = false bc.BridgeNotices = true bc.EnableStatusBroadcast = true @@ -167,13 +135,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 } @@ -181,44 +142,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() } @@ -227,10 +187,10 @@ type PermissionConfig map[string]PermissionLevel type PermissionLevel int const ( - PermissionLevelDefault PermissionLevel = 0 - PermissionLevelRelaybot PermissionLevel = 5 - PermissionLevelUser PermissionLevel = 10 - PermissionLevelAdmin PermissionLevel = 100 + PermissionLevelDefault PermissionLevel = 0 + PermissionLevelRelay PermissionLevel = 5 + PermissionLevelUser PermissionLevel = 10 + PermissionLevelAdmin PermissionLevel = 100 ) func (pc *PermissionConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { @@ -245,8 +205,8 @@ func (pc *PermissionConfig) UnmarshalYAML(unmarshal func(interface{}) error) err } for key, value := range rawPC { switch strings.ToLower(value) { - case "relaybot": - (*pc)[key] = PermissionLevelRelaybot + case "relaybot", "relay": + (*pc)[key] = PermissionLevelRelay case "user": (*pc)[key] = PermissionLevelUser case "admin": @@ -270,8 +230,8 @@ func (pc *PermissionConfig) MarshalYAML() (interface{}, error) { rawPC := make(map[string]string) for key, value := range *pc { switch value { - case PermissionLevelRelaybot: - rawPC[key] = "relaybot" + case PermissionLevelRelay: + rawPC[key] = "relay" case PermissionLevelUser: rawPC[key] = "user" case PermissionLevelAdmin: @@ -283,8 +243,8 @@ func (pc *PermissionConfig) MarshalYAML() (interface{}, error) { return rawPC, nil } -func (pc PermissionConfig) IsRelaybotWhitelisted(userID id.UserID) bool { - return pc.GetPermissionLevel(userID) >= PermissionLevelRelaybot +func (pc PermissionConfig) IsRelayWhitelisted(userID id.UserID) bool { + return pc.GetPermissionLevel(userID) >= PermissionLevelRelay } func (pc PermissionConfig) IsWhitelisted(userID id.UserID) bool { @@ -316,10 +276,8 @@ func (pc PermissionConfig) GetPermissionLevel(userID id.UserID) PermissionLevel } type RelaybotConfig struct { - Enabled bool `yaml:"enabled"` - ManagementRoom id.RoomID `yaml:"management"` - InviteUsers []id.UserID `yaml:"invites"` - + Enabled bool `yaml:"enabled"` + AdminOnly bool `yaml:"admin_only"` MessageFormats map[event.MessageType]string `yaml:"message_formats"` messageTemplates *template.Template `yaml:"-"` } diff --git a/crypto.go b/crypto.go index 4e41266..402e8f4 100644 --- a/crypto.go +++ b/crypto.go @@ -14,6 +14,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +//go:build cgo && !nocrypto // +build cgo,!nocrypto package main @@ -100,7 +101,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..e7eb124 100644 --- a/custompuppet.go +++ b/custompuppet.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 @@ -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) } @@ -222,7 +222,7 @@ func (puppet *Puppet) handleReceiptEvent(portal *Portal, event *event.Event) { // 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) + err := puppet.customUser.Client.MarkRead([]types.MessageID{message.JID}, time.UnixMilli(receipt.Timestamp), portal.Key.JID, message.Sender) if err != nil { puppet.customUser.log.Warnln("Error marking read:", err) } @@ -240,14 +240,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/cryptostore.go b/database/cryptostore.go index 618d150..872a9d6 100644 --- a/database/cryptostore.go +++ b/database/cryptostore.go @@ -14,6 +14,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +//go:build cgo && !nocrypto // +build cgo,!nocrypto package database diff --git a/database/database.go b/database/database.go index 6d6baa3..c920521 100644 --- a/database/database.go +++ b/database/database.go @@ -19,14 +19,19 @@ package database import ( "database/sql" - _ "github.com/lib/pq" + "github.com/lib/pq" _ "github.com/mattn/go-sqlite3" log "maunium.net/go/maulogger/v2" + "go.mau.fi/whatsmeow/store/sqlstore" "maunium.net/go/mautrix-whatsapp/database/upgrades" ) +func init() { + sqlstore.PostgresArrayWrapper = pq.Array +} + type Database struct { *sql.DB log log.Logger diff --git a/database/message.go b/database/message.go index 83ca81a..2ec7e95 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,11 @@ 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 { @@ -40,45 +40,66 @@ func (mq *MessageQuery) New() *Message { } } +const ( + getAllMessagesQuery = ` + SELECT chat_jid, chat_receiver, jid, mxid, sender, timestamp, sent, decryption_error FROM message + WHERE chat_jid=$1 AND chat_receiver=$2 + ` + getMessageByJIDQuery = ` + SELECT chat_jid, chat_receiver, jid, mxid, sender, timestamp, sent, decryption_error FROM message + WHERE chat_jid=$1 AND chat_receiver=$2 AND jid=$3 + ` + getMessageByMXIDQuery = ` + SELECT chat_jid, chat_receiver, jid, mxid, sender, timestamp, sent, decryption_error FROM message + WHERE mxid=$1 + ` + getLastMessageInChatQuery = ` + SELECT chat_jid, chat_receiver, jid, mxid, sender, timestamp, sent, decryption_error FROM message + WHERE chat_jid=$1 AND chat_receiver=$2 AND timestamp<=$3 AND sent=true ORDER BY timestamp DESC LIMIT 1 + ` + getFirstMessageInChatQuery = ` + SELECT chat_jid, chat_receiver, jid, mxid, sender, timestamp, sent, decryption_error FROM message + WHERE chat_jid=$1 AND chat_receiver=$2 AND sent=true ORDER BY timestamp ASC LIMIT 1 + ` +) + func (mq *MessageQuery) GetAll(chat PortalKey) (messages []*Message) { - rows, err := mq.db.Query("SELECT chat_jid, chat_receiver, jid, mxid, sender, timestamp, sent FROM message WHERE chat_jid=$1 AND chat_receiver=$2", chat.JID, chat.Receiver) + rows, err := mq.db.Query(getAllMessagesQuery, chat.JID, chat.Receiver) if err != nil || rows == nil { return nil } - defer rows.Close() for rows.Next() { messages = append(messages, mq.New().Scan(rows)) } return } -func (mq *MessageQuery) GetByJID(chat PortalKey, jid whatsapp.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) +func (mq *MessageQuery) GetByJID(chat PortalKey, jid types.MessageID) *Message { + return mq.maybeScan(mq.db.QueryRow(getMessageByJIDQuery, chat.JID, chat.Receiver, jid)) } func (mq *MessageQuery) GetByMXID(mxid id.EventID) *Message { - return mq.get("SELECT chat_jid, chat_receiver, jid, mxid, sender, timestamp, sent "+ - "FROM message WHERE mxid=$1", mxid) + return mq.maybeScan(mq.db.QueryRow(getMessageByMXIDQuery, mxid)) } func (mq *MessageQuery) GetLastInChat(chat PortalKey) *Message { - return mq.GetLastInChatBefore(chat, time.Now().Unix()+60) + return mq.GetLastInChatBefore(chat, time.Now().Add(60*time.Second)) } -func (mq *MessageQuery) GetLastInChatBefore(chat PortalKey, maxTimestamp int64) *Message { - msg := mq.get("SELECT chat_jid, chat_receiver, jid, mxid, sender, timestamp, sent "+ - "FROM message WHERE chat_jid=$1 AND chat_receiver=$2 AND timestamp<=$3 AND sent=true ORDER BY timestamp DESC LIMIT 1", - chat.JID, chat.Receiver, maxTimestamp) - if msg == nil || msg.Timestamp == 0 { +func (mq *MessageQuery) GetLastInChatBefore(chat PortalKey, maxTimestamp time.Time) *Message { + msg := mq.maybeScan(mq.db.QueryRow(getLastMessageInChatQuery, chat.JID, chat.Receiver, maxTimestamp.Unix())) + if msg == nil || msg.Timestamp.IsZero() { // Old db, we don't know what the last message is. return nil } return msg } -func (mq *MessageQuery) get(query string, args ...interface{}) *Message { - row := mq.db.QueryRow(query, args...) +func (mq *MessageQuery) GetFirstInChat(chat PortalKey) *Message { + return mq.maybeScan(mq.db.QueryRow(getFirstMessageInChatQuery, chat.JID, chat.Receiver)) +} + +func (mq *MessageQuery) maybeScan(row *sql.Row) *Message { if row == nil { return nil } @@ -90,11 +111,13 @@ type Message struct { log log.Logger Chat PortalKey - JID whatsapp.MessageID + JID types.MessageID MXID id.EventID - Sender whatsapp.JID - Timestamp int64 + Sender types.JID + Timestamp time.Time Sent bool + + DecryptionError bool } func (msg *Message) IsFakeMXID() bool { @@ -102,22 +125,30 @@ func (msg *Message) IsFakeMXID() bool { } func (msg *Message) Scan(row Scannable) *Message { - err := row.Scan(&msg.Chat.JID, &msg.Chat.Receiver, &msg.JID, &msg.MXID, &msg.Sender, &msg.Timestamp, &msg.Sent) + var ts int64 + err := row.Scan(&msg.Chat.JID, &msg.Chat.Receiver, &msg.JID, &msg.MXID, &msg.Sender, &ts, &msg.Sent, &msg.DecryptionError) if err != nil { if err != sql.ErrNoRows { msg.log.Errorln("Database scan failed:", err) } return nil } - + if ts != 0 { + msg.Timestamp = time.Unix(ts, 0) + } return msg } func (msg *Message) Insert() { + var sender interface{} = msg.Sender + // Slightly hacky hack to allow inserting empty senders (used for post-backfill dummy events) + if msg.Sender.IsEmpty() { + sender = "" + } _, err := msg.db.Exec(`INSERT INTO message - (chat_jid, chat_receiver, jid, mxid, sender, timestamp, sent) - VALUES ($1, $2, $3, $4, $5, $6, $7)`, - msg.Chat.JID, msg.Chat.Receiver, msg.JID, msg.MXID, msg.Sender, msg.Timestamp, msg.Sent) + (chat_jid, chat_receiver, jid, mxid, sender, timestamp, sent, decryption_error) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + msg.Chat.JID, msg.Chat.Receiver, msg.JID, msg.MXID, sender, msg.Timestamp.Unix(), msg.Sent, msg.DecryptionError) if err != nil { msg.log.Warnfln("Failed to insert %s@%s: %v", msg.Chat, msg.JID, err) } @@ -131,6 +162,15 @@ func (msg *Message) MarkSent() { } } +func (msg *Message) UpdateMXID(mxid id.EventID, stillDecryptionError bool) { + msg.MXID = mxid + msg.DecryptionError = stillDecryptionError + _, err := msg.db.Exec("UPDATE message SET mxid=$4, decryption_error=$5 WHERE chat_jid=$1 AND chat_receiver=$2 AND jid=$3", msg.Chat.JID, msg.Chat.Receiver, msg.JID, mxid, stillDecryptionError) + if err != nil { + msg.log.Warnfln("Failed to update %s@%s: %v", msg.Chat, msg.JID, err) + } +} + func (msg *Message) Delete() { _, err := msg.db.Exec("DELETE FROM message WHERE chat_jid=$1 AND chat_receiver=$2 AND jid=$3", msg.Chat.JID, msg.Chat.Receiver, msg.JID) if err != nil { diff --git a/database/portal.go b/database/portal.go index 3f26fac..35a494e 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 { - return PortalKey{ - JID: jid, - Receiver: jid, - } +func GroupPortalKey(jid types.JID) PortalKey { + return NewPortalKey(jid, jid) } -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 + } else if jid.Server == types.LegacyUserServer { + jid.Server = types.DefaultUserServer } 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 { - return pq.getAll("SELECT * FROM portal WHERE jid=$1", jid) +func (pq *PortalQuery) GetAllByJID(jid types.JID) []*Portal { + return pq.getAll("SELECT * FROM portal WHERE jid=$1", jid.ToNonAD()) } -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 AND jid LIKE '%@s.whatsapp.net'", receiver.ToNonAD()) } func (pq *PortalQuery) getAll(query string, args ...interface{}) (portals []*Portal) { @@ -120,11 +118,16 @@ type Portal struct { Avatar string AvatarURL id.ContentURI Encrypted bool + + FirstEventID id.EventID + NextBatchID id.BatchID + + RelayUserID id.UserID } func (portal *Portal) Scan(row Scannable) *Portal { - var mxid, avatarURL sql.NullString - err := row.Scan(&portal.Key.JID, &portal.Key.Receiver, &mxid, &portal.Name, &portal.Topic, &portal.Avatar, &avatarURL, &portal.Encrypted) + var mxid, avatarURL, firstEventID, nextBatchID, relayUserID sql.NullString + err := row.Scan(&portal.Key.JID, &portal.Key.Receiver, &mxid, &portal.Name, &portal.Topic, &portal.Avatar, &avatarURL, &portal.Encrypted, &firstEventID, &nextBatchID, &relayUserID) if err != nil { if err != sql.ErrNoRows { portal.log.Errorln("Database scan failed:", err) @@ -133,6 +136,9 @@ func (portal *Portal) Scan(row Scannable) *Portal { } portal.MXID = id.RoomID(mxid.String) portal.AvatarURL, _ = id.ParseContentURI(avatarURL.String) + portal.FirstEventID = id.EventID(firstEventID.String) + portal.NextBatchID = id.BatchID(nextBatchID.String) + portal.RelayUserID = id.UserID(relayUserID.String) return portal } @@ -143,21 +149,24 @@ func (portal *Portal) mxidPtr() *id.RoomID { return nil } +func (portal *Portal) relayUserPtr() *id.UserID { + if len(portal.RelayUserID) > 0 { + return &portal.RelayUserID + } + return nil +} + func (portal *Portal) Insert() { - _, err := portal.db.Exec("INSERT INTO portal (jid, receiver, mxid, name, topic, avatar, avatar_url, encrypted) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", - portal.Key.JID, portal.Key.Receiver, portal.mxidPtr(), portal.Name, portal.Topic, portal.Avatar, portal.AvatarURL.String(), portal.Encrypted) + _, err := portal.db.Exec("INSERT INTO portal (jid, receiver, mxid, name, topic, avatar, avatar_url, encrypted, first_event_id, next_batch_id, relay_user_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)", + portal.Key.JID, portal.Key.Receiver, portal.mxidPtr(), portal.Name, portal.Topic, portal.Avatar, portal.AvatarURL.String(), portal.Encrypted, portal.FirstEventID.String(), portal.NextBatchID.String(), portal.relayUserPtr()) if err != nil { portal.log.Warnfln("Failed to insert %s: %v", portal.Key, err) } } func (portal *Portal) Update() { - var mxid *id.RoomID - if len(portal.MXID) > 0 { - mxid = &portal.MXID - } - _, err := portal.db.Exec("UPDATE portal SET mxid=$1, name=$2, topic=$3, avatar=$4, avatar_url=$5, encrypted=$6 WHERE jid=$7 AND receiver=$8", - mxid, portal.Name, portal.Topic, portal.Avatar, portal.AvatarURL.String(), portal.Encrypted, portal.Key.JID, portal.Key.Receiver) + _, err := portal.db.Exec("UPDATE portal SET mxid=$3, name=$4, topic=$5, avatar=$6, avatar_url=$7, encrypted=$8, first_event_id=$9, next_batch_id=$10, relay_user_id=$11 WHERE jid=$1 AND receiver=$2", + portal.Key.JID, portal.Key.Receiver, portal.mxidPtr(), portal.Name, portal.Topic, portal.Avatar, portal.AvatarURL.String(), portal.Encrypted, portal.FirstEventID.String(), portal.NextBatchID.String(), portal.relayUserPtr()) if err != nil { portal.log.Warnfln("Failed to update %s: %v", portal.Key, err) } @@ -169,26 +178,3 @@ func (portal *Portal) Delete() { portal.log.Warnfln("Failed to delete %s: %v", portal.Key, err) } } - -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..f9f1308 --- /dev/null +++ b/database/upgrades/2021-10-21-multidevice-updates.go @@ -0,0 +1,93 @@ +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 + } + + // Clear all usernames, the users need to relogin anyway. + _, err = tx.Exec(`UPDATE "user" SET username=null`) + if err != nil { + return err + } + } + return nil + }} +} diff --git a/database/upgrades/2021-10-26-portal-origin-event-id.go b/database/upgrades/2021-10-26-portal-origin-event-id.go new file mode 100644 index 0000000..37b8908 --- /dev/null +++ b/database/upgrades/2021-10-26-portal-origin-event-id.go @@ -0,0 +1,19 @@ +package upgrades + +import ( + "database/sql" +) + +func init() { + upgrades[26] = upgrade{"Add columns to store infinite backfill pointers for portals", func(tx *sql.Tx, ctx context) error { + _, err := tx.Exec(`ALTER TABLE portal ADD COLUMN first_event_id TEXT NOT NULL DEFAULT ''`) + if err != nil { + return err + } + _, err = tx.Exec(`ALTER TABLE portal ADD COLUMN next_batch_id TEXT NOT NULL DEFAULT ''`) + if err != nil { + return err + } + return nil + }} +} diff --git a/database/upgrades/2021-10-27-message-decryption-errors.go b/database/upgrades/2021-10-27-message-decryption-errors.go new file mode 100644 index 0000000..288709e --- /dev/null +++ b/database/upgrades/2021-10-27-message-decryption-errors.go @@ -0,0 +1,12 @@ +package upgrades + +import ( + "database/sql" +) + +func init() { + upgrades[27] = upgrade{"Add marker for WhatsApp decryption errors in message table", func(tx *sql.Tx, ctx context) error { + _, err := tx.Exec(`ALTER TABLE message ADD COLUMN decryption_error BOOLEAN NOT NULL DEFAULT false`) + return err + }} +} diff --git a/database/upgrades/2021-10-28-portal-relay-user.go b/database/upgrades/2021-10-28-portal-relay-user.go new file mode 100644 index 0000000..81beedc --- /dev/null +++ b/database/upgrades/2021-10-28-portal-relay-user.go @@ -0,0 +1,12 @@ +package upgrades + +import ( + "database/sql" +) + +func init() { + upgrades[28] = upgrade{"Add relay user field to portal table", func(tx *sql.Tx, ctx context) error { + _, err := tx.Exec(`ALTER TABLE portal ADD COLUMN relay_user_id TEXT`) + return err + }} +} diff --git a/database/upgrades/upgrades.go b/database/upgrades/upgrades.go index 9bcc1c2..c0d9260 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 = 29 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 dd378f9..e2d84f4 100644 --- a/example-config.yaml +++ b/example-config.yaml @@ -27,6 +27,7 @@ appservice: # The database URI. # SQLite: File name is enough. https://github.com/mattn/go-sqlite3#connection-string # Postgres: Connection string. For example, postgres://user:password@host/database?sslmode=disable + # To connect via Unix socket, use something like postgres:///dbname?host=/var/run/postgresql uri: mautrix-whatsapp.db # Maximum number of connections. Mostly relevant for Postgres. max_open_conns: 20 @@ -63,9 +64,10 @@ metrics: whatsapp: # Device name that's shown in the "WhatsApp Web" section in the mobile app. os_name: Mautrix-WhatsApp bridge - # Browser name that determines the logo shown in the mobile app. If the name is unrecognized, a generic icon is shown. - # Use the name of an actual browser (Chrome, Firefox, Safari, IE, Edge, Opera) if you want a specific icon. - browser_name: mx-wa + # Browser name that determines the logo shown in the mobile app. + # Must be "unknown" for a generic icon or a valid browser name if you want a specific icon. + # List of valid browser names: https://github.com/tulir/whatsmeow/blob/2a72655ef600a7fd7a2e98d53ec6da029759c4b8/binary/proto/def.proto#L1582-L1594 + browser_name: unknown # Bridge config bridge: @@ -73,49 +75,19 @@ 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 - # If WhatsApp doesn't respond within connection_timeout, should the bridge try to fetch the message - # to see if it was actually bridged? Use this if you have problems with sends timing out but actually - # succeeding. - fetch_message_on_timeout: false # Whether or not the bridge should send a read receipt from the bridge bot when a message has been # sent to WhatsApp. If fetch_message_on_timeout is enabled, a successful post-timeout fetch will # trigger a read receipt too. delivery_receipts: false - # Maximum number of times to retry connecting on connection error. - max_connection_attempts: 3 - # Number of seconds to wait between connection attempts. - # Negative numbers are exponential backoff: -connection_retry_delay + 1 + 2^attempts - connection_retry_delay: -1 - # Whether or not the bridge should send a notice to the user's management room when it retries connecting. - # If false, it will only report when it stops retrying. - report_connection_retry: true - # Whether or not the bridge should reconnect even if WhatsApp says another web client connected. - aggressive_reconnect: false - # Maximum number of seconds to wait for chats to be sent at startup. - # If this is too low and you have lots of chats, it could cause backfilling to fail. - chat_list_wait: 30 - # Maximum number of seconds to wait to sync portals before force unlocking message processing. - # If this is too low and you have lots of chats, it could cause backfilling to fail. - portal_sync_wait: 600 - user_message_buffer: 1024 + portal_message_buffer: 128 # Whether or not to send call start/end notices to Matrix. @@ -123,32 +95,20 @@ bridge: start: true end: true - # Number of chats to sync for new users. - initial_chat_sync_count: 10 - # Number of old messages to fill when creating new portal rooms. - initial_history_fill_count: 20 - # Whether or not notifications should be turned off while filling initial history. - # Only applicable when using double puppeting. - initial_history_disable_notifications: false - # Maximum number of chats to sync when recovering from downtime. - # Set to -1 to sync all new chats during downtime. - recovery_chat_sync_limit: -1 - # Whether or not to sync history when recovering from downtime. - recovery_history_backfill: true - # Whether or not portal info should be fetched from the server when syncing, - # instead of relying on finding any changes in the message history. - # If you get 599 errors often, you should try disabling this. - chat_meta_sync: true + history_sync: + # Whether to create portals from history sync payloads from WhatsApp. + create_portals: true + # Whether to enable backfilling history sync payloads from WhatsApp using batch sending + # This requires a server with MSC2716 support, which is currently an experimental feature in synapse. + # It can be enabled by setting experimental_features -> enable_msc2716 to true in homeserver.yaml. + backfill: false + # Whether to use custom puppet for backfilling. + # In order to use this, the custom puppets must be in the appservice's user ID namespace. + double_puppet_backfill: false # Whether or not puppet avatars should be fetched from the server even if an avatar is already set. - # If you get 599 errors often, you should try disabling this. user_avatar_sync: true # Whether or not Matrix users leaving groups should be bridged to WhatsApp bridge_matrix_leave: true - # Maximum number of seconds since last message in chat to skip - # syncing the chat in any case. This setting will take priority - # over both recovery_chat_sync_limit and initial_chat_sync_count. - # Default is 3 days = 259200 seconds - sync_max_chat_age: 259200 # Whether or not to sync with custom puppets to receive EDUs that # are not normally sent to appservices. @@ -169,20 +129,12 @@ bridge: # manually. login_shared_secret: null - # Whether or not to invite own WhatsApp user's Matrix puppet into private - # chat portals when backfilling if needed. - # This always uses the default puppet instead of custom puppets due to - # rate limits and timestamp massaging. - invite_own_puppet_for_backfilling: true - # Whether or not to explicitly set the avatar and room name for private - # chat portal rooms. This can be useful if the previous field works fine, - # but causes room avatar/name bugs. + # Whether to explicitly set the avatar and room name for private chat portal rooms. private_chat_portal_meta: false - # Whether or not Matrix m.notice-type messages should be bridged. + # Whether Matrix m.notice-type messages should be bridged. bridge_notices: true # Set this to true to tell the bridge to re-send m.bridge events to all rooms on the next run. - # This field will automatically be changed back to false after it, - # except if the config file is not writable. + # This field will automatically be changed back to false after it, except if the config file is not writable. resend_bridge_info: false # When using double puppeting, should muted chats be muted in Matrix? mute_bridging: false @@ -246,7 +198,7 @@ bridge: # Permissions for using the bridge. # Permitted values: - # relaybot - Talk through the relaybot (if enabled), no access otherwise + # relay - Talk through the relaybot (if enabled), no access otherwise # user - Access to use the bridge to chat with a WhatsApp account. # admin - User level and some additional administration tools # Permitted keys: @@ -254,19 +206,16 @@ bridge: # domain - All users on that homeserver # mxid - Specific user permissions: - "*": relaybot + "*": relay "example.com": user "@admin:example.com": admin - relaybot: - # Whether or not relaybot support is enabled. + relay: + # Whether relay mode should be allowed. If allowed, `!signal set-relay` can be used to turn any + # authenticated user into a relaybot for that chat. enabled: false - # The management room for the bot. This is where all status notifications are posted and - # in this room, you can use `!wa ` instead of `!wa relaybot `. Omitting - # the command prefix completely like in user management rooms is not possible. - management: "!foo:example.com" - # List of users to invite to all created rooms that include the relaybot. - invites: [] + # Should only admins be allowed to set themselves as relay users? + admin_only: true # The formats to use when sending messages to WhatsApp via the relaybot. message_formats: m.text: "{{ .Sender.Displayname }}: {{ .Message }}" 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..973d708 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-20211029221633-b2fb3fda9a8c 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/maulogger/v2 v2.3.1 + maunium.net/go/mautrix v0.9.31 ) -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.10.2 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.0 // indirect + github.com/tidwall/sjson v1.2.3 // indirect + go.mau.fi/libsignal v0.0.0-20211024113310-f9fc6a1855f2 // indirect + golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 // indirect + golang.org/x/net v0.0.0-20211020060615-d418f374d309 // indirect + golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 // indirect +) diff --git a/go.sum b/go.sum index e301303..785b073 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= @@ -76,12 +77,10 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN 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/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/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.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= @@ -128,26 +127,27 @@ github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= -github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/tidwall/gjson v1.6.8 h1:CTmXMClGYPAmln7652e69B7OLXfTi5ABcPPwjIWUv7w= -github.com/tidwall/gjson v1.6.8/go.mod h1:zeFuBCIqD4sN/gmqBzZ4j7Jd6UcA2Fc56x7QFsv+8fI= -github.com/tidwall/match v1.0.3 h1:FQUVvBImDutD8wJLN6c5eMzWtjgONK9MwIBCOrUJKeE= -github.com/tidwall/match v1.0.3/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= -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= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/tidwall/gjson v1.10.2 h1:APbLGOM0rrEkd8WBw9C24nllro4ajFuJu0Sc9hRz8Bo= +github.com/tidwall/gjson v1.10.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.3 h1:5+deguEhHSEjmuICXZ21uSSsXotWMA0orU783+Z7Cp8= +github.com/tidwall/sjson v1.2.3/go.mod h1:5WdjKx3AQMvCJ4RG6/2UYT7dLrGvJUV1x4jdTAyGvZs= +go.mau.fi/libsignal v0.0.0-20211024113310-f9fc6a1855f2 h1:xpQTMgJGGaF+c8jV/LA/FVXAPJxZbSAGeflOc+Ly6uQ= +go.mau.fi/libsignal v0.0.0-20211024113310-f9fc6a1855f2/go.mod h1:3XlVlwOfp8f9Wri+C1D4ORqgUsN4ZvunJOoPjQMBhos= +go.mau.fi/whatsmeow v0.0.0-20211029221633-b2fb3fda9a8c h1:ZmmT3L8pMKLW3JhcP6Rt0dJg09N+20a8fROxr8MUKzg= +go.mau.fi/whatsmeow v0.0.0-20211029221633-b2fb3fda9a8c/go.mod h1:ODEmmqeUn9eBDQHFc1S902YA3YFLtmaBujYRRFl53jI= 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= @@ -157,9 +157,9 @@ golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73r golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20210220033124-5f55cee0dc0d/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110 h1:qWPm9rbaAMKs8Bq/9LRpbMqxWRVUAQwMI9fVrssnTfw= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20211020060615-d418f374d309 h1:A0lJIi+hcTR6aajJH4YqKWwohY4aW9RO7oRMcdv+HKI= +golang.org/x/net v0.0.0-20211020060615-d418f374d309/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -172,16 +172,16 @@ golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 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-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= +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-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= @@ -198,8 +198,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= @@ -217,8 +217,7 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M= maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA= -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/maulogger/v2 v2.3.1 h1:fwBYJne0pHvJrrIPHK+TAPfyxxbBEz46oVGez2x0ODE= +maunium.net/go/maulogger/v2 v2.3.1/go.mod h1:TYWy7wKwz/tIXTpsx8G3mZseIRiC5DoMxSZazOHy68A= +maunium.net/go/mautrix v0.9.31 h1:n7UF5tqq2zCyfdNsv++RyQ2anjjrFVOmOA2VkZCSgZc= +maunium.net/go/mautrix v0.9.31/go.mod h1:3U7pOAx4bxdIVJuunLDAToI+M7YwxcGMm74zBmX5aY0= diff --git a/main.go b/main.go index 9432d0a..5a881ee 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" @@ -41,21 +47,29 @@ import ( "maunium.net/go/mautrix-whatsapp/database/upgrades" ) +// The name and repo URL of the bridge. var ( - // These are static Name = "mautrix-whatsapp" URL = "https://github.com/mautrix/whatsapp" - // This is changed when making a release - Version = "0.1.9" - // This is filled by init() - WAVersion = "" - VersionString = "" - // These are filled at build time with the -X linker flag +) + +// Information to find out exactly which commit the bridge was built from. +// These are filled at build time with the -X linker flag. +var ( Tag = "unknown" Commit = "unknown" BuildTime = "unknown" ) +var ( + // Version is the version number of the bridge. Changed manually when making a release. + Version = "0.2.0+dev" + // WAVersion is the version number exposed to WhatsApp. Filled in init() + WAVersion = "" + // VersionString is the bridge version, plus commit information. Filled in init() using the build-time values. + VersionString = "" +) + func init() { if len(Tag) > 0 && Tag[0] == 'v' { Tag = Tag[1:] @@ -145,19 +159,19 @@ type Bridge struct { Provisioning *ProvisioningAPI Bot *appservice.IntentAPI Formatter *Formatter - 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 +190,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 +273,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 +287,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() { @@ -289,12 +322,10 @@ func (bridge *Bridge) Start() { os.Exit(19) } } - bridge.sendGlobalBridgeState(BridgeState{StateEvent: StateStarting}.fill(nil)) if bridge.Provisioning != nil { bridge.Log.Debugln("Initializing provisioning API") bridge.Provisioning.Init() } - bridge.LoadRelaybot() bridge.Log.Debugln("Starting application service HTTP server") go bridge.AS.Start() bridge.Log.Debugln("Starting event processor") @@ -327,21 +358,6 @@ func (bridge *Bridge) ResendBridgeInfo() { bridge.Log.Infoln("Finished re-sending bridge info state events") } -func (bridge *Bridge) LoadRelaybot() { - if !bridge.Config.Bridge.Relaybot.Enabled { - return - } - bridge.Relaybot = bridge.GetUserByMXID("relaybot") - if bridge.Relaybot.HasSession() { - bridge.Log.Debugln("Relaybot is enabled") - } else { - bridge.Log.Debugln("Relaybot is enabled, but not logged in") - } - bridge.Relaybot.ManagementRoom = bridge.Config.Bridge.Relaybot.ManagementRoom - bridge.Relaybot.IsRelaybot = true - bridge.Relaybot.Connect(false) -} - func (bridge *Bridge) UpdateBotProfile() { bridge.Log.Debugln("Updating bot profile") botConfig := bridge.Config.AppService.Bot @@ -374,10 +390,10 @@ 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) + go user.Connect() } if !foundAnySessions { bridge.sendGlobalBridgeState(BridgeState{StateEvent: StateUnconfigured}.fill(nil)) @@ -401,15 +417,13 @@ 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() + close(user.historySyncs) } } diff --git a/matrix.go b/matrix.go index 43c48d4..92869bd 100644 --- a/matrix.go +++ b/matrix.go @@ -121,29 +121,9 @@ func (mx *MatrixHandler) HandleBotInvite(evt *event.Event) { return } - if evt.RoomID == mx.bridge.Config.Bridge.Relaybot.ManagementRoom { - _, _ = intent.SendNotice(evt.RoomID, "This is the relaybot management room. Send `!wa help` to get a list of commands.") - mx.log.Debugln("Joined relaybot management room", evt.RoomID, "after invite from", evt.Sender) - return - } - - hasPuppets := false - for mxid, _ := range members.Joined { - if mxid == intent.UserID || mxid == evt.Sender { - continue - } else if _, ok := mx.bridge.ParsePuppetMXID(mxid); ok { - hasPuppets = true - continue - } - mx.log.Debugln("Leaving multi-user room", evt.RoomID, "after accepting invite from", evt.Sender) - _, _ = intent.SendNotice(evt.RoomID, "This bridge is user-specific, please don't invite me into rooms with other users.") - _, _ = intent.LeaveRoom(evt.RoomID) - return - } - _, _ = mx.sendNoticeWithMarkdown(evt.RoomID, mx.bridge.Config.Bridge.ManagementRoomText.Welcome) - if !hasPuppets && (len(user.ManagementRoom) == 0 || evt.Content.AsMember().IsDirect) { + if len(members.Joined) == 2 && (len(user.ManagementRoom) == 0 || evt.Content.AsMember().IsDirect) { user.SetManagementRoom(evt.RoomID) _, _ = intent.SendNotice(user.ManagementRoom, "This room has been registered as your bridge management/status room.") mx.log.Debugln(evt.RoomID, "registered as a management room with", evt.Sender) @@ -223,13 +203,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) { @@ -281,7 +258,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 } @@ -322,7 +299,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 } @@ -343,7 +320,7 @@ func (mx *MatrixHandler) shouldIgnoreEvent(evt *event.Event) bool { return true } user := mx.bridge.GetUserByMXID(evt.Sender) - if !user.RelaybotWhitelisted { + if !user.RelayWhitelisted { return true } return false @@ -461,7 +438,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 064dc11..73d9762 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 @@ -28,7 +28,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" @@ -59,16 +59,12 @@ type MetricsHandler struct { unencryptedGroupCount prometheus.Gauge unencryptedPrivateCount prometheus.Gauge - connected prometheus.Gauge - connectedState map[whatsapp.JID]bool - connectedStateLock sync.Mutex - loggedIn prometheus.Gauge - loggedInState map[whatsapp.JID]bool - loggedInStateLock sync.Mutex - syncLocked prometheus.Gauge - syncLockedState map[whatsapp.JID]bool - syncLockedStateLock sync.Mutex - bufferLength *prometheus.GaugeVec + connected prometheus.Gauge + connectedState map[string]bool + connectedStateLock sync.Mutex + loggedIn prometheus.Gauge + loggedInState map[string]bool + loggedInStateLock sync.Mutex } func NewMetricsHandler(address string, log log.Logger, db *database.Database) *MetricsHandler { @@ -125,21 +121,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), } } @@ -158,7 +145,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 } @@ -169,7 +156,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()) } } @@ -180,15 +167,15 @@ 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 } mh.loggedInStateLock.Lock() defer mh.loggedInStateLock.Unlock() - 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 { @@ -197,16 +184,15 @@ 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 } - mh.connectedStateLock.Lock() defer mh.connectedStateLock.Unlock() - 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 { @@ -215,30 +201,6 @@ func (mh *MetricsHandler) TrackConnectionState(jid whatsapp.JID, connected bool) } } -func (mh *MetricsHandler) TrackSyncLock(jid whatsapp.JID, locked bool) { - if !mh.running { - return - } - mh.syncLockedStateLock.Lock() - defer mh.syncLockedStateLock.Unlock() - 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/no-crypto.go b/no-crypto.go index 75a2c68..14e49c0 100644 --- a/no-crypto.go +++ b/no-crypto.go @@ -1,3 +1,4 @@ +//go:build !cgo || nocrypto // +build !cgo nocrypto package main diff --git a/portal.go b/portal.go index a28eb47..566e746 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,25 @@ 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" + + "maunium.net/go/mautrix/format" + + "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,9 +55,7 @@ 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" "maunium.net/go/mautrix-whatsapp/database" ) @@ -91,7 +92,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 +140,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 +153,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 +162,14 @@ 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 + undecryptable *events.UndecryptableMessage + source *User +} + +type recentlyHandledWrapper struct { + id types.MessageID + err bool } type Portal struct { @@ -179,51 +180,23 @@ type Portal struct { roomCreateLock sync.Mutex encryptLock sync.Mutex + backfillLock sync.Mutex - recentlyHandled [recentlyHandledLength]whatsapp.MessageID + recentlyHandled [recentlyHandledLength]recentlyHandledWrapper recentlyHandledLock sync.Mutex recentlyHandledIndex uint8 - backfillLock sync.Mutex - backfilling bool - lastMessageTs uint64 - privateChatBackfillInvitePuppet func() messages chan PortalMessage - isPrivate *bool - isBroadcast *bool - hasRelaybot *bool -} - -const MaxMessageAgeToCreatePortal = 5 * 60 // 5 minutes - -func (portal *Portal) syncDoublePuppetDetailsAfterCreate(source *User) { - doublePuppet := portal.bridge.GetPuppetByCustomMXID(source.MXID) - 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) + relayUser *User } 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 } @@ -233,103 +206,205 @@ func (portal *Portal) handleMessageLoop() { portal.log.Errorln("Failed to create portal room:", err) continue } - portal.syncDoublePuppetDetailsAfterCreate(msg.source) } - portal.backfillLock.Lock() - portal.handleMessage(msg, false) - portal.backfillLock.Unlock() + if msg.evt != nil { + portal.handleMessage(msg.source, msg.evt) + } else if msg.undecryptable != nil { + portal.handleUndecryptableMessage(msg.source, msg.undecryptable) + } else { + portal.log.Warnln("Unexpected PortalMessage with no message: %+v", msg) + } } } 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 + if msg.undecryptable != nil { + return true } - return true -} - -func (portal *Portal) handleMessage(msg PortalMessage, isBackfill bool) { - if len(portal.MXID) == 0 { - portal.log.Warnln("handleMessage called even though portal.MXID is empty") - return + 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, } - var triedToHandle bool - var trackMessageCallback func() - dataType := reflect.TypeOf(msg.data) - if !isBackfill { - trackMessageCallback = portal.bridge.Metrics.TrackWhatsAppMessage(msg.timestamp, dataType.Name()) - } - 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) - default: - portal.log.Warnln("Unknown message type:", dataType) - } - if triedToHandle && trackMessageCallback != nil { - trackMessageCallback() - } -} - -func (portal *Portal) isRecentlyHandled(id whatsapp.MessageID) bool { - start := portal.recentlyHandledIndex - for i := start; i != start; i = (i - 1) % recentlyHandledLength { - if portal.recentlyHandled[i] == id { + for _, message := range supportedMessages { + if message != nil { return true } } return false } -func (portal *Portal) isDuplicate(id whatsapp.MessageID) bool { - msg := portal.bridge.DB.Message.GetByJID(portal.Key, id) - if msg != nil { - return true +func (portal *Portal) getMessageType(waMsg *waProto.Message) string { + switch { + case waMsg == nil: + return "ignore" + case waMsg.Conversation != nil || waMsg.ExtendedTextMessage != nil: + return "text" + case waMsg.ImageMessage != nil: + return fmt.Sprintf("image %s", waMsg.GetImageMessage().GetMimetype()) + case waMsg.StickerMessage != nil: + return fmt.Sprintf("sticker %s", waMsg.GetStickerMessage().GetMimetype()) + case waMsg.VideoMessage != nil: + return fmt.Sprintf("video %s", waMsg.GetVideoMessage().GetMimetype()) + case waMsg.AudioMessage != nil: + return fmt.Sprintf("audio %s", waMsg.GetAudioMessage().GetMimetype()) + case waMsg.DocumentMessage != nil: + return fmt.Sprintf("document %s", waMsg.GetDocumentMessage().GetMimetype()) + case waMsg.ContactMessage != nil: + return "contact" + case waMsg.LocationMessage != nil: + return "location" + case waMsg.GetProtocolMessage() != nil: + switch waMsg.GetProtocolMessage().GetType() { + case waProto.ProtocolMessage_REVOKE: + return "revoke" + case waProto.ProtocolMessage_APP_STATE_SYNC_KEY_SHARE, waProto.ProtocolMessage_HISTORY_SYNC_NOTIFICATION, waProto.ProtocolMessage_INITIAL_SECURITY_NOTIFICATION_SETTING_SYNC: + return "ignore" + default: + return "unknown_protocol" + } + default: + return "unknown" + } +} + +func (portal *Portal) convertMessage(intent *appservice.IntentAPI, source *User, info *types.MessageInfo, waMsg *waProto.Message) *ConvertedMessage { + switch { + case waMsg.Conversation != nil || waMsg.ExtendedTextMessage != nil: + return portal.convertTextMessage(intent, waMsg) + case waMsg.ImageMessage != nil: + return portal.convertMediaMessage(intent, source, info, waMsg.GetImageMessage()) + case waMsg.StickerMessage != nil: + return portal.convertMediaMessage(intent, source, info, waMsg.GetStickerMessage()) + case waMsg.VideoMessage != nil: + return portal.convertMediaMessage(intent, source, info, waMsg.GetVideoMessage()) + case waMsg.AudioMessage != nil: + return portal.convertMediaMessage(intent, source, info, waMsg.GetAudioMessage()) + case waMsg.DocumentMessage != nil: + return portal.convertMediaMessage(intent, source, info, waMsg.GetDocumentMessage()) + case waMsg.ContactMessage != nil: + return portal.convertContactMessage(intent, waMsg.GetContactMessage()) + case waMsg.LocationMessage != nil: + return portal.convertLocationMessage(intent, waMsg.GetLocationMessage()) + default: + return nil + } +} + +const UndecryptableMessageNotice = "Decrypting message from WhatsApp failed, waiting for sender to re-send... " + + "([learn more](https://faq.whatsapp.com/general/security-and-privacy/seeing-waiting-for-this-message-this-may-take-a-while))" + +var undecryptableMessageContent event.MessageEventContent + +func init() { + undecryptableMessageContent = format.RenderMarkdown(UndecryptableMessageNotice, true, false) + undecryptableMessageContent.MsgType = event.MsgNotice +} + +func (portal *Portal) handleUndecryptableMessage(source *User, evt *events.UndecryptableMessage) { + if len(portal.MXID) == 0 { + portal.log.Warnln("handleUndecryptableMessage called even though portal.MXID is empty") + return + } else if portal.isRecentlyHandled(evt.Info.ID, true) { + portal.log.Debugfln("Not handling %s (undecryptable): message was recently handled", evt.Info.ID) + return + } else if existingMsg := portal.bridge.DB.Message.GetByJID(portal.Key, evt.Info.ID); existingMsg != nil { + portal.log.Debugfln("Not handling %s (undecryptable): message is duplicate", evt.Info.ID) + return + } + intent := portal.getMessageIntent(source, &evt.Info) + content := undecryptableMessageContent + resp, err := portal.sendMessage(intent, event.EventMessage, &content, evt.Info.Timestamp.UnixMilli()) + if err != nil { + portal.log.Errorln("Failed to send decryption error of %s to Matrix: %v", evt.Info.ID, err) + } + portal.finishHandling(nil, &evt.Info, resp.EventID, true) +} + +func (portal *Portal) handleMessage(source *User, evt *events.Message) { + if len(portal.MXID) == 0 { + portal.log.Warnln("handleMessage called even though portal.MXID is empty") + return + } + msgID := evt.Info.ID + msgType := portal.getMessageType(evt.Message) + if msgType == "ignore" { + return + } else if portal.isRecentlyHandled(msgID, false) { + portal.log.Debugfln("Not handling %s (%s): message was recently handled", msgID, msgType) + return + } + existingMsg := portal.bridge.DB.Message.GetByJID(portal.Key, msgID) + if existingMsg != nil { + if existingMsg.DecryptionError { + portal.log.Debugfln("Got decryptable version of previously undecryptable message %s (%s)", msgID, msgType) + } else { + portal.log.Debugfln("Not handling %s (%s): message is duplicate", msgID, msgType) + return + } + } + + intent := portal.getMessageIntent(source, &evt.Info) + converted := portal.convertMessage(intent, source, &evt.Info, evt.Message) + if converted != nil { + var eventID id.EventID + if existingMsg != nil { + converted.Content.SetEdit(existingMsg.MXID) + } + resp, err := portal.sendMessage(converted.Intent, converted.Type, converted.Content, evt.Info.Timestamp.UnixMilli()) + if err != nil { + portal.log.Errorln("Failed to send %s to Matrix: %v", msgID, err) + } else { + eventID = resp.EventID + } + // TODO figure out how to handle captions with undecryptable messages turning decryptable + if converted.Caption != nil && existingMsg == nil { + resp, err = portal.sendMessage(converted.Intent, converted.Type, converted.Content, evt.Info.Timestamp.UnixMilli()) + if err != nil { + portal.log.Errorln("Failed to send caption of %s to Matrix: %v", msgID, err) + } else { + eventID = resp.EventID + } + } + if len(eventID) != 0 { + portal.finishHandling(existingMsg, &evt.Info, resp.EventID, false) + } + } else if msgType == "revoke" { + portal.HandleMessageRevoke(source, &evt.Info, evt.Message.GetProtocolMessage().GetKey()) + if existingMsg != nil { + _, _ = portal.MainIntent().RedactEvent(portal.MXID, existingMsg.MXID, mautrix.ReqRedact{ + Reason: "The undecryptable message was actually the deletion of another message", + }) + existingMsg.UpdateMXID("net.maunium.whatsapp.fake::"+existingMsg.MXID, false) + } + } else { + portal.log.Warnfln("Unhandled message: %+v / %+v", evt.Info, evt.Message) + if existingMsg != nil { + _, _ = portal.MainIntent().RedactEvent(portal.MXID, existingMsg.MXID, mautrix.ReqRedact{ + Reason: "The undecryptable message contained an unsupported message type", + }) + existingMsg.UpdateMXID("net.maunium.whatsapp.fake::"+existingMsg.MXID, false) + } + return + } + portal.bridge.Metrics.TrackWhatsAppMessage(evt.Info.Timestamp, strings.Split(msgType, " ")[0]) +} + +func (portal *Portal) isRecentlyHandled(id types.MessageID, decryptionError bool) bool { + start := portal.recentlyHandledIndex + lookingForMsg := recentlyHandledWrapper{id, decryptionError} + for i := start; i != start; i = (i - 1) % recentlyHandledLength { + if portal.recentlyHandled[i] == lookingForMsg { + return true + } } return false } @@ -338,148 +413,127 @@ func init() { gob.Register(&waProto.Message{}) } -func (portal *Portal) markHandled(source *User, message *waProto.WebMessageInfo, mxid id.EventID, isSent bool) *database.Message { - msg := portal.bridge.DB.Message.New() - msg.Chat = portal.Key - msg.JID = message.GetKey().GetId() - 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 +func (portal *Portal) markHandled(msg *database.Message, info *types.MessageInfo, mxid id.EventID, isSent, recent, decryptionError bool) *database.Message { + if msg == nil { + msg = portal.bridge.DB.Message.New() + msg.Chat = portal.Key + msg.JID = info.ID + msg.MXID = mxid + msg.Timestamp = info.Timestamp + msg.Sender = info.Sender + msg.Sent = isSent + msg.DecryptionError = decryptionError + msg.Insert() } else { - msg.Sender = message.GetKey().GetParticipant() - if len(msg.Sender) == 0 { - msg.Sender = message.GetParticipant() - } + msg.UpdateMXID(mxid, decryptionError) } - msg.Sent = isSent - msg.Insert() - portal.recentlyHandledLock.Lock() - index := portal.recentlyHandledIndex - portal.recentlyHandledIndex = (portal.recentlyHandledIndex + 1) % recentlyHandledLength - portal.recentlyHandledLock.Unlock() - portal.recentlyHandled[index] = msg.JID + if recent { + portal.recentlyHandledLock.Lock() + index := portal.recentlyHandledIndex + portal.recentlyHandledIndex = (portal.recentlyHandledIndex + 1) % recentlyHandledLength + portal.recentlyHandledLock.Unlock() + portal.recentlyHandled[index] = recentlyHandledWrapper{msg.JID, decryptionError} + } return msg } -func (portal *Portal) getMessageIntent(user *User, info whatsapp.MessageInfo) *appservice.IntentAPI { - if info.FromMe { +func (portal *Portal) getMessagePuppet(user *User, info *types.MessageInfo) *Puppet { + if info.IsFromMe { + return portal.bridge.GetPuppetByJID(user.JID) + } else if portal.IsPrivateChat() { + return portal.bridge.GetPuppetByJID(portal.Key.JID) + } else { + puppet := portal.bridge.GetPuppetByJID(info.Sender) + puppet.SyncContact(user, true) + return puppet + } +} + +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) - } 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) - } else { - 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) { - portal.markHandled(source, message, mxid, true) +func (portal *Portal) finishHandling(existing *database.Message, message *types.MessageInfo, mxid id.EventID, decryptionError bool) { + portal.markHandled(existing, message, mxid, true, true, decryptionError) portal.sendDeliveryReceipt(mxid) - portal.log.Debugln("Handled message", message.GetKey().GetId(), "->", mxid) + if !decryptionError { + portal.log.Debugln("Handled message", message.ID, "->", mxid) + } else { + portal.log.Debugln("Handled message", message.ID, "->", mxid, "(undecryptable message error notice)") + } } -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) - } else { - for member := range members.Joined { - jid, ok := portal.bridge.ParsePuppetMXID(member) - if ok { - _, shouldBePresent := participantMap[jid] - if !shouldBePresent { - _, err = portal.MainIntent().KickUser(portal.MXID, &mautrix.ReqKickUser{ - UserID: member, - Reason: "User had left this WhatsApp chat", - }) - if err != nil { - portal.log.Warnfln("Failed to kick user %s who had left: %v", member, err) - } + return + } + for member := range members.Joined { + jid, ok := portal.bridge.ParsePuppetMXID(member) + if ok { + _, shouldBePresent := participantMap[jid] + if !shouldBePresent { + _, err = portal.MainIntent().KickUser(portal.MXID, &mautrix.ReqKickUser{ + UserID: member, + Reason: "User had left this WhatsApp chat", + }) + if err != nil { + portal.log.Warnfln("Failed to kick user %s who had left: %v", member, err) } } } } } -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) - 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) + puppet.SyncContact(source, true) + user := portal.bridge.GetUserByJID(participant.JID) + if user != nil { + portal.ensureUserInvited(user) + } + if user == nil || !puppet.IntentFor(portal).IsCustomPuppet { + 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,71 +552,70 @@ 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, setBy types.JID, 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 { - _, err := portal.MainIntent().SetRoomAvatar(portal.MXID, portal.AvatarURL) + intent := portal.MainIntent() + if !setBy.IsEmpty() { + intent = portal.bridge.GetPuppetByJID(setBy).IntentFor(portal) + } + _, err = intent.SetRoomAvatar(portal.MXID, portal.AvatarURL) + if errors.Is(err, mautrix.MForbidden) && intent != portal.MainIntent() { + _, err = portal.MainIntent().SetRoomAvatar(portal.MXID, portal.AvatarURL) + } if err != nil { portal.log.Warnln("Failed to set room topic:", err) 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, updateInfo bool) bool { if name == "" && portal.IsBroadcastList() { name = UnnamedBroadcastName } if portal.Name != name { portal.log.Debugfln("Updating name %s -> %s", portal.Name, name) portal.Name = name - if intent == nil { - intent = portal.MainIntent() - if len(setBy) > 0 { - intent = portal.bridge.GetPuppetByJID(setBy).IntentFor(portal) - } + + intent := portal.MainIntent() + if !setBy.IsEmpty() { + intent = portal.bridge.GetPuppetByJID(setBy).IntentFor(portal) } _, err := intent.SetRoomName(portal.MXID, name) + if errors.Is(err, mautrix.MForbidden) && intent != portal.MainIntent() { + _, err = portal.MainIntent().SetRoomName(portal.MXID, name) + } if err == nil { if updateInfo { portal.UpdateBridgeInfo() @@ -576,17 +629,19 @@ 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, 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 { - intent = portal.bridge.GetPuppetByJID(setBy).IntentFor(portal) - } + + intent := portal.MainIntent() + if !setBy.IsEmpty() { + intent = portal.bridge.GetPuppetByJID(setBy).IntentFor(portal) } _, err := intent.SetRoomTopic(portal.MXID, topic) + if errors.Is(err, mautrix.MForbidden) && intent != portal.MainIntent() { + _, err = portal.MainIntent().SetRoomTopic(portal.MXID, topic) + } if err == nil { if updateInfo { portal.UpdateBridgeInfo() @@ -605,63 +660,41 @@ 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, false) || update + update = portal.UpdateTopic(StatusBroadcastTopic, types.EmptyJID, 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 } portal.SyncParticipants(user, metadata) update := false - update = portal.UpdateName(metadata.Name, metadata.NameSetBy, nil, false) || update - update = portal.UpdateTopic(metadata.Topic, metadata.TopicSetBy, nil, false) || update + update = portal.UpdateName(metadata.Name, metadata.NameSetBy, false) || update + update = portal.UpdateTopic(metadata.Topic, metadata.TopicSetBy, false) || update - portal.RestrictMessageSending(metadata.Announce) + portal.RestrictMessageSending(metadata.IsAnnounce) + portal.RestrictMetadataChanges(metadata.IsLocked) return update } -func (portal *Portal) userMXIDAction(user *User, fn func(mxid id.UserID)) { - if user == nil { - return - } - - if user == portal.bridge.Relaybot { - for _, mxid := range portal.bridge.Config.Bridge.Relaybot.InviteUsers { - fn(mxid) - } - } else { - fn(user.MXID) - } -} - func (portal *Portal) ensureMXIDInvited(mxid id.UserID) { err := portal.MainIntent().EnsureInvited(portal.MXID, mxid) if err != nil { @@ -669,16 +702,11 @@ func (portal *Portal) ensureMXIDInvited(mxid id.UserID) { } } -func (portal *Portal) ensureUserInvited(user *User) { - if user.IsRelaybot { - portal.userMXIDAction(user, portal.ensureMXIDInvited) - return - } - +func (portal *Portal) ensureUserInvited(user *User) (ok bool) { inviteContent := event.Content{ Parsed: &event.MemberEventContent{ Membership: event.MembershipInvite, - IsDirect: portal.IsPrivateChat(), + IsDirect: portal.IsPrivateChat(), }, Raw: map[string]interface{}{}, } @@ -690,30 +718,29 @@ func (portal *Portal) ensureUserInvited(user *User) { var httpErr mautrix.HTTPError if err != nil && errors.As(err, &httpErr) && httpErr.RespError != nil && strings.Contains(httpErr.RespError.Err, "is already in the room") { portal.bridge.StateStore.SetMembership(portal.MXID, user.MXID, event.MembershipJoin) + ok = true } else if err != nil { portal.log.Warnfln("Failed to invite %s: %v", user.MXID, err) + } else { + ok = true } if customPuppet != nil && customPuppet.CustomIntent() != nil { err = customPuppet.CustomIntent().EnsureJoined(portal.MXID) if err != nil { portal.log.Warnfln("Failed to auto-join portal as %s: %v", user.MXID, err) + ok = false + } else { + ok = true } } + return } -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 { - yes := true - portal.hasRelaybot = &yes - } - 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 +753,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, types.EmptyJID, false) || update } if update { portal.Update() @@ -760,7 +787,7 @@ func (portal *Portal) GetBasePowerLevels() *event.PowerLevelsEventContent { } } -func (portal *Portal) ChangeAdminStatus(jids []string, setAdmin bool) id.EventID { +func (portal *Portal) ChangeAdminStatus(jids []types.JID, setAdmin bool) id.EventID { levels, err := portal.MainIntent().PowerLevels(portal.MXID) if err != nil { levels = portal.GetBasePowerLevels() @@ -839,196 +866,273 @@ func (portal *Portal) RestrictMetadataChanges(restrict bool) id.EventID { return "" } -func (portal *Portal) BackfillHistory(user *User, lastMessageTime int64) error { - if !portal.bridge.Config.Bridge.RecoverHistory { +func (portal *Portal) parseWebMessageInfo(webMsg *waProto.WebMessageInfo) *types.MessageInfo { + info := types.MessageInfo{ + MessageSource: types.MessageSource{ + Chat: portal.Key.JID, + IsFromMe: webMsg.GetKey().GetFromMe(), + IsGroup: false, + }, + ID: webMsg.GetKey().GetId(), + PushName: webMsg.GetPushName(), + Timestamp: time.Unix(int64(webMsg.GetMessageTimestamp()), 0), + } + var err error + if info.IsFromMe { + info.Sender = portal.Key.Receiver + } else if portal.IsPrivateChat() { + info.Sender = portal.Key.JID + } else if webMsg.GetParticipant() != "" { + info.Sender, err = types.ParseJID(webMsg.GetParticipant()) + } else if webMsg.GetKey().GetParticipant() != "" { + info.Sender, err = types.ParseJID(webMsg.GetKey().GetParticipant()) + } + if info.Sender.IsEmpty() { + portal.log.Warnfln("Failed to get sender of message %s (parse error: %v)", info.ID, err) return nil } + return &info +} - endBackfill := portal.beginBackfill() - defer endBackfill() +const backfillIDField = "net.maunium.whatsapp.id" - lastMessage := portal.bridge.DB.Message.GetLastInChat(portal.Key) - if lastMessage == nil { - return nil +func (portal *Portal) wrapBatchEvent(info *types.MessageInfo, intent *appservice.IntentAPI, eventType event.Type, content *event.MessageEventContent) (*event.Event, error) { + wrappedContent := event.Content{ + Parsed: content, + Raw: map[string]interface{}{ + backfillIDField: info.ID, + }, } - if lastMessage.Timestamp >= lastMessageTime { - portal.log.Debugln("Not backfilling: no new messages") - return nil + if intent.IsCustomPuppet { + wrappedContent.Raw["net.maunium.whatsapp.puppet"] = intent.IsCustomPuppet } + newEventType, err := portal.encrypt(&wrappedContent, eventType) + if err != nil { + return nil, err + } + return &event.Event{ + Sender: intent.UserID, + Type: newEventType, + Timestamp: info.Timestamp.UnixMilli(), + Content: wrappedContent, + }, 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) - } +func (portal *Portal) appendBatchEvents(converted *ConvertedMessage, info *types.MessageInfo, eventsArray *[]*event.Event, infoArray *[]*types.MessageInfo) error { + mainEvt, err := portal.wrapBatchEvent(info, converted.Intent, converted.Type, converted.Content) + if err != nil { + return err + } + if converted.Caption != nil { + captionEvt, err := portal.wrapBatchEvent(info, converted.Intent, converted.Type, converted.Caption) 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() - } + *eventsArray = append(*eventsArray, mainEvt, captionEvt) + *infoArray = append(*infoArray, nil, info) + } else { + *eventsArray = append(*eventsArray, mainEvt) + *infoArray = append(*infoArray, info) } - portal.log.Infoln("Backfilling finished") return nil } -func (portal *Portal) beginBackfill() func() { +func (portal *Portal) finishBatch(eventIDs []id.EventID, infos []*types.MessageInfo) { + if len(eventIDs) != len(infos) { + portal.log.Errorfln("Length of event IDs (%d) and message infos (%d) doesn't match! Using slow path for mapping event IDs", len(eventIDs), len(infos)) + infoMap := make(map[types.MessageID]*types.MessageInfo, len(infos)) + for _, info := range infos { + infoMap[info.ID] = info + } + for _, eventID := range eventIDs { + if evt, err := portal.MainIntent().GetEvent(portal.MXID, eventID); err != nil { + portal.log.Warnfln("Failed to get event %s to register it in the database: %v", eventID, err) + } else if msgID, ok := evt.Content.Raw[backfillIDField].(string); !ok { + portal.log.Warnfln("Event %s doesn't include the WhatsApp message ID", eventID) + } else if info, ok := infoMap[types.MessageID(msgID)]; !ok { + portal.log.Warnfln("Didn't find info of message %s (event %s) to register it in the database", msgID, eventID) + } else { + portal.markHandled(nil, info, eventID, true, false, false) + } + } + } else { + for i := 0; i < len(infos); i++ { + if infos[i] != nil { + portal.markHandled(nil, infos[i], eventIDs[i], true, false, false) + } + } + portal.log.Infofln("Successfully sent %d events", len(eventIDs)) + } +} + +func (portal *Portal) backfill(source *User, messages []*waProto.HistorySyncMsg) { 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 + defer portal.backfillLock.Unlock() + + var historyBatch, newBatch mautrix.ReqBatchSend + var historyBatchInfos, newBatchInfos []*types.MessageInfo + + firstMsgTimestamp := time.Unix(int64(messages[len(messages)-1].GetMessage().GetMessageTimestamp()), 0) + + historyBatch.StateEventsAtStart = make([]*event.Event, 1) + newBatch.StateEventsAtStart = make([]*event.Event, 1) + + // TODO remove the dummy state events after https://github.com/matrix-org/synapse/pull/11188 + emptyStr := "" + dummyStateEvent := event.Event{ + Type: BackfillDummyStateEvent, + Sender: portal.MainIntent().UserID, + StateKey: &emptyStr, + Timestamp: firstMsgTimestamp.UnixMilli(), + Content: event.Content{}, + } + historyBatch.StateEventsAtStart[0] = &dummyStateEvent + newBatch.StateEventsAtStart[0] = &dummyStateEvent + + addedMembers := make(map[id.UserID]*event.MemberEventContent) + addMember := func(puppet *Puppet) { + if _, alreadyAdded := addedMembers[puppet.MXID]; alreadyAdded { + return + } + mxid := puppet.MXID.String() + content := event.MemberEventContent{ + Membership: event.MembershipJoin, + Displayname: puppet.Displayname, + AvatarURL: puppet.AvatarURL.CUString(), + } + inviteContent := content + inviteContent.Membership = event.MembershipInvite + historyBatch.StateEventsAtStart = append(historyBatch.StateEventsAtStart, &event.Event{ + Type: event.StateMember, + Sender: portal.MainIntent().UserID, + StateKey: &mxid, + Timestamp: firstMsgTimestamp.UnixMilli(), + Content: event.Content{Parsed: &inviteContent}, + }, &event.Event{ + Type: event.StateMember, + Sender: puppet.MXID, + StateKey: &mxid, + Timestamp: firstMsgTimestamp.UnixMilli(), + Content: event.Content{Parsed: &content}, + }) + addedMembers[puppet.MXID] = &content + } + + firstMessage := portal.bridge.DB.Message.GetFirstInChat(portal.Key) + lastMessage := portal.bridge.DB.Message.GetLastInChat(portal.Key) + var historyMaxTs, newMinTs time.Time + + if portal.FirstEventID != "" || portal.NextBatchID != "" { + historyBatch.PrevEventID = portal.FirstEventID + historyBatch.BatchID = portal.NextBatchID + if firstMessage == nil && lastMessage == nil { + historyMaxTs = time.Now() + } else { + historyMaxTs = firstMessage.Timestamp + } + } + if lastMessage != nil { + newBatch.PrevEventID = lastMessage.MXID + newMinTs = lastMessage.Timestamp + } + + portal.log.Infofln("Processing history sync with %d messages", len(messages)) + // The messages are ordered newest to oldest, so iterate them in reverse order. + for i := len(messages) - 1; i >= 0; i-- { + wrappedMsg := messages[i] + webMsg := wrappedMsg.GetMessage() + msgType := portal.getMessageType(webMsg.GetMessage()) + if msgType == "unknown" || msgType == "ignore" { + if msgType == "unknown" { + portal.log.Debugfln("Skipping message %s with unknown type in backfill", webMsg.GetKey().GetId()) } - privateChatPuppetInvited = true - _, _ = portal.MainIntent().InviteUser(portal.MXID, &mautrix.ReqInviteUser{UserID: privateChatPuppet.MXID}) - _ = privateChatPuppet.DefaultIntent().EnsureJoined(portal.MXID) + continue } - } - return func() { - portal.backfilling = false - portal.privateChatBackfillInvitePuppet = nil - portal.backfillLock.Unlock() - if privateChatPuppet != nil && privateChatPuppetInvited { - _, _ = privateChatPuppet.DefaultIntent().LeaveRoom(portal.MXID) + info := portal.parseWebMessageInfo(webMsg) + if info == nil { + continue } - } -} - -func (portal *Portal) disableNotifications(user *User) { - if !portal.bridge.Config.Bridge.HistoryDisableNotifs { - return - } - puppet := portal.bridge.GetPuppetByCustomMXID(user.MXID) - if puppet == nil || puppet.customIntent == nil { - return - } - portal.log.Debugfln("Disabling notifications for %s for backfilling", user.MXID) - ruleID := fmt.Sprintf("net.maunium.silence_while_backfilling.%s", portal.MXID) - err := puppet.customIntent.PutPushRule("global", pushrules.OverrideRule, ruleID, &mautrix.ReqPutPushRule{ - Actions: []pushrules.PushActionType{pushrules.ActionDontNotify}, - Conditions: []pushrules.PushCondition{{ - Kind: pushrules.KindEventMatch, - Key: "room_id", - Pattern: string(portal.MXID), - }}, - }) - if err != nil { - portal.log.Warnfln("Failed to disable notifications for %s while backfilling: %v", user.MXID, err) - } -} - -func (portal *Portal) enableNotifications(user *User) { - if !portal.bridge.Config.Bridge.HistoryDisableNotifs { - return - } - puppet := portal.bridge.GetPuppetByCustomMXID(user.MXID) - if puppet == nil || puppet.customIntent == nil { - return - } - portal.log.Debugfln("Re-enabling notifications for %s after backfilling", user.MXID) - ruleID := fmt.Sprintf("net.maunium.silence_while_backfilling.%s", portal.MXID) - err := puppet.customIntent.DeletePushRule("global", pushrules.OverrideRule, ruleID) - if err != nil { - portal.log.Warnfln("Failed to re-enable notifications for %s after backfilling: %v", user.MXID, err) - } -} - -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 + var batch *mautrix.ReqBatchSend + var infos *[]*types.MessageInfo + var history bool + if !historyMaxTs.IsZero() && info.Timestamp.Before(historyMaxTs) { + batch, infos, history = &historyBatch, &historyBatchInfos, true + } else if !newMinTs.IsZero() && info.Timestamp.After(newMinTs) { + batch, infos = &newBatch, &newBatchInfos + } else { + continue } - 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) + puppet := portal.getMessagePuppet(source, info) + var intent *appservice.IntentAPI + if portal.Key.JID == puppet.JID { + intent = puppet.DefaultIntent() + } else { + intent = puppet.IntentFor(portal) + if intent.IsCustomPuppet && !portal.bridge.Config.Bridge.HistorySync.DoublePuppetBackfill { + intent = puppet.DefaultIntent() + addMember(puppet) + } + } + converted := portal.convertMessage(intent, source, info, webMsg.GetMessage()) + if converted == nil { + portal.log.Debugfln("Skipping unsupported message %s in backfill", info.ID) + continue + } + if history && !portal.IsPrivateChat() && !portal.bridge.StateStore.IsInRoom(portal.MXID, puppet.MXID) { + addMember(puppet) + } + err := portal.appendBatchEvents(converted, info, &batch.Events, infos) 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.log.Errorfln("Error handling message %s during backfill: %v", info.ID, err) + } + } + + if len(historyBatch.Events) > 0 && len(historyBatch.PrevEventID) > 0 { + portal.log.Infofln("Sending %d historical messages...", len(historyBatch.Events)) + historyResp, err := portal.MainIntent().BatchSend(portal.MXID, &historyBatch) + if err != nil { + portal.log.Errorln("Error sending batch of historical messages:", err) + } else { + portal.finishBatch(historyResp.EventIDs, historyBatchInfos) + portal.NextBatchID = historyResp.NextBatchID + portal.Update() + // If batchID is non-empty, it means this is backfilling very old messages, and we don't need a post-backfill dummy. + if historyBatch.BatchID == "" { + portal.sendPostBackfillDummy(time.UnixMilli(historyBatch.Events[len(historyBatch.Events)-1].Timestamp)) + } + } + } + + if len(newBatch.Events) > 0 && len(newBatch.PrevEventID) > 0 { + portal.log.Debugln("Sending a dummy event to avoid forward extremity errors with forward backfill") + _, err := portal.MainIntent().SendMessageEvent(portal.MXID, ForwardBackfillDummyEvent, struct{}{}) + if err != nil { + portal.log.Warnln("Error sending dummy event for forward backfill:", err) + } + portal.log.Infofln("Sending %d new messages...", len(newBatch.Events)) + newResp, err := portal.MainIntent().BatchSend(portal.MXID, &newBatch) + if err != nil { + portal.log.Errorln("Error sending batch of new messages:", err) + } else { + portal.finishBatch(newResp.EventIDs, newBatchInfos) + portal.sendPostBackfillDummy(time.UnixMilli(newBatch.Events[len(newBatch.Events)-1].Timestamp)) } } - 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) sendPostBackfillDummy(lastTimestamp time.Time) { + resp, err := portal.MainIntent().SendMessageEvent(portal.MXID, BackfillEndDummyEvent, struct{}{}) + if err != nil { + portal.log.Errorln("Error sending post-backfill dummy event:", err) + return } + msg := portal.bridge.DB.Message.New() + msg.Chat = portal.Key + msg.MXID = resp.EventID + msg.JID = types.MessageID(resp.EventID) + msg.Timestamp = lastTimestamp.Add(1 * time.Second) + msg.Sent = true + msg.Insert() } type BridgeInfoSection struct { @@ -1062,7 +1166,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(), }, @@ -1088,6 +1192,11 @@ func (portal *Portal) UpdateBridgeInfo() { } } +var PortalCreationDummyEvent = event.Type{Type: "fi.mau.dummy.portal_created", Class: event.MessageEventType} +var BackfillDummyStateEvent = event.Type{Type: "fi.mau.dummy.blank_backfill_state", Class: event.StateEventType} +var BackfillEndDummyEvent = event.Type{Type: "fi.mau.dummy.backfill_end", Class: event.MessageEventType} +var ForwardBackfillDummyEvent = event.Type{Type: "fi.mau.dummy.pre_forward_backfill", Class: event.MessageEventType} + func (portal *Portal) CreateMatrixRoom(user *User) error { portal.roomCreateLock.Lock() defer portal.roomCreateLock.Unlock() @@ -1102,11 +1211,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 +1232,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, types.EmptyJID, false) } bridgeInfoStateKey, bridgeInfo := portal.getBridgeInfo() @@ -1174,9 +1285,6 @@ func (portal *Portal) CreateMatrixRoom(user *User) error { } var invite []id.UserID - if user.IsRelaybot { - invite = portal.bridge.Config.Bridge.Relaybot.InviteUsers - } if portal.bridge.Config.Bridge.Encryption.Default { initialState = append(initialState, &event.Event{ @@ -1215,20 +1323,22 @@ func (portal *Portal) CreateMatrixRoom(user *User) error { } portal.ensureUserInvited(user) + user.syncChatDoublePuppetDetails(portal, true) 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 portal.IsPrivateChat() && !user.IsRelaybot { + //if broadcastMetadata != nil { + // portal.SyncBroadcastRecipients(user, broadcastMetadata) + //} + if portal.IsPrivateChat() { 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,43 +1350,39 @@ 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}) - - err = portal.FillInitialHistory(user) + firstEventResp, err := portal.MainIntent().SendMessageEvent(portal.MXID, PortalCreationDummyEvent, struct{}{}) if err != nil { - portal.log.Errorln("Failed to fill history:", err) + portal.log.Errorln("Failed to send dummy event to mark portal creation:", err) + } else { + portal.FirstEventID = firstEventResp.EventID + portal.Update() } 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) - portal.hasRelaybot = &val + return portal.bridge.Config.Bridge.Relay.Enabled && len(portal.RelayUserID) > 0 +} + +func (portal *Portal) GetRelayUser() *User { + if !portal.HasRelaybot() { + return nil + } else if portal.relayUser == nil { + portal.relayUser = portal.bridge.GetUserByMXID(portal.RelayUserID) } - return *portal.hasRelaybot + return portal.relayUser } func (portal *Portal) MainIntent() *appservice.IntentAPI { @@ -1286,19 +1392,19 @@ 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 { portal.log.Warnln("Failed to get reply target:", err) return } + _ = evt.Content.ParseRaw(evt.Type) if evt.Type == event.EventEncrypted { - _ = evt.Content.ParseRaw(evt.Type) decryptedEvt, err := portal.bridge.Crypto.Decrypt(evt) if err != nil { portal.log.Warnln("Failed to decrypt reply target:", err) @@ -1306,69 +1412,76 @@ func (portal *Portal) SetReply(content *event.MessageEventContent, info whatsapp evt = decryptedEvt } } - _ = evt.Content.ParseRaw(evt.Type) content.SetReply(evt) } 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, info *types.MessageInfo, 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 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) - } - if intent == nil { - intent = portal.MainIntent() - } + intent := portal.bridge.GetPuppetByJID(info.Sender).IntentFor(portal) _, err := intent.RedactEvent(portal.MXID, msg.MXID) if err != nil { - portal.log.Errorln("Failed to redact %s: %v", msg.JID, err) + if errors.Is(err, mautrix.MForbidden) { + _, err = portal.MainIntent().RedactEvent(portal.MXID, msg.MXID) + if err != nil { + portal.log.Errorln("Failed to redact %s: %v", msg.JID, err) + } + } } else { msg.Delete() } 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) } +func (portal *Portal) encrypt(content *event.Content, eventType event.Type) (event.Type, error) { + if portal.Encrypted && portal.bridge.Crypto != nil { + // TODO maybe the locking should be inside mautrix-go? + portal.encryptLock.Lock() + encrypted, err := portal.bridge.Crypto.Encrypt(portal.MXID, eventType, *content) + portal.encryptLock.Unlock() + if err != nil { + return eventType, fmt.Errorf("failed to encrypt event: %w", err) + } + eventType = event.EventEncrypted + content.Parsed = encrypted + } + return eventType, nil +} + func (portal *Portal) sendMessage(intent *appservice.IntentAPI, eventType event.Type, content interface{}, timestamp int64) (*mautrix.RespSendEvent, error) { wrappedContent := event.Content{Parsed: content} if timestamp != 0 && intent.IsCustomPuppet { @@ -1376,16 +1489,10 @@ func (portal *Portal) sendMessage(intent *appservice.IntentAPI, eventType event. "net.maunium.whatsapp.puppet": intent.IsCustomPuppet, } } - if portal.Encrypted && portal.bridge.Crypto != nil { - // TODO maybe the locking should be inside mautrix-go? - portal.encryptLock.Lock() - encrypted, err := portal.bridge.Crypto.Encrypt(portal.MXID, eventType, wrappedContent) - portal.encryptLock.Unlock() - if err != nil { - return nil, fmt.Errorf("failed to encrypt event: %w", err) - } - eventType = event.EventEncrypted - wrappedContent.Parsed = encrypted + var err error + eventType, err = portal.encrypt(&wrappedContent, eventType) + if err != nil { + return nil, err } _, _ = intent.UserTyping(portal.MXID, false, 0) if timestamp == 0 { @@ -1395,119 +1502,116 @@ 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") - if intent == nil { - return false - } +type ConvertedMessage struct { + Intent *appservice.IntentAPI + Type event.Type + Content *event.MessageEventContent + Caption *event.MessageEventContent +} +func (portal *Portal) convertTextMessage(intent *appservice.IntentAPI, msg *waProto.Message) *ConvertedMessage { content := &event.MessageEventContent{ - Body: message.Text, + Body: msg.GetConversation(), MsgType: event.MsgText, } + if msg.GetExtendedTextMessage() != nil { + content.Body = msg.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.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)) - } - portal.markHandled(source, message.Info.Source, eventID, true) - return true + + return &ConvertedMessage{Intent: intent, Type: event.EventMessage, Content: content} } -func (portal *Portal) HandleLocationMessage(source *User, message whatsapp.LocationMessage) bool { - intent := portal.startHandling(source, message.Info, "location") - if intent == nil { - return false - } +//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 +//} - url := message.Url +func (portal *Portal) convertLocationMessage(intent *appservice.IntentAPI, msg *waProto.LocationMessage) *ConvertedMessage { + 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,32 +1621,21 @@ 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)) - 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 + return &ConvertedMessage{Intent: intent, Type: event.EventMessage, Content: content} } -func (portal *Portal) HandleContactMessage(source *User, message whatsapp.ContactMessage) bool { - intent := portal.startHandling(source, message.Info, "contact") - if intent == nil { - return false - } - - fileName := fmt.Sprintf("%s.vcf", message.DisplayName) - data := []byte(message.Vcard) +func (portal *Portal) convertContactMessage(intent *appservice.IntentAPI, msg *waProto.ContactMessage) *ConvertedMessage { + 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) - return true + portal.log.Errorfln("Failed to upload vcard of %s: %v", msg.GetDisplayName(), err) + return nil } content := &event.MessageEventContent{ @@ -1551,7 +1644,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,40 +1653,9 @@ 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)) - 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) sendMediaBridgeFailure(source *User, intent *appservice.IntentAPI, info whatsapp.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)) - if err != nil { - portal.log.Errorfln("Failed to send media download error message for %s: %v", info.Id, err) - } else { - portal.finishHandling(source, info.Source, resp.EventID) - } -} - -func (portal *Portal) encryptFile(data []byte, mimeType string) ([]byte, string, *event.EncryptedFileInfo) { - if !portal.Encrypted { - return data, mimeType, nil - } - - file := &event.EncryptedFileInfo{ - EncryptedFile: *attachment.NewEncryptedFile(), - URL: "", - } - return file.Encrypt(data), "application/octet-stream", file + return &ConvertedMessage{Intent: intent, Type: event.EventMessage, Content: content} } func (portal *Portal) tryKickUser(userID id.UserID, intent *appservice.IntentAPI) error { @@ -1613,11 +1675,11 @@ func (portal *Portal) removeUser(isSameUser bool, kicker *appservice.IntentAPI, if err != nil { portal.log.Warnfln("Failed to kick %s from %s: %v", target, portal.MXID, err) if targetIntent != nil { - _, _ = targetIntent.LeaveRoom(portal.MXID) + _, _ = portal.leaveWithPuppetMeta(targetIntent) } } } else { - _, err := targetIntent.LeaveRoom(portal.MXID) + _, err := portal.leaveWithPuppetMeta(targetIntent) if err != nil { portal.log.Warnfln("Failed to leave portal as %s: %v", target, err) _, _ = portal.MainIntent().KickUser(portal.MXID, &mautrix.ReqKickUser{UserID: target}) @@ -1625,11 +1687,11 @@ func (portal *Portal) removeUser(isSameUser bool, kicker *appservice.IntentAPI, } } -func (portal *Portal) HandleWhatsAppKick(source *User, senderJID string, jids []string) { +func (portal *Portal) HandleWhatsAppKick(source *User, senderJID types.JID, jids []types.JID) { sender := portal.bridge.GetPuppetByJID(senderJID) senderIntent := sender.IntentFor(portal) for _, jid := range jids { - if source != nil && source.JID == jid { + if source != nil && source.JID.User == jid.User { portal.log.Debugln("Ignoring self-kick by", source.MXID) continue } @@ -1649,17 +1711,27 @@ func (portal *Portal) HandleWhatsAppKick(source *User, senderJID string, jids [] } } -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) - } +func (portal *Portal) leaveWithPuppetMeta(intent *appservice.IntentAPI) (*mautrix.RespSendEvent, error) { + content := event.Content{ + Parsed: event.MemberEventContent{ + Membership: event.MembershipLeave, + }, + Raw: map[string]interface{}{ + "net.maunium.whatsapp.puppet": true, + }, + } + return intent.SendStateEvent(portal.MXID, event.StateMember, intent.UserID.String(), &content) +} + +func (portal *Portal) HandleWhatsAppInvite(source *User, senderJID *types.JID, jids []types.JID) (evtID id.EventID) { + intent := portal.MainIntent() + if senderJID != nil && !senderJID.IsEmpty() { + sender := portal.bridge.GetPuppetByJID(*senderJID) + intent = sender.IntentFor(portal) } for _, jid := range jids { puppet := portal.bridge.GetPuppetByJID(jid) - puppet.SyncContactIfNecessary(source) + puppet.SyncContact(source, true) content := event.Content{ Parsed: event.MemberEventContent{ Membership: "invite", @@ -1685,110 +1757,152 @@ func (portal *Portal) HandleWhatsAppInvite(source *User, senderJID string, inten return } -type base struct { - download func() ([]byte, error) - info whatsapp.MessageInfo - context whatsapp.ContextInfo - mimeType string +func (portal *Portal) makeMediaBridgeFailureMessage(intent *appservice.IntentAPI, info *types.MessageInfo, bridgeErr error, captionContent *event.MessageEventContent) *ConvertedMessage { + portal.log.Errorfln("Failed to bridge media for %s: %v", info.ID, bridgeErr) + return &ConvertedMessage{Intent: intent, Type: event.EventMessage, Content: &event.MessageEventContent{ + MsgType: event.MsgNotice, + Body: "Failed to bridge media", + }, Caption: captionContent} } -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)) - if intent == nil { - return false +func (portal *Portal) encryptFile(data []byte, mimeType string) ([]byte, string, *event.EncryptedFileInfo) { + if !portal.Encrypted { + return data, mimeType, nil } - 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 + file := &event.EncryptedFileInfo{ + EncryptedFile: *attachment.NewEncryptedFile(), + URL: "", + } + return file.Encrypt(data), "application/octet-stream", file +} + +type MediaMessage interface { + whatsmeow.DownloadableMessage + GetContextInfo() *waProto.ContextInfo + GetMimetype() string +} + +type MediaMessageWithThumbnail interface { + MediaMessage + GetJpegThumbnail() []byte + GetCaption() string +} + +type MediaMessageWithCaption interface { + MediaMessage + GetCaption() string +} + +type MediaMessageWithFileName interface { + MediaMessage + GetFileName() string +} + +type MediaMessageWithDuration interface { + MediaMessage + GetSeconds() uint32 +} + +func (portal *Portal) convertMediaMessage(intent *appservice.IntentAPI, source *User, info *types.MessageInfo, msg MediaMessage) *ConvertedMessage { + messageWithCaption, ok := msg.(MediaMessageWithCaption) + var captionContent *event.MessageEventContent + if ok && len(messageWithCaption.GetCaption()) > 0 { + captionContent = &event.MessageEventContent{ + Body: messageWithCaption.GetCaption(), + MsgType: event.MsgNotice, } - data, err = msg.download() + + portal.bridge.Formatter.ParseWhatsApp(captionContent, msg.GetContextInfo().GetMentionedJid()) } - if errors.Is(err, whatsapp.ErrNoURLPresent) { - portal.log.Debugfln("No URL present error for media message %s, ignoring...", msg.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) + + 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 nil } else if err != nil { - portal.sendMediaBridgeFailure(source, intent, msg.info, err) - return true + return portal.makeMediaBridgeFailureMessage(intent, info, err, captionContent) } 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")) + return portal.makeMediaBridgeFailureMessage(intent, info, errors.New("homeserver rejected too large file"), captionContent) } else if httpErr, ok := err.(mautrix.HTTPError); ok && httpErr.IsStatus(413) { - portal.sendMediaBridgeFailure(source, intent, msg.info, errors.New("proxy rejected too large file")) + return portal.makeMediaBridgeFailureMessage(intent, info, errors.New("proxy rejected too large file"), captionContent) } else { - portal.sendMediaBridgeFailure(source, intent, msg.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] + return portal.makeMediaBridgeFailureMessage(intent, info, fmt.Errorf("failed to upload media: %w", err), captionContent) } } 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) + messageWithThumbnail, ok := msg.(MediaMessageWithThumbnail) + if ok && messageWithThumbnail.GetJpegThumbnail() != nil && portal.bridge.Config.Bridge.WhatsappThumbnail { + thumbnailData := messageWithThumbnail.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 +1919,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,40 +1933,17 @@ func (portal *Portal) HandleMediaMessage(source *User, msg mediaMessage) bool { content.MsgType = event.MsgFile } - ts := int64(msg.info.Timestamp * 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) - return true + + return &ConvertedMessage{ + Intent: intent, + Type: eventType, + Content: content, + Caption: captionContent, } - - if len(msg.caption) > 0 { - captionContent := &event.MessageEventContent{ - Body: msg.caption, - MsgType: event.MsgNotice, - } - - portal.bridge.Formatter.ParseWhatsApp(captionContent, msg.context.MentionedJID) - - 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.finishHandling(source, msg.info.Source, 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 { @@ -1951,9 +2043,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 +2073,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 +2081,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 +2089,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 +2137,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 } @@ -2065,7 +2152,7 @@ func (portal *Portal) addRelaybotFormat(sender *User, content *event.MessageEven content.FormattedBody = strings.Replace(html.EscapeString(content.Body), "\n", "
", -1) content.Format = event.FormatHTML } - data, err := portal.bridge.Config.Bridge.Relaybot.FormatMessage(content, sender.MXID, member) + data, err := portal.bridge.Config.Bridge.Relay.FormatMessage(content, sender.MXID, member) if err != nil { portal.log.Errorln("Failed to apply relaybot format:", err) } @@ -2109,35 +2196,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 @@ -2145,18 +2219,13 @@ func (portal *Portal) convertMatrixMessage(sender *User, evt *event.Event) (*waP } } relaybotFormatted := false - if sender.NeedsRelaybot(portal) { + if !sender.HasSession() { if !portal.HasRelaybot() { - if sender.HasSession() { - portal.log.Debugln("Database says", sender.MXID, "not in chat and no relaybot, but trying to send anyway") - } else { - portal.log.Debugln("Ignoring message from", sender.MXID, "in chat with no relaybot") - return nil, sender - } - } else { - relaybotFormatted = portal.addRelaybotFormat(sender, content) - sender = portal.bridge.Relaybot + portal.log.Debugln("Ignoring message from", sender.MXID, "in chat with no relaybot") + return nil, sender } + relaybotFormatted = portal.addRelaybotFormat(sender, content) + sender = portal.GetRelayUser() } if evt.Type == event.EventSticker { content.MsgType = event.MsgImage @@ -2178,21 +2247,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 +2269,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 +2290,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 +2329,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 +2337,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,120 +2375,64 @@ 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 } - 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 - } + info := portal.generateMessageInfo(sender) + dbMsg := portal.markHandled(nil, info, evt.ID, false, true, false) + 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 { + portal.log.Debugfln("Ignoring redaction %s of unknown event by %s", msg, sender.MXID) + return + } else if msg.Sender.User != sender.JID.User { + portal.log.Debugfln("Ignoring redaction %s of %s/%s by %s: message was sent by someone else (%s, not %s)", evt.ID, msg.MXID, msg.JID, sender.MXID, msg.Sender, sender.JID) 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, - }, - }, - }, - 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 - } + portal.log.Debugfln("Sending redaction %s of %s/%s to WhatsApp", evt.ID, msg.MXID, msg.JID) + err := sender.Client.RevokeMessage(portal.Key.JID, msg.JID) if err != nil { portal.log.Errorfln("Error handling Matrix redaction %s: %v", evt.ID, err) } else { - portal.log.Debugln("Handled Matrix redaction %s of %s", evt.ID, evt.Redacts) + portal.log.Debugfln("Handled Matrix redaction %s of %s", evt.ID, evt.Redacts) portal.sendDeliveryReceipt(evt.ID) } } @@ -2523,12 +2524,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 +2538,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..7e1e996 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 @@ -29,7 +29,7 @@ import ( "github.com/gorilla/websocket" - "github.com/Rhymen/go-whatsapp" + "go.mau.fi/whatsmeow" log "maunium.net/go/maulogger/v2" @@ -50,11 +50,13 @@ func (prov *ProvisioningAPI) Init() { r.HandleFunc("/login", prov.Login).Methods(http.MethodGet) r.HandleFunc("/logout", prov.Logout).Methods(http.MethodPost) r.HandleFunc("/delete_session", prov.DeleteSession).Methods(http.MethodPost) - r.HandleFunc("/delete_connection", prov.DeleteConnection).Methods(http.MethodPost) r.HandleFunc("/disconnect", prov.Disconnect).Methods(http.MethodPost) r.HandleFunc("/reconnect", prov.Reconnect).Methods(http.MethodPost) prov.bridge.AS.Router.HandleFunc("/_matrix/app/com.beeper.asmux/ping", prov.BridgeStatePing).Methods(http.MethodPost) prov.bridge.AS.Router.HandleFunc("/_matrix/app/com.beeper.bridge_state", prov.BridgeStatePing).Methods(http.MethodPost) + + // Deprecated, just use /disconnect + r.HandleFunc("/delete_connection", prov.Disconnect).Methods(http.MethodPost) } type responseWrap struct { @@ -122,7 +124,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,128 +132,43 @@ 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 { - jsonResponse(w, http.StatusNotFound, Error{ - Error: "You don't have a WhatsApp connection.", - ErrCode: "not connected", - }) - return - } - user.DeleteConnection() - jsonResponse(w, http.StatusOK, Response{true, "Disconnected from WhatsApp and connection deleted"}) + user.removeFromJIDMap(StateLoggedOut) } 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"}) + user.sendBridgeState(BridgeState{StateEvent: StateBadCredentials, Error: WANotConnected}) } 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.", ErrCode: "no session", }) } else { - user.Connect(false) - jsonResponse(w, http.StatusOK, Response{true, "Created connection to WhatsApp."}) + user.Connect() + jsonResponse(w, http.StatusAccepted, Response{true, "Created connection to WhatsApp."}) } - 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." + user.DeleteConnection() + user.sendBridgeState(BridgeState{StateEvent: StateTransientDisconnect, Error: WANotConnected}) + user.Connect() + jsonResponse(w, http.StatusAccepted, Response{true, "Restarted connection to WhatsApp"}) } - - jsonResponse(w, http.StatusOK, Response{true, msg}) } func (prov *ProvisioningAPI) Ping(w http.ResponseWriter, r *http.Request) { @@ -259,39 +176,23 @@ 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, - "admin": user.Admin, - "whitelisted": user.Whitelisted, - "relaybot_whitelisted": user.RelaybotWhitelisted, - "whatsapp": wa, + "mxid": user.MXID, + "admin": user.Admin, + "whitelisted": user.Whitelisted, + "relay_whitelisted": user.RelayWhitelisted, + "whatsapp": wa, } jsonResponse(w, http.StatusOK, resp) } @@ -314,7 +215,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,7 +223,7 @@ func (prov *ProvisioningAPI) Logout(w http.ResponseWriter, r *http.Request) { }) } } else { - err := user.Conn.Logout() + err := user.Client.Logout() if err != nil { user.log.Warnln("Error while logging out:", err) if !force { @@ -332,16 +233,15 @@ func (prov *ProvisioningAPI) Logout(w http.ResponseWriter, r *http.Request) { }) return } + } else { + user.Session = nil } 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."}) } @@ -361,26 +261,10 @@ func (prov *ProvisioningAPI) Login(w http.ResponseWriter, r *http.Request) { 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, - }) + defer func() { + err := c.Close() + if err != nil { + user.log.Debugln("Error closing websocket:", err) } }() @@ -400,40 +284,63 @@ func (prov *ProvisioningAPI) Login(w http.ResponseWriter, r *http.Request) { return nil }) - user.log.Debugln("Starting login via provisioning API") - session, jid, err := user.Conn.Login(qrChan, ctx) - qrChan <- "stop" + qrChan, err := user.Login(ctx) 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." + user.log.Errorf("Failed to log in from provisioning API:", err) + if errors.Is(err, ErrAlreadyLoggedIn) { + go user.Connect() + _ = c.WriteJSON(Error{ + Error: "You're already logged into WhatsApp", + ErrCode: "already logged in", + }) } else { - msg = fmt.Sprintf("Unknown error while logging in: %v", err) + _ = c.WriteJSON(Error{ + Error: "Failed to connect to WhatsApp", + ErrCode: "connection error", + }) + } + } + user.log.Debugln("Started login via provisioning API") + + for { + select { + case evt := <-qrChan: + switch evt { + case whatsmeow.QRChannelSuccess: + jid := user.Client.Store.ID + user.log.Debugln("Successful login as", jid, "via provisioning API") + _ = c.WriteJSON(map[string]interface{}{ + "success": true, + "jid": jid, + "phone": fmt.Sprintf("+%s", jid.User), + }) + case whatsmeow.QRChannelTimeout: + user.log.Debugln("Login via provisioning API timed out") + _ = c.WriteJSON(Error{ + Error: "QR code scan timed out. Please try again.", + ErrCode: "login timed out", + }) + case whatsmeow.QRChannelErrUnexpectedEvent: + user.log.Debugln("Login via provisioning API failed due to unexpected event") + _ = c.WriteJSON(Error{ + Error: "Got unexpected event while waiting for QRs, perhaps you're already logged in?", + ErrCode: "unexpected event", + }) + case whatsmeow.QRChannelScannedWithoutMultidevice: + _ = c.WriteJSON(Error{ + Error: "Please enable the WhatsApp multidevice beta and scan the QR code again.", + ErrCode: "multidevice not enabled", + }) + continue + default: + _ = c.WriteJSON(map[string]interface{}{ + "code": string(evt), + }) + continue + } + return + case <-ctx.Done(): + return } - 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..4f82ed6 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,15 +17,19 @@ package main import ( + "errors" "fmt" + "io" "net/http" "regexp" - "strings" "sync" + "time" - "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" "maunium.net/go/mautrix/id" @@ -34,19 +38,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 +61,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 +132,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) } @@ -149,7 +155,7 @@ type Puppet struct { log log.Logger typingIn id.RoomID - typingAt int64 + typingAt time.Time MXID id.UserID @@ -160,14 +166,8 @@ 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) || - portal.Key.JID == puppet.JID { + if (!portal.IsPrivateChat() && puppet.customIntent == nil) || portal.Key.JID == puppet.JID { return puppet.DefaultIntent() } return puppet.customIntent @@ -181,63 +181,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 +289,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 +311,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..7778eab 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 @@ -22,21 +22,21 @@ import ( "errors" "fmt" "net/http" - "sort" - "strings" "sync" - "sync/atomic" "time" - "github.com/skip2/go-qrcode" log "maunium.net/go/maulogger/v2" + "go.mau.fi/whatsmeow/appstate" + waProto "go.mau.fi/whatsmeow/binary/proto" "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,38 +48,20 @@ import ( type User struct { *database.User - Conn *whatsapp.Conn + Client *whatsmeow.Client + Session *store.Device bridge *Bridge log log.Logger - Admin bool - Whitelisted bool - RelaybotWhitelisted bool + Admin bool + Whitelisted bool + RelayWhitelisted bool - IsRelaybot bool + mgmtCreateLock sync.Mutex + connLock sync.Mutex - ConnectionErrors int - CommunityID string - - 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() + historySyncs chan *events.HistorySync 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 load 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 @@ -160,41 +152,25 @@ func (bridge *Bridge) loadDBUser(dbUser *database.User, mxid *id.UserID) *User { return 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 -} - func (bridge *Bridge) NewUser(dbUser *database.User) *User { user := &User{ User: dbUser, bridge: bridge, 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), + historySyncs: make(chan *events.HistorySync, 32), } - user.RelaybotWhitelisted = user.bridge.Config.Bridge.Permissions.IsRelaybotWhitelisted(user.MXID) + user.RelayWhitelisted = user.bridge.Config.Bridge.Permissions.IsRelayWhitelisted(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() + go func() { + for evt := range user.historySyncs { + if evt == nil { + return + } + user.handleHistorySync(evt.Data) + } + }() return user } @@ -230,255 +206,99 @@ 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 +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)} } + +var ErrAlreadyLoggedIn = errors.New("already logged in") + +func (user *User) Login(ctx context.Context) (<-chan whatsmeow.QRChannelItem, error) { + user.connLock.Lock() + defer user.connLock.Unlock() + if user.Session != nil { + return nil, ErrAlreadyLoggedIn + } else if user.Client != nil { + user.unlockedDeleteConnection() } - user.Update() + newSession := user.bridge.WAContainer.NewDevice() + newSession.Log = &waLogger{user.log.Sub("Session")} + user.Client = whatsmeow.NewClient(newSession, &waLogger{user.log.Sub("Client")}) + user.Client.AddEventHandler(user.HandleEvent) + qrChan, err := user.Client.GetQRChannel(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get QR channel: %w", err) + } + err = user.Client.Connect() + if err != nil { + return nil, fmt.Errorf("failed to connect to WhatsApp: %w", err) + } + return qrChan, nil } -func (user *User) Connect(evenIfNoSession bool) bool { +func (user *User) Connect() bool { user.connLock.Lock() - if user.Conn != nil { - user.connLock.Unlock() - if user.Conn.IsConnected() { - return true - } else { - return user.RestoreSession() - } - } else if !evenIfNoSession && user.Session == nil { - user.connLock.Unlock() + defer user.connLock.Unlock() + if user.Client != nil { + return user.Client.IsConnected() + } else if user.Session == nil { return false } user.log.Debugln("Connecting to WhatsApp") - if user.Session != nil { - user.sendBridgeState(BridgeState{StateEvent: StateConnecting, Error: WAConnecting}) + user.sendBridgeState(BridgeState{StateEvent: StateConnecting, Error: WAConnecting}) + user.Client = whatsmeow.NewClient(user.Session, &waLogger{user.log.Sub("Client")}) + user.Client.AddEventHandler(user.HandleEvent) + err := user.Client.Connect() + if err != nil { + user.log.Warnln("Error connecting to WhatsApp:", err) + return false } - timeout := time.Duration(user.bridge.Config.Bridge.ConnectionTimeout) - if timeout == 0 { - timeout = 20 + return true +} + +func (user *User) unlockedDeleteConnection() { + if user.Client == nil { + return } - 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.Disconnect() + user.Client.RemoveEventHandlers() + user.Client = nil + user.bridge.Metrics.TrackConnectionState(user.JID, false) } func (user *User) DeleteConnection() { user.connLock.Lock() - if user.Conn == nil { - user.connLock.Unlock() - 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.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 + defer user.connLock.Unlock() + user.unlockedDeleteConnection() } func (user *User) HasSession() bool { return user.Session != nil } +func (user *User) DeleteSession() { + if user.Session != nil { + err := user.Session.Delete() + if err != nil { + user.log.Warnln("Failed to delete session:", err) + } + user.Session = nil + } + if !user.JID.IsEmpty() { + user.JID = types.EmptyJID + user.Update() + } +} + func (user *User) IsConnected() bool { - return user.Conn != nil && user.Conn.IsConnected() && user.Conn.IsLoggedIn() + return user.Client != nil && user.Client.IsConnected() } -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) - 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) - } - } - } -} - -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) PostLogin() { - user.sendBridgeState(BridgeState{StateEvent: StateBackfilling}) - 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() +func (user *User) IsLoggedIn() bool { + return user.IsConnected() && user.Client.IsLoggedIn } func (user *User) tryAutomaticDoublePuppeting() { @@ -522,172 +342,141 @@ 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") +func (user *User) handleHistorySync(evt *waProto.HistorySync) { + if evt.GetSyncType() != waProto.HistorySync_RECENT && evt.GetSyncType() != waProto.HistorySync_FULL { + return + } + user.log.Infofln("Handling history sync with type %s, chunk order %d, progress %d%%", evt.GetSyncType(), evt.GetChunkOrder(), evt.GetProgress()) + for _, conv := range evt.GetConversations() { + jid, err := types.ParseJID(conv.GetId()) + if err != nil { + user.log.Warnfln("Failed to parse chat JID '%s' in history sync: %v", conv.GetId(), err) + continue + } + + muteEnd := time.Unix(int64(conv.GetMuteEndTime()), 0) + if muteEnd.After(time.Now()) { + _ = user.Client.Store.ChatSettings.PutMutedUntil(jid, muteEnd) + } + if conv.GetArchived() { + _ = user.Client.Store.ChatSettings.PutArchived(jid, true) + } + if conv.GetPinned() > 0 { + _ = user.Client.Store.ChatSettings.PutPinned(jid, true) + } + + portal := user.GetPortalByJID(jid) + if user.bridge.Config.Bridge.HistorySync.CreatePortals && len(portal.MXID) == 0 { + user.log.Debugln("Creating portal for", portal.Key.JID, "as part of history sync handling") + err = portal.CreateMatrixRoom(user) + if err != nil { + user.log.Warnfln("Failed to create room for %s during backfill: %v", portal.Key.JID, err) + continue + } + } + if len(portal.MXID) == 0 { + user.log.Debugln("No room created, not bridging history sync payload for", portal.Key.JID) + } else if !user.bridge.Config.Bridge.HistorySync.Backfill { + user.log.Debugln("Backfill is disabled, not bridging history sync payload for", portal.Key.JID) } else { - break + user.log.Debugln("Bridging history sync payload for", portal.Key.JID) + portal.backfill(user, conv.GetMessages()) + if !conv.GetMarkedAsUnread() && conv.GetUnreadCount() == 0 { + user.markSelfReadFull(portal) + } } } - 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: - portal := user.bridge.GetPortalByJID(user.PortalKey(v.JID)) - if portal != nil { - go user.updateChatMute(nil, portal, v.MutedUntil) + case *events.LoggedOut: + go user.handleLoggedOut(v.OnConnect) + 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) + } + }() + go user.tryAutomaticDoublePuppeting() + case *events.AppStateSyncComplete: + if len(user.Client.Store.PushName) > 0 && v.Name == appstate.WAPatchCriticalBlock { + err := user.Client.SendPresence(types.PresenceUnavailable) + if err != nil { + user.log.Warnln("Failed to send presence after app state sync:", err) + } } - case whatsapp.ArchiveMessage: - portal := user.bridge.GetPortalByJID(user.PortalKey(v.JID)) - if portal != nil { - go user.updateChatTag(nil, portal, user.bridge.Config.Bridge.ArchiveTag, v.IsArchived) + case *events.PushNameSetting: + // Send presence available when connecting and when the pushname is changed. + // This makes sure that outgoing messages always have the right pushname. + err := user.Client.SendPresence(types.PresenceUnavailable) + if err != nil { + user.log.Warnln("Failed to send presence after push name update:", err) } - case whatsapp.PinMessage: - portal := user.bridge.GetPortalByJID(user.PortalKey(v.JID)) + case *events.PairSuccess: + user.Session = user.Client.Store + user.JID = v.ID + user.addToJIDMap() + user.Update() + 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.GroupInfo: + go user.handleGroupUpdate(v) + case *events.Picture: + go user.handlePictureUpdate(v) + case *events.Receipt: + go user.handleReceipt(v) + case *events.ChatPresence: + go user.handleChatPresence(v) + case *events.Message: + portal := user.GetPortalByJID(v.Info.Chat) + portal.messages <- PortalMessage{evt: v, source: user} + case *events.UndecryptableMessage: + portal := user.GetPortalByJID(v.Info.Chat) + portal.messages <- PortalMessage{undecryptable: v, source: user} + case *events.HistorySync: + user.historySyncs <- v + case *events.Mute: + portal := user.GetPortalByJID(v.JID) if portal != nil { - go user.updateChatTag(nil, portal, user.bridge.Config.Bridge.PinnedTag, v.IsPinned) + var mutedUntil time.Time + if v.Action.GetMuted() { + mutedUntil = time.Unix(v.Action.GetMuteEndTimestamp(), 0) + } + go user.updateChatMute(nil, portal, mutedUntil) } - 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) + case *events.Archive: + portal := user.GetPortalByJID(v.JID) + if portal != nil { + go user.updateChatTag(nil, portal, user.bridge.Config.Bridge.ArchiveTag, v.Action.GetArchived()) + } + case *events.Pin: + portal := user.GetPortalByJID(v.JID) + if portal != nil { + go user.updateChatTag(nil, portal, user.bridge.Config.Bridge.PinnedTag, v.Action.GetPinned()) + } + case *events.AppState: + // Ignore 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 +487,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 +546,30 @@ 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(portal *Portal, justCreated bool) { + doublePuppet := portal.bridge.GetPuppetByCustomMXID(user.MXID) + if doublePuppet == nil { 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) + if doublePuppet == nil || doublePuppet.CustomIntent() == nil || len(portal.MXID) == 0 { + return } 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) - } -} - -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: + intent := doublePuppet.CustomIntent() + 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) 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,372 +625,90 @@ 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) 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) +func (user *User) handleLoggedOut(onConnect bool) { + user.JID = types.EmptyJID + user.Update() + if onConnect { + user.sendMarkdownBridgeAlert("Connecting to WhatsApp failed as the device was logged out. Please link the bridge to your phone again.") } else { - user.sendMarkdownBridgeAlert("\u26a0 %s. Additionally, %d reconnection attempts failed. Use the `reconnect` command to try to reconnect.", msg, tries) + user.sendMarkdownBridgeAlert("You were logged out from another device. Please link the bridge to your phone again.") } + user.sendBridgeState(BridgeState{StateEvent: StateBadCredentials, Error: WANotLoggedIn}) } -func (user *User) PortalKey(jid whatsapp.JID) database.PortalKey { - return database.NewPortalKey(jid, user.JID) +func (user *User) GetPortalByJID(jid types.JID) *Portal { + return user.bridge.GetPortalByJID(database.NewPortalKey(jid, user.JID)) } -func (user *User) GetPortalByJID(jid whatsapp.JID) *Portal { - return user.bridge.GetPortalByJID(user.PortalKey(jid)) +func (user *User) syncPuppet(jid types.JID) { + user.bridge.GetPuppetByJID(jid).SyncContact(user, false) } -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 - } - } -} +const WATypingTimeout = 15 * time.Second -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) 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) handleChatPresence(presence *events.ChatPresence) { + puppet := user.bridge.GetPuppetByJID(presence.Sender) + portal := user.GetPortalByJID(presence.Chat) + if puppet == nil || portal == nil || len(portal.MXID) == 0 { 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: - 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 presence.State == types.ChatPresenceComposing { + if puppet.typingIn != "" && puppet.typingAt.Add(WATypingTimeout).Before(time.Now()) { 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) + _, _ = puppet.IntentFor(portal).UserTyping(portal.MXID, true, WATypingTimeout.Milliseconds()) + puppet.typingIn = portal.MXID + puppet.typingAt = time.Now() } else { - user.log.Debugfln("Unknown received message type: %+v", received) + _, _ = puppet.IntentFor(portal).UserTyping(portal.MXID, false, 0) + puppet.typingIn = "" } } -func (user *User) HandleReadMessage(read whatsapp.ReadMessage) { - user.log.Debugfln("Received chat read message: %+v", read) - go user.markSelfRead(read.Jid, "") +func (user *User) handleReceipt(receipt *events.Receipt) { + if receipt.Type != events.ReceiptTypeRead { + return + } + portal := user.GetPortalByJID(receipt.Chat) + if portal == nil || len(portal.MXID) == 0 { + return + } + if receipt.IsFromMe { + user.markSelfRead(portal, receipt.MessageID) + } else { + 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) markSelfRead(jid, messageID string) { - if strings.HasSuffix(jid, whatsapp.OldUserSuffix) { - jid = strings.Replace(jid, whatsapp.OldUserSuffix, whatsapp.NewUserSuffix, -1) +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 } - puppet := user.bridge.GetPuppetByJID(user.JID) - if puppet == nil { - return + + 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) } - intent := puppet.CustomIntent() - if intent == nil { - return - } - portal := user.GetPortalByJID(jid) - if portal == nil { + return true +} + +func (user *User) markSelfRead(portal *Portal, messageID types.MessageID) { + puppet := user.bridge.GetPuppetByCustomMXID(user.MXID) + if puppet == nil || puppet.CustomIntent() == nil { return } var message *database.Message @@ -1314,120 +725,66 @@ func (user *User) markSelfRead(jid, messageID string) { } user.log.Debugfln("User read message %s/%s in %s/%s in WhatsApp mobile", message.JID, message.MXID, portal.Key.JID, portal.MXID) } - err := intent.MarkReadWithContent(portal.MXID, message.MXID, &CustomReadReceipt{DoublePuppet: true}) + err := puppet.CustomIntent().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) HandleChatUpdate(cmd whatsapp.ChatUpdate) { - if cmd.Command != whatsapp.ChatUpdateCommandAction { +func (user *User) markSelfReadFull(portal *Portal) { + puppet := user.bridge.GetPuppetByCustomMXID(user.MXID) + if puppet == nil || puppet.CustomIntent() == nil { 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) - } - }() - } + lastMessage := user.bridge.DB.Message.GetLastInChat(portal.Key) + if lastMessage == nil { 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 - } - } - } + err := puppet.CustomIntent().MarkReadWithContent(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, portal.MXID, err) } +} - if !user.bridge.Config.Bridge.ChatMetaSync { - // Ignore chat update commands, we're relying on the message history. +func (user *User) handleGroupUpdate(evt *events.GroupInfo) { + portal := user.GetPortalByJID(evt.JID) + if portal == nil || len(portal.MXID) == 0 { + // TODO create portal when added to group + user.log.Debugfln("Ignoring group info update in chat with no portal: %+v", evt) return } + switch { + case evt.Announce != nil: + portal.RestrictMessageSending(evt.Announce.IsAnnounce) + case evt.Locked != nil: + portal.RestrictMetadataChanges(evt.Locked.IsLocked) + case evt.Name != nil: + portal.UpdateName(evt.Name.Name, evt.Name.NameSetBy, true) + case evt.Topic != nil: + portal.UpdateTopic(evt.Topic.Topic, evt.Topic.TopicSetBy, true) + case evt.Leave != nil: + if evt.Sender != nil && !evt.Sender.IsEmpty() { + portal.HandleWhatsAppKick(user, *evt.Sender, evt.Leave) + } + case evt.Join != nil: + portal.HandleWhatsAppInvite(user, evt.Sender, evt.Join) + case evt.Promote != nil: + portal.ChangeAdminStatus(evt.Promote, true) + case evt.Demote != nil: + portal.ChangeAdminStatus(evt.Demote, false) + } +} - 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) handlePictureUpdate(evt *events.Picture) { + if evt.JID.Server == types.DefaultUserServer { + puppet := user.bridge.GetPuppetByJID(evt.JID) + if puppet.Avatar != evt.PictureID { + puppet.UpdateAvatar(user) + } + } else { + portal := user.GetPortalByJID(evt.JID) + if portal != nil && portal.Avatar != evt.PictureID { + portal.UpdateAvatar(user, evt.Author, true) } } } - -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) NeedsRelaybot(portal *Portal) bool { - return !user.HasSession() || !user.IsInPortal(portal.Key) -}