UNPKG

@k-msg/messaging

Version:

AlimTalk messaging core for sending, queuing, and tracking messages

1,558 lines (1,551 loc) 68.9 kB
"use strict"; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // src/index.ts var index_exports = {}; __export(index_exports, { BulkMessageSender: () => BulkMessageSender, DeliveryTracker: () => DeliveryTracker, JobProcessor: () => JobProcessor, MessageErrorSchema: () => MessageErrorSchema, MessageEventType: () => MessageEventType, MessageJobProcessor: () => MessageJobProcessor, MessageRequestSchema: () => MessageRequestSchema, MessageResultSchema: () => MessageResultSchema, MessageRetryHandler: () => MessageRetryHandler, MessageStatus: () => MessageStatus, RecipientResultSchema: () => RecipientResultSchema, RecipientSchema: () => RecipientSchema, SchedulingOptionsSchema: () => SchedulingOptionsSchema, SendingOptionsSchema: () => SendingOptionsSchema, SingleMessageSender: () => SingleMessageSender, VariableMapSchema: () => VariableMapSchema, VariableReplacer: () => VariableReplacer, VariableUtils: () => VariableUtils, defaultVariableReplacer: () => defaultVariableReplacer }); module.exports = __toCommonJS(index_exports); // src/types/message.types.ts var import_zod = require("zod"); var MessageStatus = /* @__PURE__ */ ((MessageStatus2) => { MessageStatus2["QUEUED"] = "QUEUED"; MessageStatus2["SENDING"] = "SENDING"; MessageStatus2["SENT"] = "SENT"; MessageStatus2["DELIVERED"] = "DELIVERED"; MessageStatus2["FAILED"] = "FAILED"; MessageStatus2["CLICKED"] = "CLICKED"; MessageStatus2["CANCELLED"] = "CANCELLED"; return MessageStatus2; })(MessageStatus || {}); var MessageEventType = /* @__PURE__ */ ((MessageEventType2) => { MessageEventType2["TEMPLATE_CREATED"] = "template.created"; MessageEventType2["TEMPLATE_APPROVED"] = "template.approved"; MessageEventType2["TEMPLATE_REJECTED"] = "template.rejected"; MessageEventType2["TEMPLATE_UPDATED"] = "template.updated"; MessageEventType2["TEMPLATE_DELETED"] = "template.deleted"; MessageEventType2["MESSAGE_QUEUED"] = "message.queued"; MessageEventType2["MESSAGE_SENT"] = "message.sent"; MessageEventType2["MESSAGE_DELIVERED"] = "message.delivered"; MessageEventType2["MESSAGE_FAILED"] = "message.failed"; MessageEventType2["MESSAGE_CLICKED"] = "message.clicked"; MessageEventType2["MESSAGE_CANCELLED"] = "message.cancelled"; MessageEventType2["CHANNEL_CREATED"] = "channel.created"; MessageEventType2["CHANNEL_VERIFIED"] = "channel.verified"; MessageEventType2["SENDER_NUMBER_ADDED"] = "sender_number.added"; MessageEventType2["QUOTA_WARNING"] = "system.quota_warning"; MessageEventType2["QUOTA_EXCEEDED"] = "system.quota_exceeded"; MessageEventType2["PROVIDER_ERROR"] = "system.provider_error"; return MessageEventType2; })(MessageEventType || {}); var VariableMapSchema = import_zod.z.record(import_zod.z.string(), import_zod.z.union([import_zod.z.string(), import_zod.z.number(), import_zod.z.date()])); var RecipientSchema = import_zod.z.object({ phoneNumber: import_zod.z.string().regex(/^[0-9]{10,11}$/), variables: VariableMapSchema.optional(), metadata: import_zod.z.record(import_zod.z.string(), import_zod.z.any()).optional() }); var SchedulingOptionsSchema = import_zod.z.object({ scheduledAt: import_zod.z.date().min(/* @__PURE__ */ new Date()), timezone: import_zod.z.string().optional(), retryCount: import_zod.z.number().min(0).max(5).optional().default(3) }); var SendingOptionsSchema = import_zod.z.object({ priority: import_zod.z.enum(["high", "normal", "low"]).optional().default("normal"), ttl: import_zod.z.number().min(0).optional(), failover: import_zod.z.object({ enabled: import_zod.z.boolean(), fallbackChannel: import_zod.z.enum(["sms", "lms"]).optional(), fallbackContent: import_zod.z.string().optional() }).optional(), deduplication: import_zod.z.object({ enabled: import_zod.z.boolean(), window: import_zod.z.number().min(0).max(3600) }).optional(), tracking: import_zod.z.object({ enabled: import_zod.z.boolean(), webhookUrl: import_zod.z.string().url().optional() }).optional() }); var MessageRequestSchema = import_zod.z.object({ templateId: import_zod.z.string().min(1), recipients: import_zod.z.array(RecipientSchema).min(1).max(1e4), variables: VariableMapSchema, scheduling: SchedulingOptionsSchema.optional(), options: SendingOptionsSchema.optional() }); var MessageErrorSchema = import_zod.z.object({ code: import_zod.z.string(), message: import_zod.z.string(), details: import_zod.z.record(import_zod.z.string(), import_zod.z.any()).optional() }); var RecipientResultSchema = import_zod.z.object({ phoneNumber: import_zod.z.string(), messageId: import_zod.z.string().optional(), status: import_zod.z.nativeEnum(MessageStatus), error: MessageErrorSchema.optional(), metadata: import_zod.z.record(import_zod.z.string(), import_zod.z.any()).optional() }); var MessageResultSchema = import_zod.z.object({ requestId: import_zod.z.string(), results: import_zod.z.array(RecipientResultSchema), summary: import_zod.z.object({ total: import_zod.z.number().min(0), queued: import_zod.z.number().min(0), sent: import_zod.z.number().min(0), failed: import_zod.z.number().min(0) }), metadata: import_zod.z.object({ createdAt: import_zod.z.date(), provider: import_zod.z.string(), templateId: import_zod.z.string() }) }); // src/sender/single.sender.ts var SingleMessageSender = class { constructor() { this.providers = /* @__PURE__ */ new Map(); this.templates = /* @__PURE__ */ new Map(); } // Template cache addProvider(provider) { this.providers.set(provider.id, provider); } removeProvider(providerId) { this.providers.delete(providerId); } async send(request) { const requestId = this.generateRequestId(); const results = []; const template = await this.getTemplate(request.templateId); if (!template) { throw new Error(`Template ${request.templateId} not found`); } const provider = this.providers.get(template.provider); if (!provider) { throw new Error(`Provider ${template.provider} not found`); } for (const recipient of request.recipients) { try { const result = await this.sendToRecipient( provider, template, recipient, request.variables, request.options ); results.push(result); } catch (error) { results.push({ phoneNumber: recipient.phoneNumber, status: "FAILED" /* FAILED */, error: { code: "SEND_ERROR", message: error instanceof Error ? error.message : "Unknown error" }, metadata: recipient.metadata }); } } const summary = this.calculateSummary(results); return { requestId, results, summary, metadata: { createdAt: /* @__PURE__ */ new Date(), provider: template.provider, templateId: request.templateId } }; } async sendToRecipient(provider, template, recipient, commonVariables, options) { const variables = { ...commonVariables, ...recipient.variables }; const providerRequest = { templateCode: template.code, phoneNumber: recipient.phoneNumber, variables, options }; const providerResult = await provider.send(providerRequest); return { phoneNumber: recipient.phoneNumber, messageId: providerResult.messageId, status: providerResult.status, error: providerResult.error, metadata: recipient.metadata }; } async getTemplate(templateId) { if (this.templates.has(templateId)) { return this.templates.get(templateId); } const template = { id: templateId, code: "TEMPLATE_CODE", provider: "mock-provider", variables: [], content: "Mock template content" }; this.templates.set(templateId, template); return template; } calculateSummary(results) { return { total: results.length, queued: results.filter((r) => r.status === "QUEUED" /* QUEUED */).length, sent: results.filter((r) => r.status === "SENT" /* SENT */).length, failed: results.filter((r) => r.status === "FAILED" /* FAILED */).length }; } generateRequestId() { return `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; } async cancelMessage(messageId) { throw new Error("Not implemented"); } async getMessageStatus(messageId) { throw new Error("Not implemented"); } async resendMessage(messageId, options) { throw new Error("Not implemented"); } }; // src/sender/bulk.sender.ts var BulkMessageSender = class { constructor(singleSender) { this.activeBulkJobs = /* @__PURE__ */ new Map(); this.singleSender = singleSender; } async sendBulk(request) { const requestId = this.generateRequestId(); const batchSize = request.options?.batchSize || 100; const batchDelay = request.options?.batchDelay || 1e3; const batches = this.createBatches(request.recipients, batchSize); const bulkResult = { requestId, totalRecipients: request.recipients.length, batches: [], summary: { queued: request.recipients.length, sent: 0, failed: 0, processing: 0 }, createdAt: /* @__PURE__ */ new Date() }; const bulkJob = { id: requestId, request, result: bulkResult, status: "processing", createdAt: /* @__PURE__ */ new Date() }; this.activeBulkJobs.set(requestId, bulkJob); this.processBatchesAsync(bulkJob, batches, batchDelay); return bulkResult; } async processBatchesAsync(bulkJob, batches, batchDelay) { try { for (let i = 0; i < batches.length; i++) { const batch = batches[i]; const batchId = `${bulkJob.id}_batch_${i + 1}`; const batchResult = { batchId, batchNumber: i + 1, recipients: [], status: "processing", createdAt: /* @__PURE__ */ new Date() }; bulkJob.result.batches.push(batchResult); bulkJob.result.summary.processing += batch.length; bulkJob.result.summary.queued -= batch.length; try { const batchRecipients = await this.processBatch( bulkJob.request, batch, batchId ); batchResult.recipients = batchRecipients; batchResult.status = "completed"; batchResult.completedAt = /* @__PURE__ */ new Date(); const sent = batchRecipients.filter((r) => r.status === "SENT" /* SENT */).length; const failed = batchRecipients.filter((r) => r.status === "FAILED" /* FAILED */).length; bulkJob.result.summary.sent += sent; bulkJob.result.summary.failed += failed; bulkJob.result.summary.processing -= batch.length; } catch (error) { batchResult.status = "failed"; batchResult.completedAt = /* @__PURE__ */ new Date(); batchResult.recipients = batch.map((recipient) => ({ phoneNumber: recipient.phoneNumber, status: "FAILED" /* FAILED */, error: { code: "BATCH_ERROR", message: error instanceof Error ? error.message : "Batch processing failed" }, metadata: recipient.metadata })); bulkJob.result.summary.failed += batch.length; bulkJob.result.summary.processing -= batch.length; } if (i < batches.length - 1) { await this.delay(batchDelay); } } bulkJob.status = "completed"; bulkJob.result.completedAt = /* @__PURE__ */ new Date(); } catch (error) { bulkJob.status = "failed"; bulkJob.result.completedAt = /* @__PURE__ */ new Date(); } } async processBatch(request, batchRecipients, batchId) { const results = []; const maxConcurrency = request.options?.maxConcurrency || 10; const promises = []; for (let i = 0; i < batchRecipients.length; i += maxConcurrency) { const chunk = batchRecipients.slice(i, i + maxConcurrency); const chunkPromises = chunk.map( (recipient) => this.processRecipient(request, recipient) ); const chunkResults = await Promise.allSettled(chunkPromises); for (const result of chunkResults) { if (result.status === "fulfilled") { results.push(result.value); } else { results.push({ phoneNumber: "unknown", status: "FAILED" /* FAILED */, error: { code: "PROCESSING_ERROR", message: result.reason?.message || "Unknown processing error" } }); } } } return results; } async processRecipient(request, recipient) { try { const variables = { ...request.commonVariables, ...recipient.variables }; const messageRequest = { templateId: request.templateId, recipients: [{ phoneNumber: recipient.phoneNumber, variables: {}, metadata: recipient.metadata }], variables, options: request.options }; const result = await this.singleSender.send(messageRequest); return result.results[0]; } catch (error) { return { phoneNumber: recipient.phoneNumber, status: "FAILED" /* FAILED */, error: { code: "RECIPIENT_ERROR", message: error instanceof Error ? error.message : "Unknown error" }, metadata: recipient.metadata }; } } createBatches(items, batchSize) { const batches = []; for (let i = 0; i < items.length; i += batchSize) { batches.push(items.slice(i, i + batchSize)); } return batches; } delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } generateRequestId() { return `bulk_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; } async getBulkStatus(requestId) { const job = this.activeBulkJobs.get(requestId); return job ? job.result : null; } async cancelBulkJob(requestId) { const job = this.activeBulkJobs.get(requestId); if (!job) { return false; } job.status = "cancelled"; for (const batch of job.result.batches) { if (batch.status === "pending" || batch.status === "processing") { batch.status = "failed"; batch.completedAt = /* @__PURE__ */ new Date(); } } return true; } async retryFailedBatch(requestId, batchId) { const job = this.activeBulkJobs.get(requestId); if (!job) { return null; } const batch = job.result.batches.find((b) => b.batchId === batchId); if (!batch || batch.status !== "failed") { return null; } batch.status = "processing"; batch.createdAt = /* @__PURE__ */ new Date(); delete batch.completedAt; try { const failedRecipients = batch.recipients.filter((r) => r.status === "FAILED" /* FAILED */).map((r) => ({ phoneNumber: r.phoneNumber, variables: {}, metadata: r.metadata })); const retryResults = await this.processBatch( job.request, failedRecipients, batchId ); batch.recipients = retryResults; batch.status = "completed"; batch.completedAt = /* @__PURE__ */ new Date(); return batch; } catch (error) { batch.status = "failed"; batch.completedAt = /* @__PURE__ */ new Date(); return batch; } } cleanup() { const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1e3); for (const [id, job] of this.activeBulkJobs) { if (job.status === "completed" && job.createdAt < oneDayAgo) { this.activeBulkJobs.delete(id); } } } }; // src/queue/job.processor.ts var import_events = require("events"); var import_core = require("@k-msg/core"); var JobProcessor = class extends import_events.EventEmitter { constructor(options) { super(); this.options = options; this.handlers = /* @__PURE__ */ new Map(); this.queue = []; this.processing = /* @__PURE__ */ new Set(); this.isRunning = false; this.metrics = { processed: 0, succeeded: 0, failed: 0, retried: 0, activeJobs: 0, queueSize: 0, averageProcessingTime: 0 }; if (options.rateLimiter) { this.rateLimiter = new import_core.RateLimiter( options.rateLimiter.maxRequests, options.rateLimiter.windowMs ); } if (options.circuitBreaker) { this.circuitBreaker = new import_core.CircuitBreaker(options.circuitBreaker); } } /** * Register a job handler */ handle(jobType, handler) { this.handlers.set(jobType, handler); } /** * Add a job to the queue */ async add(jobType, data, options = {}) { const jobId = `${jobType}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; const now = /* @__PURE__ */ new Date(); const job = { id: jobId, type: jobType, data, priority: options.priority || 5, attempts: 0, maxAttempts: options.maxAttempts || this.options.maxRetries, delay: options.delay || 0, createdAt: now, processAt: new Date(now.getTime() + (options.delay || 0)), metadata: options.metadata || {} }; const insertIndex = this.queue.findIndex( (existingJob) => existingJob.priority < job.priority ); if (insertIndex === -1) { this.queue.push(job); } else { this.queue.splice(insertIndex, 0, job); } this.updateMetrics(); this.emit("job:added", job); return jobId; } /** * Start processing jobs */ start() { if (this.isRunning) { return; } this.isRunning = true; this.scheduleNextPoll(); this.emit("processor:started"); } /** * Stop processing jobs */ async stop() { this.isRunning = false; if (this.pollTimer) { clearTimeout(this.pollTimer); this.pollTimer = void 0; } while (this.processing.size > 0) { await new Promise((resolve) => setTimeout(resolve, 100)); } this.emit("processor:stopped"); } /** * Get current metrics */ getMetrics() { return { ...this.metrics }; } /** * Get queue status */ getQueueStatus() { const failed = this.queue.filter((job) => job.failedAt).length; return { pending: this.queue.length - failed, processing: this.processing.size, failed, totalProcessed: this.metrics.processed }; } /** * Remove completed jobs from queue */ cleanup() { const initialLength = this.queue.length; this.queue = this.queue.filter( (job) => !job.completedAt && !job.failedAt ); const removed = initialLength - this.queue.length; this.updateMetrics(); return removed; } /** * Get specific job by ID */ getJob(jobId) { return this.queue.find((job) => job.id === jobId); } /** * Remove job from queue */ removeJob(jobId) { const index = this.queue.findIndex((job) => job.id === jobId); if (index !== -1) { this.queue.splice(index, 1); this.processing.delete(jobId); this.updateMetrics(); return true; } return false; } scheduleNextPoll() { if (!this.isRunning) { return; } this.pollTimer = setTimeout(() => { this.processJobs(); this.scheduleNextPoll(); }, this.options.pollInterval); } async processJobs() { const availableSlots = this.options.concurrency - this.processing.size; if (availableSlots <= 0) { return; } const now = /* @__PURE__ */ new Date(); const readyJobs = this.queue.filter( (job) => !job.completedAt && !job.failedAt && !this.processing.has(job.id) && job.processAt <= now ).slice(0, availableSlots); for (const job of readyJobs) { this.processJob(job); } } async processJob(job) { const handler = this.handlers.get(job.type); if (!handler) { this.failJob(job, `No handler registered for job type: ${job.type}`); return; } this.processing.add(job.id); job.attempts++; this.metrics.activeJobs++; const startTime = Date.now(); try { if (this.rateLimiter) { await this.rateLimiter.acquire(); } const executeJob = async () => handler(job); const result = this.circuitBreaker ? await this.circuitBreaker.execute(executeJob) : await executeJob(); job.completedAt = /* @__PURE__ */ new Date(); this.processing.delete(job.id); this.metrics.activeJobs--; this.metrics.succeeded++; this.metrics.processed++; const processingTime = Date.now() - startTime; this.updateAverageProcessingTime(processingTime); this.emit("job:completed", { job, result, processingTime }); } catch (error) { this.processing.delete(job.id); this.metrics.activeJobs--; const shouldRetry = job.attempts < job.maxAttempts; if (shouldRetry) { const retryDelay = this.getRetryDelay(job.attempts); job.processAt = new Date(Date.now() + retryDelay); job.error = error instanceof Error ? error.message : String(error); this.metrics.retried++; this.emit("job:retry", { job, error, retryDelay }); } else { this.failJob(job, error instanceof Error ? error.message : String(error)); } } this.updateMetrics(); } failJob(job, error) { job.failedAt = /* @__PURE__ */ new Date(); job.error = error; this.metrics.failed++; this.metrics.processed++; this.emit("job:failed", { job, error }); } getRetryDelay(attempt) { const delayIndex = Math.min(attempt - 1, this.options.retryDelays.length - 1); return this.options.retryDelays[delayIndex] || this.options.retryDelays[this.options.retryDelays.length - 1]; } updateMetrics() { this.metrics.queueSize = this.queue.length; this.metrics.lastProcessedAt = /* @__PURE__ */ new Date(); } updateAverageProcessingTime(newTime) { const totalProcessed = this.metrics.succeeded + this.metrics.failed; if (totalProcessed === 1) { this.metrics.averageProcessingTime = newTime; } else { this.metrics.averageProcessingTime = (this.metrics.averageProcessingTime * (totalProcessed - 1) + newTime) / totalProcessed; } } }; var MessageJobProcessor = class extends JobProcessor { constructor(options = {}) { super({ concurrency: 5, retryDelays: [1e3, 5e3, 15e3, 6e4], // 1s, 5s, 15s, 1m maxRetries: 3, pollInterval: 1e3, enableMetrics: true, ...options }); this.setupMessageHandlers(); } setupMessageHandlers() { this.handle("send_message", async (job) => { return this.processSingleMessage(job); }); this.handle("send_bulk_messages", async (job) => { return this.processBulkMessages(job); }); this.handle("update_delivery_status", async (job) => { return this.processDeliveryUpdate(job); }); this.handle("send_scheduled_message", async (job) => { return this.processScheduledMessage(job); }); } async processSingleMessage(job) { const { data: messageRequest } = job; this.emit("message:processing", { type: "message.queued" /* MESSAGE_QUEUED */, timestamp: /* @__PURE__ */ new Date(), data: { requestId: job.id, messageRequest }, metadata: job.metadata }); const results = messageRequest.recipients.map((recipient) => ({ phoneNumber: recipient.phoneNumber, messageId: `msg_${Date.now()}_${Math.random().toString(36).substr(2, 6)}`, status: "QUEUED" /* QUEUED */, metadata: recipient.metadata })); const result = { requestId: job.id, results, summary: { total: messageRequest.recipients.length, queued: messageRequest.recipients.length, sent: 0, failed: 0 }, metadata: { createdAt: /* @__PURE__ */ new Date(), provider: "default", templateId: messageRequest.templateId } }; this.emit("message:queued", { type: "message.queued" /* MESSAGE_QUEUED */, timestamp: /* @__PURE__ */ new Date(), data: result, metadata: job.metadata }); return result; } async processBulkMessages(job) { const { data: messageRequests } = job; const results = []; for (const messageRequest of messageRequests) { const singleJob = { ...job, id: `${job.id}_${results.length}`, data: messageRequest }; const result = await this.processSingleMessage(singleJob); results.push(result); } return results; } async processDeliveryUpdate(job) { const { data: deliveryReport } = job; this.emit("delivery:updated", { type: "message.delivered" /* MESSAGE_DELIVERED */, timestamp: /* @__PURE__ */ new Date(), data: deliveryReport, metadata: job.metadata }); } async processScheduledMessage(job) { const { data: messageRequest } = job; const scheduledAt = messageRequest.scheduling?.scheduledAt; if (scheduledAt && scheduledAt > /* @__PURE__ */ new Date()) { throw new Error(`Message scheduled for ${scheduledAt.toISOString()}, rescheduling`); } return this.processSingleMessage(job); } /** * Add a message to the processing queue */ async queueMessage(messageRequest, options = {}) { const priority = options.priority || (messageRequest.options?.priority === "high" ? 10 : messageRequest.options?.priority === "low" ? 1 : 5); const delay = options.delay || 0; return this.add("send_message", messageRequest, { priority, delay, metadata: options.metadata }); } /** * Add bulk messages to the processing queue */ async queueBulkMessages(messageRequests, options = {}) { return this.add("send_bulk_messages", messageRequests, { priority: options.priority || 3, delay: options.delay || 0, metadata: options.metadata }); } /** * Schedule a message for future delivery */ async scheduleMessage(messageRequest, scheduledAt, options = {}) { const delay = Math.max(0, scheduledAt.getTime() - Date.now()); return this.add("send_scheduled_message", messageRequest, { priority: 5, delay, metadata: options.metadata }); } }; // src/queue/retry.handler.ts var import_events2 = require("events"); var import_core2 = require("@k-msg/core"); var MessageRetryHandler = class extends import_events2.EventEmitter { constructor(options) { super(); this.options = options; this.retryQueue = []; this.processing = /* @__PURE__ */ new Set(); this.isRunning = false; this.defaultPolicy = { maxAttempts: 3, backoffMultiplier: 2, initialDelay: 5e3, // 5 seconds maxDelay: 3e5, // 5 minutes jitter: true, retryableStatuses: ["FAILED" /* FAILED */], retryableErrorCodes: [ "NETWORK_TIMEOUT", "PROVIDER_CONNECTION_FAILED", "PROVIDER_RATE_LIMITED", "PROVIDER_SERVICE_UNAVAILABLE" ] }; this.options.policy = { ...this.defaultPolicy, ...this.options.policy }; this.metrics = { totalRetries: 0, successfulRetries: 0, failedRetries: 0, exhaustedRetries: 0, queueSize: 0, averageRetryDelay: 0 }; } /** * Start the retry handler */ start() { if (this.isRunning) { return; } this.isRunning = true; this.scheduleNextCheck(); this.emit("handler:started"); } /** * Stop the retry handler */ async stop() { this.isRunning = false; if (this.checkTimer) { clearTimeout(this.checkTimer); this.checkTimer = void 0; } while (this.processing.size > 0) { await new Promise((resolve) => setTimeout(resolve, 100)); } this.emit("handler:stopped"); } /** * Add a failed delivery for retry */ async addForRetry(deliveryReport) { if (!this.shouldRetry(deliveryReport)) { return false; } const existingItem = this.retryQueue.find( (item) => item.messageId === deliveryReport.messageId ); if (existingItem) { return this.updateRetryItem(existingItem, deliveryReport); } const retryItem = await this.createRetryItem(deliveryReport); if (this.retryQueue.length >= this.options.maxQueueSize) { this.cleanupQueue(); if (this.retryQueue.length >= this.options.maxQueueSize) { this.emit("queue:full", { rejected: deliveryReport }); return false; } } this.retryQueue.push(retryItem); this.updateMetrics(); this.emit("retry:queued", { type: "message.queued" /* MESSAGE_QUEUED */, timestamp: /* @__PURE__ */ new Date(), data: retryItem, metadata: deliveryReport.metadata }); return true; } /** * Cancel retry for a specific message */ cancelRetry(messageId) { const item = this.retryQueue.find((item2) => item2.messageId === messageId); if (item) { item.status = "cancelled"; item.updatedAt = /* @__PURE__ */ new Date(); this.updateMetrics(); this.emit("retry:cancelled", item); return true; } return false; } /** * Get retry status for a message */ getRetryStatus(messageId) { return this.retryQueue.find((item) => item.messageId === messageId); } /** * Get all retry queue items */ getRetryQueue() { return [...this.retryQueue]; } /** * Get metrics */ getMetrics() { return { ...this.metrics }; } /** * Clean up completed/exhausted retry items */ cleanup() { const initialLength = this.retryQueue.length; this.retryQueue = this.retryQueue.filter( (item) => item.status === "pending" || item.status === "processing" ); const removed = initialLength - this.retryQueue.length; this.updateMetrics(); return removed; } scheduleNextCheck() { if (!this.isRunning) { return; } this.checkTimer = setTimeout(() => { this.processRetryQueue(); this.scheduleNextCheck(); }, this.options.checkInterval); } async processRetryQueue() { const now = /* @__PURE__ */ new Date(); const readyItems = this.retryQueue.filter( (item) => item.status === "pending" && item.nextRetryAt <= now && !this.processing.has(item.id) ); for (const item of readyItems) { this.processRetryItem(item); } } async processRetryItem(item) { this.processing.add(item.id); item.status = "processing"; item.updatedAt = /* @__PURE__ */ new Date(); try { const attempt = { messageId: item.messageId, phoneNumber: item.phoneNumber, attemptNumber: item.attempts.length + 1, scheduledAt: /* @__PURE__ */ new Date(), provider: item.originalDeliveryReport.attempts[0]?.provider || "unknown", templateId: item.originalDeliveryReport.metadata.templateId || "", variables: item.originalDeliveryReport.metadata.variables || {}, metadata: item.originalDeliveryReport.metadata }; item.attempts.push(attempt); this.emit("retry:started", { type: "message.queued" /* MESSAGE_QUEUED */, timestamp: /* @__PURE__ */ new Date(), data: { item, attempt }, metadata: item.originalDeliveryReport.metadata }); const result = await this.executeRetry(attempt); item.status = "exhausted"; this.processing.delete(item.id); this.metrics.successfulRetries++; this.metrics.totalRetries++; this.updateMetrics(); await this.options.onRetrySuccess?.(item, result); this.emit("retry:success", { type: "message.sent" /* MESSAGE_SENT */, timestamp: /* @__PURE__ */ new Date(), data: { item, attempt, result }, metadata: item.originalDeliveryReport.metadata }); } catch (error) { this.processing.delete(item.id); this.metrics.failedRetries++; this.metrics.totalRetries++; const maxAttempts = this.options.policy.maxAttempts; const shouldRetryAgain = item.attempts.length < maxAttempts; if (shouldRetryAgain) { const nextDelay = this.calculateRetryDelay(item.attempts.length); item.nextRetryAt = new Date(Date.now() + nextDelay); item.status = "pending"; } else { item.status = "exhausted"; this.metrics.exhaustedRetries++; await this.options.onRetryExhausted?.(item); this.emit("retry:exhausted", { type: "message.failed" /* MESSAGE_FAILED */, timestamp: /* @__PURE__ */ new Date(), data: { item, finalError: error }, metadata: item.originalDeliveryReport.metadata }); } item.updatedAt = /* @__PURE__ */ new Date(); this.updateMetrics(); await this.options.onRetryFailed?.(item, error); this.emit("retry:failed", { type: "message.failed" /* MESSAGE_FAILED */, timestamp: /* @__PURE__ */ new Date(), data: { item, error, willRetry: shouldRetryAgain }, metadata: item.originalDeliveryReport.metadata }); } } async executeRetry(attempt) { return import_core2.RetryHandler.execute( async () => { if (Math.random() < 0.7) { return { messageId: attempt.messageId, status: "sent", sentAt: /* @__PURE__ */ new Date() }; } else { throw new Error("Retry failed"); } }, { maxAttempts: 1, // We handle retries at a higher level initialDelay: 0, retryCondition: () => false // No retries at this level } ); } shouldRetry(deliveryReport) { const { policy } = this.options; if (!policy.retryableStatuses.includes(deliveryReport.status)) { return false; } let errorToCheck = deliveryReport.error; if (!errorToCheck && deliveryReport.attempts.length > 0) { const latestAttempt = deliveryReport.attempts[deliveryReport.attempts.length - 1]; errorToCheck = latestAttempt.error; } if (errorToCheck) { const isRetryableError = policy.retryableErrorCodes.includes(errorToCheck.code); if (!isRetryableError) { return false; } } return deliveryReport.attempts.length < policy.maxAttempts; } async createRetryItem(deliveryReport) { const initialDelay = this.calculateRetryDelay(deliveryReport.attempts.length); return { id: `retry_${deliveryReport.messageId}_${Date.now()}`, messageId: deliveryReport.messageId, phoneNumber: deliveryReport.phoneNumber, originalDeliveryReport: deliveryReport, attempts: [], nextRetryAt: new Date(Date.now() + initialDelay), status: "pending", createdAt: /* @__PURE__ */ new Date(), updatedAt: /* @__PURE__ */ new Date() }; } updateRetryItem(item, deliveryReport) { if (item.status === "exhausted" || item.status === "cancelled") { return false; } item.originalDeliveryReport = deliveryReport; item.updatedAt = /* @__PURE__ */ new Date(); if (item.status === "pending") { const nextDelay = this.calculateRetryDelay(item.attempts.length); item.nextRetryAt = new Date(Date.now() + nextDelay); } return true; } calculateRetryDelay(attemptNumber) { const { policy } = this.options; let delay = policy.initialDelay * Math.pow(policy.backoffMultiplier, attemptNumber); delay = Math.min(delay, policy.maxDelay); if (policy.jitter) { const jitterAmount = delay * 0.1; delay += (Math.random() - 0.5) * 2 * jitterAmount; } return Math.max(0, delay); } cleanupQueue() { const cutoffTime = new Date(Date.now() - 24 * 60 * 60 * 1e3); this.retryQueue = this.retryQueue.filter( (item) => item.status === "pending" || item.status === "processing" || item.status === "exhausted" && item.updatedAt > cutoffTime ); } updateMetrics() { this.metrics.queueSize = this.retryQueue.length; this.metrics.lastRetryAt = /* @__PURE__ */ new Date(); const pendingItems = this.retryQueue.filter((item) => item.status === "pending"); if (pendingItems.length > 0) { const totalDelay = pendingItems.reduce((sum, item) => { return sum + Math.max(0, item.nextRetryAt.getTime() - Date.now()); }, 0); this.metrics.averageRetryDelay = totalDelay / pendingItems.length; } } }; // src/delivery/tracker.ts var import_events3 = require("events"); var DeliveryTracker = class extends import_events3.EventEmitter { constructor(options) { super(); this.options = options; this.trackingRecords = /* @__PURE__ */ new Map(); this.statusIndex = /* @__PURE__ */ new Map(); this.webhookQueue = []; this.isRunning = false; this.defaultOptions = { trackingInterval: 5e3, // Check every 5 seconds maxTrackingDuration: 864e5, // 24 hours batchSize: 100, enableWebhooks: true, webhookRetries: 3, webhookTimeout: 5e3, persistence: { enabled: true, retentionDays: 30 } }; this.options = { ...this.defaultOptions, ...options }; this.stats = { totalMessages: 0, byStatus: {}, byProvider: {}, averageDeliveryTime: 0, deliveryRate: 0, failureRate: 0, lastUpdated: /* @__PURE__ */ new Date() }; Object.values(MessageStatus).forEach((status) => { this.stats.byStatus[status] = 0; this.statusIndex.set(status, /* @__PURE__ */ new Set()); }); } /** * Start delivery tracking */ start() { if (this.isRunning) { return; } this.isRunning = true; this.scheduleTracking(); this.emit("tracker:started"); } /** * Stop delivery tracking */ stop() { this.isRunning = false; if (this.trackingTimer) { clearTimeout(this.trackingTimer); this.trackingTimer = void 0; } this.emit("tracker:stopped"); } /** * Start tracking a message */ async trackMessage(messageId, phoneNumber, templateId, provider, options = {}) { const now = /* @__PURE__ */ new Date(); const expiresAt = new Date(now.getTime() + this.options.maxTrackingDuration); const initialStatus = options.initialStatus || "QUEUED" /* QUEUED */; const deliveryReport = { messageId, phoneNumber, status: initialStatus, attempts: [{ attemptNumber: 1, attemptedAt: now, status: initialStatus, provider }], metadata: options.metadata || {} }; const record = { messageId, phoneNumber, templateId, provider, currentStatus: initialStatus, statusHistory: [{ status: initialStatus, timestamp: now, provider, source: "system" }], deliveryReport, webhooks: options.webhooks || [], createdAt: now, updatedAt: now, expiresAt, metadata: options.metadata || {} }; this.trackingRecords.set(messageId, record); this.statusIndex.get(initialStatus)?.add(messageId); this.updateStats(); this.emit("tracking:started", { type: "message.queued" /* MESSAGE_QUEUED */, timestamp: now, data: record, metadata: record.metadata }); if (this.options.enableWebhooks && record.webhooks.length > 0) { const event = { id: `evt_${messageId}_${Date.now()}`, type: "message.queued" /* MESSAGE_QUEUED */, timestamp: now, data: deliveryReport, metadata: record.metadata }; this.queueWebhook(record, event); } } /** * Update message status */ async updateStatus(messageId, status, details = {}) { const record = this.trackingRecords.get(messageId); if (!record) { return false; } const now = /* @__PURE__ */ new Date(); const oldStatus = record.currentStatus; if (!this.isStatusProgression(oldStatus, status)) { return false; } this.statusIndex.get(oldStatus)?.delete(messageId); record.currentStatus = status; record.updatedAt = now; record.statusHistory.push({ status, timestamp: now, provider: details.provider || record.provider, details: details.metadata, source: details.source || "system" }); record.deliveryReport.status = status; record.deliveryReport.metadata = { ...record.deliveryReport.metadata, ...details.metadata }; if (details.sentAt) record.deliveryReport.sentAt = details.sentAt; if (details.deliveredAt) record.deliveryReport.deliveredAt = details.deliveredAt; if (details.clickedAt) record.deliveryReport.clickedAt = details.clickedAt; if (details.failedAt) record.deliveryReport.failedAt = details.failedAt; if (details.error) record.deliveryReport.error = details.error; record.deliveryReport.attempts.push({ attemptNumber: record.deliveryReport.attempts.length + 1, attemptedAt: now, status, error: details.error, provider: details.provider || record.provider }); this.statusIndex.get(status)?.add(messageId); this.updateStats(); const eventType = this.getEventTypeForStatus(status); const event = { id: `evt_${messageId}_${Date.now()}`, type: eventType, timestamp: now, data: { messageId, previousStatus: oldStatus, currentStatus: status, deliveryReport: record.deliveryReport, ...details }, metadata: record.metadata }; this.emit("status:updated", event); if (this.options.enableWebhooks && record.webhooks.length > 0) { this.queueWebhook(record, event); } if (this.isTerminalStatus(status)) { this.emit("tracking:completed", { ...event, data: { ...event.data, trackingCompleted: true } }); } return true; } /** * Get delivery report for a message */ getDeliveryReport(messageId) { return this.trackingRecords.get(messageId)?.deliveryReport; } /** * Get tracking record for a message */ getTrackingRecord(messageId) { return this.trackingRecords.get(messageId); } /** * Get messages by status */ getMessagesByStatus(status) { const messageIds = this.statusIndex.get(status) || /* @__PURE__ */ new Set(); return Array.from(messageIds).map((id) => this.trackingRecords.get(id)).filter((record) => record !== void 0); } /** * Get delivery statistics */ getStats() { return { ...this.stats }; } /** * Get delivery statistics for a specific time range */ getStatsForPeriod(startDate, endDate) { const records = Array.from(this.trackingRecords.values()).filter( (record) => record.createdAt >= startDate && record.createdAt <= endDate ); const stats = { totalMessages: records.length, byStatus: {}, byProvider: {}, averageDeliveryTime: 0, deliveryRate: 0, failureRate: 0, lastUpdated: /* @__PURE__ */ new Date() }; Object.values(MessageStatus).forEach((status) => { stats.byStatus[status] = 0; }); let totalDeliveryTime = 0; let deliveredCount = 0; let failedCount = 0; records.forEach((record) => { stats.byStatus[record.currentStatus]++; stats.byProvider[record.provider] = (stats.byProvider[record.provider] || 0) + 1; if (record.deliveryReport.deliveredAt && record.deliveryReport.sentAt) { const deliveryTime = record.deliveryReport.deliveredAt.getTime() - record.deliveryReport.sentAt.getTime(); totalDeliveryTime += deliveryTime; deliveredCount++; } if (record.currentStatus === "FAILED" /* FAILED */) { failedCount++; } }); if (deliveredCount > 0) { stats.averageDeliveryTime = totalDeliveryTime / deliveredCount; } if (records.length > 0) { stats.deliveryRate = stats.byStatus["DELIVERED" /* DELIVERED */] / records.length * 100; stats.failureRate = failedCount / records.length * 100; } return stats; } /** * Clean up expired tracking records */ cleanup() { const now = /* @__PURE__ */ new Date(); let removed = 0; for (const [messageId, record] of this.trackingRecords.entries()) { if (record.expiresAt <= now || this.isTerminalStatus(record.currentStatus)) { this.trackingRecords.delete(messageId); this.statusIndex.get(record.currentStatus)?.delete(messageId); removed++; } } if (removed > 0) { this.updateStats(); this.emit("cleanup:completed", { removedCount: removed }); } return removed; } /** * Stop tracking a specific message */ stopTracking(messageId) { const record = this.trackingRecords.get(messageId); if (!record) { return false; } this.trackingRecords.delete(messageId); this.statusIndex.get(record.currentStatus)?.delete(messageId); this.updateStats(); this.emit("tracking:stopped", { type: "message.cancelled" /* MESSAGE_CANCELLED */, timestamp: /* @__PURE__ */ new Date(), data: record, metadata: record.metadata }); return true; } scheduleTracking() { if (!this.isRunning) { return; } this.trackingTimer = setTimeout(() => { this.processTracking(); this.processWebhookQueue(); this.scheduleTracking(); }, this.options.trackingInterval); } async processTracking() { await this.processWebhookQueue(); const shouldCleanup = Date.now() % (60 * 60 * 1e3) < this.options.trackingInterval; if (shouldCleanup) { this.cleanup(); } } async processWebhookQueue() { if (!this.options.enableWebhooks || this.webhookQueue.length === 0) { return; } const batch = this.webhookQueue.splice(0, this.options.batchSize); for (const { record, event } of batch) { for (const webhook of record.webhooks) { if (webhook.events.includes(event.type)) { this.deliverWebhook(webhook, event); } } } } async deliverWebhook(webhook, event) { let lastError; for (let attempt = 1; attempt <= webhook.retries + 1; attempt++) { try { const result = await this.sendWebhook(webhook, event, attempt); if (result.success) { this.emit("webhook:delivered", { webhook, event, result, attempt }); return; } else { lastError = new Error(`HTTP ${result.statusCode}: ${result.error}`); } } catch (error) { lastError = error; } if (attempt <= webhook.retries) { const delay = Math.min(1e3 * Math.pow(2, attempt - 1), 3e4); await new Promise((resolve) => setTimeout(resolve, delay)); } } this.emit("webhook:failed", { webhook, event, error: lastError, attempts: webhook.retries + 1 }); } async sendWebhook(webhook, event, attempt) { const startTime = Date.now(); try { const headers = { "Content-Type": "application/json", "User-Agent": "K-Message-Delivery-Tracker/1.0", ...webhook.headers }; if (webhook.secret) { const payload = JSON.stringify(event); headers["X-Signature"] = `sha256=${webhook.secret}`; } const response = await fetch(webhook.url, { method: "POST", headers, body: JSON.stringify(event), signal: AbortSignal.timeout(webhook.timeout) }); const responseTime = Date.now() - startTime; return { success: response.ok, statusCode: response.status, error: response.ok ? void 0 : response.statusText, responseTime, attempt }; } catch (error) { const responseTime = Date.now() - startTime; return { success: false, error: error instanceof Error ? error.message : "Unknown error", responseTime, attempt }; } } queueWebhook(record, event) { this.webhookQueue.push({ record, event }); } isStatusProgression(oldStatus, newStatus) { const statusOrder = [ "QUEUED" /* QUEUED */, "SENDING" /* SENDING */, "SENT" /* SENT */, "DELIVERED" /* DELIVERED */, "CLICKED" /* CLICKED */ ]; const oldIndex = statusOrder.indexOf(oldStatus); const newIndex = statusOrder.indexOf(newStatus); return newIndex > oldIndex || newStatus === "FAILED" /* FAILED */ || newStatus === "CANCELLED" /* CANCELLED */; } isTerminalStatus(status) { return [ "DELIVERED" /* DELIVERED */, "FAILED" /* FAILED */, "CANCELLED" /* CANCELLED */, "CLICKED" /* CLICKED */ ].includes(status); } getEventTypeForStatus(status) { switch (status) { case "QUEUED" /* QUEUED */: return "message.queued" /* MESSAGE_QUEUED */; case "SENT" /* SENT */: return "message.sent" /* MESSAGE_SENT */; case "DELIVERED" /* DELIVERED */: return "message.delivered" /* MESSAGE_DELIVERED */; case "FAILED" /* FAILED *