fix real cell RTP bridge readiness
This commit is contained in:
@@ -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 13(Trixie)minimal;该项目无 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
@@ -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`。
|
||||
- `10000–10800` 不是 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/配置目录,核对后再重启容器;不要覆盖正在使用的配置目录。
|
||||
|
||||
@@ -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。`10000–10800` 只是单 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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user