跳到正文

外呼业务代码示例

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

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

1. 业务是什么

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

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

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

新增业务的 4 步(来自 CLAUDE.md)

  1. apps/callflow-esl/src/business/ 建文件,用 defineBusiness 定义。
  2. src/business/index.ts re-export。
  3. src/runtime/business-registry.tsbusinesses 数组里 import + 追加。
  4. bun run db:push 后启动服务 —— 它把 code/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当前通道信息:uuiddestinationNumbercallerIdNumberbusinessCodevariables
ctx.config全局配置(config.json),含 ctx.config.callout.* 回写地址
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. 读取 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;

4. 最小可用骨架

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

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

async function postResult(ctx: BusinessContext, payload: Record<string, unknown>) {
  const service = ctx.config.callout.calloutServer;
  const endpoint = `${service.baseUrl}/api/call-results`;
  const headers = new Headers({ "Content-Type": "application/json" });
  const token = service.token?.trim();
  if (token) headers.set("X-Callout-Internal-Token", token);
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), service.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 },
        },
      },
    });
  },
});

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

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

5.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 }));

5.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;
};

5.3 持续 ASR + 串行 actionQueue

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

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

5.4 多轮对话状态机

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

5.5 终态回写

通话自然/主动挂机后,组装最终结果并回写。注意演示业务把结构化结果放进 payload.result, 并把完整对话时间线同时放进 transcript(带录音地址时会落 transcript_segments)和 payload.result.demo.transcriptTimeline

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,                      // 本通用到的能力清单
      },
    },
  },
});

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

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

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

  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), service.requestTimeoutMs);
  try {
    const headers = new Headers({ "Content-Type": "application/json" });
    const token = service.token?.trim();
    if (token) headers.set("X-Callout-Internal-Token", token);   // = callout-server internalApi.callResultToken
    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);
  }
}

6. 检查清单

  • [ ] defineBusinesscode 与任务 businessCode 一致,且已注册进 business-registry.ts + db:push
  • [ ] 交互业务显式 ctx.execute("answer")
  • [ ] 读 callout_request_id / callout_campaign_id / callout_contact_id 用于回写定位。
  • [ ] 客户业务数据按号码/contactId 实时查询,不依赖导入冗余。
  • [ ] 结束 POST /api/call-resultscallStatus + payload.result,回写失败不抛错。
  • [ ] X-Callout-Internal-Token 与 callout-server 的 internalApi.callResultToken 一致(生产环境)。
  • [ ] 不在 catch 里清理 audio_fork/会议/挂机——交给 SessionRunner。

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