| 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, |
| }, |
| }; |
|
|