@engenty/wizards 0.0.0-stage → 0.7.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,821 @@
1
+ export const freshThreadState = (lang = "en") => ({
2
+ lang,
3
+ wizardId: null,
4
+ page: null,
5
+ awaiting: null,
6
+ target: null,
7
+ said: null,
8
+ delivered: false,
9
+ choices: null,
10
+ });
11
+ // ── Words ─────────────────────────────────────────────────────────────────────
12
+ const texts = {
13
+ de: {
14
+ nothing: "Hier ist noch kein Wizard verbunden.",
15
+ which: "Welcher Wizard soll es sein?",
16
+ choose: "Auswählen",
17
+ over: "Dieser Lauf ist beendet. Mit dem Stichwort startest du einen neuen.",
18
+ stopped: "Abgebrochen. Mit dem Stichwort startest du einen neuen Lauf.",
19
+ optional: "(freiwillig – mit „weiter“ überspringst du es)",
20
+ pickOne: "Wähl eine der Möglichkeiten.",
21
+ picked: "Gewählt: {list}",
22
+ more: "Noch etwas?",
23
+ done: "Fertig",
24
+ yes: "Ja",
25
+ no: "Nein",
26
+ yesOrNo: "Antworte mit Ja oder Nein.",
27
+ asText: "Antworte mit einem Text.",
28
+ asDate: "Schick ein Datum, zum Beispiel 24.12.2026.",
29
+ sendPhoto: "Schick ein Foto.",
30
+ sendFile: "Schick eine Datei.",
31
+ sendVoice: "Schick eine Sprachnachricht.",
32
+ another: "Noch eins",
33
+ whereAre: "Teil deinen Standort oder schreib die Adresse.",
34
+ needed: "Dafür brauche ich eine Antwort.",
35
+ screen: "Dieser Teil braucht einen Bildschirm. Danach geht es hier weiter.",
36
+ open: "Öffnen",
37
+ working: "{step} – das dauert einen Moment.",
38
+ reviewAsk: "Passt das so?",
39
+ reviewOk: "Passt",
40
+ reviewRedo: "Neue Version",
41
+ reviewWhich: "Was soll neu gemacht werden?",
42
+ reviewNote: "Was soll anders sein?",
43
+ allow: "Erlauben",
44
+ skip: "Überspringen",
45
+ finished: "Fertig. Alles liegt hier:",
46
+ failed: "Der Wizard ist stehen geblieben. Hier geht es weiter:",
47
+ },
48
+ en: {
49
+ nothing: "No wizard is connected here yet.",
50
+ which: "Which wizard?",
51
+ choose: "Choose",
52
+ over: "This run is over. Send the keyword to start a new one.",
53
+ stopped: "Stopped. Send the keyword to start a new run.",
54
+ optional: "(optional – reply “skip” to leave it out)",
55
+ pickOne: "Choose one of the options.",
56
+ picked: "Chosen: {list}",
57
+ more: "Anything else?",
58
+ done: "Done",
59
+ yes: "Yes",
60
+ no: "No",
61
+ yesOrNo: "Answer yes or no.",
62
+ asText: "Reply with text.",
63
+ asDate: "Send a date, for example 24/12/2026.",
64
+ sendPhoto: "Send a photo.",
65
+ sendFile: "Send a file.",
66
+ sendVoice: "Send a voice message.",
67
+ another: "One more",
68
+ whereAre: "Share your location or type the address.",
69
+ needed: "I need an answer for this one.",
70
+ screen: "This part needs a screen. You carry on here afterwards.",
71
+ open: "Open",
72
+ working: "{step} – this takes a moment.",
73
+ reviewAsk: "Is this right?",
74
+ reviewOk: "Looks good",
75
+ reviewRedo: "New version",
76
+ reviewWhich: "What should be redone?",
77
+ reviewNote: "What should be different?",
78
+ allow: "Allow",
79
+ skip: "Skip",
80
+ finished: "Done. Everything is here:",
81
+ failed: "The wizard stopped. You can carry on here:",
82
+ },
83
+ };
84
+ function say(lang, key, vars = {}) {
85
+ let text = texts[lang][key];
86
+ for (const [name, value] of Object.entries(vars)) {
87
+ text = text.replaceAll(`{${name}}`, String(value));
88
+ }
89
+ return text;
90
+ }
91
+ const SKIP_WORDS = new Set(["weiter", "skip", "-", "überspringen", "keine", "none"]);
92
+ const DONE_WORDS = new Set(["fertig", "done", "ok", "passt", "das wars", "that's it"]);
93
+ const STOP_WORDS = new Set(["stop", "stopp", "abbrechen", "cancel", "abbruch", "ende"]);
94
+ const YES_WORDS = new Set(["ja", "yes", "y", "j", "jo", "klar", "yep", "true", "✓"]);
95
+ const NO_WORDS = new Set(["nein", "no", "n", "nö", "nope", "false"]);
96
+ const shownOf = (w) => w.fields.filter((f) => f.shown !== false);
97
+ /** `24.12.2026`, `24/12/26` or an ISO date, as the page's date input gives it. */
98
+ export function toIso(text) {
99
+ if (/^\d{4}-\d{2}-\d{2}$/.test(text)) {
100
+ return text;
101
+ }
102
+ const dmy = text.match(/^(\d{1,2})[./](\d{1,2})[./](\d{2,4})$/);
103
+ if (!dmy) {
104
+ return null;
105
+ }
106
+ const [, d, m, y] = dmy;
107
+ const year = y.length === 2 ? `20${y}` : y;
108
+ const date = new Date(Number(year), Number(m) - 1, Number(d));
109
+ if (date.getMonth() !== Number(m) - 1 || date.getDate() !== Number(d)) {
110
+ return null;
111
+ }
112
+ return `${year}-${m.padStart(2, "0")}-${d.padStart(2, "0")}`;
113
+ }
114
+ /** Cut to a length, with an ellipsis where something was cut. */
115
+ export function clipText(text, max) {
116
+ const t = text.trim();
117
+ return t.length > max ? `${t.slice(0, max - 1).trimEnd()}…` : t;
118
+ }
119
+ const extOf = (mime) => ({
120
+ "image/jpeg": "jpg",
121
+ "image/png": "png",
122
+ "image/webp": "webp",
123
+ "application/pdf": "pdf",
124
+ "audio/ogg": "ogg",
125
+ "audio/mpeg": "mp3",
126
+ "video/mp4": "mp4",
127
+ })[mime] ?? "bin";
128
+ /** Which option a reply means: a tap on `o:<i>`, a number, or the option's own words. */
129
+ function optionIndex(msg, options) {
130
+ if (msg.replyId?.startsWith("o:")) {
131
+ const i = Number(msg.replyId.slice(2));
132
+ return Number.isInteger(i) && options[i] !== undefined ? i : -1;
133
+ }
134
+ const text = (msg.text ?? "").trim();
135
+ if (/^\d+$/.test(text)) {
136
+ const n = Number(text);
137
+ return n >= 1 && n <= options.length ? n - 1 : -1;
138
+ }
139
+ const lower = text.toLowerCase();
140
+ return options.findIndex((o) => o.toLowerCase() === lower);
141
+ }
142
+ const mediaOf = (mime) => mime.startsWith("image/")
143
+ ? "image"
144
+ : mime === "application/pdf"
145
+ ? "document"
146
+ : mime.startsWith("video/")
147
+ ? "video"
148
+ : mime.startsWith("audio/")
149
+ ? "audio"
150
+ : null;
151
+ // ── The door ──────────────────────────────────────────────────────────────────
152
+ export class ThreadDoor {
153
+ o;
154
+ asks;
155
+ constructor(o) {
156
+ this.o = o;
157
+ this.asks = new Set(o.surface.asks);
158
+ }
159
+ /** A message of the person arrived. The caller keeps the thread before and after. */
160
+ async inbound(thread, given) {
161
+ const msg = this.asTap(thread, given);
162
+ const text = (msg.text ?? "").trim();
163
+ const lower = text.toLowerCase();
164
+ const offered = await this.o.offered();
165
+ // A keyword starts its wizard, from anywhere in the thread; so does a pick from the menu.
166
+ const picked = (lower ? offered.find((w) => w.keyword.toLowerCase() === lower) : undefined) ??
167
+ (msg.replyId?.startsWith("w:")
168
+ ? offered.find((w) => `w:${w.wizardId}` === msg.replyId)
169
+ : undefined);
170
+ if (picked) {
171
+ await this.start(thread, picked);
172
+ return;
173
+ }
174
+ if (STOP_WORDS.has(lower)) {
175
+ await this.stop(thread);
176
+ return;
177
+ }
178
+ if (!thread.runId) {
179
+ await this.menu(thread, offered);
180
+ return;
181
+ }
182
+ const report = await this.report(thread);
183
+ if (!report) {
184
+ thread.runId = null;
185
+ await this.menu(thread, offered);
186
+ return;
187
+ }
188
+ await this.step(thread, report, msg);
189
+ }
190
+ /** The run changed while nobody wrote: a step ended, the page was filled on the screen. */
191
+ async resume(thread) {
192
+ if (!thread.runId) {
193
+ return;
194
+ }
195
+ const report = await this.report(thread);
196
+ if (report) {
197
+ await this.step(thread, report, null);
198
+ }
199
+ }
200
+ /** A typed number or the words of an option of the last question count as a tap on it. */
201
+ asTap(thread, msg) {
202
+ const choices = thread.state.choices;
203
+ if (msg.replyId || msg.kind !== "text" || !choices?.length) {
204
+ return msg;
205
+ }
206
+ const text = (msg.text ?? "").trim();
207
+ const hit = /^\d+$/.test(text)
208
+ ? choices[Number(text) - 1]
209
+ : choices.find((c) => c.title.replace(/^✓ /, "").toLowerCase() === text.toLowerCase());
210
+ return hit ? { ...msg, kind: "reply", replyId: hit.id, replyTitle: hit.title } : msg;
211
+ }
212
+ // ── Runs ───────────────────────────────────────────────────────────────────
213
+ async report(thread, waitSeconds = 0) {
214
+ if (!thread.runId) {
215
+ return null;
216
+ }
217
+ try {
218
+ return await this.o.runs.report(thread.runId, {
219
+ waitSeconds,
220
+ draft: thread.state.page?.values,
221
+ });
222
+ }
223
+ catch (err) {
224
+ if (err.code === "not_found") {
225
+ return null;
226
+ }
227
+ throw err;
228
+ }
229
+ }
230
+ async start(thread, wizard) {
231
+ if (thread.runId) {
232
+ this.o.unwatch(thread.runId);
233
+ }
234
+ thread.state = { ...freshThreadState(wizard.lang), wizardId: wizard.wizardId };
235
+ try {
236
+ const { runId } = await this.o.runs.start({
237
+ token: wizard.token,
238
+ person: { channel: this.o.surface.runner, id: thread.id },
239
+ runner: this.o.surface.runner,
240
+ });
241
+ thread.runId = runId;
242
+ }
243
+ catch (err) {
244
+ thread.runId = null;
245
+ await this.send(thread, { kind: "text", text: err.message });
246
+ return;
247
+ }
248
+ const report = await this.report(thread, 10);
249
+ if (report) {
250
+ await this.step(thread, report, null);
251
+ }
252
+ }
253
+ async stop(thread) {
254
+ if (thread.runId) {
255
+ this.o.unwatch(thread.runId);
256
+ await this.o.runs.control(thread.runId, "cancel").catch(() => undefined);
257
+ thread.runId = null;
258
+ }
259
+ thread.state = freshThreadState(thread.state.lang);
260
+ await this.send(thread, { kind: "text", text: say(thread.state.lang, "stopped") });
261
+ }
262
+ async menu(thread, offered) {
263
+ // Before a wizard is picked, the thread speaks the wizards' language where they share one.
264
+ const spoken = new Set(offered.map((w) => w.lang));
265
+ if (spoken.size === 1) {
266
+ thread.state.lang = offered[0].lang;
267
+ }
268
+ const lang = thread.state.lang;
269
+ if (!offered.length) {
270
+ await this.send(thread, { kind: "text", text: say(lang, "nothing") });
271
+ return;
272
+ }
273
+ if (offered.length === 1) {
274
+ await this.start(thread, offered[0]);
275
+ return;
276
+ }
277
+ await this.send(thread, {
278
+ kind: "choice",
279
+ text: say(lang, "which"),
280
+ button: say(lang, "choose"),
281
+ options: offered.slice(0, 10).map((w) => ({ id: `w:${w.wizardId}`, title: w.title })),
282
+ });
283
+ }
284
+ /** Where the run stands, and what to do about it. `msg`: what the person just sent, if anything. */
285
+ async step(thread, report, msg) {
286
+ const s = thread.state;
287
+ const runId = thread.runId;
288
+ if (report.status === "done") {
289
+ if (s.delivered) {
290
+ if (msg) {
291
+ await this.send(thread, { kind: "text", text: say(s.lang, "over") });
292
+ }
293
+ return;
294
+ }
295
+ await this.deliver(thread, report);
296
+ return;
297
+ }
298
+ if (report.status === "failed" || report.status === "cancelled") {
299
+ this.o.unwatch(runId);
300
+ thread.runId = null;
301
+ if (report.status === "failed") {
302
+ const url = await this.o.runs.handoffUrl(runId).catch(() => report.browserUrl);
303
+ await this.send(thread, url
304
+ ? { kind: "link", text: say(s.lang, "failed"), url, label: say(s.lang, "open") }
305
+ : { kind: "text", text: say(s.lang, "failed") });
306
+ }
307
+ else {
308
+ await this.send(thread, { kind: "text", text: say(s.lang, "stopped") });
309
+ }
310
+ return;
311
+ }
312
+ const w = report.waitingFor;
313
+ if (!w) {
314
+ // The wizard works: said once, then the thread waits for the run to change.
315
+ const key = `working:${report.step?.id ?? ""}`;
316
+ if (s.said !== key) {
317
+ s.said = key;
318
+ await this.send(thread, {
319
+ kind: "text",
320
+ text: say(s.lang, "working", { step: report.step?.title ?? "…" }),
321
+ });
322
+ }
323
+ this.o.watch(thread);
324
+ return;
325
+ }
326
+ if ("page" in w) {
327
+ await this.page(thread, w, msg);
328
+ }
329
+ else if ("review" in w) {
330
+ await this.review(thread, report, w, msg);
331
+ }
332
+ else {
333
+ await this.ask(thread, w, msg);
334
+ }
335
+ }
336
+ /** The run moved on after a command: wait a little for it, then look again. */
337
+ async after(thread) {
338
+ const report = await this.report(thread, 25);
339
+ if (report) {
340
+ await this.step(thread, report, null);
341
+ }
342
+ }
343
+ // ── A page ─────────────────────────────────────────────────────────────────
344
+ async page(thread, waiting, msg) {
345
+ const s = thread.state;
346
+ let w = waiting;
347
+ if (!s.page || s.page.step !== w.page) {
348
+ s.page = { step: w.page, values: {}, asking: null, picks: [], files: [] };
349
+ }
350
+ const page = s.page;
351
+ let fields = shownOf(w);
352
+ // A field only the screen can take (a signature, a sign-in): the whole page goes there.
353
+ if (fields.some((f) => !this.asks.has(f.kind))) {
354
+ const key = `screen:${w.page}`;
355
+ if (s.said !== key || msg) {
356
+ s.said = key;
357
+ await this.handoff(thread);
358
+ }
359
+ this.o.watch(thread);
360
+ return;
361
+ }
362
+ if (msg && page.asking) {
363
+ const field = fields.find((f) => f.id === page.asking);
364
+ if (field) {
365
+ const taken = await this.take(thread, field, msg);
366
+ if (taken.kind === "again") {
367
+ await this.askField(thread, field, taken.hint);
368
+ return;
369
+ }
370
+ if (taken.kind === "wait") {
371
+ return;
372
+ }
373
+ page.values[field.id] = taken.value;
374
+ page.asking = null;
375
+ page.picks = [];
376
+ page.files = [];
377
+ s.choices = null;
378
+ // A later field's condition may read this answer: the page is read again with it.
379
+ const again = await this.report(thread);
380
+ if (!again) {
381
+ return;
382
+ }
383
+ if (!again.waitingFor ||
384
+ !("page" in again.waitingFor) ||
385
+ again.waitingFor.page !== w.page) {
386
+ await this.step(thread, again, null);
387
+ return;
388
+ }
389
+ w = again.waitingFor;
390
+ fields = shownOf(w);
391
+ }
392
+ }
393
+ const next = fields.find((f) => !(f.id in page.values));
394
+ if (next) {
395
+ page.asking = next.id;
396
+ await this.askField(thread, next);
397
+ return;
398
+ }
399
+ // Every field has its answer: the page is sent.
400
+ try {
401
+ await this.o.runs.answerPage(thread.runId, w.page, page.values);
402
+ }
403
+ catch (err) {
404
+ const wrong = (err.data?.fields ?? []).find((e) => e.field in page.values);
405
+ const field = wrong ? fields.find((f) => f.id === wrong.field) : undefined;
406
+ if (wrong && field) {
407
+ delete page.values[wrong.field];
408
+ page.asking = wrong.field;
409
+ await this.askField(thread, field, wrong.message);
410
+ return;
411
+ }
412
+ await this.send(thread, { kind: "text", text: err.message });
413
+ return;
414
+ }
415
+ s.page = null;
416
+ s.said = null;
417
+ await this.after(thread);
418
+ }
419
+ /** The question for a field, with the options it takes. `hint` first: what was wrong. */
420
+ async askField(thread, field, hint) {
421
+ const { lang } = thread.state;
422
+ const page = thread.state.page;
423
+ const lines = [field.label, field.help].filter((x) => Boolean(x));
424
+ if (!field.required && field.kind !== "toggle") {
425
+ lines.push(say(lang, "optional"));
426
+ }
427
+ if (field.kind === "date") {
428
+ lines.push(say(lang, "asDate"));
429
+ }
430
+ const text = [hint, lines.join("\n")].filter(Boolean).join("\n\n");
431
+ const options = field.options ?? [];
432
+ switch (field.kind) {
433
+ case "select":
434
+ return this.send(thread, {
435
+ kind: "choice",
436
+ text,
437
+ button: say(lang, "choose"),
438
+ options: options.map((o, i) => ({ id: `o:${i}`, title: o })),
439
+ });
440
+ case "multiselect": {
441
+ const picked = page.picks.length
442
+ ? `\n\n${say(lang, "picked", { list: page.picks.join(", ") })}`
443
+ : "";
444
+ return this.send(thread, {
445
+ kind: "choice",
446
+ text: `${page.picks.length ? say(lang, "more") : text}${picked}`,
447
+ button: say(lang, "choose"),
448
+ multiple: true,
449
+ options: [
450
+ ...options.map((o, i) => ({ id: `o:${i}`, title: o, picked: page.picks.includes(o) })),
451
+ ...(page.picks.length ? [{ id: "done", title: `✓ ${say(lang, "done")}` }] : []),
452
+ ],
453
+ });
454
+ }
455
+ case "toggle":
456
+ return this.send(thread, {
457
+ kind: "choice",
458
+ text,
459
+ button: say(lang, "choose"),
460
+ options: [
461
+ { id: "t:1", title: say(lang, "yes") },
462
+ { id: "t:0", title: say(lang, "no") },
463
+ ],
464
+ });
465
+ case "image":
466
+ case "file": {
467
+ const ask = say(lang, field.kind === "image" ? "sendPhoto" : "sendFile");
468
+ if (page.files.length) {
469
+ return this.send(thread, {
470
+ kind: "choice",
471
+ text: `${say(lang, "more")} (${page.files.length})`,
472
+ button: say(lang, "choose"),
473
+ options: [
474
+ { id: "f:more", title: say(lang, "another") },
475
+ { id: "f:done", title: say(lang, "done") },
476
+ ],
477
+ });
478
+ }
479
+ return this.send(thread, { kind: "text", text: `${text}\n${ask}` });
480
+ }
481
+ case "audio":
482
+ return this.send(thread, { kind: "text", text: `${text}\n${say(lang, "sendVoice")}` });
483
+ case "location":
484
+ return this.send(thread, {
485
+ kind: "location",
486
+ text: `${text}\n${say(lang, "whereAre")}`,
487
+ });
488
+ default:
489
+ return this.send(thread, { kind: "text", text });
490
+ }
491
+ }
492
+ /** What the message means for the field: its value, a second try with a hint, or more to come. */
493
+ async take(thread, field, msg) {
494
+ const { lang } = thread.state;
495
+ const page = thread.state.page;
496
+ const text = (msg.text ?? "").trim();
497
+ const lower = text.toLowerCase();
498
+ const typed = msg.kind === "text" || msg.kind === "reply";
499
+ if (!field.required && typed && SKIP_WORDS.has(lower)) {
500
+ return { kind: "value", value: null };
501
+ }
502
+ const again = (hint) => ({ kind: "again", hint: say(lang, hint) });
503
+ switch (field.kind) {
504
+ case "text":
505
+ case "textarea":
506
+ case "email":
507
+ case "url":
508
+ case "number": {
509
+ if (msg.kind === "audio" && msg.media) {
510
+ const said = await this.transcribe(thread, msg);
511
+ return said ? { kind: "value", value: said } : again("asText");
512
+ }
513
+ return typed && text ? { kind: "value", value: text } : again("asText");
514
+ }
515
+ case "date": {
516
+ const iso = typed ? toIso(text) : null;
517
+ return iso ? { kind: "value", value: iso } : again("asDate");
518
+ }
519
+ case "select": {
520
+ const i = optionIndex(msg, field.options ?? []);
521
+ return i >= 0 ? { kind: "value", value: field.options?.[i] } : again("pickOne");
522
+ }
523
+ case "multiselect": {
524
+ const options = field.options ?? [];
525
+ if (msg.replyId === "done" || (typed && DONE_WORDS.has(lower))) {
526
+ if (!page.picks.length && field.required) {
527
+ return again("needed");
528
+ }
529
+ return { kind: "value", value: [...page.picks] };
530
+ }
531
+ if (msg.replyId?.startsWith("o:")) {
532
+ const i = optionIndex(msg, options);
533
+ if (i >= 0 && !page.picks.includes(options[i])) {
534
+ page.picks.push(options[i]);
535
+ }
536
+ if (page.picks.length >= options.length) {
537
+ return { kind: "value", value: [...page.picks] };
538
+ }
539
+ await this.askField(thread, field);
540
+ return { kind: "wait" };
541
+ }
542
+ if (typed && text) {
543
+ const chosen = text
544
+ .split(/[,;\n]+/)
545
+ .map((part) => part.trim())
546
+ .map((part) => {
547
+ const n = Number(part);
548
+ return Number.isInteger(n) && n >= 1 && n <= options.length
549
+ ? options[n - 1]
550
+ : options.find((o) => o.toLowerCase() === part.toLowerCase());
551
+ })
552
+ .filter((o) => Boolean(o));
553
+ if (chosen.length) {
554
+ return { kind: "value", value: [...new Set([...page.picks, ...chosen])] };
555
+ }
556
+ }
557
+ return again("pickOne");
558
+ }
559
+ case "toggle": {
560
+ if (msg.replyId === "t:1" || YES_WORDS.has(lower)) {
561
+ return { kind: "value", value: true };
562
+ }
563
+ if (msg.replyId === "t:0" || NO_WORDS.has(lower)) {
564
+ return { kind: "value", value: false };
565
+ }
566
+ return again("yesOrNo");
567
+ }
568
+ case "image":
569
+ case "file": {
570
+ const ask = field.kind === "image" ? "sendPhoto" : "sendFile";
571
+ if (msg.replyId === "f:done" || (typed && DONE_WORDS.has(lower))) {
572
+ return page.files.length
573
+ ? { kind: "value", value: field.multiple ? [...page.files] : page.files[0] }
574
+ : again(ask);
575
+ }
576
+ if (msg.replyId === "f:more") {
577
+ await this.send(thread, { kind: "text", text: say(lang, ask) });
578
+ return { kind: "wait" };
579
+ }
580
+ const fits = field.kind === "image"
581
+ ? msg.kind === "image"
582
+ : msg.kind === "image" ||
583
+ msg.kind === "document" ||
584
+ msg.kind === "video" ||
585
+ msg.kind === "audio";
586
+ if (fits && msg.media && this.o.surface.media) {
587
+ const { assetId } = await this.upload(thread, msg);
588
+ if (!field.multiple) {
589
+ return { kind: "value", value: assetId };
590
+ }
591
+ page.files.push(assetId);
592
+ await this.askField(thread, field);
593
+ return { kind: "wait" };
594
+ }
595
+ return again(ask);
596
+ }
597
+ case "audio": {
598
+ if (msg.kind === "audio" && msg.media && this.o.surface.media) {
599
+ const { assetId } = await this.upload(thread, msg);
600
+ return { kind: "value", value: { asset: assetId } };
601
+ }
602
+ return again("sendVoice");
603
+ }
604
+ case "location": {
605
+ if (msg.location) {
606
+ const label = [msg.location.name, msg.location.address].filter(Boolean).join(", ");
607
+ return {
608
+ kind: "value",
609
+ value: { lat: msg.location.lat, lng: msg.location.lng, ...(label ? { label } : {}) },
610
+ };
611
+ }
612
+ return typed && text ? { kind: "value", value: { label: text } } : again("whereAre");
613
+ }
614
+ default:
615
+ return again("asText");
616
+ }
617
+ }
618
+ async upload(thread, msg) {
619
+ const ref = msg.media;
620
+ const file = await this.o.surface.media(ref);
621
+ return this.o.runs.upload(thread.runId, {
622
+ data: file.data,
623
+ mime: file.mime,
624
+ name: ref.filename ?? `${this.o.surface.runner}-${ref.id.slice(-8)}.${extOf(file.mime)}`,
625
+ });
626
+ }
627
+ async transcribe(thread, msg) {
628
+ if (!this.o.surface.media) {
629
+ return null;
630
+ }
631
+ try {
632
+ const file = await this.o.surface.media(msg.media);
633
+ const { text } = await this.o.runs.transcribe(thread.runId, file);
634
+ return text.trim() || null;
635
+ }
636
+ catch (err) {
637
+ this.o.log.warn("a voice message could not be written down:", err.message);
638
+ return null;
639
+ }
640
+ }
641
+ // ── A review, a question ───────────────────────────────────────────────────
642
+ async review(thread, report, w, msg) {
643
+ const s = thread.state;
644
+ const runId = thread.runId;
645
+ if (msg?.replyId === "r:ok") {
646
+ await this.o.runs.review(runId, w.review, { type: "accept" });
647
+ s.said = null;
648
+ s.awaiting = null;
649
+ await this.after(thread);
650
+ return;
651
+ }
652
+ if (msg?.replyId === "r:redo") {
653
+ if (w.show.length > 1) {
654
+ s.awaiting = "target";
655
+ await this.send(thread, {
656
+ kind: "choice",
657
+ text: say(s.lang, "reviewWhich"),
658
+ button: say(s.lang, "choose"),
659
+ options: w.show.map((id) => ({
660
+ id: `r:t:${id}`,
661
+ title: report.outputs.find((o) => o.stepId === id)?.title ?? id,
662
+ })),
663
+ });
664
+ return;
665
+ }
666
+ s.target = w.show[0] ?? null;
667
+ s.awaiting = "note";
668
+ await this.send(thread, { kind: "text", text: say(s.lang, "reviewNote") });
669
+ return;
670
+ }
671
+ if (s.awaiting === "target" && msg?.replyId?.startsWith("r:t:")) {
672
+ s.target = msg.replyId.slice("r:t:".length);
673
+ s.awaiting = "note";
674
+ await this.send(thread, { kind: "text", text: say(s.lang, "reviewNote") });
675
+ return;
676
+ }
677
+ if (s.awaiting === "note" && msg?.text?.trim() && s.target) {
678
+ await this.o.runs.review(runId, w.review, {
679
+ type: "regenerate",
680
+ target: s.target,
681
+ note: msg.text.trim(),
682
+ });
683
+ s.awaiting = null;
684
+ s.said = null;
685
+ await this.after(thread);
686
+ return;
687
+ }
688
+ const key = `review:${w.review}`;
689
+ const fresh = s.said !== key;
690
+ if (fresh) {
691
+ s.said = key;
692
+ for (const output of report.outputs.filter((o) => w.show.includes(o.stepId))) {
693
+ await this.sendOutput(thread, output);
694
+ }
695
+ }
696
+ if (fresh || msg) {
697
+ await this.send(thread, {
698
+ kind: "choice",
699
+ text: say(s.lang, "reviewAsk"),
700
+ button: say(s.lang, "choose"),
701
+ options: [
702
+ { id: "r:ok", title: say(s.lang, "reviewOk") },
703
+ { id: "r:redo", title: say(s.lang, "reviewRedo") },
704
+ ],
705
+ });
706
+ }
707
+ }
708
+ async ask(thread, w, msg) {
709
+ const s = thread.state;
710
+ if (w.kind === "login") {
711
+ const key = `login:${w.ask}`;
712
+ if (s.said !== key) {
713
+ s.said = key;
714
+ await this.handoff(thread);
715
+ }
716
+ this.o.watch(thread);
717
+ return;
718
+ }
719
+ if (msg?.replyId === "a:allow" || msg?.replyId === "a:skip") {
720
+ await this.o.runs.answerAsk(thread.runId, w.ask, msg.replyId === "a:allow" ? "allow" : "skip");
721
+ s.said = null;
722
+ await this.after(thread);
723
+ return;
724
+ }
725
+ const key = `ask:${w.ask}`;
726
+ if (s.said !== key || msg) {
727
+ s.said = key;
728
+ await this.send(thread, {
729
+ kind: "choice",
730
+ text: [w.reason, w.action, w.input ? clipText(w.input, 600) : ""]
731
+ .filter(Boolean)
732
+ .join("\n"),
733
+ button: say(s.lang, "choose"),
734
+ options: [
735
+ { id: "a:allow", title: say(s.lang, "allow") },
736
+ { id: "a:skip", title: say(s.lang, "skip") },
737
+ ],
738
+ });
739
+ }
740
+ }
741
+ // ── What goes out ──────────────────────────────────────────────────────────
742
+ /** The link to the run page, for what the thread cannot do. */
743
+ async handoff(thread) {
744
+ const url = await this.o.runs.handoffUrl(thread.runId);
745
+ await this.send(thread, {
746
+ kind: "link",
747
+ text: say(thread.state.lang, "screen"),
748
+ url,
749
+ label: say(thread.state.lang, "open"),
750
+ });
751
+ }
752
+ /** One output as a thread can carry it: text, pictures, documents, the picture of a widget. */
753
+ async sendOutput(thread, output) {
754
+ const parts = [];
755
+ if (output.text) {
756
+ parts.push({ kind: "text", text: `*${output.title}*\n${output.text}` });
757
+ }
758
+ let picture = false;
759
+ let pdf = false;
760
+ for (const a of output.assets) {
761
+ const media = mediaOf(a.mime);
762
+ if (!media) {
763
+ continue;
764
+ }
765
+ parts.push({ kind: "media", media, url: a.url, caption: output.title, filename: a.name });
766
+ picture ||= media === "image";
767
+ pdf ||= media === "document";
768
+ }
769
+ if (!picture && output.picture) {
770
+ parts.push({ kind: "media", media: "image", url: output.picture, caption: output.title });
771
+ }
772
+ if (!pdf && output.downloads.pdf) {
773
+ parts.push({
774
+ kind: "media",
775
+ media: "document",
776
+ url: output.downloads.pdf,
777
+ caption: output.title,
778
+ filename: `${output.title}.pdf`,
779
+ });
780
+ }
781
+ if (!parts.length && output.json) {
782
+ parts.push({ kind: "text", text: `*${output.title}*\n${clipText(output.json, 3000)}` });
783
+ }
784
+ for (const part of parts) {
785
+ await this.send(thread, part);
786
+ }
787
+ }
788
+ /** The run reached its end: what it made, then the link to all of it. */
789
+ async deliver(thread, report) {
790
+ const s = thread.state;
791
+ const runId = thread.runId;
792
+ this.o.unwatch(runId);
793
+ const url = await this.o.runs.handoffUrl(runId);
794
+ if (this.o.surface.mayDeliver && !this.o.surface.mayDeliver(thread)) {
795
+ // The channel does not take a free message now: the link by other means, or nothing.
796
+ if (this.o.surface.reach && (await this.o.surface.reach(thread, url))) {
797
+ s.delivered = true;
798
+ }
799
+ else {
800
+ this.o.log.warn(`run ${runId} is done, but the thread takes no message now: nothing sent`);
801
+ }
802
+ return;
803
+ }
804
+ const listed = report.outputs.filter((o) => Object.keys(o.downloads).length);
805
+ for (const output of listed.length ? listed : report.outputs) {
806
+ await this.sendOutput(thread, output);
807
+ }
808
+ await this.send(thread, {
809
+ kind: "link",
810
+ text: say(s.lang, "finished"),
811
+ url,
812
+ label: say(s.lang, "open"),
813
+ });
814
+ s.delivered = true;
815
+ }
816
+ async send(thread, prompt) {
817
+ // The last question's options are what a typed number means next.
818
+ thread.state.choices = prompt.kind === "choice" ? prompt.options : null;
819
+ await this.o.surface.send(thread, prompt);
820
+ }
821
+ }