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,158 @@
|
|
|
1
|
+
require "rack/mock"
|
|
2
|
+
require "action_dispatch"
|
|
3
|
+
|
|
4
|
+
module Smswire
|
|
5
|
+
# Minitest contract every provider adapter must pass. Include it in a
|
|
6
|
+
# test case that also has Smswire::TestHelper and WebMock, and define the
|
|
7
|
+
# hooks below. Tests for optional capabilities run only when the adapter
|
|
8
|
+
# declares them.
|
|
9
|
+
#
|
|
10
|
+
# class AcmeProviderContractTest < ActiveSupport::TestCase
|
|
11
|
+
# include Smswire::TestHelper
|
|
12
|
+
# include Smswire::ProviderContract
|
|
13
|
+
#
|
|
14
|
+
# def contract_provider_name = :acme
|
|
15
|
+
# def contract_provider_options = {api_key: "test"}
|
|
16
|
+
# def stub_contract_send(outcome, message) = ... # WebMock stub; see OUTCOMES
|
|
17
|
+
# def contract_status_request(provider_id:, status:, signed: true) = ... # with :status_callbacks
|
|
18
|
+
# def contract_inbound_request(from:, to:, body:, signed: true) = ... # with :inbound
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# +stub_contract_send+ must stub the provider API for +outcome+ and return
|
|
22
|
+
# the WebMock stub. For :success it should also match the message's
|
|
23
|
+
# recipient and body. +status+ is :delivered or :undelivered. Define
|
|
24
|
+
# +contract_reports_opt_outs?+ as false when the provider cannot report an
|
|
25
|
+
# opt-out when sending. Request
|
|
26
|
+
# hooks return an ActionDispatch::Request, built with +contract_request+.
|
|
27
|
+
module ProviderContract
|
|
28
|
+
OUTCOMES = %i[success invalid_number opted_out authentication throttled server_error timeout].freeze
|
|
29
|
+
STATUSES = %i[queued accepted sent delivered undelivered failed read].freeze
|
|
30
|
+
CAPABILITIES = %i[mms status_callbacks inbound messaging_services scheduling].freeze
|
|
31
|
+
|
|
32
|
+
# Return false when the provider has no send-time error that
|
|
33
|
+
# specifically means the recipient opted out. The suite then skips the
|
|
34
|
+
# :opted_out outcome rather than accept a guess.
|
|
35
|
+
def contract_reports_opt_outs? = true
|
|
36
|
+
|
|
37
|
+
# The message the suite sends. Override to use one of your messengers.
|
|
38
|
+
def contract_message
|
|
39
|
+
Message.new(to: "+14155552671", from: "+15005550006", body: "Smswire contract check", messenger: "ContractMessenger",
|
|
40
|
+
action: "check")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def contract_provider
|
|
44
|
+
Providers.lookup(contract_provider_name).new(contract_provider_options, name: contract_provider_name)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def contract_request(url, body: nil, params: nil, headers: {})
|
|
48
|
+
env = Rack::MockRequest.env_for(url, :method => "POST", :input => body || Rack::Utils.build_query(params || {}),
|
|
49
|
+
"CONTENT_TYPE" => body ? "application/json" : "application/x-www-form-urlencoded")
|
|
50
|
+
headers.each { |key, value| env["HTTP_#{key.upcase.tr("-", "_")}"] = value }
|
|
51
|
+
ActionDispatch::Request.new(env)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def test_contract_declares_known_capabilities
|
|
55
|
+
assert_empty contract_provider.capabilities.to_a - CAPABILITIES
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def test_contract_delivers_and_returns_a_receipt
|
|
59
|
+
message = contract_message
|
|
60
|
+
stub = stub_contract_send(:success, message)
|
|
61
|
+
receipt = contract_provider.deliver(message)
|
|
62
|
+
|
|
63
|
+
assert_requested stub
|
|
64
|
+
assert_equal contract_provider_name, receipt.provider
|
|
65
|
+
assert receipt.provider_id.present?, "receipt needs the provider message id"
|
|
66
|
+
assert_includes STATUSES, receipt.status
|
|
67
|
+
assert(receipt.segments.nil? || receipt.segments.positive?)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def test_contract_delivers_through_the_pipeline
|
|
71
|
+
message = contract_message
|
|
72
|
+
stub_contract_send(:success, message)
|
|
73
|
+
providers = Smswire.config.providers.merge(contract_provider_name => contract_provider_options)
|
|
74
|
+
result = contract_with_config(default_provider: contract_provider_name, providers:) do
|
|
75
|
+
message.provider = contract_provider_name
|
|
76
|
+
Pipeline.call(message)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
assert result.accepted?, "expected accepted, got #{result.status} #{result.error&.message}"
|
|
80
|
+
if Smswire.config.persist_deliveries
|
|
81
|
+
assert_equal result.provider_id, result.delivery.reload.provider_id
|
|
82
|
+
assert_equal contract_provider_name.to_s, result.delivery.provider
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def test_contract_maps_permanent_errors_to_reasons
|
|
87
|
+
expected = {invalid_number: :invalid_number, opted_out: :opted_out, authentication: :authentication}
|
|
88
|
+
expected.delete(:opted_out) unless contract_reports_opt_outs?
|
|
89
|
+
expected.each do |outcome, reason|
|
|
90
|
+
message = contract_message
|
|
91
|
+
stub_contract_send(outcome, message)
|
|
92
|
+
error = assert_raises(PermanentError, "#{outcome} should be permanent") { contract_provider.deliver(message) }
|
|
93
|
+
assert_equal reason, error.reason, "#{outcome} should map to #{reason}"
|
|
94
|
+
assert_equal contract_provider_name, error.provider
|
|
95
|
+
WebMock.reset!
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def test_contract_raises_retryable_errors
|
|
100
|
+
{throttled: ThrottledError, server_error: TransientError, timeout: TransientError}.each do |outcome, klass|
|
|
101
|
+
message = contract_message
|
|
102
|
+
stub_contract_send(outcome, message)
|
|
103
|
+
assert_raises(klass, "#{outcome} should raise #{klass}") { contract_provider.deliver(message) }
|
|
104
|
+
WebMock.reset!
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def test_contract_parses_and_verifies_status_callbacks
|
|
109
|
+
skip "no :status_callbacks capability" unless contract_provider.capabilities.include?(:status_callbacks)
|
|
110
|
+
|
|
111
|
+
%i[delivered undelivered].each do |status|
|
|
112
|
+
request = contract_status_request(provider_id: "contract-#{status}", status:)
|
|
113
|
+
contract_provider.verify_signature!(request, url: request.original_url)
|
|
114
|
+
update = contract_provider.parse_status_callback(request)
|
|
115
|
+
assert_equal "contract-#{status}", update.provider_id
|
|
116
|
+
assert_equal status, update.status
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
forged = contract_status_request(provider_id: "contract-forged", status: :delivered, signed: false)
|
|
120
|
+
assert_raises(SignatureError) { contract_provider.verify_signature!(forged, url: forged.original_url) }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def test_contract_parses_and_verifies_inbound_messages
|
|
124
|
+
skip "no :inbound capability" unless contract_provider.capabilities.include?(:inbound)
|
|
125
|
+
|
|
126
|
+
request = contract_inbound_request(from: "+14155552671", to: "+15005550006", body: "STOP")
|
|
127
|
+
contract_provider.verify_signature!(request, url: request.original_url)
|
|
128
|
+
inbound = contract_provider.parse_inbound(request)
|
|
129
|
+
assert inbound.provider_id.present?
|
|
130
|
+
assert_equal ["+14155552671", "+15005550006", "STOP"], [inbound.from, inbound.to, inbound.body]
|
|
131
|
+
if contract_provider.capabilities.include?(:status_callbacks)
|
|
132
|
+
assert_nil contract_provider.parse_status_callback(request), "inbound messages must not parse as status updates"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
forged = contract_inbound_request(from: "+14155552671", to: "+15005550006", body: "STOP", signed: false)
|
|
136
|
+
assert_raises(SignatureError) { contract_provider.verify_signature!(forged, url: forged.original_url) }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def test_contract_status_callbacks_are_not_inbound_messages
|
|
140
|
+
capabilities = contract_provider.capabilities
|
|
141
|
+
skip "needs both :status_callbacks and :inbound" unless capabilities.superset?(Set[:status_callbacks, :inbound])
|
|
142
|
+
|
|
143
|
+
request = contract_status_request(provider_id: "contract-status", status: :delivered)
|
|
144
|
+
assert_nil contract_provider.parse_inbound(request)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
private
|
|
148
|
+
|
|
149
|
+
def contract_with_config(**overrides)
|
|
150
|
+
config = Smswire.config
|
|
151
|
+
previous = overrides.keys.to_h { |key| [key, config.public_send(key)] }
|
|
152
|
+
overrides.each { |key, value| config.public_send(:"#{key}=", value) }
|
|
153
|
+
yield
|
|
154
|
+
ensure
|
|
155
|
+
previous&.each { |key, value| config.public_send(:"#{key}=", value) }
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
module Smswire
|
|
2
|
+
module Providers
|
|
3
|
+
# Adapter contract. Subclasses implement +deliver(message)+ and return a
|
|
4
|
+
# Smswire::Receipt, raising Smswire::TransientError,
|
|
5
|
+
# Smswire::ThrottledError, or Smswire::PermanentError on failure.
|
|
6
|
+
class Base
|
|
7
|
+
attr_reader :options, :name
|
|
8
|
+
|
|
9
|
+
def initialize(options = {}, name: nil)
|
|
10
|
+
@options = options.to_h.symbolize_keys
|
|
11
|
+
@name = name || self.class.name.demodulize.underscore.to_sym
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def deliver(message)
|
|
15
|
+
raise NotImplementedError, "#{self.class.name} must implement #deliver"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def capabilities
|
|
19
|
+
Set.new
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Make one authenticated, read-only call to the provider. Returns true,
|
|
23
|
+
# raises ConfigurationError when credentials are rejected or missing,
|
|
24
|
+
# or TransientError when the provider cannot be reached. Returns nil
|
|
25
|
+
# for adapters that cannot check.
|
|
26
|
+
def verify_credentials!
|
|
27
|
+
nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Raise Smswire::SignatureError unless +request+ was signed by the
|
|
31
|
+
# provider. +url+ is the public URL the provider posted to.
|
|
32
|
+
def verify_signature!(request, url:)
|
|
33
|
+
raise NotImplementedError, "#{self.class.name} does not support callbacks"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Return a Smswire::Inbound for an inbound message webhook.
|
|
37
|
+
def parse_inbound(request)
|
|
38
|
+
raise NotImplementedError, "#{self.class.name} does not support inbound messages"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# [body, content_type] to answer an inbound webhook with, or nil for 204.
|
|
42
|
+
def inbound_response
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Return a Smswire::StatusUpdate for a status callback request.
|
|
47
|
+
def parse_status_callback(request)
|
|
48
|
+
raise NotImplementedError, "#{self.class.name} does not support status callbacks"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def check_credentials_response(response)
|
|
54
|
+
status = response.code.to_i
|
|
55
|
+
return true if status.between?(200, 299)
|
|
56
|
+
raise TransientError.new("#{name} returned #{status}", provider: name, http_status: status) if status >= 500
|
|
57
|
+
|
|
58
|
+
raise ConfigurationError, "#{name} rejected the configured credentials (HTTP #{status})"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def receipt(**attributes)
|
|
62
|
+
Receipt.new(provider: name, **attributes)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
require "securerandom"
|
|
2
|
+
|
|
3
|
+
module Smswire
|
|
4
|
+
module Providers
|
|
5
|
+
# Writes messages, including the body, to Smswire.logger instead of
|
|
6
|
+
# sending them. The default provider in development.
|
|
7
|
+
class Log < Base
|
|
8
|
+
def verify_credentials! = true
|
|
9
|
+
|
|
10
|
+
def deliver(message)
|
|
11
|
+
analysis = message.analysis
|
|
12
|
+
sender = message.from || message.messaging_service || "(none)"
|
|
13
|
+
lines = ["[Smswire] SMS to #{message.to} from #{sender} via #{message.messenger}##{message.action}",
|
|
14
|
+
"[Smswire] #{analysis.segments} segment(s), #{analysis.encoding_label}, #{analysis.units} units"]
|
|
15
|
+
lines << "[Smswire] media: #{message.media_urls.join(", ")}" if message.media_urls.any?
|
|
16
|
+
lines << message.body.to_s
|
|
17
|
+
Smswire.logger.info(lines.join("\n"))
|
|
18
|
+
|
|
19
|
+
receipt(provider_id: "log-#{SecureRandom.hex(8)}", status: :accepted, segments: analysis.segments)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
require "base64"
|
|
2
|
+
require "bigdecimal"
|
|
3
|
+
require "json"
|
|
4
|
+
require "openssl"
|
|
5
|
+
|
|
6
|
+
module Smswire
|
|
7
|
+
module Providers
|
|
8
|
+
# Telnyx Messaging API v2 over REST. No SDK dependency.
|
|
9
|
+
#
|
|
10
|
+
# Smswire.config.providers[:telnyx] = {
|
|
11
|
+
# api_key: "KEY...",
|
|
12
|
+
# public_key: "base64 key from Mission Control > Keys & Credentials",
|
|
13
|
+
# messaging_profile_id: "..." # optional default; senders may set messaging_service
|
|
14
|
+
# }
|
|
15
|
+
#
|
|
16
|
+
# A sender's +messaging_service+ is sent as +messaging_profile_id+.
|
|
17
|
+
# Credentials fall back to Rails.application.credentials.telnyx.
|
|
18
|
+
# Telnyx does not accept a validity period here, so it is ignored.
|
|
19
|
+
class Telnyx < Base
|
|
20
|
+
API_URL = "https://api.telnyx.com/v2/messages".freeze
|
|
21
|
+
SIGNATURE_TOLERANCE = 300
|
|
22
|
+
ED25519_SPKI_PREFIX = ["302a300506032b6570032100"].pack("H*").freeze
|
|
23
|
+
|
|
24
|
+
PERMANENT_REASONS = {
|
|
25
|
+
"40300" => :opted_out, # Blocked: recipient replied STOP
|
|
26
|
+
"40310" => :invalid_number, # Invalid 'to' address
|
|
27
|
+
"40012" => :invalid_number, # Invalid destination number
|
|
28
|
+
"40001" => :unroutable, # Not routable (landline or non-SMS)
|
|
29
|
+
"40305" => :rejected, # Invalid 'from' address
|
|
30
|
+
"40013" => :rejected, # Invalid source number
|
|
31
|
+
"40311" => :authentication, # Invalid profile secret
|
|
32
|
+
"40313" => :authentication # Missing profile secret
|
|
33
|
+
}.freeze
|
|
34
|
+
TRANSIENT_CODES = %w[40006 40008].freeze # Recipient unavailable, undeliverable
|
|
35
|
+
THROTTLED_CODES = %w[40002 40011 40016 40018].freeze # Spam throttle and carrier throughput limits
|
|
36
|
+
|
|
37
|
+
STATUSES = {
|
|
38
|
+
"queued" => :queued, "sending" => :accepted, "sent" => :sent, "delivered" => :delivered,
|
|
39
|
+
"delivery_unconfirmed" => :sent, "sending_failed" => :failed, "delivery_failed" => :undelivered,
|
|
40
|
+
"expired" => :undelivered
|
|
41
|
+
}.freeze
|
|
42
|
+
|
|
43
|
+
def deliver(message)
|
|
44
|
+
response = HTTP.post_json(API_URL, payload_for(message),
|
|
45
|
+
headers: {"Authorization" => "Bearer #{credential(:api_key)}"}, provider: name)
|
|
46
|
+
handle(response)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
BALANCE_URL = "https://api.telnyx.com/v2/balance".freeze
|
|
50
|
+
|
|
51
|
+
def capabilities
|
|
52
|
+
Set[:mms, :status_callbacks, :inbound, :messaging_services]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def verify_credentials!
|
|
56
|
+
check_credentials_response(HTTP.get(BALANCE_URL, headers: {"Authorization" => "Bearer #{credential(:api_key)}"}, provider: name))
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def payload_for(message)
|
|
60
|
+
profile = message.messaging_service || options[:messaging_profile_id]
|
|
61
|
+
if message.from.blank? && profile.blank?
|
|
62
|
+
raise ConfigurationError, "Telnyx requires a from number or messaging_profile_id"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
{
|
|
66
|
+
to: message.to,
|
|
67
|
+
from: message.from.presence,
|
|
68
|
+
messaging_profile_id: profile.presence,
|
|
69
|
+
text: message.body.presence,
|
|
70
|
+
media_urls: message.media_urls.presence,
|
|
71
|
+
webhook_url: message.status_callback_url || options[:webhook_url]
|
|
72
|
+
}.compact
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Ed25519 over "<telnyx-timestamp>|<raw body>", checked against the
|
|
76
|
+
# account public key, rejecting timestamps older than five minutes.
|
|
77
|
+
def verify_signature!(request, url: nil)
|
|
78
|
+
signature = request.headers["telnyx-signature-ed25519"].to_s
|
|
79
|
+
timestamp = request.headers["telnyx-timestamp"].to_s
|
|
80
|
+
raise SignatureError, "Missing Telnyx signature headers" if signature.empty? || timestamp.empty?
|
|
81
|
+
raise SignatureError, "Stale Telnyx timestamp" if (Time.now.to_i - timestamp.to_i).abs > SIGNATURE_TOLERANCE
|
|
82
|
+
|
|
83
|
+
verified = public_key.verify(nil, Base64.decode64(signature), "#{timestamp}|#{request.raw_post}")
|
|
84
|
+
raise SignatureError, "Invalid Telnyx signature" unless verified
|
|
85
|
+
rescue OpenSSL::PKey::PKeyError, ArgumentError
|
|
86
|
+
raise SignatureError, "Invalid Telnyx signature"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def parse_status_callback(request)
|
|
90
|
+
event_type, payload = event(request)
|
|
91
|
+
return nil unless %w[message.sent message.finalized].include?(event_type)
|
|
92
|
+
|
|
93
|
+
recipient = Array(payload["to"]).first || {}
|
|
94
|
+
error = Array(payload["errors"]).first || {}
|
|
95
|
+
StatusUpdate.new(
|
|
96
|
+
provider_id: payload["id"],
|
|
97
|
+
status: STATUSES.fetch(recipient["status"].to_s, :accepted),
|
|
98
|
+
error_code: error["code"]&.to_s,
|
|
99
|
+
error_message: error["title"] || error["detail"],
|
|
100
|
+
raw: payload
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def parse_inbound(request)
|
|
105
|
+
event_type, payload = event(request)
|
|
106
|
+
return nil unless event_type == "message.received"
|
|
107
|
+
|
|
108
|
+
Inbound.new(
|
|
109
|
+
provider_id: payload["id"],
|
|
110
|
+
from: payload.dig("from", "phone_number"),
|
|
111
|
+
to: Array(payload["to"]).first&.dig("phone_number"),
|
|
112
|
+
body: payload["text"].to_s,
|
|
113
|
+
messaging_service: payload["messaging_profile_id"],
|
|
114
|
+
raw: payload
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
def handle(response)
|
|
121
|
+
status = response.code.to_i
|
|
122
|
+
json = HTTP.parse_json(response.body)
|
|
123
|
+
return success(json["data"] || {}) if status.between?(200, 299)
|
|
124
|
+
|
|
125
|
+
error = Array(json["errors"]).first || {}
|
|
126
|
+
code = error["code"]&.to_s
|
|
127
|
+
detail = "Telnyx #{status}#{" (#{code})" if code}: #{error["title"] || error["detail"] || response.message}"
|
|
128
|
+
error_options = {provider: name, provider_code: code, http_status: status, raw: json}
|
|
129
|
+
|
|
130
|
+
if status == 429 || THROTTLED_CODES.include?(code)
|
|
131
|
+
retry_after = response["Retry-After"]&.to_i
|
|
132
|
+
raise ThrottledError.new(detail, retry_after: retry_after&.positive? ? retry_after : nil, **error_options)
|
|
133
|
+
elsif status >= 500 || TRANSIENT_CODES.include?(code)
|
|
134
|
+
raise TransientError.new(detail, **error_options)
|
|
135
|
+
elsif status == 401 || (status == 403 && code.nil?)
|
|
136
|
+
raise PermanentError.new(detail, reason: :authentication, **error_options)
|
|
137
|
+
else
|
|
138
|
+
raise PermanentError.new(detail, reason: PERMANENT_REASONS.fetch(code, :rejected), **error_options)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def success(data)
|
|
143
|
+
recipient = Array(data["to"]).first || {}
|
|
144
|
+
cost = data["cost"] || {}
|
|
145
|
+
receipt(
|
|
146
|
+
provider_id: data["id"],
|
|
147
|
+
status: STATUSES.fetch(recipient["status"].to_s, :queued),
|
|
148
|
+
segments: data["parts"]&.to_i,
|
|
149
|
+
price_amount: (BigDecimal(cost["amount"].to_s) if cost["amount"].present?),
|
|
150
|
+
price_currency: cost["currency"],
|
|
151
|
+
raw: data
|
|
152
|
+
)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def event(request)
|
|
156
|
+
data = JSON.parse(request.raw_post.to_s)["data"] || {}
|
|
157
|
+
[data["event_type"], data["payload"] || {}]
|
|
158
|
+
rescue JSON::ParserError
|
|
159
|
+
[nil, {}]
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def public_key
|
|
163
|
+
@public_key ||= OpenSSL::PKey.read(ED25519_SPKI_PREFIX + Base64.decode64(credential(:public_key)))
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def credential(key)
|
|
167
|
+
value = options[key] || rails_credentials&.dig(key)
|
|
168
|
+
raise ConfigurationError, "Missing Telnyx #{key}" if value.blank?
|
|
169
|
+
value
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def rails_credentials
|
|
173
|
+
return unless defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
|
|
174
|
+
::Rails.application.credentials.telnyx&.to_h&.symbolize_keys
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
require "securerandom"
|
|
2
|
+
|
|
3
|
+
module Smswire
|
|
4
|
+
module Providers
|
|
5
|
+
# Records messages in memory. The default provider in the test
|
|
6
|
+
# environment.
|
|
7
|
+
#
|
|
8
|
+
# Smswire::Providers::Test.deliveries # => [#<Smswire::Message ...>]
|
|
9
|
+
# Smswire::Providers::Test.fail_next(Smswire::PermanentError.new("nope", reason: :opted_out))
|
|
10
|
+
class Test < Base
|
|
11
|
+
@deliveries = []
|
|
12
|
+
@failures = []
|
|
13
|
+
@mutex = Mutex.new
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
attr_reader :mutex
|
|
17
|
+
|
|
18
|
+
def deliveries
|
|
19
|
+
mutex.synchronize { @deliveries.dup }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def record(message)
|
|
23
|
+
mutex.synchronize { @deliveries << message }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Raise +error+ on the next delivery instead of recording it.
|
|
27
|
+
def fail_next(error)
|
|
28
|
+
mutex.synchronize { @failures << error }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def next_failure
|
|
32
|
+
mutex.synchronize { @failures.shift }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def clear!
|
|
36
|
+
mutex.synchronize do
|
|
37
|
+
@deliveries.clear
|
|
38
|
+
@failures.clear
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def verify_credentials! = true
|
|
44
|
+
|
|
45
|
+
def deliver(message)
|
|
46
|
+
if (error = self.class.next_failure)
|
|
47
|
+
raise error
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
self.class.record(message)
|
|
51
|
+
receipt(provider_id: "SMtest#{SecureRandom.hex(12)}", status: :accepted, segments: message.segments)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def capabilities
|
|
55
|
+
Set[:mms]
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
require "bigdecimal"
|
|
2
|
+
require "base64"
|
|
3
|
+
|
|
4
|
+
module Smswire
|
|
5
|
+
module Providers
|
|
6
|
+
# Twilio Programmable Messaging over the REST API. No SDK dependency.
|
|
7
|
+
#
|
|
8
|
+
# Smswire.config.providers[:twilio] = {
|
|
9
|
+
# account_sid: "AC...", auth_token: "...",
|
|
10
|
+
# status_callback: "https://app.example.com/sms/status" # optional
|
|
11
|
+
# }
|
|
12
|
+
#
|
|
13
|
+
# Credentials fall back to Rails.application.credentials.twilio.
|
|
14
|
+
class Twilio < Base
|
|
15
|
+
API_BASE = "https://api.twilio.com/2010-04-01".freeze
|
|
16
|
+
|
|
17
|
+
PERMANENT_REASONS = {
|
|
18
|
+
21211 => :invalid_number, # Invalid 'To' number
|
|
19
|
+
21214 => :invalid_number, # 'To' number cannot be reached
|
|
20
|
+
21217 => :invalid_number, # Phone number does not appear to be valid
|
|
21
|
+
21614 => :invalid_number, # 'To' number is not a valid mobile number
|
|
22
|
+
21610 => :opted_out, # Recipient replied STOP
|
|
23
|
+
21408 => :unroutable, # Permission to send to this region not enabled
|
|
24
|
+
21612 => :unroutable, # Cannot route to this number
|
|
25
|
+
21606 => :unroutable, # 'From' number is not SMS capable
|
|
26
|
+
20003 => :authentication # Authentication failed
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
STATUSES = {
|
|
30
|
+
"queued" => :queued, "scheduled" => :queued, "accepted" => :accepted,
|
|
31
|
+
"sending" => :accepted, "sent" => :sent, "delivered" => :delivered,
|
|
32
|
+
"undelivered" => :undelivered, "failed" => :failed, "read" => :read,
|
|
33
|
+
"receiving" => :accepted, "received" => :delivered, "canceled" => :failed
|
|
34
|
+
}.freeze
|
|
35
|
+
|
|
36
|
+
def deliver(message)
|
|
37
|
+
response = HTTP.post_form(messages_url, form_for(message), basic_auth: [account_sid, auth_token], provider: name)
|
|
38
|
+
handle(response)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def capabilities
|
|
42
|
+
Set[:mms, :status_callbacks, :inbound, :messaging_services]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def verify_credentials!
|
|
46
|
+
url = "#{options.fetch(:api_base, API_BASE)}/Accounts/#{account_sid}.json"
|
|
47
|
+
check_credentials_response(HTTP.get(url, basic_auth: [account_sid, auth_token], provider: name))
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def form_for(message)
|
|
51
|
+
pairs = [["To", message.to]]
|
|
52
|
+
if message.messaging_service
|
|
53
|
+
pairs << ["MessagingServiceSid", message.messaging_service]
|
|
54
|
+
elsif message.from
|
|
55
|
+
pairs << ["From", message.from]
|
|
56
|
+
else
|
|
57
|
+
raise ConfigurationError, "Twilio requires a from number or messaging_service"
|
|
58
|
+
end
|
|
59
|
+
pairs << ["Body", message.body] if message.body.present?
|
|
60
|
+
message.media_urls.each { |url| pairs << ["MediaUrl", url] }
|
|
61
|
+
pairs << ["ValidityPeriod", message.validity_period.to_i.to_s] if message.validity_period
|
|
62
|
+
callback = message.status_callback_url || options[:status_callback]
|
|
63
|
+
pairs << ["StatusCallback", callback] if callback
|
|
64
|
+
pairs
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Twilio signs callbacks with HMAC-SHA1 over the full URL followed by
|
|
68
|
+
# each POST parameter's name and value, sorted by name.
|
|
69
|
+
def verify_signature!(request, url:)
|
|
70
|
+
signature = request.headers["X-Twilio-Signature"].to_s
|
|
71
|
+
data = url + request.request_parameters.sort.map { |key, value| "#{key}#{value}" }.join
|
|
72
|
+
expected = Base64.strict_encode64(OpenSSL::HMAC.digest("SHA1", auth_token, data))
|
|
73
|
+
return if signature.present? && ActiveSupport::SecurityUtils.secure_compare(expected, signature)
|
|
74
|
+
|
|
75
|
+
raise SignatureError, "Invalid X-Twilio-Signature"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def parse_status_callback(request)
|
|
79
|
+
params = request.request_parameters
|
|
80
|
+
raw_status = (params["MessageStatus"] || params["SmsStatus"]).to_s
|
|
81
|
+
return nil if raw_status.empty? || raw_status == "received"
|
|
82
|
+
|
|
83
|
+
StatusUpdate.new(
|
|
84
|
+
provider_id: params["MessageSid"] || params["SmsSid"],
|
|
85
|
+
status: STATUSES.fetch(raw_status, :accepted),
|
|
86
|
+
error_code: params["ErrorCode"].presence,
|
|
87
|
+
error_message: params["ErrorMessage"].presence,
|
|
88
|
+
raw: params.to_h
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def parse_inbound(request)
|
|
93
|
+
params = request.request_parameters
|
|
94
|
+
return nil unless params.key?("Body") && params["From"].present?
|
|
95
|
+
|
|
96
|
+
Inbound.new(
|
|
97
|
+
provider_id: params["MessageSid"] || params["SmsSid"],
|
|
98
|
+
from: params["From"],
|
|
99
|
+
to: params["To"],
|
|
100
|
+
body: params["Body"].to_s,
|
|
101
|
+
messaging_service: params["MessagingServiceSid"].presence,
|
|
102
|
+
provider_keyword: params["OptOutType"].present?,
|
|
103
|
+
raw: params.to_h
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Empty TwiML, so Twilio sends no reply of its own.
|
|
108
|
+
def inbound_response
|
|
109
|
+
['<?xml version="1.0" encoding="UTF-8"?><Response></Response>', "text/xml"]
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
def handle(response)
|
|
115
|
+
status = response.code.to_i
|
|
116
|
+
json = HTTP.parse_json(response.body)
|
|
117
|
+
return success(json) if status.between?(200, 299)
|
|
118
|
+
|
|
119
|
+
code = json["code"]
|
|
120
|
+
detail = "Twilio #{status}#{" (#{code})" if code}: #{json["message"] || response.message}"
|
|
121
|
+
error_options = {provider: name, provider_code: code&.to_s, http_status: status, raw: json}
|
|
122
|
+
|
|
123
|
+
case status
|
|
124
|
+
when 429
|
|
125
|
+
retry_after = response["Retry-After"]&.to_i
|
|
126
|
+
raise ThrottledError.new(detail, retry_after: retry_after&.positive? ? retry_after : nil, **error_options)
|
|
127
|
+
when 500..599
|
|
128
|
+
raise TransientError.new(detail, **error_options)
|
|
129
|
+
when 401, 403
|
|
130
|
+
raise PermanentError.new(detail, reason: :authentication, **error_options)
|
|
131
|
+
else
|
|
132
|
+
raise PermanentError.new(detail, reason: PERMANENT_REASONS.fetch(code, :rejected), **error_options)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def success(json)
|
|
137
|
+
receipt(
|
|
138
|
+
provider_id: json["sid"],
|
|
139
|
+
status: STATUSES.fetch(json["status"].to_s, :accepted),
|
|
140
|
+
segments: json["num_segments"]&.to_i,
|
|
141
|
+
price_amount: (BigDecimal(json["price"].to_s).abs if json["price"].present?),
|
|
142
|
+
price_currency: json["price_unit"],
|
|
143
|
+
raw: json
|
|
144
|
+
)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def messages_url
|
|
148
|
+
"#{options.fetch(:api_base, API_BASE)}/Accounts/#{account_sid}/Messages.json"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def account_sid = credential(:account_sid)
|
|
152
|
+
|
|
153
|
+
def auth_token = credential(:auth_token)
|
|
154
|
+
|
|
155
|
+
def credential(key)
|
|
156
|
+
value = options[key] || rails_credentials&.dig(key)
|
|
157
|
+
raise ConfigurationError, "Missing Twilio #{key}" if value.blank?
|
|
158
|
+
value
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def rails_credentials
|
|
162
|
+
return unless defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
|
|
163
|
+
::Rails.application.credentials.twilio&.to_h&.symbolize_keys
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|