服务定位
callflow-esl 是基于 Bun + TypeScript 的 FreeSWITCH outbound socket 业务编排服务。每条 outbound 连接被包装成一条通话会话,由业务代码驱动整通流程:摘机、播报、识别、桥接、外呼、会议、录音、DTMF……所有能力通过统一的 CallContext 暴露给业务层。
你只关心”这通电话要怎么走”,剩下的 ESL 协议细节、事件订阅、状态机、超时清理由 runtime 兜底。
双入口架构
服务同时承载两个入口:
1 | ┌────────────────────────────┐ |
- ESL TCP(默认 9911):FreeSWITCH dialplan 通过
<action application="socket" data="host:port async full"/>把通话送入 - HTTP(默认 9912):调用方通过
POST /outbound-calls触发外呼
三层分层
1 | ┌─────────────────────────────────────────────────┐ |
关键文件:
| 文件 | 职责 |
|---|---|
index.ts |
进程入口,启动 ESL + HTTP server |
src/runtime/session-runner.ts |
一条 outbound 连接的生命周期编排 |
src/runtime/call-runtime.ts |
CallRuntime 主体 |
src/runtime/runtime-context.ts |
给业务层暴露的 CallContext facade |
src/runtime/playback-controller.ts |
speak 状态机 |
src/runtime/recognition-controller.ts |
hear / callHear / conference hear |
src/runtime/conference-controller.ts |
FS conference 集成 |
src/runtime/dtmf-controller.ts |
DTMF 监听与发送 |
src/runtime/recording-controller.ts |
通话录音 |
src/runtime/originate/ |
外呼 / 桥接 / 回铃检测 |
src/runtime/business-registry.ts |
业务名 → 业务函数注册表 |
src/business/*.ts |
业务实现(每文件一业务) |
src/esl/ |
ESL 协议层 |
src/freeswitch/ |
inbound ESL 客户端 / originate |
src/http/ |
HTTP 路由 |
src/db/ |
MySQL schema、业务路由、Drizzle ORM |
CallContext 能力面板
业务里能直接用的方法:
| 类别 | 方法 | 用途 |
|---|---|---|
| 基础控制 | ctx.execute(app, args?) |
下发任意 FS application(answer / pre_answer / playback / hangup …) |
ctx.hangup(cause?) |
挂机 | |
ctx.waitForHangup() |
等到当前通道挂断 | |
| 播报 | ctx.speak({ kind: "tts", text }) |
TTS 合成并播放 |
ctx.speak({ kind: "wav", file }) |
播放 wav 文件 | |
| 识别 | ctx.hear({ prompt?, timeoutMs?, interruptible? }) |
单轮识别(可带 prompt) |
ctx.callHear(opts, handlers) |
整通持续 ASR,回调驱动 | |
ctx.cancelHear(reason) |
主动取消当前 hear | |
| 外呼 | ctx.originate({ destinationNumber, bizId, ... }) |
发起 fire-and-forget 外呼 |
ctx.originateParkedCall(...) |
外呼到 park,业务自己决定何时 bridge | |
ctx.originateWithRingbackDetection(...) |
外呼 + 回铃运营商提示音识别 | |
| 桥接 | ctx.bridgeToNumber(num) |
直接 bridge 到号码 |
ctx.bridgeCall(uuid, opts) |
把当前通道 bridge 到已有 leg | |
ctx.hangupCall(uuid, cause) |
远端挂断指定通道 | |
ctx.watchCallHangup(uuid) |
订阅指定通道挂机事件 | |
| 会议 | ctx.conference.create(id) |
创建会议 |
ctx.conference.join(id) |
当前通道入会 | |
ctx.conference.hear(opts, handlers) |
会议级 ASR | |
ctx.conference.play({ input, ... }) |
会议级播报 | |
ctx.conference.injectSpeak(input, opts) |
只播给特定成员 | |
| DTMF | ctx.onDtmf(handler) |
注册 DTMF 监听 |
ctx.sendDtmf(digits) |
主动下发 DTMF | |
| 录音 | ctx.startRecording(opts?) |
启动通话录音 |
| 通道变量 | ctx.getVariable(name) |
读 channel variable |
ctx.setVariable(name, value) |
写 channel variable | |
| 日志 | ctx.log(event, payload) |
结构化日志 |
业务路由
SessionRunner 在 outbound 握手拿到 channel info 后,按以下顺序解析业务:
1 | 1. 若 channel variable biz_id 非空 |
路由失败时,runtime 接通后播报”系统错误”再挂机。
数据库 schema
1 | -- call_businesses:业务定义 |
详见 src/db/schema.ts。
HTTP 外呼 API
1 | POST /outbound-calls |
返回:
1 | { |
字段说明:
destinationNumber(必填):被叫号码bizId(可选):写入 channel variablebiz_id,回拨进入 ESL 后优先按biz_id路由callerIdNumber/callerIdName(可选):覆盖origination_caller_id_*variables(可选):透传到 ESL 会话的额外通道变量dialStringTemplate(可选):覆盖默认拨号串模板,必须包含{{destinationNumber}}占位
配置文件
config.json(节选,完整字段见 配置参考):
1 | { |
启动
1 | cd callflow-esl |
服务启动后会同时监听:
- ESL TCP:
config.server.host:port(默认0.0.0.0:9911) - HTTP:
config.http.host:port(默认0.0.0.0:9912)
FreeSWITCH dialplan 接入
1 | <extension name="route-to-callflow"> |
async full 表示让 dialplan 立即把控制权交给 callflow-esl,并允许全部事件订阅。
设计要点
- 不自动 answer:交互类业务必须显式
ctx.execute("answer"),避免无关业务被误响铃 - runtime 兜底清理:hangup / socket-end 时自动清理 audio_fork、playback、conference registry,业务无需手动
- 事件订阅策略:
audioFork.eventSubscriptionMode默认"all",在当前 FS 版本下是唯一稳定下发transcription的策略 - 业务参数集中管理:每个业务文件顶部用常量管理首轮播报、识别后动作、barge-in 行为,方便一处变更一类业务