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,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,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
|