86 lines
4.2 KiB
Markdown
86 lines
4.2 KiB
Markdown
# Twilio — `@suaveplan/sms-providers`
|
|
|
|
<!-- begin:auto-docs -->
|
|
_(no submodules)_
|
|
<!-- end:auto-docs -->
|
|
|
|
## Purpose
|
|
|
|
Real Twilio SMS provider implementing the `SmsProvider` contract from `@suaveplan/sms`. Issues authenticated, form-encoded HTTP requests against Twilio's documented Messages API (`POST /2010-04-01/Accounts/{AccountSid}/Messages.json`), normalises Twilio's status vocabulary into the canonical `SmsDeliveryStatus`, fetches delivery status via the corresponding `GET`, and verifies inbound webhooks per Twilio's `X-Twilio-Signature` HMAC-SHA1 scheme. MMS `MediaUrl[]` is forwarded as repeated `MediaUrl` form parameters per Twilio's wire convention.
|
|
|
|
The class holds no in-flight state: every `send` issues exactly one HTTP request through the injectable `FetchLike` seam. Tests bind a `node:http` server on a random port and inject a `fetch` adapter pointing at it; production resolves `globalThis.fetch.bind(globalThis)`.
|
|
|
|
## Features
|
|
|
|
- HTTP Basic auth (`Authorization: Basic base64(AccountSid:AuthToken)`).
|
|
- Form-encoded body via `URLSearchParams` — Twilio rejects JSON bodies.
|
|
- MMS `MediaUrl[]` forwarded as repeated `MediaUrl` params.
|
|
- Canonical status mapping (`queued`, `sending`, `sent`, `delivered`, `undelivered`, `failed`, `canceled`, `receiving`, `received`, `accepted`, `scheduled`).
|
|
- `getStatus(sid)` round-trips the same auth + URL convention.
|
|
- `verifyWebhookSignature(url, params, signature)` implements the documented HMAC-SHA1-over-sorted-form scheme; mismatched signatures throw `SmsProvidersError(SIGNATURE_INVALID)` and increment `sms_providers.signature_failures_total{provider="twilio"}`.
|
|
- `parseInbound(params)` discriminates between message bodies, delivery receipts, and opt-in/opt-out/help keywords using the canonical `parseOptOutKeyword` classifier.
|
|
- OTel — every `send` is counted (`send_total`) and timed (`send_duration_ms`); every signature verify is timed (`webhook_verify_duration_ms`).
|
|
|
|
## Usage: Basic
|
|
|
|
```ts
|
|
import { TwilioProvider } from "@suaveplan/sms-providers";
|
|
|
|
const twilio = new TwilioProvider({
|
|
accountSid: "AC…",
|
|
authToken: "…",
|
|
});
|
|
const report = await twilio.send({
|
|
to: "+15551234567",
|
|
from: "+15559999999",
|
|
body: "Hello",
|
|
});
|
|
console.log(report.messageId, report.status);
|
|
```
|
|
|
|
## Usage: Advanced
|
|
|
|
```ts
|
|
import { TwilioProvider, type FetchLike } from "@suaveplan/sms-providers";
|
|
import { createServer } from "node:http";
|
|
|
|
// Stand up a real HTTP server, point Twilio's FetchLike at it.
|
|
const server = createServer((req, res) => {
|
|
res.writeHead(201, { "content-type": "application/json" });
|
|
res.end(JSON.stringify({ sid: "SMxxx", status: "queued", to: "+1" }));
|
|
});
|
|
await new Promise<void>((r) => server.listen(0, r));
|
|
const { port } = server.address() as { port: number };
|
|
const fetchImpl: FetchLike = async (url, init) =>
|
|
globalThis.fetch(url.replace("https://api.twilio.com", `http://127.0.0.1:${port}`), init);
|
|
|
|
const twilio = new TwilioProvider({
|
|
accountSid: "AC1",
|
|
authToken: "tok",
|
|
fetch: fetchImpl,
|
|
});
|
|
const r = await twilio.send({
|
|
to: "+15551234567",
|
|
from: "+15550000000",
|
|
body: "Hi",
|
|
mediaUrls: ["https://cdn/example.png"],
|
|
} as Parameters<typeof twilio.send>[0]);
|
|
|
|
await new Promise<void>((r) => server.close(() => r()));
|
|
```
|
|
|
|
## API Reference
|
|
|
|
| Symbol | Kind | Notes |
|
|
|---|---|---|
|
|
| `TwilioProvider` | class | Implements `SmsProvider` from `@suaveplan/sms`. |
|
|
| `send(message, options?)` | method | Issues the POST and returns an `SmsDeliveryReport`. |
|
|
| `getStatus(sid)` | method | Round-trips a `GET` for the message resource. |
|
|
| `mapStatus(raw)` | method | Maps a Twilio status string to `SmsDeliveryStatus`. |
|
|
| `verifyWebhookSignature(url, params, sig)` | method | HMAC-SHA1 over `url + sortedKey1 + value1 + …`; throws `SIGNATURE_INVALID` on mismatch. |
|
|
| `parseInbound(params)` | method | Discriminates into the `InboundSmsEvent` union. |
|
|
|
|
## Implementation Notes
|
|
|
|
The `baseUrl` strips any trailing `/` so callers may pass either form. `accountSid` and `authToken` are validated at construction time so credential omissions surface immediately rather than on first call. MMS `mediaUrls` are read off the canonical `OutboundMessage` shape — `SmsMessage & OutboundExtras` — so the call site can pass `mediaUrls` without breaking the `SmsProvider#send` signature.
|