外观
业务流程编写文档
本文档讲清楚一段业务(CallBusiness)从“一个空函数”到“跑通通话”的所有 约定:函数签名、生命周期、可用能力、异常处理与注册流程。所有示例都基于 仓库中已经跑通的业务,可以直接复制改造。
如果你想看“目前有哪些业务、各自做什么”,请看 existing-businesses.md。
如果你想看“这套代码能实现哪些通话场景”,请看 use-cases.md。
如果你想看“speak / hear / originate / conference 这些组件本身的参数与回调 契约”,请看 components.md。
1. 业务在整个调用链中的位置
text
FreeSWITCH 拨号计划
│ <action application="socket" data="host:port async full"/>
▼
callflow-esl: Bun.listen() ──► SessionRunner.run()
│
├── 连接 / 捕获通道
├── 解析业务路由(DB)
├── 订阅事件 / linger
├── activeBusiness.handler(ctx) ◀── 你的业务在这里执行
├── waitForHangup()
└── session_completedSessionRunner 负责所有“与业务无关的协议琐事”(握手、订阅事件、兜底 清理);业务函数只关心“这通电话要做什么”。
2. 业务函数签名
业务实现位于 src/business/:简单业务用单文件 xxx-business.ts,复杂业务用 xxx/index.ts 文件夹形态(可再拆 prompts / 子流程等同级文件)。每个业务的 handler 都遵循统一签名:
ts
// src/types/business.ts
export type CallBusiness = (ctx: BusinessContext) => Promise<void>;业务入口不直接导出裸 handler,而是用 defineBusiness({ code, desc, handler }) 把「稳定业务编码 / 中文描述 / handler」三者就地共置,并具名导出。defineBusiness 是恒等函数,只为对象字面量提供精确的上下文类型——handler 的 ctx 会被自动 推导为 BusinessContext,无需在箭头函数上再标注 CallBusiness。
BusinessContext 是 runtime-context.ts createCallContext() 的返回值类型 (约 25 个能力),见 components.md 中的方法清单。
最小业务模板:
ts
import { defineBusiness } from "../types/business.ts";
export const myDemoBusiness = defineBusiness({
code: "my-demo-business",
desc: "示例业务",
handler: async (ctx) => {
// 1. 决定是否接通:answer / pre_answer / 直接挂机
await ctx.execute("answer");
await ctx.execute("sleep", String(ctx.config["esl-server"].settleDelayMs));
// 2. 做你的事情:speak / hear / originate / conference / DTMF ...
await ctx.speak({ kind: "tts", text: "你好,这里是测试业务。" });
// 3. 函数返回(或主动 hangup)后,SessionRunner 会接管 waitForHangup
},
});也可以先写一个
const handler: CallBusiness = async (ctx) => {...},再export const myDemoBusiness = defineBusiness({ code, desc, handler })—— 当 handler 较长、或需要拆出多个辅助函数时这种写法更清晰。
3. 业务必须知道的约束
- 不会自动 answer。
SessionRunner已经在最新版本里去掉了自动摘机; 普通交互业务必须显式ctx.execute("answer"),模拟运营商早媒体业务则用ctx.execute("pre_answer"),需要直接“回绝”的业务可以连pre_answer都 不做、直接ctx.hangup("CALL_REJECTED")。 - 业务函数返回 ≠ 通话结束。函数
return后,SessionRunner 会waitForHangup()等对端挂机;想立刻结束需要await ctx.hangup(...),再return。 - 只能依赖 ctx 暴露的能力。runtime 内部状态(
activeRecognition、audioForkActive等)由 controllers 管理,业务不应直接修改。 - callHear / hear / conference.hear 互斥。这三者共用
audio_fork, 同一时间只能存在一个。runtime 会在第二次启动时抛错。 - 回调内禁止抛错把会话拖死。组件回调(
onResult/onFinal/onDtmf…)抛错只会被 runtime 吞掉、落component_callback_error日志,不会 触发业务级清理;如果你需要中止业务,自己持一个标志位+ctx.cancelHear()。
4. ctx 上常用能力速查
下表只列“写业务时最常用”的方法,完整契约与回调字段见 components.md。
| 类别 | 方法 | 用途 |
|---|---|---|
| 通道控制 | ctx.execute(app, arg?) | 阻塞式执行拨号计划 application |
ctx.api(command) | 任意 FreeSWITCH API | |
ctx.hangup(cause?) / ctx.waitForHangup() | 主动挂 / 等待挂 | |
ctx.setVariable(name, value) / ctx.setVariables({...}) | 写通道变量 | |
ctx.getVariable(name) / ctx.getVariables([names]) | 读通道变量 | |
ctx.channel | 当前通道快照(uuid、主被叫等) | |
ctx.config | 应用配置 | |
ctx.log(event, details) | 结构化日志 | |
| 播报 | ctx.speak(input, handlers?) | 独立播报,可设 repeat |
| 识别 | ctx.hear(input, handlers?) | 一轮识别(可携带提示音 + barge-in) |
ctx.cancelHear(reason?) | 取消当前 hear(如 DTMF 抢占) | |
ctx.callHear(input, handlers?) | 整通通话持续识别(手动 stop) | |
ctx.stopCallHear(reason?) | 停 callHear | |
| DTMF | ctx.onDtmf(handler) → unsub | 订阅 DTMF |
ctx.sendDtmf(digit) | 主动发送 DTMF | |
| 外呼/桥接 | ctx.originate(request) | 通过 inbound ESL 发起新通 |
ctx.bridgeToNumber(numberOrInput, options?) | 当前通道直接 bridge 到号码 | |
ctx.originateParkedCall(numberOrInput, options?) | 外呼接通后停在 park 状态 | |
ctx.originateAndBridge(numberOrInput, options?) | 外呼后再 uuid_bridge 回当前通道 | |
ctx.originateWithRingbackDetection(numberOrInput, options?) | 外呼并 ASR 检测回铃运营商提示音 | |
ctx.bridgeCall(bLegUuid, options?) | 当前通道与已有 B-leg uuid_bridge | |
ctx.watchCallHangup(callUuid, timeoutMs?) | 监听指定通道挂机 | |
ctx.hangupCall(callUuid, cause?) | 挂断指定通道 | |
| 会议 | ctx.conference.join({...}) | 当前通道加入会议 |
ctx.conference.play({...}) / injectSpeak | 向会议或单成员播报 | |
ctx.conference.hear({...}, handlers?) | 会议级 ASR(按成员聚合) | |
ctx.conference.publishMessage / onMessage | 业务层消息广播 / 订阅 | |
ctx.conference.getMembers(conferenceId) | 列出会议成员 | |
| 录音 | ctx.startRecording(input?) | 当前通道录音(文件名可省略) |
5. 业务流程模式
5.1 一次性流程(线性)
适合一次性提示、外呼确认、单次回显这类业务。示例: prompt-then-recognize-echo-business。
ts
export const linearDemoBusiness: 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("business_no_recognition", { status: result.status });
return;
}
await ctx.speak({ kind: "tts", text: `您说的是:${result.text}` });
await ctx.hangup("NORMAL_CLEARING");
};5.2 循环识别(while + hear)
适合连续问答 / 闲聊。示例:default-call-business、dtmf-business。
ts
export const loopDemoBusiness: CallBusiness = async (ctx) => {
await ctx.execute("answer");
await ctx.execute("sleep", String(ctx.config["esl-server"].settleDelayMs));
let prompt: SpeakInput | undefined = {
kind: "tts",
text: "您好,请说话。",
};
while (true) {
const result = await ctx.hear({
prompt,
interruptible: true,
bargeInTrigger: "vad",
timeoutMs: 10000,
});
prompt = undefined;
if (result.status === "recognized") {
if (result.text === "挂机") {
await ctx.speak({ kind: "tts", text: "好的,再见" });
await ctx.hangup();
return;
}
prompt = { kind: "tts", text: `你刚刚说了,${result.text}` };
continue;
}
ctx.log("business_turn_unrecognized", { status: result.status });
}
};5.3 持续 ASR + 业务驱动
callHear 启动后整通电话一直识别,runtime 在 final 文本到达时回调;业务 拿到文本后再驱动播报、挂机等动作。示例:call-hear-business。
ts
export const continuousAsrBusiness: CallBusiness = async (ctx) => {
await ctx.execute("answer");
await ctx.execute("sleep", String(ctx.config["esl-server"].settleDelayMs));
let shuttingDown = false;
let actionQueue: Promise<void> = Promise.resolve();
const session = await ctx.callHear(
{
partialResults: true,
interruptPlayback: true,
bargeInTrigger: "vad",
},
{
onFinal: (event) => {
actionQueue = actionQueue.then(async () => {
if (shuttingDown) return;
if (event.text.trim() === "挂机") {
shuttingDown = true;
await ctx.speak({ kind: "tts", text: "好的,再见" });
await ctx.hangup();
return;
}
await ctx.speak({
kind: "tts",
text: `你刚刚说了,${event.text}`,
interruptible: true,
});
});
},
},
);
try {
await ctx.speak({ kind: "tts", text: "欢迎致电", interruptible: true });
await ctx.waitForHangup();
} finally {
shuttingDown = true;
await session.stop("business-complete");
await actionQueue;
}
};注意 actionQueue 串行模式:onFinal 是事件回调,多个 final 可能背靠背 到达,串行队列保证 speak/hangup 不会重入;如果你的处理可以并行,可去掉 队列。
挂机后尾部识别(postHangupDrainMs):默认对端一挂机就停 callHear,可能丢掉 “挂机瞬间还在说”的最后一段 final。给 callHear 入参加 postHangupDrainMs: 1500 后,runtime 会在挂机后多保留 audio_fork 一小段时间收尾部识别——这些事件走 onPostHangupResult / onPostHangupFinal(不再触发 onResult / onFinal), 事件 phase 为 "post-hangup",只应用于补收文本,不要再驱动播放 / 按键 / 挂机 等通话内动作。session.stop() 会等 drain 结束后才返回。完整示例见 verification-code-business/(持续 ASR + 自动按键 + 尾部补收 + 结果上报)。
5.4 并发:语音 + DTMF 抢占
ctx.onDtmf(...) 与 ctx.hear(...) 并存时,自己持一个 turnState 标志, 两边在抢占时互相 cancelHear 或忽略对方结果即可。完整实现参考 dtmf-business。
ts
type TurnState = { handled: boolean; cancelledByDtmf: boolean };
let currentTurn: TurnState | null = null;
const unsubscribeDtmf = ctx.onDtmf(async (event) => {
if (!currentTurn || currentTurn.handled) return;
currentTurn.handled = true;
currentTurn.cancelledByDtmf = true;
await ctx.cancelHear(`dtmf:${event.digit}`);
await ctx.speak({ kind: "tts", text: `您按了${event.digit}` });
});
try {
while (true) {
currentTurn = { handled: false, cancelledByDtmf: false };
const result = await ctx.hear({ prompt, interruptible: true, timeoutMs: 10000 });
if (currentTurn.cancelledByDtmf) {
currentTurn = null;
continue;
}
if (result.status === "recognized" && result.text === "挂断") {
await ctx.hangup();
return;
}
currentTurn = null;
}
} finally {
unsubscribeDtmf();
}5.5 早媒体 / 直接拒接
不 answer,只 pre_answer 发 183 early media,再用 TTS 播报运营商提示音 (早媒体阶段即可出声,无需 200 OK):
ts
export const earlyMediaDemoBusiness: CallBusiness = async (ctx) => {
await ctx.execute("pre_answer");
await ctx.execute("sleep", String(ctx.config["esl-server"].settleDelayMs));
// 早媒体阶段播报;真实运营商提示通常会循环,直到主叫取消或上游清理 B-leg。
await ctx.speak({ kind: "tts", text: "您好,您所拨打的号码是空号,请核对后再拨。" });
};需要“直接回绝”的业务可以连 pre_answer 都不做,直接 await ctx.hangup("CALL_REJECTED")。完整实现参考 carrier-simulator-business/ (按通道变量选三大运营商 18 条样本、支持回铃 + 提示音两种时序、循环播报且 不主动挂机),它专门给 carrier-ringback-detection-demo-business 的 originateWithRingbackDetection 链路当被叫陪练。
5.6 外呼 / 桥接
四种外呼能力的语义见 components.md 的 “Originate” 小节。call-transfer-demo-business 是最典型的 “当前通道直接 bridge 到 人工号码”;parked-dial-demo-business 演示 “外呼停泊+确认接通+桥接” 的完整流程,包括被叫提前挂机后的重试。运营商回铃 ASR 检测则参考 carrier-ringback-detection-demo-business。
ts
const result = await ctx.bridgeToNumber("1001");
// 桥接成功后当前 A-leg 已经与 B-leg 拼接,bridge application 返回,
// 业务通常 hangup 即可让 SessionRunner 走 waitForHangup
if (result.status === "bridged") {
await ctx.hangup();
}5.7 会议
会议至少有“主持人”和“访客”两段业务。主持人 originate 邀请访客,访客 拿到 conference_id 后 ctx.conference.join({conferenceId})。会议级 ASR 通过 ctx.conference.hear() 启动后,会按成员维度回调 partial / final。
参考 conference-host-business 和 conference-guest-business。
6. 异常与挂机约定
业务层应当只关心“逻辑异常”(DB 查询失败、外部 HTTP 失败、参数非法等)。 所有“对端挂机”相关的错误已经被 runtime 标记成 normal termination:
- 业务直接 throw → SessionRunner 判定是否
isNormalTerminationError:- 是 → 落
session_completed,按对端挂机收尾 - 否 → 落
session_error,并尝试停播放 / 停识别 / 主动hangup NORMAL_TEMPORARY_FAILURE
- 是 → 落
- 业务
await ctx.hangup(...)后 return → 等待 hangup 事件触达 → 正常退出
推荐写法:
ts
try {
await doSomethingThatMayFail(ctx);
} catch (error) {
ctx.log("business_step_failed", {
error: error instanceof Error ? error.message : String(error),
});
await ctx.speak({ kind: "tts", text: "处理出错,电话即将挂断。" });
await ctx.hangup("NORMAL_TEMPORARY_FAILURE");
return;
}对端挂机后还触发的命令(typically playback / uuid_audio_fork)会被 FreeSWITCH 直接拒绝;runtime 已经在 executeApp 里加了 tolerateError 分支用于挂机原因 ,不需要业务自己捕获。
7. 业务级常量 vs 配置项
SessionRunner 不再支持通过环境变量配置业务参数。一类参数的归属如下:
| 参数 | 应放在哪里 |
|---|---|
| 业务文本、关键词、循环次数 | 业务文件顶部 const |
| 模型 / 后端地址 / 超时 / TCP 监听 | config.json + AppConfig |
| barge-in 触发模式、停播命令 | 业务文件常量;可在 hear() 入参覆盖 |
| 业务编码 / 中文描述 / handler | 业务文件内 defineBusiness({...}) |
| 业务汇总(注册表) | src/runtime/business-registry.ts |
| 业务 ↔ 号码映射 | 数据库 call_business_number_mappings |
7.1 业务路由解析
SessionRunner 完成 outbound 握手后按以下顺序解析业务:
business_code非空时,查询call_businesses获取稳定businessCode。- 否则按
destinationNumber查询call_business_number_mappings并关联call_businesses。 - 使用
businessCode在business-registry中定位 handler。
路由失败时会接通并播报“系统错误”后挂机。路由结果携带 call_businesses.businessConfig;只有号码映射命中时才携带 call_business_number_mappings.mappingConfig。最终只读 ctx.businessConfig 按 “配置文件 < 业务 < 映射”覆盖,显式 business_code 路由不应用映射层。
8. 注册一段新业务(操作清单)
在
src/business/新建业务。简单业务用单文件voice-vote-business.ts, 入口用defineBusiness把编码 / 描述 / handler 就地写在一起并具名导出:tsimport { defineBusiness } from "../types/business.ts"; export const voiceVoteBusiness = defineBusiness({ code: "voice-vote-business", desc: "语音投票业务", handler: async (ctx) => { await ctx.execute("answer"); // ... 业务逻辑 ... }, });复杂业务改用文件夹形态
voice-vote/index.ts,并把文案常量、子流程拆到prompts.ts/xxx.ts等同级文件(参考ai-voice-platform-demo/)。在
src/business/index.ts重新导出该符号:tsexport { voiceVoteBusiness } from "./voice-vote-business.ts"; // 文件夹形态则是 "./voice-vote/index.ts"在
src/runtime/business-registry.ts的import块引入它,并把它追加到businesses数组。注意:registry 不再重复 code / desc,只负责「汇总 + 唯一性校验」(重复 code 会在启动期直接抛错):tsimport { // ... 其它业务 ... voiceVoteBusiness, } from "../business/index.ts"; const businesses: RegisteredCallBusiness[] = [ // ... 其它业务 ... voiceVoteBusiness, ];运行一次
bun run typecheck确保类型通过。选择一种路由方式:
- 按
businessCode命中:服务启动时syncRegisteredCallBusinesses会把 registry 中每条业务的code/desc同步到call_businesses - 按号码命中:先启动一次服务完成业务同步,再在
call_business_number_mappings插入("destinationNumber"="9919", "businessId"=<上一步的 id>, "isEnabled"=1)
开发期可以直接通过
bun run db:studio在浏览器里改表,或在数据库迁移脚本中加入。业务如需声明文件配置必填与最终校验器,可在
defineBusiness中增加config: { requiredInFile: true, validate };可缺省的业务只声明validate。 文件默认值由稳定code自动从config.json.businessConfig[code]定位,handler 只通过 只读ctx.businessConfig读取最终值。覆盖优先级为配置文件、call_businesses.businessConfig、call_business_number_mappings.mappingConfig;数据库两层 直接填写业务字段,不增加业务编码包裹,显式business_code路由跳过最后一层。- 按
启动外呼(通过 HTTP)或在 FreeSWITCH 拨号计划中拨那个号码即可触达。
9. 调试技巧
ctx.log(event, details)写出来的日志带callUuid/businessCode/businessName/sessionStage,落盘到config.logging.dir下的 winston 文件中verbose-prompt-then-recognize-business把speak/hear的全部回调 都接上了,初次接入时很适合复制一份当作“打印一切”的脚手架- 单元测试与模块同目录(
src/**/*.test.ts,如src/runtime/、src/runtime/conference/与各业务目录下的用例),可用bun test跑(部分用例需要 mock socket) - 数据库结构变更后必须
bun run db:generate+bun run db:push - 与远端 FreeSWITCH 联调时,
config.freeswitch.callbackHost必须是 FreeSWITCH 主机能访问到的本机地址;否则 originate 接通后 outbound 永远 连不到 callflow-esl