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.
- checksums.yaml +4 -4
- data/README.md +20 -3
- data/lib/generators/whatsapp_notifier/install_service_generator.rb +3 -0
- data/lib/whatsapp_notifier/client.rb +20 -0
- data/lib/whatsapp_notifier/providers/web_automation.rb +54 -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 +357 -2
- data/lib/whatsapp_notifier/services/web_automation/inbound.ts +228 -18
- data/lib/whatsapp_notifier/services/web_automation/index.ts +123 -41
- data/lib/whatsapp_notifier/services/web_automation/media.test.ts +751 -0
- data/lib/whatsapp_notifier/services/web_automation/media.ts +548 -0
- 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 +199 -13
- data/lib/whatsapp_notifier.rb +20 -0
- data/spec/client_spec.rb +48 -0
- data/spec/generators/install_service_generator_spec.rb +12 -1
- data/spec/providers/web_automation_spec.rb +97 -0
- data/spec/web_adapter_spec.rb +407 -0
- data/spec/whatsapp_notifier_spec.rb +33 -0
- metadata +7 -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
|
+
}
|