Initial Commit
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user