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.
Files changed (86) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +67 -3
  3. data/README.md +222 -9
  4. data/RELEASING.md +49 -0
  5. data/app/controllers/smswire/development_controller.rb +17 -0
  6. data/app/controllers/smswire/inbound_messages_controller.rb +9 -0
  7. data/app/controllers/smswire/inbox_controller.rb +31 -0
  8. data/app/controllers/smswire/previews_controller.rb +22 -0
  9. data/app/controllers/smswire/status_callbacks_controller.rb +8 -0
  10. data/app/controllers/smswire/webhooks_controller.rb +49 -0
  11. data/app/models/smswire/application_record.rb +5 -0
  12. data/app/models/smswire/consent.rb +71 -0
  13. data/app/models/smswire/delivery.rb +157 -0
  14. data/app/models/smswire/inbound_message.rb +25 -0
  15. data/app/views/layouts/smswire/application.html.erb +60 -0
  16. data/app/views/smswire/inbox/index.html.erb +56 -0
  17. data/app/views/smswire/previews/index.html.erb +21 -0
  18. data/app/views/smswire/previews/show.html.erb +34 -0
  19. data/config/routes.rb +10 -0
  20. data/db/migrate/20261008000000_create_smswire_deliveries.rb +47 -0
  21. data/db/migrate/20261008000001_create_smswire_consents_and_inbound_messages.rb +34 -0
  22. data/docs/SPEC.md +164 -43
  23. data/docs/guides/compliance.md +124 -0
  24. data/docs/guides/getting-started.md +154 -0
  25. data/docs/guides/migrating-from-noticed-twilio.md +105 -0
  26. data/docs/guides/migrating-from-twilio-ruby.md +146 -0
  27. data/docs/guides/writing-a-provider.md +126 -0
  28. data/lib/generators/smswire/install/install_generator.rb +44 -0
  29. data/lib/generators/smswire/install/templates/application_messenger.rb.tt +3 -0
  30. data/lib/generators/smswire/install/templates/initializer.rb.tt +35 -0
  31. data/lib/generators/smswire/messenger/messenger_generator.rb +51 -0
  32. data/lib/generators/smswire/messenger/templates/messenger.rb.tt +14 -0
  33. data/lib/generators/smswire/messenger/templates/preview.rb.tt +13 -0
  34. data/lib/generators/smswire/messenger/templates/test.rb.tt +18 -0
  35. data/lib/generators/smswire/messenger/templates/view.text.erb.tt +1 -0
  36. data/lib/generators/smswire/provider/provider_generator.rb +36 -0
  37. data/lib/generators/smswire/provider/templates/provider.rb.tt +52 -0
  38. data/lib/generators/smswire/provider/templates/test.rb.tt +29 -0
  39. data/lib/smswire/base.rb +123 -0
  40. data/lib/smswire/body_formatter.rb +14 -0
  41. data/lib/smswire/callbacks.rb +24 -0
  42. data/lib/smswire/configuration.rb +224 -0
  43. data/lib/smswire/delivery_job.rb +29 -0
  44. data/lib/smswire/engine.rb +52 -0
  45. data/lib/smswire/errors.rb +59 -0
  46. data/lib/smswire/http.rb +65 -0
  47. data/lib/smswire/inbound.rb +11 -0
  48. data/lib/smswire/inbound_handler.rb +29 -0
  49. data/lib/smswire/interceptors/allowlist.rb +36 -0
  50. data/lib/smswire/keyword_messenger.rb +12 -0
  51. data/lib/smswire/keywords.rb +51 -0
  52. data/lib/smswire/log_subscriber.rb +57 -0
  53. data/lib/smswire/message.rb +72 -0
  54. data/lib/smswire/message_delivery.rb +69 -0
  55. data/lib/smswire/noticed_delivery_method.rb +54 -0
  56. data/lib/smswire/parameterized_messenger.rb +24 -0
  57. data/lib/smswire/phone_number.rb +65 -0
  58. data/lib/smswire/pipeline.rb +114 -0
  59. data/lib/smswire/preview.rb +54 -0
  60. data/lib/smswire/provider_contract.rb +158 -0
  61. data/lib/smswire/providers/base.rb +66 -0
  62. data/lib/smswire/providers/log.rb +23 -0
  63. data/lib/smswire/providers/null.rb +12 -0
  64. data/lib/smswire/providers/telnyx.rb +178 -0
  65. data/lib/smswire/providers/test.rb +59 -0
  66. data/lib/smswire/providers/twilio.rb +167 -0
  67. data/lib/smswire/providers/vonage.rb +193 -0
  68. data/lib/smswire/providers.rb +38 -0
  69. data/lib/smswire/quiet_hours.rb +45 -0
  70. data/lib/smswire/rate_limit.rb +27 -0
  71. data/lib/smswire/receipt.rb +11 -0
  72. data/lib/smswire/recipient.rb +38 -0
  73. data/lib/smswire/result.rb +40 -0
  74. data/lib/smswire/rspec.rb +133 -0
  75. data/lib/smswire/segments.rb +81 -0
  76. data/lib/smswire/sender.rb +27 -0
  77. data/lib/smswire/status_handler.rb +25 -0
  78. data/lib/smswire/status_update.rb +8 -0
  79. data/lib/smswire/test_helper.rb +81 -0
  80. data/lib/smswire/testing.rb +72 -0
  81. data/lib/smswire/version.rb +1 -1
  82. data/lib/smswire.rb +70 -6
  83. data/smswire.gemspec +9 -5
  84. metadata +195 -10
  85. data/Gemfile +0 -7
  86. 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