@laburen/openclaw-plugin-whatsapp-api 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 +123 -0
- package/index.ts +16 -0
- package/openclaw.plugin.json +43 -0
- package/package.json +21 -0
- package/src/accounts.ts +86 -0
- package/src/channel.ts +246 -0
- package/src/inbound.ts +126 -0
- package/src/outbound.ts +126 -0
- package/src/runtime.ts +18 -0
- package/src/types.ts +32 -0
- package/src/webhook.ts +119 -0
package/README.md
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# WhatsApp API Plugin for OpenClaw
|
|
2
|
+
|
|
3
|
+
WhatsApp Cloud API channel for OpenClaw: your router receives Meta webhooks and forwards them to OpenClaw; replies go straight to the Meta Graph API.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
openclaw plugins install @laburen/openclaw-plugin-whatsapp-api
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
If you install from a local folder instead, copy the package into your OpenClaw extensions root and enable it in config (same plugin id: `whatsapp-api`).
|
|
12
|
+
|
|
13
|
+
## Setup
|
|
14
|
+
|
|
15
|
+
1. In [Meta for Developers](https://developers.facebook.com/), create or open an app with **WhatsApp** product enabled and note your **Phone number ID** and a long-lived **System User** or **Temporary** access token with `whatsapp_business_messaging` (and webhook permissions as required by your setup).
|
|
16
|
+
2. Point Meta’s webhook callback URL to **your** public HTTPS endpoint (router / reverse proxy). That service should forward the raw `POST` body to OpenClaw at the path you set as `webhookPath` (default below).
|
|
17
|
+
3. Set the same verify token / shared secret flow your deployment uses: configure `inboundSharedSecret` in OpenClaw and send it from your router as the header `x-openclaw-shared-secret` (recommended).
|
|
18
|
+
|
|
19
|
+
### Via OpenClaw Config
|
|
20
|
+
|
|
21
|
+
Minimal example — set secrets and IDs (use env-backed config in production; never commit tokens):
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
openclaw config set channels.whatsapp-api.enabled true
|
|
25
|
+
openclaw config set channels.whatsapp-api.inboundSharedSecret "your-internal-secret"
|
|
26
|
+
openclaw config set channels.whatsapp-api.outboundPhoneNumberId "123456789012345"
|
|
27
|
+
openclaw config set channels.whatsapp-api.outboundAccessToken "EAAG..."
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Allow the plugin and enable its entry, then restart the gateway:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
openclaw config set plugins.allow '["whatsapp-api"]'
|
|
34
|
+
openclaw config set plugins.entries.whatsapp-api.enabled true
|
|
35
|
+
openclaw gateway restart
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## How It Works
|
|
39
|
+
|
|
40
|
+
- Registers channel id **`whatsapp-api`** and exposes an inbound **`POST`** route (default **`/webhook/whatsapp-api`**).
|
|
41
|
+
- Parses WhatsApp Cloud API webhook payloads (`entry[].changes[].value.messages[]`) and sends inbound text into the OpenClaw reply pipeline.
|
|
42
|
+
- Outbound replies are sent **directly** to Meta:
|
|
43
|
+
|
|
44
|
+
`https://graph.facebook.com/{apiVersion}/{phoneNumberId}/messages`
|
|
45
|
+
|
|
46
|
+
with `Authorization: Bearer <outboundAccessToken>`.
|
|
47
|
+
|
|
48
|
+
- Transient failures (`429`, `5xx`, timeouts) are retried according to `maxRetries`, `retryBackoffMs`, and `requestTimeoutMs`.
|
|
49
|
+
|
|
50
|
+
**Inbound webhook (from your router to OpenClaw)**
|
|
51
|
+
|
|
52
|
+
| Item | Detail |
|
|
53
|
+
|------|--------|
|
|
54
|
+
| Method | `POST` |
|
|
55
|
+
| Path | Value of `webhookPath` |
|
|
56
|
+
| Header (recommended) | `x-openclaw-shared-secret: <inboundSharedSecret>` |
|
|
57
|
+
| Body | Raw WhatsApp Cloud webhook JSON forwarded by your router |
|
|
58
|
+
|
|
59
|
+
Responses: **`403`** if the shared secret does not match, **`400`** if the payload is invalid, **`200`** when accepted (processing continues asynchronously after ACK).
|
|
60
|
+
|
|
61
|
+
## Configuration
|
|
62
|
+
|
|
63
|
+
```json
|
|
64
|
+
{
|
|
65
|
+
"plugins": {
|
|
66
|
+
"allow": ["whatsapp-api"],
|
|
67
|
+
"entries": {
|
|
68
|
+
"whatsapp-api": {
|
|
69
|
+
"enabled": true
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
"channels": {
|
|
74
|
+
"whatsapp-api": {
|
|
75
|
+
"enabled": true,
|
|
76
|
+
"webhookPath": "/webhook/whatsapp-api",
|
|
77
|
+
"inboundSharedSecret": "replace-with-internal-secret",
|
|
78
|
+
"outboundPhoneNumberId": "123456789012345",
|
|
79
|
+
"outboundAccessToken": "EAAG...",
|
|
80
|
+
"outboundApiVersion": "v22.0",
|
|
81
|
+
"requestTimeoutMs": 10000,
|
|
82
|
+
"maxRetries": 2,
|
|
83
|
+
"retryBackoffMs": 500,
|
|
84
|
+
"dedupeTtlMs": 300000,
|
|
85
|
+
"maxBodyBytes": 524288,
|
|
86
|
+
"accounts": {
|
|
87
|
+
"default": {
|
|
88
|
+
"enabled": true
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
| Option | Description | Default |
|
|
97
|
+
|--------|-------------|---------|
|
|
98
|
+
| `plugins.entries.whatsapp-api.enabled` | Turn the plugin on or off | — |
|
|
99
|
+
| `channels.whatsapp-api.enabled` | Turn the channel on or off | — |
|
|
100
|
+
| `webhookPath` | HTTP path OpenClaw listens on for inbound webhooks | `/webhook/whatsapp-api` |
|
|
101
|
+
| `inboundSharedSecret` | Secret your router must send (e.g. header `x-openclaw-shared-secret`) | — |
|
|
102
|
+
| `outboundPhoneNumberId` | WhatsApp **Phone number ID** from Meta | — |
|
|
103
|
+
| `outboundAccessToken` | Bearer token for Graph API sends | — |
|
|
104
|
+
| `outboundApiVersion` | Graph API version segment in the URL | e.g. `v22.0` |
|
|
105
|
+
| `requestTimeoutMs` | HTTP timeout for outbound calls (ms) | `10000` |
|
|
106
|
+
| `maxRetries` | Retries on transient errors | `2` |
|
|
107
|
+
| `retryBackoffMs` | Base backoff between retries (ms) | `500` |
|
|
108
|
+
| `dedupeTtlMs` | Deduplication window for inbound events (ms) | `300000` |
|
|
109
|
+
| `maxBodyBytes` | Max accepted webhook body size (bytes) | `524288` |
|
|
110
|
+
| `accounts` | Per-account overrides (multi-tenant); keys are account ids | `{ "default": { "enabled": true } }` |
|
|
111
|
+
|
|
112
|
+
Per-account fields can override phone id, token, retries, etc. under `channels["whatsapp-api"].accounts.<accountId>`.
|
|
113
|
+
|
|
114
|
+
## Notes
|
|
115
|
+
|
|
116
|
+
- Outbound mode is **direct-to-Meta** only (no third-party relay in this plugin).
|
|
117
|
+
- Keep tokens out of logs and rotate them on a schedule.
|
|
118
|
+
|
|
119
|
+
## Links
|
|
120
|
+
|
|
121
|
+
- [WhatsApp Cloud API — Overview](https://developers.facebook.com/docs/whatsapp/cloud-api)
|
|
122
|
+
- [Graph API — WhatsApp messages](https://developers.facebook.com/docs/whatsapp/cloud-api/guides/send-messages)
|
|
123
|
+
- [OpenClaw](https://github.com/openclaw/openclaw) (ecosystem reference)
|
package/index.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
|
+
import { createWhatsAppApiChannel } from "./src/channel.js";
|
|
3
|
+
import { setPluginApi } from "./src/runtime.js";
|
|
4
|
+
|
|
5
|
+
const plugin = {
|
|
6
|
+
id: "whatsapp-api",
|
|
7
|
+
name: "WhatsApp API",
|
|
8
|
+
description: "WhatsApp API channel plugin with inbound webhook and direct Meta outbound",
|
|
9
|
+
register(api: OpenClawPluginApi) {
|
|
10
|
+
setPluginApi(api);
|
|
11
|
+
api.registerChannel({ plugin: createWhatsAppApiChannel(api) });
|
|
12
|
+
api.logger.info("[whatsapp-api] plugin registered");
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export default plugin;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "whatsapp-api",
|
|
3
|
+
"name": "WhatsApp API (Minimal)",
|
|
4
|
+
"description": "WhatsApp API channel plugin with inbound webhook and direct Meta outbound",
|
|
5
|
+
"channels": [
|
|
6
|
+
"whatsapp-api"
|
|
7
|
+
],
|
|
8
|
+
"configSchema": {
|
|
9
|
+
"type": "object",
|
|
10
|
+
"additionalProperties": false,
|
|
11
|
+
"properties": {
|
|
12
|
+
"defaults": {
|
|
13
|
+
"type": "object",
|
|
14
|
+
"additionalProperties": false,
|
|
15
|
+
"properties": {
|
|
16
|
+
"outboundApiVersion": {
|
|
17
|
+
"type": "string"
|
|
18
|
+
},
|
|
19
|
+
"requestTimeoutMs": {
|
|
20
|
+
"type": "number",
|
|
21
|
+
"minimum": 100
|
|
22
|
+
},
|
|
23
|
+
"maxRetries": {
|
|
24
|
+
"type": "number",
|
|
25
|
+
"minimum": 0
|
|
26
|
+
},
|
|
27
|
+
"retryBackoffMs": {
|
|
28
|
+
"type": "number",
|
|
29
|
+
"minimum": 0
|
|
30
|
+
},
|
|
31
|
+
"dedupeTtlMs": {
|
|
32
|
+
"type": "number",
|
|
33
|
+
"minimum": 1000
|
|
34
|
+
},
|
|
35
|
+
"maxBodyBytes": {
|
|
36
|
+
"type": "number",
|
|
37
|
+
"minimum": 1024
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@laburen/openclaw-plugin-whatsapp-api",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "WhatsApp API channel plugin for OpenClaw",
|
|
6
|
+
"files": [
|
|
7
|
+
"index.ts",
|
|
8
|
+
"src",
|
|
9
|
+
"openclaw.plugin.json",
|
|
10
|
+
"README.md"
|
|
11
|
+
],
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"devDependencies": {
|
|
14
|
+
"openclaw": "*"
|
|
15
|
+
},
|
|
16
|
+
"openclaw": {
|
|
17
|
+
"extensions": [
|
|
18
|
+
"./index.ts"
|
|
19
|
+
]
|
|
20
|
+
}
|
|
21
|
+
}
|
package/src/accounts.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { ResolvedWhatsAppApiAccount } from "./types.js";
|
|
2
|
+
|
|
3
|
+
const CHANNEL_ID = "whatsapp-api";
|
|
4
|
+
const DEFAULT_ACCOUNT_ID = "default";
|
|
5
|
+
const DEFAULT_WEBHOOK_PATH = "/webhook/whatsapp-api";
|
|
6
|
+
const DEFAULT_API_VERSION = "v22.0";
|
|
7
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 10_000;
|
|
8
|
+
const DEFAULT_MAX_RETRIES = 2;
|
|
9
|
+
const DEFAULT_RETRY_BACKOFF_MS = 500;
|
|
10
|
+
const DEFAULT_DEDUPE_TTL_MS = 5 * 60_000;
|
|
11
|
+
const DEFAULT_MAX_BODY_BYTES = 512 * 1024;
|
|
12
|
+
|
|
13
|
+
function getChannelConfig(cfg: Record<string, unknown>): Record<string, unknown> {
|
|
14
|
+
const channels = (cfg.channels as Record<string, unknown> | undefined) ?? {};
|
|
15
|
+
return (channels[CHANNEL_ID] as Record<string, unknown> | undefined) ?? {};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function listAccountIds(cfg: Record<string, unknown>): string[] {
|
|
19
|
+
const channelCfg = getChannelConfig(cfg);
|
|
20
|
+
const ids = new Set<string>();
|
|
21
|
+
const accounts =
|
|
22
|
+
(channelCfg.accounts as Record<string, Record<string, unknown>> | undefined) ?? {};
|
|
23
|
+
|
|
24
|
+
if (Object.keys(accounts).length > 0) {
|
|
25
|
+
for (const id of Object.keys(accounts)) {
|
|
26
|
+
ids.add(id);
|
|
27
|
+
}
|
|
28
|
+
} else {
|
|
29
|
+
ids.add(DEFAULT_ACCOUNT_ID);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return [...ids];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function resolveAccount(
|
|
36
|
+
cfg: Record<string, unknown>,
|
|
37
|
+
accountId?: string | null,
|
|
38
|
+
): ResolvedWhatsAppApiAccount {
|
|
39
|
+
const channelCfg = getChannelConfig(cfg);
|
|
40
|
+
const id = accountId ?? DEFAULT_ACCOUNT_ID;
|
|
41
|
+
const accounts =
|
|
42
|
+
(channelCfg.accounts as Record<string, Record<string, unknown>> | undefined) ?? {};
|
|
43
|
+
const accountCfg = accounts[id] ?? {};
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
accountId: id,
|
|
47
|
+
enabled: (accountCfg.enabled as boolean | undefined) ?? true,
|
|
48
|
+
webhookPath:
|
|
49
|
+
(accountCfg.webhookPath as string | undefined) ??
|
|
50
|
+
(channelCfg.webhookPath as string | undefined) ??
|
|
51
|
+
DEFAULT_WEBHOOK_PATH,
|
|
52
|
+
inboundSharedSecret:
|
|
53
|
+
(accountCfg.inboundSharedSecret as string | undefined) ??
|
|
54
|
+
(channelCfg.inboundSharedSecret as string | undefined),
|
|
55
|
+
outboundPhoneNumberId:
|
|
56
|
+
(accountCfg.outboundPhoneNumberId as string | undefined) ??
|
|
57
|
+
(channelCfg.outboundPhoneNumberId as string | undefined),
|
|
58
|
+
outboundAccessToken:
|
|
59
|
+
(accountCfg.outboundAccessToken as string | undefined) ??
|
|
60
|
+
(channelCfg.outboundAccessToken as string | undefined),
|
|
61
|
+
outboundApiVersion:
|
|
62
|
+
(accountCfg.outboundApiVersion as string | undefined) ??
|
|
63
|
+
(channelCfg.outboundApiVersion as string | undefined) ??
|
|
64
|
+
DEFAULT_API_VERSION,
|
|
65
|
+
requestTimeoutMs:
|
|
66
|
+
(accountCfg.requestTimeoutMs as number | undefined) ??
|
|
67
|
+
(channelCfg.requestTimeoutMs as number | undefined) ??
|
|
68
|
+
DEFAULT_REQUEST_TIMEOUT_MS,
|
|
69
|
+
maxRetries:
|
|
70
|
+
(accountCfg.maxRetries as number | undefined) ??
|
|
71
|
+
(channelCfg.maxRetries as number | undefined) ??
|
|
72
|
+
DEFAULT_MAX_RETRIES,
|
|
73
|
+
retryBackoffMs:
|
|
74
|
+
(accountCfg.retryBackoffMs as number | undefined) ??
|
|
75
|
+
(channelCfg.retryBackoffMs as number | undefined) ??
|
|
76
|
+
DEFAULT_RETRY_BACKOFF_MS,
|
|
77
|
+
dedupeTtlMs:
|
|
78
|
+
(accountCfg.dedupeTtlMs as number | undefined) ??
|
|
79
|
+
(channelCfg.dedupeTtlMs as number | undefined) ??
|
|
80
|
+
DEFAULT_DEDUPE_TTL_MS,
|
|
81
|
+
maxBodyBytes:
|
|
82
|
+
(accountCfg.maxBodyBytes as number | undefined) ??
|
|
83
|
+
(channelCfg.maxBodyBytes as number | undefined) ??
|
|
84
|
+
DEFAULT_MAX_BODY_BYTES,
|
|
85
|
+
};
|
|
86
|
+
}
|
package/src/channel.ts
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
|
+
import { listAccountIds, resolveAccount } from "./accounts.js";
|
|
3
|
+
import { sendWhatsAppApiText, normalizeReplyText } from "./outbound.js";
|
|
4
|
+
import { getPluginRuntime } from "./runtime.js";
|
|
5
|
+
import { handleWhatsAppApiWebhook } from "./webhook.js";
|
|
6
|
+
import type { WhatsAppApiInboundMessage } from "./types.js";
|
|
7
|
+
|
|
8
|
+
const CHANNEL_ID = "whatsapp-api";
|
|
9
|
+
|
|
10
|
+
function waitUntilAbort(signal?: AbortSignal, onAbort?: () => void): Promise<void> {
|
|
11
|
+
return new Promise((resolve) => {
|
|
12
|
+
const done = () => {
|
|
13
|
+
onAbort?.();
|
|
14
|
+
resolve();
|
|
15
|
+
};
|
|
16
|
+
if (!signal) {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
if (signal.aborted) {
|
|
20
|
+
done();
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
signal.addEventListener("abort", done, { once: true });
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function buildInboundBody(rt: any, message: WhatsAppApiInboundMessage): string {
|
|
28
|
+
if (typeof rt?.channel?.reply?.formatInboundEnvelope === "function") {
|
|
29
|
+
return rt.channel.reply.formatInboundEnvelope({
|
|
30
|
+
channel: "WhatsApp API",
|
|
31
|
+
from: message.senderName || message.from,
|
|
32
|
+
timestamp: message.timestamp,
|
|
33
|
+
body: message.text,
|
|
34
|
+
chatType: "direct",
|
|
35
|
+
sender: {
|
|
36
|
+
name: message.senderName,
|
|
37
|
+
id: message.from,
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
return message.text;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function dispatchInboundToAgent(params: {
|
|
45
|
+
accountId: string;
|
|
46
|
+
message: WhatsAppApiInboundMessage;
|
|
47
|
+
log?: { info?: (msg: string) => void; error?: (msg: string) => void };
|
|
48
|
+
onOutbound?: () => void;
|
|
49
|
+
}): Promise<void> {
|
|
50
|
+
const { accountId, message, log, onOutbound } = params;
|
|
51
|
+
const rt = getPluginRuntime() as any;
|
|
52
|
+
const cfg = await rt.config.loadConfig();
|
|
53
|
+
const route = rt.channel.routing.resolveAgentRoute({
|
|
54
|
+
cfg,
|
|
55
|
+
channel: CHANNEL_ID,
|
|
56
|
+
accountId,
|
|
57
|
+
peer: {
|
|
58
|
+
kind: "direct",
|
|
59
|
+
id: message.from,
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
const body = buildInboundBody(rt, message);
|
|
63
|
+
const to = `whatsapp-api:${message.from}`;
|
|
64
|
+
const ctxPayload = rt.channel.reply.finalizeInboundContext({
|
|
65
|
+
Body: body,
|
|
66
|
+
BodyForAgent: message.text,
|
|
67
|
+
RawBody: message.text,
|
|
68
|
+
CommandBody: message.text,
|
|
69
|
+
From: `whatsapp-api:${message.from}`,
|
|
70
|
+
To: to,
|
|
71
|
+
SessionKey: route.sessionKey,
|
|
72
|
+
AccountId: route.accountId,
|
|
73
|
+
ChatType: "direct",
|
|
74
|
+
ConversationLabel: message.senderName || message.from,
|
|
75
|
+
SenderName: message.senderName,
|
|
76
|
+
SenderId: message.from,
|
|
77
|
+
Provider: CHANNEL_ID,
|
|
78
|
+
Surface: CHANNEL_ID,
|
|
79
|
+
MessageSid: message.messageId,
|
|
80
|
+
ReplyToId: message.replyToId,
|
|
81
|
+
OriginatingChannel: CHANNEL_ID,
|
|
82
|
+
OriginatingTo: to,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
86
|
+
ctx: ctxPayload,
|
|
87
|
+
cfg,
|
|
88
|
+
dispatcherOptions: {
|
|
89
|
+
deliver: async (payload: unknown) => {
|
|
90
|
+
const text = normalizeReplyText(payload);
|
|
91
|
+
if (!text) {
|
|
92
|
+
log?.info?.(
|
|
93
|
+
`[${CHANNEL_ID}] skipping outbound: reply text empty for messageId=${message.messageId}`,
|
|
94
|
+
);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
log?.info?.(
|
|
98
|
+
`[${CHANNEL_ID}] sending reply to ${message.from}, length=${text.length}`,
|
|
99
|
+
);
|
|
100
|
+
const account = resolveAccount(cfg, accountId);
|
|
101
|
+
const messageId = await sendWhatsAppApiText({
|
|
102
|
+
to: message.from,
|
|
103
|
+
text,
|
|
104
|
+
account,
|
|
105
|
+
});
|
|
106
|
+
log?.info?.(
|
|
107
|
+
`[${CHANNEL_ID}] reply sent to ${message.from}, messageId=${messageId}`,
|
|
108
|
+
);
|
|
109
|
+
onOutbound?.();
|
|
110
|
+
},
|
|
111
|
+
onError: (err: unknown, info: { kind?: string }) => {
|
|
112
|
+
log?.error?.(
|
|
113
|
+
`[${CHANNEL_ID}] ${info?.kind ?? "reply"} dispatch failed for account=${accountId} messageId=${message.messageId}: ${String(err)}`,
|
|
114
|
+
);
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function createWhatsAppApiChannel(api: OpenClawPluginApi) {
|
|
121
|
+
return {
|
|
122
|
+
id: CHANNEL_ID,
|
|
123
|
+
meta: {
|
|
124
|
+
id: CHANNEL_ID,
|
|
125
|
+
label: "WhatsApp API",
|
|
126
|
+
selectionLabel: "WhatsApp API (Cloud)",
|
|
127
|
+
detailLabel: "WhatsApp API (Cloud)",
|
|
128
|
+
docsPath: "/channels/whatsapp-api",
|
|
129
|
+
blurb: "WhatsApp Cloud API channel with router-fed inbound webhook",
|
|
130
|
+
order: 5,
|
|
131
|
+
},
|
|
132
|
+
capabilities: {
|
|
133
|
+
chatTypes: ["direct" as const],
|
|
134
|
+
media: false,
|
|
135
|
+
threads: false,
|
|
136
|
+
reactions: false,
|
|
137
|
+
edit: false,
|
|
138
|
+
unsend: false,
|
|
139
|
+
reply: false,
|
|
140
|
+
effects: false,
|
|
141
|
+
blockStreaming: false,
|
|
142
|
+
},
|
|
143
|
+
config: {
|
|
144
|
+
listAccountIds: (cfg: Record<string, unknown>) => listAccountIds(cfg),
|
|
145
|
+
resolveAccount: (cfg: Record<string, unknown>, accountId?: string | null) =>
|
|
146
|
+
resolveAccount(cfg, accountId),
|
|
147
|
+
defaultAccountId: () => "default",
|
|
148
|
+
},
|
|
149
|
+
outbound: {
|
|
150
|
+
deliveryMode: "gateway" as const,
|
|
151
|
+
sendText: async ({
|
|
152
|
+
to,
|
|
153
|
+
text,
|
|
154
|
+
cfg,
|
|
155
|
+
accountId,
|
|
156
|
+
}: {
|
|
157
|
+
to: string;
|
|
158
|
+
text: string;
|
|
159
|
+
cfg: Record<string, unknown>;
|
|
160
|
+
accountId?: string;
|
|
161
|
+
}) => {
|
|
162
|
+
const account = resolveAccount(cfg, accountId ?? null);
|
|
163
|
+
const messageId = await sendWhatsAppApiText({
|
|
164
|
+
to,
|
|
165
|
+
text,
|
|
166
|
+
account,
|
|
167
|
+
});
|
|
168
|
+
return {
|
|
169
|
+
channel: CHANNEL_ID,
|
|
170
|
+
chatId: to,
|
|
171
|
+
messageId,
|
|
172
|
+
};
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
gateway: {
|
|
176
|
+
startAccount: async (ctx: any) => {
|
|
177
|
+
const account = resolveAccount(ctx.cfg, ctx.accountId);
|
|
178
|
+
|
|
179
|
+
if (!account.enabled) {
|
|
180
|
+
ctx.log?.info?.(`[${CHANNEL_ID}] account ${account.accountId} disabled, skipping route`);
|
|
181
|
+
return waitUntilAbort(ctx.abortSignal);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const routePath = account.webhookPath;
|
|
185
|
+
ctx.setStatus?.({
|
|
186
|
+
accountId: account.accountId,
|
|
187
|
+
running: true,
|
|
188
|
+
lastStartAt: Date.now(),
|
|
189
|
+
lastError: null,
|
|
190
|
+
});
|
|
191
|
+
api.registerHttpRoute({
|
|
192
|
+
path: routePath,
|
|
193
|
+
auth: "plugin",
|
|
194
|
+
replaceExisting: true,
|
|
195
|
+
handler: async (req, res) => {
|
|
196
|
+
return await handleWhatsAppApiWebhook({
|
|
197
|
+
req,
|
|
198
|
+
res,
|
|
199
|
+
account,
|
|
200
|
+
log: ctx.log,
|
|
201
|
+
onMessage: async (message) => {
|
|
202
|
+
ctx.setStatus?.({
|
|
203
|
+
accountId: account.accountId,
|
|
204
|
+
lastInboundAt: Date.now(),
|
|
205
|
+
});
|
|
206
|
+
try {
|
|
207
|
+
await dispatchInboundToAgent({
|
|
208
|
+
accountId: account.accountId,
|
|
209
|
+
message,
|
|
210
|
+
log: ctx.log,
|
|
211
|
+
onOutbound: () => {
|
|
212
|
+
ctx.setStatus?.({
|
|
213
|
+
accountId: account.accountId,
|
|
214
|
+
lastOutboundAt: Date.now(),
|
|
215
|
+
});
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
} catch (err) {
|
|
219
|
+
ctx.setStatus?.({
|
|
220
|
+
accountId: account.accountId,
|
|
221
|
+
lastError: String(err),
|
|
222
|
+
});
|
|
223
|
+
throw err;
|
|
224
|
+
}
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
ctx.log?.info?.(`[${CHANNEL_ID}] registered HTTP route: ${routePath}`);
|
|
231
|
+
|
|
232
|
+
return waitUntilAbort(ctx.abortSignal, () => {
|
|
233
|
+
ctx.log?.info?.(`[${CHANNEL_ID}] stopped account ${account.accountId}`);
|
|
234
|
+
ctx.setStatus?.({
|
|
235
|
+
accountId: account.accountId,
|
|
236
|
+
running: false,
|
|
237
|
+
lastStopAt: Date.now(),
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
},
|
|
241
|
+
stopAccount: async (ctx: { accountId: string; log?: { info?: (msg: string) => void } }) => {
|
|
242
|
+
ctx.log?.info?.(`[${CHANNEL_ID}] stopAccount called for ${ctx.accountId}`);
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
}
|
package/src/inbound.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type { WhatsAppApiInboundMessage } from "./types.js";
|
|
2
|
+
|
|
3
|
+
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
4
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
5
|
+
return null;
|
|
6
|
+
}
|
|
7
|
+
return value as Record<string, unknown>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function asArray(value: unknown): unknown[] {
|
|
11
|
+
return Array.isArray(value) ? value : [];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function readTextPayload(msg: Record<string, unknown>): string {
|
|
15
|
+
const msgType = typeof msg.type === "string" ? msg.type : "";
|
|
16
|
+
if (msgType === "text") {
|
|
17
|
+
const text = asRecord(msg.text);
|
|
18
|
+
return typeof text?.body === "string" ? text.body.trim() : "";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (msgType === "image") return "<media:image>";
|
|
22
|
+
if (msgType === "video") return "<media:video>";
|
|
23
|
+
if (msgType === "audio") return "<media:audio>";
|
|
24
|
+
if (msgType === "document") return "<media:document>";
|
|
25
|
+
if (msgType === "sticker") return "<media:sticker>";
|
|
26
|
+
if (msgType === "location") return "<location>";
|
|
27
|
+
if (msgType === "contacts") return "<contacts>";
|
|
28
|
+
if (msgType === "reaction") return "<reaction>";
|
|
29
|
+
if (msgType === "button") return "<button>";
|
|
30
|
+
if (msgType === "interactive") return "<interactive>";
|
|
31
|
+
|
|
32
|
+
return "";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function parseWhatsAppCloudInbound(params: {
|
|
36
|
+
payload: unknown;
|
|
37
|
+
accountId: string;
|
|
38
|
+
}): WhatsAppApiInboundMessage[] {
|
|
39
|
+
const root = asRecord(params.payload);
|
|
40
|
+
if (!root) {
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const result: WhatsAppApiInboundMessage[] = [];
|
|
45
|
+
const entries = asArray(root.entry);
|
|
46
|
+
for (const entryRaw of entries) {
|
|
47
|
+
const entry = asRecord(entryRaw);
|
|
48
|
+
if (!entry) {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const changes = asArray(entry.changes);
|
|
53
|
+
for (const changeRaw of changes) {
|
|
54
|
+
const change = asRecord(changeRaw);
|
|
55
|
+
if (!change) {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (change.field !== "messages") {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const value = asRecord(change.value);
|
|
63
|
+
if (!value) {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const metadata = asRecord(value.metadata) ?? {};
|
|
67
|
+
const to =
|
|
68
|
+
(typeof metadata.display_phone_number === "string" &&
|
|
69
|
+
metadata.display_phone_number.trim()) ||
|
|
70
|
+
(typeof metadata.phone_number_id === "string" && metadata.phone_number_id.trim()) ||
|
|
71
|
+
"";
|
|
72
|
+
|
|
73
|
+
const contactNames = new Map<string, string>();
|
|
74
|
+
for (const contactRaw of asArray(value.contacts)) {
|
|
75
|
+
const contact = asRecord(contactRaw);
|
|
76
|
+
if (!contact) {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const waId = typeof contact.wa_id === "string" ? contact.wa_id.trim() : "";
|
|
80
|
+
const profile = asRecord(contact.profile);
|
|
81
|
+
const name = typeof profile?.name === "string" ? profile.name.trim() : "";
|
|
82
|
+
if (waId && name) {
|
|
83
|
+
contactNames.set(waId, name);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const messages = asArray(value.messages);
|
|
88
|
+
for (const messageRaw of messages) {
|
|
89
|
+
const msg = asRecord(messageRaw);
|
|
90
|
+
if (!msg) {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const from = typeof msg.from === "string" ? msg.from.trim() : "";
|
|
94
|
+
const messageId = typeof msg.id === "string" ? msg.id.trim() : "";
|
|
95
|
+
if (!from || !messageId) {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const text = readTextPayload(msg);
|
|
100
|
+
if (!text) {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const context = asRecord(msg.context);
|
|
105
|
+
const replyToId = typeof context?.id === "string" ? context.id.trim() : undefined;
|
|
106
|
+
const timestampRaw = typeof msg.timestamp === "string" ? Number(msg.timestamp) : NaN;
|
|
107
|
+
const timestamp =
|
|
108
|
+
Number.isFinite(timestampRaw) && timestampRaw > 0 ? timestampRaw * 1000 : undefined;
|
|
109
|
+
|
|
110
|
+
result.push({
|
|
111
|
+
accountId: params.accountId,
|
|
112
|
+
from,
|
|
113
|
+
to,
|
|
114
|
+
messageId,
|
|
115
|
+
text,
|
|
116
|
+
chatType: "direct",
|
|
117
|
+
timestamp,
|
|
118
|
+
senderName: contactNames.get(from),
|
|
119
|
+
replyToId,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return result;
|
|
126
|
+
}
|
package/src/outbound.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ResolvedWhatsAppApiAccount,
|
|
3
|
+
WhatsAppApiSendTextParams,
|
|
4
|
+
} from "./types.js";
|
|
5
|
+
|
|
6
|
+
function sleep(ms: number): Promise<void> {
|
|
7
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function normalizeRecipient(to: string): string {
|
|
11
|
+
const trimmed = to.trim();
|
|
12
|
+
if (trimmed.startsWith("+")) {
|
|
13
|
+
return trimmed.slice(1);
|
|
14
|
+
}
|
|
15
|
+
return trimmed;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function readGraphError(bodyText: string): string | null {
|
|
19
|
+
try {
|
|
20
|
+
const parsed = JSON.parse(bodyText) as { error?: { message?: string } };
|
|
21
|
+
return parsed.error?.message ?? null;
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isRetryableStatus(status: number): boolean {
|
|
28
|
+
return status === 429 || status >= 500;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function withTimeout(input: RequestInfo | URL, init: RequestInit, timeoutMs: number) {
|
|
32
|
+
const controller = new AbortController();
|
|
33
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
34
|
+
try {
|
|
35
|
+
return await fetch(input, {
|
|
36
|
+
...init,
|
|
37
|
+
signal: controller.signal,
|
|
38
|
+
});
|
|
39
|
+
} finally {
|
|
40
|
+
clearTimeout(timer);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function sendWhatsAppApiText(params: WhatsAppApiSendTextParams): Promise<string> {
|
|
45
|
+
const account = params.account;
|
|
46
|
+
const phoneNumberId = account.outboundPhoneNumberId?.trim();
|
|
47
|
+
const accessToken = account.outboundAccessToken?.trim();
|
|
48
|
+
if (!phoneNumberId || !accessToken) {
|
|
49
|
+
throw new Error("Missing outboundPhoneNumberId/outboundAccessToken in whatsapp-api account config");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const recipient = normalizeRecipient(params.to);
|
|
53
|
+
if (!recipient) {
|
|
54
|
+
throw new Error("Recipient id is empty");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const endpoint = `https://graph.facebook.com/${account.outboundApiVersion}/${phoneNumberId}/messages`;
|
|
58
|
+
const payload = {
|
|
59
|
+
messaging_product: "whatsapp",
|
|
60
|
+
to: recipient,
|
|
61
|
+
type: "text",
|
|
62
|
+
text: { body: params.text },
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
let lastErr: unknown = null;
|
|
66
|
+
const maxAttempts = Math.max(1, account.maxRetries + 1);
|
|
67
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
68
|
+
try {
|
|
69
|
+
const res = await withTimeout(
|
|
70
|
+
endpoint,
|
|
71
|
+
{
|
|
72
|
+
method: "POST",
|
|
73
|
+
headers: {
|
|
74
|
+
authorization: `Bearer ${accessToken}`,
|
|
75
|
+
"content-type": "application/json",
|
|
76
|
+
},
|
|
77
|
+
body: JSON.stringify(payload),
|
|
78
|
+
},
|
|
79
|
+
account.requestTimeoutMs,
|
|
80
|
+
);
|
|
81
|
+
const bodyText = await res.text();
|
|
82
|
+
if (!res.ok) {
|
|
83
|
+
const detail = readGraphError(bodyText);
|
|
84
|
+
const err = new Error(
|
|
85
|
+
`Meta API send failed: HTTP ${res.status}${detail ? ` - ${detail}` : ""}`,
|
|
86
|
+
);
|
|
87
|
+
if (!isRetryableStatus(res.status)) {
|
|
88
|
+
throw err;
|
|
89
|
+
}
|
|
90
|
+
lastErr = err;
|
|
91
|
+
} else {
|
|
92
|
+
const parsed = JSON.parse(bodyText) as { messages?: Array<{ id?: string }> };
|
|
93
|
+
const messageId = parsed.messages?.[0]?.id;
|
|
94
|
+
return messageId && messageId.trim() ? messageId.trim() : "unknown";
|
|
95
|
+
}
|
|
96
|
+
} catch (err) {
|
|
97
|
+
lastErr = err;
|
|
98
|
+
if (attempt >= maxAttempts) {
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
await sleep(account.retryBackoffMs * attempt);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (attempt >= maxAttempts) {
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
await sleep(account.retryBackoffMs * attempt);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr ?? "Unknown outbound error"));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function normalizeReplyText(payload: unknown): string {
|
|
115
|
+
if (!payload || typeof payload !== "object") {
|
|
116
|
+
return "";
|
|
117
|
+
}
|
|
118
|
+
const asRecord = payload as Record<string, unknown>;
|
|
119
|
+
const candidates = [asRecord.text, asRecord.body];
|
|
120
|
+
for (const candidate of candidates) {
|
|
121
|
+
if (typeof candidate === "string" && candidate.trim()) {
|
|
122
|
+
return candidate.trim();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return "";
|
|
126
|
+
}
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
|
+
|
|
3
|
+
let pluginApi: OpenClawPluginApi | null = null;
|
|
4
|
+
|
|
5
|
+
export function setPluginApi(api: OpenClawPluginApi): void {
|
|
6
|
+
pluginApi = api;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function getPluginApi(): OpenClawPluginApi {
|
|
10
|
+
if (!pluginApi) {
|
|
11
|
+
throw new Error("whatsapp-api plugin API not initialized");
|
|
12
|
+
}
|
|
13
|
+
return pluginApi;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function getPluginRuntime(): OpenClawPluginApi["runtime"] {
|
|
17
|
+
return getPluginApi().runtime;
|
|
18
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export type ResolvedWhatsAppApiAccount = {
|
|
2
|
+
accountId: string;
|
|
3
|
+
enabled: boolean;
|
|
4
|
+
webhookPath: string;
|
|
5
|
+
inboundSharedSecret?: string;
|
|
6
|
+
outboundPhoneNumberId?: string;
|
|
7
|
+
outboundAccessToken?: string;
|
|
8
|
+
outboundApiVersion: string;
|
|
9
|
+
requestTimeoutMs: number;
|
|
10
|
+
maxRetries: number;
|
|
11
|
+
retryBackoffMs: number;
|
|
12
|
+
dedupeTtlMs: number;
|
|
13
|
+
maxBodyBytes: number;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type WhatsAppApiInboundMessage = {
|
|
17
|
+
accountId: string;
|
|
18
|
+
from: string;
|
|
19
|
+
to: string;
|
|
20
|
+
messageId: string;
|
|
21
|
+
text: string;
|
|
22
|
+
chatType: "direct";
|
|
23
|
+
timestamp?: number;
|
|
24
|
+
senderName?: string;
|
|
25
|
+
replyToId?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type WhatsAppApiSendTextParams = {
|
|
29
|
+
to: string;
|
|
30
|
+
text: string;
|
|
31
|
+
account: ResolvedWhatsAppApiAccount;
|
|
32
|
+
};
|
package/src/webhook.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { parseWhatsAppCloudInbound } from "./inbound.js";
|
|
3
|
+
import type { ResolvedWhatsAppApiAccount, WhatsAppApiInboundMessage } from "./types.js";
|
|
4
|
+
|
|
5
|
+
type LogLike = {
|
|
6
|
+
info?: (message: string) => void;
|
|
7
|
+
warn?: (message: string) => void;
|
|
8
|
+
error?: (message: string) => void;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const dedupeStore = new Map<string, number>();
|
|
12
|
+
|
|
13
|
+
function cleanupDedupe(now: number): void {
|
|
14
|
+
for (const [key, expiresAt] of dedupeStore.entries()) {
|
|
15
|
+
if (expiresAt <= now) {
|
|
16
|
+
dedupeStore.delete(key);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isDuplicate(accountId: string, messageId: string, ttlMs: number): boolean {
|
|
22
|
+
const now = Date.now();
|
|
23
|
+
cleanupDedupe(now);
|
|
24
|
+
const key = `${accountId}:${messageId}`;
|
|
25
|
+
const current = dedupeStore.get(key);
|
|
26
|
+
if (current && current > now) {
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
dedupeStore.set(key, now + ttlMs);
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function readBody(req: IncomingMessage, maxBodyBytes: number): Promise<string> {
|
|
34
|
+
const chunks: Buffer[] = [];
|
|
35
|
+
let total = 0;
|
|
36
|
+
for await (const chunk of req) {
|
|
37
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
|
|
38
|
+
total += buf.length;
|
|
39
|
+
if (total > maxBodyBytes) {
|
|
40
|
+
throw new Error("Payload exceeds maxBodyBytes");
|
|
41
|
+
}
|
|
42
|
+
chunks.push(buf);
|
|
43
|
+
}
|
|
44
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isAuthorized(req: IncomingMessage, account: ResolvedWhatsAppApiAccount): boolean {
|
|
48
|
+
const required = account.inboundSharedSecret?.trim();
|
|
49
|
+
if (!required) {
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
const header = req.headers["x-openclaw-shared-secret"];
|
|
53
|
+
const value = Array.isArray(header) ? header[0] : header;
|
|
54
|
+
return value?.trim() === required;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function json(res: ServerResponse, statusCode: number, body: Record<string, unknown>) {
|
|
58
|
+
res.statusCode = statusCode;
|
|
59
|
+
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
60
|
+
res.end(JSON.stringify(body));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function handleWhatsAppApiWebhook(params: {
|
|
64
|
+
req: IncomingMessage;
|
|
65
|
+
res: ServerResponse;
|
|
66
|
+
account: ResolvedWhatsAppApiAccount;
|
|
67
|
+
log?: LogLike;
|
|
68
|
+
onMessage: (message: WhatsAppApiInboundMessage) => Promise<void>;
|
|
69
|
+
}): Promise<boolean> {
|
|
70
|
+
const { req, res, account, log, onMessage } = params;
|
|
71
|
+
if (req.method !== "POST") {
|
|
72
|
+
json(res, 405, { ok: false, error: "Method not allowed" });
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (!isAuthorized(req, account)) {
|
|
77
|
+
json(res, 403, { ok: false, error: "Forbidden" });
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let payload: unknown;
|
|
82
|
+
try {
|
|
83
|
+
const bodyText = await readBody(req, account.maxBodyBytes);
|
|
84
|
+
payload = bodyText ? JSON.parse(bodyText) : {};
|
|
85
|
+
} catch (err) {
|
|
86
|
+
log?.warn?.(`[whatsapp-api] invalid inbound payload for account ${account.accountId}: ${String(err)}`);
|
|
87
|
+
json(res, 400, { ok: false, error: "Invalid payload" });
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const inboundMessages = parseWhatsAppCloudInbound({
|
|
92
|
+
payload,
|
|
93
|
+
accountId: account.accountId,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// ACK immediately; process asynchronously to keep webhook latency low.
|
|
97
|
+
json(res, 200, {
|
|
98
|
+
ok: true,
|
|
99
|
+
accepted: inboundMessages.length,
|
|
100
|
+
accountId: account.accountId,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
void (async () => {
|
|
104
|
+
for (const message of inboundMessages) {
|
|
105
|
+
if (isDuplicate(account.accountId, message.messageId, account.dedupeTtlMs)) {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
await onMessage(message);
|
|
110
|
+
} catch (err) {
|
|
111
|
+
log?.error?.(
|
|
112
|
+
`[whatsapp-api] failed processing message ${message.messageId} for account ${account.accountId}: ${String(err)}`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
})();
|
|
117
|
+
|
|
118
|
+
return true;
|
|
119
|
+
}
|