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.
- checksums.yaml +7 -0
- data/.yardopts +6 -0
- data/CHANGELOG.md +42 -0
- data/LICENSE.txt +21 -0
- data/README.md +376 -0
- data/lib/sms_ru/auth.rb +21 -0
- data/lib/sms_ru/call_check.rb +32 -0
- data/lib/sms_ru/callbacks.rb +38 -0
- data/lib/sms_ru/client.rb +190 -0
- data/lib/sms_ru/coerce.rb +51 -0
- data/lib/sms_ru/data.rb +263 -0
- data/lib/sms_ru/errors.rb +35 -0
- data/lib/sms_ru/events.rb +65 -0
- data/lib/sms_ru/my.rb +29 -0
- data/lib/sms_ru/statuses.rb +50 -0
- data/lib/sms_ru/stoplist.rb +43 -0
- data/lib/sms_ru/version.rb +6 -0
- data/lib/sms_ru/webhook.rb +74 -0
- data/lib/smsru_ruby.rb +19 -0
- data/sig/manifest.yaml +9 -0
- data/sig/sms_ru/auth.rbs +9 -0
- data/sig/sms_ru/call_check.rbs +10 -0
- data/sig/sms_ru/callbacks.rbs +15 -0
- data/sig/sms_ru/client.rbs +53 -0
- data/sig/sms_ru/coerce.rbs +15 -0
- data/sig/sms_ru/data.rbs +141 -0
- data/sig/sms_ru/errors.rbs +25 -0
- data/sig/sms_ru/events.rbs +49 -0
- data/sig/sms_ru/my.rbs +12 -0
- data/sig/sms_ru/statuses.rbs +35 -0
- data/sig/sms_ru/stoplist.rbs +11 -0
- data/sig/sms_ru/version.rbs +3 -0
- data/sig/sms_ru/webhook.rbs +17 -0
- data/smsru_ruby.gemspec +29 -0
- metadata +81 -0
|
@@ -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
|
data/lib/sms_ru/data.rb
ADDED
|
@@ -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
|