跳到正文

外呼业务代码示例 ​

业务代码不在 callout-server,而在 apps/callflow-esl。 callout-server 只负责把电话拨通并 注入 business_code 通道变量;接通后 FreeSWITCH 把通道交给 callflow-esl,由 business_code 选中一个业务 handler 来真正「说话/听话/录音/转人工/回写结果」。本篇讲怎么写这个 handler。

TIP

外呼业务模式选型:

  • 可视化流程脚本模式(推荐,绝大部分外呼场景):任务 businessCode 选 callout-flow-business,业务人员直接在前端系统二「流程脚本」中可视化拖拽节点编排话术、意图分支与大模型对话,开发者零代码编写,由平台通用解释器自动处理全生命周期的 NLU、Chat、转人工与结果回写。
  • 定制代码开发模式(本文档核心):仅在需要手写全流程高度定制的状态机(如复杂电销业务护栏、特定游戏/测试逻辑)时,才需按本文指导在 apps/callflow-esl 中编写纯 TypeScript 代码。

阅读前请先读 integration.md(注入的通道变量、生命周期)。

1. 业务是什么 ​

一个业务就是一个 (ctx) => Promise<void> 处理函数,用 defineBusiness 注册稳定编码:

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

export const myOutboundBusiness = defineBusiness({
  code: "my-outbound-business", // 全局唯一且不可变,= 任务的 businessCode
  category: "outbound", // 主场景分类,只用于展示/分组
  desc: "我的外呼业务", // 中文名,同步到 call_businesses.businessName
  handler: async ctx => {
    /* ... */
  }
});

新增业务的 3 步 ​

  1. 在 apps/callflow-esl/src/business/ 建文件,用 defineBusiness 定义。
  2. 在 src/business/index.ts 里 import 并追加到 coreBusinesses(演示/测试业务则改为追加到 src/business/demo/index.ts 的 demoBusinesses)。src/business/index.ts 是唯一注册权威, src/runtime/business-registry.ts 只消费它导出的 allBusinesses,新增业务不需要改它。
  3. 启动服务 —— 启动时自动把 code/category/desc 同步进 callflow.call_businesses。

然后在 callout-server 建任务时,把 businessCode 选成 my-outbound-business 即可。

两个易踩的坑 ​

  • 不自动接听:交互业务必须显式 await ctx.execute("answer")。
  • 不要在 catch 里清理资源:audio_fork / playback / 会议 / 挂机由 SessionRunner 在挂机/断开时兜底, 业务无需在 catch 里重复处理。

2. ctx(CallContext)能力速查 ​

业务只接触 ctx 这个 facade(定义见 apps/callflow-esl/src/runtime/runtime-context.ts),不碰 runtime 内部:

能力说明
ctx.channel当前通道信息:uuid、destinationNumber、callerIdNumber、businessCode、variables 等
ctx.config全局配置(config.toml),含 ctx.config.callout.baseUrl / token 等回写配置
ctx.execute(app, arg?)阻塞执行任意 dialplan application(answer/sleep/playback...)
ctx.api(cmd)执行任意 FreeSWITCH API 命令(如 conference <id> count),返回应答正文
ctx.speak(input, handlers?)播放 TTS 文本或 wav;支持 interruptible(barge-in)、repeat;返回 SpeakResult(含 status/interruptedBy)
ctx.hear(opts, handlers?)启动一轮 ASR(可携带 prompt 并发播放、识别期 barge-in)
ctx.callHear(opts, handlers?)启动单呼持续 ASR:会话期间常驻 audio_fork,回调持续推 partial/final
ctx.cancelHear() / ctx.stopCallHear()取消/停止识别
ctx.startRecording({ fileName })启动录音,返回 { filePath, fileName, fileUrl }
ctx.onDtmf(cb)订阅 DTMF 按键,返回取消函数;ctx.sendDtmf(d) 发送按键
ctx.conference.join({ conferenceId, flags? })把当前通道加入会议室,阻塞至退出
ctx.conference.play({ conferenceId, input })向会议室播放语音/媒体
ctx.conference.getMembers() / injectSpeak / publishMessage / onMessage会议成员/注入播报/会内消息
ctx.originate / bridgeToNumber / originateAndBridge / originateParkedCall / bridgeCall外呼/桥接编排原语
ctx.getVariable(name) / getVariables读通道变量(命中缓存不触发 ESL IO)
ctx.setVariable / setVariables写通道变量
ctx.waitForHangup()阻塞等待挂机;交互业务在最后一步调用,避免提前关 socket
ctx.hangup(cause)主动挂断
ctx.log(event, fields)结构化日志(自动带 callUuid/sessionStage)

3. ESL ↔ callout-server 接口契约全景 ​

在完整的通话生命周期中,apps/callflow-esl 作为呼叫执行端,向 apps/callout-server(外呼中台与内置 AI 策略中心)发起的全部 HTTP 接口契约如下:

接口路径方法主要调用方调用时机与业务作用详细契约专题
/api/call-resultsPOST手写外呼业务 / callout-flow-business通话结果最终回写:挂机后回写呼叫状态、录音文件、时长、转写时间线(transcript)与结构化业务数据(payload.result)。结果回写约定
/api/call-progressPOSTbusiness/shared/callout/live-session.ts实时盯屏进度上报:通话中尽力而为(best-effort)上报逐轮对话片段与生命周期事件,供前端盯屏多角色推演。接口参考
/api/scripts/runtimePOSTcallout-flow-business流程脚本运行时拉取:接通后根据通道变量 script_id 拉取可视化流程脚本完整图结构。接口参考
/api/ai/nlu/analyzePOSTcallout-flow-businessNLU 意图判断与字段抽取:将客户回答发给中台匹配分支路由意图或提取槽位字段。流程脚本 NLU 开发
/api/ai/chat/streamPOSTcallout-flow-business/chat-node.tsChat 大模型流式对话:流程进入 chat 节点时发起 SSE 流式对话,配合本地 TTS 实时吐字播报。流程脚本 Chat 开发
/api/ai/chat/completionsPOSTbusiness/kb-audio-chat-business独立问答对话策略:知识库录音对话业务执行确定性开场引导语获取或逐轮问答查询。接口参考
/api/transfers/routePOSTbusiness/shared/callout/transfer.ts转人工坐席预占与呼出:AI 转人工或坐席主动介入时,从坐席池预占坐席并将其分机呼入指定会议室。转人工专题
/api/transfers/endPOSTbusiness/shared/callout/transfer.ts转人工结束与收尾:客户挂断或会议结束时,通知中台结束坐席人工腿并更新坐席通话时长与记录。转人工专题

定制代码开发重点:

  • 编写定制外呼业务时,必须调用的仅有 POST /api/call-results;
  • 若业务需接入实时盯屏或转人工,可直接复用 src/business/shared/callout/index.ts 统一导出的进度上报与转人工运行时 helper,无需在业务中自行组装底层 HTTP 请求。

4. 读取 callout-server 注入的变量 ​

封装一个「先读缓存、回退 ESL」的小工具,安全读取注入的标识:

ts
async function readChannelVariable(
  ctx: BusinessContext,
  name: string
): Promise<string | null> {
  const cached = ctx.channel.variables?.[name];
  if (cached?.trim()) return cached.trim();
  try {
    const value = await ctx.getVariable(name);
    return value?.trim() || null;
  } catch {
    return null;
  }
}

// 用法
const calloutRequestId = await readChannelVariable(ctx, "callout_request_id");
const campaignId =
  Number(await readChannelVariable(ctx, "callout_campaign_id")) || null;
const contactId =
  Number(await readChannelVariable(ctx, "callout_contact_id")) || null;

5. 最小可用骨架 ​

一个「接听 → 录音 → 播报 → 单轮识别 → 回写结果」的最小外呼业务:

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

async function postResult(
  ctx: BusinessContext,
  payload: Record<string, unknown>
) {
  const callout = ctx.config.callout;
  if (!callout) {
    ctx.log("result_callback_skipped", { reason: "callout_unconfigured" });
    return;
  }
  const endpoint = `${callout.baseUrl.replace(/\/+$/, "")}/api/call-results`;
  const headers = new Headers({ "Content-Type": "application/json" });
  const token = callout.token?.trim();
  if (token) headers.set("X-Callout-Internal-Token", token);
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), callout.requestTimeoutMs);
  try {
    await fetch(endpoint, {
      method: "POST",
      headers,
      body: JSON.stringify(payload),
      signal: controller.signal
    });
  } catch (err) {
    ctx.log("result_callback_failed", {
      error: err instanceof Error ? err.message : String(err)
    });
  } finally {
    clearTimeout(timer);
  }
}

export const myOutboundBusiness = defineBusiness({
  code: "my-outbound-business",
  desc: "最小外呼业务示例",
  handler: async ctx => {
    await ctx.execute("answer");
    await ctx.execute("sleep", String(ctx.config["esl-server"].settleDelayMs));

    const requestId = ctx.channel.variables?.callout_request_id ?? null;
    const answeredAt = new Date();

    const recording = await ctx
      .startRecording({ fileName: `out-${ctx.channel.uuid}.wav` })
      .catch(() => null);

    await ctx.speak({
      kind: "tts",
      text: "您好,这里是示例外呼,请问方便了解一下吗?",
      interruptible: true
    });

    // 单轮识别:拿一句客户回复。HearResult 是按 status 区分的联合类型,
    // 只有 status === "recognized" 才有 text 字段。
    const heard = await ctx.hear({
      partialResults: false,
      noSpeechTimeoutMs: 8000
    });
    const text = heard.status === "recognized" ? heard.text.trim() : "";

    const interested = ["可以", "好", "了解", "需要"].some(k =>
      text.includes(k)
    );
    await ctx.speak({
      kind: "tts",
      text: interested
        ? "好的,已记录您的意向,稍后联系您。"
        : "好的,打扰了,再见。"
    });
    await ctx.hangup("NORMAL_CLEARING");
    await ctx.waitForHangup();

    const endedAt = new Date();
    await postResult(ctx, {
      requestId,
      callStatus: "completed",
      hangupCause: "NORMAL_CLEARING",
      answeredAt: answeredAt.toISOString(),
      endedAt: endedAt.toISOString(),
      recordingUrl: recording?.fileName ?? undefined,
      payload: {
        result: {
          recognizedText: text || null,
          demo: { interested }
        }
      }
    });
  }
});

6. 完整参考实现:callout-platform-demo-business ​

仓库内 apps/callflow-esl/src/business/demo/callout-platform-demo-business.ts 是平台能力的完整演示, 覆盖了真实外呼业务该有的全部要素。核心结构如下(精简自源码):

6.1 开场:读变量、查档、起录音、订阅 DTMF ​

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

  const answeredAt = new Date();
  const answeredAtMs = answeredAt.getTime();
  const relMs = () => Math.max(0, Date.now() - answeredAtMs);   // 转写时间轴

  const calloutRequestId = await readChannelVariable(ctx, "callout_request_id");
  const campaignId = parsePositiveInteger(await readChannelVariable(ctx, "callout_campaign_id"));
  const contactId  = parsePositiveInteger(await readChannelVariable(ctx, "callout_contact_id"));
  const conferenceId = `transfer-${randomUUID()}`;             // 转人工时才会用到

  // 客户业务数据按号码实时查询(演示用号码派生,真实环境换成业务系统接口)
  const customerProfile = await lookupCustomerProfileByPhone(ctx, ctx.channel.destinationNumber ?? null);

  const recording = await startRecordingSafely(ctx, calloutRequestId);
  const offDtmf = ctx.onDtmf((e) => ctx.log("dtmf", { digit: e.digit }));

6.2 实时组装对话时间线(transcript) ​

audio_fork 只取主叫单声道(听不到 AI 的 TTS),所以对话时间线必须实时组装: 客户句来自 ASR final,AI 句来自业务自己播报的 TTS 文本,打断标记来自运行时 barge-in 事件。

ts
const transcript: TranscriptSegment[] = [];

// 统一的 AI 播报入口:落时间线 + 标记是否被打断
const speakTurn = async (
  phase: string,
  input: TtsSpeakInput
): Promise<SpeakResult> => {
  const startMs = relMs();
  const result = await ctx.speak(input, {
    onComplete: e =>
      ctx.log("tts_complete", {
        phase,
        status: e.status,
        interruptedBy: e.interruptedBy ?? null
      })
  });
  const interrupted = result.status === "interrupted";
  transcript.push({
    speaker: "bot",
    startMs,
    endMs: relMs(),
    text: input.text,
    metadata: {
      phase,
      interrupted,
      ...(interrupted
        ? { interruptedBy: result.interruptedBy ?? "barge-in" }
        : {})
    }
  });
  return result;
};

6.3 持续 ASR + 串行 actionQueue ​

用 ctx.callHear 开常驻识别,partial 用于捕捉客户起说时间、final 进入对话决策。 所有播报/终态动作经 actionQueue 串行,避免并发 speak:

ts
const asrSession = await ctx.callHear(
  {
    partialResults: true,
    interruptPlayback: true,
    bargeInTrigger: "partial",
    noSpeechTimeoutMs: NO_SPEECH_TIMEOUT_MS,
    noTextTimeoutMs: NO_TEXT_TIMEOUT_MS
  },
  {
    onResult: e => {
      if (!e.isFinal && customerSpeechStartMs === null)
        customerSpeechStartMs = relMs();
    },
    onFinal: e => {
      actionQueue = actionQueue.then(() => handleCustomerFinal(e));
    },
    onTimeout: e => {
      actionQueue = actionQueue.then(() => handleTimeout(e));
    }
  }
);

// 开场白入队,然后阻塞等挂机
actionQueue = actionQueue.then(() =>
  speakTurn("greeting", {
    kind: "tts",
    text: customerName
      ? `${customerName},${GREETING_PROMPT}`
      : GREETING_PROMPT,
    interruptible: true
  })
);
await ctx.waitForHangup();

6.4 多轮对话状态机 ​

handleCustomerFinal → decideDialogAction 按阶段(opening → capability_intro → value_probe → transfer_offer → closing)推进,并据关键词分流到三类动作:speak(继续话术)、conclude(得出终态)、 transfer(转人工,见 transfer-to-human.md)。 命中负向词直接结束,命中转人工词触发转人工。

6.5 终态回写 ​

通话自然/主动挂机后,组装最终结果并回写。注意演示业务把结构化结果放进 payload.result, 并把完整对话时间线同时放进 transcript 和 payload.result.demo.transcriptTimeline。 前者只要非空就会落 transcript_segments(与是否带录音无关,且是整段替换),供前端按时间线展示; 后者是业务自己的留档副本,字段格式见 result-schemas.md:

ts
await postCalloutResultSafely(ctx, {
  requestId: calloutRequestId ?? undefined,
  campaignId: campaignId ?? undefined,
  contactId: contactId ?? undefined,
  callStatus: "completed",
  hangupCause: "NORMAL_CLEARING",
  answeredAt: answeredAt.toISOString(),
  endedAt: endedAt.toISOString(),
  durationSeconds,
  recordingUrl: recording?.fileName ?? undefined,
  recordingDurationSeconds: recording?.fileName ? durationSeconds : undefined,
  recordingMimeType: recording?.fileName ? "audio/wav" : undefined,
  transcript: transcript.length > 0 ? transcript : undefined,
  payload: {
    result: {
      businessCode: BUSINESS_CODE,
      recognizedText,
      demo: {
        outcomeCode: finalOutcome.outcomeCode,
        outcomeCategory: finalOutcome.outcomeCategory,
        customerName,
        customerProfile,
        durationSeconds,
        recordingAvailable: Boolean(recording?.fileName),
        transferRequested: finalOutcome.outcomeCategory.startsWith("transfer"),
        transcriptTimeline: transcript,
        interruptionCount,
        platformCapabilities // 本通用到的能力清单
      }
    }
  }
});

6.6 回写 helper:失败只记日志、不抛错 ​

回写要做到「失败也不影响通话已经发生的事实」,统一 try/catch + 超时 + 可选 token:

ts
async function postCalloutResultSafely(
  ctx: BusinessContext,
  payload: CalloutCallbackPayload
) {
  // 没有可定位身份就跳过
  if (!payload.requestId && !(payload.campaignId && payload.contactId)) return;
  const callout = ctx.config.callout;
  if (!callout) {
    ctx.log("result_callback_skipped", { reason: "callout_unconfigured" });
    return;
  }
  const endpoint = `${callout.baseUrl.replace(/\/+$/, "")}/api/call-results`;

  const controller = new AbortController();
  const timeout = setTimeout(
    () => controller.abort(),
    callout.requestTimeoutMs
  );
  try {
    const headers = new Headers({ "Content-Type": "application/json" });
    const token = callout.token?.trim();
    if (token) headers.set("X-Callout-Internal-Token", token); // = callout-server server.serviceApi.authToken
    const response = await fetch(endpoint, {
      method: "POST",
      headers,
      body: JSON.stringify(payload),
      signal: controller.signal
    });
    if (!response.ok)
      throw new Error(`HTTP ${response.status} ${await response.text()}`);
  } catch (error) {
    ctx.log("result_callback_failed", {
      endpoint,
      error: error instanceof Error ? error.message : String(error)
    });
  } finally {
    clearTimeout(timeout);
  }
}

7. 检查清单 ​

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