Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,280 @@
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<Record<string, SmsDeliveryStatus>> = 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<SmsDeliveryReport> {
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<TwilioMessageResource>({
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<SmsDeliveryReport> {
const url = `${this.baseUrl}/2010-04-01/Accounts/${encodeURIComponent(this.config.accountSid)}/Messages/${encodeURIComponent(messageId)}.json`;
const res = await httpRequest<TwilioMessageResource>({
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<Record<string, string>>,
signature: string
): Promise<boolean> {
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<Record<string, string>>
): 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;
}
}