whatsapp_notifier 0.6.0 → 0.7.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 +1 -0
- data/lib/whatsapp_notifier/client.rb +8 -0
- data/lib/whatsapp_notifier/providers/web_automation.rb +21 -0
- data/lib/whatsapp_notifier/services/web_automation/inbound.test.ts +149 -1
- data/lib/whatsapp_notifier/services/web_automation/inbound.ts +90 -2
- data/lib/whatsapp_notifier/services/web_automation/index.ts +42 -29
- data/lib/whatsapp_notifier/services/web_automation/media.test.ts +585 -0
- data/lib/whatsapp_notifier/services/web_automation/media.ts +458 -0
- data/lib/whatsapp_notifier/version.rb +1 -1
- data/lib/whatsapp_notifier/web_adapter.rb +118 -12
- data/lib/whatsapp_notifier.rb +8 -0
- data/spec/client_spec.rb +21 -0
- data/spec/generators/install_service_generator_spec.rb +12 -1
- data/spec/providers/web_automation_spec.rb +39 -0
- data/spec/web_adapter_spec.rb +176 -0
- data/spec/whatsapp_notifier_spec.rb +6 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 554f8799b1a8ce9c71c59f4b801d3ea5f614a313f2ce22df8f93a6983ba06780
|
|
4
|
+
data.tar.gz: 0015b28a62c3471d63fbaa13060faec05084ede1acd6df72018d6e7fee38ca4a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5c8eed1d767807dfbd1de97daa5f3f017f5a853acc7c008a66f99523d135c12b3f420e8866b03e0523e53d45b2df4efdc29036b527dc359918b99b23b19e279e
|
|
7
|
+
data.tar.gz: 0a065dd16263cba9b7f4f8c27f5b4d5b0aae1ba2b0e0b55f8d1a767b61e08804fbdbd432998532298aa33ce2c5cefe2e9b6841c3a2e35c59dda0752e4a86a255
|
data/README.md
CHANGED
|
@@ -57,9 +57,11 @@ status = WhatsAppNotifier.connection_status(metadata: { user_id: current_user.id
|
|
|
57
57
|
## Log out (disconnect + clear session)
|
|
58
58
|
|
|
59
59
|
Explicitly disconnects the user and wipes their saved WhatsApp session from the
|
|
60
|
-
service
|
|
61
|
-
|
|
62
|
-
|
|
60
|
+
service — including any downloaded inbound media and queued replies, which
|
|
61
|
+
belong to the old pairing — so the next connect starts fresh with a new QR.
|
|
62
|
+
Call this from a user-initiated "Log out WhatsApp" action — NOT from your
|
|
63
|
+
app's sign-out, which should leave the WhatsApp session intact for the next
|
|
64
|
+
login.
|
|
63
65
|
|
|
64
66
|
```ruby
|
|
65
67
|
WhatsAppNotifier.logout(metadata: { user_id: current_user.id })
|
|
@@ -144,6 +146,21 @@ creates a WhatsApp client — so it is safe to poll every few seconds:
|
|
|
144
146
|
those have a fully hydrated WhatsApp Web store. Prometheus metrics live at
|
|
145
147
|
`GET /metrics`.
|
|
146
148
|
|
|
149
|
+
## Upgrading to 0.7.0
|
|
150
|
+
|
|
151
|
+
- **If your app monkey-patches `WhatsAppNotifier::WebAdapter#request`** (a
|
|
152
|
+
common trick to bolt TLS onto the service URL): the shared JSON path now
|
|
153
|
+
also carries `DELETE` (`delete_media`) and attaches `X-WA-Token` when
|
|
154
|
+
`WHATSAPP_WEBHOOK_TOKEN` is set. A patch that only routes `POST`/`GET` or
|
|
155
|
+
drops headers will break the new media calls. Prefer retiring the patch
|
|
156
|
+
entirely — the adapter now honors `https://` service URLs natively on both
|
|
157
|
+
the JSON control plane and the binary media path (`use_ssl` follows the URL
|
|
158
|
+
scheme), so TLS no longer needs a patch.
|
|
159
|
+
- **Logout now wipes stored media**: `POST /logout` removes the user's
|
|
160
|
+
downloaded inbound media along with the session dir and queued replies.
|
|
161
|
+
Fetch (and ideally `delete_media`) anything you want to keep before logging
|
|
162
|
+
a user out.
|
|
163
|
+
|
|
147
164
|
## Notes
|
|
148
165
|
|
|
149
166
|
- This gem uses WhatsApp Web automation. Use responsibly and follow WhatsApp policies.
|
|
@@ -31,6 +31,14 @@ module WhatsAppNotifier
|
|
|
31
31
|
provider_for(provider || @configuration.provider).fetch_inbound(metadata: metadata)
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
+
def fetch_media(message_id:, metadata: {}, provider: nil)
|
|
35
|
+
provider_for(provider || @configuration.provider).fetch_media(message_id: message_id, metadata: metadata)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def delete_media(message_id:, metadata: {}, provider: nil)
|
|
39
|
+
provider_for(provider || @configuration.provider).delete_media(message_id: message_id, metadata: metadata)
|
|
40
|
+
end
|
|
41
|
+
|
|
34
42
|
def logout(metadata: {}, provider: nil)
|
|
35
43
|
provider_for(provider || @configuration.provider).logout(metadata: metadata)
|
|
36
44
|
end
|
|
@@ -61,6 +61,27 @@ module WhatsAppNotifier
|
|
|
61
61
|
adapter.fetch_inbound(metadata: metadata)
|
|
62
62
|
end
|
|
63
63
|
|
|
64
|
+
# Media helpers are optional adapter capabilities (added in v0.7.0) —
|
|
65
|
+
# same guard idiom as fetch_inbound so older adapters fail with a clear
|
|
66
|
+
# ConfigurationError rather than NoMethodError.
|
|
67
|
+
def fetch_media(message_id:, metadata: {})
|
|
68
|
+
raise ConfigurationError, "web automation provider is disabled" unless configuration.web_automation_enabled
|
|
69
|
+
|
|
70
|
+
adapter = configuration.web_adapter
|
|
71
|
+
raise ConfigurationError, "web_adapter does not support media fetch (upgrade to a fetch_media-capable adapter)" unless adapter.respond_to?(:fetch_media)
|
|
72
|
+
|
|
73
|
+
adapter.fetch_media(message_id: message_id, metadata: metadata)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def delete_media(message_id:, metadata: {})
|
|
77
|
+
raise ConfigurationError, "web automation provider is disabled" unless configuration.web_automation_enabled
|
|
78
|
+
|
|
79
|
+
adapter = configuration.web_adapter
|
|
80
|
+
raise ConfigurationError, "web_adapter does not support media deletion (upgrade to a delete_media-capable adapter)" unless adapter.respond_to?(:delete_media)
|
|
81
|
+
|
|
82
|
+
adapter.delete_media(message_id: message_id, metadata: metadata)
|
|
83
|
+
end
|
|
84
|
+
|
|
64
85
|
def logout(metadata: {})
|
|
65
86
|
raise ConfigurationError, "web automation provider is disabled" unless configuration.web_automation_enabled
|
|
66
87
|
|
|
@@ -15,9 +15,12 @@ import {
|
|
|
15
15
|
clearInbound,
|
|
16
16
|
shouldCapture,
|
|
17
17
|
normalizeInbound,
|
|
18
|
+
processInbound,
|
|
18
19
|
resetInboundState,
|
|
19
|
-
type ChatResolver
|
|
20
|
+
type ChatResolver,
|
|
21
|
+
type InboundMsg
|
|
20
22
|
} from './inbound';
|
|
23
|
+
import { configureMedia, resolveMediaForMessage, mediaDiskBytes, resetMediaState } from './media';
|
|
21
24
|
|
|
22
25
|
const root = mkdtempSync(join(tmpdir(), 'wa-inbound-'));
|
|
23
26
|
const dirFor = (userId: string) => join(root, `session-user-${userId}`);
|
|
@@ -215,3 +218,148 @@ test('normalizeInbound maps fields and falls back on missing id', () => {
|
|
|
215
218
|
expect(b.body).toBe('');
|
|
216
219
|
expect(b.type).toBe('chat');
|
|
217
220
|
});
|
|
221
|
+
|
|
222
|
+
// ── processInbound (capture pipeline ordering) ──
|
|
223
|
+
|
|
224
|
+
const LID_FROM = '125417440686124@lid';
|
|
225
|
+
|
|
226
|
+
function mediaMsg(overrides: any = {}) {
|
|
227
|
+
return msg({
|
|
228
|
+
hasMedia: true,
|
|
229
|
+
type: 'image',
|
|
230
|
+
_data: { size: 10, mimetype: 'image/jpeg' },
|
|
231
|
+
getContact: async () => ({ pushname: 'Asha' }),
|
|
232
|
+
downloadMedia: async () => ({
|
|
233
|
+
data: Buffer.from('jpeg-bytes').toString('base64'),
|
|
234
|
+
mimetype: 'image/jpeg',
|
|
235
|
+
filename: 'beach.jpg'
|
|
236
|
+
}),
|
|
237
|
+
...overrides
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Wire the real media store at a per-test root so "nothing written" is a
|
|
242
|
+
// statement about the disk, not about a stub.
|
|
243
|
+
function useMediaRoot(name: string) {
|
|
244
|
+
const mediaRoot = join(root, name);
|
|
245
|
+
configureMedia(() => mediaRoot);
|
|
246
|
+
resetMediaState();
|
|
247
|
+
return mediaRoot;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
test('processInbound drops an unresolvable @lid BEFORE any media download', async () => {
|
|
251
|
+
const mediaRoot = useMediaRoot('media-lid-drop');
|
|
252
|
+
let resolveCalls = 0;
|
|
253
|
+
let downloads = 0;
|
|
254
|
+
const m = mediaMsg({
|
|
255
|
+
from: LID_FROM,
|
|
256
|
+
getContact: async () => ({ pushname: 'Asha' }), // no phone → unresolvable
|
|
257
|
+
downloadMedia: async () => { downloads += 1; return null; }
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
await processInbound('pi1', m, {
|
|
261
|
+
resolveMedia: (u, message) => { resolveCalls += 1; return resolveMediaForMessage(u, message); }
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
expect(resolveCalls).toBe(0); // dropped before the download path
|
|
265
|
+
expect(downloads).toBe(0);
|
|
266
|
+
expect(drainInbound('pi1')).toEqual([]);
|
|
267
|
+
expect(existsSync(mediaRoot)).toBe(false); // nothing written to disk
|
|
268
|
+
expect(mediaDiskBytes()).toBe(0);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test('processInbound resolves an @lid sender, then downloads for the kept message', async () => {
|
|
272
|
+
useMediaRoot('media-lid-kept');
|
|
273
|
+
rememberTarget('pi2', CUST); // resolved phone is a known outbound target
|
|
274
|
+
const m = mediaMsg({
|
|
275
|
+
from: LID_FROM,
|
|
276
|
+
getContact: async () => ({ pushname: 'Asha', number: '919999000001' })
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
const pushed: InboundMsg[] = [];
|
|
280
|
+
await processInbound('pi2', m, {
|
|
281
|
+
resolveMedia: resolveMediaForMessage,
|
|
282
|
+
push: (_u, inbound) => pushed.push(inbound)
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
const drained = drainInbound('pi2');
|
|
286
|
+
expect(drained.length).toBe(1);
|
|
287
|
+
expect(drained[0].from).toBe(CUST); // resolved to the phone
|
|
288
|
+
expect(drained[0].senderName).toBe('Asha');
|
|
289
|
+
expect(drained[0].mediaStatus).toBe('available');
|
|
290
|
+
expect(drained[0].mediaSize).toBe(10);
|
|
291
|
+
expect(pushed).toEqual(drained); // webhook saw the same payload
|
|
292
|
+
expect(loadTargets('pi2').has(LID_FROM)).toBe(true); // alias allowlisted for backfill
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test('processInbound keeps an @c.us message when the contact lookup fails', async () => {
|
|
296
|
+
useMediaRoot('media-contact-fail');
|
|
297
|
+
const m = msg({ getContact: async () => { throw new Error('boom'); } });
|
|
298
|
+
|
|
299
|
+
await processInbound('pi3', m, { resolveMedia: resolveMediaForMessage });
|
|
300
|
+
|
|
301
|
+
const drained = drainInbound('pi3');
|
|
302
|
+
expect(drained.length).toBe(1);
|
|
303
|
+
expect(drained[0].from).toBe(CUST);
|
|
304
|
+
expect('senderName' in drained[0]).toBe(false);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test('processInbound enqueues type-only when the media resolver reports a failure', async () => {
|
|
308
|
+
const m = mediaMsg({});
|
|
309
|
+
await processInbound('pi4', m, {
|
|
310
|
+
resolveMedia: async () => ({ mediaStatus: 'unavailable', mediaError: 'download_failed' })
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
const drained = drainInbound('pi4');
|
|
314
|
+
expect(drained.length).toBe(1);
|
|
315
|
+
expect(drained[0].hasMedia).toBe(true);
|
|
316
|
+
expect(drained[0].mediaStatus).toBe('unavailable');
|
|
317
|
+
expect(drained[0].mediaError).toBe('download_failed');
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test('processInbound rejects filtered messages without resolving anything', async () => {
|
|
321
|
+
let resolveCalls = 0;
|
|
322
|
+
const deps = { resolveMedia: async () => { resolveCalls += 1; return { mediaStatus: 'available' as const }; } };
|
|
323
|
+
|
|
324
|
+
await processInbound('pi5', mediaMsg({ fromMe: true }), deps);
|
|
325
|
+
await processInbound('pi5', mediaMsg({ from: '12@g.us' }), deps);
|
|
326
|
+
|
|
327
|
+
expect(resolveCalls).toBe(0);
|
|
328
|
+
expect(drainInbound('pi5')).toEqual([]);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
// 0.6.0 wire back-compat: text payloads must keep the exact five-field shape —
|
|
332
|
+
// no media keys, not even hasMedia:false (hosts key-gate on hasMedia presence).
|
|
333
|
+
test('normalizeInbound keeps the 0.6.0 shape for non-media messages', () => {
|
|
334
|
+
const plain = normalizeInbound(msg({ hasMedia: false }));
|
|
335
|
+
expect(Object.keys(plain).sort()).toEqual(['body', 'from', 'messageId', 'timestamp', 'type']);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test('normalizeInbound flags hasMedia even when no verdict is supplied', () => {
|
|
339
|
+
const out = normalizeInbound(msg({ hasMedia: true, type: 'image' }));
|
|
340
|
+
expect(out.hasMedia).toBe(true);
|
|
341
|
+
expect(out.type).toBe('image');
|
|
342
|
+
expect('mediaStatus' in out).toBe(false); // verdict is capture-level
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
test('normalizeInbound merges an available media verdict into the payload', () => {
|
|
346
|
+
const out = normalizeInbound(msg({ hasMedia: true, type: 'image' }), {
|
|
347
|
+
mediaStatus: 'available', mediaMime: 'image/jpeg', mediaFilename: 'beach.jpg', mediaSize: 1024
|
|
348
|
+
});
|
|
349
|
+
expect(out).toEqual({
|
|
350
|
+
from: CUST, body: 'hello', messageId: 'true_919999000001@c.us_ABC',
|
|
351
|
+
timestamp: 1717000000, type: 'image',
|
|
352
|
+
hasMedia: true, mediaStatus: 'available',
|
|
353
|
+
mediaMime: 'image/jpeg', mediaFilename: 'beach.jpg', mediaSize: 1024
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test('normalizeInbound merges an unavailable verdict with its typed reason', () => {
|
|
358
|
+
const out = normalizeInbound(msg({ hasMedia: true, type: 'video' }), {
|
|
359
|
+
mediaStatus: 'unavailable', mediaError: 'unsupported_type'
|
|
360
|
+
});
|
|
361
|
+
expect(out.hasMedia).toBe(true);
|
|
362
|
+
expect(out.mediaStatus).toBe('unavailable');
|
|
363
|
+
expect(out.mediaError).toBe('unsupported_type');
|
|
364
|
+
expect('mediaMime' in out).toBe(false);
|
|
365
|
+
});
|
|
@@ -19,6 +19,27 @@ export interface InboundMsg {
|
|
|
19
19
|
messageId: string;
|
|
20
20
|
timestamp: number;
|
|
21
21
|
type: string;
|
|
22
|
+
// 0.7.0 media + sender enrichment. ALL optional and only present when the
|
|
23
|
+
// message actually carries them, so the wire format stays byte-compatible
|
|
24
|
+
// with 0.6.0 hosts (and 0.7.0 hosts can key-gate on hasMedia).
|
|
25
|
+
hasMedia?: boolean;
|
|
26
|
+
mediaStatus?: 'available' | 'unavailable';
|
|
27
|
+
mediaError?: string;
|
|
28
|
+
mediaMime?: string;
|
|
29
|
+
mediaFilename?: string;
|
|
30
|
+
mediaSize?: number;
|
|
31
|
+
senderName?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Media verdict merged into the payload by captureInbound — structurally
|
|
35
|
+
// matches media.ts's MediaResolution without importing it (inbound stays the
|
|
36
|
+
// dependency-free core).
|
|
37
|
+
export interface InboundMediaInfo {
|
|
38
|
+
mediaStatus: 'available' | 'unavailable';
|
|
39
|
+
mediaError?: string;
|
|
40
|
+
mediaMime?: string;
|
|
41
|
+
mediaFilename?: string;
|
|
42
|
+
mediaSize?: number;
|
|
22
43
|
}
|
|
23
44
|
|
|
24
45
|
export const INBOUND_QUEUE_CAP = 1000;
|
|
@@ -113,15 +134,82 @@ export function shouldCapture(userId: string, msg: any): boolean {
|
|
|
113
134
|
return true;
|
|
114
135
|
}
|
|
115
136
|
|
|
116
|
-
export function normalizeInbound(msg: any): InboundMsg {
|
|
137
|
+
export function normalizeInbound(msg: any, media?: InboundMediaInfo): InboundMsg {
|
|
117
138
|
const from: string = msg.from || '';
|
|
118
|
-
|
|
139
|
+
const inbound: InboundMsg = {
|
|
119
140
|
from,
|
|
120
141
|
body: msg.body || '',
|
|
121
142
|
messageId: (msg.id && msg.id._serialized) || `${from}-${msg.timestamp}`,
|
|
122
143
|
timestamp: msg.timestamp || Math.floor(Date.now() / 1000),
|
|
123
144
|
type: msg.type || 'chat'
|
|
124
145
|
};
|
|
146
|
+
// Media keys are added ONLY for media messages so text payloads keep the
|
|
147
|
+
// exact 0.6.0 five-field shape (hosts key-gate on hasMedia presence).
|
|
148
|
+
if (msg.hasMedia) inbound.hasMedia = true;
|
|
149
|
+
if (media) Object.assign(inbound, media);
|
|
150
|
+
return inbound;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── Capture pipeline ──
|
|
154
|
+
//
|
|
155
|
+
// Extracted from index.ts so the ordering contract is unit-testable:
|
|
156
|
+
// sanity filter → contact/@lid sender resolution (drop early) → media
|
|
157
|
+
// download → normalize → enqueue → optional webhook push. The media resolver
|
|
158
|
+
// is injected (media.ts's resolveMediaForMessage in production) so this file
|
|
159
|
+
// stays the dependency-free core.
|
|
160
|
+
export interface CaptureDeps {
|
|
161
|
+
resolveMedia: (userId: string, msg: any) => Promise<InboundMediaInfo>;
|
|
162
|
+
push?: (userId: string, msg: InboundMsg) => void;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function processInbound(userId: string, msg: any, deps: CaptureDeps) {
|
|
166
|
+
if (!shouldCapture(userId, msg)) return;
|
|
167
|
+
|
|
168
|
+
// One best-effort contact lookup feeds both the sender's display name
|
|
169
|
+
// and the @lid phone resolution. Failure must never drop the message —
|
|
170
|
+
// unless the sender is an unresolvable @lid (handled below).
|
|
171
|
+
let contact: any;
|
|
172
|
+
try {
|
|
173
|
+
contact = await msg.getContact();
|
|
174
|
+
} catch (e) {
|
|
175
|
+
console.error(`contact lookup failed for ${userId}`, e);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Resolve the sender BEFORE downloading media. Newer WhatsApp delivers
|
|
179
|
+
// the reply's `from` as an @lid privacy id with no phone number, which
|
|
180
|
+
// the host can't match; if the contact can't supply the real phone the
|
|
181
|
+
// message is dropped — and a dropped message must not have cost a
|
|
182
|
+
// download that leaves up to 25MB of unreferenced bytes on disk.
|
|
183
|
+
const rawFrom: string = msg.from || '';
|
|
184
|
+
let from = rawFrom;
|
|
185
|
+
if (rawFrom.endsWith('@lid')) {
|
|
186
|
+
const num = contact && (contact.number || (contact.id && contact.id.user));
|
|
187
|
+
if (num) from = `${String(num).replace(/\D/g, '')}@c.us`;
|
|
188
|
+
// Still an @lid => no phone to match or scope by. Drop it rather than
|
|
189
|
+
// forward an unmatchable, unpurgeable plaintext body.
|
|
190
|
+
if (from.endsWith('@lid')) return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Only a kept message earns the download. Every resolver failure mode
|
|
194
|
+
// returns an 'unavailable' verdict instead of throwing, so the message
|
|
195
|
+
// still reaches the host type-only.
|
|
196
|
+
let media: InboundMediaInfo | undefined;
|
|
197
|
+
if (msg.hasMedia) {
|
|
198
|
+
media = await deps.resolveMedia(userId, msg);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const inbound = normalizeInbound(msg, media);
|
|
202
|
+
inbound.from = from;
|
|
203
|
+
const senderName = contact && (contact.pushname || contact.name || contact.shortName);
|
|
204
|
+
if (senderName) inbound.senderName = String(senderName);
|
|
205
|
+
if (rawFrom.endsWith('@lid')) {
|
|
206
|
+
// Known recipient replying from a privacy-number chat: allowlist the
|
|
207
|
+
// @lid chat id too, so the reconnect backfill can re-open this chat.
|
|
208
|
+
rememberLidAlias(userId, rawFrom, from);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
enqueueInbound(userId, inbound);
|
|
212
|
+
if (deps.push) deps.push(userId, inbound);
|
|
125
213
|
}
|
|
126
214
|
|
|
127
215
|
// Minimal slice of whatsapp-web.js Client that backfill needs — a seam so the
|
|
@@ -16,14 +16,19 @@ import {
|
|
|
16
16
|
InboundMsg,
|
|
17
17
|
configureInbound,
|
|
18
18
|
rememberTarget,
|
|
19
|
-
rememberLidAlias,
|
|
20
19
|
backfillTargets,
|
|
21
|
-
enqueueInbound,
|
|
22
20
|
drainInbound,
|
|
23
21
|
clearInbound,
|
|
24
|
-
|
|
25
|
-
normalizeInbound
|
|
22
|
+
processInbound
|
|
26
23
|
} from './inbound';
|
|
24
|
+
import {
|
|
25
|
+
configureMedia,
|
|
26
|
+
resolveMediaForMessage,
|
|
27
|
+
sweepExpired,
|
|
28
|
+
clearUserMedia,
|
|
29
|
+
mediaGetResponse,
|
|
30
|
+
mediaDeleteResponse
|
|
31
|
+
} from './media';
|
|
27
32
|
|
|
28
33
|
const app = new Hono();
|
|
29
34
|
const port = Number(process.env.PORT || 3001);
|
|
@@ -71,8 +76,10 @@ const initRetries = new InitRetryLimiter(MAX_INIT_RETRIES);
|
|
|
71
76
|
const WEBHOOK_URL = process.env.WHATSAPP_WEBHOOK_URL;
|
|
72
77
|
const WEBHOOK_TOKEN = process.env.WHATSAPP_WEBHOOK_TOKEN;
|
|
73
78
|
|
|
74
|
-
// Tell the inbound core how to resolve each user's on-disk session dir
|
|
79
|
+
// Tell the inbound core how to resolve each user's on-disk session dir, and
|
|
80
|
+
// the media store where downloaded inbound media lives (survives restarts).
|
|
75
81
|
configureInbound(sessionDirForUser);
|
|
82
|
+
configureMedia(() => join(SESSION_BASE_DIR, 'media'));
|
|
76
83
|
|
|
77
84
|
async function pushWebhook(userId: string, msg: InboundMsg) {
|
|
78
85
|
if (!WEBHOOK_URL) return;
|
|
@@ -90,32 +97,15 @@ async function pushWebhook(userId: string, msg: InboundMsg) {
|
|
|
90
97
|
}
|
|
91
98
|
}
|
|
92
99
|
|
|
93
|
-
// Wrapper
|
|
100
|
+
// Wrapper around the testable pipeline in inbound.ts (sanity filter → sender
|
|
101
|
+
// resolution with early @lid drop → media download → normalize → enqueue →
|
|
102
|
+
// webhook). The catch keeps a single bad message from killing the listener.
|
|
94
103
|
async function captureInbound(userId: string, msg: any) {
|
|
95
104
|
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);
|
|
105
|
+
await processInbound(userId, msg, {
|
|
106
|
+
resolveMedia: resolveMediaForMessage,
|
|
107
|
+
push: pushWebhook
|
|
108
|
+
});
|
|
119
109
|
} catch (e) {
|
|
120
110
|
console.error(`captureInbound error for ${userId}`, e);
|
|
121
111
|
}
|
|
@@ -460,6 +450,14 @@ setInterval(() => {
|
|
|
460
450
|
destroyClient(userId, wipe).catch(console.error);
|
|
461
451
|
}
|
|
462
452
|
}
|
|
453
|
+
|
|
454
|
+
// Evict downloaded inbound media past its TTL (and refresh the disk-cap
|
|
455
|
+
// accounting) on the same cadence — a sweep failure must not stop reaping.
|
|
456
|
+
try {
|
|
457
|
+
sweepExpired();
|
|
458
|
+
} catch (e) {
|
|
459
|
+
console.error('Media sweep failed', e);
|
|
460
|
+
}
|
|
463
461
|
}, SWEEP_INTERVAL_MS);
|
|
464
462
|
|
|
465
463
|
// API Routes
|
|
@@ -510,6 +508,11 @@ app.post('/logout/:userId', async (c) => {
|
|
|
510
508
|
// anything captured between the last poll and this logout would sit in
|
|
511
509
|
// memory and replay into the WRONG pairing if this userId pairs again.
|
|
512
510
|
clearInbound(userId);
|
|
511
|
+
// Logout privacy contract: downloaded media belongs to the old pairing.
|
|
512
|
+
// Without this wipe, customer photos/documents stayed on disk (and
|
|
513
|
+
// fetchable via GET /media) for up to the 48h TTL after the operator
|
|
514
|
+
// severed the pairing.
|
|
515
|
+
clearUserMedia(userId);
|
|
513
516
|
initializingClients.delete(userId);
|
|
514
517
|
return c.json({ success: true });
|
|
515
518
|
});
|
|
@@ -561,6 +564,16 @@ app.get('/inbound/:userId', async (c) => {
|
|
|
561
564
|
return c.json({ messages: drainInbound(userId) });
|
|
562
565
|
});
|
|
563
566
|
|
|
567
|
+
// GET/DELETE /media/:userId/:messageId — serve / evict downloaded inbound
|
|
568
|
+
// media. Token-gated when WHATSAPP_WEBHOOK_TOKEN is set; ids are sanitized in
|
|
569
|
+
// the store; NEVER calls getOrCreateClient (same fast-reject rule as /inbound:
|
|
570
|
+
// fetching bytes for a never-paired user must not boot a Chromium).
|
|
571
|
+
app.get('/media/:userId/:messageId', (c) =>
|
|
572
|
+
mediaGetResponse(c.req.param('userId'), c.req.param('messageId'), c.req.header('X-WA-Token'), WEBHOOK_TOKEN));
|
|
573
|
+
|
|
574
|
+
app.delete('/media/:userId/:messageId', (c) =>
|
|
575
|
+
mediaDeleteResponse(c.req.param('userId'), c.req.param('messageId'), c.req.header('X-WA-Token'), WEBHOOK_TOKEN));
|
|
576
|
+
|
|
564
577
|
|
|
565
578
|
console.log(`Starting Multi-User WhatsApp service (Bun Native) on port ${port}...`);
|
|
566
579
|
|