whatsapp_notifier 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ }