thermograph/backend/daemon/internal/gateway/gateway.go
Emi Griffith adf824b33f
All checks were successful
shell-lint / shellcheck (pull_request) Successful in 8s
PR build (required check) / changes (pull_request) Successful in 6s
secrets-guard / encrypted (pull_request) Successful in 5s
PR build (required check) / build-frontend (pull_request) Has been skipped
PR build (required check) / validate-observability (pull_request) Has been skipped
PR build (required check) / build-backend (pull_request) Successful in 1m33s
PR build (required check) / gate (pull_request) Successful in 3s
daemon: move the Discord gateway and scheduler out of the web process into Go
web/app.py started two long-lived background jobs under a leader election: the
Discord gateway bot and an APScheduler. Both are stateful I/O loops -- reconnect,
RESUME, heartbeat, backoff, interval timers -- living inside an async web app
that also has to serve requests. This moves them into a single Go binary.

Go owns ONLY the stateful I/O. It owns no climate or grading logic: anything
needing data calls back into Python over a new internal-only HTTP surface
(/internal/discord/grade, /internal/jobs/warm-cities, /internal/jobs/indexnow).
Grading depends on polars and the parquet cache; reimplementing it in Go would
make the bot's grades drift from the API's, and the slash-command path
deliberately shares one grade builder so the two can never disagree. The grade
route returns gateway-ready JSON -- including the ephemeral-flag drop that
discord_bot.py used to do -- and Go relays those bytes verbatim without parsing
the embed.

Packaging: the binary is built by a golang:1.26 stage in the backend Dockerfile
and shipped in the SAME image, run as a second compose service off the SAME tag.
The daemon and backend share the /internal/* contract, so they must never skew
versions; one image makes that structural rather than a convention. Its
entrypoint bypasses entrypoint.sh -- the backend owns alembic, and two racing
migrators is a real hazard.

replicas: 1 in the Swarm stack is load-bearing. Discord permits exactly one
gateway connection per bot token; the pin replaces core/singleton.claim_leader
for this workload. update_config uses order: stop-first, since start-first would
briefly run two gateways. autoscale.sh targets ${STACK_NAME}_web only, so it
cannot scale this.

Security: the internal routes compare the token with hmac.compare_digest and the
whole router 404s when THERMOGRAPH_INTERNAL_TOKEN is unset -- fail closed, never
default open. Caddy only routes /api/*, /digest and /discord/interactions to the
backend, so /internal/* was never publicly reachable; the token is defence in
depth. The router mounts before the catch-all frontend proxy so /internal/*
cannot fall through to it. The daemon refuses to start without the token.

Behaviour preserved from the Python, with the reasoning carried into the Go
comments: non-privileged intents (no MESSAGE_CONTENT, so no portal review);
fatal close codes 4004/4010-4014 stop rather than loop; the bot-author and
self-author mention-loop guard; allowed_mentions locked to {"parse":[],
"replied_user":true} so a crafted query cannot turn a reply into an @everyone
ping; the first cron tick deferred one full interval rather than firing at boot,
since warm-cities already runs at deploy time; and no overlapping warm-cities
run, which would double-spend the archive-fetch quota.

Two deliberate improvements over the Python. A close intended for RESUME now
uses 4000 rather than 1000 -- Discord invalidates a session closed 1000/1001, so
the Python's default close silently defeated its own resume. And MESSAGE_CREATE
is handled on a bounded worker pool rather than an unbounded thread hand-off, so
a flood of mentions cannot spawn unbounded work against the backend.

A .dockerignore is added because a disposable backend/.venv was being swallowed
by COPY . /app/ and duplicated again by the chown layer, inflating the image to
1.8 GB; it builds at 578 MB.

Tests: 29 Go gateway tests covering every behaviour the deleted
test_discord_bot.py asserted, plus cron/config/apiclient suites; 10 new Python
tests for the internal routes (fail-closed, auth, flag drop, per-job 409 guard).
Full suite 359 passed / 7 skipped; go build, vet and test -race clean.
2026-07-23 15:47:07 -07:00

529 lines
17 KiB
Go

// 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)
}
}
}