fix real cell RTP bridge readiness

This commit is contained in:
2026-09-17 14:29:37 +08:00
parent b6d0af1a56
commit 668c12e523
10 changed files with 781 additions and 51 deletions
+42 -6
View File
@@ -2,11 +2,21 @@
## 目录与实施范围
- 项目根目录用于部署,`docs/` 保存需求、计划与验收资料,不挪回根目录。
- 项目根目录用于部署,`docs/` 保存本项目需求、计划与验收资料,不挪回根目录。
- `sip_mock_server/` 是计划独立拆仓的子项目,源码、配置、测试和文档均保留在该目录,遵守其 `AGENTS.md`;父项目只通过固定镜像和版本化协议/契约与其集成。
- 现有实现为 ASR Web 验证、Asterisk 配置和阿里云主机准备工具,不是完整外呼平台。用户已接受最终方案,后续按 `docs/最终开发部署监控与验收计划_v1.0.md` 开展契约驱动 Mock 开发,再替换真实依赖。
- 用户已决定**仅复用 voice_test 的 ASR**。禁止复制或启用该仓库的 LLM/TTS;新规范确认前,界面必须明确显示未启用。
- SaaS 指令与所有业务结果均走 RabbitMQ;录音先上传 OSS,再通过 MQ 回传 OSS ID。接口/消息规范由用户制定,不能擅自改成 HTTP 业务回调。
## 管理平台独立项目约束(用户已确认)
- 整套 SIP 管理平台在 `management/` 实现,作为同仓库内独立项目,拥有自己的配置、依赖、持久化迁移、构建、测试、部署和发布入口;不与其它部署、管理或测试业务耦合。设计与权威契约仍保留在 `docs/`
- 前端固定使用 <https://github.com/Kiranism/tanstack-start-dashboard> 作为管理系统基座,实施时锁定上游版本和依赖并核验/保留许可证声明;后端 API 使用 **Go**。建议目录为 `management/frontend/``management/backend/``management/deploy/`,由 `management/README.md` 给出独立运行与验证步骤。
- 既有 `agent_call/sip_management.py` 仅作契约与行为参考,不作为管理平台运行依赖;禁止导入其它项目内部代码、代理/调用 Python 实现业务 API、共享其它业务数据库或依赖其部署脚本/环境文件/测试夹具。TanStack 服务端仅承担页面及必要会话/转发衔接,不另建业务后端;Go API 负责最终授权和管理逻辑。
- Cell、调度器、身份提供方及统计事实来源通过批准的版本化接口/事件对接,不跨库 JOIN 或修改外部业务表。独立开发/测试使用项目内协议 Mock、自有隔离持久数据库,MQ 集成测试按需配置隔离真实 broker;不以云资源、真实拨号或其它业务测试栈为启动前置。
- 独立部署不取消真实发布安全屏障:外部系统缺失时仍可启动/管理草稿,状态和统计标 unknown/不完整;真实发布/停用需外部确认时必须阻塞或待对账。旧 Python 与新 Go 管理端不得同时写同一资源。不得新增 HTTP 拨号或业务结果回调。
- 契约从 `docs/` 唯一来源生成/同步版本化产物并校验,不维护重复手写 schema;管理平台打包后无需读取其它项目目录。管理功能验收与真实 SIP/其它业务验收分开报告;详见 `docs/SIP管理后台_接口与页面设计及验收_v1.0.md`
## 最终实施与 Mock 边界(用户已确认)
- 以最终开发部署监控与验收计划为实施入口,`docs/SaaS交互_OpenAPI与MQ契约规划_v0.1.md` 正文 v1.0 是字段/路径/状态的唯一维护来源;从 D01 生成 OpenAPI/共用 MQ Schema 开始,不重复等待已接受方案拍板。
@@ -19,23 +29,28 @@
1. 区域为阿里云北京 `cn-beijing`
2. 我方对 SIP 服务商登记的公网出口白名单 IP 是 **123.56.71.98**。它不是 SIP 服务端地址,不得填入 trunk contact。
3. 目标计算资源为阿里云竞价 ECS。必须先使用阿里云 CLI 查询 IP/EIP 与现有实例;确认没有可复用主机后,才允许按明确的实例规格、价格上限、磁盘/VSwitch/安全组/SSH KeyPair 创建。
3. 目标计算资源为阿里云竞价 ECS。必须先使用阿里云 CLI 查询 IP/EIP 与现有实例;确认没有可复用主机后,才允许按明确的实例规格、竞价策略(限价策略需提供价格上限、磁盘/VSwitch/安全组/SSH KeyPair 创建。
4. 固定 IP 是否为本账号可操作的 EIP 必须查询。找不到该 EIP/现有公网 IP 时停止,**禁止新分配任意公网 IP 冒充白名单地址**。若属于非 EIP 的实例公网 IP,只能复用经明确指定的原实例;回收后的地址保留不能保证。
5. 不解绑或覆盖其他实例上的 EIP,不删除、停机或修改无关实例;多个候选实例、未知归属或未授权绑定一律停止。
6. 工具默认只读计划;`--apply` 才执行创建/绑定。用户未确认预算、云凭据未配置、现有绑定不明时,不能实际消费或改绑。竞价价格上限不包含系统盘/EIP/流量费用
6. 工具默认只读计划;`--apply` 才执行创建/绑定。用户未明确授权、云凭据未配置、现有绑定不明时,不能实际消费或改绑。竞价策略可使用 `SpotWithPriceLimit`(必须提供正的价格上限)或 `SpotAsPriceGo`(无价格上限;仅在用户已明确确认自动出价时允许)。价格上限(如使用)不包含系统盘/EIP/流量费用;`SpotAsPriceGo` 不提供价格保护
7. 创建使用持久化 ClientToken;创建后绑定失败保留实例ID供恢复,不自动再创建一台、不自动删除实例。竞价回收不等于自动迁移活动通话。
8. 使用 `aliyun` CLI 创建竞价 ECS 进行测试时,测试完成必须先询问用户是否清理。未获得明确清理指令时,保留唯一 `project=agent-call` 可用/运行实例,不自动删除或并行创建第二台;用户明确要求清理后,只停止并删除本次创建的实例,不删除、释放或解绑指定 IP/EIP。
9. 不把 AK/Secret、ASR Key、ARI 密码、SSH 私钥放入代码、文档、日志或聊天。优先配置 CLI RAM Role/STS,运行时通过环境或受控文件注入。
10. 创建主机时必须开通 TCP 22 供后续 SSH 运维;不要求按用户当前源 IP 限制,但不得因此放宽其他端口。创建后必须建立用户 `rogee`,禁止 root 直接 SSH 登录,并在系统级关闭 root 和普通用户的密码/键盘交互登录(`PasswordAuthentication no``KbdInteractiveAuthentication no``PermitRootLogin no`)。仅安装以下用户提供的 rogee 公钥,后续命令必须通过 SSH 以 `rogee` 用户执行,需要特权时使用受控 `sudo`
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPoGJTH1JrXbWu+ZhIgaHMhume0oWQiWmadhTwbk75Ny rogee@ipao.vip
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPoGJTH1JrXbWu+ZhIgaHMhume0oWQiWmadhTwbk75Ny <rogee@ipao.vip>
不得把对应私钥写入代码、文档、日志或聊天;使用受控本地私钥/SSH agent。不得用密码登录或以 root 作为后续常规命令入口。
11. 主机操作系统固定优先使用 Debian 13Trixieminimal;该项目无 Ubuntu 专属依赖,Docker/Asterisk/Go/Python 均按 Debian 13 构建和验收。仅当北京区域无可用 Debian 13 镜像时,才使用 Ubuntu 24.04 LTS;创建前必须查询实际可用镜像,不得擅自切换到非 LTS Ubuntu。
12. 当前第二组 SIP`bjmkkj`)恢复基线固定使用系统盘快照 `s-2ze28ulhip70y5vsbnf8`(源系统盘 `d-2ze8yfac91hmae1vx2lz`40 GiB ESSD PL1)。使用前必须查询快照状态为 `accomplished`;不得删除或覆盖该快照。原镜像 `m-2ze0rw9430vfv6ifym8m` 不删除;使用前必须查询状态为 `Available`,但若创建接口拒绝该镜像,不得重复盲试。
13. 2026-09-16 竞价回收前的当前系统盘备份为快照 `s-2zeaj3c0izs18cdcafe7`,状态须为 `accomplished`;由该备份制作的恢复镜像为 `m-2ze9kli7ztwzzqofsyb6`,状态须为 `Available` 后才可用于恢复远程修改继续定位。
## SIP 接入信息
以下为用户提供的首组 SIP 参数,供应商名称待补充;已记录不代表已完成真实线路验证。
以下为用户提供的 SIP 参数,供应商名称待补充;已记录不代表已完成真实线路验证。
### 第一组
| 项目 | 参数 |
| --- | --- |
@@ -44,9 +59,29 @@
| 被叫前缀 | `7089` |
| 我方出口白名单 IP | `123.56.71.98` |
### 第二组(新增)
| 项目 | 参数 |
| --- | --- |
| SIP 服务端 | `60.171.24.90:5060` |
| 主叫号码/标识 | `mbkq` |
| 被叫前缀 | 无 |
| 测试被叫号码 | `18625770806` |
| 我方出口白名单 IP | `123.56.71.98`(沿用项目固定出口,仍需线路侧核验) |
### 第三组(新增)
| 项目 | 参数 |
| --- | --- |
| SIP 服务端 | `160.202.254.79:5060` |
| 主叫号码/标识 | `KQ91526` |
| 被叫前缀 | `mka755` |
| 测试被叫号码 | `18625770806` |
| 我方出口白名单 IP | `123.56.71.98`(沿用项目固定出口,仍需线路侧核验) |
## 测试呼叫号码
- 用户指定测试被叫号码:`15003164745`。仅用于已授权的 Mock/明确安排的测试;不得因写入此处而自动发起真实呼叫,原始号码保持不变。
- 用户指定测试被叫号码:`15003164745`(第一组)、`18625770806`(第二组)。仅用于已授权的 Mock/明确安排的测试;不得因写入此处而自动发起真实呼叫,原始号码保持不变。
- 主叫标识保留原值(包括 `BD`),不能按纯数字手机号清洗,也不能直接当成 Digest 认证用户名;具体 From/PAI 等字段映射仍需确认。
- 业务原始被叫号码保持不变;使用该线路时按其规则构造 `7089<被叫号码>`,避免重复添加或把该前缀带到其他供应商线路。
@@ -101,6 +136,7 @@
## 验证与交付
- 管理平台独立验证:在 `management/backend/` 的 Go 模块执行 `go test -race ./...``go vet ./...` 与格式化检查;前端按自身锁文件及实际 package scripts 检查/测试/构建,入口记录于 `management/README.md`。以下其它项目测试不作为管理平台默认前置,仅改动对应项目时执行。
- Python`python3 -m unittest discover -s tests -v`
- Go`cd services/asr-web && go test -race ./...`;改动后执行格式化与静态检查。
- PCM`node --test tests/test_pcm.cjs`Shell`bash -n deploy/asterisk.sh`
+195 -43
View File
@@ -11,9 +11,11 @@ from __future__ import annotations
import base64
import contextlib
import http.client
import ipaddress
import json
import os
import queue
import re
import socket
import sqlite3
import ssl
@@ -56,6 +58,7 @@ class CellCallConfig:
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"
@@ -80,6 +83,13 @@ class CellCallConfig:
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"
@@ -106,6 +116,7 @@ class CellCallConfig:
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"),
@@ -235,6 +246,7 @@ class RTPMedia:
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
@@ -258,7 +270,7 @@ class RTPMedia:
self.socket.settimeout(max(0.0, timeout_s))
try:
packet, peer = self.socket.recvfrom(2048)
except (TimeoutError, ConnectionRefusedError):
except (BlockingIOError, TimeoutError, ConnectionRefusedError):
return None
except OSError as exc:
if self.socket.fileno() < 0:
@@ -266,15 +278,26 @@ class RTPMedia:
raise CellCallError(
"RTP_RECEIVE_FAILED", "RTP receive failed", True
) from exc
payload = self._payload(packet)
if payload is None:
parsed = self._packet_info(packet)
if parsed is None:
return None
payload, payload_type, ssrc = parsed
if payload_type != 8:
return None
try:
self.peer = (str(peer[0]), int(peer[1]))
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:
@@ -282,7 +305,7 @@ class RTPMedia:
return 0
if self.peer is None:
raise CellCallError(
"RTP_PEER_MISSING", "no inbound RTP peer has been observed"
"RTP_PEER_MISSING", "no Asterisk externalMedia RTP peer is configured"
)
payload = pcm16_to_alaw(raw, input_rate_hz, 8000)
sent = 0
@@ -312,9 +335,11 @@ class RTPMedia:
return sent
@staticmethod
def _payload(packet: bytes) -> bytes | None:
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:
@@ -332,7 +357,13 @@ class RTPMedia:
if not padding or padding > end - offset:
return None
end -= padding
return packet[offset:end] or None
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:
@@ -462,16 +493,17 @@ class RealCellCall:
},
)
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/{'7089' + callee}@{self.config.trunk_id}",
"endpoint": f"PJSIP/{self.config.dial_prefix + callee}@{self.config.trunk_id}",
"app": self.config.ari_app,
"appArgs": self.call_id,
"callerId": self.config.caller_id,
"timeout": 30000,
"timeout": 30,
"channelId": f"target_{self.call_id}",
},
)
@@ -615,30 +647,64 @@ class RealCellCall:
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:
continue
event_type = event.get("type")
channel = event.get("channel") or {}
channel_id = channel.get("id")
if event_type == "StasisStart" and isinstance(channel_id, str):
self._add_when_ready(channel_id)
if (
event_type == "ChannelStateChange"
and channel_id == self.target_channel_id
and channel.get("state") == "Up"
):
return
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"
)
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
)
@@ -724,16 +790,89 @@ class RealCellCall:
self._out_bytes += bytes_sent
return sent
def _add_when_ready(self, channel_id: str | None) -> None:
if not channel_id or channel_id in self._known_channels:
return
self._known_channels.add(channel_id)
with contextlib.suppress(CellCallError):
self.ari.request(
"POST",
f"bridges/{self.bridge_id}/addChannel",
{"channel": channel_id},
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:
@@ -745,12 +884,25 @@ class RealCellCall:
return channel_id
def _finish_recording(self) -> str | None:
with contextlib.suppress(CellCallError):
self.ari.request("DELETE", f"recordings/live/{self.recording_name}")
try:
raw = self.ari.request(
"GET", f"recordings/stored/{self.recording_name}/file"
)
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:
@@ -0,0 +1,83 @@
{
"record_version": "1.0",
"recorded_at_utc": "2026-09-17T05:32:21Z",
"mode": "real",
"scope": "post-security-group-expansion and Cell media/recording code-fix validation",
"deployment": {
"region": "cn-beijing",
"instance_id": "i-2zeimnh6hq53q4srr3o2",
"instance_status": "Running",
"spot_strategy": "SpotAsPriceGo",
"public_ip": "123.56.71.98",
"eip_allocation_id": "eip-2zeevfsaxzwuue2szy7xb",
"asterisk_container": "agent-call-asterisk"
},
"security_group": {
"diagnostic_rules": [
{
"source_cidr": "160.202.254.79/32",
"protocol": "UDP",
"port_range": "1/65535",
"rule_id": "sgr-2ze0bq4elp873ybfmphi",
"request_id": "01A0ADD1-F69F-54B6-A806-D3CF2E6FFA78"
},
{
"source_cidr": "111.9.126.254/32",
"protocol": "UDP",
"port_range": "1/65535",
"rule_id": "sgr-2ze90x5nqxa788u8tc2g",
"request_id": "01A0ADD1-F7AC-5DDE-86A8-F135B9F3B344"
}
],
"scope": "provider signaling/media IPs only; not 0.0.0.0/0"
},
"code_fixes_under_test": [
"Cell seeds RTP send peer from Asterisk UNICASTRTP_LOCAL_ADDRESS/PORT instead of requiring a first inbound packet",
"ARI live recordings are stopped with POST /recordings/live/{name}/stop before reading the stored file",
"mka755 is accepted as an ASCII alphanumeric dial prefix"
],
"call": {
"original_number": "18625770806",
"callee_prefix": "mka755",
"effective_sip_user": "mka75518625770806",
"trunk": "160.202.254.79:5060",
"caller_id": "KQ91526",
"attempts": 1,
"retries": 0,
"execution_id": "exec_cell_1789623117192788072_1",
"call_id": "call_a7224f59f1fb400ebcc51b8975e7fbf3",
"connected": true,
"status": "failed",
"reason_code": "CUSTOMER_HANGUP",
"duration_ms": 27200.655292000192,
"turns": [],
"media_packets_in": 0,
"media_packets_out": 435,
"recording": "no valid stored recording reported",
"tcpdump": "not captured; tcpdump exists but sudo policy denies capture command"
},
"assessment": {
"our_previous_rtp_peer_bug": "fixed enough to send 435 outbound RTP packets to Asterisk without waiting for inbound RTP",
"provider_to_cell_audio": "still not proven; Cell received zero inbound RTP packets even after provider/media-source full-UDP rules",
"remaining_boundary": "without a packet capture on the host/Asterisk side, cannot distinguish provider not sending RTP from Asterisk not forwarding provider RTP into externalMedia",
"llm_conversation": "not started; zero turns because no inbound customer audio was captured",
"overall": "FAIL_RTP_INBOUND_STILL_ZERO"
},
"cleanup": {
"active_channels_after_call": 0,
"temporary_cell_container_removed": true,
"temporary_rabbitmq_removed": true,
"temporary_remote_credentials_removed": true,
"ecs_and_eip_retained": true
},
"raw_report": {
"path": ".local/agent-call-third/llm-call-report-codefix-20260917.json",
"sha256": "2b6d1f56f98ebf2ba40ebdf2740c9d7daa5ad4ddc12074d582248934735c027d",
"mode": "0600"
},
"notes": [
"This was one newly authorized post-fix attempt; no automatic redial was performed.",
"The expanded source-limited UDP rules did not produce inbound RTP at the Cell.",
"The next diagnostic step is an explicitly authorized call with host/Asterisk packet capture or provider-side RTP packet evidence; do not claim provider fault solely from the Cell counter."
]
}
@@ -0,0 +1,81 @@
{
"record_version": "1.0",
"recorded_at_utc": "2026-09-17T06:07:42Z",
"mode": "real",
"scope": "post externalMedia/bridge/RTP/recording code fixes",
"deployment": {
"region": "cn-beijing",
"instance_id": "i-2zeimnh6hq53q4srr3o2",
"instance_status": "Running",
"spot_strategy": "SpotAsPriceGo",
"public_ip": "123.56.71.98",
"eip_allocation_id": "eip-2zeevfsaxzwuue2szy7xb",
"asterisk_container": "agent-call-asterisk"
},
"code_fixes_included": [
"externalMedia peer lookup tolerates the pre-Stasis 404 and retries from StasisStart",
"bridge addChannel only marks success after the API succeeds, with bounded 400/404/409/422 retry",
"answer is accepted only after target and externalMedia are both present in the expected bridge",
"RTP receive validates PCMA payload type, expected peer, and stable SSRC",
"ARI originate timeout uses seconds",
"recording stop/readback uses POST stop and bounded stored-file polling",
"mka755 alphanumeric dial-prefix validation"
],
"security_group": {
"source_limited_full_udp": [
"160.202.254.79/32",
"111.9.126.254/32"
],
"port_range": "1/65535",
"global_udp_open": false
},
"call": {
"original_number": "18625770806",
"callee_prefix": "mka755",
"effective_sip_user": "mka75518625770806",
"trunk": "160.202.254.79:5060",
"caller_id": "KQ91526",
"attempts": 1,
"retries": 0,
"execution_id": "exec_cell_1789625261537310100_1",
"call_id": "call_17c1bc8f75e3409e8e654d0498847e83",
"connected": false,
"status": "failed",
"reason_code": "CALL_NOT_ANSWERED",
"duration_ms": 601.2159009987954,
"turns": [],
"media_packets_in": 0,
"media_packets_out": 0,
"recording": {
"bytes": 44,
"sha256": "4f8734c5e13ac599e168cf247a51c1dd0758537ce00bf16d7fed1a3d14d07041",
"valid_wav": false
}
},
"asterisk_observation": {
"external_media_channel": "joined bridge",
"target_action": "Called mka75518625770806@provider-primary",
"termination": "Message ended by control; target answer/SIP final response was not captured",
"active_channels_after_test": 0,
"raw_log": ".local/agent-call-third/llm-call-fix2-asterisk.log",
"raw_log_sha256": "71bf15f74a239eb2753da1522a923ec3fae716b87522f553ed4f76ebdca60330"
},
"diagnosis": {
"our_code": "bridge/externalMedia readiness races are now guarded; this attempt did not reach the answer/media stage",
"provider_or_asterisk_boundary": "undetermined for this attempt because PJSIP logger/packet capture was unavailable",
"llm_audio": "not started; zero turns",
"overall": "FAIL_CALL_NOT_ANSWERED_NEEDS_SIP_TRACE"
},
"cleanup": {
"temporary_cell_removed": true,
"temporary_rabbitmq_removed": true,
"temporary_credentials_removed": true,
"ecs_and_eip_retained": true
},
"runner_report": {
"path": ".local/agent-call-third/llm-call-fix2-report-20260917.json",
"sha256": "7a5974fc94fe1277ecb0c0af93e9efa534e8160c6adb866cf045ed511956f217",
"mode": "0600"
},
"next_step": "One separately authorized diagnostic call with PJSIP logger and a working host packet capture is required to distinguish provider no-answer/no-RTP from Asterisk forwarding failure; no automatic redial was performed."
}
@@ -0,0 +1,71 @@
{
"record_version": "1.0",
"recorded_at_utc": "2026-09-17T04:34:08Z",
"mode": "real",
"authorization": "用户明确要求带 LLM 音频自动对话功能呼出测试",
"deployment": {
"region": "cn-beijing",
"instance_id": "i-2zeimnh6hq53q4srr3o2",
"instance_status": "Running",
"spot_strategy": "SpotAsPriceGo",
"public_ip": "123.56.71.98",
"eip_allocation_id": "eip-2zeevfsaxzwuue2szy7xb",
"asterisk_container": "agent-call-asterisk",
"cell_runtime": "agent-call-real-cell:bjmkkj-20260916 with patched alphanumeric dial-prefix validation",
"broker": "RabbitMQ 4.1 management alpine, isolated temporary test container"
},
"trunk": {
"server": "160.202.254.79:5060",
"endpoint": "provider-primary",
"caller_id": "KQ91526",
"callee_prefix": "mka755",
"codec": "PCMA"
},
"call": {
"original_number": "18625770806",
"effective_sip_user": "mka75518625770806",
"attempts": 1,
"retries": 0,
"execution_id": "exec_cell_1789619650651950061_1",
"call_id": "call_6017aab0bd8d4cf698d685de43563022",
"connected": true,
"status": "failed",
"reason_code": "RTP_PEER_MISSING",
"duration_ms": 18987.630626999817,
"turns": [],
"media_packets_in": 0,
"media_packets_out": 0,
"recording": {
"bytes": 44,
"sha256": "4f8734c5e13ac599e168cf247a51c1dd0758537ce00bf16d7fed1a3d14d07041",
"channels": 1,
"sample_rate_hz": 8000,
"frames": 0,
"valid_wav": false
}
},
"validation": {
"sip_signaling": "connected=true in Cell result",
"llm_conversation": "not_started; zero turns because RTP peer was not learned",
"audio_media": "FAIL; zero RTP packets in/out",
"recording": "FAIL; 44-byte zero-frame WAV",
"overall": "BLOCKED_BY_RTP_PEER_MISSING"
},
"cleanup": {
"active_channels_after_call": 0,
"temporary_cell_container_removed": true,
"temporary_rabbitmq_removed": true,
"temporary_remote_credentials_removed": true,
"ecs_and_eip_retained": true
},
"raw_report": {
"path": ".local/agent-call-third/llm-call-report-20260917.json",
"sha256": "fbc6114e4f9ed3a64bcb28a6b1a959b23f01575ba3c6e76e687729b379781df9",
"mode": "0600"
},
"notes": [
"The call was initiated once through the tenant RabbitMQ command path and the real Cell worker; no automatic redial was performed.",
"SIP connected, but no RTP packet arrived from the provider, so the LLM/TTS/ASR conversation could not start.",
"The missing RTP is consistent with the previously observed provider-side SDP/RTP reachability issue and requires provider/media-path investigation before another real call."
]
}
@@ -0,0 +1,62 @@
{
"record_version": "1.0",
"recorded_at_utc": "2026-09-17T04:02:46Z",
"authorization": "用户明确授权创建 SpotAsPriceGo 实例并测试一次外呼",
"deployment": {
"region": "cn-beijing",
"instance_id": "i-2zeimnh6hq53q4srr3o2",
"instance_status": "Running",
"charge_type": "PostPaid",
"spot_strategy": "SpotAsPriceGo",
"public_ip": "123.56.71.98",
"ip_kind": "eip",
"eip_allocation_id": "eip-2zeevfsaxzwuue2szy7xb",
"asterisk_image_digest": "git.ipao.vip/rogee/asterisk-real@sha256:0f5e3cd0e9a86bc9dc4750929abee9ba1faae11ed39739f3e75343d39d837e41"
},
"trunk": {
"server": "160.202.254.79:5060",
"auth_mode": "ip",
"caller_id_configured": "KQ91526",
"codec_configured": "PCMA",
"endpoint": "provider-primary",
"endpoint_contact_status": "Avail"
},
"call": {
"original_number": "18625770806",
"callee_prefix": "mka755",
"effective_sip_user": "mka75518625770806",
"attempts": 1,
"retries": 0,
"started_at_utc": "2026-09-17T04:02:15Z",
"answered_at_utc": "2026-09-17T04:02:29Z",
"ended_at_utc": "2026-09-17T04:02:46Z",
"call_id": "b22e4055-0e12-4d59-9cd2-cb343aece9b1",
"originate_rc": 0,
"sip_responses": [
{"code": 100, "phrase": "Trying"},
{"code": 180, "phrase": "Ringing"},
{"code": 183, "phrase": "Session Progress"},
{"code": 200, "phrase": "OK"}
],
"sip_result": "answered",
"local_sdp": {"address": "123.56.71.98", "rtp_port": 10542, "codec": "PCMA/8000"},
"remote_sdp": {"address": "111.9.126.254", "rtp_port": 14248, "codec": "PCMA/8000"},
"caller_identity_observed": "From display=Anonymous; URI user=KQ91526; no P-Asserted-Identity observed",
"caller_identity_validation": "partial",
"media_validation": "not_performed",
"final_active_channels": 0
},
"result": "SIP signaling answered; caller presentation and RTP/audio not fully validated",
"raw_log": {
"path": ".local/agent-call-third/call-20260917-040214-raw.txt",
"sha256": "6ea3246b2a5a04f4914ea44f51ad3b0799ec72e7964f6b9d72d3aa3499d9b329",
"mode": "0600"
},
"notes": [
"The single test sent INVITE sip:mka75518625770806@160.202.254.79:5060 and received 100/180/183/200.",
"The call was originated directly as a PJSIP channel; the observed From display was Anonymous even though the URI user remained KQ91526.",
"No automatic redial was performed.",
"The Spot instance and EIP were retained; cleanup was not requested.",
"SIP 200 OK is not proof of two-way RTP, valid recording, or full AI call acceptance."
]
}
@@ -0,0 +1,72 @@
{
"record_version": "1.0",
"recorded_at_utc": "2026-09-17T04:45:00Z",
"scope": "third SIP provider RTP_PEER_MISSING diagnosis",
"conclusion": "我方安全组存在明确配置缺口;已修复,但尚未用新的真实外呼验证供应商是否实际发送 RTP",
"evidence": {
"failed_llm_call": {
"report": "docs/evidence/third-sip-llm-outbound-20260917.json",
"call_id": "call_6017aab0bd8d4cf698d685de43563022",
"connected": true,
"reason_code": "RTP_PEER_MISSING",
"media_packets_in": 0,
"media_packets_out": 0,
"turns": [],
"recording_bytes": 44,
"recording_valid": false
},
"provider_sdp": {
"sip_server": "160.202.254.79:5060",
"invite_uri": "sip:mka75518625770806@160.202.254.79:5060",
"remote_media_address": "111.9.126.254",
"remote_media_port": 14248,
"codec": "PCMA/8000",
"sip_result": "100 Trying, 180 Ringing, 183 Session Progress, 200 OK",
"raw_log": ".local/agent-call-third/call-20260917-040214-raw.txt",
"raw_log_sha256": "6ea3246b2a5a04f4914ea44f51ad3b0799ec72e7964f6b9d72d3aa3499d9b329"
},
"our_asterisk": {
"container_network": "host",
"external_signaling_address": "123.56.71.98",
"external_media_address": "123.56.71.98",
"direct_media": false,
"rtp_port_range": "10000/10800",
"local_sdp_example": "123.56.71.98:10542",
"host_firewall_matching_rules": "none observed in nft query",
"active_channels_after_test": 0
},
"security_group_before_fix": {
"rtp_sources_present": [
"61.132.228.221/32",
"60.171.24.90/32",
"160.202.254.79/32"
],
"missing_source": "111.9.126.254/32",
"destination_port_range": "UDP 10000/10800"
}
},
"root_cause_assessment": {
"our_side": "confirmed configuration gap: the provider's advertised media IP was not allowed into the RTP port range",
"provider_side": "not yet determinable: the provider may still be sending no RTP, using another source IP, or advertising an unreachable SDP address",
"why_rtp_peer_missing": "Cell learns its RTP peer only from an inbound RTP packet; the pre-fix security group could drop packets from 111.9.126.254 before Asterisk/Cell received them"
},
"remediation": {
"action": "AuthorizeSecurityGroup",
"region": "cn-beijing",
"security_group_id": "sg-2zed6d5vmcvojt7zek4r",
"rule_id": "sgr-2zehipdrkv23gjpnun8n",
"protocol": "UDP",
"port_range": "10000/10800",
"source_cidr": "111.9.126.254/32",
"description": "agent-call third SIP SDP RTP media source",
"request_id": "01A0ADBF-329B-570A-A36A-36C144EF4999",
"status": "applied_and_verified"
},
"next_gate": {
"required": "one separately authorized real outbound call after this rule change",
"no_automatic_retry": true,
"pass_condition": "RTP packets arrive, Cell learns a peer, at least one valid AI turn/recording is produced",
"provider_fault_if": "post-fix capture still shows no RTP from the provider or the advertised SDP media address is unreachable",
"our_fault_if": "post-fix packets reach the host/Asterisk but are not forwarded to externalMedia or Cell"
}
}
@@ -123,6 +123,13 @@ python3 deploy/sip_probe.py --number 18601013734
脚本会自动构造 `7089` + 原始号码、开启 SIP logger、发起一次外呼并提取首个最终 SIP 响应;`--wait-seconds 3` 可缩短等待,`--output` 可指定证据文件。`Avail`/OPTIONS 只证明探活,不证明外呼接通。
### 4.1 SIP 与 RTP 白名单、端口范围
- SIP 信令地址与 SDP 中的媒体地址可能不同;安全组必须分别核对 `5060` 信令来源和 SDP `c=` 媒体来源,不能只放行 SIP 服务端 IP。第三组曾从 `160.202.254.79` 建立信令,但 SDP 宣告媒体源为 `111.9.126.254`,遗漏后会出现 SIP `200 OK``RTP_PEER_MISSING`
- `1000010800` 不是 SIP/RTP 协议固定范围,而是本项目单 Cell 测试基线;远端 SDP 的 RTP 端口(例如 `14248`)是对端端口,不应直接替换我方本地端口范围。扩容必须按端口分配、RTCP、externalMedia、文件描述符和带宽实测后版本化调整。
- 为排除测试期端口范围因素,可以在明确授权下仅对已登记的供应商信令/媒体 IP 临时放开 UDP 全端口;不得对 `0.0.0.0/0` 全 UDP 放行,规则与授权必须留证,测试结束后回收或收窄。
- 当前第三组诊断例外为 `160.202.254.79/32` 和已观测媒体源 `111.9.126.254/32` 入站 UDP `1/65535`;这是用户授权的测试配置,不是生产安全基线。
## 5. 更新与回收
- 配置变更:生成新的 UserData/配置目录,核对后再重启容器;不要覆盖正在使用的配置目录。
+4
View File
@@ -224,6 +224,10 @@ python3 deploy/render_asterisk.py --config .local/asterisk.json
确认:SIP服务端IP/协议/端口、RTP回程、EIP/NAT、实际VPC网段、管理来源、录音卷目录和权限。安全组只按来源和用途开放;不公开裸ARI,不清空既有防火墙。
2026-09-17 第三组真实测试发现:SIP 信令来自 `160.202.254.79`,但 SDP 宣告 RTP 媒体源为 `111.9.126.254`;只允许信令 IP 会导致 SIP `200 OK` 但 Cell 收不到 RTP。当前测试例外已对这两个已登记 IP 放行 UDP `1/65535`,未对公网全量放行;完成 RTP/LLM 验收后应收窄为实际媒体端口及供应商媒体源 CIDR。`1000010800` 只是单 Cell 测试基线,不是 SIP/RTP 协议固定范围,扩大范围前需同步 Asterisk `rtp.conf`、安全组、端口/FD/带宽容量测算。
真实 Cell 的 `externalMedia` 通道创建后,变量可能要等 `StasisStart` 才可查询;必须在通道就绪后重试读取 `UNICASTRTP_LOCAL_ADDRESS``UNICASTRTP_LOCAL_PORT`,以此建立向 Asterisk 的 RTP 发送目标。`addChannel` 在通道未进入 Stasis 时的 `400/404/409/422` 只能有限重试,成功后才能标记已入桥;收到目标 `Up` 后还必须通过 `GET /bridges/{id}` 确认 externalMedia 与目标都在同一桥内,不能只看 `Up` 就开始 AI。RTP 接收需校验来源 peer、PCMA payload type 和 SSRC。ARI originate 的 `timeout` 单位是秒,不是毫秒。实时录音必须先 `POST /recordings/live/{name}/stop`,再轮询读取 stored file,不能用 DELETE 丢弃录音后伪装成有效结果。
```bash
# 默认为检查,不启动容器。
bash deploy/asterisk.sh
+164 -2
View File
@@ -6,7 +6,7 @@ import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
from typing import cast
from typing import Any, cast
from agent_call.real_cell import (
CellCallConfig,
@@ -75,11 +75,173 @@ class RealCellTests(unittest.TestCase):
self.assertEqual(RTPMedia._payload(packet), payload)
self.assertIsNone(RTPMedia._payload(b"bad"))
def test_cell_config_rejects_non_pcma_and_bad_port(self) -> None:
def test_nonblocking_receive_returns_no_packet(self) -> None:
media = RTPMedia("127.0.0.1", 0)
try:
self.assertIsNone(media.receive(0.0))
finally:
media.close()
def test_rtp_receive_filters_payload_peer_and_ssrc(self) -> None:
media = RTPMedia("127.0.0.1", 0)
sender = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sender.bind(("127.0.0.1", 0))
media.peer = ("127.0.0.1", sender.getsockname()[1])
def packet(payload_type: int, ssrc: int, payload: bytes) -> bytes:
return struct.pack("!BBHII", 0x80, payload_type, 1, 0, ssrc) + payload
try:
sender.sendto(packet(0, 1, b"wrong-pt"), media.address)
self.assertIsNone(media.receive(0.2))
sender.sendto(packet(8, 1, b"voice"), media.address)
self.assertEqual(media.receive(0.2), b"voice")
sender.sendto(packet(8, 2, b"wrong-ssrc"), media.address)
self.assertIsNone(media.receive(0.2))
finally:
sender.close()
media.close()
def test_external_media_peer_comes_from_ari(self) -> None:
config = CellCallConfig("http://127.0.0.1:8088", "u", "p")
call = cast(
Any,
RealCellCall(
config,
cast(Any, SimpleNamespace(config={})),
cast(Any, SimpleNamespace()),
),
)
media = RTPMedia("127.0.0.1", 0)
calls: list[tuple[str, str, dict | None]] = []
class FakeARI:
def request(self, method: str, resource: str, params=None, **_kwargs):
calls.append((method, resource, params))
if params and params.get("variable") == "UNICASTRTP_LOCAL_ADDRESS":
return {"value": "127.0.0.1"}
return {"value": "12345"}
call.media = media
call.ari = FakeARI()
try:
call._set_external_media_peer("external-1")
self.assertEqual(media.peer, ("127.0.0.1", 12345))
self.assertEqual(len(calls), 2)
finally:
media.close()
def test_add_when_ready_marks_only_after_success(self) -> None:
config = CellCallConfig("http://127.0.0.1:8088", "u", "p")
call = cast(
Any,
RealCellCall(
config,
cast(Any, SimpleNamespace(config={})),
cast(Any, SimpleNamespace()),
),
)
class FakeARI:
def __init__(self) -> None:
self.attempts = 0
def request(self, method: str, _resource: str, _params=None, **_kwargs):
if method == "POST":
self.attempts += 1
if self.attempts == 1:
raise CellCallError("ARI_HTTP_422", "not in Stasis", True)
return {}
fake_ari = FakeARI()
call.ari = fake_ari
self.assertTrue(call._add_when_ready("target"))
self.assertEqual(fake_ari.attempts, 2)
self.assertIn("target", call._known_channels)
def test_wait_answer_requires_bridge_membership(self) -> None:
config = CellCallConfig("http://127.0.0.1:8088", "u", "p", event_timeout_s=1.0)
call = cast(
Any,
RealCellCall(
config,
cast(Any, SimpleNamespace(config={})),
cast(Any, SimpleNamespace()),
),
)
call.target_channel_id = "target"
call.external_channel_id = "external"
call.media = RTPMedia("127.0.0.1", 0)
call.media.peer = ("127.0.0.1", 12345)
bridge_members = [{"external"}, {"external", "target"}]
class FakeARI:
def request(self, method: str, resource: str, _params=None, **_kwargs):
if method == "GET" and resource == f"bridges/{call.bridge_id}":
return {"channels": list(bridge_members.pop(0))}
return {}
call.ari = FakeARI()
call.events.put(
{"type": "ChannelStateChange", "channel": {"id": "target", "state": "Up"}}
)
call.events.put({"type": "StasisStart", "channel": {"id": "target"}})
try:
call._wait_answer(SimpleNamespace())
self.assertEqual(bridge_members, [])
finally:
call.media.close()
def test_finish_recording_stops_then_reads_stored_file(self) -> None:
class FakeARI:
def __init__(self) -> None:
self.calls: list[tuple[str, str]] = []
def request(self, method: str, resource: str, **_kwargs):
self.calls.append((method, resource))
return {} if method == "POST" else b"RIFFtest"
with tempfile.TemporaryDirectory() as directory:
config = CellCallConfig(
"http://127.0.0.1:8088", "u", "p", recording_dir=directory
)
call = cast(
Any,
RealCellCall(
config,
cast(Any, SimpleNamespace(config={})),
cast(Any, SimpleNamespace()),
),
)
fake_ari = FakeARI()
call.ari = fake_ari
path = call._finish_recording()
self.assertIsNotNone(path)
self.assertEqual(fake_ari.calls[0][0], "POST")
self.assertIn("/stop", fake_ari.calls[0][1])
self.assertNotIn(
("DELETE", fake_ari.calls[0][1].rsplit("/stop", 1)[0]), fake_ari.calls
)
def test_cell_config_rejects_non_pcma_bad_port_and_prefix(self) -> None:
with self.assertRaises(CellCallError):
CellCallConfig("http://127.0.0.1:8088", "u", "p", rtp_format="ulaw")
with self.assertRaises(CellCallError):
CellCallConfig("http://127.0.0.1:8088", "u", "p", rtp_bind_port=65536)
with self.assertRaises(CellCallError):
CellCallConfig("http://127.0.0.1:8088", "u", "p", dial_prefix="7089+")
self.assertEqual(
CellCallConfig(
"http://127.0.0.1:8088", "u", "p", dial_prefix=""
).dial_prefix,
"",
)
self.assertEqual(
CellCallConfig(
"http://127.0.0.1:8088", "u", "p", dial_prefix="mka755"
).dial_prefix,
"mka755",
)
def test_ledger_marks_in_progress_as_in_doubt(self) -> None:
with tempfile.TemporaryDirectory() as directory: