@maroonedsoftware/whatsapp 0.0.1

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/README.md ADDED
@@ -0,0 +1,226 @@
1
+ # @maroonedsoftware/whatsapp
2
+
3
+ Transport-agnostic WhatsApp Cloud API integration for ServerKit. The package gives you:
4
+
5
+ - a DI-friendly `fetch`-based wrapper around Meta's Graph API for sending messages — no SDK dependency, and
6
+ - a single `WhatsAppDispatcher` service that walks a batched webhook body and routes each message and status to typed handlers.
7
+
8
+ The package owns no HTTP routes or middleware — wire `WhatsAppDispatcher` from your own Koa, Express, Fastify, or Lambda handler.
9
+
10
+ ## Installation
11
+
12
+ ```bash
13
+ pnpm add @maroonedsoftware/whatsapp
14
+ ```
15
+
16
+ ## Exports
17
+
18
+ | Symbol | Purpose |
19
+ |-----------------------------------|---------------------------------------------------------------------------------------------------------------|
20
+ | `WhatsAppConfig` | Abstract `@Injectable()` token; carries `accessToken`, `phoneNumberId`, `appSecret`, `verifyToken`, optional `graphApiVersion`. |
21
+ | `WhatsAppClient` | `fetch`-based Graph API wrapper. Methods: `sendMessage`, `sendText`, `sendInteractive`, `markAsRead`, plus a generic `request`. |
22
+ | `WhatsAppDispatcher` | Single-method service: `dispatchWebhook` (walks the batched body). |
23
+ | `WhatsAppMessageHandlerMap` | `Map<messageType, WhatsAppMessageHandler>` — register one handler per message `type` (`text`, `image`, …). |
24
+ | `WhatsAppInteractiveHandlerMap` | `Map<replyId, WhatsAppInteractiveHandler>` — register handlers for interactive button/list replies by id. |
25
+ | `WhatsAppStatusHandlerMap` | `Map<status, WhatsAppStatusHandler>` — register handlers per delivery status (`sent`/`delivered`/`read`/`failed`). |
26
+ | `WhatsAppError` | `ServerkitError` subclass for non-HTTP domain failures (signature mismatch, Graph API call failed, …). |
27
+ | `verifyWhatsAppSignature(input)` | Pure helper that validates Meta's `X-Hub-Signature-256` HMAC. No request/context coupling. |
28
+ | `verifyWhatsAppWebhook(input)` | Pure helper for the subscription verification (`GET`) handshake; returns the challenge to echo. |
29
+ | `WhatsAppSignaturePolicy` | `@maroonedsoftware/policies` form of `verifyWhatsAppSignature` (registered under `WHATSAPP_SIGNATURE_POLICY`). |
30
+ | `interactiveReplyId(message)` | Helper that produces the `WhatsAppInteractiveHandlerMap` key for an interactive/quick-reply message. |
31
+
32
+ ## Configuration
33
+
34
+ The package does not read `AppConfig` itself — services take `WhatsAppConfig` directly via DI:
35
+
36
+ ```ts
37
+ import { WhatsAppConfig } from '@maroonedsoftware/whatsapp';
38
+
39
+ const whatsappConfig = appConfig.getAs<WhatsAppConfig>('whatsapp');
40
+ container.register(WhatsAppConfig, { useValue: whatsappConfig });
41
+ ```
42
+
43
+ ```jsonc
44
+ // config.json
45
+ {
46
+ "whatsapp": {
47
+ "accessToken": "EAAG...", // Graph API token (Bearer)
48
+ "phoneNumberId": "123456789", // sends from this number
49
+ "appSecret": "...", // verifies X-Hub-Signature-256
50
+ "verifyToken": "...", // echoed during the GET handshake
51
+ "graphApiVersion": "v21.0" // optional, defaults to v21.0
52
+ }
53
+ }
54
+ ```
55
+
56
+ | Field | Required | Used by |
57
+ |-------------------|----------|----------------------------------------------------------------------|
58
+ | `accessToken` | yes | `WhatsAppClient` — sent as `Authorization: Bearer`. |
59
+ | `phoneNumberId` | yes | `WhatsAppClient` send endpoint (`/{phoneNumberId}/messages`). |
60
+ | `appSecret` | yes | Webhook signature verification (`X-Hub-Signature-256`). |
61
+ | `verifyToken` | yes | Subscription verification (`GET`) handshake. |
62
+ | `graphApiVersion` | no | Graph API version segment. Defaults to `v21.0`. |
63
+
64
+ ## Sending messages
65
+
66
+ ```ts
67
+ import { WhatsAppClient } from '@maroonedsoftware/whatsapp';
68
+
69
+ const whatsapp = container.get(WhatsAppClient);
70
+
71
+ await whatsapp.sendText('15551234567', 'Your order has shipped :package:');
72
+ await whatsapp.markAsRead(message.id);
73
+
74
+ // Interactive (buttons / list), templates, media — pass the raw payload
75
+ await whatsapp.sendInteractive('15551234567', { type: 'button', body: { text: 'Confirm?' }, action: { buttons: [/* … */] } });
76
+ await whatsapp.sendMessage({ messaging_product: 'whatsapp', to: '15551234567', type: 'template', template: { /* … */ } });
77
+ ```
78
+
79
+ Every method throws `WhatsAppError` (with `{ status, body, url }` on `internalDetails`) on a non-2xx response.
80
+
81
+ ## Receiving webhooks
82
+
83
+ WhatsApp uses two HTTP exchanges on the same path: a one-off `GET` to verify the subscription, and ongoing `POST`s carrying message/status batches.
84
+
85
+ ### Verification handshake (GET)
86
+
87
+ ```ts
88
+ import { WhatsAppConfig, verifyWhatsAppWebhook, WhatsAppError } from '@maroonedsoftware/whatsapp';
89
+
90
+ router.get('/whatsapp/webhook', (ctx) => {
91
+ try {
92
+ ctx.body = verifyWhatsAppWebhook({
93
+ verifyToken: ctx.container.get(WhatsAppConfig).verifyToken,
94
+ mode: ctx.query['hub.mode'],
95
+ token: ctx.query['hub.verify_token'],
96
+ challenge: ctx.query['hub.challenge'],
97
+ });
98
+ } catch (err) {
99
+ if (err instanceof WhatsAppError) { ctx.status = 403; return; }
100
+ throw err;
101
+ }
102
+ });
103
+ ```
104
+
105
+ ### Message delivery (POST)
106
+
107
+ ```ts
108
+ import {
109
+ WhatsAppConfig,
110
+ WhatsAppDispatcher,
111
+ WhatsAppMessageHandlerMap,
112
+ verifyWhatsAppSignature,
113
+ type WhatsAppMessageHandler,
114
+ } from '@maroonedsoftware/whatsapp';
115
+ import rawBody from 'raw-body';
116
+
117
+ class TextHandler implements WhatsAppMessageHandler {
118
+ async handle(message, context) {
119
+ // Ack quickly — WhatsApp retries any non-2xx. Offload slow work via @maroonedsoftware/jobbroker.
120
+ }
121
+ }
122
+
123
+ const messages = new WhatsAppMessageHandlerMap();
124
+ messages.set('text', container.get(TextHandler));
125
+ container.register(WhatsAppMessageHandlerMap, { useValue: messages });
126
+
127
+ router.post('/whatsapp/webhook', async (ctx) => {
128
+ const raw = await rawBody(ctx.req, { encoding: 'utf8' });
129
+ verifyWhatsAppSignature({
130
+ appSecret: ctx.container.get(WhatsAppConfig).appSecret,
131
+ rawBody: raw,
132
+ signature: ctx.get('x-hub-signature-256'),
133
+ });
134
+ await ctx.container.get(WhatsAppDispatcher).dispatchWebhook(JSON.parse(raw));
135
+ ctx.status = 200;
136
+ });
137
+ ```
138
+
139
+ ### Routing
140
+
141
+ A webhook body is a **batch** — `dispatchWebhook` walks every `entry → change → value` and dispatches each message and status:
142
+
143
+ | Inbound | Map | Key |
144
+ |---------------------------------|----------------------------------|--------------------------------------------|
145
+ | message (by type) | `WhatsAppMessageHandlerMap` | `message.type` (`text`, `image`, …) |
146
+ | interactive / quick-reply | `WhatsAppInteractiveHandlerMap` | reply id (`interactiveReplyId(message)`) |
147
+ | delivery status | `WhatsAppStatusHandlerMap` | `status.status` (`delivered`, `read`, …) |
148
+
149
+ For `interactive` and `button` messages the dispatcher tries the interactive map (by reply id) **first**, then falls back to the message-type map. Each handler receives a context with the resolved `phoneNumberId`, `displayPhoneNumber`, `wabaId`, the sender `contact`, and the raw `value`.
150
+
151
+ ## Signature verification
152
+
153
+ `verifyWhatsAppSignature` is a pure function — the caller pulls the header and raw body from whatever transport it's using:
154
+
155
+ ```ts
156
+ import { verifyWhatsAppSignature, WhatsAppError } from '@maroonedsoftware/whatsapp';
157
+
158
+ try {
159
+ verifyWhatsAppSignature({
160
+ appSecret: whatsappConfig.appSecret,
161
+ rawBody, // exactly what Meta sent
162
+ signature: req.headers['x-hub-signature-256'] as string,
163
+ });
164
+ } catch (err) {
165
+ if (err instanceof WhatsAppError) {
166
+ // err.internalDetails.reason is 'missing_signature' | 'invalid_signature'
167
+ throw httpError(401).withCause(err);
168
+ }
169
+ throw err;
170
+ }
171
+ ```
172
+
173
+ Meta signs the raw body with `HMAC-SHA256(appSecret, rawBody)` and sends the hex digest as `X-Hub-Signature-256: sha256=<hex>`, compared here with `crypto.timingSafeEqual`. There is no timestamp in the scheme, so there is no replay window.
174
+
175
+ ### As a policy
176
+
177
+ `WhatsAppSignaturePolicy` is the same rule wrapped as a `@maroonedsoftware/policies` policy. It delegates to `verifyWhatsAppSignature` (one source of truth) but returns a `PolicyResult` instead of throwing.
178
+
179
+ ```ts
180
+ import { WhatsAppSignaturePolicy, WHATSAPP_SIGNATURE_POLICY, WhatsAppConfig } from '@maroonedsoftware/whatsapp';
181
+
182
+ registry.set(WHATSAPP_SIGNATURE_POLICY, WhatsAppSignaturePolicy);
183
+
184
+ const result = await ctx.container.get(PolicyService).check(WHATSAPP_SIGNATURE_POLICY, {
185
+ rawBody: ctx.rawBody,
186
+ getHeader: name => ctx.get(name),
187
+ options: ctx.container.get(WhatsAppConfig),
188
+ });
189
+ if (isPolicyResultDenied(result)) throw httpError(401);
190
+ ```
191
+
192
+ The context (`rawBody` + a case-insensitive `getHeader` + `options`) is structurally compatible with `@maroonedsoftware/koa`'s `SignaturePolicyContext<WhatsAppSignatureOptions>`, so the koa `requireSignature` middleware can drive this policy when registered under the signature policy name — no koa dependency in this package.
193
+
194
+ ## Limitations
195
+
196
+ - v1 targets a single phone number via `WhatsAppConfig`. Media upload/download helpers are out of scope (use `WhatsAppClient.request` against the media endpoints).
197
+
198
+ ## Use with `@maroonedsoftware/comms`
199
+
200
+ The `@maroonedsoftware/whatsapp/comms` subpath adapts this package to the channel-agnostic
201
+ [`@maroonedsoftware/comms`](../comms) router (declared as an **optional peer**), so one handler runs
202
+ on WhatsApp and every other wired channel.
203
+
204
+ ```ts
205
+ import { WhatsAppClient, WhatsAppConfig, verifyWhatsAppSignature } from '@maroonedsoftware/whatsapp';
206
+ import { dispatchWhatsApp, createWhatsAppNotifier } from '@maroonedsoftware/whatsapp/comms';
207
+ import { router } from './router.js'; // a shared ChannelRouter
208
+
209
+ http.post('/whatsapp/webhook', async (ctx) => {
210
+ const raw = await rawBody(ctx.req, { encoding: 'utf8' });
211
+ verifyWhatsAppSignature({ appSecret: ctx.container.get(WhatsAppConfig).appSecret, rawBody: raw, signature: ctx.get('x-hub-signature-256') });
212
+ await dispatchWhatsApp(router, ctx.container.get(WhatsAppClient), JSON.parse(raw));
213
+ ctx.status = 200;
214
+ });
215
+ ```
216
+
217
+ - `dispatchWhatsApp` walks the batch: `/`-prefixed text → `command`, other text → `message`,
218
+ interactive/quick-reply → `action`. Media and delivery statuses are skipped here — handle them on
219
+ the native `WhatsAppMessageHandlerMap` / `WhatsAppStatusHandlerMap`.
220
+ - Replies go back to the sender (`message.from`). Buttons render as an interactive button message
221
+ (≤3) or degrade to a list (>3). `createWhatsAppNotifier(client, router.templates)` sends
222
+ proactively; `reply.sendTemplate` / `reply.sendNative` cover templates and other rich payloads.
223
+
224
+ ## License
225
+
226
+ MIT
@@ -0,0 +1,19 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
3
+
4
+ // src/whatsapp.message.handler.ts
5
+ var interactiveReplyId = /* @__PURE__ */ __name((message) => {
6
+ if (message.type === "interactive") {
7
+ return message.interactive?.button_reply?.id ?? message.interactive?.list_reply?.id;
8
+ }
9
+ if (message.type === "button") {
10
+ return message.button?.payload;
11
+ }
12
+ return void 0;
13
+ }, "interactiveReplyId");
14
+
15
+ export {
16
+ __name,
17
+ interactiveReplyId
18
+ };
19
+ //# sourceMappingURL=chunk-ASMXL2NK.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/whatsapp.message.handler.ts"],"sourcesContent":["/**\n * Top-level webhook body Meta POSTs to the WhatsApp Cloud API webhook. A single\n * delivery can batch multiple entries, each with multiple changes, each value\n * carrying multiple messages and/or statuses.\n *\n * @see https://developers.facebook.com/docs/whatsapp/cloud-api/webhooks/payload-examples\n */\nexport type WhatsAppWebhookBody = {\n /** Always `\"whatsapp_business_account\"` for WhatsApp webhooks. */\n object: string;\n entry?: WhatsAppEntry[];\n [key: string]: unknown;\n};\n\n/** One entry in a webhook body; `id` is the WhatsApp Business Account (WABA) ID. */\nexport type WhatsAppEntry = {\n id: string;\n changes?: WhatsAppChange[];\n [key: string]: unknown;\n};\n\n/** One change within an entry. `field` is typically `\"messages\"`. */\nexport type WhatsAppChange = {\n field: string;\n value: WhatsAppValue;\n [key: string]: unknown;\n};\n\n/** The `value` payload of a change — holds metadata, contacts, messages, and statuses. */\nexport type WhatsAppValue = {\n messaging_product: 'whatsapp';\n metadata: { display_phone_number?: string; phone_number_id: string; [key: string]: unknown };\n contacts?: WhatsAppContact[];\n messages?: WhatsAppMessage[];\n statuses?: WhatsAppStatus[];\n [key: string]: unknown;\n};\n\n/** Sender contact info included alongside inbound messages. */\nexport type WhatsAppContact = {\n wa_id: string;\n profile?: { name?: string };\n [key: string]: unknown;\n};\n\n/**\n * Loose typing for an inbound message; consumers narrow per handler by `type`.\n * Every message has `from`, `id`, `timestamp`, and `type`, plus a type-specific\n * payload (`text`, `image`, `interactive`, `button`, …).\n */\nexport type WhatsAppMessage = {\n from: string;\n id: string;\n timestamp: string;\n /** Message type: `text`, `image`, `audio`, `video`, `document`, `sticker`, `location`, `contacts`, `interactive`, `button`, `reaction`, `order`, `system`, … */\n type: string;\n text?: { body: string };\n interactive?: {\n type: 'button_reply' | 'list_reply' | string;\n button_reply?: { id: string; title?: string };\n list_reply?: { id: string; title?: string; description?: string };\n [key: string]: unknown;\n };\n button?: { payload?: string; text?: string };\n [key: string]: unknown;\n};\n\n/** Outbound-message delivery status (`sent` / `delivered` / `read` / `failed`). */\nexport type WhatsAppStatus = {\n id: string;\n status: string;\n timestamp: string;\n recipient_id: string;\n [key: string]: unknown;\n};\n\n/**\n * Metadata accompanying every dispatched message — the resolved phone number,\n * sending account, and contact, plus the raw `value` for handlers that need\n * fields the typed accessors don't expose.\n */\nexport type WhatsAppMessageContext = {\n /** Phone number ID the message was delivered to (your number). */\n phoneNumberId: string;\n /** Display phone number, if present in metadata. */\n displayPhoneNumber?: string;\n /** WhatsApp Business Account ID (the entry `id`). */\n wabaId: string;\n /** Sender contact, matched from `value.contacts` by `message.from`. */\n contact?: WhatsAppContact;\n /** The enclosing `value` payload, untouched. */\n value: WhatsAppValue;\n};\n\n/** Metadata accompanying every dispatched status. */\nexport type WhatsAppStatusContext = {\n phoneNumberId: string;\n displayPhoneNumber?: string;\n wabaId: string;\n value: WhatsAppValue;\n};\n\n/**\n * Handler for one inbound message type (e.g. `text`, `image`, `location`).\n * Registered in `WhatsAppMessageHandlerMap`.\n *\n * Handlers should ack quickly — WhatsApp retries any webhook that doesn't get a\n * 2xx. For slow work, enqueue a job (`@maroonedsoftware/jobbroker`) and return.\n */\nexport interface WhatsAppMessageHandler {\n handle(message: WhatsAppMessage, context: WhatsAppMessageContext): Promise<void>;\n}\n\n/**\n * Handler for one interactive reply, keyed in `WhatsAppInteractiveHandlerMap` by\n * the developer-defined reply `id` (button/list reply id, or quick-reply button\n * payload) — see {@link interactiveReplyId}.\n */\nexport interface WhatsAppInteractiveHandler {\n handle(message: WhatsAppMessage, context: WhatsAppMessageContext): Promise<void>;\n}\n\n/** Handler for one delivery status, registered in `WhatsAppStatusHandlerMap` by status value. */\nexport interface WhatsAppStatusHandler {\n handle(status: WhatsAppStatus, context: WhatsAppStatusContext): Promise<void>;\n}\n\n/**\n * Extracts the developer-defined identifier from an interactive or quick-reply\n * button message, used by `WhatsAppDispatcher` to look a handler up in\n * `WhatsAppInteractiveHandlerMap`.\n *\n * - `interactive` (button_reply) → `interactive.button_reply.id`\n * - `interactive` (list_reply) → `interactive.list_reply.id`\n * - `button` (template quick-reply) → `button.payload`\n *\n * @returns The reply id, or `undefined` if the message carries no routable id.\n */\nexport const interactiveReplyId = (message: WhatsAppMessage): string | undefined => {\n if (message.type === 'interactive') {\n return message.interactive?.button_reply?.id ?? message.interactive?.list_reply?.id;\n }\n if (message.type === 'button') {\n return message.button?.payload;\n }\n return undefined;\n};\n"],"mappings":";;;;AA0IO,IAAMA,qBAAqB,wBAACC,YAAAA;AACjC,MAAIA,QAAQC,SAAS,eAAe;AAClC,WAAOD,QAAQE,aAAaC,cAAcC,MAAMJ,QAAQE,aAAaG,YAAYD;EACnF;AACA,MAAIJ,QAAQC,SAAS,UAAU;AAC7B,WAAOD,QAAQM,QAAQC;EACzB;AACA,SAAOC;AACT,GARkC;","names":["interactiveReplyId","message","type","interactive","button_reply","id","list_reply","button","payload","undefined"]}
@@ -0,0 +1,44 @@
1
+ import { Logger } from '@maroonedsoftware/logger';
2
+ import { WhatsAppConfig } from '../whatsapp.config.js';
3
+ /** Base host for the Meta Graph API. */
4
+ export declare const WHATSAPP_GRAPH_API_HOST = "https://graph.facebook.com";
5
+ /** HTTP methods used by {@link WhatsAppClient.request}. */
6
+ type WhatsAppHttpMethod = 'GET' | 'POST' | 'DELETE';
7
+ /**
8
+ * Thin DI-friendly wrapper around the WhatsApp Cloud API built on `fetch` (no
9
+ * SDK). Constructed once per request scope (or as a singleton, depending on how
10
+ * the consumer registers it) and exposes typed helpers for the most common
11
+ * messaging calls, plus a generic {@link request} escape hatch.
12
+ *
13
+ * @example
14
+ * ```ts
15
+ * await container.get(WhatsAppClient).sendText('15551234567', 'hello');
16
+ * await container.get(WhatsAppClient).markAsRead('wamid.abc');
17
+ * ```
18
+ */
19
+ export declare class WhatsAppClient {
20
+ private readonly config;
21
+ private readonly logger;
22
+ private readonly version;
23
+ constructor(config: WhatsAppConfig, logger: Logger);
24
+ /** Sends a message via `POST /{phoneNumberId}/messages`. The body is forwarded verbatim. */
25
+ sendMessage(body: Record<string, unknown>): Promise<unknown>;
26
+ /** Convenience helper for a plain text message. */
27
+ sendText(to: string, body: string, options?: {
28
+ previewUrl?: boolean;
29
+ }): Promise<unknown>;
30
+ /** Convenience helper for an interactive message (buttons / list). */
31
+ sendInteractive(to: string, interactive: Record<string, unknown>): Promise<unknown>;
32
+ /** Marks an inbound message as read via the messages endpoint. */
33
+ markAsRead(messageId: string): Promise<unknown>;
34
+ /**
35
+ * Low-level request helper. Prefixes the Graph API host + version, sets JSON
36
+ * headers, adds the `Authorization: Bearer <accessToken>` header, and throws
37
+ * {@link WhatsAppError} on a non-2xx response.
38
+ *
39
+ * Returns the parsed JSON body, or `undefined` for empty responses.
40
+ */
41
+ request(method: WhatsAppHttpMethod, path: string, body?: unknown): Promise<unknown>;
42
+ }
43
+ export {};
44
+ //# sourceMappingURL=whatsapp.client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"whatsapp.client.d.ts","sourceRoot":"","sources":["../../src/client/whatsapp.client.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,MAAM,EAAE,MAAM,0BAA0B,CAAC;AAClD,OAAO,EAAE,cAAc,EAAsC,MAAM,uBAAuB,CAAC;AAG3F,wCAAwC;AACxC,eAAO,MAAM,uBAAuB,+BAA+B,CAAC;AAEpE,2DAA2D;AAC3D,KAAK,kBAAkB,GAAG,KAAK,GAAG,MAAM,GAAG,QAAQ,CAAC;AAEpD;;;;;;;;;;;GAWG;AACH,qBACa,cAAc;IAIvB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,MAAM;IAJzB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;gBAGd,MAAM,EAAE,cAAc,EACtB,MAAM,EAAE,MAAM;IAKjC,4FAA4F;IAC5F,WAAW,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,CAAC,OAAO,CAAC;IAI5D,mDAAmD;IACnD,QAAQ,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE;QAAE,UAAU,CAAC,EAAE,OAAO,CAAA;KAAO,GAAG,OAAO,CAAC,OAAO,CAAC;IAU5F,sEAAsE;IACtE,eAAe,CAAC,EAAE,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,CAAC,OAAO,CAAC;IAInF,kEAAkE;IAClE,UAAU,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAI/C;;;;;;OAMG;IACG,OAAO,CAAC,MAAM,EAAE,kBAAkB,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC;CA0B1F"}
@@ -0,0 +1,23 @@
1
+ /**
2
+ * `@maroonedsoftware/whatsapp/comms` — adapter binding the WhatsApp package to
3
+ * the channel-agnostic `@maroonedsoftware/comms` router. Importing this subpath
4
+ * pulls in `@maroonedsoftware/comms` (an optional peer); the whatsapp core does not.
5
+ */
6
+ import { type ChannelRouter, type Notifier, type TemplateRegistry } from '@maroonedsoftware/comms';
7
+ import { WhatsAppClient } from './client/whatsapp.client.js';
8
+ import { type WhatsAppWebhookBody } from './whatsapp.message.handler.js';
9
+ /**
10
+ * Builds a {@link Notifier} that sends portable messages and registered templates
11
+ * through {@link WhatsAppClient}. The recipient is a `wa_id` (phone). Native
12
+ * template/`sendNative` payloads are the message body minus `messaging_product`/`to`.
13
+ */
14
+ export declare const createWhatsAppNotifier: (client: WhatsAppClient, templates: TemplateRegistry) => Notifier;
15
+ /**
16
+ * Walks a parsed WhatsApp webhook body and routes each message to the
17
+ * {@link ChannelRouter}: `/`-prefixed text → `command`, other text → `message`,
18
+ * interactive/quick-reply → `action` (keyed by the reply id). Media and other
19
+ * message types are skipped here (handle them on the whatsapp package's native
20
+ * handlers). Replies go back to the sender via the {@link WhatsAppClient}.
21
+ */
22
+ export declare const dispatchWhatsApp: (router: ChannelRouter, client: WhatsAppClient, body: WhatsAppWebhookBody) => Promise<void>;
23
+ //# sourceMappingURL=comms.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"comms.d.ts","sourceRoot":"","sources":["../src/comms.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,EAAyB,KAAK,aAAa,EAAsB,KAAK,QAAQ,EAAwB,KAAK,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;AACpK,OAAO,EAAE,cAAc,EAAE,MAAM,6BAA6B,CAAC;AAC7D,OAAO,EAAgE,KAAK,mBAAmB,EAAE,MAAM,+BAA+B,CAAC;AAwBvI;;;;GAIG;AACH,eAAO,MAAM,sBAAsB,GAAI,QAAQ,cAAc,EAAE,WAAW,gBAAgB,KAAG,QAS3F,CAAC;AA4BH;;;;;;GAMG;AACH,eAAO,MAAM,gBAAgB,GAAU,QAAQ,aAAa,EAAE,QAAQ,cAAc,EAAE,MAAM,mBAAmB,KAAG,OAAO,CAAC,IAAI,CAY7H,CAAC"}
package/dist/comms.js ADDED
@@ -0,0 +1,139 @@
1
+ import {
2
+ __name,
3
+ interactiveReplyId
4
+ } from "./chunk-ASMXL2NK.js";
5
+
6
+ // src/comms.ts
7
+ import { bindReply, CommsError } from "@maroonedsoftware/comms";
8
+ var deliverPortable = /* @__PURE__ */ __name((client, to, message) => {
9
+ const buttons = message.buttons ?? [];
10
+ if (buttons.length === 0) return client.sendText(to, message.text);
11
+ if (buttons.length <= 3) {
12
+ return client.sendInteractive(to, {
13
+ type: "button",
14
+ body: {
15
+ text: message.text
16
+ },
17
+ action: {
18
+ buttons: buttons.map((b) => ({
19
+ type: "reply",
20
+ reply: {
21
+ id: b.id,
22
+ title: b.label
23
+ }
24
+ }))
25
+ }
26
+ });
27
+ }
28
+ return client.sendInteractive(to, {
29
+ type: "list",
30
+ body: {
31
+ text: message.text
32
+ },
33
+ action: {
34
+ button: "Choose",
35
+ sections: [
36
+ {
37
+ rows: buttons.map((b) => ({
38
+ id: b.id,
39
+ title: b.label
40
+ }))
41
+ }
42
+ ]
43
+ }
44
+ });
45
+ }, "deliverPortable");
46
+ var deliverNative = /* @__PURE__ */ __name((client, to, payload) => client.sendMessage({
47
+ messaging_product: "whatsapp",
48
+ recipient_type: "individual",
49
+ to,
50
+ ...payload
51
+ }), "deliverNative");
52
+ var createWhatsAppNotifier = /* @__PURE__ */ __name((client, templates) => ({
53
+ channel: "whatsapp",
54
+ send: /* @__PURE__ */ __name(async (to, message) => void await deliverPortable(client, to, message), "send"),
55
+ sendTemplate: /* @__PURE__ */ __name(async (to, name, data) => {
56
+ const resolved = templates.render(name, "whatsapp", data);
57
+ if (!resolved) throw new CommsError(`No comms template registered for "${name}"`).withInternalDetails({
58
+ channel: "whatsapp",
59
+ name
60
+ });
61
+ await (resolved.kind === "native" ? deliverNative(client, to, resolved.payload) : deliverPortable(client, to, resolved.message));
62
+ }, "sendTemplate"),
63
+ sendNative: /* @__PURE__ */ __name(async (to, payload) => void await deliverNative(client, to, payload), "sendNative")
64
+ }), "createWhatsAppNotifier");
65
+ var normalize = /* @__PURE__ */ __name((message, value) => {
66
+ const from = message.from;
67
+ const contact = value.contacts?.find((c) => c.wa_id === from) ?? value.contacts?.[0];
68
+ const user = {
69
+ id: from,
70
+ username: contact?.profile?.name
71
+ };
72
+ const conversation = {
73
+ id: from
74
+ };
75
+ const raw = {
76
+ message,
77
+ value
78
+ };
79
+ const replyId = interactiveReplyId(message);
80
+ if (replyId) {
81
+ const value2 = message.type === "interactive" ? message.interactive?.button_reply?.title ?? message.interactive?.list_reply?.title : message.button?.text;
82
+ return {
83
+ channel: "whatsapp",
84
+ kind: "action",
85
+ user,
86
+ conversation,
87
+ action: {
88
+ id: replyId,
89
+ value: value2
90
+ },
91
+ raw
92
+ };
93
+ }
94
+ if (message.type === "text") {
95
+ const text = message.text?.body ?? "";
96
+ if (text.startsWith("/")) {
97
+ const [name, ...rest] = text.split(/\s+/);
98
+ return {
99
+ channel: "whatsapp",
100
+ kind: "command",
101
+ user,
102
+ conversation,
103
+ text,
104
+ command: {
105
+ name: name ?? text,
106
+ args: rest.join(" ").trim()
107
+ },
108
+ raw
109
+ };
110
+ }
111
+ return {
112
+ channel: "whatsapp",
113
+ kind: "message",
114
+ user,
115
+ conversation,
116
+ text,
117
+ raw
118
+ };
119
+ }
120
+ return void 0;
121
+ }, "normalize");
122
+ var dispatchWhatsApp = /* @__PURE__ */ __name(async (router, client, body) => {
123
+ for (const entry of body.entry ?? []) {
124
+ for (const change of entry.changes ?? []) {
125
+ const value = change.value;
126
+ if (!value) continue;
127
+ for (const message of value.messages ?? []) {
128
+ const event = normalize(message, value);
129
+ if (!event) continue;
130
+ await router.dispatch(event, bindReply(createWhatsAppNotifier(client, router.templates), message.from));
131
+ }
132
+ }
133
+ }
134
+ }, "dispatchWhatsApp");
135
+ export {
136
+ createWhatsAppNotifier,
137
+ dispatchWhatsApp
138
+ };
139
+ //# sourceMappingURL=comms.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/comms.ts"],"sourcesContent":["/**\n * `@maroonedsoftware/whatsapp/comms` — adapter binding the WhatsApp package to\n * the channel-agnostic `@maroonedsoftware/comms` router. Importing this subpath\n * pulls in `@maroonedsoftware/comms` (an optional peer); the whatsapp core does not.\n */\nimport { bindReply, CommsError, type ChannelRouter, type IncomingEvent, type Notifier, type OutgoingMessage, type TemplateRegistry } from '@maroonedsoftware/comms';\nimport { WhatsAppClient } from './client/whatsapp.client.js';\nimport { interactiveReplyId, type WhatsAppMessage, type WhatsAppValue, type WhatsAppWebhookBody } from './whatsapp.message.handler.js';\n\n/** Sends a portable message: plain text, ≤3 reply buttons, or a list for >3 (degradation). */\nconst deliverPortable = (client: WhatsAppClient, to: string, message: OutgoingMessage): Promise<unknown> => {\n const buttons = message.buttons ?? [];\n if (buttons.length === 0) return client.sendText(to, message.text);\n if (buttons.length <= 3) {\n return client.sendInteractive(to, {\n type: 'button',\n body: { text: message.text },\n action: { buttons: buttons.map(b => ({ type: 'reply', reply: { id: b.id, title: b.label } })) },\n });\n }\n return client.sendInteractive(to, {\n type: 'list',\n body: { text: message.text },\n action: { button: 'Choose', sections: [{ rows: buttons.map(b => ({ id: b.id, title: b.label })) }] },\n });\n};\n\n/** Sends a native message body (the type-specific part); `messaging_product`/`to` are added. */\nconst deliverNative = (client: WhatsAppClient, to: string, payload: unknown): Promise<unknown> =>\n client.sendMessage({ messaging_product: 'whatsapp', recipient_type: 'individual', to, ...(payload as Record<string, unknown>) });\n\n/**\n * Builds a {@link Notifier} that sends portable messages and registered templates\n * through {@link WhatsAppClient}. The recipient is a `wa_id` (phone). Native\n * template/`sendNative` payloads are the message body minus `messaging_product`/`to`.\n */\nexport const createWhatsAppNotifier = (client: WhatsAppClient, templates: TemplateRegistry): Notifier => ({\n channel: 'whatsapp',\n send: async (to, message) => void (await deliverPortable(client, to, message)),\n sendTemplate: async (to, name, data) => {\n const resolved = templates.render(name, 'whatsapp', data);\n if (!resolved) throw new CommsError(`No comms template registered for \"${name}\"`).withInternalDetails({ channel: 'whatsapp', name });\n await (resolved.kind === 'native' ? deliverNative(client, to, resolved.payload) : deliverPortable(client, to, resolved.message));\n },\n sendNative: async (to, payload) => void (await deliverNative(client, to, payload)),\n});\n\nconst normalize = (message: WhatsAppMessage, value: WhatsAppValue): IncomingEvent | undefined => {\n const from = message.from;\n const contact = value.contacts?.find(c => c.wa_id === from) ?? value.contacts?.[0];\n const user = { id: from, username: contact?.profile?.name };\n const conversation = { id: from };\n const raw = { message, value };\n\n const replyId = interactiveReplyId(message);\n if (replyId) {\n const value2 =\n message.type === 'interactive' ? (message.interactive?.button_reply?.title ?? message.interactive?.list_reply?.title) : message.button?.text;\n return { channel: 'whatsapp', kind: 'action', user, conversation, action: { id: replyId, value: value2 }, raw };\n }\n\n if (message.type === 'text') {\n const text = message.text?.body ?? '';\n if (text.startsWith('/')) {\n const [name, ...rest] = text.split(/\\s+/);\n return { channel: 'whatsapp', kind: 'command', user, conversation, text, command: { name: name ?? text, args: rest.join(' ').trim() }, raw };\n }\n return { channel: 'whatsapp', kind: 'message', user, conversation, text, raw };\n }\n\n return undefined; // media/other types stay on the native WhatsAppMessageHandlerMap\n};\n\n/**\n * Walks a parsed WhatsApp webhook body and routes each message to the\n * {@link ChannelRouter}: `/`-prefixed text → `command`, other text → `message`,\n * interactive/quick-reply → `action` (keyed by the reply id). Media and other\n * message types are skipped here (handle them on the whatsapp package's native\n * handlers). Replies go back to the sender via the {@link WhatsAppClient}.\n */\nexport const dispatchWhatsApp = async (router: ChannelRouter, client: WhatsAppClient, body: WhatsAppWebhookBody): Promise<void> => {\n for (const entry of body.entry ?? []) {\n for (const change of entry.changes ?? []) {\n const value = change.value;\n if (!value) continue;\n for (const message of value.messages ?? []) {\n const event = normalize(message, value);\n if (!event) continue;\n await router.dispatch(event, bindReply(createWhatsAppNotifier(client, router.templates), message.from));\n }\n }\n }\n};\n"],"mappings":"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","names":["bindReply","CommsError","deliverPortable","client","to","message","buttons","length","sendText","text","sendInteractive","type","body","action","map","b","reply","id","title","label","button","sections","rows","deliverNative","payload","sendMessage","messaging_product","recipient_type","createWhatsAppNotifier","templates","channel","send","sendTemplate","name","data","resolved","render","CommsError","withInternalDetails","kind","sendNative","normalize","value","from","contact","contacts","find","c","wa_id","user","username","profile","conversation","raw","replyId","interactiveReplyId","value2","interactive","button_reply","list_reply","startsWith","rest","split","command","args","join","trim","undefined","dispatchWhatsApp","router","entry","change","changes","messages","event","dispatch","bindReply"]}
@@ -0,0 +1,9 @@
1
+ export * from './whatsapp.config.js';
2
+ export * from './whatsapp.error.js';
3
+ export * from './whatsapp.signature.js';
4
+ export * from './whatsapp.webhook.js';
5
+ export * from './whatsapp.signature.policy.js';
6
+ export * from './whatsapp.message.handler.js';
7
+ export * from './whatsapp.dispatcher.js';
8
+ export * from './client/whatsapp.client.js';
9
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,sBAAsB,CAAC;AACrC,cAAc,qBAAqB,CAAC;AACpC,cAAc,yBAAyB,CAAC;AACxC,cAAc,uBAAuB,CAAC;AACtC,cAAc,gCAAgC,CAAC;AAC/C,cAAc,+BAA+B,CAAC;AAC9C,cAAc,0BAA0B,CAAC;AACzC,cAAc,6BAA6B,CAAC"}