smswire 0.0.1.pre → 0.1.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 +4 -4
- data/CHANGELOG.md +67 -3
- data/README.md +222 -9
- data/RELEASING.md +49 -0
- data/app/controllers/smswire/development_controller.rb +17 -0
- data/app/controllers/smswire/inbound_messages_controller.rb +9 -0
- data/app/controllers/smswire/inbox_controller.rb +31 -0
- data/app/controllers/smswire/previews_controller.rb +22 -0
- data/app/controllers/smswire/status_callbacks_controller.rb +8 -0
- data/app/controllers/smswire/webhooks_controller.rb +49 -0
- data/app/models/smswire/application_record.rb +5 -0
- data/app/models/smswire/consent.rb +71 -0
- data/app/models/smswire/delivery.rb +157 -0
- data/app/models/smswire/inbound_message.rb +25 -0
- data/app/views/layouts/smswire/application.html.erb +60 -0
- data/app/views/smswire/inbox/index.html.erb +56 -0
- data/app/views/smswire/previews/index.html.erb +21 -0
- data/app/views/smswire/previews/show.html.erb +34 -0
- data/config/routes.rb +10 -0
- data/db/migrate/20261008000000_create_smswire_deliveries.rb +47 -0
- data/db/migrate/20261008000001_create_smswire_consents_and_inbound_messages.rb +34 -0
- data/docs/SPEC.md +164 -43
- data/docs/guides/compliance.md +124 -0
- data/docs/guides/getting-started.md +154 -0
- data/docs/guides/migrating-from-noticed-twilio.md +105 -0
- data/docs/guides/migrating-from-twilio-ruby.md +146 -0
- data/docs/guides/writing-a-provider.md +126 -0
- data/lib/generators/smswire/install/install_generator.rb +44 -0
- data/lib/generators/smswire/install/templates/application_messenger.rb.tt +3 -0
- data/lib/generators/smswire/install/templates/initializer.rb.tt +35 -0
- data/lib/generators/smswire/messenger/messenger_generator.rb +51 -0
- data/lib/generators/smswire/messenger/templates/messenger.rb.tt +14 -0
- data/lib/generators/smswire/messenger/templates/preview.rb.tt +13 -0
- data/lib/generators/smswire/messenger/templates/test.rb.tt +18 -0
- data/lib/generators/smswire/messenger/templates/view.text.erb.tt +1 -0
- data/lib/generators/smswire/provider/provider_generator.rb +36 -0
- data/lib/generators/smswire/provider/templates/provider.rb.tt +52 -0
- data/lib/generators/smswire/provider/templates/test.rb.tt +29 -0
- data/lib/smswire/base.rb +123 -0
- data/lib/smswire/body_formatter.rb +14 -0
- data/lib/smswire/callbacks.rb +24 -0
- data/lib/smswire/configuration.rb +224 -0
- data/lib/smswire/delivery_job.rb +29 -0
- data/lib/smswire/engine.rb +52 -0
- data/lib/smswire/errors.rb +59 -0
- data/lib/smswire/http.rb +65 -0
- data/lib/smswire/inbound.rb +11 -0
- data/lib/smswire/inbound_handler.rb +29 -0
- data/lib/smswire/interceptors/allowlist.rb +36 -0
- data/lib/smswire/keyword_messenger.rb +12 -0
- data/lib/smswire/keywords.rb +51 -0
- data/lib/smswire/log_subscriber.rb +57 -0
- data/lib/smswire/message.rb +72 -0
- data/lib/smswire/message_delivery.rb +69 -0
- data/lib/smswire/noticed_delivery_method.rb +54 -0
- data/lib/smswire/parameterized_messenger.rb +24 -0
- data/lib/smswire/phone_number.rb +65 -0
- data/lib/smswire/pipeline.rb +114 -0
- data/lib/smswire/preview.rb +54 -0
- data/lib/smswire/provider_contract.rb +158 -0
- data/lib/smswire/providers/base.rb +66 -0
- data/lib/smswire/providers/log.rb +23 -0
- data/lib/smswire/providers/null.rb +12 -0
- data/lib/smswire/providers/telnyx.rb +178 -0
- data/lib/smswire/providers/test.rb +59 -0
- data/lib/smswire/providers/twilio.rb +167 -0
- data/lib/smswire/providers/vonage.rb +193 -0
- data/lib/smswire/providers.rb +38 -0
- data/lib/smswire/quiet_hours.rb +45 -0
- data/lib/smswire/rate_limit.rb +27 -0
- data/lib/smswire/receipt.rb +11 -0
- data/lib/smswire/recipient.rb +38 -0
- data/lib/smswire/result.rb +40 -0
- data/lib/smswire/rspec.rb +133 -0
- data/lib/smswire/segments.rb +81 -0
- data/lib/smswire/sender.rb +27 -0
- data/lib/smswire/status_handler.rb +25 -0
- data/lib/smswire/status_update.rb +8 -0
- data/lib/smswire/test_helper.rb +81 -0
- data/lib/smswire/testing.rb +72 -0
- data/lib/smswire/version.rb +1 -1
- data/lib/smswire.rb +70 -6
- data/smswire.gemspec +9 -5
- metadata +195 -10
- data/Gemfile +0 -7
- data/Rakefile +0 -8
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
require "bigdecimal"
|
|
2
|
+
require "digest/md5"
|
|
3
|
+
require "openssl"
|
|
4
|
+
|
|
5
|
+
module Smswire
|
|
6
|
+
module Providers
|
|
7
|
+
# Vonage SMS API over REST. No SDK dependency.
|
|
8
|
+
#
|
|
9
|
+
# Smswire.config.providers[:vonage] = {
|
|
10
|
+
# api_key: "...", api_secret: "...",
|
|
11
|
+
# signature_secret: "...", # required to verify webhooks
|
|
12
|
+
# signature_method: "sha256" # md5hash (Vonage's default), md5, sha1, sha256, or sha512
|
|
13
|
+
# }
|
|
14
|
+
#
|
|
15
|
+
# Set the dashboard webhook method to POST. Long messages are billed as
|
|
16
|
+
# several parts; the receipt keeps the first part's message id, so
|
|
17
|
+
# delivery receipts for later parts are acknowledged but not matched.
|
|
18
|
+
# Prices are recorded in the account currency, which the API does not name.
|
|
19
|
+
# Credentials fall back to Rails.application.credentials.vonage.
|
|
20
|
+
class Vonage < Base
|
|
21
|
+
API_URL = "https://rest.nexmo.com/sms/json".freeze
|
|
22
|
+
SIGNATURE_METHODS = %w[md5hash md5 sha1 sha256 sha512].freeze
|
|
23
|
+
SIGNATURE_TOLERANCE = 300
|
|
24
|
+
|
|
25
|
+
# Send response status codes.
|
|
26
|
+
PERMANENT_REASONS = {
|
|
27
|
+
"4" => :authentication, # Invalid credentials
|
|
28
|
+
"14" => :authentication, # Invalid signature
|
|
29
|
+
"32" => :authentication, # Signature and API secret disallowed
|
|
30
|
+
"6" => :invalid_number, # Invalid message (unrecognized number prefix)
|
|
31
|
+
"33" => :invalid_number, # Number de-activated
|
|
32
|
+
"7" => :unroutable, # Number barred; may include opt-outs but is not specific to them
|
|
33
|
+
"29" => :unroutable # Non-whitelisted destination (demo account)
|
|
34
|
+
}.freeze
|
|
35
|
+
THROTTLED_CODES = %w[1].freeze
|
|
36
|
+
TRANSIENT_CODES = %w[5].freeze
|
|
37
|
+
|
|
38
|
+
# Delivery receipt statuses.
|
|
39
|
+
STATUSES = {
|
|
40
|
+
"accepted" => :accepted, "buffered" => :sent, "delivered" => :delivered, "expired" => :undelivered,
|
|
41
|
+
"failed" => :undelivered, "rejected" => :failed, "unknown" => :sent
|
|
42
|
+
}.freeze
|
|
43
|
+
|
|
44
|
+
def deliver(message)
|
|
45
|
+
response = HTTP.post_form(API_URL, form_for(message), provider: name)
|
|
46
|
+
handle(response)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
BALANCE_URL = "https://rest.nexmo.com/account/get-balance".freeze
|
|
50
|
+
|
|
51
|
+
def capabilities
|
|
52
|
+
Set[:status_callbacks, :inbound]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def verify_credentials!
|
|
56
|
+
query = URI.encode_www_form(api_key: credential(:api_key), api_secret: credential(:api_secret))
|
|
57
|
+
check_credentials_response(HTTP.get("#{BALANCE_URL}?#{query}", provider: name))
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def form_for(message)
|
|
61
|
+
raise ConfigurationError, "Vonage requires a from number or sender id" if message.from.blank?
|
|
62
|
+
|
|
63
|
+
pairs = [
|
|
64
|
+
["api_key", credential(:api_key)], ["api_secret", credential(:api_secret)],
|
|
65
|
+
["to", message.to.delete_prefix("+")], ["from", message.from.delete_prefix("+")],
|
|
66
|
+
["text", message.body.to_s], ["type", (message.encoding == :ucs2) ? "unicode" : "text"]
|
|
67
|
+
]
|
|
68
|
+
pairs << ["ttl", (message.validity_period.to_i * 1000).to_s] if message.validity_period
|
|
69
|
+
if (callback = message.status_callback_url || options[:callback])
|
|
70
|
+
pairs << ["status-report-req", "1"] << ["callback", callback]
|
|
71
|
+
end
|
|
72
|
+
pairs
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Vonage signs webhook parameters as sorted "&key=value" pairs with
|
|
76
|
+
# "&" and "=" in values replaced by "_", then hashes with the
|
|
77
|
+
# signature secret. Timestamps older than five minutes are rejected.
|
|
78
|
+
def verify_signature!(request, url: nil)
|
|
79
|
+
params = request.request_parameters.to_h.transform_keys(&:to_s)
|
|
80
|
+
signature = params.delete("sig").to_s
|
|
81
|
+
raise SignatureError, "Missing Vonage sig parameter" if signature.empty?
|
|
82
|
+
if params["timestamp"].present? && (Time.now.to_i - params["timestamp"].to_i).abs > SIGNATURE_TOLERANCE
|
|
83
|
+
raise SignatureError, "Stale Vonage timestamp"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
expected = self.class.signature(params, secret: signature_secret, method: signature_method)
|
|
87
|
+
return if ActiveSupport::SecurityUtils.secure_compare(expected.downcase, signature.downcase)
|
|
88
|
+
|
|
89
|
+
raise SignatureError, "Invalid Vonage signature"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def self.signature(params, secret:, method:)
|
|
93
|
+
data = params.sort.map { |key, value| "&#{key}=#{value.to_s.tr("&=", "__")}" }.join
|
|
94
|
+
if method == "md5hash"
|
|
95
|
+
Digest::MD5.hexdigest("#{data}#{secret}")
|
|
96
|
+
else
|
|
97
|
+
OpenSSL::HMAC.hexdigest(method, secret, data).upcase
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def parse_status_callback(request)
|
|
102
|
+
params = request.request_parameters
|
|
103
|
+
return nil if params["status"].blank?
|
|
104
|
+
|
|
105
|
+
err_code = params["err-code"].to_s
|
|
106
|
+
StatusUpdate.new(
|
|
107
|
+
provider_id: params["messageId"],
|
|
108
|
+
status: STATUSES.fetch(params["status"].to_s, :sent),
|
|
109
|
+
error_code: (err_code unless err_code.empty? || err_code == "0"),
|
|
110
|
+
raw: params.to_h
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def parse_inbound(request)
|
|
115
|
+
params = request.request_parameters
|
|
116
|
+
return nil unless params.key?("text") && params["msisdn"].present?
|
|
117
|
+
|
|
118
|
+
Inbound.new(
|
|
119
|
+
provider_id: params["messageId"],
|
|
120
|
+
from: international(params["msisdn"]),
|
|
121
|
+
to: international(params["to"]),
|
|
122
|
+
body: params["text"].to_s,
|
|
123
|
+
raw: params.to_h
|
|
124
|
+
)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
def handle(response)
|
|
130
|
+
status = response.code.to_i
|
|
131
|
+
json = HTTP.parse_json(response.body)
|
|
132
|
+
raise TransientError.new("Vonage #{status}", provider: name, http_status: status) if status >= 500
|
|
133
|
+
if status == 429
|
|
134
|
+
raise ThrottledError.new("Vonage 429", provider: name, http_status: status)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
parts = Array(json["messages"])
|
|
138
|
+
failed = parts.find { |part| part["status"].to_s != "0" }
|
|
139
|
+
return success(parts) if status.between?(200, 299) && parts.any? && failed.nil?
|
|
140
|
+
|
|
141
|
+
code = failed&.dig("status")&.to_s
|
|
142
|
+
detail = "Vonage #{status}#{" (#{code})" if code}: #{failed&.dig("error-text") || response.message}"
|
|
143
|
+
error_options = {provider: name, provider_code: code, http_status: status, raw: json}
|
|
144
|
+
if THROTTLED_CODES.include?(code)
|
|
145
|
+
raise ThrottledError.new(detail, **error_options)
|
|
146
|
+
elsif TRANSIENT_CODES.include?(code)
|
|
147
|
+
raise TransientError.new(detail, **error_options)
|
|
148
|
+
else
|
|
149
|
+
raise PermanentError.new(detail, reason: PERMANENT_REASONS.fetch(code, :rejected), **error_options)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def success(parts)
|
|
154
|
+
prices = parts.filter_map { |part| part["message-price"].presence }
|
|
155
|
+
receipt(
|
|
156
|
+
provider_id: parts.first["message-id"],
|
|
157
|
+
status: :accepted,
|
|
158
|
+
segments: parts.size,
|
|
159
|
+
price_amount: (prices.sum { |price| BigDecimal(price.to_s) } if prices.any?),
|
|
160
|
+
raw: {"messages" => parts}
|
|
161
|
+
)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def international(number)
|
|
165
|
+
number = number.to_s
|
|
166
|
+
number.match?(/\A\d+\z/) ? "+#{number}" : number
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def signature_secret
|
|
170
|
+
credential(:signature_secret)
|
|
171
|
+
rescue ConfigurationError
|
|
172
|
+
raise SignatureError, "Set providers[:vonage][:signature_secret] to verify Vonage webhooks"
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def signature_method
|
|
176
|
+
method = (options[:signature_method] || rails_credentials&.dig(:signature_method) || "md5hash").to_s
|
|
177
|
+
raise ConfigurationError, "Unknown Vonage signature_method #{method}" unless SIGNATURE_METHODS.include?(method)
|
|
178
|
+
method
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def credential(key)
|
|
182
|
+
value = options[key] || rails_credentials&.dig(key)
|
|
183
|
+
raise ConfigurationError, "Missing Vonage #{key}" if value.blank?
|
|
184
|
+
value
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def rails_credentials
|
|
188
|
+
return unless defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
|
|
189
|
+
::Rails.application.credentials.vonage&.to_h&.symbolize_keys
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module Smswire
|
|
2
|
+
module Providers
|
|
3
|
+
@registry = {
|
|
4
|
+
test: "Smswire::Providers::Test",
|
|
5
|
+
log: "Smswire::Providers::Log",
|
|
6
|
+
null: "Smswire::Providers::Null",
|
|
7
|
+
twilio: "Smswire::Providers::Twilio",
|
|
8
|
+
telnyx: "Smswire::Providers::Telnyx",
|
|
9
|
+
vonage: "Smswire::Providers::Vonage"
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
# Register a custom adapter: Smswire::Providers.register(:acme, "AcmeProvider").
|
|
14
|
+
def register(name, klass)
|
|
15
|
+
@registry[name.to_sym] = klass
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def registered
|
|
19
|
+
@registry.keys
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def lookup(name)
|
|
23
|
+
klass = @registry.fetch(name.to_sym) do
|
|
24
|
+
raise ConfigurationError, "Unknown provider :#{name}. Registered: #{registered.join(", ")}"
|
|
25
|
+
end
|
|
26
|
+
klass.is_a?(String) ? klass.constantize : klass
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def resolve(name = nil)
|
|
30
|
+
name ||= Smswire.config.default_provider
|
|
31
|
+
unless name
|
|
32
|
+
raise ConfigurationError, "No SMS provider configured. Set Smswire.config.default_provider."
|
|
33
|
+
end
|
|
34
|
+
lookup(name).new(Smswire.config.provider_options(name), name: name.to_sym)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
module Smswire
|
|
2
|
+
# Quiet-hour windows given as "HH:MM".."HH:MM" in the recipient's local
|
|
3
|
+
# time. A window whose start is after its end wraps past midnight. The
|
|
4
|
+
# start is inclusive and the end exclusive.
|
|
5
|
+
module QuietHours
|
|
6
|
+
FORMAT = /\A([01]?\d|2[0-3]):([0-5]\d)\z/
|
|
7
|
+
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# Returns when sending may resume, or nil when +now+ is outside the window.
|
|
11
|
+
def resume_at(window, time_zone:, now: Time.current)
|
|
12
|
+
return nil if window.blank?
|
|
13
|
+
|
|
14
|
+
start_minute, end_minute = parse(window)
|
|
15
|
+
local = now.in_time_zone(time_zone)
|
|
16
|
+
minute = (local.hour * 60) + local.min
|
|
17
|
+
quiet =
|
|
18
|
+
if start_minute < end_minute
|
|
19
|
+
minute >= start_minute && minute < end_minute
|
|
20
|
+
else
|
|
21
|
+
minute >= start_minute || minute < end_minute
|
|
22
|
+
end
|
|
23
|
+
return nil unless quiet
|
|
24
|
+
|
|
25
|
+
resume = local.change(hour: end_minute / 60, min: end_minute % 60)
|
|
26
|
+
(resume > local) ? resume : resume.advance(days: 1)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def parse(window)
|
|
30
|
+
unless window.is_a?(Range)
|
|
31
|
+
raise ConfigurationError, "quiet_hours must be a Range like \"21:00\"..\"08:00\", got #{window.inspect}"
|
|
32
|
+
end
|
|
33
|
+
[window.begin, window.end].map do |value|
|
|
34
|
+
match = FORMAT.match(value.to_s) or raise ConfigurationError, "Invalid quiet_hours time #{value.inspect}"
|
|
35
|
+
(match[1].to_i * 60) + match[2].to_i
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def time_zone_for(recipient)
|
|
40
|
+
zone = recipient&.time_zone
|
|
41
|
+
zone = ActiveSupport::TimeZone[zone] if zone.is_a?(String)
|
|
42
|
+
zone || Smswire.config.time_zone
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module Smswire
|
|
2
|
+
# Per-recipient, per-category sliding-window limits counted from
|
|
3
|
+
# smswire_deliveries. Failed and suppressed deliveries do not count.
|
|
4
|
+
# Concurrent sends can briefly exceed the limit by the number of workers.
|
|
5
|
+
module RateLimit
|
|
6
|
+
COUNTED = %w[pending queued accepted sent delivered undelivered read].freeze
|
|
7
|
+
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# Returns when the next send fits the window, or nil when it fits now.
|
|
11
|
+
def resume_at(message, rule, now: Time.current)
|
|
12
|
+
max = rule.fetch(:max)
|
|
13
|
+
per = rule.fetch(:per)
|
|
14
|
+
unless Smswire.config.persist_deliveries
|
|
15
|
+
raise ConfigurationError, "Rate limits require persist_deliveries"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
recent = Delivery.where(to_number: message.to, category: message.category.to_s, status: COUNTED)
|
|
19
|
+
.where(Delivery.arel_table[:created_at].gt(now - per))
|
|
20
|
+
count = recent.count
|
|
21
|
+
return nil if count < max
|
|
22
|
+
|
|
23
|
+
oldest = recent.order(:created_at).offset(count - max).pick(:created_at)
|
|
24
|
+
oldest + per
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
module Smswire
|
|
2
|
+
# What a provider returns after accepting a message.
|
|
3
|
+
#
|
|
4
|
+
# +status+ uses the normalized vocabulary: :queued, :accepted, :sent,
|
|
5
|
+
# :delivered, :undelivered, :failed, :read.
|
|
6
|
+
Receipt = Data.define(:provider, :provider_id, :status, :segments, :price_amount, :price_currency, :raw) do
|
|
7
|
+
def initialize(provider:, provider_id: nil, status: :accepted, segments: nil, price_amount: nil, price_currency: nil, raw: nil)
|
|
8
|
+
super
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module Smswire
|
|
2
|
+
# The resolved target of a message: the phone number as given, plus the
|
|
3
|
+
# originating record and its time zone when available.
|
|
4
|
+
Recipient = Data.define(:phone, :record, :time_zone) do
|
|
5
|
+
def initialize(phone:, record: nil, time_zone: nil)
|
|
6
|
+
super
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
# Resolution order: Recipient, String, the configured resolver, then
|
|
10
|
+
# +phone_number+ on the object.
|
|
11
|
+
def self.resolve(value)
|
|
12
|
+
case value
|
|
13
|
+
when Recipient
|
|
14
|
+
value
|
|
15
|
+
when String
|
|
16
|
+
raise UnresolvableRecipient, "Recipient phone number is blank" if value.blank?
|
|
17
|
+
new(phone: value)
|
|
18
|
+
when nil
|
|
19
|
+
raise UnresolvableRecipient, "No recipient given"
|
|
20
|
+
else
|
|
21
|
+
phone = phone_for(value)
|
|
22
|
+
if phone.blank?
|
|
23
|
+
raise UnresolvableRecipient, "Could not resolve a phone number from #{value.class.name}"
|
|
24
|
+
end
|
|
25
|
+
new(phone: phone.to_s, record: value, time_zone: (value.time_zone if value.respond_to?(:time_zone)))
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.phone_for(value)
|
|
30
|
+
if (resolver = Smswire.config.recipient_resolver)
|
|
31
|
+
resolver.call(value)
|
|
32
|
+
elsif value.respond_to?(:phone_number)
|
|
33
|
+
value.phone_number
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
private_class_method :phone_for
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
module Smswire
|
|
2
|
+
# The outcome of running a message through the delivery pipeline.
|
|
3
|
+
#
|
|
4
|
+
# Statuses:
|
|
5
|
+
# :accepted provider accepted the message
|
|
6
|
+
# :failed provider permanently refused it (see +error+)
|
|
7
|
+
# :rejected_invalid_number recipient is not a valid E.164 number
|
|
8
|
+
# :rejected_empty_body no body and no media
|
|
9
|
+
# :suppressed_by_interceptor an interceptor cancelled delivery
|
|
10
|
+
# :skipped the messenger action did not call +text+
|
|
11
|
+
# :duplicate an identical message was sent inside the dedupe window
|
|
12
|
+
# :rejected_opted_out the recipient opted out in this consent scope
|
|
13
|
+
# :rejected_no_consent the category requires opt-in and there is none
|
|
14
|
+
# :rejected_rate_limited over the category rate limit, with exceed: :reject
|
|
15
|
+
# :deferred_quiet_hours inside quiet hours; re-enqueued for +resume_at+
|
|
16
|
+
# :deferred_rate_limited over the category rate limit; re-enqueued for +resume_at+
|
|
17
|
+
#
|
|
18
|
+
# +delivery+ is the Smswire::Delivery row when persistence is enabled.
|
|
19
|
+
Result = Data.define(:status, :message, :receipt, :error, :delivery, :resume_at) do
|
|
20
|
+
def initialize(status:, message: nil, receipt: nil, error: nil, delivery: nil, resume_at: nil)
|
|
21
|
+
super
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def accepted? = status == :accepted
|
|
25
|
+
|
|
26
|
+
def failed? = status == :failed
|
|
27
|
+
|
|
28
|
+
def rejected? = status.to_s.start_with?("rejected_")
|
|
29
|
+
|
|
30
|
+
def suppressed? = status == :suppressed_by_interceptor
|
|
31
|
+
|
|
32
|
+
def skipped? = status == :skipped
|
|
33
|
+
|
|
34
|
+
def duplicate? = status == :duplicate
|
|
35
|
+
|
|
36
|
+
def deferred? = status.to_s.start_with?("deferred_")
|
|
37
|
+
|
|
38
|
+
def provider_id = receipt&.provider_id
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
require "smswire"
|
|
2
|
+
require "rspec/expectations"
|
|
3
|
+
|
|
4
|
+
module Smswire
|
|
5
|
+
# RSpec matchers. Load with +require "smswire/rspec"+.
|
|
6
|
+
#
|
|
7
|
+
# expect { order.ship! }.to deliver_sms(to: "+15551234567", body: /shipped/)
|
|
8
|
+
# expect { order.ship! }.to deliver_sms.exactly(2).times
|
|
9
|
+
# expect { order.ship! }.to have_enqueued_sms(OrderMessenger, :shipped)
|
|
10
|
+
#
|
|
11
|
+
# +deliver_sms+ counts deliveries made during the block. Wrap the block
|
|
12
|
+
# in +perform_enqueued_jobs+ to include deliver_later.
|
|
13
|
+
module RSpec
|
|
14
|
+
module Matchers
|
|
15
|
+
def deliver_sms(**filters)
|
|
16
|
+
DeliverSms.new(filters)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def have_enqueued_sms(messenger = nil, action = nil, **filters)
|
|
20
|
+
HaveEnqueuedSms.new(messenger, action, filters)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
module Counting
|
|
24
|
+
def exactly(count)
|
|
25
|
+
@expected_count = count
|
|
26
|
+
self
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def once = exactly(1)
|
|
30
|
+
|
|
31
|
+
def twice = exactly(2)
|
|
32
|
+
|
|
33
|
+
def times = self
|
|
34
|
+
|
|
35
|
+
def supports_block_expectations? = true
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def count_matches?(actual)
|
|
40
|
+
@expected_count ? actual == @expected_count : actual.positive?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def expectation
|
|
44
|
+
@expected_count ? "exactly #{@expected_count}" : "at least one"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
class DeliverSms
|
|
49
|
+
include ::RSpec::Matchers::Composable
|
|
50
|
+
include Counting
|
|
51
|
+
|
|
52
|
+
def initialize(filters)
|
|
53
|
+
@filters = filters
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def matches?(block)
|
|
57
|
+
before = Smswire::Testing.deliveries.size
|
|
58
|
+
block.call
|
|
59
|
+
@delivered = Smswire::Testing.deliveries.drop(before)
|
|
60
|
+
@matching = Smswire::Testing.match_deliveries(@delivered, **@filters)
|
|
61
|
+
count_matches?(@matching.size)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def failure_message
|
|
65
|
+
"expected #{expectation} SMS#{described} to be delivered, got #{@matching.size} " \
|
|
66
|
+
"(#{@delivered.size} delivered in total)"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def failure_message_when_negated
|
|
70
|
+
"expected no SMS#{described} to be delivered, got #{@matching.size}"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def description
|
|
74
|
+
"deliver #{expectation} SMS#{described}"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def described = Smswire::Testing.describe_filters(@filters)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
class HaveEnqueuedSms
|
|
83
|
+
include ::RSpec::Matchers::Composable
|
|
84
|
+
include Counting
|
|
85
|
+
|
|
86
|
+
def initialize(messenger, action, filters)
|
|
87
|
+
@messenger = messenger
|
|
88
|
+
@action = action
|
|
89
|
+
@filters = filters
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def matches?(block)
|
|
93
|
+
adapter = ActiveJob::Base.queue_adapter
|
|
94
|
+
unless adapter.respond_to?(:enqueued_jobs)
|
|
95
|
+
raise ArgumentError, "have_enqueued_sms requires the ActiveJob :test adapter"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
before = adapter.enqueued_jobs.size
|
|
99
|
+
block.call
|
|
100
|
+
entries = Smswire::Testing.enqueued(adapter.enqueued_jobs.drop(before))
|
|
101
|
+
@matching = Smswire::Testing.match_enqueued(entries, messenger: @messenger, action: @action, **@filters)
|
|
102
|
+
count_matches?(@matching.size)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def failure_message
|
|
106
|
+
"expected #{expectation} #{label} to be enqueued, got #{@matching.size}"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def failure_message_when_negated
|
|
110
|
+
"expected no #{label} to be enqueued, got #{@matching.size}"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def description
|
|
114
|
+
"enqueue #{expectation} #{label}"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
def label
|
|
120
|
+
name = [@messenger, @action].compact.join("#")
|
|
121
|
+
name.empty? ? "SMS" : name
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
if defined?(::RSpec::Core) && ::RSpec.respond_to?(:configure)
|
|
129
|
+
::RSpec.configure do |config|
|
|
130
|
+
config.include Smswire::RSpec::Matchers
|
|
131
|
+
config.before { Smswire::Testing.clear! }
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
module Smswire
|
|
2
|
+
# Encoding and segment calculation for SMS bodies.
|
|
3
|
+
#
|
|
4
|
+
# A body that fits the GSM 03.38 alphabet is sent as GSM-7: 160 units in
|
|
5
|
+
# one segment, 153 per segment when concatenated. Extension-table
|
|
6
|
+
# characters cost two units. Anything else is sent as UCS-2: 70 units in
|
|
7
|
+
# one segment, 67 per segment, where characters outside the Basic
|
|
8
|
+
# Multilingual Plane (most emoji) cost two units. Characters are never
|
|
9
|
+
# split across segments, so packing is greedy rather than a division.
|
|
10
|
+
module Segments
|
|
11
|
+
GSM_BASIC = Set.new(
|
|
12
|
+
"@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞÆæßÉ !\"#¤%&'()*+,-./0123456789:;<=>?" \
|
|
13
|
+
"¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà".chars
|
|
14
|
+
).freeze
|
|
15
|
+
GSM_EXTENDED = Set.new("\f^{}\\[~]|€".chars).freeze
|
|
16
|
+
|
|
17
|
+
LIMITS = {
|
|
18
|
+
gsm7: {single: 160, multi: 153},
|
|
19
|
+
ucs2: {single: 70, multi: 67}
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
Analysis = Data.define(:encoding, :units, :segments) do
|
|
23
|
+
def gsm7? = encoding == :gsm7
|
|
24
|
+
|
|
25
|
+
def ucs2? = encoding == :ucs2
|
|
26
|
+
|
|
27
|
+
def encoding_label = gsm7? ? "GSM-7" : "UCS-2"
|
|
28
|
+
|
|
29
|
+
# Units left before the message needs another segment.
|
|
30
|
+
def remaining
|
|
31
|
+
limits = LIMITS.fetch(encoding)
|
|
32
|
+
capacity = (segments <= 1) ? limits[:single] : segments * limits[:multi]
|
|
33
|
+
[capacity - units, 0].max
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
module_function
|
|
38
|
+
|
|
39
|
+
def analyze(text)
|
|
40
|
+
chars = text.to_s.chars
|
|
41
|
+
encoding = gsm7?(chars) ? :gsm7 : :ucs2
|
|
42
|
+
costs = chars.map { |char| cost(char, encoding) }
|
|
43
|
+
units = costs.sum
|
|
44
|
+
Analysis.new(encoding:, units:, segments: count(costs, units, LIMITS.fetch(encoding)))
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Characters that force UCS-2 encoding, in order of appearance.
|
|
48
|
+
def non_gsm_characters(text)
|
|
49
|
+
text.to_s.chars.reject { |char| GSM_BASIC.include?(char) || GSM_EXTENDED.include?(char) }.uniq
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def gsm7?(chars)
|
|
53
|
+
chars.all? { |char| GSM_BASIC.include?(char) || GSM_EXTENDED.include?(char) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def cost(char, encoding)
|
|
57
|
+
if encoding == :gsm7
|
|
58
|
+
GSM_EXTENDED.include?(char) ? 2 : 1
|
|
59
|
+
else
|
|
60
|
+
(char.ord > 0xFFFF) ? 2 : 1
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def count(costs, units, limits)
|
|
65
|
+
return 0 if units.zero?
|
|
66
|
+
return 1 if units <= limits[:single]
|
|
67
|
+
|
|
68
|
+
segments = 1
|
|
69
|
+
used = 0
|
|
70
|
+
costs.each do |cost|
|
|
71
|
+
if used + cost > limits[:multi]
|
|
72
|
+
segments += 1
|
|
73
|
+
used = cost
|
|
74
|
+
else
|
|
75
|
+
used += cost
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
segments
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module Smswire
|
|
2
|
+
# A from-number or messaging service, with the provider that owns it.
|
|
3
|
+
# +consent_scope+ names the consent program the sender belongs to.
|
|
4
|
+
Sender = Data.define(:number, :messaging_service, :provider, :consent_scope) do
|
|
5
|
+
def initialize(number: nil, messaging_service: nil, provider: nil, consent_scope: nil)
|
|
6
|
+
super
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def self.resolve(value)
|
|
10
|
+
case value
|
|
11
|
+
when nil then new
|
|
12
|
+
when Sender then value
|
|
13
|
+
when String then new(number: value)
|
|
14
|
+
when Symbol
|
|
15
|
+
options = Smswire.config.senders[value] || Smswire.config.senders[value.to_s]
|
|
16
|
+
unless options
|
|
17
|
+
raise ConfigurationError, "Unknown sender :#{value}. Define it in Smswire.config.senders."
|
|
18
|
+
end
|
|
19
|
+
options = options.to_h.symbolize_keys
|
|
20
|
+
new(number: options[:number], messaging_service: options[:messaging_service],
|
|
21
|
+
provider: options[:provider]&.to_sym, consent_scope: options[:consent_scope]&.to_s)
|
|
22
|
+
else
|
|
23
|
+
raise ConfigurationError, "from: must be a phone number String or a sender Symbol, got #{value.class.name}"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module Smswire
|
|
2
|
+
# Applies a parsed provider status update to its delivery and notifies
|
|
3
|
+
# observers. Shared by the webhook endpoints.
|
|
4
|
+
module StatusHandler
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def call(update, provider:, delivery_id: nil)
|
|
8
|
+
payload = {provider:, provider_id: update.provider_id, status: update.status, error_code: update.error_code}
|
|
9
|
+
ActiveSupport::Notifications.instrument("status_update.smswire", payload) do
|
|
10
|
+
delivery = Delivery.apply_status_update(update, provider:, delivery_id:)
|
|
11
|
+
payload[:delivery_id] = delivery&.id
|
|
12
|
+
payload[:delivery_status] = delivery&.status
|
|
13
|
+
notify_observers(delivery, update) if delivery
|
|
14
|
+
delivery
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def notify_observers(delivery, update)
|
|
19
|
+
Smswire.config.observers.each do |observer|
|
|
20
|
+
observer = observer.is_a?(String) ? observer.constantize : observer
|
|
21
|
+
observer.sms_status_updated(delivery, update) if observer.respond_to?(:sms_status_updated)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
module Smswire
|
|
2
|
+
# A provider's report that a message changed status, parsed from a callback.
|
|
3
|
+
StatusUpdate = Data.define(:provider_id, :status, :error_code, :error_message, :raw) do
|
|
4
|
+
def initialize(provider_id:, status:, error_code: nil, error_message: nil, raw: {})
|
|
5
|
+
super
|
|
6
|
+
end
|
|
7
|
+
end
|
|
8
|
+
end
|