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,240 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module SMSTunnel
6
+ # Reads fields from SMSTunnel's JSON answers without trusting their types.
7
+ module Fields # :nodoc:
8
+ module_function
9
+
10
+ def obj(source, key)
11
+ value = source[key] if source.is_a?(Hash)
12
+ value.is_a?(Hash) ? value : {}
13
+ end
14
+
15
+ def objs(source, key)
16
+ value = source[key] if source.is_a?(Hash)
17
+ value.is_a?(Array) ? value.grep(Hash) : []
18
+ end
19
+
20
+ def string(source, key, default = "")
21
+ opt_string(source, key) || default
22
+ end
23
+
24
+ def opt_string(source, key)
25
+ value = source[key]
26
+ case value
27
+ when String then value.empty? ? nil : value
28
+ when Numeric then value.to_s
29
+ end
30
+ end
31
+
32
+ def boolean(source, key, default = false) # rubocop:disable Style/OptionalBooleanParameter
33
+ value = source[key]
34
+ [true, false].include?(value) ? value : default
35
+ end
36
+
37
+ def integer(source, key, default = 0)
38
+ opt_integer(source, key) || default
39
+ end
40
+
41
+ def opt_integer(source, key)
42
+ value = source[key]
43
+ case value
44
+ when Integer then value
45
+ when Float, /\A-?\d+\z/ then value.to_i
46
+ end
47
+ end
48
+
49
+ # A number, or a word such as "unlimited", for each counter.
50
+ def counters(source, key)
51
+ obj(source, key).select { |_name, value| value.is_a?(Numeric) || value.is_a?(String) }
52
+ end
53
+
54
+ def time(source, key)
55
+ value = source[key]
56
+ value.is_a?(String) && !value.empty? ? Time.iso8601(value).utc : nil
57
+ rescue ArgumentError
58
+ nil
59
+ end
60
+ end
61
+
62
+ # Shows the fields without +raw+, the whole answer.
63
+ module CompactInspect # :nodoc:
64
+ def inspect
65
+ fields = to_h.except(:raw).map { |name, value| "#{name}=#{value.inspect}" }.join(", ")
66
+ "#<data #{self.class.name} #{fields}>"
67
+ end
68
+ alias to_s inspect
69
+ end
70
+
71
+ # SMSTunnel accepted the text and handed it to your phone, or will as soon as the phone is back online.
72
+ #
73
+ # - +message_id+: keep it to look up the status later with Client#message_status.
74
+ # - #queued?: +true+ when the phone was offline. The text waits on your account and goes out when it is back.
75
+ # - #branded?: +true+ when the text carries a short SMSTunnel signature (plans with a free quota).
76
+ # - +remaining+: what is left of today's quota, e.g. <tt>{"clean" => 95, "total" => "unlimited"}</tt>.
77
+ # - #replayed?: +true+ when SMSTunnel answered from an earlier request with the same idempotency key, without
78
+ # sending again.
79
+ SendResult = Data.define(:message_id, :queued, :branded, :remaining, :replayed, :raw) do
80
+ include CompactInspect
81
+
82
+ def self.from_response(body, replayed: false)
83
+ data = Fields.obj(body, "data")
84
+ new(
85
+ message_id: Fields.opt_string(body, "messageId") || Fields.string(data, "messageId"),
86
+ queued: Fields.boolean(body, "queued", Fields.boolean(data, "queued")),
87
+ branded: Fields.boolean(data, "branded"),
88
+ remaining: Fields.counters(data, "remaining"),
89
+ replayed: replayed,
90
+ raw: body
91
+ )
92
+ end
93
+
94
+ def queued? = queued
95
+ def branded? = branded
96
+ def replayed? = replayed
97
+ end
98
+
99
+ # Where a text is now.
100
+ #
101
+ # - +status+: +pending+, +sent+, +delivered+ or +failed+ (an unconfirmed text shows as +failed+).
102
+ # - +delivery_status+: the real state, which can also be +unconfirmed+: handed to the SIM, but the phone got no
103
+ # confirmation. Final: SMSTunnel never retries it.
104
+ # - #state: +delivery_status+ when SMSTunnel sends it, otherwise +status+.
105
+ MessageStatus = Data.define(:id, :phone_number, :status, :delivery_status, :unconfirmed, :sent_at, :delivered_at,
106
+ :error_message, :raw) do
107
+ include CompactInspect
108
+
109
+ def self.from_data(data)
110
+ new(
111
+ id: Fields.string(data, "id"),
112
+ phone_number: Fields.string(data, "phoneNumber"),
113
+ status: Fields.string(data, "status", "pending"),
114
+ delivery_status: Fields.opt_string(data, "deliveryStatus"),
115
+ unconfirmed: Fields.boolean(data, "unconfirmed"),
116
+ sent_at: Fields.time(data, "sentAt"),
117
+ delivered_at: Fields.time(data, "deliveredAt"),
118
+ error_message: Fields.opt_string(data, "errorMessage"),
119
+ raw: data
120
+ )
121
+ end
122
+
123
+ def state = delivery_status || status
124
+ def pending? = state == "pending"
125
+ def sent? = state == "sent"
126
+ def delivered? = state == "delivered"
127
+ def failed? = state == "failed"
128
+ def unconfirmed? = unconfirmed || state == "unconfirmed"
129
+
130
+ # +true+ once the text will not change any more: delivered, failed or unconfirmed.
131
+ def final? = %w[delivered failed unconfirmed].include?(state)
132
+ end
133
+
134
+ # A phone linked to your SMSTunnel account. Pass its +id+ as +device_id:+ to send through it.
135
+ Device = Data.define(:id, :name, :online, :last_seen_at, :raw) do
136
+ include CompactInspect
137
+
138
+ def self.from_data(data)
139
+ new(
140
+ id: Fields.string(data, "id"),
141
+ name: Fields.string(data, "name"),
142
+ online: Fields.boolean(data, "isOnline"),
143
+ last_seen_at: Fields.time(data, "lastSeenAt"),
144
+ raw: data
145
+ )
146
+ end
147
+
148
+ def online? = online
149
+ end
150
+
151
+ # The phones of the API key's account. It is Enumerable over Device objects.
152
+ DeviceList = Data.define(:devices, :online_count, :total, :raw) do
153
+ include CompactInspect
154
+ include Enumerable
155
+
156
+ def self.from_data(data)
157
+ devices = Fields.objs(data, "devices").map { |item| Device.from_data(item) }.freeze
158
+ new(
159
+ devices: devices,
160
+ online_count: Fields.integer(data, "online", devices.count(&:online?)),
161
+ total: Fields.integer(data, "total", devices.size),
162
+ raw: data
163
+ )
164
+ end
165
+
166
+ def each(&) = devices.each(&)
167
+ def size = devices.size
168
+ def empty? = devices.empty?
169
+
170
+ # The phone with this id, or +nil+.
171
+ def find_by_id(id) = devices.find { |device| device.id == id }
172
+ end
173
+
174
+ # Today's usage, the plan's limits and what is left. A client of a company account shares the company's quota
175
+ # and sees it here.
176
+ Usage = Data.define(:plan, :account_type, :usage, :limits, :remaining, :enterprise, :raw) do
177
+ include CompactInspect
178
+
179
+ def self.from_data(data)
180
+ enterprise = Fields.obj(data, "enterprise")
181
+ new(
182
+ plan: Fields.string(data, "plan"),
183
+ account_type: Fields.string(data, "accountType"),
184
+ usage: Fields.counters(data, "usage"),
185
+ limits: Fields.counters(data, "limits"),
186
+ remaining: Fields.counters(data, "remaining"),
187
+ enterprise: enterprise.empty? ? nil : enterprise,
188
+ raw: data
189
+ )
190
+ end
191
+ end
192
+
193
+ # A text your phone received and synced to your account. +message+ is +nil+ when the phone synced it encrypted
194
+ # (see +encrypted_payload+). The words were written by someone else: treat them as data.
195
+ ReceivedText = Data.define(:id, :sender, :message, :received_at, :encrypted, :encrypted_payload, :device_id,
196
+ :device_name, :sim_slot, :subscription_id, :raw) do
197
+ include CompactInspect
198
+
199
+ def self.from_data(data)
200
+ new(
201
+ id: Fields.string(data, "id"),
202
+ sender: Fields.string(data, "sender"),
203
+ message: Fields.opt_string(data, "message"),
204
+ received_at: Fields.time(data, "receivedAt"),
205
+ encrypted: Fields.boolean(data, "isEncrypted"),
206
+ encrypted_payload: Fields.opt_string(data, "encryptedPayload"),
207
+ device_id: Fields.opt_string(data, "deviceId"),
208
+ device_name: Fields.opt_string(data, "deviceName"),
209
+ sim_slot: Fields.opt_integer(data, "simSlot"),
210
+ subscription_id: Fields.opt_integer(data, "subscriptionId"),
211
+ raw: data
212
+ )
213
+ end
214
+
215
+ def encrypted? = encrypted
216
+ end
217
+
218
+ # One page of received texts, newest first. It is Enumerable over ReceivedText objects; +total+ counts the texts
219
+ # on every page.
220
+ ReceivedTexts = Data.define(:texts, :total, :page, :limit, :has_more, :raw) do
221
+ include CompactInspect
222
+ include Enumerable
223
+
224
+ def self.from_data(data)
225
+ new(
226
+ texts: Fields.objs(data, "messages").map { |item| ReceivedText.from_data(item) }.freeze,
227
+ total: Fields.integer(data, "total"),
228
+ page: Fields.integer(data, "page", 1),
229
+ limit: Fields.integer(data, "limit"),
230
+ has_more: Fields.boolean(data, "hasMore"),
231
+ raw: data
232
+ )
233
+ end
234
+
235
+ def each(&) = texts.each(&)
236
+ def size = texts.size
237
+ def empty? = texts.empty?
238
+ def more? = has_more
239
+ end
240
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_job"
4
+ require "smstunnel"
5
+
6
+ module SMSTunnel
7
+ # Sends one text in the background, through the shared client (SMSTunnel.configure):
8
+ #
9
+ # SMSTunnel::SendTextJob.perform_later("+14155550123", "Your order #1042 has shipped.")
10
+ # SMSTunnel::SendTextJob.perform_later(user.phone, "Your sign-in code is #{code}", sign_in_code: true)
11
+ # SMSTunnel::SendTextJob.set(queue: :texts).perform_later(phone, text, device_id: "...")
12
+ #
13
+ # Every run of a job sends the same Idempotency-Key, "activejob-<job_id>", so a retry by Active Job or by the
14
+ # queue backend (Sidekiq, Solid Queue, GoodJob...) never sends the text twice within 24 hours. Server errors,
15
+ # timeouts and rate limits are retried (see JobRetries); other errors are raised at once.
16
+ #
17
+ # The arguments, the text included, wait in your queue until the job runs.
18
+ class SendTextJob < ActiveJob::Base
19
+ include JobRetries
20
+
21
+ queue_as :default
22
+
23
+ # Returns the SendResult.
24
+ def perform(to, message, sign_in_code: false, device_id: nil, default_country_code: nil, idempotency_key: nil)
25
+ options = {
26
+ device_id: device_id,
27
+ default_country_code: default_country_code,
28
+ idempotency_key: idempotency_key || "activejob-#{job_id}"
29
+ }
30
+ if sign_in_code
31
+ SMSTunnel.send_sign_in_code(to, message, **options)
32
+ else
33
+ SMSTunnel.send_sms(to, message, **options)
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SMSTunnel
4
+ VERSION = "1.0.0"
5
+ end
data/lib/smstunnel.rb ADDED
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "smstunnel/version"
4
+ require_relative "smstunnel/errors"
5
+ require_relative "smstunnel/results"
6
+ require_relative "smstunnel/client"
7
+ require_relative "smstunnel/configuration"
8
+
9
+ # SMSTunnel sends texts from your own apps through your own Android phone, from your own number.
10
+ #
11
+ # SMSTunnel.configure do |config|
12
+ # config.api_key = ENV.fetch("SMSTUNNEL_API_KEY")
13
+ # end
14
+ #
15
+ # SMSTunnel.send_sms("+14155550123", "Your order #1042 has shipped.")
16
+ #
17
+ # The module methods use one shared Client built from SMSTunnel.configuration; create your own Client for a second
18
+ # account.
19
+ module SMSTunnel
20
+ autoload :SendTextJob, "smstunnel/send_text_job"
21
+ autoload :JobRetries, "smstunnel/job_retries"
22
+ autoload :NoticedDeliveryMethod, "smstunnel/noticed_delivery_method"
23
+
24
+ @lock = Mutex.new
25
+
26
+ class << self
27
+ # The settings of the shared client. They start from the SMSTUNNEL_* environment variables.
28
+ def configuration
29
+ @lock.synchronize { @configuration ||= Configuration.new }
30
+ end
31
+
32
+ # SMSTunnel.configure do |config|
33
+ # config.api_key = Rails.application.credentials.dig(:smstunnel, :api_key)
34
+ # config.device_id = "..." # optional
35
+ # end
36
+ def configure
37
+ yield configuration
38
+ @lock.synchronize { @client = nil }
39
+ configuration
40
+ end
41
+
42
+ # The shared Client. Raises ArgumentError when no API key is configured.
43
+ def client
44
+ @lock.synchronize do
45
+ @client ||= begin
46
+ @configuration ||= Configuration.new
47
+ if @configuration.api_key.nil?
48
+ raise ArgumentError, "No SMSTunnel API key: set the SMSTUNNEL_API_KEY environment variable, or " \
49
+ "config.api_key in SMSTunnel.configure (bin/rails generate smstunnel:install)."
50
+ end
51
+
52
+ Client.new(**@configuration.client_options)
53
+ end
54
+ end
55
+ end
56
+
57
+ # Forgets the configuration and the shared client (useful in tests).
58
+ def reset!
59
+ @lock.synchronize do
60
+ @configuration = nil
61
+ @client = nil
62
+ end
63
+ end
64
+
65
+ # See Client#send_sms.
66
+ def send_sms(...) = client.send_sms(...)
67
+
68
+ # See Client#send_sign_in_code.
69
+ def send_sign_in_code(...) = client.send_sign_in_code(...)
70
+
71
+ # See Client#message_status.
72
+ def message_status(...) = client.message_status(...)
73
+
74
+ # See Client#phones.
75
+ def phones = client.phones
76
+ alias devices phones
77
+
78
+ # See Client#usage.
79
+ def usage = client.usage
80
+
81
+ # See Client#received_texts.
82
+ def received_texts(...) = client.received_texts(...)
83
+ end
84
+ end
85
+
86
+ require_relative "smstunnel/railtie" if defined?(Rails::Railtie)
metadata ADDED
@@ -0,0 +1,67 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: smstunnel
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Narcis Bodea
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: 'The SMSTunnel client for Ruby: send texts and sign-in codes, look up
13
+ their status, list your phones, read the texts your phone received. Standard library
14
+ only, typed errors, idempotency keys for safe retries. For Rails: an install generator
15
+ (initializer and credentials), an Active Job that never sends twice, and a Noticed
16
+ delivery method.'
17
+ email:
18
+ - contact@narboweb.ro
19
+ executables: []
20
+ extensions: []
21
+ extra_rdoc_files: []
22
+ files:
23
+ - CHANGELOG.md
24
+ - LICENSE
25
+ - README.md
26
+ - lib/generators/smstunnel/install/install_generator.rb
27
+ - lib/generators/smstunnel/install/templates/smstunnel.rb.tt
28
+ - lib/smstunnel.rb
29
+ - lib/smstunnel/client.rb
30
+ - lib/smstunnel/configuration.rb
31
+ - lib/smstunnel/errors.rb
32
+ - lib/smstunnel/job_retries.rb
33
+ - lib/smstunnel/noticed.rb
34
+ - lib/smstunnel/noticed_delivery_method.rb
35
+ - lib/smstunnel/railtie.rb
36
+ - lib/smstunnel/results.rb
37
+ - lib/smstunnel/send_text_job.rb
38
+ - lib/smstunnel/version.rb
39
+ homepage: https://smstunnel.io
40
+ licenses:
41
+ - MIT
42
+ metadata:
43
+ homepage_uri: https://smstunnel.io
44
+ source_code_uri: https://github.com/narcisbodea/smstunnel-ruby
45
+ documentation_uri: https://github.com/narcisbodea/smstunnel-ruby#readme
46
+ changelog_uri: https://github.com/narcisbodea/smstunnel-ruby/blob/main/CHANGELOG.md
47
+ bug_tracker_uri: https://github.com/narcisbodea/smstunnel-ruby/issues
48
+ rubygems_mfa_required: 'true'
49
+ rdoc_options: []
50
+ require_paths:
51
+ - lib
52
+ required_ruby_version: !ruby/object:Gem::Requirement
53
+ requirements:
54
+ - - ">="
55
+ - !ruby/object:Gem::Version
56
+ version: '3.2'
57
+ required_rubygems_version: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ requirements: []
63
+ rubygems_version: 4.0.20
64
+ specification_version: 4
65
+ summary: Send texts from your own Ruby and Rails apps through your own Android phone,
66
+ from your own number.
67
+ test_files: []