niahere 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Minimal Twilio REST API helpers shared by all Twilio-based channels
3
+ * (voice, SMS, WhatsApp). No SDK — keeps dependency surface small and
4
+ * the helpers easy to read.
5
+ *
6
+ * Auth: Basic with `${authSid}:${authSecret}`. authSid can be either an
7
+ * Account SID (AC…) or an API Key SID (SK…); Twilio resolves both. The
8
+ * URL path always uses the Account SID — when using an API Key, pass
9
+ * `accountSid` separately.
10
+ */
11
+
12
+ const TWILIO_BASE = "https://api.twilio.com/2010-04-01";
13
+
14
+ export interface TwilioCreds {
15
+ /** Account SID (AC…) — required for the URL path. */
16
+ accountSid: string;
17
+ /** SID used for Basic-auth username. Can be the same Account SID or an API Key SID (SK…). */
18
+ authSid: string;
19
+ /** Basic-auth password (Account Auth Token, or API Key Secret if authSid is SK…). */
20
+ authSecret: string;
21
+ }
22
+
23
+ function basicAuth({ authSid, authSecret }: TwilioCreds): string {
24
+ return `Basic ${Buffer.from(`${authSid}:${authSecret}`).toString("base64")}`;
25
+ }
26
+
27
+ function accountUrl({ accountSid }: TwilioCreds, suffix: string): string {
28
+ return `${TWILIO_BASE}/Accounts/${encodeURIComponent(accountSid)}${suffix}`;
29
+ }
30
+
31
+ async function twilioPost<T = unknown>(creds: TwilioCreds, suffix: string, body: URLSearchParams): Promise<T> {
32
+ const resp = await fetch(accountUrl(creds, suffix), {
33
+ method: "POST",
34
+ headers: { Authorization: basicAuth(creds), "Content-Type": "application/x-www-form-urlencoded" },
35
+ body: body.toString(),
36
+ });
37
+ if (!resp.ok) {
38
+ const text = await resp.text().catch(() => "");
39
+ throw new Error(`Twilio ${suffix} failed: ${resp.status} ${text}`);
40
+ }
41
+ return (await resp.json()) as T;
42
+ }
43
+
44
+ // --- Voice ---
45
+
46
+ export interface PlaceCallOpts extends TwilioCreds {
47
+ to: string;
48
+ from: string;
49
+ twimlUrl: string;
50
+ statusCallbackUrl?: string;
51
+ maxDurationSec?: number;
52
+ }
53
+
54
+ export interface PlaceCallResult {
55
+ callSid: string;
56
+ status: string;
57
+ }
58
+
59
+ export async function placeCall(opts: PlaceCallOpts): Promise<PlaceCallResult> {
60
+ const body = new URLSearchParams({ To: opts.to, From: opts.from, Url: opts.twimlUrl, Method: "POST" });
61
+ if (opts.statusCallbackUrl) {
62
+ body.set("StatusCallback", opts.statusCallbackUrl);
63
+ body.set("StatusCallbackMethod", "POST");
64
+ body.append("StatusCallbackEvent", "initiated");
65
+ body.append("StatusCallbackEvent", "answered");
66
+ body.append("StatusCallbackEvent", "completed");
67
+ }
68
+ if (opts.maxDurationSec && opts.maxDurationSec > 0) {
69
+ body.set("TimeLimit", String(opts.maxDurationSec));
70
+ }
71
+ const data = await twilioPost<{ sid: string; status: string }>(opts, "/Calls.json", body);
72
+ return { callSid: data.sid, status: data.status };
73
+ }
74
+
75
+ export async function updateCallUrl(opts: TwilioCreds & { callSid: string; url: string }): Promise<void> {
76
+ const body = new URLSearchParams({ Url: opts.url, Method: "POST" });
77
+ await twilioPost(opts, `/Calls/${encodeURIComponent(opts.callSid)}.json`, body);
78
+ }
79
+
80
+ export async function hangupCall(opts: TwilioCreds & { callSid: string }): Promise<void> {
81
+ const body = new URLSearchParams({ Status: "completed" });
82
+ await twilioPost(opts, `/Calls/${encodeURIComponent(opts.callSid)}.json`, body);
83
+ }
84
+
85
+ // --- Messages (SMS / WhatsApp) ---
86
+
87
+ export interface SendMessageOpts extends TwilioCreds {
88
+ /** E.164 for SMS, "whatsapp:+E164" for WhatsApp. */
89
+ to: string;
90
+ from: string;
91
+ body: string;
92
+ statusCallbackUrl?: string;
93
+ /** Optional MMS media URLs. */
94
+ mediaUrl?: string[];
95
+ }
96
+
97
+ export interface SendMessageResult {
98
+ messageSid: string;
99
+ status: string;
100
+ }
101
+
102
+ export async function sendMessage(opts: SendMessageOpts): Promise<SendMessageResult> {
103
+ const body = new URLSearchParams({ To: opts.to, From: opts.from, Body: opts.body });
104
+ if (opts.statusCallbackUrl) body.set("StatusCallback", opts.statusCallbackUrl);
105
+ if (opts.mediaUrl) {
106
+ for (const u of opts.mediaUrl) body.append("MediaUrl", u);
107
+ }
108
+ const data = await twilioPost<{ sid: string; status: string }>(opts, "/Messages.json", body);
109
+ return { messageSid: data.sid, status: data.status };
110
+ }
111
+
112
+ // --- Phone number config (update inbound webhook on a number) ---
113
+
114
+ export async function updateIncomingPhoneNumber(
115
+ opts: TwilioCreds & {
116
+ phoneNumberSid: string;
117
+ voiceUrl?: string;
118
+ voiceMethod?: "GET" | "POST";
119
+ smsUrl?: string;
120
+ smsMethod?: "GET" | "POST";
121
+ statusCallback?: string;
122
+ statusCallbackMethod?: "GET" | "POST";
123
+ },
124
+ ): Promise<void> {
125
+ const body = new URLSearchParams();
126
+ if (opts.voiceUrl !== undefined) body.set("VoiceUrl", opts.voiceUrl);
127
+ if (opts.voiceMethod) body.set("VoiceMethod", opts.voiceMethod);
128
+ if (opts.smsUrl !== undefined) body.set("SmsUrl", opts.smsUrl);
129
+ if (opts.smsMethod) body.set("SmsMethod", opts.smsMethod);
130
+ if (opts.statusCallback !== undefined) body.set("StatusCallback", opts.statusCallback);
131
+ if (opts.statusCallbackMethod) body.set("StatusCallbackMethod", opts.statusCallbackMethod);
132
+ await twilioPost(opts, `/IncomingPhoneNumbers/${encodeURIComponent(opts.phoneNumberSid)}.json`, body);
133
+ }
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Shared Bun HTTP + WebSocket server for all Twilio-based channels
3
+ * (voice, SMS, WhatsApp). Channels register routes during their start();
4
+ * the server lazily binds the port on first start() and stays bound until
5
+ * stop() is called. Idempotent in both directions.
6
+ *
7
+ * Cross-cutting features applied per route:
8
+ * - X-Twilio-Signature HMAC-SHA1 validation (default on for /twilio/*).
9
+ * - Dedup based on a configurable form field (e.g. MessageSid, CallSid).
10
+ * - Per-key rate limit (e.g. From), to bound cost when a stranger
11
+ * guesses the shared WhatsApp Sandbox join code.
12
+ *
13
+ * Channels keep their domain logic; the server keeps the wiring.
14
+ */
15
+ import type { Server, ServerWebSocket } from "bun";
16
+ import { log } from "../../utils/log";
17
+ import { validateTwilioSignature } from "./signature";
18
+ import { Dedup } from "./dedup";
19
+ import { RateLimiter } from "./rate-limit";
20
+ import { cacheMedia, readCachedMedia } from "./media-cache";
21
+
22
+ export interface TwilioRequestContext {
23
+ /** Form-decoded body. */
24
+ params: Record<string, string>;
25
+ }
26
+
27
+ export type HttpHandler = (req: Request, ctx: TwilioRequestContext) => Promise<Response> | Response;
28
+
29
+ export interface HttpRouteOpts {
30
+ /** Validate X-Twilio-Signature. Defaults to true. Set false for /healthz etc. */
31
+ verifySignature?: boolean;
32
+ /** If set, dedup based on this form field. Repeated values within the dedup window 204 silently. */
33
+ dedupOn?: string;
34
+ /** If set, rate-limit based on this form field. Over-limit 429s. */
35
+ rateLimitOn?: string;
36
+ }
37
+
38
+ export interface WsConnectionData {
39
+ path: string;
40
+ channel: Record<string, unknown>;
41
+ }
42
+
43
+ export interface WsRoute {
44
+ /** Called when a frame arrives. */
45
+ onMessage(ws: ServerWebSocket<WsConnectionData>, data: string): void;
46
+ /** Called when the connection closes. Optional. */
47
+ onClose?(ws: ServerWebSocket<WsConnectionData>): void;
48
+ }
49
+
50
+ export interface ServerConfig {
51
+ port: number;
52
+ /** Public base URL the server is reachable at (e.g. https://nia.example.com). No trailing slash. */
53
+ publicBaseUrl: string | null;
54
+ /** Account Auth Token used to verify X-Twilio-Signature. */
55
+ signingToken: string | null;
56
+ }
57
+
58
+ export interface TwilioServer {
59
+ configure(cfg: ServerConfig): void;
60
+ registerHttp(path: string, handler: HttpHandler, opts?: HttpRouteOpts): void;
61
+ registerWs(path: string, route: WsRoute): void;
62
+ /** Mark a key as exempt from rate-limiting (e.g. the owner's number). */
63
+ exemptFromRateLimit(key: string): void;
64
+ start(): Promise<void>;
65
+ stop(): void;
66
+ /** Build the WSS URL for a registered path, using publicBaseUrl. */
67
+ buildWssUrl(path: string): string;
68
+ /** Build the HTTPS URL for a registered path, using publicBaseUrl. */
69
+ buildHttpUrl(path: string): string;
70
+ /**
71
+ * Write a media payload to the on-disk outbound cache and return its
72
+ * publicly-reachable URL. Twilio fetches the URL when delivering MMS /
73
+ * WhatsApp media. Requires publicBaseUrl to be configured.
74
+ */
75
+ serveMedia(buffer: Uint8Array, mime: string, ext?: string): Promise<string>;
76
+ }
77
+
78
+ class TwilioServerImpl implements TwilioServer {
79
+ private cfg: ServerConfig | null = null;
80
+ private bunServer: Server<WsConnectionData> | null = null;
81
+ private readonly httpRoutes = new Map<string, { handler: HttpHandler; opts: HttpRouteOpts }>();
82
+ private readonly wsRoutes = new Map<string, WsRoute>();
83
+ private readonly dedup = new Dedup();
84
+ private readonly rateLimit = new RateLimiter();
85
+ private readonly rateLimitExempt = new Set<string>();
86
+
87
+ configure(cfg: ServerConfig): void {
88
+ this.cfg = cfg;
89
+ }
90
+
91
+ registerHttp(path: string, handler: HttpHandler, opts: HttpRouteOpts = {}): void {
92
+ if (this.httpRoutes.has(path)) {
93
+ log.warn({ path }, "twilio-server: overwriting existing HTTP route");
94
+ }
95
+ this.httpRoutes.set(path, { handler, opts });
96
+ }
97
+
98
+ registerWs(path: string, route: WsRoute): void {
99
+ if (this.wsRoutes.has(path)) {
100
+ log.warn({ path }, "twilio-server: overwriting existing WS route");
101
+ }
102
+ this.wsRoutes.set(path, route);
103
+ }
104
+
105
+ exemptFromRateLimit(key: string): void {
106
+ this.rateLimitExempt.add(key);
107
+ this.rateLimit.exempt(key);
108
+ }
109
+
110
+ async start(): Promise<void> {
111
+ if (this.bunServer) return;
112
+ if (!this.cfg) throw new Error("twilio-server: configure() must be called before start()");
113
+ const cfg = this.cfg;
114
+ const self = this;
115
+
116
+ this.bunServer = Bun.serve<WsConnectionData, never>({
117
+ port: cfg.port,
118
+ async fetch(req, server) {
119
+ const path = new URL(req.url).pathname;
120
+
121
+ if (path === "/healthz") return new Response("ok", { status: 200 });
122
+ if (path === "/twilio/health") return new Response("ok", { status: 200 });
123
+
124
+ if (req.method === "GET" && path.startsWith("/twilio/media/")) {
125
+ return await self.handleMedia(path.slice("/twilio/media/".length));
126
+ }
127
+
128
+ const wsRoute = self.wsRoutes.get(path);
129
+ if (wsRoute) {
130
+ const ok = server.upgrade(req, { data: { path, channel: {} } });
131
+ return ok ? undefined : new Response("expected websocket", { status: 400 });
132
+ }
133
+
134
+ const httpRoute = self.httpRoutes.get(path);
135
+ if (!httpRoute) return new Response("not found", { status: 404 });
136
+
137
+ return await self.handleHttp(req, httpRoute);
138
+ },
139
+ websocket: {
140
+ message(ws, message) {
141
+ const route = self.wsRoutes.get(ws.data.path);
142
+ if (!route) {
143
+ try {
144
+ ws.close();
145
+ } catch {}
146
+ return;
147
+ }
148
+ const data = typeof message === "string" ? message : new TextDecoder().decode(message);
149
+ route.onMessage(ws, data);
150
+ },
151
+ close(ws) {
152
+ const route = self.wsRoutes.get(ws.data.path);
153
+ route?.onClose?.(ws);
154
+ },
155
+ },
156
+ });
157
+
158
+ log.info({ port: cfg.port, publicBaseUrl: cfg.publicBaseUrl }, "twilio-server: started");
159
+ }
160
+
161
+ stop(): void {
162
+ if (!this.bunServer) return;
163
+ this.bunServer.stop(true);
164
+ this.bunServer = null;
165
+ log.info("twilio-server: stopped");
166
+ }
167
+
168
+ buildWssUrl(path: string): string {
169
+ if (!this.cfg?.publicBaseUrl) throw new Error("twilio-server: publicBaseUrl not configured");
170
+ return this.cfg.publicBaseUrl.replace(/^http/, "ws") + path;
171
+ }
172
+
173
+ buildHttpUrl(path: string): string {
174
+ if (!this.cfg?.publicBaseUrl) throw new Error("twilio-server: publicBaseUrl not configured");
175
+ return this.cfg.publicBaseUrl + path;
176
+ }
177
+
178
+ async serveMedia(buffer: Uint8Array, mime: string, ext?: string): Promise<string> {
179
+ if (!this.cfg?.publicBaseUrl) {
180
+ throw new Error("twilio-server: serveMedia requires channels.twilio.public_base_url to be configured");
181
+ }
182
+ const { filename } = await cacheMedia(buffer, mime, ext);
183
+ return `${this.cfg.publicBaseUrl}/twilio/media/${filename}`;
184
+ }
185
+
186
+ private async handleMedia(filename: string): Promise<Response> {
187
+ const hit = await readCachedMedia(filename);
188
+ if (!hit) return new Response("not found", { status: 404 });
189
+ return new Response(new Uint8Array(hit.buffer), {
190
+ status: 200,
191
+ headers: {
192
+ "Content-Type": hit.mime,
193
+ "Cache-Control": "private, max-age=300",
194
+ },
195
+ });
196
+ }
197
+
198
+ private async handleHttp(req: Request, route: { handler: HttpHandler; opts: HttpRouteOpts }): Promise<Response> {
199
+ if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
200
+
201
+ const params = await this.readForm(req);
202
+
203
+ if (route.opts.verifySignature !== false) {
204
+ if (!this.verifySignature(req, params)) {
205
+ log.warn({ path: new URL(req.url).pathname }, "twilio-server: signature check failed");
206
+ return new Response("invalid signature", { status: 403 });
207
+ }
208
+ }
209
+
210
+ if (route.opts.rateLimitOn) {
211
+ const key = params[route.opts.rateLimitOn];
212
+ if (key && !this.rateLimitExempt.has(key) && !this.rateLimit.allow(key)) {
213
+ log.warn({ key, field: route.opts.rateLimitOn }, "twilio-server: rate limit exceeded");
214
+ return new Response("too many requests", { status: 429 });
215
+ }
216
+ }
217
+
218
+ if (route.opts.dedupOn) {
219
+ const id = params[route.opts.dedupOn];
220
+ if (id && this.dedup.check(id)) {
221
+ log.debug({ id, field: route.opts.dedupOn }, "twilio-server: duplicate webhook dropped");
222
+ return new Response("", { status: 204 });
223
+ }
224
+ }
225
+
226
+ return await route.handler(req, { params });
227
+ }
228
+
229
+ private async readForm(req: Request): Promise<Record<string, string>> {
230
+ const body = await req.text();
231
+ const params: Record<string, string> = {};
232
+ for (const [k, v] of new URLSearchParams(body)) params[k] = v;
233
+ return params;
234
+ }
235
+
236
+ private verifySignature(req: Request, params: Record<string, string>): boolean {
237
+ const token = this.cfg?.signingToken;
238
+ if (!token) return false;
239
+ const signature = req.headers.get("X-Twilio-Signature") || "";
240
+ const fullUrl = this.cfg?.publicBaseUrl ? `${this.cfg.publicBaseUrl}${new URL(req.url).pathname}` : req.url;
241
+ return validateTwilioSignature({ authToken: token, fullUrl, params, signature });
242
+ }
243
+ }
244
+
245
+ let _instance: TwilioServer | null = null;
246
+
247
+ export function getTwilioServer(): TwilioServer {
248
+ if (!_instance) _instance = new TwilioServerImpl();
249
+ return _instance;
250
+ }
251
+
252
+ /** Test-only: drop the singleton so tests get a fresh server. */
253
+ export function resetTwilioServer(): void {
254
+ _instance = null;
255
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Validate Twilio's X-Twilio-Signature header.
3
+ *
4
+ * Algorithm (per Twilio webhook security docs):
5
+ * 1. Take the full URL Twilio sent the request to (including ?query).
6
+ * 2. For application/x-www-form-urlencoded bodies, sort POST keys and
7
+ * append each "key" + "value" to the URL string.
8
+ * 3. HMAC-SHA1 with the account AuthToken, base64-encode.
9
+ * 4. Timing-safe compare with the X-Twilio-Signature header.
10
+ *
11
+ * Signed with the account-level Auth Token, NOT the API Key Secret.
12
+ * When an API Key is used for REST auth, set TWILIO_AUTH_TOKEN separately.
13
+ */
14
+ import { createHmac, timingSafeEqual } from "crypto";
15
+
16
+ export function validateTwilioSignature(opts: {
17
+ authToken: string;
18
+ fullUrl: string;
19
+ params: Record<string, string>;
20
+ signature: string;
21
+ }): boolean {
22
+ const { authToken, fullUrl, params, signature } = opts;
23
+ if (!signature) return false;
24
+
25
+ const sortedKeys = Object.keys(params).sort();
26
+ let data = fullUrl;
27
+ for (const key of sortedKeys) {
28
+ data += key + params[key];
29
+ }
30
+
31
+ const computed = createHmac("sha1", authToken).update(data, "utf8").digest("base64");
32
+ const a = Buffer.from(computed);
33
+ const b = Buffer.from(signature);
34
+ if (a.length !== b.length) return false;
35
+ return timingSafeEqual(a, b);
36
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Transcribe a short audio clip via OpenAI's gpt-4o-mini-transcribe.
3
+ *
4
+ * Used by the WhatsApp channel for voice notes. We accept the raw bytes
5
+ * + MIME (Twilio's WhatsApp voice notes are typically audio/ogg with
6
+ * opus codec — the endpoint handles ogg natively).
7
+ */
8
+ import { log } from "../../utils/log";
9
+
10
+ const ENDPOINT = "https://api.openai.com/v1/audio/transcriptions";
11
+ const MODEL = "gpt-4o-mini-transcribe";
12
+ const TIMEOUT_MS = 30_000;
13
+
14
+ const MIME_TO_FILENAME: Record<string, string> = {
15
+ "audio/ogg": "audio.ogg",
16
+ "audio/mpeg": "audio.mp3",
17
+ "audio/mp4": "audio.m4a",
18
+ "audio/wav": "audio.wav",
19
+ "audio/webm": "audio.webm",
20
+ "audio/flac": "audio.flac",
21
+ };
22
+
23
+ export interface TranscribeOpts {
24
+ apiKey: string;
25
+ data: Buffer;
26
+ mime: string;
27
+ language?: string;
28
+ }
29
+
30
+ export async function transcribeAudio(opts: TranscribeOpts): Promise<string> {
31
+ const filename = MIME_TO_FILENAME[opts.mime] ?? "audio.ogg";
32
+ const form = new FormData();
33
+ form.set("file", new Blob([new Uint8Array(opts.data)], { type: opts.mime }), filename);
34
+ form.set("model", MODEL);
35
+ if (opts.language) form.set("language", opts.language);
36
+ form.set("response_format", "json");
37
+
38
+ const controller = new AbortController();
39
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
40
+ try {
41
+ const resp = await fetch(ENDPOINT, {
42
+ method: "POST",
43
+ headers: { Authorization: `Bearer ${opts.apiKey}` },
44
+ body: form,
45
+ signal: controller.signal,
46
+ });
47
+ if (!resp.ok) {
48
+ const text = await resp.text().catch(() => "");
49
+ throw new Error(`OpenAI transcribe failed: ${resp.status} ${text}`);
50
+ }
51
+ const json = (await resp.json()) as { text?: string };
52
+ const text = (json.text || "").trim();
53
+ log.info({ chars: text.length, mime: opts.mime }, "twilio: voice note transcribed");
54
+ return text;
55
+ } finally {
56
+ clearTimeout(timer);
57
+ }
58
+ }