whatsapp_notifier 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +20 -3
- data/lib/generators/whatsapp_notifier/install_service_generator.rb +1 -0
- data/lib/whatsapp_notifier/client.rb +8 -0
- data/lib/whatsapp_notifier/providers/web_automation.rb +21 -0
- data/lib/whatsapp_notifier/services/web_automation/inbound.test.ts +149 -1
- data/lib/whatsapp_notifier/services/web_automation/inbound.ts +90 -2
- data/lib/whatsapp_notifier/services/web_automation/index.ts +42 -29
- data/lib/whatsapp_notifier/services/web_automation/media.test.ts +585 -0
- data/lib/whatsapp_notifier/services/web_automation/media.ts +458 -0
- data/lib/whatsapp_notifier/version.rb +1 -1
- data/lib/whatsapp_notifier/web_adapter.rb +118 -12
- data/lib/whatsapp_notifier.rb +8 -0
- data/spec/client_spec.rb +21 -0
- data/spec/generators/install_service_generator_spec.rb +12 -1
- data/spec/providers/web_automation_spec.rb +39 -0
- data/spec/web_adapter_spec.rb +176 -0
- data/spec/whatsapp_notifier_spec.rb +6 -0
- metadata +3 -1
|
@@ -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 })
|
data/spec/web_adapter_spec.rb
CHANGED
|
@@ -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.
|
|
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
|