@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.
- package/LICENSE +21 -0
- package/README.md +236 -0
- package/dist/adapter.d.ts +57 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +242 -0
- package/dist/adapter.test.d.ts +2 -0
- package/dist/adapter.test.d.ts.map +1 -0
- package/dist/adapter.test.js +137 -0
- package/dist/built-in-context.d.ts +7 -0
- package/dist/built-in-context.d.ts.map +1 -0
- package/dist/built-in-context.js +19 -0
- package/dist/built-in-context.test.d.ts +2 -0
- package/dist/built-in-context.test.d.ts.map +1 -0
- package/dist/built-in-context.test.js +17 -0
- package/dist/built-in-tools.d.ts +8 -0
- package/dist/built-in-tools.d.ts.map +1 -0
- package/dist/built-in-tools.js +6 -0
- package/dist/client.d.ts +40 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +108 -0
- package/dist/client.test.d.ts +2 -0
- package/dist/client.test.d.ts.map +1 -0
- package/dist/client.test.js +108 -0
- package/dist/conversation-store.d.ts +23 -0
- package/dist/conversation-store.d.ts.map +1 -0
- package/dist/conversation-store.js +41 -0
- package/dist/conversation-store.test.d.ts +2 -0
- package/dist/conversation-store.test.d.ts.map +1 -0
- package/dist/conversation-store.test.js +40 -0
- package/dist/download-files.d.ts +44 -0
- package/dist/download-files.d.ts.map +1 -0
- package/dist/download-files.js +66 -0
- package/dist/download-files.test.d.ts +2 -0
- package/dist/download-files.test.d.ts.map +1 -0
- package/dist/download-files.test.js +34 -0
- package/dist/event-renderer.d.ts +18 -0
- package/dist/event-renderer.d.ts.map +1 -0
- package/dist/event-renderer.js +110 -0
- package/dist/event-renderer.test.d.ts +2 -0
- package/dist/event-renderer.test.d.ts.map +1 -0
- package/dist/event-renderer.test.js +68 -0
- package/dist/history-store.d.ts +36 -0
- package/dist/history-store.d.ts.map +1 -0
- package/dist/history-store.js +17 -0
- package/dist/history-store.test.d.ts +2 -0
- package/dist/history-store.test.d.ts.map +1 -0
- package/dist/history-store.test.js +24 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/interaction.d.ts +19 -0
- package/dist/interaction.d.ts.map +1 -0
- package/dist/interaction.js +56 -0
- package/dist/interaction.test.d.ts +2 -0
- package/dist/interaction.test.d.ts.map +1 -0
- package/dist/interaction.test.js +67 -0
- package/dist/markdown-to-wa.d.ts +15 -0
- package/dist/markdown-to-wa.d.ts.map +1 -0
- package/dist/markdown-to-wa.js +56 -0
- package/dist/markdown-to-wa.test.d.ts +2 -0
- package/dist/markdown-to-wa.test.d.ts.map +1 -0
- package/dist/markdown-to-wa.test.js +29 -0
- package/dist/render/budget.d.ts +34 -0
- package/dist/render/budget.d.ts.map +1 -0
- package/dist/render/budget.js +40 -0
- package/dist/render/budget.test.d.ts +2 -0
- package/dist/render/budget.test.d.ts.map +1 -0
- package/dist/render/budget.test.js +24 -0
- package/dist/render/message.d.ts +74 -0
- package/dist/render/message.d.ts.map +1 -0
- package/dist/render/message.js +247 -0
- package/dist/render/message.test.d.ts +2 -0
- package/dist/render/message.test.d.ts.map +1 -0
- package/dist/render/message.test.js +184 -0
- package/dist/types.d.ts +107 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/dist/webhook-listener.d.ts +16 -0
- package/dist/webhook-listener.d.ts.map +1 -0
- package/dist/webhook-listener.js +128 -0
- package/dist/webhook-listener.test.d.ts +2 -0
- package/dist/webhook-listener.test.d.ts.map +1 -0
- package/dist/webhook-listener.test.js +204 -0
- package/dist/webhook-server.d.ts +23 -0
- package/dist/webhook-server.d.ts.map +1 -0
- package/dist/webhook-server.js +99 -0
- package/dist/webhook-server.test.d.ts +2 -0
- package/dist/webhook-server.test.d.ts.map +1 -0
- package/dist/webhook-server.test.js +150 -0
- 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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"}
|
package/dist/adapter.js
ADDED
|
@@ -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 @@
|
|
|
1
|
+
{"version":3,"file":"adapter.test.d.ts","sourceRoot":"","sources":["../src/adapter.test.ts"],"names":[],"mappings":""}
|