外呼业务代码示例
业务代码不在 callout-server,而在
apps/callflow-esl。 callout-server 只负责把电话拨通并 注入business_code通道变量;接通后 FreeSWITCH 把通道交给 callflow-esl,由business_code选中一个业务 handler 来真正「说话/听话/录音/转人工/回写结果」。本篇讲怎么写这个 handler。
TIP
外呼业务模式选型:
- 可视化流程脚本模式(推荐,绝大部分外呼场景):任务
businessCode选callout-flow-business,业务人员直接在前端系统二「流程脚本」中可视化拖拽节点编排话术、意图分支与大模型对话,开发者零代码编写,由平台通用解释器自动处理全生命周期的 NLU、Chat、转人工与结果回写。 - 定制代码开发模式(本文档核心):仅在需要手写全流程高度定制的状态机(如复杂电销业务护栏、特定游戏/测试逻辑)时,才需按本文指导在
apps/callflow-esl中编写纯 TypeScript 代码。
阅读前请先读 integration.md(注入的通道变量、生命周期)。
1. 业务是什么
一个业务就是一个 (ctx) => Promise<void> 处理函数,用 defineBusiness 注册稳定编码:
ts
import { defineBusiness } from "../types/business.ts";
export const myOutboundBusiness = defineBusiness({
code: "my-outbound-business", // 全局唯一且不可变,= 任务的 businessCode
category: "outbound", // 主场景分类,只用于展示/分组
desc: "我的外呼业务", // 中文名,同步到 call_businesses.businessName
handler: async ctx => {
/* ... */
}
});新增业务的 3 步
- 在
apps/callflow-esl/src/business/建文件,用defineBusiness定义。 - 在
src/business/index.ts里 import 并追加到coreBusinesses(演示/测试业务则改为追加到src/business/demo/index.ts的demoBusinesses)。src/business/index.ts是唯一注册权威,src/runtime/business-registry.ts只消费它导出的allBusinesses,新增业务不需要改它。 - 启动服务 —— 启动时自动把
code/category/desc同步进callflow.call_businesses。
然后在 callout-server 建任务时,把 businessCode 选成 my-outbound-business 即可。
两个易踩的坑
- 不自动接听:交互业务必须显式
await ctx.execute("answer")。 - 不要在 catch 里清理资源:audio_fork / playback / 会议 / 挂机由
SessionRunner在挂机/断开时兜底, 业务无需在catch里重复处理。
2. ctx(CallContext)能力速查
业务只接触 ctx 这个 facade(定义见 apps/callflow-esl/src/runtime/runtime-context.ts),不碰 runtime 内部:
| 能力 | 说明 |
|---|---|
ctx.channel | 当前通道信息:uuid、destinationNumber、callerIdNumber、businessCode、variables 等 |
ctx.config | 全局配置(config.toml),含 ctx.config.callout.baseUrl / token 等回写配置 |
ctx.execute(app, arg?) | 阻塞执行任意 dialplan application(answer/sleep/playback...) |
ctx.api(cmd) | 执行任意 FreeSWITCH API 命令(如 conference <id> count),返回应答正文 |
ctx.speak(input, handlers?) | 播放 TTS 文本或 wav;支持 interruptible(barge-in)、repeat;返回 SpeakResult(含 status/interruptedBy) |
ctx.hear(opts, handlers?) | 启动一轮 ASR(可携带 prompt 并发播放、识别期 barge-in) |
ctx.callHear(opts, handlers?) | 启动单呼持续 ASR:会话期间常驻 audio_fork,回调持续推 partial/final |
ctx.cancelHear() / ctx.stopCallHear() | 取消/停止识别 |
ctx.startRecording({ fileName }) | 启动录音,返回 { filePath, fileName, fileUrl } |
ctx.onDtmf(cb) | 订阅 DTMF 按键,返回取消函数;ctx.sendDtmf(d) 发送按键 |
ctx.conference.join({ conferenceId, flags? }) | 把当前通道加入会议室,阻塞至退出 |
ctx.conference.play({ conferenceId, input }) | 向会议室播放语音/媒体 |
ctx.conference.getMembers() / injectSpeak / publishMessage / onMessage | 会议成员/注入播报/会内消息 |
ctx.originate / bridgeToNumber / originateAndBridge / originateParkedCall / bridgeCall | 外呼/桥接编排原语 |
ctx.getVariable(name) / getVariables | 读通道变量(命中缓存不触发 ESL IO) |
ctx.setVariable / setVariables | 写通道变量 |
ctx.waitForHangup() | 阻塞等待挂机;交互业务在最后一步调用,避免提前关 socket |
ctx.hangup(cause) | 主动挂断 |
ctx.log(event, fields) | 结构化日志(自动带 callUuid/sessionStage) |
3. ESL ↔ callout-server 接口契约全景
在完整的通话生命周期中,apps/callflow-esl 作为呼叫执行端,向 apps/callout-server(外呼中台与内置 AI 策略中心)发起的全部 HTTP 接口契约如下:
| 接口路径 | 方法 | 主要调用方 | 调用时机与业务作用 | 详细契约专题 |
|---|---|---|---|---|
/api/call-results | POST | 手写外呼业务 / callout-flow-business | 通话结果最终回写:挂机后回写呼叫状态、录音文件、时长、转写时间线(transcript)与结构化业务数据(payload.result)。 | 结果回写约定 |
/api/call-progress | POST | business/shared/callout/live-session.ts | 实时盯屏进度上报:通话中尽力而为(best-effort)上报逐轮对话片段与生命周期事件,供前端盯屏多角色推演。 | 接口参考 |
/api/scripts/runtime | POST | callout-flow-business | 流程脚本运行时拉取:接通后根据通道变量 script_id 拉取可视化流程脚本完整图结构。 | 接口参考 |
/api/ai/nlu/analyze | POST | callout-flow-business | NLU 意图判断与字段抽取:将客户回答发给中台匹配分支路由意图或提取槽位字段。 | 流程脚本 NLU 开发 |
/api/ai/chat/stream | POST | callout-flow-business/chat-node.ts | Chat 大模型流式对话:流程进入 chat 节点时发起 SSE 流式对话,配合本地 TTS 实时吐字播报。 | 流程脚本 Chat 开发 |
/api/ai/chat/completions | POST | business/kb-audio-chat-business | 独立问答对话策略:知识库录音对话业务执行确定性开场引导语获取或逐轮问答查询。 | 接口参考 |
/api/transfers/route | POST | business/shared/callout/transfer.ts | 转人工坐席预占与呼出:AI 转人工或坐席主动介入时,从坐席池预占坐席并将其分机呼入指定会议室。 | 转人工专题 |
/api/transfers/end | POST | business/shared/callout/transfer.ts | 转人工结束与收尾:客户挂断或会议结束时,通知中台结束坐席人工腿并更新坐席通话时长与记录。 | 转人工专题 |
定制代码开发重点:
- 编写定制外呼业务时,必须调用的仅有
POST /api/call-results;- 若业务需接入实时盯屏或转人工,可直接复用
src/business/shared/callout/index.ts统一导出的进度上报与转人工运行时 helper,无需在业务中自行组装底层 HTTP 请求。
4. 读取 callout-server 注入的变量
封装一个「先读缓存、回退 ESL」的小工具,安全读取注入的标识:
ts
async function readChannelVariable(
ctx: BusinessContext,
name: string
): Promise<string | null> {
const cached = ctx.channel.variables?.[name];
if (cached?.trim()) return cached.trim();
try {
const value = await ctx.getVariable(name);
return value?.trim() || null;
} catch {
return null;
}
}
// 用法
const calloutRequestId = await readChannelVariable(ctx, "callout_request_id");
const campaignId =
Number(await readChannelVariable(ctx, "callout_campaign_id")) || null;
const contactId =
Number(await readChannelVariable(ctx, "callout_contact_id")) || null;5. 最小可用骨架
一个「接听 → 录音 → 播报 → 单轮识别 → 回写结果」的最小外呼业务:
ts
import { defineBusiness, type BusinessContext } from "../types/business.ts";
async function postResult(
ctx: BusinessContext,
payload: Record<string, unknown>
) {
const callout = ctx.config.callout;
if (!callout) {
ctx.log("result_callback_skipped", { reason: "callout_unconfigured" });
return;
}
const endpoint = `${callout.baseUrl.replace(/\/+$/, "")}/api/call-results`;
const headers = new Headers({ "Content-Type": "application/json" });
const token = callout.token?.trim();
if (token) headers.set("X-Callout-Internal-Token", token);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), callout.requestTimeoutMs);
try {
await fetch(endpoint, {
method: "POST",
headers,
body: JSON.stringify(payload),
signal: controller.signal
});
} catch (err) {
ctx.log("result_callback_failed", {
error: err instanceof Error ? err.message : String(err)
});
} finally {
clearTimeout(timer);
}
}
export const myOutboundBusiness = defineBusiness({
code: "my-outbound-business",
desc: "最小外呼业务示例",
handler: async ctx => {
await ctx.execute("answer");
await ctx.execute("sleep", String(ctx.config["esl-server"].settleDelayMs));
const requestId = ctx.channel.variables?.callout_request_id ?? null;
const answeredAt = new Date();
const recording = await ctx
.startRecording({ fileName: `out-${ctx.channel.uuid}.wav` })
.catch(() => null);
await ctx.speak({
kind: "tts",
text: "您好,这里是示例外呼,请问方便了解一下吗?",
interruptible: true
});
// 单轮识别:拿一句客户回复。HearResult 是按 status 区分的联合类型,
// 只有 status === "recognized" 才有 text 字段。
const heard = await ctx.hear({
partialResults: false,
noSpeechTimeoutMs: 8000
});
const text = heard.status === "recognized" ? heard.text.trim() : "";
const interested = ["可以", "好", "了解", "需要"].some(k =>
text.includes(k)
);
await ctx.speak({
kind: "tts",
text: interested
? "好的,已记录您的意向,稍后联系您。"
: "好的,打扰了,再见。"
});
await ctx.hangup("NORMAL_CLEARING");
await ctx.waitForHangup();
const endedAt = new Date();
await postResult(ctx, {
requestId,
callStatus: "completed",
hangupCause: "NORMAL_CLEARING",
answeredAt: answeredAt.toISOString(),
endedAt: endedAt.toISOString(),
recordingUrl: recording?.fileName ?? undefined,
payload: {
result: {
recognizedText: text || null,
demo: { interested }
}
}
});
}
});6. 完整参考实现:callout-platform-demo-business
仓库内 apps/callflow-esl/src/business/demo/callout-platform-demo-business.ts 是平台能力的完整演示, 覆盖了真实外呼业务该有的全部要素。核心结构如下(精简自源码):
6.1 开场:读变量、查档、起录音、订阅 DTMF
ts
const handler: CallBusiness = async (ctx) => {
await ctx.execute("answer");
await ctx.execute("sleep", String(ctx.config["esl-server"].settleDelayMs));
const answeredAt = new Date();
const answeredAtMs = answeredAt.getTime();
const relMs = () => Math.max(0, Date.now() - answeredAtMs); // 转写时间轴
const calloutRequestId = await readChannelVariable(ctx, "callout_request_id");
const campaignId = parsePositiveInteger(await readChannelVariable(ctx, "callout_campaign_id"));
const contactId = parsePositiveInteger(await readChannelVariable(ctx, "callout_contact_id"));
const conferenceId = `transfer-${randomUUID()}`; // 转人工时才会用到
// 客户业务数据按号码实时查询(演示用号码派生,真实环境换成业务系统接口)
const customerProfile = await lookupCustomerProfileByPhone(ctx, ctx.channel.destinationNumber ?? null);
const recording = await startRecordingSafely(ctx, calloutRequestId);
const offDtmf = ctx.onDtmf((e) => ctx.log("dtmf", { digit: e.digit }));6.2 实时组装对话时间线(transcript)
audio_fork 只取主叫单声道(听不到 AI 的 TTS),所以对话时间线必须实时组装: 客户句来自 ASR final,AI 句来自业务自己播报的 TTS 文本,打断标记来自运行时 barge-in 事件。
ts
const transcript: TranscriptSegment[] = [];
// 统一的 AI 播报入口:落时间线 + 标记是否被打断
const speakTurn = async (
phase: string,
input: TtsSpeakInput
): Promise<SpeakResult> => {
const startMs = relMs();
const result = await ctx.speak(input, {
onComplete: e =>
ctx.log("tts_complete", {
phase,
status: e.status,
interruptedBy: e.interruptedBy ?? null
})
});
const interrupted = result.status === "interrupted";
transcript.push({
speaker: "bot",
startMs,
endMs: relMs(),
text: input.text,
metadata: {
phase,
interrupted,
...(interrupted
? { interruptedBy: result.interruptedBy ?? "barge-in" }
: {})
}
});
return result;
};6.3 持续 ASR + 串行 actionQueue
用 ctx.callHear 开常驻识别,partial 用于捕捉客户起说时间、final 进入对话决策。 所有播报/终态动作经 actionQueue 串行,避免并发 speak:
ts
const asrSession = await ctx.callHear(
{
partialResults: true,
interruptPlayback: true,
bargeInTrigger: "partial",
noSpeechTimeoutMs: NO_SPEECH_TIMEOUT_MS,
noTextTimeoutMs: NO_TEXT_TIMEOUT_MS
},
{
onResult: e => {
if (!e.isFinal && customerSpeechStartMs === null)
customerSpeechStartMs = relMs();
},
onFinal: e => {
actionQueue = actionQueue.then(() => handleCustomerFinal(e));
},
onTimeout: e => {
actionQueue = actionQueue.then(() => handleTimeout(e));
}
}
);
// 开场白入队,然后阻塞等挂机
actionQueue = actionQueue.then(() =>
speakTurn("greeting", {
kind: "tts",
text: customerName
? `${customerName},${GREETING_PROMPT}`
: GREETING_PROMPT,
interruptible: true
})
);
await ctx.waitForHangup();6.4 多轮对话状态机
handleCustomerFinal → decideDialogAction 按阶段(opening → capability_intro → value_probe → transfer_offer → closing)推进,并据关键词分流到三类动作:speak(继续话术)、conclude(得出终态)、 transfer(转人工,见 transfer-to-human.md)。 命中负向词直接结束,命中转人工词触发转人工。
6.5 终态回写
通话自然/主动挂机后,组装最终结果并回写。注意演示业务把结构化结果放进 payload.result, 并把完整对话时间线同时放进 transcript 和 payload.result.demo.transcriptTimeline。 前者只要非空就会落 transcript_segments(与是否带录音无关,且是整段替换),供前端按时间线展示; 后者是业务自己的留档副本,字段格式见 result-schemas.md:
ts
await postCalloutResultSafely(ctx, {
requestId: calloutRequestId ?? undefined,
campaignId: campaignId ?? undefined,
contactId: contactId ?? undefined,
callStatus: "completed",
hangupCause: "NORMAL_CLEARING",
answeredAt: answeredAt.toISOString(),
endedAt: endedAt.toISOString(),
durationSeconds,
recordingUrl: recording?.fileName ?? undefined,
recordingDurationSeconds: recording?.fileName ? durationSeconds : undefined,
recordingMimeType: recording?.fileName ? "audio/wav" : undefined,
transcript: transcript.length > 0 ? transcript : undefined,
payload: {
result: {
businessCode: BUSINESS_CODE,
recognizedText,
demo: {
outcomeCode: finalOutcome.outcomeCode,
outcomeCategory: finalOutcome.outcomeCategory,
customerName,
customerProfile,
durationSeconds,
recordingAvailable: Boolean(recording?.fileName),
transferRequested: finalOutcome.outcomeCategory.startsWith("transfer"),
transcriptTimeline: transcript,
interruptionCount,
platformCapabilities // 本通用到的能力清单
}
}
}
});6.6 回写 helper:失败只记日志、不抛错
回写要做到「失败也不影响通话已经发生的事实」,统一 try/catch + 超时 + 可选 token:
ts
async function postCalloutResultSafely(
ctx: BusinessContext,
payload: CalloutCallbackPayload
) {
// 没有可定位身份就跳过
if (!payload.requestId && !(payload.campaignId && payload.contactId)) return;
const callout = ctx.config.callout;
if (!callout) {
ctx.log("result_callback_skipped", { reason: "callout_unconfigured" });
return;
}
const endpoint = `${callout.baseUrl.replace(/\/+$/, "")}/api/call-results`;
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(),
callout.requestTimeoutMs
);
try {
const headers = new Headers({ "Content-Type": "application/json" });
const token = callout.token?.trim();
if (token) headers.set("X-Callout-Internal-Token", token); // = callout-server server.serviceApi.authToken
const response = await fetch(endpoint, {
method: "POST",
headers,
body: JSON.stringify(payload),
signal: controller.signal
});
if (!response.ok)
throw new Error(`HTTP ${response.status} ${await response.text()}`);
} catch (error) {
ctx.log("result_callback_failed", {
endpoint,
error: error instanceof Error ? error.message : String(error)
});
} finally {
clearTimeout(timeout);
}
}