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.
- checksums.yaml +4 -4
- data/lib/generators/whatsapp_notifier/install_service_generator.rb +2 -0
- data/lib/whatsapp_notifier/client.rb +12 -0
- data/lib/whatsapp_notifier/providers/web_automation.rb +33 -0
- data/lib/whatsapp_notifier/services/web_automation/history.test.ts +695 -0
- data/lib/whatsapp_notifier/services/web_automation/history.ts +323 -0
- data/lib/whatsapp_notifier/services/web_automation/inbound.test.ts +209 -2
- data/lib/whatsapp_notifier/services/web_automation/inbound.ts +138 -16
- data/lib/whatsapp_notifier/services/web_automation/index.ts +81 -12
- data/lib/whatsapp_notifier/services/web_automation/media.test.ts +175 -9
- data/lib/whatsapp_notifier/services/web_automation/media.ts +94 -4
- data/lib/whatsapp_notifier/services/web_automation/send.test.ts +20 -0
- data/lib/whatsapp_notifier/services/web_automation/send.ts +17 -0
- data/lib/whatsapp_notifier/version.rb +1 -1
- data/lib/whatsapp_notifier/web_adapter.rb +85 -5
- data/lib/whatsapp_notifier.rb +12 -0
- data/spec/client_spec.rb +28 -1
- data/spec/generators/install_service_generator_spec.rb +1 -1
- data/spec/providers/web_automation_spec.rb +61 -3
- data/spec/web_adapter_spec.rb +232 -1
- data/spec/whatsapp_notifier_spec.rb +27 -0
- metadata +5 -1
|
@@ -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', () => {
|