外观
可实现场景与示例代码
本文档系统梳理 callflow-esl 当前代码能覆盖的通话场景,并给出可以直接复用 的业务代码示例。所有示例只在文档中列出,不会自动落到 src/business/; 落地时按 business-development.md §8 注册即可。
为聚焦通话流程,下文示例以
export const xxxBusiness: CallBusiness = ...的形式直接展示 handler 逻辑。真正落地时应改成export const xxxBusiness = defineBusiness({ code, desc, handler })把编码 / 描述与 handler 就地共置,再按 §8 接入注册表。
按场景分类:
- 1. 提示音 / TTS 播报
- 2. 一次性识别
- 3. 多轮识别 / 闲聊
- 4. 持续 ASR
- 5. DTMF(按键交互)
- 6. 早媒体 / 拒接 / 模拟运营商提示音
- 7. 外呼
- 8. 转接 / 桥接
- 9. 帮拨号(park → 确认 → bridge)
- 10. 智能外呼(回铃运营商提示音识别 + 重试)
- 11. 多方语音会议
- 12. 会议级 ASR + 播报
- 13. 通话录音
- 14. DTMF 抢占语音
- 15. 综合:IVR 菜单 + 转人工
- 16. 综合:语音预筛 + 智能拨号 + 桥接
- 17. 综合:AI 客服 + 整通持续识别 + 录音 + 转人工
1. 提示音 / TTS 播报
接通后只播报一段文本/wav 然后挂机。
ts
import type { CallBusiness } from "../types/business.ts";
export const announceBusiness: CallBusiness = async (ctx) => {
await ctx.execute("answer");
await ctx.execute("sleep", String(ctx.config["esl-server"].settleDelayMs));
await ctx.speak({
kind: "tts",
text: "您好,今天的天气是晴天,气温二十二摄氏度。",
});
await ctx.hangup("NORMAL_CLEARING");
};播放 wav 文件:
ts
await ctx.speak({ kind: "wav", file: "${sounds_dir}/welcome.wav" });重复播放 + 异常监听:
ts
await ctx.speak(
{ kind: "tts", text: "请稍候", repeat: 3 },
{ onError: (e) => ctx.log("speak_error", { error: e.error }) },
);2. 一次性识别
接通后问一句话,识别成功就回显然后挂机。已实现: prompt-then-recognize-echo-business,原样使用即可。
ts
export const oneShotAskBusiness: CallBusiness = async (ctx) => {
await ctx.execute("answer");
await ctx.execute("sleep", String(ctx.config["esl-server"].settleDelayMs));
await ctx.speak({ kind: "tts", text: "您好,请说出您的需求。" });
const result = await ctx.hear({ timeoutMs: 10000 });
if (result.status !== "recognized") {
ctx.log("oneshot_no_text", { status: result.status });
await ctx.hangup("NORMAL_TEMPORARY_FAILURE");
return;
}
await ctx.speak({ kind: "tts", text: `您说的是:${result.text}` });
await ctx.hangup("NORMAL_CLEARING");
};3. 多轮识别 / 闲聊
while + hear,识别成功就把文本作为下轮提示音。已实现: default-call-business。如果想接 LLM 回答:
ts
import type { CallBusiness, BusinessContext } from "../types/business.ts";
export const chatBusiness: CallBusiness = async (ctx) => {
await ctx.execute("answer");
await ctx.execute("sleep", String(ctx.config["esl-server"].settleDelayMs));
let prompt: { kind: "tts"; text: string } | undefined = {
kind: "tts",
text: "您好,有什么可以帮您?",
};
while (true) {
const result = await ctx.hear({
prompt,
interruptible: true,
bargeInTrigger: "vad",
timeoutMs: 10000,
});
prompt = undefined;
if (result.status !== "recognized") {
ctx.log("chat_no_speech", { status: result.status });
continue;
}
if (result.text.includes("挂机") || result.text.includes("再见")) {
await ctx.speak({ kind: "tts", text: "好的,祝您生活愉快。" });
await ctx.hangup();
return;
}
const reply = await callLlmReply(ctx, result.text);
prompt = { kind: "tts", text: reply };
}
};
async function callLlmReply(_ctx: BusinessContext, userText: string): Promise<string> {
// TODO: 替换为真实 LLM 调用
return `您刚才说:${userText}`;
}4. 持续 ASR
整通通话保持 audio_fork,业务按 final 文本驱动。已实现:call-hear-business。 适合需要 partial 反馈、低延迟反应的场景:
ts
export const transcribeOnlyBusiness: CallBusiness = async (ctx) => {
await ctx.execute("answer");
await ctx.execute("sleep", String(ctx.config["esl-server"].settleDelayMs));
const session = await ctx.callHear(
{ partialResults: true, bargeInTrigger: "vad" },
{
onResult: (e) =>
ctx.log("partial", { isFinal: e.isFinal, text: e.text }),
onFinal: (e) => ctx.log("final", { text: e.text }),
},
);
try {
await ctx.speak({ kind: "tts", text: "请开始讲话,我会持续转写。" });
await ctx.waitForHangup();
} finally {
await session.stop("business-complete");
}
};5. DTMF(按键交互)
只关注 DTMF:
ts
export const dtmfMenuBusiness: CallBusiness = async (ctx) => {
await ctx.execute("answer");
await ctx.execute("sleep", String(ctx.config["esl-server"].settleDelayMs));
await ctx.speak({
kind: "tts",
text: "您好,按 1 查询余额,按 2 转人工,按 0 退出。",
});
const digit = await new Promise<string>((resolve) => {
const off = ctx.onDtmf((event) => {
off();
resolve(event.digit);
});
});
ctx.log("dtmf_menu_selected", { digit });
if (digit === "1") {
await ctx.speak({ kind: "tts", text: "您的余额为一百二十三元。" });
} else if (digit === "2") {
await ctx.bridgeToNumber("9000");
}
await ctx.hangup("NORMAL_CLEARING");
};6. 早媒体 / 拒接 / 模拟运营商提示音
不接通、仅 pre_answer 发 183 early media,再播报运营商提示音。已实现: carrier-simulator-business(按通道变量在三大运营商 18 条样本中选一条,用项目 统一 TTS 链路循环播报,自身不主动挂机,等主叫取消或检测器清理 B-leg)。常配合 originateWithRingbackDetection 与 carrier-ringback-detection-demo-business 做端到端测试。下面是一个最简版(播一遍忙音提示就挂断):
ts
export const carrierBusyBusiness: CallBusiness = async (ctx) => {
await ctx.execute("pre_answer");
await ctx.execute("sleep", String(ctx.config["esl-server"].settleDelayMs));
await ctx.speak({ kind: "tts", text: "您好,您拨打的电话正在通话中,请稍后再拨。" });
await ctx.hangup("USER_BUSY");
};未播放任何音直接拒接:
ts
export const rejectBusiness: CallBusiness = async (ctx) => {
ctx.log("rejected_immediately", { destinationNumber: ctx.channel.destinationNumber });
await ctx.hangup("CALL_REJECTED");
};7. 外呼
业务内主动发起一通新通话(A→B),常用于会议邀请、提醒回访。
ts
export const inviteFriendBusiness: CallBusiness = async (ctx) => {
await ctx.execute("answer");
await ctx.execute("sleep", String(ctx.config["esl-server"].settleDelayMs));
const friendNumber = await ctx.getVariable("friend_number");
if (!friendNumber) {
await ctx.speak({ kind: "tts", text: "缺少邀请号码。" });
await ctx.hangup("NORMAL_TEMPORARY_FAILURE");
return;
}
const result = await ctx.originate({
destinationNumber: friendNumber,
businessCode: "friend-callback-business",
callerIdNumber: ctx.channel.callerIdNumber,
variables: {
invited_from: ctx.channel.uuid,
},
});
ctx.log("invite_origination", { ok: result.ok, replyText: result.replyText });
await ctx.speak({
kind: "tts",
text: result.ok ? "邀请已发出。" : "邀请失败。",
});
await ctx.hangup();
};也可以通过 HTTP API 触发:
bash
curl -s -X POST http://127.0.0.1:9912/outbound-calls \
-H "Content-Type: application/json" \
-d '{
"destinationNumber": "13800138000",
"businessCode": "default-call-business",
"callerIdNumber": "10086",
"variables": { "campaign": "may-test" }
}'8. 转接 / 桥接
最简单的“直接 bridge”转人工。已实现:call-transfer-demo-business。
ts
export const directTransferBusiness: CallBusiness = async (ctx) => {
await ctx.execute("answer");
await ctx.execute("sleep", String(ctx.config["esl-server"].settleDelayMs));
await ctx.speak({ kind: "tts", text: "正在为您转接客服。" });
const r = await ctx.bridgeToNumber("9000");
ctx.log("transfer_result", {
status: r.status,
bridgeHangupCause: r.bridgeHangupCause,
});
if (r.status === "bridged") {
await ctx.hangup();
} else {
await ctx.speak({ kind: "tts", text: "未能接通,请稍后再试。" });
await ctx.hangup("NORMAL_TEMPORARY_FAILURE");
}
};9. 帮拨号(park → 确认 → bridge)
让用户在确认前听到“对方已接通”再连接。已实现:parked-dial-demo-business。 核心模式:
ts
const dial = await ctx.originateParkedCall("1001", {
originateTimeoutMs: 20_000,
dialStringTemplate: "user/{{destinationNumber}}",
});
if (dial.status !== "parked") {
await ctx.speak({ kind: "tts", text: "对方未接通。" });
return;
}
const ok = await askConfirm(ctx, "对方已接通,是否为您连接?");
if (!ok) {
await ctx.hangupCall(dial.bLegUuid, "ORIGINATOR_CANCEL");
return;
}
const bLegHangup = ctx.watchCallHangup(dial.bLegUuid);
const bridge = await ctx.bridgeCall(dial.bLegUuid, {
preserveCurrentCallAfterBridge: true,
});
if (bridge.status === "bridged") {
// 两人通话;监听任一端挂机,让业务可以拿回控制
const callerHangup = ctx.watchCallHangup(ctx.channel.uuid);
const ended = await Promise.race([
bLegHangup.promise.then((r) => ({ type: "callee" as const, r })),
callerHangup.promise.then((r) => ({ type: "caller" as const, r })),
]);
ctx.log("two_party_ended", { side: ended.type, cause: ended.r.hangupCause });
}askConfirm 是仓库里大多数 demo 都在用的小工具,可放到业务里:
ts
async function askConfirm(ctx: BusinessContext, promptText: string): Promise<boolean> {
for (let attempt = 1; attempt <= 2; attempt++) {
const r = await ctx.hear({
prompt: { kind: "tts", text: promptText },
interruptible: true,
bargeInTrigger: "vad",
timeoutMs: 10000,
});
if (r.status === "recognized") {
const text = r.text;
if (/不|拒|否/.test(text)) return false;
if (/是|好的|可以|确认|连接/.test(text)) return true;
}
promptText = "没有听清,请再说一次。";
}
return false;
}10. 智能外呼(回铃运营商提示音识别 + 重试)
originateWithRingbackDetection 在 ringing / early-media 阶段同步开启 ASR,按返回 carrierPromptKind 判定空号 / 关机 / 停机等。已实现: carrier-ringback-detection-demo-business。
ts
export const smartOutboundBusiness: CallBusiness = async (ctx) => {
await ctx.execute("answer");
await ctx.execute("sleep", String(ctx.config["esl-server"].settleDelayMs));
const target = await ctx.getVariable("target_number");
if (!target) {
await ctx.hangup("NORMAL_TEMPORARY_FAILURE");
return;
}
await ctx.speak({ kind: "tts", text: "正在为您拨号。" });
const dial = await ctx.originateWithRingbackDetection(target, {
originateTimeoutMs: 20_000,
dialStringTemplate: "sofia/gateway/local-freeswitch-internal/{{destinationNumber}}",
});
ctx.log("smart_outbound_result", {
status: dial.status,
kind: dial.carrierPromptKind,
detectedText: dial.detectedText,
});
switch (dial.status) {
case "answered":
// 已接通,自行决定下一步:bridge / 进入 IVR / hangup
await ctx.speak({ kind: "tts", text: "对方已接通。" });
break;
case "carrier_prompt_detected":
await ctx.speak({
kind: "tts",
text: `对方${dial.carrierPromptKind === "empty_number" ? "是空号" : "暂时无法接通"}。`,
});
break;
case "no_answer":
await ctx.speak({ kind: "tts", text: "对方未接听。" });
break;
case "failed":
await ctx.speak({ kind: "tts", text: "拨号失败。" });
break;
}
await ctx.hangup();
};11. 多方语音会议
主持人 + 访客两段业务配合使用:
- 主持人:
conference-host-business(已实现) - 访客:
conference-guest-business(已实现)
外部触发主持人入会:
bash
curl -X POST http://127.0.0.1:9912/outbound-calls -H "Content-Type: application/json" \
-d '{ "destinationNumber": "1000", "businessCode": "conference-host-business", "callerIdNumber": "1000" }'主持人接通后会 originate 1001 进访客业务,访客读取 conference_id 后入会。
12. 会议级 ASR + 播报
ctx.conference.hear 按成员维度回调;主持人业务里典型用法(已实现):
ts
await ctx.conference.hear(
{ conferenceId, partialResults: true },
{
onFinal: async (event) => {
const announcement = `会议识别结果,${event.participant.callerIdNumber} 说:${event.text}`;
try {
await ctx.conference.play({
conferenceId: event.conferenceId,
input: { kind: "tts", text: announcement, interruptible: true },
});
} catch (error) {
ctx.log("conference_announcement_failed", {
error: error instanceof Error ? error.message : String(error),
});
}
},
},
);只播给某成员:
ts
await ctx.conference.injectSpeak(
{ kind: "tts", text: "您已被主持人禁言。" },
{ conferenceId, targetCallUuid: someParticipantUuid },
);13. 通话录音
调用一次 startRecording 即可,录音随通道挂机自动结束:
ts
export const recordOnlyBusiness: CallBusiness = async (ctx) => {
await ctx.execute("answer");
await ctx.execute("sleep", String(ctx.config["esl-server"].settleDelayMs));
const r = await ctx.startRecording();
ctx.log("recording", { filePath: r.filePath, fileName: r.fileName });
await ctx.speak({ kind: "tts", text: "通话正在录音中,请讲话。" });
await ctx.waitForHangup();
};自定义文件名:
ts
await ctx.startRecording({
fileName: `vm-${ctx.channel.uuid}.wav`,
directory: "/usr/local/freeswitch/voicemails",
});14. DTMF 抢占语音
dtmf-business 是参考实现。一个简化版(任意按键即抢占 hear):
ts
export const dtmfOrSpeechBusiness: CallBusiness = async (ctx) => {
await ctx.execute("answer");
await ctx.execute("sleep", String(ctx.config["esl-server"].settleDelayMs));
const off = ctx.onDtmf(async (event) => {
await ctx.cancelHear(`dtmf:${event.digit}`);
await ctx.speak({ kind: "tts", text: `您按了 ${event.digit} 键,再见。` });
await ctx.hangup("NORMAL_CLEARING");
});
try {
const r = await ctx.hear({
prompt: { kind: "tts", text: "请说话,或按任意键退出。" },
interruptible: true,
timeoutMs: 15000,
});
if (r.status === "recognized") {
await ctx.speak({ kind: "tts", text: `您说的是:${r.text}` });
}
await ctx.hangup();
} finally {
off();
}
};15. 综合:IVR 菜单 + 转人工
“按 1 查余额、按 2 转人工、说‘人工’也转人工”的组合:
ts
export const ivrMenuBusiness: CallBusiness = async (ctx) => {
await ctx.execute("answer");
await ctx.execute("sleep", String(ctx.config["esl-server"].settleDelayMs));
let resolved: { action: "balance" | "agent" | "exit" } | null = null;
const off = ctx.onDtmf(async (event) => {
if (resolved) return;
if (event.digit === "1") {
resolved = { action: "balance" };
await ctx.cancelHear(`dtmf:${event.digit}`);
} else if (event.digit === "2") {
resolved = { action: "agent" };
await ctx.cancelHear(`dtmf:${event.digit}`);
} else if (event.digit === "0") {
resolved = { action: "exit" };
await ctx.cancelHear(`dtmf:${event.digit}`);
}
});
try {
while (!resolved) {
const r = await ctx.hear({
prompt: {
kind: "tts",
text: "按 1 查余额,按 2 转人工,按 0 退出,也可以直接说‘人工’。",
},
interruptible: true,
bargeInTrigger: "vad",
timeoutMs: 10000,
});
if (r.status === "recognized" && /人工/.test(r.text)) {
resolved = { action: "agent" };
break;
}
}
} finally {
off();
}
switch (resolved.action) {
case "balance":
await ctx.speak({ kind: "tts", text: "您的余额为一百二十三元。" });
await ctx.hangup();
break;
case "agent":
await ctx.speak({ kind: "tts", text: "正在为您转接人工。" });
await ctx.bridgeToNumber("9000");
await ctx.hangup();
break;
case "exit":
await ctx.speak({ kind: "tts", text: "感谢来电,再见。" });
await ctx.hangup();
break;
}
};16. 综合:语音预筛 + 智能拨号 + 桥接
“用户拨进来 → 报上他要找的人 → 我们外呼 + 回铃 ASR 判定 → 通了再 bridge” 可以组合 carrier-ringback-detection-demo-business 的早媒体检测模式与 parked-dial-demo-business 的确认桥接模式,再加上数据库查号实现“按名字找人”:
ts
export const reachByNameBusiness: CallBusiness = async (ctx) => {
await ctx.execute("answer");
await ctx.execute("sleep", String(ctx.config["esl-server"].settleDelayMs));
const ask = await ctx.hear({
prompt: { kind: "tts", text: "您想找谁,请说他的名字。" },
interruptible: true,
bargeInTrigger: "vad",
timeoutMs: 10000,
});
if (ask.status !== "recognized") {
await ctx.speak({ kind: "tts", text: "未听清姓名,请稍后再试。" });
await ctx.hangup();
return;
}
const number = await lookupPhoneByName(ask.text); // 业务自己实现的查号
if (!number) {
await ctx.speak({ kind: "tts", text: `没有找到 ${ask.text} 的联系方式。` });
await ctx.hangup();
return;
}
await ctx.speak({ kind: "tts", text: `找到 ${ask.text},正在拨号。` });
const dial = await ctx.originateWithRingbackDetection(number, {
originateTimeoutMs: 20_000,
});
if (dial.status !== "answered") {
await ctx.speak({ kind: "tts", text: "未能接通。" });
await ctx.hangup();
return;
}
await ctx.bridgeCall(dial.bLegUuid, { preserveCurrentCallAfterBridge: true });
};
async function lookupPhoneByName(_name: string): Promise<string | null> {
// TODO: 查询业务库
return null;
}17. 综合:AI 客服 + 整通持续识别 + 录音 + 转人工
把持续 ASR、转接、录音组合起来,是真实生产场景最常见的形态:
ts
export const aiAgentBusiness: CallBusiness = async (ctx) => {
await ctx.execute("answer");
await ctx.execute("sleep", String(ctx.config["esl-server"].settleDelayMs));
await ctx.startRecording();
let shuttingDown = false;
let actionQueue: Promise<void> = Promise.resolve();
let transferRequested = false;
const asr = await ctx.callHear(
{
partialResults: true,
interruptPlayback: true,
bargeInTrigger: "vad",
},
{
onFinal: (event) => {
actionQueue = actionQueue.then(async () => {
if (shuttingDown) return;
const text = event.text.trim();
if (!text) return;
if (/人工|客服/.test(text)) {
transferRequested = true;
shuttingDown = true;
return;
}
if (/挂机|再见|拜拜/.test(text)) {
shuttingDown = true;
await ctx.speak({ kind: "tts", text: "好的,再见。" });
await ctx.hangup();
return;
}
const reply = await aiReply(text);
await ctx.speak({ kind: "tts", text: reply, interruptible: true });
});
},
},
);
try {
await ctx.speak({
kind: "tts",
text: "您好,我是智能客服,请讲。",
interruptible: true,
});
while (!shuttingDown) {
await new Promise((resolve) => setTimeout(resolve, 200));
}
if (transferRequested) {
await asr.stop("transferring-to-agent");
await actionQueue;
await ctx.speak({ kind: "tts", text: "正在为您转接人工。" });
await ctx.bridgeToNumber("9000");
await ctx.hangup();
} else {
await ctx.waitForHangup();
}
} finally {
shuttingDown = true;
await asr.stop("business-complete");
await actionQueue;
}
};
async function aiReply(userText: string): Promise<string> {
// TODO: 调用 LLM / 知识库返回回答
return `我听到了:${userText}`;
}还想实现但当前 runtime 缺失的能力
整理一下当前 BusinessContext 暂时没有的能力(如果需要可扩展 runtime):
stopRecording:当前录音随通道挂机自动结束,没有显式停止接口- 直接的“语音转外语” / “声纹识别”:sherpa-asr-online-server 只输出中文(双语模型)
- 跨业务的全局状态共享:业务之间通过通道变量 / DB 通信,没有 runtime 内置的 KV 存储
- 主动建立 conference 并主持人不入会的“纯 AI 主持人”模式(当前
ctx.conference.hear需要绑定在当前通道上)
需要时新增 runtime 能力即可,按 components.md 的分组在 对应 controller 中加方法、再在 runtime-context.ts 的 createCallContext 暴露。