Browse Source

Add portal rooms to user-specific community for filtering

Tulir Asokan 5 years ago
parent
commit
7bf470d69e

+ 100 - 0
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 <https://www.gnu.org/licenses/>.
+
+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
+}

+ 30 - 2
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

+ 12 - 0
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
+	}}
+}

+ 1 - 1
database/upgrades/upgrades.go

@@ -22,7 +22,7 @@ type upgrade struct {
 	fn upgradeFunc
 }
 
-const NumberOfUpgrades = 8
+const NumberOfUpgrades = 9
 
 var upgrades [NumberOfUpgrades]upgrade
 

+ 35 - 8
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
-	}
-	query := fmt.Sprintf("INSERT INTO user_portal (user_jid, portal_jid, portal_receiver) VALUES %s",
+		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, 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
+}

+ 5 - 1
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
 

+ 16 - 7
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)