diff --git a/packages/services/agent-service/src/interfaces/rest/controllers/voice-session.controller.ts b/packages/services/agent-service/src/interfaces/rest/controllers/voice-session.controller.ts index b28da05..6a62543 100644 --- a/packages/services/agent-service/src/interfaces/rest/controllers/voice-session.controller.ts +++ b/packages/services/agent-service/src/interfaces/rest/controllers/voice-session.controller.ts @@ -27,10 +27,23 @@ import { AgentEngineType } from '../../../domain/value-objects/agent-engine-type import { AgentSession } from '../../../domain/entities/agent-session.entity'; import * as crypto from 'crypto'; +/** OAuth flow timeout — give the user this long to complete authorization before + * allowing the voice session to be terminated (voice-agent disconnect during OAuth). */ +const OAUTH_GRACE_MS = 10 * 60 * 1000; // 10 minutes + @Controller('api/v1/agent/sessions') export class VoiceSessionController { private readonly logger = new Logger(VoiceSessionController.name); + /** + * Tracks sessions where a DingTalk OAuth flow was triggered. + * While an entry is present (and not expired), the voice session will NOT + * be terminated if the voice-agent calls DELETE /voice (which happens when + * the LiveKit participant disconnects — a side-effect of the user leaving + * the app to complete OAuth in DingTalk/browser). + */ + private readonly oauthPendingSessions = new Map(); // sessionId → expiresAt + constructor( private readonly voiceSessionManager: VoiceSessionManager, private readonly sessionRepository: SessionRepository, @@ -160,6 +173,20 @@ export class VoiceSessionController { @TenantId() tenantId: string, @Param('sessionId') sessionId: string, ) { + // If a DingTalk OAuth flow was triggered for this session, do NOT terminate yet. + // The user left the app to authorize in DingTalk/browser, causing the LiveKit + // participant to disconnect, which triggers this call from the voice-agent. + // We keep the session alive so the user can resume after OAuth completes. + const oauthExpiry = this.oauthPendingSessions.get(sessionId); + if (oauthExpiry && Date.now() < oauthExpiry) { + this.logger.log( + `Voice session ${sessionId} terminate suppressed — DingTalk OAuth in progress ` + + `(grace expires in ${Math.round((oauthExpiry - Date.now()) / 1000)}s)`, + ); + return { sessionId, terminated: false, reason: 'oauth_pending' }; + } + this.oauthPendingSessions.delete(sessionId); + const session = await this.sessionRepository.findById(sessionId); if (session && session.tenantId === tenantId) { @@ -211,6 +238,12 @@ export class VoiceSessionController { const instanceName = body.instanceName || inst.name || body.instanceId; const { oauthUrl } = this.dingTalkRouter.generateOAuthUrl(body.instanceId); + // Mark this session as having an in-flight OAuth flow. + // terminateVoiceSession will suppress the terminate call while this is set, + // so the LiveKit disconnect caused by the user leaving to authorize in + // DingTalk/browser does not destroy the voice session prematurely. + this.oauthPendingSessions.set(sessionId, Date.now() + OAUTH_GRACE_MS); + // Emit the oauth_prompt event to the session's WebSocket stream. // voice-agent subscribes to this stream and forwards it to Flutter via // LiveKit publish_data (topic="oauth_prompt"), which triggers the bottom sheet.