// The REST half of answering a message: replies go out over plain HTTP with // the bot token, not the gateway socket (the gateway is receive-only for us). package gateway import ( "bytes" "context" "encoding/json" "io" "log" "net/http" "strconv" "time" ) const discordAPIBase = "https://discord.com/api/v10" // replyBodyLimit caps how much of a failed reply's response body we echo into // logs. const replyBodyLimit = 4096 // injectReplyFields adds message_reference + allowed_mentions to an otherwise // verbatim message body. Only the top level is decoded — every value that // came from Python (embeds and all) passes through as raw bytes, unparsed, so // the grading/embed contract stays entirely on the Python side. // // allowed_mentions is a SECURITY control, not decoration: the graded reply // echoes the user's query text, so without {"parse":[]} a crafted query could // turn our reply into an @everyone/role ping. Only the person who asked is // pinged, via the reply reference. func injectReplyFields(body []byte, messageID string) ([]byte, error) { var top map[string]json.RawMessage if err := json.Unmarshal(body, &top); err != nil { return nil, err } if messageID != "" { ref, err := json.Marshal(map[string]string{"message_id": messageID}) if err != nil { return nil, err } top["message_reference"] = ref top["allowed_mentions"] = json.RawMessage(`{"parse":[],"replied_user":true}`) } return json.Marshal(top) } // reply posts body to the triggering message's channel as a proper reply. A // failed reply is logged and swallowed — it must never kill the read loop. func (b *Bot) reply(ctx context.Context, channelID, messageID string, body json.RawMessage) { if channelID == "" { return } payload, err := injectReplyFields(body, messageID) if err != nil { log.Printf("gateway: reply body is not a JSON object: %v", err) return } url := b.rest + "/channels/" + channelID + "/messages" for attempt := 0; ; attempt++ { req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) if err != nil { log.Printf("gateway: reply failed: %v", err) return } req.Header.Set("Authorization", "Bot "+b.token) req.Header.Set("Content-Type", "application/json") resp, err := b.http.Do(req) if err != nil { log.Printf("gateway: reply failed: %v", err) return } raw, _ := io.ReadAll(io.LimitReader(resp.Body, replyBodyLimit)) resp.Body.Close() if resp.StatusCode == http.StatusTooManyRequests && attempt == 0 { // One retry honouring the advertised wait — parity with the // Python REST helper's 429 handling. if !sleepCtx(ctx, retryAfter(resp.Header, raw)) { return } continue } if resp.StatusCode < 200 || resp.StatusCode >= 300 { log.Printf("gateway: reply failed: status %d: %s", resp.StatusCode, raw) } return } } // retryAfter extracts Discord's requested wait from a 429 (JSON retry_after // in seconds, falling back to the Retry-After header), clamped so a bogus // server value can't park the handler for minutes. func retryAfter(h http.Header, body []byte) time.Duration { seconds := 1.0 var d struct { RetryAfter float64 `json:"retry_after"` } if err := json.Unmarshal(body, &d); err == nil && d.RetryAfter > 0 { seconds = d.RetryAfter } else if v, err := strconv.ParseFloat(h.Get("Retry-After"), 64); err == nil && v > 0 { seconds = v } // 5s matches the Python REST helper's _MAX_BACKOFF_S. It also bounds the cost // of a bogus/hostile retry_after: replies run on a small fixed worker pool, so // a parked handler holds one of very few slots and mentions start being // dropped that much sooner. if seconds > 5 { seconds = 5 } return time.Duration(seconds * float64(time.Second)) } // handleMessage triages one MESSAGE_CREATE and sends whatever reply it calls // for. Runs in its own goroutine (see dispatch) so a slow grade lookup can // never stall heartbeats or the read loop. func (b *Bot) handleMessage(ctx context.Context, m *gwMessage, botID string) { act, query := triage(m, botID) switch act { case actSilent: case actHelp: body, err := json.Marshal(map[string]string{"content": helpText}) if err != nil { return } b.reply(ctx, string(m.ChannelID), string(m.ID), body) case actGrade: // The callback owns all grading; its JSON is relayed VERBATIM (no // parsing, no reshaping) so the bot's grades can never drift from // the API's. A failed callback stays silent — better no reply than a // made-up one. raw, err := b.api.Grade(ctx, query) if err != nil { log.Printf("gateway: grade callback failed for %q: %v", query, err) return } b.reply(ctx, string(m.ChannelID), string(m.ID), raw) } }