mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
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:
163
src/supervisor/supervisor-barge.controller.ts
Normal file
163
src/supervisor/supervisor-barge.controller.ts
Normal 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' };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,12 +3,13 @@ import { PlatformModule } from '../platform/platform.module';
|
|||||||
import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module';
|
import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module';
|
||||||
import { ConfigThemeModule } from '../config/config-theme.module';
|
import { ConfigThemeModule } from '../config/config-theme.module';
|
||||||
import { SupervisorController } from './supervisor.controller';
|
import { SupervisorController } from './supervisor.controller';
|
||||||
|
import { SupervisorBargeController } from './supervisor-barge.controller';
|
||||||
import { SupervisorService } from './supervisor.service';
|
import { SupervisorService } from './supervisor.service';
|
||||||
import { OzonetelAdminAuthService } from '../ozonetel/ozonetel-admin-auth.service';
|
import { OzonetelAdminAuthService } from '../ozonetel/ozonetel-admin-auth.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PlatformModule, forwardRef(() => OzonetelAgentModule), ConfigThemeModule],
|
imports: [PlatformModule, forwardRef(() => OzonetelAgentModule), ConfigThemeModule],
|
||||||
controllers: [SupervisorController],
|
controllers: [SupervisorController, SupervisorBargeController],
|
||||||
providers: [SupervisorService, OzonetelAdminAuthService],
|
providers: [SupervisorService, OzonetelAdminAuthService],
|
||||||
exports: [SupervisorService, OzonetelAdminAuthService],
|
exports: [SupervisorService, OzonetelAdminAuthService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -34,7 +34,16 @@ export class SupervisorService implements OnModuleInit {
|
|||||||
private readonly activeCalls = new Map<string, ActiveCall>();
|
private readonly activeCalls = new Map<string, ActiveCall>();
|
||||||
private readonly agentStates = new Map<string, AgentStateEntry>();
|
private readonly agentStates = new Map<string, AgentStateEntry>();
|
||||||
private readonly acwTimers = new Map<string, NodeJS.Timeout>();
|
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(
|
constructor(
|
||||||
private platform: PlatformGraphqlService,
|
private platform: PlatformGraphqlService,
|
||||||
@@ -234,4 +243,42 @@ export class SupervisorService implements OnModuleInit {
|
|||||||
|
|
||||||
return { date, agents: summaries, teamTotals };
|
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}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user