Commit ad06a8bf by luoqi

feat: Twilio 网页点击拨号(Voice JS SDK)

- 后端 telephony 模块:POST /twilio/token(签 Voice AccessToken,JWT)+ POST /twilio/voice(@Public,Twilio 回调返回 <Dial> TwiML)
- 患者真号不下发浏览器(前端传 planId,后端查库解析);API Key Secret 只服务端
- TWILIO_FORCE_TO 联调开关(trial 账号只能打已验证号 → 强制拨它)
- 前端 use-twilio-call hook(@twilio/voice-sdk 动态 import)+ CallWidget(拨打/呼叫中/计时/静音/挂断/重拨),挂在详情页"通话结果"头部
- wrap-response 拦截器跳过 /twilio/voice(返回裸 TwiML XML)
- 配置:twilio 块(accountSid/apiKeySid/apiKeySecret/twimlAppSid/callerId/forceTo)

注:trial 阶段只能拨已验证号 + 美国主叫;生产需 Upgrade + China geo permissions,主叫建议换国内合规线路

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 524efac7
......@@ -79,6 +79,7 @@
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"socket.io": "^4.8.3",
"twilio": "^6.0.2",
"winston": "^3.19.0",
"ws": "^8.21.0",
"zod": "^4.4.3"
......
......@@ -17,6 +17,7 @@ import { PlanAggregateModule } from './modules/plan-aggregate/plan-aggregate.mod
import { AgentModule } from './modules/agent/agent.module';
import { AiModule } from './modules/ai/ai.module';
import { RealtimeCoachModule } from './modules/realtime-coach/realtime-coach.module';
import { TelephonyModule } from './modules/telephony/telephony.module';
import { AdminModule } from './modules/admin/admin.module';
import { QueuesModule } from './queues/queues.module';
import { QueuesBullBoardModule } from './queues/bull-board.module';
......@@ -49,6 +50,7 @@ import { HealthController } from './health.controller';
AgentModule,
AiModule,
RealtimeCoachModule,
TelephonyModule,
AdminModule,
PlanAggregateModule,
],
......
......@@ -49,6 +49,8 @@ function shouldSkip(req: Request, res: Response): boolean {
const url = req.url ?? '';
if (url === '/health') return true;
if (url.startsWith('/api/openapi.json') || url.startsWith('/api/docs')) return true;
// Twilio voice webhook 返回裸 TwiML(XML),不能包成 {code,msg,data} JSON
if (url.startsWith('/pac/v1/twilio/voice')) return true;
const ct = res.getHeader('content-type');
if (typeof ct === 'string' && ct.startsWith('text/event-stream')) return true;
return false;
......
......@@ -37,6 +37,18 @@ export interface AppConfig {
alert: { webhookUrl: string };
cors: { origins: string[] };
exchangeCodeTtlSeconds: number;
/// Twilio 网页拨号(Voice JS SDK 点击拨号)。全部只服务端,Secret 绝不下发浏览器
twilio: {
accountSid: string;
apiKeySid: string;
apiKeySecret: string;
/// TwiML App SID(出站客户端呼叫的 Voice URL 指向我们的 /twilio/voice)
twimlAppSid: string;
/// 主叫号(Twilio 账号下的号码,如 +16365386601)
callerId: string;
/// 强制拨号目标(trial 账号只能打已验证号;设了就所有呼叫都打它,生产置空走 planId 解析)
forceTo: string;
};
}
export function loadConfig(): AppConfig {
......@@ -79,6 +91,14 @@ export function loadConfig(): AppConfig {
.filter(Boolean),
},
exchangeCodeTtlSeconds: Number(process.env.EXCHANGE_CODE_TTL_SECONDS ?? 60),
twilio: {
accountSid: process.env.TWILIO_ACCOUNT_SID ?? '',
apiKeySid: process.env.TWILIO_API_KEY_SID ?? '',
apiKeySecret: process.env.TWILIO_API_KEY_SECRET ?? '',
twimlAppSid: process.env.TWILIO_TWIML_APP_SID ?? '',
callerId: process.env.TWILIO_CALLER_ID ?? '',
forceTo: process.env.TWILIO_FORCE_TO ?? '',
},
};
}
......
import { Controller, Post, Body, Req, Res, ForbiddenException } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import type { Request, Response } from 'express';
import { Public } from '../../common/decorators/public.decorator';
import {
CurrentUser,
AuthenticatedUser,
} from '../../common/decorators/current-user.decorator';
import { TelephonyService } from './telephony.service';
/**
* Twilio 网页拨号端点。
* POST /pac/v1/twilio/token — 客服端拿 Voice AccessToken(需 JWT)
* POST /pac/v1/twilio/voice — Twilio 回调,返回 Dial TwiML(@Public,Twilio 调,无 JWT)
*/
@ApiTags('telephony')
@Controller('twilio')
export class TelephonyController {
constructor(private readonly telephony: TelephonyService) {}
@Post('token')
@ApiOperation({ summary: '签发 Twilio Voice AccessToken(网页点击拨号用)' })
token(@CurrentUser() user: AuthenticatedUser) {
if (!this.telephony.isEnabled()) {
throw new ForbiddenException('网页拨号未配置(缺 Twilio 凭证)');
}
return this.telephony.createVoiceToken(user.sub);
}
@Public()
@Post('voice')
@ApiOperation({ summary: 'Twilio 出站呼叫 TwiML 回调(返回 <Dial>)' })
async voice(
@Body() body: Record<string, string>,
@Req() _req: Request,
@Res() res: Response,
) {
// ⚠️ 生产应校验 X-Twilio-Signature(需 Auth Token);联调阶段先靠 forceTo + planId 解析收敛风险。
const twiml = await this.telephony.buildDialTwiml({ planId: body.planId });
res.type('text/xml').send(twiml);
}
}
import { Module } from '@nestjs/common';
import { TelephonyController } from './telephony.controller';
import { TelephonyService } from './telephony.service';
/**
* TelephonyModule — Twilio 网页点击拨号(Voice JS SDK)。
* PrismaModule 全局可用,无需在此 import。
*/
@Module({
controllers: [TelephonyController],
providers: [TelephonyService],
exports: [TelephonyService],
})
export class TelephonyModule {}
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import twilio from 'twilio';
import type { AppConfig } from '../../config/configuration';
import { PrismaService } from '../../prisma/prisma.service';
/**
* TelephonyService — Twilio 网页点击拨号(Voice JS SDK / WebRTC click-to-call)。
*
* 链路:
* 浏览器 @twilio/voice-sdk Device(用 AccessToken 注册)
* → device.connect({ params:{ planId } })
* → Twilio 回调 POST /pac/v1/twilio/voice(本服务返回 <Dial> TwiML)
* → Twilio 桥接到患者手机(PSTN)
*
* 安全:API Key Secret 只服务端持有(签 AccessToken),绝不下发浏览器。
* 患者真实号码不进浏览器 —— 前端只传 planId,这里查库解析真号再 Dial。
*
* ⚠️ Trial 账号只能拨"已验证"号码:设 TWILIO_FORCE_TO=已验证号 → 所有呼叫都打它(联调用);
* 生产置空 → 走 planId 解析患者真实号(需 Upgrade 解除 trial 限制 + 开 China geo permissions)。
*/
@Injectable()
export class TelephonyService {
private readonly logger = new Logger(TelephonyService.name);
constructor(
private readonly config: ConfigService<AppConfig, true>,
private readonly prisma: PrismaService,
) {}
private cfg() {
return this.config.get('twilio', { infer: true });
}
/** 网页拨号是否已配置(凭证齐全) */
isEnabled(): boolean {
const c = this.cfg();
return Boolean(c.accountSid && c.apiKeySid && c.apiKeySecret && c.twimlAppSid && c.callerId);
}
/**
* 签发 Voice AccessToken(短期凭证,可安全下发浏览器)。
* identity = 客服 user id(用于 Twilio 端标识这通客户端)。
*/
createVoiceToken(identity: string): { token: string; identity: string } {
const c = this.cfg();
const AccessToken = twilio.jwt.AccessToken;
const VoiceGrant = AccessToken.VoiceGrant;
const token = new AccessToken(c.accountSid, c.apiKeySid, c.apiKeySecret, {
identity,
ttl: 3600,
});
token.addGrant(
new VoiceGrant({
outgoingApplicationSid: c.twimlAppSid,
incomingAllow: false,
}),
);
return { token: token.toJwt(), identity };
}
/**
* 生成出站呼叫的 TwiML(Twilio 回调本端点时返回)。
* params.planId → 查库解析患者真实手机号;TWILIO_FORCE_TO 设了则强制拨它(trial 联调)。
*/
async buildDialTwiml(params: { planId?: string }): Promise<string> {
const c = this.cfg();
const VoiceResponse = twilio.twiml.VoiceResponse;
const resp = new VoiceResponse();
const to = c.forceTo || (params.planId ? await this.resolvePhone(params.planId) : '');
if (!to) {
resp.say({ language: 'zh-CN' }, '未找到可拨打的号码');
return resp.toString();
}
const dial = resp.dial({ callerId: c.callerId, answerOnBridge: true });
dial.number(to);
this.logger.log(`dial → ${maskPhone(to)} (callerId=${c.callerId}, planId=${params.planId ?? '-'})`);
return resp.toString();
}
/** planId → 患者手机号(真号,只服务端用) */
private async resolvePhone(planId: string): Promise<string> {
const plan = await this.prisma.followupPlan.findUnique({
where: { id: planId },
select: { patient: { select: { phone: true } } },
});
return plan?.patient?.phone ?? '';
}
}
function maskPhone(p: string): string {
if (p.length < 7) return '***';
return `${p.slice(0, 3)}****${p.slice(-4)}`;
}
......@@ -21,6 +21,7 @@
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@twilio/voice-sdk": "^2.18.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.4.0",
......
'use client';
import { Phone, PhoneOff, Mic, MicOff } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useTwilioCall } from './use-twilio-call';
const fmt = (s: number) => `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`;
/**
* CallWidget — 详情页网页点击拨号(Twilio Voice)。
* 空闲显示"拨打";呼叫/通话中显示状态 + 计时 + 静音/挂断。
*/
export function CallWidget({ planId }: { planId: string }) {
const { state, error, muted, seconds, dial, hangup, toggleMute, reset } = useTwilioCall();
if (state === 'idle') {
return (
<button
onClick={() => dial(planId)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-teal-600 text-white text-[12px] font-medium hover:bg-teal-700 transition-colors"
>
<Phone className="w-3.5 h-3.5" />
拨打
</button>
);
}
if (state === 'error' || state === 'ended') {
return (
<div className="inline-flex items-center gap-2 text-[11px]">
<span className={cn(state === 'error' ? 'text-rose-600' : 'text-slate-500')}>
{state === 'error' ? (error ?? '呼叫失败') : `已结束 ${fmt(seconds)}`}
</span>
<button
onClick={() => reset()}
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-md border border-teal-300 text-teal-700 text-[11px] hover:bg-teal-50"
>
<Phone className="w-3 h-3" />
重拨
</button>
</div>
);
}
// connecting / ringing / in_call
const label = state === 'in_call' ? fmt(seconds) : state === 'ringing' ? '响铃中…' : '呼叫中…';
return (
<div className="inline-flex items-center gap-1.5">
<span className="inline-flex items-center gap-1.5 px-2 py-1 rounded-md bg-emerald-50 text-emerald-700 text-[11px] font-medium tabular-nums">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
{label}
</span>
{state === 'in_call' && (
<button
onClick={toggleMute}
title={muted ? '取消静音' : '静音'}
className={cn(
'inline-flex items-center justify-center w-7 h-7 rounded-md border transition-colors',
muted ? 'border-amber-300 text-amber-600 bg-amber-50' : 'border-slate-200 text-slate-500 hover:bg-slate-50',
)}
>
{muted ? <MicOff className="w-3.5 h-3.5" /> : <Mic className="w-3.5 h-3.5" />}
</button>
)}
<button
onClick={hangup}
title="挂断"
className="inline-flex items-center justify-center w-7 h-7 rounded-md bg-rose-600 text-white hover:bg-rose-700 transition-colors"
>
<PhoneOff className="w-3.5 h-3.5" />
</button>
</div>
);
}
......@@ -47,6 +47,7 @@ import type { AdaptedFact } from './adapt-data';
import { useScriptStream } from './use-script-stream';
import { useSummaryStream } from './use-summary-stream';
import { RealtimeCoach } from '@/components/realtime-coach';
import { CallWidget } from './call-widget';
import { submitExecution, adaptAbandonReasons } from './execution-api';
/// 话术生成模型(具体型号,直传后端 AiProviderService.resolve)
......@@ -400,11 +401,12 @@ export function PlanDetailApp({
rightPane={
<aside className="min-h-0 flex flex-col gap-2.5 overflow-hidden h-full">
<section className="bg-white rounded-lg border border-slate-200 shadow-sm flex flex-col min-h-0 flex-1 overflow-hidden">
<header className="flex-none px-4 py-2.5 border-b border-slate-100 flex items-center justify-between">
<header className="flex-none px-4 py-2.5 border-b border-slate-100 flex items-center justify-between gap-2">
<div>
<h2 className="text-[14px] font-semibold text-slate-900 leading-tight">通话结果</h2>
<p className="text-[10.5px] text-slate-500 mt-0.5">提交后本次任务自动归档</p>
</div>
<CallWidget planId={plan.id} />
</header>
<div className="flex-1 min-h-0 overflow-y-auto p-3">
<OutcomeForm
......
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import type { Call, Device } from '@twilio/voice-sdk';
import { api } from '@/lib/api-client';
export type CallState = 'idle' | 'connecting' | 'ringing' | 'in_call' | 'ended' | 'error';
/**
* Twilio 网页点击拨号 hook(@twilio/voice-sdk)。
*
* 流程:dial(planId) → 拿 AccessToken → 建 Device → device.connect({ params:{ planId } })
* → Twilio 回调后端 /twilio/voice 返回 <Dial> → 桥接患者手机。
*
* Device/SDK 动态 import(只在点击时加载,避免 SSR + 首屏体积)。
*/
export function useTwilioCall() {
const [state, setState] = useState<CallState>('idle');
const [error, setError] = useState<string | null>(null);
const [muted, setMuted] = useState(false);
const [seconds, setSeconds] = useState(0);
const deviceRef = useRef<Device | null>(null);
const callRef = useRef<Call | null>(null);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const stopTimer = () => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
};
const cleanup = useCallback(() => {
stopTimer();
callRef.current = null;
if (deviceRef.current) {
try {
deviceRef.current.destroy();
} catch {
/* noop */
}
deviceRef.current = null;
}
}, []);
useEffect(() => cleanup, [cleanup]);
const hangup = useCallback(() => {
try {
callRef.current?.disconnect();
deviceRef.current?.disconnectAll();
} catch {
/* noop */
}
stopTimer();
setState('ended');
}, []);
const toggleMute = useCallback(() => {
const c = callRef.current;
if (!c) return;
const next = !muted;
c.mute(next);
setMuted(next);
}, [muted]);
const dial = useCallback(async (planId: string) => {
setError(null);
setMuted(false);
setSeconds(0);
setState('connecting');
try {
const { token } = await api.post<{ token: string; identity: string }>(
'/pac/v1/twilio/token',
);
const { Device } = await import('@twilio/voice-sdk');
const device = new Device(token, {
codecPreferences: ['opus', 'pcmu'] as never,
logLevel: 'error' as never,
});
deviceRef.current = device;
const call = await device.connect({ params: { planId } });
callRef.current = call;
call.on('ringing', () => setState('ringing'));
call.on('accept', () => {
setState('in_call');
stopTimer();
timerRef.current = setInterval(() => setSeconds((s) => s + 1), 1000);
});
call.on('disconnect', () => {
stopTimer();
setState('ended');
});
call.on('cancel', () => {
stopTimer();
setState('ended');
});
call.on('error', (e: { message?: string }) => {
stopTimer();
setError(e?.message ?? '通话错误');
setState('error');
});
} catch (e) {
setError(e instanceof Error ? e.message : '拨号失败');
setState('error');
cleanup();
}
}, [cleanup]);
const reset = useCallback(() => {
cleanup();
setState('idle');
setError(null);
setSeconds(0);
setMuted(false);
}, [cleanup]);
return { state, error, muted, seconds, dial, hangup, toggleMute, reset };
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment