@0xmaxma/claude-gateway 1.8.9 → 1.8.11
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 +46 -6
- package/config.template.json +2 -1
- package/dist/agent/runner.d.ts +86 -1
- package/dist/agent/runner.d.ts.map +1 -1
- package/dist/agent/runner.js +396 -9
- package/dist/agent/runner.js.map +1 -1
- package/dist/api/router.d.ts.map +1 -1
- package/dist/api/router.js +1028 -6
- package/dist/api/router.js.map +1 -1
- package/dist/api/share-router.d.ts.map +1 -1
- package/dist/api/share-router.js +24 -0
- package/dist/api/share-router.js.map +1 -1
- package/dist/api/webhooks-router.d.ts +2 -0
- package/dist/api/webhooks-router.d.ts.map +1 -1
- package/dist/api/webhooks-router.js +2 -0
- package/dist/api/webhooks-router.js.map +1 -1
- package/dist/api/whatsapp-access.d.ts +123 -0
- package/dist/api/whatsapp-access.d.ts.map +1 -0
- package/dist/api/whatsapp-access.js +135 -0
- package/dist/api/whatsapp-access.js.map +1 -0
- package/dist/api/whatsapp-cloud-access.d.ts +48 -0
- package/dist/api/whatsapp-cloud-access.d.ts.map +1 -0
- package/dist/api/whatsapp-cloud-access.js +58 -0
- package/dist/api/whatsapp-cloud-access.js.map +1 -0
- package/dist/api/whatsapp-cloud-client.d.ts +143 -0
- package/dist/api/whatsapp-cloud-client.d.ts.map +1 -0
- package/dist/api/whatsapp-cloud-client.js +298 -0
- package/dist/api/whatsapp-cloud-client.js.map +1 -0
- package/dist/api/whatsapp-cloud-webhook-router.d.ts +132 -0
- package/dist/api/whatsapp-cloud-webhook-router.d.ts.map +1 -0
- package/dist/api/whatsapp-cloud-webhook-router.js +494 -0
- package/dist/api/whatsapp-cloud-webhook-router.js.map +1 -0
- package/dist/cli/args.d.ts +34 -0
- package/dist/cli/args.d.ts.map +1 -1
- package/dist/cli/args.js +55 -0
- package/dist/cli/args.js.map +1 -1
- package/dist/cli/commands/app.d.ts +28 -0
- package/dist/cli/commands/app.d.ts.map +1 -0
- package/dist/cli/commands/app.js +411 -0
- package/dist/cli/commands/app.js.map +1 -0
- package/dist/cli/commands/doctor.d.ts.map +1 -1
- package/dist/cli/commands/doctor.js +77 -16
- package/dist/cli/commands/doctor.js.map +1 -1
- package/dist/cli/commands/service.d.ts.map +1 -1
- package/dist/cli/commands/service.js +373 -56
- package/dist/cli/commands/service.js.map +1 -1
- package/dist/cli/commands/update.d.ts.map +1 -1
- package/dist/cli/commands/update.js +1 -17
- package/dist/cli/commands/update.js.map +1 -1
- package/dist/cli/http-client.d.ts +8 -0
- package/dist/cli/http-client.d.ts.map +1 -1
- package/dist/cli/http-client.js +5 -2
- package/dist/cli/http-client.js.map +1 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +16 -4
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/prompt.d.ts +17 -2
- package/dist/cli/prompt.d.ts.map +1 -1
- package/dist/cli/prompt.js +33 -2
- package/dist/cli/prompt.js.map +1 -1
- package/dist/config/loader.d.ts +8 -0
- package/dist/config/loader.d.ts.map +1 -1
- package/dist/config/loader.js +22 -4
- package/dist/config/loader.js.map +1 -1
- package/dist/config/watcher.d.ts.map +1 -1
- package/dist/config/watcher.js +12 -0
- package/dist/config/watcher.js.map +1 -1
- package/dist/config/whatsapp-accounts.d.ts +77 -0
- package/dist/config/whatsapp-accounts.d.ts.map +1 -0
- package/dist/config/whatsapp-accounts.js +218 -0
- package/dist/config/whatsapp-accounts.js.map +1 -0
- package/dist/history/db.d.ts.map +1 -1
- package/dist/history/db.js +43 -11
- package/dist/history/db.js.map +1 -1
- package/dist/history/types.d.ts +18 -1
- package/dist/history/types.d.ts.map +1 -1
- package/dist/history/types.js +1 -1
- package/dist/history/types.js.map +1 -1
- package/dist/index.js +26 -0
- package/dist/index.js.map +1 -1
- package/dist/session/process.d.ts.map +1 -1
- package/dist/session/process.js +37 -0
- package/dist/session/process.js.map +1 -1
- package/dist/session/store.d.ts +3 -2
- package/dist/session/store.d.ts.map +1 -1
- package/dist/session/store.js +1 -1
- package/dist/session/store.js.map +1 -1
- package/dist/share/session-video-catalog.d.ts +19 -0
- package/dist/share/session-video-catalog.d.ts.map +1 -0
- package/dist/share/session-video-catalog.js +119 -0
- package/dist/share/session-video-catalog.js.map +1 -0
- package/dist/shared/image-optimize.d.ts +32 -0
- package/dist/shared/image-optimize.d.ts.map +1 -0
- package/dist/shared/image-optimize.js +197 -0
- package/dist/shared/image-optimize.js.map +1 -0
- package/dist/shared/image-sniff.d.ts +9 -0
- package/dist/shared/image-sniff.d.ts.map +1 -1
- package/dist/shared/image-sniff.js +13 -0
- package/dist/shared/image-sniff.js.map +1 -1
- package/dist/shared/text-chunk.d.ts +37 -0
- package/dist/shared/text-chunk.d.ts.map +1 -0
- package/dist/shared/text-chunk.js +105 -0
- package/dist/shared/text-chunk.js.map +1 -0
- package/dist/shared/whatsapp-ack.d.ts +13 -0
- package/dist/shared/whatsapp-ack.d.ts.map +1 -0
- package/dist/shared/whatsapp-ack.js +16 -0
- package/dist/shared/whatsapp-ack.js.map +1 -0
- package/dist/types.d.ts +183 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/whatsapp/manager.d.ts +173 -0
- package/dist/whatsapp/manager.d.ts.map +1 -0
- package/dist/whatsapp/manager.js +838 -0
- package/dist/whatsapp/manager.js.map +1 -0
- package/mcp/server.ts +4 -0
- package/mcp/tools/image/module.ts +6 -1
- package/mcp/tools/telegram/receiver-server.ts +15 -8
- package/mcp/tools/telegram/reply-attachment.ts +81 -0
- package/mcp/tools/video/module.ts +6 -1
- package/mcp/tools/whatsapp/module.ts +162 -0
- package/mcp/tools/whatsapp-cloud/module.ts +536 -0
- package/package.json +7 -2
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WhatsApp Business Cloud API outbound tool module — exposes
|
|
3
|
+
* `whatsapp_cloud_reply` to the Claude session.
|
|
4
|
+
*
|
|
5
|
+
* WhatsApp Cloud is a ToolModule (reply-only, like Slack/LINE): inbound
|
|
6
|
+
* arrives via the gateway's Express webhook route
|
|
7
|
+
* (src/api/whatsapp-cloud-webhook-router.ts), not here.
|
|
8
|
+
*
|
|
9
|
+
* Mirrors `mcp/tools/slack/module.ts` directly — real, Meta-issued
|
|
10
|
+
* credentials with no reply-token TTL to work around, so this module always
|
|
11
|
+
* sends directly from the subprocess, same as Slack. The one Slack param it
|
|
12
|
+
* still has no analogue for is `thread_id`: the Cloud API has no threads, only
|
|
13
|
+
* per-message quoting (`reply_to_message_id`).
|
|
14
|
+
*
|
|
15
|
+
* Phase 3 adds three Cloud-only send modes on top of text/files: interactive
|
|
16
|
+
* `buttons` and `list` (no other channel here has any interactive-block
|
|
17
|
+
* support), and `template_name` — gated behind the agent's
|
|
18
|
+
* `whatsapp_cloud.templatesEnabled` opt-in, forwarded here as
|
|
19
|
+
* WHATSAPP_CLOUD_TEMPLATES_ENABLED, because a template is what reaches a user
|
|
20
|
+
* OUTSIDE WhatsApp's 24h customer-service window.
|
|
21
|
+
*/
|
|
22
|
+
import * as fs from 'fs';
|
|
23
|
+
import * as path from 'path';
|
|
24
|
+
import type { ToolModule, McpToolDefinition, McpToolResult, ToolVisibility } from '../../types';
|
|
25
|
+
// mcp/ ships as source (package.json `files` lists "mcp/", not "src/") and
|
|
26
|
+
// runs directly under bun — it may only import a compiled dist/ artifact,
|
|
27
|
+
// never src/ directly (see tests/unit/mcp-no-src-imports.test.ts). `npm run
|
|
28
|
+
// build` must have run at least once for this import to resolve locally.
|
|
29
|
+
import { WhatsAppCloudClient } from '../../../dist/api/whatsapp-cloud-client.js';
|
|
30
|
+
// Same dist-only rule as the client import above. MediaStore is reached for
|
|
31
|
+
// its image size cap alone — the Baileys channel's outbound path already
|
|
32
|
+
// measures against that exact value, and a second literal here would drift.
|
|
33
|
+
import { MediaStore } from '../../../dist/history/media-store.js';
|
|
34
|
+
import { optimizeImageFile } from '../../../dist/shared/image-optimize.js';
|
|
35
|
+
// Same dist-only rule as the imports above — reused rather than
|
|
36
|
+
// reimplemented so the outbound gate agrees with the inbound webhook's own
|
|
37
|
+
// dmPolicy/dmAllowlist semantics.
|
|
38
|
+
import { isWhatsAppCloudSenderAllowed } from '../../../dist/api/whatsapp-cloud-access.js';
|
|
39
|
+
import { MAX_ATTACHMENT_BYTES } from '../shared/limits';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Channel state-directory names that must never be reachable through
|
|
43
|
+
* `whatsapp_cloud_reply`'s `files` param — refusing these blocks the
|
|
44
|
+
* concrete exfiltration path (a prompt-injected turn passing a secret file
|
|
45
|
+
* as an "attachment to send"). Deny-list, not allow-list: this channel has
|
|
46
|
+
* no single "inbox" directory the way Telegram does, and legitimate
|
|
47
|
+
* attachments (agent-generated files, previously-downloaded inbound media in
|
|
48
|
+
* os.tmpdir()) can legitimately live outside the workspace.
|
|
49
|
+
*/
|
|
50
|
+
const DENIED_STATE_DIR_NAMES = [
|
|
51
|
+
'.whatsapp-state',
|
|
52
|
+
'.telegram-state',
|
|
53
|
+
'.discord-state',
|
|
54
|
+
'.line-state',
|
|
55
|
+
'.slack-state',
|
|
56
|
+
// Holds mcp-config.json — GATEWAY_API_KEY and every channel's tokens.
|
|
57
|
+
'.sessions',
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Refuse to send a file living inside one of this agent's own secret-bearing
|
|
62
|
+
* directories (session credentials, per-session mcp-config.json). Mirrors
|
|
63
|
+
* Telegram's `assertSendable` posture: fail OPEN (allow) when a path can't
|
|
64
|
+
* be resolved at all — a genuinely missing file is caught by the send call
|
|
65
|
+
* itself with a clearer error.
|
|
66
|
+
*/
|
|
67
|
+
function assertSendableWhatsAppCloudFile(filePath: string): void {
|
|
68
|
+
const workspace = process.env.GATEWAY_WORKSPACE_DIR;
|
|
69
|
+
if (!workspace) return;
|
|
70
|
+
let real: string;
|
|
71
|
+
let workspaceReal: string;
|
|
72
|
+
try {
|
|
73
|
+
real = fs.realpathSync(filePath);
|
|
74
|
+
workspaceReal = fs.realpathSync(workspace);
|
|
75
|
+
} catch {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
for (const name of DENIED_STATE_DIR_NAMES) {
|
|
79
|
+
let dirReal: string;
|
|
80
|
+
try {
|
|
81
|
+
dirReal = fs.realpathSync(path.join(workspaceReal, name));
|
|
82
|
+
} catch {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (real === dirReal || real.startsWith(dirReal + path.sep)) {
|
|
86
|
+
throw new Error(`refusing to send channel state: ${filePath}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Extension-based mime sniff for outbound files — self-contained on purpose
|
|
93
|
+
* (mirrors limits.ts's own doc comment: mcp/** must not import src/**, so
|
|
94
|
+
* this can't reuse src/shared/image-sniff.ts's magic-byte sniffer). Good
|
|
95
|
+
* enough here because these are files the AGENT itself wrote (generate_image
|
|
96
|
+
* output, a downloaded PDF, ...), not attacker-controlled bytes — unlike the
|
|
97
|
+
* inbound side, where the webhook router sniffs the real bytes.
|
|
98
|
+
*/
|
|
99
|
+
/** A reply button as the tool accepts it (mirrors the client's WhatsAppCloudButton). */
|
|
100
|
+
interface ReplyButton {
|
|
101
|
+
id: string;
|
|
102
|
+
title: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Coerce the tool's `buttons` argument into well-formed buttons, dropping
|
|
107
|
+
* anything without BOTH an id and a title (a half-specified button would
|
|
108
|
+
* render blank or be rejected by Meta). Returns [] for a missing/non-array
|
|
109
|
+
* argument, which the caller reads as "not an interactive send".
|
|
110
|
+
*/
|
|
111
|
+
function parseButtons(raw: unknown): ReplyButton[] {
|
|
112
|
+
if (!Array.isArray(raw)) return [];
|
|
113
|
+
const out: ReplyButton[] = [];
|
|
114
|
+
for (const b of raw) {
|
|
115
|
+
if (!b || typeof b !== 'object') continue;
|
|
116
|
+
const { id, title } = b as { id?: unknown; title?: unknown };
|
|
117
|
+
if (typeof id === 'string' && id && typeof title === 'string' && title) out.push({ id, title });
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Normalize `template_params` into Meta's `components` array.
|
|
124
|
+
*
|
|
125
|
+
* Two accepted shapes, because template definitions vary wildly and a small
|
|
126
|
+
* model should not have to hand-build Meta's nested component JSON for the
|
|
127
|
+
* overwhelmingly common case (a handful of {{1}}, {{2}} body variables):
|
|
128
|
+
*
|
|
129
|
+
* - SIMPLE — an array of strings/numbers: ["Alice", "3pm"] becomes one body
|
|
130
|
+
* component with those values as positional text parameters, in order.
|
|
131
|
+
* - RAW — an array of objects: passed through VERBATIM as `components`, for
|
|
132
|
+
* templates with header/button components, named params, currency or
|
|
133
|
+
* date_time parameters, or anything else the simple form cannot express.
|
|
134
|
+
*
|
|
135
|
+
* A mixed array is treated as RAW (pass-through) — Meta will reject it with a
|
|
136
|
+
* template-specific error, which is more useful than us guessing.
|
|
137
|
+
*/
|
|
138
|
+
export function buildTemplateComponents(raw: unknown): unknown[] | undefined {
|
|
139
|
+
if (!Array.isArray(raw) || raw.length === 0) return undefined;
|
|
140
|
+
const allScalar = raw.every((p) => typeof p === 'string' || typeof p === 'number');
|
|
141
|
+
if (!allScalar) return raw as unknown[];
|
|
142
|
+
return [
|
|
143
|
+
{
|
|
144
|
+
type: 'body',
|
|
145
|
+
parameters: raw.map((p) => ({ type: 'text', text: String(p) })),
|
|
146
|
+
},
|
|
147
|
+
];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function guessMimeType(filePath: string): string {
|
|
151
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
152
|
+
switch (ext) {
|
|
153
|
+
case '.png': return 'image/png';
|
|
154
|
+
case '.jpg':
|
|
155
|
+
case '.jpeg': return 'image/jpeg';
|
|
156
|
+
case '.gif': return 'image/gif';
|
|
157
|
+
case '.webp': return 'image/webp';
|
|
158
|
+
case '.pdf': return 'application/pdf';
|
|
159
|
+
default: return 'application/octet-stream';
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export class WhatsAppCloudModule implements ToolModule {
|
|
164
|
+
id = 'whatsapp_cloud';
|
|
165
|
+
toolVisibility: ToolVisibility = 'current-channel';
|
|
166
|
+
|
|
167
|
+
// Files already delivered this session — same retry-dedup as Slack's
|
|
168
|
+
// module (a small model sometimes retries after a transient send hiccup
|
|
169
|
+
// even though the upload landed, which would spam duplicate media).
|
|
170
|
+
private readonly sentFiles = new Set<string>();
|
|
171
|
+
|
|
172
|
+
isEnabled(): boolean {
|
|
173
|
+
return process.env.GATEWAY_ORIGIN_CHANNEL === 'whatsapp_cloud';
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
getTools(): McpToolDefinition[] {
|
|
177
|
+
return [
|
|
178
|
+
{
|
|
179
|
+
name: 'whatsapp_cloud_reply',
|
|
180
|
+
description:
|
|
181
|
+
'Send a reply to the current WhatsApp conversation (WhatsApp Business Cloud API). ' +
|
|
182
|
+
'Pass chat_id (the phone number shown in the <channel> tag) and text. ' +
|
|
183
|
+
'Optionally pass files (absolute paths) to attach images or PDF documents — ' +
|
|
184
|
+
'each file is sent as its own message; a caption (from text) rides on the first one. ' +
|
|
185
|
+
'Also pass message_id from the <channel> tag when present — it clears the ' +
|
|
186
|
+
'⏳ "seen" reaction the gateway left on the inbound message. ' +
|
|
187
|
+
'For a multiple-choice question, pass buttons (up to 3 tappable replies) or ' +
|
|
188
|
+
'list (a picker, for more than 3 options) instead of writing the options into text — ' +
|
|
189
|
+
"the user's tap arrives back as a normal message containing the option's title. " +
|
|
190
|
+
'To message a user who has NOT written in the last 24 hours, a free-form reply is ' +
|
|
191
|
+
'impossible: pass template_name + template_language for a pre-approved template ' +
|
|
192
|
+
'(only works if the agent has WhatsApp templates enabled).',
|
|
193
|
+
inputSchema: {
|
|
194
|
+
type: 'object',
|
|
195
|
+
properties: {
|
|
196
|
+
chat_id: {
|
|
197
|
+
type: 'string',
|
|
198
|
+
description: 'WhatsApp phone number to send to (the chat_id from the channel turn).',
|
|
199
|
+
},
|
|
200
|
+
text: {
|
|
201
|
+
type: 'string',
|
|
202
|
+
description: 'Message text.',
|
|
203
|
+
},
|
|
204
|
+
reply_to_message_id: {
|
|
205
|
+
type: 'string',
|
|
206
|
+
description:
|
|
207
|
+
'Optional inbound message id to quote — the reply appears attached to that ' +
|
|
208
|
+
'message in the conversation.',
|
|
209
|
+
},
|
|
210
|
+
message_id: {
|
|
211
|
+
type: 'string',
|
|
212
|
+
description:
|
|
213
|
+
'Optional inbound message id from the <channel> tag — clears the ⏳ ack ' +
|
|
214
|
+
'reaction the gateway left on it.',
|
|
215
|
+
},
|
|
216
|
+
files: {
|
|
217
|
+
type: 'array',
|
|
218
|
+
items: { type: 'string' },
|
|
219
|
+
description:
|
|
220
|
+
'Absolute file paths to attach (images or PDF documents). Optional — text can be ' +
|
|
221
|
+
'sent alone, files can be sent alone, or both together (text becomes the first file\'s caption). ' +
|
|
222
|
+
'An oversized image is downscaled automatically, so a large screenshot or chart does not ' +
|
|
223
|
+
'need to be resized before calling this.',
|
|
224
|
+
},
|
|
225
|
+
buttons: {
|
|
226
|
+
type: 'array',
|
|
227
|
+
maxItems: 3,
|
|
228
|
+
items: {
|
|
229
|
+
type: 'object',
|
|
230
|
+
properties: {
|
|
231
|
+
id: { type: 'string', description: 'Machine-readable id returned when this button is tapped.' },
|
|
232
|
+
title: { type: 'string', description: 'Button label the user sees (keep it short — WhatsApp truncates).' },
|
|
233
|
+
},
|
|
234
|
+
required: ['id', 'title'],
|
|
235
|
+
},
|
|
236
|
+
description:
|
|
237
|
+
'Optional: up to 3 tappable reply buttons shown under text (which becomes the question). ' +
|
|
238
|
+
'WhatsApp allows no more than 3 — use `list` for more options. Cannot be combined with files.',
|
|
239
|
+
},
|
|
240
|
+
list: {
|
|
241
|
+
type: 'object',
|
|
242
|
+
properties: {
|
|
243
|
+
button_label: {
|
|
244
|
+
type: 'string',
|
|
245
|
+
description: 'Label of the button that opens the picker (e.g. "Choose a slot").',
|
|
246
|
+
},
|
|
247
|
+
sections: {
|
|
248
|
+
type: 'array',
|
|
249
|
+
items: {
|
|
250
|
+
type: 'object',
|
|
251
|
+
properties: {
|
|
252
|
+
title: { type: 'string' },
|
|
253
|
+
rows: {
|
|
254
|
+
type: 'array',
|
|
255
|
+
items: {
|
|
256
|
+
type: 'object',
|
|
257
|
+
properties: {
|
|
258
|
+
id: { type: 'string' },
|
|
259
|
+
title: { type: 'string' },
|
|
260
|
+
description: { type: 'string' },
|
|
261
|
+
},
|
|
262
|
+
required: ['id', 'title'],
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
required: ['title', 'rows'],
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
required: ['button_label', 'sections'],
|
|
271
|
+
description:
|
|
272
|
+
'Optional: a list message — one button that opens a picker of grouped rows. ' +
|
|
273
|
+
'Use when there are more than 3 options. `text` becomes the question above it.',
|
|
274
|
+
},
|
|
275
|
+
template_name: {
|
|
276
|
+
type: 'string',
|
|
277
|
+
description:
|
|
278
|
+
'Optional: name of a pre-approved WhatsApp message template. Required to reach a user ' +
|
|
279
|
+
'more than 24h after their last message (free-form text is rejected by WhatsApp then). ' +
|
|
280
|
+
'Must be enabled for this agent, otherwise the send is refused.',
|
|
281
|
+
},
|
|
282
|
+
template_language: {
|
|
283
|
+
type: 'string',
|
|
284
|
+
description:
|
|
285
|
+
'Language code of the template, e.g. "en_US" or "th". Required whenever template_name is given.',
|
|
286
|
+
},
|
|
287
|
+
template_params: {
|
|
288
|
+
type: 'array',
|
|
289
|
+
description:
|
|
290
|
+
'Optional template variables. Simple form: an array of strings filling the template body\'s ' +
|
|
291
|
+
'{{1}}, {{2}}, ... in order, e.g. ["Alice", "3pm"]. Advanced form: an array of raw Meta ' +
|
|
292
|
+
'"components" objects, passed through untouched, for templates with header or button variables.',
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
// `text` is NOT required: a files-only reply (an image with no caption)
|
|
296
|
+
// is a legitimate send.
|
|
297
|
+
required: ['chat_id'],
|
|
298
|
+
},
|
|
299
|
+
},
|
|
300
|
+
];
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async handleTool(name: string, args: Record<string, unknown>): Promise<McpToolResult> {
|
|
304
|
+
if (name === 'whatsapp_cloud_reply') return this.handleReply(args);
|
|
305
|
+
return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
private async handleReply(args: Record<string, unknown>): Promise<McpToolResult> {
|
|
309
|
+
const chatId = typeof args.chat_id === 'string' ? args.chat_id : '';
|
|
310
|
+
const text = typeof args.text === 'string' ? args.text : '';
|
|
311
|
+
const requested = Array.isArray(args.files) ? (args.files as unknown[]) : [];
|
|
312
|
+
const replyTo =
|
|
313
|
+
typeof args.reply_to_message_id === 'string' && args.reply_to_message_id
|
|
314
|
+
? args.reply_to_message_id
|
|
315
|
+
: undefined;
|
|
316
|
+
const messageId = typeof args.message_id === 'string' && args.message_id ? args.message_id : '';
|
|
317
|
+
const accessToken = process.env.WHATSAPP_CLOUD_ACCESS_TOKEN ?? '';
|
|
318
|
+
const phoneNumberId = process.env.WHATSAPP_CLOUD_PHONE_NUMBER_ID ?? '';
|
|
319
|
+
|
|
320
|
+
// Phase 3 send modes. An EMPTY buttons array counts as "not interactive"
|
|
321
|
+
// (falls through to a plain text send) rather than an error — the model
|
|
322
|
+
// sometimes emits `buttons: []` when it decided against offering choices.
|
|
323
|
+
const buttons = parseButtons(args.buttons);
|
|
324
|
+
const list = args.list && typeof args.list === 'object' ? (args.list as Record<string, unknown>) : undefined;
|
|
325
|
+
const templateName =
|
|
326
|
+
typeof args.template_name === 'string' && args.template_name ? args.template_name : '';
|
|
327
|
+
|
|
328
|
+
if (!chatId) {
|
|
329
|
+
return { content: [{ type: 'text', text: 'whatsapp_cloud_reply: missing chat_id' }], isError: true };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// chat_id allowlist: this channel has no gateway-side relay route to add
|
|
333
|
+
// a second checkpoint to (it posts to Meta directly from this
|
|
334
|
+
// subprocess), so the DM allowlist gate has to be enforced HERE — a
|
|
335
|
+
// prompt-injected turn must not be able to message an arbitrary phone
|
|
336
|
+
// number. A normal reply's chat_id is exactly the sender the inbound
|
|
337
|
+
// message arrived from, which already cleared this same gate, so the
|
|
338
|
+
// golden path is unaffected.
|
|
339
|
+
const dmPolicy = (process.env.WHATSAPP_CLOUD_DM_POLICY || undefined) as
|
|
340
|
+
| 'open'
|
|
341
|
+
| 'allowlist'
|
|
342
|
+
| 'disabled'
|
|
343
|
+
| undefined;
|
|
344
|
+
let dmAllowlist: string[] = [];
|
|
345
|
+
try {
|
|
346
|
+
const parsed: unknown = JSON.parse(process.env.WHATSAPP_CLOUD_DM_ALLOWLIST ?? '[]');
|
|
347
|
+
if (Array.isArray(parsed)) dmAllowlist = parsed.filter((v): v is string => typeof v === 'string');
|
|
348
|
+
} catch {
|
|
349
|
+
/* malformed env — treat as empty allowlist, closed-by-default posture below */
|
|
350
|
+
}
|
|
351
|
+
if (!isWhatsAppCloudSenderAllowed(dmPolicy, dmAllowlist, chatId)) {
|
|
352
|
+
return {
|
|
353
|
+
content: [{ type: 'text', text: `whatsapp_cloud_reply: chat_id '${chatId}' is not allowed for this account` }],
|
|
354
|
+
isError: true,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Template gate (Phase 3). Refused loudly, never silently downgraded to a
|
|
359
|
+
// plain text send: outside the 24h window that text would ALSO fail, and a
|
|
360
|
+
// silent swap would hide the real reason from the agent. The opt-in lives
|
|
361
|
+
// in agent config (whatsapp_cloud.templatesEnabled) and reaches this
|
|
362
|
+
// subprocess as an env var — see session/process.ts.
|
|
363
|
+
if (templateName && process.env.WHATSAPP_CLOUD_TEMPLATES_ENABLED !== '1') {
|
|
364
|
+
return {
|
|
365
|
+
content: [
|
|
366
|
+
{
|
|
367
|
+
type: 'text',
|
|
368
|
+
text:
|
|
369
|
+
'whatsapp_cloud_reply: message templates are not enabled for this agent. ' +
|
|
370
|
+
'Enable whatsapp_cloud.templatesEnabled in the agent settings first (it is off by ' +
|
|
371
|
+
'default because templates can reach users outside the 24h reply window). ' +
|
|
372
|
+
'Within 24h of the user\'s last message, send plain text instead.',
|
|
373
|
+
},
|
|
374
|
+
],
|
|
375
|
+
isError: true,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
if (templateName && !(typeof args.template_language === 'string' && args.template_language)) {
|
|
379
|
+
return {
|
|
380
|
+
content: [
|
|
381
|
+
{
|
|
382
|
+
type: 'text',
|
|
383
|
+
text: 'whatsapp_cloud_reply: template_language is required when template_name is given (e.g. "en_US").',
|
|
384
|
+
},
|
|
385
|
+
],
|
|
386
|
+
isError: true,
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Drop files already delivered successfully this session (retry-dedup).
|
|
391
|
+
const files = requested.filter(
|
|
392
|
+
(f): f is string => typeof f === 'string' && !this.sentFiles.has(f),
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
// Nothing new to say or send — the whole reply is a duplicate retry. No-op
|
|
396
|
+
// success so the agent treats it as delivered and stops retrying.
|
|
397
|
+
// A template send carries its own content (the approved template body), so
|
|
398
|
+
// it is exempt from both empty-text checks below.
|
|
399
|
+
if (!text && files.length === 0 && !templateName && requested.length > 0) {
|
|
400
|
+
return { content: [{ type: 'text', text: 'already sent (duplicate suppressed)' }] };
|
|
401
|
+
}
|
|
402
|
+
if (!text && files.length === 0 && !templateName) {
|
|
403
|
+
return { content: [{ type: 'text', text: 'whatsapp_cloud_reply: text cannot be empty' }], isError: true };
|
|
404
|
+
}
|
|
405
|
+
if (!accessToken || !phoneNumberId) {
|
|
406
|
+
return {
|
|
407
|
+
content: [{ type: 'text', text: 'whatsapp_cloud_reply: missing WHATSAPP_CLOUD_ACCESS_TOKEN/WHATSAPP_CLOUD_PHONE_NUMBER_ID' }],
|
|
408
|
+
isError: true,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const client = new WhatsAppCloudClient({
|
|
413
|
+
accessToken,
|
|
414
|
+
phoneNumberId,
|
|
415
|
+
logDir: process.env.GATEWAY_WORKSPACE_DIR ?? '/tmp',
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
try {
|
|
419
|
+
// Size-check before any upload starts, so an oversized file fails fast
|
|
420
|
+
// instead of half-way through a multi-file batch.
|
|
421
|
+
for (const f of files) {
|
|
422
|
+
assertSendableWhatsAppCloudFile(f);
|
|
423
|
+
const st = fs.statSync(f);
|
|
424
|
+
if (st.size > MAX_ATTACHMENT_BYTES) {
|
|
425
|
+
throw new Error(`file too large: ${f} (${(st.size / 1024 / 1024).toFixed(1)}MB, max 50MB)`);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Send-mode precedence: template → buttons → list → files → plain text.
|
|
430
|
+
// Template wins outright because it is the only mode that works outside
|
|
431
|
+
// the 24h window; interactive modes come before files because the Cloud
|
|
432
|
+
// API cannot attach buttons to a media message at all.
|
|
433
|
+
if (templateName) {
|
|
434
|
+
const sent = await client.sendTemplate(
|
|
435
|
+
chatId,
|
|
436
|
+
templateName,
|
|
437
|
+
args.template_language as string,
|
|
438
|
+
buildTemplateComponents(args.template_params),
|
|
439
|
+
);
|
|
440
|
+
if (sent.error) throw new Error(sent.error.message ?? 'send failed');
|
|
441
|
+
} else if (buttons.length > 0) {
|
|
442
|
+
// >3 buttons throws inside the client with a readable message, which
|
|
443
|
+
// the catch below turns into an actionable tool error.
|
|
444
|
+
const sent = await client.sendInteractiveButtons(chatId, text, buttons);
|
|
445
|
+
if (sent.error) throw new Error(sent.error.message ?? 'send failed');
|
|
446
|
+
} else if (list) {
|
|
447
|
+
const buttonLabel = typeof list.button_label === 'string' ? list.button_label : '';
|
|
448
|
+
const sections = Array.isArray(list.sections) ? list.sections : [];
|
|
449
|
+
if (!buttonLabel || sections.length === 0) {
|
|
450
|
+
throw new Error('list requires button_label and at least one section');
|
|
451
|
+
}
|
|
452
|
+
const sent = await client.sendInteractiveList(chatId, text, buttonLabel, sections);
|
|
453
|
+
if (sent.error) throw new Error(sent.error.message ?? 'send failed');
|
|
454
|
+
} else if (files.length > 0) {
|
|
455
|
+
// The Cloud API sends one message per media item (no Slack-style
|
|
456
|
+
// batch-into-one-message) — the caption rides on the FIRST file only.
|
|
457
|
+
for (let i = 0; i < files.length; i++) {
|
|
458
|
+
const f = files[i]!;
|
|
459
|
+
const mime = guessMimeType(f);
|
|
460
|
+
const isImage = mime.startsWith('image/');
|
|
461
|
+
|
|
462
|
+
// Auto-optimize an over-cap PHOTO rather than letting Meta reject the
|
|
463
|
+
// upload. Only on the image branch: the extension-based image-vs-
|
|
464
|
+
// document decision below IS this channel's equivalent of Baileys'
|
|
465
|
+
// asDocument flag (the Cloud API has no single force-document
|
|
466
|
+
// toggle), and a document send must deliver its exact bytes.
|
|
467
|
+
let uploadPath = f;
|
|
468
|
+
let uploadMime = mime;
|
|
469
|
+
if (isImage) {
|
|
470
|
+
const size = fs.statSync(f).size;
|
|
471
|
+
if (size > MediaStore.maxUploadBytes) {
|
|
472
|
+
uploadPath = await optimizeImageFile(f, MediaStore.maxUploadBytes).catch(() => f);
|
|
473
|
+
// optimizeImageFile always emits JPEG; keep the declared mime in
|
|
474
|
+
// step with the bytes actually being uploaded.
|
|
475
|
+
if (uploadPath !== f) uploadMime = 'image/jpeg';
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const uploaded = await client.uploadMedia(uploadPath, uploadMime);
|
|
480
|
+
if ('error' in uploaded) {
|
|
481
|
+
throw new Error(uploaded.error);
|
|
482
|
+
}
|
|
483
|
+
const caption = i === 0 ? (text || undefined) : undefined;
|
|
484
|
+
const sent = isImage
|
|
485
|
+
? await client.sendImage(chatId, uploaded.mediaId, caption)
|
|
486
|
+
: await client.sendDocument(chatId, uploaded.mediaId, path.basename(f), caption);
|
|
487
|
+
if (sent.error) {
|
|
488
|
+
throw new Error(sent.error.message ?? 'send failed');
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
} else {
|
|
492
|
+
const sent = await client.sendText(chatId, text, replyTo);
|
|
493
|
+
if (sent.error) {
|
|
494
|
+
throw new Error(sent.error.message ?? 'send failed');
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Mark as sent only AFTER the send succeeds — a genuine failure leaves
|
|
499
|
+
// them eligible for a retry rather than silently dropped. Only when the
|
|
500
|
+
// FILE branch actually ran: a template/interactive send takes precedence
|
|
501
|
+
// over any files passed alongside it, and those files were not delivered,
|
|
502
|
+
// so marking them here would suppress a later, legitimate retry.
|
|
503
|
+
const filesWereSent = !templateName && buttons.length === 0 && !list && files.length > 0;
|
|
504
|
+
if (filesWereSent) for (const f of files) this.sentFiles.add(f);
|
|
505
|
+
// Best-effort: clear the ack-reaction the webhook left on the inbound
|
|
506
|
+
// message (mirrors slack_reply's removeReaction call site). Never blocks
|
|
507
|
+
// or fails the reply itself. Skipped when the gateway has reactions
|
|
508
|
+
// turned off for this channel, so nothing tries to clear a reaction that
|
|
509
|
+
// was never added.
|
|
510
|
+
if (messageId && (process.env.WHATSAPP_CLOUD_REACTION_LEVEL ?? 'ack') === 'ack') {
|
|
511
|
+
void client.removeReaction(chatId, messageId).catch(() => {});
|
|
512
|
+
}
|
|
513
|
+
return {
|
|
514
|
+
content: [
|
|
515
|
+
{
|
|
516
|
+
type: 'text',
|
|
517
|
+
text: templateName
|
|
518
|
+
? `Sent WhatsApp template "${templateName}".`
|
|
519
|
+
: buttons.length > 0
|
|
520
|
+
? `Sent message to WhatsApp (${buttons.length} button(s)).`
|
|
521
|
+
: list
|
|
522
|
+
? 'Sent message to WhatsApp (list).'
|
|
523
|
+
: filesWereSent
|
|
524
|
+
? `Sent message to WhatsApp (${files.length} file(s)).`
|
|
525
|
+
: 'Sent message to WhatsApp.',
|
|
526
|
+
},
|
|
527
|
+
],
|
|
528
|
+
};
|
|
529
|
+
} catch (err) {
|
|
530
|
+
return {
|
|
531
|
+
content: [{ type: 'text', text: `whatsapp_cloud_reply failed: ${(err as Error).message}` }],
|
|
532
|
+
isError: true,
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@0xmaxma/claude-gateway",
|
|
3
|
-
"version": "1.8.
|
|
3
|
+
"version": "1.8.11",
|
|
4
4
|
"description": "Multi-agent gateway for Claude",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
48
|
"@line/bot-sdk": "^11.0.2",
|
|
49
|
+
"@whiskeysockets/baileys": "^6.7.24",
|
|
49
50
|
"@xterm/headless": "^6.0.0",
|
|
50
51
|
"chokidar": "^3.5.0",
|
|
51
52
|
"cron-parser": "^5.5.0",
|
|
@@ -55,17 +56,21 @@
|
|
|
55
56
|
"node-cron": "^3.0.0",
|
|
56
57
|
"node-pty": "^1.1.0",
|
|
57
58
|
"p-queue": "^6.6.2",
|
|
59
|
+
"pino": "^10.3.1",
|
|
60
|
+
"qrcode": "^1.5.4",
|
|
61
|
+
"sharp": "^0.35.4",
|
|
58
62
|
"ws": "^8.21.0"
|
|
59
63
|
},
|
|
60
64
|
"devDependencies": {
|
|
61
65
|
"@types/express": "^4.17.0",
|
|
62
|
-
"@types/ws": "^8.18.1",
|
|
63
66
|
"@types/jest": "^29.0.0",
|
|
64
67
|
"@types/js-yaml": "^4.0.0",
|
|
65
68
|
"@types/node": "^22.19.18",
|
|
66
69
|
"@types/node-cron": "^3.0.0",
|
|
70
|
+
"@types/qrcode": "^1.5.6",
|
|
67
71
|
"@types/supertest": "^7.2.0",
|
|
68
72
|
"@types/tmp": "^0.2.0",
|
|
73
|
+
"@types/ws": "^8.18.1",
|
|
69
74
|
"jest": "^29.0.0",
|
|
70
75
|
"supertest": "^7.2.2",
|
|
71
76
|
"tmp": "^0.2.0",
|