diff --git a/pkg/connector/chatinfo.go b/pkg/connector/chatinfo.go index 449460c..e08f049 100644 --- a/pkg/connector/chatinfo.go +++ b/pkg/connector/chatinfo.go @@ -18,11 +18,171 @@ package connector import ( "context" + "fmt" + "github.com/bwmarrin/discordgo" + "github.com/rs/zerolog" + "go.mau.fi/util/ptr" "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/bridgev2/networkid" + + "go.mau.fi/mautrix-discord/pkg/discordid" ) -func (d *DiscordClient) GetChatInfo(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.ChatInfo, error) { - //TODO implement me - panic("implement me") +// getGuildSpaceInfo computes the [bridgev2.ChatInfo] for a guild space. +func (d *DiscordClient) getGuildSpaceInfo(_ctx context.Context, guild *discordgo.Guild) (*bridgev2.ChatInfo, error) { + selfEvtSender := d.selfEventSender() + + return &bridgev2.ChatInfo{ + Name: &guild.Name, + Topic: nil, + Members: &bridgev2.ChatMemberList{ + MemberMap: map[networkid.UserID]bridgev2.ChatMember{ + selfEvtSender.Sender: {EventSender: selfEvtSender}, + }, + // As recommended by the spec, prohibit normal events by setting + // events_default to a suitably high number. + PowerLevels: &bridgev2.PowerLevelOverrides{EventsDefault: ptr.Ptr(100)}, + }, + Avatar: d.makeAvatarForGuild(guild), + Type: ptr.Ptr(database.RoomTypeSpace), + }, nil +} + +func channelIsPrivate(ch *discordgo.Channel) bool { + return ch.Type == discordgo.ChannelTypeDM || ch.Type == discordgo.ChannelTypeGroupDM +} + +func (d *DiscordClient) makeAvatarForChannel(ctx context.Context, ch *discordgo.Channel) *bridgev2.Avatar { + // TODO make this configurable (ala workspace_avatar_in_rooms) + if !channelIsPrivate(ch) { + guild, err := d.Session.State.Guild(ch.GuildID) + + if err != nil || guild == nil { + zerolog.Ctx(ctx).Err(err).Msg("Couldn't look up guild in cache in order to create room avatar") + return nil + } + + return d.makeAvatarForGuild(guild) + } + + return &bridgev2.Avatar{ + ID: discordid.MakeAvatarID(ch.Icon), + Get: func(ctx context.Context) ([]byte, error) { + url := discordgo.EndpointGroupIcon(ch.ID, ch.Icon) + return d.simpleDownload(ctx, url, "channel/gdm icon") + }, + Remove: ch.Icon == "", + } +} + +func (d *DiscordClient) getPrivateChannelMemberList(ch *discordgo.Channel) bridgev2.ChatMemberList { + var members bridgev2.ChatMemberList + members.IsFull = true + members.MemberMap = make(bridgev2.ChatMemberMap, len(ch.Recipients)) + + if len(ch.Recipients) > 0 { + selfEventSender := d.selfEventSender() + + // Private channels' array of participants doesn't include ourselves, + // so inject ourselves as a member. + members.MemberMap[selfEventSender.Sender] = bridgev2.ChatMember{EventSender: selfEventSender} + + for _, recipient := range ch.Recipients { + sender := d.makeEventSender(recipient) + members.MemberMap[sender.Sender] = bridgev2.ChatMember{EventSender: sender} + } + + members.TotalMemberCount = len(ch.Recipients) + } + + return members +} + +// getChannelChatInfo computes [bridgev2.ChatInfo] for a guild channel or private (DM or group DM) channel. +func (d *DiscordClient) getChannelChatInfo(ctx context.Context, ch *discordgo.Channel) (*bridgev2.ChatInfo, error) { + var roomType database.RoomType + switch ch.Type { + case discordgo.ChannelTypeGuildCategory: + roomType = database.RoomTypeSpace + case discordgo.ChannelTypeDM: + roomType = database.RoomTypeDM + case discordgo.ChannelTypeGroupDM: + roomType = database.RoomTypeGroupDM + default: + roomType = database.RoomTypeDefault + } + + var parentPortalID *networkid.PortalID + if ch.Type == discordgo.ChannelTypeGuildCategory || (ch.ParentID == "" && ch.GuildID != "") { + // Categories and uncategorized guild channels always have the guild as their parent. + parentPortalID = ptr.Ptr(discordid.MakeGuildPortalID(ch.GuildID)) + } else if ch.ParentID != "" { + // Categorized guild channels. + parentPortalID = ptr.Ptr(discordid.MakePortalID(ch.ParentID)) + } + + var memberList bridgev2.ChatMemberList + if channelIsPrivate(ch) { + memberList = d.getPrivateChannelMemberList(ch) + } else { + // TODO we're _always_ sending partial member lists for guilds; we can probably + // do better than that + selfEventSender := d.selfEventSender() + + memberList = bridgev2.ChatMemberList{ + IsFull: false, + MemberMap: map[networkid.UserID]bridgev2.ChatMember{ + selfEventSender.Sender: {EventSender: selfEventSender}, + }, + } + } + + return &bridgev2.ChatInfo{ + Name: &ch.Name, + Topic: &ch.Topic, + Avatar: d.makeAvatarForChannel(ctx, ch), + + Members: &memberList, + + Type: &roomType, + ParentID: parentPortalID, + + CanBackfill: true, + + ExtraUpdates: func(ctx context.Context, portal *bridgev2.Portal) (changed bool) { + meta := portal.Metadata.(*discordid.PortalMetadata) + if meta.GuildID != ch.GuildID { + meta.GuildID = ch.GuildID + changed = true + } + + return + }, + }, nil +} + +func (d *DiscordClient) GetChatInfo(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.ChatInfo, error) { + guildID := discordid.ParseGuildPortalID(portal.ID) + if guildID != "" { + // Portal is a space representing a Discord guild. + + guild, err := d.Session.State.Guild(guildID) + if err != nil { + return nil, fmt.Errorf("couldn't get guild: %w", err) + } + + return d.getGuildSpaceInfo(ctx, guild) + } else { + // Portal is to a channel of some kind (private or guild). + channelID := discordid.ParsePortalID(portal.ID) + + ch, err := d.Session.State.Channel(channelID) + if err != nil { + return nil, fmt.Errorf("couldn't get channel: %w", err) + } + + return d.getChannelChatInfo(ctx, ch) + } } diff --git a/pkg/connector/client.go b/pkg/connector/client.go index 9bf31f9..3bf8e24 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -308,7 +308,7 @@ func (d *DiscordClient) syncGuilds(ctx context.Context) { Str("action", "sync guild"). Logger() - err := d.bridgeGuild(log.WithContext(ctx), guildID) + err := d.syncGuild(log.WithContext(ctx), guildID) if err != nil { log.Err(err).Msg("Couldn't bridge guild during sync") } @@ -331,7 +331,7 @@ func (d *DiscordClient) deleteGuildPortalSpace(ctx context.Context, guildID stri }) } -func (d *DiscordClient) bridgeGuild(ctx context.Context, guildID string) error { +func (d *DiscordClient) syncGuild(ctx context.Context, guildID string) error { log := zerolog.Ctx(ctx).With(). Str("guild_id", guildID). Str("action", "bridge guild"). @@ -348,8 +348,8 @@ func (d *DiscordClient) bridgeGuild(ctx context.Context, guildID string) error { d.syncGuildSpace(ctx, guild) for _, guildCh := range guild.Channels { - if guildCh.Type != discordgo.ChannelTypeGuildText { - // TODO implement categories (spaces) and news channels + if guildCh.Type != discordgo.ChannelTypeGuildText && guildCh.Type != discordgo.ChannelTypeGuildCategory { + // TODO also bridge news channels log.Trace(). Str("channel_id", guildCh.ID). Int("channel_type", int(guildCh.Type)). diff --git a/pkg/connector/events_chat_resync.go b/pkg/connector/events_chat_resync.go index 6bc189f..dcc6b56 100644 --- a/pkg/connector/events_chat_resync.go +++ b/pkg/connector/events_chat_resync.go @@ -21,7 +21,6 @@ import ( "github.com/bwmarrin/discordgo" "github.com/rs/zerolog" - "go.mau.fi/util/ptr" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/database" "maunium.net/go/mautrix/bridgev2/networkid" @@ -58,117 +57,9 @@ func (d *DiscordChatResync) GetType() bridgev2.RemoteEventType { return bridgev2.RemoteEventChatResync } -func (d *DiscordChatResync) avatar(ctx context.Context) *bridgev2.Avatar { - ch := d.channel - - // TODO make this configurable (ala workspace_avatar_in_rooms) - if !d.isPrivate() { - guild, err := d.Client.Session.State.Guild(ch.GuildID) - - if err != nil || guild == nil { - zerolog.Ctx(ctx).Err(err).Msg("Couldn't look up guild in cache in order to create room avatar") - return nil - } - - return d.Client.makeAvatarForGuild(guild) - } - - return &bridgev2.Avatar{ - ID: discordid.MakeAvatarID(ch.Icon), - Get: func(ctx context.Context) ([]byte, error) { - url := discordgo.EndpointGroupIcon(ch.ID, ch.Icon) - return d.Client.simpleDownload(ctx, url, "group dm icon") - }, - Remove: ch.Icon == "", - } -} - -func (d *DiscordChatResync) privateChannelMemberList() bridgev2.ChatMemberList { - ch := d.channel - - var members bridgev2.ChatMemberList - members.IsFull = true - members.MemberMap = make(bridgev2.ChatMemberMap, len(ch.Recipients)) - if len(ch.Recipients) > 0 { - selfEventSender := d.Client.selfEventSender() - - // Private channels' array of participants doesn't include ourselves, - // so inject ourselves as a member. - members.MemberMap[selfEventSender.Sender] = bridgev2.ChatMember{EventSender: selfEventSender} - - for _, recipient := range ch.Recipients { - sender := d.Client.makeEventSender(recipient) - members.MemberMap[sender.Sender] = bridgev2.ChatMember{EventSender: sender} - } - - members.TotalMemberCount = len(ch.Recipients) - } - - return members -} - -func (d *DiscordChatResync) memberList() bridgev2.ChatMemberList { - if d.isPrivate() { - return d.privateChannelMemberList() - } - - // TODO we're _always_ sending partial member lists for guilds; we can probably - // do better - selfEventSender := d.Client.selfEventSender() - - return bridgev2.ChatMemberList{ - IsFull: false, - MemberMap: map[networkid.UserID]bridgev2.ChatMember{ - selfEventSender.Sender: {EventSender: selfEventSender}, - }, - } -} - -func (d *DiscordChatResync) isPrivate() bool { - ch := d.channel - return ch.Type == discordgo.ChannelTypeDM || ch.Type == discordgo.ChannelTypeGroupDM -} - func (d *DiscordChatResync) GetChatInfo(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.ChatInfo, error) { - ch := d.channel + return d.Client.GetChatInfo(ctx, portal) - var roomType database.RoomType - - switch ch.Type { - case discordgo.ChannelTypeDM: - roomType = database.RoomTypeDM - case discordgo.ChannelTypeGroupDM: - roomType = database.RoomTypeGroupDM - } - - info := &bridgev2.ChatInfo{ - Name: &ch.Name, - Topic: &ch.Topic, - Avatar: d.avatar(ctx), - - Members: ptr.Ptr(d.memberList()), - - Type: &roomType, - - CanBackfill: true, - - ExtraUpdates: func(ctx context.Context, portal *bridgev2.Portal) (changed bool) { - meta := portal.Metadata.(*discordid.PortalMetadata) - if meta.GuildID != ch.GuildID { - meta.GuildID = ch.GuildID - changed = true - } - - return - }, - } - - if !d.isPrivate() { - // Channel belongs to a guild; associate it with the respective space. - info.ParentID = ptr.Ptr(d.Client.guildPortalKeyFromID(ch.GuildID).ID) - } - - return info, nil } func (d *DiscordChatResync) ShouldCreatePortal() bool { diff --git a/pkg/connector/events_guild_resync.go b/pkg/connector/events_guild_resync.go index 54a6912..cd8ccc9 100644 --- a/pkg/connector/events_guild_resync.go +++ b/pkg/connector/events_guild_resync.go @@ -21,9 +21,7 @@ import ( "github.com/bwmarrin/discordgo" "github.com/rs/zerolog" - "go.mau.fi/util/ptr" "maunium.net/go/mautrix/bridgev2" - "maunium.net/go/mautrix/bridgev2/database" "maunium.net/go/mautrix/bridgev2/networkid" ) @@ -59,20 +57,5 @@ func (d *DiscordGuildResync) ShouldCreatePortal() bool { } func (d *DiscordGuildResync) GetChatInfo(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.ChatInfo, error) { - selfEvtSender := d.Client.selfEventSender() - - return &bridgev2.ChatInfo{ - Name: &d.guild.Name, - Topic: nil, - Members: &bridgev2.ChatMemberList{ - MemberMap: map[networkid.UserID]bridgev2.ChatMember{ - selfEvtSender.Sender: {EventSender: selfEvtSender}, - }, - // As recommended by the spec, prohibit normal events by setting - // events_default to a suitably high number. - PowerLevels: &bridgev2.PowerLevelOverrides{EventsDefault: ptr.Ptr(100)}, - }, - Avatar: d.Client.makeAvatarForGuild(d.guild), - Type: ptr.Ptr(database.RoomTypeSpace), - }, nil + return d.Client.GetChatInfo(ctx, portal) } diff --git a/pkg/connector/provisioning.go b/pkg/connector/provisioning.go index 376c697..5e9130b 100644 --- a/pkg/connector/provisioning.go +++ b/pkg/connector/provisioning.go @@ -215,7 +215,7 @@ func (p *ProvisioningAPI) bridgeGuild(w http.ResponseWriter, r *http.Request, lo return } - go client.bridgeGuild(p.connector.Bridge.BackgroundCtx, guildID) + go client.syncGuild(p.connector.Bridge.BackgroundCtx, guildID) responseStatus := 201 if alreadyBridged { diff --git a/pkg/discordid/id.go b/pkg/discordid/id.go index a1089dd..10a8b13 100644 --- a/pkg/discordid/id.go +++ b/pkg/discordid/id.go @@ -18,6 +18,7 @@ package discordid import ( "strconv" + "strings" "time" "github.com/bwmarrin/discordgo" @@ -126,6 +127,20 @@ func MakeGuildPortalID(guildID string) networkid.PortalID { return networkid.PortalID(GuildPortalKeySigil + guildID) } +// ParseGuildPortalID converts a [network.PortalID] pointing to a guild space +// back into the guild's ID on Discord. +// +// If the portal ID does not point to a guild, then an empty string is returned. +func ParseGuildPortalID(portalID networkid.PortalID) string { + opaque := string(portalID) + if strings.HasPrefix(opaque, GuildPortalKeySigil) { + guildID := opaque[1:] + return guildID + } + + return "" +} + func MakePortalKey(ch *discordgo.Channel, userLoginID networkid.UserLoginID, wantReceiver bool) (key networkid.PortalKey) { key.ID = MakePortalID(ch.ID) if wantReceiver {