whatsapp_notifier 0.7.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,323 @@
1
+ // Chat-history endpoints core (v0.8.0)
2
+ //
3
+ // Pure, testable logic behind GET /chats/:userId (discovery: which 1:1
4
+ // conversations exist on the paired number) and POST /history/:userId
5
+ // (replay one chat's recent messages so the host can sync conversations
6
+ // that predate the pairing). Kept separate from index.ts (which calls
7
+ // Bun.serve() at import time) so the gating, validation and response
8
+ // contracts can be unit-tested without booting the server or
9
+ // whatsapp-web.js — same seam pattern as media.ts / send.ts.
10
+
11
+ import {
12
+ InboundMsg,
13
+ InboundMediaInfo,
14
+ ChatLike,
15
+ shouldCapture,
16
+ normalizeInbound
17
+ } from './inbound';
18
+ import { verifyMediaToken, MediaResolution, sanitizeId } from './media';
19
+
20
+ // ── Chat list (GET /chats) ──
21
+
22
+ export interface ChatSummary {
23
+ id: string;
24
+ name: string | null;
25
+ lastMessageAt: number | null; // epoch seconds; null when the chat carries none
26
+ }
27
+
28
+ // Discovery cap. getChats() on a long-lived personal number can return
29
+ // thousands of chats; serializing them all on every discovery call bloats the
30
+ // response and stalls the session, while the host only needs the recent
31
+ // conversations to offer for syncing — the newest 500 is far more than any
32
+ // operator will ever scroll through.
33
+ export const CHAT_LIST_CAP = 500;
34
+
35
+ // Reduce whatsapp-web.js Chat objects to the discovery wire shape: 1:1 chats
36
+ // ONLY (ids ending @c.us — groups @g.us, status @broadcast and @lid privacy
37
+ // chats are excluded; the last two carry nothing the host can thread on and
38
+ // groups are never captured), name best-effort, newest first, capped.
39
+ export function summarizeChats(chats: any[]): ChatSummary[] {
40
+ return (Array.isArray(chats) ? chats : [])
41
+ .filter((chat) =>
42
+ typeof chat?.id?._serialized === 'string' && chat.id._serialized.endsWith('@c.us'))
43
+ .map((chat) => ({
44
+ id: chat.id._serialized,
45
+ name: typeof chat.name === 'string' && chat.name ? chat.name : null,
46
+ lastMessageAt: Number.isFinite(chat.timestamp) ? chat.timestamp : null
47
+ }))
48
+ .sort((a, b) => (b.lastMessageAt ?? 0) - (a.lastMessageAt ?? 0))
49
+ .slice(0, CHAT_LIST_CAP);
50
+ }
51
+
52
+ // ── History replay (POST /history) ──
53
+
54
+ export const DEFAULT_HISTORY_LIMIT = 50;
55
+ export const MAX_HISTORY_LIMIT = 200;
56
+
57
+ // Clamp the requested message count to 1..200 (default 50). fetchMessages
58
+ // hydrates every message through puppeteer, so an unbounded limit could stall
59
+ // the session for minutes on one request; non-numeric garbage falls back to
60
+ // the default rather than erroring (the host's knob, not a contract field).
61
+ export function clampHistoryLimit(raw: unknown): number {
62
+ if (raw === undefined || raw === null) return DEFAULT_HISTORY_LIMIT;
63
+ const parsed = Math.floor(Number(raw));
64
+ if (!Number.isFinite(parsed)) return DEFAULT_HISTORY_LIMIT;
65
+ return Math.min(Math.max(parsed, 1), MAX_HISTORY_LIMIT);
66
+ }
67
+
68
+ // Mirror /send's `${digits}@c.us` normalization (a bare phone number gets the
69
+ // @c.us suffix appended), then REQUIRE the result to be a 1:1 @c.us id.
70
+ // History is 1:1-only by contract, and unlike /send the suffix is appended
71
+ // only to suffix-less input: blindly appending to "...@g.us" / "...@lid"
72
+ // would mint a never-resolvable franken-id that sails past the gate.
73
+ export function normalizeHistoryChatId(raw: unknown): string | null {
74
+ if (typeof raw !== 'string') return null;
75
+ const trimmed = raw.trim();
76
+ if (!trimmed) return null;
77
+ const chatId = trimmed.includes('@') ? trimmed : `${trimmed}@c.us`;
78
+ return chatId.endsWith('@c.us') ? chatId : null;
79
+ }
80
+
81
+ // Media in history is marked, never downloaded: a 200-message replay of a
82
+ // photo-heavy chat would pull hundreds of MB in a single request — blowing
83
+ // the media disk caps and stalling the session behind sequential puppeteer
84
+ // downloads. The host receives hasMedia plus this verdict (mediaError
85
+ // 'history' tells it apart from a failed live download) and live capture
86
+ // keeps downloading media for everything that arrives going forward.
87
+ export const HISTORY_MEDIA_ERROR = 'history';
88
+
89
+ export function historyMediaInfo(): InboundMediaInfo {
90
+ return { mediaStatus: 'unavailable', mediaError: HISTORY_MEDIA_ERROR };
91
+ }
92
+
93
+ // Replay one chat's recent messages through the live-capture normalizer.
94
+ // BOTH directions survive — fetchMessages returns operator (fromMe) messages
95
+ // too, and normalizeInbound adds fromMe/to exactly like live capture, so the
96
+ // host threads whole conversations. Messages failing shouldCapture (system
97
+ // events, status, group posts) are skipped. Returned oldest-first so the
98
+ // host can ingest in thread order.
99
+ export async function replayHistory(userId: string, chat: ChatLike, limit: number): Promise<InboundMsg[]> {
100
+ const msgs = await chat.fetchMessages({ limit });
101
+ return (Array.isArray(msgs) ? msgs : [])
102
+ .filter((m) => shouldCapture(userId, m))
103
+ .map((m) => normalizeInbound(m, m.hasMedia ? historyMediaInfo() : undefined))
104
+ .sort((a, b) => a.timestamp - b.timestamp);
105
+ }
106
+
107
+ // ── Route responses ──
108
+ //
109
+ // Full Response builders (same pattern as media.ts) so index.ts stays
110
+ // glue-only. The deps seam injects index.ts's pairing fast-reject and client
111
+ // accessor: both routes mirror /send EXACTLY — reject a never-paired user
112
+ // BEFORE any client exists (so the route can never boot a Chromium that
113
+ // parks in QR_REQUIRED forever), then getOrCreateClient (never any other
114
+ // initialization path), then require AUTHENTICATED + ready. On top of /send's
115
+ // gate they also enforce X-WA-Token (same helper as /media) when the service
116
+ // has WHATSAPP_WEBHOOK_TOKEN set: these routes expose whole conversations,
117
+ // not just the caller's own queue.
118
+
119
+ export interface GatedClient {
120
+ state: string;
121
+ ready?: boolean;
122
+ lastUsed: number;
123
+ client: any;
124
+ }
125
+
126
+ export interface SessionGateDeps {
127
+ hasPaired: (userId: string) => boolean;
128
+ getClient: (userId: string) => Promise<GatedClient>;
129
+ }
130
+
131
+ export interface HistoryDeps extends SessionGateDeps {
132
+ resolveChat: (client: any, chatId: string) => Promise<ChatLike | null>;
133
+ rememberTarget: (userId: string, chatId: string) => void;
134
+ }
135
+
136
+ function deny(status: number, error: string): Response {
137
+ return Response.json({ success: false, error }, { status });
138
+ }
139
+
140
+ // Paired + ready gate shared by both routes — returns the live client data,
141
+ // or the 401 Response the route must answer with.
142
+ async function gatePairedReady(userId: string, deps: SessionGateDeps): Promise<GatedClient | Response> {
143
+ if (!deps.hasPaired(userId)) {
144
+ return deny(401, 'No saved WhatsApp session for this user — pair via QR first');
145
+ }
146
+ const data = await deps.getClient(userId);
147
+ if (data.state !== 'AUTHENTICATED' || !data.ready) {
148
+ return deny(401, 'User not authenticated');
149
+ }
150
+ return data;
151
+ }
152
+
153
+ export async function chatsResponse(
154
+ userId: string,
155
+ token: string | undefined,
156
+ expectedToken: string | undefined,
157
+ deps: SessionGateDeps
158
+ ): Promise<Response> {
159
+ if (!verifyMediaToken(token, expectedToken)) return deny(401, 'unauthorized');
160
+ const gate = await gatePairedReady(userId, deps);
161
+ if (gate instanceof Response) return gate;
162
+
163
+ try {
164
+ const chats = await gate.client.getChats();
165
+ gate.lastUsed = Date.now();
166
+ return Response.json({ success: true, chats: summarizeChats(chats) });
167
+ } catch (error: any) {
168
+ console.error(`Chat list error for user ${userId}:`, error);
169
+ return deny(500, (error && error.message) || String(error));
170
+ }
171
+ }
172
+
173
+ export async function historyResponse(
174
+ userId: string,
175
+ body: any,
176
+ token: string | undefined,
177
+ expectedToken: string | undefined,
178
+ deps: HistoryDeps
179
+ ): Promise<Response> {
180
+ if (!verifyMediaToken(token, expectedToken)) return deny(401, 'unauthorized');
181
+ // Validate the body before the pairing gate, mirroring /send's ordering
182
+ // (a malformed request earns its 422 without touching any client).
183
+ const chatId = normalizeHistoryChatId(body && body.chatId);
184
+ if (!chatId) {
185
+ return deny(422, '`chatId` is required and must be a 1:1 @c.us chat id');
186
+ }
187
+ const gate = await gatePairedReady(userId, deps);
188
+ if (gate instanceof Response) return gate;
189
+
190
+ try {
191
+ // Same resolver seam the reconnect backfill uses (getChatById with the
192
+ // contact fallback) — null means the chat never materialized.
193
+ const chat = await deps.resolveChat(gate.client, chatId);
194
+ if (!chat) return deny(404, 'chat not found');
195
+
196
+ const messages = await replayHistory(userId, chat, clampHistoryLimit(body && body.limit));
197
+
198
+ // A synced chat is a conversation of record: allowlist it like a /send
199
+ // recipient so disconnect-window replies to it backfill on reconnect.
200
+ deps.rememberTarget(userId, chatId);
201
+ gate.lastUsed = Date.now();
202
+ // Returned DIRECTLY — no queue, no webhook. The host ingests the
203
+ // response synchronously; queueing a bulk replay would interleave it
204
+ // with (and delay) live traffic in the /inbound drain.
205
+ return Response.json({ success: true, messages });
206
+ } catch (error: any) {
207
+ console.error(`History replay error for user ${userId}:`, error);
208
+ return deny(500, (error && error.message) || String(error));
209
+ }
210
+ }
211
+
212
+ // ── On-demand media re-download (POST /media/:userId/refetch) ──
213
+ //
214
+ // WhatsApp's tap-to-download model. Eager capture only keeps RECENT media
215
+ // (the per-user rolling cap rolls old media off; the TTL sweep expires media
216
+ // past 48h) so an operator opening an old or evicted media bubble finds the
217
+ // service has no copy (GET /media 404s). This endpoint re-pulls THAT one
218
+ // message's bytes on demand: resolve the message upstream, download it
219
+ // (respecting the SAME per-message size cap + 30s timeout as live capture),
220
+ // store it (the write re-enforces the per-user cap), and report it ready so
221
+ // the host can GET /media as usual. A message whose media is gone upstream
222
+ // (deleted, too old) answers a 404 'gone' so the host can grey the bubble out.
223
+
224
+ export interface RefetchDeps extends SessionGateDeps {
225
+ // getMessageById is the fast path; resolveChat + a fetchMessages scan is
226
+ // the fallback for ids getMessageById can't hydrate directly.
227
+ getMessageById: (client: any, messageId: string) => Promise<any | null>;
228
+ resolveChat: (client: any, chatId: string) => Promise<ChatLike | null>;
229
+ // Injected media.ts pipeline (download → cap-enforced store → verdict),
230
+ // seamed so the route can be tested without a real downloadMedia.
231
+ resolveMedia: (userId: string, msg: any) => Promise<MediaResolution>;
232
+ }
233
+
234
+ // Find the live whatsapp-web.js Message for an id: getMessageById first, then
235
+ // scan the chat's recent messages for a matching serialized id. Returns null
236
+ // when neither path turns it up (message rolled out of the chat window, or the
237
+ // id is unknown) — the route maps that to 'gone'.
238
+ export async function findMessage(
239
+ deps: RefetchDeps,
240
+ client: any,
241
+ messageId: string,
242
+ chatId: string
243
+ ): Promise<any | null> {
244
+ try {
245
+ const direct = await deps.getMessageById(client, messageId);
246
+ if (direct) return direct;
247
+ } catch (_) { /* fall through to the chat scan */ }
248
+
249
+ try {
250
+ const chat = await deps.resolveChat(client, chatId);
251
+ if (!chat) return null;
252
+ const msgs = await chat.fetchMessages({ limit: MAX_HISTORY_LIMIT });
253
+ return (Array.isArray(msgs) ? msgs : [])
254
+ .find((m) => m && m.id && m.id._serialized === messageId) || null;
255
+ } catch (_) {
256
+ return null;
257
+ }
258
+ }
259
+
260
+ // Re-download the bytes for one message. Same token gate, paired+ready gate
261
+ // and @c.us-validated chatId as /history; the messageId must sanitize to the
262
+ // store charset (the path the host will GET it back on). On success answers
263
+ // { success: true, mediaStatus: 'available', media* }; when the media no
264
+ // longer exists upstream answers 404 { success: false, mediaStatus:
265
+ // 'unavailable', mediaError: 'gone' }.
266
+ export async function refetchResponse(
267
+ userId: string,
268
+ body: any,
269
+ token: string | undefined,
270
+ expectedToken: string | undefined,
271
+ deps: RefetchDeps
272
+ ): Promise<Response> {
273
+ if (!verifyMediaToken(token, expectedToken)) return deny(401, 'unauthorized');
274
+ // Validate the body before the pairing gate (mirrors /history): a malformed
275
+ // request earns its 422 without touching any client.
276
+ const messageId = sanitizeId(body && body.messageId);
277
+ if (!messageId) {
278
+ return deny(422, '`messageId` is required');
279
+ }
280
+ const chatId = normalizeHistoryChatId(body && body.chatId);
281
+ if (!chatId) {
282
+ return deny(422, '`chatId` is required and must be a 1:1 @c.us chat id');
283
+ }
284
+ const gate = await gatePairedReady(userId, deps);
285
+ if (gate instanceof Response) return gate;
286
+
287
+ try {
288
+ const msg = await findMessage(deps, gate.client, messageId, chatId);
289
+ gate.lastUsed = Date.now();
290
+ // Message not found upstream, or found but carries no media → nothing
291
+ // to re-download. Same 'gone' verdict either way: the bytes are gone.
292
+ if (!msg || !msg.hasMedia) {
293
+ return Response.json(
294
+ { success: false, mediaStatus: 'unavailable', mediaError: 'gone' },
295
+ { status: 404 }
296
+ );
297
+ }
298
+
299
+ // resolveMediaForMessage downloads (per-message cap + 30s timeout) and
300
+ // stores under the SAME id the host GETs, re-enforcing the per-user cap.
301
+ const verdict = await deps.resolveMedia(userId, msg);
302
+ if (verdict.mediaStatus !== 'available') {
303
+ // expired (gone upstream), too_large, download_failed, … → the host
304
+ // can't show bytes. 404 with the typed reason so it greys the bubble.
305
+ return Response.json(
306
+ { success: false, mediaStatus: 'unavailable', mediaError: verdict.mediaError || 'gone' },
307
+ { status: 404 }
308
+ );
309
+ }
310
+
311
+ return Response.json({
312
+ success: true,
313
+ messageId,
314
+ mediaStatus: 'available',
315
+ mediaMime: verdict.mediaMime,
316
+ ...(verdict.mediaFilename ? { mediaFilename: verdict.mediaFilename } : {}),
317
+ mediaSize: verdict.mediaSize
318
+ });
319
+ } catch (error: any) {
320
+ console.error(`Media refetch error for user ${userId}:`, error);
321
+ return deny(500, (error && error.message) || String(error));
322
+ }
323
+ }
@@ -4,10 +4,14 @@ import { tmpdir } from 'os';
4
4
  import { join } from 'path';
5
5
  import {
6
6
  INBOUND_QUEUE_CAP,
7
+ SELF_SEND_MAX,
8
+ SELF_SEND_TTL_MS,
7
9
  configureInbound,
8
10
  loadTargets,
9
11
  rememberTarget,
10
12
  rememberLidAlias,
13
+ rememberSelfSend,
14
+ isSelfSend,
11
15
  resolveChat,
12
16
  backfillTargets,
13
17
  enqueueInbound,
@@ -35,6 +39,7 @@ afterAll(() => {
35
39
  });
36
40
 
37
41
  const CUST = '919999000001@c.us';
42
+ const OPERATOR = '919000000001@c.us'; // the linked number's own jid (fromMe sender)
38
43
 
39
44
  function msg(overrides: any = {}) {
40
45
  return {
@@ -56,7 +61,6 @@ test('shouldCapture: any inbound 1:1 chat, no allowlist gate', () => {
56
61
 
57
62
  expect(shouldCapture('1', msg({ type: 'image' }))).toBe(true); // media is real content
58
63
 
59
- expect(shouldCapture('1', msg({ fromMe: true }))).toBe(false); // own message
60
64
  expect(shouldCapture('1', msg({ from: '12@g.us' }))).toBe(false); // group
61
65
  expect(shouldCapture('1', msg({ isStatus: true }))).toBe(false); // status
62
66
  expect(shouldCapture('1', msg({ from: 'status@broadcast' }))).toBe(false);
@@ -65,6 +69,20 @@ test('shouldCapture: any inbound 1:1 chat, no allowlist gate', () => {
65
69
  expect(shouldCapture('1', null)).toBe(false); // junk
66
70
  });
67
71
 
72
+ // 0.8.0 two-way capture: operator-sent (fromMe) messages are kept, and every
73
+ // jid gate moves to the COUNTERPARTY (msg.to) — the operator's own `from` is
74
+ // always @c.us and must not vouch for a group/status post.
75
+ test('shouldCapture: fromMe messages gate on the counterparty at msg.to', () => {
76
+ expect(shouldCapture('1', msg({ fromMe: true, from: OPERATOR, to: CUST }))).toBe(true);
77
+ expect(shouldCapture('1', msg({ fromMe: true, from: OPERATOR, to: '125417440686124@lid' }))).toBe(true);
78
+
79
+ expect(shouldCapture('1', msg({ fromMe: true, from: OPERATOR, to: '12@g.us' }))).toBe(false); // own group post
80
+ expect(shouldCapture('1', msg({ fromMe: true, from: OPERATOR, to: 'status@broadcast' }))).toBe(false); // own status
81
+ expect(shouldCapture('1', msg({ fromMe: true, from: OPERATOR }))).toBe(false); // no counterparty
82
+ expect(shouldCapture('1', msg({ fromMe: true, from: OPERATOR, to: CUST, isStatus: true }))).toBe(false);
83
+ expect(shouldCapture('1', msg({ fromMe: true, from: OPERATOR, to: CUST, type: 'revoked' }))).toBe(false); // system event
84
+ });
85
+
68
86
  // allowlist persists to disk + reloads
69
87
  test('rememberTarget persists and loadTargets reloads from disk', () => {
70
88
  rememberTarget('1', CUST);
@@ -219,6 +237,24 @@ test('normalizeInbound maps fields and falls back on missing id', () => {
219
237
  expect(b.type).toBe('chat');
220
238
  });
221
239
 
240
+ // fromMe normalization: the wire gains fromMe + to (counterparty), and the
241
+ // fallback id keys on the counterparty — the operator's `from` is shared by
242
+ // every chat, so id-less sends to two customers in the same second must not
243
+ // collide in the host's messageId dedupe.
244
+ test('normalizeInbound marks fromMe and carries the counterparty at to', () => {
245
+ const out = normalizeInbound(msg({ fromMe: true, from: OPERATOR, to: CUST, body: 'on my way' }));
246
+ expect(out).toEqual({
247
+ from: OPERATOR, to: CUST, fromMe: true,
248
+ body: 'on my way', messageId: 'true_919999000001@c.us_ABC',
249
+ timestamp: 1717000000, type: 'chat'
250
+ });
251
+ });
252
+
253
+ test('normalizeInbound falls back to a counterparty-keyed id for fromMe', () => {
254
+ const out = normalizeInbound({ fromMe: true, from: OPERATOR, to: CUST, timestamp: 42 });
255
+ expect(out.messageId).toBe(`${CUST}-42`);
256
+ });
257
+
222
258
  // ── processInbound (capture pipeline ordering) ──
223
259
 
224
260
  const LID_FROM = '125417440686124@lid';
@@ -321,13 +357,184 @@ test('processInbound rejects filtered messages without resolving anything', asyn
321
357
  let resolveCalls = 0;
322
358
  const deps = { resolveMedia: async () => { resolveCalls += 1; return { mediaStatus: 'available' as const }; } };
323
359
 
324
- await processInbound('pi5', mediaMsg({ fromMe: true }), deps);
360
+ await processInbound('pi5', mediaMsg({ fromMe: true }), deps); // fromMe without a counterparty
325
361
  await processInbound('pi5', mediaMsg({ from: '12@g.us' }), deps);
326
362
 
327
363
  expect(resolveCalls).toBe(0);
328
364
  expect(drainInbound('pi5')).toEqual([]);
329
365
  });
330
366
 
367
+ // ── processInbound: operator-sent (fromMe) leg ──
368
+
369
+ test('processInbound captures a fromMe message without a contact lookup', async () => {
370
+ let contactCalls = 0;
371
+ const m = msg({
372
+ fromMe: true, from: OPERATOR, to: CUST, body: 'on my way',
373
+ getContact: async () => { contactCalls += 1; return { pushname: 'The Operator' }; }
374
+ });
375
+
376
+ const pushed: InboundMsg[] = [];
377
+ await processInbound('fm1', m, {
378
+ resolveMedia: async () => ({ mediaStatus: 'available' as const }),
379
+ push: (_u, inbound) => pushed.push(inbound)
380
+ });
381
+
382
+ const drained = drainInbound('fm1');
383
+ expect(drained.length).toBe(1);
384
+ expect(drained[0].fromMe).toBe(true);
385
+ expect(drained[0].to).toBe(CUST);
386
+ expect(drained[0].body).toBe('on my way');
387
+ expect('senderName' in drained[0]).toBe(false); // the operator needs no display name…
388
+ expect(contactCalls).toBe(0); // …so the puppeteer roundtrip is skipped
389
+ expect(pushed).toEqual(drained); // webhook saw the same payload
390
+ });
391
+
392
+ // A fromMe message to a brand-new number = operator opened the chat in the
393
+ // WhatsApp app. It must join the backfill allowlist exactly like a /send
394
+ // recipient, or the conversation would vanish from disconnect-window recovery.
395
+ test('processInbound allowlists the fromMe counterparty for backfill', async () => {
396
+ const m = msg({ fromMe: true, from: OPERATOR, to: CUST });
397
+
398
+ await processInbound('fm2', m, { resolveMedia: async () => ({ mediaStatus: 'available' as const }) });
399
+
400
+ expect(loadTargets('fm2').has(CUST)).toBe(true);
401
+ expect(loadTargets('fm2').has(OPERATOR)).toBe(false); // counterparty, not self
402
+ });
403
+
404
+ test('processInbound resolves media for operator-sent media messages', async () => {
405
+ useMediaRoot('media-fromme');
406
+ const m = mediaMsg({ fromMe: true, from: OPERATOR, to: CUST });
407
+
408
+ await processInbound('fm3', m, { resolveMedia: resolveMediaForMessage });
409
+
410
+ const drained = drainInbound('fm3');
411
+ expect(drained.length).toBe(1);
412
+ expect(drained[0].fromMe).toBe(true);
413
+ expect(drained[0].mediaStatus).toBe('available');
414
+ expect(drained[0].mediaSize).toBe(10);
415
+ });
416
+
417
+ // An @lid counterparty on fromMe has no phone the host can thread on and no
418
+ // contact handle to resolve it through (getContact resolves the sender — the
419
+ // operator). Dropped with a log, before any download — same disk-hygiene rule
420
+ // as the inbound @lid drop.
421
+ test('processInbound drops a fromMe message to an @lid chat before any download', async () => {
422
+ let resolveCalls = 0;
423
+ const m = mediaMsg({ fromMe: true, from: OPERATOR, to: LID_FROM });
424
+
425
+ await processInbound('fm4', m, {
426
+ resolveMedia: async () => { resolveCalls += 1; return { mediaStatus: 'available' as const }; }
427
+ });
428
+
429
+ expect(resolveCalls).toBe(0);
430
+ expect(drainInbound('fm4')).toEqual([]);
431
+ expect(loadTargets('fm4').size).toBe(0); // an unmatchable chat earns no allowlist slot
432
+ });
433
+
434
+ // ── Self-send echo suppression ──
435
+ //
436
+ // Every /send fires its own fromMe message_create echo. A registry hit must
437
+ // suppress the WHOLE pipeline: no media re-download (each platform media send
438
+ // would otherwise re-fetch its own attachment and burn the shared disk cap on
439
+ // bytes nobody fetches), no queue slot, no webhook — the host already got
440
+ // this id from the /send response.
441
+
442
+ test('processOwnMessage suppresses a registered self-send echo entirely', async () => {
443
+ const mediaRoot = useMediaRoot('media-self-send');
444
+ let resolveCalls = 0;
445
+ const m = mediaMsg({ fromMe: true, from: OPERATOR, to: CUST });
446
+ rememberSelfSend('ss1', m.id._serialized);
447
+
448
+ const pushed: InboundMsg[] = [];
449
+ await processInbound('ss1', m, {
450
+ resolveMedia: (u, message) => { resolveCalls += 1; return resolveMediaForMessage(u, message); },
451
+ push: (_u, inbound) => pushed.push(inbound)
452
+ });
453
+
454
+ expect(resolveCalls).toBe(0); // no media re-download
455
+ expect(drainInbound('ss1')).toEqual([]); // no queue slot
456
+ expect(pushed).toEqual([]); // no webhook
457
+ expect(existsSync(mediaRoot)).toBe(false); // nothing hit the disk
458
+ expect(loadTargets('ss1').size).toBe(0); // /send already allowlisted it
459
+ });
460
+
461
+ test('a fromMe message NOT in the registry flows through unchanged', async () => {
462
+ rememberSelfSend('ss2', 'some-other-send');
463
+ const m = msg({ fromMe: true, from: OPERATOR, to: CUST, body: 'typed on the phone' });
464
+
465
+ await processInbound('ss2', m, { resolveMedia: async () => ({ mediaStatus: 'available' as const }) });
466
+
467
+ const drained = drainInbound('ss2');
468
+ expect(drained.length).toBe(1);
469
+ expect(drained[0].fromMe).toBe(true);
470
+ expect(drained[0].body).toBe('typed on the phone');
471
+ });
472
+
473
+ test('self-send ids expire after the TTL and the registry stays bounded', () => {
474
+ const now = 1717000000000;
475
+ rememberSelfSend('ss3', 'echo-1', now);
476
+ expect(isSelfSend('ss3', 'echo-1', now + SELF_SEND_TTL_MS)).toBe(true); // still inside
477
+ expect(isSelfSend('ss3', 'echo-1', now + SELF_SEND_TTL_MS + 1)).toBe(false); // expired
478
+ expect(isSelfSend('ss3', 'echo-1', now)).toBe(false); // …and forgotten
479
+
480
+ // Bound: the oldest id is evicted once the per-user cap overflows.
481
+ for (let i = 0; i < SELF_SEND_MAX + 1; i++) rememberSelfSend('ss3', `m${i}`, now);
482
+ expect(isSelfSend('ss3', 'm0', now)).toBe(false); // evicted oldest
483
+ expect(isSelfSend('ss3', 'm1', now)).toBe(true);
484
+ expect(isSelfSend('ss3', `m${SELF_SEND_MAX}`, now)).toBe(true); // newest kept
485
+ });
486
+
487
+ test('isSelfSend scopes ids per user and misses an empty registry', () => {
488
+ rememberSelfSend('ss4', 'echo-1');
489
+ expect(isSelfSend('ss4', 'echo-1')).toBe(true);
490
+ expect(isSelfSend('other-user', 'echo-1')).toBe(false); // per-user scope
491
+ expect(isSelfSend('never-sent', 'anything')).toBe(false);
492
+ });
493
+
494
+ // Restart-equivalent: the registry is in-memory, so after a service restart
495
+ // (empty registry) the echo falls back to flowing through — the host's
496
+ // id-dedupe catches it, harmless.
497
+ test('after a restart (empty registry) the echo flows through to the host', async () => {
498
+ rememberSelfSend('ss5', 'true_echo@c.us_X');
499
+ resetInboundState(); // the restart
500
+ configureInbound(dirFor);
501
+
502
+ const m = msg({ fromMe: true, from: OPERATOR, to: CUST, id: { _serialized: 'true_echo@c.us_X' } });
503
+ await processInbound('ss5', m, { resolveMedia: async () => ({ mediaStatus: 'available' as const }) });
504
+
505
+ expect(drainInbound('ss5').length).toBe(1);
506
+ });
507
+
508
+ test('clearInbound drops the self-send registry so suppression cannot leak across a re-pair', () => {
509
+ rememberSelfSend('ss6', 'echo-1');
510
+ clearInbound('ss6');
511
+ expect(isSelfSend('ss6', 'echo-1')).toBe(false);
512
+ });
513
+
514
+ // Reconnect recovery: fetchMessages returns BOTH directions, so with fromMe
515
+ // accepted the backfill replays operator-app messages typed during a
516
+ // disconnect window alongside the customer replies.
517
+ test('backfillTargets replays both directions through the capture pipeline', async () => {
518
+ rememberTarget('bf3', CUST);
519
+ const client: ChatResolver = {
520
+ async getChatById(_chatId: string) {
521
+ return fakeChat([
522
+ msg({ body: 'customer-reply', getContact: async () => ({}) }),
523
+ msg({ fromMe: true, from: OPERATOR, to: CUST, body: 'operator-app', id: { _serialized: 'op1' } })
524
+ ]);
525
+ },
526
+ async getContactById(_chatId: string) { throw new Error('unused'); }
527
+ };
528
+
529
+ await backfillTargets('bf3', client, (userId, m) =>
530
+ processInbound(userId, m, { resolveMedia: async () => ({ mediaStatus: 'available' as const }) }));
531
+
532
+ const drained = drainInbound('bf3');
533
+ expect(drained.map((m) => m.body).sort()).toEqual(['customer-reply', 'operator-app']);
534
+ expect(drained.find((m) => m.body === 'operator-app')?.fromMe).toBe(true);
535
+ expect(drained.find((m) => m.body === 'customer-reply')?.fromMe).toBeUndefined();
536
+ });
537
+
331
538
  // 0.6.0 wire back-compat: text payloads must keep the exact five-field shape —
332
539
  // no media keys, not even hasMedia:false (hosts key-gate on hasMedia presence).
333
540
  test('normalizeInbound keeps the 0.6.0 shape for non-media messages', () => {