@0xmaxma/claude-gateway 1.8.10 → 1.8.12
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 +14 -1
- package/config.template.json +2 -1
- package/dist/agent/runner.d.ts +72 -0
- package/dist/agent/runner.d.ts.map +1 -1
- package/dist/agent/runner.js +300 -6
- package/dist/agent/runner.js.map +1 -1
- package/dist/api/router.d.ts.map +1 -1
- package/dist/api/router.js +1001 -4
- package/dist/api/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/config/loader.d.ts.map +1 -1
- package/dist/config/loader.js +7 -0
- package/dist/config/loader.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 +18 -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/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 +158 -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/whatsapp/module.ts +162 -0
- package/mcp/tools/whatsapp-cloud/module.ts +536 -0
- package/package.json +9 -4
|
@@ -0,0 +1,838 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.WhatsAppManager = void 0;
|
|
40
|
+
/**
|
|
41
|
+
* WhatsAppManager — per-agent WhatsApp Web bridge via Baileys, run IN-PROCESS
|
|
42
|
+
* in the main gateway (not a spawned child, unlike Telegram/Discord's
|
|
43
|
+
* receivers). See the design note in AgentConfig.whatsapp's doc comment
|
|
44
|
+
* (src/types.ts) and the plan this was built from for why: Baileys
|
|
45
|
+
* multiplexes ALL send/receive through one live multi-device WebSocket tied
|
|
46
|
+
* to the linked session — there is no stateless per-call REST path the way
|
|
47
|
+
* Discord/Telegram/Slack/SMS have, so the socket must be a long-lived
|
|
48
|
+
* resource the gateway process holds and reaches into directly (via
|
|
49
|
+
* src/api/router.ts's internal /whatsapp/send route), not something an MCP
|
|
50
|
+
* subprocess can independently reconnect for every reply.
|
|
51
|
+
*
|
|
52
|
+
* One WhatsAppManager per LINKED ACCOUNT (Phase 1 of the WhatsApp
|
|
53
|
+
* feature-parity plan made this multi-account; it used to be one per agent).
|
|
54
|
+
* AgentRunner keeps a Map<accountId, WhatsAppManager> and only adds/removes
|
|
55
|
+
* entries as `agentConfig.whatsapp.accounts` changes — an existing account's
|
|
56
|
+
* live socket is never torn down just because a sibling account's config
|
|
57
|
+
* moved, since the credential IS the on-disk session, not a config.json field.
|
|
58
|
+
*
|
|
59
|
+
* The account's id selects its session directory: 'default' keeps the
|
|
60
|
+
* historical bare `<workspace>/.whatsapp-state/`, anything else nests under
|
|
61
|
+
* it (see whatsAppStateDir in src/config/whatsapp-accounts.ts).
|
|
62
|
+
*/
|
|
63
|
+
const fs = __importStar(require("fs"));
|
|
64
|
+
const fsp = __importStar(require("fs/promises"));
|
|
65
|
+
const path = __importStar(require("path"));
|
|
66
|
+
const os = __importStar(require("os"));
|
|
67
|
+
async function loadBaileys() {
|
|
68
|
+
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
|
69
|
+
return (await Promise.resolve().then(() => __importStar(require('@whiskeysockets/baileys'))));
|
|
70
|
+
}
|
|
71
|
+
const QRCode = __importStar(require("qrcode"));
|
|
72
|
+
const pino_1 = __importDefault(require("pino"));
|
|
73
|
+
const whatsapp_accounts_1 = require("../config/whatsapp-accounts");
|
|
74
|
+
const logger_1 = require("../logger");
|
|
75
|
+
const media_store_1 = require("../history/media-store");
|
|
76
|
+
const image_sniff_1 = require("../shared/image-sniff");
|
|
77
|
+
const whatsapp_access_1 = require("../api/whatsapp-access");
|
|
78
|
+
const text_chunk_1 = require("../shared/text-chunk");
|
|
79
|
+
const image_optimize_1 = require("../shared/image-optimize");
|
|
80
|
+
const whatsapp_ack_1 = require("../shared/whatsapp-ack");
|
|
81
|
+
const pending_senders_1 = require("../api/pending-senders");
|
|
82
|
+
const AUTO_RESTART_DELAY_MS = 5000;
|
|
83
|
+
const MAX_RESTARTS = 3;
|
|
84
|
+
const SLOW_RESTART_DELAY_MS = 5 * 60000;
|
|
85
|
+
const MAX_IMAGE_BYTES = media_store_1.MediaStore.maxUploadBytes;
|
|
86
|
+
/**
|
|
87
|
+
* Per-message character budget for outbound text (Phase 2). WhatsApp's own
|
|
88
|
+
* ceiling is ~65k, but long single bubbles render badly and Baileys can be
|
|
89
|
+
* flaky well below the protocol limit — 4000 is the conservative value the
|
|
90
|
+
* Cloud channel's hard 4096 limit already forces us to design for, so both
|
|
91
|
+
* WhatsApp channels behave the same way.
|
|
92
|
+
*/
|
|
93
|
+
const MAX_TEXT_CHARS = 4000;
|
|
94
|
+
/**
|
|
95
|
+
* How many recent inbound messages to keep addressable. A quote-reply needs
|
|
96
|
+
* the WHOLE original message (Baileys' `quoted` option takes a WAMessage, not
|
|
97
|
+
* an id) and a reaction needs its full key (participant included, or a
|
|
98
|
+
* reaction in a group lands on nothing), but the MCP tool only ever hands
|
|
99
|
+
* back a message_id string. Bounded FIFO: this only has to outlive the gap
|
|
100
|
+
* between an inbound message and the agent's reply to it.
|
|
101
|
+
*/
|
|
102
|
+
const RECENT_MESSAGE_CACHE_SIZE = 200;
|
|
103
|
+
/**
|
|
104
|
+
* Extension-based mime guess for an outbound `forceDocument` send. These are
|
|
105
|
+
* files the AGENT produced (a generated image, a rendered chart), not
|
|
106
|
+
* attacker bytes, so the extension is trustworthy enough here — the inbound
|
|
107
|
+
* side sniffs real magic bytes instead (sniffImageExt).
|
|
108
|
+
*/
|
|
109
|
+
function guessOutboundMimeType(filePath) {
|
|
110
|
+
switch (path.extname(filePath).toLowerCase()) {
|
|
111
|
+
case '.png': return 'image/png';
|
|
112
|
+
case '.jpg':
|
|
113
|
+
case '.jpeg': return 'image/jpeg';
|
|
114
|
+
case '.gif': return 'image/gif';
|
|
115
|
+
case '.webp': return 'image/webp';
|
|
116
|
+
case '.pdf': return 'application/pdf';
|
|
117
|
+
default: return 'application/octet-stream';
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Pull the quote context off whichever message variant carries it. A plain
|
|
122
|
+
* text message that quotes something is upgraded to `extendedTextMessage` by
|
|
123
|
+
* WhatsApp, but a quoted IMAGE/sticker/document/video reply keeps its own
|
|
124
|
+
* type and hangs `contextInfo` off that instead — so all of them are checked.
|
|
125
|
+
*/
|
|
126
|
+
function extractQuotedContext(message) {
|
|
127
|
+
if (!message)
|
|
128
|
+
return undefined;
|
|
129
|
+
for (const key of [
|
|
130
|
+
'extendedTextMessage',
|
|
131
|
+
'imageMessage',
|
|
132
|
+
'stickerMessage',
|
|
133
|
+
'documentMessage',
|
|
134
|
+
'videoMessage',
|
|
135
|
+
'audioMessage',
|
|
136
|
+
]) {
|
|
137
|
+
const ctx = message[key]?.contextInfo;
|
|
138
|
+
if (ctx?.stanzaId)
|
|
139
|
+
return ctx;
|
|
140
|
+
}
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
/** Best-effort body text of a quoted message, across the variants that have one. */
|
|
144
|
+
function quotedMessageText(quoted) {
|
|
145
|
+
if (!quoted)
|
|
146
|
+
return '';
|
|
147
|
+
const conversation = quoted.conversation;
|
|
148
|
+
if (typeof conversation === 'string')
|
|
149
|
+
return conversation;
|
|
150
|
+
const extended = quoted.extendedTextMessage?.text;
|
|
151
|
+
if (typeof extended === 'string')
|
|
152
|
+
return extended;
|
|
153
|
+
for (const key of ['imageMessage', 'videoMessage', 'documentMessage']) {
|
|
154
|
+
const caption = quoted[key]?.caption;
|
|
155
|
+
if (typeof caption === 'string')
|
|
156
|
+
return caption;
|
|
157
|
+
}
|
|
158
|
+
return '';
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* One-time pairing-code message — same visual-match-code contract as every
|
|
162
|
+
* other channel's pairing flow (the sender reports the code to the admin,
|
|
163
|
+
* who matches it in the UI before adding them to the allowlist).
|
|
164
|
+
*/
|
|
165
|
+
function pairingMessage(code, isGroup) {
|
|
166
|
+
const thWhere = isGroup ? 'ในกลุ่มนี้' : '';
|
|
167
|
+
const enWhere = isGroup ? ' in this group' : '';
|
|
168
|
+
return (`รหัสจับคู่ (pairing code) ของคุณคือ: ${code}\n` +
|
|
169
|
+
`กรุณาแจ้งรหัสนี้ให้แอดมินเพื่อขอเปิดใช้งานบอท${thWhere} (ไม่ต้องพิมพ์รหัสตอบกลับ)\n\n` +
|
|
170
|
+
`Your pairing code: ${code}\n` +
|
|
171
|
+
`Share this code with the admin to get access${enWhere}. (No need to reply with it.)`);
|
|
172
|
+
}
|
|
173
|
+
class WhatsAppManager {
|
|
174
|
+
constructor(agentConfig,
|
|
175
|
+
/**
|
|
176
|
+
* Which of the agent's WhatsApp accounts this instance owns. Selects the
|
|
177
|
+
* on-disk session directory, tags every event forwarded to the runner,
|
|
178
|
+
* and picks the access-control block read on each inbound message.
|
|
179
|
+
*/
|
|
180
|
+
accountId, callbackPort, logDir) {
|
|
181
|
+
this.agentConfig = agentConfig;
|
|
182
|
+
this.accountId = accountId;
|
|
183
|
+
this.callbackPort = callbackPort;
|
|
184
|
+
this.logDir = logDir;
|
|
185
|
+
this.sock = null;
|
|
186
|
+
this.status = 'unlinked';
|
|
187
|
+
this.loggedOut = false;
|
|
188
|
+
this.stopping = false;
|
|
189
|
+
this.restartCount = 0;
|
|
190
|
+
/**
|
|
191
|
+
* Bumped at the start of every connect() call. A socket's event handlers
|
|
192
|
+
* capture the generation they were created under and become no-ops once a
|
|
193
|
+
* newer connect() supersedes them — see connect()'s doc comment.
|
|
194
|
+
*/
|
|
195
|
+
this.connectGeneration = 0;
|
|
196
|
+
this.restartTimer = null;
|
|
197
|
+
this.pinoLogger = (0, pino_1.default)({ level: 'silent' });
|
|
198
|
+
this.baileys = null;
|
|
199
|
+
/** Recent inbound messages by id — see RECENT_MESSAGE_CACHE_SIZE. */
|
|
200
|
+
this.recentMessages = new Map();
|
|
201
|
+
this.stateDir = (0, whatsapp_accounts_1.whatsAppStateDir)(agentConfig.workspace, accountId);
|
|
202
|
+
this.logger = (0, logger_1.createLogger)(accountId === whatsapp_accounts_1.DEFAULT_WHATSAPP_ACCOUNT_ID
|
|
203
|
+
? `${agentConfig.id}:whatsapp`
|
|
204
|
+
: `${agentConfig.id}:whatsapp:${accountId}`, logDir);
|
|
205
|
+
}
|
|
206
|
+
updateAgentConfig(newConfig) {
|
|
207
|
+
this.agentConfig = newConfig;
|
|
208
|
+
// No credential to react to (see class doc comment) — access-control
|
|
209
|
+
// fields (dmPolicy etc.) are read live off this.accountConfig() on every
|
|
210
|
+
// inbound message, so nothing else needs to happen here.
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* This account's access-control block. Undefined when the account isn't in
|
|
214
|
+
* config at all (an unconfigured agent's implicit 'default'), which the
|
|
215
|
+
* gate treats exactly like an empty block — closed by default.
|
|
216
|
+
*/
|
|
217
|
+
accountConfig() {
|
|
218
|
+
return (0, whatsapp_accounts_1.findWhatsAppAccount)(this.agentConfig.whatsapp, this.accountId);
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Wipe this account's linked session.
|
|
222
|
+
*
|
|
223
|
+
* The 'default' account shares the bare `.whatsapp-state/` directory with
|
|
224
|
+
* BOTH the channel's message-turn state and every other account's
|
|
225
|
+
* subdirectory (see whatsAppStateDir), so a blind `rm -rf` of it would
|
|
226
|
+
* unlink sibling accounts as collateral. Delete only the files at the top
|
|
227
|
+
* level there — Baileys' `creds.json` and key files — and leave
|
|
228
|
+
* subdirectories alone. Non-default accounts own their directory outright,
|
|
229
|
+
* so those are removed wholesale.
|
|
230
|
+
*/
|
|
231
|
+
async wipeStateDir() {
|
|
232
|
+
if (this.accountId !== whatsapp_accounts_1.DEFAULT_WHATSAPP_ACCOUNT_ID) {
|
|
233
|
+
await fsp.rm(this.stateDir, { recursive: true, force: true }).catch(() => { });
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const entries = await fsp.readdir(this.stateDir, { withFileTypes: true }).catch(() => []);
|
|
237
|
+
await Promise.all(entries
|
|
238
|
+
.filter((e) => e.isFile())
|
|
239
|
+
.map((e) => fsp.rm(path.join(this.stateDir, e.name), { force: true }).catch(() => { })));
|
|
240
|
+
}
|
|
241
|
+
getStatus() {
|
|
242
|
+
return {
|
|
243
|
+
status: this.status,
|
|
244
|
+
qr: this.qrDataUri,
|
|
245
|
+
pairingCode: this.pairingCode,
|
|
246
|
+
phoneNumber: this.phoneNumber,
|
|
247
|
+
loggedOut: this.loggedOut,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
/** Resume a previously-linked session on gateway boot — no-op if never linked. */
|
|
251
|
+
async resumeIfLinked() {
|
|
252
|
+
if (!fs.existsSync(path.join(this.stateDir, 'creds.json')))
|
|
253
|
+
return;
|
|
254
|
+
this.logger.info('Resuming previously-linked WhatsApp session', {
|
|
255
|
+
agentId: this.agentConfig.id,
|
|
256
|
+
accountId: this.accountId,
|
|
257
|
+
});
|
|
258
|
+
await this.connect();
|
|
259
|
+
}
|
|
260
|
+
/** Start a fresh QR-code linking flow. */
|
|
261
|
+
async startLinking() {
|
|
262
|
+
this.pairingCode = undefined;
|
|
263
|
+
await this.connect();
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Request a pairing code instead of QR. Per Baileys' contract this must be
|
|
267
|
+
* called once the socket is up but before it's registered — connect()
|
|
268
|
+
* always opens the socket first; if the caller wants a pairing code, pass
|
|
269
|
+
* the phone number and it's requested right after the socket is ready.
|
|
270
|
+
*/
|
|
271
|
+
async requestPairingCode(phoneNumber) {
|
|
272
|
+
this.qrDataUri = undefined;
|
|
273
|
+
await this.connect(phoneNumber);
|
|
274
|
+
if (!this.pairingCode)
|
|
275
|
+
throw new Error('Failed to obtain a pairing code');
|
|
276
|
+
return this.pairingCode;
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* A concurrent second call (double-click "Link", a pairing-code retry, or
|
|
280
|
+
* .../link then .../pairing-code back to back — none of this is guarded at
|
|
281
|
+
* the route layer) must not leave two live sockets both wired to
|
|
282
|
+
* this.sock/this.status/this.phoneNumber. Every call bumps
|
|
283
|
+
* connectGeneration and captures it as `myGeneration`; any socket from an
|
|
284
|
+
* earlier generation is torn down (here, or by the generation check inside
|
|
285
|
+
* its own event handlers below) rather than left running alongside the new
|
|
286
|
+
* one.
|
|
287
|
+
*/
|
|
288
|
+
async connect(pairingPhoneNumber) {
|
|
289
|
+
this.stopping = false;
|
|
290
|
+
const myGeneration = ++this.connectGeneration;
|
|
291
|
+
if (this.sock) {
|
|
292
|
+
try {
|
|
293
|
+
this.sock.end(undefined);
|
|
294
|
+
}
|
|
295
|
+
catch { /* already closing */ }
|
|
296
|
+
this.sock = null;
|
|
297
|
+
}
|
|
298
|
+
const baileys = this.baileys ?? (this.baileys = await loadBaileys());
|
|
299
|
+
// 0o700: this directory holds creds.json — a live linked-session
|
|
300
|
+
// credential equivalent to a password. Matches the 0o700 convention used
|
|
301
|
+
// for every other secret-bearing directory in this codebase (see
|
|
302
|
+
// config/bootstrap.ts, connectors/token-env.ts, session/process.ts).
|
|
303
|
+
fs.mkdirSync(this.stateDir, { recursive: true, mode: 0o700 });
|
|
304
|
+
const { state, saveCreds } = await baileys.useMultiFileAuthState(this.stateDir);
|
|
305
|
+
const sock = baileys.default({
|
|
306
|
+
auth: state,
|
|
307
|
+
browser: baileys.Browsers.ubuntu('Claude Gateway'),
|
|
308
|
+
logger: this.pinoLogger,
|
|
309
|
+
});
|
|
310
|
+
if (myGeneration !== this.connectGeneration) {
|
|
311
|
+
// Superseded again while awaiting the auth-state load above.
|
|
312
|
+
try {
|
|
313
|
+
sock.end(undefined);
|
|
314
|
+
}
|
|
315
|
+
catch { /* already closing */ }
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
this.sock = sock;
|
|
319
|
+
this.status = this.status === 'linked' ? 'reconnecting' : 'pending_scan';
|
|
320
|
+
sock.ev.on('creds.update', saveCreds);
|
|
321
|
+
sock.ev.on('connection.update', (update) => {
|
|
322
|
+
if (myGeneration !== this.connectGeneration)
|
|
323
|
+
return;
|
|
324
|
+
void this.handleConnectionUpdate(update);
|
|
325
|
+
});
|
|
326
|
+
sock.ev.on('messages.upsert', (payload) => {
|
|
327
|
+
if (myGeneration !== this.connectGeneration)
|
|
328
|
+
return;
|
|
329
|
+
void this.handleMessagesUpsert(payload);
|
|
330
|
+
});
|
|
331
|
+
if (pairingPhoneNumber && !state.creds.registered) {
|
|
332
|
+
try {
|
|
333
|
+
// sock.ws only finishes its handshake asynchronously after
|
|
334
|
+
// baileys.default() returns — requestPairingCode sends over that raw
|
|
335
|
+
// socket immediately, so calling it before ws.isOpen throws
|
|
336
|
+
// "Connection Closed" nearly every time (the comment above used to
|
|
337
|
+
// assume "the socket is up" meant "the connection is open"; it
|
|
338
|
+
// doesn't — those are two different moments).
|
|
339
|
+
await this.waitForSocketOpen(sock);
|
|
340
|
+
if (myGeneration !== this.connectGeneration) {
|
|
341
|
+
throw new Error('WhatsApp connection superseded by a newer connect() call');
|
|
342
|
+
}
|
|
343
|
+
this.pairingCode = await sock.requestPairingCode(pairingPhoneNumber);
|
|
344
|
+
}
|
|
345
|
+
catch (err) {
|
|
346
|
+
this.logger.error('requestPairingCode failed', { error: err.message });
|
|
347
|
+
// The socket never finished opening (or got superseded mid-wait) —
|
|
348
|
+
// drop it rather than leaving an abandoned, never-linked socket
|
|
349
|
+
// wired to this.sock (waitForSocketOpen's timeout has no Baileys
|
|
350
|
+
// 'close' event to clean it up on its own).
|
|
351
|
+
if (myGeneration === this.connectGeneration) {
|
|
352
|
+
try {
|
|
353
|
+
this.sock?.end(undefined);
|
|
354
|
+
}
|
|
355
|
+
catch { /* already closing */ }
|
|
356
|
+
this.sock = null;
|
|
357
|
+
}
|
|
358
|
+
throw err;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Poll `sock.ws.isOpen` until the underlying WebSocket handshake completes
|
|
364
|
+
* (or `timeoutMs` elapses). There's no Baileys-emitted event for "ws is
|
|
365
|
+
* open but not yet authenticated" to await instead — `connection.update`
|
|
366
|
+
* only fires once the higher-level handshake is further along, which is
|
|
367
|
+
* later than requestPairingCode needs.
|
|
368
|
+
*/
|
|
369
|
+
async waitForSocketOpen(sock, timeoutMs = 10000) {
|
|
370
|
+
const start = Date.now();
|
|
371
|
+
while (!sock.ws?.isOpen) {
|
|
372
|
+
if (this.stopping)
|
|
373
|
+
throw new Error('WhatsApp connection stopped before it opened');
|
|
374
|
+
if (Date.now() - start > timeoutMs) {
|
|
375
|
+
throw new Error('WhatsApp socket did not open in time for the pairing-code request');
|
|
376
|
+
}
|
|
377
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
async handleConnectionUpdate(update) {
|
|
381
|
+
if (update.qr && !this.pairingCode) {
|
|
382
|
+
try {
|
|
383
|
+
this.qrDataUri = await QRCode.toDataURL(update.qr);
|
|
384
|
+
}
|
|
385
|
+
catch (err) {
|
|
386
|
+
this.logger.error('Failed to render QR', { error: err.message });
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
if (update.connection === 'open') {
|
|
390
|
+
this.status = 'linked';
|
|
391
|
+
this.qrDataUri = undefined;
|
|
392
|
+
this.pairingCode = undefined;
|
|
393
|
+
this.loggedOut = false;
|
|
394
|
+
this.restartCount = 0;
|
|
395
|
+
this.phoneNumber = this.sock?.user?.id?.split(':')[0]?.split('@')[0];
|
|
396
|
+
this.logger.info('WhatsApp linked', { agentId: this.agentConfig.id, phoneNumber: this.phoneNumber });
|
|
397
|
+
}
|
|
398
|
+
if (update.connection === 'close') {
|
|
399
|
+
const statusCode = update.lastDisconnect?.error?.output?.statusCode;
|
|
400
|
+
this.sock = null;
|
|
401
|
+
if (statusCode === this.baileys.DisconnectReason.loggedOut) {
|
|
402
|
+
this.loggedOut = true;
|
|
403
|
+
this.status = 'unlinked';
|
|
404
|
+
this.logger.warn('WhatsApp session logged out — re-linking required', {
|
|
405
|
+
agentId: this.agentConfig.id,
|
|
406
|
+
});
|
|
407
|
+
// The old session is dead; wipe it so a fresh link doesn't try to
|
|
408
|
+
// resume invalid creds.
|
|
409
|
+
await this.wipeStateDir();
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
this.status = 'reconnecting';
|
|
413
|
+
if (!this.stopping)
|
|
414
|
+
this.scheduleReconnect();
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
scheduleReconnect() {
|
|
418
|
+
const slowPhase = this.restartCount >= MAX_RESTARTS;
|
|
419
|
+
const delay = slowPhase ? SLOW_RESTART_DELAY_MS : AUTO_RESTART_DELAY_MS;
|
|
420
|
+
if (!slowPhase)
|
|
421
|
+
this.restartCount++;
|
|
422
|
+
this.logger.warn(`Reconnecting WhatsApp in ${delay}ms`, {
|
|
423
|
+
agentId: this.agentConfig.id,
|
|
424
|
+
attempt: this.restartCount,
|
|
425
|
+
slowPhase,
|
|
426
|
+
});
|
|
427
|
+
this.restartTimer = setTimeout(() => {
|
|
428
|
+
this.restartTimer = null;
|
|
429
|
+
if (!this.stopping)
|
|
430
|
+
void this.connect();
|
|
431
|
+
}, delay);
|
|
432
|
+
}
|
|
433
|
+
async handleMessagesUpsert(payload) {
|
|
434
|
+
// Only live/new messages, not history-sync backfill on (re)connect.
|
|
435
|
+
if (payload.type !== 'notify')
|
|
436
|
+
return;
|
|
437
|
+
for (const msg of payload.messages) {
|
|
438
|
+
if (msg.key?.fromMe)
|
|
439
|
+
continue; // bot-loop protection
|
|
440
|
+
const resolved = (0, whatsapp_access_1.resolveWhatsAppSource)(msg);
|
|
441
|
+
if (resolved.kind === 'other' || !resolved.conversationId)
|
|
442
|
+
continue;
|
|
443
|
+
const cfg = this.accountConfig();
|
|
444
|
+
const deniedAgentId = this.agentConfig.id;
|
|
445
|
+
if (!(0, whatsapp_access_1.isResolvedSourceAllowed)(cfg, resolved)) {
|
|
446
|
+
this.logger.debug('WhatsApp message denied', {
|
|
447
|
+
agentId: deniedAgentId,
|
|
448
|
+
accountId: this.accountId,
|
|
449
|
+
kind: resolved.kind,
|
|
450
|
+
conversationId: resolved.conversationId,
|
|
451
|
+
});
|
|
452
|
+
const knockId = resolved.kind === 'user' ? resolved.senderId : resolved.conversationId;
|
|
453
|
+
const isGroup = resolved.kind === 'group';
|
|
454
|
+
const sourcePolicy = isGroup ? cfg?.groupPolicy : cfg?.dmPolicy;
|
|
455
|
+
const isPairing = cfg?.pairing !== false && sourcePolicy !== 'open' && sourcePolicy !== 'disabled';
|
|
456
|
+
// Namespaced per account, not just 'whatsapp' — the same JID can
|
|
457
|
+
// legitimately knock on two different numbers linked to this agent,
|
|
458
|
+
// and without the account dimension a knock on one number would
|
|
459
|
+
// show up (and be approvable) against the other's allowlist too.
|
|
460
|
+
const pendingChannel = `whatsapp:${this.accountId}`;
|
|
461
|
+
const prev = (0, pending_senders_1.getPendingSender)(pendingChannel, deniedAgentId, knockId);
|
|
462
|
+
const code = prev?.code ?? (isPairing ? (0, pending_senders_1.generatePairingCode)() : undefined);
|
|
463
|
+
const wasNew = isGroup
|
|
464
|
+
? (0, pending_senders_1.recordDeniedConversation)(pendingChannel, deniedAgentId, knockId, 'group', undefined, Date.now(), code)
|
|
465
|
+
: (0, pending_senders_1.recordDeniedSender)(pendingChannel, deniedAgentId, knockId, undefined, Date.now(), code);
|
|
466
|
+
if (isPairing && wasNew && code) {
|
|
467
|
+
void this.sock?.sendMessage(resolved.conversationId, { text: pairingMessage(code, isGroup) })
|
|
468
|
+
.catch((err) => this.logger.debug('WhatsApp pairing code send failed', { error: err.message }));
|
|
469
|
+
}
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
// Group mention gate — mirrors Slack/LINE's requireMention (default true, no effect on DMs).
|
|
473
|
+
if (resolved.kind === 'group' && cfg?.requireMention !== false) {
|
|
474
|
+
const botJid = this.sock?.user?.id;
|
|
475
|
+
// `sock.user.lid` is the bot's own @lid identity under WhatsApp's Linked
|
|
476
|
+
// ID privacy system — groups can report the mention using either form.
|
|
477
|
+
const botLid = this.sock?.user?.lid;
|
|
478
|
+
if (!(0, whatsapp_access_1.wasBotMentioned)(resolved.mentionedJids, botJid, botLid))
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
// Phase 2 — remember the raw message before anything else touches it: a
|
|
482
|
+
// later quote-reply needs the whole WAMessage and a reaction needs its
|
|
483
|
+
// full key, but the MCP tool only ever hands back the id string.
|
|
484
|
+
if (msg.key?.id)
|
|
485
|
+
this.rememberMessage(msg.key.id, msg);
|
|
486
|
+
// Receipt signals, both best-effort and both config-gated. Fired right
|
|
487
|
+
// after the access gate (same call site as Slack's ack reaction in
|
|
488
|
+
// slack-webhook-router.ts), never awaited, never able to fail the turn.
|
|
489
|
+
// The ack reaction is cleared once the reply actually sends — see
|
|
490
|
+
// sendMessage's clearAckReaction call.
|
|
491
|
+
if (msg.key) {
|
|
492
|
+
if (cfg?.sendReadReceipts !== false) {
|
|
493
|
+
void this.markAsRead(resolved.conversationId, msg.key).catch(() => { });
|
|
494
|
+
}
|
|
495
|
+
if ((cfg?.reactionLevel ?? 'ack') === 'ack') {
|
|
496
|
+
void this.sendReaction(resolved.conversationId, msg.key, whatsapp_ack_1.WHATSAPP_ACK_EMOJI).catch(() => { });
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
let content = msg.message?.conversation ?? msg.message?.extendedTextMessage?.text ?? '';
|
|
500
|
+
const meta = {
|
|
501
|
+
source: 'whatsapp',
|
|
502
|
+
chat_id: resolved.conversationId,
|
|
503
|
+
user_id: resolved.senderId,
|
|
504
|
+
user: resolved.senderId,
|
|
505
|
+
message_id: msg.key?.id ?? '',
|
|
506
|
+
whatsapp_chat_type: resolved.kind,
|
|
507
|
+
// Which linked number this arrived on. Round-trips through the
|
|
508
|
+
// <channel> tag (AgentRunner.buildChannelXml's optionalAttrs) so the
|
|
509
|
+
// session can hand it straight back to whatsapp_reply, and is also
|
|
510
|
+
// stashed runner-side per chat so an auto-forward reply without an
|
|
511
|
+
// explicit account_id still leaves on the same number.
|
|
512
|
+
account_id: this.accountId,
|
|
513
|
+
};
|
|
514
|
+
// Reply context (Phase 2). Unlike the Cloud API — which only reports the
|
|
515
|
+
// quoted message's ID and nothing else — Baileys inlines the whole
|
|
516
|
+
// quoted message, so all three `replied_*` keys buildChannelXml reads
|
|
517
|
+
// can be populated here.
|
|
518
|
+
const quotedCtx = extractQuotedContext(msg.message);
|
|
519
|
+
if (quotedCtx?.stanzaId) {
|
|
520
|
+
meta.replied_message_id = quotedCtx.stanzaId;
|
|
521
|
+
if (quotedCtx.participant)
|
|
522
|
+
meta.replied_user = quotedCtx.participant;
|
|
523
|
+
const repliedText = quotedMessageText(quotedCtx.quotedMessage);
|
|
524
|
+
if (repliedText)
|
|
525
|
+
meta.replied_text = repliedText;
|
|
526
|
+
}
|
|
527
|
+
// Location — same meta keys the Cloud channel writes (location_lat /
|
|
528
|
+
// location_lng), so runner.ts needs no channel-specific attribute names.
|
|
529
|
+
const locationMsg = msg.message?.locationMessage;
|
|
530
|
+
if (locationMsg) {
|
|
531
|
+
if (typeof locationMsg.degreesLatitude === 'number') {
|
|
532
|
+
meta.location_lat = String(locationMsg.degreesLatitude);
|
|
533
|
+
}
|
|
534
|
+
if (typeof locationMsg.degreesLongitude === 'number') {
|
|
535
|
+
meta.location_lng = String(locationMsg.degreesLongitude);
|
|
536
|
+
}
|
|
537
|
+
if (!content) {
|
|
538
|
+
content = [locationMsg.name, locationMsg.address].filter(Boolean).join(', ') || '[Location shared]';
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
// Contact card. Baileys hands over the RAW vCard string the sender's
|
|
542
|
+
// phone produced, so it's used verbatim — the Cloud channel has to
|
|
543
|
+
// synthesize one instead (its payload is structured JSON, not a vCard).
|
|
544
|
+
const vcard = msg.message?.contactMessage?.vcard ??
|
|
545
|
+
msg.message?.contactsArrayMessage?.contacts?.find((c) => c.vcard)?.vcard;
|
|
546
|
+
if (vcard)
|
|
547
|
+
meta.vcard = vcard;
|
|
548
|
+
// Sticker — downloaded like an image but onto its own meta key, so the
|
|
549
|
+
// agent can tell a sticker apart from a photo.
|
|
550
|
+
if (msg.message?.stickerMessage) {
|
|
551
|
+
try {
|
|
552
|
+
const buf = (await this.baileys.downloadMediaMessage(msg, 'buffer', {}));
|
|
553
|
+
if (buf.length > 0 && buf.length <= MAX_IMAGE_BYTES) {
|
|
554
|
+
const dest = path.join(os.tmpdir(), `whatsapp-sticker-${(0, image_sniff_1.sanitizeFilenameId)(msg.key?.id)}.${(0, image_sniff_1.sniffImageExt)(buf)}`);
|
|
555
|
+
fs.writeFileSync(dest, buf, { mode: 0o600 });
|
|
556
|
+
meta.sticker_path = dest;
|
|
557
|
+
}
|
|
558
|
+
else if (buf.length > MAX_IMAGE_BYTES) {
|
|
559
|
+
this.logger.warn('Inbound WhatsApp sticker exceeds cap, dropping media', {
|
|
560
|
+
agentId: deniedAgentId,
|
|
561
|
+
bytes: buf.length,
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
catch (err) {
|
|
566
|
+
this.logger.warn('Inbound WhatsApp sticker download failed', {
|
|
567
|
+
agentId: deniedAgentId,
|
|
568
|
+
error: err.message,
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
// Inbound image — best-effort, same posture as Slack's downloadSlackImage:
|
|
573
|
+
// a failed download still forwards the text turn.
|
|
574
|
+
if (msg.message?.imageMessage) {
|
|
575
|
+
try {
|
|
576
|
+
const buf = (await this.baileys.downloadMediaMessage(msg, 'buffer', {}));
|
|
577
|
+
if (buf.length > 0 && buf.length <= MAX_IMAGE_BYTES) {
|
|
578
|
+
const dest = path.join(os.tmpdir(), `whatsapp-img-${(0, image_sniff_1.sanitizeFilenameId)(msg.key?.id)}.${(0, image_sniff_1.sniffImageExt)(buf)}`);
|
|
579
|
+
fs.writeFileSync(dest, buf, { mode: 0o600 });
|
|
580
|
+
meta.image_path = dest;
|
|
581
|
+
}
|
|
582
|
+
else if (buf.length > MAX_IMAGE_BYTES) {
|
|
583
|
+
this.logger.warn('Inbound WhatsApp image exceeds cap, dropping media', {
|
|
584
|
+
agentId: deniedAgentId,
|
|
585
|
+
bytes: buf.length,
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
catch (err) {
|
|
590
|
+
this.logger.warn('Inbound WhatsApp image download failed', {
|
|
591
|
+
agentId: deniedAgentId,
|
|
592
|
+
error: err.message,
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
try {
|
|
597
|
+
await fetch(`http://127.0.0.1:${this.callbackPort}/channel`, {
|
|
598
|
+
method: 'POST',
|
|
599
|
+
headers: { 'Content-Type': 'application/json' },
|
|
600
|
+
body: JSON.stringify({ content, meta }),
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
catch (err) {
|
|
604
|
+
this.logger.error('WhatsApp: failed to forward to callback', {
|
|
605
|
+
agentId: deniedAgentId,
|
|
606
|
+
error: err.message,
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
/** Keep the last RECENT_MESSAGE_CACHE_SIZE inbound messages addressable by id (FIFO). */
|
|
612
|
+
rememberMessage(id, msg) {
|
|
613
|
+
this.recentMessages.delete(id); // re-insert so this id becomes the newest
|
|
614
|
+
this.recentMessages.set(id, msg);
|
|
615
|
+
while (this.recentMessages.size > RECENT_MESSAGE_CACHE_SIZE) {
|
|
616
|
+
const oldest = this.recentMessages.keys().next().value;
|
|
617
|
+
if (oldest === undefined)
|
|
618
|
+
break;
|
|
619
|
+
this.recentMessages.delete(oldest);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* Mark an inbound message as read (blue double-ticks). Best-effort: logged,
|
|
624
|
+
* never thrown — same posture as the Cloud client's markAsRead and Slack's
|
|
625
|
+
* ack reaction. `jid` is only used for log context; Baileys addresses the
|
|
626
|
+
* read by key alone.
|
|
627
|
+
*/
|
|
628
|
+
async markAsRead(jid, key) {
|
|
629
|
+
if (!this.sock || !key)
|
|
630
|
+
return;
|
|
631
|
+
try {
|
|
632
|
+
await this.sock.readMessages([key]);
|
|
633
|
+
}
|
|
634
|
+
catch (err) {
|
|
635
|
+
this.logger.debug('WhatsApp markAsRead failed', { jid, error: err.message });
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* React to a message — an EMPTY `emoji` clears an existing reaction, which
|
|
640
|
+
* is how the ack is removed (same convention as the Cloud API). Best-effort,
|
|
641
|
+
* same as markAsRead.
|
|
642
|
+
*/
|
|
643
|
+
async sendReaction(jid, key, emoji) {
|
|
644
|
+
if (!this.sock || !key)
|
|
645
|
+
return;
|
|
646
|
+
try {
|
|
647
|
+
await this.sock.sendMessage(jid, { react: { text: emoji, key } });
|
|
648
|
+
}
|
|
649
|
+
catch (err) {
|
|
650
|
+
this.logger.debug('WhatsApp sendReaction failed', { jid, error: err.message });
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
/**
|
|
654
|
+
* Clear the ⏳ ack left on an inbound message at receipt.
|
|
655
|
+
*
|
|
656
|
+
* Unlike Slack (whose MCP tool holds a real API client and calls
|
|
657
|
+
* `removeReaction` itself) the `whatsapp_reply` tool has no socket of its
|
|
658
|
+
* own — every send is proxied through the gateway (see this class's doc
|
|
659
|
+
* comment). So the tool passes the inbound `message_id` along with the
|
|
660
|
+
* reply and the clear happens HERE, right after the send succeeds; the
|
|
661
|
+
* call site is the same logical one, just on the other side of the bridge.
|
|
662
|
+
*/
|
|
663
|
+
async clearAckReaction(jid, messageId) {
|
|
664
|
+
if ((this.accountConfig()?.reactionLevel ?? 'ack') !== 'ack')
|
|
665
|
+
return;
|
|
666
|
+
const key = this.recentMessages.get(messageId)?.key;
|
|
667
|
+
if (!key)
|
|
668
|
+
return;
|
|
669
|
+
await this.sendReaction(jid, key, '');
|
|
670
|
+
}
|
|
671
|
+
/**
|
|
672
|
+
* Native @mentions for an outbound GROUP reply.
|
|
673
|
+
*
|
|
674
|
+
* WhatsApp only renders "@66812345678" as a real, notifying mention when the
|
|
675
|
+
* matching JID is also listed in the message's `mentions` array — the text
|
|
676
|
+
* alone is inert. So: scan the reply for `@<digits>` tokens, look up the
|
|
677
|
+
* group's CURRENT participants, and return the JIDs that match. Matching
|
|
678
|
+
* reuses the same normalization the inbound mention gate uses
|
|
679
|
+
* (`whatsAppJidUser`, which strips the `:<device>` suffix) rather than a
|
|
680
|
+
* second, subtly-different comparison. Participants are also matched on
|
|
681
|
+
* their phone-number field, since a LID-privacy group reports `id` as
|
|
682
|
+
* `<opaque>@lid` while the human still types the phone number.
|
|
683
|
+
*
|
|
684
|
+
* Best-effort: a failed groupMetadata lookup just sends the reply without
|
|
685
|
+
* mentions rather than failing it.
|
|
686
|
+
*/
|
|
687
|
+
async resolveMentions(jid, text) {
|
|
688
|
+
if (!jid.endsWith('@g.us') || !text)
|
|
689
|
+
return undefined;
|
|
690
|
+
const wanted = new Set([...text.matchAll(/@(\d{5,})/g)].map((m) => m[1]));
|
|
691
|
+
if (wanted.size === 0)
|
|
692
|
+
return undefined;
|
|
693
|
+
try {
|
|
694
|
+
const metadata = await this.sock?.groupMetadata(jid);
|
|
695
|
+
const mentions = [];
|
|
696
|
+
for (const participant of metadata?.participants ?? []) {
|
|
697
|
+
const p = participant;
|
|
698
|
+
if (!p.id)
|
|
699
|
+
continue;
|
|
700
|
+
const identities = [p.id, p.jid, p.phoneNumber].filter((v) => !!v);
|
|
701
|
+
if (identities.some((i) => wanted.has((0, whatsapp_access_1.whatsAppJidUser)(i))))
|
|
702
|
+
mentions.push(p.id);
|
|
703
|
+
}
|
|
704
|
+
return mentions.length > 0 ? mentions : undefined;
|
|
705
|
+
}
|
|
706
|
+
catch (err) {
|
|
707
|
+
this.logger.debug('WhatsApp groupMetadata lookup for mentions failed', {
|
|
708
|
+
jid,
|
|
709
|
+
error: err.message,
|
|
710
|
+
});
|
|
711
|
+
return undefined;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* Send a text (+ optional image) reply on the live socket. Throws if not
|
|
716
|
+
* currently linked.
|
|
717
|
+
*
|
|
718
|
+
* Text over MAX_TEXT_CHARS is split into several messages (Phase 2); with
|
|
719
|
+
* an image attached the first chunk rides as the caption and the rest
|
|
720
|
+
* follow as plain messages. A quote (`quotedMessageId`) and the ack-clear
|
|
721
|
+
* (`ackMessageId`) both resolve through the recent-message cache, so a
|
|
722
|
+
* message older than that cache simply sends unquoted rather than failing.
|
|
723
|
+
*/
|
|
724
|
+
async sendMessage(jid, text, imagePath, opts = {}) {
|
|
725
|
+
if (!this.sock || this.status !== 'linked') {
|
|
726
|
+
throw new Error('WhatsApp is not linked');
|
|
727
|
+
}
|
|
728
|
+
const sock = this.sock;
|
|
729
|
+
const quoted = opts.quotedMessageId ? this.recentMessages.get(opts.quotedMessageId) : undefined;
|
|
730
|
+
// Only the FIRST message of a multi-chunk reply quotes the original —
|
|
731
|
+
// repeating the quote block on every chunk is visual noise.
|
|
732
|
+
const firstOpts = quoted ? { quoted } : undefined;
|
|
733
|
+
const mentions = await this.resolveMentions(jid, text);
|
|
734
|
+
const mentionField = mentions ? { mentions } : {};
|
|
735
|
+
// Baileys' third argument is optional — pass it ONLY when there is
|
|
736
|
+
// actually something to say, so an ordinary unquoted send keeps the exact
|
|
737
|
+
// two-argument call shape it had before quoting existed.
|
|
738
|
+
const send = (content, options) => options
|
|
739
|
+
? sock.sendMessage(jid, content, options)
|
|
740
|
+
: sock.sendMessage(jid, content);
|
|
741
|
+
// An empty text with no image keeps the historical single-send behaviour
|
|
742
|
+
// (chunkText returns [] for '') rather than silently sending nothing.
|
|
743
|
+
const chunks = (0, text_chunk_1.chunkText)(text, MAX_TEXT_CHARS);
|
|
744
|
+
const parts = chunks.length > 0 ? chunks : imagePath ? [] : [text];
|
|
745
|
+
if (imagePath) {
|
|
746
|
+
const stat = await fsp.stat(imagePath).catch(() => null);
|
|
747
|
+
if (!stat)
|
|
748
|
+
throw new Error(`image not found: ${imagePath}`);
|
|
749
|
+
// Auto-optimize an over-cap PHOTO instead of refusing the whole reply.
|
|
750
|
+
// Deliberately skipped when asDocument is set: that mode exists to
|
|
751
|
+
// deliver the exact bytes (see the forceDocument note below), so
|
|
752
|
+
// recompressing there would defeat the only reason to choose it — an
|
|
753
|
+
// over-cap document still throws, exactly as before.
|
|
754
|
+
//
|
|
755
|
+
// The throw below is kept as a backstop rather than removed: optimizeImage
|
|
756
|
+
// is best-effort and can hand back something still over the cap (a
|
|
757
|
+
// non-image file, sharp unavailable), and silently handing WhatsApp bytes
|
|
758
|
+
// it will reject is worse than a readable error the agent can act on.
|
|
759
|
+
let sendPath = imagePath;
|
|
760
|
+
let sendBytes = stat.size;
|
|
761
|
+
if (sendBytes > MAX_IMAGE_BYTES && !opts.asDocument) {
|
|
762
|
+
sendPath = await (0, image_optimize_1.optimizeImageFile)(imagePath, MAX_IMAGE_BYTES).catch(() => imagePath);
|
|
763
|
+
if (sendPath !== imagePath) {
|
|
764
|
+
sendBytes = (await fsp.stat(sendPath).catch(() => null))?.size ?? sendBytes;
|
|
765
|
+
this.logger.info('Outbound WhatsApp image optimized to fit the size cap', {
|
|
766
|
+
agentId: this.agentConfig.id,
|
|
767
|
+
originalBytes: stat.size,
|
|
768
|
+
optimizedBytes: sendBytes,
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
if (sendBytes > MAX_IMAGE_BYTES) {
|
|
773
|
+
throw new Error(`image exceeds ${MAX_IMAGE_BYTES} byte cap`);
|
|
774
|
+
}
|
|
775
|
+
const caption = parts[0] || undefined;
|
|
776
|
+
// forceDocument: WhatsApp re-compresses anything sent as `image`, which
|
|
777
|
+
// destroys fine detail in screenshots/diagrams the agent generated. Sent
|
|
778
|
+
// as a `document` the exact bytes arrive intact.
|
|
779
|
+
const content = opts.asDocument
|
|
780
|
+
? {
|
|
781
|
+
document: { url: imagePath },
|
|
782
|
+
mimetype: guessOutboundMimeType(imagePath),
|
|
783
|
+
fileName: path.basename(imagePath),
|
|
784
|
+
caption,
|
|
785
|
+
...mentionField,
|
|
786
|
+
}
|
|
787
|
+
: { image: { url: sendPath }, caption, ...mentionField };
|
|
788
|
+
await send(content, firstOpts);
|
|
789
|
+
for (const chunk of parts.slice(1)) {
|
|
790
|
+
await send({ text: chunk, ...mentionField });
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
else {
|
|
794
|
+
for (let i = 0; i < parts.length; i++) {
|
|
795
|
+
await send({ text: parts[i], ...mentionField }, i === 0 ? firstOpts : undefined);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
if (opts.ackMessageId) {
|
|
799
|
+
await this.clearAckReaction(jid, opts.ackMessageId).catch(() => { });
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
isLinked() {
|
|
803
|
+
return this.status === 'linked';
|
|
804
|
+
}
|
|
805
|
+
/** Logout and wipe the linked session — the user must scan/pair fresh afterward. */
|
|
806
|
+
async unlink() {
|
|
807
|
+
this.stopping = true;
|
|
808
|
+
if (this.restartTimer) {
|
|
809
|
+
clearTimeout(this.restartTimer);
|
|
810
|
+
this.restartTimer = null;
|
|
811
|
+
}
|
|
812
|
+
try {
|
|
813
|
+
await this.sock?.logout();
|
|
814
|
+
}
|
|
815
|
+
catch {
|
|
816
|
+
// best-effort — proceed to wipe state regardless
|
|
817
|
+
}
|
|
818
|
+
this.sock = null;
|
|
819
|
+
this.status = 'unlinked';
|
|
820
|
+
this.qrDataUri = undefined;
|
|
821
|
+
this.pairingCode = undefined;
|
|
822
|
+
this.phoneNumber = undefined;
|
|
823
|
+
this.loggedOut = false;
|
|
824
|
+
await this.wipeStateDir();
|
|
825
|
+
}
|
|
826
|
+
/** Tear down without wiping state (gateway shutdown — a reconnect on next boot should resume). */
|
|
827
|
+
stop() {
|
|
828
|
+
this.stopping = true;
|
|
829
|
+
if (this.restartTimer) {
|
|
830
|
+
clearTimeout(this.restartTimer);
|
|
831
|
+
this.restartTimer = null;
|
|
832
|
+
}
|
|
833
|
+
this.sock?.end(undefined);
|
|
834
|
+
this.sock = null;
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
exports.WhatsAppManager = WhatsAppManager;
|
|
838
|
+
//# sourceMappingURL=manager.js.map
|