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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +26 -0
- data/LICENSE +21 -0
- data/README.md +376 -0
- data/lib/generators/smstunnel/install/install_generator.rb +130 -0
- data/lib/generators/smstunnel/install/templates/smstunnel.rb.tt +17 -0
- data/lib/smstunnel/client.rb +281 -0
- data/lib/smstunnel/configuration.rb +48 -0
- data/lib/smstunnel/errors.rb +131 -0
- data/lib/smstunnel/job_retries.rb +41 -0
- data/lib/smstunnel/noticed.rb +11 -0
- data/lib/smstunnel/noticed_delivery_method.rb +98 -0
- data/lib/smstunnel/railtie.rb +14 -0
- data/lib/smstunnel/results.rb +240 -0
- data/lib/smstunnel/send_text_job.rb +37 -0
- data/lib/smstunnel/version.rb +5 -0
- data/lib/smstunnel.rb +86 -0
- metadata +67 -0
|
@@ -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 = { "&" => "&", "<" => "<", ">" => ">", """ => '"', "'" => "'", " " => " " }.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
|