Files
rogee 758c0a29e6
management-images / build-and-publish (push) Successful in 34s
fix RTP packet pacing
2026-09-17 18:46:51 +08:00

1343 lines
50 KiB
Python

"""Real Cell ARI/RTP call runner.
This is intentionally a small Cell-side adapter: RabbitMQ owns the business
command, while this module owns one already-authorized call's ARI channels,
RTP media, AI turns, and recording. No provider credential or SIP endpoint is
accepted from the command payload.
"""
from __future__ import annotations
import base64
import contextlib
import http.client
import io
import ipaddress
import json
import os
import queue
import re
import socket
import sqlite3
import ssl
import struct
import threading
import time
import uuid
import wave
from collections.abc import Mapping
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from urllib.parse import urlencode, urlsplit
from .ai_runtime import (
AIProviderError,
ConversationEngine,
pcm16_to_alaw,
pcm_to_wav,
)
from .core import BrokerError
class CellCallError(RuntimeError):
"""A real Cell call failed without permitting a silent fallback."""
def __init__(self, code: str, detail: str, retryable: bool = False) -> None:
super().__init__(detail)
self.code = code
self.detail = detail
self.retryable = retryable
@dataclass(frozen=True)
class CellRoute:
"""A trusted local route binding; call payloads cannot define its values."""
route_policy_id: str
caller_profile_id: str
trunk_id: str
caller_id: str
dial_prefix: str = ""
def __post_init__(self) -> None:
for field_name, value in (
("route_policy_id", self.route_policy_id),
("caller_profile_id", self.caller_profile_id),
):
if not isinstance(value, str) or not re.fullmatch(
r"[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}", value
):
raise CellCallError("ROUTE_CONFIG_INVALID", f"{field_name} is invalid")
if not isinstance(self.trunk_id, str) or not re.fullmatch(
r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}", self.trunk_id
):
raise CellCallError("ROUTE_CONFIG_INVALID", "trunk_id is invalid")
if not isinstance(self.caller_id, str) or not re.fullmatch(
r"[A-Za-z0-9_.+\- ]+(?:<[A-Za-z0-9_.+\- ]+>)?", self.caller_id
):
raise CellCallError("ROUTE_CONFIG_INVALID", "caller_id is invalid")
if (
not isinstance(self.dial_prefix, str)
or len(self.dial_prefix) > 32
or (
self.dial_prefix and not re.fullmatch(r"[A-Za-z0-9]+", self.dial_prefix)
)
):
raise CellCallError("ROUTE_CONFIG_INVALID", "dial_prefix is invalid")
def load_cell_routes(raw: str | None) -> dict[str, CellRoute]:
if not raw or not raw.strip():
return {}
try:
data = json.loads(raw)
except (TypeError, json.JSONDecodeError) as exc:
raise CellCallError(
"ROUTE_CONFIG_INVALID", "CELL_ROUTE_MAP_JSON is invalid"
) from exc
if not isinstance(data, Mapping):
raise CellCallError(
"ROUTE_CONFIG_INVALID", "CELL_ROUTE_MAP_JSON must be an object"
)
routes: dict[str, CellRoute] = {}
for route_policy_id, value in data.items():
if not isinstance(value, Mapping):
raise CellCallError(
"ROUTE_CONFIG_INVALID", f"route {route_policy_id!r} must be an object"
)
caller_profile_id = value.get("caller_profile_id")
trunk_id = value.get("trunk_id")
caller_id = value.get("caller_id")
dial_prefix = value.get("dial_prefix", "")
if not isinstance(caller_profile_id, str):
raise CellCallError(
"ROUTE_CONFIG_INVALID",
f"route {route_policy_id!r} has invalid caller profile",
)
if not isinstance(trunk_id, str):
raise CellCallError(
"ROUTE_CONFIG_INVALID", f"route {route_policy_id!r} has invalid trunk"
)
if not isinstance(caller_id, str):
raise CellCallError(
"ROUTE_CONFIG_INVALID", f"route {route_policy_id!r} has invalid caller"
)
if not isinstance(dial_prefix, str):
raise CellCallError(
"ROUTE_CONFIG_INVALID", f"route {route_policy_id!r} has invalid prefix"
)
route = CellRoute(
route_policy_id=str(route_policy_id),
caller_profile_id=caller_profile_id,
trunk_id=trunk_id,
caller_id=caller_id,
dial_prefix=dial_prefix,
)
routes[route.route_policy_id] = route
return routes
@dataclass(frozen=True)
class CellCallConfig:
"""Trusted, Cell-local settings; none are read from ``call.execute``."""
ari_url: str
ari_username: str
ari_password: str
ari_app: str = "agent-call"
trunk_id: str = "provider-primary"
caller_id: str = "BD93205882"
dial_prefix: str = "7089"
rtp_bind_host: str = "127.0.0.1"
rtp_bind_port: int = 0
rtp_format: str = "alaw"
event_timeout_s: float = 45.0
first_speech_timeout_s: float = 10.0
max_turn_s: float = 12.0
end_silence_s: float = 0.9
voice_threshold: int = 350
recording_dir: str = "/data/recordings"
def __post_init__(self) -> None:
parsed = urlsplit(self.ari_url)
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
raise CellCallError(
"ARI_URL_INVALID", "Cell ARI URL must be an HTTP(S) URL"
)
if not self.ari_username or not self.ari_password:
raise CellCallError(
"ARI_CREDENTIALS_MISSING", "Cell ARI credentials are required"
)
if not self.ari_app or not self.trunk_id or not self.caller_id:
raise CellCallError(
"CELL_CONFIG_INVALID", "Cell ARI/trunk identity is incomplete"
)
if len(self.dial_prefix) > 32 or (
self.dial_prefix and not re.fullmatch(r"[A-Za-z0-9]+", self.dial_prefix)
):
raise CellCallError(
"DIAL_PREFIX_INVALID",
"Cell dial prefix must be an ASCII alphanumeric token",
)
if self.rtp_format != "alaw":
raise CellCallError(
"RTP_FORMAT_UNSUPPORTED", "real Cell currently requires PCMA/alaw"
)
if not 0 <= self.rtp_bind_port <= 65535:
raise CellCallError("RTP_PORT_INVALID", "RTP bind port is invalid")
if self.event_timeout_s <= 0 or self.first_speech_timeout_s <= 0:
raise CellCallError(
"CELL_TIMEOUT_INVALID", "Cell timeouts must be positive"
)
@classmethod
def from_env(cls) -> CellCallConfig:
try:
rtp_bind_port = int(os.environ.get("CELL_RTP_BIND_PORT", "0"))
except (TypeError, ValueError) as exc:
raise CellCallError(
"RTP_PORT_INVALID", "CELL_RTP_BIND_PORT is invalid"
) from exc
return cls(
ari_url=os.environ.get("CELL_ARI_URL", "http://127.0.0.1:8088"),
ari_username=os.environ.get("CELL_ARI_USERNAME", "outbound"),
ari_password=os.environ.get("CELL_ARI_PASSWORD", ""),
ari_app=os.environ.get("CELL_ARI_APP", "agent-call"),
trunk_id=os.environ.get("CELL_SIP_TRUNK_ID", "provider-primary"),
caller_id=os.environ.get("CELL_CALLER_ID", "BD93205882"),
dial_prefix=os.environ.get("CELL_DIAL_PREFIX", "7089"),
rtp_bind_host=os.environ.get("CELL_RTP_BIND_HOST", "127.0.0.1"),
rtp_bind_port=rtp_bind_port,
recording_dir=os.environ.get("CELL_RECORDING_DIR", "/data/recordings"),
)
class ARIClient:
def __init__(self, config: CellCallConfig) -> None:
parsed = urlsplit(config.ari_url)
self.scheme = parsed.scheme
self.host = parsed.hostname or ""
self.port = parsed.port or (443 if self.scheme == "https" else 80)
self.prefix = parsed.path.rstrip("/") or "/ari"
self.username = config.ari_username
self.password = config.ari_password
self.timeout_s = config.event_timeout_s
def request(
self,
method: str,
resource: str,
params: Mapping[str, Any] | None = None,
body: bytes | None = None,
content_type: str = "application/json",
) -> dict[str, Any] | bytes:
path = self.prefix + "/" + resource.lstrip("/")
if params:
encoded = urlencode(
[
(key, str(value).lower() if isinstance(value, bool) else str(value))
for key, value in params.items()
]
)
path += "?" + encoded
auth = base64.b64encode(f"{self.username}:{self.password}".encode()).decode(
"ascii"
)
connection: http.client.HTTPConnection | http.client.HTTPSConnection
if self.scheme == "https":
connection = http.client.HTTPSConnection(
self.host,
self.port,
timeout=self.timeout_s,
context=ssl.create_default_context(),
)
else:
connection = http.client.HTTPConnection(
self.host, self.port, timeout=self.timeout_s
)
try:
connection.request(
method,
path,
body=body,
headers={
"Authorization": f"Basic {auth}",
"Accept": "application/json, audio/wav",
**({"Content-Type": content_type} if body is not None else {}),
},
)
response = connection.getresponse()
data = response.read()
if response.status >= 400:
raise CellCallError(
f"ARI_HTTP_{response.status}",
f"ARI returned HTTP {response.status}",
retryable=response.status == 409 or response.status >= 500,
)
if not data:
return {}
if "json" in (response.getheader("Content-Type") or ""):
try:
parsed = json.loads(data.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise CellCallError(
"ARI_RESPONSE_INVALID", "ARI returned invalid JSON"
) from exc
if not isinstance(parsed, dict):
raise CellCallError(
"ARI_RESPONSE_INVALID", "ARI returned a non-object response"
)
return parsed
return data
except CellCallError:
raise
except (TimeoutError, OSError, http.client.HTTPException) as exc:
raise CellCallError(
"ARI_CONNECTION_FAILED", "ARI request failed", True
) from exc
finally:
connection.close()
def events(self, app: str) -> Any:
try:
import websocket
except ImportError as exc: # pragma: no cover - dependency gate
raise CellCallError(
"WEBSOCKET_CLIENT_MISSING", "websocket-client is required for ARI"
) from exc
parsed_scheme = "wss" if self.scheme == "https" else "ws"
url = f"{parsed_scheme}://{self.host}:{self.port}{self.prefix}/events?{urlencode({'app': app})}"
auth = base64.b64encode(f"{self.username}:{self.password}".encode()).decode(
"ascii"
)
try:
return websocket.create_connection(
url,
header=[f"Authorization: Basic {auth}"],
timeout=self.timeout_s,
http_no_proxy=[self.host, "localhost", "127.0.0.1"],
)
except Exception as exc:
raise CellCallError(
"ARI_EVENTS_FAILED", "ARI event connection failed", True
) from exc
class RTPMedia:
"""Minimal RTP/PCMA endpoint for an Asterisk externalMedia channel."""
def __init__(self, host: str, port: int) -> None:
self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
self.socket.bind((host, port))
except OSError:
self.socket.close()
raise
self.peer: tuple[str, int] | None = None
self.recv_ssrc: int | None = None
self.sequence = 0
self.timestamp = 0
self.ssrc = uuid.uuid4().int & 0xFFFFFFFF
self._next_send_at: float | None = None
self._send_lock = threading.Lock()
@property
def address(self) -> tuple[str, int]:
value = self.socket.getsockname()
try:
return str(value[0]), int(value[1])
except (IndexError, TypeError, ValueError) as exc:
raise CellCallError(
"RTP_ADDRESS_INVALID", "RTP socket address is invalid"
) from exc
def close(self) -> None:
with contextlib.suppress(OSError):
self.socket.close()
def receive(self, timeout_s: float) -> bytes | None:
self.socket.settimeout(max(0.0, timeout_s))
try:
packet, peer = self.socket.recvfrom(2048)
except (BlockingIOError, TimeoutError, ConnectionRefusedError):
return None
except OSError as exc:
if self.socket.fileno() < 0:
return None
raise CellCallError(
"RTP_RECEIVE_FAILED", "RTP receive failed", True
) from exc
parsed = self._packet_info(packet)
if parsed is None:
return None
payload, payload_type, ssrc = parsed
if payload_type != 8:
return None
try:
candidate = (str(peer[0]), int(peer[1]))
except (IndexError, TypeError, ValueError) as exc:
raise CellCallError(
"RTP_PEER_INVALID", "RTP peer address is invalid"
) from exc
if self.peer is not None and candidate != self.peer:
return None
if self.recv_ssrc is not None and ssrc != self.recv_ssrc:
return None
if self.peer is None:
self.peer = candidate
if self.recv_ssrc is None:
self.recv_ssrc = ssrc
return payload
def send_pcm16(self, raw: bytes, input_rate_hz: int = 24000) -> int:
if not raw or len(raw) % 2:
return 0
if self.peer is None:
raise CellCallError(
"RTP_PEER_MISSING", "no Asterisk externalMedia RTP peer is configured"
)
payload = pcm16_to_alaw(raw, input_rate_hz, 8000)
sent = 0
packet_interval_s = 0.02
with self._send_lock:
now = time.monotonic()
if self._next_send_at is None or self._next_send_at < now - packet_interval_s:
self._next_send_at = now
for offset in range(0, len(payload), 160):
frame = payload[offset : offset + 160]
if not frame:
continue
delay = self._next_send_at - time.monotonic()
if delay > 0:
time.sleep(delay)
header = struct.pack(
"!BBHII",
0x80,
0x80 | 8 if offset == 0 else 8,
self.sequence & 0xFFFF,
self.timestamp & 0xFFFFFFFF,
self.ssrc,
)
try:
self.socket.sendto(header + frame, self.peer)
except OSError as exc:
raise CellCallError(
"RTP_SEND_FAILED", "RTP send failed", True
) from exc
self.sequence += 1
self.timestamp += 160
sent += len(frame)
self._next_send_at = max(
self._next_send_at + packet_interval_s, time.monotonic()
)
return sent
@staticmethod
def _packet_info(packet: bytes) -> tuple[bytes, int, int] | None:
if len(packet) < 12 or packet[0] >> 6 != 2:
return None
payload_type = packet[1] & 0x7F
ssrc = struct.unpack("!I", packet[8:12])[0]
cc = packet[0] & 0x0F
offset = 12 + cc * 4
if len(packet) < offset:
return None
if packet[0] & 0x10:
if len(packet) < offset + 4:
return None
extension_words = struct.unpack("!H", packet[offset + 2 : offset + 4])[0]
offset += 4 + extension_words * 4
if len(packet) < offset:
return None
end = len(packet)
if packet[0] & 0x20:
padding = packet[-1]
if not padding or padding > end - offset:
return None
end -= padding
payload = packet[offset:end]
return (payload, payload_type, ssrc) if payload else None
@classmethod
def _payload(cls, packet: bytes) -> bytes | None:
parsed = cls._packet_info(packet)
return parsed[0] if parsed is not None else None
def alaw_to_pcm16(payload: bytes) -> bytes:
output = bytearray()
for encoded in payload:
value = encoded ^ 0x55
magnitude = ((value & 0x0F) << 4) + 8
exponent = (value & 0x70) >> 4
if exponent:
magnitude = (magnitude + 0x100) << (exponent - 1)
sample = -magnitude if value & 0x80 else magnitude
try:
output.extend(int(sample).to_bytes(2, "little", signed=True))
except OverflowError as exc:
raise CellCallError(
"RTP_AUDIO_INVALID", "decoded PCMA sample is out of range"
) from exc
return bytes(output)
def voice_level(raw_pcm16: bytes) -> int:
if len(raw_pcm16) < 2:
return 0
samples = [
int.from_bytes(raw_pcm16[index : index + 2], "little", signed=True)
for index in range(0, len(raw_pcm16) - 1, 2)
]
return max(abs(sample) for sample in samples) if samples else 0
@dataclass
class CellCallResult:
call_id: str
status: str
reason_code: str
turns: list[dict[str, Any]] = field(default_factory=list)
recording_path: str | None = None
connected: bool = False
media_packets_in: int = 0
media_packets_out: int = 0
started_at: float = 0.0
ended_at: float = 0.0
route_policy_id: str | None = None
caller_profile_id: str | None = None
trunk_id: str | None = None
effective_target: str | None = None
def as_dict(self) -> dict[str, Any]:
return {
"call_id": self.call_id,
"status": self.status,
"reason_code": self.reason_code,
"turns": self.turns,
"recording_path": self.recording_path,
"connected": self.connected,
"media_packets_in": self.media_packets_in,
"media_packets_out": self.media_packets_out,
"duration_ms": max(0.0, (self.ended_at - self.started_at) * 1000),
"route_policy_id": self.route_policy_id,
"caller_profile_id": self.caller_profile_id,
"trunk_id": self.trunk_id,
"effective_target": self.effective_target,
}
class RealCellCall:
"""Execute one authorized call using a trusted Cell-local route binding."""
def __init__(
self, config: CellCallConfig, engine: ConversationEngine, asr: Any
) -> None:
self.config = config
self.engine = engine
self.asr = asr
self.ari = ARIClient(config)
self.call_id = f"call_{uuid.uuid4().hex}"
self.events: queue.Queue[dict[str, Any]] = queue.Queue(maxsize=256)
self.event_stop = threading.Event()
self.event_thread: threading.Thread | None = None
self.ws: Any | None = None
self.media: RTPMedia | None = None
self.bridge_id = f"bridge_{self.call_id}"
self.external_channel_id: str | None = None
self.target_channel_id: str | None = None
self.recording_name = f"agent-call-{self.call_id}"
self._known_channels: set[str] = set()
self._prebuffer: list[bytes] = []
self._call_ended = False
self._in_packets = 0
self._out_bytes = 0
def _reset_call(self) -> None:
self.call_id = f"call_{uuid.uuid4().hex}"
self.event_stop = threading.Event()
self.event_thread = None
self.ws = None
self.media = None
self.bridge_id = f"bridge_{self.call_id}"
self.external_channel_id = None
self.target_channel_id = None
self.recording_name = f"agent-call-{self.call_id}"
self._known_channels.clear()
self._prebuffer.clear()
self._call_ended = False
self._in_packets = 0
self._out_bytes = 0
def start_authorized_call(
self, callee: str, route: CellRoute | None = None
) -> CellCallResult:
if not callee.isdigit() or not 3 <= len(callee) <= 32:
raise CellCallError(
"CALLEE_INVALID", "callee must be the original digit string"
)
selected_route = route or CellRoute(
"legacy",
"legacy",
self.config.trunk_id,
self.config.caller_id,
self.config.dial_prefix,
)
self._reset_call()
self.engine.history.clear()
result = CellCallResult(
self.call_id,
"failed",
"CELL_EXECUTOR_EXCEPTION",
started_at=time.monotonic(),
route_policy_id=selected_route.route_policy_id,
caller_profile_id=selected_route.caller_profile_id,
trunk_id=selected_route.trunk_id,
effective_target=selected_route.dial_prefix + callee,
)
try:
self.media = RTPMedia(self.config.rtp_bind_host, self.config.rtp_bind_port)
self.ws = self.ari.events(self.config.ari_app)
self._start_event_reader()
self.ari.request("POST", f"bridges/{self.bridge_id}", {"type": "mixing"})
external = self.ari.request(
"POST",
"channels/externalMedia",
{
"app": self.config.ari_app,
"external_host": f"{self.media.address[0]}:{self.media.address[1]}",
"format": self.config.rtp_format,
"direction": "both",
"channelId": f"external_{self.call_id}",
},
)
self.external_channel_id = self._channel_id(external)
self._try_set_external_media_peer()
self._add_when_ready(self.external_channel_id)
target = self.ari.request(
"POST",
"channels",
{
"endpoint": f"PJSIP/{selected_route.dial_prefix + callee}@{selected_route.trunk_id}",
"app": self.config.ari_app,
"appArgs": self.call_id,
"callerId": selected_route.caller_id,
"timeout": 30,
"channelId": f"target_{self.call_id}",
},
)
self.target_channel_id = self._channel_id(target)
self._add_when_ready(self.target_channel_id)
self._wait_answer(result)
result.connected = True
self.ari.request(
"POST",
f"bridges/{self.bridge_id}/record",
{
"name": self.recording_name,
"format": "wav",
"ifExists": "fail",
"beepEnabled": False,
},
)
opening = str(self.engine.config["conversation"].get("opening", ""))
if opening:
opening_result = self.engine.speak(opening, f"opening_{self.call_id}")
self._play(opening_result["audio"])
result.media_packets_out = self._out_bytes // 160
max_turns = int(self.engine.config["conversation"]["max_turns"])
for turn_index in range(max_turns):
captured = self._capture_turn(
self.config.first_speech_timeout_s
if turn_index == 0
else self.config.end_silence_s * 2
)
if self._call_ended:
result.reason_code = "CUSTOMER_HANGUP"
result.status = "completed" if result.turns else "failed"
break
if not captured:
result.reason_code = (
"CUSTOMER_SILENT"
if turn_index == 0
else "CONVERSATION_COMPLETE"
)
result.status = "completed" if turn_index else "failed"
break
wav = pcm_to_wav(captured, 8000)
segments = list(
self.asr.transcribe(
wav, self.engine.config, f"turn_{turn_index}_{self.call_id}"
)
)
final = next(
(item for item in reversed(segments) if item.get("is_final")), None
)
if (
not isinstance(final, Mapping)
or not isinstance(final.get("text"), str)
or not final["text"]
):
raise CellCallError(
"ASR_NO_FINAL", "ASR did not produce a final telephone turn"
)
text = str(final["text"])
response = self.engine.run_text(text)
if response.get("status") != "completed":
raise CellCallError(
str(response.get("reason_code", "AI_GENERATION_FAILED")),
"AI turn failed",
)
self._play(response.get("audio", b""))
result.media_packets_out = self._out_bytes // 160
result.turns.append(
{
"turn_index": turn_index,
"customer_text": text,
"assistant_text": response.get("text", ""),
"asr_segments": segments,
"llm_first_token_ms": response.get("llm_first_token_ms"),
"tts_first_audio_ms": response.get("tts_first_audio_ms"),
}
)
else:
result.status = "completed"
result.reason_code = "MAX_TURNS"
if (
result.status == "failed"
and result.reason_code == "CELL_EXECUTOR_EXCEPTION"
):
result.reason_code = (
"CALL_COMPLETED" if result.turns else result.reason_code
)
except (CellCallError, AIProviderError) as exc:
result.status = "failed"
result.reason_code = (
exc.code
if isinstance(exc, (CellCallError, AIProviderError))
else "AI_PROVIDER_ERROR"
)
except (OSError, RuntimeError, TypeError, ValueError, KeyError) as exc:
del exc
result.status = "failed"
result.reason_code = "CELL_EXECUTOR_EXCEPTION"
finally:
result.ended_at = time.monotonic()
result.media_packets_in = self._in_packets
result.media_packets_out = self._out_bytes // 160
result.recording_path = self._finish_recording()
self._cleanup()
return result
def _start_event_reader(self) -> None:
self.event_thread = threading.Thread(
target=self._read_events, name=f"ari-events-{self.call_id}", daemon=True
)
self.event_thread.start()
def _read_events(self) -> None:
try:
import websocket
except ImportError:
return
while not self.event_stop.is_set() and self.ws is not None:
try:
raw = self.ws.recv()
except (
OSError,
RuntimeError,
TimeoutError,
ValueError,
websocket.WebSocketException,
):
return
if not raw:
continue
try:
event = json.loads(raw)
except (TypeError, json.JSONDecodeError):
continue
if not isinstance(event, dict):
continue
try:
self.events.put_nowait(event)
except queue.Full:
return
def _wait_answer(self, result: CellCallResult) -> None:
deadline = time.monotonic() + self.config.event_timeout_s
answered = False
expected_channels = {
channel_id
for channel_id in (self.external_channel_id, self.target_channel_id)
if channel_id
}
while time.monotonic() < deadline:
try:
event = self.events.get(timeout=min(0.5, deadline - time.monotonic()))
except queue.Empty:
event = None
if event is not None:
event_type = event.get("type")
channel = event.get("channel") or {}
channel_id = channel.get("id")
if (
event_type == "StasisStart"
and isinstance(channel_id, str)
and channel_id in expected_channels
):
if channel_id == self.external_channel_id:
self._try_set_external_media_peer()
self._add_when_ready(channel_id)
if (
event_type == "ChannelStateChange"
and channel_id == self.target_channel_id
and channel.get("state") == "Up"
):
answered = True
if (
event_type
in {"ChannelDestroyed", "StasisEnd", "ChannelHangupRequest"}
and channel_id == self.target_channel_id
):
self._call_ended = True
raise CellCallError(
"CALL_NOT_ANSWERED", "target channel ended before answer"
)
if answered:
self._try_set_external_media_peer()
try:
bridge_channels = self._bridge_channels()
except CellCallError as exc:
if exc.code not in {"ARI_HTTP_404", "ARI_HTTP_409"}:
raise
else:
if (
expected_channels.issubset(bridge_channels)
and self.media is not None
and self.media.peer is not None
):
return
if answered:
raise CellCallError(
"ARI_BRIDGE_NOT_READY",
"answered channels did not join the expected bridge",
True,
)
raise CellCallError(
"CALL_ANSWER_TIMEOUT", "ARI did not report an answered channel", True
)
def _capture_turn(self, initial_timeout_s: float) -> bytes:
if self.media is None:
raise CellCallError("RTP_UNAVAILABLE", "RTP media is not initialized")
started = bool(self._prebuffer)
last_voice = time.monotonic() if started else 0.0
started_at = time.monotonic()
deadline = started_at + max(initial_timeout_s, self.config.end_silence_s)
frames = self._prebuffer
self._prebuffer = []
while time.monotonic() < deadline:
if self._drain_terminal_events():
break
payload = self.media.receive(
min(0.1, max(0.0, deadline - time.monotonic()))
)
if payload is None:
if self._drain_terminal_events():
break
if (
started
and time.monotonic() - last_voice >= self.config.end_silence_s
):
break
continue
self._in_packets += 1
pcm = alaw_to_pcm16(payload)
level = voice_level(pcm)
if level >= self.config.voice_threshold:
if not started:
started = True
deadline = min(
started_at + self.config.max_turn_s,
time.monotonic() + self.config.max_turn_s,
)
last_voice = time.monotonic()
if started:
frames.append(pcm)
if time.monotonic() - last_voice >= self.config.end_silence_s:
break
if self.media is not None:
self.media.socket.settimeout(None)
return b"".join(frames)
def _drain_terminal_events(self) -> bool:
while True:
try:
event = self.events.get_nowait()
except queue.Empty:
return self._call_ended
event_type = event.get("type")
channel_id = (event.get("channel") or {}).get("id")
if (
event_type in {"ChannelDestroyed", "StasisEnd", "ChannelHangupRequest"}
and channel_id == self.target_channel_id
):
self._call_ended = True
return True
def _play(self, audio: bytes) -> int:
if self.media is None or not audio:
return 0
sent = 0
for offset in range(0, len(audio), 2 * 24000 // 50):
frame = audio[offset : offset + 2 * 24000 // 50]
if not frame:
continue
if self.engine.config["conversation"].get("allow_interrupt"):
self.media.socket.settimeout(0.0)
with contextlib.suppress(BlockingIOError, socket.timeout):
packet = self.media.receive(0.0)
if packet:
self._in_packets += 1
pcm = alaw_to_pcm16(packet)
if voice_level(pcm) >= self.config.voice_threshold:
self._prebuffer.append(pcm)
break
bytes_sent = self.media.send_pcm16(frame, 24000)
sent += bytes_sent
self._out_bytes += bytes_sent
return sent
def _try_set_external_media_peer(self) -> bool:
if self.external_channel_id is None:
return False
try:
self._set_external_media_peer(self.external_channel_id)
except CellCallError as exc:
if exc.code in {
"ARI_HTTP_400",
"ARI_HTTP_404",
"ARI_HTTP_409",
"RTP_EXTERNAL_PEER_UNAVAILABLE",
}:
return False
raise
return True
def _set_external_media_peer(self, channel_id: str) -> None:
if self.media is None:
raise CellCallError("RTP_UNAVAILABLE", "RTP media is not initialized")
values: dict[str, str] = {}
for variable in ("UNICASTRTP_LOCAL_ADDRESS", "UNICASTRTP_LOCAL_PORT"):
response = self.ari.request(
"GET",
f"channels/{channel_id}/variable",
{"variable": variable},
)
value = response.get("value") if isinstance(response, dict) else None
if not isinstance(value, str) or not value:
raise CellCallError(
"RTP_EXTERNAL_PEER_UNAVAILABLE",
f"ARI did not provide {variable}",
True,
)
values[variable] = value
try:
address = ipaddress.ip_address(values["UNICASTRTP_LOCAL_ADDRESS"])
port = int(values["UNICASTRTP_LOCAL_PORT"])
except (ValueError, TypeError) as exc:
raise CellCallError(
"RTP_EXTERNAL_PEER_INVALID", "ARI external RTP peer is invalid"
) from exc
if address.is_unspecified or address.is_multicast or not 1 <= port <= 65535:
raise CellCallError(
"RTP_EXTERNAL_PEER_INVALID", "ARI external RTP peer is invalid"
)
self.media.peer = (str(address), port)
def _bridge_channels(self) -> set[str]:
response = self.ari.request("GET", f"bridges/{self.bridge_id}")
channels = response.get("channels") if isinstance(response, dict) else None
if not isinstance(channels, list) or not all(
isinstance(channel_id, str) for channel_id in channels
):
raise CellCallError(
"ARI_BRIDGE_INVALID", "ARI did not return bridge channels"
)
return set(channels)
def _add_when_ready(self, channel_id: str | None) -> bool:
if not channel_id or channel_id in self._known_channels:
return channel_id in self._known_channels
for attempt in range(3):
try:
self.ari.request(
"POST",
f"bridges/{self.bridge_id}/addChannel",
{"channel": channel_id},
)
except CellCallError as exc:
if exc.code not in {
"ARI_HTTP_400",
"ARI_HTTP_404",
"ARI_HTTP_409",
"ARI_HTTP_422",
}:
raise
if attempt == 2:
return False
time.sleep(0.05 * (attempt + 1))
else:
self._known_channels.add(channel_id)
return True
return False
@staticmethod
def _channel_id(value: dict[str, Any] | bytes) -> str:
channel_id = value.get("id") if isinstance(value, dict) else None
if not isinstance(channel_id, str) or not channel_id:
raise CellCallError(
"ARI_CHANNEL_INVALID", "ARI did not return a channel ID"
)
return channel_id
def _finish_recording(self) -> str | None:
try:
try:
self.ari.request("POST", f"recordings/live/{self.recording_name}/stop")
except CellCallError as exc:
if exc.code != "ARI_HTTP_404":
return None
deadline = time.monotonic() + min(self.config.event_timeout_s, 5.0)
while True:
try:
raw = self.ari.request(
"GET", f"recordings/stored/{self.recording_name}/file"
)
break
except CellCallError as exc:
if exc.code != "ARI_HTTP_404":
return None
if time.monotonic() >= deadline:
return None
time.sleep(0.1)
except CellCallError:
return None
if not isinstance(raw, bytes) or not raw:
return None
try:
with wave.open(io.BytesIO(raw), "rb") as source:
frames = source.getnframes()
except (EOFError, OSError, wave.Error):
return None
if not frames > 0:
return None
path = Path(self.config.recording_dir) / f"{self.recording_name}.wav"
try:
if path.is_symlink():
return None
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(raw)
return str(path)
except OSError:
return None
def _cleanup(self) -> None:
self.event_stop.set()
for channel_id in (self.target_channel_id, self.external_channel_id):
if channel_id:
with contextlib.suppress(CellCallError):
self.ari.request("DELETE", f"channels/{channel_id}")
with contextlib.suppress(CellCallError):
self.ari.request("DELETE", f"bridges/{self.bridge_id}")
if self.ws is not None:
with contextlib.suppress(Exception):
self.ws.close()
if self.event_thread is not None:
self.event_thread.join(timeout=1.0)
if self.media is not None:
self.media.close()
class CellExecutionLedger:
"""Durable claim/result ledger preventing a post-dial redial."""
def __init__(self, path: str | Path) -> None:
self.path = Path(path)
if self.path.exists() and self.path.is_symlink():
raise CellCallError(
"LEDGER_PATH_INVALID", "execution ledger must not be a symlink"
)
self.path.parent.mkdir(parents=True, exist_ok=True)
with self._connect() as connection:
connection.execute(
"""
CREATE TABLE IF NOT EXISTS cell_executions (
execution_id TEXT PRIMARY KEY,
callee TEXT NOT NULL,
status TEXT NOT NULL,
result_json TEXT,
updated_at REAL NOT NULL
)
"""
)
def _connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(self.path, timeout=5)
connection.row_factory = sqlite3.Row
return connection
def claim(self, execution_id: str, callee: str) -> dict[str, Any]:
now = time.time()
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
row = connection.execute(
"SELECT status, result_json FROM cell_executions WHERE execution_id=?",
(execution_id,),
).fetchone()
if row is None:
connection.execute(
"INSERT INTO cell_executions(execution_id, callee, status, updated_at) VALUES (?, ?, 'in_progress', ?)",
(execution_id, callee, now),
)
return {"claimed": True, "in_doubt": False, "result": None}
status = str(row["status"])
if status == "in_progress":
return {"claimed": False, "in_doubt": True, "result": None}
try:
result = json.loads(row["result_json"]) if row["result_json"] else None
except (TypeError, json.JSONDecodeError) as exc:
raise CellCallError(
"LEDGER_CORRUPT", "execution ledger contains invalid JSON"
) from exc
return {"claimed": False, "in_doubt": False, "result": result}
def complete(self, execution_id: str, result: Mapping[str, Any]) -> None:
encoded = json.dumps(dict(result), ensure_ascii=False, sort_keys=True)
with self._connect() as connection:
connection.execute(
"UPDATE cell_executions SET status='completed', result_json=?, updated_at=? WHERE execution_id=?",
(encoded, time.time(), execution_id),
)
def result(self, execution_id: str) -> dict[str, Any] | None:
with self._connect() as connection:
row = connection.execute(
"SELECT result_json FROM cell_executions WHERE execution_id=?",
(execution_id,),
).fetchone()
if row is None or not row["result_json"]:
return None
try:
value = json.loads(row["result_json"])
except (TypeError, json.JSONDecodeError) as exc:
raise CellCallError(
"LEDGER_CORRUPT", "execution ledger contains invalid JSON"
) from exc
if not isinstance(value, dict):
raise CellCallError(
"LEDGER_CORRUPT", "execution ledger result is not an object"
)
return value
class RealCellWorker:
"""Consume one tenant's ``call.execute`` queue and run a Cell executor."""
def __init__(
self,
broker: Any,
tenant_key: str,
ledger: CellExecutionLedger,
executor: RealCellCall,
routes: Mapping[str, CellRoute] | None = None,
) -> None:
if not tenant_key:
raise CellCallError("TENANT_KEY_INVALID", "tenant_key is required")
self.broker = broker
self.tenant_key = tenant_key
self.queue_name = f"agent-call.executor.{tenant_key}.v1"
self.ledger = ledger
self.executor = executor
self.routes = dict(routes or {})
self.broker.declare_tenant(tenant_key)
def process_once(self) -> dict[str, Any] | None:
message = self.broker.consume(self.queue_name)
if message is None:
return None
body = message.get("body") if isinstance(message, Mapping) else None
try:
(
execution_id,
tenant_id,
agent_version_id,
callee,
route_policy_id,
caller_profile_id,
) = self._command(body)
except CellCallError:
self.broker.reject(message)
return {"status": "rejected", "reason_code": "COMMAND_INVALID"}
try:
route = self._route(route_policy_id, caller_profile_id)
except CellCallError as exc:
self.broker.reject(message)
return {"status": "rejected", "reason_code": exc.code}
claim = self.ledger.claim(execution_id, callee)
if claim["in_doubt"]:
self.broker.ack(message)
return {"status": "reconciled", "reason_code": "EXECUTION_IN_DOUBT"}
result = claim["result"]
if claim["claimed"]:
try:
if agent_version_id != self.executor.engine.config["agent_version_id"]:
raise CellCallError(
"AGENT_VERSION_UNAVAILABLE",
"agent version is not installed on this Cell",
)
result = (
self.executor.start_authorized_call(callee, route)
if route is not None
else self.executor.start_authorized_call(callee)
).as_dict()
except (
CellCallError,
AIProviderError,
OSError,
RuntimeError,
TypeError,
ValueError,
KeyError,
) as exc:
result = {
"call_id": f"call_{uuid.uuid4().hex}",
"status": "failed",
"reason_code": getattr(exc, "code", "CELL_EXECUTOR_EXCEPTION"),
"connected": False,
"turns": [],
}
self.ledger.complete(execution_id, result)
event = self._event(execution_id, tenant_id, callee, result or {})
try:
self.broker.publish(
"agent-call.events.v1",
"agent-call.call.finished",
event,
message_id=event["event_id"],
)
except (BrokerError, OSError, RuntimeError, TypeError, ValueError, KeyError):
self.broker.requeue(message)
return {"status": "pending", "reason_code": "EVENT_PUBLISH_FAILED"}
self.broker.ack(message)
return event
def _route(self, route_policy_id: str, caller_profile_id: str) -> CellRoute | None:
if not route_policy_id and not caller_profile_id:
if self.routes:
raise CellCallError(
"ROUTE_POLICY_REQUIRED",
"route policy is required on a multi-trunk Cell",
)
return None
if not route_policy_id or not caller_profile_id:
raise CellCallError(
"ROUTE_POLICY_INVALID", "route policy and caller profile must be paired"
)
if not self.routes:
if (route_policy_id, caller_profile_id) == (
"route_policy_test",
"caller_profile_test",
):
return None
raise CellCallError(
"ROUTE_POLICY_UNAVAILABLE", "route policy is not installed on this Cell"
)
route = self.routes.get(route_policy_id)
if route is None:
raise CellCallError(
"ROUTE_POLICY_UNAVAILABLE", "route policy is not installed on this Cell"
)
if route.caller_profile_id != caller_profile_id:
raise CellCallError(
"CALLER_PROFILE_MISMATCH",
"caller profile is not bound to the route policy",
)
return route
def _command(self, body: Any) -> tuple[str, str, str, str, str, str]:
if not isinstance(body, Mapping) or body.get("command_type") != "call.execute":
raise CellCallError("COMMAND_INVALID", "message is not call.execute")
if body.get("tenant_key") != self.tenant_key:
raise CellCallError(
"TENANT_ROUTE_MISMATCH", "message tenant_key does not match queue"
)
payload = body.get("payload")
if not isinstance(payload, Mapping):
raise CellCallError("COMMAND_INVALID", "call.execute payload is required")
values = (
body.get("tenant_id"),
payload.get("execution_id"),
payload.get("agent_version_id"),
payload.get("callee"),
)
route_policy_id = payload.get("route_policy_id", "")
caller_profile_id = payload.get("caller_profile_id", "")
if not isinstance(route_policy_id, str) or not isinstance(
caller_profile_id, str
):
raise CellCallError(
"COMMAND_INVALID", "route policy and caller profile must be strings"
)
if bool(route_policy_id) != bool(caller_profile_id):
raise CellCallError(
"COMMAND_INVALID", "route policy and caller profile must be paired"
)
if not all(isinstance(value, str) and value for value in values):
raise CellCallError(
"COMMAND_INVALID", "call.execute identity fields are required"
)
return (
str(values[1]),
str(values[0]),
str(values[2]),
str(values[3]),
route_policy_id,
caller_profile_id,
)
@staticmethod
def _event(
execution_id: str, tenant_id: str, callee: str, result: Mapping[str, Any]
) -> dict[str, Any]:
payload = dict(result)
payload.pop("recording_path", None)
payload["callee"] = callee
payload["execution_id"] = execution_id
return {
"schema_version": "1.0",
"event_type": "call.finished",
"event_id": f"{execution_id}:call.finished",
"tenant_id": tenant_id,
"payload": payload,
}
__all__ = [
"ARIClient",
"CellCallConfig",
"CellCallError",
"CellCallResult",
"CellExecutionLedger",
"CellRoute",
"RTPMedia",
"RealCellCall",
"RealCellWorker",
"alaw_to_pcm16",
"load_cell_routes",
"voice_level",
]