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.
@@ -1,14 +1,17 @@
1
- // Inbound capture core (v0.4.0)
1
+ // Inbound capture core (v0.4.0; two-way since v0.8.0)
2
2
  //
3
3
  // Pure, testable logic for the two-way layer. Kept separate from index.ts so
4
4
  // it can be unit-tested without booting a whatsapp-web.js Client.
5
5
  //
6
- // Policy: surface real inbound 1:1 messages (phone @c.us or privacy-id @lid)
7
- // — dropping our own messages, groups (@g.us), status broadcasts, and non-text
8
- // system events (e2e_notification, call_log, revoked, …) that carry no real
9
- // reply. Captured messages buffer in an in-memory queue drained by GET
10
- // /inbound/:userId (at-least-once; the host dedupes on messageId and decides
11
- // relevance by matching the resolved phone to its own recipient records).
6
+ // Policy: surface real 1:1 messages in BOTH directions (phone @c.us or
7
+ // privacy-id @lid) customer replies AND messages the operator sends from
8
+ // the WhatsApp app itself (fromMe), so host threads show whole conversations.
9
+ // Groups (@g.us), status broadcasts, and non-text system events
10
+ // (e2e_notification, call_log, revoked, …) that carry no real reply are
11
+ // dropped. Captured messages buffer in an in-memory queue drained by GET
12
+ // /inbound/:userId (at-least-once; the host dedupes on messageId — which also
13
+ // eats the fromMe echo of its own /send calls — and decides relevance by
14
+ // matching the resolved phone to its own recipient records).
12
15
 
13
16
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
14
17
  import { join } from 'path';
@@ -19,6 +22,32 @@ export interface InboundMsg {
19
22
  messageId: string;
20
23
  timestamp: number;
21
24
  type: string;
25
+ // 0.7.0 media + sender enrichment. ALL optional and only present when the
26
+ // message actually carries them, so the wire format stays byte-compatible
27
+ // with 0.6.0 hosts (and 0.7.0 hosts can key-gate on hasMedia).
28
+ hasMedia?: boolean;
29
+ mediaStatus?: 'available' | 'unavailable';
30
+ mediaError?: string;
31
+ mediaMime?: string;
32
+ mediaFilename?: string;
33
+ mediaSize?: number;
34
+ senderName?: string;
35
+ // 0.8.0 two-way capture. Present ONLY on operator-sent messages (fromMe),
36
+ // where `to` carries the counterparty (customer) chat id the host threads
37
+ // on. Inbound payloads keep the exact pre-0.8.0 shape.
38
+ fromMe?: boolean;
39
+ to?: string;
40
+ }
41
+
42
+ // Media verdict merged into the payload by captureInbound — structurally
43
+ // matches media.ts's MediaResolution without importing it (inbound stays the
44
+ // dependency-free core).
45
+ export interface InboundMediaInfo {
46
+ mediaStatus: 'available' | 'unavailable';
47
+ mediaError?: string;
48
+ mediaMime?: string;
49
+ mediaFilename?: string;
50
+ mediaSize?: number;
22
51
  }
23
52
 
24
53
  export const INBOUND_QUEUE_CAP = 1000;
@@ -92,11 +121,62 @@ export function drainInbound(userId: string): InboundMsg[] {
92
121
  return q;
93
122
  }
94
123
 
95
- // Sanity filter for a real inbound 1:1 reply. Accepts both phone-number chats
96
- // (@c.us) and privacy-id chats (@lid — newer WhatsApp delivers replies from an
97
- // @lid). Drops own messages, groups (@g.us) and status (@broadcast). The host
98
- // app decides relevance by matching the resolved phone to its own records, so
99
- // we no longer gate on the per-send allowlist (which was unreliable).
124
+ // ── Self-send echo registry ──
125
+ //
126
+ // Every POST /send fires its own fromMe message_create echo. Letting that
127
+ // echo run the capture pipeline is pure waste: a media send re-downloads the
128
+ // attachment it just uploaded (up to the 30s download budget on the sending
129
+ // Chromium) and stores bytes nobody will ever fetch against the per-user
130
+ // media cap — at campaign scale those phantom copies churn the cap, evicting
131
+ // REAL customer inbound media the operator still wants. The echo's payload is
132
+ // redundant too: the /send response already handed the host this exact
133
+ // messageId, and the host's id-dedupe would discard the echo anyway — so a
134
+ // registry hit is suppressed ENTIRELY (no media resolution, no enqueue, no
135
+ // webhook), which also closes the host-side echo-beats-response adopt race
136
+ // for same-process sends. Messages NOT in the registry (typed on the phone,
137
+ // or replayed by the reconnect backfill) flow through unchanged.
138
+ //
139
+ // Best-effort by design: the registry is in-memory, so an echo landing after
140
+ // a service restart (or one that fires before /send finishes registering the
141
+ // id) flows through — the host's id-dedupe catches it, harmless. Bounded per
142
+ // user and TTL'd because echoes land within seconds of the send; the limits
143
+ // only have to outlive the echo lag, not the conversation.
144
+ export const SELF_SEND_MAX = 200;
145
+ export const SELF_SEND_TTL_MS = 10 * 60 * 1000;
146
+
147
+ // userId → (messageId → expiry epoch ms). Maps iterate in insertion order,
148
+ // so the first key is always the oldest send — eviction is O(evicted).
149
+ const selfSendIds = new Map<string, Map<string, number>>();
150
+
151
+ export function rememberSelfSend(userId: string, messageId: string, nowMs = Date.now()) {
152
+ let ids = selfSendIds.get(userId);
153
+ if (!ids) { ids = new Map(); selfSendIds.set(userId, ids); }
154
+ ids.set(messageId, nowMs + SELF_SEND_TTL_MS);
155
+ while (ids.size > SELF_SEND_MAX) {
156
+ ids.delete(ids.keys().next().value as string);
157
+ }
158
+ }
159
+
160
+ export function isSelfSend(userId: string, messageId: string, nowMs = Date.now()): boolean {
161
+ const ids = selfSendIds.get(userId);
162
+ const expiry = ids && ids.get(messageId);
163
+ if (expiry === undefined) return false;
164
+ if (nowMs > expiry) {
165
+ ids!.delete(messageId); // expired — forget it so the map stays lean
166
+ return false;
167
+ }
168
+ return true;
169
+ }
170
+
171
+ // Sanity filter for a real 1:1 message in either direction. Accepts both
172
+ // phone-number chats (@c.us) and privacy-id chats (@lid — newer WhatsApp
173
+ // delivers replies from an @lid). The jid gate always validates the
174
+ // COUNTERPARTY: the customer is at `from` on inbound but at `to` on
175
+ // operator-sent (fromMe) messages — gating fromMe on `from` (the operator's
176
+ // own jid, always @c.us) would let group/status posts through. Groups
177
+ // (@g.us) and status (@broadcast) are dropped. The host app decides relevance
178
+ // by matching the resolved phone to its own records, so we no longer gate on
179
+ // the per-send allowlist (which was unreliable).
100
180
  // Real human/content message types. Anything else (e2e_notification,
101
181
  // notification_template, call_log, revoked, protocol, gp2, …) is a system event
102
182
  // with no body and must NOT be surfaced as a reply.
@@ -105,23 +185,149 @@ const TEXTUAL_TYPES = new Set([
105
185
  ]);
106
186
 
107
187
  export function shouldCapture(userId: string, msg: any): boolean {
108
- if (!msg || msg.fromMe) return false;
109
- const from: string = msg.from || '';
110
- if (!from.endsWith('@c.us') && !from.endsWith('@lid')) return false;
188
+ if (!msg) return false;
189
+ const counterparty: string = (msg.fromMe ? msg.to : msg.from) || '';
190
+ if (!counterparty.endsWith('@c.us') && !counterparty.endsWith('@lid')) return false;
111
191
  if (msg.isStatus) return false;
112
192
  if (msg.type && !TEXTUAL_TYPES.has(msg.type)) return false; // drop system events
113
193
  return true;
114
194
  }
115
195
 
116
- export function normalizeInbound(msg: any): InboundMsg {
196
+ export function normalizeInbound(msg: any, media?: InboundMediaInfo): InboundMsg {
117
197
  const from: string = msg.from || '';
118
- return {
198
+ const to: string = msg.to || '';
199
+ // The fallback id keys on the COUNTERPARTY (the customer): on fromMe the
200
+ // `from` is the operator's own jid, shared by every chat — id-less
201
+ // operator messages to different customers in the same second must not
202
+ // collide in the host's dedupe.
203
+ const counterparty = msg.fromMe ? to : from;
204
+ const inbound: InboundMsg = {
119
205
  from,
120
206
  body: msg.body || '',
121
- messageId: (msg.id && msg.id._serialized) || `${from}-${msg.timestamp}`,
207
+ messageId: (msg.id && msg.id._serialized) || `${counterparty}-${msg.timestamp}`,
122
208
  timestamp: msg.timestamp || Math.floor(Date.now() / 1000),
123
209
  type: msg.type || 'chat'
124
210
  };
211
+ // fromMe/to are added ONLY for operator-sent messages — like the media
212
+ // keys below, inbound payloads keep the exact pre-0.8.0 shape so older
213
+ // hosts stay byte-compatible and newer ones key-gate on fromMe presence.
214
+ if (msg.fromMe) {
215
+ inbound.fromMe = true;
216
+ inbound.to = to;
217
+ }
218
+ // Media keys are added ONLY for media messages so text payloads keep the
219
+ // exact 0.6.0 five-field shape (hosts key-gate on hasMedia presence).
220
+ if (msg.hasMedia) inbound.hasMedia = true;
221
+ if (media) Object.assign(inbound, media);
222
+ return inbound;
223
+ }
224
+
225
+ // ── Capture pipeline ──
226
+ //
227
+ // Extracted from index.ts so the ordering contract is unit-testable:
228
+ // sanity filter → contact/@lid sender resolution (drop early) → media
229
+ // download → normalize → enqueue → optional webhook push. The media resolver
230
+ // is injected (media.ts's resolveMediaForMessage in production) so this file
231
+ // stays the dependency-free core.
232
+ export interface CaptureDeps {
233
+ resolveMedia: (userId: string, msg: any) => Promise<InboundMediaInfo>;
234
+ push?: (userId: string, msg: InboundMsg) => void;
235
+ }
236
+
237
+ export async function processInbound(userId: string, msg: any, deps: CaptureDeps) {
238
+ if (!shouldCapture(userId, msg)) return;
239
+
240
+ // Operator-sent messages take their own (shorter) path: no sender to
241
+ // resolve, and the chat is keyed by the counterparty at msg.to.
242
+ if (msg.fromMe) return processOwnMessage(userId, msg, deps);
243
+
244
+ // One best-effort contact lookup feeds both the sender's display name
245
+ // and the @lid phone resolution. Failure must never drop the message —
246
+ // unless the sender is an unresolvable @lid (handled below).
247
+ let contact: any;
248
+ try {
249
+ contact = await msg.getContact();
250
+ } catch (e) {
251
+ console.error(`contact lookup failed for ${userId}`, e);
252
+ }
253
+
254
+ // Resolve the sender BEFORE downloading media. Newer WhatsApp delivers
255
+ // the reply's `from` as an @lid privacy id with no phone number, which
256
+ // the host can't match; if the contact can't supply the real phone the
257
+ // message is dropped — and a dropped message must not have cost a
258
+ // download that leaves up to 25MB of unreferenced bytes on disk.
259
+ const rawFrom: string = msg.from || '';
260
+ let from = rawFrom;
261
+ if (rawFrom.endsWith('@lid')) {
262
+ const num = contact && (contact.number || (contact.id && contact.id.user));
263
+ if (num) from = `${String(num).replace(/\D/g, '')}@c.us`;
264
+ // Still an @lid => no phone to match or scope by. Drop it rather than
265
+ // forward an unmatchable, unpurgeable plaintext body.
266
+ if (from.endsWith('@lid')) return;
267
+ }
268
+
269
+ // Only a kept message earns the download. Every resolver failure mode
270
+ // returns an 'unavailable' verdict instead of throwing, so the message
271
+ // still reaches the host type-only.
272
+ let media: InboundMediaInfo | undefined;
273
+ if (msg.hasMedia) {
274
+ media = await deps.resolveMedia(userId, msg);
275
+ }
276
+
277
+ const inbound = normalizeInbound(msg, media);
278
+ inbound.from = from;
279
+ const senderName = contact && (contact.pushname || contact.name || contact.shortName);
280
+ if (senderName) inbound.senderName = String(senderName);
281
+ if (rawFrom.endsWith('@lid')) {
282
+ // Known recipient replying from a privacy-number chat: allowlist the
283
+ // @lid chat id too, so the reconnect backfill can re-open this chat.
284
+ rememberLidAlias(userId, rawFrom, from);
285
+ }
286
+
287
+ enqueueInbound(userId, inbound);
288
+ if (deps.push) deps.push(userId, inbound);
289
+ }
290
+
291
+ // Operator-sent (fromMe) leg of the pipeline. The senderName contact lookup
292
+ // is skipped on purpose: the "sender" is the operator themself, so the name
293
+ // adds nothing and the lookup costs a puppeteer roundtrip per message. Media
294
+ // resolution, enqueue and webhook are the SAME as inbound — operator photos/
295
+ // documents sync through the existing GET /media path under the same caps.
296
+ async function processOwnMessage(userId: string, msg: any, deps: CaptureDeps) {
297
+ // The echo of our own /send: suppress entirely — no media resolution, no
298
+ // enqueue, no webhook (see the self-send registry above for why).
299
+ // rememberTarget is skipped too: /send already allowlisted this recipient.
300
+ const messageId = msg.id && msg.id._serialized;
301
+ if (messageId && isSelfSend(userId, messageId)) return;
302
+
303
+ const to: string = msg.to || '';
304
+ // An @lid counterparty carries no phone number the host can thread on,
305
+ // and unlike inbound there is no contact handle to resolve it through
306
+ // (msg.getContact() resolves the SENDER — here, the operator). Rare in
307
+ // practice: operator-initiated chats are keyed by the phone @c.us. Drop
308
+ // with a log rather than forward an unmatchable body.
309
+ if (to.endsWith('@lid')) {
310
+ console.log(`Dropping fromMe message to unresolved @lid chat for ${userId}`);
311
+ return;
312
+ }
313
+
314
+ // Same kept-message-earns-the-download rule as inbound: every resolver
315
+ // failure mode reports 'unavailable' instead of throwing.
316
+ let media: InboundMediaInfo | undefined;
317
+ if (msg.hasMedia) {
318
+ media = await deps.resolveMedia(userId, msg);
319
+ }
320
+
321
+ const inbound = normalizeInbound(msg, media);
322
+
323
+ // A fromMe message to a brand-new number means the operator opened the
324
+ // conversation in the WhatsApp app — allowlist the chat exactly like
325
+ // /send does for its recipients, so the reconnect backfill can replay
326
+ // this conversation after a disconnect window too.
327
+ rememberTarget(userId, to);
328
+
329
+ enqueueInbound(userId, inbound);
330
+ if (deps.push) deps.push(userId, inbound);
125
331
  }
126
332
 
127
333
  // Minimal slice of whatsapp-web.js Client that backfill needs — a seam so the
@@ -179,10 +385,14 @@ export async function backfillTargets(
179
385
  export function clearInbound(userId: string) {
180
386
  inboundQueues.delete(userId);
181
387
  outboundTargets.delete(userId);
388
+ // Self-send echo ids belong to the old pairing too — and suppression must
389
+ // never leak across a re-pair (however unlikely an id collision is).
390
+ selfSendIds.delete(userId);
182
391
  }
183
392
 
184
393
  // Test helper: wipe in-memory state between examples.
185
394
  export function resetInboundState() {
186
395
  inboundQueues.clear();
187
396
  outboundTargets.clear();
397
+ selfSendIds.clear();
188
398
  }
@@ -16,14 +16,23 @@ import {
16
16
  InboundMsg,
17
17
  configureInbound,
18
18
  rememberTarget,
19
- rememberLidAlias,
19
+ rememberSelfSend,
20
20
  backfillTargets,
21
- enqueueInbound,
21
+ resolveChat,
22
22
  drainInbound,
23
23
  clearInbound,
24
- shouldCapture,
25
- normalizeInbound
24
+ processInbound
26
25
  } from './inbound';
26
+ import { chatsResponse, historyResponse, refetchResponse, HistoryDeps, RefetchDeps } from './history';
27
+ import {
28
+ configureMedia,
29
+ resolveMediaForMessage,
30
+ sweepExpired,
31
+ clearUserMedia,
32
+ mediaGetResponse,
33
+ mediaDeleteResponse
34
+ } from './media';
35
+ import { sentMessageId } from './send';
27
36
 
28
37
  const app = new Hono();
29
38
  const port = Number(process.env.PORT || 3001);
@@ -71,8 +80,10 @@ const initRetries = new InitRetryLimiter(MAX_INIT_RETRIES);
71
80
  const WEBHOOK_URL = process.env.WHATSAPP_WEBHOOK_URL;
72
81
  const WEBHOOK_TOKEN = process.env.WHATSAPP_WEBHOOK_TOKEN;
73
82
 
74
- // Tell the inbound core how to resolve each user's on-disk session dir.
83
+ // Tell the inbound core how to resolve each user's on-disk session dir, and
84
+ // the media store where downloaded inbound media lives (survives restarts).
75
85
  configureInbound(sessionDirForUser);
86
+ configureMedia(() => join(SESSION_BASE_DIR, 'media'));
76
87
 
77
88
  async function pushWebhook(userId: string, msg: InboundMsg) {
78
89
  if (!WEBHOOK_URL) return;
@@ -90,32 +101,15 @@ async function pushWebhook(userId: string, msg: InboundMsg) {
90
101
  }
91
102
  }
92
103
 
93
- // Wrapper: sanity filter normalize resolve @lid enqueue optional webhook.
104
+ // Wrapper around the testable pipeline in inbound.ts (sanity filtersender
105
+ // resolution with early @lid drop → media download → normalize → enqueue →
106
+ // webhook). The catch keeps a single bad message from killing the listener.
94
107
  async function captureInbound(userId: string, msg: any) {
95
108
  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);
109
+ await processInbound(userId, msg, {
110
+ resolveMedia: resolveMediaForMessage,
111
+ push: pushWebhook
112
+ });
119
113
  } catch (e) {
120
114
  console.error(`captureInbound error for ${userId}`, e);
121
115
  }
@@ -124,8 +118,10 @@ async function captureInbound(userId: string, msg: any) {
124
118
  async function backfillInbound(userId: string, client: Client) {
125
119
  // On reconnect, replay recent messages ONLY from chats we actually messaged
126
120
  // (the per-send allowlist) so a disconnect window doesn't drop a reply —
127
- // without scraping every personal conversation on the linked number. Live
128
- // replies are covered by the message_create handler; this is just recovery.
121
+ // without scraping every personal conversation on the linked number.
122
+ // fetchMessages returns BOTH directions, so operator-app (fromMe) messages
123
+ // typed during the window recover too. Live traffic is covered by the
124
+ // message_create handler; this is just recovery.
129
125
  await backfillTargets(userId, client, captureInbound);
130
126
  }
131
127
 
@@ -205,6 +201,8 @@ async function waitForClientReady(clientData: ClientData, timeoutMs = 30000): Pr
205
201
  throw new Error('Client not ready: WhatsApp Web store did not initialize in time');
206
202
  }
207
203
 
204
+ // Resolves to the sent whatsapp-web.js Message so /send can hand the real
205
+ // message id back to the host (the echo-dedupe key for two-way capture).
208
206
  async function sendMessageWithRetry(client: Client, clientData: ClientData, chatId: string, message: string, mediaUrl?: string | null) {
209
207
  const maxAttempts = 5;
210
208
 
@@ -216,12 +214,10 @@ async function sendMessageWithRetry(client: Client, clientData: ClientData, chat
216
214
  if (mediaUrl) {
217
215
  const { MessageMedia } = require('whatsapp-web.js');
218
216
  const media = await MessageMedia.fromUrl(mediaUrl);
219
- await client.sendMessage(chatId, media, { caption: message });
220
- } else {
221
- await client.sendMessage(chatId, message);
217
+ return await client.sendMessage(chatId, media, { caption: message });
222
218
  }
223
219
 
224
- return;
220
+ return await client.sendMessage(chatId, message);
225
221
  } catch (error) {
226
222
  console.error(`Send attempt ${attempt}/${maxAttempts} failed for chat ${chatId}:`, error);
227
223
 
@@ -312,10 +308,13 @@ async function getOrCreateClient(userId: string): Promise<ClientData> {
312
308
  backfillInbound(userId, client).catch((e) => console.error(`Backfill failed for ${userId}`, e));
313
309
  });
314
310
 
315
- // Capture inbound replies. Only 'message_create' — it fires reliably for
316
- // every message across linked/multi-device sessions (plain 'message'
317
- // silently never fires on some). shouldCapture drops our own sends (fromMe)
318
- // + groups/status; the queue dedupes on message_id on the Rails side.
311
+ // Capture BOTH directions of every 1:1 chat. Only 'message_create' — it
312
+ // fires reliably for every message across linked/multi-device sessions
313
+ // (plain 'message' silently never fires on some) and, unlike 'message',
314
+ // also fires for operator-sent (fromMe) messages, which the host threads
315
+ // as the other half of the conversation since 0.8.0. shouldCapture drops
316
+ // groups/status; the host dedupes on message_id — including the fromMe
317
+ // echo of its own /send calls.
319
318
  client.on('message_create', (msg) => captureInbound(userId, msg));
320
319
 
321
320
  client.on('authenticated', () => {
@@ -460,6 +459,14 @@ setInterval(() => {
460
459
  destroyClient(userId, wipe).catch(console.error);
461
460
  }
462
461
  }
462
+
463
+ // Evict downloaded inbound media past its TTL (and refresh the disk-cap
464
+ // accounting) on the same cadence — a sweep failure must not stop reaping.
465
+ try {
466
+ sweepExpired();
467
+ } catch (e) {
468
+ console.error('Media sweep failed', e);
469
+ }
463
470
  }, SWEEP_INTERVAL_MS);
464
471
 
465
472
  // API Routes
@@ -510,6 +517,11 @@ app.post('/logout/:userId', async (c) => {
510
517
  // anything captured between the last poll and this logout would sit in
511
518
  // memory and replay into the WRONG pairing if this userId pairs again.
512
519
  clearInbound(userId);
520
+ // Logout privacy contract: downloaded media belongs to the old pairing.
521
+ // Without this wipe, customer photos/documents stayed on disk (and
522
+ // fetchable via GET /media) for up to the 48h TTL after the operator
523
+ // severed the pairing.
524
+ clearUserMedia(userId);
513
525
  initializingClients.delete(userId);
514
526
  return c.json({ success: true });
515
527
  });
@@ -534,13 +546,21 @@ app.post('/send/:userId', async (c) => {
534
546
 
535
547
  try {
536
548
  const chatId = to.includes('@c.us') ? to : `${to}@c.us`;
537
- await sendMessageWithRetry(data.client, data, chatId, message, mediaUrl);
549
+ const sent = await sendMessageWithRetry(data.client, data, chatId, message, mediaUrl);
538
550
 
539
551
  // Record the recipient so their replies survive a reconnect backfill.
540
552
  rememberTarget(userId, chatId);
541
553
 
542
554
  data.lastUsed = Date.now();
543
- return c.json({ success: true });
555
+ // messageId is the echo-dedupe key: this send fires its own fromMe
556
+ // message_create, which the host must match by id (see send.ts).
557
+ const messageId = sentMessageId(sent);
558
+ // Register the id so the echo is suppressed service-side (no media
559
+ // re-download, no queue slot, no webhook — see inbound.ts). Best
560
+ // effort: an echo that already fired before sendMessage resolved
561
+ // flows through, and the host's id-dedupe catches it.
562
+ if (messageId) rememberSelfSend(userId, messageId);
563
+ return c.json({ success: true, messageId });
544
564
  } catch (error: any) {
545
565
  console.error(`Send error for user ${userId}:`, error);
546
566
  return c.json({ success: false, error: error.message }, 500);
@@ -561,6 +581,68 @@ app.get('/inbound/:userId', async (c) => {
561
581
  return c.json({ messages: drainInbound(userId) });
562
582
  });
563
583
 
584
+ // GET/DELETE /media/:userId/:messageId — serve / evict downloaded inbound
585
+ // media. Token-gated when WHATSAPP_WEBHOOK_TOKEN is set; ids are sanitized in
586
+ // the store; NEVER calls getOrCreateClient (same fast-reject rule as /inbound:
587
+ // fetching bytes for a never-paired user must not boot a Chromium).
588
+ app.get('/media/:userId/:messageId', (c) =>
589
+ mediaGetResponse(c.req.param('userId'), c.req.param('messageId'), c.req.header('X-WA-Token'), WEBHOOK_TOKEN));
590
+
591
+ app.delete('/media/:userId/:messageId', (c) =>
592
+ mediaDeleteResponse(c.req.param('userId'), c.req.param('messageId'), c.req.header('X-WA-Token'), WEBHOOK_TOKEN));
593
+
594
+ // ── Chat history (history.ts) ──
595
+ //
596
+ // Shared deps for the two history routes: the SAME pairing fast-reject and
597
+ // client accessor /send uses (never any other initialization path), plus
598
+ // inbound.ts's chat resolver + reconnect allowlist.
599
+ const historyDeps: HistoryDeps = {
600
+ hasPaired: (userId) => hasPairedSession(userId, clients, sessionDirForUser),
601
+ getClient: getOrCreateClient,
602
+ resolveChat,
603
+ rememberTarget
604
+ };
605
+
606
+ // GET /chats/:userId — list the paired number's 1:1 chats (discovery for the
607
+ // host's old-conversation sync). Token-gated like /media; paired+ready gated
608
+ // like /send; capped at the newest 500 (see history.ts).
609
+ app.get('/chats/:userId', (c) =>
610
+ chatsResponse(c.req.param('userId'), c.req.header('X-WA-Token'), WEBHOOK_TOKEN, historyDeps));
611
+
612
+ // POST /history/:userId { chatId, limit } — replay one chat's history through
613
+ // the live-capture normalizer and return it DIRECTLY in the response (no
614
+ // queue, no webhook — the host ingests synchronously). History media is
615
+ // marked unavailable by design, never downloaded (see history.ts).
616
+ app.post('/history/:userId', async (c) =>
617
+ historyResponse(
618
+ c.req.param('userId'),
619
+ await c.req.json().catch(() => ({})),
620
+ c.req.header('X-WA-Token'),
621
+ WEBHOOK_TOKEN,
622
+ historyDeps
623
+ ));
624
+
625
+ // POST /media/:userId/refetch { messageId, chatId } — WhatsApp tap-to-download.
626
+ // The host calls this when an operator opens an evicted/expired media bubble:
627
+ // re-pull THAT message's bytes on demand, store them (cap re-enforced), and
628
+ // report ready so the host can GET /media as usual. Token-gated + paired-ready
629
+ // gated like /history; media no longer upstream → 404 'gone'. Reuses the live
630
+ // resolveMediaForMessage pipeline (per-message size cap + 30s timeout).
631
+ const refetchDeps: RefetchDeps = {
632
+ ...historyDeps,
633
+ getMessageById: (client, messageId) => client.getMessageById(messageId),
634
+ resolveMedia: (userId, msg) => resolveMediaForMessage(userId, msg)
635
+ };
636
+
637
+ app.post('/media/:userId/refetch', async (c) =>
638
+ refetchResponse(
639
+ c.req.param('userId'),
640
+ await c.req.json().catch(() => ({})),
641
+ c.req.header('X-WA-Token'),
642
+ WEBHOOK_TOKEN,
643
+ refetchDeps
644
+ ));
645
+
564
646
 
565
647
  console.log(`Starting Multi-User WhatsApp service (Bun Native) on port ${port}...`);
566
648