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.
- package/dashboard/dist/assets/index-ZP9Js-Pi.js +28 -0
- package/dashboard/dist/index.html +1 -1
- package/dashboard/routes/agents.ts +23 -1
- package/dashboard/routes/chat.test.ts +94 -0
- package/dashboard/routes/chat.ts +14 -7
- 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 +8 -1
- package/voice-server/config_reload_test.py +96 -0
- package/voice-server/heartbeat.py +84 -23
- 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-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
|
+
});
|
package/dashboard/routes/chat.ts
CHANGED
|
@@ -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
|
+
}
|
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.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
|
};
|