外观
集成契约:FreeSWITCH originate、通道变量与生命周期
本篇是 callout-server ↔ FreeSWITCH ↔ callflow-esl 之间的接口契约,是阅读外呼业务与转人工业务示例的前置。
1. callout-server 如何发起通话
callout-server 直连 FreeSWITCH inbound ESL(config.json 的 freeswitch.host/port/password), 对每个被叫提交一条后台 originate 命令。命令由 src/freeswitch/client.ts 构造,整体形如:
text
bgapi originate {var1=val1,var2=val2,...}<dialString> &socket('<callbackHost>:<callbackPort> async full')<dialString>:由线路(或任务)拨号模板渲染,替换为被叫号码。 例:user/、sofia/gateway/my-trunk/。&socket('<callbackHost>:<callbackPort> async full'):接通后把通道交给 callflow-esl 的 outbound socket(freeswitch.callbackHost/callbackPort,默认:9911)。callbackHost必须是 FreeSWITCH 主机能回连到的地址,否则接通后永远进不了业务。{...}:注入的通道变量段(见下)。
bgapi表示后台提交:FreeSWITCH 接受 originate job 后立即返回+OK <job-uuid>, 不等待号码响铃/接通。因此 dispatch 接口返回accepted/submitted只代表“已交给 FreeSWITCH”, 真正的接通/挂机由事件监听器与业务回写推进。
三种 originate 由不同函数构造,命令结构一致,区别在拨号串与变量:
| 场景 | 函数 | 拨号串 | 关键变量 |
|---|---|---|---|
| 普通外呼 | submitOutboundCall | 被叫号码模板 | business_code + callout_* 一组 |
| 坐席入会(转人工/主管) | submitAgentConferenceCall | 坐席分机模板 user/ | business_code=agent-conference-guest、conference_id、callout_conference_role |
| 坐席桥接呼出/回拨 | submitAgentBridgeCall | 坐席分机模板,接通后 &bridge('<客户拨号串>') | callout_agent_call_record_id、callout_agent_extension |
2. 普通外呼注入的通道变量
由 buildOutboundPayload + submitOutboundCall 合成。业务在 callflow-esl 里通过 ctx.channel.variables[...] 或 ctx.getVariable(...) 读取:
| 通道变量 | 含义 | 来源 |
|---|---|---|
business_code | 接通后路由到哪个 callflow-esl 业务 | campaigns.businessCode |
callout_request_id | 本次外呼请求 ID(= call_attempts.requestId),回写时用于定位 | 每次 originate 生成的 UUID |
callout_campaign_id | 外呼任务 ID | options.campaignId |
callout_contact_id | 联系人 ID | contact.id |
script_id | 流程脚本 id(流程脚本模式必填):business_code=callout-flow-business 时业务据此拉取 GET /api/v1/scripts/:id/runtime 并逐节点执行 | 前端把它作为字符串放进 campaigns.businessConfig.variables 下发 |
outbound_request_id | FreeSWITCH 侧请求 ID(与 callout_request_id 不同,仅排障用) | submitOutboundCall 内部 UUID |
origination_caller_id_number / origination_caller_id_name | 主叫号码/名称(按线路 callerIdHeaderMode 透传) | 选中线路/号码池 |
任务级 variables 的各键 | 任务公共变量(仅字符串值) | campaigns.businessConfig.variables |
联系人 metadata 的各字符串键 | 联系人级变量(仅字符串值) | contacts.metadata |
要点:
- 只有字符串值会被注入。
variables/metadata里的数字、布尔、对象会被过滤掉。 需要传给业务的字段请存成字符串。 - 联系人
metadata同名键会覆盖任务级variables同名键;系统生成的callout_*/business_code等变量最后写入,优先级最高。 - 流程脚本模式的接线约定:任务
businessCode选callout-flow-business,并在任务businessConfig.variables里放script_id(字符串)——campaigns表没有独立的 scriptId 列,脚本 id 就是靠这条通道变量下发的。runtime JSON 拉取走内部 token 鉴权(见 api-reference 「流程脚本」一节)。 - 普通外呼不注入
callout_attempt_id;回写时用callout_request_id或callout_campaign_id+callout_contact_id定位 attempt 即可。
外呼身份与数据分离:导入时只携带号码 + 必要标识,客户业务数据(姓名、订单等)应由业务侧按号码/contactId 实时查询, 不随导入冗余携带。demo 业务里的 lookupCustomerProfileByPhone 就是这个查询点的占位(演示用号码做确定性派生)。
3. 坐席入会注入的通道变量
由 submitAgentConferenceCall 合成,用于 AI 转人工与坐席间流转、主管监听:
| 通道变量 | 含义 |
|---|---|
business_code | 固定 agent-conference-guest(坐席入会业务) |
conference_id | 坐席要加入的会议室 ID(客户已在或将进入该会议室) |
callout_conference_role | agent / supervisor |
callout_conference_flags | 会议加入 flags,` |
callout_agent_call_record_id | 关联 agent_call_records 记录 ID,供事件回写接听/挂机状态 |
callout_agent_id | 坐席 ID |
callout_direction | 软电话腿方向(transfer / supervisor) |
call_timeout | 坐席分机振铃超时(秒) |
callout_campaign_id / callout_contact_id / callout_attempt_id | 关联任务/联系人/通话(存在时透传) |
4. 通话与联系人状态机
callout-server 区分三层状态,不要混淆:
4.1 call_attempts.status(提交层)
描述 callout → FreeSWITCH 的 originate 提交结果,取值 accepted / rejected / error。 accepted 仅表示 FreeSWITCH 接受了后台 originate,不代表接通。
4.2 call_attempts.callStatus(通话层)
描述真实通话进程,由事件监听器 / 业务回写: ringing / answered / no_answer / busy / failed / completed / unknown。
4.3 contacts.status(联系人层)
pending → dialing → submitted → completed / failed(另有 paused)。
pending:待派发。dialing:已被一轮 dispatch 取走、正在提交 originate。submitted:originate 已被 FreeSWITCH 接受,等待接通/结果回写。completed/failed:由回写的callStatus推导(见deriveContactStatus)。completed→completed;no_answer/busy/failed→failed;否则保持submitted。paused:被POST /api/campaigns/:id/contacts/pause暂停,调度器不再领取。
dialing/submitted 称为「活跃联系人」,参与并发计数。
4.4 任务完成判定
campaigns.status=completed 表示:无 pending、无可继续重试的 failed、无 dialing/submitted 活跃联系人。 它不等于全部成功;可能全失败重试耗尽。后台调度器只扫描 running 任务,completed 不再自动调度。 需要再处理用 POST /api/campaigns/:id/retry 把联系人重置回 pending 并重新启动任务。
5. 两层并发模型
派发时受两层限制(活跃联系人 = dialing + submitted):
- 任务并发
campaigns.maxConcurrency:单个任务内可同时活跃的联系人数。 - 线路并发
lines.maxConcurrency:共享同一线路的所有任务可同时活跃的联系人数。
一次批次实际派发数量:
text
min(请求批次大小, 任务剩余槽位, sum(已绑定启用线路的剩余槽位))每个联系人选当前剩余槽位最多的线路(并列按线路 ID 升序)。主叫号码/名称/拨号模板完全用本次选中线路的配置。
多实例下用两层协调:Redis 锁(防重复进入派发流程,全局调度器锁 + 活动锁)+ PostgreSQL FOR UPDATE SKIP LOCKED 行锁(最终一致性,防同一联系人被重复占用)。 数据库锁刻意短生命周期,只覆盖批次创建/并发计算/联系人占用,originate 网络调用在事务提交之后才发生。
6. callflow-esl 侧需要的配置
业务回写/转人工路由的地址在 callflow-esl 的 config.json 里(不是 callout-server):
json
{
"callout": {
"calloutServer": {
"baseUrl": "http://127.0.0.1:9920",
"token": "",
"requestTimeoutMs": 10000
},
"nlu": {
"baseUrl": "http://127.0.0.1:9930",
"token": "",
"requestTimeoutMs": 30000
},
"chat": {
"baseUrl": "http://127.0.0.1:9931",
"token": "",
"requestTimeoutMs": 60000
}
}
}calloutServer:callout-server 的服务基址、独立 token 和超时;代码固定调用/api/call-results、/api/call-progress、/api/transfers/route、/api/transfers/end与/api/v1/scripts/:id/runtime。nlu:llm-nlu-server 的服务基址、独立 token 和超时;固定调用/api/v1/nlu。chat:llm-chat-server 的服务基址、独立 token 和超时;固定调用/api/v1/chat/stream。calloutServer.token会作为X-Callout-Internal-Token请求头发送; 必须与 callout-server 的internalApi.callResultToken(或CALLOUT_INTERNAL_CALL_RESULT_TOKEN)一致。 两端都为空时放行(仅本地联调),生产环境务必配置。
三组 baseUrl 可包含反向代理路径前缀,但不能包含 query 或 fragment。token 和 requestTimeoutMs 严格按服务隔离,不会互相回退。示例空 token 仅适合本地联调;生产配置 必须替换为独立凭证并限制文件权限。旧版扁平字段不再兼容。