Эх сурвалжийг харах

Add basic Matrix puppeting support

May contain bugs.
EDUs from /sync are not yet handled.
Tulir Asokan 6 жил өмнө
parent
commit
2c9c473040

+ 1 - 1
ROADMAP.md

@@ -59,7 +59,7 @@
     * [ ] When receiving invite<sup>[2]</sup>
     * [x] When receiving message
   * [ ] Private chat creation by inviting Matrix puppet of WhatsApp user to new room
-  * [ ] 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
 
 <sup>[1]</sup> May involve reverse-engineering the WhatsApp Web API and/or editing go-whatsapp  

+ 31 - 3
commands.go

@@ -18,10 +18,12 @@ package main
 
 import (
 	"fmt"
-	"github.com/Rhymen/go-whatsapp"
+	"strings"
+
 	"maunium.net/go/mautrix"
 	"maunium.net/go/mautrix/format"
-	"strings"
+
+	"github.com/Rhymen/go-whatsapp"
 
 	"maunium.net/go/maulogger/v2"
 	"maunium.net/go/mautrix-appservice"
@@ -80,6 +82,8 @@ func (handler *CommandHandler) Handle(roomID types.MatrixRoomID, user *User, mes
 	switch cmd {
 	case "login":
 		handler.CommandLogin(ce)
+	case "logout-matrix":
+		handler.CommandLogoutMatrix(ce)
 	case "help":
 		handler.CommandHelp(ce)
 	case "reconnect":
@@ -92,7 +96,7 @@ func (handler *CommandHandler) Handle(roomID types.MatrixRoomID, user *User, mes
 		handler.CommandDeleteSession(ce)
 	case "delete-portal":
 		handler.CommandDeletePortal(ce)
-	case "logout", "sync", "list", "open", "pm":
+	case "login-matrix", "logout", "sync", "list", "open", "pm":
 		if ce.User.Conn == nil {
 			ce.Reply("You are not logged in. Use the `login` command to log into WhatsApp.")
 			return
@@ -102,6 +106,8 @@ func (handler *CommandHandler) Handle(roomID types.MatrixRoomID, user *User, mes
 		}
 
 		switch cmd {
+		case "login-matrix":
+			handler.CommandLoginMatrix(ce)
 		case "logout":
 			handler.CommandLogout(ce)
 		case "sync":
@@ -433,3 +439,25 @@ func (handler *CommandHandler) CommandPM(ce *CommandEvent) {
 	}
 	ce.Reply("Created portal room and invited you to it.")
 }
+
+const cmdLoginMatrixHelp = `login-matrix <_access token_> - Replace your WhatsApp account's Matrix puppet with your real Matrix account.'`
+
+func (handler *CommandHandler) CommandLoginMatrix(ce *CommandEvent) {
+	if len(ce.Args) == 0 {
+		ce.Reply("**Usage:** `login-matrix <access token>`")
+		return
+	}
+	puppet := handler.bridge.GetPuppetByJID(ce.User.JID)
+	err := puppet.SwitchCustomMXID(ce.Args[0], ce.User.MXID)
+	if err != nil {
+		ce.Reply("Failed to switch puppet: %v", err)
+		return
+	}
+	ce.Reply("Successfully switched puppet")
+}
+
+const cmdLogoutMatrixHelp = `logout-matrix - Switch your WhatsApp account's Matrix puppet back to the default one.`
+
+func (handler *CommandHandler) CommandLogoutMatrix(ce *CommandEvent) {
+
+}

+ 4 - 0
config/bridge.go

@@ -43,6 +43,8 @@ type BridgeConfig struct {
 	RecoverHistory     bool   `yaml:"recovery_history_backfill"`
 	SyncChatMaxAge     uint64 `yaml:"sync_max_chat_age"`
 
+	SyncWithCustomPuppets bool `yaml:"sync_with_custom_puppets"`
+
 	CommandPrefix string `yaml:"command_prefix"`
 
 	Permissions PermissionConfig `yaml:"permissions"`
@@ -61,6 +63,8 @@ func (bc *BridgeConfig) setDefaults() {
 	bc.RecoverChatSync = -1
 	bc.RecoverHistory = true
 	bc.SyncChatMaxAge = 259200
+
+	bc.SyncWithCustomPuppets = true
 }
 
 type umBridgeConfig BridgeConfig

+ 168 - 0
custompuppet.go

@@ -0,0 +1,168 @@
+// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
+// Copyright (C) 2019 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 (
+	"encoding/json"
+	"fmt"
+	"strings"
+	"time"
+
+	"github.com/pkg/errors"
+	"maunium.net/go/mautrix"
+	"maunium.net/go/mautrix-appservice"
+)
+
+var (
+	ErrNoCustomMXID = errors.New("no custom mxid set")
+	ErrMismatchingMXID = errors.New("whoami result does not match custom mxid")
+)
+
+func (puppet *Puppet) SwitchCustomMXID(accessToken string, mxid string) error {
+	prevCustomMXID := puppet.CustomMXID
+	if puppet.customIntent != nil {
+		puppet.stopSyncing()
+	}
+	puppet.CustomMXID = mxid
+	puppet.AccessToken = accessToken
+
+	err := puppet.StartCustomMXID()
+	if err != nil {
+		return err
+	}
+
+	if len(prevCustomMXID) > 0 {
+		delete(puppet.bridge.puppetsByCustomMXID, prevCustomMXID)
+	}
+	if len(puppet.CustomMXID) > 0 {
+		puppet.bridge.puppetsByCustomMXID[puppet.CustomMXID] = puppet
+	}
+	puppet.Update()
+	// TODO leave rooms with default puppet
+	return nil
+}
+
+func (puppet *Puppet) newCustomIntent() (*appservice.IntentAPI, error) {
+	if len(puppet.CustomMXID) == 0 {
+		return nil, ErrNoCustomMXID
+	}
+	client, err := mautrix.NewClient(puppet.bridge.AS.HomeserverURL, puppet.CustomMXID, puppet.AccessToken)
+	if err != nil {
+		return nil, err
+	}
+	client.Store = puppet
+
+	ia := puppet.bridge.AS.NewIntentAPI("custom")
+	ia.Client = client
+	ia.Localpart = puppet.CustomMXID[1:strings.IndexRune(puppet.CustomMXID, ':')]
+	ia.UserID = puppet.CustomMXID
+	ia.IsCustomPuppet = true
+	return ia, nil
+}
+
+func (puppet *Puppet) StartCustomMXID() error {
+	if len(puppet.CustomMXID) == 0 {
+		return nil
+	}
+	intent, err := puppet.newCustomIntent()
+	if err != nil {
+		puppet.CustomMXID = ""
+		puppet.AccessToken = ""
+		return err
+	}
+	urlPath := intent.BuildURL("account", "whoami")
+	var resp struct{ UserID string `json:"user_id"` }
+	_, err = intent.MakeRequest("GET", urlPath, nil, &resp)
+	if err != nil {
+		puppet.CustomMXID = ""
+		puppet.AccessToken = ""
+		return err
+	}
+	if resp.UserID != puppet.CustomMXID {
+		puppet.CustomMXID = ""
+		puppet.AccessToken = ""
+		return ErrMismatchingMXID
+	}
+	puppet.customIntent = intent
+	puppet.startSyncing()
+	return nil
+}
+
+func (puppet *Puppet) startSyncing() {
+	if !puppet.bridge.Config.Bridge.SyncWithCustomPuppets {
+		return
+	}
+	go func() {
+		puppet.log.Debugln("Starting syncing...")
+		err := puppet.customIntent.Sync()
+		if err != nil {
+			puppet.log.Errorln("Fatal error syncing:", err)
+		}
+	}()
+}
+
+func (puppet *Puppet) stopSyncing() {
+	if !puppet.bridge.Config.Bridge.SyncWithCustomPuppets {
+		return
+	}
+	puppet.customIntent.StopSync()
+}
+
+func (puppet *Puppet) ProcessResponse(resp *mautrix.RespSync, since string) error {
+	puppet.log.Debugln("Sync data:", resp, since)
+	// TODO handle sync data
+	return nil
+}
+
+func (puppet *Puppet) OnFailedSync(res *mautrix.RespSync, err error) (time.Duration, error) {
+	puppet.log.Warnln("Sync error:", err)
+	return 10 * time.Second, nil
+}
+
+func (puppet *Puppet) GetFilterJSON(_ string) json.RawMessage {
+	mxid, _ := json.Marshal(puppet.CustomMXID)
+	return json.RawMessage(fmt.Sprintf(`{
+    "account_data": { "types": [] },
+    "presence": {
+        "senders": [
+            %s
+	    ],
+        "types": [
+            "m.presence"
+        ]
+    },
+    "room": {
+        "ephemeral": {
+            "types": [
+                "m.typing",
+                "m.receipt"
+            ]
+        },
+        "include_leave": false,
+        "account_data": { "types": [] },
+        "state": { "types": [] },
+        "timeline": { "types": [] }
+    }
+}`, mxid))
+}
+
+func (puppet *Puppet) SaveFilterID(_, _ string)             {}
+func (puppet *Puppet) SaveNextBatch(_, nbt string)          { puppet.NextBatch = nbt }
+func (puppet *Puppet) SaveRoom(room *mautrix.Room)          {}
+func (puppet *Puppet) LoadFilterID(_ string) string         { return "" }
+func (puppet *Puppet) LoadNextBatch(_ string) string        { return puppet.NextBatch }
+func (puppet *Puppet) LoadRoom(roomID string) *mautrix.Room { return nil }

+ 33 - 6
database/puppet.go

@@ -56,6 +56,26 @@ func (pq *PuppetQuery) Get(jid types.WhatsAppID) *Puppet {
 	return pq.New().Scan(row)
 }
 
+func (pq *PuppetQuery) GetByCustomMXID(mxid types.MatrixUserID) *Puppet {
+	row := pq.db.QueryRow("SELECT * FROM puppet WHERE custom_mxid=$1", mxid)
+	if row == nil {
+		return nil
+	}
+	return pq.New().Scan(row)
+}
+
+func (pq *PuppetQuery) GetAllWithCustomMXID() (puppets []*Puppet) {
+	rows, err := pq.db.Query("SELECT * FROM puppet WHERE custom_mxid<>''")
+	if err != nil || rows == nil {
+		return nil
+	}
+	defer rows.Close()
+	for rows.Next() {
+		puppets = append(puppets, pq.New().Scan(rows))
+	}
+	return
+}
+
 type Puppet struct {
 	db  *Database
 	log log.Logger
@@ -64,12 +84,16 @@ type Puppet struct {
 	Avatar      string
 	Displayname string
 	NameQuality int8
+
+	CustomMXID  string
+	AccessToken string
+	NextBatch   string
 }
 
 func (puppet *Puppet) Scan(row Scannable) *Puppet {
-	var displayname, avatar sql.NullString
+	var displayname, avatar, customMXID, accessToken, nextBatch sql.NullString
 	var quality sql.NullInt64
-	err := row.Scan(&puppet.JID, &avatar, &displayname, &quality)
+	err := row.Scan(&puppet.JID, &avatar, &displayname, &quality, &customMXID, &accessToken, &nextBatch)
 	if err != nil {
 		if err != sql.ErrNoRows {
 			puppet.log.Errorln("Database scan failed:", err)
@@ -79,20 +103,23 @@ func (puppet *Puppet) Scan(row Scannable) *Puppet {
 	puppet.Displayname = displayname.String
 	puppet.Avatar = avatar.String
 	puppet.NameQuality = int8(quality.Int64)
+	puppet.CustomMXID = customMXID.String
+	puppet.AccessToken = accessToken.String
+	puppet.NextBatch = nextBatch.String
 	return puppet
 }
 
 func (puppet *Puppet) Insert() {
-	_, err := puppet.db.Exec("INSERT INTO puppet VALUES ($1, $2, $3, $4)",
-		puppet.JID, puppet.Avatar, puppet.Displayname, puppet.NameQuality)
+	_, err := puppet.db.Exec("INSERT INTO puppet VALUES ($1, $2, $3, $4, $5, $6, $7)",
+		puppet.JID, puppet.Avatar, puppet.Displayname, puppet.NameQuality, puppet.CustomMXID, puppet.AccessToken, puppet.NextBatch)
 	if err != nil {
 		puppet.log.Warnfln("Failed to insert %s: %v", puppet.JID, err)
 	}
 }
 
 func (puppet *Puppet) Update() {
-	_, err := puppet.db.Exec("UPDATE puppet SET displayname=$1, name_quality=$2, avatar=$3 WHERE jid=$4",
-		puppet.Displayname, puppet.NameQuality, puppet.Avatar, puppet.JID)
+	_, err := puppet.db.Exec("UPDATE puppet SET displayname=$1, name_quality=$2, avatar=$3, custom_mxid=$4, access_token=$5, next_batch=$6 WHERE jid=$7",
+		puppet.Displayname, puppet.NameQuality, puppet.Avatar, puppet.CustomMXID, puppet.AccessToken, puppet.NextBatch, puppet.JID)
 	if err != nil {
 		puppet.log.Warnfln("Failed to update %s->%s: %v", puppet.JID, err)
 	}

+ 23 - 0
database/upgrades/2019-05-23-puppet-custom-mxid-columns.go

@@ -0,0 +1,23 @@
+package upgrades
+
+import (
+	"database/sql"
+)
+
+func init() {
+	upgrades[5] = upgrade{"Add columns to store custom puppet info", func(dialect Dialect, tx *sql.Tx, db *sql.DB) error {
+		_, err := tx.Exec(`ALTER TABLE puppet ADD COLUMN custom_mxid VARCHAR(255)`)
+		if err != nil {
+			return err
+		}
+		_, err = tx.Exec(`ALTER TABLE puppet ADD COLUMN access_token VARCHAR(1023)`)
+		if err != nil {
+			return err
+		}
+		_, err = tx.Exec(`ALTER TABLE puppet ADD COLUMN next_batch VARCHAR(255)`)
+		if err != nil {
+			return err
+		}
+		return nil
+	}}
+}

+ 4 - 2
database/upgrades/upgrades.go

@@ -22,7 +22,9 @@ type upgrade struct {
 	fn upgradeFunc
 }
 
-var upgrades [5]upgrade
+const NumberOfUpgrades = 6
+
+var upgrades [NumberOfUpgrades]upgrade
 
 func getVersion(dialect Dialect, db *sql.DB) (int, error) {
 	_, err := db.Exec("CREATE TABLE IF NOT EXISTS version (version INTEGER)")
@@ -63,7 +65,7 @@ func Run(log log.Logger, dialectName string, db *sql.DB) error {
 		return err
 	}
 
-	log.Infofln("Database currently on v%d, latest: v%d", version, len(upgrades))
+	log.Infofln("Database currently on v%d, latest: v%d", version, NumberOfUpgrades)
 	for i, upgrade := range upgrades[version:] {
 		log.Infofln("Upgrading database to v%d: %s", version+i+1, upgrade.message)
 		tx, err := db.Begin()

+ 4 - 0
example-config.yaml

@@ -81,6 +81,10 @@ bridge:
     # Default is 3 days = 259200 seconds
     sync_max_chat_age: 259200
 
+    # Whether or not to sync with custom puppets to receive EDUs that
+    # are not normally sent to appservices.
+    sync_with_custom_puppets: true
+
     # The prefix for commands. Only required in non-management rooms.
     command_prefix: "!wa"
 

+ 4 - 2
go.mod

@@ -8,6 +8,7 @@ require (
 	github.com/lib/pq v1.1.1
 	github.com/mattn/go-isatty v0.0.8 // indirect
 	github.com/mattn/go-sqlite3 v1.10.0
+	github.com/pkg/errors v0.8.1
 	github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
 	github.com/skip2/go-qrcode v0.0.0-20190110000554-dc11ecdae0a9
 	golang.org/x/net v0.0.0-20190522155817-f3200d17e092 // indirect
@@ -17,9 +18,10 @@ require (
 	maunium.net/go/mauflag v1.0.0
 	maunium.net/go/maulogger/v2 v2.0.0
 	maunium.net/go/mautrix v0.1.0-alpha.3.0.20190515215109-3e27638f3f1d
-	maunium.net/go/mautrix-appservice v0.1.0-alpha.3.0.20190515184712-aecd1f0cca6f
+	maunium.net/go/mautrix-appservice v0.1.0-alpha.3.0.20190523231710-8b9923f4ca89
 )
 
 replace gopkg.in/russross/blackfriday.v2 => github.com/russross/blackfriday/v2 v2.0.1
 
-replace github.com/Rhymen/go-whatsapp => github.com/tulir/go-whatsapp v0.0.2-0.20190523194501-cc7603f853df
+//replace github.com/Rhymen/go-whatsapp => github.com/tulir/go-whatsapp v0.0.2-0.20190523194501-cc7603f853df
+replace github.com/Rhymen/go-whatsapp => ../../Go/go-whatsapp

+ 18 - 6
main.go

@@ -80,17 +80,19 @@ type Bridge struct {
 	portalsByJID        map[database.PortalKey]*Portal
 	portalsLock         sync.Mutex
 	puppets             map[types.WhatsAppID]*Puppet
+	puppetsByCustomMXID map[types.MatrixUserID]*Puppet
 	puppetsLock         sync.Mutex
 }
 
 func NewBridge() *Bridge {
 	bridge := &Bridge{
-		usersByMXID:     make(map[types.MatrixUserID]*User),
-		usersByJID:      make(map[types.WhatsAppID]*User),
-		managementRooms: make(map[types.MatrixRoomID]*User),
-		portalsByMXID:   make(map[types.MatrixRoomID]*Portal),
-		portalsByJID:    make(map[database.PortalKey]*Portal),
-		puppets:         make(map[types.WhatsAppID]*Puppet),
+		usersByMXID:         make(map[types.MatrixUserID]*User),
+		usersByJID:          make(map[types.WhatsAppID]*User),
+		managementRooms:     make(map[types.MatrixRoomID]*User),
+		portalsByMXID:       make(map[types.MatrixRoomID]*Portal),
+		portalsByJID:        make(map[database.PortalKey]*Portal),
+		puppets:             make(map[types.WhatsAppID]*Puppet),
+		puppetsByCustomMXID: make(map[types.MatrixUserID]*Puppet),
 	}
 
 	var err error
@@ -192,6 +194,16 @@ func (bridge *Bridge) StartUsers() {
 	for _, user := range bridge.GetAllUsers() {
 		go user.Connect(false)
 	}
+	bridge.Log.Debugln("Starting custom puppets")
+	for _, puppet := range bridge.GetAllPuppetsWithCustomMXID() {
+		go func() {
+			puppet.log.Debugln("Starting custom puppet", puppet.CustomMXID)
+			err := puppet.StartCustomMXID()
+			if err != nil {
+				puppet.log.Errorln("Failed to start custom puppet:", err)
+			}
+		}()
+	}
 }
 
 func (bridge *Bridge) Stop() {

+ 4 - 0
matrix.go

@@ -166,6 +166,10 @@ func (mx *MatrixHandler) HandleMessage(evt *mautrix.Event) {
 	if _, isPuppet := mx.bridge.ParsePuppetMXID(evt.Sender); evt.Sender == mx.bridge.Bot.UserID || isPuppet {
 		return
 	}
+	isCustomPuppet, ok := evt.Content.Raw["net.maunium.whatsapp.puppet"].(bool)
+	if ok && isCustomPuppet && mx.bridge.GetPuppetByCustomMXID(evt.Sender) != nil {
+		return
+	}
 
 	roomID := types.MatrixRoomID(evt.RoomID)
 	user := mx.bridge.GetUserByMXID(types.MatrixUserID(evt.Sender))

+ 28 - 21
portal.go

@@ -281,12 +281,6 @@ func (portal *Portal) SyncParticipants(metadata *whatsappExt.GroupInfo) {
 		changed = true
 	}
 	for _, participant := range metadata.Participants {
-		puppet := portal.bridge.GetPuppetByJID(participant.JID)
-		err := puppet.Intent().EnsureJoined(portal.MXID)
-		if err != nil {
-			portal.log.Warnfln("Failed to make puppet of %s join %s: %v", participant.JID, portal.MXID, err)
-		}
-
 		user := portal.bridge.GetUserByJID(participant.JID)
 		if user != nil && !portal.bridge.AS.StateStore.IsInvited(portal.MXID, user.MXID) {
 			_, err = portal.MainIntent().InviteUser(portal.MXID, &mautrix.ReqInviteUser{
@@ -297,6 +291,12 @@ func (portal *Portal) SyncParticipants(metadata *whatsappExt.GroupInfo) {
 			}
 		}
 
+		puppet := portal.bridge.GetPuppetByJID(participant.JID)
+		err := puppet.IntentFor(portal).EnsureJoined(portal.MXID)
+		if err != nil {
+			portal.log.Warnfln("Failed to make puppet of %s join %s: %v", participant.JID, portal.MXID, err)
+		}
+
 		expectedLevel := 0
 		if participant.IsSuperAdmin {
 			expectedLevel = 95
@@ -363,7 +363,7 @@ func (portal *Portal) UpdateName(name string, setBy types.WhatsAppID) bool {
 	if portal.Name != name {
 		intent := portal.MainIntent()
 		if len(setBy) > 0 {
-			intent = portal.bridge.GetPuppetByJID(setBy).Intent()
+			intent = portal.bridge.GetPuppetByJID(setBy).IntentFor(portal)
 		}
 		_, err := intent.SetRoomName(portal.MXID, name)
 		if err == nil {
@@ -379,7 +379,7 @@ func (portal *Portal) UpdateTopic(topic string, setBy types.WhatsAppID) bool {
 	if portal.Topic != topic {
 		intent := portal.MainIntent()
 		if len(setBy) > 0 {
-			intent = portal.bridge.GetPuppetByJID(setBy).Intent()
+			intent = portal.bridge.GetPuppetByJID(setBy).IntentFor(portal)
 		}
 		_, err := intent.SetRoomTopic(portal.MXID, topic)
 		if err == nil {
@@ -719,7 +719,7 @@ func (portal *Portal) IsStatusBroadcastRoom() bool {
 
 func (portal *Portal) MainIntent() *appservice.IntentAPI {
 	if portal.IsPrivateChat() {
-		return portal.bridge.GetPuppetByJID(portal.Key.JID).Intent()
+		return portal.bridge.GetPuppetByJID(portal.Key.JID).DefaultIntent()
 	}
 	return portal.bridge.Bot
 }
@@ -727,10 +727,9 @@ func (portal *Portal) MainIntent() *appservice.IntentAPI {
 func (portal *Portal) GetMessageIntent(user *User, info whatsapp.MessageInfo) *appservice.IntentAPI {
 	if info.FromMe {
 		if portal.IsPrivateChat() {
-			// TODO handle own messages in private chats properly
-			return nil
+			return portal.bridge.GetPuppetByJID(user.JID).CustomIntent()
 		}
-		return portal.bridge.GetPuppetByJID(user.JID).Intent()
+		return portal.bridge.GetPuppetByJID(user.JID).IntentFor(portal)
 	} else if portal.IsPrivateChat() {
 		return portal.MainIntent()
 	} else if len(info.SenderJid) == 0 {
@@ -740,7 +739,7 @@ func (portal *Portal) GetMessageIntent(user *User, info whatsapp.MessageInfo) *a
 			return nil
 		}
 	}
-	return portal.bridge.GetPuppetByJID(info.SenderJid).Intent()
+	return portal.bridge.GetPuppetByJID(info.SenderJid).IntentFor(portal)
 }
 
 func (portal *Portal) SetReply(content *mautrix.Content, info whatsapp.MessageInfo) {
@@ -765,15 +764,18 @@ func (portal *Portal) HandleMessageRevoke(user *User, message whatsappExt.Messag
 	if msg == nil {
 		return
 	}
-	intent := portal.MainIntent()
+	var intent *appservice.IntentAPI
 	if message.FromMe {
 		if portal.IsPrivateChat() {
-			// TODO handle
+			intent = portal.bridge.GetPuppetByJID(user.JID).CustomIntent()
 		} else {
-			intent = portal.bridge.GetPuppetByJID(user.JID).Intent()
+			intent = portal.bridge.GetPuppetByJID(user.JID).IntentFor(portal)
 		}
 	} else if len(message.Participant) > 0 {
-		intent = portal.bridge.GetPuppetByJID(message.Participant).Intent()
+		intent = portal.bridge.GetPuppetByJID(message.Participant).IntentFor(portal)
+	}
+	if intent == nil {
+		intent = portal.MainIntent()
 	}
 	_, err := intent.RedactEvent(portal.MXID, msg.MXID)
 	if err != nil {
@@ -783,6 +785,11 @@ func (portal *Portal) HandleMessageRevoke(user *User, message whatsappExt.Messag
 	msg.Delete()
 }
 
+type MessageContent struct {
+	*mautrix.Content
+	IsCustomPuppet bool `json:"net.maunium.whatsapp.puppet,omitempty"`
+}
+
 func (portal *Portal) HandleTextMessage(source *User, message whatsapp.TextMessage) {
 	if len(portal.MXID) == 0 {
 		return
@@ -808,7 +815,7 @@ func (portal *Portal) HandleTextMessage(source *User, message whatsapp.TextMessa
 	portal.SetReply(content, message.Info)
 
 	_, _ = intent.UserTyping(portal.MXID, false, 0)
-	resp, err := intent.SendMassagedMessageEvent(portal.MXID, mautrix.EventMessage, content, int64(message.Info.Timestamp*1000))
+	resp, err := intent.SendMassagedMessageEvent(portal.MXID, mautrix.EventMessage, &MessageContent{content, intent.IsCustomPuppet}, int64(message.Info.Timestamp*1000))
 	if err != nil {
 		portal.log.Errorfln("Failed to handle message %s: %v", message.Info.Id, err)
 		return
@@ -891,7 +898,7 @@ func (portal *Portal) HandleMediaMessage(source *User, download func() ([]byte,
 
 	_, _ = intent.UserTyping(portal.MXID, false, 0)
 	ts := int64(info.Timestamp * 1000)
-	resp, err := intent.SendMassagedMessageEvent(portal.MXID, mautrix.EventMessage, content, ts)
+	resp, err := intent.SendMassagedMessageEvent(portal.MXID, mautrix.EventMessage, &MessageContent{content, intent.IsCustomPuppet}, ts)
 	if err != nil {
 		portal.log.Errorfln("Failed to handle message %s: %v", info.Id, err)
 		return
@@ -905,7 +912,7 @@ func (portal *Portal) HandleMediaMessage(source *User, download func() ([]byte,
 
 		portal.bridge.Formatter.ParseWhatsApp(captionContent)
 
-		_, err := intent.SendMassagedMessageEvent(portal.MXID, mautrix.EventMessage, captionContent, ts)
+		_, err := intent.SendMassagedMessageEvent(portal.MXID, mautrix.EventMessage, &MessageContent{captionContent, intent.IsCustomPuppet}, ts)
 		if err != nil {
 			portal.log.Warnfln("Failed to handle caption of message %s: %v", info.Id, err)
 		}
@@ -1198,7 +1205,7 @@ func (portal *Portal) Cleanup(puppetsOnly bool) {
 		}
 		puppet := portal.bridge.GetPuppetByMXID(member)
 		if puppet != nil {
-			_, err = puppet.Intent().LeaveRoom(portal.MXID)
+			_, err = puppet.DefaultIntent().LeaveRoom(portal.MXID)
 			if err != nil {
 				portal.log.Errorln("Error leaving as puppet while cleaning up portal:", err)
 			}

+ 49 - 7
puppet.go

@@ -71,20 +71,49 @@ func (bridge *Bridge) GetPuppetByJID(jid types.WhatsAppID) *Puppet {
 		}
 		puppet = bridge.NewPuppet(dbPuppet)
 		bridge.puppets[puppet.JID] = puppet
+		if len(puppet.CustomMXID) > 0 {
+			bridge.puppetsByCustomMXID[puppet.CustomMXID] = puppet
+		}
+	}
+	return puppet
+}
+
+func (bridge *Bridge) GetPuppetByCustomMXID(mxid types.MatrixUserID) *Puppet {
+	bridge.puppetsLock.Lock()
+	defer bridge.puppetsLock.Unlock()
+	puppet, ok := bridge.puppetsByCustomMXID[mxid]
+	if !ok {
+		dbPuppet := bridge.DB.Puppet.GetByCustomMXID(mxid)
+		if dbPuppet == nil {
+			return nil
+		}
+		puppet = bridge.NewPuppet(dbPuppet)
+		bridge.puppets[puppet.JID] = puppet
+		bridge.puppetsByCustomMXID[puppet.CustomMXID] = puppet
 	}
 	return puppet
 }
 
+func (bridge *Bridge) GetAllPuppetsWithCustomMXID() []*Puppet {
+	return bridge.dbPuppetsToPuppets(bridge.DB.Puppet.GetAllWithCustomMXID())
+}
+
 func (bridge *Bridge) GetAllPuppets() []*Puppet {
+	return bridge.dbPuppetsToPuppets(bridge.DB.Puppet.GetAll())
+}
+
+func (bridge *Bridge) dbPuppetsToPuppets(dbPuppets []*database.Puppet) []*Puppet {
 	bridge.puppetsLock.Lock()
 	defer bridge.puppetsLock.Unlock()
-	dbPuppets := bridge.DB.Puppet.GetAll()
 	output := make([]*Puppet, len(dbPuppets))
 	for index, dbPuppet := range dbPuppets {
 		puppet, ok := bridge.puppets[dbPuppet.JID]
 		if !ok {
 			puppet = bridge.NewPuppet(dbPuppet)
 			bridge.puppets[dbPuppet.JID] = puppet
+			if len(dbPuppet.CustomMXID) > 0  {
+				bridge.puppetsByCustomMXID[dbPuppet.CustomMXID] = puppet
+			}
 		}
 		output[index] = puppet
 	}
@@ -116,13 +145,26 @@ type Puppet struct {
 	typingAt int64
 
 	MXID types.MatrixUserID
+
+	customIntent *appservice.IntentAPI
 }
 
 func (puppet *Puppet) PhoneNumber() string {
 	return strings.Replace(puppet.JID, whatsappExt.NewUserSuffix, "", 1)
 }
 
-func (puppet *Puppet) Intent() *appservice.IntentAPI {
+func (puppet *Puppet) IntentFor(portal *Portal) *appservice.IntentAPI {
+	if puppet.customIntent == nil || portal.Key.JID == puppet.JID{
+		return puppet.DefaultIntent()
+	}
+	return puppet.customIntent
+}
+
+func (puppet *Puppet) CustomIntent() *appservice.IntentAPI {
+	return puppet.customIntent
+}
+
+func (puppet *Puppet) DefaultIntent() *appservice.IntentAPI {
 	return puppet.bridge.AS.Intent(puppet.MXID)
 }
 
@@ -145,7 +187,7 @@ func (puppet *Puppet) UpdateAvatar(source *User, avatar *whatsappExt.ProfilePicI
 	}
 
 	if len(avatar.URL) == 0 {
-		err := puppet.Intent().SetAvatarURL("")
+		err := puppet.DefaultIntent().SetAvatarURL("")
 		if err != nil {
 			puppet.log.Warnln("Failed to remove avatar:", err)
 		}
@@ -160,13 +202,13 @@ func (puppet *Puppet) UpdateAvatar(source *User, avatar *whatsappExt.ProfilePicI
 	}
 
 	mime := http.DetectContentType(data)
-	resp, err := puppet.Intent().UploadBytes(data, mime)
+	resp, err := puppet.DefaultIntent().UploadBytes(data, mime)
 	if err != nil {
 		puppet.log.Warnln("Failed to upload avatar:", err)
 		return false
 	}
 
-	err = puppet.Intent().SetAvatarURL(resp.ContentURI)
+	err = puppet.DefaultIntent().SetAvatarURL(resp.ContentURI)
 	if err != nil {
 		puppet.log.Warnln("Failed to set avatar:", err)
 	}
@@ -175,7 +217,7 @@ func (puppet *Puppet) UpdateAvatar(source *User, avatar *whatsappExt.ProfilePicI
 }
 
 func (puppet *Puppet) Sync(source *User, contact whatsapp.Contact) {
-	err := puppet.Intent().EnsureRegistered()
+	err := puppet.DefaultIntent().EnsureRegistered()
 	if err != nil {
 		puppet.log.Errorln("Failed to ensure registered:", err)
 	}
@@ -185,7 +227,7 @@ func (puppet *Puppet) Sync(source *User, contact whatsapp.Contact) {
 	}
 	newName, quality := puppet.bridge.Config.Bridge.FormatDisplayname(contact)
 	if puppet.Displayname != newName && quality >= puppet.NameQuality {
-		err := puppet.Intent().SetDisplayName(newName)
+		err := puppet.DefaultIntent().SetDisplayName(newName)
 		if err == nil {
 			puppet.Displayname = newName
 			puppet.NameQuality = quality

+ 7 - 6
user.go

@@ -465,14 +465,15 @@ func (user *User) HandlePresence(info whatsappExt.Presence) {
 	puppet := user.bridge.GetPuppetByJID(info.SenderJID)
 	switch info.Status {
 	case whatsappExt.PresenceUnavailable:
-		puppet.Intent().SetPresence("offline")
+		_ = puppet.DefaultIntent().SetPresence("offline")
 	case whatsappExt.PresenceAvailable:
 		if len(puppet.typingIn) > 0 && puppet.typingAt+15 > time.Now().Unix() {
-			puppet.Intent().UserTyping(puppet.typingIn, false, 0)
+			portal := user.bridge.GetPortalByMXID(puppet.typingIn)
+			_, _ = puppet.IntentFor(portal).UserTyping(puppet.typingIn, false, 0)
 			puppet.typingIn = ""
 			puppet.typingAt = 0
 		} else {
-			puppet.Intent().SetPresence("online")
+			_ = puppet.DefaultIntent().SetPresence("online")
 		}
 	case whatsappExt.PresenceComposing:
 		portal := user.GetPortalByJID(info.JID)
@@ -480,11 +481,11 @@ func (user *User) HandlePresence(info whatsappExt.Presence) {
 			if puppet.typingIn == portal.MXID {
 				return
 			}
-			puppet.Intent().UserTyping(puppet.typingIn, false, 0)
+			_, _ = puppet.IntentFor(portal).UserTyping(puppet.typingIn, false, 0)
 		}
 		puppet.typingIn = portal.MXID
 		puppet.typingAt = time.Now().Unix()
-		puppet.Intent().UserTyping(portal.MXID, true, 15*1000)
+		_, _ = puppet.IntentFor(portal).UserTyping(portal.MXID, true, 15*1000)
 	}
 }
 
@@ -496,7 +497,7 @@ func (user *User) HandleMsgInfo(info whatsappExt.MsgInfo) {
 		}
 
 		go func() {
-			intent := user.bridge.GetPuppetByJID(info.SenderJID).Intent()
+			intent := user.bridge.GetPuppetByJID(info.SenderJID).IntentFor(portal)
 			for _, id := range info.IDs {
 				msg := user.bridge.DB.Message.GetByJID(portal.Key, id)
 				if msg == nil {