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.
- package/apps/runtime/dist/agents/studio-assistant.js +67 -20
- package/apps/runtime/dist/docs/dev/plugins/reference.md +7 -4
- 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/dev/plugins/server.md +3 -1
- package/apps/runtime/dist/docs/dev/plugins/space.md +19 -1
- package/apps/runtime/dist/docs/dev/plugins/studio.md +43 -6
- package/apps/runtime/dist/docs/dev/plugins/styling.md +8 -1
- package/apps/runtime/dist/docs/user/space.md +4 -4
- package/apps/runtime/dist/docs/user/test-and-share.md +22 -58
- package/apps/runtime/dist/engine/steps.js +3 -2
- package/apps/runtime/dist/engine/template.js +14 -10
- package/apps/runtime/dist/film/project.js +15 -3
- package/apps/runtime/dist/plugins/loader.js +38 -2
- package/apps/runtime/dist/routes/studio.js +28 -1
- package/apps/runtime/dist/services/brand.js +61 -1
- package/apps/runtime/dist/services/projects.js +19 -10
- package/apps/runtime/dist/tools/plugin.js +11 -3
- package/apps/web/dist/assets/AboutDialog-CH1rQdia.js +1 -0
- package/apps/web/dist/assets/{ChangelogDialog-DynvQuuR.js → ChangelogDialog-78MQLHfA.js} +1 -1
- package/apps/web/dist/assets/ChatView-DLJfy69U.js +10 -0
- package/apps/web/dist/assets/EditorPage-BYrZokVk.js +3 -0
- package/apps/web/dist/assets/{FlowPreview-BZLnG7GV.js → FlowPreview-xIWPdOCV.js} +1 -1
- package/apps/web/dist/assets/{Harness-CNh3nbB_.js → Harness-CsDhNLSW.js} +2 -2
- package/apps/web/dist/assets/NewWizardChannels-JB3V6DCL.js +1 -0
- package/apps/web/dist/assets/NewWizardPage-Bkh-hjSe.js +7 -0
- package/apps/web/dist/assets/OnboardingPage-Diw1ILjC.js +1 -0
- package/apps/web/dist/assets/ProjectPage-FgoZDzPI.js +1 -0
- package/apps/web/dist/assets/PublicRunner-ClT6B7t5.js +1 -0
- package/apps/web/dist/assets/{ReadOnly-DTjv2Jzm.js → ReadOnly-DQPHmvS0.js} +1 -1
- package/apps/web/dist/assets/RunDrawer-DAwTEyjM.js +4 -0
- package/apps/web/dist/assets/{RunnerView-BPPmlv9z.js → RunnerView-B_tJroFM.js} +1 -1
- package/apps/web/dist/assets/SettingsPage-BsMN-XQs.js +1 -0
- package/apps/web/dist/assets/SetupPage-C7FuIpSt.js +1 -0
- package/apps/web/dist/assets/SharePage-CbFe83L9.js +1 -0
- package/apps/web/dist/assets/changelog-mYclku73.js +1 -0
- package/apps/web/dist/assets/{circle-alert-CVOH4wAv.js → circle-alert-DCBV7n5k.js} +1 -1
- package/apps/web/dist/assets/{esm-BfXdeWaI.js → esm-BhIeKBJY.js} +1 -1
- package/apps/web/dist/assets/index-CmxVazOf.css +1 -0
- package/apps/web/dist/assets/index-fflYi8P1.js +14 -0
- package/apps/web/dist/assets/{lock-DHBh62LA.js → lock-DlzTfwxP.js} +1 -1
- package/apps/web/dist/assets/{phone-Bt1cyWpY.js → phone-BFD815nc.js} +1 -1
- package/apps/web/dist/assets/{settings-menu-ace1vI3i.js → settings-menu-C5Y8SOhG.js} +1 -1
- package/apps/web/dist/assets/ui-DTuixH4y.js +134 -0
- package/apps/web/dist/assets/{upload-CCEq88l4.js → upload-BEf5-d6X.js} +1 -1
- package/apps/web/dist/index.html +4 -5
- package/apps/web/dist/mcp/flow.html +2 -2
- package/node_modules/@engenty-wizards/plugin-sdk/dist/index.d.ts +36 -2
- package/node_modules/@engenty-wizards/plugin-sdk/dist/studio.d.ts +44 -1
- package/node_modules/@engenty-wizards/shared/dist/feature-flags.d.ts +18 -0
- package/node_modules/@engenty-wizards/shared/dist/feature-flags.js +18 -0
- package/node_modules/@engenty-wizards/shared/dist/projects.d.ts +22 -6
- package/node_modules/@engenty-wizards/shared/dist/projects.js +20 -0
- package/package.json +1 -1
- package/apps/web/dist/assets/AboutDialog-CPQiN7H9.js +0 -1
- package/apps/web/dist/assets/ChatView-CKo_9IZD.js +0 -10
- package/apps/web/dist/assets/EditorPage-CDIJXC4T.js +0 -3
- package/apps/web/dist/assets/NewWizardChannels-C1Ss9UMY.js +0 -1
- package/apps/web/dist/assets/NewWizardPage-2qLAbvDL.js +0 -7
- package/apps/web/dist/assets/OnboardingPage-C1FJwEsT.js +0 -1
- package/apps/web/dist/assets/ProjectPage-CGuLUuOs.js +0 -1
- package/apps/web/dist/assets/PublicRunner-BKw1lOW4.js +0 -1
- package/apps/web/dist/assets/RunDrawer-D1I5xqVf.js +0 -4
- package/apps/web/dist/assets/SettingsPage-zbomQfGy.js +0 -1
- package/apps/web/dist/assets/SetupPage-D3F7M6Pz.js +0 -1
- package/apps/web/dist/assets/SharePage-BY49IJgT.js +0 -1
- package/apps/web/dist/assets/changelog-DYZC2E6q.js +0 -1
- package/apps/web/dist/assets/index-B9xVkr2X.css +0 -1
- package/apps/web/dist/assets/index-BH0PYgov.js +0 -15
- package/apps/web/dist/assets/logo-C4V2_3Ar.js +0 -1
- package/apps/web/dist/assets/ui-D2SqIZeQ.js +0 -133
- 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,271 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Meta's WhatsApp Cloud API, the part this plugin uses: sending a message to a number, marking
|
|
3
|
-
* one read, and fetching a file the person sent. The tenant's own number and token; the limits
|
|
4
|
-
* of the API (how long a button may be, how many rows a list holds) are kept here.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
export const GRAPH = "https://graph.facebook.com/v22.0";
|
|
8
|
-
|
|
9
|
-
export interface Account {
|
|
10
|
-
phoneNumberId: string;
|
|
11
|
-
number: string;
|
|
12
|
-
accessToken: string;
|
|
13
|
-
appSecret: string | null;
|
|
14
|
-
verifyToken: string;
|
|
15
|
-
template: string | null;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/** Three buttons of twenty characters at most; a list holds ten rows of twenty-four. */
|
|
19
|
-
export const LIMITS = {
|
|
20
|
-
buttons: 3,
|
|
21
|
-
buttonTitle: 20,
|
|
22
|
-
rows: 10,
|
|
23
|
-
rowTitle: 24,
|
|
24
|
-
rowDescription: 72,
|
|
25
|
-
listButton: 20,
|
|
26
|
-
body: 1024,
|
|
27
|
-
text: 4096,
|
|
28
|
-
caption: 1024,
|
|
29
|
-
header: 60,
|
|
30
|
-
} as const;
|
|
31
|
-
|
|
32
|
-
export type Outgoing =
|
|
33
|
-
| { type: "text"; text: string; preview?: boolean }
|
|
34
|
-
| { type: "buttons"; text: string; buttons: { id: string; title: string }[]; header?: string }
|
|
35
|
-
| {
|
|
36
|
-
type: "list";
|
|
37
|
-
text: string;
|
|
38
|
-
button: string;
|
|
39
|
-
rows: { id: string; title: string; description?: string }[];
|
|
40
|
-
header?: string;
|
|
41
|
-
}
|
|
42
|
-
| { type: "image"; link: string; caption?: string }
|
|
43
|
-
| { type: "document"; link: string; caption?: string; filename?: string }
|
|
44
|
-
| { type: "video"; link: string; caption?: string }
|
|
45
|
-
| { type: "audio"; link: string }
|
|
46
|
-
| { type: "link"; text: string; url: string; label: string }
|
|
47
|
-
| { type: "location_request"; text: string }
|
|
48
|
-
| { type: "template"; name: string; lang: string; params: string[] };
|
|
49
|
-
|
|
50
|
-
export class GraphError extends Error {
|
|
51
|
-
constructor(
|
|
52
|
-
message: string,
|
|
53
|
-
readonly status: number,
|
|
54
|
-
readonly code: number | null,
|
|
55
|
-
) {
|
|
56
|
-
super(message);
|
|
57
|
-
this.name = "GraphError";
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/** Outside the 24-hour window a free-form message is refused with this code. */
|
|
62
|
-
export const RE_ENGAGEMENT = 131047;
|
|
63
|
-
|
|
64
|
-
/** Cut to a length, with an ellipsis where something was cut. */
|
|
65
|
-
export function clip(text: string, max: number): string {
|
|
66
|
-
const t = text.trim();
|
|
67
|
-
return t.length > max ? `${t.slice(0, max - 1).trimEnd()}…` : t;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function body(to: string, message: Outgoing): Record<string, unknown> {
|
|
71
|
-
const base = { messaging_product: "whatsapp", recipient_type: "individual", to };
|
|
72
|
-
switch (message.type) {
|
|
73
|
-
case "text":
|
|
74
|
-
return {
|
|
75
|
-
...base,
|
|
76
|
-
type: "text",
|
|
77
|
-
text: { body: clip(message.text, LIMITS.text), preview_url: message.preview ?? false },
|
|
78
|
-
};
|
|
79
|
-
case "buttons":
|
|
80
|
-
return {
|
|
81
|
-
...base,
|
|
82
|
-
type: "interactive",
|
|
83
|
-
interactive: {
|
|
84
|
-
type: "button",
|
|
85
|
-
...(message.header
|
|
86
|
-
? { header: { type: "text", text: clip(message.header, LIMITS.header) } }
|
|
87
|
-
: {}),
|
|
88
|
-
body: { text: clip(message.text, LIMITS.body) },
|
|
89
|
-
action: {
|
|
90
|
-
buttons: message.buttons.slice(0, LIMITS.buttons).map((b) => ({
|
|
91
|
-
type: "reply",
|
|
92
|
-
reply: { id: b.id, title: clip(b.title, LIMITS.buttonTitle) },
|
|
93
|
-
})),
|
|
94
|
-
},
|
|
95
|
-
},
|
|
96
|
-
};
|
|
97
|
-
case "list":
|
|
98
|
-
return {
|
|
99
|
-
...base,
|
|
100
|
-
type: "interactive",
|
|
101
|
-
interactive: {
|
|
102
|
-
type: "list",
|
|
103
|
-
...(message.header
|
|
104
|
-
? { header: { type: "text", text: clip(message.header, LIMITS.header) } }
|
|
105
|
-
: {}),
|
|
106
|
-
body: { text: clip(message.text, LIMITS.body) },
|
|
107
|
-
action: {
|
|
108
|
-
button: clip(message.button, LIMITS.listButton),
|
|
109
|
-
sections: [
|
|
110
|
-
{
|
|
111
|
-
rows: message.rows.slice(0, LIMITS.rows).map((r) => ({
|
|
112
|
-
id: r.id,
|
|
113
|
-
title: clip(r.title, LIMITS.rowTitle),
|
|
114
|
-
...(r.description
|
|
115
|
-
? { description: clip(r.description, LIMITS.rowDescription) }
|
|
116
|
-
: {}),
|
|
117
|
-
})),
|
|
118
|
-
},
|
|
119
|
-
],
|
|
120
|
-
},
|
|
121
|
-
},
|
|
122
|
-
};
|
|
123
|
-
case "image":
|
|
124
|
-
return {
|
|
125
|
-
...base,
|
|
126
|
-
type: "image",
|
|
127
|
-
image: {
|
|
128
|
-
link: message.link,
|
|
129
|
-
...(message.caption ? { caption: clip(message.caption, LIMITS.caption) } : {}),
|
|
130
|
-
},
|
|
131
|
-
};
|
|
132
|
-
case "document":
|
|
133
|
-
return {
|
|
134
|
-
...base,
|
|
135
|
-
type: "document",
|
|
136
|
-
document: {
|
|
137
|
-
link: message.link,
|
|
138
|
-
...(message.caption ? { caption: clip(message.caption, LIMITS.caption) } : {}),
|
|
139
|
-
...(message.filename ? { filename: message.filename } : {}),
|
|
140
|
-
},
|
|
141
|
-
};
|
|
142
|
-
case "video":
|
|
143
|
-
return {
|
|
144
|
-
...base,
|
|
145
|
-
type: "video",
|
|
146
|
-
video: {
|
|
147
|
-
link: message.link,
|
|
148
|
-
...(message.caption ? { caption: clip(message.caption, LIMITS.caption) } : {}),
|
|
149
|
-
},
|
|
150
|
-
};
|
|
151
|
-
case "audio":
|
|
152
|
-
return { ...base, type: "audio", audio: { link: message.link } };
|
|
153
|
-
case "link":
|
|
154
|
-
return {
|
|
155
|
-
...base,
|
|
156
|
-
type: "interactive",
|
|
157
|
-
interactive: {
|
|
158
|
-
type: "cta_url",
|
|
159
|
-
body: { text: clip(message.text, LIMITS.body) },
|
|
160
|
-
action: {
|
|
161
|
-
name: "cta_url",
|
|
162
|
-
parameters: { display_text: clip(message.label, LIMITS.buttonTitle), url: message.url },
|
|
163
|
-
},
|
|
164
|
-
},
|
|
165
|
-
};
|
|
166
|
-
case "location_request":
|
|
167
|
-
return {
|
|
168
|
-
...base,
|
|
169
|
-
type: "interactive",
|
|
170
|
-
interactive: {
|
|
171
|
-
type: "location_request_message",
|
|
172
|
-
body: { text: clip(message.text, LIMITS.body) },
|
|
173
|
-
action: { name: "send_location" },
|
|
174
|
-
},
|
|
175
|
-
};
|
|
176
|
-
case "template":
|
|
177
|
-
return {
|
|
178
|
-
...base,
|
|
179
|
-
type: "template",
|
|
180
|
-
template: {
|
|
181
|
-
name: message.name,
|
|
182
|
-
language: { code: message.lang },
|
|
183
|
-
components: message.params.length
|
|
184
|
-
? [
|
|
185
|
-
{
|
|
186
|
-
type: "body",
|
|
187
|
-
parameters: message.params.map((text) => ({ type: "text", text })),
|
|
188
|
-
},
|
|
189
|
-
]
|
|
190
|
-
: [],
|
|
191
|
-
},
|
|
192
|
-
};
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
export class Graph {
|
|
197
|
-
constructor(
|
|
198
|
-
private readonly account: Account,
|
|
199
|
-
private readonly fetchImpl: typeof fetch = (...args) => globalThis.fetch(...args),
|
|
200
|
-
) {}
|
|
201
|
-
|
|
202
|
-
private async call(path: string, init: RequestInit): Promise<Record<string, unknown>> {
|
|
203
|
-
const res = await this.fetchImpl(`${GRAPH}/${path}`, {
|
|
204
|
-
...init,
|
|
205
|
-
headers: {
|
|
206
|
-
authorization: `Bearer ${this.account.accessToken}`,
|
|
207
|
-
"content-type": "application/json",
|
|
208
|
-
...(init.headers ?? {}),
|
|
209
|
-
},
|
|
210
|
-
signal: AbortSignal.timeout(20_000),
|
|
211
|
-
});
|
|
212
|
-
const text = await res.text();
|
|
213
|
-
let parsed: Record<string, unknown> = {};
|
|
214
|
-
try {
|
|
215
|
-
parsed = text ? (JSON.parse(text) as Record<string, unknown>) : {};
|
|
216
|
-
} catch {
|
|
217
|
-
parsed = {};
|
|
218
|
-
}
|
|
219
|
-
if (!res.ok) {
|
|
220
|
-
const error = (parsed.error ?? {}) as { message?: string; code?: number };
|
|
221
|
-
throw new GraphError(
|
|
222
|
-
error.message ?? `WhatsApp answered ${res.status}.`,
|
|
223
|
-
res.status,
|
|
224
|
-
typeof error.code === "number" ? error.code : null,
|
|
225
|
-
);
|
|
226
|
-
}
|
|
227
|
-
return parsed;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
/** Sends one message; gives the message's id. */
|
|
231
|
-
async send(to: string, message: Outgoing): Promise<string | null> {
|
|
232
|
-
const answer = await this.call(`${this.account.phoneNumberId}/messages`, {
|
|
233
|
-
method: "POST",
|
|
234
|
-
body: JSON.stringify(body(to, message)),
|
|
235
|
-
});
|
|
236
|
-
const messages = answer.messages as { id?: string }[] | undefined;
|
|
237
|
-
return messages?.[0]?.id ?? null;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
/** The two ticks turn blue: the person sees their message arrived. */
|
|
241
|
-
async markRead(messageId: string): Promise<void> {
|
|
242
|
-
await this.call(`${this.account.phoneNumberId}/messages`, {
|
|
243
|
-
method: "POST",
|
|
244
|
-
body: JSON.stringify({
|
|
245
|
-
messaging_product: "whatsapp",
|
|
246
|
-
status: "read",
|
|
247
|
-
message_id: messageId,
|
|
248
|
-
}),
|
|
249
|
-
});
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
/** A file the person sent, by the id the webhook named: its bytes and type. */
|
|
253
|
-
async media(id: string): Promise<{ data: Uint8Array; mime: string }> {
|
|
254
|
-
const meta = await this.call(id, { method: "GET" });
|
|
255
|
-
const url = typeof meta.url === "string" ? meta.url : null;
|
|
256
|
-
if (!url) {
|
|
257
|
-
throw new GraphError("The file has no address.", 404, null);
|
|
258
|
-
}
|
|
259
|
-
const res = await this.fetchImpl(url, {
|
|
260
|
-
headers: { authorization: `Bearer ${this.account.accessToken}` },
|
|
261
|
-
signal: AbortSignal.timeout(60_000),
|
|
262
|
-
});
|
|
263
|
-
if (!res.ok) {
|
|
264
|
-
throw new GraphError(`The file could not be fetched (${res.status}).`, res.status, null);
|
|
265
|
-
}
|
|
266
|
-
const mime =
|
|
267
|
-
(typeof meta.mime_type === "string" ? meta.mime_type : res.headers.get("content-type")) ??
|
|
268
|
-
"application/octet-stream";
|
|
269
|
-
return { data: new Uint8Array(await res.arrayBuffer()), mime: mime.split(";")[0].trim() };
|
|
270
|
-
}
|
|
271
|
-
}
|
|
@@ -1,143 +0,0 @@
|
|
|
1
|
-
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* What Meta posts to the webhook, read into one shape per message: text, a tap on a button or
|
|
5
|
-
* a row, a file, a location. Statuses (sent, delivered, read) are left out.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
export interface Inbound {
|
|
9
|
-
id: string;
|
|
10
|
-
/** The person's number, as WhatsApp names it (digits, no plus). */
|
|
11
|
-
from: string;
|
|
12
|
-
name: string | null;
|
|
13
|
-
/** The number it was sent to, by its id. */
|
|
14
|
-
phoneNumberId: string;
|
|
15
|
-
at: number;
|
|
16
|
-
kind: "text" | "reply" | "image" | "document" | "video" | "audio" | "location" | "other";
|
|
17
|
-
text?: string;
|
|
18
|
-
/** The id of the button or the row the person tapped. */
|
|
19
|
-
replyId?: string;
|
|
20
|
-
replyTitle?: string;
|
|
21
|
-
media?: { id: string; mime: string; filename?: string; caption?: string };
|
|
22
|
-
location?: { lat: number; lng: number; name?: string; address?: string };
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
type Rec = Record<string, unknown>;
|
|
26
|
-
const rec = (v: unknown): Rec =>
|
|
27
|
-
v && typeof v === "object" && !Array.isArray(v) ? (v as Rec) : {};
|
|
28
|
-
const str = (v: unknown): string | undefined => (typeof v === "string" && v ? v : undefined);
|
|
29
|
-
|
|
30
|
-
function oneMessage(m: Rec, phoneNumberId: string, name: string | null): Inbound | null {
|
|
31
|
-
const id = str(m.id);
|
|
32
|
-
const from = str(m.from);
|
|
33
|
-
if (!id || !from) {
|
|
34
|
-
return null;
|
|
35
|
-
}
|
|
36
|
-
const at = Number(m.timestamp) * 1000 || Date.now();
|
|
37
|
-
const base = { id, from, name, phoneNumberId, at };
|
|
38
|
-
switch (m.type) {
|
|
39
|
-
case "text":
|
|
40
|
-
return { ...base, kind: "text", text: str(rec(m.text).body) ?? "" };
|
|
41
|
-
case "interactive": {
|
|
42
|
-
const i = rec(m.interactive);
|
|
43
|
-
const reply = rec(i.button_reply ?? i.list_reply);
|
|
44
|
-
return {
|
|
45
|
-
...base,
|
|
46
|
-
kind: "reply",
|
|
47
|
-
replyId: str(reply.id) ?? "",
|
|
48
|
-
replyTitle: str(reply.title),
|
|
49
|
-
text: str(reply.title),
|
|
50
|
-
};
|
|
51
|
-
}
|
|
52
|
-
case "button":
|
|
53
|
-
// A tap on a template's quick-reply button.
|
|
54
|
-
return {
|
|
55
|
-
...base,
|
|
56
|
-
kind: "reply",
|
|
57
|
-
replyId: str(rec(m.button).payload) ?? "",
|
|
58
|
-
text: str(rec(m.button).text),
|
|
59
|
-
};
|
|
60
|
-
case "image":
|
|
61
|
-
case "document":
|
|
62
|
-
case "video":
|
|
63
|
-
case "audio": {
|
|
64
|
-
const file = rec(m[m.type]);
|
|
65
|
-
const mediaId = str(file.id);
|
|
66
|
-
if (!mediaId) {
|
|
67
|
-
return null;
|
|
68
|
-
}
|
|
69
|
-
return {
|
|
70
|
-
...base,
|
|
71
|
-
kind: m.type,
|
|
72
|
-
media: {
|
|
73
|
-
id: mediaId,
|
|
74
|
-
mime: str(file.mime_type) ?? "application/octet-stream",
|
|
75
|
-
filename: str(file.filename),
|
|
76
|
-
caption: str(file.caption),
|
|
77
|
-
},
|
|
78
|
-
text: str(file.caption),
|
|
79
|
-
};
|
|
80
|
-
}
|
|
81
|
-
case "location": {
|
|
82
|
-
const l = rec(m.location);
|
|
83
|
-
const lat = Number(l.latitude);
|
|
84
|
-
const lng = Number(l.longitude);
|
|
85
|
-
if (!(Number.isFinite(lat) && Number.isFinite(lng))) {
|
|
86
|
-
return null;
|
|
87
|
-
}
|
|
88
|
-
return {
|
|
89
|
-
...base,
|
|
90
|
-
kind: "location",
|
|
91
|
-
location: { lat, lng, name: str(l.name), address: str(l.address) },
|
|
92
|
-
};
|
|
93
|
-
}
|
|
94
|
-
default:
|
|
95
|
-
return { ...base, kind: "other" };
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/** Every message in a webhook's body, oldest first. */
|
|
100
|
-
export function parseWebhook(body: unknown): Inbound[] {
|
|
101
|
-
const out: Inbound[] = [];
|
|
102
|
-
const root = rec(body);
|
|
103
|
-
if (root.object !== "whatsapp_business_account") {
|
|
104
|
-
return out;
|
|
105
|
-
}
|
|
106
|
-
for (const entry of Array.isArray(root.entry) ? root.entry : []) {
|
|
107
|
-
for (const change of Array.isArray(rec(entry).changes)
|
|
108
|
-
? (rec(entry).changes as unknown[])
|
|
109
|
-
: []) {
|
|
110
|
-
const value = rec(rec(change).value);
|
|
111
|
-
if (rec(change).field !== "messages") {
|
|
112
|
-
continue;
|
|
113
|
-
}
|
|
114
|
-
const phoneNumberId = str(rec(value.metadata).phone_number_id) ?? "";
|
|
115
|
-
const contacts = Array.isArray(value.contacts) ? (value.contacts as unknown[]) : [];
|
|
116
|
-
const names = new Map(
|
|
117
|
-
contacts.map((c) => [str(rec(c).wa_id) ?? "", str(rec(rec(c).profile).name) ?? null]),
|
|
118
|
-
);
|
|
119
|
-
for (const m of Array.isArray(value.messages) ? (value.messages as unknown[]) : []) {
|
|
120
|
-
const message = rec(m);
|
|
121
|
-
const parsed = oneMessage(
|
|
122
|
-
message,
|
|
123
|
-
phoneNumberId,
|
|
124
|
-
names.get(str(message.from) ?? "") ?? null,
|
|
125
|
-
);
|
|
126
|
-
if (parsed) {
|
|
127
|
-
out.push(parsed);
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
return out.sort((a, b) => a.at - b.at);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/** `X-Hub-Signature-256` is the HMAC of the raw body with the app's secret. */
|
|
136
|
-
export function signatureOk(secret: string, raw: string, header: string | null): boolean {
|
|
137
|
-
if (!header?.startsWith("sha256=")) {
|
|
138
|
-
return false;
|
|
139
|
-
}
|
|
140
|
-
const expected = Buffer.from(createHmac("sha256", secret).update(raw, "utf8").digest("hex"));
|
|
141
|
-
const given = Buffer.from(header.slice("sha256=".length));
|
|
142
|
-
return expected.length === given.length && timingSafeEqual(expected, given);
|
|
143
|
-
}
|