Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add group creation #285

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion bridgev2/commands/processor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
65 changes: 65 additions & 0 deletions bridgev2/commands/startchat.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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)
}
90 changes: 90 additions & 0 deletions bridgev2/matrix/connector.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
4 changes: 4 additions & 0 deletions bridgev2/matrix/intent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
1 change: 1 addition & 0 deletions bridgev2/matrixinterface.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
121 changes: 121 additions & 0 deletions bridgev2/matrixinvite.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
17 changes: 16 additions & 1 deletion bridgev2/networkinterface.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
8 changes: 6 additions & 2 deletions bridgev2/queue.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down