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

@@ -0,0 +1,70 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
export type AgentConfig = {
id: string;
ozonetelAgentId: string;
sipExtension: string;
sipPassword: string;
campaignName: string;
sipUri: string;
sipWsServer: string;
};
@Injectable()
export class AgentConfigService {
private readonly logger = new Logger(AgentConfigService.name);
private readonly cache = new Map<string, AgentConfig>();
private readonly sipDomain: string;
private readonly sipWsPort: string;
constructor(
private platform: PlatformGraphqlService,
private config: ConfigService,
) {
this.sipDomain = config.get<string>('sip.domain', 'blr-pub-rtc4.ozonetel.com');
this.sipWsPort = config.get<string>('sip.wsPort', '444');
}
async getByMemberId(memberId: string): Promise<AgentConfig | null> {
const cached = this.cache.get(memberId);
if (cached) return cached;
try {
const data = await this.platform.query<any>(
`{ agents(first: 1, filter: { wsmemberId: { eq: "${memberId}" } }) { edges { node {
id ozonetelagentid sipextension sippassword campaignname
} } } }`,
);
const node = data?.agents?.edges?.[0]?.node;
if (!node || !node.ozonetelagentid || !node.sipextension) return null;
const agentConfig: AgentConfig = {
id: node.id,
ozonetelAgentId: node.ozonetelagentid,
sipExtension: node.sipextension,
sipPassword: node.sippassword ?? node.sipextension,
campaignName: node.campaignname ?? process.env.OZONETEL_CAMPAIGN_NAME ?? 'Inbound_918041763265',
sipUri: `sip:${node.sipextension}@${this.sipDomain}`,
sipWsServer: `wss://${this.sipDomain}:${this.sipWsPort}`,
};
this.cache.set(memberId, agentConfig);
this.logger.log(`Loaded agent config for member ${memberId}: ${agentConfig.ozonetelAgentId} / ${agentConfig.sipExtension}`);
return agentConfig;
} catch (err) {
this.logger.warn(`Failed to fetch agent config: ${err}`);
return null;
}
}
getFromCache(memberId: string): AgentConfig | null {
return this.cache.get(memberId) ?? null;
}
clearCache(memberId: string): void {
this.cache.delete(memberId);
}
}

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

View File

@@ -1,9 +1,14 @@
import { Module } from '@nestjs/common';
import { AuthController } from './auth.controller';
import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module';
import { PlatformModule } from '../platform/platform.module';
import { SessionService } from './session.service';
import { AgentConfigService } from './agent-config.service';
@Module({
imports: [OzonetelAgentModule],
imports: [OzonetelAgentModule, PlatformModule],
controllers: [AuthController],
providers: [SessionService, AgentConfigService],
exports: [SessionService, AgentConfigService],
})
export class AuthModule {}

View File

@@ -0,0 +1,40 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import Redis from 'ioredis';
const SESSION_TTL = 3600; // 1 hour
@Injectable()
export class SessionService implements OnModuleInit {
private readonly logger = new Logger(SessionService.name);
private redis: Redis;
constructor(private config: ConfigService) {}
onModuleInit() {
const url = this.config.get<string>('redis.url', 'redis://localhost:6379');
this.redis = new Redis(url);
this.redis.on('connect', () => this.logger.log('Redis connected'));
this.redis.on('error', (err) => this.logger.error(`Redis error: ${err.message}`));
}
private key(agentId: string): string {
return `agent:session:${agentId}`;
}
async lockSession(agentId: string, memberId: string): Promise<void> {
await this.redis.set(this.key(agentId), memberId, 'EX', SESSION_TTL);
}
async isSessionLocked(agentId: string): Promise<string | null> {
return this.redis.get(this.key(agentId));
}
async refreshSession(agentId: string): Promise<void> {
await this.redis.expire(this.key(agentId), SESSION_TTL);
}
async unlockSession(agentId: string): Promise<void> {
await this.redis.del(this.key(agentId));
}
}

View File

@@ -12,6 +12,13 @@ export default () => ({
subdomain: process.env.EXOTEL_SUBDOMAIN ?? 'api.exotel.com',
webhookSecret: process.env.EXOTEL_WEBHOOK_SECRET ?? '',
},
redis: {
url: process.env.REDIS_URL ?? 'redis://localhost:6379',
},
sip: {
domain: process.env.SIP_DOMAIN ?? 'blr-pub-rtc4.ozonetel.com',
wsPort: process.env.SIP_WS_PORT ?? '444',
},
missedQueue: {
pollIntervalMs: parseInt(process.env.MISSED_QUEUE_POLL_INTERVAL_MS ?? '30000', 10),
},