跳到正文

业务流程编写文档

本文档讲清楚一段业务(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_completed

SessionRunner 负责所有“与业务无关的协议琐事”(握手、订阅事件、兜底 清理);业务函数只关心“这通电话要做什么”。

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 是恒等函数,只为对象字面量提供精确的上下文类型——handlerctx 会被自动 推导为 BusinessContext,无需在箭头函数上再标注 CallBusiness

BusinessContextruntime-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. 业务必须知道的约束

  1. 不会自动 answerSessionRunner 已经在最新版本里去掉了自动摘机; 普通交互业务必须显式 ctx.execute("answer"),模拟运营商早媒体业务则用 ctx.execute("pre_answer"),需要直接“回绝”的业务可以连 pre_answer 都 不做、直接 ctx.hangup("CALL_REJECTED")
  2. 业务函数返回 ≠ 通话结束。函数 return 后,SessionRunner 会 waitForHangup() 等对端挂机;想立刻结束需要 await ctx.hangup(...),再 return
  3. 只能依赖 ctx 暴露的能力。runtime 内部状态(activeRecognitionaudioForkActive 等)由 controllers 管理,业务不应直接修改。
  4. callHear / hear / conference.hear 互斥。这三者共用 audio_fork, 同一时间只能存在一个。runtime 会在第二次启动时抛错。
  5. 回调内禁止抛错把会话拖死。组件回调(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
DTMFctx.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-businessdtmf-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-businessoriginateWithRingbackDetection 链路当被叫陪练。

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_idctx.conference.join({conferenceId})。会议级 ASR 通过 ctx.conference.hear() 启动后,会按成员维度回调 partial / final。

参考 conference-host-businessconference-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 握手后按以下顺序解析业务:

  1. business_code 非空时,查询 call_businesses 获取稳定 businessCode
  2. 否则按 destinationNumber 查询 call_business_number_mappings 并关联 call_businesses
  3. 使用 businessCodebusiness-registry 中定位 handler。

路由失败时会接通并播报“系统错误”后挂机。路由结果携带 call_businesses.businessConfig;只有号码映射命中时才携带 call_business_number_mappings.mappingConfig。最终只读 ctx.businessConfig 按 “配置文件 < 业务 < 映射”覆盖,显式 business_code 路由不应用映射层。

8. 注册一段新业务(操作清单)

  1. src/business/ 新建业务。简单业务用单文件 voice-vote-business.ts, 入口用 defineBusiness 把编码 / 描述 / handler 就地写在一起并具名导出:

    ts
    import { 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/)。

  2. src/business/index.ts 重新导出该符号:

    ts
    export { voiceVoteBusiness } from "./voice-vote-business.ts";
    // 文件夹形态则是 "./voice-vote/index.ts"
  3. src/runtime/business-registry.tsimport 块引入它,并把它追加到 businesses 数组。注意:registry 不再重复 code / desc,只负责「汇总 + 唯一性校验」(重复 code 会在启动期直接抛错):

    ts
    import {
      // ... 其它业务 ...
      voiceVoteBusiness,
    } from "../business/index.ts";
    
    const businesses: RegisteredCallBusiness[] = [
      // ... 其它业务 ...
      voiceVoteBusiness,
    ];
  4. 运行一次 bun run typecheck 确保类型通过。

  5. 选择一种路由方式:

    • 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.businessConfigcall_business_number_mappings.mappingConfig;数据库两层 直接填写业务字段,不增加业务编码包裹,显式 business_code 路由跳过最后一层。

  6. 启动外呼(通过 HTTP)或在 FreeSWITCH 拨号计划中拨那个号码即可触达。

9. 调试技巧

  • ctx.log(event, details) 写出来的日志带 callUuid / businessCode / businessName / sessionStage,落盘到 config.logging.dir 下的 winston 文件中
  • verbose-prompt-then-recognize-businessspeak / 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

文档与代码在同一仓库维护,现有 Markdown 是唯一内容源。