eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Eleven's deterministic command floor: slash-commands it answers WITHOUT the
2// model. This is the "bot" half of a helper that upgrades to "ai" — the welcome
3// and these commands work with no ANTHROPIC_API_KEY, and conversational replies
4// layer on top when a key is present (see runtime.mjs). A command maps text to
5// { text, actions? }: the SAME one-tap action chips the LLM can propose
6// (actions.mjs), just triggered deterministically, so "make it dark" (AI) and
7// "/dark" (no AI) reach the identical chip. Pure + dependency-light so it's
8// unit-testable in Node (test/commands.test.mjs).
9import { THEMES, APPEARANCES } from "./actions.mjs";
10import { BOT_COMMANDS } from "../web/static/bot-commands.js";
11
12// Human-friendly theme names for the reply text (the chip itself carries the id).
13const THEME_LABELS = {
14 litroom: "Lit Room (warm amber)",
15 lagoon: "Lagoon (teal)",
16 sunny: "Sunny (yellow)",
17 plum: "Plum (purple)",
18 linen: "Linen (soft neutral)",
19 classic: "Classic (blue)",
20};
21
22function themeCmd(args) {
23 const name = (args || "").trim().toLowerCase();
24 if (THEMES.includes(name))
25 return { text: `Here you go — tap to switch to ${THEME_LABELS[name] || name}.`, actions: [{ type: "set_theme", theme: name }] };
26 // No/unknown theme → list the options and offer the picker.
27 return { text: `Themes: ${THEMES.join(", ")}. Tap to browse, or try e.g. /theme lagoon.`, actions: [{ type: "open_theme_picker" }] };
28}
29
30function appearanceCmd(mode) {
31 return { text: `Here you go — tap to switch to ${mode} mode.`, actions: [{ type: "set_appearance", appearance: mode }] };
32}
33
34// The deterministic run(args, ctx) -> { text, actions? } for each command. Pure:
35// no I/O, no model. `ctx.aiEnabled` only tweaks the /help wording (never gates a
36// chip). Keyed by the shared BOT_COMMANDS name list (web/static/bot-commands.js),
37// which the client also registers — so bot + client can't drift.
38const RUN = {
39 "/help": (_args, ctx) => ({ text: helpText(ctx) }),
40 "/theme": themeCmd,
41 "/themes": () => ({ text: "Here you go — tap to browse themes.", actions: [{ type: "open_theme_picker" }] }),
42 "/dark": () => appearanceCmd("dark"),
43 "/light": () => appearanceCmd("light"),
44 "/profile": () => ({ text: "Here you go — tap to open your profile.", actions: [{ type: "open_profile" }] }),
45};
46
47// name -> { help, run } — help from the shared list, run from above.
48export const COMMANDS = Object.fromEntries(
49 Object.entries(BOT_COMMANDS).map(([name, help]) => [name, { help, run: RUN[name] }]),
50);
51
52// A bullet list of the commands and what they do — shared by /help and Eleven's
53// own reference (eleven.mjs), so the LLM side knows the commands its bot side
54// answers and can point people at them. One source of truth, no drift.
55export function commandSummary() {
56 return Object.entries(COMMANDS)
57 .map(([name, c]) => `• ${name} — ${c.help}`)
58 .join("\n");
59}
60
61function helpText(ctx) {
62 return [
63 "Here's what I can do right now:",
64 commandSummary(),
65 ctx && ctx.aiEnabled
66 ? "\nOr just ask me anything in your own words — I'll do my best to help."
67 : "\nConversational AI answers aren't enabled on this server, so I stick to the commands above.",
68 ].join("\n");
69}
70
71// parseCommand splits "/theme lagoon" into { name:"/theme", args:"lagoon" }, or
72// returns null when the text isn't a registered command (no leading "/", or the
73// first word isn't one we own — so "/unknown …" falls through to normal chat).
74export function parseCommand(text) {
75 const t = (text || "").trim();
76 if (t[0] !== "/") return null;
77 const sp = t.indexOf(" ");
78 const name = (sp === -1 ? t : t.slice(0, sp)).toLowerCase();
79 if (!COMMANDS[name]) return null;
80 return { name, args: sp === -1 ? "" : t.slice(sp + 1).trim() };
81}
82
83// runCommand returns { text, actions } for a handled command, or null if the
84// text isn't one (the caller then falls back to the model, or degrades).
85export function runCommand(text, ctx = {}) {
86 const cmd = parseCommand(text);
87 if (!cmd) return null;
88 const res = COMMANDS[cmd.name].run(cmd.args, ctx);
89 return res || null;
90}
91
92// The registered command names, for typeahead / help surfaces later.
93export function commandNames() {
94 return Object.keys(COMMANDS);
95}
96