@k-msg/messaging
Version:
AlimTalk messaging core for sending, queuing, and tracking messages
1,590 lines (1,584 loc) • 66.3 kB
JavaScript
// src/types/message.types.ts
import { z } from "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 = z.record(z.string(), z.union([z.string(), z.number(), z.date()]));
var RecipientSchema = z.object({
phoneNumber: z.string().regex(/^[0-9]{10,11}$/),
variables: VariableMapSchema.optional(),
metadata: z.record(z.string(), z.any()).optional()
});
var SchedulingOptionsSchema = z.object({
scheduledAt: z.date().min(/* @__PURE__ */ new Date()),
timezone: z.string().optional(),
retryCount: z.number().min(0).max(5).optional().default(3)
});
var SendingOptionsSchema = z.object({
priority: z.enum(["high", "normal", "low"]).optional().default("normal"),
ttl: z.number().min(0).optional(),
failover: z.object({
enabled: z.boolean(),
fallbackChannel: z.enum(["sms", "lms"]).optional(),
fallbackContent: z.string().optional()
}).optional(),
deduplication: z.object({
enabled: z.boolean(),
window: z.number().min(0).max(3600)
}).optional(),
tracking: z.object({
enabled: z.boolean(),
webhookUrl: z.string().url().optional()
}).optional()
});
var MessageRequestSchema = z.object({
templateId: z.string().min(1),
recipients: z.array(RecipientSchema).min(1).max(1e4),
variables: VariableMapSchema,
scheduling: SchedulingOptionsSchema.optional(),
options: SendingOptionsSchema.optional()
});
var MessageErrorSchema = z.object({
code: z.string(),
message: z.string(),
details: z.record(z.string(), z.any()).optional()
});
var RecipientResultSchema = z.object({
phoneNumber: z.string(),
messageId: z.string().optional(),
status: z.nativeEnum(MessageStatus),
error: MessageErrorSchema.optional(),
metadata: z.record(z.string(), z.any()).optional()
});
var MessageResultSchema = z.object({
requestId: z.string(),
results: z.array(RecipientResultSchema),
summary: z.object({
total: z.number().min(0),
queued: z.number().min(0),
sent: z.number().min(0),
failed: z.number().min(0)
}),
metadata: z.object({
createdAt: z.date(),
provider: z.string(),
templateId: 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
import { EventEmitter } from "events";
import { CircuitBreaker, RateLimiter } from "@k-msg/core";
var JobProcessor = class extends 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 RateLimiter(
options.rateLimiter.maxRequests,
options.rateLimiter.windowMs
);
}
if (options.circuitBreaker) {
this.circuitBreaker = new 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
import { EventEmitter as EventEmitter2 } from "events";
import { RetryHandler as CoreRetryHandler } from "@k-msg/core";
var MessageRetryHandler = class extends EventEmitter2 {
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 CoreRetryHandler.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
import { EventEmitter as EventEmitter3 } from "events";
var DeliveryTracker = class extends EventEmitter3 {
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 */:
return "message.failed" /* MESSAGE_FAILED */;
case "CLICKED" /* CLICKED */:
return "message.clicked" /* MESSAGE_CLICKED */;
default:
return "message.queued" /* MESSAGE_QUEUED */;
}
}
updateStats() {
this.stats.totalMessages = this.trackingRecords.size;
this.stats.lastUpdated = /* @__PURE__ */ new Date();
Object.values(MessageStatus).forEach((status) => {
this.stats.byStatus[status] = 0;
});
this.stats.byProvider = {};
let totalDeliveryTime = 0;
let deliveredCount = 0;
let failedCount = 0;
for (const record of this.trackingRecords.values()) {
this.stats.byStatus[record.currentStatus]++;
this.stats.byProvider[record.provider] = (this.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) {
this.stats.averageDeliveryTime = totalDeliveryTime / deliveredCount;
}
if (this.stats.totalMessages > 0) {
this.stats.deliveryRate = this.stats.byStatus["DELIVERED" /* DELIVERED */] / this.stats.totalMessages * 100;
this.stats.failureRate = failedCount / this.stats.totalMessages * 100;
}
}
};
// src/personalization/variable.replacer.ts
var VariableReplacer = class {
constructor(options = {}) {
this.options = options;
this.defaultOptions = {
variablePattern: /\#\{([^}]+)\}/g,
allowUndefined: false,
undefinedReplacement: "",
caseSensitive: true,
enableFormatting: true,
enableConditionals: true,
enableLoops: true,
maxRecursionDepth: 10
};
this.options = { ...this.defaultOptions, ...options };
}
/**
* Replace variables in content
*/
replace(content, variables) {
const startTime = Date.now();
const originalLength = content.length;
const result = {
content,
variables: [],
missingVariables: [],
errors: [],
metadata: {
originalLength,
finalLength: 0,
variableCount: 0,
replacementTime: 0
}
};
try {
if (this.options.enableConditionals) {
re