跳到正文

集成契约:FreeSWITCH originate、通道变量与生命周期

本篇是 callout-server ↔ FreeSWITCH ↔ callflow-esl 之间的接口契约,是阅读外呼业务与转人工业务示例的前置。

1. callout-server 如何发起通话

callout-server 直连 FreeSWITCH inbound ESL(config.jsonfreeswitch.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-guestconference_idcallout_conference_role
坐席桥接呼出/回拨submitAgentBridgeCall坐席分机模板,接通后 &bridge('<客户拨号串>')callout_agent_call_record_idcallout_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外呼任务 IDoptions.campaignId
callout_contact_id联系人 IDcontact.id
script_id流程脚本 id(流程脚本模式必填):business_code=callout-flow-business 时业务据此拉取 GET /api/v1/scripts/:id/runtime 并逐节点执行前端把它作为字符串放进 campaigns.businessConfig.variables 下发
outbound_request_idFreeSWITCH 侧请求 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 等变量最后写入,优先级最高。
  • 流程脚本模式的接线约定:任务 businessCodecallout-flow-business,并在任务 businessConfig.variables 里放 script_id(字符串)——campaigns 表没有独立的 scriptId 列,脚本 id 就是靠这条通道变量下发的。runtime JSON 拉取走内部 token 鉴权(见 api-reference 「流程脚本」一节)。
  • 普通外呼不注入 callout_attempt_id;回写时用 callout_request_idcallout_campaign_id+callout_contact_id 定位 attempt 即可。

外呼身份与数据分离:导入时只携带号码 + 必要标识,客户业务数据(姓名、订单等)应由业务侧按号码/contactId 实时查询, 不随导入冗余携带。demo 业务里的 lookupCustomerProfileByPhone 就是这个查询点的占位(演示用号码做确定性派生)。

3. 坐席入会注入的通道变量

submitAgentConferenceCall 合成,用于 AI 转人工与坐席间流转、主管监听:

通道变量含义
business_code固定 agent-conference-guest(坐席入会业务)
conference_id坐席要加入的会议室 ID(客户已在或将进入该会议室)
callout_conference_roleagent / 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 / erroraccepted 仅表示 FreeSWITCH 接受了后台 originate,不代表接通

4.2 call_attempts.callStatus(通话层)

描述真实通话进程,由事件监听器 / 业务回写: ringing / answered / no_answer / busy / failed / completed / unknown

4.3 contacts.status(联系人层)

pendingdialingsubmittedcompleted / failed(另有 paused)。

  • pending:待派发。
  • dialing:已被一轮 dispatch 取走、正在提交 originate。
  • submitted:originate 已被 FreeSWITCH 接受,等待接通/结果回写。
  • completed / failed:由回写的 callStatus 推导(见 deriveContactStatus)。 completedcompletedno_answer / busy / failedfailed;否则保持 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 仅适合本地联调;生产配置 必须替换为独立凭证并限制文件权限。旧版扁平字段不再兼容。

文档与代码在同一仓库维护,现有 Markdown 是唯一内容源。