voicecc 1.2.13 → 1.3.0
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/dashboard/dist/assets/index-CVP_3PYo.js +28 -0
- package/dashboard/dist/index.html +1 -1
- package/dashboard/routes/agents.ts +23 -1
- package/dashboard/routes/integrations.ts +12 -1
- package/dashboard/routes/whatsapp.ts +98 -0
- package/dashboard/server.ts +2 -0
- package/package.json +2 -1
- package/server/index.ts +12 -0
- package/server/services/agent-store.ts +1 -0
- package/server/services/whatsapp-groups.ts +289 -0
- package/server/services/whatsapp-integration.test.ts +343 -0
- package/server/services/whatsapp-manager.ts +395 -0
- package/server/services/whatsapp-message-handler.test.ts +272 -0
- package/server/services/whatsapp-message-handler.ts +429 -0
- package/voice-server/claude_session.py +68 -14
- package/voice-server/config.py +7 -0
- package/voice-server/heartbeat.py +72 -5
- package/voice-server/server.py +24 -24
- package/dashboard/dist/assets/index-DbjqXBdo.js +0 -28
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Baileys WhatsApp socket lifecycle manager.
|
|
3
|
+
*
|
|
4
|
+
* Manages the connection to WhatsApp via the Baileys library, including
|
|
5
|
+
* QR code generation for first-time linking, credential storage, reconnection
|
|
6
|
+
* with exponential backoff, and permanent credential revocation handling.
|
|
7
|
+
*
|
|
8
|
+
* Responsibilities:
|
|
9
|
+
* - Initialize and manage the Baileys socket connection
|
|
10
|
+
* - Generate QR codes for first-time WhatsApp linking
|
|
11
|
+
* - Store credentials at ~/.voicecc/whatsapp/ via useMultiFileAuthState
|
|
12
|
+
* - Reconnect with exponential backoff on temporary disconnects
|
|
13
|
+
* - Delete credentials and reset on permanent revocation (401/515)
|
|
14
|
+
* - Trigger group sync on successful connection
|
|
15
|
+
* - Forward incoming messages to the message handler
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import makeWASocket, {
|
|
19
|
+
useMultiFileAuthState,
|
|
20
|
+
DisconnectReason,
|
|
21
|
+
makeCacheableSignalKeyStore,
|
|
22
|
+
Browsers,
|
|
23
|
+
type WASocket,
|
|
24
|
+
type ConnectionState,
|
|
25
|
+
proto,
|
|
26
|
+
} from "baileys";
|
|
27
|
+
import { join } from "node:path";
|
|
28
|
+
import { homedir } from "node:os";
|
|
29
|
+
import { rmSync, existsSync, mkdirSync } from "node:fs";
|
|
30
|
+
import { syncAllGroups } from "./whatsapp-groups.js"; // Created in Phase 3
|
|
31
|
+
import { shouldHandleMessage, handleIncomingMessage } from "./whatsapp-message-handler.js"; // Created in Phase 4
|
|
32
|
+
|
|
33
|
+
// ============================================================================
|
|
34
|
+
// CONSTANTS
|
|
35
|
+
// ============================================================================
|
|
36
|
+
|
|
37
|
+
/** Directory where Baileys stores auth credentials */
|
|
38
|
+
const CREDENTIALS_DIR = join(process.env.VOICECC_DIR ?? join(homedir(), ".voicecc"), "whatsapp");
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* WhatsApp Web protocol version to use.
|
|
42
|
+
* Baileys hardcodes an outdated version that WA servers reject with 405.
|
|
43
|
+
* This must be updated when WhatsApp changes their accepted version range.
|
|
44
|
+
* See: https://github.com/WhiskeySockets/Baileys/issues/2376
|
|
45
|
+
*/
|
|
46
|
+
const WA_VERSION: [number, number, number] = [2, 3000, 1034074495];
|
|
47
|
+
|
|
48
|
+
/** Initial reconnect delay in milliseconds */
|
|
49
|
+
const RECONNECT_BASE_DELAY_MS = 2_000;
|
|
50
|
+
|
|
51
|
+
/** Maximum reconnect delay in milliseconds (2 minutes) */
|
|
52
|
+
const RECONNECT_MAX_DELAY_MS = 120_000;
|
|
53
|
+
|
|
54
|
+
/** Disconnect status codes that indicate permanent credential revocation (delete creds) */
|
|
55
|
+
const PERMANENT_DISCONNECT_CODES = [
|
|
56
|
+
DisconnectReason.loggedOut, // 401 -- user logged out from phone
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
/** Disconnect status codes that require a reconnect (keep creds) */
|
|
60
|
+
const RECONNECT_DISCONNECT_CODES = [
|
|
61
|
+
DisconnectReason.restartRequired, // 515 -- expected after pairing, also periodic server restart
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
// ============================================================================
|
|
65
|
+
// TYPES
|
|
66
|
+
// ============================================================================
|
|
67
|
+
|
|
68
|
+
/** Connection state exposed to the dashboard and other modules */
|
|
69
|
+
export interface WhatsAppConnectionState {
|
|
70
|
+
status: "disconnected" | "qr_pending" | "connecting" | "connected";
|
|
71
|
+
qrCode: string | null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ============================================================================
|
|
75
|
+
// STATE
|
|
76
|
+
// ============================================================================
|
|
77
|
+
|
|
78
|
+
/** Current connection state */
|
|
79
|
+
let connectionState: WhatsAppConnectionState = {
|
|
80
|
+
status: "disconnected",
|
|
81
|
+
qrCode: null,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** Active Baileys socket instance */
|
|
85
|
+
let socket: WASocket | null = null;
|
|
86
|
+
|
|
87
|
+
/** Current reconnect attempt count (resets on successful connection) */
|
|
88
|
+
let reconnectAttempt = 0;
|
|
89
|
+
|
|
90
|
+
/** Timer reference for pending reconnect */
|
|
91
|
+
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
92
|
+
|
|
93
|
+
/** Whether stopWhatsApp was called intentionally (prevents auto-reconnect) */
|
|
94
|
+
let manuallyStopped = false;
|
|
95
|
+
|
|
96
|
+
/** Whether we have ever successfully connected in this session (connection === "open") */
|
|
97
|
+
let hasBeenConnected = false;
|
|
98
|
+
|
|
99
|
+
// ============================================================================
|
|
100
|
+
// MAIN HANDLERS
|
|
101
|
+
// ============================================================================
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Initialize the Baileys socket and connect to WhatsApp.
|
|
105
|
+
* On first connect (no stored credentials), enters QR pending state.
|
|
106
|
+
* On successful connection, triggers group sync.
|
|
107
|
+
*
|
|
108
|
+
* @returns Resolves when the socket is initialized (not necessarily connected)
|
|
109
|
+
*/
|
|
110
|
+
export async function startWhatsApp(): Promise<void> {
|
|
111
|
+
if (socket) {
|
|
112
|
+
throw new Error("WhatsApp is already running");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
manuallyStopped = false;
|
|
116
|
+
reconnectAttempt = 0;
|
|
117
|
+
hasBeenConnected = false;
|
|
118
|
+
|
|
119
|
+
await createSocket();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Close the Baileys socket and set state to disconnected.
|
|
124
|
+
* Prevents automatic reconnection.
|
|
125
|
+
*/
|
|
126
|
+
export function stopWhatsApp(): void {
|
|
127
|
+
manuallyStopped = true;
|
|
128
|
+
clearReconnectTimer();
|
|
129
|
+
destroySocket();
|
|
130
|
+
connectionState = { status: "disconnected", qrCode: null };
|
|
131
|
+
console.log("WhatsApp connection stopped.");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Get the current WhatsApp connection state.
|
|
136
|
+
*
|
|
137
|
+
* @returns Current status and QR code (if pending)
|
|
138
|
+
*/
|
|
139
|
+
export function getConnectionState(): WhatsAppConnectionState {
|
|
140
|
+
return { ...connectionState };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Get the active Baileys socket for sending messages.
|
|
145
|
+
*
|
|
146
|
+
* @returns The active socket, or null if not connected
|
|
147
|
+
*/
|
|
148
|
+
export function getSocket(): WASocket | null {
|
|
149
|
+
if (connectionState.status !== "connected") {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
return socket;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Check if WhatsApp is currently connected.
|
|
157
|
+
*
|
|
158
|
+
* @returns True if connected
|
|
159
|
+
*/
|
|
160
|
+
export function isConnected(): boolean {
|
|
161
|
+
return connectionState.status === "connected";
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ============================================================================
|
|
165
|
+
// HELPER FUNCTIONS
|
|
166
|
+
// ============================================================================
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Create and configure a new Baileys socket with auth state and event handlers.
|
|
170
|
+
* Sets up connection.update and messages.upsert event listeners.
|
|
171
|
+
*/
|
|
172
|
+
async function createSocket(): Promise<void> {
|
|
173
|
+
// Ensure credentials directory exists
|
|
174
|
+
mkdirSync(CREDENTIALS_DIR, { recursive: true });
|
|
175
|
+
|
|
176
|
+
const { state, saveCreds } = await useMultiFileAuthState(CREDENTIALS_DIR);
|
|
177
|
+
|
|
178
|
+
connectionState = { status: "connecting", qrCode: null };
|
|
179
|
+
|
|
180
|
+
const sock = makeWASocket({
|
|
181
|
+
version: WA_VERSION,
|
|
182
|
+
auth: {
|
|
183
|
+
creds: state.creds,
|
|
184
|
+
keys: makeCacheableSignalKeyStore(state.keys),
|
|
185
|
+
},
|
|
186
|
+
browser: Browsers.macOS("VoiceCC"),
|
|
187
|
+
printQRInTerminal: false,
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
socket = sock;
|
|
191
|
+
|
|
192
|
+
// Save credentials whenever they are updated
|
|
193
|
+
sock.ev.on("creds.update", saveCreds);
|
|
194
|
+
|
|
195
|
+
// Handle connection state changes
|
|
196
|
+
sock.ev.on("connection.update", (update: Partial<ConnectionState>) => {
|
|
197
|
+
handleConnectionUpdate(update, sock);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// Handle incoming messages
|
|
201
|
+
sock.ev.on("messages.upsert", (upsert) => {
|
|
202
|
+
handleMessagesUpsert(upsert, sock);
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Handle Baileys connection.update events.
|
|
208
|
+
* Manages QR code display, successful connections, and disconnects.
|
|
209
|
+
*
|
|
210
|
+
* @param update - Partial connection state from Baileys
|
|
211
|
+
*/
|
|
212
|
+
function handleConnectionUpdate(update: Partial<ConnectionState>, sock: WASocket): void {
|
|
213
|
+
const { connection, qr, lastDisconnect } = update;
|
|
214
|
+
|
|
215
|
+
// QR code received -- user needs to scan
|
|
216
|
+
if (qr) {
|
|
217
|
+
connectionState = { status: "qr_pending", qrCode: qr };
|
|
218
|
+
console.log("WhatsApp QR code generated. Scan it from the dashboard.");
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (connection === "open") {
|
|
223
|
+
// Restore the socket reference (may have been nulled during a temporary close)
|
|
224
|
+
socket = sock;
|
|
225
|
+
connectionState = { status: "connected", qrCode: null };
|
|
226
|
+
reconnectAttempt = 0;
|
|
227
|
+
hasBeenConnected = true;
|
|
228
|
+
console.log("WhatsApp connected successfully.");
|
|
229
|
+
|
|
230
|
+
// Trigger group sync for all agents
|
|
231
|
+
syncAllGroups().catch((err: unknown) => {
|
|
232
|
+
console.error(`WhatsApp group sync failed: ${err}`);
|
|
233
|
+
});
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (connection === "close") {
|
|
238
|
+
const statusCode = extractDisconnectStatusCode(lastDisconnect?.error);
|
|
239
|
+
console.log(`WhatsApp disconnected with status code: ${statusCode ?? "unknown"}`);
|
|
240
|
+
|
|
241
|
+
// Clean up socket reference
|
|
242
|
+
socket = null;
|
|
243
|
+
|
|
244
|
+
// Permanent revocation (401): delete credentials and stop
|
|
245
|
+
if (statusCode !== null && PERMANENT_DISCONNECT_CODES.includes(statusCode)) {
|
|
246
|
+
console.log("WhatsApp credentials revoked. Deleting stored credentials.");
|
|
247
|
+
deleteCredentials();
|
|
248
|
+
connectionState = { status: "disconnected", qrCode: null };
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Restart required (515): reconnect immediately with existing credentials.
|
|
253
|
+
// This is expected after initial pairing and during periodic server restarts.
|
|
254
|
+
if (statusCode !== null && RECONNECT_DISCONNECT_CODES.includes(statusCode)) {
|
|
255
|
+
console.log("WhatsApp restart required. Reconnecting with existing credentials...");
|
|
256
|
+
connectionState = { status: "connecting", qrCode: null };
|
|
257
|
+
scheduleReconnect();
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Other disconnects: only auto-reconnect if we previously had a successful connection.
|
|
262
|
+
// During the initial pairing flow (QR scan), Baileys handles its own
|
|
263
|
+
// internal retries. Creating a new socket here would kill the pairing.
|
|
264
|
+
if (hasBeenConnected && !manuallyStopped) {
|
|
265
|
+
connectionState = { status: "connecting", qrCode: null };
|
|
266
|
+
scheduleReconnect();
|
|
267
|
+
} else {
|
|
268
|
+
connectionState = { status: "disconnected", qrCode: null };
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Handle incoming messages from Baileys.
|
|
275
|
+
* Filters messages via shouldHandleMessage and routes valid ones to handleIncomingMessage.
|
|
276
|
+
*
|
|
277
|
+
* @param upsert - The messages.upsert event payload from Baileys
|
|
278
|
+
* @param sock - The active Baileys socket
|
|
279
|
+
*/
|
|
280
|
+
function handleMessagesUpsert(
|
|
281
|
+
upsert: { messages: proto.IWebMessageInfo[]; type: string },
|
|
282
|
+
sock: WASocket
|
|
283
|
+
): void {
|
|
284
|
+
// Only process new messages (not history sync)
|
|
285
|
+
if (upsert.type !== "notify") {
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const ownJid = sock.user?.id;
|
|
290
|
+
if (!ownJid) {
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
for (const rawMsg of upsert.messages) {
|
|
295
|
+
const parsed = shouldHandleMessage(rawMsg, ownJid);
|
|
296
|
+
if (!parsed) {
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
handleIncomingMessage(parsed).catch((err: unknown) => {
|
|
301
|
+
console.error(`WhatsApp message handling failed for ${parsed.groupJid}: ${err}`);
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Extract the HTTP status code from a Baileys disconnect error.
|
|
308
|
+
* Baileys disconnect errors carry an output.statusCode property.
|
|
309
|
+
*
|
|
310
|
+
* @param error - The disconnect error
|
|
311
|
+
* @returns The status code, or null if not extractable
|
|
312
|
+
*/
|
|
313
|
+
function extractDisconnectStatusCode(error: Error | undefined): number | null {
|
|
314
|
+
if (!error) {
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Baileys disconnect errors have an output.statusCode property
|
|
319
|
+
const maybeStatusCode = (error as unknown as { output?: { statusCode?: number } }).output?.statusCode;
|
|
320
|
+
if (typeof maybeStatusCode === "number") {
|
|
321
|
+
return maybeStatusCode;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Schedule a reconnect attempt with exponential backoff.
|
|
329
|
+
* Doubles the delay on each attempt, capped at RECONNECT_MAX_DELAY_MS.
|
|
330
|
+
*/
|
|
331
|
+
function scheduleReconnect(): void {
|
|
332
|
+
clearReconnectTimer();
|
|
333
|
+
|
|
334
|
+
const delay = Math.min(
|
|
335
|
+
RECONNECT_BASE_DELAY_MS * Math.pow(2, reconnectAttempt),
|
|
336
|
+
RECONNECT_MAX_DELAY_MS
|
|
337
|
+
);
|
|
338
|
+
reconnectAttempt++;
|
|
339
|
+
|
|
340
|
+
console.log(`WhatsApp reconnecting in ${delay / 1000}s (attempt ${reconnectAttempt})...`);
|
|
341
|
+
|
|
342
|
+
reconnectTimer = setTimeout(async () => {
|
|
343
|
+
if (manuallyStopped) {
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
try {
|
|
348
|
+
await createSocket();
|
|
349
|
+
} catch (err: unknown) {
|
|
350
|
+
console.error(`WhatsApp reconnect failed: ${err}`);
|
|
351
|
+
if (!manuallyStopped) {
|
|
352
|
+
scheduleReconnect();
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}, delay);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Clear any pending reconnect timer.
|
|
360
|
+
*/
|
|
361
|
+
function clearReconnectTimer(): void {
|
|
362
|
+
if (reconnectTimer) {
|
|
363
|
+
clearTimeout(reconnectTimer);
|
|
364
|
+
reconnectTimer = null;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Close and clean up the active socket.
|
|
370
|
+
*/
|
|
371
|
+
function destroySocket(): void {
|
|
372
|
+
if (socket) {
|
|
373
|
+
try {
|
|
374
|
+
socket.end(undefined);
|
|
375
|
+
} catch {
|
|
376
|
+
// Socket may already be closed
|
|
377
|
+
}
|
|
378
|
+
socket = null;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Delete stored WhatsApp credentials from disk.
|
|
384
|
+
* Used when credentials are permanently revoked (401/515).
|
|
385
|
+
*/
|
|
386
|
+
function deleteCredentials(): void {
|
|
387
|
+
if (existsSync(CREDENTIALS_DIR)) {
|
|
388
|
+
try {
|
|
389
|
+
rmSync(CREDENTIALS_DIR, { recursive: true, force: true });
|
|
390
|
+
console.log(`Deleted WhatsApp credentials at ${CREDENTIALS_DIR}`);
|
|
391
|
+
} catch (err: unknown) {
|
|
392
|
+
console.error(`Failed to delete WhatsApp credentials: ${err}`);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for WhatsApp message handler functions.
|
|
3
|
+
*
|
|
4
|
+
* Tests shouldHandleMessage filtering and collectSseResponse SSE parsing.
|
|
5
|
+
*
|
|
6
|
+
* Run: node --experimental-test-module-mocks --import tsx/esm --test server/services/whatsapp-message-handler.test.ts
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, mock, beforeEach } from "node:test";
|
|
10
|
+
import { strict as assert } from "node:assert";
|
|
11
|
+
import { proto } from "baileys";
|
|
12
|
+
|
|
13
|
+
// ============================================================================
|
|
14
|
+
// MODULE MOCKS (must be set up before importing the module under test)
|
|
15
|
+
// ============================================================================
|
|
16
|
+
|
|
17
|
+
/** Tracks which groupJids are considered "mapped" to agents */
|
|
18
|
+
const mappedGroups: Map<string, string> = new Map();
|
|
19
|
+
|
|
20
|
+
mock.module("./whatsapp-manager.js", {
|
|
21
|
+
namedExports: {
|
|
22
|
+
getSocket: () => null,
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
mock.module("./whatsapp-groups.js", {
|
|
27
|
+
namedExports: {
|
|
28
|
+
getAgentIdForGroup: (jid: string) => mappedGroups.get(jid),
|
|
29
|
+
getLastSessionId: () => null,
|
|
30
|
+
setLastSessionId: async () => {},
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const { shouldHandleMessage, streamSseSegments, normalizeJid } = await import(
|
|
35
|
+
"./whatsapp-message-handler.js"
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
// ============================================================================
|
|
39
|
+
// HELPERS
|
|
40
|
+
// ============================================================================
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Build a minimal Baileys IWebMessageInfo-like object for testing.
|
|
44
|
+
*
|
|
45
|
+
* @param overrides - Fields to override on the default message
|
|
46
|
+
* @returns A fake Baileys message
|
|
47
|
+
*/
|
|
48
|
+
function buildMessage(overrides: {
|
|
49
|
+
remoteJid?: string;
|
|
50
|
+
participant?: string;
|
|
51
|
+
fromMe?: boolean;
|
|
52
|
+
text?: string | null;
|
|
53
|
+
id?: string;
|
|
54
|
+
} = {}): proto.IWebMessageInfo {
|
|
55
|
+
const {
|
|
56
|
+
remoteJid = "120363001234567890@g.us",
|
|
57
|
+
participant = "5511999998888@s.whatsapp.net",
|
|
58
|
+
fromMe = false,
|
|
59
|
+
text = "Hello agent",
|
|
60
|
+
id = "msg-001",
|
|
61
|
+
} = overrides;
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
key: {
|
|
65
|
+
remoteJid,
|
|
66
|
+
participant,
|
|
67
|
+
fromMe,
|
|
68
|
+
id,
|
|
69
|
+
},
|
|
70
|
+
message: text !== null
|
|
71
|
+
? { conversation: text }
|
|
72
|
+
: undefined,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Create a fake Response with an SSE body stream.
|
|
78
|
+
*
|
|
79
|
+
* @param sseEvents - Array of SSE event strings (each is a "data: {...}" line)
|
|
80
|
+
* @param status - HTTP status code
|
|
81
|
+
* @returns A Response-like object
|
|
82
|
+
*/
|
|
83
|
+
function buildSseResponse(sseEvents: string[], status = 200): Response {
|
|
84
|
+
const sseText = sseEvents.map((e) => `data: ${e}\n\n`).join("");
|
|
85
|
+
const encoder = new TextEncoder();
|
|
86
|
+
const encoded = encoder.encode(sseText);
|
|
87
|
+
|
|
88
|
+
const stream = new ReadableStream({
|
|
89
|
+
start(controller) {
|
|
90
|
+
controller.enqueue(encoded);
|
|
91
|
+
controller.close();
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
return new Response(stream, {
|
|
96
|
+
status,
|
|
97
|
+
headers: { "Content-Type": "text/event-stream" },
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ============================================================================
|
|
102
|
+
// TESTS: shouldHandleMessage
|
|
103
|
+
// ============================================================================
|
|
104
|
+
|
|
105
|
+
describe("shouldHandleMessage", () => {
|
|
106
|
+
const ownJid = "5511999991111@s.whatsapp.net";
|
|
107
|
+
const mappedGroupJid = "120363001234567890@g.us";
|
|
108
|
+
|
|
109
|
+
beforeEach(() => {
|
|
110
|
+
mappedGroups.clear();
|
|
111
|
+
mappedGroups.set(mappedGroupJid, "agent-1");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("returns null for DMs (user JID, not a group)", () => {
|
|
115
|
+
const msg = buildMessage({ remoteJid: "5511999998888@s.whatsapp.net" });
|
|
116
|
+
const result = shouldHandleMessage(msg, ownJid);
|
|
117
|
+
assert.equal(result, null);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("returns null for status broadcasts", () => {
|
|
121
|
+
const msg = buildMessage({ remoteJid: "status@broadcast" });
|
|
122
|
+
const result = shouldHandleMessage(msg, ownJid);
|
|
123
|
+
assert.equal(result, null);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("returns null for messages from the bot itself (fromMe: true)", () => {
|
|
127
|
+
const msg = buildMessage({
|
|
128
|
+
remoteJid: mappedGroupJid,
|
|
129
|
+
fromMe: true,
|
|
130
|
+
participant: ownJid,
|
|
131
|
+
});
|
|
132
|
+
const result = shouldHandleMessage(msg, ownJid);
|
|
133
|
+
assert.equal(result, null);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("returns null for messages in unmapped groups", () => {
|
|
137
|
+
const unmappedGroupJid = "120363009999999999@g.us";
|
|
138
|
+
const msg = buildMessage({ remoteJid: unmappedGroupJid });
|
|
139
|
+
const result = shouldHandleMessage(msg, ownJid);
|
|
140
|
+
assert.equal(result, null);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("returns null for non-text messages (no message content)", () => {
|
|
144
|
+
const msg = buildMessage({ remoteJid: mappedGroupJid, text: null });
|
|
145
|
+
const result = shouldHandleMessage(msg, ownJid);
|
|
146
|
+
assert.equal(result, null);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("returns null when remoteJid is missing", () => {
|
|
150
|
+
const msg = {
|
|
151
|
+
key: { remoteJid: undefined, participant: "someone@s.whatsapp.net", fromMe: false },
|
|
152
|
+
message: { conversation: "Hello" },
|
|
153
|
+
};
|
|
154
|
+
const result = shouldHandleMessage(msg, ownJid);
|
|
155
|
+
assert.equal(result, null);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("returns a WhatsAppIncomingMessage for valid text in a mapped group", () => {
|
|
159
|
+
const msg = buildMessage({
|
|
160
|
+
remoteJid: mappedGroupJid,
|
|
161
|
+
participant: "5511999998888@s.whatsapp.net",
|
|
162
|
+
text: "What is the weather?",
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const result = shouldHandleMessage(msg, ownJid);
|
|
166
|
+
assert.notEqual(result, null);
|
|
167
|
+
assert.equal(result!.groupJid, mappedGroupJid);
|
|
168
|
+
assert.equal(result!.senderJid, "5511999998888@s.whatsapp.net");
|
|
169
|
+
assert.equal(result!.text, "What is the weather?");
|
|
170
|
+
assert.equal(result!.messageId, "msg-001");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("handles JID normalization -- ownJid with :0 device suffix", () => {
|
|
174
|
+
const ownJidWithDevice = "5511999991111:0@s.whatsapp.net";
|
|
175
|
+
// Sender JID matches ownJid after stripping the :0 suffix, but fromMe=false
|
|
176
|
+
// so it should NOT be filtered as "bot itself". However, the sender and owner
|
|
177
|
+
// have different numbers, so this message passes through.
|
|
178
|
+
const msg = buildMessage({
|
|
179
|
+
remoteJid: mappedGroupJid,
|
|
180
|
+
participant: "5511999998888@s.whatsapp.net",
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const result = shouldHandleMessage(msg, ownJidWithDevice);
|
|
184
|
+
assert.notEqual(result, null);
|
|
185
|
+
assert.equal(result!.groupJid, mappedGroupJid);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("normalizeJid strips :0 device suffix correctly", () => {
|
|
189
|
+
assert.equal(normalizeJid("5511999991111:0@s.whatsapp.net"), "5511999991111@s.whatsapp.net");
|
|
190
|
+
assert.equal(normalizeJid("5511999991111:42@s.whatsapp.net"), "5511999991111@s.whatsapp.net");
|
|
191
|
+
assert.equal(normalizeJid("5511999991111@s.whatsapp.net"), "5511999991111@s.whatsapp.net");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("filters out messages where normalized sender matches normalized ownJid", () => {
|
|
195
|
+
// Sender has :0 suffix, ownJid does not, but after normalization they match
|
|
196
|
+
const msg = buildMessage({
|
|
197
|
+
remoteJid: mappedGroupJid,
|
|
198
|
+
participant: "5511999991111:0@s.whatsapp.net",
|
|
199
|
+
fromMe: false,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const result = shouldHandleMessage(msg, ownJid);
|
|
203
|
+
assert.equal(result, null);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// ============================================================================
|
|
208
|
+
// HELPERS: collect all segments from an async generator
|
|
209
|
+
// ============================================================================
|
|
210
|
+
|
|
211
|
+
import type { SseSegment } from "./whatsapp-message-handler.js";
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Collect all segments from the streamSseSegments async generator into an array.
|
|
215
|
+
*
|
|
216
|
+
* @param gen - The async generator to drain
|
|
217
|
+
* @returns Array of all yielded SseSegment objects
|
|
218
|
+
*/
|
|
219
|
+
async function collectSegments(gen: AsyncGenerator<SseSegment>): Promise<SseSegment[]> {
|
|
220
|
+
const segments: SseSegment[] = [];
|
|
221
|
+
for await (const segment of gen) {
|
|
222
|
+
segments.push(segment);
|
|
223
|
+
}
|
|
224
|
+
return segments;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ============================================================================
|
|
228
|
+
// TESTS: streamSseSegments
|
|
229
|
+
// ============================================================================
|
|
230
|
+
|
|
231
|
+
describe("streamSseSegments", () => {
|
|
232
|
+
it("yields separate segments at tool_start boundaries", async () => {
|
|
233
|
+
const events = [
|
|
234
|
+
JSON.stringify({ type: "text_delta", content: "Hello" }),
|
|
235
|
+
JSON.stringify({ type: "tool_start", toolName: "read_file" }),
|
|
236
|
+
JSON.stringify({ type: "text_delta", content: "Done" }),
|
|
237
|
+
JSON.stringify({ type: "result", sessionId: "sess-abc-123" }),
|
|
238
|
+
];
|
|
239
|
+
|
|
240
|
+
const response = buildSseResponse(events);
|
|
241
|
+
const segments = await collectSegments(streamSseSegments(response));
|
|
242
|
+
|
|
243
|
+
assert.equal(segments.length, 2);
|
|
244
|
+
assert.equal(segments[0]!.text, "Hello");
|
|
245
|
+
assert.equal(segments[0]!.sessionId, null);
|
|
246
|
+
assert.equal(segments[1]!.text, "Done");
|
|
247
|
+
assert.equal(segments[1]!.sessionId, "sess-abc-123");
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("yields single segment when no tool calls", async () => {
|
|
251
|
+
const events = [
|
|
252
|
+
JSON.stringify({ type: "text_delta", content: "Hello " }),
|
|
253
|
+
JSON.stringify({ type: "text_delta", content: "world!" }),
|
|
254
|
+
JSON.stringify({ type: "result", sessionId: "sess-abc-123" }),
|
|
255
|
+
];
|
|
256
|
+
|
|
257
|
+
const response = buildSseResponse(events);
|
|
258
|
+
const segments = await collectSegments(streamSseSegments(response));
|
|
259
|
+
|
|
260
|
+
assert.equal(segments.length, 1);
|
|
261
|
+
assert.equal(segments[0]!.text, "Hello world!");
|
|
262
|
+
assert.equal(segments[0]!.sessionId, "sess-abc-123");
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("yields isAlreadyStreaming segment for HTTP 409", async () => {
|
|
266
|
+
const response = new Response("Already streaming", { status: 409 });
|
|
267
|
+
const segments = await collectSegments(streamSseSegments(response));
|
|
268
|
+
|
|
269
|
+
assert.equal(segments.length, 1);
|
|
270
|
+
assert.equal(segments[0]!.isAlreadyStreaming, true);
|
|
271
|
+
});
|
|
272
|
+
});
|