import type { SmsDeliveryReport, SmsDeliveryStatus, SmsMessage, SmsProvider, SmsSendCallOptions, } from "@suaveplan/sms"; import { constantTimeEqual, defaultHmac, fromBase64, toBase64, utf8, } from "../crypto/default-hmac.js"; import { configMissing, signatureInvalid } from "../error-codes/error-codes.js"; import { httpRequest } from "../http/http.js"; import { parseOptOutKeyword } from "../keywords/keywords.js"; import { recordSend, recordSendDuration, recordSignatureFailure, recordWebhookVerifyDuration, } from "../otel/otel.js"; import type { FetchLike, HmacSign, InboundSmsEvent, OutboundMessage, TwilioConfig, } from "../types/provider.js"; const PROVIDER = "twilio" as const; const DEFAULT_BASE_URL = "https://api.twilio.com"; const STATUS_MAP: Readonly> = Object.freeze({ queued: "queued", accepted: "queued", scheduled: "queued", sending: "queued", sent: "sent", receiving: "queued", received: "delivered", delivered: "delivered", undelivered: "undelivered", failed: "failed", canceled: "failed", }); /** Wire-shape of Twilio's `POST /Messages.json` response. */ interface TwilioMessageResource { readonly sid: string; readonly status: string; readonly to: string; readonly date_created?: string; readonly error_code?: number | null; readonly error_message?: string | null; } /** * Real Twilio SMS provider implementing the `SmsProvider` contract from * `@suaveplan/sms`. Issues a `POST /2010-04-01/Accounts/{AccountSid}/ * Messages.json` form-encoded request authenticated with HTTP Basic * (`AccountSid:AuthToken`); MMS `MediaUrl[]` is forwarded as repeated * `MediaUrl` form parameters. Status fetched via `GET .../Messages/ * {Sid}.json`. Webhook signature verification follows Twilio's * documented `X-Twilio-Signature` HMAC-SHA1 scheme. */ export class TwilioProvider implements SmsProvider { public readonly name = PROVIDER; private readonly config: TwilioConfig; private readonly baseUrl: string; private readonly hmac: HmacSign; private readonly fetch: FetchLike | undefined; public constructor(config: TwilioConfig) { if (!config.accountSid) { throw configMissing(PROVIDER, "accountSid"); } if (!config.authToken) { throw configMissing(PROVIDER, "authToken"); } this.config = config; this.baseUrl = (config.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, ""); this.hmac = config.hmac ?? defaultHmac; this.fetch = config.fetch; } /** Map a raw Twilio status code to the canonical `SmsDeliveryStatus`. */ public mapStatus(raw: string): SmsDeliveryStatus { return STATUS_MAP[raw.toLowerCase()] ?? "failed"; } public async send( message: SmsMessage, options: SmsSendCallOptions = {} ): Promise { const t0 = Date.now(); try { const url = `${this.baseUrl}/2010-04-01/Accounts/${encodeURIComponent(this.config.accountSid)}/Messages.json`; const params = new URLSearchParams(); params.set("To", message.to); params.set("Body", message.body); if (message.from !== undefined) { params.set("From", message.from); } const extras = message as OutboundMessage; if (extras.mediaUrls !== undefined) { for (const u of extras.mediaUrls) { params.append("MediaUrl", u); } } const res = await httpRequest({ method: "POST", url, headers: { Authorization: this.basicAuth() }, body: params.toString(), provider: PROVIDER, ...this.common(options.signal), }); recordSend(PROVIDER, "ok"); return { messageId: res.data.sid, to: res.data.to, status: this.mapStatus(res.data.status), timestamp: res.data.date_created ? new Date(res.data.date_created) : new Date(), ...(res.data.error_code ? { errorCode: String(res.data.error_code) } : {}), ...(res.data.error_message ? { errorMessage: res.data.error_message } : {}), }; } catch (err) { recordSend(PROVIDER, "error"); throw err; } finally { recordSendDuration(PROVIDER, Date.now() - t0); } } public async getStatus(messageId: string): Promise { const url = `${this.baseUrl}/2010-04-01/Accounts/${encodeURIComponent(this.config.accountSid)}/Messages/${encodeURIComponent(messageId)}.json`; const res = await httpRequest({ method: "GET", url, headers: { Authorization: this.basicAuth() }, provider: PROVIDER, ...this.common(undefined), }); return { messageId: res.data.sid, to: res.data.to, status: this.mapStatus(res.data.status), timestamp: res.data.date_created ? new Date(res.data.date_created) : new Date(), ...(res.data.error_code ? { errorCode: String(res.data.error_code) } : {}), ...(res.data.error_message ? { errorMessage: res.data.error_message } : {}), }; } /** * Verify an inbound Twilio webhook per the documented X-Twilio- * Signature scheme: HMAC-SHA1 of `url + sortedKey1 + value1 + …` * over the auth token, Base-64 encoded; constant-time compared * against the supplied signature. */ public async verifyWebhookSignature( url: string, params: Readonly>, signature: string ): Promise { const t0 = Date.now(); try { const sortedKeys = Object.keys(params).sort(); let payload = url; for (const k of sortedKeys) { payload += k; payload += params[k]; } const sig = await this.hmac( "SHA-1", utf8(this.config.authToken), utf8(payload) ); const expected = toBase64(sig); const ok = constantTimeEqual(utf8(expected), utf8(signature)); if (!ok) { recordSignatureFailure(PROVIDER); throw signatureInvalid(PROVIDER, "X-Twilio-Signature mismatch"); } // Touch the decode path so callers may supply Base-64 sigs. fromBase64(expected); return true; } finally { recordWebhookVerifyDuration(PROVIDER, Date.now() - t0); } } /** * Parse a Twilio-shaped inbound webhook body (already-decoded form * params) into the canonical `InboundSmsEvent` discriminated union. * Supports inbound message bodies (`From`/`Body`) and delivery * receipts (`MessageSid` + `MessageStatus`). */ public parseInbound( params: Readonly> ): InboundSmsEvent { if ( params["MessageStatus"] !== undefined && params["MessageSid"] !== undefined ) { return { type: "delivery_receipt", messageId: params["MessageSid"], status: this.mapStatus(params["MessageStatus"]), ...(params["ErrorCode"] !== undefined ? { errorCode: params["ErrorCode"] } : {}), }; } const from = params["From"] ?? ""; const to = params["To"] ?? ""; const body = params["Body"] ?? ""; const keyword = parseOptOutKeyword(body); if (keyword === "opt_out") { return { type: "opt_out", from }; } if (keyword === "opt_in") { return { type: "opt_in", from }; } if (keyword === "help") { return { type: "help", from }; } return { type: "message_received", from, to, body, ...(params["MessageSid"] !== undefined ? { providerId: params["MessageSid"] } : {}), }; } private basicAuth(): string { return `Basic ${toBase64( utf8(`${this.config.accountSid}:${this.config.authToken}`) )}`; } private common(signal: AbortSignal | undefined): { fetch?: FetchLike; signal?: AbortSignal; timeoutMs?: number; } { const out: { fetch?: FetchLike; signal?: AbortSignal; timeoutMs?: number; } = {}; if (this.fetch !== undefined) { out.fetch = this.fetch; } const combinedSignal = signal ?? this.config.signal; if (combinedSignal !== undefined) { out.signal = combinedSignal; } if (this.config.timeoutMs !== undefined) { out.timeoutMs = this.config.timeoutMs; } return out; } }