业务模型

在 callflow-esl 里,一通通话 = 一段业务函数。函数签名固定:

1
2
3
4
5
import type { CallBusiness } from "../types/business.ts";

export const myBusiness: CallBusiness = async (ctx) => {
// ctx 是 CallContext,全部能力都从它出
};

业务函数从开始执行到 returnthrow,对应一通通话的完整生命周期。中途挂机、超时、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
// TTS
await ctx.speak({ kind: "tts", text: "请稍候" });

// 播 wav
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, // 用户说话即打断 prompt
bargeInTrigger: "vad", // 由 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
// fire-and-forget
const r = await ctx.originate({
destinationNumber: "13800138000",
bizId: "friend-callback-business",
callerIdNumber: "10086",
variables: { invited_from: ctx.channel.uuid },
});

// 外呼到 park(业务自己决定何时 bridge)
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,
});
// smart.status: "answered" | "carrier_prompt_detected" | "no_answer" | "failed"
// smart.carrierPromptKind: "empty_number" | "powered_off" | ...

桥接 / 转接

1
2
3
4
5
6
await ctx.bridgeToNumber("9000");                          // 直接 bridge 到号码
await ctx.bridgeCall(dial.bLegUuid, { // bridge 到已有 leg
preserveCurrentCallAfterBridge: true,
});
await ctx.hangupCall(dial.bLegUuid, "ORIGINATOR_CANCEL"); // 远端挂断
const w = ctx.watchCallHangup(dial.bLegUuid); // 订阅指定 leg 挂机

会议

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);

// 会议级 ASR
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);
});
});

// 持续监听(与 hear 配合做 "按键抢占语音")
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,但有些场景是回调驱动(callHearonDtmfconference.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 () => {
// 顺序处理 final 文本,避免并发交错
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 # TS 严格类型检查(CI 必跑)
bun run db:generate # 生成 Drizzle migration
bun run db:push # 应用 schema 到数据库
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 暴露。