voicecc 1.3.0 → 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/chat.test.ts +94 -0
- package/dashboard/routes/chat.ts +14 -7
- package/package.json +1 -1
- package/voice-server/config.py +1 -1
- package/voice-server/config_reload_test.py +96 -0
- package/voice-server/heartbeat.py +12 -18
- package/dashboard/dist/assets/index-CVP_3PYo.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>
|
|
@@ -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")) {
|
package/package.json
CHANGED
package/voice-server/config.py
CHANGED
|
@@ -110,7 +110,7 @@ def load_config() -> VoiceServerConfig:
|
|
|
110
110
|
"""
|
|
111
111
|
voicecc_dir = os.environ.get("VOICECC_DIR", DEFAULT_VOICECC_DIR)
|
|
112
112
|
env_path = os.path.join(voicecc_dir, ".env")
|
|
113
|
-
load_dotenv(env_path)
|
|
113
|
+
load_dotenv(env_path, override=True)
|
|
114
114
|
|
|
115
115
|
api_key = os.environ.get("ELEVENLABS_API_KEY", "")
|
|
116
116
|
if not api_key:
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Test that load_config() always reads fresh values from the .env file.
|
|
2
|
+
|
|
3
|
+
Catches regressions where env vars get cached at startup instead of being
|
|
4
|
+
re-read on each call. If a new setting is added to VoiceServerConfig and
|
|
5
|
+
backed by an env var, add it to ENV_BACKED_FIELDS below so it's covered.
|
|
6
|
+
|
|
7
|
+
Run: cd voice-server && python -m pytest config_reload_test.py -v
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import tempfile
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
from config import load_config, VoiceServerConfig
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# Map of VoiceServerConfig field -> (env var name, first value, second value)
|
|
20
|
+
# Add new env-backed settings here to keep them covered.
|
|
21
|
+
ENV_BACKED_FIELDS: dict[str, tuple[str, str, str]] = {
|
|
22
|
+
"twilio_account_sid": ("TWILIO_ACCOUNT_SID", "AC_old_sid", "AC_new_sid"),
|
|
23
|
+
"twilio_auth_token": ("TWILIO_AUTH_TOKEN", "old_token", "new_token"),
|
|
24
|
+
"twilio_phone_number": ("TWILIO_PHONE_NUMBER", "+10000000000", "+19999999999"),
|
|
25
|
+
"user_phone_number": ("USER_PHONE_NUMBER", "+11111111111", "+12222222222"),
|
|
26
|
+
"elevenlabs_api_key": ("ELEVENLABS_API_KEY", "ek_old_key", "ek_new_key"),
|
|
27
|
+
"elevenlabs_voice_id": ("ELEVENLABS_VOICE_ID", "voice_old", "voice_new"),
|
|
28
|
+
"elevenlabs_tts_model": ("ELEVENLABS_MODEL_ID", "model_old", "model_new"),
|
|
29
|
+
"elevenlabs_stt_model": ("ELEVENLABS_STT_MODEL_ID", "stt_old", "stt_new"),
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
# Env vars we write into every .env so load_config() doesn't fail validation
|
|
33
|
+
REQUIRED_DEFAULTS = {
|
|
34
|
+
"ELEVENLABS_API_KEY": "ek_placeholder",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _write_env(path: Path, overrides: dict[str, str]) -> None:
|
|
39
|
+
"""Write a .env file with required defaults + overrides."""
|
|
40
|
+
merged = {**REQUIRED_DEFAULTS, **overrides}
|
|
41
|
+
path.write_text("\n".join(f"{k}={v}" for k, v in merged.items()) + "\n")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@pytest.fixture(autouse=True)
|
|
45
|
+
def _isolated_env(tmp_path, monkeypatch):
|
|
46
|
+
"""Point VOICECC_DIR at a temp dir and clean up env vars after each test."""
|
|
47
|
+
monkeypatch.setenv("VOICECC_DIR", str(tmp_path))
|
|
48
|
+
|
|
49
|
+
# Clear all env vars we test so there's no bleed between tests
|
|
50
|
+
for _, (env_var, _, _) in ENV_BACKED_FIELDS.items():
|
|
51
|
+
monkeypatch.delenv(env_var, raising=False)
|
|
52
|
+
for k in REQUIRED_DEFAULTS:
|
|
53
|
+
monkeypatch.delenv(k, raising=False)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_load_config_picks_up_changed_values(tmp_path):
|
|
57
|
+
"""load_config() must return updated values after the .env file changes."""
|
|
58
|
+
env_file = tmp_path / ".env"
|
|
59
|
+
|
|
60
|
+
# Write initial values
|
|
61
|
+
initial = {env_var: v1 for env_var, v1, _ in ENV_BACKED_FIELDS.values()}
|
|
62
|
+
_write_env(env_file, initial)
|
|
63
|
+
|
|
64
|
+
config1 = load_config()
|
|
65
|
+
for field_name, (_, v1, _) in ENV_BACKED_FIELDS.items():
|
|
66
|
+
assert getattr(config1, field_name) == v1, (
|
|
67
|
+
f"{field_name} was not {v1!r} on first read"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# Overwrite with new values
|
|
71
|
+
updated = {env_var: v2 for env_var, _, v2 in ENV_BACKED_FIELDS.values()}
|
|
72
|
+
_write_env(env_file, updated)
|
|
73
|
+
|
|
74
|
+
config2 = load_config()
|
|
75
|
+
for field_name, (_, _, v2) in ENV_BACKED_FIELDS.items():
|
|
76
|
+
assert getattr(config2, field_name) == v2, (
|
|
77
|
+
f"{field_name} was not updated to {v2!r} on second read — "
|
|
78
|
+
"load_config() is returning stale values"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_new_env_var_appears_after_file_update(tmp_path):
|
|
83
|
+
"""If TWILIO_PHONE_NUMBER is absent initially and added later, it's picked up."""
|
|
84
|
+
env_file = tmp_path / ".env"
|
|
85
|
+
|
|
86
|
+
# Start without TWILIO_PHONE_NUMBER
|
|
87
|
+
_write_env(env_file, {})
|
|
88
|
+
config1 = load_config()
|
|
89
|
+
assert config1.twilio_phone_number == ""
|
|
90
|
+
|
|
91
|
+
# Add it
|
|
92
|
+
_write_env(env_file, {"TWILIO_PHONE_NUMBER": "+15550001234"})
|
|
93
|
+
config2 = load_config()
|
|
94
|
+
assert config2.twilio_phone_number == "+15550001234", (
|
|
95
|
+
"TWILIO_PHONE_NUMBER not picked up after being added to .env"
|
|
96
|
+
)
|
|
@@ -37,6 +37,7 @@ from config import (
|
|
|
37
37
|
build_system_prompt,
|
|
38
38
|
list_agents,
|
|
39
39
|
load_agent,
|
|
40
|
+
load_config,
|
|
40
41
|
DEFAULT_AGENTS_DIR,
|
|
41
42
|
PROJECT_ROOT,
|
|
42
43
|
)
|
|
@@ -108,9 +109,6 @@ _in_flight_checks: set[str] = set()
|
|
|
108
109
|
# Pending calls keyed by token, waiting for Twilio WebSocket connection
|
|
109
110
|
_pending_calls: dict[str, PendingCall] = {}
|
|
110
111
|
|
|
111
|
-
# Reference to config (set on start)
|
|
112
|
-
_config: VoiceServerConfig | None = None
|
|
113
|
-
|
|
114
112
|
# Getter for tunnel URL (set on start, imported from server module)
|
|
115
113
|
_get_tunnel_url = None
|
|
116
114
|
|
|
@@ -125,15 +123,14 @@ def start_heartbeat(config: VoiceServerConfig, get_tunnel_url_fn) -> None:
|
|
|
125
123
|
Runs _check_all_agents every 60 seconds via an asyncio task.
|
|
126
124
|
|
|
127
125
|
Args:
|
|
128
|
-
config: Voice server configuration
|
|
126
|
+
config: Voice server configuration (unused, kept for API compat)
|
|
129
127
|
get_tunnel_url_fn: Callable that returns the current tunnel URL
|
|
130
128
|
"""
|
|
131
|
-
global _interval_task,
|
|
129
|
+
global _interval_task, _get_tunnel_url
|
|
132
130
|
|
|
133
131
|
if _interval_task is not None:
|
|
134
132
|
return
|
|
135
133
|
|
|
136
|
-
_config = config
|
|
137
134
|
_get_tunnel_url = get_tunnel_url_fn
|
|
138
135
|
|
|
139
136
|
_interval_task = asyncio.create_task(_interval_loop())
|
|
@@ -217,10 +214,8 @@ async def _interval_loop() -> None:
|
|
|
217
214
|
|
|
218
215
|
async def _check_all_agents() -> None:
|
|
219
216
|
"""Check all enabled agents and spawn heartbeat sessions for those that are due."""
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
agents = list_agents(_config.agents_dir)
|
|
217
|
+
config = load_config()
|
|
218
|
+
agents = list_agents(config.agents_dir)
|
|
224
219
|
if not agents:
|
|
225
220
|
return
|
|
226
221
|
|
|
@@ -396,17 +391,16 @@ async def initiate_agent_call(agent: Agent, client: ClaudeSDKClient) -> str:
|
|
|
396
391
|
Returns:
|
|
397
392
|
The Twilio call SID
|
|
398
393
|
"""
|
|
399
|
-
|
|
400
|
-
raise RuntimeError("Heartbeat not started -- no config")
|
|
394
|
+
config = load_config()
|
|
401
395
|
|
|
402
396
|
tunnel_url = _get_tunnel_url() if _get_tunnel_url else None
|
|
403
397
|
if not tunnel_url:
|
|
404
398
|
raise RuntimeError("Tunnel is not running. Cannot place outbound call.")
|
|
405
399
|
|
|
406
|
-
if not
|
|
400
|
+
if not config.twilio_account_sid or not config.twilio_auth_token:
|
|
407
401
|
raise RuntimeError("TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN must be set")
|
|
408
402
|
|
|
409
|
-
if not
|
|
403
|
+
if not config.user_phone_number:
|
|
410
404
|
raise RuntimeError("USER_PHONE_NUMBER must be set in Settings > General")
|
|
411
405
|
|
|
412
406
|
token = str(uuid4())
|
|
@@ -427,11 +421,11 @@ async def initiate_agent_call(agent: Agent, client: ClaudeSDKClient) -> str:
|
|
|
427
421
|
# Get the configured Twilio phone number
|
|
428
422
|
from twilio.rest import Client as TwilioClient
|
|
429
423
|
|
|
430
|
-
from_number: str =
|
|
424
|
+
from_number: str = config.twilio_phone_number
|
|
431
425
|
if not from_number:
|
|
432
426
|
raise RuntimeError("No TWILIO_PHONE_NUMBER configured")
|
|
433
427
|
|
|
434
|
-
twilio_client = TwilioClient(
|
|
428
|
+
twilio_client = TwilioClient(config.twilio_account_sid, config.twilio_auth_token)
|
|
435
429
|
|
|
436
430
|
# Build TwiML with WebSocket stream URL
|
|
437
431
|
tunnel_host = tunnel_url.replace("https://", "").replace("http://", "")
|
|
@@ -442,13 +436,13 @@ async def initiate_agent_call(agent: Agent, client: ClaudeSDKClient) -> str:
|
|
|
442
436
|
)
|
|
443
437
|
|
|
444
438
|
call = twilio_client.calls.create(
|
|
445
|
-
to=
|
|
439
|
+
to=config.user_phone_number,
|
|
446
440
|
from_=from_number,
|
|
447
441
|
twiml=twiml,
|
|
448
442
|
)
|
|
449
443
|
|
|
450
444
|
logger.info(
|
|
451
|
-
f"[heartbeat] outbound call placed to {
|
|
445
|
+
f"[heartbeat] outbound call placed to {config.user_phone_number} "
|
|
452
446
|
f"(callSid={call.sid})"
|
|
453
447
|
)
|
|
454
448
|
return call.sid or ""
|