whatsapp_notifier 0.8.2 → 0.9.2
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 +391 -102
- data/docs/CONFIGURATION.md +89 -0
- data/lib/whatsapp_notifier/client.rb +4 -0
- data/lib/whatsapp_notifier/error_code.rb +137 -0
- data/lib/whatsapp_notifier/errors.rb +21 -0
- data/lib/whatsapp_notifier/providers/base.rb +18 -0
- data/lib/whatsapp_notifier/providers/web_automation.rb +14 -3
- data/lib/whatsapp_notifier/services/web_automation/bun.lock +77 -71
- data/lib/whatsapp_notifier/services/web_automation/index.ts +64 -0
- data/lib/whatsapp_notifier/services/web_automation/metrics.test.ts +3 -0
- data/lib/whatsapp_notifier/services/web_automation/metrics.ts +3 -0
- data/lib/whatsapp_notifier/services/web_automation/package.json +1 -1
- data/lib/whatsapp_notifier/services/web_automation/sessions.test.ts +22 -0
- data/lib/whatsapp_notifier/services/web_automation/sessions.ts +19 -0
- data/lib/whatsapp_notifier/version.rb +1 -1
- data/lib/whatsapp_notifier/web_adapter.rb +6 -2
- data/lib/whatsapp_notifier.rb +11 -0
- data/spec/error_code_spec.rb +107 -0
- data/spec/providers/web_automation_spec.rb +62 -0
- data/spec/session_ready_spec.rb +114 -0
- data/spec/web_adapter_spec.rb +35 -0
- metadata +5 -1
|
@@ -19,6 +19,7 @@ export interface Counters {
|
|
|
19
19
|
init_failures_total: number; // client.initialize() rejected
|
|
20
20
|
ws_endpoint_timeouts_total: number; // Chromium never produced a WS endpoint (crash-loop signature)
|
|
21
21
|
init_timeouts_total: number; // watchdog recycled a stuck-INITIALIZING client
|
|
22
|
+
ready_timeouts_total: number; // watchdog recycled an AUTHENTICATED-but-never-ready wedge
|
|
22
23
|
auth_failures_total: number; // auth_failure event
|
|
23
24
|
disconnects_total: number; // disconnected event
|
|
24
25
|
}
|
|
@@ -28,6 +29,7 @@ export function newCounters(): Counters {
|
|
|
28
29
|
init_failures_total: 0,
|
|
29
30
|
ws_endpoint_timeouts_total: 0,
|
|
30
31
|
init_timeouts_total: 0,
|
|
32
|
+
ready_timeouts_total: 0,
|
|
31
33
|
auth_failures_total: 0,
|
|
32
34
|
disconnects_total: 0,
|
|
33
35
|
};
|
|
@@ -103,6 +105,7 @@ export function renderMetrics(
|
|
|
103
105
|
...counter('whatsapp_init_failures_total', 'client.initialize() rejections.', counters.init_failures_total),
|
|
104
106
|
...counter('whatsapp_ws_endpoint_timeouts_total', 'Chromium launches that never produced a WS endpoint (crash-loop signature).', counters.ws_endpoint_timeouts_total),
|
|
105
107
|
...counter('whatsapp_init_timeouts_total', 'Clients recycled by the INITIALIZING watchdog.', counters.init_timeouts_total),
|
|
108
|
+
...counter('whatsapp_ready_timeouts_total', 'Clients recycled by the ready watchdog (AUTHENTICATED but never ready).', counters.ready_timeouts_total),
|
|
106
109
|
...counter('whatsapp_auth_failures_total', 'auth_failure events.', counters.auth_failures_total),
|
|
107
110
|
...counter('whatsapp_disconnects_total', 'disconnected events.', counters.disconnects_total),
|
|
108
111
|
...gauge('whatsapp_service_uptime_seconds', 'Seconds since the service process started.', uptimeSeconds),
|
|
@@ -5,6 +5,7 @@ import { join } from 'path';
|
|
|
5
5
|
import {
|
|
6
6
|
hasPairedSession,
|
|
7
7
|
InitRetryLimiter,
|
|
8
|
+
isReadyWedged,
|
|
8
9
|
reapLimitMs,
|
|
9
10
|
touchClient,
|
|
10
11
|
shouldWipeSessionOnReap
|
|
@@ -127,3 +128,24 @@ describe('InitRetryLimiter', () => {
|
|
|
127
128
|
expect(limiter.shouldRetry('b')).toBe(false);
|
|
128
129
|
});
|
|
129
130
|
});
|
|
131
|
+
|
|
132
|
+
describe('isReadyWedged', () => {
|
|
133
|
+
test('AUTHENTICATED but never ready is the wedge', () => {
|
|
134
|
+
expect(isReadyWedged({ state: 'AUTHENTICATED', ready: false })).toBe(true);
|
|
135
|
+
expect(isReadyWedged({ state: 'AUTHENTICATED' })).toBe(true); // ready never set
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('a ready client is the normal path, not a wedge', () => {
|
|
139
|
+
expect(isReadyWedged({ state: 'AUTHENTICATED', ready: true })).toBe(false);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('a client already being destroyed must not be recycled again', () => {
|
|
143
|
+
expect(isReadyWedged({ state: 'AUTHENTICATED', ready: false, isDestroying: true })).toBe(false);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('other states are owned by their own paths', () => {
|
|
147
|
+
expect(isReadyWedged({ state: 'QR_REQUIRED', ready: false })).toBe(false); // QR is showing
|
|
148
|
+
expect(isReadyWedged({ state: 'DISCONNECTED', ready: false })).toBe(false); // disconnect path
|
|
149
|
+
expect(isReadyWedged({ state: 'INITIALIZING', ready: false })).toBe(false); // init watchdog owns it
|
|
150
|
+
});
|
|
151
|
+
});
|
|
@@ -89,3 +89,22 @@ export class InitRetryLimiter {
|
|
|
89
89
|
this.counts.delete(userId);
|
|
90
90
|
}
|
|
91
91
|
}
|
|
92
|
+
|
|
93
|
+
// The AUTHENTICATED-but-never-ready wedge (incident 2026-06-05, again
|
|
94
|
+
// 2026-07-31): after a service restart a session re-authenticates from disk,
|
|
95
|
+
// but Chromium never finishes loading the WhatsApp Web store, so `ready`
|
|
96
|
+
// never fires. The client then serves qr=null + authenticated=false forever —
|
|
97
|
+
// the broadcast modal shows "No QR available" and sends 503. The INITIALIZING
|
|
98
|
+
// watchdog can't see it (state already moved on), and the idle reaper takes
|
|
99
|
+
// 30+ minutes. This predicate names the wedge for the ready watchdog that
|
|
100
|
+
// index.ts arms when 'authenticated' fires.
|
|
101
|
+
//
|
|
102
|
+
// NOT wedged: already ready (the normal path), already being destroyed
|
|
103
|
+
// (recycling twice double-frees nothing but spams logs), or moved to another
|
|
104
|
+
// state — QR_REQUIRED means WhatsApp wants a re-scan and IS showing a QR, and
|
|
105
|
+
// DISCONNECTED clients are on the disconnect path already.
|
|
106
|
+
export function isReadyWedged(
|
|
107
|
+
client: { state: string; ready?: boolean; isDestroying?: boolean }
|
|
108
|
+
): boolean {
|
|
109
|
+
return client.state === 'AUTHENTICATED' && !client.ready && !client.isDestroying;
|
|
110
|
+
}
|
|
@@ -67,6 +67,10 @@ module WhatsAppNotifier
|
|
|
67
67
|
message_id: response["messageId"] || response["message_id"] ||
|
|
68
68
|
payload[:idempotency_key] || "local-#{Time.now.to_i}",
|
|
69
69
|
session: session,
|
|
70
|
+
# A code the service supplied wins over anything the provider could
|
|
71
|
+
# infer from the message text. Absent on every service ≤ 0.8.x, which
|
|
72
|
+
# is why the provider still classifies as a fallback.
|
|
73
|
+
error_code: ErrorCode.normalize(response["errorCode"] || response["error_code"]),
|
|
70
74
|
error_message: response["error"]
|
|
71
75
|
}
|
|
72
76
|
end
|
|
@@ -109,7 +113,7 @@ module WhatsAppNotifier
|
|
|
109
113
|
user_id = user_id_from(metadata)
|
|
110
114
|
res = binary_get("/media/#{user_id}/#{path_id(message_id)}")
|
|
111
115
|
return nil if res.code.to_s == "404"
|
|
112
|
-
raise "service request failed (#{res.code}): #{res.body}" unless res.is_a?(Net::HTTPSuccess)
|
|
116
|
+
raise ServiceError.new("service request failed (#{res.code}): #{res.body}", status: res.code) unless res.is_a?(Net::HTTPSuccess)
|
|
113
117
|
|
|
114
118
|
body = res.body.to_s
|
|
115
119
|
{
|
|
@@ -283,7 +287,7 @@ module WhatsAppNotifier
|
|
|
283
287
|
# carries no "success" key, so they degrade rather than raise.
|
|
284
288
|
return parsed if allow_404 && res.code.to_s == "404"
|
|
285
289
|
|
|
286
|
-
raise "service request failed (#{res.code}): #{parsed["error"] || res.body}"
|
|
290
|
+
raise ServiceError.new("service request failed (#{res.code}): #{parsed["error"] || res.body}", status: res.code)
|
|
287
291
|
end
|
|
288
292
|
|
|
289
293
|
def parse_body(raw)
|
data/lib/whatsapp_notifier.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require_relative "whatsapp_notifier/version"
|
|
2
2
|
require_relative "whatsapp_notifier/errors"
|
|
3
|
+
require_relative "whatsapp_notifier/error_code"
|
|
3
4
|
require_relative "whatsapp_notifier/result"
|
|
4
5
|
require_relative "whatsapp_notifier/configuration"
|
|
5
6
|
require_relative "whatsapp_notifier/web_adapter"
|
|
@@ -62,6 +63,16 @@ module WhatsAppNotifier
|
|
|
62
63
|
client.connection_status(provider: provider, metadata: metadata)
|
|
63
64
|
end
|
|
64
65
|
|
|
66
|
+
# One call for "can this operator send right now?" — an authenticated
|
|
67
|
+
# session answers true, everything else (including an unreachable status
|
|
68
|
+
# endpoint) answers false. `user_id:` is sugar for the metadata key every
|
|
69
|
+
# multi-user host passes; both forms work.
|
|
70
|
+
def session_ready?(user_id: nil, provider: nil, metadata: {})
|
|
71
|
+
meta = metadata.to_h
|
|
72
|
+
meta = meta.merge(user_id: user_id) unless user_id.nil?
|
|
73
|
+
client.session_ready?(provider: provider, metadata: meta)
|
|
74
|
+
end
|
|
75
|
+
|
|
65
76
|
def fetch_inbound(provider: nil, metadata: {})
|
|
66
77
|
client.fetch_inbound(provider: provider, metadata: metadata)
|
|
67
78
|
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
require "spec_helper"
|
|
2
|
+
require "net/http"
|
|
3
|
+
|
|
4
|
+
RSpec.describe WhatsAppNotifier::ErrorCode do
|
|
5
|
+
describe ".from_exception" do
|
|
6
|
+
it "classifies a service error by its HTTP status, not its body text" do
|
|
7
|
+
# A 401 body that happens to mention a number must still read as auth.
|
|
8
|
+
error = WhatsAppNotifier::ServiceError.new(
|
|
9
|
+
"service request failed (401): No saved WhatsApp session for this user — pair via QR first",
|
|
10
|
+
status: "401"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
expect(described_class.from_exception(error)).to eq(:auth_required)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
it "classifies a 403 as auth and a 429 as rate limiting" do
|
|
17
|
+
forbidden = WhatsAppNotifier::ServiceError.new("nope", status: 403)
|
|
18
|
+
throttled = WhatsAppNotifier::ServiceError.new("slow down", status: "429")
|
|
19
|
+
|
|
20
|
+
expect(described_class.from_exception(forbidden)).to eq(:auth_required)
|
|
21
|
+
expect(described_class.from_exception(throttled)).to eq(:rate_limited)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# The service 422s a malformed REQUEST (a host bug), never a bad number,
|
|
25
|
+
# so it must NOT be classified as :invalid_phone.
|
|
26
|
+
it "leaves a 422 unclassified" do
|
|
27
|
+
error = WhatsAppNotifier::ServiceError.new("service request failed (422): `to` is required", status: 422)
|
|
28
|
+
|
|
29
|
+
expect(described_class.from_exception(error)).to eq(:delivery_exception)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
it "classifies socket failures as service_unreachable" do
|
|
33
|
+
expect(described_class.from_exception(Errno::ECONNREFUSED.new)).to eq(:service_unreachable)
|
|
34
|
+
expect(described_class.from_exception(SocketError.new("getaddrinfo"))).to eq(:service_unreachable)
|
|
35
|
+
expect(described_class.from_exception(Net::OpenTimeout.new)).to eq(:service_unreachable)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# A read timeout is NOT the same as a connect timeout: the request went
|
|
39
|
+
# out, so the send may well have landed.
|
|
40
|
+
it "classifies a read timeout as :timeout, separate from :service_unreachable" do
|
|
41
|
+
expect(described_class.from_exception(Net::ReadTimeout.new)).to eq(:timeout)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
it "falls back to the message when the class and status say nothing" do
|
|
45
|
+
expect(described_class.from_exception(RuntimeError.new("No LID for 919999000001"))).to eq(:recipient_unresolved)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it "falls back to the message for a 500 carrying a library error" do
|
|
49
|
+
error = WhatsAppNotifier::ServiceError.new("service request failed (500): No LID for 919999000001", status: 500)
|
|
50
|
+
|
|
51
|
+
expect(described_class.from_exception(error)).to eq(:recipient_unresolved)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
it "leaves an opaque exception unclassified" do
|
|
55
|
+
expect(described_class.from_exception(RuntimeError.new("Evaluation failed: e"))).to eq(:delivery_exception)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
describe ".from_message" do
|
|
60
|
+
{
|
|
61
|
+
"No LID for 919999000001" => :recipient_unresolved,
|
|
62
|
+
"Phone number is not registered" => :not_on_whatsapp,
|
|
63
|
+
"wid error: invalid wid" => :invalid_phone,
|
|
64
|
+
"User not authenticated" => :auth_required,
|
|
65
|
+
"Connection refused - connect(2) for 127.0.0.1:3001" => :service_unreachable,
|
|
66
|
+
"execution expired" => :timeout,
|
|
67
|
+
"Too Many Requests" => :rate_limited
|
|
68
|
+
}.each do |text, code|
|
|
69
|
+
it "reads #{text.inspect} as #{code}" do
|
|
70
|
+
expect(described_class.from_message(text)).to eq(code)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
it "returns the catch-all for text it cannot place" do
|
|
75
|
+
expect(described_class.from_message("something new")).to eq(:delivery_exception)
|
|
76
|
+
expect(described_class.from_message(nil)).to eq(:delivery_exception)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
describe ".normalize" do
|
|
81
|
+
it "folds the service's synonyms into one code" do
|
|
82
|
+
expect(described_class.normalize("unauthenticated")).to eq(:auth_required)
|
|
83
|
+
expect(described_class.normalize("NUMBER_NOT_REGISTERED")).to eq(:not_on_whatsapp)
|
|
84
|
+
expect(described_class.normalize(:throttled)).to eq(:rate_limited)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
it "returns nil for a blank code so callers fall back to their own guess" do
|
|
88
|
+
expect(described_class.normalize(nil)).to be_nil
|
|
89
|
+
expect(described_class.normalize(" ")).to be_nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# A newer service may introduce a code before the gem knows it — pass it
|
|
93
|
+
# through rather than flattening it, but only if it looks like an
|
|
94
|
+
# identifier.
|
|
95
|
+
it "passes an unknown identifier-shaped code through" do
|
|
96
|
+
expect(described_class.normalize("queue_full")).to eq(:queue_full)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
it "flattens code-shaped garbage to the catch-all" do
|
|
100
|
+
expect(described_class.normalize("<html>502 Bad Gateway</html>")).to eq(:delivery_exception)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
it "lists every code it can return" do
|
|
105
|
+
expect(described_class::ALL).to include(described_class::UNCLASSIFIED)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -69,6 +69,68 @@ RSpec.describe WhatsAppNotifier::Providers::WebAutomation do
|
|
|
69
69
|
end
|
|
70
70
|
end
|
|
71
71
|
|
|
72
|
+
# Error codes are what hosts branch on to decide whether a retry could
|
|
73
|
+
# double-message someone, so a raised exception must survive as a code and
|
|
74
|
+
# not only as text.
|
|
75
|
+
it "classifies the raised exception into an error code" do
|
|
76
|
+
Dir.mktmpdir do |dir|
|
|
77
|
+
adapter = double
|
|
78
|
+
allow(adapter).to receive(:send_message)
|
|
79
|
+
.and_raise(WhatsAppNotifier::ServiceError.new("service request failed (401): User not authenticated", status: 401))
|
|
80
|
+
allow(adapter).to receive(:fetch_qr_code).and_return("qr")
|
|
81
|
+
allow(adapter).to receive(:connection_status).and_return({})
|
|
82
|
+
config = build_config(path: File.join(dir, "session.json"), adapter: adapter)
|
|
83
|
+
provider = described_class.new(configuration: config)
|
|
84
|
+
|
|
85
|
+
result = provider.deliver(to: "+1", body: "h")
|
|
86
|
+
|
|
87
|
+
expect(result.error_code).to eq(:auth_required)
|
|
88
|
+
# The message field is untouched — hosts still fingerprinting it work.
|
|
89
|
+
expect(result.error_message).to eq("service request failed (401): User not authenticated")
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
it "classifies a failure the adapter reported without raising" do
|
|
94
|
+
Dir.mktmpdir do |dir|
|
|
95
|
+
adapter = double
|
|
96
|
+
allow(adapter).to receive(:send_message)
|
|
97
|
+
.and_return(success: false, session: {}, error_message: "No LID for 919999000001")
|
|
98
|
+
allow(adapter).to receive(:fetch_qr_code).and_return("qr")
|
|
99
|
+
allow(adapter).to receive(:connection_status).and_return({})
|
|
100
|
+
config = build_config(path: File.join(dir, "session.json"), adapter: adapter)
|
|
101
|
+
provider = described_class.new(configuration: config)
|
|
102
|
+
|
|
103
|
+
expect(provider.deliver(to: "+1", body: "h").error_code).to eq(:recipient_unresolved)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
it "keeps a code the adapter supplied instead of re-guessing from the text" do
|
|
108
|
+
Dir.mktmpdir do |dir|
|
|
109
|
+
adapter = double
|
|
110
|
+
allow(adapter).to receive(:send_message)
|
|
111
|
+
.and_return(success: false, session: {}, error_code: :not_on_whatsapp, error_message: "No LID for 919999000001")
|
|
112
|
+
allow(adapter).to receive(:fetch_qr_code).and_return("qr")
|
|
113
|
+
allow(adapter).to receive(:connection_status).and_return({})
|
|
114
|
+
config = build_config(path: File.join(dir, "session.json"), adapter: adapter)
|
|
115
|
+
provider = described_class.new(configuration: config)
|
|
116
|
+
|
|
117
|
+
expect(provider.deliver(to: "+1", body: "h").error_code).to eq(:not_on_whatsapp)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
it "leaves a successful send without an error code" do
|
|
122
|
+
Dir.mktmpdir do |dir|
|
|
123
|
+
adapter = double
|
|
124
|
+
allow(adapter).to receive(:send_message).and_return(success: true, message_id: "w1", session: {})
|
|
125
|
+
allow(adapter).to receive(:fetch_qr_code).and_return("qr")
|
|
126
|
+
allow(adapter).to receive(:connection_status).and_return({})
|
|
127
|
+
config = build_config(path: File.join(dir, "session.json"), adapter: adapter)
|
|
128
|
+
provider = described_class.new(configuration: config)
|
|
129
|
+
|
|
130
|
+
expect(provider.deliver(to: "+1", body: "h").error_code).to be_nil
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
72
134
|
it "raises for missing adapter methods in scan" do
|
|
73
135
|
Dir.mktmpdir do |dir|
|
|
74
136
|
adapter = Object.new
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
require "spec_helper"
|
|
2
|
+
|
|
3
|
+
# WhatsAppNotifier.session_ready? — the one call hosts make before sending, so
|
|
4
|
+
# they stop each writing their own "authenticated == true, rescue to false".
|
|
5
|
+
RSpec.describe "session readiness" do
|
|
6
|
+
def configure(adapter)
|
|
7
|
+
WhatsAppNotifier.configure do |config|
|
|
8
|
+
config.provider = :web_automation
|
|
9
|
+
config.web_automation_enabled = true
|
|
10
|
+
config.web_adapter = adapter
|
|
11
|
+
config.logger = Logger.new(nil)
|
|
12
|
+
config.warn_on_risky_provider = false
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def adapter_returning(status)
|
|
17
|
+
adapter = double(send_message: { success: true, session: {} }, fetch_qr_code: "qr")
|
|
18
|
+
allow(adapter).to receive(:connection_status).and_return(status)
|
|
19
|
+
adapter
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def adapter_raising(error)
|
|
23
|
+
adapter = double(send_message: { success: true, session: {} }, fetch_qr_code: "qr")
|
|
24
|
+
allow(adapter).to receive(:connection_status).and_raise(error)
|
|
25
|
+
adapter
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it "is true only when the service reports an authenticated session" do
|
|
29
|
+
configure(adapter_returning(state: "AUTHENTICATED", authenticated: true))
|
|
30
|
+
|
|
31
|
+
expect(WhatsAppNotifier.session_ready?(user_id: 7)).to be(true)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
it "is false when the session is not authenticated" do
|
|
35
|
+
configure(adapter_returning(state: "QR_REQUIRED", authenticated: false))
|
|
36
|
+
|
|
37
|
+
expect(WhatsAppNotifier.session_ready?(user_id: 7)).to be(false)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Truthy-but-not-true (a "yes" string from a sloppy adapter) must not read
|
|
41
|
+
# as ready — the check is deliberately exact.
|
|
42
|
+
it "is false for a truthy non-true authenticated value" do
|
|
43
|
+
configure(adapter_returning(authenticated: "yes"))
|
|
44
|
+
|
|
45
|
+
expect(WhatsAppNotifier.session_ready?(user_id: 7)).to be(false)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it "is false when the adapter answers with something that is not a hash" do
|
|
49
|
+
configure(adapter_returning(nil))
|
|
50
|
+
|
|
51
|
+
expect(WhatsAppNotifier.session_ready?(user_id: 7)).to be(false)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# An unreachable status endpoint means the service is down, so sends can't
|
|
55
|
+
# succeed either — not ready, and logged rather than raised.
|
|
56
|
+
it "is false and logs when the status endpoint is unreachable" do
|
|
57
|
+
logger = double(warn: true)
|
|
58
|
+
configure(adapter_raising(Errno::ECONNREFUSED))
|
|
59
|
+
WhatsAppNotifier.configuration.logger = logger
|
|
60
|
+
|
|
61
|
+
expect(WhatsAppNotifier.session_ready?(user_id: 7)).to be(false)
|
|
62
|
+
expect(logger).to have_received(:warn).with(/session_ready\? failed/)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
it "survives a nil logger" do
|
|
66
|
+
configure(adapter_raising(Errno::ECONNREFUSED))
|
|
67
|
+
WhatsAppNotifier.configuration.logger = nil
|
|
68
|
+
|
|
69
|
+
expect(WhatsAppNotifier.session_ready?(user_id: 7)).to be(false)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# A ConfigurationError is a boot-time mistake in the HOST. Swallowing it
|
|
73
|
+
# would quietly park every campaign behind a "session down" backoff.
|
|
74
|
+
it "does not swallow a configuration error" do
|
|
75
|
+
configure(adapter_returning(authenticated: true))
|
|
76
|
+
WhatsAppNotifier.configuration.web_automation_enabled = false
|
|
77
|
+
|
|
78
|
+
expect { WhatsAppNotifier.session_ready?(user_id: 7) }
|
|
79
|
+
.to raise_error(WhatsAppNotifier::ConfigurationError, /disabled/)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
it "passes the user id through as status metadata" do
|
|
83
|
+
adapter = adapter_returning(authenticated: true)
|
|
84
|
+
configure(adapter)
|
|
85
|
+
|
|
86
|
+
WhatsAppNotifier.session_ready?(user_id: 42)
|
|
87
|
+
|
|
88
|
+
expect(adapter).to have_received(:connection_status).with(metadata: { user_id: 42 })
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
it "accepts a metadata hash instead of user_id" do
|
|
92
|
+
adapter = adapter_returning(authenticated: true)
|
|
93
|
+
configure(adapter)
|
|
94
|
+
|
|
95
|
+
WhatsAppNotifier.session_ready?(metadata: { user_id: 9, tenant: "x" })
|
|
96
|
+
|
|
97
|
+
expect(adapter).to have_received(:connection_status).with(metadata: { user_id: 9, tenant: "x" })
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
it "asks for the default session when neither is given" do
|
|
101
|
+
adapter = adapter_returning(authenticated: true)
|
|
102
|
+
configure(adapter)
|
|
103
|
+
|
|
104
|
+
WhatsAppNotifier.session_ready?
|
|
105
|
+
|
|
106
|
+
expect(adapter).to have_received(:connection_status).with(metadata: {})
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
it "is not implemented by a bare provider" do
|
|
110
|
+
provider = WhatsAppNotifier::Providers::Base.new(configuration: WhatsAppNotifier.configuration)
|
|
111
|
+
|
|
112
|
+
expect { provider.session_ready? }.to raise_error(NotImplementedError)
|
|
113
|
+
end
|
|
114
|
+
end
|
data/spec/web_adapter_spec.rb
CHANGED
|
@@ -104,6 +104,41 @@ RSpec.describe WhatsAppNotifier::WebAdapter do
|
|
|
104
104
|
end.to raise_error(/service request failed/)
|
|
105
105
|
end
|
|
106
106
|
|
|
107
|
+
# The status is what ErrorCode classifies on — re-deriving it from the
|
|
108
|
+
# message text would be a regex round-trip through our own string.
|
|
109
|
+
it "carries the HTTP status on the raised service error" do
|
|
110
|
+
allow(Net::HTTP).to receive(:start)
|
|
111
|
+
.and_return(http_failure(code: "401", body: JSON.generate({ error: "User not authenticated" })))
|
|
112
|
+
|
|
113
|
+
expect { adapter.send_message(payload: { to: "+1", body: "hi" }, session: {}) }
|
|
114
|
+
.to raise_error(WhatsAppNotifier::ServiceError) { |error|
|
|
115
|
+
expect(error.status.to_i).to eq(401)
|
|
116
|
+
# Pre-0.9.0 this was a bare RuntimeError; hosts rescue that class by
|
|
117
|
+
# name around media fetches and status polls.
|
|
118
|
+
expect(error).to be_a(RuntimeError)
|
|
119
|
+
# Wording unchanged from 0.8.x — hosts that fingerprinted it still match.
|
|
120
|
+
expect(error.message).to eq("service request failed (401): User not authenticated")
|
|
121
|
+
}
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Forward compatibility: a future service can name the failure itself and
|
|
125
|
+
# the gem stops guessing from text.
|
|
126
|
+
it "normalizes an error code the service supplied" do
|
|
127
|
+
response = http_success(body: { "success" => false, "errorCode" => "NUMBER_NOT_REGISTERED", "error" => "boom" })
|
|
128
|
+
allow(Net::HTTP).to receive(:start).and_return(response)
|
|
129
|
+
|
|
130
|
+
result = adapter.send_message(payload: { to: "+1", body: "hi" }, session: {})
|
|
131
|
+
|
|
132
|
+
expect(result).to include(success: false, error_code: :not_on_whatsapp, error_message: "boom")
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
it "leaves error_code nil when the service does not send one" do
|
|
136
|
+
response = http_success(body: { "success" => false, "error" => "boom" })
|
|
137
|
+
allow(Net::HTTP).to receive(:start).and_return(response)
|
|
138
|
+
|
|
139
|
+
expect(adapter.send_message(payload: { to: "+1", body: "hi" }, session: {})[:error_code]).to be_nil
|
|
140
|
+
end
|
|
141
|
+
|
|
107
142
|
it "handles empty and invalid response bodies gracefully" do
|
|
108
143
|
empty_body = instance_double(Net::HTTPOK, body: "", code: "200", is_a?: true)
|
|
109
144
|
invalid_body = instance_double(Net::HTTPInternalServerError, body: "raw-error", code: "500", is_a?: false)
|
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.9.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kshitiz Sinha
|
|
@@ -56,6 +56,7 @@ files:
|
|
|
56
56
|
- app/controllers/whatsapp_notifier/sessions_controller.rb
|
|
57
57
|
- bin/whatsapp_notifier
|
|
58
58
|
- config/routes.rb
|
|
59
|
+
- docs/CONFIGURATION.md
|
|
59
60
|
- docs/bulk_messaging_policy.md
|
|
60
61
|
- docs/graphify.md
|
|
61
62
|
- docs/rails_setup.md
|
|
@@ -72,6 +73,7 @@ files:
|
|
|
72
73
|
- lib/whatsapp_notifier/configuration.rb
|
|
73
74
|
- lib/whatsapp_notifier/doctor.rb
|
|
74
75
|
- lib/whatsapp_notifier/engine.rb
|
|
76
|
+
- lib/whatsapp_notifier/error_code.rb
|
|
75
77
|
- lib/whatsapp_notifier/errors.rb
|
|
76
78
|
- lib/whatsapp_notifier/jobs/send_message_job.rb
|
|
77
79
|
- lib/whatsapp_notifier/notification.rb
|
|
@@ -107,6 +109,7 @@ files:
|
|
|
107
109
|
- spec/client_spec.rb
|
|
108
110
|
- spec/configuration_spec.rb
|
|
109
111
|
- spec/doctor_spec.rb
|
|
112
|
+
- spec/error_code_spec.rb
|
|
110
113
|
- spec/generators/install_service_generator_spec.rb
|
|
111
114
|
- spec/jobs/send_message_job_spec.rb
|
|
112
115
|
- spec/notification_spec.rb
|
|
@@ -116,6 +119,7 @@ files:
|
|
|
116
119
|
- spec/result_spec.rb
|
|
117
120
|
- spec/session/qr_service_spec.rb
|
|
118
121
|
- spec/session/store_spec.rb
|
|
122
|
+
- spec/session_ready_spec.rb
|
|
119
123
|
- spec/spec_helper.rb
|
|
120
124
|
- spec/web_adapter_spec.rb
|
|
121
125
|
- spec/whatsapp_notifier_spec.rb
|