| 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). |
| 30 | const NAME_GREETINGS = ["hiya", "howdy", "hello", "hey", "hi"]; |
| 31 | const 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 | |
| 37 | export 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 | |
| 65 | function 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). |
| 83 | export 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. |
| 97 | export 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. |
| 112 | export 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). |
| 127 | function 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"). |
| 139 | const resultKey = (r) => (r === undefined || r === true ? "ok" : r === false ? "error" : String(r)); |
| 140 | |
| 141 | export 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 | |