Răsfoiți Sursa

Merge branch 'multidevice'

Fixes #330
Tulir Asokan 3 ani în urmă
părinte
comite
b80e0c8db5

+ 1 - 1
.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/*
 

+ 4 - 5
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))

+ 10 - 14
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<sup>[1]</sup>
-    * [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<sup>[2]</sup>
+    * [ ] 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<sup>[3]</sup>
+  * [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
-
-<sup>[1]</sup> May involve reverse-engineering the WhatsApp Web API and/or editing go-whatsapp  
-<sup>[2]</sup> May already work  
-<sup>[3]</sup> May not be possible  

+ 6 - 71
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

+ 377 - 355
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 <command>`")
-				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 <invite link> - 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] <power level> - 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 <contacts|groups> [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 <group JID>`")
 		return
 	}
-
-	user := ce.User
-	jid := ce.Args[0]
-
-	if strings.HasSuffix(jid, whatsapp.NewUserSuffix) {
-		ce.Reply("That looks like a user JID. Did you mean `pm %s`?", jid[:len(jid)-len(whatsapp.NewUserSuffix)])
-		return
-	}
-
-	user.Conn.Store.ContactsLock.RLock()
-	contact, ok := user.Conn.Store.Contacts[jid]
-	user.Conn.Store.ContactsLock.RUnlock()
-	if !ok {
-		ce.Reply("Group JID not found in contacts. Try syncing contacts with `sync` first.")
-		return
-	}
-	handler.log.Debugln("Importing", jid, "for", user)
-	portal := user.bridge.GetPortalByJID(database.GroupPortalKey(jid))
-	if len(portal.MXID) > 0 {
-		portal.Sync(user, contact)
-		ce.Reply("Portal room synced.")
-	} else {
-		portal.Sync(user, contact)
-		ce.Reply("Portal room created.")
-	}
-	_, _ = portal.MainIntent().InviteUser(portal.MXID, &mautrix.ReqInviteUser{UserID: user.MXID})
-}
-
-const cmdPMHelp = `pm [--force] <_international phone number_> - Open a private chat with the given phone number.`
+	ce.Reply("Not yet implemented")
+
+	// 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 <_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] <international phone number>`")
+		ce.Reply("**Usage:** `pm <international phone number>`")
 		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 <number>`.")
-			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

+ 0 - 132
community.go

@@ -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 <https://www.gnu.org/licenses/>.
-
-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
-}

+ 60 - 102
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:"-"`
 }

+ 3 - 1
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 <https://www.gnu.org/licenses/>.
 
+//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"}
 		}

+ 10 - 10
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)
 		}

+ 1 - 0
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 <https://www.gnu.org/licenses/>.
 
+//go:build cgo && !nocrypto
 // +build cgo,!nocrypto
 
 package database

+ 6 - 1
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

+ 66 - 26
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 {

+ 40 - 54
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
-}

+ 20 - 15
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)
 	}

+ 13 - 0
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))
+	}}
+}

+ 93 - 0
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
+	}}
+}

+ 19 - 0
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
+	}}
+}

+ 12 - 0
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
+	}}
+}

+ 12 - 0
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
+	}}
+}

+ 1 - 1
database/upgrades/upgrades.go

@@ -39,7 +39,7 @@ type upgrade struct {
 	fn      upgradeFunc
 }
 
-const NumberOfUpgrades = 24
+const NumberOfUpgrades = 29
 
 var upgrades [NumberOfUpgrades]upgrade
 

+ 119 - 157
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
+func (user *User) usernamePtr() *string {
+	if !user.JID.IsEmpty() {
+		return &user.JID.User
 	}
-
-	index := strings.IndexRune(jid, '@')
-	if index < 0 {
-		return jid
-	}
-
-	return jid[:index]
+	return nil
 }
 
-func (user *User) jidPtr() *string {
-	if len(user.JID) > 0 {
-		str := stripSuffix(user.JID)
-		return &str
+func (user *User) agentPtr() *uint8 {
+	if !user.JID.IsEmpty() {
+		return &user.JID.Agent
 	}
 	return nil
 }
 
-func (user *User) sessionUnptr() (sess whatsapp.Session) {
-	if user.Session != nil {
-		sess = *user.Session
+func (user *User) devicePtr() *uint8 {
+	if !user.JID.IsEmpty() {
+		return &user.JID.Device
 	}
-	return
+	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)
+//	}
+//}

+ 32 - 83
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 <command>` instead of `!wa relaybot <command>`. 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: "<b>{{ .Sender.Displayname }}</b>: {{ .Message }}"

+ 27 - 32
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<em>$2</em>$3",
@@ -99,12 +89,11 @@ func NewFormatter(bridge *Bridge) *Formatter {
 			return fmt.Sprintf("<code>%s</code>", 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(`<a href="https://matrix.to/#/%s">%s</a>`, mxid, displayname), -1)
-		content.Body = strings.Replace(content.Body, number, displayname, -1)
+		number := "@" + jid.User
+		output = strings.ReplaceAll(output, number, fmt.Sprintf(`<a href="https://matrix.to/#/%s">%s</a>`, mxid, displayname))
+		content.Body = strings.ReplaceAll(content.Body, number, displayname)
 	}
 	if output != content.Body {
-		output = strings.Replace(output, "\n", "<br/>", -1)
+		output = strings.ReplaceAll(output, "\n", "<br/>")
 		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
 }

+ 28 - 7
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
+)

+ 34 - 35
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=

+ 53 - 39
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)
 	}
 }
 

+ 9 - 32
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)

+ 18 - 56
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

+ 1 - 0
no-crypto.go

@@ -1,3 +1,4 @@
+//go:build !cgo || nocrypto
 // +build !cgo nocrypto
 
 package main

Fișier diff suprimat deoarece este prea mare
+ 590 - 486
portal.go


+ 93 - 186
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."})
-		}
-		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)
+			user.Connect()
+			jsonResponse(w, http.StatusAccepted, Response{true, "Created connection to WhatsApp."})
 		}
-		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(),
-		}
-		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()
+			"is_connected": user.Client.IsConnected(),
+			"is_logged_in": user.Client.IsLoggedIn,
 		}
-		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()
 }

+ 74 - 78
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
+func reuploadAvatar(intent *appservice.IntentAPI, url string) (id.ContentURI, error) {
+	getResp, err := http.DefaultClient.Get(url)
+	if err != nil {
+		return id.ContentURI{}, fmt.Errorf("failed to download avatar: %w", err)
 	}
-
-	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 := io.ReadAll(getResp.Body)
+	_ = getResp.Body.Close()
+	if err != nil {
+		return id.ContentURI{}, fmt.Errorf("failed to read avatar bytes: %w", err)
 	}
 
-	data, err := avatar.DownloadBytes()
+	mime := http.DetectContentType(data)
+	resp, err := intent.UploadBytes(data, mime)
 	if err != nil {
-		puppet.log.Warnln("Failed to download avatar:", err)
-		return false
+		return id.ContentURI{}, fmt.Errorf("failed to upload avatar to Matrix: %w", err)
 	}
+	return resp.ContentURI, nil
+}
 
-	mime := http.DetectContentType(data)
-	resp, err := puppet.DefaultIntent().UploadBytes(data, mime)
+func (puppet *Puppet) UpdateAvatar(source *User) bool {
+	avatar, err := source.Client.GetProfilePictureInfo(puppet.JID, false)
 	if err != nil {
-		puppet.log.Warnln("Failed to upload avatar:", err)
+		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()

Fișier diff suprimat deoarece este prea mare
+ 291 - 880
user.go


Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff