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
+2
View File
@@ -38,6 +38,8 @@ CELL_ARI_PASSWORD=
CELL_ARI_APP=agent-call
CELL_SIP_TRUNK_ID=provider-primary
CELL_CALLER_ID=BD93205882
# Optional trusted JSON route map; call.execute supplies only route_policy_id/caller_profile_id.
CELL_ROUTE_MAP_JSON=
CELL_RTP_BIND_HOST=127.0.0.1
CELL_RECORDING_DIR=/data/recordings
+4 -4
View File
@@ -81,14 +81,14 @@
## 测试呼叫号码
- 用户指定测试被叫号码:`15003164745`(第一组)、`18625770806`(第二组)。仅用于已授权的 Mock/明确安排的测试;不得因写入此处而自动发起真实呼叫,原始号码保持不变。
- 用户指定测试被叫号码:`15003164745`(第一组)、`18625770806`(第二组、第三组,按各自线路规则)。仅用于已授权的 Mock/明确安排的测试;不得因写入此处而自动发起真实呼叫,原始号码保持不变。
- 主叫标识保留原值(包括 `BD`),不能按纯数字手机号清洗,也不能直接当成 Digest 认证用户名;具体 From/PAI 等字段映射仍需确认。
- 业务原始被叫号码保持不变;使用该线路时按其规则构造 `7089<被叫号码>`,避免重复添加或把该前缀带到其他供应商线路。
- 传输协议、IP/Digest 鉴权、是否注册及并发限制仍需供应商确认;当前供应商已反馈需使用 PCMA,Asterisk 配置以 `allow=alaw` 表示,仍需真实线路验证。
- 目前只提供一服务端地址,未提供独立备用地址。不能把同一地址重复填写成主备并宣称具备容灾;现有生成器要求 primary/backup,单线路启动支持尚待调整
- 每条线路目前只提供一服务端地址,未提供独立备用地址。不能把同一地址重复填写成主备并宣称具备容灾;三条已登记线路应作为独立 trunk 配置,不为凑主备虚构供应商
## 多 SIP 供应商方案(用户已确认,运行代码待实现)
## 多 SIP 供应商方案(用户已确认,测试运行骨架已实现)
- 采用**供应商按需预接入 + 每次外呼动态选路**。先验证当前供应商,后续逐家新增,不等待所有供应商一次性接入完毕。
- 供应商/线路层预先维护独立 trunk:服务端、协议、鉴权、注册、允许的主叫、被叫改写规则和并发额度。不同供应商不能共用并逐呼覆盖同一份线路配置。
@@ -96,7 +96,7 @@
- MQ 指令不能让调用方任意注入 SIP 地址、认证凭据或越权主叫;线路选择对应的具体消息字段仍由用户制定,不擅自定接口。
- SIP Trunk、codec、主叫/被叫规则、出口池及多机器 Cell 发布由独立 Asterisk/SIP 管理后台作为唯一写入面统一管理;SaaS 仅可读取受控 Trunk 目录/状态,不直接访问 Asterisk/ARI 管理面或写入 SIP 配置。
- 切换线路时从原始被叫重新应用目标线路规则,不能沿用上一家的前缀或主叫。重试/切换条件需按业务契约另行实现,不能默认重复拨打已接通的电话。
- 现有生成器支持固定 primary/backup,尚非完整多供应商路由;后续支持单线路启动、可扩展线路列表及逐呼选路,不为凑齐主备而虚构供应商
- Asterisk 生成器支持任意数量的独立 `trunks`Real Cell 通过受信的 Cell 本地路由表把 `route_policy_id + caller_profile_id` 绑定到已配置 `trunk_id`、主叫和被叫前缀,任务不能注入 SIP 地址、认证凭据或主叫。无需逐呼重写 `pjsip.conf`、重载 Asterisk 或重建注册;生产路由表仍由 SIP 管理后台发布
## 生产容量与网络架构基线(用户已确认,运行代码待实现)
+9 -1
View File
@@ -13,7 +13,13 @@ from typing import Any
from .ai_runtime import ConversationEngine, load_prompt
from .bailian import BailianASR, BailianLLM, BailianTTS, build_bailian_config
from .core import PikaBroker
from .real_cell import CellCallConfig, CellExecutionLedger, RealCellCall, RealCellWorker
from .real_cell import (
CellCallConfig,
CellExecutionLedger,
RealCellCall,
RealCellWorker,
load_cell_routes,
)
def _profile(path: str) -> dict[str, Any]:
@@ -77,11 +83,13 @@ def build_worker() -> RealCellWorker:
ledger = CellExecutionLedger(
os.environ.get("AGENT_CALL_EXECUTION_LEDGER", "/data/cell-executions.sqlite3")
)
routes = load_cell_routes(os.environ.get("CELL_ROUTE_MAP_JSON"))
return RealCellWorker(
broker,
tenant_key,
ledger,
RealCellCall(CellCallConfig.from_env(), engine, asr),
routes=routes,
)
+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",
]
+2
View File
@@ -95,6 +95,8 @@ services:
CELL_ARI_APP: "${CELL_ARI_APP:-agent-call}"
CELL_SIP_TRUNK_ID: "${CELL_SIP_TRUNK_ID:?Set CELL_SIP_TRUNK_ID}"
CELL_CALLER_ID: "${CELL_CALLER_ID:?Set CELL_CALLER_ID}"
CELL_DIAL_PREFIX: "${CELL_DIAL_PREFIX:-7089}"
CELL_ROUTE_MAP_JSON: "${CELL_ROUTE_MAP_JSON:-}"
CELL_RTP_BIND_HOST: "${CELL_RTP_BIND_HOST:-127.0.0.1}"
CELL_RECORDING_DIR: "${CELL_RECORDING_DIR:-/data/recordings}"
volumes:
+1 -1
View File
@@ -17,7 +17,7 @@
"max_turns": 3,
"max_duration_ms": 180000,
"callee": "15003164745",
"authorized_callees": ["15003164745"],
"authorized_callees": ["15003164745", "18625770806"],
"max_prompt_bytes": 32768,
"notes": "Use BAILIAN_* environment variables; keep secrets out of this file."
}
+3
View File
@@ -23,5 +23,8 @@ CELL_ARI_PASSWORD=
CELL_ARI_APP=agent-call
CELL_SIP_TRUNK_ID=provider-primary
CELL_CALLER_ID=BD93205882
CELL_DIAL_PREFIX=7089
# Optional trusted JSON route map; do not populate from an untrusted call payload.
CELL_ROUTE_MAP_JSON=
CELL_RTP_BIND_HOST=127.0.0.1
CELL_RECORDING_DIR=/data/recordings
+46
View File
@@ -0,0 +1,46 @@
{
"public_ip": "123.56.71.98",
"transport": "udp",
"local_net": "172.16.0.0/12",
"trunks": [
{
"trunk_id": "provider-primary",
"host": "61.132.228.221",
"port": 5060,
"auth_mode": "ip",
"register": false,
"from_user": "BD93205882",
"caller_id": "BD93205882",
"codec_profile": {
"allowed": ["PCMA"],
"preferred": "PCMA"
}
},
{
"trunk_id": "provider-second",
"host": "60.171.24.90",
"port": 5060,
"auth_mode": "ip",
"register": false,
"from_user": "mbkq",
"caller_id": "mbkq",
"codec_profile": {
"allowed": ["PCMA"],
"preferred": "PCMA"
}
},
{
"trunk_id": "provider-third",
"host": "160.202.254.79",
"port": 5060,
"auth_mode": "ip",
"register": false,
"from_user": "KQ91526",
"caller_id": "KQ91526",
"codec_profile": {
"allowed": ["PCMA"],
"preferred": "PCMA"
}
}
]
}
+20
View File
@@ -0,0 +1,20 @@
{
"route_policy_sip_first": {
"caller_profile_id": "caller_profile_sip_first",
"trunk_id": "provider-primary",
"caller_id": "BD93205882",
"dial_prefix": "7089"
},
"route_policy_sip_second": {
"caller_profile_id": "caller_profile_sip_second",
"trunk_id": "provider-second",
"caller_id": "mbkq",
"dial_prefix": ""
},
"route_policy_sip_third": {
"caller_profile_id": "caller_profile_sip_third",
"trunk_id": "provider-third",
"caller_id": "KQ91526",
"dial_prefix": "mka755"
}
}
+2
View File
@@ -32,5 +32,7 @@ CELL_ARI_PASSWORD=replace-before-start
CELL_ARI_APP=agent-call
CELL_SIP_TRUNK_ID=provider-primary
CELL_CALLER_ID=BD93205882
# Optional trusted JSON route map for a real Cell; leave empty in mock mode.
CELL_ROUTE_MAP_JSON=
CELL_RTP_BIND_HOST=127.0.0.1
CELL_RECORDING_DIR=/data/recordings
+49 -19
View File
@@ -13,6 +13,7 @@ from pathlib import Path
PUBLIC_IP = "123.56.71.98"
CODEC_TO_ASTERISK = {"PCMA": "alaw", "PCMU": "ulaw"}
TRUNK_ID_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}")
def scalar(value, name, secret=False):
@@ -115,6 +116,47 @@ def endpoint(name, data, env):
return text + auth
def _trunks(cfg):
if "trunks" not in cfg:
primary_data = cfg.get("primary", {})
if not isinstance(primary_data, dict):
raise ValueError("primary must be an object")
rows = [("provider-primary", primary_data)]
backup_data = cfg.get("backup")
if backup_data is not None and not isinstance(backup_data, dict):
raise ValueError("backup must be an object when configured")
if backup_data:
rows.append(("provider-backup", backup_data))
if (primary_data.get("host"), primary_data.get("port", 5060)) == (
backup_data.get("host"),
backup_data.get("port", 5060),
):
raise ValueError(
"primary and backup targets must be distinct; shared failure domains still require validation"
)
return rows
if "primary" in cfg or "backup" in cfg:
raise ValueError("use trunks instead of mixing arbitrary trunks with primary/backup")
raw = cfg["trunks"]
if not isinstance(raw, list) or not raw:
raise ValueError("trunks must be a non-empty list")
rows = []
seen = set()
for index, data in enumerate(raw):
if not isinstance(data, dict):
raise TypeError(f"trunks[{index}] must be an object")
name = data.get("trunk_id")
if not isinstance(name, str) or not TRUNK_ID_RE.fullmatch(name):
raise ValueError(
f"trunks[{index}].trunk_id must be a safe Asterisk endpoint identifier"
)
if name in seen:
raise ValueError(f"duplicate trunk_id: {name}")
seen.add(name)
rows.append((name, data))
return rows
def render(cfg, env):
if cfg.get("public_ip") != PUBLIC_IP or cfg.get("transport") != "udp":
raise ValueError(
@@ -138,32 +180,20 @@ def render(cfg, env):
password = scalar(env.get("ARI_PASSWORD"), "ARI_PASSWORD", secret=True)
if len(password) < 32:
raise ValueError("ARI_PASSWORD must have at least 32 characters")
primary_data = cfg.get("primary", {})
primary = endpoint("provider-primary", primary_data, env)
backup_data = cfg.get("backup")
if backup_data is not None and not isinstance(backup_data, dict):
raise ValueError("backup must be an object when configured")
sections = [primary]
if backup_data:
backup = endpoint("provider-backup", backup_data, env)
if (primary_data["host"], primary_data.get("port", 5060)) == (
backup_data["host"],
backup_data.get("port", 5060),
):
raise ValueError(
"primary and backup targets must be distinct; shared failure domains still require validation"
)
sections.append(backup)
trunks = _trunks(cfg)
sections = [endpoint(name, data, env) for name, data in trunks]
primary_name, primary_data = trunks[0]
transport = f"[global]\ntype=global\nuser_agent=agent-call\n\n[transport-udp]\ntype=transport\nprotocol=udp\nbind=0.0.0.0:5060\nlocal_net={network}\nexternal_signaling_address={PUBLIC_IP}\nexternal_media_address={PUBLIC_IP}\n\n"
caller = primary_data.get("caller_id") or primary_data.get("from_user")
extensions = "[deny-inbound]\nexten => s,1,Hangup()\nexten => _.,1,Hangup()\n"
if caller:
caller = scalar(caller, "primary.caller_id")
caller = scalar(caller, f"{primary_name}.caller_id")
extensions += (
"\n[outbound]\n"
"exten => _X.,1,NoOp(agent-call outbound)\n"
"; ARI Cell calls select an approved endpoint with PJSIP/<number>@<trunk_id>.\n"
"exten => _X.,1,NoOp(agent-call outbound fallback)\n"
f" same => n,Set(CALLERID(all)={caller})\n"
" same => n,Dial(PJSIP/${EXTEN}@provider-primary,60)\n"
f" same => n,Dial(PJSIP/${{EXTEN}}@{primary_name},60)\n"
" same => n,Hangup()\n"
)
return {
+4 -3
View File
@@ -202,12 +202,13 @@ if [ ! -e .local/asterisk.json ]; then
fi
```
填写实际`local_net`、主/备SIP服务器、端口、IP或Digest鉴权方式、用户名及是否注册。当前生成器按供应商反馈提供经过明确限制的UDP/PCMA外呼基线(Asterisk `allow=alaw`),TCP/TLS及特殊号码/主叫策略需要另行确认。
填写实际`local_net`及独立SIP线路列表。三线路测试配置见`deploy/asterisk.three-sip.json`,每个`trunk_id`对应一个独立PJSIP endpoint;不能把不同供应商合并成主/备后逐呼覆盖。当前生成器按供应商反馈提供经过明确限制的UDP/PCMA外呼基线(Asterisk `allow=alaw`),TCP/TLS及特殊号码/主叫策略需要另行确认。
通过受控进程环境注入:
- `ARI_PASSWORD`:至少32位,不能使用样例密码。
- Digest接入另需`SIP_PRIMARY_PASSWORD``SIP_BACKUP_PASSWORD`;仅在相应接入启用Digest时要求。
- Digest接入另需对应`SIP_<TRUNK_ID>_PASSWORD`环境变量;仅在相应接入启用Digest时要求。
- Real Cell 多线路运行时,将批准的`deploy/cell-routes.three-sip.json`序列化到`CELL_ROUTE_MAP_JSON`MQ任务只携带`route_policy_id``caller_profile_id`,Cell拒绝未安装或主叫绑定不匹配的路由。
- 为防INI注入,换行、分号、方括号等字符会被拒绝;供应商固定密码不满足时需评审正确转义方案,不能删除校验绕过。
```bash
@@ -241,7 +242,7 @@ docker compose -f compose.asterisk.yaml exec -T asterisk \
asterisk -rx 'pjsip show contacts'
```
只有一个节点采用host network;不要在同一宿主机直接启动第二套争用5060/8088/RTP的配置。主备trunk是接入配置,自动FALLBACK、长期ARI事件连接、业务状态机和录音上传程序仍需开发
只有一个节点采用host network;不要在同一宿主机直接启动第二套争用5060/8088/RTP的配置。三条trunk是独立接入配置,任务通过受信路由表选定具体endpoint;自动FALLBACK、长期ARI事件连接、业务状态机和录音上传程序仍需按契约实现
开始真实外呼前,必须通过SIP供应商侧日志核对我方实际出口确为123.56.71.98;仅EIP绑定成功不能证明没有其他NAT/路由改变出口。
+12 -3
View File
@@ -50,6 +50,8 @@ def _args() -> argparse.Namespace:
parser.add_argument("--callee", required=True)
parser.add_argument("--tenant-id", default=None)
parser.add_argument("--tenant-key", default=None)
parser.add_argument("--route-policy-id", default="route_policy_test")
parser.add_argument("--caller-profile-id", default="caller_profile_test")
parser.add_argument("--broker-url", default=None)
parser.add_argument("--ledger", default="/data/agent-call-cell.sqlite3")
parser.add_argument("--wait-seconds", type=int, default=240)
@@ -84,8 +86,8 @@ def _command(
"task_item_id": f"item_{execution_id}",
"task_revision": 1,
"callee": args.callee,
"route_policy_id": "route_policy_test",
"caller_profile_id": "caller_profile_test",
"route_policy_id": args.route_policy_id,
"caller_profile_id": args.caller_profile_id,
"agent_version_id": agent_version_id,
"variables": {},
"ring_timeout_ms": 30000,
@@ -142,6 +144,7 @@ def run(args: argparse.Namespace) -> dict[str, Any]:
CellExecutionLedger,
RealCellCall,
RealCellWorker,
load_cell_routes,
)
settings = _data(_path(args.profile))
@@ -181,7 +184,13 @@ def run(args: argparse.Namespace) -> dict[str, Any]:
broker = PikaBroker(args.broker_url or os.environ.get("RABBITMQ_URL", ""))
ledger = CellExecutionLedger(args.ledger)
executor = RealCellCall(CellCallConfig.from_env(), engine, BailianASR.from_env())
worker = RealCellWorker(broker, tenant_key, ledger, executor)
worker = RealCellWorker(
broker,
tenant_key,
ledger,
executor,
routes=load_cell_routes(os.environ.get("CELL_ROUTE_MAP_JSON")),
)
command = _command(settings, args, tenant_id, tenant_key, agent_version_id)
route = f"agent-call.tenant.{tenant_key}.call.execute"
broker.publish(
+31
View File
@@ -308,6 +308,37 @@ class AsteriskTests(unittest.TestCase):
self.assertIn("context=deny-inbound", files["pjsip.conf"])
self.assertIn("strictrtp=yes", files["rtp.conf"])
def test_renders_arbitrary_task_selectable_trunks(self):
cfg = self.cfg()
cfg.pop("primary")
cfg.pop("backup")
cfg["trunks"] = [
{
"trunk_id": "provider-first",
"host": "61.132.228.221",
"auth_mode": "ip",
"caller_id": "BD93205882",
"codec_profile": {"allowed": ["PCMA"], "preferred": "PCMA"},
},
{
"trunk_id": "provider-second",
"host": "60.171.24.90",
"auth_mode": "ip",
"codec_profile": {"allowed": ["PCMA"], "preferred": "PCMA"},
},
{
"trunk_id": "provider-third",
"host": "160.202.254.79",
"auth_mode": "ip",
"codec_profile": {"allowed": ["PCMA"], "preferred": "PCMA"},
},
]
files = ast.render(cfg, {"ARI_PASSWORD": "x" * 32})
for trunk_id in ("provider-first", "provider-second", "provider-third"):
self.assertIn(f"[{trunk_id}]", files["pjsip.conf"])
self.assertNotIn("provider-backup", files["pjsip.conf"])
self.assertIn("PJSIP/${EXTEN}@provider-first", files["extensions.conf"])
def test_preserves_explicit_caller_mapping(self):
cfg = self.cfg()
cfg["primary"].update(
+87
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import json
import socket
import struct
import tempfile
@@ -12,10 +13,12 @@ from agent_call.real_cell import (
CellCallConfig,
CellCallError,
CellExecutionLedger,
CellRoute,
RealCellCall,
RealCellWorker,
RTPMedia,
alaw_to_pcm16,
load_cell_routes,
voice_level,
)
@@ -243,6 +246,90 @@ class RealCellTests(unittest.TestCase):
"mka755",
)
def test_route_map_binds_task_policy_to_trusted_trunk(self) -> None:
routes = load_cell_routes(
json.dumps(
{
"route-a": {
"caller_profile_id": "caller-a",
"trunk_id": "provider-second",
"caller_id": "mbkq",
"dial_prefix": "",
}
}
)
)
self.assertEqual(routes["route-a"].trunk_id, "provider-second")
self.assertEqual(routes["route-a"].caller_id, "mbkq")
with self.assertRaises(CellCallError):
load_cell_routes(
json.dumps(
{
"route-a": {
"caller_profile_id": "caller-a",
"trunk_id": "provider-second",
"caller_id": "mbkq\nspoof",
"dial_prefix": "",
}
}
)
)
def test_worker_selects_installed_route_without_payload_sip_values(self) -> None:
command = {
"body": {
"command_type": "call.execute",
"tenant_id": "tenant-demo",
"tenant_key": "tenant-key",
"payload": {
"execution_id": "exec-route-1",
"agent_version_id": "agent_v1",
"callee": "18625770806",
"route_policy_id": "route-a",
"caller_profile_id": "caller-a",
},
}
}
broker = FakeBroker([command])
with tempfile.TemporaryDirectory() as directory:
ledger = CellExecutionLedger(Path(directory) / "ledger.sqlite3")
fake_result = SimpleNamespace(
as_dict=lambda: {"call_id": "call-route", "status": "failed", "turns": []}
)
class FakeExecutor:
def __init__(self) -> None:
self.engine = SimpleNamespace(
config={"agent_version_id": "agent_v1"}
)
self.calls: list[tuple[str, CellRoute | None]] = []
def start_authorized_call(
self, callee: str, route: CellRoute | None = None
) -> SimpleNamespace:
self.calls.append((callee, route))
return fake_result
fake_executor = FakeExecutor()
worker = RealCellWorker(
broker,
"tenant-key",
ledger,
cast(RealCellCall, fake_executor),
routes={
"route-a": CellRoute(
"route-a", "caller-a", "provider-second", "mbkq", ""
)
},
)
event = worker.process_once()
if event is None:
self.fail("worker did not publish a call.finished event")
self.assertEqual(fake_executor.calls[0][0], "18625770806")
selected_route = fake_executor.calls[0][1]
assert selected_route is not None
self.assertEqual(selected_route.trunk_id, "provider-second")
def test_ledger_marks_in_progress_as_in_doubt(self) -> None:
with tempfile.TemporaryDirectory() as directory:
ledger = CellExecutionLedger(Path(directory) / "ledger.sqlite3")