外观
组件说明文档
本文档按 BusinessContext 提供的能力分组,列出参数、返回值、回调与典型用法。 所有方法都是 ctx.<method>(...) 调用,类型定义见 src/types/types.ts。
CallContext 的所有方法都是 runtime controller 上的 bind,业务侧只接触 facade,runtime 内部状态对业务不可见。
目录
通道控制
ctx.execute(appName, appArg?)
阻塞式执行一个拨号计划 application(answer / pre_answer / sleep / playback / bridge / hangup 等)。runtime 内部走 ESL execute,会等 CHANNEL_EXECUTE_COMPLETE 事件后再 resolve。
ts
await ctx.execute("answer");
await ctx.execute("sleep", "1000");
await ctx.execute("playback", "${sounds_dir}/welcome.wav");挂机原因等场景下 FreeSWITCH 会拒绝命令,runtime 已经在内部 tolerateError 分支处理;业务无需自己 try/catch。
ctx.api(command)
发送一条 FreeSWITCH inline API 命令并返回纯文本 reply(如 uuid_getvar / uuid_kill)。
ts
const reply = await ctx.api(`uuid_getvar ${ctx.channel.uuid} hangup_cause`);ctx.hangup(cause?)
主动挂当前通道。cause 默认 "NORMAL_CLEARING",常见值:
NORMAL_CLEARING:正常挂机CALL_REJECTED:拒接UNALLOCATED_NUMBER:空号NORMAL_TEMPORARY_FAILURE:临时失败
挂机命令失败(通道已被对端释放)不会抛错。
ctx.waitForHangup()
等到当前通道挂机事件(最长 24 小时)。如已挂机则立即返回。 SessionRunner 在业务函数返回后会主动调用一次,业务一般不需要显式 await。
ctx.channel / ctx.config
通道快照与应用配置。通道快照在 outbound 握手时从 CHANNEL_DATA headers 抓取一次:
ts
ctx.channel.uuid // 通道 UUID,所有 ESL API 都基于它
ctx.channel.callerIdNumber // 主叫号
ctx.channel.callerIdName
ctx.channel.destinationNumber // 被叫号
ctx.channel.businessCode // 来自通道变量 business_code
ctx.channel.channelName
ctx.channel.variables // CHANNEL_DATA 抓到的所有通道变量ctx.config 是 AppConfig(config.json 的强类型映射)。
Speak(播报)
ts
ctx.speak(input: SpeakInput, handlers?: SpeakHandlers): Promise<SpeakResult>输入
SpeakInput 是 TTS / wav 的联合类型:
ts
type SpeakInput = TtsSpeakInput | WavSpeakInput;
interface TtsSpeakInput {
kind: "tts";
text: string;
speakerId?: number; // 缺省用 config.tts.defaults.speakerId
speed?: number; // 缺省用 config.tts.defaults.speed
repeat?: number; // 默认 1
interruptible?: boolean; // 仅独立 speak 生效;hear-prompt 由 hear 控制
}
interface WavSpeakInput {
kind: "wav";
file: string; // FreeSWITCH 能访问的绝对路径,可用 ${sounds_dir}
repeat?: number;
interruptible?: boolean;
}tts 模式下,runtime 会按 config.tts.use.call 选择 wav 或 shout profile,再请求 sherpa-tts-server 取得 wav URL、本地路径或流式 URL,最后交给 FreeSWITCH playback。
返回值
ts
type SpeakResult = {
status: "completed" | "interrupted" | "failed";
iterationsCompleted: number;
interruptedBy?: "barge-in" | "manual-stop" | "hangup";
stopReason?: string;
};completed:按repeat全部完成interrupted:被外部 stop / barge-in / 挂机打断(interruptedBy/stopReason会携带)failed:执行过程异常(如 wav 不存在),异常已经通过onError回调送出
回调(SpeakHandlers)
| 回调 | 时机 |
|---|---|
onStart | speak 开始(尚未播第一次) |
onPlaybackStart | 第 N 次迭代开始(每个 repeat 触发一次) |
onPlaybackStopRequested | 收到 stop 请求但还未完成 |
onPlaybackInterrupted | 实际被打断(CHANNEL_EXECUTE_COMPLETE 确认) |
onPlaybackComplete | 当前迭代结束(成功或被中断都触发) |
onComplete | 整个 speak 结束(status / iterationsCompleted 等汇总信息) |
onError | speak 链路异常 |
所有事件都带 callUuid / component:"speak" / timestamp / input / totalIterations / source。source 区分:
"standalone":业务直接ctx.speak(...)的独立播报"hear-prompt":作为ctx.hear({ prompt })的子流程播放
示例
ts
await ctx.speak(
{ kind: "tts", text: "欢迎致电", repeat: 1 },
{
onPlaybackStart: (e) => ctx.log("speak_iter", { iter: e.iteration }),
onComplete: (e) => ctx.log("speak_done", { status: e.status }),
},
);
// 拨号过程中常见:循环播报“正在为您拨号”
await ctx.speak({ kind: "tts", text: "正在为您拨号", repeat: 3 });
// 播放固定 wav
await ctx.speak({
kind: "wav",
file: "${sounds_dir}/carrier-empty-number.wav",
});Hear(单轮识别)
ts
ctx.hear(input: HearInput, handlers?: HearHandlers): Promise<HearResult>一轮识别 = 启动 uuid_audio_fork → 收 mod_audio_fork::transcription 事件 → 得到 final 文本或超时 → 停 audio_fork。可选地在识别期间并发播放提示音, 配合 interruptible + bargeInTrigger 实现 barge-in。
输入
ts
interface HearInput {
timeoutMs?: number; // 默认 config["esl-server"].asrTimeoutMs
interruptible?: boolean; // 是否允许 barge-in
bargeInTrigger?: "vad" | "begin-speaking" | "final";
prompt?: SpeakInput; // 识别期间并发播报的提示音
}bargeInTrigger 语义:
"vad"(默认):必须等 audio_fork 给到非空 transcript 才打断提示音,能避开 空 speech-start、纯 VAD 活动和空白识别结果误触发"begin-speaking":检测到 begin-speaking 立即打断"final":仅在拿到非空 final 时才打断;空 final 不停止播放
返回值
ts
type HearResult =
| { status: "recognized"; text: string; recognition: AsrRecognitionResult }
| { status: "timeout"; elapsedMs: number }
| { status: "cancelled"; reason: string; interruptedBy: "dtmf" | "manual-stop" | "hangup" }
| { status: HearFailureStatus; recognition: AsrRecognitionResult };HearFailureStatus 是 AsrRecognitionStatus 排除掉成功类后的剩余集合 (error / no-input-timeout / recognition-timeout 等)。
回调(HearHandlers)
| 回调 | 时机 |
|---|---|
onPromptStart / onPromptPlaybackStart / onPromptPlaybackComplete / onPromptComplete / onPromptError | 与 SpeakHandlers 同名(source="hear-prompt") |
onPromptPlaybackStopRequested / onPromptPlaybackInterrupted | 提示音被 barge-in 抢断的两阶段 |
onStart | audio_fork 已启动 |
onSpeech | 收到 DETECTED_SPEECH 事件(含是否打断提示音的决策) |
onResult | 拿到结果(成功或失败都会触发) |
onTimeout | hear 等待超时 |
onComplete | hear 结束(无论结果) |
示例
ts
// 最小:一轮识别,无提示音
const r = await ctx.hear({ timeoutMs: 10000 });
if (r.status === "recognized") {
ctx.log("got_text", { text: r.text });
}
// 带提示音 + barge-in
const r2 = await ctx.hear(
{
prompt: { kind: "tts", text: "请说出您的需求。" },
interruptible: true,
bargeInTrigger: "vad",
timeoutMs: 10000,
},
{
onResult: (e) => ctx.log("hear_result", { status: e.result.status }),
onTimeout: (e) => ctx.log("hear_timeout", { elapsedMs: e.elapsedMs }),
},
);ctx.cancelHear(reason?)
外部请求取消当前 hear。常见用途:DTMF 抢占整轮。
ts
const off = ctx.onDtmf(async (e) => {
await ctx.cancelHear(`dtmf:${e.digit}`);
});取消后 ctx.hear(...) 的返回 status:"cancelled",并附 reason / interruptedBy。
CallHear(持续 ASR)
ts
ctx.callHear(input: CallHearInput, handlers?: CallHearHandlers): Promise<CallHearSession>整通通话级别的持续识别。一旦启动,runtime 会保持 audio_fork 连接直到业务 显式 session.stop() 或通话结束;每个 partial / final 都通过回调推给业务。
输入
ts
interface CallHearInput {
partialResults?: boolean; // 默认 true
interruptPlayback?: boolean; // 是否在用户开口时打断当前播放,默认 false
bargeInTrigger?: BargeInTrigger; // 同 hear
noSpeechTimeoutMs?: number; // 多久没检测到语音触发 timeout 回调
noTextTimeoutMs?: number; // 多久没出 final 触发 timeout 回调
postHangupDrainMs?: number; // 挂机后继续收尾部识别的 drain 窗口(毫秒),默认 0 = 立即清理
}句柄
ts
interface CallHearSession {
callUuid: string;
listenerId: string;
stop: (reason?: string) => Promise<CallHearStopResult>;
}回调
| 回调 | 字段 |
|---|---|
onStart | { callUuid, listenerId, input, timestamp } |
onResult | live 阶段每一条 transcription(partial + final) |
onFinal | live 阶段 final transcription,可驱动通话内动作 |
onPostHangupResult | 挂机后 drain 阶段每一条 transcription(仅尾部补收) |
onPostHangupFinal | 挂机后 drain 阶段 final transcription(不应再驱动播放/按键) |
onError | audio_fork 链路 error / disconnect / connect_failed |
onTimeout | 触达 noSpeechTimeoutMs / noTextTimeoutMs |
onStop | session.stop 完成 |
每条 transcription 事件都带
phase: "live" | "post-hangup"。postHangupDrainMs > 0时,对端挂机后 runtime 会保留 audio_fork 一小段时间继续收尾部识别,这些事件走onPostHangupResult/onPostHangupFinal(不再触发onResult/onFinal), 只用于补收最后一段 final,不应再驱动播放、按键或其它通话内动作。session.stop()会等待 drain 结束后再返回。验证码、语音留言尾音等"挂机瞬间还在说"的场景适用。
示例
ts
const session = await ctx.callHear(
{
partialResults: true,
interruptPlayback: true,
bargeInTrigger: "vad",
},
{
onFinal: (e) => ctx.log("final", { text: e.text }),
},
);
try {
await ctx.speak({ kind: "tts", text: "您好" });
await ctx.waitForHangup();
} finally {
await session.stop("business-complete");
}callHear / hear / conference.hear 同一时间只能存在一个。
DTMF
ctx.onDtmf(handler)
注册一个会话级监听,返回取消订阅函数。handler 接收 DtmfEvent:
ts
interface DtmfEvent {
callUuid: string;
digit: DtmfDigit; // "0"-"9" / "*" / "#"
durationMs: number | null;
eventName: string;
timestamp: string;
rawEvent: EslEvent;
}回调内可以异步发起 cancelHear / speak / hangup 等任意操作。
ctx.sendDtmf(digit)
向当前通道发送一位 DTMF。runtime 会校验 0-9*#,非法字符抛错。
示例
ts
const off = ctx.onDtmf(async (event) => {
ctx.log("dtmf", { digit: event.digit });
if (event.digit === "0") {
await ctx.speak({ kind: "tts", text: "正在转人工。" });
await ctx.bridgeToNumber("9000");
}
});
// ... 业务逻辑 ...
off(); // 清理订阅,通常放在 try/finally外呼 / 桥接(Originate)
四种语义不同的外呼方式,按场景挑:
| 方法 | 适用场景 | 完成态 |
|---|---|---|
originate | 新起一通通话(HTTP 外呼,不绑定当前通道) | 返回 originate 回执 |
bridgeToNumber | 当前 A-leg 想直接桥接到目标号 | bridged / failed |
originateParkedCall | 外呼接通后停在 park,业务再决定 bridge / hangup | parked / not_answered / failed |
originateAndBridge | 外呼成功后自动 uuid_bridge 回当前通道 | bridged / not_answered / failed |
originateWithRingbackDetection | 外呼并 ASR 检测运营商提示音(空号 / 关机 / 停机 / 忙) | 见下 |
ctx.originate(request)
通过 inbound ESL 发起一通新通话。这是 HTTP /outbound-calls 内部使用的同 一接口,可在业务里复用(如主持人会议邀请访客)。
ts
interface OutboundCallRequest {
destinationNumber: string;
businessCode?: string; // 写入通道变量 business_code
variables?: Record<string, string>; // 透传通道变量
callerIdNumber?: string;
callerIdName?: string;
dialStringTemplate?: string; // 缺省用 config.freeswitch.originateDialStringTemplate
}ctx.bridgeToNumber(numberOrInput, options?)
ts
const r = await ctx.bridgeToNumber("1001");
// 或:
const r = await ctx.bridgeToNumber({
destinationNumber: "1001",
variables: { transfer_from: ctx.channel.uuid },
});返回 originate_disposition / bridge_hangup_cause / last_bridge_proto_specific_hangup_cause 等通道变量,便于业务判断对端失败原因。
ctx.originateParkedCall(numberOrInput, options?)
适合“先把 B 拨通 park 在那,A 这边再决定要不要接”:
ts
const dial = await ctx.originateParkedCall("1001", {
originateTimeoutMs: 20000,
dialStringTemplate: "user/{{destinationNumber}}",
});
if (dial.status !== "parked") {
await ctx.speak({ kind: "tts", text: "对方未接通。" });
return;
}
// 业务里可以再询问用户
const ok = await askConfirm(ctx, "对方已接通,是否连接?");
if (ok) {
await ctx.bridgeCall(dial.bLegUuid, { preserveCurrentCallAfterBridge: true });
} else {
await ctx.hangupCall(dial.bLegUuid, "ORIGINATOR_CANCEL");
}ctx.originateAndBridge(numberOrInput, options?)
“一步到位”的外呼+桥接,不需要业务自己监听 originate 事件。
ctx.originateWithRingbackDetection(numberOrInput, options?)
外呼并在 ringing / early-media 阶段开启 ASR,同时用本地 tone_detect 计数回铃嘟声;ASR 文本用于判定运营商提示音,嘟声达到阈值时可提前判定无人接听。
ts
type CarrierPromptKind =
| "empty_number" | "powered_off" | "out_of_service"
| "unreachable" | "busy" | "no_answer" | "unknown";
type RingbackDetectionSource =
| "asr" | "tone" | "answer" | "hangup" | "timeout" | "originate";
interface OriginateWithRingbackDetectionInput {
destinationNumber: string;
originateTimeoutMs?: number;
ringbackDetectionTimeoutMs?: number;
ringbackToneDetectionEnabled?: boolean; // 默认 true
ringbackToneFrequency?: string; // 默认 "450"
ringbackToneHits?: number; // 默认 5
ringbackToneTimeoutMs?: number;
}
interface OriginateWithRingbackDetectionResult {
status: "answered" | "carrier_prompt_detected" | "no_answer" | "failed";
destinationNumber: string;
dialString: string;
bLegUuid: string;
jobUuid: string | null;
originateReplyText: string;
detectedText: string | null;
carrierPromptKind: CarrierPromptKind | null;
detectionSource: RingbackDetectionSource;
ringbackToneHits: number | null;
remark: string | null;
hangupCause: string | null;
error?: string;
}适合 “拨号前预筛” 场景:空号 / 关机 / 停机直接挂掉,避免坐席浪费时间。
ctx.bridgeCall(bLegUuid, options?)
把当前 A-leg 与已知 B-leg uuid_bridge。preserveCurrentCallAfterBridge 为 true 时会预先解绑当前通道,bridge 失败后业务还能拿回控制(用于 parked-dial-demo-business 这类先确认再桥接的场景)。
ctx.watchCallHangup(callUuid, timeoutMs?)
监听任意通道挂机事件,返回 { promise, cancel }:
ts
const watch = ctx.watchCallHangup(bLegUuid);
const result = await watch.promise;
ctx.log("b_leg_ended", { cause: result.hangupCause });ctx.hangupCall(callUuid, cause?)
uuid_kill 指定通道。常配合 originateParkedCall / bridgeCall 失败时使用。
Conference(会议)
会议域所有能力在 ctx.conference.*。
ctx.conference.join({ conferenceId, profile?, flags? })
把当前通道加入 FreeSWITCH conference。会阻塞直到 join 完成;如果想让通道 留在会议里,业务可以直接 await ctx.waitForHangup() 或 ctx.conference.join 之后立即 return(runtime 会等待挂机)。
ts
await ctx.conference.join({
conferenceId: "demo-123",
flags: ["moderator"],
});ctx.conference.play({ conferenceId, input, delaySeconds? })
向整个会议播放音频。delaySeconds 用于 join 之前预排会议广播:
ts
await ctx.conference.play({
conferenceId,
input: { kind: "tts", text: "主持人已加入会议,会议即将开始。" },
delaySeconds: 2,
});ctx.conference.injectSpeak(input, options)
向单成员(或整个会议)注入一段播报。options.targetCallUuid 指定向某通话 注入;缺省则向整个会议。
ctx.conference.hear(input, handlers?)
会议级 ASR。会按 includeHost / includeGuests 把成员的音频拉到 audio_fork,回调按成员维度区分:
ts
await ctx.conference.hear(
{ conferenceId, partialResults: true },
{
onResult: (e) => ctx.log("conf_partial", { from: e.participant.callerIdNumber, text: e.text }),
onFinal: async (e) => {
// 广播给所有成员
await ctx.conference.play({
conferenceId: e.conferenceId,
input: { kind: "tts", text: `${e.participant.callerIdNumber}说: ${e.text}` },
});
},
},
);ctx.conference.publishMessage(message) / onMessage(handler)
业务层会议广播消息,与 FreeSWITCH 事件无关。当前只用于 ConferenceAsrAnnouncementMessage。
ctx.conference.getMembers(conferenceId)
返回当前会议成员列表(ConferenceParticipantRef[])。
ctx.conference.getState(conferenceId?)
返回会议状态快照:
ts
const state = ctx.conference.getState(conferenceId);
ctx.log("conference_state", {
locked: state.locked,
members: state.participants.length,
});成员字段除 callUuid / conferenceMemberId / 主叫信息外,还会尽量维护 muted、deaf、talking、role、joinedAt、updatedAt。这些状态来自 conference::maintenance 事件与 conference list 刷新,FreeSWITCH 未上报时 字段可能为 undefined。
ctx.conference.onStateChange(handler, conferenceId?)
订阅会议状态变化,包含成员加入 / 离开、mute / unmute、deaf / undeaf、开始 / 停止说话、应用层角色变化、锁会 / 解锁:
ts
const off = ctx.conference.onStateChange((event) => {
ctx.log("conference_state_changed", {
action: event.action,
member: event.participant?.callerIdNumber,
});
}, conferenceId);成员控制
以下方法都会下发 FreeSWITCH conference <id> ... API;FreeSWITCH 返回 -ERR 时会抛错:
ts
await ctx.conference.muteMember({ conferenceId, targetCallUuid: guestUuid });
await ctx.conference.unmuteMember({ conferenceId, targetConferenceMemberId: 3 });
await ctx.conference.deafMember({ conferenceId, all: true });
await ctx.conference.undeafMember({ conferenceId, all: true });
await ctx.conference.kickMember({ conferenceId, targetCallUuid: guestUuid });成员定位支持:
targetCallUuid:通过本服务成员表定位 FreeSWITCH member id;必要时会主动conference <id> list delim |刷新。targetConferenceMemberId:直接使用 FreeSWITCH member id。all: true:对全员执行,适用于 mute / unmute / deaf / undeaf / kick。
锁会与结束会议
ts
await ctx.conference.lock(conferenceId);
await ctx.conference.unlock(conferenceId);
await ctx.conference.end({ conferenceId }); // 默认 conference <id> hup all
await ctx.conference.end({ conferenceId, mode: "kick" });ctx.conference.inviteMembers(input)
批量邀请成员。实现上复用当前 outbound socket 业务模型,而不是直接使用 FreeSWITCH conference bgdial,因此被邀请成员仍会进入 conference-guest-business 并保留完整业务日志。
ts
const result = await ctx.conference.inviteMembers({
conferenceId,
destinationNumbers: ["1001", "1002"],
concurrency: 2,
variables: { source: "host-panel" },
});ctx.conference.setMemberRole(input)
应用层更新成员角色,用于业务展示、过滤和状态事件:
ts
await ctx.conference.setMemberRole({
conferenceId,
targetCallUuid: guestUuid,
role: "host",
});注意:该方法不改变 FreeSWITCH moderator 权限。真实会议主持权限仍取决于成员 入会时的 flags: ["moderator"] 或 FreeSWITCH profile 配置。
ctx.conference.recording.*
会议录音控制:
ts
const rec = await ctx.conference.recording.start({
conferenceId,
fileName: `conference-${conferenceId}.wav`,
});
await ctx.conference.recording.pause({ conferenceId, filePath: rec.filePath });
await ctx.conference.recording.resume({ conferenceId, filePath: rec.filePath });
await ctx.conference.recording.stop({ conferenceId, filePath: rec.filePath });
const status = await ctx.conference.recording.status(conferenceId);start 复用 recording.directory 配置,返回 { conferenceId, filePath, fileName, command, replyText }。stop / pause / resume 未传 filePath 时默认作用于 all。
录音
ctx.startRecording(input?)
启动当前通道录音,文件名可省(默认 call-<uuid>-<timestamp>.wav)。
ts
const r = await ctx.startRecording();
ctx.log("recording", { filePath: r.filePath, fileName: r.fileName });
// 自定义文件名 / 目录
await ctx.startRecording({
fileName: `voicemail-${ctx.channel.uuid}.wav`,
directory: "/usr/local/freeswitch/voicemails",
});返回 { filePath, fileName }。callflow-esl 不再拼接公开播放 URL;调用方如需播放, 应基于自身媒体配置和 fileName 拼接。当前实现不提供停止接口,录音随通道挂机自动结束。
通道变量与日志
ctx.setVariable(name, value) / ctx.setVariables({...})
写一个 / 一批通道变量,通过 uuid_setvar / uuid_setvar_multi。
ctx.getVariable(name) / ctx.getVariables([names])
读一个 / 一批通道变量,通过 uuid_getvar。返回值为 string | null。
ctx.log(event, details)
结构化日志,输出到 winston。runtime 会自动附 callUuid / businessCode / businessName / sessionStage,业务侧只填业务相关字段:
ts
ctx.log("business_step", {
step: "ask_for_confirm",
attempt: 1,
promptText: "请确认是否转人工",
});一图速查:组件之间的关系
text
CallContext(facade)
├─ runtime.execute / api / hangup / waitForHangup ...
├─ playbackController.speak ◀── 串行 speak
├─ recognitionController.hear ◀── 单轮识别(互斥)
│ └─ recognitionController.callHear ◀── 持续识别(互斥)
├─ conferenceController.hear/play/join ... ◀── 会议级
├─ dtmfController.on / send ◀── DTMF
├─ originateController.originate / bridge*
├─ recordingController.start
└─ variableStore.get / set底层共享:
- 所有 ESL I/O 走
EslTransport,命令在 runtime 内串行 + reply matcher - audio_fork 是共享资源,
hear/callHear/conference.hear三者互斥 - 播放命令独占 channel 的 playback application,speak 内部串行排队