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,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,12 @@
1
+ module Smswire
2
+ module Providers
3
+ # Accepts and discards every message.
4
+ class Null < Base
5
+ def verify_credentials! = true
6
+
7
+ def deliver(message)
8
+ receipt(status: :accepted, segments: message.segments)
9
+ end
10
+ end
11
+ end
12
+ 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