whatsapp_notifier 0.6.0 → 0.7.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1a84b4298c91af2e175b2a8aa46fd2b9101e5b5729558f1387325a8c39894a31
4
- data.tar.gz: e11b629a0b342288eb8199ed95eaf6aa72f9d7ce557984c76e71b58f2e37bb4c
3
+ metadata.gz: 554f8799b1a8ce9c71c59f4b801d3ea5f614a313f2ce22df8f93a6983ba06780
4
+ data.tar.gz: 0015b28a62c3471d63fbaa13060faec05084ede1acd6df72018d6e7fee38ca4a
5
5
  SHA512:
6
- metadata.gz: ed49385c1ea444b94a1ee99ca5ec1c891145bb5474a63544e314a715f85dab8712b743908ce1765efd9c1981b476287c62ed24930061a5fb5a667f2ff781011a
7
- data.tar.gz: 5b7eaec3a131a137edc5ce9dcba1e7cadcabe74bf210ef167975bb6952ac087b803a31d573f6c67b62f188be8d6c6593cd503a371cbdf57e91337a3ddf06f14e
6
+ metadata.gz: 5c8eed1d767807dfbd1de97daa5f3f017f5a853acc7c008a66f99523d135c12b3f420e8866b03e0523e53d45b2df4efdc29036b527dc359918b99b23b19e279e
7
+ data.tar.gz: 0a065dd16263cba9b7f4f8c27f5b4d5b0aae1ba2b0e0b55f8d1a767b61e08804fbdbd432998532298aa33ce2c5cefe2e9b6841c3a2e35c59dda0752e4a86a255
data/README.md CHANGED
@@ -57,9 +57,11 @@ status = WhatsAppNotifier.connection_status(metadata: { user_id: current_user.id
57
57
  ## Log out (disconnect + clear session)
58
58
 
59
59
  Explicitly disconnects the user and wipes their saved WhatsApp session from the
60
- service, so the next connect starts fresh with a new QR. Call this from a
61
- user-initiated "Log out WhatsApp" actionNOT from your app's sign-out, which
62
- should leave the WhatsApp session intact for the next login.
60
+ service including any downloaded inbound media and queued replies, which
61
+ belong to the old pairingso the next connect starts fresh with a new QR.
62
+ Call this from a user-initiated "Log out WhatsApp" action NOT from your
63
+ app's sign-out, which should leave the WhatsApp session intact for the next
64
+ login.
63
65
 
64
66
  ```ruby
65
67
  WhatsAppNotifier.logout(metadata: { user_id: current_user.id })
@@ -144,6 +146,21 @@ creates a WhatsApp client — so it is safe to poll every few seconds:
144
146
  those have a fully hydrated WhatsApp Web store. Prometheus metrics live at
145
147
  `GET /metrics`.
146
148
 
149
+ ## Upgrading to 0.7.0
150
+
151
+ - **If your app monkey-patches `WhatsAppNotifier::WebAdapter#request`** (a
152
+ common trick to bolt TLS onto the service URL): the shared JSON path now
153
+ also carries `DELETE` (`delete_media`) and attaches `X-WA-Token` when
154
+ `WHATSAPP_WEBHOOK_TOKEN` is set. A patch that only routes `POST`/`GET` or
155
+ drops headers will break the new media calls. Prefer retiring the patch
156
+ entirely — the adapter now honors `https://` service URLs natively on both
157
+ the JSON control plane and the binary media path (`use_ssl` follows the URL
158
+ scheme), so TLS no longer needs a patch.
159
+ - **Logout now wipes stored media**: `POST /logout` removes the user's
160
+ downloaded inbound media along with the session dir and queued replies.
161
+ Fetch (and ideally `delete_media`) anything you want to keep before logging
162
+ a user out.
163
+
147
164
  ## Notes
148
165
 
149
166
  - This gem uses WhatsApp Web automation. Use responsibly and follow WhatsApp policies.
@@ -14,6 +14,7 @@ module WhatsAppNotifier
14
14
  index.ts
15
15
  inbound.ts
16
16
  init_gate.ts
17
+ media.ts
17
18
  metrics.ts
18
19
  sessions.ts
19
20
  package.json
@@ -31,6 +31,14 @@ module WhatsAppNotifier
31
31
  provider_for(provider || @configuration.provider).fetch_inbound(metadata: metadata)
32
32
  end
33
33
 
34
+ def fetch_media(message_id:, metadata: {}, provider: nil)
35
+ provider_for(provider || @configuration.provider).fetch_media(message_id: message_id, metadata: metadata)
36
+ end
37
+
38
+ def delete_media(message_id:, metadata: {}, provider: nil)
39
+ provider_for(provider || @configuration.provider).delete_media(message_id: message_id, metadata: metadata)
40
+ end
41
+
34
42
  def logout(metadata: {}, provider: nil)
35
43
  provider_for(provider || @configuration.provider).logout(metadata: metadata)
36
44
  end
@@ -61,6 +61,27 @@ module WhatsAppNotifier
61
61
  adapter.fetch_inbound(metadata: metadata)
62
62
  end
63
63
 
64
+ # Media helpers are optional adapter capabilities (added in v0.7.0) —
65
+ # same guard idiom as fetch_inbound so older adapters fail with a clear
66
+ # ConfigurationError rather than NoMethodError.
67
+ def fetch_media(message_id:, metadata: {})
68
+ raise ConfigurationError, "web automation provider is disabled" unless configuration.web_automation_enabled
69
+
70
+ adapter = configuration.web_adapter
71
+ raise ConfigurationError, "web_adapter does not support media fetch (upgrade to a fetch_media-capable adapter)" unless adapter.respond_to?(:fetch_media)
72
+
73
+ adapter.fetch_media(message_id: message_id, metadata: metadata)
74
+ end
75
+
76
+ def delete_media(message_id:, metadata: {})
77
+ raise ConfigurationError, "web automation provider is disabled" unless configuration.web_automation_enabled
78
+
79
+ adapter = configuration.web_adapter
80
+ raise ConfigurationError, "web_adapter does not support media deletion (upgrade to a delete_media-capable adapter)" unless adapter.respond_to?(:delete_media)
81
+
82
+ adapter.delete_media(message_id: message_id, metadata: metadata)
83
+ end
84
+
64
85
  def logout(metadata: {})
65
86
  raise ConfigurationError, "web automation provider is disabled" unless configuration.web_automation_enabled
66
87
 
@@ -15,9 +15,12 @@ import {
15
15
  clearInbound,
16
16
  shouldCapture,
17
17
  normalizeInbound,
18
+ processInbound,
18
19
  resetInboundState,
19
- type ChatResolver
20
+ type ChatResolver,
21
+ type InboundMsg
20
22
  } from './inbound';
23
+ import { configureMedia, resolveMediaForMessage, mediaDiskBytes, resetMediaState } from './media';
21
24
 
22
25
  const root = mkdtempSync(join(tmpdir(), 'wa-inbound-'));
23
26
  const dirFor = (userId: string) => join(root, `session-user-${userId}`);
@@ -215,3 +218,148 @@ test('normalizeInbound maps fields and falls back on missing id', () => {
215
218
  expect(b.body).toBe('');
216
219
  expect(b.type).toBe('chat');
217
220
  });
221
+
222
+ // ── processInbound (capture pipeline ordering) ──
223
+
224
+ const LID_FROM = '125417440686124@lid';
225
+
226
+ function mediaMsg(overrides: any = {}) {
227
+ return msg({
228
+ hasMedia: true,
229
+ type: 'image',
230
+ _data: { size: 10, mimetype: 'image/jpeg' },
231
+ getContact: async () => ({ pushname: 'Asha' }),
232
+ downloadMedia: async () => ({
233
+ data: Buffer.from('jpeg-bytes').toString('base64'),
234
+ mimetype: 'image/jpeg',
235
+ filename: 'beach.jpg'
236
+ }),
237
+ ...overrides
238
+ });
239
+ }
240
+
241
+ // Wire the real media store at a per-test root so "nothing written" is a
242
+ // statement about the disk, not about a stub.
243
+ function useMediaRoot(name: string) {
244
+ const mediaRoot = join(root, name);
245
+ configureMedia(() => mediaRoot);
246
+ resetMediaState();
247
+ return mediaRoot;
248
+ }
249
+
250
+ test('processInbound drops an unresolvable @lid BEFORE any media download', async () => {
251
+ const mediaRoot = useMediaRoot('media-lid-drop');
252
+ let resolveCalls = 0;
253
+ let downloads = 0;
254
+ const m = mediaMsg({
255
+ from: LID_FROM,
256
+ getContact: async () => ({ pushname: 'Asha' }), // no phone → unresolvable
257
+ downloadMedia: async () => { downloads += 1; return null; }
258
+ });
259
+
260
+ await processInbound('pi1', m, {
261
+ resolveMedia: (u, message) => { resolveCalls += 1; return resolveMediaForMessage(u, message); }
262
+ });
263
+
264
+ expect(resolveCalls).toBe(0); // dropped before the download path
265
+ expect(downloads).toBe(0);
266
+ expect(drainInbound('pi1')).toEqual([]);
267
+ expect(existsSync(mediaRoot)).toBe(false); // nothing written to disk
268
+ expect(mediaDiskBytes()).toBe(0);
269
+ });
270
+
271
+ test('processInbound resolves an @lid sender, then downloads for the kept message', async () => {
272
+ useMediaRoot('media-lid-kept');
273
+ rememberTarget('pi2', CUST); // resolved phone is a known outbound target
274
+ const m = mediaMsg({
275
+ from: LID_FROM,
276
+ getContact: async () => ({ pushname: 'Asha', number: '919999000001' })
277
+ });
278
+
279
+ const pushed: InboundMsg[] = [];
280
+ await processInbound('pi2', m, {
281
+ resolveMedia: resolveMediaForMessage,
282
+ push: (_u, inbound) => pushed.push(inbound)
283
+ });
284
+
285
+ const drained = drainInbound('pi2');
286
+ expect(drained.length).toBe(1);
287
+ expect(drained[0].from).toBe(CUST); // resolved to the phone
288
+ expect(drained[0].senderName).toBe('Asha');
289
+ expect(drained[0].mediaStatus).toBe('available');
290
+ expect(drained[0].mediaSize).toBe(10);
291
+ expect(pushed).toEqual(drained); // webhook saw the same payload
292
+ expect(loadTargets('pi2').has(LID_FROM)).toBe(true); // alias allowlisted for backfill
293
+ });
294
+
295
+ test('processInbound keeps an @c.us message when the contact lookup fails', async () => {
296
+ useMediaRoot('media-contact-fail');
297
+ const m = msg({ getContact: async () => { throw new Error('boom'); } });
298
+
299
+ await processInbound('pi3', m, { resolveMedia: resolveMediaForMessage });
300
+
301
+ const drained = drainInbound('pi3');
302
+ expect(drained.length).toBe(1);
303
+ expect(drained[0].from).toBe(CUST);
304
+ expect('senderName' in drained[0]).toBe(false);
305
+ });
306
+
307
+ test('processInbound enqueues type-only when the media resolver reports a failure', async () => {
308
+ const m = mediaMsg({});
309
+ await processInbound('pi4', m, {
310
+ resolveMedia: async () => ({ mediaStatus: 'unavailable', mediaError: 'download_failed' })
311
+ });
312
+
313
+ const drained = drainInbound('pi4');
314
+ expect(drained.length).toBe(1);
315
+ expect(drained[0].hasMedia).toBe(true);
316
+ expect(drained[0].mediaStatus).toBe('unavailable');
317
+ expect(drained[0].mediaError).toBe('download_failed');
318
+ });
319
+
320
+ test('processInbound rejects filtered messages without resolving anything', async () => {
321
+ let resolveCalls = 0;
322
+ const deps = { resolveMedia: async () => { resolveCalls += 1; return { mediaStatus: 'available' as const }; } };
323
+
324
+ await processInbound('pi5', mediaMsg({ fromMe: true }), deps);
325
+ await processInbound('pi5', mediaMsg({ from: '12@g.us' }), deps);
326
+
327
+ expect(resolveCalls).toBe(0);
328
+ expect(drainInbound('pi5')).toEqual([]);
329
+ });
330
+
331
+ // 0.6.0 wire back-compat: text payloads must keep the exact five-field shape —
332
+ // no media keys, not even hasMedia:false (hosts key-gate on hasMedia presence).
333
+ test('normalizeInbound keeps the 0.6.0 shape for non-media messages', () => {
334
+ const plain = normalizeInbound(msg({ hasMedia: false }));
335
+ expect(Object.keys(plain).sort()).toEqual(['body', 'from', 'messageId', 'timestamp', 'type']);
336
+ });
337
+
338
+ test('normalizeInbound flags hasMedia even when no verdict is supplied', () => {
339
+ const out = normalizeInbound(msg({ hasMedia: true, type: 'image' }));
340
+ expect(out.hasMedia).toBe(true);
341
+ expect(out.type).toBe('image');
342
+ expect('mediaStatus' in out).toBe(false); // verdict is capture-level
343
+ });
344
+
345
+ test('normalizeInbound merges an available media verdict into the payload', () => {
346
+ const out = normalizeInbound(msg({ hasMedia: true, type: 'image' }), {
347
+ mediaStatus: 'available', mediaMime: 'image/jpeg', mediaFilename: 'beach.jpg', mediaSize: 1024
348
+ });
349
+ expect(out).toEqual({
350
+ from: CUST, body: 'hello', messageId: 'true_919999000001@c.us_ABC',
351
+ timestamp: 1717000000, type: 'image',
352
+ hasMedia: true, mediaStatus: 'available',
353
+ mediaMime: 'image/jpeg', mediaFilename: 'beach.jpg', mediaSize: 1024
354
+ });
355
+ });
356
+
357
+ test('normalizeInbound merges an unavailable verdict with its typed reason', () => {
358
+ const out = normalizeInbound(msg({ hasMedia: true, type: 'video' }), {
359
+ mediaStatus: 'unavailable', mediaError: 'unsupported_type'
360
+ });
361
+ expect(out.hasMedia).toBe(true);
362
+ expect(out.mediaStatus).toBe('unavailable');
363
+ expect(out.mediaError).toBe('unsupported_type');
364
+ expect('mediaMime' in out).toBe(false);
365
+ });
@@ -19,6 +19,27 @@ export interface InboundMsg {
19
19
  messageId: string;
20
20
  timestamp: number;
21
21
  type: string;
22
+ // 0.7.0 media + sender enrichment. ALL optional and only present when the
23
+ // message actually carries them, so the wire format stays byte-compatible
24
+ // with 0.6.0 hosts (and 0.7.0 hosts can key-gate on hasMedia).
25
+ hasMedia?: boolean;
26
+ mediaStatus?: 'available' | 'unavailable';
27
+ mediaError?: string;
28
+ mediaMime?: string;
29
+ mediaFilename?: string;
30
+ mediaSize?: number;
31
+ senderName?: string;
32
+ }
33
+
34
+ // Media verdict merged into the payload by captureInbound — structurally
35
+ // matches media.ts's MediaResolution without importing it (inbound stays the
36
+ // dependency-free core).
37
+ export interface InboundMediaInfo {
38
+ mediaStatus: 'available' | 'unavailable';
39
+ mediaError?: string;
40
+ mediaMime?: string;
41
+ mediaFilename?: string;
42
+ mediaSize?: number;
22
43
  }
23
44
 
24
45
  export const INBOUND_QUEUE_CAP = 1000;
@@ -113,15 +134,82 @@ export function shouldCapture(userId: string, msg: any): boolean {
113
134
  return true;
114
135
  }
115
136
 
116
- export function normalizeInbound(msg: any): InboundMsg {
137
+ export function normalizeInbound(msg: any, media?: InboundMediaInfo): InboundMsg {
117
138
  const from: string = msg.from || '';
118
- return {
139
+ const inbound: InboundMsg = {
119
140
  from,
120
141
  body: msg.body || '',
121
142
  messageId: (msg.id && msg.id._serialized) || `${from}-${msg.timestamp}`,
122
143
  timestamp: msg.timestamp || Math.floor(Date.now() / 1000),
123
144
  type: msg.type || 'chat'
124
145
  };
146
+ // Media keys are added ONLY for media messages so text payloads keep the
147
+ // exact 0.6.0 five-field shape (hosts key-gate on hasMedia presence).
148
+ if (msg.hasMedia) inbound.hasMedia = true;
149
+ if (media) Object.assign(inbound, media);
150
+ return inbound;
151
+ }
152
+
153
+ // ── Capture pipeline ──
154
+ //
155
+ // Extracted from index.ts so the ordering contract is unit-testable:
156
+ // sanity filter → contact/@lid sender resolution (drop early) → media
157
+ // download → normalize → enqueue → optional webhook push. The media resolver
158
+ // is injected (media.ts's resolveMediaForMessage in production) so this file
159
+ // stays the dependency-free core.
160
+ export interface CaptureDeps {
161
+ resolveMedia: (userId: string, msg: any) => Promise<InboundMediaInfo>;
162
+ push?: (userId: string, msg: InboundMsg) => void;
163
+ }
164
+
165
+ export async function processInbound(userId: string, msg: any, deps: CaptureDeps) {
166
+ if (!shouldCapture(userId, msg)) return;
167
+
168
+ // One best-effort contact lookup feeds both the sender's display name
169
+ // and the @lid phone resolution. Failure must never drop the message —
170
+ // unless the sender is an unresolvable @lid (handled below).
171
+ let contact: any;
172
+ try {
173
+ contact = await msg.getContact();
174
+ } catch (e) {
175
+ console.error(`contact lookup failed for ${userId}`, e);
176
+ }
177
+
178
+ // Resolve the sender BEFORE downloading media. Newer WhatsApp delivers
179
+ // the reply's `from` as an @lid privacy id with no phone number, which
180
+ // the host can't match; if the contact can't supply the real phone the
181
+ // message is dropped — and a dropped message must not have cost a
182
+ // download that leaves up to 25MB of unreferenced bytes on disk.
183
+ const rawFrom: string = msg.from || '';
184
+ let from = rawFrom;
185
+ if (rawFrom.endsWith('@lid')) {
186
+ const num = contact && (contact.number || (contact.id && contact.id.user));
187
+ if (num) from = `${String(num).replace(/\D/g, '')}@c.us`;
188
+ // Still an @lid => no phone to match or scope by. Drop it rather than
189
+ // forward an unmatchable, unpurgeable plaintext body.
190
+ if (from.endsWith('@lid')) return;
191
+ }
192
+
193
+ // Only a kept message earns the download. Every resolver failure mode
194
+ // returns an 'unavailable' verdict instead of throwing, so the message
195
+ // still reaches the host type-only.
196
+ let media: InboundMediaInfo | undefined;
197
+ if (msg.hasMedia) {
198
+ media = await deps.resolveMedia(userId, msg);
199
+ }
200
+
201
+ const inbound = normalizeInbound(msg, media);
202
+ inbound.from = from;
203
+ const senderName = contact && (contact.pushname || contact.name || contact.shortName);
204
+ if (senderName) inbound.senderName = String(senderName);
205
+ if (rawFrom.endsWith('@lid')) {
206
+ // Known recipient replying from a privacy-number chat: allowlist the
207
+ // @lid chat id too, so the reconnect backfill can re-open this chat.
208
+ rememberLidAlias(userId, rawFrom, from);
209
+ }
210
+
211
+ enqueueInbound(userId, inbound);
212
+ if (deps.push) deps.push(userId, inbound);
125
213
  }
126
214
 
127
215
  // Minimal slice of whatsapp-web.js Client that backfill needs — a seam so the
@@ -16,14 +16,19 @@ import {
16
16
  InboundMsg,
17
17
  configureInbound,
18
18
  rememberTarget,
19
- rememberLidAlias,
20
19
  backfillTargets,
21
- enqueueInbound,
22
20
  drainInbound,
23
21
  clearInbound,
24
- shouldCapture,
25
- normalizeInbound
22
+ processInbound
26
23
  } from './inbound';
24
+ import {
25
+ configureMedia,
26
+ resolveMediaForMessage,
27
+ sweepExpired,
28
+ clearUserMedia,
29
+ mediaGetResponse,
30
+ mediaDeleteResponse
31
+ } from './media';
27
32
 
28
33
  const app = new Hono();
29
34
  const port = Number(process.env.PORT || 3001);
@@ -71,8 +76,10 @@ const initRetries = new InitRetryLimiter(MAX_INIT_RETRIES);
71
76
  const WEBHOOK_URL = process.env.WHATSAPP_WEBHOOK_URL;
72
77
  const WEBHOOK_TOKEN = process.env.WHATSAPP_WEBHOOK_TOKEN;
73
78
 
74
- // Tell the inbound core how to resolve each user's on-disk session dir.
79
+ // Tell the inbound core how to resolve each user's on-disk session dir, and
80
+ // the media store where downloaded inbound media lives (survives restarts).
75
81
  configureInbound(sessionDirForUser);
82
+ configureMedia(() => join(SESSION_BASE_DIR, 'media'));
76
83
 
77
84
  async function pushWebhook(userId: string, msg: InboundMsg) {
78
85
  if (!WEBHOOK_URL) return;
@@ -90,32 +97,15 @@ async function pushWebhook(userId: string, msg: InboundMsg) {
90
97
  }
91
98
  }
92
99
 
93
- // Wrapper: sanity filter normalize resolve @lid enqueue optional webhook.
100
+ // Wrapper around the testable pipeline in inbound.ts (sanity filtersender
101
+ // resolution with early @lid drop → media download → normalize → enqueue →
102
+ // webhook). The catch keeps a single bad message from killing the listener.
94
103
  async function captureInbound(userId: string, msg: any) {
95
104
  try {
96
- if (!shouldCapture(userId, msg)) return;
97
- const inbound = normalizeInbound(msg);
98
- // Newer WhatsApp delivers the reply's `from` as an @lid privacy id with
99
- // no phone number, which the host can't match. Resolve it to the real
100
- // phone via the contact so callers always get a phone-number @c.us.
101
- if (inbound.from.endsWith('@lid')) {
102
- const rawFrom = inbound.from;
103
- try {
104
- const contact = await msg.getContact();
105
- const num = contact && (contact.number || (contact.id && contact.id.user));
106
- if (num) inbound.from = `${String(num).replace(/\D/g, '')}@c.us`;
107
- } catch (e) {
108
- console.error(`lid->phone resolve failed for ${userId}`, e);
109
- }
110
- // Still an @lid => no phone to match or scope by. Drop it rather than
111
- // forward an unmatchable, unpurgeable plaintext body.
112
- if (inbound.from.endsWith('@lid')) return;
113
- // Known recipient replying from a privacy-number chat: allowlist the
114
- // @lid chat id too, so the reconnect backfill can re-open this chat.
115
- rememberLidAlias(userId, rawFrom, inbound.from);
116
- }
117
- enqueueInbound(userId, inbound);
118
- pushWebhook(userId, inbound);
105
+ await processInbound(userId, msg, {
106
+ resolveMedia: resolveMediaForMessage,
107
+ push: pushWebhook
108
+ });
119
109
  } catch (e) {
120
110
  console.error(`captureInbound error for ${userId}`, e);
121
111
  }
@@ -460,6 +450,14 @@ setInterval(() => {
460
450
  destroyClient(userId, wipe).catch(console.error);
461
451
  }
462
452
  }
453
+
454
+ // Evict downloaded inbound media past its TTL (and refresh the disk-cap
455
+ // accounting) on the same cadence — a sweep failure must not stop reaping.
456
+ try {
457
+ sweepExpired();
458
+ } catch (e) {
459
+ console.error('Media sweep failed', e);
460
+ }
463
461
  }, SWEEP_INTERVAL_MS);
464
462
 
465
463
  // API Routes
@@ -510,6 +508,11 @@ app.post('/logout/:userId', async (c) => {
510
508
  // anything captured between the last poll and this logout would sit in
511
509
  // memory and replay into the WRONG pairing if this userId pairs again.
512
510
  clearInbound(userId);
511
+ // Logout privacy contract: downloaded media belongs to the old pairing.
512
+ // Without this wipe, customer photos/documents stayed on disk (and
513
+ // fetchable via GET /media) for up to the 48h TTL after the operator
514
+ // severed the pairing.
515
+ clearUserMedia(userId);
513
516
  initializingClients.delete(userId);
514
517
  return c.json({ success: true });
515
518
  });
@@ -561,6 +564,16 @@ app.get('/inbound/:userId', async (c) => {
561
564
  return c.json({ messages: drainInbound(userId) });
562
565
  });
563
566
 
567
+ // GET/DELETE /media/:userId/:messageId — serve / evict downloaded inbound
568
+ // media. Token-gated when WHATSAPP_WEBHOOK_TOKEN is set; ids are sanitized in
569
+ // the store; NEVER calls getOrCreateClient (same fast-reject rule as /inbound:
570
+ // fetching bytes for a never-paired user must not boot a Chromium).
571
+ app.get('/media/:userId/:messageId', (c) =>
572
+ mediaGetResponse(c.req.param('userId'), c.req.param('messageId'), c.req.header('X-WA-Token'), WEBHOOK_TOKEN));
573
+
574
+ app.delete('/media/:userId/:messageId', (c) =>
575
+ mediaDeleteResponse(c.req.param('userId'), c.req.param('messageId'), c.req.header('X-WA-Token'), WEBHOOK_TOKEN));
576
+
564
577
 
565
578
  console.log(`Starting Multi-User WhatsApp service (Bun Native) on port ${port}...`);
566
579