wizards 0.4.0 → 0.4.2

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.
Files changed (124) hide show
  1. package/apps/runtime/dist/agents/studio-assistant.js +67 -20
  2. package/apps/runtime/dist/docs/dev/plugins/reference.md +7 -4
  3. package/apps/runtime/dist/docs/dev/plugins/reloading.md +3 -3
  4. package/apps/runtime/dist/docs/dev/plugins/runners.md +9 -12
  5. package/apps/runtime/dist/docs/dev/plugins/server.md +3 -1
  6. package/apps/runtime/dist/docs/dev/plugins/space.md +19 -1
  7. package/apps/runtime/dist/docs/dev/plugins/studio.md +43 -6
  8. package/apps/runtime/dist/docs/dev/plugins/styling.md +8 -1
  9. package/apps/runtime/dist/docs/user/space.md +4 -4
  10. package/apps/runtime/dist/docs/user/test-and-share.md +22 -58
  11. package/apps/runtime/dist/engine/steps.js +3 -2
  12. package/apps/runtime/dist/engine/template.js +14 -10
  13. package/apps/runtime/dist/film/project.js +15 -3
  14. package/apps/runtime/dist/plugins/loader.js +38 -2
  15. package/apps/runtime/dist/routes/studio.js +28 -1
  16. package/apps/runtime/dist/services/brand.js +61 -1
  17. package/apps/runtime/dist/services/projects.js +19 -10
  18. package/apps/runtime/dist/tools/plugin.js +11 -3
  19. package/apps/web/dist/assets/AboutDialog-CH1rQdia.js +1 -0
  20. package/apps/web/dist/assets/{ChangelogDialog-DynvQuuR.js → ChangelogDialog-78MQLHfA.js} +1 -1
  21. package/apps/web/dist/assets/ChatView-DLJfy69U.js +10 -0
  22. package/apps/web/dist/assets/EditorPage-BYrZokVk.js +3 -0
  23. package/apps/web/dist/assets/{FlowPreview-BZLnG7GV.js → FlowPreview-xIWPdOCV.js} +1 -1
  24. package/apps/web/dist/assets/{Harness-CNh3nbB_.js → Harness-CsDhNLSW.js} +2 -2
  25. package/apps/web/dist/assets/NewWizardChannels-JB3V6DCL.js +1 -0
  26. package/apps/web/dist/assets/NewWizardPage-Bkh-hjSe.js +7 -0
  27. package/apps/web/dist/assets/OnboardingPage-Diw1ILjC.js +1 -0
  28. package/apps/web/dist/assets/ProjectPage-FgoZDzPI.js +1 -0
  29. package/apps/web/dist/assets/PublicRunner-ClT6B7t5.js +1 -0
  30. package/apps/web/dist/assets/{ReadOnly-DTjv2Jzm.js → ReadOnly-DQPHmvS0.js} +1 -1
  31. package/apps/web/dist/assets/RunDrawer-DAwTEyjM.js +4 -0
  32. package/apps/web/dist/assets/{RunnerView-BPPmlv9z.js → RunnerView-B_tJroFM.js} +1 -1
  33. package/apps/web/dist/assets/SettingsPage-BsMN-XQs.js +1 -0
  34. package/apps/web/dist/assets/SetupPage-C7FuIpSt.js +1 -0
  35. package/apps/web/dist/assets/SharePage-CbFe83L9.js +1 -0
  36. package/apps/web/dist/assets/changelog-mYclku73.js +1 -0
  37. package/apps/web/dist/assets/{circle-alert-CVOH4wAv.js → circle-alert-DCBV7n5k.js} +1 -1
  38. package/apps/web/dist/assets/{esm-BfXdeWaI.js → esm-BhIeKBJY.js} +1 -1
  39. package/apps/web/dist/assets/index-CmxVazOf.css +1 -0
  40. package/apps/web/dist/assets/index-fflYi8P1.js +14 -0
  41. package/apps/web/dist/assets/{lock-DHBh62LA.js → lock-DlzTfwxP.js} +1 -1
  42. package/apps/web/dist/assets/{phone-Bt1cyWpY.js → phone-BFD815nc.js} +1 -1
  43. package/apps/web/dist/assets/{settings-menu-ace1vI3i.js → settings-menu-C5Y8SOhG.js} +1 -1
  44. package/apps/web/dist/assets/ui-DTuixH4y.js +134 -0
  45. package/apps/web/dist/assets/{upload-CCEq88l4.js → upload-BEf5-d6X.js} +1 -1
  46. package/apps/web/dist/index.html +4 -5
  47. package/apps/web/dist/mcp/flow.html +2 -2
  48. package/node_modules/@engenty-wizards/plugin-sdk/dist/index.d.ts +36 -2
  49. package/node_modules/@engenty-wizards/plugin-sdk/dist/studio.d.ts +44 -1
  50. package/node_modules/@engenty-wizards/shared/dist/feature-flags.d.ts +18 -0
  51. package/node_modules/@engenty-wizards/shared/dist/feature-flags.js +18 -0
  52. package/node_modules/@engenty-wizards/shared/dist/projects.d.ts +22 -6
  53. package/node_modules/@engenty-wizards/shared/dist/projects.js +20 -0
  54. package/package.json +1 -1
  55. package/apps/web/dist/assets/AboutDialog-CPQiN7H9.js +0 -1
  56. package/apps/web/dist/assets/ChatView-CKo_9IZD.js +0 -10
  57. package/apps/web/dist/assets/EditorPage-CDIJXC4T.js +0 -3
  58. package/apps/web/dist/assets/NewWizardChannels-C1Ss9UMY.js +0 -1
  59. package/apps/web/dist/assets/NewWizardPage-2qLAbvDL.js +0 -7
  60. package/apps/web/dist/assets/OnboardingPage-C1FJwEsT.js +0 -1
  61. package/apps/web/dist/assets/ProjectPage-CGuLUuOs.js +0 -1
  62. package/apps/web/dist/assets/PublicRunner-BKw1lOW4.js +0 -1
  63. package/apps/web/dist/assets/RunDrawer-D1I5xqVf.js +0 -4
  64. package/apps/web/dist/assets/SettingsPage-zbomQfGy.js +0 -1
  65. package/apps/web/dist/assets/SetupPage-D3F7M6Pz.js +0 -1
  66. package/apps/web/dist/assets/SharePage-BY49IJgT.js +0 -1
  67. package/apps/web/dist/assets/changelog-DYZC2E6q.js +0 -1
  68. package/apps/web/dist/assets/index-B9xVkr2X.css +0 -1
  69. package/apps/web/dist/assets/index-BH0PYgov.js +0 -15
  70. package/apps/web/dist/assets/logo-C4V2_3Ar.js +0 -1
  71. package/apps/web/dist/assets/ui-D2SqIZeQ.js +0 -133
  72. package/modules/calls/dist/client.css +0 -3
  73. package/modules/calls/dist/client.js +0 -1
  74. package/modules/calls/engenty.plugin.json +0 -8
  75. package/modules/calls/migrations/0001_init.sql +0 -26
  76. package/modules/calls/package.json +0 -25
  77. package/modules/calls/src/openai.ts +0 -155
  78. package/modules/calls/src/plugin.ts +0 -407
  79. package/modules/calls/src/rules.ts +0 -226
  80. package/modules/calls/src/schema.ts +0 -39
  81. package/modules/calls/src/session.ts +0 -353
  82. package/modules/calls/src/store.ts +0 -124
  83. package/modules/calls/src/twilio.ts +0 -90
  84. package/modules/calls/tsconfig.json +0 -10
  85. package/modules/calls/ui/Settings.tsx +0 -137
  86. package/modules/calls/ui/app.ts +0 -50
  87. package/modules/calls/ui/messages.ts +0 -81
  88. package/modules/calls/ui/parts.tsx +0 -36
  89. package/modules/calls/ui/plugin.tsx +0 -15
  90. package/modules/sms/dist/client.css +0 -3
  91. package/modules/sms/dist/client.js +0 -1
  92. package/modules/sms/engenty.plugin.json +0 -8
  93. package/modules/sms/migrations/0001_init.sql +0 -26
  94. package/modules/sms/package.json +0 -24
  95. package/modules/sms/src/plugin.ts +0 -329
  96. package/modules/sms/src/schema.ts +0 -26
  97. package/modules/sms/src/store.ts +0 -140
  98. package/modules/sms/src/surface.ts +0 -70
  99. package/modules/sms/src/twilio.ts +0 -130
  100. package/modules/sms/tsconfig.json +0 -10
  101. package/modules/sms/ui/Settings.tsx +0 -113
  102. package/modules/sms/ui/Share.tsx +0 -60
  103. package/modules/sms/ui/app.ts +0 -51
  104. package/modules/sms/ui/messages.ts +0 -70
  105. package/modules/sms/ui/parts.tsx +0 -36
  106. package/modules/sms/ui/plugin.tsx +0 -20
  107. package/modules/whatsapp/dist/client.css +0 -3
  108. package/modules/whatsapp/dist/client.js +0 -1
  109. package/modules/whatsapp/engenty.plugin.json +0 -8
  110. package/modules/whatsapp/migrations/0001_init.sql +0 -30
  111. package/modules/whatsapp/package.json +0 -24
  112. package/modules/whatsapp/src/graph.ts +0 -271
  113. package/modules/whatsapp/src/inbound.ts +0 -143
  114. package/modules/whatsapp/src/plugin.ts +0 -374
  115. package/modules/whatsapp/src/schema.ts +0 -32
  116. package/modules/whatsapp/src/store.ts +0 -156
  117. package/modules/whatsapp/src/surface.ts +0 -127
  118. package/modules/whatsapp/tsconfig.json +0 -10
  119. package/modules/whatsapp/ui/Settings.tsx +0 -141
  120. package/modules/whatsapp/ui/Share.tsx +0 -60
  121. package/modules/whatsapp/ui/app.ts +0 -54
  122. package/modules/whatsapp/ui/messages.ts +0 -80
  123. package/modules/whatsapp/ui/parts.tsx +0 -36
  124. package/modules/whatsapp/ui/plugin.tsx +0 -20
@@ -1,329 +0,0 @@
1
- import { definePlugin, type PluginRequest } from "@engenty-wizards/plugin-sdk";
2
- import {
3
- freshThreadState,
4
- type Thread,
5
- ThreadDoor,
6
- type ThreadInbound,
7
- type ThreadWizard,
8
- } from "@engenty-wizards/plugin-sdk/thread";
9
- import { z } from "zod";
10
- import {
11
- dropAccount,
12
- getAccount,
13
- keywordOf,
14
- keywords,
15
- loadThread,
16
- putAccount,
17
- saveThread,
18
- setKeyword,
19
- smsLink,
20
- threadByRun,
21
- } from "./store";
22
- import { ASKS, RUNNER, smsSurface } from "./surface";
23
- import { e164, parseInbound, signatureOk, Twilio, TwilioError } from "./twilio";
24
-
25
- /**
26
- * SMS as a door: the tenant connects its Twilio account and number, the owner switches the
27
- * runner on per wizard and gets a keyword; a person texts the keyword to the number and runs
28
- * the wizard in texts. The conversation itself is the SDK's thread door (surface.ts says how
29
- * it reads as text); this file is the webhook, the rows and the studio's routes. The studio
30
- * half is ui/plugin.tsx.
31
- */
32
-
33
- const fail = (status: number, code: string, message: string): never => {
34
- throw Object.assign(new Error(message), { status, code });
35
- };
36
-
37
- /** An empty TwiML answer: Twilio expects one, and sends nothing of its own. */
38
- const TWIML = new Response('<?xml version="1.0" encoding="UTF-8"?><Response></Response>', {
39
- headers: { "content-type": "text/xml" },
40
- });
41
-
42
- const accountInput = z.object({
43
- accountSid: z.string().trim().min(1).max(60),
44
- number: z.string().trim().min(5).max(20),
45
- authToken: z.string().trim().max(200).optional(),
46
- });
47
-
48
- export default definePlugin((wizards) => {
49
- const { server, log } = wizards;
50
- const db = () => server.getTenantDb();
51
-
52
- server.registerMigrations("migrations");
53
-
54
- // One turn per thread at a time: a webhook, a run's signal and a retry never interleave.
55
- const turns = new Map<string, Promise<void>>();
56
- function turn(key: string, work: () => Promise<void>): Promise<void> {
57
- const next = (turns.get(key) ?? Promise.resolve())
58
- .catch(() => undefined)
59
- .then(work)
60
- .catch((err) => log.error(`thread ${key}:`, err));
61
- turns.set(key, next);
62
- void next.finally(() => {
63
- if (turns.get(key) === next) {
64
- turns.delete(key);
65
- }
66
- });
67
- return next;
68
- }
69
-
70
- const watching = new Map<string, () => void>();
71
- const timers = new Map<string, ReturnType<typeof setTimeout>>();
72
-
73
- /** The wizards switched on for SMS, each with its keyword. */
74
- async function offered(): Promise<ThreadWizard[]> {
75
- const on = await server.runs.wizards(RUNNER);
76
- const words = await keywords(db());
77
- return Promise.all(
78
- on.map(async (w) => ({
79
- ...w,
80
- keyword: words.get(w.wizardId) ?? (await keywordOf(db(), w.wizardId, w.title)),
81
- })),
82
- );
83
- }
84
-
85
- const door = new ThreadDoor({
86
- runs: server.runs,
87
- surface: smsSurface({ account: () => getAccount(db()) }),
88
- offered,
89
- watch(thread) {
90
- const runId = thread.runId;
91
- if (!runId || watching.has(runId)) {
92
- return;
93
- }
94
- const { id } = thread;
95
- const stop = server.runs.subscribe(runId, (signal) => {
96
- if (signal.event && signal.event.type !== "step_done" && signal.event.type !== "error") {
97
- return;
98
- }
99
- clearTimeout(timers.get(runId));
100
- timers.set(
101
- runId,
102
- setTimeout(() => {
103
- timers.delete(runId);
104
- void turn(id, () => resume(id));
105
- }, 300),
106
- );
107
- });
108
- watching.set(runId, stop);
109
- },
110
- unwatch(runId) {
111
- watching.get(runId)?.();
112
- watching.delete(runId);
113
- clearTimeout(timers.get(runId));
114
- timers.delete(runId);
115
- },
116
- log,
117
- });
118
- server.onUnload(() => {
119
- for (const stop of watching.values()) {
120
- stop();
121
- }
122
- watching.clear();
123
- for (const timer of timers.values()) {
124
- clearTimeout(timer);
125
- }
126
- });
127
-
128
- async function handle(msg: ThreadInbound): Promise<void> {
129
- const thread: Thread = (await loadThread(db(), msg.from)) ?? {
130
- id: msg.from,
131
- name: null,
132
- runId: null,
133
- state: freshThreadState(),
134
- lastInboundAt: msg.at,
135
- lastMessageId: null,
136
- };
137
- if (thread.lastMessageId === msg.id) {
138
- // Twilio posts again when the answer was slow: the message was dealt with.
139
- return;
140
- }
141
- thread.lastMessageId = msg.id;
142
- thread.lastInboundAt = Math.max(thread.lastInboundAt, msg.at);
143
- try {
144
- await door.inbound(thread, msg);
145
- } finally {
146
- await saveThread(db(), thread);
147
- }
148
- }
149
-
150
- async function resume(id: string): Promise<void> {
151
- const thread = await loadThread(db(), id);
152
- if (!thread) {
153
- return;
154
- }
155
- try {
156
- await door.resume(thread);
157
- } finally {
158
- await saveThread(db(), thread);
159
- }
160
- }
161
-
162
- // ── The door ───────────────────────────────────────────────────────────────
163
-
164
- server.registerRunner({
165
- id: RUNNER,
166
- label: { de: "SMS", en: "SMS" },
167
- kind: "channel",
168
- capabilities: {
169
- input: ASKS,
170
- output: { shows: ["text", "data"], pictures: [] },
171
- asks: ["confirm"],
172
- review: ["accept", "regenerate"],
173
- waits: true,
174
- handoff: ["sms"],
175
- },
176
- problem: async () =>
177
- (await getAccount(db())) ? null : "Keine Nummer verbunden (Einstellungen → SMS)",
178
- // The share dialog's row: the number, the keyword and the link that writes it.
179
- address: async (wizard) => {
180
- const b = await bindingView(wizard.id);
181
- return b.number ? { number: b.number, keyword: b.keyword, link: b.link } : null;
182
- },
183
- });
184
-
185
- // What people text: Twilio posts a form; answered with empty TwiML, dealt with in the thread's turn.
186
- server.registerPublicRoute({
187
- method: "POST",
188
- path: "/webhook",
189
- handler: async ({ request }) => {
190
- const account = await getAccount(db());
191
- if (!account) {
192
- return TWIML.clone();
193
- }
194
- const form = new URLSearchParams(await request.text());
195
- const urls = [await server.publicUrl("/webhook"), request.url];
196
- if (!signatureOk(account.authToken, urls, form, request.headers.get("x-twilio-signature"))) {
197
- return fail(403, "bad_signature", "The signature does not match.");
198
- }
199
- const msg = parseInbound(form);
200
- if (msg) {
201
- void turn(msg.from, () => handle(msg));
202
- }
203
- return TWIML.clone();
204
- },
205
- });
206
-
207
- // A run that ended while nobody listened (the runtime was restarted): told on its own.
208
- for (const event of ["run.done", "run.failed"] as const) {
209
- server.on(event, async ({ run }) => {
210
- if (run.mode !== "live") {
211
- return;
212
- }
213
- const thread = await threadByRun(db(), run.id);
214
- if (thread && !thread.state.delivered) {
215
- await turn(thread.id, () => resume(thread.id));
216
- }
217
- });
218
- }
219
-
220
- // ── The studio's side ──────────────────────────────────────────────────────
221
-
222
- const admin = (request: PluginRequest) => {
223
- if (request.user.role === "member") {
224
- fail(403, "forbidden", "Owners and admins only.");
225
- }
226
- };
227
-
228
- async function accountView() {
229
- const account = await getAccount(db());
230
- return {
231
- connected: Boolean(account),
232
- accountSid: account?.accountSid ?? "",
233
- number: account?.number ?? "",
234
- hasToken: Boolean(account?.authToken),
235
- webhook: await server.publicUrl("/webhook"),
236
- };
237
- }
238
-
239
- server.registerHttpRoute({
240
- method: "GET",
241
- path: "/account",
242
- role: "admin",
243
- handler: accountView,
244
- });
245
-
246
- server.registerHttpRoute({
247
- method: "PUT",
248
- path: "/account",
249
- role: "admin",
250
- handler: async (request) => {
251
- admin(request);
252
- const parsed = accountInput.safeParse(await request.json());
253
- if (!parsed.success) {
254
- return fail(400, "invalid", parsed.error.issues[0]?.message ?? "invalid");
255
- }
256
- await putAccount(db(), parsed.data);
257
- return accountView();
258
- },
259
- });
260
-
261
- server.registerHttpRoute({
262
- method: "DELETE",
263
- path: "/account",
264
- role: "admin",
265
- handler: async (request) => {
266
- admin(request);
267
- await dropAccount(db());
268
- return accountView();
269
- },
270
- });
271
-
272
- // A text to a number of one's own: the account and the number are right when it arrives.
273
- server.registerHttpRoute({
274
- method: "POST",
275
- path: "/account/test",
276
- role: "admin",
277
- handler: async (request) => {
278
- admin(request);
279
- const account = await getAccount(db());
280
- if (!account) {
281
- return fail(409, "not_connected", "No number is connected.");
282
- }
283
- const { to } = (await request.json()) as { to?: string };
284
- const number = e164(String(to ?? ""));
285
- if (number.length < 6) {
286
- return fail(400, "invalid", "A number with its country code.");
287
- }
288
- try {
289
- const id = await new Twilio(account).send(number, "✓ engenty wizards");
290
- return { ok: true, id };
291
- } catch (err) {
292
- return fail(
293
- 502,
294
- "provider",
295
- err instanceof TwilioError ? err.message : "The text was not sent.",
296
- );
297
- }
298
- },
299
- });
300
-
301
- // The keyword a wizard answers to, and the link that types it.
302
- async function bindingView(wizardId: string) {
303
- const account = await getAccount(db());
304
- const on = await server.runs.wizards(RUNNER);
305
- const title = on.find((w) => w.wizardId === wizardId)?.title ?? "wizard";
306
- const keyword = await keywordOf(db(), wizardId, title);
307
- return {
308
- keyword,
309
- link: account ? smsLink(account.number, keyword) : null,
310
- number: account?.number ?? null,
311
- };
312
- }
313
-
314
- server.registerHttpRoute({
315
- method: "GET",
316
- path: "/binding/:wizardId",
317
- handler: ({ params }) => bindingView(params.wizardId),
318
- });
319
-
320
- server.registerHttpRoute({
321
- method: "PUT",
322
- path: "/binding/:wizardId",
323
- handler: async (request) => {
324
- const { keyword } = (await request.json()) as { keyword?: string };
325
- await setKeyword(db(), request.params.wizardId, String(keyword ?? ""));
326
- return bindingView(request.params.wizardId);
327
- },
328
- });
329
- });
@@ -1,26 +0,0 @@
1
- import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
2
-
3
- /** The tables of migrations/, for Drizzle. Kept in step with the SQL by hand. */
4
-
5
- export const account = sqliteTable("sms_account", {
6
- id: integer("id").primaryKey(),
7
- accountSid: text("account_sid").notNull(),
8
- authToken: text("auth_token").notNull(),
9
- /** The number people text, as E.164: +43660… */
10
- number: text("number").notNull(),
11
- updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(),
12
- });
13
-
14
- export const binding = sqliteTable("sms_binding", {
15
- wizardId: text("wizard_id").primaryKey(),
16
- keyword: text("keyword").notNull(),
17
- });
18
-
19
- export const thread = sqliteTable("sms_thread", {
20
- number: text("number").primaryKey(),
21
- runId: text("run_id"),
22
- state: text("state").notNull(),
23
- lastInboundAt: integer("last_inbound_at", { mode: "timestamp_ms" }).notNull(),
24
- lastMessageId: text("last_message_id"),
25
- updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(),
26
- });
@@ -1,140 +0,0 @@
1
- import type { PluginDb } from "@engenty-wizards/plugin-sdk";
2
- import type { Thread, ThreadState } from "@engenty-wizards/plugin-sdk/thread";
3
- import { eq } from "drizzle-orm";
4
- import { account, binding, thread } from "./schema";
5
- import { type Account, e164 } from "./twilio";
6
-
7
- /** The plugin's rows: the account, the keywords, the threads. */
8
-
9
- export async function getAccount(db: PluginDb): Promise<Account | null> {
10
- const [row] = await db.select().from(account).where(eq(account.id, 1));
11
- return row ? { accountSid: row.accountSid, authToken: row.authToken, number: row.number } : null;
12
- }
13
-
14
- export interface AccountInput {
15
- accountSid: string;
16
- number: string;
17
- /** Left out: the token stays as it is. */
18
- authToken?: string;
19
- }
20
-
21
- export async function putAccount(db: PluginDb, input: AccountInput): Promise<Account> {
22
- const before = await getAccount(db);
23
- const authToken = input.authToken || before?.authToken;
24
- if (!authToken) {
25
- throw Object.assign(new Error("An auth token is needed."), { status: 400, code: "no_token" });
26
- }
27
- const next = {
28
- accountSid: input.accountSid,
29
- authToken,
30
- number: e164(input.number),
31
- updatedAt: new Date(),
32
- };
33
- await db
34
- .insert(account)
35
- .values({ id: 1, ...next })
36
- .onConflictDoUpdate({ target: account.id, set: next });
37
- return { accountSid: next.accountSid, authToken, number: next.number };
38
- }
39
-
40
- export async function dropAccount(db: PluginDb): Promise<void> {
41
- await db.delete(account).where(eq(account.id, 1));
42
- }
43
-
44
- // ── Keywords ────────────────────────────────────────────────────────────────
45
-
46
- export const KEYWORD = /^[\p{L}\p{N}][\p{L}\p{N} _-]{0,38}$/u;
47
-
48
- /** The keyword of every bound wizard, by wizard id. */
49
- export async function keywords(db: PluginDb): Promise<Map<string, string>> {
50
- const rows = await db.select().from(binding);
51
- return new Map(rows.map((r) => [r.wizardId, r.keyword]));
52
- }
53
-
54
- /** The wizard's keyword, made from its title the first time it is asked for. */
55
- export async function keywordOf(db: PluginDb, wizardId: string, title: string): Promise<string> {
56
- const all = await keywords(db);
57
- const own = all.get(wizardId);
58
- if (own) {
59
- return own;
60
- }
61
- const taken = new Set([...all.values()].map((k) => k.toLowerCase()));
62
- const base =
63
- title
64
- .toLowerCase()
65
- .replace(/[^\p{L}\p{N}]+/gu, " ")
66
- .trim()
67
- .split(" ")[0]
68
- ?.slice(0, 30) || "wizard";
69
- let keyword = base;
70
- for (let n = 2; taken.has(keyword); n++) {
71
- keyword = `${base}${n}`;
72
- }
73
- await db.insert(binding).values({ wizardId, keyword }).onConflictDoNothing();
74
- return (await keywords(db)).get(wizardId) ?? keyword;
75
- }
76
-
77
- export async function setKeyword(db: PluginDb, wizardId: string, keyword: string): Promise<string> {
78
- const clean = keyword.trim().replace(/\s+/g, " ");
79
- if (!KEYWORD.test(clean)) {
80
- throw Object.assign(new Error("A keyword is letters, digits, spaces and dashes, up to 40."), {
81
- status: 400,
82
- code: "bad_keyword",
83
- });
84
- }
85
- const other = [...(await keywords(db))].find(
86
- ([id, k]) => id !== wizardId && k.toLowerCase() === clean.toLowerCase(),
87
- );
88
- if (other) {
89
- throw Object.assign(new Error("Another wizard has this keyword."), {
90
- status: 409,
91
- code: "keyword_taken",
92
- });
93
- }
94
- await db
95
- .insert(binding)
96
- .values({ wizardId, keyword: clean })
97
- .onConflictDoUpdate({ target: binding.wizardId, set: { keyword: clean } });
98
- return clean;
99
- }
100
-
101
- /** The link that opens the phone's messages with the keyword typed (RFC 5724). */
102
- export const smsLink = (number: string, keyword: string) =>
103
- `sms:${number}?body=${encodeURIComponent(keyword)}`;
104
-
105
- // ── Threads ─────────────────────────────────────────────────────────────────
106
-
107
- function rowToThread(row: typeof thread.$inferSelect): Thread {
108
- return {
109
- id: row.number,
110
- name: null,
111
- runId: row.runId,
112
- state: JSON.parse(row.state) as ThreadState,
113
- lastInboundAt: row.lastInboundAt.getTime(),
114
- lastMessageId: row.lastMessageId,
115
- };
116
- }
117
-
118
- export async function loadThread(db: PluginDb, number: string): Promise<Thread | null> {
119
- const [row] = await db.select().from(thread).where(eq(thread.number, number));
120
- return row ? rowToThread(row) : null;
121
- }
122
-
123
- export async function threadByRun(db: PluginDb, runId: string): Promise<Thread | null> {
124
- const [row] = await db.select().from(thread).where(eq(thread.runId, runId));
125
- return row ? rowToThread(row) : null;
126
- }
127
-
128
- export async function saveThread(db: PluginDb, t: Thread): Promise<void> {
129
- const next = {
130
- runId: t.runId,
131
- state: JSON.stringify(t.state),
132
- lastInboundAt: new Date(t.lastInboundAt),
133
- lastMessageId: t.lastMessageId,
134
- updatedAt: new Date(),
135
- };
136
- await db
137
- .insert(thread)
138
- .values({ number: t.id, ...next })
139
- .onConflictDoUpdate({ target: thread.number, set: next });
140
- }
@@ -1,70 +0,0 @@
1
- import {
2
- clipText,
3
- type Thread,
4
- type ThreadLang,
5
- type ThreadPrompt,
6
- type ThreadSurface,
7
- } from "@engenty-wizards/plugin-sdk/thread";
8
- import { type Account, Twilio } from "./twilio";
9
-
10
- /**
11
- * SMS as a surface of the thread door: text only. A question with options is numbered lines
12
- * and the person answers with the number; a place is an address typed; every picture and
13
- * file is a link; a long text is its gist, the link at the end has the rest.
14
- */
15
-
16
- export const RUNNER = "sms";
17
-
18
- /** The field kinds a text can answer; a photo, a file or a signature hands the page to the screen. */
19
- export const ASKS = [
20
- "text",
21
- "textarea",
22
- "number",
23
- "select",
24
- "multiselect",
25
- "date",
26
- "email",
27
- "url",
28
- "toggle",
29
- "location",
30
- ];
31
-
32
- /** How long a text may be before its gist goes out instead: a few segments. */
33
- const GIST = 600;
34
-
35
- const hints = {
36
- de: { one: "Antwort: die Nummer", many: "Antwort: die Nummern, mit Komma getrennt" },
37
- en: { one: "Reply with the number", many: "Reply with the numbers, separated by commas" },
38
- };
39
-
40
- /** What the door says, as one text. */
41
- export function toText(prompt: ThreadPrompt, lang: ThreadLang): string {
42
- switch (prompt.kind) {
43
- case "text":
44
- return clipText(prompt.text, GIST);
45
- case "choice": {
46
- const lines = prompt.options.map((o, i) => `${i + 1}. ${o.picked ? "✓ " : ""}${o.title}`);
47
- return `${clipText(prompt.text, GIST)}\n\n${lines.join("\n")}\n\n${hints[lang][prompt.multiple ? "many" : "one"]}`;
48
- }
49
- case "location":
50
- return clipText(prompt.text, GIST);
51
- case "link":
52
- return `${clipText(prompt.text, GIST)}\n${prompt.url}`;
53
- case "media":
54
- return `${prompt.caption ?? prompt.filename ?? prompt.media}: ${prompt.url}`;
55
- }
56
- }
57
-
58
- export function smsSurface(deps: { account: () => Promise<Account | null> }): ThreadSurface {
59
- return {
60
- runner: RUNNER,
61
- asks: ASKS,
62
- async send(thread: Thread, prompt: ThreadPrompt) {
63
- const account = await deps.account();
64
- if (!account) {
65
- throw new Error("SMS is not connected.");
66
- }
67
- await new Twilio(account).send(thread.id, toText(prompt, thread.state.lang));
68
- },
69
- };
70
- }
@@ -1,130 +0,0 @@
1
- import { createHmac, timingSafeEqual } from "node:crypto";
2
- import type { ThreadInbound } from "@engenty-wizards/plugin-sdk/thread";
3
-
4
- /**
5
- * Twilio's messaging API, the part this plugin uses: sending a text to a number, reading
6
- * what Twilio posts when one arrives, and checking that it was Twilio who posted it.
7
- */
8
-
9
- export const API = "https://api.twilio.com/2010-04-01";
10
-
11
- export interface Account {
12
- accountSid: string;
13
- authToken: string;
14
- /** E.164: +43660… */
15
- number: string;
16
- }
17
-
18
- /** A text may hold up to 1600 characters over several segments; longer is cut. */
19
- export const TEXT_LIMIT = 1500;
20
-
21
- export class TwilioError extends Error {
22
- constructor(
23
- message: string,
24
- readonly status: number,
25
- readonly code: number | null,
26
- ) {
27
- super(message);
28
- this.name = "TwilioError";
29
- }
30
- }
31
-
32
- /** A number as Twilio and the person's phone write it: +, then digits. */
33
- export function e164(raw: string): string {
34
- const digits = raw.replace(/[^\d+]/g, "");
35
- return digits.startsWith("+") ? `+${digits.slice(1).replace(/\+/g, "")}` : `+${digits}`;
36
- }
37
-
38
- export class Twilio {
39
- constructor(
40
- private readonly account: Account,
41
- private readonly fetchImpl: typeof fetch = (...args) => globalThis.fetch(...args),
42
- ) {}
43
-
44
- /** Sends one text; gives the message's sid. */
45
- async send(to: string, body: string): Promise<string | null> {
46
- const form = new URLSearchParams({
47
- To: to,
48
- From: this.account.number,
49
- Body: body.length > TEXT_LIMIT ? `${body.slice(0, TEXT_LIMIT - 1)}…` : body,
50
- });
51
- const res = await this.fetchImpl(
52
- `${API}/Accounts/${encodeURIComponent(this.account.accountSid)}/Messages.json`,
53
- {
54
- method: "POST",
55
- headers: {
56
- authorization: `Basic ${Buffer.from(`${this.account.accountSid}:${this.account.authToken}`).toString("base64")}`,
57
- "content-type": "application/x-www-form-urlencoded",
58
- },
59
- body: form.toString(),
60
- signal: AbortSignal.timeout(20_000),
61
- },
62
- );
63
- const text = await res.text();
64
- let parsed: Record<string, unknown> = {};
65
- try {
66
- parsed = text ? (JSON.parse(text) as Record<string, unknown>) : {};
67
- } catch {
68
- parsed = {};
69
- }
70
- if (!res.ok) {
71
- throw new TwilioError(
72
- typeof parsed.message === "string" ? parsed.message : `Twilio answered ${res.status}.`,
73
- res.status,
74
- typeof parsed.code === "number" ? parsed.code : null,
75
- );
76
- }
77
- return typeof parsed.sid === "string" ? parsed.sid : null;
78
- }
79
- }
80
-
81
- /**
82
- * `X-Twilio-Signature` is the HMAC-SHA1, base64, of the address Twilio posted to with every
83
- * form field appended, sorted by name, each as name then value.
84
- */
85
- export function signatureOf(authToken: string, url: string, form: URLSearchParams): string {
86
- const names = [...new Set(form.keys())].sort();
87
- const data = names.reduce((acc, name) => acc + name + form.getAll(name).join(""), url);
88
- return createHmac("sha1", authToken).update(data, "utf8").digest("base64");
89
- }
90
-
91
- export function signatureOk(
92
- authToken: string,
93
- urls: string[],
94
- form: URLSearchParams,
95
- header: string | null,
96
- ): boolean {
97
- if (!header) {
98
- return false;
99
- }
100
- const given = Buffer.from(header);
101
- return urls.some((url) => {
102
- const expected = Buffer.from(signatureOf(authToken, url, form));
103
- return expected.length === given.length && timingSafeEqual(expected, given);
104
- });
105
- }
106
-
107
- /** What Twilio posted, as the thread door reads a message. Null for a status callback. */
108
- export function parseInbound(form: URLSearchParams): ThreadInbound | null {
109
- const id = form.get("MessageSid") || form.get("SmsSid");
110
- const from = form.get("From");
111
- if (!id || !from || form.get("MessageStatus")) {
112
- return null;
113
- }
114
- const body = (form.get("Body") ?? "").trim();
115
- const count = Number(form.get("NumMedia") ?? 0) || 0;
116
- const first = count ? form.get("MediaUrl0") : null;
117
- const mime = count ? (form.get("MediaContentType0") ?? "application/octet-stream") : "";
118
- const base = { id, from, name: form.get("ProfileName") || null, at: Date.now() };
119
- if (first) {
120
- const kind = mime.startsWith("image/")
121
- ? "image"
122
- : mime.startsWith("video/")
123
- ? "video"
124
- : mime.startsWith("audio/")
125
- ? "audio"
126
- : "document";
127
- return { ...base, kind, media: { id: first, mime, caption: body || undefined }, text: body };
128
- }
129
- return { ...base, kind: "text", text: body };
130
- }