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