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.
This commit is contained in:
@@ -63,6 +63,7 @@ func (dc *DiscordClient) FetchMessages(ctx context.Context, fetchParams bridgev2
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
dc.userCache.HandleMessages(msgs)
|
||||||
|
|
||||||
converted := make([]*bridgev2.BackfillMessage, 0, len(msgs))
|
converted := make([]*bridgev2.BackfillMessage, 0, len(msgs))
|
||||||
for _, msg := range msgs {
|
for _, msg := range msgs {
|
||||||
|
|||||||
@@ -37,16 +37,17 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type DiscordClient struct {
|
type DiscordClient struct {
|
||||||
connector *DiscordConnector
|
connector *DiscordConnector
|
||||||
usersFromReady map[string]*discordgo.User
|
UserLogin *bridgev2.UserLogin
|
||||||
UserLogin *bridgev2.UserLogin
|
Session *discordgo.Session
|
||||||
Session *discordgo.Session
|
client *http.Client
|
||||||
client *http.Client
|
|
||||||
|
|
||||||
hasBegunSyncing bool
|
hasBegunSyncing bool
|
||||||
|
|
||||||
markedOpened map[string]time.Time
|
markedOpened map[string]time.Time
|
||||||
markedOpenedLock sync.Mutex
|
markedOpenedLock sync.Mutex
|
||||||
|
|
||||||
|
userCache *UserCache
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DiscordConnector) LoadUserLogin(ctx context.Context, login *bridgev2.UserLogin) error {
|
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,
|
UserLogin: login,
|
||||||
Session: session,
|
Session: session,
|
||||||
client: d.Bridge.GetHTTPClientSettings().Compile(),
|
client: d.Bridge.GetHTTPClientSettings().Compile(),
|
||||||
|
userCache: NewUserCache(session),
|
||||||
}
|
}
|
||||||
|
|
||||||
login.Client = &cl
|
login.Client = &cl
|
||||||
@@ -128,12 +130,9 @@ func (cl *DiscordClient) connect(ctx context.Context) error {
|
|||||||
user := cl.Session.State.User
|
user := cl.Session.State.User
|
||||||
log.Info().Str("user_id", user.ID).Str("user_username", user.Username).Msg("Connected to Discord")
|
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
|
// Populate the user cache with the users from the READY payload.
|
||||||
// keyed by user ID.
|
log.Debug().Int("n_users", len(cl.Session.State.Ready.Users)).Msg("Inserting users from READY into cache")
|
||||||
cl.usersFromReady = make(map[string]*discordgo.User)
|
cl.userCache.HandleReady(&cl.Session.State.Ready)
|
||||||
for _, user := range cl.Session.State.Ready.Users {
|
|
||||||
cl.usersFromReady[user.ID] = user
|
|
||||||
}
|
|
||||||
|
|
||||||
cl.BeginSyncing(ctx)
|
cl.BeginSyncing(ctx)
|
||||||
|
|
||||||
|
|||||||
@@ -189,6 +189,7 @@ func (d *DiscordClient) handleDiscordEvent(rawEvt any) {
|
|||||||
switch evt := rawEvt.(type) {
|
switch evt := rawEvt.(type) {
|
||||||
case *discordgo.Ready:
|
case *discordgo.Ready:
|
||||||
log.Info().Msg("Received READY dispatch from discordgo")
|
log.Info().Msg("Received READY dispatch from discordgo")
|
||||||
|
d.userCache.HandleReady(evt)
|
||||||
d.UserLogin.BridgeState.Send(status.BridgeState{
|
d.UserLogin.BridgeState.Send(status.BridgeState{
|
||||||
StateEvent: status.StateConnected,
|
StateEvent: status.StateConnected,
|
||||||
})
|
})
|
||||||
@@ -206,8 +207,11 @@ func (d *DiscordClient) handleDiscordEvent(rawEvt any) {
|
|||||||
Msg("Dropping message that lacks an author")
|
Msg("Dropping message that lacks an author")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
d.userCache.HandleMessage(evt.Message)
|
||||||
wrappedEvt := d.wrapDiscordMessage(evt)
|
wrappedEvt := d.wrapDiscordMessage(evt)
|
||||||
d.UserLogin.Bridge.QueueRemoteEvent(d.UserLogin, &wrappedEvt)
|
d.UserLogin.Bridge.QueueRemoteEvent(d.UserLogin, &wrappedEvt)
|
||||||
|
case *discordgo.UserUpdate:
|
||||||
|
d.userCache.HandleUserUpdate(evt)
|
||||||
case *discordgo.MessageReactionAdd:
|
case *discordgo.MessageReactionAdd:
|
||||||
wrappedEvt := d.wrapDiscordReaction(evt.MessageReaction, true)
|
wrappedEvt := d.wrapDiscordReaction(evt.MessageReaction, true)
|
||||||
d.UserLogin.Bridge.QueueRemoteEvent(d.UserLogin, &wrappedEvt)
|
d.UserLogin.Bridge.QueueRemoteEvent(d.UserLogin, &wrappedEvt)
|
||||||
|
|||||||
161
pkg/connector/usercache.go
Normal file
161
pkg/connector/usercache.go
Normal file
@@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -54,18 +54,19 @@ func (d *DiscordClient) GetUserInfo(ctx context.Context, ghost *bridgev2.Ghost)
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// FIXME(skip): This won't work for users in guilds.
|
discordUserID := discordid.ParseUserID(ghost.ID)
|
||||||
|
discordUser := d.userCache.Resolve(ctx, discordUserID)
|
||||||
user, ok := d.usersFromReady[discordid.ParseUserID(ghost.ID)]
|
if discordUser == nil {
|
||||||
if !ok {
|
log.Error().Str("discord_user_id", discordUserID).
|
||||||
log.Error().Str("ghost_id", discordid.ParseUserID(ghost.ID)).Msg("Couldn't find corresponding user from READY for ghost")
|
Msg("Failed to resolve user")
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return &bridgev2.UserInfo{
|
return &bridgev2.UserInfo{
|
||||||
Identifiers: []string{fmt.Sprintf("discord:%s", user.ID)},
|
// FIXME clear this for webhooks (stash in ghost metadata)
|
||||||
Name: ptr.Ptr(user.DisplayName()),
|
Identifiers: []string{fmt.Sprintf("discord:%s", discordUser.ID)},
|
||||||
Avatar: d.makeUserAvatar(user),
|
Name: ptr.Ptr(discordUser.DisplayName()),
|
||||||
IsBot: &user.Bot,
|
Avatar: d.makeUserAvatar(discordUser),
|
||||||
|
IsBot: &discordUser.Bot,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,23 @@ import (
|
|||||||
"maunium.net/go/mautrix/bridgev2/networkid"
|
"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
|
const DiscordEpochMillis = 1420070400000
|
||||||
|
|
||||||
// GenerateNonce creates a Discord-style snowflake nonce for message idempotency.
|
// GenerateNonce creates a Discord-style snowflake nonce for message idempotency.
|
||||||
|
|||||||
Reference in New Issue
Block a user