@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/src/types.ts ADDED
@@ -0,0 +1,284 @@
1
+ /** Connection options for a single WhatsApp Business phone number */
2
+ export interface WhatsappConnectionOptions {
3
+ /** Permanent access token from Meta System User */
4
+ accessToken: string;
5
+ /** The business phone number ID */
6
+ phoneNumberId: string;
7
+ /** Token for webhook verification challenge */
8
+ verifyToken: string;
9
+ /** App secret for HMAC-SHA256 signature verification */
10
+ appSecret: string;
11
+ /** Graph API version (default: "v21.0") */
12
+ graphApiVersion?: string;
13
+ }
14
+
15
+ /** Plugin options */
16
+ export interface WhatsappPluginOptions<TCtx> {
17
+ /** Single connection (stored as "default"). Mutually exclusive with `connections`. */
18
+ connection?: WhatsappConnectionOptions;
19
+ /** Multiple named connections at startup. Mutually exclusive with `connection`. */
20
+ connections?: Record<string, WhatsappConnectionOptions>;
21
+ /** Load connections dynamically at init time (receives app context with repos, plugins, etc.) */
22
+ loadConnections?: (ctx: TCtx) => Promise<Record<string, WhatsappConnectionOptions>>;
23
+ /** Webhook path for receiving messages (default: "/webhooks/whatsapp") */
24
+ webhookPath?: string;
25
+ /** Resolve authenticated user from a WhatsApp message */
26
+ resolveUser?: (message: WhatsappMessage, ctx: TCtx) => Promise<any>;
27
+ /** Resolve permissions for a resolved user */
28
+ resolvePermissions?: (user: any, ctx: TCtx) => Promise<string[]>;
29
+ /** Called when no WhatsappHandler matched the incoming message */
30
+ onUnhandled?: (message: WhatsappMessage, ctx: TCtx) => Promise<void>;
31
+ /** Called when a status update is received (sent, delivered, read, failed) */
32
+ onStatusUpdate?: (status: WhatsappStatusUpdate, ctx: TCtx) => Promise<void>;
33
+ }
34
+
35
+ /** WhatsApp message types */
36
+ export type WhatsappMessageType =
37
+ | "text"
38
+ | "image"
39
+ | "video"
40
+ | "audio"
41
+ | "document"
42
+ | "sticker"
43
+ | "location"
44
+ | "contacts"
45
+ | "interactive"
46
+ | "reaction"
47
+ | "order"
48
+ | "unknown";
49
+
50
+ /** Media info from incoming messages */
51
+ export interface WhatsappMedia {
52
+ /** Media ID (use downloadMedia to fetch content) */
53
+ id: string;
54
+ mimeType: string;
55
+ sha256?: string;
56
+ caption?: string;
57
+ filename?: string;
58
+ }
59
+
60
+ /** Location from incoming messages */
61
+ export interface WhatsappLocation {
62
+ latitude: number;
63
+ longitude: number;
64
+ name?: string;
65
+ address?: string;
66
+ }
67
+
68
+ /** Contact from incoming messages */
69
+ export interface WhatsappContact {
70
+ name: {
71
+ formatted_name: string;
72
+ first_name?: string;
73
+ last_name?: string;
74
+ };
75
+ phones?: Array<{
76
+ phone: string;
77
+ type?: string;
78
+ wa_id?: string;
79
+ }>;
80
+ }
81
+
82
+ /** Interactive reply (button or list selection) from incoming messages */
83
+ export interface WhatsappInteractiveReply {
84
+ type: "button_reply" | "list_reply";
85
+ /** Button reply ID */
86
+ buttonReplyId?: string;
87
+ /** Button reply title */
88
+ buttonReplyTitle?: string;
89
+ /** List reply ID */
90
+ listReplyId?: string;
91
+ /** List reply title */
92
+ listReplyTitle?: string;
93
+ /** List reply description */
94
+ listReplyDescription?: string;
95
+ }
96
+
97
+ /** Reaction from incoming messages */
98
+ export interface WhatsappReaction {
99
+ /** Message ID being reacted to */
100
+ messageId: string;
101
+ /** Emoji used as reaction (empty string means reaction removed) */
102
+ emoji: string;
103
+ }
104
+
105
+ /** Normalized inbound WhatsApp message */
106
+ export interface WhatsappMessage {
107
+ /** Connection that received this message ("default" for single-connection mode) */
108
+ connectionId: string;
109
+ /** WhatsApp message ID (wamid.XX) */
110
+ messageId: string;
111
+ /** Sender phone number (wa_id) */
112
+ from: string;
113
+ /** Recipient phone number ID */
114
+ to: string;
115
+ /** Sender display name from contacts[].profile.name */
116
+ senderName: string;
117
+ /** Unix timestamp */
118
+ timestamp: number;
119
+ /** Message type */
120
+ type: WhatsappMessageType;
121
+ /** Text content (for type "text") */
122
+ text?: string;
123
+ /** Media info (for image/video/audio/document/sticker) */
124
+ media?: WhatsappMedia;
125
+ /** Location (for type "location") */
126
+ location?: WhatsappLocation;
127
+ /** Contacts (for type "contacts") */
128
+ contacts?: WhatsappContact[];
129
+ /** Interactive reply (for button/list replies) */
130
+ interactive?: WhatsappInteractiveReply;
131
+ /** Reaction (for type "reaction") */
132
+ reaction?: WhatsappReaction;
133
+ /** Raw webhook payload */
134
+ raw: Record<string, any>;
135
+ }
136
+
137
+ /** Route matching criteria for a WhatsappHandler */
138
+ export interface WhatsappRouteProps {
139
+ /** Match by message type */
140
+ type?: WhatsappMessageType | WhatsappMessageType[];
141
+ /** Match by sender phone number */
142
+ from?: string | RegExp | ((msg: WhatsappMessage) => boolean);
143
+ /** Match message text */
144
+ pattern?: string | RegExp | ((msg: WhatsappMessage) => boolean);
145
+ /** Only match messages from specific connection(s). Omit to match all connections. */
146
+ connectionId?: string | string[];
147
+ }
148
+
149
+ /** Handler function type */
150
+ export type WhatsappHandler<TCtx> = (args: {
151
+ ctx: TCtx;
152
+ message: WhatsappMessage;
153
+ user?: any;
154
+ permissions?: string[];
155
+ whatsapp: ScopedWhatsappContext;
156
+ }) => Promise<void>;
157
+
158
+ /** Handler file shape (populated by compiler) */
159
+ export interface WhatsappHandlerFile<TCtx = any> {
160
+ Route?: WhatsappRouteProps;
161
+ default: WhatsappHandler<TCtx>;
162
+ __file?: string;
163
+ }
164
+
165
+ /** Send options for the raw send method */
166
+ export interface WhatsappSendOptions {
167
+ /** Recipient phone number */
168
+ to: string;
169
+ /** Message type */
170
+ type: string;
171
+ /** Message payload (type-specific) */
172
+ [key: string]: any;
173
+ }
174
+
175
+ /** Result from sending a message */
176
+ export interface WhatsappSendResult {
177
+ messageId: string;
178
+ }
179
+
180
+ /** Media payload for sending images/documents/etc */
181
+ export interface WhatsappMediaPayload {
182
+ /** Media ID (from uploadMedia) */
183
+ id?: string;
184
+ /** Public URL to the media */
185
+ link?: string;
186
+ }
187
+
188
+ /** Interactive button */
189
+ export interface WhatsappButton {
190
+ id: string;
191
+ title: string;
192
+ }
193
+
194
+ /** List section for interactive list messages */
195
+ export interface WhatsappListSection {
196
+ title: string;
197
+ rows: Array<{
198
+ id: string;
199
+ title: string;
200
+ description?: string;
201
+ }>;
202
+ }
203
+
204
+ /** Template for sending template messages */
205
+ export interface WhatsappTemplate {
206
+ name: string;
207
+ language: { code: string };
208
+ components?: Array<Record<string, any>>;
209
+ }
210
+
211
+ /** Status update from webhook */
212
+ export interface WhatsappStatusUpdate {
213
+ connectionId: string;
214
+ messageId: string;
215
+ recipientId: string;
216
+ status: "sent" | "delivered" | "read" | "failed";
217
+ timestamp: number;
218
+ errors?: WhatsappError[];
219
+ }
220
+
221
+ /** WhatsApp API error */
222
+ export interface WhatsappError {
223
+ code: number;
224
+ title: string;
225
+ message?: string;
226
+ href?: string;
227
+ }
228
+
229
+ /** Scoped context bound to a specific connection — no management methods */
230
+ export interface ScopedWhatsappContext {
231
+ /** Send a raw message payload */
232
+ send(opts: WhatsappSendOptions): Promise<WhatsappSendResult>;
233
+ /** Send a text message */
234
+ sendText(to: string, text: string): Promise<WhatsappSendResult>;
235
+ /** Send a template message (required outside 24h window) */
236
+ sendTemplate(to: string, template: WhatsappTemplate): Promise<WhatsappSendResult>;
237
+ /** Send an image */
238
+ sendImage(to: string, image: WhatsappMediaPayload, caption?: string): Promise<WhatsappSendResult>;
239
+ /** Send a document */
240
+ sendDocument(to: string, doc: WhatsappMediaPayload, caption?: string): Promise<WhatsappSendResult>;
241
+ /** Send interactive buttons (max 3) */
242
+ sendInteractiveButtons(to: string, body: string, buttons: WhatsappButton[]): Promise<WhatsappSendResult>;
243
+ /** Send interactive list */
244
+ sendInteractiveList(to: string, body: string, buttonText: string, sections: WhatsappListSection[]): Promise<WhatsappSendResult>;
245
+ /** Send a location */
246
+ sendLocation(to: string, location: WhatsappLocation): Promise<WhatsappSendResult>;
247
+ /** Send a reaction emoji to a message */
248
+ sendReaction(to: string, messageId: string, emoji: string): Promise<WhatsappSendResult>;
249
+ /** Reply to a message with text */
250
+ reply(message: WhatsappMessage, text: string): Promise<WhatsappSendResult>;
251
+ /** Mark a message as read */
252
+ markAsRead(messageId: string): Promise<void>;
253
+ /** Upload media and return the media ID */
254
+ uploadMedia(file: Buffer, mimeType: string, filename: string): Promise<string>;
255
+ /** Download media by ID */
256
+ downloadMedia(mediaId: string): Promise<Buffer>;
257
+ }
258
+
259
+ /** Full WhatsApp API — exposed at runtime as ctx.plugins.whatsapp */
260
+ export interface WhatsappApi extends ScopedWhatsappContext {
261
+ /** Add a new connection at runtime */
262
+ addConnection(id: string, connection: WhatsappConnectionOptions): void;
263
+ /** Remove a connection */
264
+ removeConnection(id: string): void;
265
+ /** Get a scoped context bound to a specific connection */
266
+ withConnection(id: string): ScopedWhatsappContext;
267
+ /** List active connection IDs */
268
+ listConnections(): string[];
269
+ }
270
+
271
+ /**
272
+ * Plugin context type — extend your app Ctx with this to get typed access to ctx.plugins.whatsapp.
273
+ *
274
+ * @example
275
+ * import { FlinkContext } from "@flink-app/flink";
276
+ * import { WhatsappPluginCtx } from "@flink-app/whatsapp-plugin";
277
+ *
278
+ * export interface Ctx extends FlinkContext<WhatsappPluginCtx> {
279
+ * repos: { ... };
280
+ * }
281
+ */
282
+ export interface WhatsappPluginCtx {
283
+ whatsapp: WhatsappApi;
284
+ }
@@ -0,0 +1,385 @@
1
+ import { WhatsappConnectionManager } from "./WhatsappConnectionManager";
2
+ import * as transport from "./WhatsappTransport";
3
+ import {
4
+ WhatsappApi,
5
+ WhatsappButton,
6
+ WhatsappConnectionOptions,
7
+ WhatsappListSection,
8
+ WhatsappLocation,
9
+ WhatsappMediaPayload,
10
+ WhatsappMessage,
11
+ WhatsappSendOptions,
12
+ WhatsappSendResult,
13
+ WhatsappTemplate,
14
+ ScopedWhatsappContext,
15
+ } from "./types";
16
+
17
+ const DEFAULT_VERSION = "v21.0";
18
+
19
+ function getVersion(conn: WhatsappConnectionOptions): string {
20
+ return conn.graphApiVersion ?? DEFAULT_VERSION;
21
+ }
22
+
23
+ function createScopedContext(getConn: () => WhatsappConnectionOptions): ScopedWhatsappContext {
24
+ return {
25
+ async send(opts: WhatsappSendOptions): Promise<WhatsappSendResult> {
26
+ const conn = getConn();
27
+ const { to, ...rest } = opts;
28
+ return transport.sendMessage(
29
+ conn.phoneNumberId,
30
+ conn.accessToken,
31
+ { to, ...rest },
32
+ getVersion(conn)
33
+ );
34
+ },
35
+
36
+ async sendText(to: string, text: string): Promise<WhatsappSendResult> {
37
+ const conn = getConn();
38
+ return transport.sendMessage(
39
+ conn.phoneNumberId,
40
+ conn.accessToken,
41
+ { to, type: "text", text: { body: text } },
42
+ getVersion(conn)
43
+ );
44
+ },
45
+
46
+ async sendTemplate(to: string, template: WhatsappTemplate): Promise<WhatsappSendResult> {
47
+ const conn = getConn();
48
+ return transport.sendMessage(
49
+ conn.phoneNumberId,
50
+ conn.accessToken,
51
+ { to, type: "template", template },
52
+ getVersion(conn)
53
+ );
54
+ },
55
+
56
+ async sendImage(to: string, image: WhatsappMediaPayload, caption?: string): Promise<WhatsappSendResult> {
57
+ const conn = getConn();
58
+ return transport.sendMessage(
59
+ conn.phoneNumberId,
60
+ conn.accessToken,
61
+ { to, type: "image", image: { ...image, ...(caption ? { caption } : {}) } },
62
+ getVersion(conn)
63
+ );
64
+ },
65
+
66
+ async sendDocument(to: string, doc: WhatsappMediaPayload, caption?: string): Promise<WhatsappSendResult> {
67
+ const conn = getConn();
68
+ return transport.sendMessage(
69
+ conn.phoneNumberId,
70
+ conn.accessToken,
71
+ { to, type: "document", document: { ...doc, ...(caption ? { caption } : {}) } },
72
+ getVersion(conn)
73
+ );
74
+ },
75
+
76
+ async sendInteractiveButtons(to: string, body: string, buttons: WhatsappButton[]): Promise<WhatsappSendResult> {
77
+ const conn = getConn();
78
+ return transport.sendMessage(
79
+ conn.phoneNumberId,
80
+ conn.accessToken,
81
+ {
82
+ to,
83
+ type: "interactive",
84
+ interactive: {
85
+ type: "button",
86
+ body: { text: body },
87
+ action: {
88
+ buttons: buttons.map((b) => ({
89
+ type: "reply",
90
+ reply: { id: b.id, title: b.title },
91
+ })),
92
+ },
93
+ },
94
+ },
95
+ getVersion(conn)
96
+ );
97
+ },
98
+
99
+ async sendInteractiveList(
100
+ to: string,
101
+ body: string,
102
+ buttonText: string,
103
+ sections: WhatsappListSection[]
104
+ ): Promise<WhatsappSendResult> {
105
+ const conn = getConn();
106
+ return transport.sendMessage(
107
+ conn.phoneNumberId,
108
+ conn.accessToken,
109
+ {
110
+ to,
111
+ type: "interactive",
112
+ interactive: {
113
+ type: "list",
114
+ body: { text: body },
115
+ action: {
116
+ button: buttonText,
117
+ sections,
118
+ },
119
+ },
120
+ },
121
+ getVersion(conn)
122
+ );
123
+ },
124
+
125
+ async sendLocation(to: string, location: WhatsappLocation): Promise<WhatsappSendResult> {
126
+ const conn = getConn();
127
+ return transport.sendMessage(
128
+ conn.phoneNumberId,
129
+ conn.accessToken,
130
+ { to, type: "location", location },
131
+ getVersion(conn)
132
+ );
133
+ },
134
+
135
+ async sendReaction(to: string, messageId: string, emoji: string): Promise<WhatsappSendResult> {
136
+ const conn = getConn();
137
+ return transport.sendMessage(
138
+ conn.phoneNumberId,
139
+ conn.accessToken,
140
+ { to, type: "reaction", reaction: { message_id: messageId, emoji } },
141
+ getVersion(conn)
142
+ );
143
+ },
144
+
145
+ async reply(message: WhatsappMessage, text: string): Promise<WhatsappSendResult> {
146
+ const conn = getConn();
147
+ return transport.sendMessage(
148
+ conn.phoneNumberId,
149
+ conn.accessToken,
150
+ {
151
+ to: message.from,
152
+ type: "text",
153
+ text: { body: text },
154
+ context: { message_id: message.messageId },
155
+ },
156
+ getVersion(conn)
157
+ );
158
+ },
159
+
160
+ async markAsRead(messageId: string): Promise<void> {
161
+ const conn = getConn();
162
+ await transport.markAsRead(
163
+ conn.phoneNumberId,
164
+ conn.accessToken,
165
+ messageId,
166
+ getVersion(conn)
167
+ );
168
+ },
169
+
170
+ async uploadMedia(file: Buffer, mimeType: string, filename: string): Promise<string> {
171
+ const conn = getConn();
172
+ return transport.uploadMedia(
173
+ conn.phoneNumberId,
174
+ conn.accessToken,
175
+ file,
176
+ mimeType,
177
+ filename,
178
+ getVersion(conn)
179
+ );
180
+ },
181
+
182
+ async downloadMedia(mediaId: string): Promise<Buffer> {
183
+ const conn = getConn();
184
+ return transport.downloadMedia(mediaId, conn.accessToken, getVersion(conn));
185
+ },
186
+ };
187
+ }
188
+
189
+ export function createWhatsappContext(
190
+ manager: WhatsappConnectionManager,
191
+ addConnectionFn: (id: string, connection: WhatsappConnectionOptions) => void
192
+ ): WhatsappApi {
193
+ const getConn = (connectionId?: string): WhatsappConnectionOptions => {
194
+ return manager.get(connectionId ?? "default");
195
+ };
196
+
197
+ // Build the full object directly — do NOT spread a scoped context,
198
+ // because spreading eagerly evaluates getters before any connections have been added.
199
+ return {
200
+ async send(opts: WhatsappSendOptions): Promise<WhatsappSendResult> {
201
+ const conn = getConn();
202
+ const { to, ...rest } = opts;
203
+ return transport.sendMessage(
204
+ conn.phoneNumberId,
205
+ conn.accessToken,
206
+ { to, ...rest },
207
+ getVersion(conn)
208
+ );
209
+ },
210
+
211
+ async sendText(to: string, text: string): Promise<WhatsappSendResult> {
212
+ const conn = getConn();
213
+ return transport.sendMessage(
214
+ conn.phoneNumberId,
215
+ conn.accessToken,
216
+ { to, type: "text", text: { body: text } },
217
+ getVersion(conn)
218
+ );
219
+ },
220
+
221
+ async sendTemplate(to: string, template: WhatsappTemplate): Promise<WhatsappSendResult> {
222
+ const conn = getConn();
223
+ return transport.sendMessage(
224
+ conn.phoneNumberId,
225
+ conn.accessToken,
226
+ { to, type: "template", template },
227
+ getVersion(conn)
228
+ );
229
+ },
230
+
231
+ async sendImage(to: string, image: WhatsappMediaPayload, caption?: string): Promise<WhatsappSendResult> {
232
+ const conn = getConn();
233
+ return transport.sendMessage(
234
+ conn.phoneNumberId,
235
+ conn.accessToken,
236
+ { to, type: "image", image: { ...image, ...(caption ? { caption } : {}) } },
237
+ getVersion(conn)
238
+ );
239
+ },
240
+
241
+ async sendDocument(to: string, doc: WhatsappMediaPayload, caption?: string): Promise<WhatsappSendResult> {
242
+ const conn = getConn();
243
+ return transport.sendMessage(
244
+ conn.phoneNumberId,
245
+ conn.accessToken,
246
+ { to, type: "document", document: { ...doc, ...(caption ? { caption } : {}) } },
247
+ getVersion(conn)
248
+ );
249
+ },
250
+
251
+ async sendInteractiveButtons(to: string, body: string, buttons: WhatsappButton[]): Promise<WhatsappSendResult> {
252
+ const conn = getConn();
253
+ return transport.sendMessage(
254
+ conn.phoneNumberId,
255
+ conn.accessToken,
256
+ {
257
+ to,
258
+ type: "interactive",
259
+ interactive: {
260
+ type: "button",
261
+ body: { text: body },
262
+ action: {
263
+ buttons: buttons.map((b) => ({
264
+ type: "reply",
265
+ reply: { id: b.id, title: b.title },
266
+ })),
267
+ },
268
+ },
269
+ },
270
+ getVersion(conn)
271
+ );
272
+ },
273
+
274
+ async sendInteractiveList(
275
+ to: string,
276
+ body: string,
277
+ buttonText: string,
278
+ sections: WhatsappListSection[]
279
+ ): Promise<WhatsappSendResult> {
280
+ const conn = getConn();
281
+ return transport.sendMessage(
282
+ conn.phoneNumberId,
283
+ conn.accessToken,
284
+ {
285
+ to,
286
+ type: "interactive",
287
+ interactive: {
288
+ type: "list",
289
+ body: { text: body },
290
+ action: {
291
+ button: buttonText,
292
+ sections,
293
+ },
294
+ },
295
+ },
296
+ getVersion(conn)
297
+ );
298
+ },
299
+
300
+ async sendLocation(to: string, location: WhatsappLocation): Promise<WhatsappSendResult> {
301
+ const conn = getConn();
302
+ return transport.sendMessage(
303
+ conn.phoneNumberId,
304
+ conn.accessToken,
305
+ { to, type: "location", location },
306
+ getVersion(conn)
307
+ );
308
+ },
309
+
310
+ async sendReaction(to: string, messageId: string, emoji: string): Promise<WhatsappSendResult> {
311
+ const conn = getConn();
312
+ return transport.sendMessage(
313
+ conn.phoneNumberId,
314
+ conn.accessToken,
315
+ { to, type: "reaction", reaction: { message_id: messageId, emoji } },
316
+ getVersion(conn)
317
+ );
318
+ },
319
+
320
+ async reply(message: WhatsappMessage, text: string): Promise<WhatsappSendResult> {
321
+ const conn = getConn();
322
+ return transport.sendMessage(
323
+ conn.phoneNumberId,
324
+ conn.accessToken,
325
+ {
326
+ to: message.from,
327
+ type: "text",
328
+ text: { body: text },
329
+ context: { message_id: message.messageId },
330
+ },
331
+ getVersion(conn)
332
+ );
333
+ },
334
+
335
+ async markAsRead(messageId: string): Promise<void> {
336
+ const conn = getConn();
337
+ await transport.markAsRead(
338
+ conn.phoneNumberId,
339
+ conn.accessToken,
340
+ messageId,
341
+ getVersion(conn)
342
+ );
343
+ },
344
+
345
+ async uploadMedia(file: Buffer, mimeType: string, filename: string): Promise<string> {
346
+ const conn = getConn();
347
+ return transport.uploadMedia(
348
+ conn.phoneNumberId,
349
+ conn.accessToken,
350
+ file,
351
+ mimeType,
352
+ filename,
353
+ getVersion(conn)
354
+ );
355
+ },
356
+
357
+ async downloadMedia(mediaId: string): Promise<Buffer> {
358
+ const conn = getConn();
359
+ return transport.downloadMedia(mediaId, conn.accessToken, getVersion(conn));
360
+ },
361
+
362
+ addConnection(id: string, connection: WhatsappConnectionOptions): void {
363
+ addConnectionFn(id, connection);
364
+ },
365
+
366
+ removeConnection(id: string): void {
367
+ manager.remove(id);
368
+ },
369
+
370
+ withConnection(id: string): ScopedWhatsappContext {
371
+ return createScopedContext(() => manager.get(id));
372
+ },
373
+
374
+ listConnections(): string[] {
375
+ return manager.list();
376
+ },
377
+ };
378
+ }
379
+
380
+ export function createScopedWhatsappContext(
381
+ manager: WhatsappConnectionManager,
382
+ connectionId: string
383
+ ): ScopedWhatsappContext {
384
+ return createScopedContext(() => manager.get(connectionId));
385
+ }