voicecc 1.2.12 → 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 +28 -6
- package/dashboard/routes/integrations.ts +12 -1
- package/dashboard/routes/twilio.ts +5 -7
- 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/twilio-manager.ts +21 -11
- 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 +9 -0
- package/voice-server/heartbeat.py +77 -10
- package/voice-server/server.py +24 -24
- package/dashboard/dist/assets/index-DCeOdulF.js +0 -28
|
@@ -26,6 +26,9 @@ import time
|
|
|
26
26
|
from dataclasses import dataclass, field
|
|
27
27
|
from uuid import uuid4
|
|
28
28
|
|
|
29
|
+
import urllib.request
|
|
30
|
+
import urllib.error
|
|
31
|
+
|
|
29
32
|
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient, AssistantMessage, ResultMessage, TextBlock
|
|
30
33
|
|
|
31
34
|
from config import (
|
|
@@ -274,11 +277,18 @@ async def check_single_agent(agent: Agent) -> HeartbeatResult:
|
|
|
274
277
|
)
|
|
275
278
|
|
|
276
279
|
if result.should_call:
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
280
|
+
outbound = agent.config.outbound_channel
|
|
281
|
+
|
|
282
|
+
if outbound == "whatsapp":
|
|
283
|
+
# Send via WhatsApp -- no session handoff needed
|
|
284
|
+
await send_whatsapp_message(agent.id, result.reason)
|
|
285
|
+
else:
|
|
286
|
+
# Default: place a Twilio call with session handoff
|
|
287
|
+
try:
|
|
288
|
+
await initiate_agent_call(agent, client)
|
|
289
|
+
client = None # Don't close -- voice session owns it now
|
|
290
|
+
except Exception as e:
|
|
291
|
+
logger.error(f'[heartbeat] failed to call agent "{agent.id}": {e}')
|
|
282
292
|
|
|
283
293
|
return result
|
|
284
294
|
finally:
|
|
@@ -414,14 +424,14 @@ async def initiate_agent_call(agent: Agent, client: ClaudeSDKClient) -> str:
|
|
|
414
424
|
# Schedule cleanup for unanswered calls
|
|
415
425
|
asyncio.create_task(_cleanup_pending_call(token))
|
|
416
426
|
|
|
417
|
-
# Get
|
|
427
|
+
# Get the configured Twilio phone number
|
|
418
428
|
from twilio.rest import Client as TwilioClient
|
|
419
429
|
|
|
430
|
+
from_number: str = _config.twilio_phone_number
|
|
431
|
+
if not from_number:
|
|
432
|
+
raise RuntimeError("No TWILIO_PHONE_NUMBER configured")
|
|
433
|
+
|
|
420
434
|
twilio_client = TwilioClient(_config.twilio_account_sid, _config.twilio_auth_token)
|
|
421
|
-
numbers = twilio_client.incoming_phone_numbers.list(limit=1)
|
|
422
|
-
if not numbers:
|
|
423
|
-
raise RuntimeError("No Twilio phone numbers found on the account")
|
|
424
|
-
from_number: str = numbers[0].phone_number or ""
|
|
425
435
|
|
|
426
436
|
# Build TwiML with WebSocket stream URL
|
|
427
437
|
tunnel_host = tunnel_url.replace("https://", "").replace("http://", "")
|
|
@@ -444,6 +454,63 @@ async def initiate_agent_call(agent: Agent, client: ClaudeSDKClient) -> str:
|
|
|
444
454
|
return call.sid or ""
|
|
445
455
|
|
|
446
456
|
|
|
457
|
+
async def send_whatsapp_message(agent_id: str, text: str) -> None:
|
|
458
|
+
"""Send a WhatsApp message to an agent's group via the dashboard API.
|
|
459
|
+
|
|
460
|
+
POSTs { agentId, text } to the dashboard's POST /api/whatsapp/send endpoint.
|
|
461
|
+
Logs errors but does NOT raise -- the caller should not retry or fall back.
|
|
462
|
+
|
|
463
|
+
Args:
|
|
464
|
+
agent_id: Agent identifier
|
|
465
|
+
text: Message text to send
|
|
466
|
+
"""
|
|
467
|
+
dashboard_port = _get_dashboard_port()
|
|
468
|
+
if not dashboard_port:
|
|
469
|
+
logger.error("[heartbeat] Cannot send WhatsApp message: dashboard port unknown")
|
|
470
|
+
return
|
|
471
|
+
|
|
472
|
+
url = f"http://localhost:{dashboard_port}/api/whatsapp/send"
|
|
473
|
+
try:
|
|
474
|
+
payload = json.dumps({"agentId": agent_id, "text": text}).encode("utf-8")
|
|
475
|
+
req = urllib.request.Request(
|
|
476
|
+
url,
|
|
477
|
+
data=payload,
|
|
478
|
+
headers={"Content-Type": "application/json"},
|
|
479
|
+
method="POST",
|
|
480
|
+
)
|
|
481
|
+
response = await asyncio.to_thread(urllib.request.urlopen, req, timeout=10)
|
|
482
|
+
if response.status == 200:
|
|
483
|
+
logger.info(f'[heartbeat] WhatsApp message sent for agent "{agent_id}"')
|
|
484
|
+
else:
|
|
485
|
+
logger.error(
|
|
486
|
+
f'[heartbeat] WhatsApp send failed for agent "{agent_id}": '
|
|
487
|
+
f"HTTP {response.status}"
|
|
488
|
+
)
|
|
489
|
+
except urllib.error.HTTPError as e:
|
|
490
|
+
logger.error(
|
|
491
|
+
f'[heartbeat] WhatsApp send failed for agent "{agent_id}": '
|
|
492
|
+
f"HTTP {e.code} -- {e.read().decode('utf-8', errors='replace')}"
|
|
493
|
+
)
|
|
494
|
+
except Exception as e:
|
|
495
|
+
logger.error(f'[heartbeat] WhatsApp send error for agent "{agent_id}": {e}')
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _get_dashboard_port() -> int | None:
|
|
499
|
+
"""Read the dashboard port from ~/.voicecc/status.json.
|
|
500
|
+
|
|
501
|
+
Returns:
|
|
502
|
+
The dashboard port number, or None if the status file is unreadable
|
|
503
|
+
"""
|
|
504
|
+
voicecc_dir = os.environ.get("VOICECC_DIR", os.path.join(os.path.expanduser("~"), ".voicecc"))
|
|
505
|
+
status_path = os.path.join(voicecc_dir, "status.json")
|
|
506
|
+
try:
|
|
507
|
+
with open(status_path, "r", encoding="utf-8") as f:
|
|
508
|
+
status = json.load(f)
|
|
509
|
+
return int(status["dashboardPort"])
|
|
510
|
+
except Exception:
|
|
511
|
+
return None
|
|
512
|
+
|
|
513
|
+
|
|
447
514
|
async def _cleanup_pending_call(token: str) -> None:
|
|
448
515
|
"""Clean up a pending call after PENDING_CALL_TIMEOUT_S if not claimed.
|
|
449
516
|
|
package/voice-server/server.py
CHANGED
|
@@ -86,6 +86,7 @@ async def chat_send(request: Request):
|
|
|
86
86
|
session_key = body.get("session_key")
|
|
87
87
|
text = body.get("text", "").strip()
|
|
88
88
|
agent_id = body.get("agent_id")
|
|
89
|
+
resume_session_id = body.get("resume_session_id")
|
|
89
90
|
|
|
90
91
|
if not session_key or not isinstance(session_key, str):
|
|
91
92
|
return JSONResponse({"error": "Missing 'session_key' field"}, status_code=400)
|
|
@@ -94,35 +95,34 @@ async def chat_send(request: Request):
|
|
|
94
95
|
|
|
95
96
|
# Get or create the chat session
|
|
96
97
|
try:
|
|
97
|
-
await get_or_create_session(session_key, agent_id)
|
|
98
|
+
session = await get_or_create_session(session_key, agent_id, resume_session_id)
|
|
98
99
|
except RuntimeError as e:
|
|
99
100
|
logger.error(f"[server] Failed to create chat session: {e}")
|
|
100
101
|
return JSONResponse({"error": str(e)}, status_code=503)
|
|
101
102
|
|
|
102
|
-
#
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
yield f"data: {data}\n\n"
|
|
109
|
-
|
|
110
|
-
return StreamingResponse(
|
|
111
|
-
event_generator(),
|
|
112
|
-
media_type="text/event-stream",
|
|
113
|
-
headers={
|
|
114
|
-
"Cache-Control": "no-cache",
|
|
115
|
-
"Connection": "keep-alive",
|
|
116
|
-
"X-Accel-Buffering": "no",
|
|
117
|
-
},
|
|
103
|
+
# Check if already streaming before creating the StreamingResponse.
|
|
104
|
+
# This returns HTTP 409 immediately instead of an abruptly closed empty stream.
|
|
105
|
+
if session.streaming:
|
|
106
|
+
return JSONResponse(
|
|
107
|
+
{"error": "Already streaming a response. Wait for it to complete."},
|
|
108
|
+
status_code=409,
|
|
118
109
|
)
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
110
|
+
|
|
111
|
+
# Stream response as SSE
|
|
112
|
+
async def event_generator():
|
|
113
|
+
async for event in stream_message(session_key, text):
|
|
114
|
+
data = json.dumps(event.to_dict())
|
|
115
|
+
yield f"data: {data}\n\n"
|
|
116
|
+
|
|
117
|
+
return StreamingResponse(
|
|
118
|
+
event_generator(),
|
|
119
|
+
media_type="text/event-stream",
|
|
120
|
+
headers={
|
|
121
|
+
"Cache-Control": "no-cache",
|
|
122
|
+
"Connection": "keep-alive",
|
|
123
|
+
"X-Accel-Buffering": "no",
|
|
124
|
+
},
|
|
125
|
+
)
|
|
126
126
|
|
|
127
127
|
|
|
128
128
|
@app.post("/chat/stop")
|