eleven / messenger Public

server: bot recipes — behaviour as data for the Go runtime (#307)

Recipe schema v1 (docs/bot-recipes.md): an ordered trigger→action rule list
from a bounded palette — message (trigger/any + keyword/regex gates) →
commands/reply, changelog → announce — validated fail-closed, one
<session>.recipe.json per identity, watchers loudly flagged (Recipe.Watches).
The Engine folds message rules into the Runtime's Handler seam and runs one
poller per changelog rule, so lchat bot run is now an engine executing config
(the docs/bots.md 'recipe-shaped from day one' decision) and the Announcer is
expressible as a recipe.

Runtime change to enable watchers: the Handler now sees every person-authored
message with a Triggered flag (DM/@mention) instead of being gated up front;
CommandFloor checks the flag, so default behaviour is unchanged.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 6cd43c8c10f4e950d1a87a9622d7792bc07ea0fa parent fc11fcd
10 files changed, +631 −41
  • bot_run_test.go +90 −0
  • botcmd.go +28 −14
  • docs/bot-recipes.md +101 −0
  • docs/bots.md +8 −3
  • internal/bot/announcer.go +9 −3
  • internal/bot/commands.go +18 −8
  • internal/bot/commands_test.go +7 −3
  • internal/bot/recipe.go +230 −0
  • internal/bot/recipe_test.go +120 −0
  • internal/bot/runtime.go +20 −10
diff --git a/bot_run_test.go b/bot_run_test.go
index cb5ad2a..947ed0d 100644
--- a/bot_run_test.go
+++ b/bot_run_test.go
@@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"net/http"
+ "os"
"strings"
"testing"
"time"
@@ -213,3 +214,92 @@ func TestBotRunIgnoresOtherAgents(t *testing.T) {
t.Fatalf("agent-authored mention must be ignored; got %+v", replies)
}
}
+
+// TestBotEngineRecipe drives a recipe end-to-end: a watcher rule nudges on a
+// keyword in an unaddressed group message, the commands rule still answers
+// mentions, and a changelog rule announces fresh entries — one Engine, one
+// identity, all three behaviours live.
+func TestBotEngineRecipe(t *testing.T) {
+ ts, s := newTestServer(t)
+ if len(s.changelog) < 1 {
+ t.Fatal("embedded changelog is empty")
+ }
+ owner := provisionNamed(t, ts.URL, "Owner", false)
+ eleven := provisionNamed(t, ts.URL, "Eleven", true)
+ groupID, groupKey := createGroupWith(t, ts.URL, s, owner, eleven)
+
+ dir := t.TempDir()
+ sessionPath := dir + "/eleven.session.json"
+ if err := eleven.Save(sessionPath); err != nil {
+ t.Fatal(err)
+ }
+ recipeSrc := `{"rules":[
+ {"on":"message","match":"any","regex":"(?i)\\bguys\\b","action":"reply","text":"Hey {name} — how about \"folks\"?"},
+ {"on":"message","action":"commands"},
+ {"on":"changelog","action":"announce","every":"50ms"}
+ ]}`
+ if err := os.WriteFile(bot.RecipePathFor(sessionPath), []byte(recipeSrc), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ recipe, err := bot.LoadRecipe(bot.RecipePathFor(sessionPath))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ eng := &bot.Engine{Base: ts.URL, Session: eleven, SessionPath: sessionPath, Recipe: recipe, Logf: t.Logf}
+ go eng.Run(ctx)
+
+ // The changelog poller baselines silently on its first tick.
+ statePath := bot.StatePathFor(sessionPath)
+ waitFor(t, "announce baseline", func() bool { _, err := os.Stat(statePath); return err == nil })
+
+ uid, _ := s.db.deviceTokenUser(eleven.Token)
+ waitFor(t, "bot to connect", func() bool {
+ var kind string
+ s.db.sql.QueryRow("SELECT COALESCE(kind, '') FROM bot_configs WHERE user_id = ?", uid).Scan(&kind)
+ return kind == "bot"
+ })
+
+ // Unaddressed keyword → the watcher's templated nudge.
+ postAs(t, ts.URL, owner, groupID, groupKey, "morning guys")
+ waitFor(t, "nudge", func() bool { return len(botReplies(t, s, groupID, groupKey, "Eleven")) > 0 })
+ replies := botReplies(t, s, groupID, groupKey, "Eleven")
+ if replies[0].Text != "Hey Owner — how about \"folks\"?" {
+ t.Fatalf("bad nudge: %+v", replies[0])
+ }
+
+ // Mentioned command still reaches the floor through the recipe.
+ postAs(t, ts.URL, owner, groupID, groupKey, "@Eleven /dark")
+ waitFor(t, "command reply", func() bool { return len(botReplies(t, s, groupID, groupKey, "Eleven")) >= 2 })
+ replies = botReplies(t, s, groupID, groupKey, "Eleven")
+ if replies[1].Actions[0]["appearance"] != "dark" {
+ t.Fatalf("bad command reply: %+v", replies[1])
+ }
+
+ // Forget the newest changelog entry → the poller announces it within a tick.
+ raw, err := os.ReadFile(statePath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var state struct {
+ Announced []string `json:"announced"`
+ }
+ if err := json.Unmarshal(raw, &state); err != nil {
+ t.Fatal(err)
+ }
+ state.Announced = state.Announced[:len(state.Announced)-1]
+ raw, _ = json.Marshal(state)
+ if err := os.WriteFile(statePath, raw, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ waitFor(t, "announcement", func() bool {
+ for _, r := range botReplies(t, s, groupID, groupKey, "Eleven") {
+ if strings.Contains(r.Text, "🚀 Just shipped:") {
+ return true
+ }
+ }
+ return false
+ })
+}
diff --git a/botcmd.go b/botcmd.go
index 3ec7950..d26701a 100644
--- a/botcmd.go
+++ b/botcmd.go
@@ -86,19 +86,20 @@ func runBotAnnounce(args []string) {
}
return
}
- a.Run(*poll)
+ a.Run(context.Background(), *poll)
}
// runBotRun is the live runtime (agent/runtime.mjs's core in Go): connect over
-// WebSocket as one or more provisioned identities and answer whatever triggers
-// them — a DM, or an @mention in a group. Today the brain is the deterministic
-// command floor (slash-commands + a hint), exactly what the Node sidecar
-// answers with no ANTHROPIC_API_KEY; the recipe engine and the model layer
-// plug into the same Handler seam next (docs/bots.md).
+// WebSocket as one or more provisioned identities and run each one's recipe
+// (docs/bot-recipes.md) — behaviour as data from a bounded trigger→action
+// palette. A bot with no recipe file gets the default: answer the
+// deterministic command floor when addressed, exactly what the Node sidecar
+// does with no ANTHROPIC_API_KEY. The model layer plugs into the same seam
+// next (docs/bots.md).
//
// One process, many bots: each session file on the command line becomes one
-// identity with its own connection, so a single `lchat bot run` replaces a
-// whole sidecar's worth of Node.
+// identity with its own connection and recipe (<session>.recipe.json), so a
+// single `lchat bot run` replaces a whole sidecar's worth of Node.
func runBotRun(args []string) {
fs := flag.NewFlagSet("bot run", flag.ExitOnError)
base := fs.String("base", envOr("LCHAT_BASE", ""), "instance URL (env: LCHAT_BASE)")
@@ -119,14 +120,27 @@ func runBotRun(args []string) {
if sess == nil {
die("bot run: no session at %s — provision it first (lchat bot provision)", path)
}
- rt := &bot.Runtime{
- Base: strings.TrimRight(*base, "/"),
- Session: sess,
- Handler: bot.CommandFloor(sess.Name, false),
- Logf: log.Printf,
+ // A broken recipe stops the bot (fail closed) — silently falling back
+ // to the default would run behaviour the operator didn't write.
+ recipe, err := bot.LoadRecipe(bot.RecipePathFor(path))
+ if err != nil {
+ die("bot run: %v", err)
+ }
+ if recipe == nil {
+ recipe = bot.DefaultRecipe()
+ } else {
+ log.Printf("bot run: %s follows %s%s", sess.Name, bot.RecipePathFor(path),
+ map[bool]string{true: " (watches every message)", false: ""}[recipe.Watches()])
+ }
+ eng := &bot.Engine{
+ Base: strings.TrimRight(*base, "/"),
+ Session: sess,
+ SessionPath: path,
+ Recipe: recipe,
+ Logf: log.Printf,
}
go func() {
- if err := rt.Run(context.Background()); err != nil {
+ if err := eng.Run(context.Background()); err != nil {
log.Printf("bot run: %s: %v", sess.Name, err)
}
}()
diff --git a/docs/bot-recipes.md b/docs/bot-recipes.md
new file mode 100644
index 0000000..b74d98a
--- /dev/null
+++ b/docs/bot-recipes.md
@@ -0,0 +1,101 @@
+# 🤖 Bot recipes: behaviour as data
+
+A **recipe** is a bot's behaviour written as data — an ordered list of
+trigger→action rules from a small, bounded palette — executed by the Go bot
+runtime (`lchat bot run`, `internal/bot/recipe.go`). It's the concrete form of
+the decision in `docs/bots.md` that the Go runtime should be *recipe-shaped
+from day one*: the engine executes config, so declarative bots (and eventually
+the admin console's "describe a bot" front-end) need no code and no second
+rewrite.
+
+Recipes have a **sibling schema, deliberately not unified**:
+`docs/conversation-scripts.md` — Eleven's onboarding conversations as data,
+interpreted *client-side* per platform. Conversation scripts choreograph a UI
+the member is looking at; recipes run headless in a sidecar. They share a
+philosophy (flows as data, closed capability sets, fail-closed loading), not a
+language.
+
+## The palette is the capability boundary
+
+A recipe can only express what the schema names. That's the point: an action
+the palette doesn't have *cannot be written down*, so a recipe is auditable by
+reading it, and the plain-English builder planned in `docs/bots.md`
+§ "Creating bots in plain English" can only ever emit things an operator could
+have typed. Out-of-palette ideas ("delete messages", "email people") have no
+spelling. Growth happens by adding verbs to the schema — a code change, a
+review — never by a recipe smuggling one in.
+
+Loading **fails closed**: a recipe that doesn't validate stops the bot with an
+error. It never falls back to different behaviour the operator didn't write.
+
+## Schema v1
+
+A JSON file next to the bot's session file — `<session>.recipe.json`, the same
+sibling convention as the announcer's `.state.json`. No recipe file means the
+**default recipe**: answer the deterministic command floor when addressed
+(exactly the keyless Node sidecar's behaviour).
+
+```json
+{
+ "rules": [
+ { "on": "message", "match": "any", "regex": "(?i)\\bguys\\b",
+ "action": "reply", "text": "Hey {name} — how about \"folks\"?" },
+ { "on": "message", "action": "commands" },
+ { "on": "changelog", "action": "announce", "every": "5m" }
+ ]
+}
+```
+
+Rules are evaluated **in order; the first matching rule acts** and the rest
+are skipped.
+
+### Triggers (`on`)
+
+- **`message`** — an incoming chat message the bot can read. Refinements:
+ - `match` — who the rule listens to. `"trigger"` (the default) fires only
+ when the bot is *addressed*: a DM, or a group @mention. `"any"` fires on
+ every message — the loud **watcher** mode (below).
+ - `keyword` — case-insensitive substring gate on the text.
+ - `regex` — RE2 gate. Both set = both must match.
+- **`changelog`** — new entries in the instance's embedded changelog (the
+ Announcer's trigger). `every` sets the poll interval (Go duration, default
+ `5m`). At most one changelog rule per recipe — one announce memory per bot.
+
+### Actions (`action`)
+
+- **`commands`** — answer the deterministic command floor (`/help`, `/theme`,
+ `/dark`, …, `internal/bot/commands.go`). A non-command falls through to
+ later rules; if nothing else acts and the bot was addressed, it answers with
+ the "try /help" hint. Message rules only.
+- **`reply`** — post `text` in the thread, with `{name}` replaced by the
+ sender's name. Deterministic, no model call — per `docs/bots.md`, a fixed
+ template is often the *best* form for a nudge. Message rules only.
+- **`announce`** — post fresh changelog entries to every thread the bot is in
+ (the full Announcer behaviour, including the silent first-run baseline).
+ Changelog rules only.
+
+Planned, not in v1: a `model` action (phrase the response with the LLM — the
+runtime's model layer), a `schedule` trigger, and a private-DM nudge variant.
+Each lands as a schema addition with its own review, per the boundary above.
+
+## Watchers are loud
+
+`match: "any"` is the **"reads every message in this group"** mode from
+`docs/bots.md` § Consent — the exception, never the default. The engine
+surfaces it (`Recipe.Watches()`; `lchat bot run` logs *"watches every
+message"* at startup), and the consent story rides on it: adding a watcher to
+a group is the consent moment, and the admin console should show the flag
+wherever the bot appears. Detection stays in the sidecar — with a
+deterministic `reply` action, a watched message **never leaves the box at
+all**; when the `model` action exists, only *matches* ever reach the provider.
+
+## Execution
+
+`lchat bot run -base <url> a.session.json b.session.json …` loads each
+session's recipe (or the default), then runs one `bot.Engine` per identity:
+the live WebSocket runtime with the message rules folded into its Handler
+seam, plus one poller per changelog rule. One process, many bots, zero Node.
+
+Announcer-as-recipe replaces `lchat bot announce -poll` for long-running
+setups (`announce` remains for cron-style one-shots); both share the same
+`.state.json`, so switching modes never replays entries.
diff --git a/docs/bots.md b/docs/bots.md
index 33c9904..8c96c3b 100644
--- a/docs/bots.md
+++ b/docs/bots.md
@@ -128,9 +128,14 @@ answers exactly like a keyless Node one. One process hosts many identities
(one session file per bot on the command line), and one deliberate divergence:
the Go runtime answers the *first* message of a newly-seen thread (Node
baselines the cursor past it and stays silent). Remaining for full Node
-parity: the recipe schema + engine in that Handler seat (next), then the model
-layer (Claude API + Eleven's persona; welcome stays client-side, see
-`docs/conversation-scripts.md`).
+parity: the model layer (Claude API + Eleven's persona; welcome stays
+client-side, see `docs/conversation-scripts.md`). **And recipes**: the runtime
+is now recipe-shaped (`internal/bot/recipe.go`, schema + rationale in
+`docs/bot-recipes.md`) — behaviour as an ordered trigger→action rule list from
+a bounded palette (message × trigger/any/keyword/regex → commands/reply;
+changelog → announce), validated fail-closed, one `<session>.recipe.json` per
+identity, watcher rules loudly flagged. The Announcer is expressible as a
+recipe, and the planned `model` action is where the Claude layer lands.
## What exists today (`agent/`)
diff --git a/internal/bot/announcer.go b/internal/bot/announcer.go
index 00e4efb..cce704d 100644
--- a/internal/bot/announcer.go
+++ b/internal/bot/announcer.go
@@ -1,6 +1,7 @@
package bot
import (
+ "context"
"encoding/json"
"fmt"
"net/http"
@@ -127,13 +128,18 @@ func (a *Announcer) Tick() error {
return nil
}
-// Run is the poll loop. Tick errors are logged and retried next tick.
-func (a *Announcer) Run(interval time.Duration) {
+// Run is the poll loop, until ctx is cancelled. Tick errors are logged and
+// retried next tick.
+func (a *Announcer) Run(ctx context.Context, interval time.Duration) {
for {
if err := a.Tick(); err != nil {
a.logf("tick failed: %v", err)
}
- time.Sleep(interval)
+ select {
+ case <-time.After(interval):
+ case <-ctx.Done():
+ return
+ }
}
}
diff --git a/internal/bot/commands.go b/internal/bot/commands.go
index ee84653..c087cf8 100644
--- a/internal/bot/commands.go
+++ b/internal/bot/commands.go
@@ -100,16 +100,26 @@ func helpText(aiEnabled bool) string {
// a command.
const degradeText = "I can help with a few quick commands — try /help. (Conversational AI answers aren't enabled on this server.)"
-// CommandFloor is the deterministic Handler: commands answered in place,
-// anything else gets the no-AI hint — the exact behaviour of the Node sidecar
-// running without an ANTHROPIC_API_KEY.
-func CommandFloor(name string, aiEnabled bool) func(Incoming) (Reply, bool) {
- // Strip a leading "@Name" so a group command ("@Eleven /help") parses; a
- // bare command in a DM passes through unchanged.
+// mentionStripper removes a leading "@Name" so a group command
+// ("@Eleven /help") parses; a bare command in a DM passes through unchanged.
+func mentionStripper(name string) func(string) string {
strip := regexp.MustCompile(`(?i)^\s*@` + regexp.QuoteMeta(name) + `\b[:,]?\s*`)
+ return func(text string) string {
+ return strings.TrimSpace(strip.ReplaceAllString(text, ""))
+ }
+}
+
+// CommandFloor is the deterministic Handler: when addressed, commands are
+// answered in place and anything else gets the no-AI hint — the exact
+// behaviour of the Node sidecar running without an ANTHROPIC_API_KEY.
+// (Recipes fold the same floor in via the "commands" action.)
+func CommandFloor(name string, aiEnabled bool) func(Incoming) (Reply, bool) {
+ strip := mentionStripper(name)
return func(in Incoming) (Reply, bool) {
- text := strings.TrimSpace(strip.ReplaceAllString(in.Text, ""))
- if rep, ok := RunCommand(text, aiEnabled); ok {
+ if !in.Triggered {
+ return Reply{}, false
+ }
+ if rep, ok := RunCommand(strip(in.Text), aiEnabled); ok {
return rep, true
}
return Reply{Text: degradeText}, true
diff --git a/internal/bot/commands_test.go b/internal/bot/commands_test.go
index be4205c..540e9fd 100644
--- a/internal/bot/commands_test.go
+++ b/internal/bot/commands_test.go
@@ -83,16 +83,20 @@ func TestRunCommand(t *testing.T) {
func TestCommandFloor(t *testing.T) {
h := CommandFloor("Eleven", false)
// Group command with the mention prefix.
- rep, ok := h(Incoming{Kind: "group", Text: "@Eleven /dark"})
+ rep, ok := h(Incoming{Kind: "group", Text: "@Eleven /dark", Triggered: true})
if !ok || rep.Actions[0]["appearance"] != "dark" {
t.Fatalf("mention + command: ok=%v rep=%+v", ok, rep)
}
// Bare command in a DM.
- if rep, _ := h(Incoming{Kind: "dm", Text: "/light"}); rep.Actions[0]["appearance"] != "light" {
+ if rep, _ := h(Incoming{Kind: "dm", Text: "/light", Triggered: true}); rep.Actions[0]["appearance"] != "light" {
t.Fatalf("dm command: %+v", rep)
}
// Not a command → the deterministic hint.
- if rep, _ := h(Incoming{Kind: "dm", Text: "hi there"}); rep.Text != degradeText {
+ if rep, _ := h(Incoming{Kind: "dm", Text: "hi there", Triggered: true}); rep.Text != degradeText {
t.Fatalf("degrade: %+v", rep)
}
+ // Unaddressed messages never reach the floor's mouth.
+ if _, ok := h(Incoming{Kind: "group", Text: "/help"}); ok {
+ t.Fatal("unaddressed message must stay silent")
+ }
}
diff --git a/internal/bot/recipe.go b/internal/bot/recipe.go
new file mode 100644
index 0000000..c607b39
--- /dev/null
+++ b/internal/bot/recipe.go
@@ -0,0 +1,230 @@
+package bot
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "os"
+ "regexp"
+ "strings"
+ "time"
+)
+
+// Recipe schema v1 (docs/bot-recipes.md): a bot's behaviour as data — an
+// ordered list of trigger→action rules from a bounded, admin-auditable palette
+// (docs/bots.md § Governance), executed by Engine. Declarative bots need no
+// code, and the palette is the capability boundary: an action the schema
+// doesn't name cannot be expressed, so a recipe can never escalate a bot
+// beyond what the operator can read off this file.
+//
+// Sibling schema, deliberately not unified: docs/conversation-scripts.md
+// (client-executed onboarding flows). Recipes are sidecar-executed.
+type Recipe struct {
+ Rules []Rule `json:"rules"`
+}
+
+// Rule is one trigger→action pair. Rules are evaluated in order; the first
+// rule whose trigger matches acts, and the rest are skipped.
+type Rule struct {
+ // On is the trigger: "message" (an incoming chat message) or "changelog"
+ // (new entries in the instance's embedded changelog — the Announcer).
+ On string `json:"on"`
+
+ // Message-trigger refinements. Match is who the rule listens to:
+ // "trigger" (default) fires only when the bot is addressed — a DM, or a
+ // group @mention; "any" fires on every message the bot can read — the
+ // loud, consent-gated watcher mode (docs/bots.md § Consent). Keyword
+ // (case-insensitive substring) and Regex (RE2) further gate the text;
+ // both set means both must match.
+ Match string `json:"match,omitempty"`
+ Keyword string `json:"keyword,omitempty"`
+ Regex string `json:"regex,omitempty"`
+
+ // Action is what a matched rule does: "commands" (answer the deterministic
+ // command floor; non-commands fall through to later rules), "reply" (post
+ // Text, with {name} replaced by the sender's name), or "announce" (post
+ // fresh changelog entries — changelog trigger only).
+ Action string `json:"action"`
+ Text string `json:"text,omitempty"`
+
+ // Every is the changelog poll interval (Go duration, default 5m).
+ Every string `json:"every,omitempty"`
+
+ re *regexp.Regexp
+ every time.Duration
+}
+
+// RecipePathFor mirrors the .state.json convention: the recipe rides next to
+// the session file it configures.
+func RecipePathFor(sessionPath string) string { return sessionPath + ".recipe.json" }
+
+// DefaultRecipe is a bot with no recipe file: answer the command floor when
+// addressed — exactly the keyless Node sidecar's behaviour.
+func DefaultRecipe() *Recipe {
+ return &Recipe{Rules: []Rule{{On: "message", Action: "commands"}}}
+}
+
+// LoadRecipe reads and validates a recipe file. A missing file returns
+// (nil, nil) — "no recipe" is a normal state (the caller falls back to
+// DefaultRecipe). Anything else fails closed: a recipe that doesn't validate
+// must stop the bot, never silently degrade to different behaviour.
+func LoadRecipe(path string) (*Recipe, error) {
+ raw, err := os.ReadFile(path)
+ if os.IsNotExist(err) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ var r Recipe
+ if err := json.Unmarshal(raw, &r); err != nil {
+ return nil, fmt.Errorf("bad recipe %s: %w", path, err)
+ }
+ if err := r.validate(); err != nil {
+ return nil, fmt.Errorf("bad recipe %s: %w", path, err)
+ }
+ return &r, nil
+}
+
+// validate enforces the palette. Unknown triggers, actions, or fields that
+// don't belong together are errors — the schema is the capability boundary,
+// so nothing unrecognised may pass.
+func (r *Recipe) validate() error {
+ if len(r.Rules) == 0 {
+ return fmt.Errorf("no rules")
+ }
+ changelogs := 0
+ for i := range r.Rules {
+ rule := &r.Rules[i]
+ switch rule.On {
+ case "message":
+ switch rule.Match {
+ case "", "trigger", "any":
+ default:
+ return fmt.Errorf("rule %d: unknown match %q", i, rule.Match)
+ }
+ switch rule.Action {
+ case "commands":
+ if rule.Text != "" {
+ return fmt.Errorf("rule %d: commands takes no text", i)
+ }
+ case "reply":
+ if rule.Text == "" {
+ return fmt.Errorf("rule %d: reply needs text", i)
+ }
+ default:
+ return fmt.Errorf("rule %d: unknown message action %q", i, rule.Action)
+ }
+ if rule.Regex != "" {
+ re, err := regexp.Compile(rule.Regex)
+ if err != nil {
+ return fmt.Errorf("rule %d: %w", i, err)
+ }
+ rule.re = re
+ }
+ if rule.Every != "" {
+ return fmt.Errorf("rule %d: every belongs to changelog rules", i)
+ }
+ case "changelog":
+ changelogs++
+ if changelogs > 1 {
+ return fmt.Errorf("rule %d: at most one changelog rule (one announce state per bot)", i)
+ }
+ if rule.Action != "announce" {
+ return fmt.Errorf("rule %d: unknown changelog action %q", i, rule.Action)
+ }
+ if rule.Match != "" || rule.Keyword != "" || rule.Regex != "" || rule.Text != "" {
+ return fmt.Errorf("rule %d: changelog rules take only every", i)
+ }
+ rule.every = 5 * time.Minute
+ if rule.Every != "" {
+ d, err := time.ParseDuration(rule.Every)
+ if err != nil || d <= 0 {
+ return fmt.Errorf("rule %d: bad every %q", i, rule.Every)
+ }
+ rule.every = d
+ }
+ default:
+ return fmt.Errorf("rule %d: unknown trigger %q", i, rule.On)
+ }
+ }
+ return nil
+}
+
+// Watches reports whether any rule reads beyond addressed messages — the flag
+// that carries the watcher consent weight (docs/bots.md § Consent).
+func (r *Recipe) Watches() bool {
+ for _, rule := range r.Rules {
+ if rule.On == "message" && rule.Match == "any" {
+ return true
+ }
+ }
+ return false
+}
+
+// handler folds the message rules into the Runtime's Handler seam. First
+// matching rule acts. A "commands" rule that was addressed but got a
+// non-command lets later rules try, then falls back to the no-AI hint — the
+// same UX as the plain command floor.
+func (r *Recipe) handler(botName string, aiEnabled bool) func(Incoming) (Reply, bool) {
+ strip := mentionStripper(botName)
+ return func(in Incoming) (Reply, bool) {
+ sawCommands := false
+ for _, rule := range r.Rules {
+ if rule.On != "message" {
+ continue
+ }
+ if rule.Match != "any" && !in.Triggered {
+ continue
+ }
+ text := strip(in.Text)
+ if rule.Keyword != "" && !strings.Contains(strings.ToLower(in.Text), strings.ToLower(rule.Keyword)) {
+ continue
+ }
+ if rule.re != nil && !rule.re.MatchString(in.Text) {
+ continue
+ }
+ switch rule.Action {
+ case "commands":
+ if rep, ok := RunCommand(text, aiEnabled); ok {
+ return rep, true
+ }
+ sawCommands = true // not a command — later rules may still act
+ case "reply":
+ return Reply{Text: strings.ReplaceAll(rule.Text, "{name}", in.Sender)}, true
+ }
+ }
+ if sawCommands && in.Triggered {
+ return Reply{Text: degradeText}, true
+ }
+ return Reply{}, false
+ }
+}
+
+// Engine runs one bot from its recipe: the live Runtime with the folded
+// message rules, plus a poller per changelog rule. This is the whole of
+// `lchat bot run` for one identity.
+type Engine struct {
+ Base string
+ Session *Session
+ SessionPath string // for the announce state file (StatePathFor)
+ Recipe *Recipe
+ Logf func(format string, args ...any)
+}
+
+func (e *Engine) Run(ctx context.Context) error {
+ for _, rule := range e.Recipe.Rules {
+ if rule.On != "changelog" {
+ continue
+ }
+ a := &Announcer{Base: e.Base, Session: e.Session, StatePath: StatePathFor(e.SessionPath), Logf: e.Logf}
+ go a.Run(ctx, rule.every)
+ }
+ rt := &Runtime{
+ Base: e.Base,
+ Session: e.Session,
+ Handler: e.Recipe.handler(e.Session.Name, false),
+ Logf: e.Logf,
+ }
+ return rt.Run(ctx)
+}
diff --git a/internal/bot/recipe_test.go b/internal/bot/recipe_test.go
new file mode 100644
index 0000000..abb4adc
--- /dev/null
+++ b/internal/bot/recipe_test.go
@@ -0,0 +1,120 @@
+package bot
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func mustRecipe(t *testing.T, src string) *Recipe {
+ t.Helper()
+ path := filepath.Join(t.TempDir(), "r.recipe.json")
+ if err := os.WriteFile(path, []byte(src), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ r, err := LoadRecipe(path)
+ if err != nil {
+ t.Fatalf("LoadRecipe: %v", err)
+ }
+ return r
+}
+
+func loadErr(t *testing.T, src string) error {
+ t.Helper()
+ path := filepath.Join(t.TempDir(), "r.recipe.json")
+ if err := os.WriteFile(path, []byte(src), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ _, err := LoadRecipe(path)
+ return err
+}
+
+// TestRecipeValidation pins fail-closed: everything outside the palette is a
+// load error, never a silently different behaviour.
+func TestRecipeValidation(t *testing.T) {
+ bad := map[string]string{
+ "empty rules": `{"rules":[]}`,
+ "unknown trigger": `{"rules":[{"on":"webhook","action":"reply","text":"x"}]}`,
+ "unknown action": `{"rules":[{"on":"message","action":"deploy"}]}`,
+ "unknown match": `{"rules":[{"on":"message","match":"sometimes","action":"commands"}]}`,
+ "reply without text": `{"rules":[{"on":"message","action":"reply"}]}`,
+ "commands with text": `{"rules":[{"on":"message","action":"commands","text":"x"}]}`,
+ "bad regex": `{"rules":[{"on":"message","action":"reply","text":"x","regex":"("}]}`,
+ "every on message": `{"rules":[{"on":"message","action":"commands","every":"5m"}]}`,
+ "changelog with text": `{"rules":[{"on":"changelog","action":"announce","text":"x"}]}`,
+ "changelog bad action": `{"rules":[{"on":"changelog","action":"reply"}]}`,
+ "changelog bad every": `{"rules":[{"on":"changelog","action":"announce","every":"soon"}]}`,
+ "two changelog rules": `{"rules":[{"on":"changelog","action":"announce"},{"on":"changelog","action":"announce"}]}`,
+ }
+ for name, src := range bad {
+ if loadErr(t, src) == nil {
+ t.Errorf("%s: expected a load error", name)
+ }
+ }
+
+ // Missing file is "no recipe", not an error.
+ if r, err := LoadRecipe(filepath.Join(t.TempDir(), "absent.json")); r != nil || err != nil {
+ t.Fatalf("missing file: %v %v", r, err)
+ }
+}
+
+func TestRecipeHandler(t *testing.T) {
+ // The inclusivity-nudge watcher over a command floor — the docs' canonical
+ // recipe (docs/bots.md § Capability tiers).
+ r := mustRecipe(t, `{"rules":[
+ {"on":"message","match":"any","regex":"(?i)\\bguys\\b","action":"reply","text":"Hey {name} — how about \"folks\"?"},
+ {"on":"message","action":"commands"}
+ ]}`)
+ if !r.Watches() {
+ t.Fatal("match:any recipe must report Watches")
+ }
+ h := r.handler("Eleven", false)
+
+ // Watcher fires on an unaddressed group message, template fills the sender.
+ rep, ok := h(Incoming{Kind: "group", Sender: "Paul", Text: "morning guys", Triggered: false})
+ if !ok || rep.Text != `Hey Paul — how about "folks"?` {
+ t.Fatalf("nudge: ok=%v %+v", ok, rep)
+ }
+ // Unaddressed, no keyword → silent (commands rule is mention-gated).
+ if _, ok := h(Incoming{Kind: "group", Sender: "Paul", Text: "morning all", Triggered: false}); ok {
+ t.Fatal("unaddressed non-match must stay silent")
+ }
+ // Addressed command answers via the floor (mention stripped first).
+ rep, ok = h(Incoming{Kind: "group", Sender: "Paul", Text: "@Eleven /dark", Triggered: true})
+ if !ok || rep.Actions[0]["appearance"] != "dark" {
+ t.Fatalf("command via recipe: ok=%v %+v", ok, rep)
+ }
+ // Addressed non-command falls through the commands rule to the hint.
+ rep, ok = h(Incoming{Kind: "dm", Sender: "Paul", Text: "hello", Triggered: true})
+ if !ok || rep.Text != degradeText {
+ t.Fatalf("degrade via recipe: ok=%v %+v", ok, rep)
+ }
+ // Rule order: an addressed message that ALSO matches the watcher gets the
+ // first rule's action, not the floor.
+ rep, _ = h(Incoming{Kind: "group", Sender: "Paul", Text: "@Eleven you guys ok?", Triggered: true})
+ if !strings.Contains(rep.Text, "folks") {
+ t.Fatalf("first matching rule must win: %+v", rep)
+ }
+
+ // Keyword gate is case-insensitive substring.
+ r2 := mustRecipe(t, `{"rules":[{"on":"message","match":"any","keyword":"standup","action":"reply","text":"Logged."}]}`)
+ if _, ok := r2.handler("Bot", false)(Incoming{Text: "STANDUP done", Sender: "P"}); !ok {
+ t.Fatal("keyword must match case-insensitively")
+ }
+ if r2.Watches() != true {
+ t.Fatal("watcher flag")
+ }
+
+ // The default recipe is mention-gated commands — never a watcher.
+ d := DefaultRecipe()
+ if err := d.validate(); err != nil {
+ t.Fatalf("default recipe must validate: %v", err)
+ }
+ if d.Watches() {
+ t.Fatal("default recipe must not watch")
+ }
+ if _, ok := d.handler("Bot", false)(Incoming{Kind: "group", Text: "/help", Triggered: false}); ok {
+ t.Fatal("default recipe must ignore unaddressed messages")
+ }
+}
diff --git a/internal/bot/runtime.go b/internal/bot/runtime.go
index 0da43e8..ed9c2a1 100644
--- a/internal/bot/runtime.go
+++ b/internal/bot/runtime.go
@@ -48,12 +48,16 @@ type Runtime struct {
conn *websocket.Conn
}
-// Incoming is one decrypted chat message that triggered the bot.
+// Incoming is one decrypted chat message. The Runtime hands the Handler every
+// person-authored message it can read (that's what lets watcher recipes exist);
+// Triggered says whether the bot was actually addressed — a DM, or a group
+// @mention — and mention-gated handlers must check it before acting.
type Incoming struct {
- ThreadID string
- Kind string // "dm" or "group"
- Sender string
- Text string // raw text, @mention included (see StripMention)
+ ThreadID string
+ Kind string // "dm" or "group"
+ Sender string
+ Text string // raw text, @mention included (see mentionStripper)
+ Triggered bool // a DM, or an explicit @mention in a group
}
// Reply is what a Handler posts back: text plus optional one-tap action chips
@@ -220,19 +224,25 @@ func (r *Runtime) processMsg(threadID string, id int64, blob string) {
if p.Type != "" {
return // a typed protocol payload (connect.*, app event), not chat text
}
- if !r.triggered(entry.kind, p.Text) || r.Handler == nil {
+ if r.Handler == nil {
return
}
- rep, ok := r.Handler(Incoming{ThreadID: threadID, Kind: entry.kind, Sender: p.Name, Text: p.Text})
+ rep, ok := r.Handler(Incoming{
+ ThreadID: threadID,
+ Kind: entry.kind,
+ Sender: p.Name,
+ Text: p.Text,
+ Triggered: r.triggered(entry.kind, p.Text),
+ })
if !ok {
return
}
r.postReply(threadID, entry.key, rep)
}
-// triggered: DMs always reply; groups only on an explicit @Name. The Node
-// runtime's listen-mode "name" (bare-name + classifier) needs the model brain,
-// so it arrives with the model layer.
+// triggered: DMs always address the bot; groups only on an explicit @Name. The
+// Node runtime's listen-mode "name" (bare-name + classifier) needs the model
+// brain, so it arrives with the model layer.
func (r *Runtime) triggered(kind, text string) bool {
if kind == "dm" {
return true