diff --git a/AGENTS.md b/AGENTS.md index d275585..dd40da4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,14 +88,14 @@ - SaaS→Dispatcher 的**五类只读配置**为 `GET /internal/v1/dispatcher/sip`、`/task/:task_id`、`/tasks`、`/tenant/:tenant_id/quota`、`/ai-providers`;路径前缀固定,均须校验 Dispatcher UUID/资源归属、数字 `tenant_id`、完整快照、来源、有效授权和版本。配置读失败、过期、矛盾或不确定时关新准入;没有旧 MQ 配置回退、通用业务 HTTP、ETag 兜底或偷偷启用旧执行字段。已接纳任务持久绑定原快照。 - 呼叫、控制、必要回执和**每通话唯一最终结果**经固定 `v1` RabbitMQ Topic/队列,任务与控制队列由 SaaS 预建,Dispatcher 不可自行建/删/绑定;stop 时先停该任务消费者并关闭持久准入,再 purge 仅该任务队列的待投递消息,失败不回成功;SaaS 停止继续投递已 stop 任务,后续误投递不执行。结果进入指定共享 durable 队列。独立 D UUID 和接收队列不能广播后正文过滤;`tenant_key` 原值保留,任务只能由归属 D 执行。入站先校验和持久 inbox 后 ACK,状态/outbox 同事务;persistent、mandatory、无 return、publisher confirm 成功才记交付,confirm **不是** SaaS 应用收讫。失败/确认丢失与重启只重发同身份消息,不重复拨号或捏造结果。 -- `call.execute` 只带获批 `task_id/callee`,调用线路、主叫、AI 和时限由该任务快照固定;`task.control` 的 start/pause/resume/stop 无旧 CAS/版本字段,控制回 task/action/status 处理结果,stop 不可恢复。任务启动/重启完整读取列表,运行中不定时轮询任务列表;新建任务由 start 读取任务配置和租户额度,暂停后修改的任务由 resume 重读;全局 AI 服务商列表每次启动只读取一次,本进程全部任务复用,变更须重启后才生效;没有 edit 事件。启动时全量读取并核验 SIP,运行中只由 `sip.config` 通知触发全量 SIP 读取,无常规定时 SIP 轮询;任务 start/resume 使用已生效 SIP 快照,不自行拉取或向 Agent 核验 SIP。SIP 修订变化待旧呼叫结束且 Agent 已加载后,仅在本地重新绑定任务快照,不重读任务列表;待处理期间新准入关闭。发现分页同快照先全部校验后提交,MQ 控制持久状态高于偶发 HTTP 状态;冲突关准入、resume 须重新核验。历史命令不因消息年龄过期,但实际呼出前仍检查任务/白名单/时段/授权/额度和有效通话上限;任务结束不删未确认结果、恢复、幂等或未知占用。 +- `call.execute` 只带获批 `task_id/callee`,调用线路、主叫、AI 和时限由该任务快照固定;`task.control` 的 start/pause/resume/stop 无旧 CAS/版本字段,控制回 task/action/status 处理结果,stop 不可恢复。任务启动/重启完整读取列表,运行中不定时轮询任务列表;新建任务由 start 读取任务配置和租户额度,暂停后修改的任务由 resume 重读;全局 AI 服务商列表每次启动只读取一次,本进程全部任务复用,变更须重启后才生效;没有 edit 事件。启动时全量读取并核验 SIP,运行中只由 `sip.config` 通知触发全量 SIP 读取,无常规定时 SIP 轮询;任务 start/resume 使用已生效 SIP 快照,不自行拉取或向 Agent 核验 SIP。SIP 修订变化待旧呼叫结束且 Agent 已加载后,仅在本地重新绑定任务快照,不重读任务列表;待处理期间新准入关闭。发现分页同快照先全部校验后提交,MQ 控制持久状态高于偶发 HTTP 状态;冲突关准入、resume 须重新核验。历史命令不因消息年龄过期,但实际呼出前仍检查任务归属/事件号码格式/时段/授权/额度和有效通话上限;任务结束不删未确认结果、恢复、幂等或未知占用。 - 独立 Dispatcher 的 SQLite 是任务、额度、inbox/outbox 的权威数据;Agent 无业务数据库,录音、执行与上传恢复只写受控私有文件。额度包含未知占用,新 boot/租约到期不得自动清除未知执行;明确 Agent 在拨号前拒绝时须持久拒绝并释放,已接纳但证实 ARI originate 从未提交的失败由原 Agent 经正式终结回执释放,RPC 交付未知时仍占用。2026-10-04 修复版 `d2c4a99` 已安装并通过非呼出时段主机诊断,新隔离测试租户额度 2/revision 2 已启用并由 Dispatcher 只读拉取;原快照保留;该条旧执行仅按前述经使用者授权的人工核实记录解除 Dispatcher 占用,绝非新 boot 或超时自动清除未知执行。仅 Agent 用机器可读标记证实本次未发起的拒绝可自动释放;旧执行的同错误码不算证明。不实现双活数据库、自动跨机热备、多 D 共享额度或第二租户公平。本轮不借本机 D1/D2 隔离夹具宣称多 D 运行。不得建立旧表/旧消息/旧 HTTP 执行兼容通道。 - Dispatcher↔Agent 复用 Unary gRPC 和受控 Endpoint;Agent 预绑定 D UUID 与服务端证书指纹,激活/会话代际、peer mTLS/SAN/SNI 和已签发期限须核对,新 boot 不清未知占用。Agent 不自行向 SaaS 取任务/AI/OSS 授权;Dispatcher 只用已经核验的 Agent `GetLoadedSIP` revision 开执行准入。本机 Mock 的加载回报不证明 Asterisk 已实际加载;仅隔离 SIP-only Agent 在配置原子写入、PJSIP reload 和运行态 endpoint/AOR/UDP transport 一致后持久标记 revision,每次加载查询重新核验。只支持明确的 UDP、IP/none 鉴权、无需 REGISTER 的 IPv4/PCMA 线路;未知字段和其他传输/鉴权/注册方式拒绝,不热更静态 transport。SIP 配置的唯一编辑/审批面仍是 management。 - 非生产真实 Agent 的 Asterisk `res_hep`/`res_hep_pjsip` 仅镜像至本机 UDP:在 ARI Dial 前读取本次 SIP Call-ID 并绑定执行,按 Call-ID 和原始 INVITE transaction 只采集真实最终响应的状态码、状态行与完整 `raw`;丢失/无法关联以 `sip_capture_error` 显式报告,不从 ARI、号码或挂断原因推测。镜像缺失不阻止已证实终结的额度释放;未经确认的占用仍保留。配置及回滚见 [`deploys/cell/README.md`](deploys/cell/README.md),不得将完整 SIP 报文写入普通日志、提交或聊天;本地测试通过不等于测试机已部署 HEP 或真实接通。 - AI 使用任务内不可变授权快照:仅经获批准百炼/火山 ASR、OpenAI 兼容 LLM、百炼 TTS(`qwen3-tts-flash`/`Cherry`/`Chinese`)能表达的参数进入每通话实例;ASR-only 不启动 LLM/TTS,完整 AI 不借旧语音测试的授权或参数。只有最终用户 ASR 文本的明确字面关键词可触发拒联/挂断;不由 SDK 默认值、环境、CLI、metadata 或宽松 Schema 改写业务参数,不因 SDK 重试产生第二次发起/收费或重播。日志只存脱敏版本/摘要/计数,不存密钥、prompt、完整对话或音频。 - **私有配置位置(本机路径相对本仓库根目录,只读,绝不提交)**:`.local/provider-ai.env` 是 `0600` 的 `KEY=VALUE` 文件;字段名为 `BAILIAN_API_KEY`、`BAILIAN_BASE_URL`、`BAILIAN_WSS_BASE_URL`、`BAILIAN_TTS_VOICE`、`VOLC_ASR_APP_NAME`、`VOLC_ASR_APP_KEY`、`VOLCENGINE_ACCESS_KEY`、`VOLCENGINE_SECRET_KEY`、`VOLCENGINE_REGION`、`VOLCENGINE_DISABLE_SSL`。根目录 `aliyun-oss.env` 也是 `0600`,**不是 shell env 文件**;它以冒号分隔,字段名准确为 `bucket`、`Endpoint`、`Region`,以及 `RAM` 下的 `username`、`accessKeyId`、`accessKeySecret`(大小写须保持原样)。测试机 `rogee` 用户的现行 ARI 文件位于 `~/.config/go-sip-asterisk/{ari.conf,http.conf,ari-secret}`,不是旧 `.local/asterisk-*/ari.conf`;访问测试机前先核对已登记的 SSH 主机指纹,不展示 `ari-secret`。 - **下次安全读取步骤**:先确认工作目录是本仓库,用 `stat` 仅检查本机两份文件是否存在、所有者与权限 `0600`;不满足即停止。按各自格式在受限本机进程中解析所需字段到内存,不执行 `source`、不打印全文/字段值、不写临时明文副本,不把密钥、签名 URL、音频或完整对话带入聊天、日志、提交及长期证据。AI 的历史文件只可作为**获准凭据来源**,模型/voice/速度等仍由当前获批的 task/providers 快照固定,不能用环境变量覆盖。OSS 历史文件也不能直接传给 `DISPATCHER_OSS_CONFIG_FILE`:该运行配置要求私有 JSON、`dispatcher_id` 和 `oss` 字段,并以环境变量**名称引用**密钥;需按现行合同构造并核验授权后才能使用。普通构建和测试不读取这些私有文件;真实服务测试必须显式启用对应 opt-in 并受现行门禁约束。 -- **授权边界**:使用者将本轮验收目标改为三条已登记线路、两个既有白名单号码中任意一组真实接通并有 LLM 正常应答;上述 AI 与 OSS 私有配置仍仅用于当前获准的非生产测试,下次任务须重新确认范围和真实服务调用授权,不能沿用本轮或历史一次性授权。不得把历史配置直接当 SaaS 快照、任务授权或真实呼叫准入,不覆盖/清理旧 OSS 对象。 +- **授权边界**:本轮验收目标是三条已登记线路的真实接通及 LLM 正常应答;旧两个号码已分别在三条线路试拨,六通均为 SIP 480,零接通。新增号码的历史逐次授权不等于本次代码变更获准部署或拨号;本次仅修改并本地验证号码准入,**不部署、不拨号**。上述 AI 与 OSS 私有配置仍仅用于另经明确授权的非生产测试,下次任务须重新确认范围和真实服务调用授权,不能沿用本轮或历史一次性授权。不得把历史配置直接当 SaaS 快照、任务授权或真实呼叫准入,不覆盖/清理旧 OSS 对象。 - Agent 录音经受控双向 TLS 向 D 领取短期 OSS 上传授权,每次尝试只作**一次 HTTPS PUT**;正常上传不写录音文件,最终结果在 Dispatcher 确认前允许写入 Agent 私有临时结果文件,确认后删除;已确认挂断但结束回报未确认时须保留原结果并重报原结束事实;PUT 前预存的结果在成功未被确认时不得自行报告。首次明确失败须先完整保存录音与结果两份恢复文件,才从该时刻启动 48 小时重试;按 1、2、4、8、16、32、60 分钟及其后每 60 分钟的固定节奏显式重新申请授权,同一 OSS 目标、同一消息身份。PUT 结果未知不得盲目重传;48 小时届满仍失败时保留文件待人工,**不伪造最终结果或自动清理**。D 不转发文件,已确认结束的通话及时释放执行占用;未知执行仍占用。只有真实终结后才通过唯一 `call.execute.result` 回报录音路径、最终转写和拒联事实;无录音或录音生成失败以空 `recording={}` 和真实结果收口,生成失败须说明原因。不能恢复的录音不声称零丢失,也不伪造 OSS/SaaS 应用回执。凭据/TOKEN/签名 URL 不写入样例、日志、源码或证据。 ## SIP 与真实呼叫限制 @@ -106,7 +106,7 @@ | 中鼎 | `60.171.24.90:5060` | `mbkq` | 无 | | 百应 | `160.202.254.79:5060` | `KQ91526` | `mka755` | -- 全线路**只允许原始号码** `15003164745`、`15830461047`,但白名单和上述登记绝非真实拨号授权。已授权的真实/旧路径仅在 Asia/Shanghai 每日 `09:00`(含)至 `20:00`(不含)放行,每条 trunk 对每个原始号码每天最多 3 次,窗口外直接拒绝,不等候/自动延迟/自动重试/静默换线。每次真实试拨仍须使用者明确安排,并由专用主机脚本在拨号前启用抓包和 PJSIP logger、签发与该通 `event_id`/trunk/原始号码绑定的短时有效活跃抓包凭证;Agent 拒绝缺失/失效/不匹配的凭证。不能拿 Mock 时段测试宣称真实放行。 +- 全线路的原始被叫号码仅由校验归属及任务后的 SaaS `call.execute.payload.callee` 确定;不在 Dispatcher、SaaS Mock 或抓证脚本设置固定号码/日期特判,不向任务快照增设号码列表。只接受 1–32 位 ASCII 数字;格式校验不等于真实拨号授权。非生产真实呼叫仍仅在 Asia/Shanghai 每日 `09:00`(含)至 `20:00`(不含)放行,每条 trunk 对每个原始号码每天最多 3 次,窗口外直接拒绝,不等候/自动延迟/自动重试/静默换线。每次真实试拨仍须使用者明确安排,并由专用主机脚本在拨号前启用抓包和 PJSIP logger、签发与该通 `event_id`/trunk/原始号码绑定的短时有效活跃抓包凭证;Agent 拒绝缺失/失效/不匹配的凭证。不能拿 Mock 时段测试宣称真实放行。 - 任务按周一至周日多个时段与指定排除日期配置,线路只有每周允许时段(**没有线路排除日期**),Asia/Shanghai 左闭右开、跨日拆分;缺失或不确定 fail-closed,不自动重拨。由 Dispatcher 在持久接纳与实际发出指令前判定,并取任务/获批 AI 较小通话时限;Agent 仅校验会话和签发期限,不重算外呼策略。本地策略 Mock 与固定真实门禁必须分别报告。 - `BD` 等主叫原值不得清洗或当作 Digest 用户名;业务原始被叫号码不变,仅被选定数企 trunk 按规则构造 `7089<原号>`(其它线路使用自己的前缀),不重复加前缀。三条 trunk 独立,不能把同地址伪造为备用线路或换线重拨;服务商反馈 PCMA,对应 Asterisk `allow=alaw`,传输/注册/鉴权/并发仍待真实签收。不以 sipgo/diago 另造 Asterisk 替代架构。 diff --git a/contracts/local/manifest.json b/contracts/local/manifest.json index bdff5cf..8db5219 100644 --- a/contracts/local/manifest.json +++ b/contracts/local/manifest.json @@ -4,8 +4,8 @@ "sources": { "docs/archive/sources/v0.5-proposal.md": "612fdaee50aff6aa7fbef16c2d469d99857646c6d2235617d0e67f6098cd7ada", "docs/archive/sources/plan-saas-dispatcher-v05-v0.1.md": "666f39e56ea9f4b55661efcac82edd6f9729848e2d60e5f24cdf5aa3ac97ee87", - "docs/thirds/saas-dispatcher.md": "c4fad50a3eac37a4cae3440f70f0a374d00e392372893946b3085f84abc2541c" + "docs/thirds/saas-dispatcher.md": "f8e7f52c05ed83e47b600331e3f8ac6338d94c00d75688ab9e1dedf57b2f41ae" }, - "bundle_sha256": "4cda02cdab7659c278ff22af45f4ab2190da9d97846b7f5a8a64bb2ffccb7e3c", + "bundle_sha256": "e5ac2b46cb6544778774cce4616ae0d9b6da941206805f81e1a4a6aaa3e3a3d5", "bundle_algorithm": "sha256 of sorted relative-path + space + sha256(file) + newline; only root-level JSON and examples/**/*.json, excluding manifest.json" } diff --git a/contracts/local/mq.schema.json b/contracts/local/mq.schema.json index f1e11f8..b48d0a4 100644 --- a/contracts/local/mq.schema.json +++ b/contracts/local/mq.schema.json @@ -52,7 +52,7 @@ "required": ["event_id", "event_type", "dispatcher_id", "tenant_id", "issued_at", "payload"], "properties": { "event_id": {"$ref": "#/$defs/event_id"}, "event_type": {"const": "call.execute"}, "dispatcher_id": {"$ref": "#/$defs/dispatcher_id"}, "tenant_id": {"$ref": "#/$defs/tenant_id"}, "issued_at": {"$ref": "#/$defs/issued_at"}, - "payload": {"type": "object", "additionalProperties": false, "required": ["task_id", "callee"], "properties": {"task_id": {"$ref": "#/$defs/task_id"}, "callee": {"type": "string", "minLength": 1}}} + "payload": {"type": "object", "additionalProperties": false, "required": ["task_id", "callee"], "properties": {"task_id": {"$ref": "#/$defs/task_id"}, "callee": {"type": "string", "minLength": 1, "description": "SaaS event original callee; Dispatcher rejects non-ASCII digits or more than 32 digits with an application receipt after persisting the event"}}} } }, "execute_ack": { diff --git a/deploys/test/nonprod-call-evidence.sh b/deploys/test/nonprod-call-evidence.sh index e2c7ee2..34660b5 100755 --- a/deploys/test/nonprod-call-evidence.sh +++ b/deploys/test/nonprod-call-evidence.sh @@ -76,7 +76,7 @@ esac [[ "$asterisk_scope" == system || "$asterisk_scope" == user ]] || { echo 'invalid Asterisk service scope' >&2; exit 1; } [[ "$call_id" =~ ^[A-Za-z0-9._-]+$ ]] || { echo 'invalid call id' >&2; exit 1; } [[ "$trunk" =~ ^(provider-primary|provider-second|provider-third|trunk-[A-Za-z0-9._-]+)$ ]] || { echo 'trunk is not an approved non-production trunk id' >&2; exit 1; } -[[ "$target" =~ ^(15003164745|15830461047)$ ]] || { echo 'target is outside the approved outbound whitelist' >&2; exit 1; } +[[ "$target" =~ ^[0-9]{1,32}$ ]] || { echo 'SaaS event target is not an original numeric dial route' >&2; exit 1; } [[ "$attempt_ledger" =~ ^/[A-Za-z0-9._/-]+$ ]] || { echo 'invalid attempt ledger path' >&2; exit 1; } [[ "$proof_root" =~ ^/[A-Za-z0-9._/-]+$ ]] || { echo 'invalid proof root path' >&2; exit 1; } [[ ${#call_command[@]} -gt 0 ]] || { echo 'call command is required after --' >&2; exit 1; } diff --git a/deploys/test/saas-mock/README.md b/deploys/test/saas-mock/README.md index 68ff63e..2651d9b 100644 --- a/deploys/test/saas-mock/README.md +++ b/deploys/test/saas-mock/README.md @@ -13,7 +13,7 @@ - 准备测试 HTTPS 证书与私钥;将 `SAAS_MOCK_DISPATCHER_SECRET` 和含凭据的 `SAAS_MOCK_RABBITMQ_URL` 放在受限环境文件,不在命令行、仓库或聊天中传输。 - 启动:`go run ./deploys/test/saas-mock --data <私有目录> --dispatcher-id --listen <地址:端口> --tls-cert <证书文件> --tls-key <私钥文件>`。 - 服务以标准 `X-DISPATCHER-id` 和 `X-DISPATCHER-SECRET-KEY` 校验 Dispatcher,再提供五类只读配置。错误的归属、资源、租户、快照或消息队列会导致拒绝启动/读取。 -- 单次投递另起命令:`go run ./deploys/test/saas-mock --data <私有目录> --dispatcher-id --publish-event-id <唯一事件号> --publish-task-id <单线路任务号> --publish-callee <原始白名单号码> --await-result-file <私有结果文件>`。此命令在专用结果队列中等待精确匹配的单通最终结果,先将原始结果写入 `0600` 私有文件并同步磁盘,才确认 MQ 消费;标准输出只显示结果摘要/hash,不输出转写、录音或签名 URL。必须在 `nonprod-call-evidence.sh --call-id <同一事件号> --trunk <任务唯一线路> --target <同一原始号码> -- <单次投递命令>` 启用并确认 SIP/RTP 抓包、PJSIP logger 和主机门禁之后运行;不得预投、批量投递、自动重试或换线。任务快照须只允许一条真实线路,投递仅含任务号和原始号码。RabbitMQ 必须用上述专用环境变量,不能借用默认或共享 vhost。`call.execute` 的 `dispatched` 只是派发回执:归属匹配时先私密保存为 `<结果文件>.receipt.json` 并确认,再继续等待唯一最终结果并保持抓包。明确未拨号的 `rejected` 回执则先私密保存为 `<结果文件>.rejected.json` 再确认,并立即按无呼叫失败结束等待;不伪造最终通话结果。不匹配的消息不确认。发布确认只代表 MQ 接收,不代表 SaaS 已收到最终结果;结果等待超时/归属不符时不清理未知通话,也不重发同通命令。已有未交付队列消息须人工确认处置,不自动清理。 +- 单次投递另起命令:`go run ./deploys/test/saas-mock --data <私有目录> --dispatcher-id --publish-event-id <唯一事件号> --publish-task-id <单线路任务号> --publish-callee --await-result-file <私有结果文件>`。此命令在专用结果队列中等待精确匹配的单通最终结果,先将原始结果写入 `0600` 私有文件并同步磁盘,才确认 MQ 消费;标准输出只显示结果摘要/hash,不输出转写、录音或签名 URL。必须在 `nonprod-call-evidence.sh --call-id <同一事件号> --trunk <任务唯一线路> --target <同一原始号码> -- <单次投递命令>` 启用并确认 SIP/RTP 抓包、PJSIP logger 和主机门禁之后运行;不得预投、批量投递、自动重试或换线。任务快照须只允许一条真实线路,投递仅含任务号和原始号码。RabbitMQ 必须用上述专用环境变量,不能借用默认或共享 vhost。`call.execute` 的 `dispatched` 只是派发回执:归属匹配时先私密保存为 `<结果文件>.receipt.json` 并确认,再继续等待唯一最终结果并保持抓包。明确未拨号的 `rejected` 回执则先私密保存为 `<结果文件>.rejected.json` 再确认,并立即按无呼叫失败结束等待;不伪造最终通话结果。不匹配的消息不确认。发布确认只代表 MQ 接收,不代表 SaaS 已收到最终结果;结果等待超时/归属不符时不清理未知通话,也不重发同通命令。已有未交付队列消息须人工确认处置,不自动清理。 测试:`go test ./deploys/test/saas-mock` 验证正式配置客户端;设置指向**单独隔离 vhost** 的 `SAAS_MOCK_TEST_BROKER_URL` 后,`TestSaaSMockProvisionsDispatcherTopology` 还将实际预建 MQ 并用 Dispatcher 被动读回。缺省测试不会连接共享 RabbitMQ。 diff --git a/deploys/test/saas-mock/publish.go b/deploys/test/saas-mock/publish.go index 2671523..f005337 100644 --- a/deploys/test/saas-mock/publish.go +++ b/deploys/test/saas-mock/publish.go @@ -15,6 +15,7 @@ import ( ) var mockCallID = regexp.MustCompile(`^[A-Za-z0-9._-]{1,100}$`) +var mockCallee = regexp.MustCompile(`^[0-9]{1,32}$`) // buildExecute deliberately carries only the two approved call inputs. Trunk, // caller, AI and duration remain immutable properties of the SaaS task read. @@ -25,8 +26,8 @@ func buildExecute(data dataset, eventID, taskID, callee string, now time.Time) ( } hour := now.In(shanghai).Hour() if !mockCallID.MatchString(eventID) || !mockCallID.MatchString(taskID) || - (callee != "15003164745" && callee != "15830461047") || hour < 9 || hour >= 20 { - return "", nil, errors.New("one-shot command identity, whitelist or real call window rejected") + !mockCallee.MatchString(callee) || hour < 9 || hour >= 20 { + return "", nil, errors.New("one-shot command identity, numeric callee or real call window rejected") } body, ok := data.tasks[taskID] if !ok { diff --git a/deploys/test/saas-mock/publish_test.go b/deploys/test/saas-mock/publish_test.go index a12c08e..890f4fc 100644 --- a/deploys/test/saas-mock/publish_test.go +++ b/deploys/test/saas-mock/publish_test.go @@ -333,14 +333,20 @@ func TestSaaSMockBuildsOneApprovedCommandForTheBoundTask(t *testing.T) { if err := json.Unmarshal(body, &event); err != nil || event.EventID != "event-once-1" || event.Type != "call.execute" || event.DispatcherID != testDispatcher || event.TenantID != 1001 || event.Payload.TaskID != "task-full" || event.Payload.Callee != "15003164745" || strings.Contains(string(body), "trunk-mock") { t.Fatalf("SaaS must not leak a trunk/caller/AI override into the execute command: %+v err=%v", event, err) } + for _, number := range []string{"15803300952", "13900000000"} { + _, eventBody, err := buildExecute(data, "saas-event-"+number, "task-full", number, inside) + if err != nil || contract.ValidateCurrent("mq", eventBody) != nil || !strings.Contains(string(eventBody), `"callee":"`+number+`"`) { + t.Fatalf("valid SaaS number was changed or rejected: %q err=%v", number, err) + } + } for _, test := range []struct { id, task, callee string at time.Time }{ {"../event", "task-full", "15003164745", inside}, {"event-2", "missing", "15003164745", inside}, - {"event-3", "task-full", "708915003164745", inside}, - {"event-4", "task-full", "13900000000", inside}, + {"event-3", "task-full", "abc", inside}, + {"event-4", "task-full", strings.Repeat("1", 33), inside}, {"event-5", "task-full", "15003164745", inside.Add(-2 * time.Hour)}, {"event-6", "task-full", "15003164745", inside.Add(10 * time.Hour)}, } { diff --git a/docs/thirds/saas-dispatcher.md b/docs/thirds/saas-dispatcher.md index 85faa7e..8113562 100644 --- a/docs/thirds/saas-dispatcher.md +++ b/docs/thirds/saas-dispatcher.md @@ -26,7 +26,7 @@ RabbitMQ 是 Topic,**SaaS 独占创建、绑定、退役 exchange/queue,D | --- | --- | --- | | `sip.config` | SaaS→D;`revision` 触发全量重新读取和实际加载核验,不用通知正文代替全量 | [`notification`](../../contracts/local/examples/mq-sip-change.json) | | `task.control` | SaaS→D;start/pause/resume/stop,无控制去重/CAS 字段。start 读取新任务配置及租户配额并持久绑定,无 Agent 控制动作;resume 重读最新任务配置和额度后解除人工暂停;均沿用启动时 AI 服务商列表及已生效 SIP 快照,不重复拉取全局服务商或 SIP,也不额外核验 SIP;pause/stop 发给 Agent,省略 `active_call_policy` 默认 **hangup**,显式仅 drain/hangup。配置读取、归属或 AI 核验失败时不接纳,SIP 待处理时新呼叫仍关闭;stop 先停止该任务消费者(使在途未确认消息回队列),持久关闭任务准入并抑制本地待执行指令,再对该任务队列执行 purge 清除当时待投递消息;purge 失败不发送成功回执、保持准入关闭并重试原控制消息。SaaS 须停止继续投递已 stop 的任务;purge 不阻止之后新投递,这些消息不执行。成功操作后才回应用回执,回执不是已完成活跃通话排空/挂断、SaaS 已停止投递或未来消息不存在的证据;stop 同 ID 不可恢复 | [`start`](../../contracts/local/examples/mq-control-start.json) · [`control`](../../contracts/local/examples/mq-control.json) · [`ack`](../../contracts/local/examples/mq-control-ack.json) | -| `call.execute` | SaaS→D 仅 `{task_id,callee}`;一次指令保留独立消息/执行身份,路由/主叫/AI/时限从绑定任务读取。`dispatched` 表示已发出呼叫指令,**不表示接通**;白名单/单号码格式不合规则回 `rejected,reason_code:null,reason_message`、不拨号不发最终结果、不暂停整任务 | [`execute`](../../contracts/local/examples/mq-execute.json) · [`dispatched`](../../contracts/local/examples/mq-execute-ack.json) · [`rejected`](../../contracts/local/examples/mq-execute-rejected.json) | +| `call.execute` | SaaS→D 仅 `{task_id,callee}`;一次指令保留独立消息/执行身份,路由/主叫/AI/时限从绑定任务读取。`callee` 原值由通过归属及任务校验的 SaaS 事件确定,不从任务快照或本地号码列表另选;`dispatched` 表示已发出呼叫指令,**不表示接通**;号码格式不合规则回 `rejected,reason_code:null,reason_message`、不拨号不发最终结果、不暂停整任务 | [`execute`](../../contracts/local/examples/mq-execute.json) · [`dispatched`](../../contracts/local/examples/mq-execute-ack.json) · [`rejected`](../../contracts/local/examples/mq-execute-rejected.json) | | `call.execute.result` | D→SaaS;按 `task_id` + 原号码关联,每次呼叫仅一份最终结果;不新增外部 call_id/source_command_id;真正终结且录音成功上传、无录音或预期录音生成失败后才发送 | [`uploaded`](../../contracts/local/examples/mq-result-uploaded.json) · [`empty`](../../contracts/local/examples/mq-result-no-recording.json) | 旧 `command.result`、`call.result`、分散通话/转写/拒联事件、`recording.uploaded` 不再作为对外并行通知或兼容别名。D 在 inbox 持久后 ACK;状态/outbox 同事务;结果发布使用原消息身份可靠交付;confirm 不是 SaaS 应用收讫。消息年龄不让旧命令绕过准入;未来 issued_at 不提前接纳。重复投递/未知执行不触发再次拨号。 @@ -34,7 +34,7 @@ RabbitMQ 是 Topic,**SaaS 独占创建、绑定、退役 exchange/queue,D ## 调度、AI 与真实结果(K01–K09、K11–K14) - 当前 TTS 唯一获批适配器是 `bailian_tts`:任务快照须明确提供 `qwen3-tts-flash`、`Cherry`、`Chinese`、速度 `1` 和单声道 16 kHz PCM16 目标格式;使用已核验的 provider 凭据及生成端点。每段仅发起一次生成请求,下载返回的短期音频引用后转换为电话可用的 PCM16;不可用、超时、缺少转换工具或参数不支持时显式失败,不回退旧火山 TTS、不隐式重试或记录签名音频 URL。历史测试凭据不是任务授权,本地转换 Mock 不构成真实百炼/通话验收。 -- 白名单仅含 `15003164745`、`15830461047` 原值;已选 SIP trunk、任务与线路每周时段、任务排除日期、任务/租户/线路额度、任务与 AI 较小通话时限均在接纳及实际发呼叫指令前检查。线路字段未知则 fail-closed;选线后固定、不自动重拨/换线。隔离 Mock 中规则暂不满足时保留待执行指令、暂停该任务的调度,规则允许后重验;与人工 pause/stop 分离,不能自动解除人为停止。本规则**不**放宽真实路径 Asia/Shanghai `09:00`–`20:00` 固定门禁或授权真实拨号。 +- 被叫号码只来自归属当前 D、租户及已接纳任务的 `call.execute.payload.callee` 原值;不在 Dispatcher、SaaS Mock 或抓证脚本另设固定号码列表,也不在任务快照增加号码列表。Dispatcher 与非生产发布/抓证入口只接受 1–32 位 ASCII 数字的原始号码,不能把线路前缀当成该号码的本地替代值。已选 SIP trunk、任务与线路每周时段、任务排除日期、任务/租户/线路额度、任务与 AI 较小通话时限均在接纳及实际发呼叫指令前检查。线路字段未知则 fail-closed;选线后固定、不自动重拨/换线。隔离 Mock 中规则暂不满足时保留待执行指令、暂停该任务的调度,规则允许后重验;与人工 pause/stop 分离,不能自动解除人为停止。本规则**不**放宽真实路径 Asia/Shanghai `09:00`–`20:00` 固定门禁、每线路每原始号码每日 3 次及逐通抓证门禁;格式有效或本地 Mock 收件均不是一次真实拨号的授权。 - 接通事实为真时 `outcome=answered`(后续异常不抹掉接通);已发起但忙线、拒接、无人接听且确定结束为 `no_answer`;确认未接通并由 Agent/Asterisk 执行故障终结为 `failed`;未知状态保持未知占用,不能伪造结束、结果或自动重拨。真实 SIP 状态码原样数字写入 `reason_code`,无真实 SIP 码则 `null` 并以 `reason_message` 说明;禁止本地虚构数字错误码。`call.execute.result.payload` 的 `status_line`、`raw` 与 `sip_capture_error` 始终存在:仅将经同一 ARI 通道拨号前取得的 SIP Call-ID 与 HEP INVITE 事务严格关联的最终响应写入原样状态行、完整原样报文与状态码;`raw` 不拼装、不截断,不能从目标号码、时间、挂断原因或 ARI HTTP 状态猜测。无 SIP 响应时前两项为 `null`;若已发起 SIP 但镜像/关联/解码失败,第三项须写明确错误,已确认结束仍报告真实结果并释放额度,不以原文缺失伪装为通话未知。原始报文只进入受控结果通道,不写日志、仓库或长期测试证据。无应答且没有录音时 `transcript=[]`、`opt_out=false`、`recording={}`。 - 只有**用户侧 ASR 最终识别文本**包含任一 `hangup_keywords` 字面字符串才挂断;中间识别、助手回复、开场白、TTS 均不能触发;重复结果不可反复终结。同一任务 revision 不同内容拒绝准入;provider 禁用/角色不符不可调用。Mock 参数验证不等于真实供应商验收。 diff --git a/internal/config/nonprod_evidence_cleanup_test.go b/internal/config/nonprod_evidence_cleanup_test.go index 5b8b13f..d9d3a51 100644 --- a/internal/config/nonprod_evidence_cleanup_test.go +++ b/internal/config/nonprod_evidence_cleanup_test.go @@ -35,6 +35,15 @@ func TestNonprodUserAsteriskScopeRequiresExplicitConfig(t *testing.T) { if err == nil || !strings.Contains(string(output), "explicit user Asterisk configuration required") { t.Fatalf("off-hours non-dial preflight must check Asterisk configuration: err=%v output=%s", err, output) } + fromSaaS := exec.Command("bash", "../../deploys/test/nonprod-call-evidence.sh", "--environment", "mock", + "--asterisk-scope", "user", "--trunk", "provider-primary", "--target", "15803300952", "--run-as", currentUser.Username, + "--interface", "lo", "--recording-dir", filepath.Join(root, "recordings"), "--evidence-dir", filepath.Join(root, "saas-evidence"), + "--attempt-ledger", filepath.Join(root, "attempts.tsv"), "--preflight-only", "--", "/bin/true") + fromSaaS.Env = command.Env + output, err = fromSaaS.CombinedOutput() + if err == nil || !strings.Contains(string(output), "explicit user Asterisk configuration required") { + t.Fatalf("SaaS supplied digit route did not reach the same evidence gate: err=%v output=%s", err, output) + } withoutPreflight := exec.Command("bash", "../../deploys/test/nonprod-call-evidence.sh", "--environment", "mock", "--asterisk-scope", "user", "--trunk", "provider-primary", "--target", "15003164745", "--run-as", currentUser.Username, "--interface", "lo", "--recording-dir", filepath.Join(root, "recordings"), "--evidence-dir", filepath.Join(root, "real-attempt"), diff --git a/internal/dispatcher/execute.go b/internal/dispatcher/execute.go index 798a2ac..d514bf6 100644 --- a/internal/dispatcher/execute.go +++ b/internal/dispatcher/execute.go @@ -103,7 +103,7 @@ func (c *ExecuteController) ProcessExecute(ctx context.Context, body []byte) err if stored.Status != "pending" { return nil } - if _, whitelisted := calleeWhitelist[cmd.Callee]; !whitelisted { + if !validRawCallee(cmd.Callee) { if err := c.Store.RejectExecute(cmd.DispatcherID, cmd.EventID, ErrCalleeRejected.Error()); err != nil { return fmt.Errorf("persist rejected call %q: %w", cmd.EventID, err) } diff --git a/internal/dispatcher/execute_test.go b/internal/dispatcher/execute_test.go index 40c2515..f3857d1 100644 --- a/internal/dispatcher/execute_test.go +++ b/internal/dispatcher/execute_test.go @@ -100,11 +100,14 @@ func TestExecuteRejectsInvalidCalleeWithoutStoppingTask(t *testing.T) { if err := controller.ProcessExecute(context.Background(), executeBody(t, "good-1", "15003164745")); err != nil || len(originator.calls) != 1 { t.Fatalf("duplicate command reoriginated: %d %v", len(originator.calls), err) } + if err := controller.ProcessExecute(context.Background(), executeBody(t, "saas-number-1", "15803300952")); err != nil || len(originator.calls) != 2 || originator.calls[1].Callee != "15803300952" { + t.Fatalf("valid SaaS event callee was not dispatched once: calls=%+v err=%v", originator.calls, err) + } if err := controller.FlushOutbox(context.Background()); err != nil { t.Fatal(err) } - if len(publisher.bodies) != 2 { - t.Fatalf("expected rejected and dispatched acknowledgments, got %d", len(publisher.bodies)) + if len(publisher.bodies) != 3 { + t.Fatalf("expected one malformed rejection and two dispatched acknowledgments, got %d", len(publisher.bodies)) } for _, body := range publisher.bodies { var message struct { diff --git a/internal/dispatcher/policy.go b/internal/dispatcher/policy.go index 8486800..8055c35 100644 --- a/internal/dispatcher/policy.go +++ b/internal/dispatcher/policy.go @@ -12,7 +12,7 @@ import ( ) var ( - ErrCalleeRejected = errors.New("callee is not on the approved outbound whitelist") + ErrCalleeRejected = errors.New("SaaS event callee is not a valid original numeric dial route") ErrRuleWait = errors.New("task rules are temporarily not satisfied") ErrRuleInvalid = errors.New("task execution rules are invalid or unknown") ) @@ -44,13 +44,25 @@ type callerProfile struct { CallerID string `json:"caller_id"` } -var calleeWhitelist = map[string]struct{}{"15003164745": {}, "15830461047": {}} +// validRawCallee checks only what the native dial route can safely represent; +// the SaaS call.execute event, not a local list, chooses the business number. +func validRawCallee(callee string) bool { + if len(callee) == 0 || len(callee) > 32 { + return false + } + for i := 0; i < len(callee); i++ { + if callee[i] < '0' || callee[i] > '9' { + return false + } + } + return true +} // SelectTrunk makes one ordered choice before originate. Its answer is // frozen with the accepted command; callers never silently reselect on an // unknown execution or after a failed originate. func SelectTrunk(snapshot configread.Snapshot, callee string, at time.Time, trunkOccupancy, loadedRevisions map[string]int64) (SelectedTrunk, error) { - if _, allowed := calleeWhitelist[callee]; !allowed { + if !validRawCallee(callee) { return SelectedTrunk{}, ErrCalleeRejected } if snapshot.Task.MaxCallDurationMS <= 0 || snapshot.Task.MaxCallDurationMS > math.MaxInt64/int64(time.Millisecond) { diff --git a/internal/dispatcher/policy_test.go b/internal/dispatcher/policy_test.go index ce6300d..852aebf 100644 --- a/internal/dispatcher/policy_test.go +++ b/internal/dispatcher/policy_test.go @@ -43,7 +43,7 @@ func monday(hour, minute int) time.Time { return time.Date(2026, 9, 21, hour, minute, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60)) } -func TestSelectTrunkWhitelistScheduleAndCaller(t *testing.T) { +func TestSelectTrunkSaaSEventCalleeScheduleAndCaller(t *testing.T) { snapshot := policySnapshot(t) at := monday(9, 30) selected, err := SelectTrunk(snapshot, "15003164745", at, map[string]int64{}, map[string]int64{"trunk-mock": 8}) @@ -53,9 +53,15 @@ func TestSelectTrunkWhitelistScheduleAndCaller(t *testing.T) { if selected.TrunkID != "trunk-mock" || selected.CallerID != "BD00000000" || selected.Callee != "15003164745" || selected.DialedCallee != "15003164745" || selected.Deadline.After(at.Add(120*time.Second)) || !selected.Deadline.After(at) || selected.MaxCallDurationMS != snapshot.Task.MaxCallDurationMS { t.Fatalf("selected route/prefix/caller/deadline invalid: %+v", selected) } - for _, number := range []string{"", "708915003164745", "15003164746", "abc", "158304610470"} { + for _, number := range []string{"15803300952", "13900000000", "15003164746"} { + choice, err := SelectTrunk(snapshot, number, at, nil, map[string]int64{"trunk-mock": 8}) + if err != nil || choice.Callee != number || choice.DialedCallee != number { + t.Fatalf("SaaS event number changed or rejected: number=%q choice=%+v err=%v", number, choice, err) + } + } + for _, number := range []string{"", "abc", "15803300952x", "15803300952\n", strings.Repeat("7", 33)} { if _, err := SelectTrunk(snapshot, number, at, nil, map[string]int64{"trunk-mock": 8}); !errors.Is(err, ErrCalleeRejected) { - t.Fatalf("unapproved number %q not rejected: %v", number, err) + t.Fatalf("invalid raw dial route %q not rejected: %v", number, err) } } for _, at := range []time.Time{monday(8, 59), monday(20, 0)} { diff --git a/internal/dispatcher/runtime_integration_test.go b/internal/dispatcher/runtime_integration_test.go index 4bea1d1..4cb7d0d 100644 --- a/internal/dispatcher/runtime_integration_test.go +++ b/internal/dispatcher/runtime_integration_test.go @@ -93,7 +93,7 @@ func TestRuntimeIsolatedControlBacklogExecuteAndSharedResult(t *testing.T) { } } publish(controlRoute, controlBody(t, "control-example", "pause", "drain")) - publish(taskRoute, executeBody(t, "call-example", "15003164745")) + publish(taskRoute, executeBody(t, "call-example", "15803300952")) snapshot := policySnapshot(t) sipJSON, err := json.Marshal(snapshot.SIP) @@ -218,8 +218,8 @@ func TestRuntimeIsolatedControlBacklogExecuteAndSharedResult(t *testing.T) { } select { case spec := <-agent.calls: - if spec.EventID != "call-example" || spec.TrunkID != "trunk-mock" { - t.Fatalf("wrong dispatch: %+v", spec) + if spec.EventID != "call-example" || spec.TrunkID != "trunk-mock" || spec.Callee != "15803300952" { + t.Fatalf("wrong SaaS event callee or dispatch: %+v", spec) } case <-finished: t.Fatalf("runtime failed before call dispatch: %v", runtimeErr) @@ -260,7 +260,7 @@ func TestRuntimeIsolatedControlBacklogExecuteAndSharedResult(t *testing.T) { if seen["control-example"] != "applied" || seen["resume-integration"] != "applied" || seen["call-example"] != "dispatched" { t.Fatalf("wrong shared SaaS results: %+v", seen) } - publish(taskRoute, executeBody(t, "call-example", "15003164745")) + publish(taskRoute, executeBody(t, "call-example", "15803300952")) select { case spec := <-agent.calls: t.Fatalf("redelivery reoriginated call %s", spec.EventID)