From ce4350c64ecd6d6aaa89bdd965ea93f63b463295 Mon Sep 17 00:00:00 2001 From: Emi Griffith Date: Fri, 24 Jul 2026 13:24:21 -0700 Subject: [PATCH] daemon: let the conversational agent own @mentions in server channels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit THERMOGRAPH_DISCORD_BOT_MENTIONS=0 makes the gateway bot silent on guild mentions. Default on, and unset counts as on, so every env file that predates the knob behaves exactly as before. The reason is a second responder. The operator's desktop now runs a conversational agent that replies as this same bot account (discord-voice-bot, AGENTS.md) and grades through the same API we do, so with both live a single "@Thermograph Phoenix" gets answered twice — once as a card, once as a reply. Only one of us can own that surface, and the agent is the one that can also hold a conversation. Scoped to guild mentions on purpose. DMs stay here: the agent polls guild channels only, so staying silent in a DM would just drop the message. And /grade is a signed HTTP interaction that never touched this path, so it keeps answering from prod while the desktop is asleep — which is what stops this from trading an always-on surface for a sometimes-on one. Claude-Session: https://claude.ai/code/session_015Z1ebLbhUxeZ9ozpNrVTCP --- backend/daemon/internal/config/config.go | 31 +++++++++++++ backend/daemon/internal/config/config_test.go | 28 ++++++++++++ backend/daemon/internal/gateway/gateway.go | 19 +++++--- backend/daemon/internal/gateway/message.go | 10 ++++- .../daemon/internal/gateway/message_test.go | 43 +++++++++++++++---- backend/daemon/internal/gateway/reply.go | 2 +- infra/deploy/thermograph.env.example | 8 ++++ 7 files changed, 124 insertions(+), 17 deletions(-) diff --git a/backend/daemon/internal/config/config.go b/backend/daemon/internal/config/config.go index c7d60e6..23e176c 100644 --- a/backend/daemon/internal/config/config.go +++ b/backend/daemon/internal/config/config.go @@ -47,6 +47,23 @@ type Config struct { // DiscordToken is the bot token; only meaningful when DiscordEnabled. DiscordToken string + // DiscordGuildMentions gates whether an @mention in a *server channel* gets + // a grade card from us. On by default — that is the behaviour this daemon + // shipped with. + // + // It exists because the same bot identity can be answered for from two + // places. The operator's desktop runs a conversational agent that replies as + // this account (see the discord-voice-bot repo, AGENTS.md); it grades through + // the same API we do, and can also hold a conversation. With both live, one + // mention gets two answers. Setting THERMOGRAPH_DISCORD_BOT_MENTIONS to a + // falsey value hands the channel surface to that agent. + // + // DMs are unaffected and always answered here: the agent polls guild + // channels only, so a DM has no other responder. The /grade slash command is + // a separate HTTP path and keeps working either way — which matters, because + // it is then what still answers while the desktop is asleep. + DiscordGuildMentions bool + WarmCitiesInterval time.Duration IndexNowInterval time.Duration } @@ -61,6 +78,17 @@ func truthy(s string) bool { return false } +// truthyDefault is truthy for a knob that has a non-false default: unset (or +// whitespace) keeps the default, anything else is read normally. Distinguishing +// "unset" from "set to something falsey" is the whole point — plain truthy() +// would silently read an absent var as off. +func truthyDefault(s string, def bool) bool { + if strings.TrimSpace(s) == "" { + return def + } + return truthy(s) +} + // intervalHours parses an interval env var. A malformed or non-positive value // falls back to the default rather than erroring — mirrors the Python // `int(os.environ.get(..., "24") or 24)` behaviour where a bad knob degrades @@ -102,6 +130,9 @@ func load(getenv func(string) string) (*Config, error) { // The gateway needs both the opt-in flag and a token; missing either just // disables it — the cron half is still worth running on its own. cfg.DiscordEnabled = truthy(getenv("THERMOGRAPH_DISCORD_BOT")) && cfg.DiscordToken != "" + // Defaults on when unset: this is a switch for turning existing behaviour + // off, so an env file that has never heard of it must keep working. + cfg.DiscordGuildMentions = truthyDefault(getenv("THERMOGRAPH_DISCORD_BOT_MENTIONS"), true) cfg.WarmCitiesInterval = intervalHours(getenv("THERMOGRAPH_WARM_CITIES_INTERVAL_HOURS"), DefaultWarmCitiesIntervalHours) cfg.IndexNowInterval = intervalHours(getenv("THERMOGRAPH_INDEXNOW_INTERVAL_HOURS"), DefaultIndexNowIntervalHours) diff --git a/backend/daemon/internal/config/config_test.go b/backend/daemon/internal/config/config_test.go index c5257c3..efcea7d 100644 --- a/backend/daemon/internal/config/config_test.go +++ b/backend/daemon/internal/config/config_test.go @@ -62,6 +62,34 @@ func TestTruthyParsing(t *testing.T) { } } +func TestGuildMentionsDefaultsOn(t *testing.T) { + // This knob turns existing behaviour off, so every env file that predates + // it — which is all of them — must keep answering mentions. + cases := []struct { + raw string + want bool + }{ + {"", true}, // unset means the default, not "off" + {" ", true}, // whitespace is still unset + {"1", true}, + {"true", true}, + {"on", true}, + {"0", false}, + {"false", false}, + {"no", false}, + {"off", false}, + } + for _, c := range cases { + cfg, err := load(env(map[string]string{"THERMOGRAPH_DISCORD_BOT_MENTIONS": c.raw})) + if err != nil { + t.Fatalf("raw=%q: unexpected error %v", c.raw, err) + } + if cfg.DiscordGuildMentions != c.want { + t.Errorf("raw=%q: DiscordGuildMentions=%v, want %v", c.raw, cfg.DiscordGuildMentions, c.want) + } + } +} + func TestIntervalDefaultsAndFallbacks(t *testing.T) { cases := []struct { name string diff --git a/backend/daemon/internal/gateway/gateway.go b/backend/daemon/internal/gateway/gateway.go index 123652e..86c2cb8 100644 --- a/backend/daemon/internal/gateway/gateway.go +++ b/backend/daemon/internal/gateway/gateway.go @@ -48,7 +48,9 @@ import ( // 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) + b := New(cfg.DiscordToken, api) + b.guildMentions = cfg.DiscordGuildMentions + return b.Run(ctx) } // v10 JSON gateway. On a resume we reconnect to the session's @@ -144,17 +146,22 @@ type Bot struct { 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) + + // guildMentions answers mentions in server channels. Defaults true via New; + // Run lowers it from config when the desktop agent owns that surface. + guildMentions bool } // 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}, + token: token, + api: api, + sem: make(chan struct{}, maxConcurrentReplies), + rest: discordAPIBase, + http: &http.Client{Timeout: 30 * time.Second}, + guildMentions: true, } } diff --git a/backend/daemon/internal/gateway/message.go b/backend/daemon/internal/gateway/message.go index 762f846..a93457e 100644 --- a/backend/daemon/internal/gateway/message.go +++ b/backend/daemon/internal/gateway/message.go @@ -97,7 +97,12 @@ func stripMentions(content, botID string) string { // reply quoting the mention must not trigger another reply). The text after // the mention is treated as a city query; DMs are the query as-is (trimmed // but not collapsed, matching the Python's .strip()). -func triage(m *gwMessage, botID string) (action, string) { +// +// guildMentions false keeps us silent on server-channel mentions while leaving +// DMs alone: the operator's conversational agent answers as this same identity, +// and both of us replying means one mention gets two answers. See +// config.DiscordGuildMentions for the full picture. +func triage(m *gwMessage, botID string, guildMentions bool) (action, string) { if m.Author.Bot || string(m.Author.ID) == botID { return actSilent, "" } @@ -105,6 +110,9 @@ func triage(m *gwMessage, botID string) (action, string) { if !dm && !mentionsBot(m, botID) { return actSilent, "" } + if !dm && !guildMentions { + return actSilent, "" + } var query string if dm { query = strings.TrimSpace(m.Content) diff --git a/backend/daemon/internal/gateway/message_test.go b/backend/daemon/internal/gateway/message_test.go index 294d363..1d391a9 100644 --- a/backend/daemon/internal/gateway/message_test.go +++ b/backend/daemon/internal/gateway/message_test.go @@ -52,21 +52,21 @@ func asDM() func(*gwMessage) { // --- when the bot stays silent ---------------------------------------------- func TestIgnoresOtherBots(t *testing.T) { - act, _ := triage(testMsg("<@999> Phoenix", fromID("8"), fromBot(), mentioning(botID)), botID) + act, _ := triage(testMsg("<@999> Phoenix", fromID("8"), fromBot(), mentioning(botID)), botID, true) if act != actSilent { t.Fatalf("bot author should be ignored, got action %d", act) } } func TestIgnoresItsOwnMessages(t *testing.T) { - act, _ := triage(testMsg("hello", fromID(botID), mentioning(botID)), botID) + act, _ := triage(testMsg("hello", fromID(botID), mentioning(botID)), botID, true) if act != actSilent { t.Fatalf("own message should be ignored, got action %d", act) } } func TestIgnoresGuildMessageWithoutAMention(t *testing.T) { - act, _ := triage(testMsg("just chatting"), botID) + act, _ := triage(testMsg("just chatting"), botID, true) if act != actSilent { t.Fatalf("unmentioned guild message should be ignored, got action %d", act) } @@ -75,21 +75,21 @@ func TestIgnoresGuildMessageWithoutAMention(t *testing.T) { // --- when the bot replies ---------------------------------------------------- func TestMentionWithCityGrades(t *testing.T) { - act, query := triage(testMsg("<@999> Phoenix", mentioning(botID)), botID) + act, query := triage(testMsg("<@999> Phoenix", mentioning(botID)), botID, true) if act != actGrade || query != "Phoenix" { t.Fatalf("want grade %q, got action %d query %q", "Phoenix", act, query) } } func TestLegacyNicknameMentionIsStripped(t *testing.T) { - act, query := triage(testMsg("<@!999> Tokyo", mentioning(botID)), botID) + act, query := triage(testMsg("<@!999> Tokyo", mentioning(botID)), botID, true) if act != actGrade || query != "Tokyo" { t.Fatalf("want grade %q, got action %d query %q", "Tokyo", act, query) } } func TestDMNeedsNoMention(t *testing.T) { - act, query := triage(testMsg("Berlin", asDM()), botID) + act, query := triage(testMsg("Berlin", asDM()), botID, true) if act != actGrade || query != "Berlin" { t.Fatalf("want grade %q, got action %d query %q", "Berlin", act, query) } @@ -98,14 +98,39 @@ func TestDMNeedsNoMention(t *testing.T) { func TestDMTrimsButKeepsInnerWhitespace(t *testing.T) { // Parity with the Python DM path (.strip(), not a collapse): only the // edges are trimmed. - act, query := triage(testMsg(" New York ", asDM()), botID) + act, query := triage(testMsg(" New York ", asDM()), botID, true) if act != actGrade || query != "New York" { t.Fatalf("want grade %q, got action %d query %q", "New York", act, query) } } +// --- handing the channel surface to the desktop agent ------------------------ + +func TestGuildMentionsOffStaysSilentInChannels(t *testing.T) { + // With the conversational agent live, a grade card here would be the second + // answer to the same mention. + act, _ := triage(testMsg("<@999> Phoenix", mentioning(botID)), botID, false) + if act != actSilent { + t.Fatalf("guild mention should be silent when the agent owns it, got action %d", act) + } + // The bare-mention help line is a reply too, and would double up the same way. + act, _ = triage(testMsg("<@999>", mentioning(botID)), botID, false) + if act != actSilent { + t.Fatalf("bare guild mention should be silent too, got action %d", act) + } +} + +func TestGuildMentionsOffStillAnswersDMs(t *testing.T) { + // The agent polls guild channels only, so a DM has no other responder — + // staying silent here would just drop the message. + act, query := triage(testMsg("Berlin", asDM()), botID, false) + if act != actGrade || query != "Berlin" { + t.Fatalf("DMs must keep working, got action %d query %q", act, query) + } +} + func TestMentionWithNoQueryGivesHelp(t *testing.T) { - act, query := triage(testMsg("<@999>", mentioning(botID)), botID) + act, query := triage(testMsg("<@999>", mentioning(botID)), botID, true) if act != actHelp || query != "" { t.Fatalf("want help, got action %d query %q", act, query) } @@ -143,7 +168,7 @@ func TestSnowflakeAcceptsStringOrNumber(t *testing.T) { if m.ID != "42" || string(m.Author.ID) != "1" { t.Fatalf("numeric ids mis-decoded: id=%q author=%q", m.ID, m.Author.ID) } - act, query := triage(&m, botID) + act, query := triage(&m, botID, true) if act != actGrade || query != "Lima" { t.Fatalf("want grade %q, got action %d query %q", "Lima", act, query) } diff --git a/backend/daemon/internal/gateway/reply.go b/backend/daemon/internal/gateway/reply.go index 0c7b52d..a1cede1 100644 --- a/backend/daemon/internal/gateway/reply.go +++ b/backend/daemon/internal/gateway/reply.go @@ -114,7 +114,7 @@ func retryAfter(h http.Header, body []byte) time.Duration { // 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) + act, query := triage(m, botID, b.guildMentions) switch act { case actSilent: case actHelp: diff --git a/infra/deploy/thermograph.env.example b/infra/deploy/thermograph.env.example index 7020e2d..5cadad6 100644 --- a/infra/deploy/thermograph.env.example +++ b/infra/deploy/thermograph.env.example @@ -232,3 +232,11 @@ THERMOGRAPH_BASE_URL=https://thermograph.org # content for mentions/DMs. Unset/0 => no gateway connection. # (Code: backend/daemon/, calling backend's /internal/discord/grade.) #THERMOGRAPH_DISCORD_BOT=1 +# Whether that gateway bot answers @mentions in SERVER CHANNELS. Default on +# (unset = on); set 0/false only once the operator's conversational agent is +# live, because it replies as this same bot account and both answering means one +# mention gets two replies. DMs are answered here regardless, and the /grade +# slash command is a separate HTTP path unaffected by this — which is what keeps +# grading available while the desktop running that agent is asleep. +# (Agent code: the discord-voice-bot repo, AGENTS.md.) +#THERMOGRAPH_DISCORD_BOT_MENTIONS=0