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 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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue