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
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:
parent
519541c378
commit
ce4350c64e
7 changed files with 124 additions and 17 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue