diff --git a/example-config.yaml b/example-config.yaml index 2a0485c..547c325 100644 --- a/example-config.yaml +++ b/example-config.yaml @@ -81,8 +81,12 @@ bridge: listen_address: "0.0.0.0:20002" listen_secret: foobar # Should the bridge create a space for each logged-in user and add bridged rooms to it? - # Users who logged in before turning this on should run `!wa sync space` to create and fill the space for the first time. - personal_filtering_spaces: false + # Users who logged in before turning this on should run `sync space` to create and fill the space for the first time. + personal_filtering_spaces: true + # Should the bridge create a single separate space for all official accounts? + # If disabled, PMs and official accounts will be added to the same space. + # Only works if personal_filtering_spaces is enabled. + space_for_official_accounts: true # Whether the bridge should send the message status as a custom com.beeper.message_send_status event. message_status_events: false # Whether the bridge should send error notices via m.notice events when a message fails to bridge. diff --git a/internal/command.go b/internal/command.go index 496c589..3f54c1a 100644 --- a/internal/command.go +++ b/internal/command.go @@ -478,18 +478,62 @@ func fnSync(ce *WrappedCommandEvent) { ce.Reply("Personal filtering spaces are not enabled on this instance of the bridge") return } - keys := ce.Bridge.DB.Portal.FindPrivateChatsNotInSpace(ce.User.UID) - count := 0 - for _, key := range keys { - portal := ce.Bridge.GetPortalByUID(key) - portal.addToSpace(ce.User) - count++ - } - plural := "s" - if count == 1 { - plural = "" + if !ce.Bridge.Config.Bridge.SpaceForOfficialAccounts { + count := 0 + chatsToAdd := ce.Bridge.DB.Portal.FindAllChatsNotInSpace(ce.User.UID) + for _, key := range chatsToAdd { + portal := ce.Bridge.GetPortalByUID(key) + portal.addToSpace(ce.User) + count++ + } + plural := "s" + if count == 1 { + plural = "" + } + println("[DEBUG] Added", plural, "room"+plural+" to space") + ce.Reply("Added %d room%s to space", count, plural) + } else { + privateChatsToAdd := ce.Bridge.DB.Portal.FindPrivateChatsNotInSpace(ce.User.UID) + officialAccountsToAdd := ce.Bridge.DB.Portal.FindOfficialAccountsNotInOASpace(ce.User.UID) + officialAccountsToRemove := ce.Bridge.DB.Portal.FindOfficialAccountsInDefaultSpace(ce.User.UID) + privateAdded := 0 + officialAdded := 0 + officialRemoved := 0 + for _, key := range privateChatsToAdd { + portal := ce.Bridge.GetPortalByUID(key) + portal.addToSpace(ce.User) + privateAdded++ + } + for _, key := range officialAccountsToAdd { + portal := ce.Bridge.GetPortalByUID(key) + portal.addToOfficialAccountSpace(ce.User) + officialAdded++ + } + for _, key := range officialAccountsToRemove { + portal := ce.Bridge.GetPortalByUID(key) + portal.removeFromSpace(ce.User) + officialRemoved++ + } + privateAddedPlural := "s" + officialAddedPlural := "s" + if privateAdded == 1 { + privateAddedPlural = "" + } + if officialAdded == 1 { + officialAddedPlural = "" + } + officialRemovedMessage := "" + if officialRemoved > 0 { + plural := "s" + if officialRemoved == 1 { + plural = "" + } + officialRemovedMessage = ", and removed " + strconv.Itoa(officialRemoved) + " official account" + plural + " from space" + } + println("[DEBUG] Added", privateAdded, "DM room"+privateAddedPlural+" to space"+officialRemovedMessage) + ce.Reply("Added %d DM room%s and %d official account%s to space"+officialRemovedMessage, + privateAdded, privateAddedPlural, officialAdded, officialAddedPlural) } - ce.Reply("Added %d DM room%s to space", count, plural) } if groups { err := ce.User.ResyncGroups(createPortals) diff --git a/internal/config/bridgeconfig.go b/internal/config/bridgeconfig.go index 7e3c02f..d6f8417 100644 --- a/internal/config/bridgeconfig.go +++ b/internal/config/bridgeconfig.go @@ -25,7 +25,8 @@ type BridgeConfig struct { ListenAddress string `yaml:"listen_address"` ListenSecret string `yaml:"listen_secret"` - PersonalFilteringSpaces bool `yaml:"personal_filtering_spaces"` + PersonalFilteringSpaces bool `yaml:"personal_filtering_spaces"` + SpaceForOfficialAccounts bool `yaml:"space_for_official_accounts"` MessageStatusEvents bool `yaml:"message_status_events"` MessageErrorNotices bool `yaml:"message_error_notices"` diff --git a/internal/config/upgrade.go b/internal/config/upgrade.go index 03770d3..6000049 100644 --- a/internal/config/upgrade.go +++ b/internal/config/upgrade.go @@ -15,6 +15,7 @@ func DoUpgrade(helper *up.Helper) { helper.Copy(up.Str, "bridge", "listen_address") helper.Copy(up.Str, "bridge", "listen_secret") helper.Copy(up.Bool, "bridge", "personal_filtering_spaces") + helper.Copy(up.Bool, "bridge", "space_for_official_accounts") helper.Copy(up.Bool, "bridge", "message_status_events") helper.Copy(up.Bool, "bridge", "message_error_notices") helper.Copy(up.Int, "bridge", "portal_message_buffer") diff --git a/internal/database/portalquery.go b/internal/database/portalquery.go index 33ec14a..4b78310 100644 --- a/internal/database/portalquery.go +++ b/internal/database/portalquery.go @@ -66,13 +66,117 @@ func (pq *PortalQuery) FindPrivateChats(receiver types.UID) []*Portal { return pq.getAll(query, args...) } +func (pq *PortalQuery) FindAllChatsNotInSpace(receiver types.UID) []PortalKey { + keys := []PortalKey{} + + query := ` + SELECT portal.uid FROM portal + LEFT JOIN user_portal + ON portal.uid = user_portal.portal_uid + AND portal.receiver = user_portal.portal_receiver + WHERE portal.mxid != '' + AND portal.receiver = $1 + AND (user_portal.in_space = false OR user_portal.in_space IS NULL) + ` + args := []interface{}{receiver} + + rows, err := pq.db.Query(query, args...) + if err != nil || rows == nil { + return keys + } + + defer rows.Close() + for rows.Next() { + var key PortalKey + key.Receiver = receiver + err = rows.Scan(&key.UID) + if err == nil { + keys = append(keys, key) + } + } + + return keys +} + func (pq *PortalQuery) FindPrivateChatsNotInSpace(receiver types.UID) []PortalKey { keys := []PortalKey{} query := ` - SELECT uid FROM portal - LEFT JOIN user_portal ON portal.uid=user_portal.portal_uid AND portal.receiver=user_portal.portal_receiver - WHERE mxid<>'' AND receiver=$1 AND (in_space=false OR in_space IS NULL) + SELECT portal.uid FROM portal + LEFT JOIN user_portal + ON portal.uid = user_portal.portal_uid + AND portal.receiver = user_portal.portal_receiver + WHERE portal.mxid != '' + AND portal.uid NOT LIKE 'gh_%' + AND portal.receiver = $1 + AND (user_portal.in_space = false OR user_portal.in_space IS NULL) + ` + args := []interface{}{receiver} + + rows, err := pq.db.Query(query, args...) + if err != nil || rows == nil { + return keys + } + + defer rows.Close() + for rows.Next() { + var key PortalKey + key.Receiver = receiver + err = rows.Scan(&key.UID) + if err == nil { + keys = append(keys, key) + } + } + + return keys +} + +func (pq *PortalQuery) FindOfficialAccountsInDefaultSpace(receiver types.UID) []PortalKey { + keys := []PortalKey{} + + query := ` + SELECT portal.uid FROM portal + LEFT JOIN user_portal + ON portal.uid = user_portal.portal_uid + AND portal.receiver = user_portal.portal_receiver + WHERE portal.mxid != '' + AND portal.uid LIKE 'gh_%' + AND portal.receiver = $1 + AND user_portal.in_space = true + ` + args := []interface{}{receiver} + + rows, err := pq.db.Query(query, args...) + if err != nil || rows == nil { + return keys + } + + defer rows.Close() + for rows.Next() { + var key PortalKey + key.Receiver = receiver + err = rows.Scan(&key.UID) + if err == nil { + keys = append(keys, key) + } + } + + return keys +} + +func (pq *PortalQuery) FindOfficialAccountsNotInOASpace(receiver types.UID) []PortalKey { + keys := []PortalKey{} + + query := ` + SELECT portal.uid FROM portal + LEFT JOIN user_portal + ON portal.uid = user_portal.portal_uid + AND portal.receiver = user_portal.portal_receiver + WHERE portal.mxid != '' + AND portal.uid LIKE 'gh_%' + AND portal.receiver = $1 + AND (user_portal.in_official_account_space = false + OR user_portal.in_official_account_space IS NULL) ` args := []interface{}{receiver} diff --git a/internal/database/upgrades/01-add-official-account-space.sql b/internal/database/upgrades/01-add-official-account-space.sql new file mode 100644 index 0000000..2a9434e --- /dev/null +++ b/internal/database/upgrades/01-add-official-account-space.sql @@ -0,0 +1,6 @@ +-- v2: Add official account space room +ALTER TABLE "user" ADD COLUMN official_account_space_room TEXT; +UPDATE "user" SET official_account_space_room = '' WHERE official_account_space_room IS NULL; + +ALTER TABLE "user_portal" ADD COLUMN in_official_account_space BOOLEAN NOT NULL DEFAULT false; +UPDATE "user_portal" SET in_official_account_space = false WHERE in_official_account_space IS NULL; diff --git a/internal/database/user.go b/internal/database/user.go index 85bf738..7a01bdc 100644 --- a/internal/database/user.go +++ b/internal/database/user.go @@ -17,10 +17,11 @@ type User struct { db *Database log log.Logger - MXID id.UserID - UID types.UID - ManagementRoom id.RoomID - SpaceRoom id.RoomID + MXID id.UserID + UID types.UID + ManagementRoom id.RoomID + SpaceRoom id.RoomID + OfficialAccountSpaceRoom id.RoomID lastReadCache map[PortalKey]time.Time lastReadCacheLock sync.Mutex @@ -30,7 +31,7 @@ type User struct { func (u *User) Scan(row dbutil.Scannable) *User { var uin sql.NullString - err := row.Scan(&u.MXID, &uin, &u.ManagementRoom, &u.SpaceRoom) + err := row.Scan(&u.MXID, &uin, &u.ManagementRoom, &u.SpaceRoom, &u.OfficialAccountSpaceRoom) if err != nil { if err != sql.ErrNoRows { u.log.Errorln("Database scan failed:", err) @@ -47,11 +48,11 @@ func (u *User) Scan(row dbutil.Scannable) *User { func (u *User) Insert() { query := ` - INSERT INTO "user" (mxid, uin, management_room, space_room) - VALUES ($1, $2, $3, $4) + INSERT INTO "user" (mxid, uin, management_room, space_room, official_account_space_room) + VALUES ($1, $2, $3, $4, $5) ` args := []interface{}{ - u.MXID, u.UID.Uin, u.ManagementRoom, u.SpaceRoom, + u.MXID, u.UID.Uin, u.ManagementRoom, u.SpaceRoom, u.OfficialAccountSpaceRoom, } _, err := u.db.Exec(query, args...) @@ -63,11 +64,11 @@ func (u *User) Insert() { func (u *User) Update() { query := ` UPDATE "user" - SET uin=$1, management_room=$2, space_room=$3 - WHERE mxid=$4 + SET uin=$1, management_room=$2, space_room=$3, official_account_space_room=$4 + WHERE mxid=$5 ` args := []interface{}{ - u.UID.Uin, u.ManagementRoom, u.SpaceRoom, u.MXID, + u.UID.Uin, u.ManagementRoom, u.SpaceRoom, u.OfficialAccountSpaceRoom, u.MXID, } _, err := u.db.Exec(query, args...) if err != nil { diff --git a/internal/database/userportal.go b/internal/database/userportal.go index d42b21a..41a9fa6 100644 --- a/internal/database/userportal.go +++ b/internal/database/userportal.go @@ -113,3 +113,51 @@ func (u *User) MarkInSpace(portal PortalKey) { u.inSpaceCache[portal] = true } } + +func (u *User) MarkNotInSpace(portal PortalKey) { + u.inSpaceCacheLock.Lock() + defer u.inSpaceCacheLock.Unlock() + + query := ` + INSERT INTO user_portal + (user_mxid, portal_uid, portal_receiver, in_space) + VALUES ($1, $2, $3, true) + ON CONFLICT (user_mxid, portal_uid, portal_receiver) + DO UPDATE SET + in_space=false + ` + args := []interface{}{ + u.MXID, portal.UID, portal.Receiver, + } + + _, err := u.db.Exec(query, args...) + if err != nil { + u.log.Warnfln("Failed to update in space status: %v", err) + } else { + u.inSpaceCache[portal] = true + } +} + +func (u *User) MarkInOfficialAccountSpace(portal PortalKey) { + u.inSpaceCacheLock.Lock() + defer u.inSpaceCacheLock.Unlock() + + query := ` + INSERT INTO user_portal + (user_mxid, portal_uid, portal_receiver, in_space) + VALUES ($1, $2, $3, true) + ON CONFLICT (user_mxid, portal_uid, portal_receiver) + DO UPDATE SET + in_official_account_space=true + ` + args := []interface{}{ + u.MXID, portal.UID, portal.Receiver, + } + + _, err := u.db.Exec(query, args...) + if err != nil { + u.log.Warnfln("Failed to update in space status: %v", err) + } else { + u.inSpaceCache[portal] = true + } +} diff --git a/internal/database/userquery.go b/internal/database/userquery.go index dd2c19e..be80861 100644 --- a/internal/database/userquery.go +++ b/internal/database/userquery.go @@ -9,7 +9,7 @@ import ( log "maunium.net/go/maulogger/v2" ) -const userColumns = "mxid, uin, management_room, space_room" +const userColumns = "mxid, uin, management_room, space_room, official_account_space_room" type UserQuery struct { db *Database diff --git a/internal/portal.go b/internal/portal.go index 30b8ca9..87090eb 100644 --- a/internal/portal.go +++ b/internal/portal.go @@ -808,7 +808,14 @@ func (p *Portal) UpdateMatrixRoom(user *User, groupInfo *wechat.GroupInfo, force p.log.Infofln("Syncing portal %s for %s", p.Key, user.MXID) p.ensureUserInvited(user) - go p.addToSpace(user) + if strings.HasPrefix(user.UID.String(), "gh_") { + if user.IsInSpace(p.Key) { + go p.removeFromSpace(user) + } + go p.addToOfficialAccountSpace(user) + } else { + go p.addToSpace(user) + } update := false update = p.UpdateMetadata(user, groupInfo, forceAvatarSync) || update @@ -1119,7 +1126,11 @@ func (p *Portal) CreateMatrixRoom(user *User, groupInfo *wechat.GroupInfo, isFul p.ensureUserInvited(user) // TODO: sync chat double puppet detail - go p.addToSpace(user) + if strings.HasPrefix(user.UID.String(), "gh_") { + go p.addToOfficialAccountSpace(user) + } else { + go p.addToSpace(user) + } if groupInfo != nil { p.SyncParticipants(user, groupInfo, true) @@ -1165,6 +1176,36 @@ func (p *Portal) addToSpace(user *User) { } } +func (p *Portal) removeFromSpace(user *User) { + spaceID := user.GetSpaceRoom() + if len(spaceID) == 0 || !user.IsInSpace(p.Key) { + return + } + _, err := p.bridge.Bot.SendStateEvent(spaceID, event.StateSpaceChild, p.MXID.String(), nil) + if err != nil { + p.log.Errorfln("Failed to remove room from %s's personal filtering space (%s): %v", user.MXID, spaceID, err) + } else { + p.log.Debugfln("Removed room from %s's personal filtering space (%s)", user.MXID, spaceID) + user.MarkNotInSpace(p.Key) + } +} + +func (p *Portal) addToOfficialAccountSpace(user *User) { + spaceID := user.GetOfficialAccountSpaceRoom() + if len(spaceID) == 0 { + return + } + _, err := p.bridge.Bot.SendStateEvent(spaceID, event.StateSpaceChild, p.MXID.String(), &event.SpaceChildEventContent{ + Via: []string{p.bridge.Config.Homeserver.Domain}, + }) + if err != nil { + p.log.Errorfln("Failed to add room to %s's personal filtering space (%s): %v", user.MXID, spaceID, err) + } else { + p.log.Debugfln("Added room to %s's personal filtering space (%s)", user.MXID, spaceID) + user.MarkInOfficialAccountSpace(p.Key) + } +} + func (p *Portal) IsPrivateChat() bool { return p.Key.UID.Type == types.User } diff --git a/internal/user.go b/internal/user.go index 319eff9..82936ea 100644 --- a/internal/user.go +++ b/internal/user.go @@ -56,7 +56,8 @@ type User struct { spaceCreateLock sync.Mutex connLock sync.Mutex - spaceMembershipChecked bool + spaceMembershipChecked bool + officialAccountSpaceMembershipChecked bool BridgeState *bridge.BridgeStateQueue @@ -269,6 +270,59 @@ func (u *User) GetSpaceRoom() id.RoomID { return u.SpaceRoom } +func (u *User) GetOfficialAccountSpaceRoom() id.RoomID { + if !u.bridge.Config.Bridge.PersonalFilteringSpaces { + return "" + } + if !u.bridge.Config.Bridge.SpaceForOfficialAccounts { + return "" + } + + if len(u.OfficialAccountSpaceRoom) == 0 { + u.spaceCreateLock.Lock() + defer u.spaceCreateLock.Unlock() + if len(u.OfficialAccountSpaceRoom) > 0 { + return u.OfficialAccountSpaceRoom + } + + resp, err := u.bridge.Bot.CreateRoom(&mautrix.ReqCreateRoom{ + Visibility: "private", + Name: "WeChat Official Accounts", + Topic: "Your WeChat bridged official accounts", + InitialState: []*event.Event{{ + Type: event.StateRoomAvatar, + Content: event.Content{ + Parsed: &event.RoomAvatarEventContent{ + URL: u.bridge.Config.AppService.Bot.ParsedAvatar, + }, + }, + }}, + CreationContent: map[string]interface{}{ + "type": event.RoomTypeSpace, + }, + PowerLevelOverride: &event.PowerLevelsEventContent{ + Users: map[id.UserID]int{ + u.bridge.Bot.UserID: 9001, + u.MXID: 50, + }, + }, + }) + + if err != nil { + u.log.Errorln("Failed to auto-create space room for official accounts:", err) + } else { + u.OfficialAccountSpaceRoom = resp.RoomID + u.Update() + u.ensureInvited(u.bridge.Bot, u.OfficialAccountSpaceRoom, false) + } + } else if !u.officialAccountSpaceMembershipChecked && !u.bridge.StateStore.IsInRoom(u.OfficialAccountSpaceRoom, u.MXID) { + u.ensureInvited(u.bridge.Bot, u.OfficialAccountSpaceRoom, false) + } + u.officialAccountSpaceMembershipChecked = true + + return u.OfficialAccountSpaceRoom +} + func (u *User) GetManagementRoom() id.RoomID { if len(u.ManagementRoom) == 0 { u.mgmtCreateLock.Lock()