server: lchat bot run — the live Go bot runtime with a deterministic command floor (#303)
The persona-agnostic core of agent/runtime.mjs in Go (internal/bot/runtime.go): WebSocket with reconnect/backoff/keepalive, thread-key unwrap, cursor-gated idempotent delivery, catch-up after a blip, never-reply-to-agents, DM-always / group-@mention triggers. The brain sits behind one Handler seam — today the deterministic command floor (commands.go, the Go twin of agent/commands.mjs, drift-pinned to web/static/bot-commands.js by test), so a keyless Go bot answers exactly like a keyless Node one. One process hosts many identities. One deliberate divergence: the Go runtime answers the FIRST message of a newly-seen thread (Node baselines the cursor past it and drops it).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
7 files changed,
+912
−6
bot_provision_test.go+9 −2bot_run_test.go+215 −0botcmd.go+51 −1docs/bots.md+15 −3internal/bot/commands.go+117 −0internal/bot/commands_test.go+98 −0internal/bot/runtime.go+407 −0
diff --git a/bot_provision_test.go b/bot_provision_test.go| index 948e6ab..b0cb642 100644 |
| --- a/bot_provision_test.go |
| +++ b/bot_provision_test.go |
| @@ -15,6 +15,13 @@ import ( |
| // agent" is ticked: derive id + claim_hash from a fresh secret T client-side, |
| // then store them via the admin API. Returns T (the link #fragment). |
| func mintAgentInviteForTest(t *testing.T, tsURL string) string { |
| + return mintInviteForTest(t, tsURL, "TestBot", true) |
| +} |
| + |
| +// mintInviteForTest mints a labelled invite (agent or plain member). The label |
| +// matters to the runtime tests: it becomes the roster label, which is what the |
| +// bots-never-talk-to-bots rule matches payload names against. |
| +func mintInviteForTest(t *testing.T, tsURL, label string, agent bool) string { |
| t.Helper() |
| secret, err := e2ee.RandomSecret() |
| if err != nil { |
| @@ -25,10 +32,10 @@ func mintAgentInviteForTest(t *testing.T, tsURL string) string { |
| t.Fatal(err) |
| } |
| body, _ := json.Marshal(map[string]any{ |
| - "label": "TestBot", |
| + "label": label, |
| "id": inv.ID, |
| "claim_hash": inv.ClaimHash, |
| - "agent": true, |
| + "agent": agent, |
| }) |
| req, _ := http.NewRequest("POST", tsURL+"/api/admin/key-invites", bytes.NewReader(body)) |
| req.Header.Set("Authorization", "Bearer admintok") |
diff --git a/bot_run_test.go b/bot_run_test.go| new file mode 100644 |
| index 0000000..cb5ad2a |
| --- /dev/null |
| +++ b/bot_run_test.go |
| @@ -0,0 +1,215 @@ |
| +package main |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "encoding/json" |
| + "net/http" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "lchat/internal/bot" |
| + "lchat/internal/e2ee" |
| +) |
| + |
| +// provisionNamed provisions an identity whose roster label matches its payload |
| +// name — how real members and bots look. (provisionTestBot's shared "TestBot" |
| +// label would defeat the agent-skip rule these tests pin.) |
| +func provisionNamed(t *testing.T, tsURL, name string, agent bool) *bot.Session { |
| + t.Helper() |
| + secret := mintInviteForTest(t, tsURL, name, agent) |
| + sess, err := bot.Provision(tsURL, secret, name) |
| + if err != nil { |
| + t.Fatalf("provision %s: %v", name, err) |
| + } |
| + return sess |
| +} |
| + |
| +// createThreadKind is createGroupWith for an arbitrary thread kind ("dm" for |
| +// the bot-DM tests below). |
| +func createThreadKind(t *testing.T, tsURL string, s *Server, kind string, sessions ...*bot.Session) (string, []byte) { |
| + t.Helper() |
| + threadKey, err := e2ee.GenerateKey() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + members := []map[string]string{} |
| + for _, sess := range sessions { |
| + uid, _ := s.db.deviceTokenUser(sess.Token) |
| + wrapped, err := e2ee.WrapThreadKey(e2ee.B64uEncode(threadKey), sess.UserPub) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + members = append(members, map[string]string{"user_id": uid, "wrapped": wrapped}) |
| + } |
| + body, _ := json.Marshal(map[string]any{"token": sessions[0].Token, "kind": kind, "members": members}) |
| + resp, err := http.Post(tsURL+"/api/threads", "application/json", bytes.NewReader(body)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer resp.Body.Close() |
| + var out struct { |
| + ThreadID string `json:"thread_id"` |
| + } |
| + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil || out.ThreadID == "" { |
| + t.Fatalf("create %s thread: status %d err %v", kind, resp.StatusCode, err) |
| + } |
| + return out.ThreadID, threadKey |
| +} |
| + |
| +// postAs encrypts a plain chat payload under the thread key and posts it over |
| +// the REST doorway (stores + broadcasts, same path as a WS "msg" frame). |
| +func postAs(t *testing.T, tsURL string, sess *bot.Session, threadID string, threadKey []byte, text string) { |
| + t.Helper() |
| + blob, err := e2ee.EncryptJSON(threadKey, map[string]any{"name": sess.Name, "text": text, "ts": time.Now().UnixMilli()}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + body, _ := json.Marshal(map[string]string{"thread_id": threadID, "blob": blob}) |
| + resp, err := http.Post(tsURL+"/api/messages?token="+sess.Token, "application/json", bytes.NewReader(body)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + resp.Body.Close() |
| + if resp.StatusCode != http.StatusOK { |
| + t.Fatalf("post message: %d", resp.StatusCode) |
| + } |
| +} |
| + |
| +// botReplies returns the decrypted payloads authored by `name` in a thread. |
| +func botReplies(t *testing.T, s *Server, threadID string, threadKey []byte, name string) []struct { |
| + Text string |
| + Actions []map[string]any |
| +} { |
| + t.Helper() |
| + msgs, err := s.db.messagesAfter(threadID, 0, 200) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var out []struct { |
| + Text string |
| + Actions []map[string]any |
| + } |
| + for _, m := range msgs { |
| + var p struct { |
| + Name string `json:"name"` |
| + Text string `json:"text"` |
| + Actions []map[string]any `json:"actions"` |
| + } |
| + if e2ee.DecryptJSON(threadKey, m.Blob, &p) != nil || p.Name != name { |
| + continue |
| + } |
| + out = append(out, struct { |
| + Text string |
| + Actions []map[string]any |
| + }{p.Text, p.Actions}) |
| + } |
| + return out |
| +} |
| + |
| +func waitFor(t *testing.T, what string, cond func() bool) { |
| + t.Helper() |
| + deadline := time.Now().Add(5 * time.Second) |
| + for time.Now().Before(deadline) { |
| + if cond() { |
| + return |
| + } |
| + time.Sleep(20 * time.Millisecond) |
| + } |
| + t.Fatalf("timed out waiting for %s", what) |
| +} |
| + |
| +// TestBotRunCommandFloor drives the live runtime end-to-end against the real |
| +// router: a provisioned bot connects over WebSocket, and the deterministic |
| +// command floor answers a group @mention and a DM — with the non-mention group |
| +// message provably ignored (replies are processed in order, so one reply after |
| +// two posts means the first was skipped, no sleep needed). |
| +func TestBotRunCommandFloor(t *testing.T) { |
| + ts, s := newTestServer(t) |
| + owner := provisionNamed(t, ts.URL, "Owner", false) |
| + eleven := provisionNamed(t, ts.URL, "Eleven", true) |
| + groupID, groupKey := createGroupWith(t, ts.URL, s, owner, eleven) |
| + |
| + ctx, cancel := context.WithCancel(context.Background()) |
| + defer cancel() |
| + rt := &bot.Runtime{Base: ts.URL, Session: eleven, Handler: bot.CommandFloor("Eleven", false), Logf: t.Logf} |
| + go rt.Run(ctx) |
| + |
| + // declareKind runs during connect setup, so the stored kind doubles as |
| + // "the bot is connected and listening". |
| + uid, _ := s.db.deviceTokenUser(eleven.Token) |
| + waitFor(t, "bot to connect", func() bool { |
| + var kind string |
| + s.db.sql.QueryRow("SELECT COALESCE(kind, '') FROM bot_configs WHERE user_id = ?", uid).Scan(&kind) |
| + return kind == "bot" |
| + }) |
| + |
| + // A plain group message must not trigger the bot; the @mention after it must. |
| + postAs(t, ts.URL, owner, groupID, groupKey, "just chatting amongst ourselves") |
| + postAs(t, ts.URL, owner, groupID, groupKey, "@Eleven /theme lagoon") |
| + waitFor(t, "group reply", func() bool { return len(botReplies(t, s, groupID, groupKey, "Eleven")) > 0 }) |
| + replies := botReplies(t, s, groupID, groupKey, "Eleven") |
| + if len(replies) != 1 { |
| + t.Fatalf("got %d replies, want 1 (the plain message must be ignored)", len(replies)) |
| + } |
| + if !strings.Contains(replies[0].Text, "Lagoon") || |
| + len(replies[0].Actions) != 1 || replies[0].Actions[0]["theme"] != "lagoon" { |
| + t.Fatalf("bad /theme reply: %+v", replies[0]) |
| + } |
| + |
| + // A DM created while the bot is already connected exercises the |
| + // unknown-thread path — and its FIRST message must get a reply (the cursor |
| + // rewind in onFrame; the Node runtime drops this one). |
| + dmID, dmKey := createThreadKind(t, ts.URL, s, "dm", owner, eleven) |
| + postAs(t, ts.URL, owner, dmID, dmKey, "hello there") |
| + waitFor(t, "dm reply", func() bool { return len(botReplies(t, s, dmID, dmKey, "Eleven")) > 0 }) |
| + dmReplies := botReplies(t, s, dmID, dmKey, "Eleven") |
| + if !strings.Contains(dmReplies[0].Text, "/help") { |
| + t.Fatalf("dm to a no-AI bot should degrade to the commands hint: %+v", dmReplies[0]) |
| + } |
| + |
| + // Bare command in the DM answers deterministically. |
| + postAs(t, ts.URL, owner, dmID, dmKey, "/help") |
| + waitFor(t, "help reply", func() bool { return len(botReplies(t, s, dmID, dmKey, "Eleven")) >= 2 }) |
| + dmReplies = botReplies(t, s, dmID, dmKey, "Eleven") |
| + help := dmReplies[len(dmReplies)-1] |
| + if !strings.Contains(help.Text, "/theme") || !strings.Contains(help.Text, "aren't enabled") { |
| + t.Fatalf("bad /help reply: %+v", help) |
| + } |
| +} |
| + |
| +// TestBotRunIgnoresOtherAgents pins the bots-never-talk-to-bots rule: a message |
| +// authored under another agent's roster label gets no reply, even in a DM. |
| +func TestBotRunIgnoresOtherAgents(t *testing.T) { |
| + ts, s := newTestServer(t) |
| + owner := provisionNamed(t, ts.URL, "Owner", false) |
| + eleven := provisionNamed(t, ts.URL, "Eleven", true) |
| + announcer := provisionNamed(t, ts.URL, "Announcer", true) |
| + // All three share the group: the ping-pong risk the rule prevents only |
| + // exists between bots in the same thread — which is also what puts the |
| + // Announcer in Eleven's (connections-only) roster. |
| + threadID, threadKey := createThreadKind(t, ts.URL, s, "group", owner, eleven, announcer) |
| + |
| + ctx, cancel := context.WithCancel(context.Background()) |
| + defer cancel() |
| + rt := &bot.Runtime{Base: ts.URL, Session: eleven, Handler: bot.CommandFloor("Eleven", false), Logf: t.Logf} |
| + go rt.Run(ctx) |
| + |
| + uid, _ := s.db.deviceTokenUser(eleven.Token) |
| + waitFor(t, "bot to connect", func() bool { |
| + var kind string |
| + s.db.sql.QueryRow("SELECT COALESCE(kind, '') FROM bot_configs WHERE user_id = ?", uid).Scan(&kind) |
| + return kind == "bot" |
| + }) |
| + |
| + // An agent-authored mention must be skipped; prove it with an ordinary |
| + // mention after it that gets the (single) reply. |
| + postAs(t, ts.URL, announcer, threadID, threadKey, "@Eleven /help") |
| + postAs(t, ts.URL, owner, threadID, threadKey, "@Eleven /light") |
| + waitFor(t, "reply", func() bool { return len(botReplies(t, s, threadID, threadKey, "Eleven")) > 0 }) |
| + replies := botReplies(t, s, threadID, threadKey, "Eleven") |
| + if len(replies) != 1 || replies[0].Actions[0]["appearance"] != "light" { |
| + t.Fatalf("agent-authored mention must be ignored; got %+v", replies) |
| + } |
| +} |
diff --git a/botcmd.go b/botcmd.go| index a8982ba..3ec7950 100644 |
| --- a/botcmd.go |
| +++ b/botcmd.go |
| @@ -6,6 +6,7 @@ |
| package main |
| import ( |
| + "context" |
| "flag" |
| "fmt" |
| "log" |
| @@ -21,7 +22,8 @@ func runBotCmd(args []string) { |
| commands: |
| provision claim an agent invite and write the bot's session file |
| - announce post new changelog entries to the bot's threads (the Announcer)`) |
| + announce post new changelog entries to the bot's threads (the Announcer) |
| + run connect live and answer DMs/@mentions (deterministic command floor)`) |
| os.Exit(2) |
| } |
| switch args[0] { |
| @@ -29,6 +31,8 @@ commands: |
| runBotProvision(args[1:]) |
| case "announce": |
| runBotAnnounce(args[1:]) |
| + case "run": |
| + runBotRun(args[1:]) |
| default: |
| die("bot: unknown command %q", args[0]) |
| } |
| @@ -85,6 +89,52 @@ func runBotAnnounce(args []string) { |
| a.Run(*poll) |
| } |
| +// runBotRun is the live runtime (agent/runtime.mjs's core in Go): connect over |
| +// WebSocket as one or more provisioned identities and answer whatever triggers |
| +// them — a DM, or an @mention in a group. Today the brain is the deterministic |
| +// command floor (slash-commands + a hint), exactly what the Node sidecar |
| +// answers with no ANTHROPIC_API_KEY; the recipe engine and the model layer |
| +// plug into the same Handler seam next (docs/bots.md). |
| +// |
| +// One process, many bots: each session file on the command line becomes one |
| +// identity with its own connection, so a single `lchat bot run` replaces a |
| +// whole sidecar's worth of Node. |
| +func runBotRun(args []string) { |
| + fs := flag.NewFlagSet("bot run", flag.ExitOnError) |
| + base := fs.String("base", envOr("LCHAT_BASE", ""), "instance URL (env: LCHAT_BASE)") |
| + _ = fs.Parse(reorderFlagsFirst(fs, args)) |
| + |
| + if *base == "" { |
| + die("bot run: set -base (or LCHAT_BASE) to your instance URL") |
| + } |
| + paths := fs.Args() |
| + if len(paths) == 0 { |
| + paths = []string{envOr("LCHAT_SESSION", "session.json")} |
| + } |
| + for _, path := range paths { |
| + sess, err := bot.LoadSession(path) |
| + if err != nil { |
| + die("bot run: %v", err) |
| + } |
| + if sess == nil { |
| + die("bot run: no session at %s — provision it first (lchat bot provision)", path) |
| + } |
| + rt := &bot.Runtime{ |
| + Base: strings.TrimRight(*base, "/"), |
| + Session: sess, |
| + Handler: bot.CommandFloor(sess.Name, false), |
| + Logf: log.Printf, |
| + } |
| + go func() { |
| + if err := rt.Run(context.Background()); err != nil { |
| + log.Printf("bot run: %s: %v", sess.Name, err) |
| + } |
| + }() |
| + } |
| + log.Printf("bot run: %d identity(ies) connecting to %s", len(paths), *base) |
| + select {} // run until killed; each runtime reconnects on its own |
| +} |
| + |
| func runBotProvision(args []string) { |
| fs := flag.NewFlagSet("bot provision", flag.ExitOnError) |
| base := fs.String("base", envOr("LCHAT_BASE", ""), "instance URL, e.g. https://test.luan.chat (env: LCHAT_BASE)") |
diff --git a/docs/bots.md b/docs/bots.md| index 315e327..33c9904 100644 |
| --- a/docs/bots.md |
| +++ b/docs/bots.md |
| @@ -116,9 +116,21 @@ loop) is the full Go port of `announcer.mjs` — auto-provisions from |
| `LCHAT_ANNOUNCER_INVITE`, baselines silently on first run, batches new |
| changelog entries into one encrypted post per thread, self-declares kind |
| `bot`. Env names and the `.state.json` shape match the Node announcer, so a |
| -unit swap inherits identity *and* memory (no replayed entries). Remaining for |
| -full Node parity: the chat runtime (WS loop + Claude API) and Eleven's |
| -welcome/persona layer. |
| +unit swap inherits identity *and* memory (no replayed entries). **And the live |
| +runtime**: `lchat bot run` (`internal/bot/runtime.go`) is the persona-agnostic |
| +core of `runtime.mjs` in Go — WebSocket with reconnect/backoff/keepalive, |
| +thread-key unwrap, cursor-gated idempotent delivery, catch-up after a blip, |
| +never-reply-to-agents, DM-always / group-@mention triggers — with the brain |
| +behind a single `Handler` seam. Today that seam holds the **deterministic |
| +command floor** (`internal/bot/commands.go`, the Go twin of `commands.mjs`; |
| +drift-pinned to `web/static/bot-commands.js` by a test), so a keyless Go bot |
| +answers exactly like a keyless Node one. One process hosts many identities |
| +(one session file per bot on the command line), and one deliberate divergence: |
| +the Go runtime answers the *first* message of a newly-seen thread (Node |
| +baselines the cursor past it and stays silent). Remaining for full Node |
| +parity: the recipe schema + engine in that Handler seat (next), then the model |
| +layer (Claude API + Eleven's persona; welcome stays client-side, see |
| +`docs/conversation-scripts.md`). |
| ## What exists today (`agent/`) |
diff --git a/internal/bot/commands.go b/internal/bot/commands.go| new file mode 100644 |
| index 0000000..ee84653 |
| --- /dev/null |
| +++ b/internal/bot/commands.go |
| @@ -0,0 +1,117 @@ |
| +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.)" |
| + |
| +// CommandFloor is the deterministic Handler: commands answered in place, |
| +// anything else gets the no-AI hint — the exact behaviour of the Node sidecar |
| +// running without an ANTHROPIC_API_KEY. |
| +func CommandFloor(name string, aiEnabled bool) func(Incoming) (Reply, bool) { |
| + // Strip a leading "@Name" so a group command ("@Eleven /help") parses; a |
| + // bare command in a DM passes through unchanged. |
| + strip := regexp.MustCompile(`(?i)^\s*@` + regexp.QuoteMeta(name) + `\b[:,]?\s*`) |
| + return func(in Incoming) (Reply, bool) { |
| + text := strings.TrimSpace(strip.ReplaceAllString(in.Text, "")) |
| + if rep, ok := RunCommand(text, aiEnabled); ok { |
| + return rep, true |
| + } |
| + return Reply{Text: degradeText}, true |
| + } |
| +} |
diff --git a/internal/bot/commands_test.go b/internal/bot/commands_test.go| new file mode 100644 |
| index 0000000..be4205c |
| --- /dev/null |
| +++ b/internal/bot/commands_test.go |
| @@ -0,0 +1,98 @@ |
| +package bot |
| + |
| +import ( |
| + "os" |
| + "regexp" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// TestCommandsMatchJS pins the Go command floor to the JS sources of truth: |
| +// web/static/bot-commands.js (names + help, shared with the browser typeahead |
| +// and the Node sidecar) and agent/actions.mjs (the theme list). If either |
| +// changes, this fails and the Go mirror gets updated in the same PR — the same |
| +// drift-proofing pattern as the e2ee test vectors. |
| +func TestCommandsMatchJS(t *testing.T) { |
| + raw, err := os.ReadFile("../../web/static/bot-commands.js") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + // Entries look like: "/theme": "switch colour theme, e.g. /theme lagoon", |
| + re := regexp.MustCompile(`"(/[a-z]+)":\s*"([^"]*)"`) |
| + var js [][2]string |
| + for _, m := range re.FindAllStringSubmatch(string(raw), -1) { |
| + js = append(js, [2]string{m[1], m[2]}) |
| + } |
| + if len(js) != len(botCommands) { |
| + t.Fatalf("bot-commands.js has %d commands, Go floor has %d", len(js), len(botCommands)) |
| + } |
| + for i, c := range botCommands { |
| + if js[i][0] != c.Name || js[i][1] != c.Help { |
| + t.Errorf("command %d: js %q/%q, go %q/%q", i, js[i][0], js[i][1], c.Name, c.Help) |
| + } |
| + } |
| + |
| + raw, err = os.ReadFile("../../agent/actions.mjs") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + m := regexp.MustCompile(`export const THEMES = \[([^\]]*)\]`).FindStringSubmatch(string(raw)) |
| + if m == nil { |
| + t.Fatal("THEMES not found in actions.mjs") |
| + } |
| + var jsThemes []string |
| + for _, s := range strings.Split(m[1], ",") { |
| + if s = strings.Trim(strings.TrimSpace(s), `"`); s != "" { |
| + jsThemes = append(jsThemes, s) |
| + } |
| + } |
| + if strings.Join(jsThemes, ",") != strings.Join(themes, ",") { |
| + t.Errorf("themes drift: js %v, go %v", jsThemes, themes) |
| + } |
| + for _, th := range themes { |
| + if themeLabels[th] == "" { |
| + t.Errorf("theme %q has no label", th) |
| + } |
| + } |
| +} |
| + |
| +func TestRunCommand(t *testing.T) { |
| + if rep, ok := RunCommand("/theme lagoon", false); !ok || |
| + len(rep.Actions) != 1 || rep.Actions[0]["theme"] != "lagoon" { |
| + t.Fatalf("theme lagoon: ok=%v rep=%+v", ok, rep) |
| + } |
| + if rep, ok := RunCommand("/theme neon", false); !ok || |
| + rep.Actions[0]["type"] != "open_theme_picker" { |
| + t.Fatalf("unknown theme should offer the picker: ok=%v rep=%+v", ok, rep) |
| + } |
| + if _, ok := RunCommand("/unknown thing", false); ok { |
| + t.Fatal("unregistered command should fall through") |
| + } |
| + if _, ok := RunCommand("hello", false); ok { |
| + t.Fatal("plain text should fall through") |
| + } |
| + rep, ok := RunCommand("/help", false) |
| + if !ok || !strings.Contains(rep.Text, "/theme") || !strings.Contains(rep.Text, "aren't enabled") { |
| + t.Fatalf("help (no AI): %+v", rep) |
| + } |
| + if rep, _ := RunCommand("/help", true); !strings.Contains(rep.Text, "in your own words") { |
| + t.Fatalf("help (AI): %+v", rep) |
| + } |
| +} |
| + |
| +func TestCommandFloor(t *testing.T) { |
| + h := CommandFloor("Eleven", false) |
| + // Group command with the mention prefix. |
| + rep, ok := h(Incoming{Kind: "group", Text: "@Eleven /dark"}) |
| + if !ok || rep.Actions[0]["appearance"] != "dark" { |
| + t.Fatalf("mention + command: ok=%v rep=%+v", ok, rep) |
| + } |
| + // Bare command in a DM. |
| + if rep, _ := h(Incoming{Kind: "dm", Text: "/light"}); rep.Actions[0]["appearance"] != "light" { |
| + t.Fatalf("dm command: %+v", rep) |
| + } |
| + // Not a command → the deterministic hint. |
| + if rep, _ := h(Incoming{Kind: "dm", Text: "hi there"}); rep.Text != degradeText { |
| + t.Fatalf("degrade: %+v", rep) |
| + } |
| +} |
diff --git a/internal/bot/runtime.go b/internal/bot/runtime.go| new file mode 100644 |
| index 0000000..0da43e8 |
| --- /dev/null |
| +++ b/internal/bot/runtime.go |
| @@ -0,0 +1,407 @@ |
| +package bot |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "fmt" |
| + "net/http" |
| + "net/url" |
| + "regexp" |
| + "strings" |
| + "sync" |
| + "time" |
| + |
| + "github.com/coder/websocket" |
| + |
| + "lchat/internal/e2ee" |
| +) |
| + |
| +// Runtime is the Go port of agent/runtime.mjs's persona-agnostic core: one bot |
| +// identity's live connection to an instance. It dials the WebSocket, unwraps |
| +// thread keys, decrypts what arrives, and hands anything that triggers the bot |
| +// (a DM, or an @mention in a group) to Handler — the seam where a brain plugs |
| +// in. Today that's the deterministic command floor (CommandFloor); the recipe |
| +// engine and the model layer slot into the same seam next (docs/bots.md). |
| +// |
| +// One process hosts many bots: each Runtime is self-contained (own session, |
| +// socket, keys, cursors), so `lchat bot run` starts one goroutine per identity. |
| +// Like every bot, it is a pure outbound client — the private key in Session |
| +// never leaves this process, and the server only ever sees ciphertext. |
| +type Runtime struct { |
| + Base string |
| + Session *Session |
| + // Handler decides the response to a message that triggered the bot. |
| + // Return ok=false to stay silent. |
| + Handler func(Incoming) (Reply, bool) |
| + // Kind is what we self-declare to the server so the UI badges us honestly: |
| + // "bot" (deterministic, the default) or "ai" (model brain present). |
| + Kind string |
| + Logf func(format string, args ...any) |
| + |
| + client *http.Client |
| + keys map[string]threadEntry |
| + cursors map[string]int64 // threadId -> last message id we've processed |
| + agents map[string]bool // roster labels of ALL agents — never reply to one |
| + mention *regexp.Regexp |
| + |
| + wmu sync.Mutex // coder/websocket allows one concurrent writer |
| + conn *websocket.Conn |
| +} |
| + |
| +// Incoming is one decrypted chat message that triggered the bot. |
| +type Incoming struct { |
| + ThreadID string |
| + Kind string // "dm" or "group" |
| + Sender string |
| + Text string // raw text, @mention included (see StripMention) |
| +} |
| + |
| +// Reply is what a Handler posts back: text plus optional one-tap action chips |
| +// (the same client-whitelisted `actions` payload the Node sidecar ships). |
| +type Reply struct { |
| + Text string |
| + Actions []map[string]any |
| +} |
| + |
| +type threadEntry struct { |
| + key []byte |
| + kind string |
| +} |
| + |
| +// Run connects and serves until ctx is cancelled, reconnecting with backoff on |
| +// any drop — the same loop shape as the Node runtime's ws.on("close"). |
| +func (r *Runtime) Run(ctx context.Context) error { |
| + r.client = &http.Client{Timeout: 30 * time.Second} |
| + r.keys = map[string]threadEntry{} |
| + r.cursors = map[string]int64{} |
| + r.agents = map[string]bool{} |
| + r.mention = regexp.MustCompile(`(?i)(^|[^a-z0-9])@` + regexp.QuoteMeta(r.Session.Name) + `\b`) |
| + if r.Kind == "" { |
| + r.Kind = "bot" |
| + } |
| + backoff := time.Second |
| + for { |
| + start := time.Now() |
| + err := r.connectOnce(ctx) |
| + if ctx.Err() != nil { |
| + return ctx.Err() |
| + } |
| + // A connection that held for a while earned a fresh backoff. |
| + if time.Since(start) > time.Minute { |
| + backoff = time.Second |
| + } |
| + r.logf("disconnected (%v); reconnecting in %s", err, backoff) |
| + select { |
| + case <-time.After(backoff): |
| + case <-ctx.Done(): |
| + return ctx.Err() |
| + } |
| + backoff = min(backoff*2, 30*time.Second) |
| + } |
| +} |
| + |
| +func (r *Runtime) connectOnce(ctx context.Context) error { |
| + wsBase := strings.Replace(r.Base, "http", "ws", 1) |
| + conn, _, err := websocket.Dial(ctx, wsBase+"/ws?token="+url.QueryEscape(r.Session.Token), nil) |
| + if err != nil { |
| + return err |
| + } |
| + defer conn.CloseNow() |
| + // The server caps blobs at 64K; the library's 32K default would kill the |
| + // socket on the first big message. |
| + conn.SetReadLimit(256 * 1024) |
| + r.conn = conn |
| + defer func() { r.conn = nil }() |
| + |
| + // Same setup as the Node runtime's ws.on("open"): learn the roster's bots, |
| + // unwrap thread keys, declare what we are, then backfill anything missed. |
| + // Order matters only in that declareKind is a visible "connected and caught |
| + // up to here" signal; the cursor gate makes catchUp vs live frames safe in |
| + // any interleaving. |
| + r.loadAgents() |
| + r.loadThreads() |
| + r.declareKind() |
| + r.catchUp() |
| + r.logf("connected to %s", r.Base) |
| + |
| + // Keepalive: the server closes idle sockets and ignores "ping" frames. |
| + pingCtx, cancel := context.WithCancel(ctx) |
| + defer cancel() |
| + go func() { |
| + t := time.NewTicker(30 * time.Second) |
| + defer t.Stop() |
| + for { |
| + select { |
| + case <-t.C: |
| + r.send(map[string]any{"type": "ping"}) |
| + case <-pingCtx.Done(): |
| + return |
| + } |
| + } |
| + }() |
| + |
| + for { |
| + _, data, err := conn.Read(ctx) |
| + if err != nil { |
| + return err |
| + } |
| + r.onFrame(data) |
| + } |
| +} |
| + |
| +type wsFrame struct { |
| + Type string `json:"type"` |
| + ThreadID string `json:"thread_id"` |
| + ID int64 `json:"id"` |
| + Blob string `json:"blob"` |
| + Ephemeral bool `json:"ephemeral"` |
| +} |
| + |
| +func (r *Runtime) onFrame(data []byte) { |
| + var f wsFrame |
| + if json.Unmarshal(data, &f) != nil { |
| + return |
| + } |
| + // Ignore reactions/edits/deletes/typing, and — crucially — ephemeral frames: |
| + // relay-only presence traffic (typing beats, live cursors) arrives as |
| + // type:"msg" with no id, so the type check alone would let it through and a |
| + // DM bot would answer every beat. |
| + if f.Type != "msg" || f.ThreadID == "" || f.Ephemeral { |
| + return |
| + } |
| + if _, ok := r.keys[f.ThreadID]; !ok { |
| + r.loadThreads() // a thread we were just added to |
| + // loadThreads baselines a new thread's cursor at its latest message — |
| + // which is the very frame that made us load it (the server stores |
| + // before it broadcasts), so the gate below would swallow it. Rewind so |
| + // the triggering message gets an answer; anything older stays history. |
| + // (The Node runtime drops this first message — a quirk, not mirrored.) |
| + if f.ID != 0 && r.cursors[f.ThreadID] >= f.ID { |
| + r.cursors[f.ThreadID] = f.ID - 1 |
| + } |
| + } |
| + r.processMsg(f.ThreadID, f.ID, f.Blob) |
| +} |
| + |
| +// chatPayload is the decrypted message shape the runtime cares about. Typed |
| +// payloads (connect.*, mini-app events) are protocol traffic, not chat lines. |
| +type chatPayload struct { |
| + Type string `json:"type"` |
| + Name string `json:"name"` |
| + Text string `json:"text"` |
| +} |
| + |
| +// processMsg decrypts one message, advances the thread cursor, and replies if |
| +// it's for us. Frames and catch-up pages are processed on one goroutine per |
| +// bot, so the cursor read+advance can't race — a message delivered twice (a |
| +// reconnect's catch-up overlapping a live frame) is dropped here instead of |
| +// answered twice. |
| +func (r *Runtime) processMsg(threadID string, id int64, blob string) { |
| + entry, ok := r.keys[threadID] |
| + if !ok { |
| + return |
| + } |
| + if id != 0 { |
| + if id <= r.cursors[threadID] { |
| + return |
| + } |
| + r.cursors[threadID] = id |
| + } |
| + var p chatPayload |
| + if e2ee.DecryptJSON(entry.key, blob, &p) != nil { |
| + return // e.g. a message from before we held the key |
| + } |
| + if p.Name == r.Session.Name { |
| + return // our own message, echoed back |
| + } |
| + if r.agents[p.Name] { |
| + return // bots never talk to bots (see loadAgents) |
| + } |
| + if p.Type != "" { |
| + return // a typed protocol payload (connect.*, app event), not chat text |
| + } |
| + if !r.triggered(entry.kind, p.Text) || r.Handler == nil { |
| + return |
| + } |
| + rep, ok := r.Handler(Incoming{ThreadID: threadID, Kind: entry.kind, Sender: p.Name, Text: p.Text}) |
| + if !ok { |
| + return |
| + } |
| + r.postReply(threadID, entry.key, rep) |
| +} |
| + |
| +// triggered: DMs always reply; groups only on an explicit @Name. The Node |
| +// runtime's listen-mode "name" (bare-name + classifier) needs the model brain, |
| +// so it arrives with the model layer. |
| +func (r *Runtime) triggered(kind, text string) bool { |
| + if kind == "dm" { |
| + return true |
| + } |
| + return r.mention.MatchString(text) |
| +} |
| + |
| +// postReply encrypts and sends one reply payload — the same wire shape the Node |
| +// sidecar posts, so clients can't tell the implementations apart. |
| +func (r *Runtime) postReply(threadID string, key []byte, rep Reply) { |
| + if rep.Text == "" && len(rep.Actions) > 0 { |
| + rep.Text = "Tap to apply:" |
| + } |
| + if rep.Text == "" { |
| + return |
| + } |
| + payload := map[string]any{"name": r.Session.Name, "text": rep.Text, "ts": time.Now().UnixMilli()} |
| + if len(rep.Actions) > 0 { |
| + payload["actions"] = rep.Actions |
| + } |
| + blob, err := e2ee.EncryptJSON(key, payload) |
| + if err != nil { |
| + r.logf("encrypt reply: %v", err) |
| + return |
| + } |
| + if err := r.send(map[string]any{"type": "msg", "thread_id": threadID, "blob": blob}); err != nil { |
| + r.logf("send reply: %v", err) |
| + } |
| +} |
| + |
| +func (r *Runtime) send(v any) error { |
| + r.wmu.Lock() |
| + defer r.wmu.Unlock() |
| + conn := r.conn |
| + if conn == nil { |
| + return fmt.Errorf("not connected") |
| + } |
| + data, err := json.Marshal(v) |
| + if err != nil { |
| + return err |
| + } |
| + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) |
| + defer cancel() |
| + return conn.Write(ctx, websocket.MessageText, data) |
| +} |
| + |
| +// loadAgents refreshes the never-reply-to set: a bot's mention trigger matches |
| +// its bare name, and bot-authored text naturally names other bots — without |
| +// this, two bots in one group ping-pong forever. |
| +func (r *Runtime) loadAgents() { |
| + var rl struct { |
| + Roster []struct { |
| + Label string `json:"label"` |
| + IsAgent bool `json:"is_agent"` |
| + } `json:"roster"` |
| + } |
| + if err := r.get("/api/roster", &rl); err != nil { |
| + r.logf("loadAgents: %v", err) |
| + return |
| + } |
| + agents := map[string]bool{} |
| + for _, e := range rl.Roster { |
| + if e.IsAgent { |
| + agents[e.Label] = true |
| + } |
| + } |
| + r.agents = agents |
| +} |
| + |
| +// loadThreads caches the (unwrapped) key for any thread we don't hold yet. A |
| +// newly-seen thread's cursor starts at its latest message, so we never reply |
| +// to history — only to messages that arrive after we become aware of it. |
| +func (r *Runtime) loadThreads() { |
| + var tl struct { |
| + Threads []struct { |
| + ID string `json:"id"` |
| + Kind string `json:"kind"` |
| + Wrapped string `json:"wrapped"` |
| + } `json:"threads"` |
| + } |
| + if err := r.get("/api/threads", &tl); err != nil { |
| + r.logf("loadThreads: %v", err) |
| + return |
| + } |
| + for _, t := range tl.Threads { |
| + if t.Wrapped == "" { |
| + continue |
| + } |
| + if _, ok := r.keys[t.ID]; ok { |
| + continue |
| + } |
| + keyB64, err := e2ee.UnwrapThreadKey(t.Wrapped, r.Session.UserPriv) |
| + if err != nil { |
| + r.logf("load thread %s: %v", t.ID, err) |
| + continue |
| + } |
| + key, err := e2ee.ImportKey(keyB64) |
| + if err != nil { |
| + r.logf("load thread %s: %v", t.ID, err) |
| + continue |
| + } |
| + r.keys[t.ID] = threadEntry{key: key, kind: t.Kind} |
| + var ml struct { |
| + Messages []struct { |
| + ID int64 `json:"id"` |
| + } `json:"messages"` |
| + } |
| + if err := r.get("/api/messages?thread="+url.QueryEscape(t.ID)+"&limit=1", &ml); err == nil { |
| + for _, m := range ml.Messages { |
| + if m.ID > r.cursors[t.ID] { |
| + r.cursors[t.ID] = m.ID |
| + } |
| + } |
| + } |
| + } |
| +} |
| + |
| +// catchUp processes anything that arrived while disconnected, so a message |
| +// sent during a reconnect blip still gets a reply. Cursors start at each |
| +// thread's latest on first sight, so the initial connect backfills nothing. |
| +func (r *Runtime) catchUp() { |
| + for threadID := range r.keys { |
| + var ml struct { |
| + Messages []struct { |
| + ID int64 `json:"id"` |
| + Blob string `json:"blob"` |
| + } `json:"messages"` |
| + } |
| + path := fmt.Sprintf("/api/messages?thread=%s&after=%d", url.QueryEscape(threadID), r.cursors[threadID]) |
| + if err := r.get(path, &ml); err != nil { |
| + r.logf("catchUp %s: %v", threadID, err) |
| + continue |
| + } |
| + for _, m := range ml.Messages { |
| + r.processMsg(threadID, m.ID, m.Blob) |
| + } |
| + } |
| +} |
| + |
| +// declareKind self-reports what we are so the UI badges us honestly. Re-declared |
| +// on every connect, best-effort. |
| +func (r *Runtime) declareKind() { |
| + body, _ := json.Marshal(map[string]string{"token": r.Session.Token, "kind": r.Kind}) |
| + resp, err := r.client.Post(r.Base+"/api/bot-config", "application/json", strings.NewReader(string(body))) |
| + if err != nil { |
| + r.logf("declareKind: %v", err) |
| + return |
| + } |
| + resp.Body.Close() |
| +} |
| + |
| +func (r *Runtime) get(path string, out any) error { |
| + sep := "?" |
| + if strings.Contains(path, "?") { |
| + sep = "&" |
| + } |
| + resp, err := r.client.Get(r.Base + path + sep + "token=" + url.QueryEscape(r.Session.Token)) |
| + if err != nil { |
| + return err |
| + } |
| + defer resp.Body.Close() |
| + if resp.StatusCode != http.StatusOK { |
| + return fmt.Errorf("%s: server %d", path, resp.StatusCode) |
| + } |
| + return json.NewDecoder(resp.Body).Decode(out) |
| +} |
| + |
| +func (r *Runtime) logf(format string, args ...any) { |
| + if r.Logf != nil { |
| + r.Logf("["+r.Session.Name+"] "+format, args...) |
| + } |
| +} |