外观
外呼业务代码示例
业务代码不在 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)
- 在
apps/callflow-esl/src/business/建文件,用defineBusiness定义。 - 从
src/business/index.tsre-export。 - 在
src/runtime/business-registry.ts的businesses数组里 import + 追加。 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 | 当前通道信息:uuid、destinationNumber、callerIdNumber、businessCode、variables 等 |
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 多轮对话状态机
handleCustomerFinal → decideDialogAction 按阶段(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. 检查清单
- [ ]
defineBusiness的code与任务businessCode一致,且已注册进business-registry.ts+db:push。 - [ ] 交互业务显式
ctx.execute("answer")。 - [ ] 读
callout_request_id/callout_campaign_id/callout_contact_id用于回写定位。 - [ ] 客户业务数据按号码/contactId 实时查询,不依赖导入冗余。
- [ ] 结束
POST /api/call-results带callStatus+payload.result,回写失败不抛错。 - [ ]
X-Callout-Internal-Token与 callout-server 的internalApi.callResultToken一致(生产环境)。 - [ ] 不在
catch里清理 audio_fork/会议/挂机——交给 SessionRunner。