eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

1// First-run setup — Eleven walks a fresh instance's admin through naming
2// themselves and the space, contact email, server photo, member invites, and —
3// most importantly — backing up their key. Chat-first: the whole thing is a
4// conversation (chips + typed answers), not a console. See
5// docs/conversational-onboarding.md.
6//
7// The conversation itself is DATA: setup.flow.js, interpreted by the flow
8// engine (flow.js — docs/conversation-scripts.md). This file is the app shell
9// + the host binding: the bridge-verb table (the closed set of side effects
10// the flow may name) and the io wiring the engine to eleven.js's feel (pacing,
11// persistence) and shell.js's setup bridge (setSetupBridge). Like /tutorial it
12// must work with no AI and no bot at all (a fresh instance has neither).
13import { defineApp, namespace, setupBridge, awaitAnswer, cancelAnswer, hasAnswerWaiter, appStateFor, emitAppEvent, spaceInfo, openAppThread } from "../apps.js";
14import { makeSay, makeLiveSay, sayEvents, sayBubble } from "../eleven.js";
15import { runFlow } from "../flow.js";
16import SETUP_FLOW from "../setup.flow.js";
17import { startTour } from "../tutorial.js";
18
19// One declaration of the app's prefix — N.ev("step") → the "setup.step" wire type.
20const N = namespace("setup");
21
22export const app = defineApp({
23 name: "setup",
24 title: "Eleven",
25 glyph: "🕚",
26 avatar: "/static/icon-192.png", // Eleven wears the app icon, not a clock glyph
27 // The sidebar badges this thread BOT, not APP — it presents as a conversation
28 // with Eleven, and the app machinery underneath is an implementation detail.
29 senderKind: "bot",
30 chatIntro: "This is the start of your chat with Eleven.",
31 // Chat-only: the conversation IS the thread — no Interface view, no toggle.
32 // (The old setup progress card was superseded by the Admin panel.)
33 chatOnly: true,
34 // System scope: never offered in the Apps menu — the host mounts it for the
35 // admin on first run (maybeStartSetup in shell.js), and only for them.
36 places: [], // host-mounted conversation
37 initialState: () => ({ steps: {}, said: {} }),
38 events: {
39 // A completed step. `label` is the human line the chat view shows; `value`
40 // (optional) remembers the answer, for steps that resume on it.
41 [N.ev("step")]: (s, e) => (e.step ? { ...s, steps: { ...s.steps, [e.step]: { done: true, value: e.value || "" } } } : s),
42 // Eleven's own lines persist too (via makeSay), so the conversation is
43 // real history after a reload, not just the ✓ action lines.
44 ...sayEvents(N.ev("say")),
45 },
46 chatMessage(e) {
47 const line = sayBubble(e, N.ev("say"));
48 if (line) return line;
49 if (e.type !== N.ev("step") || !e.label) return null;
50 return { text: e.label, action: true };
51 },
52 // A step-record from ANOTHER client (a second device answering this same
53 // conversation) landing while OUR engine sits parked on a question — the
54 // 2026-07-19 stale-composer bug: the transcript streamed on live but the
55 // armed "Your name:" chip never moved. Re-derive: tear the parked run down
56 // and boot the flow again — every say/record dedups against the folded
57 // log, so the restart lands parked on the first genuinely-unanswered step.
58 // Own records are filtered by the ownRecords ledger (their socket echo is
59 // not news); env.reentry suppresses the "Welcome back" re-greeting.
60 folded(threadId, event) {
61 if (event.type !== N.ev("step") || !event.step) return;
62 const env = runningEnvs.get(threadId);
63 if (!env || !hasAnswerWaiter(threadId)) return; // not parked — mid-run or ended
64 const own = ownRecords.get(threadId);
65 if (own && own.has(event.step)) return;
66 cancelAnswer(threadId);
67 running.delete(threadId);
68 clearRunTimers(threadId); // the old run's settle/probe timers die with it
69 env.reentry = true;
70 startSetup(env);
71 },
72});
73
74// ---- the host binding -------------------------------------------------------
75
76// One live run per thread (a reload starts a fresh one; the engine's step
77// guards and say-dedupe handle everything within).
78const running = new Set();
79// The live run's env, kept so the folded hook can reboot the flow in place —
80// and the steps THIS client recorded, so its own echoes never trigger one.
81const runningEnvs = new Map(); // threadId → env
82const ownRecords = new Map(); // threadId → Set(step)
83const say = makeSay("setup", N.ev("say"));
84
85// ---- run-scoped timers (#1377) ----------------------------------------------
86// flow.js documents io.delay as cancellable; a bare setTimeout let a torn-down
87// run's settle/probe timers keep walking the old step graph beside the new
88// run — two engines on one thread. Every timer is ledgered per thread and
89// cleared on re-derive, on end, and at pagehide (the same-tab sign-in
90// navigation: an in-flight probe was persisting an orphan "I'll wait here…").
91const runTimers = new Map(); // threadId → Set(timeout id)
92export function delayFor(threadId) {
93 return (ms, fn) => {
94 let set = runTimers.get(threadId);
95 if (!set) runTimers.set(threadId, (set = new Set()));
96 const id = setTimeout(() => {
97 set.delete(id);
98 fn();
99 }, ms);
100 set.add(id);
101 };
102}
103export function clearRunTimers(threadId) {
104 const set = runTimers.get(threadId);
105 if (!set) return;
106 for (const id of set) clearTimeout(id);
107 runTimers.delete(threadId);
108}
109// Guarded for Node (the flow tests import this module without a DOM).
110if (typeof addEventListener === "function") {
111 addEventListener("pagehide", () => {
112 for (const threadId of [...runTimers.keys()]) clearRunTimers(threadId);
113 });
114}
115
116// waitForRecordEchoes: records ride the WebSocket, and a same-tab redirect can
117// outrun their echoes and lose the step answered right before "Sign in" — the
118// quick form already waits exactly like this (onboardingui.js). Capped, so a
119// lost echo can never wedge the ceremony.
120export async function waitForRecordEchoes(threadId, app, own) {
121 if (!own || !own.size) return;
122 const settled = (step) => {
123 const st = appStateFor(threadId, app);
124 return !!(st && st.steps && st.steps[step] && st.steps[step].done);
125 };
126 for (let i = 0; i < 20 && ![...own].every(settled); i++) await new Promise((r) => setTimeout(r, 100));
127}
128
129// ---- the plan step's price data (#1374) -------------------------------------
130
131// The price book, fetched from home's public /api/pricebook before the flow
132// runs (startSetup awaits it, so bridge.plan* are plain sync getters like
133// every other bridge field). null = the plan step doesn't happen: a native
134// wrapper (no external purchases — App Store rules), a self-hosted or
135// already-paid space, billing off, or an unreachable book. Keyed by AUDIENCE;
136// the book's tier keys are the ledger's routing keys, mapped here.
137let planBook = null; // { team|community|family: { key, name, price } }
138
139async function fetchPlanBook() {
140 planBook = null;
141 if (window.__entitlement !== "free" || !window.__homeOrigin) return;
142 if (document.documentElement.classList.contains("native")) return;
143 try {
144 // Capped so a slow book never delays Eleven's hello — no book, no step.
145 const r = await fetch(window.__homeOrigin + "/api/pricebook", { signal: AbortSignal.timeout(4000) });
146 if (!r.ok) return;
147 const body = await r.json();
148 const audience = { business: "team", community: "community", family: "family" };
149 const book = {};
150 for (const t of body.tiers || []) {
151 const kind = audience[t.key];
152 if (kind && t.name && t.label) book[kind] = { key: t.key, name: t.name, price: t.label };
153 }
154 if (Object.keys(book).length) planBook = book;
155 } catch {
156 // Unreachable book — the conversation simply skips the plan step.
157 }
158}
159
160// The bridge-verb table: the CLOSED set of side effects the setup flow may
161// name (docs/conversation-scripts.md § interpreter contract). A flow is data —
162// it can never do anything this table doesn't offer.
163function verb(name, args, env) {
164 const bridge = setupBridge();
165 switch (name) {
166 case "setMyName":
167 return bridge.setMyName(args[0]);
168 case "setGroupName":
169 return bridge.setGroupName(args[0]);
170 case "setContactEmail":
171 return bridge.setContactEmail(args[0]);
172 case "setUserInvites":
173 return bridge.setUserInvites(args[0]);
174 case "setFeature":
175 // The extras steps' gates ("ai" | "bots" | "apps" → the #712 settings).
176 return bridge.setFeature(args[0], args[1]);
177 case "adoptServerPhoto":
178 return bridge.adoptServerPhoto(env.threadId, args[0], args[1]);
179 case "savePasskey":
180 // Wait for this run's record echoes before the same-tab navigation —
181 // the redirect must not outrun the step recorded just before the tap.
182 return waitForRecordEchoes(env.threadId, "setup", ownRecords.get(env.threadId)).then(() => bridge.savePasskey());
183 case "markSetupComplete":
184 return bridge.markSetupComplete();
185 case "deferBackupReminders":
186 return bridge.deferBackupReminders && bridge.deferBackupReminders();
187 case "openAdmin":
188 return openAppThread("admin");
189 case "openPlanCheckout": {
190 // The finish line's payment chip (#1374): an upgrade of the already-
191 // created (free) space on the home page — this server has no billing
192 // surface. The recorded plan step holds the audience; the fetched book
193 // maps it back to the tier key. Same-tab: setup is complete, and home
194 // returns you to your list once the payment settles.
195 const st = appStateFor(env.threadId, "setup");
196 const kind = st && st.steps && st.steps.plan && st.steps.plan.value;
197 const tier = planBook && kind && planBook[kind];
198 if (!tier || !window.__homeOrigin) return;
199 location.href =
200 window.__homeOrigin + "/#upgrade=" + encodeURIComponent(location.host) + "&tier=" + encodeURIComponent(tier.key);
201 return;
202 }
203 case "openQuickForm":
204 // The name step's fast path — the host owns the form sheet (web:
205 // openSetupQuickForm in shell.js; native: the quick-form sheet).
206 return env.openQuickForm && env.openQuickForm();
207 case "startTour":
208 // Reached by tapping "Take the tour" at the end of setup — the user has
209 // just spent minutes with Eleven, so skip the tour's own "Hi, fancy a
210 // tour?" intro and drop straight into it.
211 return startTour(env.respond, env.typing, { skipIntro: true });
212 default:
213 throw new Error(`setup: unknown verb ${name}`);
214 }
215}
216
217// startSetup(env): env = { threadId, respond, typing } — respond appends one
218// Eleven notice to the setup thread's feed. The host guarantees we're the
219// admin and setup isn't complete before calling.
220export function startSetup(env) {
221 const bridge = setupBridge();
222 if (!bridge || running.has(env.threadId)) return;
223 running.add(env.threadId);
224 runningEnvs.set(env.threadId, env);
225 env.sender = { name: "Eleven", kind: "bot" };
226 env.bridge = {
227 myName: () => setupBridge().myName(),
228 enrolled: () => setupBridge().enrolled(),
229 // Did this admin arrive from their Eleven sign-in page (home)? If so the key
230 // step offers to ADD this space to their existing passkey (keys-home) rather
231 // than pitching passkeys from scratch. Must be forwarded here — the flow's
232 // bridge.* only sees what env.bridge exposes.
233 fromHome: () => setupBridge().fromHome(),
234 // Whether signing in is possible at all — self-hosted spaces have no home
235 // server, and the flow skips the un-actionable pitch (#1377).
236 canEnrol: () => !!window.__homeOrigin,
237 // The plan step's price book (#1374) — absent means the step is skipped.
238 planPrices: () => !!planBook,
239 planTeam: () => !!(planBook && planBook.team),
240 planCommunity: () => !!(planBook && planBook.community),
241 planFamily: () => !!(planBook && planBook.family),
242 planNameTeam: () => (planBook && planBook.team && planBook.team.name) || "",
243 planPriceTeam: () => (planBook && planBook.team && planBook.team.price) || "",
244 planNameCommunity: () => (planBook && planBook.community && planBook.community.name) || "",
245 planPriceCommunity: () => (planBook && planBook.community && planBook.community.price) || "",
246 planNameFamily: () => (planBook && planBook.family && planBook.family.name) || "",
247 planPriceFamily: () => (planBook && planBook.family && planBook.family.price) || "",
248 };
249 env.onEnd = () => {
250 running.delete(env.threadId);
251 runningEnvs.delete(env.threadId);
252 ownRecords.delete(env.threadId);
253 clearRunTimers(env.threadId);
254 if (env.live) env.live.flowState(false); // un-mute the real Eleven
255 };
256 if (env.live) env.live.flowState(true); // the script has the floor (one Eleven)
257 const io = {
258 say: env.live ? makeLiveSay(env.live) : say,
259 ask: awaitAnswer,
260 cancelAnswer,
261 verb,
262 record: (step, label, value) => {
263 // Ledger our own record BEFORE the emit — its socket echo folds back
264 // through the `folded` hook and must read as ours, not a foreign answer.
265 let own = ownRecords.get(env.threadId);
266 if (!own) ownRecords.set(env.threadId, (own = new Set()));
267 own.add(step);
268 return emitAppEvent(env.threadId, { type: N.ev("step"), step, label, value: value || "" });
269 },
270 done: (step) => {
271 const st = appStateFor(env.threadId, "setup");
272 return !!(st && st.steps && st.steps[step] && st.steps[step].done);
273 },
274 imgSource: (token) => (token === "$serverAvatar" ? "/api/server-avatar?t=" + Date.now() : token),
275 delay: delayFor(env.threadId),
276 };
277 // The plan step's price book rides alongside the space info — both settle
278 // before the flow starts, so every bridge getter stays synchronous.
279 Promise.all([
280 spaceInfo()
281 .then((info) => {
282 env.space = info || {};
283 })
284 .catch(() => {
285 env.space = {};
286 }),
287 fetchPlanBook(),
288 ]).finally(() => runFlow(SETUP_FLOW, env, io));
289}
290