diff --git a/bridgev2/commands/startchat.go b/bridgev2/commands/startchat.go index da246f50..7b755064 100644 --- a/bridgev2/commands/startchat.go +++ b/bridgev2/commands/startchat.go @@ -7,13 +7,20 @@ package commands import ( + "context" "fmt" "html" + "maps" + "slices" "strings" + "github.com/rs/zerolog" + "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/networkid" "maunium.net/go/mautrix/bridgev2/provisionutil" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" ) var CommandResolveIdentifier = &FullHandler{ @@ -100,6 +107,97 @@ func fnResolveIdentifier(ce *Event) { } } +var CommandCreateGroup = &FullHandler{ + Func: fnCreateGroup, + Name: "create-group", + Aliases: []string{"create"}, + Help: HelpMeta{ + Section: HelpSectionChats, + Description: "Create a new group chat for the current Matrix room", + Args: "[_group type_]", + }, + RequiresLogin: true, + NetworkAPI: NetworkAPIImplements[bridgev2.GroupCreatingNetworkAPI], +} + +func getState[T any](ctx context.Context, roomID id.RoomID, evtType event.Type, provider bridgev2.MatrixConnectorWithArbitraryRoomState) (content T) { + evt, err := provider.GetStateEvent(ctx, roomID, evtType, "") + if err != nil { + zerolog.Ctx(ctx).Err(err).Stringer("event_type", evtType).Msg("Failed to get state event for group creation") + } else if evt != nil { + content, _ = evt.Content.Parsed.(T) + } + return +} + +func fnCreateGroup(ce *Event) { + ce.Bridge.Matrix.GetCapabilities() + login, api, remainingArgs := getClientForStartingChat[bridgev2.GroupCreatingNetworkAPI](ce, "creating group") + if api == nil { + return + } + stateProvider, ok := ce.Bridge.Matrix.(bridgev2.MatrixConnectorWithArbitraryRoomState) + if !ok { + ce.Reply("Matrix connector doesn't support fetching room state") + return + } + members, err := ce.Bridge.Matrix.GetMembers(ce.Ctx, ce.RoomID) + if err != nil { + ce.Log.Err(err).Msg("Failed to get room members for group creation") + ce.Reply("Failed to get room members: %v", err) + return + } + caps := ce.Bridge.Network.GetCapabilities() + params := &bridgev2.GroupCreateParams{ + Username: "", + Participants: make([]networkid.UserID, 0, len(members)-2), + Parent: nil, // TODO check space parent event + Name: getState[*event.RoomNameEventContent](ce.Ctx, ce.RoomID, event.StateRoomName, stateProvider), + Avatar: getState[*event.RoomAvatarEventContent](ce.Ctx, ce.RoomID, event.StateRoomAvatar, stateProvider), + Topic: getState[*event.TopicEventContent](ce.Ctx, ce.RoomID, event.StateTopic, stateProvider), + Disappear: getState[*event.BeeperDisappearingTimer](ce.Ctx, ce.RoomID, event.StateBeeperDisappearingTimer, stateProvider), + RoomID: ce.RoomID, + } + for userID, member := range members { + if userID == ce.User.MXID || userID == ce.Bot.GetMXID() || !member.Membership.IsInviteOrJoin() { + continue + } + if parsedUserID, ok := ce.Bridge.Matrix.ParseGhostMXID(userID); ok { + params.Participants = append(params.Participants, parsedUserID) + } else if !ce.Bridge.Config.SplitPortals { + if user, err := ce.Bridge.GetExistingUserByMXID(ce.Ctx, userID); err != nil { + ce.Log.Err(err).Stringer("user_id", userID).Msg("Failed to get user for room member") + } else if user != nil { + // TODO add user logins to participants + //for _, login := range user.GetUserLogins() { + // params.Participants = append(params.Participants, login.GetUserID()) + //} + } + } + } + + if len(caps.Provisioning.GroupCreation) == 0 { + ce.Reply("No group creation types defined in network capabilities") + return + } else if len(remainingArgs) > 0 { + params.Type = remainingArgs[0] + } else if len(caps.Provisioning.GroupCreation) == 1 { + for params.Type = range caps.Provisioning.GroupCreation { + // The loop assigns the variable we want + } + } else { + types := strings.Join(slices.Collect(maps.Keys(caps.Provisioning.GroupCreation)), "`, `") + ce.Reply("Please specify type of group to create: `%s`", types) + return + } + resp, err := provisionutil.CreateGroup(ce.Ctx, login, params) + if err != nil { + ce.Reply("Failed to create group: %v", err) + return + } + ce.Reply("Successfully created group `%s`", resp.ID) +} + var CommandSearch = &FullHandler{ Func: fnSearch, Name: "search", diff --git a/bridgev2/matrix/provisioning.go b/bridgev2/matrix/provisioning.go index 02ad6abd..4e11aa22 100644 --- a/bridgev2/matrix/provisioning.go +++ b/bridgev2/matrix/provisioning.go @@ -120,6 +120,7 @@ func (prov *ProvisioningAPI) Init() { tp.Transport.TLSHandshakeTimeout = 10 * time.Second prov.Router = http.NewServeMux() prov.Router.HandleFunc("GET /v3/whoami", prov.GetWhoami) + prov.Router.HandleFunc("GET /v3/capabilities", prov.GetCapabilities) prov.Router.HandleFunc("GET /v3/login/flows", prov.GetLoginFlows) prov.Router.HandleFunc("POST /v3/login/start/{flowID}", prov.PostLoginStart) prov.Router.HandleFunc("POST /v3/login/step/{loginProcessID}/{stepID}/{stepType}", prov.PostLoginStep) @@ -129,7 +130,7 @@ func (prov *ProvisioningAPI) Init() { prov.Router.HandleFunc("POST /v3/search_users", prov.PostSearchUsers) prov.Router.HandleFunc("GET /v3/resolve_identifier/{identifier}", prov.GetResolveIdentifier) prov.Router.HandleFunc("POST /v3/create_dm/{identifier}", prov.PostCreateDM) - prov.Router.HandleFunc("POST /v3/create_group", prov.PostCreateGroup) + prov.Router.HandleFunc("POST /v3/create_group/{type}", prov.PostCreateGroup) if prov.br.Config.Provisioning.EnableSessionTransfers { prov.log.Debug().Msg("Enabling session transfer API") @@ -361,6 +362,10 @@ func (prov *ProvisioningAPI) GetLoginFlows(w http.ResponseWriter, r *http.Reques }) } +func (prov *ProvisioningAPI) GetCapabilities(w http.ResponseWriter, r *http.Request) { + exhttp.WriteJSONResponse(w, http.StatusOK, &prov.net.GetCapabilities().Provisioning) +} + var ErrNilStep = errors.New("bridge returned nil step with no error") func (prov *ProvisioningAPI) PostLoginStart(w http.ResponseWriter, r *http.Request) { @@ -673,11 +678,24 @@ func (prov *ProvisioningAPI) PostCreateDM(w http.ResponseWriter, r *http.Request } func (prov *ProvisioningAPI) PostCreateGroup(w http.ResponseWriter, r *http.Request) { + var req bridgev2.GroupCreateParams + err := json.NewDecoder(r.Body).Decode(&req) + if err != nil { + zerolog.Ctx(r.Context()).Err(err).Msg("Failed to decode request body") + mautrix.MNotJSON.WithMessage("Failed to decode request body").Write(w) + return + } + req.Type = r.PathValue("type") login := prov.GetLoginForRequest(w, r) if login == nil { return } - mautrix.MUnrecognized.WithMessage("Creating groups is not yet implemented").Write(w) + resp, err := provisionutil.CreateGroup(r.Context(), login, &req) + if err != nil { + RespondWithError(w, err, "Internal error creating group") + return + } + exhttp.WriteJSONResponse(w, http.StatusOK, resp) } type ReqExportCredentials struct { diff --git a/bridgev2/matrix/provisioning.yaml b/bridgev2/matrix/provisioning.yaml index b9879ea5..5bb27272 100644 --- a/bridgev2/matrix/provisioning.yaml +++ b/bridgev2/matrix/provisioning.yaml @@ -361,14 +361,25 @@ paths: $ref: '#/components/responses/InternalError' 501: $ref: '#/components/responses/NotSupported' - /v3/create_group: + /v3/create_group/{type}: post: tags: [ snc ] summary: Create a group chat on the remote network. operationId: createGroup parameters: - $ref: "#/components/parameters/loginID" + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/GroupCreateParams' responses: + 200: + description: Identifier resolved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/CreatedGroup' 401: $ref: '#/components/responses/Unauthorized' 404: @@ -572,6 +583,74 @@ components: description: The Matrix room ID of the direct chat with the user. examples: - '!OKhS0I5q2fCzdnl2qgeozDQw:t2bot.io' + GroupCreateParams: + type: object + description: | + Parameters for creating a group chat. + The /capabilities endpoint response must be checked to see which fields are actually allowed. + properties: + type: + type: string + description: The type of group to create. + examples: + - channel + username: + type: string + description: The public username for the created group. + participants: + type: array + description: The users to add to the group initially. + items: + type: string + parent: + type: object + name: + type: object + description: The `m.room.name` event content for the room. + properties: + name: + type: string + avatar: + type: object + description: The `m.room.avatar` event content for the room. + properties: + url: + type: string + format: mxc + topic: + type: object + description: The `m.room.topic` event content for the room. + properties: + topic: + type: string + disappear: + type: object + description: The `com.beeper.disappearing_timer` event content for the room. + properties: + type: + type: string + timer: + type: number + room_id: + type: string + format: matrix_room_id + description: | + An existing Matrix room ID to bridge to. + The other parameters must be already in sync with the room state when using this parameter. + CreatedGroup: + type: object + description: A successfully created group chat. + required: [id, mxid] + properties: + id: + type: string + description: The internal chat ID of the created group. + mxid: + type: string + format: matrix_room_id + description: The Matrix room ID of the portal. + examples: + - '!OKhS0I5q2fCzdnl2qgeozDQw:t2bot.io' LoginStep: type: object description: A step in a login process. diff --git a/bridgev2/matrixinvite.go b/bridgev2/matrixinvite.go index bfbabd26..2c14cc7f 100644 --- a/bridgev2/matrixinvite.go +++ b/bridgev2/matrixinvite.go @@ -206,72 +206,64 @@ func (br *Bridge) handleGhostDMInvite(ctx context.Context, evt *event.Event, sen return EventHandlingResultFailed } - didSetPortal := portal.setMXIDToExistingRoom(ctx, evt.RoomID) - if didSetPortal { - message := "Private chat portal created" - err = br.givePowerToBot(ctx, evt.RoomID, invitedGhost.Intent) - hasWarning := false - if err != nil { - log.Warn().Err(err).Msg("Failed to give power to bot in new DM") - message += "\n\nWarning: failed to promote bot" - hasWarning = true - } - if resp.DMRedirectedTo != "" && resp.DMRedirectedTo != invitedGhost.ID { - log.Debug(). - Str("dm_redirected_to_id", string(resp.DMRedirectedTo)). - Msg("Created DM was redirected to another user ID") - _, err = invitedGhost.Intent.SendState(ctx, portal.MXID, event.StateMember, invitedGhost.Intent.GetMXID().String(), &event.Content{ - Parsed: &event.MemberEventContent{ - Membership: event.MembershipLeave, - Reason: "Direct chat redirected to another internal user ID", - }, - }, time.Time{}) - if err != nil { - log.Err(err).Msg("Failed to make incorrect ghost leave new DM room") - } - otherUserGhost, err := br.GetGhostByID(ctx, resp.DMRedirectedTo) - if err != nil { - log.Err(err).Msg("Failed to get ghost of real portal other user ID") - } else { - invitedGhost = otherUserGhost - } - } - if resp.PortalInfo != nil { - portal.UpdateInfo(ctx, resp.PortalInfo, sourceLogin, nil, time.Time{}) - } else { - portal.UpdateCapabilities(ctx, sourceLogin, true) - portal.UpdateBridgeInfo(ctx) - } - // TODO this might become unnecessary if UpdateInfo starts taking care of it - _, err = br.Bot.SendState(ctx, portal.MXID, event.StateElementFunctionalMembers, "", &event.Content{ - Parsed: &event.ElementFunctionalMembersContent{ - ServiceMembers: []id.UserID{br.Bot.GetMXID()}, + portal.roomCreateLock.Lock() + defer portal.roomCreateLock.Unlock() + portalMXID := portal.MXID + if portalMXID != "" { + sendErrorAndLeave(ctx, evt, invitedGhost.Intent, "You already have a direct chat with me at [%s](%s)", portalMXID, portalMXID.URI(br.Matrix.ServerName()).MatrixToURL()) + rejectInvite(ctx, evt, br.Bot, "") + return EventHandlingResultSuccess + } + err = br.givePowerToBot(ctx, evt.RoomID, invitedGhost.Intent) + if err != nil { + log.Err(err).Msg("Failed to give permissions to bridge bot") + sendErrorAndLeave(ctx, evt, invitedGhost.Intent, "Failed to give permissions to bridge bot") + rejectInvite(ctx, evt, br.Bot, "") + return EventHandlingResultSuccess + } + if resp.DMRedirectedTo != "" && resp.DMRedirectedTo != invitedGhost.ID { + log.Debug(). + Str("dm_redirected_to_id", string(resp.DMRedirectedTo)). + Msg("Created DM was redirected to another user ID") + _, err = invitedGhost.Intent.SendState(ctx, portal.MXID, event.StateMember, invitedGhost.Intent.GetMXID().String(), &event.Content{ + Parsed: &event.MemberEventContent{ + Membership: event.MembershipLeave, + Reason: "Direct chat redirected to another internal user ID", }, }, time.Time{}) if err != nil { - log.Warn().Err(err).Msg("Failed to set service members in room") - if !hasWarning { - message += "\n\nWarning: failed to set service members" - hasWarning = true - } + log.Err(err).Msg("Failed to make incorrect ghost leave new DM room") } - mx, ok := br.Matrix.(MatrixConnectorWithPostRoomBridgeHandling) - if ok { - err = mx.HandleNewlyBridgedRoom(ctx, evt.RoomID) - if err != nil { - if hasWarning { - message += fmt.Sprintf(", %s", err.Error()) - } else { - message += fmt.Sprintf("\n\nWarning: %s", err.Error()) - } - } + otherUserGhost, err := br.GetGhostByID(ctx, resp.DMRedirectedTo) + if err != nil { + log.Err(err).Msg("Failed to get ghost of real portal other user ID") + } else { + invitedGhost = otherUserGhost } - sendNotice(ctx, evt, invitedGhost.Intent, message) - } else { - // TODO ensure user is invited even if PortalInfo wasn't provided? - sendErrorAndLeave(ctx, evt, invitedGhost.Intent, "You already have a direct chat with me at [%s](%s)", portal.MXID, portal.MXID.URI(br.Matrix.ServerName()).MatrixToURL()) - rejectInvite(ctx, evt, br.Bot, "") } + err = portal.UpdateMatrixRoomID(ctx, evt.RoomID, UpdateMatrixRoomIDParams{ + // We locked it before checking the mxid + RoomCreateAlreadyLocked: true, + + FailIfMXIDSet: true, + ChatInfo: resp.PortalInfo, + ChatInfoSource: sourceLogin, + }) + if err != nil { + log.Err(err).Msg("Failed to update Matrix room ID for new DM portal") + sendNotice(ctx, evt, invitedGhost.Intent, "Failed to finish configuring portal. The chat may or may not work") + return EventHandlingResultSuccess + } + message := "Private chat portal created" + mx, ok := br.Matrix.(MatrixConnectorWithPostRoomBridgeHandling) + if ok { + err = mx.HandleNewlyBridgedRoom(ctx, evt.RoomID) + if err != nil { + log.Err(err).Msg("Error in connector newly bridged room handler") + message += fmt.Sprintf("\n\nWarning: %s", err.Error()) + } + } + sendNotice(ctx, evt, invitedGhost.Intent, message) return EventHandlingResultSuccess } @@ -294,21 +286,3 @@ func (br *Bridge) givePowerToBot(ctx context.Context, roomID id.RoomID, userWith } return nil } - -func (portal *Portal) setMXIDToExistingRoom(ctx context.Context, roomID id.RoomID) bool { - portal.roomCreateLock.Lock() - defer portal.roomCreateLock.Unlock() - if portal.MXID != "" { - return false - } - portal.MXID = roomID - portal.updateLogger() - portal.Bridge.cacheLock.Lock() - portal.Bridge.portalsByMXID[portal.MXID] = portal - portal.Bridge.cacheLock.Unlock() - err := portal.Save(ctx) - if err != nil { - zerolog.Ctx(ctx).Err(err).Msg("Failed to save portal after updating mxid") - } - return true -} diff --git a/bridgev2/networkid/bridgeid.go b/bridgev2/networkid/bridgeid.go index 443d3655..e3a6df70 100644 --- a/bridgev2/networkid/bridgeid.go +++ b/bridgev2/networkid/bridgeid.go @@ -47,8 +47,8 @@ type PortalID string // As a special case, Receiver MUST be set if the Bridge.Config.SplitPortals flag is set to true. // The flag is intended for puppeting-only bridges which want multiple logins to create separate portals for each user. type PortalKey struct { - ID PortalID - Receiver UserLoginID + ID PortalID `json:"portal_id"` + Receiver UserLoginID `json:"portal_receiver,omitempty"` } func (pk PortalKey) IsEmpty() bool { diff --git a/bridgev2/networkinterface.go b/bridgev2/networkinterface.go index dcbcbad5..8293be51 100644 --- a/bridgev2/networkinterface.go +++ b/bridgev2/networkinterface.go @@ -350,6 +350,8 @@ type NetworkGeneralCapabilities struct { // to handle asynchronous message responses, this field can be set to enable // automatic timeout errors in case the asynchronous response never arrives. OutgoingMessageTimeouts *OutgoingTimeoutConfig + // Capabilities related to the provisioning API. + Provisioning ProvisioningCapabilities } // NetworkAPI is an interface representing a remote network client for a single user login. @@ -750,9 +752,75 @@ type UserSearchingNetworkAPI interface { SearchUsers(ctx context.Context, query string) ([]*ResolveIdentifierResponse, error) } +type ProvisioningCapabilities struct { + ResolveIdentifier ResolveIdentifierCapabilities `json:"resolve_identifier"` + GroupCreation map[string]GroupTypeCapabilities `json:"group_creation"` +} + +type ResolveIdentifierCapabilities struct { + // Can DMs be created after resolving an identifier? + CreateDM bool `json:"create_dm"` + // Can users be looked up by phone number? + LookupPhone bool `json:"lookup_phone"` + // Can users be looked up by email address? + LookupEmail bool `json:"lookup_email"` + // Can users be looked up by network-specific username? + LookupUsername bool `json:"lookup_username"` + // Can any phone number be contacted without having to validate it via lookup first? + AnyPhone bool `json:"any_phone"` + // Can a contact list be retrieved from the bridge? + ContactList bool `json:"contact_list"` + // Can users be searched by name on the remote network? + Search bool `json:"search"` +} + +type GroupTypeCapabilities struct { + TypeDescription string `json:"type_description"` + + Name GroupFieldCapability `json:"name"` + Username GroupFieldCapability `json:"username"` + Avatar GroupFieldCapability `json:"avatar"` + Topic GroupFieldCapability `json:"topic"` + Disappear GroupFieldCapability `json:"disappear"` + Participants GroupFieldCapability `json:"participants"` + Parent GroupFieldCapability `json:"parent"` +} + +type GroupFieldCapability struct { + // Is setting this field allowed at all in the create request? + // Even if false, the network connector should attempt to set the metadata after group creation, + // as the allowed flag can't be enforced properly when creating a group for an existing Matrix room. + Allowed bool `json:"allowed"` + // Is setting this field mandatory for the creation to succeed? + Required bool `json:"required,omitempty"` + // The minimum/maximum length of the field, if applicable. + // For members, length means the number of members excluding the creator. + MinLength int `json:"min_length,omitempty"` + MaxLength int `json:"max_length,omitempty"` + + // Only for the disappear field: allowed disappearing settings + DisappearSettings *event.DisappearingTimerCapability `json:"settings,omitempty"` +} + +type GroupCreateParams struct { + Type string `json:"type"` + + Username string `json:"username"` + Participants []networkid.UserID `json:"participants"` + Parent *networkid.PortalKey `json:"parent"` + + Name *event.RoomNameEventContent `json:"name"` + Avatar *event.RoomAvatarEventContent `json:"avatar"` + Topic *event.TopicEventContent `json:"topic"` + Disappear *event.BeeperDisappearingTimer `json:"disappear"` + + // An existing room ID to bridge to. If unset, a new room will be created. + RoomID id.RoomID `json:"room_id"` +} + type GroupCreatingNetworkAPI interface { IdentifierResolvingNetworkAPI - CreateGroup(ctx context.Context, name string, users ...networkid.UserID) (*CreateChatResponse, error) + CreateGroup(ctx context.Context, params *GroupCreateParams) (*CreateChatResponse, error) } type MembershipChangeType struct { diff --git a/bridgev2/portal.go b/bridgev2/portal.go index 5e0a9137..85d670d9 100644 --- a/bridgev2/portal.go +++ b/bridgev2/portal.go @@ -1840,42 +1840,134 @@ func (portal *Portal) handleMatrixTombstone(ctx context.Context, evt *event.Even return EventHandlingResultIgnored } } - - portal.Bridge.cacheLock.Lock() - if _, alreadyExists := portal.Bridge.portalsByMXID[content.ReplacementRoom]; alreadyExists { - log.Warn().Msg("Replacement room is already a portal, ignoring tombstone") - portal.Bridge.cacheLock.Unlock() + err = portal.UpdateMatrixRoomID(ctx, content.ReplacementRoom, UpdateMatrixRoomIDParams{ + DeleteOldRoom: true, + FetchInfoVia: senderUser, + }) + if errors.Is(err, ErrTargetRoomIsPortal) { return EventHandlingResultIgnored + } else if err != nil { + return EventHandlingResultFailed.WithError(err) } - delete(portal.Bridge.portalsByMXID, portal.MXID) - portal.MXID = content.ReplacementRoom + return EventHandlingResultSuccess +} + +var ErrTargetRoomIsPortal = errors.New("target room is already a portal") +var ErrRoomAlreadyExists = errors.New("this portal already has a room") + +type UpdateMatrixRoomIDParams struct { + SyncDBMetadata func() + FailIfMXIDSet bool + OverwriteOldPortal bool + TombstoneOldRoom bool + DeleteOldRoom bool + + RoomCreateAlreadyLocked bool + + FetchInfoVia *User + ChatInfo *ChatInfo + ChatInfoSource *UserLogin +} + +func (portal *Portal) UpdateMatrixRoomID( + ctx context.Context, + newRoomID id.RoomID, + params UpdateMatrixRoomIDParams, +) error { + if !params.RoomCreateAlreadyLocked { + portal.roomCreateLock.Lock() + defer portal.roomCreateLock.Unlock() + } + oldRoom := portal.MXID + if oldRoom == newRoomID { + return nil + } else if oldRoom != "" && params.FailIfMXIDSet { + return ErrRoomAlreadyExists + } + log := zerolog.Ctx(ctx) + portal.Bridge.cacheLock.Lock() + // Wrap unlock in a sync.OnceFunc because we want to both defer it to catch early returns + // and unlock it before return if nothing goes wrong. + unlockCacheLock := sync.OnceFunc(portal.Bridge.cacheLock.Unlock) + defer unlockCacheLock() + if existingPortal, alreadyExists := portal.Bridge.portalsByMXID[newRoomID]; alreadyExists && !params.OverwriteOldPortal { + log.Warn().Msg("Replacement room is already a portal, ignoring") + return ErrTargetRoomIsPortal + } else if alreadyExists { + log.Debug().Msg("Replacement room is already a portal, overwriting") + existingPortal.MXID = "" + err := existingPortal.Save(ctx) + if err != nil { + return fmt.Errorf("failed to clear mxid of existing portal: %w", err) + } + delete(portal.Bridge.portalsByMXID, portal.MXID) + } + portal.MXID = newRoomID portal.Bridge.portalsByMXID[portal.MXID] = portal portal.NameSet = false portal.AvatarSet = false portal.TopicSet = false portal.InSpace = false portal.CapState = database.CapabilityState{} - portal.Bridge.cacheLock.Unlock() + portal.lastCapUpdate = time.Time{} + if params.SyncDBMetadata != nil { + params.SyncDBMetadata() + } + unlockCacheLock() + portal.updateLogger() - err = portal.Save(ctx) + err := portal.Save(ctx) if err != nil { - log.Err(err).Msg("Failed to save portal after tombstone") - return EventHandlingResultFailed.WithError(err) + log.Err(err).Msg("Failed to save portal in UpdateMatrixRoomID") + return err } log.Info().Msg("Successfully followed tombstone and updated portal MXID") err = portal.Bridge.DB.UserPortal.MarkAllNotInSpace(ctx, portal.PortalKey) if err != nil { - log.Err(err).Msg("Failed to update in_space flag for user portals after tombstone") + log.Err(err).Msg("Failed to update in_space flag for user portals after updating portal MXID") } go portal.addToUserSpaces(ctx) - go portal.updateInfoAfterTombstone(ctx, senderUser) + if params.FetchInfoVia != nil { + go portal.updateInfoAfterTombstone(ctx, params.FetchInfoVia) + } else if params.ChatInfo != nil { + go portal.UpdateInfo(ctx, params.ChatInfo, params.ChatInfoSource, nil, time.Time{}) + } else if params.ChatInfoSource != nil { + portal.UpdateCapabilities(ctx, params.ChatInfoSource, true) + portal.UpdateBridgeInfo(ctx) + } go func() { - err = portal.Bridge.Bot.DeleteRoom(ctx, evt.RoomID, true) + // TODO this might become unnecessary if UpdateInfo starts taking care of it + _, err = portal.Bridge.Bot.SendState(ctx, portal.MXID, event.StateElementFunctionalMembers, "", &event.Content{ + Parsed: &event.ElementFunctionalMembersContent{ + ServiceMembers: []id.UserID{portal.Bridge.Bot.GetMXID()}, + }, + }, time.Time{}) if err != nil { - log.Err(err).Msg("Failed to clean up Matrix room after following tombstone") + if err != nil { + log.Warn().Err(err).Msg("Failed to set service members in new room") + } } }() - return EventHandlingResultSuccess + if params.TombstoneOldRoom && oldRoom != "" { + _, err = portal.Bridge.Bot.SendState(ctx, portal.MXID, event.StateTombstone, "", &event.Content{ + Parsed: &event.TombstoneEventContent{ + Body: "Room has been replaced.", + ReplacementRoom: newRoomID, + }, + }, time.Now()) + if err != nil { + log.Err(err).Msg("Failed to send tombstone event to old room") + } + } + if params.DeleteOldRoom && oldRoom != "" { + go func() { + err = portal.Bridge.Bot.DeleteRoom(ctx, oldRoom, true) + if err != nil { + log.Err(err).Msg("Failed to clean up old Matrix room after updating portal MXID") + } + }() + } + return nil } func (portal *Portal) updateInfoAfterTombstone(ctx context.Context, senderUser *User) { diff --git a/bridgev2/portalinternal.go b/bridgev2/portalinternal.go index e82c481a..0223b4f2 100644 --- a/bridgev2/portalinternal.go +++ b/bridgev2/portalinternal.go @@ -125,6 +125,14 @@ func (portal *PortalInternals) HandleMatrixPowerLevels(ctx context.Context, send return (*Portal)(portal).handleMatrixPowerLevels(ctx, sender, origSender, evt) } +func (portal *PortalInternals) HandleMatrixTombstone(ctx context.Context, evt *event.Event) EventHandlingResult { + return (*Portal)(portal).handleMatrixTombstone(ctx, evt) +} + +func (portal *PortalInternals) UpdateInfoAfterTombstone(ctx context.Context, senderUser *User) { + (*Portal)(portal).updateInfoAfterTombstone(ctx, senderUser) +} + func (portal *PortalInternals) HandleMatrixRedaction(ctx context.Context, sender *UserLogin, origSender *OrigSender, evt *event.Event) EventHandlingResult { return (*Portal)(portal).handleMatrixRedaction(ctx, sender, origSender, evt) } @@ -133,6 +141,10 @@ func (portal *PortalInternals) HandleRemoteEvent(ctx context.Context, source *Us return (*Portal)(portal).handleRemoteEvent(ctx, source, evtType, evt) } +func (portal *PortalInternals) EnsureFunctionalMember(ctx context.Context, ghost *Ghost) { + (*Portal)(portal).ensureFunctionalMember(ctx, ghost) +} + func (portal *PortalInternals) GetIntentAndUserMXIDFor(ctx context.Context, sender EventSender, source *UserLogin, otherLogins []*UserLogin, evtType RemoteEventType) (intent MatrixAPI, extraUserID id.UserID, err error) { return (*Portal)(portal).getIntentAndUserMXIDFor(ctx, sender, source, otherLogins, evtType) } @@ -297,6 +309,10 @@ func (portal *PortalInternals) CreateMatrixRoomInLoop(ctx context.Context, sourc return (*Portal)(portal).createMatrixRoomInLoop(ctx, source, info, backfillBundle) } +func (portal *PortalInternals) AddToUserSpaces(ctx context.Context) { + (*Portal)(portal).addToUserSpaces(ctx) +} + func (portal *PortalInternals) RemoveInPortalCache(ctx context.Context) { (*Portal)(portal).removeInPortalCache(ctx) } @@ -360,7 +376,3 @@ func (portal *PortalInternals) AddToParentSpaceAndSave(ctx context.Context, save func (portal *PortalInternals) ToggleSpace(ctx context.Context, spaceID id.RoomID, canonical, remove bool) error { return (*Portal)(portal).toggleSpace(ctx, spaceID, canonical, remove) } - -func (portal *PortalInternals) SetMXIDToExistingRoom(ctx context.Context, roomID id.RoomID) bool { - return (*Portal)(portal).setMXIDToExistingRoom(ctx, roomID) -} diff --git a/bridgev2/provisionutil/creategroup.go b/bridgev2/provisionutil/creategroup.go new file mode 100644 index 00000000..891f9615 --- /dev/null +++ b/bridgev2/provisionutil/creategroup.go @@ -0,0 +1,99 @@ +// Copyright (c) 2025 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package provisionutil + +import ( + "context" + + "github.com/rs/zerolog" + "go.mau.fi/util/ptr" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/id" +) + +type RespCreateGroup struct { + ID networkid.PortalID `json:"id"` + MXID id.RoomID `json:"mxid"` + Portal *bridgev2.Portal `json:"-"` +} + +func CreateGroup(ctx context.Context, login *bridgev2.UserLogin, params *bridgev2.GroupCreateParams) (*RespCreateGroup, error) { + api, ok := login.Client.(bridgev2.GroupCreatingNetworkAPI) + if !ok { + return nil, bridgev2.RespError(mautrix.MUnrecognized.WithMessage("This bridge does not support creating groups")) + } + caps := login.Bridge.Network.GetCapabilities() + typeSpec, validType := caps.Provisioning.GroupCreation[params.Type] + if !validType { + return nil, bridgev2.RespError(mautrix.MUnrecognized.WithMessage("Unrecognized group type %s", params.Type)) + } + if len(params.Participants) < typeSpec.Participants.MinLength { + return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Must have at least %d members", typeSpec.Participants.MinLength)) + } + if (params.Name == nil || params.Name.Name == "") && typeSpec.Name.Required { + return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Name is required")) + } else if nameLen := len(ptr.Val(params.Name).Name); nameLen > 0 && nameLen < typeSpec.Name.MinLength { + return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Name must be at least %d characters", typeSpec.Name.MinLength)) + } else if nameLen > typeSpec.Name.MaxLength { + return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Name must be at most %d characters", typeSpec.Name.MaxLength)) + } + if (params.Avatar == nil || params.Avatar.URL == "") && typeSpec.Avatar.Required { + return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Avatar is required")) + } + if (params.Topic == nil || params.Topic.Topic == "") && typeSpec.Topic.Required { + return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Topic is required")) + } else if topicLen := len(ptr.Val(params.Topic).Topic); topicLen > 0 && topicLen < typeSpec.Topic.MinLength { + return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Topic must be at least %d characters", typeSpec.Topic.MinLength)) + } else if topicLen > typeSpec.Topic.MaxLength { + return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Topic must be at most %d characters", typeSpec.Topic.MaxLength)) + } + if (params.Disappear == nil || params.Disappear.Timer.Duration == 0) && typeSpec.Disappear.Required { + return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Disappearing timer is required")) + } else if !typeSpec.Disappear.DisappearSettings.Supports(params.Disappear) { + return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Unsupported value for disappearing timer")) + } + if params.Username == "" && typeSpec.Username.Required { + return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Username is required")) + } else if len(params.Username) > 0 && len(params.Username) < typeSpec.Username.MinLength { + return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Username must be at least %d characters", typeSpec.Username.MinLength)) + } else if len(params.Username) > typeSpec.Username.MaxLength { + return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Username must be at most %d characters", typeSpec.Username.MaxLength)) + } + if params.Parent == nil && typeSpec.Parent.Required { + return nil, bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Parent is required")) + } + resp, err := api.CreateGroup(ctx, params) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to create group") + return nil, err + } + if resp.PortalKey.IsEmpty() { + return nil, ErrNoPortalKey + } + if resp.Portal == nil { + resp.Portal, err = login.Bridge.GetPortalByKey(ctx, resp.PortalKey) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to get portal") + return nil, bridgev2.RespError(mautrix.MUnknown.WithMessage("Failed to get portal")) + } + } + if resp.Portal.MXID == "" { + err = resp.Portal.CreateMatrixRoom(ctx, login, resp.PortalInfo) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to create portal room") + return nil, bridgev2.RespError(mautrix.MUnknown.WithMessage("Failed to create portal room")) + } + } + return &RespCreateGroup{ + ID: resp.Portal.ID, + MXID: resp.Portal.MXID, + Portal: resp.Portal, + }, nil +} diff --git a/event/capabilities.go b/event/capabilities.go index ebedb6a2..94662428 100644 --- a/event/capabilities.go +++ b/event/capabilities.go @@ -77,7 +77,7 @@ type DisappearingTimerCapability struct { } func (dtc *DisappearingTimerCapability) Supports(content *BeeperDisappearingTimer) bool { - if dtc == nil || content.Type == DisappearingTypeNone { + if dtc == nil || content == nil || content.Type == DisappearingTypeNone { return true } return slices.Contains(dtc.Types, content.Type) && (dtc.Timers == nil || slices.Contains(dtc.Timers, content.Timer))