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,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for WhatsApp message round-trip and group sync.
|
|
3
|
+
*
|
|
4
|
+
* Tests the full message handling pipeline (message -> Python -> reply) and
|
|
5
|
+
* the group lifecycle when agents are created/deleted.
|
|
6
|
+
*
|
|
7
|
+
* Run: node --experimental-test-module-mocks --import tsx/esm --test server/services/whatsapp-integration.test.ts
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, it, mock, beforeEach } from "node:test";
|
|
11
|
+
import { strict as assert } from "node:assert";
|
|
12
|
+
|
|
13
|
+
// ============================================================================
|
|
14
|
+
// MODULE MOCKS (must be set up before importing modules under test)
|
|
15
|
+
// ============================================================================
|
|
16
|
+
|
|
17
|
+
/** In-memory group mappings used by the mocked whatsapp-groups module */
|
|
18
|
+
const groupMappings: Map<string, { agentId: string; lastSessionId: string | null }> = new Map();
|
|
19
|
+
|
|
20
|
+
/** Tracks calls to sock.sendMessage */
|
|
21
|
+
const sendMessageCalls: Array<{ jid: string; content: { text: string } }> = [];
|
|
22
|
+
|
|
23
|
+
/** Tracks calls to sock.groupCreate */
|
|
24
|
+
const groupCreateCalls: Array<{ name: string; participants: string[] }> = [];
|
|
25
|
+
|
|
26
|
+
/** Tracks calls to sock.groupLeave */
|
|
27
|
+
const groupLeaveCalls: Array<{ jid: string }> = [];
|
|
28
|
+
|
|
29
|
+
/** Counter for generating unique group JIDs from groupCreate */
|
|
30
|
+
let groupCreateCounter = 0;
|
|
31
|
+
|
|
32
|
+
/** Mock Baileys socket */
|
|
33
|
+
const mockSocket = {
|
|
34
|
+
sendMessage: async (jid: string, content: { text: string }) => {
|
|
35
|
+
sendMessageCalls.push({ jid, content });
|
|
36
|
+
},
|
|
37
|
+
groupCreate: async (name: string, participants: string[]) => {
|
|
38
|
+
groupCreateCalls.push({ name, participants });
|
|
39
|
+
groupCreateCounter++;
|
|
40
|
+
return { id: `new-group-${groupCreateCounter}@g.us` };
|
|
41
|
+
},
|
|
42
|
+
groupLeave: async (jid: string) => {
|
|
43
|
+
groupLeaveCalls.push({ jid });
|
|
44
|
+
},
|
|
45
|
+
groupFetchAllParticipating: async () => ({}),
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// Mock whatsapp-manager to return our mock socket
|
|
49
|
+
mock.module("./whatsapp-manager.js", {
|
|
50
|
+
namedExports: {
|
|
51
|
+
getSocket: () => mockSocket,
|
|
52
|
+
isConnected: () => true,
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// Mock whatsapp-groups with in-memory state that we control
|
|
57
|
+
mock.module("./whatsapp-groups.js", {
|
|
58
|
+
namedExports: {
|
|
59
|
+
getAgentIdForGroup: (jid: string) => groupMappings.get(jid)?.agentId,
|
|
60
|
+
getLastSessionId: (jid: string) => groupMappings.get(jid)?.lastSessionId ?? null,
|
|
61
|
+
setLastSessionId: async (jid: string, sessionId: string) => {
|
|
62
|
+
const existing = groupMappings.get(jid);
|
|
63
|
+
if (existing) {
|
|
64
|
+
existing.lastSessionId = sessionId;
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
syncGroupsForNewAgent: async (agentId: string) => {
|
|
68
|
+
// Delegate to the real-ish logic using our mock socket
|
|
69
|
+
const name = `[VoiceCC] ${agentId}`;
|
|
70
|
+
const result = await mockSocket.groupCreate(name, []);
|
|
71
|
+
groupMappings.set(result.id, { agentId, lastSessionId: null });
|
|
72
|
+
},
|
|
73
|
+
syncGroupsForDeletedAgent: async (agentId: string) => {
|
|
74
|
+
for (const [jid, mapping] of groupMappings.entries()) {
|
|
75
|
+
if (mapping.agentId === agentId) {
|
|
76
|
+
await mockSocket.groupLeave(jid);
|
|
77
|
+
groupMappings.delete(jid);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
formatGroupName: (agentId: string) => `[VoiceCC] ${agentId}`,
|
|
83
|
+
loadMappings: async () => {},
|
|
84
|
+
saveMappings: async () => {},
|
|
85
|
+
findMappingByAgentId: (agentId: string) => {
|
|
86
|
+
for (const [jid, mapping] of groupMappings.entries()) {
|
|
87
|
+
if (mapping.agentId === agentId) {
|
|
88
|
+
return { groupJid: jid, ...mapping };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return undefined;
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// Mock agent-store for the group sync tests
|
|
97
|
+
mock.module("./agent-store.js", {
|
|
98
|
+
namedExports: {
|
|
99
|
+
listAgents: async () => [],
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const { handleIncomingMessage } = await import("./whatsapp-message-handler.js");
|
|
104
|
+
const {
|
|
105
|
+
syncGroupsForNewAgent,
|
|
106
|
+
syncGroupsForDeletedAgent,
|
|
107
|
+
} = await import("./whatsapp-groups.js");
|
|
108
|
+
|
|
109
|
+
// ============================================================================
|
|
110
|
+
// HELPERS
|
|
111
|
+
// ============================================================================
|
|
112
|
+
|
|
113
|
+
/** The original global fetch, used to restore after mocking */
|
|
114
|
+
const originalFetch = globalThis.fetch;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Build a mock SSE response from Python /chat/send.
|
|
118
|
+
*
|
|
119
|
+
* @param events - Array of SSE event payloads
|
|
120
|
+
* @param status - HTTP status code
|
|
121
|
+
* @returns A Response with SSE body
|
|
122
|
+
*/
|
|
123
|
+
function buildPythonResponse(
|
|
124
|
+
events: Array<{ type: string; content?: string; sessionId?: string; toolName?: string }>,
|
|
125
|
+
status = 200
|
|
126
|
+
): Response {
|
|
127
|
+
const sseText = events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join("");
|
|
128
|
+
const stream = new ReadableStream({
|
|
129
|
+
start(controller) {
|
|
130
|
+
controller.enqueue(new TextEncoder().encode(sseText));
|
|
131
|
+
controller.close();
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
return new Response(stream, {
|
|
135
|
+
status,
|
|
136
|
+
headers: { "Content-Type": "text/event-stream" },
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Parse the JSON body from a captured fetch call.
|
|
142
|
+
*
|
|
143
|
+
* @param fetchCall - The arguments passed to fetch
|
|
144
|
+
* @returns The parsed JSON body
|
|
145
|
+
*/
|
|
146
|
+
async function parseFetchBody(fetchCall: [string, RequestInit]): Promise<Record<string, unknown>> {
|
|
147
|
+
const body = fetchCall[1].body as string;
|
|
148
|
+
return JSON.parse(body);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ============================================================================
|
|
152
|
+
// TESTS: Full message round-trip
|
|
153
|
+
// ============================================================================
|
|
154
|
+
|
|
155
|
+
describe("handleIncomingMessage - message round-trip", () => {
|
|
156
|
+
const groupJid = "120363001234567890@g.us";
|
|
157
|
+
|
|
158
|
+
beforeEach(() => {
|
|
159
|
+
// Reset state
|
|
160
|
+
sendMessageCalls.length = 0;
|
|
161
|
+
groupMappings.clear();
|
|
162
|
+
groupMappings.set(groupJid, { agentId: "agent-1", lastSessionId: "prev-session-42" });
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("calls Python with correct params and sends reply back via Baileys", async (t) => {
|
|
166
|
+
// Track fetch calls
|
|
167
|
+
const fetchCalls: Array<[string, RequestInit]> = [];
|
|
168
|
+
|
|
169
|
+
// Mock global fetch to return a known SSE response
|
|
170
|
+
t.after(() => { globalThis.fetch = originalFetch; });
|
|
171
|
+
globalThis.fetch = (async (url: string, init: RequestInit) => {
|
|
172
|
+
fetchCalls.push([url, init]);
|
|
173
|
+
return buildPythonResponse([
|
|
174
|
+
{ type: "text_delta", content: "I am " },
|
|
175
|
+
{ type: "text_delta", content: "agent-1." },
|
|
176
|
+
{ type: "result", sessionId: "new-session-99" },
|
|
177
|
+
]);
|
|
178
|
+
}) as typeof fetch;
|
|
179
|
+
|
|
180
|
+
await handleIncomingMessage({
|
|
181
|
+
groupJid,
|
|
182
|
+
senderJid: "5511999998888@s.whatsapp.net",
|
|
183
|
+
text: "Hello agent",
|
|
184
|
+
messageId: "msg-001",
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// Verify Python was called with correct params
|
|
188
|
+
assert.equal(fetchCalls.length, 1);
|
|
189
|
+
const [url, _init] = fetchCalls[0]!;
|
|
190
|
+
assert.ok(url.endsWith("/chat/send"));
|
|
191
|
+
|
|
192
|
+
const body = await parseFetchBody(fetchCalls[0]!);
|
|
193
|
+
assert.equal(body.session_key, `wa:${groupJid}`);
|
|
194
|
+
assert.equal(body.agent_id, "agent-1");
|
|
195
|
+
assert.equal(body.resume_session_id, "prev-session-42");
|
|
196
|
+
assert.equal(body.text, "Hello agent");
|
|
197
|
+
|
|
198
|
+
// Verify reply was sent back via Baileys
|
|
199
|
+
assert.equal(sendMessageCalls.length, 1);
|
|
200
|
+
assert.equal(sendMessageCalls[0]!.jid, groupJid);
|
|
201
|
+
assert.equal(sendMessageCalls[0]!.content.text, "[voicecc] I am agent-1.");
|
|
202
|
+
|
|
203
|
+
// Verify session ID was stored
|
|
204
|
+
assert.equal(groupMappings.get(groupJid)?.lastSessionId, "new-session-99");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("sends 'Still thinking' when Python returns HTTP 409", async (t) => {
|
|
208
|
+
t.after(() => { globalThis.fetch = originalFetch; });
|
|
209
|
+
globalThis.fetch = (async () => {
|
|
210
|
+
return new Response("Already streaming", { status: 409 });
|
|
211
|
+
}) as typeof fetch;
|
|
212
|
+
|
|
213
|
+
await handleIncomingMessage({
|
|
214
|
+
groupJid,
|
|
215
|
+
senderJid: "5511999998888@s.whatsapp.net",
|
|
216
|
+
text: "Another question",
|
|
217
|
+
messageId: "msg-002",
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// Verify "Still thinking" reply was sent
|
|
221
|
+
assert.equal(sendMessageCalls.length, 1);
|
|
222
|
+
assert.equal(sendMessageCalls[0]!.jid, groupJid);
|
|
223
|
+
assert.equal(sendMessageCalls[0]!.content.text, "Still thinking, please wait...");
|
|
224
|
+
|
|
225
|
+
// Session ID should not have changed
|
|
226
|
+
assert.equal(groupMappings.get(groupJid)?.lastSessionId, "prev-session-42");
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("passes null resume_session_id when no previous session exists", async (t) => {
|
|
230
|
+
// Clear the previous session
|
|
231
|
+
groupMappings.set(groupJid, { agentId: "agent-1", lastSessionId: null });
|
|
232
|
+
|
|
233
|
+
const fetchCalls: Array<[string, RequestInit]> = [];
|
|
234
|
+
t.after(() => { globalThis.fetch = originalFetch; });
|
|
235
|
+
globalThis.fetch = (async (url: string, init: RequestInit) => {
|
|
236
|
+
fetchCalls.push([url, init]);
|
|
237
|
+
return buildPythonResponse([
|
|
238
|
+
{ type: "text_delta", content: "First reply" },
|
|
239
|
+
{ type: "result", sessionId: "first-session" },
|
|
240
|
+
]);
|
|
241
|
+
}) as typeof fetch;
|
|
242
|
+
|
|
243
|
+
await handleIncomingMessage({
|
|
244
|
+
groupJid,
|
|
245
|
+
senderJid: "5511999998888@s.whatsapp.net",
|
|
246
|
+
text: "First message",
|
|
247
|
+
messageId: "msg-003",
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
const body = await parseFetchBody(fetchCalls[0]!);
|
|
251
|
+
assert.equal(body.resume_session_id, null);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("sends multiple WhatsApp messages when response has tool boundaries", async (t) => {
|
|
255
|
+
t.after(() => { globalThis.fetch = originalFetch; });
|
|
256
|
+
globalThis.fetch = (async () => {
|
|
257
|
+
return buildPythonResponse([
|
|
258
|
+
{ type: "text_delta", content: "Looking that up..." },
|
|
259
|
+
{ type: "tool_start", toolName: "read_file" },
|
|
260
|
+
{ type: "text_delta", content: "Here is the answer." },
|
|
261
|
+
{ type: "result", sessionId: "new-session-99" },
|
|
262
|
+
]);
|
|
263
|
+
}) as typeof fetch;
|
|
264
|
+
|
|
265
|
+
await handleIncomingMessage({
|
|
266
|
+
groupJid,
|
|
267
|
+
senderJid: "5511999998888@s.whatsapp.net",
|
|
268
|
+
text: "What does the config say?",
|
|
269
|
+
messageId: "msg-004",
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// Two separate messages: one before tool, one after
|
|
273
|
+
assert.equal(sendMessageCalls.length, 2);
|
|
274
|
+
assert.equal(sendMessageCalls[0]!.jid, groupJid);
|
|
275
|
+
assert.equal(sendMessageCalls[0]!.content.text, "[voicecc] Looking that up...");
|
|
276
|
+
assert.equal(sendMessageCalls[1]!.jid, groupJid);
|
|
277
|
+
assert.equal(sendMessageCalls[1]!.content.text, "[voicecc] Here is the answer.");
|
|
278
|
+
|
|
279
|
+
// Session ID should be stored
|
|
280
|
+
assert.equal(groupMappings.get(groupJid)?.lastSessionId, "new-session-99");
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// ============================================================================
|
|
285
|
+
// TESTS: Group sync on agent lifecycle
|
|
286
|
+
// ============================================================================
|
|
287
|
+
|
|
288
|
+
describe("Group sync on agent lifecycle", () => {
|
|
289
|
+
beforeEach(() => {
|
|
290
|
+
groupMappings.clear();
|
|
291
|
+
groupCreateCalls.length = 0;
|
|
292
|
+
groupLeaveCalls.length = 0;
|
|
293
|
+
sendMessageCalls.length = 0;
|
|
294
|
+
groupCreateCounter = 0;
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("syncGroupsForNewAgent creates a group with [VoiceCC] prefix and persists mapping", async () => {
|
|
298
|
+
await syncGroupsForNewAgent("sales-bot");
|
|
299
|
+
|
|
300
|
+
// Verify groupCreate was called with the correct name
|
|
301
|
+
assert.equal(groupCreateCalls.length, 1);
|
|
302
|
+
assert.equal(groupCreateCalls[0]!.name, "[VoiceCC] sales-bot");
|
|
303
|
+
assert.deepEqual(groupCreateCalls[0]!.participants, []);
|
|
304
|
+
|
|
305
|
+
// Verify mapping was persisted
|
|
306
|
+
const mapping = groupMappings.get("new-group-1@g.us");
|
|
307
|
+
assert.notEqual(mapping, undefined);
|
|
308
|
+
assert.equal(mapping!.agentId, "sales-bot");
|
|
309
|
+
assert.equal(mapping!.lastSessionId, null);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("syncGroupsForDeletedAgent leaves the group and removes mapping", async () => {
|
|
313
|
+
// First create a mapping
|
|
314
|
+
groupMappings.set("existing-group@g.us", { agentId: "old-agent", lastSessionId: "sess-1" });
|
|
315
|
+
|
|
316
|
+
await syncGroupsForDeletedAgent("old-agent");
|
|
317
|
+
|
|
318
|
+
// Verify groupLeave was called
|
|
319
|
+
assert.equal(groupLeaveCalls.length, 1);
|
|
320
|
+
assert.equal(groupLeaveCalls[0]!.jid, "existing-group@g.us");
|
|
321
|
+
|
|
322
|
+
// Verify mapping was removed
|
|
323
|
+
assert.equal(groupMappings.has("existing-group@g.us"), false);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("syncGroupsForDeletedAgent does nothing for unmapped agent", async () => {
|
|
327
|
+
await syncGroupsForDeletedAgent("nonexistent-agent");
|
|
328
|
+
|
|
329
|
+
assert.equal(groupLeaveCalls.length, 0);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("creating then deleting an agent results in clean state", async () => {
|
|
333
|
+
// Create
|
|
334
|
+
await syncGroupsForNewAgent("temp-agent");
|
|
335
|
+
assert.equal(groupCreateCalls.length, 1);
|
|
336
|
+
assert.equal(groupMappings.size, 1);
|
|
337
|
+
|
|
338
|
+
// Delete
|
|
339
|
+
await syncGroupsForDeletedAgent("temp-agent");
|
|
340
|
+
assert.equal(groupLeaveCalls.length, 1);
|
|
341
|
+
assert.equal(groupMappings.size, 0);
|
|
342
|
+
});
|
|
343
|
+
});
|