diff --git a/src/supervisor/supervisor-barge.controller.ts b/src/supervisor/supervisor-barge.controller.ts new file mode 100644 index 0000000..8e3caa7 --- /dev/null +++ b/src/supervisor/supervisor-barge.controller.ts @@ -0,0 +1,163 @@ +import { Controller, Post, Get, Body, HttpException, Logger } from '@nestjs/common'; +import axios from 'axios'; +import { OzonetelAdminAuthService } from '../ozonetel/ozonetel-admin-auth.service'; +import { SupervisorService } from './supervisor.service'; +import { TelephonyConfigService } from '../config/telephony-config.service'; + +// Supervisor barge/whisper/listen endpoints. +// Proxies requests to Ozonetel's dashboardApi using admin JWT auth. +// +// API reference (from CA-Admin source code): +// apiId 63 → CALL_BARGEIN (initiate barge) +// apiId 158 → Redis barge state (insert/delete) +// apiId 139 → SIP credential pool (sipSubscribe) + +@Controller('api/supervisor/barge') +export class SupervisorBargeController { + private readonly logger = new Logger(SupervisorBargeController.name); + private readonly dashboardApiUrl = 'https://api.cloudagent.ozonetel.com/dashboardApi/monitor/api'; + private readonly adminApiUrl = 'https://api.cloudagent.ozonetel.com/ca-admin-Api/CloudAgentAPI'; + + constructor( + private readonly adminAuth: OzonetelAdminAuthService, + private readonly supervisor: SupervisorService, + private readonly telephony: TelephonyConfigService, + ) {} + + @Get('sip-credentials') + async getSipCredentials() { + if (!this.adminAuth.isConfigured()) { + throw new HttpException('Ozonetel admin not configured — add credentials in Settings → Telephony', 503); + } + + const config = this.telephony.getConfig(); + const sipGateway = `${config.sip.domain}:${config.sip.wsPort}`; + const headers = await this.adminAuth.getAuthHeaders(); + + try { + const res = await axios.post(`${this.adminApiUrl}/endpoint/sipnumber/sipSubscribe`, { + apiId: 139, + sipURL: sipGateway, + }, { headers }); + + const data = res.data; + this.logger.log(`[BARGE] SIP credentials response: ${JSON.stringify(data)}`); + + if (!data?.sip_number) { + throw new HttpException('No SIP numbers available in pool', 503); + } + + return { + sipNumber: data.sip_number, + sipPassword: data.password, + sipDomain: data.pop_location ?? config.sip.domain, + sipPort: config.sip.wsPort, + }; + } catch (err: any) { + this.logger.error(`[BARGE] SIP credentials failed: ${err.message}`); + if (err instanceof HttpException) throw err; + throw new HttpException('Failed to fetch SIP credentials', 502); + } + } + + @Post() + async initiateBarge(@Body() body: { ucid: string; agentId: string; agentNumber: string; supervisorId?: string }) { + if (!body.ucid || !body.agentNumber) { + throw new HttpException('ucid and agentNumber required', 400); + } + if (!this.adminAuth.isConfigured()) { + throw new HttpException('Ozonetel admin not configured — add credentials in Settings → Telephony', 503); + } + + // Prevent double-barge on same agent + const existing = this.supervisor.getBargeSession(body.agentId); + if (existing) { + throw new HttpException(`Agent ${body.agentId} is already being monitored`, 409); + } + + // Get SIP credentials from Ozonetel pool + const sipCreds = await this.getSipCredentials(); + const headers = await this.adminAuth.getAuthHeaders(); + + try { + const res = await axios.post(this.dashboardApiUrl, { + apiId: 63, + ucid: body.ucid, + action: 'CALL_BARGEIN', + isSip: true, + phoneno: sipCreds.sipNumber, + agentNumber: body.agentNumber, + cbURL: 'helix-engage', + }, { headers }); + + this.logger.log(`[BARGE] Initiated: ucid=${body.ucid} agent=${body.agentId} sip=${sipCreds.sipNumber} response=${JSON.stringify(res.data)}`); + + // Track the session + this.supervisor.startBargeSession({ + supervisorId: body.supervisorId ?? 'admin', + agentId: body.agentId, + sipNumber: sipCreds.sipNumber, + mode: 'listen', + startedAt: new Date().toISOString(), + }); + + return { + status: 'ok', + ...sipCreds, + ozonetelResponse: res.data, + }; + } catch (err: any) { + this.logger.error(`[BARGE] Initiation failed: ${err.message} ${err.response?.data ? JSON.stringify(err.response.data) : ''}`); + throw new HttpException(`Barge failed: ${err.response?.data?.Message ?? err.message}`, 502); + } + } + + @Post('mode') + async updateMode(@Body() body: { agentId: string; mode: 'listen' | 'whisper' | 'barge' }) { + if (!body.agentId || !body.mode) { + throw new HttpException('agentId and mode required', 400); + } + if (!['listen', 'whisper', 'barge'].includes(body.mode)) { + throw new HttpException('mode must be listen, whisper, or barge', 400); + } + + const session = this.supervisor.getBargeSession(body.agentId); + if (!session) { + throw new HttpException('No active barge session for this agent', 404); + } + + this.supervisor.updateBargeMode(body.agentId, body.mode); + return { status: 'ok', mode: body.mode }; + } + + @Post('end') + async endBarge(@Body() body: { agentId: string }) { + if (!body.agentId) { + throw new HttpException('agentId required', 400); + } + + const session = this.supervisor.getBargeSession(body.agentId); + if (!session) { + return { status: 'ok', message: 'No active session' }; + } + + // Clear Redis tracking on Ozonetel side (best-effort) + if (this.adminAuth.isConfigured()) { + try { + const headers = await this.adminAuth.getAuthHeaders(); + await axios.post(this.dashboardApiUrl, { + apiId: 158, + Action: 'delete', + AgentId: body.agentId, + Sip: session.sipNumber, + }, { headers }); + this.logger.log(`[BARGE] Redis cleanup: ${body.agentId}`); + } catch (err: any) { + this.logger.warn(`[BARGE] Redis cleanup failed (non-critical): ${err.message}`); + } + } + + this.supervisor.endBargeSession(body.agentId); + return { status: 'ok' }; + } +} diff --git a/src/supervisor/supervisor.module.ts b/src/supervisor/supervisor.module.ts index 3e37234..1dc53ee 100644 --- a/src/supervisor/supervisor.module.ts +++ b/src/supervisor/supervisor.module.ts @@ -3,12 +3,13 @@ import { PlatformModule } from '../platform/platform.module'; import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module'; import { ConfigThemeModule } from '../config/config-theme.module'; import { SupervisorController } from './supervisor.controller'; +import { SupervisorBargeController } from './supervisor-barge.controller'; import { SupervisorService } from './supervisor.service'; import { OzonetelAdminAuthService } from '../ozonetel/ozonetel-admin-auth.service'; @Module({ imports: [PlatformModule, forwardRef(() => OzonetelAgentModule), ConfigThemeModule], - controllers: [SupervisorController], + controllers: [SupervisorController, SupervisorBargeController], providers: [SupervisorService, OzonetelAdminAuthService], exports: [SupervisorService, OzonetelAdminAuthService], }) diff --git a/src/supervisor/supervisor.service.ts b/src/supervisor/supervisor.service.ts index ccecc02..cefcc42 100644 --- a/src/supervisor/supervisor.service.ts +++ b/src/supervisor/supervisor.service.ts @@ -34,7 +34,16 @@ export class SupervisorService implements OnModuleInit { private readonly activeCalls = new Map(); private readonly agentStates = new Map(); private readonly acwTimers = new Map(); - readonly agentStateSubject = new Subject<{ agentId: string; state: AgentOzonetelState; timestamp: string }>(); + readonly agentStateSubject = new Subject<{ agentId: string; state: AgentOzonetelState | string; timestamp: string }>(); + + // Barge session tracking — key is agentId + private readonly bargeSessions = new Map(); constructor( private platform: PlatformGraphqlService, @@ -234,4 +243,42 @@ export class SupervisorService implements OnModuleInit { return { date, agents: summaries, teamTotals }; } + + // --- Barge session management --- + + getBargeSession(agentId: string) { + return this.bargeSessions.get(agentId) ?? null; + } + + startBargeSession(session: { supervisorId: string; agentId: string; sipNumber: string; mode: 'listen' | 'whisper' | 'barge'; startedAt: string }) { + this.bargeSessions.set(session.agentId, session); + this.logger.log(`[BARGE] Started: ${session.supervisorId} → ${session.agentId} (${session.mode})`); + } + + updateBargeMode(agentId: string, mode: 'listen' | 'whisper' | 'barge') { + const session = this.bargeSessions.get(agentId); + if (!session) return; + + const previousMode = session.mode; + session.mode = mode; + + // Emit SSE to agent — whisper/barge show indicator, listen is silent + if (mode === 'whisper' || mode === 'barge') { + this.agentStateSubject.next({ agentId, state: `supervisor-${mode}`, timestamp: new Date().toISOString() }); + } else if (previousMode !== 'listen') { + // Switching back to listen from whisper/barge + this.agentStateSubject.next({ agentId, state: 'supervisor-left', timestamp: new Date().toISOString() }); + } + + this.logger.log(`[BARGE] Mode: ${agentId} → ${mode}`); + } + + endBargeSession(agentId: string) { + const session = this.bargeSessions.get(agentId); + if (!session) return; + + this.bargeSessions.delete(agentId); + this.agentStateSubject.next({ agentId, state: 'supervisor-left', timestamp: new Date().toISOString() }); + this.logger.log(`[BARGE] Ended: ${session.supervisorId} → ${agentId}`); + } }