跳到正文

组件说明文档

本文档按 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.configAppConfigconfig.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 选择 wavshout 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)

回调时机
onStartspeak 开始(尚未播第一次)
onPlaybackStart第 N 次迭代开始(每个 repeat 触发一次)
onPlaybackStopRequested收到 stop 请求但还未完成
onPlaybackInterrupted实际被打断(CHANNEL_EXECUTE_COMPLETE 确认)
onPlaybackComplete当前迭代结束(成功或被中断都触发)
onComplete整个 speak 结束(status / iterationsCompleted 等汇总信息)
onErrorspeak 链路异常

所有事件都带 callUuid / component:"speak" / timestamp / input / totalIterations / sourcesource 区分:

  • "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 };

HearFailureStatusAsrRecognitionStatus 排除掉成功类后的剩余集合 (error / no-input-timeout / recognition-timeout 等)。

回调(HearHandlers)

回调时机
onPromptStart / onPromptPlaybackStart / onPromptPlaybackComplete / onPromptComplete / onPromptError与 SpeakHandlers 同名(source="hear-prompt"
onPromptPlaybackStopRequested / onPromptPlaybackInterrupted提示音被 barge-in 抢断的两阶段
onStartaudio_fork 已启动
onSpeech收到 DETECTED_SPEECH 事件(含是否打断提示音的决策)
onResult拿到结果(成功或失败都会触发)
onTimeouthear 等待超时
onCompletehear 结束(无论结果)

示例

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 }
onResultlive 阶段每一条 transcription(partial + final)
onFinallive 阶段 final transcription,可驱动通话内动作
onPostHangupResult挂机后 drain 阶段每一条 transcription(仅尾部补收)
onPostHangupFinal挂机后 drain 阶段 final transcription(不应再驱动播放/按键)
onErroraudio_fork 链路 error / disconnect / connect_failed
onTimeout触达 noSpeechTimeoutMs / noTextTimeoutMs
onStopsession.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 / hangupparked / 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_bridgepreserveCurrentCallAfterBridge 为 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 / 主叫信息外,还会尽量维护 muteddeaftalkingrolejoinedAtupdatedAt。这些状态来自 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 内部串行排队

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