whatsapp_notifier 0.6.0 → 0.8.0

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,548 @@
1
+ // Inbound media store (v0.7.0)
2
+ //
3
+ // Downloads customer media (images, voice notes, documents) to disk and serves
4
+ // it back to the host over GET /media/:userId/:messageId. Kept separate from
5
+ // index.ts (which calls Bun.serve() at import time) so every piece — id
6
+ // sanitization, the size/type policy, the TTL sweep, the download pipeline and
7
+ // the route responses — can be unit-tested without booting Chromium.
8
+ //
9
+ // Layout: <media root>/<safeUser>/<safeMessageId> holds the raw bytes and
10
+ // <safeMessageId>~meta.json the sidecar { mime, filename, size, capturedAt }.
11
+ // The '~' sits OUTSIDE the sanitize charset, so a hostile message id ending in
12
+ // ".json" can never name-collide with (or overwrite) another message's sidecar
13
+ // — data files and sidecars live in disjoint namespaces by construction. The
14
+ // media root is <SESSION_DIR>/media in production (survives restarts that wipe
15
+ // the in-memory inbound queues); tests point it at a tmp dir via
16
+ // configureMedia, mirroring configureInbound.
17
+
18
+ import {
19
+ existsSync,
20
+ mkdirSync,
21
+ readFileSync,
22
+ writeFileSync,
23
+ rmSync,
24
+ readdirSync,
25
+ statSync
26
+ } from 'fs';
27
+ import { join, resolve, sep } from 'path';
28
+ import { createHash, timingSafeEqual } from 'crypto';
29
+
30
+ export interface MediaMeta {
31
+ mime: string;
32
+ filename: string | null;
33
+ size: number;
34
+ capturedAt: number; // epoch ms
35
+ }
36
+
37
+ export type MediaSkipReason = 'unsupported_type' | 'too_large';
38
+ export type MediaFailureReason = MediaSkipReason | 'expired' | 'download_failed' | 'invalid_id';
39
+
40
+ // The verdict captureInbound merges into the inbound payload. Structurally
41
+ // compatible with inbound.ts's optional media fields — every failure mode
42
+ // still surfaces the message itself, just without bytes.
43
+ export interface MediaResolution {
44
+ mediaStatus: 'available' | 'unavailable';
45
+ mediaError?: MediaFailureReason;
46
+ mediaMime?: string;
47
+ mediaFilename?: string;
48
+ mediaSize?: number;
49
+ }
50
+
51
+ export type MediaPolicy = { download: true } | { download: false; reason: MediaSkipReason };
52
+
53
+ // Inline media (image / voice note) caps at WhatsApp's own 16MB ceiling;
54
+ // documents get a separate, env-tunable cap. Envs are read lazily (not frozen
55
+ // at import) so the limits can be retuned per deployment and per test.
56
+ export const INLINE_MEDIA_MAX_BYTES = 16 * 1024 * 1024;
57
+ export const MEDIA_DOWNLOAD_TIMEOUT_MS = 30000;
58
+
59
+ // Malformed env values ("50GB", "2 days") parse to NaN, and every NaN
60
+ // comparison is false — the TTL sweep and the disk cap would silently
61
+ // disable themselves. Fall back to the default instead.
62
+ function envLimit(name: string, fallback: number): number {
63
+ const parsed = Number(process.env[name] || fallback);
64
+ return Number.isFinite(parsed) ? parsed : fallback;
65
+ }
66
+
67
+ export function mediaTtlMs(): number {
68
+ return envLimit('WHATSAPP_MEDIA_TTL_MS', 48 * 60 * 60 * 1000); // 48h
69
+ }
70
+
71
+ export function maxDocumentBytes(): number {
72
+ return envLimit('WHATSAPP_MEDIA_MAX_BYTES', 25 * 1024 * 1024); // 25MB
73
+ }
74
+
75
+ export function maxDiskBytes(): number {
76
+ return envLimit('WHATSAPP_MEDIA_MAX_DISK_BYTES', 5 * 1024 * 1024 * 1024); // 5GB
77
+ }
78
+
79
+ // Per-user rolling cap (default 1GB). This is the cap that actually shapes disk
80
+ // use: maxDiskBytes() is only an absolute global backstop now. NaN-safe like
81
+ // the others so a malformed env can't silently disable eviction.
82
+ export function maxUserBytes(): number {
83
+ return envLimit('WHATSAPP_MEDIA_MAX_USER_BYTES', 1024 * 1024 * 1024); // 1GB
84
+ }
85
+
86
+ // ── Root + cap accounting ──
87
+
88
+ // index.ts wires this to <SESSION_BASE_DIR>/media; tests to a tmp dir.
89
+ let mediaRootResolver: () => string = () => './media';
90
+ // Total payload bytes on disk, kept incrementally by write/delete and
91
+ // recomputed by the sweep, so downloadPolicy's disk-full check is O(1).
92
+ let cachedDiskBytes: number | null = null;
93
+
94
+ export function configureMedia(rootResolver: () => string) {
95
+ mediaRootResolver = rootResolver;
96
+ cachedDiskBytes = null;
97
+ }
98
+
99
+ export function mediaDiskBytes(): number {
100
+ if (cachedDiskBytes === null) cachedDiskBytes = computeDiskBytes();
101
+ return cachedDiskBytes;
102
+ }
103
+
104
+ function computeDiskBytes(): number {
105
+ let total = 0;
106
+ try {
107
+ const root = mediaRootResolver();
108
+ for (const user of readdirSync(root, { withFileTypes: true })) {
109
+ if (!user.isDirectory()) continue;
110
+ const dir = join(root, user.name);
111
+ for (const file of readdirSync(dir)) {
112
+ if (isSidecarName(file)) continue; // sidecars are negligible
113
+ try { total += statSync(join(dir, file)).size; } catch (_) { /* raced a delete */ }
114
+ }
115
+ }
116
+ } catch (_) { /* media root not created yet → nothing stored */ }
117
+ return total;
118
+ }
119
+
120
+ // Payload bytes stored for ONE user — sidecars excluded, exactly as
121
+ // computeDiskBytes counts them, so the per-user total and the global total
122
+ // stay on the same scale and the eviction decrement keeps cachedDiskBytes
123
+ // honest.
124
+ export function userDirBytes(userId: string): number {
125
+ const safeUser = sanitizeId(userId);
126
+ if (!safeUser) return 0;
127
+ let total = 0;
128
+ try {
129
+ const dir = join(mediaRootResolver(), safeUser);
130
+ for (const file of readdirSync(dir)) {
131
+ if (isSidecarName(file)) continue;
132
+ try { total += statSync(join(dir, file)).size; } catch (_) { /* raced a delete */ }
133
+ }
134
+ } catch (_) { /* user dir not created yet → nothing stored */ }
135
+ return total;
136
+ }
137
+
138
+ // ── Id sanitization + path layout ──
139
+
140
+ // Both route params become path segments, so they must be reduced to a safe
141
+ // charset. WhatsApp message ids ("true_9199...@c.us_ABC") and our numeric user
142
+ // ids fit [A-Za-z0-9@._-] untouched; anything else is hostile or garbage.
143
+ export function sanitizeId(raw: unknown): string | null {
144
+ if (typeof raw !== 'string') return null;
145
+ const cleaned = raw.replace(/[^A-Za-z0-9@._-]/g, '');
146
+ if (!cleaned || cleaned.length > 200) return null;
147
+ if (/^\.+$/.test(cleaned)) return null; // '.', '..', … are path-segment hazards
148
+ return cleaned;
149
+ }
150
+
151
+ // Sidecar names end with a suffix whose '~' is outside the sanitize charset:
152
+ // no sanitized message id can ever produce (or overwrite) a sidecar name, so
153
+ // the accounting/sweep/orphan logic can tell the two apart by name alone.
154
+ const SIDECAR_SUFFIX = '~meta.json';
155
+
156
+ function isSidecarName(file: string): boolean {
157
+ return file.endsWith(SIDECAR_SUFFIX);
158
+ }
159
+
160
+ export function mediaPaths(
161
+ userId: string,
162
+ messageId: string
163
+ ): { dir: string; dataPath: string; metaPath: string } | null {
164
+ const safeUser = sanitizeId(userId);
165
+ const safeMessage = sanitizeId(messageId);
166
+ if (!safeUser || !safeMessage) return null;
167
+
168
+ const root = resolve(mediaRootResolver());
169
+ const dir = resolve(root, safeUser);
170
+ const dataPath = resolve(dir, safeMessage);
171
+ // Belt and braces: even a sanitizer bug must never escape the media root.
172
+ if (!dir.startsWith(root + sep) || !dataPath.startsWith(dir + sep)) return null;
173
+
174
+ return { dir, dataPath, metaPath: `${dataPath}${SIDECAR_SUFFIX}` };
175
+ }
176
+
177
+ // ── Store primitives ──
178
+
179
+ export function writeMedia(
180
+ userId: string,
181
+ messageId: string,
182
+ data: Uint8Array,
183
+ meta: { mime: string; filename?: string | null }
184
+ ): boolean {
185
+ const paths = mediaPaths(userId, messageId);
186
+ if (!paths) return false;
187
+ try {
188
+ mkdirSync(paths.dir, { recursive: true });
189
+ writeFileSync(paths.dataPath, data);
190
+ const sidecar: MediaMeta = {
191
+ mime: meta.mime,
192
+ filename: meta.filename ?? null,
193
+ size: data.byteLength,
194
+ capturedAt: Date.now()
195
+ };
196
+ writeFileSync(paths.metaPath, JSON.stringify(sidecar));
197
+ if (cachedDiskBytes !== null) cachedDiskBytes += data.byteLength;
198
+ // WhatsApp's own model: keep the recent media, roll the old off. The
199
+ // newly written file pushed this user over their cap? Evict their
200
+ // oldest until they fit again. An evicted item is not lost — the host
201
+ // re-downloads it on demand via POST /media/:userId/refetch when an
202
+ // operator opens that bubble.
203
+ enforceUserCap(userId);
204
+ return true;
205
+ } catch (e) {
206
+ console.error(`Failed to persist media ${messageId} for ${userId}`, e);
207
+ return false;
208
+ }
209
+ }
210
+
211
+ // Roll the user back under maxUserBytes() by deleting their OLDEST media pair
212
+ // (by file mtime — same pairing/ordering logic as sweepExpired) one at a time.
213
+ // Bounded by the file count: each pass either deletes one pair or stops, so it
214
+ // can never spin (a user already at/under the cap exits immediately, and a
215
+ // single oversize file that alone exceeds the cap is deleted once and the loop
216
+ // ends with the dir empty). cachedDiskBytes is decremented per eviction so
217
+ // downloadPolicy's global backstop and mediaDiskBytes stay accurate.
218
+ export function enforceUserCap(userId: string): number {
219
+ const safeUser = sanitizeId(userId);
220
+ if (!safeUser) return 0;
221
+ const cap = maxUserBytes();
222
+ const dir = join(mediaRootResolver(), safeUser);
223
+
224
+ let evicted = 0;
225
+ let total = userDirBytes(userId);
226
+ // Bound the loop by the directory's file count — defensive belt over the
227
+ // total-shrinks-each-pass invariant.
228
+ let guard = 0;
229
+ while (total > cap) {
230
+ const oldest = oldestMediaName(dir);
231
+ if (!oldest) break; // nothing left to evict (cap smaller than 0 bytes is impossible)
232
+ // deleteMedia decrements cachedDiskBytes by the payload size for us.
233
+ deleteMedia(userId, oldest);
234
+ evicted += 1;
235
+ total = userDirBytes(userId);
236
+ guard += 1;
237
+ if (guard > 100000) break; // pathological safety valve
238
+ }
239
+ return evicted;
240
+ }
241
+
242
+ // The user's oldest media payload by capturedAt (sidecar) / mtime (fallback) —
243
+ // same age source sweepExpired uses — or null when the dir holds no payloads.
244
+ // Sidecars are skipped; an orphaned sidecar is left for sweepExpired to reap.
245
+ function oldestMediaName(dir: string): string | null {
246
+ let oldestName: string | null = null;
247
+ let oldestAt = Infinity;
248
+ try {
249
+ for (const file of readdirSync(dir)) {
250
+ if (isSidecarName(file)) continue;
251
+ const at = capturedAtFor(join(dir, file), `${join(dir, file)}${SIDECAR_SUFFIX}`);
252
+ if (at < oldestAt) { oldestAt = at; oldestName = file; }
253
+ }
254
+ } catch (_) { /* dir gone → nothing to evict */ }
255
+ return oldestName;
256
+ }
257
+
258
+ // Returns the sidecar when BOTH the bytes and the sidecar are present —
259
+ // captureInbound uses this to skip re-downloading on a reconnect backfill.
260
+ export function mediaExists(userId: string, messageId: string): MediaMeta | null {
261
+ const paths = mediaPaths(userId, messageId);
262
+ if (!paths) return null;
263
+ try {
264
+ if (!existsSync(paths.dataPath) || !existsSync(paths.metaPath)) return null;
265
+ const raw = JSON.parse(readFileSync(paths.metaPath, 'utf8'));
266
+ return {
267
+ mime: typeof raw?.mime === 'string' ? raw.mime : 'application/octet-stream',
268
+ filename: typeof raw?.filename === 'string' ? raw.filename : null,
269
+ size: Number(raw?.size) || 0,
270
+ capturedAt: Number(raw?.capturedAt) || 0
271
+ };
272
+ } catch (_) {
273
+ return null; // corrupt sidecar → treat as absent (a re-download heals it)
274
+ }
275
+ }
276
+
277
+ export function readMedia(userId: string, messageId: string): { data: Buffer; meta: MediaMeta } | null {
278
+ const meta = mediaExists(userId, messageId);
279
+ if (!meta) return null;
280
+ try {
281
+ return { data: readFileSync(mediaPaths(userId, messageId)!.dataPath), meta };
282
+ } catch (_) {
283
+ return null; // raced a sweep/delete between the exists check and the read
284
+ }
285
+ }
286
+
287
+ // Logout privacy contract: stored media belongs to the OLD pairing. POST
288
+ // /logout wipes the session dir and the inbound queue, but without this the
289
+ // customer photos/documents stayed on disk — fetchable via GET /media — for
290
+ // up to the 48h TTL after the operator severed the pairing. Same sanitize +
291
+ // containment rules as mediaPaths; recomputing the cached disk total keeps
292
+ // downloadPolicy's cap check honest after a bulk removal.
293
+ export function clearUserMedia(userId: string): boolean {
294
+ const safeUser = sanitizeId(userId);
295
+ if (!safeUser) return false;
296
+ const root = resolve(mediaRootResolver());
297
+ const dir = resolve(root, safeUser);
298
+ if (!dir.startsWith(root + sep)) return false;
299
+ try {
300
+ rmSync(dir, { recursive: true, force: true });
301
+ } catch (e) {
302
+ console.error(`Failed to clear media dir for ${userId}`, e);
303
+ return false;
304
+ }
305
+ cachedDiskBytes = computeDiskBytes();
306
+ return true;
307
+ }
308
+
309
+ // Idempotent: deleting media that was never stored (or already swept) is fine.
310
+ export function deleteMedia(userId: string, messageId: string): boolean {
311
+ const paths = mediaPaths(userId, messageId);
312
+ if (!paths) return false;
313
+ const meta = mediaExists(userId, messageId);
314
+ try {
315
+ rmSync(paths.dataPath, { force: true });
316
+ rmSync(paths.metaPath, { force: true });
317
+ if (meta && cachedDiskBytes !== null) {
318
+ cachedDiskBytes = Math.max(0, cachedDiskBytes - meta.size);
319
+ }
320
+ return true;
321
+ } catch (e) {
322
+ console.error(`Failed to delete media ${messageId} for ${userId}`, e);
323
+ return false;
324
+ }
325
+ }
326
+
327
+ // ── Download policy ──
328
+
329
+ // Stickers and videos are deliberately not downloaded (no CMS rendering need,
330
+ // videos routinely blow the cap); view-once media must not be persisted at
331
+ // all — the sender chose ephemerality.
332
+ const DOWNLOADABLE_TYPES = new Set(['image', 'audio', 'ptt', 'document']);
333
+
334
+ export function downloadPolicy(type: string, size: number, viewOnce = false): MediaPolicy {
335
+ if (viewOnce || !DOWNLOADABLE_TYPES.has(type)) return { download: false, reason: 'unsupported_type' };
336
+ const cap = type === 'document' ? maxDocumentBytes() : INLINE_MEDIA_MAX_BYTES;
337
+ if (size > cap) return { download: false, reason: 'too_large' };
338
+ // No disk check here any more. A user is NEVER skipped on disk grounds:
339
+ // the per-user 1GB cap is enforced AFTER each successful write by
340
+ // enforceUserCap, which rolls the user's OLDEST media off (WhatsApp's
341
+ // tap-to-download model — an evicted item re-downloads on demand). Because
342
+ // we download-then-evict, we only ever write at most one per-message cap
343
+ // (≤25MB) over the limit before rolling back under it — harmless. The
344
+ // per-MESSAGE size gate above still stands; maxDiskBytes() remains only an
345
+ // absolute global backstop that the per-user cap keeps us far beneath.
346
+ return { download: true };
347
+ }
348
+
349
+ // ── TTL sweep ──
350
+
351
+ // Remove media older than the TTL (the host attaches what it wants well within
352
+ // 48h; everything else is abandoned) plus orphaned sidecars, then refresh the
353
+ // disk-cap accounting. index.ts runs this on the existing reaper interval.
354
+ export function sweepExpired(nowMs = Date.now()): number {
355
+ const ttl = mediaTtlMs();
356
+ let removed = 0;
357
+ try {
358
+ const root = mediaRootResolver();
359
+ for (const user of readdirSync(root, { withFileTypes: true })) {
360
+ if (!user.isDirectory()) continue;
361
+ const dir = join(root, user.name);
362
+ for (const file of readdirSync(dir)) {
363
+ if (isSidecarName(file)) continue;
364
+ const dataPath = join(dir, file);
365
+ if (nowMs - capturedAtFor(dataPath, `${dataPath}${SIDECAR_SUFFIX}`) > ttl) {
366
+ rmSync(dataPath, { force: true });
367
+ rmSync(`${dataPath}${SIDECAR_SUFFIX}`, { force: true });
368
+ removed += 1;
369
+ }
370
+ }
371
+ // Sidecars whose payload is already gone are garbage regardless of age.
372
+ for (const file of readdirSync(dir)) {
373
+ if (isSidecarName(file) && !existsSync(join(dir, file.slice(0, -SIDECAR_SUFFIX.length)))) {
374
+ rmSync(join(dir, file), { force: true });
375
+ }
376
+ }
377
+ }
378
+ } catch (_) { /* media root not created yet → nothing to sweep */ }
379
+ cachedDiskBytes = computeDiskBytes();
380
+ return removed;
381
+ }
382
+
383
+ function capturedAtFor(dataPath: string, metaPath: string): number {
384
+ try {
385
+ const raw = JSON.parse(readFileSync(metaPath, 'utf8'));
386
+ const capturedAt = Number(raw?.capturedAt);
387
+ if (Number.isFinite(capturedAt) && capturedAt > 0) return capturedAt;
388
+ } catch (_) { /* missing/corrupt sidecar → fall back to the file clock */ }
389
+ try {
390
+ return statSync(dataPath).mtimeMs;
391
+ } catch (_) {
392
+ return 0; // unstattable → looks ancient → swept
393
+ }
394
+ }
395
+
396
+ // ── Download pipeline ──
397
+
398
+ // Policy pre-check on the declared size → bounded downloadMedia() → policy
399
+ // re-check on the actual bytes → persist. Every failure mode returns a typed
400
+ // 'unavailable' verdict instead of throwing: the message itself must always
401
+ // reach the host, with or without its bytes.
402
+ export async function resolveMediaForMessage(
403
+ userId: string,
404
+ msg: any,
405
+ deps: { timeoutMs?: number } = {}
406
+ ): Promise<MediaResolution> {
407
+ // Must mirror normalizeInbound's messageId fallback (inbound.ts) so the
408
+ // stored file is addressable by the id the host received. Like there, the
409
+ // fallback keys on the COUNTERPARTY: on fromMe the `from` is the
410
+ // operator's own jid, shared by every chat — keying on it would store the
411
+ // bytes under one id while the wire advertises another (host GET 404s),
412
+ // and two same-second sends to different customers would collide on disk.
413
+ const counterparty = (msg?.fromMe ? msg?.to : msg?.from) || '';
414
+ const messageId = (msg?.id && msg.id._serialized) || `${counterparty}-${msg?.timestamp}`;
415
+ if (!mediaPaths(userId, messageId)) {
416
+ return { mediaStatus: 'unavailable', mediaError: 'invalid_id' };
417
+ }
418
+
419
+ // Reconnect backfill replays recent messages — serve the copy already on
420
+ // disk instead of re-downloading (and re-counting against the disk cap).
421
+ const existing = mediaExists(userId, messageId);
422
+ if (existing) return availableResolution(existing.mime, existing.filename, existing.size);
423
+
424
+ const type = msg?.type || 'chat';
425
+ const viewOnce = !!(msg?._data?.isViewOnce);
426
+ const declaredSize = Number(msg?._data?.size) || 0; // 0 = unknown → re-checked post-download
427
+ const pre = downloadPolicy(type, declaredSize, viewOnce);
428
+ if (!pre.download) return { mediaStatus: 'unavailable', mediaError: pre.reason };
429
+
430
+ let media: any;
431
+ try {
432
+ media = await withTimeout(
433
+ (async () => msg.downloadMedia())(),
434
+ deps.timeoutMs ?? MEDIA_DOWNLOAD_TIMEOUT_MS
435
+ );
436
+ } catch (e) {
437
+ console.error(`Media download failed for ${userId}/${messageId}`, e);
438
+ return { mediaStatus: 'unavailable', mediaError: 'download_failed' };
439
+ }
440
+ // whatsapp-web.js resolves undefined when the media is no longer on
441
+ // WhatsApp's servers (old message, sender deleted it, …).
442
+ if (!media || !media.data) return { mediaStatus: 'unavailable', mediaError: 'expired' };
443
+
444
+ const data = Buffer.from(media.data, 'base64');
445
+ // The declared size is advisory — re-apply the caps to the real bytes.
446
+ const post = downloadPolicy(type, data.byteLength, viewOnce);
447
+ if (!post.download) return { mediaStatus: 'unavailable', mediaError: post.reason };
448
+
449
+ const mime = media.mimetype || msg?._data?.mimetype || 'application/octet-stream';
450
+ const filename = media.filename || msg?._data?.filename || null;
451
+ if (!writeMedia(userId, messageId, data, { mime, filename })) {
452
+ return { mediaStatus: 'unavailable', mediaError: 'download_failed' };
453
+ }
454
+ return availableResolution(mime, filename, data.byteLength);
455
+ }
456
+
457
+ function availableResolution(mime: string, filename: string | null, size: number): MediaResolution {
458
+ return {
459
+ mediaStatus: 'available',
460
+ mediaMime: mime,
461
+ ...(filename ? { mediaFilename: filename } : {}),
462
+ mediaSize: size
463
+ };
464
+ }
465
+
466
+ function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
467
+ return new Promise((resolvePromise, rejectPromise) => {
468
+ const timer = setTimeout(
469
+ () => rejectPromise(new Error(`media download timed out after ${ms}ms`)),
470
+ ms
471
+ );
472
+ promise.then(
473
+ (value) => { clearTimeout(timer); resolvePromise(value); },
474
+ (err) => { clearTimeout(timer); rejectPromise(err); }
475
+ );
476
+ });
477
+ }
478
+
479
+ // ── Route responses ──
480
+ //
481
+ // Full Response builders for GET/DELETE /media/:userId/:messageId so index.ts
482
+ // stays glue-only and the route contract is unit-testable. Neither handler may
483
+ // ever create a WhatsApp client (same fast-reject rule as GET /inbound): they
484
+ // touch only the on-disk store.
485
+
486
+ // X-WA-Token check shared by both /media routes — ENFORCED ONLY when the
487
+ // service has WHATSAPP_WEBHOOK_TOKEN set (mirrors the host's webhook receiver,
488
+ // which reuses the same shared secret in the other direction). Hashing both
489
+ // sides first gives constant-length inputs for the timing-safe comparison.
490
+ export function verifyMediaToken(provided: string | undefined, expected: string | undefined): boolean {
491
+ if (!expected) return true;
492
+ const a = createHash('sha256').update(provided ?? '').digest();
493
+ const b = createHash('sha256').update(expected).digest();
494
+ return timingSafeEqual(a, b);
495
+ }
496
+
497
+ // Keep stored filenames from smuggling header syntax (quotes, CR/LF) into
498
+ // Content-Disposition.
499
+ function headerSafeFilename(name: string): string {
500
+ return name.replace(/[^A-Za-z0-9@. _-]/g, '_');
501
+ }
502
+
503
+ export function mediaGetResponse(
504
+ userId: string,
505
+ messageId: string,
506
+ token: string | undefined,
507
+ expectedToken: string | undefined
508
+ ): Response {
509
+ if (!verifyMediaToken(token, expectedToken)) {
510
+ return Response.json({ error: 'unauthorized' }, { status: 401 });
511
+ }
512
+ const found = readMedia(userId, messageId); // sanitizes both ids itself
513
+ if (!found) {
514
+ // Unknown, swept, deleted AND invalid ids all answer the same 404 —
515
+ // the route must not reveal which.
516
+ return Response.json({ error: 'not_found' }, { status: 404 });
517
+ }
518
+ return new Response(found.data, {
519
+ status: 200,
520
+ headers: {
521
+ 'Content-Type': found.meta.mime || 'application/octet-stream',
522
+ 'Content-Length': String(found.data.byteLength),
523
+ 'Content-Disposition': found.meta.filename
524
+ ? `attachment; filename="${headerSafeFilename(found.meta.filename)}"`
525
+ : 'attachment'
526
+ }
527
+ });
528
+ }
529
+
530
+ // Idempotent by contract: the host calls this after attaching the bytes, and a
531
+ // retry (or a TTL sweep racing it) must not turn into an error.
532
+ export function mediaDeleteResponse(
533
+ userId: string,
534
+ messageId: string,
535
+ token: string | undefined,
536
+ expectedToken: string | undefined
537
+ ): Response {
538
+ if (!verifyMediaToken(token, expectedToken)) {
539
+ return Response.json({ error: 'unauthorized' }, { status: 401 });
540
+ }
541
+ deleteMedia(userId, messageId);
542
+ return Response.json({ success: true });
543
+ }
544
+
545
+ // Test helper: wipe in-memory state between examples (mirrors resetInboundState).
546
+ export function resetMediaState() {
547
+ cachedDiskBytes = null;
548
+ }
@@ -0,0 +1,20 @@
1
+ import { test, expect } from 'bun:test';
2
+ import { sentMessageId } from './send';
3
+
4
+ // The id the host stores against its outbound record — it MUST be the real
5
+ // serialized WhatsApp id so the fromMe echo of this send dedupes on it.
6
+ test('sentMessageId returns the serialized id of the sent message', () => {
7
+ const sent = { id: { _serialized: 'true_919999000001@c.us_ABC' } };
8
+ expect(sentMessageId(sent)).toBe('true_919999000001@c.us_ABC');
9
+ });
10
+
11
+ // Null fallback, never a fabricated id: a made-up id matches no echo but
12
+ // would still occupy the host's unique message-id slot, blocking the echo
13
+ // from being adopted onto the right record.
14
+ test('sentMessageId falls back to null when no id is available', () => {
15
+ expect(sentMessageId(undefined)).toBeNull(); // library resolved nothing
16
+ expect(sentMessageId(null)).toBeNull();
17
+ expect(sentMessageId({})).toBeNull(); // Message without an id
18
+ expect(sentMessageId({ id: {} })).toBeNull(); // id without a serialization
19
+ expect(sentMessageId({ id: { _serialized: '' } })).toBeNull(); // empty id is no id
20
+ });
@@ -0,0 +1,17 @@
1
+ // /send response helpers (pure, unit-testable — see send.test.ts).
2
+ //
3
+ // Kept separate from index.ts (which calls Bun.serve() at import time) so the
4
+ // wire shape can be tested without booting the server or whatsapp-web.js.
5
+
6
+ // The real WhatsApp id of a just-sent message, for the /send response. Hosts
7
+ // store it on their outbound record so the message_create echo of this very
8
+ // send (two-way capture replays our own messages too) dedupes on messageId
9
+ // instead of duplicating as an "operator app" bubble.
10
+ //
11
+ // Null — never a fabricated id — when the library hands nothing back: a
12
+ // made-up id matches no echo, yet would still occupy the host's unique
13
+ // message-id slot and block the echo from being adopted onto the right
14
+ // record.
15
+ export function sentMessageId(sent: any): string | null {
16
+ return (sent && sent.id && sent.id._serialized) || null;
17
+ }
@@ -1,4 +1,4 @@
1
1
  module WhatsAppNotifier
2
- VERSION = "0.6.0"
2
+ VERSION = "0.8.0"
3
3
 
4
4
  end