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.
@@ -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-DbjqXBdo.js"></script>
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
+ }
@@ -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.2.13",
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
  };
@@ -58,6 +58,7 @@ export interface AgentConfig {
58
58
  heartbeatTimeoutMinutes?: number;
59
59
  enabled: boolean;
60
60
  voice?: AgentVoiceConfig;
61
+ outboundChannel?: "call" | "whatsapp";
61
62
  }
62
63
 
63
64
  /** Full agent data including all file contents */
@@ -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
+ }