From d8ca44ecd94e37c1727255e2e0914fe06c5cb4d1 Mon Sep 17 00:00:00 2001 From: Skip R Date: Fri, 6 Feb 2026 13:24:26 -0800 Subject: [PATCH] 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. --- pkg/connector/backfill.go | 1 + pkg/connector/client.go | 21 ++--- pkg/connector/handlediscord.go | 4 + pkg/connector/usercache.go | 161 +++++++++++++++++++++++++++++++++ pkg/connector/userinfo.go | 19 ++-- pkg/discordid/id.go | 17 ++++ 6 files changed, 203 insertions(+), 20 deletions(-) create mode 100644 pkg/connector/usercache.go diff --git a/pkg/connector/backfill.go b/pkg/connector/backfill.go index b23de03..c921f1f 100644 --- a/pkg/connector/backfill.go +++ b/pkg/connector/backfill.go @@ -63,6 +63,7 @@ func (dc *DiscordClient) FetchMessages(ctx context.Context, fetchParams bridgev2 if err != nil { return nil, err } + dc.userCache.HandleMessages(msgs) converted := make([]*bridgev2.BackfillMessage, 0, len(msgs)) for _, msg := range msgs { diff --git a/pkg/connector/client.go b/pkg/connector/client.go index 718f171..4d2daac 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -37,16 +37,17 @@ import ( ) type DiscordClient struct { - connector *DiscordConnector - usersFromReady map[string]*discordgo.User - UserLogin *bridgev2.UserLogin - Session *discordgo.Session - client *http.Client + 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 { @@ -63,6 +64,7 @@ func (d *DiscordConnector) LoadUserLogin(ctx context.Context, login *bridgev2.Us UserLogin: login, Session: session, client: d.Bridge.GetHTTPClientSettings().Compile(), + userCache: NewUserCache(session), } login.Client = &cl @@ -128,12 +130,9 @@ 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.HandleReady(&cl.Session.State.Ready) cl.BeginSyncing(ctx) diff --git a/pkg/connector/handlediscord.go b/pkg/connector/handlediscord.go index 9516779..a4ba6bb 100644 --- a/pkg/connector/handlediscord.go +++ b/pkg/connector/handlediscord.go @@ -189,6 +189,7 @@ func (d *DiscordClient) handleDiscordEvent(rawEvt any) { switch evt := rawEvt.(type) { case *discordgo.Ready: log.Info().Msg("Received READY dispatch from discordgo") + d.userCache.HandleReady(evt) d.UserLogin.BridgeState.Send(status.BridgeState{ StateEvent: status.StateConnected, }) @@ -206,8 +207,11 @@ func (d *DiscordClient) handleDiscordEvent(rawEvt any) { Msg("Dropping message that lacks an author") return } + d.userCache.HandleMessage(evt.Message) wrappedEvt := d.wrapDiscordMessage(evt) d.UserLogin.Bridge.QueueRemoteEvent(d.UserLogin, &wrappedEvt) + case *discordgo.UserUpdate: + d.userCache.HandleUserUpdate(evt) case *discordgo.MessageReactionAdd: wrappedEvt := d.wrapDiscordReaction(evt.MessageReaction, true) d.UserLogin.Bridge.QueueRemoteEvent(d.UserLogin, &wrappedEvt) diff --git a/pkg/connector/usercache.go b/pkg/connector/usercache.go new file mode 100644 index 0000000..f83ff56 --- /dev/null +++ b/pkg/connector/usercache.go @@ -0,0 +1,161 @@ +// 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 . + +package connector + +import ( + "context" + "errors" + "net/http" + "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) HandleReady(ready *discordgo.Ready) { + if ready == nil { + return + } + + uc.lock.Lock() + defer uc.lock.Unlock() + + for _, user := range ready.Users { + uc.cache[user.ID] = user + } +} + +func (uc *UserCache) HandleMessage(msg *discordgo.Message) { + if msg == nil { + return + } + + // For now just forward to HandleMessages until a need for a specialized + // path makes itself known. + uc.HandleMessages([]*discordgo.Message{msg}) +} + +// HandleMessages updates the user cache with message authors from a slice of +// discordgo.Message. +func (uc *UserCache) HandleMessages(msgs []*discordgo.Message) { + if len(msgs) == 0 { + return + } + + 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 + } +} + +func (uc *UserCache) HandleUserUpdate(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 +} diff --git a/pkg/connector/userinfo.go b/pkg/connector/userinfo.go index 5a0bef6..5bb2954 100644 --- a/pkg/connector/userinfo.go +++ b/pkg/connector/userinfo.go @@ -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: d.makeUserAvatar(user), - IsBot: &user.Bot, + // FIXME clear this for webhooks (stash in ghost metadata) + Identifiers: []string{fmt.Sprintf("discord:%s", discordUser.ID)}, + Name: ptr.Ptr(discordUser.DisplayName()), + Avatar: d.makeUserAvatar(discordUser), + IsBot: &discordUser.Bot, }, nil } diff --git a/pkg/discordid/id.go b/pkg/discordid/id.go index 346604a..a1089dd 100644 --- a/pkg/discordid/id.go +++ b/pkg/discordid/id.go @@ -24,6 +24,23 @@ import ( "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.