// Package gateway holds Discord's **gateway** bot — it reacts to @mentions // (and DMs) in real time. The other two Discord paths need no persistent // connection: slash commands arrive as signed HTTP POSTs and the daily post // goes out over an incoming webhook, both handled on the Python side. This one // is different — to answer an *ordinary* message that mentions the bot, we // have to hold a live websocket to Discord's gateway and listen for // MESSAGE_CREATE events. // // Single-connection invariant. Discord permits exactly one gateway connection // per bot token (per shard). The Python predecessor enforced that with a // leader election inside web/app.py; here it holds by deployment shape — the // daemon runs as exactly one replica, and this package owns its only // connection. // // Least privilege / no privileged intent. We subscribe to GUILDS + // GUILD_MESSAGES + DIRECT_MESSAGES and act only on messages that @mention the // bot or DM it. Discord delivers message *content* for exactly those cases // even without the privileged MESSAGE_CONTENT intent, so the bot needs no // privileged-intent portal review — a real design constraint, not trivia. To // trigger on arbitrary keywords in channels, enable MESSAGE_CONTENT in the // Developer Portal and extend triage; the rest of the machinery is unchanged. // // The gateway owns NO climate/grading logic. A mention with a city query is // answered by calling back into the Python app (Grader / apiclient), which // shares one grade builder with the /grade slash command so the two never // drift. package gateway import ( "context" "encoding/json" "fmt" "log" "math/rand" "net/http" "strings" "sync" "sync/atomic" "time" "github.com/coder/websocket" "thermograph/daemon/internal/config" ) // Run is the package entrypoint: build a Bot from the resolved config and // drive it for ctx's lifetime. The caller has already checked // cfg.DiscordEnabled — an unconfigured deploy never gets this far, so an // unconfigured deploy pays nothing. func Run(ctx context.Context, cfg *config.Config, api Grader) error { return New(cfg.DiscordToken, api).Run(ctx) } // v10 JSON gateway. On a resume we reconnect to the session's // resume_gateway_url (from the READY payload) instead, with the same query // string appended. const ( gatewayURL = "wss://gateway.discord.gg/?v=10&encoding=json" gatewayQS = "/?v=10&encoding=json" ) // Gateway intents (bitfield). GUILDS gives guild/channel context; // GUILD_MESSAGES and DIRECT_MESSAGES deliver message-create events in servers // and DMs. We deliberately do NOT request MESSAGE_CONTENT (privileged) — // Discord still includes content for messages that mention us or DM us, which // is all we act on. const ( intentGuilds = 1 << 0 intentGuildMessages = 1 << 9 intentDirectMessages = 1 << 12 intents = intentGuilds | intentGuildMessages | intentDirectMessages ) // Gateway opcodes (discord.com/developers/docs/topics/gateway-events#opcodes). const ( opDispatch = 0 opHeartbeat = 1 opIdentify = 2 opResume = 6 opReconnect = 7 opInvalidSession = 9 opHello = 10 opHeartbeatACK = 11 ) // isFatalClose reports whether a close code is unrecoverable — reconnecting // just loops, so we stop: 4004 auth failed (bad token), 4010 invalid shard, // 4011 sharding required, 4012 invalid API version, 4013 invalid intents, // 4014 disallowed (privileged) intents. func isFatalClose(code websocket.StatusCode) bool { switch code { case 4004, 4010, 4011, 4012, 4013, 4014: return true } return false } // Reconnect backoff bounds. A server-requested reconnect (op 7) resets to the // start so a planned reconnect comes back promptly. const ( backoffStart = 1 * time.Second backoffMax = 60 * time.Second ) // nextBackoff doubles the reconnect delay, capped at backoffMax. func nextBackoff(d time.Duration) time.Duration { d *= 2 if d > backoffMax { d = backoffMax } return d } // maxConcurrentReplies bounds in-flight MESSAGE_CREATE handling. The Python // version pushed each message onto a thread (asyncio.to_thread) so a slow // grading lookup couldn't stall heartbeats, but its queue was unbounded; the // semaphore here keeps the same never-block-the-read-loop property while // making sure a flood of mentions can't spawn unbounded goroutines against // the backend. const maxConcurrentReplies = 4 // dialTimeout mirrors the Python's open_timeout=30. const dialTimeout = 30 * time.Second // maxFrameSize mirrors the Python's max_size=2**20 — a grade embed is tiny, // so anything near a megabyte is not a frame we want to buffer. const maxFrameSize = 1 << 20 const ( modeIdentify = "identify" modeResume = "resume" ) // Grader is the one slice of the internal API the gateway needs: a city query // in, gateway-ready Discord message JSON out. Satisfied by *apiclient.Client. type Grader interface { Grade(ctx context.Context, query string) (json.RawMessage, error) } // Bot owns the single gateway connection and everything hanging off it. type Bot struct { token string api Grader sem chan struct{} // bounds concurrent MESSAGE_CREATE handlers rest string // Discord REST base; swapped for a test server in tests http *http.Client // REST replies (not the gateway socket) } // New returns a Bot that authenticates with token and answers city queries // through api. func New(token string, api Grader) *Bot { return &Bot{ token: token, api: api, sem: make(chan struct{}, maxConcurrentReplies), rest: discordAPIBase, http: &http.Client{Timeout: 30 * time.Second}, } } // payload is the gateway envelope: opcode, event data, sequence number, // dispatch type. type payload struct { Op int `json:"op"` D json.RawMessage `json:"d"` S *int64 `json:"s"` T string `json:"t"` } // session is the per-connection state that must survive a resume: the last // sequence number (what RESUME replays from), the resume token/URL, and our // own user id (for mention detection). seq is mutex-guarded because the read // loop advances it while the heartbeat goroutine reads it; the other fields // are only ever touched from the read/run loop. type session struct { mu sync.Mutex seq int64 haveSeq bool sessionID string resumeURL string userID string } func (s *session) setSeq(v int64) { s.mu.Lock() s.seq, s.haveSeq = v, true s.mu.Unlock() } func (s *session) lastSeq() (int64, bool) { s.mu.Lock() defer s.mu.Unlock() return s.seq, s.haveSeq } // wsConn is a thin JSON send/receive wrapper. coder/websocket allows // concurrent calls to everything except Read, and only the read loop reads, // so no extra locking is needed for the heartbeat goroutine's writes. type wsConn struct { c *websocket.Conn } func (w *wsConn) send(ctx context.Context, v any) error { data, err := json.Marshal(v) if err != nil { return err } return w.c.Write(ctx, websocket.MessageText, data) } func (w *wsConn) read(ctx context.Context) ([]byte, error) { _, data, err := w.c.Read(ctx) return data, err } func (w *wsConn) close(code websocket.StatusCode, reason string) { // Best-effort: the connection may already be gone, and a failed close on // a dead socket changes nothing about what happens next. _ = w.c.Close(code, reason) } // jitter is the 0..1s randomness added to every reconnect sleep so a fleet of // clients dropped by the same incident doesn't reconnect in lockstep. func jitter() time.Duration { return time.Duration(rand.Float64() * float64(time.Second)) } // sleepCtx sleeps for d, returning false if ctx was cancelled first. func sleepCtx(ctx context.Context, d time.Duration) bool { select { case <-ctx.Done(): return false case <-time.After(d): return true } } // Run maintains the gateway connection for the daemon's lifetime: connect, // IDENTIFY (or RESUME), dispatch events, and reconnect with capped // exponential backoff. It returns nil on context cancellation and an error // only on a fatal close (bad token / disallowed intents) — reconnecting after // those loops forever, so the caller must not restart us. func (b *Bot) Run(ctx context.Context) error { sess := &session{} mode := modeIdentify backoff := backoffStart for { useResume := mode == modeResume && sess.resumeURL != "" url := gatewayURL if useResume { url = sess.resumeURL } dialCtx, cancel := context.WithTimeout(ctx, dialTimeout) conn, _, err := websocket.Dial(dialCtx, url, nil) cancel() if err != nil { if ctx.Err() != nil { log.Print("gateway: shutting down") return nil } // A failed dial carries no gateway close code, so it is never // fatal — back off and retry. mode = modeIdentify if sess.sessionID != "" { mode = modeResume } log.Printf("gateway: connect failed (%v); reconnecting in %s", err, backoff) if !sleepCtx(ctx, backoff+jitter()) { return nil } backoff = nextBackoff(backoff) continue } conn.SetReadLimit(maxFrameSize) ws := &wsConn{c: conn} next, err := b.connection(ctx, ws, sess, useResume) if ctx.Err() != nil { // Shutdown: close clean (1000) — we are gone for good, so the // session should not be held open for a RESUME that never comes. ws.close(websocket.StatusNormalClosure, "shutting down") log.Print("gateway: shutting down") return nil } if err != nil { if code := websocket.CloseStatus(err); isFatalClose(code) { log.Printf("gateway: fatal gateway close %d; stopping", code) return fmt.Errorf("gateway: fatal close code %d", code) } mode = modeIdentify if sess.sessionID != "" { mode = modeResume } log.Printf("gateway: connection dropped (%v); reconnecting in %s", err, backoff) if !sleepCtx(ctx, backoff+jitter()) { return nil } backoff = nextBackoff(backoff) continue } // Deliberate reconnect (op 7 / op 9): close it ourselves. Non-1000 // when we intend to RESUME — Discord invalidates the session on a // 1000/1001 close, which would force a fresh IDENTIFY. if next == modeResume { ws.close(websocket.StatusCode(4000), "resuming") } else { ws.close(websocket.StatusNormalClosure, "reidentifying") } mode = next backoff = backoffStart // planned reconnect: come back promptly } } // parseHello validates the op-10 HELLO that opens every connection and returns // its heartbeat interval. // // Every failure here is an ERROR rather than a clean "reidentify". That // distinction is load-bearing: a clean return is Run's PLANNED-reconnect path, // which resets the backoff to 1s and redials with no sleep at all. That is // correct for a server-requested reconnect (op 7), but a gateway persistently // answering with a malformed HELLO would turn it into a tight reconnect loop // against Discord -- precisely what the capped backoff exists to prevent. The // error path applies backoff and still picks the next mode from surviving // session state. func parseHello(raw []byte) (time.Duration, error) { var hello payload if err := json.Unmarshal(raw, &hello); err != nil { return 0, fmt.Errorf("gateway: unparseable HELLO: %w", err) } if hello.Op != opHello { return 0, fmt.Errorf("gateway: expected HELLO (op %d), got op %d", opHello, hello.Op) } var hd struct { HeartbeatInterval float64 `json:"heartbeat_interval"` } if err := json.Unmarshal(hello.D, &hd); err != nil { return 0, fmt.Errorf("gateway: unparseable HELLO payload: %w", err) } // A non-positive interval would busy-loop the heartbeat goroutine. if hd.HeartbeatInterval <= 0 { return 0, fmt.Errorf("gateway: invalid heartbeat_interval %v", hd.HeartbeatInterval) } return time.Duration(hd.HeartbeatInterval * float64(time.Millisecond)), nil } // connection drives one live connection until it ends. A clean return carries // the mode for the next connect ("resume" to reconnect and RESUME, "identify" // to start a fresh session); an error return means the socket died and the // caller picks the next mode from the surviving session state. func (b *Bot) connection(ctx context.Context, ws *wsConn, sess *session, resume bool) (string, error) { raw, err := ws.read(ctx) if err != nil { return "", err } interval, err := parseHello(raw) if err != nil { return modeIdentify, err } // The heartbeat lives exactly as long as this connection. hbCtx, stopHB := context.WithCancel(ctx) defer stopHB() acked := &atomic.Bool{} acked.Store(true) beatNow := make(chan struct{}, 1) go b.heartbeat(hbCtx, ws, interval, sess, acked, beatNow) seq, haveSeq := sess.lastSeq() if resume && sess.sessionID != "" && haveSeq { err = ws.send(ctx, map[string]any{"op": opResume, "d": map[string]any{ "token": b.token, "session_id": sess.sessionID, "seq": seq, }}) } else { err = ws.send(ctx, map[string]any{"op": opIdentify, "d": map[string]any{ "token": b.token, "intents": intents, "properties": map[string]string{"os": "linux", "browser": "thermograph", "device": "thermograph"}, }}) } if err != nil { return "", err } for { data, err := ws.read(ctx) if err != nil { return "", err } var p payload if err := json.Unmarshal(data, &p); err != nil { log.Printf("gateway: dropping unparseable frame: %v", err) continue } if p.S != nil { // Every payload that carries a sequence number advances it — it // is what RESUME replays from. sess.setSeq(*p.S) } switch p.Op { case opDispatch: b.dispatch(ctx, p, sess) case opHeartbeat: // Server asked for an immediate beat; route it through the // heartbeat goroutine, which owns beat sending. select { case beatNow <- struct{}{}: default: } case opHeartbeatACK: acked.Store(true) case opReconnect: return modeResume, nil // planned reconnect — resume case opInvalidSession: // d == true => the session can still be resumed; d == false => // it is dead and the next connect must IDENTIFY fresh. var resumable bool _ = json.Unmarshal(p.D, &resumable) if !resumable { sess.sessionID = "" sess.resumeURL = "" } // Discord wants a short randomized wait before re-authing. if !sleepCtx(ctx, time.Second+jitter()) { return "", ctx.Err() } if sess.sessionID != "" { return modeResume, nil } return modeIdentify, nil } } } // heartbeat sends op-1 heartbeats every interval, the first one jittered by // interval*rand per Discord's guidance. If the previous beat was never ACKed // the link is a zombie — a half-open TCP connection where our writes // "succeed" but nothing comes back — so close with a non-1000 code (1000 // would invalidate the session) and stop; the next connect can then RESUME. func (b *Bot) heartbeat(ctx context.Context, ws *wsConn, interval time.Duration, sess *session, acked *atomic.Bool, beatNow <-chan struct{}) { timer := time.NewTimer(time.Duration(rand.Float64() * float64(interval))) defer timer.Stop() for { select { case <-ctx.Done(): return case <-beatNow: // Server-requested beat (op 1): send immediately, outside the // zombie accounting — it is not the reply to one of ours. if err := b.sendHeartbeat(ctx, ws, sess); err != nil { return // socket gone; the read loop owns reconnecting } case <-timer.C: if !acked.Load() { ws.close(websocket.StatusCode(4000), "heartbeat ack missed") return } acked.Store(false) if err := b.sendHeartbeat(ctx, ws, sess); err != nil { return } timer.Reset(interval) } } } func (b *Bot) sendHeartbeat(ctx context.Context, ws *wsConn, sess *session) error { var d any // null until the first sequence number arrives if seq, ok := sess.lastSeq(); ok { d = seq } return ws.send(ctx, map[string]any{"op": opHeartbeat, "d": d}) } // dispatch handles an op-0 DISPATCH event we care about. func (b *Bot) dispatch(ctx context.Context, p payload, sess *session) { switch p.T { case "READY": var d struct { SessionID string `json:"session_id"` ResumeGatewayURL string `json:"resume_gateway_url"` User struct { ID snowflake `json:"id"` } `json:"user"` } if err := json.Unmarshal(p.D, &d); err != nil { log.Printf("gateway: unparseable READY: %v", err) return } sess.sessionID = d.SessionID sess.resumeURL = "" if r := strings.TrimRight(d.ResumeGatewayURL, "/"); r != "" { sess.resumeURL = r + gatewayQS } sess.userID = string(d.User.ID) log.Printf("gateway: ready (user %s)", sess.userID) case "MESSAGE_CREATE": // Until READY tells us our own user id we can't detect mentions (or // guard against answering ourselves), so stay silent. if sess.userID == "" { return } var m gwMessage if err := json.Unmarshal(p.D, &m); err != nil { log.Printf("gateway: unparseable MESSAGE_CREATE: %v", err) return } // Grading is a synchronous parquet/DB round trip on the Python side, // so it must never run on the read loop — a slow lookup would stall // heartbeats (the Python used asyncio.to_thread for the same // reason). Each message gets its own goroutine, with the semaphore // bounding in-flight replies so a flood of mentions can't spawn // unbounded goroutines against the backend — an improvement over // to_thread's unbounded queue. Over the bound we drop and log rather // than block the read loop. botID := sess.userID select { case b.sem <- struct{}{}: go func() { defer func() { <-b.sem }() b.handleMessage(ctx, &m, botID) }() default: log.Printf("gateway: dropping message %s: too many replies in flight", m.ID) } } }