eleven / messenger Public

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>
Paul Campbell 99b92b97ecf6f20e5d7236c450c3b490cdfcc3cc parent fa1ddab
7 files changed, +912 −6
  • bot_provision_test.go +9 −2
  • bot_run_test.go +215 −0
  • botcmd.go +51 −1
  • docs/bots.md +15 −3
  • internal/bot/commands.go +117 −0
  • internal/bot/commands_test.go +98 −0
  • internal/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...)
+ }
+}