Merge branch 'multidevice'

Fixes #330
This commit is contained in:
Tulir Asokan 2021-10-30 18:53:34 +03:00
commit b80e0c8db5
33 changed files with 2662 additions and 3551 deletions

View file

@ -55,7 +55,7 @@ build docker amd64:
DOCKER_ARCH: amd64 DOCKER_ARCH: amd64
after_script: 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 apk add --update curl jq
rm -rf /var/cache/apk/* rm -rf /var/cache/apk/*

View file

@ -1,11 +1,10 @@
# mautrix-whatsapp # mautrix-whatsapp
A Matrix-WhatsApp puppeting bridge based on the [Rhymen/go-whatsapp](https://github.com/Rhymen/go-whatsapp) A Matrix-WhatsApp puppeting bridge based on [whatsmeow](https://github.com/tulir/whatsmeow).
implementation of the [sigalor/whatsapp-web-reveng](https://github.com/sigalor/whatsapp-web-reveng) project.
### Documentation ### Documentation
All setup and usage instructions are located on 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).
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) * [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)) (or [with Docker](https://docs.mau.fi/bridges/go/whatsapp/setup/docker.html))

View file

@ -12,14 +12,14 @@
* [x] Read receipts * [x] Read receipts
* [ ] Power level * [ ] Power level
* [ ] Membership actions * [ ] Membership actions
* [x] Invite * [ ] Invite
* [ ] Join * [ ] Join
* [x] Leave * [ ] Leave
* [x] Kick * [ ] Kick
* [ ] Room metadata changes * [ ] Room metadata changes
* [x] Name * [ ] Name
* [ ] Avatar<sup>[1]</sup> * [ ] Avatar
* [x] Topic * [ ] Topic
* [ ] Initial room metadata * [ ] Initial room metadata
* WhatsApp → Matrix * WhatsApp → Matrix
* [x] Message content * [x] Message content
@ -32,10 +32,10 @@
* [ ] Chat types * [ ] Chat types
* [x] Private chat * [x] Private chat
* [x] Group chat * [x] Group chat
* [ ] Broadcast list<sup>[2]</sup> * [ ] Broadcast list
* [x] Message deletions * [x] Message deletions
* [x] Avatars * [x] Avatars
* [x] Presence * [ ] Presence
* [x] Typing notifications * [x] Typing notifications
* [x] Read receipts * [x] Read receipts
* [x] Admin/superadmin status * [x] Admin/superadmin status
@ -49,8 +49,8 @@
* [x] Avatar * [x] Avatar
* [x] Description * [x] Description
* [x] Initial group metadata * [x] Initial group metadata
* [ ] User metadata changes * [x] User metadata changes
* [ ] Display name<sup>[3]</sup> * [x] Display name
* [x] Avatar * [x] Avatar
* [x] Initial user metadata * [x] Initial user metadata
* [x] Display name * [x] Display name
@ -63,7 +63,3 @@
* [x] Private chat creation by inviting Matrix puppet of WhatsApp user to new room * [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] Option to use own Matrix account for messages sent from WhatsApp mobile/other web clients
* [x] Shared group chat portals * [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

View file

@ -20,16 +20,11 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io/ioutil" "io/ioutil"
"net/http" "net/http"
"strings"
"sync/atomic"
"time" "time"
"github.com/Rhymen/go-whatsapp"
log "maunium.net/go/maulogger/v2" log "maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
@ -38,7 +33,6 @@ import (
type BridgeStateEvent string type BridgeStateEvent string
const ( const (
StateStarting BridgeStateEvent = "STARTING"
StateUnconfigured BridgeStateEvent = "UNCONFIGURED" StateUnconfigured BridgeStateEvent = "UNCONFIGURED"
StateRunning BridgeStateEvent = "RUNNING" StateRunning BridgeStateEvent = "RUNNING"
StateConnecting BridgeStateEvent = "CONNECTING" StateConnecting BridgeStateEvent = "CONNECTING"
@ -56,20 +50,14 @@ const (
WANotLoggedIn BridgeErrorCode = "wa-logged-out" WANotLoggedIn BridgeErrorCode = "wa-logged-out"
WANotConnected BridgeErrorCode = "wa-not-connected" WANotConnected BridgeErrorCode = "wa-not-connected"
WAConnecting BridgeErrorCode = "wa-connecting" WAConnecting BridgeErrorCode = "wa-connecting"
WATimeout BridgeErrorCode = "wa-timeout"
WAServerTimeout BridgeErrorCode = "wa-server-timeout" WAServerTimeout BridgeErrorCode = "wa-server-timeout"
WAPingFalse BridgeErrorCode = "wa-ping-false"
WAPingError BridgeErrorCode = "wa-ping-error"
) )
var bridgeHumanErrors = map[BridgeErrorCode]string{ var bridgeHumanErrors = map[BridgeErrorCode]string{
WANotLoggedIn: "You're not logged into WhatsApp", WANotLoggedIn: "You're not logged into WhatsApp",
WANotConnected: "You're not connected to 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.", 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.", 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 { type BridgeState struct {
@ -94,8 +82,8 @@ type GlobalBridgeState struct {
func (pong BridgeState) fill(user *User) BridgeState { func (pong BridgeState) fill(user *User) BridgeState {
if user != nil { if user != nil {
pong.UserID = user.MXID pong.UserID = user.MXID
pong.RemoteID = strings.TrimSuffix(user.JID, whatsapp.NewUserSuffix) pong.RemoteID = fmt.Sprintf("%s_a%d_d%d", user.JID.User, user.JID.Agent, user.JID.Device)
pong.RemoteName = fmt.Sprintf("+%s", pong.RemoteID) pong.RemoteName = fmt.Sprintf("+%s", user.JID.User)
} }
pong.Timestamp = time.Now().Unix() 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() 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) { func (bridge *Bridge) createBridgeStateRequest(ctx context.Context, state *BridgeState) (req *http.Request, err error) {
var body bytes.Buffer var body bytes.Buffer
if err = json.NewEncoder(&body).Encode(&state); err != nil { 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) { func (prov *ProvisioningAPI) BridgeStatePing(w http.ResponseWriter, r *http.Request) {
if !prov.bridge.AS.CheckServerToken(w, r) { if !prov.bridge.AS.CheckServerToken(w, r) {
return return
@ -221,37 +181,12 @@ func (prov *ProvisioningAPI) BridgeStatePing(w http.ResponseWriter, r *http.Requ
var global BridgeState var global BridgeState
global.StateEvent = StateRunning global.StateEvent = StateRunning
var remote BridgeState var remote BridgeState
if user.Conn != nil { if user.IsConnected() {
if user.Conn.IsConnected() && user.Conn.IsLoggedIn() { if user.Client.IsLoggedIn {
pingID := atomic.AddUint32(&bridgeStatePingID, 1) remote.StateEvent = StateConnected
user.log.Debugfln("Pinging WhatsApp mobile due to bridge status /ping API request (ID %d)", pingID) } else if user.Session != nil {
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 {
remote.StateEvent = StateConnecting remote.StateEvent = StateConnecting
remote.Error = WAConnecting remote.Error = WAConnecting
} else if !user.Conn.IsConnected() && user.Session != nil {
remote.StateEvent = StateBadCredentials
remote.Error = WANotConnected
} // else: unconfigured } // else: unconfigured
} else if user.Session != nil { } else if user.Session != nil {
remote.StateEvent = StateBadCredentials remote.StateEvent = StateBadCredentials

View file

@ -1,5 +1,5 @@
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge. // 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 // 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 // it under the terms of the GNU Affero General Public License as published by
@ -20,22 +20,21 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"math"
"sort"
"strconv" "strconv"
"strings" "strings"
"github.com/Rhymen/go-whatsapp" "github.com/skip2/go-qrcode"
"maunium.net/go/maulogger/v2" "maunium.net/go/maulogger/v2"
"go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/types"
"maunium.net/go/mautrix" "maunium.net/go/mautrix"
"maunium.net/go/mautrix/appservice" "maunium.net/go/mautrix/appservice"
"maunium.net/go/mautrix/event" "maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/format" "maunium.net/go/mautrix/format"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
"maunium.net/go/mautrix-whatsapp/database"
) )
type CommandHandler struct { type CommandHandler struct {
@ -94,17 +93,11 @@ func (handler *CommandHandler) Handle(roomID id.RoomID, user *User, message stri
Args: args[1:], Args: args[1:],
} }
handler.log.Debugfln("%s sent '%s' in %s", user.MXID, message, roomID) handler.log.Debugfln("%s sent '%s' in %s", user.MXID, message, roomID)
if roomID == handler.bridge.Config.Bridge.Relaybot.ManagementRoom { handler.CommandMux(ce)
handler.CommandRelaybot(ce)
} else {
handler.CommandMux(ce)
}
} }
func (handler *CommandHandler) CommandMux(ce *CommandEvent) { func (handler *CommandHandler) CommandMux(ce *CommandEvent) {
switch ce.Command { switch ce.Command {
case "relaybot":
handler.CommandRelaybot(ce)
case "login": case "login":
handler.CommandLogin(ce) handler.CommandLogin(ce)
case "logout-matrix": case "logout-matrix":
@ -119,8 +112,6 @@ func (handler *CommandHandler) CommandMux(ce *CommandEvent) {
handler.CommandDisconnect(ce) handler.CommandDisconnect(ce)
case "ping": case "ping":
handler.CommandPing(ce) handler.CommandPing(ce)
case "delete-connection":
handler.CommandDeleteConnection(ce)
case "delete-session": case "delete-session":
handler.CommandDeleteSession(ce) handler.CommandDeleteSession(ce)
case "delete-portal": case "delete-portal":
@ -137,20 +128,22 @@ func (handler *CommandHandler) CommandMux(ce *CommandEvent) {
handler.CommandLogout(ce) handler.CommandLogout(ce)
case "toggle": case "toggle":
handler.CommandToggle(ce) 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() { if !ce.User.HasSession() {
ce.Reply("You are not logged in. Use the `login` command to log into WhatsApp.") ce.Reply("You are not logged in. Use the `login` command to log into WhatsApp.")
return 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.") ce.Reply("You are not connected to WhatsApp. Use the `reconnect` command to reconnect.")
return return
} }
switch ce.Command { switch ce.Command {
case "set-relay":
handler.CommandSetRelay(ce)
case "unset-relay":
handler.CommandUnsetRelay(ce)
case "login-matrix": case "login-matrix":
handler.CommandLoginMatrix(ce) handler.CommandLoginMatrix(ce)
case "sync":
handler.CommandSync(ce)
case "list": case "list":
handler.CommandList(ce) handler.CommandList(ce)
case "open": case "open":
@ -180,22 +173,35 @@ func (handler *CommandHandler) CommandDiscardMegolmSession(ce *CommandEvent) {
} }
} }
func (handler *CommandHandler) CommandRelaybot(ce *CommandEvent) { const cmdSetRelayHelp = `set-relay - Relay messages in this room through your WhatsApp account.`
if handler.bridge.Relaybot == nil {
ce.Reply("The relaybot is disabled") func (handler *CommandHandler) CommandSetRelay(ce *CommandEvent) {
} else if !ce.User.Admin { if !handler.bridge.Config.Bridge.Relay.Enabled {
ce.Reply("Only admins can manage the relaybot") 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 { } else {
if ce.Command == "relaybot" { ce.Portal.RelayUserID = ce.User.MXID
if len(ce.Args) == 0 { ce.Portal.Update()
ce.Reply("**Usage:** `relaybot <command>`") ce.Reply("Messages from non-logged-in users in this room will now be bridged through your WhatsApp account")
return }
} }
ce.Command = strings.ToLower(ce.Args[0])
ce.Args = ce.Args[1:] const cmdUnsetRelayHelp = `set-relay - Stop relaying messages in this room.`
}
ce.User = handler.bridge.Relaybot func (handler *CommandHandler) CommandUnsetRelay(ce *CommandEvent) {
handler.CommandMux(ce) 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 return
} }
link, err := ce.User.Conn.GroupInviteLink(ce.Portal.Key.JID) ce.Reply("Not yet implemented")
if err != nil { // TODO reimplement
ce.Reply("Failed to get invite link: %v", err) //link, err := ce.User.Conn.GroupInviteLink(ce.Portal.Key.JID)
return //if err != nil {
} // ce.Reply("Failed to get invite link: %v", err)
ce.Reply("%s%s", inviteLinkPrefix, link) // return
//}
//ce.Reply("%s%s", inviteLinkPrefix, link)
} }
const cmdJoinHelp = `join <invite link> - Join a group chat with an invite 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 return
} }
jid, err := ce.User.Conn.GroupAcceptInviteCode(ce.Args[0][len(inviteLinkPrefix):]) ce.Reply("Not yet implemented")
if err != nil { // TODO reimplement
ce.Reply("Failed to join group: %v", err) //jid, err := ce.User.Conn.GroupAcceptInviteCode(ce.Args[0][len(inviteLinkPrefix):])
return //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 { //handler.log.Debugln("%s successfully joined group %s", ce.User.MXID, jid)
portal.Sync(ce.User, whatsapp.Contact{JID: portal.Key.JID}) //portal := handler.bridge.GetPortalByJID(database.GroupPortalKey(jid))
ce.Reply("Successfully joined group \"%s\" and synced portal room: [%s](https://matrix.to/#/%s)", portal.Name, portal.Name, portal.MXID) //if len(portal.MXID) > 0 {
} else { // portal.Sync(ce.User, whatsapp.Contact{JID: portal.Key.JID})
err = portal.CreateMatrixRoom(ce.User) // ce.Reply("Successfully joined group \"%s\" and synced portal room: [%s](https://matrix.to/#/%s)", portal.Name, portal.Name, portal.MXID)
if err != nil { //} else {
ce.Reply("Failed to create portal room: %v", err) // err = portal.CreateMatrixRoom(ce.User)
return // 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("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.` const cmdCreateHelp = `create - Create a group chat.`
@ -299,43 +309,45 @@ func (handler *CommandHandler) CommandCreate(ce *CommandEvent) {
return return
} }
participants := []string{ce.User.JID} participants := []types.JID{ce.User.JID.ToNonAD()}
for userID := range members.Joined { for userID := range members.Joined {
jid, ok := handler.bridge.ParsePuppetMXID(userID) jid, ok := handler.bridge.ParsePuppetMXID(userID)
if ok && jid != ce.User.JID { if ok && jid.User != ce.User.JID.User {
participants = append(participants, jid) participants = append(participants, jid)
} }
} }
resp, err := ce.User.Conn.CreateGroup(roomNameEvent.Name, participants) ce.Reply("Not yet implemented")
if err != nil { // TODO reimplement
ce.Reply("Failed to create group: %v", err) //resp, err := ce.User.Conn.CreateGroup(roomNameEvent.Name, participants)
return //if err != nil {
} // ce.Reply("Failed to create group: %v", err)
portal := handler.bridge.GetPortalByJID(database.GroupPortalKey(resp.GroupID)) // return
portal.roomCreateLock.Lock() //}
defer portal.roomCreateLock.Unlock() //portal := handler.bridge.GetPortalByJID(database.GroupPortalKey(resp.GroupID))
if len(portal.MXID) != 0 { //portal.roomCreateLock.Lock()
portal.log.Warnln("Detected race condition in room creation") //defer portal.roomCreateLock.Unlock()
// TODO race condition, clean up the old room //if len(portal.MXID) != 0 {
} // portal.log.Warnln("Detected race condition in room creation")
portal.MXID = ce.RoomID // // TODO race condition, clean up the old room
portal.Name = roomNameEvent.Name //}
portal.Encrypted = encryptionEvent.Algorithm == id.AlgorithmMegolmV1 //portal.MXID = ce.RoomID
if !portal.Encrypted && handler.bridge.Config.Bridge.Encryption.Default { //portal.Name = roomNameEvent.Name
_, err = portal.MainIntent().SendStateEvent(portal.MXID, event.StateEncryption, "", &event.EncryptionEventContent{Algorithm: id.AlgorithmMegolmV1}) //portal.Encrypted = encryptionEvent.Algorithm == id.AlgorithmMegolmV1
if err != nil { //if !portal.Encrypted && handler.bridge.Config.Bridge.Encryption.Default {
portal.log.Warnln("Failed to enable e2be:", err) // _, err = portal.MainIntent().SendStateEvent(portal.MXID, event.StateEncryption, "", &event.EncryptionEventContent{Algorithm: id.AlgorithmMegolmV1})
} // if err != nil {
portal.Encrypted = true // portal.log.Warnln("Failed to enable e2be:", err)
} // }
// portal.Encrypted = true
portal.Update() //}
portal.UpdateBridgeInfo() //
//portal.Update()
ce.Reply("Successfully created WhatsApp group %s", portal.Key.JID) //portal.UpdateBridgeInfo()
inCommunity := ce.User.addPortalToCommunity(portal) //
ce.User.CreateUserPortal(database.PortalKeyWithMeta{PortalKey: portal.Key, InCommunity: inCommunity}) //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.` 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 // CommandLogin handles login command
func (handler *CommandHandler) CommandLogin(ce *CommandEvent) { func (handler *CommandHandler) CommandLogin(ce *CommandEvent) {
if !ce.User.Connect(true) { if ce.User.Session != nil {
ce.User.log.Debugln("Connect() returned false, assuming error was logged elsewhere and canceling login.") 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 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 // CommandLogout handles !logout command
func (handler *CommandHandler) CommandLogout(ce *CommandEvent) { func (handler *CommandHandler) CommandLogout(ce *CommandEvent) {
if ce.User.Session == nil { if ce.User.Session == nil {
ce.Reply("You're not logged in.") ce.Reply("You're not logged in.")
return 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.") ce.Reply("You are not connected to WhatsApp. Use the `reconnect` command to reconnect, or `delete-session` to forget all login information.")
return 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) 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 { if err != nil {
ce.User.log.Warnln("Error while logging out:", err) ce.User.log.Warnln("Error while logging out:", err)
ce.Reply("Unknown error while logging out: %v", err) ce.Reply("Unknown error while logging out: %v", err)
return return
} }
ce.User.Session = nil
ce.User.removeFromJIDMap(StateLoggedOut) 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.DeleteConnection()
ce.User.DeleteSession()
ce.Reply("Logged out successfully.") ce.Reply("Logged out successfully.")
} }
@ -439,16 +519,16 @@ func (handler *CommandHandler) CommandToggle(ce *CommandEvent) {
} }
if ce.Args[0] == "presence" || ce.Args[0] == "all" { if ce.Args[0] == "presence" || ce.Args[0] == "all" {
customPuppet.EnablePresence = !customPuppet.EnablePresence customPuppet.EnablePresence = !customPuppet.EnablePresence
var newPresence whatsapp.Presence var newPresence types.Presence
if customPuppet.EnablePresence { if customPuppet.EnablePresence {
newPresence = whatsapp.PresenceAvailable newPresence = types.PresenceAvailable
ce.Reply("Enabled presence bridging") ce.Reply("Enabled presence bridging")
} else { } else {
newPresence = whatsapp.PresenceUnavailable newPresence = types.PresenceUnavailable
ce.Reply("Disabled presence bridging") ce.Reply("Disabled presence bridging")
} }
if ce.User.IsConnected() { if ce.User.IsLoggedIn() {
_, err := ce.User.Conn.Presence("", newPresence) err := ce.User.Client.SendPresence(newPresence)
if err != nil { if err != nil {
ce.User.log.Warnln("Failed to set presence:", err) 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` const cmdDeleteSessionHelp = `delete-session - Delete session information and disconnect from WhatsApp without sending a logout request`
func (handler *CommandHandler) CommandDeleteSession(ce *CommandEvent) { 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.") ce.Reply("Nothing to purge: no session information stored and no active connection.")
return return
} }
//ce.User.JID = ""
ce.User.removeFromJIDMap(StateLoggedOut) ce.User.removeFromJIDMap(StateLoggedOut)
ce.User.SetSession(nil)
ce.User.DeleteConnection() ce.User.DeleteConnection()
ce.User.DeleteSession()
ce.Reply("Session information purged") ce.Reply("Session information purged")
} }
const cmdReconnectHelp = `reconnect - Reconnect to WhatsApp` const cmdReconnectHelp = `reconnect - Reconnect to WhatsApp`
func (handler *CommandHandler) CommandReconnect(ce *CommandEvent) { func (handler *CommandHandler) CommandReconnect(ce *CommandEvent) {
if ce.User.Conn == nil { ce.Reply("Not yet implemented")
if ce.User.Session == nil { // TODO reimplement
ce.Reply("No existing connection and no session. Did you mean `login`?") //if ce.User.Client == nil {
} else { // if ce.User.Session == nil {
ce.Reply("No existing connection, creating one...") // ce.Reply("No existing connection and no session. Did you mean `login`?")
ce.User.Connect(false) // } else {
} // ce.Reply("No existing connection, creating one...")
return // ce.User.Connect(false)
} // }
// return
wasConnected := true //}
err := ce.User.Conn.Disconnect() //
if err == whatsapp.ErrNotConnected { //wasConnected := true
wasConnected = false //ce.User.Client.Disconnect()
} else if err != nil { //ctx := context.Background()
ce.User.log.Warnln("Error while disconnecting:", err) //connected := ce.User.Connect(false)
} //
//err = ce.User.Conn.Restore(true, ctx)
ctx := context.Background() //if err == whatsapp.ErrInvalidSession {
// if ce.User.Session != nil {
err = ce.User.Conn.Restore(true, ctx) // ce.User.log.Debugln("Got invalid session error when reconnecting, but user has session. Retrying using RestoreWithSession()...")
if err == whatsapp.ErrInvalidSession { // ce.User.Conn.SetSession(*ce.User.Session)
if ce.User.Session != nil { // err = ce.User.Conn.Restore(true, ctx)
ce.User.log.Debugln("Got invalid session error when reconnecting, but user has session. Retrying using RestoreWithSession()...") // } else {
ce.User.Conn.SetSession(*ce.User.Session) // ce.Reply("You are not logged in.")
err = ce.User.Conn.Restore(true, ctx) // return
} else { // }
ce.Reply("You are not logged in.") //} else if err == whatsapp.ErrLoginInProgress {
return // ce.Reply("A login or reconnection is already in progress.")
} // return
} else if err == whatsapp.ErrLoginInProgress { //} else if err == whatsapp.ErrAlreadyLoggedIn {
ce.Reply("A login or reconnection is already in progress.") // ce.Reply("You were already connected.")
return // return
} else if err == whatsapp.ErrAlreadyLoggedIn { //}
ce.Reply("You were already connected.") //if err != nil {
return // ce.User.log.Warnln("Error while reconnecting:", err)
} // ce.Reply("Unknown error while reconnecting: %v", err)
if err != nil { // ce.User.log.Debugln("Disconnecting due to failed session restore in reconnect command...")
ce.User.log.Warnln("Error while reconnecting:", err) // err = ce.User.Conn.Disconnect()
ce.Reply("Unknown error while reconnecting: %v", err) // if err != nil {
ce.User.log.Debugln("Disconnecting due to failed session restore in reconnect command...") // ce.User.log.Errorln("Failed to disconnect after failed session restore in reconnect command:", err)
err = ce.User.Conn.Disconnect() // }
if err != nil { // return
ce.User.log.Errorln("Failed to disconnect after failed session restore in reconnect command:", err) //}
} //ce.User.ConnectionErrors = 0
return //
} //var msg string
ce.User.ConnectionErrors = 0 //if wasConnected {
// msg = "Reconnected successfully."
var msg string //} else {
if wasConnected { // msg = "Connected successfully."
msg = "Reconnected successfully." //}
} else { //ce.Reply(msg)
msg = "Connected successfully." //ce.User.PostLogin()
}
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.")
} }
const cmdDisconnectHelp = `disconnect - Disconnect from WhatsApp (without logging out)` const cmdDisconnectHelp = `disconnect - Disconnect from WhatsApp (without logging out)`
func (handler *CommandHandler) CommandDisconnect(ce *CommandEvent) { 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.") ce.Reply("You don't have a WhatsApp connection.")
return return
} }
err := ce.User.Conn.Disconnect() ce.User.DeleteConnection()
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.Reply("Successfully disconnected. Use the `reconnect` command to reconnect.") 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.` const cmdPingHelp = `ping - Check your connection to WhatsApp.`
func (handler *CommandHandler) CommandPing(ce *CommandEvent) { func (handler *CommandHandler) CommandPing(ce *CommandEvent) {
if ce.User.Session == nil { if ce.User.Session == nil {
if ce.User.IsLoginInProgress() { ce.Reply("You're not logged into WhatsApp.")
ce.Reply("You're not logged into WhatsApp, but there's a login in progress.") } else if ce.User.Client == nil || !ce.User.Client.IsConnected() {
} else {
ce.Reply("You're not logged into WhatsApp.")
}
} else if ce.User.Conn == nil {
ce.Reply("You don't have a WhatsApp connection.") 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 { } 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 // CommandHelp handles help command
func (handler *CommandHandler) CommandHelp(ce *CommandEvent) { func (handler *CommandHandler) CommandHelp(ce *CommandEvent) {
cmdPrefix := "" cmdPrefix := ""
if ce.User.ManagementRoom != ce.RoomID || ce.User.IsRelaybot { if ce.User.ManagementRoom != ce.RoomID {
cmdPrefix = handler.bridge.Config.Bridge.CommandPrefix + " " cmdPrefix = handler.bridge.Config.Bridge.CommandPrefix + " "
} }
@ -612,12 +658,12 @@ func (handler *CommandHandler) CommandHelp(ce *CommandEvent) {
cmdPrefix + cmdDeleteSessionHelp, cmdPrefix + cmdDeleteSessionHelp,
cmdPrefix + cmdReconnectHelp, cmdPrefix + cmdReconnectHelp,
cmdPrefix + cmdDisconnectHelp, cmdPrefix + cmdDisconnectHelp,
cmdPrefix + cmdDeleteConnectionHelp,
cmdPrefix + cmdPingHelp, cmdPrefix + cmdPingHelp,
cmdPrefix + cmdSetRelayHelp,
cmdPrefix + cmdUnsetRelayHelp,
cmdPrefix + cmdLoginMatrixHelp, cmdPrefix + cmdLoginMatrixHelp,
cmdPrefix + cmdLogoutMatrixHelp, cmdPrefix + cmdLogoutMatrixHelp,
cmdPrefix + cmdToggleHelp, cmdPrefix + cmdToggleHelp,
cmdPrefix + cmdSyncHelp,
cmdPrefix + cmdListHelp, cmdPrefix + cmdListHelp,
cmdPrefix + cmdOpenHelp, cmdPrefix + cmdOpenHelp,
cmdPrefix + cmdPMHelp, cmdPrefix + cmdPMHelp,
@ -630,35 +676,23 @@ func (handler *CommandHandler) CommandHelp(ce *CommandEvent) {
}, "\n* ")) }, "\n* "))
} }
const cmdSyncHelp = `sync [--create-all] - Synchronize contacts from phone and optionally create portals for group chats.` func canDeletePortal(portal *Portal, userID id.UserID) bool {
members, err := portal.MainIntent().JoinedMembers(portal.MXID)
// CommandSync handles sync command
func (handler *CommandHandler) CommandSync(ce *CommandEvent) {
user := ce.User
create := len(ce.Args) > 0 && ce.Args[0] == "--create-all"
ce.Reply("Updating contact and chat list...")
handler.log.Debugln("Importing contacts of", user.MXID)
_, err := user.Conn.Contacts()
if err != nil { if err != nil {
user.log.Errorln("Error updating contacts:", err) portal.log.Errorfln("Failed to get joined members to check if portal can be deleted by %s: %v", userID, err)
ce.Reply("Failed to sync contact list (see logs for details)") return false
return
} }
handler.log.Debugln("Importing chats of", user.MXID) for otherUser := range members.Joined {
_, err = user.Conn.Chats() _, isPuppet := portal.bridge.ParsePuppetMXID(otherUser)
if err != nil { if isPuppet || otherUser == portal.bridge.Bot.UserID || otherUser == userID {
user.log.Errorln("Error updating chats:", err) continue
ce.Reply("Failed to sync chat list (see logs for details)") }
return user := portal.bridge.GetUserByMXID(otherUser)
if user != nil && user.Session != nil {
return false
}
} }
return true
ce.Reply("Syncing contacts...")
user.syncPuppets(nil)
ce.Reply("Syncing chats...")
user.syncPortals(nil, create)
ce.Reply("Sync complete.")
} }
const cmdDeletePortalHelp = `delete-portal - Delete the current portal. If the portal is used by other people, this is limited to bridge admins.` 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 return
} }
if !ce.User.Admin { if !ce.User.Admin && !canDeletePortal(ce.Portal, ce.User.MXID) {
users := ce.Portal.GetUserIDs() ce.Reply("Only bridge admins can delete portals with other Matrix users")
if len(users) > 1 || (len(users) == 1 && users[0] != ce.User.MXID) { return
ce.Reply("Only bridge admins can delete portals with other Matrix users")
return
}
} }
ce.Portal.log.Infoln(ce.User.MXID, "requested deletion of portal.") 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) 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) { func (handler *CommandHandler) CommandDeleteAllPortals(ce *CommandEvent) {
portals := ce.User.GetPortals() portals := handler.bridge.GetAllPortals()
portalsToDelete := make([]*Portal, 0, len(portals)) var portalsToDelete []*Portal
for _, portal := range portals {
users := portal.GetUserIDs() if ce.User.Admin {
if len(users) == 1 && users[0] == ce.User.MXID { portals = portalsToDelete
portalsToDelete = append(portalsToDelete, portal) } else {
portalsToDelete = portals[:0]
for _, portal := range portals {
if canDeletePortal(portal, ce.User.MXID) {
portalsToDelete = append(portalsToDelete, portal)
}
} }
} }
leave := func(portal *Portal) { leave := func(portal *Portal) {
if len(portal.MXID) > 0 { if len(portal.MXID) > 0 {
_, _ = portal.MainIntent().KickUser(portal.MXID, &mautrix.ReqKickUser{ _, _ = 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 { for _, portal := range portalsToDelete {
portal.Delete() portal.Delete()
leave(portal) leave(portal)
} }
ce.Reply("Finished deleting portal info. Now cleaning up rooms in background. " + ce.Reply("Finished deleting portal info. Now cleaning up rooms in background.")
"You may already continue using the bridge. Use `sync` to recreate portals.")
go func() { go func() {
for _, portal := range portalsToDelete { 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.` 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) { //func formatContacts(contacts bool, input map[string]whatsapp.Contact) (result []string) {
for jid, contact := range input { // for jid, contact := range input {
if strings.HasSuffix(jid, whatsapp.NewUserSuffix) != contacts { // if strings.HasSuffix(jid, whatsapp.NewUserSuffix) != contacts {
continue // continue
} // }
//
if contacts { // if contacts {
result = append(result, fmt.Sprintf("* %s / %s - `%s`", contact.Name, contact.Notify, contact.JID[:len(contact.JID)-len(whatsapp.NewUserSuffix)])) // result = append(result, fmt.Sprintf("* %s / %s - `%s`", contact.Name, contact.Notify, contact.JID[:len(contact.JID)-len(whatsapp.NewUserSuffix)]))
} else { // } else {
result = append(result, fmt.Sprintf("* %s - `%s`", contact.Name, contact.JID)) // result = append(result, fmt.Sprintf("* %s - `%s`", contact.Name, contact.JID))
} // }
} // }
sort.Sort(sort.StringSlice(result)) // sort.Sort(sort.StringSlice(result))
return // return
} //}
func (handler *CommandHandler) CommandList(ce *CommandEvent) { func (handler *CommandHandler) CommandList(ce *CommandEvent) {
if len(ce.Args) == 0 { 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") ce.Reply("Warning: a high number of items per page may fail to send a reply")
} }
} }
contacts := mode[0] == 'c' ce.Reply("Not yet implemented")
typeName := "Groups" // TODO reimplement
if contacts { //contacts := mode[0] == 'c'
typeName = "Contacts" //typeName := "Groups"
} //if contacts {
ce.User.Conn.Store.ContactsLock.RLock() // typeName = "Contacts"
result := formatContacts(contacts, ce.User.Conn.Store.Contacts) //}
ce.User.Conn.Store.ContactsLock.RUnlock() //ce.User.Conn.Store.ContactsLock.RLock()
if len(result) == 0 { //result := formatContacts(contacts, ce.User.Conn.Store.Contacts)
ce.Reply("No %s found", strings.ToLower(typeName)) //ce.User.Conn.Store.ContactsLock.RUnlock()
return //if len(result) == 0 {
} // ce.Reply("No %s found", strings.ToLower(typeName))
pages := int(math.Ceil(float64(len(result)) / float64(max))) // return
if (page-1)*max >= len(result) { //}
if pages == 1 { //pages := int(math.Ceil(float64(len(result)) / float64(max)))
ce.Reply("There is only 1 page of %s", strings.ToLower(typeName)) //if (page-1)*max >= len(result) {
} else { // if pages == 1 {
ce.Reply("There are only %d pages of %s", pages, strings.ToLower(typeName)) // ce.Reply("There is only 1 page of %s", strings.ToLower(typeName))
} // } else {
return // ce.Reply("There are only %d pages of %s", pages, strings.ToLower(typeName))
} // }
lastIndex := page * max // return
if lastIndex > len(result) { //}
lastIndex = len(result) //lastIndex := page * max
} //if lastIndex > len(result) {
result = result[(page-1)*max : lastIndex] // lastIndex = len(result)
ce.Reply("### %s (page %d of %d)\n\n%s", typeName, page, pages, strings.Join(result, "\n")) //}
//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.` 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>`") ce.Reply("**Usage:** `open <group JID>`")
return return
} }
ce.Reply("Not yet implemented")
user := ce.User // TODO reimplement
jid := ce.Args[0] //user := ce.User
//jid := ce.Args[0]
if strings.HasSuffix(jid, whatsapp.NewUserSuffix) { //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)]) // ce.Reply("That looks like a user JID. Did you mean `pm %s`?", jid[:len(jid)-len(whatsapp.NewUserSuffix)])
return // return
} //}
//
user.Conn.Store.ContactsLock.RLock() //user.Conn.Store.ContactsLock.RLock()
contact, ok := user.Conn.Store.Contacts[jid] //contact, ok := user.Conn.Store.Contacts[jid]
user.Conn.Store.ContactsLock.RUnlock() //user.Conn.Store.ContactsLock.RUnlock()
if !ok { //if !ok {
ce.Reply("Group JID not found in contacts. Try syncing contacts with `sync` first.") // ce.Reply("Group JID not found in contacts. Try syncing contacts with `sync` first.")
return // return
} //}
handler.log.Debugln("Importing", jid, "for", user) //handler.log.Debugln("Importing", jid, "for", user)
portal := user.bridge.GetPortalByJID(database.GroupPortalKey(jid)) //portal := user.bridge.GetPortalByJID(database.GroupPortalKey(jid))
if len(portal.MXID) > 0 { //if len(portal.MXID) > 0 {
portal.Sync(user, contact) // portal.Sync(user, contact)
ce.Reply("Portal room synced.") // ce.Reply("Portal room synced.")
} else { //} else {
portal.Sync(user, contact) // portal.Sync(user, contact)
ce.Reply("Portal room created.") // ce.Reply("Portal room created.")
} //}
_, _ = portal.MainIntent().InviteUser(portal.MXID, &mautrix.ReqInviteUser{UserID: user.MXID}) //_, _ = portal.MainIntent().InviteUser(portal.MXID, &mautrix.ReqInviteUser{UserID: user.MXID})
} }
const cmdPMHelp = `pm [--force] <_international phone number_> - Open a private chat with the given phone number.` const cmdPMHelp = `pm <_international phone number_> - Open a private chat with the given phone number.`
func (handler *CommandHandler) CommandPM(ce *CommandEvent) { func (handler *CommandHandler) CommandPM(ce *CommandEvent) {
if len(ce.Args) == 0 { if len(ce.Args) == 0 {
ce.Reply("**Usage:** `pm [--force] <international phone number>`") ce.Reply("**Usage:** `pm <international phone number>`")
return return
} }
force := ce.Args[0] == "--force"
if force {
ce.Args = ce.Args[1:]
}
user := ce.User user := ce.User
number := strings.Join(ce.Args, "") number := strings.Join(ce.Args, "")
if number[0] == '+' { resp, err := ce.User.Client.IsOnWhatsApp([]string{number})
number = number[1:] 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 { targetUser := resp[0]
if char < '0' || char > '9' { if !targetUser.IsIn {
ce.Reply("Invalid phone number.") ce.Reply("The server said +%s is not on WhatsApp", targetUser.JID.User)
return return
}
} }
jid := number + whatsapp.NewUserSuffix
handler.log.Debugln("Importing", jid, "for", user) handler.log.Debugln("Importing", targetUser.JID, "for", user)
puppet := user.bridge.GetPuppetByJID(targetUser.JID)
user.Conn.Store.ContactsLock.RLock() puppet.SyncContact(user, true)
contact, ok := user.Conn.Store.Contacts[jid] portal := user.GetPortalByJID(puppet.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))
if len(portal.MXID) > 0 { if len(portal.MXID) > 0 {
var err error ok := portal.ensureUserInvited(user)
if !user.IsRelaybot { if !ok {
err = portal.MainIntent().EnsureInvited(portal.MXID, user.MXID) portal.log.Warnfln("ensureUserInvited(%s) returned false, creating new portal", user.MXID)
}
if err != nil {
portal.log.Warnfln("Failed to invite %s to portal: %v. Creating new portal", user.MXID, err)
portal.MXID = "" portal.MXID = ""
} else { } else {
ce.Reply("You already have a private chat portal with that user at [%s](https://matrix.to/#/%s)", puppet.Displayname, portal.MXID) ce.Reply("You already have a private chat portal with that user at [%s](https://matrix.to/#/%s)", puppet.Displayname, portal.MXID)
return return
} }
} }
err := portal.CreateMatrixRoom(user) err = portal.CreateMatrixRoom(user)
if err != nil { if err != nil {
ce.Reply("Failed to create portal room: %v", err) ce.Reply("Failed to create portal room: %v", err)
return return

View file

@ -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
}

View file

@ -1,5 +1,5 @@
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge. // 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 // 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 // it under the terms of the GNU Affero General Public License as published by
@ -17,12 +17,11 @@
package config package config
import ( import (
"bytes"
"strconv" "strconv"
"strings" "strings"
"text/template" "text/template"
"github.com/Rhymen/go-whatsapp" "go.mau.fi/whatsmeow/types"
"maunium.net/go/mautrix/event" "maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
@ -31,18 +30,8 @@ import (
type BridgeConfig struct { type BridgeConfig struct {
UsernameTemplate string `yaml:"username_template"` UsernameTemplate string `yaml:"username_template"`
DisplaynameTemplate string `yaml:"displayname_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"` 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"` PortalMessageBuffer int `yaml:"portal_message_buffer"`
CallNotices struct { CallNotices struct {
@ -50,15 +39,13 @@ type BridgeConfig struct {
End bool `yaml:"end"` End bool `yaml:"end"`
} `yaml:"call_notices"` } `yaml:"call_notices"`
InitialChatSync int `yaml:"initial_chat_sync_count"` HistorySync struct {
InitialHistoryFill int `yaml:"initial_history_fill_count"` CreatePortals bool `yaml:"create_portals"`
HistoryDisableNotifs bool `yaml:"initial_history_disable_notifications"` Backfill bool `yaml:"backfill"`
RecoverChatSync int `yaml:"recovery_chat_sync_count"` DoublePuppetBackfill bool `yaml:"double_puppet_backfill"`
RecoverHistory bool `yaml:"recovery_history_backfill"` } `yaml:"history_sync"`
ChatMetaSync bool `yaml:"chat_meta_sync"` UserAvatarSync bool `yaml:"user_avatar_sync"`
UserAvatarSync bool `yaml:"user_avatar_sync"` BridgeMatrixLeave bool `yaml:"bridge_matrix_leave"`
BridgeMatrixLeave bool `yaml:"bridge_matrix_leave"`
SyncChatMaxAge int64 `yaml:"sync_max_chat_age"`
SyncWithCustomPuppets bool `yaml:"sync_with_custom_puppets"` SyncWithCustomPuppets bool `yaml:"sync_with_custom_puppets"`
SyncDirectChatList bool `yaml:"sync_direct_chat_list"` SyncDirectChatList bool `yaml:"sync_direct_chat_list"`
@ -66,16 +53,15 @@ type BridgeConfig struct {
DefaultBridgePresence bool `yaml:"default_bridge_presence"` DefaultBridgePresence bool `yaml:"default_bridge_presence"`
LoginSharedSecret string `yaml:"login_shared_secret"` LoginSharedSecret string `yaml:"login_shared_secret"`
InviteOwnPuppetForBackfilling bool `yaml:"invite_own_puppet_for_backfilling"` PrivateChatPortalMeta bool `yaml:"private_chat_portal_meta"`
PrivateChatPortalMeta bool `yaml:"private_chat_portal_meta"` BridgeNotices bool `yaml:"bridge_notices"`
BridgeNotices bool `yaml:"bridge_notices"` ResendBridgeInfo bool `yaml:"resend_bridge_info"`
ResendBridgeInfo bool `yaml:"resend_bridge_info"` MuteBridging bool `yaml:"mute_bridging"`
MuteBridging bool `yaml:"mute_bridging"` ArchiveTag string `yaml:"archive_tag"`
ArchiveTag string `yaml:"archive_tag"` PinnedTag string `yaml:"pinned_tag"`
PinnedTag string `yaml:"pinned_tag"` TagOnlyOnCreate bool `yaml:"tag_only_on_create"`
TagOnlyOnCreate bool `yaml:"tag_only_on_create"` MarkReadOnlyOnCreate bool `yaml:"mark_read_only_on_create"`
MarkReadOnlyOnCreate bool `yaml:"mark_read_only_on_create"` EnableStatusBroadcast bool `yaml:"enable_status_broadcast"`
EnableStatusBroadcast bool `yaml:"enable_status_broadcast"`
WhatsappThumbnail bool `yaml:"whatsapp_thumbnail"` WhatsappThumbnail bool `yaml:"whatsapp_thumbnail"`
@ -103,44 +89,26 @@ type BridgeConfig struct {
Permissions PermissionConfig `yaml:"permissions"` Permissions PermissionConfig `yaml:"permissions"`
Relaybot RelaybotConfig `yaml:"relaybot"` Relay RelaybotConfig `yaml:"relay"`
usernameTemplate *template.Template `yaml:"-"` usernameTemplate *template.Template `yaml:"-"`
displaynameTemplate *template.Template `yaml:"-"` displaynameTemplate *template.Template `yaml:"-"`
communityTemplate *template.Template `yaml:"-"`
} }
func (bc *BridgeConfig) setDefaults() { 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.PortalMessageBuffer = 128
bc.CallNotices.Start = true bc.CallNotices.Start = true
bc.CallNotices.End = true bc.CallNotices.End = true
bc.InitialChatSync = 10 bc.HistorySync.CreatePortals = true
bc.InitialHistoryFill = 20
bc.RecoverChatSync = -1
bc.RecoverHistory = true
bc.ChatMetaSync = true
bc.UserAvatarSync = true bc.UserAvatarSync = true
bc.BridgeMatrixLeave = true bc.BridgeMatrixLeave = true
bc.SyncChatMaxAge = 259200
bc.SyncWithCustomPuppets = true bc.SyncWithCustomPuppets = true
bc.DefaultBridgePresence = true bc.DefaultBridgePresence = true
bc.DefaultBridgeReceipts = true bc.DefaultBridgeReceipts = true
bc.LoginSharedSecret = ""
bc.InviteOwnPuppetForBackfilling = true
bc.PrivateChatPortalMeta = false
bc.BridgeNotices = true bc.BridgeNotices = true
bc.EnableStatusBroadcast = true bc.EnableStatusBroadcast = true
@ -167,13 +135,6 @@ func (bc *BridgeConfig) UnmarshalYAML(unmarshal func(interface{}) error) error {
return err return err
} }
if len(bc.CommunityTemplate) > 0 {
bc.communityTemplate, err = template.New("community").Parse(bc.CommunityTemplate)
if err != nil {
return err
}
}
return nil return nil
} }
@ -181,44 +142,43 @@ type UsernameTemplateArgs struct {
UserID id.UserID UserID id.UserID
} }
func (bc BridgeConfig) FormatDisplayname(contact whatsapp.Contact) (string, int8) { type legacyContactInfo struct {
var buf bytes.Buffer types.ContactInfo
if index := strings.IndexRune(contact.JID, '@'); index > 0 { Phone string
contact.JID = "+" + contact.JID[:index]
} Notify string
bc.displaynameTemplate.Execute(&buf, contact) 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 var quality int8
switch { switch {
case len(contact.Notify) > 0 || len(contact.VName) > 0: case len(contact.PushName) > 0 || len(contact.BusinessName) > 0:
quality = 3 quality = 3
case len(contact.Name) > 0 || len(contact.Short) > 0: case len(contact.FullName) > 0 || len(contact.FirstName) > 0:
quality = 2 quality = 2
case len(contact.JID) > 0:
quality = 1
default: default:
quality = 0 quality = 1
} }
return buf.String(), quality return buf.String(), quality
} }
func (bc BridgeConfig) FormatUsername(userID whatsapp.JID) string { func (bc BridgeConfig) FormatUsername(username string) string {
var buf bytes.Buffer var buf strings.Builder
bc.usernameTemplate.Execute(&buf, userID) _ = bc.usernameTemplate.Execute(&buf, username)
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})
return buf.String() return buf.String()
} }
@ -227,10 +187,10 @@ type PermissionConfig map[string]PermissionLevel
type PermissionLevel int type PermissionLevel int
const ( const (
PermissionLevelDefault PermissionLevel = 0 PermissionLevelDefault PermissionLevel = 0
PermissionLevelRelaybot PermissionLevel = 5 PermissionLevelRelay PermissionLevel = 5
PermissionLevelUser PermissionLevel = 10 PermissionLevelUser PermissionLevel = 10
PermissionLevelAdmin PermissionLevel = 100 PermissionLevelAdmin PermissionLevel = 100
) )
func (pc *PermissionConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { 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 { for key, value := range rawPC {
switch strings.ToLower(value) { switch strings.ToLower(value) {
case "relaybot": case "relaybot", "relay":
(*pc)[key] = PermissionLevelRelaybot (*pc)[key] = PermissionLevelRelay
case "user": case "user":
(*pc)[key] = PermissionLevelUser (*pc)[key] = PermissionLevelUser
case "admin": case "admin":
@ -270,8 +230,8 @@ func (pc *PermissionConfig) MarshalYAML() (interface{}, error) {
rawPC := make(map[string]string) rawPC := make(map[string]string)
for key, value := range *pc { for key, value := range *pc {
switch value { switch value {
case PermissionLevelRelaybot: case PermissionLevelRelay:
rawPC[key] = "relaybot" rawPC[key] = "relay"
case PermissionLevelUser: case PermissionLevelUser:
rawPC[key] = "user" rawPC[key] = "user"
case PermissionLevelAdmin: case PermissionLevelAdmin:
@ -283,8 +243,8 @@ func (pc *PermissionConfig) MarshalYAML() (interface{}, error) {
return rawPC, nil return rawPC, nil
} }
func (pc PermissionConfig) IsRelaybotWhitelisted(userID id.UserID) bool { func (pc PermissionConfig) IsRelayWhitelisted(userID id.UserID) bool {
return pc.GetPermissionLevel(userID) >= PermissionLevelRelaybot return pc.GetPermissionLevel(userID) >= PermissionLevelRelay
} }
func (pc PermissionConfig) IsWhitelisted(userID id.UserID) bool { func (pc PermissionConfig) IsWhitelisted(userID id.UserID) bool {
@ -316,10 +276,8 @@ func (pc PermissionConfig) GetPermissionLevel(userID id.UserID) PermissionLevel
} }
type RelaybotConfig struct { type RelaybotConfig struct {
Enabled bool `yaml:"enabled"` Enabled bool `yaml:"enabled"`
ManagementRoom id.RoomID `yaml:"management"` AdminOnly bool `yaml:"admin_only"`
InviteUsers []id.UserID `yaml:"invites"`
MessageFormats map[event.MessageType]string `yaml:"message_formats"` MessageFormats map[event.MessageType]string `yaml:"message_formats"`
messageTemplates *template.Template `yaml:"-"` messageTemplates *template.Template `yaml:"-"`
} }

View file

@ -14,6 +14,7 @@
// You should have received a copy of the GNU Affero General Public License // 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/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
//go:build cgo && !nocrypto
// +build cgo,!nocrypto // +build cgo,!nocrypto
package main 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"} return &crypto.KeyShareRejection{Code: event.RoomKeyWithheldUnavailable, Reason: "Requested room is not a portal room"}
} }
user := helper.bridge.GetUserByMXID(device.UserID) 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) 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"} return &crypto.KeyShareRejection{Code: event.RoomKeyWithheldUnauthorized, Reason: "You're not in that portal"}
} }

View file

@ -1,5 +1,5 @@
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge. // 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 // 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 // it under the terms of the GNU Affero General Public License as published by
@ -23,7 +23,7 @@ import (
"errors" "errors"
"time" "time"
"github.com/Rhymen/go-whatsapp" "go.mau.fi/whatsmeow/types"
"maunium.net/go/mautrix" "maunium.net/go/mautrix"
"maunium.net/go/mautrix/appservice" "maunium.net/go/mautrix/appservice"
@ -160,7 +160,7 @@ func (puppet *Puppet) stopSyncing() {
} }
func (puppet *Puppet) ProcessResponse(resp *mautrix.RespSync, _ string) error { 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") puppet.log.Debugln("Skipping sync processing: custom user not connected to whatsapp")
return nil return nil
} }
@ -200,14 +200,14 @@ func (puppet *Puppet) ProcessResponse(resp *mautrix.RespSync, _ string) error {
} }
func (puppet *Puppet) handlePresenceEvent(event *event.Event) { func (puppet *Puppet) handlePresenceEvent(event *event.Event) {
presence := whatsapp.PresenceAvailable presence := types.PresenceAvailable
if event.Content.Raw["presence"].(string) != "online" { if event.Content.Raw["presence"].(string) != "online" {
presence = whatsapp.PresenceUnavailable presence = types.PresenceUnavailable
puppet.customUser.log.Debugln("Marking offline") puppet.customUser.log.Debugln("Marking offline")
} else { } else {
puppet.customUser.log.Debugln("Marking online") puppet.customUser.log.Debugln("Marking online")
} }
_, err := puppet.customUser.Conn.Presence("", presence) err := puppet.customUser.Client.SendPresence(presence)
if err != nil { if err != nil {
puppet.customUser.log.Warnln("Failed to set presence:", err) 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. // Ignore double puppeted read receipts.
} else if message := puppet.bridge.DB.Message.GetByMXID(eventID); message != nil { } 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) 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 { if err != nil {
puppet.customUser.log.Warnln("Error marking read:", err) 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 { if puppet.customTypingIn[evt.RoomID] != isTyping {
puppet.customTypingIn[evt.RoomID] = isTyping puppet.customTypingIn[evt.RoomID] = isTyping
presence := whatsapp.PresenceComposing presence := types.ChatPresenceComposing
if !isTyping { if !isTyping {
puppet.customUser.log.Debugfln("Marking not typing in %s/%s", portal.Key.JID, portal.MXID) puppet.customUser.log.Debugfln("Marking not typing in %s/%s", portal.Key.JID, portal.MXID)
presence = whatsapp.PresencePaused presence = types.ChatPresencePaused
} else { } else {
puppet.customUser.log.Debugfln("Marking typing in %s/%s", portal.Key.JID, portal.MXID) 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 { if err != nil {
puppet.customUser.log.Warnln("Error setting typing:", err) puppet.customUser.log.Warnln("Error setting typing:", err)
} }

View file

@ -14,6 +14,7 @@
// You should have received a copy of the GNU Affero General Public License // 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/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
//go:build cgo && !nocrypto
// +build cgo,!nocrypto // +build cgo,!nocrypto
package database package database

View file

@ -19,14 +19,19 @@ package database
import ( import (
"database/sql" "database/sql"
_ "github.com/lib/pq" "github.com/lib/pq"
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
log "maunium.net/go/maulogger/v2" log "maunium.net/go/maulogger/v2"
"go.mau.fi/whatsmeow/store/sqlstore"
"maunium.net/go/mautrix-whatsapp/database/upgrades" "maunium.net/go/mautrix-whatsapp/database/upgrades"
) )
func init() {
sqlstore.PostgresArrayWrapper = pq.Array
}
type Database struct { type Database struct {
*sql.DB *sql.DB
log log.Logger log log.Logger

View file

@ -1,5 +1,5 @@
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge. // 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 // 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 // it under the terms of the GNU Affero General Public License as published by
@ -21,11 +21,11 @@ import (
"strings" "strings"
"time" "time"
"github.com/Rhymen/go-whatsapp"
log "maunium.net/go/maulogger/v2" log "maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
"go.mau.fi/whatsmeow/types"
) )
type MessageQuery struct { 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) { 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 { if err != nil || rows == nil {
return nil return nil
} }
defer rows.Close()
for rows.Next() { for rows.Next() {
messages = append(messages, mq.New().Scan(rows)) messages = append(messages, mq.New().Scan(rows))
} }
return return
} }
func (mq *MessageQuery) GetByJID(chat PortalKey, jid whatsapp.MessageID) *Message { func (mq *MessageQuery) GetByJID(chat PortalKey, jid types.MessageID) *Message {
return mq.get("SELECT chat_jid, chat_receiver, jid, mxid, sender, timestamp, sent "+ return mq.maybeScan(mq.db.QueryRow(getMessageByJIDQuery, chat.JID, chat.Receiver, jid))
"FROM message WHERE chat_jid=$1 AND chat_receiver=$2 AND jid=$3", chat.JID, chat.Receiver, jid)
} }
func (mq *MessageQuery) GetByMXID(mxid id.EventID) *Message { func (mq *MessageQuery) GetByMXID(mxid id.EventID) *Message {
return mq.get("SELECT chat_jid, chat_receiver, jid, mxid, sender, timestamp, sent "+ return mq.maybeScan(mq.db.QueryRow(getMessageByMXIDQuery, mxid))
"FROM message WHERE mxid=$1", mxid)
} }
func (mq *MessageQuery) GetLastInChat(chat PortalKey) *Message { 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 { func (mq *MessageQuery) GetLastInChatBefore(chat PortalKey, maxTimestamp time.Time) *Message {
msg := mq.get("SELECT chat_jid, chat_receiver, jid, mxid, sender, timestamp, sent "+ msg := mq.maybeScan(mq.db.QueryRow(getLastMessageInChatQuery, chat.JID, chat.Receiver, maxTimestamp.Unix()))
"FROM message WHERE chat_jid=$1 AND chat_receiver=$2 AND timestamp<=$3 AND sent=true ORDER BY timestamp DESC LIMIT 1", if msg == nil || msg.Timestamp.IsZero() {
chat.JID, chat.Receiver, maxTimestamp)
if msg == nil || msg.Timestamp == 0 {
// Old db, we don't know what the last message is. // Old db, we don't know what the last message is.
return nil return nil
} }
return msg return msg
} }
func (mq *MessageQuery) get(query string, args ...interface{}) *Message { func (mq *MessageQuery) GetFirstInChat(chat PortalKey) *Message {
row := mq.db.QueryRow(query, args...) return mq.maybeScan(mq.db.QueryRow(getFirstMessageInChatQuery, chat.JID, chat.Receiver))
}
func (mq *MessageQuery) maybeScan(row *sql.Row) *Message {
if row == nil { if row == nil {
return nil return nil
} }
@ -90,11 +111,13 @@ type Message struct {
log log.Logger log log.Logger
Chat PortalKey Chat PortalKey
JID whatsapp.MessageID JID types.MessageID
MXID id.EventID MXID id.EventID
Sender whatsapp.JID Sender types.JID
Timestamp int64 Timestamp time.Time
Sent bool Sent bool
DecryptionError bool
} }
func (msg *Message) IsFakeMXID() bool { func (msg *Message) IsFakeMXID() bool {
@ -102,22 +125,30 @@ func (msg *Message) IsFakeMXID() bool {
} }
func (msg *Message) Scan(row Scannable) *Message { 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 != nil {
if err != sql.ErrNoRows { if err != sql.ErrNoRows {
msg.log.Errorln("Database scan failed:", err) msg.log.Errorln("Database scan failed:", err)
} }
return nil return nil
} }
if ts != 0 {
msg.Timestamp = time.Unix(ts, 0)
}
return msg return msg
} }
func (msg *Message) Insert() { 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 _, err := msg.db.Exec(`INSERT INTO message
(chat_jid, chat_receiver, jid, mxid, sender, timestamp, sent) (chat_jid, chat_receiver, jid, mxid, sender, timestamp, sent, decryption_error)
VALUES ($1, $2, $3, $4, $5, $6, $7)`, VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
msg.Chat.JID, msg.Chat.Receiver, msg.JID, msg.MXID, msg.Sender, msg.Timestamp, msg.Sent) msg.Chat.JID, msg.Chat.Receiver, msg.JID, msg.MXID, sender, msg.Timestamp.Unix(), msg.Sent, msg.DecryptionError)
if err != nil { if err != nil {
msg.log.Warnfln("Failed to insert %s@%s: %v", msg.Chat, msg.JID, err) 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() { 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) _, 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 { if err != nil {

View file

@ -1,5 +1,5 @@
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge. // 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 // 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 // it under the terms of the GNU Affero General Public License as published by
@ -18,42 +18,40 @@ package database
import ( import (
"database/sql" "database/sql"
"strings"
log "maunium.net/go/maulogger/v2" log "maunium.net/go/maulogger/v2"
"github.com/Rhymen/go-whatsapp"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
"go.mau.fi/whatsmeow/types"
) )
type PortalKey struct { type PortalKey struct {
JID whatsapp.JID JID types.JID
Receiver whatsapp.JID Receiver types.JID
} }
func GroupPortalKey(jid whatsapp.JID) PortalKey { func GroupPortalKey(jid types.JID) PortalKey {
return PortalKey{ return NewPortalKey(jid, jid)
JID: jid,
Receiver: jid,
}
} }
func NewPortalKey(jid, receiver whatsapp.JID) PortalKey { func NewPortalKey(jid, receiver types.JID) PortalKey {
if strings.HasSuffix(jid, whatsapp.GroupSuffix) { if jid.Server == types.GroupServer {
receiver = jid receiver = jid
} else if jid.Server == types.LegacyUserServer {
jid.Server = types.DefaultUserServer
} }
return PortalKey{ return PortalKey{
JID: jid, JID: jid.ToNonAD(),
Receiver: receiver, Receiver: receiver.ToNonAD(),
} }
} }
func (key PortalKey) String() string { func (key PortalKey) String() string {
if key.Receiver == key.JID { 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 { 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) return pq.get("SELECT * FROM portal WHERE mxid=$1", mxid)
} }
func (pq *PortalQuery) GetAllByJID(jid whatsapp.JID) []*Portal { func (pq *PortalQuery) GetAllByJID(jid types.JID) []*Portal {
return pq.getAll("SELECT * FROM portal WHERE jid=$1", jid) return pq.getAll("SELECT * FROM portal WHERE jid=$1", jid.ToNonAD())
} }
func (pq *PortalQuery) FindPrivateChats(receiver whatsapp.JID) []*Portal { func (pq *PortalQuery) FindPrivateChats(receiver types.JID) []*Portal {
return pq.getAll("SELECT * FROM portal WHERE receiver=$1 AND jid LIKE '%@s.whatsapp.net'", receiver) 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) { func (pq *PortalQuery) getAll(query string, args ...interface{}) (portals []*Portal) {
@ -120,11 +118,16 @@ type Portal struct {
Avatar string Avatar string
AvatarURL id.ContentURI AvatarURL id.ContentURI
Encrypted bool Encrypted bool
FirstEventID id.EventID
NextBatchID id.BatchID
RelayUserID id.UserID
} }
func (portal *Portal) Scan(row Scannable) *Portal { func (portal *Portal) Scan(row Scannable) *Portal {
var mxid, avatarURL sql.NullString 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) 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 != nil {
if err != sql.ErrNoRows { if err != sql.ErrNoRows {
portal.log.Errorln("Database scan failed:", err) 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.MXID = id.RoomID(mxid.String)
portal.AvatarURL, _ = id.ParseContentURI(avatarURL.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 return portal
} }
@ -143,21 +149,24 @@ func (portal *Portal) mxidPtr() *id.RoomID {
return nil return nil
} }
func (portal *Portal) relayUserPtr() *id.UserID {
if len(portal.RelayUserID) > 0 {
return &portal.RelayUserID
}
return nil
}
func (portal *Portal) Insert() { 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)", _, 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.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 { if err != nil {
portal.log.Warnfln("Failed to insert %s: %v", portal.Key, err) portal.log.Warnfln("Failed to insert %s: %v", portal.Key, err)
} }
} }
func (portal *Portal) Update() { func (portal *Portal) Update() {
var mxid *id.RoomID _, 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",
if len(portal.MXID) > 0 { 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())
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)
if err != nil { if err != nil {
portal.log.Warnfln("Failed to update %s: %v", portal.Key, err) 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) 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
}

View file

@ -1,5 +1,5 @@
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge. // 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 // 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 // it under the terms of the GNU Affero General Public License as published by
@ -20,10 +20,9 @@ import (
"database/sql" "database/sql"
log "maunium.net/go/maulogger/v2" log "maunium.net/go/maulogger/v2"
"github.com/Rhymen/go-whatsapp"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
"go.mau.fi/whatsmeow/types"
) )
type PuppetQuery struct { type PuppetQuery struct {
@ -42,7 +41,7 @@ func (pq *PuppetQuery) New() *Puppet {
} }
func (pq *PuppetQuery) GetAll() (puppets []*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 { if err != nil || rows == nil {
return nil return nil
} }
@ -53,8 +52,8 @@ func (pq *PuppetQuery) GetAll() (puppets []*Puppet) {
return return
} }
func (pq *PuppetQuery) Get(jid whatsapp.JID) *Puppet { func (pq *PuppetQuery) Get(jid types.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) 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 { if row == nil {
return nil return nil
} }
@ -62,7 +61,7 @@ func (pq *PuppetQuery) Get(jid whatsapp.JID) *Puppet {
} }
func (pq *PuppetQuery) GetByCustomMXID(mxid id.UserID) *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 { if row == nil {
return nil return nil
} }
@ -70,7 +69,7 @@ func (pq *PuppetQuery) GetByCustomMXID(mxid id.UserID) *Puppet {
} }
func (pq *PuppetQuery) GetAllWithCustomMXID() (puppets []*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 { if err != nil || rows == nil {
return nil return nil
} }
@ -85,7 +84,7 @@ type Puppet struct {
db *Database db *Database
log log.Logger log log.Logger
JID whatsapp.JID JID types.JID
Avatar string Avatar string
AvatarURL id.ContentURI AvatarURL id.ContentURI
Displayname string Displayname string
@ -102,13 +101,15 @@ func (puppet *Puppet) Scan(row Scannable) *Puppet {
var displayname, avatar, avatarURL, customMXID, accessToken, nextBatch sql.NullString var displayname, avatar, avatarURL, customMXID, accessToken, nextBatch sql.NullString
var quality sql.NullInt64 var quality sql.NullInt64
var enablePresence, enableReceipts sql.NullBool 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 != nil {
if err != sql.ErrNoRows { if err != sql.ErrNoRows {
puppet.log.Errorln("Database scan failed:", err) puppet.log.Errorln("Database scan failed:", err)
} }
return nil return nil
} }
puppet.JID = types.NewJID(username, types.DefaultUserServer)
puppet.Displayname = displayname.String puppet.Displayname = displayname.String
puppet.Avatar = avatar.String puppet.Avatar = avatar.String
puppet.AvatarURL, _ = id.ParseContentURI(avatarURL.String) puppet.AvatarURL, _ = id.ParseContentURI(avatarURL.String)
@ -122,16 +123,20 @@ func (puppet *Puppet) Scan(row Scannable) *Puppet {
} }
func (puppet *Puppet) Insert() { 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)", if puppet.JID.Server != types.DefaultUserServer {
puppet.JID, puppet.Avatar, puppet.AvatarURL.String(), puppet.Displayname, puppet.NameQuality, puppet.CustomMXID, puppet.AccessToken, puppet.NextBatch, puppet.EnablePresence, puppet.EnableReceipts) 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 { if err != nil {
puppet.log.Warnfln("Failed to insert %s: %v", puppet.JID, err) puppet.log.Warnfln("Failed to insert %s: %v", puppet.JID, err)
} }
} }
func (puppet *Puppet) Update() { 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", _, 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) 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 { if err != nil {
puppet.log.Warnfln("Failed to update %s->%s: %v", puppet.JID, err) puppet.log.Warnfln("Failed to update %s->%s: %v", puppet.JID, err)
} }

View file

@ -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))
}}
}

View file

@ -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
}}
}

View file

@ -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
}}
}

View file

@ -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
}}
}

View file

@ -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
}}
}

View file

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

View file

@ -1,5 +1,5 @@
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge. // 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 // 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 // it under the terms of the GNU Affero General Public License as published by
@ -18,15 +18,11 @@ package database
import ( import (
"database/sql" "database/sql"
"fmt"
"strings"
"time"
"github.com/Rhymen/go-whatsapp"
log "maunium.net/go/maulogger/v2" log "maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
"go.mau.fi/whatsmeow/types"
) )
type UserQuery struct { type UserQuery struct {
@ -42,7 +38,7 @@ func (uq *UserQuery) New() *User {
} }
func (uq *UserQuery) GetAll() (users []*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 { if err != nil || rows == nil {
return nil return nil
} }
@ -54,15 +50,15 @@ func (uq *UserQuery) GetAll() (users []*User) {
} }
func (uq *UserQuery) GetByMXID(userID id.UserID) *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 { if row == nil {
return nil return nil
} }
return uq.New().Scan(row) return uq.New().Scan(row)
} }
func (uq *UserQuery) GetByJID(userID whatsapp.JID) *User { func (uq *UserQuery) GetByUsername(username string) *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)) row := uq.db.QueryRow(`SELECT mxid, username, agent, device, management_room FROM "user" WHERE username=$1`, username)
if row == nil { if row == nil {
return nil return nil
} }
@ -74,185 +70,151 @@ type User struct {
log log.Logger log log.Logger
MXID id.UserID MXID id.UserID
JID whatsapp.JID JID types.JID
ManagementRoom id.RoomID ManagementRoom id.RoomID
Session *whatsapp.Session
LastConnection int64
} }
func (user *User) Scan(row Scannable) *User { func (user *User) Scan(row Scannable) *User {
var jid, clientID, clientToken, serverToken sql.NullString var username sql.NullString
var encKey, macKey []byte var device, agent sql.NullByte
err := row.Scan(&user.MXID, &jid, &user.ManagementRoom, &user.LastConnection, &clientID, &clientToken, &serverToken, &encKey, &macKey) err := row.Scan(&user.MXID, &username, &agent, &device, &user.ManagementRoom)
if err != nil { if err != nil {
if err != sql.ErrNoRows { if err != sql.ErrNoRows {
user.log.Errorln("Database scan failed:", err) user.log.Errorln("Database scan failed:", err)
} }
return nil return nil
} }
if len(jid.String) > 0 && len(clientID.String) > 0 { if len(username.String) > 0 {
user.JID = jid.String + whatsapp.NewUserSuffix user.JID = types.NewADJID(username.String, agent.Byte, device.Byte)
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
} }
return user return user
} }
func stripSuffix(jid whatsapp.JID) string { func (user *User) usernamePtr() *string {
if len(jid) == 0 { if !user.JID.IsEmpty() {
return jid return &user.JID.User
}
index := strings.IndexRune(jid, '@')
if index < 0 {
return jid
}
return jid[:index]
}
func (user *User) jidPtr() *string {
if len(user.JID) > 0 {
str := stripSuffix(user.JID)
return &str
} }
return nil return nil
} }
func (user *User) sessionUnptr() (sess whatsapp.Session) { func (user *User) agentPtr() *uint8 {
if user.Session != nil { if !user.JID.IsEmpty() {
sess = *user.Session return &user.JID.Agent
} }
return return nil
}
func (user *User) devicePtr() *uint8 {
if !user.JID.IsEmpty() {
return &user.JID.Device
}
return nil
} }
func (user *User) Insert() { func (user *User) Insert() {
sess := user.sessionUnptr() _, err := user.db.Exec(`INSERT INTO "user" (mxid, username, agent, device, management_room) VALUES ($1, $2, $3, $4, $5)`,
_, 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.usernamePtr(), user.agentPtr(), user.devicePtr(), user.ManagementRoom)
user.MXID, user.jidPtr(),
user.ManagementRoom, user.LastConnection,
sess.ClientID, sess.ClientToken, sess.ServerToken, sess.EncKey, sess.MacKey)
if err != nil { if err != nil {
user.log.Warnfln("Failed to insert %s: %v", user.MXID, err) 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() { func (user *User) Update() {
sess := user.sessionUnptr() _, err := user.db.Exec(`UPDATE "user" SET username=$1, agent=$2, device=$3, management_room=$4 WHERE mxid=$5`,
_, 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.usernamePtr(), user.agentPtr(), user.devicePtr(), user.ManagementRoom, user.MXID)
user.jidPtr(), user.ManagementRoom, user.LastConnection,
sess.ClientID, sess.ClientToken, sess.ServerToken, sess.EncKey, sess.MacKey,
user.MXID)
if err != nil { if err != nil {
user.log.Warnfln("Failed to update %s: %v", user.MXID, err) user.log.Warnfln("Failed to update %s: %v", user.MXID, err)
} }
} }
type PortalKeyWithMeta struct { //type PortalKeyWithMeta struct {
PortalKey // PortalKey
InCommunity bool // InCommunity bool
} //}
//
func (user *User) SetPortalKeys(newKeys []PortalKeyWithMeta) error { //func (user *User) SetPortalKeys(newKeys []PortalKeyWithMeta) error {
tx, err := user.db.Begin() // tx, err := user.db.Begin()
if err != nil { // if err != nil {
return err // return err
} // }
_, err = tx.Exec("DELETE FROM user_portal WHERE user_jid=$1", user.jidPtr()) // _, err = tx.Exec("DELETE FROM user_portal WHERE user_jid=$1", user.jidPtr())
if err != nil { // if err != nil {
_ = tx.Rollback() // _ = tx.Rollback()
return err // return err
} // }
valueStrings := make([]string, len(newKeys)) // valueStrings := make([]string, len(newKeys))
values := make([]interface{}, len(newKeys)*4) // values := make([]interface{}, len(newKeys)*4)
for i, key := range newKeys { // for i, key := range newKeys {
pos := i * 4 // pos := i * 4
valueStrings[i] = fmt.Sprintf("($%d, $%d, $%d, $%d)", pos+1, pos+2, pos+3, pos+4) // valueStrings[i] = fmt.Sprintf("($%d, $%d, $%d, $%d)", pos+1, pos+2, pos+3, pos+4)
values[pos] = user.jidPtr() // values[pos] = user.jidPtr()
values[pos+1] = key.JID // values[pos+1] = key.JID
values[pos+2] = key.Receiver // values[pos+2] = key.Receiver
values[pos+3] = key.InCommunity // values[pos+3] = key.InCommunity
} // }
query := fmt.Sprintf("INSERT INTO user_portal (user_jid, portal_jid, portal_receiver, in_community) VALUES %s", // query := fmt.Sprintf("INSERT INTO user_portal (user_jid, portal_jid, portal_receiver, in_community) VALUES %s",
strings.Join(valueStrings, ", ")) // strings.Join(valueStrings, ", "))
_, err = tx.Exec(query, values...) // _, err = tx.Exec(query, values...)
if err != nil { // if err != nil {
_ = tx.Rollback() // _ = tx.Rollback()
return err // return err
} // }
return tx.Commit() // return tx.Commit()
} //}
//
func (user *User) IsInPortal(key PortalKey) bool { //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) // 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 // var exists bool
_ = row.Scan(&exists) // _ = row.Scan(&exists)
return exists // return exists
} //}
//
func (user *User) GetPortalKeys() []PortalKey { //func (user *User) GetPortalKeys() []PortalKey {
rows, err := user.db.Query(`SELECT portal_jid, portal_receiver FROM user_portal WHERE user_jid=$1`, user.jidPtr()) // rows, err := user.db.Query(`SELECT portal_jid, portal_receiver FROM user_portal WHERE user_jid=$1`, user.jidPtr())
if err != nil { // if err != nil {
user.log.Warnln("Failed to get user portal keys:", err) // user.log.Warnln("Failed to get user portal keys:", err)
return nil // return nil
} // }
var keys []PortalKey // var keys []PortalKey
for rows.Next() { // for rows.Next() {
var key PortalKey // var key PortalKey
err = rows.Scan(&key.JID, &key.Receiver) // err = rows.Scan(&key.JID, &key.Receiver)
if err != nil { // if err != nil {
user.log.Warnln("Failed to scan row:", err) // user.log.Warnln("Failed to scan row:", err)
continue // continue
} // }
keys = append(keys, key) // keys = append(keys, key)
} // }
return keys // return keys
} //}
//
func (user *User) GetInCommunityMap() map[PortalKey]bool { //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()) // rows, err := user.db.Query(`SELECT portal_jid, portal_receiver, in_community FROM user_portal WHERE user_jid=$1`, user.jidPtr())
if err != nil { // if err != nil {
user.log.Warnln("Failed to get user portal keys:", err) // user.log.Warnln("Failed to get user portal keys:", err)
return nil // return nil
} // }
keys := make(map[PortalKey]bool) // keys := make(map[PortalKey]bool)
for rows.Next() { // for rows.Next() {
var key PortalKey // var key PortalKey
var inCommunity bool // var inCommunity bool
err = rows.Scan(&key.JID, &key.Receiver, &inCommunity) // err = rows.Scan(&key.JID, &key.Receiver, &inCommunity)
if err != nil { // if err != nil {
user.log.Warnln("Failed to scan row:", err) // user.log.Warnln("Failed to scan row:", err)
continue // continue
} // }
keys[key] = inCommunity // keys[key] = inCommunity
} // }
return keys // return keys
} //}
//
func (user *User) CreateUserPortal(newKey PortalKeyWithMeta) { //func (user *User) CreateUserPortal(newKey PortalKeyWithMeta) {
user.log.Debugfln("Creating new portal %s for %s", newKey.PortalKey.JID, newKey.PortalKey.Receiver) // 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)`, // _, err := user.db.Exec(`INSERT INTO user_portal (user_jid, portal_jid, portal_receiver, in_community) VALUES ($1, $2, $3, $4)`,
user.jidPtr(), // user.jidPtr(),
newKey.PortalKey.JID, newKey.PortalKey.Receiver, // newKey.PortalKey.JID, newKey.PortalKey.Receiver,
newKey.InCommunity) // newKey.InCommunity)
if err != nil { // if err != nil {
user.log.Warnfln("Failed to insert %s: %v", user.MXID, err) // user.log.Warnfln("Failed to insert %s: %v", user.MXID, err)
} // }
} //}

View file

@ -27,6 +27,7 @@ appservice:
# The database URI. # The database URI.
# SQLite: File name is enough. https://github.com/mattn/go-sqlite3#connection-string # 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 # 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 uri: mautrix-whatsapp.db
# Maximum number of connections. Mostly relevant for Postgres. # Maximum number of connections. Mostly relevant for Postgres.
max_open_conns: 20 max_open_conns: 20
@ -63,9 +64,10 @@ metrics:
whatsapp: whatsapp:
# Device name that's shown in the "WhatsApp Web" section in the mobile app. # Device name that's shown in the "WhatsApp Web" section in the mobile app.
os_name: Mautrix-WhatsApp bridge 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. # Browser name that determines the logo shown in the mobile app.
# Use the name of an actual browser (Chrome, Firefox, Safari, IE, Edge, Opera) if you want a specific icon. # Must be "unknown" for a generic icon or a valid browser name if you want a specific icon.
browser_name: mx-wa # List of valid browser names: https://github.com/tulir/whatsmeow/blob/2a72655ef600a7fd7a2e98d53ec6da029759c4b8/binary/proto/def.proto#L1582-L1594
browser_name: unknown
# Bridge config # Bridge config
bridge: bridge:
@ -73,49 +75,19 @@ bridge:
# {{.}} is replaced with the phone number of the WhatsApp user. # {{.}} is replaced with the phone number of the WhatsApp user.
username_template: whatsapp_{{.}} username_template: whatsapp_{{.}}
# Displayname template for WhatsApp users. # Displayname template for WhatsApp users.
# {{.Notify}} - nickname set by the WhatsApp user # {{.PushName}} - nickname set by the WhatsApp user
# {{.VName}} - validated WhatsApp business name # {{.BusinessName}} - validated WhatsApp business name
# {{.JID}} - phone number (international format) # {{.Phone}} - phone number (international format)
# The following variables are also available, but will cause problems on multi-user instances: # The following variables are also available, but will cause problems on multi-user instances:
# {{.Name}} - display name from contact list # {{.FullName}} - full name from contact list
# {{.Short}} - short display name from contact list # {{.FirstName}} - first name from contact list
displayname_template: "{{if .Notify}}{{.Notify}}{{else if .VName}}{{.VName}}{{else}}{{.JID}}{{end}} (WA)" displayname_template: "{{if .PushName}}{{.PushName}}{{else if .BusinessName}}{{.BusinessName}}{{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
# 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 # 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 # sent to WhatsApp. If fetch_message_on_timeout is enabled, a successful post-timeout fetch will
# trigger a read receipt too. # trigger a read receipt too.
delivery_receipts: false 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 portal_message_buffer: 128
# Whether or not to send call start/end notices to Matrix. # Whether or not to send call start/end notices to Matrix.
@ -123,32 +95,20 @@ bridge:
start: true start: true
end: true end: true
# Number of chats to sync for new users. history_sync:
initial_chat_sync_count: 10 # Whether to create portals from history sync payloads from WhatsApp.
# Number of old messages to fill when creating new portal rooms. create_portals: true
initial_history_fill_count: 20 # Whether to enable backfilling history sync payloads from WhatsApp using batch sending
# Whether or not notifications should be turned off while filling initial history. # This requires a server with MSC2716 support, which is currently an experimental feature in synapse.
# Only applicable when using double puppeting. # It can be enabled by setting experimental_features -> enable_msc2716 to true in homeserver.yaml.
initial_history_disable_notifications: false backfill: false
# Maximum number of chats to sync when recovering from downtime. # Whether to use custom puppet for backfilling.
# Set to -1 to sync all new chats during downtime. # In order to use this, the custom puppets must be in the appservice's user ID namespace.
recovery_chat_sync_limit: -1 double_puppet_backfill: false
# 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
# Whether or not puppet avatars should be fetched from the server even if an avatar is already set. # 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 user_avatar_sync: true
# Whether or not Matrix users leaving groups should be bridged to WhatsApp # Whether or not Matrix users leaving groups should be bridged to WhatsApp
bridge_matrix_leave: true 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 # Whether or not to sync with custom puppets to receive EDUs that
# are not normally sent to appservices. # are not normally sent to appservices.
@ -169,20 +129,12 @@ bridge:
# manually. # manually.
login_shared_secret: null login_shared_secret: null
# Whether or not to invite own WhatsApp user's Matrix puppet into private # Whether to explicitly set the avatar and room name for private chat portal rooms.
# 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.
private_chat_portal_meta: false 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 bridge_notices: true
# Set this to true to tell the bridge to re-send m.bridge events to all rooms on the next run. # 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, # This field will automatically be changed back to false after it, except if the config file is not writable.
# except if the config file is not writable.
resend_bridge_info: false resend_bridge_info: false
# When using double puppeting, should muted chats be muted in Matrix? # When using double puppeting, should muted chats be muted in Matrix?
mute_bridging: false mute_bridging: false
@ -246,7 +198,7 @@ bridge:
# Permissions for using the bridge. # Permissions for using the bridge.
# Permitted values: # 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. # user - Access to use the bridge to chat with a WhatsApp account.
# admin - User level and some additional administration tools # admin - User level and some additional administration tools
# Permitted keys: # Permitted keys:
@ -254,19 +206,16 @@ bridge:
# domain - All users on that homeserver # domain - All users on that homeserver
# mxid - Specific user # mxid - Specific user
permissions: permissions:
"*": relaybot "*": relay
"example.com": user "example.com": user
"@admin:example.com": admin "@admin:example.com": admin
relaybot: relay:
# Whether or not relaybot support is enabled. # 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 enabled: false
# The management room for the bot. This is where all status notifications are posted and # Should only admins be allowed to set themselves as relay users?
# in this room, you can use `!wa <command>` instead of `!wa relaybot <command>`. Omitting admin_only: true
# 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: []
# The formats to use when sending messages to WhatsApp via the relaybot. # The formats to use when sending messages to WhatsApp via the relaybot.
message_formats: message_formats:
m.text: "<b>{{ .Sender.Displayname }}</b>: {{ .Message }}" m.text: "<b>{{ .Sender.Displayname }}</b>: {{ .Message }}"

View file

@ -1,5 +1,5 @@
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge. // 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 // 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 // it under the terms of the GNU Affero General Public License as published by
@ -22,7 +22,7 @@ import (
"regexp" "regexp"
"strings" "strings"
"github.com/Rhymen/go-whatsapp" "go.mau.fi/whatsmeow/types"
"maunium.net/go/mautrix/event" "maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/format" "maunium.net/go/mautrix/format"
@ -57,32 +57,22 @@ func NewFormatter(bridge *Bridge) *Formatter {
if mxid[0] == '@' { if mxid[0] == '@' {
puppet := bridge.GetPuppetByMXID(id.UserID(mxid)) puppet := bridge.GetPuppetByMXID(id.UserID(mxid))
if puppet != nil { if puppet != nil {
jids, ok := ctx[mentionedJIDsContextKey].([]whatsapp.JID) jids, ok := ctx[mentionedJIDsContextKey].([]string)
if !ok { if !ok {
ctx[mentionedJIDsContextKey] = []whatsapp.JID{puppet.JID} ctx[mentionedJIDsContextKey] = []string{puppet.JID.String()}
} else { } else {
ctx[mentionedJIDsContextKey] = append(jids, puppet.JID) ctx[mentionedJIDsContextKey] = append(jids, puppet.JID.String())
} }
return "@" + puppet.PhoneNumber() return "@" + puppet.JID.User
} }
} }
return mxid return mxid
}, },
BoldConverter: func(text string, _ format.Context) string { BoldConverter: func(text string, _ format.Context) string { return fmt.Sprintf("*%s*", text) },
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) },
ItalicConverter: func(text string, _ format.Context) string { MonospaceConverter: func(text string, _ format.Context) string { return fmt.Sprintf("```%s```", text) },
return fmt.Sprintf("_%s_", text) MonospaceBlockConverter: func(text, language 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{ waReplString: map[*regexp.Regexp]string{
italicRegex: "$1<em>$2</em>$3", italicRegex: "$1<em>$2</em>$3",
@ -99,12 +89,11 @@ func NewFormatter(bridge *Bridge) *Formatter {
return fmt.Sprintf("<code>%s</code>", str) 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 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 { if user := formatter.bridge.GetUserByJID(jid); user != nil {
mxid = user.MXID mxid = user.MXID
displayname = string(user.MXID) displayname = string(user.MXID)
@ -115,7 +104,7 @@ func (formatter *Formatter) getMatrixInfoByJID(jid whatsapp.JID) (mxid id.UserID
return 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) output := html.EscapeString(content.Body)
for regex, replacement := range formatter.waReplString { for regex, replacement := range formatter.waReplString {
output = regex.ReplaceAllString(output, replacement) output = regex.ReplaceAllString(output, replacement)
@ -123,14 +112,20 @@ func (formatter *Formatter) ParseWhatsApp(content *event.MessageEventContent, me
for regex, replacer := range formatter.waReplFunc { for regex, replacer := range formatter.waReplFunc {
output = regex.ReplaceAllStringFunc(output, replacer) 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) mxid, displayname := formatter.getMatrixInfoByJID(jid)
number := "@" + strings.Replace(jid, whatsapp.NewUserSuffix, "", 1) number := "@" + jid.User
output = strings.Replace(output, number, fmt.Sprintf(`<a href="https://matrix.to/#/%s">%s</a>`, mxid, displayname), -1) output = strings.ReplaceAll(output, number, fmt.Sprintf(`<a href="https://matrix.to/#/%s">%s</a>`, mxid, displayname))
content.Body = strings.Replace(content.Body, number, displayname, -1) content.Body = strings.ReplaceAll(content.Body, number, displayname)
} }
if output != content.Body { if output != content.Body {
output = strings.Replace(output, "\n", "<br/>", -1) output = strings.ReplaceAll(output, "\n", "<br/>")
content.FormattedBody = output content.FormattedBody = output
content.Format = event.FormatHTML content.Format = event.FormatHTML
for regex, replacer := range formatter.waReplFuncText { 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) ctx := make(format.Context)
result := formatter.matrixHTMLParser.Parse(html, ctx) result := formatter.matrixHTMLParser.Parse(html, ctx)
mentionedJIDs, _ := ctx[mentionedJIDsContextKey].([]whatsapp.JID) mentionedJIDs, _ := ctx[mentionedJIDsContextKey].([]string)
return result, mentionedJIDs return result, mentionedJIDs
} }

35
go.mod
View file

@ -1,19 +1,40 @@
module maunium.net/go/mautrix-whatsapp module maunium.net/go/mautrix-whatsapp
go 1.14 go 1.17
require ( require (
github.com/Rhymen/go-whatsapp v0.1.0
github.com/gorilla/websocket v1.4.2 github.com/gorilla/websocket v1.4.2
github.com/lib/pq v1.10.2 github.com/lib/pq v1.10.3
github.com/mattn/go-sqlite3 v1.14.8 github.com/mattn/go-sqlite3 v1.14.9
github.com/prometheus/client_golang v1.11.0 github.com/prometheus/client_golang v1.11.0
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e 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 golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d
google.golang.org/protobuf v1.27.1
gopkg.in/yaml.v2 v2.4.0 gopkg.in/yaml.v2 v2.4.0
maunium.net/go/mauflag v1.0.0 maunium.net/go/mauflag v1.0.0
maunium.net/go/maulogger/v2 v2.3.0 maunium.net/go/maulogger/v2 v2.3.1
maunium.net/go/mautrix v0.9.27 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
)

69
go.sum
View file

@ -1,4 +1,6 @@
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 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/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-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/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.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 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.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.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.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.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 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/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 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 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.3 h1:v9QZf2Sn6AmjXtQeFpdoq/eaNtYP6IN+7lcrygsIAtg=
github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8= github.com/lib/pq v1.10.3/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mattn/go-sqlite3 v1.14.9 h1:10HX2Td0ocZpYEjhilsuo6WWtUqttj2Kb0KtD86/KYA=
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/mattn/go-sqlite3 v1.14.9/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/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= 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/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= 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.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 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.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/tidwall/gjson v1.6.8 h1:CTmXMClGYPAmln7652e69B7OLXfTi5ABcPPwjIWUv7w= github.com/tidwall/gjson v1.10.2 h1:APbLGOM0rrEkd8WBw9C24nllro4ajFuJu0Sc9hRz8Bo=
github.com/tidwall/gjson v1.6.8/go.mod h1:zeFuBCIqD4sN/gmqBzZ4j7Jd6UcA2Fc56x7QFsv+8fI= github.com/tidwall/gjson v1.10.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.0.3 h1:FQUVvBImDutD8wJLN6c5eMzWtjgONK9MwIBCOrUJKeE= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.0.3/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.0.2 h1:Z7S3cePv9Jwm1KwS0513MRaoUe3S01WPbLNV40pwWZU= github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.0.2/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.1.5 h1:wsUceI/XDyZk3J1FUvuuYlK62zJv2HO2Pzb8A5EWdUE= github.com/tidwall/sjson v1.2.3 h1:5+deguEhHSEjmuICXZ21uSSsXotWMA0orU783+Z7Cp8=
github.com/tidwall/sjson v1.1.5/go.mod h1:VuJzsZnTowhSxWdOgsAnb886i4AjEyTkk7tNtsL7EYE= github.com/tidwall/sjson v1.2.3/go.mod h1:5WdjKx3AQMvCJ4RG6/2UYT7dLrGvJUV1x4jdTAyGvZs=
github.com/tulir/go-whatsapp v0.5.12 h1:JGU5yhoh+CyDcSMUilwy7FL0gFo0zqqepsHRqEjrjKc= go.mau.fi/libsignal v0.0.0-20211024113310-f9fc6a1855f2 h1:xpQTMgJGGaF+c8jV/LA/FVXAPJxZbSAGeflOc+Ly6uQ=
github.com/tulir/go-whatsapp v0.5.12/go.mod h1:7J3IIL3bEQiBJGtiZst1N4PgXHlWIartdVQLe6lcx9A= 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-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-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-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-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-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-20210921155107-089bfa567519 h1:7I4JAnoQBe7ZtJcBaYHi5UtiO8tQHbUSXxL+pnGRANg=
golang.org/x/crypto v0.0.0-20210506145944-38f3c27a63bf h1:B2n+Zi5QeYRDAEodEu72OS36gmTWjgpXr2+cWcBW90o= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20210506145944-38f3c27a63bf/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d h1:RNPAfi2nHY7C2srAV8A49jpsYr0ADedCk1wq6fTMTvs= 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/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= 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-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-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-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-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/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-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/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-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-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-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-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-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-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-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-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-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/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/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.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 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.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 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-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0 h1:bxAC2xTBsZGibn2RTntX0oH50xLsqy1OxA9tTL3p/lk= google.golang.org/protobuf v1.27.1 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 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/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 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 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= 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 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M=
maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA= 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.1 h1:fwBYJne0pHvJrrIPHK+TAPfyxxbBEz46oVGez2x0ODE=
maunium.net/go/maulogger/v2 v2.3.0 h1:TMCcO65fLk6+pJXo7sl38tzjzW0KBFgc6JWJMBJp4GE= maunium.net/go/maulogger/v2 v2.3.1/go.mod h1:TYWy7wKwz/tIXTpsx8G3mZseIRiC5DoMxSZazOHy68A=
maunium.net/go/maulogger/v2 v2.3.0/go.mod h1:TYWy7wKwz/tIXTpsx8G3mZseIRiC5DoMxSZazOHy68A= maunium.net/go/mautrix v0.9.31 h1:n7UF5tqq2zCyfdNsv++RyQ2anjjrFVOmOA2VkZCSgZc=
maunium.net/go/mautrix v0.9.27 h1:6MV6YSCGqfw8Rb0G1PHjTOkYkTY0vcZaz6wd+U+V1Is= maunium.net/go/mautrix v0.9.31/go.mod h1:3U7pOAx4bxdIVJuunLDAToI+M7YwxcGMm74zBmX5aY0=
maunium.net/go/mautrix v0.9.27/go.mod h1:7IzKfWvpQtN+W2Lzxc0rLvIxFM3ryKX6Ys3S/ZoWbg8=

92
main.go
View file

@ -1,5 +1,5 @@
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge. // 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 // 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 // it under the terms of the GNU Affero General Public License as published by
@ -21,12 +21,18 @@ import (
"fmt" "fmt"
"os" "os"
"os/signal" "os/signal"
"strconv"
"strings" "strings"
"sync" "sync"
"syscall" "syscall"
"time" "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" flag "maunium.net/go/mauflag"
log "maunium.net/go/maulogger/v2" log "maunium.net/go/maulogger/v2"
@ -41,21 +47,29 @@ import (
"maunium.net/go/mautrix-whatsapp/database/upgrades" "maunium.net/go/mautrix-whatsapp/database/upgrades"
) )
// The name and repo URL of the bridge.
var ( var (
// These are static
Name = "mautrix-whatsapp" Name = "mautrix-whatsapp"
URL = "https://github.com/mautrix/whatsapp" URL = "https://github.com/mautrix/whatsapp"
// This is changed when making a release )
Version = "0.1.9"
// This is filled by init() // Information to find out exactly which commit the bridge was built from.
WAVersion = "" // These are filled at build time with the -X linker flag.
VersionString = "" var (
// These are filled at build time with the -X linker flag
Tag = "unknown" Tag = "unknown"
Commit = "unknown" Commit = "unknown"
BuildTime = "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() { func init() {
if len(Tag) > 0 && Tag[0] == 'v' { if len(Tag) > 0 && Tag[0] == 'v' {
Tag = Tag[1:] Tag = Tag[1:]
@ -145,19 +159,19 @@ type Bridge struct {
Provisioning *ProvisioningAPI Provisioning *ProvisioningAPI
Bot *appservice.IntentAPI Bot *appservice.IntentAPI
Formatter *Formatter Formatter *Formatter
Relaybot *User
Crypto Crypto Crypto Crypto
Metrics *MetricsHandler Metrics *MetricsHandler
WAContainer *sqlstore.Container
usersByMXID map[id.UserID]*User usersByMXID map[id.UserID]*User
usersByJID map[whatsapp.JID]*User usersByUsername map[string]*User
usersLock sync.Mutex usersLock sync.Mutex
managementRooms map[id.RoomID]*User managementRooms map[id.RoomID]*User
managementRoomsLock sync.Mutex managementRoomsLock sync.Mutex
portalsByMXID map[id.RoomID]*Portal portalsByMXID map[id.RoomID]*Portal
portalsByJID map[database.PortalKey]*Portal portalsByJID map[database.PortalKey]*Portal
portalsLock sync.Mutex portalsLock sync.Mutex
puppets map[whatsapp.JID]*Puppet puppets map[types.JID]*Puppet
puppetsByCustomMXID map[id.UserID]*Puppet puppetsByCustomMXID map[id.UserID]*Puppet
puppetsLock sync.Mutex puppetsLock sync.Mutex
} }
@ -176,11 +190,11 @@ type Crypto interface {
func NewBridge() *Bridge { func NewBridge() *Bridge {
bridge := &Bridge{ bridge := &Bridge{
usersByMXID: make(map[id.UserID]*User), usersByMXID: make(map[id.UserID]*User),
usersByJID: make(map[whatsapp.JID]*User), usersByUsername: make(map[string]*User),
managementRooms: make(map[id.RoomID]*User), managementRooms: make(map[id.RoomID]*User),
portalsByMXID: make(map[id.RoomID]*Portal), portalsByMXID: make(map[id.RoomID]*Portal),
portalsByJID: make(map[database.PortalKey]*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), puppetsByCustomMXID: make(map[id.UserID]*Puppet),
} }
@ -259,6 +273,8 @@ func (bridge *Bridge) Init() {
bridge.DB.SetMaxOpenConns(bridge.Config.AppService.Database.MaxOpenConns) bridge.DB.SetMaxOpenConns(bridge.Config.AppService.Database.MaxOpenConns)
bridge.DB.SetMaxIdleConns(bridge.Config.AppService.Database.MaxIdleConns) 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 ss := bridge.Config.AppService.Provisioning.SharedSecret
if len(ss) > 0 && ss != "disable" { if len(ss) > 0 && ss != "disable" {
bridge.Provisioning = &ProvisioningAPI{bridge: bridge} bridge.Provisioning = &ProvisioningAPI{bridge: bridge}
@ -271,6 +287,23 @@ func (bridge *Bridge) Init() {
bridge.Formatter = NewFormatter(bridge) bridge.Formatter = NewFormatter(bridge)
bridge.Crypto = NewCryptoHelper(bridge) bridge.Crypto = NewCryptoHelper(bridge)
bridge.Metrics = NewMetricsHandler(bridge.Config.Metrics.Listen, bridge.Log.Sub("Metrics"), bridge.DB) 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() { func (bridge *Bridge) Start() {
@ -289,12 +322,10 @@ func (bridge *Bridge) Start() {
os.Exit(19) os.Exit(19)
} }
} }
bridge.sendGlobalBridgeState(BridgeState{StateEvent: StateStarting}.fill(nil))
if bridge.Provisioning != nil { if bridge.Provisioning != nil {
bridge.Log.Debugln("Initializing provisioning API") bridge.Log.Debugln("Initializing provisioning API")
bridge.Provisioning.Init() bridge.Provisioning.Init()
} }
bridge.LoadRelaybot()
bridge.Log.Debugln("Starting application service HTTP server") bridge.Log.Debugln("Starting application service HTTP server")
go bridge.AS.Start() go bridge.AS.Start()
bridge.Log.Debugln("Starting event processor") bridge.Log.Debugln("Starting event processor")
@ -327,21 +358,6 @@ func (bridge *Bridge) ResendBridgeInfo() {
bridge.Log.Infoln("Finished re-sending bridge info state events") 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() { func (bridge *Bridge) UpdateBotProfile() {
bridge.Log.Debugln("Updating bot profile") bridge.Log.Debugln("Updating bot profile")
botConfig := bridge.Config.AppService.Bot botConfig := bridge.Config.AppService.Bot
@ -374,10 +390,10 @@ func (bridge *Bridge) StartUsers() {
bridge.Log.Debugln("Starting users") bridge.Log.Debugln("Starting users")
foundAnySessions := false foundAnySessions := false
for _, user := range bridge.GetAllUsers() { for _, user := range bridge.GetAllUsers() {
if user.Session != nil { if !user.JID.IsEmpty() {
foundAnySessions = true foundAnySessions = true
} }
go user.Connect(false) go user.Connect()
} }
if !foundAnySessions { if !foundAnySessions {
bridge.sendGlobalBridgeState(BridgeState{StateEvent: StateUnconfigured}.fill(nil)) bridge.sendGlobalBridgeState(BridgeState{StateEvent: StateUnconfigured}.fill(nil))
@ -401,15 +417,13 @@ func (bridge *Bridge) Stop() {
bridge.AS.Stop() bridge.AS.Stop()
bridge.Metrics.Stop() bridge.Metrics.Stop()
bridge.EventProcessor.Stop() bridge.EventProcessor.Stop()
for _, user := range bridge.usersByJID { for _, user := range bridge.usersByUsername {
if user.Conn == nil { if user.Client == nil {
continue continue
} }
bridge.Log.Debugln("Disconnecting", user.MXID) bridge.Log.Debugln("Disconnecting", user.MXID)
err := user.Conn.Disconnect() user.Client.Disconnect()
if err != nil { close(user.historySyncs)
bridge.Log.Errorfln("Error while disconnecting %s: %v", user.MXID, err)
}
} }
} }

View file

@ -121,29 +121,9 @@ func (mx *MatrixHandler) HandleBotInvite(evt *event.Event) {
return 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) _, _ = 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) user.SetManagementRoom(evt.RoomID)
_, _ = intent.SendNotice(user.ManagementRoom, "This room has been registered as your bridge management/status room.") _, _ = 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) 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() portal.UpdateBridgeInfo()
_, _ = intent.SendNotice(roomID, "Private chat portal created") _, _ = intent.SendNotice(roomID, "Private chat portal created")
err := portal.FillInitialHistory(inviter) //err := portal.FillInitialHistory(inviter)
if err != nil { //if err != nil {
portal.log.Errorln("Failed to fill history:", err) // portal.log.Errorln("Failed to fill history:", err)
} //}
inviter.addPortalToCommunity(portal)
inviter.addPuppetToCommunity(puppet)
} }
func (mx *MatrixHandler) HandlePuppetInvite(evt *event.Event, inviter *User, puppet *Puppet) { 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) user := mx.bridge.GetUserByMXID(evt.Sender)
if user == nil || !user.Whitelisted || !user.IsConnected() { if user == nil || !user.Whitelisted || !user.IsLoggedIn() {
return return
} }
@ -322,7 +299,7 @@ func (mx *MatrixHandler) HandleRoomMetadata(evt *event.Event) {
} }
user := mx.bridge.GetUserByMXID(evt.Sender) user := mx.bridge.GetUserByMXID(evt.Sender)
if user == nil || !user.Whitelisted || !user.IsConnected() { if user == nil || !user.Whitelisted || !user.IsLoggedIn() {
return return
} }
@ -343,7 +320,7 @@ func (mx *MatrixHandler) shouldIgnoreEvent(evt *event.Event) bool {
return true return true
} }
user := mx.bridge.GetUserByMXID(evt.Sender) user := mx.bridge.GetUserByMXID(evt.Sender)
if !user.RelaybotWhitelisted { if !user.RelayWhitelisted {
return true return true
} }
return false return false
@ -461,7 +438,7 @@ func (mx *MatrixHandler) HandleRedaction(evt *event.Event) {
if !user.HasSession() { if !user.HasSession() {
return return
} else if !user.IsConnected() { } else if !user.IsLoggedIn() {
msg := format.RenderMarkdown(fmt.Sprintf("[%[1]s](https://matrix.to/#/%[1]s): \u26a0 "+ 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. "+ "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) "Use `%[2]s reconnect` to reconnect.", user.MXID, mx.bridge.Config.Bridge.CommandPrefix), true, false)

View file

@ -1,5 +1,5 @@
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge. // 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 // 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 // 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" "github.com/prometheus/client_golang/prometheus/promhttp"
log "maunium.net/go/maulogger/v2" 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/event"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
@ -59,16 +59,12 @@ type MetricsHandler struct {
unencryptedGroupCount prometheus.Gauge unencryptedGroupCount prometheus.Gauge
unencryptedPrivateCount prometheus.Gauge unencryptedPrivateCount prometheus.Gauge
connected prometheus.Gauge connected prometheus.Gauge
connectedState map[whatsapp.JID]bool connectedState map[string]bool
connectedStateLock sync.Mutex connectedStateLock sync.Mutex
loggedIn prometheus.Gauge loggedIn prometheus.Gauge
loggedInState map[whatsapp.JID]bool loggedInState map[string]bool
loggedInStateLock sync.Mutex loggedInStateLock sync.Mutex
syncLocked prometheus.Gauge
syncLockedState map[whatsapp.JID]bool
syncLockedStateLock sync.Mutex
bufferLength *prometheus.GaugeVec
} }
func NewMetricsHandler(address string, log log.Logger, db *database.Database) *MetricsHandler { 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", Name: "bridge_logged_in",
Help: "Users logged into the bridge", Help: "Users logged into the bridge",
}), }),
loggedInState: make(map[whatsapp.JID]bool), loggedInState: make(map[string]bool),
connected: promauto.NewGauge(prometheus.GaugeOpts{ connected: promauto.NewGauge(prometheus.GaugeOpts{
Name: "bridge_connected", Name: "bridge_connected",
Help: "Bridge users connected to WhatsApp", Help: "Bridge users connected to WhatsApp",
}), }),
connectedState: make(map[whatsapp.JID]bool), connectedState: make(map[string]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"}),
} }
} }
@ -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 { if !mh.running {
return noop return noop
} }
@ -169,7 +156,7 @@ func (mh *MetricsHandler) TrackWhatsAppMessage(timestamp uint64, messageType str
mh.whatsappMessageHandling. mh.whatsappMessageHandling.
With(prometheus.Labels{"message_type": messageType}). With(prometheus.Labels{"message_type": messageType}).
Observe(duration.Seconds()) 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() 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 { if !mh.running {
return return
} }
mh.loggedInStateLock.Lock() mh.loggedInStateLock.Lock()
defer mh.loggedInStateLock.Unlock() defer mh.loggedInStateLock.Unlock()
currentVal, ok := mh.loggedInState[jid] currentVal, ok := mh.loggedInState[jid.User]
if !ok || currentVal != loggedIn { if !ok || currentVal != loggedIn {
mh.loggedInState[jid] = loggedIn mh.loggedInState[jid.User] = loggedIn
if loggedIn { if loggedIn {
mh.loggedIn.Inc() mh.loggedIn.Inc()
} else { } 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 { if !mh.running {
return return
} }
mh.connectedStateLock.Lock() mh.connectedStateLock.Lock()
defer mh.connectedStateLock.Unlock() defer mh.connectedStateLock.Unlock()
currentVal, ok := mh.connectedState[jid] currentVal, ok := mh.connectedState[jid.User]
if !ok || currentVal != connected { if !ok || currentVal != connected {
mh.connectedState[jid] = connected mh.connectedState[jid.User] = connected
if connected { if connected {
mh.connected.Inc() mh.connected.Inc()
} else { } 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() { func (mh *MetricsHandler) updateStats() {
start := time.Now() start := time.Now()
var puppetCount int var puppetCount int

View file

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

2121
portal.go

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge. // 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 // 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 // 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/gorilla/websocket"
"github.com/Rhymen/go-whatsapp" "go.mau.fi/whatsmeow"
log "maunium.net/go/maulogger/v2" log "maunium.net/go/maulogger/v2"
@ -50,11 +50,13 @@ func (prov *ProvisioningAPI) Init() {
r.HandleFunc("/login", prov.Login).Methods(http.MethodGet) r.HandleFunc("/login", prov.Login).Methods(http.MethodGet)
r.HandleFunc("/logout", prov.Logout).Methods(http.MethodPost) r.HandleFunc("/logout", prov.Logout).Methods(http.MethodPost)
r.HandleFunc("/delete_session", prov.DeleteSession).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("/disconnect", prov.Disconnect).Methods(http.MethodPost)
r.HandleFunc("/reconnect", prov.Reconnect).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.asmux/ping", prov.BridgeStatePing).Methods(http.MethodPost)
prov.bridge.AS.Router.HandleFunc("/_matrix/app/com.beeper.bridge_state", 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 { type responseWrap struct {
@ -122,7 +124,7 @@ type Response struct {
func (prov *ProvisioningAPI) DeleteSession(w http.ResponseWriter, r *http.Request) { func (prov *ProvisioningAPI) DeleteSession(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value("user").(*User) 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{ jsonResponse(w, http.StatusNotFound, Error{
Error: "Nothing to purge: no session information stored and no active connection.", Error: "Nothing to purge: no session information stored and no active connection.",
ErrCode: "no session", ErrCode: "no session",
@ -130,128 +132,43 @@ func (prov *ProvisioningAPI) DeleteSession(w http.ResponseWriter, r *http.Reques
return return
} }
user.DeleteConnection() user.DeleteConnection()
user.SetSession(nil) user.DeleteSession()
jsonResponse(w, http.StatusOK, Response{true, "Session information purged"}) jsonResponse(w, http.StatusOK, Response{true, "Session information purged"})
} user.removeFromJIDMap(StateLoggedOut)
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"})
} }
func (prov *ProvisioningAPI) Disconnect(w http.ResponseWriter, r *http.Request) { func (prov *ProvisioningAPI) Disconnect(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value("user").(*User) user := r.Context().Value("user").(*User)
if user.Conn == nil { if user.Client == nil {
jsonResponse(w, http.StatusNotFound, Error{ jsonResponse(w, http.StatusNotFound, Error{
Error: "You don't have a WhatsApp connection.", Error: "You don't have a WhatsApp connection.",
ErrCode: "no connection", ErrCode: "no connection",
}) })
return return
} }
err := user.Conn.Disconnect() user.DeleteConnection()
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)
jsonResponse(w, http.StatusOK, Response{true, "Disconnected from WhatsApp"}) 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) { func (prov *ProvisioningAPI) Reconnect(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value("user").(*User) user := r.Context().Value("user").(*User)
if user.Conn == nil { if user.Client == nil {
if user.Session == nil { if user.Session == nil {
jsonResponse(w, http.StatusForbidden, Error{ jsonResponse(w, http.StatusForbidden, Error{
Error: "No existing connection and no session. Please log in first.", Error: "No existing connection and no session. Please log in first.",
ErrCode: "no session", ErrCode: "no session",
}) })
} else { } else {
user.Connect(false) user.Connect()
jsonResponse(w, http.StatusOK, Response{true, "Created connection to WhatsApp."}) jsonResponse(w, http.StatusAccepted, Response{true, "Created connection to WhatsApp."})
} }
return
}
user.log.Debugln("Received /reconnect request, disconnecting")
wasConnected := true
err := user.Conn.Disconnect()
if err == whatsapp.ErrNotConnected {
wasConnected = false
} else if err != nil {
user.log.Warnln("Error while disconnecting:", err)
}
user.log.Debugln("Restoring session for /reconnect")
err = user.Conn.Restore(true, r.Context())
user.log.Debugfln("Restore session for /reconnect responded with %v", err)
if err == whatsapp.ErrInvalidSession {
if user.Session != nil {
user.log.Debugln("Got invalid session error when reconnecting, but user has session. Retrying using RestoreWithSession()...")
user.Conn.SetSession(*user.Session)
err = user.Conn.Restore(true, r.Context())
} else {
jsonResponse(w, http.StatusForbidden, Error{
Error: "You're not logged in",
ErrCode: "not logged in",
})
return
}
}
if err == whatsapp.ErrLoginInProgress {
jsonResponse(w, http.StatusConflict, Error{
Error: "A login or reconnection is already in progress.",
ErrCode: "login in progress",
})
return
} else if err == whatsapp.ErrAlreadyLoggedIn {
jsonResponse(w, http.StatusConflict, Error{
Error: "You were already connected.",
ErrCode: err.Error(),
})
return
}
if err != nil {
user.log.Warnln("Error while reconnecting:", err)
jsonResponse(w, http.StatusInternalServerError, Error{
Error: fmt.Sprintf("Unknown error while reconnecting: %v", err),
ErrCode: err.Error(),
})
user.log.Debugln("Disconnecting due to failed session restore in reconnect command...")
err = user.Conn.Disconnect()
if err != nil {
user.log.Errorln("Failed to disconnect after failed session restore in reconnect command:", err)
}
return
}
user.ConnectionErrors = 0
user.PostLogin()
var msg string
if wasConnected {
msg = "Reconnected successfully."
} else { } 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) { 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{}{ wa := map[string]interface{}{
"has_session": user.Session != nil, "has_session": user.Session != nil,
"management_room": user.ManagementRoom, "management_room": user.ManagementRoom,
"jid": user.JID,
"conn": nil, "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{}{ wa["conn"] = map[string]interface{}{
"is_connected": user.Conn.IsConnected(), "is_connected": user.Client.IsConnected(),
"is_logged_in": user.Conn.IsLoggedIn(), "is_logged_in": user.Client.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()
}
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{}{ resp := map[string]interface{}{
"mxid": user.MXID, "mxid": user.MXID,
"admin": user.Admin, "admin": user.Admin,
"whitelisted": user.Whitelisted, "whitelisted": user.Whitelisted,
"relaybot_whitelisted": user.RelaybotWhitelisted, "relay_whitelisted": user.RelayWhitelisted,
"whatsapp": wa, "whatsapp": wa,
} }
jsonResponse(w, http.StatusOK, resp) 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" force := strings.ToLower(r.URL.Query().Get("force")) != "false"
if user.Conn == nil { if user.Client == nil {
if !force { if !force {
jsonResponse(w, http.StatusNotFound, Error{ jsonResponse(w, http.StatusNotFound, Error{
Error: "You're not connected", Error: "You're not connected",
@ -322,7 +223,7 @@ func (prov *ProvisioningAPI) Logout(w http.ResponseWriter, r *http.Request) {
}) })
} }
} else { } else {
err := user.Conn.Logout() err := user.Client.Logout()
if err != nil { if err != nil {
user.log.Warnln("Error while logging out:", err) user.log.Warnln("Error while logging out:", err)
if !force { if !force {
@ -332,16 +233,15 @@ func (prov *ProvisioningAPI) Logout(w http.ResponseWriter, r *http.Request) {
}) })
return return
} }
} else {
user.Session = nil
} }
user.DeleteConnection() user.DeleteConnection()
} }
user.bridge.Metrics.TrackConnectionState(user.JID, false) user.bridge.Metrics.TrackConnectionState(user.JID, false)
user.removeFromJIDMap(StateLoggedOut) user.removeFromJIDMap(StateLoggedOut)
user.DeleteSession()
// TODO this causes a foreign key violation, which should be fixed
//ce.User.JID = ""
user.SetSession(nil)
jsonResponse(w, http.StatusOK, Response{true, "Logged out successfully."}) 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) prov.log.Errorln("Failed to upgrade connection to websocket:", err)
return return
} }
defer c.Close() defer func() {
err := c.Close()
if !user.Connect(true) { if err != nil {
user.log.Debugln("Connect() returned false, assuming error was logged elsewhere and canceling login.") user.log.Debugln("Error closing websocket:", err)
_ = 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,
})
} }
}() }()
@ -400,40 +284,63 @@ func (prov *ProvisioningAPI) Login(w http.ResponseWriter, r *http.Request) {
return nil return nil
}) })
user.log.Debugln("Starting login via provisioning API") qrChan, err := user.Login(ctx)
session, jid, err := user.Conn.Login(qrChan, ctx)
qrChan <- "stop"
if err != nil { if err != nil {
var msg string user.log.Errorf("Failed to log in from provisioning API:", err)
if errors.Is(err, whatsapp.ErrAlreadyLoggedIn) { if errors.Is(err, ErrAlreadyLoggedIn) {
msg = "You're already logged in" go user.Connect()
} else if errors.Is(err, whatsapp.ErrLoginInProgress) { _ = c.WriteJSON(Error{
msg = "You have a login in progress already." Error: "You're already logged into WhatsApp",
} else if errors.Is(err, whatsapp.ErrLoginTimedOut) { ErrCode: "already logged in",
msg = "QR code scan timed out. Please try again." })
} else if errors.Is(err, whatsapp.ErrInvalidWebsocket) {
msg = "WhatsApp connection error. Please try again."
// TODO might need to make sure it reconnects?
} else if errors.Is(err, whatsapp.ErrMultiDeviceNotSupported) {
msg = "WhatsApp multi-device is not currently supported. Please disable it and try again."
} else { } 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()
} }

156
puppet.go
View file

@ -1,5 +1,5 @@
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge. // 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 // 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 // it under the terms of the GNU Affero General Public License as published by
@ -17,15 +17,19 @@
package main package main
import ( import (
"errors"
"fmt" "fmt"
"io"
"net/http" "net/http"
"regexp" "regexp"
"strings"
"sync" "sync"
"time"
"github.com/Rhymen/go-whatsapp" "go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/types"
log "maunium.net/go/maulogger/v2" log "maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix/appservice" "maunium.net/go/mautrix/appservice"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
@ -34,19 +38,18 @@ import (
var userIDRegex *regexp.Regexp 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 { if userIDRegex == nil {
userIDRegex = regexp.MustCompile(fmt.Sprintf("^@%s:%s$", userIDRegex = regexp.MustCompile(fmt.Sprintf("^@%s:%s$",
bridge.Config.Bridge.FormatUsername("([0-9]+)"), bridge.Config.Bridge.FormatUsername("([0-9]+)"),
bridge.Config.Homeserver.Domain)) bridge.Config.Homeserver.Domain))
} }
match := userIDRegex.FindStringSubmatch(string(mxid)) match := userIDRegex.FindStringSubmatch(string(mxid))
if match == nil || len(match) != 2 { if len(match) == 2 {
return "", false jid = types.NewJID(match[1], types.DefaultUserServer)
ok = true
} }
return
jid := whatsapp.JID(match[1] + whatsapp.NewUserSuffix)
return jid, true
} }
func (bridge *Bridge) GetPuppetByMXID(mxid id.UserID) *Puppet { func (bridge *Bridge) GetPuppetByMXID(mxid id.UserID) *Puppet {
@ -58,7 +61,13 @@ func (bridge *Bridge) GetPuppetByMXID(mxid id.UserID) *Puppet {
return bridge.GetPuppetByJID(jid) 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() bridge.puppetsLock.Lock()
defer bridge.puppetsLock.Unlock() defer bridge.puppetsLock.Unlock()
puppet, ok := bridge.puppets[jid] puppet, ok := bridge.puppets[jid]
@ -123,12 +132,9 @@ func (bridge *Bridge) dbPuppetsToPuppets(dbPuppets []*database.Puppet) []*Puppet
return output return output
} }
func (bridge *Bridge) FormatPuppetMXID(jid whatsapp.JID) id.UserID { func (bridge *Bridge) FormatPuppetMXID(jid types.JID) id.UserID {
return id.NewUserID( return id.NewUserID(
bridge.Config.Bridge.FormatUsername( bridge.Config.Bridge.FormatUsername(jid.User),
strings.Replace(
jid,
whatsapp.NewUserSuffix, "", 1)),
bridge.Config.Homeserver.Domain) bridge.Config.Homeserver.Domain)
} }
@ -149,7 +155,7 @@ type Puppet struct {
log log.Logger log log.Logger
typingIn id.RoomID typingIn id.RoomID
typingAt int64 typingAt time.Time
MXID id.UserID MXID id.UserID
@ -160,14 +166,8 @@ type Puppet struct {
syncLock sync.Mutex syncLock sync.Mutex
} }
func (puppet *Puppet) PhoneNumber() string {
return strings.Replace(puppet.JID, whatsapp.NewUserSuffix, "", 1)
}
func (puppet *Puppet) IntentFor(portal *Portal) *appservice.IntentAPI { func (puppet *Puppet) IntentFor(portal *Portal) *appservice.IntentAPI {
if (!portal.IsPrivateChat() && puppet.customIntent == nil) || if (!portal.IsPrivateChat() && puppet.customIntent == nil) || portal.Key.JID == puppet.JID {
(portal.backfilling && portal.bridge.Config.Bridge.InviteOwnPuppetForBackfilling) ||
portal.Key.JID == puppet.JID {
return puppet.DefaultIntent() return puppet.DefaultIntent()
} }
return puppet.customIntent return puppet.customIntent
@ -181,63 +181,64 @@ func (puppet *Puppet) DefaultIntent() *appservice.IntentAPI {
return puppet.bridge.AS.Intent(puppet.MXID) return puppet.bridge.AS.Intent(puppet.MXID)
} }
func (puppet *Puppet) UpdateAvatar(source *User, avatar *whatsapp.ProfilePicInfo) bool { func reuploadAvatar(intent *appservice.IntentAPI, url string) (id.ContentURI, error) {
if avatar == nil { getResp, err := http.DefaultClient.Get(url)
var err error
avatar, err = source.Conn.GetProfilePicThumb(puppet.JID)
if err != nil {
puppet.log.Warnln("Failed to get avatar:", err)
return false
}
}
if avatar.Status == 404 {
avatar.Tag = "remove"
avatar.Status = 0
} else if avatar.Status == 401 && puppet.Avatar != "unauthorized" {
puppet.Avatar = "unauthorized"
return true
}
if avatar.Status != 0 || avatar.Tag == puppet.Avatar {
return false
}
if avatar.Tag == "remove" || len(avatar.URL) == 0 {
err := puppet.DefaultIntent().SetAvatarURL(id.ContentURI{})
if err != nil {
puppet.log.Warnln("Failed to remove avatar:", err)
}
puppet.AvatarURL = id.ContentURI{}
puppet.Avatar = avatar.Tag
go puppet.updatePortalAvatar()
return true
}
data, err := avatar.DownloadBytes()
if err != nil { if err != nil {
puppet.log.Warnln("Failed to download avatar:", err) return id.ContentURI{}, fmt.Errorf("failed to download avatar: %w", err)
return false }
data, err := io.ReadAll(getResp.Body)
_ = getResp.Body.Close()
if err != nil {
return id.ContentURI{}, fmt.Errorf("failed to read avatar bytes: %w", err)
} }
mime := http.DetectContentType(data) mime := http.DetectContentType(data)
resp, err := puppet.DefaultIntent().UploadBytes(data, mime) resp, err := intent.UploadBytes(data, mime)
if err != nil { if err != nil {
puppet.log.Warnln("Failed to upload avatar:", err) return id.ContentURI{}, fmt.Errorf("failed to upload avatar to Matrix: %w", err)
}
return resp.ContentURI, nil
}
func (puppet *Puppet) UpdateAvatar(source *User) bool {
avatar, err := source.Client.GetProfilePictureInfo(puppet.JID, false)
if err != nil {
if !errors.Is(err, whatsmeow.ErrProfilePictureUnauthorized) {
puppet.log.Warnln("Failed to get avatar URL:", err)
}
return false 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) err = puppet.DefaultIntent().SetAvatarURL(puppet.AvatarURL)
if err != nil { if err != nil {
puppet.log.Warnln("Failed to set avatar:", err) puppet.log.Warnln("Failed to set avatar:", err)
} }
puppet.Avatar = avatar.Tag puppet.Avatar = avatar.ID
go puppet.updatePortalAvatar() go puppet.updatePortalAvatar()
return true return true
} }
func (puppet *Puppet) UpdateName(source *User, contact whatsapp.Contact) bool { func (puppet *Puppet) UpdateName(source *User, contact types.ContactInfo) bool {
newName, quality := puppet.bridge.Config.Bridge.FormatDisplayname(contact) newName, quality := puppet.bridge.Config.Bridge.FormatDisplayname(puppet.JID, contact)
if puppet.Displayname != newName && quality >= puppet.NameQuality { if puppet.Displayname != newName && quality >= puppet.NameQuality {
err := puppet.DefaultIntent().SetDisplayName(newName) err := puppet.DefaultIntent().SetDisplayName(newName)
if err == nil { if err == nil {
@ -288,25 +289,21 @@ func (puppet *Puppet) updatePortalName() {
}) })
} }
func (puppet *Puppet) SyncContactIfNecessary(source *User) { func (puppet *Puppet) SyncContact(source *User, onlyIfNoName bool) {
if len(puppet.Displayname) > 0 { if onlyIfNoName && len(puppet.Displayname) > 0 {
return return
} }
source.Conn.Store.ContactsLock.RLock() contact, err := source.Client.Store.Contacts.GetContact(puppet.JID)
contact, ok := source.Conn.Store.Contacts[puppet.JID] if err != nil {
source.Conn.Store.ContactsLock.RUnlock() puppet.log.Warnfln("Failed to get contact info through %s in SyncContact: %v", source.MXID)
if !ok { } else if !contact.Found {
puppet.log.Warnfln("No contact info found through %s in SyncContactIfNecessary", source.MXID) puppet.log.Warnfln("No contact info found through %s in SyncContact", 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)
} }
puppet.Sync(source, contact) 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() puppet.syncLock.Lock()
defer puppet.syncLock.Unlock() defer puppet.syncLock.Unlock()
err := puppet.DefaultIntent().EnsureRegistered() 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) puppet.log.Errorln("Failed to ensure registered:", err)
} }
if contact.JID == source.JID { if puppet.JID.User == source.JID.User {
contact.Notify = source.pushName contact.PushName = source.Client.Store.PushName
} }
update := false update := false
update = puppet.UpdateName(source, contact) || update 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 { if len(puppet.Avatar) == 0 || puppet.bridge.Config.Bridge.UserAvatarSync {
update = puppet.UpdateAvatar(source, nil) || update update = puppet.UpdateAvatar(source) || update
} }
if update { if update {
puppet.Update() puppet.Update()

1353
user.go

File diff suppressed because it is too large Load diff