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 "regexp"
5 "strings"
6)
7
8// The deterministic command floor: the Go port of agent/commands.mjs — the
9// slash-commands a helper answers WITHOUT a model, mapping text to the same
10// client-whitelisted one-tap action chips the LLM can propose, so "/dark" (no
11// AI) and "make it dark" (AI) reach the identical chip.
12//
13// The command NAMES + help below mirror web/static/bot-commands.js — the one
14// source of truth the browser typeahead and the Node sidecar share. Go can't
15// import that module, so TestCommandsMatchJS parses it and fails the build on
16// drift, same pattern as the e2ee test vectors.
17var botCommands = []struct{ Name, Help string }{
18 {"/help", "what I can do"},
19 {"/theme", "switch colour theme, e.g. /theme lagoon"},
20 {"/themes", "browse the theme picker"},
21 {"/dark", "switch to dark mode"},
22 {"/light", "switch to light mode"},
23 {"/profile", "edit your screen name & photo"},
24}
25
26// Mirrors agent/actions.mjs THEMES (drift-checked alongside the commands).
27var themes = []string{"litroom", "lagoon", "sunny", "plum", "linen", "classic"}
28
29// Human-friendly theme names for the reply text (the chip carries the id).
30var themeLabels = map[string]string{
31 "litroom": "Lit Room (warm amber)",
32 "lagoon": "Lagoon (teal)",
33 "sunny": "Sunny (yellow)",
34 "plum": "Plum (purple)",
35 "linen": "Linen (soft neutral)",
36 "classic": "Classic (blue)",
37}
38
39// RunCommand answers a registered command, or ok=false when the text isn't one
40// (no leading "/", or a name we don't own — "/unknown …" falls through to
41// normal chat, exactly like the Node floor).
42func RunCommand(text string, aiEnabled bool) (Reply, bool) {
43 t := strings.TrimSpace(text)
44 if !strings.HasPrefix(t, "/") {
45 return Reply{}, false
46 }
47 name, args, _ := strings.Cut(t, " ")
48 name = strings.ToLower(name)
49 args = strings.TrimSpace(args)
50 switch name {
51 case "/help":
52 return Reply{Text: helpText(aiEnabled)}, true
53 case "/theme":
54 return themeCmd(args), true
55 case "/themes":
56 return Reply{Text: "Here you go — tap to browse themes.",
57 Actions: []map[string]any{{"type": "open_theme_picker"}}}, true
58 case "/dark":
59 return appearanceCmd("dark"), true
60 case "/light":
61 return appearanceCmd("light"), true
62 case "/profile":
63 return Reply{Text: "Here you go — tap to open your profile.",
64 Actions: []map[string]any{{"type": "open_profile"}}}, true
65 }
66 return Reply{}, false
67}
68
69func themeCmd(args string) Reply {
70 name := strings.ToLower(strings.TrimSpace(args))
71 if label, ok := themeLabels[name]; ok {
72 return Reply{Text: "Here you go — tap to switch to " + label + ".",
73 Actions: []map[string]any{{"type": "set_theme", "theme": name}}}
74 }
75 // No/unknown theme → list the options and offer the picker.
76 return Reply{Text: "Themes: " + strings.Join(themes, ", ") + ". Tap to browse, or try e.g. /theme lagoon.",
77 Actions: []map[string]any{{"type": "open_theme_picker"}}}
78}
79
80func appearanceCmd(mode string) Reply {
81 return Reply{Text: "Here you go — tap to switch to " + mode + " mode.",
82 Actions: []map[string]any{{"type": "set_appearance", "appearance": mode}}}
83}
84
85func helpText(aiEnabled bool) string {
86 var b strings.Builder
87 b.WriteString("Here's what I can do right now:\n")
88 for _, c := range botCommands {
89 b.WriteString("• " + c.Name + " — " + c.Help + "\n")
90 }
91 if aiEnabled {
92 b.WriteString("\nOr just ask me anything in your own words — I'll do my best to help.")
93 } else {
94 b.WriteString("\nConversational AI answers aren't enabled on this server, so I stick to the commands above.")
95 }
96 return b.String()
97}
98
99// degradeText is the reply when there's no model brain and the message wasn't
100// a command.
101const degradeText = "I can help with a few quick commands — try /help. (Conversational AI answers aren't enabled on this server.)"
102
103// mentionStripper removes a leading "@Name" so a group command
104// ("@Eleven /help") parses; a bare command in a DM passes through unchanged.
105func mentionStripper(name string) func(string) string {
106 strip := regexp.MustCompile(`(?i)^\s*@` + regexp.QuoteMeta(name) + `\b[:,]?\s*`)
107 return func(text string) string {
108 return strings.TrimSpace(strip.ReplaceAllString(text, ""))
109 }
110}
111
112// CommandFloor is the deterministic Handler: when addressed, commands are
113// answered in place and anything else gets the no-AI hint — the exact
114// behaviour of the Node sidecar running without an ANTHROPIC_API_KEY.
115// (Recipes fold the same floor in via the "commands" action.)
116func CommandFloor(name string, aiEnabled bool) func(*ThreadCtx, Incoming) (Reply, bool) {
117 strip := mentionStripper(name)
118 return func(_ *ThreadCtx, in Incoming) (Reply, bool) {
119 if !in.Triggered {
120 return Reply{}, false
121 }
122 if rep, ok := RunCommand(strip(in.Text), aiEnabled); ok {
123 return rep, true
124 }
125 return Reply{Text: degradeText}, true
126 }
127}
128