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); // 接 LLM
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 模型;可换模型

下一步