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,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
|
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: []
|