Default agents: Claude, Eleven (app guide + welcomes), Announcer (self-announcing deploys) (#55)
* agent: deploy announcer — a write-only "Deploys" bot posts what shipped
A per-instance announcer bot whose identity lives on the operator machine
(deploys run from there), posting E2E-encrypted deploy notes to any group
that adds it from the Helpers catalog (or any DM started with it).
- agent/announce.mjs: one-shot, pure HTTP (POST /api/messages broadcasts +
pushes). Write-only by construction: never fetches or decrypts a message,
no Claude API — unwraps thread keys only to encrypt what it posts.
- provision-announcer.sh: mints the agent invite (catalog metadata included)
via the admin API and claims it headlessly; session lands in
~/.lchat/announcers/<host>.json (operator secret, back it up).
- announce-deploy.sh: fan an announcement out to every instance with a
provisioned announcer.
- agent/README.md: usage section.
Verified end-to-end against a local server: member discovers the bot via
the Helpers catalog, creates a group with it, announce posts, member
decrypts the exact message.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* server+agent: deploy announcements — the changelog ships with the deploy
Replaces the operator-machine announcer (previous commit) with a fully
self-contained design: release notes live in changelog/<date>-<slug>.md
(one short user-facing file per change; parallel PRs never conflict), are
embedded in the server binary, and served at token-gated /api/changelog.
The sidecar hosts a second write-only identity, "Deploys", that polls the
endpoint and posts unseen entries to every group/DM it's been added to —
no operator machine, GitHub credential, or extra service in the loop.
- main.go: //go:embed changelog/*.md, loadChangelog(), GET /api/changelog
(tokenOK-gated). Entries are operator-authored notes about the app, in
clear like roster labels — never user content.
- agent/announcer.mjs: self-provisions from LCHAT_ANNOUNCER_INVITE (first
boot), baselines silently on first run (announces what ships after it,
not history), batches multiple new entries into one message, persists
announced ids next to its session on the /data disk. Never fetches or
decrypts a message; no Claude API. Runs in the sidecar process
(startAnnouncer) or standalone one-tick (node announcer.mjs).
- deploy-agent.sh + .miren/agent.app.toml: LCHAT_ANNOUNCER_INVITE /
LCHAT_ANNOUNCER_SESSION plumbing.
- CLAUDE.md: changelog-entry convention for user-visible changes.
- Seed entries for today's merges + TestChangelogEndpoint.
Verified E2E locally: provision → silent baseline → rebuild with a new
entry → restart → exactly that entry posted to the member's group,
decrypted and matched; a further tick posts nothing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* agent+server: default bot name is "Eleven"; admin can rename users
- agent/config.mjs: LCHAT_AGENT_NAME default "Claude" -> "Eleven" (matching
the app's name); admin form placeholder follows.
- agent.mjs: the bot's display name and self-identity now come from config
(AGENT_NAME) instead of the name frozen into the session file at provision
time — so renaming a live bot is an env/default change plus a roster
relabel, no re-provision. Old messages keep their old name (it's inside
the encrypted payload; the server couldn't rewrite it anyway).
- New admin endpoint POST /api/admin/users/{id}/label (+ db.setUserLabel,
TestRenameUser): relabel a roster entry — display only, keys untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: changelog entry for the Eleven rename
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* agent+server: three default agents — Claude, Eleven (guide+welcomes), Announcer
One sidecar process now hosts the instance's default agents, each an
optional, separate E2EE identity on the /data disk:
- Claude: the plain assistant (unchanged behaviour; default name stays
Claude — reverts the earlier default flip).
- Eleven: the app guide. Shipped-in persona + app reference (updates with
every deploy; console config can still override), answers how-do-I
questions, and DMs each NEW member a welcome on join. Baselines silently
on first run so a lived-in instance isn't spammed. eleven.mjs runs one
welcome tick standalone.
- Announcer (renamed from Deploys): unchanged write-only changelog poster.
Supporting changes:
- runtime.mjs: the per-bot chat loop extracted from agent.mjs module state
(session, keys, cursors, WS, config) so several identities share one
process; bots post/self-identify by configured name (rename-friendly).
- Vetted bots (agent + available_to_members) now read the FULL joined
roster — Eleven can't welcome a member it can't see. One-way: people
stay connections-only; unvetted agents too (test covers all three).
- provision(T, {name, path}) generalised; deploy-agent.sh + agent.app.toml
plumb LCHAT_ELEVEN_INVITE / LCHAT_ELEVEN_SESSION.
Verified locally: welcome E2E (baseline honoured for pre-existing member,
newcomer welcomed exactly once, third tick no-op, welcome decrypts as the
member) and a three-identity boot smoke test against a live local server.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
17 files changed,
+901
−254
.miren/agent.app.toml+17 −4CLAUDE.md+5 −0agent/README.md+35 −8agent/agent.mjs+42 −228agent/announcer.mjs+138 −0agent/eleven.mjs+177 −0agent/provision.mjs+11 −9agent/runtime.mjs+239 −0bots_test.go+82 −0changelog/2026-07-01-helpers.md+1 −0changelog/2026-07-01-message-polish.md+1 −0changelog/2026-07-02-announcer.md+1 −0changelog/2026-07-02-eleven.md+1 −0db.go+30 −2deploy-agent.sh+3 −0handlers_test.go+26 −1main.go+92 −2
diff --git a/.miren/agent.app.toml b/.miren/agent.app.toml| index c92a856..68a0a63 100644 |
| --- a/.miren/agent.app.toml |
| +++ b/.miren/agent.app.toml |
| @@ -26,8 +26,21 @@ size_gb = 1 |
| key = "LCHAT_SESSION" |
| value = "/data/session.json" |
| +# The deploy announcer's identity + announced-state live on the same disk (its |
| +# state file is derived: /data/announcer-session.json.state.json). |
| +[[env]] |
| +key = "LCHAT_ANNOUNCER_SESSION" |
| +value = "/data/announcer-session.json" |
| + |
| +# Eleven (the app guide): identity + welcomed-members state, same pattern. |
| +[[env]] |
| +key = "LCHAT_ELEVEN_SESSION" |
| +value = "/data/eleven-session.json" |
| + |
| # Set at deploy time via `-e` (see deploy-agent.sh), NOT declared here: |
| -# LCHAT_BASE the instance to connect to, e.g. https://test-bots.luan.chat |
| -# ANTHROPIC_API_KEY billing credential — lives only in this app's env |
| -# LCHAT_INVITE one-time invite #fragment; used only on first boot |
| -# PORT container port for the health check (defaults to 8080) |
| +# LCHAT_BASE the instance to connect to, e.g. https://test-bots.luan.chat |
| +# ANTHROPIC_API_KEY billing credential — lives only in this app's env |
| +# LCHAT_INVITE one-time invite #fragment; used only on first boot |
| +# LCHAT_ANNOUNCER_INVITE same, for the "Announcer" identity |
| +# LCHAT_ELEVEN_INVITE same, for the "Eleven" identity |
| +# PORT container port for the health check (defaults to 8080) |
diff --git a/CLAUDE.md b/CLAUDE.md| index b0b8879..25fd0dd 100644 |
| --- a/CLAUDE.md |
| +++ b/CLAUDE.md |
| @@ -89,6 +89,11 @@ Frontend module layout: |
| always shows. |
| - Match the surrounding code's style and comment density. Comment the **why**, |
| not the what. |
| +- **User-visible changes add a changelog entry**: a short, user-facing note in |
| + `changelog/<date>-<slug>.md` (one file per change — parallel PRs never |
| + conflict). Entries are embedded in the binary and served at `/api/changelog`; |
| + the deploy announcer bot posts new ones to its groups after a deploy, so write |
| + them for members, not developers. |
| ## Build & test |
diff --git a/agent/README.md b/agent/README.md| index 272fe2a..e630335 100644 |
| --- a/agent/README.md |
| +++ b/agent/README.md |
| @@ -1,14 +1,20 @@ |
| # lchat agent sidecar |
| -A headless lchat member that relays a thread to Claude and posts replies. It |
| -behaves exactly like a member's browser: it holds its own keypair, is added to |
| -threads by a human, decrypts messages with the thread key, calls the Claude API, |
| -and posts an encrypted reply. **The lchat server only ever sees ciphertext** — |
| -the agent's private key and your `ANTHROPIC_API_KEY` live only on this host. |
| +One headless process that hosts an instance's **default agents**, each a normal |
| +E2EE member with its own keypair and identity file. **The lchat server only |
| +ever sees ciphertext** — private keys and your `ANTHROPIC_API_KEY` live only on |
| +this host. It shares `web/static/crypto.js` verbatim (imported directly), so |
| +its wire format is guaranteed to match the app. |
| -It runs as a separate process, not part of the Go binary, and shares |
| -`web/static/crypto.js` verbatim (imported directly), so its wire format is |
| -guaranteed to match the app. |
| +| agent | what it does | |
| +|---|---| |
| +| **Claude** | The plain assistant: replies to every DM, and on @mention in groups. (`runtime.mjs`, identity via `LCHAT_SESSION`/`LCHAT_INVITE`.) | |
| +| **Eleven** | The app guide: same chat loop with a shipped-in persona + app reference (updated with every deploy), and it **DMs each new member a welcome** when they join. (`eleven.mjs`, `LCHAT_ELEVEN_*`.) | |
| +| **Announcer** | Write-only deploy notes: polls the embedded changelog (`/api/changelog`) and posts what's new to groups it's added to. Never reads messages, never calls the Claude API. (`announcer.mjs`, `LCHAT_ANNOUNCER_*`.) | |
| + |
| +Each identity is optional — it runs if its session file exists or its invite is |
| +set. Every identity is minted the same way: an agent invite from the admin |
| +console, its `#fragment` passed once as the matching `*_INVITE` env var. |
| ## What you provide |
| @@ -40,6 +46,27 @@ LCHAT_BASE=https://test.luan.chat npm start |
| Then, in the app, add the agent to a group or start a DM with it. It replies to |
| every message in a DM, and only when @mentioned by name in a group. |
| +## Eleven & the Announcer |
| + |
| +**Eleven** needs to see members it shares no thread with (to welcome them), so |
| +vetted bots — agents the admin marked *available to members* — read the full |
| +joined roster; people stay connections-only. On its first run it baselines |
| +silently (existing members are never welcomed retroactively). Its app knowledge |
| +ships in `eleven.mjs`, so it improves with each deploy; console config can |
| +still override the persona per instance. |
| + |
| +**The Announcer** closes the loop on deploys: release notes live in the repo |
| +(`changelog/<date>-<slug>.md`, one short user-facing file per change), ship |
| +**inside the server binary**, and are served at the token-gated |
| +`/api/changelog`. The announcer polls it and posts entries it hasn't seen to |
| +every group/DM it's been added to — a deploy announces itself, with no operator |
| +machine, GitHub, or extra service involved. Like Eleven's welcomes, it |
| +baselines silently on first run. It never reads or decrypts messages (it |
| +unwraps thread keys only to encrypt its own posts). |
| + |
| +`node eleven.mjs` and `node announcer.mjs` each run one standalone tick |
| +(welcome pass / changelog check) — handy for tests and cron-style setups. |
| + |
| ## Notes & limits |
| - **The Claude API sees plaintext.** The lchat server does not — but the reply is |
diff --git a/agent/agent.mjs b/agent/agent.mjs| index 041e0f6..827679a 100644 |
| --- a/agent/agent.mjs |
| +++ b/agent/agent.mjs |
| @@ -1,14 +1,23 @@ |
| -// The agent runtime. Behaves like a member's browser (chat.js), headless: |
| -// connect the WebSocket, decrypt incoming messages with the thread key, ask |
| -// Claude for a reply, encrypt it, and post it back. The lchat server only ever |
| -// sees ciphertext; plaintext exists only here and at the Claude API. |
| +// Sidecar boot: one process hosts the instance's default agents, each a normal |
| +// E2EE member with its own identity on the /data disk (see runtime.mjs for the |
| +// per-bot chat loop): |
| +// |
| +// Claude the plain assistant — replies in DMs, on @mention in groups |
| +// (LCHAT_SESSION / LCHAT_INVITE; name via LCHAT_AGENT_NAME) |
| +// Eleven the app guide — same chat loop with a shipped-in persona, plus a |
| +// welcome DM to each new member (LCHAT_ELEVEN_SESSION / _INVITE) |
| +// Announcer write-only deploy notes from /api/changelog (announcer.mjs; |
| +// LCHAT_ANNOUNCER_SESSION / _INVITE) |
| +// |
| +// Each identity is optional: it runs if its session exists or its invite is set, |
| +// so an instance can host any subset. |
| import fs from "node:fs"; |
| import http from "node:http"; |
| -import Anthropic from "@anthropic-ai/sdk"; |
| -import WebSocket from "ws"; |
| -import { importKey, encrypt, decrypt, unwrapThreadKey } from "../web/static/crypto.js"; |
| -import { BASE, WS_BASE, AGENT_NAME, MODEL, requireBase, loadSession } from "./config.mjs"; |
| +import { BASE, AGENT_NAME, requireBase, loadSession } from "./config.mjs"; |
| import { provision } from "./provision.mjs"; |
| +import { runBot } from "./runtime.mjs"; |
| +import { startAnnouncer } from "./announcer.mjs"; |
| +import { ELEVEN_NAME, ELEVEN_PERSONA, ELEVEN_DOCS, loadElevenSession, startWelcomer } from "./eleven.mjs"; |
| // Synchronous logging: async stdout to a pipe is buffered and lost if the |
| // container is killed (e.g. a failed health check), so write key lines with |
| @@ -26,7 +35,7 @@ process.on("unhandledRejection", (e) => { |
| log(`unhandledRejection: ${e?.stack || e}`); |
| process.exit(1); |
| }); |
| -log(`boot: ${AGENT_NAME} starting, base=${BASE}`); |
| +log(`boot: sidecar starting, base=${BASE}`); |
| requireBase(); |
| @@ -40,236 +49,41 @@ http |
| }) |
| .listen(PORT, "0.0.0.0", () => log(`health listening on :${PORT}`)); |
| -// On a fresh host (empty disk) with an invite in env, self-provision on boot. |
| -let session = loadSession(); |
| -if (!session && process.env.LCHAT_INVITE) { |
| +// --- Claude: the plain assistant (the original sidecar bot) ---------------- |
| +let claudeSession = loadSession(); |
| +if (!claudeSession && process.env.LCHAT_INVITE) { |
| log("no session; provisioning from LCHAT_INVITE…"); |
| try { |
| - session = await provision(process.env.LCHAT_INVITE); |
| - log(`provisioned "${session.name}"`); |
| + claudeSession = await provision(process.env.LCHAT_INVITE); |
| + log(`provisioned "${claudeSession.name}"`); |
| } catch (e) { |
| log(`provision failed: ${e?.message || e}`); |
| process.exit(1); |
| } |
| } |
| -if (!session) { |
| - log("no session — set LCHAT_INVITE (or run provision)"); |
| +if (claudeSession) runBot({ name: AGENT_NAME, session: claudeSession }); |
| + |
| +// --- Eleven: the app guide + welcomer --------------------------------------- |
| +let elevenSession = null; |
| +try { |
| + elevenSession = await loadElevenSession(); |
| +} catch (e) { |
| + log(`eleven provision failed: ${e?.message || e}`); |
| process.exit(1); |
| } |
| - |
| -const claude = new Anthropic(); // reads ANTHROPIC_API_KEY from the environment |
| - |
| -// A non-overridable safety + app-awareness preamble is always applied. The |
| -// persona, model, and reference docs come from the console-managed bot config |
| -// (see /api/bot-config), falling back to env then a friendly default. |
| -const SAFETY = |
| - `You are ${AGENT_NAME}, an AI assistant and a member of lchat — a small, private, ` + |
| - `end-to-end encrypted group chat; 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 when someone mentions you by name. ` + |
| - `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."; |
| - |
| -let persona = process.env.LCHAT_SYSTEM || DEFAULT_PERSONA; |
| -let model = MODEL; // env LCHAT_MODEL || claude-opus-4-8; overridden by console config |
| -let docs = ""; |
| - |
| -function systemPrompt() { |
| - let s = `${SAFETY}\n\n${persona}`; |
| - if (docs) s += `\n\nAlways keep this reference in mind:\n${docs}`; |
| - return s; |
| -} |
| - |
| -const keys = new Map(); // threadId -> { key: CryptoKey, kind: "dm"|"group"|"self" } |
| -const cursors = new Map(); // threadId -> last message id we've processed |
| - |
| -const api = (path) => |
| - fetch(`${BASE}${path}${path.includes("?") ? "&" : "?"}token=${encodeURIComponent(session.token)}`).then((r) => |
| - r.json() |
| - ); |
| - |
| -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 api("/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 api(`/api/messages?thread=${encodeURIComponent(t.id)}&limit=1`); |
| - cursors.set(t.id, maxId(messages)); |
| - } catch (e) { |
| - log(`load thread ${t.id}: ${e.message}`); |
| - } |
| - } |
| -} |
| - |
| -// Pull console-managed config (persona / model / reference docs) for this bot and |
| -// apply it over the env/defaults. Re-fetched on reconnect so admin edits take effect. |
| -async function loadConfig() { |
| - try { |
| - const c = await api("/api/bot-config"); |
| - if (c && typeof c === "object") { |
| - persona = c.system_prompt || process.env.LCHAT_SYSTEM || DEFAULT_PERSONA; |
| - model = c.model || MODEL; |
| - docs = c.docs || ""; |
| - } |
| - } catch (e) { |
| - log(`loadConfig: ${e.message}`); |
| - } |
| +if (elevenSession) { |
| + runBot({ name: ELEVEN_NAME, session: elevenSession, builtinPersona: ELEVEN_PERSONA, builtinDocs: ELEVEN_DOCS }); |
| + startWelcomer(); |
| } |
| -// DMs to the bot: always answer. Groups: only when @mentioned by name. |
| -function wantsReply(kind, text) { |
| - if (kind === "dm") return true; |
| - return new RegExp(`(^|[^a-z0-9])@?${AGENT_NAME}\\b`, "i").test(text || ""); |
| -} |
| +// --- Announcer: write-only deploy notes ------------------------------------- |
| +startAnnouncer(); |
| -// 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 api(`/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; |
| - const mine = p.name === session.name; |
| - out.push({ role: mine ? "assistant" : "user", content: mine ? p.text : `${p.name}: ${p.text}` }); |
| - } |
| - // The API requires the first turn to be `user`. |
| - while (out.length && out[0].role === "assistant") out.shift(); |
| - return out; |
| -} |
| - |
| -async function reply(threadId, entry) { |
| - sendTyping(threadId, entry.key); // best-effort "…is typing" hint |
| - const context = await buildContext(threadId, entry.key); |
| - let text; |
| - try { |
| - const r = await claude.messages.create({ |
| - model, |
| - max_tokens: 1024, |
| - system: systemPrompt(), |
| - thinking: { type: "adaptive" }, |
| - messages: context.length ? context : [{ role: "user", content: "Say hello." }], |
| - }); |
| - text = r.content |
| - .filter((b) => b.type === "text") |
| - .map((b) => b.text) |
| - .join("") |
| - .trim(); |
| - } catch (e) { |
| - console.error("Claude error:", e.message); |
| - return; |
| - } |
| - if (!text) return; |
| - send({ type: "msg", thread_id: threadId, blob: await encrypt(entry.key, { name: session.name, text, ts: Date.now() }) }); |
| -} |
| - |
| -async function sendTyping(threadId, key) { |
| - try { |
| - // Same shape chat.js emits (see hub.go's "typing" relay). |
| - send({ type: "typing", thread_id: threadId, blob: await encrypt(key, { type: "typing", name: session.name, expiry: 15 }) }); |
| - } 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; |
| - log(`connected to ${BASE}`); |
| - clearInterval(ping); |
| - ping = setInterval(() => { |
| - try { |
| - if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "ping" })); |
| - } catch {} |
| - }, 30000); |
| - await loadConfig(); |
| - await loadThreads(); |
| - await catchUp(); |
| - for (const d of queue.splice(0)) ws.send(d); |
| - }); |
| - ws.on("message", (data) => onFrame(data).catch((e) => log(`frame: ${e.message}`))); |
| - ws.on("error", (e) => log(`WS error: ${e.message}`)); |
| - ws.on("close", () => { |
| - clearInterval(ping); |
| - log(`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; |
| - cursors.set(threadId, Math.max(cursors.get(threadId) || 0, id || 0)); |
| - let p; |
| - try { |
| - p = await decrypt(entry.key, blob); |
| - } catch { |
| - return; |
| - } |
| - if (p.name === session.name) return; // our own message, echoed back |
| - if (!wantsReply(entry.kind, p.text)) return; |
| - await reply(threadId, entry); |
| -} |
| - |
| -async function onFrame(data) { |
| - let f; |
| - try { |
| - f = JSON.parse(data); |
| - } catch { |
| - return; |
| - } |
| - if (f.type !== "msg" || !f.thread_id) return; // ignore reactions/edits/typing/etc. |
| - 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 api(`/api/messages?thread=${encodeURIComponent(threadId)}&after=${after}`); |
| - for (const m of messages) await processMsg(threadId, m.id, m.blob); |
| - } catch (e) { |
| - log(`catchUp ${threadId}: ${e.message}`); |
| - } |
| +if (!claudeSession && !elevenSession) { |
| + log("no chat identities — set LCHAT_INVITE and/or LCHAT_ELEVEN_INVITE (or provide sessions)"); |
| + // The announcer alone can still be a valid deployment; exit only when |
| + // nothing at all is configured. |
| + if (!fs.existsSync(process.env.LCHAT_ANNOUNCER_SESSION || "") && !process.env.LCHAT_ANNOUNCER_INVITE) { |
| + process.exit(1); |
| } |
| } |
| - |
| -// On first start the agent only reacts to messages that arrive after boot |
| -// (cursors start at each thread's latest, so no backlog is answered). On a |
| -// reconnect it catches up on anything missed during the gap (see catchUp). |
| -await loadConfig(); |
| -await loadThreads(); |
| -connect(); |
diff --git a/agent/announcer.mjs b/agent/announcer.mjs| new file mode 100644 |
| index 0000000..a0f7da4 |
| --- /dev/null |
| +++ b/agent/announcer.mjs |
| @@ -0,0 +1,138 @@ |
| +// The deploy announcer: a second, write-only bot identity ("Deploys") hosted by |
| +// the sidecar. It polls the instance's embedded changelog (/api/changelog — |
| +// release notes that ship inside the server binary) and posts entries it hasn't |
| +// announced yet into every group/DM it's been added to. So a deploy announces |
| +// itself: no operator machine, no GitHub, no extra service in the loop. |
| +// |
| +// Deliberately write-only: unlike agent.mjs it never fetches or decrypts a |
| +// single message — it unwraps thread keys only to *encrypt* what it posts, and |
| +// it makes no Claude API calls. "Deploys is in this group" means "it can post |
| +// here", not "it reads this room". |
| +// |
| +// Identity + memory live next to the main bot's session (the /data disk): |
| +// LCHAT_ANNOUNCER_SESSION (default: announcer-session.json beside this file) |
| +// LCHAT_ANNOUNCER_INVITE (first boot only: fragment of an admin agent-invite) |
| +// State (which entries have been announced) sits in <session>.state.json. On the |
| +// very first run it baselines to the current changelog without posting, so a |
| +// fresh announcer doesn't replay history — it announces what ships *after* it. |
| +// |
| +// Used two ways: agent.mjs imports startAnnouncer() (poll loop in the sidecar), |
| +// and `node announcer.mjs` runs exactly one tick (provision if needed, check, |
| +// post, exit) — handy for tests and cron-style setups. |
| +import fs from "node:fs"; |
| +import { fileURLToPath } from "node:url"; |
| +import { importKey, encrypt, unwrapThreadKey, deriveInvite, generateUserKeypair } from "../web/static/crypto.js"; |
| +import { BASE, requireBase } from "./config.mjs"; |
| + |
| +const log = (m) => { |
| + try { |
| + fs.writeSync(1, `announcer: ${m}\n`); |
| + } catch {} |
| +}; |
| + |
| +export const ANNOUNCER_NAME = process.env.LCHAT_ANNOUNCER_NAME || "Announcer"; |
| +const SESSION_PATH = |
| + process.env.LCHAT_ANNOUNCER_SESSION || fileURLToPath(new URL("./announcer-session.json", import.meta.url)); |
| +const STATE_PATH = `${SESSION_PATH}.state.json`; |
| +const POLL_MS = Number(process.env.LCHAT_ANNOUNCE_POLL_MS) || 5 * 60 * 1000; |
| + |
| +const readJSON = (p) => (fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, "utf8")) : null); |
| + |
| +// Provision the announcer's own identity from LCHAT_ANNOUNCER_INVITE (same |
| +// claim flow as provision.mjs, separate session file). Returns null — quietly |
| +// disabled — when there's neither a session nor an invite. |
| +async function loadOrProvision() { |
| + const existing = readJSON(SESSION_PATH); |
| + if (existing) return existing; |
| + const T = process.env.LCHAT_ANNOUNCER_INVITE; |
| + if (!T) return null; |
| + const post = (path, body) => |
| + fetch(`${BASE}${path}`, { |
| + method: "POST", |
| + headers: { "content-type": "application/json" }, |
| + body: JSON.stringify(body), |
| + }).then((r) => r.json()); |
| + const { id } = await deriveInvite(T); |
| + const claim = await post(`/api/key-invite/${encodeURIComponent(id)}/claim`, { label: ANNOUNCER_NAME }); |
| + if (!claim.ok || !claim.token) throw new Error("couldn't claim the announcer invite (used or expired?)"); |
| + const kp = await generateUserKeypair(); |
| + await post("/api/pubkey", { token: claim.token, public_key: kp.publicKey }); |
| + const session = { token: claim.token, name: ANNOUNCER_NAME, userPub: kp.publicKey, userPriv: kp.privateKey }; |
| + fs.writeFileSync(SESSION_PATH, JSON.stringify(session, null, 2)); |
| + log(`provisioned "${ANNOUNCER_NAME}"`); |
| + return session; |
| +} |
| + |
| +// One poll: fetch the changelog, post anything new to every group/DM we're in, |
| +// remember what we've announced. Multiple new entries batch into one message. |
| +export async function announceTick() { |
| + const session = await loadOrProvision(); |
| + if (!session) return false; |
| + const api = (path) => |
| + fetch(`${BASE}${path}${path.includes("?") ? "&" : "?"}token=${encodeURIComponent(session.token)}`).then((r) => |
| + r.json() |
| + ); |
| + |
| + const { entries = [] } = await api("/api/changelog"); |
| + const state = readJSON(STATE_PATH); |
| + if (!state) { |
| + // First run: baseline silently so a new announcer doesn't replay history. |
| + fs.writeFileSync(STATE_PATH, JSON.stringify({ announced: entries.map((e) => e.id) })); |
| + log(`baselined at ${entries.length} entr${entries.length === 1 ? "y" : "ies"}`); |
| + return true; |
| + } |
| + const seen = new Set(state.announced || []); |
| + const fresh = entries.filter((e) => e.id && e.text && !seen.has(e.id)); |
| + if (!fresh.length) return true; |
| + |
| + const text = `🚀 Just shipped:\n\n${fresh.map((e) => e.text).join("\n\n")}`; |
| + const { threads = [] } = await api("/api/threads"); |
| + const targets = threads.filter((t) => t.wrapped && (t.kind === "group" || t.kind === "dm")); |
| + let ok = 0; |
| + for (const t of targets) { |
| + try { |
| + const key = await importKey(await unwrapThreadKey(t.wrapped, session.userPriv)); |
| + const blob = await encrypt(key, { name: ANNOUNCER_NAME, text, ts: Date.now() }); |
| + const r = await fetch(`${BASE}/api/messages?token=${encodeURIComponent(session.token)}`, { |
| + method: "POST", |
| + headers: { "content-type": "application/json" }, |
| + body: JSON.stringify({ thread_id: t.id, blob }), |
| + }); |
| + if (!r.ok) throw new Error(`server ${r.status}`); |
| + ok++; |
| + } catch (e) { |
| + log(`thread ${t.id}: ${e.message}`); |
| + } |
| + } |
| + // Mark announced even with zero targets: entries shipped while the bot sat in |
| + // no groups are old news by the time someone adds it — don't replay them. |
| + fresh.forEach((e) => seen.add(e.id)); |
| + fs.writeFileSync(STATE_PATH, JSON.stringify({ announced: [...seen] })); |
| + log(`announced ${fresh.length} entr${fresh.length === 1 ? "y" : "ies"} to ${ok}/${targets.length} thread(s)`); |
| + return true; |
| +} |
| + |
| +// The sidecar's poll loop. Errors are logged and retried next tick — a failed |
| +// poll must never take the main bot down with it. |
| +export function startAnnouncer() { |
| + const tick = () => |
| + announceTick() |
| + .then((enabled) => { |
| + if (!enabled) log("disabled (no session and no LCHAT_ANNOUNCER_INVITE)"); |
| + return enabled; |
| + }) |
| + .catch((e) => (log(`tick failed: ${e.message}`), true)); |
| + tick().then((enabled) => { |
| + if (enabled) setInterval(() => tick(), POLL_MS); |
| + }); |
| +} |
| + |
| +// CLI: run exactly one tick. |
| +if (import.meta.url === `file://${process.argv[1]}`) { |
| + requireBase(); |
| + const enabled = await announceTick(); |
| + if (!enabled) { |
| + console.error("No announcer session and no LCHAT_ANNOUNCER_INVITE set."); |
| + process.exit(1); |
| + } |
| +} |
diff --git a/agent/eleven.mjs b/agent/eleven.mjs| new file mode 100644 |
| index 0000000..7cab364 |
| --- /dev/null |
| +++ b/agent/eleven.mjs |
| @@ -0,0 +1,177 @@ |
| +// Eleven — the app's guide. One of the sidecar's default identities: it knows |
| +// how lchat works (persona + reference shipped here in code, so every deploy |
| +// updates its knowledge), answers "how do I…?" questions in DMs and on |
| +// @mention, and WELCOMES each new member with a DM when they join. |
| +// |
| +// The welcome loop needs to see members it shares no thread with, which is why |
| +// vetted bots (agent + available_to_members) read the full joined roster — see |
| +// getRoster in main.go. First run baselines silently: existing members are |
| +// marked welcomed without being messaged, so rolling Eleven out to a lived-in |
| +// instance doesn't spam everyone. State lives next to its session on /data. |
| +// |
| +// LCHAT_ELEVEN_SESSION identity file (default: eleven-session.json here) |
| +// LCHAT_ELEVEN_INVITE first boot only: fragment of an admin agent-invite |
| +// |
| +// `node eleven.mjs` runs one welcome tick standalone (used by tests); inside |
| +// the sidecar, agent.mjs calls startWelcomer() + runBot() with these exports. |
| +import fs from "node:fs"; |
| +import { fileURLToPath } from "node:url"; |
| +import { importKey, encrypt, generateKey, wrapThreadKey, unwrapThreadKey } from "../web/static/crypto.js"; |
| +import { BASE, requireBase } from "./config.mjs"; |
| +import { provision } from "./provision.mjs"; |
| + |
| +const log = (m) => { |
| + try { |
| + fs.writeSync(1, `eleven: ${m}\n`); |
| + } catch {} |
| +}; |
| + |
| +export const ELEVEN_NAME = process.env.LCHAT_ELEVEN_NAME || "Eleven"; |
| +export const ELEVEN_SESSION_PATH = |
| + process.env.LCHAT_ELEVEN_SESSION || fileURLToPath(new URL("./eleven-session.json", import.meta.url)); |
| +const WELCOMED_PATH = `${ELEVEN_SESSION_PATH}.welcomed.json`; |
| +const POLL_MS = Number(process.env.LCHAT_WELCOME_POLL_MS) || 60 * 1000; |
| + |
| +// Shipped-in persona + app knowledge (console config can still override per |
| +// instance). Update these alongside features — they deploy with the sidecar. |
| +export const ELEVEN_PERSONA = |
| + "You are the app's friendly guide. Help people get the most out of lchat: answer questions about " + |
| + "how the app works, suggest features they might not know, and keep answers concrete and step-by-step. " + |
| + "If someone asks something unrelated to the app, you can chat — but gently note that Claude (the other " + |
| + "helper) is the better companion for general questions."; |
| +export const ELEVEN_DOCS = [ |
| + "lchat basics: every chat is end-to-end encrypted — the server (and its operator) can never read messages.", |
| + "Start a chat: tap ➕, pick a person for a DM, or 'New group' to pick several people and name the group.", |
| + "Helpers: bots live under the Helpers section of the ➕ picker; each says what it does. Mention a bot by name (@Claude) to get its attention in a group; in a DM it always replies.", |
| + "Invite someone new: if the admin enabled member invites, ➕ → 'Invite someone' creates a private link — share it only with that person, since the link can read the chat it opens.", |
| + "Groups: tap the group name for members; add people via the menu; swipe right on a message (or use the message menu) to reply; double-tap (touch) or press-and-hold (desktop) to react.", |
| + "Photos & files: the paperclip attaches them; images are shrunk and stripped of location data before upload, and everything is encrypted.", |
| + "Profile: tap your avatar to set a photo; it's encrypted so only members see it.", |
| + "Themes: the ⋮ menu has a Theme picker with light/dark and colour options.", |
| + "Notifications: enable them from the ⋮ menu; you won't be notified for chats you have open.", |
| + "The Announcer helper posts a note in groups it's added to whenever the app gets new features.", |
| +].join("\n"); |
| + |
| +const readJSON = (p) => (fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, "utf8")) : null); |
| + |
| +// Load or first-boot-provision Eleven's identity. Null (disabled) when there's |
| +// neither a session nor an invite. |
| +export async function loadElevenSession() { |
| + const existing = readJSON(ELEVEN_SESSION_PATH); |
| + if (existing) return existing; |
| + const T = process.env.LCHAT_ELEVEN_INVITE; |
| + if (!T) return null; |
| + const session = await provision(T, { name: ELEVEN_NAME, path: ELEVEN_SESSION_PATH }); |
| + log(`provisioned "${ELEVEN_NAME}"`); |
| + return session; |
| +} |
| + |
| +const welcomeText = (label) => |
| + `Hi ${label}, welcome! 👋 I'm ${ELEVEN_NAME}, your guide to this app.\n\n` + |
| + `A few things worth knowing:\n` + |
| + `• Everything here is end-to-end encrypted — only the people in a chat can read it.\n` + |
| + `• Tap ➕ to start a chat or a group. Helpers (like me) have their own section there.\n` + |
| + `• Double-tap a message to react, swipe right to reply.\n\n` + |
| + `Ask me anything about how the app works — I'm happy to help!`; |
| + |
| +// One welcome pass: look for joined members we haven't welcomed, DM them. |
| +// Returns false when Eleven is disabled (no session/invite). |
| +export async function welcomeTick() { |
| + const session = await loadElevenSession(); |
| + if (!session) return false; |
| + const get = (path) => |
| + fetch(`${BASE}${path}${path.includes("?") ? "&" : "?"}token=${encodeURIComponent(session.token)}`).then((r) => |
| + r.json() |
| + ); |
| + |
| + const { roster = [] } = await get("/api/roster"); |
| + // People only (never other bots), joined only (a pending invitee has no key |
| + // to wrap a DM to — they're picked up on a later tick, right after they join). |
| + const people = roster.filter((e) => e.public_key && !e.is_agent && e.public_key !== session.userPub); |
| + |
| + const state = readJSON(WELCOMED_PATH); |
| + if (!state) { |
| + // First run: existing members are marked welcomed silently — Eleven greets |
| + // people who arrive after it, it doesn't spam a lived-in instance. |
| + fs.writeFileSync(WELCOMED_PATH, JSON.stringify({ welcomed: people.map((e) => e.user_id) })); |
| + log(`baselined at ${people.length} existing member(s)`); |
| + return true; |
| + } |
| + const welcomed = new Set(state.welcomed || []); |
| + const newcomers = people.filter((e) => !welcomed.has(e.user_id)); |
| + if (!newcomers.length) return true; |
| + |
| + // Existing DM threads (e.g. an invite's pending DM the newcomer adopted, or a |
| + // re-run after a partial failure): don't create a duplicate, post into it. |
| + const { threads = [] } = await get("/api/threads"); |
| + |
| + for (const person of newcomers) { |
| + try { |
| + let dm = threads.find((t) => t.kind === "dm" && (t.members || []).includes(person.user_id)); |
| + let key; |
| + if (dm) { |
| + key = await importKey(await unwrapThreadKey(dm.wrapped, session.userPriv)); |
| + } else { |
| + const tk = await generateKey(); |
| + key = tk.key; |
| + const r = await fetch(`${BASE}/api/threads`, { |
| + method: "POST", |
| + headers: { "content-type": "application/json" }, |
| + body: JSON.stringify({ |
| + token: session.token, |
| + kind: "dm", |
| + name_blob: "", |
| + members: [ |
| + { user_id: rosterSelfId(roster, session), wrapped: await wrapThreadKey(tk.b64, session.userPub) }, |
| + { user_id: person.user_id, wrapped: await wrapThreadKey(tk.b64, person.public_key) }, |
| + ], |
| + }), |
| + }).then((x) => x.json()); |
| + if (!r.thread_id) throw new Error("createThread failed"); |
| + dm = { id: r.thread_id }; |
| + } |
| + const blob = await encrypt(key, { name: ELEVEN_NAME, text: welcomeText(person.label), ts: Date.now() }); |
| + const pr = await fetch(`${BASE}/api/messages?token=${encodeURIComponent(session.token)}`, { |
| + method: "POST", |
| + headers: { "content-type": "application/json" }, |
| + body: JSON.stringify({ thread_id: dm.id, blob }), |
| + }); |
| + if (!pr.ok) throw new Error(`post ${pr.status}`); |
| + welcomed.add(person.user_id); |
| + log(`welcomed ${person.label}`); |
| + } catch (e) { |
| + log(`welcome ${person.label}: ${e.message}`); // retried next tick (not marked welcomed) |
| + } |
| + } |
| + fs.writeFileSync(WELCOMED_PATH, JSON.stringify({ welcomed: [...welcomed] })); |
| + return true; |
| +} |
| + |
| +function rosterSelfId(roster, session) { |
| + const me = roster.find((e) => e.public_key === session.userPub); |
| + if (!me) throw new Error("own roster entry not found"); |
| + return me.user_id; |
| +} |
| + |
| +// The sidecar's welcome loop; failures log and retry next tick. |
| +export function startWelcomer() { |
| + const tick = () => |
| + welcomeTick() |
| + .then((enabled) => { |
| + if (!enabled) log("disabled (no session and no LCHAT_ELEVEN_INVITE)"); |
| + return enabled; |
| + }) |
| + .catch((e) => (log(`tick failed: ${e.message}`), true)); |
| + tick().then((enabled) => { |
| + if (enabled) setInterval(() => tick(), POLL_MS); |
| + }); |
| +} |
| + |
| +// CLI: one welcome tick. |
| +if (import.meta.url === `file://${process.argv[1]}`) { |
| + requireBase(); |
| + if (!(await welcomeTick())) { |
| + console.error("No Eleven session and no LCHAT_ELEVEN_INVITE set."); |
| + process.exit(1); |
| + } |
| +} |
diff --git a/agent/provision.mjs b/agent/provision.mjs| index a42aea5..6649e84 100644 |
| --- a/agent/provision.mjs |
| +++ b/agent/provision.mjs |
| @@ -6,16 +6,18 @@ |
| // derive the invite id from it, generate our own keypair, claim a device token, |
| // and register the public key in the roster. |
| // |
| -// Exposed as provision(T) so the runtime (agent.mjs) can self-provision on first |
| -// boot in a container; also runnable directly: `node provision.mjs`. |
| +// Exposed as provision(T, {name, path}) so the runtime (agent.mjs) can |
| +// self-provision each bot identity on first boot in a container; also runnable |
| +// directly: `node provision.mjs` (provisions the primary bot). |
| +import { writeFileSync } from "node:fs"; |
| import { deriveInvite, generateUserKeypair } from "../web/static/crypto.js"; |
| -import { BASE, AGENT_NAME, SESSION_PATH, requireBase, loadSession, saveSession } from "./config.mjs"; |
| +import { BASE, AGENT_NAME, SESSION_PATH, requireBase, loadSession } from "./config.mjs"; |
| // provision claims the invite secret T, generates a keypair, registers the |
| // pubkey, and persists + returns the session. Throws on any failure. |
| -export async function provision(T) { |
| - const post = (path, body) => |
| - fetch(`${BASE}${path}`, { |
| +export async function provision(T, { name = AGENT_NAME, path = SESSION_PATH } = {}) { |
| + const post = (p, body) => |
| + fetch(`${BASE}${p}`, { |
| method: "POST", |
| headers: { "content-type": "application/json" }, |
| body: JSON.stringify(body), |
| @@ -27,12 +29,12 @@ export async function provision(T) { |
| if (info.wrapped) throw new Error("that's an add-device link; use a fresh-person invite"); |
| const kp = await generateUserKeypair(); |
| - const claim = await post(`/api/key-invite/${encodeURIComponent(id)}/claim`, { label: AGENT_NAME }); |
| + const claim = await post(`/api/key-invite/${encodeURIComponent(id)}/claim`, { label: name }); |
| if (!claim.ok || !claim.token) throw new Error("couldn't claim the invite (it may have just been used)"); |
| await post("/api/pubkey", { token: claim.token, public_key: kp.publicKey }); |
| - const session = { token: claim.token, name: AGENT_NAME, userPub: kp.publicKey, userPriv: kp.privateKey }; |
| - saveSession(session); |
| + const session = { token: claim.token, name, userPub: kp.publicKey, userPriv: kp.privateKey }; |
| + writeFileSync(path, JSON.stringify(session, null, 2)); |
| return session; |
| } |
diff --git a/agent/runtime.mjs b/agent/runtime.mjs| new file mode 100644 |
| index 0000000..421909a |
| --- /dev/null |
| +++ b/agent/runtime.mjs |
| @@ -0,0 +1,239 @@ |
| +// 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 } from "../web/static/crypto.js"; |
| +import { BASE, WS_BASE, MODEL } from "./config.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 lchat — a small, private, ` + |
| + `end-to-end encrypted group chat; 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 when someone mentions you by name. ` + |
| + `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. |
| +export function runBot({ name, session, builtinPersona = "", builtinDocs = "" }) { |
| + let persona = builtinPersona || process.env.LCHAT_SYSTEM || DEFAULT_PERSONA; |
| + let model = MODEL; |
| + let docs = builtinDocs; |
| + |
| + const tag = (m) => log(`[${name}] ${m}`); |
| + |
| + function systemPrompt() { |
| + let s = `${SAFETY(name)}\n\n${persona}`; |
| + if (docs) s += `\n\nAlways keep this reference in mind:\n${docs}`; |
| + return s; |
| + } |
| + |
| + const keys = new Map(); // threadId -> { key: CryptoKey, kind: "dm"|"group"|"self" } |
| + const cursors = new Map(); // threadId -> last message id we've processed |
| + |
| + const get = (path) => |
| + fetch(`${BASE}${path}${path.includes("?") ? "&" : "?"}token=${encodeURIComponent(session.token)}`).then((r) => |
| + r.json() |
| + ); |
| + |
| + 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; |
| + } |
| + } catch (e) { |
| + tag(`loadConfig: ${e.message}`); |
| + } |
| + } |
| + |
| + // DMs to the bot: always answer. Groups: only when @mentioned by name. |
| + function wantsReply(kind, text) { |
| + if (kind === "dm") return true; |
| + return new RegExp(`(^|[^a-z0-9])@?${name}\\b`, "i").test(text || ""); |
| + } |
| + |
| + // 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; |
| + out.push({ role: mine ? "assistant" : "user", content: mine ? p.text : `${p.name}: ${p.text}` }); |
| + } |
| + // The API requires the first turn to be `user`. |
| + while (out.length && out[0].role === "assistant") out.shift(); |
| + return out; |
| + } |
| + |
| + async function reply(threadId, entry) { |
| + sendTyping(threadId, entry.key); // best-effort "…is typing" hint |
| + const context = await buildContext(threadId, entry.key); |
| + let text; |
| + try { |
| + const r = await api().messages.create({ |
| + model, |
| + max_tokens: 1024, |
| + system: systemPrompt(), |
| + thinking: { type: "adaptive" }, |
| + messages: context.length ? context : [{ role: "user", content: "Say hello." }], |
| + }); |
| + text = r.content |
| + .filter((b) => b.type === "text") |
| + .map((b) => b.text) |
| + .join("") |
| + .trim(); |
| + } catch (e) { |
| + console.error(`[${name}] Claude error:`, e.message); |
| + return; |
| + } |
| + if (!text) return; |
| + send({ type: "msg", thread_id: threadId, blob: await encrypt(entry.key, { name, text, ts: Date.now() }) }); |
| + } |
| + |
| + async function sendTyping(threadId, key) { |
| + try { |
| + // Same shape chat.js emits (see hub.go's "typing" relay). |
| + send({ type: "typing", thread_id: threadId, blob: await encrypt(key, { type: "typing", name, expiry: 15 }) }); |
| + } 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 loadThreads(); |
| + 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; |
| + cursors.set(threadId, Math.max(cursors.get(threadId) || 0, id || 0)); |
| + let p; |
| + try { |
| + p = await decrypt(entry.key, blob); |
| + } catch { |
| + return; |
| + } |
| + if (p.name === name) return; // our own message, echoed back |
| + if (!wantsReply(entry.kind, p.text)) return; |
| + await reply(threadId, entry); |
| + } |
| + |
| + async function onFrame(data) { |
| + let f; |
| + try { |
| + f = JSON.parse(data); |
| + } catch { |
| + return; |
| + } |
| + if (f.type !== "msg" || !f.thread_id) return; // ignore reactions/edits/typing/etc. |
| + 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(); |
| +} |
diff --git a/bots_test.go b/bots_test.go| index 8dffaf6..f8dc16b 100644 |
| --- a/bots_test.go |
| +++ b/bots_test.go |
| @@ -80,6 +80,37 @@ func TestHelpersCatalogInConnections(t *testing.T) { |
| } |
| } |
| +// The embedded changelog: token-gated, entries in filename (date) order, and |
| +// non-empty — the deploy announcer depends on this endpoint's shape. |
| +func TestChangelogEndpoint(t *testing.T) { |
| + ts, srv := newTestServer(t) |
| + |
| + r, _ := http.Get(ts.URL + "/api/changelog?token=nope") |
| + if r.StatusCode != http.StatusForbidden { |
| + t.Fatalf("bad token: want 403, got %d", r.StatusCode) |
| + } |
| + |
| + uid, _ := srv.db.createUser("Paul") |
| + srv.db.createKeyInvite("inv-cl", uid, "", 0) |
| + tok, _, _ := srv.db.claimKeyInvite("inv-cl", "Paul", "") |
| + rr, _ := http.Get(ts.URL + "/api/changelog?token=" + tok) |
| + var out struct { |
| + Entries []ChangelogEntry `json:"entries"` |
| + } |
| + json.NewDecoder(rr.Body).Decode(&out) |
| + if len(out.Entries) == 0 { |
| + t.Fatal("expected at least one embedded changelog entry") |
| + } |
| + for i, e := range out.Entries { |
| + if e.ID == "" || e.Text == "" { |
| + t.Fatalf("entry %d should have id and text: %+v", i, e) |
| + } |
| + if i > 0 && out.Entries[i-1].ID > e.ID { |
| + t.Fatalf("entries should be in filename order: %q before %q", out.Entries[i-1].ID, e.ID) |
| + } |
| + } |
| +} |
| + |
| // End to end over HTTP: the admin's "Add an agent" carries the catalog metadata, |
| // and once the sidecar joins, a stranger member sees the helper in /api/roster. |
| func TestHelpersCatalogHTTP(t *testing.T) { |
| @@ -138,3 +169,54 @@ func TestHelpersCatalogHTTP(t *testing.T) { |
| t.Fatalf("helper entry should carry badge, blurb and key: %+v", helper) |
| } |
| } |
| + |
| +// Vetted bots (agent + available_to_members) read the FULL joined roster — a |
| +// welcome bot must see members it shares no thread with. People and unvetted |
| +// agents stay connections-only. |
| +func TestVettedAgentSeesFullRoster(t *testing.T) { |
| + ts, srv := newTestServer(t) |
| + join := func(label, inv string) (string, string) { |
| + uid, _ := srv.db.createUser(label) |
| + srv.db.createKeyInvite(inv, uid, "", 0) |
| + tok, _, _ := srv.db.claimKeyInvite(inv, label, "") |
| + srv.db.setUserPublicKey(uid, "pub-"+label) |
| + return uid, tok |
| + } |
| + _, paulTok := join("Paul", "inv-p") |
| + botID, botTok := join("Eleven", "inv-b") |
| + srv.db.setUserAgent(botID) |
| + if err := srv.db.setBotConfig(botID, "", "", "", "Your guide to the app", true); err != nil { |
| + t.Fatalf("setBotConfig: %v", err) |
| + } |
| + |
| + labels := func(tok string) map[string]bool { |
| + rr, _ := http.Get(ts.URL + "/api/roster?token=" + tok) |
| + var out struct { |
| + Roster []RosterEntry `json:"roster"` |
| + } |
| + json.NewDecoder(rr.Body).Decode(&out) |
| + got := map[string]bool{} |
| + for _, e := range out.Roster { |
| + got[e.Label] = true |
| + } |
| + return got |
| + } |
| + |
| + // The vetted bot sees Paul despite sharing no thread with him. |
| + if got := labels(botTok); !got["Paul"] || !got["Eleven"] { |
| + t.Fatalf("vetted bot should see the full roster: %v", got) |
| + } |
| + // Paul sees the bot only through the catalog (it's available), and himself — |
| + // the widening is one-way. |
| + if got := labels(paulTok); !got["Eleven"] || !got["Paul"] { |
| + t.Fatalf("member roster should be self + catalog bots: %v", got) |
| + } |
| + |
| + // Unvetted: flip availability off — the bot drops back to connections-only. |
| + if err := srv.db.setBotConfig(botID, "", "", "", "Your guide to the app", false); err != nil { |
| + t.Fatalf("setBotConfig off: %v", err) |
| + } |
| + if got := labels(botTok); got["Paul"] { |
| + t.Fatalf("unvetted agent must not see strangers: %v", got) |
| + } |
| +} |
diff --git a/changelog/2026-07-01-helpers.md b/changelog/2026-07-01-helpers.md| new file mode 100644 |
| index 0000000..79f3405 |
| --- /dev/null |
| +++ b/changelog/2026-07-01-helpers.md |
| @@ -0,0 +1 @@ |
| +You can now discover helpers (bots) when starting a chat or adding people to a group — look for the new Helpers section, where each one says what it does. |
diff --git a/changelog/2026-07-01-message-polish.md b/changelog/2026-07-01-message-polish.md| new file mode 100644 |
| index 0000000..14afe52 |
| --- /dev/null |
| +++ b/changelog/2026-07-01-message-polish.md |
| @@ -0,0 +1 @@ |
| +Messages got smarter: code blocks and quotes now render properly, and YouTube links show a tap-to-play preview. |
diff --git a/changelog/2026-07-02-announcer.md b/changelog/2026-07-02-announcer.md| new file mode 100644 |
| index 0000000..3118024 |
| --- /dev/null |
| +++ b/changelog/2026-07-02-announcer.md |
| @@ -0,0 +1 @@ |
| +Deploy announcements are live — this very message arrived through one. |
diff --git a/changelog/2026-07-02-eleven.md b/changelog/2026-07-02-eleven.md| new file mode 100644 |
| index 0000000..82643cf |
| --- /dev/null |
| +++ b/changelog/2026-07-02-eleven.md |
| @@ -0,0 +1 @@ |
| +Meet Eleven — your guide to the app. It welcomes new members when they join, and you can DM it any time to ask how something works. |
diff --git a/db.go b/db.go| index b92504b..6eea34b 100644 |
| --- a/db.go |
| +++ b/db.go |
| @@ -821,13 +821,41 @@ func (db *DB) removeUser(id string) error { |
| return tx.Commit() |
| } |
| -// setUserAgent flags a user as an agent (bot) so the UI can badge it. Purely |
| -// cosmetic — it confers no extra permission. |
| +// setUserLabel renames a user in the roster (admin roster management — e.g. |
| +// renaming a bot). Labels are display names, not identity: keys, tokens, and |
| +// message history are untouched. |
| +func (db *DB) setUserLabel(userID, label string) error { |
| + res, err := db.sql.Exec("UPDATE users SET label = ? WHERE id = ?", label, userID) |
| + if err != nil { |
| + return err |
| + } |
| + if n, _ := res.RowsAffected(); n == 0 { |
| + return sql.ErrNoRows |
| + } |
| + return nil |
| +} |
| + |
| +// setUserAgent flags a user as an agent (bot) so the UI can badge it. On its |
| +// own it confers no privilege; combined with available_to_members it makes the |
| +// bot a vetted helper (catalog-visible, full-roster reads — see isVettedAgent). |
| func (db *DB) setUserAgent(userID string) error { |
| _, err := db.sql.Exec("UPDATE users SET is_agent = 1 WHERE id = ?", userID) |
| return err |
| } |
| +// isVettedAgent reports whether a user is an agent the admin marked available |
| +// to members. Vetted bots are instance-wide infrastructure: they appear in |
| +// everyone's Helpers catalog and read the full joined roster (so e.g. Eleven |
| +// can welcome a brand-new member it shares no thread with). |
| +func (db *DB) isVettedAgent(userID string) bool { |
| + var n int |
| + db.sql.QueryRow(` |
| + SELECT COUNT(*) FROM users u JOIN bot_configs b ON b.user_id = u.id |
| + WHERE u.id = ? AND COALESCE(u.is_agent, 0) = 1 AND COALESCE(b.available_to_members, 0) = 1`, |
| + userID).Scan(&n) |
| + return n > 0 |
| +} |
| + |
| // BotConfig is an agent's console-managed settings (see bot_configs). |
| type BotConfig struct { |
| Model string `json:"model"` |
diff --git a/deploy-agent.sh b/deploy-agent.sh| index 035fe0e..16ecd16 100755 |
| --- a/deploy-agent.sh |
| +++ b/deploy-agent.sh |
| @@ -31,6 +31,9 @@ ENVARGS=(-e "LCHAT_BASE=$LCHAT_BASE" -e "ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY") |
| [ -n "${LCHAT_INVITE:-}" ] && ENVARGS+=(-e "LCHAT_INVITE=$LCHAT_INVITE") |
| [ -n "${LCHAT_AGENT_NAME:-}" ] && ENVARGS+=(-e "LCHAT_AGENT_NAME=$LCHAT_AGENT_NAME") |
| [ -n "${LCHAT_MODEL:-}" ] && ENVARGS+=(-e "LCHAT_MODEL=$LCHAT_MODEL") |
| +# First-boot invites for the other default identities, like LCHAT_INVITE. |
| +[ -n "${LCHAT_ANNOUNCER_INVITE:-}" ] && ENVARGS+=(-e "LCHAT_ANNOUNCER_INVITE=$LCHAT_ANNOUNCER_INVITE") |
| +[ -n "${LCHAT_ELEVEN_INVITE:-}" ] && ENVARGS+=(-e "LCHAT_ELEVEN_INVITE=$LCHAT_ELEVEN_INVITE") |
| echo "Deploying sidecar '$APP' (cluster $CLUSTER) -> $LCHAT_BASE" |
| miren deploy -d "$TMP" -C "$CLUSTER" -f "${ENVARGS[@]}" |
diff --git a/handlers_test.go b/handlers_test.go| index b56c7c9..bc8429f 100644 |
| --- a/handlers_test.go |
| +++ b/handlers_test.go |
| @@ -25,7 +25,7 @@ func newTestServer(t *testing.T) (*httptest.Server, *Server) { |
| } |
| hub := newHub() |
| go hub.run() |
| - s := &Server{db: db, hub: hub, pages: pages, vapidPub: pub, vapidPriv: priv} |
| + s := &Server{db: db, hub: hub, pages: pages, changelog: loadChangelog(), vapidPub: pub, vapidPriv: priv} |
| ts := httptest.NewServer(s.handler()) |
| t.Cleanup(ts.Close) |
| return ts, s |
| @@ -60,6 +60,31 @@ func TestAdminAuthGating(t *testing.T) { |
| } |
| } |
| +// Renaming a user (e.g. a bot after its default name changes) is admin-gated, |
| +// validates the label, and shows up in the roster; keys are untouched. |
| +func TestRenameUser(t *testing.T) { |
| + ts, srv := newTestServer(t) |
| + uid, _ := srv.db.createUser("Claude") |
| + srv.db.setUserPublicKey(uid, "pub-bot") |
| + |
| + if r, _ := http.Post(ts.URL+"/api/admin/users/"+uid+"/label", "application/json", strings.NewReader(`{"label":"Eleven"}`)); r.StatusCode != http.StatusUnauthorized { |
| + t.Fatalf("no auth: want 401, got %d", r.StatusCode) |
| + } |
| + if r := adminPost(t, ts.URL+"/api/admin/users/"+uid+"/label", `{"label":""}`); r.StatusCode != http.StatusBadRequest { |
| + t.Fatalf("empty label: want 400, got %d", r.StatusCode) |
| + } |
| + if r := adminPost(t, ts.URL+"/api/admin/users/nosuch/label", `{"label":"Eleven"}`); r.StatusCode != http.StatusBadRequest { |
| + t.Fatalf("unknown user: want 400, got %d", r.StatusCode) |
| + } |
| + if r := adminPost(t, ts.URL+"/api/admin/users/"+uid+"/label", `{"label":"Eleven"}`); r.StatusCode != http.StatusOK { |
| + t.Fatalf("rename: want 200, got %d", r.StatusCode) |
| + } |
| + entries, _ := srv.db.roster() |
| + if len(entries) != 1 || entries[0].Label != "Eleven" || entries[0].PublicKey != "pub-bot" { |
| + t.Fatalf("roster after rename: %+v", entries) |
| + } |
| +} |
| + |
| func TestInviteAndMessagesFlow(t *testing.T) { |
| ts, srv := newTestServer(t) |
diff --git a/main.go b/main.go| index 1d34449..d3e3bed 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -2,6 +2,7 @@ package main |
| import ( |
| "crypto/sha256" |
| + "database/sql" |
| "embed" |
| "encoding/json" |
| "flag" |
| @@ -27,10 +28,20 @@ const maxUploadBytes = 26 << 20 |
| //go:embed web/pages/*.html web/static/* |
| var webFS embed.FS |
| +// The changelog ships inside the binary: one short, user-facing entry per file, |
| +// named <date>-<slug>.md so filename order is announcement order. The deploy |
| +// announcer bot (agent/announcer.mjs) polls /api/changelog and posts entries it |
| +// hasn't seen — "what just shipped" needs no external service, no repo |
| +// credential, and no operator machine: it travels with the deploy itself. |
| +// |
| +//go:embed changelog/*.md |
| +var changelogFS embed.FS |
| + |
| type Server struct { |
| db *DB |
| hub *Hub |
| pages map[string][]byte |
| + changelog []ChangelogEntry |
| version string // frontend build hash; clients reload when it changes (see frontendVersion) |
| vapidPub string |
| vapidPriv string |
| @@ -76,7 +87,7 @@ func main() { |
| } |
| hub := newHub() |
| go hub.run() |
| - s := &Server{db: db, hub: hub, pages: pages, version: frontendVersion()} |
| + s := &Server{db: db, hub: hub, pages: pages, changelog: loadChangelog(), version: frontendVersion()} |
| // Web Push: self-generated VAPID keypair (no third-party account). |
| if pub, priv, err := db.ensureVAPID(); err != nil { |
| @@ -154,6 +165,7 @@ func (s *Server) handler() http.Handler { |
| mux.HandleFunc("POST /api/admin/key-invites", s.createKeyInvite) |
| mux.HandleFunc("GET /api/admin/users", s.listUsers) |
| mux.HandleFunc("POST /api/admin/users/{id}/remove", s.removeUser) |
| + mux.HandleFunc("POST /api/admin/users/{id}/label", s.renameUser) |
| // A member can mint a fresh invite for their own account (add a device). |
| mux.HandleFunc("POST /api/device-invites", s.createDeviceInvite) |
| // A member can invite a new person straight into a DM (gated by an admin setting). |
| @@ -164,6 +176,7 @@ func (s *Server) handler() http.Handler { |
| // Frontend build version — clients poll this on returning to the app and reload |
| // if it changed (auto-update). No auth: it's just a build hash. |
| mux.HandleFunc("GET /api/version", s.getVersion) |
| + mux.HandleFunc("GET /api/changelog", s.getChangelog) |
| mux.HandleFunc("POST /api/admin/group", s.setGroup) |
| // Admin API (requires admin token) |
| @@ -245,6 +258,43 @@ func (s *Server) getVersion(w http.ResponseWriter, r *http.Request) { |
| writeJSON(w, http.StatusOK, map[string]any{"version": s.version}) |
| } |
| +// ChangelogEntry is one embedded release note (see the changelogFS comment). |
| +// The text is operator-authored notes about the app itself — never user |
| +// content — so, like roster labels, it lives in clear. |
| +type ChangelogEntry struct { |
| + ID string `json:"id"` // filename without .md, e.g. 2026-07-01-helpers |
| + Text string `json:"text"` // the note, posted verbatim by the announcer |
| +} |
| + |
| +// loadChangelog reads the embedded entries once at startup, in filename order |
| +// (date-prefixed names, so oldest first). |
| +func loadChangelog() []ChangelogEntry { |
| + out := []ChangelogEntry{} |
| + files, _ := changelogFS.ReadDir("changelog") |
| + for _, f := range files { // ReadDir returns names sorted |
| + b, err := changelogFS.ReadFile("changelog/" + f.Name()) |
| + if err != nil { |
| + continue |
| + } |
| + text := strings.TrimSpace(string(b)) |
| + if text == "" { |
| + continue |
| + } |
| + out = append(out, ChangelogEntry{ID: strings.TrimSuffix(f.Name(), ".md"), Text: text}) |
| + } |
| + return out |
| +} |
| + |
| +// getChangelog returns the embedded release notes. Token-gated like every data |
| +// endpoint; the announcer bot reads it with its own device token. |
| +func (s *Server) getChangelog(w http.ResponseWriter, r *http.Request) { |
| + if !s.tokenOK(r.URL.Query().Get("token")) { |
| + httpError(w, http.StatusForbidden, "not a member") |
| + return |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{"entries": s.changelog}) |
| +} |
| + |
| // ---- chat API ----------------------------------------------------------- |
| func (s *Server) getMessages(w http.ResponseWriter, r *http.Request) { |
| @@ -420,7 +470,17 @@ func (s *Server) getRoster(w http.ResponseWriter, r *http.Request) { |
| writeJSON(w, http.StatusOK, map[string]any{"roster": []RosterEntry{}}) |
| return |
| } |
| - entries, err := s.db.connections(uid) |
| + // Admin-vetted bots (agents in the Helpers catalog) see the full joined |
| + // roster, not just their connections: Eleven can't welcome a member it |
| + // can't see. This widens only what a *vetted bot* sees — people still get |
| + // connections-only, and an unvetted agent is treated like anyone else. |
| + var entries []RosterEntry |
| + var err error |
| + if s.db.isVettedAgent(uid) { |
| + entries, err = s.db.roster() |
| + } else { |
| + entries, err = s.db.connections(uid) |
| + } |
| if err != nil { |
| httpError(w, http.StatusInternalServerError, "db error") |
| return |
| @@ -1182,6 +1242,36 @@ func (s *Server) listUsers(w http.ResponseWriter, r *http.Request) { |
| writeJSON(w, http.StatusOK, map[string]any{"users": users}) |
| } |
| +// renameUser changes a user's roster label (admin roster management — e.g. |
| +// renaming a bot after its default changes). Display only: keys and history |
| +// are untouched, and messages keep the name they were sent under (it's inside |
| +// the encrypted payload, which the server can't rewrite anyway). |
| +func (s *Server) renameUser(w http.ResponseWriter, r *http.Request) { |
| + if !s.requireAdmin(w, r) { |
| + return |
| + } |
| + var body struct { |
| + Label string `json:"label"` |
| + } |
| + if json.NewDecoder(r.Body).Decode(&body) != nil { |
| + httpError(w, http.StatusBadRequest, "bad request") |
| + return |
| + } |
| + label := strings.TrimSpace(body.Label) |
| + if label == "" || len(label) > 60 { |
| + httpError(w, http.StatusBadRequest, "bad label") |
| + return |
| + } |
| + if err := s.db.setUserLabel(r.PathValue("id"), label); err == sql.ErrNoRows { |
| + httpError(w, http.StatusBadRequest, "unknown user") |
| + return |
| + } else if err != nil { |
| + httpError(w, http.StatusInternalServerError, "db error") |
| + return |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) |
| +} |
| + |
| // removeUser deletes a person from the instance (admin roster management). |
| func (s *Server) removeUser(w http.ResponseWriter, r *http.Request) { |
| if !s.requireAdmin(w, r) { |