@flink-app/whatsapp-plugin 2.0.0-alpha.74
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/CHANGELOG.md +10 -0
- package/LICENSE +21 -0
- package/README.md +80 -0
- package/bin/flink-whatsapp.js +22 -0
- package/dist/WhatsappConnectionManager.d.ts +17 -0
- package/dist/WhatsappConnectionManager.js +60 -0
- package/dist/WhatsappTransport.d.ts +18 -0
- package/dist/WhatsappTransport.js +241 -0
- package/dist/autoRegisteredWhatsappHandlers.d.ts +6 -0
- package/dist/autoRegisteredWhatsappHandlers.js +8 -0
- package/dist/cli/send.d.ts +21 -0
- package/dist/cli/send.js +90 -0
- package/dist/compiler.d.ts +25 -0
- package/dist/compiler.js +27 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +192 -0
- package/dist/types.d.ts +252 -0
- package/dist/types.js +2 -0
- package/dist/whatsappContext.d.ts +4 -0
- package/dist/whatsappContext.js +236 -0
- package/dist/whatsappFormatting.d.ts +13 -0
- package/dist/whatsappFormatting.js +51 -0
- package/dist/whatsappRouter.d.ts +6 -0
- package/dist/whatsappRouter.js +54 -0
- package/package.json +39 -0
- package/src/WhatsappConnectionManager.ts +67 -0
- package/src/WhatsappTransport.ts +297 -0
- package/src/autoRegisteredWhatsappHandlers.ts +7 -0
- package/src/cli/send.ts +102 -0
- package/src/compiler.ts +32 -0
- package/src/index.ts +197 -0
- package/src/types.ts +284 -0
- package/src/whatsappContext.ts +385 -0
- package/src/whatsappFormatting.ts +59 -0
- package/src/whatsappRouter.ts +48 -0
- package/tsconfig.dist.json +4 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import * as crypto from "crypto";
|
|
2
|
+
import { FlinkLogFactory } from "@flink-app/flink";
|
|
3
|
+
import {
|
|
4
|
+
WhatsappConnectionOptions,
|
|
5
|
+
WhatsappMedia,
|
|
6
|
+
WhatsappMessage,
|
|
7
|
+
WhatsappMessageType,
|
|
8
|
+
WhatsappInteractiveReply,
|
|
9
|
+
WhatsappStatusUpdate,
|
|
10
|
+
WhatsappSendResult,
|
|
11
|
+
} from "./types";
|
|
12
|
+
|
|
13
|
+
const log = FlinkLogFactory.createLogger("flink.whatsapp-plugin.transport");
|
|
14
|
+
|
|
15
|
+
const DEFAULT_GRAPH_API_VERSION = "v21.0";
|
|
16
|
+
|
|
17
|
+
function graphUrl(version: string, path: string): string {
|
|
18
|
+
return `https://graph.facebook.com/${version}/${path}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Verify HMAC-SHA256 webhook signature from Meta */
|
|
22
|
+
export function verifySignature(rawBody: string | Buffer, signature: string, appSecret: string): boolean {
|
|
23
|
+
if (!signature || !signature.startsWith("sha256=")) {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
const expected = crypto
|
|
27
|
+
.createHmac("sha256", appSecret)
|
|
28
|
+
.update(rawBody)
|
|
29
|
+
.digest("hex");
|
|
30
|
+
return crypto.timingSafeEqual(
|
|
31
|
+
Buffer.from(signature.replace("sha256=", "")),
|
|
32
|
+
Buffer.from(expected)
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Make an authenticated Graph API call */
|
|
37
|
+
export async function callApi(
|
|
38
|
+
path: string,
|
|
39
|
+
method: string,
|
|
40
|
+
body: Record<string, any> | undefined,
|
|
41
|
+
accessToken: string,
|
|
42
|
+
version: string = DEFAULT_GRAPH_API_VERSION
|
|
43
|
+
): Promise<any> {
|
|
44
|
+
const url = graphUrl(version, path);
|
|
45
|
+
const headers: Record<string, string> = {
|
|
46
|
+
Authorization: `Bearer ${accessToken}`,
|
|
47
|
+
"Content-Type": "application/json",
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const response = await fetch(url, {
|
|
51
|
+
method,
|
|
52
|
+
headers,
|
|
53
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const data: any = await response.json();
|
|
57
|
+
|
|
58
|
+
if (!response.ok) {
|
|
59
|
+
const errMsg = data?.error?.message ?? response.statusText;
|
|
60
|
+
throw new Error(`WhatsApp API error (${response.status}): ${errMsg}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return data;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Send a message via the WhatsApp Cloud API */
|
|
67
|
+
export async function sendMessage(
|
|
68
|
+
phoneNumberId: string,
|
|
69
|
+
accessToken: string,
|
|
70
|
+
payload: Record<string, any>,
|
|
71
|
+
version: string = DEFAULT_GRAPH_API_VERSION
|
|
72
|
+
): Promise<WhatsappSendResult> {
|
|
73
|
+
const data = await callApi(
|
|
74
|
+
`${phoneNumberId}/messages`,
|
|
75
|
+
"POST",
|
|
76
|
+
{ messaging_product: "whatsapp", ...payload },
|
|
77
|
+
accessToken,
|
|
78
|
+
version
|
|
79
|
+
);
|
|
80
|
+
return { messageId: data.messages?.[0]?.id ?? "" };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Upload media to WhatsApp (multipart form data) */
|
|
84
|
+
export async function uploadMedia(
|
|
85
|
+
phoneNumberId: string,
|
|
86
|
+
accessToken: string,
|
|
87
|
+
file: Buffer,
|
|
88
|
+
mimeType: string,
|
|
89
|
+
filename: string,
|
|
90
|
+
version: string = DEFAULT_GRAPH_API_VERSION
|
|
91
|
+
): Promise<string> {
|
|
92
|
+
const url = graphUrl(version, `${phoneNumberId}/media`);
|
|
93
|
+
|
|
94
|
+
const formData = new FormData();
|
|
95
|
+
formData.append("messaging_product", "whatsapp");
|
|
96
|
+
formData.append("file", new Blob([file], { type: mimeType }), filename);
|
|
97
|
+
formData.append("type", mimeType);
|
|
98
|
+
|
|
99
|
+
const response = await fetch(url, {
|
|
100
|
+
method: "POST",
|
|
101
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
102
|
+
body: formData,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const data: any = await response.json();
|
|
106
|
+
|
|
107
|
+
if (!response.ok) {
|
|
108
|
+
const errMsg = data?.error?.message ?? response.statusText;
|
|
109
|
+
throw new Error(`WhatsApp media upload error (${response.status}): ${errMsg}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return data.id;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Download media by ID (two-step: get URL, then fetch binary) */
|
|
116
|
+
export async function downloadMedia(
|
|
117
|
+
mediaId: string,
|
|
118
|
+
accessToken: string,
|
|
119
|
+
version: string = DEFAULT_GRAPH_API_VERSION
|
|
120
|
+
): Promise<Buffer> {
|
|
121
|
+
// Step 1: Get the media URL
|
|
122
|
+
const mediaInfo = await callApi(mediaId, "GET", undefined, accessToken, version);
|
|
123
|
+
const mediaUrl = mediaInfo.url;
|
|
124
|
+
|
|
125
|
+
if (!mediaUrl) {
|
|
126
|
+
throw new Error(`No download URL returned for media ${mediaId}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Step 2: Download the binary content
|
|
130
|
+
const response = await fetch(mediaUrl, {
|
|
131
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
if (!response.ok) {
|
|
135
|
+
throw new Error(`Failed to download media ${mediaId}: ${response.status} ${response.statusText}`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return Buffer.from(await response.arrayBuffer());
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Mark a message as read */
|
|
142
|
+
export async function markAsRead(
|
|
143
|
+
phoneNumberId: string,
|
|
144
|
+
accessToken: string,
|
|
145
|
+
messageId: string,
|
|
146
|
+
version: string = DEFAULT_GRAPH_API_VERSION
|
|
147
|
+
): Promise<void> {
|
|
148
|
+
await callApi(
|
|
149
|
+
`${phoneNumberId}/messages`,
|
|
150
|
+
"POST",
|
|
151
|
+
{
|
|
152
|
+
messaging_product: "whatsapp",
|
|
153
|
+
status: "read",
|
|
154
|
+
message_id: messageId,
|
|
155
|
+
},
|
|
156
|
+
accessToken,
|
|
157
|
+
version
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function resolveMessageType(msgType: string): WhatsappMessageType {
|
|
162
|
+
const known: WhatsappMessageType[] = [
|
|
163
|
+
"text", "image", "video", "audio", "document",
|
|
164
|
+
"sticker", "location", "contacts", "interactive",
|
|
165
|
+
"reaction", "order",
|
|
166
|
+
];
|
|
167
|
+
return known.includes(msgType as WhatsappMessageType)
|
|
168
|
+
? (msgType as WhatsappMessageType)
|
|
169
|
+
: "unknown";
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function extractMedia(msg: Record<string, any>, type: string): WhatsappMedia | undefined {
|
|
173
|
+
const mediaTypes = ["image", "video", "audio", "document", "sticker"];
|
|
174
|
+
if (!mediaTypes.includes(type)) return undefined;
|
|
175
|
+
|
|
176
|
+
const mediaData = msg[type];
|
|
177
|
+
if (!mediaData) return undefined;
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
id: mediaData.id,
|
|
181
|
+
mimeType: mediaData.mime_type,
|
|
182
|
+
sha256: mediaData.sha256,
|
|
183
|
+
caption: mediaData.caption,
|
|
184
|
+
filename: mediaData.filename,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function extractInteractive(msg: Record<string, any>): WhatsappInteractiveReply | undefined {
|
|
189
|
+
const interactive = msg.interactive;
|
|
190
|
+
if (!interactive) return undefined;
|
|
191
|
+
|
|
192
|
+
const type = interactive.type === "button_reply" ? "button_reply" : "list_reply";
|
|
193
|
+
|
|
194
|
+
if (type === "button_reply" && interactive.button_reply) {
|
|
195
|
+
return {
|
|
196
|
+
type: "button_reply",
|
|
197
|
+
buttonReplyId: interactive.button_reply.id,
|
|
198
|
+
buttonReplyTitle: interactive.button_reply.title,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (type === "list_reply" && interactive.list_reply) {
|
|
203
|
+
return {
|
|
204
|
+
type: "list_reply",
|
|
205
|
+
listReplyId: interactive.list_reply.id,
|
|
206
|
+
listReplyTitle: interactive.list_reply.title,
|
|
207
|
+
listReplyDescription: interactive.list_reply.description,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Extract normalized messages and status updates from a webhook payload */
|
|
215
|
+
export function normalizeWebhookPayload(
|
|
216
|
+
payload: Record<string, any>,
|
|
217
|
+
connectionId: string,
|
|
218
|
+
phoneNumberId: string
|
|
219
|
+
): { messages: WhatsappMessage[]; statuses: WhatsappStatusUpdate[] } {
|
|
220
|
+
const messages: WhatsappMessage[] = [];
|
|
221
|
+
const statuses: WhatsappStatusUpdate[] = [];
|
|
222
|
+
|
|
223
|
+
const entries = payload?.entry ?? [];
|
|
224
|
+
|
|
225
|
+
for (const entry of entries) {
|
|
226
|
+
const changes = entry?.changes ?? [];
|
|
227
|
+
|
|
228
|
+
for (const change of changes) {
|
|
229
|
+
if (change?.field !== "messages") continue;
|
|
230
|
+
|
|
231
|
+
const value = change?.value;
|
|
232
|
+
if (!value) continue;
|
|
233
|
+
|
|
234
|
+
const contacts = value.contacts ?? [];
|
|
235
|
+
const contactMap = new Map<string, string>();
|
|
236
|
+
for (const contact of contacts) {
|
|
237
|
+
contactMap.set(contact.wa_id, contact.profile?.name ?? "");
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Process messages
|
|
241
|
+
for (const msg of value.messages ?? []) {
|
|
242
|
+
const type = resolveMessageType(msg.type);
|
|
243
|
+
const from = msg.from ?? "";
|
|
244
|
+
|
|
245
|
+
const normalized: WhatsappMessage = {
|
|
246
|
+
connectionId,
|
|
247
|
+
messageId: msg.id ?? "",
|
|
248
|
+
from,
|
|
249
|
+
to: phoneNumberId,
|
|
250
|
+
senderName: contactMap.get(from) ?? "",
|
|
251
|
+
timestamp: Number(msg.timestamp) || 0,
|
|
252
|
+
type,
|
|
253
|
+
text: msg.text?.body,
|
|
254
|
+
media: extractMedia(msg, msg.type),
|
|
255
|
+
location: msg.location
|
|
256
|
+
? {
|
|
257
|
+
latitude: msg.location.latitude,
|
|
258
|
+
longitude: msg.location.longitude,
|
|
259
|
+
name: msg.location.name,
|
|
260
|
+
address: msg.location.address,
|
|
261
|
+
}
|
|
262
|
+
: undefined,
|
|
263
|
+
contacts: msg.contacts,
|
|
264
|
+
interactive: extractInteractive(msg),
|
|
265
|
+
reaction: msg.reaction
|
|
266
|
+
? {
|
|
267
|
+
messageId: msg.reaction.message_id,
|
|
268
|
+
emoji: msg.reaction.emoji ?? "",
|
|
269
|
+
}
|
|
270
|
+
: undefined,
|
|
271
|
+
raw: msg,
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
messages.push(normalized);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Process statuses
|
|
278
|
+
for (const status of value.statuses ?? []) {
|
|
279
|
+
statuses.push({
|
|
280
|
+
connectionId,
|
|
281
|
+
messageId: status.id ?? "",
|
|
282
|
+
recipientId: status.recipient_id ?? "",
|
|
283
|
+
status: status.status ?? "failed",
|
|
284
|
+
timestamp: Number(status.timestamp) || 0,
|
|
285
|
+
errors: status.errors?.map((e: any) => ({
|
|
286
|
+
code: e.code,
|
|
287
|
+
title: e.title,
|
|
288
|
+
message: e.message,
|
|
289
|
+
href: e.href,
|
|
290
|
+
})),
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return { messages, statuses };
|
|
297
|
+
}
|
package/src/cli/send.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* flink-whatsapp send — send a test message to a WhatsApp number.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* flink-whatsapp send --token <access-token> --phone-id <phone-number-id> --to <number> "Hello world"
|
|
7
|
+
*
|
|
8
|
+
* Options:
|
|
9
|
+
* --token <token> Access token (or set WHATSAPP_ACCESS_TOKEN env var)
|
|
10
|
+
* --phone-id <id> Phone number ID (or set WHATSAPP_PHONE_NUMBER_ID env var)
|
|
11
|
+
* --to <number> Recipient phone number (with country code, e.g. 14155551234)
|
|
12
|
+
* --api-version <ver> Graph API version (default: v21.0)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
function parseArgs(argv: string[]): {
|
|
16
|
+
token: string;
|
|
17
|
+
phoneNumberId: string;
|
|
18
|
+
to: string;
|
|
19
|
+
text: string;
|
|
20
|
+
apiVersion: string;
|
|
21
|
+
} {
|
|
22
|
+
const args = argv.slice(2);
|
|
23
|
+
let token = process.env.WHATSAPP_ACCESS_TOKEN ?? "";
|
|
24
|
+
let phoneNumberId = process.env.WHATSAPP_PHONE_NUMBER_ID ?? "";
|
|
25
|
+
let to = "";
|
|
26
|
+
let apiVersion = "v21.0";
|
|
27
|
+
const textParts: string[] = [];
|
|
28
|
+
|
|
29
|
+
for (let i = 0; i < args.length; i++) {
|
|
30
|
+
const a = args[i];
|
|
31
|
+
if (a === "--token") {
|
|
32
|
+
token = args[++i];
|
|
33
|
+
} else if (a === "--phone-id") {
|
|
34
|
+
phoneNumberId = args[++i];
|
|
35
|
+
} else if (a === "--to") {
|
|
36
|
+
to = args[++i];
|
|
37
|
+
} else if (a === "--api-version") {
|
|
38
|
+
apiVersion = args[++i];
|
|
39
|
+
} else if (!a.startsWith("--")) {
|
|
40
|
+
textParts.push(a);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const text = textParts.join(" ");
|
|
45
|
+
|
|
46
|
+
if (!token) {
|
|
47
|
+
console.error("Error: --token is required (or set WHATSAPP_ACCESS_TOKEN env var)");
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
if (!phoneNumberId) {
|
|
51
|
+
console.error("Error: --phone-id is required (or set WHATSAPP_PHONE_NUMBER_ID env var)");
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
if (!to) {
|
|
55
|
+
console.error("Error: --to is required");
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
if (!text) {
|
|
59
|
+
console.error("Error: message text is required");
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { token, phoneNumberId, to, text, apiVersion };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function main(): Promise<void> {
|
|
67
|
+
const { token, phoneNumberId, to, text, apiVersion } = parseArgs(process.argv);
|
|
68
|
+
|
|
69
|
+
const url = `https://graph.facebook.com/${apiVersion}/${phoneNumberId}/messages`;
|
|
70
|
+
|
|
71
|
+
console.log(`Sending to ${to}…`);
|
|
72
|
+
|
|
73
|
+
const response = await fetch(url, {
|
|
74
|
+
method: "POST",
|
|
75
|
+
headers: {
|
|
76
|
+
Authorization: `Bearer ${token}`,
|
|
77
|
+
"Content-Type": "application/json",
|
|
78
|
+
},
|
|
79
|
+
body: JSON.stringify({
|
|
80
|
+
messaging_product: "whatsapp",
|
|
81
|
+
to,
|
|
82
|
+
type: "text",
|
|
83
|
+
text: { body: text },
|
|
84
|
+
}),
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const data: any = await response.json();
|
|
88
|
+
|
|
89
|
+
if (!response.ok) {
|
|
90
|
+
const errMsg = data?.error?.message ?? response.statusText;
|
|
91
|
+
throw new Error(`WhatsApp API error (${response.status}): ${errMsg}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const messageId = data.messages?.[0]?.id ?? "unknown";
|
|
95
|
+
console.log(`Message sent (id: ${messageId})`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
main().catch((err: unknown) => {
|
|
99
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
100
|
+
console.error("Error:", msg || "unknown error");
|
|
101
|
+
process.exit(1);
|
|
102
|
+
});
|
package/src/compiler.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build-time compiler plugin descriptor for @flink-app/whatsapp-plugin.
|
|
3
|
+
*
|
|
4
|
+
* Import this from flink.config.js — it MUST NOT import from @flink-app/flink
|
|
5
|
+
* to avoid circular build-time dependencies.
|
|
6
|
+
*
|
|
7
|
+
* Usage in flink.config.js:
|
|
8
|
+
* ```js
|
|
9
|
+
* const { compilerPlugin } = require("@flink-app/whatsapp-plugin/compiler");
|
|
10
|
+
* module.exports = {
|
|
11
|
+
* compilerPlugins: [compilerPlugin()],
|
|
12
|
+
* };
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
interface FlinkCompilerPlugin {
|
|
17
|
+
package: string;
|
|
18
|
+
scanDir: string;
|
|
19
|
+
generatedFile: string;
|
|
20
|
+
registrationVar: string;
|
|
21
|
+
detectBy?: (fileContent: string, filePath: string) => boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function compilerPlugin(opts?: { scanDir?: string }): FlinkCompilerPlugin {
|
|
25
|
+
return {
|
|
26
|
+
package: "@flink-app/whatsapp-plugin",
|
|
27
|
+
scanDir: opts?.scanDir ?? "src/whatsapp-handlers",
|
|
28
|
+
generatedFile: "generatedWhatsappHandlers",
|
|
29
|
+
registrationVar: "autoRegisteredWhatsappHandlers",
|
|
30
|
+
detectBy: (fileContent) => fileContent.includes("WhatsappHandler"),
|
|
31
|
+
};
|
|
32
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import * as crypto from "crypto";
|
|
2
|
+
import { requestContext, FlinkLogFactory } from "@flink-app/flink";
|
|
3
|
+
import { autoRegisteredWhatsappHandlers } from "./autoRegisteredWhatsappHandlers";
|
|
4
|
+
import { WhatsappConnectionManager } from "./WhatsappConnectionManager";
|
|
5
|
+
import { createWhatsappContext, createScopedWhatsappContext } from "./whatsappContext";
|
|
6
|
+
import { matchesRoute } from "./whatsappRouter";
|
|
7
|
+
import { verifySignature, normalizeWebhookPayload } from "./WhatsappTransport";
|
|
8
|
+
import { WhatsappConnectionOptions, WhatsappMessage, WhatsappPluginOptions } from "./types";
|
|
9
|
+
|
|
10
|
+
const log = FlinkLogFactory.createLogger("flink.whatsapp-plugin");
|
|
11
|
+
|
|
12
|
+
export * from "./types";
|
|
13
|
+
export * from "./autoRegisteredWhatsappHandlers";
|
|
14
|
+
export * from "./whatsappRouter";
|
|
15
|
+
export { toWhatsappFormat } from "./whatsappFormatting";
|
|
16
|
+
|
|
17
|
+
export function whatsappPlugin<TCtx>(options: WhatsappPluginOptions<TCtx>) {
|
|
18
|
+
const manager = new WhatsappConnectionManager();
|
|
19
|
+
let appCtx: TCtx;
|
|
20
|
+
|
|
21
|
+
const webhookPath = options.webhookPath ?? "/webhooks/whatsapp";
|
|
22
|
+
|
|
23
|
+
const handleIncomingMessage = async (message: WhatsappMessage) => {
|
|
24
|
+
const user = options.resolveUser
|
|
25
|
+
? await options.resolveUser(message, appCtx)
|
|
26
|
+
: undefined;
|
|
27
|
+
|
|
28
|
+
const permissions =
|
|
29
|
+
user && options.resolvePermissions
|
|
30
|
+
? await options.resolvePermissions(user, appCtx)
|
|
31
|
+
: [];
|
|
32
|
+
|
|
33
|
+
const scopedWhatsapp = createScopedWhatsappContext(manager, message.connectionId);
|
|
34
|
+
|
|
35
|
+
let handled = false;
|
|
36
|
+
|
|
37
|
+
for (const h of autoRegisteredWhatsappHandlers) {
|
|
38
|
+
if (!matchesRoute(message, h.Route ?? {})) continue;
|
|
39
|
+
|
|
40
|
+
handled = true;
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
await requestContext.run(
|
|
44
|
+
{
|
|
45
|
+
reqId: crypto.randomUUID(),
|
|
46
|
+
user,
|
|
47
|
+
userPermissions: permissions,
|
|
48
|
+
timestamp: Date.now(),
|
|
49
|
+
},
|
|
50
|
+
() =>
|
|
51
|
+
h.default({
|
|
52
|
+
ctx: appCtx,
|
|
53
|
+
message,
|
|
54
|
+
user,
|
|
55
|
+
permissions,
|
|
56
|
+
whatsapp: scopedWhatsapp,
|
|
57
|
+
})
|
|
58
|
+
);
|
|
59
|
+
} catch (err) {
|
|
60
|
+
log.error(`WhatsApp handler error for message from "${message.from}"`, err);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!handled && options.onUnhandled) {
|
|
65
|
+
await options.onUnhandled(message, appCtx);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const addConnection = (id: string, connection: WhatsappConnectionOptions) => {
|
|
70
|
+
manager.add(id, connection);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const pluginCtx = createWhatsappContext(manager, addConnection);
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
id: "whatsapp",
|
|
77
|
+
ctx: pluginCtx,
|
|
78
|
+
init: async (app: { ctx: TCtx; expressApp?: any }) => {
|
|
79
|
+
appCtx = app.ctx;
|
|
80
|
+
|
|
81
|
+
// Add connections
|
|
82
|
+
if (options.connection) {
|
|
83
|
+
addConnection("default", options.connection);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (options.connections) {
|
|
87
|
+
for (const [id, conn] of Object.entries(options.connections)) {
|
|
88
|
+
addConnection(id, conn);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (options.loadConnections) {
|
|
93
|
+
const loaded = await options.loadConnections(app.ctx);
|
|
94
|
+
for (const [id, conn] of Object.entries(loaded)) {
|
|
95
|
+
addConnection(id, conn);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!app.expressApp) {
|
|
100
|
+
log.warn("No Express app available — webhook routes not registered");
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// GET webhook — Meta verification challenge
|
|
105
|
+
app.expressApp.get(webhookPath, (req: any, res: any) => {
|
|
106
|
+
const mode = req.query["hub.mode"];
|
|
107
|
+
const token = req.query["hub.verify_token"];
|
|
108
|
+
const challenge = req.query["hub.challenge"];
|
|
109
|
+
|
|
110
|
+
// Find any connection with a matching verify token
|
|
111
|
+
const connectionIds = manager.list();
|
|
112
|
+
const verified = connectionIds.some((id) => {
|
|
113
|
+
const conn = manager.get(id);
|
|
114
|
+
return conn.verifyToken === token;
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
if (mode === "subscribe" && verified) {
|
|
118
|
+
log.info("Webhook verification successful");
|
|
119
|
+
res.status(200).send(challenge);
|
|
120
|
+
} else {
|
|
121
|
+
log.warn("Webhook verification failed");
|
|
122
|
+
res.sendStatus(403);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// POST webhook — receive messages and status updates
|
|
127
|
+
app.expressApp.post(webhookPath, async (req: any, res: any) => {
|
|
128
|
+
// Always respond 200 quickly to avoid Meta retries
|
|
129
|
+
res.sendStatus(200);
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
// Verify signature
|
|
133
|
+
const signature = req.headers["x-hub-signature-256"] as string;
|
|
134
|
+
if (signature) {
|
|
135
|
+
const rawBody = (req as any).rawBody ?? JSON.stringify(req.body);
|
|
136
|
+
const secrets = manager.getAppSecrets();
|
|
137
|
+
const verified = secrets.some((secret) =>
|
|
138
|
+
verifySignature(rawBody, signature, secret)
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
if (!verified) {
|
|
142
|
+
log.warn("Webhook signature verification failed");
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const payload = req.body;
|
|
148
|
+
if (!payload?.entry) return;
|
|
149
|
+
|
|
150
|
+
// Extract phone number ID from the payload to resolve connection
|
|
151
|
+
for (const entry of payload.entry) {
|
|
152
|
+
for (const change of entry?.changes ?? []) {
|
|
153
|
+
if (change?.field !== "messages") continue;
|
|
154
|
+
|
|
155
|
+
const value = change?.value;
|
|
156
|
+
if (!value?.metadata?.phone_number_id) continue;
|
|
157
|
+
|
|
158
|
+
const phoneNumberId = value.metadata.phone_number_id;
|
|
159
|
+
const connectionId = manager.resolveConnectionId(phoneNumberId);
|
|
160
|
+
|
|
161
|
+
if (!connectionId) {
|
|
162
|
+
log.warn(`No connection found for phone number ID: ${phoneNumberId}`);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const { messages, statuses } = normalizeWebhookPayload(
|
|
167
|
+
{ entry: [{ changes: [change] }] },
|
|
168
|
+
connectionId,
|
|
169
|
+
phoneNumberId
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
// Handle messages
|
|
173
|
+
for (const message of messages) {
|
|
174
|
+
handleIncomingMessage(message).catch((err) => {
|
|
175
|
+
log.error("Error handling WhatsApp message", err);
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Handle status updates
|
|
180
|
+
if (options.onStatusUpdate) {
|
|
181
|
+
for (const status of statuses) {
|
|
182
|
+
options.onStatusUpdate(status, appCtx).catch((err: any) => {
|
|
183
|
+
log.error("Error handling WhatsApp status update", err);
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
} catch (err) {
|
|
190
|
+
log.error("Error processing WhatsApp webhook", err);
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
log.info(`WhatsApp webhook registered at ${webhookPath}`);
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
}
|