pink-house-sms-sender 0.0.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 0e314dfc5e9603195c8a2a0856b3ad8dbadfffb05385e3f8cc6a3de30a5b5ce3
4
+ data.tar.gz: 320b578a16f4fa37b6b310128cdc3355d622973a6a22bfb62a1cfc4677dbdbae
5
+ SHA512:
6
+ metadata.gz: b3edba5ba1dc4a4d29133d9b09b7fdbf287c7aa491bc5bf671a1c7b4bd591e5caea95fdc5c88ad8738a25b2651b50cd7152a254d1927e335626f5d233aee0bcf
7
+ data.tar.gz: 156726c35e889348db6e87abb26ff796f80fc191cdd5f966629ad0459b95711021e47a48b0f9fe34b0baa56541971d99152a4887cb9b4b96d4e045ace2088d0e
data/CHANGELOG.md ADDED
@@ -0,0 +1,30 @@
1
+ ## [Unreleased]
2
+
3
+ ### Added
4
+
5
+ - Support multiple named instances of the same SMS provider adapter with
6
+ independent settings and runtime state.
7
+ - Mark Infobip as an advisory `unsupported` provider after live delivery
8
+ verification could not be completed.
9
+ - Identify catalog providers with advisory `support_level` metadata; values are
10
+ informational and never prevent provider use.
11
+ - Add the `Sms::Sender` API backed by shared `sender-core` contracts while
12
+ preserving legacy require paths.
13
+ - Add provider configuration, lazy loading, capability filtering, and
14
+ adapters for TextBee, Semaphore, Twilio, and Infobip.
15
+ - Add a standard-library HTTP transport with JSON and form serialization,
16
+ configurable timeouts, normalized transport errors, and credential
17
+ redaction.
18
+ - Add provider-neutral routing with deterministic election, bounded failover,
19
+ circuit protection, health tracking, and attempt history.
20
+ - Add immutable delivery events and state updates for receipt integrations.
21
+ - Add dependency-free structured observability events for elections, attempts,
22
+ failovers, and circuit transitions.
23
+
24
+ ### Quality
25
+
26
+ - Add focused tests, concurrency coverage, RBS signatures, and the
27
+ `bundle exec rake` quality harness with 100% YARD documentation coverage.
28
+ - Add gem metadata and publishing configuration.
29
+ - Add environment-based provider selection, default-provider priority, and
30
+ JSON provider settings configuration.
@@ -0,0 +1,10 @@
1
+ # Code of Conduct
2
+
3
+ "sms-sender" follows [The Ruby Community Conduct Guideline](https://www.ruby-lang.org/en/conduct) in all "collaborative space", which is defined as community communications channels (such as mailing lists, submitted patches, commit comments, etc.):
4
+
5
+ * Participants will be tolerant of opposing views.
6
+ * Participants must ensure that their language and actions are free of personal attacks and disparaging personal remarks.
7
+ * When interpreting the words and actions of others, participants should always assume good intentions.
8
+ * Behaviour which can be reasonably considered harassment will not be tolerated.
9
+
10
+ If you have any concerns about behaviour within this project, please contact us at ["kenneth.c.demanawa@gmail.com"](mailto:"kenneth.c.demanawa@gmail.com").
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Ken C. Demanawa
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,230 @@
1
+ # sms-sender
2
+
3
+ `sms-sender` is a Ruby gem for provider-neutral SMS delivery. It is designed
4
+ to let applications use one domain API while provider adapters handle the
5
+ details of individual HTTP APIs.
6
+
7
+ The project uses thin, lazily loaded provider adapters and a shared runtime
8
+ for health-aware provider selection, controlled failover, circuit breaking,
9
+ capability filtering, and normalized delivery results and errors.
10
+
11
+ ## Origin
12
+
13
+ `sms-sender` was extracted from the Pink House SaaS project so its SMS
14
+ delivery capability could be reused independently of the application. Shared
15
+ delivery contracts and runtime behavior live in
16
+ [`sender-core`](https://github.com/kanutocd/sender-core); this gem owns SMS
17
+ validation, provider adapters, and SMS-specific request and response mapping.
18
+
19
+ ## Installation
20
+
21
+ Add the gem to your application's Gemfile:
22
+
23
+ ```ruby
24
+ gem "sms-sender"
25
+ ```
26
+
27
+ Then install it with Bundler:
28
+
29
+ ```bash
30
+ bundle install
31
+ ```
32
+
33
+ The gem depends on the channel-neutral `sender-core` runtime and has no
34
+ mandatory provider SDK dependencies. Provider integrations use documented
35
+ HTTP APIs directly.
36
+
37
+ To configure providers from environment variables, list the desired providers
38
+ and identify the preferred default. The default receives the highest election
39
+ priority; other selected providers remain available for failover:
40
+
41
+ ```bash
42
+ SMS_SENDER_PROVIDERS=textbee,twilio
43
+ SMS_SENDER_DEFAULT_PROVIDER=twilio
44
+ SMS_SENDER_TEXTBEE_SETTINGS='{"api_key":"...","device_id":"..."}'
45
+ ```
46
+
47
+ Multiple accounts of one provider can be configured as named instances. An
48
+ instance name uses the adapter name as its prefix:
49
+
50
+ ```bash
51
+ SMS_SENDER_PROVIDERS=textbee_primary,textbee_backup
52
+ SMS_SENDER_DEFAULT_PROVIDER=textbee_primary
53
+ SMS_SENDER_TEXTBEE_PRIMARY_SETTINGS='{"api_key":"...","device_id":"..."}'
54
+ SMS_SENDER_TEXTBEE_BACKUP_SETTINGS='{"api_key":"...","device_id":"..."}'
55
+ ```
56
+
57
+ Each instance has independent priority, fallback, circuit-breaker, and health
58
+ state. Existing single-provider variables remain supported.
59
+
60
+ Call `Sms::Sender.configure_from_env` during application boot. Each provider's
61
+ `*_SETTINGS` value is a JSON object whose keys match that adapter's settings.
62
+ Provider metadata includes an advisory `support_level`; it is informational
63
+ only and does not prevent use of experimental, deprecated, or unsupported
64
+ providers.
65
+
66
+ ## Compatibility boundary
67
+
68
+ Shared contracts such as `Message`, `Delivery`, `Provider`, `Router`, and
69
+ normalized errors are supplied by `sender-core` and exposed through the
70
+ existing `Sms::Sender` constants. SMS provider adapters and the SMS provider
71
+ catalog remain in this gem. Existing `lib/sms/sender/*` require paths are
72
+ preserved as compatibility shims.
73
+
74
+ ## Usage
75
+
76
+ Construct provider-neutral messages and configurations:
77
+
78
+ ```ruby
79
+ require "sms_sender"
80
+
81
+ message = Sms::Sender::Message.new(
82
+ to: "+639171234567",
83
+ body: "Your OTP is 123456",
84
+ requirements: [:unicode]
85
+ )
86
+
87
+ configuration = Sms::Sender::ProviderConfiguration.new(
88
+ name: :textbee,
89
+ settings: {
90
+ api_key: ENV.fetch("TEXTBEE_API_KEY")
91
+ },
92
+ priority: 10,
93
+ capabilities: [:sms, :unicode]
94
+ )
95
+ ```
96
+
97
+ `Message` and `ProviderConfiguration` are immutable after construction.
98
+ Recipients and bodies are validated, provider capabilities and message
99
+ requirements are normalized to symbols, and delivery results distinguish
100
+ provider acceptance from confirmed handset delivery.
101
+
102
+ Provider adapters implement the common contract:
103
+
104
+ ```ruby
105
+ class MyProvider < Sms::Sender::Provider
106
+ def deliver(message)
107
+ # Translate `message` to the provider's HTTP API and return a
108
+ # Sms::Sender::Delivery result.
109
+ end
110
+ end
111
+ ```
112
+
113
+ The high-level API routes through configured providers and returns a normalized
114
+ delivery result:
115
+
116
+ ```ruby
117
+ delivery = Sms::Sender.deliver(
118
+ to: "+639171234567",
119
+ body: "Your OTP is 123456"
120
+ )
121
+ ```
122
+
123
+ The registry exposes the providers supported by the gem without loading their
124
+ adapter implementations:
125
+
126
+ ```ruby
127
+ Sms::Sender.providers
128
+ # => [:textbee, :semaphore, :twilio, :infobip]
129
+ ```
130
+
131
+ Provider adapters can use the HTTP client without taking a dependency on a
132
+ provider SDK:
133
+
134
+ ```ruby
135
+ client = Sms::Sender::HTTP::Client.new
136
+ response = client.request(
137
+ :post,
138
+ "https://example.test/messages",
139
+ headers: { "Authorization" => "Bearer secret" },
140
+ body: { to: message.to, body: message.body }
141
+ )
142
+ ```
143
+
144
+ The client normalizes responses, serializes Hash bodies as JSON, supports
145
+ separate connection/read/write and total timeouts, maps transport failures to
146
+ normalized errors, and provides redacted headers for diagnostics.
147
+
148
+ The first provider adapter can be used through the registry:
149
+
150
+ ```ruby
151
+ registry = Sms::Sender::ProviderRegistry.new
152
+ registry.configure(
153
+ :textbee,
154
+ settings: { api_key: ENV.fetch("TEXTBEE_API_KEY") }
155
+ )
156
+
157
+ delivery = registry.provider(:textbee).deliver(message)
158
+ ```
159
+
160
+ TextBee sends through its default device unless `device_id` is configured.
161
+ The adapter returns an accepted delivery with the provider's batch ID; that
162
+ acceptance does not confirm handset delivery.
163
+
164
+ ## Design principles
165
+
166
+ - One repository and one gem for all supported providers.
167
+ - Direct documented HTTP integrations instead of provider Ruby SDKs.
168
+ - Lazy loading so unused providers and dependencies remain unloaded.
169
+ - Thin adapters limited to configuration, authentication, HTTP mapping,
170
+ response parsing, and provider error mapping.
171
+ - Provider-neutral routing, health, failover, and circuit-breaking behavior.
172
+ - Explicit error classification so terminal request errors do not trigger
173
+ provider failover.
174
+ - Provider acceptance is distinct from final handset delivery.
175
+ - Process-local state initially, behind an abstraction for future shared state.
176
+
177
+ The initial provider set is TextBee, Semaphore, Twilio, and Infobip. Telnyx,
178
+ Vonage, and Plivo are potential later additions. AWS SNS is not part of the
179
+ initial provider set.
180
+
181
+ ## Development
182
+
183
+ After checking out the repository, install dependencies:
184
+
185
+ ```bash
186
+ bin/setup
187
+ ```
188
+
189
+ Run the complete quality harness with:
190
+
191
+ ```bash
192
+ bundle exec rake
193
+ ```
194
+
195
+ This runs the test suite, RuboCop, RBS validation, and YARD documentation
196
+ coverage checks. Normal tests do not require provider credentials or send
197
+ real SMS messages.
198
+
199
+ Other useful commands:
200
+
201
+ ```bash
202
+ bundle exec rake test # Run tests
203
+ bundle exec rake rubocop # Run RuboCop
204
+ bundle exec rake rbs # Validate RBS signatures
205
+ bundle exec rake yard # Generate API documentation
206
+ bundle exec rake build # Build the gem
207
+ bin/console # Open a Ruby console
208
+ ```
209
+
210
+ ### Compatibility and integration boundary
211
+
212
+ The supported runtime is Ruby 3.2 and newer. Standard CI uses only injected
213
+ HTTP transports and in-memory state; it never requires provider credentials,
214
+ network access, or live SMS delivery. Provider-specific integration tests are
215
+ opt-in and should be isolated from the normal `bundle exec rake` quality gate.
216
+
217
+ ## Contributing
218
+
219
+ Bug reports and pull requests are welcome at
220
+ [github.com/kanutocd/sms-sender](https://github.com/kanutocd/sms-sender).
221
+
222
+ Changes should preserve the provider-neutral core, keep adapters thin, and
223
+ include focused tests. Meaningful user, operator, integration, tooling, or
224
+ architectural changes should be recorded in `CHANGELOG.md` under `Unreleased`.
225
+
226
+ ## License
227
+
228
+ This project is available under the [MIT License](LICENSE.txt).
229
+
230
+ See [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) for community guidelines.
data/Rakefile ADDED
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+
6
+ Rake::TestTask.new(:test) do |task|
7
+ task.libs << "test"
8
+ task.libs << "lib"
9
+ task.test_files = FileList["test/**/test_*.rb", "test/**/*_test.rb"]
10
+ end
11
+
12
+ require "rubocop/rake_task"
13
+ require "yard"
14
+ require "yard/rake/yardoc_task"
15
+
16
+ RuboCop::RakeTask.new do |task|
17
+ task.options = ["--cache", "false"]
18
+ end
19
+
20
+ YARD::Rake::YardocTask.new(:yard) do |task|
21
+ task.files = ["lib/**/*.rb"]
22
+ task.options = ["--output-dir", "doc", "--readme", "README.md", "--markup", "markdown"]
23
+ end
24
+
25
+ desc "Validate RBS signatures"
26
+ task :rbs do
27
+ sh "rbs validate"
28
+ end
29
+
30
+ desc "Enforce YARD documentation coverage"
31
+ task "yard:coverage" => :yard do
32
+ output = `yard stats --no-progress --list-undoc --exclude sig`
33
+ puts output
34
+ coverage = output[/([0-9]+(?:\.[0-9]+)?)% documented/, 1]&.to_f
35
+ abort "Could not determine YARD documentation coverage" unless coverage
36
+ abort "YARD documentation coverage #{coverage}% is below 100%" if coverage < 100.0
37
+ end
38
+
39
+ desc "Run the test and lint quality gates"
40
+ task quality: %i[test rubocop rbs yard:coverage]
41
+
42
+ task default: :quality
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Backwards-compatible load path for the capabilities contract now supplied by
4
+ # sender-core.
5
+ require_relative "../sender"
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Backwards-compatible load path for the circuit breaker contract now supplied by
4
+ # sender-core.
5
+ require_relative "../sender"
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Backwards-compatible load path for the delivery contract now supplied by
4
+ # sender-core.
5
+ require_relative "../sender"
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Backwards-compatible load path for the delivery event contract now supplied by
4
+ # sender-core.
5
+ require_relative "../sender"
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Backwards-compatible load path for the election contract now supplied by
4
+ # sender-core.
5
+ require_relative "../sender"
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Backwards-compatible load path for the errors contract now supplied by
4
+ # sender-core.
5
+ require_relative "../sender"
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Backwards-compatible load path for the health contract now supplied by
4
+ # sender-core.
5
+ require_relative "../sender"
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Backwards-compatible load path for the http contract now supplied by
4
+ # sender-core.
5
+ require_relative "../sender"
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Backwards-compatible load path for the message contract now supplied by
4
+ # sender-core.
5
+ require_relative "../sender"
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Backwards-compatible load path for the observability contract now supplied by
4
+ # sender-core.
5
+ require_relative "../sender"
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Backwards-compatible load path for the provider contract now supplied by
4
+ # sender-core.
5
+ require "sender/core"
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Backwards-compatible load path for the provider configuration contract now supplied by
4
+ # sender-core.
5
+ require_relative "../sender"
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Backwards-compatible load path for the provider registry contract now supplied by
4
+ # sender-core.
5
+ require_relative "../sender"
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Sms
6
+ module Sender
7
+ module Providers
8
+ # Infobip HTTP adapter for advanced SMS message submission.
9
+ class Infobip < Provider
10
+ # Default Infobip SMS API base URL.
11
+ BASE_URL = "https://api.infobip.com"
12
+ # Infobip advanced SMS endpoint path.
13
+ SEND_PATH = "/sms/3/messages"
14
+
15
+ # @param configuration [ProviderConfiguration] Infobip settings
16
+ # @param http [HTTP::Client] injectable HTTP client
17
+ def initialize(configuration:, http: HTTP::Client.new)
18
+ super(configuration: configuration)
19
+ @http = http
20
+ end
21
+
22
+ # Submit an SMS through Infobip.
23
+ # @param message [Message] normalized outbound message
24
+ # @return [Delivery] accepted provider response
25
+ def deliver(message)
26
+ validate_message(message)
27
+ response = @http.request(:post, endpoint, headers: headers, body: request_body(message))
28
+ map_response(response)
29
+ end
30
+
31
+ private
32
+
33
+ def endpoint
34
+ "#{configuration[:base_url] || BASE_URL}#{SEND_PATH}"
35
+ end
36
+
37
+ def headers
38
+ { "Authorization" => "App #{api_key}", "Content-Type" => "application/json", "Accept" => "application/json" }
39
+ end
40
+
41
+ def api_key
42
+ key = configuration[:api_key]
43
+ return key.to_s unless key.nil? || key.to_s.empty?
44
+
45
+ raise Errors::ConfigurationError.new("Infobip api_key is required", category: :configuration,
46
+ provider: :infobip)
47
+ end
48
+
49
+ def sender
50
+ value = configuration[:from] || configuration[:sender]
51
+ return value.to_s unless value.nil? || value.to_s.empty?
52
+
53
+ raise Errors::ConfigurationError.new("Infobip from or sender is required", category: :configuration,
54
+ provider: :infobip)
55
+ end
56
+
57
+ def request_body(message)
58
+ {
59
+ "messages" => [
60
+ {
61
+ "sender" => sender,
62
+ "destinations" => [{ "to" => message.to }],
63
+ "content" => { "text" => message.body }
64
+ }
65
+ ]
66
+ }
67
+ end
68
+
69
+ def map_response(response)
70
+ payload = JSON.parse(response.body.to_s)
71
+ return map_failure(response.status, payload) unless response.successful?
72
+
73
+ result = payload.is_a?(Hash) && payload["messages"]&.first
74
+ raise TypeError unless result.is_a?(Hash) && result["messageId"]
75
+
76
+ Delivery.new(status: :accepted, provider: :infobip, provider_message_id: result["messageId"],
77
+ metadata: payload)
78
+ rescue JSON::ParserError, TypeError, NoMethodError
79
+ raise Errors::Unknown.new("Infobip returned an invalid response", category: :malformed_response,
80
+ provider: :infobip)
81
+ end
82
+
83
+ def map_failure(status, payload)
84
+ message = payload.is_a?(Hash) ? payload["requestError"] : nil
85
+ if message.is_a?(Hash) && message["serviceException"].is_a?(Hash)
86
+ message = message["serviceException"]["text"]
87
+ end
88
+ message ||= "Infobip request failed with HTTP #{status}"
89
+ error_class = case status
90
+ when 400 then Errors::InvalidRequest
91
+ when 401 then Errors::Authentication
92
+ when 403 then Errors::Authorization
93
+ when 429 then Errors::RateLimited
94
+ when 500..599 then Errors::ProviderUnavailable
95
+ else Errors::Unknown
96
+ end
97
+ raise error_class.new(message, category: error_class.name.split("::").last.downcase.to_sym,
98
+ provider: :infobip)
99
+ end
100
+
101
+ def validate_message(message)
102
+ return if message.is_a?(Message)
103
+
104
+ raise ArgumentError, "message must be a Sms::Sender::Message"
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "uri"
5
+
6
+ module Sms
7
+ module Sender
8
+ module Providers
9
+ # Semaphore HTTP adapter for Philippine SMS delivery.
10
+ class Semaphore < Provider
11
+ # Default hosted Semaphore API base URL.
12
+ BASE_URL = "https://api.semaphore.co"
13
+ # Semaphore SMS submission endpoint path.
14
+ SEND_PATH = "/api/v4/messages"
15
+
16
+ # @param configuration [ProviderConfiguration] Semaphore settings
17
+ # @param http [HTTP::Client] injectable HTTP client
18
+ def initialize(configuration:, http: HTTP::Client.new)
19
+ super(configuration: configuration)
20
+ @http = http
21
+ end
22
+
23
+ # Submit an SMS through Semaphore.
24
+ # @param message [Message] normalized outbound message
25
+ # @return [Delivery] accepted provider response
26
+ def deliver(message)
27
+ validate_message(message)
28
+ response = @http.request(:post, endpoint, headers: headers, body: request_body(message))
29
+ map_response(response)
30
+ end
31
+
32
+ private
33
+
34
+ def endpoint
35
+ "#{configuration[:base_url] || BASE_URL}#{SEND_PATH}"
36
+ end
37
+
38
+ def headers
39
+ { "Content-Type" => "application/x-www-form-urlencoded" }
40
+ end
41
+
42
+ def api_key
43
+ key = configuration[:api_key]
44
+ return key.to_s unless key.nil? || key.to_s.empty?
45
+
46
+ raise Errors::ConfigurationError.new("Semaphore api_key is required", category: :configuration,
47
+ provider: :semaphore)
48
+ end
49
+
50
+ def request_body(message)
51
+ fields = { "apikey" => api_key, "number" => message.to, "message" => message.body }
52
+ fields["sendername"] = configuration[:sender] if configuration[:sender]
53
+ URI.encode_www_form(fields)
54
+ end
55
+
56
+ def map_response(response)
57
+ payload = JSON.parse(response.body.to_s)
58
+ return map_failure(response.status, payload) unless response.successful?
59
+
60
+ result = payload.first
61
+ raise TypeError unless result.is_a?(Hash)
62
+ if result["status"].to_s.casecmp("failed").zero? || result["status"].to_s.casecmp("refunded").zero?
63
+ return map_rejection(result)
64
+ end
65
+
66
+ Delivery.new(
67
+ status: :accepted,
68
+ provider: :semaphore,
69
+ provider_message_id: result["message_id"].to_s,
70
+ metadata: result
71
+ )
72
+ rescue JSON::ParserError, TypeError, NoMethodError
73
+ raise Errors::Unknown.new("Semaphore returned an invalid response", category: :malformed_response,
74
+ provider: :semaphore)
75
+ end
76
+
77
+ def map_failure(status, payload)
78
+ message = payload.is_a?(Hash) ? payload["message"] : nil
79
+ message ||= "Semaphore request failed with HTTP #{status}"
80
+ error_class = case status
81
+ when 401 then Errors::Authentication
82
+ when 429 then Errors::RateLimited
83
+ when 400 then Errors::InvalidRequest
84
+ when 500..599 then Errors::ProviderUnavailable
85
+ else Errors::Unknown
86
+ end
87
+ raise error_class.new(message, category: error_class.name.split("::").last.downcase.to_sym,
88
+ provider: :semaphore)
89
+ end
90
+
91
+ def map_rejection(_result)
92
+ raise Errors::ProviderRejected.new("Semaphore rejected the message", category: :provider_rejected,
93
+ provider: :semaphore)
94
+ end
95
+
96
+ def validate_message(message)
97
+ return if message.is_a?(Message)
98
+
99
+ raise ArgumentError, "message must be a Sms::Sender::Message"
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end