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
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>Claude Voice</title>
|
|
7
|
-
<script type="module" crossorigin src="/assets/index-
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-CVP_3PYo.js"></script>
|
|
8
8
|
<link rel="stylesheet" crossorigin href="/assets/index-DeN1WDo9.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body>
|
|
@@ -24,6 +24,8 @@ import {
|
|
|
24
24
|
import type { AgentConfig } from "../../server/services/agent-store.js";
|
|
25
25
|
import { readEnv } from "../../server/services/env.js";
|
|
26
26
|
import { getTunnelUrl } from "../../server/services/tunnel.js";
|
|
27
|
+
import { syncGroupsForNewAgent, syncGroupsForDeletedAgent } from "../../server/services/whatsapp-groups.js";
|
|
28
|
+
import { isConnected as isWhatsAppConnected } from "../../server/services/whatsapp-manager.js";
|
|
27
29
|
|
|
28
30
|
/** Base URL for the Python voice server API */
|
|
29
31
|
const VOICE_API_URL = process.env.VOICE_SERVER_URL ?? "http://localhost:7861";
|
|
@@ -125,10 +127,20 @@ export function agentsRoutes(): Hono {
|
|
|
125
127
|
|
|
126
128
|
try {
|
|
127
129
|
await createAgent(body.id, body.soulMd, body.heartbeatMd, body.config);
|
|
128
|
-
return c.json({ success: true });
|
|
129
130
|
} catch (err) {
|
|
130
131
|
return c.json({ error: (err as Error).message }, 400);
|
|
131
132
|
}
|
|
133
|
+
|
|
134
|
+
// Sync WhatsApp group if connected (non-blocking)
|
|
135
|
+
if (isWhatsAppConnected()) {
|
|
136
|
+
try {
|
|
137
|
+
await syncGroupsForNewAgent(body.id);
|
|
138
|
+
} catch (err) {
|
|
139
|
+
console.error(`[agents] Failed to create WhatsApp group for "${body.id}": ${(err as Error).message}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return c.json({ success: true });
|
|
132
144
|
});
|
|
133
145
|
|
|
134
146
|
/** Update an agent's config */
|
|
@@ -146,6 +158,16 @@ export function agentsRoutes(): Hono {
|
|
|
146
158
|
/** Delete an agent by ID */
|
|
147
159
|
app.delete("/:id", async (c) => {
|
|
148
160
|
const id = c.req.param("id");
|
|
161
|
+
|
|
162
|
+
// Leave WhatsApp group before deleting agent (non-blocking)
|
|
163
|
+
if (isWhatsAppConnected()) {
|
|
164
|
+
try {
|
|
165
|
+
await syncGroupsForDeletedAgent(id);
|
|
166
|
+
} catch (err) {
|
|
167
|
+
console.error(`[agents] Failed to leave WhatsApp group for "${id}": ${(err as Error).message}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
149
171
|
try {
|
|
150
172
|
await deleteAgent(id);
|
|
151
173
|
return c.json({ success: true });
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Integration enable/disable API routes.
|
|
3
3
|
*
|
|
4
|
-
* Manages the enabled state of toggleable integrations (Twilio).
|
|
4
|
+
* Manages the enabled state of toggleable integrations (Twilio, WhatsApp).
|
|
5
5
|
* Browser calling is always enabled and not toggleable.
|
|
6
6
|
*
|
|
7
7
|
* - GET / -- returns enabled state for each integration
|
|
@@ -12,6 +12,7 @@ import { Hono } from "hono";
|
|
|
12
12
|
import { readEnv, writeEnvKey } from "../../server/services/env.js";
|
|
13
13
|
import { startTwilioServer, stopTwilioServer, isRunning as isTwilioRunning } from "../../server/services/twilio-manager.js";
|
|
14
14
|
import { isTunnelRunning, getTunnelUrl } from "../../server/services/tunnel.js";
|
|
15
|
+
import { startWhatsApp, stopWhatsApp } from "../../server/services/whatsapp-manager.js";
|
|
15
16
|
|
|
16
17
|
// ============================================================================
|
|
17
18
|
// CONSTANTS
|
|
@@ -20,6 +21,7 @@ import { isTunnelRunning, getTunnelUrl } from "../../server/services/tunnel.js";
|
|
|
20
21
|
/** Map of integration names to their .env key */
|
|
21
22
|
const INTEGRATION_ENV_KEYS: Record<string, string> = {
|
|
22
23
|
twilio: "TWILIO_ENABLED",
|
|
24
|
+
whatsapp: "WHATSAPP_ENABLED",
|
|
23
25
|
};
|
|
24
26
|
|
|
25
27
|
// ============================================================================
|
|
@@ -55,6 +57,7 @@ export function integrationsRoutes(): Hono {
|
|
|
55
57
|
const envVars = await readEnv();
|
|
56
58
|
return c.json({
|
|
57
59
|
twilio: { enabled: envVars.TWILIO_ENABLED === "true" },
|
|
60
|
+
whatsapp: { enabled: envVars.WHATSAPP_ENABLED === "true" },
|
|
58
61
|
});
|
|
59
62
|
});
|
|
60
63
|
|
|
@@ -113,6 +116,10 @@ async function startIntegration(name: string): Promise<void> {
|
|
|
113
116
|
await startTwilioServer(dashboardPort, getTunnelUrl() ?? undefined);
|
|
114
117
|
}
|
|
115
118
|
}
|
|
119
|
+
|
|
120
|
+
if (name === "whatsapp") {
|
|
121
|
+
await startWhatsApp();
|
|
122
|
+
}
|
|
116
123
|
}
|
|
117
124
|
|
|
118
125
|
/**
|
|
@@ -124,4 +131,8 @@ function stopIntegration(name: string): void {
|
|
|
124
131
|
if (name === "twilio") {
|
|
125
132
|
stopTwilioServer();
|
|
126
133
|
}
|
|
134
|
+
|
|
135
|
+
if (name === "whatsapp") {
|
|
136
|
+
stopWhatsApp();
|
|
137
|
+
}
|
|
127
138
|
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WhatsApp management API routes.
|
|
3
|
+
*
|
|
4
|
+
* Provides endpoints for checking WhatsApp connection status (including QR code),
|
|
5
|
+
* listing current group-to-agent mappings, and sending outbound messages.
|
|
6
|
+
*
|
|
7
|
+
* Responsibilities:
|
|
8
|
+
* - GET /status -- returns connection state and QR code string
|
|
9
|
+
* - GET /groups -- lists current group mappings with agent IDs
|
|
10
|
+
* - POST /send -- sends a message to an agent's WhatsApp group (used by heartbeat)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { Hono } from "hono";
|
|
14
|
+
import { getConnectionState, isConnected, getSocket } from "../../server/services/whatsapp-manager.js";
|
|
15
|
+
import { loadMappings, findMappingByAgentId } from "../../server/services/whatsapp-groups.js";
|
|
16
|
+
import { readFile } from "node:fs/promises";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
|
|
20
|
+
// ============================================================================
|
|
21
|
+
// CONSTANTS
|
|
22
|
+
// ============================================================================
|
|
23
|
+
|
|
24
|
+
/** Path to the persisted group mappings file */
|
|
25
|
+
const MAPPINGS_FILE_PATH = join(
|
|
26
|
+
process.env.VOICECC_DIR ?? join(homedir(), ".voicecc"),
|
|
27
|
+
"whatsapp",
|
|
28
|
+
"group-mappings.json"
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
// ============================================================================
|
|
32
|
+
// ROUTES
|
|
33
|
+
// ============================================================================
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Create Hono route group for WhatsApp operations.
|
|
37
|
+
*
|
|
38
|
+
* @returns Hono instance with GET /status, GET /groups, and POST /send routes
|
|
39
|
+
*/
|
|
40
|
+
export function whatsappRoutes(): Hono {
|
|
41
|
+
const app = new Hono();
|
|
42
|
+
|
|
43
|
+
/** Get WhatsApp connection state and QR code string */
|
|
44
|
+
app.get("/status", (c) => {
|
|
45
|
+
const state = getConnectionState();
|
|
46
|
+
return c.json(state);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
/** List current group mappings with agent IDs */
|
|
50
|
+
app.get("/groups", async (c) => {
|
|
51
|
+
try {
|
|
52
|
+
const raw = await readFile(MAPPINGS_FILE_PATH, "utf-8");
|
|
53
|
+
const groups = JSON.parse(raw);
|
|
54
|
+
return c.json({ groups });
|
|
55
|
+
} catch {
|
|
56
|
+
// No mappings file yet -- return empty list
|
|
57
|
+
return c.json({ groups: [] });
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Send a message to an agent's WhatsApp group.
|
|
63
|
+
* Used by the Python heartbeat when outboundChannel is "whatsapp".
|
|
64
|
+
*
|
|
65
|
+
* Body: { agentId: string, text: string }
|
|
66
|
+
* Returns 404 if no group mapping found, 503 if WhatsApp not connected.
|
|
67
|
+
*/
|
|
68
|
+
app.post("/send", async (c) => {
|
|
69
|
+
const body = await c.req.json<{ agentId: string; text: string }>();
|
|
70
|
+
const { agentId, text } = body;
|
|
71
|
+
|
|
72
|
+
if (!agentId || !text) {
|
|
73
|
+
return c.json({ error: "agentId and text are required" }, 400);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (!isConnected()) {
|
|
77
|
+
return c.json({ error: "WhatsApp is not connected" }, 503);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const sock = getSocket();
|
|
81
|
+
if (!sock) {
|
|
82
|
+
return c.json({ error: "WhatsApp socket not available" }, 503);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Reverse lookup: find the groupJid for this agentId
|
|
86
|
+
const mapping = findMappingByAgentId(agentId);
|
|
87
|
+
if (!mapping) {
|
|
88
|
+
return c.json({ error: `No WhatsApp group mapping found for agent "${agentId}"` }, 404);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
await sock.sendMessage(mapping.groupJid, { text: `[voicecc] ${text}` });
|
|
92
|
+
console.log(`[whatsapp] Sent outbound message to group ${mapping.groupJid} for agent "${agentId}"`);
|
|
93
|
+
|
|
94
|
+
return c.json({ ok: true });
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
return app;
|
|
98
|
+
}
|
package/dashboard/server.ts
CHANGED
|
@@ -35,6 +35,7 @@ import { agentsRoutes } from "./routes/agents.js";
|
|
|
35
35
|
import { versionRoutes } from "./routes/version.js";
|
|
36
36
|
import { chatRoutes } from "./routes/chat.js";
|
|
37
37
|
import { voiceRoutes } from "./routes/voice.js";
|
|
38
|
+
import { whatsappRoutes } from "./routes/whatsapp.js";
|
|
38
39
|
import { loadDeviceTokens } from "../server/services/device-pairing.js";
|
|
39
40
|
|
|
40
41
|
// ============================================================================
|
|
@@ -90,6 +91,7 @@ export function createApp(): Hono {
|
|
|
90
91
|
app.route("/api/version", versionRoutes());
|
|
91
92
|
app.route("/api/chat", chatRoutes());
|
|
92
93
|
app.route("/api/voice", voiceRoutes());
|
|
94
|
+
app.route("/api/whatsapp", whatsappRoutes());
|
|
93
95
|
|
|
94
96
|
// Status endpoint (user CLAUDE.md conflict check)
|
|
95
97
|
app.get("/api/status", async (c) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "voicecc",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Voice Agent Platform running on Claude Code -- create and deploy conversational voice agents with ElevenLabs STT/TTS and VAD",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
"@anthropic-ai/sdk": "^0.39.0",
|
|
41
41
|
"@hono/node-server": "^1.19.9",
|
|
42
42
|
"archiver": "^7.0.1",
|
|
43
|
+
"baileys": "6.7.21",
|
|
43
44
|
"cloudflared": "^0.7.1",
|
|
44
45
|
"dotenv": "^16.4.0",
|
|
45
46
|
"hono": "^4.12.0",
|
package/server/index.ts
CHANGED
|
@@ -33,6 +33,7 @@ import { startDashboard } from "../dashboard/server.js";
|
|
|
33
33
|
import { readEnv } from "./services/env.js";
|
|
34
34
|
import { startTunnel, stopTunnel, isTunnelRunning, getTunnelUrl } from "./services/tunnel.js";
|
|
35
35
|
import { startTwilioServer } from "./services/twilio-manager.js";
|
|
36
|
+
import { startWhatsApp, stopWhatsApp } from "./services/whatsapp-manager.js";
|
|
36
37
|
|
|
37
38
|
/** Base URL for the Python FastAPI server (for tunnel URL notification) */
|
|
38
39
|
const VOICE_SERVER_API_URL = process.env.VOICE_SERVER_URL ?? "http://localhost:7861";
|
|
@@ -252,10 +253,21 @@ async function main(): Promise<void> {
|
|
|
252
253
|
}
|
|
253
254
|
}
|
|
254
255
|
|
|
256
|
+
// Auto-start WhatsApp if enabled
|
|
257
|
+
if (envVars.WHATSAPP_ENABLED === "true") {
|
|
258
|
+
console.log("WhatsApp integration enabled, starting...");
|
|
259
|
+
try {
|
|
260
|
+
await startWhatsApp();
|
|
261
|
+
} catch (err) {
|
|
262
|
+
console.error(`WhatsApp auto-start failed: ${err}`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
255
266
|
// Graceful shutdown: stop tunnel subprocess, then clean up status file
|
|
256
267
|
const shutdown = () => {
|
|
257
268
|
stopPythonVoiceServer();
|
|
258
269
|
stopTunnel();
|
|
270
|
+
stopWhatsApp();
|
|
259
271
|
cleanupStatusFile();
|
|
260
272
|
process.exit(0);
|
|
261
273
|
};
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WhatsApp group management for VoiceCC agents.
|
|
3
|
+
*
|
|
4
|
+
* Manages the mapping between VoiceCC agents and WhatsApp groups,
|
|
5
|
+
* and persists mappings + session IDs to disk for resume support.
|
|
6
|
+
*
|
|
7
|
+
* Responsibilities:
|
|
8
|
+
* - Create/leave WhatsApp groups when agents are created/deleted
|
|
9
|
+
* - Sync all agent groups on WhatsApp connect (with duplicate detection)
|
|
10
|
+
* - Map groupJid to agentId for incoming message routing
|
|
11
|
+
* - Store/retrieve Claude session IDs per group for conversation resume
|
|
12
|
+
* - Persist mappings to ~/.voicecc/whatsapp/group-mappings.json
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
16
|
+
import { join, dirname } from "node:path";
|
|
17
|
+
import { homedir } from "node:os";
|
|
18
|
+
import { getSocket } from "./whatsapp-manager.js";
|
|
19
|
+
import { listAgents } from "./agent-store.js";
|
|
20
|
+
|
|
21
|
+
// ============================================================================
|
|
22
|
+
// CONSTANTS
|
|
23
|
+
// ============================================================================
|
|
24
|
+
|
|
25
|
+
/** Prefix used for all VoiceCC WhatsApp group names */
|
|
26
|
+
const GROUP_NAME_PREFIX = "[VoiceCC] ";
|
|
27
|
+
|
|
28
|
+
/** Path to the persisted group mappings file */
|
|
29
|
+
const MAPPINGS_FILE_PATH = join(
|
|
30
|
+
process.env.VOICECC_DIR ?? join(homedir(), ".voicecc"),
|
|
31
|
+
"whatsapp",
|
|
32
|
+
"group-mappings.json"
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
// ============================================================================
|
|
36
|
+
// TYPES
|
|
37
|
+
// ============================================================================
|
|
38
|
+
|
|
39
|
+
/** Mapping between a WhatsApp group and a VoiceCC agent */
|
|
40
|
+
export interface GroupMapping {
|
|
41
|
+
groupJid: string;
|
|
42
|
+
agentId: string;
|
|
43
|
+
lastSessionId: string | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ============================================================================
|
|
47
|
+
// STATE
|
|
48
|
+
// ============================================================================
|
|
49
|
+
|
|
50
|
+
/** In-memory mappings indexed by groupJid */
|
|
51
|
+
let mappings: Map<string, GroupMapping> = new Map();
|
|
52
|
+
|
|
53
|
+
// ============================================================================
|
|
54
|
+
// MAIN HANDLERS
|
|
55
|
+
// ============================================================================
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Create a WhatsApp group for a new agent and register the mapping.
|
|
59
|
+
* Requires an active Baileys socket connection.
|
|
60
|
+
*
|
|
61
|
+
* @param agentId - The agent identifier to create a group for
|
|
62
|
+
*/
|
|
63
|
+
export async function syncGroupsForNewAgent(agentId: string): Promise<void> {
|
|
64
|
+
const sock = getSocket();
|
|
65
|
+
if (!sock) {
|
|
66
|
+
throw new Error("WhatsApp socket is not connected");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Check if a mapping already exists for this agent
|
|
70
|
+
const existing = findMappingByAgentId(agentId);
|
|
71
|
+
if (existing) {
|
|
72
|
+
console.log(`WhatsApp group already exists for agent "${agentId}" (${existing.groupJid})`);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const groupName = formatGroupName(agentId);
|
|
77
|
+
const result = await sock.groupCreate(groupName, []);
|
|
78
|
+
|
|
79
|
+
const mapping: GroupMapping = {
|
|
80
|
+
groupJid: result.id,
|
|
81
|
+
agentId,
|
|
82
|
+
lastSessionId: null,
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
mappings.set(result.id, mapping);
|
|
86
|
+
await saveMappings();
|
|
87
|
+
|
|
88
|
+
console.log(`Created WhatsApp group "${groupName}" (${result.id}) for agent "${agentId}"`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Leave the WhatsApp group for a deleted agent and remove the mapping.
|
|
93
|
+
* Requires an active Baileys socket connection.
|
|
94
|
+
*
|
|
95
|
+
* @param agentId - The agent identifier whose group should be left
|
|
96
|
+
*/
|
|
97
|
+
export async function syncGroupsForDeletedAgent(agentId: string): Promise<void> {
|
|
98
|
+
const sock = getSocket();
|
|
99
|
+
if (!sock) {
|
|
100
|
+
throw new Error("WhatsApp socket is not connected");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const mapping = findMappingByAgentId(agentId);
|
|
104
|
+
if (!mapping) {
|
|
105
|
+
console.log(`No WhatsApp group mapping found for agent "${agentId}"`);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
await sock.groupLeave(mapping.groupJid);
|
|
110
|
+
|
|
111
|
+
mappings.delete(mapping.groupJid);
|
|
112
|
+
await saveMappings();
|
|
113
|
+
|
|
114
|
+
console.log(`Left WhatsApp group (${mapping.groupJid}) for deleted agent "${agentId}"`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Sync WhatsApp groups for all agents. For each agent without a mapping,
|
|
119
|
+
* checks if a group named [VoiceCC] <agentId> already exists (duplicate
|
|
120
|
+
* detection). If found, registers the existing group. If not, creates a new one.
|
|
121
|
+
*
|
|
122
|
+
* Called on WhatsApp connect to ensure all agents have groups.
|
|
123
|
+
*/
|
|
124
|
+
export async function syncAllGroups(): Promise<void> {
|
|
125
|
+
const sock = getSocket();
|
|
126
|
+
if (!sock) {
|
|
127
|
+
throw new Error("WhatsApp socket is not connected");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Load persisted mappings first
|
|
131
|
+
await loadMappings();
|
|
132
|
+
|
|
133
|
+
const agents = await listAgents();
|
|
134
|
+
const agentsWithoutMapping = agents.filter(
|
|
135
|
+
(agent) => !findMappingByAgentId(agent.id)
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
if (agentsWithoutMapping.length === 0) {
|
|
139
|
+
console.log("All agents already have WhatsApp group mappings.");
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Fetch existing WhatsApp groups for duplicate detection
|
|
144
|
+
const existingGroups = await sock.groupFetchAllParticipating();
|
|
145
|
+
const groupsByName = new Map<string, string>();
|
|
146
|
+
for (const [jid, metadata] of Object.entries(existingGroups)) {
|
|
147
|
+
const groupMetadata = metadata as { subject: string };
|
|
148
|
+
groupsByName.set(groupMetadata.subject, jid);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
let synced = 0;
|
|
152
|
+
|
|
153
|
+
for (const agent of agentsWithoutMapping) {
|
|
154
|
+
const groupName = formatGroupName(agent.id);
|
|
155
|
+
const existingJid = groupsByName.get(groupName);
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
if (existingJid) {
|
|
159
|
+
// Register existing group instead of creating a duplicate
|
|
160
|
+
const mapping: GroupMapping = {
|
|
161
|
+
groupJid: existingJid,
|
|
162
|
+
agentId: agent.id,
|
|
163
|
+
lastSessionId: null,
|
|
164
|
+
};
|
|
165
|
+
mappings.set(existingJid, mapping);
|
|
166
|
+
console.log(`Registered existing WhatsApp group "${groupName}" (${existingJid}) for agent "${agent.id}"`);
|
|
167
|
+
} else {
|
|
168
|
+
// Create a new group
|
|
169
|
+
const result = await sock.groupCreate(groupName, []);
|
|
170
|
+
const mapping: GroupMapping = {
|
|
171
|
+
groupJid: result.id,
|
|
172
|
+
agentId: agent.id,
|
|
173
|
+
lastSessionId: null,
|
|
174
|
+
};
|
|
175
|
+
mappings.set(result.id, mapping);
|
|
176
|
+
console.log(`Created WhatsApp group "${groupName}" (${result.id}) for agent "${agent.id}"`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Save after each successful creation so partial progress is persisted
|
|
180
|
+
await saveMappings();
|
|
181
|
+
synced++;
|
|
182
|
+
|
|
183
|
+
// Delay between group creations to avoid WhatsApp rate-limiting
|
|
184
|
+
if (synced < agentsWithoutMapping.length) {
|
|
185
|
+
await new Promise((resolve) => setTimeout(resolve, 5_000));
|
|
186
|
+
}
|
|
187
|
+
} catch (err: unknown) {
|
|
188
|
+
console.error(`Failed to sync WhatsApp group for agent "${agent.id}": ${err}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
console.log(`WhatsApp group sync complete. ${synced}/${agentsWithoutMapping.length} agent(s) synced.`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Look up which agent a WhatsApp group belongs to.
|
|
197
|
+
*
|
|
198
|
+
* @param groupJid - The WhatsApp group JID
|
|
199
|
+
* @returns The agent ID, or undefined if the group is not mapped
|
|
200
|
+
*/
|
|
201
|
+
export function getAgentIdForGroup(groupJid: string): string | undefined {
|
|
202
|
+
return mappings.get(groupJid)?.agentId;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Get the stored Claude session ID for a group (used for conversation resume).
|
|
207
|
+
*
|
|
208
|
+
* @param groupJid - The WhatsApp group JID
|
|
209
|
+
* @returns The last session ID, or null if none stored
|
|
210
|
+
*/
|
|
211
|
+
export function getLastSessionId(groupJid: string): string | null {
|
|
212
|
+
return mappings.get(groupJid)?.lastSessionId ?? null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Update the stored Claude session ID for a group and persist to disk.
|
|
217
|
+
*
|
|
218
|
+
* @param groupJid - The WhatsApp group JID
|
|
219
|
+
* @param sessionId - The Claude session ID to store
|
|
220
|
+
*/
|
|
221
|
+
export async function setLastSessionId(groupJid: string, sessionId: string): Promise<void> {
|
|
222
|
+
const mapping = mappings.get(groupJid);
|
|
223
|
+
if (!mapping) {
|
|
224
|
+
throw new Error(`No mapping found for group "${groupJid}"`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
mapping.lastSessionId = sessionId;
|
|
228
|
+
await saveMappings();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ============================================================================
|
|
232
|
+
// HELPER FUNCTIONS
|
|
233
|
+
// ============================================================================
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Format the WhatsApp group name for an agent.
|
|
237
|
+
*
|
|
238
|
+
* @param agentId - The agent identifier
|
|
239
|
+
* @returns The formatted group name, e.g. "[VoiceCC] my-agent"
|
|
240
|
+
*/
|
|
241
|
+
export function formatGroupName(agentId: string): string {
|
|
242
|
+
return `${GROUP_NAME_PREFIX}${agentId}`;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Load group mappings from disk. Creates an empty mappings file if none exists.
|
|
247
|
+
* Called on startup and before syncAllGroups.
|
|
248
|
+
*/
|
|
249
|
+
export async function loadMappings(): Promise<void> {
|
|
250
|
+
try {
|
|
251
|
+
const raw = await readFile(MAPPINGS_FILE_PATH, "utf-8");
|
|
252
|
+
const parsed: GroupMapping[] = JSON.parse(raw);
|
|
253
|
+
|
|
254
|
+
mappings = new Map();
|
|
255
|
+
for (const mapping of parsed) {
|
|
256
|
+
mappings.set(mapping.groupJid, mapping);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
console.log(`Loaded ${mappings.size} WhatsApp group mapping(s) from disk.`);
|
|
260
|
+
} catch {
|
|
261
|
+
// File does not exist yet -- start with empty mappings
|
|
262
|
+
mappings = new Map();
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Persist current group mappings to disk as JSON.
|
|
268
|
+
*/
|
|
269
|
+
export async function saveMappings(): Promise<void> {
|
|
270
|
+
const data = Array.from(mappings.values());
|
|
271
|
+
|
|
272
|
+
await mkdir(dirname(MAPPINGS_FILE_PATH), { recursive: true });
|
|
273
|
+
await writeFile(MAPPINGS_FILE_PATH, JSON.stringify(data, null, 2), "utf-8");
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Find a mapping by agent ID (reverse lookup).
|
|
278
|
+
*
|
|
279
|
+
* @param agentId - The agent identifier to search for
|
|
280
|
+
* @returns The mapping, or undefined if not found
|
|
281
|
+
*/
|
|
282
|
+
export function findMappingByAgentId(agentId: string): GroupMapping | undefined {
|
|
283
|
+
for (const mapping of mappings.values()) {
|
|
284
|
+
if (mapping.agentId === agentId) {
|
|
285
|
+
return mapping;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return undefined;
|
|
289
|
+
}
|