@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.
@@ -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
+ }
@@ -0,0 +1,7 @@
1
+ import { WhatsappHandlerFile } from "./types";
2
+
3
+ /**
4
+ * Populated at compile time by the Flink compiler extension.
5
+ * Do not modify manually.
6
+ */
7
+ export const autoRegisteredWhatsappHandlers: WhatsappHandlerFile[] = [];
@@ -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
+ });
@@ -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
+ }