// The per-bot chat runtime, extracted from agent.mjs so one sidecar process can // host several identities (Claude, Eleven, …) — each bot gets its own session, // WebSocket, thread keys, cursors, and console config, all closed over here. // Behaviour is unchanged from the single-bot runtime: decrypt incoming messages // with the thread key, ask the Claude API for a reply, encrypt it, post it back. // The lchat server only ever sees ciphertext. import fs from "node:fs"; import Anthropic from "@anthropic-ai/sdk"; import WebSocket from "ws"; import { importKey, encrypt, decrypt, unwrapThreadKey, openSealed, sealForPub } from "../web/static/crypto.js"; import { BASE, WS_BASE, MODEL, CLASSIFIER_MODEL, APP_NAME, AI_ENABLED } from "./config.mjs"; import { actionsFromToolUse } from "./actions.mjs"; import { runCommand } from "./commands.mjs"; import { trimContext, scrubSelfNotes } from "./context.mjs"; import { listDomains, exchangeViaBroker } from "./dnsimple.mjs"; const log = (m) => { try { fs.writeSync(1, `${m}\n`); } catch {} }; let claude; // one API client shared by all bots in the process const api = () => (claude ??= new Anthropic()); // reads ANTHROPIC_API_KEY from env // A non-overridable safety + app-awareness preamble is always applied, then the // bot's persona (console config > builtin > env > default), then reference docs. const SAFETY = (name) => `You are ${name}, an AI assistant and a member of ${APP_NAME} — a small, private, ` + `end-to-end encrypted group chat app; the server and its operator can't read messages, ` + `and that privacy is the whole point of the app. You are one participant among the people here. ` + `You may be in a one-to-one DM or in a group; in a group you're brought in only when someone addresses you directly (usually an @mention). ` + `You can only take part in the conversation — no reminders, notifications, files, or web access — ` + `so never promise to do those things; if asked, say you can't yet. ` + `This chat may include children: always be kind and age-appropriate, and never say anything scary, adult, or unsafe. ` + `Each message is prefixed with the sender's name — reply naturally and don't prefix your own name.`; const DEFAULT_PERSONA = "Keep replies short, warm, and easy to read."; // runBot starts one bot identity: { name, session, builtinPersona?, builtinDocs? }. // builtin* are code-shipped defaults (e.g. Eleven's app guide) that console // config can still override per instance. `tools` (Claude tool defs) let the // bot propose one-tap action chips; tool_use blocks in a reply become the // payload's `actions` (see actions.mjs — the client whitelists again). export function runBot({ name, session, builtinPersona = "", builtinDocs = "", tools = null, commands = false }) { let persona = builtinPersona || process.env.LCHAT_SYSTEM || DEFAULT_PERSONA; let model = MODEL; let docs = builtinDocs; // How the bot listens in groups: "mention" (default) replies only to an // explicit @Name; "name" also considers a bare-name mention (still gated by // the classifier below). Console config wins; env is the headless fallback. let listen = process.env.LCHAT_LISTEN || "mention"; const tag = (m) => log(`[${name}] ${m}`); function systemPrompt() { let s = `${SAFETY(name)}\n\n${persona}`; // Bots know who their fellow helpers are — without this, one bot seeing // another's message treats it as an impostor (Claude once publicly doubted // Eleven's legitimacy). They still never reply to each other (processMsg). const others = [...agentNames].filter((n) => n !== name); if (others.length) s += `\n\nOther helper bots are members of this server alongside you: ${others.join(", ")}. ` + `They are legitimate, separately-run assistants (Eleven guides people around the app; ` + `Claude is a general companion). You may see their messages ` + `in shared groups; you never reply to them, and there's no need to question their presence.`; // Context is rebuilt from message TEXT only, so the model sees its own past // chip replies as bare "tap to switch" texts and starts imitating them // without calling the tool — the button silently vanishes. Make the rule // unconditional: promising a button REQUIRES the tool call, every time. if (tools) s += `\n\nYour buttons only exist when you call the matching tool: every reply that offers ` + `a one-tap action ("tap to switch", "here you go") MUST include that tool call — text alone ` + `attaches nothing, even if earlier replies in the conversation look like plain text. ` + `Never tell someone a button is attached without calling the tool in that same reply. ` + `"[note to self: …]" lines in the history are added automatically where you called tools ` + `before — never write them yourself.`; if (docs) s += `\n\nAlways keep this reference in mind:\n${docs}`; return s; } // The system prompt as a cache-friendly content block. Eleven ships an // exhaustive app/architecture reference, so the same large system prompt rides // EVERY DM and @mention; marking it cacheable lets Anthropic serve it from the // prompt cache instead of re-reading it each call — a big cost + latency win. // The whole prompt is stable between calls (the reference and persona don't // change; only an occasional roster change to the "other helpers" line busts // it), and `docs` sits last, so one trailing breakpoint caches everything. // Small prompts (no docs) fall below the cache floor and simply aren't cached — // harmless, so the marker is unconditional. function systemBlocks() { return [{ type: "text", text: systemPrompt(), cache_control: { type: "ephemeral" } }]; } const keys = new Map(); // threadId -> { key: CryptoKey, kind: "dm"|"group"|"self" } const cursors = new Map(); // threadId -> last message id we've processed // Threads where a client-run scripted flow has the floor (flow.state events // — the one-Eleven welcome): the brain stays quiet there until the script // ends or the entry expires (the fail-safe for a client that dies mid-flow). const muted = new Map(); // threadId -> expiry epoch ms let agentNames = new Set(); // roster labels of ALL agents — never reply to one const get = (path) => fetch(`${BASE}${path}${path.includes("?") ? "&" : "?"}token=${encodeURIComponent(session.token)}`).then((r) => r.json() ); // Bots never talk to bots: a bot's mention trigger matches its bare name, and // bot-authored text naturally names other bots (Eleven recommends Claude) — // without this, two bots in one group // ping-pong forever. Name-based (payloads aren't sender-signed yet), refreshed // on every (re)connect so newly added bots are picked up within a cycle. async function loadAgents() { try { const { roster = [] } = await get("/api/roster"); agentNames = new Set(roster.filter((e) => e.is_agent).map((e) => e.label)); } catch (e) { tag(`loadAgents: ${e.message}`); } } const maxId = (messages) => messages.reduce((m, x) => Math.max(m, x.id || 0), 0); // Fetch our threads and cache the (unwrapped) key for any 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. async function loadThreads() { const { threads = [] } = await get("/api/threads"); for (const t of threads) { if (!t.wrapped || keys.has(t.id)) continue; try { const raw = await unwrapThreadKey(t.wrapped, session.userPriv); keys.set(t.id, { key: await importKey(raw), kind: t.kind }); const { messages = [] } = await get(`/api/messages?thread=${encodeURIComponent(t.id)}&limit=1`); cursors.set(t.id, maxId(messages)); } catch (e) { tag(`load thread ${t.id}: ${e.message}`); } } } // Pull console-managed config (persona / model / reference docs) for this bot // and apply it over the builtins/env. Re-fetched on reconnect so admin edits // take effect. async function loadConfig() { try { const c = await get("/api/bot-config"); if (c && typeof c === "object") { persona = c.system_prompt || builtinPersona || process.env.LCHAT_SYSTEM || DEFAULT_PERSONA; model = c.model || MODEL; docs = c.docs || builtinDocs; listen = c.listen || process.env.LCHAT_LISTEN || "mention"; } } catch (e) { tag(`loadConfig: ${e.message}`); } } // DMs to the bot: always answer. Groups: an explicit @Name is a guaranteed, // deterministic reply. A bare name counts only in listen mode "name" — and // even then it's ambiguous (people talk *about* a bot as often as *to* it), // so it must also pass the classifier below. Anything else is ignored. function replyTrigger(kind, text) { if (kind === "dm") return "always"; if (new RegExp(`(^|[^a-z0-9])@${name}\\b`, "i").test(text || "")) return "always"; if (listen === "name" && new RegExp(`(^|[^a-z0-9])${name}\\b`, "i").test(text || "")) return "classify"; return null; } // The bare-name gate: one tiny yes/no call over the last few messages. Errs // toward silence (failure or ambiguity = no reply) — @Name is the guaranteed // path, so a false negative here is always recoverable by the sender. async function isAddressed(threadId, key) { const { messages = [] } = await get(`/api/messages?thread=${encodeURIComponent(threadId)}&limit=6`); const lines = []; for (const m of messages) { try { const p = await decrypt(key, m.blob); if (p.text) lines.push(`${p.name}: ${p.text}`); } catch {} } if (!lines.length) return false; try { const r = await api().messages.create({ model: CLASSIFIER_MODEL, max_tokens: 5, system: `"${name}" is a chat bot in a group conversation. Given the recent messages, decide whether ` + `the LAST message is addressed to ${name} and expects ${name} to reply — as opposed to merely ` + `mentioning ${name} or talking about ${name}. Answer with exactly "yes" or "no".`, messages: [{ role: "user", content: lines.join("\n") }], }); return /yes/i.test( r.content .filter((b) => b.type === "text") .map((b) => b.text) .join("") ); } catch (e) { tag(`addressed-classifier: ${e.message}`); return false; } } // Build the Claude message list from recent thread history (already includes // the triggering message, since the server stores before it broadcasts). async function buildContext(threadId, key) { const { messages = [] } = await get(`/api/messages?thread=${encodeURIComponent(threadId)}&limit=30`); const out = []; for (const m of messages) { let p; try { p = await decrypt(key, m.blob); } catch { continue; // e.g. a message from before we held the key } if (!p.text) continue; // Self-identity is the configured name, not the session file's record — so // renaming a bot is an env/default change + roster relabel, no re-provision. const mine = p.name === name; let content = mine ? p.text : `${p.name}: ${p.text}`; // Keep history truthful about tool calls: without this note the model // sees its own past chip replies as bare text, learns that bare text // "worked", and starts skipping the tool call — the chip vanishes. if (mine && Array.isArray(p.actions) && p.actions.length) content += `\n[note to self: this reply attached ${p.actions.length} button(s) via tool calls]`; out.push({ role: mine ? "assistant" : "user", content }); } // Drop assistant turns from both ends (see trimContext): a leading one breaks // the API's "first turn must be user" rule, and a trailing one is a hard 400 // under thinking mode. return trimContext(out); } async function reply(threadId, entry) { // Best-effort "…is typing" hint, re-beaten for the whole generation: a // single beat's 15s expiry lapses under a long thinking run, so members // watched the indicator die well before the reply landed. sendTyping(threadId, entry.key); const beat = setInterval(() => sendTyping(threadId, entry.key), 10_000); try { await replyInner(threadId, entry); } finally { clearInterval(beat); // An errored/empty run sends no message (the guaranteed clear signal) — // stop the indicator explicitly rather than leaving it to lapse. sendTyping(threadId, entry.key, 0); } } async function replyInner(threadId, entry) { const context = await buildContext(threadId, entry.key); let text; let actions = []; try { const r = await api().messages.create({ model, // Thinking, text and any tool call all share this budget — a tight cap // truncates the tool_use off the end and the chip silently vanishes. max_tokens: 4096, system: systemBlocks(), thinking: { type: "adaptive" }, ...(tools ? { tools } : {}), messages: context.length ? context : [{ role: "user", content: "Say hello." }], }); text = scrubSelfNotes( r.content .filter((b) => b.type === "text") .map((b) => b.text) .join(""), ); // Tool calls become one-tap chips on the message; there's no tool_result // round-trip — the "result" is the member tapping (or not tapping) it. if (tools) actions = actionsFromToolUse(r.content); // Self-heal the promised-button case. Threads full of pre-annotation chip // texts still bait the model into saying "tap to switch" WITHOUT the tool // call (system-prompt rules lose to 25 in-context counter-examples). The // promise is detectable, so make it deterministic: ask once more with a // forced tool call. Forced tool_choice requires thinking off. if (tools && !actions.length && /\b(tap|button|chip)\b/i.test(text || "")) { tag(`reply promised a button without a tool call — retrying forced`); const r2 = await api().messages.create({ model, max_tokens: 4096, system: systemBlocks(), thinking: { type: "disabled" }, tools, tool_choice: { type: "any" }, messages: context.length ? context : [{ role: "user", content: "Say hello." }], }); actions = actionsFromToolUse(r2.content); const t2 = scrubSelfNotes( r2.content .filter((b) => b.type === "text") .map((b) => b.text) .join(""), ); if (actions.length && t2) text = t2; // keep the original text if the retry was tool-only } if (!text && !actions.length) tag(`empty reply dropped (stop_reason=${r.stop_reason})`); } catch (e) { console.error(`[${name}] Claude error:`, e.message); return; } if (!text && !actions.length) return; await postReply(threadId, entry, text, actions); } // Encrypt + send a reply payload (text + optional one-tap chips). Shared by the // model path (replyInner) and the deterministic command path (processMsg). async function postReply(threadId, entry, text, actions = []) { if (!text && actions.length) text = "Tap to apply:"; if (!text) return; const payload = { name, text, ts: Date.now(), ...(actions.length ? { actions } : {}) }; send({ type: "msg", thread_id: threadId, blob: await encrypt(entry.key, payload) }); } // A connected-service capability op (docs/connected-services.md). The member // sealed a time-boxed grant to THIS bot's public key and asked us to act with // it. We open it with our own private key; a grant sealed to a different bot // won't open, so we stay silent (it isn't ours). The third-party token lives // only for this one call — we never persist it, and the window is one op wide. async function handleConnect(threadId, entry, p) { let grant; try { grant = await openSealed(p.grant || "", session.userPriv); } catch { return; // not sealed to us } // Reply ONLY with a non-rendering `connect.result` (matched by rid) — the // DNSimple app renders the data itself. We never postReply here, so a // connected-service op is NEVER visible as a bot message in the chat (the // domain list belongs to the DNSimple app, not the bot's DM). Ops only ever // come from the app, which always sets rid; a rid-less op gets no reply. const reply = async (result) => { if (!p.rid) return; send({ type: "msg", thread_id: threadId, blob: await encrypt(entry.key, { type: "connect.result", service: "dnsimple", op: p.op, rid: p.rid, ...result, ts: Date.now() }), }); }; if (!grant || (grant.exp && grant.exp < Date.now())) { await reply({ error: "That authorization expired — try again." }); return; } if (grant.service !== "dnsimple") { await reply({ error: `Can't act on "${grant.service}" yet.` }); return; } try { if (p.op !== "list") { await reply({ error: `Unknown action "${p.op}".` }); return; } const domains = await listDomains(grant.token); await reply({ domains }); } catch (e) { await reply({ error: e.message }); } finally { grant.token = null; // drop the token } } // A connected-service OAuth exchange (docs/connected-services.md). The member // did the PKCE dance in their browser — no key pasted — and hands us the // one-time `code` + `verifier`. We run the server-side token exchange (public // client, no secret), seal the token to the MEMBER's public key so only they // can open it, and hand it straight back. We keep no copy. async function handleOAuthExchange(threadId, entry, p) { if (p.service !== "dnsimple") return; let token; try { // The broker holds the client_secret and does the exchange; this sidecar // just forwards the one-time code + state with its per-instance broker token. token = await exchangeViaBroker({ code: p.code, state: p.state, brokerUrl: process.env.LCHAT_CONNECT_BROKER_URL || "https://connect.elevenmessenger.com", brokerToken: process.env.LCHAT_CONNECT_BROKER_TOKEN, }); } catch (e) { tag(`connect: dnsimple exchange failed: ${e.message}`); await postReply(threadId, entry, `Couldn't finish connecting DNSimple: ${e.message}`); return; } try { const { roster = [] } = await get("/api/roster"); const sender = roster.find((e) => e.user_id === p.uid); if (!sender || !sender.public_key) { await postReply(threadId, entry, "Connected, but I couldn't find your key to hand it back — try again."); return; } const sealed = await sealForPub(sender.public_key, { secret: token }); send({ type: "msg", thread_id: threadId, blob: await encrypt(entry.key, { type: "connect.token", service: "dnsimple", sealed, name, ts: Date.now() }), }); } finally { token = null; // drop the token; the member holds the only copy now } } // Strip a leading "@Name" so a group command ("@Eleven /help") parses; a bare // command in a DM passes through unchanged. function stripMention(text) { return (text || "").replace(new RegExp(`^\\s*@${name}\\b[:,]?\\s*`, "i"), "").trim(); } // The reply when there's no model brain and the message wasn't a command. function degradeText() { return "I can help with a few quick commands — try /help. (Conversational AI answers aren't enabled on this server.)"; } // Tell the server what we are so it can badge us honestly: "ai" when the model // brain is available, else "bot" (deterministic). Re-declared on every connect, // so toggling the key (a redeploy) flips the badge with no admin action. async function declareKind() { try { await fetch(`${BASE}/api/bot-config`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ token: session.token, kind: AI_ENABLED ? "ai" : "bot" }), }); } catch (e) { tag(`declareKind: ${e.message}`); } } async function sendTyping(threadId, key, expiry = 15) { try { // Match the live chat engine's typing wire shape: a relay-only ephemeral // `chat.typing` event (apps/chat.js's `ephemeral` hook), NOT the legacy // dedicated `type:"typing"` frame — that only feeds the retired #feed // renderer in shell.js, so the bot's typing indicator never showed in the // app. The transport injects our name; expiry 0 = the "stopped" signal. send({ type: "msg", thread_id: threadId, ephemeral: true, blob: await encrypt(key, { type: "chat.typing", name, expiry }), }); } catch {} } // ---- WebSocket with reconnect + a small outbound queue ----------------- let ws; let backoff = 1000; let ping; // keepalive timer (the server closes idle sockets ~60s; it ignores "ping") const queue = []; function send(obj) { const data = JSON.stringify(obj); if (ws && ws.readyState === WebSocket.OPEN) ws.send(data); else queue.push(data); } function connect() { // `ws` sends no Origin header, so it passes the server's same-origin check. ws = new WebSocket(`${WS_BASE}/ws?token=${encodeURIComponent(session.token)}`); ws.on("open", async () => { backoff = 1000; tag(`connected to ${BASE}`); clearInterval(ping); ping = setInterval(() => { try { if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "ping" })); } catch {} }, 30000); await loadConfig(); await loadAgents(); await loadThreads(); await declareKind(); await catchUp(); for (const d of queue.splice(0)) ws.send(d); }); ws.on("message", (data) => onFrame(data).catch((e) => tag(`frame: ${e.message}`))); ws.on("error", (e) => tag(`WS error: ${e.message}`)); ws.on("close", () => { clearInterval(ping); tag(`disconnected; reconnecting in ${backoff}ms`); setTimeout(connect, backoff); backoff = Math.min(backoff * 2, 30000); }); } // Decrypt one message, advance the thread cursor, and reply if it's for us. async function processMsg(threadId, id, blob) { const entry = keys.get(threadId); if (!entry) return; // Idempotency gate. Message ids are monotonic and we only ever act on ones // strictly newer than the highest we've seen on this thread, so a message // delivered more than once — a reconnect's catch-up overlapping a live frame, // or a socket that briefly double-delivered — is dropped here instead of // answered twice (the cause of the occasional double bot reply). The read + // advance are synchronous (no await between) so two concurrent deliveries of // the same id can't both slip past. const seen = cursors.get(threadId) || 0; if (id && id <= seen) return; cursors.set(threadId, id || seen); let p; try { p = await decrypt(entry.key, blob); } catch { return; } if (p.name === name) return; // our own message, echoed back if (agentNames.has(p.name)) return; // bots never talk to bots (see loadAgents) // A direct capability invocation (a sealed connected-service grant), not a // chat trigger — handle it whether or not we have a model brain. if (p.type === "connect.op") { await handleConnect(threadId, entry, p); return; } if (p.type === "connect.oauth") { await handleOAuthExchange(threadId, entry, p); return; } if (p.type === "flow.state") { // A client-run scripted flow (the one-Eleven welcome, running in this // bot's own DM) taking or ceding the floor — the member's typed answers // belong to the script, not the brain. DMs only; hard expiry so a dead // client can't mute the bot forever. Mirrors the Go runtime's handleTyped. if (entry.kind === "dm") { if (p.active) muted.set(threadId, Date.now() + 15 * 60 * 1000); else muted.delete(threadId); } return; } if (p.type) return; // any other typed payload (a mini-app event), not chat text const exp = muted.get(threadId); if (exp) { if (Date.now() < exp) return; // the script has the floor muted.delete(threadId); } const trigger = replyTrigger(entry.kind, p.text); if (!trigger) return; // The bare-name gate needs the model; with no AI there's nothing to classify. if (trigger === "classify" && (!AI_ENABLED || !(await isAddressed(threadId, entry.key)))) return; // Deterministic command floor first — works with or without a model brain. if (commands) { const res = runCommand(stripMention(p.text), { aiEnabled: AI_ENABLED, name }); if (res) { await postReply(threadId, entry, res.text, res.actions || []); return; } } // Conversational upgrade: only when the model brain is available. if (AI_ENABLED) { await reply(threadId, entry); return; } // No AI and not a command → a deterministic hint (command-capable bots only). if (commands) await postReply(threadId, entry, degradeText(), []); } async function onFrame(data) { let f; try { f = JSON.parse(data); } catch { return; } // Ignore reactions/edits/deletes/typing, and — crucially — ephemeral frames: // relay-only presence traffic (the chat app's "…is typing" beat, live cursors) // arrives as `type:"msg"` with no id (see hub.go), so the type check alone lets // it through. Without this a DM bot (replyTrigger "always") answers every beat. if (f.type !== "msg" || !f.thread_id || f.ephemeral) return; if (!keys.has(f.thread_id)) await loadThreads(); // a thread we were just added to await processMsg(f.thread_id, f.id, f.blob); } // On (re)connect, catch up on 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. async function catchUp() { for (const threadId of keys.keys()) { const after = cursors.get(threadId) || 0; try { const { messages = [] } = await get(`/api/messages?thread=${encodeURIComponent(threadId)}&after=${after}`); for (const m of messages) await processMsg(threadId, m.id, m.blob); } catch (e) { tag(`catchUp ${threadId}: ${e.message}`); } } } connect(); }