服务定位

callflow-esl 是基于 Bun + TypeScript 的 FreeSWITCH outbound socket 业务编排服务。每条 outbound 连接被包装成一条通话会话,由业务代码驱动整通流程:摘机、播报、识别、桥接、外呼、会议、录音、DTMF……所有能力通过统一的 CallContext 暴露给业务层。

你只关心”这通电话要怎么走”,剩下的 ESL 协议细节、事件订阅、状态机、超时清理由 runtime 兜底。

双入口架构

服务同时承载两个入口:

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
        ┌────────────────────────────┐
│ Caller / Trunk │
└────────────┬───────────────┘

┌────────────────────────────────────────┐
│ FreeSWITCH │
│ ┌────────────────────────────────┐ │
│ │ dialplan: socket host:port ... │◀──┐│
│ └──────────────┬─────────────────┘ ││
│ │ outbound TCP ││
│ ▼ ││
└────────────────────────────────────────┘
│ │
│ ESL │ inbound ESL
▼ │ (originate)
┌────────────────────────────────────────┐
│ callflow-esl (Bun + TypeScript) │
│ SessionRunner → CallRuntime → │
│ speak / hear / originate / ... │
│ HTTP POST /outbound-calls ────────────┘
└────────────────────────────────────────┘

▼ 外部依赖
┌────────────────────────────────────────┐
│ sherpa-tts-server (HTTP) │
│ sherpa-asr-online-server (WebSocket) │
│ MySQL (业务路由 + 号码映射) │
└────────────────────────────────────────┘
  • ESL TCP(默认 9911):FreeSWITCH dialplan 通过 <action application="socket" data="host:port async full"/> 把通话送入
  • HTTP(默认 9912):调用方通过 POST /outbound-calls 触发外呼

三层分层

1
2
3
4
5
6
7
8
9
10
11
12
13
14
┌─────────────────────────────────────────────────┐
│ Business Layer │
│ 一文件一业务:src/business/*.ts │
│ 函数签名:(ctx: BusinessContext) => Promise │
├─────────────────────────────────────────────────┤
│ Runtime Layer │
│ SessionRunner ← 编排一条 outbound 连接 │
│ CallRuntime ← 通话运行时主体 │
│ 各 Controller ← speak / hear / dtmf / ... │
├─────────────────────────────────────────────────┤
│ Protocol Layer │
│ ESL parser / commands / events │
│ inbound ESL client(originate 用) │
└─────────────────────────────────────────────────┘

关键文件:

文件 职责
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
2
3
4
5
6
1. 若 channel variable biz_id 非空
→ 查 call_businesses 表 → 拿到 businessName
2. 否则按 destinationNumber
→ 查 call_business_number_mappings inner join call_businesses
3. 用 businessName 在 business-registry 中查到对应函数
4. 调用业务函数,传入 CallContext

路由失败时,runtime 接通后播报”系统错误”再挂机。

数据库 schema

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
-- call_businesses:业务定义
CREATE TABLE call_businesses (
id INT PRIMARY KEY AUTO_INCREMENT,
biz_id VARCHAR(64) UNIQUE, -- 外部触发用,对应 ctx.originate({ bizId })
business_name VARCHAR(128), -- 与 business-registry 注册名一致
...
);

-- call_business_number_mappings:被叫号码 → 业务
CREATE TABLE call_business_number_mappings (
id INT PRIMARY KEY AUTO_INCREMENT,
destination_number VARCHAR(64),
business_id INT REFERENCES call_businesses(id),
...
);

详见 src/db/schema.ts

HTTP 外呼 API

1
2
3
4
5
6
7
8
9
10
11
12
13
14
POST /outbound-calls
Content-Type: application/json

{
"destinationNumber": "13800138000",
"bizId": "default-call-business",
"callerIdNumber": "10086",
"callerIdName": "AI Bot",
"variables": {
"customer_id": "c001",
"campaign": "may-test"
},
"dialStringTemplate": "sofia/gateway/local-freeswitch-internal/{{destinationNumber}}"
}

返回:

1
2
3
4
5
6
{
"ok": true,
"requestId": "uuid",
"replyText": "+OK ...",
"destinationNumber": "13800138000"
}

字段说明:

  • destinationNumber(必填):被叫号码
  • bizId(可选):写入 channel variable biz_id,回拨进入 ESL 后优先按 biz_id 路由
  • callerIdNumber / callerIdName(可选):覆盖 origination_caller_id_*
  • variables(可选):透传到 ESL 会话的额外通道变量
  • dialStringTemplate(可选):覆盖默认拨号串模板,必须包含 {{destinationNumber}} 占位

配置文件

config.json(节选,完整字段见 配置参考):

1
2
3
4
5
6
7
8
{
"server": { "host": "0.0.0.0", "port": 9911, "asrTimeoutMs": 30000 },
"http": { "host": "0.0.0.0", "port": 9912 },
"freeswitch": { "host": "192.168.2.184", "port": 8021, "password": "ClueCon" },
"audioFork": { "wsUrl": "ws://192.168.2.246:10096/audio", "mixType": "mono", "sampleRate": "16k" },
"tts": { "sherpaHttpEndpoint": "http://127.0.0.1:9081/tts", "playbackTarget": "wav-url" },
"db": { "url": "mysql://root:mysql@localhost:3306/ai-voice" }
}

启动

1
2
3
4
5
6
7
8
cd callflow-esl
bun install
bun run db:generate
bun run db:push
bun run dev # 监听模式
# 或
bun run start # 一次性启动
bun run typecheck # 严格类型检查

服务启动后会同时监听:

  • ESL TCP:config.server.host:port(默认 0.0.0.0:9911
  • HTTP:config.http.host:port(默认 0.0.0.0:9912

FreeSWITCH dialplan 接入

1
2
3
4
5
6
<extension name="route-to-callflow">
<condition field="destination_number" expression="^(.+)$">
<action application="set" data="hangup_after_bridge=true"/>
<action application="socket" data="192.168.2.246:9911 async full"/>
</condition>
</extension>

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 行为,方便一处变更一类业务

下一步