@copilotkit/channels-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.
Files changed (90) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +236 -0
  3. package/dist/adapter.d.ts +57 -0
  4. package/dist/adapter.d.ts.map +1 -0
  5. package/dist/adapter.js +242 -0
  6. package/dist/adapter.test.d.ts +2 -0
  7. package/dist/adapter.test.d.ts.map +1 -0
  8. package/dist/adapter.test.js +137 -0
  9. package/dist/built-in-context.d.ts +7 -0
  10. package/dist/built-in-context.d.ts.map +1 -0
  11. package/dist/built-in-context.js +19 -0
  12. package/dist/built-in-context.test.d.ts +2 -0
  13. package/dist/built-in-context.test.d.ts.map +1 -0
  14. package/dist/built-in-context.test.js +17 -0
  15. package/dist/built-in-tools.d.ts +8 -0
  16. package/dist/built-in-tools.d.ts.map +1 -0
  17. package/dist/built-in-tools.js +6 -0
  18. package/dist/client.d.ts +40 -0
  19. package/dist/client.d.ts.map +1 -0
  20. package/dist/client.js +108 -0
  21. package/dist/client.test.d.ts +2 -0
  22. package/dist/client.test.d.ts.map +1 -0
  23. package/dist/client.test.js +108 -0
  24. package/dist/conversation-store.d.ts +23 -0
  25. package/dist/conversation-store.d.ts.map +1 -0
  26. package/dist/conversation-store.js +41 -0
  27. package/dist/conversation-store.test.d.ts +2 -0
  28. package/dist/conversation-store.test.d.ts.map +1 -0
  29. package/dist/conversation-store.test.js +40 -0
  30. package/dist/download-files.d.ts +44 -0
  31. package/dist/download-files.d.ts.map +1 -0
  32. package/dist/download-files.js +66 -0
  33. package/dist/download-files.test.d.ts +2 -0
  34. package/dist/download-files.test.d.ts.map +1 -0
  35. package/dist/download-files.test.js +34 -0
  36. package/dist/event-renderer.d.ts +18 -0
  37. package/dist/event-renderer.d.ts.map +1 -0
  38. package/dist/event-renderer.js +110 -0
  39. package/dist/event-renderer.test.d.ts +2 -0
  40. package/dist/event-renderer.test.d.ts.map +1 -0
  41. package/dist/event-renderer.test.js +68 -0
  42. package/dist/history-store.d.ts +36 -0
  43. package/dist/history-store.d.ts.map +1 -0
  44. package/dist/history-store.js +17 -0
  45. package/dist/history-store.test.d.ts +2 -0
  46. package/dist/history-store.test.d.ts.map +1 -0
  47. package/dist/history-store.test.js +24 -0
  48. package/dist/index.d.ts +18 -0
  49. package/dist/index.d.ts.map +1 -0
  50. package/dist/index.js +12 -0
  51. package/dist/interaction.d.ts +19 -0
  52. package/dist/interaction.d.ts.map +1 -0
  53. package/dist/interaction.js +56 -0
  54. package/dist/interaction.test.d.ts +2 -0
  55. package/dist/interaction.test.d.ts.map +1 -0
  56. package/dist/interaction.test.js +67 -0
  57. package/dist/markdown-to-wa.d.ts +15 -0
  58. package/dist/markdown-to-wa.d.ts.map +1 -0
  59. package/dist/markdown-to-wa.js +56 -0
  60. package/dist/markdown-to-wa.test.d.ts +2 -0
  61. package/dist/markdown-to-wa.test.d.ts.map +1 -0
  62. package/dist/markdown-to-wa.test.js +29 -0
  63. package/dist/render/budget.d.ts +34 -0
  64. package/dist/render/budget.d.ts.map +1 -0
  65. package/dist/render/budget.js +40 -0
  66. package/dist/render/budget.test.d.ts +2 -0
  67. package/dist/render/budget.test.d.ts.map +1 -0
  68. package/dist/render/budget.test.js +24 -0
  69. package/dist/render/message.d.ts +74 -0
  70. package/dist/render/message.d.ts.map +1 -0
  71. package/dist/render/message.js +247 -0
  72. package/dist/render/message.test.d.ts +2 -0
  73. package/dist/render/message.test.d.ts.map +1 -0
  74. package/dist/render/message.test.js +184 -0
  75. package/dist/types.d.ts +107 -0
  76. package/dist/types.d.ts.map +1 -0
  77. package/dist/types.js +1 -0
  78. package/dist/webhook-listener.d.ts +16 -0
  79. package/dist/webhook-listener.d.ts.map +1 -0
  80. package/dist/webhook-listener.js +128 -0
  81. package/dist/webhook-listener.test.d.ts +2 -0
  82. package/dist/webhook-listener.test.d.ts.map +1 -0
  83. package/dist/webhook-listener.test.js +204 -0
  84. package/dist/webhook-server.d.ts +23 -0
  85. package/dist/webhook-server.d.ts.map +1 -0
  86. package/dist/webhook-server.js +99 -0
  87. package/dist/webhook-server.test.d.ts +2 -0
  88. package/dist/webhook-server.test.d.ts.map +1 -0
  89. package/dist/webhook-server.test.js +150 -0
  90. package/package.json +55 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License
2
+
3
+ Copyright (c) Atai Barkai
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,236 @@
1
+ # @copilotkit/channels-whatsapp
2
+
3
+ The **WhatsApp `PlatformAdapter`** for [`@copilotkit/channels`](../channels). It connects a
4
+ WhatsApp Business number to any AG-UI agent: ingress via the Meta Cloud API webhook,
5
+ egress as text or interactive messages rendered from the `@copilotkit/channels-ui` JSX
6
+ vocabulary, opaque-id interactions, and HITL.
7
+
8
+ You write your UI as JSX once (`@copilotkit/channels-ui`) and drive the bot with
9
+ `@copilotkit/channels`; this package is the only one that talks to the WhatsApp Cloud API.
10
+
11
+ ## Install
12
+
13
+ ```sh
14
+ pnpm add @copilotkit/channels @copilotkit/channels-whatsapp
15
+ ```
16
+
17
+ ## Quickstart
18
+
19
+ ```ts
20
+ import { createBot } from "@copilotkit/channels";
21
+ import {
22
+ whatsapp,
23
+ defaultWhatsAppContext,
24
+ } from "@copilotkit/channels-whatsapp";
25
+
26
+ const bot = createBot({
27
+ adapters: [
28
+ whatsapp({
29
+ accessToken: process.env.WHATSAPP_ACCESS_TOKEN!,
30
+ phoneNumberId: process.env.WHATSAPP_PHONE_NUMBER_ID!,
31
+ appSecret: process.env.WHATSAPP_APP_SECRET!,
32
+ verifyToken: process.env.WHATSAPP_VERIFY_TOKEN!,
33
+ port: 3000,
34
+ }),
35
+ ],
36
+ agent: makeAgent(process.env.AGENT_URL!),
37
+ tools: [...appTools],
38
+ context: [...defaultWhatsAppContext, ...appContext],
39
+ });
40
+
41
+ // Every inbound text is for the bot — there is no @-mention concept on WhatsApp.
42
+ bot.onMessage(async ({ thread }) => {
43
+ await thread.runAgent();
44
+ });
45
+
46
+ await bot.start();
47
+ console.log("[whatsapp-bot] listening for webhooks");
48
+ ```
49
+
50
+ `whatsapp(opts)` returns a `WhatsAppAdapter`. It starts an HTTP server on `port`
51
+ (default 3000) that handles the Meta webhook: a `GET /webhook` verification
52
+ handshake and signed `POST /webhook` event delivery. You must expose this port
53
+ publicly (e.g. via ngrok) and register the URL + `verifyToken` in the Meta app
54
+ configuration. See [`examples/whatsapp`](../../examples/whatsapp) for a complete
55
+ setup walkthrough.
56
+
57
+ ### Required env
58
+
59
+ | Var | Purpose |
60
+ | -------------------------- | ----------------------------------------------------------- |
61
+ | `WHATSAPP_ACCESS_TOKEN` | Cloud API access token (Bearer), from Meta App → API setup. |
62
+ | `WHATSAPP_PHONE_NUMBER_ID` | Business phone-number id that sends messages. |
63
+ | `WHATSAPP_APP_SECRET` | App secret for `X-Hub-Signature-256` webhook validation. |
64
+ | `WHATSAPP_VERIFY_TOKEN` | Token echoed during the GET verification handshake. |
65
+
66
+ ## Capabilities
67
+
68
+ | Capability | Supported | Notes |
69
+ | ------------------- | --------- | -------------------------------------------------------------- |
70
+ | `supportsStreaming` | false | WhatsApp messages are immutable; there is no edit-message API. |
71
+ | `supportsModals` | false | No modal surface in the Cloud API. |
72
+ | `supportsTyping` | false | No typing-indicator API for business accounts. |
73
+ | `supportsReactions` | false | No reaction API for business-sent messages. |
74
+
75
+ Because messages are immutable, `thread.stream(...)` buffers the full iterable
76
+ and sends it as a single message — there is no token-by-token streaming. Calls to
77
+ `update` and `delete` are also no-ops (they post a new message instead, or silently
78
+ drop). The `defaultWhatsAppContext` entry tells the agent about this constraint so
79
+ it doesn't promise to "update this message."
80
+
81
+ ## `WhatsAppAdapterOptions` reference
82
+
83
+ | Option | Type | Default | Description |
84
+ | --------------------- | --------------------- | ------------------------------ | ------------------------------------------------------------------- |
85
+ | `accessToken` | `string` | required | Cloud API access token (Bearer). |
86
+ | `phoneNumberId` | `string` | required | Business phone-number id that sends messages. |
87
+ | `appSecret` | `string` | required | App secret for `X-Hub-Signature-256` webhook validation. |
88
+ | `verifyToken` | `string` | required | Token echoed during the GET verification handshake. |
89
+ | `port` | `number` | `3000` | HTTP server port. |
90
+ | `path` | `string` | `"/webhook"` | Webhook path. |
91
+ | `apiVersion` | `string` | `"v21.0"` | Graph API version. |
92
+ | `graphBaseUrl` | `string` | `"https://graph.facebook.com"` | Graph API base origin. Overridable for tests. |
93
+ | `interruptEventNames` | `ReadonlySet<string>` | `undefined` | Custom AG-UI event names treated as interrupts by the run renderer. |
94
+ | `commandPrefix` | `string` | `"/"` | Prefix for leading-keyword command matching. |
95
+ | `historyStore` | `HistoryStore` | `new InMemoryHistoryStore()` | Pluggable conversation-history persistence. |
96
+ | `files` | `FileDeliveryConfig` | `{}` | Inbound media handling configuration. |
97
+
98
+ ## JSX → WhatsApp rendering
99
+
100
+ `renderWhatsAppMessage(ir)` lowers the `@copilotkit/channels-ui` IR to Cloud API
101
+ payloads. The strategy:
102
+
103
+ - **0 actions** → plain `text` message (markdown converted to WhatsApp formatting).
104
+ - **1–3 button actions** → interactive `button` message (reply buttons).
105
+ - **4–10 actions** → interactive `list` message (list picker).
106
+ - **>10 actions** → numbered text menu (degraded fallback).
107
+
108
+ Image nodes always emit their own `image` payload. Markdown is translated to
109
+ WhatsApp formatting: `**bold**`, `_italic_`, `~~strikethrough~~`, `` `code` ``, and
110
+ code blocks. Headings, tables, and clickable Markdown links are not supported on
111
+ WhatsApp — links render as plain text.
112
+
113
+ ### Per-element budget
114
+
115
+ WhatsApp caps interactive elements. Limits live in `WA_LIMITS`:
116
+
117
+ | Limit | Value | Element |
118
+ | ------------------- | ----- | ------------------------------------------------ |
119
+ | `bodyText` | 4096 | text message body chars |
120
+ | `replyButtons` | 3 | reply buttons in an interactive button message |
121
+ | `buttonTitle` | 20 | reply-button title chars |
122
+ | `interactiveBody` | 1024 | interactive message body chars |
123
+ | `interactiveHeader` | 60 | interactive header chars |
124
+ | `interactiveFooter` | 60 | interactive footer chars |
125
+ | `listRows` | 10 | total rows across all sections in a list message |
126
+ | `rowTitle` | 24 | list-row title chars |
127
+ | `rowDescription` | 72 | list-row description chars |
128
+ | `listButton` | 20 | list open-button label chars |
129
+ | `controlId` | 256 | interactive control id chars |
130
+
131
+ ## Persistence
132
+
133
+ ### ActionStore (interaction rehydration)
134
+
135
+ The engine's `ActionStore` (from `@copilotkit/channels`) stores the minted opaque ids
136
+ that power `Button` / `Select` click handlers. By default it is in-memory: after a
137
+ process restart, clicks on old interactive messages are acknowledged but ignored.
138
+ For persistent interactions, pass a durable `ActionStore` to
139
+ `createBot({ actionStore })`.
140
+
141
+ ### HistoryStore (conversation memory)
142
+
143
+ Unlike Slack, WhatsApp exposes no readable message history. The adapter maintains
144
+ its own `HistoryStore` and replays it into `agent.messages` on every turn. The
145
+ default is `InMemoryHistoryStore` (up to 100 messages per conversation, drops
146
+ oldest). Swap a durable backend by implementing the `HistoryStore` interface:
147
+
148
+ ```ts
149
+ interface HistoryStore {
150
+ append(conversationKey: string, message: StoredMessage): Promise<void>;
151
+ read(conversationKey: string): Promise<StoredMessage[]>;
152
+ }
153
+ ```
154
+
155
+ Pass it as `historyStore` in the adapter options:
156
+
157
+ ```ts
158
+ whatsapp({
159
+ // ...
160
+ historyStore: new MyRedisHistoryStore(),
161
+ });
162
+ ```
163
+
164
+ Without a durable `HistoryStore`, conversation history is lost on process restart.
165
+
166
+ ## Commands
167
+
168
+ Commands are matched by a leading keyword in the message text (default prefix `/`).
169
+ Register handlers with `bot.onCommand`:
170
+
171
+ ```ts
172
+ bot.onCommand("status", async ({ thread, text }) => {
173
+ await thread.runAgent({ prompt: `Status check: ${text}` });
174
+ });
175
+ ```
176
+
177
+ Unlike Slack, WhatsApp has no native slash-command surface — commands are plain
178
+ text messages that start with the prefix. They are NOT pre-filtered by the adapter
179
+ (the engine matches them), and command messages are not persisted to the
180
+ `HistoryStore` at ingress. Sent commands need to be serialized into the agent prompt
181
+ explicitly if the agent needs to see them as history.
182
+
183
+ ## Built-ins
184
+
185
+ - `defaultWhatsAppTools` — empty in v1 (WhatsApp exposes no user directory, so
186
+ there is no `lookup_user` equivalent). Spread into `tools` for future
187
+ compatibility.
188
+ - `defaultWhatsAppContext` — two context entries: WhatsApp formatting rules
189
+ (bold/italic/code, no headings or clickable links) and delivery constraints (no
190
+ streaming, no message editing). Spread into `context`.
191
+ - `whatsAppFormattingContext` / `whatsAppDeliveryContext` — the individual entries
192
+ if you need to compose them selectively.
193
+
194
+ ## Tool context
195
+
196
+ Tools receive the single shared `BotToolContext` from `@copilotkit/channels`
197
+ (`{ thread, message?, user?, signal?, platform }`) and reach WhatsApp power through
198
+ capability-gated `thread` methods this adapter backs:
199
+
200
+ - `thread.getMessages()` — the current conversation's message history (from
201
+ `HistoryStore`), each a `ThreadMessage` (`{ user?, text, ts?, isBot? }`).
202
+ - `thread.postFile({ bytes, filename, title?, altText? })` — upload and send a
203
+ file (image → `image` payload; other → `document` payload via the media-upload
204
+ API).
205
+
206
+ Note: `thread.lookupUser(query)` is a no-op on WhatsApp — the Cloud API exposes no
207
+ user directory. It always returns `undefined`.
208
+
209
+ ## Running the demo
210
+
211
+ This package is the **library**. A runnable end-to-end demo wiring everything
212
+ against a real WhatsApp number lives in
213
+ [`examples/whatsapp`](../../examples/whatsapp).
214
+
215
+ ## What's NOT in v1
216
+
217
+ - No message editing or streaming (WhatsApp messages are immutable)
218
+ - No proactive messaging outside the 24-hour customer-service window — the adapter
219
+ does not implement template-message sending; the bot can only reply within the
220
+ 24-hour window opened by an inbound user message
221
+ - No user directory (`lookupUser` always returns `undefined`)
222
+ - No OAuth / multi-number install (single access token only)
223
+ - Durable `ActionStore` and `HistoryStore` are in-memory by default; actions and
224
+ history expire on restart unless you provide durable implementations
225
+
226
+ ## Exports
227
+
228
+ `whatsapp`, `WhatsAppAdapter`; `WhatsAppAdapterOptions`, `ReplyTarget`,
229
+ `WhatsAppMessageRef` (types); `WhatsAppConversationStore`;
230
+ `InMemoryHistoryStore`, `HistoryStore`, `StoredMessage` (types);
231
+ `renderWhatsAppMessage`, `WhatsAppOutbound` (type); `WA_LIMITS`, `truncateText`,
232
+ `clampArray`; `markdownToWhatsApp`; `decodeInteraction`, `conversationKeyOf`;
233
+ `createRunRenderer`; `WhatsAppClient`, `DownloadedMedia` (type);
234
+ `buildFileContentParts`, `AgentContentPart`, `FileDeliveryConfig` (types);
235
+ `defaultWhatsAppTools`; `defaultWhatsAppContext`, `whatsAppFormattingContext`,
236
+ `whatsAppDeliveryContext`.
@@ -0,0 +1,57 @@
1
+ import type { PlatformAdapter, SurfaceCapabilities, IngressSink, InteractionEvent, RunRenderer, ConversationStore, UserQuery } from "@copilotkit/channels";
2
+ import type { BotNode, MessageRef, PlatformUser, ThreadMessage } from "@copilotkit/channels-ui";
3
+ import type { ReplyTarget, WhatsAppAdapterOptions } from "./types.js";
4
+ import { WhatsAppClient } from "./client.js";
5
+ import type { WhatsAppOutbound } from "./render/message.js";
6
+ /** Factory mirroring `slack(opts)`. */
7
+ export declare function whatsapp(opts: WhatsAppAdapterOptions): WhatsAppAdapter;
8
+ export declare class WhatsAppAdapter implements PlatformAdapter {
9
+ private readonly opts;
10
+ readonly platform = "whatsapp";
11
+ readonly capabilities: SurfaceCapabilities;
12
+ readonly ackDeadlineMs = 5000;
13
+ readonly conversationStore: ConversationStore;
14
+ client: WhatsAppClient;
15
+ private readonly history;
16
+ private readonly server;
17
+ private readonly port;
18
+ private readonly commandPrefix;
19
+ private readonly interruptEventNames?;
20
+ private readonly waStore;
21
+ private sink;
22
+ constructor(opts: WhatsAppAdapterOptions);
23
+ start(sink: IngressSink): Promise<void>;
24
+ stop(): Promise<void>;
25
+ private onWebhook;
26
+ render(ir: BotNode[]): WhatsAppOutbound[];
27
+ post(target: ReplyTarget, ir: BotNode[]): Promise<MessageRef>;
28
+ update(ref: MessageRef, ir: BotNode[]): Promise<void>;
29
+ stream(target: ReplyTarget, chunks: AsyncIterable<string>): Promise<MessageRef>;
30
+ delete(_ref: MessageRef): Promise<void>;
31
+ createRunRenderer(target: ReplyTarget): RunRenderer;
32
+ decodeInteraction(raw: unknown): InteractionEvent | undefined;
33
+ lookupUser(_q: UserQuery): Promise<PlatformUser | undefined>;
34
+ getMessages(target: ReplyTarget): Promise<ThreadMessage[]>;
35
+ postFile(target: ReplyTarget, args: {
36
+ bytes: Uint8Array;
37
+ filename: string;
38
+ title?: string;
39
+ altText?: string;
40
+ }): Promise<{
41
+ ok: boolean;
42
+ fileId?: string;
43
+ error?: string;
44
+ }>;
45
+ /** Send agent/freeform text: convert markdown to WhatsApp formatting, split to ≤bodyText chunks. */
46
+ private sendText;
47
+ /**
48
+ * Record an outbound message in history keyed by its WhatsApp id, so a later
49
+ * quote-reply to it (the webhook sends only the quoted id) resolves to this
50
+ * text. Best-effort: no id or no text → skip. Errors are swallowed so a
51
+ * history write can never break a send.
52
+ */
53
+ private recordOutbound;
54
+ }
55
+ /** Split text into chunks no longer than `max` characters. */
56
+ export declare function splitForWhatsApp(text: string, max: number): string[];
57
+ //# sourceMappingURL=adapter.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../src/adapter.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,242 @@
1
+ import { WhatsAppClient } from "./client.js";
2
+ import { WhatsAppConversationStore } from "./conversation-store.js";
3
+ import { InMemoryHistoryStore } from "./history-store.js";
4
+ import { WebhookServer } from "./webhook-server.js";
5
+ import { handleWebhookValue } from "./webhook-listener.js";
6
+ import { createRunRenderer } from "./event-renderer.js";
7
+ import { conversationKeyOf, decodeInteraction } from "./interaction.js";
8
+ import { renderWhatsAppMessage } from "./render/message.js";
9
+ import { markdownToWhatsApp } from "./markdown-to-wa.js";
10
+ import { WA_LIMITS } from "./render/budget.js";
11
+ /** Factory mirroring `slack(opts)`. */
12
+ export function whatsapp(opts) {
13
+ return new WhatsAppAdapter(opts);
14
+ }
15
+ export class WhatsAppAdapter {
16
+ opts;
17
+ platform = "whatsapp";
18
+ capabilities = {
19
+ supportsModals: false,
20
+ supportsTyping: true,
21
+ supportsReactions: false,
22
+ supportsStreaming: false,
23
+ };
24
+ ackDeadlineMs = 5000;
25
+ conversationStore;
26
+ client;
27
+ history;
28
+ server;
29
+ port;
30
+ commandPrefix;
31
+ interruptEventNames;
32
+ waStore;
33
+ sink;
34
+ constructor(opts) {
35
+ this.opts = opts;
36
+ this.client = new WhatsAppClient({
37
+ accessToken: opts.accessToken,
38
+ phoneNumberId: opts.phoneNumberId,
39
+ apiVersion: opts.apiVersion,
40
+ graphBaseUrl: opts.graphBaseUrl,
41
+ });
42
+ this.history = opts.historyStore ?? new InMemoryHistoryStore();
43
+ this.waStore = new WhatsAppConversationStore({
44
+ historyStore: this.history,
45
+ });
46
+ this.conversationStore = this.waStore;
47
+ this.port = opts.port ?? 3000;
48
+ this.commandPrefix = opts.commandPrefix ?? "/";
49
+ this.interruptEventNames = opts.interruptEventNames;
50
+ this.server = new WebhookServer({
51
+ path: opts.path ?? "/webhook",
52
+ verifyToken: opts.verifyToken,
53
+ appSecret: opts.appSecret,
54
+ onEvent: (body) => this.onWebhook(body),
55
+ });
56
+ }
57
+ async start(sink) {
58
+ this.sink = sink;
59
+ await this.server.start(this.port);
60
+ }
61
+ async stop() {
62
+ await this.server.stop();
63
+ }
64
+ async onWebhook(body) {
65
+ if (!this.sink)
66
+ return;
67
+ for (const entry of body.entry ?? []) {
68
+ for (const change of entry.changes ?? []) {
69
+ if (!change.value)
70
+ continue;
71
+ await handleWebhookValue(change.value, {
72
+ sink: this.sink,
73
+ history: this.history,
74
+ phoneNumberId: this.opts.phoneNumberId,
75
+ commandPrefix: this.commandPrefix,
76
+ client: this.client,
77
+ files: this.opts.files ?? {},
78
+ });
79
+ }
80
+ }
81
+ }
82
+ render(ir) {
83
+ return renderWhatsAppMessage(ir);
84
+ }
85
+ async post(target, ir) {
86
+ const payloads = this.render(ir);
87
+ let last = {
88
+ id: "",
89
+ to: target.to,
90
+ phoneNumberId: target.phoneNumberId,
91
+ };
92
+ for (const p of payloads) {
93
+ last = await this.client.sendMessage(target.to, p);
94
+ this.recordOutbound(target.to, outboundText(p), last);
95
+ }
96
+ return last;
97
+ }
98
+ async update(ref, ir) {
99
+ // WhatsApp can't edit messages; "update" posts a fresh message instead.
100
+ const r = ref;
101
+ await this.post({ to: r.to, phoneNumberId: r.phoneNumberId }, ir);
102
+ }
103
+ async stream(target, chunks) {
104
+ // No live streaming: buffer the whole iterable, then send once.
105
+ let text = "";
106
+ for await (const c of chunks)
107
+ text += c;
108
+ return this.sendText(target.to, text);
109
+ }
110
+ async delete(_ref) {
111
+ // WhatsApp has no message-delete API for business-sent messages; no-op.
112
+ }
113
+ createRunRenderer(target) {
114
+ // `sendText` records each outbound message in history (with its wamid), so
115
+ // the renderer doesn't need a separate `onAssistantText` history hook.
116
+ return createRunRenderer({
117
+ send: async (text) => {
118
+ await this.sendText(target.to, text);
119
+ },
120
+ interruptEventNames: this.interruptEventNames,
121
+ });
122
+ }
123
+ decodeInteraction(raw) {
124
+ const r = raw;
125
+ if (!r?.message || !r.replyTarget)
126
+ return undefined;
127
+ return decodeInteraction(r.message, r.replyTarget);
128
+ }
129
+ async lookupUser(_q) {
130
+ return undefined; // WhatsApp exposes no user directory.
131
+ }
132
+ async getMessages(target) {
133
+ return this.waStore.getMessages(target);
134
+ }
135
+ async postFile(target, args) {
136
+ try {
137
+ const mime = guessMime(args.filename);
138
+ const mediaId = await this.client.uploadMedia(args.bytes, mime, args.filename);
139
+ const payload = mime.startsWith("image/")
140
+ ? {
141
+ type: "image",
142
+ image: {
143
+ id: mediaId,
144
+ ...(args.altText ? { caption: args.altText } : {}),
145
+ },
146
+ }
147
+ : {
148
+ type: "document",
149
+ document: {
150
+ id: mediaId,
151
+ filename: args.filename,
152
+ ...(args.title ? { caption: args.title } : {}),
153
+ },
154
+ };
155
+ const ref = await this.client.sendMessage(target.to, payload);
156
+ this.recordOutbound(target.to, args.title ??
157
+ args.altText ??
158
+ (mime.startsWith("image/") ? "[image]" : "[document]"), ref);
159
+ return { ok: true, fileId: mediaId };
160
+ }
161
+ catch (err) {
162
+ return { ok: false, error: err.message };
163
+ }
164
+ }
165
+ /** Send agent/freeform text: convert markdown to WhatsApp formatting, split to ≤bodyText chunks. */
166
+ async sendText(to, text) {
167
+ const body = markdownToWhatsApp(text);
168
+ if (!body)
169
+ return { id: "", to, phoneNumberId: this.opts.phoneNumberId };
170
+ const parts = splitForWhatsApp(body, WA_LIMITS.bodyText);
171
+ let last = {
172
+ id: "",
173
+ to,
174
+ phoneNumberId: this.opts.phoneNumberId,
175
+ };
176
+ for (const part of parts) {
177
+ last = await this.client.sendMessage(to, {
178
+ type: "text",
179
+ text: { body: part, preview_url: false },
180
+ });
181
+ this.recordOutbound(to, part, last);
182
+ }
183
+ return last;
184
+ }
185
+ /**
186
+ * Record an outbound message in history keyed by its WhatsApp id, so a later
187
+ * quote-reply to it (the webhook sends only the quoted id) resolves to this
188
+ * text. Best-effort: no id or no text → skip. Errors are swallowed so a
189
+ * history write can never break a send.
190
+ */
191
+ recordOutbound(to, text, ref) {
192
+ if (!ref.id || !text)
193
+ return;
194
+ void this.history
195
+ .append(conversationKeyOf(to), {
196
+ role: "assistant",
197
+ content: text,
198
+ ts: `${Date.now()}`,
199
+ id: ref.id,
200
+ })
201
+ .catch(() => { });
202
+ }
203
+ }
204
+ /** A short text representation of an outbound payload, for history/quote-resolution. */
205
+ function outboundText(p) {
206
+ switch (p.type) {
207
+ case "text":
208
+ return p.text.body;
209
+ case "image":
210
+ return p.image.caption ?? "[image]";
211
+ case "document":
212
+ return p.document.caption ?? p.document.filename ?? "[document]";
213
+ case "interactive":
214
+ return p.interactive.body.text;
215
+ }
216
+ }
217
+ /** Split text into chunks no longer than `max` characters. */
218
+ export function splitForWhatsApp(text, max) {
219
+ if (text.length <= max)
220
+ return [text];
221
+ const parts = [];
222
+ let i = 0;
223
+ while (i < text.length) {
224
+ parts.push(text.slice(i, i + max));
225
+ i += max;
226
+ }
227
+ return parts;
228
+ }
229
+ function guessMime(filename) {
230
+ const ext = filename.toLowerCase().split(".").pop() ?? "";
231
+ const map = {
232
+ png: "image/png",
233
+ jpg: "image/jpeg",
234
+ jpeg: "image/jpeg",
235
+ gif: "image/gif",
236
+ pdf: "application/pdf",
237
+ txt: "text/plain",
238
+ csv: "text/csv",
239
+ json: "application/json",
240
+ };
241
+ return map[ext] ?? "application/octet-stream";
242
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=adapter.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"adapter.test.d.ts","sourceRoot":"","sources":["../src/adapter.test.ts"],"names":[],"mappings":""}