eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

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.
17import fs from "node:fs";
18import { fileURLToPath } from "node:url";
19import { importKey, encrypt, decrypt, generateKey, wrapThreadKey, unwrapThreadKey } from "../web/static/crypto.js";
20import { BASE, requireBase, AI_ENABLED } from "./config.mjs";
21import { provision } from "./provision.mjs";
22import { commandSummary } from "./commands.mjs";
23import { APP_KNOWLEDGE } from "./knowledge.mjs";
24
25const log = (m) => {
26 try {
27 fs.writeSync(1, `eleven: ${m}\n`);
28 } catch {}
29};
30
31export const ELEVEN_NAME = process.env.LCHAT_ELEVEN_NAME || "Eleven";
32export const ELEVEN_SESSION_PATH =
33 process.env.LCHAT_ELEVEN_SESSION || fileURLToPath(new URL("./eleven-session.json", import.meta.url));
34const WELCOMED_PATH = `${ELEVEN_SESSION_PATH}.welcomed.json`;
35const 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.
39export 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.
56export 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
63const 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.
67export 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.
80const 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.
91const 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).
96async 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.
108async 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).
119export 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
201function 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.
208export 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.
222if (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