Watch
1
0
Fork
You've already forked mautrix-discord
0
mirror of https://github.com/mautrix/discord.git synced 2026-08-23 20:34:56 -04:00
mautrix-discord/pkg/connector/client.go

1406 lines
41 KiB
Go
Raw Permalink Normal View History

2024-08-15 16:43:13 +03:00
// mautrix-discord - A Matrix-Discord puppeting bridge.
2026-01-08 16:55:34 -08:00
// Copyright (C) 2026 Tulir Asokan
2024-08-15 16:43:13 +03:00
//
// 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"
"fmt"
2025-11-24 13:03:40 -08:00
"io"
"iter"
"maps"
2025-11-24 13:03:40 -08:00
"net/http"
"regexp"
"slices"
"sync"
"sync/atomic"
"time"
2024-08-15 16:43:13 +03:00
"github.com/bwmarrin/discordgo"
"github.com/coder/websocket"
"github.com/rs/zerolog"
2024-08-15 16:43:13 +03:00
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/networkid"
2026-02-11 17:37:40 -08:00
"maunium.net/go/mautrix/bridgev2/simplevent"
"maunium.net/go/mautrix/bridgev2/status"
"maunium.net/go/mautrix/event"
2025-12-11 19:55:00 -08:00
"go.mau.fi/util/exmaps"
"go.mau.fi/mautrix-discord/pkg/discordauth"
2025-12-11 19:55:00 -08:00
"go.mau.fi/mautrix-discord/pkg/discordid"
2024-08-15 16:43:13 +03:00
)
type DiscordClient struct {
2026-02-16 21:00:15 -08:00
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
2026-03-03 20:53:29 -08:00
// 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
2024-08-15 16:43:13 +03:00
}
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
}
}
2025-12-09 17:43:48 -08:00
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),
}
2025-12-09 17:43:48 -08:00
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
2024-08-15 16:43:13 +03:00
}
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<<retryCount)
log.Debug().Dur("retry_sleeping_seconds", sleepDuration).
Msg("Sleeping and retrying gateway connection")
select {
case <-time.After(sleepDuration):
case <-retryCtx.Done():
log.Debug().Msg("Was told to stop connecting")
return
}
d.connectRetrying(ctx, retryCount+1)
} else {
d.UserLogin.BridgeState.Send(status.BridgeState{
StateEvent: status.StateUnknownError,
Error: DCUnknownWebsocketError,
Message: err.Error(),
})
log.Error().Msg("Exhausted connect retries")
}
}
}
func (d *DiscordClient) handleDiscordEventSync(event any) {
// Dispatch event handlers that maintain important state synchronously, or
// else we might end up inadvertently relying on goroutine scheduling.
d.handleDiscordStateEvent(event)
go d.handleDiscordEvent(event)
2025-12-09 17:43:48 -08:00
}
func (d *DiscordClient) connect(ctx context.Context) error {
log := zerolog.Ctx(ctx)
log.Info().Msg("Opening session")
d.Session.EventHandler = d.handleDiscordEventSync
2025-12-09 17:43:48 -08:00
// Open() returns once the first frame after IDENTIFY has been processed.
// In the common case that's READY (or RESUMED), which is dispatched to our
// synchronous handler before Open() returns, so State is populated by the
// time we get here. But it can also return on a non-dispatch control frame
// (e.g. an Op1 heartbeat request arriving before READY), in which case
// State.User is still nil and READY will land shortly via the listen
// goroutine.
err := d.Session.Open()
if err != nil {
log.Err(err).Msg("Failed to connect to Discord")
return err
}
return nil
}
func (d *DiscordClient) applyReadyPayload(
ctx context.Context,
ready *discordgo.Ready,
) {
log := zerolog.Ctx(ctx)
d.rebuildRelationships()
log.Debug().Int("n_users", len(ready.Users)).
Msg("Inserting users from READY into cache")
// NOTE: This can potentially block for a while if the user cache is
// concurrently resolving a user and internally performing an HTTP request
// (the lock is held across it).
d.userCache.UpdateWithReady(ready)
2026-03-03 20:53:29 -08:00
readState := ready.ReadState
2026-03-03 20:53:29 -08:00
if readState != nil {
log.Debug().Int("n_read_states", len(readState.Entries)).
Msg("Applying read states from READY")
d.readStatesLock.Lock()
if !readState.Partial {
clear(d.readStates)
}
2026-03-03 20:53:29 -08:00
for _, state := range readState.Entries {
d.readStates[state.ID] = state
2026-03-03 20:53:29 -08:00
}
d.readStatesLock.Unlock()
2026-03-03 20:53:29 -08:00
}
settings := ready.UserGuildSettings
if settings != nil {
log.Debug().Int("n_guild_settings", len(settings.Entries)).
Msg("Applying guild settings from READY")
d.bulkApplyGuildSettings(settings)
}
2024-08-15 16:43:13 +03:00
}
func (d *DiscordClient) bulkApplyGuildSettings(sl *discordgo.UserGuildSettingsList) {
d.guildSettingsLock.Lock()
defer d.guildSettingsLock.Unlock()
d.UserLogin.Log.Warn().
Int("n_settings_entries", len(sl.Entries)).
Int("settings_version", sl.Version).
Bool("settings_is_partial", sl.Partial).
Msg("Bulk applying partial guild settings")
if !sl.Partial {
clear(d.guildSettings)
}
for _, setting := range sl.Entries {
d.guildSettings[setting.GuildID] = setting
}
}
func (d *DiscordClient) applySingleGuildSettings(s *discordgo.UserGuildSettings) {
d.guildSettingsLock.Lock()
defer d.guildSettingsLock.Unlock()
d.guildSettings[s.GuildID] = s
}
2024-08-15 16:43:13 +03:00
func (d *DiscordClient) Disconnect() {
if stopConnecting := d.stopConnecting.Swap(nil); stopConnecting != nil {
(*stopConnecting)()
}
d.UserLogin.Log.Info().Msg("Disconnecting session")
if d.Session != nil {
d.Session.Close()
}
2024-08-15 16:43:13 +03:00
}
func (d *DiscordClient) HasToken() bool {
meta := d.userLoginMetadata()
return meta != nil && meta.Token != ""
}
2024-08-15 16:43:13 +03:00
func (d *DiscordClient) IsLoggedIn() bool {
if !d.HasToken() {
// If the token was emptied, immediately treat that as if we were
// logged out, even if we still hold a connection to Discord. This is
// less risky than nilling out Session entirely.
return false
}
if vitals := d.peekVitals(); vitals.RequiresUserIntervention() {
// When user intervention is required, block nearly everything the
// bridge can do (outgoing messages, edits, reactions, etc.) This will
// also tell mautrix to pause backfill queues. It's OK to receive
// incoming events though, and is actually needed to get notified that
// vitals are restabilized.
return false
}
return d.Session != nil &&
d.Session.State != nil &&
d.Session.State.User != nil &&
d.Session.State.User.ID != ""
2024-08-15 16:43:13 +03:00
}
func (d *DiscordClient) LogoutRemote(ctx context.Context) {
// FIXME(skip): Implement.
d.Disconnect()
2024-08-15 16:43:13 +03:00
}
// beginFullSync kicks off a full account sync (remote profile, private
// channels, and bridged guilds) in the background, if one is pending and the
// account's vitals permit it.
//
// Calling it when a full sync isn't needed is cheap and safe.
func (d *DiscordClient) beginFullSync(ctx context.Context) {
log := zerolog.Ctx(ctx).With().Str("action", "full sync").Logger()
ctx = log.WithContext(ctx)
if d.fullSyncDone.Load() {
return
}
if d.peekVitals().RequiresUserIntervention() {
log.Warn().Msg("Refusing to begin full sync as user intervention is required")
return
}
if !d.fullSyncDone.CompareAndSwap(false, true) {
return
}
go d.syncRemoteProfile(ctx) // don't block the caller
d.beginResyncingChatsAndSpaces(ctx)
}
// beginResyncingChatsAndSpaces reconciles guild spaces against the latest gateway state
// and pokes background resync and backfill for all private channels and
// bridged guilds.
//
// This should be called on every (re)connection where we may have missed
// gateway events.
func (d *DiscordClient) beginResyncingChatsAndSpaces(ctx context.Context) {
// Build the set of IDs for all guilds the user is definitively a member
// of. This includes guilds that are currently unavailable. Also note that
// we pointedly don't do this in the goroutines we're about to spawn, in
// order to avoid races.
//
// The READY payload is the only authoritative source on which guilds the
// user is a member of, as guilds are evicted from discordgo's in-memory
// state when they are "deleted", even if only due to unavailability (i.e.
// a Discord service outage).
//
// In other words, a guild's absence from discordgo State is not enough to
// determine if the user is a member of that guild or not.
guildIDs := make(exmaps.Set[string])
d.Session.State.RLock()
for _, guild := range d.Session.State.Guilds {
guildIDs.Add(guild.ID)
}
d.Session.State.RUnlock()
go d.reconcileGuildSpaces(ctx, guildIDs)
go d.syncPrivateChannels(ctx)
go d.syncGuilds(ctx)
}
func (d *DiscordClient) existingPortals(ctx context.Context) iter.Seq[*bridgev2.Portal] {
log := zerolog.Ctx(ctx)
ups, err := d.connector.Bridge.DB.UserPortal.GetAllForLogin(ctx, d.UserLogin.UserLogin)
if err != nil {
log.Err(err).Msg("Failed to fetch all user portals, returning empty iterator")
// Return a dummy iterator that is empty.
return func(yield func(*bridgev2.Portal) bool) {}
}
return func(yield func(*bridgev2.Portal) bool) {
seen := make(exmaps.Set[networkid.PortalKey])
for _, up := range ups {
portal, err := d.connector.Bridge.GetExistingPortalByKey(ctx, up.Portal)
if err != nil {
log.Err(err).Msg("Failed to fetch portal corresponding to user portal, proceeding")
continue
}
if portal == nil {
// ?
continue
}
// Depending on how split portals are configured,
// GetExistingPortalByKey can target the same portal from distinct
// user portal rows.
if seen.Has(portal.PortalKey) {
continue
}
seen.Add(portal.PortalKey)
if !yield(portal) {
return
}
}
}
}
func (d *DiscordClient) syncPrivateChannels(ctx context.Context) {
log := zerolog.Ctx(ctx)
resyncedExistingPrivateChannels := make(exmaps.Set[string])
// Queue resyncs for all private channel portals that are already bridged,
// while queueing deletion for those that are not found in discordgo state.
for portal := range d.existingPortals(ctx) {
if !portalIsPrivate(portal) {
continue
}
channelID := discordid.ParseChannelPortalID(portal.ID)
// We could check State.PrivateChannels directly, but that would be
// a linear search.
channel := d.channelWithID(ctx, channelID)
if channel == nil {
log.Info().
Str("deleting_channel_id", channelID).
Str("deleting_portal_room_type", string(portal.RoomType)).
Stringer("deleting_portal_key", portal.PortalKey).
Msg("Deleting portal corresponding to a private channel that isn't in state")
d.queueChatDelete(portal.PortalKey, "")
continue
}
if portal.MXID != "" {
log.Debug().
Str("channel_id", channelID).
Stringer("portal_key", portal.PortalKey).
Msg("Resyncing existing private channel portal")
resyncedExistingPrivateChannels.Add(channelID)
d.queueExistingChannelResync(ctx, channel)
}
}
d.Session.State.RLock()
dms := slices.Clone(d.Session.State.PrivateChannels)
d.Session.State.RUnlock()
// Only queue portal-creating resyncs for the top n private channels with
// recent activity (that haven't already been synced above).
slices.SortFunc(dms, func(a, b *discordgo.Channel) int {
ats, _ := discordgo.SnowflakeTimestamp(a.LastMessageID)
bts, _ := discordgo.SnowflakeTimestamp(b.LastMessageID)
return bts.Compare(ats)
})
// TODO(skip): This is startup_private_channel_create_limit. Support this
// in the config.
maxDms := min(10, len(dms))
recentDmsSynced := 0
for _, dm := range dms[:maxDms] {
if resyncedExistingPrivateChannels.Has(dm.ID) {
continue
}
log.Debug().Str("channel_id", dm.ID).Msg("Syncing private channel with recent activity")
d.queueChannelResync(ctx, dm)
recentDmsSynced++
}
2026-04-28 20:55:43 -07:00
log.Info().
Int("existing_private_portals_synced", len(resyncedExistingPrivateChannels)).
Int("recent_dms_synced", recentDmsSynced).
2026-04-28 20:55:43 -07:00
Int("dms_total", len(dms)).
Msg("Synced private channels")
}
func (d *DiscordClient) canSeeGuildChannel(ctx context.Context, ch *discordgo.Channel) bool {
log := zerolog.Ctx(ctx).With().
Str("channel_id", ch.ID).
Int("channel_type", int(ch.Type)).
Str("action", "determine guild channel visbility").Logger()
sess := d.Session
myDiscordUserID := d.Session.State.User.ID
// To calculate guild channel visibility we need to know our effective permission
// bitmask, which can only be truly determined when we know which roles we have
// in the guild.
//
// To this end, make sure we have detailed information about ourselves in the
// cache ("state").
_, err := sess.State.Member(ch.GuildID, myDiscordUserID)
if errors.Is(err, discordgo.ErrStateNotFound) {
log.Debug().Msg("Fetching own membership in guild to check roles")
member, err := sess.GuildMember(ch.GuildID, myDiscordUserID)
if err != nil {
log.Warn().Err(err).Msg("Failed to get own membership in guild from server")
} else {
err = sess.State.MemberAdd(member)
if err != nil {
log.Warn().Err(err).Msg("Failed to add own membership in guild to cache")
}
}
} else if err != nil {
log.Warn().Err(err).Msg("Failed to get own membership in guild from cache")
}
err = sess.State.ChannelAdd(ch)
if err != nil {
log.Warn().Err(err).Msg("Failed to add channel to cache")
}
perms, err := sess.State.UserChannelPermissions(myDiscordUserID, ch.ID)
if err != nil {
log.Warn().Err(err).Msg("Failed to get permissions in channel to determine if it's bridgeable")
return true
}
canView := perms&discordgo.PermissionViewChannel > 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)
2026-02-16 21:00:15 -08:00
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")
}
2026-02-25 07:05:44 -08:00
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")
}
}
2026-02-16 21:00:15 -08:00
func httpGet(ctx context.Context, httpClient *http.Client, url, thing string) ([]byte, error) {
2025-11-24 13:03:40 -08:00
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("failed to prepare request: %w", err)
}
2026-02-16 21:00:15 -08:00
resp, err := httpClient.Do(req)
2025-11-24 13:03:40 -08:00
if err != nil {
return nil, fmt.Errorf("failed to download %s: %w", thing, err)
}
2026-02-16 21:00:15 -08:00
defer resp.Body.Close()
if resp.StatusCode > 300 {
return nil, fmt.Errorf("failed to download %s: got HTTP %d", thing, resp.StatusCode)
}
2025-11-24 13:03:40 -08:00
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,
})
}
2026-03-03 20:53:29 -08:00
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
}
2026-03-06 19:57:53 -08:00
func (d *DiscordClient) syncRemoteProfile(ctx context.Context) bool {
if !d.IsLoggedIn() {
return false
}
2026-03-06 19:57:53 -08:00
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.
}
2026-03-10 20:21:33 -07:00
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")
}
2026-03-10 20:21:33 -07:00
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)
2026-03-10 20:21:33 -07:00
// TODO: Make this bridge state actually sticky/latching. This needs to
// stop the backfill loops.
2026-03-10 20:21:33 -07:00
d.UserLogin.BridgeState.Send(status.BridgeState{
StateEvent: status.StateBadCredentials,
UserAction: status.UserActionOpenNative,
2026-03-10 21:08:37 -07:00
Error: DCHTTP40002,
2026-03-10 20:21:33 -07:00
})
return bridgev2.WrapErrorInStatus(err).
// Tell clients to not retry.
WithStatus(event.MessageStatusFail).
WithIsCertain(true).
2026-03-10 21:08:37 -07:00
WithMessage(accountVerificationRequiredMessage).
2026-03-10 20:21:33 -07:00
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
}
2026-03-10 20:21:33 -07:00
rel := d.relationshipWithUserID(*recip)
return rel
2026-03-10 20:21:33 -07:00
}
// 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)
}