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,179 @@
1
+ /**
2
+ * SMS channel via Twilio.
3
+ *
4
+ * Same Twilio number as voice (channels.phone.from_number by default,
5
+ * overridable via channels.sms.from_number). Inbound webhook →
6
+ * chat engine → REST reply. Reuses the shared TwilioWebhookServer for
7
+ * routing, signature validation, dedup, and rate-limiting.
8
+ *
9
+ * Use case: cellular-only-no-data reachability — Aman can text Nia from
10
+ * patchy zones (Ladakh highways, basements, etc.) where Telegram /
11
+ * WhatsApp / voice over data won't work but SMS over SS7 still does.
12
+ *
13
+ * Note: outbound from US Twilio long codes to Indian mobile numbers has
14
+ * variable deliverability under TRAI scrubbing rules. Test empirically;
15
+ * if outbound fails, the inbound leg (Aman → Nia) is more reliable.
16
+ */
17
+ import { getMcpServers } from "../mcp";
18
+ import { runMigrations } from "../db/migrate";
19
+ import type { Channel, ChatState, Outbound, TwilioConfig } from "../types";
20
+ import { getConfig } from "../utils/config";
21
+ import { log } from "../utils/log";
22
+ import { sendMessage as twilioSendMessage } from "./twilio/rest";
23
+ import { getTwilioServer } from "./twilio/server";
24
+ import { chainLock, openChatEngine } from "./common/chat-session";
25
+
26
+ const EMPTY_TWIML = '<?xml version="1.0" encoding="UTF-8"?><Response></Response>';
27
+
28
+ class SmsChannel implements Channel {
29
+ name = "sms" as const;
30
+ private readonly twilio: TwilioConfig;
31
+ /** Cached resolved "from" number: sms.from_number || phone.from_number */
32
+ private readonly fromNumber: string;
33
+ private readonly chats = new Map<string, ChatState>();
34
+
35
+ constructor(twilio: TwilioConfig, fromNumber: string) {
36
+ this.twilio = twilio;
37
+ this.fromNumber = fromNumber;
38
+ }
39
+
40
+ async start(): Promise<void> {
41
+ await runMigrations();
42
+
43
+ const server = getTwilioServer();
44
+ server.configure({
45
+ port: this.twilio.port,
46
+ publicBaseUrl: this.twilio.public_base_url,
47
+ signingToken: this.twilio.auth_token || this.twilio.secret,
48
+ });
49
+
50
+ server.registerHttp("/twilio/sms/incoming", (_req, ctx) => this.handleInbound(ctx.params), {
51
+ dedupOn: "MessageSid",
52
+ rateLimitOn: "From",
53
+ });
54
+ server.registerHttp("/twilio/sms/status", (_req, ctx) => this.handleStatus(ctx.params), {
55
+ dedupOn: "MessageSid",
56
+ });
57
+
58
+ if (this.twilio.owner_number) server.exemptFromRateLimit(this.twilio.owner_number);
59
+
60
+ await server.start();
61
+
62
+ log.info(
63
+ {
64
+ from: this.fromNumber,
65
+ owner: this.twilio.owner_number,
66
+ publicBaseUrl: this.twilio.public_base_url,
67
+ },
68
+ "sms channel started",
69
+ );
70
+ }
71
+
72
+ async stop(): Promise<void> {
73
+ for (const state of this.chats.values()) state.engine.close();
74
+ this.chats.clear();
75
+ }
76
+
77
+ /** Outbound — used by send_message MCP tool. SMS is text-only; media is dropped with a warning. */
78
+ async deliver(out: Outbound): Promise<void> {
79
+ if (!this.twilio.owner_number) throw new Error("sms: owner_number not set");
80
+ // SMS has no threading; recipient kind is ignored.
81
+ if (out.media) {
82
+ log.warn({ filename: out.media.filename }, "sms: media payload dropped (channel is text-only)");
83
+ }
84
+ if (out.text) {
85
+ await this.sendTo(this.twilio.owner_number, out.text);
86
+ }
87
+ }
88
+
89
+ // --- Inbound webhook ---
90
+
91
+ private async handleInbound(params: Record<string, string>): Promise<Response> {
92
+ const from = params.From || "";
93
+ const body = params.Body || "";
94
+
95
+ if (!this.isAllowed(from)) {
96
+ log.warn({ from }, "sms: rejecting non-allowlisted sender");
97
+ return new Response(EMPTY_TWIML, { status: 200, headers: { "Content-Type": "text/xml" } });
98
+ }
99
+
100
+ const state = await this.getState(from);
101
+ // Ack the webhook immediately; reply via REST asynchronously to avoid
102
+ // Twilio's ~15s webhook timeout when the engine takes longer.
103
+ chainLock(state, async () => {
104
+ try {
105
+ const { result } = await state.engine.send(body);
106
+ const reply = result.trim() || "(no response)";
107
+ await this.sendTo(from, reply);
108
+ } catch (err) {
109
+ log.error({ err, from }, "sms: engine error");
110
+ await this.sendTo(from, `[error] ${err instanceof Error ? err.message : String(err)}`).catch(() => {});
111
+ }
112
+ });
113
+
114
+ return new Response(EMPTY_TWIML, { status: 200, headers: { "Content-Type": "text/xml" } });
115
+ }
116
+
117
+ private handleStatus(params: Record<string, string>): Response {
118
+ log.info(
119
+ {
120
+ messageSid: params.MessageSid,
121
+ status: params.MessageStatus,
122
+ errorCode: params.ErrorCode,
123
+ to: params.To,
124
+ },
125
+ "sms: delivery status",
126
+ );
127
+ return new Response("", { status: 204 });
128
+ }
129
+
130
+ // --- Outbound ---
131
+
132
+ private async sendTo(remoteE164: string, body: string): Promise<void> {
133
+ if (!this.twilio.sid || !this.twilio.secret) {
134
+ log.warn("sms: twilio sid/secret missing, cannot send");
135
+ return;
136
+ }
137
+ try {
138
+ const res = await twilioSendMessage({
139
+ accountSid: this.twilio.sid,
140
+ authSid: this.twilio.sid,
141
+ authSecret: this.twilio.secret,
142
+ to: remoteE164,
143
+ from: this.fromNumber,
144
+ body,
145
+ statusCallbackUrl: this.twilio.public_base_url ? `${this.twilio.public_base_url}/twilio/sms/status` : undefined,
146
+ });
147
+ log.info({ to: remoteE164, sid: res.messageSid, status: res.status }, "sms: sent");
148
+ } catch (err) {
149
+ log.error({ err, to: remoteE164 }, "sms: send failed");
150
+ }
151
+ }
152
+
153
+ // --- Helpers ---
154
+
155
+ private isAllowed(remoteE164: string): boolean {
156
+ if (this.twilio.owner_number && remoteE164 === this.twilio.owner_number) return true;
157
+ return this.twilio.allowlist.includes(remoteE164);
158
+ }
159
+
160
+ private async getState(remoteE164: string): Promise<ChatState> {
161
+ let state = this.chats.get(remoteE164);
162
+ if (state) return state;
163
+ state = await openChatEngine(`sms-${remoteE164}`, () => ({ channel: "sms", mcpServers: getMcpServers() }));
164
+ this.chats.set(remoteE164, state);
165
+ return state;
166
+ }
167
+ }
168
+
169
+ export function createSmsChannel(): SmsChannel | null {
170
+ const { twilio, sms, phone } = getConfig().channels;
171
+ if (!sms.enabled) return null;
172
+ if (!twilio.sid || !twilio.secret) return null;
173
+ // sms.from_number falls back to phone.from_number (same number for voice + SMS).
174
+ const fromNumber = sms.from_number ?? phone.from_number;
175
+ if (!fromNumber) return null;
176
+ return new SmsChannel(twilio, fromNumber);
177
+ }
178
+
179
+ export type { SmsChannel };
@@ -2,15 +2,15 @@ import { Bot, InputFile } from "grammy";
2
2
  import { createHash } from "crypto";
3
3
  import { mkdirSync, writeFileSync } from "fs";
4
4
  import { join } from "path";
5
- import { createChatEngine } from "../chat/engine";
6
- import type { Channel, ChatState, Attachment } from "../types";
5
+ import type { Channel, ChatState, Attachment, Outbound } from "../types";
7
6
  import { getConfig, updateRawConfig } from "../utils/config";
8
7
  import { runMigrations } from "../db/migrate";
9
- import { Session, Message } from "../db/models";
8
+ import { Message } from "../db/models";
10
9
  import { log } from "../utils/log";
11
10
  import { getMcpServers } from "../mcp";
12
11
  import { classifyMime, validateAttachment, prepareImage } from "../utils/attachment";
13
12
  import { getNiaHome } from "../utils/paths";
13
+ import { chainLock, openChatEngine, rotateRoom } from "./common/chat-session";
14
14
 
15
15
  function safeExtension(filename?: string): string {
16
16
  const ext = filename?.split(".").pop();
@@ -23,27 +23,27 @@ function cacheExtension(filename: string | undefined, mimeType: string): string
23
23
  }
24
24
 
25
25
  class TelegramChannel implements Channel {
26
- name = "telegram";
26
+ name = "telegram" as const;
27
27
  private bot: Bot | null = null;
28
28
  private outboundChatId: number | null = null;
29
29
 
30
- async sendMessage(text: string): Promise<void> {
30
+ async deliver(out: Outbound): Promise<void> {
31
31
  if (!this.bot) throw new Error("Telegram not started");
32
32
  const chatId = this.outboundChatId;
33
33
  if (!chatId) throw new Error("No outbound chat ID registered");
34
- await this.bot.api.sendMessage(chatId, text);
35
- }
36
-
37
- async sendMedia(data: Buffer, mimeType: string, filename?: string): Promise<void> {
38
- if (!this.bot) throw new Error("Telegram not started");
39
- const chatId = this.outboundChatId;
40
- if (!chatId) throw new Error("No outbound chat ID registered");
41
-
42
- const file = new InputFile(data, filename);
43
- if (mimeType.startsWith("image/")) {
44
- await this.bot.api.sendPhoto(chatId, file);
45
- } else {
46
- await this.bot.api.sendDocument(chatId, file);
34
+ // Telegram has no native threading; thread recipients fall back to the
35
+ // configured DM chat (the same place we'd send to for `owner`).
36
+
37
+ if (out.media) {
38
+ const file = new InputFile(Buffer.from(out.media.data), out.media.filename);
39
+ if (out.media.mimeType.startsWith("image/")) {
40
+ await this.bot.api.sendPhoto(chatId, file);
41
+ } else {
42
+ await this.bot.api.sendDocument(chatId, file);
43
+ }
44
+ }
45
+ if (out.text) {
46
+ await this.bot.api.sendMessage(chatId, out.text);
47
47
  }
48
48
  }
49
49
 
@@ -57,7 +57,13 @@ class TelegramChannel implements Channel {
57
57
  return Buffer.from(await resp.arrayBuffer());
58
58
  }
59
59
 
60
- private cacheAttachment(chatId: number, roomIndex: number, data: Buffer, mimeType: string, filename?: string): string {
60
+ private cacheAttachment(
61
+ chatId: number,
62
+ roomIndex: number,
63
+ data: Buffer,
64
+ mimeType: string,
65
+ filename?: string,
66
+ ): string {
61
67
  const scope = `telegram-${chatId}-${roomIndex}`;
62
68
  const dir = join(getNiaHome(), "tmp", "attachments", scope);
63
69
  mkdirSync(dir, { recursive: true });
@@ -78,44 +84,23 @@ class TelegramChannel implements Channel {
78
84
 
79
85
  const chats = new Map<number, ChatState>();
80
86
 
81
- function roomPrefix(chatId: number): string {
87
+ function keyOf(chatId: number): string {
82
88
  return `tg-${chatId}`;
83
89
  }
84
90
 
85
- function roomName(chatId: number, index: number): string {
86
- return `tg-${chatId}-${index}`;
87
- }
88
-
89
91
  async function getState(chatId: number): Promise<ChatState> {
90
92
  let state = chats.get(chatId);
91
- if (!state) {
92
- const prefix = roomPrefix(chatId);
93
- const idx = await Session.getLatestRoomIndex(prefix);
94
- const room = roomName(chatId, idx);
95
- log.info({ chatId, room }, "telegram: creating chat engine");
96
- const engine = await createChatEngine({ room, channel: "telegram", resume: true, mcpServers: getMcpServers() });
97
- state = { engine, roomIndex: idx, lock: Promise.resolve() };
98
- chats.set(chatId, state);
99
- log.info({ chatId, room, activeSessions: chats.size }, "telegram: engine ready");
100
- }
93
+ if (state) return state;
94
+ state = await openChatEngine(keyOf(chatId), () => ({ channel: "telegram", mcpServers: getMcpServers() }));
95
+ chats.set(chatId, state);
101
96
  return state;
102
97
  }
103
98
 
104
99
  async function restartChat(chatId: number): Promise<ChatState> {
105
- const old = chats.get(chatId);
106
- if (old) old.engine.close();
107
-
108
- const prefix = roomPrefix(chatId);
109
- const prevIdx = await Session.getLatestRoomIndex(prefix);
110
- const newIdx = prevIdx + 1;
111
- const room = roomName(chatId, newIdx);
112
-
113
- // Persist a placeholder session immediately so the room index survives
114
- // daemon restarts (otherwise getState falls back to the old room).
115
- await Session.create(`placeholder-${room}`, room);
116
-
117
- const engine = await createChatEngine({ room, channel: "telegram", resume: false, mcpServers: getMcpServers() });
118
- const state: ChatState = { engine, roomIndex: newIdx, lock: Promise.resolve() };
100
+ const state = await rotateRoom(keyOf(chatId), chats.get(chatId), () => ({
101
+ channel: "telegram",
102
+ mcpServers: getMcpServers(),
103
+ }));
119
104
  chats.set(chatId, state);
120
105
  return state;
121
106
  }
@@ -126,9 +111,7 @@ class TelegramChannel implements Channel {
126
111
  fn().catch((err) => log.error({ err, chatId }, "unhandled error in locked handler"));
127
112
  return;
128
113
  }
129
- const queued = state.lock !== Promise.resolve();
130
- if (queued) log.debug({ chatId }, "telegram: message queued behind active lock");
131
- state.lock = state.lock.then(fn, fn);
114
+ chainLock(state, fn);
132
115
  }
133
116
 
134
117
  const isOpen = config.channels.telegram.open;
@@ -151,7 +134,10 @@ class TelegramChannel implements Channel {
151
134
 
152
135
  async function processMessage(ctx: any, state: ChatState, text: string, attachments?: Attachment[]): Promise<void> {
153
136
  const chatId = ctx.chatId;
154
- log.info({ chatId, text: text.slice(0, 100), attachments: attachments?.length || 0 }, "telegram message received");
137
+ log.info(
138
+ { chatId, text: text.slice(0, 100), attachments: attachments?.length || 0 },
139
+ "telegram message received",
140
+ );
155
141
 
156
142
  // Show typing indicator throughout
157
143
  const typingInterval = setInterval(() => {
@@ -251,7 +237,7 @@ class TelegramChannel implements Channel {
251
237
  const mime = doc.mime_type || "application/octet-stream";
252
238
  const attType = classifyMime(mime) || "file";
253
239
  let data = await self.downloadFile(doc.file_id);
254
- const error = validateAttachment(data, mime);
240
+ const error = validateAttachment(data);
255
241
  if (error) {
256
242
  await ctx.reply(error);
257
243
  return;
@@ -263,7 +249,13 @@ class TelegramChannel implements Channel {
263
249
  finalMime = prepared.mimeType;
264
250
  }
265
251
  const sourcePath = self.cacheAttachment(ctx.chatId, state.roomIndex, data, finalMime, doc.file_name);
266
- const attachment: Attachment = { type: attType, data, mimeType: finalMime, filename: doc.file_name, sourcePath };
252
+ const attachment: Attachment = {
253
+ type: attType,
254
+ data,
255
+ mimeType: finalMime,
256
+ filename: doc.file_name,
257
+ sourcePath,
258
+ };
267
259
  const caption = ctx.message.caption || (attType === "image" ? "What's in this image?" : "Here's a file.");
268
260
  await processMessage(ctx, state, caption, [attachment]);
269
261
  } catch (err) {
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Time-bounded set for deduplicating webhook deliveries.
3
+ *
4
+ * Twilio retries webhooks on 5xx/timeouts, so the same MessageSid /
5
+ * CallSid can arrive multiple times. We track recently-seen IDs and
6
+ * drop duplicates, expiring entries after `ttlMs`.
7
+ */
8
+ export class Dedup {
9
+ private readonly seen = new Map<string, number>();
10
+
11
+ constructor(
12
+ private readonly ttlMs: number = 10 * 60 * 1000,
13
+ private readonly maxEntries: number = 5000,
14
+ ) {}
15
+
16
+ /** Returns true if this id was already seen recently; false (and records it) otherwise. */
17
+ check(id: string): boolean {
18
+ const now = Date.now();
19
+ const cutoff = now - this.ttlMs;
20
+ const seenAt = this.seen.get(id);
21
+ if (seenAt !== undefined && seenAt > cutoff) return true;
22
+ this.seen.set(id, now);
23
+ if (this.seen.size > this.maxEntries) this.prune(cutoff);
24
+ return false;
25
+ }
26
+
27
+ private prune(cutoff: number): void {
28
+ for (const [k, v] of this.seen) {
29
+ if (v <= cutoff) this.seen.delete(k);
30
+ }
31
+ }
32
+
33
+ size(): number {
34
+ return this.seen.size;
35
+ }
36
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Disk-backed cache for outbound Twilio media.
3
+ *
4
+ * Twilio fetches outbound MMS/WhatsApp media by URL, so we write the
5
+ * payload to ~/.niahere/tmp/outbound/<sha>.<ext>, expose it under
6
+ * GET /twilio/media/<sha>.<ext>, and Twilio retrieves it. Disk (not
7
+ * memory) so the URL survives daemon restarts and Twilio's webhook
8
+ * retries within the eviction window.
9
+ *
10
+ * Eviction is opportunistic: capped at 100 files, 10MB total, 24h max
11
+ * age. Oldest-first; expired-first. Runs after every write; cheap
12
+ * enough at this scale (single user, low traffic).
13
+ */
14
+ import { mkdir, readdir, readFile, stat, unlink, writeFile } from "fs/promises";
15
+ import { createHash } from "crypto";
16
+ import { join } from "path";
17
+ import { getNiaHome } from "../../utils/paths";
18
+ import { log } from "../../utils/log";
19
+
20
+ const MAX_FILES = 100;
21
+ const MAX_BYTES = 10 * 1024 * 1024;
22
+ const MAX_AGE_MS = 24 * 60 * 60 * 1000;
23
+
24
+ const MIME_TO_EXT: Record<string, string> = {
25
+ "image/jpeg": "jpg",
26
+ "image/png": "png",
27
+ "image/webp": "webp",
28
+ "image/gif": "gif",
29
+ "audio/mpeg": "mp3",
30
+ "audio/mp4": "m4a",
31
+ "audio/ogg": "ogg",
32
+ "audio/wav": "wav",
33
+ "video/mp4": "mp4",
34
+ "application/pdf": "pdf",
35
+ };
36
+
37
+ const FILENAME_RE = /^[a-f0-9]{16,64}\.[a-z0-9]{1,8}$/i;
38
+
39
+ export function getMediaDir(): string {
40
+ return join(getNiaHome(), "tmp", "outbound");
41
+ }
42
+
43
+ export interface CachedMedia {
44
+ filename: string;
45
+ path: string;
46
+ }
47
+
48
+ export async function cacheMedia(buffer: Uint8Array, mime: string, ext?: string): Promise<CachedMedia> {
49
+ const dir = getMediaDir();
50
+ await mkdir(dir, { recursive: true });
51
+ const resolvedExt = (ext ?? MIME_TO_EXT[mime] ?? "bin").replace(/[^a-z0-9]/gi, "").slice(0, 8) || "bin";
52
+ const hash = createHash("sha256").update(buffer).digest("hex").slice(0, 32);
53
+ const filename = `${hash}.${resolvedExt}`;
54
+ const path = join(dir, filename);
55
+ await writeFile(path, buffer);
56
+ await evict().catch((err) => log.warn({ err }, "media-cache: eviction failed"));
57
+ return { filename, path };
58
+ }
59
+
60
+ export async function readCachedMedia(filename: string): Promise<{ buffer: Buffer; mime: string } | null> {
61
+ if (!FILENAME_RE.test(filename)) return null;
62
+ const path = join(getMediaDir(), filename);
63
+ try {
64
+ const buffer = await readFile(path);
65
+ const ext = filename.split(".").pop()!.toLowerCase();
66
+ const mime = Object.entries(MIME_TO_EXT).find(([, e]) => e === ext)?.[0] ?? "application/octet-stream";
67
+ return { buffer, mime };
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ async function evict(): Promise<void> {
74
+ const dir = getMediaDir();
75
+ const entries = await readdir(dir);
76
+ const stats = await Promise.all(
77
+ entries.map(async (name) => {
78
+ const path = join(dir, name);
79
+ const s = await stat(path);
80
+ return { name, path, mtime: s.mtimeMs, size: s.size };
81
+ }),
82
+ );
83
+
84
+ const now = Date.now();
85
+ let alive: typeof stats = [];
86
+ for (const s of stats) {
87
+ if (now - s.mtime > MAX_AGE_MS) {
88
+ await unlink(s.path).catch(() => {});
89
+ } else {
90
+ alive.push(s);
91
+ }
92
+ }
93
+
94
+ alive.sort((a, b) => a.mtime - b.mtime);
95
+
96
+ while (alive.length > MAX_FILES) {
97
+ const victim = alive.shift()!;
98
+ await unlink(victim.path).catch(() => {});
99
+ }
100
+
101
+ let totalBytes = alive.reduce((sum, f) => sum + f.size, 0);
102
+ while (totalBytes > MAX_BYTES && alive.length > 0) {
103
+ const victim = alive.shift()!;
104
+ totalBytes -= victim.size;
105
+ await unlink(victim.path).catch(() => {});
106
+ }
107
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Download inbound media attached to a Twilio webhook.
3
+ *
4
+ * Twilio's MediaUrlN values are HTTPS URLs that 302-redirect to S3.
5
+ * They sit behind Basic auth (same Twilio creds), so a vanilla fetch
6
+ * without an Authorization header gets 401. Bun's fetch follows the
7
+ * redirect transparently.
8
+ *
9
+ * Concurrency is bounded (4 in flight, 10s per item) so a sender that
10
+ * attaches many large files cannot stall the webhook handler.
11
+ */
12
+ import { log } from "../../utils/log";
13
+ import type { TwilioCreds } from "./rest";
14
+
15
+ const MAX_CONCURRENT = 4;
16
+ const TIMEOUT_MS = 10_000;
17
+
18
+ export interface InboundMedia {
19
+ index: number;
20
+ url: string;
21
+ mime: string;
22
+ data: Buffer;
23
+ }
24
+
25
+ export interface MediaDescriptor {
26
+ index: number;
27
+ url: string;
28
+ mime: string;
29
+ }
30
+
31
+ /**
32
+ * Pull out the NumMedia / MediaUrlN / MediaContentTypeN fields from a
33
+ * Twilio webhook form body.
34
+ */
35
+ export function extractMedia(params: Record<string, string>): MediaDescriptor[] {
36
+ const num = parseInt(params.NumMedia || "0", 10);
37
+ if (!Number.isFinite(num) || num <= 0) return [];
38
+ const items: MediaDescriptor[] = [];
39
+ for (let i = 0; i < num; i++) {
40
+ const url = params[`MediaUrl${i}`];
41
+ const mime = params[`MediaContentType${i}`];
42
+ if (url && mime) items.push({ index: i, url, mime });
43
+ }
44
+ return items;
45
+ }
46
+
47
+ export async function downloadInboundMedia(
48
+ descriptors: MediaDescriptor[],
49
+ creds: TwilioCreds,
50
+ ): Promise<InboundMedia[]> {
51
+ const out: InboundMedia[] = [];
52
+ for (let i = 0; i < descriptors.length; i += MAX_CONCURRENT) {
53
+ const slice = descriptors.slice(i, i + MAX_CONCURRENT);
54
+ const results = await Promise.allSettled(slice.map((d) => downloadOne(d, creds)));
55
+ for (let j = 0; j < results.length; j++) {
56
+ const r = results[j];
57
+ if (r.status === "fulfilled") {
58
+ out.push(r.value);
59
+ } else {
60
+ log.warn({ err: r.reason, descriptor: slice[j] }, "twilio: media download failed");
61
+ }
62
+ }
63
+ }
64
+ return out;
65
+ }
66
+
67
+ async function downloadOne(d: MediaDescriptor, creds: TwilioCreds): Promise<InboundMedia> {
68
+ const auth = `Basic ${Buffer.from(`${creds.authSid}:${creds.authSecret}`).toString("base64")}`;
69
+ const controller = new AbortController();
70
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
71
+ try {
72
+ const resp = await fetch(d.url, { headers: { Authorization: auth }, signal: controller.signal });
73
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
74
+ const buffer = Buffer.from(await resp.arrayBuffer());
75
+ return { index: d.index, url: d.url, mime: d.mime, data: buffer };
76
+ } finally {
77
+ clearTimeout(timer);
78
+ }
79
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Sliding-window rate limiter keyed by an arbitrary string (e.g. caller
3
+ * E.164). Protects against runaway costs when the WhatsApp Sandbox's
4
+ * shared number gets random opt-ins and someone spams.
5
+ */
6
+ export class RateLimiter {
7
+ private readonly hits = new Map<string, number[]>();
8
+
9
+ constructor(
10
+ private readonly maxPerWindow: number = 30,
11
+ private readonly windowMs: number = 60_000,
12
+ ) {}
13
+
14
+ /** Returns true if this hit was allowed (and recorded); false if over limit. */
15
+ allow(key: string): boolean {
16
+ const now = Date.now();
17
+ const cutoff = now - this.windowMs;
18
+ const arr = this.hits.get(key) ?? [];
19
+ const recent = arr.filter((t) => t > cutoff);
20
+ if (recent.length >= this.maxPerWindow) {
21
+ this.hits.set(key, recent);
22
+ return false;
23
+ }
24
+ recent.push(now);
25
+ this.hits.set(key, recent);
26
+ return true;
27
+ }
28
+
29
+ /** Drop tracking for a key (e.g. owner number — never rate-limit yourself). */
30
+ exempt(key: string): void {
31
+ this.hits.delete(key);
32
+ }
33
+ }