feat: multi-agent SIP with Redis session lockout

- SessionService: Redis-backed session lock/unlock/refresh with 1hr TTL
- AgentConfigService: queries Agent entity, caches per-member config
- Auth login: resolves agent config, locks Redis session, returns SIP credentials
- Auth logout: unlocks Redis session, Ozonetel logout, clears cache
- Auth heartbeat: refreshes Redis TTL every 5 minutes
- Added ioredis dependency

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-23 21:24:32 +05:30
parent 4b5edc4e55
commit e4a24feedb
7 changed files with 307 additions and 9 deletions

View File

@@ -1,7 +1,9 @@
import { Controller, Post, Body, Logger, HttpException } from '@nestjs/common';
import { Controller, Post, Body, Headers, Logger, HttpException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios from 'axios';
import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
import { SessionService } from './session.service';
import { AgentConfigService } from './agent-config.service';
@Controller('auth')
export class AuthController {
@@ -13,6 +15,8 @@ export class AuthController {
constructor(
private config: ConfigService,
private ozonetelAgent: OzonetelAgentService,
private sessionService: SessionService,
private agentConfigService: AgentConfigService,
) {
this.graphqlUrl = config.get<string>('platform.graphqlUrl')!;
this.workspaceSubdomain = process.env.PLATFORM_WORKSPACE_SUBDOMAIN ?? 'fortytwo-dev';
@@ -111,20 +115,48 @@ export class AuthController {
this.logger.log(`User ${body.email} logged in with role: ${appRole} (platform roles: ${roleLabels.join(', ')})`);
// Auto-login Ozonetel agent for CC agents (fire and forget)
if (appRole === 'cc-agent') {
const ozAgentId = process.env.OZONETEL_AGENT_ID ?? 'agent3';
const ozAgentPassword = process.env.OZONETEL_AGENT_PASSWORD ?? 'Test123$';
const ozSipId = process.env.OZONETEL_SIP_ID ?? '521814';
// Multi-agent: resolve agent config + session lock for CC agents
let agentConfigResponse: any = undefined;
if (appRole === 'cc-agent') {
const memberId = workspaceMember?.id;
if (!memberId) throw new HttpException('Workspace member not found', 400);
const agentConfig = await this.agentConfigService.getByMemberId(memberId);
if (!agentConfig) {
throw new HttpException('Agent account not configured. Contact administrator.', 403);
}
// Check for duplicate login
const existingSession = await this.sessionService.isSessionLocked(agentConfig.ozonetelAgentId);
if (existingSession && existingSession !== memberId) {
throw new HttpException('You are already logged in on another device. Please log out there first.', 409);
}
// Lock session in Redis
await this.sessionService.lockSession(agentConfig.ozonetelAgentId, memberId);
// Login to Ozonetel with agent-specific credentials
const ozAgentPassword = process.env.OZONETEL_AGENT_PASSWORD ?? 'Test123$';
this.ozonetelAgent.loginAgent({
agentId: ozAgentId,
agentId: agentConfig.ozonetelAgentId,
password: ozAgentPassword,
phoneNumber: ozSipId,
phoneNumber: agentConfig.sipExtension,
mode: 'blended',
}).catch(err => {
this.logger.warn(`Ozonetel agent login failed (non-blocking): ${err.message}`);
});
agentConfigResponse = {
ozonetelAgentId: agentConfig.ozonetelAgentId,
sipExtension: agentConfig.sipExtension,
sipPassword: agentConfig.sipPassword,
sipUri: agentConfig.sipUri,
sipWsServer: agentConfig.sipWsServer,
campaignName: agentConfig.campaignName,
};
this.logger.log(`CC agent ${body.email} → Ozonetel ${agentConfig.ozonetelAgentId} / SIP ${agentConfig.sipExtension}`);
}
return {
@@ -139,6 +171,7 @@ export class AuthController {
role: appRole,
platformRoles: roleLabels,
},
...(agentConfigResponse ? { agentConfig: agentConfigResponse } : {}),
};
} catch (error) {
if (error instanceof HttpException) throw error;
@@ -186,4 +219,58 @@ export class AuthController {
throw new HttpException('Token refresh failed', 401);
}
}
@Post('logout')
async logout(@Headers('authorization') auth: string) {
if (!auth) return { status: 'ok' };
try {
const profileRes = await axios.post(this.graphqlUrl, {
query: '{ currentUser { workspaceMember { id } } }',
}, { headers: { 'Content-Type': 'application/json', Authorization: auth } });
const memberId = profileRes.data?.data?.currentUser?.workspaceMember?.id;
if (!memberId) return { status: 'ok' };
const agentConfig = this.agentConfigService.getFromCache(memberId);
if (agentConfig) {
await this.sessionService.unlockSession(agentConfig.ozonetelAgentId);
this.logger.log(`Session unlocked for ${agentConfig.ozonetelAgentId}`);
this.ozonetelAgent.logoutAgent({
agentId: agentConfig.ozonetelAgentId,
password: process.env.OZONETEL_AGENT_PASSWORD ?? 'Test123$',
}).catch(err => this.logger.warn(`Ozonetel logout failed: ${err.message}`));
this.agentConfigService.clearCache(memberId);
}
return { status: 'ok' };
} catch (err) {
this.logger.warn(`Logout cleanup failed: ${err}`);
return { status: 'ok' };
}
}
@Post('heartbeat')
async heartbeat(@Headers('authorization') auth: string) {
if (!auth) return { status: 'ok' };
try {
const profileRes = await axios.post(this.graphqlUrl, {
query: '{ currentUser { workspaceMember { id } } }',
}, { headers: { 'Content-Type': 'application/json', Authorization: auth } });
const memberId = profileRes.data?.data?.currentUser?.workspaceMember?.id;
const agentConfig = memberId ? this.agentConfigService.getFromCache(memberId) : null;
if (agentConfig) {
await this.sessionService.refreshSession(agentConfig.ozonetelAgentId);
}
return { status: 'ok' };
} catch {
return { status: 'ok' };
}
}
}