support task-selectable SIP trunks

This commit is contained in:
2026-09-17 15:23:46 +08:00
parent 84d86319a3
commit e65cfbe3af
15 changed files with 452 additions and 39 deletions
+180 -8
View File
@@ -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",
]