const axios = require('axios'); const crypto = require('crypto'); const db = require('../config/db'); const SEND_INTERVAL_MS = Number(process.env.SMS_SEND_INTERVAL_MS || 5000); const MAX_ATTEMPTS = Number(process.env.SMS_MAX_ATTEMPTS || 4); const RATE_LIMIT_BACKOFF_MS = Number(process.env.SMS_RATE_LIMIT_BACKOFF_MS || 60000); const MAX_BACKOFF_MS = Number(process.env.SMS_MAX_BACKOFF_MS || 180000); const DEFAULT_TTL_SECONDS = 24 * 60 * 60; const DEFAULT_PRIORITY = 0; const HIGH_PRIORITY = 100; let sendQueue = Promise.resolve(); let lastSendAt = 0; let rateLimitUntil = 0; function normalizePhone(phone) { const d = phone.replace(/\D/g, ''); if (d.startsWith('255')) return '+' + d; if (d.startsWith('0')) return '+255' + d.slice(1); return '+255' + d; } function delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } function errorDetails(err) { const statusCode = err.response?.status; const body = err.response?.data; const bodyText = body ? ` body=${typeof body === 'string' ? body : JSON.stringify(body).slice(0, 180)}` : ''; return `${err.message}${statusCode ? ` status=${statusCode}` : ''}${bodyText}`.slice(0, 255); } function retryAfterMs(err) { const retryAfter = err.response?.headers?.['retry-after']; if (!retryAfter) return null; const seconds = Number(retryAfter); if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000); const dateMs = Date.parse(retryAfter); if (Number.isFinite(dateMs)) return Math.max(0, dateMs - Date.now()); return null; } async function waitForTurn() { const cooldownMs = Math.max(0, rateLimitUntil - Date.now()); if (cooldownMs > 0) await delay(cooldownMs); const elapsed = Date.now() - lastSendAt; const waitMs = Math.max(0, SEND_INTERVAL_MS - elapsed); if (waitMs > 0) await delay(waitMs); lastSendAt = Date.now(); } function boundedNumber(value, fallback, min, max) { const number = Number(value); if (!Number.isFinite(number)) return fallback; return Math.min(max, Math.max(min, Math.trunc(number))); } function buildPayload(to, message, options = {}) { const ttl = boundedNumber(options.ttl, DEFAULT_TTL_SECONDS, 60, 7 * 24 * 60 * 60); const priority = boundedNumber(options.priority, DEFAULT_PRIORITY, -128, 127); const payload = { id: options.id || crypto.randomUUID(), textMessage: { text: message }, phoneNumbers: [to], ttl, priority, withDeliveryReport: options.withDeliveryReport === true, }; if (options.deviceId) payload.deviceId = options.deviceId; if (options.simNumber !== undefined) { payload.simNumber = boundedNumber(options.simNumber, 1, 1, 3); } return payload; } async function postSMS(payload) { await waitForTurn(); const res = await axios.post( process.env.SMS_GATEWAY_URL, payload, { auth: { username: process.env.SMS_GATEWAY_USER, password: process.env.SMS_GATEWAY_PASS, }, timeout: 10000, } ); return res.data?.id || payload.id || null; } async function sendWithRetry(to, message, options) { let lastError = null; const payload = buildPayload(to, message, options); for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) { try { return { sent: true, gatewayId: await postSMS(payload), error: null }; } catch (err) { lastError = err; const statusCode = err.response?.status; const isRateLimited = statusCode === 429; const canRetry = isRateLimited && attempt < MAX_ATTEMPTS; const gatewayWait = retryAfterMs(err); const backoffMs = Math.min(gatewayWait || RATE_LIMIT_BACKOFF_MS * attempt, MAX_BACKOFF_MS); if (isRateLimited) { rateLimitUntil = Math.max(rateLimitUntil, Date.now() + backoffMs); } if (!canRetry) break; console.warn(`[SMS] Rate limited for ${to}; retrying attempt ${attempt + 1}/${MAX_ATTEMPTS} in ${backoffMs}ms`); await delay(backoffMs); } } return { sent: false, gatewayId: null, error: errorDetails(lastError) }; } async function sendNow(phone, message, options = {}) { const to = normalizePhone(phone); const result = await sendWithRetry(to, message, options); const status = result.sent ? 'sent' : 'failed'; if (result.sent) { console.log(`[SMS] Sent to ${to}: "${message.slice(0, 40)}..."`); } else { console.error(`[SMS] Failed to ${to}:`, result.error); } db.query( 'INSERT INTO sms_log (phone, message, status, gateway_id, error) VALUES (?, ?, ?, ?, ?)', [to, message, status, result.gatewayId, result.error] ).catch(() => {}); return result.sent; } async function sendSMS(phone, message, options = {}) { const queued = sendQueue.then(() => sendNow(phone, message, options)); sendQueue = queued.catch(() => {}); return queued; } module.exports = { sendSMS, priorities: { high: HIGH_PRIORITY, normal: DEFAULT_PRIORITY, low: -50, }, };