// The conversation-flow engine: walks a flow definition (pure data — see // docs/conversation-scripts.md) and drives one of Eleven's scripted // conversations through an injected `io`. The engine owns control flow only; // FEEL (typing beats, the reply queue, persistence dedupe, spent chips) lives // in eleven.js behind io.say, and every side effect goes through io.verb — the // closed bridge-verb contract. Nothing here touches the DOM or the network, so // the whole engine runs (and is tested) in Node with a fake io. // // io = { // say(env, text, chips, extra) — one Eleven line (eleven.js makeSay shape) // ask(threadId, cb) — next composer message → cb(text, payload) // cancelAnswer(threadId) — disarm a pending ask // verb(name, args, env) — run a bridge verb; resolves to its result // record(step, label, value) — persist a completed step (the ✓ line) // done(step) — is a step already recorded done? // imgSource(token) — resolve an image token ("$serverAvatar"…) // delay(ms, fn) — a cancellable timer (probes); tests fire it // } // ---- the name-step answer cleaner (input.clean === "name") --------------- // People answer a conversational "what should folks here call you?" // conversationally — "I'm Dermot", "hi, my name is Ana." — and the raw reply // used to become their screen name verbatim (a real member spent days as // "I'm Dermot"). Strip one leading greeting, one leading preamble, and any // trailing punctuation; if stripping eats everything, keep the trimmed // original — never clean a name to nothing. Prefix lists are matched on a // lowercased, straight-apostrophe copy (iOS keyboards type ’), longest // preamble first so "i'm called" wins over "i'm". // Twin: FlowEngine.cleanNameAnswer (native runs the same flows). const NAME_GREETINGS = ["hiya", "howdy", "hello", "hey", "hi"]; const NAME_PREAMBLES = [ "you can call me", "everyone calls me", "people call me", "they call me", "i'm called", "i am called", "my name is", "my name's", "the name's", "name is", "name's", "call me", "i am", "i'm", "im", "it's", "its", ]; export function cleanNameAnswer(raw) { const original = (raw || "").trim(); let s = original; const lower = () => s.toLowerCase().replaceAll("’", "'"); for (const g of NAME_GREETINGS) { if (!lower().startsWith(g)) continue; let i = g.length; // greeting must end at punctuation-or-space ("hi," yes, "hilary" no) if (i < s.length && ",.!".includes(s[i])) i++; if (i < s.length && s[i] === " ") s = s.slice(i + 1).trim(); break; } const l = lower(); for (const p of NAME_PREAMBLES) { if (!l.startsWith(p + " ")) continue; s = s.slice(p.length + 1).trim(); break; } while (s && ".!,;:".includes(s[s.length - 1])) s = s.slice(0, -1); s = s.trim(); return s || original; } // ---- tiny expression + template language --------------------------------- // Conditions are dotted paths into the env with an optional leading "!": // "vars.photoAlt", "!vars.backedUp", "space.nameSet", "bridge.enrolled", // "resumed", "done" (this step recorded). bridge.* resolves through env.bridge // (calling functions), everything else reads env / env.vars / env.space. function resolve(env, path) { if (path === "done") return env.__done(); // "done.X" reads another step's recorded state — welcome's resumed check // ("did access or name already happen?") needs cross-step lookups. if (path.startsWith("done.")) return env.__doneOf(path.slice(5)); const parts = path.split("."); let cur = env; for (const p of parts) { if (cur == null) return undefined; cur = cur[p]; if (typeof cur === "function") cur = cur.call(env); } return cur; } // Conditions: a dotted path with optional "!", or the compound forms // {all:[...]} / {any:[...]} for the few spots that genuinely need AND/OR // (welcome's passkey-return check is enrolled AND not-yet-recorded). export function evalCond(env, expr) { if (expr == null) return true; if (typeof expr === "boolean") return expr; if (typeof expr === "object") { if (expr.all) return expr.all.every((e) => evalCond(env, e)); if (expr.any) return expr.any.some((e) => evalCond(env, e)); } const neg = expr.startsWith("!"); const v = resolve(env, neg ? expr.slice(1) : expr); return neg ? !v : !!v; } // "{path}" and "{path|fallback}" interpolation; "{ack?}" splices (and consumes) // the pending acknowledgement from the previous step, with its trailing gap. export function template(env, s) { return String(s).replace(/\{([^}]+)\}/g, (_, body) => { if (body === "ack?") { const a = env.__ack; env.__ack = ""; return a ? a + "\n\n" : ""; } const [path, fallback] = body.split("|"); const v = resolve(env, path.trim()); return v == null || v === "" ? (fallback != null ? fallback : "") : String(v); }); } // A text value is a string, {if, then, else}, or an array of segments // (strings, or {if, text}) joined after filtering. export function renderText(env, t) { if (t == null) return ""; if (typeof t === "string") return template(env, t); if (Array.isArray(t)) { return t .map((seg) => (typeof seg === "string" ? template(env, seg) : evalCond(env, seg.if) ? template(env, seg.text) : "")) .join(""); } return template(env, evalCond(env, t.if) ? t.then : t.else || ""); } // ---- the walker ----------------------------------------------------------- // One-tap-wins step guard (mirrors eleven.js onceStep; local so the engine has // no import and runs bare in Node). function onceStep() { const go = (fn) => { if (go.taken) return; go.taken = true; return fn(); }; go.taken = false; return go; } // Map a verb result onto a branch key: void/true → ok, false → error, strings // verbatim (setGroupName's "ok"/"invalid"/"error", adoptProfilePhoto's "nokey"). const resultKey = (r) => (r === undefined || r === true ? "ok" : r === false ? "error" : String(r)); export function runFlow(flow, env, io) { const byId = {}; flow.steps.forEach((s, i) => (byId[s.id] = { step: s, next: flow.steps[i + 1] ? flow.steps[i + 1].id : null })); env.vars = env.vars || {}; env.__ack = ""; env.__doneOf = (id) => io.done(id); // The resumed re-greeting (a client-only line — history already shows the // real bubbles). Flows that special-case the return leg (welcome's passkey // ack) suppress it with resumeUnless. env.reentry marks a MID-SESSION // re-derive (another client answered the parked step — apps/setup.js's // folded hook), not a returning member: greeting them "Welcome back" once // per foreign answer would spam the feed. env.resumed = evalCond(env, flow.resumedIf || false); // Consume the reentry flag: one re-derive suppresses one greeting. Left // set (the pre-#1377 bug) every later genuine resume in the same page life // lost its "Welcome back". const reentry = env.reentry; env.reentry = false; if (env.resumed && !reentry && !(flow.resumeUnless && evalCond(env, flow.resumeUnless)) && flow.resumeSay) { io.say(env, flow.resumeSay, null, { local: true }); } const setPaths = (sets) => { for (const [path, value] of Object.entries(sets || {})) { const parts = path.split("."); let cur = env; while (parts.length > 1) cur = cur[parts.shift()] || {}; cur[parts[0]] = typeof value === "string" ? valueOf(value) : value; } }; // "$answer"/"$answerText" read the captured input; anything else templates. const valueOf = (v) => v === "$answer" ? env.vars.answer : v === "$answerText" ? env.vars.answerText : typeof v === "string" ? template(env, v) : v; async function runActions(a, stepCtx) { if (!a) return false; if (a.set) setPaths(a.set); // A record may name its step explicitly: the photo PREVIEW records "photo", // the keys WAITING/CONFIRM steps record "keys" — resume guards check those. 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) : ""); if (a.ack) env.__ack = renderText(env, a.ack); if (a.say) io.say(env, renderText(env, a.say), null, a.local ? { local: true } : undefined); if (a.ask) { askIn(stepCtx, renderText(env, a.ask.text != null ? a.ask.text : a.ask)); return true; } if (a.gotoAsk) { // Jump to another step's ASK portion with fresh prompt text, skipping its // context bubbles (the preview's "Use a different photo" → the photo ask). askIn(byId[a.gotoAsk.step], renderText(env, a.gotoAsk.text)); return true; } if (a.retry === "step") { enter(stepCtx.step.id); return true; } if (a.goto) { enter(a.goto); return true; } return false; } async function runEffect(stepCtx, eff, args) { const r = await io.verb(eff.verb, args || (eff.args || []).map(valueOf), env); const on = stepCtx.step.on || {}; const branch = on[resultKey(r)]; if (!branch) return false; // fire-and-forget verb (setMyName, savePasskey) const moved = await runActions(branch, stepCtx); if (!moved) { // A branch with no movement re-arms in place (rejected email re-asks). armInput(stepCtx); } return true; } function buildChips(stepCtx, go) { return (stepCtx.step.chips || []).filter((c) => evalCond(env, c.if)).map((c) => ({ label: renderText(env, c.label), done: c.spent, // A trailing chip renders right-aligned — the quiet alternative path // (the quick form) sits apart from the step's primary answers. trailing: c.trailing ? true : undefined, // Finish-line chips (advancing:false, no probe) stay live forever. stale: c.advancing === false && !c.probe ? undefined : () => go.taken, run: () => { const act = async () => { if (c.cancelInput) io.cancelAnswer(env.threadId); // Sets land before the effect so its args can read them (the // suggested-name chip sets vars.answer, then setMyName reads it). if (c.set) setPaths(c.set); if (c.effect) await io.verb(c.effect.verb, (c.effect.args || []).map(valueOf), env); await runActions(c, stepCtx); }; if (c.advancing === false) { act(); // The probe: if the verb didn't take us away (the passkey ceremony // stayed in place), fall through to the waiting step. if (c.probe) io.delay(c.probe.afterMs, () => { if (!evalCond(env, c.probe.unless)) go(() => enter(c.probe.goto)); }); } else { go(act); } }, })); } // (Re-)arm the step's input without re-saying its bubbles. function armInput(stepCtx) { const { step } = stepCtx; if (!step.input) return; if (step.input.kind === "text") { io.ask(env.threadId, (text) => stepCtx.go(async () => { // Clean BEFORE the cap so a chatty preamble can't eat into it // ("my name is " + 25 chars must keep the whole name). let answer = (text || "").trim(); if (step.input.clean === "name") answer = cleanNameAnswer(answer); env.vars.answer = answer.slice(0, step.input.maxLen || Infinity); // A required answer left empty re-asks the whole step (say-dedupe by // key means no duplicate bubbles — the input just re-arms). if (step.input.required && !env.vars.answer) return enter(step.id); if (step.effect) await runEffect(stepCtx, step.effect); }) ); } else if (step.input.kind === "photo") { const arm = () => io.ask(env.threadId, (text, payload) => { const img = (((payload || {}).attachments) || []).find((a) => a && a.type && String(a.type).startsWith("image/")); if (!img) { io.say(env, renderText(env, step.input.reject.text), null, step.input.reject.local ? { local: true } : undefined); return arm(); } stepCtx.go(async () => { env.vars.answerText = (text || "").trim(); await runEffect(stepCtx, step.effect, [img, env.vars.answerText]); }); }); arm(); } } async function enter(id) { const stepCtx = byId[id]; if (!stepCtx) throw new Error(`flow ${flow.flow}: unknown step "${id}"`); const { step } = stepCtx; env.__done = () => io.done(step.id); // Entry branches: state that satisfies the step on arrival (an enrolled // passkey, a name already set) — with once-only ack lines the first time. for (const e of step.entries || []) { if (!evalCond(env, e.if)) continue; if (e.set) setPaths(e.set); if (!io.done(step.id)) { if (e.recordIfNew) await io.record(step.id, renderText(env, e.recordIfNew.label), e.recordIfNew.value || ""); if (e.sayIfNew) io.say(env, renderText(env, e.sayIfNew)); } return enter(e.goto || stepCtx.next); } if ((step.skipIf || []).some((c) => evalCond(env, c))) return enter(step.skipTo || stepCtx.next); // A step may persist itself on arrival (welcome's finish records its // completion marker) — once only, so a host can tell a FINISHED // conversation from a half-done one purely from recorded state. if (step.record && !io.done(step.id)) { await io.record(step.id, renderText(env, step.record.label), step.record.value != null ? step.record.value : ""); } stepCtx.go = onceStep(); // Effects fire before the step speaks (markSetupComplete lands before the // "you're all set" bubble, as the hand-written flow did). if (step.effects) { for (const eff of step.effects) if (evalCond(env, eff.if)) await io.verb(eff.verb, (eff.args || []).map(valueOf), env); } const bubbles = (step.bubbles || []).filter((b) => evalCond(env, b.if)); for (let i = 0; i < bubbles.length; i++) { const b = bubbles[i]; const last = i === bubbles.length - 1; const extra = {}; if (b.key) extra.key = template(env, b.key); if (b.hint) extra.hint = b.hint; if (b.attachHint) extra.attachHint = b.attachHint; if (b.label) extra.label = b.label; // the composer's inset field label if (b.local) extra.local = true; if (b.img) { // imgSource may be async (welcome's profile photo decrypts to an // object URL); a falsy resolution drops the image, keeps the bubble. const src = await io.imgSource(b.img); if (src) { extra.img = src; extra.imgAlt = renderText(env, b.imgAlt || ""); if (b.imgRound) extra.imgRound = true; } } // Chips ride the last bubble — the question the answer belongs to. io.say(env, renderText(env, b.text), last ? orNull(buildChips(stepCtx, stepCtx.go)) : null, extra); } if (!bubbles.length && step.chips) { // Chip-only continuation (no new bubble) — not used by setup, kept tiny. io.say(env, "", buildChips(stepCtx, stepCtx.go), {}); } armInput(stepCtx); // A step may WAIT for a condition to become true on its own — the passkey // ceremony returns asynchronously (a redirect back, or the user tabbing back // from a system browser), so `bridge.enrolled` can flip true seconds after // we've shown the pitch. Poll it while the step is on screen and, the moment // it holds, re-enter the step so its entries fire (record the backup, advance) // — no manual "Continue" tap. The shared onceStep guard means a chip tap still // wins and stops the poll. if (step.settle && !evalCond(env, step.settle.when)) { const go = stepCtx.go; const period = step.settle.everyMs || 700; const tick = () => { if (go.taken) return; // a chip/advance already took the step if (evalCond(env, step.settle.when)) return go(() => enter(step.settle.goto || step.id)); io.delay(period, tick); }; io.delay(period, tick); } // A bubble-only step may auto-advance (welcome's greeting → the passkey // step); input/chip steps advance through answers instead. ("auto", not // "then" — text conditionals already use then/else.) if (step.auto) return enter(step.auto); if (step.end) env.onEnd && env.onEnd(); } // A retry that re-asks with new text: one fresh line carrying the step's // chips + hint, then the input re-arms (askEmail/askPhoto recursion). function askIn(stepCtx, text) { stepCtx.go = onceStep(); const extra = {}; const src = stepCtx.step.bubbles && stepCtx.step.bubbles[stepCtx.step.bubbles.length - 1]; if (src && src.hint) extra.hint = src.hint; if (src && src.attachHint) extra.attachHint = src.attachHint; if (src && src.label) extra.label = src.label; io.say(env, text, orNull(buildChips(stepCtx, stepCtx.go)), extra); armInput(stepCtx); } const orNull = (chips) => (chips && chips.length ? chips : null); return enter(flow.entry); }