voicecc 1.2.13 → 1.3.1

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-ZP9Js-Pi.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 });
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Tests for chat proxy route -- verifies resumeSessionId forwarding.
3
+ *
4
+ * Run: npx tsx --test dashboard/routes/chat.test.ts
5
+ */
6
+
7
+ import { test, describe, afterEach } from "node:test";
8
+ import { strict as assert } from "node:assert";
9
+
10
+ import { chatRoutes } from "./chat.js";
11
+
12
+ // ============================================================================
13
+ // HELPERS
14
+ // ============================================================================
15
+
16
+ const originalFetch = globalThis.fetch;
17
+
18
+ /** Restore global fetch after each test */
19
+ afterEach(() => {
20
+ globalThis.fetch = originalFetch;
21
+ });
22
+
23
+ /**
24
+ * Build a Hono Request to POST /send with the given JSON body.
25
+ * Uses x-forwarded-for: 127.0.0.1 to bypass device token validation.
26
+ *
27
+ * @param body - JSON body to send
28
+ * @returns Response from the Hono app
29
+ */
30
+ async function postSend(body: Record<string, unknown>): Promise<Response> {
31
+ const app = chatRoutes();
32
+ const req = new Request("http://localhost/send", {
33
+ method: "POST",
34
+ headers: {
35
+ "Content-Type": "application/json",
36
+ "x-forwarded-for": "127.0.0.1",
37
+ },
38
+ body: JSON.stringify(body),
39
+ });
40
+ return app.request(req);
41
+ }
42
+
43
+ // ============================================================================
44
+ // TESTS
45
+ // ============================================================================
46
+
47
+ describe("POST /send - resumeSessionId forwarding", () => {
48
+ test("forwards resumeSessionId as resume_session_id to Python", async () => {
49
+ let capturedBody: Record<string, unknown> | undefined;
50
+
51
+ globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
52
+ const bodyText = typeof init?.body === "string" ? init.body : "";
53
+ capturedBody = JSON.parse(bodyText);
54
+ // Return a minimal SSE response so the handler succeeds
55
+ return new Response("data: done\n\n", {
56
+ status: 200,
57
+ headers: { "Content-Type": "text/event-stream" },
58
+ });
59
+ };
60
+
61
+ await postSend({
62
+ token: "test-token",
63
+ agentId: "agent-1",
64
+ text: "hello",
65
+ resumeSessionId: "ses_abc123",
66
+ });
67
+
68
+ assert.ok(capturedBody, "fetch should have been called");
69
+ assert.equal(capturedBody!.resume_session_id, "ses_abc123");
70
+ assert.equal(capturedBody!.session_key, "test-token");
71
+ assert.equal(capturedBody!.text, "hello");
72
+ });
73
+
74
+ test("omits resume_session_id when resumeSessionId is absent", async () => {
75
+ let capturedBody: Record<string, unknown> | undefined;
76
+
77
+ globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
78
+ const bodyText = typeof init?.body === "string" ? init.body : "";
79
+ capturedBody = JSON.parse(bodyText);
80
+ return new Response("data: done\n\n", {
81
+ status: 200,
82
+ headers: { "Content-Type": "text/event-stream" },
83
+ });
84
+ };
85
+
86
+ await postSend({
87
+ token: "test-token",
88
+ text: "hello",
89
+ });
90
+
91
+ assert.ok(capturedBody, "fetch should have been called");
92
+ assert.equal("resume_session_id" in capturedBody!, false, "resume_session_id should not be present");
93
+ });
94
+ });
@@ -30,6 +30,7 @@ interface ChatSendBody {
30
30
  token: string;
31
31
  agentId?: string;
32
32
  text: string;
33
+ resumeSessionId?: string;
33
34
  }
34
35
 
35
36
  /** Request body for POST /stop and /close */
@@ -66,24 +67,30 @@ export function chatRoutes(): Hono {
66
67
  return c.json({ error: "Missing 'token' field" }, 400);
67
68
  }
68
69
 
69
- // Validate device token (localhost bypass via x-forwarded-for)
70
+ // Validate device token (localhost bypass via x-forwarded-for, resume bypass for dashboard)
70
71
  const forwarded = c.req.header("x-forwarded-for") ?? "";
71
72
  const isLocalhost = forwarded === "127.0.0.1";
73
+ const isResumeFlow = !!body.resumeSessionId;
72
74
 
73
- if (!isLocalhost && !isValidDeviceToken(body.token)) {
75
+ if (!isLocalhost && !isResumeFlow && !isValidDeviceToken(body.token)) {
74
76
  return c.json({ error: "Invalid device token" }, 401);
75
77
  }
76
78
 
77
79
  // Proxy to Python server
78
80
  try {
81
+ const pythonBody: Record<string, string | undefined> = {
82
+ session_key: body.token,
83
+ agent_id: body.agentId,
84
+ text: body.text.trim(),
85
+ };
86
+ if (body.resumeSessionId) {
87
+ pythonBody.resume_session_id = body.resumeSessionId;
88
+ }
89
+
79
90
  const response = await fetch(`${VOICE_SERVER_URL}/chat/send`, {
80
91
  method: "POST",
81
92
  headers: { "Content-Type": "application/json" },
82
- body: JSON.stringify({
83
- session_key: body.token,
84
- agent_id: body.agentId,
85
- text: body.text.trim(),
86
- }),
93
+ body: JSON.stringify(pythonBody),
87
94
  });
88
95
 
89
96
  if (!response.ok && !response.headers.get("content-type")?.includes("text/event-stream")) {
@@ -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.1",
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 */