From e4a24feedb6e36949b31c94f6053fa30ad0ee6f4 Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Mon, 23 Mar 2026 21:24:32 +0530 Subject: [PATCH] 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) --- package-lock.json | 88 ++++++++++++++++++++++++++ package.json | 1 + src/auth/agent-config.service.ts | 70 +++++++++++++++++++++ src/auth/auth.controller.ts | 103 ++++++++++++++++++++++++++++--- src/auth/auth.module.ts | 7 ++- src/auth/session.service.ts | 40 ++++++++++++ src/config/configuration.ts | 7 +++ 7 files changed, 307 insertions(+), 9 deletions(-) create mode 100644 src/auth/agent-config.service.ts create mode 100644 src/auth/session.service.ts diff --git a/package-lock.json b/package-lock.json index 4e2dfd5..05c3d87 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@nestjs/websockets": "^11.1.17", "ai": "^6.0.116", "axios": "^1.13.6", + "ioredis": "^5.10.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "socket.io": "^4.8.3" @@ -1881,6 +1882,12 @@ } } }, + "node_modules/@ioredis/commands": { + "version": "1.5.1", + "resolved": "http://localhost:4873/@ioredis/commands/-/commands-1.5.1.tgz", + "integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==", + "license": "MIT" + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "http://localhost:4873/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -5153,6 +5160,15 @@ "node": ">=0.8" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "http://localhost:4873/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "http://localhost:4873/co/-/co-4.6.0.tgz", @@ -5467,6 +5483,15 @@ "node": ">=0.4.0" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "http://localhost:4873/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "http://localhost:4873/depd/-/depd-2.0.0.tgz", @@ -6978,6 +7003,30 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ioredis": { + "version": "5.10.1", + "resolved": "http://localhost:4873/ioredis/-/ioredis-5.10.1.tgz", + "integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.5.1", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "http://localhost:4873/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -8171,6 +8220,18 @@ "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "license": "MIT" }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "http://localhost:4873/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "http://localhost:4873/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "http://localhost:4873/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -9166,6 +9227,27 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "http://localhost:4873/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "http://localhost:4873/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "http://localhost:4873/reflect-metadata/-/reflect-metadata-0.2.2.tgz", @@ -9653,6 +9735,12 @@ "node": ">=8" } }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "http://localhost:4873/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "http://localhost:4873/statuses/-/statuses-2.0.2.tgz", diff --git a/package.json b/package.json index a61c058..8b6f103 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@nestjs/websockets": "^11.1.17", "ai": "^6.0.116", "axios": "^1.13.6", + "ioredis": "^5.10.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "socket.io": "^4.8.3" diff --git a/src/auth/agent-config.service.ts b/src/auth/agent-config.service.ts new file mode 100644 index 0000000..47f8423 --- /dev/null +++ b/src/auth/agent-config.service.ts @@ -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(); + private readonly sipDomain: string; + private readonly sipWsPort: string; + + constructor( + private platform: PlatformGraphqlService, + private config: ConfigService, + ) { + this.sipDomain = config.get('sip.domain', 'blr-pub-rtc4.ozonetel.com'); + this.sipWsPort = config.get('sip.wsPort', '444'); + } + + async getByMemberId(memberId: string): Promise { + const cached = this.cache.get(memberId); + if (cached) return cached; + + try { + const data = await this.platform.query( + `{ 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); + } +} diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index bce2340..eff3f53 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -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('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' }; + } + } } diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 76842f5..3ff25ea 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -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 {} diff --git a/src/auth/session.service.ts b/src/auth/session.service.ts new file mode 100644 index 0000000..f1aa198 --- /dev/null +++ b/src/auth/session.service.ts @@ -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('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 { + await this.redis.set(this.key(agentId), memberId, 'EX', SESSION_TTL); + } + + async isSessionLocked(agentId: string): Promise { + return this.redis.get(this.key(agentId)); + } + + async refreshSession(agentId: string): Promise { + await this.redis.expire(this.key(agentId), SESSION_TTL); + } + + async unlockSession(agentId: string): Promise { + await this.redis.del(this.key(agentId)); + } +} diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 638f5fa..48ad069 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -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), },