浏览代码

Add basic relaybot support. Fixes #20

Tulir Asokan 5 年之前
父节点
当前提交
03d42640fe

+ 38 - 8
commands.go

@@ -20,13 +20,13 @@ import (
 	"fmt"
 	"strings"
 
-	"maunium.net/go/mautrix"
-	"maunium.net/go/mautrix/format"
-
 	"github.com/Rhymen/go-whatsapp"
 
 	"maunium.net/go/maulogger/v2"
+
+	"maunium.net/go/mautrix"
 	"maunium.net/go/mautrix-appservice"
+	"maunium.net/go/mautrix/format"
 
 	"maunium.net/go/mautrix-whatsapp/database"
 	"maunium.net/go/mautrix-whatsapp/types"
@@ -53,6 +53,7 @@ type CommandEvent struct {
 	Handler *CommandHandler
 	RoomID  types.MatrixRoomID
 	User    *User
+	Command string
 	Args    []string
 }
 
@@ -60,7 +61,11 @@ type CommandEvent struct {
 func (ce *CommandEvent) Reply(msg string, args ...interface{}) {
 	content := format.RenderMarkdown(fmt.Sprintf(msg, args...))
 	content.MsgType = mautrix.MsgNotice
-	_, err := ce.Bot.SendMessageEvent(ce.User.ManagementRoom, mautrix.EventMessage, content)
+	room := ce.User.ManagementRoom
+	if len(room) == 0 {
+		room = ce.RoomID
+	}
+	_, err := ce.Bot.SendMessageEvent(room, mautrix.EventMessage, content)
 	if err != nil {
 		ce.Handler.log.Warnfln("Failed to reply to command from %s: %v", ce.User.MXID, err)
 	}
@@ -69,17 +74,27 @@ func (ce *CommandEvent) Reply(msg string, args ...interface{}) {
 // Handle handles messages to the bridge
 func (handler *CommandHandler) Handle(roomID types.MatrixRoomID, user *User, message string) {
 	args := strings.Split(message, " ")
-	cmd := strings.ToLower(args[0])
 	ce := &CommandEvent{
 		Bot:     handler.bridge.Bot,
 		Bridge:  handler.bridge,
 		Handler: handler,
 		RoomID:  roomID,
 		User:    user,
+		Command: strings.ToLower(args[0]),
 		Args:    args[1:],
 	}
 	handler.log.Debugfln("%s sent '%s' in %s", user.MXID, message, roomID)
-	switch cmd {
+	if roomID == handler.bridge.Config.Bridge.Relaybot.ManagementRoom {
+		handler.CommandRelaybot(ce)
+	} else {
+		handler.CommandMux(ce)
+	}
+}
+
+func (handler *CommandHandler) CommandMux(ce *CommandEvent) {
+	switch ce.Command {
+	case "relaybot":
+		handler.CommandRelaybot(ce)
 	case "login":
 		handler.CommandLogin(ce)
 	case "logout-matrix":
@@ -111,7 +126,7 @@ func (handler *CommandHandler) Handle(roomID types.MatrixRoomID, user *User, mes
 			return
 		}
 
-		switch cmd {
+		switch ce.Command {
 		case "login-matrix":
 			handler.CommandLoginMatrix(ce)
 		case "logout":
@@ -130,6 +145,21 @@ func (handler *CommandHandler) Handle(roomID types.MatrixRoomID, user *User, mes
 	}
 }
 
+func (handler *CommandHandler) CommandRelaybot(ce *CommandEvent) {
+	if handler.bridge.Relaybot == nil {
+		ce.Reply("The relaybot is disabled")
+	} else if !ce.User.Admin {
+		ce.Reply("Only admins can manage the relaybot")
+	} else {
+		if ce.Command == "relaybot" {
+			ce.Command = strings.ToLower(ce.Args[0])
+			ce.Args = ce.Args[1:]
+		}
+		ce.User = handler.bridge.Relaybot
+		handler.CommandMux(ce)
+	}
+}
+
 func (handler *CommandHandler) CommandDevTest(ce *CommandEvent) {
 
 }
@@ -305,7 +335,7 @@ const cmdHelpHelp = `help - Prints this help`
 // CommandHelp handles help command
 func (handler *CommandHandler) CommandHelp(ce *CommandEvent) {
 	cmdPrefix := ""
-	if ce.User.ManagementRoom != ce.RoomID {
+	if ce.User.ManagementRoom != ce.RoomID || ce.User.IsRelaybot {
 		cmdPrefix = handler.bridge.Config.Bridge.CommandPrefix + " "
 	}
 

+ 69 - 5
config/bridge.go

@@ -24,6 +24,7 @@ import (
 
 	"github.com/Rhymen/go-whatsapp"
 
+	"maunium.net/go/mautrix"
 	"maunium.net/go/mautrix-appservice"
 
 	"maunium.net/go/mautrix-whatsapp/types"
@@ -64,6 +65,8 @@ type BridgeConfig struct {
 
 	Permissions PermissionConfig `yaml:"permissions"`
 
+	Relaybot RelaybotConfig `yaml:"relaybot"`
+
 	usernameTemplate    *template.Template `yaml:"-"`
 	displaynameTemplate *template.Template `yaml:"-"`
 	communityTemplate   *template.Template `yaml:"-"`
@@ -171,9 +174,10 @@ type PermissionConfig map[string]PermissionLevel
 type PermissionLevel int
 
 const (
-	PermissionLevelDefault PermissionLevel = 0
-	PermissionLevelUser    PermissionLevel = 10
-	PermissionLevelAdmin   PermissionLevel = 100
+	PermissionLevelDefault  PermissionLevel = 0
+	PermissionLevelRelaybot PermissionLevel = 5
+	PermissionLevelUser     PermissionLevel = 10
+	PermissionLevelAdmin    PermissionLevel = 100
 )
 
 func (pc *PermissionConfig) UnmarshalYAML(unmarshal func(interface{}) error) error {
@@ -188,6 +192,8 @@ func (pc *PermissionConfig) UnmarshalYAML(unmarshal func(interface{}) error) err
 	}
 	for key, value := range rawPC {
 		switch strings.ToLower(value) {
+		case "relaybot":
+			(*pc)[key] = PermissionLevelRelaybot
 		case "user":
 			(*pc)[key] = PermissionLevelUser
 		case "admin":
@@ -211,6 +217,8 @@ func (pc *PermissionConfig) MarshalYAML() (interface{}, error) {
 	rawPC := make(map[string]string)
 	for key, value := range *pc {
 		switch value {
+		case PermissionLevelRelaybot:
+			rawPC[key] = "relaybot"
 		case PermissionLevelUser:
 			rawPC[key] = "user"
 		case PermissionLevelAdmin:
@@ -222,12 +230,16 @@ func (pc *PermissionConfig) MarshalYAML() (interface{}, error) {
 	return rawPC, nil
 }
 
+func (pc PermissionConfig) IsRelaybotWhitelisted(userID string) bool {
+	return pc.GetPermissionLevel(userID) >= PermissionLevelRelaybot
+}
+
 func (pc PermissionConfig) IsWhitelisted(userID string) bool {
-	return pc.GetPermissionLevel(userID) >= 10
+	return pc.GetPermissionLevel(userID) >= PermissionLevelUser
 }
 
 func (pc PermissionConfig) IsAdmin(userID string) bool {
-	return pc.GetPermissionLevel(userID) >= 100
+	return pc.GetPermissionLevel(userID) >= PermissionLevelAdmin
 }
 
 func (pc PermissionConfig) GetPermissionLevel(userID string) PermissionLevel {
@@ -249,3 +261,55 @@ func (pc PermissionConfig) GetPermissionLevel(userID string) PermissionLevel {
 
 	return PermissionLevelDefault
 }
+
+type RelaybotConfig struct {
+	Enabled        bool                 `yaml:"enabled"`
+	ManagementRoom string               `yaml:"management"`
+	InviteUsers    []types.MatrixUserID `yaml:"invites"`
+
+	MessageFormats   map[mautrix.MessageType]string `yaml:"message_formats"`
+	messageTemplates *template.Template             `yaml:"-"`
+}
+
+type umRelaybotConfig RelaybotConfig
+
+func (rc *RelaybotConfig) UnmarshalYAML(unmarshal func(interface{}) error) error {
+	err := unmarshal((*umRelaybotConfig)(rc))
+	if err != nil {
+		return err
+	}
+
+	rc.messageTemplates = template.New("messageTemplates")
+	for key, format := range rc.MessageFormats {
+		_, err := rc.messageTemplates.New(string(key)).Parse(format)
+		if err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+type Sender struct {
+	UserID types.MatrixUserID
+	mautrix.Member
+}
+
+type formatData struct {
+	Sender  Sender
+	Message string
+	Content mautrix.Content
+}
+
+func (rc *RelaybotConfig) FormatMessage(evt *mautrix.Event, member mautrix.Member) (string, error) {
+	var output strings.Builder
+	err := rc.messageTemplates.ExecuteTemplate(&output, string(evt.Content.MsgType), formatData{
+		Sender: Sender{
+			UserID: evt.Sender,
+			Member: member,
+		},
+		Content: evt.Content,
+		Message: evt.Content.FormattedBody,
+	})
+	return output.String(), err
+}

+ 44 - 9
database/statestore.go

@@ -70,23 +70,23 @@ func (store *SQLStateStore) MarkRegistered(userID string) {
 	}
 }
 
-func (store *SQLStateStore) GetRoomMemberships(roomID string) map[string]mautrix.Membership {
-	memberships := make(map[string]mautrix.Membership)
-	rows, err := store.db.Query("SELECT user_id, membership FROM mx_user_profile WHERE room_id=$1", roomID)
+func (store *SQLStateStore) GetRoomMembers(roomID string) map[string]mautrix.Member {
+	members := make(map[string]mautrix.Member)
+	rows, err := store.db.Query("SELECT user_id, membership, displayname, avatar_url FROM mx_user_profile WHERE room_id=$1", roomID)
 	if err != nil {
-		return memberships
+		return members
 	}
 	var userID string
-	var membership mautrix.Membership
+	var member mautrix.Member
 	for rows.Next() {
-		err := rows.Scan(&userID, &membership)
+		err := rows.Scan(&userID, &member.Membership, &member.Displayname, &member.AvatarURL)
 		if err != nil {
-			store.log.Warnfln("Failed to scan membership in %s: %v", roomID, err)
+			store.log.Warnfln("Failed to scan member in %s: %v", roomID, err)
 		} else {
-			memberships[userID] = membership
+			members[userID] = member
 		}
 	}
-	return memberships
+	return members
 }
 
 func (store *SQLStateStore) GetMembership(roomID, userID string) mautrix.Membership {
@@ -99,6 +99,24 @@ func (store *SQLStateStore) GetMembership(roomID, userID string) mautrix.Members
 	return membership
 }
 
+func (store *SQLStateStore) GetMember(roomID, userID string) mautrix.Member {
+	member, ok := store.TryGetMember(roomID, userID)
+	if !ok {
+		member.Membership = mautrix.MembershipLeave
+	}
+	return member
+}
+
+func (store *SQLStateStore) TryGetMember(roomID, userID string) (mautrix.Member, bool) {
+	row := store.db.QueryRow("SELECT membership, displayname, avatar_url FROM mx_user_profile WHERE room_id=$1 AND user_id=$2", roomID, userID)
+	var member mautrix.Member
+	err := row.Scan(&member.Membership, &member.Displayname, &member.AvatarURL)
+	if err != nil && err != sql.ErrNoRows {
+		store.log.Warnfln("Failed to scan member info of %s in %s: %v", userID, roomID, err)
+	}
+	return member, err == nil
+}
+
 func (store *SQLStateStore) IsInRoom(roomID, userID string) bool {
 	return store.IsMembership(roomID, userID, "join")
 }
@@ -116,6 +134,7 @@ func (store *SQLStateStore) IsMembership(roomID, userID string, allowedMembershi
 	}
 	return false
 }
+
 func (store *SQLStateStore) SetMembership(roomID, userID string, membership mautrix.Membership) {
 	var err error
 	if store.db.dialect == "postgres" {
@@ -131,6 +150,22 @@ func (store *SQLStateStore) SetMembership(roomID, userID string, membership maut
 	}
 }
 
+func (store *SQLStateStore) SetMember(roomID, userID string, member mautrix.Member) {
+	var err error
+	if store.db.dialect == "postgres" {
+		_, err = store.db.Exec(`INSERT INTO mx_user_profile (room_id, user_id, membership, displayname, avatar_url) VALUES ($1, $2, $3, $4, $5)
+			ON CONFLICT (room_id, user_id) DO UPDATE SET membership=$3`, roomID, userID, member.Membership, member.Displayname, member.AvatarURL)
+	} else if store.db.dialect == "sqlite3" {
+		_, err = store.db.Exec("INSERT OR REPLACE INTO mx_user_profile (room_id, user_id, membership, displayname, avatar_url) VALUES ($1, $2, $3, $4, $5)",
+			roomID, userID, member.Membership, member.Displayname, member.AvatarURL)
+	} else {
+		err = fmt.Errorf("unsupported dialect %s", store.db.dialect)
+	}
+	if err != nil {
+		store.log.Warnfln("Failed to set membership of %s in %s to %s: %v", userID, roomID, member, err)
+	}
+}
+
 func (store *SQLStateStore) SetPowerLevels(roomID string, levels *mautrix.PowerLevels) {
 	levelsBytes, err := json.Marshal(levels)
 	if err != nil {

+ 2 - 2
database/upgrades/2019-08-25-move-state-store-to-db.go

@@ -47,7 +47,7 @@ func init() {
 		return executeBatch(tx, valueStrings, values...)
 	}
 
-	migrateMemberships := func(tx *sql.Tx, rooms map[string]map[string]mautrix.Membership) error {
+	migrateMemberships := func(tx *sql.Tx, rooms map[string]map[string]mautrix.Member) error {
 		for roomID, members := range rooms {
 			if len(members) == 0 {
 				continue
@@ -125,7 +125,7 @@ func init() {
 			return err
 		} else if err = migrateRegistrations(tx, store.Registrations); err != nil {
 			return err
-		} else if err = migrateMemberships(tx, store.Memberships); err != nil {
+		} else if err = migrateMemberships(tx, store.Members); err != nil {
 			return err
 		} else if err = migratePowerLevels(tx, store.PowerLevels); err != nil {
 			return err

+ 16 - 0
database/upgrades/2019-11-10-full-member-state-store.go

@@ -0,0 +1,16 @@
+package upgrades
+
+import (
+	"database/sql"
+)
+
+func init() {
+	upgrades[10] = upgrade{"Add columns to store full member info in state store", func(tx *sql.Tx, ctx context) error {
+		_, err := tx.Exec(`ALTER TABLE mx_user_profile ADD COLUMN displayname TEXT`)
+		if err != nil {
+			return err
+		}
+		_, err = tx.Exec(`ALTER TABLE mx_user_profile ADD COLUMN avatar_url VARCHAR(255)`)
+		return err
+	}}
+}

+ 1 - 1
database/upgrades/upgrades.go

@@ -28,7 +28,7 @@ type upgrade struct {
 	fn      upgradeFunc
 }
 
-const NumberOfUpgrades = 10
+const NumberOfUpgrades = 11
 
 var upgrades [NumberOfUpgrades]upgrade
 

+ 7 - 0
database/user.go

@@ -201,6 +201,13 @@ func (user *User) SetPortalKeys(newKeys []PortalKeyWithMeta) error {
 	return tx.Commit()
 }
 
+func (user *User) IsInPortal(jid types.WhatsAppID) bool {
+	row := user.db.QueryRow(`SELECT portal_jid, portal_receiver FROM user_portal WHERE user_jid=$1 AND portal_jid=$2 AND (portal_receiver=$1 OR portal_receiver=$2)`, user.jidPtr(), &jid)
+	var scanJid, scanReceiver types.WhatsAppID
+	_ = row.Scan(&scanJid, &scanReceiver)
+	return scanJid == jid && (scanReceiver == jid || scanReceiver == user.JID)
+}
+
 func (user *User) GetPortalKeys() []PortalKey {
 	rows, err := user.db.Query(`SELECT portal_jid, portal_receiver FROM user_portal WHERE user_jid=$1`, user.jidPtr())
 	if err != nil {

+ 24 - 2
example-config.yaml

@@ -1,9 +1,9 @@
 # Homeserver details.
 homeserver:
     # The address that this appservice can use to connect to the homeserver.
-    address: https://matrix.org
+    address: https://example.com
     # The domain of the homeserver (for MXIDs, etc).
-    domain: matrix.org
+    domain: example.com
 
 # Application service host/registration related details.
 # Changing these values requires regeneration of the registration.
@@ -122,6 +122,7 @@ bridge:
 
     # Permissions for using the bridge.
     # Permitted values:
+    # relaybot - Talk through the relaybot (if enabled), no access otherwise
     #     user - Access to use the bridge to chat with a WhatsApp account.
     #    admin - User level and some additional administration tools
     # Permitted keys:
@@ -129,9 +130,30 @@ bridge:
     #   domain - All users on that homeserver
     #     mxid - Specific user
     permissions:
+        "*": relaybot
         "example.com": user
         "@admin:example.com": admin
 
+    relaybot:
+        # Whether or not relaybot support is enabled.
+        enabled: false
+        # The management room for the bot. This is where all status notifications are posted and
+        # in this room, you can use `!wa <command>` instead of `!wa relaybot <command>`. Omitting
+        # the command prefix completely like in user management rooms is not possible.
+        management: !foo:example.com
+        # List of users to invite to all created rooms that include the relaybot.
+        invites: []
+        # The formats to use when sending messages to WhatsApp via the relaybot.
+        message_formats:
+            m.text: "<b>{{ .Sender.Displayname }}</b>: {{ .Message }}"
+            m.notice: "<b>{{ .Sender.Displayname }}</b>: {{ .Message }}"
+            m.emote: "* <b>{{ .Sender.Displayname }}</b> {{ .Message }}"
+            m.file: "<b>{{ .Sender.Displayname }}</b> sent a file"
+            m.image: "<b>{{ .Sender.Displayname }}</b> sent an image"
+            m.audio: "<b>{{ .Sender.Displayname }}</b> sent an audio file"
+            m.video: "<b>{{ .Sender.Displayname }}</b> sent a video"
+            m.location: "<b>{{ .Sender.Displayname }}</b> sent a location"
+
 # Logging config.
 logging:
     # The directory for log files. Will be created if not found.

+ 3 - 3
go.mod

@@ -12,11 +12,11 @@ require (
 	gopkg.in/yaml.v2 v2.2.2
 	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.20190825132810-9d870654e9d2
-	maunium.net/go/mautrix-appservice v0.1.0-alpha.3.0.20190901152202-40639f5932be
+	maunium.net/go/mautrix v0.1.0-alpha.3.0.20191110191816-178ce1f1561d
+	maunium.net/go/mautrix-appservice v0.1.0-alpha.3.0.20191110192030-cd699619a163
 )
 
 replace (
-	github.com/Rhymen/go-whatsapp => github.com/tulir/go-whatsapp v0.0.2-0.20191004160943-faf0ee6fab98
+	github.com/Rhymen/go-whatsapp => github.com/tulir/go-whatsapp v0.0.2-0.20191109203156-c477dae1c7e9
 	gopkg.in/russross/blackfriday.v2 => github.com/russross/blackfriday/v2 v2.0.1
 )

+ 3 - 2
go.sum

@@ -45,8 +45,7 @@ github.com/tulir/go-whatsapp v0.0.2-0.20190830212741-33ca6ee47cf5 h1:0pUczFGOo4s
 github.com/tulir/go-whatsapp v0.0.2-0.20190830212741-33ca6ee47cf5/go.mod h1:u3Hdptbz3iB5y/NEoSKgsp9hBzUlm0A5OrLMVdENAX8=
 github.com/tulir/go-whatsapp v0.0.2-0.20190903182221-4e1a838ff3ba h1:exEcedSHn0qEZ1iwNwFF5brEuflhMScjFyyzmxUA+og=
 github.com/tulir/go-whatsapp v0.0.2-0.20190903182221-4e1a838ff3ba/go.mod h1:u3Hdptbz3iB5y/NEoSKgsp9hBzUlm0A5OrLMVdENAX8=
-github.com/tulir/go-whatsapp v0.0.2-0.20191004160943-faf0ee6fab98 h1:TkKWIdhqxRBM8bZaJvp1q+awGJcY1f76zmlH7nHPDR8=
-github.com/tulir/go-whatsapp v0.0.2-0.20191004160943-faf0ee6fab98/go.mod h1:u3Hdptbz3iB5y/NEoSKgsp9hBzUlm0A5OrLMVdENAX8=
+github.com/tulir/go-whatsapp v0.0.2-0.20191109203156-c477dae1c7e9/go.mod h1:ustkccVUt0hOuKikjFb6b4Eray6At5djkcKYYu4+Lco=
 golang.org/x/crypto v0.0.0-20190131182504-b8fe1690c613/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 h1:VklqNMn3ovrHsnt90PveolxSbWFaJdECFbxSq0Mqo2M=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@@ -74,6 +73,7 @@ maunium.net/go/mautrix v0.1.0-alpha.3.0.20190622085722-6406f15cb8e3 h1:oVabjOi2r
 maunium.net/go/mautrix v0.1.0-alpha.3.0.20190622085722-6406f15cb8e3/go.mod h1:O+QWJP3H7BZEzIBSrECKpnpRnEKBwaoWVEu/yZwVwxg=
 maunium.net/go/mautrix v0.1.0-alpha.3.0.20190825132810-9d870654e9d2 h1:0iVxLLAOSBqtJqhIjW9EbblMsaSYoCJRo5mHPZnytUk=
 maunium.net/go/mautrix v0.1.0-alpha.3.0.20190825132810-9d870654e9d2/go.mod h1:O+QWJP3H7BZEzIBSrECKpnpRnEKBwaoWVEu/yZwVwxg=
+maunium.net/go/mautrix v0.1.0-alpha.3.0.20191110191816-178ce1f1561d/go.mod h1:O+QWJP3H7BZEzIBSrECKpnpRnEKBwaoWVEu/yZwVwxg=
 maunium.net/go/mautrix-appservice v0.1.0-alpha.3.0.20190618052224-6e6c9bb47548 h1:ni1nqs+2AOO+g1ND6f2W0pMcb6sIDVqzerXosO+pI2g=
 maunium.net/go/mautrix-appservice v0.1.0-alpha.3.0.20190618052224-6e6c9bb47548/go.mod h1:yVWU0gvIHIXClgyVnShiufiDksFbFrBqHG9lDAYcmGI=
 maunium.net/go/mautrix-appservice v0.1.0-alpha.3.0.20190822210104-3e49344e186b h1:/03X0PPgtk4pqXcdH86xMzOl891whG5A1hFXQ+xXons=
@@ -86,3 +86,4 @@ maunium.net/go/mautrix-appservice v0.1.0-alpha.3.0.20190830063827-e7dcd7e42e7c h
 maunium.net/go/mautrix-appservice v0.1.0-alpha.3.0.20190830063827-e7dcd7e42e7c/go.mod h1:FJRRpH5+p3wCfEt6u/3kMeu9aGX/pk2PqtvjRDRW74w=
 maunium.net/go/mautrix-appservice v0.1.0-alpha.3.0.20190901152202-40639f5932be h1:sSBx9AGR4iYHRFwljqNwxXFtbY2bKLJHgI9B4whAU8I=
 maunium.net/go/mautrix-appservice v0.1.0-alpha.3.0.20190901152202-40639f5932be/go.mod h1:FJRRpH5+p3wCfEt6u/3kMeu9aGX/pk2PqtvjRDRW74w=
+maunium.net/go/mautrix-appservice v0.1.0-alpha.3.0.20191110192030-cd699619a163/go.mod h1:ST7YYCoHtFC4c7/Iga8W5wwKXyxjwVh4DlsnyIU6rYw=

+ 21 - 4
main.go

@@ -18,23 +18,23 @@ package main
 
 import (
 	"fmt"
+	"net/http"
 	"os"
 	"os/signal"
 	"sync"
 	"syscall"
+	"time"
 
 	flag "maunium.net/go/mauflag"
 	log "maunium.net/go/maulogger/v2"
 
+	"maunium.net/go/mautrix"
 	"maunium.net/go/mautrix-appservice"
-	"maunium.net/go/mautrix-whatsapp/database/upgrades"
 
 	"maunium.net/go/mautrix-whatsapp/config"
 	"maunium.net/go/mautrix-whatsapp/database"
+	"maunium.net/go/mautrix-whatsapp/database/upgrades"
 	"maunium.net/go/mautrix-whatsapp/types"
-	"net/http"
-	"maunium.net/go/mautrix"
-	"time"
 )
 
 var configPath = flag.MakeFull("c", "config", "The path to your config file.", "config.yaml").String()
@@ -104,6 +104,7 @@ type Bridge struct {
 	StateStore     *database.SQLStateStore
 	Bot            *appservice.IntentAPI
 	Formatter      *Formatter
+	Relaybot       *User
 
 	usersByMXID         map[types.MatrixUserID]*User
 	usersByJID          map[types.WhatsAppID]*User
@@ -220,6 +221,7 @@ func (bridge *Bridge) Start() {
 		bridge.Log.Fatalln("Failed to initialize database:", err)
 		os.Exit(15)
 	}
+	bridge.LoadRelaybot()
 	bridge.Log.Debugln("Checking connection to homeserver")
 	bridge.ensureConnection()
 	bridge.Log.Debugln("Starting application service HTTP server")
@@ -230,6 +232,21 @@ func (bridge *Bridge) Start() {
 	go bridge.StartUsers()
 }
 
+func (bridge *Bridge) LoadRelaybot() {
+	if !bridge.Config.Bridge.Relaybot.Enabled {
+		return
+	}
+	bridge.Relaybot = bridge.GetUserByMXID("relaybot")
+	if bridge.Relaybot.HasSession() {
+		bridge.Log.Debugln("Relaybot is enabled")
+	} else {
+		bridge.Log.Debugln("Relaybot is enabled, but not logged in")
+	}
+	bridge.Relaybot.ManagementRoom = bridge.Config.Bridge.Relaybot.ManagementRoom
+	bridge.Relaybot.IsRelaybot = true
+	bridge.Relaybot.Connect(false)
+}
+
 func (bridge *Bridge) UpdateBotProfile() {
 	bridge.Log.Debugln("Updating bot profile")
 	botConfig := bridge.Config.AppService.Bot

+ 13 - 6
matrix.go

@@ -21,6 +21,7 @@ import (
 	"strings"
 
 	"maunium.net/go/maulogger/v2"
+
 	"maunium.net/go/mautrix"
 	"maunium.net/go/mautrix-appservice"
 	"maunium.net/go/mautrix/format"
@@ -85,6 +86,12 @@ func (mx *MatrixHandler) HandleBotInvite(evt *mautrix.Event) {
 		return
 	}
 
+	if evt.RoomID == mx.bridge.Config.Bridge.Relaybot.ManagementRoom {
+		intent.SendNotice(evt.RoomID, "This is the relaybot management room. Send `!wa help` to get a list of commands.")
+		mx.log.Debugln("Joined relaybot management room", evt.RoomID, "after invite from", evt.Sender)
+		return
+	}
+
 	hasPuppets := false
 	for mxid, _ := range members.Joined {
 		if mxid == intent.UserID || mxid == evt.Sender {
@@ -135,7 +142,7 @@ func (mx *MatrixHandler) HandleMembership(evt *mautrix.Event) {
 
 func (mx *MatrixHandler) HandleRoomMetadata(evt *mautrix.Event) {
 	user := mx.bridge.GetUserByMXID(types.MatrixUserID(evt.Sender))
-	if user == nil || !user.Whitelisted || !user.IsConnected()  {
+	if user == nil || !user.Whitelisted || !user.IsConnected() {
 		return
 	}
 
@@ -174,11 +181,11 @@ func (mx *MatrixHandler) HandleMessage(evt *mautrix.Event) {
 	roomID := types.MatrixRoomID(evt.RoomID)
 	user := mx.bridge.GetUserByMXID(types.MatrixUserID(evt.Sender))
 
-	if !user.Whitelisted {
+	if !user.RelaybotWhitelisted {
 		return
 	}
 
-	if evt.Content.MsgType == mautrix.MsgText {
+	if user.Whitelisted && evt.Content.MsgType == mautrix.MsgText {
 		commandPrefix := mx.bridge.Config.Bridge.CommandPrefix
 		hasCommandPrefix := strings.HasPrefix(evt.Content.Body, commandPrefix)
 		if hasCommandPrefix {
@@ -191,7 +198,7 @@ func (mx *MatrixHandler) HandleMessage(evt *mautrix.Event) {
 	}
 
 	portal := mx.bridge.GetPortalByMXID(roomID)
-	if portal != nil {
+	if portal != nil && (user.Whitelisted || portal.HasRelaybot()) {
 		portal.HandleMatrixMessage(user, evt)
 	}
 }
@@ -211,8 +218,8 @@ func (mx *MatrixHandler) HandleRedaction(evt *mautrix.Event) {
 	if !user.HasSession() {
 		return
 	} else if !user.IsConnected() {
-		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. " +
+		msg := format.RenderMarkdown(fmt.Sprintf("[%[1]s](https://matrix.to/#/%[1]s): \u26a0 "+
+			"You are not connected to WhatsApp, so your redaction was not bridged. "+
 			"Use `%[2]s reconnect` to reconnect.", user.MXID, mx.bridge.Config.Bridge.CommandPrefix))
 		msg.MsgType = mautrix.MsgNotice
 		_, _ = mx.bridge.Bot.SendMessageEvent(roomID, mautrix.EventMessage, msg)

+ 100 - 42
portal.go

@@ -21,6 +21,7 @@ import (
 	"encoding/gob"
 	"encoding/hex"
 	"fmt"
+	"html"
 	"image"
 	"image/gif"
 	"image/jpeg"
@@ -34,15 +35,14 @@ import (
 	"time"
 
 	"github.com/chai2010/webp"
+	log "maunium.net/go/maulogger/v2"
 
 	"github.com/Rhymen/go-whatsapp"
 	waProto "github.com/Rhymen/go-whatsapp/binary/proto"
-	"maunium.net/go/mautrix/format"
-
-	log "maunium.net/go/maulogger/v2"
 
 	"maunium.net/go/mautrix"
 	"maunium.net/go/mautrix-appservice"
+	"maunium.net/go/mautrix/format"
 
 	"maunium.net/go/mautrix-whatsapp/database"
 	"maunium.net/go/mautrix-whatsapp/types"
@@ -158,7 +158,8 @@ type Portal struct {
 
 	messages chan PortalMessage
 
-	isPrivate *bool
+	isPrivate   *bool
+	hasRelaybot *bool
 }
 
 const MaxMessageAgeToCreatePortal = 5 * 60 // 5 minutes
@@ -191,15 +192,15 @@ func (portal *Portal) handleMessage(msg PortalMessage) {
 	case whatsapp.TextMessage:
 		portal.HandleTextMessage(msg.source, data)
 	case whatsapp.ImageMessage:
-		portal.HandleMediaMessage(msg.source, data.Download, data.Thumbnail, data.Info, data.Type, data.Caption, false)
+		portal.HandleMediaMessage(msg.source, data.Download, data.Thumbnail, data.Info, data.ContextInfo, data.Type, data.Caption, false)
 	case whatsapp.StickerMessage:
-		portal.HandleMediaMessage(msg.source, data.Download, data.Thumbnail, data.Info, data.Type, "", true)
+		portal.HandleMediaMessage(msg.source, data.Download, nil, data.Info, data.ContextInfo, data.Type, "", true)
 	case whatsapp.VideoMessage:
-		portal.HandleMediaMessage(msg.source, data.Download, data.Thumbnail, data.Info, data.Type, data.Caption, false)
+		portal.HandleMediaMessage(msg.source, data.Download, data.Thumbnail, data.Info, data.ContextInfo, data.Type, data.Caption, false)
 	case whatsapp.AudioMessage:
-		portal.HandleMediaMessage(msg.source, data.Download, nil, data.Info, data.Type, "", false)
+		portal.HandleMediaMessage(msg.source, data.Download, nil, data.Info, data.ContextInfo, data.Type, "", false)
 	case whatsapp.DocumentMessage:
-		portal.HandleMediaMessage(msg.source, data.Download, data.Thumbnail, data.Info, data.Type, data.Title, false)
+		portal.HandleMediaMessage(msg.source, data.Download, data.Thumbnail, data.Info, data.ContextInfo, data.Type, data.Title, false)
 	case whatsappExt.MessageRevocation:
 		portal.HandleMessageRevoke(msg.source, data)
 	case FakeMessage:
@@ -281,14 +282,7 @@ func (portal *Portal) SyncParticipants(metadata *whatsappExt.GroupInfo) {
 	}
 	for _, participant := range metadata.Participants {
 		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{
-				UserID: user.MXID,
-			})
-			if err != nil {
-				portal.log.Warnfln("Failed to invite %s to %s: %v", user.MXID, portal.MXID, err)
-			}
-		}
+		portal.userMXIDAction(user, portal.ensureMXIDInvited)
 
 		puppet := portal.bridge.GetPuppetByJID(participant.JID)
 		err := puppet.IntentFor(portal).EnsureJoined(portal.MXID)
@@ -421,11 +415,30 @@ func (portal *Portal) UpdateMetadata(user *User) bool {
 	return update
 }
 
-func (portal *Portal) ensureUserInvited(user *User) {
-	err := portal.MainIntent().EnsureInvited(portal.MXID, user.MXID)
+func (portal *Portal) userMXIDAction(user *User, fn func(mxid types.MatrixUserID)) {
+	if user == nil {
+		return
+	}
+
+	if user == portal.bridge.Relaybot {
+		for _, mxid := range portal.bridge.Config.Bridge.Relaybot.InviteUsers {
+			fn(mxid)
+		}
+	} else {
+		fn(user.MXID)
+	}
+}
+
+func (portal *Portal) ensureMXIDInvited(mxid types.MatrixUserID) {
+	err := portal.MainIntent().EnsureInvited(portal.MXID, mxid)
 	if err != nil {
-		portal.log.Warnfln("Failed to ensure %s is invited to %s: %v", user.MXID, portal.MXID, err)
+		portal.log.Warnfln("Failed to ensure %s is invited to %s: %v", mxid, portal.MXID, err)
 	}
+}
+
+func (portal *Portal) ensureUserInvited(user *User) {
+	portal.userMXIDAction(user, portal.ensureMXIDInvited)
+
 	customPuppet := portal.bridge.GetPuppetByCustomMXID(user.MXID)
 	if customPuppet != nil && customPuppet.CustomIntent() != nil {
 		_ = customPuppet.CustomIntent().EnsureJoined(portal.MXID)
@@ -435,6 +448,11 @@ func (portal *Portal) ensureUserInvited(user *User) {
 func (portal *Portal) Sync(user *User, contact whatsapp.Contact) {
 	portal.log.Infoln("Syncing portal for", user.MXID)
 
+	if user.IsRelaybot {
+		yes := true
+		portal.hasRelaybot = &yes
+	}
+
 	if len(portal.MXID) == 0 {
 		if !portal.IsPrivateChat() {
 			portal.Name = contact.Name
@@ -745,11 +763,16 @@ func (portal *Portal) CreateMatrixRoom(user *User) error {
 		})
 	}
 
+	invite := []string{user.MXID}
+	if user.IsRelaybot {
+		invite = portal.bridge.Config.Bridge.Relaybot.InviteUsers
+	}
+
 	resp, err := intent.CreateRoom(&mautrix.ReqCreateRoom{
 		Visibility:   "private",
 		Name:         portal.Name,
 		Topic:        portal.Topic,
-		Invite:       []string{user.MXID},
+		Invite:       invite,
 		Preset:       "private_chat",
 		IsDirect:     isPrivateChat,
 		InitialState: initialState,
@@ -783,6 +806,16 @@ func (portal *Portal) IsPrivateChat() bool {
 	return *portal.isPrivate
 }
 
+func (portal *Portal) HasRelaybot() bool {
+	if portal.bridge.Relaybot == nil {
+		return false
+	} else if portal.hasRelaybot == nil {
+		val := portal.bridge.Relaybot.IsInPortal(portal.Key.JID)
+		portal.hasRelaybot = &val
+	}
+	return *portal.hasRelaybot
+}
+
 func (portal *Portal) IsStatusBroadcastRoom() bool {
 	return portal.Key.JID == "status@broadcast"
 }
@@ -809,7 +842,7 @@ func (portal *Portal) GetMessageIntent(user *User, info whatsapp.MessageInfo) *a
 	return portal.bridge.GetPuppetByJID(info.SenderJid).IntentFor(portal)
 }
 
-func (portal *Portal) SetReply(content *mautrix.Content, info whatsapp.MessageInfo) {
+func (portal *Portal) SetReply(content *mautrix.Content, info whatsapp.ContextInfo) {
 	if len(info.QuotedMessageID) == 0 {
 		return
 	}
@@ -891,7 +924,7 @@ func (portal *Portal) HandleTextMessage(source *User, message whatsapp.TextMessa
 	}
 
 	portal.bridge.Formatter.ParseWhatsApp(content)
-	portal.SetReply(content, message.Info)
+	portal.SetReply(content, message.ContextInfo)
 
 	_, _ = intent.UserTyping(portal.MXID, false, 0)
 	resp, err := intent.SendMassagedMessageEvent(portal.MXID, mautrix.EventMessage, &MessageContent{content, intent.IsCustomPuppet}, int64(message.Info.Timestamp*1000))
@@ -902,7 +935,7 @@ func (portal *Portal) HandleTextMessage(source *User, message whatsapp.TextMessa
 	portal.finishHandling(source, message.Info.Source, resp.EventID)
 }
 
-func (portal *Portal) HandleMediaMessage(source *User, download func() ([]byte, error), thumbnail []byte, info whatsapp.MessageInfo, mimeType, caption string, sendAsSticker bool) {
+func (portal *Portal) HandleMediaMessage(source *User, download func() ([]byte, error), thumbnail []byte, info whatsapp.MessageInfo, context whatsapp.ContextInfo, mimeType, caption string, sendAsSticker bool) {
 	if !portal.startHandling(info) {
 		return
 	}
@@ -967,7 +1000,7 @@ func (portal *Portal) HandleMediaMessage(source *User, download func() ([]byte,
 			MimeType: mimeType,
 		},
 	}
-	portal.SetReply(content, info)
+	portal.SetReply(content, context)
 
 	if thumbnail != nil {
 		thumbnailMime := http.DetectContentType(thumbnail)
@@ -986,7 +1019,7 @@ func (portal *Portal) HandleMediaMessage(source *User, download func() ([]byte,
 
 	switch strings.ToLower(strings.Split(mimeType, "/")[0]) {
 	case "image":
-		if (!sendAsSticker) {
+		if !sendAsSticker {
 			content.MsgType = mautrix.MsgImage
 		}
 		cfg, _, _ := image.DecodeConfig(bytes.NewReader(data))
@@ -1070,18 +1103,15 @@ func (portal *Portal) downloadThumbnail(evt *mautrix.Event) []byte {
 	return buf.Bytes()
 }
 
-func (portal *Portal) preprocessMatrixMedia(sender *User, evt *mautrix.Event, mediaType whatsapp.MediaType) *MediaUpload {
+func (portal *Portal) preprocessMatrixMedia(sender *User, relaybotFormatted bool, evt *mautrix.Event, mediaType whatsapp.MediaType) *MediaUpload {
 	if evt.Content.Info == nil {
 		evt.Content.Info = &mautrix.FileInfo{}
 	}
-	caption := evt.Content.Body
-	exts, err := mime.ExtensionsByType(evt.Content.Info.MimeType)
-	for _, ext := range exts {
-		if strings.HasSuffix(caption, ext) {
-			caption = ""
-			break
-		}
+	var caption string
+	if relaybotFormatted {
+		caption = portal.bridge.Formatter.ParseMatrix(evt.Content.FormattedBody)
 	}
+
 	content, err := portal.MainIntent().DownloadBytes(evt.Content.URL)
 	if err != nil {
 		portal.log.Errorfln("Failed to download media in %s: %v", evt.ID, err)
@@ -1140,10 +1170,28 @@ func (portal *Portal) sendMatrixConnectionError(sender *User, eventID string) bo
 	return false
 }
 
+func (portal *Portal) addRelaybotFormat(user *User, evt *mautrix.Event) bool {
+	member := portal.MainIntent().Member(portal.MXID, evt.Sender)
+	if len(member.Displayname) == 0 {
+		member.Displayname = evt.Sender
+	}
+
+	if evt.Content.Format != mautrix.FormatHTML {
+		evt.Content.FormattedBody = strings.ReplaceAll(html.EscapeString(evt.Content.Body), "\n", "<br/>")
+		evt.Content.Format = mautrix.FormatHTML
+	}
+	data, err := portal.bridge.Config.Bridge.Relaybot.FormatMessage(evt, member)
+	if err != nil {
+		portal.log.Errorln("Failed to apply relaybot format:", err)
+	}
+	evt.Content.FormattedBody = data
+	return true
+}
+
 func (portal *Portal) HandleMatrixMessage(sender *User, evt *mautrix.Event) {
-	if portal.IsPrivateChat() && sender.JID != portal.Key.Receiver {
-		return
-	} else if portal.sendMatrixConnectionError(sender, evt.ID) {
+	if !portal.HasRelaybot() && (
+		(portal.IsPrivateChat() && sender.JID != portal.Key.Receiver) ||
+			portal.sendMatrixConnectionError(sender, evt.ID)) {
 		return
 	}
 	portal.log.Debugfln("Received event %s", evt.ID)
@@ -1172,6 +1220,15 @@ func (portal *Portal) HandleMatrixMessage(sender *User, evt *mautrix.Event) {
 			ctxInfo.QuotedMessage = msg.Content
 		}
 	}
+	relaybotFormatted := false
+	if sender.NeedsRelaybot(portal) {
+		if !portal.HasRelaybot() {
+			portal.log.Debugln("Ignoring message from", sender.MXID, "in chat with no relaybot")
+			return
+		}
+		relaybotFormatted = portal.addRelaybotFormat(sender, evt)
+		sender = portal.bridge.Relaybot
+	}
 	var err error
 	switch evt.Content.MsgType {
 	case mautrix.MsgText, mautrix.MsgEmote:
@@ -1179,7 +1236,7 @@ func (portal *Portal) HandleMatrixMessage(sender *User, evt *mautrix.Event) {
 		if evt.Content.Format == mautrix.FormatHTML {
 			text = portal.bridge.Formatter.ParseMatrix(evt.Content.FormattedBody)
 		}
-		if evt.Content.MsgType == mautrix.MsgEmote {
+		if evt.Content.MsgType == mautrix.MsgEmote && !relaybotFormatted {
 			text = "/me " + text
 		}
 		ctxInfo.MentionedJid = mentionRegex.FindAllString(text, -1)
@@ -1195,7 +1252,7 @@ func (portal *Portal) HandleMatrixMessage(sender *User, evt *mautrix.Event) {
 			info.Message.Conversation = &text
 		}
 	case mautrix.MsgImage:
-		media := portal.preprocessMatrixMedia(sender, evt, whatsapp.MediaImage)
+		media := portal.preprocessMatrixMedia(sender, relaybotFormatted, evt, whatsapp.MediaImage)
 		if media == nil {
 			return
 		}
@@ -1210,7 +1267,7 @@ func (portal *Portal) HandleMatrixMessage(sender *User, evt *mautrix.Event) {
 			FileLength:    &media.FileLength,
 		}
 	case mautrix.MsgVideo:
-		media := portal.preprocessMatrixMedia(sender, evt, whatsapp.MediaVideo)
+		media := portal.preprocessMatrixMedia(sender, relaybotFormatted, evt, whatsapp.MediaVideo)
 		if media == nil {
 			return
 		}
@@ -1227,7 +1284,7 @@ func (portal *Portal) HandleMatrixMessage(sender *User, evt *mautrix.Event) {
 			FileLength:    &media.FileLength,
 		}
 	case mautrix.MsgAudio:
-		media := portal.preprocessMatrixMedia(sender, evt, whatsapp.MediaAudio)
+		media := portal.preprocessMatrixMedia(sender, relaybotFormatted, evt, whatsapp.MediaAudio)
 		if media == nil {
 			return
 		}
@@ -1242,12 +1299,13 @@ func (portal *Portal) HandleMatrixMessage(sender *User, evt *mautrix.Event) {
 			FileLength:    &media.FileLength,
 		}
 	case mautrix.MsgFile:
-		media := portal.preprocessMatrixMedia(sender, evt, whatsapp.MediaDocument)
+		media := portal.preprocessMatrixMedia(sender, relaybotFormatted, evt, whatsapp.MediaDocument)
 		if media == nil {
 			return
 		}
 		info.Message.DocumentMessage = &waProto.DocumentMessage{
 			Url:           &media.URL,
+			FileName:      &evt.Content.Body,
 			MediaKey:      media.MediaKey,
 			Mimetype:      &evt.Content.GetInfo().MimeType,
 			FileEncSha256: media.FileEncSHA256,

+ 14 - 4
user.go

@@ -47,8 +47,11 @@ type User struct {
 	bridge *Bridge
 	log    log.Logger
 
-	Admin       bool
-	Whitelisted bool
+	Admin               bool
+	Whitelisted         bool
+	RelaybotWhitelisted bool
+
+	IsRelaybot bool
 
 	ConnectionErrors int
 	CommunityID      string
@@ -144,10 +147,13 @@ func (bridge *Bridge) NewUser(dbUser *database.User) *User {
 		bridge: bridge,
 		log:    bridge.Log.Sub("User").Sub(string(dbUser.MXID)),
 
+		IsRelaybot: false,
+
 		chatListReceived: make(chan struct{}, 1),
-		syncPortalsDone: make(chan struct{}, 1),
-		messages:        make(chan PortalMessage, 256),
+		syncPortalsDone:  make(chan struct{}, 1),
+		messages:         make(chan PortalMessage, 256),
 	}
+	user.RelaybotWhitelisted = user.bridge.Config.Bridge.Permissions.IsRelaybotWhitelisted(user.MXID)
 	user.Whitelisted = user.bridge.Config.Bridge.Permissions.IsWhitelisted(user.MXID)
 	user.Admin = user.bridge.Config.Bridge.Permissions.IsAdmin(user.MXID)
 	go user.handleMessageLoop()
@@ -773,3 +779,7 @@ func (user *User) HandleJsonMessage(message string) {
 func (user *User) HandleRawMessage(message *waProto.WebMessageInfo) {
 	user.updateLastConnectionIfNecessary()
 }
+
+func (user *User) NeedsRelaybot(portal *Portal) bool {
+	return !user.HasSession() || user.IsInPortal(portal.Key.JID) || portal.IsPrivateChat()
+}