sms_traffic_sdk 0.0.8 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,78 +1,148 @@
1
1
  require 'net/http'
2
+ require 'openssl'
3
+ require 'uri'
4
+ require_relative 'client/reply'
5
+ require_relative 'client/deliver_reply'
6
+ require_relative 'client/status_reply'
7
+ require_relative 'client/account_reply'
2
8
 
3
9
  module SmsTraffic
4
10
  class Client
5
- class << self
6
- attr_reader :uri
11
+ ENDPOINT = 'multi.php'.freeze
12
+ FILTERED = '[FILTERED]'.freeze
13
+ FILTERED_PARAMS = %i[login password message].freeze
14
+ CONNECTION_ERRORS = [
15
+ SocketError, SystemCallError, IOError, Net::OpenTimeout, Net::ReadTimeout, OpenSSL::SSL::SSLError
16
+ ].freeze
17
+ # Failures that prove the request never reached the server.
18
+ UNREACHABLE_ERRORS = [
19
+ SocketError, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ENETUNREACH, Net::OpenTimeout
20
+ ].freeze
7
21
 
8
- # @param phone [String] phone number
22
+ class << self
23
+ # @param phone [String] phone number or phone numbers separated by comma
9
24
  # @param message [String] message
10
- # @param originator [String] originator (subject)
25
+ # @param originator [String] originator (subject), defaults to the configured one
11
26
  # @param options [Hash] additional options
27
+ # @return [DeliverReply]
28
+ # @raise [ConnectionError, HttpError, ApiError]
12
29
  def deliver(phone, message, originator = nil, **options)
13
30
  originator ||= SmsTraffic.configuration.originator
14
- form_data = deliver_params(phone, message, originator, **options)
15
- response = perform_request(form_data)
16
- Response.new(response, type: :deliver)
31
+ request(DeliverReply, deliver_params(phone, message, originator, **options), read_only: false)
32
+ end
33
+
34
+ # @param sms_ids [Array<String>] sms ids
35
+ # @return [StatusReply]
36
+ # @raise [ConnectionError, HttpError, ApiError]
37
+ def status(*sms_ids)
38
+ request(StatusReply, status_params(sms_ids), read_only: true)
17
39
  end
18
40
 
19
- # @param message_id [String] message id
20
- def status(*message_ids)
21
- form_data = status_params(message_ids)
22
- response = perform_request(form_data)
23
- Response.new(response, type: :status)
41
+ # @return [AccountReply]
42
+ # @raise [ConnectionError, HttpError, ApiError]
43
+ def balance
44
+ request(AccountReply, account_params, read_only: true)
24
45
  end
25
46
 
26
47
  private
27
48
 
28
- # @return [Net::HTTP]
29
- def connection
30
- host, logger = SmsTraffic.configuration.values_at(:host, :logger)
49
+ # Sends the request to the configured host and, when a fallback host is set, repeats it there once if the
50
+ # failure allows it.
51
+ def request(reply_class, form_data, read_only:)
52
+ config = SmsTraffic.configuration
53
+ attempt(reply_class, form_data, config.host)
54
+ rescue Error => e
55
+ raise unless config.fallback_host && retry_on_fallback?(e, read_only:)
56
+
57
+ log("retry on #{config.fallback_host} after #{retry_reason(e)}")
58
+ attempt(reply_class, form_data, config.fallback_host)
59
+ end
60
+
61
+ # Names the root cause and, for an API error, its code; never the message text.
62
+ def retry_reason(error)
63
+ reason = (error.cause&.class || error.class).to_s
64
+ error.is_a?(ApiError) ? "#{reason} #{error.code}" : reason
65
+ end
66
+
67
+ def attempt(reply_class, form_data, host)
68
+ reply = reply_class.new(post(form_data, host))
69
+ raise ApiError.new(reply.code, reply.description) if reply.api_error?
70
+
71
+ reply
72
+ end
31
73
 
32
- @uri ||= URI.join(host, 'multi.php')
33
- http = Net::HTTP.new(uri.host, uri.port)
34
- http.read_timeout = 30
35
- http.use_ssl = (uri.scheme == 'https')
36
- http.set_debug_output(logger)
37
- http
74
+ # A read-only request is safe to repeat after any transport failure. A deliver is repeated only when the
75
+ # request never reached the server, as the provider may already have queued the SMS otherwise.
76
+ def retry_on_fallback?(error, read_only:)
77
+ case error
78
+ when ApiError then error.retryable?
79
+ when ConnectionError then read_only || UNREACHABLE_ERRORS.any? { |klass| error.cause.is_a?(klass) }
80
+ when HttpError then read_only && error.status >= 500
81
+ else false
82
+ end
38
83
  end
39
84
 
40
85
  # @param form_data [Hash<Symbol, String|Integer>]
41
- # @return [Net::HTTPResponse]
42
- def perform_request(form_data)
43
- connection.start do |http|
86
+ # @return [String] response body
87
+ def post(form_data, host)
88
+ config = SmsTraffic.configuration
89
+ uri = URI.join(host, ENDPOINT)
90
+ log("request POST #{uri} #{filter(form_data)}")
91
+
92
+ response = perform(uri, form_data, config)
93
+ log("response #{response.code} (#{response.body.to_s.bytesize} bytes)")
94
+ successful_body(response)
95
+ rescue *CONNECTION_ERRORS => e
96
+ raise ConnectionError, "#{e.class}: #{e.message}"
97
+ end
98
+
99
+ def successful_body(response)
100
+ raise HttpError.new(response.code.to_i, response.body) unless response.is_a?(Net::HTTPSuccess)
101
+
102
+ response.body
103
+ end
104
+
105
+ def perform(uri, form_data, config)
106
+ options = { use_ssl: uri.scheme == 'https', open_timeout: config.open_timeout,
107
+ read_timeout: config.read_timeout }
108
+ Net::HTTP.start(uri.host, uri.port, **options) do |http|
44
109
  request = Net::HTTP::Post.new(uri.request_uri)
45
110
  request.set_form_data(form_data)
46
111
  http.request(request)
47
112
  end
48
113
  end
49
114
 
50
- # @param phone [String] phone number or phone numbers separated by comma
51
- # @param message [String] message
52
- # @param originator [String] originator (subject)
53
- # @param options [Hash] additional options
54
- # @return [Hash<Symbol, String|Integer>]
115
+ def log(line)
116
+ config = SmsTraffic.configuration
117
+ config.logger.debug("[SmsTraffic] #{line}") if config.debug
118
+ end
119
+
120
+ def filter(form_data)
121
+ form_data.to_h { |key, value| [key, FILTERED_PARAMS.include?(key) ? FILTERED : value] }
122
+ end
123
+
55
124
  def deliver_params(phone, message, originator, **options)
56
125
  {
57
126
  login: SmsTraffic.configuration.login,
58
127
  password: SmsTraffic.configuration.password,
59
128
  phones: phone,
60
- message: message,
129
+ message:,
61
130
  want_sms_ids: 1,
62
131
  rus: 5,
63
- originator: originator
132
+ originator:
64
133
  }.merge(options)
65
134
  end
66
135
 
67
- # @param message_ids [Array<String>] message ids
68
- # @return [Hash<Symbol, String|Integer>]
69
- def status_params(*message_ids)
70
- {
71
- login: SmsTraffic.configuration.login,
72
- password: SmsTraffic.configuration.password,
73
- operation: 'status',
74
- sms_id: message_ids.join(',')
75
- }
136
+ def status_params(sms_ids)
137
+ operation_params('status').merge(sms_id: sms_ids.join(','))
138
+ end
139
+
140
+ def account_params
141
+ operation_params('account')
142
+ end
143
+
144
+ def operation_params(operation)
145
+ { login: SmsTraffic.configuration.login, password: SmsTraffic.configuration.password, operation: }
76
146
  end
77
147
  end
78
148
  end
@@ -1,75 +1,34 @@
1
- require 'singleton'
2
1
  require 'nori'
3
2
 
4
3
  module SmsTraffic
5
4
  class Configuration
6
- include Singleton
5
+ ATTRIBUTES = %i[
6
+ login password host fallback_host originator debug logger open_timeout read_timeout validate_phone xml_parser
7
+ ].freeze
7
8
 
8
- attr_accessor :login, :password, :host, :originator, :debug, :logger, :xml_parser, :validate_phone
9
+ # Attributes not listed here default to nil.
10
+ DEFAULTS = { debug: false, open_timeout: 10, read_timeout: 30, validate_phone: true }.freeze
11
+
12
+ attr_accessor(*ATTRIBUTES)
9
13
 
10
14
  def initialize
11
- self.debug = default_configuration[:debug]
12
- self.logger = default_configuration[:logger]
13
- self.xml_parser = default_configuration[:xml_parser]
14
- self.validate_phone = default_configuration[:validate_phone]
15
- super
15
+ reset!
16
16
  end
17
17
 
18
18
  def reset!
19
- default_configuration.each do |key, value|
20
- send("#{key}=", value)
21
- end
19
+ ATTRIBUTES.each { |attribute| public_send("#{attribute}=", DEFAULTS[attribute]) }
20
+ self.xml_parser = Nori.new(advanced_typecasting: false)
22
21
  end
23
22
 
24
23
  def validate!
25
- validation_rules.each do |key, value|
26
- raise ArgumentError, "#{key} should be defined for #{self}." if value.nil?
27
- end
24
+ raise ArgumentError, 'login should be defined' if login.nil?
25
+ raise ArgumentError, 'password should be defined' if password.nil?
26
+ raise ArgumentError, 'host should be defined' if host.nil?
27
+ raise ArgumentError, 'logger should be defined when debug is enabled' if debug && logger.nil?
28
28
  end
29
29
 
30
30
  def to_h
31
- {
32
- login: login,
33
- password: password,
34
- host: host,
35
- originator: originator,
36
- debug: debug,
37
- logger: logger,
38
- xml_parser: xml_parser
39
- }
40
- end
41
-
42
- def values_at(*keys)
43
- to_h.values_at(*keys)
44
- end
45
-
46
- private
47
-
48
- def default_configuration
49
- {
50
- login: nil,
51
- password: nil,
52
- host: nil,
53
- originator: nil,
54
- debug: false,
55
- logger: $stdout,
56
- xml_parser: default_xml_parser,
57
- validate_phone: true
58
- }
59
- end
60
-
61
- def validation_rules
62
- {
63
- 'Login' => login,
64
- 'Password' => password,
65
- 'Server' => host,
66
- 'Originator' => originator,
67
- 'Debug logger' => debug && !logger ? nil : true
68
- }
69
- end
70
-
71
- def default_xml_parser
72
- @default_xml_parser ||= Nori.new(advanced_typecasting: true).freeze
31
+ ATTRIBUTES.to_h { |attribute| [attribute, public_send(attribute)] }
73
32
  end
74
33
  end
75
34
  end
@@ -0,0 +1,45 @@
1
+ module SmsTraffic
2
+ class Error < StandardError; end
3
+
4
+ # Network or TLS failure; the original exception is available as +cause+.
5
+ class ConnectionError < Error; end
6
+
7
+ # Non-2xx HTTP status.
8
+ class HttpError < Error
9
+ attr_reader :status, :body
10
+
11
+ def initialize(status, body)
12
+ @status = status
13
+ @body = body
14
+ super("HTTP #{status}")
15
+ end
16
+ end
17
+
18
+ # HTTP 200 reply whose `result` is not `OK` (`ERROR`, `ERRO`).
19
+ class ApiError < Error
20
+ attr_reader :code, :description
21
+
22
+ def initialize(code, description)
23
+ @code = code
24
+ @description = description
25
+ super("#{code}: #{description}")
26
+ end
27
+
28
+ # Temporary server problem: retry later or use the backup host.
29
+ def retryable?
30
+ code.to_i == 1000
31
+ end
32
+
33
+ def access_denied?
34
+ [411, 412].include?(code.to_i)
35
+ end
36
+
37
+ def insufficient_funds?
38
+ code.to_i == 415
39
+ end
40
+
41
+ def blocked_phone?
42
+ code.to_i == 432
43
+ end
44
+ end
45
+ end
@@ -1,55 +1,80 @@
1
1
  module SmsTraffic
2
2
  class Sms
3
+ PHONE_FORMAT = /\A\d{11,}\z/
4
+
3
5
  attr_accessor :phone, :message, :originator
4
- attr_reader :id, :status, :errors, :response
6
+ attr_reader :id, :status, :errors, :reply
5
7
 
6
8
  def initialize(phone, message, originator: nil)
7
9
  @phone = phone
8
10
  @message = message
9
11
  @originator = originator || SmsTraffic.configuration.originator
10
- @response = nil
12
+ @reply = nil
11
13
  @status = 'not-sent'
12
14
  @errors = []
13
15
  validate!
14
16
  end
15
17
 
16
- def deliver # rubocop:disable Metrics/MethodLength
17
- # @type [Client::Response]
18
- @response = Client.deliver(phone, message, originator)
19
-
20
- unless @response.success?
21
- @errors << @response.error_description
22
- return false
23
- end
18
+ # @return [Boolean] true when the provider enqueued the sms
19
+ def deliver
20
+ @reply = Client.deliver(phone, message, originator)
24
21
 
25
- # @type [Client::Response::Reply]
26
- reply = @response.reply
27
-
28
- if reply.ok?
29
- @status = 'sent'
30
- @id = reply.sms_id || reply.sms_ids.values.first
31
- true
32
- else
33
- @errors << (reply.error_description || 'Sms has been not enqueued')
34
- false
35
- end
22
+ reply.ok? ? mark_sent : add_error(reply.error_description)
23
+ reply.ok?
24
+ rescue SmsTraffic::Error => e
25
+ add_error(e.message)
26
+ false
36
27
  end
37
28
 
29
+ # @return [String] the current status; unchanged when the check fails
38
30
  def update_status
39
31
  return status if id.nil?
40
32
 
41
- response = Client.status(id)
42
- return response.code unless response.success?
33
+ apply_entry(Client.status(id).entry(id))
34
+ status
35
+ rescue SmsTraffic::Error => e
36
+ add_error(e.message)
37
+ status
38
+ end
39
+
40
+ # @return [Boolean] the stored status is a final provider status
41
+ def final?
42
+ Status.final?(status)
43
+ end
43
44
 
44
- @status = response.reply.status.downcase
45
+ # @return [Boolean] the stored status is Delivered
46
+ def delivered?
47
+ Status.delivered?(status)
45
48
  end
46
49
 
47
50
  private
48
51
 
52
+ def mark_sent
53
+ @status = 'sent'
54
+ @id = reply.sms_id
55
+ end
56
+
57
+ def add_error(error)
58
+ @errors << error
59
+ end
60
+
61
+ def apply_entry(entry)
62
+ if entry&.error?
63
+ add_error(entry.error)
64
+ elsif entry&.status
65
+ @status = entry.status.downcase
66
+ elsif entry&.status_reported?
67
+ # An empty status element is an intermediate provider status: keep the current one.
68
+ nil
69
+ else
70
+ add_error("No status returned for sms #{id}")
71
+ end
72
+ end
73
+
49
74
  def validate! # rubocop:disable Metrics/AbcSize
50
75
  raise ArgumentError, "Phone should be assigned to #{self.class}." if phone.nil?
51
76
 
52
- if SmsTraffic.configuration.validate_phone && phone.to_s !~ /^[0-9]{11,}$/
77
+ if SmsTraffic.configuration.validate_phone && !PHONE_FORMAT.match?(phone.to_s)
53
78
  raise ArgumentError, 'Phone number should contain only numbers. Minimum length is 11.'
54
79
  end
55
80
 
@@ -0,0 +1,26 @@
1
+ module SmsTraffic
2
+ # Provider delivery statuses (HTTP API v1, operation=status).
3
+ module Status
4
+ INTERMEDIATE = ['', 'Buffered SMSC'].freeze
5
+ DELIVERED = 'Delivered'.freeze
6
+ FINAL = [DELIVERED, 'Non Delivered', 'Rejected', 'Expired', 'Deleted', 'Unknown status'].freeze
7
+
8
+ class << self
9
+ # @param status [String, nil] provider status, compared case-insensitively
10
+ def final?(status)
11
+ matches?(FINAL, status)
12
+ end
13
+
14
+ def delivered?(status)
15
+ matches?([DELIVERED], status)
16
+ end
17
+
18
+ private
19
+
20
+ def matches?(list, status)
21
+ value = status.to_s.strip
22
+ list.any? { |item| item.casecmp?(value) }
23
+ end
24
+ end
25
+ end
26
+ end
@@ -1,3 +1,3 @@
1
1
  module SmsTraffic
2
- VERSION = '0.0.8'.freeze
2
+ VERSION = '0.2.0'.freeze
3
3
  end
data/lib/sms_traffic.rb CHANGED
@@ -1,19 +1,17 @@
1
- require 'zeitwerk'
2
-
3
- loader = Zeitwerk::Loader.for_gem
4
- loader.ignore("#{__dir__}/sms_traffic/version.rb")
5
- loader.enable_reloading
6
- loader.setup
7
-
8
1
  require_relative 'sms_traffic/version'
2
+ require_relative 'sms_traffic/errors'
3
+ require_relative 'sms_traffic/status'
4
+ require_relative 'sms_traffic/configuration'
5
+ require_relative 'sms_traffic/client'
6
+ require_relative 'sms_traffic/sms'
9
7
 
10
8
  module SmsTraffic
11
9
  class << self
12
10
  def configuration
13
- @configuration ||= Configuration.instance
11
+ @configuration ||= Configuration.new
14
12
  end
15
13
 
16
- def configure(&_block)
14
+ def configure
17
15
  yield(configuration) if block_given?
18
16
  configuration.validate!
19
17
  end
@@ -0,0 +1 @@
1
+ require_relative 'sms_traffic'
metadata CHANGED
@@ -1,96 +1,90 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sms_traffic_sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.8
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Rybolovlev
8
- bindir: exe
8
+ autorequire:
9
+ bindir: bin
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-11-03 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
- name: nokogiri
14
+ name: bigdecimal
14
15
  requirement: !ruby/object:Gem::Requirement
15
16
  requirements:
16
- - - "~>"
17
+ - - ">="
17
18
  - !ruby/object:Gem::Version
18
- version: '1.12'
19
+ version: '3.1'
19
20
  type: :runtime
20
21
  prerelease: false
21
22
  version_requirements: !ruby/object:Gem::Requirement
22
23
  requirements:
23
- - - "~>"
24
+ - - ">="
24
25
  - !ruby/object:Gem::Version
25
- version: '1.12'
26
+ version: '3.1'
26
27
  - !ruby/object:Gem::Dependency
27
- name: nori
28
+ name: nokogiri
28
29
  requirement: !ruby/object:Gem::Requirement
29
30
  requirements:
30
31
  - - "~>"
31
32
  - !ruby/object:Gem::Version
32
- version: '2.6'
33
+ version: '1.12'
33
34
  type: :runtime
34
35
  prerelease: false
35
36
  version_requirements: !ruby/object:Gem::Requirement
36
37
  requirements:
37
38
  - - "~>"
38
39
  - !ruby/object:Gem::Version
39
- version: '2.6'
40
+ version: '1.12'
40
41
  - !ruby/object:Gem::Dependency
41
- name: zeitwerk
42
+ name: nori
42
43
  requirement: !ruby/object:Gem::Requirement
43
44
  requirements:
44
45
  - - "~>"
45
46
  - !ruby/object:Gem::Version
46
- version: '2.5'
47
+ version: '2.8'
47
48
  type: :runtime
48
49
  prerelease: false
49
50
  version_requirements: !ruby/object:Gem::Requirement
50
51
  requirements:
51
52
  - - "~>"
52
53
  - !ruby/object:Gem::Version
53
- version: '2.5'
54
+ version: '2.8'
54
55
  description: |-
55
56
  Ruby Gem as a SDK for interacting with the SMS Traffic HTTP API (smstraffic.ru/api).
56
57
  This gem provides a convenient way to integrate SMS Traffic services into Ruby applications,
57
58
  enabling tasks such as sending SMS messages, checking delivery status, and others.
58
59
  email:
59
60
  - i@golifox.ru
60
- executables:
61
- - konsierge-sms.rb
61
+ executables: []
62
62
  extensions: []
63
63
  extra_rdoc_files: []
64
64
  files:
65
- - ".env.template"
66
- - ".fasterer.yml"
67
- - ".github/workflows/ci.yml"
68
- - ".gitignore"
69
- - ".rspec"
70
- - ".rubocop.yml"
71
- - ".tool-versions"
72
- - Gemfile
65
+ - CHANGELOG.md
73
66
  - LICENSE
74
67
  - README.md
75
- - Rakefile
76
- - bin/console
77
- - bin/setup
78
- - exe/konsierge-sms.rb
79
68
  - lib/sms_traffic.rb
80
69
  - lib/sms_traffic/client.rb
70
+ - lib/sms_traffic/client/account_reply.rb
81
71
  - lib/sms_traffic/client/deliver_reply.rb
82
72
  - lib/sms_traffic/client/reply.rb
83
- - lib/sms_traffic/client/response.rb
84
73
  - lib/sms_traffic/client/status_reply.rb
85
74
  - lib/sms_traffic/configuration.rb
75
+ - lib/sms_traffic/errors.rb
86
76
  - lib/sms_traffic/sms.rb
77
+ - lib/sms_traffic/status.rb
87
78
  - lib/sms_traffic/version.rb
88
- - sms_traffic_sdk.gemspec
89
- homepage: https://github.com/golifox/sms_traffic
79
+ - lib/sms_traffic_sdk.rb
80
+ homepage: https://github.com/golifox/sms_traffic_sdk
90
81
  licenses:
91
82
  - MIT
92
83
  metadata:
93
84
  rubygems_mfa_required: 'true'
85
+ source_code_uri: https://github.com/golifox/sms_traffic_sdk
86
+ changelog_uri: https://github.com/golifox/sms_traffic_sdk/blob/main/CHANGELOG.md
87
+ post_install_message:
94
88
  rdoc_options: []
95
89
  require_paths:
96
90
  - lib
@@ -98,14 +92,15 @@ required_ruby_version: !ruby/object:Gem::Requirement
98
92
  requirements:
99
93
  - - ">="
100
94
  - !ruby/object:Gem::Version
101
- version: 2.7.0
95
+ version: '3.1'
102
96
  required_rubygems_version: !ruby/object:Gem::Requirement
103
97
  requirements:
104
98
  - - ">="
105
99
  - !ruby/object:Gem::Version
106
100
  version: '0'
107
101
  requirements: []
108
- rubygems_version: 3.6.7
102
+ rubygems_version: 3.5.22
103
+ signing_key:
109
104
  specification_version: 4
110
105
  summary: Send sms via SmsTraffic service.
111
106
  test_files: []
data/.env.template DELETED
@@ -1,4 +0,0 @@
1
- SMS_TRAFFIC_HOST=SMS_TRAFFIC_HOST
2
- SMS_TRAFFIC_LOGIN=SMS_TRAFFIC_LOGIN
3
- SMS_TRAFFIC_PASSWORD=SMS_TRAFFIC_PASSWORD
4
- SMS_TRAFFIC_ORIGINATOR=SMS_TRAFFIC_ORIGINATOR
data/.fasterer.yml DELETED
@@ -1,2 +0,0 @@
1
- speedups:
2
- fetch_with_argument_vs_block: false