From 7bf470d69e3a5e132a7da20bf5c2747caa6d32c8 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 10 Aug 2019 15:24:53 +0300 Subject: [PATCH] Add portal rooms to user-specific community for filtering --- community.go | 100 ++++++++++++++++++ config/bridge.go | 32 +++++- .../2019-08-10-portal-in-community-field.go | 12 +++ database/upgrades/upgrades.go | 2 +- database/user.go | 41 +++++-- example-config.yaml | 6 +- user.go | 23 ++-- 7 files changed, 198 insertions(+), 18 deletions(-) create mode 100644 community.go create mode 100644 database/upgrades/2019-08-10-portal-in-community-field.go diff --git a/community.go b/community.go new file mode 100644 index 0000000..367f243 --- /dev/null +++ b/community.go @@ -0,0 +1,100 @@ +// 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 . + +package main + +import ( + "fmt" + "net/http" + + "maunium.net/go/mautrix" + appservice "maunium.net/go/mautrix-appservice" +) + +func (user *User) inviteToCommunity() { + url := user.bridge.Bot.BuildURL("groups", user.CommunityID, "admin", "users", "invite", user.MXID) + reqBody := map[string]interface{}{} + _, err := user.bridge.Bot.MakeRequest(http.MethodPut, url, &reqBody, nil) + if err != nil { + user.log.Warnln("Failed to invite user to personal filtering community %s: %v", user.CommunityID, err) + } +} + +func (user *User) updateCommunityProfile() { + url := user.bridge.Bot.BuildURL("groups", user.CommunityID, "profile") + profileReq := struct { + Name string `json:"name"` + AvatarURL string `json:"avatar_url"` + ShortDescription string `json:"short_description"` + }{"WhatsApp", user.bridge.Config.AppService.Bot.Avatar, "Your WhatsApp bridged chats"} + _, err := user.bridge.Bot.MakeRequest(http.MethodPost, url, &profileReq, nil) + if err != nil { + user.log.Warnln("Failed to update metadata of %s: %v", user.CommunityID, err) + } +} + +func (user *User) createCommunity() { + if !user.bridge.Config.Bridge.EnableCommunities() { + return + } + + localpart, server := appservice.ParseUserID(user.MXID) + community := user.bridge.Config.Bridge.FormatCommunity(localpart, server) + user.log.Debugln("Creating personal filtering community", community) + bot := user.bridge.Bot + req := struct { + Localpart string `json:"localpart"` + }{community} + resp := struct { + GroupID string `json:"group_id"` + }{} + _, err := bot.MakeRequest(http.MethodPost, bot.BuildURL("create_group"), &req, &resp) + if err != nil { + if httpErr, ok := err.(mautrix.HTTPError); ok { + if httpErr.RespError.Err != "Group already exists" { + user.log.Warnln("Server responded with error creating personal filtering community:", err) + return + } else { + resp.GroupID = fmt.Sprintf("+%s:%s", req.Localpart, user.bridge.Config.Homeserver.Domain) + user.log.Debugln("Personal filtering community", resp.GroupID, "already existed") + } + } else { + user.log.Warnln("Unknown error creating personal filtering community:", err) + return + } + } else { + user.log.Infoln("Created personal filtering community %s", resp.GroupID) + user.inviteToCommunity() + user.updateCommunityProfile() + } + user.CommunityID = resp.GroupID +} + +func (user *User) addPortalToCommunity(portal *Portal) bool { + if len(user.CommunityID) == 0 { + return false + } + bot := user.bridge.Bot + url := bot.BuildURL("groups", user.CommunityID, "admin", "rooms", portal.MXID) + reqBody := map[string]interface{}{} + _, err := bot.MakeRequest(http.MethodPut, url, &reqBody, nil) + if err != nil { + user.log.Warnln("Failed to add %s to %s: %v", portal.MXID, user.CommunityID, err) + return false + } + user.log.Debugln("Added", portal.MXID, "to", user.CommunityID) + return true +} diff --git a/config/bridge.go b/config/bridge.go index 8056a24..c5f8b5c 100644 --- a/config/bridge.go +++ b/config/bridge.go @@ -32,6 +32,7 @@ import ( type BridgeConfig struct { UsernameTemplate string `yaml:"username_template"` DisplaynameTemplate string `yaml:"displayname_template"` + CommunityTemplate string `yaml:"community_template"` ConnectionTimeout int `yaml:"connection_timeout"` LoginQRRegenCount int `yaml:"login_qr_regen_count"` @@ -51,7 +52,7 @@ type BridgeConfig struct { InviteOwnPuppetForBackfilling bool `yaml:"invite_own_puppet_for_backfilling"` PrivateChatPortalMeta bool `yaml:"private_chat_portal_meta"` - AllowUserInvite bool `yaml:"allow_user_invite"` + AllowUserInvite bool `yaml:"allow_user_invite"` CommandPrefix string `yaml:"command_prefix"` @@ -59,6 +60,7 @@ type BridgeConfig struct { usernameTemplate *template.Template `yaml:"-"` displaynameTemplate *template.Template `yaml:"-"` + communityTemplate *template.Template `yaml:"-"` } func (bc *BridgeConfig) setDefaults() { @@ -95,7 +97,18 @@ func (bc *BridgeConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { } bc.displaynameTemplate, err = template.New("displayname").Parse(bc.DisplaynameTemplate) - return err + if err != nil { + return err + } + + if len(bc.CommunityTemplate) > 0 { + bc.communityTemplate, err = template.New("community").Parse(bc.CommunityTemplate) + if err != nil { + return err + } + } + + return nil } type UsernameTemplateArgs struct { @@ -128,6 +141,21 @@ func (bc BridgeConfig) FormatUsername(userID types.WhatsAppID) string { return buf.String() } +type CommunityTemplateArgs struct { + Localpart string + Server string +} + +func (bc BridgeConfig) EnableCommunities() bool { + return bc.communityTemplate != nil +} + +func (bc BridgeConfig) FormatCommunity(localpart, server string) string { + var buf bytes.Buffer + bc.communityTemplate.Execute(&buf, CommunityTemplateArgs{localpart, server}) + return buf.String() +} + type PermissionConfig map[string]PermissionLevel type PermissionLevel int diff --git a/database/upgrades/2019-08-10-portal-in-community-field.go b/database/upgrades/2019-08-10-portal-in-community-field.go new file mode 100644 index 0000000..e5cb608 --- /dev/null +++ b/database/upgrades/2019-08-10-portal-in-community-field.go @@ -0,0 +1,12 @@ +package upgrades + +import ( + "database/sql" +) + +func init() { + upgrades[8] = upgrade{"Add columns to store portal in filtering community meta", func(dialect Dialect, tx *sql.Tx, db *sql.DB) error { + _, err := tx.Exec(`ALTER TABLE user_portal ADD COLUMN in_community BOOLEAN NOT NULL DEFAULT FALSE`) + return err + }} +} diff --git a/database/upgrades/upgrades.go b/database/upgrades/upgrades.go index 54171b1..ad18b29 100644 --- a/database/upgrades/upgrades.go +++ b/database/upgrades/upgrades.go @@ -22,7 +22,7 @@ type upgrade struct { fn upgradeFunc } -const NumberOfUpgrades = 8 +const NumberOfUpgrades = 9 var upgrades [NumberOfUpgrades]upgrade diff --git a/database/user.go b/database/user.go index 2d621cc..283eb3e 100644 --- a/database/user.go +++ b/database/user.go @@ -166,7 +166,12 @@ func (user *User) Update() { } } -func (user *User) SetPortalKeys(newKeys []PortalKey) error { +type PortalKeyWithMeta struct { + PortalKey + InCommunity bool +} + +func (user *User) SetPortalKeys(newKeys []PortalKeyWithMeta) error { tx, err := user.db.Begin() if err != nil { return err @@ -177,14 +182,16 @@ func (user *User) SetPortalKeys(newKeys []PortalKey) error { return err } valueStrings := make([]string, len(newKeys)) - values := make([]interface{}, len(newKeys)*3) + values := make([]interface{}, len(newKeys)*4) for i, key := range newKeys { - valueStrings[i] = fmt.Sprintf("($%d, $%d, $%d)", i*3+1, i*3+2, i*3+3) - values[i*3] = user.jidPtr() - values[i*3+1] = key.JID - values[i*3+2] = key.Receiver + pos := i * 4 + valueStrings[i] = fmt.Sprintf("($%d, $%d, $%d, $%d)", pos+1, pos+2, pos+3, pos+4) + values[pos] = user.jidPtr() + values[pos+1] = key.JID + values[pos+2] = key.Receiver + values[pos+3] = key.InCommunity } - query := fmt.Sprintf("INSERT INTO user_portal (user_jid, portal_jid, portal_receiver) VALUES %s", + query := fmt.Sprintf("INSERT INTO user_portal (user_jid, portal_jid, portal_receiver, in_community) VALUES %s", strings.Join(valueStrings, ", ")) _, err = tx.Exec(query, values...) if err != nil { @@ -212,3 +219,23 @@ func (user *User) GetPortalKeys() []PortalKey { } return keys } + +func (user *User) GetInCommunityMap() map[PortalKey]bool { + rows, err := user.db.Query(`SELECT portal_jid, portal_receiver, in_community FROM user_portal WHERE user_jid=$1`, user.jidPtr()) + if err != nil { + user.log.Warnln("Failed to get user portal keys:", err) + return nil + } + keys := make(map[PortalKey]bool) + for rows.Next() { + var key PortalKey + var inCommunity bool + err = rows.Scan(&key.JID, &key.Receiver, &inCommunity) + if err != nil { + user.log.Warnln("Failed to scan row:", err) + continue + } + keys[key] = inCommunity + } + return keys +} diff --git a/example-config.yaml b/example-config.yaml index 571a7ed..1fdf443 100644 --- a/example-config.yaml +++ b/example-config.yaml @@ -57,6 +57,10 @@ bridge: # {{.Name}} - display name from contact list # {{.Short}} - short display name from contact list displayname_template: "{{if .Notify}}{{.Notify}}{{else}}{{.Jid}}{{end}} (WA)" + # Localpart template for per-user room grouping community IDs. + # The bridge will create these communities and add all of the specific user's portals to the community. + # {{.Localpart}} is the MXID localpart and {{.Server}} is the MXID server part of the user. + community_template: whatsapp_{{.Localpart}}={{.Server}} # WhatsApp connection timeout in seconds. connection_timeout: 20 @@ -104,7 +108,7 @@ bridge: # but causes room avatar/name bugs. private_chat_portal_meta: false - # Allow invite permission for user. User can invite any bots to room with whatsapp + # Allow invite permission for user. User can invite any bots to room with whatsapp # users (private chat and groups) allow_user_invite: false diff --git a/user.go b/user.go index 4da90da..97c2eb7 100644 --- a/user.go +++ b/user.go @@ -29,12 +29,12 @@ import ( "github.com/skip2/go-qrcode" log "maunium.net/go/maulogger/v2" - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/format" - "github.com/Rhymen/go-whatsapp" waProto "github.com/Rhymen/go-whatsapp/binary/proto" + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/format" + "maunium.net/go/mautrix-whatsapp/database" "maunium.net/go/mautrix-whatsapp/types" "maunium.net/go/mautrix-whatsapp/whatsapp-ext" @@ -52,6 +52,7 @@ type User struct { Connected bool ConnectionErrors int + CommunityID string cleanDisconnection bool @@ -183,7 +184,7 @@ func (user *User) Connect(evenIfNoSession bool) bool { conn, err := whatsapp.NewConn(timeout * time.Second) if err != nil { user.log.Errorln("Failed to connect to WhatsApp:", err) - msg := format.RenderMarkdown("\u26a0 Failed to connect to WhatsApp server. "+ + msg := format.RenderMarkdown("\u26a0 Failed to connect to WhatsApp server. " + "This indicates a network problem on the bridge server. See bridge logs for more info.") _, _ = user.bridge.Bot.SendMessageEvent(user.ManagementRoom, mautrix.EventMessage, msg) return false @@ -202,7 +203,7 @@ func (user *User) RestoreSession() bool { return true } else if err != nil { user.log.Errorln("Failed to restore session:", err) - msg := format.RenderMarkdown("\u26a0 Failed to connect to WhatsApp. Make sure WhatsApp "+ + msg := format.RenderMarkdown("\u26a0 Failed to connect to WhatsApp. Make sure WhatsApp " + "on your phone is reachable and use `reconnect` to try connecting again.") _, _ = user.bridge.Bot.SendMessageEvent(user.ManagementRoom, mautrix.EventMessage, msg) return false @@ -338,6 +339,7 @@ func (cl ChatList) Swap(i, j int) { } func (user *User) PostLogin() { + user.log.Debugln("Locking processing of incoming messages and starting post-login sync") user.syncLock.Lock() go user.intPostLogin() } @@ -349,15 +351,18 @@ func (user *User) intPostLogin() { time.Sleep(dur) user.log.Debugfln("Waited %s, have %d chats and %d contacts", dur, len(user.Conn.Store.Chats), len(user.Conn.Store.Contacts)) + user.createCommunity() go user.syncPuppets() user.syncPortals(false) + user.log.Debugln("Post-login sync complete, unlocking processing of incoming messages") user.syncLock.Unlock() } func (user *User) syncPortals(createAll bool) { user.log.Infoln("Reading chat list") chats := make(ChatList, 0, len(user.Conn.Store.Chats)) - portalKeys := make([]database.PortalKey, 0, len(user.Conn.Store.Chats)) + existingKeys := user.GetInCommunityMap() + portalKeys := make([]database.PortalKeyWithMeta, 0, len(user.Conn.Store.Chats)) for _, chat := range user.Conn.Store.Chats { ts, err := strconv.ParseUint(chat.LastMessageTime, 10, 64) if err != nil { @@ -371,7 +376,11 @@ func (user *User) syncPortals(createAll bool) { Contact: user.Conn.Store.Contacts[chat.Jid], LastMessageTime: ts, }) - portalKeys = append(portalKeys, portal.Key) + var inCommunity, ok bool + if inCommunity, ok = existingKeys[portal.Key]; !ok || !inCommunity { + inCommunity = user.addPortalToCommunity(portal) + } + portalKeys = append(portalKeys, database.PortalKeyWithMeta{PortalKey: portal.Key, InCommunity: inCommunity}) } user.log.Infoln("Read chat list, updating user-portal mapping") err := user.SetPortalKeys(portalKeys)