跳到正文

转人工业务示例

AI 外呼通话转人工,用的是会议室桥接模型:把客户腿放进一个 FreeSWITCH 会议室,再把坐席分机 也 originate 进同一会议室,两边在会议里通话。这样后续「加人/减人/换人」(盲转、协商转、主管监听/强插) 都能在同一个会议室上动态做,互不影响。

阅读前请先读 integration.mdoutbound-business-example.md

1. 角色与组件

组件位置职责
AI 外呼业务callflow-esl *-business.ts识别转人工意图、生成会议室 ID、请求路由、把客户腿入会、回退
转人工 helpercallflow-esl business/shared/human-transfer.ts封装 POST /api/transfers/route 调用
路由入口callout-server handleTransferRoute预占空闲坐席 + originate 坐席入会 + 登记会议会话
坐席预占callout-server services/agent-routing.tsRedis 锁 + 行锁原子选坐席、置 busy / 复位 idle
会议会话callout-server services/conference-sessions.ts + conference_sessions会话事实源(会议室、客户腿、主坐席)
坐席入会业务callflow-esl agent-conference-guest-business.ts坐席接通后 ctx.conference.join 进会议室
坐席间流转callout-server handlers/conference-sessions.ts加/踢成员(盲转、协商转、主管监听/强插)
兜底对账callout-server services/conference-reconcile.ts关闭僵尸会话、释放卡住坐席

2. 端到端流程

AI业务: 识别"转人工"
   │ 1. 生成 conferenceId = transfer-<uuid>,播报"正在转接",停掉自己的 ASR

AI业务 ──POST /api/transfers/route──▶ callout-server
   │                                     │ 2. reserveIdleAgent(): Redis锁+行锁 原子预占在线坐席,置 busy
   │                                     │ 3. bgapi originate user/<分机> &socket(callflow-esl)
   │                                     │      {business_code=agent-conference-guest, conference_id, callout_*}
   │                                     │ 4. openConferenceSession(): 登记 conference_sessions(主坐席=该坐席)
   │   ◀──── { routed, agent, conferenceId, softphoneCallId } ────
   │ 5. routed=true → 客户腿 ctx.conference.join({ conferenceId })
   │      routed=false(no_agent) → 播报"稍后联系您" + 挂机或按业务策略收尾

FreeSWITCH 接通坐席分机 → 回连 callflow-esl → agent-conference-guest 业务
   │ 6. answer → 读 conference_id → ctx.conference.join({ conferenceId, flags })

客户与坐席在同一会议室通话 ✅
   │ 7. 看门狗:振铃超时后若会议成员<2 → 改判未接通、播报、挂机
   │ 8. 末位坐席离开/客户挂断 → 挂机事件 或 conferenceReconcile 收尾、releaseAgent

3. AI 业务侧:请求路由 + 入会 + 回退

3.1 调用路由 helper

business/shared/human-transfer.ts 把路由请求封装好,失败一律归一化为 { routed: false, reason }, 让业务安全回退:

ts
import { requestHumanAgentTransfer } from "./shared/human-transfer.ts";

const route = await requestHumanAgentTransfer(ctx, {
  conferenceId,                                  // 业务生成的会议室 ID
  requestId: calloutRequestId ?? undefined,
  campaignId: campaignId ?? undefined,
  contactId: contactId ?? undefined,
  customerNumber: ctx.channel.destinationNumber ?? null,
  customerCallUuid: ctx.channel.uuid,            // 客户A腿UUID,供末位坐席离开时收尾
  ringTimeoutSeconds: 30,                         // 坐席分机振铃超时
});

helper 内部:从 ctx.config.callout.calloutServer.baseUrl 构造 /api/transfers/route,带 X-Callout-Internal-Token(= calloutServer.token),POST JSON,把响应 data 原样返回 (HumanTransferRouteResult):

ts
interface HumanTransferRouteResult {
  routed: boolean;            // FreeSWITCH 是否已接受坐席后台 originate job
  reason?: string;            // no_agent / originate_failed / not_configured / http_xxx / request_error
  conferenceId?: string;
  agent?: { id: number; name: string; extension: string | null } | null;
  softphoneCallId?: number;
  ringTimeoutSeconds?: number;
}

3.2 完整 transferToAgent(精简自 demo 业务)

ts
const transferToAgent = async (): Promise<void> => {
  if (shuttingDown) return;
  shuttingDown = true;
  await speakTurn("transfer", { kind: "tts", text: "好的,正在为您转接人工坐席,请稍候。" });
  await asrSession.stop("transfer-to-agent");    // 转人工后不再驱动 AI 流程

  const route = await requestHumanAgentTransfer(ctx, {
    conferenceId, requestId: calloutRequestId ?? undefined,
    campaignId: campaignId ?? undefined, contactId: contactId ?? undefined,
    customerNumber: ctx.channel.destinationNumber ?? null,
    customerCallUuid: ctx.channel.uuid, ringTimeoutSeconds: 30,
  });

  if (!route.routed) {                            // 无坐席/提交失败 → 回退
    outcome = { outcomeCode: "completed", outcomePriority: "B", outcomeCategory: "transfer_failed",
      prompt: TRANSFER_UNAVAILABLE_PROMPT, resultSummary: `要求转人工但未接入(${route.reason})` };
    await speakTurn("transfer-unavailable", { kind: "tts", text: TRANSFER_UNAVAILABLE_PROMPT });
    await ctx.hangup("NORMAL_CLEARING");
    return;
  }

  outcome = { outcomeCode: "transferred", outcomePriority: "A", outcomeCategory: "transfer_requested",
    prompt: TRANSFER_PROMPT, resultSummary: `已路由到坐席 ${route.agent?.name}(会议 ${conferenceId})` };

  // 看门狗:振铃超时(+5s)后若会议成员 < 2,说明坐席没进来,改判未接通并挂机以中断 join 阻塞
  const watchdog = setTimeout(() => void (async () => {
    const reply = await ctx.api(`conference ${conferenceId} count`);
    if (Number.parseInt(reply.trim(), 10) >= 2) return;
    outcome = { outcomeCode: "completed", outcomePriority: "B", outcomeCategory: "transfer_failed",
      prompt: TRANSFER_TIMEOUT_PROMPT, resultSummary: "坐席振铃超时未接,本次未接通" };
    await ctx.conference.play({ conferenceId, input: { kind: "tts", text: TRANSFER_TIMEOUT_PROMPT } }).catch(() => undefined);
    await ctx.hangup("NORMAL_CLEARING");
  })(), (30 + 5) * 1000);

  try {
    await ctx.conference.join({ conferenceId });  // 客户腿入会,阻塞至退出会议
  } finally {
    clearTimeout(watchdog);
  }
};

要点:

  • 业务自己生成 conferenceId(如 transfer-<uuid>),客户腿和坐席腿据它汇合。
  • routed=true 只代表坐席后台 originate job 已被接受,不代表坐席已接听。坐席是否真进会议室 由看门狗用 conference <id> count 兜底(成员 < 2 视为没进来)。
  • 转人工成功后业务终态为 transferred,最终仍会照常 POST /api/call-results 回写。

4. callout-server 侧:POST /api/transfers/route

内部接口(token 鉴权,与 /api/call-results 同款;非会话鉴权)。请求体:

jsonc
{
  "conferenceId": "transfer-xxxx",   // 必填
  "requestId": "<callout_request_id>",
  "campaignId": 1, "contactId": 100, "attemptId": 555,
  "customerNumber": "13800138000",
  "customerCallUuid": "<客户A腿UUID>",
  "ringTimeoutSeconds": 30
}

响应 data(注意:即使无坐席/失败也返回 ok:true,用 routed 区分):

jsonc
{ "routed": true, "conferenceId": "transfer-xxxx",
  "agent": { "id": 7, "name": "坐席A", "extension": "1007" },
  "softphoneCallId": 88, "ringTimeoutSeconds": 30 }
// 无坐席:{ "routed": false, "reason": "no_agent", "conferenceId": "...", "agent": null }

处理逻辑(handleTransferRoute):

  1. reserveIdleAgent(deps) 原子预占一个空闲在线坐席(见下)。无坐席 → 记 agent_transfer_no_agent 运行事件,返回 routed:false, reason:no_agent
  2. 预生成 jobUuidchannelUuid,插一条 agent_call_recordsdirection=transfer, status=requested),并把两个 UUID 与 conferenceId 一并写入 payload。
  3. submitAgentConferenceCall 后台 originate 坐席分机入会 (business_code=agent-conference-guestconference_idorigination_uuid=channelUuidcall_timeout=ringTimeoutSeconds 等)。
  4. originate 被接受 → openConferenceSession 登记 conference_sessions(主坐席 = 该坐席、客户腿 UUID); 失败 → releaseAgent 复位坐席。
  5. 把转人工信息并入 call_attempts.payload.result.transferToAgent,记 agent_transfer_routed 运行事件。

客户挂机收尾:POST /api/transfers/end

kb-audio-chat-business/route 成功后保存会议 ID;客户腿进入 finally 时,使用 callout.calloutServer.baseUrl 固定构造 /api/transfers/end,复用该服务 token 与超时发送:

json
{ "conferenceId": "transfer-xxxx", "customerCallUuid": "<客户A腿UUID>", "reason": "customer_hangup" }

接口校验客户 UUID 与会议会话一致,重复请求或已结束会话幂等返回成功。首次收尾会先关闭 会议会话,再将该会议全部 requested/submitted/answered 坐席和主管腿标记终止:接通腿为 completed,未接通腿为 failed,挂机原因为 ORIGINATOR_CANCEL;随后释放忙碌坐席并对 预生成的 channelUuid 执行 uuid_kill。事件监听同时订阅 CHANNEL_CREATE:如果通道晚于 收尾创建,会读取 terminationRequested=customer_hangup 再次立即挂断;晚到的 CHANNEL_ANSWER 不能把已取消记录推进回 answered

因此客户在坐席振铃、坐席已接听入会、协商转新增坐席或主管监听任一阶段挂机,会议内全部 人工 SIP 腿都会收到 BYE。反向的“末位坐席离开时挂断客户腿”规则仍保留。

坐席预占(agent-routing.ts

reserveIdleAgentRedis 锁(跨实例)+ FOR UPDATE SKIP LOCKED(库内) 保证两通转人工不会抢到同一坐席:

  • 候选条件:启用、未软删、状态为 idle(示闲)、配置了非空分机。滚动升级期间查询侧仍兼容尚未归一的历史 online 数据。
  • 排序:lastStatusAt 最久空闲优先。
  • 命中后立刻置 busy,下次预占不会再选到它。
  • releaseAgent:坐席没有其它进行中的转接软电话腿时,把 busy 复位 idle (由坐席腿挂断/振铃超时触发,避免一次未接通长期卡 busy)。

5. 坐席入会业务 agent-conference-guest

坐席分机被 originate 接通后,FreeSWITCH 按 business_code=agent-conference-guest 路由到这个业务。 它只做一件事:读会议室 ID 并 join。完整源码:

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

  const conferenceId = await ctx.getVariable("conference_id");
  if (!conferenceId) {                              // 缺参数兜底,避免空 join 抛错
    await ctx.speak({ kind: "tts", text: "转接参数缺失,无法加入会议。" });
    await ctx.hangup("NORMAL_TEMPORARY_FAILURE");
    return;
  }

  // callout-server 透传角色与会议 flags(主管静默监听 = mute)
  const role = await ctx.getVariable("callout_conference_role");
  const flagsRaw = await ctx.getVariable("callout_conference_flags");
  const flags = flagsRaw ? flagsRaw.split("|").map((f) => f.trim()).filter(Boolean) : [];

  await ctx.conference.join({ conferenceId, ...(flags.length > 0 ? { flags } : {}) });
};

export const agentConferenceGuestBusiness = defineBusiness({
  code: "agent-conference-guest",
  desc: "坐席入会(AI 转人工)业务",
  handler,
});

这个业务对「首次转人工 / 后续坐席间流转 / 主管监听」是通用的——加成员只是再 originate 一条坐席腿 进同一会议室,业务无需改动。

6. 坐席间流转(监控台/坐席操作)

围绕同一个会议室做成员动态进出。

6.1 查看会话

  • GET /api/conference-sessions(需 campaign:dispatch):进行中会话列表,每条附 FreeSWITCH 实时成员数 liveMemberCountliveStatuslive/ended/unknown,只读校准,不写库)。
  • GET /api/conference-sessions/:conferenceId:会话详情 + 成员腿(participants: SoftphoneCallRow[])。

6.2 加成员

POST /api/conference-sessions/:conferenceId/participants

jsonc
// 协商转/三方:加新坐席,主坐席不变(primary 不动)
{ "role": "agent", "agentId": 9 }
// 盲转:加新坐席成功后踢掉旧主坐席,并把 primary 切到新坐席
{ "role": "agent", "agentId": 9, "replacePrimary": true }
// 从空闲池自动选坐席(不指定 agentId)
{ "role": "agent" }
// 主管静默监听(mute)
{ "role": "supervisor", "agentId": 2, "mode": "monitor" }
// 主管强插(可发言)
{ "role": "supervisor", "agentId": 2, "mode": "barge" }

语义:

  • role=agent(默认,需 agent:operate):指定 agentIdreserveSpecificAgent,否则从空闲池 reserveIdleAgent 预占。replacePrimary=true = 盲转;否则 = 协商转/三方
  • role=supervisor(需 campaign:dispatch):必须带 agentId不占用坐席池(可旁听多路); mode=monitor → 以 mute flag 静默监听;mode=barge → 正常可发言。
  • 实现:插 agent_call_recordssubmitAgentConferenceCall 后台 originate 坐席分机入会 (固定 business_code=agent-conference-guest),FreeSWITCH 接受 job 后即返回。成员记录 同样预存 channelUuid;落库后会再次确认会话仍为 active,客户已收尾时不再提交 originate。
  • 响应:{ added: true, conferenceId, role, agent, softphoneCallId, replacedSoftphoneCallId }; 无坐席/提交失败时 { added: false, reason: "no_agent"|"agent_unavailable"|"originate_failed"|... }
  • conference_participant_added 运行事件。

6.3 踢成员

DELETE /api/conference-sessions/:conferenceId/participants/:softphoneCallId(需 agent:operate): 对该成员腿 uuid_kill。用于协商转完成(移除旧主坐席)、取消(移除新坐席)、主管退出。 移除的若是当前主坐席,会自动把 primary 提升为该会议剩余的最新一条 answered 坐席腿(协商转完成后归属正确)。 记 conference_participant_removed 运行事件。

6.4 典型操作组合

  • 盲转:加成员 {role:agent, agentId, replacePrimary:true} → 旧主坐席被自动踢、primary 切新坐席。
  • 协商转:先 {role:agent, agentId}(三方,原坐席介绍)→ 介绍完踢掉旧主坐席(DELETE 旧 softphoneCallId)。
  • 主管监听{role:supervisor, agentId, mode:"monitor"};需要插话时再 {...mode:"barge"} 或退出后重进。

7. 坐席工作台触发的转人工 / 回拨 / 呼出

除 AI 自动转人工(第 2–6 节的会议模型)外,坐席也可在工作台主动发起通话(均需 agent:operate,落 agent_call_records)。 注意这一组与 AI 会议转人工是两套机制:它们经 submitAgentBridgeCall —— 先 originate 坐席分机、 接通后 &bridge('<客户拨号串>') 直接桥接客户号码,是对客户发起的新一通桥接呼叫, 并不接管正在进行的 AI 会议。

接口方向行为
POST /api/agents/:id/transferstransfer对指定 attemptId 对应的客户号码发起坐席↔客户桥接呼叫;在 call_attempts.payload.result.transferToAgent 登记转接标记
POST /api/agents/:id/callbackscallback对联系人发起人工回拨:originate 坐席分机、接通后 &bridge 客户号码
POST /api/agents/:id/manual-callsmanual_call坐席对任意客户号码人工呼出(同样 originate 坐席→&bridge 客户)

POST /api/agents/:id/transfers 请求体:{ "attemptId": 555, "note": "..." }

选型:通话正在进行中要把人工接进来用 AI 会议转人工(/api/transfers/route + ctx.conference.join); 事后/工作台要由坐席重新接触该客户用这一组桥接接口。

8. 僵尸会话兜底

如果客户先挂断且事件丢失,会议在 FreeSWITCH 已结束但库里仍 active。后台 conferenceReconcile worker (默认每 30s、graceMs 60s 宽限)用 conference <id> list count 校准:成员数为 0 且过宽限期, 就回收卡住的坐席腿、挂断残留客户腿、关闭会话并记 conference_session_reconciled 运行事件。 FreeSWITCH 不可达时整轮跳过,绝不误关。

9. 检查清单

  • [ ] callflow-esl 配了 callout.calloutServer.baseUrl / token / requestTimeoutMs
  • [ ] AI 业务自己生成 conferenceId,请求路由时带 customerCallUuid(末位坐席离开收尾要用)。
  • [ ] routed=false 有回退分支(播报稍后联系 + 挂机或按业务策略收尾),不要硬等。
  • [ ] 用看门狗 + conference <id> count 处理坐席振铃超时未接。
  • [ ] agent-conference-guest 业务已注册(business-registry.ts + db:push)。
  • [ ] 坐席在 callout-server 配了正确的 phoneExtension,且 FreeSWITCH 能 user/<分机> 拨到。
  • [ ] 转人工成功后业务仍照常回写 /api/call-results(如在 payload.result.demo.outcomeCode 标记 transferred)。

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