mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-04-11 10:07:22 +00:00
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:
@@ -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 {}
|
||||
|
||||
114
src/telephony-registration.service.ts
Normal file
114
src/telephony-registration.service.ts
Normal 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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user