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.
@@ -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)
@@ -131,7 +177,9 @@ RSpec.describe WhatsAppNotifier::WebAdapter do
131
177
 
132
178
  # A 0.6.0 service sends no media keys at all — the mapped hash must omit
133
179
  # them (not nil them), because hosts key-gate ingest on has_media presence.
134
- it "omits the media keys entirely for 0.6.0-shaped payloads" do
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
135
183
  body = [{ "from" => "919@c.us", "body" => "hi", "messageId" => "m1", "timestamp" => 123, "type" => "chat" }]
136
184
  allow(Net::HTTP).to receive(:start).and_return(http_success(body: body))
137
185
 
@@ -140,6 +188,35 @@ RSpec.describe WhatsAppNotifier::WebAdapter do
140
188
  expect(message.keys).to match_array(%i[from body message_id timestamp type])
141
189
  end
142
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
+
143
220
  def binary_response(code:, body: "", headers: {})
144
221
  response = double("binary response", code: code, body: body)
145
222
  allow(response).to receive(:is_a?) { |klass| code == "200" && klass == Net::HTTPSuccess }
@@ -242,6 +319,160 @@ RSpec.describe WhatsAppNotifier::WebAdapter do
242
319
  .to raise_error(/service request failed \(500\)/)
243
320
  end
244
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
+
245
476
  it "logs out via the service" do
246
477
  allow(Net::HTTP).to receive(:start).and_return(http_success(body: { "success" => true }))
247
478
 
@@ -96,6 +96,9 @@ RSpec.describe WhatsAppNotifier do
96
96
  allow(fake_client).to receive(:fetch_inbound).and_return([{ from: "q@c.us" }])
97
97
  allow(fake_client).to receive(:fetch_media).and_return(body: "bytes", mime: "image/jpeg", filename: nil, size: 5)
98
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" }])
99
102
  allow(fake_client).to receive(:logout).and_return(success: true)
100
103
  described_class.instance_variable_set(:@client, fake_client)
101
104
 
@@ -105,9 +108,33 @@ RSpec.describe WhatsAppNotifier do
105
108
  expect(described_class.fetch_inbound(provider: :web_automation, metadata: { user_id: 1 })).to eq([{ from: "q@c.us" }])
106
109
  expect(described_class.fetch_media(message_id: "m1", provider: :web_automation, metadata: { user_id: 1 })).to include(mime: "image/jpeg")
107
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" }])
108
114
  expect(described_class.logout(provider: :web_automation, metadata: { user_id: 1 })).to eq(success: true)
109
115
  expect(fake_client).to have_received(:fetch_media).with(message_id: "m1", provider: :web_automation, metadata: { user_id: 1 })
110
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 })
111
138
  end
112
139
 
113
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.7.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kshitiz Sinha
@@ -80,6 +80,8 @@ 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
@@ -90,6 +92,8 @@ files:
90
92
  - lib/whatsapp_notifier/services/web_automation/metrics.test.ts
91
93
  - lib/whatsapp_notifier/services/web_automation/metrics.ts
92
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
93
97
  - lib/whatsapp_notifier/services/web_automation/sessions.test.ts
94
98
  - lib/whatsapp_notifier/services/web_automation/sessions.ts
95
99
  - lib/whatsapp_notifier/session/qr_service.rb