whatsapp_notifier 0.8.2 → 0.9.1
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/index.ts +59 -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/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
|
@@ -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.1
|
|
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
|