34 Commits

Author SHA1 Message Date
Skip R
dfa9c52974 discordid: make function names more explicit
These are fairly wordy but help maintain correctness. A channel portal
ID should not be confused with a guild portal ID.
2026-02-13 21:19:07 -08:00
Skip R
04c15d15a7 handlediscord: bridge categories with proper parenting 2026-02-13 21:05:35 -08:00
Skip R
66badc0709 handlediscord: bridge channel topics 2026-02-13 18:16:09 -08:00
Skip R
d36528400d handlediscord: don't log upon "unknown" events
Due to how discordgo dispatches events, this would be extraneously
logged for every event received.
2026-02-11 19:36:30 -08:00
Skip R
c80fba31d6 handlediscord: bridge typing 2026-02-11 19:36:22 -08:00
Skip R
aba6f5aafc dbmeta: merge incoming metadata
By not implementing MetaMerger, UserLogin metadata such as
BridgedGuildIDs would get clobbered upon reauthing because the metadata
was replaced entirely. Implement CopyFrom so we can gain deeper control
over what is preserved upon reauth. Notably, preserve BridgedGuildIDs so
we can't get into a weird state where a guild is bridged but
simultaneously absent from BridgedGuildIDs, which would cause us to not
subscribe (OP 14) to it properly.
2026-02-11 19:33:46 -08:00
Skip R
6407a3e3e0 connector: set up provisioning in Start instead of Init
Doing it in Init happens to work for local bridges, but not when
connecting to a remote Matrix homeserver.
2026-02-11 19:23:33 -08:00
Skip R
40ae884e7f connector/client: refactor guild subscription, push log onto context 2026-02-11 19:23:19 -08:00
Skip R
07ba87f9d6 handlediscord: bridge guild delete 2026-02-11 17:42:25 -08:00
Skip R
82aab381ab handlediscord: bridge message edits 2026-02-09 18:04:23 -08:00
Skip R
c8561de9c4 connector: panic with a more useful message when creating nil sender 2026-02-09 15:05:07 -08:00
Skip R
9013e01b49 handlediscord: bridge message deletes 2026-02-09 15:04:47 -08:00
Skip R
7a6f59ad73 handlematrix: bridge message edits 2026-02-09 15:04:33 -08:00
Skip R
2ddba507c2 connector/capabilities: clean up stale comments 2026-02-09 14:34:11 -08:00
Skip R
abcc0dca47 msgconv/from-discord: port sticker conversion 2026-02-09 14:13:54 -08:00
Skip R
2310d2c036 usercache: rename methods
"Update" better expresses what is being done to the cache.
2026-02-06 17:55:46 -08:00
Skip R
1fcc910184 msgconv/from-discord: add per-message profiles 2026-02-06 17:42:51 -08:00
Skip R
808993c174 backfill: update ghosts as we backfill 2026-02-06 17:42:09 -08:00
Skip R
a1d4c4cb28 usercache: return user ids that were updated 2026-02-06 17:41:54 -08:00
Skip R
ce6404ac78 backfill: attach sublogger to context 2026-02-06 17:36:26 -08:00
Skip R
7cfa17023b userinfo: use username as ghost identifier instead of user id
This is more correct.
2026-02-06 15:43:51 -08:00
Skip R
d8ca44ecd9 connector: implement user cache
* Fixes the totally broken UserInfo resolution in guilds.
* Adds support for USER_UPDATE from the gateway.

Design considerations behind the user cache:

* Explicitly handle deleted user IDs by short circuiting the lookup
  logic and returning a singleton.
* The cache map is protected during HTTP requests to the Discord API.
* The nonexistence of a user is cached. This is to prevent excessive
  requests (a user can't suddenly begin existing at a given ID).

The user cache is upserted on READY, incoming messages, backfill, etc.
2026-02-06 15:43:51 -08:00
Skip R
c611e8f116 connector: tell discordgo to not track presences/voice state 2026-02-06 13:37:45 -08:00
Skip R
a7ae544999 provisioning: improve compat with beeper desktop 2026-02-05 22:48:36 -08:00
Skip R
4f420c4662 provisioning: preserve logger context 2026-02-05 22:22:13 -08:00
Skip R
4bdb0de559 discordid,connector: remember which guilds were bridged 2026-02-05 22:21:16 -08:00
Skip R
869d8c5412 handlematrix: actually use the qualified emoji when reacting
By accessing reaction.Content.RelatesTo.Key we bypass the work done in
PreHandleMatrixReaction.
2026-02-04 12:44:33 -08:00
Skip R
094bc9bd77 connector: support transaction IDs 2026-02-03 22:03:29 -08:00
Skip R
36c23bef87 dependencies: update discordgo 2026-02-03 22:02:53 -08:00
Skip R
6adf319cfb connector: sync guild spaces via event instead of manually 2026-02-03 21:36:08 -08:00
Skip R
9dfc91ff14 handlematrix: fully qualify reaction emojis 2026-02-03 21:07:51 -08:00
Skip R
47095f1993 connector: instantiate http.Client from bridge settings 2026-02-03 21:00:35 -08:00
Skip R
1900993acd connector/login: remove custom LoadUserLogin
Consolidate how we construct `DiscordClient` by always going through the
connector's `LoadUserLogin` method.
2026-02-03 20:44:38 -08:00
Skip R
2682175508 connector: fetch @me to create login before creating client
Creating the client before the actual UserLogin is bad form.
2026-02-02 22:52:14 -08:00
22 changed files with 1104 additions and 432 deletions

2
go.mod
View File

@@ -41,4 +41,4 @@ require (
maunium.net/go/mauflag v1.0.0 // indirect
)
replace github.com/bwmarrin/discordgo => github.com/beeper/discordgo v0.0.0-20251117165013-20c39e9899ec
replace github.com/bwmarrin/discordgo => github.com/beeper/discordgo v0.0.0-20260204060113-54486b4788c0

4
go.sum
View File

@@ -2,8 +2,8 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/beeper/discordgo v0.0.0-20251117165013-20c39e9899ec h1:5yvEHHd6f4GharWjdBVCjdvL0C09h9wZlayBaI75q1I=
github.com/beeper/discordgo v0.0.0-20251117165013-20c39e9899ec/go.mod h1:lioivnibvB8j1KcF5TVpLdRLKCKHtcl8A03GpxRCre4=
github.com/beeper/discordgo v0.0.0-20260204060113-54486b4788c0 h1:cKnAGjgYtCO4DLQePwsx1bBbX2imPSggm8da4t2AzBQ=
github.com/beeper/discordgo v0.0.0-20260204060113-54486b4788c0/go.mod h1:lioivnibvB8j1KcF5TVpLdRLKCKHtcl8A03GpxRCre4=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=

View File

@@ -37,11 +37,13 @@ func (dc *DiscordClient) FetchMessages(ctx context.Context, fetchParams bridgev2
return nil, bridgev2.ErrNotLoggedIn
}
channelID := discordid.ParsePortalID(fetchParams.Portal.ID)
channelID := discordid.ParseChannelPortalID(fetchParams.Portal.ID)
log := zerolog.Ctx(ctx).With().
Str("action", "fetch messages").
Str("channel_id", channelID).
Int("desired_count", fetchParams.Count).
Bool("forward", fetchParams.Forward).Logger()
ctx = log.WithContext(ctx)
var beforeID string
var afterID string
@@ -64,6 +66,32 @@ func (dc *DiscordClient) FetchMessages(ctx context.Context, fetchParams bridgev2
return nil, err
}
// Update our user cache with all of the users present in the response. This
// indirectly makes `GetUserInfo` on `DiscordClient` return the information
// we've fetched above.
cachedDiscordUserIDs := dc.userCache.UpdateWithMessages(msgs)
{
log := zerolog.Ctx(ctx).With().
Str("action", "update ghosts via fetched messages").
Logger()
ctx := log.WithContext(ctx)
// Update/create all of the ghosts for the users involved. This lets us
// set a correct per-message profile on each message, even for users
// that we've never seen until now.
for _, discordUserID := range cachedDiscordUserIDs {
ghost, err := dc.connector.Bridge.GetGhostByID(ctx, discordid.MakeUserID(discordUserID))
if err != nil {
log.Err(err).Str("ghost_id", discordUserID).
Msg("Failed to get ghost associated with message")
continue
}
ghost.UpdateInfoIfNecessary(ctx, dc.UserLogin, bridgev2.RemoteEventMessage)
}
}
converted := make([]*bridgev2.BackfillMessage, 0, len(msgs))
for _, msg := range msgs {
streamOrder, _ := strconv.ParseInt(msg.ID, 10, 64)

View File

@@ -65,6 +65,7 @@ var discordCaps = &event.RoomFeatures{
ID: capID(),
Reply: event.CapLevelFullySupported,
Reaction: event.CapLevelFullySupported,
Edit: event.CapLevelFullySupported,
Delete: event.CapLevelFullySupported,
Formatting: event.FormattingFeatureMap{
event.FmtBold: event.CapLevelFullySupported,
@@ -136,10 +137,7 @@ var discordCaps = &event.RoomFeatures{
},
LocationMessage: event.CapLevelUnsupported,
MaxTextLength: MaxTextLength,
// TODO: Support reactions.
// TODO: Support threads.
// TODO: Support editing.
// TODO: Support message deletion.
}
func (dc *DiscordClient) GetCapabilities(ctx context.Context, portal *bridgev2.Portal) *event.RoomFeatures {

View File

@@ -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.MakeGuildPortalIDWithID(ch.GuildID))
} else if ch.ParentID != "" {
// Categorized guild channels.
parentPortalID = ptr.Ptr(discordid.MakeChannelPortalIDWithID(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.ParseChannelPortalID(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)
}
}

View File

@@ -21,6 +21,7 @@ import (
"errors"
"fmt"
"io"
"maps"
"net/http"
"slices"
"sync"
@@ -29,32 +30,31 @@ import (
"github.com/bwmarrin/discordgo"
"github.com/rs/zerolog"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/bridgev2/simplevent"
"maunium.net/go/mautrix/bridgev2/status"
"go.mau.fi/util/ptr"
"go.mau.fi/mautrix-discord/pkg/discordid"
)
type DiscordClient struct {
connector *DiscordConnector
usersFromReady map[string]*discordgo.User
UserLogin *bridgev2.UserLogin
Session *discordgo.Session
connector *DiscordConnector
UserLogin *bridgev2.UserLogin
Session *discordgo.Session
client *http.Client
hasBegunSyncing bool
markedOpened map[string]time.Time
markedOpenedLock sync.Mutex
userCache *UserCache
}
func (d *DiscordConnector) LoadUserLogin(ctx context.Context, login *bridgev2.UserLogin) error {
meta := login.Metadata.(*discordid.UserLoginMetadata)
session, err := NewDiscordSession(ctx, meta.Token)
login.Save(ctx)
if err != nil {
return err
@@ -64,8 +64,9 @@ func (d *DiscordConnector) LoadUserLogin(ctx context.Context, login *bridgev2.Us
connector: d,
UserLogin: login,
Session: session,
client: d.Bridge.GetHTTPClientSettings().Compile(),
userCache: NewUserCache(session),
}
cl.SetUp(ctx, meta)
login.Client = &cl
@@ -74,46 +75,31 @@ func (d *DiscordConnector) LoadUserLogin(ctx context.Context, login *bridgev2.Us
var _ bridgev2.NetworkAPI = (*DiscordClient)(nil)
// SetUp performs basic bookkeeping and initialization that should be done
// immediately after a DiscordClient has been created.
//
// nil may be passed for meta, especially during provisioning where we need to
// connect to the Discord gateway, but don't have a UserLogin yet.
func (d *DiscordClient) SetUp(ctx context.Context, meta *discordid.UserLoginMetadata) {
// TODO: Turn this into a factory function like `NewDiscordClient`.
log := zerolog.Ctx(ctx)
// We'll have UserLogin metadata if this UserLogin is being loaded from the
// database, i.e. it hasn't just been provisioned.
if meta != nil {
if meta.HeartbeatSession.IsExpired() {
log.Info().Msg("Heartbeat session expired, creating a new one")
meta.HeartbeatSession = discordgo.NewHeartbeatSession()
}
meta.HeartbeatSession.BumpLastUsed()
d.Session.HeartbeatSession = meta.HeartbeatSession
}
d.markedOpened = make(map[string]time.Time)
}
func (d *DiscordClient) Connect(ctx context.Context) {
log := zerolog.Ctx(ctx)
if d.Session == nil {
log.Error().Msg("No session present")
d.UserLogin.BridgeState.Send(status.BridgeState{
StateEvent: status.StateBadCredentials,
Error: "discord-not-logged-in",
})
return
}
meta := d.UserLogin.Metadata.(*discordid.UserLoginMetadata)
if meta.HeartbeatSession.IsExpired() {
log.Info().Msg("Heartbeat session expired, creating a new one")
meta.HeartbeatSession = discordgo.NewHeartbeatSession()
}
meta.HeartbeatSession.BumpLastUsed()
d.Session.HeartbeatSession = meta.HeartbeatSession
d.markedOpened = make(map[string]time.Time)
log.Debug().Msg("Connecting to Discord")
d.UserLogin.BridgeState.Send(status.BridgeState{
StateEvent: status.StateConnecting,
})
if err := d.connect(ctx); err != nil {
log.Err(err).Msg("Couldn't connect to Discord")
d.UserLogin.BridgeState.Send(status.BridgeState{
StateEvent: status.StateUnknownError,
Error: "discord-connect-error",
Message: err.Error(),
})
}
}
@@ -145,18 +131,11 @@ func (cl *DiscordClient) connect(ctx context.Context) error {
user := cl.Session.State.User
log.Info().Str("user_id", user.ID).Str("user_username", user.Username).Msg("Connected to Discord")
// Stash all of the users we received in READY so we can perform quick lookups
// keyed by user ID.
cl.usersFromReady = make(map[string]*discordgo.User)
for _, user := range cl.Session.State.Ready.Users {
cl.usersFromReady[user.ID] = user
}
// Populate the user cache with the users from the READY payload.
log.Debug().Int("n_users", len(cl.Session.State.Ready.Users)).Msg("Inserting users from READY into cache")
cl.userCache.UpdateWithReady(&cl.Session.State.Ready)
// NOTE: We won't have a UserLogin during provisioning, because the UserLogin
// can only be properly constructed once we know what the Discord user ID is
// (i.e. we have returned from this function). We'll rely on the login
// process calling this method manually instead.
cl.BeginSyncingIfUserLoginPresent(ctx)
cl.BeginSyncing(ctx)
return nil
}
@@ -175,11 +154,7 @@ func (d *DiscordClient) LogoutRemote(ctx context.Context) {
d.Disconnect()
}
func (cl *DiscordClient) BeginSyncingIfUserLoginPresent(ctx context.Context) {
if cl.UserLogin == nil {
cl.connector.Bridge.Log.Warn().Msg("Not syncing just yet as we don't have a UserLogin")
return
}
func (cl *DiscordClient) BeginSyncing(ctx context.Context) {
if cl.hasBegunSyncing {
cl.connector.Bridge.Log.Warn().Msg("Not beginning sync more than once")
return
@@ -278,7 +253,7 @@ func (d *DiscordClient) canSeeGuildChannel(ctx context.Context, ch *discordgo.Ch
func (d *DiscordClient) guildPortalKeyFromID(guildID string) networkid.PortalKey {
// TODO: Support configuring `split_portals`.
return networkid.PortalKey{
ID: discordid.MakeGuildPortalID(guildID),
ID: discordid.MakeGuildPortalIDWithID(guildID),
Receiver: d.UserLogin.ID,
}
}
@@ -288,48 +263,44 @@ func (d *DiscordClient) makeAvatarForGuild(guild *discordgo.Guild) *bridgev2.Ava
ID: discordid.MakeAvatarID(guild.Icon),
Get: func(ctx context.Context) ([]byte, error) {
url := discordgo.EndpointGuildIcon(guild.ID, guild.Icon)
return simpleDownload(ctx, url, "guild icon")
return d.simpleDownload(ctx, url, "guild 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)
}
func (d *DiscordClient) syncGuildSpace(_ context.Context, guild *discordgo.Guild) {
d.connector.Bridge.QueueRemoteEvent(d.UserLogin, &DiscordGuildResync{
Client: d,
guild: guild,
portalKey: d.guildPortalKeyFromID(guild.ID),
})
}
selfEvtSender := d.selfEventSender()
info := &bridgev2.ChatInfo{
Name: &guild.Name,
Topic: nil,
Members: &bridgev2.ChatMemberList{
MemberMap: map[networkid.UserID]bridgev2.ChatMember{selfEvtSender.Sender: {EventSender: selfEvtSender}},
// bridgedGuildIDs returns a set of guild IDs that should be bridged. Note that
// presence in the returned set does not imply anything about the corresponding
// portals and rooms.
func (d *DiscordClient) bridgedGuildIDs() map[string]struct{} {
meta := d.UserLogin.Metadata.(*discordid.UserLoginMetadata)
bridgingGuildIDs := map[string]struct{}{}
// 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)
// guilds that were bridged via the provisioning api
for guildID, bridged := range meta.BridgedGuildIDs {
if bridged {
bridgingGuildIDs[guildID] = struct{}{}
}
} else {
prt.UpdateInfo(ctx, info, d.UserLogin, nil, time.Time{})
}
return nil
// guilds that were declared in the configuration file
for _, guildID := range d.connector.Config.Guilds.BridgingGuildIDs {
bridgingGuildIDs[guildID] = struct{}{}
}
return bridgingGuildIDs
}
func (d *DiscordClient) syncGuilds(ctx context.Context) {
guildIDs := d.connector.Config.Guilds.BridgingGuildIDs
guildIDs := slices.Sorted(maps.Keys(d.bridgedGuildIDs()))
for _, guildID := range guildIDs {
log := zerolog.Ctx(ctx).With().
@@ -337,31 +308,48 @@ 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")
}
}
}
func (d *DiscordClient) bridgeGuild(ctx context.Context, guildID string) error {
// deleteGuildPortalSpace queues a remote event that deletes a guild space
// (including children).
func (d *DiscordClient) deleteGuildPortalSpace(ctx context.Context, guildID string) {
log := zerolog.Ctx(ctx)
log.Info().Msg("Unbridging guild by deleting the entire space")
d.connector.Bridge.QueueRemoteEvent(d.UserLogin, &simplevent.ChatDelete{
EventMeta: simplevent.EventMeta{
Type: bridgev2.RemoteEventChatDelete,
PortalKey: d.guildPortalKeyFromID(guildID),
},
OnlyForMe: true,
Children: true,
})
}
func (d *DiscordClient) syncGuild(ctx context.Context, guildID string) error {
log := zerolog.Ctx(ctx).With().
Str("guild_id", guildID).
Str("action", "bridge guild").
Logger()
ctx = log.WithContext(ctx)
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?")
// TODO likely left/kicked/banned from guild; nuke the portals
return errors.New("couldn't find guild in state")
}
err = d.syncGuildSpace(ctx, guild)
if err != nil {
log.Err(err).Msg("Couldn't sync guild space portal")
return fmt.Errorf("couldn't sync guild space portal: %w", err)
}
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)).
@@ -381,27 +369,33 @@ func (d *DiscordClient) bridgeGuild(ctx context.Context, guildID string) error {
d.syncChannel(ctx, guildCh)
}
log.Debug().Msg("Subscribing to guild after bridging")
err = d.Session.SubscribeGuild(discordgo.GuildSubscribeData{
GuildID: guild.ID,
d.subscribeGuild(ctx, guildID)
return nil
}
func (d *DiscordClient) subscribeGuild(ctx context.Context, guildID string) {
log := zerolog.Ctx(ctx)
log.Debug().Msg("Subscribing to guild")
err := d.Session.SubscribeGuild(discordgo.GuildSubscribeData{
GuildID: guildID,
Typing: true,
Activities: true,
Threads: true,
})
if err != nil {
log.Warn().Err(err).Msg("Failed to subscribe to guild; proceeding")
log.Warn().Err(err).Msg("Failed to subscribe to guild, proceeding")
}
return nil
}
func simpleDownload(ctx context.Context, url, thing string) ([]byte, error) {
func (d *DiscordClient) simpleDownload(ctx context.Context, url, thing string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("failed to prepare request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
resp, err := d.client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to download %s: %w", thing, err)
}
@@ -427,6 +421,10 @@ func (d *DiscordClient) selfEventSender() bridgev2.EventSender {
}
func (d *DiscordClient) makeEventSender(user *discordgo.User) bridgev2.EventSender {
if user == nil {
panic("DiscordClient makeEventSender was passed a nil user")
}
return d.makeEventSenderWithID(user.ID)
}
@@ -434,6 +432,6 @@ 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),
portalKey: discordid.MakeChannelPortalKey(ch, d.UserLogin.ID, true),
})
}

View File

@@ -19,8 +19,13 @@ package connector
import (
"context"
"github.com/rs/zerolog"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-discord/pkg/discordid"
"go.mau.fi/mautrix-discord/pkg/msgconv"
)
@@ -31,14 +36,14 @@ type DiscordConnector struct {
}
var (
_ bridgev2.NetworkConnector = (*DiscordConnector)(nil)
_ bridgev2.MaxFileSizeingNetwork = (*DiscordConnector)(nil)
_ bridgev2.NetworkConnector = (*DiscordConnector)(nil)
_ bridgev2.MaxFileSizeingNetwork = (*DiscordConnector)(nil)
_ bridgev2.TransactionIDGeneratingNetwork = (*DiscordConnector)(nil)
)
func (d *DiscordConnector) Init(bridge *bridgev2.Bridge) {
d.Bridge = bridge
d.MsgConv = msgconv.NewMessageConverter(bridge)
d.setUpProvisioningAPIs()
}
func (d *DiscordConnector) SetMaxFileSize(maxSize int64) {
@@ -46,6 +51,15 @@ func (d *DiscordConnector) SetMaxFileSize(maxSize int64) {
}
func (d *DiscordConnector) Start(ctx context.Context) error {
log := zerolog.Ctx(ctx)
log.Debug().Msg("Setting up provisioning API")
err := d.setUpProvisioningAPIs()
if err != nil {
log.Err(err).Msg("Failed to set up provisioning API, proceeding")
// Don't treat this error as fatal.
}
return nil
}
@@ -59,3 +73,7 @@ func (d *DiscordConnector) GetName() bridgev2.BridgeName {
DefaultPort: 29334,
}
}
func (d *DiscordConnector) GenerateTransactionID(_ id.UserID, _ id.RoomID, _ event.Type) networkid.RawTransactionID {
return networkid.RawTransactionID(discordid.GenerateNonce())
}

View File

@@ -1,179 +0,0 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2026 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package connector
import (
"context"
"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
}
var (
_ bridgev2.RemoteChatResyncWithInfo = (*DiscordChatResync)(nil)
_ bridgev2.RemoteChatResyncBackfill = (*DiscordChatResync)(nil)
_ bridgev2.RemoteEventThatMayCreatePortal = (*DiscordChatResync)(nil)
)
func (d *DiscordChatResync) AddLogContext(c zerolog.Context) zerolog.Context {
c = c.Str("channel_id", d.channel.ID).Int("channel_type", int(d.channel.Type))
return c
}
func (d *DiscordChatResync) GetPortalKey() networkid.PortalKey {
return d.portalKey
}
func (d *DiscordChatResync) GetSender() bridgev2.EventSender {
return bridgev2.EventSender{}
}
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 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 {
return true
}
func (d *DiscordChatResync) CheckNeedsBackfill(ctx context.Context, latestBridged *database.Message) (bool, error) {
if latestBridged == nil {
zerolog.Ctx(ctx).Debug().Str("channel_id", d.channel.ID).Msg("Haven't bridged any messages at all, not forward backfilling")
return false, nil
}
return latestBridged.ID < discordid.MakeMessageID(d.channel.LastMessageID), nil
}

View File

@@ -0,0 +1,75 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2026 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package connector
import (
"context"
"github.com/bwmarrin/discordgo"
"github.com/rs/zerolog"
"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
}
var (
_ bridgev2.RemoteChatResyncWithInfo = (*DiscordChatResync)(nil)
_ bridgev2.RemoteChatResyncBackfill = (*DiscordChatResync)(nil)
_ bridgev2.RemoteEventThatMayCreatePortal = (*DiscordChatResync)(nil)
)
func (d *DiscordChatResync) AddLogContext(c zerolog.Context) zerolog.Context {
c = c.Str("channel_id", d.channel.ID).Int("channel_type", int(d.channel.Type))
return c
}
func (d *DiscordChatResync) GetPortalKey() networkid.PortalKey {
return d.portalKey
}
func (d *DiscordChatResync) GetSender() bridgev2.EventSender {
return bridgev2.EventSender{}
}
func (d *DiscordChatResync) GetType() bridgev2.RemoteEventType {
return bridgev2.RemoteEventChatResync
}
func (d *DiscordChatResync) GetChatInfo(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.ChatInfo, error) {
return d.Client.GetChatInfo(ctx, portal)
}
func (d *DiscordChatResync) ShouldCreatePortal() bool {
return true
}
func (d *DiscordChatResync) CheckNeedsBackfill(ctx context.Context, latestBridged *database.Message) (bool, error) {
if latestBridged == nil {
zerolog.Ctx(ctx).Debug().Str("channel_id", d.channel.ID).Msg("Haven't bridged any messages at all, not forward backfilling")
return false, nil
}
return latestBridged.ID < discordid.MakeMessageID(d.channel.LastMessageID), nil
}

View File

@@ -0,0 +1,61 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2026 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package connector
import (
"context"
"github.com/bwmarrin/discordgo"
"github.com/rs/zerolog"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/networkid"
)
type DiscordGuildResync struct {
Client *DiscordClient
guild *discordgo.Guild
portalKey networkid.PortalKey
}
var (
_ bridgev2.RemoteChatResyncWithInfo = (*DiscordGuildResync)(nil)
_ bridgev2.RemoteEventThatMayCreatePortal = (*DiscordGuildResync)(nil)
)
func (d *DiscordGuildResync) AddLogContext(c zerolog.Context) zerolog.Context {
return c.Str("guild_id", d.guild.ID).Str("guild_name", d.guild.Name)
}
func (d *DiscordGuildResync) GetPortalKey() networkid.PortalKey {
return d.portalKey
}
func (d *DiscordGuildResync) GetSender() bridgev2.EventSender {
return bridgev2.EventSender{}
}
func (d *DiscordGuildResync) GetType() bridgev2.RemoteEventType {
return bridgev2.RemoteEventChatResync
}
func (d *DiscordGuildResync) ShouldCreatePortal() bool {
return true
}
func (d *DiscordGuildResync) GetChatInfo(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.ChatInfo, error) {
return d.Client.GetChatInfo(ctx, portal)
}

View File

@@ -20,11 +20,16 @@ import (
"context"
"fmt"
"runtime/debug"
"slices"
"strconv"
"time"
"github.com/bwmarrin/discordgo"
"github.com/rs/zerolog"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/bridgev2/simplevent"
"maunium.net/go/mautrix/bridgev2/status"
"go.mau.fi/mautrix-discord/pkg/discordid"
@@ -58,12 +63,69 @@ type DiscordMessage struct {
Client *DiscordClient
}
func (m *DiscordMessage) ConvertEdit(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, existing []*database.Message) (*bridgev2.ConvertedEdit, error) {
log := zerolog.Ctx(ctx).With().
Str("action", "convert discord edit").Logger()
ctx = log.WithContext(ctx)
// FIXME don't redundantly reupload attachments
convertedEdit := m.Client.connector.MsgConv.ToMatrix(
ctx,
portal,
intent,
m.Client.UserLogin,
m.Client.Session,
m.Data,
)
// TODO this is really gross and relies on how we assign incrementing numeric
// part ids. to return a semantically correct `ConvertedEdit` we should ditch
// this system
slices.SortStableFunc(existing, func(a *database.Message, b *database.Message) int {
ai, _ := strconv.Atoi(string(a.PartID))
bi, _ := strconv.Atoi(string(b.PartID))
return ai - bi
})
if len(convertedEdit.Parts) != len(existing) {
// FIXME support # of parts changing; triggerable by removing individual
// attachments, etc.
//
// at the very least we can make this better by handling attachments,
// which are always(?) at the end
log.Warn().Int("n_parts_existing", len(existing)).Int("n_parts_after_edit", len(convertedEdit.Parts)).
Msg("Ignoring message edit that changed number of parts")
return nil, bridgev2.ErrIgnoringRemoteEvent
}
parts := make([]*bridgev2.ConvertedEditPart, 0, len(existing))
for pi, part := range convertedEdit.Parts {
parts = append(parts, part.ToEditPart(existing[pi]))
}
return &bridgev2.ConvertedEdit{
ModifiedParts: parts,
}, nil
}
var (
_ bridgev2.RemoteMessage = (*DiscordMessage)(nil)
// _ bridgev2.RemoteEdit = (*DiscordMessage)(nil)
// _ bridgev2.RemoteMessageRemove = (*DiscordMessage)(nil)
_ bridgev2.RemoteMessage = (*DiscordMessage)(nil)
_ bridgev2.RemoteMessageWithTransactionID = (*DiscordMessage)(nil)
_ bridgev2.RemoteEdit = (*DiscordMessage)(nil)
_ bridgev2.RemoteMessageRemove = (*DiscordMessage)(nil)
)
func (m *DiscordMessage) GetTargetMessage() networkid.MessageID {
return discordid.MakeMessageID(m.Data.ID)
}
func (m *DiscordMessage) GetTransactionID() networkid.TransactionID {
if m.Data.Nonce == "" {
return ""
}
return networkid.TransactionID(m.Data.Nonce)
}
func (m *DiscordMessage) ConvertMessage(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI) (*bridgev2.ConvertedMessage, error) {
return m.Client.connector.MsgConv.ToMatrix(ctx, portal, intent, m.Client.UserLogin, m.Client.Session, m.Data), nil
}
@@ -73,19 +135,24 @@ func (m *DiscordMessage) GetID() networkid.MessageID {
}
func (m *DiscordMessage) GetSender() bridgev2.EventSender {
if m.Data.Author == nil {
// Message deletions don't have a sender associated with them.
return bridgev2.EventSender{}
}
return m.Client.makeEventSender(m.Data.Author)
}
func (d *DiscordClient) wrapDiscordMessage(evt *discordgo.MessageCreate) DiscordMessage {
func (d *DiscordClient) wrapDiscordMessage(msg *discordgo.Message, typ bridgev2.RemoteEventType) DiscordMessage {
return DiscordMessage{
DiscordEventMeta: &DiscordEventMeta{
Type: bridgev2.RemoteEventMessage,
Type: typ,
PortalKey: networkid.PortalKey{
ID: discordid.MakePortalID(evt.ChannelID),
ID: discordid.MakeChannelPortalIDWithID(msg.ChannelID),
Receiver: d.UserLogin.ID,
},
},
Data: evt.Message,
Data: msg,
Client: d,
}
}
@@ -154,7 +221,7 @@ func (d *DiscordClient) wrapDiscordReaction(reaction *discordgo.MessageReaction,
DiscordEventMeta: &DiscordEventMeta{
Type: evtType,
PortalKey: networkid.PortalKey{
ID: discordid.MakePortalID(reaction.ChannelID),
ID: discordid.MakeChannelPortalIDWithID(reaction.ChannelID),
Receiver: d.UserLogin.ID,
},
},
@@ -163,24 +230,41 @@ func (d *DiscordClient) wrapDiscordReaction(reaction *discordgo.MessageReaction,
}
}
func (d *DiscordClient) handleDiscordTyping(ctx context.Context, typing *discordgo.TypingStart) {
if typing.UserID == d.Session.State.User.ID {
return
}
log := zerolog.Ctx(ctx)
portalKey := networkid.PortalKey{
ID: discordid.MakeChannelPortalIDWithID(typing.ChannelID),
Receiver: d.UserLogin.ID,
}
portal, err := d.connector.Bridge.GetExistingPortalByKey(ctx, portalKey)
if err != nil {
log.Err(err).Msg("Failed to query for existing portal")
return
}
if portal == nil || portal.MXID == "" {
return
}
// Make sure we have this user's info in case we haven't seen them at all yet.
_ = d.userCache.Resolve(ctx, typing.UserID)
d.UserLogin.Bridge.QueueRemoteEvent(d.UserLogin, &simplevent.Typing{
EventMeta: simplevent.EventMeta{
Type: bridgev2.RemoteEventTyping,
PortalKey: portalKey,
Sender: d.makeEventSenderWithID(typing.UserID),
},
Timeout: 12 * time.Second,
Type: bridgev2.TypingTypeText,
})
}
func (d *DiscordClient) handleDiscordEvent(rawEvt any) {
if d.UserLogin == nil {
// Our event handlers are able to assume that a UserLogin is available.
// We respond to special events like READY outside of this function,
// by virtue of methods like Session.Open only returning control flow
// after RESUME or READY.
log := zerolog.Ctx(context.TODO())
log.Trace().Msg("Dropping Discord event received before UserLogin creation")
return
}
if d.Session == nil || d.Session.State == nil || d.Session.State.User == nil {
// Our event handlers are able to assume that we've fully connected to the
// gateway.
d.UserLogin.Log.Debug().Msg("Dropping Discord event received before READY or RESUMED")
return
}
defer func() {
err := recover()
if err != nil {
@@ -194,13 +278,21 @@ func (d *DiscordClient) handleDiscordEvent(rawEvt any) {
log := d.UserLogin.Log.With().Str("action", "handle discord event").
Type("event_type", rawEvt).
Logger()
ctx := log.WithContext(d.UserLogin.Bridge.BackgroundCtx)
// NOTE: discordgo seemingly dispatches both the proper unmarshalled type
// (e.g. `*discordgo.TypingStart`) _as well as_ a "raw" *discordgo.Event
// (e.g. `*discordgo.Event` with `Type` of `TYPING_START`) for every gateway
// event
switch evt := rawEvt.(type) {
case *discordgo.Ready:
log.Info().Msg("Received READY dispatch from discordgo")
d.userCache.UpdateWithReady(evt)
d.UserLogin.BridgeState.Send(status.BridgeState{
StateEvent: status.StateConnected,
})
case *discordgo.TypingStart:
d.handleDiscordTyping(ctx, evt)
case *discordgo.Resumed:
log.Info().Msg("Received RESUMED dispatch from discordgo")
d.UserLogin.BridgeState.Send(status.BridgeState{
@@ -215,8 +307,18 @@ func (d *DiscordClient) handleDiscordEvent(rawEvt any) {
Msg("Dropping message that lacks an author")
return
}
wrappedEvt := d.wrapDiscordMessage(evt)
d.userCache.UpdateWithMessage(evt.Message)
wrappedEvt := d.wrapDiscordMessage(evt.Message, bridgev2.RemoteEventMessage)
d.UserLogin.Bridge.QueueRemoteEvent(d.UserLogin, &wrappedEvt)
case *discordgo.MessageUpdate:
wrappedEvt := d.wrapDiscordMessage(evt.Message, bridgev2.RemoteEventEdit)
d.UserLogin.Bridge.QueueRemoteEvent(d.UserLogin, &wrappedEvt)
case *discordgo.UserUpdate:
d.userCache.UpdateWithUserUpdate(evt)
case *discordgo.MessageDelete:
wrappedEvt := d.wrapDiscordMessage(evt.Message, bridgev2.RemoteEventMessageRemove)
d.UserLogin.Bridge.QueueRemoteEvent(d.UserLogin, &wrappedEvt)
// TODO *discordgo.MessageDeleteBulk
case *discordgo.MessageReactionAdd:
wrappedEvt := d.wrapDiscordReaction(evt.MessageReaction, true)
d.UserLogin.Bridge.QueueRemoteEvent(d.UserLogin, &wrappedEvt)
@@ -227,12 +329,12 @@ func (d *DiscordClient) handleDiscordEvent(rawEvt any) {
// TODO case *discordgo.MessageReactionRemoveEmoji: (needs impl. in discordgo)
case *discordgo.PresenceUpdate:
return
case *discordgo.Event:
// For presently unknown reasons sometimes discordgo won't unmarshal
// events into their proper corresponding structs.
if evt.Type == "PRESENCE_UPDATE" || evt.Type == "PASSIVE_UPDATE_V2" || evt.Type == "CONVERSATION_SUMMARY_UPDATE" {
case *discordgo.GuildDelete:
if evt.Unavailable {
log.Warn().Str("guild_id", evt.ID).Msg("Guild became unavailable")
// For now, leave the portals alone if the guild only went away due to an outage.
return
}
log.Debug().Str("event_type", evt.Type).Msg("Ignoring unknown Discord event")
d.deleteGuildPortalSpace(ctx, evt.ID)
}
}

View File

@@ -18,6 +18,7 @@ package connector
import (
"context"
"fmt"
"time"
"github.com/bwmarrin/discordgo"
@@ -25,6 +26,8 @@ import (
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database"
"go.mau.fi/util/variationselector"
"go.mau.fi/mautrix-discord/pkg/discordid"
)
@@ -43,7 +46,7 @@ func (d *DiscordClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.M
portal := msg.Portal
guildID := portal.Metadata.(*discordid.PortalMetadata).GuildID
channelID := discordid.ParsePortalID(portal.ID)
channelID := discordid.ParseChannelPortalID(portal.ID)
sendReq, err := d.connector.MsgConv.ToDiscord(ctx, d.Session, msg)
if err != nil {
@@ -54,7 +57,7 @@ func (d *DiscordClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.M
// TODO: When supporting threads (and not a bot user), send a thread referer.
options = append(options, discordgo.WithChannelReferer(guildID, channelID))
sentMsg, err := d.Session.ChannelMessageSendComplex(discordid.ParsePortalID(msg.Portal.ID), sendReq, options...)
sentMsg, err := d.Session.ChannelMessageSendComplex(discordid.ParseChannelPortalID(msg.Portal.ID), sendReq, options...)
if err != nil {
return nil, err
}
@@ -70,12 +73,32 @@ func (d *DiscordClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.M
}
func (d *DiscordClient) HandleMatrixEdit(ctx context.Context, msg *bridgev2.MatrixEdit) error {
//TODO implement me
panic("implement me")
log := zerolog.Ctx(ctx).With().Str("action", "matrix message edit").Logger()
ctx = log.WithContext(ctx)
content, _ := d.connector.MsgConv.ConvertMatrixMessageContent(
ctx,
msg.Portal,
msg.Content,
// Disregard link previews for now. Discord generally allows you to
// remove individual link previews from a message though.
[]string{},
)
_, err := d.Session.ChannelMessageEdit(
discordid.ParseChannelPortalID(msg.Portal.ID),
discordid.ParseMessageID(msg.EditTarget.ID),
content,
)
if err != nil {
return fmt.Errorf("failed to send message edit to discord: %w", err)
}
return nil
}
func (d *DiscordClient) PreHandleMatrixReaction(ctx context.Context, reaction *bridgev2.MatrixReaction) (bridgev2.MatrixReactionPreResponse, error) {
key := reaction.Content.RelatesTo.Key
key := variationselector.FullyQualify(reaction.Content.RelatesTo.Key)
// TODO: Handle custom emoji.
return bridgev2.MatrixReactionPreResponse{
@@ -85,18 +108,22 @@ func (d *DiscordClient) PreHandleMatrixReaction(ctx context.Context, reaction *b
}
func (d *DiscordClient) HandleMatrixReaction(ctx context.Context, reaction *bridgev2.MatrixReaction) (*database.Reaction, error) {
relatesToKey := reaction.Content.RelatesTo.Key
portal := reaction.Portal
meta := portal.Metadata.(*discordid.PortalMetadata)
err := d.Session.MessageReactionAddUser(meta.GuildID, discordid.ParsePortalID(portal.ID), discordid.ParseMessageID(reaction.TargetMessage.ID), relatesToKey)
err := d.Session.MessageReactionAddUser(
meta.GuildID,
discordid.ParseChannelPortalID(portal.ID),
discordid.ParseMessageID(reaction.TargetMessage.ID),
discordid.ParseEmojiID(reaction.PreHandleResp.EmojiID),
)
return nil, err
}
func (d *DiscordClient) HandleMatrixReactionRemove(ctx context.Context, removal *bridgev2.MatrixReactionRemove) error {
removing := removal.TargetReaction
emojiID := removing.EmojiID
channelID := discordid.ParsePortalID(removing.Room.ID)
channelID := discordid.ParseChannelPortalID(removing.Room.ID)
guildID := removal.Portal.Metadata.(*discordid.PortalMetadata).GuildID
err := d.Session.MessageReactionRemoveUser(guildID, channelID, discordid.ParseMessageID(removing.MessageID), discordid.ParseEmojiID(emojiID), discordid.ParseUserLoginID(d.UserLogin.ID))
@@ -104,7 +131,7 @@ func (d *DiscordClient) HandleMatrixReactionRemove(ctx context.Context, removal
}
func (d *DiscordClient) HandleMatrixMessageRemove(ctx context.Context, removal *bridgev2.MatrixMessageRemove) error {
channelID := discordid.ParsePortalID(removal.Portal.ID)
channelID := discordid.ParseChannelPortalID(removal.Portal.ID)
messageID := discordid.ParseMessageID(removal.TargetMessage.ID)
return d.Session.ChannelMessageDelete(channelID, messageID)
}
@@ -150,7 +177,7 @@ func (d *DiscordClient) HandleMatrixReadReceipt(ctx context.Context, msg *bridge
// TODO: Support threads.
guildID := msg.Portal.Metadata.(*discordid.PortalMetadata).GuildID
channelID := discordid.ParsePortalID(msg.Portal.ID)
channelID := discordid.ParseChannelPortalID(msg.Portal.ID)
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")
@@ -175,7 +202,7 @@ func (d *DiscordClient) viewingChannel(ctx context.Context, portal *bridgev2.Por
d.markedOpenedLock.Lock()
defer d.markedOpenedLock.Unlock()
channelID := discordid.ParsePortalID(portal.ID)
channelID := discordid.ParseChannelPortalID(portal.ID)
log := zerolog.Ctx(ctx).With().
Str("channel_id", channelID).Logger()
@@ -206,7 +233,7 @@ func (d *DiscordClient) HandleMatrixTyping(ctx context.Context, msg *bridgev2.Ma
_ = d.viewingChannel(ctx, msg.Portal)
guildID := msg.Portal.Metadata.(*discordid.PortalMetadata).GuildID
channelID := discordid.ParsePortalID(msg.Portal.ID)
channelID := discordid.ParseChannelPortalID(msg.Portal.ID)
// TODO: Support threads properly when sending the referer.
err := d.Session.ChannelTyping(channelID, discordgo.WithChannelReferer(guildID, channelID))

View File

@@ -47,55 +47,40 @@ type DiscordGenericLogin struct {
}
func (dl *DiscordGenericLogin) FinalizeCreatingLogin(ctx context.Context, token string) (*bridgev2.UserLogin, error) {
log := zerolog.Ctx(ctx).With().Str("action", "finalize login").Logger()
// TODO we don't need an entire discordgo session for this as we're just
// interested in /users/@me
log.Info().Msg("Creating initial session with provided token")
session, err := NewDiscordSession(ctx, token)
if err != nil {
return nil, fmt.Errorf("couldn't create discord session: %w", err)
}
client := DiscordClient{
connector: dl.connector,
Session: session,
}
client.SetUp(ctx, nil)
err = client.connect(ctx)
if err != nil {
dl.Cancel()
return nil, err
}
// At this point we've opened a WebSocket connection to the gateway, received
// a READY packet, and know who we are.
user := session.State.User
dl.DiscordUser = user
dl.Session = session
log.Info().Msg("Requesting @me with provided token")
self, err := session.User("@me")
if err != nil {
return nil, fmt.Errorf("couldn't request self user (bad credentials?): %w", err)
}
dl.DiscordUser = self
log.Info().Msg("Fetched @me")
ul, err := dl.User.NewLogin(ctx, &database.UserLogin{
ID: discordid.MakeUserLoginID(user.ID),
ID: discordid.MakeUserLoginID(self.ID),
Metadata: &discordid.UserLoginMetadata{
Token: token,
HeartbeatSession: session.HeartbeatSession,
},
}, &bridgev2.NewLoginParams{
LoadUserLogin: func(ctx context.Context, login *bridgev2.UserLogin) error {
login.Client = &client
client.UserLogin = login
// Only now that we have a UserLogin can we begin syncing.
client.BeginSyncingIfUserLoginPresent(ctx)
return nil
},
DeleteOnConflict: true,
DontReuseExisting: false,
DeleteOnConflict: true,
})
if err != nil {
dl.Cancel()
return nil, fmt.Errorf("couldn't create login: %w", err)
return nil, fmt.Errorf("couldn't create login during finalization: %w", err)
}
zerolog.Ctx(ctx).Info().
Str("user_id", user.ID).
Str("user_username", user.Username).
Msg("Logged in to Discord")
(ul.Client.(*DiscordClient)).Connect(ctx)
return ul, nil
}

View File

@@ -20,6 +20,7 @@ import (
"context"
"errors"
"net/http"
"strings"
"github.com/bwmarrin/discordgo"
"github.com/rs/zerolog"
@@ -81,16 +82,17 @@ func (d *DiscordConnector) setUpProvisioningAPIs() error {
type guildEntry struct {
ID string `json:"id"`
Name string `json:"name"`
// TODO v1 uses `id.ContentURI` whereas we stuff the discord cdn url here
AvatarURL string `json:"avatar_url"`
// v1-compatible fields:
// new in v2:
Bridged bool `json:"bridged"`
Available bool `json:"available"`
// legacy fields from v1:
MXID string `json:"mxid"`
AutoBridge bool `json:"auto_bridge_channels"`
BridgingMode string `json:"bridging_mode"`
Available bool `json:"available"`
}
type respGuildsList struct {
Guilds []guildEntry `json:"guilds"`
@@ -117,27 +119,78 @@ func (p *ProvisioningAPI) makeHandler(handler func(http.ResponseWriter, *http.Re
}
func (p *ProvisioningAPI) guildsList(w http.ResponseWriter, r *http.Request, login *bridgev2.UserLogin, client *DiscordClient) {
ctx := r.Context()
p.log.Info().Str("login_id", discordid.ParseUserLoginID(login.ID)).Msg("guilds list requested via provisioning api")
bridgedGuildIDs := client.bridgedGuildIDs()
var resp respGuildsList
resp.Guilds = []guildEntry{}
for _, guild := range client.Session.State.Guilds {
portalKey := client.guildPortalKeyFromID(guild.ID)
portal, err := p.connector.Bridge.GetExistingPortalByKey(ctx, portalKey)
if err != nil {
p.log.Err(err).
Str("guild_id", guild.ID).
Msg("Failed to get guild portal for provisioning list")
}
_, beingBridged := bridgedGuildIDs[guild.ID]
mxid := ""
if portal != nil && portal.MXID != "" {
mxid = portal.MXID.String()
} else if beingBridged {
// Beeper Desktop expects the space to exist by the time it receives
// our HTTP response. If it doesn't, then the space won't appear
// until the app is reloaded, and the toggle in the user interface
// won't respond to the user's click.
//
// Pre-bridgev2, we synchronously bridged guilds. However, this
// might take a while for guilds with many channels.
//
// To solve this, generate a deterministic room ID to use as the
// MXID so that it recognizes the guild as bridged, even if the
// portals haven't been created just yet. This lets us
// asynchronously bridge guilds while keeping the UI responsive.
mxid = p.connector.Bridge.Matrix.GenerateDeterministicRoomID(portalKey).String()
}
resp.Guilds = append(resp.Guilds, guildEntry{
ID: guild.ID,
// For now, have the ID exactly correspond to the portal ID. This
// practically means that the ID will begin with an asterisk (the
// guild portal ID sigil).
//
// Otherwise, Beeper Desktop will show a duplicate space for every
// guild, as it recognizes the guild returned from this HTTP
// endpoint and the actual space itself as separate "entities".
// (Despite this, they point to identical rooms.)
ID: string(discordid.MakeGuildPortalIDWithID(guild.ID)),
Name: guild.Name,
AvatarURL: discordgo.EndpointGuildIcon(guild.ID, guild.Icon),
BridgingMode: "everything",
Bridged: beingBridged,
Available: !guild.Unavailable,
// v1 (legacy) backwards compat:
MXID: mxid,
AutoBridge: beingBridged,
BridgingMode: "everything",
})
}
exhttp.WriteJSONResponse(w, 200, resp)
}
// normalizeGuildID removes the guild portal sigil from a guild ID if it's
// there.
//
// This helps facilitate code that would like to accept portal keys
// corresponding to guilds as well as plain Discord guild IDs.
func normalizeGuildID(guildID string) string {
return strings.TrimPrefix(guildID, discordid.GuildPortalKeySigil)
}
func (p *ProvisioningAPI) bridgeGuild(w http.ResponseWriter, r *http.Request, login *bridgev2.UserLogin, client *DiscordClient) {
guildID := r.PathValue("guildID")
guildID := normalizeGuildID(r.PathValue("guildID"))
if guildID == "" {
mautrix.MInvalidParam.WithMessage("no guild id").Write(w)
return
@@ -148,14 +201,31 @@ func (p *ProvisioningAPI) bridgeGuild(w http.ResponseWriter, r *http.Request, lo
Str("guild_id", guildID).
Msg("requested to bridge guild via provisioning api")
// TODO detect guild already bridged
go client.bridgeGuild(context.TODO(), guildID)
meta := login.Metadata.(*discordid.UserLoginMetadata)
exhttp.WriteJSONResponse(w, 201, nil)
if meta.BridgedGuildIDs == nil {
meta.BridgedGuildIDs = map[string]bool{}
}
_, alreadyBridged := meta.BridgedGuildIDs[guildID]
meta.BridgedGuildIDs[guildID] = true
if err := login.Save(r.Context()); err != nil {
p.log.Err(err).Msg("Failed to save login after guild bridge request")
mautrix.MUnknown.WithMessage("failed to save login: %v", err).Write(w)
return
}
go client.syncGuild(p.connector.Bridge.BackgroundCtx, guildID)
responseStatus := 201
if alreadyBridged {
responseStatus = 200
}
exhttp.WriteJSONResponse(w, responseStatus, nil)
}
func (p *ProvisioningAPI) unbridgeGuild(w http.ResponseWriter, r *http.Request, login *bridgev2.UserLogin, client *DiscordClient) {
guildID := r.PathValue("guildID")
guildID := normalizeGuildID(r.PathValue("guildID"))
if guildID == "" {
mautrix.MInvalidParam.WithMessage("no guild id").Write(w)
return
@@ -166,7 +236,22 @@ func (p *ProvisioningAPI) unbridgeGuild(w http.ResponseWriter, r *http.Request,
Str("guild_id", guildID).
Msg("requested to unbridge guild via provisioning api")
ctx := context.TODO()
meta := login.Metadata.(*discordid.UserLoginMetadata)
if meta.BridgedGuildIDs != nil {
delete(meta.BridgedGuildIDs, guildID)
}
if err := login.Save(r.Context()); err != nil {
p.log.Err(err).Msg("Failed to save login after guild unbridge request")
mautrix.MUnknown.WithMessage("failed to save login: %v", err).Write(w)
return
}
ctx := login.Log.With().
Str("component", "provisioning").
Str("action", "unbridge guild").
Str("guild_id", guildID).
Logger().
WithContext(context.Background())
portalKey := client.guildPortalKeyFromID(guildID)
portal, err := p.connector.Bridge.GetExistingPortalByKey(ctx, portalKey)

View File

@@ -33,6 +33,11 @@ func NewDiscordSession(ctx context.Context, token string) (*discordgo.Session, e
return nil, fmt.Errorf("couldn't create discord session: %w", err)
}
// Don't bother tracking things we don't care/support right now. Presences
// are especially expensive to track as they occur extremely frequently.
session.State.TrackPresences = false
session.State.TrackVoice = false
// Set up logging.
session.LogLevel = discordgo.LogInformational
session.Logger = func(msgL, caller int, format string, a ...any) {

172
pkg/connector/usercache.go Normal file
View File

@@ -0,0 +1,172 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
// Copyright (C) 2026 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package connector
import (
"context"
"errors"
"maps"
"net/http"
"slices"
"sync"
"github.com/bwmarrin/discordgo"
"github.com/rs/zerolog"
"go.mau.fi/mautrix-discord/pkg/discordid"
)
// NOTE: Not simply using `exsync.Map` because we want the lock to be held
// during HTTP requests.
type UserCache struct {
session *discordgo.Session
cache map[string]*discordgo.User
lock sync.Mutex
}
func NewUserCache(session *discordgo.Session) *UserCache {
return &UserCache{
session: session,
cache: make(map[string]*discordgo.User),
}
}
func (uc *UserCache) UpdateWithReady(ready *discordgo.Ready) {
if ready == nil {
return
}
uc.lock.Lock()
defer uc.lock.Unlock()
for _, user := range ready.Users {
uc.cache[user.ID] = user
}
}
// UpdateWithMessage updates the user cache with the users involved in a single
// message (author, mentioned, mentioned author, etc.)
//
// The updated user IDs are returned.
func (uc *UserCache) UpdateWithMessage(msg *discordgo.Message) []string {
if msg == nil {
return []string{}
}
// For now just forward to HandleMessages until a need for a specialized
// path makes itself known.
return uc.UpdateWithMessages([]*discordgo.Message{msg})
}
// UpdateWithMessages updates the user cache with the total set of users involved
// with multiple messages (authors, mentioned users, mentioned authors, etc.)
//
// The updated user IDs are returned.
func (uc *UserCache) UpdateWithMessages(msgs []*discordgo.Message) []string {
if len(msgs) == 0 {
return []string{}
}
collectedUsers := map[string]*discordgo.User{}
for _, msg := range msgs {
collectedUsers[msg.Author.ID] = msg.Author
referenced := msg.ReferencedMessage
if referenced != nil && referenced.Author != nil {
collectedUsers[referenced.Author.ID] = referenced.Author
}
for _, mentioned := range msg.Mentions {
collectedUsers[mentioned.ID] = mentioned
}
// Message snapshots lack `author` entirely and seemingly have an empty
// `mentions` array, even when the original message actually mentions
// someone.
}
uc.lock.Lock()
defer uc.lock.Unlock()
for _, user := range collectedUsers {
uc.cache[user.ID] = user
}
return slices.Collect(maps.Keys(collectedUsers))
}
func (uc *UserCache) UpdateWithUserUpdate(update *discordgo.UserUpdate) {
if update == nil || update.User == nil {
return
}
uc.lock.Lock()
defer uc.lock.Unlock()
uc.cache[update.ID] = update.User
}
// Resolve looks up a user in the cache, requesting the user from the Discord
// HTTP API if not present.
//
// If the user cannot be found, then its nonexistence is cached. This is to
// avoid excessive requests when e.g. backfilling messages from a user that has
// since been deleted since connecting. If some other error occurs, the cache
// isn't touched and nil is returned.
//
// Otherwise, the cache is updated as you'd expect.
func (uc *UserCache) Resolve(ctx context.Context, userID string) *discordgo.User {
if userID == discordid.DeletedGuildUserID {
return &discordid.DeletedGuildUser
}
// Hopefully this isn't too contentious?
uc.lock.Lock()
defer uc.lock.Unlock()
cachedUser, present := uc.cache[userID]
if cachedUser != nil {
return cachedUser
} else if present {
// If a `nil` is present in the map, then we already know that the user
// doesn't exist.
return nil
}
log := zerolog.Ctx(ctx).With().
Str("action", "resolve user").
Str("user_id", userID).Logger()
log.Trace().Msg("Fetching user")
user, err := uc.session.User(userID)
var restError *discordgo.RESTError
if errors.As(err, &restError) && restError.Response.StatusCode == http.StatusNotFound {
log.Info().Msg("Tried to resolve a user that doesn't exist, caching nonexistence")
uc.cache[userID] = nil
return nil
} else if err != nil {
log.Err(err).Msg("Failed to resolve user")
return nil
}
uc.cache[userID] = user
return user
}

View File

@@ -35,13 +35,13 @@ func (d *DiscordClient) IsThisUser(ctx context.Context, userID networkid.UserID)
return userID == discordid.UserLoginIDToUserID(d.UserLogin.ID)
}
func makeUserAvatar(u *discordgo.User) *bridgev2.Avatar {
func (d *DiscordClient) makeUserAvatar(u *discordgo.User) *bridgev2.Avatar {
url := u.AvatarURL("256")
return &bridgev2.Avatar{
ID: discordid.MakeAvatarID(url),
Get: func(ctx context.Context) ([]byte, error) {
return simpleDownload(ctx, url, "user avatar")
return d.simpleDownload(ctx, url, "user avatar")
},
}
}
@@ -54,18 +54,19 @@ func (d *DiscordClient) GetUserInfo(ctx context.Context, ghost *bridgev2.Ghost)
return nil, nil
}
// FIXME(skip): This won't work for users in guilds.
user, ok := d.usersFromReady[discordid.ParseUserID(ghost.ID)]
if !ok {
log.Error().Str("ghost_id", discordid.ParseUserID(ghost.ID)).Msg("Couldn't find corresponding user from READY for ghost")
discordUserID := discordid.ParseUserID(ghost.ID)
discordUser := d.userCache.Resolve(ctx, discordUserID)
if discordUser == nil {
log.Error().Str("discord_user_id", discordUserID).
Msg("Failed to resolve user")
return nil, nil
}
return &bridgev2.UserInfo{
Identifiers: []string{fmt.Sprintf("discord:%s", user.ID)},
Name: ptr.Ptr(user.DisplayName()),
Avatar: makeUserAvatar(user),
IsBot: &user.Bot,
// FIXME clear this for webhooks (stash in ghost metadata)
Identifiers: []string{fmt.Sprintf("discord:%s", discordUser.String())},
Name: ptr.Ptr(discordUser.DisplayName()),
Avatar: d.makeUserAvatar(discordUser),
IsBot: &discordUser.Bot,
}, nil
}

View File

@@ -16,7 +16,10 @@
package discordid
import "github.com/bwmarrin/discordgo"
import (
"github.com/bwmarrin/discordgo"
"maunium.net/go/mautrix/bridgev2/database"
)
type PortalMetadata struct {
// The ID of the Discord guild that the channel corresponding to this portal
@@ -30,4 +33,21 @@ type PortalMetadata struct {
type UserLoginMetadata struct {
Token string `json:"token"`
HeartbeatSession discordgo.HeartbeatSession `json:"heartbeat_session"`
BridgedGuildIDs map[string]bool `json:"bridged_guild_ids,omitempty"`
}
var _ database.MetaMerger = (*UserLoginMetadata)(nil)
func (ulm *UserLoginMetadata) CopyFrom(incoming any) {
incomingMeta, ok := incoming.(*UserLoginMetadata)
if !ok || incomingMeta == nil {
return
}
if incomingMeta.Token != "" {
ulm.Token = incomingMeta.Token
}
ulm.HeartbeatSession = discordgo.NewHeartbeatSession()
// Retain the BridgedGuildIDs from the existing login.
}

View File

@@ -17,10 +17,39 @@
package discordid
import (
"strconv"
"strings"
"time"
"github.com/bwmarrin/discordgo"
"maunium.net/go/mautrix/bridgev2/networkid"
)
// DeletedGuildUserID is a magic user ID that is used in place of an actual user
// ID once they have deleted their account. This only applies in non-private
// (i.e. guild) contexts, such as guild channel message authors and mentions.
//
// Note that this user ID can also appear in message content as part of user
// mention markup ("<@456226577798135808>").
const DeletedGuildUserID = "456226577798135808"
// DeletedGuildUser is the user returned from the Discord API as a stand-in for
// users who have since deleted their account. As the name suggests, this only
// applies to fetched entities within guilds.
var DeletedGuildUser = discordgo.User{
ID: DeletedGuildUserID,
Username: "Deleted User",
Discriminator: "0000",
}
const DiscordEpochMillis = 1420070400000
// GenerateNonce creates a Discord-style snowflake nonce for message idempotency.
func GenerateNonce() string {
snowflake := (time.Now().UnixMilli() - DiscordEpochMillis) << 22
return strconv.FormatInt(snowflake, 10)
}
func MakeUserID(userID string) networkid.UserID {
return networkid.UserID(userID)
}
@@ -43,11 +72,24 @@ func UserLoginIDToUserID(id networkid.UserLoginID) networkid.UserID {
return networkid.UserID(id)
}
func MakePortalID(channelID string) networkid.PortalID {
func MakeChannelPortalKey(ch *discordgo.Channel, userLoginID networkid.UserLoginID, wantReceiver bool) (key networkid.PortalKey) {
key.ID = MakeChannelPortalIDWithID(ch.ID)
if wantReceiver {
key.Receiver = userLoginID
}
return
}
func MakeChannelPortalKeyWithID(channelID string) (key networkid.PortalKey) {
key.ID = MakeChannelPortalIDWithID(channelID)
return
}
func MakeChannelPortalIDWithID(channelID string) networkid.PortalID {
return networkid.PortalID(channelID)
}
func ParsePortalID(portalID networkid.PortalID) string {
func ParseChannelPortalID(portalID networkid.PortalID) string {
return string(portalID)
}
@@ -85,8 +127,8 @@ func MakeAvatarID(avatar string) networkid.AvatarID {
//
// 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.
// guild and the default channel. Otherwise, we wouldn't be able to bridge both
// the channel portal as well as the guild space; their keys would conflict.
//
// "*" was chosen as the asterisk character is used to filter by guilds in
// the quick switcher (in Discord's first-party clients).
@@ -94,19 +136,20 @@ func MakeAvatarID(avatar string) networkid.AvatarID {
// For more information, see: https://discord.com/developers/docs/change-log#breaking-change-default-channels:~:text=New%20guilds%20will%20no%20longer.
const GuildPortalKeySigil = "*"
func MakeGuildPortalID(guildID string) networkid.PortalID {
func MakeGuildPortalIDWithID(guildID string) networkid.PortalID {
return networkid.PortalID(GuildPortalKeySigil + guildID)
}
func MakePortalKey(ch *discordgo.Channel, userLoginID networkid.UserLoginID, wantReceiver bool) (key networkid.PortalKey) {
key.ID = MakePortalID(ch.ID)
if wantReceiver {
key.Receiver = userLoginID
// 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 MakePortalKeyWithID(channelID string) (key networkid.PortalKey) {
key.ID = MakePortalID(channelID)
return
return ""
}

View File

@@ -285,7 +285,7 @@ func (r *discordTagHTMLRenderer) renderDiscordMention(w util.BufWriter, source [
return
// }
case *astDiscordChannelMention:
if portal, _ := node.portal.Bridge.GetPortalByKey(ctx, discordid.MakePortalKeyWithID(
if portal, _ := node.portal.Bridge.GetPortalByKey(ctx, discordid.MakeChannelPortalKeyWithID(
strconv.FormatInt(node.id, 10),
)); portal != nil {
if portal.MXID != "" {

View File

@@ -44,6 +44,9 @@ const (
contextKeyDiscordClient
)
// ToMatrix bridges a Discord message to Matrix.
//
// This method expects ghost information to be up-to-date.
func (mc *MessageConverter) ToMatrix(
ctx context.Context,
portal *bridgev2.Portal,
@@ -128,9 +131,21 @@ func (mc *MessageConverter) ToMatrix(
// puppet.addMemberMeta(part, msg)
// }
sender := discordid.MakeUserID(msg.Author.ID)
pmp, err := portal.PerMessageProfileForSender(ctx, sender)
if err != nil {
log.Err(err).Msg("Failed to make per-message profile")
}
// Assign incrementing part IDs.
for i, part := range parts {
part.ID = networkid.PartID(strconv.Itoa(i))
// Beeper clients support backfilling backwards (scrolling up to load
// more messages). Adding per-message profiles to every part helps them
// present the right message authorship information even when a
// membership event isn't present.
part.Content.BeeperPerMessageProfile = &pmp
}
converted := &bridgev2.ConvertedMessage{Parts: parts}
@@ -179,9 +194,9 @@ func (mc *MessageConverter) tryAddingReplyToConvertedMessage(
// The portal containing the message that was replied to.
targetPortal := portal
if ref.ChannelID != discordid.ParsePortalID(portal.ID) {
if ref.ChannelID != discordid.ParseChannelPortalID(portal.ID) {
var err error
targetPortal, err = mc.Bridge.GetPortalByKey(ctx, discordid.MakePortalKeyWithID(ref.ChannelID))
targetPortal, err = mc.Bridge.GetPortalByKey(ctx, discordid.MakeChannelPortalKeyWithID(ref.ChannelID))
if err != nil {
log.Err(err).Msg("Failed to get cross-room reply portal; proceeding")
return
@@ -301,7 +316,7 @@ func (mc *MessageConverter) forwardedMessageHTMLPart(ctx context.Context, portal
forwardedHTML := mc.renderDiscordMarkdownOnlyHTMLNoUnwrap(portal, msg.MessageSnapshots[0].Message.Content, true)
msgTSText := msg.MessageSnapshots[0].Message.Timestamp.Format("2006-01-02 15:04 MST")
origLink := fmt.Sprintf("unknown channel • %s", msgTSText)
if forwardedFromPortal, err := mc.Bridge.DB.Portal.GetByKey(ctx, discordid.MakePortalKeyWithID(msg.MessageReference.ChannelID)); err == nil && forwardedFromPortal != nil {
if forwardedFromPortal, err := mc.Bridge.DB.Portal.GetByKey(ctx, discordid.MakeChannelPortalKeyWithID(msg.MessageReference.ChannelID)); err == nil && forwardedFromPortal != nil {
if origMessage, err := mc.Bridge.DB.Message.GetFirstPartByID(ctx, source.ID, discordid.MakeMessageID(msg.MessageReference.MessageID)); err == nil && origMessage != nil {
// We've bridged the message that was forwarded, so we can link to it directly.
origLink = fmt.Sprintf(
@@ -404,7 +419,71 @@ func (mc *MessageConverter) renderDiscordVideoEmbed(ctx context.Context, embed *
}
func (mc *MessageConverter) renderDiscordSticker(ctx context.Context, sticker *discordgo.StickerItem) *bridgev2.ConvertedMessagePart {
panic("unimplemented")
var mime string
switch sticker.FormatType {
case discordgo.StickerFormatTypePNG:
mime = "image/png"
case discordgo.StickerFormatTypeAPNG:
mime = "image/apng"
case discordgo.StickerFormatTypeLottie:
mime = "application/json"
case discordgo.StickerFormatTypeGIF:
mime = "image/gif"
default:
zerolog.Ctx(ctx).Warn().
Int("sticker_format", int(sticker.FormatType)).
Str("sticker_id", sticker.ID).
Msg("Unknown sticker format")
}
// TODO(skip): Support direct media.
reupload, err := mc.ReuploadMedia(ctx, sticker.URL(), mime, "", -1, true)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to copy sticker to Matrix")
return &bridgev2.ConvertedMessagePart{
Type: event.EventMessage,
Content: mediaFailedMessage(err),
}
}
content := &event.MessageEventContent{
Body: sticker.Name, // TODO(skip): Find description from somewhere?
Info: &event.FileInfo{
MimeType: reupload.MimeType,
Size: reupload.Size,
},
}
content.URL, content.File = reupload.MXC, reupload.File
cleanupConvertedStickerInfo(content)
return &bridgev2.ConvertedMessagePart{
Type: event.EventSticker,
Content: content,
}
}
const DiscordStickerSize = 160
func cleanupConvertedStickerInfo(content *event.MessageEventContent) {
if content.Info == nil {
return
}
if content.Info.Width == 0 && content.Info.Height == 0 {
content.Info.Width = DiscordStickerSize
content.Info.Height = DiscordStickerSize
} else if content.Info.Width > DiscordStickerSize || content.Info.Height > DiscordStickerSize {
if content.Info.Width > content.Info.Height {
content.Info.Height /= content.Info.Width / DiscordStickerSize
content.Info.Width = DiscordStickerSize
} else if content.Info.Width < content.Info.Height {
content.Info.Width /= content.Info.Height / DiscordStickerSize
content.Info.Height = DiscordStickerSize
} else {
content.Info.Width = DiscordStickerSize
content.Info.Height = DiscordStickerSize
}
}
}
const (

View File

@@ -22,8 +22,6 @@ import (
"fmt"
"io"
"net/http"
"strconv"
"time"
"github.com/bwmarrin/discordgo"
"github.com/rs/zerolog"
@@ -35,14 +33,6 @@ import (
"go.mau.fi/mautrix-discord/pkg/discordid"
)
const discordEpochMillis = 1420070400000
func generateMessageNonce() string {
snowflake := (time.Now().UnixMilli() - discordEpochMillis) << 22
// Nonce snowflakes don't have internal IDs or increments
return strconv.FormatInt(snowflake, 10)
}
func parseAllowedLinkPreviews(raw map[string]any) []string {
if raw == nil {
return nil
@@ -102,23 +92,27 @@ func (mc *MessageConverter) ToDiscord(
ctx = context.WithValue(ctx, contextKeyPortal, msg.Portal)
ctx = context.WithValue(ctx, contextKeyDiscordClient, session)
var req discordgo.MessageSend
req.Nonce = generateMessageNonce()
if msg.InputTransactionID != "" {
req.Nonce = string(msg.InputTransactionID)
} else {
req.Nonce = discordid.GenerateNonce()
}
log := zerolog.Ctx(ctx)
if msg.ReplyTo != nil {
req.Reference = &discordgo.MessageReference{
ChannelID: discordid.ParsePortalID(msg.ReplyTo.Room.ID),
ChannelID: discordid.ParseChannelPortalID(msg.ReplyTo.Room.ID),
MessageID: discordid.ParseMessageID(msg.ReplyTo.ID),
}
}
portal := msg.Portal
guildID := msg.Portal.Metadata.(*discordid.PortalMetadata).GuildID
channelID := discordid.ParsePortalID(portal.ID)
channelID := discordid.ParseChannelPortalID(portal.ID)
content := msg.Content
convertMatrix := func() {
req.Content, req.AllowedMentions = mc.convertMatrixMessageContent(ctx, msg.Portal, content, parseAllowedLinkPreviews(msg.Event.Content.Raw))
req.Content, req.AllowedMentions = mc.ConvertMatrixMessageContent(ctx, msg.Portal, content, parseAllowedLinkPreviews(msg.Event.Content.Raw))
if content.MsgType == event.MsgEmote {
req.Content = fmt.Sprintf("_%s_", req.Content)
}
@@ -182,7 +176,7 @@ func (mc *MessageConverter) ToDiscord(
return &req, nil
}
func (mc *MessageConverter) convertMatrixMessageContent(ctx context.Context, portal *bridgev2.Portal, content *event.MessageEventContent, allowedLinkPreviews []string) (string, *discordgo.MessageAllowedMentions) {
func (mc *MessageConverter) ConvertMatrixMessageContent(ctx context.Context, portal *bridgev2.Portal, content *event.MessageEventContent, allowedLinkPreviews []string) (string, *discordgo.MessageAllowedMentions) {
allowedMentions := &discordgo.MessageAllowedMentions{
Parse: []discordgo.AllowedMentionType{},
Users: []string{},