跳到正文

转人工业务示例 ​

AI 外呼通话转人工采用会议室桥接模型:把客户腿放进一个 FreeSWITCH 会议室,再把坐席分机 originate 进同一会议室,两边在会议里通话。后续「加人/减人/换人」(盲转、协商转、主管监听/耳语/强插)都能在同一个会议室上动态操作,无需重新拨打客户号码或切换客户腿通道。

FreeSWITCH 会议无法跨实例混音。因此转人工链路在路由预检、坐席筛选、分机呼叫与会话登记全生命周期中,必须严格锁定在客户腿所在的同一个 FreeSWITCH Cell 实例。

阅读前请先读 integration.md 与 outbound-business-example.md。

1. 角色与组件 ​

组件位置职责
AI 外呼业务callflow-esl *-business.ts识别转人工意图、生成会议室 ID、停单呼 ASR、开启会议级转写、请求路由、入会及回退
转人工 helpercallflow-esl business/shared/callout/transfer.ts封装 POST /api/transfers/route、POST /api/transfers/end 与两阶段看门狗 joinConferenceWithAgentWatchdog
路由入口callout-server handleTransferRoute校验凭证、定位 Cell、预检 SIP 注册、原子预占坐席、originate 坐席腿、登记会议会话
Cell 与注册预检callout-server agent-transfer.ts + client.ts锁定客户通话所在 Cell,查询实时 SIP 注册表(listRegisteredExtensions)提前拦截未注册分机
坐席预占与释放callout-server services/agent/agent-routing.tsRedis 跨实例锁 + PostgreSQL 行锁原子选坐席、置 busy / 复位 idle,并通过 SSE 广播状态
会议会话callout-server services/conference/conference-sessions.ts会话事实源(记录 cellId、客户腿 UUID、主坐席、关联外呼记录与状态)
坐席入会业务callflow-esl agent-conference-guest-business.ts坐席接通后按 business_code=agent-conference-guest 路由,读取会议 ID 与角色 flags 并入会
坐席间流转callout-server services/conference/conference-sessions.ts同一会议动态加/踢成员(盲转、协商转、主管静默监听/私密耳语/全员强插及就地模式切换)
事件与挂机收尾callout-server services/live-call/freeswitch-events.ts消费通道挂机与末位职员离会事件,双向触发会议结案、释放坐席与通道清理
僵尸会话对账callout-server services/conference/conference-reconcile.ts后台定期探测在会成员数,关闭僵尸会话并回收卡住坐席

2. 端到端流程与状态机 ​

text
AI 业务: 识别"转人工"
   │ 1. 生成 conferenceId = transfer-<uuid>,播报"正在转接",停止客户单腿 ASR
   ▼
AI 业务 ──POST /api/transfers/route (带 conferenceId, customerCallUuid, 可选 agentId)──▶ callout-server
   │                                                                               │ 2. 定位客户所在 Cell,向该 Cell 预检 SIP 注册表
   │                                                                               │ 3. 原子预占坐席(定向 agentId 或示闲池),置 busy
   │                                                                               │ 4. 预生成 Job/Channel UUID,先插入 agent_call_records(requested)
   │                                                                               │ 5. bgapi originate 坐席分机入会 (agent-conference-guest)
   │                                                                               │ 6. openConferenceSession() 登记 conference_sessions
   │ ◀──── { routed, agent, conferenceId, softphoneCallId, ringTimeoutSeconds } ───┘
   │
   ├─► routed=false (无坐席/分机未注册/提交失败)
   │     └─▶ 播报"稍后联系您" + 挂机或按业务策略收尾
   │
   └─► routed=true (FreeSWITCH 已接受坐席 originate job)
         │ 7. 可选启动会议级 ASR 转写 (startTransferConferenceHear) 供实时盯屏与质检
         │ 8. 客户腿进入两阶段自治看门狗 joinConferenceWithAgentWatchdog:
         │    - Phase 1 (waiting_for_agent): 等待坐席加入 (member-joined 事件 + 2s 轮询双通道感知)
         ▼
FreeSWITCH 呼通坐席分机 ──▶ 回连 callflow-esl ──▶ agent-conference-guest 业务
   │ 9. answer + settle ──▶ 读 conference_id / role / flags ──▶ ctx.conference.join
   ▼
坐席入会成功
   │ 10. 看门狗感知坐席入会,注销超时定时器,平滑切入 Phase 2 (in_call_guard)
   │ 11. 客户与坐席在同一会议室双向通话 ✅
   ▼
通话结束收尾(双向对称闭环)
   ├─► 客户先挂断:
   │     - 业务 finally 调 POST /api/transfers/end (幂等终止会内全部坐席/主管腿,释放坐席)
   │     - 底层事件 reconcileCustomerHangupConference 监听 CHANNEL_HANGUP 作为第二道保险
   └─► 坐席先挂断 / 末位职员离会:
         - Phase 2 看门狗探测 count <= 1,触发 emptyGraceSeconds (默认 3s) 防抖倒计时
         - 防抖期内新坐席加入(协商转/模式切换)立即取消倒计时;期满复核仍无职员,客户腿优雅自退
         - callout-server 通道事件探测 countActiveStaffLegs === 0,挂断客户腿并关闭会话

3. AI 业务侧实现(callflow-esl) ​

3.1 调用路由 helper:requestHumanAgentTransfer ​

apps/callflow-esl/src/business/shared/callout/transfer.ts 封装了 /api/transfers/route 的内部调用,将任何网络异常或非 2xx 响应归一化为 { routed: false, reason },让业务安全执行兜底分支:

ts
import { requestHumanAgentTransfer } from "./shared/callout/index.ts";

const route = await requestHumanAgentTransfer(ctx, {
  conferenceId, // 业务生成的专属会议室 ID(如 transfer-<uuid>)
  requestId: calloutRequestId ?? undefined,
  campaignId: campaignId ?? undefined,
  contactId: contactId ?? undefined,
  attemptId: attemptId ?? undefined,
  customerNumber: ctx.channel.destinationNumber ?? null,
  customerCallUuid: ctx.channel.uuid, // 客户 A 腿 UUID,供末位坐席离开及会话收尾使用
  ringTimeoutSeconds: 30, // 坐席分机振铃超时(秒)
  agentId: requestedAgentId ?? undefined // 可选:指定坐席 ID(定向人工介入场景)
});

接口类型定义:

ts
export interface HumanTransferRequest {
  conferenceId: string;
  requestId?: string | null;
  campaignId?: number | null;
  contactId?: number | null;
  attemptId?: number | null;
  customerNumber?: string | null;
  customerCallUuid?: string | null;
  ringTimeoutSeconds?: number;
  agentId?: number | null; // 指定坐席时走定向预占;不填则从示闲池自动选择
}

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

3.2 客户腿入会与两阶段自治看门狗:joinConferenceWithAgentWatchdog ​

在转人工链路中,routed=true 仅代表后台 originate 任务被接受,不代表坐席已经摘机接听;同时在通话中,若坐席中途挂断,客户不应被遗留在空会议室中。

joinConferenceWithAgentWatchdog 实现了统一的两阶段在会状态机(Unified Pipeline):

  1. 阶段一(Phase 1: waiting_for_agent):
    • 客户腿入会等待首位坐席加入;
    • 双通道感知:实时监听 FreeSWITCH member-joined 事件,配合 2 秒一次的 conference <id> list count 轮询真值校验;
    • 超时自退:在 ringTimeoutSeconds + graceSeconds(默认缓冲 5 秒)内无坐席入会,自动通过 kickMember 踢出客户腿自身,并标记 leaveReason: "agent_join_timeout",打断 join 阻塞以执行兜底播报。
  2. 阶段二(Phase 2: in_call_guard):
    • 感知到非客户成员加入后,自动注销阶段一超时定时器,平滑切换为在会看护;
    • 当会内所有职员离会(count <= 1)时,启动 emptyGraceSeconds(默认 3 秒)防抖倒计时;
    • 若防抖期内有新职员加入(如协商转接、主管接入),立即撤销倒计时维持通话;
    • 防抖期满最终复核仍无职员在会,自动执行 kickMember 自退,并标记 leaveReason: "agent_left"。
  3. 客户主动挂断:
    • 通道已挂机时标记 leaveReason: "customer_hangup"。

看门狗返回结构:

ts
export interface ConferenceAgentJoinWatchResult {
  agentJoined: boolean; // 等待期间是否有坐席加入过会议
  hungUp: boolean; // join 返回时客户腿是否已挂机
  leaveReason?: "agent_join_timeout" | "agent_left" | "customer_hangup";
}

3.3 客户挂机收尾 helper:requestHumanAgentTransferEnd ​

无论是在转接等待中、坐席通话中还是转接失败退出,当客户通道生命周期结束(进入业务 finally)时,调用 requestHumanAgentTransferEnd 通知 callout-server 幂等收尾会议人工腿:

ts
import { requestHumanAgentTransferEnd } from "./shared/callout/index.ts";

await requestHumanAgentTransferEnd(ctx, {
  conferenceId,
  customerCallUuid: ctx.channel.uuid,
  reason: "customer_hangup"
});

该 helper 发送 POST /api/transfers/end,异常仅记录结构化日志,绝不抛出异常遮蔽客户业务的录音与结果回写。

3.4 完整 transferToAgent 示例(生产规范) ​

以下代码精简自 callout-flow-business 与 kb-audio-chat-business 的标准实现:

ts
const transferToAgent = async (): Promise<void> => {
  if (shuttingDown) return;
  shuttingDown = true;

  // 1. 播报转接提示语
  await speakTurn("transfer", {
    kind: "tts",
    text: "好的,正在为您转接人工坐席,请稍候。"
  });

  // 2. 停掉针对客户单腿的 ASR,避免后续与真人对话时冲突
  await asrSession?.stop("transfer-to-agent").catch(() => undefined);

  // 3. 请求 ACD 路由
  const route = await requestHumanAgentTransfer(ctx, {
    conferenceId,
    requestId: calloutRequestId ?? undefined,
    campaignId: campaignId ?? undefined,
    contactId: contactId ?? undefined,
    attemptId: attemptId ?? undefined,
    customerNumber: ctx.channel.destinationNumber ?? null,
    customerCallUuid: ctx.channel.uuid,
    ringTimeoutSeconds: 30
  });

  if (!route.routed) {
    // 路由失败(无空闲坐席 / 分机未注册 / FreeSWITCH 拒绝):执行业务兜底
    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;
  }

  // 4. 路由成功,开启会议级 ASR 转写监听(可选,供前端实时盯屏与质检记录双方语音)
  const targetConferenceId = route.conferenceId ?? conferenceId;
  const hearSession = await startTransferConferenceHear(ctx, {
    conferenceId: targetConferenceId,
    customerCallUuid: ctx.channel.uuid,
    transcript
  });

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

  try {
    // 5. 客户腿入会并启动两阶段自治看门狗(阻塞直至双方挂机或超时离会)
    const watch = await joinConferenceWithAgentWatchdog(ctx, {
      conferenceId: targetConferenceId,
      ringTimeoutSeconds: route.ringTimeoutSeconds ?? 30,
      emptyGraceSeconds: 3
    });

    // 6. 根据退出原因收尾
    if (!watch.agentJoined && !watch.hungUp) {
      // 坐席振铃超时未接听入会:播放超时兜底并挂机
      outcome = {
        outcomeCode: "completed",
        outcomePriority: "B",
        outcomeCategory: "transfer_failed",
        prompt: TRANSFER_TIMEOUT_PROMPT,
        resultSummary: "坐席振铃超时未接,本次未接通"
      };
      await speakTurn("transfer-timeout", {
        kind: "tts",
        text: TRANSFER_TIMEOUT_PROMPT
      });
      await ctx.hangup("NORMAL_CLEARING");
    }
  } finally {
    await hearSession?.stop("transfer-ended").catch(() => undefined);
    // 客户离会时幂等收尾会议人工腿
    await requestHumanAgentTransferEnd(ctx, {
      conferenceId: targetConferenceId,
      customerCallUuid: ctx.channel.uuid,
      reason: "customer_hangup"
    });
  }
};

4. callout-server 侧架构与接口契约 ​

4.1 转人工路由入口:POST /api/transfers/route ​

服务间内部接口,使用 X-Callout-Internal-Token 鉴权(与 /api/call-results 同款凭证)。

请求体(JSON):

jsonc
{
  "conferenceId": "transfer-xxxx", // 必填:会议室唯一标识
  "customerCallUuid": "xxxx-xxxx", // 推荐:客户 A 腿通道 UUID
  "ringTimeoutSeconds": 30, // 可选:坐席分机振铃超时,默认 30s
  "agentId": 7, // 可选:指定坐席 ID(定向人工介入,跳过示闲池自动选举)
  "attemptId": 555, // 可选:外呼尝试 ID,用于确定所属 FreeSWITCH Cell 与回写结果
  "campaignId": 1, // 可选:外呼任务 ID
  "contactId": 100, // 可选:联系人 ID
  "customerNumber": "13800138000" // 可选:客户电话号码
}

响应体(JSON):

即使无可用坐席或提交失败,HTTP 层仍返回 200,通过 data.routed 字段区分:

jsonc
// 成功受理坐席 originate
{
  "code": "0000",
  "msg": "ok",
  "data": {
    "routed": true,
    "conferenceId": "transfer-xxxx",
    "agent": { "id": 7, "name": "张三", "extension": "1007" },
    "softphoneCallId": 88,
    "ringTimeoutSeconds": 30
  }
}

// 无可用坐席
{
  "code": "0000",
  "msg": "ok",
  "data": {
    "routed": false,
    "reason": "no_agent",
    "conferenceId": "transfer-xxxx",
    "agent": null
  }
}

处理管线(handleTransferRoute):

  1. 凭证鉴权:校验请求头 X-Callout-Internal-Token,失败直接 401。
  2. Cell 实例定位:读取 attemptId -> call_attempts.cellId,锁定该通话所在的 FreeSWITCH Cell;禁止在多 Cell 环境下降级为默认 Cell,防止跨实例造成会议无法混音。
  3. 实时 SIP 注册预检:向客户所在 Cell 查询实时注册表(listRegisteredExtensions)。若分机未注册,直接判定 no_agent 拦截,避免产生无意义的 USER_NOT_REGISTERED 挂机事件。
  4. 原子预占坐席:
    • 若指定 agentId:执行 reserveSpecificAgent,严格复核该坐席状态为 idle、分机在注册白名单且归属于当前 Cell;
    • 未指定 agentId:执行 reserveIdleAgent,在 Redis 分布式锁 + PostgreSQL 行级排他锁(FOR UPDATE SKIP LOCKED)保护下,按最久空闲优先选举坐席,并立即置为 busy。
  5. 预生成 UUID 并落库:预生成 jobUuid 与 channelUuid,先向 agent_call_records 插入一条记录(status=requested,携带两个 UUID 与 cellId)。顺序不可颠倒:若先提交再落库,即时失败的 BACKGROUND_JOB 事件反查扑空,将导致坐席无法释放。
  6. 向同 Cell 提交 originate:调用 submitAgentConferenceCall 向该 Cell 发起后台呼叫(business_code=agent-conference-guest、origination_uuid=channelUuid、Job-UUID=jobUuid)。
  7. 与异步事件竞争终态:通过带 status=requested 条件的 SQL UPDATE 与抢先到达的 BACKGROUND_JOB 事件竞争:若事件已抢先判败并释放坐席,本次更新受影响行数为 0,接口返回 routed=false,避免业务层进入死等。
  8. 登记会议会话:仅当确认 routed=true 时,调用 openConferenceSession 写入 conference_sessions(记录 cellId、客户腿 UUID、主坐席与记录 ID)。
  9. 回写 Attempt 摘要:将本次转接详情合并写入 call_attempts.payload.result.transferToAgent。
  10. 记录运行事件:记录 agent_transfer_routed 结构化事件。

4.2 客户挂机收尾接口:POST /api/transfers/end 与底层双重闭环 ​

当客户挂断时,系统具备应用层与底层事件的双重保险机制:

jsonc
// POST /api/transfers/end 请求体
{
  "conferenceId": "transfer-xxxx",
  "customerCallUuid": "<客户A腿UUID>",
  "reason": "customer_hangup"
}
  1. 应用层主动收尾(handleTransferEnd):
    • 校验 customerCallUuid 与会话一致;
    • 首次收尾先将 conference_sessions 状态置为 ended;
    • 将会内全部处于 requested/submitted/answered 状态的人工腿置为终止(已接通置 completed,未接通置 failed,挂机原因为 ORIGINATOR_CANCEL);
    • 释放关联坐席为 idle;
    • 对预生成的各通道 channelUuid 执行 submitUuidKill,发送 SIP BYE。
  2. 底层事件自动闭环(reconcileCustomerHangupConference):
    • freeswitch-events.ts 订阅了全局 CHANNEL_HANGUP 与 CHANNEL_HANGUP_COMPLETE 事件;
    • 当收到挂机通道的 UUID 匹配活跃会议的 customerCallUuid 时,底层事件链路自动触发相同的会议结案、坐席腿清理与坐席释放,防止业务进程异常崩溃时产生孤儿坐席腿。

4.3 坐席预占与状态机演进(agent-routing.ts) ​

  • 示闲选举条件:isEnabled = 1、未被软删除、状态为 idle(兼容升级期 online)、分机非空且在实时 SIP 注册白名单内、归属同 Cell。
  • 排序策略:lastStatusAt ASC NULLS FIRST(最久示闲优先)。
  • 状态广播:坐席置 busy 或复位 idle 时,服务端自动通过 SSE(broadcastAgentStatus)向前端工作台广播最新状态,实现坐席看板毫秒级同步。
  • 释放机制(releaseAgent):在坐席腿挂断、振铃超时未接或转接提交失败时触发。释放前复核该坐席是否还存在其它活动转接软电话腿,避免并发多路时误释放。

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

当坐席分机接听后,FreeSWITCH 按照 business_code=agent-conference-guest 将坐席腿路由回 callflow-esl。该业务极其轻量,只负责解析参数并加入会议:

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

const MISSING_CONFERENCE_PROMPT = "转接参数缺失,无法加入会议。";

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) {
    ctx.log("agent_conference_guest_missing_conference_id", {
      callerIdNumber: ctx.channel.callerIdNumber ?? null,
      businessCode: ctx.channel.businessCode ?? null
    });
    await ctx.speak({ kind: "tts", text: MISSING_CONFERENCE_PROMPT });
    await ctx.hangup("NORMAL_TEMPORARY_FAILURE");
    return;
  }

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

  await ctx.setVariables({
    conference_role: role,
    callout_conference_role: role
  });

  ctx.log("agent_conference_guest_joining", {
    conferenceId,
    role,
    flags,
    callerIdNumber: ctx.channel.callerIdNumber ?? null
  });

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

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

架构优势:该业务对首次转人工、后续盲转新坐席、协商转接以及主管协同是通用的。加成员只需再 originate 一条坐席腿进入该会议,业务无需做任何分支判断。

6. 坐席间流转与主管协同(管理台 / 坐席操作) ​

围绕同一个会议室进行多方成员的动态操作。

6.1 查看会话状态 ​

  • GET /api/conferences/list(需 campaign:dispatch 权限):查询进行中的会议会话列表。每条会话附带向底层 FreeSWITCH 实时查询的 liveMemberCount 与 liveStatus(live/ended/unknown)。liveStatus=ended 且会话已超过宽限期(conferenceReconcile.graceMs,默认 30s)时会触发 autoRepairEndedSession 自动收尾写库:成员腿置 completed/failed、释放坐席、会话结案。
  • POST /api/conferences/detail:查询会话详情及成员列表(请求体 { "conferenceId": "transfer-xxxx" },响应含 participants: SoftphoneCallRow[])。

6.2 加成员(POST /api/conferences/participants/add) ​

根据角色与参数支持盲转、协商转与主管三方协同:

jsonc
// 1. 协商转 / 邀请新坐席入会(原坐席不动)
{ "conferenceId": "transfer-xxxx", "role": "agent", "agentId": 9 }

// 2. 盲转(加新坐席成功后自动踢除旧主坐席,并把 primary 切到新坐席)
{ "conferenceId": "transfer-xxxx", "role": "agent", "agentId": 9, "replacePrimary": true }

// 3. 从示闲池自动选坐席入会(不指定 agentId)
{ "conferenceId": "transfer-xxxx", "role": "agent" }

// 4. 主管静默旁听(以 mute flag 入会,不占用坐席示闲池)
{ "conferenceId": "transfer-xxxx", "role": "supervisor", "agentId": 2, "mode": "monitor" }

// 5. 主管私密耳语(建立客户单向声障,仅对坐席发声指导)
{ "conferenceId": "transfer-xxxx", "role": "supervisor", "agentId": 2, "mode": "whisper" }

// 6. 主管全员强插(双向全通,向会议内所有人发声)
{ "conferenceId": "transfer-xxxx", "role": "supervisor", "agentId": 2, "mode": "barge" }

关键机制:

  1. 同 Cell 约束与成员为 0 阻断:加成员必须确保目标坐席注册在与会议相同的 Cell。若底层 FreeSWITCH 现场在会成员数为 0,接口直接返回 409: 会议已结束,禁止变更成员,防止向死会议塞入幽灵成员。
  2. 主管协同声障时序:
    • whisper(耳语):先闭麦主管 ➔ 执行 conference relate <主管> <客户> nospeak 与 conference relate <客户> <主管> nohear 建立单向声障 ➔ 开麦主管。实现坐席能听见主管指导,客户完全听不见主管声音;
    • barge(强插):先闭麦主管 ➔ 执行 conference relate clear 解除与客户的声障 ➔ 开麦主管,全员可听;
    • monitor(监听):解除声障并闭麦主管。
  3. 就地模式切换(In-place Switch):若主管已经在会议中,再次调用接口切换模式(如从 monitor 切到 whisper 或 barge)时,不会发起二次拨号,而是直接通过 FreeSWITCH 指令就地调整声障与麦克风状态,响应返回 { switched: true, mode }。

6.3 踢成员(POST /api/conferences/participants/remove) ​

需 agent:operate 权限,请求体 { "conferenceId": "transfer-xxxx", "softphoneCallId": 88 }。对该成员通道执行 uuid_kill。用于:

  • 协商转完成:新坐席已接入并了解情况,踢出原坐席;
  • 协商转取消:新坐席未接听或忙,取消并踢出新坐席腿;
  • 主管主动退会:主管听完后退出。

主坐席自动晋升:若踢出的是当前会话的主坐席(primaryAgentId),系统会自动将 primary 晋升为该会议剩余的最新一条已接听坐席腿,确保协商转完成后归属正确。

6.4 典型操作组合示例 ​

  • 标准盲转: 加成员 { conferenceId, role: "agent", agentId: 9, replacePrimary: true } ➔ 系统呼叫坐席 9 ➔ 接受后自动踢掉旧坐席,primary 变更为坐席 9。
  • 带介绍的协商转:
    1. 旧坐席调用加成员 { conferenceId, role: "agent", agentId: 9 }(三方通话,旧坐席向新坐席介绍客户情况);
    2. 介绍完毕,旧坐席调用 POST /api/conferences/participants/remove 踢出自身通话腿,系统自动将 primary 晋升为坐席 9。
  • 主管阶梯式协同介入:
    1. 初始旁听:{ conferenceId, role: "supervisor", agentId: 2, mode: "monitor" },静默入会;
    2. 坐席指导:就地调用 { conferenceId, role: "supervisor", agentId: 2, mode: "whisper" },隔绝客户向坐席耳语提示;
    3. 全员强插:局面复杂时就地调用 { conferenceId, role: "supervisor", agentId: 2, mode: "barge" },接入全员三方对话;
    4. 恢复旁听:就地调用 { conferenceId, role: "supervisor", agentId: 2, mode: "monitor" } 退回静默监听;
    5. 离会:POST /api/conferences/participants/remove 踢出自身通话腿。

7. 坐席工作台主动发起的呼叫机制对比(Bridge vs 会议) ​

除上述 AI 自动转人工(第 2–6 节的会议室模型)外,坐席可在工作台主动发起呼叫:

接口方向业务机制通话模型
POST /api/transfers/routetransfer通话中 AI 自动或人工介入,复用已有会议室会议室模型(可动态加/踢/换人、耳语、强插)
POST /api/agents/transferstransfer坐席对已挂机记录重新接触客户直接桥接模型(originate 坐席分机 ➔ &bridge 拨打客户号码)
POST /api/agents/callbackscallback坐席对名单联系人主动回拨直接桥接模型(originate 坐席分机 ➔ &bridge 拨打客户号码)
POST /api/agents/manual-callsmanual_call坐席在拨号盘人工呼出任意号码直接桥接模型(originate 坐席分机 ➔ &bridge 拨打客户号码)

选型准则:

  • 客户通话正在进行中:必须使用会议转人工(requestHumanAgentTransfer + joinConferenceWithAgentWatchdog);
  • 客户通话已经挂断,需要重新回访或主动呼出:使用桥接类接口。

8. 僵尸会话兜底与自动化对账 ​

为防止因底层网络抖动或 FreeSWITCH 事件偶发丢失导致会话卡死,callout-server 启动了后台 conferenceReconcile worker:

  • 运行周期:默认每 30 秒轮询一次,设置 60 秒宽限期(graceMs);
  • 对账校验:按 session.cellId 分片向对应 FreeSWITCH 发送 conference <id> list count;
  • 清理动作:当现场成员数为 0 且超过宽限期,强制更新卡住的坐席腿为 failed,回收坐席为 idle,挂断残留客户腿,结案会话并记录 conference_session_reconciled 运维事件;
  • 网络保护:当 FreeSWITCH 连接不可达时,整轮对账安全跳过,绝不发生误杀。

9. 联调与上线检查清单 ​

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