sms_traffic_sdk 0.0.8 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6d03f7cface745a7a9ea2df32398b6e295e62c8efc48cf90e41f6eb73624f387
4
- data.tar.gz: da2123261e9a4949ba122819135feb6faed7a7ea82006871fb70bc31a12a0c23
3
+ metadata.gz: 7073f3fdf9537cf98008d5c6fac2bbc28457fbe4ef05852ccfd4e3b3a4606fbc
4
+ data.tar.gz: 7ad131b8ca90beebf7d4e227fa4031655bdc49d7e5db4f12c7ee11763cde8e0a
5
5
  SHA512:
6
- metadata.gz: 915582d8ba488e04028588e4b7461ad0c286f74ecc191a397ba600b0a4e3f346199b79f04d9097de837493a12a481451685ff93375979fc72421c9b80b747704
7
- data.tar.gz: 6af993a023313a727cf90ae0774fd81646e588ebab585273ac67e2b037a5edd50402889f4d0984586af94f2c74451c594e0a1560e277cbb8c47f8bce03e6b33d
6
+ metadata.gz: f22009b2e84816ea0ad7294fd80ec8810dfc99c4ad46a7c66e9f9604f837c31fe5d6ae996639e776cb79501523b50257d20390069412993c704b2456ed3b5fb7
7
+ data.tar.gz: 4dc45cb30d25dd521d716f16bcb7a4ae530ee9fa3b8d14f0b8ec0cfde6a479c81d41268b1015a7518bdd16d79a11fc4f8c5663d2e8266dc7f34c1d0415202843
data/CHANGELOG.md ADDED
@@ -0,0 +1,87 @@
1
+ # Changelog
2
+
3
+ ## 0.2.0 - 2026-11-03
4
+
5
+ ### Added
6
+
7
+ - `Configuration#fallback_host` (default `nil`). When set, the client repeats a request once on that host after a
8
+ connection failure that proves the server was not reached (`SocketError`, `Errno::ECONNREFUSED`,
9
+ `Errno::EHOSTUNREACH`, `Errno::ENETUNREACH`, `Net::OpenTimeout`) or after API error 1000. `Client.status` and
10
+ `Client.balance` also retry after any `ConnectionError` and HTTP 5xx; `Client.deliver` never does, because the
11
+ provider may already have queued the SMS. With `debug`, the retry is logged in one line without credentials.
12
+ - `Client.balance` returns an `AccountReply`; `AccountReply#balance` is a `BigDecimal` (units as sent by the
13
+ provider). `bigdecimal` is a runtime dependency.
14
+ - `SmsTraffic::Status` (`FINAL`, `INTERMEDIATE`), `StatusReply::Entry#final?` / `#delivered?`, the same methods on
15
+ `StatusReply` (optional `sms_id`) and `Sms#final?` / `#delivered?`.
16
+ - `ApiError#retryable?`, `#access_denied?`, `#insufficient_funds?` and `#blocked_phone?`.
17
+
18
+ ### Fixed
19
+
20
+ - A provider reply with `<result>ERRO</result>` (the spelling in the provider's own example) is now an
21
+ `ApiError`. `Reply#api_error?` is true for any present `result` other than `OK`.
22
+ - `Sms#update_status` no longer records "No status returned" for an empty `<status>` element (an intermediate
23
+ provider status); the status stays `sent`. `StatusReply::Entry#status_reported?` tells an empty status from an
24
+ absent one.
25
+
26
+ ## 0.1.0 - 2026-11-03
27
+
28
+ ### Breaking changes
29
+
30
+ - Ruby 3.1 or newer is required.
31
+ - Zeitwerk is no longer a dependency; files are loaded with `require_relative`.
32
+ - `SmsTraffic::Configuration` is a plain class, not a `Singleton`. Defaults changed: `logger` is `nil` (was `$stdout`),
33
+ new `open_timeout` (10) and `read_timeout` (30). `originator` is no longer required by `configure`.
34
+ `Configuration#values_at` is removed.
35
+ - `SmsTraffic::Client::Response` is removed. `Client.deliver` returns a `DeliverReply` and `Client.status` a
36
+ `StatusReply` directly, and raise `SmsTraffic::ConnectionError`, `SmsTraffic::HttpError` or `SmsTraffic::ApiError`
37
+ (all `SmsTraffic::Error`) instead of returning a failed response.
38
+ - `Reply#hash` no longer returns the parsed reply (it is `Object#hash` again); use `Reply#to_h`.
39
+ - `DeliverReply#ok?` is strictly boolean and requires a queued message. `#sms_ids` is an Array of every sms id (it was
40
+ a Hash keyed by phone). `#message_infos` is replaced by `#messages`. `#error_description` reads
41
+ `"Sms has not been enqueued: <description>"`.
42
+ - `StatusReply#sms_array` is replaced by `#entries` (`StatusReply::Entry`); `#entry`, `#error?`, `#not_found?`
43
+ and `#send_date` are new.
44
+ - `Sms#response` is replaced by `Sms#reply`. `Sms#update_status` always returns the status String, and `Sms#deliver`
45
+ records `SmsTraffic::Error`s in `errors` instead of the HTTP code.
46
+ - The `konsierge-sms.rb` executable is removed.
47
+ - The default XML parser no longer typecasts (`Nori.new(advanced_typecasting: false)`): dates and `true` stay
48
+ provider strings.
49
+ - `nori` 2.8 or newer is required.
50
+
51
+ ### Fixed
52
+
53
+ - `Sms` phone validation no longer accepts a newline-separated value (`\A`/`\z` anchors).
54
+ - `Client.status` with several ids sends a flat comma-separated `sms_id` list.
55
+ - The client builds its URI from the current `host` on every request instead of memoizing the first one.
56
+ - `open_timeout` and `read_timeout` are applied to every request.
57
+ - `Reply` no longer mutates (or fails on) frozen input.
58
+ - A multi-part SMS keeps every `sms_id`; a single `<message_info>` with a phone is parsed.
59
+ - `require 'sms_traffic_sdk'` works with `Bundler.require`.
60
+ - Loading the gem without a preloaded `StringIO` no longer fails with `NameError` (nori 2.7.0 missed
61
+ `require 'stringio'`; fixed in nori 2.8).
62
+
63
+ ### Changed
64
+
65
+ - CI runs lint and RSpec on Ruby 3.1-3.4, smoke-tests the built gem in a clean Ruby process, uploads Cobertura
66
+ coverage to Codecov, and publishes to RubyGems through trusted publishing (OIDC) from a published release whose tag
67
+ matches the gem version. The gem package contains only `lib`, `README.md`, `LICENSE` and `CHANGELOG.md`.
68
+
69
+ ### Security
70
+
71
+ - `Net::HTTP#set_debug_output` is no longer used, so the login, password and SMS text are never written to the logger.
72
+ Debug logging emits one request line with `login`, `password` and `message` filtered, and one response line with
73
+ the HTTP status and body size only.
74
+
75
+ ### Migration
76
+
77
+ - Replace `Client.deliver(...).reply` / `.success?` with the returned reply and `rescue SmsTraffic::Error`.
78
+ - Replace `reply.hash` with `reply.to_h`, and `sms.response` with `sms.reply`.
79
+ - Treat `reply.sms_ids` as an Array; use `reply.messages` for per-phone data.
80
+ - Set `config.logger` explicitly if you relied on the `$stdout` default with `debug = true`.
81
+ - `Client.deliver` and `Client.status` no longer raise `SocketError`, `SystemCallError` (`Errno::*`), `IOError`,
82
+ `Net::OpenTimeout`, `Net::ReadTimeout` or `OpenSSL::SSL::SSLError`; rescue `SmsTraffic::ConnectionError` (the
83
+ original exception is in `cause`). `Sms#deliver` and `Sms#update_status` already rescue `SmsTraffic::Error` into
84
+ `errors`.
85
+ - Replace `response.as_json` / `sms.response.as_json` with `reply.to_h` / `sms.reply&.to_h`.
86
+ - Replace `response.success?` checks with `rescue SmsTraffic::Error`.
87
+ - Replace `reply.hash["send_date"]` / `reply.hash["status"]` with `reply.send_date` / `reply.status`.
data/README.md CHANGED
@@ -1,9 +1,9 @@
1
1
  # SmsTraffic
2
2
 
3
3
  [![Gem Version](https://badge.fury.io/rb/sms_traffic_sdk.svg)](https://badge.fury.io/rb/sms_traffic_sdk)
4
- [![Maintainability](https://api.codeclimate.com/v1/badges/bb4795be5024dd81d927/maintainability)](https://codeclimate.com/github/golifox/sms_traffic/maintainability)
5
- [![Coverage](https://codecov.io/github/golifox/sms_traffic/graph/badge.svg?token=74C0YBJP3F)](https://codecov.io/github/golifox/sms_traffic)
6
- [![CI](https://github.com/golifox/sms_traffic/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/golifox/sms_traffic/actions/workflows/ci.yml)
4
+ [![Maintainability](https://api.codeclimate.com/v1/badges/bb4795be5024dd81d927/maintainability)](https://codeclimate.com/github/golifox/sms_traffic_sdk/maintainability)
5
+ [![Coverage](https://codecov.io/github/golifox/sms_traffic_sdk/graph/badge.svg?token=74C0YBJP3F)](https://codecov.io/github/golifox/sms_traffic_sdk)
6
+ [![CI](https://github.com/golifox/sms_traffic_sdk/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/golifox/sms_traffic_sdk/actions/workflows/ci.yml)
7
7
 
8
8
  Ruby Gem as a software development kit (SDK) that facilitates interaction with the SMS Traffic HTTP API (smstraffic.ru/api).
9
9
  This gem provides a convenient wrapper to integrate SMS Traffic services within Ruby applications, allowing for easy
@@ -13,6 +13,8 @@ sending of SMS messages and checking delivery statuses.
13
13
 
14
14
  - Send SMS messages to single or multiple recipients.
15
15
  - Check the delivery status of sent messages.
16
+ - Read the account balance.
17
+ - Fail over to the provider's backup host.
16
18
  - **TODO:** Support for various message formats and encodings.
17
19
 
18
20
 
@@ -37,63 +39,126 @@ Or install it yourself as:
37
39
  Define settings:
38
40
 
39
41
  ```ruby
40
- # initializers/sms_traffic.rb
41
- require 'sms_traffic_sdk'
42
+ # config/initializers/sms_traffic.rb
43
+ require 'sms_traffic'
42
44
 
43
45
  SmsTraffic.configure do |config|
44
- config.login = 'login'
45
- config.password = 'password'
46
- config.originator = 'default_originator'
47
- config.server = 'https://api.smstraffic.ru'
48
- config.debug = true
49
- config.debugger = Rails.logger
46
+ config.login = 'login'
47
+ config.password = 'password'
48
+ config.host = 'https://api.smstraffic.ru'
49
+ config.fallback_host = 'https://api2.smstraffic.ru' # optional backup host, default nil
50
+ config.originator = 'default_originator' # optional, can be given per SMS
51
+ config.open_timeout = 10 # seconds, default 10
52
+ config.read_timeout = 30 # seconds, default 30
53
+ config.validate_phone = true # default true: 11+ digits
54
+ config.debug = true # default false
55
+ config.logger = Rails.logger # required when debug is true
50
56
  end
51
57
  ```
52
58
 
53
- Initialize sms:
59
+ `login`, `password` and `host` are required; `logger` is required when `debug` is enabled.
60
+ With `debug` on, one request line and one response line are written through `logger.debug`;
61
+ `login`, `password` and `message` are replaced with `[FILTERED]`.
62
+
63
+ Send an SMS:
64
+
54
65
  ```ruby
55
- sms = SmsTraffic::Sms.new('phone', 'text', 'originator') # initialize sms, originator by default from settings
56
- # => #<SmsTraffic::Sms:0x0000 @errors=[], @message="text", @originator="default_originator", @phone="phone", @status="not-sent">
66
+ sms = SmsTraffic::Sms.new('79991112233', 'text', originator: 'my_brand') # originator defaults to the configured one
57
67
 
68
+ sms.deliver # => true when the provider enqueued it, false otherwise
69
+ sms.status # => 'sent' ('not-sent' until delivered)
70
+ sms.id # => '123456789' (the first sms id of the reply)
71
+ sms.reply # => #<SmsTraffic::Client::DeliverReply>
72
+ sms.errors # => [] (messages of failed attempts)
58
73
  ```
59
74
 
60
- Send it and get sent sms id and dispatch code:
75
+ `deliver` and `update_status` never raise on provider or network failures; they append the message to `errors`
76
+ and keep the current `status`.
77
+
78
+ Refresh the status:
79
+
61
80
  ```ruby
62
- sms.deliver # send sms. returns sms id or dispatch code if something went wrong
63
- # => true
64
- sms.id # get sms id
65
- # => '123456789'
81
+ sms.update_status # => 'delivered' (downcased provider status, or the unchanged status on failure)
66
82
  ```
67
83
 
68
- Get current sms status and update it:
84
+ Use the client directly. It raises typed errors:
85
+
69
86
  ```ruby
70
- sms.status # get sms delivery status
71
- # => 'sent'
72
- sms.update_status # updates sms delivery status. returns status or status check response code on error
73
- # => 'delivered'
87
+ reply = SmsTraffic::Client.deliver('79991112233,79261112233', 'text', 'my_brand')
88
+ reply.ok? # => true
89
+ reply.sms_ids # => ['8287366071', '8287366073']
90
+ reply.messages # => [{ phone: '79991112233', sms_id: '8287366071', push_id: 'a' }, ...]
91
+ reply.sms_id('79261112233') # first sms id for the phone
92
+
93
+ reply = SmsTraffic::Client.status('8287713301', '8287713303')
94
+ reply.entries # => [#<struct SmsTraffic::Client::StatusReply::Entry sms_id="8287713301", status="Delivered", ...>]
95
+ reply.statuses # => { '8287713301' => 'Delivered' }
96
+ reply.errors # => { '8287713303' => 'no such message or this message does not belong to you' }
97
+ reply.entry('8287713303').not_found? # => true
98
+ reply.status('8287713301') # => 'Delivered'; the id may be omitted when there is one entry
74
99
  ```
75
100
 
76
- Get any sms status:
101
+ Each entry has `sms_id`, `status`, `error`, `submission_date`, `send_date`, `last_status_change_date`
102
+ (strings as sent by the provider), `error?`, `not_found?`, `final?`, `delivered?` and `status_reported?`
103
+ (true when the provider sent a status element, even an empty one).
104
+
105
+ ### Final and delivered statuses
106
+
107
+ Provider statuses `Delivered`, `Non Delivered`, `Rejected`, `Expired`, `Deleted` and `Unknown status` are final;
108
+ an empty status and `Buffered SMSC` are intermediate (`SmsTraffic::Status::FINAL` / `INTERMEDIATE`).
109
+ Comparison is case-insensitive.
110
+
77
111
  ```ruby
78
- response = SmsTraffic.status(sms_id) # status - sms delivery status unless error or return error
79
- reply = response.reply
80
- # => #<SmsTraffic::Client::StatusReply:0x0000>
81
- status = reply.status
82
- # => "Delivered"
83
- reply.hash
84
- # =>
85
- # {"error"=>nil,
86
- # "submition_date"=>"2024-07-15 11:36:25",
87
- # "send_date"=>"2024-07-15 11:36:25",
88
- # "last_status_change_date"=>"2024-07-15 11:36:25",
89
- # "sms_id"=>"865149797164801235",
90
- # "status"=>"Delivered"}
112
+ reply = SmsTraffic::Client.status('8287713301')
113
+ reply.final? # => true
114
+ reply.delivered? # => true; pass an sms id when the reply has several entries
115
+ reply.entry.final?
116
+
117
+ sms.update_status
118
+ sms.final? # => true for a final stored status; false for 'not-sent' and 'sent'
119
+ sms.delivered? # => true only for 'delivered'
120
+ ```
91
121
 
122
+ ### Balance
123
+
124
+ ```ruby
125
+ SmsTraffic::Client.balance.balance # => 0.10025e5 (BigDecimal), nil when absent or not a number
126
+ ```
127
+
128
+ The value is returned as the provider sends it; its units are not documented and are not converted.
129
+
130
+ ### Fallback host
131
+
132
+ Set `config.fallback_host` (the provider documents `https://api2.smstraffic.ru`) to repeat a failed request once
133
+ on the backup host. The request is repeated after `SocketError`, `Errno::ECONNREFUSED`, `Errno::EHOSTUNREACH`,
134
+ `Errno::ENETUNREACH`, `Net::OpenTimeout`, or API error 1000 (temporary server problem). `Client.status` and
135
+ `Client.balance` also retry after any `ConnectionError` (read timeout, reset connection, TLS failure) and HTTP 5xx.
136
+ `Client.deliver` does not, since the provider may already have queued the SMS. The error of the last attempt is raised. With `debug`, the retry is
137
+ logged in one line without credentials.
138
+
139
+ ### Errors
140
+
141
+ All errors inherit from `SmsTraffic::Error`:
142
+
143
+ - `SmsTraffic::ConnectionError` - network, timeout or TLS failure (the original exception is `cause`).
144
+ - `SmsTraffic::HttpError` - non-2xx response; has `status` and `body`.
145
+ - `SmsTraffic::ApiError` - the provider replied `ERROR` (or `ERRO`); has `code` and `description`, and the
146
+ predicates `retryable?` (1000), `access_denied?` (411, 412), `insufficient_funds?` (415) and `blocked_phone?` (432).
147
+
148
+ ```ruby
149
+ begin
150
+ SmsTraffic::Client.deliver('79991112233', 'text')
151
+ rescue SmsTraffic::ApiError => e
152
+ e.code # => '401'
153
+ e.description # => 'login param is missing'
154
+ rescue SmsTraffic::Error => e
155
+ e.message
156
+ end
92
157
  ```
93
158
 
94
159
  # Contributing
95
160
 
96
- Bug reports and pull requests are welcome on GitHub at https://github.com/golifox/sms_traffic_sdk.
161
+ Bug reports and pull requests are welcome on GitHub at https://github.com/golifox/sms_traffic_sdk.
97
162
 
98
163
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
99
164
 
@@ -0,0 +1,14 @@
1
+ require 'bigdecimal'
2
+
3
+ module SmsTraffic
4
+ class Client
5
+ class AccountReply < Reply
6
+ # @return [BigDecimal, nil] account balance as sent by the provider (units are not converted); nil when absent
7
+ # or not a number
8
+ def balance
9
+ value = fetch_value(to_h, :account).to_s.strip
10
+ BigDecimal(value, exception: false) unless value.empty?
11
+ end
12
+ end
13
+ end
14
+ end
@@ -1,62 +1,43 @@
1
1
  module SmsTraffic
2
2
  class Client
3
- class DeliverReply < Client::Reply
4
- OK_REPLY = 'OK'.freeze unless const_defined?(:OK_REPLY)
5
-
3
+ class DeliverReply < Reply
4
+ # @return [Boolean] result is OK and at least one message was queued
6
5
  def ok?
7
- result == OK_REPLY && message_infos
6
+ result == OK_RESULT && !messages.empty?
8
7
  end
9
8
 
10
9
  def error?
11
10
  !ok?
12
11
  end
13
12
 
14
- def result
15
- fetch_value(hash, :result)
16
- end
17
-
18
- def code
19
- fetch_value(hash, :code)
20
- end
21
-
22
- def description
23
- fetch_value(hash, :description)
24
- end
25
-
26
- def message_infos
27
- fetch_value(hash, :message_infos)
28
- end
29
-
30
- def sms_id(phone = nil)
31
- message_info = fetch_value(message_infos, :message_info)
32
- return fetch_value(message_info, :sms_id) if message_info.is_a?(Hash) || message_info.nil?
33
-
34
- if phone
35
- fetch_value(find_message_info(phone), :sms_id)
36
- elsif message_info.is_a?(Array)
37
- fetch_value(message_info.first, :sms_id)
13
+ # @return [Array<Hash>] queued messages as { phone:, sms_id:, push_id: }
14
+ def messages
15
+ @messages ||= message_infos.map do |info|
16
+ { phone: fetch_value(info, :phone), sms_id: fetch_value(info, :sms_id), push_id: fetch_value(info, :push_id) }
38
17
  end
39
18
  end
40
19
 
20
+ # @return [Array<String>] every sms id in reply order
41
21
  def sms_ids
42
- message_info = fetch_value(message_infos, :message_info)
43
-
44
- return [fetch_value(message_info, :sms_id)] if message_info&.size == 1
45
- return [] if message_info.nil?
22
+ messages.filter_map { |message| message[:sms_id] }
23
+ end
46
24
 
47
- message_info.each_with_object({}) do |info, hash|
48
- hash[fetch_value(info, :phone)] = fetch_value(info, :sms_id)
49
- end
25
+ # @param phone [String, nil]
26
+ # @return [String, nil] the first sms id, or the first one for the phone
27
+ def sms_id(phone = nil)
28
+ message = phone ? messages.find { |item| item[:phone].to_s == phone.to_s } : messages.first
29
+ message&.fetch(:sms_id)
50
30
  end
51
31
 
52
32
  def error_description
53
- "#{result}: code: #{code}, description: #{description}" if error?
33
+ "Sms has not been enqueued: #{description}" if error?
54
34
  end
55
35
 
56
36
  private
57
37
 
58
- def find_message_info(phone)
59
- fetch_value(message_infos, :message_info).find { |info| fetch_value(info, :phone) == phone }
38
+ def message_infos
39
+ info = fetch_value(fetch_value(to_h, :message_infos), :message_info)
40
+ info.is_a?(Array) ? info : [info].compact
60
41
  end
61
42
  end
62
43
  end
@@ -1,19 +1,40 @@
1
1
  module SmsTraffic
2
2
  class Client
3
3
  class Reply
4
- attr_reader :xml, :hash
4
+ OK_RESULT = 'OK'.freeze
5
+ ERROR_RESULT = 'ERROR'.freeze
6
+
7
+ attr_reader :xml, :to_h
5
8
 
6
9
  def initialize(xml, xml_parser: SmsTraffic.configuration.xml_parser)
7
- @xml = xml.gsub!(/\s+/, ' ')
8
- @hash = fetch_value(xml_parser.parse(xml), :reply)
10
+ @xml = xml.gsub(/\s+/, ' ')
11
+ parsed = fetch_value(xml_parser.parse(@xml), :reply)
12
+ @to_h = parsed.is_a?(Hash) ? parsed : {}
13
+ end
14
+
15
+ def result
16
+ fetch_value(to_h, :result)
17
+ end
18
+
19
+ def code
20
+ fetch_value(to_h, :code)
21
+ end
22
+
23
+ def description
24
+ fetch_value(to_h, :description)
25
+ end
26
+
27
+ # The provider documents `ERROR` but its example replies `ERRO`, so any result other than OK is an error.
28
+ def api_error?
29
+ !result.nil? && result != OK_RESULT
9
30
  end
10
31
 
11
32
  private
12
33
 
13
- def fetch_value(hash, key)
14
- hash.fetch(key.to_s, nil) || hash.fetch(key.to_sym, nil)
15
- rescue NoMethodError
16
- nil
34
+ def fetch_value(source, key)
35
+ return unless source.is_a?(Hash)
36
+
37
+ source.fetch(key.to_s) { source[key.to_sym] }
17
38
  end
18
39
  end
19
40
  end
@@ -1,43 +1,96 @@
1
1
  module SmsTraffic
2
2
  class Client
3
3
  class StatusReply < Reply
4
- def sms_array
5
- fetch_value(hash, :sms).is_a?(Array) ? fetch_value(hash, :sms) : [hash]
4
+ NOT_FOUND = /no such message/i
5
+
6
+ Entry = Struct.new(
7
+ :sms_id, :status, :status_reported, :error, :submission_date, :send_date, :last_status_change_date,
8
+ keyword_init: true
9
+ ) do
10
+ # @return [Boolean] the provider sent a status element, even an empty one (an intermediate status)
11
+ def status_reported?
12
+ status_reported == true
13
+ end
14
+
15
+ def error?
16
+ !error.nil?
17
+ end
18
+
19
+ def not_found?
20
+ error? && error.match?(NOT_FOUND)
21
+ end
22
+
23
+ def final?
24
+ Status.final?(status)
25
+ end
26
+
27
+ def delivered?
28
+ Status.delivered?(status)
29
+ end
6
30
  end
7
31
 
8
- def status(sms_id = nil)
9
- return fetch_value(sms_array.first, :status) if sms_array.size == 1
32
+ # @return [Array<Entry>]
33
+ def entries
34
+ @entries ||= entry_sources.map { |source| build_entry(source) }
35
+ end
36
+
37
+ # @param sms_id [String, nil] required unless the reply has exactly one entry
38
+ # @return [Entry, nil]
39
+ def entry(sms_id = nil)
40
+ return entries.find { |item| item.sms_id.to_s == sms_id.to_s } if sms_id
41
+ return entries.first if entries.size == 1
42
+
43
+ raise ArgumentError, 'sms_id is required'
44
+ end
10
45
 
11
- raise ArgumentError, 'sms_id is required' if sms_id.nil?
46
+ %i[status error send_date].each do |name|
47
+ define_method(name) { |sms_id = nil| entry(sms_id)&.public_send(name) }
48
+ end
12
49
 
13
- statuses[sms_id.to_s]
50
+ %i[error? not_found? final? delivered?].each do |name|
51
+ define_method(name) { |sms_id = nil| entry(sms_id)&.public_send(name) || false }
14
52
  end
15
53
 
16
- # @return [Hash] statuses hash (key: sms_id, value: status)
17
- # @example
18
- #
19
- # {
20
- # '1234567890': 'DELIVERED',
21
- # '0987654321': 'FAILED'
22
- # }
54
+ # @return [Hash{String => String}] sms_id => status, without nil values
23
55
  def statuses
24
- sms_array.each_with_object({}) do |sms, hash|
25
- hash[fetch_value(sms, :sms_id)] = fetch_value(sms, :status)
26
- end.compact
56
+ entries.to_h { |item| [item.sms_id, item.status] }.compact
27
57
  end
28
58
 
29
- def error(sms_id = nil)
30
- return fetch_value(sms_array.first, :error) if sms_array.size == 1
59
+ # @return [Hash{String => String}] sms_id => error, without nil values
60
+ def errors
61
+ entries.to_h { |item| [item.sms_id, item.error] }.compact
62
+ end
31
63
 
32
- raise ArgumentError, 'sms_id is required' if sms_id.nil?
64
+ private
33
65
 
34
- errors[sms_id.to_s]
66
+ def entry_sources
67
+ sms = fetch_value(to_h, :sms)
68
+ return sms if sms.is_a?(Array)
69
+ return [sms] if sms.is_a?(Hash)
70
+
71
+ to_h.empty? ? [] : [to_h]
35
72
  end
36
73
 
37
- def errors
38
- sms_array.each_with_object({}) do |sms, hash|
39
- hash[fetch_value(sms, :sms_id)] = fetch_value(sms, :error)&.strip
40
- end.compact
74
+ def key?(source, key)
75
+ source.is_a?(Hash) && (source.key?(key.to_s) || source.key?(key.to_sym))
76
+ end
77
+
78
+ def blank_to_nil(value)
79
+ stripped = value&.strip
80
+ stripped unless stripped.nil? || stripped.empty?
81
+ end
82
+
83
+ def build_entry(source)
84
+ Entry.new(
85
+ sms_id: fetch_value(source, :sms_id),
86
+ status: fetch_value(source, :status),
87
+ status_reported: key?(source, :status),
88
+ error: blank_to_nil(fetch_value(source, :error)),
89
+ # The provider spells this field "submition_date".
90
+ submission_date: fetch_value(source, :submition_date),
91
+ send_date: fetch_value(source, :send_date),
92
+ last_status_change_date: fetch_value(source, :last_status_change_date)
93
+ )
41
94
  end
42
95
  end
43
96
  end