diff --git a/pkg/connector/client.go b/pkg/connector/client.go index 8bf9925..b324377 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -33,6 +33,8 @@ import ( "maunium.net/go/mautrix/bridgev2/networkid" "maunium.net/go/mautrix/bridgev2/status" + "go.mau.fi/util/ptr" + "go.mau.fi/mautrix-discord/pkg/discordid" ) @@ -202,6 +204,7 @@ func (cl *DiscordClient) BeginSyncingIfUserLoginPresent(ctx context.Context) { } go cl.syncPrivateChannels(ctx) + go cl.syncGuilds(ctx) } func (d *DiscordClient) syncPrivateChannels(ctx context.Context) { @@ -212,13 +215,199 @@ func (d *DiscordClient) syncPrivateChannels(ctx context.Context) { bts, _ := discordgo.SnowflakeTimestamp(b.LastMessageID) return bts.Compare(ats) }) + // TODO(skip): This is startup_private_channel_create_limit. Support this in the config. for _, dm := range dms[:10] { zerolog.Ctx(ctx).Debug().Str("channel_id", dm.ID).Msg("Syncing private channel with recent activity") - d.syncChannel(ctx, dm, true) + d.syncChannel(ctx, dm) } } +func (d *DiscordClient) canSeeGuildChannel(ctx context.Context, ch *discordgo.Channel) bool { + log := zerolog.Ctx(ctx).With(). + Str("channel_id", ch.ID). + Int("channel_type", int(ch.Type)). + Str("action", "determine guild channel visbility").Logger() + + sess := d.Session + myDiscordUserID := d.Session.State.User.ID + + // To calculate guild channel visibility we need to know our effective permission + // bitmask, which can only be truly determined when we know which roles we have + // in the guild. + // + // To this end, make sure we have detailed information about ourselves in the + // cache ("state"). + + _, err := sess.State.Member(ch.GuildID, myDiscordUserID) + if errors.Is(err, discordgo.ErrStateNotFound) { + log.Debug().Msg("Fetching own membership in guild to check roles") + + member, err := sess.GuildMember(ch.GuildID, myDiscordUserID) + if err != nil { + log.Warn().Err(err).Msg("Failed to get own membership in guild from server") + } else { + err = sess.State.MemberAdd(member) + if err != nil { + log.Warn().Err(err).Msg("Failed to add own membership in guild to cache") + } + } + } else if err != nil { + log.Warn().Err(err).Msg("Failed to get own membership in guild from cache") + } + + err = sess.State.ChannelAdd(ch) + if err != nil { + log.Warn().Err(err).Msg("Failed to add channel to cache") + } + + perms, err := sess.State.UserChannelPermissions(myDiscordUserID, ch.ID) + if err != nil { + log.Warn().Err(err).Msg("Failed to get permissions in channel to determine if it's bridgeable") + return true + } + + canView := perms&discordgo.PermissionViewChannel > 0 + log.Debug(). + Int64("permissions", perms). + Bool("channel_visible", canView). + Msg("Computed visibility of guild channel") + return canView +} + +// The string prepended to [networkid.PortalKey]s identifying spaces that +// bridge Discord guilds. +// +// Every Discord guild created before August 2017 contained an channel +// having _the same ID as the guild itself_. This channel also functioned as +// the "default channel" in that incoming members would view this channel by +// default. It was also impossible to delete. +// +// After this date, these "default channels" became deletable, and fresh guilds +// were no longer created with a channel that exactly corresponded to the guild +// ID. +// +// To accommodate Discord guilds created before this API change that have also +// never deleted the default channel, we need a way to distinguish between the +// guild and the default channel, as we wouldn't be able to bridge the guild +// as a space otherwise. +// +// "*" was chosen as the asterisk character is used to filter by guilds in +// the quick switcher (in Discord's first-party clients). +// +// For more information, see: https://discord.com/developers/docs/change-log#breaking-change-default-channels:~:text=New%20guilds%20will%20no%20longer. +const guildPortalKeySigil = "*" + +func (d *DiscordClient) guildPortalKeyFromID(guildID string) networkid.PortalKey { + // TODO: Support configuring `split_portals`. + return networkid.PortalKey{ + ID: networkid.PortalID(guildPortalKeySigil + guildID), + Receiver: d.UserLogin.ID, + } +} + +func (d *DiscordClient) makeAvatarForGuild(guild *discordgo.Guild) *bridgev2.Avatar { + return &bridgev2.Avatar{ + ID: networkid.AvatarID(guild.Icon), + Get: func(ctx context.Context) ([]byte, error) { + url := discordgo.EndpointGuildIcon(guild.ID, guild.Icon) + return simpleDownload(ctx, url, "group dm icon") + }, + Remove: guild.Icon == "", + } +} + +func (d *DiscordClient) syncGuildSpace(ctx context.Context, guild *discordgo.Guild) error { + prt, err := d.connector.Bridge.GetPortalByKey(ctx, d.guildPortalKeyFromID(guild.ID)) + if err != nil { + return fmt.Errorf("couldn't get/create portal corresponding to guild: %w", err) + } + + selfEvtSender := d.selfEventSender() + info := &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), + } + + if prt.MXID == "" { + err := prt.CreateMatrixRoom(ctx, d.UserLogin, info) + + if err != nil { + return fmt.Errorf("couldn't create room in order to materialize guild portal: %w", err) + } + } else { + prt.UpdateInfo(ctx, info, d.UserLogin, nil, time.Time{}) + } + + return nil +} + +func (d *DiscordClient) syncGuilds(ctx context.Context) { + guildIDs := d.connector.Config.Guilds.BridgingGuildIDs + + for _, guildID := range guildIDs { + log := zerolog.Ctx(ctx).With(). + Str("guild_id", guildID). + Str("action", "sync guild"). + Logger() + + guild, err := d.Session.State.Guild(guildID) + if errors.Is(err, discordgo.ErrStateNotFound) || guild == nil { + log.Err(err).Msg("Couldn't find guild, user isn't a member?") + continue + } + + err = d.syncGuildSpace(ctx, guild) + if err != nil { + log.Err(err).Msg("Couldn't sync guild space portal") + continue + } + + for _, guildCh := range guild.Channels { + if guildCh.Type != discordgo.ChannelTypeGuildText { + // TODO implement categories (spaces) and news channels + log.Trace(). + Str("channel_id", guildCh.ID). + Int("channel_type", int(guildCh.Type)). + Msg("Not bridging guild channel due to type") + continue + } + + if !d.canSeeGuildChannel(ctx, guildCh) { + log.Trace(). + Str("channel_id", guildCh.ID). + Int("channel_type", int(guildCh.Type)). + Msg("Not bridging guild channel that the user doesn't have permission to view") + + continue + } + + d.syncChannel(ctx, guildCh) + } + + log.Debug().Msg("Subscribing to guild after bridging") + err = d.Session.SubscribeGuild(discordgo.GuildSubscribeData{ + GuildID: guild.ID, + Typing: true, + Activities: true, + Threads: true, + }) + if err != nil { + log.Warn().Err(err).Msg("Failed to subscribe to guild") + } + } + +} + func simpleDownload(ctx context.Context, url, thing string) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { @@ -238,17 +427,6 @@ func simpleDownload(ctx context.Context, url, thing string) ([]byte, error) { return data, nil } -func makeChannelAvatar(ch *discordgo.Channel) *bridgev2.Avatar { - return &bridgev2.Avatar{ - ID: networkid.AvatarID(ch.Icon), - Get: func(ctx context.Context) ([]byte, error) { - url := discordgo.EndpointGroupIcon(ch.ID, ch.Icon) - return simpleDownload(ctx, url, "group dm icon") - }, - Remove: ch.Icon == "", - } -} - func (d *DiscordClient) makeEventSenderWithID(userID string) bridgev2.EventSender { return bridgev2.EventSender{ IsFromMe: userID == d.Session.State.User.ID, @@ -257,49 +435,18 @@ func (d *DiscordClient) makeEventSenderWithID(userID string) bridgev2.EventSende } } +func (d *DiscordClient) selfEventSender() bridgev2.EventSender { + return d.makeEventSenderWithID(d.Session.State.User.ID) +} + func (d *DiscordClient) makeEventSender(user *discordgo.User) bridgev2.EventSender { return d.makeEventSenderWithID(user.ID) } -func (d *DiscordClient) syncChannel(_ context.Context, ch *discordgo.Channel, selfIsInChannel bool) { - isGroup := len(ch.RecipientIDs) > 1 - - var roomType database.RoomType - if isGroup { - roomType = database.RoomTypeGroupDM - } else { - roomType = database.RoomTypeDM - } - - selfEventSender := d.makeEventSender(d.Session.State.User) - - var members bridgev2.ChatMemberList - members.IsFull = true - members.MemberMap = make(bridgev2.ChatMemberMap, len(ch.Recipients)) - if len(ch.Recipients) > 0 { - // Private channels' array of participants doesn't include ourselves, - // so this boolean can be used to inject ourselves as a member. - if selfIsInChannel { - 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) - } - +func (d *DiscordClient) syncChannel(_ context.Context, ch *discordgo.Channel) { d.connector.Bridge.QueueRemoteEvent(d.UserLogin, &DiscordChatResync{ + Client: d, channel: ch, portalKey: discordid.MakePortalKey(ch, d.UserLogin.ID, true), - info: &bridgev2.ChatInfo{ - Name: &ch.Name, - Members: &members, - Avatar: makeChannelAvatar(ch), - Type: &roomType, - CanBackfill: true, - }, }) } diff --git a/pkg/connector/config.go b/pkg/connector/config.go index a1f839f..22da26d 100644 --- a/pkg/connector/config.go +++ b/pkg/connector/config.go @@ -17,9 +17,24 @@ package connector import ( - "go.mau.fi/util/configupgrade" + _ "embed" + + up "go.mau.fi/util/configupgrade" ) -func (d *DiscordConnector) GetConfig() (example string, data any, upgrader configupgrade.Upgrader) { - return "", nil, configupgrade.NoopUpgrader +//go:embed example-config.yaml +var ExampleConfig string + +type Config struct { + Guilds struct { + BridgingGuildIDs []string `yaml:"bridging_guild_ids"` + } `yaml:"guilds"` +} + +func upgradeConfig(helper up.Helper) { + helper.Copy(up.List, "guilds", "bridging_guild_ids") +} + +func (d *DiscordConnector) GetConfig() (example string, data any, upgrader up.Upgrader) { + return ExampleConfig, &d.Config, up.SimpleUpgrader(upgradeConfig) } diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 858c22d..7f66d22 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -26,6 +26,7 @@ import ( type DiscordConnector struct { Bridge *bridgev2.Bridge + Config *Config MsgConv *msgconv.MessageConverter } diff --git a/pkg/connector/dbmeta.go b/pkg/connector/dbmeta.go index d4ecb85..6d8fbd5 100644 --- a/pkg/connector/dbmeta.go +++ b/pkg/connector/dbmeta.go @@ -24,6 +24,9 @@ import ( func (d *DiscordConnector) GetDBMetaTypes() database.MetaTypes { return database.MetaTypes{ + Portal: func() any { + return &discordid.PortalMetadata{} + }, UserLogin: func() any { return &discordid.UserLoginMetadata{} }, diff --git a/pkg/connector/events.go b/pkg/connector/events.go index 3395d5b..aa263db 100644 --- a/pkg/connector/events.go +++ b/pkg/connector/events.go @@ -21,15 +21,18 @@ 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" + + "go.mau.fi/mautrix-discord/pkg/discordid" ) type DiscordChatResync struct { + Client *DiscordClient channel *discordgo.Channel portalKey networkid.PortalKey - info *bridgev2.ChatInfo } var ( @@ -55,11 +58,112 @@ func (d *DiscordChatResync) GetType() bridgev2.RemoteEventType { return bridgev2.RemoteEventChatResync } -func (d *DiscordChatResync) GetChatInfo(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.ChatInfo, error) { - if d.info == nil { - return nil, nil +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 d.info, nil + + return &bridgev2.Avatar{ + ID: networkid.AvatarID(ch.Icon), + Get: func(ctx context.Context) ([]byte, error) { + url := discordgo.EndpointGroupIcon(ch.ID, ch.Icon) + return 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 + + 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, + Members: ptr.Ptr(d.memberList()), + Avatar: d.avatar(ctx), + 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/example-config.yaml b/pkg/connector/example-config.yaml new file mode 100644 index 0000000..bfd5c3c --- /dev/null +++ b/pkg/connector/example-config.yaml @@ -0,0 +1,6 @@ +# Configuration options related to Discord guilds (also known as "servers"). +guilds: + # UNSTABLE: The IDs of the guilds to bridge. This is a stopgap measure + # during bridge development. If no guild IDs are specified, then no guilds + # are bridged at all. + bridging_guild_ids: [] diff --git a/pkg/connector/handlematrix.go b/pkg/connector/handlematrix.go index 1c5802b..1c74002 100644 --- a/pkg/connector/handlematrix.go +++ b/pkg/connector/handlematrix.go @@ -25,6 +25,8 @@ import ( "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/database" "maunium.net/go/mautrix/bridgev2/networkid" + + "go.mau.fi/mautrix-discord/pkg/discordid" ) var ( @@ -41,6 +43,7 @@ func (d *DiscordClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.M } portal := msg.Portal + guildID := portal.Metadata.(*discordid.PortalMetadata).GuildID channelID := string(portal.ID) sendReq, err := d.connector.MsgConv.ToDiscord(ctx, d.Session, msg) @@ -50,8 +53,7 @@ func (d *DiscordClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.M var options []discordgo.RequestOption // TODO: When supporting threads (and not a bot user), send a thread referer. - // TODO: Pass the guild ID when send messages in guild channels. - options = append(options, discordgo.WithChannelReferer("", channelID)) + options = append(options, discordgo.WithChannelReferer(guildID, channelID)) sentMsg, err := d.Session.ChannelMessageSendComplex(string(msg.Portal.ID), sendReq, options...) if err != nil { @@ -84,12 +86,11 @@ func (d *DiscordClient) PreHandleMatrixReaction(ctx context.Context, reaction *b } func (d *DiscordClient) HandleMatrixReaction(ctx context.Context, reaction *bridgev2.MatrixReaction) (*database.Reaction, error) { - key := reaction.Content.RelatesTo.Key + relatesToKey := reaction.Content.RelatesTo.Key portal := reaction.Portal - // TODO: Support guilds. - guildID := "" + meta := portal.Metadata.(*discordid.PortalMetadata) - err := d.Session.MessageReactionAddUser(guildID, string(portal.ID), string(reaction.TargetMessage.ID), key) + err := d.Session.MessageReactionAddUser(meta.GuildID, string(portal.ID), string(reaction.TargetMessage.ID), relatesToKey) return nil, err } @@ -97,8 +98,7 @@ func (d *DiscordClient) HandleMatrixReactionRemove(ctx context.Context, removal removing := removal.TargetReaction emojiID := removing.EmojiID channelID := string(removing.Room.ID) - // TODO: Support guilds. - guildID := "" + guildID := removal.Portal.Metadata.(*discordid.PortalMetadata).GuildID err := d.Session.MessageReactionRemoveUser(guildID, channelID, string(removing.MessageID), string(emojiID), string(d.UserLogin.ID)) return err @@ -149,9 +149,10 @@ func (d *DiscordClient) HandleMatrixReadReceipt(ctx context.Context, msg *bridge } } - // TODO: Support guilds and threads. + // TODO: Support threads. + guildID := msg.Portal.Metadata.(*discordid.PortalMetadata).GuildID channelID := string(msg.Portal.ID) - resp, err := d.Session.ChannelMessageAckNoToken(channelID, targetMessageID, discordgo.WithChannelReferer("", channelID)) + resp, err := d.Session.ChannelMessageAckNoToken(channelID, targetMessageID, discordgo.WithChannelReferer(guildID, channelID)) if err != nil { log.Err(err).Msg("Failed to send read receipt to Discord") return err @@ -167,7 +168,11 @@ func (d *DiscordClient) HandleMatrixReadReceipt(ctx context.Context, msg *bridge } func (d *DiscordClient) viewingChannel(ctx context.Context, portal *bridgev2.Portal) error { - // TODO: When guilds are supported, explicitly bail out if this method is called with a guild channel. + if portal.Metadata.(*discordid.PortalMetadata).GuildID != "" { + // Only private channels need this logic. + return nil + } + d.markedOpenedLock.Lock() defer d.markedOpenedLock.Unlock() @@ -201,9 +206,10 @@ func (d *DiscordClient) HandleMatrixTyping(ctx context.Context, msg *bridgev2.Ma // Don't mind if this fails. _ = d.viewingChannel(ctx, msg.Portal) + guildID := msg.Portal.Metadata.(*discordid.PortalMetadata).GuildID channelID := string(msg.Portal.ID) - // TODO: Support guilds and threads properly when sending the referer. - err := d.Session.ChannelTyping(channelID, discordgo.WithChannelReferer("", channelID)) + // TODO: Support threads properly when sending the referer. + err := d.Session.ChannelTyping(channelID, discordgo.WithChannelReferer(guildID, channelID)) if err != nil { log.Warn().Err(err).Msg("Failed to mark user as typing") diff --git a/pkg/discordid/dbmeta.go b/pkg/discordid/dbmeta.go index 2e8131b..1f2b939 100644 --- a/pkg/discordid/dbmeta.go +++ b/pkg/discordid/dbmeta.go @@ -18,6 +18,15 @@ package discordid import "github.com/bwmarrin/discordgo" +type PortalMetadata struct { + // The ID of the Discord guild that the channel corresponding to this portal + // belongs to. + // + // For private channels (DMs and group DMs), this will be the zero value + // (an empty string). + GuildID string `json:"guild_id"` +} + type UserLoginMetadata struct { Token string `json:"token"` HeartbeatSession discordgo.HeartbeatSession `json:"heartbeat_session"` diff --git a/pkg/msgconv/from-matrix.go b/pkg/msgconv/from-matrix.go index 2dddac4..317a48b 100644 --- a/pkg/msgconv/from-matrix.go +++ b/pkg/msgconv/from-matrix.go @@ -31,6 +31,8 @@ import ( "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/format" + + "go.mau.fi/mautrix-discord/pkg/discordid" ) const discordEpochMillis = 1420070400000 @@ -109,6 +111,7 @@ func (mc *MessageConverter) ToDiscord( } portal := msg.Portal + guildID := msg.Portal.Metadata.(*discordid.PortalMetadata).GuildID channelID := string(portal.ID) content := msg.Content @@ -152,8 +155,8 @@ func (mc *MessageConverter) ToDiscord( Name: att.Filename, ID: mc.NextDiscordUploadID(), }}, - // TODO: Populate with guild ID. Support threads. - }, discordgo.WithChannelReferer("", channelID)) + // TODO: Support threads. + }, discordgo.WithChannelReferer(guildID, channelID)) if err != nil { log.Err(err).Msg("Failed to create attachment in preparation for attachment reupload")