Class: SmsPilot::Client

Inherits:
Object
  • Object
show all
Defined in:
lib/sms_pilot/client.rb

Constant Summary collapse

API_ENDPOINT =

Check current API endpoint URL at https://smspilot.ru/apikey.php#api1

"https://smspilot.ru/api.php".freeze
AVAILABLE_LOCALES =

Locale influences only the language of API errors

[:ru, :en].freeze
REQUEST_ACCEPT_FORMAT =
"json".freeze
REQUEST_CHARSET =
"utf-8".freeze

Instance Attribute Summary collapse

Main collapse

State accessors collapse

Validations collapse

Instance Method Summary collapse

Constructor Details

#initialize(api_key:, locale: AVAILABLE_LOCALES[0]) ⇒ SmsPilot::Client

Note:

Current development API key is "XXXXXXXXXXXXYYYYYYYYYYYYZZZZZZZZXXXXXXXXXXXXYYYYYYYYYYYYZZZZZZZZ"

Examples:

client = SmsPilot::Client.new(api_key: ENV["SMS_PILOT_API_KEY"])
client = SmsPilot::Client.new(api_key: ENV["SMS_PILOT_API_KEY"], locale: :en)

Parameters:

  • api_key (String)
  • locale (Symbol) (defaults to: AVAILABLE_LOCALES[0])

Raises:

See Also:


106
107
108
109
110
111
112
113
# File 'lib/sms_pilot/client.rb', line 106

def initialize(api_key:, locale: AVAILABLE_LOCALES[0])
  @api_key          = validate_api_key!(api_key)
  @error            = nil
  @locale           = validate_locale!(locale)
  @response_status  = nil
  @response_headers = {}
  @response_body    = nil
end

Instance Attribute Details

#api_keyString (readonly)

Returns your API key.

Examples:

client.api_key #=> "XXX..."

Returns:

  • (String)

    your API key


69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
# File 'lib/sms_pilot/client.rb', line 69

class Client

  # Check current API endpoint URL at {https://smspilot.ru/apikey.php#api1}
  #
  API_ENDPOINT = "https://smspilot.ru/api.php".freeze

  # Locale influences only the language of API errors
  #
  AVAILABLE_LOCALES = [:ru, :en].freeze

  REQUEST_ACCEPT_FORMAT = "json".freeze
  REQUEST_CHARSET = "utf-8".freeze

  attr_reader :api_key
  attr_reader :error
  attr_reader :locale
  attr_reader :phone
  attr_reader :response_body
  attr_reader :response_headers
  attr_reader :response_status


  # @param [String] api_key
  # @param [Symbol] locale
  #
  # @return [SmsPilot::Client]
  # @raise [SmsPilot::InvalidAPIkeyError] if you pass anything but a non-empty String
  # @raise [SmsPilot::InvalidLocaleError] if you pass anything but <tt>:ru</tt> or <tt>:en</tt>
  #
  # @see https://smspilot.ru/my-settings.php Get your production API key here
  # @see https://smspilot.ru/apikey.php Get your development API key here
  # @note Current development API key is <tt>"XXXXXXXXXXXXYYYYYYYYYYYYZZZZZZZZXXXXXXXXXXXXYYYYYYYYYYYYZZZZZZZZ"</tt>
  #
  # @example
  #   client = SmsPilot::Client.new(api_key: ENV["SMS_PILOT_API_KEY"])
  #   client = SmsPilot::Client.new(api_key: ENV["SMS_PILOT_API_KEY"], locale: :en)
  #
  def initialize(api_key:, locale: AVAILABLE_LOCALES[0])
    @api_key          = validate_api_key!(api_key)
    @error            = nil
    @locale           = validate_locale!(locale)
    @response_status  = nil
    @response_headers = {}
    @response_body    = nil
  end


  # @!group Main

  # Send HTTP request to the API to ask them to transmit your SMS
  #
  # @return [Boolean] <tt>true</tt> if the SMS has been sent, <tt>false</tt> otherwise
  #
  # @param [String] phone The phone to send the SMS to. In free-form, will be sanitized.
  # @param [String] message The text of your message.
  # @param [nil, String] sender_name Must be registered in your SMS Pilot member area
  #
  # @raise [SmsPilot::InvalidPhoneError] if you pass anythig but a String with the <tt>phone</tt> argument
  # @raise [SmsPilot::InvalidMessageError] if you pass anythig but a String with the <tt>message</tt> argument
  # @raise [SmsPilot::InvalidMessageError] if your message is empty
  # @raise [SmsPilot::InvalidPhoneError] if your phone is empty
  # @raise [SmsPilot::InvalidPhoneError] if your phone has no digits
  # @raise [SmsPilot::InvalidSenderNameError] if you pass anything but <tt>nil</tt> or non-empty String
  # @raise [URI::InvalidURIError] but is almost impossible, because we provide the URL ourselves
  #
  # @example
  #   client.send_sms("+7 (902) 123-45-67", "Привет, мир!") # => true
  #   client.send_sms("+7 (902) 123-45-67", "Здарова бандиты", "ФССПРФ") # => true
  #
  def send_sms(phone, message, sender_name = nil)
    validate_phone! phone
    validate_message! message
    validate_sender_name! sender_name

    @phone = normalize_phone(phone)
    @uri   = build_uri(@phone, message, sender_name)

    response = persist_response_details Net::HTTP.get_response(@uri)

    @error = "HTTP request failed with code #{response.code}"   and return false unless response.is_a?(Net::HTTPSuccess)
    @error = "#{error_description} (error code: #{error_code})" and return false if rejected?

    true

  rescue JSON::ParserError => error
    @error = "API returned invalid JSON. #{error.message}"
    return false

  rescue SocketError, EOFError, IOError, SystemCallError,
         Timeout::Error, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError,
         Net::ProtocolError, OpenSSL::SSL::SSLError => error
    @error = error.message
    return false
  end

  # @!endgroup


  # @!group State accessors

  # Your current balance, remaining after sending that latest SMS.
  #
  # @return [nil, Float] Always <tt>nil</tt> before you send SMS and if the SMS was not sent, always Float after successfull SMS transmission.
  # @example
  #   client.balance #=> 20215.25
  #
  def balance
    response_data["balance"]&.to_f if sms_sent?
  end


  # SMS broadcast ID (API documentation calls it “server ID” but it makes no sense, as it is clearly the ID of the transmission, not of a server)
  #
  # @example
  #   client.broadcast_id #=> 10000
  #
  # @return [nil, Integer]
  #
  # @see #response_data
  #
  def broadcast_id
    @response_data.dig("send", 0, "server_id")&.to_i if sms_sent?
  end


  # SMS delivery status, as returned by the API
  #
  # @return [nil, Integer] <tt>nil</tt> is returned before sending SMS or if the request was rejected. Otherwise an <tt>Integer</tt> in the range of [-2..3] is returned.
  # @see https://smspilot.ru/apikey.php#status List of available statuses at API documentation website
  #
  # Code | Name          | Final? | Description
  # ----:|:--------------|:-------|:-------------
  # -2   | Ошибка        | Да     | Ошибка, неправильные параметры запроса
  # -1   | Не доставлено | Да     | Сообщение не доставлено (не в сети, заблокирован, не взял трубку), PING — не в сети, HLR — не обслуживается (заблокирован)
  #  0   | Новое         | Нет    | Новое сообщение/запрос, ожидает обработки у нас на сервере
  #  1   | В очереди     | Нет    | Сообщение или запрос ожидают отправки на сервере оператора
  #  2   | Доставлено    | Да     | Доставлено, звонок совершен, PING — в сети, HLR — обслуживается
  #  3   | Отложено      | Нет    | Отложенная отправка, отправка сообщения/запроса запланирована на другое время
  #
  # @example
  #   client.broadcast_status #=> 2
  #
  # @see #sms_status
  #
  def broadcast_status
    @response_data.dig("send", 0, "status")&.to_i if sms_sent?
  end


  # Numerical code of the error that occured when sending the SMS. In the range from 0 to 715 (which may change).
  #
  # @return [nil, Integer] <tt>nil</tt> is returned before sending SMS. Otherwise <tt>Integer</tt>
  # @example
  #   client.error_code #=> 122
  # @see #error
  # @see #error_description
  # @see https://smspilot.ru/apikey.php#err Error codes at the API documentation website
  #
  def error_code
    @response_data.dig("error", "code")&.to_i if rejected?
  end


  # Description of the error that occured when sending the SMS
  #
  # @return [nil, String] <tt>nil</tt> is returned before sending SMS. Otherwise <tt>String</tt>
  # @example
  #   client.error_description #=> "Пользователь временно блокирован (спорная ситуация)"
  # @see #error
  # @see #error_code
  # @see https://smspilot.ru/apikey.php#err Error codes at the API documentation website
  #
  def error_description
    method_name = (@locale == :ru) ? "description_ru" : "description"
    @response_data.dig("error", method_name) if rejected?
  end


  # Did the API reject your request to send that SMS
  #
  # @return [Boolean] <tt>false</tt> is returned before sending SMS. Otherwise the <tt>Boolean</tt> corresponds to whether your request to send an SMS was rejected.
  # @example
  #   client.rejected? #=> false
  #
  def rejected?
    return false if sms_sent?
    response_data["error"].is_a? Hash
  end


  # Parses <tt>@response_body</tt> and memoizes result in <tt>@response_data</tt>
  #
  # @example
  #   {
  #     "balance" => "20006.97",
  #     "cost" => "1.68",
  #     "send" => [
  #       {
  #         "phone" => "79021234567",
  #         "price" => "1.68",
  #         "server_id" => "10000",
  #         "status" => "0"
  #       }
  #     ]
  #   }
  #
  # @return [Hash]
  # @raise [JSON::ParserError] which is rescued in {#send_sms}
  #
  # @see #response_body
  # @see #response_headers
  # @see #response_status
  #
  def response_data
    return {} unless @response_body
    @response_data ||= JSON.parse @response_body
  end


  # Did the API block you
  #
  # Error code | Description
  # :---|:------------------
  # 105 | из-за низкого баланса
  # 106 | за спам/ошибки
  # 107 | за недостоверные учетные данные / недоступна эл. почта / проблемы с телефоном
  # 122 | спорная ситуация
  #
  # @return [Boolean] <tt>nil</tt> is returned before sending SMS. Otherwise the <tt>Boolean</tt> corresponds to whether the API has blocked you.
  # @example
  #   client.sender_blocked? #=> false
  # @see #error
  # @see https://smspilot.ru/apikey.php#err Error codes at the API documentation website
  #
  def sender_blocked?
    [105, 106, 107, 122].include? error_code
  end


  # The cost of the SMS that has just been sent, in RUB
  #
  # @return [nil, Float]
  # @example
  #   client.sms_cost #=> 2.63
  #
  def sms_cost
    response_data["cost"]&.to_f if sms_sent?
  end


  # Has the SMS transmission been a success.
  #
  # @return [Boolean] <tt>nil</tt> is returned before sending SMS. Otherwise the <tt>Boolean</tt> corresponds to the result of SMS transmission.
  # @see #sms_status
  # @see #rejected?
  # @see #error
  #
  # @example
  #   client.sms_sent? #=> true
  #
  def sms_sent?
    response_data["send"] != nil
  end


  # @deprecated (in favor of {#broadcast_status})
  #
  def sms_status
    broadcast_status
  end


  # URL generated by combining <tt>API_ENDPOINT</tt>, your API key, SMS text & phone
  #
  # @example
  #   client.url #=> "https://smspilot.ru/api.php?api_key=XXX&format=json&send=TEXT&to=79021234567"
  #
  # @return [nil, String]
  #
  def url
    @uri&.to_s
  end

  # @!endgroup


  # The URI we will send an HTTP request to
  # @private
  #
  # @example
  #   build_uri("79021234567", "Hello, World!")
  #   #=> #<URI::HTTPS https://smspilot.ru/api.php?apikey=XXX…&format=json&send=Hello%2C+World%21&to=79021234567>
  #
  # @return [URI]
  # @raise [URI::InvalidURIError] but is almost impossible, because we provide the URL ourselves
  #
  # @param [String] phone
  # @param [String] text
  # @param [nil, String] sender_name
  #
  # @see #api_key
  # @see #phone
  # @see #validate_phone!
  # @see #validate_message!
  # @see #validate_sender_name!
  #
  private def build_uri(phone, text, sender_name)
    attributes = {
      apikey:  @api_key,
      charset: REQUEST_CHARSET,
      format:  REQUEST_ACCEPT_FORMAT,
      lang:    @locale,
      send:    text,
      to:      phone
    }
    attributes = attributes.merge({ sender: sender_name }) if sender_name

    URI.parse(API_ENDPOINT).tap do |uri|
      uri.query = URI.encode_www_form(attributes)
    end
  end




  # Cleans up your phone from anything but digits. Also replaces 8 to 7 if it is the first digit.
  #
  # @private
  # @param [String] phone
  # @return [String]
  #
  # @example
  #   normalize_phone("8 (902) 123-45-67") #=> 79021234567
  #   normalize_phone("+7-902-123-45-67")  #=> 79021234567
  #
  private def normalize_phone(phone)
    phone.gsub(/[^0-9]/, '').sub(/^8/, '7').gsub('+7', '8')
  end


  # Saves response details into instance variables
  # @private
  #
  # @return [response]
  # @raise [TypeError] unless a Net::HTTPResponse passed
  #
  private def persist_response_details(response)
    fail TypeError, "Net::HTTPResponse expected, you pass a #{response.class}" unless response.is_a? Net::HTTPResponse
    @response_body    = response.body
    @response_status  = response.code.to_i
    @response_headers = response.each_capitalized.to_h
    response
  end


  # @!group Validations

  # Validates api_key
  #
  # @private
  # @return [String] the original value passed into the method, only if it was valid
  # @param [String] api_key
  #
  # @raise [SmsPilot::InvalidError] if api_key is not a String
  # @raise [SmsPilot::InvalidError] if api_key is an empty String
  #
  private def validate_api_key!(api_key)
    fail SmsPilot::InvalidAPIkeyError, "API key must be a String, you pass a #{api_key.class} (#{api_key})" unless api_key.is_a? String
    fail SmsPilot::InvalidAPIkeyError, "API key cannot be empty" if api_key == ""
    return api_key
  end


  # Validates locale
  #
  # @private
  # @return [Symbol] the original value passed into the method, only if it was valid
  # @param [Symbol] locale
  #
  # @raise [SmsPilot::InvalidError] if locale is not a Symbol
  # @raise [SmsPilot::InvalidError] if locale is unrecognized
  #
  private def validate_locale!(locale)
    fail SmsPilot::InvalidLocaleError, "locale must be a Symbol" unless locale.is_a? Symbol
    fail SmsPilot::InvalidLocaleError, "API does not support locale :#{locale}; choose one of #{AVAILABLE_LOCALES.inspect}" unless AVAILABLE_LOCALES.include? locale
    return locale
  end


  # Validates message
  # @private
  #
  # @param [String] message
  # @return [String] the original value passed into the method, only if it was valid
  #
  # @raise [SmsPilot::InvalidMessageError] if you pass anythig but a String with the <tt>message</tt> argument
  # @raise [SmsPilot::InvalidMessageError] if your message is empty
  #
  private def validate_message!(message)
    fail SmsPilot::InvalidMessageError, "SMS message must be a String, you pass a #{ message.class} (#{ message})" unless message.is_a? String
    fail SmsPilot::InvalidMessageError, "SMS message cannot be empty" if  message == ""
    message
  end


  # Validates phone
  # @private
  #
  # @param [String] phone
  # @return [String] the original value passed into the method, only if it was valid
  #
  # @raise [SmsPilot::InvalidPhoneError] if you pass anythig but a String with the <tt>phone</tt> argument
  # @raise [SmsPilot::InvalidPhoneError] if your phone is empty
  # @raise [SmsPilot::InvalidPhoneError] if your phone has no digits
  #
  private def validate_phone!(phone)
    fail SmsPilot::InvalidPhoneError, "phone must be a String, you pass a #{phone.class} (#{phone})" unless phone.is_a? String
    fail SmsPilot::InvalidPhoneError, "phone cannot be empty" if phone == ""
    fail SmsPilot::InvalidPhoneError, "phone must contain digits" if phone.scan(/\d/).none?
    phone
  end


  # Validates sender name
  # @private
  #
  # @param [nil, String] sender_name
  # @return [String] the original value passed into the method, only if it was valid
  #
  # @raise [SmsPilot::InvalidSenderNameError] if you pass anything but <tt>nil</tt> or non-empty <tt>String</tt>
  #
  private def validate_sender_name!(sender_name)
    fail SmsPilot::InvalidSenderNameError, "sender name must be either nil or String" unless [NilClass, String].include? sender_name.class
    fail SmsPilot::InvalidSenderNameError, "sender name cannot be empty" if sender_name == ""
    sender_name
  end

  # @!endgroup

end

#errornil, String (readonly)

Error message returned from the API, combined with the error code

Examples:

client.error #=> "Пользователь временно блокирован (спорная ситуация) (error code: 122)"

Returns:

  • (nil, String)

See Also:


69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
# File 'lib/sms_pilot/client.rb', line 69

class Client

  # Check current API endpoint URL at {https://smspilot.ru/apikey.php#api1}
  #
  API_ENDPOINT = "https://smspilot.ru/api.php".freeze

  # Locale influences only the language of API errors
  #
  AVAILABLE_LOCALES = [:ru, :en].freeze

  REQUEST_ACCEPT_FORMAT = "json".freeze
  REQUEST_CHARSET = "utf-8".freeze

  attr_reader :api_key
  attr_reader :error
  attr_reader :locale
  attr_reader :phone
  attr_reader :response_body
  attr_reader :response_headers
  attr_reader :response_status


  # @param [String] api_key
  # @param [Symbol] locale
  #
  # @return [SmsPilot::Client]
  # @raise [SmsPilot::InvalidAPIkeyError] if you pass anything but a non-empty String
  # @raise [SmsPilot::InvalidLocaleError] if you pass anything but <tt>:ru</tt> or <tt>:en</tt>
  #
  # @see https://smspilot.ru/my-settings.php Get your production API key here
  # @see https://smspilot.ru/apikey.php Get your development API key here
  # @note Current development API key is <tt>"XXXXXXXXXXXXYYYYYYYYYYYYZZZZZZZZXXXXXXXXXXXXYYYYYYYYYYYYZZZZZZZZ"</tt>
  #
  # @example
  #   client = SmsPilot::Client.new(api_key: ENV["SMS_PILOT_API_KEY"])
  #   client = SmsPilot::Client.new(api_key: ENV["SMS_PILOT_API_KEY"], locale: :en)
  #
  def initialize(api_key:, locale: AVAILABLE_LOCALES[0])
    @api_key          = validate_api_key!(api_key)
    @error            = nil
    @locale           = validate_locale!(locale)
    @response_status  = nil
    @response_headers = {}
    @response_body    = nil
  end


  # @!group Main

  # Send HTTP request to the API to ask them to transmit your SMS
  #
  # @return [Boolean] <tt>true</tt> if the SMS has been sent, <tt>false</tt> otherwise
  #
  # @param [String] phone The phone to send the SMS to. In free-form, will be sanitized.
  # @param [String] message The text of your message.
  # @param [nil, String] sender_name Must be registered in your SMS Pilot member area
  #
  # @raise [SmsPilot::InvalidPhoneError] if you pass anythig but a String with the <tt>phone</tt> argument
  # @raise [SmsPilot::InvalidMessageError] if you pass anythig but a String with the <tt>message</tt> argument
  # @raise [SmsPilot::InvalidMessageError] if your message is empty
  # @raise [SmsPilot::InvalidPhoneError] if your phone is empty
  # @raise [SmsPilot::InvalidPhoneError] if your phone has no digits
  # @raise [SmsPilot::InvalidSenderNameError] if you pass anything but <tt>nil</tt> or non-empty String
  # @raise [URI::InvalidURIError] but is almost impossible, because we provide the URL ourselves
  #
  # @example
  #   client.send_sms("+7 (902) 123-45-67", "Привет, мир!") # => true
  #   client.send_sms("+7 (902) 123-45-67", "Здарова бандиты", "ФССПРФ") # => true
  #
  def send_sms(phone, message, sender_name = nil)
    validate_phone! phone
    validate_message! message
    validate_sender_name! sender_name

    @phone = normalize_phone(phone)
    @uri   = build_uri(@phone, message, sender_name)

    response = persist_response_details Net::HTTP.get_response(@uri)

    @error = "HTTP request failed with code #{response.code}"   and return false unless response.is_a?(Net::HTTPSuccess)
    @error = "#{error_description} (error code: #{error_code})" and return false if rejected?

    true

  rescue JSON::ParserError => error
    @error = "API returned invalid JSON. #{error.message}"
    return false

  rescue SocketError, EOFError, IOError, SystemCallError,
         Timeout::Error, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError,
         Net::ProtocolError, OpenSSL::SSL::SSLError => error
    @error = error.message
    return false
  end

  # @!endgroup


  # @!group State accessors

  # Your current balance, remaining after sending that latest SMS.
  #
  # @return [nil, Float] Always <tt>nil</tt> before you send SMS and if the SMS was not sent, always Float after successfull SMS transmission.
  # @example
  #   client.balance #=> 20215.25
  #
  def balance
    response_data["balance"]&.to_f if sms_sent?
  end


  # SMS broadcast ID (API documentation calls it “server ID” but it makes no sense, as it is clearly the ID of the transmission, not of a server)
  #
  # @example
  #   client.broadcast_id #=> 10000
  #
  # @return [nil, Integer]
  #
  # @see #response_data
  #
  def broadcast_id
    @response_data.dig("send", 0, "server_id")&.to_i if sms_sent?
  end


  # SMS delivery status, as returned by the API
  #
  # @return [nil, Integer] <tt>nil</tt> is returned before sending SMS or if the request was rejected. Otherwise an <tt>Integer</tt> in the range of [-2..3] is returned.
  # @see https://smspilot.ru/apikey.php#status List of available statuses at API documentation website
  #
  # Code | Name          | Final? | Description
  # ----:|:--------------|:-------|:-------------
  # -2   | Ошибка        | Да     | Ошибка, неправильные параметры запроса
  # -1   | Не доставлено | Да     | Сообщение не доставлено (не в сети, заблокирован, не взял трубку), PING — не в сети, HLR — не обслуживается (заблокирован)
  #  0   | Новое         | Нет    | Новое сообщение/запрос, ожидает обработки у нас на сервере
  #  1   | В очереди     | Нет    | Сообщение или запрос ожидают отправки на сервере оператора
  #  2   | Доставлено    | Да     | Доставлено, звонок совершен, PING — в сети, HLR — обслуживается
  #  3   | Отложено      | Нет    | Отложенная отправка, отправка сообщения/запроса запланирована на другое время
  #
  # @example
  #   client.broadcast_status #=> 2
  #
  # @see #sms_status
  #
  def broadcast_status
    @response_data.dig("send", 0, "status")&.to_i if sms_sent?
  end


  # Numerical code of the error that occured when sending the SMS. In the range from 0 to 715 (which may change).
  #
  # @return [nil, Integer] <tt>nil</tt> is returned before sending SMS. Otherwise <tt>Integer</tt>
  # @example
  #   client.error_code #=> 122
  # @see #error
  # @see #error_description
  # @see https://smspilot.ru/apikey.php#err Error codes at the API documentation website
  #
  def error_code
    @response_data.dig("error", "code")&.to_i if rejected?
  end


  # Description of the error that occured when sending the SMS
  #
  # @return [nil, String] <tt>nil</tt> is returned before sending SMS. Otherwise <tt>String</tt>
  # @example
  #   client.error_description #=> "Пользователь временно блокирован (спорная ситуация)"
  # @see #error
  # @see #error_code
  # @see https://smspilot.ru/apikey.php#err Error codes at the API documentation website
  #
  def error_description
    method_name = (@locale == :ru) ? "description_ru" : "description"
    @response_data.dig("error", method_name) if rejected?
  end


  # Did the API reject your request to send that SMS
  #
  # @return [Boolean] <tt>false</tt> is returned before sending SMS. Otherwise the <tt>Boolean</tt> corresponds to whether your request to send an SMS was rejected.
  # @example
  #   client.rejected? #=> false
  #
  def rejected?
    return false if sms_sent?
    response_data["error"].is_a? Hash
  end


  # Parses <tt>@response_body</tt> and memoizes result in <tt>@response_data</tt>
  #
  # @example
  #   {
  #     "balance" => "20006.97",
  #     "cost" => "1.68",
  #     "send" => [
  #       {
  #         "phone" => "79021234567",
  #         "price" => "1.68",
  #         "server_id" => "10000",
  #         "status" => "0"
  #       }
  #     ]
  #   }
  #
  # @return [Hash]
  # @raise [JSON::ParserError] which is rescued in {#send_sms}
  #
  # @see #response_body
  # @see #response_headers
  # @see #response_status
  #
  def response_data
    return {} unless @response_body
    @response_data ||= JSON.parse @response_body
  end


  # Did the API block you
  #
  # Error code | Description
  # :---|:------------------
  # 105 | из-за низкого баланса
  # 106 | за спам/ошибки
  # 107 | за недостоверные учетные данные / недоступна эл. почта / проблемы с телефоном
  # 122 | спорная ситуация
  #
  # @return [Boolean] <tt>nil</tt> is returned before sending SMS. Otherwise the <tt>Boolean</tt> corresponds to whether the API has blocked you.
  # @example
  #   client.sender_blocked? #=> false
  # @see #error
  # @see https://smspilot.ru/apikey.php#err Error codes at the API documentation website
  #
  def sender_blocked?
    [105, 106, 107, 122].include? error_code
  end


  # The cost of the SMS that has just been sent, in RUB
  #
  # @return [nil, Float]
  # @example
  #   client.sms_cost #=> 2.63
  #
  def sms_cost
    response_data["cost"]&.to_f if sms_sent?
  end


  # Has the SMS transmission been a success.
  #
  # @return [Boolean] <tt>nil</tt> is returned before sending SMS. Otherwise the <tt>Boolean</tt> corresponds to the result of SMS transmission.
  # @see #sms_status
  # @see #rejected?
  # @see #error
  #
  # @example
  #   client.sms_sent? #=> true
  #
  def sms_sent?
    response_data["send"] != nil
  end


  # @deprecated (in favor of {#broadcast_status})
  #
  def sms_status
    broadcast_status
  end


  # URL generated by combining <tt>API_ENDPOINT</tt>, your API key, SMS text & phone
  #
  # @example
  #   client.url #=> "https://smspilot.ru/api.php?api_key=XXX&format=json&send=TEXT&to=79021234567"
  #
  # @return [nil, String]
  #
  def url
    @uri&.to_s
  end

  # @!endgroup


  # The URI we will send an HTTP request to
  # @private
  #
  # @example
  #   build_uri("79021234567", "Hello, World!")
  #   #=> #<URI::HTTPS https://smspilot.ru/api.php?apikey=XXX…&format=json&send=Hello%2C+World%21&to=79021234567>
  #
  # @return [URI]
  # @raise [URI::InvalidURIError] but is almost impossible, because we provide the URL ourselves
  #
  # @param [String] phone
  # @param [String] text
  # @param [nil, String] sender_name
  #
  # @see #api_key
  # @see #phone
  # @see #validate_phone!
  # @see #validate_message!
  # @see #validate_sender_name!
  #
  private def build_uri(phone, text, sender_name)
    attributes = {
      apikey:  @api_key,
      charset: REQUEST_CHARSET,
      format:  REQUEST_ACCEPT_FORMAT,
      lang:    @locale,
      send:    text,
      to:      phone
    }
    attributes = attributes.merge({ sender: sender_name }) if sender_name

    URI.parse(API_ENDPOINT).tap do |uri|
      uri.query = URI.encode_www_form(attributes)
    end
  end




  # Cleans up your phone from anything but digits. Also replaces 8 to 7 if it is the first digit.
  #
  # @private
  # @param [String] phone
  # @return [String]
  #
  # @example
  #   normalize_phone("8 (902) 123-45-67") #=> 79021234567
  #   normalize_phone("+7-902-123-45-67")  #=> 79021234567
  #
  private def normalize_phone(phone)
    phone.gsub(/[^0-9]/, '').sub(/^8/, '7').gsub('+7', '8')
  end


  # Saves response details into instance variables
  # @private
  #
  # @return [response]
  # @raise [TypeError] unless a Net::HTTPResponse passed
  #
  private def persist_response_details(response)
    fail TypeError, "Net::HTTPResponse expected, you pass a #{response.class}" unless response.is_a? Net::HTTPResponse
    @response_body    = response.body
    @response_status  = response.code.to_i
    @response_headers = response.each_capitalized.to_h
    response
  end


  # @!group Validations

  # Validates api_key
  #
  # @private
  # @return [String] the original value passed into the method, only if it was valid
  # @param [String] api_key
  #
  # @raise [SmsPilot::InvalidError] if api_key is not a String
  # @raise [SmsPilot::InvalidError] if api_key is an empty String
  #
  private def validate_api_key!(api_key)
    fail SmsPilot::InvalidAPIkeyError, "API key must be a String, you pass a #{api_key.class} (#{api_key})" unless api_key.is_a? String
    fail SmsPilot::InvalidAPIkeyError, "API key cannot be empty" if api_key == ""
    return api_key
  end


  # Validates locale
  #
  # @private
  # @return [Symbol] the original value passed into the method, only if it was valid
  # @param [Symbol] locale
  #
  # @raise [SmsPilot::InvalidError] if locale is not a Symbol
  # @raise [SmsPilot::InvalidError] if locale is unrecognized
  #
  private def validate_locale!(locale)
    fail SmsPilot::InvalidLocaleError, "locale must be a Symbol" unless locale.is_a? Symbol
    fail SmsPilot::InvalidLocaleError, "API does not support locale :#{locale}; choose one of #{AVAILABLE_LOCALES.inspect}" unless AVAILABLE_LOCALES.include? locale
    return locale
  end


  # Validates message
  # @private
  #
  # @param [String] message
  # @return [String] the original value passed into the method, only if it was valid
  #
  # @raise [SmsPilot::InvalidMessageError] if you pass anythig but a String with the <tt>message</tt> argument
  # @raise [SmsPilot::InvalidMessageError] if your message is empty
  #
  private def validate_message!(message)
    fail SmsPilot::InvalidMessageError, "SMS message must be a String, you pass a #{ message.class} (#{ message})" unless message.is_a? String
    fail SmsPilot::InvalidMessageError, "SMS message cannot be empty" if  message == ""
    message
  end


  # Validates phone
  # @private
  #
  # @param [String] phone
  # @return [String] the original value passed into the method, only if it was valid
  #
  # @raise [SmsPilot::InvalidPhoneError] if you pass anythig but a String with the <tt>phone</tt> argument
  # @raise [SmsPilot::InvalidPhoneError] if your phone is empty
  # @raise [SmsPilot::InvalidPhoneError] if your phone has no digits
  #
  private def validate_phone!(phone)
    fail SmsPilot::InvalidPhoneError, "phone must be a String, you pass a #{phone.class} (#{phone})" unless phone.is_a? String
    fail SmsPilot::InvalidPhoneError, "phone cannot be empty" if phone == ""
    fail SmsPilot::InvalidPhoneError, "phone must contain digits" if phone.scan(/\d/).none?
    phone
  end


  # Validates sender name
  # @private
  #
  # @param [nil, String] sender_name
  # @return [String] the original value passed into the method, only if it was valid
  #
  # @raise [SmsPilot::InvalidSenderNameError] if you pass anything but <tt>nil</tt> or non-empty <tt>String</tt>
  #
  private def validate_sender_name!(sender_name)
    fail SmsPilot::InvalidSenderNameError, "sender name must be either nil or String" unless [NilClass, String].include? sender_name.class
    fail SmsPilot::InvalidSenderNameError, "sender name cannot be empty" if sender_name == ""
    sender_name
  end

  # @!endgroup

end

#localeSymbol (readonly)

Chosen locale (affects only the language of errors)

Examples:

client.locale #=> :ru

Returns:

  • (Symbol)

69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
# File 'lib/sms_pilot/client.rb', line 69

class Client

  # Check current API endpoint URL at {https://smspilot.ru/apikey.php#api1}
  #
  API_ENDPOINT = "https://smspilot.ru/api.php".freeze

  # Locale influences only the language of API errors
  #
  AVAILABLE_LOCALES = [:ru, :en].freeze

  REQUEST_ACCEPT_FORMAT = "json".freeze
  REQUEST_CHARSET = "utf-8".freeze

  attr_reader :api_key
  attr_reader :error
  attr_reader :locale
  attr_reader :phone
  attr_reader :response_body
  attr_reader :response_headers
  attr_reader :response_status


  # @param [String] api_key
  # @param [Symbol] locale
  #
  # @return [SmsPilot::Client]
  # @raise [SmsPilot::InvalidAPIkeyError] if you pass anything but a non-empty String
  # @raise [SmsPilot::InvalidLocaleError] if you pass anything but <tt>:ru</tt> or <tt>:en</tt>
  #
  # @see https://smspilot.ru/my-settings.php Get your production API key here
  # @see https://smspilot.ru/apikey.php Get your development API key here
  # @note Current development API key is <tt>"XXXXXXXXXXXXYYYYYYYYYYYYZZZZZZZZXXXXXXXXXXXXYYYYYYYYYYYYZZZZZZZZ"</tt>
  #
  # @example
  #   client = SmsPilot::Client.new(api_key: ENV["SMS_PILOT_API_KEY"])
  #   client = SmsPilot::Client.new(api_key: ENV["SMS_PILOT_API_KEY"], locale: :en)
  #
  def initialize(api_key:, locale: AVAILABLE_LOCALES[0])
    @api_key          = validate_api_key!(api_key)
    @error            = nil
    @locale           = validate_locale!(locale)
    @response_status  = nil
    @response_headers = {}
    @response_body    = nil
  end


  # @!group Main

  # Send HTTP request to the API to ask them to transmit your SMS
  #
  # @return [Boolean] <tt>true</tt> if the SMS has been sent, <tt>false</tt> otherwise
  #
  # @param [String] phone The phone to send the SMS to. In free-form, will be sanitized.
  # @param [String] message The text of your message.
  # @param [nil, String] sender_name Must be registered in your SMS Pilot member area
  #
  # @raise [SmsPilot::InvalidPhoneError] if you pass anythig but a String with the <tt>phone</tt> argument
  # @raise [SmsPilot::InvalidMessageError] if you pass anythig but a String with the <tt>message</tt> argument
  # @raise [SmsPilot::InvalidMessageError] if your message is empty
  # @raise [SmsPilot::InvalidPhoneError] if your phone is empty
  # @raise [SmsPilot::InvalidPhoneError] if your phone has no digits
  # @raise [SmsPilot::InvalidSenderNameError] if you pass anything but <tt>nil</tt> or non-empty String
  # @raise [URI::InvalidURIError] but is almost impossible, because we provide the URL ourselves
  #
  # @example
  #   client.send_sms("+7 (902) 123-45-67", "Привет, мир!") # => true
  #   client.send_sms("+7 (902) 123-45-67", "Здарова бандиты", "ФССПРФ") # => true
  #
  def send_sms(phone, message, sender_name = nil)
    validate_phone! phone
    validate_message! message
    validate_sender_name! sender_name

    @phone = normalize_phone(phone)
    @uri   = build_uri(@phone, message, sender_name)

    response = persist_response_details Net::HTTP.get_response(@uri)

    @error = "HTTP request failed with code #{response.code}"   and return false unless response.is_a?(Net::HTTPSuccess)
    @error = "#{error_description} (error code: #{error_code})" and return false if rejected?

    true

  rescue JSON::ParserError => error
    @error = "API returned invalid JSON. #{error.message}"
    return false

  rescue SocketError, EOFError, IOError, SystemCallError,
         Timeout::Error, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError,
         Net::ProtocolError, OpenSSL::SSL::SSLError => error
    @error = error.message
    return false
  end

  # @!endgroup


  # @!group State accessors

  # Your current balance, remaining after sending that latest SMS.
  #
  # @return [nil, Float] Always <tt>nil</tt> before you send SMS and if the SMS was not sent, always Float after successfull SMS transmission.
  # @example
  #   client.balance #=> 20215.25
  #
  def balance
    response_data["balance"]&.to_f if sms_sent?
  end


  # SMS broadcast ID (API documentation calls it “server ID” but it makes no sense, as it is clearly the ID of the transmission, not of a server)
  #
  # @example
  #   client.broadcast_id #=> 10000
  #
  # @return [nil, Integer]
  #
  # @see #response_data
  #
  def broadcast_id
    @response_data.dig("send", 0, "server_id")&.to_i if sms_sent?
  end


  # SMS delivery status, as returned by the API
  #
  # @return [nil, Integer] <tt>nil</tt> is returned before sending SMS or if the request was rejected. Otherwise an <tt>Integer</tt> in the range of [-2..3] is returned.
  # @see https://smspilot.ru/apikey.php#status List of available statuses at API documentation website
  #
  # Code | Name          | Final? | Description
  # ----:|:--------------|:-------|:-------------
  # -2   | Ошибка        | Да     | Ошибка, неправильные параметры запроса
  # -1   | Не доставлено | Да     | Сообщение не доставлено (не в сети, заблокирован, не взял трубку), PING — не в сети, HLR — не обслуживается (заблокирован)
  #  0   | Новое         | Нет    | Новое сообщение/запрос, ожидает обработки у нас на сервере
  #  1   | В очереди     | Нет    | Сообщение или запрос ожидают отправки на сервере оператора
  #  2   | Доставлено    | Да     | Доставлено, звонок совершен, PING — в сети, HLR — обслуживается
  #  3   | Отложено      | Нет    | Отложенная отправка, отправка сообщения/запроса запланирована на другое время
  #
  # @example
  #   client.broadcast_status #=> 2
  #
  # @see #sms_status
  #
  def broadcast_status
    @response_data.dig("send", 0, "status")&.to_i if sms_sent?
  end


  # Numerical code of the error that occured when sending the SMS. In the range from 0 to 715 (which may change).
  #
  # @return [nil, Integer] <tt>nil</tt> is returned before sending SMS. Otherwise <tt>Integer</tt>
  # @example
  #   client.error_code #=> 122
  # @see #error
  # @see #error_description
  # @see https://smspilot.ru/apikey.php#err Error codes at the API documentation website
  #
  def error_code
    @response_data.dig("error", "code")&.to_i if rejected?
  end


  # Description of the error that occured when sending the SMS
  #
  # @return [nil, String] <tt>nil</tt> is returned before sending SMS. Otherwise <tt>String</tt>
  # @example
  #   client.error_description #=> "Пользователь временно блокирован (спорная ситуация)"
  # @see #error
  # @see #error_code
  # @see https://smspilot.ru/apikey.php#err Error codes at the API documentation website
  #
  def error_description
    method_name = (@locale == :ru) ? "description_ru" : "description"
    @response_data.dig("error", method_name) if rejected?
  end


  # Did the API reject your request to send that SMS
  #
  # @return [Boolean] <tt>false</tt> is returned before sending SMS. Otherwise the <tt>Boolean</tt> corresponds to whether your request to send an SMS was rejected.
  # @example
  #   client.rejected? #=> false
  #
  def rejected?
    return false if sms_sent?
    response_data["error"].is_a? Hash
  end


  # Parses <tt>@response_body</tt> and memoizes result in <tt>@response_data</tt>
  #
  # @example
  #   {
  #     "balance" => "20006.97",
  #     "cost" => "1.68",
  #     "send" => [
  #       {
  #         "phone" => "79021234567",
  #         "price" => "1.68",
  #         "server_id" => "10000",
  #         "status" => "0"
  #       }
  #     ]
  #   }
  #
  # @return [Hash]
  # @raise [JSON::ParserError] which is rescued in {#send_sms}
  #
  # @see #response_body
  # @see #response_headers
  # @see #response_status
  #
  def response_data
    return {} unless @response_body
    @response_data ||= JSON.parse @response_body
  end


  # Did the API block you
  #
  # Error code | Description
  # :---|:------------------
  # 105 | из-за низкого баланса
  # 106 | за спам/ошибки
  # 107 | за недостоверные учетные данные / недоступна эл. почта / проблемы с телефоном
  # 122 | спорная ситуация
  #
  # @return [Boolean] <tt>nil</tt> is returned before sending SMS. Otherwise the <tt>Boolean</tt> corresponds to whether the API has blocked you.
  # @example
  #   client.sender_blocked? #=> false
  # @see #error
  # @see https://smspilot.ru/apikey.php#err Error codes at the API documentation website
  #
  def sender_blocked?
    [105, 106, 107, 122].include? error_code
  end


  # The cost of the SMS that has just been sent, in RUB
  #
  # @return [nil, Float]
  # @example
  #   client.sms_cost #=> 2.63
  #
  def sms_cost
    response_data["cost"]&.to_f if sms_sent?
  end


  # Has the SMS transmission been a success.
  #
  # @return [Boolean] <tt>nil</tt> is returned before sending SMS. Otherwise the <tt>Boolean</tt> corresponds to the result of SMS transmission.
  # @see #sms_status
  # @see #rejected?
  # @see #error
  #
  # @example
  #   client.sms_sent? #=> true
  #
  def sms_sent?
    response_data["send"] != nil
  end


  # @deprecated (in favor of {#broadcast_status})
  #
  def sms_status
    broadcast_status
  end


  # URL generated by combining <tt>API_ENDPOINT</tt>, your API key, SMS text & phone
  #
  # @example
  #   client.url #=> "https://smspilot.ru/api.php?api_key=XXX&format=json&send=TEXT&to=79021234567"
  #
  # @return [nil, String]
  #
  def url
    @uri&.to_s
  end

  # @!endgroup


  # The URI we will send an HTTP request to
  # @private
  #
  # @example
  #   build_uri("79021234567", "Hello, World!")
  #   #=> #<URI::HTTPS https://smspilot.ru/api.php?apikey=XXX…&format=json&send=Hello%2C+World%21&to=79021234567>
  #
  # @return [URI]
  # @raise [URI::InvalidURIError] but is almost impossible, because we provide the URL ourselves
  #
  # @param [String] phone
  # @param [String] text
  # @param [nil, String] sender_name
  #
  # @see #api_key
  # @see #phone
  # @see #validate_phone!
  # @see #validate_message!
  # @see #validate_sender_name!
  #
  private def build_uri(phone, text, sender_name)
    attributes = {
      apikey:  @api_key,
      charset: REQUEST_CHARSET,
      format:  REQUEST_ACCEPT_FORMAT,
      lang:    @locale,
      send:    text,
      to:      phone
    }
    attributes = attributes.merge({ sender: sender_name }) if sender_name

    URI.parse(API_ENDPOINT).tap do |uri|
      uri.query = URI.encode_www_form(attributes)
    end
  end




  # Cleans up your phone from anything but digits. Also replaces 8 to 7 if it is the first digit.
  #
  # @private
  # @param [String] phone
  # @return [String]
  #
  # @example
  #   normalize_phone("8 (902) 123-45-67") #=> 79021234567
  #   normalize_phone("+7-902-123-45-67")  #=> 79021234567
  #
  private def normalize_phone(phone)
    phone.gsub(/[^0-9]/, '').sub(/^8/, '7').gsub('+7', '8')
  end


  # Saves response details into instance variables
  # @private
  #
  # @return [response]
  # @raise [TypeError] unless a Net::HTTPResponse passed
  #
  private def persist_response_details(response)
    fail TypeError, "Net::HTTPResponse expected, you pass a #{response.class}" unless response.is_a? Net::HTTPResponse
    @response_body    = response.body
    @response_status  = response.code.to_i
    @response_headers = response.each_capitalized.to_h
    response
  end


  # @!group Validations

  # Validates api_key
  #
  # @private
  # @return [String] the original value passed into the method, only if it was valid
  # @param [String] api_key
  #
  # @raise [SmsPilot::InvalidError] if api_key is not a String
  # @raise [SmsPilot::InvalidError] if api_key is an empty String
  #
  private def validate_api_key!(api_key)
    fail SmsPilot::InvalidAPIkeyError, "API key must be a String, you pass a #{api_key.class} (#{api_key})" unless api_key.is_a? String
    fail SmsPilot::InvalidAPIkeyError, "API key cannot be empty" if api_key == ""
    return api_key
  end


  # Validates locale
  #
  # @private
  # @return [Symbol] the original value passed into the method, only if it was valid
  # @param [Symbol] locale
  #
  # @raise [SmsPilot::InvalidError] if locale is not a Symbol
  # @raise [SmsPilot::InvalidError] if locale is unrecognized
  #
  private def validate_locale!(locale)
    fail SmsPilot::InvalidLocaleError, "locale must be a Symbol" unless locale.is_a? Symbol
    fail SmsPilot::InvalidLocaleError, "API does not support locale :#{locale}; choose one of #{AVAILABLE_LOCALES.inspect}" unless AVAILABLE_LOCALES.include? locale
    return locale
  end


  # Validates message
  # @private
  #
  # @param [String] message
  # @return [String] the original value passed into the method, only if it was valid
  #
  # @raise [SmsPilot::InvalidMessageError] if you pass anythig but a String with the <tt>message</tt> argument
  # @raise [SmsPilot::InvalidMessageError] if your message is empty
  #
  private def validate_message!(message)
    fail SmsPilot::InvalidMessageError, "SMS message must be a String, you pass a #{ message.class} (#{ message})" unless message.is_a? String
    fail SmsPilot::InvalidMessageError, "SMS message cannot be empty" if  message == ""
    message
  end


  # Validates phone
  # @private
  #
  # @param [String] phone
  # @return [String] the original value passed into the method, only if it was valid
  #
  # @raise [SmsPilot::InvalidPhoneError] if you pass anythig but a String with the <tt>phone</tt> argument
  # @raise [SmsPilot::InvalidPhoneError] if your phone is empty
  # @raise [SmsPilot::InvalidPhoneError] if your phone has no digits
  #
  private def validate_phone!(phone)
    fail SmsPilot::InvalidPhoneError, "phone must be a String, you pass a #{phone.class} (#{phone})" unless phone.is_a? String
    fail SmsPilot::InvalidPhoneError, "phone cannot be empty" if phone == ""
    fail SmsPilot::InvalidPhoneError, "phone must contain digits" if phone.scan(/\d/).none?
    phone
  end


  # Validates sender name
  # @private
  #
  # @param [nil, String] sender_name
  # @return [String] the original value passed into the method, only if it was valid
  #
  # @raise [SmsPilot::InvalidSenderNameError] if you pass anything but <tt>nil</tt> or non-empty <tt>String</tt>
  #
  private def validate_sender_name!(sender_name)
    fail SmsPilot::InvalidSenderNameError, "sender name must be either nil or String" unless [NilClass, String].include? sender_name.class
    fail SmsPilot::InvalidSenderNameError, "sender name cannot be empty" if sender_name == ""
    sender_name
  end

  # @!endgroup

end

#phonenil, String (readonly)

Returns phone after normalization.

Examples:

client.phone #=> "79021234567"

Returns:

  • (nil, String)

    phone after normalization


69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
# File 'lib/sms_pilot/client.rb', line 69

class Client

  # Check current API endpoint URL at {https://smspilot.ru/apikey.php#api1}
  #
  API_ENDPOINT = "https://smspilot.ru/api.php".freeze

  # Locale influences only the language of API errors
  #
  AVAILABLE_LOCALES = [:ru, :en].freeze

  REQUEST_ACCEPT_FORMAT = "json".freeze
  REQUEST_CHARSET = "utf-8".freeze

  attr_reader :api_key
  attr_reader :error
  attr_reader :locale
  attr_reader :phone
  attr_reader :response_body
  attr_reader :response_headers
  attr_reader :response_status


  # @param [String] api_key
  # @param [Symbol] locale
  #
  # @return [SmsPilot::Client]
  # @raise [SmsPilot::InvalidAPIkeyError] if you pass anything but a non-empty String
  # @raise [SmsPilot::InvalidLocaleError] if you pass anything but <tt>:ru</tt> or <tt>:en</tt>
  #
  # @see https://smspilot.ru/my-settings.php Get your production API key here
  # @see https://smspilot.ru/apikey.php Get your development API key here
  # @note Current development API key is <tt>"XXXXXXXXXXXXYYYYYYYYYYYYZZZZZZZZXXXXXXXXXXXXYYYYYYYYYYYYZZZZZZZZ"</tt>
  #
  # @example
  #   client = SmsPilot::Client.new(api_key: ENV["SMS_PILOT_API_KEY"])
  #   client = SmsPilot::Client.new(api_key: ENV["SMS_PILOT_API_KEY"], locale: :en)
  #
  def initialize(api_key:, locale: AVAILABLE_LOCALES[0])
    @api_key          = validate_api_key!(api_key)
    @error            = nil
    @locale           = validate_locale!(locale)
    @response_status  = nil
    @response_headers = {}
    @response_body    = nil
  end


  # @!group Main

  # Send HTTP request to the API to ask them to transmit your SMS
  #
  # @return [Boolean] <tt>true</tt> if the SMS has been sent, <tt>false</tt> otherwise
  #
  # @param [String] phone The phone to send the SMS to. In free-form, will be sanitized.
  # @param [String] message The text of your message.
  # @param [nil, String] sender_name Must be registered in your SMS Pilot member area
  #
  # @raise [SmsPilot::InvalidPhoneError] if you pass anythig but a String with the <tt>phone</tt> argument
  # @raise [SmsPilot::InvalidMessageError] if you pass anythig but a String with the <tt>message</tt> argument
  # @raise [SmsPilot::InvalidMessageError] if your message is empty
  # @raise [SmsPilot::InvalidPhoneError] if your phone is empty
  # @raise [SmsPilot::InvalidPhoneError] if your phone has no digits
  # @raise [SmsPilot::InvalidSenderNameError] if you pass anything but <tt>nil</tt> or non-empty String
  # @raise [URI::InvalidURIError] but is almost impossible, because we provide the URL ourselves
  #
  # @example
  #   client.send_sms("+7 (902) 123-45-67", "Привет, мир!") # => true
  #   client.send_sms("+7 (902) 123-45-67", "Здарова бандиты", "ФССПРФ") # => true
  #
  def send_sms(phone, message, sender_name = nil)
    validate_phone! phone
    validate_message! message
    validate_sender_name! sender_name

    @phone = normalize_phone(phone)
    @uri   = build_uri(@phone, message, sender_name)

    response = persist_response_details Net::HTTP.get_response(@uri)

    @error = "HTTP request failed with code #{response.code}"   and return false unless response.is_a?(Net::HTTPSuccess)
    @error = "#{error_description} (error code: #{error_code})" and return false if rejected?

    true

  rescue JSON::ParserError => error
    @error = "API returned invalid JSON. #{error.message}"
    return false

  rescue SocketError, EOFError, IOError, SystemCallError,
         Timeout::Error, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError,
         Net::ProtocolError, OpenSSL::SSL::SSLError => error
    @error = error.message
    return false
  end

  # @!endgroup


  # @!group State accessors

  # Your current balance, remaining after sending that latest SMS.
  #
  # @return [nil, Float] Always <tt>nil</tt> before you send SMS and if the SMS was not sent, always Float after successfull SMS transmission.
  # @example
  #   client.balance #=> 20215.25
  #
  def balance
    response_data["balance"]&.to_f if sms_sent?
  end


  # SMS broadcast ID (API documentation calls it “server ID” but it makes no sense, as it is clearly the ID of the transmission, not of a server)
  #
  # @example
  #   client.broadcast_id #=> 10000
  #
  # @return [nil, Integer]
  #
  # @see #response_data
  #
  def broadcast_id
    @response_data.dig("send", 0, "server_id")&.to_i if sms_sent?
  end


  # SMS delivery status, as returned by the API
  #
  # @return [nil, Integer] <tt>nil</tt> is returned before sending SMS or if the request was rejected. Otherwise an <tt>Integer</tt> in the range of [-2..3] is returned.
  # @see https://smspilot.ru/apikey.php#status List of available statuses at API documentation website
  #
  # Code | Name          | Final? | Description
  # ----:|:--------------|:-------|:-------------
  # -2   | Ошибка        | Да     | Ошибка, неправильные параметры запроса
  # -1   | Не доставлено | Да     | Сообщение не доставлено (не в сети, заблокирован, не взял трубку), PING — не в сети, HLR — не обслуживается (заблокирован)
  #  0   | Новое         | Нет    | Новое сообщение/запрос, ожидает обработки у нас на сервере
  #  1   | В очереди     | Нет    | Сообщение или запрос ожидают отправки на сервере оператора
  #  2   | Доставлено    | Да     | Доставлено, звонок совершен, PING — в сети, HLR — обслуживается
  #  3   | Отложено      | Нет    | Отложенная отправка, отправка сообщения/запроса запланирована на другое время
  #
  # @example
  #   client.broadcast_status #=> 2
  #
  # @see #sms_status
  #
  def broadcast_status
    @response_data.dig("send", 0, "status")&.to_i if sms_sent?
  end


  # Numerical code of the error that occured when sending the SMS. In the range from 0 to 715 (which may change).
  #
  # @return [nil, Integer] <tt>nil</tt> is returned before sending SMS. Otherwise <tt>Integer</tt>
  # @example
  #   client.error_code #=> 122
  # @see #error
  # @see #error_description
  # @see https://smspilot.ru/apikey.php#err Error codes at the API documentation website
  #
  def error_code
    @response_data.dig("error", "code")&.to_i if rejected?
  end


  # Description of the error that occured when sending the SMS
  #
  # @return [nil, String] <tt>nil</tt> is returned before sending SMS. Otherwise <tt>String</tt>
  # @example
  #   client.error_description #=> "Пользователь временно блокирован (спорная ситуация)"
  # @see #error
  # @see #error_code
  # @see https://smspilot.ru/apikey.php#err Error codes at the API documentation website
  #
  def error_description
    method_name = (@locale == :ru) ? "description_ru" : "description"
    @response_data.dig("error", method_name) if rejected?
  end


  # Did the API reject your request to send that SMS
  #
  # @return [Boolean] <tt>false</tt> is returned before sending SMS. Otherwise the <tt>Boolean</tt> corresponds to whether your request to send an SMS was rejected.
  # @example
  #   client.rejected? #=> false
  #
  def rejected?
    return false if sms_sent?
    response_data["error"].is_a? Hash
  end


  # Parses <tt>@response_body</tt> and memoizes result in <tt>@response_data</tt>
  #
  # @example
  #   {
  #     "balance" => "20006.97",
  #     "cost" => "1.68",
  #     "send" => [
  #       {
  #         "phone" => "79021234567",
  #         "price" => "1.68",
  #         "server_id" => "10000",
  #         "status" => "0"
  #       }
  #     ]
  #   }
  #
  # @return [Hash]
  # @raise [JSON::ParserError] which is rescued in {#send_sms}
  #
  # @see #response_body
  # @see #response_headers
  # @see #response_status
  #
  def response_data
    return {} unless @response_body
    @response_data ||= JSON.parse @response_body
  end


  # Did the API block you
  #
  # Error code | Description
  # :---|:------------------
  # 105 | из-за низкого баланса
  # 106 | за спам/ошибки
  # 107 | за недостоверные учетные данные / недоступна эл. почта / проблемы с телефоном
  # 122 | спорная ситуация
  #
  # @return [Boolean] <tt>nil</tt> is returned before sending SMS. Otherwise the <tt>Boolean</tt> corresponds to whether the API has blocked you.
  # @example
  #   client.sender_blocked? #=> false
  # @see #error
  # @see https://smspilot.ru/apikey.php#err Error codes at the API documentation website
  #
  def sender_blocked?
    [105, 106, 107, 122].include? error_code
  end


  # The cost of the SMS that has just been sent, in RUB
  #
  # @return [nil, Float]
  # @example
  #   client.sms_cost #=> 2.63
  #
  def sms_cost
    response_data["cost"]&.to_f if sms_sent?
  end


  # Has the SMS transmission been a success.
  #
  # @return [Boolean] <tt>nil</tt> is returned before sending SMS. Otherwise the <tt>Boolean</tt> corresponds to the result of SMS transmission.
  # @see #sms_status
  # @see #rejected?
  # @see #error
  #
  # @example
  #   client.sms_sent? #=> true
  #
  def sms_sent?
    response_data["send"] != nil
  end


  # @deprecated (in favor of {#broadcast_status})
  #
  def sms_status
    broadcast_status
  end


  # URL generated by combining <tt>API_ENDPOINT</tt>, your API key, SMS text & phone
  #
  # @example
  #   client.url #=> "https://smspilot.ru/api.php?api_key=XXX&format=json&send=TEXT&to=79021234567"
  #
  # @return [nil, String]
  #
  def url
    @uri&.to_s
  end

  # @!endgroup


  # The URI we will send an HTTP request to
  # @private
  #
  # @example
  #   build_uri("79021234567", "Hello, World!")
  #   #=> #<URI::HTTPS https://smspilot.ru/api.php?apikey=XXX…&format=json&send=Hello%2C+World%21&to=79021234567>
  #
  # @return [URI]
  # @raise [URI::InvalidURIError] but is almost impossible, because we provide the URL ourselves
  #
  # @param [String] phone
  # @param [String] text
  # @param [nil, String] sender_name
  #
  # @see #api_key
  # @see #phone
  # @see #validate_phone!
  # @see #validate_message!
  # @see #validate_sender_name!
  #
  private def build_uri(phone, text, sender_name)
    attributes = {
      apikey:  @api_key,
      charset: REQUEST_CHARSET,
      format:  REQUEST_ACCEPT_FORMAT,
      lang:    @locale,
      send:    text,
      to:      phone
    }
    attributes = attributes.merge({ sender: sender_name }) if sender_name

    URI.parse(API_ENDPOINT).tap do |uri|
      uri.query = URI.encode_www_form(attributes)
    end
  end




  # Cleans up your phone from anything but digits. Also replaces 8 to 7 if it is the first digit.
  #
  # @private
  # @param [String] phone
  # @return [String]
  #
  # @example
  #   normalize_phone("8 (902) 123-45-67") #=> 79021234567
  #   normalize_phone("+7-902-123-45-67")  #=> 79021234567
  #
  private def normalize_phone(phone)
    phone.gsub(/[^0-9]/, '').sub(/^8/, '7').gsub('+7', '8')
  end


  # Saves response details into instance variables
  # @private
  #
  # @return [response]
  # @raise [TypeError] unless a Net::HTTPResponse passed
  #
  private def persist_response_details(response)
    fail TypeError, "Net::HTTPResponse expected, you pass a #{response.class}" unless response.is_a? Net::HTTPResponse
    @response_body    = response.body
    @response_status  = response.code.to_i
    @response_headers = response.each_capitalized.to_h
    response
  end


  # @!group Validations

  # Validates api_key
  #
  # @private
  # @return [String] the original value passed into the method, only if it was valid
  # @param [String] api_key
  #
  # @raise [SmsPilot::InvalidError] if api_key is not a String
  # @raise [SmsPilot::InvalidError] if api_key is an empty String
  #
  private def validate_api_key!(api_key)
    fail SmsPilot::InvalidAPIkeyError, "API key must be a String, you pass a #{api_key.class} (#{api_key})" unless api_key.is_a? String
    fail SmsPilot::InvalidAPIkeyError, "API key cannot be empty" if api_key == ""
    return api_key
  end


  # Validates locale
  #
  # @private
  # @return [Symbol] the original value passed into the method, only if it was valid
  # @param [Symbol] locale
  #
  # @raise [SmsPilot::InvalidError] if locale is not a Symbol
  # @raise [SmsPilot::InvalidError] if locale is unrecognized
  #
  private def validate_locale!(locale)
    fail SmsPilot::InvalidLocaleError, "locale must be a Symbol" unless locale.is_a? Symbol
    fail SmsPilot::InvalidLocaleError, "API does not support locale :#{locale}; choose one of #{AVAILABLE_LOCALES.inspect}" unless AVAILABLE_LOCALES.include? locale
    return locale
  end


  # Validates message
  # @private
  #
  # @param [String] message
  # @return [String] the original value passed into the method, only if it was valid
  #
  # @raise [SmsPilot::InvalidMessageError] if you pass anythig but a String with the <tt>message</tt> argument
  # @raise [SmsPilot::InvalidMessageError] if your message is empty
  #
  private def validate_message!(message)
    fail SmsPilot::InvalidMessageError, "SMS message must be a String, you pass a #{ message.class} (#{ message})" unless message.is_a? String
    fail SmsPilot::InvalidMessageError, "SMS message cannot be empty" if  message == ""
    message
  end


  # Validates phone
  # @private
  #
  # @param [String] phone
  # @return [String] the original value passed into the method, only if it was valid
  #
  # @raise [SmsPilot::InvalidPhoneError] if you pass anythig but a String with the <tt>phone</tt> argument
  # @raise [SmsPilot::InvalidPhoneError] if your phone is empty
  # @raise [SmsPilot::InvalidPhoneError] if your phone has no digits
  #
  private def validate_phone!(phone)
    fail SmsPilot::InvalidPhoneError, "phone must be a String, you pass a #{phone.class} (#{phone})" unless phone.is_a? String
    fail SmsPilot::InvalidPhoneError, "phone cannot be empty" if phone == ""
    fail SmsPilot::InvalidPhoneError, "phone must contain digits" if phone.scan(/\d/).none?
    phone
  end


  # Validates sender name
  # @private
  #
  # @param [nil, String] sender_name
  # @return [String] the original value passed into the method, only if it was valid
  #
  # @raise [SmsPilot::InvalidSenderNameError] if you pass anything but <tt>nil</tt> or non-empty <tt>String</tt>
  #
  private def validate_sender_name!(sender_name)
    fail SmsPilot::InvalidSenderNameError, "sender name must be either nil or String" unless [NilClass, String].include? sender_name.class
    fail SmsPilot::InvalidSenderNameError, "sender name cannot be empty" if sender_name == ""
    sender_name
  end

  # @!endgroup

end

#response_bodynil, String (readonly)

Response format is JSON (because we request it that way in #build_uri).

Examples:

"{\"send\":[{\"server_id\":\"10000\",\"phone\":\"79021234567\",\"price\":\"1.68\",\"status\":\"0\"}],\"balance\":\"20006.97\",\"cost\":\"1.68\"}"

Returns:

  • (nil, String)

    Unmodified HTTP resonse body that API returned

See Also:


69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
# File 'lib/sms_pilot/client.rb', line 69

class Client

  # Check current API endpoint URL at {https://smspilot.ru/apikey.php#api1}
  #
  API_ENDPOINT = "https://smspilot.ru/api.php".freeze

  # Locale influences only the language of API errors
  #
  AVAILABLE_LOCALES = [:ru, :en].freeze

  REQUEST_ACCEPT_FORMAT = "json".freeze
  REQUEST_CHARSET = "utf-8".freeze

  attr_reader :api_key
  attr_reader :error
  attr_reader :locale
  attr_reader :phone
  attr_reader :response_body
  attr_reader :response_headers
  attr_reader :response_status


  # @param [String] api_key
  # @param [Symbol] locale
  #
  # @return [SmsPilot::Client]
  # @raise [SmsPilot::InvalidAPIkeyError] if you pass anything but a non-empty String
  # @raise [SmsPilot::InvalidLocaleError] if you pass anything but <tt>:ru</tt> or <tt>:en</tt>
  #
  # @see https://smspilot.ru/my-settings.php Get your production API key here
  # @see https://smspilot.ru/apikey.php Get your development API key here
  # @note Current development API key is <tt>"XXXXXXXXXXXXYYYYYYYYYYYYZZZZZZZZXXXXXXXXXXXXYYYYYYYYYYYYZZZZZZZZ"</tt>
  #
  # @example
  #   client = SmsPilot::Client.new(api_key: ENV["SMS_PILOT_API_KEY"])
  #   client = SmsPilot::Client.new(api_key: ENV["SMS_PILOT_API_KEY"], locale: :en)
  #
  def initialize(api_key:, locale: AVAILABLE_LOCALES[0])
    @api_key          = validate_api_key!(api_key)
    @error            = nil
    @locale           = validate_locale!(locale)
    @response_status  = nil
    @response_headers = {}
    @response_body    = nil
  end


  # @!group Main

  # Send HTTP request to the API to ask them to transmit your SMS
  #
  # @return [Boolean] <tt>true</tt> if the SMS has been sent, <tt>false</tt> otherwise
  #
  # @param [String] phone The phone to send the SMS to. In free-form, will be sanitized.
  # @param [String] message The text of your message.
  # @param [nil, String] sender_name Must be registered in your SMS Pilot member area
  #
  # @raise [SmsPilot::InvalidPhoneError] if you pass anythig but a String with the <tt>phone</tt> argument
  # @raise [SmsPilot::InvalidMessageError] if you pass anythig but a String with the <tt>message</tt> argument
  # @raise [SmsPilot::InvalidMessageError] if your message is empty
  # @raise [SmsPilot::InvalidPhoneError] if your phone is empty
  # @raise [SmsPilot::InvalidPhoneError] if your phone has no digits
  # @raise [SmsPilot::InvalidSenderNameError] if you pass anything but <tt>nil</tt> or non-empty String
  # @raise [URI::InvalidURIError] but is almost impossible, because we provide the URL ourselves
  #
  # @example
  #   client.send_sms("+7 (902) 123-45-67", "Привет, мир!") # => true
  #   client.send_sms("+7 (902) 123-45-67", "Здарова бандиты", "ФССПРФ") # => true
  #
  def send_sms(phone, message, sender_name = nil)
    validate_phone! phone
    validate_message! message
    validate_sender_name! sender_name

    @phone = normalize_phone(phone)
    @uri   = build_uri(@phone, message, sender_name)

    response = persist_response_details Net::HTTP.get_response(@uri)

    @error = "HTTP request failed with code #{response.code}"   and return false unless response.is_a?(Net::HTTPSuccess)
    @error = "#{error_description} (error code: #{error_code})" and return false if rejected?

    true

  rescue JSON::ParserError => error
    @error = "API returned invalid JSON. #{error.message}"
    return false

  rescue SocketError, EOFError, IOError, SystemCallError,
         Timeout::Error, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError,
         Net::ProtocolError, OpenSSL::SSL::SSLError => error
    @error = error.message
    return false
  end

  # @!endgroup


  # @!group State accessors

  # Your current balance, remaining after sending that latest SMS.
  #
  # @return [nil, Float] Always <tt>nil</tt> before you send SMS and if the SMS was not sent, always Float after successfull SMS transmission.
  # @example
  #   client.balance #=> 20215.25
  #
  def balance
    response_data["balance"]&.to_f if sms_sent?
  end


  # SMS broadcast ID (API documentation calls it “server ID” but it makes no sense, as it is clearly the ID of the transmission, not of a server)
  #
  # @example
  #   client.broadcast_id #=> 10000
  #
  # @return [nil, Integer]
  #
  # @see #response_data
  #
  def broadcast_id
    @response_data.dig("send", 0, "server_id")&.to_i if sms_sent?
  end


  # SMS delivery status, as returned by the API
  #
  # @return [nil, Integer] <tt>nil</tt> is returned before sending SMS or if the request was rejected. Otherwise an <tt>Integer</tt> in the range of [-2..3] is returned.
  # @see https://smspilot.ru/apikey.php#status List of available statuses at API documentation website
  #
  # Code | Name          | Final? | Description
  # ----:|:--------------|:-------|:-------------
  # -2   | Ошибка        | Да     | Ошибка, неправильные параметры запроса
  # -1   | Не доставлено | Да     | Сообщение не доставлено (не в сети, заблокирован, не взял трубку), PING — не в сети, HLR — не обслуживается (заблокирован)
  #  0   | Новое         | Нет    | Новое сообщение/запрос, ожидает обработки у нас на сервере
  #  1   | В очереди     | Нет    | Сообщение или запрос ожидают отправки на сервере оператора
  #  2   | Доставлено    | Да     | Доставлено, звонок совершен, PING — в сети, HLR — обслуживается
  #  3   | Отложено      | Нет    | Отложенная отправка, отправка сообщения/запроса запланирована на другое время
  #
  # @example
  #   client.broadcast_status #=> 2
  #
  # @see #sms_status
  #
  def broadcast_status
    @response_data.dig("send", 0, "status")&.to_i if sms_sent?
  end


  # Numerical code of the error that occured when sending the SMS. In the range from 0 to 715 (which may change).
  #
  # @return [nil, Integer] <tt>nil</tt> is returned before sending SMS. Otherwise <tt>Integer</tt>
  # @example
  #   client.error_code #=> 122
  # @see #error
  # @see #error_description
  # @see https://smspilot.ru/apikey.php#err Error codes at the API documentation website
  #
  def error_code
    @response_data.dig("error", "code")&.to_i if rejected?
  end


  # Description of the error that occured when sending the SMS
  #
  # @return [nil, String] <tt>nil</tt> is returned before sending SMS. Otherwise <tt>String</tt>
  # @example
  #   client.error_description #=> "Пользователь временно блокирован (спорная ситуация)"
  # @see #error
  # @see #error_code
  # @see https://smspilot.ru/apikey.php#err Error codes at the API documentation website
  #
  def error_description
    method_name = (@locale == :ru) ? "description_ru" : "description"
    @response_data.dig("error", method_name) if rejected?
  end


  # Did the API reject your request to send that SMS
  #
  # @return [Boolean] <tt>false</tt> is returned before sending SMS. Otherwise the <tt>Boolean</tt> corresponds to whether your request to send an SMS was rejected.
  # @example
  #   client.rejected? #=> false
  #
  def rejected?
    return false if sms_sent?
    response_data["error"].is_a? Hash
  end


  # Parses <tt>@response_body</tt> and memoizes result in <tt>@response_data</tt>
  #
  # @example
  #   {
  #     "balance" => "20006.97",
  #     "cost" => "1.68",
  #     "send" => [
  #       {
  #         "phone" => "79021234567",
  #         "price" => "1.68",
  #         "server_id" => "10000",
  #         "status" => "0"
  #       }
  #     ]
  #   }
  #
  # @return [Hash]
  # @raise [JSON::ParserError] which is rescued in {#send_sms}
  #
  # @see #response_body
  # @see #response_headers
  # @see #response_status
  #
  def response_data
    return {} unless @response_body
    @response_data ||= JSON.parse @response_body
  end


  # Did the API block you
  #
  # Error code | Description
  # :---|:------------------
  # 105 | из-за низкого баланса
  # 106 | за спам/ошибки
  # 107 | за недостоверные учетные данные / недоступна эл. почта / проблемы с телефоном
  # 122 | спорная ситуация
  #
  # @return [Boolean] <tt>nil</tt> is returned before sending SMS. Otherwise the <tt>Boolean</tt> corresponds to whether the API has blocked you.
  # @example
  #   client.sender_blocked? #=> false
  # @see #error
  # @see https://smspilot.ru/apikey.php#err Error codes at the API documentation website
  #
  def sender_blocked?
    [105, 106, 107, 122].include? error_code
  end


  # The cost of the SMS that has just been sent, in RUB
  #
  # @return [nil, Float]
  # @example
  #   client.sms_cost #=> 2.63
  #
  def sms_cost
    response_data["cost"]&.to_f if sms_sent?
  end


  # Has the SMS transmission been a success.
  #
  # @return [Boolean] <tt>nil</tt> is returned before sending SMS. Otherwise the <tt>Boolean</tt> corresponds to the result of SMS transmission.
  # @see #sms_status
  # @see #rejected?
  # @see #error
  #
  # @example
  #   client.sms_sent? #=> true
  #
  def sms_sent?
    response_data["send"] != nil
  end


  # @deprecated (in favor of {#broadcast_status})
  #
  def sms_status
    broadcast_status
  end


  # URL generated by combining <tt>API_ENDPOINT</tt>, your API key, SMS text & phone
  #
  # @example
  #   client.url #=> "https://smspilot.ru/api.php?api_key=XXX&format=json&send=TEXT&to=79021234567"
  #
  # @return [nil, String]
  #
  def url
    @uri&.to_s
  end

  # @!endgroup


  # The URI we will send an HTTP request to
  # @private
  #
  # @example
  #   build_uri("79021234567", "Hello, World!")
  #   #=> #<URI::HTTPS https://smspilot.ru/api.php?apikey=XXX…&format=json&send=Hello%2C+World%21&to=79021234567>
  #
  # @return [URI]
  # @raise [URI::InvalidURIError] but is almost impossible, because we provide the URL ourselves
  #
  # @param [String] phone
  # @param [String] text
  # @param [nil, String] sender_name
  #
  # @see #api_key
  # @see #phone
  # @see #validate_phone!
  # @see #validate_message!
  # @see #validate_sender_name!
  #
  private def build_uri(phone, text, sender_name)
    attributes = {
      apikey:  @api_key,
      charset: REQUEST_CHARSET,
      format:  REQUEST_ACCEPT_FORMAT,
      lang:    @locale,
      send:    text,
      to:      phone
    }
    attributes = attributes.merge({ sender: sender_name }) if sender_name

    URI.parse(API_ENDPOINT).tap do |uri|
      uri.query = URI.encode_www_form(attributes)
    end
  end




  # Cleans up your phone from anything but digits. Also replaces 8 to 7 if it is the first digit.
  #
  # @private
  # @param [String] phone
  # @return [String]
  #
  # @example
  #   normalize_phone("8 (902) 123-45-67") #=> 79021234567
  #   normalize_phone("+7-902-123-45-67")  #=> 79021234567
  #
  private def normalize_phone(phone)
    phone.gsub(/[^0-9]/, '').sub(/^8/, '7').gsub('+7', '8')
  end


  # Saves response details into instance variables
  # @private
  #
  # @return [response]
  # @raise [TypeError] unless a Net::HTTPResponse passed
  #
  private def persist_response_details(response)
    fail TypeError, "Net::HTTPResponse expected, you pass a #{response.class}" unless response.is_a? Net::HTTPResponse
    @response_body    = response.body
    @response_status  = response.code.to_i
    @response_headers = response.each_capitalized.to_h
    response
  end


  # @!group Validations

  # Validates api_key
  #
  # @private
  # @return [String] the original value passed into the method, only if it was valid
  # @param [String] api_key
  #
  # @raise [SmsPilot::InvalidError] if api_key is not a String
  # @raise [SmsPilot::InvalidError] if api_key is an empty String
  #
  private def validate_api_key!(api_key)
    fail SmsPilot::InvalidAPIkeyError, "API key must be a String, you pass a #{api_key.class} (#{api_key})" unless api_key.is_a? String
    fail SmsPilot::InvalidAPIkeyError, "API key cannot be empty" if api_key == ""
    return api_key
  end


  # Validates locale
  #
  # @private
  # @return [Symbol] the original value passed into the method, only if it was valid
  # @param [Symbol] locale
  #
  # @raise [SmsPilot::InvalidError] if locale is not a Symbol
  # @raise [SmsPilot::InvalidError] if locale is unrecognized
  #
  private def validate_locale!(locale)
    fail SmsPilot::InvalidLocaleError, "locale must be a Symbol" unless locale.is_a? Symbol
    fail SmsPilot::InvalidLocaleError, "API does not support locale :#{locale}; choose one of #{AVAILABLE_LOCALES.inspect}" unless AVAILABLE_LOCALES.include? locale
    return locale
  end


  # Validates message
  # @private
  #
  # @param [String] message
  # @return [String] the original value passed into the method, only if it was valid
  #
  # @raise [SmsPilot::InvalidMessageError] if you pass anythig but a String with the <tt>message</tt> argument
  # @raise [SmsPilot::InvalidMessageError] if your message is empty
  #
  private def validate_message!(message)
    fail SmsPilot::InvalidMessageError, "SMS message must be a String, you pass a #{ message.class} (#{ message})" unless message.is_a? String
    fail SmsPilot::InvalidMessageError, "SMS message cannot be empty" if  message == ""
    message
  end


  # Validates phone
  # @private
  #
  # @param [String] phone
  # @return [String] the original value passed into the method, only if it was valid
  #
  # @raise [SmsPilot::InvalidPhoneError] if you pass anythig but a String with the <tt>phone</tt> argument
  # @raise [SmsPilot::InvalidPhoneError] if your phone is empty
  # @raise [SmsPilot::InvalidPhoneError] if your phone has no digits
  #
  private def validate_phone!(phone)
    fail SmsPilot::InvalidPhoneError, "phone must be a String, you pass a #{phone.class} (#{phone})" unless phone.is_a? String
    fail SmsPilot::InvalidPhoneError, "phone cannot be empty" if phone == ""
    fail SmsPilot::InvalidPhoneError, "phone must contain digits" if phone.scan(/\d/).none?
    phone
  end


  # Validates sender name
  # @private
  #
  # @param [nil, String] sender_name
  # @return [String] the original value passed into the method, only if it was valid
  #
  # @raise [SmsPilot::InvalidSenderNameError] if you pass anything but <tt>nil</tt> or non-empty <tt>String</tt>
  #
  private def validate_sender_name!(sender_name)
    fail SmsPilot::InvalidSenderNameError, "sender name must be either nil or String" unless [NilClass, String].include? sender_name.class
    fail SmsPilot::InvalidSenderNameError, "sender name cannot be empty" if sender_name == ""
    sender_name
  end

  # @!endgroup

end

#response_headersnil, String (readonly)

Returns Unmodified HTTP resonse headers that API returned.

Examples:

client.response_headers #=>
{
  "Access-Control-Allow-Origin" => "*",
  "Connection" => "close",
  "Content-Length" => "179",
  "Content-Type" => "application/json; charset=utf-8",
  "Date" => "Thu, 06 May 2021 04:52:58 GMT",
  "Server" => "nginx"
}

Returns:

  • (nil, String)

    Unmodified HTTP resonse headers that API returned.

See Also:


69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
# File 'lib/sms_pilot/client.rb', line 69

class Client

  # Check current API endpoint URL at {https://smspilot.ru/apikey.php#api1}
  #
  API_ENDPOINT = "https://smspilot.ru/api.php".freeze

  # Locale influences only the language of API errors
  #
  AVAILABLE_LOCALES = [:ru, :en].freeze

  REQUEST_ACCEPT_FORMAT = "json".freeze
  REQUEST_CHARSET = "utf-8".freeze

  attr_reader :api_key
  attr_reader :error
  attr_reader :locale
  attr_reader :phone
  attr_reader :response_body
  attr_reader :response_headers
  attr_reader :response_status


  # @param [String] api_key
  # @param [Symbol] locale
  #
  # @return [SmsPilot::Client]
  # @raise [SmsPilot::InvalidAPIkeyError] if you pass anything but a non-empty String
  # @raise [SmsPilot::InvalidLocaleError] if you pass anything but <tt>:ru</tt> or <tt>:en</tt>
  #
  # @see https://smspilot.ru/my-settings.php Get your production API key here
  # @see https://smspilot.ru/apikey.php Get your development API key here
  # @note Current development API key is <tt>"XXXXXXXXXXXXYYYYYYYYYYYYZZZZZZZZXXXXXXXXXXXXYYYYYYYYYYYYZZZZZZZZ"</tt>
  #
  # @example
  #   client = SmsPilot::Client.new(api_key: ENV["SMS_PILOT_API_KEY"])
  #   client = SmsPilot::Client.new(api_key: ENV["SMS_PILOT_API_KEY"], locale: :en)
  #
  def initialize(api_key:, locale: AVAILABLE_LOCALES[0])
    @api_key          = validate_api_key!(api_key)
    @error            = nil
    @locale           = validate_locale!(locale)
    @response_status  = nil
    @response_headers = {}
    @response_body    = nil
  end


  # @!group Main

  # Send HTTP request to the API to ask them to transmit your SMS
  #
  # @return [Boolean] <tt>true</tt> if the SMS has been sent, <tt>false</tt> otherwise
  #
  # @param [String] phone The phone to send the SMS to. In free-form, will be sanitized.
  # @param [String] message The text of your message.
  # @param [nil, String] sender_name Must be registered in your SMS Pilot member area
  #
  # @raise [SmsPilot::InvalidPhoneError] if you pass anythig but a String with the <tt>phone</tt> argument
  # @raise [SmsPilot::InvalidMessageError] if you pass anythig but a String with the <tt>message</tt> argument
  # @raise [SmsPilot::InvalidMessageError] if your message is empty
  # @raise [SmsPilot::InvalidPhoneError] if your phone is empty
  # @raise [SmsPilot::InvalidPhoneError] if your phone has no digits
  # @raise [SmsPilot::InvalidSenderNameError] if you pass anything but <tt>nil</tt> or non-empty String
  # @raise [URI::InvalidURIError] but is almost impossible, because we provide the URL ourselves
  #
  # @example
  #   client.send_sms("+7 (902) 123-45-67", "Привет, мир!") # => true
  #   client.send_sms("+7 (902) 123-45-67", "Здарова бандиты", "ФССПРФ") # => true
  #
  def send_sms(phone, message, sender_name = nil)
    validate_phone! phone
    validate_message! message
    validate_sender_name! sender_name

    @phone = normalize_phone(phone)
    @uri   = build_uri(@phone, message, sender_name)

    response = persist_response_details Net::HTTP.get_response(@uri)

    @error = "HTTP request failed with code #{response.code}"   and return false unless response.is_a?(Net::HTTPSuccess)
    @error = "#{error_description} (error code: #{error_code})" and return false if rejected?

    true

  rescue JSON::ParserError => error
    @error = "API returned invalid JSON. #{error.message}"
    return false

  rescue SocketError, EOFError, IOError, SystemCallError,
         Timeout::Error, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError,
         Net::ProtocolError, OpenSSL::SSL::SSLError => error
    @error = error.message
    return false
  end

  # @!endgroup


  # @!group State accessors

  # Your current balance, remaining after sending that latest SMS.
  #
  # @return [nil, Float] Always <tt>nil</tt> before you send SMS and if the SMS was not sent, always Float after successfull SMS transmission.
  # @example
  #   client.balance #=> 20215.25
  #
  def balance
    response_data["balance"]&.to_f if sms_sent?
  end


  # SMS broadcast ID (API documentation calls it “server ID” but it makes no sense, as it is clearly the ID of the transmission, not of a server)
  #
  # @example
  #   client.broadcast_id #=> 10000
  #
  # @return [nil, Integer]
  #
  # @see #response_data
  #
  def broadcast_id
    @response_data.dig("send", 0, "server_id")&.to_i if sms_sent?
  end


  # SMS delivery status, as returned by the API
  #
  # @return [nil, Integer] <tt>nil</tt> is returned before sending SMS or if the request was rejected. Otherwise an <tt>Integer</tt> in the range of [-2..3] is returned.
  # @see https://smspilot.ru/apikey.php#status List of available statuses at API documentation website
  #
  # Code | Name          | Final? | Description
  # ----:|:--------------|:-------|:-------------
  # -2   | Ошибка        | Да     | Ошибка, неправильные параметры запроса
  # -1   | Не доставлено | Да     | Сообщение не доставлено (не в сети, заблокирован, не взял трубку), PING — не в сети, HLR — не обслуживается (заблокирован)
  #  0   | Новое         | Нет    | Новое сообщение/запрос, ожидает обработки у нас на сервере
  #  1   | В очереди     | Нет    | Сообщение или запрос ожидают отправки на сервере оператора
  #  2   | Доставлено    | Да     | Доставлено, звонок совершен, PING — в сети, HLR — обслуживается
  #  3   | Отложено      | Нет    | Отложенная отправка, отправка сообщения/запроса запланирована на другое время
  #
  # @example
  #   client.broadcast_status #=> 2
  #
  # @see #sms_status
  #
  def broadcast_status
    @response_data.dig("send", 0, "status")&.to_i if sms_sent?
  end


  # Numerical code of the error that occured when sending the SMS. In the range from 0 to 715 (which may change).
  #
  # @return [nil, Integer] <tt>nil</tt> is returned before sending SMS. Otherwise <tt>Integer</tt>
  # @example
  #   client.error_code #=> 122
  # @see #error
  # @see #error_description
  # @see https://smspilot.ru/apikey.php#err Error codes at the API documentation website
  #
  def error_code
    @response_data.dig("error", "code")&.to_i if rejected?
  end


  # Description of the error that occured when sending the SMS
  #
  # @return [nil, String] <tt>nil</tt> is returned before sending SMS. Otherwise <tt>String</tt>
  # @example
  #   client.error_description #=> "Пользователь временно блокирован (спорная ситуация)"
  # @see #error
  # @see #error_code
  # @see https://smspilot.ru/apikey.php#err Error codes at the API documentation website
  #
  def error_description
    method_name = (@locale == :ru) ? "description_ru" : "description"
    @response_data.dig("error", method_name) if rejected?
  end


  # Did the API reject your request to send that SMS
  #
  # @return [Boolean] <tt>false</tt> is returned before sending SMS. Otherwise the <tt>Boolean</tt> corresponds to whether your request to send an SMS was rejected.
  # @example
  #   client.rejected? #=> false
  #
  def rejected?
    return false if sms_sent?
    response_data["error"].is_a? Hash
  end


  # Parses <tt>@response_body</tt> and memoizes result in <tt>@response_data</tt>
  #
  # @example
  #   {
  #     "balance" => "20006.97",
  #     "cost" => "1.68",
  #     "send" => [
  #       {
  #         "phone" => "79021234567",
  #         "price" => "1.68",
  #         "server_id" => "10000",
  #         "status" => "0"
  #       }
  #     ]
  #   }
  #
  # @return [Hash]
  # @raise [JSON::ParserError] which is rescued in {#send_sms}
  #
  # @see #response_body
  # @see #response_headers
  # @see #response_status
  #
  def response_data
    return {} unless @response_body
    @response_data ||= JSON.parse @response_body
  end


  # Did the API block you
  #
  # Error code | Description
  # :---|:------------------
  # 105 | из-за низкого баланса
  # 106 | за спам/ошибки
  # 107 | за недостоверные учетные данные / недоступна эл. почта / проблемы с телефоном
  # 122 | спорная ситуация
  #
  # @return [Boolean] <tt>nil</tt> is returned before sending SMS. Otherwise the <tt>Boolean</tt> corresponds to whether the API has blocked you.
  # @example
  #   client.sender_blocked? #=> false
  # @see #error
  # @see https://smspilot.ru/apikey.php#err Error codes at the API documentation website
  #
  def sender_blocked?
    [105, 106, 107, 122].include? error_code
  end


  # The cost of the SMS that has just been sent, in RUB
  #
  # @return [nil, Float]
  # @example
  #   client.sms_cost #=> 2.63
  #
  def sms_cost
    response_data["cost"]&.to_f if sms_sent?
  end


  # Has the SMS transmission been a success.
  #
  # @return [Boolean] <tt>nil</tt> is returned before sending SMS. Otherwise the <tt>Boolean</tt> corresponds to the result of SMS transmission.
  # @see #sms_status
  # @see #rejected?
  # @see #error
  #
  # @example
  #   client.sms_sent? #=> true
  #
  def sms_sent?
    response_data["send"] != nil
  end


  # @deprecated (in favor of {#broadcast_status})
  #
  def sms_status
    broadcast_status
  end


  # URL generated by combining <tt>API_ENDPOINT</tt>, your API key, SMS text & phone
  #
  # @example
  #   client.url #=> "https://smspilot.ru/api.php?api_key=XXX&format=json&send=TEXT&to=79021234567"
  #
  # @return [nil, String]
  #
  def url
    @uri&.to_s
  end

  # @!endgroup


  # The URI we will send an HTTP request to
  # @private
  #
  # @example
  #   build_uri("79021234567", "Hello, World!")
  #   #=> #<URI::HTTPS https://smspilot.ru/api.php?apikey=XXX…&format=json&send=Hello%2C+World%21&to=79021234567>
  #
  # @return [URI]
  # @raise [URI::InvalidURIError] but is almost impossible, because we provide the URL ourselves
  #
  # @param [String] phone
  # @param [String] text
  # @param [nil, String] sender_name
  #
  # @see #api_key
  # @see #phone
  # @see #validate_phone!
  # @see #validate_message!
  # @see #validate_sender_name!
  #
  private def build_uri(phone, text, sender_name)
    attributes = {
      apikey:  @api_key,
      charset: REQUEST_CHARSET,
      format:  REQUEST_ACCEPT_FORMAT,
      lang:    @locale,
      send:    text,
      to:      phone
    }
    attributes = attributes.merge({ sender: sender_name }) if sender_name

    URI.parse(API_ENDPOINT).tap do |uri|
      uri.query = URI.encode_www_form(attributes)
    end
  end




  # Cleans up your phone from anything but digits. Also replaces 8 to 7 if it is the first digit.
  #
  # @private
  # @param [String] phone
  # @return [String]
  #
  # @example
  #   normalize_phone("8 (902) 123-45-67") #=> 79021234567
  #   normalize_phone("+7-902-123-45-67")  #=> 79021234567
  #
  private def normalize_phone(phone)
    phone.gsub(/[^0-9]/, '').sub(/^8/, '7').gsub('+7', '8')
  end


  # Saves response details into instance variables
  # @private
  #
  # @return [response]
  # @raise [TypeError] unless a Net::HTTPResponse passed
  #
  private def persist_response_details(response)
    fail TypeError, "Net::HTTPResponse expected, you pass a #{response.class}" unless response.is_a? Net::HTTPResponse
    @response_body    = response.body
    @response_status  = response.code.to_i
    @response_headers = response.each_capitalized.to_h
    response
  end


  # @!group Validations

  # Validates api_key
  #
  # @private
  # @return [String] the original value passed into the method, only if it was valid
  # @param [String] api_key
  #
  # @raise [SmsPilot::InvalidError] if api_key is not a String
  # @raise [SmsPilot::InvalidError] if api_key is an empty String
  #
  private def validate_api_key!(api_key)
    fail SmsPilot::InvalidAPIkeyError, "API key must be a String, you pass a #{api_key.class} (#{api_key})" unless api_key.is_a? String
    fail SmsPilot::InvalidAPIkeyError, "API key cannot be empty" if api_key == ""
    return api_key
  end


  # Validates locale
  #
  # @private
  # @return [Symbol] the original value passed into the method, only if it was valid
  # @param [Symbol] locale
  #
  # @raise [SmsPilot::InvalidError] if locale is not a Symbol
  # @raise [SmsPilot::InvalidError] if locale is unrecognized
  #
  private def validate_locale!(locale)
    fail SmsPilot::InvalidLocaleError, "locale must be a Symbol" unless locale.is_a? Symbol
    fail SmsPilot::InvalidLocaleError, "API does not support locale :#{locale}; choose one of #{AVAILABLE_LOCALES.inspect}" unless AVAILABLE_LOCALES.include? locale
    return locale
  end


  # Validates message
  # @private
  #
  # @param [String] message
  # @return [String] the original value passed into the method, only if it was valid
  #
  # @raise [SmsPilot::InvalidMessageError] if you pass anythig but a String with the <tt>message</tt> argument
  # @raise [SmsPilot::InvalidMessageError] if your message is empty
  #
  private def validate_message!(message)
    fail SmsPilot::InvalidMessageError, "SMS message must be a String, you pass a #{ message.class} (#{ message})" unless message.is_a? String
    fail SmsPilot::InvalidMessageError, "SMS message cannot be empty" if  message == ""
    message
  end


  # Validates phone
  # @private
  #
  # @param [String] phone
  # @return [String] the original value passed into the method, only if it was valid
  #
  # @raise [SmsPilot::InvalidPhoneError] if you pass anythig but a String with the <tt>phone</tt> argument
  # @raise [SmsPilot::InvalidPhoneError] if your phone is empty
  # @raise [SmsPilot::InvalidPhoneError] if your phone has no digits
  #
  private def validate_phone!(phone)
    fail SmsPilot::InvalidPhoneError, "phone must be a String, you pass a #{phone.class} (#{phone})" unless phone.is_a? String
    fail SmsPilot::InvalidPhoneError, "phone cannot be empty" if phone == ""
    fail SmsPilot::InvalidPhoneError, "phone must contain digits" if phone.scan(/\d/).none?
    phone
  end


  # Validates sender name
  # @private
  #
  # @param [nil, String] sender_name
  # @return [String] the original value passed into the method, only if it was valid
  #
  # @raise [SmsPilot::InvalidSenderNameError] if you pass anything but <tt>nil</tt> or non-empty <tt>String</tt>
  #
  private def validate_sender_name!(sender_name)
    fail SmsPilot::InvalidSenderNameError, "sender name must be either nil or String" unless [NilClass, String].include? sender_name.class
    fail SmsPilot::InvalidSenderNameError, "sender name cannot be empty" if sender_name == ""
    sender_name
  end

  # @!endgroup

end

#response_statusnil, Integer (readonly)

HTTP status of the request to the API. 200 in case of success.

Examples:

client.response_status #=> 200

Returns:

  • (nil, Integer)

See Also:


69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
# File 'lib/sms_pilot/client.rb', line 69

class Client

  # Check current API endpoint URL at {https://smspilot.ru/apikey.php#api1}
  #
  API_ENDPOINT = "https://smspilot.ru/api.php".freeze

  # Locale influences only the language of API errors
  #
  AVAILABLE_LOCALES = [:ru, :en].freeze

  REQUEST_ACCEPT_FORMAT = "json".freeze
  REQUEST_CHARSET = "utf-8".freeze

  attr_reader :api_key
  attr_reader :error
  attr_reader :locale
  attr_reader :phone
  attr_reader :response_body
  attr_reader :response_headers
  attr_reader :response_status


  # @param [String] api_key
  # @param [Symbol] locale
  #
  # @return [SmsPilot::Client]
  # @raise [SmsPilot::InvalidAPIkeyError] if you pass anything but a non-empty String
  # @raise [SmsPilot::InvalidLocaleError] if you pass anything but <tt>:ru</tt> or <tt>:en</tt>
  #
  # @see https://smspilot.ru/my-settings.php Get your production API key here
  # @see https://smspilot.ru/apikey.php Get your development API key here
  # @note Current development API key is <tt>"XXXXXXXXXXXXYYYYYYYYYYYYZZZZZZZZXXXXXXXXXXXXYYYYYYYYYYYYZZZZZZZZ"</tt>
  #
  # @example
  #   client = SmsPilot::Client.new(api_key: ENV["SMS_PILOT_API_KEY"])
  #   client = SmsPilot::Client.new(api_key: ENV["SMS_PILOT_API_KEY"], locale: :en)
  #
  def initialize(api_key:, locale: AVAILABLE_LOCALES[0])
    @api_key          = validate_api_key!(api_key)
    @error            = nil
    @locale           = validate_locale!(locale)
    @response_status  = nil
    @response_headers = {}
    @response_body    = nil
  end


  # @!group Main

  # Send HTTP request to the API to ask them to transmit your SMS
  #
  # @return [Boolean] <tt>true</tt> if the SMS has been sent, <tt>false</tt> otherwise
  #
  # @param [String] phone The phone to send the SMS to. In free-form, will be sanitized.
  # @param [String] message The text of your message.
  # @param [nil, String] sender_name Must be registered in your SMS Pilot member area
  #
  # @raise [SmsPilot::InvalidPhoneError] if you pass anythig but a String with the <tt>phone</tt> argument
  # @raise [SmsPilot::InvalidMessageError] if you pass anythig but a String with the <tt>message</tt> argument
  # @raise [SmsPilot::InvalidMessageError] if your message is empty
  # @raise [SmsPilot::InvalidPhoneError] if your phone is empty
  # @raise [SmsPilot::InvalidPhoneError] if your phone has no digits
  # @raise [SmsPilot::InvalidSenderNameError] if you pass anything but <tt>nil</tt> or non-empty String
  # @raise [URI::InvalidURIError] but is almost impossible, because we provide the URL ourselves
  #
  # @example
  #   client.send_sms("+7 (902) 123-45-67", "Привет, мир!") # => true
  #   client.send_sms("+7 (902) 123-45-67", "Здарова бандиты", "ФССПРФ") # => true
  #
  def send_sms(phone, message, sender_name = nil)
    validate_phone! phone
    validate_message! message
    validate_sender_name! sender_name

    @phone = normalize_phone(phone)
    @uri   = build_uri(@phone, message, sender_name)

    response = persist_response_details Net::HTTP.get_response(@uri)

    @error = "HTTP request failed with code #{response.code}"   and return false unless response.is_a?(Net::HTTPSuccess)
    @error = "#{error_description} (error code: #{error_code})" and return false if rejected?

    true

  rescue JSON::ParserError => error
    @error = "API returned invalid JSON. #{error.message}"
    return false

  rescue SocketError, EOFError, IOError, SystemCallError,
         Timeout::Error, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError,
         Net::ProtocolError, OpenSSL::SSL::SSLError => error
    @error = error.message
    return false
  end

  # @!endgroup


  # @!group State accessors

  # Your current balance, remaining after sending that latest SMS.
  #
  # @return [nil, Float] Always <tt>nil</tt> before you send SMS and if the SMS was not sent, always Float after successfull SMS transmission.
  # @example
  #   client.balance #=> 20215.25
  #
  def balance
    response_data["balance"]&.to_f if sms_sent?
  end


  # SMS broadcast ID (API documentation calls it “server ID” but it makes no sense, as it is clearly the ID of the transmission, not of a server)
  #
  # @example
  #   client.broadcast_id #=> 10000
  #
  # @return [nil, Integer]
  #
  # @see #response_data
  #
  def broadcast_id
    @response_data.dig("send", 0, "server_id")&.to_i if sms_sent?
  end


  # SMS delivery status, as returned by the API
  #
  # @return [nil, Integer] <tt>nil</tt> is returned before sending SMS or if the request was rejected. Otherwise an <tt>Integer</tt> in the range of [-2..3] is returned.
  # @see https://smspilot.ru/apikey.php#status List of available statuses at API documentation website
  #
  # Code | Name          | Final? | Description
  # ----:|:--------------|:-------|:-------------
  # -2   | Ошибка        | Да     | Ошибка, неправильные параметры запроса
  # -1   | Не доставлено | Да     | Сообщение не доставлено (не в сети, заблокирован, не взял трубку), PING — не в сети, HLR — не обслуживается (заблокирован)
  #  0   | Новое         | Нет    | Новое сообщение/запрос, ожидает обработки у нас на сервере
  #  1   | В очереди     | Нет    | Сообщение или запрос ожидают отправки на сервере оператора
  #  2   | Доставлено    | Да     | Доставлено, звонок совершен, PING — в сети, HLR — обслуживается
  #  3   | Отложено      | Нет    | Отложенная отправка, отправка сообщения/запроса запланирована на другое время
  #
  # @example
  #   client.broadcast_status #=> 2
  #
  # @see #sms_status
  #
  def broadcast_status
    @response_data.dig("send", 0, "status")&.to_i if sms_sent?
  end


  # Numerical code of the error that occured when sending the SMS. In the range from 0 to 715 (which may change).
  #
  # @return [nil, Integer] <tt>nil</tt> is returned before sending SMS. Otherwise <tt>Integer</tt>
  # @example
  #   client.error_code #=> 122
  # @see #error
  # @see #error_description
  # @see https://smspilot.ru/apikey.php#err Error codes at the API documentation website
  #
  def error_code
    @response_data.dig("error", "code")&.to_i if rejected?
  end


  # Description of the error that occured when sending the SMS
  #
  # @return [nil, String] <tt>nil</tt> is returned before sending SMS. Otherwise <tt>String</tt>
  # @example
  #   client.error_description #=> "Пользователь временно блокирован (спорная ситуация)"
  # @see #error
  # @see #error_code
  # @see https://smspilot.ru/apikey.php#err Error codes at the API documentation website
  #
  def error_description
    method_name = (@locale == :ru) ? "description_ru" : "description"
    @response_data.dig("error", method_name) if rejected?
  end


  # Did the API reject your request to send that SMS
  #
  # @return [Boolean] <tt>false</tt> is returned before sending SMS. Otherwise the <tt>Boolean</tt> corresponds to whether your request to send an SMS was rejected.
  # @example
  #   client.rejected? #=> false
  #
  def rejected?
    return false if sms_sent?
    response_data["error"].is_a? Hash
  end


  # Parses <tt>@response_body</tt> and memoizes result in <tt>@response_data</tt>
  #
  # @example
  #   {
  #     "balance" => "20006.97",
  #     "cost" => "1.68",
  #     "send" => [
  #       {
  #         "phone" => "79021234567",
  #         "price" => "1.68",
  #         "server_id" => "10000",
  #         "status" => "0"
  #       }
  #     ]
  #   }
  #
  # @return [Hash]
  # @raise [JSON::ParserError] which is rescued in {#send_sms}
  #
  # @see #response_body
  # @see #response_headers
  # @see #response_status
  #
  def response_data
    return {} unless @response_body
    @response_data ||= JSON.parse @response_body
  end


  # Did the API block you
  #
  # Error code | Description
  # :---|:------------------
  # 105 | из-за низкого баланса
  # 106 | за спам/ошибки
  # 107 | за недостоверные учетные данные / недоступна эл. почта / проблемы с телефоном
  # 122 | спорная ситуация
  #
  # @return [Boolean] <tt>nil</tt> is returned before sending SMS. Otherwise the <tt>Boolean</tt> corresponds to whether the API has blocked you.
  # @example
  #   client.sender_blocked? #=> false
  # @see #error
  # @see https://smspilot.ru/apikey.php#err Error codes at the API documentation website
  #
  def sender_blocked?
    [105, 106, 107, 122].include? error_code
  end


  # The cost of the SMS that has just been sent, in RUB
  #
  # @return [nil, Float]
  # @example
  #   client.sms_cost #=> 2.63
  #
  def sms_cost
    response_data["cost"]&.to_f if sms_sent?
  end


  # Has the SMS transmission been a success.
  #
  # @return [Boolean] <tt>nil</tt> is returned before sending SMS. Otherwise the <tt>Boolean</tt> corresponds to the result of SMS transmission.
  # @see #sms_status
  # @see #rejected?
  # @see #error
  #
  # @example
  #   client.sms_sent? #=> true
  #
  def sms_sent?
    response_data["send"] != nil
  end


  # @deprecated (in favor of {#broadcast_status})
  #
  def sms_status
    broadcast_status
  end


  # URL generated by combining <tt>API_ENDPOINT</tt>, your API key, SMS text & phone
  #
  # @example
  #   client.url #=> "https://smspilot.ru/api.php?api_key=XXX&format=json&send=TEXT&to=79021234567"
  #
  # @return [nil, String]
  #
  def url
    @uri&.to_s
  end

  # @!endgroup


  # The URI we will send an HTTP request to
  # @private
  #
  # @example
  #   build_uri("79021234567", "Hello, World!")
  #   #=> #<URI::HTTPS https://smspilot.ru/api.php?apikey=XXX…&format=json&send=Hello%2C+World%21&to=79021234567>
  #
  # @return [URI]
  # @raise [URI::InvalidURIError] but is almost impossible, because we provide the URL ourselves
  #
  # @param [String] phone
  # @param [String] text
  # @param [nil, String] sender_name
  #
  # @see #api_key
  # @see #phone
  # @see #validate_phone!
  # @see #validate_message!
  # @see #validate_sender_name!
  #
  private def build_uri(phone, text, sender_name)
    attributes = {
      apikey:  @api_key,
      charset: REQUEST_CHARSET,
      format:  REQUEST_ACCEPT_FORMAT,
      lang:    @locale,
      send:    text,
      to:      phone
    }
    attributes = attributes.merge({ sender: sender_name }) if sender_name

    URI.parse(API_ENDPOINT).tap do |uri|
      uri.query = URI.encode_www_form(attributes)
    end
  end




  # Cleans up your phone from anything but digits. Also replaces 8 to 7 if it is the first digit.
  #
  # @private
  # @param [String] phone
  # @return [String]
  #
  # @example
  #   normalize_phone("8 (902) 123-45-67") #=> 79021234567
  #   normalize_phone("+7-902-123-45-67")  #=> 79021234567
  #
  private def normalize_phone(phone)
    phone.gsub(/[^0-9]/, '').sub(/^8/, '7').gsub('+7', '8')
  end


  # Saves response details into instance variables
  # @private
  #
  # @return [response]
  # @raise [TypeError] unless a Net::HTTPResponse passed
  #
  private def persist_response_details(response)
    fail TypeError, "Net::HTTPResponse expected, you pass a #{response.class}" unless response.is_a? Net::HTTPResponse
    @response_body    = response.body
    @response_status  = response.code.to_i
    @response_headers = response.each_capitalized.to_h
    response
  end


  # @!group Validations

  # Validates api_key
  #
  # @private
  # @return [String] the original value passed into the method, only if it was valid
  # @param [String] api_key
  #
  # @raise [SmsPilot::InvalidError] if api_key is not a String
  # @raise [SmsPilot::InvalidError] if api_key is an empty String
  #
  private def validate_api_key!(api_key)
    fail SmsPilot::InvalidAPIkeyError, "API key must be a String, you pass a #{api_key.class} (#{api_key})" unless api_key.is_a? String
    fail SmsPilot::InvalidAPIkeyError, "API key cannot be empty" if api_key == ""
    return api_key
  end


  # Validates locale
  #
  # @private
  # @return [Symbol] the original value passed into the method, only if it was valid
  # @param [Symbol] locale
  #
  # @raise [SmsPilot::InvalidError] if locale is not a Symbol
  # @raise [SmsPilot::InvalidError] if locale is unrecognized
  #
  private def validate_locale!(locale)
    fail SmsPilot::InvalidLocaleError, "locale must be a Symbol" unless locale.is_a? Symbol
    fail SmsPilot::InvalidLocaleError, "API does not support locale :#{locale}; choose one of #{AVAILABLE_LOCALES.inspect}" unless AVAILABLE_LOCALES.include? locale
    return locale
  end


  # Validates message
  # @private
  #
  # @param [String] message
  # @return [String] the original value passed into the method, only if it was valid
  #
  # @raise [SmsPilot::InvalidMessageError] if you pass anythig but a String with the <tt>message</tt> argument
  # @raise [SmsPilot::InvalidMessageError] if your message is empty
  #
  private def validate_message!(message)
    fail SmsPilot::InvalidMessageError, "SMS message must be a String, you pass a #{ message.class} (#{ message})" unless message.is_a? String
    fail SmsPilot::InvalidMessageError, "SMS message cannot be empty" if  message == ""
    message
  end


  # Validates phone
  # @private
  #
  # @param [String] phone
  # @return [String] the original value passed into the method, only if it was valid
  #
  # @raise [SmsPilot::InvalidPhoneError] if you pass anythig but a String with the <tt>phone</tt> argument
  # @raise [SmsPilot::InvalidPhoneError] if your phone is empty
  # @raise [SmsPilot::InvalidPhoneError] if your phone has no digits
  #
  private def validate_phone!(phone)
    fail SmsPilot::InvalidPhoneError, "phone must be a String, you pass a #{phone.class} (#{phone})" unless phone.is_a? String
    fail SmsPilot::InvalidPhoneError, "phone cannot be empty" if phone == ""
    fail SmsPilot::InvalidPhoneError, "phone must contain digits" if phone.scan(/\d/).none?
    phone
  end


  # Validates sender name
  # @private
  #
  # @param [nil, String] sender_name
  # @return [String] the original value passed into the method, only if it was valid
  #
  # @raise [SmsPilot::InvalidSenderNameError] if you pass anything but <tt>nil</tt> or non-empty <tt>String</tt>
  #
  private def validate_sender_name!(sender_name)
    fail SmsPilot::InvalidSenderNameError, "sender name must be either nil or String" unless [NilClass, String].include? sender_name.class
    fail SmsPilot::InvalidSenderNameError, "sender name cannot be empty" if sender_name == ""
    sender_name
  end

  # @!endgroup

end

Instance Method Details

#balancenil, Float

Your current balance, remaining after sending that latest SMS.

Examples:

client.balance #=> 20215.25

Returns:

  • (nil, Float)

    Always nil before you send SMS and if the SMS was not sent, always Float after successfull SMS transmission.


175
176
177
# File 'lib/sms_pilot/client.rb', line 175

def balance
  response_data["balance"]&.to_f if sms_sent?
end

#broadcast_idnil, Integer

SMS broadcast ID (API documentation calls it “server ID” but it makes no sense, as it is clearly the ID of the transmission, not of a server)

Examples:

client.broadcast_id #=> 10000

Returns:

  • (nil, Integer)

See Also:


189
190
191
# File 'lib/sms_pilot/client.rb', line 189

def broadcast_id
  @response_data.dig("send", 0, "server_id")&.to_i if sms_sent?
end

#broadcast_statusnil, Integer

SMS delivery status, as returned by the API

Code Name Final? Description
-2 Ошибка Да Ошибка, неправильные параметры запроса
-1 Не доставлено Да Сообщение не доставлено (не в сети, заблокирован, не взял трубку), PING — не в сети, HLR — не обслуживается (заблокирован)
0 Новое Нет Новое сообщение/запрос, ожидает обработки у нас на сервере
1 В очереди Нет Сообщение или запрос ожидают отправки на сервере оператора
2 Доставлено Да Доставлено, звонок совершен, PING — в сети, HLR — обслуживается
3 Отложено Нет Отложенная отправка, отправка сообщения/запроса запланирована на другое время

Examples:

client.broadcast_status #=> 2

Returns:

  • (nil, Integer)

    nil is returned before sending SMS or if the request was rejected. Otherwise an Integer in the range of [-2..3] is returned.

See Also:


213
214
215
# File 'lib/sms_pilot/client.rb', line 213

def broadcast_status
  @response_data.dig("send", 0, "status")&.to_i if sms_sent?
end

#build_uri(phone, text, sender_name) ⇒ URI (private)

The URI we will send an HTTP request to

Examples:

build_uri("79021234567", "Hello, World!")
#=> #<URI::HTTPS https://smspilot.ru/api.php?apikey=XXX…&format=json&send=Hello%2C+World%21&to=79021234567>

Parameters:

  • phone (String)
  • text (String)
  • sender_name (nil, String)

Returns:

  • (URI)

Raises:

  • (URI::InvalidURIError)

    but is almost impossible, because we provide the URL ourselves

See Also:


375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
# File 'lib/sms_pilot/client.rb', line 375

private def build_uri(phone, text, sender_name)
  attributes = {
    apikey:  @api_key,
    charset: REQUEST_CHARSET,
    format:  REQUEST_ACCEPT_FORMAT,
    lang:    @locale,
    send:    text,
    to:      phone
  }
  attributes = attributes.merge({ sender: sender_name }) if sender_name

  URI.parse(API_ENDPOINT).tap do |uri|
    uri.query = URI.encode_www_form(attributes)
  end
end

#error_codenil, Integer

Numerical code of the error that occured when sending the SMS. In the range from 0 to 715 (which may change).

Examples:

client.error_code #=> 122

Returns:

  • (nil, Integer)

    nil is returned before sending SMS. Otherwise Integer

See Also:


227
228
229
# File 'lib/sms_pilot/client.rb', line 227

def error_code
  @response_data.dig("error", "code")&.to_i if rejected?
end

#error_descriptionnil, String

Description of the error that occured when sending the SMS

Examples:

client.error_description #=> "Пользователь временно блокирован (спорная ситуация)"

Returns:

  • (nil, String)

    nil is returned before sending SMS. Otherwise String

See Also:


241
242
243
244
# File 'lib/sms_pilot/client.rb', line 241

def error_description
  method_name = (@locale == :ru) ? "description_ru" : "description"
  @response_data.dig("error", method_name) if rejected?
end

#normalize_phone(phone) ⇒ String (private)

Cleans up your phone from anything but digits. Also replaces 8 to 7 if it is the first digit.

Examples:

normalize_phone("8 (902) 123-45-67") #=> 79021234567
normalize_phone("+7-902-123-45-67")  #=> 79021234567

Parameters:

  • phone (String)

Returns:

  • (String)

404
405
406
# File 'lib/sms_pilot/client.rb', line 404

private def normalize_phone(phone)
  phone.gsub(/[^0-9]/, '').sub(/^8/, '7').gsub('+7', '8')
end

#persist_response_details(response) ⇒ response (private)

Saves response details into instance variables

Returns:

  • (response)

Raises:

  • (TypeError)

    unless a Net::HTTPResponse passed


415
416
417
418
419
420
421
# File 'lib/sms_pilot/client.rb', line 415

private def persist_response_details(response)
  fail TypeError, "Net::HTTPResponse expected, you pass a #{response.class}" unless response.is_a? Net::HTTPResponse
  @response_body    = response.body
  @response_status  = response.code.to_i
  @response_headers = response.each_capitalized.to_h
  response
end

#rejected?Boolean

Did the API reject your request to send that SMS

Examples:

client.rejected? #=> false

Returns:

  • (Boolean)

    false is returned before sending SMS. Otherwise the Boolean corresponds to whether your request to send an SMS was rejected.


253
254
255
256
# File 'lib/sms_pilot/client.rb', line 253

def rejected?
  return false if sms_sent?
  response_data["error"].is_a? Hash
end

#response_dataHash

Parses @response_body and memoizes result in @response_data

Examples:

{
  "balance" => "20006.97",
  "cost" => "1.68",
  "send" => [
    {
      "phone" => "79021234567",
      "price" => "1.68",
      "server_id" => "10000",
      "status" => "0"
    }
  ]
}

Returns:

  • (Hash)

Raises:

  • (JSON::ParserError)

    which is rescued in #send_sms

See Also:


282
283
284
285
# File 'lib/sms_pilot/client.rb', line 282

def response_data
  return {} unless @response_body
  @response_data ||= JSON.parse @response_body
end

#send_sms(phone, message, sender_name = nil) ⇒ Boolean

Send HTTP request to the API to ask them to transmit your SMS

Examples:

client.send_sms("+7 (902) 123-45-67", "Привет, мир!") # => true
client.send_sms("+7 (902) 123-45-67", "Здарова бандиты", "ФССПРФ") # => true

Parameters:

  • phone (String)

    The phone to send the SMS to. In free-form, will be sanitized.

  • message (String)

    The text of your message.

  • sender_name (nil, String) (defaults to: nil)

    Must be registered in your SMS Pilot member area

Returns:

  • (Boolean)

    true if the SMS has been sent, false otherwise

Raises:


138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
# File 'lib/sms_pilot/client.rb', line 138

def send_sms(phone, message, sender_name = nil)
  validate_phone! phone
  validate_message! message
  validate_sender_name! sender_name

  @phone = normalize_phone(phone)
  @uri   = build_uri(@phone, message, sender_name)

  response = persist_response_details Net::HTTP.get_response(@uri)

  @error = "HTTP request failed with code #{response.code}"   and return false unless response.is_a?(Net::HTTPSuccess)
  @error = "#{error_description} (error code: #{error_code})" and return false if rejected?

  true

rescue JSON::ParserError => error
  @error = "API returned invalid JSON. #{error.message}"
  return false

rescue SocketError, EOFError, IOError, SystemCallError,
       Timeout::Error, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError,
       Net::ProtocolError, OpenSSL::SSL::SSLError => error
  @error = error.message
  return false
end

#sender_blocked?Boolean

Did the API block you

Error code Description
105 из-за низкого баланса
106 за спам/ошибки
107 за недостоверные учетные данные / недоступна эл. почта / проблемы с телефоном
122 спорная ситуация

Examples:

client.sender_blocked? #=> false

Returns:

  • (Boolean)

    nil is returned before sending SMS. Otherwise the Boolean corresponds to whether the API has blocked you.

See Also:


303
304
305
# File 'lib/sms_pilot/client.rb', line 303

def sender_blocked?
  [105, 106, 107, 122].include? error_code
end

#sms_costnil, Float

The cost of the SMS that has just been sent, in RUB

Examples:

client.sms_cost #=> 2.63

Returns:

  • (nil, Float)

314
315
316
# File 'lib/sms_pilot/client.rb', line 314

def sms_cost
  response_data["cost"]&.to_f if sms_sent?
end

#sms_sent?Boolean

Has the SMS transmission been a success.

Examples:

client.sms_sent? #=> true

Returns:

  • (Boolean)

    nil is returned before sending SMS. Otherwise the Boolean corresponds to the result of SMS transmission.

See Also:


329
330
331
# File 'lib/sms_pilot/client.rb', line 329

def sms_sent?
  response_data["send"] != nil
end

#sms_statusObject

Deprecated.

(in favor of #broadcast_status)


336
337
338
# File 'lib/sms_pilot/client.rb', line 336

def sms_status
  broadcast_status
end

#urlnil, String

URL generated by combining API_ENDPOINT, your API key, SMS text & phone

Examples:

client.url #=> "https://smspilot.ru/api.php?api_key=XXX&format=json&send=TEXT&to=79021234567"

Returns:

  • (nil, String)

348
349
350
# File 'lib/sms_pilot/client.rb', line 348

def url
  @uri&.to_s
end

#validate_api_key!(api_key) ⇒ String (private)

Validates api_key

Parameters:

  • api_key (String)

Returns:

  • (String)

    the original value passed into the method, only if it was valid

Raises:

  • (SmsPilot::InvalidError)

    if api_key is not a String

  • (SmsPilot::InvalidError)

    if api_key is an empty String


435
436
437
438
439
# File 'lib/sms_pilot/client.rb', line 435

private def validate_api_key!(api_key)
  fail SmsPilot::InvalidAPIkeyError, "API key must be a String, you pass a #{api_key.class} (#{api_key})" unless api_key.is_a? String
  fail SmsPilot::InvalidAPIkeyError, "API key cannot be empty" if api_key == ""
  return api_key
end

#validate_locale!(locale) ⇒ Symbol (private)

Validates locale

Parameters:

  • locale (Symbol)

Returns:

  • (Symbol)

    the original value passed into the method, only if it was valid

Raises:

  • (SmsPilot::InvalidError)

    if locale is not a Symbol

  • (SmsPilot::InvalidError)

    if locale is unrecognized


451
452
453
454
455
# File 'lib/sms_pilot/client.rb', line 451

private def validate_locale!(locale)
  fail SmsPilot::InvalidLocaleError, "locale must be a Symbol" unless locale.is_a? Symbol
  fail SmsPilot::InvalidLocaleError, "API does not support locale :#{locale}; choose one of #{AVAILABLE_LOCALES.inspect}" unless AVAILABLE_LOCALES.include? locale
  return locale
end

#validate_message!(message) ⇒ String (private)

Validates message

Parameters:

  • message (String)

Returns:

  • (String)

    the original value passed into the method, only if it was valid

Raises:


467
468
469
470
471
# File 'lib/sms_pilot/client.rb', line 467

private def validate_message!(message)
  fail SmsPilot::InvalidMessageError, "SMS message must be a String, you pass a #{ message.class} (#{ message})" unless message.is_a? String
  fail SmsPilot::InvalidMessageError, "SMS message cannot be empty" if  message == ""
  message
end

#validate_phone!(phone) ⇒ String (private)

Validates phone

Parameters:

  • phone (String)

Returns:

  • (String)

    the original value passed into the method, only if it was valid

Raises:


484
485
486
487
488
489
# File 'lib/sms_pilot/client.rb', line 484

private def validate_phone!(phone)
  fail SmsPilot::InvalidPhoneError, "phone must be a String, you pass a #{phone.class} (#{phone})" unless phone.is_a? String
  fail SmsPilot::InvalidPhoneError, "phone cannot be empty" if phone == ""
  fail SmsPilot::InvalidPhoneError, "phone must contain digits" if phone.scan(/\d/).none?
  phone
end

#validate_sender_name!(sender_name) ⇒ String (private)

Validates sender name

Parameters:

  • sender_name (nil, String)

Returns:

  • (String)

    the original value passed into the method, only if it was valid

Raises:


500
501
502
503
504
# File 'lib/sms_pilot/client.rb', line 500

private def validate_sender_name!(sender_name)
  fail SmsPilot::InvalidSenderNameError, "sender name must be either nil or String" unless [NilClass, String].include? sender_name.class
  fail SmsPilot::InvalidSenderNameError, "sender name cannot be empty" if sender_name == ""
  sender_name
end