All checks were successful
Sync infra to hosts / sync-beta (push) Successful in 8s
Sync infra to hosts / sync-prod (push) Successful in 7s
secrets-guard / encrypted (push) Successful in 8s
shell-lint / shellcheck (push) Successful in 10s
Build + push backend image (Forgejo registry) / build-push (push) Successful in 1m14s
Deploy backend to beta VPS / deploy (push) Successful in 2m4s
The gateway bot and APScheduler were long-lived stateful I/O loops running inside the async web app under a leader election. They move into a single Go binary that owns ONLY that I/O -- websocket, RESUME, heartbeat, backoff, timers. It owns no grading logic. Anything needing data calls back over a new internal-only surface (/internal/discord/grade, /internal/jobs/*). Grading depends on polars and the parquet cache; reimplementing it in Go would let the bot's grades drift from the API's. The grade route returns gateway-ready JSON and Go relays the bytes verbatim. The binary ships in the backend image and runs as a second compose service off the same tag, so the two ends of the /internal/* contract can never skew. deploy.sh rolls daemon alongside backend -- without that the service would never be created, since a single-service deploy uses --no-deps. It also probes the image first and skips the daemon when rolling a tag that predates the binary: infra tracks main while image tags are env-staged, so a host can legitimately be asked to roll an older backend image, and creating the service anyway would leave a container crash-looping on a missing binary. replicas: 1 with order: stop-first replaces the leader election -- Discord permits one gateway connection per bot token. THERMOGRAPH_INTERNAL_TOKEN is optional: both ends derive it from THERMOGRAPH_AUTH_SECRET via HMAC under a domain-separation label, so this needs no new vault entry. The derivation is pinned to a shared cross-language test vector asserted on both sides, so drift fails CI instead of 401ing every call. Fail closed when neither secret is set. Improvements over the Python: a close intended for RESUME uses 4000 rather than 1000 (Discord invalidates a session closed 1000, so the old default defeated its own resume); MESSAGE_CREATE runs on a bounded worker pool; and a malformed HELLO returns an error rather than a clean reconnect, which would otherwise reset backoff and hot-loop against the gateway. 365 Python tests pass; Go build/vet/test -race clean; shellcheck 0 findings.
529 lines
17 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|