转人工业务示例
AI 外呼通话转人工采用会议室桥接模型:把客户腿放进一个 FreeSWITCH 会议室,再把坐席分机 originate 进同一会议室,两边在会议里通话。后续「加人/减人/换人」(盲转、协商转、主管监听/耳语/强插)都能在同一个会议室上动态操作,无需重新拨打客户号码或切换客户腿通道。
FreeSWITCH 会议无法跨实例混音。因此转人工链路在路由预检、坐席筛选、分机呼叫与会话登记全生命周期中,必须严格锁定在客户腿所在的同一个 FreeSWITCH Cell 实例。
阅读前请先读 integration.md 与 outbound-business-example.md。
1. 角色与组件
| 组件 | 位置 | 职责 |
|---|---|---|
| AI 外呼业务 | callflow-esl *-business.ts | 识别转人工意图、生成会议室 ID、停单呼 ASR、开启会议级转写、请求路由、入会及回退 |
| 转人工 helper | callflow-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.ts | Redis 跨实例锁 + 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):
- 阶段一(Phase 1: waiting_for_agent):
- 客户腿入会等待首位坐席加入;
- 双通道感知:实时监听 FreeSWITCH
member-joined事件,配合 2 秒一次的conference <id> list count轮询真值校验; - 超时自退:在
ringTimeoutSeconds + graceSeconds(默认缓冲 5 秒)内无坐席入会,自动通过kickMember踢出客户腿自身,并标记leaveReason: "agent_join_timeout",打断 join 阻塞以执行兜底播报。
- 阶段二(Phase 2: in_call_guard):
- 感知到非客户成员加入后,自动注销阶段一超时定时器,平滑切换为在会看护;
- 当会内所有职员离会(
count <= 1)时,启动emptyGraceSeconds(默认 3 秒)防抖倒计时; - 若防抖期内有新职员加入(如协商转接、主管接入),立即撤销倒计时维持通话;
- 防抖期满最终复核仍无职员在会,自动执行
kickMember自退,并标记leaveReason: "agent_left"。
- 客户主动挂断:
- 通道已挂机时标记
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):
- 凭证鉴权:校验请求头
X-Callout-Internal-Token,失败直接 401。 - Cell 实例定位:读取
attemptId -> call_attempts.cellId,锁定该通话所在的 FreeSWITCH Cell;禁止在多 Cell 环境下降级为默认 Cell,防止跨实例造成会议无法混音。 - 实时 SIP 注册预检:向客户所在 Cell 查询实时注册表(
listRegisteredExtensions)。若分机未注册,直接判定no_agent拦截,避免产生无意义的USER_NOT_REGISTERED挂机事件。 - 原子预占坐席:
- 若指定
agentId:执行reserveSpecificAgent,严格复核该坐席状态为idle、分机在注册白名单且归属于当前 Cell; - 未指定
agentId:执行reserveIdleAgent,在 Redis 分布式锁 + PostgreSQL 行级排他锁(FOR UPDATE SKIP LOCKED)保护下,按最久空闲优先选举坐席,并立即置为busy。
- 若指定
- 预生成 UUID 并落库:预生成
jobUuid与channelUuid,先向agent_call_records插入一条记录(status=requested,携带两个 UUID 与cellId)。顺序不可颠倒:若先提交再落库,即时失败的BACKGROUND_JOB事件反查扑空,将导致坐席无法释放。 - 向同 Cell 提交 originate:调用
submitAgentConferenceCall向该 Cell 发起后台呼叫(business_code=agent-conference-guest、origination_uuid=channelUuid、Job-UUID=jobUuid)。 - 与异步事件竞争终态:通过带
status=requested条件的 SQL UPDATE 与抢先到达的BACKGROUND_JOB事件竞争:若事件已抢先判败并释放坐席,本次更新受影响行数为 0,接口返回routed=false,避免业务层进入死等。 - 登记会议会话:仅当确认
routed=true时,调用openConferenceSession写入conference_sessions(记录cellId、客户腿 UUID、主坐席与记录 ID)。 - 回写 Attempt 摘要:将本次转接详情合并写入
call_attempts.payload.result.transferToAgent。 - 记录运行事件:记录
agent_transfer_routed结构化事件。
4.2 客户挂机收尾接口:POST /api/transfers/end 与底层双重闭环
当客户挂断时,系统具备应用层与底层事件的双重保险机制:
jsonc
// POST /api/transfers/end 请求体
{
"conferenceId": "transfer-xxxx",
"customerCallUuid": "<客户A腿UUID>",
"reason": "customer_hangup"
}- 应用层主动收尾(
handleTransferEnd):- 校验
customerCallUuid与会话一致; - 首次收尾先将
conference_sessions状态置为ended; - 将会内全部处于
requested/submitted/answered状态的人工腿置为终止(已接通置completed,未接通置failed,挂机原因为ORIGINATOR_CANCEL); - 释放关联坐席为
idle; - 对预生成的各通道
channelUuid执行submitUuidKill,发送 SIP BYE。
- 校验
- 底层事件自动闭环(
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" }关键机制:
- 同 Cell 约束与成员为 0 阻断:加成员必须确保目标坐席注册在与会议相同的 Cell。若底层 FreeSWITCH 现场在会成员数为 0,接口直接返回
409: 会议已结束,禁止变更成员,防止向死会议塞入幽灵成员。 - 主管协同声障时序:
whisper(耳语):先闭麦主管 ➔ 执行conference relate <主管> <客户> nospeak与conference relate <客户> <主管> nohear建立单向声障 ➔ 开麦主管。实现坐席能听见主管指导,客户完全听不见主管声音;barge(强插):先闭麦主管 ➔ 执行conference relate clear解除与客户的声障 ➔ 开麦主管,全员可听;monitor(监听):解除声障并闭麦主管。
- 就地模式切换(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。 - 带介绍的协商转:
- 旧坐席调用加成员
{ conferenceId, role: "agent", agentId: 9 }(三方通话,旧坐席向新坐席介绍客户情况); - 介绍完毕,旧坐席调用
POST /api/conferences/participants/remove踢出自身通话腿,系统自动将 primary 晋升为坐席 9。
- 旧坐席调用加成员
- 主管阶梯式协同介入:
- 初始旁听:
{ conferenceId, role: "supervisor", agentId: 2, mode: "monitor" },静默入会; - 坐席指导:就地调用
{ conferenceId, role: "supervisor", agentId: 2, mode: "whisper" },隔绝客户向坐席耳语提示; - 全员强插:局面复杂时就地调用
{ conferenceId, role: "supervisor", agentId: 2, mode: "barge" },接入全员三方对话; - 恢复旁听:就地调用
{ conferenceId, role: "supervisor", agentId: 2, mode: "monitor" }退回静默监听; - 离会:
POST /api/conferences/participants/remove踢出自身通话腿。
- 初始旁听:
7. 坐席工作台主动发起的呼叫机制对比(Bridge vs 会议)
除上述 AI 自动转人工(第 2–6 节的会议室模型)外,坐席可在工作台主动发起呼叫:
| 接口 | 方向 | 业务机制 | 通话模型 |
|---|---|---|---|
POST /api/transfers/route | transfer | 通话中 AI 自动或人工介入,复用已有会议室 | 会议室模型(可动态加/踢/换人、耳语、强插) |
POST /api/agents/transfers | transfer | 坐席对已挂机记录重新接触客户 | 直接桥接模型(originate 坐席分机 ➔ &bridge 拨打客户号码) |
POST /api/agents/callbacks | callback | 坐席对名单联系人主动回拨 | 直接桥接模型(originate 坐席分机 ➔ &bridge 拨打客户号码) |
POST /api/agents/manual-calls | manual_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 连接不可达时,整轮对账安全跳过,绝不发生误杀。