diff --git a/src/app.module.ts b/src/app.module.ts index bc0c34b..41f8cb9 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -21,6 +21,7 @@ import { RulesEngineModule } from './rules-engine/rules-engine.module'; import { ConfigThemeModule } from './config/config-theme.module'; import { WidgetModule } from './widget/widget.module'; import { TeamModule } from './team/team.module'; +import { TelephonyRegistrationService } from './telephony-registration.service'; @Module({ imports: [ @@ -49,5 +50,6 @@ import { TeamModule } from './team/team.module'; WidgetModule, TeamModule, ], + providers: [TelephonyRegistrationService], }) export class AppModule {} diff --git a/src/telephony-registration.service.ts b/src/telephony-registration.service.ts new file mode 100644 index 0000000..9f700c7 --- /dev/null +++ b/src/telephony-registration.service.ts @@ -0,0 +1,114 @@ +import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import axios from 'axios'; +import { PlatformGraphqlService } from './platform/platform-graphql.service'; + +// On startup, registers this sidecar with the telephony dispatcher +// so Ozonetel events are routed to the correct sidecar by agentId. +// +// Flow: +// 1. Load agent list from platform (Agent entities in this workspace) +// 2. POST /api/supervisor/register to the dispatcher +// 3. Start heartbeat interval (every 30s) +// 4. On shutdown, DELETE /api/supervisor/register + +const HEARTBEAT_INTERVAL_MS = 30_000; + +@Injectable() +export class TelephonyRegistrationService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(TelephonyRegistrationService.name); + private heartbeatTimer: NodeJS.Timeout | null = null; + + constructor( + private config: ConfigService, + private platform: PlatformGraphqlService, + ) {} + + private get dispatcherUrl(): string { + return this.config.get('TELEPHONY_DISPATCHER_URL') ?? ''; + } + + private get sidecarUrl(): string { + return this.config.get('TELEPHONY_CALLBACK_URL') ?? ''; + } + + private get workspace(): string { + return process.env.PLATFORM_WORKSPACE_SUBDOMAIN ?? 'unknown'; + } + + async onModuleInit() { + if (!this.dispatcherUrl || !this.sidecarUrl) { + this.logger.warn('TELEPHONY_DISPATCHER_URL or TELEPHONY_CALLBACK_URL not set — skipping telephony registration'); + return; + } + + await this.register(); + + this.heartbeatTimer = setInterval(async () => { + try { + await axios.post(`${this.dispatcherUrl}/api/supervisor/heartbeat`, { + sidecarUrl: this.sidecarUrl, + }, { timeout: 5000 }); + } catch (err: any) { + this.logger.warn(`Heartbeat failed: ${err.message} — attempting re-registration`); + await this.register(); + } + }, HEARTBEAT_INTERVAL_MS); + } + + async onModuleDestroy() { + if (this.heartbeatTimer) clearInterval(this.heartbeatTimer); + + if (this.dispatcherUrl && this.sidecarUrl) { + try { + await axios.delete(`${this.dispatcherUrl}/api/supervisor/register`, { + data: { sidecarUrl: this.sidecarUrl }, + timeout: 5000, + }); + this.logger.log('Deregistered from telephony dispatcher'); + } catch { + // Best-effort — TTL will clean up anyway + } + } + } + + private async register() { + try { + const agents = await this.loadAgentIds(); + if (agents.length === 0) { + this.logger.warn('No agents found in workspace — skipping registration'); + return; + } + + await axios.post(`${this.dispatcherUrl}/api/supervisor/register`, { + sidecarUrl: this.sidecarUrl, + workspace: this.workspace, + agents, + }, { timeout: 5000 }); + + this.logger.log(`Registered with telephony dispatcher: ${agents.length} agents (${agents.join(', ')})`); + } catch (err: any) { + this.logger.error(`Registration failed: ${err.message}`); + } + } + + private async loadAgentIds(): Promise { + try { + const apiKey = this.config.get('PLATFORM_API_KEY'); + if (!apiKey) return []; + + const data = await this.platform.queryWithAuth( + `{ agents(first: 50) { edges { node { ozonetelAgentId } } } }`, + undefined, + `Bearer ${apiKey}`, + ); + + return (data.agents?.edges ?? []) + .map((e: any) => e.node.ozonetelAgentId) + .filter((id: string) => id && id !== 'PENDING'); + } catch (err: any) { + this.logger.warn(`Failed to load agents from platform: ${err.message}`); + return []; + } + } +}