瀏覽代碼

Add support for communities

Tulir Asokan 2 年之前
父節點
當前提交
a1192bd0a4
共有 10 個文件被更改,包括 182 次插入31 次删除
  1. 1 0
      CHANGELOG.md
  2. 1 0
      ROADMAP.md
  3. 17 2
      commands.go
  4. 31 14
      database/portal.go
  5. 5 1
      database/upgrades/00-latest-revision.sql
  6. 5 0
      database/upgrades/52-communities.sql
  7. 3 3
      go.mod
  8. 6 6
      go.sum
  9. 105 5
      portal.go
  10. 8 0
      user.go

+ 1 - 0
CHANGELOG.md

@@ -3,6 +3,7 @@
 * Added support for bridging polls from WhatsApp and votes in both directions.
   * Votes are only bridged if MSC3381 polls are enabled
     (`extev_polls` in the config).
+* Added support for bridging WhatsApp communities as spaces.
 * Updated backfill logic to mark rooms as read if the only message is a notice
   about the disappearing message timer.
 * Switched SQLite config from `sqlite3` to `sqlite3-fk-wal` to enforce foreign

+ 1 - 0
ROADMAP.md

@@ -36,6 +36,7 @@
   * [ ] Chat types
     * [x] Private chat
     * [x] Group chat
+    * [x] Communities
     * [x] Status broadcast
     * [ ] Broadcast list (not currently supported on WhatsApp web)
   * [x] Message deletions

+ 17 - 2
commands.go

@@ -356,6 +356,13 @@ func fnCreate(ce *WrappedCommandEvent) {
 		return
 	}
 
+	var createEvent event.CreateEventContent
+	err = ce.Bot.StateEvent(ce.RoomID, event.StateCreate, "", &createEvent)
+	if err != nil && !errors.Is(err, mautrix.MNotFound) {
+		ce.Reply("Failed to get room create event")
+		return
+	}
+
 	var participants []types.JID
 	participantDedup := make(map[types.JID]bool)
 	participantDedup[ce.User.JID.ToNonAD()] = true
@@ -373,9 +380,16 @@ func fnCreate(ce *WrappedCommandEvent) {
 			participants = append(participants, jid)
 		}
 	}
+	// TODO check m.space.parent to create rooms directly in communities
 
 	ce.Log.Infofln("Creating group for %s with name %s and participants %+v", ce.RoomID, roomNameEvent.Name, participants)
-	resp, err := ce.User.Client.CreateGroup(roomNameEvent.Name, participants, "")
+	resp, err := ce.User.Client.CreateGroup(whatsmeow.ReqCreateGroup{
+		Name:         roomNameEvent.Name,
+		Participants: participants,
+		GroupParent: types.GroupParent{
+			IsParent: createEvent.Type == event.RoomTypeSpace,
+		},
+	})
 	if err != nil {
 		ce.Reply("Failed to create group: %v", err)
 		return
@@ -389,6 +403,7 @@ func fnCreate(ce *WrappedCommandEvent) {
 	}
 	portal.MXID = ce.RoomID
 	portal.Name = roomNameEvent.Name
+	portal.IsParent = resp.IsParent
 	portal.Encrypted = encryptionEvent.Algorithm == id.AlgorithmMegolmV1
 	if !portal.Encrypted && ce.Bridge.Config.Bridge.Encryption.Default {
 		_, err = portal.MainIntent().SendStateEvent(portal.MXID, event.StateEncryption, "", portal.GetEncryptionEventContent())
@@ -1127,7 +1142,7 @@ func fnSync(ce *WrappedCommandEvent) {
 		count := 0
 		for _, key := range keys {
 			portal := ce.Bridge.GetPortalByJID(key)
-			portal.addToSpace(ce.User)
+			portal.addToPersonalSpace(ce.User)
 			count++
 		}
 		plural := "s"

+ 31 - 14
database/portal.go

@@ -65,7 +65,7 @@ func (pq *PortalQuery) New() *Portal {
 	}
 }
 
-const portalColumns = "jid, receiver, mxid, name, name_set, topic, topic_set, avatar, avatar_url, avatar_set, encrypted, last_sync, first_event_id, next_batch_id, relay_user_id, expiration_time"
+const portalColumns = "jid, receiver, mxid, name, name_set, topic, topic_set, avatar, avatar_url, avatar_set, encrypted, last_sync, is_parent, parent_group, in_space, first_event_id, next_batch_id, relay_user_id, expiration_time"
 
 func (pq *PortalQuery) GetAll() []*Portal {
 	return pq.getAll(fmt.Sprintf("SELECT %s FROM portal", portalColumns))
@@ -145,18 +145,20 @@ type Portal struct {
 	Encrypted bool
 	LastSync  time.Time
 
-	FirstEventID id.EventID
-	NextBatchID  id.BatchID
-
-	RelayUserID id.UserID
+	IsParent    bool
+	ParentGroup types.JID
+	InSpace     bool
 
+	FirstEventID   id.EventID
+	NextBatchID    id.BatchID
+	RelayUserID    id.UserID
 	ExpirationTime uint32
 }
 
 func (portal *Portal) Scan(row dbutil.Scannable) *Portal {
-	var mxid, avatarURL, firstEventID, nextBatchID, relayUserID sql.NullString
+	var mxid, avatarURL, firstEventID, nextBatchID, relayUserID, parentGroupJID sql.NullString
 	var lastSyncTs int64
-	err := row.Scan(&portal.Key.JID, &portal.Key.Receiver, &mxid, &portal.Name, &portal.NameSet, &portal.Topic, &portal.TopicSet, &portal.Avatar, &avatarURL, &portal.AvatarSet, &portal.Encrypted, &lastSyncTs, &firstEventID, &nextBatchID, &relayUserID, &portal.ExpirationTime)
+	err := row.Scan(&portal.Key.JID, &portal.Key.Receiver, &mxid, &portal.Name, &portal.NameSet, &portal.Topic, &portal.TopicSet, &portal.Avatar, &avatarURL, &portal.AvatarSet, &portal.Encrypted, &lastSyncTs, &portal.IsParent, &parentGroupJID, &portal.InSpace, &firstEventID, &nextBatchID, &relayUserID, &portal.ExpirationTime)
 	if err != nil {
 		if err != sql.ErrNoRows {
 			portal.log.Errorln("Database scan failed:", err)
@@ -168,6 +170,9 @@ func (portal *Portal) Scan(row dbutil.Scannable) *Portal {
 	}
 	portal.MXID = id.RoomID(mxid.String)
 	portal.AvatarURL, _ = id.ParseContentURI(avatarURL.String)
+	if parentGroupJID.Valid {
+		portal.ParentGroup, _ = types.ParseJID(parentGroupJID.String)
+	}
 	portal.FirstEventID = id.EventID(firstEventID.String)
 	portal.NextBatchID = id.BatchID(nextBatchID.String)
 	portal.RelayUserID = id.UserID(relayUserID.String)
@@ -188,6 +193,14 @@ func (portal *Portal) relayUserPtr() *id.UserID {
 	return nil
 }
 
+func (portal *Portal) parentGroupPtr() *string {
+	if !portal.ParentGroup.IsEmpty() {
+		val := portal.ParentGroup.String()
+		return &val
+	}
+	return nil
+}
+
 func (portal *Portal) lastSyncTs() int64 {
 	if portal.LastSync.IsZero() {
 		return 0
@@ -198,12 +211,14 @@ func (portal *Portal) lastSyncTs() int64 {
 func (portal *Portal) Insert() {
 	_, err := portal.db.Exec(`
 		INSERT INTO portal (jid, receiver, mxid, name, name_set, topic, topic_set, avatar, avatar_url, avatar_set,
-		                    encrypted, last_sync, first_event_id, next_batch_id, relay_user_id, expiration_time)
-		VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
+		                    encrypted, last_sync, is_parent, parent_group, in_space, first_event_id, next_batch_id,
+		                    relay_user_id, expiration_time)
+		VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
 	`,
 		portal.Key.JID, portal.Key.Receiver, portal.mxidPtr(), portal.Name, portal.NameSet, portal.Topic, portal.TopicSet,
 		portal.Avatar, portal.AvatarURL.String(), portal.AvatarSet, portal.Encrypted, portal.lastSyncTs(),
-		portal.FirstEventID.String(), portal.NextBatchID.String(), portal.relayUserPtr(), portal.ExpirationTime)
+		portal.IsParent, portal.parentGroupPtr(), portal.InSpace, portal.FirstEventID.String(), portal.NextBatchID.String(),
+		portal.relayUserPtr(), portal.ExpirationTime)
 	if err != nil {
 		portal.log.Warnfln("Failed to insert %s: %v", portal.Key, err)
 	}
@@ -216,11 +231,13 @@ func (portal *Portal) Update(txn dbutil.Execable) {
 	_, err := txn.Exec(`
 		UPDATE portal
 		SET mxid=$1, name=$2, name_set=$3, topic=$4, topic_set=$5, avatar=$6, avatar_url=$7, avatar_set=$8,
-		    encrypted=$9, last_sync=$10, first_event_id=$11, next_batch_id=$12, relay_user_id=$13, expiration_time=$14
-		WHERE jid=$15 AND receiver=$16
+		    encrypted=$9, last_sync=$10, is_parent=$11, parent_group=$12, in_space=$13,
+		    first_event_id=$14, next_batch_id=$15, relay_user_id=$16, expiration_time=$17
+		WHERE jid=$18 AND receiver=$19
 	`, portal.mxidPtr(), portal.Name, portal.NameSet, portal.Topic, portal.TopicSet, portal.Avatar, portal.AvatarURL.String(),
-		portal.AvatarSet, portal.Encrypted, portal.lastSyncTs(), portal.FirstEventID.String(), portal.NextBatchID.String(),
-		portal.relayUserPtr(), portal.ExpirationTime, portal.Key.JID, portal.Key.Receiver)
+		portal.AvatarSet, portal.Encrypted, portal.lastSyncTs(), portal.IsParent, portal.parentGroupPtr(), portal.InSpace,
+		portal.FirstEventID.String(), portal.NextBatchID.String(), portal.relayUserPtr(), portal.ExpirationTime,
+		portal.Key.JID, portal.Key.Receiver)
 	if err != nil {
 		portal.log.Warnfln("Failed to update %s: %v", portal.Key, err)
 	}

+ 5 - 1
database/upgrades/00-latest-revision.sql

@@ -1,4 +1,4 @@
--- v0 -> v51: Latest revision
+-- v0 -> v52: Latest revision
 
 CREATE TABLE "user" (
     mxid     TEXT PRIMARY KEY,
@@ -29,6 +29,10 @@ CREATE TABLE portal (
     encrypted  BOOLEAN NOT NULL DEFAULT false,
     last_sync  BIGINT NOT NULL DEFAULT 0,
 
+    is_parent    BOOLEAN NOT NULL DEFAULT false,
+    parent_group TEXT,
+    in_space     BOOLEAN NOT NULL DEFAULT false,
+
     first_event_id  TEXT,
     next_batch_id   TEXT,
     relay_user_id   TEXT,

+ 5 - 0
database/upgrades/52-communities.sql

@@ -0,0 +1,5 @@
+-- v52: Store portal metadata for communities
+
+ALTER TABLE portal ADD COLUMN is_parent BOOLEAN NOT NULL DEFAULT false;
+ALTER TABLE portal ADD COLUMN parent_group TEXT;
+ALTER TABLE portal ADD COLUMN in_space BOOLEAN NOT NULL DEFAULT false;

+ 3 - 3
go.mod

@@ -10,13 +10,13 @@ require (
 	github.com/mattn/go-sqlite3 v1.14.16
 	github.com/prometheus/client_golang v1.14.0
 	github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
-	github.com/tidwall/gjson v1.14.3
-	go.mau.fi/whatsmeow v0.0.0-20221126173344-e660988acdbc
+	github.com/tidwall/gjson v1.14.4
+	go.mau.fi/whatsmeow v0.0.0-20221202110551-e067ee7293b0
 	golang.org/x/image v0.1.0
 	golang.org/x/net v0.2.0
 	google.golang.org/protobuf v1.28.1
 	maunium.net/go/maulogger/v2 v2.3.2
-	maunium.net/go/mautrix v0.12.4-0.20221122192554-26c9ef6e7157
+	maunium.net/go/mautrix v0.12.4-0.20221201124911-2c57226ad4cd
 )
 
 require (

+ 6 - 6
go.sum

@@ -53,8 +53,8 @@ github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1
 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
 github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
 github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
-github.com/tidwall/gjson v1.14.3 h1:9jvXn7olKEHU1S9vwoMGliaT8jq1vJ7IH/n9zD9Dnlw=
-github.com/tidwall/gjson v1.14.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
+github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM=
+github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
 github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
 github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
 github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
@@ -66,8 +66,8 @@ github.com/yuin/goldmark v1.5.3 h1:3HUJmBFbQW9fhQOzMgseU134xfi6hU+mjWywx5Ty+/M=
 github.com/yuin/goldmark v1.5.3/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
 go.mau.fi/libsignal v0.0.0-20221015105917-d970e7c3c9cf h1:mzPxXBgDPHKDHMVV1tIWh7lwCiRpzCsXC0gNRX+K07c=
 go.mau.fi/libsignal v0.0.0-20221015105917-d970e7c3c9cf/go.mod h1:XCjaU93vl71YNRPn059jMrK0xRDwVO5gKbxoPxow9mQ=
-go.mau.fi/whatsmeow v0.0.0-20221126173344-e660988acdbc h1:uZCZs8Ju83OmM1A1+VhpZMXpvVAg5BEQNP0KBXALJBI=
-go.mau.fi/whatsmeow v0.0.0-20221126173344-e660988acdbc/go.mod h1:2yweL8nczvtlIxkrvCb0y8xiO13rveX9lJPambwYV/E=
+go.mau.fi/whatsmeow v0.0.0-20221202110551-e067ee7293b0 h1:danzDOlj/KiDi8kNsaHOhwJ7IZdo7V7hXelkZXhJhsc=
+go.mau.fi/whatsmeow v0.0.0-20221202110551-e067ee7293b0/go.mod h1:2yweL8nczvtlIxkrvCb0y8xiO13rveX9lJPambwYV/E=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
 golang.org/x/crypto v0.2.0 h1:BRXPfhNivWL5Yq0BGQ39a2sW6t44aODpfxkWjYdzewE=
@@ -122,5 +122,5 @@ maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M=
 maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA=
 maunium.net/go/maulogger/v2 v2.3.2 h1:1XmIYmMd3PoQfp9J+PaHhpt80zpfmMqaShzUTC7FwY0=
 maunium.net/go/maulogger/v2 v2.3.2/go.mod h1:TYWy7wKwz/tIXTpsx8G3mZseIRiC5DoMxSZazOHy68A=
-maunium.net/go/mautrix v0.12.4-0.20221122192554-26c9ef6e7157 h1:x2SiQnZQeJJ8qYCwEJ/rN0SEpGgT/Ct8kqjB4y/7vM4=
-maunium.net/go/mautrix v0.12.4-0.20221122192554-26c9ef6e7157/go.mod h1:uOUjkOjm2C+nQS3mr9B5ATjqemZfnPHvjdd1kZezAwg=
+maunium.net/go/mautrix v0.12.4-0.20221201124911-2c57226ad4cd h1:RHe8UuNE3opwiwvj4gRN7o5RYQYy9Gg8IsHxV218ms0=
+maunium.net/go/mautrix v0.12.4-0.20221201124911-2c57226ad4cd/go.mod h1:uOUjkOjm2C+nQS3mr9B5ATjqemZfnPHvjdd1kZezAwg=

+ 105 - 5
portal.go

@@ -248,6 +248,7 @@ type Portal struct {
 	avatarLock     sync.Mutex
 
 	latestEventBackfillLock sync.Mutex
+	parentGroupUpdateLock   sync.Mutex
 
 	recentlyHandled      [recentlyHandledLength]recentlyHandledWrapper
 	recentlyHandledLock  sync.Mutex
@@ -262,7 +263,8 @@ type Portal struct {
 
 	mediaErrorCache map[types.MessageID]*FailedMediaMeta
 
-	relayUser *User
+	relayUser    *User
+	parentPortal *Portal
 }
 
 var (
@@ -1194,6 +1196,27 @@ func (portal *Portal) UpdateTopic(topic string, setBy types.JID, updateInfo bool
 	return false
 }
 
+func (portal *Portal) UpdateParentGroup(parent types.JID, updateInfo bool) bool {
+	portal.parentGroupUpdateLock.Lock()
+	defer portal.parentGroupUpdateLock.Unlock()
+	if portal.ParentGroup != parent {
+		portal.log.Debugfln("Updating parent group %v -> %v", portal.ParentGroup, parent)
+		portal.updateCommunitySpace(false)
+		portal.ParentGroup = parent
+		portal.parentPortal = nil
+		portal.InSpace = false
+		portal.updateCommunitySpace(true)
+		if updateInfo {
+			portal.UpdateBridgeInfo()
+			portal.Update(nil)
+		}
+		return true
+	} else if !portal.ParentGroup.IsEmpty() && !portal.InSpace {
+		return portal.updateCommunitySpace(true)
+	}
+	return false
+}
+
 func (portal *Portal) UpdateMetadata(user *User, groupInfo *types.GroupInfo) bool {
 	if portal.IsPrivateChat() {
 		return false
@@ -1230,10 +1253,18 @@ func (portal *Portal) UpdateMetadata(user *User, groupInfo *types.GroupInfo) boo
 	update := false
 	update = portal.UpdateName(groupInfo.Name, groupInfo.NameSetBy, false) || update
 	update = portal.UpdateTopic(groupInfo.Topic, groupInfo.TopicSetBy, false) || update
+	update = portal.UpdateParentGroup(groupInfo.LinkedParentJID, false) || update
 	if portal.ExpirationTime != groupInfo.DisappearingTimer {
 		update = true
 		portal.ExpirationTime = groupInfo.DisappearingTimer
 	}
+	if portal.IsParent != groupInfo.IsParent {
+		if portal.MXID != "" {
+			portal.log.Warnfln("Existing group changed is_parent from %t to %t", portal.IsParent, groupInfo.IsParent)
+		}
+		update = true
+		portal.IsParent = true
+	}
 
 	portal.RestrictMessageSending(groupInfo.IsAnnounce)
 	portal.RestrictMetadataChanges(groupInfo.IsLocked)
@@ -1252,7 +1283,7 @@ func (portal *Portal) UpdateMatrixRoom(user *User, groupInfo *types.GroupInfo) b
 	portal.log.Infoln("Syncing portal for", user.MXID)
 
 	portal.ensureUserInvited(user)
-	go portal.addToSpace(user)
+	go portal.addToPersonalSpace(user)
 
 	update := false
 	update = portal.UpdateMetadata(user, groupInfo) || update
@@ -1401,6 +1432,13 @@ func (portal *Portal) getBridgeInfo() (string, event.BridgeEventContent) {
 			AvatarURL:   portal.AvatarURL.CUString(),
 		},
 	}
+	if parent := portal.GetParentPortal(); parent != nil {
+		bridgeInfo.Network = &event.BridgeInfoSection{
+			ID:          parent.Key.JID.String(),
+			DisplayName: parent.Name,
+			AvatarURL:   parent.AvatarURL.CUString(),
+		}
+	}
 	return portal.getBridgeInfoStateKey(), bridgeInfo
 }
 
@@ -1502,6 +1540,8 @@ func (portal *Portal) CreateMatrixRoom(user *User, groupInfo *types.GroupInfo, i
 		if groupInfo != nil {
 			portal.Name = groupInfo.Name
 			portal.Topic = groupInfo.Topic
+			portal.IsParent = groupInfo.IsParent
+			portal.ParentGroup = groupInfo.LinkedParentJID
 		}
 		portal.UpdateAvatar(user, types.EmptyJID, false)
 	}
@@ -1552,6 +1592,19 @@ func (portal *Portal) CreateMatrixRoom(user *User, groupInfo *types.GroupInfo, i
 	if !portal.bridge.Config.Bridge.FederateRooms {
 		creationContent["m.federate"] = false
 	}
+	if portal.IsParent {
+		creationContent["type"] = event.RoomTypeSpace
+	} else if parent := portal.GetParentPortal(); parent != nil {
+		initialState = append(initialState, &event.Event{
+			Type: event.StateSpaceParent,
+			Content: event.Content{
+				Parsed: &event.SpaceParentEventContent{
+					Via:       []string{portal.bridge.Config.Homeserver.Domain},
+					Canonical: true,
+				},
+			},
+		})
+	}
 	autoJoinInvites := portal.bridge.Config.Homeserver.Software == bridgeconfig.SoftwareHungry
 	if autoJoinInvites {
 		portal.log.Debugfln("Hungryserv mode: adding all group members in create request")
@@ -1582,14 +1635,16 @@ func (portal *Portal) CreateMatrixRoom(user *User, groupInfo *types.GroupInfo, i
 	if err != nil {
 		return err
 	}
+	portal.log.Infoln("Matrix room created:", portal.MXID)
+	portal.InSpace = false
 	portal.NameSet = len(portal.Name) > 0
 	portal.TopicSet = len(portal.Topic) > 0
 	portal.MXID = resp.RoomID
 	portal.bridge.portalsLock.Lock()
 	portal.bridge.portalsByMXID[portal.MXID] = portal
 	portal.bridge.portalsLock.Unlock()
+	portal.updateCommunitySpace(true)
 	portal.Update(nil)
-	portal.log.Infoln("Matrix room created:", portal.MXID)
 
 	// We set the memberships beforehand to make sure the encryption key exchange in initial backfill knows the users are here.
 	inviteMembership := event.MembershipInvite
@@ -1605,7 +1660,7 @@ func (portal *Portal) CreateMatrixRoom(user *User, groupInfo *types.GroupInfo, i
 	}
 	user.syncChatDoublePuppetDetails(portal, true)
 
-	go portal.addToSpace(user)
+	go portal.addToPersonalSpace(user)
 
 	if groupInfo != nil {
 		if groupInfo.IsEphemeral {
@@ -1655,7 +1710,7 @@ func (portal *Portal) CreateMatrixRoom(user *User, groupInfo *types.GroupInfo, i
 	return nil
 }
 
-func (portal *Portal) addToSpace(user *User) {
+func (portal *Portal) addToPersonalSpace(user *User) {
 	spaceID := user.GetSpaceRoom()
 	if len(spaceID) == 0 || user.IsInSpace(portal.Key) {
 		return
@@ -1671,6 +1726,42 @@ func (portal *Portal) addToSpace(user *User) {
 	}
 }
 
+func (portal *Portal) updateCommunitySpace(add bool) bool {
+	if add == portal.InSpace {
+		return false
+	}
+	space := portal.GetParentPortal()
+	if space == nil || space.MXID == "" {
+		return false
+	}
+
+	var action string
+	var parentContent event.SpaceParentEventContent
+	var childContent event.SpaceChildEventContent
+	if add {
+		parentContent.Canonical = true
+		parentContent.Via = []string{portal.bridge.Config.Homeserver.Domain}
+		childContent.Via = []string{portal.bridge.Config.Homeserver.Domain}
+		action = "add portal to"
+		portal.log.Debugfln("Adding %s to space %s (%s)", portal.MXID, space.MXID, space.Key.JID)
+	} else {
+		action = "remove portal from"
+		portal.log.Debugfln("Removing %s from space %s (%s)", portal.MXID, space.MXID, space.Key.JID)
+	}
+
+	_, err := space.MainIntent().SendStateEvent(space.MXID, event.StateSpaceChild, portal.MXID.String(), &childContent)
+	if err != nil {
+		portal.log.Errorfln("Failed to send m.space.child event to %s %s: %v", action, space.MXID, err)
+		return false
+	}
+	_, err = portal.MainIntent().SendStateEvent(portal.MXID, event.StateSpaceParent, space.MXID.String(), &parentContent)
+	if err != nil {
+		portal.log.Warnfln("Failed to send m.space.parent event to %s %s: %v", action, space.MXID, err)
+	}
+	portal.InSpace = add
+	return true
+}
+
 func (portal *Portal) IsPrivateChat() bool {
 	return portal.Key.JID.Server == types.DefaultUserServer
 }
@@ -1700,6 +1791,15 @@ func (portal *Portal) GetRelayUser() *User {
 	return portal.relayUser
 }
 
+func (portal *Portal) GetParentPortal() *Portal {
+	if portal.ParentGroup.IsEmpty() {
+		return nil
+	} else if portal.parentPortal == nil {
+		portal.parentPortal = portal.bridge.GetPortalByJID(database.NewPortalKey(portal.ParentGroup, portal.ParentGroup))
+	}
+	return portal.parentPortal
+}
+
 func (portal *Portal) MainIntent() *appservice.IntentAPI {
 	if portal.IsPrivateChat() {
 		return portal.bridge.GetPuppetByJID(portal.Key.JID).DefaultIntent()

+ 8 - 0
user.go

@@ -1322,6 +1322,14 @@ func (user *User) handleGroupUpdate(evt *events.GroupInfo) {
 		portal.ChangeAdminStatus(evt.Demote, false)
 	case evt.Ephemeral != nil:
 		portal.UpdateGroupDisappearingMessages(evt.Sender, evt.Timestamp, evt.Ephemeral.DisappearingTimer)
+	case evt.Link != nil:
+		if evt.Link.Type == types.GroupLinkChangeTypeParent {
+			portal.UpdateParentGroup(evt.Link.Group.JID, true)
+		}
+	case evt.Unlink != nil:
+		if evt.Unlink.Type == types.GroupLinkChangeTypeParent && portal.ParentGroup == evt.Unlink.Group.JID {
+			portal.UpdateParentGroup(types.EmptyJID, true)
+		}
 	}
 }