17 个典型场景
callflow-esl 当前的能力覆盖了语音应用里最常见的 17 类场景。下面每个场景给一句话定位、是否已落地、最小代码示例。完整版(含详细注释)见仓库 callflow-esl/docs/use-cases.md。
按主题分组:
基础交互
1. 提示音 / TTS 播报
定位:接通后只播报一段文本/wav 然后挂机。✅ 内置
1 2 3 4 5 6
| export const announceBusiness: CallBusiness = async (ctx) => { await ctx.execute("answer"); await ctx.execute("sleep", String(ctx.config.server.settleDelayMs)); await ctx.speak({ kind: "tts", text: "您好,今天天气晴朗,气温二十二度。" }); await ctx.hangup("NORMAL_CLEARING"); };
|
2. 一次性识别
定位:问一句话,识别成功就回显然后挂机。✅ prompt-then-recognize-echo-business
1 2 3 4 5 6
| await ctx.speak({ kind: "tts", text: "您好,请说出您的需求。" }); const r = await ctx.hear({ timeoutMs: 10000 }); if (r.status === "recognized") { await ctx.speak({ kind: "tts", text: `您说的是:${r.text}` }); } await ctx.hangup();
|
3. 多轮识别 / 闲聊
定位:while + hear,识别结果作为下轮 prompt。✅ default-call-business
1 2 3 4 5 6 7 8 9
| let prompt = { kind: "tts" as const, text: "您好,有什么可以帮您?" }; while (true) { const r = await ctx.hear({ prompt, interruptible: true, bargeInTrigger: "vad", timeoutMs: 10000 }); prompt = undefined as any; if (r.status !== "recognized") continue; if (/挂机|再见/.test(r.text)) { await ctx.hangup(); return; } const reply = await callLlmReply(ctx, r.text); prompt = { kind: "tts", text: reply }; }
|
4. 持续 ASR
定位:整通通话保持 audio_fork,partial 实时回调。✅ call-hear-business
1 2 3 4 5 6 7 8 9 10 11 12 13
| const asr = 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 asr.stop("business-complete"); }
|
5. DTMF(按键交互)
定位:只关注 DTMF 按键。
1 2 3 4 5 6 7
| await ctx.speak({ kind: "tts", text: "按 1 查余额,按 2 转人工。" }); const digit = await new Promise<string>((resolve) => { const off = ctx.onDtmf((e) => { off(); resolve(e.digit); }); }); if (digit === "1") await ctx.speak({ kind: "tts", text: "余额一百元。" }); if (digit === "2") await ctx.bridgeToNumber("9000"); await ctx.hangup();
|
呼叫控制
6. 早媒体 / 拒接 / 模拟运营商提示音
定位:不接通、仅 pre_answer 发 183 + 固定 wav;常用于测试。✅ carrier-empty-number-simulator-business
1 2 3 4
| await ctx.execute("pre_answer"); await ctx.execute("sleep", String(ctx.config.server.settleDelayMs)); await ctx.execute("playback", "${sounds_dir}/carrier-busy.wav"); await ctx.hangup("USER_BUSY");
|
7. 外呼
定位:业务内主动发起新通话(A→B),常用于会议邀请、提醒回访。
1 2 3 4 5 6
| const r = await ctx.originate({ destinationNumber: "13800138000", bizId: "friend-callback-business", callerIdNumber: "10086", variables: { invited_from: ctx.channel.uuid }, });
|
HTTP 触发:
1 2
| curl -X POST http://127.0.0.1:9912/outbound-calls -H "Content-Type:application/json" \ -d '{"destinationNumber":"13800138000","bizId":"default-call-business","callerIdNumber":"10086"}'
|
8. 转接 / 桥接
定位:最简单的”直接 bridge”转人工。✅ call-transfer-demo-business
1 2 3 4
| await ctx.speak({ kind: "tts", text: "正在为您转接客服。" }); const r = await ctx.bridgeToNumber("9000"); if (r.status === "bridged") await ctx.hangup(); else await ctx.speak({ kind: "tts", text: "未接通,请稍后再试。" });
|
9. 帮拨号(park → 确认 → bridge)
定位:让用户在确认前听到”对方已接通”再连接。✅ parked-dial-demo-business
1 2 3 4 5 6 7
| const dial = await ctx.originateParkedCall("1001", { originateTimeoutMs: 20_000 }); 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 bridge = await ctx.bridgeCall(dial.bLegUuid, { preserveCurrentCallAfterBridge: true });
|
10. 智能外呼(回铃运营商提示音识别 + 重试)
定位:在 ringing / early-media 阶段就开识别,按返回 carrierPromptKind 判定空号/关机/停机。✅ proxy-dial-demo-business
1 2 3 4 5 6 7 8 9 10
| const dial = await ctx.originateWithRingbackDetection(target, { originateTimeoutMs: 20_000 });
switch (dial.status) { case "answered": break; case "carrier_prompt_detected": await ctx.speak({ kind: "tts", text: `对方${dial.carrierPromptKind === "empty_number" ? "是空号" : "无法接通"}。` }); break; case "no_answer": break; case "failed": break; }
|
会议
11. 多方语音会议
定位:主持人 + 访客两段业务配合。✅ conference-host-business / conference-guest-business
外部触发主持人入会:
1 2
| curl -X POST http://127.0.0.1:9912/outbound-calls -H "Content-Type:application/json" \ -d '{"destinationNumber":"1000","bizId":"conference-host-business","callerIdNumber":"1000"}'
|
主持人接通后会 originate 1001 进访客业务,访客读取 conference_id 后入会。
12. 会议级 ASR + 播报
定位:会议成员发言被识别后,把识别文本回放到整个会议。
1 2 3 4 5 6 7 8 9 10 11 12
| await ctx.conference.hear( { conferenceId, partialResults: true }, { onFinal: async (event) => { const announcement = `${event.participant.callerIdNumber} 说:${event.text}`; await ctx.conference.play({ conferenceId: event.conferenceId, input: { kind: "tts", text: announcement, interruptible: true }, }); }, }, );
|
只播给某成员:
1 2 3 4
| await ctx.conference.injectSpeak( { kind: "tts", text: "您已被禁言。" }, { conferenceId, targetCallUuid: someParticipantUuid }, );
|
媒体处理
13. 通话录音
定位:调用一次 startRecording 即可,录音随通道挂机自动结束。
1 2 3 4
| const r = await ctx.startRecording(); ctx.log("recording", { filePath: r.filePath, fileUrl: r.fileUrl }); await ctx.speak({ kind: "tts", text: "通话正在录音。" }); await ctx.waitForHangup();
|
自定义:
1 2 3 4
| await ctx.startRecording({ fileName: `vm-${ctx.channel.uuid}.wav`, directory: "/usr/local/freeswitch/voicemails", });
|
14. DTMF 抢占语音
定位:用户按键即打断 hear。✅ dtmf-business
1 2 3 4 5 6 7 8 9 10 11
| const off = ctx.onDtmf(async (e) => { await ctx.cancelHear(`dtmf:${e.digit}`); await ctx.speak({ kind: "tts", text: `您按了 ${e.digit}。` }); await ctx.hangup(); });
try { const r = await ctx.hear({ prompt: { kind: "tts", text: "请说话,或按任意键退出。" }, interruptible: true }); if (r.status === "recognized") await ctx.speak({ kind: "tts", text: `您说的是:${r.text}` }); await ctx.hangup(); } finally { off(); }
|
综合场景
15. IVR 菜单 + 转人工
定位:”按 1 查余额、按 2 转人工、说’人工’也转人工”的组合。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| let resolved: { action: "balance" | "agent" | "exit" } | null = null;
const off = ctx.onDtmf(async (e) => { if (resolved) return; if (e.digit === "1") { resolved = { action: "balance" }; await ctx.cancelHear(`dtmf:${e.digit}`); } else if (e.digit === "2") { resolved = { action: "agent" }; await ctx.cancelHear(`dtmf:${e.digit}`); } else if (e.digit === "0") { resolved = { action: "exit" }; await ctx.cancelHear(`dtmf:${e.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: "余额一百二十三元。" }); break; case "agent": await ctx.speak({ kind: "tts", text: "正在转接人工。" }); await ctx.bridgeToNumber("9000"); break; case "exit": await ctx.speak({ kind: "tts", text: "感谢来电,再见。" }); break; } await ctx.hangup();
|
16. 语音预筛 + 智能拨号 + 桥接
定位:用户拨进来 → 报上要找的人 → 我们外呼 + 回铃 ASR 判定 → 通了再 bridge。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| const ask = await ctx.hear({ prompt: { kind: "tts", text: "您想找谁,请说他的名字。" }, interruptible: true, bargeInTrigger: "vad", timeoutMs: 10000, }); if (ask.status !== "recognized") { 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.bridgeCall(dial.bLegUuid, { preserveCurrentCallAfterBridge: true }); } else { await ctx.speak({ kind: "tts", text: "未能接通。" }); }
|
17. AI 客服 + 整通持续识别 + 录音 + 转人工
定位:生产场景最常见的形态——持续 ASR、串行 LLM 处理、按关键词转人工。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48
| 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((r) => setTimeout(r, 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; }
|
想要但当前没有
整理一下当前 BusinessContext 暂时没有的能力(需要时按 callflow-esl 分层架构扩 runtime):
| 能力 |
现状 |
stopRecording |
当前录音随通道挂机自动结束,无显式 stop |
| 跨业务全局 KV |
业务间靠 channel variable / DB 通信 |
| 纯 AI 主持人(不入会) |
ctx.conference.hear 需绑定当前通道 |
| 声纹识别 / 多语种 |
受限于当前默认 ASR 模型;可换模型 |
下一步