diff --git a/bridgev2/commands/processor.go b/bridgev2/commands/processor.go index 49769514..bd49158b 100644 --- a/bridgev2/commands/processor.go +++ b/bridgev2/commands/processor.go @@ -45,7 +45,7 @@ func NewProcessor(bridge *bridgev2.Bridge) bridgev2.CommandProcessor { CommandRegisterPush, CommandDeletePortal, CommandDeleteAllPortals, CommandLogin, CommandListLogins, CommandLogout, CommandSetPreferredLogin, CommandSetRelay, CommandUnsetRelay, - CommandResolveIdentifier, CommandStartChat, CommandSearch, + CommandResolveIdentifier, CommandStartChat, CommandSearch, CommandCreate, ) return proc } diff --git a/bridgev2/commands/startchat.go b/bridgev2/commands/startchat.go index 24c8a488..ee3eba77 100644 --- a/bridgev2/commands/startchat.go +++ b/bridgev2/commands/startchat.go @@ -12,10 +12,12 @@ import ( "strings" "time" + "github.com/rs/zerolog/log" "golang.org/x/net/html" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" ) @@ -192,3 +194,66 @@ func fnSearch(ce *Event) { } ce.Reply("Search results:\n\n%s", strings.Join(resultsString, "\n")) } + +var CommandCreate = &FullHandler{ + Func: fnCreate, + Name: "create", + Help: HelpMeta{ + Section: HelpSectionChats, + Description: "Create a group chat for the current Matrix room.", + }, + RequiresLogin: true, +} + +func fnCreate(ce *Event) { + if ce.Portal != nil { + ce.Reply("This is already a portal room") + return + } + login, api, _ := getClientForStartingChat[bridgev2.GroupCreatingNetworkAPI](ce, "creating groups") + if api == nil { + return + } + groupCreateInfo, err := ce.Bot.GetGroupCreateInfo(ce.Ctx, ce.RoomID, login) + if err != nil { + log.Err(err).Msg("Failed getting GroupCreateInfo") + return + } + createResponse, err := api.CreateGroup(ce.Ctx, groupCreateInfo) + if err != nil { + log.Err(err).Msg("Failed to create Group") + return + } + portal := createResponse.Portal + portal.MXID = ce.RoomID + if createResponse.PortalInfo != nil { + portal.UpdateInfo(ce.Ctx, createResponse.PortalInfo, login, nil, time.Time{}) + } + _, err = ce.Bot.SendState(ce.Ctx, portal.MXID, event.StateElementFunctionalMembers, "", &event.Content{ + Parsed: &event.ElementFunctionalMembersContent{ + ServiceMembers: []id.UserID{ce.Bot.GetMXID()}, + }, + }, time.Time{}) + if err != nil { + log.Warn().Err(err).Msg("Failed to set service members in room") + } + message := "Group chat portal created" + hasWarning := false + if err != nil { + log.Warn().Err(err).Msg("Failed to give power to bot in new Group") + message += "\n\nWarning: failed to promote bot" + hasWarning = true + } + mx, ok := ce.Bridge.Matrix.(bridgev2.MatrixConnectorWithPostRoomBridgeHandling) + if ok { + err = mx.HandleNewlyBridgedRoom(ce.Ctx, ce.RoomID) + if err != nil { + if hasWarning { + message += fmt.Sprintf(", %s", err.Error()) + } else { + message += fmt.Sprintf("\n\nWarning: %s", err.Error()) + } + } + } + ce.Reply(message) +} diff --git a/bridgev2/matrix/connector.go b/bridgev2/matrix/connector.go index c2b3f7cd..f806cb87 100644 --- a/bridgev2/matrix/connector.go +++ b/bridgev2/matrix/connector.go @@ -625,3 +625,93 @@ func (br *Connector) HandleNewlyBridgedRoom(ctx context.Context, roomID id.RoomI } return nil } + +func (br *Connector) GetGroupCreateInfo(ctx context.Context, roomID id.RoomID, creator *bridgev2.UserLogin) (*bridgev2.GroupCreateInfo, error) { + log := zerolog.Ctx(ctx) + if creator == nil { + return nil, fmt.Errorf("no group creator provided") + } + roomState, err := br.Bot.State(ctx, roomID) + if err != nil { + log.Err(err).Msg("Failed to get room state") + return nil, err + } + createInfo := bridgev2.GroupCreateInfo{} + members := roomState[event.StateMember] + powerLevelsRaw, ok := roomState[event.StatePowerLevels][""] + if !ok { + return nil, err + } + powerLevelsRaw.Content.ParseRaw(event.StatePowerLevels) + powerLevels := powerLevelsRaw.Content.AsPowerLevels() + for mxid, member := range members { + userID := id.UserID(mxid) + var target bridgev2.GhostOrUserLogin + if id.UserID(mxid) == creator.UserMXID { + target = creator + } else { + user, err := br.Bridge.GetUserByMXID(ctx, userID) + if err != nil { + log.Err(err).Msg("Error getting user") + return nil, err + } + if user != nil { + target = user.GetDefaultLogin() + if target == nil { + continue + } + } else { + ghost, err := br.Bridge.GetGhostByMXID(ctx, userID) + if err != nil { + log.Err(err).Msg("Error getting ghost") + return nil, err + } + if ghost == nil { + continue + } + target = ghost + } + } + member.Content.ParseRaw(event.StateMember) + content := member.Content.AsMember() + createInfo.Users = append(createInfo.Users, &bridgev2.LevelAndMembership{ + Target: target, + PowerLevel: powerLevels.GetUserLevel(userID), + Membership: content.Membership, + }) + } + joinRulesRaw, ok := roomState[event.StateJoinRules][""] + if ok { + joinRulesRaw.Content.ParseRaw(event.StateJoinRules) + createInfo.JoinRule = &joinRulesRaw.Content.AsJoinRules().JoinRule + } + roomNameEventRaw, ok := roomState[event.StateRoomName][""] + if ok { + roomNameEventRaw.Content.ParseRaw(event.StateRoomName) + createInfo.Name = &roomNameEventRaw.Content.AsRoomName().Name + } + roomTopicEvent, ok := roomState[event.StateTopic][""] + if ok { + roomTopicEvent.Content.ParseRaw(event.StateTopic) + createInfo.Topic = &roomTopicEvent.Content.AsTopic().Topic + } + roomAvatarEvent, ok := roomState[event.StateRoomAvatar][""] + if ok { + var avatarURL id.ContentURI + var avatarBytes []byte + roomAvatarEvent.Content.ParseRaw(event.StateRoomAvatar) + avatarURL, err = roomAvatarEvent.Content.AsRoomAvatar().URL.Parse() + if err != nil { + log.Err(err).Msg("Failed to parse avatar content URI") + } + if !avatarURL.IsEmpty() { + avatarBytes, err = br.Bot.DownloadBytes(ctx, avatarURL) + if err != nil { + log.Err(err).Stringer("Failed to download updated avatar %s", avatarURL) + return nil, err + } + } + createInfo.Avatar = avatarBytes + } + return &createInfo, nil +} diff --git a/bridgev2/matrix/intent.go b/bridgev2/matrix/intent.go index e789fa75..18cc42f9 100644 --- a/bridgev2/matrix/intent.go +++ b/bridgev2/matrix/intent.go @@ -445,3 +445,7 @@ func (as *ASIntent) MuteRoom(ctx context.Context, roomID id.RoomID, until time.T }) } } + +func (as *ASIntent) GetGroupCreateInfo(ctx context.Context, roomID id.RoomID, creator *bridgev2.UserLogin) (*bridgev2.GroupCreateInfo, error) { + return as.Connector.GetGroupCreateInfo(ctx, roomID, creator) +} diff --git a/bridgev2/matrixinterface.go b/bridgev2/matrixinterface.go index 6d30891e..51b2e33c 100644 --- a/bridgev2/matrixinterface.go +++ b/bridgev2/matrixinterface.go @@ -99,6 +99,7 @@ type MatrixAPI interface { TagRoom(ctx context.Context, roomID id.RoomID, tag event.RoomTag, isTagged bool) error MuteRoom(ctx context.Context, roomID id.RoomID, until time.Time) error + GetGroupCreateInfo(ctx context.Context, roomID id.RoomID, creator *UserLogin) (*GroupCreateInfo, error) } type MarkAsDMMatrixAPI interface { diff --git a/bridgev2/matrixinvite.go b/bridgev2/matrixinvite.go index 740743f6..ca946685 100644 --- a/bridgev2/matrixinvite.go +++ b/bridgev2/matrixinvite.go @@ -209,6 +209,127 @@ func (br *Bridge) handleGhostDMInvite(ctx context.Context, evt *event.Event, sen } } +func (br *Bridge) handleGhostGroupInvite(ctx context.Context, evt *event.Event, sender *User) { + ghostID, _ := br.Matrix.ParseGhostMXID(id.UserID(evt.GetStateKey())) + validator, ok := br.Network.(IdentifierValidatingNetwork) + if ghostID == "" || (ok && !validator.ValidateUserID(ghostID)) { + rejectInvite(ctx, evt, br.Matrix.GhostIntent(ghostID), "Malformed user ID") + return + } + log := zerolog.Ctx(ctx).With(). + Str("invitee_network_id", string(ghostID)). + Stringer("room_id", evt.RoomID). + Logger() + // TODO sort in preference order + logins := sender.GetCachedUserLogins() + if len(logins) == 0 { + rejectInvite(ctx, evt, br.Matrix.GhostIntent(ghostID), "You're not logged in") + return + } + creatingAPI, ok := logins[0].Client.(GroupCreatingNetworkAPI) + if !ok { + rejectInvite(ctx, evt, br.Matrix.GhostIntent(ghostID), "This bridge does not support creating groups") + return + } + doublePuppet := sender.DoublePuppet(ctx) + if doublePuppet == nil { + // TODO: should the ghost join and print some message like in v1? + return + } + invitedGhost, err := br.GetGhostByID(ctx, ghostID) + if err != nil { + log.Err(err).Msg("Failed to get invited ghost") + return + } + var resp *ResolveIdentifierResponse + var sourceLogin *UserLogin + // TODO this should somehow lock incoming event processing to avoid race conditions where a new portal room is created + // between ResolveIdentifier returning and the portal MXID being updated. + for _, login := range logins { + api, ok := login.Client.(IdentifierResolvingNetworkAPI) + if !ok { + continue + } + resp, err = api.ResolveIdentifier(ctx, string(ghostID), false) + if errors.Is(err, ErrResolveIdentifierTryNext) { + log.Debug().Err(err).Str("login_id", string(login.ID)).Msg("Failed to resolve identifier, trying next login") + continue + } else if err != nil { + log.Err(err).Msg("Failed to resolve identifier") + sendErrorAndLeave(ctx, evt, invitedGhost.Intent, "Failed to create chat") + return + } else { + sourceLogin = login + break + } + } + if resp == nil { + log.Warn().Msg("No login could resolve the identifier") + sendErrorAndLeave(ctx, evt, br.Matrix.GhostIntent(ghostID), "Failed to create chat via any login") + return + } + err = doublePuppet.EnsureInvited(ctx, evt.RoomID, br.Bot.GetMXID()) + if err != nil { + log.Err(err).Msg("Failed to ensure bot is invited to room") + sendErrorAndLeave(ctx, evt, invitedGhost.Intent, "Failed to invite bridge bot") + return + } + err = br.Bot.EnsureJoined(ctx, evt.RoomID) + if err != nil { + log.Err(err).Msg("Failed to ensure bot is joined to room") + sendErrorAndLeave(ctx, evt, invitedGhost.Intent, "Failed to join with bridge bot") + return + } + groupCreateInfo, err := br.Bot.GetGroupCreateInfo(ctx, evt.RoomID, sourceLogin) + if err != nil { + log.Err(err).Msg("Failed getting GroupCreateInfo") + return + } + createResponse, err := creatingAPI.CreateGroup(ctx, groupCreateInfo) + if err != nil { + log.Err(err).Msg("Failed to create Group") + return + } + portal := createResponse.Portal + didSetPortal := portal.setMXIDToExistingRoom(evt.RoomID) + if createResponse.PortalInfo != nil { + portal.UpdateInfo(ctx, createResponse.PortalInfo, sourceLogin, nil, time.Time{}) + } + if didSetPortal { + // 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()}, + }, + }, time.Time{}) + if err != nil { + log.Warn().Err(err).Msg("Failed to set service members in room") + } + message := "Group chat portal created" + err = br.givePowerToBot(ctx, evt.RoomID, doublePuppet) + hasWarning := false + if err != nil { + log.Warn().Err(err).Msg("Failed to give power to bot in new Group") + message += "\n\nWarning: failed to promote bot" + hasWarning = true + } + 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()) + } + } + } + sendNotice(ctx, evt, invitedGhost.Intent, message) + } else { + rejectInvite(ctx, evt, br.Bot, "") + } +} + func (br *Bridge) givePowerToBot(ctx context.Context, roomID id.RoomID, userWithPower MatrixAPI) error { powers, err := br.Matrix.GetPowerLevels(ctx, roomID) if err != nil { diff --git a/bridgev2/networkinterface.go b/bridgev2/networkinterface.go index 4fd8a603..25a7e666 100644 --- a/bridgev2/networkinterface.go +++ b/bridgev2/networkinterface.go @@ -604,9 +604,24 @@ type UserSearchingNetworkAPI interface { SearchUsers(ctx context.Context, query string) ([]*ResolveIdentifierResponse, error) } +type LevelAndMembership struct { + Target GhostOrUserLogin + PowerLevel int + Membership event.Membership +} + +type GroupCreateInfo struct { + Users []*LevelAndMembership + Name *string + Topic *string + Avatar []byte + PowerLevels *event.PowerLevelsEventContent + JoinRule *event.JoinRule +} + type GroupCreatingNetworkAPI interface { IdentifierResolvingNetworkAPI - CreateGroup(ctx context.Context, name string, users ...networkid.UserID) (*CreateChatResponse, error) + CreateGroup(ctx context.Context, msg *GroupCreateInfo) (*CreateChatResponse, error) } type MembershipChangeType struct { diff --git a/bridgev2/queue.go b/bridgev2/queue.go index a79d56e3..4e3d0d30 100644 --- a/bridgev2/queue.go +++ b/bridgev2/queue.go @@ -131,8 +131,12 @@ func (br *Bridge) QueueMatrixEvent(ctx context.Context, evt *event.Event) { evt: evt, sender: sender, }) - } else if evt.Type == event.StateMember && br.IsGhostMXID(id.UserID(evt.GetStateKey())) && evt.Content.AsMember().Membership == event.MembershipInvite && evt.Content.AsMember().IsDirect { - br.handleGhostDMInvite(ctx, evt, sender) + } else if evt.Type == event.StateMember && br.IsGhostMXID(id.UserID(evt.GetStateKey())) && evt.Content.AsMember().Membership == event.MembershipInvite { + if evt.Content.AsMember().IsDirect { + br.handleGhostDMInvite(ctx, evt, sender) + } else { + br.handleGhostGroupInvite(ctx, evt, sender) + } } else { status := WrapErrorInStatus(ErrNoPortal) br.Matrix.SendMessageStatus(ctx, &status, StatusEventInfoFromEvent(evt))