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,193 @@
1
+ require "bigdecimal"
2
+ require "digest/md5"
3
+ require "openssl"
4
+
5
+ module Smswire
6
+ module Providers
7
+ # Vonage SMS API over REST. No SDK dependency.
8
+ #
9
+ # Smswire.config.providers[:vonage] = {
10
+ # api_key: "...", api_secret: "...",
11
+ # signature_secret: "...", # required to verify webhooks
12
+ # signature_method: "sha256" # md5hash (Vonage's default), md5, sha1, sha256, or sha512
13
+ # }
14
+ #
15
+ # Set the dashboard webhook method to POST. Long messages are billed as
16
+ # several parts; the receipt keeps the first part's message id, so
17
+ # delivery receipts for later parts are acknowledged but not matched.
18
+ # Prices are recorded in the account currency, which the API does not name.
19
+ # Credentials fall back to Rails.application.credentials.vonage.
20
+ class Vonage < Base
21
+ API_URL = "https://rest.nexmo.com/sms/json".freeze
22
+ SIGNATURE_METHODS = %w[md5hash md5 sha1 sha256 sha512].freeze
23
+ SIGNATURE_TOLERANCE = 300
24
+
25
+ # Send response status codes.
26
+ PERMANENT_REASONS = {
27
+ "4" => :authentication, # Invalid credentials
28
+ "14" => :authentication, # Invalid signature
29
+ "32" => :authentication, # Signature and API secret disallowed
30
+ "6" => :invalid_number, # Invalid message (unrecognized number prefix)
31
+ "33" => :invalid_number, # Number de-activated
32
+ "7" => :unroutable, # Number barred; may include opt-outs but is not specific to them
33
+ "29" => :unroutable # Non-whitelisted destination (demo account)
34
+ }.freeze
35
+ THROTTLED_CODES = %w[1].freeze
36
+ TRANSIENT_CODES = %w[5].freeze
37
+
38
+ # Delivery receipt statuses.
39
+ STATUSES = {
40
+ "accepted" => :accepted, "buffered" => :sent, "delivered" => :delivered, "expired" => :undelivered,
41
+ "failed" => :undelivered, "rejected" => :failed, "unknown" => :sent
42
+ }.freeze
43
+
44
+ def deliver(message)
45
+ response = HTTP.post_form(API_URL, form_for(message), provider: name)
46
+ handle(response)
47
+ end
48
+
49
+ BALANCE_URL = "https://rest.nexmo.com/account/get-balance".freeze
50
+
51
+ def capabilities
52
+ Set[:status_callbacks, :inbound]
53
+ end
54
+
55
+ def verify_credentials!
56
+ query = URI.encode_www_form(api_key: credential(:api_key), api_secret: credential(:api_secret))
57
+ check_credentials_response(HTTP.get("#{BALANCE_URL}?#{query}", provider: name))
58
+ end
59
+
60
+ def form_for(message)
61
+ raise ConfigurationError, "Vonage requires a from number or sender id" if message.from.blank?
62
+
63
+ pairs = [
64
+ ["api_key", credential(:api_key)], ["api_secret", credential(:api_secret)],
65
+ ["to", message.to.delete_prefix("+")], ["from", message.from.delete_prefix("+")],
66
+ ["text", message.body.to_s], ["type", (message.encoding == :ucs2) ? "unicode" : "text"]
67
+ ]
68
+ pairs << ["ttl", (message.validity_period.to_i * 1000).to_s] if message.validity_period
69
+ if (callback = message.status_callback_url || options[:callback])
70
+ pairs << ["status-report-req", "1"] << ["callback", callback]
71
+ end
72
+ pairs
73
+ end
74
+
75
+ # Vonage signs webhook parameters as sorted "&key=value" pairs with
76
+ # "&" and "=" in values replaced by "_", then hashes with the
77
+ # signature secret. Timestamps older than five minutes are rejected.
78
+ def verify_signature!(request, url: nil)
79
+ params = request.request_parameters.to_h.transform_keys(&:to_s)
80
+ signature = params.delete("sig").to_s
81
+ raise SignatureError, "Missing Vonage sig parameter" if signature.empty?
82
+ if params["timestamp"].present? && (Time.now.to_i - params["timestamp"].to_i).abs > SIGNATURE_TOLERANCE
83
+ raise SignatureError, "Stale Vonage timestamp"
84
+ end
85
+
86
+ expected = self.class.signature(params, secret: signature_secret, method: signature_method)
87
+ return if ActiveSupport::SecurityUtils.secure_compare(expected.downcase, signature.downcase)
88
+
89
+ raise SignatureError, "Invalid Vonage signature"
90
+ end
91
+
92
+ def self.signature(params, secret:, method:)
93
+ data = params.sort.map { |key, value| "&#{key}=#{value.to_s.tr("&=", "__")}" }.join
94
+ if method == "md5hash"
95
+ Digest::MD5.hexdigest("#{data}#{secret}")
96
+ else
97
+ OpenSSL::HMAC.hexdigest(method, secret, data).upcase
98
+ end
99
+ end
100
+
101
+ def parse_status_callback(request)
102
+ params = request.request_parameters
103
+ return nil if params["status"].blank?
104
+
105
+ err_code = params["err-code"].to_s
106
+ StatusUpdate.new(
107
+ provider_id: params["messageId"],
108
+ status: STATUSES.fetch(params["status"].to_s, :sent),
109
+ error_code: (err_code unless err_code.empty? || err_code == "0"),
110
+ raw: params.to_h
111
+ )
112
+ end
113
+
114
+ def parse_inbound(request)
115
+ params = request.request_parameters
116
+ return nil unless params.key?("text") && params["msisdn"].present?
117
+
118
+ Inbound.new(
119
+ provider_id: params["messageId"],
120
+ from: international(params["msisdn"]),
121
+ to: international(params["to"]),
122
+ body: params["text"].to_s,
123
+ raw: params.to_h
124
+ )
125
+ end
126
+
127
+ private
128
+
129
+ def handle(response)
130
+ status = response.code.to_i
131
+ json = HTTP.parse_json(response.body)
132
+ raise TransientError.new("Vonage #{status}", provider: name, http_status: status) if status >= 500
133
+ if status == 429
134
+ raise ThrottledError.new("Vonage 429", provider: name, http_status: status)
135
+ end
136
+
137
+ parts = Array(json["messages"])
138
+ failed = parts.find { |part| part["status"].to_s != "0" }
139
+ return success(parts) if status.between?(200, 299) && parts.any? && failed.nil?
140
+
141
+ code = failed&.dig("status")&.to_s
142
+ detail = "Vonage #{status}#{" (#{code})" if code}: #{failed&.dig("error-text") || response.message}"
143
+ error_options = {provider: name, provider_code: code, http_status: status, raw: json}
144
+ if THROTTLED_CODES.include?(code)
145
+ raise ThrottledError.new(detail, **error_options)
146
+ elsif TRANSIENT_CODES.include?(code)
147
+ raise TransientError.new(detail, **error_options)
148
+ else
149
+ raise PermanentError.new(detail, reason: PERMANENT_REASONS.fetch(code, :rejected), **error_options)
150
+ end
151
+ end
152
+
153
+ def success(parts)
154
+ prices = parts.filter_map { |part| part["message-price"].presence }
155
+ receipt(
156
+ provider_id: parts.first["message-id"],
157
+ status: :accepted,
158
+ segments: parts.size,
159
+ price_amount: (prices.sum { |price| BigDecimal(price.to_s) } if prices.any?),
160
+ raw: {"messages" => parts}
161
+ )
162
+ end
163
+
164
+ def international(number)
165
+ number = number.to_s
166
+ number.match?(/\A\d+\z/) ? "+#{number}" : number
167
+ end
168
+
169
+ def signature_secret
170
+ credential(:signature_secret)
171
+ rescue ConfigurationError
172
+ raise SignatureError, "Set providers[:vonage][:signature_secret] to verify Vonage webhooks"
173
+ end
174
+
175
+ def signature_method
176
+ method = (options[:signature_method] || rails_credentials&.dig(:signature_method) || "md5hash").to_s
177
+ raise ConfigurationError, "Unknown Vonage signature_method #{method}" unless SIGNATURE_METHODS.include?(method)
178
+ method
179
+ end
180
+
181
+ def credential(key)
182
+ value = options[key] || rails_credentials&.dig(key)
183
+ raise ConfigurationError, "Missing Vonage #{key}" if value.blank?
184
+ value
185
+ end
186
+
187
+ def rails_credentials
188
+ return unless defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
189
+ ::Rails.application.credentials.vonage&.to_h&.symbolize_keys
190
+ end
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,38 @@
1
+ module Smswire
2
+ module Providers
3
+ @registry = {
4
+ test: "Smswire::Providers::Test",
5
+ log: "Smswire::Providers::Log",
6
+ null: "Smswire::Providers::Null",
7
+ twilio: "Smswire::Providers::Twilio",
8
+ telnyx: "Smswire::Providers::Telnyx",
9
+ vonage: "Smswire::Providers::Vonage"
10
+ }
11
+
12
+ class << self
13
+ # Register a custom adapter: Smswire::Providers.register(:acme, "AcmeProvider").
14
+ def register(name, klass)
15
+ @registry[name.to_sym] = klass
16
+ end
17
+
18
+ def registered
19
+ @registry.keys
20
+ end
21
+
22
+ def lookup(name)
23
+ klass = @registry.fetch(name.to_sym) do
24
+ raise ConfigurationError, "Unknown provider :#{name}. Registered: #{registered.join(", ")}"
25
+ end
26
+ klass.is_a?(String) ? klass.constantize : klass
27
+ end
28
+
29
+ def resolve(name = nil)
30
+ name ||= Smswire.config.default_provider
31
+ unless name
32
+ raise ConfigurationError, "No SMS provider configured. Set Smswire.config.default_provider."
33
+ end
34
+ lookup(name).new(Smswire.config.provider_options(name), name: name.to_sym)
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,45 @@
1
+ module Smswire
2
+ # Quiet-hour windows given as "HH:MM".."HH:MM" in the recipient's local
3
+ # time. A window whose start is after its end wraps past midnight. The
4
+ # start is inclusive and the end exclusive.
5
+ module QuietHours
6
+ FORMAT = /\A([01]?\d|2[0-3]):([0-5]\d)\z/
7
+
8
+ module_function
9
+
10
+ # Returns when sending may resume, or nil when +now+ is outside the window.
11
+ def resume_at(window, time_zone:, now: Time.current)
12
+ return nil if window.blank?
13
+
14
+ start_minute, end_minute = parse(window)
15
+ local = now.in_time_zone(time_zone)
16
+ minute = (local.hour * 60) + local.min
17
+ quiet =
18
+ if start_minute < end_minute
19
+ minute >= start_minute && minute < end_minute
20
+ else
21
+ minute >= start_minute || minute < end_minute
22
+ end
23
+ return nil unless quiet
24
+
25
+ resume = local.change(hour: end_minute / 60, min: end_minute % 60)
26
+ (resume > local) ? resume : resume.advance(days: 1)
27
+ end
28
+
29
+ def parse(window)
30
+ unless window.is_a?(Range)
31
+ raise ConfigurationError, "quiet_hours must be a Range like \"21:00\"..\"08:00\", got #{window.inspect}"
32
+ end
33
+ [window.begin, window.end].map do |value|
34
+ match = FORMAT.match(value.to_s) or raise ConfigurationError, "Invalid quiet_hours time #{value.inspect}"
35
+ (match[1].to_i * 60) + match[2].to_i
36
+ end
37
+ end
38
+
39
+ def time_zone_for(recipient)
40
+ zone = recipient&.time_zone
41
+ zone = ActiveSupport::TimeZone[zone] if zone.is_a?(String)
42
+ zone || Smswire.config.time_zone
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,27 @@
1
+ module Smswire
2
+ # Per-recipient, per-category sliding-window limits counted from
3
+ # smswire_deliveries. Failed and suppressed deliveries do not count.
4
+ # Concurrent sends can briefly exceed the limit by the number of workers.
5
+ module RateLimit
6
+ COUNTED = %w[pending queued accepted sent delivered undelivered read].freeze
7
+
8
+ module_function
9
+
10
+ # Returns when the next send fits the window, or nil when it fits now.
11
+ def resume_at(message, rule, now: Time.current)
12
+ max = rule.fetch(:max)
13
+ per = rule.fetch(:per)
14
+ unless Smswire.config.persist_deliveries
15
+ raise ConfigurationError, "Rate limits require persist_deliveries"
16
+ end
17
+
18
+ recent = Delivery.where(to_number: message.to, category: message.category.to_s, status: COUNTED)
19
+ .where(Delivery.arel_table[:created_at].gt(now - per))
20
+ count = recent.count
21
+ return nil if count < max
22
+
23
+ oldest = recent.order(:created_at).offset(count - max).pick(:created_at)
24
+ oldest + per
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,11 @@
1
+ module Smswire
2
+ # What a provider returns after accepting a message.
3
+ #
4
+ # +status+ uses the normalized vocabulary: :queued, :accepted, :sent,
5
+ # :delivered, :undelivered, :failed, :read.
6
+ Receipt = Data.define(:provider, :provider_id, :status, :segments, :price_amount, :price_currency, :raw) do
7
+ def initialize(provider:, provider_id: nil, status: :accepted, segments: nil, price_amount: nil, price_currency: nil, raw: nil)
8
+ super
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,38 @@
1
+ module Smswire
2
+ # The resolved target of a message: the phone number as given, plus the
3
+ # originating record and its time zone when available.
4
+ Recipient = Data.define(:phone, :record, :time_zone) do
5
+ def initialize(phone:, record: nil, time_zone: nil)
6
+ super
7
+ end
8
+
9
+ # Resolution order: Recipient, String, the configured resolver, then
10
+ # +phone_number+ on the object.
11
+ def self.resolve(value)
12
+ case value
13
+ when Recipient
14
+ value
15
+ when String
16
+ raise UnresolvableRecipient, "Recipient phone number is blank" if value.blank?
17
+ new(phone: value)
18
+ when nil
19
+ raise UnresolvableRecipient, "No recipient given"
20
+ else
21
+ phone = phone_for(value)
22
+ if phone.blank?
23
+ raise UnresolvableRecipient, "Could not resolve a phone number from #{value.class.name}"
24
+ end
25
+ new(phone: phone.to_s, record: value, time_zone: (value.time_zone if value.respond_to?(:time_zone)))
26
+ end
27
+ end
28
+
29
+ def self.phone_for(value)
30
+ if (resolver = Smswire.config.recipient_resolver)
31
+ resolver.call(value)
32
+ elsif value.respond_to?(:phone_number)
33
+ value.phone_number
34
+ end
35
+ end
36
+ private_class_method :phone_for
37
+ end
38
+ end
@@ -0,0 +1,40 @@
1
+ module Smswire
2
+ # The outcome of running a message through the delivery pipeline.
3
+ #
4
+ # Statuses:
5
+ # :accepted provider accepted the message
6
+ # :failed provider permanently refused it (see +error+)
7
+ # :rejected_invalid_number recipient is not a valid E.164 number
8
+ # :rejected_empty_body no body and no media
9
+ # :suppressed_by_interceptor an interceptor cancelled delivery
10
+ # :skipped the messenger action did not call +text+
11
+ # :duplicate an identical message was sent inside the dedupe window
12
+ # :rejected_opted_out the recipient opted out in this consent scope
13
+ # :rejected_no_consent the category requires opt-in and there is none
14
+ # :rejected_rate_limited over the category rate limit, with exceed: :reject
15
+ # :deferred_quiet_hours inside quiet hours; re-enqueued for +resume_at+
16
+ # :deferred_rate_limited over the category rate limit; re-enqueued for +resume_at+
17
+ #
18
+ # +delivery+ is the Smswire::Delivery row when persistence is enabled.
19
+ Result = Data.define(:status, :message, :receipt, :error, :delivery, :resume_at) do
20
+ def initialize(status:, message: nil, receipt: nil, error: nil, delivery: nil, resume_at: nil)
21
+ super
22
+ end
23
+
24
+ def accepted? = status == :accepted
25
+
26
+ def failed? = status == :failed
27
+
28
+ def rejected? = status.to_s.start_with?("rejected_")
29
+
30
+ def suppressed? = status == :suppressed_by_interceptor
31
+
32
+ def skipped? = status == :skipped
33
+
34
+ def duplicate? = status == :duplicate
35
+
36
+ def deferred? = status.to_s.start_with?("deferred_")
37
+
38
+ def provider_id = receipt&.provider_id
39
+ end
40
+ end
@@ -0,0 +1,133 @@
1
+ require "smswire"
2
+ require "rspec/expectations"
3
+
4
+ module Smswire
5
+ # RSpec matchers. Load with +require "smswire/rspec"+.
6
+ #
7
+ # expect { order.ship! }.to deliver_sms(to: "+15551234567", body: /shipped/)
8
+ # expect { order.ship! }.to deliver_sms.exactly(2).times
9
+ # expect { order.ship! }.to have_enqueued_sms(OrderMessenger, :shipped)
10
+ #
11
+ # +deliver_sms+ counts deliveries made during the block. Wrap the block
12
+ # in +perform_enqueued_jobs+ to include deliver_later.
13
+ module RSpec
14
+ module Matchers
15
+ def deliver_sms(**filters)
16
+ DeliverSms.new(filters)
17
+ end
18
+
19
+ def have_enqueued_sms(messenger = nil, action = nil, **filters)
20
+ HaveEnqueuedSms.new(messenger, action, filters)
21
+ end
22
+
23
+ module Counting
24
+ def exactly(count)
25
+ @expected_count = count
26
+ self
27
+ end
28
+
29
+ def once = exactly(1)
30
+
31
+ def twice = exactly(2)
32
+
33
+ def times = self
34
+
35
+ def supports_block_expectations? = true
36
+
37
+ private
38
+
39
+ def count_matches?(actual)
40
+ @expected_count ? actual == @expected_count : actual.positive?
41
+ end
42
+
43
+ def expectation
44
+ @expected_count ? "exactly #{@expected_count}" : "at least one"
45
+ end
46
+ end
47
+
48
+ class DeliverSms
49
+ include ::RSpec::Matchers::Composable
50
+ include Counting
51
+
52
+ def initialize(filters)
53
+ @filters = filters
54
+ end
55
+
56
+ def matches?(block)
57
+ before = Smswire::Testing.deliveries.size
58
+ block.call
59
+ @delivered = Smswire::Testing.deliveries.drop(before)
60
+ @matching = Smswire::Testing.match_deliveries(@delivered, **@filters)
61
+ count_matches?(@matching.size)
62
+ end
63
+
64
+ def failure_message
65
+ "expected #{expectation} SMS#{described} to be delivered, got #{@matching.size} " \
66
+ "(#{@delivered.size} delivered in total)"
67
+ end
68
+
69
+ def failure_message_when_negated
70
+ "expected no SMS#{described} to be delivered, got #{@matching.size}"
71
+ end
72
+
73
+ def description
74
+ "deliver #{expectation} SMS#{described}"
75
+ end
76
+
77
+ private
78
+
79
+ def described = Smswire::Testing.describe_filters(@filters)
80
+ end
81
+
82
+ class HaveEnqueuedSms
83
+ include ::RSpec::Matchers::Composable
84
+ include Counting
85
+
86
+ def initialize(messenger, action, filters)
87
+ @messenger = messenger
88
+ @action = action
89
+ @filters = filters
90
+ end
91
+
92
+ def matches?(block)
93
+ adapter = ActiveJob::Base.queue_adapter
94
+ unless adapter.respond_to?(:enqueued_jobs)
95
+ raise ArgumentError, "have_enqueued_sms requires the ActiveJob :test adapter"
96
+ end
97
+
98
+ before = adapter.enqueued_jobs.size
99
+ block.call
100
+ entries = Smswire::Testing.enqueued(adapter.enqueued_jobs.drop(before))
101
+ @matching = Smswire::Testing.match_enqueued(entries, messenger: @messenger, action: @action, **@filters)
102
+ count_matches?(@matching.size)
103
+ end
104
+
105
+ def failure_message
106
+ "expected #{expectation} #{label} to be enqueued, got #{@matching.size}"
107
+ end
108
+
109
+ def failure_message_when_negated
110
+ "expected no #{label} to be enqueued, got #{@matching.size}"
111
+ end
112
+
113
+ def description
114
+ "enqueue #{expectation} #{label}"
115
+ end
116
+
117
+ private
118
+
119
+ def label
120
+ name = [@messenger, @action].compact.join("#")
121
+ name.empty? ? "SMS" : name
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
127
+
128
+ if defined?(::RSpec::Core) && ::RSpec.respond_to?(:configure)
129
+ ::RSpec.configure do |config|
130
+ config.include Smswire::RSpec::Matchers
131
+ config.before { Smswire::Testing.clear! }
132
+ end
133
+ end
@@ -0,0 +1,81 @@
1
+ module Smswire
2
+ # Encoding and segment calculation for SMS bodies.
3
+ #
4
+ # A body that fits the GSM 03.38 alphabet is sent as GSM-7: 160 units in
5
+ # one segment, 153 per segment when concatenated. Extension-table
6
+ # characters cost two units. Anything else is sent as UCS-2: 70 units in
7
+ # one segment, 67 per segment, where characters outside the Basic
8
+ # Multilingual Plane (most emoji) cost two units. Characters are never
9
+ # split across segments, so packing is greedy rather than a division.
10
+ module Segments
11
+ GSM_BASIC = Set.new(
12
+ "@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞÆæßÉ !\"#¤%&'()*+,-./0123456789:;<=>?" \
13
+ "¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà".chars
14
+ ).freeze
15
+ GSM_EXTENDED = Set.new("\f^{}\\[~]|€".chars).freeze
16
+
17
+ LIMITS = {
18
+ gsm7: {single: 160, multi: 153},
19
+ ucs2: {single: 70, multi: 67}
20
+ }.freeze
21
+
22
+ Analysis = Data.define(:encoding, :units, :segments) do
23
+ def gsm7? = encoding == :gsm7
24
+
25
+ def ucs2? = encoding == :ucs2
26
+
27
+ def encoding_label = gsm7? ? "GSM-7" : "UCS-2"
28
+
29
+ # Units left before the message needs another segment.
30
+ def remaining
31
+ limits = LIMITS.fetch(encoding)
32
+ capacity = (segments <= 1) ? limits[:single] : segments * limits[:multi]
33
+ [capacity - units, 0].max
34
+ end
35
+ end
36
+
37
+ module_function
38
+
39
+ def analyze(text)
40
+ chars = text.to_s.chars
41
+ encoding = gsm7?(chars) ? :gsm7 : :ucs2
42
+ costs = chars.map { |char| cost(char, encoding) }
43
+ units = costs.sum
44
+ Analysis.new(encoding:, units:, segments: count(costs, units, LIMITS.fetch(encoding)))
45
+ end
46
+
47
+ # Characters that force UCS-2 encoding, in order of appearance.
48
+ def non_gsm_characters(text)
49
+ text.to_s.chars.reject { |char| GSM_BASIC.include?(char) || GSM_EXTENDED.include?(char) }.uniq
50
+ end
51
+
52
+ def gsm7?(chars)
53
+ chars.all? { |char| GSM_BASIC.include?(char) || GSM_EXTENDED.include?(char) }
54
+ end
55
+
56
+ def cost(char, encoding)
57
+ if encoding == :gsm7
58
+ GSM_EXTENDED.include?(char) ? 2 : 1
59
+ else
60
+ (char.ord > 0xFFFF) ? 2 : 1
61
+ end
62
+ end
63
+
64
+ def count(costs, units, limits)
65
+ return 0 if units.zero?
66
+ return 1 if units <= limits[:single]
67
+
68
+ segments = 1
69
+ used = 0
70
+ costs.each do |cost|
71
+ if used + cost > limits[:multi]
72
+ segments += 1
73
+ used = cost
74
+ else
75
+ used += cost
76
+ end
77
+ end
78
+ segments
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,27 @@
1
+ module Smswire
2
+ # A from-number or messaging service, with the provider that owns it.
3
+ # +consent_scope+ names the consent program the sender belongs to.
4
+ Sender = Data.define(:number, :messaging_service, :provider, :consent_scope) do
5
+ def initialize(number: nil, messaging_service: nil, provider: nil, consent_scope: nil)
6
+ super
7
+ end
8
+
9
+ def self.resolve(value)
10
+ case value
11
+ when nil then new
12
+ when Sender then value
13
+ when String then new(number: value)
14
+ when Symbol
15
+ options = Smswire.config.senders[value] || Smswire.config.senders[value.to_s]
16
+ unless options
17
+ raise ConfigurationError, "Unknown sender :#{value}. Define it in Smswire.config.senders."
18
+ end
19
+ options = options.to_h.symbolize_keys
20
+ new(number: options[:number], messaging_service: options[:messaging_service],
21
+ provider: options[:provider]&.to_sym, consent_scope: options[:consent_scope]&.to_s)
22
+ else
23
+ raise ConfigurationError, "from: must be a phone number String or a sender Symbol, got #{value.class.name}"
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,25 @@
1
+ module Smswire
2
+ # Applies a parsed provider status update to its delivery and notifies
3
+ # observers. Shared by the webhook endpoints.
4
+ module StatusHandler
5
+ module_function
6
+
7
+ def call(update, provider:, delivery_id: nil)
8
+ payload = {provider:, provider_id: update.provider_id, status: update.status, error_code: update.error_code}
9
+ ActiveSupport::Notifications.instrument("status_update.smswire", payload) do
10
+ delivery = Delivery.apply_status_update(update, provider:, delivery_id:)
11
+ payload[:delivery_id] = delivery&.id
12
+ payload[:delivery_status] = delivery&.status
13
+ notify_observers(delivery, update) if delivery
14
+ delivery
15
+ end
16
+ end
17
+
18
+ def notify_observers(delivery, update)
19
+ Smswire.config.observers.each do |observer|
20
+ observer = observer.is_a?(String) ? observer.constantize : observer
21
+ observer.sms_status_updated(delivery, update) if observer.respond_to?(:sms_status_updated)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,8 @@
1
+ module Smswire
2
+ # A provider's report that a message changed status, parsed from a callback.
3
+ StatusUpdate = Data.define(:provider_id, :status, :error_code, :error_message, :raw) do
4
+ def initialize(provider_id:, status:, error_code: nil, error_message: nil, raw: {})
5
+ super
6
+ end
7
+ end
8
+ end