smsru_ruby 1.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.
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Ruby client for the SMS.ru HTTP API (https://sms.ru/api).
4
+ #
5
+ # client = SmsRu.new("YOUR_API_ID")
6
+ # client.deliver("79991234567", "Hello!")
7
+ class SmsRu
8
+ # Base URL of the SMS.ru HTTP API.
9
+ BASE_URL = "https://sms.ru"
10
+
11
+ # Transport-level exceptions that warrant a retry.
12
+ RETRIABLE = [
13
+ Net::OpenTimeout, Net::ReadTimeout, IOError, EOFError, SocketError,
14
+ Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH, OpenSSL::SSL::SSLError
15
+ ].freeze
16
+
17
+ # @param api_id [String] your SMS.ru API id
18
+ # @param timeout [Integer] open/read timeout in seconds
19
+ # @param test [Boolean] when true, every deliver defaults to test mode (no charge)
20
+ # @param retries [Integer] retry attempts on transport failure (0 disables; PHP default is 5)
21
+ # @param from [String, nil] default sender name for #deliver (overridable per call)
22
+ # @param logger [Logger, nil] optional logger; logs the request path and transport
23
+ # failures only — never the api_id, phone numbers, or message text
24
+ def initialize(api_id, timeout: 30, test: false, retries: 5, from: nil, logger: nil)
25
+ @api_id = api_id
26
+ @timeout = timeout
27
+ @test = test
28
+ @retries = retries
29
+ @from = from
30
+ @logger = logger
31
+ end
32
+
33
+ # Sends a message.
34
+ #
35
+ # @param to [String, Array<String>, Hash{String => String}] recipient(s):
36
+ # a String for one number, an Array for the same text to many numbers, or a
37
+ # Hash of `number => text` pairs for a different text per number.
38
+ # @param text [String, nil] the message text; required for the String/Array
39
+ # forms, must be omitted for the Hash form
40
+ # @param from [String, nil] an approved sender name
41
+ # @param time [Integer, nil] schedule the send at this UNIX timestamp
42
+ # @param ttl [Integer, nil] message lifetime in minutes (1–1440); undelivered
43
+ # messages are discarded after this period
44
+ # @param daytime [Boolean] when true, defer night-time sends to the recipient's daytime
45
+ # @param translit [Boolean] transliterate Cyrillic to Latin
46
+ # @param test [Boolean, nil] override the client's global test mode for this call
47
+ # @param ip [String, nil] the end-user IP (anti-fraud for auth codes)
48
+ # @param partner_id [Integer, nil] partner program id
49
+ # @return [SmsRu::SendResult]
50
+ # @raise [ArgumentError] if `text` is missing (String/Array) or given (Hash)
51
+ # @raise [SmsRu::ResponseError] if SMS.ru rejects the whole request
52
+ # @example Send one message
53
+ # client.deliver("79991234567", "Hello!")
54
+ # @example Per-number text
55
+ # client.deliver({ "79991234567" => "Hi Alice", "79991234568" => "Hi Bob" })
56
+ def deliver(to, text = nil, from: nil, time: nil, ttl: nil, daytime: false,
57
+ translit: false, test: nil, ip: nil, partner_id: nil)
58
+ params = { from: from || @from, time:, ttl:, ip:, partner_id: }.compact
59
+ params[:translit] = 1 if translit
60
+ params[:daytime] = 1 if daytime
61
+ params[:test] = 1 if test.nil? ? @test : test
62
+ add_recipients(params, to, text)
63
+ SendResult.build(request("/sms/send", **params))
64
+ end
65
+
66
+ # Returns the cost and SMS count for a message without sending it.
67
+ #
68
+ # @param to [String, Array<String>] recipient(s)
69
+ # @param text [String, nil] the message text (omit for the price of one SMS)
70
+ # @param translit [Boolean] transliterate Cyrillic to Latin
71
+ # @return [SmsRu::Cost]
72
+ # @raise [SmsRu::ResponseError] if SMS.ru rejects the request
73
+ # @example
74
+ # client.cost("79991234567", "How much?").total_cost
75
+ def cost(to, text = nil, translit: false)
76
+ # @type var params: Hash[Symbol, untyped]
77
+ params = { to: Array(to).join(","), text: text }.compact
78
+ params[:translit] = 1 if translit
79
+ Cost.build(request("/sms/cost", **params))
80
+ end
81
+
82
+ # Delivery status for one id or an Array of ids.
83
+ #
84
+ # @param sms_id [String, Array<String>] one message id or an Array of ids
85
+ # @return [SmsRu::Status, Array<SmsRu::Status>] a single Status for a String
86
+ # argument, or an Array of Status for an Array argument
87
+ # @raise [SmsRu::ResponseError] if SMS.ru rejects the request
88
+ def status(sms_id)
89
+ statuses = Status.build_all(request("/sms/status", sms_id: Array(sms_id).join(",")))
90
+ sms_id.is_a?(Array) ? statuses : statuses.first
91
+ end
92
+
93
+ # Requests a flash-call verification: SMS.ru calls the number; the last 4
94
+ # digits of the calling number (returned as `code`) are the code the user enters.
95
+ #
96
+ # @param phone [String, Integer] the number to call
97
+ # @param ip [String] the end-user IP (anti-fraud); "-1" for manual/local requests
98
+ # @param partner_id [Integer, nil] partner program id
99
+ # @return [SmsRu::Call]
100
+ # @raise [SmsRu::ResponseError] if SMS.ru rejects the request
101
+ def call(phone, ip: "-1", partner_id: nil)
102
+ Call.build(request("/code/call", **{ phone: phone.to_s, ip:, partner_id: }.compact))
103
+ end
104
+
105
+ # @return [SmsRu::My] the account-info sub-resource (balance, limit, free_limit, senders)
106
+ def my = @my ||= My.new(method(:request))
107
+
108
+ # @return [SmsRu::Auth] the authentication sub-resource
109
+ def auth = @auth ||= Auth.new(method(:request))
110
+
111
+ # @return [SmsRu::Stoplist] the stoplist sub-resource
112
+ def stoplist = @stoplist ||= Stoplist.new(method(:request))
113
+
114
+ # @return [SmsRu::Callbacks] the callbacks sub-resource
115
+ def callbacks = @callbacks ||= Callbacks.new(method(:request))
116
+
117
+ # @return [SmsRu::CallCheck] the call-check (incoming-call auth) sub-resource
118
+ def callcheck = @callcheck ||= CallCheck.new(method(:request))
119
+
120
+ private
121
+
122
+ def add_recipients(params, to, text)
123
+ if to.is_a?(Hash)
124
+ raise ArgumentError, "do not pass `text` when `to` is a Hash of number => text" unless text.nil?
125
+
126
+ to.each { |phone, message| params[:"multi[#{phone}]"] = message }
127
+ else
128
+ raise ArgumentError, "`text` is required" if text.nil?
129
+
130
+ params[:to] = Array(to).join(",")
131
+ params[:msg] = text
132
+ end
133
+ end
134
+
135
+ def request(path, **params)
136
+ params[:api_id] = @api_id unless Coerce.string(params[:api_id]) == "none"
137
+ @logger&.debug("[SmsRu] POST #{path}")
138
+ uri = URI("#{BASE_URL}#{path}?json=1") #: URI::HTTP
139
+ perform(uri, URI.encode_www_form(params))
140
+ end
141
+
142
+ def perform(uri, body)
143
+ attempts = 0
144
+ begin
145
+ attempts += 1
146
+ response = http(uri).post(uri.request_uri, body, "Content-Type" => "application/x-www-form-urlencoded")
147
+ parse(response.body)
148
+ rescue *RETRIABLE => e
149
+ error = e #: StandardError
150
+ @logger&.warn("[SmsRu] #{uri.path} failed (attempt #{attempts}): #{error.message}")
151
+ retry if attempts <= @retries
152
+
153
+ raise ConnectionError, "Cannot reach SMS.ru: #{error.message}"
154
+ end
155
+ end
156
+
157
+ def http(uri)
158
+ host = uri.host #: String
159
+ http = Net::HTTP.new(host, uri.port)
160
+ http.use_ssl = true
161
+ http.open_timeout = @timeout
162
+ http.read_timeout = @timeout
163
+ http
164
+ end
165
+
166
+ def parse(raw)
167
+ data = Coerce.records(JSON.parse(raw.to_s))
168
+ status = Coerce.string?(data["status"])
169
+ raise ConnectionError, "Malformed response from SMS.ru" unless status
170
+ return data if status == "OK"
171
+
172
+ raise error_for(data)
173
+ rescue JSON::ParserError
174
+ raise ConnectionError, "Invalid JSON from SMS.ru"
175
+ end
176
+
177
+ def error_for(data)
178
+ code = Coerce.integer(data["status_code"])
179
+ text = Coerce.string(data["status_text"], "SMS.ru returned an error")
180
+ error_class(code).new(code: code, text: text)
181
+ end
182
+
183
+ def error_class(code)
184
+ case code
185
+ when 200, 300, 301, 302 then AuthError
186
+ when 201 then InsufficientFundsError
187
+ else ResponseError
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ class SmsRu
4
+ # Normalizes loosely-typed SMS.ru JSON values into the types the result
5
+ # objects declare. SMS.ru is inconsistent on the wire — `/my/limit` returns
6
+ # `total_limit` as the string `"10"` but `used_today` as the number `0`, and
7
+ # some counters arrive as `null` — so each field is coerced here rather than
8
+ # trusted as-is.
9
+ #
10
+ # Each type has two helpers: the `?` variant returns nil for a
11
+ # missing/blank/unparseable value (for the nullable fields), while the plain
12
+ # variant falls back to a default (`""`/`0`/`0.0`, overridable) for the fields
13
+ # the API always populates — so call sites declare their nullability by name.
14
+ #
15
+ # @api private
16
+ module Coerce
17
+ module_function
18
+
19
+ # @param value [Object, nil] a raw JSON value
20
+ # @return [String, nil] the value stringified, or nil when absent
21
+ # `?` marks the nullable variant, not a boolean predicate, so nil is intended.
22
+ def string?(value) = value ? String(value) : nil # rubocop:disable Style/ReturnNilInPredicateMethodDefinition
23
+
24
+ # @param value [Object, nil] a raw JSON value
25
+ # @param default [String] returned when the value is absent
26
+ # @return [String] the value stringified, or the default
27
+ def string(value, default = "") = string?(value) || default
28
+
29
+ # @param value [Object, nil] a raw JSON value (number or numeric string)
30
+ # @return [Integer, nil] the parsed integer, or nil when absent/unparseable
31
+ def integer?(value) = Integer(value, exception: false)
32
+
33
+ # @param value [Object, nil] a raw JSON value (number or numeric string)
34
+ # @param default [Integer] returned when the value is absent/unparseable
35
+ # @return [Integer] the parsed integer, or the default
36
+ def integer(value, default = 0) = integer?(value) || default
37
+
38
+ # @param value [Object, nil] a raw JSON value (number or numeric string)
39
+ # @return [Float, nil] the parsed float, or nil when absent/unparseable
40
+ def float?(value) = Float(value, exception: false)
41
+
42
+ # @param value [Object, nil] a raw JSON value (number or numeric string)
43
+ # @param default [Float] returned when the value is absent/unparseable
44
+ # @return [Float] the parsed float, or the default
45
+ def float(value, default = 0.0) = float?(value) || default
46
+
47
+ # @param value [Object, nil] a raw JSON value expected to be an object
48
+ # @return [Hash] the value when it is a Hash, otherwise an empty Hash
49
+ def records(value) = Hash.try_convert(value) || {}
50
+ end
51
+ end
@@ -0,0 +1,263 @@
1
+ # frozen_string_literal: true
2
+
3
+ class SmsRu
4
+ # Collection helpers shared by results that wrap a `messages` array of
5
+ # per-recipient entries (each responding to #ok?): SmsRu::SendResult and
6
+ # SmsRu::Cost. There is intentionally no lookup-by-phone, since one request may
7
+ # target the same number more than once.
8
+ module MessageCollection
9
+ # @return [Boolean] true when every recipient succeeded
10
+ def ok? = messages.all?(&:ok?)
11
+
12
+ # @return [Array] the recipient entries that succeeded
13
+ def ok = messages.select(&:ok?)
14
+
15
+ # @return [Array] the recipient entries that failed
16
+ def failed = messages.reject(&:ok?)
17
+ end
18
+
19
+ # A single message inside a send response (one entry of the `sms` object).
20
+ # `error_code`/`error_text` are populated only when this recipient was rejected.
21
+ #
22
+ # @!attribute [r] phone
23
+ # @return [String] the recipient's phone number
24
+ # @!attribute [r] sms_id
25
+ # @return [String, nil] the message id assigned by SMS.ru (nil when rejected)
26
+ # @!attribute [r] error_code
27
+ # @return [Integer, nil] the rejection code, or nil when accepted
28
+ # @!attribute [r] error_text
29
+ # @return [String, nil] the rejection reason, or nil when accepted
30
+ class Sms < Data.define(:phone, :sms_id, :error_code, :error_text)
31
+ # @param phone [String] the recipient's phone number
32
+ # @param hash [Hash] one entry of the response `sms` object
33
+ # @return [SmsRu::Sms]
34
+ def self.build(phone, hash)
35
+ ok = Coerce.string(hash["status"]) == "OK"
36
+ new(
37
+ phone: String(phone),
38
+ sms_id: Coerce.string?(hash["sms_id"]),
39
+ error_code: ok ? nil : Coerce.integer(hash["status_code"]),
40
+ error_text: ok ? nil : Coerce.string(hash["status_text"])
41
+ )
42
+ end
43
+
44
+ # @return [Boolean] true when this recipient was accepted
45
+ def ok? = error_code.nil?
46
+ end
47
+
48
+ # Result of SmsRu#deliver. `messages` holds one Sms per recipient; individual
49
+ # recipients may have failed even when the overall request succeeded (use #ok?
50
+ # or #failed to tell).
51
+ #
52
+ # @!attribute [r] balance
53
+ # @return [Float] the account balance after the request
54
+ # @!attribute [r] messages
55
+ # @return [Array<SmsRu::Sms>] one entry per recipient
56
+ class SendResult < Data.define(:balance, :messages)
57
+ include MessageCollection
58
+
59
+ # @param hash [Hash] the parsed /sms/send response
60
+ # @return [SmsRu::SendResult]
61
+ def self.build(hash)
62
+ messages = Coerce.records(hash["sms"]).map { |phone, sms| Sms.build(phone, sms) }
63
+ new(balance: Coerce.float(hash["balance"]), messages: messages)
64
+ end
65
+ end
66
+
67
+ # Delivery status of one message (one entry of a /sms/status response).
68
+ # `status_code` is the message's delivery state; read it with #delivered?,
69
+ # #pending?, #failed?, or the SmsRu::Statuses constants.
70
+ #
71
+ # @!attribute [r] sms_id
72
+ # @return [String] the message id
73
+ # @!attribute [r] status_code
74
+ # @return [Integer] the delivery state code (see SmsRu::Statuses)
75
+ # @!attribute [r] status_text
76
+ # @return [String] the human-readable delivery state
77
+ # @!attribute [r] cost
78
+ # @return [Float, nil] the message cost, when present
79
+ class Status < Data.define(:sms_id, :status_code, :status_text, :cost)
80
+ include DeliveryStatus
81
+
82
+ # @param sms_id [String] the message id
83
+ # @param hash [Hash] one entry of the response `sms` object
84
+ # @return [SmsRu::Status]
85
+ def self.build(sms_id, hash)
86
+ new(
87
+ sms_id: String(sms_id),
88
+ status_code: Coerce.integer(hash["status_code"], Statuses::NOT_FOUND),
89
+ status_text: Coerce.string(hash["status_text"]),
90
+ cost: Coerce.float?(hash["cost"])
91
+ )
92
+ end
93
+
94
+ # @param hash [Hash] the parsed /sms/status response
95
+ # @return [Array<SmsRu::Status>] one Status per requested id
96
+ def self.build_all(hash) = Coerce.records(hash["sms"]).map { |sms_id, sms| build(sms_id, sms) }
97
+
98
+ # @return [Boolean] true when the queried id exists (not status code -1)
99
+ def found? = status_code != Statuses::NOT_FOUND
100
+ end
101
+
102
+ # Per-recipient cost (one entry of a /sms/cost response).
103
+ # `error_code`/`error_text` are populated only when this recipient cannot be priced.
104
+ #
105
+ # @!attribute [r] phone
106
+ # @return [String] the recipient's phone number
107
+ # @!attribute [r] cost
108
+ # @return [Float, nil] the price for this recipient, or nil when it errored
109
+ # @!attribute [r] sms_count
110
+ # @return [Integer, nil] the number of SMS segments, or nil when it errored
111
+ # @!attribute [r] error_code
112
+ # @return [Integer, nil] the error code, or nil when priced
113
+ # @!attribute [r] error_text
114
+ # @return [String, nil] the error reason, or nil when priced
115
+ class CostItem < Data.define(:phone, :cost, :sms_count, :error_code, :error_text)
116
+ # @param phone [String] the recipient's phone number
117
+ # @param hash [Hash] one entry of the response `sms` object
118
+ # @return [SmsRu::CostItem]
119
+ def self.build(phone, hash)
120
+ ok = Coerce.string(hash["status"]) == "OK"
121
+ new(
122
+ phone: String(phone),
123
+ cost: Coerce.float?(hash["cost"]),
124
+ sms_count: Coerce.integer?(hash["sms"]),
125
+ error_code: ok ? nil : Coerce.integer(hash["status_code"]),
126
+ error_text: ok ? nil : Coerce.string(hash["status_text"])
127
+ )
128
+ end
129
+
130
+ # @return [Boolean] true when this recipient was priced successfully
131
+ def ok? = error_code.nil?
132
+ end
133
+
134
+ # Result of SmsRu#cost.
135
+ #
136
+ # @!attribute [r] total_cost
137
+ # @return [Float] the total price across all recipients
138
+ # @!attribute [r] total_sms
139
+ # @return [Integer] the total number of SMS segments
140
+ # @!attribute [r] messages
141
+ # @return [Array<SmsRu::CostItem>] one entry per recipient
142
+ class Cost < Data.define(:total_cost, :total_sms, :messages)
143
+ include MessageCollection
144
+
145
+ # @param hash [Hash] the parsed /sms/cost response
146
+ # @return [SmsRu::Cost]
147
+ def self.build(hash)
148
+ messages = Coerce.records(hash["sms"]).map { |phone, cost| CostItem.build(phone, cost) }
149
+ new(
150
+ total_cost: Coerce.float(hash["total_cost"]),
151
+ total_sms: Coerce.integer(hash["total_sms"]),
152
+ messages: messages
153
+ )
154
+ end
155
+ end
156
+
157
+ # Result of SmsRu#call (flash call). `code` is the last 4 digits of the number
158
+ # that calls the user — what they read off the incoming call and enter.
159
+ #
160
+ # @!attribute [r] code
161
+ # @return [String] the 4-digit code (the calling number's last 4 digits)
162
+ # @!attribute [r] call_id
163
+ # @return [String] the call id assigned by SMS.ru
164
+ # @!attribute [r] cost
165
+ # @return [Float] the price of the call
166
+ # @!attribute [r] balance
167
+ # @return [Float] the account balance after the call
168
+ class Call < Data.define(:code, :call_id, :cost, :balance)
169
+ # @param hash [Hash] the parsed /code/call response
170
+ # @return [SmsRu::Call]
171
+ def self.build(hash)
172
+ new(
173
+ code: Coerce.string(hash["code"]),
174
+ call_id: Coerce.string(hash["call_id"]),
175
+ cost: Coerce.float(hash["cost"]),
176
+ balance: Coerce.float(hash["balance"])
177
+ )
178
+ end
179
+ end
180
+
181
+ # Result of SmsRu::My#limit (daily sending limit).
182
+ #
183
+ # @!attribute [r] total_limit
184
+ # @return [Integer] the daily limit
185
+ # @!attribute [r] used_today
186
+ # @return [Integer] the number of messages sent today
187
+ class Limit < Data.define(:total_limit, :used_today)
188
+ # @param hash [Hash] the parsed /my/limit response
189
+ # @return [SmsRu::Limit]
190
+ def self.build(hash)
191
+ new(total_limit: Coerce.integer(hash["total_limit"]), used_today: Coerce.integer(hash["used_today"]))
192
+ end
193
+
194
+ # @return [Integer] how many more messages can be sent today
195
+ def available_today = total_limit - used_today
196
+ end
197
+
198
+ # Result of SmsRu::My#free_limit (free daily messages).
199
+ #
200
+ # @!attribute [r] total_free
201
+ # @return [Integer] the daily allowance of free messages
202
+ # @!attribute [r] used_today
203
+ # @return [Integer] the number used today (0 when the API omits it)
204
+ class FreeLimit < Data.define(:total_free, :used_today)
205
+ # @param hash [Hash] the parsed /my/free response
206
+ # @return [SmsRu::FreeLimit]
207
+ def self.build(hash)
208
+ new(total_free: Coerce.integer(hash["total_free"]), used_today: Coerce.integer(hash["used_today"]))
209
+ end
210
+
211
+ # @return [Integer] how many free messages remain today
212
+ def available_today = total_free - used_today
213
+ end
214
+
215
+ # Result of SmsRu::CallCheck#add — the number the user must call to authorize.
216
+ #
217
+ # @!attribute [r] check_id
218
+ # @return [String] the check id to poll with SmsRu::CallCheck#status
219
+ # @!attribute [r] call_phone
220
+ # @return [String] the number the user must call
221
+ # @!attribute [r] call_phone_pretty
222
+ # @return [String] the same number, formatted for display
223
+ # @!attribute [r] call_phone_html
224
+ # @return [String] a mobile-clickable `tel:` link for the number
225
+ class CallCheckResult < Data.define(:check_id, :call_phone, :call_phone_pretty, :call_phone_html)
226
+ # @param hash [Hash] the parsed /callcheck/add response
227
+ # @return [SmsRu::CallCheckResult]
228
+ def self.build(hash)
229
+ new(
230
+ check_id: Coerce.string(hash["check_id"]),
231
+ call_phone: Coerce.string(hash["call_phone"]),
232
+ call_phone_pretty: Coerce.string(hash["call_phone_pretty"]),
233
+ call_phone_html: Coerce.string(hash["call_phone_html"])
234
+ )
235
+ end
236
+ end
237
+
238
+ # Result of SmsRu::CallCheck#status — whether the authorizing call has arrived.
239
+ #
240
+ # @!attribute [r] status_code
241
+ # @return [Integer] the check status code (401 once confirmed)
242
+ # @!attribute [r] status_text
243
+ # @return [String] the human-readable status
244
+ class CallCheckStatus < Data.define(:status_code, :status_text)
245
+ # @param hash [Hash] the parsed /callcheck/status response
246
+ # @return [SmsRu::CallCheckStatus]
247
+ def self.build(hash)
248
+ new(status_code: Coerce.integer(hash["check_status"]), status_text: Coerce.string(hash["check_status_text"]))
249
+ end
250
+
251
+ # @return [Boolean] true once the user has placed the authorizing call
252
+ def confirmed? = status_code == 401
253
+ end
254
+
255
+ # One stoplist entry returned by SmsRu::Stoplist#list.
256
+ #
257
+ # @!attribute [r] phone
258
+ # @return [String] the stoplisted phone number
259
+ # @!attribute [r] note
260
+ # @return [String] the note you stored with the number
261
+ class StoplistEntry < Data.define(:phone, :note)
262
+ end
263
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ class SmsRu
4
+ # Base class for every error raised by the gem. Rescue this to catch them all.
5
+ class Error < StandardError; end
6
+
7
+ # Raised when SMS.ru cannot be reached or returns an unparseable body
8
+ # (network failure, timeout, invalid JSON). Retried up to `retries` times first.
9
+ class ConnectionError < Error; end
10
+
11
+ # Raised when SMS.ru replies with a non-OK status. Carries the API's numeric
12
+ # `code` and human-readable `text` (status_text).
13
+ #
14
+ # @!attribute [r] code
15
+ # @return [Integer] the SMS.ru numeric status code
16
+ # @!attribute [r] text
17
+ # @return [String] the human-readable status text
18
+ class ResponseError < Error
19
+ attr_reader :code, :text
20
+
21
+ # @param code [Integer] the SMS.ru numeric status code
22
+ # @param text [String] the human-readable status text
23
+ def initialize(code:, text:)
24
+ @code = code
25
+ @text = text
26
+ super("[#{code}] #{text}")
27
+ end
28
+ end
29
+
30
+ # Invalid api_id / token / unconfirmed account (codes 200, 300, 301, 302).
31
+ class AuthError < ResponseError; end
32
+
33
+ # Not enough money on the account (code 201).
34
+ class InsufficientFundsError < ResponseError; end
35
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ class SmsRu
4
+ # Typed events decoded from an inbound SMS.ru webhook payload. One of these is
5
+ # produced per record by SmsRu::Webhook.parse.
6
+ module Events
7
+ # A delivery-status notification (record type "sms_status").
8
+ #
9
+ # @!attribute [r] id
10
+ # @return [String] the message id
11
+ # @!attribute [r] status_code
12
+ # @return [Integer, nil] the delivery status code (per /sms/status)
13
+ # @!attribute [r] created_at
14
+ # @return [Time, nil] when SMS.ru created the status
15
+ # @!attribute [r] raw
16
+ # @return [Array<String>] every line of the original record
17
+ class SmsStatus < Data.define(:id, :status_code, :created_at, :raw)
18
+ include DeliveryStatus
19
+
20
+ # @return [String] the wire record type
21
+ def type = "sms_status"
22
+ end
23
+
24
+ # A call-authorization notification (record type "callcheck_status").
25
+ #
26
+ # @!attribute [r] id
27
+ # @return [String] the check id (the one returned by SmsRu::CallCheck#add)
28
+ # @!attribute [r] status_code
29
+ # @return [Integer, nil] the check status code (per /callcheck/status)
30
+ # @!attribute [r] created_at
31
+ # @return [Time, nil] when SMS.ru created the status
32
+ # @!attribute [r] raw
33
+ # @return [Array<String>] every line of the original record
34
+ class CallcheckStatus < Data.define(:id, :status_code, :created_at, :raw)
35
+ # @return [String] the wire record type
36
+ def type = "callcheck_status"
37
+
38
+ # @return [Boolean] true when the user placed the authorizing call (code 401)
39
+ def confirmed? = status_code == 401
40
+
41
+ # @return [Boolean] true when the authorization window elapsed (code 402)
42
+ def expired? = status_code == 402
43
+ end
44
+
45
+ # SMS.ru's periodic heartbeat record (record type "test").
46
+ #
47
+ # @!attribute [r] created_at
48
+ # @return [Time, nil] when SMS.ru sent the heartbeat
49
+ # @!attribute [r] raw
50
+ # @return [Array<String>] every line of the original record
51
+ class Test < Data.define(:created_at, :raw)
52
+ # @return [String] the wire record type
53
+ def type = "test"
54
+ end
55
+
56
+ # Any record type this gem does not model explicitly. `raw` keeps every line.
57
+ #
58
+ # @!attribute [r] type
59
+ # @return [String] the wire record type
60
+ # @!attribute [r] raw
61
+ # @return [Array<String>] every line of the original record
62
+ class Unknown < Data.define(:type, :raw)
63
+ end
64
+ end
65
+ end
data/lib/sms_ru/my.rb ADDED
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ class SmsRu
4
+ # Account information: balance, daily limit, free quota, and approved sender
5
+ # names. Reached via SmsRu#my, e.g. `client.my.balance`.
6
+ class My
7
+ # @api private
8
+ # @param request [Method] the client's bound `request` method
9
+ def initialize(request)
10
+ @request = request
11
+ end
12
+
13
+ # @return [Float] the current account balance, in rubles
14
+ # @raise [SmsRu::ResponseError] if SMS.ru rejects the request
15
+ def balance = @request.call("/my/balance")["balance"]
16
+
17
+ # @return [SmsRu::Limit] the daily sending limit and today's usage
18
+ # @raise [SmsRu::ResponseError] if SMS.ru rejects the request
19
+ def limit = Limit.build(@request.call("/my/limit"))
20
+
21
+ # @return [SmsRu::FreeLimit] the free-message allowance and today's usage
22
+ # @raise [SmsRu::ResponseError] if SMS.ru rejects the request
23
+ def free_limit = FreeLimit.build(@request.call("/my/free"))
24
+
25
+ # @return [Array<String>] the approved sender names on the account
26
+ # @raise [SmsRu::ResponseError] if SMS.ru rejects the request
27
+ def senders = @request.call("/my/senders")["senders"] || []
28
+ end
29
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ class SmsRu
4
+ # Delivery status codes returned by /sms/status and carried in `sms_status`
5
+ # webhooks. See https://sms.ru/api/status for the authoritative list.
6
+ module Statuses
7
+ # The message id was not found.
8
+ NOT_FOUND = -1
9
+ # Accepted and waiting in the SMS.ru queue.
10
+ QUEUED = 100
11
+ # Being transmitted to the mobile operator.
12
+ SENT_TO_OPERATOR = 101
13
+ # Handed to the operator; in transit to the handset.
14
+ IN_TRANSIT = 102
15
+ # Delivered to the handset.
16
+ DELIVERED = 103
17
+ # Not delivered: the message's time-to-live expired.
18
+ EXPIRED = 104
19
+ # Not delivered: deleted by the operator.
20
+ DELETED = 105
21
+ # Not delivered: handset malfunction.
22
+ PHONE_FAILURE = 106
23
+ # Not delivered: unknown reason.
24
+ UNKNOWN_FAILURE = 107
25
+ # Not delivered: rejected by the operator.
26
+ REJECTED = 108
27
+ # Delivered and read (where the channel reports it).
28
+ READ = 110
29
+ # Not delivered: no route to the number.
30
+ NO_ROUTE = 150
31
+
32
+ # Codes for a message still on its way (no final outcome yet).
33
+ PENDING = [QUEUED, SENT_TO_OPERATOR, IN_TRANSIT].freeze
34
+ # Codes for a message that will not be delivered.
35
+ FAILED = [EXPIRED, DELETED, PHONE_FAILURE, UNKNOWN_FAILURE, REJECTED, NO_ROUTE].freeze
36
+ end
37
+
38
+ # Delivery-state predicates shared by SmsRu::Status and
39
+ # SmsRu::Events::SmsStatus. The including object must expose `status_code`.
40
+ module DeliveryStatus
41
+ # @return [Boolean] true once the message reached the handset (code 103)
42
+ def delivered? = status_code == Statuses::DELIVERED
43
+
44
+ # @return [Boolean] true while the message is still in transit (codes 100–102)
45
+ def pending? = !status_code.nil? && Statuses::PENDING.include?(status_code)
46
+
47
+ # @return [Boolean] true when the message will not be delivered (codes 104–108, 150)
48
+ def failed? = !status_code.nil? && Statuses::FAILED.include?(status_code)
49
+ end
50
+ end