diff --git a/AGENTS.md b/AGENTS.md index 35d1848..129bbbe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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/`。 +- 前端固定使用 作为管理系统基座,实施时锁定上游版本和依赖并核验/保留许可证声明;后端 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 不得把对应私钥写入代码、文档、日志或聊天;使用受控本地私钥/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`。 diff --git a/agent_call/real_cell.py b/agent_call/real_cell.py index 307bb2d..72bcdf2 100644 --- a/agent_call/real_cell.py +++ b/agent_call/real_cell.py @@ -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: diff --git a/docs/evidence/third-sip-llm-codefix-outbound-20260917.json b/docs/evidence/third-sip-llm-codefix-outbound-20260917.json new file mode 100644 index 0000000..3ce72c1 --- /dev/null +++ b/docs/evidence/third-sip-llm-codefix-outbound-20260917.json @@ -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." + ] +} diff --git a/docs/evidence/third-sip-llm-fix2-outbound-20260917.json b/docs/evidence/third-sip-llm-fix2-outbound-20260917.json new file mode 100644 index 0000000..8c7a950 --- /dev/null +++ b/docs/evidence/third-sip-llm-fix2-outbound-20260917.json @@ -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." +} diff --git a/docs/evidence/third-sip-llm-outbound-20260917.json b/docs/evidence/third-sip-llm-outbound-20260917.json new file mode 100644 index 0000000..05fc189 --- /dev/null +++ b/docs/evidence/third-sip-llm-outbound-20260917.json @@ -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." + ] +} diff --git a/docs/evidence/third-sip-outbound-20260917.json b/docs/evidence/third-sip-outbound-20260917.json new file mode 100644 index 0000000..9652df0 --- /dev/null +++ b/docs/evidence/third-sip-outbound-20260917.json @@ -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." + ] +} diff --git a/docs/evidence/third-sip-rtp-diagnosis-20260917.json b/docs/evidence/third-sip-rtp-diagnosis-20260917.json new file mode 100644 index 0000000..d3fb390 --- /dev/null +++ b/docs/evidence/third-sip-rtp-diagnosis-20260917.json @@ -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" + } +} diff --git a/docs/真实SIP_Asterisk快速部署_v1.0.md b/docs/真实SIP_Asterisk快速部署_v1.0.md index 58b16b5..baaa456 100644 --- a/docs/真实SIP_Asterisk快速部署_v1.0.md +++ b/docs/真实SIP_Asterisk快速部署_v1.0.md @@ -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/配置目录,核对后再重启容器;不要覆盖正在使用的配置目录。 diff --git a/docs/部署接入_运行说明.md b/docs/部署接入_运行说明.md index 9da8d0b..259762a 100644 --- a/docs/部署接入_运行说明.md +++ b/docs/部署接入_运行说明.md @@ -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 diff --git a/tests/test_real_cell.py b/tests/test_real_cell.py index 64f8266..862a7d4 100644 --- a/tests/test_real_cell.py +++ b/tests/test_real_cell.py @@ -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: