跳到正文

业务流程编写文档 ​

本文档讲清楚一段业务(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, category, desc, handler }) 把「稳定业务编码 / 主场景分类 / 中文描述 / handler」四者就地共置,并具名导出。defineBusiness 是恒等函数,只为对象字面量提供精确的上下文类型——handler 的 ctx 会被自动 推导为 BusinessContext,无需在箭头函数上再标注 CallBusiness。

category 是业务注册表同步至数据库的稳定主场景分类,只允许四个值:

值主场景
outbound外呼主导业务(通用外呼流程解释器、电销对话、知识库外呼等)
inbound呼入主导业务(客服接待、会议主持/嘉宾、验证码接听识别等)
assist坐席辅助与协同(软电话辅助、AI 转人工坐席入会、回铃检测等)
demo测试、诊断、模拟与功能验证演示业务

分类按业务的主要场景选择,不等同于物理目录。分类只用于 API/UI 展示、分组和筛选,不参与 handler 路由、号码映射、campaign,也不会写入 FreeSWITCH 通道变量。现有 26 项权威映射见 existing-businesses.md。

BusinessContext 由 runtime-context.ts 的 createCallContext() 返回值推导, 方法清单见 组件 API。

最小业务模板:

ts
import { defineBusiness } from "../types/business.ts";

export const myDemoBusiness = defineBusiness({
  code: "my-demo-business",
  category: "demo",
  desc: "示例业务",
  handler: async ctx => {
    // 1. 决定是否接通:answer / pre_answer / 直接挂机
    await ctx.execute("answer");
    await ctx.execute("sleep", String(ctx.config.eslServer.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, category, desc, handler })—— 当 handler 较长、或需要拆出多个辅助函数时这种写法更清晰。

3. 业务必须知道的约束 ​

  1. 不会自动 answer。SessionRunner 不自动摘机,普通交互业务必须显式 调用 ctx.execute("answer"),模拟运营商早媒体业务则用 ctx.execute("pre_answer"),需要直接“回绝”的业务可以连 pre_answer 都 不做、直接 ctx.hangup("CALL_REJECTED")。
  2. 业务函数返回 ≠ 通话结束。函数 return 后,SessionRunner 会 waitForHangup() 等对端挂机;想立刻结束需要 await ctx.hangup(...),再 return。
  3. 只能依赖 ctx 暴露的能力。runtime 内部状态(activeRecognition、 audioForkActive 等)由 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
全双工ctx.duplex.start(options?, handlers?)建立全双工流式会话与 media bug
ctx.duplex.speak(text, options?)下行推流播报(支持开嗓瞬时打断与定稿收尾)
ctx.duplex.interrupt(reason?)业务主动瞬时打断当前下行播报
ctx.duplex.stop(reason?)停止全双工会话并注销 media bug
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.conference.recording.start/stop/pause/resume/status会议级录音调度与状态查询
录音ctx.startRecording(input?)当前通道录音(文件名可省略)
转人工协同requestHumanAgentTransfer(ctx, options)请求 ACD 路由、Cell 注册预检与坐席预占
(helper)joinConferenceWithAgentWatchdog(ctx, options)客户入会并执行两阶段在会看护(防抖+优雅自退)
requestHumanAgentTransferEnd(ctx, options)客户离会时幂等收尾会议人工腿

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.eslServer.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-business、dtmf-business。

ts
export const loopDemoBusiness: CallBusiness = async ctx => {
  await ctx.execute("answer");
  await ctx.execute("sleep", String(ctx.config.eslServer.settleDelayMs));

  let prompt: SpeakInput | undefined = {
    kind: "tts",
    text: "您好,请说话。"
  };

  while (true) {
    const result = await ctx.hear({
      prompt,
      interruptible: true,
      bargeInTrigger: "partial",
      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.eslServer.settleDelayMs));

  let shuttingDown = false;
  let actionQueue: Promise<void> = Promise.resolve();

  const session = await ctx.callHear(
    {
      partialResults: true,
      interruptPlayback: true,
      bargeInTrigger: "partial"
    },
    {
      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;
  }
};

5.4 原生全双工流式交互(ctx.duplex) ​

全双工模式适合支持流式播报和用户打断的实时对话。bargeInTrigger 决定打断时机;下例选择 partial,收到首个非空增量文本后打断。完整实现见 duplex-ai-chat-demo-business。

ts
export const duplexChatDemoBusiness: CallBusiness = async ctx => {
  await ctx.execute("answer");
  await ctx.execute("sleep", String(ctx.config.eslServer.settleDelayMs));

  let actionQueue: Promise<void> = Promise.resolve();
  let exiting = false;
  const session = await ctx.duplex.start(
    { bargeInTrigger: "partial" },
    {
      onFinal: e => {
        if (exiting || !e.text.trim()) return;
        actionQueue = actionQueue.then(async () => {
          if (e.text.includes("再见")) {
            exiting = true;
            await ctx.duplex.speak("好的,再见!", { interruptible: false });
            await ctx.hangup();
            return;
          }
          await ctx.duplex.speak(`您刚刚说:${e.text}。`, {
            interruptible: true
          });
        });
      },
      onBargeIn: e => {
        ctx.log("user_barge_in", { reason: e.reason });
      }
    }
  );
  ctx.log("duplex_started", { uuid: session.uuid });

  try {
    await ctx.duplex.speak(
      "您好!我是原生全双工语音助手,请问有什么可以帮您?",
      {
        interruptible: true
      }
    );
    await ctx.waitForHangup();
  } finally {
    exiting = true;
    await actionQueue.catch(() => {});
    await ctx.duplex.stop("call-completed");
  }
};

onFinal 是同步事件回调,不会等待其中启动的异步任务。用 actionQueue 串行处理 连续到达的 final,并在清理前等待队列结束,避免播报与挂机重入。

挂机后尾部识别(postHangupDrainMs):默认对端一挂机就停 callHear,可能丢掉 “挂机瞬间还在说”的最后一段 final。给 callHear 入参加 postHangupDrainMs: 1500 后,runtime 会在挂机后多保留 audio_fork 一小段时间收尾部识别——这些事件走 onPostHangupResult / onPostHangupFinal(不再触发 onResult / onFinal), 事件 phase 为 "post-hangup",只应用于补收文本,不要再驱动播放 / 按键 / 挂机 等通话内动作。session.stop() 会等 drain 结束后才返回。完整示例见 verification-code-business/(持续 ASR + 自动按键 + 尾部补收 + 结果上报)。

5.5 并发:语音 + 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.6 早媒体 / 直接拒接 ​

不 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.eslServer.settleDelayMs));
  // 早媒体阶段播报;真实运营商提示通常会循环,直到主叫取消或上游清理 B-leg。
  await ctx.speak({
    kind: "tts",
    text: "您好,您所拨打的号码是空号,请核对后再拨。"
  });
};

需要“直接回绝”的业务可以连 pre_answer 都不做,直接 await ctx.hangup("CALL_REJECTED")。完整实现参考 carrier-simulator-business/ (按通道变量选三大运营商 19 条样本、支持回铃 + 提示音两种时序、循环播报且 不主动挂机),它专门给 carrier-ringback-detection-demo-business 的 originateWithRingbackDetection 链路当被叫陪练。

5.7 外呼 / 桥接 ​

四种外呼能力的语义见 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.8 会议 ​

会议至少有“主持人”和“访客”两段业务。主持人 originate 邀请访客,访客 拿到 conference_id 后 ctx.conference.join({conferenceId})。会议级 ASR 通过 ctx.conference.hear() 启动后,会按成员维度回调 partial / final。

参考 conference-host-business 和 conference-guest-business。

5.9 转人工与两阶段会议看护 ​

转人工采用会议室桥接模型。业务侧停单呼 ASR、调用 requestHumanAgentTransfer 预占坐席并派发 originate,随后进入 joinConferenceWithAgentWatchdog(两阶段自治看门狗):

  • Phase 1(waiting_for_agent):等待坐席加入会议,振铃超时(默认 +5s 缓冲)无坐席入会则自动通过自退打断阻塞,执行超时兜底;
  • Phase 2(in_call_guard):坐席入会后平滑切换在会看护。职员离会使 count <= 1 时启动防抖倒计时(默认 3 秒);期满复核仍无职员,客户腿优雅自退。
  • 在 finally 块中调用 requestHumanAgentTransferEnd 幂等收尾所有人工腿。
ts
import {
  requestHumanAgentTransfer,
  joinConferenceWithAgentWatchdog,
  requestHumanAgentTransferEnd
} from "./shared/callout/index.ts";

const conferenceId = `transfer-${randomUUID()}`;
await ctx.speak({ kind: "tts", text: "正在为您转接人工坐席,请稍候。" });
await asrSession?.stop("transfer-to-agent");

const route = await requestHumanAgentTransfer(ctx, {
  conferenceId,
  customerCallUuid: ctx.channel.uuid,
  ringTimeoutSeconds: 30
});

if (!route.routed) {
  await ctx.speak({ kind: "tts", text: "人工坐席忙,请稍后再拨。" });
  await ctx.hangup("NORMAL_CLEARING");
  return;
}

try {
  const watch = await joinConferenceWithAgentWatchdog(ctx, {
    conferenceId,
    ringTimeoutSeconds: 30,
    emptyGraceSeconds: 3
  });
  if (!watch.agentJoined && !watch.hungUp) {
    await ctx.speak({
      kind: "tts",
      text: "坐席振铃超时未接听,电话即将挂断。"
    });
    await ctx.hangup("NORMAL_CLEARING");
  }
} finally {
  await requestHumanAgentTransferEnd(ctx, {
    conferenceId,
    customerCallUuid: ctx.channel.uuid,
    reason: "customer_hangup"
  });
}

5.10 声明式 Voice Agent SDK(defineVoiceAgent + runVoiceAgent) ​

对于大模型驱动的语音智能体对话业务,无需手动编写复杂的持续 ASR 状态机、多轮转写队列与分片播报逻辑,推荐使用 src/llm/ 提供的 Voice Agent SDK:

  1. 声明式智能体角色(defineVoiceAgent):结构化声明角色职责(persona.role)、行为约束(persona.constraints)、对话样例(persona.examples)与大模型采样参数(sampling),SDK 会自动组合编译为符合语音对话最佳实践的系统提示词;
  2. 一行式编排运行(runVoiceAgent):在 handler 内直接调用,全自动托管接通问候、整通持续 ASR、开嗓即打断(bargeInTrigger: "partial")、SSE 流式分句、TTS 即产即播、静默催问与挂机收尾;
  3. Provider 抽象:支持通过 channel: "openai" 直连 OpenAI 兼容后端,或通过 channel: "callout" 接入外呼平台 AI 策略中台。
ts
import { defineBusiness } from "../types/business.ts";
import { defineVoiceAgent, runVoiceAgent } from "../llm/index.ts";

const demoAgent = defineVoiceAgent({
  persona: {
    role: "你是亲切、有耐心的智能语音客服助手,先直接回答用户当前的问题,不寒暄绕弯。",
    constraints: ["涉及具体金额、账号等敏感信息时,引导用户走人工核实。"],
    examples: [
      {
        user: "你们几点下班?",
        assistant: "我们晚上九点下班,现在还在营业,有需要您现在说就行。"
      }
    ]
  },
  // openingPrompt 支持纯文本字符串(默认可打断),或 { text, interruptible: false } 对象(在模拟线/电信号转 SIP 设备回音消除收敛慢时可设为 false,确保开场白播完后再建 ASR)
  openingPrompt: "您好,我是智能语音助手,请直接说出您的问题。",
  goodbyePrompt: "好的,本次通话就到这里,祝您生活愉快,再见。"
});

export const voiceAgentDemoBusiness = defineBusiness({
  code: "voice-agent-demo-business",
  category: "demo",
  desc: "Voice Agent SDK 演示业务",
  handler: async ctx => {
    await runVoiceAgent(ctx, demoAgent, {
      channel: "openai",
      record: true,
      bargeInTrigger: "partial",
      minBargeInTextLength: 2
    });
  }
});

完整端到端样板见 src/business/demo/voice-agent-demo-business.ts。

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 配置项 ​

业务参数不通过环境变量配置,归属如下:

参数应放在哪里
业务文本、关键词、循环次数业务文件顶部 const
模型 / 后端地址 / 超时 / TCP 监听config.toml + AppConfig
barge-in 触发模式、停播命令业务文件常量;可在 hear() 入参覆盖
业务编码 / 主场景分类 / 中文描述 / handler业务文件内 defineBusiness({...})
业务汇总(清单与导出)src/business/index.ts
业务反查与索引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. 使用 businessCode 在 business-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",
      category: "demo",
      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 中引入并追加到 coreBusinesses(或 allBusinesses);
    • 测试/演示/模拟业务:放在 src/business/demo/ 下,并在 src/business/demo/index.ts 的 demoBusinesses 数组中追加。
    ts
    // 核心业务 (src/business/index.ts)
    import { voiceVoteBusiness } from "./voice-vote-business.ts";
    export const coreBusinesses: RegisteredCallBusiness[] = [
      // ...
      voiceVoteBusiness
    ];
    
    // 测试演示业务 (src/business/demo/index.ts)
    import { voiceVoteDemoBusiness } from "./voice-vote-demo-business.ts";
    export const demoBusinesses: RegisteredCallBusiness[] = [
      // ...
      voiceVoteDemoBusiness
    ];

    src/runtime/business-registry.ts 会直接消费全量 allBusinesses 自动构建索引与唯一性校验,无需再手动修改 registry。

  3. 运行一次 bun run typecheck 确保类型通过。

  4. 选择一种路由方式:

    • 按 businessCode 命中:服务启动时 syncRegisteredCallBusinesses 会把 registry 中每条业务的 code / category / desc 同步到 call_businesses; businessCategory 仅供查询与展示,不参与本步骤的命中
    • 按号码命中:先启动一次服务完成业务同步,再在 call_business_number_mappings 插入 ("destinationNumber"="9919", "businessCode"="<业务编码>", "isEnabled"=1)

    开发期可以在 apps/callflow-server 下通过 bun run db:studio 在浏览器里改表,或在数据库迁移脚本中加入。

    业务如需声明业务配置校验器,可在 defineBusiness 中增加 config: { validate }(如需强制要求静态配置文件必填,可增加 requiredInFile: true)。 文件默认值由稳定 code 自动从 config.toml.businessConfig[code] 定位,handler 只通过 只读 ctx.businessConfig 读取最终值。覆盖优先级为配置文件、 call_businesses.businessConfig、call_business_number_mappings.mappingConfig;数据库两层 直接填写业务字段,不增加业务编码包裹,显式 business_code 路由跳过最后一层。

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

9. 调试技巧 ​

  • ctx.log(event, details) 写出来的日志带 callUuid / businessCode / businessName / sessionStage,落盘到 config.logging.dir 下的结构化日志文件中
  • verbose-prompt-then-recognize-business 把 speak / hear 的全部回调 都接上了,初次接入时很适合复制一份当作“打印一切”的脚手架
  • 单元测试与模块同目录(src/**/*.test.ts,如 src/runtime/、src/runtime/conference/ 与各业务目录下的用例),可用 bun test 跑(部分用例需要 mock socket)
  • 数据库结构与迁移统一由 apps/callflow-server 维护,变更后在 apps/callflow-server 执行 bun run db:generate + bun run db:push
  • 与远端 FreeSWITCH 联调时,config.freeswitch.callbackHost 必须是 FreeSWITCH 主机能访问到的本机地址;否则 originate 接通后 outbound 永远 连不到 callflow-esl

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