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,105 @@
1
+ # Migrating from Noticed's Twilio delivery method
2
+
3
+ Noticed is a good fit for notification inboxes and fan-out across channels.
4
+ Its `twilio_messaging` delivery method posts a message string to Twilio and
5
+ leaves templates, consent, retries, and delivery tracking to the app.
6
+ Smswire adds a `deliver_by :smswire` delivery method, so you keep Noticed
7
+ and move only the SMS channel.
8
+
9
+ ## Before
10
+
11
+ ```ruby
12
+ class OrderShippedNotifier < Noticed::Event
13
+ deliver_by :twilio_messaging do |config|
14
+ config.json = -> {
15
+ {
16
+ From: Rails.application.credentials.dig(:twilio, :phone_number),
17
+ To: recipient.phone_number,
18
+ Body: "Order #{params[:order].number} has shipped"
19
+ }
20
+ }
21
+ config.error_handler = ->(response) { ... }
22
+ end
23
+ end
24
+ ```
25
+
26
+ ## After
27
+
28
+ ```ruby
29
+ class OrderShippedNotifier < Noticed::Event
30
+ deliver_by :smswire do |config|
31
+ config.messenger = "OrderMessenger"
32
+ config.action = :shipped
33
+ end
34
+ end
35
+ ```
36
+
37
+ ```ruby
38
+ class OrderMessenger < ApplicationMessenger
39
+ # Called with the Noticed recipient. params holds the event params plus
40
+ # notification, record, and recipient.
41
+ def shipped(user)
42
+ @order = params[:order]
43
+ text to: user
44
+ end
45
+ end
46
+ ```
47
+
48
+ ```erb
49
+ <%# app/views/order_messenger/shipped.text.erb %>
50
+ Order <%= @order.number %> has shipped
51
+ ```
52
+
53
+ Install Smswire first with the [getting started](getting-started.md) steps.
54
+ The delivery method loads automatically when Noticed is in the bundle.
55
+
56
+ ## Options
57
+
58
+ | Option | Default | Use |
59
+ |---|---|---|
60
+ | `messenger` | required | class or class name |
61
+ | `action` | required | messenger action |
62
+ | `args` | `[recipient]` | positional arguments for the action |
63
+ | `kwargs` | `{}` | keyword arguments |
64
+ | `params` | the event params | the messenger's `params`, merged with `notification`, `record`, `recipient` |
65
+ | `sms_queue`, `sms_priority` | Smswire's defaults | queue and priority for `Smswire::DeliveryJob` |
66
+
67
+ Noticed's own options, such as `if`, `unless`, `wait`, and `queue`, still
68
+ apply to the Noticed job. A `wait` delays the message once; Smswire does not
69
+ add a second delay.
70
+
71
+ ## Credentials
72
+
73
+ Noticed reads `Rails.application.credentials.twilio`, and so does Smswire.
74
+ The `phone_number` key becomes a sender:
75
+
76
+ ```ruby
77
+ config.senders = {transactional: {number: Rails.application.credentials.dig(:twilio, :phone_number)}}
78
+ ```
79
+
80
+ ## Error handling
81
+
82
+ `config.error_handler` received Twilio's 4xx response. With Smswire:
83
+
84
+ - permanent errors become a `:failed` delivery with a reason, such as
85
+ `:invalid_number` or `:opted_out`;
86
+ - a 21610 opt-out also records the opt-out, so later sends are refused;
87
+ - 429 and 5xx errors are retried by `Smswire::DeliveryJob`.
88
+
89
+ Use an observer for alerting; see the
90
+ [twilio-ruby guide](migrating-from-twilio-ruby.md#3-map-your-error-handling).
91
+
92
+ ## Joining notifications and deliveries
93
+
94
+ Each `Smswire::Delivery` stores `noticed_notification_id` and
95
+ `noticed_event_id` in `metadata`, and `recipient` points at the Noticed
96
+ recipient when it is an Active Record model.
97
+
98
+ ## What you gain
99
+
100
+ - Templates, previews, and test helpers for the SMS body.
101
+ - Delivery status from Twilio callbacks on every send.
102
+ - STOP / START / HELP handling and consent checks before sending.
103
+ - Quiet hours and rate limits per category.
104
+ - Deduplication and retries.
105
+ - The same code works with Telnyx or Vonage by changing the provider.
@@ -0,0 +1,146 @@
1
+ # Migrating from direct twilio-ruby calls
2
+
3
+ Many apps send SMS by calling the Twilio SDK from models, jobs, or service
4
+ objects. This guide moves that code onto Smswire one message at a time.
5
+ Both can run side by side during the move.
6
+
7
+ ## What changes
8
+
9
+ | Before | After |
10
+ |---|---|
11
+ | `client.messages.create(from:, to:, body:)` | a messenger action plus `deliver_later` |
12
+ | bodies built in Ruby strings | `.text.erb` templates with I18n and URL helpers |
13
+ | your own job and retry logic | `Smswire::DeliveryJob` with transient and permanent errors separated |
14
+ | `rescue Twilio::REST::RestError` | a `Smswire::Result` with a status and a reason |
15
+ | your own status callback controller | the engine's signed `/smswire/status/twilio` endpoint |
16
+ | stubbing the Twilio client in tests | `Smswire::TestHelper` and the `:test` provider |
17
+ | `twilio-ruby` in the Gemfile | no provider SDK |
18
+
19
+ Smswire calls Twilio's REST API directly. You can remove `twilio-ruby` once
20
+ nothing else uses it, such as Voice or Verify.
21
+
22
+ ## 1. Install and keep your credentials
23
+
24
+ Run the [getting started](getting-started.md) install steps. Smswire reads
25
+ `Rails.application.credentials.twilio[:account_sid]` and `[:auth_token]`, the
26
+ same keys most apps already use. Move the sending number into a sender:
27
+
28
+ ```ruby
29
+ config.senders = {
30
+ transactional: {number: Rails.application.credentials.dig(:twilio, :phone_number)},
31
+ # A Messaging Service maps to messaging_service:
32
+ # marketing: {messaging_service: "MG...", consent_scope: "marketing"}
33
+ }
34
+ ```
35
+
36
+ ## 2. Replace one send
37
+
38
+ Before:
39
+
40
+ ```ruby
41
+ class ShipmentNotifier
42
+ def call(order)
43
+ client = Twilio::REST::Client.new(sid, token)
44
+ client.messages.create(
45
+ from: ENV["TWILIO_NUMBER"],
46
+ to: order.customer.phone,
47
+ body: "Order #{order.number} has shipped",
48
+ status_callback: twilio_status_url(order_id: order.id)
49
+ )
50
+ rescue Twilio::REST::RestError => error
51
+ Rails.logger.error(error.message)
52
+ end
53
+ end
54
+ ```
55
+
56
+ After:
57
+
58
+ ```ruby
59
+ class OrderMessenger < ApplicationMessenger
60
+ def shipped(order)
61
+ @order = order
62
+ text to: order.customer.phone
63
+ end
64
+ end
65
+
66
+ OrderMessenger.shipped(order).deliver_later
67
+ ```
68
+
69
+ ```erb
70
+ <%# app/views/order_messenger/shipped.text.erb %>
71
+ Order <%= @order.number %> has shipped
72
+ ```
73
+
74
+ `text to:` takes a string here. Pass the customer instead if it has a
75
+ `phone_number` method, which also links the delivery row to the customer.
76
+
77
+ ## 3. Map your error handling
78
+
79
+ Smswire turns Twilio errors into outcomes:
80
+
81
+ | Twilio | Smswire |
82
+ |---|---|
83
+ | 21211, 21214, 21217, 21614 invalid number | `:failed`, `error.reason == :invalid_number` |
84
+ | 21610 recipient replied STOP | `:failed`, reason `:opted_out`, and the number is opted out |
85
+ | 21408, 21606, 21612 cannot route | `:failed`, reason `:unroutable` |
86
+ | 401, 403, 20003 | `:failed`, reason `:authentication` |
87
+ | 429 | retried after `Retry-After` |
88
+ | 5xx, timeouts | retried with backoff, up to `retry_attempts` |
89
+ | other 4xx | `:failed`, reason `:rejected` |
90
+
91
+ Code that rescued `RestError` to log or alert becomes an observer:
92
+
93
+ ```ruby
94
+ class SmsAlerts
95
+ def self.delivered_sms(result)
96
+ Sentry.capture_message("SMS failed: #{result.error.message}") if result.failed?
97
+ end
98
+ end
99
+ Smswire.register_observer(SmsAlerts)
100
+ ```
101
+
102
+ ## 4. Retire your status controller
103
+
104
+ If you had a controller for Twilio status callbacks, set
105
+ `config.callbacks_url` and delete it. Every message then carries a
106
+ `StatusCallback` URL with its delivery id, the engine checks the
107
+ `X-Twilio-Signature`, and `Smswire::Delivery#status` only moves forward. Use
108
+ `sms_status_updated(delivery, update)` on an observer for anything you did
109
+ in that controller.
110
+
111
+ Behind a load balancer that terminates TLS, the signature still verifies
112
+ because Smswire rebuilds the URL from `callbacks_url`.
113
+
114
+ ## 5. Point incoming messages at the engine
115
+
116
+ Set the number's or Messaging Service's incoming webhook to
117
+ `https://app.example.com/smswire/inbound/twilio`. If you use Twilio Advanced
118
+ Opt-Out, Smswire records the opt-out and skips its own reply, since Twilio
119
+ already sent one.
120
+
121
+ ## 6. Update tests
122
+
123
+ Before:
124
+
125
+ ```ruby
126
+ client = instance_double(Twilio::REST::Client)
127
+ expect(client).to receive_message_chain(:messages, :create)
128
+ ```
129
+
130
+ After:
131
+
132
+ ```ruby
133
+ assert_sms_delivered(to: "+15555550100", messenger: OrderMessenger) { order.ship! }
134
+ ```
135
+
136
+ The test environment uses the `:test` provider automatically, so no request
137
+ leaves the process.
138
+
139
+ ## Behaviour to expect
140
+
141
+ - **Duplicates.** The same messenger, action, recipient, and body inside 10
142
+ minutes returns `:duplicate`. Pass `idempotency_key:` to `text`, or set
143
+ `config.dedupe_window = nil`.
144
+ - **Opt-outs.** Opted-out numbers are refused for every category.
145
+ - **Marketing.** Messages with `category: :marketing` need an opt-in and wait
146
+ for quiet hours to end.
@@ -0,0 +1,126 @@
1
+ # Writing a provider adapter
2
+
3
+ An adapter connects Smswire to one SMS API. It sends messages, classifies
4
+ errors, and optionally verifies and parses webhooks. Every adapter must pass
5
+ `Smswire::ProviderContract`, a Minitest suite shipped in the gem.
6
+
7
+ ## Generate the skeleton
8
+
9
+ ```
10
+ bin/rails generate smswire:provider Acme
11
+ ```
12
+
13
+ This writes:
14
+
15
+ - `app/sms_providers/acme_provider.rb`, a working adapter for a fictional API;
16
+ - `test/sms_providers/acme_provider_test.rb`, which runs the contract against it;
17
+ - a `Smswire::Providers.register(:acme, "AcmeProvider")` line in the initializer.
18
+
19
+ The generated pair passes the contract as generated. Change the API calls,
20
+ error codes, and stubs together, running the test as you go.
21
+
22
+ ## The adapter contract
23
+
24
+ ```ruby
25
+ class AcmeProvider < Smswire::Providers::Base
26
+ def deliver(message) # => Smswire::Receipt, or raises
27
+ def capabilities # => Set of :mms, :status_callbacks, :inbound, :messaging_services, :scheduling
28
+ def verify_credentials! # => true, or raises; nil if unsupported
29
+
30
+ # With :status_callbacks or :inbound:
31
+ def verify_signature!(request, url:) # raises Smswire::SignatureError
32
+ def parse_status_callback(request) # => Smswire::StatusUpdate, or nil if not a status event
33
+ def parse_inbound(request) # => Smswire::Inbound, or nil if not an inbound message
34
+ def inbound_response # => [body, content_type], or nil for 204
35
+ end
36
+ ```
37
+
38
+ `options` holds `Smswire.config.providers[:acme]` with symbol keys, and
39
+ `name` is the registered name.
40
+
41
+ ### Sending
42
+
43
+ Read `message.to` (E.164), `from`, `messaging_service`, `body`,
44
+ `media_urls`, `validity_period`, and `status_callback_url`. Use
45
+ `Smswire::HTTP.post_form` or `post_json`; both turn network failures into
46
+ `TransientError` and never log bodies. Return `receipt(provider_id:, status:,
47
+ segments:, price_amount:, price_currency:, raw:)` with a status from the
48
+ normalized vocabulary: `:queued`, `:accepted`, `:sent`, `:delivered`,
49
+ `:undelivered`, `:failed`, `:read`.
50
+
51
+ ### Errors
52
+
53
+ | Situation | Raise |
54
+ |---|---|
55
+ | network failure, timeout, 5xx | `Smswire::TransientError` (retried) |
56
+ | rate limit | `Smswire::ThrottledError.new(..., retry_after: seconds)` |
57
+ | retrying cannot help | `Smswire::PermanentError.new(..., reason:)` |
58
+ | missing credentials or settings | `Smswire::ConfigurationError` |
59
+
60
+ Pass `provider: name, provider_code:, http_status:, raw:` to error
61
+ constructors. Reasons are `:invalid_number`, `:opted_out`, `:unroutable`,
62
+ `:authentication`, and `:rejected`. Use `:opted_out` only for an error that
63
+ specifically means the recipient opted out, because Smswire records an
64
+ opt-out when it sees one. If the provider has no such error, define
65
+ `contract_reports_opt_outs?` as `false` in the test.
66
+
67
+ ### Webhooks
68
+
69
+ Verify before parsing. Use constant-time comparison, such as
70
+ `ActiveSupport::SecurityUtils.secure_compare`, and reject stale timestamps
71
+ when the provider signs them. `url:` is the public URL the provider posted
72
+ to, rebuilt from `config.callbacks_url` when that is set.
73
+
74
+ Return `nil` from `parse_status_callback` for events that are not status
75
+ updates, and from `parse_inbound` for events that are not messages. The
76
+ engine tries both, so a provider that posts every event to one URL works.
77
+
78
+ ## Contract hooks
79
+
80
+ ```ruby
81
+ class AcmeProviderTest < ActiveSupport::TestCase
82
+ include Smswire::TestHelper
83
+ include Smswire::ProviderContract
84
+
85
+ def contract_provider_name = :acme
86
+ def contract_provider_options = {api_key: "test-key"}
87
+
88
+ # Stub the API for :success, :invalid_number, :opted_out, :authentication,
89
+ # :throttled, :server_error, and :timeout. Return the WebMock stub.
90
+ def stub_contract_send(outcome, message) = ...
91
+
92
+ # With :status_callbacks; status is :delivered or :undelivered.
93
+ def contract_status_request(provider_id:, status:, signed: true) = ...
94
+
95
+ # With :inbound.
96
+ def contract_inbound_request(from:, to:, body:, signed: true) = ...
97
+ end
98
+ ```
99
+
100
+ Build requests with `contract_request(url, params:)` for form posts or
101
+ `contract_request(url, body: json)` for JSON, plus `headers:`. Override
102
+ `contract_message` to run the contract with one of your own messengers.
103
+
104
+ The suite checks:
105
+
106
+ - the receipt, its provider id, and its status;
107
+ - a full pipeline send that records a delivery row;
108
+ - each permanent outcome's reason, and that throttling, server errors, and
109
+ timeouts are retryable;
110
+ - for declared capabilities, that signed requests parse and verify and
111
+ unsigned ones raise `SignatureError`, and that status and inbound events
112
+ are not mistaken for each other.
113
+
114
+ The bundled Twilio, Telnyx, and Vonage adapters pass the same suite; their
115
+ tests in `test/provider_contract_test.rb` in the Smswire repository are
116
+ worked examples.
117
+
118
+ ## Sharing an adapter
119
+
120
+ Publish it as a gem that depends on `smswire` and registers itself:
121
+
122
+ ```ruby
123
+ # lib/smswire-acme.rb
124
+ require "smswire"
125
+ Smswire::Providers.register(:acme, "Smswire::Acme::Provider")
126
+ ```
@@ -0,0 +1,44 @@
1
+ require "rails/generators"
2
+ require "active_record"
3
+
4
+ module Smswire
5
+ module Generators
6
+ # bin/rails generate smswire:install
7
+ class InstallGenerator < ::Rails::Generators::Base
8
+ source_root File.expand_path("templates", __dir__)
9
+
10
+ desc "Copy Smswire migrations, add an initializer and ApplicationMessenger, and mount the engine."
11
+
12
+ def copy_migrations
13
+ destination = File.join(destination_root, "db/migrate")
14
+ FileUtils.mkdir_p(destination)
15
+ copied = ActiveRecord::Migration.copy(destination, {smswire: Smswire::Engine.root.join("db/migrate").to_s})
16
+ copied.each { |migration| say_status :create, "db/migrate/#{File.basename(migration.filename)}" }
17
+ say_status :identical, "db/migrate (Smswire migrations already installed)" if copied.empty?
18
+ end
19
+
20
+ def create_initializer
21
+ template "initializer.rb", "config/initializers/smswire.rb"
22
+ end
23
+
24
+ def create_application_messenger
25
+ template "application_messenger.rb", "app/messengers/application_messenger.rb"
26
+ end
27
+
28
+ def mount_engine
29
+ route %(mount Smswire::Engine => "/smswire")
30
+ end
31
+
32
+ def show_next_steps
33
+ say <<~TEXT
34
+
35
+ Smswire is installed. Next:
36
+ 1. bin/rails db:migrate
37
+ 2. Set provider credentials, senders, and callbacks_url in config/initializers/smswire.rb
38
+ 3. Point your provider's status and inbound webhooks at /smswire/status/<provider> and /smswire/inbound/<provider>
39
+ 4. bin/rails generate smswire:messenger Welcome greeting
40
+ TEXT
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,3 @@
1
+ class ApplicationMessenger < Smswire::Base
2
+ default from: :transactional
3
+ end
@@ -0,0 +1,35 @@
1
+ Smswire.configure do |config|
2
+ # Development uses the :log provider and tests use :test unless set here.
3
+ # config.default_provider = :twilio
4
+
5
+ # Credentials default to Rails.application.credentials.twilio.
6
+ # config.providers = {
7
+ # twilio: {account_sid: ENV["TWILIO_ACCOUNT_SID"], auth_token: ENV["TWILIO_AUTH_TOKEN"]}
8
+ # }
9
+
10
+ # Named senders. Messengers pick one with `default from: :transactional`.
11
+ config.senders = {
12
+ transactional: {number: ENV.fetch("SMS_FROM_NUMBER", "+15555550100")}
13
+ # marketing: {messaging_service: "MG...", provider: :twilio, consent_scope: "marketing"}
14
+ }
15
+
16
+ # Public URL where Smswire::Engine is mounted. Enables delivery status callbacks.
17
+ # config.callbacks_url = "https://example.com/smswire"
18
+
19
+ # Used in STOP / START / HELP auto-replies. HELP replies should name a
20
+ # support contact (phone, email, or URL).
21
+ # config.program_name = "<%= Rails.application.class.module_parent_name.titleize rescue "My App" %>"
22
+ # config.support_contact = "support@example.com"
23
+
24
+ # Verify provider credentials at boot (one read-only API call):
25
+ # Rails.application.config.after_initialize { Smswire.verify_credentials! } if Rails.env.production?
26
+
27
+ # Category rules merge over the defaults (marketing requires opt-in and has quiet hours).
28
+ # config.categories = {marketing: {rate_limit: {max: 3, per: 1.day}}}
29
+ # config.default_time_zone = "America/New_York"
30
+ end
31
+
32
+ # Staging safety: only listed numbers receive messages.
33
+ # if ENV["SMS_ALLOWLIST"]
34
+ # Smswire.register_interceptor(Smswire::Interceptors::Allowlist.new(numbers: ENV["SMS_ALLOWLIST"].split(",")))
35
+ # end
@@ -0,0 +1,51 @@
1
+ require "rails/generators/named_base"
2
+
3
+ module Smswire
4
+ module Generators
5
+ # bin/rails generate smswire:messenger Order shipped delivered
6
+ class MessengerGenerator < ::Rails::Generators::NamedBase
7
+ source_root File.expand_path("templates", __dir__)
8
+
9
+ argument :actions, type: :array, default: [], banner: "action action"
10
+
11
+ desc "Create a messenger with text templates, a preview, and a test."
12
+
13
+ check_class_collision suffix: "Messenger"
14
+
15
+ def create_application_messenger
16
+ return if File.exist?(File.join(destination_root, "app/messengers/application_messenger.rb"))
17
+
18
+ template "../../install/templates/application_messenger.rb", "app/messengers/application_messenger.rb"
19
+ end
20
+
21
+ def create_messenger
22
+ template "messenger.rb", File.join("app/messengers", class_path, "#{file_name}.rb")
23
+ end
24
+
25
+ def create_templates
26
+ actions.each do |action|
27
+ @action = action
28
+ template "view.text.erb", File.join("app/views", class_path, file_name, "#{action}.text.erb")
29
+ end
30
+ end
31
+
32
+ def create_preview
33
+ template "preview.rb", File.join("test/messengers/previews", class_path, "#{file_name}_preview.rb")
34
+ end
35
+
36
+ def create_test
37
+ template "test.rb", File.join("test/messengers", class_path, "#{file_name}_test.rb")
38
+ end
39
+
40
+ private
41
+
42
+ def file_name
43
+ @_file_name ||= super.sub(/_messenger\z/i, "") + "_messenger"
44
+ end
45
+
46
+ def sample_number
47
+ "+15555550100"
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,14 @@
1
+ <% module_namespacing do -%>
2
+ class <%= class_name.delete_suffix("Messenger") %>Messenger < ApplicationMessenger
3
+ <% actions.each_with_index do |action, index| -%>
4
+ <% if index > 0 -%>
5
+
6
+ <% end -%>
7
+ # Template: app/views/<%= file_path %>/<%= action %>.text.erb
8
+ def <%= action %>(recipient)
9
+ @greeting = "Hi"
10
+ text to: recipient
11
+ end
12
+ <% end -%>
13
+ end
14
+ <% end -%>
@@ -0,0 +1,13 @@
1
+ # Preview at /smswire/previews/<%= file_path %>
2
+ <% module_namespacing do -%>
3
+ class <%= class_name.delete_suffix("Messenger") %>MessengerPreview < Smswire::Preview
4
+ <% actions.each_with_index do |action, index| -%>
5
+ <% if index > 0 -%>
6
+
7
+ <% end -%>
8
+ def <%= action %>
9
+ <%= class_name.delete_suffix("Messenger") %>Messenger.<%= action %>("<%= sample_number %>")
10
+ end
11
+ <% end -%>
12
+ end
13
+ <% end -%>
@@ -0,0 +1,18 @@
1
+ require "test_helper"
2
+
3
+ <% module_namespacing do -%>
4
+ class <%= class_name.delete_suffix("Messenger") %>MessengerTest < ActiveSupport::TestCase
5
+ include Smswire::TestHelper
6
+ <% actions.each do |action| -%>
7
+
8
+ test "<%= action %>" do
9
+ message = <%= class_name.delete_suffix("Messenger") %>Messenger.<%= action %>("<%= sample_number %>").message
10
+ assert_match "<%= class_name.delete_suffix("Messenger") %>Messenger#<%= action %>", message.body
11
+
12
+ assert_sms_delivered(to: "<%= sample_number %>", action: :<%= action %>) do
13
+ <%= class_name.delete_suffix("Messenger") %>Messenger.<%= action %>("<%= sample_number %>").deliver_later
14
+ end
15
+ end
16
+ <% end -%>
17
+ end
18
+ <% end -%>
@@ -0,0 +1 @@
1
+ <%%= @greeting %>, this is <%= class_name.delete_suffix("Messenger") %>Messenger#<%= @action %>.
@@ -0,0 +1,36 @@
1
+ require "rails/generators/named_base"
2
+
3
+ module Smswire
4
+ module Generators
5
+ # bin/rails generate smswire:provider Acme
6
+ class ProviderGenerator < ::Rails::Generators::NamedBase
7
+ source_root File.expand_path("templates", __dir__)
8
+
9
+ desc "Create a Smswire provider adapter skeleton and register it."
10
+
11
+ def create_provider
12
+ template "provider.rb", File.join("app/sms_providers", class_path, "#{file_name}_provider.rb")
13
+ end
14
+
15
+ def create_test
16
+ template "test.rb", File.join("test/sms_providers", class_path, "#{file_name}_provider_test.rb")
17
+ end
18
+
19
+ def register_provider
20
+ initializer = File.join(destination_root, "config/initializers/smswire.rb")
21
+ line = %(\nSmswire::Providers.register(:#{file_name}, "#{provider_class}")\n)
22
+ if File.exist?(initializer)
23
+ append_to_file "config/initializers/smswire.rb", line
24
+ else
25
+ say "Register the adapter in an initializer:#{line}"
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def provider_class
32
+ "#{class_name}Provider"
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,52 @@
1
+ # Smswire adapter for <%= class_name %>.
2
+ #
3
+ # Options come from Smswire.config.providers[:<%= file_name %>].
4
+ # Raise Smswire::TransientError for network failures and 5xx responses,
5
+ # Smswire::ThrottledError (with retry_after) for rate limiting, and
6
+ # Smswire::PermanentError (with a reason) when retrying cannot help.
7
+ # test/sms_providers/<%= file_name %>_provider_test.rb runs the shared
8
+ # Smswire::ProviderContract against this class.
9
+ class <%= provider_class %> < Smswire::Providers::Base
10
+ API_URL = "https://api.example.com/messages".freeze
11
+
12
+ # Replace with the provider's error codes.
13
+ PERMANENT_REASONS = {
14
+ "invalid_number" => :invalid_number,
15
+ "opted_out" => :opted_out
16
+ }.freeze
17
+
18
+ def deliver(message)
19
+ response = Smswire::HTTP.post_form(API_URL, [
20
+ ["to", message.to],
21
+ ["from", message.from.to_s],
22
+ ["body", message.body.to_s]
23
+ ], headers: {"Authorization" => "Bearer #{options.fetch(:api_key)}"}, provider: name)
24
+
25
+ status = response.code.to_i
26
+ json = Smswire::HTTP.parse_json(response.body)
27
+ code = json.dig("error", "code")&.to_s
28
+ error_options = {provider: name, provider_code: code, http_status: status, raw: json}
29
+
30
+ case status
31
+ when 200..299
32
+ receipt(provider_id: json["id"], status: :accepted, segments: message.segments, raw: json)
33
+ when 401, 403
34
+ raise Smswire::PermanentError.new("<%= class_name %> authentication failed", reason: :authentication, **error_options)
35
+ when 429
36
+ raise Smswire::ThrottledError.new("<%= class_name %> rate limited", retry_after: response["Retry-After"]&.to_i, **error_options)
37
+ when 500..599
38
+ raise Smswire::TransientError.new("<%= class_name %> #{status}", **error_options)
39
+ else
40
+ raise Smswire::PermanentError.new("<%= class_name %> #{status}: #{code}",
41
+ reason: PERMANENT_REASONS.fetch(code, :rejected), **error_options)
42
+ end
43
+ end
44
+
45
+ # Add :status_callbacks and :inbound once verify_signature!,
46
+ # parse_status_callback, and parse_inbound are implemented; the contract
47
+ # then also checks them through contract_status_request and
48
+ # contract_inbound_request.
49
+ def capabilities
50
+ Set.new
51
+ end
52
+ end