eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1package bot
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "os"
8 "regexp"
9 "strings"
10 "time"
11)
12
13// Recipe schema v1 (docs/bot-recipes.md): a bot's behaviour as data — an
14// ordered list of trigger→action rules from a bounded, admin-auditable palette
15// (docs/bots.md § Governance), executed by Engine. Declarative bots need no
16// code, and the palette is the capability boundary: an action the schema
17// doesn't name cannot be expressed, so a recipe can never escalate a bot
18// beyond what the operator can read off this file.
19//
20// Sibling schema, deliberately not unified: docs/conversation-scripts.md
21// (client-executed onboarding flows). Recipes are sidecar-executed.
22type Recipe struct {
23 Rules []Rule `json:"rules"`
24}
25
26// Rule is one trigger→action pair. Rules are evaluated in order; the first
27// rule whose trigger matches acts, and the rest are skipped.
28type Rule struct {
29 // On is the trigger: "message" (an incoming chat message) or "changelog"
30 // (new entries in the instance's embedded changelog — a custom deploy
31 // watcher; the retired default Announcer used this).
32 On string `json:"on"`
33
34 // Message-trigger refinements. Match is who the rule listens to:
35 // "trigger" (default) fires only when the bot is addressed — a DM, or a
36 // group @mention; "any" fires on every message the bot can read — the
37 // loud, consent-gated watcher mode (docs/bots.md § Consent). Keyword
38 // (case-insensitive substring) and Regex (RE2) further gate the text;
39 // both set means both must match.
40 Match string `json:"match,omitempty"`
41 Keyword string `json:"keyword,omitempty"`
42 Regex string `json:"regex,omitempty"`
43
44 // Action is what a matched rule does: "commands" (answer the deterministic
45 // command floor; non-commands fall through to later rules), "reply" (post
46 // Text, with {name} replaced by the sender's name), or "announce" (post
47 // fresh changelog entries — changelog trigger only).
48 Action string `json:"action"`
49 Text string `json:"text,omitempty"`
50
51 // Every is the changelog poll interval (Go duration, default 5m).
52 Every string `json:"every,omitempty"`
53
54 re *regexp.Regexp
55 every time.Duration
56}
57
58// RecipePathFor mirrors the .state.json convention: the recipe rides next to
59// the session file it configures.
60func RecipePathFor(sessionPath string) string { return sessionPath + ".recipe.json" }
61
62// DefaultRecipe is a bot with no recipe file: when addressed, answer the
63// command floor, and let the model take anything that isn't a command —
64// exactly the Node sidecar's behaviour with and without an ANTHROPIC_API_KEY
65// (the model rule falls through harmlessly when there's no key).
66func DefaultRecipe() *Recipe {
67 return &Recipe{Rules: []Rule{
68 {On: "message", Action: "commands"},
69 {On: "message", Action: "model"},
70 }}
71}
72
73// usesModel reports whether any rule can produce a model-generated reply — the
74// signal for the AI badge (a bot with a key but no model rule, like a
75// changelog watcher or a template-only bot, is deterministic and badges BOT).
76func (r *Recipe) usesModel() bool {
77 for _, rule := range r.Rules {
78 if rule.On == "message" && rule.Action == "model" {
79 return true
80 }
81 }
82 return false
83}
84
85// LoadRecipe reads and validates a recipe file. A missing file returns
86// (nil, nil) — "no recipe" is a normal state (the caller falls back to
87// DefaultRecipe). Anything else fails closed: a recipe that doesn't validate
88// must stop the bot, never silently degrade to different behaviour.
89func LoadRecipe(path string) (*Recipe, error) {
90 raw, err := os.ReadFile(path)
91 if os.IsNotExist(err) {
92 return nil, nil
93 }
94 if err != nil {
95 return nil, err
96 }
97 var r Recipe
98 if err := json.Unmarshal(raw, &r); err != nil {
99 return nil, fmt.Errorf("bad recipe %s: %w", path, err)
100 }
101 if err := r.validate(); err != nil {
102 return nil, fmt.Errorf("bad recipe %s: %w", path, err)
103 }
104 return &r, nil
105}
106
107// validate enforces the palette. Unknown triggers, actions, or fields that
108// don't belong together are errors — the schema is the capability boundary,
109// so nothing unrecognised may pass.
110func (r *Recipe) validate() error {
111 if len(r.Rules) == 0 {
112 return fmt.Errorf("no rules")
113 }
114 changelogs := 0
115 for i := range r.Rules {
116 rule := &r.Rules[i]
117 switch rule.On {
118 case "message":
119 switch rule.Match {
120 case "", "trigger", "any":
121 default:
122 return fmt.Errorf("rule %d: unknown match %q", i, rule.Match)
123 }
124 switch rule.Action {
125 case "commands", "model":
126 if rule.Text != "" {
127 return fmt.Errorf("rule %d: %s takes no text", i, rule.Action)
128 }
129 case "reply":
130 if rule.Text == "" {
131 return fmt.Errorf("rule %d: reply needs text", i)
132 }
133 default:
134 return fmt.Errorf("rule %d: unknown message action %q", i, rule.Action)
135 }
136 if rule.Regex != "" {
137 re, err := regexp.Compile(rule.Regex)
138 if err != nil {
139 return fmt.Errorf("rule %d: %w", i, err)
140 }
141 rule.re = re
142 }
143 if rule.Every != "" {
144 return fmt.Errorf("rule %d: every belongs to changelog rules", i)
145 }
146 case "changelog":
147 changelogs++
148 if changelogs > 1 {
149 return fmt.Errorf("rule %d: at most one changelog rule (one announce state per bot)", i)
150 }
151 if rule.Action != "announce" {
152 return fmt.Errorf("rule %d: unknown changelog action %q", i, rule.Action)
153 }
154 if rule.Match != "" || rule.Keyword != "" || rule.Regex != "" || rule.Text != "" {
155 return fmt.Errorf("rule %d: changelog rules take only every", i)
156 }
157 rule.every = 5 * time.Minute
158 if rule.Every != "" {
159 d, err := time.ParseDuration(rule.Every)
160 if err != nil || d <= 0 {
161 return fmt.Errorf("rule %d: bad every %q", i, rule.Every)
162 }
163 rule.every = d
164 }
165 default:
166 return fmt.Errorf("rule %d: unknown trigger %q", i, rule.On)
167 }
168 }
169 return nil
170}
171
172// Watches reports whether any rule reads beyond addressed messages — the flag
173// that carries the watcher consent weight (docs/bots.md § Consent).
174func (r *Recipe) Watches() bool {
175 for _, rule := range r.Rules {
176 if rule.On == "message" && rule.Match == "any" {
177 return true
178 }
179 }
180 return false
181}
182
183// handler folds the message rules into the Runtime's Handler seam. First
184// matching rule acts. A "commands" rule that was addressed but got a
185// non-command lets later rules try; if nothing else acts, an addressed
186// message falls back to the no-AI hint (only without a model — with one, a
187// failed model call stays silent, like the Node runtime).
188func (r *Recipe) handler(botName string, aiEnabled bool) func(*ThreadCtx, Incoming) (Reply, bool) {
189 strip := mentionStripper(botName)
190 return func(tc *ThreadCtx, in Incoming) (Reply, bool) {
191 sawCommands := false
192 for _, rule := range r.Rules {
193 if rule.On != "message" {
194 continue
195 }
196 if rule.Match != "any" && !in.Triggered {
197 continue
198 }
199 text := strip(in.Text)
200 if rule.Keyword != "" && !strings.Contains(strings.ToLower(in.Text), strings.ToLower(rule.Keyword)) {
201 continue
202 }
203 if rule.re != nil && !rule.re.MatchString(in.Text) {
204 continue
205 }
206 switch rule.Action {
207 case "commands":
208 if rep, ok := RunCommand(text, aiEnabled); ok {
209 return rep, true
210 }
211 sawCommands = true // not a command — later rules may still act
212 case "reply":
213 return Reply{Text: strings.ReplaceAll(rule.Text, "{name}", in.Sender)}, true
214 case "model":
215 if rep, ok := tc.ModelReply(); ok {
216 return rep, true
217 }
218 // No brain, or the call failed — later rules may still act.
219 }
220 }
221 if sawCommands && in.Triggered && !aiEnabled {
222 return Reply{Text: degradeText}, true
223 }
224 return Reply{}, false
225 }
226}
227
228// Engine runs one bot from its recipe: the live Runtime with the folded
229// message rules, plus a poller per changelog rule. This is the whole of
230// `lchat bot run` for one identity.
231type Engine struct {
232 Base string
233 Session *Session
234 SessionPath string // for the announce state file (StatePathFor)
235 Recipe *Recipe
236 // AIKey is the ANTHROPIC_API_KEY (empty = deterministic-only: model rules
237 // fall through, the bot badges BOT, and nothing ever leaves the box).
238 AIKey string
239 // Charters marks this identity as the sidecar's charter host — it runs
240 // member bots and offers the create_bot tool (docs/member-bots.md; elected
241 // in botcmd's runBotRun, exactly one per sidecar).
242 Charters bool
243 // Ready, if set, is forwarded to the Runtime (called once the bot is live).
244 Ready func()
245 Logf func(format string, args ...any)
246}
247
248func (e *Engine) Run(ctx context.Context) error {
249 for _, rule := range e.Recipe.Rules {
250 if rule.On != "changelog" {
251 continue
252 }
253 a := &Announcer{Base: e.Base, Session: e.Session, StatePath: StatePathFor(e.SessionPath), Logf: e.Logf}
254 go a.Run(ctx, rule.every)
255 }
256 // The AI brain is live only when the recipe can actually use it: a bot with
257 // a key but no model rule (the Announcer, a template-only watcher) is
258 // deterministic and must badge BOT, not AI.
259 aiKey := ""
260 if e.Recipe.usesModel() {
261 aiKey = e.AIKey
262 }
263 rt := &Runtime{
264 Base: e.Base,
265 Session: e.Session,
266 Handler: e.Recipe.handler(e.Session.Name, aiKey != ""),
267 AIKey: aiKey,
268 Charters: e.Charters,
269 Ready: e.Ready,
270 Logf: e.Logf,
271 }
272 return rt.Run(ctx)
273}
274