daemon: let the conversational agent own @mentions in server channels
All checks were successful
PR build (required check) / build-frontend (pull_request) Has been skipped
PR build (required check) / validate-observability (pull_request) Has been skipped
secrets-guard / encrypted (pull_request) Successful in 6s
PR build (required check) / changes (pull_request) Successful in 9s
shell-lint / shellcheck (pull_request) Successful in 6s
PR build (required check) / build-backend (pull_request) Successful in 1m10s
PR build (required check) / gate (pull_request) Successful in 2s

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
This commit is contained in:
Emi Griffith 2026-07-24 13:24:21 -07:00
parent 519541c378
commit ce4350c64e
7 changed files with 124 additions and 17 deletions

View file

@ -47,6 +47,23 @@ type Config struct {
// DiscordToken is the bot token; only meaningful when DiscordEnabled. // DiscordToken is the bot token; only meaningful when DiscordEnabled.
DiscordToken string 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 WarmCitiesInterval time.Duration
IndexNowInterval time.Duration IndexNowInterval time.Duration
} }
@ -61,6 +78,17 @@ func truthy(s string) bool {
return false 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 // intervalHours parses an interval env var. A malformed or non-positive value
// falls back to the default rather than erroring — mirrors the Python // falls back to the default rather than erroring — mirrors the Python
// `int(os.environ.get(..., "24") or 24)` behaviour where a bad knob degrades // `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 // 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. // disables it — the cron half is still worth running on its own.
cfg.DiscordEnabled = truthy(getenv("THERMOGRAPH_DISCORD_BOT")) && cfg.DiscordToken != "" 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.WarmCitiesInterval = intervalHours(getenv("THERMOGRAPH_WARM_CITIES_INTERVAL_HOURS"), DefaultWarmCitiesIntervalHours)
cfg.IndexNowInterval = intervalHours(getenv("THERMOGRAPH_INDEXNOW_INTERVAL_HOURS"), DefaultIndexNowIntervalHours) cfg.IndexNowInterval = intervalHours(getenv("THERMOGRAPH_INDEXNOW_INTERVAL_HOURS"), DefaultIndexNowIntervalHours)

View file

@ -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) { func TestIntervalDefaultsAndFallbacks(t *testing.T) {
cases := []struct { cases := []struct {
name string name string

View file

@ -48,7 +48,9 @@ import (
// cfg.DiscordEnabled — an unconfigured deploy never gets this far, so an // cfg.DiscordEnabled — an unconfigured deploy never gets this far, so an
// unconfigured deploy pays nothing. // unconfigured deploy pays nothing.
func Run(ctx context.Context, cfg *config.Config, api Grader) error { 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 // 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 sem chan struct{} // bounds concurrent MESSAGE_CREATE handlers
rest string // Discord REST base; swapped for a test server in tests rest string // Discord REST base; swapped for a test server in tests
http *http.Client // REST replies (not the gateway socket) 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 // New returns a Bot that authenticates with token and answers city queries
// through api. // through api.
func New(token string, api Grader) *Bot { func New(token string, api Grader) *Bot {
return &Bot{ return &Bot{
token: token, token: token,
api: api, api: api,
sem: make(chan struct{}, maxConcurrentReplies), sem: make(chan struct{}, maxConcurrentReplies),
rest: discordAPIBase, rest: discordAPIBase,
http: &http.Client{Timeout: 30 * time.Second}, http: &http.Client{Timeout: 30 * time.Second},
guildMentions: true,
} }
} }

View file

@ -97,7 +97,12 @@ func stripMentions(content, botID string) string {
// reply quoting the mention must not trigger another reply). The text after // 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 // the mention is treated as a city query; DMs are the query as-is (trimmed
// but not collapsed, matching the Python's .strip()). // 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 { if m.Author.Bot || string(m.Author.ID) == botID {
return actSilent, "" return actSilent, ""
} }
@ -105,6 +110,9 @@ func triage(m *gwMessage, botID string) (action, string) {
if !dm && !mentionsBot(m, botID) { if !dm && !mentionsBot(m, botID) {
return actSilent, "" return actSilent, ""
} }
if !dm && !guildMentions {
return actSilent, ""
}
var query string var query string
if dm { if dm {
query = strings.TrimSpace(m.Content) query = strings.TrimSpace(m.Content)

View file

@ -52,21 +52,21 @@ func asDM() func(*gwMessage) {
// --- when the bot stays silent ---------------------------------------------- // --- when the bot stays silent ----------------------------------------------
func TestIgnoresOtherBots(t *testing.T) { 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 { if act != actSilent {
t.Fatalf("bot author should be ignored, got action %d", act) t.Fatalf("bot author should be ignored, got action %d", act)
} }
} }
func TestIgnoresItsOwnMessages(t *testing.T) { 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 { if act != actSilent {
t.Fatalf("own message should be ignored, got action %d", act) t.Fatalf("own message should be ignored, got action %d", act)
} }
} }
func TestIgnoresGuildMessageWithoutAMention(t *testing.T) { func TestIgnoresGuildMessageWithoutAMention(t *testing.T) {
act, _ := triage(testMsg("just chatting"), botID) act, _ := triage(testMsg("just chatting"), botID, true)
if act != actSilent { if act != actSilent {
t.Fatalf("unmentioned guild message should be ignored, got action %d", act) 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 ---------------------------------------------------- // --- when the bot replies ----------------------------------------------------
func TestMentionWithCityGrades(t *testing.T) { 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" { if act != actGrade || query != "Phoenix" {
t.Fatalf("want grade %q, got action %d query %q", "Phoenix", act, query) t.Fatalf("want grade %q, got action %d query %q", "Phoenix", act, query)
} }
} }
func TestLegacyNicknameMentionIsStripped(t *testing.T) { 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" { if act != actGrade || query != "Tokyo" {
t.Fatalf("want grade %q, got action %d query %q", "Tokyo", act, query) t.Fatalf("want grade %q, got action %d query %q", "Tokyo", act, query)
} }
} }
func TestDMNeedsNoMention(t *testing.T) { 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" { if act != actGrade || query != "Berlin" {
t.Fatalf("want grade %q, got action %d query %q", "Berlin", act, query) 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) { func TestDMTrimsButKeepsInnerWhitespace(t *testing.T) {
// Parity with the Python DM path (.strip(), not a collapse): only the // Parity with the Python DM path (.strip(), not a collapse): only the
// edges are trimmed. // 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" { if act != actGrade || query != "New York" {
t.Fatalf("want grade %q, got action %d query %q", "New York", act, query) 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) { 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 != "" { if act != actHelp || query != "" {
t.Fatalf("want help, got action %d query %q", act, 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" { if m.ID != "42" || string(m.Author.ID) != "1" {
t.Fatalf("numeric ids mis-decoded: id=%q author=%q", m.ID, m.Author.ID) 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" { if act != actGrade || query != "Lima" {
t.Fatalf("want grade %q, got action %d query %q", "Lima", act, query) t.Fatalf("want grade %q, got action %d query %q", "Lima", act, query)
} }

View file

@ -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 // for. Runs in its own goroutine (see dispatch) so a slow grade lookup can
// never stall heartbeats or the read loop. // never stall heartbeats or the read loop.
func (b *Bot) handleMessage(ctx context.Context, m *gwMessage, botID string) { 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 { switch act {
case actSilent: case actSilent:
case actHelp: case actHelp:

View file

@ -232,3 +232,11 @@ THERMOGRAPH_BASE_URL=https://thermograph.org
# content for mentions/DMs. Unset/0 => no gateway connection. # content for mentions/DMs. Unset/0 => no gateway connection.
# (Code: backend/daemon/, calling backend's /internal/discord/grade.) # (Code: backend/daemon/, calling backend's /internal/discord/grade.)
#THERMOGRAPH_DISCORD_BOT=1 #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