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,29 @@
|
|
|
1
|
+
require "test_helper"
|
|
2
|
+
require "webmock/minitest"
|
|
3
|
+
|
|
4
|
+
class <%= provider_class %>Test < ActiveSupport::TestCase
|
|
5
|
+
include Smswire::TestHelper
|
|
6
|
+
include Smswire::ProviderContract
|
|
7
|
+
|
|
8
|
+
def contract_provider_name = :<%= file_name %>
|
|
9
|
+
|
|
10
|
+
def contract_provider_options = {api_key: "test-key"}
|
|
11
|
+
|
|
12
|
+
# Stub the provider API for each contract outcome. Update the error
|
|
13
|
+
# payloads to the provider's real responses.
|
|
14
|
+
def stub_contract_send(outcome, message)
|
|
15
|
+
stub = stub_request(:post, <%= provider_class %>::API_URL)
|
|
16
|
+
error = ->(status, code) { stub.to_return(status:, body: {error: {code:}}.to_json) }
|
|
17
|
+
case outcome
|
|
18
|
+
when :success
|
|
19
|
+
stub.with(body: hash_including("to" => message.to, "body" => message.body))
|
|
20
|
+
.to_return(status: 200, body: {id: "msg_1"}.to_json)
|
|
21
|
+
when :invalid_number then error.call(400, "invalid_number")
|
|
22
|
+
when :opted_out then error.call(400, "opted_out")
|
|
23
|
+
when :authentication then stub.to_return(status: 401)
|
|
24
|
+
when :throttled then stub.to_return(status: 429)
|
|
25
|
+
when :server_error then stub.to_return(status: 503)
|
|
26
|
+
when :timeout then stub.to_timeout
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
data/lib/smswire/base.rb
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
require "abstract_controller"
|
|
2
|
+
require "action_view"
|
|
3
|
+
|
|
4
|
+
module Smswire
|
|
5
|
+
# Base class for messengers, modeled on ActionMailer::Base.
|
|
6
|
+
#
|
|
7
|
+
# class OrderMessenger < Smswire::Base
|
|
8
|
+
# default from: :transactional
|
|
9
|
+
#
|
|
10
|
+
# def shipped(user)
|
|
11
|
+
# @order = params[:order]
|
|
12
|
+
# text to: user # renders app/views/order_messenger/shipped.text.erb
|
|
13
|
+
# end
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# OrderMessenger.with(order:).shipped(user).deliver_later
|
|
17
|
+
class Base < AbstractController::Base
|
|
18
|
+
include AbstractController::Rendering
|
|
19
|
+
include AbstractController::Logger
|
|
20
|
+
include AbstractController::Helpers
|
|
21
|
+
include AbstractController::Translation
|
|
22
|
+
include AbstractController::Callbacks
|
|
23
|
+
include ActionView::Layouts
|
|
24
|
+
|
|
25
|
+
abstract!
|
|
26
|
+
|
|
27
|
+
TEXT_OPTIONS = %i[category provider messaging_service media_urls metadata idempotency_key validity_period consent_scope].freeze
|
|
28
|
+
DEFAULT_KEYS = (%i[from] + TEXT_OPTIONS - %i[media_urls idempotency_key]).freeze
|
|
29
|
+
|
|
30
|
+
class_attribute :default_params, default: {}.freeze
|
|
31
|
+
|
|
32
|
+
class << self
|
|
33
|
+
# Class-level defaults for every message this messenger builds.
|
|
34
|
+
# Supported keys: from, category, provider, messaging_service,
|
|
35
|
+
# metadata, validity_period.
|
|
36
|
+
def default(value = nil)
|
|
37
|
+
if value
|
|
38
|
+
unknown = value.keys.map(&:to_sym) - DEFAULT_KEYS
|
|
39
|
+
raise ArgumentError, "Unknown default(s): #{unknown.join(", ")}" if unknown.any?
|
|
40
|
+
self.default_params = default_params.merge(value.symbolize_keys).freeze
|
|
41
|
+
end
|
|
42
|
+
default_params
|
|
43
|
+
end
|
|
44
|
+
alias_method :default_options=, :default
|
|
45
|
+
|
|
46
|
+
def with(params)
|
|
47
|
+
ParameterizedMessenger.new(self, params)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def messenger_name
|
|
51
|
+
controller_path
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def respond_to_missing?(name, include_all = false)
|
|
55
|
+
action_methods.include?(name.to_s) || super
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def method_missing(name, *args, **kwargs, &block)
|
|
61
|
+
if action_methods.include?(name.to_s)
|
|
62
|
+
MessageDelivery.new(self, name, args:, kwargs:)
|
|
63
|
+
else
|
|
64
|
+
super
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
attr_writer :params
|
|
70
|
+
|
|
71
|
+
def params
|
|
72
|
+
@params ||= {}
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The message built by the last processed action, or nil when the
|
|
76
|
+
# action returned without calling +text+.
|
|
77
|
+
def message
|
|
78
|
+
@_message if @_message_was_called
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def process(method_name, *args, **kwargs)
|
|
82
|
+
@_message_was_called = false
|
|
83
|
+
@_message = nil
|
|
84
|
+
super
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def messenger_name
|
|
88
|
+
self.class.messenger_name
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Build the message for this action. When +body+ is omitted the body is
|
|
92
|
+
# rendered from app/views/<messenger_name>/<action>.text.erb.
|
|
93
|
+
def text(to:, body: nil, from: nil, **options)
|
|
94
|
+
unknown = options.keys - TEXT_OPTIONS
|
|
95
|
+
raise ArgumentError, "Unknown option(s) for text: #{unknown.join(", ")}" if unknown.any?
|
|
96
|
+
|
|
97
|
+
settings = self.class.default_params.merge(options)
|
|
98
|
+
sender = Sender.resolve(from || settings[:from])
|
|
99
|
+
recipient = Recipient.resolve(to)
|
|
100
|
+
content = body.nil? ? render_to_string(action: action_name, formats: [:text]) : body
|
|
101
|
+
|
|
102
|
+
@_message_was_called = true
|
|
103
|
+
@_message = Message.new(
|
|
104
|
+
to: recipient.phone,
|
|
105
|
+
recipient:,
|
|
106
|
+
from: sender.number,
|
|
107
|
+
messaging_service: settings[:messaging_service] || sender.messaging_service,
|
|
108
|
+
provider: settings[:provider] || sender.provider,
|
|
109
|
+
body: BodyFormatter.call(content),
|
|
110
|
+
media_urls: settings[:media_urls],
|
|
111
|
+
category: settings[:category],
|
|
112
|
+
metadata: (settings[:metadata] || {}).merge(params.fetch(:smswire_metadata, {})),
|
|
113
|
+
validity_period: settings[:validity_period],
|
|
114
|
+
idempotency_key: settings[:idempotency_key],
|
|
115
|
+
consent_scope: settings[:consent_scope] || sender.consent_scope,
|
|
116
|
+
messenger: self.class.name,
|
|
117
|
+
action: action_name
|
|
118
|
+
)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
ActiveSupport.run_load_hooks(:smswire, self)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module Smswire
|
|
2
|
+
module BodyFormatter
|
|
3
|
+
module_function
|
|
4
|
+
|
|
5
|
+
def call(text, mode = Smswire.config.body_whitespace)
|
|
6
|
+
text = text.to_s
|
|
7
|
+
case mode
|
|
8
|
+
when :squish then text.squish
|
|
9
|
+
when :preserve then text
|
|
10
|
+
else text.gsub(/[ \t]+$/, "").gsub(/\n{3,}/, "\n\n").strip
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
module Smswire
|
|
2
|
+
# Builds callback URLs from Smswire.config.callbacks_url and reconstructs
|
|
3
|
+
# the public URL of an incoming callback for signature checks.
|
|
4
|
+
module Callbacks
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def status_url(provider, delivery = nil)
|
|
8
|
+
base = Smswire.config.callbacks_url.presence or return nil
|
|
9
|
+
url = "#{base.chomp("/")}/status/#{provider}"
|
|
10
|
+
delivery ? "#{url}?delivery_id=#{delivery.id}" : url
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# The URL the provider signed. Behind a TLS-terminating proxy the
|
|
14
|
+
# request may look like plain HTTP, so when callbacks_url is configured
|
|
15
|
+
# its scheme, host, and port are used with the request path.
|
|
16
|
+
def request_url(request)
|
|
17
|
+
base = Smswire.config.callbacks_url.presence or return request.original_url
|
|
18
|
+
uri = URI(base)
|
|
19
|
+
origin = "#{uri.scheme}://#{uri.host}"
|
|
20
|
+
origin += ":#{uri.port}" unless uri.port == uri.default_port
|
|
21
|
+
origin + request.original_fullpath
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
module Smswire
|
|
2
|
+
class Configuration
|
|
3
|
+
WHITESPACE_MODES = %i[strip squish preserve].freeze
|
|
4
|
+
CONSENT_RULES = %i[allow_unless_opted_out require_opted_in none].freeze
|
|
5
|
+
|
|
6
|
+
# Shipped category rules. Settings in +categories+ are merged over these
|
|
7
|
+
# per key, so overriding one category leaves the others intact.
|
|
8
|
+
# Marketing quiet hours follow the US federal TCPA window (8am to 9pm
|
|
9
|
+
# recipient time); some states are stricter.
|
|
10
|
+
DEFAULT_CATEGORIES = {
|
|
11
|
+
otp: {consent: :allow_unless_opted_out, store_body: false},
|
|
12
|
+
transactional: {consent: :allow_unless_opted_out},
|
|
13
|
+
marketing: {consent: :require_opted_in, quiet_hours: "21:00".."08:00"},
|
|
14
|
+
compliance: {consent: :none}
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
DEFAULT_KEYWORDS = {
|
|
18
|
+
opt_out: %w[STOP STOPALL UNSUBSCRIBE CANCEL END QUIT OPTOUT REVOKE],
|
|
19
|
+
opt_in: %w[START YES UNSTOP],
|
|
20
|
+
help: %w[HELP INFO]
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
# CTIA guidance: the opt-out confirmation names the brand, confirms the
|
|
24
|
+
# removal, and says no further messages will be sent; the HELP reply
|
|
25
|
+
# names the brand and a support contact.
|
|
26
|
+
DEFAULT_KEYWORD_REPLIES = {
|
|
27
|
+
opt_out: "%{program}: You are unsubscribed and will no longer receive any further messages. Reply START to resubscribe.",
|
|
28
|
+
opt_in: "%{program}: You are subscribed again. Reply HELP for help, STOP to unsubscribe. Msg & data rates may apply.",
|
|
29
|
+
help: "%{program}: For help, contact %{contact}. Reply STOP to unsubscribe. Msg & data rates may apply."
|
|
30
|
+
}.freeze
|
|
31
|
+
HELP_WITHOUT_CONTACT = "%{program}: Reply STOP to unsubscribe. Msg & data rates may apply.".freeze
|
|
32
|
+
PHONE_VALIDATORS = %i[auto phonelib e164].freeze
|
|
33
|
+
|
|
34
|
+
# Provider name used when a message or sender does not name one.
|
|
35
|
+
# The Railtie defaults this to :log in development and :test in test.
|
|
36
|
+
attr_accessor :default_provider
|
|
37
|
+
|
|
38
|
+
# Per-provider options, e.g. { twilio: { account_sid: "...", auth_token: "..." } }.
|
|
39
|
+
attr_accessor :providers
|
|
40
|
+
|
|
41
|
+
# Named senders, e.g. { transactional: { number: "+1...", provider: :twilio } }.
|
|
42
|
+
attr_accessor :senders
|
|
43
|
+
|
|
44
|
+
# Callable that turns a recipient object into a phone number string.
|
|
45
|
+
# When nil, objects responding to +phone_number+ are used.
|
|
46
|
+
attr_accessor :recipient_resolver
|
|
47
|
+
|
|
48
|
+
# ISO 3166 region used to parse numbers written in national format.
|
|
49
|
+
attr_accessor :default_region
|
|
50
|
+
|
|
51
|
+
# :auto uses phonelib when it is loaded, :phonelib requires it, and
|
|
52
|
+
# :e164 uses the built-in validator (E.164 plus NANP national formats).
|
|
53
|
+
attr_reader :phone_validator
|
|
54
|
+
|
|
55
|
+
attr_accessor :default_category
|
|
56
|
+
|
|
57
|
+
# Total attempts for transient errors in Smswire::DeliveryJob,
|
|
58
|
+
# counting the first.
|
|
59
|
+
attr_accessor :retry_attempts
|
|
60
|
+
|
|
61
|
+
attr_accessor :deliver_later_queue
|
|
62
|
+
|
|
63
|
+
# How rendered and literal bodies are cleaned before sending.
|
|
64
|
+
# :strip removes trailing spaces on each line, collapses three or more
|
|
65
|
+
# newlines to two, and strips the ends. :squish collapses all
|
|
66
|
+
# whitespace to single spaces. :preserve leaves the body untouched.
|
|
67
|
+
attr_reader :body_whitespace
|
|
68
|
+
|
|
69
|
+
attr_accessor :interceptors, :observers, :logger
|
|
70
|
+
|
|
71
|
+
# Record every send in smswire_deliveries. Requires the engine migrations.
|
|
72
|
+
attr_accessor :persist_deliveries
|
|
73
|
+
|
|
74
|
+
# Identical messages (same messenger, action, recipient, and body, or the
|
|
75
|
+
# same explicit idempotency_key) inside this window are refused as
|
|
76
|
+
# :duplicate. nil disables deduplication.
|
|
77
|
+
attr_accessor :dedupe_window
|
|
78
|
+
|
|
79
|
+
# Public base URL where Smswire::Engine is mounted, e.g.
|
|
80
|
+
# "https://app.example.com/smswire". When set, providers that support
|
|
81
|
+
# status callbacks are told to post to "<callbacks_url>/status/<provider>".
|
|
82
|
+
attr_accessor :callbacks_url
|
|
83
|
+
|
|
84
|
+
attr_accessor :verify_callback_signatures
|
|
85
|
+
|
|
86
|
+
# Store message bodies on deliveries. Overridden per category with
|
|
87
|
+
# categories: { otp: { store_body: false } }.
|
|
88
|
+
attr_accessor :store_bodies
|
|
89
|
+
|
|
90
|
+
# Per-category settings merged over DEFAULT_CATEGORIES:
|
|
91
|
+
# consent: :allow_unless_opted_out, :require_opted_in, or :none
|
|
92
|
+
# quiet_hours: "21:00".."08:00" in the recipient's time zone, or nil
|
|
93
|
+
# rate_limit: { max: 3, per: 1.day, exceed: :defer } (or :reject)
|
|
94
|
+
# store_body: overrides store_bodies
|
|
95
|
+
attr_accessor :categories
|
|
96
|
+
|
|
97
|
+
# Check consent before sending and record carrier opt-outs. Turn off
|
|
98
|
+
# only when another system, such as Twilio Advanced Opt-Out, owns consent.
|
|
99
|
+
attr_accessor :enforce_consent
|
|
100
|
+
|
|
101
|
+
# Time zone for quiet hours when the recipient has none. Defaults to
|
|
102
|
+
# Time.zone.
|
|
103
|
+
attr_accessor :default_time_zone
|
|
104
|
+
|
|
105
|
+
# Inbound keyword sets: { opt_out: [...], opt_in: [...], help: [...] }.
|
|
106
|
+
attr_accessor :keywords
|
|
107
|
+
|
|
108
|
+
# Auto-reply bodies per keyword kind; %{program} is replaced by
|
|
109
|
+
# program_name. Set a kind to nil to send no reply.
|
|
110
|
+
attr_accessor :keyword_replies
|
|
111
|
+
|
|
112
|
+
attr_accessor :program_name
|
|
113
|
+
|
|
114
|
+
# Phone number, email, or URL for customer care, used in HELP replies
|
|
115
|
+
# as %{contact}. CTIA guidance expects one.
|
|
116
|
+
attr_accessor :support_contact
|
|
117
|
+
|
|
118
|
+
# When true, STOP to any sender opts the number out of every consent
|
|
119
|
+
# scope, as STOPALL does. The FCC rule applying a revocation to all of a
|
|
120
|
+
# sender's unrelated messages is delayed to January 31, 2027.
|
|
121
|
+
attr_accessor :opt_out_all_scopes
|
|
122
|
+
|
|
123
|
+
# Development tools served by the engine. Both default to true in
|
|
124
|
+
# development only. Set them in config/application.rb or an environment
|
|
125
|
+
# file as config.smswire.show_previews, since preview paths are
|
|
126
|
+
# registered for autoloading before initializers run.
|
|
127
|
+
attr_accessor :show_previews, :show_inbox, :preview_paths
|
|
128
|
+
|
|
129
|
+
def initialize
|
|
130
|
+
@default_provider = nil
|
|
131
|
+
@providers = {}
|
|
132
|
+
@senders = {}
|
|
133
|
+
@recipient_resolver = nil
|
|
134
|
+
@default_region = "US"
|
|
135
|
+
@phone_validator = :auto
|
|
136
|
+
@default_category = :transactional
|
|
137
|
+
@retry_attempts = 5
|
|
138
|
+
@deliver_later_queue = :default
|
|
139
|
+
@body_whitespace = :strip
|
|
140
|
+
@interceptors = []
|
|
141
|
+
@observers = []
|
|
142
|
+
@logger = nil
|
|
143
|
+
@persist_deliveries = true
|
|
144
|
+
@dedupe_window = 10.minutes
|
|
145
|
+
@callbacks_url = nil
|
|
146
|
+
@verify_callback_signatures = true
|
|
147
|
+
@store_bodies = true
|
|
148
|
+
@categories = {}
|
|
149
|
+
@enforce_consent = true
|
|
150
|
+
@default_time_zone = nil
|
|
151
|
+
@keywords = DEFAULT_KEYWORDS.transform_values(&:dup)
|
|
152
|
+
@keyword_replies = DEFAULT_KEYWORD_REPLIES.dup
|
|
153
|
+
@program_name = nil
|
|
154
|
+
@support_contact = nil
|
|
155
|
+
@opt_out_all_scopes = false
|
|
156
|
+
@show_previews = false
|
|
157
|
+
@show_inbox = false
|
|
158
|
+
@preview_paths = []
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def phone_validator=(value)
|
|
162
|
+
value = value.to_sym
|
|
163
|
+
unless PHONE_VALIDATORS.include?(value)
|
|
164
|
+
raise ConfigurationError, "phone_validator must be one of #{PHONE_VALIDATORS.join(", ")}"
|
|
165
|
+
end
|
|
166
|
+
@phone_validator = value
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def body_whitespace=(value)
|
|
170
|
+
value = value.to_sym
|
|
171
|
+
unless WHITESPACE_MODES.include?(value)
|
|
172
|
+
raise ConfigurationError, "body_whitespace must be one of #{WHITESPACE_MODES.join(", ")}"
|
|
173
|
+
end
|
|
174
|
+
@body_whitespace = value
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def category_options(category)
|
|
178
|
+
key = category.to_s.to_sym
|
|
179
|
+
custom = (categories[key] || categories[category.to_s] || {}).to_h.symbolize_keys
|
|
180
|
+
options = DEFAULT_CATEGORIES.fetch(key, {}).merge(custom)
|
|
181
|
+
consent = options.fetch(:consent, :allow_unless_opted_out).to_sym
|
|
182
|
+
unless CONSENT_RULES.include?(consent)
|
|
183
|
+
raise ConfigurationError, "Unknown consent rule #{consent.inspect} for category #{key}"
|
|
184
|
+
end
|
|
185
|
+
options.merge(consent:)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def time_zone
|
|
189
|
+
zone = default_time_zone || Time.zone || "UTC"
|
|
190
|
+
ActiveSupport::TimeZone[zone] || raise(ConfigurationError, "Unknown time zone #{zone.inspect}")
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def consent_scopes
|
|
194
|
+
scopes = senders.values.filter_map { |options| options.to_h.symbolize_keys[:consent_scope]&.to_s }
|
|
195
|
+
(["default"] + scopes).uniq
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def keyword_reply(kind)
|
|
199
|
+
template = keyword_replies[kind.to_sym] or return nil
|
|
200
|
+
if template.include?("%{contact}") && support_contact.blank?
|
|
201
|
+
Smswire.logger.warn("[Smswire] Set config.support_contact; HELP replies should include a support contact.")
|
|
202
|
+
template = HELP_WITHOUT_CONTACT if template == DEFAULT_KEYWORD_REPLIES[:help]
|
|
203
|
+
end
|
|
204
|
+
format(template, program: program_name || default_program_name, contact: support_contact.to_s)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def store_body?(category)
|
|
208
|
+
setting = category_options(category)[:store_body]
|
|
209
|
+
setting.nil? ? store_bodies : setting
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def default_program_name
|
|
213
|
+
if defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
|
|
214
|
+
::Rails.application.class.module_parent_name.titleize
|
|
215
|
+
else
|
|
216
|
+
"our"
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def provider_options(name)
|
|
221
|
+
(providers[name.to_sym] || providers[name.to_s] || {}).to_h.symbolize_keys
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module Smswire
|
|
2
|
+
# Delivers a messenger action in the background. Transient errors are
|
|
3
|
+
# retried with polynomial backoff up to Smswire.config.retry_attempts
|
|
4
|
+
# total attempts. Throttled errors wait for the provider's Retry-After.
|
|
5
|
+
# Permanent errors are recorded as :failed results and not retried.
|
|
6
|
+
class DeliveryJob < ActiveJob::Base
|
|
7
|
+
queue_as { Smswire.config.deliver_later_queue }
|
|
8
|
+
|
|
9
|
+
rescue_from TransientError do |error|
|
|
10
|
+
raise error if executions >= Smswire.config.retry_attempts
|
|
11
|
+
|
|
12
|
+
retry_job(wait: retry_wait(error))
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def perform(messenger_name, action, params, args, kwargs)
|
|
16
|
+
MessageDelivery.new(messenger_name.constantize, action, args:, kwargs:, params:).deliver_now
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def retry_wait(error)
|
|
22
|
+
if error.is_a?(ThrottledError) && error.retry_after
|
|
23
|
+
error.retry_after.seconds
|
|
24
|
+
else
|
|
25
|
+
((executions**4) + 2).seconds
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
require "rails/engine"
|
|
2
|
+
require "abstract_controller/railties/routes_helpers"
|
|
3
|
+
|
|
4
|
+
module Smswire
|
|
5
|
+
# Mount for status callbacks:
|
|
6
|
+
#
|
|
7
|
+
# mount Smswire::Engine => "/smswire"
|
|
8
|
+
#
|
|
9
|
+
# Install the migrations with +bin/rails smswire:install:migrations+.
|
|
10
|
+
class Engine < ::Rails::Engine
|
|
11
|
+
isolate_namespace Smswire
|
|
12
|
+
|
|
13
|
+
ENVIRONMENT_PROVIDERS = {development: :log, test: :test}.freeze
|
|
14
|
+
|
|
15
|
+
config.smswire = ActiveSupport::OrderedOptions.new
|
|
16
|
+
|
|
17
|
+
# Like Action Mailer: register preview directories for autoloading
|
|
18
|
+
# before Rails builds its autoload paths. Declared first because Rails
|
|
19
|
+
# chains an engine's initializers in declaration order.
|
|
20
|
+
initializer "smswire.preview_paths", before: :set_autoload_paths do |app|
|
|
21
|
+
options = app.config.smswire
|
|
22
|
+
development = ::Rails.env.development?
|
|
23
|
+
options.show_previews = development if options.show_previews.nil?
|
|
24
|
+
options.show_inbox = development if options.show_inbox.nil?
|
|
25
|
+
options.preview_paths ||= %w[test/messengers/previews spec/messengers/previews].map { |path| "#{app.root}/#{path}" }
|
|
26
|
+
app.config.paths.add "smswire/previews", with: options.preview_paths, autoload: true
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Runs after config/initializers so app settings win over defaults.
|
|
30
|
+
initializer "smswire.configure", after: :load_config_initializers do |app|
|
|
31
|
+
app.config.smswire.each { |key, value| Smswire.config.public_send(:"#{key}=", value) }
|
|
32
|
+
Smswire.config.default_provider ||= ENVIRONMENT_PROVIDERS[::Rails.env.to_sym]
|
|
33
|
+
Smswire.config.logger ||= ::Rails.logger
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
config.after_initialize do
|
|
37
|
+
require_relative "noticed_delivery_method" if defined?(::Noticed::DeliveryMethod)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
initializer "smswire.log_subscriber" do
|
|
41
|
+
Smswire::LogSubscriber.attach_to :smswire
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
initializer "smswire.messenger" do |app|
|
|
45
|
+
ActiveSupport.on_load(:smswire) do
|
|
46
|
+
self.view_paths = ["#{::Rails.root}/app/views"]
|
|
47
|
+
extend ::AbstractController::Railties::RoutesHelpers.with(app.routes, false)
|
|
48
|
+
include app.routes.mounted_helpers
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
module Smswire
|
|
2
|
+
class Error < StandardError; end
|
|
3
|
+
|
|
4
|
+
# Raised for missing or invalid configuration: no provider, unknown sender,
|
|
5
|
+
# missing credentials. Never retried.
|
|
6
|
+
class ConfigurationError < Error; end
|
|
7
|
+
|
|
8
|
+
# Raised when +text to:+ is given something that cannot produce a phone
|
|
9
|
+
# number. Raised while building the message so it surfaces in tests.
|
|
10
|
+
class UnresolvableRecipient < Error; end
|
|
11
|
+
|
|
12
|
+
# Raised when a provider callback fails signature verification.
|
|
13
|
+
class SignatureError < Error; end
|
|
14
|
+
|
|
15
|
+
# Base class for errors raised by provider adapters.
|
|
16
|
+
class DeliveryError < Error
|
|
17
|
+
attr_reader :provider, :provider_code, :http_status, :raw
|
|
18
|
+
|
|
19
|
+
def initialize(message = nil, provider: nil, provider_code: nil, http_status: nil, raw: nil)
|
|
20
|
+
super(message)
|
|
21
|
+
@provider = provider
|
|
22
|
+
@provider_code = provider_code
|
|
23
|
+
@http_status = http_status
|
|
24
|
+
@raw = raw
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Network failures, timeouts, and provider 5xx responses. Retried by
|
|
29
|
+
# Smswire::DeliveryJob and re-raised from +deliver_now+.
|
|
30
|
+
class TransientError < DeliveryError; end
|
|
31
|
+
|
|
32
|
+
# Provider rate limiting. Retried after +retry_after+ seconds when known.
|
|
33
|
+
class ThrottledError < TransientError
|
|
34
|
+
attr_reader :retry_after
|
|
35
|
+
|
|
36
|
+
def initialize(message = nil, retry_after: nil, **options)
|
|
37
|
+
super(message, **options)
|
|
38
|
+
@retry_after = retry_after
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The provider refused the message and retrying will not help. The
|
|
43
|
+
# pipeline turns this into a +:failed+ result instead of raising.
|
|
44
|
+
#
|
|
45
|
+
# +reason+ is one of :invalid_number, :opted_out, :unroutable,
|
|
46
|
+
# :authentication, or :rejected.
|
|
47
|
+
class PermanentError < DeliveryError
|
|
48
|
+
attr_reader :reason
|
|
49
|
+
|
|
50
|
+
def initialize(message = nil, reason: :rejected, **options)
|
|
51
|
+
super(message, **options)
|
|
52
|
+
@reason = reason
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def opted_out?
|
|
56
|
+
reason == :opted_out
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
data/lib/smswire/http.rb
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "openssl"
|
|
3
|
+
require "uri"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Smswire
|
|
7
|
+
# Minimal HTTP client shared by provider adapters. Network failures are
|
|
8
|
+
# raised as Smswire::TransientError. Bodies are never logged.
|
|
9
|
+
module HTTP
|
|
10
|
+
NETWORK_ERRORS = [
|
|
11
|
+
Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout,
|
|
12
|
+
Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH, Errno::ETIMEDOUT,
|
|
13
|
+
EOFError, IOError, SocketError, OpenSSL::SSL::SSLError
|
|
14
|
+
].freeze
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def post_form(url, pairs, basic_auth: nil, headers: {}, open_timeout: 5, read_timeout: 15, provider: nil)
|
|
19
|
+
uri = URI(url)
|
|
20
|
+
request = Net::HTTP::Post.new(uri, {"User-Agent" => "smswire/#{Smswire::VERSION}"}.merge(headers))
|
|
21
|
+
request.basic_auth(*basic_auth) if basic_auth
|
|
22
|
+
request.content_type = "application/x-www-form-urlencoded"
|
|
23
|
+
request.body = URI.encode_www_form(pairs)
|
|
24
|
+
|
|
25
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout:, read_timeout:) do |http|
|
|
26
|
+
http.request(request)
|
|
27
|
+
end
|
|
28
|
+
rescue *NETWORK_ERRORS => error
|
|
29
|
+
raise TransientError.new("#{error.class}: #{error.message}", provider:)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def post_json(url, payload, headers: {}, open_timeout: 5, read_timeout: 15, provider: nil)
|
|
33
|
+
uri = URI(url)
|
|
34
|
+
request = Net::HTTP::Post.new(uri, {"User-Agent" => "smswire/#{Smswire::VERSION}",
|
|
35
|
+
"Accept" => "application/json"}.merge(headers))
|
|
36
|
+
request.content_type = "application/json"
|
|
37
|
+
request.body = JSON.generate(payload)
|
|
38
|
+
|
|
39
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout:, read_timeout:) do |http|
|
|
40
|
+
http.request(request)
|
|
41
|
+
end
|
|
42
|
+
rescue *NETWORK_ERRORS => error
|
|
43
|
+
raise TransientError.new("#{error.class}: #{error.message}", provider:)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def get(url, basic_auth: nil, headers: {}, open_timeout: 5, read_timeout: 15, provider: nil)
|
|
47
|
+
uri = URI(url)
|
|
48
|
+
request = Net::HTTP::Get.new(uri, {"User-Agent" => "smswire/#{Smswire::VERSION}",
|
|
49
|
+
"Accept" => "application/json"}.merge(headers))
|
|
50
|
+
request.basic_auth(*basic_auth) if basic_auth
|
|
51
|
+
|
|
52
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout:, read_timeout:) do |http|
|
|
53
|
+
http.request(request)
|
|
54
|
+
end
|
|
55
|
+
rescue *NETWORK_ERRORS => error
|
|
56
|
+
raise TransientError.new("#{error.class}: #{error.message}", provider:)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def parse_json(body)
|
|
60
|
+
JSON.parse(body.to_s)
|
|
61
|
+
rescue JSON::ParserError
|
|
62
|
+
{}
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
module Smswire
|
|
2
|
+
# A received message as parsed by a provider adapter.
|
|
3
|
+
#
|
|
4
|
+
# +provider_keyword+ is true when the provider already handled an opt-out
|
|
5
|
+
# keyword and replied itself, as Twilio Advanced Opt-Out does.
|
|
6
|
+
Inbound = Data.define(:provider_id, :from, :to, :body, :messaging_service, :provider_keyword, :raw) do
|
|
7
|
+
def initialize(provider_id:, from:, to:, body:, messaging_service: nil, provider_keyword: false, raw: {})
|
|
8
|
+
super
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module Smswire
|
|
2
|
+
# Records an inbound message once, handles compliance keywords, and
|
|
3
|
+
# notifies observers. Shared by the webhook endpoint and the development
|
|
4
|
+
# inbox.
|
|
5
|
+
module InboundHandler
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
# Returns the new Smswire::InboundMessage, or nil for a repeated webhook.
|
|
9
|
+
def call(inbound, provider:)
|
|
10
|
+
payload = {provider:, provider_id: inbound.provider_id, from: inbound.from}
|
|
11
|
+
ActiveSupport::Notifications.instrument("inbound.smswire", payload) do
|
|
12
|
+
record = InboundMessage.receive(inbound, provider:)
|
|
13
|
+
payload[:duplicate] = record.nil?
|
|
14
|
+
if record
|
|
15
|
+
payload[:keyword] = Keywords.handle(record, inbound, provider:) if Smswire.config.enforce_consent
|
|
16
|
+
notify_observers(record)
|
|
17
|
+
end
|
|
18
|
+
record
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def notify_observers(record)
|
|
23
|
+
Smswire.config.observers.each do |observer|
|
|
24
|
+
observer = observer.is_a?(String) ? observer.constantize : observer
|
|
25
|
+
observer.sms_received(record) if observer.respond_to?(:sms_received)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|