feat: telephony dispatcher registration — sidecar self-registers on boot

Adds TelephonyRegistrationService that:
1. On startup: queries platform for agent list, registers with the
   telephony dispatcher at TELEPHONY_DISPATCHER_URL
2. Every 30s: sends heartbeat to keep registration alive (90s TTL)
3. On shutdown: deregisters (best-effort, TTL cleans up anyway)
4. On heartbeat failure: auto re-registers

Env vars:
  TELEPHONY_DISPATCHER_URL — where to register (outbound to dispatcher)
  TELEPHONY_CALLBACK_URL — where events come back (inbound to sidecar)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-10 15:08:30 +05:30
parent 898ff65951
commit 9f5935e417
2 changed files with 116 additions and 0 deletions

View File

@@ -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 {}

View File

@@ -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<string>('TELEPHONY_DISPATCHER_URL') ?? '';
}
private get sidecarUrl(): string {
return this.config.get<string>('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<string[]> {
try {
const apiKey = this.config.get<string>('PLATFORM_API_KEY');
if (!apiKey) return [];
const data = await this.platform.queryWithAuth<any>(
`{ 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 [];
}
}
}