support task-selectable SIP trunks
This commit is contained in:
+180
-8
@@ -48,6 +48,87 @@ class CellCallError(RuntimeError):
|
||||
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``."""
|
||||
@@ -406,6 +487,10 @@ class CellCallResult:
|
||||
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 {
|
||||
@@ -418,11 +503,15 @@ class CellCallResult:
|
||||
"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 fixed Cell-local trunk."""
|
||||
"""Execute one authorized call using a trusted Cell-local route binding."""
|
||||
|
||||
def __init__(
|
||||
self, config: CellCallConfig, engine: ConversationEngine, asr: Any
|
||||
@@ -463,11 +552,16 @@ class RealCellCall:
|
||||
self._in_packets = 0
|
||||
self._out_bytes = 0
|
||||
|
||||
def start_authorized_call(self, callee: str) -> CellCallResult:
|
||||
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(
|
||||
@@ -475,6 +569,10 @@ class RealCellCall:
|
||||
"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)
|
||||
@@ -499,10 +597,10 @@ class RealCellCall:
|
||||
"POST",
|
||||
"channels",
|
||||
{
|
||||
"endpoint": f"PJSIP/{self.config.dial_prefix + callee}@{self.config.trunk_id}",
|
||||
"endpoint": f"PJSIP/{selected_route.dial_prefix + callee}@{selected_route.trunk_id}",
|
||||
"app": self.config.ari_app,
|
||||
"appArgs": self.call_id,
|
||||
"callerId": self.config.caller_id,
|
||||
"callerId": selected_route.caller_id,
|
||||
"timeout": 30,
|
||||
"channelId": f"target_{self.call_id}",
|
||||
},
|
||||
@@ -1025,6 +1123,7 @@ class RealCellWorker:
|
||||
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")
|
||||
@@ -1033,6 +1132,7 @@ class RealCellWorker:
|
||||
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:
|
||||
@@ -1041,10 +1141,22 @@ class RealCellWorker:
|
||||
return None
|
||||
body = message.get("body") if isinstance(message, Mapping) else None
|
||||
try:
|
||||
execution_id, tenant_id, agent_version_id, callee = self._command(body)
|
||||
(
|
||||
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)
|
||||
@@ -1057,7 +1169,11 @@ class RealCellWorker:
|
||||
"AGENT_VERSION_UNAVAILABLE",
|
||||
"agent version is not installed on this Cell",
|
||||
)
|
||||
result = self.executor.start_authorized_call(callee).as_dict()
|
||||
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,
|
||||
@@ -1089,7 +1205,42 @@ class RealCellWorker:
|
||||
self.broker.ack(message)
|
||||
return event
|
||||
|
||||
def _command(self, body: Any) -> tuple[str, str, str, str]:
|
||||
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:
|
||||
@@ -1105,11 +1256,30 @@ class RealCellWorker:
|
||||
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])
|
||||
return (
|
||||
str(values[1]),
|
||||
str(values[0]),
|
||||
str(values[2]),
|
||||
str(values[3]),
|
||||
route_policy_id,
|
||||
caller_profile_id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _event(
|
||||
@@ -1134,9 +1304,11 @@ __all__ = [
|
||||
"CellCallError",
|
||||
"CellCallResult",
|
||||
"CellExecutionLedger",
|
||||
"CellRoute",
|
||||
"RTPMedia",
|
||||
"RealCellCall",
|
||||
"RealCellWorker",
|
||||
"alaw_to_pcm16",
|
||||
"load_cell_routes",
|
||||
"voice_level",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user