wizards 0.4.1 → 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.
- package/apps/runtime/dist/docs/dev/plugins/reloading.md +3 -3
- package/apps/runtime/dist/docs/dev/plugins/runners.md +9 -12
- package/apps/runtime/dist/docs/user/test-and-share.md +22 -58
- package/apps/web/dist/assets/{AboutDialog-CXEpsI6L.js → AboutDialog-CH1rQdia.js} +1 -1
- package/apps/web/dist/assets/{ChangelogDialog-Cc2APUGI.js → ChangelogDialog-78MQLHfA.js} +1 -1
- package/apps/web/dist/assets/{ChatView-B2r4dW_m.js → ChatView-DLJfy69U.js} +1 -1
- package/apps/web/dist/assets/{EditorPage-CNAYlSms.js → EditorPage-BYrZokVk.js} +1 -1
- package/apps/web/dist/assets/{FlowPreview-DzU1nGDb.js → FlowPreview-xIWPdOCV.js} +1 -1
- package/apps/web/dist/assets/{Harness-ALQ9Csm6.js → Harness-CsDhNLSW.js} +1 -1
- package/apps/web/dist/assets/{NewWizardChannels-DMPqHkCF.js → NewWizardChannels-JB3V6DCL.js} +1 -1
- package/apps/web/dist/assets/{NewWizardPage-pi053e8N.js → NewWizardPage-Bkh-hjSe.js} +1 -1
- package/apps/web/dist/assets/{OnboardingPage-TYNX6Umd.js → OnboardingPage-Diw1ILjC.js} +1 -1
- package/apps/web/dist/assets/{ProjectPage-CvEkx_Ux.js → ProjectPage-FgoZDzPI.js} +1 -1
- package/apps/web/dist/assets/{PublicRunner-BrivKNHo.js → PublicRunner-ClT6B7t5.js} +1 -1
- package/apps/web/dist/assets/{RunDrawer-3osugizk.js → RunDrawer-DAwTEyjM.js} +1 -1
- package/apps/web/dist/assets/{RunnerView-Ci4_WzVv.js → RunnerView-B_tJroFM.js} +1 -1
- package/apps/web/dist/assets/{SettingsPage-DKntf7-9.js → SettingsPage-BsMN-XQs.js} +1 -1
- package/apps/web/dist/assets/{SetupPage-DZQxEQyk.js → SetupPage-C7FuIpSt.js} +1 -1
- package/apps/web/dist/assets/{SharePage-B9l4KthU.js → SharePage-CbFe83L9.js} +1 -1
- package/apps/web/dist/assets/changelog-mYclku73.js +1 -0
- package/apps/web/dist/assets/{esm-CuqXcPS3.js → esm-BhIeKBJY.js} +1 -1
- package/apps/web/dist/assets/{index-j09nLtEs.js → index-fflYi8P1.js} +3 -3
- package/apps/web/dist/assets/{settings-menu-C7VsRpe9.js → settings-menu-C5Y8SOhG.js} +1 -1
- package/apps/web/dist/index.html +1 -1
- package/package.json +1 -1
- package/apps/web/dist/assets/changelog-CxdXZo3V.js +0 -1
- package/modules/calls/dist/client.css +0 -3
- package/modules/calls/dist/client.js +0 -1
- package/modules/calls/engenty.plugin.json +0 -8
- package/modules/calls/migrations/0001_init.sql +0 -26
- package/modules/calls/package.json +0 -25
- package/modules/calls/src/openai.ts +0 -155
- package/modules/calls/src/plugin.ts +0 -407
- package/modules/calls/src/rules.ts +0 -226
- package/modules/calls/src/schema.ts +0 -39
- package/modules/calls/src/session.ts +0 -353
- package/modules/calls/src/store.ts +0 -124
- package/modules/calls/src/twilio.ts +0 -90
- package/modules/calls/tsconfig.json +0 -10
- package/modules/calls/ui/Settings.tsx +0 -137
- package/modules/calls/ui/app.ts +0 -50
- package/modules/calls/ui/messages.ts +0 -81
- package/modules/calls/ui/parts.tsx +0 -36
- package/modules/calls/ui/plugin.tsx +0 -15
- package/modules/sms/dist/client.css +0 -3
- package/modules/sms/dist/client.js +0 -1
- package/modules/sms/engenty.plugin.json +0 -8
- package/modules/sms/migrations/0001_init.sql +0 -26
- package/modules/sms/package.json +0 -24
- package/modules/sms/src/plugin.ts +0 -329
- package/modules/sms/src/schema.ts +0 -26
- package/modules/sms/src/store.ts +0 -140
- package/modules/sms/src/surface.ts +0 -70
- package/modules/sms/src/twilio.ts +0 -130
- package/modules/sms/tsconfig.json +0 -10
- package/modules/sms/ui/Settings.tsx +0 -113
- package/modules/sms/ui/Share.tsx +0 -60
- package/modules/sms/ui/app.ts +0 -51
- package/modules/sms/ui/messages.ts +0 -70
- package/modules/sms/ui/parts.tsx +0 -36
- package/modules/sms/ui/plugin.tsx +0 -20
- package/modules/whatsapp/dist/client.css +0 -3
- package/modules/whatsapp/dist/client.js +0 -1
- package/modules/whatsapp/engenty.plugin.json +0 -8
- package/modules/whatsapp/migrations/0001_init.sql +0 -30
- package/modules/whatsapp/package.json +0 -24
- package/modules/whatsapp/src/graph.ts +0 -271
- package/modules/whatsapp/src/inbound.ts +0 -143
- package/modules/whatsapp/src/plugin.ts +0 -374
- package/modules/whatsapp/src/schema.ts +0 -32
- package/modules/whatsapp/src/store.ts +0 -156
- package/modules/whatsapp/src/surface.ts +0 -127
- package/modules/whatsapp/tsconfig.json +0 -10
- package/modules/whatsapp/ui/Settings.tsx +0 -141
- package/modules/whatsapp/ui/Share.tsx +0 -60
- package/modules/whatsapp/ui/app.ts +0 -54
- package/modules/whatsapp/ui/messages.ts +0 -80
- package/modules/whatsapp/ui/parts.tsx +0 -36
- package/modules/whatsapp/ui/plugin.tsx +0 -20
|
@@ -1,374 +0,0 @@
|
|
|
1
|
-
import { definePlugin, type PluginRequest } from "@engenty-wizards/plugin-sdk";
|
|
2
|
-
import {
|
|
3
|
-
freshThreadState,
|
|
4
|
-
type Thread,
|
|
5
|
-
ThreadDoor,
|
|
6
|
-
type ThreadWizard,
|
|
7
|
-
} from "@engenty-wizards/plugin-sdk/thread";
|
|
8
|
-
import { z } from "zod";
|
|
9
|
-
import { Graph, GraphError } from "./graph";
|
|
10
|
-
import { type Inbound, parseWebhook, signatureOk } from "./inbound";
|
|
11
|
-
import {
|
|
12
|
-
dropAccount,
|
|
13
|
-
getAccount,
|
|
14
|
-
keywordOf,
|
|
15
|
-
keywords,
|
|
16
|
-
loadThread,
|
|
17
|
-
putAccount,
|
|
18
|
-
saveThread,
|
|
19
|
-
setKeyword,
|
|
20
|
-
threadByRun,
|
|
21
|
-
waLink,
|
|
22
|
-
} from "./store";
|
|
23
|
-
import { ASKS, RUNNER, whatsappSurface } from "./surface";
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* WhatsApp as a door: the tenant connects its business number (Meta's Cloud API), the owner
|
|
27
|
-
* switches the runner on per wizard and gets a keyword; a person writes the keyword to the
|
|
28
|
-
* number and runs the wizard in the thread. The conversation itself is the SDK's thread door
|
|
29
|
-
* (surface.ts says how it looks on WhatsApp); this file is the webhook, the rows and the
|
|
30
|
-
* studio's routes. The studio 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
|
-
const accountInput = z.object({
|
|
38
|
-
phoneNumberId: z.string().trim().min(1).max(40),
|
|
39
|
-
number: z.string().trim().min(5).max(20),
|
|
40
|
-
accessToken: z.string().trim().max(1000).optional(),
|
|
41
|
-
appSecret: z.string().trim().max(200).nullable().optional(),
|
|
42
|
-
template: z.string().trim().max(512).nullable().optional(),
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
export default definePlugin((wizards) => {
|
|
46
|
-
const { server, log } = wizards;
|
|
47
|
-
const db = () => server.getTenantDb();
|
|
48
|
-
|
|
49
|
-
server.registerMigrations("migrations");
|
|
50
|
-
|
|
51
|
-
// One turn per thread at a time: a webhook, a run's signal and a retry never interleave.
|
|
52
|
-
const turns = new Map<string, Promise<void>>();
|
|
53
|
-
function turn(key: string, work: () => Promise<void>): Promise<void> {
|
|
54
|
-
const next = (turns.get(key) ?? Promise.resolve())
|
|
55
|
-
.catch(() => undefined)
|
|
56
|
-
.then(work)
|
|
57
|
-
.catch((err) => log.error(`thread ${key}:`, err));
|
|
58
|
-
turns.set(key, next);
|
|
59
|
-
void next.finally(() => {
|
|
60
|
-
if (turns.get(key) === next) {
|
|
61
|
-
turns.delete(key);
|
|
62
|
-
}
|
|
63
|
-
});
|
|
64
|
-
return next;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// The runs the threads listen to, with what stops the listening.
|
|
68
|
-
const watching = new Map<string, () => void>();
|
|
69
|
-
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
70
|
-
|
|
71
|
-
/** The wizards switched on for WhatsApp, each with its keyword. */
|
|
72
|
-
async function offered(): Promise<ThreadWizard[]> {
|
|
73
|
-
const on = await server.runs.wizards(RUNNER);
|
|
74
|
-
const words = await keywords(db());
|
|
75
|
-
return Promise.all(
|
|
76
|
-
on.map(async (w) => ({
|
|
77
|
-
...w,
|
|
78
|
-
keyword: words.get(w.wizardId) ?? (await keywordOf(db(), w.wizardId, w.title)),
|
|
79
|
-
})),
|
|
80
|
-
);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const door = new ThreadDoor({
|
|
84
|
-
runs: server.runs,
|
|
85
|
-
surface: whatsappSurface({ account: () => getAccount(db()), log }),
|
|
86
|
-
offered,
|
|
87
|
-
watch(thread) {
|
|
88
|
-
const runId = thread.runId;
|
|
89
|
-
if (!runId || watching.has(runId)) {
|
|
90
|
-
return;
|
|
91
|
-
}
|
|
92
|
-
const { id } = thread;
|
|
93
|
-
const stop = server.runs.subscribe(runId, (signal) => {
|
|
94
|
-
// Only a step that ended or a change of status moves the thread; a burst is one look.
|
|
95
|
-
if (signal.event && signal.event.type !== "step_done" && signal.event.type !== "error") {
|
|
96
|
-
return;
|
|
97
|
-
}
|
|
98
|
-
clearTimeout(timers.get(runId));
|
|
99
|
-
timers.set(
|
|
100
|
-
runId,
|
|
101
|
-
setTimeout(() => {
|
|
102
|
-
timers.delete(runId);
|
|
103
|
-
void turn(id, () => resume(id));
|
|
104
|
-
}, 300),
|
|
105
|
-
);
|
|
106
|
-
});
|
|
107
|
-
watching.set(runId, stop);
|
|
108
|
-
},
|
|
109
|
-
unwatch(runId) {
|
|
110
|
-
watching.get(runId)?.();
|
|
111
|
-
watching.delete(runId);
|
|
112
|
-
clearTimeout(timers.get(runId));
|
|
113
|
-
timers.delete(runId);
|
|
114
|
-
},
|
|
115
|
-
log,
|
|
116
|
-
});
|
|
117
|
-
server.onUnload(() => {
|
|
118
|
-
for (const stop of watching.values()) {
|
|
119
|
-
stop();
|
|
120
|
-
}
|
|
121
|
-
watching.clear();
|
|
122
|
-
for (const timer of timers.values()) {
|
|
123
|
-
clearTimeout(timer);
|
|
124
|
-
}
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
async function handle(msg: Inbound): Promise<void> {
|
|
128
|
-
const thread: Thread = (await loadThread(db(), msg.from)) ?? {
|
|
129
|
-
id: msg.from,
|
|
130
|
-
name: msg.name,
|
|
131
|
-
runId: null,
|
|
132
|
-
state: freshThreadState(),
|
|
133
|
-
lastInboundAt: msg.at,
|
|
134
|
-
lastMessageId: null,
|
|
135
|
-
};
|
|
136
|
-
if (thread.lastMessageId === msg.id) {
|
|
137
|
-
// Meta posts a webhook again when the answer was slow: the message was dealt with.
|
|
138
|
-
return;
|
|
139
|
-
}
|
|
140
|
-
thread.lastMessageId = msg.id;
|
|
141
|
-
thread.lastInboundAt = Math.max(thread.lastInboundAt, msg.at);
|
|
142
|
-
if (msg.name) {
|
|
143
|
-
thread.name = msg.name;
|
|
144
|
-
}
|
|
145
|
-
// The two ticks turn blue while the answer is worked out.
|
|
146
|
-
void getAccount(db())
|
|
147
|
-
.then((account) => (account ? new Graph(account).markRead(msg.id) : undefined))
|
|
148
|
-
.catch(() => undefined);
|
|
149
|
-
try {
|
|
150
|
-
await door.inbound(thread, msg);
|
|
151
|
-
} finally {
|
|
152
|
-
await saveThread(db(), thread);
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
async function resume(id: string): Promise<void> {
|
|
157
|
-
const thread = await loadThread(db(), id);
|
|
158
|
-
if (!thread) {
|
|
159
|
-
return;
|
|
160
|
-
}
|
|
161
|
-
try {
|
|
162
|
-
await door.resume(thread);
|
|
163
|
-
} finally {
|
|
164
|
-
await saveThread(db(), thread);
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// ── The door ───────────────────────────────────────────────────────────────
|
|
169
|
-
|
|
170
|
-
server.registerRunner({
|
|
171
|
-
id: RUNNER,
|
|
172
|
-
label: { de: "WhatsApp", en: "WhatsApp" },
|
|
173
|
-
kind: "channel",
|
|
174
|
-
capabilities: {
|
|
175
|
-
input: ASKS,
|
|
176
|
-
output: {
|
|
177
|
-
shows: ["text", "data", "image", "video", "voice", "document", "film"],
|
|
178
|
-
pictures: ["dashboard", "widget"],
|
|
179
|
-
},
|
|
180
|
-
asks: ["confirm"],
|
|
181
|
-
review: ["accept", "regenerate"],
|
|
182
|
-
waits: true,
|
|
183
|
-
handoff: ["thread"],
|
|
184
|
-
},
|
|
185
|
-
problem: async () =>
|
|
186
|
-
(await getAccount(db())) ? null : "Keine Nummer verbunden (Einstellungen → WhatsApp)",
|
|
187
|
-
// The share dialog's row: the number, the keyword and the link that writes it.
|
|
188
|
-
address: async (wizard) => {
|
|
189
|
-
const b = await bindingView(wizard.id);
|
|
190
|
-
return b.number ? { number: `+${b.number}`, keyword: b.keyword, link: b.link } : null;
|
|
191
|
-
},
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
// Meta checks the address once, with the verify token the settings show.
|
|
195
|
-
server.registerPublicRoute({
|
|
196
|
-
method: "GET",
|
|
197
|
-
path: "/webhook",
|
|
198
|
-
handler: async ({ query }) => {
|
|
199
|
-
const account = await getAccount(db());
|
|
200
|
-
if (
|
|
201
|
-
account &&
|
|
202
|
-
query.get("hub.mode") === "subscribe" &&
|
|
203
|
-
query.get("hub.verify_token") === account.verifyToken
|
|
204
|
-
) {
|
|
205
|
-
return new Response(query.get("hub.challenge") ?? "", {
|
|
206
|
-
status: 200,
|
|
207
|
-
headers: { "content-type": "text/plain" },
|
|
208
|
-
});
|
|
209
|
-
}
|
|
210
|
-
return fail(403, "forbidden", "The verify token does not match.");
|
|
211
|
-
},
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
// What people write: answered at once, dealt with in the thread's own turn.
|
|
215
|
-
server.registerPublicRoute({
|
|
216
|
-
method: "POST",
|
|
217
|
-
path: "/webhook",
|
|
218
|
-
handler: async ({ request }) => {
|
|
219
|
-
const account = await getAccount(db());
|
|
220
|
-
if (!account) {
|
|
221
|
-
return { ok: true };
|
|
222
|
-
}
|
|
223
|
-
const raw = await request.text();
|
|
224
|
-
if (
|
|
225
|
-
account.appSecret &&
|
|
226
|
-
!signatureOk(account.appSecret, raw, request.headers.get("x-hub-signature-256"))
|
|
227
|
-
) {
|
|
228
|
-
return fail(403, "bad_signature", "The signature does not match.");
|
|
229
|
-
}
|
|
230
|
-
let body: unknown;
|
|
231
|
-
try {
|
|
232
|
-
body = JSON.parse(raw);
|
|
233
|
-
} catch {
|
|
234
|
-
return fail(400, "bad_body", "Not JSON.");
|
|
235
|
-
}
|
|
236
|
-
for (const msg of parseWebhook(body)) {
|
|
237
|
-
if (msg.phoneNumberId && msg.phoneNumberId !== account.phoneNumberId) {
|
|
238
|
-
continue;
|
|
239
|
-
}
|
|
240
|
-
void turn(msg.from, () => handle(msg));
|
|
241
|
-
}
|
|
242
|
-
return { ok: true };
|
|
243
|
-
},
|
|
244
|
-
});
|
|
245
|
-
|
|
246
|
-
// A run that ended while nobody listened (the runtime was restarted): told on its own.
|
|
247
|
-
for (const event of ["run.done", "run.failed"] as const) {
|
|
248
|
-
server.on(event, async ({ run }) => {
|
|
249
|
-
if (run.mode !== "live") {
|
|
250
|
-
return;
|
|
251
|
-
}
|
|
252
|
-
const thread = await threadByRun(db(), run.id);
|
|
253
|
-
if (thread && !thread.state.delivered) {
|
|
254
|
-
await turn(thread.id, () => resume(thread.id));
|
|
255
|
-
}
|
|
256
|
-
});
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
// ── The studio's side ──────────────────────────────────────────────────────
|
|
260
|
-
|
|
261
|
-
const admin = (request: PluginRequest) => {
|
|
262
|
-
if (request.user.role === "member") {
|
|
263
|
-
fail(403, "forbidden", "Owners and admins only.");
|
|
264
|
-
}
|
|
265
|
-
};
|
|
266
|
-
|
|
267
|
-
async function accountView() {
|
|
268
|
-
const account = await getAccount(db());
|
|
269
|
-
return {
|
|
270
|
-
connected: Boolean(account),
|
|
271
|
-
phoneNumberId: account?.phoneNumberId ?? "",
|
|
272
|
-
number: account?.number ?? "",
|
|
273
|
-
hasToken: Boolean(account?.accessToken),
|
|
274
|
-
hasSecret: Boolean(account?.appSecret),
|
|
275
|
-
template: account?.template ?? "",
|
|
276
|
-
verifyToken: account?.verifyToken ?? null,
|
|
277
|
-
webhook: await server.publicUrl("/webhook"),
|
|
278
|
-
};
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
server.registerHttpRoute({
|
|
282
|
-
method: "GET",
|
|
283
|
-
path: "/account",
|
|
284
|
-
role: "admin",
|
|
285
|
-
handler: accountView,
|
|
286
|
-
});
|
|
287
|
-
|
|
288
|
-
server.registerHttpRoute({
|
|
289
|
-
method: "PUT",
|
|
290
|
-
path: "/account",
|
|
291
|
-
role: "admin",
|
|
292
|
-
handler: async (request) => {
|
|
293
|
-
admin(request);
|
|
294
|
-
const parsed = accountInput.safeParse(await request.json());
|
|
295
|
-
if (!parsed.success) {
|
|
296
|
-
return fail(400, "invalid", parsed.error.issues[0]?.message ?? "invalid");
|
|
297
|
-
}
|
|
298
|
-
await putAccount(db(), parsed.data);
|
|
299
|
-
return accountView();
|
|
300
|
-
},
|
|
301
|
-
});
|
|
302
|
-
|
|
303
|
-
server.registerHttpRoute({
|
|
304
|
-
method: "DELETE",
|
|
305
|
-
path: "/account",
|
|
306
|
-
role: "admin",
|
|
307
|
-
handler: async (request) => {
|
|
308
|
-
admin(request);
|
|
309
|
-
await dropAccount(db());
|
|
310
|
-
return accountView();
|
|
311
|
-
},
|
|
312
|
-
});
|
|
313
|
-
|
|
314
|
-
// A word to a number of one's own: the token and the number id are right when it arrives.
|
|
315
|
-
server.registerHttpRoute({
|
|
316
|
-
method: "POST",
|
|
317
|
-
path: "/account/test",
|
|
318
|
-
role: "admin",
|
|
319
|
-
handler: async (request) => {
|
|
320
|
-
admin(request);
|
|
321
|
-
const account = await getAccount(db());
|
|
322
|
-
if (!account) {
|
|
323
|
-
return fail(409, "not_connected", "No number is connected.");
|
|
324
|
-
}
|
|
325
|
-
const { to } = (await request.json()) as { to?: string };
|
|
326
|
-
const number = String(to ?? "").replace(/\D/g, "");
|
|
327
|
-
if (number.length < 5) {
|
|
328
|
-
return fail(400, "invalid", "A number with its country code.");
|
|
329
|
-
}
|
|
330
|
-
try {
|
|
331
|
-
const id = await new Graph(account).send(number, {
|
|
332
|
-
type: "text",
|
|
333
|
-
text: "✓ engenty wizards",
|
|
334
|
-
});
|
|
335
|
-
return { ok: true, id };
|
|
336
|
-
} catch (err) {
|
|
337
|
-
return fail(
|
|
338
|
-
502,
|
|
339
|
-
"provider",
|
|
340
|
-
err instanceof GraphError ? err.message : "The message was not sent.",
|
|
341
|
-
);
|
|
342
|
-
}
|
|
343
|
-
},
|
|
344
|
-
});
|
|
345
|
-
|
|
346
|
-
// The keyword a wizard answers to, and the link that writes it.
|
|
347
|
-
async function bindingView(wizardId: string) {
|
|
348
|
-
const account = await getAccount(db());
|
|
349
|
-
const on = await server.runs.wizards(RUNNER);
|
|
350
|
-
const title = on.find((w) => w.wizardId === wizardId)?.title ?? "wizard";
|
|
351
|
-
const keyword = await keywordOf(db(), wizardId, title);
|
|
352
|
-
return {
|
|
353
|
-
keyword,
|
|
354
|
-
link: account ? waLink(account.number, keyword) : null,
|
|
355
|
-
number: account?.number ?? null,
|
|
356
|
-
};
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
server.registerHttpRoute({
|
|
360
|
-
method: "GET",
|
|
361
|
-
path: "/binding/:wizardId",
|
|
362
|
-
handler: ({ params }) => bindingView(params.wizardId),
|
|
363
|
-
});
|
|
364
|
-
|
|
365
|
-
server.registerHttpRoute({
|
|
366
|
-
method: "PUT",
|
|
367
|
-
path: "/binding/:wizardId",
|
|
368
|
-
handler: async (request) => {
|
|
369
|
-
const { keyword } = (await request.json()) as { keyword?: string };
|
|
370
|
-
await setKeyword(db(), request.params.wizardId, String(keyword ?? ""));
|
|
371
|
-
return bindingView(request.params.wizardId);
|
|
372
|
-
},
|
|
373
|
-
});
|
|
374
|
-
});
|
|
@@ -1,32 +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("whatsapp_account", {
|
|
6
|
-
id: integer("id").primaryKey(),
|
|
7
|
-
phoneNumberId: text("phone_number_id").notNull(),
|
|
8
|
-
/** Digits only: the number people write to, for `wa.me` links. */
|
|
9
|
-
number: text("number").notNull(),
|
|
10
|
-
accessToken: text("access_token").notNull(),
|
|
11
|
-
/** The Meta app's secret: with it, every webhook's signature is checked. */
|
|
12
|
-
appSecret: text("app_secret"),
|
|
13
|
-
verifyToken: text("verify_token").notNull(),
|
|
14
|
-
/** An approved utility template with one body parameter (the link), for a word after a day of silence. */
|
|
15
|
-
template: text("template"),
|
|
16
|
-
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(),
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
export const binding = sqliteTable("whatsapp_binding", {
|
|
20
|
-
wizardId: text("wizard_id").primaryKey(),
|
|
21
|
-
keyword: text("keyword").notNull(),
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
export const thread = sqliteTable("whatsapp_thread", {
|
|
25
|
-
waId: text("wa_id").primaryKey(),
|
|
26
|
-
name: text("name"),
|
|
27
|
-
runId: text("run_id"),
|
|
28
|
-
state: text("state").notNull(),
|
|
29
|
-
lastInboundAt: integer("last_inbound_at", { mode: "timestamp_ms" }).notNull(),
|
|
30
|
-
lastMessageId: text("last_message_id"),
|
|
31
|
-
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(),
|
|
32
|
-
});
|
|
@@ -1,156 +0,0 @@
|
|
|
1
|
-
import { randomBytes } from "node:crypto";
|
|
2
|
-
import type { PluginDb } from "@engenty-wizards/plugin-sdk";
|
|
3
|
-
import type { Thread, ThreadState } from "@engenty-wizards/plugin-sdk/thread";
|
|
4
|
-
import { eq } from "drizzle-orm";
|
|
5
|
-
import type { Account } from "./graph";
|
|
6
|
-
import { account, binding, thread } from "./schema";
|
|
7
|
-
|
|
8
|
-
/** The plugin's rows: the number, the keywords, the threads. */
|
|
9
|
-
|
|
10
|
-
export async function getAccount(db: PluginDb): Promise<Account | null> {
|
|
11
|
-
const [row] = await db.select().from(account).where(eq(account.id, 1));
|
|
12
|
-
return row
|
|
13
|
-
? {
|
|
14
|
-
phoneNumberId: row.phoneNumberId,
|
|
15
|
-
number: row.number,
|
|
16
|
-
accessToken: row.accessToken,
|
|
17
|
-
appSecret: row.appSecret,
|
|
18
|
-
verifyToken: row.verifyToken,
|
|
19
|
-
template: row.template,
|
|
20
|
-
}
|
|
21
|
-
: null;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export interface AccountInput {
|
|
25
|
-
phoneNumberId: string;
|
|
26
|
-
number: string;
|
|
27
|
-
/** Left out: the token stays as it is. */
|
|
28
|
-
accessToken?: string;
|
|
29
|
-
appSecret?: string | null;
|
|
30
|
-
template?: string | null;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export async function putAccount(db: PluginDb, input: AccountInput): Promise<Account> {
|
|
34
|
-
const before = await getAccount(db);
|
|
35
|
-
const accessToken = input.accessToken || before?.accessToken;
|
|
36
|
-
if (!accessToken) {
|
|
37
|
-
throw Object.assign(new Error("An access token is needed."), { status: 400, code: "no_token" });
|
|
38
|
-
}
|
|
39
|
-
const next = {
|
|
40
|
-
phoneNumberId: input.phoneNumberId,
|
|
41
|
-
number: input.number.replace(/\D/g, ""),
|
|
42
|
-
accessToken,
|
|
43
|
-
appSecret:
|
|
44
|
-
input.appSecret === undefined ? (before?.appSecret ?? null) : input.appSecret || null,
|
|
45
|
-
verifyToken: before?.verifyToken ?? randomBytes(18).toString("base64url"),
|
|
46
|
-
template: input.template === undefined ? (before?.template ?? null) : input.template || null,
|
|
47
|
-
updatedAt: new Date(),
|
|
48
|
-
};
|
|
49
|
-
await db
|
|
50
|
-
.insert(account)
|
|
51
|
-
.values({ id: 1, ...next })
|
|
52
|
-
.onConflictDoUpdate({ target: account.id, set: next });
|
|
53
|
-
return { ...next };
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export async function dropAccount(db: PluginDb): Promise<void> {
|
|
57
|
-
await db.delete(account).where(eq(account.id, 1));
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
// ── Keywords ────────────────────────────────────────────────────────────────
|
|
61
|
-
|
|
62
|
-
export const KEYWORD = /^[\p{L}\p{N}][\p{L}\p{N} _-]{0,38}$/u;
|
|
63
|
-
|
|
64
|
-
/** The keyword of every bound wizard, by wizard id. */
|
|
65
|
-
export async function keywords(db: PluginDb): Promise<Map<string, string>> {
|
|
66
|
-
const rows = await db.select().from(binding);
|
|
67
|
-
return new Map(rows.map((r) => [r.wizardId, r.keyword]));
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/** The wizard's keyword, made from its title the first time it is asked for. */
|
|
71
|
-
export async function keywordOf(db: PluginDb, wizardId: string, title: string): Promise<string> {
|
|
72
|
-
const all = await keywords(db);
|
|
73
|
-
const own = all.get(wizardId);
|
|
74
|
-
if (own) {
|
|
75
|
-
return own;
|
|
76
|
-
}
|
|
77
|
-
const taken = new Set([...all.values()].map((k) => k.toLowerCase()));
|
|
78
|
-
const base =
|
|
79
|
-
title
|
|
80
|
-
.toLowerCase()
|
|
81
|
-
.replace(/[^\p{L}\p{N}]+/gu, " ")
|
|
82
|
-
.trim()
|
|
83
|
-
.split(" ")[0]
|
|
84
|
-
?.slice(0, 30) || "wizard";
|
|
85
|
-
let keyword = base;
|
|
86
|
-
for (let n = 2; taken.has(keyword); n++) {
|
|
87
|
-
keyword = `${base}${n}`;
|
|
88
|
-
}
|
|
89
|
-
await db.insert(binding).values({ wizardId, keyword }).onConflictDoNothing();
|
|
90
|
-
return (await keywords(db)).get(wizardId) ?? keyword;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
export async function setKeyword(db: PluginDb, wizardId: string, keyword: string): Promise<string> {
|
|
94
|
-
const clean = keyword.trim().replace(/\s+/g, " ");
|
|
95
|
-
if (!KEYWORD.test(clean)) {
|
|
96
|
-
throw Object.assign(new Error("A keyword is letters, digits, spaces and dashes, up to 40."), {
|
|
97
|
-
status: 400,
|
|
98
|
-
code: "bad_keyword",
|
|
99
|
-
});
|
|
100
|
-
}
|
|
101
|
-
const other = [...(await keywords(db))].find(
|
|
102
|
-
([id, k]) => id !== wizardId && k.toLowerCase() === clean.toLowerCase(),
|
|
103
|
-
);
|
|
104
|
-
if (other) {
|
|
105
|
-
throw Object.assign(new Error("Another wizard has this keyword."), {
|
|
106
|
-
status: 409,
|
|
107
|
-
code: "keyword_taken",
|
|
108
|
-
});
|
|
109
|
-
}
|
|
110
|
-
await db
|
|
111
|
-
.insert(binding)
|
|
112
|
-
.values({ wizardId, keyword: clean })
|
|
113
|
-
.onConflictDoUpdate({ target: binding.wizardId, set: { keyword: clean } });
|
|
114
|
-
return clean;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
export const waLink = (number: string, keyword: string) =>
|
|
118
|
-
`https://wa.me/${number}?text=${encodeURIComponent(keyword)}`;
|
|
119
|
-
|
|
120
|
-
// ── Threads ─────────────────────────────────────────────────────────────────
|
|
121
|
-
|
|
122
|
-
function rowToThread(row: typeof thread.$inferSelect): Thread {
|
|
123
|
-
return {
|
|
124
|
-
id: row.waId,
|
|
125
|
-
name: row.name,
|
|
126
|
-
runId: row.runId,
|
|
127
|
-
state: JSON.parse(row.state) as ThreadState,
|
|
128
|
-
lastInboundAt: row.lastInboundAt.getTime(),
|
|
129
|
-
lastMessageId: row.lastMessageId,
|
|
130
|
-
};
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
export async function loadThread(db: PluginDb, waId: string): Promise<Thread | null> {
|
|
134
|
-
const [row] = await db.select().from(thread).where(eq(thread.waId, waId));
|
|
135
|
-
return row ? rowToThread(row) : null;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
export async function threadByRun(db: PluginDb, runId: string): Promise<Thread | null> {
|
|
139
|
-
const [row] = await db.select().from(thread).where(eq(thread.runId, runId));
|
|
140
|
-
return row ? rowToThread(row) : null;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
export async function saveThread(db: PluginDb, t: Thread): Promise<void> {
|
|
144
|
-
const next = {
|
|
145
|
-
name: t.name,
|
|
146
|
-
runId: t.runId,
|
|
147
|
-
state: JSON.stringify(t.state),
|
|
148
|
-
lastInboundAt: new Date(t.lastInboundAt),
|
|
149
|
-
lastMessageId: t.lastMessageId,
|
|
150
|
-
updatedAt: new Date(),
|
|
151
|
-
};
|
|
152
|
-
await db
|
|
153
|
-
.insert(thread)
|
|
154
|
-
.values({ waId: t.id, ...next })
|
|
155
|
-
.onConflictDoUpdate({ target: thread.waId, set: next });
|
|
156
|
-
}
|
|
@@ -1,127 +0,0 @@
|
|
|
1
|
-
import type { Thread, ThreadPrompt, ThreadSurface } from "@engenty-wizards/plugin-sdk/thread";
|
|
2
|
-
import { type Account, Graph, GraphError, LIMITS, type Outgoing, RE_ENGAGEMENT } from "./graph";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* WhatsApp as a surface of the thread door: a question with up to three short options is
|
|
6
|
-
* buttons, with up to ten a list, beyond that numbered lines; a place is the "send location"
|
|
7
|
-
* button; files come through the Cloud API; after a day of silence only a template goes out.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
export const RUNNER = "whatsapp";
|
|
11
|
-
|
|
12
|
-
/** The field kinds the thread asks itself; any other on a page hands the page to the screen. */
|
|
13
|
-
export const ASKS = [
|
|
14
|
-
"text",
|
|
15
|
-
"textarea",
|
|
16
|
-
"number",
|
|
17
|
-
"select",
|
|
18
|
-
"multiselect",
|
|
19
|
-
"date",
|
|
20
|
-
"email",
|
|
21
|
-
"url",
|
|
22
|
-
"toggle",
|
|
23
|
-
"image",
|
|
24
|
-
"file",
|
|
25
|
-
"location",
|
|
26
|
-
"audio",
|
|
27
|
-
];
|
|
28
|
-
|
|
29
|
-
const DAY = 24 * 3600_000;
|
|
30
|
-
|
|
31
|
-
/** What the door says, as a message of the Cloud API. */
|
|
32
|
-
export function toOutgoing(prompt: ThreadPrompt): Outgoing {
|
|
33
|
-
switch (prompt.kind) {
|
|
34
|
-
case "text":
|
|
35
|
-
return { type: "text", text: prompt.text };
|
|
36
|
-
case "choice": {
|
|
37
|
-
// Of a multiple choice the picked options are left out; their ids keep their numbers.
|
|
38
|
-
const options = prompt.options.filter((o) => !o.picked);
|
|
39
|
-
if (
|
|
40
|
-
options.length <= LIMITS.buttons &&
|
|
41
|
-
options.every((o) => o.title.length <= LIMITS.buttonTitle)
|
|
42
|
-
) {
|
|
43
|
-
return {
|
|
44
|
-
type: "buttons",
|
|
45
|
-
text: prompt.text,
|
|
46
|
-
buttons: options.map((o) => ({ id: o.id, title: o.title })),
|
|
47
|
-
};
|
|
48
|
-
}
|
|
49
|
-
if (options.length <= LIMITS.rows) {
|
|
50
|
-
return {
|
|
51
|
-
type: "list",
|
|
52
|
-
text: prompt.text,
|
|
53
|
-
button: prompt.button,
|
|
54
|
-
rows: options.map((o) => ({ id: o.id, title: o.title })),
|
|
55
|
-
};
|
|
56
|
-
}
|
|
57
|
-
return {
|
|
58
|
-
type: "text",
|
|
59
|
-
text: `${prompt.text}\n\n${prompt.options.map((o, i) => `${i + 1}. ${o.title}`).join("\n")}`,
|
|
60
|
-
};
|
|
61
|
-
}
|
|
62
|
-
case "location":
|
|
63
|
-
return { type: "location_request", text: prompt.text };
|
|
64
|
-
case "link":
|
|
65
|
-
return { type: "link", text: prompt.text, url: prompt.url, label: prompt.label };
|
|
66
|
-
case "media":
|
|
67
|
-
switch (prompt.media) {
|
|
68
|
-
case "image":
|
|
69
|
-
return { type: "image", link: prompt.url, caption: prompt.caption };
|
|
70
|
-
case "document":
|
|
71
|
-
return {
|
|
72
|
-
type: "document",
|
|
73
|
-
link: prompt.url,
|
|
74
|
-
caption: prompt.caption,
|
|
75
|
-
filename: prompt.filename,
|
|
76
|
-
};
|
|
77
|
-
case "video":
|
|
78
|
-
return { type: "video", link: prompt.url, caption: prompt.caption };
|
|
79
|
-
case "audio":
|
|
80
|
-
return { type: "audio", link: prompt.url };
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
export function whatsappSurface(deps: {
|
|
86
|
-
account: () => Promise<Account | null>;
|
|
87
|
-
log: { warn(...args: unknown[]): void };
|
|
88
|
-
}): ThreadSurface {
|
|
89
|
-
const graph = async () => {
|
|
90
|
-
const account = await deps.account();
|
|
91
|
-
if (!account) {
|
|
92
|
-
throw new Error("WhatsApp is not connected.");
|
|
93
|
-
}
|
|
94
|
-
return new Graph(account);
|
|
95
|
-
};
|
|
96
|
-
return {
|
|
97
|
-
runner: RUNNER,
|
|
98
|
-
asks: ASKS,
|
|
99
|
-
async send(thread: Thread, prompt: ThreadPrompt) {
|
|
100
|
-
try {
|
|
101
|
-
await (await graph()).send(thread.id, toOutgoing(prompt));
|
|
102
|
-
} catch (err) {
|
|
103
|
-
if (err instanceof GraphError && err.code === RE_ENGAGEMENT) {
|
|
104
|
-
deps.log.warn(`thread ${thread.id}: older than a day, the message was refused`);
|
|
105
|
-
return;
|
|
106
|
-
}
|
|
107
|
-
throw err;
|
|
108
|
-
}
|
|
109
|
-
},
|
|
110
|
-
media: async (ref) => (await graph()).media(ref.id),
|
|
111
|
-
// A free message only within a day of the person's last one.
|
|
112
|
-
mayDeliver: (thread) => Date.now() - thread.lastInboundAt < DAY,
|
|
113
|
-
async reach(thread, url) {
|
|
114
|
-
const account = await deps.account();
|
|
115
|
-
if (!account?.template) {
|
|
116
|
-
return false;
|
|
117
|
-
}
|
|
118
|
-
await new Graph(account).send(thread.id, {
|
|
119
|
-
type: "template",
|
|
120
|
-
name: account.template,
|
|
121
|
-
lang: thread.state.lang,
|
|
122
|
-
params: [url],
|
|
123
|
-
});
|
|
124
|
-
return true;
|
|
125
|
-
},
|
|
126
|
-
};
|
|
127
|
-
}
|