UNPKG

@k-msg/messaging

Version:

AlimTalk messaging core for sending, queuing, and tracking messages

1 lines 135 kB
{"version":3,"sources":["../src/index.ts","../src/types/message.types.ts","../src/sender/single.sender.ts","../src/sender/bulk.sender.ts","../src/queue/job.processor.ts","../src/queue/retry.handler.ts","../src/delivery/tracker.ts","../src/personalization/variable.replacer.ts"],"sourcesContent":["// Types\nexport * from './types/message.types';\n\n// Senders\nexport { SingleMessageSender, type Provider, type ProviderMessageRequest, type ProviderMessageResult } from './sender/single.sender';\nexport { BulkMessageSender } from './sender/bulk.sender';\n\n// Queue system\nexport { JobProcessor, MessageJobProcessor } from './queue/job.processor';\nexport { MessageRetryHandler } from './queue/retry.handler';\n\n// Delivery tracking\nexport { DeliveryTracker } from './delivery/tracker';\n\n// Personalization\nexport { VariableReplacer, defaultVariableReplacer, VariableUtils } from './personalization/variable.replacer';","import { z } from 'zod';\n\nexport interface MessageRequest {\n templateId: string; // 템플릿 ID\n recipients: Recipient[]; // 수신자 목록\n variables: VariableMap; // 공통 변수\n scheduling?: SchedulingOptions; // 예약 발송\n options?: SendingOptions; // 발송 옵션\n}\n\nexport interface Recipient {\n phoneNumber: string;\n variables?: VariableMap; // 개별 변수 (공통 변수 오버라이드)\n metadata?: Record<string, any>; // 추적용 메타데이터\n}\n\nexport interface VariableMap {\n [key: string]: string | number | Date;\n}\n\nexport interface SchedulingOptions {\n scheduledAt: Date; // 예약 발송 시간\n timezone?: string; // 타임존\n retryCount?: number; // 재시도 횟수\n}\n\nexport interface SendingOptions {\n priority?: 'high' | 'normal' | 'low';\n ttl?: number; // Time to live (초)\n failover?: {\n enabled: boolean;\n fallbackChannel?: 'sms' | 'lms';\n fallbackContent?: string;\n };\n deduplication?: {\n enabled: boolean;\n window: number; // 중복 제거 시간 (초)\n };\n tracking?: {\n enabled: boolean;\n webhookUrl?: string;\n };\n}\n\nexport interface MessageResult {\n requestId: string;\n results: RecipientResult[];\n summary: {\n total: number;\n queued: number;\n sent: number;\n failed: number;\n };\n metadata: {\n createdAt: Date;\n provider: string;\n templateId: string;\n };\n}\n\nexport interface RecipientResult {\n phoneNumber: string;\n messageId?: string;\n status: MessageStatus;\n error?: MessageError;\n metadata?: Record<string, any>;\n}\n\nexport enum MessageStatus {\n QUEUED = 'QUEUED', // 큐에 대기 중\n SENDING = 'SENDING', // 발송 중\n SENT = 'SENT', // 발송 완료\n DELIVERED = 'DELIVERED', // 전달 완료\n FAILED = 'FAILED', // 발송 실패\n CLICKED = 'CLICKED', // 버튼 클릭됨\n CANCELLED = 'CANCELLED' // 취소됨\n}\n\nexport interface MessageError {\n code: string;\n message: string;\n details?: Record<string, any>;\n}\n\nexport interface DeliveryReport {\n messageId: string;\n phoneNumber: string;\n status: MessageStatus;\n sentAt?: Date;\n deliveredAt?: Date;\n clickedAt?: Date;\n failedAt?: Date;\n error?: MessageError;\n attempts: DeliveryAttempt[];\n metadata: Record<string, any>;\n}\n\nexport interface DeliveryAttempt {\n attemptNumber: number;\n attemptedAt: Date;\n status: MessageStatus;\n error?: MessageError;\n provider: string;\n}\n\n// Bulk messaging types\nexport interface BulkMessageRequest {\n templateId: string;\n recipients: BulkRecipient[];\n commonVariables?: VariableMap;\n options?: BulkSendingOptions;\n}\n\nexport interface BulkRecipient {\n phoneNumber: string;\n variables: VariableMap;\n metadata?: Record<string, any>;\n}\n\nexport interface BulkSendingOptions extends SendingOptions {\n batchSize?: number; // 배치 크기\n batchDelay?: number; // 배치 간 지연 시간 (ms)\n maxConcurrency?: number; // 최대 동시 처리 수\n}\n\nexport interface BulkMessageResult {\n requestId: string;\n totalRecipients: number;\n batches: BulkBatchResult[];\n summary: {\n queued: number;\n sent: number;\n failed: number;\n processing: number;\n };\n createdAt: Date;\n completedAt?: Date;\n}\n\nexport interface BulkBatchResult {\n batchId: string;\n batchNumber: number;\n recipients: RecipientResult[];\n status: 'pending' | 'processing' | 'completed' | 'failed';\n createdAt: Date;\n completedAt?: Date;\n}\n\n// Event types\nexport enum MessageEventType {\n // 템플릿 이벤트\n TEMPLATE_CREATED = 'template.created',\n TEMPLATE_APPROVED = 'template.approved',\n TEMPLATE_REJECTED = 'template.rejected',\n TEMPLATE_UPDATED = 'template.updated',\n TEMPLATE_DELETED = 'template.deleted',\n \n // 메시지 이벤트\n MESSAGE_QUEUED = 'message.queued',\n MESSAGE_SENT = 'message.sent',\n MESSAGE_DELIVERED = 'message.delivered',\n MESSAGE_FAILED = 'message.failed',\n MESSAGE_CLICKED = 'message.clicked',\n MESSAGE_CANCELLED = 'message.cancelled',\n \n // 채널 이벤트\n CHANNEL_CREATED = 'channel.created',\n CHANNEL_VERIFIED = 'channel.verified',\n SENDER_NUMBER_ADDED = 'sender_number.added',\n \n // 시스템 이벤트\n QUOTA_WARNING = 'system.quota_warning',\n QUOTA_EXCEEDED = 'system.quota_exceeded',\n PROVIDER_ERROR = 'system.provider_error'\n}\n\nexport interface MessageEvent<T = any> {\n id: string;\n type: MessageEventType;\n timestamp: Date;\n data: T;\n metadata: {\n providerId?: string;\n userId?: string;\n organizationId?: string;\n correlationId?: string;\n };\n}\n\n// Zod schemas\nexport const VariableMapSchema = z.record(z.string(), z.union([z.string(), z.number(), z.date()]));\n\nexport const RecipientSchema = z.object({\n phoneNumber: z.string().regex(/^[0-9]{10,11}$/),\n variables: VariableMapSchema.optional(),\n metadata: z.record(z.string(), z.any()).optional(),\n});\n\nexport const SchedulingOptionsSchema = z.object({\n scheduledAt: z.date().min(new Date()),\n timezone: z.string().optional(),\n retryCount: z.number().min(0).max(5).optional().default(3),\n});\n\nexport const SendingOptionsSchema = z.object({\n priority: z.enum(['high', 'normal', 'low']).optional().default('normal'),\n ttl: z.number().min(0).optional(),\n failover: z.object({\n enabled: z.boolean(),\n fallbackChannel: z.enum(['sms', 'lms']).optional(),\n fallbackContent: z.string().optional(),\n }).optional(),\n deduplication: z.object({\n enabled: z.boolean(),\n window: z.number().min(0).max(3600),\n }).optional(),\n tracking: z.object({\n enabled: z.boolean(),\n webhookUrl: z.string().url().optional(),\n }).optional(),\n});\n\nexport const MessageRequestSchema = z.object({\n templateId: z.string().min(1),\n recipients: z.array(RecipientSchema).min(1).max(10000),\n variables: VariableMapSchema,\n scheduling: SchedulingOptionsSchema.optional(),\n options: SendingOptionsSchema.optional(),\n});\n\nexport const MessageErrorSchema = z.object({\n code: z.string(),\n message: z.string(),\n details: z.record(z.string(), z.any()).optional(),\n});\n\nexport const RecipientResultSchema = z.object({\n phoneNumber: z.string(),\n messageId: z.string().optional(),\n status: z.nativeEnum(MessageStatus),\n error: MessageErrorSchema.optional(),\n metadata: z.record(z.string(), z.any()).optional(),\n});\n\nexport const MessageResultSchema = z.object({\n requestId: z.string(),\n results: z.array(RecipientResultSchema),\n summary: z.object({\n total: z.number().min(0),\n queued: z.number().min(0),\n sent: z.number().min(0),\n failed: z.number().min(0),\n }),\n metadata: z.object({\n createdAt: z.date(),\n provider: z.string(),\n templateId: z.string(),\n }),\n});\n\nexport type MessageRequestType = z.infer<typeof MessageRequestSchema>;\nexport type RecipientType = z.infer<typeof RecipientSchema>;\nexport type MessageResultType = z.infer<typeof MessageResultSchema>;","import { MessageRequest, MessageResult, RecipientResult, MessageStatus } from '../types/message.types';\n\nexport interface Provider {\n id: string;\n name: string;\n send(request: ProviderMessageRequest): Promise<ProviderMessageResult>;\n}\n\nexport interface ProviderMessageRequest {\n templateCode: string;\n phoneNumber: string;\n variables: Record<string, any>;\n options?: Record<string, any>;\n}\n\nexport interface ProviderMessageResult {\n messageId: string;\n status: MessageStatus;\n error?: { code: string; message: string };\n}\n\nexport class SingleMessageSender {\n private providers: Map<string, Provider> = new Map();\n private templates: Map<string, any> = new Map(); // Template cache\n\n addProvider(provider: Provider): void {\n this.providers.set(provider.id, provider);\n }\n\n removeProvider(providerId: string): void {\n this.providers.delete(providerId);\n }\n\n async send(request: MessageRequest): Promise<MessageResult> {\n const requestId = this.generateRequestId();\n const results: RecipientResult[] = [];\n\n // Get template information\n const template = await this.getTemplate(request.templateId);\n if (!template) {\n throw new Error(`Template ${request.templateId} not found`);\n }\n\n // Get provider\n const provider = this.providers.get(template.provider);\n if (!provider) {\n throw new Error(`Provider ${template.provider} not found`);\n }\n\n // Process each recipient\n for (const recipient of request.recipients) {\n try {\n const result = await this.sendToRecipient(\n provider,\n template,\n recipient,\n request.variables,\n request.options\n );\n results.push(result);\n } catch (error) {\n results.push({\n phoneNumber: recipient.phoneNumber,\n status: MessageStatus.FAILED,\n error: {\n code: 'SEND_ERROR',\n message: error instanceof Error ? error.message : 'Unknown error'\n },\n metadata: recipient.metadata\n });\n }\n }\n\n // Calculate summary\n const summary = this.calculateSummary(results);\n\n return {\n requestId,\n results,\n summary,\n metadata: {\n createdAt: new Date(),\n provider: template.provider,\n templateId: request.templateId\n }\n };\n }\n\n private async sendToRecipient(\n provider: Provider,\n template: any,\n recipient: any,\n commonVariables: Record<string, any>,\n options?: any\n ): Promise<RecipientResult> {\n // Merge common variables with recipient-specific variables\n const variables = { ...commonVariables, ...recipient.variables };\n\n // Note: Variable parsing moved to personalization system\n // Variables are now handled by VariableReplacer in the personalization package\n\n // Prepare provider request\n const providerRequest: ProviderMessageRequest = {\n templateCode: template.code,\n phoneNumber: recipient.phoneNumber,\n variables,\n options\n };\n\n // Send message\n const providerResult = await provider.send(providerRequest);\n\n return {\n phoneNumber: recipient.phoneNumber,\n messageId: providerResult.messageId,\n status: providerResult.status,\n error: providerResult.error,\n metadata: recipient.metadata\n };\n }\n\n private async getTemplate(templateId: string): Promise<any> {\n // Check cache first\n if (this.templates.has(templateId)) {\n return this.templates.get(templateId);\n }\n\n // In a real implementation, this would fetch from a database\n // For now, return a mock template\n const template = {\n id: templateId,\n code: 'TEMPLATE_CODE',\n provider: 'mock-provider',\n variables: [],\n content: 'Mock template content'\n };\n\n this.templates.set(templateId, template);\n return template;\n }\n\n private calculateSummary(results: RecipientResult[]) {\n return {\n total: results.length,\n queued: results.filter(r => r.status === MessageStatus.QUEUED).length,\n sent: results.filter(r => r.status === MessageStatus.SENT).length,\n failed: results.filter(r => r.status === MessageStatus.FAILED).length\n };\n }\n\n private generateRequestId(): string {\n return `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;\n }\n\n async cancelMessage(messageId: string): Promise<boolean> {\n // Implementation for canceling a scheduled message\n // This would interact with the queue system\n throw new Error('Not implemented');\n }\n\n async getMessageStatus(messageId: string): Promise<MessageStatus> {\n // Implementation for getting message status\n // This would query the delivery tracking system\n throw new Error('Not implemented');\n }\n\n async resendMessage(messageId: string, options?: { newRecipient?: string }): Promise<MessageResult> {\n // Implementation for resending a failed message\n throw new Error('Not implemented');\n }\n}","import { \n BulkMessageRequest, \n BulkMessageResult, \n BulkBatchResult, \n RecipientResult,\n MessageStatus \n} from '../types/message.types';\nimport { SingleMessageSender } from './single.sender';\n\nexport class BulkMessageSender {\n private singleSender: SingleMessageSender;\n private activeBulkJobs: Map<string, BulkJob> = new Map();\n\n constructor(singleSender: SingleMessageSender) {\n this.singleSender = singleSender;\n }\n\n async sendBulk(request: BulkMessageRequest): Promise<BulkMessageResult> {\n const requestId = this.generateRequestId();\n const batchSize = request.options?.batchSize || 100;\n const batchDelay = request.options?.batchDelay || 1000;\n \n // Split recipients into batches\n const batches = this.createBatches(request.recipients, batchSize);\n \n const bulkResult: BulkMessageResult = {\n requestId,\n totalRecipients: request.recipients.length,\n batches: [],\n summary: {\n queued: request.recipients.length,\n sent: 0,\n failed: 0,\n processing: 0\n },\n createdAt: new Date()\n };\n\n // Create bulk job for tracking\n const bulkJob: BulkJob = {\n id: requestId,\n request,\n result: bulkResult,\n status: 'processing',\n createdAt: new Date()\n };\n\n this.activeBulkJobs.set(requestId, bulkJob);\n\n // Process batches asynchronously\n this.processBatchesAsync(bulkJob, batches, batchDelay);\n\n return bulkResult;\n }\n\n private async processBatchesAsync(\n bulkJob: BulkJob,\n batches: any[][],\n batchDelay: number\n ): Promise<void> {\n try {\n for (let i = 0; i < batches.length; i++) {\n const batch = batches[i];\n const batchId = `${bulkJob.id}_batch_${i + 1}`;\n\n const batchResult: BulkBatchResult = {\n batchId,\n batchNumber: i + 1,\n recipients: [],\n status: 'processing',\n createdAt: new Date()\n };\n\n bulkJob.result.batches.push(batchResult);\n bulkJob.result.summary.processing += batch.length;\n bulkJob.result.summary.queued -= batch.length;\n\n try {\n // Process batch\n const batchRecipients = await this.processBatch(\n bulkJob.request,\n batch,\n batchId\n );\n\n batchResult.recipients = batchRecipients;\n batchResult.status = 'completed';\n batchResult.completedAt = new Date();\n\n // Update summary\n const sent = batchRecipients.filter(r => r.status === MessageStatus.SENT).length;\n const failed = batchRecipients.filter(r => r.status === MessageStatus.FAILED).length;\n\n bulkJob.result.summary.sent += sent;\n bulkJob.result.summary.failed += failed;\n bulkJob.result.summary.processing -= batch.length;\n\n } catch (error) {\n batchResult.status = 'failed';\n batchResult.completedAt = new Date();\n \n // Mark all recipients in this batch as failed\n batchResult.recipients = batch.map(recipient => ({\n phoneNumber: recipient.phoneNumber,\n status: MessageStatus.FAILED,\n error: {\n code: 'BATCH_ERROR',\n message: error instanceof Error ? error.message : 'Batch processing failed'\n },\n metadata: recipient.metadata\n }));\n\n bulkJob.result.summary.failed += batch.length;\n bulkJob.result.summary.processing -= batch.length;\n }\n\n // Add delay between batches (except for the last one)\n if (i < batches.length - 1) {\n await this.delay(batchDelay);\n }\n }\n\n bulkJob.status = 'completed';\n bulkJob.result.completedAt = new Date();\n\n } catch (error) {\n bulkJob.status = 'failed';\n bulkJob.result.completedAt = new Date();\n }\n }\n\n private async processBatch(\n request: BulkMessageRequest,\n batchRecipients: any[],\n batchId: string\n ): Promise<RecipientResult[]> {\n const results: RecipientResult[] = [];\n const maxConcurrency = request.options?.maxConcurrency || 10;\n\n // Process recipients in parallel with concurrency limit\n const promises: Promise<RecipientResult>[] = [];\n \n for (let i = 0; i < batchRecipients.length; i += maxConcurrency) {\n const chunk = batchRecipients.slice(i, i + maxConcurrency);\n \n const chunkPromises = chunk.map(recipient => \n this.processRecipient(request, recipient)\n );\n\n const chunkResults = await Promise.allSettled(chunkPromises);\n \n for (const result of chunkResults) {\n if (result.status === 'fulfilled') {\n results.push(result.value);\n } else {\n // Handle promise rejection\n results.push({\n phoneNumber: 'unknown',\n status: MessageStatus.FAILED,\n error: {\n code: 'PROCESSING_ERROR',\n message: result.reason?.message || 'Unknown processing error'\n }\n });\n }\n }\n }\n\n return results;\n }\n\n private async processRecipient(\n request: BulkMessageRequest,\n recipient: any\n ): Promise<RecipientResult> {\n try {\n // Merge common variables with recipient-specific variables\n const variables = { ...request.commonVariables, ...recipient.variables };\n\n // Create single message request\n const messageRequest = {\n templateId: request.templateId,\n recipients: [{\n phoneNumber: recipient.phoneNumber,\n variables: {},\n metadata: recipient.metadata\n }],\n variables,\n options: request.options\n };\n\n const result = await this.singleSender.send(messageRequest);\n return result.results[0];\n\n } catch (error) {\n return {\n phoneNumber: recipient.phoneNumber,\n status: MessageStatus.FAILED,\n error: {\n code: 'RECIPIENT_ERROR',\n message: error instanceof Error ? error.message : 'Unknown error'\n },\n metadata: recipient.metadata\n };\n }\n }\n\n private createBatches<T>(items: T[], batchSize: number): T[][] {\n const batches: T[][] = [];\n \n for (let i = 0; i < items.length; i += batchSize) {\n batches.push(items.slice(i, i + batchSize));\n }\n \n return batches;\n }\n\n private delay(ms: number): Promise<void> {\n return new Promise(resolve => setTimeout(resolve, ms));\n }\n\n private generateRequestId(): string {\n return `bulk_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;\n }\n\n async getBulkStatus(requestId: string): Promise<BulkMessageResult | null> {\n const job = this.activeBulkJobs.get(requestId);\n return job ? job.result : null;\n }\n\n async cancelBulkJob(requestId: string): Promise<boolean> {\n const job = this.activeBulkJobs.get(requestId);\n if (!job) {\n return false;\n }\n\n job.status = 'cancelled';\n \n // Cancel pending batches\n for (const batch of job.result.batches) {\n if (batch.status === 'pending' || batch.status === 'processing') {\n batch.status = 'failed';\n batch.completedAt = new Date();\n }\n }\n\n return true;\n }\n\n async retryFailedBatch(requestId: string, batchId: string): Promise<BulkBatchResult | null> {\n const job = this.activeBulkJobs.get(requestId);\n if (!job) {\n return null;\n }\n\n const batch = job.result.batches.find(b => b.batchId === batchId);\n if (!batch || batch.status !== 'failed') {\n return null;\n }\n\n // Reset batch status\n batch.status = 'processing';\n batch.createdAt = new Date();\n delete batch.completedAt;\n\n try {\n // Extract failed recipients for retry\n const failedRecipients = batch.recipients\n .filter(r => r.status === MessageStatus.FAILED)\n .map(r => ({\n phoneNumber: r.phoneNumber,\n variables: {},\n metadata: r.metadata\n }));\n\n const retryResults = await this.processBatch(\n job.request,\n failedRecipients,\n batchId\n );\n\n batch.recipients = retryResults;\n batch.status = 'completed';\n batch.completedAt = new Date();\n\n return batch;\n\n } catch (error) {\n batch.status = 'failed';\n batch.completedAt = new Date();\n return batch;\n }\n }\n\n cleanup(): void {\n // Remove completed jobs older than 24 hours\n const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);\n \n for (const [id, job] of this.activeBulkJobs) {\n if (job.status === 'completed' && job.createdAt < oneDayAgo) {\n this.activeBulkJobs.delete(id);\n }\n }\n }\n}\n\ninterface BulkJob {\n id: string;\n request: BulkMessageRequest;\n result: BulkMessageResult;\n status: 'processing' | 'completed' | 'failed' | 'cancelled';\n createdAt: Date;\n}","/**\n * Job processor for message queue system\n */\n\nimport { EventEmitter } from 'events';\nimport {\n MessageRequest,\n MessageResult,\n MessageStatus,\n MessageEventType,\n MessageEvent,\n RecipientResult,\n DeliveryReport\n} from '../types/message.types';\nimport { RetryHandler, CircuitBreaker, RateLimiter } from '@k-msg/core';\n\nexport interface Job<T = any> {\n id: string;\n type: string;\n data: T;\n priority: number;\n attempts: number;\n maxAttempts: number;\n delay: number;\n createdAt: Date;\n processAt: Date;\n completedAt?: Date;\n failedAt?: Date;\n error?: string;\n metadata: Record<string, any>;\n}\n\nexport interface JobProcessorOptions {\n concurrency: number;\n retryDelays: number[];\n maxRetries: number;\n pollInterval: number;\n enableMetrics: boolean;\n rateLimiter?: {\n maxRequests: number;\n windowMs: number;\n };\n circuitBreaker?: {\n failureThreshold: number;\n timeout: number;\n resetTimeout: number;\n };\n}\n\nexport interface JobHandler<T = any> {\n (job: Job<T>): Promise<any>;\n}\n\nexport interface JobProcessorMetrics {\n processed: number;\n succeeded: number;\n failed: number;\n retried: number;\n activeJobs: number;\n queueSize: number;\n averageProcessingTime: number;\n lastProcessedAt?: Date;\n}\n\nexport class JobProcessor extends EventEmitter {\n private handlers = new Map<string, JobHandler>();\n private queue: Job[] = [];\n private processing = new Set<string>();\n private isRunning = false;\n private pollTimer?: NodeJS.Timeout;\n private metrics: JobProcessorMetrics;\n private rateLimiter?: RateLimiter;\n private circuitBreaker?: CircuitBreaker;\n\n constructor(private options: JobProcessorOptions) {\n super();\n\n this.metrics = {\n processed: 0,\n succeeded: 0,\n failed: 0,\n retried: 0,\n activeJobs: 0,\n queueSize: 0,\n averageProcessingTime: 0\n };\n\n if (options.rateLimiter) {\n this.rateLimiter = new RateLimiter(\n options.rateLimiter.maxRequests,\n options.rateLimiter.windowMs\n );\n }\n\n if (options.circuitBreaker) {\n this.circuitBreaker = new CircuitBreaker(options.circuitBreaker);\n }\n }\n\n /**\n * Register a job handler\n */\n handle<T>(jobType: string, handler: JobHandler<T>): void {\n this.handlers.set(jobType, handler);\n }\n\n /**\n * Add a job to the queue\n */\n async add<T>(\n jobType: string,\n data: T,\n options: {\n priority?: number;\n delay?: number;\n maxAttempts?: number;\n metadata?: Record<string, any>;\n } = {}\n ): Promise<string> {\n const jobId = `${jobType}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;\n const now = new Date();\n\n const job: Job<T> = {\n id: jobId,\n type: jobType,\n data,\n priority: options.priority || 5,\n attempts: 0,\n maxAttempts: options.maxAttempts || this.options.maxRetries,\n delay: options.delay || 0,\n createdAt: now,\n processAt: new Date(now.getTime() + (options.delay || 0)),\n metadata: options.metadata || {}\n };\n\n // Insert job in priority order (higher priority first)\n const insertIndex = this.queue.findIndex(\n existingJob => existingJob.priority < job.priority\n );\n\n if (insertIndex === -1) {\n this.queue.push(job);\n } else {\n this.queue.splice(insertIndex, 0, job);\n }\n\n this.updateMetrics();\n this.emit('job:added', job);\n\n return jobId;\n }\n\n /**\n * Start processing jobs\n */\n start(): void {\n if (this.isRunning) {\n return;\n }\n\n this.isRunning = true;\n this.scheduleNextPoll();\n this.emit('processor:started');\n }\n\n /**\n * Stop processing jobs\n */\n async stop(): Promise<void> {\n this.isRunning = false;\n\n if (this.pollTimer) {\n clearTimeout(this.pollTimer);\n this.pollTimer = undefined;\n }\n\n // Wait for active jobs to complete\n while (this.processing.size > 0) {\n await new Promise(resolve => setTimeout(resolve, 100));\n }\n\n this.emit('processor:stopped');\n }\n\n /**\n * Get current metrics\n */\n getMetrics(): JobProcessorMetrics {\n return { ...this.metrics };\n }\n\n /**\n * Get queue status\n */\n getQueueStatus(): {\n pending: number;\n processing: number;\n failed: number;\n totalProcessed: number;\n } {\n const failed = this.queue.filter(job => job.failedAt).length;\n\n return {\n pending: this.queue.length - failed,\n processing: this.processing.size,\n failed,\n totalProcessed: this.metrics.processed\n };\n }\n\n /**\n * Remove completed jobs from queue\n */\n cleanup(): number {\n const initialLength = this.queue.length;\n\n this.queue = this.queue.filter(job =>\n !job.completedAt && !job.failedAt\n );\n\n const removed = initialLength - this.queue.length;\n this.updateMetrics();\n\n return removed;\n }\n\n /**\n * Get specific job by ID\n */\n getJob(jobId: string): Job | undefined {\n return this.queue.find(job => job.id === jobId);\n }\n\n /**\n * Remove job from queue\n */\n removeJob(jobId: string): boolean {\n const index = this.queue.findIndex(job => job.id === jobId);\n if (index !== -1) {\n this.queue.splice(index, 1);\n this.processing.delete(jobId);\n this.updateMetrics();\n return true;\n }\n return false;\n }\n\n private scheduleNextPoll(): void {\n if (!this.isRunning) {\n return;\n }\n\n this.pollTimer = setTimeout(() => {\n this.processJobs();\n this.scheduleNextPoll();\n }, this.options.pollInterval);\n }\n\n private async processJobs(): Promise<void> {\n const availableSlots = this.options.concurrency - this.processing.size;\n if (availableSlots <= 0) {\n return;\n }\n\n const now = new Date();\n const readyJobs = this.queue\n .filter(job =>\n !job.completedAt &&\n !job.failedAt &&\n !this.processing.has(job.id) &&\n job.processAt <= now\n )\n .slice(0, availableSlots);\n\n for (const job of readyJobs) {\n this.processJob(job);\n }\n }\n\n private async processJob(job: Job): Promise<void> {\n const handler = this.handlers.get(job.type);\n if (!handler) {\n this.failJob(job, `No handler registered for job type: ${job.type}`);\n return;\n }\n\n this.processing.add(job.id);\n job.attempts++;\n this.metrics.activeJobs++;\n\n const startTime = Date.now();\n\n try {\n // Apply rate limiting\n if (this.rateLimiter) {\n await this.rateLimiter.acquire();\n }\n\n // Execute through circuit breaker if configured\n const executeJob = async () => handler(job);\n const result = this.circuitBreaker\n ? await this.circuitBreaker.execute(executeJob)\n : await executeJob();\n\n // Job completed successfully\n job.completedAt = new Date();\n this.processing.delete(job.id);\n this.metrics.activeJobs--;\n this.metrics.succeeded++;\n this.metrics.processed++;\n\n const processingTime = Date.now() - startTime;\n this.updateAverageProcessingTime(processingTime);\n\n this.emit('job:completed', { job, result, processingTime });\n\n } catch (error) {\n this.processing.delete(job.id);\n this.metrics.activeJobs--;\n\n const shouldRetry = job.attempts < job.maxAttempts;\n\n if (shouldRetry) {\n // Schedule retry\n const retryDelay = this.getRetryDelay(job.attempts);\n job.processAt = new Date(Date.now() + retryDelay);\n job.error = error instanceof Error ? error.message : String(error);\n\n this.metrics.retried++;\n this.emit('job:retry', { job, error, retryDelay });\n } else {\n // Job failed permanently\n this.failJob(job, error instanceof Error ? error.message : String(error));\n }\n }\n\n this.updateMetrics();\n }\n\n private failJob(job: Job, error: string): void {\n job.failedAt = new Date();\n job.error = error;\n this.metrics.failed++;\n this.metrics.processed++;\n this.emit('job:failed', { job, error });\n }\n\n private getRetryDelay(attempt: number): number {\n const delayIndex = Math.min(attempt - 1, this.options.retryDelays.length - 1);\n return this.options.retryDelays[delayIndex] || this.options.retryDelays[this.options.retryDelays.length - 1];\n }\n\n private updateMetrics(): void {\n this.metrics.queueSize = this.queue.length;\n this.metrics.lastProcessedAt = new Date();\n }\n\n private updateAverageProcessingTime(newTime: number): void {\n const totalProcessed = this.metrics.succeeded + this.metrics.failed;\n if (totalProcessed === 1) {\n this.metrics.averageProcessingTime = newTime;\n } else {\n this.metrics.averageProcessingTime =\n (this.metrics.averageProcessingTime * (totalProcessed - 1) + newTime) / totalProcessed;\n }\n }\n}\n\n/**\n * Specific processor for message jobs\n */\nexport class MessageJobProcessor extends JobProcessor {\n constructor(options: Partial<JobProcessorOptions> = {}) {\n super({\n concurrency: 5,\n retryDelays: [1000, 5000, 15000, 60000], // 1s, 5s, 15s, 1m\n maxRetries: 3,\n pollInterval: 1000,\n enableMetrics: true,\n ...options\n });\n\n this.setupMessageHandlers();\n }\n\n private setupMessageHandlers(): void {\n // Handle single message sending\n this.handle('send_message', async (job: Job<MessageRequest>) => {\n return this.processSingleMessage(job);\n });\n\n // Handle bulk message sending\n this.handle('send_bulk_messages', async (job: Job<MessageRequest[]>) => {\n return this.processBulkMessages(job);\n });\n\n // Handle delivery status updates\n this.handle('update_delivery_status', async (job: Job<DeliveryReport>) => {\n return this.processDeliveryUpdate(job);\n });\n\n // Handle scheduled message sending\n this.handle('send_scheduled_message', async (job: Job<MessageRequest>) => {\n return this.processScheduledMessage(job);\n });\n }\n\n private async processSingleMessage(job: Job<MessageRequest>): Promise<MessageResult> {\n const { data: messageRequest } = job;\n\n // Emit processing event\n this.emit('message:processing', {\n type: MessageEventType.MESSAGE_QUEUED,\n timestamp: new Date(),\n data: { requestId: job.id, messageRequest },\n metadata: job.metadata\n } as MessageEvent);\n\n // Process message (this would integrate with actual provider)\n const results: RecipientResult[] = messageRequest.recipients.map(recipient => ({\n phoneNumber: recipient.phoneNumber,\n messageId: `msg_${Date.now()}_${Math.random().toString(36).substr(2, 6)}`,\n status: MessageStatus.QUEUED,\n metadata: recipient.metadata\n }));\n\n const result: MessageResult = {\n requestId: job.id,\n results,\n summary: {\n total: messageRequest.recipients.length,\n queued: messageRequest.recipients.length,\n sent: 0,\n failed: 0\n },\n metadata: {\n createdAt: new Date(),\n provider: 'default',\n templateId: messageRequest.templateId\n }\n };\n\n // Emit completion event\n this.emit('message:queued', {\n type: MessageEventType.MESSAGE_QUEUED,\n timestamp: new Date(),\n data: result,\n metadata: job.metadata\n } as MessageEvent);\n\n return result;\n }\n\n private async processBulkMessages(job: Job<MessageRequest[]>): Promise<MessageResult[]> {\n const { data: messageRequests } = job;\n const results: MessageResult[] = [];\n\n for (const messageRequest of messageRequests) {\n const singleJob: Job<MessageRequest> = {\n ...job,\n id: `${job.id}_${results.length}`,\n data: messageRequest\n };\n\n const result = await this.processSingleMessage(singleJob);\n results.push(result);\n }\n\n return results;\n }\n\n private async processDeliveryUpdate(job: Job<DeliveryReport>): Promise<void> {\n const { data: deliveryReport } = job;\n\n // Update delivery status (this would update database)\n this.emit('delivery:updated', {\n type: MessageEventType.MESSAGE_DELIVERED,\n timestamp: new Date(),\n data: deliveryReport,\n metadata: job.metadata\n } as MessageEvent);\n }\n\n private async processScheduledMessage(job: Job<MessageRequest>): Promise<MessageResult> {\n const { data: messageRequest } = job;\n\n // Check if it's time to send\n const scheduledAt = messageRequest.scheduling?.scheduledAt;\n if (scheduledAt && scheduledAt > new Date()) {\n // Reschedule for later\n throw new Error(`Message scheduled for ${scheduledAt.toISOString()}, rescheduling`);\n }\n\n // Process as normal message\n return this.processSingleMessage(job);\n }\n\n /**\n * Add a message to the processing queue\n */\n async queueMessage(\n messageRequest: MessageRequest,\n options: {\n priority?: number;\n delay?: number;\n metadata?: Record<string, any>;\n } = {}\n ): Promise<string> {\n const priority = options.priority ||\n (messageRequest.options?.priority === 'high' ? 10 :\n messageRequest.options?.priority === 'low' ? 1 : 5);\n\n const delay = options.delay || 0;\n\n return this.add('send_message', messageRequest, {\n priority,\n delay,\n metadata: options.metadata\n });\n }\n\n /**\n * Add bulk messages to the processing queue\n */\n async queueBulkMessages(\n messageRequests: MessageRequest[],\n options: {\n priority?: number;\n delay?: number;\n metadata?: Record<string, any>;\n } = {}\n ): Promise<string> {\n return this.add('send_bulk_messages', messageRequests, {\n priority: options.priority || 3,\n delay: options.delay || 0,\n metadata: options.metadata\n });\n }\n\n /**\n * Schedule a message for future delivery\n */\n async scheduleMessage(\n messageRequest: MessageRequest,\n scheduledAt: Date,\n options: {\n metadata?: Record<string, any>;\n } = {}\n ): Promise<string> {\n const delay = Math.max(0, scheduledAt.getTime() - Date.now());\n\n return this.add('send_scheduled_message', messageRequest, {\n priority: 5,\n delay,\n metadata: options.metadata\n });\n }\n}","/**\n * Retry handler for failed message deliveries\n */\n\nimport { EventEmitter } from 'events';\nimport {\n MessageStatus,\n DeliveryReport,\n DeliveryAttempt,\n MessageEventType,\n MessageEvent\n} from '../types/message.types';\nimport { RetryHandler as CoreRetryHandler, ErrorUtils } from '@k-msg/core';\n\nexport interface RetryPolicy {\n maxAttempts: number;\n backoffMultiplier: number;\n initialDelay: number;\n maxDelay: number;\n jitter: boolean;\n retryableStatuses: MessageStatus[];\n retryableErrorCodes: string[];\n}\n\nexport interface RetryAttempt {\n messageId: string;\n phoneNumber: string;\n attemptNumber: number;\n scheduledAt: Date;\n provider: string;\n templateId: string;\n variables: Record<string, any>;\n metadata: Record<string, any>;\n}\n\nexport interface RetryQueueItem {\n id: string;\n messageId: string;\n phoneNumber: string;\n originalDeliveryReport: DeliveryReport;\n attempts: RetryAttempt[];\n nextRetryAt: Date;\n status: 'pending' | 'processing' | 'exhausted' | 'cancelled';\n createdAt: Date;\n updatedAt: Date;\n}\n\nexport interface RetryHandlerOptions {\n policy: RetryPolicy;\n checkInterval: number;\n maxQueueSize: number;\n enablePersistence: boolean;\n onRetryExhausted?: (item: RetryQueueItem) => Promise<void>;\n onRetrySuccess?: (item: RetryQueueItem, result: any) => Promise<void>;\n onRetryFailed?: (item: RetryQueueItem, error: Error) => Promise<void>;\n}\n\nexport interface RetryHandlerMetrics {\n totalRetries: number;\n successfulRetries: number;\n failedRetries: number;\n exhaustedRetries: number;\n queueSize: number;\n averageRetryDelay: number;\n lastRetryAt?: Date;\n}\n\nexport class MessageRetryHandler extends EventEmitter {\n private retryQueue: RetryQueueItem[] = [];\n private processing = new Set<string>();\n private checkTimer?: NodeJS.Timeout;\n private isRunning = false;\n private metrics: RetryHandlerMetrics;\n\n private defaultPolicy: RetryPolicy = {\n maxAttempts: 3,\n backoffMultiplier: 2,\n initialDelay: 5000, // 5 seconds\n maxDelay: 300000, // 5 minutes\n jitter: true,\n retryableStatuses: [MessageStatus.FAILED],\n retryableErrorCodes: [\n 'NETWORK_TIMEOUT',\n 'PROVIDER_CONNECTION_FAILED',\n 'PROVIDER_RATE_LIMITED',\n 'PROVIDER_SERVICE_UNAVAILABLE'\n ]\n };\n\n constructor(private options: RetryHandlerOptions) {\n super();\n\n this.options.policy = { ...this.defaultPolicy, ...this.options.policy };\n\n this.metrics = {\n totalRetries: 0,\n successfulRetries: 0,\n failedRetries: 0,\n exhaustedRetries: 0,\n queueSize: 0,\n averageRetryDelay: 0\n };\n }\n\n /**\n * Start the retry handler\n */\n start(): void {\n if (this.isRunning) {\n return;\n }\n\n this.isRunning = true;\n this.scheduleNextCheck();\n this.emit('handler:started');\n }\n\n /**\n * Stop the retry handler\n */\n async stop(): Promise<void> {\n this.isRunning = false;\n\n if (this.checkTimer) {\n clearTimeout(this.checkTimer);\n this.checkTimer = undefined;\n }\n\n // Wait for active retries to complete\n while (this.processing.size > 0) {\n await new Promise(resolve => setTimeout(resolve, 100));\n }\n\n this.emit('handler:stopped');\n }\n\n /**\n * Add a failed delivery for retry\n */\n async addForRetry(deliveryReport: DeliveryReport): Promise<boolean> {\n // Check if this message should be retried\n if (!this.shouldRetry(deliveryReport)) {\n return false;\n }\n\n // Check if already in retry queue\n const existingItem = this.retryQueue.find(\n item => item.messageId === deliveryReport.messageId\n );\n\n if (existingItem) {\n // Update existing item\n return this.updateRetryItem(existingItem, deliveryReport);\n }\n\n // Create new retry item\n const retryItem = await this.createRetryItem(deliveryReport);\n\n if (this.retryQueue.length >= this.options.maxQueueSize) {\n // Remove oldest exhausted items to make space\n this.cleanupQueue();\n\n if (this.retryQueue.length >= this.options.maxQueueSize) {\n this.emit('queue:full', { rejected: deliveryReport });\n return false;\n }\n }\n\n this.retryQueue.push(retryItem);\n this.updateMetrics();\n\n this.emit('retry:queued', {\n type: MessageEventType.MESSAGE_QUEUED,\n timestamp: new Date(),\n data: retryItem,\n metadata: deliveryReport.metadata\n } as MessageEvent);\n\n return true;\n }\n\n /**\n * Cancel retry for a specific message\n */\n cancelRetry(messageId: string): boolean {\n const item = this.retryQueue.find(item => item.messageId === messageId);\n if (item) {\n item.status = 'cancelled';\n item.updatedAt = new Date();\n this.updateMetrics();\n this.emit('retry:cancelled', item);\n return true;\n }\n return false;\n }\n\n /**\n * Get retry status for a message\n */\n getRetryStatus(messageId: string): RetryQueueItem | undefined {\n return this.retryQueue.find(item => item.messageId === messageId);\n }\n\n /**\n * Get all retry queue items\n */\n getRetryQueue(): RetryQueueItem[] {\n return [...this.retryQueue];\n }\n\n /**\n * Get metrics\n */\n getMetrics(): RetryHandlerMetrics {\n return { ...this.metrics };\n }\n\n /**\n * Clean up completed/exhausted retry items\n */\n cleanup(): number {\n const initialLength = this.retryQueue.length;\n\n this.retryQueue = this.retryQueue.filter(item =>\n item.status === 'pending' || item.status === 'processing'\n );\n\n const removed = initialLength - this.retryQueue.length;\n this.updateMetrics();\n\n return removed;\n }\n\n private scheduleNextCheck(): void {\n if (!this.isRunning) {\n return;\n }\n\n this.checkTimer = setTimeout(() => {\n this.processRetryQueue();\n this.scheduleNextCheck();\n }, this.options.checkInterval);\n }\n\n private async processRetryQueue(): Promise<void> {\n const now = new Date();\n\n const readyItems = this.retryQueue.filter(item =>\n item.status === 'pending' &&\n item.nextRetryAt <= now &&\n !this.processing.has(item.id)\n );\n\n for (const item of readyItems) {\n this.processRetryItem(item);\n }\n }\n\n private async processRetryItem(item: RetryQueueItem): Promise<void> {\n this.processing.add(item.id);\n item.status = 'processing';\n item.updatedAt = new Date();\n\n try {\n // Create retry attempt\n const attempt: RetryAttempt = {\n messageId: item.messageId,\n phoneNumber: item.phoneNumber,\n attemptNumber: item.attempts.length + 1,\n scheduledAt: new Date(),\n provider: item.originalDeliveryReport.attempts[0]?.provider || 'unknown',\n templateId: item.originalDeliveryReport.metadata.templateId || '',\n variables: item.originalDeliveryReport.metadata.variables || {},\n metadata: item.originalDeliveryReport.metadata\n };\n\n item.attempts.push(attempt);\n\n // Emit retry started event\n this.emit('retry:started', {\n type: MessageEventType.MESSAGE_QUEUED,\n timestamp: new Date(),\n data: { item, attempt },\n metadata: item.originalDeliveryReport.metadata\n } as MessageEvent);\n\n // Execute retry (this would integrate with actual message sender)\n const result = await this.executeRetry(attempt);\n\n // Retry succeeded\n item.status = 'exhausted'; // Mark as completed\n this.processing.delete(item.id);\n this.metrics.successfulRetries++;\n this.metrics.totalRetries++;\n this.updateMetrics();\n\n await this.options.onRetrySuccess?.(item, result);\n\n this.emit('retry:success', {\n type: MessageEventType.MESSAGE_SENT,\n timestamp: new Date(),\n data: { item, attempt, result },\n metadata: item.originalDeliveryReport.metadata\n } as MessageEvent);\n\n } catch (error) {\n this.processing.delete(item.id);\n this.metrics.failedRetries++;\n this.metrics.totalRetries++;\n\n const maxAttempts = this.options.policy.maxAttempts;\n const shouldRetryAgain = item.attempts.length < maxAttempts;\n\n if (shouldRetryAgain) {\n // Schedule next retry\n const nextDelay = this.calculateRetryDelay(item.attempts.length);\n item.nextRetryAt = new Date(Date.now() + nextDelay);\n item.status = 'pending';\n } else {\n // Retry exhausted\n item.status = 'exhausted';\n this.metrics.exhaustedRetries++;\n\n await this.options.onRetryExhausted?.(item);\n\n this.emit('retry:exhausted', {\n type: MessageEventType.MESSAGE_FAILED,\n timestamp: new Date(),\n data: { item, finalError: error },\n metadata: item.originalDeliveryReport.metadata\n } as MessageEvent);\n }\n\n item.updatedAt = new Date();\n this.updateMetrics();\n\n await this.options.onRetryFailed?.(item, error as Error);\n\n this.emit('retry:failed', {\n type: MessageEventType.MESSAGE_FAILED,\n timestamp: new Date(),\n data: { item, error, willRetry: shouldRetryAgain },\n metadata: item.originalDeliveryReport.metadata\n } as MessageEvent);\n }\n }\n\n private async executeRetry(attempt: RetryAttempt): Promise<any> {\n // This would integrate with the actual message sender\n // For now, simulate the retry operation\n return CoreRetryHandler.execute(\n async () => {\n // Simulate message sending\n if (Math.random() < 0.7) { // 70% success rate for retries\n return {\n messageId: attempt.messageId,\n status: 'sent',\n sentAt: new Date()\n };\n } else {\n throw new Error('Retry failed');\n }\n },\n {\n maxAttempts: 1, // We handle retries at a higher level\n initialDelay: 0,\n retryCondition: () => false // No retries at this level\n }\n );\n }\n\n private shouldRetry(deliveryReport: DeliveryReport): boolean {\n const { policy } = this.options;\n\n // Check if status is retryable\n if (!policy.retryableStatuses.includes(deliveryReport.status)) {\n return false;\n }\n\n // Check if error code is retryable\n // First check top-level error, then latest attempt error\n let errorToCheck = deliveryReport.error;\n if (!errorToCheck && deliveryReport.attempts.length > 0) {\n const latestAttempt = deliveryReport.attempts[deliveryReport.attempts.length - 1];\n errorToCheck = latestAttempt.error;\n }\n\n if (errorToCheck) {\n const isRetryableError = policy.retryableErrorCodes.includes(errorToCheck.code);\n if (!isRetryableError) {\n return false;\n }\n }\n\n // Check if attempts haven't been exhausted\n return deliveryReport.attempts.length < policy.maxAttempts;\n }\n\n private async createRetryItem(deliveryReport: DeliveryReport): Promise<RetryQueueItem> {\n const initialDelay = this.calculateRetryDelay(deliveryReport.attempts.length);\n\n return {\n id: `retry_${deliveryReport.messageId}_${Date.now()}`,\n messageId: deliveryReport.messageId,\n phoneNumber: deliveryReport.phoneNumber,\n originalDeliveryReport: deliveryReport,\n attempts: [],\n nextRetryAt: new Date(Date.now() + initialDelay),\n status: 'pending',\n createdAt: new Date(),\n updatedAt: new Date()\n };\n }\n\n private updateRetryItem(item: RetryQueueItem, deliveryReport: DeliveryReport): boolean {\n if (item.status === 'exhausted' || item.status === 'cancelled') {\n return false;\n }\n\n // Update the delivery report\n item.originalDeliveryReport = deliveryReport;\n item.updatedAt = new Date();\n\n // Recalculate next retry time if needed\n if (item.status === 'pending') {\n const nextDelay = this.calculateRetryDelay(item.attempts.length);\n item.nextRetryAt = new Date(Date.now() + nextDelay);\n }\n\n return true;\n }\n\n private calculateRetryDelay(attemptNumber: number): number {\n const { policy } = this.options;\n\n let delay = policy.initialDelay * Math.pow(policy.backoffMultiplier, attemptNumber);\n delay = Math.min(delay, policy.maxDelay);\n\n // Add jitter if enabled\n if (policy.jitter) {\n const jitterAmount = delay * 0.1; // 10% jitter\n delay += (Math.random() - 0.5) * 2 * jitterAmount;\n }\n\n return Math.max(0, delay);\n }\n\n private cleanupQueue(): void {\n const cutoffTime = new Date(Date.now() - 24 * 60 * 60 * 1000); // 24 hours ago\n\n this.retryQueue = this.retryQueue.filter(item =>\n item.status === 'pending' ||\n item.status === 'processing' ||\n (item.status === 'exhausted' && item.updatedAt > cutoffTime)\n );\n }\n\n private updateMetrics(): void {\n this.metrics.queueSize = this.retryQueue.length;\n this.metrics.lastRetryAt = new Date();\n\n // Calculate average retry delay\n const pendingItems = this.retryQueue.filter(item => item.status === 'pending');\n if (pendingItems.length > 0) {\n const totalDelay = pendingItems.r