| 1 | // Eleven — the app's guide. One of the sidecar's default identities: it knows |
| 2 | // how lchat works (persona + reference shipped here in code, so every deploy |
| 3 | // updates its knowledge), answers "how do I…?" questions in DMs and on |
| 4 | // @mention, and WELCOMES each new member with a DM when they join. |
| 5 | // |
| 6 | // The welcome loop needs to see members it shares no thread with, which is why |
| 7 | // vetted bots (agent + available_to_members) read the full joined roster — see |
| 8 | // getRoster in main.go. First run baselines silently: existing members are |
| 9 | // marked welcomed without being messaged, so rolling Eleven out to a lived-in |
| 10 | // instance doesn't spam everyone. State lives next to its session on /data. |
| 11 | // |
| 12 | // LCHAT_ELEVEN_SESSION identity file (default: eleven-session.json here) |
| 13 | // LCHAT_ELEVEN_INVITE first boot only: fragment of an admin agent-invite |
| 14 | // |
| 15 | // `node eleven.mjs` runs one welcome tick standalone (used by tests); inside |
| 16 | // the sidecar, agent.mjs calls startWelcomer() + runBot() with these exports. |
| 17 | import fs from "node:fs"; |
| 18 | import { fileURLToPath } from "node:url"; |
| 19 | import { importKey, encrypt, decrypt, generateKey, wrapThreadKey, unwrapThreadKey } from "../web/static/crypto.js"; |
| 20 | import { BASE, requireBase, AI_ENABLED } from "./config.mjs"; |
| 21 | import { provision } from "./provision.mjs"; |
| 22 | import { commandSummary } from "./commands.mjs"; |
| 23 | import { APP_KNOWLEDGE } from "./knowledge.mjs"; |
| 24 | |
| 25 | const log = (m) => { |
| 26 | try { |
| 27 | fs.writeSync(1, `eleven: ${m}\n`); |
| 28 | } catch {} |
| 29 | }; |
| 30 | |
| 31 | export const ELEVEN_NAME = process.env.LCHAT_ELEVEN_NAME || "Eleven"; |
| 32 | export const ELEVEN_SESSION_PATH = |
| 33 | process.env.LCHAT_ELEVEN_SESSION || fileURLToPath(new URL("./eleven-session.json", import.meta.url)); |
| 34 | const WELCOMED_PATH = `${ELEVEN_SESSION_PATH}.welcomed.json`; |
| 35 | const POLL_MS = Number(process.env.LCHAT_WELCOME_POLL_MS) || 60 * 1000; |
| 36 | |
| 37 | // Shipped-in persona + app knowledge (console config can still override per |
| 38 | // instance). Update these alongside features — they deploy with the sidecar. |
| 39 | export const ELEVEN_PERSONA = |
| 40 | "You are the app's friendly guide. The app is called Eleven — yes, you share its name; own the joke " + |
| 41 | "if it comes up. Help people get the most out of it: answer questions about " + |
| 42 | "how the app works, suggest features they might not know, and keep answers concrete and step-by-step. " + |
| 43 | "You can DO some things for people via one-tap buttons: when someone asks to change the theme, switch " + |
| 44 | "light/dark mode, browse themes, or edit their profile, call the matching tool — it attaches a button " + |
| 45 | "they tap to apply (nothing happens without their tap, and only on their own device). Keep the " + |
| 46 | "accompanying text short, like: 'Here you go — tap to switch.' " + |
| 47 | "You're not only an AI — you're also a bot: the very same actions are available as instant slash " + |
| 48 | "commands (like /dark or /theme lagoon) that work even when AI features are switched off. When it's " + |
| 49 | "natural, mention the quick command alongside the button so people learn the shortcut. " + |
| 50 | "If someone asks something unrelated to the app, you can chat — but gently note that Claude (the other " + |
| 51 | "helper) is the better companion for general questions."; |
| 52 | // Eleven's reference: the exhaustive, open app/architecture knowledge base |
| 53 | // (knowledge.mjs — updated with every deploy and marked cacheable in the runtime), |
| 54 | // followed by the LIVE list of slash commands so the two never drift from the |
| 55 | // shared BOT_COMMANDS. Console config can still override `docs` per instance. |
| 56 | export const ELEVEN_DOCS = [ |
| 57 | APP_KNOWLEDGE, |
| 58 | "Slash commands you can run yourself — they work instantly, even when conversational AI is off, so " + |
| 59 | "point people at the one that fits:\n" + |
| 60 | commandSummary(), |
| 61 | ].join("\n\n"); |
| 62 | |
| 63 | const readJSON = (p) => (fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, "utf8")) : null); |
| 64 | |
| 65 | // Load or first-boot-provision Eleven's identity. Null (disabled) when there's |
| 66 | // neither a session nor an invite. |
| 67 | export async function loadElevenSession() { |
| 68 | const existing = readJSON(ELEVEN_SESSION_PATH); |
| 69 | if (existing) return existing; |
| 70 | const T = process.env.LCHAT_ELEVEN_INVITE; |
| 71 | if (!T) return null; |
| 72 | const session = await provision(T, { name: ELEVEN_NAME, path: ELEVEN_SESSION_PATH }); |
| 73 | log(`provisioned "${ELEVEN_NAME}"`); |
| 74 | return session; |
| 75 | } |
| 76 | |
| 77 | // The closing line adapts to whether Eleven has its model brain: with AI it |
| 78 | // invites free-form questions; without, it points at the commands it can |
| 79 | // actually run — so the welcome never promises what this instance can't do. |
| 80 | const welcomeText = (name, aiEnabled) => |
| 81 | `Hi ${name || "there"}, welcome! 👋 I'm ${ELEVEN_NAME}, your guide to this app.\n\n` + |
| 82 | `Everything here is end-to-end encrypted — only the people in a chat can read it, never the server.\n\n` + |
| 83 | `Tap “Take the tour” below for a quick guided walkthrough (or type /tutorial anytime). ` + |
| 84 | (aiEnabled |
| 85 | ? `You can also just ask me anything about how the app works.` |
| 86 | : `And /help lists what I can do.`); |
| 87 | |
| 88 | // The welcome offers a one-tap chip that starts the client-side guided tour |
| 89 | // (tutorial.js). It's a harmless client action, so it rides even on an |
| 90 | // admin-customised welcome — the tour is useful either way. |
| 91 | const WELCOME_ACTIONS = [{ type: "open_tutorial" }]; |
| 92 | |
| 93 | // Member display names are encrypted under the instance key (the server is |
| 94 | // blind to them). Fetch + unwrap our copy; null if a holder hasn't granted it to |
| 95 | // us yet (then we greet without a name rather than block). |
| 96 | async function loadInstanceKey(session, get) { |
| 97 | try { |
| 98 | const r = await get("/api/instance-key"); |
| 99 | if (!r || !r.wrapped) return null; |
| 100 | return await importKey(await unwrapThreadKey(r.wrapped, session.userPriv)); |
| 101 | } catch { |
| 102 | return null; |
| 103 | } |
| 104 | } |
| 105 | |
| 106 | // A member's display name: decrypt the instance-key blob when we can, else fall |
| 107 | // back to any plaintext label (bots keep one), else "" → a nameless greeting. |
| 108 | async function memberName(person, instanceKey) { |
| 109 | if (person.label_blob && instanceKey) { |
| 110 | try { |
| 111 | return await decrypt(instanceKey, person.label_blob); |
| 112 | } catch {} |
| 113 | } |
| 114 | return person.label || ""; |
| 115 | } |
| 116 | |
| 117 | // One welcome pass: look for joined members we haven't welcomed, DM them. |
| 118 | // Returns false when Eleven is disabled (no session/invite). |
| 119 | export async function welcomeTick() { |
| 120 | const session = await loadElevenSession(); |
| 121 | if (!session) return false; |
| 122 | const get = (path) => |
| 123 | fetch(`${BASE}${path}${path.includes("?") ? "&" : "?"}token=${encodeURIComponent(session.token)}`).then((r) => |
| 124 | r.json() |
| 125 | ); |
| 126 | |
| 127 | const instanceKey = await loadInstanceKey(session, get); |
| 128 | const { roster = [] } = await get("/api/roster"); |
| 129 | // People only (never other bots), joined only (a pending invitee has no key |
| 130 | // to wrap a DM to — they're picked up on a later tick, right after they join). |
| 131 | const people = roster.filter((e) => e.public_key && !e.is_agent && e.public_key !== session.userPub); |
| 132 | |
| 133 | const state = readJSON(WELCOMED_PATH); |
| 134 | if (!state) { |
| 135 | // First run: existing members are marked welcomed silently — Eleven greets |
| 136 | // people who arrive after it, it doesn't spam a lived-in instance. |
| 137 | fs.writeFileSync(WELCOMED_PATH, JSON.stringify({ welcomed: people.map((e) => e.user_id) })); |
| 138 | log(`baselined at ${people.length} existing member(s)`); |
| 139 | return true; |
| 140 | } |
| 141 | const welcomed = new Set(state.welcomed || []); |
| 142 | const newcomers = people.filter((e) => !welcomed.has(e.user_id)); |
| 143 | if (!newcomers.length) return true; |
| 144 | |
| 145 | // Existing DM threads (e.g. an invite's pending DM the newcomer adopted, or a |
| 146 | // re-run after a partial failure): don't create a duplicate, post into it. |
| 147 | const { threads = [] } = await get("/api/threads"); |
| 148 | |
| 149 | // Admin-tweakable welcome text (console → bot_configs.welcome). Falls back to |
| 150 | // the shipped template; a literal "{name}" is replaced with the member's name. |
| 151 | let welcomeTmpl = ""; |
| 152 | try { |
| 153 | const cfg = await get("/api/bot-config"); |
| 154 | if (cfg && cfg.welcome) welcomeTmpl = cfg.welcome; |
| 155 | } catch {} |
| 156 | |
| 157 | for (const person of newcomers) { |
| 158 | try { |
| 159 | let dm = threads.find((t) => t.kind === "dm" && (t.members || []).includes(person.user_id)); |
| 160 | let key; |
| 161 | if (dm) { |
| 162 | key = await importKey(await unwrapThreadKey(dm.wrapped, session.userPriv)); |
| 163 | } else { |
| 164 | const tk = await generateKey(); |
| 165 | key = tk.key; |
| 166 | const r = await fetch(`${BASE}/api/threads`, { |
| 167 | method: "POST", |
| 168 | headers: { "content-type": "application/json" }, |
| 169 | body: JSON.stringify({ |
| 170 | token: session.token, |
| 171 | kind: "dm", |
| 172 | name_blob: "", |
| 173 | members: [ |
| 174 | { user_id: rosterSelfId(roster, session), wrapped: await wrapThreadKey(tk.b64, session.userPub) }, |
| 175 | { user_id: person.user_id, wrapped: await wrapThreadKey(tk.b64, person.public_key) }, |
| 176 | ], |
| 177 | }), |
| 178 | }).then((x) => x.json()); |
| 179 | if (!r.thread_id) throw new Error("createThread failed"); |
| 180 | dm = { id: r.thread_id }; |
| 181 | } |
| 182 | const name = await memberName(person, instanceKey); |
| 183 | const text = welcomeTmpl ? welcomeTmpl.replaceAll("{name}", name || "there") : welcomeText(name, AI_ENABLED); |
| 184 | const blob = await encrypt(key, { name: ELEVEN_NAME, text, ts: Date.now(), actions: WELCOME_ACTIONS }); |
| 185 | const pr = await fetch(`${BASE}/api/messages?token=${encodeURIComponent(session.token)}`, { |
| 186 | method: "POST", |
| 187 | headers: { "content-type": "application/json" }, |
| 188 | body: JSON.stringify({ thread_id: dm.id, blob }), |
| 189 | }); |
| 190 | if (!pr.ok) throw new Error(`post ${pr.status}`); |
| 191 | welcomed.add(person.user_id); |
| 192 | log(`welcomed ${name || person.user_id}`); |
| 193 | } catch (e) { |
| 194 | log(`welcome ${person.user_id}: ${e.message}`); // retried next tick (not marked welcomed) |
| 195 | } |
| 196 | } |
| 197 | fs.writeFileSync(WELCOMED_PATH, JSON.stringify({ welcomed: [...welcomed] })); |
| 198 | return true; |
| 199 | } |
| 200 | |
| 201 | function rosterSelfId(roster, session) { |
| 202 | const me = roster.find((e) => e.public_key === session.userPub); |
| 203 | if (!me) throw new Error("own roster entry not found"); |
| 204 | return me.user_id; |
| 205 | } |
| 206 | |
| 207 | // The sidecar's welcome loop; failures log and retry next tick. |
| 208 | export function startWelcomer() { |
| 209 | const tick = () => |
| 210 | welcomeTick() |
| 211 | .then((enabled) => { |
| 212 | if (!enabled) log("disabled (no session and no LCHAT_ELEVEN_INVITE)"); |
| 213 | return enabled; |
| 214 | }) |
| 215 | .catch((e) => (log(`tick failed: ${e.message}`), true)); |
| 216 | tick().then((enabled) => { |
| 217 | if (enabled) setInterval(() => tick(), POLL_MS); |
| 218 | }); |
| 219 | } |
| 220 | |
| 221 | // CLI: one welcome tick. |
| 222 | if (import.meta.url === `file://${process.argv[1]}`) { |
| 223 | requireBase(); |
| 224 | if (!(await welcomeTick())) { |
| 225 | console.error("No Eleven session and no LCHAT_ELEVEN_INVITE set."); |
| 226 | process.exit(1); |
| 227 | } |
| 228 | } |
| 229 | |