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 +7 -0
- data/CHANGELOG.md +30 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +230 -0
- data/Rakefile +42 -0
- data/lib/sms/sender/capabilities.rb +5 -0
- data/lib/sms/sender/circuit_breaker.rb +5 -0
- data/lib/sms/sender/delivery.rb +5 -0
- data/lib/sms/sender/delivery_event.rb +5 -0
- data/lib/sms/sender/election.rb +5 -0
- data/lib/sms/sender/errors.rb +5 -0
- data/lib/sms/sender/health.rb +5 -0
- data/lib/sms/sender/http.rb +5 -0
- data/lib/sms/sender/message.rb +5 -0
- data/lib/sms/sender/observability.rb +5 -0
- data/lib/sms/sender/provider.rb +5 -0
- data/lib/sms/sender/provider_configuration.rb +5 -0
- data/lib/sms/sender/provider_registry.rb +5 -0
- data/lib/sms/sender/providers/infobip.rb +109 -0
- data/lib/sms/sender/providers/semaphore.rb +104 -0
- data/lib/sms/sender/providers/text_bee.rb +112 -0
- data/lib/sms/sender/providers/twilio.rb +119 -0
- data/lib/sms/sender/router.rb +5 -0
- data/lib/sms/sender/state_store.rb +5 -0
- data/lib/sms/sender/version.rb +8 -0
- data/lib/sms/sender.rb +176 -0
- data/lib/sms_sender.rb +10 -0
- data/sig/sms/sender.rbs +255 -0
- metadata +94 -0
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.
|
data/CODE_OF_CONDUCT.md
ADDED
|
@@ -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,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
|