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.
@@ -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 metrics.ts sessions.ts package.json bun.lock])
61
+ expect(sources).to match_array(%w[index.ts inbound.ts init_gate.ts media.ts metrics.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
@@ -69,6 +69,17 @@ RSpec.describe "WhatsAppNotifier::Generators::InstallServiceGenerator" do
69
69
  "SERVICE_FILES lists #{file} but it is missing from the service dir"
70
70
  end
71
71
  end
72
+
73
+ # An ejected service that is missing a module index.ts imports dies on
74
+ # boot — pin the file list to the imports so a new module (like media.ts
75
+ # in 0.7.0) can never be forgotten again.
76
+ it "ships every local module index.ts imports" do
77
+ index = File.read(File.join(generator_class.source_root, "index.ts"), encoding: "UTF-8")
78
+ local_imports = index.scan(%r{from '\./([A-Za-z0-9_]+)'}).flatten.map { |name| "#{name}.ts" }
79
+
80
+ expect(local_imports).not_to be_empty
81
+ expect(generator_class.const_get(:SERVICE_FILES)).to include(*local_imports)
82
+ end
72
83
  end
73
84
 
74
85
  describe "#add_to_gitignore" do
@@ -137,6 +137,45 @@ RSpec.describe WhatsAppNotifier::Providers::WebAutomation do
137
137
  end
138
138
  end
139
139
 
140
+ it "fetches and deletes media via the adapter when enabled" do
141
+ Dir.mktmpdir do |dir|
142
+ adapter = double(
143
+ fetch_qr_code: "qr", connection_status: {},
144
+ fetch_media: { body: "bytes", mime: "image/jpeg", filename: "a.jpg", size: 5 },
145
+ delete_media: { success: true }
146
+ )
147
+ config = build_config(path: File.join(dir, "session.json"), adapter: adapter)
148
+ provider = described_class.new(configuration: config)
149
+
150
+ expect(provider.fetch_media(message_id: "m1", metadata: { user_id: 1 })).to include(mime: "image/jpeg")
151
+ expect(provider.delete_media(message_id: "m1", metadata: { user_id: 1 })).to eq(success: true)
152
+ expect(adapter).to have_received(:fetch_media).with(message_id: "m1", metadata: { user_id: 1 })
153
+ expect(adapter).to have_received(:delete_media).with(message_id: "m1", metadata: { user_id: 1 })
154
+ end
155
+ end
156
+
157
+ it "raises on the media helpers when the provider is disabled" do
158
+ Dir.mktmpdir do |dir|
159
+ adapter = double(fetch_qr_code: "qr", connection_status: {}, fetch_media: nil, delete_media: { success: true })
160
+ config = build_config(path: File.join(dir, "session.json"), adapter: adapter, enabled: false)
161
+ provider = described_class.new(configuration: config)
162
+
163
+ expect { provider.fetch_media(message_id: "m1") }.to raise_error(WhatsAppNotifier::ConfigurationError, /disabled/)
164
+ expect { provider.delete_media(message_id: "m1") }.to raise_error(WhatsAppNotifier::ConfigurationError, /disabled/)
165
+ end
166
+ end
167
+
168
+ it "raises on the media helpers when the adapter lacks media support" do
169
+ Dir.mktmpdir do |dir|
170
+ adapter = double(fetch_qr_code: "qr", connection_status: {})
171
+ config = build_config(path: File.join(dir, "session.json"), adapter: adapter)
172
+ provider = described_class.new(configuration: config)
173
+
174
+ expect { provider.fetch_media(message_id: "m1") }.to raise_error(WhatsAppNotifier::ConfigurationError, /media fetch/)
175
+ expect { provider.delete_media(message_id: "m1") }.to raise_error(WhatsAppNotifier::ConfigurationError, /media deletion/)
176
+ end
177
+ end
178
+
140
179
  it "logs out via adapter when enabled" do
141
180
  Dir.mktmpdir do |dir|
142
181
  adapter = double(fetch_qr_code: "qr", connection_status: {}, logout: { success: true })
@@ -100,6 +100,148 @@ RSpec.describe WhatsAppNotifier::WebAdapter do
100
100
  expect { adapter.fetch_inbound(metadata: {}) }.to raise_error(/service request failed/)
101
101
  end
102
102
 
103
+ it "maps the 0.7.0 media and sender keys when the wire payload carries them" do
104
+ body = { "messages" => [{
105
+ "from" => "919@c.us", "body" => "", "messageId" => "m1", "timestamp" => 123, "type" => "image",
106
+ "hasMedia" => true, "mediaStatus" => "available", "mediaMime" => "image/jpeg",
107
+ "mediaFilename" => "beach.jpg", "mediaSize" => 1024, "senderName" => "Asha"
108
+ }] }
109
+ allow(Net::HTTP).to receive(:start).and_return(http_success(body: body))
110
+
111
+ message = adapter.fetch_inbound(metadata: { user_id: "u-1" }).first
112
+
113
+ expect(message).to include(
114
+ has_media: true, media_status: "available", media_mime: "image/jpeg",
115
+ media_filename: "beach.jpg", media_size: 1024, sender_name: "Asha"
116
+ )
117
+ end
118
+
119
+ it "accepts snake_case wire aliases and maps an unavailable verdict's error" do
120
+ body = { "messages" => [{
121
+ "from" => "919@c.us", "body" => "", "message_id" => "m1", "timestamp" => 1, "type" => "video",
122
+ "has_media" => true, "media_status" => "unavailable", "media_error" => "unsupported_type"
123
+ }] }
124
+ allow(Net::HTTP).to receive(:start).and_return(http_success(body: body))
125
+
126
+ message = adapter.fetch_inbound(metadata: {}).first
127
+
128
+ expect(message).to include(has_media: true, media_status: "unavailable", media_error: "unsupported_type")
129
+ expect(message).not_to have_key(:media_mime)
130
+ end
131
+
132
+ # A 0.6.0 service sends no media keys at all — the mapped hash must omit
133
+ # 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
135
+ body = [{ "from" => "919@c.us", "body" => "hi", "messageId" => "m1", "timestamp" => 123, "type" => "chat" }]
136
+ allow(Net::HTTP).to receive(:start).and_return(http_success(body: body))
137
+
138
+ message = adapter.fetch_inbound(metadata: {}).first
139
+
140
+ expect(message.keys).to match_array(%i[from body message_id timestamp type])
141
+ end
142
+
143
+ def binary_response(code:, body: "", headers: {})
144
+ response = double("binary response", code: code, body: body)
145
+ allow(response).to receive(:is_a?) { |klass| code == "200" && klass == Net::HTTPSuccess }
146
+ allow(response).to receive(:[]) { |name| headers[name] }
147
+ response
148
+ end
149
+
150
+ def run_binary_request(response)
151
+ captured = nil
152
+ http = double("http")
153
+ allow(http).to receive(:request) { |req| captured = req; response }
154
+ allow(Net::HTTP).to receive(:start) { |*_args, **_kwargs, &blk| blk.call(http) }
155
+ -> { captured }
156
+ end
157
+
158
+ it "fetches media bytes with mime, filename and size on a dedicated binary path" do
159
+ allow(ENV).to receive(:[]).and_call_original
160
+ allow(ENV).to receive(:[]).with("WHATSAPP_WEBHOOK_TOKEN").and_return(nil)
161
+ response = binary_response(
162
+ code: "200", body: "\xFF\xD8raw-jpeg".b,
163
+ headers: { "Content-Type" => "image/jpeg", "Content-Disposition" => 'attachment; filename="beach.jpg"' }
164
+ )
165
+ captured = run_binary_request(response)
166
+
167
+ media = adapter.fetch_media(message_id: "true_919@c.us_ABC", metadata: { user_id: "u-1" })
168
+
169
+ expect(media).to eq(body: "\xFF\xD8raw-jpeg".b, mime: "image/jpeg", filename: "beach.jpg", size: 10)
170
+ expect(captured.call.path).to eq("/media/u-1/true_919@c.us_ABC")
171
+ expect(captured.call["X-WA-Token"]).to be_nil # env unset -> no token header
172
+ end
173
+
174
+ it "returns nil when the service has no copy of the media (404)" do
175
+ allow(Net::HTTP).to receive(:start).and_return(binary_response(code: "404", body: '{"error":"not_found"}'))
176
+
177
+ expect(adapter.fetch_media(message_id: "m1", metadata: {})).to be_nil
178
+ end
179
+
180
+ it "raises on non-404 media fetch failures" do
181
+ allow(Net::HTTP).to receive(:start).and_return(binary_response(code: "500", body: "boom"))
182
+
183
+ expect { adapter.fetch_media(message_id: "m1", metadata: {}) }.to raise_error(/service request failed \(500\)/)
184
+ end
185
+
186
+ it "sends X-WA-Token on the media fetch when WHATSAPP_WEBHOOK_TOKEN is set" do
187
+ allow(ENV).to receive(:[]).and_call_original
188
+ allow(ENV).to receive(:[]).with("WHATSAPP_WEBHOOK_TOKEN").and_return("sekrit")
189
+ response = binary_response(code: "200", body: "x", headers: { "Content-Type" => "audio/ogg" })
190
+ captured = run_binary_request(response)
191
+
192
+ media = adapter.fetch_media(message_id: "m1", metadata: { user_id: "u-1" })
193
+
194
+ expect(captured.call["X-WA-Token"]).to eq("sekrit")
195
+ expect(media).to include(mime: "audio/ogg", filename: nil, size: 1)
196
+ end
197
+
198
+ it "strips path and query characters from the message id before building the URL" do
199
+ response = binary_response(code: "404")
200
+ captured = run_binary_request(response)
201
+
202
+ adapter.fetch_media(message_id: "../m1?x=1#f", metadata: { user_id: "u-1" })
203
+
204
+ expect(captured.call.path).to eq("/media/u-1/..m1x1f")
205
+ end
206
+
207
+ it "deletes media via the JSON control plane with the token attached" do
208
+ allow(ENV).to receive(:[]).and_call_original
209
+ allow(ENV).to receive(:[]).with("WHATSAPP_WEBHOOK_TOKEN").and_return("sekrit")
210
+ response = http_success(body: { "success" => true })
211
+ captured = nil
212
+ http = double("http")
213
+ allow(http).to receive(:request) { |req| captured = req; response }
214
+ allow(Net::HTTP).to receive(:start) { |*_args, **_kwargs, &blk| blk.call(http) }
215
+
216
+ expect(adapter.delete_media(message_id: "m/1", metadata: { user_id: "u-1" })).to eq(success: true)
217
+ expect(captured).to be_a(Net::HTTP::Delete)
218
+ expect(captured.path).to eq("/media/u-1/m1")
219
+ expect(captured["X-WA-Token"]).to eq("sekrit")
220
+ end
221
+
222
+ it "defaults delete_media success to false when the service omits it" do
223
+ allow(Net::HTTP).to receive(:start).and_return(http_success(body: {}))
224
+
225
+ expect(adapter.delete_media(message_id: "m1", metadata: {})).to eq(success: false)
226
+ end
227
+
228
+ # A 0.6.0 service mid-rollout has no /media routes — delete must degrade
229
+ # like fetch_media's nil-on-404, not raise.
230
+ it "returns success false when delete_media hits a 404" do
231
+ allow(Net::HTTP).to receive(:start)
232
+ .and_return(http_failure(code: "404", body: JSON.generate({ error: "not_found" })))
233
+
234
+ expect(adapter.delete_media(message_id: "m1", metadata: {})).to eq(success: false)
235
+ end
236
+
237
+ it "still raises when delete_media fails with a non-404 code" do
238
+ allow(Net::HTTP).to receive(:start)
239
+ .and_return(http_failure(code: "500", body: JSON.generate({ error: "boom" })))
240
+
241
+ expect { adapter.delete_media(message_id: "m1", metadata: {}) }
242
+ .to raise_error(/service request failed \(500\)/)
243
+ end
244
+
103
245
  it "logs out via the service" do
104
246
  allow(Net::HTTP).to receive(:start).and_return(http_success(body: { "success" => true }))
105
247
 
@@ -123,6 +265,40 @@ RSpec.describe WhatsAppNotifier::WebAdapter do
123
265
  expect(Net::HTTP::Get).to have_received(:new).with("/qr/default")
124
266
  end
125
267
 
268
+ # Net::HTTP does not infer TLS from the URL scheme — an https service URL
269
+ # without use_ssl would send the token and payloads in plaintext.
270
+ it "enables TLS for https service URLs on the JSON request path" do
271
+ secure = described_class.new(base_url: "https://wa.example.com")
272
+ allow(Net::HTTP).to receive(:start).and_return(http_success(body: { "success" => true }))
273
+
274
+ secure.logout(metadata: { user_id: "u-1" })
275
+
276
+ expect(Net::HTTP).to have_received(:start)
277
+ .with("wa.example.com", 443, hash_including(use_ssl: true))
278
+ end
279
+
280
+ it "enables TLS for https service URLs on the binary media path" do
281
+ secure = described_class.new(base_url: "https://wa.example.com")
282
+ allow(Net::HTTP).to receive(:start).and_return(binary_response(code: "404"))
283
+
284
+ secure.fetch_media(message_id: "m1", metadata: { user_id: "u-1" })
285
+
286
+ expect(Net::HTTP).to have_received(:start)
287
+ .with("wa.example.com", 443, hash_including(use_ssl: true))
288
+ end
289
+
290
+ it "keeps TLS off for plain http service URLs on both paths" do
291
+ allow(Net::HTTP).to receive(:start).and_return(
292
+ http_success(body: { "success" => true }), binary_response(code: "404")
293
+ )
294
+
295
+ adapter.logout(metadata: {})
296
+ adapter.fetch_media(message_id: "m1", metadata: {})
297
+
298
+ expect(Net::HTTP).to have_received(:start)
299
+ .with("127.0.0.1", 3001, hash_including(use_ssl: false)).twice
300
+ end
301
+
126
302
  it "executes the request inside the Net::HTTP block" do
127
303
  fake_http = instance_double(Net::HTTP)
128
304
  allow(fake_http).to receive(:request).and_return(http_success(body: { "qr" => "data:image/png;base64,x" }))
@@ -94,6 +94,8 @@ 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)
97
99
  allow(fake_client).to receive(:logout).and_return(success: true)
98
100
  described_class.instance_variable_set(:@client, fake_client)
99
101
 
@@ -101,7 +103,11 @@ RSpec.describe WhatsAppNotifier do
101
103
  expect(described_class.scan_qr(provider: :web_automation, metadata: { user_id: 1 })).to eq("qr-code")
102
104
  expect(described_class.connection_status(provider: :web_automation, metadata: { user_id: 1 })).to include(state: "QR_REQUIRED")
103
105
  expect(described_class.fetch_inbound(provider: :web_automation, metadata: { user_id: 1 })).to eq([{ from: "q@c.us" }])
106
+ expect(described_class.fetch_media(message_id: "m1", provider: :web_automation, metadata: { user_id: 1 })).to include(mime: "image/jpeg")
107
+ expect(described_class.delete_media(message_id: "m1", provider: :web_automation, metadata: { user_id: 1 })).to eq(success: true)
104
108
  expect(described_class.logout(provider: :web_automation, metadata: { user_id: 1 })).to eq(success: true)
109
+ expect(fake_client).to have_received(:fetch_media).with(message_id: "m1", provider: :web_automation, metadata: { user_id: 1 })
110
+ expect(fake_client).to have_received(:delete_media).with(message_id: "m1", provider: :web_automation, metadata: { user_id: 1 })
105
111
  end
106
112
 
107
113
  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.6.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kshitiz Sinha
@@ -85,6 +85,8 @@ files:
85
85
  - lib/whatsapp_notifier/services/web_automation/index.ts
86
86
  - lib/whatsapp_notifier/services/web_automation/init_gate.test.ts
87
87
  - lib/whatsapp_notifier/services/web_automation/init_gate.ts
88
+ - lib/whatsapp_notifier/services/web_automation/media.test.ts
89
+ - lib/whatsapp_notifier/services/web_automation/media.ts
88
90
  - lib/whatsapp_notifier/services/web_automation/metrics.test.ts
89
91
  - lib/whatsapp_notifier/services/web_automation/metrics.ts
90
92
  - lib/whatsapp_notifier/services/web_automation/package.json