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,36 @@
|
|
|
1
|
+
module Smswire
|
|
2
|
+
module Interceptors
|
|
3
|
+
# Staging safety: only listed numbers receive messages.
|
|
4
|
+
#
|
|
5
|
+
# Smswire.register_interceptor(
|
|
6
|
+
# Smswire::Interceptors::Allowlist.new(numbers: ENV.fetch("SMS_ALLOWLIST", "").split(","))
|
|
7
|
+
# )
|
|
8
|
+
#
|
|
9
|
+
# Other recipients are suppressed, or with +redirect_to:+ rerouted to
|
|
10
|
+
# that number with the original recipient prefixed to the body.
|
|
11
|
+
class Allowlist
|
|
12
|
+
attr_reader :numbers, :redirect_to
|
|
13
|
+
|
|
14
|
+
def initialize(numbers:, redirect_to: nil)
|
|
15
|
+
@numbers = Array(numbers).filter_map { |number| PhoneNumber.normalize(number.to_s.strip) }.to_set
|
|
16
|
+
@redirect_to = redirect_to && (PhoneNumber.normalize(redirect_to) or
|
|
17
|
+
raise ConfigurationError, "Invalid redirect_to number #{redirect_to.inspect}")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def allowed?(number)
|
|
21
|
+
numbers.include?(number)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def delivering_sms(message)
|
|
25
|
+
return if allowed?(message.to)
|
|
26
|
+
|
|
27
|
+
if redirect_to
|
|
28
|
+
message.body = "[to #{message.to}] #{message.body}"
|
|
29
|
+
message.to = redirect_to
|
|
30
|
+
else
|
|
31
|
+
message.cancel!
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
module Smswire
|
|
2
|
+
# Sends keyword auto-replies. Uses the :compliance category, which skips
|
|
3
|
+
# consent checks so the opt-out confirmation reaches an opted-out number.
|
|
4
|
+
class KeywordMessenger < Base
|
|
5
|
+
def reply(phone, kind, from: nil, messaging_service: nil, provider: nil)
|
|
6
|
+
body = Smswire.config.keyword_reply(kind) or return
|
|
7
|
+
|
|
8
|
+
text to: phone, body:, category: :compliance, from: (from unless messaging_service),
|
|
9
|
+
messaging_service:, provider: provider&.to_sym
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
module Smswire
|
|
2
|
+
# Detects STOP / START / HELP style keywords in inbound messages, updates
|
|
3
|
+
# consent, and sends the configured auto-reply.
|
|
4
|
+
#
|
|
5
|
+
# A keyword matches only when it is the whole message, ignoring case,
|
|
6
|
+
# spaces, and surrounding punctuation, so "Opt out." matches OPTOUT and
|
|
7
|
+
# "stop all" matches STOPALL. Free-text requests such as "please stop
|
|
8
|
+
# texting me" are recorded but not matched; see docs/guides/compliance.md.
|
|
9
|
+
module Keywords
|
|
10
|
+
STORED = {opt_out: "stop", opt_in: "start", help: "help"}.freeze
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# Returns [kind, word] or nil.
|
|
15
|
+
def classify(body)
|
|
16
|
+
word = body.to_s.strip.gsub(/\A[^[:alnum:]]+|[^[:alnum:]]+\z/, "").gsub(/[[:space:]-]+/, "").upcase
|
|
17
|
+
return nil if word.empty?
|
|
18
|
+
|
|
19
|
+
Smswire.config.keywords.each do |kind, words|
|
|
20
|
+
return [kind.to_sym, word] if words.map { |candidate| candidate.upcase.delete(" -") }.include?(word)
|
|
21
|
+
end
|
|
22
|
+
nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def handle(record, inbound, provider:)
|
|
26
|
+
kind, word = classify(record.body)
|
|
27
|
+
return nil unless kind
|
|
28
|
+
|
|
29
|
+
phone = record.from_number
|
|
30
|
+
scope = Consent.scope_for_sender(number: record.to_number, messaging_service: record.messaging_service)
|
|
31
|
+
case kind
|
|
32
|
+
when :opt_out
|
|
33
|
+
if word == "STOPALL" || Smswire.config.opt_out_all_scopes
|
|
34
|
+
Consent.opt_out_everywhere!(phone, source: :keyword, keyword: word)
|
|
35
|
+
else
|
|
36
|
+
Consent.opt_out!(phone, scope:, source: :keyword, keyword: word)
|
|
37
|
+
end
|
|
38
|
+
when :opt_in
|
|
39
|
+
Consent.opt_in!(phone, scope:, source: :keyword, keyword: word)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
unless inbound.provider_keyword
|
|
43
|
+
KeywordMessenger.reply(phone, kind.to_s, from: record.to_number,
|
|
44
|
+
messaging_service: record.messaging_service, provider: provider.to_s).deliver_later
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
record.update!(keyword: STORED.fetch(kind), handled_at: Time.current)
|
|
48
|
+
kind
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
module Smswire
|
|
2
|
+
# Logs deliveries, rejections, and status updates. Phone numbers are
|
|
3
|
+
# masked and bodies are never logged.
|
|
4
|
+
class LogSubscriber < ActiveSupport::LogSubscriber
|
|
5
|
+
def deliver(event)
|
|
6
|
+
payload = event.payload
|
|
7
|
+
route = "#{payload[:messenger]}##{payload[:action]} to #{mask(payload[:to])} via #{payload[:provider]}"
|
|
8
|
+
if (error = payload[:exception_object])
|
|
9
|
+
warn { "SMS #{route} raised #{error.class}: #{error.message} (#{event.duration.round(1)}ms)" }
|
|
10
|
+
else
|
|
11
|
+
info do
|
|
12
|
+
"SMS #{route}: #{payload[:status]} #{payload[:provider_id]} " \
|
|
13
|
+
"(#{payload[:segments]} segment(s), #{event.duration.round(1)}ms)"
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def reject(event)
|
|
19
|
+
payload = event.payload
|
|
20
|
+
until_time = payload[:resume_at] ? " until #{payload[:resume_at].utc.iso8601}" : ""
|
|
21
|
+
info { "SMS #{payload[:messenger]}##{payload[:action]} to #{mask(payload[:to])} not sent: #{payload[:reason]}#{until_time}" }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def status_update(event)
|
|
25
|
+
payload = event.payload
|
|
26
|
+
if payload[:delivery_id]
|
|
27
|
+
info do
|
|
28
|
+
"SMS status #{payload[:provider]} #{payload[:provider_id]}: #{payload[:status]} " \
|
|
29
|
+
"(delivery #{payload[:delivery_id]} now #{payload[:delivery_status]})"
|
|
30
|
+
end
|
|
31
|
+
else
|
|
32
|
+
warn { "SMS status #{payload[:provider]} #{payload[:provider_id]}: #{payload[:status]} for unknown delivery" }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def inbound(event)
|
|
37
|
+
payload = event.payload
|
|
38
|
+
info do
|
|
39
|
+
keyword = payload[:keyword] ? " keyword #{payload[:keyword]}" : ""
|
|
40
|
+
"SMS received via #{payload[:provider]} from #{mask(payload[:from])}#{keyword}#{" (duplicate webhook)" if payload[:duplicate]}"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def logger
|
|
45
|
+
Smswire.logger
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def mask(number)
|
|
51
|
+
number = number.to_s
|
|
52
|
+
return number if number.length < 8
|
|
53
|
+
|
|
54
|
+
"#{number[0, 2]}#{"*" * (number.length - 6)}#{number[-4..]}"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
|
|
3
|
+
module Smswire
|
|
4
|
+
# A single outbound SMS. Built by Smswire::Base#text and passed through
|
|
5
|
+
# the pipeline. Interceptors may change any attribute before delivery.
|
|
6
|
+
class Message
|
|
7
|
+
attr_accessor :to, :from, :body, :media_urls, :messaging_service, :provider,
|
|
8
|
+
:category, :metadata, :validity_period, :messenger, :action, :recipient, :consent_scope
|
|
9
|
+
attr_reader :raw_to
|
|
10
|
+
|
|
11
|
+
# Set by the pipeline when Smswire.config.callbacks_url is configured.
|
|
12
|
+
attr_accessor :status_callback_url
|
|
13
|
+
attr_writer :idempotency_key
|
|
14
|
+
|
|
15
|
+
def initialize(to:, body:, from: nil, media_urls: [], messaging_service: nil, provider: nil,
|
|
16
|
+
category: nil, metadata: {}, validity_period: nil, idempotency_key: nil,
|
|
17
|
+
messenger: nil, action: nil, recipient: nil, consent_scope: nil)
|
|
18
|
+
@to = to
|
|
19
|
+
@raw_to = to
|
|
20
|
+
@from = from
|
|
21
|
+
@body = body
|
|
22
|
+
@media_urls = Array(media_urls)
|
|
23
|
+
@messaging_service = messaging_service
|
|
24
|
+
@provider = provider&.to_sym
|
|
25
|
+
@category = (category || Smswire.config.default_category)&.to_sym
|
|
26
|
+
@metadata = metadata || {}
|
|
27
|
+
@validity_period = validity_period
|
|
28
|
+
@idempotency_key = idempotency_key
|
|
29
|
+
@messenger = messenger
|
|
30
|
+
@action = action&.to_s
|
|
31
|
+
@recipient = recipient
|
|
32
|
+
@consent_scope = (consent_scope || "default").to_s
|
|
33
|
+
@perform_deliveries = true
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def perform_deliveries?
|
|
37
|
+
@perform_deliveries
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def cancel!
|
|
41
|
+
@perform_deliveries = false
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def analysis
|
|
45
|
+
Segments.analyze(body)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def segments = analysis.segments
|
|
49
|
+
|
|
50
|
+
def encoding = analysis.encoding
|
|
51
|
+
|
|
52
|
+
def idempotency_key
|
|
53
|
+
@idempotency_key || Digest::SHA256.hexdigest([messenger, action, to, body].join("\u0000"))[0, 32]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Fix the key to the current content, so interceptors that rewrite the
|
|
57
|
+
# body do not change which message this is.
|
|
58
|
+
def lock_idempotency_key!
|
|
59
|
+
@idempotency_key = idempotency_key
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Attributes safe to log or instrument. Never includes the body.
|
|
63
|
+
def to_log_h
|
|
64
|
+
{messenger:, action:, to:, from:, messaging_service:, provider:, category:,
|
|
65
|
+
segments:, encoding:, media_count: media_urls.size}
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def inspect
|
|
69
|
+
"#<#{self.class.name} #{messenger}##{action} to=#{to.inspect} segments=#{segments} encoding=#{encoding}>"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
require "delegate"
|
|
2
|
+
|
|
3
|
+
module Smswire
|
|
4
|
+
# A lazy handle on a messenger action, like ActionMailer::MessageDelivery.
|
|
5
|
+
# The action runs on first access to the message, on +deliver_now+, or
|
|
6
|
+
# inside the job for +deliver_later+.
|
|
7
|
+
class MessageDelivery < Delegator
|
|
8
|
+
def initialize(messenger_class, action, args: [], kwargs: {}, params: {})
|
|
9
|
+
@messenger_class = messenger_class
|
|
10
|
+
@action = action
|
|
11
|
+
@args = args
|
|
12
|
+
@kwargs = kwargs
|
|
13
|
+
@params = params
|
|
14
|
+
@processed_messenger = nil
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def __getobj__
|
|
18
|
+
processed_messenger.message
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def __setobj__(_object)
|
|
22
|
+
raise NotImplementedError, "MessageDelivery cannot be reassigned"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def message
|
|
26
|
+
__getobj__
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def processed?
|
|
30
|
+
!@processed_messenger.nil?
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Run the pipeline now and return a Smswire::Result. A deferred result
|
|
34
|
+
# (quiet hours or rate limit) enqueues the action again for +resume_at+,
|
|
35
|
+
# so arguments must then be serializable by ActiveJob.
|
|
36
|
+
def deliver_now
|
|
37
|
+
message = processed_messenger.message
|
|
38
|
+
return Result.new(status: :skipped) if message.nil?
|
|
39
|
+
|
|
40
|
+
result = Pipeline.call(message)
|
|
41
|
+
enqueue(wait_until: result.resume_at) if result.deferred? && result.resume_at
|
|
42
|
+
result
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Enqueue Smswire::DeliveryJob. The action runs again inside the job,
|
|
46
|
+
# so arguments and params must be serializable by ActiveJob.
|
|
47
|
+
def deliver_later(wait: nil, wait_until: nil, queue: nil, priority: nil)
|
|
48
|
+
if processed?
|
|
49
|
+
raise Error, "#{@messenger_class.name}##{@action} was accessed before deliver_later. " \
|
|
50
|
+
"Changes made to the message would be lost; call deliver_later without reading it first."
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
enqueue(wait:, wait_until:, queue:, priority:)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def enqueue(**options)
|
|
59
|
+
DeliveryJob.set(options.compact).perform_later(@messenger_class.name, @action.to_s, @params, @args, @kwargs)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def processed_messenger
|
|
63
|
+
@processed_messenger ||= @messenger_class.new.tap do |messenger|
|
|
64
|
+
messenger.params = @params
|
|
65
|
+
messenger.process(@action, *@args, **@kwargs)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Loaded by Smswire::Engine when Noticed is present.
|
|
2
|
+
module Noticed
|
|
3
|
+
module DeliveryMethods
|
|
4
|
+
# Sends a Noticed notification through a Smswire messenger, so
|
|
5
|
+
# templates, consent, quiet hours, deduplication, persistence, retries,
|
|
6
|
+
# and status tracking all apply.
|
|
7
|
+
#
|
|
8
|
+
# class OrderShippedNotifier < Noticed::Event
|
|
9
|
+
# deliver_by :smswire do |config|
|
|
10
|
+
# config.messenger = "OrderMessenger"
|
|
11
|
+
# config.action = :shipped
|
|
12
|
+
# # Optional:
|
|
13
|
+
# # config.args = -> { [recipient] } # default
|
|
14
|
+
# # config.kwargs = -> { {} }
|
|
15
|
+
# # config.params = -> { {order: record} } # default: the event params
|
|
16
|
+
# # config.sms_queue, config.sms_priority # for Smswire::DeliveryJob
|
|
17
|
+
# # config.wait, config.queue, config.if, ... # Noticed's own job options
|
|
18
|
+
# end
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# The messenger receives the event params plus +notification+, +record+,
|
|
22
|
+
# and +recipient+. Each Smswire::Delivery records the Noticed
|
|
23
|
+
# notification and event ids in its metadata.
|
|
24
|
+
class Smswire < DeliveryMethod
|
|
25
|
+
required_options :messenger, :action
|
|
26
|
+
|
|
27
|
+
def deliver
|
|
28
|
+
messenger = fetch_constant(:messenger)
|
|
29
|
+
action = evaluate_option(:action).to_sym
|
|
30
|
+
args = evaluate_option(:args) || [recipient]
|
|
31
|
+
kwargs = evaluate_option(:kwargs) || {}
|
|
32
|
+
|
|
33
|
+
::Smswire::MessageDelivery.new(messenger, action, args: Array(args), kwargs:, params: messenger_params)
|
|
34
|
+
.deliver_later(**job_options)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def messenger_params
|
|
40
|
+
base = evaluate_option(:params) || event.params || {}
|
|
41
|
+
base.to_h.symbolize_keys.merge(
|
|
42
|
+
notification:, record: event.record, recipient:,
|
|
43
|
+
smswire_metadata: {"noticed_notification_id" => notification.id, "noticed_event_id" => event.id}
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Noticed reads wait, wait_until, queue, and priority for its own job,
|
|
48
|
+
# which already applies any delay, so Smswire's job takes separate keys.
|
|
49
|
+
def job_options
|
|
50
|
+
{queue: evaluate_option(:sms_queue), priority: evaluate_option(:sms_priority)}.compact
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
module Smswire
|
|
2
|
+
# Returned by Smswire::Base.with(params). Actions called on it build a
|
|
3
|
+
# MessageDelivery whose messenger instance sees +params+.
|
|
4
|
+
class ParameterizedMessenger
|
|
5
|
+
def initialize(messenger, params)
|
|
6
|
+
@messenger = messenger
|
|
7
|
+
@params = params
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def respond_to_missing?(name, include_all = false)
|
|
11
|
+
@messenger.action_methods.include?(name.to_s) || super
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def method_missing(name, *args, **kwargs, &block)
|
|
17
|
+
if @messenger.action_methods.include?(name.to_s)
|
|
18
|
+
MessageDelivery.new(@messenger, name, args:, kwargs:, params: @params)
|
|
19
|
+
else
|
|
20
|
+
super
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
module Smswire
|
|
2
|
+
# E.164 normalization.
|
|
3
|
+
#
|
|
4
|
+
# With phonelib loaded (or +phone_validator = :phonelib+) any region's
|
|
5
|
+
# national format is supported. The built-in fallback accepts
|
|
6
|
+
# international numbers written with + or 00, and national numbers for
|
|
7
|
+
# North American Numbering Plan regions.
|
|
8
|
+
module PhoneNumber
|
|
9
|
+
E164 = /\A\+[1-9]\d{6,14}\z/
|
|
10
|
+
NANP = /\A\+1[2-9]\d{2}[2-9]\d{6}\z/
|
|
11
|
+
NANP_REGIONS = %w[US CA AG AI AS BB BM BS DM DO GD GU JM KN KY LC MP MS PR SX TC TT VC VG VI].freeze
|
|
12
|
+
SEPARATORS = /[\s\-.()\/]/
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# Returns the E.164 string, or nil when the input is not a valid number.
|
|
17
|
+
def normalize(raw, region: Smswire.config.default_region)
|
|
18
|
+
return nil if raw.blank?
|
|
19
|
+
|
|
20
|
+
if use_phonelib?
|
|
21
|
+
parsed = ::Phonelib.parse(raw.to_s, region)
|
|
22
|
+
parsed.valid? ? parsed.e164 : nil
|
|
23
|
+
else
|
|
24
|
+
fallback(raw.to_s, region.to_s.upcase)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def valid?(raw, **options)
|
|
29
|
+
!normalize(raw, **options).nil?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def use_phonelib?
|
|
33
|
+
case Smswire.config.phone_validator
|
|
34
|
+
when :e164 then false
|
|
35
|
+
when :phonelib
|
|
36
|
+
require "phonelib" unless defined?(::Phonelib)
|
|
37
|
+
true
|
|
38
|
+
else
|
|
39
|
+
defined?(::Phonelib) ? true : false
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def fallback(raw, region)
|
|
44
|
+
compact = raw.strip.gsub(SEPARATORS, "")
|
|
45
|
+
return nil unless compact.match?(/\A\+?\d+\z/)
|
|
46
|
+
|
|
47
|
+
candidate =
|
|
48
|
+
if compact.start_with?("+")
|
|
49
|
+
compact
|
|
50
|
+
elsif compact.start_with?("00")
|
|
51
|
+
"+#{compact.delete_prefix("00")}"
|
|
52
|
+
elsif NANP_REGIONS.include?(region)
|
|
53
|
+
case compact.length
|
|
54
|
+
when 10 then "+1#{compact}"
|
|
55
|
+
when 11 then compact.start_with?("1") ? "+#{compact}" : nil
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
return nil unless candidate&.match?(E164)
|
|
60
|
+
return nil if candidate.start_with?("+1") && !candidate.match?(NANP)
|
|
61
|
+
|
|
62
|
+
candidate
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
module Smswire
|
|
2
|
+
# Runs a message through normalize, validate, claim, intercept, send,
|
|
3
|
+
# record, and observe.
|
|
4
|
+
#
|
|
5
|
+
# Returns a Smswire::Result. Permanent provider errors become a :failed
|
|
6
|
+
# result. Transient errors and configuration errors propagate so the
|
|
7
|
+
# caller (or Smswire::DeliveryJob) can retry or fail loudly. When
|
|
8
|
+
# persistence is enabled, each claimed message has a Smswire::Delivery row
|
|
9
|
+
# that a retry reuses, so a message is never recorded twice.
|
|
10
|
+
class Pipeline
|
|
11
|
+
def self.call(message)
|
|
12
|
+
new(message).call
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
attr_reader :message
|
|
16
|
+
|
|
17
|
+
def initialize(message)
|
|
18
|
+
@message = message
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call
|
|
22
|
+
normalized = PhoneNumber.normalize(message.to)
|
|
23
|
+
return reject(:rejected_invalid_number) unless normalized
|
|
24
|
+
message.to = normalized
|
|
25
|
+
|
|
26
|
+
return reject(:rejected_empty_body) if message.body.blank? && message.media_urls.empty?
|
|
27
|
+
|
|
28
|
+
rule = Smswire.config.category_options(message.category)
|
|
29
|
+
if (status = consent_rejection(rule))
|
|
30
|
+
return reject(status)
|
|
31
|
+
end
|
|
32
|
+
if (resume_at = QuietHours.resume_at(rule[:quiet_hours], time_zone: QuietHours.time_zone_for(message.recipient)))
|
|
33
|
+
return defer(:deferred_quiet_hours, resume_at)
|
|
34
|
+
end
|
|
35
|
+
if rule[:rate_limit] && (resume_at = RateLimit.resume_at(message, rule[:rate_limit]))
|
|
36
|
+
return reject(:rejected_rate_limited) if rule[:rate_limit][:exceed]&.to_sym == :reject
|
|
37
|
+
return defer(:deferred_rate_limited, resume_at)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
provider = Providers.resolve(message.provider)
|
|
41
|
+
message.lock_idempotency_key!
|
|
42
|
+
|
|
43
|
+
delivery = nil
|
|
44
|
+
if Smswire.config.persist_deliveries
|
|
45
|
+
delivery = Delivery.claim_for(message, provider: provider.name)
|
|
46
|
+
return reject(:duplicate) unless delivery
|
|
47
|
+
message.status_callback_url ||= Callbacks.status_url(provider.name, delivery) if provider.capabilities.include?(:status_callbacks)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
run_interceptors
|
|
51
|
+
unless message.perform_deliveries?
|
|
52
|
+
delivery&.record_suppression!(message)
|
|
53
|
+
return finish(Result.new(status: :suppressed_by_interceptor, message:, delivery:))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
deliver(provider, delivery)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def deliver(provider, delivery)
|
|
62
|
+
payload = message.to_log_h.merge(provider: provider.name, delivery_id: delivery&.id)
|
|
63
|
+
receipt = ActiveSupport::Notifications.instrument("deliver.smswire", payload) do
|
|
64
|
+
provider.deliver(message).tap do |r|
|
|
65
|
+
payload[:provider_id] = r.provider_id
|
|
66
|
+
payload[:status] = r.status
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
delivery&.record_receipt!(receipt, message)
|
|
70
|
+
status = (receipt.status == :failed) ? :failed : :accepted
|
|
71
|
+
finish(Result.new(status:, message:, receipt:, delivery:))
|
|
72
|
+
rescue PermanentError => error
|
|
73
|
+
delivery&.record_failure!(error, message)
|
|
74
|
+
if error.opted_out? && Smswire.config.enforce_consent
|
|
75
|
+
Consent.opt_out!(message.to, scope: message.consent_scope, source: :carrier)
|
|
76
|
+
end
|
|
77
|
+
finish(Result.new(status: :failed, message:, error:, delivery:))
|
|
78
|
+
rescue TransientError => error
|
|
79
|
+
delivery&.release!(error)
|
|
80
|
+
raise
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def consent_rejection(rule)
|
|
84
|
+
return nil if rule[:consent] == :none || !Smswire.config.enforce_consent
|
|
85
|
+
|
|
86
|
+
status = Consent.status_for(message.to, scope: message.consent_scope)
|
|
87
|
+
return :rejected_opted_out if status == "opted_out"
|
|
88
|
+
:rejected_no_consent if rule[:consent] == :require_opted_in && status != "opted_in"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def defer(status, resume_at)
|
|
92
|
+
ActiveSupport::Notifications.instrument("reject.smswire", message.to_log_h.merge(reason: status, resume_at:))
|
|
93
|
+
finish(Result.new(status:, message:, resume_at:))
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def reject(status)
|
|
97
|
+
ActiveSupport::Notifications.instrument("reject.smswire", message.to_log_h.merge(reason: status))
|
|
98
|
+
finish(Result.new(status:, message:))
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def finish(result)
|
|
102
|
+
Smswire.config.observers.each { |observer| resolve(observer).delivered_sms(result) }
|
|
103
|
+
result
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def run_interceptors
|
|
107
|
+
Smswire.config.interceptors.each { |interceptor| resolve(interceptor).delivering_sms(message) }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def resolve(hook)
|
|
111
|
+
hook.is_a?(String) ? hook.constantize : hook
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
require "active_support/core_ext/class/subclasses"
|
|
2
|
+
|
|
3
|
+
module Smswire
|
|
4
|
+
# Previews for messengers, like ActionMailer::Preview. Put them in
|
|
5
|
+
# test/messengers/previews (or spec/messengers/previews) and open
|
|
6
|
+
# <mount>/previews in development.
|
|
7
|
+
#
|
|
8
|
+
# class OrderMessengerPreview < Smswire::Preview
|
|
9
|
+
# def shipped
|
|
10
|
+
# OrderMessenger.with(order: Order.first).shipped(User.first)
|
|
11
|
+
# end
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# Each public method returns a MessageDelivery. Nothing is sent.
|
|
15
|
+
class Preview
|
|
16
|
+
class << self
|
|
17
|
+
def all
|
|
18
|
+
load_previews
|
|
19
|
+
descendants.reject { |preview| preview.name.nil? }.sort_by(&:preview_name)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def find(preview_name)
|
|
23
|
+
all.find { |preview| preview.preview_name == preview_name }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def preview_name
|
|
27
|
+
name.delete_suffix("Preview").underscore
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def messages
|
|
31
|
+
public_instance_methods(false).map(&:to_s).sort
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def message_exists?(name)
|
|
35
|
+
messages.include?(name.to_s)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Runs the preview method and returns the built Smswire::Message, or
|
|
39
|
+
# nil when the action did not call +text+. Interceptors are not run.
|
|
40
|
+
def call(name)
|
|
41
|
+
delivery = new.public_send(name)
|
|
42
|
+
delivery.respond_to?(:message) ? delivery.message : delivery
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def load_previews
|
|
48
|
+
Smswire.config.preview_paths.each do |path|
|
|
49
|
+
Dir["#{path}/**/*_preview.rb"].sort.each { |file| require file }
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|