// 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" "fmt" "io" "iter" "maps" "net/http" "regexp" "slices" "sync" "sync/atomic" "time" "github.com/bwmarrin/discordgo" "github.com/coder/websocket" "github.com/rs/zerolog" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/networkid" "maunium.net/go/mautrix/bridgev2/simplevent" "maunium.net/go/mautrix/bridgev2/status" "maunium.net/go/mautrix/event" "go.mau.fi/util/exmaps" "go.mau.fi/mautrix-discord/pkg/discordauth" "go.mau.fi/mautrix-discord/pkg/discordid" ) type DiscordClient struct { connector *DiscordConnector UserLogin *bridgev2.UserLogin Session *discordgo.Session httpClient *http.Client stopConnecting atomic.Pointer[context.CancelFunc] fullSyncDone atomic.Bool // inverted (i.e. not needsInitSync) so zero value is "correct" // seenReady is used to discern the initial READY payload from ones // received during reconnections (where resumption is not possible). seenReady atomic.Bool markedOpened map[string]time.Time markedOpenedLock sync.Mutex // A map of guild ID (or "" for the settings concerning private channels) // to its corresponding UserGuildSettings. guildSettings map[string]*discordgo.UserGuildSettings guildSettingsLock sync.RWMutex // A map of resource (e.g. channel) ID to its corresponding read state. // // Since there can be thousands of read state entries, the map is to help // keep lookups by channel ID speedy by avoiding constant linear searching. readStates map[string]*discordgo.ReadState readStatesLock sync.RWMutex relationshipLock sync.RWMutex relationships map[string]*discordgo.Relationship userCache *UserCache lastSendAttemptMutex sync.Mutex lastSendAttempt *SendAttempt vitalsMu sync.Mutex // guards vitals and safetyHub vitals *vitals safetyHub *discordgo.SafetyHub // last fetched safety hub information when permitted } func (d *DiscordConnector) LoadUserLogin(ctx context.Context, login *bridgev2.UserLogin) error { meta := login.Metadata.(*discordid.UserLoginMetadata) var session *discordgo.Session if meta.Token == "" { login.Log.Warn().Msg("Login has no token, not setting up a session") // Session on the UserLogin will be nil. } else { var err error session, err = NewDiscordSession(ctx, d.Bridge.GetHTTPClientSettings(), meta.Token) if err != nil { return err } } cl := DiscordClient{ connector: d, UserLogin: login, Session: session, // This HTTP client is quickly overridden by a proxied version (when // one is configured). httpClient: d.Bridge.GetHTTPClientSettings().Compile(), userCache: NewUserCache(session), guildSettings: make(map[string]*discordgo.UserGuildSettings), readStates: make(map[string]*discordgo.ReadState), relationships: make(map[string]*discordgo.Relationship), } login.Client = &cl if session != nil { session.RESTResponseHook = cl.tapDiscordRESTResponse session.BeforeReconnect = func(*discordgo.Session) { c := login.Client.(*DiscordClient) if c.connector.proxyConfigured() && !c.updateProxy(c.connector.Bridge.BackgroundCtx, "reconnect") { // Failed to update the proxy. Continue reconnecting via the // last good proxy, but report the failure. c.UserLogin.BridgeState.Send(status.BridgeState{ StateEvent: status.StateTransientDisconnect, Error: DCProxyResolveFail, }) } } } return nil } var _ bridgev2.NetworkAPI = (*DiscordClient)(nil) func (d *DiscordClient) userLoginMetadata() *discordid.UserLoginMetadata { return d.UserLogin.Metadata.(*discordid.UserLoginMetadata) } func (d *DiscordClient) Connect(ctx context.Context) { log := zerolog.Ctx(ctx) lacksToken := !d.HasToken() lacksSession := d.Session == nil if lacksToken || lacksSession { // (d.Session can be nil if we lacked credentials on startup.) log.Warn().Bool("lacking_token", lacksToken). Bool("lacking_session", lacksSession). Msg("Refusing to connect") d.UserLogin.BridgeState.Send(status.BridgeState{ StateEvent: status.StateBadCredentials, Error: DCNotLoggedIn, UserAction: status.UserActionRelogin, }) return } meta := d.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) d.connectRetrying(ctx, 0) } func vitalsErrorCode(v *vitals) (code status.BridgeStateErrorCode) { if v == nil { return } // TODO: Arrange the code assignments based on what actually takes priority // in Discord's UI. // TODO: This is somewhat isomorphic to RequiresUserIntervention, so maybe // we can just define a set of intervention codes (?) in discordgo? switch v.RequiredAction { case discordgo.RequireAgreements: code = DCRequireAgreements case discordgo.RequireVerifiedEmail: code = DCRequireVerifiedEmail case discordgo.RequireVerifiedPhone: code = DCRequireVerifiedPhone case discordgo.RequireReverifiedEmail: code = DCRequireReverifiedEmail case discordgo.RequireReverifiedPhone: code = DCRequireReverifiedPhone case discordgo.RequireVerifiedEmailOrVerifiedPhone: code = DCRequireVerifiedEmailOrVerifiedPhone case discordgo.RequireReverifiedEmailOrVerifiedPhone: code = DCRequireReverifiedEmailOrVerifiedPhone case discordgo.RequireVerifiedEmailOrReverifiedPhone: code = DCRequireVerifiedEmailOrReverifiedPhone case discordgo.RequireReverifiedEmailOrReverifiedPhone: code = DCRequireReverifiedEmailOrReverifiedPhone case discordgo.RequireSafetyFlows: code = DCRequireSafetyFlows } if v.HasUnreadSystemMessages { code = DCUnreadSystemMessages } return } func (d *DiscordClient) sendCurrentState(ctx context.Context) { log := zerolog.Ctx(ctx) vitals := d.peekVitals() info := make(map[string]any) if vi, err := vitals.infoMap(); err == nil { info["vitals"] = vi } else { log.Err(err).Msg("Failed to compute vitals info map, omitting from bridge state") } if vitals.RequiresUserIntervention() { d.UserLogin.BridgeState.Send(status.BridgeState{ StateEvent: status.StateBadCredentials, Error: vitalsErrorCode(vitals), UserAction: status.UserActionOpenNative, Info: info, }) return } d.UserLogin.BridgeState.Send(status.BridgeState{ StateEvent: status.StateConnected, Info: info, }) } const maxGatewayConnectRetries = 5 // tokenInvalidated responds to Discord invalidating our token. func (d *DiscordClient) tokenInvalidated(ctx context.Context, circumstance string) { log := zerolog.Ctx(ctx) log.Info().Msg("Invalidating user login") d.UserLogin.BridgeState.Send(status.BridgeState{ StateEvent: status.StateBadCredentials, Error: DCWebsocketDisconnect4004, UserAction: status.UserActionRelogin, }) props := d.baseAnalyticsProps(ctx) props["circumstance"] = circumstance d.UserLogin.TrackAnalytics("Discord auth invalidation", props) // Empty out the token. log.Debug().Msg("Emptying token") meta := d.UserLogin.Metadata.(*discordid.UserLoginMetadata) meta.Token = "" if err := d.UserLogin.Save(ctx); err != nil { log.Err(err).Msg("Failed to save user login in order to invalidate session") } } func (d *DiscordClient) connectRetrying(ctx context.Context, retryCount int) { retryCtx, cancel := context.WithCancel(ctx) oldStop := d.stopConnecting.Swap(&cancel) if oldStop != nil { (*oldStop)() } log := zerolog.Ctx(ctx).With().Int("retry_count", retryCount).Logger() log.Debug().Msg("Connecting to Discord") d.UserLogin.BridgeState.Send(status.BridgeState{ StateEvent: status.StateConnecting, }) if d.connector.proxyConfigured() && !d.updateProxy(ctx, "connect") { d.UserLogin.BridgeState.Send(status.BridgeState{ StateEvent: status.StateUnknownError, Error: DCProxyResolveFail, }) return } err := d.connect(ctx) if err != nil { log.Err(err).Msg("Couldn't connect to Discord") if websocket.CloseStatus(err) == 4004 { // Effectively the same as *discordgo.InvalidAuth, but at connect // time. (discordgo only dispatches the synthetic InvalidAuth event // once you've already connected successfully.) // // Don't retry. d.tokenInvalidated(ctx, "when connecting") } else if retryCount <= maxGatewayConnectRetries { d.UserLogin.BridgeState.Send(status.BridgeState{ StateEvent: status.StateTransientDisconnect, Error: DCUnknownWebsocketError, Message: err.Error(), }) sleepDuration := time.Second * time.Duration(2< 0 log.Trace(). Int64("permissions", perms). Bool("channel_visible", canView). Msg("Computed visibility of guild channel") return canView } func (d *DiscordClient) makeAvatarForGuild(guild *discordgo.Guild) *bridgev2.Avatar { return &bridgev2.Avatar{ ID: discordid.MakeAvatarID(guild.Icon), Get: func(ctx context.Context) ([]byte, error) { url := discordgo.EndpointGuildIcon(guild.ID, guild.Icon) return httpGet(ctx, d.httpClient, url, "guild icon") }, Remove: guild.Icon == "", } } // 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{}{} // guilds that were bridged via the provisioning api for guildID, bridged := range meta.BridgedGuildIDs { if bridged { bridgingGuildIDs[guildID] = struct{}{} } } // 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 := slices.Sorted(maps.Keys(d.bridgedGuildIDs())) for _, guildID := range guildIDs { log := zerolog.Ctx(ctx).With(). Str("guild_id", guildID). Str("action", "sync guild"). Logger() err := d.syncGuild(log.WithContext(ctx), guildID) if err != nil { log.Err(err).Msg("Couldn't bridge guild during sync") } } } // ensurePortal _synchronously_ guarantees the existence of a portal's Matrix // room with up-to-date chat info. // // This is especially useful in situations where the ordering of room creation // is important. // // If info is nil, then the chat info is fetched from the NetworkAPI. // // If the portal already has a room, then we merely ensure that the portal's // info is up-to-date. func (d *DiscordClient) ensurePortal(ctx context.Context, key networkid.PortalKey, info *bridgev2.ChatInfo) error { portal, err := d.connector.Bridge.GetPortalByKey(ctx, key) if err != nil { return fmt.Errorf("failed to get portal: %w", err) } if info == nil { info, err = d.GetChatInfo(ctx, portal) if err != nil { return fmt.Errorf("failed to get chat info: %w", err) } } if portal.MXID == "" { // CreateMatrixRoom will indirectly lead to UpdateInfo being called. if err := portal.CreateMatrixRoom(ctx, d.UserLogin, info); err != nil { return fmt.Errorf("failed to create matrix room: %w", err) } } else { portal.UpdateInfo(ctx, info, d.UserLogin, nil, time.Time{}) } return nil } // queueGuildDeletion should be called to evict a guild from the bridge, e.g. // whenever it is determined that the user has left a Discord guild that is // currently bridged. // // The following occurs: // // - An attempt is made to delete all of the roles associated with the given // guild. This will proceed even upon error. // - A Matrix event is queued to delete the guild space and all of its contained // rooms. func (d *DiscordClient) queueGuildDeletion( ctx context.Context, guildID string, ) { log := zerolog.Ctx(ctx).With(). Str("guild_id", guildID). Str("action", "queue guild deletion"). Logger() // TODO: This is deleting roles globally. Other logins might still be in // the guild. if err := d.connector.DB.Role.DeleteByGuildID(ctx, guildID); err != nil { // Best effort. log.Err(err).Msg("Failed to delete guild roles from database, proceeding to delete guild space anyways") } log.Info().Msg("Queueing event to recursively delete the guild space") d.connector.Bridge.QueueRemoteEvent(d.UserLogin, &simplevent.ChatDelete{ EventMeta: simplevent.EventMeta{ Type: bridgev2.RemoteEventChatDelete, PortalKey: d.guildPortalKey(guildID), }, OnlyForMe: true, Children: true, }) } // reconcileGuildSpaces examines all existing guild spaces and deletes those // that do not appear in the provided set of guild IDs. func (d *DiscordClient) reconcileGuildSpaces( ctx context.Context, guildIDs exmaps.Set[string], ) { log := zerolog.Ctx(ctx).With(). Str("action", "reconcile guilds"). Logger() ctx = log.WithContext(ctx) for portal := range d.existingPortals(ctx) { guildID := discordid.ParseGuildPortalID(portal.ID) if guildID == "" { // Portal isn't a guild space. continue } if guildIDs.Has(guildID) { // Still a member of the guild. continue } log := log.With(). Str("guild_id", guildID). Logger() ctx := log.WithContext(ctx) log.Info().Msg("Guild no longer appears in READY payload (user has left), queueing deletion") d.queueGuildDeletion(ctx, guildID) } } // shouldBridgeChannel reports whether a channel should be bridged. This // considers information such as the type of the channel, the user's effective // permissions within the guild, which guilds are bridged, etc. func (d *DiscordClient) shouldBridgeChannel( ctx context.Context, ch *discordgo.Channel, ) bool { if ch == nil { return false } // TODO(skip): This method is relatively hot, consider maintaining the set // of bridged guild IDs in memory? bridgedGuildIDs := d.bridgedGuildIDs() if ch.GuildID != "" { if _, ok := bridgedGuildIDs[ch.GuildID]; !ok { // Only bridge guild channels that are part of bridged guilds. return false } } // Only ever bridge guild text channels. // TODO(skip): Consider bridging voice channels (make sure to check for the // right permission bits)? if ch.Type != discordgo.ChannelTypeGuildText && ch.Type != discordgo.ChannelTypeGuildNews { return false } if !d.canSeeGuildChannel(ctx, ch) { return false } return 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 { // This isn't problematic per se, because we can get here if a guild is // unavailable due to an outage; when that happens, the guild is // removed from the state entirely (via GUILD_DELETE). log.Warn().Err(err). Msg("Cannot sync guild that is not present in state") return errors.New("couldn't find guild in state") } if err = d.syncGuildRoles(ctx, guildID, guild.Roles); err != nil { return fmt.Errorf("failed to sync guild roles during guild sync: %w", err) } // Synchronously guarantee the proper creation of the guild space portal so // child rooms are born with the correct `m.bridge` state. portalKey := d.guildPortalKey(guild.ID) if err := d.ensurePortal(ctx, portalKey, nil); err != nil { return fmt.Errorf("failed to ensure guild space portal: %w", err) } visibleCategoryIDs := make(exmaps.Set[string]) visibleChannels := make([]*discordgo.Channel, 0, len(guild.Channels)) for _, guildCh := range guild.Channels { if !d.shouldBridgeChannel(ctx, guildCh) { continue } visibleChannels = append(visibleChannels, guildCh) if guildCh.ParentID != "" { visibleCategoryIDs.Add(guildCh.ParentID) } } // Synchronously guarantee the proper creation of category space portals // for the same reason that we do so for guild space portals. // // Note that we only care about syncing categories that contain at least // one channel we can actually see. This matches the behavior of Discord's // first-party clients. The permission bits on the category channel // _itself_ are irrelevant. for categoryID := range visibleCategoryIDs.Iter() { category := d.channelWithID(ctx, categoryID) if category == nil { log.Error().Str("channel_id", categoryID).Msg("Failed to find category channel somehow, proceeding") continue } err := d.ensurePortal(ctx, d.portalKeyForChannel(category), nil) if err != nil { log.Err(err).Msg("Failed to ensure category space, proceeding") // FIXME The children of this category channel will still be synced // but with bogus `m.bridge` state. } } // Now that all possible parent spaces exist, we can fan out the syncing of // all guild channels we can see. for _, visibleCh := range visibleChannels { d.queueChannelResync(ctx, visibleCh) } for _, thread := range guild.Threads { err = d.upsertThreadInfoFromChannel(ctx, thread) if err != nil { log.Err(err).Str("thread_id", thread.ID).Msg("Failed to cache thread info during guild sync") } } 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") } } func httpGet(ctx context.Context, httpClient *http.Client, 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 := httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to download %s: %w", thing, err) } defer resp.Body.Close() if resp.StatusCode > 300 { return nil, fmt.Errorf("failed to download %s: got HTTP %d", thing, resp.StatusCode) } data, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read %s data: %w", thing, err) } return data, nil } func (d *DiscordClient) makeEventSenderWithID(userID string) bridgev2.EventSender { return bridgev2.EventSender{ IsFromMe: userID == d.Session.State.User.ID, SenderLogin: discordid.MakeUserLoginID(userID), Sender: discordid.MakeUserID(userID), } } func (d *DiscordClient) selfEventSender() bridgev2.EventSender { return d.makeEventSenderWithID(d.Session.State.User.ID) } 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) } func (d *DiscordClient) queueChannelResync(_ context.Context, ch *discordgo.Channel) { d.connector.Bridge.QueueRemoteEvent(d.UserLogin, &DiscordChatResync{ Client: d, channel: ch, createPortal: true, }) } func (d *DiscordClient) queueExistingChannelResync(_ context.Context, ch *discordgo.Channel) { d.connector.Bridge.QueueRemoteEvent(d.UserLogin, &DiscordChatResync{ Client: d, channel: ch, createPortal: false, }) } func (d *DiscordClient) readStateForID(resourceID string) *discordgo.ReadState { d.readStatesLock.RLock() defer d.readStatesLock.RUnlock() return d.readStates[resourceID] } func (d *DiscordClient) computeMutedUntil(muted bool, cfg *discordgo.MuteConfig) time.Time { if !muted { return bridgev2.Unmuted } // If Muted is true but we don't have a MuteConfig, then the mute is // indefinite. if cfg == nil { return event.MutedForever } // Check for the explicit "forever" time window. if cfg.SelectedTimeWindow != nil && *cfg.SelectedTimeWindow == -1 { return event.MutedForever } endTime := cfg.EndTime if endTime == nil { d.UserLogin.Log.Warn(). Bool("muted", muted). Any("mute_config", cfg). Msg("Encountered bogus mute state, falling back to indefinite mute") return event.MutedForever } return *endTime } // channelMutedUntil computes an appropriate UserLocalPortalInfo.MutedUntil time // for a given channel. // // This method works with private channels if an empty string is passed as the // guild ID. func (d *DiscordClient) channelMutedUntil(guildID string, channelID string) time.Time { settings := d.guildSettingsForGuildID(guildID) if settings == nil { return bridgev2.Unmuted } // TODO: Might be worth speeding this up via map. for _, override := range settings.ChannelOverrides { if override.ChannelID == channelID { return d.computeMutedUntil(override.Muted, override.MuteConfig) } } return d.computeMutedUntil(settings.Muted, settings.MuteConfig) } func (d *DiscordClient) guildSettingsForGuildID(guildID string) *discordgo.UserGuildSettings { d.guildSettingsLock.RLock() defer d.guildSettingsLock.RUnlock() return d.guildSettings[guildID] } func (d *DiscordClient) channelWithID(ctx context.Context, channelID string) *discordgo.Channel { if d.Session == nil { return nil } ch, err := d.Session.State.Channel(channelID) if err != nil { if errors.Is(err, discordgo.ErrStateNotFound) { return nil } // Some other weird error happened. This is currently impossible but it's // best to not rely on implementation details. zerolog.Ctx(ctx).Err(err). Str("channel_id", channelID). Msg("Failed to look up channel") return nil } return ch } func (d *DiscordClient) syncRemoteProfile(ctx context.Context) bool { if !d.IsLoggedIn() { return false } log := zerolog.Ctx(ctx).With(). Str("action", "sync remote discord profile"). Logger() ctx = log.WithContext(ctx) me := d.Session.State.User if me == nil { return false } log.Debug().Msg("Updating remote profile if needed") changed := false remoteName := makeRemoteName(me) // Try to update our own ghost, which should upload the avatar if // everything goes well. ghost, err := d.connector.Bridge.GetGhostByID(ctx, discordid.MakeUserID(me.ID)) if err != nil { log.Err(err).Msg("Failed to get own ghost, remote profile will lack an avatar") } else if info, err := d.GetUserInfo(ctx, ghost); err != nil { // Shouldn't happen as the user cache shouldn't even reach out to the // network; our own user should be there by now. log.Err(err).Msg("Failed to get own user info") } else { log.Debug().Msg("Updating own ghost with user info") ghost.UpdateInfo(ctx, info) } profile := makeRemoteProfile(me, ghost) if d.UserLogin.RemoteName != remoteName { d.UserLogin.RemoteName = remoteName changed = true } if d.UserLogin.RemoteProfile != profile { d.UserLogin.RemoteProfile = profile changed = true } if changed { if err := d.UserLogin.Save(ctx); err != nil { log.Err(err).Msg("Failed to save UserLogin while updating remote profile") } } return changed // NOTE: For clients to immediately get the new remote profile, you need to // send a bridge state. } func (d *DiscordClient) resyncGhostsFromReady(ctx context.Context, ready *discordgo.Ready) { log := zerolog.Ctx(ctx).With(). Str("action", "resync ghosts from ready"). Logger() ctx = log.WithContext(ctx) scanned := 0 resynced := 0 for _, user := range ready.Users { if ctx.Err() != nil { return } scanned++ // TODO: For now, do not actively materialize ghosts by calling e.g. // GetGhostByID. Before we consider switching to that method, verify // the breadth of the users returned in READY by inspecting a payload. ghost, err := d.connector.Bridge.GetExistingGhostByID(ctx, discordid.MakeUserID(user.ID)) if err != nil { log.Err(err).Str("user_id", user.ID). Msg("Failed to look up existing ghost while resyncing from READY") continue } if ghost == nil { // We've never bridged this user, so don't materialize a ghost. continue } ghost.UpdateInfo(ctx, d.getUserInfo(ctx, user)) resynced++ } log.Debug(). Int("n_ghosts_scanned", scanned). Int("n_ghosts_resynced", resynced). Msg("Finished resyncing ghosts from READY") } func (d *DiscordClient) wrapReceived40002(ctx context.Context, err error) error { log := zerolog.Ctx(ctx) log.Err(err).Msg("Received 40002 from Discord") props := d.baseAnalyticsProps(ctx) props["errorMessage"] = err.Error() d.UserLogin.TrackAnalytics("Discord account verification required", props) // TODO: Make this bridge state actually sticky/latching. This needs to // stop the backfill loops. d.UserLogin.BridgeState.Send(status.BridgeState{ StateEvent: status.StateBadCredentials, UserAction: status.UserActionOpenNative, Error: DCHTTP40002, }) return bridgev2.WrapErrorInStatus(err). // Tell clients to not retry. WithStatus(event.MessageStatusFail). WithIsCertain(true). WithMessage(accountVerificationRequiredMessage). WithSendNotice(true) } func (d *DiscordClient) tryWrappingError(ctx context.Context, err error) error { if err == nil { return nil } var restErr *discordgo.RESTError if errors.As(err, &restErr) && restErr.Message != nil { if restErr.Message.Code == discordgo.ErrCodeActionRequiredVerifiedAccount { return d.wrapReceived40002(ctx, err) } } return err } var snowflakeish = regexp.MustCompile(`\d{17,}`) func redactDiscordRESTPath(path string) string { return snowflakeish.ReplaceAllLiteralString(path, "...") } func dmChannelRecipientID(ch *discordgo.Channel) *string { if ch == nil { return nil } if ch.Type != discordgo.ChannelTypeDM { return nil } if len(ch.Recipients) != 1 { return nil } return &ch.Recipients[0].ID } func channelIsOrWasMessageRequest(ch *discordgo.Channel) bool { if ch == nil { return false } return ch.IsMessageRequest || ch.IsMessageRequestTimestamp != nil } func (d *DiscordClient) baseAnalyticsProps(ctx context.Context) map[string]any { props := make(map[string]any) if ctx == nil { return props } ch, ok := ctx.Value(contextKeyChannel).(*discordgo.Channel) if ok && ch != nil { risky := false props["channelType"] = readableChannelType(ch.Type) if recipientID := dmChannelRecipientID(ch); recipientID != nil { relationshipDesc := "none" if rel := d.relationshipWithUserID(*recipientID); rel != nil { relationshipDesc = readableRelationshipType(rel.Type) } else if ch.Type == discordgo.ChannelTypeDM { // No relationship with the recipient and it's a 1:1 DM. risky = true } props["relationshipWithRecipient"] = relationshipDesc props["risky"] = risky } } d.lastSendAttemptMutex.Lock() if attempt := d.lastSendAttempt; attempt != nil { props["lastInMemorySendAttemptAgeMs"] = time.Since(attempt.At).Milliseconds() props["lastInMemorySendAttemptChannelType"] = readableChannelType(attempt.ChannelType) if relType := attempt.RecipientRelationshipType; relType != nil { props["lastInMemorySendAttemptRecipientRelationshipType"] = readableRelationshipType(*relType) } } d.lastSendAttemptMutex.Unlock() return props } func (d *DiscordClient) tapDiscordRESTResponse(req *http.Request, resp *http.Response, body []byte) { // NOTE: discordgo calls this in a blocking fashion after reading the HTTP // response from Discord, so don't block here. ctx := context.Background() if d.Session != nil && !d.Session.IsUser { return } captcha := discordauth.CheckCaptcha(ctx, resp, body) if captcha == nil { return } redactedEndpoint := redactDiscordRESTPath(req.URL.Path) props := d.baseAnalyticsProps(req.Context()) maps.Copy(props, map[string]any{ "apiEndpoint": redactedEndpoint, "httpMethod": req.Method, "captchaService": string(captcha.Service), "captchaInvisible": captcha.Invisible, "captchaUserFlow": captcha.UserFlow, }) // (This fires a goroutine under the hood so it's alright to call this from // here.) d.UserLogin.TrackAnalytics("Discord CAPTCHA challenge", props) } func (d *DiscordClient) relationshipWithUserID(userID string) *discordgo.Relationship { if d.Session == nil || d.Session.State == nil { return nil } d.relationshipLock.RLock() defer d.relationshipLock.RUnlock() return d.relationships[userID] } func (d *DiscordClient) relationshipWithDMRecipient(ch *discordgo.Channel) *discordgo.Relationship { if ch == nil { return nil } recip := dmChannelRecipientID(ch) if recip == nil { return nil } rel := d.relationshipWithUserID(*recip) return rel } // dmChannelForUserID finds the DM channel with the given user, if any. func (d *DiscordClient) dmChannelForUserID(userID string) *discordgo.Channel { if d.Session == nil || d.Session.State == nil { return nil } d.Session.State.RLock() defer d.Session.State.RUnlock() for _, ch := range d.Session.State.PrivateChannels { if len(ch.Recipients) == 1 && ch.Recipients[0].ID == userID { return ch } } return nil } func (d *DiscordClient) rebuildRelationships() { if d.Session == nil || d.Session.State == nil { return } d.relationshipLock.Lock() defer d.relationshipLock.Unlock() clear(d.relationships) for _, rel := range d.Session.State.Relationships { if rel == nil { continue } d.relationships[rel.ID] = rel } } func (d *DiscordClient) upsertRelationship(rel *discordgo.Relationship) { if rel == nil { return } d.relationshipLock.Lock() defer d.relationshipLock.Unlock() d.relationships[rel.ID] = rel } func (d *DiscordClient) removeRelationship(userID string) { d.relationshipLock.Lock() defer d.relationshipLock.Unlock() delete(d.relationships, userID) }