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.
@@ -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
- try:
278
- await initiate_agent_call(agent, client)
279
- client = None # Don't close -- voice session owns it now
280
- except Exception as e:
281
- logger.error(f'[heartbeat] failed to call agent "{agent.id}": {e}')
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 from number via Twilio API
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
 
@@ -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
- # Stream response as SSE
103
- try:
104
-
105
- async def event_generator():
106
- async for event in stream_message(session_key, text):
107
- data = json.dumps(event.to_dict())
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
- except RuntimeError as e:
120
- if "ALREADY_STREAMING" in str(e):
121
- return JSONResponse(
122
- {"error": "Already streaming a response. Wait for it to complete."},
123
- status_code=409,
124
- )
125
- return JSONResponse({"error": str(e)}, status_code=500)
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")