From 03d42640fe67a7262d160497cd79678612249cc5 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 10 Nov 2019 21:22:11 +0200 Subject: [PATCH] Add basic relaybot support. Fixes #20 --- commands.go | 46 +++++- config/bridge.go | 74 ++++++++- database/statestore.go | 53 +++++-- .../2019-08-25-move-state-store-to-db.go | 4 +- .../2019-11-10-full-member-state-store.go | 16 ++ database/upgrades/upgrades.go | 2 +- database/user.go | 7 + example-config.yaml | 26 +++- go.mod | 6 +- go.sum | 5 +- main.go | 25 ++- matrix.go | 19 ++- portal.go | 144 ++++++++++++------ user.go | 18 ++- 14 files changed, 356 insertions(+), 89 deletions(-) create mode 100644 database/upgrades/2019-11-10-full-member-state-store.go diff --git a/commands.go b/commands.go index e9f10bd..e831ead 100644 --- a/commands.go +++ b/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 + " " } diff --git a/config/bridge.go b/config/bridge.go index 5a9469f..a4e3046 100644 --- a/config/bridge.go +++ b/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 +} diff --git a/database/statestore.go b/database/statestore.go index 3977deb..0768219 100644 --- a/database/statestore.go +++ b/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 { diff --git a/database/upgrades/2019-08-25-move-state-store-to-db.go b/database/upgrades/2019-08-25-move-state-store-to-db.go index c182a34..c9b70b1 100644 --- a/database/upgrades/2019-08-25-move-state-store-to-db.go +++ b/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 diff --git a/database/upgrades/2019-11-10-full-member-state-store.go b/database/upgrades/2019-11-10-full-member-state-store.go new file mode 100644 index 0000000..4040e7f --- /dev/null +++ b/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 + }} +} diff --git a/database/upgrades/upgrades.go b/database/upgrades/upgrades.go index b332fa0..d97c10f 100644 --- a/database/upgrades/upgrades.go +++ b/database/upgrades/upgrades.go @@ -28,7 +28,7 @@ type upgrade struct { fn upgradeFunc } -const NumberOfUpgrades = 10 +const NumberOfUpgrades = 11 var upgrades [NumberOfUpgrades]upgrade diff --git a/database/user.go b/database/user.go index 283eb3e..e0c96c6 100644 --- a/database/user.go +++ b/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 { diff --git a/example-config.yaml b/example-config.yaml index c65686a..346bda8 100644 --- a/example-config.yaml +++ b/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 ` instead of `!wa relaybot `. Omitting + # the command prefix completely like in user management rooms is not possible. + management: !foo:example.com + # List of users to invite to all created rooms that include the relaybot. + invites: [] + # The formats to use when sending messages to WhatsApp via the relaybot. + message_formats: + m.text: "{{ .Sender.Displayname }}: {{ .Message }}" + m.notice: "{{ .Sender.Displayname }}: {{ .Message }}" + m.emote: "* {{ .Sender.Displayname }} {{ .Message }}" + m.file: "{{ .Sender.Displayname }} sent a file" + m.image: "{{ .Sender.Displayname }} sent an image" + m.audio: "{{ .Sender.Displayname }} sent an audio file" + m.video: "{{ .Sender.Displayname }} sent a video" + m.location: "{{ .Sender.Displayname }} sent a location" + # Logging config. logging: # The directory for log files. Will be created if not found. diff --git a/go.mod b/go.mod index 7a3ee86..0afaa9f 100644 --- a/go.mod +++ b/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 ) diff --git a/go.sum b/go.sum index 8cdfe7b..f638f9a 100644 --- a/go.sum +++ b/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= diff --git a/main.go b/main.go index e02adf8..60167ec 100644 --- a/main.go +++ b/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 diff --git a/matrix.go b/matrix.go index b9f2ca8..6bd3ea7 100644 --- a/matrix.go +++ b/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) diff --git a/portal.go b/portal.go index 15c4c17..c7a6ba7 100644 --- a/portal.go +++ b/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) - if err != nil { - portal.log.Warnfln("Failed to ensure %s is invited to %s: %v", user.MXID, portal.MXID, err) +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", 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", "
") + 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, diff --git a/user.go b/user.go index af7f171..3d31cbc 100644 --- a/user.go +++ b/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() +}