feat(sidecar): supervisor barge endpoints — initiate, mode switch, end

Endpoints:
- GET  /api/supervisor/barge/sip-credentials — fetch SIP number from pool
- POST /api/supervisor/barge — initiate barge via Ozonetel apiId 63
- POST /api/supervisor/barge/mode — update mode (listen/whisper/barge)
- POST /api/supervisor/barge/end — cleanup session + Redis

SupervisorService extended with barge session tracking (in-memory Map).
Mode changes emit SSE events to agent: supervisor-whisper, supervisor-barge,
supervisor-left. Listen mode is silent (no event to agent).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-12 16:06:57 +05:30
parent c23792496b
commit ea60787da0
3 changed files with 213 additions and 2 deletions

View File

@@ -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' };
}
}

View File

@@ -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],
})

View File

@@ -34,7 +34,16 @@ export class SupervisorService implements OnModuleInit {
private readonly activeCalls = new Map<string, ActiveCall>();
private readonly agentStates = new Map<string, AgentStateEntry>();
private readonly acwTimers = new Map<string, NodeJS.Timeout>();
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<string, {
supervisorId: string;
agentId: string;
sipNumber: string;
mode: 'listen' | 'whisper' | 'barge';
startedAt: string;
}>();
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}`);
}
}