eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

1// The conversation-flow engine: walks a flow definition (pure data — see
2// docs/conversation-scripts.md) and drives one of Eleven's scripted
3// conversations through an injected `io`. The engine owns control flow only;
4// FEEL (typing beats, the reply queue, persistence dedupe, spent chips) lives
5// in eleven.js behind io.say, and every side effect goes through io.verb — the
6// closed bridge-verb contract. Nothing here touches the DOM or the network, so
7// the whole engine runs (and is tested) in Node with a fake io.
8//
9// io = {
10// say(env, text, chips, extra) — one Eleven line (eleven.js makeSay shape)
11// ask(threadId, cb) — next composer message → cb(text, payload)
12// cancelAnswer(threadId) — disarm a pending ask
13// verb(name, args, env) — run a bridge verb; resolves to its result
14// record(step, label, value) — persist a completed step (the ✓ line)
15// done(step) — is a step already recorded done?
16// imgSource(token) — resolve an image token ("$serverAvatar"…)
17// delay(ms, fn) — a cancellable timer (probes); tests fire it
18// }
19
20// ---- the name-step answer cleaner (input.clean === "name") ---------------
21// People answer a conversational "what should folks here call you?"
22// conversationally — "I'm Dermot", "hi, my name is Ana." — and the raw reply
23// used to become their screen name verbatim (a real member spent days as
24// "I'm Dermot"). Strip one leading greeting, one leading preamble, and any
25// trailing punctuation; if stripping eats everything, keep the trimmed
26// original — never clean a name to nothing. Prefix lists are matched on a
27// lowercased, straight-apostrophe copy (iOS keyboards type ’), longest
28// preamble first so "i'm called" wins over "i'm".
29// Twin: FlowEngine.cleanNameAnswer (native runs the same flows).
30const NAME_GREETINGS = ["hiya", "howdy", "hello", "hey", "hi"];
31const NAME_PREAMBLES = [
32 "you can call me", "everyone calls me", "people call me", "they call me",
33 "i'm called", "i am called", "my name is", "my name's", "the name's",
34 "name is", "name's", "call me", "i am", "i'm", "im", "it's", "its",
35];
36
37export function cleanNameAnswer(raw) {
38 const original = (raw || "").trim();
39 let s = original;
40 const lower = () => s.toLowerCase().replaceAll("’", "'");
41 for (const g of NAME_GREETINGS) {
42 if (!lower().startsWith(g)) continue;
43 let i = g.length; // greeting must end at punctuation-or-space ("hi," yes, "hilary" no)
44 if (i < s.length && ",.!".includes(s[i])) i++;
45 if (i < s.length && s[i] === " ") s = s.slice(i + 1).trim();
46 break;
47 }
48 const l = lower();
49 for (const p of NAME_PREAMBLES) {
50 if (!l.startsWith(p + " ")) continue;
51 s = s.slice(p.length + 1).trim();
52 break;
53 }
54 while (s && ".!,;:".includes(s[s.length - 1])) s = s.slice(0, -1);
55 s = s.trim();
56 return s || original;
57}
58
59// ---- tiny expression + template language ---------------------------------
60// Conditions are dotted paths into the env with an optional leading "!":
61// "vars.photoAlt", "!vars.backedUp", "space.nameSet", "bridge.enrolled",
62// "resumed", "done" (this step recorded). bridge.* resolves through env.bridge
63// (calling functions), everything else reads env / env.vars / env.space.
64
65function resolve(env, path) {
66 if (path === "done") return env.__done();
67 // "done.X" reads another step's recorded state — welcome's resumed check
68 // ("did access or name already happen?") needs cross-step lookups.
69 if (path.startsWith("done.")) return env.__doneOf(path.slice(5));
70 const parts = path.split(".");
71 let cur = env;
72 for (const p of parts) {
73 if (cur == null) return undefined;
74 cur = cur[p];
75 if (typeof cur === "function") cur = cur.call(env);
76 }
77 return cur;
78}
79
80// Conditions: a dotted path with optional "!", or the compound forms
81// {all:[...]} / {any:[...]} for the few spots that genuinely need AND/OR
82// (welcome's passkey-return check is enrolled AND not-yet-recorded).
83export function evalCond(env, expr) {
84 if (expr == null) return true;
85 if (typeof expr === "boolean") return expr;
86 if (typeof expr === "object") {
87 if (expr.all) return expr.all.every((e) => evalCond(env, e));
88 if (expr.any) return expr.any.some((e) => evalCond(env, e));
89 }
90 const neg = expr.startsWith("!");
91 const v = resolve(env, neg ? expr.slice(1) : expr);
92 return neg ? !v : !!v;
93}
94
95// "{path}" and "{path|fallback}" interpolation; "{ack?}" splices (and consumes)
96// the pending acknowledgement from the previous step, with its trailing gap.
97export function template(env, s) {
98 return String(s).replace(/\{([^}]+)\}/g, (_, body) => {
99 if (body === "ack?") {
100 const a = env.__ack;
101 env.__ack = "";
102 return a ? a + "\n\n" : "";
103 }
104 const [path, fallback] = body.split("|");
105 const v = resolve(env, path.trim());
106 return v == null || v === "" ? (fallback != null ? fallback : "") : String(v);
107 });
108}
109
110// A text value is a string, {if, then, else}, or an array of segments
111// (strings, or {if, text}) joined after filtering.
112export function renderText(env, t) {
113 if (t == null) return "";
114 if (typeof t === "string") return template(env, t);
115 if (Array.isArray(t)) {
116 return t
117 .map((seg) => (typeof seg === "string" ? template(env, seg) : evalCond(env, seg.if) ? template(env, seg.text) : ""))
118 .join("");
119 }
120 return template(env, evalCond(env, t.if) ? t.then : t.else || "");
121}
122
123// ---- the walker -----------------------------------------------------------
124
125// One-tap-wins step guard (mirrors eleven.js onceStep; local so the engine has
126// no import and runs bare in Node).
127function onceStep() {
128 const go = (fn) => {
129 if (go.taken) return;
130 go.taken = true;
131 return fn();
132 };
133 go.taken = false;
134 return go;
135}
136
137// Map a verb result onto a branch key: void/true → ok, false → error, strings
138// verbatim (setGroupName's "ok"/"invalid"/"error", adoptProfilePhoto's "nokey").
139const resultKey = (r) => (r === undefined || r === true ? "ok" : r === false ? "error" : String(r));
140
141export function runFlow(flow, env, io) {
142 const byId = {};
143 flow.steps.forEach((s, i) => (byId[s.id] = { step: s, next: flow.steps[i + 1] ? flow.steps[i + 1].id : null }));
144 env.vars = env.vars || {};
145 env.__ack = "";
146 env.__doneOf = (id) => io.done(id);
147
148 // The resumed re-greeting (a client-only line — history already shows the
149 // real bubbles). Flows that special-case the return leg (welcome's passkey
150 // ack) suppress it with resumeUnless. env.reentry marks a MID-SESSION
151 // re-derive (another client answered the parked step — apps/setup.js's
152 // folded hook), not a returning member: greeting them "Welcome back" once
153 // per foreign answer would spam the feed.
154 env.resumed = evalCond(env, flow.resumedIf || false);
155 // Consume the reentry flag: one re-derive suppresses one greeting. Left
156 // set (the pre-#1377 bug) every later genuine resume in the same page life
157 // lost its "Welcome back".
158 const reentry = env.reentry;
159 env.reentry = false;
160 if (env.resumed && !reentry
161 && !(flow.resumeUnless && evalCond(env, flow.resumeUnless)) && flow.resumeSay) {
162 io.say(env, flow.resumeSay, null, { local: true });
163 }
164
165 const setPaths = (sets) => {
166 for (const [path, value] of Object.entries(sets || {})) {
167 const parts = path.split(".");
168 let cur = env;
169 while (parts.length > 1) cur = cur[parts.shift()] || {};
170 cur[parts[0]] = typeof value === "string" ? valueOf(value) : value;
171 }
172 };
173 // "$answer"/"$answerText" read the captured input; anything else templates.
174 const valueOf = (v) =>
175 v === "$answer" ? env.vars.answer : v === "$answerText" ? env.vars.answerText : typeof v === "string" ? template(env, v) : v;
176
177 async function runActions(a, stepCtx) {
178 if (!a) return false;
179 if (a.set) setPaths(a.set);
180 // A record may name its step explicitly: the photo PREVIEW records "photo",
181 // the keys WAITING/CONFIRM steps record "keys" — resume guards check those.
182 if (a.record) await io.record(a.record.step || stepCtx.step.id, renderText(env, a.record.label), a.record.value != null ? valueOf(a.record.value) : "");
183 if (a.ack) env.__ack = renderText(env, a.ack);
184 if (a.say) io.say(env, renderText(env, a.say), null, a.local ? { local: true } : undefined);
185 if (a.ask) {
186 askIn(stepCtx, renderText(env, a.ask.text != null ? a.ask.text : a.ask));
187 return true;
188 }
189 if (a.gotoAsk) {
190 // Jump to another step's ASK portion with fresh prompt text, skipping its
191 // context bubbles (the preview's "Use a different photo" → the photo ask).
192 askIn(byId[a.gotoAsk.step], renderText(env, a.gotoAsk.text));
193 return true;
194 }
195 if (a.retry === "step") {
196 enter(stepCtx.step.id);
197 return true;
198 }
199 if (a.goto) {
200 enter(a.goto);
201 return true;
202 }
203 return false;
204 }
205
206 async function runEffect(stepCtx, eff, args) {
207 const r = await io.verb(eff.verb, args || (eff.args || []).map(valueOf), env);
208 const on = stepCtx.step.on || {};
209 const branch = on[resultKey(r)];
210 if (!branch) return false; // fire-and-forget verb (setMyName, savePasskey)
211 const moved = await runActions(branch, stepCtx);
212 if (!moved) {
213 // A branch with no movement re-arms in place (rejected email re-asks).
214 armInput(stepCtx);
215 }
216 return true;
217 }
218
219 function buildChips(stepCtx, go) {
220 return (stepCtx.step.chips || []).filter((c) => evalCond(env, c.if)).map((c) => ({
221 label: renderText(env, c.label),
222 done: c.spent,
223 // A trailing chip renders right-aligned — the quiet alternative path
224 // (the quick form) sits apart from the step's primary answers.
225 trailing: c.trailing ? true : undefined,
226 // Finish-line chips (advancing:false, no probe) stay live forever.
227 stale: c.advancing === false && !c.probe ? undefined : () => go.taken,
228 run: () => {
229 const act = async () => {
230 if (c.cancelInput) io.cancelAnswer(env.threadId);
231 // Sets land before the effect so its args can read them (the
232 // suggested-name chip sets vars.answer, then setMyName reads it).
233 if (c.set) setPaths(c.set);
234 if (c.effect) await io.verb(c.effect.verb, (c.effect.args || []).map(valueOf), env);
235 await runActions(c, stepCtx);
236 };
237 if (c.advancing === false) {
238 act();
239 // The probe: if the verb didn't take us away (the passkey ceremony
240 // stayed in place), fall through to the waiting step.
241 if (c.probe) io.delay(c.probe.afterMs, () => {
242 if (!evalCond(env, c.probe.unless)) go(() => enter(c.probe.goto));
243 });
244 } else {
245 go(act);
246 }
247 },
248 }));
249 }
250
251 // (Re-)arm the step's input without re-saying its bubbles.
252 function armInput(stepCtx) {
253 const { step } = stepCtx;
254 if (!step.input) return;
255 if (step.input.kind === "text") {
256 io.ask(env.threadId, (text) =>
257 stepCtx.go(async () => {
258 // Clean BEFORE the cap so a chatty preamble can't eat into it
259 // ("my name is " + 25 chars must keep the whole name).
260 let answer = (text || "").trim();
261 if (step.input.clean === "name") answer = cleanNameAnswer(answer);
262 env.vars.answer = answer.slice(0, step.input.maxLen || Infinity);
263 // A required answer left empty re-asks the whole step (say-dedupe by
264 // key means no duplicate bubbles — the input just re-arms).
265 if (step.input.required && !env.vars.answer) return enter(step.id);
266 if (step.effect) await runEffect(stepCtx, step.effect);
267 })
268 );
269 } else if (step.input.kind === "photo") {
270 const arm = () =>
271 io.ask(env.threadId, (text, payload) => {
272 const img = (((payload || {}).attachments) || []).find((a) => a && a.type && String(a.type).startsWith("image/"));
273 if (!img) {
274 io.say(env, renderText(env, step.input.reject.text), null, step.input.reject.local ? { local: true } : undefined);
275 return arm();
276 }
277 stepCtx.go(async () => {
278 env.vars.answerText = (text || "").trim();
279 await runEffect(stepCtx, step.effect, [img, env.vars.answerText]);
280 });
281 });
282 arm();
283 }
284 }
285
286 async function enter(id) {
287 const stepCtx = byId[id];
288 if (!stepCtx) throw new Error(`flow ${flow.flow}: unknown step "${id}"`);
289 const { step } = stepCtx;
290 env.__done = () => io.done(step.id);
291
292 // Entry branches: state that satisfies the step on arrival (an enrolled
293 // passkey, a name already set) — with once-only ack lines the first time.
294 for (const e of step.entries || []) {
295 if (!evalCond(env, e.if)) continue;
296 if (e.set) setPaths(e.set);
297 if (!io.done(step.id)) {
298 if (e.recordIfNew) await io.record(step.id, renderText(env, e.recordIfNew.label), e.recordIfNew.value || "");
299 if (e.sayIfNew) io.say(env, renderText(env, e.sayIfNew));
300 }
301 return enter(e.goto || stepCtx.next);
302 }
303 if ((step.skipIf || []).some((c) => evalCond(env, c))) return enter(step.skipTo || stepCtx.next);
304
305 // A step may persist itself on arrival (welcome's finish records its
306 // completion marker) — once only, so a host can tell a FINISHED
307 // conversation from a half-done one purely from recorded state.
308 if (step.record && !io.done(step.id)) {
309 await io.record(step.id, renderText(env, step.record.label), step.record.value != null ? step.record.value : "");
310 }
311
312 stepCtx.go = onceStep();
313 // Effects fire before the step speaks (markSetupComplete lands before the
314 // "you're all set" bubble, as the hand-written flow did).
315 if (step.effects) {
316 for (const eff of step.effects) if (evalCond(env, eff.if)) await io.verb(eff.verb, (eff.args || []).map(valueOf), env);
317 }
318 const bubbles = (step.bubbles || []).filter((b) => evalCond(env, b.if));
319 for (let i = 0; i < bubbles.length; i++) {
320 const b = bubbles[i];
321 const last = i === bubbles.length - 1;
322 const extra = {};
323 if (b.key) extra.key = template(env, b.key);
324 if (b.hint) extra.hint = b.hint;
325 if (b.attachHint) extra.attachHint = b.attachHint;
326 if (b.label) extra.label = b.label; // the composer's inset field label
327 if (b.local) extra.local = true;
328 if (b.img) {
329 // imgSource may be async (welcome's profile photo decrypts to an
330 // object URL); a falsy resolution drops the image, keeps the bubble.
331 const src = await io.imgSource(b.img);
332 if (src) {
333 extra.img = src;
334 extra.imgAlt = renderText(env, b.imgAlt || "");
335 if (b.imgRound) extra.imgRound = true;
336 }
337 }
338 // Chips ride the last bubble — the question the answer belongs to.
339 io.say(env, renderText(env, b.text), last ? orNull(buildChips(stepCtx, stepCtx.go)) : null, extra);
340 }
341 if (!bubbles.length && step.chips) {
342 // Chip-only continuation (no new bubble) — not used by setup, kept tiny.
343 io.say(env, "", buildChips(stepCtx, stepCtx.go), {});
344 }
345 armInput(stepCtx);
346 // A step may WAIT for a condition to become true on its own — the passkey
347 // ceremony returns asynchronously (a redirect back, or the user tabbing back
348 // from a system browser), so `bridge.enrolled` can flip true seconds after
349 // we've shown the pitch. Poll it while the step is on screen and, the moment
350 // it holds, re-enter the step so its entries fire (record the backup, advance)
351 // — no manual "Continue" tap. The shared onceStep guard means a chip tap still
352 // wins and stops the poll.
353 if (step.settle && !evalCond(env, step.settle.when)) {
354 const go = stepCtx.go;
355 const period = step.settle.everyMs || 700;
356 const tick = () => {
357 if (go.taken) return; // a chip/advance already took the step
358 if (evalCond(env, step.settle.when)) return go(() => enter(step.settle.goto || step.id));
359 io.delay(period, tick);
360 };
361 io.delay(period, tick);
362 }
363 // A bubble-only step may auto-advance (welcome's greeting → the passkey
364 // step); input/chip steps advance through answers instead. ("auto", not
365 // "then" — text conditionals already use then/else.)
366 if (step.auto) return enter(step.auto);
367 if (step.end) env.onEnd && env.onEnd();
368 }
369
370 // A retry that re-asks with new text: one fresh line carrying the step's
371 // chips + hint, then the input re-arms (askEmail/askPhoto recursion).
372 function askIn(stepCtx, text) {
373 stepCtx.go = onceStep();
374 const extra = {};
375 const src = stepCtx.step.bubbles && stepCtx.step.bubbles[stepCtx.step.bubbles.length - 1];
376 if (src && src.hint) extra.hint = src.hint;
377 if (src && src.attachHint) extra.attachHint = src.attachHint;
378 if (src && src.label) extra.label = src.label;
379 io.say(env, text, orNull(buildChips(stepCtx, stepCtx.go)), extra);
380 armInput(stepCtx);
381 }
382
383 const orNull = (chips) => (chips && chips.length ? chips : null);
384
385 return enter(flow.entry);
386}
387