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
data/spec/web_adapter_spec.rb
CHANGED
|
@@ -24,6 +24,52 @@ RSpec.describe WhatsAppNotifier::WebAdapter do
|
|
|
24
24
|
expect(result).to include(success: true, message_id: "k1")
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
+
# The 0.8.0 service returns the real WhatsApp message id — the key the host
|
|
28
|
+
# dedupes the send's own fromMe echo on. It must beat the fabricated id.
|
|
29
|
+
it "prefers the service-issued message id over the idempotency key" do
|
|
30
|
+
response = http_success(body: { "success" => true, "messageId" => "true_919@c.us_ABC" })
|
|
31
|
+
allow(Net::HTTP).to receive(:start).and_return(response)
|
|
32
|
+
|
|
33
|
+
result = adapter.send_message(
|
|
34
|
+
payload: { to: "+1", body: "hi", metadata: { user_id: 1 }, idempotency_key: "k1" },
|
|
35
|
+
session: {}
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
expect(result).to include(success: true, message_id: "true_919@c.us_ABC")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it "accepts the snake_case message_id alias in the send response" do
|
|
42
|
+
response = http_success(body: { "success" => true, "message_id" => "true_919@c.us_DEF" })
|
|
43
|
+
allow(Net::HTTP).to receive(:start).and_return(response)
|
|
44
|
+
|
|
45
|
+
result = adapter.send_message(payload: { to: "+1", body: "hi", metadata: {} }, session: {})
|
|
46
|
+
|
|
47
|
+
expect(result).to include(message_id: "true_919@c.us_DEF")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# A 0.8.0 service that could not read the sent message's id answers
|
|
51
|
+
# messageId: null — fall through to the 0.7.0 fabrication chain.
|
|
52
|
+
it "falls back to the idempotency key when the service returns a null id" do
|
|
53
|
+
response = http_success(body: { "success" => true, "messageId" => nil })
|
|
54
|
+
allow(Net::HTTP).to receive(:start).and_return(response)
|
|
55
|
+
|
|
56
|
+
result = adapter.send_message(
|
|
57
|
+
payload: { to: "+1", body: "hi", metadata: {}, idempotency_key: "k1" },
|
|
58
|
+
session: {}
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
expect(result).to include(message_id: "k1")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
it "fabricates a local id when neither the service nor the payload offers one" do
|
|
65
|
+
response = http_success(body: { "success" => true })
|
|
66
|
+
allow(Net::HTTP).to receive(:start).and_return(response)
|
|
67
|
+
|
|
68
|
+
result = adapter.send_message(payload: { to: "+1", body: "hi", metadata: {} }, session: {})
|
|
69
|
+
|
|
70
|
+
expect(result[:message_id]).to match(/\Alocal-\d+\z/)
|
|
71
|
+
end
|
|
72
|
+
|
|
27
73
|
it "yields the http connection to run the request" do
|
|
28
74
|
response = http_success(body: { "success" => true })
|
|
29
75
|
http = instance_double(Net::HTTP, request: response)
|
|
@@ -100,6 +146,333 @@ RSpec.describe WhatsAppNotifier::WebAdapter do
|
|
|
100
146
|
expect { adapter.fetch_inbound(metadata: {}) }.to raise_error(/service request failed/)
|
|
101
147
|
end
|
|
102
148
|
|
|
149
|
+
it "maps the 0.7.0 media and sender keys when the wire payload carries them" do
|
|
150
|
+
body = { "messages" => [{
|
|
151
|
+
"from" => "919@c.us", "body" => "", "messageId" => "m1", "timestamp" => 123, "type" => "image",
|
|
152
|
+
"hasMedia" => true, "mediaStatus" => "available", "mediaMime" => "image/jpeg",
|
|
153
|
+
"mediaFilename" => "beach.jpg", "mediaSize" => 1024, "senderName" => "Asha"
|
|
154
|
+
}] }
|
|
155
|
+
allow(Net::HTTP).to receive(:start).and_return(http_success(body: body))
|
|
156
|
+
|
|
157
|
+
message = adapter.fetch_inbound(metadata: { user_id: "u-1" }).first
|
|
158
|
+
|
|
159
|
+
expect(message).to include(
|
|
160
|
+
has_media: true, media_status: "available", media_mime: "image/jpeg",
|
|
161
|
+
media_filename: "beach.jpg", media_size: 1024, sender_name: "Asha"
|
|
162
|
+
)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
it "accepts snake_case wire aliases and maps an unavailable verdict's error" do
|
|
166
|
+
body = { "messages" => [{
|
|
167
|
+
"from" => "919@c.us", "body" => "", "message_id" => "m1", "timestamp" => 1, "type" => "video",
|
|
168
|
+
"has_media" => true, "media_status" => "unavailable", "media_error" => "unsupported_type"
|
|
169
|
+
}] }
|
|
170
|
+
allow(Net::HTTP).to receive(:start).and_return(http_success(body: body))
|
|
171
|
+
|
|
172
|
+
message = adapter.fetch_inbound(metadata: {}).first
|
|
173
|
+
|
|
174
|
+
expect(message).to include(has_media: true, media_status: "unavailable", media_error: "unsupported_type")
|
|
175
|
+
expect(message).not_to have_key(:media_mime)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# A 0.6.0 service sends no media keys at all — the mapped hash must omit
|
|
179
|
+
# them (not nil them), because hosts key-gate ingest on has_media presence.
|
|
180
|
+
# Same contract for the 0.8.0 two-way keys: a customer message carries no
|
|
181
|
+
# from_me/to, so they must be absent (hosts key-gate fromMe ingest too).
|
|
182
|
+
it "omits the media and two-way keys entirely for plain inbound payloads" do
|
|
183
|
+
body = [{ "from" => "919@c.us", "body" => "hi", "messageId" => "m1", "timestamp" => 123, "type" => "chat" }]
|
|
184
|
+
allow(Net::HTTP).to receive(:start).and_return(http_success(body: body))
|
|
185
|
+
|
|
186
|
+
message = adapter.fetch_inbound(metadata: {}).first
|
|
187
|
+
|
|
188
|
+
expect(message.keys).to match_array(%i[from body message_id timestamp type])
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# 0.8.0 two-way capture: operator-sent messages arrive with fromMe + to —
|
|
192
|
+
# `to` is the counterparty chat id the host threads the conversation on.
|
|
193
|
+
it "maps the 0.8.0 two-way keys when the wire payload carries them" do
|
|
194
|
+
body = { "messages" => [{
|
|
195
|
+
"from" => "919000000001@c.us", "to" => "919@c.us", "fromMe" => true,
|
|
196
|
+
"body" => "on my way", "messageId" => "true_919@c.us_OP1", "timestamp" => 5, "type" => "chat"
|
|
197
|
+
}] }
|
|
198
|
+
allow(Net::HTTP).to receive(:start).and_return(http_success(body: body))
|
|
199
|
+
|
|
200
|
+
message = adapter.fetch_inbound(metadata: { user_id: "u-1" }).first
|
|
201
|
+
|
|
202
|
+
expect(message).to include(
|
|
203
|
+
from: "919000000001@c.us", to: "919@c.us", from_me: true,
|
|
204
|
+
body: "on my way", message_id: "true_919@c.us_OP1"
|
|
205
|
+
)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
it "accepts the snake_case from_me wire alias" do
|
|
209
|
+
body = { "messages" => [{
|
|
210
|
+
"from" => "919000000001@c.us", "to" => "919@c.us", "from_me" => true,
|
|
211
|
+
"body" => "done", "message_id" => "m9", "timestamp" => 9, "type" => "chat"
|
|
212
|
+
}] }
|
|
213
|
+
allow(Net::HTTP).to receive(:start).and_return(http_success(body: body))
|
|
214
|
+
|
|
215
|
+
message = adapter.fetch_inbound(metadata: {}).first
|
|
216
|
+
|
|
217
|
+
expect(message).to include(from_me: true, to: "919@c.us")
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def binary_response(code:, body: "", headers: {})
|
|
221
|
+
response = double("binary response", code: code, body: body)
|
|
222
|
+
allow(response).to receive(:is_a?) { |klass| code == "200" && klass == Net::HTTPSuccess }
|
|
223
|
+
allow(response).to receive(:[]) { |name| headers[name] }
|
|
224
|
+
response
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def run_binary_request(response)
|
|
228
|
+
captured = nil
|
|
229
|
+
http = double("http")
|
|
230
|
+
allow(http).to receive(:request) { |req| captured = req; response }
|
|
231
|
+
allow(Net::HTTP).to receive(:start) { |*_args, **_kwargs, &blk| blk.call(http) }
|
|
232
|
+
-> { captured }
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
it "fetches media bytes with mime, filename and size on a dedicated binary path" do
|
|
236
|
+
allow(ENV).to receive(:[]).and_call_original
|
|
237
|
+
allow(ENV).to receive(:[]).with("WHATSAPP_WEBHOOK_TOKEN").and_return(nil)
|
|
238
|
+
response = binary_response(
|
|
239
|
+
code: "200", body: "\xFF\xD8raw-jpeg".b,
|
|
240
|
+
headers: { "Content-Type" => "image/jpeg", "Content-Disposition" => 'attachment; filename="beach.jpg"' }
|
|
241
|
+
)
|
|
242
|
+
captured = run_binary_request(response)
|
|
243
|
+
|
|
244
|
+
media = adapter.fetch_media(message_id: "true_919@c.us_ABC", metadata: { user_id: "u-1" })
|
|
245
|
+
|
|
246
|
+
expect(media).to eq(body: "\xFF\xD8raw-jpeg".b, mime: "image/jpeg", filename: "beach.jpg", size: 10)
|
|
247
|
+
expect(captured.call.path).to eq("/media/u-1/true_919@c.us_ABC")
|
|
248
|
+
expect(captured.call["X-WA-Token"]).to be_nil # env unset -> no token header
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
it "returns nil when the service has no copy of the media (404)" do
|
|
252
|
+
allow(Net::HTTP).to receive(:start).and_return(binary_response(code: "404", body: '{"error":"not_found"}'))
|
|
253
|
+
|
|
254
|
+
expect(adapter.fetch_media(message_id: "m1", metadata: {})).to be_nil
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
it "raises on non-404 media fetch failures" do
|
|
258
|
+
allow(Net::HTTP).to receive(:start).and_return(binary_response(code: "500", body: "boom"))
|
|
259
|
+
|
|
260
|
+
expect { adapter.fetch_media(message_id: "m1", metadata: {}) }.to raise_error(/service request failed \(500\)/)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
it "sends X-WA-Token on the media fetch when WHATSAPP_WEBHOOK_TOKEN is set" do
|
|
264
|
+
allow(ENV).to receive(:[]).and_call_original
|
|
265
|
+
allow(ENV).to receive(:[]).with("WHATSAPP_WEBHOOK_TOKEN").and_return("sekrit")
|
|
266
|
+
response = binary_response(code: "200", body: "x", headers: { "Content-Type" => "audio/ogg" })
|
|
267
|
+
captured = run_binary_request(response)
|
|
268
|
+
|
|
269
|
+
media = adapter.fetch_media(message_id: "m1", metadata: { user_id: "u-1" })
|
|
270
|
+
|
|
271
|
+
expect(captured.call["X-WA-Token"]).to eq("sekrit")
|
|
272
|
+
expect(media).to include(mime: "audio/ogg", filename: nil, size: 1)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
it "strips path and query characters from the message id before building the URL" do
|
|
276
|
+
response = binary_response(code: "404")
|
|
277
|
+
captured = run_binary_request(response)
|
|
278
|
+
|
|
279
|
+
adapter.fetch_media(message_id: "../m1?x=1#f", metadata: { user_id: "u-1" })
|
|
280
|
+
|
|
281
|
+
expect(captured.call.path).to eq("/media/u-1/..m1x1f")
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
it "deletes media via the JSON control plane with the token attached" do
|
|
285
|
+
allow(ENV).to receive(:[]).and_call_original
|
|
286
|
+
allow(ENV).to receive(:[]).with("WHATSAPP_WEBHOOK_TOKEN").and_return("sekrit")
|
|
287
|
+
response = http_success(body: { "success" => true })
|
|
288
|
+
captured = nil
|
|
289
|
+
http = double("http")
|
|
290
|
+
allow(http).to receive(:request) { |req| captured = req; response }
|
|
291
|
+
allow(Net::HTTP).to receive(:start) { |*_args, **_kwargs, &blk| blk.call(http) }
|
|
292
|
+
|
|
293
|
+
expect(adapter.delete_media(message_id: "m/1", metadata: { user_id: "u-1" })).to eq(success: true)
|
|
294
|
+
expect(captured).to be_a(Net::HTTP::Delete)
|
|
295
|
+
expect(captured.path).to eq("/media/u-1/m1")
|
|
296
|
+
expect(captured["X-WA-Token"]).to eq("sekrit")
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
it "defaults delete_media success to false when the service omits it" do
|
|
300
|
+
allow(Net::HTTP).to receive(:start).and_return(http_success(body: {}))
|
|
301
|
+
|
|
302
|
+
expect(adapter.delete_media(message_id: "m1", metadata: {})).to eq(success: false)
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# A 0.6.0 service mid-rollout has no /media routes — delete must degrade
|
|
306
|
+
# like fetch_media's nil-on-404, not raise.
|
|
307
|
+
it "returns success false when delete_media hits a 404" do
|
|
308
|
+
allow(Net::HTTP).to receive(:start)
|
|
309
|
+
.and_return(http_failure(code: "404", body: JSON.generate({ error: "not_found" })))
|
|
310
|
+
|
|
311
|
+
expect(adapter.delete_media(message_id: "m1", metadata: {})).to eq(success: false)
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
it "still raises when delete_media fails with a non-404 code" do
|
|
315
|
+
allow(Net::HTTP).to receive(:start)
|
|
316
|
+
.and_return(http_failure(code: "500", body: JSON.generate({ error: "boom" })))
|
|
317
|
+
|
|
318
|
+
expect { adapter.delete_media(message_id: "m1", metadata: {}) }
|
|
319
|
+
.to raise_error(/service request failed \(500\)/)
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
it "refetches media via POST, mapping the success verdict and attaching the token" do
|
|
323
|
+
allow(ENV).to receive(:[]).and_call_original
|
|
324
|
+
allow(ENV).to receive(:[]).with("WHATSAPP_WEBHOOK_TOKEN").and_return("sekrit")
|
|
325
|
+
response = http_success(body: {
|
|
326
|
+
"success" => true, "messageId" => "true_919@c.us_ABC", "mediaStatus" => "available",
|
|
327
|
+
"mediaMime" => "image/jpeg", "mediaFilename" => "beach.jpg", "mediaSize" => 10
|
|
328
|
+
})
|
|
329
|
+
captured = nil
|
|
330
|
+
http = double("http")
|
|
331
|
+
allow(http).to receive(:request) { |req| captured = req; response }
|
|
332
|
+
allow(Net::HTTP).to receive(:start) { |*_args, **_kwargs, &blk| blk.call(http) }
|
|
333
|
+
|
|
334
|
+
result = adapter.refetch_media(message_id: "true_919@c.us_ABC", chat_id: "919@c.us", metadata: { user_id: "u-1" })
|
|
335
|
+
|
|
336
|
+
expect(result).to eq(mime: "image/jpeg", filename: "beach.jpg", size: 10, status: "available")
|
|
337
|
+
expect(captured).to be_a(Net::HTTP::Post)
|
|
338
|
+
expect(captured.path).to eq("/media/u-1/refetch")
|
|
339
|
+
expect(captured["X-WA-Token"]).to eq("sekrit")
|
|
340
|
+
expect(JSON.parse(captured.body)).to eq("messageId" => "true_919@c.us_ABC", "chatId" => "919@c.us")
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
it "accepts the snake_case media keys in the refetch response" do
|
|
344
|
+
body = { "success" => true, "media_status" => "available", "media_mime" => "audio/ogg",
|
|
345
|
+
"media_filename" => "vn.ogg", "media_size" => 3 }
|
|
346
|
+
allow(Net::HTTP).to receive(:start).and_return(http_success(body: body))
|
|
347
|
+
|
|
348
|
+
expect(adapter.refetch_media(message_id: "m1", chat_id: "919@c.us", metadata: {}))
|
|
349
|
+
.to eq(mime: "audio/ogg", filename: "vn.ogg", size: 3, status: "available")
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# Media gone upstream → the service answers 404 success:false; refetch
|
|
353
|
+
# degrades to nil like fetch_media, so the host can grey the bubble out.
|
|
354
|
+
it "returns nil when the refetch reports the media is gone (404)" do
|
|
355
|
+
allow(Net::HTTP).to receive(:start)
|
|
356
|
+
.and_return(http_failure(code: "404", body: JSON.generate({ success: false, mediaStatus: "unavailable", mediaError: "gone" })))
|
|
357
|
+
|
|
358
|
+
expect(adapter.refetch_media(message_id: "m1", chat_id: "919@c.us", metadata: {})).to be_nil
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# A success:false body that somehow arrives with a 2xx still degrades to nil.
|
|
362
|
+
it "returns nil when the refetch response is unsuccessful" do
|
|
363
|
+
allow(Net::HTTP).to receive(:start).and_return(http_success(body: { "success" => false, "mediaError" => "gone" }))
|
|
364
|
+
|
|
365
|
+
expect(adapter.refetch_media(message_id: "m1", chat_id: "919@c.us", metadata: {})).to be_nil
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
it "still raises when the refetch fails with a non-404 code" do
|
|
369
|
+
allow(Net::HTTP).to receive(:start)
|
|
370
|
+
.and_return(http_failure(code: "401", body: JSON.generate({ error: "User not authenticated" })))
|
|
371
|
+
|
|
372
|
+
expect { adapter.refetch_media(message_id: "m1", chat_id: "919@c.us", metadata: {}) }
|
|
373
|
+
.to raise_error(/service request failed \(401\)/)
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
it "lists chats with the token attached and maps the discovery keys" do
|
|
377
|
+
allow(ENV).to receive(:[]).and_call_original
|
|
378
|
+
allow(ENV).to receive(:[]).with("WHATSAPP_WEBHOOK_TOKEN").and_return("sekrit")
|
|
379
|
+
response = http_success(body: { "success" => true, "chats" => [
|
|
380
|
+
{ "id" => "919@c.us", "name" => "Asha", "lastMessageAt" => 1_717_000_000 },
|
|
381
|
+
{ "id" => "918@c.us", "name" => nil, "lastMessageAt" => nil }
|
|
382
|
+
] })
|
|
383
|
+
captured = nil
|
|
384
|
+
http = double("http")
|
|
385
|
+
allow(http).to receive(:request) { |req| captured = req; response }
|
|
386
|
+
allow(Net::HTTP).to receive(:start) { |*_args, **_kwargs, &blk| blk.call(http) }
|
|
387
|
+
|
|
388
|
+
chats = adapter.list_chats(metadata: { user_id: "u-1" })
|
|
389
|
+
|
|
390
|
+
expect(chats).to eq([
|
|
391
|
+
{ id: "919@c.us", name: "Asha", last_message_at: 1_717_000_000 },
|
|
392
|
+
{ id: "918@c.us", name: nil, last_message_at: nil }
|
|
393
|
+
])
|
|
394
|
+
expect(captured).to be_a(Net::HTTP::Get)
|
|
395
|
+
expect(captured.path).to eq("/chats/u-1")
|
|
396
|
+
expect(captured["X-WA-Token"]).to eq("sekrit")
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
it "accepts the snake_case last_message_at wire alias" do
|
|
400
|
+
body = { "chats" => [{ "id" => "919@c.us", "name" => "Asha", "last_message_at" => 9 }] }
|
|
401
|
+
allow(Net::HTTP).to receive(:start).and_return(http_success(body: body))
|
|
402
|
+
|
|
403
|
+
expect(adapter.list_chats(metadata: {}).first).to include(last_message_at: 9)
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
it "returns an empty chat list when the service omits the key" do
|
|
407
|
+
allow(Net::HTTP).to receive(:start).and_return(http_success(body: { "success" => true }))
|
|
408
|
+
|
|
409
|
+
expect(adapter.list_chats(metadata: {})).to eq([])
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
# An unpaired or not-ready user answers 401 — the standard non-2xx raise
|
|
413
|
+
# passes straight through to the caller.
|
|
414
|
+
it "raises the standard error when the chat list is unauthorized" do
|
|
415
|
+
allow(Net::HTTP).to receive(:start)
|
|
416
|
+
.and_return(http_failure(code: "401", body: JSON.generate({ error: "User not authenticated" })))
|
|
417
|
+
|
|
418
|
+
expect { adapter.list_chats(metadata: {}) }
|
|
419
|
+
.to raise_error(/service request failed \(401\): User not authenticated/)
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
it "fetches history with the token, posting the chat id and clamped limit" do
|
|
423
|
+
allow(ENV).to receive(:[]).and_call_original
|
|
424
|
+
allow(ENV).to receive(:[]).with("WHATSAPP_WEBHOOK_TOKEN").and_return("sekrit")
|
|
425
|
+
response = http_success(body: { "success" => true, "messages" => [
|
|
426
|
+
{ "from" => "919@c.us", "body" => "old reply", "messageId" => "h1", "timestamp" => 1, "type" => "chat" },
|
|
427
|
+
{ "from" => "919000000001@c.us", "to" => "919@c.us", "fromMe" => true,
|
|
428
|
+
"body" => "old send", "messageId" => "h2", "timestamp" => 2, "type" => "chat" },
|
|
429
|
+
{ "from" => "919@c.us", "body" => "", "messageId" => "h3", "timestamp" => 3, "type" => "image",
|
|
430
|
+
"hasMedia" => true, "mediaStatus" => "unavailable", "mediaError" => "history" }
|
|
431
|
+
] })
|
|
432
|
+
captured = nil
|
|
433
|
+
http = double("http")
|
|
434
|
+
allow(http).to receive(:request) { |req| captured = req; response }
|
|
435
|
+
allow(Net::HTTP).to receive(:start) { |*_args, **_kwargs, &blk| blk.call(http) }
|
|
436
|
+
|
|
437
|
+
messages = adapter.fetch_history(chat_id: "919@c.us", limit: 100_000, metadata: { user_id: "u-1" })
|
|
438
|
+
|
|
439
|
+
expect(captured).to be_a(Net::HTTP::Post)
|
|
440
|
+
expect(captured.path).to eq("/history/u-1")
|
|
441
|
+
expect(captured["X-WA-Token"]).to eq("sekrit")
|
|
442
|
+
expect(JSON.parse(captured.body)).to eq("chatId" => "919@c.us", "limit" => 200)
|
|
443
|
+
|
|
444
|
+
# Same mapper as fetch_inbound: two-way keys on the operator's messages,
|
|
445
|
+
# the by-design unavailable media verdict on history media.
|
|
446
|
+
expect(messages[0]).to eq(from: "919@c.us", body: "old reply", message_id: "h1", timestamp: 1, type: "chat")
|
|
447
|
+
expect(messages[1]).to include(from_me: true, to: "919@c.us", message_id: "h2")
|
|
448
|
+
expect(messages[2]).to include(has_media: true, media_status: "unavailable", media_error: "history")
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
it "clamps the history limit into 1..200 and defaults garbage to 50" do
|
|
452
|
+
bodies = []
|
|
453
|
+
http = double("http")
|
|
454
|
+
allow(http).to receive(:request) { |req| bodies << JSON.parse(req.body); http_success(body: { "messages" => [] }) }
|
|
455
|
+
allow(Net::HTTP).to receive(:start) { |*_args, **_kwargs, &blk| blk.call(http) }
|
|
456
|
+
|
|
457
|
+
adapter.fetch_history(chat_id: "919@c.us", metadata: {}) # default
|
|
458
|
+
adapter.fetch_history(chat_id: "919@c.us", limit: 0, metadata: {}) # below floor
|
|
459
|
+
adapter.fetch_history(chat_id: "919@c.us", limit: 201, metadata: {}) # above cap
|
|
460
|
+
adapter.fetch_history(chat_id: "919@c.us", limit: "120", metadata: {}) # numeric string
|
|
461
|
+
adapter.fetch_history(chat_id: "919@c.us", limit: 75.9, metadata: {}) # float floors
|
|
462
|
+
adapter.fetch_history(chat_id: "919@c.us", limit: "lots", metadata: {}) # garbage
|
|
463
|
+
adapter.fetch_history(chat_id: "919@c.us", limit: nil, metadata: {}) # nil
|
|
464
|
+
|
|
465
|
+
expect(bodies.map { |b| b["limit"] }).to eq([50, 1, 200, 120, 75, 50, 50])
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
it "raises the standard error when the history fetch fails" do
|
|
469
|
+
allow(Net::HTTP).to receive(:start)
|
|
470
|
+
.and_return(http_failure(code: "422", body: JSON.generate({ error: "`chatId` is required" })))
|
|
471
|
+
|
|
472
|
+
expect { adapter.fetch_history(chat_id: "12@g.us", metadata: {}) }
|
|
473
|
+
.to raise_error(/service request failed \(422\)/)
|
|
474
|
+
end
|
|
475
|
+
|
|
103
476
|
it "logs out via the service" do
|
|
104
477
|
allow(Net::HTTP).to receive(:start).and_return(http_success(body: { "success" => true }))
|
|
105
478
|
|
|
@@ -123,6 +496,40 @@ RSpec.describe WhatsAppNotifier::WebAdapter do
|
|
|
123
496
|
expect(Net::HTTP::Get).to have_received(:new).with("/qr/default")
|
|
124
497
|
end
|
|
125
498
|
|
|
499
|
+
# Net::HTTP does not infer TLS from the URL scheme — an https service URL
|
|
500
|
+
# without use_ssl would send the token and payloads in plaintext.
|
|
501
|
+
it "enables TLS for https service URLs on the JSON request path" do
|
|
502
|
+
secure = described_class.new(base_url: "https://wa.example.com")
|
|
503
|
+
allow(Net::HTTP).to receive(:start).and_return(http_success(body: { "success" => true }))
|
|
504
|
+
|
|
505
|
+
secure.logout(metadata: { user_id: "u-1" })
|
|
506
|
+
|
|
507
|
+
expect(Net::HTTP).to have_received(:start)
|
|
508
|
+
.with("wa.example.com", 443, hash_including(use_ssl: true))
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
it "enables TLS for https service URLs on the binary media path" do
|
|
512
|
+
secure = described_class.new(base_url: "https://wa.example.com")
|
|
513
|
+
allow(Net::HTTP).to receive(:start).and_return(binary_response(code: "404"))
|
|
514
|
+
|
|
515
|
+
secure.fetch_media(message_id: "m1", metadata: { user_id: "u-1" })
|
|
516
|
+
|
|
517
|
+
expect(Net::HTTP).to have_received(:start)
|
|
518
|
+
.with("wa.example.com", 443, hash_including(use_ssl: true))
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
it "keeps TLS off for plain http service URLs on both paths" do
|
|
522
|
+
allow(Net::HTTP).to receive(:start).and_return(
|
|
523
|
+
http_success(body: { "success" => true }), binary_response(code: "404")
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
adapter.logout(metadata: {})
|
|
527
|
+
adapter.fetch_media(message_id: "m1", metadata: {})
|
|
528
|
+
|
|
529
|
+
expect(Net::HTTP).to have_received(:start)
|
|
530
|
+
.with("127.0.0.1", 3001, hash_including(use_ssl: false)).twice
|
|
531
|
+
end
|
|
532
|
+
|
|
126
533
|
it "executes the request inside the Net::HTTP block" do
|
|
127
534
|
fake_http = instance_double(Net::HTTP)
|
|
128
535
|
allow(fake_http).to receive(:request).and_return(http_success(body: { "qr" => "data:image/png;base64,x" }))
|
|
@@ -94,6 +94,11 @@ RSpec.describe WhatsAppNotifier do
|
|
|
94
94
|
allow(fake_client).to receive(:scan_qr).and_return("qr-code")
|
|
95
95
|
allow(fake_client).to receive(:connection_status).and_return(state: "QR_REQUIRED")
|
|
96
96
|
allow(fake_client).to receive(:fetch_inbound).and_return([{ from: "q@c.us" }])
|
|
97
|
+
allow(fake_client).to receive(:fetch_media).and_return(body: "bytes", mime: "image/jpeg", filename: nil, size: 5)
|
|
98
|
+
allow(fake_client).to receive(:delete_media).and_return(success: true)
|
|
99
|
+
allow(fake_client).to receive(:refetch_media).and_return(mime: "image/jpeg", filename: nil, size: 5, status: "available")
|
|
100
|
+
allow(fake_client).to receive(:list_chats).and_return([{ id: "919@c.us", name: "Asha", last_message_at: 9 }])
|
|
101
|
+
allow(fake_client).to receive(:fetch_history).and_return([{ from: "919@c.us", body: "old", message_id: "h1" }])
|
|
97
102
|
allow(fake_client).to receive(:logout).and_return(success: true)
|
|
98
103
|
described_class.instance_variable_set(:@client, fake_client)
|
|
99
104
|
|
|
@@ -101,7 +106,35 @@ RSpec.describe WhatsAppNotifier do
|
|
|
101
106
|
expect(described_class.scan_qr(provider: :web_automation, metadata: { user_id: 1 })).to eq("qr-code")
|
|
102
107
|
expect(described_class.connection_status(provider: :web_automation, metadata: { user_id: 1 })).to include(state: "QR_REQUIRED")
|
|
103
108
|
expect(described_class.fetch_inbound(provider: :web_automation, metadata: { user_id: 1 })).to eq([{ from: "q@c.us" }])
|
|
109
|
+
expect(described_class.fetch_media(message_id: "m1", provider: :web_automation, metadata: { user_id: 1 })).to include(mime: "image/jpeg")
|
|
110
|
+
expect(described_class.delete_media(message_id: "m1", provider: :web_automation, metadata: { user_id: 1 })).to eq(success: true)
|
|
111
|
+
expect(described_class.refetch_media(message_id: "m1", chat_id: "919@c.us", provider: :web_automation, metadata: { user_id: 1 })).to include(status: "available")
|
|
112
|
+
expect(described_class.list_chats(provider: :web_automation, metadata: { user_id: 1 })).to eq([{ id: "919@c.us", name: "Asha", last_message_at: 9 }])
|
|
113
|
+
expect(described_class.fetch_history(chat_id: "919@c.us", limit: 20, provider: :web_automation, metadata: { user_id: 1 })).to eq([{ from: "919@c.us", body: "old", message_id: "h1" }])
|
|
104
114
|
expect(described_class.logout(provider: :web_automation, metadata: { user_id: 1 })).to eq(success: true)
|
|
115
|
+
expect(fake_client).to have_received(:fetch_media).with(message_id: "m1", provider: :web_automation, metadata: { user_id: 1 })
|
|
116
|
+
expect(fake_client).to have_received(:delete_media).with(message_id: "m1", provider: :web_automation, metadata: { user_id: 1 })
|
|
117
|
+
expect(fake_client).to have_received(:refetch_media).with(message_id: "m1", chat_id: "919@c.us", provider: :web_automation, metadata: { user_id: 1 })
|
|
118
|
+
expect(fake_client).to have_received(:list_chats).with(provider: :web_automation, metadata: { user_id: 1 })
|
|
119
|
+
expect(fake_client).to have_received(:fetch_history).with(chat_id: "919@c.us", limit: 20, provider: :web_automation, metadata: { user_id: 1 })
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
it "fetches history through the module API end to end with the default limit" do
|
|
123
|
+
adapter = double(
|
|
124
|
+
send_message: { success: true, session: {} },
|
|
125
|
+
fetch_qr_code: "qr",
|
|
126
|
+
connection_status: { state: "AUTHENTICATED", authenticated: true }
|
|
127
|
+
)
|
|
128
|
+
allow(adapter).to receive(:fetch_history).and_return([{ from: "z@c.us", body: "old", message_id: "h1" }])
|
|
129
|
+
described_class.configure do |config|
|
|
130
|
+
config.provider = :web_automation
|
|
131
|
+
config.web_automation_enabled = true
|
|
132
|
+
config.web_adapter = adapter
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
expect(described_class.fetch_history(chat_id: "919@c.us", metadata: { user_id: 1 }))
|
|
136
|
+
.to eq([{ from: "z@c.us", body: "old", message_id: "h1" }])
|
|
137
|
+
expect(adapter).to have_received(:fetch_history).with(chat_id: "919@c.us", limit: 50, metadata: { user_id: 1 })
|
|
105
138
|
end
|
|
106
139
|
|
|
107
140
|
it "fetches inbound through the module API end to end" do
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: whatsapp_notifier
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.8.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kshitiz Sinha
|
|
@@ -80,14 +80,20 @@ files:
|
|
|
80
80
|
- lib/whatsapp_notifier/railtie.rb
|
|
81
81
|
- lib/whatsapp_notifier/result.rb
|
|
82
82
|
- lib/whatsapp_notifier/services/web_automation/bun.lock
|
|
83
|
+
- lib/whatsapp_notifier/services/web_automation/history.test.ts
|
|
84
|
+
- lib/whatsapp_notifier/services/web_automation/history.ts
|
|
83
85
|
- lib/whatsapp_notifier/services/web_automation/inbound.test.ts
|
|
84
86
|
- lib/whatsapp_notifier/services/web_automation/inbound.ts
|
|
85
87
|
- lib/whatsapp_notifier/services/web_automation/index.ts
|
|
86
88
|
- lib/whatsapp_notifier/services/web_automation/init_gate.test.ts
|
|
87
89
|
- lib/whatsapp_notifier/services/web_automation/init_gate.ts
|
|
90
|
+
- lib/whatsapp_notifier/services/web_automation/media.test.ts
|
|
91
|
+
- lib/whatsapp_notifier/services/web_automation/media.ts
|
|
88
92
|
- lib/whatsapp_notifier/services/web_automation/metrics.test.ts
|
|
89
93
|
- lib/whatsapp_notifier/services/web_automation/metrics.ts
|
|
90
94
|
- lib/whatsapp_notifier/services/web_automation/package.json
|
|
95
|
+
- lib/whatsapp_notifier/services/web_automation/send.test.ts
|
|
96
|
+
- lib/whatsapp_notifier/services/web_automation/send.ts
|
|
91
97
|
- lib/whatsapp_notifier/services/web_automation/sessions.test.ts
|
|
92
98
|
- lib/whatsapp_notifier/services/web_automation/sessions.ts
|
|
93
99
|
- lib/whatsapp_notifier/session/qr_service.rb
|