smstunnel 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,281 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "openssl"
6
+ require "time"
7
+ require "uri"
8
+
9
+ module SMSTunnel
10
+ # Sends texts from your own apps through your own Android phone, from your own number.
11
+ #
12
+ # client = SMSTunnel::Client.new(api_key: ENV.fetch("SMSTUNNEL_API_KEY"))
13
+ # result = client.send_sms("+14155550123", "Your order #1042 has shipped.")
14
+ # result.message_id
15
+ #
16
+ # Every call raises an APIError subclass when SMSTunnel answers with an error, and ConnectionError (TimeoutError)
17
+ # when no answer comes back. One call sends one text to one number; nothing is ever retried automatically. A
18
+ # client keeps no connection open between calls and is safe to share between threads.
19
+ class Client
20
+ DEFAULT_BASE_URL = "https://smstunnel.io"
21
+ DEFAULT_TIMEOUT = 30
22
+ DEFAULT_OPEN_TIMEOUT = 10
23
+
24
+ BASE_URL_FORMAT = %r{\Ahttps?://[^/\s?#]+(/[^\s?#]*)?\z}i
25
+ IDEMPOTENCY_KEY_FORMAT = /\A[\x20-\x7E]{1,255}\z/
26
+ USER_AGENT = "smstunnel-ruby/#{VERSION} (Ruby #{RUBY_VERSION})".freeze
27
+ NETWORK_ERRORS = [
28
+ SocketError, SystemCallError, IOError, OpenSSL::SSL::SSLError, Net::HTTPBadResponse, Net::ProtocolError
29
+ ].freeze
30
+ private_constant :BASE_URL_FORMAT, :IDEMPOTENCY_KEY_FORMAT, :NETWORK_ERRORS
31
+
32
+ attr_reader :base_url, :timeout, :open_timeout, :device_id, :default_country_code
33
+
34
+ # - +api_key+: an API key from your SMSTunnel dashboard (Apps → Add an app). Keep it in your credentials or an
35
+ # environment variable, never in your code.
36
+ # - +device_id+: send every text through this phone (Phones → copy its ID) unless a call names another one;
37
+ # +nil+ lets SMSTunnel pick one of yours.
38
+ # - +default_country_code+: e.g. "+40". Completes numbers written without + or 00; a call can override it.
39
+ # - +base_url+: change it only for a test server.
40
+ # - +timeout+: seconds to wait for an answer; +open_timeout+: seconds to connect.
41
+ def initialize(api_key:, base_url: DEFAULT_BASE_URL, timeout: DEFAULT_TIMEOUT, open_timeout: DEFAULT_OPEN_TIMEOUT,
42
+ device_id: nil, default_country_code: nil)
43
+ @api_key = api_key.to_s.strip
44
+ if @api_key.empty?
45
+ raise ArgumentError,
46
+ "An SMSTunnel API key is required. Create one in your SMSTunnel dashboard: Apps → Add an app."
47
+ end
48
+
49
+ @base_url = base_url.to_s.strip.sub(%r{/+\z}, "")
50
+ unless BASE_URL_FORMAT.match?(@base_url)
51
+ raise ArgumentError, %(The SMSTunnel base URL must look like https://smstunnel.io (got "#{base_url}").)
52
+ end
53
+
54
+ @timeout = seconds(timeout, "timeout")
55
+ @open_timeout = seconds(open_timeout, "open_timeout")
56
+ @device_id = blank_to_nil(device_id)
57
+ @default_country_code = blank_to_nil(default_country_code)
58
+ end
59
+
60
+ # Sends one text to one number, from your phone. Returns a SendResult.
61
+ #
62
+ # - +to+: the number as a String, in international format: "+14155550123".
63
+ # - +message+: up to 1600 characters (10 joined SMS).
64
+ # - +device_id+: send through this phone instead of the client's default.
65
+ # - +default_country_code+: e.g. "+49", for a number written without + or 00. It always wins over the phone's own.
66
+ # - +idempotency_key+: up to 255 printable ASCII characters, e.g. "order-1042-shipped". A retry with the same key
67
+ # within 24 hours gets the first answer back instead of a second text.
68
+ #
69
+ # Raises ArgumentError, before sending anything, when the number or the text is empty or the number is not a
70
+ # String.
71
+ def send_sms(to, message, device_id: nil, default_country_code: nil, idempotency_key: nil)
72
+ send_text("/api/v1/sms/send", to, message, device_id, default_country_code, idempotency_key)
73
+ end
74
+
75
+ # Sends a sign-in or verification code. The text must contain the code, 4 to 10 digits standing on their own
76
+ # ("Your sign-in code is 482913", or "123-456"); SMSTunnel refuses it otherwise (BadRequestError). Up to 320
77
+ # characters. It counts against the separate sign-in code quota. Same options as #send_sms.
78
+ def send_sign_in_code(to, message, device_id: nil, default_country_code: nil, idempotency_key: nil)
79
+ send_text("/api/v1/sms/send-2fa", to, message, device_id, default_country_code, idempotency_key)
80
+ end
81
+
82
+ # Where a text is now (a MessageStatus). Needs a key with the +sms:read+ permission (new keys have it).
83
+ # Raises NotFoundError when the key's account has no such text.
84
+ def message_status(message_id)
85
+ id = message_id.to_s.strip
86
+ raise ArgumentError, "The message id is empty." if id.empty?
87
+
88
+ body, = request(:get, "/api/v1/sms/status/#{URI.encode_uri_component(id)}")
89
+ MessageStatus.from_data(Fields.obj(body, "data"))
90
+ end
91
+
92
+ # The phones linked to the key's account, and which are online (a DeviceList).
93
+ def phones
94
+ body, = request(:get, "/api/v1/devices")
95
+ DeviceList.from_data(Fields.obj(body, "data"))
96
+ end
97
+ alias devices phones
98
+
99
+ # Today's usage, the plan's limits and what is left (a Usage).
100
+ def usage
101
+ body, = request(:get, "/api/v1/account/usage")
102
+ Usage.from_data(Fields.obj(body, "data"))
103
+ end
104
+
105
+ # Texts your phone received, newest first (a ReceivedTexts page).
106
+ #
107
+ # Needs a key you allowed to read received texts (+sms:inbox+, a separate opt-in per key: "Can also read
108
+ # received texts"); other keys get a PermissionError with +code+ PERMISSION_REQUIRED. The phone syncs only texts
109
+ # that arrive after syncing is turned on in the app.
110
+ #
111
+ # - +limit+: texts per page (default 50, at most 100); +page+: from 1.
112
+ # - +since+: only texts newer than this (a Time, Date or ISO 8601 String).
113
+ # - +phone+: only texts from this sender; +device_id+: only texts this phone received.
114
+ def received_texts(limit: nil, page: nil, since: nil, phone: nil, device_id: nil)
115
+ query = {}
116
+ query["limit"] = Integer(limit).to_s unless limit.nil?
117
+ query["page"] = Integer(page).to_s unless page.nil?
118
+ query["since"] = iso8601(since) unless since.nil?
119
+ query["phone"] = phone.to_s.strip if blank_to_nil(phone)
120
+ query["deviceId"] = device_id.to_s.strip if blank_to_nil(device_id)
121
+ body, = request(:get, "/api/v1/sms/received", query: query)
122
+ ReceivedTexts.from_data(Fields.obj(body, "data"))
123
+ end
124
+
125
+ def inspect
126
+ "#<SMSTunnel::Client base_url=#{base_url.inspect} device_id=#{device_id.inspect}>"
127
+ end
128
+ alias to_s inspect
129
+
130
+ private
131
+
132
+ def send_text(path, to, message, device_id, default_country_code, idempotency_key)
133
+ body = send_body(to, message, device_id, default_country_code)
134
+ key = idempotency_header(idempotency_key)
135
+ answer, headers = request(:post, path, body: body, idempotency_key: key)
136
+ SendResult.from_response(answer, replayed: headers["idempotent-replayed"].to_s.casecmp?("true"))
137
+ end
138
+
139
+ def send_body(to, message, device_id, default_country_code)
140
+ body = { "to" => recipient(to), "message" => text(message) }
141
+ device = blank_to_nil(device_id) || @device_id
142
+ body["deviceId"] = device if device
143
+ country_code = blank_to_nil(default_country_code) || @default_country_code
144
+ body["defaultCountryCode"] = country_code if country_code
145
+ body
146
+ end
147
+
148
+ def recipient(to)
149
+ unless to.respond_to?(:to_str)
150
+ raise ArgumentError, "The recipient number must be a String in international format, " \
151
+ "e.g. \"+14155550123\" (got #{to.class})."
152
+ end
153
+ number = to.to_str.strip
154
+ raise ArgumentError, "The recipient number is empty." if number.empty?
155
+
156
+ number
157
+ end
158
+
159
+ def text(message)
160
+ raise ArgumentError, "The text must be a String." unless message.respond_to?(:to_str)
161
+ raise ArgumentError, "The text is empty." if message.to_str.strip.empty?
162
+
163
+ message.to_str
164
+ end
165
+
166
+ def idempotency_header(key)
167
+ return nil if key.nil?
168
+
169
+ value = key.to_s
170
+ return value if IDEMPOTENCY_KEY_FORMAT.match?(value)
171
+
172
+ raise ArgumentError,
173
+ "The idempotency key must be 1 to 255 printable ASCII characters (a UUID or \"order-1042-shipped\")."
174
+ end
175
+
176
+ def request(method, path, query: nil, body: nil, idempotency_key: nil)
177
+ uri = URI.parse(@base_url + path)
178
+ uri.query = URI.encode_www_form(query) if query && !query.empty?
179
+
180
+ http_request = (method == :post ? Net::HTTP::Post : Net::HTTP::Get).new(uri)
181
+ http_request["X-API-Key"] = @api_key
182
+ http_request["Accept"] = "application/json"
183
+ http_request["User-Agent"] = USER_AGENT
184
+ http_request["Idempotency-Key"] = idempotency_key if idempotency_key
185
+ if body
186
+ http_request["Content-Type"] = "application/json"
187
+ http_request.body = JSON.generate(body)
188
+ end
189
+
190
+ handle(perform(uri, http_request))
191
+ end
192
+
193
+ def perform(uri, http_request)
194
+ http = Net::HTTP.new(uri.host, uri.port)
195
+ http.use_ssl = uri.scheme == "https"
196
+ http.open_timeout = @open_timeout
197
+ http.read_timeout = @timeout
198
+ http.write_timeout = @timeout
199
+ http.ssl_timeout = @open_timeout
200
+ http.max_retries = 0 # Net::HTTP never retries a POST anyway; GETs are not retried either.
201
+ http.start { |connection| connection.request(http_request) }
202
+ rescue Net::OpenTimeout => e
203
+ raise TimeoutError, "Could not reach SMSTunnel at #{origin}: no connection within #{@open_timeout} s " \
204
+ "(#{e.class})."
205
+ rescue Timeout::Error => e
206
+ raise TimeoutError, "SMSTunnel at #{origin} did not answer within #{@timeout} s (#{e.class}). The request " \
207
+ "may have arrived: retry a send only with the same idempotency key."
208
+ rescue *NETWORK_ERRORS => e
209
+ raise ConnectionError, "Could not reach SMSTunnel at #{origin}: #{e.message} (#{e.class})."
210
+ end
211
+
212
+ def handle(response)
213
+ status = response.code.to_i
214
+ headers = {}
215
+ response.each_header { |name, value| headers[name.downcase] = value }
216
+ body = decode(response.body)
217
+
218
+ raise redirect_error(status, body, headers) if (300..399).cover?(status)
219
+ raise APIError.from_response(status, body, headers) unless (200..299).cover?(status)
220
+ unless body.is_a?(Hash)
221
+ raise APIError.new("SMSTunnel answered with something that is not JSON.",
222
+ status: status, body: body, headers: headers)
223
+ end
224
+ raise APIError.from_response(status, body, headers) if body["success"] == false
225
+
226
+ [body, headers]
227
+ end
228
+
229
+ def redirect_error(status, body, headers)
230
+ location = headers["location"]
231
+ message = if location
232
+ "SMSTunnel answered with a redirect to #{location}. Set base_url to that address."
233
+ else
234
+ "SMSTunnel answered with a redirect (#{status})."
235
+ end
236
+ APIError.new(message, status: status, body: body, headers: headers)
237
+ end
238
+
239
+ def decode(raw)
240
+ return nil if raw.nil?
241
+
242
+ body = raw.dup.force_encoding(Encoding::UTF_8)
243
+ return nil if body.strip.empty?
244
+
245
+ JSON.parse(body, freeze: true)
246
+ rescue JSON::ParserError
247
+ body
248
+ end
249
+
250
+ def iso8601(value)
251
+ case value
252
+ when String
253
+ raise ArgumentError, "since is empty." if value.strip.empty?
254
+
255
+ value.strip
256
+ else
257
+ raise ArgumentError, "since must be a Time, a Date or an ISO 8601 String." unless value.respond_to?(:to_time)
258
+
259
+ value.to_time.utc.iso8601(3)
260
+ end
261
+ end
262
+
263
+ def seconds(value, name)
264
+ return value if value.is_a?(Numeric) && value.positive?
265
+
266
+ raise ArgumentError, "#{name} must be a number of seconds above 0 (got #{value.inspect})."
267
+ end
268
+
269
+ def blank_to_nil(value)
270
+ return nil if value.nil?
271
+
272
+ text = value.to_s.strip
273
+ text.empty? ? nil : text
274
+ end
275
+
276
+ def origin
277
+ uri = URI.parse(@base_url)
278
+ "#{uri.scheme}://#{uri.host}#{":#{uri.port}" unless uri.port == uri.default_port}"
279
+ end
280
+ end
281
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SMSTunnel
4
+ # The settings behind SMSTunnel.client. Each one starts from an environment variable, when it is set:
5
+ #
6
+ # - +api_key+: +SMSTUNNEL_API_KEY+ (required). Keep it out of your code.
7
+ # - +device_id+: +SMSTUNNEL_DEVICE_ID+. Send through this phone; without it SMSTunnel picks one of yours.
8
+ # - +default_country_code+: +SMSTUNNEL_DEFAULT_COUNTRY_CODE+, e.g. "+40": completes numbers written without
9
+ # + or 00.
10
+ # - +base_url+: +SMSTUNNEL_BASE_URL+, default https://smstunnel.io. Change it only for a test server.
11
+ # - +timeout+ (seconds for an answer, default 30) and +open_timeout+ (seconds to connect, default 10).
12
+ class Configuration
13
+ attr_accessor :api_key, :base_url, :device_id, :default_country_code, :timeout, :open_timeout
14
+
15
+ def initialize(env = ENV)
16
+ @api_key = presence(env["SMSTUNNEL_API_KEY"])
17
+ @base_url = presence(env["SMSTUNNEL_BASE_URL"]) || Client::DEFAULT_BASE_URL
18
+ @device_id = presence(env["SMSTUNNEL_DEVICE_ID"])
19
+ @default_country_code = presence(env["SMSTUNNEL_DEFAULT_COUNTRY_CODE"])
20
+ @timeout = Client::DEFAULT_TIMEOUT
21
+ @open_timeout = Client::DEFAULT_OPEN_TIMEOUT
22
+ end
23
+
24
+ # The keyword arguments for Client.new.
25
+ def client_options
26
+ {
27
+ api_key: api_key,
28
+ base_url: base_url,
29
+ device_id: device_id,
30
+ default_country_code: default_country_code,
31
+ timeout: timeout,
32
+ open_timeout: open_timeout
33
+ }
34
+ end
35
+
36
+ def inspect
37
+ "#<SMSTunnel::Configuration api_key=#{api_key ? "[set]" : "nil"} base_url=#{base_url.inspect} " \
38
+ "device_id=#{device_id.inspect} default_country_code=#{default_country_code.inspect}>"
39
+ end
40
+
41
+ private
42
+
43
+ def presence(value)
44
+ value = value.to_s.strip
45
+ value.empty? ? nil : value
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module SMSTunnel
6
+ # The base of every exception this gem raises when a call does not go through. Rescue it to handle them all.
7
+ class Error < StandardError; end
8
+
9
+ # SMSTunnel could not be reached at all: DNS, TLS, a refused connection. No answer came back.
10
+ class ConnectionError < Error; end
11
+
12
+ # No answer within the timeout. The request may have reached SMSTunnel, so a text may have gone out: retry a send
13
+ # only with the same idempotency key.
14
+ class TimeoutError < ConnectionError; end
15
+
16
+ # SMSTunnel answered, but with an error.
17
+ #
18
+ # - +status+: the HTTP status.
19
+ # - +code+: SMSTunnel's own code when it sends one, such as +PERMISSION_REQUIRED+ or +IDEMPOTENCY_KEY_REUSED+.
20
+ # - +message+: what went wrong, in SMSTunnel's words.
21
+ # - +body+: the decoded answer (a Hash), the raw text when it was not JSON, or +nil+ when it was empty.
22
+ # - +headers+: the response headers, names in lower case.
23
+ class APIError < Error
24
+ FALLBACK = "SMSTunnel returned error: %<status>s"
25
+ ENTITIES = { "&amp;" => "&", "&lt;" => "<", "&gt;" => ">", "&quot;" => '"', "&#39;" => "'", "&nbsp;" => " " }.freeze
26
+ private_constant :FALLBACK, :ENTITIES
27
+
28
+ attr_reader :status, :code, :body, :headers
29
+
30
+ def initialize(message = nil, status: nil, code: nil, body: nil, headers: {})
31
+ @status = status
32
+ @code = code
33
+ @body = body
34
+ @headers = headers.to_h.transform_keys { |name| name.to_s.downcase }.freeze
35
+ super(message || format(FALLBACK, status: status))
36
+ end
37
+
38
+ # With +PERMISSION_REQUIRED+: the permission the key lacks, such as +sms:inbox+.
39
+ def permission
40
+ value = body["permission"] if body.is_a?(Hash)
41
+ value.is_a?(String) ? value : nil
42
+ end
43
+
44
+ # Seconds to wait before trying again, when SMSTunnel says (the +Retry-After+ header of a 429 or 409), or +nil+.
45
+ def retry_after
46
+ value = headers["retry-after"].to_s.strip
47
+ return nil if value.empty?
48
+ return Float(value) if value.match?(/\A\d+(\.\d+)?\z/)
49
+
50
+ [Time.httpdate(value) - Time.now, 0.0].max
51
+ rescue ArgumentError
52
+ nil
53
+ end
54
+
55
+ class << self
56
+ # The exception that matches the status: BadRequestError (400, 422), AuthenticationError (401),
57
+ # PermissionError (403), NotFoundError (404), ConflictError (409), RateLimitError (429), ServerError (5xx).
58
+ def from_response(status, body, headers = {})
59
+ error_class_for(status).new(message_of(body, status), status: status, code: code_of(body), body: body,
60
+ headers: headers)
61
+ end
62
+
63
+ private
64
+
65
+ def error_class_for(status)
66
+ return ServerError if (500..599).cover?(status)
67
+
68
+ {
69
+ 400 => BadRequestError, 401 => AuthenticationError, 403 => PermissionError, 404 => NotFoundError,
70
+ 409 => ConflictError, 422 => BadRequestError, 429 => RateLimitError
71
+ }.fetch(status, APIError)
72
+ end
73
+
74
+ def message_of(body, status)
75
+ case body
76
+ when String then text_of(body) || format(FALLBACK, status: status)
77
+ when Hash then hash_message(body) || format(FALLBACK, status: status)
78
+ else format(FALLBACK, status: status)
79
+ end
80
+ end
81
+
82
+ # A proxy's error page, say: its words without the markup, at most 300 characters.
83
+ def text_of(body)
84
+ text = body.gsub(/<[^>]*>/, " ").gsub(/&(?:amp|lt|gt|quot|#39|nbsp);/, ENTITIES).gsub(/\s+/, " ").strip
85
+ text.empty? ? nil : text[0, 300]
86
+ end
87
+
88
+ def hash_message(body)
89
+ message = body["message"]
90
+ message = message.grep(String).join("; ") if message.is_a?(Array)
91
+ return message if message.is_a?(String) && !message.empty?
92
+
93
+ error = body["error"]
94
+ error.is_a?(String) && !error.empty? ? error : nil
95
+ end
96
+
97
+ def code_of(body)
98
+ code = body["code"] if body.is_a?(Hash)
99
+ code.is_a?(String) ? code : nil
100
+ end
101
+ end
102
+ end
103
+
104
+ # 400 or 422: SMSTunnel refused the request, for example a number it cannot read, a sign-in code text without a
105
+ # code (+No valid verification code found+), or an idempotency key reused for a different text
106
+ # (+IDEMPOTENCY_KEY_REUSED+). Fix the request; retrying it unchanged gets the same answer.
107
+ class BadRequestError < APIError; end
108
+
109
+ # 401: the API key is wrong, was deleted or expired, or its account is suspended. Check it in your SMSTunnel
110
+ # dashboard (Apps).
111
+ class AuthenticationError < APIError; end
112
+
113
+ # 403: the key is valid but may not do this. Either it lacks a permission (+code+ is +PERMISSION_REQUIRED+ and
114
+ # #permission names it, e.g. +sms:inbox+ for received texts), or the plan does not include the feature.
115
+ class PermissionError < APIError; end
116
+
117
+ # 404: no such text or phone for this key.
118
+ class NotFoundError < APIError; end
119
+
120
+ # 409: a request with the same idempotency key is still being processed (+IDEMPOTENCY_KEY_IN_PROGRESS+). Retry in
121
+ # a moment with the same key to get its result.
122
+ class ConflictError < APIError; end
123
+
124
+ # 429: too many requests (sending allows 30 a minute per key) or a daily limit is reached. Wait #retry_after
125
+ # seconds before trying again.
126
+ class RateLimitError < APIError; end
127
+
128
+ # 5xx: a problem on SMSTunnel's side. The text may have been created: retry a send only with the same idempotency
129
+ # key.
130
+ class ServerError < APIError; end
131
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_job"
4
+ require "smstunnel"
5
+
6
+ module SMSTunnel
7
+ # The retry rules of SendTextJob and NoticedDeliveryMethod. Include it in your own Active Job classes too, but only
8
+ # in jobs whose sends carry an idempotency key, since a retry after a timeout or a 5xx must not send a text twice:
9
+ #
10
+ # class OrderShippedTextJob < ApplicationJob
11
+ # include SMSTunnel::JobRetries
12
+ #
13
+ # def perform(order)
14
+ # SMSTunnel.send_sms(order.phone, "Your order ##{order.number} has shipped.",
15
+ # idempotency_key: "order-#{order.id}-shipped")
16
+ # end
17
+ # end
18
+ #
19
+ # - ServerError (5xx) and ConnectionError (including TimeoutError): retried with growing waits.
20
+ # - ConflictError (409, the first request with the same key is still running): retried after 5 seconds.
21
+ # - RateLimitError (429): retried after SMSTunnel's Retry-After (60 seconds when it does not say).
22
+ #
23
+ # A job runs at most ATTEMPTS times; then the error is raised. Other errors are raised at once: a wrong key
24
+ # (AuthenticationError), a missing permission (PermissionError), a number SMSTunnel cannot read (BadRequestError).
25
+ module JobRetries
26
+ extend ActiveSupport::Concern
27
+
28
+ ATTEMPTS = 5
29
+
30
+ included do
31
+ retry_on SMSTunnel::ServerError, SMSTunnel::ConnectionError, wait: :polynomially_longer, attempts: ATTEMPTS
32
+ retry_on SMSTunnel::ConflictError, wait: 5, attempts: ATTEMPTS
33
+
34
+ rescue_from SMSTunnel::RateLimitError do |error|
35
+ raise error if executions >= ATTEMPTS
36
+
37
+ retry_job(wait: (error.retry_after || 60).clamp(1, 3600), error: error)
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Makes +deliver_by :smstunnel+ available in Noticed notifiers. Inside a Rails app the gem's Railtie does this for
4
+ # you; elsewhere, +require "smstunnel/noticed"+ after Noticed is loaded.
5
+ require_relative "noticed_delivery_method"
6
+
7
+ module Noticed
8
+ module DeliveryMethods # :nodoc:
9
+ Smstunnel = SMSTunnel::NoticedDeliveryMethod unless const_defined?(:Smstunnel, false) && !autoload?(:Smstunnel)
10
+ end
11
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_job"
4
+ require "smstunnel"
5
+
6
+ begin
7
+ require "noticed"
8
+ rescue LoadError
9
+ raise LoadError, 'SMSTunnel::NoticedDeliveryMethod needs the noticed gem: add `gem "noticed"` to your Gemfile.'
10
+ end
11
+
12
+ module SMSTunnel
13
+ # Delivers Noticed notifications as texts from your own phone, one text per recipient.
14
+ #
15
+ # class OrderShippedNotifier < Noticed::Event
16
+ # deliver_by :smstunnel do |config|
17
+ # config.phone = -> { recipient.phone }
18
+ # config.message = -> { "Your order ##{params[:order].number} has shipped." }
19
+ # end
20
+ # end
21
+ #
22
+ # Options (each one a value, a lambda run in the notification's context, or the name of a notifier method):
23
+ #
24
+ # - +phone+ (required): the recipient's number, a String in international format.
25
+ # - +message+ (required): the text.
26
+ # - +device_id+, +default_country_code+: as for Client#send_sms; they default to SMSTunnel.configure.
27
+ # - +sign_in_code+: +true+ sends the text as a sign-in code (Client#send_sign_in_code).
28
+ # - +api_key+: a different account's key than SMSTunnel.configure's.
29
+ # - +idempotency_key+: defaults to "noticed-<notification id>-<delivery method name>" ("noticed-<job id>" for
30
+ # ephemeral notifications), so a retried delivery never sends the text twice.
31
+ # - +error_handler+: a lambda that gets the error when SMSTunnel refuses the text (400, 401, 403, 404), run in
32
+ # the notification's context. Without it, the error is raised.
33
+ #
34
+ # Rate limits, server errors and timeouts are retried like SendTextJob (see JobRetries).
35
+ class NoticedDeliveryMethod < ::Noticed::DeliveryMethod
36
+ include JobRetries
37
+
38
+ required_options :phone, :message
39
+
40
+ def perform(delivery_method_name, notification, **options)
41
+ @delivery_method_name = delivery_method_name
42
+ super
43
+ end
44
+
45
+ def deliver
46
+ send_text
47
+ rescue BadRequestError, AuthenticationError, PermissionError, NotFoundError => e
48
+ handler = config[:error_handler]
49
+ raise unless handler
50
+
51
+ notification.instance_exec(e, &handler)
52
+ end
53
+
54
+ private
55
+
56
+ def send_text
57
+ options = {
58
+ device_id: evaluate_option(:device_id),
59
+ default_country_code: evaluate_option(:default_country_code),
60
+ idempotency_key: idempotency_key
61
+ }
62
+ if evaluate_option(:sign_in_code)
63
+ client.send_sign_in_code(phone, text, **options)
64
+ else
65
+ client.send_sms(phone, text, **options)
66
+ end
67
+ end
68
+
69
+ def phone
70
+ value = evaluate_option(:phone)
71
+ return value unless value.to_s.strip.empty?
72
+
73
+ raise ArgumentError, "deliver_by :smstunnel: the phone number is empty for #{recipient.class} " \
74
+ "#{recipient.try(:id).inspect}. Skip such recipients with config.if = " \
75
+ "-> { recipient.phone.present? }."
76
+ end
77
+
78
+ def text = evaluate_option(:message)
79
+
80
+ def idempotency_key
81
+ key = evaluate_option(:idempotency_key)
82
+ return key.to_s unless key.to_s.strip.empty?
83
+
84
+ if notification.respond_to?(:persisted?) && notification.persisted?
85
+ "noticed-#{notification.id}-#{@delivery_method_name}"
86
+ else
87
+ "noticed-#{job_id}"
88
+ end
89
+ end
90
+
91
+ def client
92
+ api_key = evaluate_option(:api_key)
93
+ return SMSTunnel.client if api_key.to_s.strip.empty?
94
+
95
+ Client.new(**SMSTunnel.configuration.client_options, api_key: api_key)
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SMSTunnel
4
+ # Loaded only inside a Rails app. It registers +deliver_by :smstunnel+ for Noticed without loading anything at
5
+ # boot: the delivery method is loaded the first time a notification uses it.
6
+ class Railtie < ::Rails::Railtie
7
+ initializer "smstunnel.noticed" do
8
+ delivery_methods = ::Noticed::DeliveryMethods if defined?(::Noticed::DeliveryMethods)
9
+ if delivery_methods && !delivery_methods.const_defined?(:Smstunnel, false)
10
+ delivery_methods.autoload(:Smstunnel, File.expand_path("noticed.rb", __dir__))
11
+ end
12
+ end
13
+ end
14
+ end