业务模型 在 callflow-esl 里,一通通话 = 一段业务函数 。函数签名固定:
1 2 3 4 5 import type { CallBusiness } from "../types/business.ts" ;export const myBusiness : CallBusiness = async (ctx) => { };
业务函数从开始执行到 return 或 throw,对应一通通话的完整生命周期。中途挂机、超时、socket 断开,由 runtime 自动兜底清理(停 audio_fork、停 playback、清 conference registry 等)。
注册一个新业务(三步) 第 1 步:写业务文件 src/business/my-business.ts:
1 2 3 4 5 6 7 8 9 import type { CallBusiness } from "../types/business.ts" ;export const myBusiness : 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 步:导出 src/business/index.ts:
1 export { myBusiness } from "./my-business.ts" ;
第 3 步:注册 src/runtime/business-registry.ts:
1 2 3 4 5 6 import { myBusiness } from "../business/index.ts" ;export const businessRegistry = { "my-business" : myBusiness, };
配数据库路由 进 MySQL:
1 2 3 4 5 INSERT INTO call_businesses (biz_id, business_name)VALUES ('my-business' , 'my-business' );INSERT INTO call_business_number_mappings (destination_number, business_id)SELECT '2000' , id FROM call_businesses WHERE biz_id = 'my-business' ;
重启 callflow-esl,拨 2000 即触发 myBusiness。
外呼则在 POST /outbound-calls 的请求体里 bizId: "my-business"。
CallContext 用法范式 基础三件套:answer / speak / hangup 1 2 3 4 await ctx.execute ("answer" );await ctx.execute ("sleep" , String (ctx.config .server .settleDelayMs ));await ctx.speak ({ kind : "tts" , text : "..." });await ctx.hangup ("NORMAL_CLEARING" );
SessionRunner 不自动 answer 。交互业务必须显式 answer,给信令时间稳定再做下一步。否则可能出现”接通瞬间 TTS 已经播完一半”的怪现象。
播报 1 2 3 4 5 6 7 8 9 10 11 await ctx.speak ({ kind : "tts" , text : "请稍候" });await ctx.speak ({ kind : "wav" , file : "${sounds_dir}/welcome.wav" });await ctx.speak ( { kind : "tts" , text : "请稍候" , repeat : 3 , interruptible : true }, { onError : (e ) => ctx.log ("speak_error" , { error : e.error }) }, );
单轮识别 hear 1 2 3 4 5 6 7 8 9 10 11 12 const result = await ctx.hear ({ prompt : { kind : "tts" , text : "您好,请说出您的需求。" }, interruptible : true , bargeInTrigger : "vad" , timeoutMs : 10000 , }); if (result.status === "recognized" ) { ctx.log ("got_text" , { text : result.text }); } else { ctx.log ("no_speech" , { status : result.status }); }
result.status 取值:
"recognized":拿到了文本
"timeout":超时无说话
"cancelled":被业务主动 cancel
"hangup":通话已挂机
持续识别 callHear 整通通话保持 ASR,回调驱动。适合需要 partial 反馈或并发多个交互的场景。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 const session = await ctx.callHear ( { partialResults : true , bargeInTrigger : "vad" , interruptPlayback : true }, { 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" ); }
外呼 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 const r = await ctx.originate ({ destinationNumber : "13800138000" , bizId : "friend-callback-business" , callerIdNumber : "10086" , variables : { invited_from : ctx.channel .uuid }, }); const dial = await ctx.originateParkedCall ("1001" , { originateTimeoutMs : 20_000 });if (dial.status === "parked" ) { await ctx.bridgeCall (dial.bLegUuid , { preserveCurrentCallAfterBridge : true }); } const smart = await ctx.originateWithRingbackDetection ("13800138000" , { originateTimeoutMs : 20_000 , });
桥接 / 转接 1 2 3 4 5 6 await ctx.bridgeToNumber ("9000" ); await ctx.bridgeCall (dial.bLegUuid , { preserveCurrentCallAfterBridge : true , }); await ctx.hangupCall (dial.bLegUuid , "ORIGINATOR_CANCEL" ); const w = ctx.watchCallHangup (dial.bLegUuid );
会议 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 const conferenceId = "meeting-" + Date .now ();await ctx.conference .create (conferenceId);await ctx.conference .join (conferenceId);await ctx.conference .hear ( { conferenceId, partialResults : true }, { onFinal : async (e) => { await ctx.conference .play ({ conferenceId, input : { kind : "tts" , text : `${e.participant.callerIdNumber} 说:${e.text} ` }, }); }, }, ); await ctx.conference .injectSpeak ( { kind : "tts" , text : "您已被禁言。" }, { conferenceId, targetCallUuid : someParticipantUuid }, );
DTMF 1 2 3 4 5 6 7 8 9 10 11 12 13 const digit = await new Promise <string >((resolve ) => { const off = ctx.onDtmf ((event ) => { off (); resolve (event.digit ); }); }); const off = ctx.onDtmf (async (event) => { await ctx.cancelHear (`dtmf:${event.digit} ` ); await ctx.speak ({ kind : "tts" , text : `您按了 ${event.digit} 。` }); });
录音 1 2 3 4 5 6 7 8 const r = await ctx.startRecording ();ctx.log ("recording_started" , { filePath : r.filePath , fileUrl : r.fileUrl }); await ctx.startRecording ({ fileName : `vm-${ctx.channel.uuid} .wav` , directory : "/usr/local/freeswitch/voicemails" , });
录音随通道挂机自动结束,当前没有显式 stopRecording 。
通道变量 1 2 const id = await ctx.getVariable ("customer_id" );await ctx.setVariable ("ai_session_id" , "abc" );
错误与超时约定
情况
runtime 行为
业务建议
业务函数 throw
runtime 兜底挂机 + 清理
try/catch 关键节点,自定义降级
ctx.hear 超时
返回 { status: "timeout" }
决定重试 / 转人工 / 挂机
通话提前挂机
后续 ctx 调用拒绝执行 / 抛错
await ctx.waitForHangup() 主动等
ESL 命令超时
抛错 commandTimeoutMs
业务调短超时或重试
socket-end
runtime 自动清理;业务 promise 拒绝
finally 清理外部资源
异步与状态管理 callflow-esl 的核心 API 都是 Promise,但有些场景是回调驱动(callHear、onDtmf、conference.hear)。处理”回调里产生的副作用要按顺序执行”时,常见模式:
1 2 3 4 5 6 7 8 9 10 11 12 13 let actionQueue : Promise <void > = Promise .resolve ();const asr = await ctx.callHear ( { partialResults : true }, { onFinal : (event ) => { actionQueue = actionQueue.then (async () => { await handleFinalText (ctx, event.text ); }); }, }, );
actionQueue 保证多次 final 之间的副作用按到达顺序串行。
配置访问 业务函数里可读 ctx.config(已加载的 config.json 对象):
1 await ctx.execute ("sleep" , String (ctx.config .server .settleDelayMs ));
新增业务级常量时,先想清楚:
业务私有、不跨业务复用 → 直接写在业务文件顶部 const FOO = ...
多业务共享、运维可调 → 加到 config.json 的合适分组下
开发命令 1 2 3 4 5 6 bun run dev bun run start bun run typecheck bun run db:generate bun run db:push bun run db:studio
提交前至少跑 bun run typecheck。
现有业务样例 仓库已实现 11 个 demo 业务,可直接参考或复制:
业务名
用途
default-call-business
多轮识别 + 回显
prompt-then-recognize-echo-business
一次性识别 + 回显
carrier-empty-number-simulator-business
模拟运营商空号提示音
conference-host-business
会议主持人
conference-guest-business
会议访客
dtmf-business
DTMF 抢占语音
call-hear-business
整通持续识别
call-transfer-demo-business
直接 bridge 转人工
proxy-dial-demo-business
智能外呼(回铃运营商识别)
parked-dial-demo-business
帮拨号(park → 确认 → bridge)
verbose-prompt-then-recognize-business
多轮带详细日志的识别
更多模式与最小代码示例见 典型场景 。
还想要但当前没有的能力
想做
状态
stopRecording
当前录音随通道挂机自动结束,无显式 stop
跨业务的全局 KV
业务之间靠 channel variable / DB 通信
“纯 AI 主持人”模式(conference 不入会)
ctx.conference.hear 需要绑定在当前通道上
多语种 ASR
当前默认 zh-en 双语模型;可换模型,但接口不变
需要时按 callflow-esl 分层架构,在对应 controller 加方法、再在 runtime-context.ts 暴露。