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
|
@@ -34,7 +34,7 @@ export interface MediaMeta {
|
|
|
34
34
|
capturedAt: number; // epoch ms
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
export type MediaSkipReason = 'unsupported_type' | 'too_large'
|
|
37
|
+
export type MediaSkipReason = 'unsupported_type' | 'too_large';
|
|
38
38
|
export type MediaFailureReason = MediaSkipReason | 'expired' | 'download_failed' | 'invalid_id';
|
|
39
39
|
|
|
40
40
|
// The verdict captureInbound merges into the inbound payload. Structurally
|
|
@@ -76,6 +76,13 @@ export function maxDiskBytes(): number {
|
|
|
76
76
|
return envLimit('WHATSAPP_MEDIA_MAX_DISK_BYTES', 5 * 1024 * 1024 * 1024); // 5GB
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
// Per-user rolling cap (default 1GB). This is the cap that actually shapes disk
|
|
80
|
+
// use: maxDiskBytes() is only an absolute global backstop now. NaN-safe like
|
|
81
|
+
// the others so a malformed env can't silently disable eviction.
|
|
82
|
+
export function maxUserBytes(): number {
|
|
83
|
+
return envLimit('WHATSAPP_MEDIA_MAX_USER_BYTES', 1024 * 1024 * 1024); // 1GB
|
|
84
|
+
}
|
|
85
|
+
|
|
79
86
|
// ── Root + cap accounting ──
|
|
80
87
|
|
|
81
88
|
// index.ts wires this to <SESSION_BASE_DIR>/media; tests to a tmp dir.
|
|
@@ -110,6 +117,24 @@ function computeDiskBytes(): number {
|
|
|
110
117
|
return total;
|
|
111
118
|
}
|
|
112
119
|
|
|
120
|
+
// Payload bytes stored for ONE user — sidecars excluded, exactly as
|
|
121
|
+
// computeDiskBytes counts them, so the per-user total and the global total
|
|
122
|
+
// stay on the same scale and the eviction decrement keeps cachedDiskBytes
|
|
123
|
+
// honest.
|
|
124
|
+
export function userDirBytes(userId: string): number {
|
|
125
|
+
const safeUser = sanitizeId(userId);
|
|
126
|
+
if (!safeUser) return 0;
|
|
127
|
+
let total = 0;
|
|
128
|
+
try {
|
|
129
|
+
const dir = join(mediaRootResolver(), safeUser);
|
|
130
|
+
for (const file of readdirSync(dir)) {
|
|
131
|
+
if (isSidecarName(file)) continue;
|
|
132
|
+
try { total += statSync(join(dir, file)).size; } catch (_) { /* raced a delete */ }
|
|
133
|
+
}
|
|
134
|
+
} catch (_) { /* user dir not created yet → nothing stored */ }
|
|
135
|
+
return total;
|
|
136
|
+
}
|
|
137
|
+
|
|
113
138
|
// ── Id sanitization + path layout ──
|
|
114
139
|
|
|
115
140
|
// Both route params become path segments, so they must be reduced to a safe
|
|
@@ -170,6 +195,12 @@ export function writeMedia(
|
|
|
170
195
|
};
|
|
171
196
|
writeFileSync(paths.metaPath, JSON.stringify(sidecar));
|
|
172
197
|
if (cachedDiskBytes !== null) cachedDiskBytes += data.byteLength;
|
|
198
|
+
// WhatsApp's own model: keep the recent media, roll the old off. The
|
|
199
|
+
// newly written file pushed this user over their cap? Evict their
|
|
200
|
+
// oldest until they fit again. An evicted item is not lost — the host
|
|
201
|
+
// re-downloads it on demand via POST /media/:userId/refetch when an
|
|
202
|
+
// operator opens that bubble.
|
|
203
|
+
enforceUserCap(userId);
|
|
173
204
|
return true;
|
|
174
205
|
} catch (e) {
|
|
175
206
|
console.error(`Failed to persist media ${messageId} for ${userId}`, e);
|
|
@@ -177,6 +208,53 @@ export function writeMedia(
|
|
|
177
208
|
}
|
|
178
209
|
}
|
|
179
210
|
|
|
211
|
+
// Roll the user back under maxUserBytes() by deleting their OLDEST media pair
|
|
212
|
+
// (by file mtime — same pairing/ordering logic as sweepExpired) one at a time.
|
|
213
|
+
// Bounded by the file count: each pass either deletes one pair or stops, so it
|
|
214
|
+
// can never spin (a user already at/under the cap exits immediately, and a
|
|
215
|
+
// single oversize file that alone exceeds the cap is deleted once and the loop
|
|
216
|
+
// ends with the dir empty). cachedDiskBytes is decremented per eviction so
|
|
217
|
+
// downloadPolicy's global backstop and mediaDiskBytes stay accurate.
|
|
218
|
+
export function enforceUserCap(userId: string): number {
|
|
219
|
+
const safeUser = sanitizeId(userId);
|
|
220
|
+
if (!safeUser) return 0;
|
|
221
|
+
const cap = maxUserBytes();
|
|
222
|
+
const dir = join(mediaRootResolver(), safeUser);
|
|
223
|
+
|
|
224
|
+
let evicted = 0;
|
|
225
|
+
let total = userDirBytes(userId);
|
|
226
|
+
// Bound the loop by the directory's file count — defensive belt over the
|
|
227
|
+
// total-shrinks-each-pass invariant.
|
|
228
|
+
let guard = 0;
|
|
229
|
+
while (total > cap) {
|
|
230
|
+
const oldest = oldestMediaName(dir);
|
|
231
|
+
if (!oldest) break; // nothing left to evict (cap smaller than 0 bytes is impossible)
|
|
232
|
+
// deleteMedia decrements cachedDiskBytes by the payload size for us.
|
|
233
|
+
deleteMedia(userId, oldest);
|
|
234
|
+
evicted += 1;
|
|
235
|
+
total = userDirBytes(userId);
|
|
236
|
+
guard += 1;
|
|
237
|
+
if (guard > 100000) break; // pathological safety valve
|
|
238
|
+
}
|
|
239
|
+
return evicted;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// The user's oldest media payload by capturedAt (sidecar) / mtime (fallback) —
|
|
243
|
+
// same age source sweepExpired uses — or null when the dir holds no payloads.
|
|
244
|
+
// Sidecars are skipped; an orphaned sidecar is left for sweepExpired to reap.
|
|
245
|
+
function oldestMediaName(dir: string): string | null {
|
|
246
|
+
let oldestName: string | null = null;
|
|
247
|
+
let oldestAt = Infinity;
|
|
248
|
+
try {
|
|
249
|
+
for (const file of readdirSync(dir)) {
|
|
250
|
+
if (isSidecarName(file)) continue;
|
|
251
|
+
const at = capturedAtFor(join(dir, file), `${join(dir, file)}${SIDECAR_SUFFIX}`);
|
|
252
|
+
if (at < oldestAt) { oldestAt = at; oldestName = file; }
|
|
253
|
+
}
|
|
254
|
+
} catch (_) { /* dir gone → nothing to evict */ }
|
|
255
|
+
return oldestName;
|
|
256
|
+
}
|
|
257
|
+
|
|
180
258
|
// Returns the sidecar when BOTH the bytes and the sidecar are present —
|
|
181
259
|
// captureInbound uses this to skip re-downloading on a reconnect backfill.
|
|
182
260
|
export function mediaExists(userId: string, messageId: string): MediaMeta | null {
|
|
@@ -257,7 +335,14 @@ export function downloadPolicy(type: string, size: number, viewOnce = false): Me
|
|
|
257
335
|
if (viewOnce || !DOWNLOADABLE_TYPES.has(type)) return { download: false, reason: 'unsupported_type' };
|
|
258
336
|
const cap = type === 'document' ? maxDocumentBytes() : INLINE_MEDIA_MAX_BYTES;
|
|
259
337
|
if (size > cap) return { download: false, reason: 'too_large' };
|
|
260
|
-
|
|
338
|
+
// No disk check here any more. A user is NEVER skipped on disk grounds:
|
|
339
|
+
// the per-user 1GB cap is enforced AFTER each successful write by
|
|
340
|
+
// enforceUserCap, which rolls the user's OLDEST media off (WhatsApp's
|
|
341
|
+
// tap-to-download model — an evicted item re-downloads on demand). Because
|
|
342
|
+
// we download-then-evict, we only ever write at most one per-message cap
|
|
343
|
+
// (≤25MB) over the limit before rolling back under it — harmless. The
|
|
344
|
+
// per-MESSAGE size gate above still stands; maxDiskBytes() remains only an
|
|
345
|
+
// absolute global backstop that the per-user cap keeps us far beneath.
|
|
261
346
|
return { download: true };
|
|
262
347
|
}
|
|
263
348
|
|
|
@@ -320,8 +405,13 @@ export async function resolveMediaForMessage(
|
|
|
320
405
|
deps: { timeoutMs?: number } = {}
|
|
321
406
|
): Promise<MediaResolution> {
|
|
322
407
|
// Must mirror normalizeInbound's messageId fallback (inbound.ts) so the
|
|
323
|
-
// stored file is addressable by the id the host received.
|
|
324
|
-
|
|
408
|
+
// stored file is addressable by the id the host received. Like there, the
|
|
409
|
+
// fallback keys on the COUNTERPARTY: on fromMe the `from` is the
|
|
410
|
+
// operator's own jid, shared by every chat — keying on it would store the
|
|
411
|
+
// bytes under one id while the wire advertises another (host GET 404s),
|
|
412
|
+
// and two same-second sends to different customers would collide on disk.
|
|
413
|
+
const counterparty = (msg?.fromMe ? msg?.to : msg?.from) || '';
|
|
414
|
+
const messageId = (msg?.id && msg.id._serialized) || `${counterparty}-${msg?.timestamp}`;
|
|
325
415
|
if (!mediaPaths(userId, messageId)) {
|
|
326
416
|
return { mediaStatus: 'unavailable', mediaError: 'invalid_id' };
|
|
327
417
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { test, expect } from 'bun:test';
|
|
2
|
+
import { sentMessageId } from './send';
|
|
3
|
+
|
|
4
|
+
// The id the host stores against its outbound record — it MUST be the real
|
|
5
|
+
// serialized WhatsApp id so the fromMe echo of this send dedupes on it.
|
|
6
|
+
test('sentMessageId returns the serialized id of the sent message', () => {
|
|
7
|
+
const sent = { id: { _serialized: 'true_919999000001@c.us_ABC' } };
|
|
8
|
+
expect(sentMessageId(sent)).toBe('true_919999000001@c.us_ABC');
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
// Null fallback, never a fabricated id: a made-up id matches no echo but
|
|
12
|
+
// would still occupy the host's unique message-id slot, blocking the echo
|
|
13
|
+
// from being adopted onto the right record.
|
|
14
|
+
test('sentMessageId falls back to null when no id is available', () => {
|
|
15
|
+
expect(sentMessageId(undefined)).toBeNull(); // library resolved nothing
|
|
16
|
+
expect(sentMessageId(null)).toBeNull();
|
|
17
|
+
expect(sentMessageId({})).toBeNull(); // Message without an id
|
|
18
|
+
expect(sentMessageId({ id: {} })).toBeNull(); // id without a serialization
|
|
19
|
+
expect(sentMessageId({ id: { _serialized: '' } })).toBeNull(); // empty id is no id
|
|
20
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// /send response helpers (pure, unit-testable — see send.test.ts).
|
|
2
|
+
//
|
|
3
|
+
// Kept separate from index.ts (which calls Bun.serve() at import time) so the
|
|
4
|
+
// wire shape can be tested without booting the server or whatsapp-web.js.
|
|
5
|
+
|
|
6
|
+
// The real WhatsApp id of a just-sent message, for the /send response. Hosts
|
|
7
|
+
// store it on their outbound record so the message_create echo of this very
|
|
8
|
+
// send (two-way capture replays our own messages too) dedupes on messageId
|
|
9
|
+
// instead of duplicating as an "operator app" bubble.
|
|
10
|
+
//
|
|
11
|
+
// Null — never a fabricated id — when the library hands nothing back: a
|
|
12
|
+
// made-up id matches no echo, yet would still occupy the host's unique
|
|
13
|
+
// message-id slot and block the echo from being adopted onto the right
|
|
14
|
+
// record.
|
|
15
|
+
export function sentMessageId(sent: any): string | null {
|
|
16
|
+
return (sent && sent.id && sent.id._serialized) || null;
|
|
17
|
+
}
|
|
@@ -18,9 +18,13 @@ module WhatsAppNotifier
|
|
|
18
18
|
}.freeze
|
|
19
19
|
|
|
20
20
|
# Optional inbound keys introduced by the 0.7.0 service (media verdict +
|
|
21
|
-
# sender display name)
|
|
22
|
-
#
|
|
23
|
-
#
|
|
21
|
+
# sender display name) and the 0.8.0 service (two-way capture). Mapped
|
|
22
|
+
# ONLY when the wire payload carries them, so hosts can key-gate on
|
|
23
|
+
# presence: a missing has_media means "0.6.0 service, no media support"
|
|
24
|
+
# (while has_media: false means "text message"), and a missing from_me
|
|
25
|
+
# means "customer message or pre-0.8.0 service". `to` carries the
|
|
26
|
+
# counterparty chat id on operator-sent (from_me) messages — the id the
|
|
27
|
+
# host threads the conversation on.
|
|
24
28
|
INBOUND_OPTIONAL_KEYS = {
|
|
25
29
|
has_media: %w[hasMedia has_media],
|
|
26
30
|
media_status: %w[mediaStatus media_status],
|
|
@@ -28,7 +32,9 @@ module WhatsAppNotifier
|
|
|
28
32
|
media_mime: %w[mediaMime media_mime],
|
|
29
33
|
media_filename: %w[mediaFilename media_filename],
|
|
30
34
|
media_size: %w[mediaSize media_size],
|
|
31
|
-
sender_name: %w[senderName sender_name]
|
|
35
|
+
sender_name: %w[senderName sender_name],
|
|
36
|
+
to: %w[to],
|
|
37
|
+
from_me: %w[fromMe from_me]
|
|
32
38
|
}.freeze
|
|
33
39
|
|
|
34
40
|
def self.default_base_url
|
|
@@ -54,7 +60,12 @@ module WhatsAppNotifier
|
|
|
54
60
|
response = request(:post, "/send/#{user_id}", body: body)
|
|
55
61
|
{
|
|
56
62
|
success: response.fetch("success"),
|
|
57
|
-
|
|
63
|
+
# Prefer the service-issued WhatsApp message id (0.8.0): it is the key
|
|
64
|
+
# the host dedupes the send's own fromMe echo on, so a real id must
|
|
65
|
+
# win over the locally fabricated one. The fallback keeps 0.7.0
|
|
66
|
+
# services (no messageId in the response) working unchanged.
|
|
67
|
+
message_id: response["messageId"] || response["message_id"] ||
|
|
68
|
+
payload[:idempotency_key] || "local-#{Time.now.to_i}",
|
|
58
69
|
session: session,
|
|
59
70
|
error_message: response["error"]
|
|
60
71
|
}
|
|
@@ -109,6 +120,29 @@ module WhatsAppNotifier
|
|
|
109
120
|
}
|
|
110
121
|
end
|
|
111
122
|
|
|
123
|
+
# On-demand re-download (WhatsApp tap-to-download). The host calls this when
|
|
124
|
+
# an operator opens a media bubble whose bytes the service no longer holds
|
|
125
|
+
# (rolled off by the per-user cap or expired by TTL): the service re-pulls
|
|
126
|
+
# THAT one message's media and stores it, after which the host fetches it
|
|
127
|
+
# with the usual fetch_media GET. Returns { mime:, filename:, size:, status: }
|
|
128
|
+
# on success, or nil when the media is gone upstream (404) — same nil-on-404
|
|
129
|
+
# contract as fetch_media, so a host that gets nil can grey the bubble out.
|
|
130
|
+
# A 0.7.0 service mid-rollout has no /refetch route and also answers 404 →
|
|
131
|
+
# nil, indistinguishable from gone, which is the safe degrade.
|
|
132
|
+
def refetch_media(message_id:, chat_id:, metadata: {})
|
|
133
|
+
user_id = user_id_from(metadata)
|
|
134
|
+
body = { messageId: message_id, chatId: chat_id }
|
|
135
|
+
response = request(:post, "/media/#{user_id}/refetch", body: body, allow_404: true)
|
|
136
|
+
return nil unless response["success"]
|
|
137
|
+
|
|
138
|
+
{
|
|
139
|
+
mime: response["mediaMime"] || response["media_mime"],
|
|
140
|
+
filename: response["mediaFilename"] || response["media_filename"],
|
|
141
|
+
size: response["mediaSize"] || response["media_size"],
|
|
142
|
+
status: response["mediaStatus"] || response["media_status"]
|
|
143
|
+
}
|
|
144
|
+
end
|
|
145
|
+
|
|
112
146
|
# Removes the service's copy after the host has attached the bytes.
|
|
113
147
|
# Idempotent on the service side: deleting absent media still succeeds.
|
|
114
148
|
# A 0.6.0 service mid-rollout has no /media routes and answers 404 —
|
|
@@ -120,6 +154,31 @@ module WhatsAppNotifier
|
|
|
120
154
|
{ success: response.fetch("success", false) }
|
|
121
155
|
end
|
|
122
156
|
|
|
157
|
+
# Lists the paired number's 1:1 chats for history-sync discovery. Returns
|
|
158
|
+
# [{ id:, name:, last_message_at: }] newest-first; the service caps the
|
|
159
|
+
# list at its newest 500 and excludes groups/status/privacy chats. The
|
|
160
|
+
# route is token-gated like /media and raises the standard error on any
|
|
161
|
+
# non-2xx (401 when the user never paired or isn't ready).
|
|
162
|
+
def list_chats(metadata: {})
|
|
163
|
+
user_id = user_id_from(metadata)
|
|
164
|
+
response = request(:get, "/chats/#{user_id}")
|
|
165
|
+
Array(response["chats"]).map { |chat| map_chat_summary(chat) }
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Replays one chat's history through the service's live-capture
|
|
169
|
+
# normalizer and returns it synchronously (no queue, no webhook) —
|
|
170
|
+
# oldest-first, mapped exactly like fetch_inbound messages, including
|
|
171
|
+
# from_me/to on the operator's side of the conversation. History media
|
|
172
|
+
# arrives marked unavailable by design (media_error "history"): the
|
|
173
|
+
# service never bulk-downloads old media; live capture handles bytes
|
|
174
|
+
# going forward.
|
|
175
|
+
def fetch_history(chat_id:, limit: 50, metadata: {})
|
|
176
|
+
user_id = user_id_from(metadata)
|
|
177
|
+
body = { chatId: chat_id, limit: clamp_history_limit(limit) }
|
|
178
|
+
response = request(:post, "/history/#{user_id}", body: body)
|
|
179
|
+
Array(response["messages"]).map { |m| map_inbound_message(m) }
|
|
180
|
+
end
|
|
181
|
+
|
|
123
182
|
# Logs the user out of WhatsApp and clears their saved session on the service.
|
|
124
183
|
def logout(metadata: {})
|
|
125
184
|
user_id = user_id_from(metadata)
|
|
@@ -127,12 +186,33 @@ module WhatsAppNotifier
|
|
|
127
186
|
{ success: response.fetch("success", false) }
|
|
128
187
|
end
|
|
129
188
|
|
|
189
|
+
# Mirrors the service-side clamp (history.ts) so a host-passed limit can
|
|
190
|
+
# never balloon one request into a session-stalling bulk fetch.
|
|
191
|
+
HISTORY_LIMIT_DEFAULT = 50
|
|
192
|
+
HISTORY_LIMIT_RANGE = (1..200).freeze
|
|
193
|
+
|
|
130
194
|
private
|
|
131
195
|
|
|
132
196
|
def user_id_from(metadata)
|
|
133
197
|
(metadata[:user_id] || metadata["user_id"] || "default").to_s
|
|
134
198
|
end
|
|
135
199
|
|
|
200
|
+
def clamp_history_limit(limit)
|
|
201
|
+
Integer(limit).clamp(HISTORY_LIMIT_RANGE.min, HISTORY_LIMIT_RANGE.max)
|
|
202
|
+
rescue ArgumentError, TypeError
|
|
203
|
+
# Non-integer garbage falls back to the default — the service does the
|
|
204
|
+
# same, so both layers agree on the effective page size.
|
|
205
|
+
HISTORY_LIMIT_DEFAULT
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def map_chat_summary(chat)
|
|
209
|
+
{
|
|
210
|
+
id: chat["id"],
|
|
211
|
+
name: chat["name"],
|
|
212
|
+
last_message_at: chat.key?("lastMessageAt") ? chat["lastMessageAt"] : chat["last_message_at"]
|
|
213
|
+
}
|
|
214
|
+
end
|
|
215
|
+
|
|
136
216
|
def map_inbound_message(message)
|
|
137
217
|
mapped = {
|
|
138
218
|
from: message["from"],
|
data/lib/whatsapp_notifier.rb
CHANGED
|
@@ -74,6 +74,18 @@ module WhatsAppNotifier
|
|
|
74
74
|
client.delete_media(message_id: message_id, provider: provider, metadata: metadata)
|
|
75
75
|
end
|
|
76
76
|
|
|
77
|
+
def refetch_media(message_id:, chat_id:, provider: nil, metadata: {})
|
|
78
|
+
client.refetch_media(message_id: message_id, chat_id: chat_id, provider: provider, metadata: metadata)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def list_chats(provider: nil, metadata: {})
|
|
82
|
+
client.list_chats(provider: provider, metadata: metadata)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def fetch_history(chat_id:, limit: 50, provider: nil, metadata: {})
|
|
86
|
+
client.fetch_history(chat_id: chat_id, limit: limit, provider: provider, metadata: metadata)
|
|
87
|
+
end
|
|
88
|
+
|
|
77
89
|
def logout(provider: nil, metadata: {})
|
|
78
90
|
client.logout(provider: provider, metadata: metadata)
|
|
79
91
|
end
|
data/spec/client_spec.rb
CHANGED
|
@@ -77,7 +77,8 @@ RSpec.describe WhatsAppNotifier::Client do
|
|
|
77
77
|
fetch_qr_code: "qr",
|
|
78
78
|
connection_status: { state: "AUTHENTICATED", authenticated: true },
|
|
79
79
|
fetch_media: { body: "bytes", mime: "image/jpeg", filename: nil, size: 5 },
|
|
80
|
-
delete_media: { success: true }
|
|
80
|
+
delete_media: { success: true },
|
|
81
|
+
refetch_media: { mime: "image/jpeg", filename: nil, size: 5, status: "available" }
|
|
81
82
|
)
|
|
82
83
|
client = described_class.new(configuration: config)
|
|
83
84
|
|
|
@@ -85,6 +86,32 @@ RSpec.describe WhatsAppNotifier::Client do
|
|
|
85
86
|
.to include(body: "bytes", size: 5)
|
|
86
87
|
expect(client.delete_media(message_id: "m1", provider: :web_automation, metadata: { user_id: 1 }))
|
|
87
88
|
.to eq(success: true)
|
|
89
|
+
expect(client.refetch_media(message_id: "m1", chat_id: "919@c.us", provider: :web_automation, metadata: { user_id: 1 }))
|
|
90
|
+
.to include(status: "available")
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
it "delegates list_chats and fetch_history to the provider" do
|
|
95
|
+
Dir.mktmpdir do |dir|
|
|
96
|
+
config.provider = :web_automation
|
|
97
|
+
config.web_automation_enabled = true
|
|
98
|
+
config.web_session_path = File.join(dir, "session.json")
|
|
99
|
+
adapter = double(
|
|
100
|
+
send_message: { success: true, session: {} },
|
|
101
|
+
fetch_qr_code: "qr",
|
|
102
|
+
connection_status: { state: "AUTHENTICATED", authenticated: true },
|
|
103
|
+
list_chats: [{ id: "919@c.us", name: "Asha", last_message_at: 9 }],
|
|
104
|
+
fetch_history: [{ from: "919@c.us", body: "old", message_id: "h1" }]
|
|
105
|
+
)
|
|
106
|
+
config.web_adapter = adapter
|
|
107
|
+
client = described_class.new(configuration: config)
|
|
108
|
+
|
|
109
|
+
expect(client.list_chats(provider: :web_automation, metadata: { user_id: 1 }))
|
|
110
|
+
.to eq([{ id: "919@c.us", name: "Asha", last_message_at: 9 }])
|
|
111
|
+
expect(client.fetch_history(chat_id: "919@c.us", provider: :web_automation, metadata: { user_id: 1 }))
|
|
112
|
+
.to eq([{ from: "919@c.us", body: "old", message_id: "h1" }])
|
|
113
|
+
# The default page size survives the delegation chain untouched.
|
|
114
|
+
expect(adapter).to have_received(:fetch_history).with(chat_id: "919@c.us", limit: 50, metadata: { user_id: 1 })
|
|
88
115
|
end
|
|
89
116
|
end
|
|
90
117
|
|
|
@@ -58,7 +58,7 @@ RSpec.describe "WhatsAppNotifier::Generators::InstallServiceGenerator" do
|
|
|
58
58
|
generator.copy_service_files
|
|
59
59
|
|
|
60
60
|
sources = generator.copied.map(&:first)
|
|
61
|
-
expect(sources).to match_array(%w[index.ts inbound.ts init_gate.ts media.ts metrics.ts sessions.ts package.json bun.lock])
|
|
61
|
+
expect(sources).to match_array(%w[index.ts history.ts inbound.ts init_gate.ts media.ts metrics.ts send.ts sessions.ts package.json bun.lock])
|
|
62
62
|
expect(sources.grep(/test|node_modules|\.wwebjs|\.puppeteer/)).to be_empty
|
|
63
63
|
expect(generator.copied.map(&:last)).to all(start_with("whatsapp_service/"))
|
|
64
64
|
end
|
|
@@ -137,31 +137,36 @@ RSpec.describe WhatsAppNotifier::Providers::WebAutomation do
|
|
|
137
137
|
end
|
|
138
138
|
end
|
|
139
139
|
|
|
140
|
-
it "fetches and
|
|
140
|
+
it "fetches, deletes and refetches media via the adapter when enabled" do
|
|
141
141
|
Dir.mktmpdir do |dir|
|
|
142
142
|
adapter = double(
|
|
143
143
|
fetch_qr_code: "qr", connection_status: {},
|
|
144
144
|
fetch_media: { body: "bytes", mime: "image/jpeg", filename: "a.jpg", size: 5 },
|
|
145
|
-
delete_media: { success: true }
|
|
145
|
+
delete_media: { success: true },
|
|
146
|
+
refetch_media: { mime: "image/jpeg", filename: "a.jpg", size: 5, status: "available" }
|
|
146
147
|
)
|
|
147
148
|
config = build_config(path: File.join(dir, "session.json"), adapter: adapter)
|
|
148
149
|
provider = described_class.new(configuration: config)
|
|
149
150
|
|
|
150
151
|
expect(provider.fetch_media(message_id: "m1", metadata: { user_id: 1 })).to include(mime: "image/jpeg")
|
|
151
152
|
expect(provider.delete_media(message_id: "m1", metadata: { user_id: 1 })).to eq(success: true)
|
|
153
|
+
expect(provider.refetch_media(message_id: "m1", chat_id: "919@c.us", metadata: { user_id: 1 }))
|
|
154
|
+
.to include(status: "available")
|
|
152
155
|
expect(adapter).to have_received(:fetch_media).with(message_id: "m1", metadata: { user_id: 1 })
|
|
153
156
|
expect(adapter).to have_received(:delete_media).with(message_id: "m1", metadata: { user_id: 1 })
|
|
157
|
+
expect(adapter).to have_received(:refetch_media).with(message_id: "m1", chat_id: "919@c.us", metadata: { user_id: 1 })
|
|
154
158
|
end
|
|
155
159
|
end
|
|
156
160
|
|
|
157
161
|
it "raises on the media helpers when the provider is disabled" do
|
|
158
162
|
Dir.mktmpdir do |dir|
|
|
159
|
-
adapter = double(fetch_qr_code: "qr", connection_status: {}, fetch_media: nil, delete_media: { success: true })
|
|
163
|
+
adapter = double(fetch_qr_code: "qr", connection_status: {}, fetch_media: nil, delete_media: { success: true }, refetch_media: nil)
|
|
160
164
|
config = build_config(path: File.join(dir, "session.json"), adapter: adapter, enabled: false)
|
|
161
165
|
provider = described_class.new(configuration: config)
|
|
162
166
|
|
|
163
167
|
expect { provider.fetch_media(message_id: "m1") }.to raise_error(WhatsAppNotifier::ConfigurationError, /disabled/)
|
|
164
168
|
expect { provider.delete_media(message_id: "m1") }.to raise_error(WhatsAppNotifier::ConfigurationError, /disabled/)
|
|
169
|
+
expect { provider.refetch_media(message_id: "m1", chat_id: "919@c.us") }.to raise_error(WhatsAppNotifier::ConfigurationError, /disabled/)
|
|
165
170
|
end
|
|
166
171
|
end
|
|
167
172
|
|
|
@@ -173,6 +178,59 @@ RSpec.describe WhatsAppNotifier::Providers::WebAutomation do
|
|
|
173
178
|
|
|
174
179
|
expect { provider.fetch_media(message_id: "m1") }.to raise_error(WhatsAppNotifier::ConfigurationError, /media fetch/)
|
|
175
180
|
expect { provider.delete_media(message_id: "m1") }.to raise_error(WhatsAppNotifier::ConfigurationError, /media deletion/)
|
|
181
|
+
expect { provider.refetch_media(message_id: "m1", chat_id: "919@c.us") }.to raise_error(WhatsAppNotifier::ConfigurationError, /media refetch/)
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
it "lists chats and fetches history via the adapter when enabled" do
|
|
186
|
+
Dir.mktmpdir do |dir|
|
|
187
|
+
adapter = double(
|
|
188
|
+
fetch_qr_code: "qr", connection_status: {},
|
|
189
|
+
list_chats: [{ id: "919@c.us", name: "Asha", last_message_at: 9 }],
|
|
190
|
+
fetch_history: [{ from: "919@c.us", body: "old", message_id: "h1" }]
|
|
191
|
+
)
|
|
192
|
+
config = build_config(path: File.join(dir, "session.json"), adapter: adapter)
|
|
193
|
+
provider = described_class.new(configuration: config)
|
|
194
|
+
|
|
195
|
+
expect(provider.list_chats(metadata: { user_id: 1 })).to eq([{ id: "919@c.us", name: "Asha", last_message_at: 9 }])
|
|
196
|
+
expect(provider.fetch_history(chat_id: "919@c.us", limit: 20, metadata: { user_id: 1 }))
|
|
197
|
+
.to eq([{ from: "919@c.us", body: "old", message_id: "h1" }])
|
|
198
|
+
expect(adapter).to have_received(:list_chats).with(metadata: { user_id: 1 })
|
|
199
|
+
expect(adapter).to have_received(:fetch_history).with(chat_id: "919@c.us", limit: 20, metadata: { user_id: 1 })
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
it "defaults the history limit to 50 through the provider" do
|
|
204
|
+
Dir.mktmpdir do |dir|
|
|
205
|
+
adapter = double(fetch_qr_code: "qr", connection_status: {}, fetch_history: [])
|
|
206
|
+
config = build_config(path: File.join(dir, "session.json"), adapter: adapter)
|
|
207
|
+
provider = described_class.new(configuration: config)
|
|
208
|
+
|
|
209
|
+
provider.fetch_history(chat_id: "919@c.us")
|
|
210
|
+
|
|
211
|
+
expect(adapter).to have_received(:fetch_history).with(chat_id: "919@c.us", limit: 50, metadata: {})
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
it "raises on the history helpers when the provider is disabled" do
|
|
216
|
+
Dir.mktmpdir do |dir|
|
|
217
|
+
adapter = double(fetch_qr_code: "qr", connection_status: {}, list_chats: [], fetch_history: [])
|
|
218
|
+
config = build_config(path: File.join(dir, "session.json"), adapter: adapter, enabled: false)
|
|
219
|
+
provider = described_class.new(configuration: config)
|
|
220
|
+
|
|
221
|
+
expect { provider.list_chats }.to raise_error(WhatsAppNotifier::ConfigurationError, /disabled/)
|
|
222
|
+
expect { provider.fetch_history(chat_id: "919@c.us") }.to raise_error(WhatsAppNotifier::ConfigurationError, /disabled/)
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
it "raises on the history helpers when the adapter lacks history support" do
|
|
227
|
+
Dir.mktmpdir do |dir|
|
|
228
|
+
adapter = double(fetch_qr_code: "qr", connection_status: {})
|
|
229
|
+
config = build_config(path: File.join(dir, "session.json"), adapter: adapter)
|
|
230
|
+
provider = described_class.new(configuration: config)
|
|
231
|
+
|
|
232
|
+
expect { provider.list_chats }.to raise_error(WhatsAppNotifier::ConfigurationError, /chat listing/)
|
|
233
|
+
expect { provider.fetch_history(chat_id: "919@c.us") }.to raise_error(WhatsAppNotifier::ConfigurationError, /history replay/)
|
|
176
234
|
end
|
|
177
235
|
end
|
|
178
236
|
|