跳到正文

可实现场景与示例代码

本文档系统梳理 callflow-esl 当前代码能覆盖的通话场景,并给出可以直接复用 的业务代码示例。所有示例只在文档中列出,不会自动落到 src/business/; 落地时按 business-development.md §8 注册即可。

为聚焦通话流程,下文示例以 export const xxxBusiness: CallBusiness = ... 的形式直接展示 handler 逻辑。真正落地时应改成 export const xxxBusiness = defineBusiness({ code, desc, handler }) 把编码 / 描述与 handler 就地共置,再按 §8 接入注册表。

按场景分类:


1. 提示音 / TTS 播报

接通后只播报一段文本/wav 然后挂机。

ts
import type { CallBusiness } from "../types/business.ts";

export const announceBusiness: CallBusiness = async (ctx) => {
  await ctx.execute("answer");
  await ctx.execute("sleep", String(ctx.config["esl-server"].settleDelayMs));

  await ctx.speak({
    kind: "tts",
    text: "您好,今天的天气是晴天,气温二十二摄氏度。",
  });

  await ctx.hangup("NORMAL_CLEARING");
};

播放 wav 文件:

ts
await ctx.speak({ kind: "wav", file: "${sounds_dir}/welcome.wav" });

重复播放 + 异常监听:

ts
await ctx.speak(
  { kind: "tts", text: "请稍候", repeat: 3 },
  { onError: (e) => ctx.log("speak_error", { error: e.error }) },
);

2. 一次性识别

接通后问一句话,识别成功就回显然后挂机。已实现: prompt-then-recognize-echo-business,原样使用即可。

ts
export const oneShotAskBusiness: 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("oneshot_no_text", { status: result.status });
    await ctx.hangup("NORMAL_TEMPORARY_FAILURE");
    return;
  }

  await ctx.speak({ kind: "tts", text: `您说的是:${result.text}` });
  await ctx.hangup("NORMAL_CLEARING");
};

3. 多轮识别 / 闲聊

while + hear,识别成功就把文本作为下轮提示音。已实现: default-call-business。如果想接 LLM 回答:

ts
import type { CallBusiness, BusinessContext } from "../types/business.ts";

export const chatBusiness: CallBusiness = async (ctx) => {
  await ctx.execute("answer");
  await ctx.execute("sleep", String(ctx.config["esl-server"].settleDelayMs));

  let prompt: { kind: "tts"; text: string } | undefined = {
    kind: "tts",
    text: "您好,有什么可以帮您?",
  };

  while (true) {
    const result = await ctx.hear({
      prompt,
      interruptible: true,
      bargeInTrigger: "vad",
      timeoutMs: 10000,
    });
    prompt = undefined;

    if (result.status !== "recognized") {
      ctx.log("chat_no_speech", { status: result.status });
      continue;
    }

    if (result.text.includes("挂机") || result.text.includes("再见")) {
      await ctx.speak({ kind: "tts", text: "好的,祝您生活愉快。" });
      await ctx.hangup();
      return;
    }

    const reply = await callLlmReply(ctx, result.text);
    prompt = { kind: "tts", text: reply };
  }
};

async function callLlmReply(_ctx: BusinessContext, userText: string): Promise<string> {
  // TODO: 替换为真实 LLM 调用
  return `您刚才说:${userText}`;
}

4. 持续 ASR

整通通话保持 audio_fork,业务按 final 文本驱动。已实现:call-hear-business。 适合需要 partial 反馈、低延迟反应的场景:

ts
export const transcribeOnlyBusiness: CallBusiness = async (ctx) => {
  await ctx.execute("answer");
  await ctx.execute("sleep", String(ctx.config["esl-server"].settleDelayMs));

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

5. DTMF(按键交互)

只关注 DTMF:

ts
export const dtmfMenuBusiness: CallBusiness = async (ctx) => {
  await ctx.execute("answer");
  await ctx.execute("sleep", String(ctx.config["esl-server"].settleDelayMs));

  await ctx.speak({
    kind: "tts",
    text: "您好,按 1 查询余额,按 2 转人工,按 0 退出。",
  });

  const digit = await new Promise<string>((resolve) => {
    const off = ctx.onDtmf((event) => {
      off();
      resolve(event.digit);
    });
  });

  ctx.log("dtmf_menu_selected", { digit });

  if (digit === "1") {
    await ctx.speak({ kind: "tts", text: "您的余额为一百二十三元。" });
  } else if (digit === "2") {
    await ctx.bridgeToNumber("9000");
  }
  await ctx.hangup("NORMAL_CLEARING");
};

6. 早媒体 / 拒接 / 模拟运营商提示音

不接通、仅 pre_answer 发 183 early media,再播报运营商提示音。已实现: carrier-simulator-business(按通道变量在三大运营商 18 条样本中选一条,用项目 统一 TTS 链路循环播报,自身不主动挂机,等主叫取消或检测器清理 B-leg)。常配合 originateWithRingbackDetectioncarrier-ringback-detection-demo-business 做端到端测试。下面是一个最简版(播一遍忙音提示就挂断):

ts
export const carrierBusyBusiness: CallBusiness = async (ctx) => {
  await ctx.execute("pre_answer");
  await ctx.execute("sleep", String(ctx.config["esl-server"].settleDelayMs));
  await ctx.speak({ kind: "tts", text: "您好,您拨打的电话正在通话中,请稍后再拨。" });
  await ctx.hangup("USER_BUSY");
};

未播放任何音直接拒接:

ts
export const rejectBusiness: CallBusiness = async (ctx) => {
  ctx.log("rejected_immediately", { destinationNumber: ctx.channel.destinationNumber });
  await ctx.hangup("CALL_REJECTED");
};

7. 外呼

业务内主动发起一通新通话(A→B),常用于会议邀请、提醒回访。

ts
export const inviteFriendBusiness: CallBusiness = async (ctx) => {
  await ctx.execute("answer");
  await ctx.execute("sleep", String(ctx.config["esl-server"].settleDelayMs));

  const friendNumber = await ctx.getVariable("friend_number");
  if (!friendNumber) {
    await ctx.speak({ kind: "tts", text: "缺少邀请号码。" });
    await ctx.hangup("NORMAL_TEMPORARY_FAILURE");
    return;
  }

  const result = await ctx.originate({
    destinationNumber: friendNumber,
    businessCode: "friend-callback-business",
    callerIdNumber: ctx.channel.callerIdNumber,
    variables: {
      invited_from: ctx.channel.uuid,
    },
  });

  ctx.log("invite_origination", { ok: result.ok, replyText: result.replyText });

  await ctx.speak({
    kind: "tts",
    text: result.ok ? "邀请已发出。" : "邀请失败。",
  });
  await ctx.hangup();
};

也可以通过 HTTP API 触发:

bash
curl -s -X POST http://127.0.0.1:9912/outbound-calls \
  -H "Content-Type: application/json" \
  -d '{
    "destinationNumber": "13800138000",
    "businessCode": "default-call-business",
    "callerIdNumber": "10086",
    "variables": { "campaign": "may-test" }
  }'

8. 转接 / 桥接

最简单的“直接 bridge”转人工。已实现:call-transfer-demo-business

ts
export const directTransferBusiness: CallBusiness = async (ctx) => {
  await ctx.execute("answer");
  await ctx.execute("sleep", String(ctx.config["esl-server"].settleDelayMs));

  await ctx.speak({ kind: "tts", text: "正在为您转接客服。" });

  const r = await ctx.bridgeToNumber("9000");
  ctx.log("transfer_result", {
    status: r.status,
    bridgeHangupCause: r.bridgeHangupCause,
  });

  if (r.status === "bridged") {
    await ctx.hangup();
  } else {
    await ctx.speak({ kind: "tts", text: "未能接通,请稍后再试。" });
    await ctx.hangup("NORMAL_TEMPORARY_FAILURE");
  }
};

9. 帮拨号(park → 确认 → bridge)

让用户在确认前听到“对方已接通”再连接。已实现:parked-dial-demo-business。 核心模式:

ts
const dial = await ctx.originateParkedCall("1001", {
  originateTimeoutMs: 20_000,
  dialStringTemplate: "user/{{destinationNumber}}",
});

if (dial.status !== "parked") {
  await ctx.speak({ kind: "tts", text: "对方未接通。" });
  return;
}

const ok = await askConfirm(ctx, "对方已接通,是否为您连接?");
if (!ok) {
  await ctx.hangupCall(dial.bLegUuid, "ORIGINATOR_CANCEL");
  return;
}

const bLegHangup = ctx.watchCallHangup(dial.bLegUuid);
const bridge = await ctx.bridgeCall(dial.bLegUuid, {
  preserveCurrentCallAfterBridge: true,
});

if (bridge.status === "bridged") {
  // 两人通话;监听任一端挂机,让业务可以拿回控制
  const callerHangup = ctx.watchCallHangup(ctx.channel.uuid);
  const ended = await Promise.race([
    bLegHangup.promise.then((r) => ({ type: "callee" as const, r })),
    callerHangup.promise.then((r) => ({ type: "caller" as const, r })),
  ]);
  ctx.log("two_party_ended", { side: ended.type, cause: ended.r.hangupCause });
}

askConfirm 是仓库里大多数 demo 都在用的小工具,可放到业务里:

ts
async function askConfirm(ctx: BusinessContext, promptText: string): Promise<boolean> {
  for (let attempt = 1; attempt <= 2; attempt++) {
    const r = await ctx.hear({
      prompt: { kind: "tts", text: promptText },
      interruptible: true,
      bargeInTrigger: "vad",
      timeoutMs: 10000,
    });
    if (r.status === "recognized") {
      const text = r.text;
      if (/||/.test(text)) return false;
      if (/|好的|可以|确认|连接/.test(text)) return true;
    }
    promptText = "没有听清,请再说一次。";
  }
  return false;
}

10. 智能外呼(回铃运营商提示音识别 + 重试)

originateWithRingbackDetection 在 ringing / early-media 阶段同步开启 ASR,按返回 carrierPromptKind 判定空号 / 关机 / 停机等。已实现: carrier-ringback-detection-demo-business

ts
export const smartOutboundBusiness: CallBusiness = async (ctx) => {
  await ctx.execute("answer");
  await ctx.execute("sleep", String(ctx.config["esl-server"].settleDelayMs));

  const target = await ctx.getVariable("target_number");
  if (!target) {
    await ctx.hangup("NORMAL_TEMPORARY_FAILURE");
    return;
  }

  await ctx.speak({ kind: "tts", text: "正在为您拨号。" });

  const dial = await ctx.originateWithRingbackDetection(target, {
    originateTimeoutMs: 20_000,
    dialStringTemplate: "sofia/gateway/local-freeswitch-internal/{{destinationNumber}}",
  });

  ctx.log("smart_outbound_result", {
    status: dial.status,
    kind: dial.carrierPromptKind,
    detectedText: dial.detectedText,
  });

  switch (dial.status) {
    case "answered":
      // 已接通,自行决定下一步:bridge / 进入 IVR / hangup
      await ctx.speak({ kind: "tts", text: "对方已接通。" });
      break;
    case "carrier_prompt_detected":
      await ctx.speak({
        kind: "tts",
        text: `对方${dial.carrierPromptKind === "empty_number" ? "是空号" : "暂时无法接通"}。`,
      });
      break;
    case "no_answer":
      await ctx.speak({ kind: "tts", text: "对方未接听。" });
      break;
    case "failed":
      await ctx.speak({ kind: "tts", text: "拨号失败。" });
      break;
  }

  await ctx.hangup();
};

11. 多方语音会议

主持人 + 访客两段业务配合使用:

  • 主持人:conference-host-business(已实现)
  • 访客:conference-guest-business(已实现)

外部触发主持人入会:

bash
curl -X POST http://127.0.0.1:9912/outbound-calls -H "Content-Type: application/json" \
  -d '{ "destinationNumber": "1000", "businessCode": "conference-host-business", "callerIdNumber": "1000" }'

主持人接通后会 originate 1001 进访客业务,访客读取 conference_id 后入会。


12. 会议级 ASR + 播报

ctx.conference.hear 按成员维度回调;主持人业务里典型用法(已实现):

ts
await ctx.conference.hear(
  { conferenceId, partialResults: true },
  {
    onFinal: async (event) => {
      const announcement = `会议识别结果,${event.participant.callerIdNumber} 说:${event.text}`;
      try {
        await ctx.conference.play({
          conferenceId: event.conferenceId,
          input: { kind: "tts", text: announcement, interruptible: true },
        });
      } catch (error) {
        ctx.log("conference_announcement_failed", {
          error: error instanceof Error ? error.message : String(error),
        });
      }
    },
  },
);

只播给某成员:

ts
await ctx.conference.injectSpeak(
  { kind: "tts", text: "您已被主持人禁言。" },
  { conferenceId, targetCallUuid: someParticipantUuid },
);

13. 通话录音

调用一次 startRecording 即可,录音随通道挂机自动结束:

ts
export const recordOnlyBusiness: CallBusiness = async (ctx) => {
  await ctx.execute("answer");
  await ctx.execute("sleep", String(ctx.config["esl-server"].settleDelayMs));

  const r = await ctx.startRecording();
  ctx.log("recording", { filePath: r.filePath, fileName: r.fileName });

  await ctx.speak({ kind: "tts", text: "通话正在录音中,请讲话。" });
  await ctx.waitForHangup();
};

自定义文件名:

ts
await ctx.startRecording({
  fileName: `vm-${ctx.channel.uuid}.wav`,
  directory: "/usr/local/freeswitch/voicemails",
});

14. DTMF 抢占语音

dtmf-business 是参考实现。一个简化版(任意按键即抢占 hear):

ts
export const dtmfOrSpeechBusiness: CallBusiness = async (ctx) => {
  await ctx.execute("answer");
  await ctx.execute("sleep", String(ctx.config["esl-server"].settleDelayMs));

  const off = ctx.onDtmf(async (event) => {
    await ctx.cancelHear(`dtmf:${event.digit}`);
    await ctx.speak({ kind: "tts", text: `您按了 ${event.digit} 键,再见。` });
    await ctx.hangup("NORMAL_CLEARING");
  });

  try {
    const r = await ctx.hear({
      prompt: { kind: "tts", text: "请说话,或按任意键退出。" },
      interruptible: true,
      timeoutMs: 15000,
    });
    if (r.status === "recognized") {
      await ctx.speak({ kind: "tts", text: `您说的是:${r.text}` });
    }
    await ctx.hangup();
  } finally {
    off();
  }
};

15. 综合:IVR 菜单 + 转人工

“按 1 查余额、按 2 转人工、说‘人工’也转人工”的组合:

ts
export const ivrMenuBusiness: CallBusiness = async (ctx) => {
  await ctx.execute("answer");
  await ctx.execute("sleep", String(ctx.config["esl-server"].settleDelayMs));

  let resolved: { action: "balance" | "agent" | "exit" } | null = null;

  const off = ctx.onDtmf(async (event) => {
    if (resolved) return;
    if (event.digit === "1") {
      resolved = { action: "balance" };
      await ctx.cancelHear(`dtmf:${event.digit}`);
    } else if (event.digit === "2") {
      resolved = { action: "agent" };
      await ctx.cancelHear(`dtmf:${event.digit}`);
    } else if (event.digit === "0") {
      resolved = { action: "exit" };
      await ctx.cancelHear(`dtmf:${event.digit}`);
    }
  });

  try {
    while (!resolved) {
      const r = await ctx.hear({
        prompt: {
          kind: "tts",
          text: "按 1 查余额,按 2 转人工,按 0 退出,也可以直接说‘人工’。",
        },
        interruptible: true,
        bargeInTrigger: "vad",
        timeoutMs: 10000,
      });
      if (r.status === "recognized" && /人工/.test(r.text)) {
        resolved = { action: "agent" };
        break;
      }
    }
  } finally {
    off();
  }

  switch (resolved.action) {
    case "balance":
      await ctx.speak({ kind: "tts", text: "您的余额为一百二十三元。" });
      await ctx.hangup();
      break;
    case "agent":
      await ctx.speak({ kind: "tts", text: "正在为您转接人工。" });
      await ctx.bridgeToNumber("9000");
      await ctx.hangup();
      break;
    case "exit":
      await ctx.speak({ kind: "tts", text: "感谢来电,再见。" });
      await ctx.hangup();
      break;
  }
};

16. 综合:语音预筛 + 智能拨号 + 桥接

“用户拨进来 → 报上他要找的人 → 我们外呼 + 回铃 ASR 判定 → 通了再 bridge” 可以组合 carrier-ringback-detection-demo-business 的早媒体检测模式与 parked-dial-demo-business 的确认桥接模式,再加上数据库查号实现“按名字找人”:

ts
export const reachByNameBusiness: CallBusiness = async (ctx) => {
  await ctx.execute("answer");
  await ctx.execute("sleep", String(ctx.config["esl-server"].settleDelayMs));

  const ask = await ctx.hear({
    prompt: { kind: "tts", text: "您想找谁,请说他的名字。" },
    interruptible: true,
    bargeInTrigger: "vad",
    timeoutMs: 10000,
  });
  if (ask.status !== "recognized") {
    await ctx.speak({ kind: "tts", text: "未听清姓名,请稍后再试。" });
    await ctx.hangup();
    return;
  }

  const number = await lookupPhoneByName(ask.text); // 业务自己实现的查号
  if (!number) {
    await ctx.speak({ kind: "tts", text: `没有找到 ${ask.text} 的联系方式。` });
    await ctx.hangup();
    return;
  }

  await ctx.speak({ kind: "tts", text: `找到 ${ask.text},正在拨号。` });
  const dial = await ctx.originateWithRingbackDetection(number, {
    originateTimeoutMs: 20_000,
  });

  if (dial.status !== "answered") {
    await ctx.speak({ kind: "tts", text: "未能接通。" });
    await ctx.hangup();
    return;
  }

  await ctx.bridgeCall(dial.bLegUuid, { preserveCurrentCallAfterBridge: true });
};

async function lookupPhoneByName(_name: string): Promise<string | null> {
  // TODO: 查询业务库
  return null;
}

17. 综合:AI 客服 + 整通持续识别 + 录音 + 转人工

把持续 ASR、转接、录音组合起来,是真实生产场景最常见的形态:

ts
export const aiAgentBusiness: CallBusiness = async (ctx) => {
  await ctx.execute("answer");
  await ctx.execute("sleep", String(ctx.config["esl-server"].settleDelayMs));

  await ctx.startRecording();

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

  const asr = await ctx.callHear(
    {
      partialResults: true,
      interruptPlayback: true,
      bargeInTrigger: "vad",
    },
    {
      onFinal: (event) => {
        actionQueue = actionQueue.then(async () => {
          if (shuttingDown) return;
          const text = event.text.trim();
          if (!text) return;

          if (/人工|客服/.test(text)) {
            transferRequested = true;
            shuttingDown = true;
            return;
          }

          if (/挂机|再见|拜拜/.test(text)) {
            shuttingDown = true;
            await ctx.speak({ kind: "tts", text: "好的,再见。" });
            await ctx.hangup();
            return;
          }

          const reply = await aiReply(text);
          await ctx.speak({ kind: "tts", text: reply, interruptible: true });
        });
      },
    },
  );

  try {
    await ctx.speak({
      kind: "tts",
      text: "您好,我是智能客服,请讲。",
      interruptible: true,
    });

    while (!shuttingDown) {
      await new Promise((resolve) => setTimeout(resolve, 200));
    }

    if (transferRequested) {
      await asr.stop("transferring-to-agent");
      await actionQueue;
      await ctx.speak({ kind: "tts", text: "正在为您转接人工。" });
      await ctx.bridgeToNumber("9000");
      await ctx.hangup();
    } else {
      await ctx.waitForHangup();
    }
  } finally {
    shuttingDown = true;
    await asr.stop("business-complete");
    await actionQueue;
  }
};

async function aiReply(userText: string): Promise<string> {
  // TODO: 调用 LLM / 知识库返回回答
  return `我听到了:${userText}`;
}

还想实现但当前 runtime 缺失的能力

整理一下当前 BusinessContext 暂时没有的能力(如果需要可扩展 runtime):

  • stopRecording:当前录音随通道挂机自动结束,没有显式停止接口
  • 直接的“语音转外语” / “声纹识别”:sherpa-asr-online-server 只输出中文(双语模型)
  • 跨业务的全局状态共享:业务之间通过通道变量 / DB 通信,没有 runtime 内置的 KV 存储
  • 主动建立 conference 并主持人不入会的“纯 AI 主持人”模式(当前 ctx.conference.hear 需要绑定在当前通道上)

需要时新增 runtime 能力即可,按 components.md 的分组在 对应 controller 中加方法、再在 runtime-context.tscreateCallContext 暴露。

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