mirror of
https://mau.dev/mautrix/go.git
synced 2026-03-14 14:25:53 +01:00
bridgev2: add support for creating groups (#405)
This commit is contained in:
parent
0627c42270
commit
f8c3a95de7
10 changed files with 543 additions and 103 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
99
bridgev2/provisionutil/creategroup.go
Normal file
99
bridgev2/provisionutil/creategroup.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue