| 1 | package bot |
| 2 | |
| 3 | import ( |
| 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. |
| 17 | var 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). |
| 27 | var themes = []string{"litroom", "lagoon", "sunny", "plum", "linen", "classic"} |
| 28 | |
| 29 | // Human-friendly theme names for the reply text (the chip carries the id). |
| 30 | var 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). |
| 42 | func 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 | |
| 69 | func 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 | |
| 80 | func 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 | |
| 85 | func 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. |
| 101 | const 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. |
| 105 | func 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.) |
| 116 | func 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 | |