package bot import ( "regexp" "strings" ) // The deterministic command floor: the Go port of agent/commands.mjs — the // slash-commands a helper answers WITHOUT a model, mapping text to the same // client-whitelisted one-tap action chips the LLM can propose, so "/dark" (no // AI) and "make it dark" (AI) reach the identical chip. // // The command NAMES + help below mirror web/static/bot-commands.js — the one // source of truth the browser typeahead and the Node sidecar share. Go can't // import that module, so TestCommandsMatchJS parses it and fails the build on // drift, same pattern as the e2ee test vectors. var botCommands = []struct{ Name, Help string }{ {"/help", "what I can do"}, {"/theme", "switch colour theme, e.g. /theme lagoon"}, {"/themes", "browse the theme picker"}, {"/dark", "switch to dark mode"}, {"/light", "switch to light mode"}, {"/profile", "edit your screen name & photo"}, } // Mirrors agent/actions.mjs THEMES (drift-checked alongside the commands). var themes = []string{"litroom", "lagoon", "sunny", "plum", "linen", "classic"} // Human-friendly theme names for the reply text (the chip carries the id). var themeLabels = map[string]string{ "litroom": "Lit Room (warm amber)", "lagoon": "Lagoon (teal)", "sunny": "Sunny (yellow)", "plum": "Plum (purple)", "linen": "Linen (soft neutral)", "classic": "Classic (blue)", } // RunCommand answers a registered command, or ok=false when the text isn't one // (no leading "/", or a name we don't own — "/unknown …" falls through to // normal chat, exactly like the Node floor). func RunCommand(text string, aiEnabled bool) (Reply, bool) { t := strings.TrimSpace(text) if !strings.HasPrefix(t, "/") { return Reply{}, false } name, args, _ := strings.Cut(t, " ") name = strings.ToLower(name) args = strings.TrimSpace(args) switch name { case "/help": return Reply{Text: helpText(aiEnabled)}, true case "/theme": return themeCmd(args), true case "/themes": return Reply{Text: "Here you go — tap to browse themes.", Actions: []map[string]any{{"type": "open_theme_picker"}}}, true case "/dark": return appearanceCmd("dark"), true case "/light": return appearanceCmd("light"), true case "/profile": return Reply{Text: "Here you go — tap to open your profile.", Actions: []map[string]any{{"type": "open_profile"}}}, true } return Reply{}, false } func themeCmd(args string) Reply { name := strings.ToLower(strings.TrimSpace(args)) if label, ok := themeLabels[name]; ok { return Reply{Text: "Here you go — tap to switch to " + label + ".", Actions: []map[string]any{{"type": "set_theme", "theme": name}}} } // No/unknown theme → list the options and offer the picker. return Reply{Text: "Themes: " + strings.Join(themes, ", ") + ". Tap to browse, or try e.g. /theme lagoon.", Actions: []map[string]any{{"type": "open_theme_picker"}}} } func appearanceCmd(mode string) Reply { return Reply{Text: "Here you go — tap to switch to " + mode + " mode.", Actions: []map[string]any{{"type": "set_appearance", "appearance": mode}}} } func helpText(aiEnabled bool) string { var b strings.Builder b.WriteString("Here's what I can do right now:\n") for _, c := range botCommands { b.WriteString("• " + c.Name + " — " + c.Help + "\n") } if aiEnabled { b.WriteString("\nOr just ask me anything in your own words — I'll do my best to help.") } else { b.WriteString("\nConversational AI answers aren't enabled on this server, so I stick to the commands above.") } return b.String() } // degradeText is the reply when there's no model brain and the message wasn't // a command. const degradeText = "I can help with a few quick commands — try /help. (Conversational AI answers aren't enabled on this server.)" // 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(*ThreadCtx, Incoming) (Reply, bool) { strip := mentionStripper(name) return func(_ *ThreadCtx, in Incoming) (Reply, bool) { if !in.Triggered { return Reply{}, false } if rep, ok := RunCommand(strip(in.Text), aiEnabled); ok { return rep, true } return Reply{Text: degradeText}, true } }