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"
2025-11-24 10:28:43 -08:00
"errors"
"fmt"
2025-11-24 13:03:40 -08:00
"io"
2026-03-11 18:13:00 -07:00
"iter"
2026-02-05 21:05:04 -08:00
"maps"
2026-10-29 14:02:31 +00:00
"math/rand/v2"
2025-11-24 13:03:40 -08:00
"net/http"
2026-03-30 21:42:52 -07:00
"regexp"
2025-11-25 14:25:17 -08:00
"slices"
2026-01-08 16:11:26 -08:00
"sync"
2026-03-10 21:08:37 -07:00
"sync/atomic"
2025-11-24 10:28:43 -08:00
"time"
2024-08-15 16:43:13 +03:00
2025-11-24 10:28:43 -08:00
"github.com/bwmarrin/discordgo"
2026-06-12 10:12:40 -07:00
"github.com/coder/websocket"
2025-11-24 10:28:43 -08:00
"github.com/rs/zerolog"
2026-10-05 18:26:10 +03:00
"go.mau.fi/util/exmaps"
2024-08-15 16:43:13 +03:00
"maunium.net/go/mautrix/bridgev2"
2026-03-11 18:13:00 -07:00
"maunium.net/go/mautrix/bridgev2/networkid"
2026-02-11 17:37:40 -08:00
"maunium.net/go/mautrix/bridgev2/simplevent"
2025-11-24 10:28:43 -08:00
"maunium.net/go/mautrix/bridgev2/status"
2026-03-05 14:05:35 -08:00
"maunium.net/go/mautrix/event"
2025-12-11 19:55:00 -08:00
2026-03-30 21:42:52 -07:00
"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
2026-01-08 16:11:26 -08:00
2026-07-15 16:42:24 -07:00
stopConnecting atomic . Pointer [ context . CancelFunc ]
2026-10-29 14:02:31 +00:00
gatewayReady atomic . Bool
2026-07-15 16:42:24 -07:00
fullSyncDone atomic . Bool // inverted (i.e. not needsInitSync) so zero value is "correct"
2026-06-19 19:01:11 -07:00
// seenReady is used to discern the initial READY payload from ones
// received during reconnections (where resumption is not possible).
seenReady atomic . Bool
2026-01-08 16:11:26 -08:00
markedOpened map [ string ] time . Time
markedOpenedLock sync . Mutex
2026-02-06 13:24:26 -08:00
2026-03-05 14:05:35 -08:00
// 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
2026-03-31 00:43:18 -07:00
relationshipLock sync . RWMutex
relationships map [ string ] * discordgo . Relationship
2026-02-06 13:24:26 -08:00
userCache * UserCache
2026-03-30 21:42:52 -07:00
lastSendAttemptMutex sync . Mutex
lastSendAttempt * SendAttempt
2026-07-08 09:04:50 -07:00
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 {
2026-01-09 17:08:33 -08:00
meta := login . Metadata . ( * discordid . UserLoginMetadata )
2025-11-24 10:28:43 -08:00
2026-03-10 22:31:16 -07:00
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
2026-06-12 11:58:57 -07:00
session , err = NewDiscordSession ( ctx , d . Bridge . GetHTTPClientSettings ( ) , meta . Token )
2026-03-10 22:31:16 -07:00
if err != nil {
return err
}
2025-11-24 10:28:43 -08:00
}
2025-12-09 17:43:48 -08:00
cl := DiscordClient {
2026-06-12 03:05:01 -07:00
connector : d ,
UserLogin : login ,
Session : session ,
// This HTTP client is quickly overridden by a proxied version (when
// one is configured).
2026-03-05 14:05:35 -08:00
httpClient : d . Bridge . GetHTTPClientSettings ( ) . Compile ( ) ,
userCache : NewUserCache ( session ) ,
guildSettings : make ( map [ string ] * discordgo . UserGuildSettings ) ,
readStates : make ( map [ string ] * discordgo . ReadState ) ,
2026-03-31 00:43:18 -07:00
relationships : make ( map [ string ] * discordgo . Relationship ) ,
2025-11-24 10:28:43 -08:00
}
2025-12-09 17:43:48 -08:00
login . Client = & cl
2025-11-24 10:28:43 -08:00
2026-03-30 21:42:52 -07:00
if session != nil {
session . RESTResponseHook = cl . tapDiscordRESTResponse
2026-06-12 03:05:01 -07:00
session . BeforeReconnect = func ( * discordgo . Session ) {
2026-10-29 14:02:31 +00:00
if cl . stopConnecting . Load ( ) == nil {
return
}
if cl . connector . proxyConfigured ( ) && ! cl . updateProxy ( cl . connector . Bridge . BackgroundCtx , "reconnect" ) {
2026-06-12 03:05:01 -07:00
// Failed to update the proxy. Continue reconnecting via the
// last good proxy, but report the failure.
2026-10-29 14:02:31 +00:00
cl . UserLogin . BridgeState . Send ( status . BridgeState {
2026-06-12 03:05:01 -07:00
StateEvent : status . StateTransientDisconnect ,
Error : DCProxyResolveFail ,
} )
}
}
2026-03-30 21:42:52 -07:00
}
2025-11-24 10:28:43 -08:00
return nil
2024-08-15 16:43:13 +03:00
}
var _ bridgev2 . NetworkAPI = ( * DiscordClient ) ( nil )
2026-03-10 22:31:16 -07:00
func ( d * DiscordClient ) userLoginMetadata ( ) * discordid . UserLoginMetadata {
return d . UserLogin . Metadata . ( * discordid . UserLoginMetadata )
}
2026-02-02 22:23:43 -08:00
func ( d * DiscordClient ) Connect ( ctx context . Context ) {
2025-12-18 18:28:07 -08:00
log := zerolog . Ctx ( ctx )
2026-03-10 22:31:16 -07:00
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" )
2026-03-10 21:08:37 -07:00
d . UserLogin . BridgeState . Send ( status . BridgeState {
StateEvent : status . StateBadCredentials ,
Error : DCNotLoggedIn ,
UserAction : status . UserActionRelogin ,
} )
return
}
2026-02-02 22:23:43 -08:00
2026-03-10 22:31:16 -07:00
meta := d . userLoginMetadata ( )
2026-02-02 22:23:43 -08:00
if meta . HeartbeatSession . IsExpired ( ) {
log . Info ( ) . Msg ( "Heartbeat session expired, creating a new one" )
meta . HeartbeatSession = discordgo . NewHeartbeatSession ( )
2025-12-18 18:28:07 -08:00
}
2026-02-02 22:23:43 -08:00
meta . HeartbeatSession . BumpLastUsed ( )
d . Session . HeartbeatSession = meta . HeartbeatSession
2026-01-08 16:11:26 -08:00
d . markedOpened = make ( map [ string ] time . Time )
2025-11-24 10:28:43 -08:00
2026-10-29 14:02:31 +00:00
d . connectRetrying ( context . WithoutCancel ( ctx ) , 0 , nil )
2026-03-10 21:08:37 -07:00
}
2026-07-08 09:04:50 -07:00
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
}
2026-10-29 14:02:31 +00:00
if ! d . gatewayReady . Load ( ) {
return
}
2026-07-08 09:04:50 -07:00
d . UserLogin . BridgeState . Send ( status . BridgeState {
StateEvent : status . StateConnected ,
Info : info ,
} )
}
2026-10-29 14:02:31 +00:00
const (
minGatewayRetryDelay = 2 * time . Second
maxGatewayRetryDelay = 5 * time . Minute
)
func gatewayRetryDelay ( retryCount int ) time . Duration {
return rand . N ( min ( minGatewayRetryDelay << min ( retryCount , 8 ) , maxGatewayRetryDelay ) )
}
2026-03-10 21:08:37 -07:00
2026-03-30 21:42:52 -07:00
// tokenInvalidated responds to Discord invalidating our token.
2026-04-02 19:21:47 -07:00
func ( d * DiscordClient ) tokenInvalidated ( ctx context . Context , circumstance string ) {
2026-03-30 21:42:52 -07:00
log := zerolog . Ctx ( ctx )
log . Info ( ) . Msg ( "Invalidating user login" )
2026-03-10 21:08:37 -07:00
2026-04-02 19:21:47 -07:00
d . UserLogin . BridgeState . Send ( status . BridgeState {
2026-03-10 21:08:37 -07:00
StateEvent : status . StateBadCredentials ,
Error : DCWebsocketDisconnect4004 ,
UserAction : status . UserActionRelogin ,
} )
2026-03-30 21:42:52 -07:00
2026-04-02 19:21:47 -07:00
props := d . baseAnalyticsProps ( ctx )
2026-03-30 21:42:52 -07:00
props [ "circumstance" ] = circumstance
2026-04-02 19:21:47 -07:00
d . UserLogin . TrackAnalytics ( "Discord auth invalidation" , props )
2026-03-30 21:42:52 -07:00
// Empty out the token.
log . Debug ( ) . Msg ( "Emptying token" )
2026-04-02 19:21:47 -07:00
meta := d . UserLogin . Metadata . ( * discordid . UserLoginMetadata )
2026-03-30 21:42:52 -07:00
meta . Token = ""
2026-04-02 19:21:47 -07:00
if err := d . UserLogin . Save ( ctx ) ; err != nil {
2026-03-30 21:42:52 -07:00
log . Err ( err ) . Msg ( "Failed to save user login in order to invalidate session" )
}
2026-03-10 21:08:37 -07:00
}
2026-10-21 21:54:48 +02:00
func ( d * DiscordClient ) connectRetrying ( ctx context . Context , retryCount int , prevStop * context . CancelFunc ) {
2026-03-10 21:08:37 -07:00
retryCtx , cancel := context . WithCancel ( ctx )
2026-10-21 21:54:48 +02:00
if prevStop == nil {
oldStop := d . stopConnecting . Swap ( & cancel )
if oldStop != nil {
( * oldStop ) ( )
}
} else if ! d . stopConnecting . CompareAndSwap ( prevStop , & cancel ) {
// Disconnect (or a new connect) took over while we were sleeping between attempts.
cancel ( )
return
2026-03-10 21:08:37 -07:00
}
log := zerolog . Ctx ( ctx ) . With ( ) . Int ( "retry_count" , retryCount ) . Logger ( )
2026-02-02 22:23:43 -08:00
log . Debug ( ) . Msg ( "Connecting to Discord" )
2026-04-02 19:21:47 -07:00
d . UserLogin . BridgeState . Send ( status . BridgeState {
2025-11-24 10:28:43 -08:00
StateEvent : status . StateConnecting ,
} )
2026-03-10 21:08:37 -07:00
2026-10-29 14:02:31 +00:00
retryAfterDelay := func ( ) {
sleepDuration := gatewayRetryDelay ( retryCount )
log . Debug ( ) . Dur ( "retry_sleeping_seconds" , sleepDuration ) .
Msg ( "Sleeping and retrying gateway connection" )
go func ( ) {
select {
case <- time . After ( sleepDuration ) :
d . connectRetrying ( ctx , retryCount + 1 , & cancel )
case <- retryCtx . Done ( ) :
log . Debug ( ) . Msg ( "Was told to stop connecting" )
}
} ( )
}
2026-06-12 03:05:01 -07:00
if d . connector . proxyConfigured ( ) && ! d . updateProxy ( ctx , "connect" ) {
d . UserLogin . BridgeState . Send ( status . BridgeState {
2026-10-29 14:02:31 +00:00
StateEvent : status . StateTransientDisconnect ,
2026-06-12 03:05:01 -07:00
Error : DCProxyResolveFail ,
} )
2026-10-29 14:02:31 +00:00
retryAfterDelay ( )
2026-06-12 03:05:01 -07:00
return
}
2026-10-21 21:54:48 +02:00
if retryCtx . Err ( ) != nil {
log . Debug ( ) . Msg ( "Was told to stop connecting" )
return
}
2026-04-02 19:21:47 -07:00
err := d . connect ( ctx )
2026-03-10 21:08:37 -07:00
if err != nil {
2025-11-24 10:28:43 -08:00
log . Err ( err ) . Msg ( "Couldn't connect to Discord" )
2026-03-10 21:08:37 -07:00
2026-06-12 10:12:40 -07:00
if websocket . CloseStatus ( err ) == 4004 {
2026-03-10 21:08:37 -07:00
// Effectively the same as *discordgo.InvalidAuth, but at connect
2026-03-30 21:42:52 -07:00
// time. (discordgo only dispatches the synthetic InvalidAuth event
// once you've already connected successfully.)
//
// Don't retry.
2026-04-02 19:21:47 -07:00
d . tokenInvalidated ( ctx , "when connecting" )
2026-03-10 21:08:37 -07:00
} else {
2026-04-02 19:21:47 -07:00
d . UserLogin . BridgeState . Send ( status . BridgeState {
2026-10-29 14:02:31 +00:00
StateEvent : status . StateTransientDisconnect ,
2026-03-10 21:08:37 -07:00
Error : DCUnknownWebsocketError ,
2026-10-29 14:02:31 +00:00
Info : map [ string ] any { "go_error" : err . Error ( ) } ,
2026-03-10 21:08:37 -07:00
} )
2026-10-29 14:02:31 +00:00
retryAfterDelay ( )
2026-03-10 21:08:37 -07:00
}
2025-11-24 10:28:43 -08:00
}
}
2026-04-02 19:21:47 -07:00
func ( d * DiscordClient ) handleDiscordEventSync ( event any ) {
2026-10-29 14:02:31 +00:00
if d . stopConnecting . Load ( ) == nil {
// discordgo's reconnect loop doesn't stop on Close, so a session can come back after Disconnect.
if _ , ok := event . ( * discordgo . Connect ) ; ok {
d . UserLogin . Log . Warn ( ) . Msg ( "Discord session reconnected after Disconnect, closing it" )
d . Session . ShouldReconnectOnError = false
// Open holds the session lock while dispatching Connect.
go d . Session . Close ( )
}
return
}
2026-03-31 00:43:18 -07:00
// Dispatch event handlers that maintain important state synchronously, or
2026-06-19 19:01:11 -07:00
// else we might end up inadvertently relying on goroutine scheduling.
2026-04-02 19:21:47 -07:00
d . handleDiscordStateEvent ( event )
2026-06-19 19:01:11 -07:00
2026-04-02 19:21:47 -07:00
go d . handleDiscordEvent ( event )
2025-12-09 17:43:48 -08:00
}
2026-04-02 19:21:47 -07:00
func ( d * DiscordClient ) connect ( ctx context . Context ) error {
2025-11-24 11:14:48 -08:00
log := zerolog . Ctx ( ctx )
2025-11-24 10:28:43 -08:00
log . Info ( ) . Msg ( "Opening session" )
2026-04-02 19:21:47 -07:00
d . Session . EventHandler = d . handleDiscordEventSync
2025-12-09 17:43:48 -08:00
2026-07-15 20:43:40 -07: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.
2026-04-02 19:21:47 -07:00
err := d . Session . Open ( )
2025-11-24 10:28:43 -08:00
if err != nil {
log . Err ( err ) . Msg ( "Failed to connect to Discord" )
return err
}
2026-06-19 21:13:43 -07:00
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
2026-03-05 14:05:35 -08:00
readState := ready . ReadState
2026-03-03 20:53:29 -08:00
if readState != nil {
2026-06-19 21:13:43 -07:00
log . Debug ( ) . Int ( "n_read_states" , len ( readState . Entries ) ) .
Msg ( "Applying read states from READY" )
2026-04-02 19:21:47 -07:00
d . readStatesLock . Lock ( )
2026-06-19 21:22:28 -07:00
if ! readState . Partial {
clear ( d . readStates )
}
2026-03-03 20:53:29 -08:00
for _ , state := range readState . Entries {
2026-04-02 19:21:47 -07:00
d . readStates [ state . ID ] = state
2026-03-03 20:53:29 -08:00
}
2026-04-02 19:21:47 -07:00
d . readStatesLock . Unlock ( )
2026-03-03 20:53:29 -08:00
}
2025-11-24 12:41:40 -08:00
2026-03-05 14:05:35 -08:00
settings := ready . UserGuildSettings
if settings != nil {
2026-06-19 21:13:43 -07:00
log . Debug ( ) . Int ( "n_guild_settings" , len ( settings . Entries ) ) .
Msg ( "Applying guild settings from READY" )
2026-04-02 19:21:47 -07:00
d . bulkApplyGuildSettings ( settings )
2026-03-05 14:05:35 -08:00
}
2024-08-15 16:43:13 +03:00
}
2026-03-05 14:05:35 -08:00
func ( d * DiscordClient ) bulkApplyGuildSettings ( sl * discordgo . UserGuildSettingsList ) {
d . guildSettingsLock . Lock ( )
defer d . guildSettingsLock . Unlock ( )
2026-06-19 21:22:28 -07:00
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 )
2026-03-05 14:05:35 -08:00
}
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 ( ) {
2026-03-10 21:08:37 -07:00
if stopConnecting := d . stopConnecting . Swap ( nil ) ; stopConnecting != nil {
( * stopConnecting ) ( )
}
2026-10-29 14:02:31 +00:00
d . gatewayReady . Store ( false )
2025-11-24 11:14:48 -08:00
d . UserLogin . Log . Info ( ) . Msg ( "Disconnecting session" )
2026-03-10 22:31:16 -07:00
if d . Session != nil {
d . Session . Close ( )
}
2024-08-15 16:43:13 +03:00
}
2026-03-10 22:31:16 -07: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 {
2026-03-10 22:31:16 -07:00
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
}
2026-07-08 09:04:50 -07:00
if vitals := d . peekVitals ( ) ; vitals . RequiresUserIntervention ( ) {
2026-07-15 16:42:24 -07:00
// 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.
2026-07-08 09:04:50 -07:00
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 ) {
2025-11-24 10:28:43 -08:00
// FIXME(skip): Implement.
d . Disconnect ( )
2024-08-15 16:43:13 +03:00
}
2025-11-24 12:41:40 -08:00
2026-07-15 16:42:24 -07: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 ) {
2025-11-25 14:25:17 -08:00
return
}
2026-07-15 16:42:24 -07:00
go d . syncRemoteProfile ( ctx ) // don't block the caller
2026-06-19 19:01:11 -07:00
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 ( )
2026-06-19 19:01:11 -07:00
go d . reconcileGuildSpaces ( ctx , guildIDs )
2026-04-02 19:21:47 -07:00
go d . syncPrivateChannels ( ctx )
go d . syncGuilds ( ctx )
2025-11-25 14:25:17 -08:00
}
2026-03-11 18:13:00 -07:00
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 {
2026-06-19 19:01:11 -07:00
log . Err ( err ) . Msg ( "Failed to fetch all user portals, returning empty iterator" )
2026-03-11 18:13:00 -07:00
// 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
}
}
}
}
2025-11-25 14:25:17 -08:00
func ( d * DiscordClient ) syncPrivateChannels ( ctx context . Context ) {
2026-03-11 18:13:00 -07:00
log := zerolog . Ctx ( ctx )
2026-06-19 21:43:59 -07:00
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.
2026-03-11 18:13:00 -07:00
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.
2026-06-19 21:43:59 -07:00
channel := d . channelWithID ( ctx , channelID )
if channel == nil {
2026-03-11 18:13:00 -07:00
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 , "" )
2026-06-19 21:43:59 -07:00
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 )
2026-03-11 18:13:00 -07:00
}
}
2026-06-19 19:01:11 -07:00
d . Session . State . RLock ( )
2025-11-25 14:25:17 -08:00
dms := slices . Clone ( d . Session . State . PrivateChannels )
2026-06-19 19:01:11 -07:00
d . Session . State . RUnlock ( )
2026-06-19 21:43:59 -07:00
// Only queue portal-creating resyncs for the top n private channels with
// recent activity (that haven't already been synced above).
2025-11-25 14:25:17 -08:00
slices . SortFunc ( dms , func ( a , b * discordgo . Channel ) int {
2025-11-25 14:46:12 -08:00
ats , _ := discordgo . SnowflakeTimestamp ( a . LastMessageID )
bts , _ := discordgo . SnowflakeTimestamp ( b . LastMessageID )
return bts . Compare ( ats )
2025-11-25 14:25:17 -08:00
} )
2026-06-19 21:43:59 -07:00
// TODO(skip): This is startup_private_channel_create_limit. Support this
// in the config.
2026-03-11 18:13:00 -07:00
maxDms := min ( 10 , len ( dms ) )
2026-06-19 21:43:59 -07:00
recentDmsSynced := 0
2026-01-21 06:54:58 -08:00
for _ , dm := range dms [ : maxDms ] {
2026-06-19 21:43:59 -07:00
if resyncedExistingPrivateChannels . Has ( dm . ID ) {
continue
}
2026-03-11 18:13:00 -07:00
log . Debug ( ) . Str ( "channel_id" , dm . ID ) . Msg ( "Syncing private channel with recent activity" )
2026-03-18 22:01:07 -07:00
d . queueChannelResync ( ctx , dm )
2026-06-19 21:43:59 -07:00
recentDmsSynced ++
2026-01-09 17:08:33 -08:00
}
2026-04-28 20:55:43 -07:00
log . Info ( ) .
2026-06-19 21:43:59 -07:00
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" )
2026-01-09 17:08:33 -08:00
}
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
2026-03-18 22:01:07 -07:00
log . Trace ( ) .
2026-01-09 17:08:33 -08:00
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 {
2026-02-01 21:03:17 -08:00
ID : discordid . MakeAvatarID ( guild . Icon ) ,
2026-01-09 17:08:33 -08:00
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" )
2026-01-09 17:08:33 -08:00
} ,
Remove : guild . Icon == "" ,
}
}
2026-02-05 21:05:04 -08:00
// bridgedGuildIDs returns a set of guild IDs that should be bridged. Note that
2026-02-11 19:03:17 -08:00
// presence in the returned set does not imply anything about the corresponding
// portals and rooms.
2026-02-05 21:05:04 -08:00
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
}
2026-01-09 17:08:33 -08:00
func ( d * DiscordClient ) syncGuilds ( ctx context . Context ) {
2026-02-05 21:05:04 -08:00
guildIDs := slices . Sorted ( maps . Keys ( d . bridgedGuildIDs ( ) ) )
2026-01-09 17:08:33 -08:00
for _ , guildID := range guildIDs {
log := zerolog . Ctx ( ctx ) . With ( ) .
Str ( "guild_id" , guildID ) .
Str ( "action" , "sync guild" ) .
Logger ( )
2026-02-13 18:16:03 -08:00
err := d . syncGuild ( log . WithContext ( ctx ) , guildID )
2026-01-09 17:08:33 -08:00
if err != nil {
2026-01-21 06:54:58 -08:00
log . Err ( err ) . Msg ( "Couldn't bridge guild during sync" )
2026-01-09 17:08:33 -08:00
}
2026-01-21 06:54:58 -08:00
}
}
2026-01-09 17:08:33 -08:00
2026-07-02 10:58:19 -07:00
// ensurePortal _synchronously_ guarantees the existence of a portal's Matrix
2026-03-18 22:01:07 -07:00
// room with up-to-date chat info.
//
2026-07-02 10:58:19 -07:00
// This is especially useful in situations where the ordering of room creation
// is important.
//
2026-03-18 22:01:07 -07:00
// If info is nil, then the chat info is fetched from the NetworkAPI.
2026-07-02 10:58:19 -07:00
//
// If the portal already has a room, then we merely ensure that the portal's
// info is up-to-date.
2026-03-18 22:01:07 -07:00
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
}
2026-06-19 19:01:11 -07:00
// 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 )
}
}
2026-07-02 10:58:19 -07:00
// shouldBridgeChannel reports whether a channel should be bridged. This
2026-07-02 10:58:19 -07:00
// considers information such as the type of the channel, the user's effective
// permissions within the guild, which guilds are bridged, etc.
2026-07-02 10:58:19 -07:00
func ( d * DiscordClient ) shouldBridgeChannel (
ctx context . Context ,
ch * discordgo . Channel ,
) bool {
if ch == nil {
return false
}
2026-07-02 10:58:19 -07:00
// 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
}
}
2026-07-02 10:58:19 -07:00
// Only ever bridge guild text channels.
// TODO(skip): Consider bridging voice channels (make sure to check for the
// right permission bits)?
2026-07-08 09:04:50 -07:00
if ch . Type != discordgo . ChannelTypeGuildText && ch . Type != discordgo . ChannelTypeGuildNews {
2026-07-02 10:58:19 -07:00
return false
}
if ! d . canSeeGuildChannel ( ctx , ch ) {
return false
}
return true
}
2026-02-13 18:16:03 -08:00
func ( d * DiscordClient ) syncGuild ( ctx context . Context , guildID string ) error {
2026-02-11 19:03:17 -08:00
log := zerolog . Ctx ( ctx ) . With ( ) .
Str ( "guild_id" , guildID ) .
Str ( "action" , "bridge guild" ) .
Logger ( )
ctx = log . WithContext ( ctx )
2026-01-09 17:08:33 -08:00
2026-01-21 06:54:58 -08:00
guild , err := d . Session . State . Guild ( guildID )
if errors . Is ( err , discordgo . ErrStateNotFound ) || guild == nil {
2026-06-19 19:01:11 -07:00
// 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" )
2026-01-21 06:54:58 -08:00
return errors . New ( "couldn't find guild in state" )
}
2026-01-09 17:08:33 -08:00
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 )
}
2026-03-18 22:01:07 -07:00
// 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 )
}
2026-01-09 17:08:33 -08:00
2026-03-18 22:01:07 -07:00
visibleCategoryIDs := make ( exmaps . Set [ string ] )
visibleChannels := make ( [ ] * discordgo . Channel , 0 , len ( guild . Channels ) )
2026-01-21 06:54:58 -08:00
for _ , guildCh := range guild . Channels {
2026-07-02 10:58:19 -07:00
if ! d . shouldBridgeChannel ( ctx , guildCh ) {
2026-01-21 06:54:58 -08:00
continue
2026-01-09 17:08:33 -08:00
}
2026-03-18 22:01:07 -07:00
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" )
2026-01-21 06:54:58 -08:00
continue
2026-01-09 17:08:33 -08:00
}
2026-01-21 06:54:58 -08:00
2026-03-18 22:01:07 -07:00
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 )
2025-11-24 12:41:40 -08:00
}
2026-01-09 17:08:33 -08:00
2026-02-25 07:53:21 -08:00
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" )
}
}
2026-02-11 19:03:17 -08:00
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 ,
2026-01-21 06:54:58 -08:00
Typing : true ,
Activities : true ,
Threads : true ,
} )
if err != nil {
2026-02-11 19:03:17 -08:00
log . Warn ( ) . Err ( err ) . Msg ( "Failed to subscribe to guild, proceeding" )
2026-01-21 06:54:58 -08:00
}
2025-11-24 12:41:40 -08:00
}
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
}
2025-12-11 19:17:57 -08:00
func ( d * DiscordClient ) makeEventSenderWithID ( userID string ) bridgev2 . EventSender {
2025-11-26 18:09:00 -08:00
return bridgev2 . EventSender {
2025-12-11 19:17:57 -08:00
IsFromMe : userID == d . Session . State . User . ID ,
2026-02-01 21:03:17 -08:00
SenderLogin : discordid . MakeUserLoginID ( userID ) ,
Sender : discordid . MakeUserID ( userID ) ,
2025-11-26 18:09:00 -08:00
}
}
2026-01-09 17:08:33 -08:00
func ( d * DiscordClient ) selfEventSender ( ) bridgev2 . EventSender {
return d . makeEventSenderWithID ( d . Session . State . User . ID )
}
2025-12-11 19:17:57 -08:00
func ( d * DiscordClient ) makeEventSender ( user * discordgo . User ) bridgev2 . EventSender {
2026-02-09 14:33:56 -08:00
if user == nil {
panic ( "DiscordClient makeEventSender was passed a nil user" )
}
2025-12-11 19:17:57 -08:00
return d . makeEventSenderWithID ( user . ID )
}
2026-03-18 22:01:07 -07:00
func ( d * DiscordClient ) queueChannelResync ( _ context . Context , ch * discordgo . Channel ) {
2025-11-26 15:07:40 -08:00
d . connector . Bridge . QueueRemoteEvent ( d . UserLogin , & DiscordChatResync {
2026-06-19 21:43:59 -07:00
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 ,
2025-11-24 12:41:40 -08:00
} )
}
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 ]
}
2026-03-03 22:12:42 -08:00
2026-03-05 14:05:35 -08:00
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.
2026-10-12 18:09:54 +00:00
func ( d * DiscordClient ) channelMutedUntil ( guildID , parentID , channelID string ) time . Time {
2026-03-05 14:05:35 -08:00
settings := d . guildSettingsForGuildID ( guildID )
if settings == nil {
return bridgev2 . Unmuted
}
2026-10-12 18:09:54 +00:00
mutedUntil := d . computeMutedUntil ( settings . Muted , settings . MuteConfig )
2026-03-05 14:05:35 -08:00
// TODO: Might be worth speeding this up via map.
for _ , override := range settings . ChannelOverrides {
2026-10-12 18:09:54 +00:00
if override . ChannelID == channelID || override . ChannelID == parentID {
if overrideMutedUntil := d . computeMutedUntil ( override . Muted , override . MuteConfig ) ; overrideMutedUntil . After ( mutedUntil ) {
mutedUntil = overrideMutedUntil
}
2026-03-05 14:05:35 -08:00
}
}
2026-10-12 18:09:54 +00:00
return mutedUntil
2026-03-05 14:05:35 -08:00
}
func ( d * DiscordClient ) guildSettingsForGuildID ( guildID string ) * discordgo . UserGuildSettings {
d . guildSettingsLock . RLock ( )
defer d . guildSettingsLock . RUnlock ( )
return d . guildSettings [ guildID ]
}
2026-03-03 22:12:42 -08:00
func ( d * DiscordClient ) channelWithID ( ctx context . Context , channelID string ) * discordgo . Channel {
2026-07-08 09:04:50 -07:00
if d . Session == nil {
2026-03-10 22:31:16 -07:00
return nil
}
2026-03-03 22:12:42 -08:00
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 {
2026-03-10 22:31:16 -07:00
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
2026-06-16 18:53:50 -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 {
2026-03-30 21:42:52 -07:00
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
2026-07-08 09:04:50 -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
2026-10-29 13:10:50 +00:00
if ! errors . As ( err , & restErr ) || restErr . Message == nil {
return err
}
2026-03-10 20:21:33 -07:00
2026-10-29 13:10:50 +00:00
switch restErr . Message . Code {
case discordgo . ErrCodeActionRequiredVerifiedAccount :
return d . wrapReceived40002 ( ctx , err )
case discordgo . ErrCodeCannotSendMessagesToThisUser :
2026-10-29 13:59:58 +00:00
return wrapRejection ( err , event . MessageStatusNoPermission , "Discord didn't deliver this message. This user may only accept messages from friends or people they share a server with. " + discordDMHelpURL )
case errCodeNoMutualGuilds :
return wrapRejection ( err , event . MessageStatusNoPermission , "Discord doesn't allow messaging this user because you don't share a server with them. " + discordDMHelpURL )
2026-10-29 13:10:50 +00:00
case discordgo . ErrCodeMissingPermissions , discordgo . ErrCodeMissingAccess :
return wrapRejection ( err , event . MessageStatusNoPermission , "You don't have permission to do that in this Discord channel." )
case discordgo . ErrCodeCannotReplyWithoutPermissionToReadMessageHistory :
return wrapRejection ( err , event . MessageStatusNoPermission , "You need permission to read the message history in this channel to reply." )
case discordgo . ErrCodeUnknownMessage :
return wrapRejection ( err , event . MessageStatusNetworkError , "The message this refers to no longer exists on Discord." )
case discordgo . ErrCodeInvalidFormBody :
return wrapRejection ( err , event . MessageStatusNetworkError , "Discord rejected this message as invalid." )
case discordgo . ErrCodeRequestEntityTooLarge :
return wrapRejection ( err , event . MessageStatusUnsupported , "This file is too large to send on Discord." )
case discordgo . ErrCodeCannotSendEmptyMessage :
return wrapRejection ( err , event . MessageStatusUnsupported , "Discord doesn't allow sending empty messages." )
2026-03-10 20:21:33 -07:00
}
return err
2026-03-30 21:42:52 -07:00
}
2026-10-29 13:59:58 +00:00
const (
errCodeNoMutualGuilds = 50278
discordDMHelpURL = "https://support.discord.com/hc/en-us/articles/360060145013"
)
2026-10-29 13:10:50 +00:00
func isUnknownMessage ( err error ) bool {
var restErr * discordgo . RESTError
return errors . As ( err , & restErr ) && restErr . Message != nil && restErr . Message . Code == discordgo . ErrCodeUnknownMessage
}
func wrapRejection ( err error , reason event . MessageStatusReason , message string ) error {
return bridgev2 . WrapErrorInStatus ( err ) .
WithStatus ( event . MessageStatusFail ) .
WithErrorReason ( reason ) .
WithIsCertain ( true ) .
WithMessage ( message ) .
WithSendNotice ( true )
}
2026-03-30 21:42:52 -07:00
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
}
2026-04-20 20:33:58 -07:00
if len ( ch . Recipients ) != 1 {
2026-03-30 21:42:52 -07:00
return nil
}
2026-04-20 20:33:58 -07:00
return & ch . Recipients [ 0 ] . ID
2026-03-30 21:42:52 -07:00
}
2026-10-29 13:40:05 +00:00
func channelIsOrWasMessageRequest ( ch * discordgo . Channel ) bool {
if ch == nil {
return false
}
return ch . IsMessageRequest || ch . IsMessageRequestTimestamp != nil
}
2026-03-30 21:42:52 -07:00
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 {
2026-03-31 00:43:18 -07:00
props [ "lastInMemorySendAttemptRecipientRelationshipType" ] = readableRelationshipType ( * relType )
2026-03-30 21:42:52 -07:00
}
}
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
}
2026-07-06 17:36:19 -07:00
captcha := discordauth . CheckCaptcha ( ctx , resp , body )
2026-03-30 21:42:52 -07:00
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
}
2026-03-31 00:43:18 -07:00
d . relationshipLock . RLock ( )
defer d . relationshipLock . RUnlock ( )
2026-03-30 21:42:52 -07:00
2026-03-31 00:43:18 -07:00
return d . relationships [ userID ]
2026-03-30 21:42:52 -07:00
}
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
2026-03-30 21:42:52 -07:00
rel := d . relationshipWithUserID ( * recip )
return rel
2026-03-10 20:21:33 -07:00
}
2026-03-31 00:43:18 -07:00
2026-04-08 23:31:59 -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 {
2026-04-20 20:33:58 -07:00
if len ( ch . Recipients ) == 1 && ch . Recipients [ 0 ] . ID == userID {
2026-04-08 23:31:59 -07:00
return ch
}
}
return nil
}
2026-03-31 00:43:18 -07:00
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 )
}