eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

1package bot
2
3import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8)
9
10func mustRecipe(t *testing.T, src string) *Recipe {
11 t.Helper()
12 path := filepath.Join(t.TempDir(), "r.recipe.json")
13 if err := os.WriteFile(path, []byte(src), 0o600); err != nil {
14 t.Fatal(err)
15 }
16 r, err := LoadRecipe(path)
17 if err != nil {
18 t.Fatalf("LoadRecipe: %v", err)
19 }
20 return r
21}
22
23func loadErr(t *testing.T, src string) error {
24 t.Helper()
25 path := filepath.Join(t.TempDir(), "r.recipe.json")
26 if err := os.WriteFile(path, []byte(src), 0o600); err != nil {
27 t.Fatal(err)
28 }
29 _, err := LoadRecipe(path)
30 return err
31}
32
33// TestRecipeValidation pins fail-closed: everything outside the palette is a
34// load error, never a silently different behaviour.
35func TestRecipeValidation(t *testing.T) {
36 bad := map[string]string{
37 "empty rules": `{"rules":[]}`,
38 "unknown trigger": `{"rules":[{"on":"webhook","action":"reply","text":"x"}]}`,
39 "unknown action": `{"rules":[{"on":"message","action":"deploy"}]}`,
40 "unknown match": `{"rules":[{"on":"message","match":"sometimes","action":"commands"}]}`,
41 "reply without text": `{"rules":[{"on":"message","action":"reply"}]}`,
42 "commands with text": `{"rules":[{"on":"message","action":"commands","text":"x"}]}`,
43 "bad regex": `{"rules":[{"on":"message","action":"reply","text":"x","regex":"("}]}`,
44 "every on message": `{"rules":[{"on":"message","action":"commands","every":"5m"}]}`,
45 "changelog with text": `{"rules":[{"on":"changelog","action":"announce","text":"x"}]}`,
46 "changelog bad action": `{"rules":[{"on":"changelog","action":"reply"}]}`,
47 "changelog bad every": `{"rules":[{"on":"changelog","action":"announce","every":"soon"}]}`,
48 "two changelog rules": `{"rules":[{"on":"changelog","action":"announce"},{"on":"changelog","action":"announce"}]}`,
49 }
50 for name, src := range bad {
51 if loadErr(t, src) == nil {
52 t.Errorf("%s: expected a load error", name)
53 }
54 }
55
56 // Missing file is "no recipe", not an error.
57 if r, err := LoadRecipe(filepath.Join(t.TempDir(), "absent.json")); r != nil || err != nil {
58 t.Fatalf("missing file: %v %v", r, err)
59 }
60}
61
62func TestRecipeHandler(t *testing.T) {
63 // The inclusivity-nudge watcher over a command floor — the docs' canonical
64 // recipe (docs/bots.md § Capability tiers).
65 r := mustRecipe(t, `{"rules":[
66 {"on":"message","match":"any","regex":"(?i)\\bguys\\b","action":"reply","text":"Hey {name} — how about \"folks\"?"},
67 {"on":"message","action":"commands"}
68 ]}`)
69 if !r.Watches() {
70 t.Fatal("match:any recipe must report Watches")
71 }
72 h := r.handler("Eleven", false)
73
74 // Watcher fires on an unaddressed group message, template fills the sender.
75 rep, ok := h(nil, Incoming{Kind: "group", Sender: "Paul", Text: "morning guys", Triggered: false})
76 if !ok || rep.Text != `Hey Paul — how about "folks"?` {
77 t.Fatalf("nudge: ok=%v %+v", ok, rep)
78 }
79 // Unaddressed, no keyword → silent (commands rule is mention-gated).
80 if _, ok := h(nil, Incoming{Kind: "group", Sender: "Paul", Text: "morning all", Triggered: false}); ok {
81 t.Fatal("unaddressed non-match must stay silent")
82 }
83 // Addressed command answers via the floor (mention stripped first).
84 rep, ok = h(nil, Incoming{Kind: "group", Sender: "Paul", Text: "@Eleven /dark", Triggered: true})
85 if !ok || rep.Actions[0]["appearance"] != "dark" {
86 t.Fatalf("command via recipe: ok=%v %+v", ok, rep)
87 }
88 // Addressed non-command falls through the commands rule to the hint.
89 rep, ok = h(nil, Incoming{Kind: "dm", Sender: "Paul", Text: "hello", Triggered: true})
90 if !ok || rep.Text != degradeText {
91 t.Fatalf("degrade via recipe: ok=%v %+v", ok, rep)
92 }
93 // Rule order: an addressed message that ALSO matches the watcher gets the
94 // first rule's action, not the floor.
95 rep, _ = h(nil, Incoming{Kind: "group", Sender: "Paul", Text: "@Eleven you guys ok?", Triggered: true})
96 if !strings.Contains(rep.Text, "folks") {
97 t.Fatalf("first matching rule must win: %+v", rep)
98 }
99
100 // Keyword gate is case-insensitive substring.
101 r2 := mustRecipe(t, `{"rules":[{"on":"message","match":"any","keyword":"standup","action":"reply","text":"Logged."}]}`)
102 if _, ok := r2.handler("Bot", false)(nil, Incoming{Text: "STANDUP done", Sender: "P"}); !ok {
103 t.Fatal("keyword must match case-insensitively")
104 }
105 if r2.Watches() != true {
106 t.Fatal("watcher flag")
107 }
108
109 // The default recipe is mention-gated commands — never a watcher.
110 d := DefaultRecipe()
111 if err := d.validate(); err != nil {
112 t.Fatalf("default recipe must validate: %v", err)
113 }
114 if d.Watches() {
115 t.Fatal("default recipe must not watch")
116 }
117 if _, ok := d.handler("Bot", false)(nil, Incoming{Kind: "group", Text: "/help", Triggered: false}); ok {
118 t.Fatal("default recipe must ignore unaddressed messages")
119 }
120}
121
122// The changelog trigger stays a supported recipe primitive after the default
123// Announcer's retirement: an operator can still write a custom deploy watcher.
124// A parsed announce recipe validates, gets the default poll interval, and —
125// using no model — stays deterministic (badges BOT).
126func TestChangelogRecipeStillSupported(t *testing.T) {
127 r := mustRecipe(t, `{"rules":[{"on":"changelog","action":"announce"}]}`)
128 if r.usesModel() {
129 t.Error("an announce recipe must not use the model")
130 }
131 var hasChangelog bool
132 for _, rule := range r.Rules {
133 if rule.On == "changelog" && rule.Action == "announce" {
134 hasChangelog = true
135 if rule.every <= 0 {
136 t.Error("announce recipe has no poll interval")
137 }
138 }
139 }
140 if !hasChangelog {
141 t.Error("parsed recipe missing the changelog rule")
142 }
143}
144