// 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/gorilla/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] hasBegunSyncing 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 } 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, meta.Token) if err != nil { return err } } cl := DiscordClient{ connector: d, UserLogin: login, Session: session, 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 } 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) } 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, }) err := d.connect(ctx) if err != nil { log.Err(err).Msg("Couldn't connect to Discord") closeErr := &websocket.CloseError{} if errors.As(err, &closeErr) && closeErr.Code == 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") } } } // 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.guildPortalKey(guildID), }, OnlyForMe: true, Children: true, }) } // ensurePortal synchronously guarantees the existence of a portal's Matrix // room with up-to-date chat info. // // If info is nil, then the chat info is fetched from the NetworkAPI. 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 } 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") } 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 { // Only bridge text channels that are visible. if guildCh.Type != discordgo.ChannelTypeGuildText || !d.canSeeGuildChannel(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, }) } 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.IsLoggedIn() { 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) 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) 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 (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.TryUnmarshalingCaptcha(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) }