集成契约:FreeSWITCH originate、通道变量与生命周期
本篇是 callout-server ↔ FreeSWITCH ↔ callflow-esl 之间的接口契约,是阅读外呼业务与转人工业务示例的前置。
1. callout-server 如何发起通话
callout-server 直连 FreeSWITCH inbound ESL(config.toml 的 cells[].freeswitch.host/port/password——每个 cell 是一台 FreeSWITCH 加配对的 callflow-esl, 同名顶层字段只用于填补 cell 里省略的项),对每个被叫提交一条后台 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(cells[].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 时业务据此调用 POST /api/scripts/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,多个用 | 分隔(如 mute 用于主管静默监听) |
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/contacts/pause暂停,调度器不再领取。
dialing/submitted 称为「活跃联系人」,参与并发计数。
4.4 任务完成判定
campaigns.status=completed 表示:无 pending、无可继续重试的 failed、无 dialing/submitted 活跃联系人。 它不等于全部成功;可能全失败重试耗尽。后台调度器只扫描 running 任务,completed 不再自动调度。 需要再处理用 POST /api/campaigns/retry 把联系人重置回 pending 并重新启动任务。
5. 并发模型
派发时按多个上限逐层收敛(活跃联系人 = dialing + submitted)。前两层是最常用到的:
- 任务并发
campaigns.maxConcurrency:单个任务内可同时活跃的联系人数。 - 线路并发
lines.maxConcurrency:共享同一线路的所有任务可同时活跃的联系人数。
另外两层来自部署与盯屏能力,配置得当时通常不是瓶颈:
- cell 并发
cells[].maxConcurrency:单台 FreeSWITCH 的活跃上限(0= 不限制)。 任务绑定了cellId只算该 cell,否则在全部 cell 里选剩余槽位最多的一台。 - 严格盯屏容量:仅当业务是
kb-audio-chat-business且businessConfig.monitoringMode为strict时生效,用当前在线盯屏坐席容量减去该 cell 上已活跃的严格盯屏通话;elastic(缺省)与unattended不占容量。
一次批次实际派发数量:
text
min(请求批次大小, 任务剩余槽位, sum(已绑定启用线路的剩余槽位), 选中 cell 剩余槽位, 严格盯屏剩余容量)线路内部还有一层号码池限制:lines.allocationMode 为 number_pool(多号多并发)或 bound_numbers(一号一并发)时,line_numbers.maxConcurrency 按号码单独扣减, 取不到可用号码时该线路本轮直接置零;single_shared(一号多并发,默认)只用线路默认主叫。
每个联系人选当前剩余槽位最多的线路(并列按线路 ID 升序)。主叫号码/名称/拨号模板完全用本次选中线路的配置。
多实例下用两层协调:Redis 锁(防重复进入派发流程;只有任务级锁 campaign:<id>,没有全局调度器锁, 每个实例都扫全部 running 任务,抢不到锁就跳过,天然分摊)+ PostgreSQL FOR UPDATE SKIP LOCKED 行锁(最终一致性,防同一联系人被重复占用)。 数据库锁刻意短生命周期,只覆盖批次创建/并发计算/联系人占用,originate 网络调用在事务提交之后才发生。
6. callflow-esl 侧需要的配置
业务回写/转人工路由以及流程脚本内大模型 NLU / Chat 的对接配置在 callflow-esl 的 config.toml 里(不是 callout-server):
toml
[callout]
baseUrl = "http://127.0.0.1:9920"
token = ""
requestTimeoutMs = 10000baseUrl:callout-server 的服务基址(默认http://127.0.0.1:9920);代码固定调用/api/call-results、/api/call-progress、/api/transfers/route、/api/transfers/end、POST /api/scripts/runtime,以及原生内嵌的 NLU(POST /api/ai/nlu/analyze)与流式 Chat(POST /api/ai/chat/stream)。token:会作为X-Callout-Internal-Token请求头发送;必须与 callout-server 的server.serviceApi.authToken(或CALLOUT_SERVICE_API_AUTH_TOKEN)一致。两端都为空时放行(仅本地联调),生产环境务必配置。requestTimeoutMs:HTTP 请求超时毫秒数(默认 10000)。
baseUrl 可包含反向代理路径前缀,但不能包含 query 或 fragment。示例空 token 仅适合本地联调;生产配置必须替换为独立凭证并限制文件权限。