与 callout-server 协同交互与接口规范
callflow-esl 作为 FreeSWITCH 通话编排底座与语音 Runtime,与外呼调度运营及 AI 策略中台服务 callout-server 紧密协同。本文档系统梳理两服务间的架构拓扑、呼叫建立变量注入、Callout 协同 SDK 用法、上行业务接口契约(附 TypeScript 调用示例)、下行控制接口契约以及完整端到端外呼业务开发样板。
1. 架构总览与通信拓扑
1.1 系统职责划分
| 模块 | 系统定位 | 核心职责 |
|---|---|---|
callflow-esl | 系统一:呼叫编排底座 / 语音 Runtime | 接收 FreeSWITCH outbound socket 回连,负责流式 ASR / TTS 音频流转发、VAD 打断、双声道录音、会议室混音管理、流程图节点解释执行与通话生命周期管理。 |
callout-server | 系统二:外呼中台 / 坐席与 AI 策略中心 | 负责外呼活动(Campaign)与联系人调度、线路并发控制、坐席权限与软电话管理、实时盯屏监控、以及原生内嵌的 AI 策略中台(流程脚本发布、NLU 意图识别与槽位抽取、大模型流式 Chat)。 |
1.2 通信维度与交互全景
两服务之间通过四条主要通信通道协同:
1.3 鉴权与配置契约
- 上行调用鉴权(callflow-esl -> callout-server): 在
apps/callflow-esl/config.toml中配置[callout]段:toml[callout] baseUrl = "http://127.0.0.1:9920" token = "your-service-api-token" requestTimeoutMs = 10000callflow-esl发出请求时,如果token非空,将自动附加 HTTP 请求头:X-Callout-Internal-Token: <token>该 token 必须与callout-server的server.serviceApi.authToken(或环境变量CALLOUT_SERVICE_API_AUTH_TOKEN)一致。 - 下行调用鉴权(callout-server -> callflow-esl): 在
apps/callout-server/config.toml中配置[cells.callflowEsl]段:toml[cells.callflowEsl] baseUrl = "http://127.0.0.1:9912" token = "your-shared-esl-token"callout-server访问callflow-esl的内部控制接口时,附带 HTTP 请求头:token: <token>该 token 必须与callflow-esl的httpServer.authToken一致。
2. 呼叫建立与通道变量注入
callout-server 调度器通过 FreeSWITCH Inbound ESL 发起后台呼叫命令:
text
bgapi originate {var1=val1,var2=val2...}<dialString> &socket('<callbackHost>:<callbackPort> async full')- 接通后,FreeSWITCH 主动连接
callflow-esl监听的 Outbound ESL 端口(默认9911)。 - 呼叫上下文由花括号
{...}内的通道变量(Channel Variables)一次性注入。业务可通过 Callout SDK 统一提取。
2.1 通道变量字典
| 通道变量名 | 数据类型 | 注入场景 | 用途与消费逻辑 |
|---|---|---|---|
business_code | string | 全部外呼 | 业务路由键:callflow-esl 接通后优先根据此变量选择执行的业务处理器(如 callout-flow-business、agent-conference-guest)。 |
script_id | string (数字文本) | 流程脚本外呼 | 流程脚本 ID:callout-flow-business 接通后据此调用 POST /api/scripts/runtime 拉取流程定义。 |
callout_request_id | string (UUID) | 普通外呼 | 外呼请求 ID:单次外呼请求的全局唯一标识,结果回写时作为最高优先级的定位索引。 |
callout_campaign_id | string (数字文本) | 普通外呼 | 外呼任务 ID:关联外呼活动,回写结果与上报进度时携带。 |
callout_contact_id | string (数字文本) | 普通外呼 | 联系人 ID:关联具体外呼客户记录。 |
callout_attempt_id | string (数字文本) | 普通外呼 / 转人工 | 拨打尝试 ID:单次拨打尝试的数据库记录 ID(存在时透传)。 |
outbound_request_id | string (UUID) | 普通外呼 | FreeSWITCH 侧 originate 命令内部标识(主要用于全链路日志与排障关联)。 |
origination_caller_id_number | string | 全部外呼 | 主叫号码,由选中线路及号码池配置渲染透传。 |
origination_caller_id_name | string | 全部外呼 | 主叫名称显示。 |
conference_id | string | 坐席入会 / 转人工 | 目标会议室 ID:坐席入会业务(agent-conference-guest)据此直接进入客户所在会议室。 |
callout_conference_role | string | 坐席入会 | 成员角色:agent(常规坐席,全双工)或 supervisor(主管静默监听)。 |
callout_conference_flags | string | 坐席入会 | 会议属性 flags,以 | 分隔(如主管监听为 mute,不向会议室发声)。 |
callout_agent_call_record_id | string (数字文本) | 坐席入会 / 软电话 | 关联 agent_call_records 记录 ID,供事件回写接听与挂断状态。 |
callout_agent_id | string (数字文本) | 坐席入会 | 坐席工号/ID。 |
callout_direction | string | 坐席入会 | 腿方向标识(transfer / supervisor)。 |
call_timeout | string (数字文本) | 坐席入会 | 坐席分机振铃超时秒数,超时未接听则释放。 |
| 任务与联系人自定义变量 | string | 普通外呼 | 从任务 variables 与联系人 metadata 注入的字符串键值对,用于话术文本模板渲染(如 、)。 |
IMPORTANT
变量类型约束:FreeSWITCH originate 仅支持纯字符串键值对。variables 与 metadata 中非字符串字段(数字、布尔、嵌套对象)在注入前会被过滤掉;需要在话术中引用的变量请确保转换为字符串。
2.2 统一代码调用入口(Callout 协同 SDK)
本文档涉及的通道变量提取、Redis 模板变量加载、双声道录音启动、上行 HTTP 交互、转人工看门狗与实时盯屏上报,全部统一收拢在 src/business/shared/callout/index.ts 模块下:
context.ts:通道变量解析、Redis 初始变量读取、双声道录音启动、通话时长与客户识别文本提取;http-client.ts:拉取流程脚本、NLU 决策/抽取、Chat 流式 SSE、通话结果安全回写;transfer.ts:转人工 ACD 路由、会议多角色实时转写、两阶段坐席看门狗与一键转人工高层封装;live-session.ts:实时盯屏进度上报(postCallProgressSafely)、自动上报转写数组(createReportingTranscript)与进行中会话 Redis 注册表(liveCallSessionRegistry);client.ts:绑定BusinessContext的实例工厂createCalloutClient(ctx)。
支持两种完全等价的调用风格:
- 上下文绑定实例风格(推荐):在业务入口调用一次
const callout = createCalloutClient(ctx),后续方法调用免传ctx; - 独立纯函数风格:按需导入
readCalloutIdentity(ctx)、requestDecision(ctx, ...)、executeHumanTransfer(ctx, ...)等函数。
| 交互阶段 / 契约路径 | createCalloutClient(ctx) 实例方法 | 独立纯函数(src/business/shared/callout) | 说明 |
|---|---|---|---|
读取外呼核心通道变量(callout_* / script_id) | callout.readIdentity() | readCalloutIdentity(ctx) | 提取 { requestId, campaignId, contactId, attemptId, scriptId } |
读取坐席/主管入会腿通道变量(conference_id 等) | callout.readConferenceGuestIdentity() | readCalloutConferenceGuestIdentity(ctx) | 提取 { conferenceId, role, flags, agentCallRecordId, agentId, direction } |
加载 Redis 初始模板变量(callout:vars:{requestId}) | callout.loadVariables(requestId) | loadCalloutVariables(ctx, requestId) | 非阻塞安全读取任务/联系人预设变量,异常回退 {} |
| 启动外呼双声道分轨录音 | callout.startRecording(options) | startCalloutRecordingSafely(ctx, options) | 左声道客户 / 右声道 AI,失败仅记日志返回 null |
POST /api/scripts/runtime | callout.fetchRuntimeScript(scriptId) | fetchRuntimeScript(ctx, scriptId) | 拉取已发布的流程图 JSON 定义 |
POST /api/ai/nlu/analyze (action="decision") | callout.requestDecision(text, branches, profile) | requestDecision(ctx, text, branches, profile, options) | 多分支意图分类决策 |
POST /api/ai/nlu/analyze (action="extract") | callout.requestExtract(text, fields, profile) | requestExtract(ctx, text, fields, profile, options) | 结构化槽位抽取 |
POST /api/ai/chat/stream (SSE) | callout.requestChatStream(input, onDelta, signal) | requestChatStream(ctx, input, onDelta, signal) | 流式多轮大模型对话,实时回调 onDelta |
POST /api/transfers/route | callout.requestTransfer(request) | requestHumanAgentTransfer(ctx, request) | 请求 ACD 预占坐席并向坐席分机发起后台呼叫 |
| 看门狗入会监控 | callout.joinConferenceWithWatchdog(input) | joinConferenceWithAgentWatchdog(ctx, input) | 客户腿入会议室并启动两阶段坐席接听/在会看门狗 |
| 会议多角色实时转写 | callout.startTransferConferenceHear(input) | startTransferConferenceHear(ctx, input) | 区分 customer / agent / supervisor 追加转写并推流盯屏 |
| 转人工全流程组合封装(高层 API) | callout.executeTransfer(options) | executeHumanTransfer(ctx, options) | 一键串联 ACD 路由 + 会议级多角色转写 + 看门狗入会与清理 |
POST /api/transfers/end | callout.requestTransferEnd(request) | requestHumanAgentTransferEnd(ctx, request) | 客户挂机后通知 callout-server 批量收尾人工腿 |
POST /api/call-progress | callout.postProgress(event) | postCallProgressSafely(ctx, event) | 实时上报盯屏状态与分段转写(配合 createReportingTranscript) |
POST /api/call-results | callout.postResult(payload) | postCalloutResultSafely(ctx, payload) | 通话结束 finally 阶段非阻塞安全回写录音、转写与业务结果 |
上下文初始化代码示例(读取身份变量、模板变量与启动双声道录音)
ts
import { createCalloutClient } from "../shared/callout/index.ts";
const callout = createCalloutClient(ctx);
// 1. 一键读取外呼身份变量(直拨测试时各字段可能为 null)
const { requestId, campaignId, contactId, attemptId, scriptId } =
await callout.readIdentity();
// 2. 从 Redis 加载任务与联系人预设变量(如 customerName、balance 等),Redis 无数据或异常时安全返回 {}
const variables = await callout.loadVariables(requestId);
// 3. 启动双声道分轨录音(左声道客户、右声道机器人),录音失败仅记日志不中断通话
const recording = await callout.startRecording({
prefix: "my-outbound",
requestId
});如果是坐席入会/主管监听腿业务(如 agent-conference-guest-business),则调用 readConferenceGuestIdentity():
ts
const guest = await callout.readConferenceGuestIdentity();
// guest.conferenceId -> "transfer-xxx"
// guest.role -> "agent" | "supervisor"
// guest.flags -> ["mute"] (主管静默监听时自动解析为数组)3. 上行业务接口契约与代码示例(callflow-esl -> callout-server)
callflow-esl 的上行请求模块(src/business/shared/callout/http-client.ts,路径常量定义于 src/config/callout-api.ts)统一遵守以下契约:
- 请求头:自动携带
Content-Type: application/json、X-Callout-Internal-Token、X-Call-UUID(通话 UUID)、X-Request-ID(追踪 ID)。 - 统一响应信封:除 SSE 接口外,所有 JSON 接口均遵循
{ "code": "0000", "data": ..., "msg": "..." }协议规范。非0000视为业务错误。 - 超时与熔断:请求均绑定
requestTimeoutMs超时中止信号(AbortSignal)。
3.1 拉取流程脚本运行时定义
- 路径:
POST /api/scripts/runtime - SDK 函数:
callout.fetchRuntimeScript(scriptId)/fetchRuntimeScript(ctx, scriptId) - 调用时机:
callout-flow-business接通后,在开始执行话术前根据通道变量script_id拉取。 - 请求体:
{ "scriptId": 12 }(正整数或非空字符串)。 - 响应体
data结构:json{ "id": 12, "name": "信用卡分期外呼", "description": "2026年营销流程", "flow_json": { "nodes": [ { "id": "node_start", "type": "start" }, { "id": "node_greeting", "type": "send", "data": { "text": "您好,这里是xx银行", "interruptible": true } }, { "id": "node_collect_intent", "type": "router", "data": { "prompt": "请问您最近有大额消费分期需求吗?", "decision_profile": "credit_card_intent", "branches": [ { "key": "agree", "desc": "表示同意或需要" }, { "key": "reject", "desc": "明确拒绝" }, { "key": "fallback", "desc": "答非所问或未识别" } ] } }, { "id": "node_transfer", "type": "transfer", "data": { "prompt": "正在为您转接人工专属客服,请稍候", "ring_timeout_seconds": 30 } }, { "id": "node_end", "type": "end", "data": { "text": "感谢您的接听,祝您生活愉快,再见!" } } ], "edges": [ { "source": "node_start", "target": "node_greeting" }, { "source": "node_greeting", "target": "node_collect_intent" }, { "source": "node_collect_intent", "sourceHandle": "agree", "target": "node_transfer" }, { "source": "node_collect_intent", "sourceHandle": "reject", "target": "node_end" }, { "source": "node_collect_intent", "sourceHandle": "fallback", "target": "node_end" } ] } }
TypeScript 调用示例:
ts
if (!scriptId) {
throw new Error("缺少通道变量 script_id");
}
const script = await callout.fetchRuntimeScript(scriptId);
ctx.log("script_loaded", {
scriptId: script.id,
scriptName: script.name
});3.2 NLU 语义决策与槽位抽取
- 路径:
POST /api/ai/nlu/analyze - SDK 函数:
- 分支意图决策:
callout.requestDecision(text, branches, profile?, options?)/requestDecision(ctx, ...) - 结构化槽位抽取:
callout.requestExtract(text, fields, profile?, options?)/requestExtract(ctx, ...)
- 分支意图决策:
(1) 分支决策请求(action="decision")
- 请求体:json
{ "action": "decision", "profile": "credit_card_intent", "text": "行啊,你们分期手续费是多少", "branches": [ { "key": "agree", "desc": "客户同意办理分期" }, { "key": "inquiry_fee", "desc": "询问费率或手续费" }, { "key": "reject", "desc": "客户拒绝办理" }, { "key": "fallback", "desc": "兜底分支" } ], "systemPrompt": "可选覆盖的系统提示词", "temperature": 0.1 } - 响应体
data:json{ "key": "inquiry_fee", "confidence": 0.95, "reason": "客户表达了对分期手续费的询问" }callflow-esl校验逻辑:返回的key必须严格包含在branches的候选列表中;当confidence <= 0.5或理由包含“未识别/兜底/超时”时,自动记录callout_flow_decision_fallback告警日志。
TypeScript 调用示例(意图分支路由):
ts
const decision = await callout.requestDecision(
customerText,
[
{ key: "agree", desc: "客户同意办理或表示感兴趣" },
{ key: "inquiry_fee", desc: "客户询问费率、利息或手续费" },
{ key: "reject", desc: "客户明确拒绝或不需要" },
{ key: "fallback", desc: "答非所问或意图不明确" }
],
"credit_card_intent", // 可选:对应 callout-server 配置的策略 profile
{
temperature: 0.1 // 可选:覆盖推理温度或 systemPrompt / requestId
}
);
switch (decision.key) {
case "agree":
// 进入办理或转人工逻辑
break;
case "inquiry_fee":
// 播报费率说明
break;
case "reject":
default:
// 播报结束语并挂机
break;
}(2) 槽位抽取请求(action="extract")
- 请求体:json
{ "action": "extract", "profile": "customer_info_extract", "text": "我打算分12期,每个月还2000左右吧", "fields": [ { "key": "installment_terms", "desc": "分期期数", "type": "number", "required": true }, { "key": "monthly_budget", "desc": "月还款预算", "type": "number", "required": false } ] } - 响应体
data:json{ "fields": { "installment_terms": 12, "monthly_budget": 2000 }, "confidence": 0.92, "complete": true }
TypeScript 调用示例(多字段槽位抽取):
ts
const extractResult = await callout.requestExtract(
customerText,
[
{
key: "installment_terms",
desc: "分期期数(如 3、6、12、24)",
type: "number",
required: true
},
{
key: "monthly_budget",
desc: "每月还款预算金额(元)",
type: "number",
required: false
}
],
"customer_info_extract"
);
if (extractResult.complete) {
// 必填槽位均已集齐,合并到会话变量表
Object.assign(variables, extractResult.fields);
}3.3 大模型多轮流式对话(SSE)
- 路径:
POST /api/ai/chat/stream - SDK 函数:
callout.requestChatStream(input, onDelta, signal?)/requestChatStream(ctx, input, onDelta, signal?) - 请求头:
Accept: text/event-stream - 调用时机:流程图执行到
chat自由问答节点或业务需要调用中台托管大模型流式对话时。 - 请求体:json
{ "profile": "general_sales_chat", "systemPrompt": "你是一名银行客服代表...", "exitPrompt": "判断对话是否可以结束...", "messages": [ { "role": "system", "content": "..." }, { "role": "user", "content": "那如果我提前还款怎么收费?" } ], "branches": [ { "key": "continue", "desc": "继续常规问答" }, { "key": "apply", "desc": "客户明确提出要申请" }, { "key": "exit", "desc": "客户表示不再需要并结束通话" } ], "turn": 1 } - SSE 事件流协议:
event: delta:增量吐字,触发onDelta(delta)回调。textevent: delta data: {"delta": "如果提前还款,"}event: complete:对话回合结束,作为requestChatStream的 Promise 返回值返回完整应答与分支判定。textevent: complete data: { "reply": "如果提前还款,我们免收后续分期的手续费,仅收取小额违约金。", "outcome": "continue", "usage": { "promptTokens": 120, "completionTokens": 32 } }event: error:大模型或服务端异常,SDK 自动抛出Error供业务捕获降级。
TypeScript 调用示例(流式接收 + 支持客户插话 Abort):
ts
const abortController = new AbortController();
const chatResult = await callout.requestChatStream(
{
profile: "general_sales_chat",
systemPrompt: "你是一名专业温和的银行信用卡客服代表,回答控制在50字以内。",
exitPrompt: "当客户明确表示立即申请(apply)或不再需要(exit)时结束对话。",
messages: [
{ role: "user", content: "如果我提前还款怎么收费?" }
],
branches: [
{ key: "continue", desc: "继续常规问答" },
{ key: "apply", desc: "客户明确提出立即申请办理" },
{ key: "exit", desc: "客户表示不需要或希望结束通话" }
],
turn: 1
},
delta => {
// 每收到一段增量文本立即回调,可送入 StreamingSentenceBuffer 切句播报
sentenceBuffer.push(delta);
},
abortController.signal
);
// chatResult.reply -> 完整回复文本
// chatResult.outcome -> "continue" | "apply" | "exit"3.4 AI 转人工 ACD 路由与看门狗监控
- 路径:
POST /api/transfers/route - 模块实现:
src/business/shared/callout/transfer.ts - 请求体:json
{ "conferenceId": "transfer-d75acc71-4043-4abd-b1a2-bc6593ac59fc", "requestId": "callout-req-1234", "campaignId": 10, "contactId": 502, "attemptId": 8012, "customerNumber": "13800138000", "customerCallUuid": "fs-channel-uuid-customer", "ringTimeoutSeconds": 30, "agentId": null } - 响应体
data:json{ "routed": true, "reason": "agent_originated", "conferenceId": "transfer-d75acc71-4043-4abd-b1a2-bc6593ac59fc", "agent": { "id": 101, "name": "张客服", "extension": "1001" }, "softphoneCallId": 2345, "ringTimeoutSeconds": 30 }
方式一:高层一键转人工 callout.executeTransfer(推荐)
绝大多数外呼业务只需调用 callout.executeTransfer(options)(或纯函数 executeHumanTransfer(ctx, options)),它会在内部自动串联:
- 调用
POST /api/transfers/route预占空闲坐席(或指定agentId)并后台呼叫坐席分机; - 若
routed === true,触发onRouted钩子,并开启会议室多角色(客户/坐席/主管)实时 ASR 转写; - 将客户腿移入 FreeSWITCH 会议室并启动两阶段看门狗(阶段一:监控坐席是否在
ringTimeoutSeconds内接听入会;阶段二:监控坐席离会后自动退会,避免客户被独留在空会议室); - 会议结束后自动关闭会议级转写。
ts
// 播报转接提示音后,一键执行转人工完整闭环
await speakBot("transfer-prompt", "正在为您转接专属人工客服,请稍候。");
const transfer = await callout.executeTransfer({
requestId,
campaignId,
contactId,
attemptId,
ringTimeoutSeconds: 30,
// 传入 transcript 与 relMs,自动将会议室里客户和坐席的对话追加到 transcript(并实时推流盯屏)
transcribe: {
transcript,
relMs
},
onRouted: async (_route, targetConferenceId) => {
// 路由成功后记录会议 ID(供 finally 阶段兜底调用 requestTransferEnd)
activeConferenceId = targetConferenceId;
// 若当前开启了客户单呼 ASR,需在进入会议前停止,让位于会议级多角色转写
await customerAsrSession?.stop("enter-transfer-conference").catch(() => undefined);
}
});
if (!transfer.agentJoined && !transfer.hungUp) {
// 坐席全忙或振铃超时未接听,客户已被看门狗安全移出会议室,可播报安抚话术
await speakBot("transfer-busy", "抱歉,当前人工坐席全忙,稍后我们将安排专员回电给您。");
}方式二:细粒度分步调用(适用于坐席强插介入失败需回滚恢复 AI 的场景)
当业务需要在坐席振铃超时后无缝恢复客户单呼 ASR 并继续由 AI 对话(如 kb-llm-chat-business 中的盯屏坐席强插介入)时,可分步调用底层函数:
ts
const route = await callout.requestTransfer({
conferenceId,
requestId,
campaignId,
contactId,
attemptId,
customerNumber: ctx.channel.destinationNumber ?? null,
customerCallUuid: ctx.channel.uuid,
ringTimeoutSeconds: 30,
agentId: interventionRequest.agentId // 指定预占点击“介入”的那位坐席
});
if (route.routed) {
const targetConferenceId = route.conferenceId ?? conferenceId;
await customerAsrSession?.stop("intervention-transfer").catch(() => undefined);
// 开启会议多角色转写
const confHear = await callout.startTransferConferenceHear({
conferenceId: targetConferenceId,
customerCallUuid: ctx.channel.uuid,
transcript,
relMs
});
// 客户腿入会并等待看门狗结果
const watch = await callout.joinConferenceWithWatchdog({
conferenceId: targetConferenceId,
ringTimeoutSeconds: route.ringTimeoutSeconds ?? 30
});
if (!watch.agentJoined && !watch.hungUp) {
// 坐席超时未入会:停止会议转写,重启客户单呼 ASR 恢复 AI 接待
await confHear.stop("agent-join-timeout");
customerAsrSession = await startCustomerAsr();
}
}3.5 客户挂机收尾会议人工腿
- 路径:
POST /api/transfers/end - SDK 函数:
callout.requestTransferEnd(request)/requestHumanAgentTransferEnd(ctx, request) - 调用时机:客户通道挂断(
finally阶段),通知callout-server挂断仍在振铃或仍在会议中的坐席/主管分机腿。 - 请求体:json
{ "conferenceId": "transfer-d75acc71-4043-4abd-b1a2-bc6593ac59fc", "customerCallUuid": "fs-channel-uuid-customer", "reason": "customer_hangup" }
TypeScript 调用示例(在 finally 中安全收尾):
ts
if (activeConferenceId) {
await callout.requestTransferEnd({
conferenceId: activeConferenceId,
customerCallUuid: ctx.channel.uuid,
reason: "customer_hangup"
});
}3.6 通话结果安全回写
- 路径:
POST /api/call-results - SDK 函数:
callout.postResult(payload)/postCalloutResultSafely(ctx, payload) - 调用时机:通话结束的
finally阶段。该调用具备非阻塞容错保护(缺少requestId时自动跳过,网络或对端错误仅记结构化日志,绝不向外抛错打断通道释放)。 - 请求体结构:json
{ "requestId": "callout-req-1234", "campaignId": 10, "contactId": 502, "status": "completed", "durationSeconds": 45, "hangupCause": "NORMAL_CLEARING", "recordingShortPath": "2026-09-25/call-1234.wav", "recordingUrl": "/recordings/2026-09-25/call-1234.wav", "recognizedText": "我想了解信用卡分期...", "payload": { "result": { "scriptId": 12, "finalNodeId": "node_end", "variables": { "installment_terms": 12, "user_intent": "agree" }, "transcript": [ { "speaker": "bot", "startMs": 0, "endMs": 2500, "text": "您好,这里是xx银行" }, { "speaker": "customer", "startMs": 3100, "endMs": 6200, "text": "我想了解分期" } ], "routeTrace": [ { "step": 0, "nodeId": "node_start", "type": "start" }, { "step": 1, "nodeId": "node_greeting", "type": "send" } ], "error": null } } }
TypeScript 调用示例:
ts
import {
extractCustomerRecognizedText,
inferDurationSeconds
} from "../shared/callout/index.ts";
const endedAt = new Date();
const durationSeconds = inferDurationSeconds(answeredAt, endedAt);
// 仅提取 speaker === "customer" 的有效发言拼接文本,供质检与文本情绪分析消费
const recognizedText = extractCustomerRecognizedText(transcript);
await callout.postResult({
...(requestId ? { requestId } : {}),
...(campaignId ? { campaignId } : {}),
...(contactId ? { contactId } : {}),
callStatus: outcome === "completed" ? "completed" : "failed",
hangupCause: resultHangupCause,
answeredAt: answeredAt.toISOString(),
endedAt: endedAt.toISOString(),
durationSeconds,
...(recording?.fileName
? {
recordingUrl: recording.fileName,
recordingDurationSeconds: durationSeconds,
recordingMimeType: "audio/wav"
}
: {}),
transcript,
payload: {
result: {
type: "my_custom_business",
outcome,
errorMessage,
variables,
recognizedText: recognizedText || null
}
}
});3.7 实时盯屏进度、分段转写上报与反向控制
- 路径:
POST /api/call-progress - 模块实现:
src/business/shared/callout/live-session.ts - 调用时机:通话接通(
"started")、每轮对话产生("segment")、人工介入状态变更("intervention")以及通话结束("ended")时实时触发。 - 请求体:json
{ "type": "segment", "requestId": "callout-req-1234", "campaignId": 10, "contactId": 502, "segment": { "speaker": "customer", "startMs": 3100, "endMs": 6200, "text": "手续费怎么算呢?" }, "timestamp": "2026-09-25T09:30:15.123Z" }
实时盯屏与反向控制完整代码示例
ts
import {
createReportingTranscript,
liveCallSessionRegistry,
postCallProgressSafely,
type CallProgressEvent,
type LiveTranscriptSegment
} from "../shared/callout/index.ts";
// 1. 构造进度事件工厂函数(自动补齐当前通话身份与时间戳)
const progressEvent = (
type: CallProgressEvent["type"],
extra: Partial<CallProgressEvent> = {}
): CallProgressEvent => ({
type,
...(attemptId ? { attemptId } : {}),
...(requestId ? { requestId } : {}),
...(campaignId ? { campaignId } : {}),
...(contactId ? { contactId } : {}),
customerNumber: ctx.channel.destinationNumber ?? null,
scriptId: null,
timestamp: new Date().toISOString(),
...extra
});
// 2. 在分布式 Redis 中注册实时会话,并异步非阻塞推送 "started" 事件
const liveSession = await liveCallSessionRegistry.register(
{
attemptId: attemptId ?? null,
requestId: requestId ?? null,
campaignId: campaignId ?? null,
contactId: contactId ?? null
},
ctx.channel.uuid
);
void postCallProgressSafely(ctx, progressEvent("started"));
// 3. 创建带“自动实时上报”钩子的转写数组:
// 每次执行 transcript.push(segment) 时,既会立即向盯屏推送 "segment" 事件,
// 又会把片段保存在本地数组中供挂机 finally 阶段完整回写 /api/call-results。
const transcript: LiveTranscriptSegment[] = createReportingTranscript(segment => {
void postCallProgressSafely(ctx, progressEvent("segment", { segment }));
});
// 4. 【最佳实践】机器人播报封装:开播即 push 实时回显,播完原地回填 endMs
const speakBot = async (phase: string, text: string): Promise<void> => {
const startMs = relMs();
const segment: LiveTranscriptSegment = {
speaker: "bot",
startMs,
endMs: startMs,
text,
metadata: { phase }
};
// 在 await ctx.speak 之前 push:确保机器人一开口前端盯屏就显示字幕,且客户中途插话时字幕顺序不倒置
transcript.push(segment);
try {
await ctx.speak({ kind: "tts", text, interruptible: true });
// 播报结束后原地更新对象引用上的 endMs,保证挂机落库时拥有真实播报时长
segment.endMs = relMs();
} catch (error) {
// 播报异常时从本地数组中移除该未播出的片段
const idx = transcript.indexOf(segment);
if (idx !== -1) transcript.splice(idx, 1);
ctx.log("speak_bot_failed", {
phase,
error: error instanceof Error ? error.message : String(error)
});
}
};4. 下行控制接口契约(callout-server -> callflow-esl)
callout-server 及其代理前端会根据通话所在的 Cell 地址,直接调用 callflow-esl 暴露的控制面接口(默认端口 9912,请求头携带 token: <token>):
| 接口方法与路径 | 请求体关键字段 | 作用与 ESL 内部执行行为 |
|---|---|---|
GET /health | 无 | 探活与容量感知:返回 { "code": "0000", "data": { "activeSessions": 8 }, "msg": "请求成功" },供 callout-server 派发调度器评估当前节点的剩余并发槽位。 |
POST /live-call-sessions/intervene | { "sessionKey": "...", "agentId": 101, "agentName": "..." } | 人工介入强插:坐席在盯屏页点击“介入”,向该通话注入强插介入信号,触发业务将客户从当前话术转入人工会议室。 |
POST /live-call-sessions/intervention/cancel | { "sessionKey": "...", "agentId": 101 } | 取消待处理介入:坐席取消尚未被业务消费的介入请求;若业务已进入转接则返回 HTTP 409 冲突。 |
POST /live-call-sessions/hangup | { "sessionKey": "..." } | 主管强拆挂断:下发 NORMAL_CLEARING 挂机信号,由持有客户通道的业务实例执行强制挂断并触发正常收尾。 |
POST /softphone-sessions/create | { "targetNumber": "...", "serviceNumber": "..." } | 创建浏览器软电话会话:初始化 BrowserSoftphoneSessionHub 实例,分配会议并管理 WebRTC/SIP 通道。 |
GET /softphone-sessions/events | SSE 请求(Query 参数 ?sessionId=xxx) | 软电话实时事件流:向前端代理推送转写、TTS 状态与通道状态变更事件。 |
POST /softphone-sessions/messages | { "sessionId": "...", "text": "..." } | 下发文本与 TTS:坐席在工作台键入文本,驱动 ESL 执行 TTS 合成并向客户通道混音播放。 |
POST /softphone-sessions/controls | { "sessionId": "...", "type": "hold" } | 坐席会话控制:执行静音、解静音、踢出成员等会控操作。 |
POST /softphone-sessions/end | { "sessionId": "...", "reason": "..." } | 销毁软电话会话:挂断双方通话并释放软电话会话内存。 |
5. 完整端到端外呼业务开发样板
以下提供一份可直接复制使用的自定义外呼业务完整样板,串联了接通应答、身份与 Redis 变量读取、实时盯屏注册、双声道录音、开播即回显的 speakBot、NLU 意图决策、一键转人工、主管强拆轮询以及 finally 安全回写:
ts
import { defineBusiness } from "../../types/business.ts";
import {
createCalloutClient,
createReportingTranscript,
extractCustomerRecognizedText,
inferDurationSeconds,
liveCallSessionRegistry,
type CallProgressEvent,
type LiveTranscriptSegment
} from "../shared/callout/index.ts";
export const customOutboundBusiness = defineBusiness({
code: "custom-outbound-business",
category: "outbound",
desc: "自定义外呼业务标准样板(含盯屏上报、NLU 决策、一键转人工与结果回写)",
handler: async ctx => {
// 1. 应答呼叫并等待媒体稳定
await ctx.execute("answer");
await ctx.execute("sleep", String(ctx.config["esl-server"].settleDelayMs));
const answeredAt = new Date();
const answeredAtMs = answeredAt.getTime();
const relMs = (): number => Math.max(0, Date.now() - answeredAtMs);
// 2. 初始化 Callout 协同客户端,读取身份变量与 Redis 预设变量
const callout = createCalloutClient(ctx);
const { requestId, campaignId, contactId, attemptId } =
await callout.readIdentity();
const variables = await callout.loadVariables(requestId);
// 3. 注册实时盯屏会话并创建自动推流 transcript 数组
const progressEvent = (
type: CallProgressEvent["type"],
extra: Partial<CallProgressEvent> = {}
): CallProgressEvent => ({
type,
...(attemptId ? { attemptId } : {}),
...(requestId ? { requestId } : {}),
...(campaignId ? { campaignId } : {}),
...(contactId ? { contactId } : {}),
customerNumber: ctx.channel.destinationNumber ?? null,
scriptId: null,
timestamp: new Date().toISOString(),
...extra
});
const liveSession = await liveCallSessionRegistry.register(
{
attemptId: attemptId ?? null,
requestId: requestId ?? null,
campaignId: campaignId ?? null,
contactId: contactId ?? null
},
ctx.channel.uuid
);
void callout.postProgress(progressEvent("started"));
const transcript: LiveTranscriptSegment[] = createReportingTranscript(
segment => {
void callout.postProgress(progressEvent("segment", { segment }));
}
);
// 4. 启动双声道分轨录音
const recording = await callout.startRecording({
prefix: "custom-outbound",
requestId
});
let outcome: "completed" | "failed" = "completed";
let errorMessage: string | null = null;
let resultHangupCause = "NORMAL_CLEARING";
let hangupIssued = false;
let activeConferenceId: string | null = null;
// 5. 封装机器人播报(开播即 push 实时回显,播完原地更新 endMs)
const speakBot = async (phase: string, text: string): Promise<void> => {
const startMs = relMs();
const segment: LiveTranscriptSegment = {
speaker: "bot",
startMs,
endMs: startMs,
text,
metadata: { phase }
};
transcript.push(segment);
try {
await ctx.speak({ kind: "tts", text, interruptible: true });
segment.endMs = relMs();
} catch (error) {
const idx = transcript.indexOf(segment);
if (idx !== -1) transcript.splice(idx, 1);
ctx.log("custom_outbound_speak_failed", {
phase,
error: error instanceof Error ? error.message : String(error)
});
}
};
// 6. 启动后台定时轮询:响应运营台下发的「主管强拆挂断」信号
const controlTimer = setInterval(() => {
if (hangupIssued) return;
void (async () => {
const hangup = await liveCallSessionRegistry.consumeHangup(liveSession);
if (hangup && !ctx.getHangupInfo()) {
hangupIssued = true;
resultHangupCause = hangup.cause;
await ctx.hangup(hangup.cause);
}
})().catch(() => undefined);
}, 1000);
try {
// 7. 播报开场白并听取客户回答
const customerName =
typeof variables.name === "string" ? variables.name : "先生/女士";
await speakBot(
"opening",
`您好${customerName},这里是客服中心,请问您现在方便接听吗?`
);
const hearStartMs = relMs();
const hearResult = await ctx.hear({
noSpeechTimeoutMs: 10000,
noTextTimeoutMs: 15000
});
const customerText = hearResult.text.trim();
if (customerText) {
transcript.push({
speaker: "customer",
startMs: hearStartMs,
endMs: relMs(),
text: customerText,
metadata: { status: hearResult.status }
});
// 8. 调用 NLU 意图决策
const decision = await callout.requestDecision(customerText, [
{ key: "agree", desc: "方便接听或同意进一步沟通" },
{ key: "transfer", desc: "要求转人工客服" },
{ key: "reject", desc: "不方便或拒绝" },
{ key: "fallback", desc: "其他未明确意图" }
]);
if (decision.key === "transfer" || decision.key === "agree") {
await speakBot("transfer", "正在为您转接专属顾问,请稍候。");
const transfer = await callout.executeTransfer({
requestId,
campaignId,
contactId,
attemptId,
ringTimeoutSeconds: 30,
transcribe: { transcript, relMs },
onRouted: (_route, targetConferenceId) => {
activeConferenceId = targetConferenceId;
}
});
if (!transfer.agentJoined && !transfer.hungUp) {
await speakBot("busy", "抱歉,当前坐席全忙,稍后将安排专员回电。");
}
} else {
await speakBot("goodbye", "打扰您了,祝您生活愉快,再见。");
}
}
} catch (error) {
outcome = "failed";
errorMessage = error instanceof Error ? error.message : String(error);
ctx.log("custom_outbound_failed", { error: errorMessage });
} finally {
// 9. 终态清理与安全回写(无论正常结束或异常中断必然执行)
clearInterval(controlTimer);
await liveCallSessionRegistry
.unregister(liveSession)
.catch(() => undefined);
void callout.postProgress(progressEvent("ended", { outcome }));
if (activeConferenceId) {
await callout.requestTransferEnd({
conferenceId: activeConferenceId,
customerCallUuid: ctx.channel.uuid,
reason: "customer_hangup"
});
}
const endedAt = new Date();
const durationSeconds = inferDurationSeconds(answeredAt, endedAt);
const recognizedText = extractCustomerRecognizedText(transcript);
await callout.postResult({
...(requestId ? { requestId } : {}),
...(campaignId ? { campaignId } : {}),
...(contactId ? { contactId } : {}),
callStatus: outcome === "completed" ? "completed" : "failed",
hangupCause: resultHangupCause,
answeredAt: answeredAt.toISOString(),
endedAt: endedAt.toISOString(),
durationSeconds,
...(recording?.fileName
? {
recordingUrl: recording.fileName,
recordingDurationSeconds: durationSeconds,
recordingMimeType: "audio/wav"
}
: {}),
transcript,
payload: {
result: {
type: "custom_outbound",
outcome,
errorMessage,
variables,
recognizedText: recognizedText || null
}
}
});
if (!hangupIssued) {
await ctx.hangup();
}
}
}
});6. 分布式协同与容灾兜底策略
- 结果回写兜底(No Call Trapping):
- 结果回写(
postCalloutResultSafely/callout.postResult)位于通话finally块中。缺少requestId或网络超时不抛出异常,防止干扰 FreeSWITCH 通道 hangup 与录音文件闭环。
- 结果回写(
- 看门狗防孤儿会话(Watchdog & Auto-cleanup):
- 客户转人工时,若坐席分机呼叫超时(默认 30 秒)未应答,看门狗触发自动退会,向客户播报“坐席全忙”并安全挂机,杜绝客户被孤立在静音会议室的现象。
- 客户率先挂机时,ESL 异步触发
POST /api/transfers/end(callout.requestTransferEnd),通知callout-server挂断残留的坐席/主管分机腿。
- 流程节点异常降级(Graceful Fallback):
- 当 NLU(
decision/extract)或 Chat 服务不可用时,系统自动捕捉异常并导向节点配置的fallback分支;若未显式配置连线,则进入优雅播报收尾,避免挂死呼叫。
- 当 NLU(
- 共享状态生命周期(Redis TTL):
- 盯屏快照与会话注册表全部赋予 Redis TTL;即便进程意外终止,快照也会在设定的 TTL 毫秒后自动消除,防止产生“幽灵通话”。