Initial LisgloSIPS V2 implementation

This commit is contained in:
hectorzhao
2026-06-22 10:56:38 +08:00
commit 5fa1bd35e9
303 changed files with 35644 additions and 0 deletions
+408
View File
@@ -0,0 +1,408 @@
import { randomUUID } from 'node:crypto';
import { CDR_CONSUMER_GROUP, CDR_DEADLETTER_STREAM, CDR_STREAM } from './index.js';
export const CDR_EVENT_SCHEMA_VERSION = '1';
export const CDR_IDEMPOTENCY_KEY_PREFIX = 'lock:cdr:';
export type CdrStreamFieldMap = Record<string, string>;
export interface CdrRedisCommands {
xadd(stream: string, id: string, ...fieldValues: string[]): Promise<string>;
xack(stream: string, group: string, ...ids: string[]): Promise<number>;
xgroup(...args: string[]): Promise<string>;
xreadgroup(...args: Array<string | number>): Promise<RedisStreamReadResponse | null>;
xautoclaim(...args: Array<string | number>): Promise<RedisAutoClaimResponse>;
set(key: string, value: string, mode: 'NX', expireMode: 'EX', ttlSeconds: number): Promise<'OK' | null>;
del(...keys: string[]): Promise<number>;
}
export type RedisStreamEntry = [id: string, fields: string[]];
export type RedisStreamReadResponse = Array<[stream: string, entries: RedisStreamEntry[]]>;
export type RedisAutoClaimResponse = [nextStartId: string, entries: RedisStreamEntry[], deletedIds?: string[]];
export interface CdrStreamEvent {
schema_version: string;
event_id: string;
idempotency_key: string;
call_id: string;
node_id: string;
opensips_instance: string;
ingress_a_ip: string;
rtpengine_node: string;
customer_id: string;
customer_gateway_id: string;
customer_gateway_policy_id: string;
source_ip: string;
caller: string;
callee: string;
vendor_id: string;
vendor_gateway_id: string;
line_group_id: string;
started_at: string;
answered_at: string;
ended_at: string;
duration_sec: string;
sip_code: string;
hangup_reason: string;
recording_key: string;
config_version: string;
created_at: string;
}
export interface ParsedCdrStreamEvent extends CdrStreamEvent {
durationSeconds: number;
sipCode: number;
}
export interface CdrStreamPublishInput {
eventId?: string;
idempotencyKey?: string;
callId: string;
nodeId?: string;
opensipsInstance?: string;
ingressAIp?: string;
rtpengineNode?: string;
customerId?: string;
customerGatewayId?: string;
customerGatewayPolicyId?: string;
sourceIp: string;
caller?: string;
callee?: string;
vendorId?: string;
vendorGatewayId?: string;
lineGroupId?: string;
startedAt?: string;
answeredAt?: string;
endedAt?: string;
durationSec?: number;
sipCode: number;
hangupReason: string;
recordingKey?: string;
configVersion?: string;
createdAt?: string;
}
export type CdrHandlerResult = 'processed' | 'duplicate';
export type CdrStreamHandler = (event: ParsedCdrStreamEvent, redisId: string) => Promise<CdrHandlerResult>;
export class CdrRetryableError extends Error {
constructor(message: string) {
super(message);
this.name = 'CdrRetryableError';
}
}
export interface ProcessCdrBatchOptions {
stream?: string;
group?: string;
consumer: string;
count?: number;
blockMs?: number;
idempotencyTtlSeconds?: number;
}
export interface ProcessPendingOptions {
stream?: string;
group?: string;
consumer: string;
minIdleMs?: number;
startId?: string;
count?: number;
idempotencyTtlSeconds?: number;
}
export interface CdrProcessSummary {
processed: number;
duplicates: number;
deadlettered: number;
pendingLeft: number;
ackedIds: string[];
}
const requiredFields = [
'schema_version',
'event_id',
'idempotency_key',
'call_id',
'node_id',
'opensips_instance',
'ingress_a_ip',
'rtpengine_node',
'source_ip',
'sip_code',
'hangup_reason',
'created_at'
] as const;
export function buildCdrStreamEvent(input: CdrStreamPublishInput): CdrStreamEvent {
const eventId = input.eventId ?? randomUUID();
const endedAt = input.endedAt ?? '';
return {
schema_version: CDR_EVENT_SCHEMA_VERSION,
event_id: eventId,
idempotency_key: input.idempotencyKey ?? `${input.callId}:${endedAt || eventId}`,
call_id: input.callId,
node_id: input.nodeId ?? 'a1',
opensips_instance: input.opensipsInstance ?? 'opensips-a1',
ingress_a_ip: input.ingressAIp ?? '',
rtpengine_node: input.rtpengineNode ?? input.nodeId ?? 'a1',
customer_id: input.customerId ?? '',
customer_gateway_id: input.customerGatewayId ?? '',
customer_gateway_policy_id: input.customerGatewayPolicyId ?? '',
source_ip: input.sourceIp,
caller: input.caller ?? '',
callee: input.callee ?? '',
vendor_id: input.vendorId ?? '',
vendor_gateway_id: input.vendorGatewayId ?? '',
line_group_id: input.lineGroupId ?? '',
started_at: input.startedAt ?? '',
answered_at: input.answeredAt ?? '',
ended_at: endedAt,
duration_sec: String(input.durationSec ?? 0),
sip_code: String(input.sipCode),
hangup_reason: input.hangupReason,
recording_key: input.recordingKey ?? '',
config_version: input.configVersion ?? '',
created_at: input.createdAt ?? new Date().toISOString()
};
}
export function serializeCdrEvent(event: CdrStreamEvent): string[] {
return Object.entries(event).flatMap(([field, value]) => [field, value]);
}
export function parseStreamFields(fields: string[]): CdrStreamFieldMap {
const parsed: CdrStreamFieldMap = {};
for (let index = 0; index < fields.length; index += 2) {
const key = fields[index];
const value = fields[index + 1];
if (key !== undefined && value !== undefined) {
parsed[key] = value;
}
}
return parsed;
}
export function parseCdrStreamEvent(fields: string[]): ParsedCdrStreamEvent {
const parsed = parseStreamFields(fields);
for (const field of requiredFields) {
if (!parsed[field]) {
throw new Error(`CDR stream event missing required field: ${field}`);
}
}
if (parsed.schema_version !== CDR_EVENT_SCHEMA_VERSION) {
throw new Error(`Unsupported CDR schema version: ${parsed.schema_version}`);
}
const durationSeconds = Number.parseInt(parsed.duration_sec ?? '0', 10);
const sipCode = Number.parseInt(parsed.sip_code, 10);
if (!Number.isInteger(durationSeconds) || durationSeconds < 0) {
throw new Error(`Invalid CDR duration_sec: ${parsed.duration_sec}`);
}
if (!Number.isInteger(sipCode) || sipCode < 100 || sipCode > 699) {
throw new Error(`Invalid CDR sip_code: ${parsed.sip_code}`);
}
return {
schema_version: parsed.schema_version,
event_id: parsed.event_id,
idempotency_key: parsed.idempotency_key,
call_id: parsed.call_id,
node_id: parsed.node_id,
opensips_instance: parsed.opensips_instance,
ingress_a_ip: parsed.ingress_a_ip,
rtpengine_node: parsed.rtpengine_node,
customer_id: parsed.customer_id ?? '',
customer_gateway_id: parsed.customer_gateway_id ?? '',
customer_gateway_policy_id: parsed.customer_gateway_policy_id ?? '',
source_ip: parsed.source_ip,
caller: parsed.caller ?? '',
callee: parsed.callee ?? '',
vendor_id: parsed.vendor_id ?? '',
vendor_gateway_id: parsed.vendor_gateway_id ?? '',
line_group_id: parsed.line_group_id ?? '',
started_at: parsed.started_at ?? '',
answered_at: parsed.answered_at ?? '',
ended_at: parsed.ended_at ?? '',
duration_sec: parsed.duration_sec ?? '0',
sip_code: parsed.sip_code,
hangup_reason: parsed.hangup_reason,
recording_key: parsed.recording_key ?? '',
config_version: parsed.config_version ?? '',
created_at: parsed.created_at,
durationSeconds,
sipCode
};
}
export async function ensureCdrConsumerGroup(
redis: CdrRedisCommands,
stream = CDR_STREAM,
group = CDR_CONSUMER_GROUP
): Promise<void> {
try {
await redis.xgroup('CREATE', stream, group, '0', 'MKSTREAM');
} catch (error) {
if (!(error instanceof Error) || !error.message.includes('BUSYGROUP')) {
throw error;
}
}
}
export async function publishCdrEvent(
redis: CdrRedisCommands,
input: CdrStreamPublishInput,
stream = CDR_STREAM
): Promise<string> {
const event = buildCdrStreamEvent(input);
return redis.xadd(stream, '*', ...serializeCdrEvent(event));
}
export async function processCdrBatch(
redis: CdrRedisCommands,
handler: CdrStreamHandler,
options: ProcessCdrBatchOptions
): Promise<CdrProcessSummary> {
const stream = options.stream ?? CDR_STREAM;
const group = options.group ?? CDR_CONSUMER_GROUP;
const count = options.count ?? 10;
const blockMs = options.blockMs ?? 1000;
const response = await redis.xreadgroup(
'GROUP',
group,
options.consumer,
'COUNT',
count,
'BLOCK',
blockMs,
'STREAMS',
stream,
'>'
);
return processReadResponse(redis, handler, response, {
stream,
group,
idempotencyTtlSeconds: options.idempotencyTtlSeconds ?? 86400
});
}
export async function processPendingCdrBatch(
redis: CdrRedisCommands,
handler: CdrStreamHandler,
options: ProcessPendingOptions
): Promise<CdrProcessSummary> {
const stream = options.stream ?? CDR_STREAM;
const group = options.group ?? CDR_CONSUMER_GROUP;
const response = await redis.xautoclaim(
stream,
group,
options.consumer,
options.minIdleMs ?? 60000,
options.startId ?? '0-0',
'COUNT',
options.count ?? 10
);
return processEntries(redis, handler, response[1], {
stream,
group,
idempotencyTtlSeconds: options.idempotencyTtlSeconds ?? 86400
});
}
async function processReadResponse(
redis: CdrRedisCommands,
handler: CdrStreamHandler,
response: RedisStreamReadResponse | null,
options: { stream: string; group: string; idempotencyTtlSeconds: number }
): Promise<CdrProcessSummary> {
const entries = response?.flatMap(([, streamEntries]) => streamEntries) ?? [];
return processEntries(redis, handler, entries, options);
}
async function processEntries(
redis: CdrRedisCommands,
handler: CdrStreamHandler,
entries: RedisStreamEntry[],
options: { stream: string; group: string; idempotencyTtlSeconds: number }
): Promise<CdrProcessSummary> {
const summary: CdrProcessSummary = {
processed: 0,
duplicates: 0,
deadlettered: 0,
pendingLeft: 0,
ackedIds: []
};
for (const [redisId, fields] of entries) {
try {
const event = parseCdrStreamEvent(fields);
const lock = await redis.set(
`${CDR_IDEMPOTENCY_KEY_PREFIX}${event.event_id}`,
redisId,
'NX',
'EX',
options.idempotencyTtlSeconds
);
if (lock === null) {
await ack(redis, options.stream, options.group, redisId, summary);
summary.duplicates += 1;
continue;
}
const result = await handler(event, redisId);
await ack(redis, options.stream, options.group, redisId, summary);
if (result === 'duplicate') {
summary.duplicates += 1;
} else {
summary.processed += 1;
}
} catch (error) {
if (error instanceof CdrRetryableError) {
const fieldMap = parseStreamFields(fields);
if (fieldMap.event_id) {
await redis.del(`${CDR_IDEMPOTENCY_KEY_PREFIX}${fieldMap.event_id}`);
}
summary.pendingLeft += 1;
continue;
}
await moveToDeadletter(redis, redisId, fields, error);
await ack(redis, options.stream, options.group, redisId, summary);
summary.deadlettered += 1;
}
}
return summary;
}
async function ack(
redis: CdrRedisCommands,
stream: string,
group: string,
redisId: string,
summary: CdrProcessSummary
): Promise<void> {
await redis.xack(stream, group, redisId);
summary.ackedIds.push(redisId);
}
async function moveToDeadletter(redis: CdrRedisCommands, redisId: string, fields: string[], error: unknown): Promise<void> {
const fieldMap = parseStreamFields(fields);
await redis.xadd(
CDR_DEADLETTER_STREAM,
'*',
'original_redis_id',
redisId,
'event_id',
fieldMap.event_id ?? '',
'call_id',
fieldMap.call_id ?? '',
'error',
error instanceof Error ? error.message : 'Unknown CDR processing error',
'payload',
JSON.stringify(fieldMap),
'deadlettered_at',
new Date().toISOString()
);
}