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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2fa9a8b61ee54a3bb0b7e0addffcf887fac34022b9f34765390fff4e6d230b3b
|
|
4
|
+
data.tar.gz: '0649a4c714cab2a26a4abf71014a4cd88d6713eef7c88aef0ce4f17c20ccb853'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 06e54b416dcb1e58be5cdd8a563ce1a1fde6022ab82fe084012be2b15bc702dd49fa90c6f5251927d92d2b3c3c4ddf8acb7b6389ffc1a4069208dd3fba25c45f
|
|
7
|
+
data.tar.gz: '095e78ad1679ec376c2f3787f1699b940190ac619226b3c16b47f7af4b3a92bfef80faeaea0d0ea830d19a6b2e09dc39a828b5aad212c418bccc0939b289ccc5'
|
data/CHANGELOG.md
CHANGED
|
@@ -1,10 +1,74 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
3
|
All notable changes to this project are documented here. The format follows
|
|
4
|
-
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow
|
|
5
|
+
[Semantic Versioning](https://semver.org/). Before 1.0, minor versions may
|
|
6
|
+
include breaking changes, which are called out here.
|
|
5
7
|
|
|
6
8
|
## [Unreleased]
|
|
7
9
|
|
|
8
|
-
## [0.
|
|
10
|
+
## [0.1.0] - 2026-10-15
|
|
9
11
|
|
|
10
|
-
|
|
12
|
+
First functional release. Requires Ruby 3.2+ and Rails 7.1 to 8.1.
|
|
13
|
+
|
|
14
|
+
### Messengers and delivery
|
|
15
|
+
|
|
16
|
+
- `Smswire::Base` messenger classes with `default`, `with(params)`, `.text.erb`
|
|
17
|
+
templates, layouts, I18n lazy lookup, URL helpers, and named senders.
|
|
18
|
+
- `Smswire::Message` with GSM-7 / UCS-2 detection and segment counting.
|
|
19
|
+
- A delivery pipeline with E.164 normalization, consent checks, quiet hours,
|
|
20
|
+
rate limits, deduplication, interceptors, and observers, returning a
|
|
21
|
+
`Smswire::Result` with a typed status.
|
|
22
|
+
- `deliver_later` through `Smswire::DeliveryJob`, retrying transient errors
|
|
23
|
+
with backoff and honoring `Retry-After`.
|
|
24
|
+
|
|
25
|
+
### Providers
|
|
26
|
+
|
|
27
|
+
- Twilio, Telnyx, and Vonage adapters over REST with no provider SDK, plus
|
|
28
|
+
`log`, `test`, and `null` providers and a registry for custom adapters.
|
|
29
|
+
- Webhook signature verification for each provider: Twilio HMAC-SHA1, Telnyx
|
|
30
|
+
Ed25519, and Vonage `md5hash` or HMAC.
|
|
31
|
+
- `Smswire::ProviderContract`, a Minitest suite every adapter passes.
|
|
32
|
+
- `Smswire.verify_credentials!` for boot checks and health endpoints.
|
|
33
|
+
|
|
34
|
+
### Persistence and tracking
|
|
35
|
+
|
|
36
|
+
- `Smswire::Engine` with `smswire:install:migrations` and tables for
|
|
37
|
+
deliveries, consents, and inbound messages, following the app's primary
|
|
38
|
+
key type.
|
|
39
|
+
- A `Smswire::Delivery` row for every send, with atomic send claims, retries
|
|
40
|
+
that reuse their row, and forward-only status from provider callbacks.
|
|
41
|
+
|
|
42
|
+
### Consent and compliance
|
|
43
|
+
|
|
44
|
+
- `Smswire::Consent` with per-sender scopes and evidence metadata.
|
|
45
|
+
- STOP / START / HELP keyword handling with replies following CTIA guidance,
|
|
46
|
+
including a `support_contact` in HELP replies.
|
|
47
|
+
- Category rules: `marketing` requires opt-in and observes 9pm to 8am quiet
|
|
48
|
+
hours in the recipient's time zone. Carrier opt-out errors record opt-outs.
|
|
49
|
+
|
|
50
|
+
### Developer experience
|
|
51
|
+
|
|
52
|
+
- Previews at `<mount>/previews` and a development inbox at `<mount>/inbox`.
|
|
53
|
+
- Generators: `smswire:install`, `smswire:messenger`, and `smswire:provider`.
|
|
54
|
+
- `Smswire::TestHelper` for Minitest and RSpec matchers in `smswire/rspec`.
|
|
55
|
+
- `Smswire::Interceptors::Allowlist` for staging.
|
|
56
|
+
- `Noticed::DeliveryMethods::Smswire` for `deliver_by :smswire`.
|
|
57
|
+
- Guides in `docs/guides`.
|
|
58
|
+
|
|
59
|
+
### Known limitations
|
|
60
|
+
|
|
61
|
+
- Free-text opt-outs such as "please stop texting me" are stored but not
|
|
62
|
+
acted on; the compliance guide shows how to review them.
|
|
63
|
+
- Concurrent sends can briefly exceed a rate limit.
|
|
64
|
+
- Vonage receipts for the second and later parts of a long message are not
|
|
65
|
+
matched to the delivery, and Vonage has no MMS.
|
|
66
|
+
- Telnyx ignores `validity_period`.
|
|
67
|
+
|
|
68
|
+
## [0.0.1.pre] - 2026-10-08
|
|
69
|
+
|
|
70
|
+
- Reserve the gem name. No functionality.
|
|
71
|
+
|
|
72
|
+
[Unreleased]: https://github.com/timimsms/smswire/compare/v0.1.0...HEAD
|
|
73
|
+
[0.1.0]: https://github.com/timimsms/smswire/compare/v0.0.1.pre...v0.1.0
|
|
74
|
+
[0.0.1.pre]: https://github.com/timimsms/smswire/releases/tag/v0.0.1.pre
|
data/README.md
CHANGED
|
@@ -3,18 +3,231 @@
|
|
|
3
3
|
> The production SMS layer for Rails that Noticed and twilio-ruby both leave to you.
|
|
4
4
|
|
|
5
5
|
Smswire gives Rails applications Action Mailer-style messenger classes and
|
|
6
|
-
templates for SMS, a provider-agnostic message object,
|
|
7
|
-
with
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
templates for SMS, a provider-agnostic message object, a delivery pipeline
|
|
7
|
+
with typed outcomes, retries with a transient-versus-permanent error
|
|
8
|
+
taxonomy, and test helpers. Persisted deliveries with status callbacks,
|
|
9
|
+
consent and STOP / HELP / START handling, quiet hours, previews, and a
|
|
10
|
+
[Noticed](https://github.com/excid3/noticed) delivery method are included.
|
|
11
|
+
See the [specification](docs/SPEC.md) for the full design.
|
|
10
12
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
that do not need a notification inbox.
|
|
13
|
+
**Status:** `0.1.0`, the first functional release. The API may still change
|
|
14
|
+
before 1.0; breaking changes are called out in the [changelog](CHANGELOG.md).
|
|
14
15
|
|
|
15
|
-
|
|
16
|
+
## Guides
|
|
16
17
|
|
|
17
|
-
- [
|
|
18
|
+
- [Getting started](docs/guides/getting-started.md)
|
|
19
|
+
- [Migrating from direct twilio-ruby calls](docs/guides/migrating-from-twilio-ruby.md)
|
|
20
|
+
- [Migrating from Noticed's Twilio delivery method](docs/guides/migrating-from-noticed-twilio.md)
|
|
21
|
+
- [Compliance primer](docs/guides/compliance.md)
|
|
22
|
+
- [Writing a provider adapter](docs/guides/writing-a-provider.md)
|
|
23
|
+
|
|
24
|
+
## Requirements
|
|
25
|
+
|
|
26
|
+
Ruby 3.2+ and Rails 7.1 through 8.1.
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
# Gemfile
|
|
32
|
+
gem "smswire"
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
bin/rails generate smswire:install
|
|
37
|
+
bin/rails db:migrate
|
|
38
|
+
bin/rails generate smswire:messenger Order shipped
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The install generator copies the migrations, writes
|
|
42
|
+
`config/initializers/smswire.rb` and `ApplicationMessenger`, and mounts the
|
|
43
|
+
engine at `/smswire`. The messenger generator writes the class, a template per
|
|
44
|
+
action, a preview, and a test.
|
|
45
|
+
|
|
46
|
+
## Usage
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
# config/initializers/smswire.rb
|
|
50
|
+
Smswire.configure do |config|
|
|
51
|
+
config.default_provider = :twilio # :log in development and :test in test by default
|
|
52
|
+
config.providers = {twilio: {account_sid: "AC...", auth_token: "..."}} # or credentials.twilio
|
|
53
|
+
config.senders = {transactional: {number: "+15005550006"}}
|
|
54
|
+
end
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
# app/messengers/order_messenger.rb
|
|
59
|
+
class OrderMessenger < Smswire::Base
|
|
60
|
+
default from: :transactional
|
|
61
|
+
|
|
62
|
+
def shipped(user)
|
|
63
|
+
@order = params[:order]
|
|
64
|
+
text to: user # any object with #phone_number, or an E.164 string
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
```erb
|
|
70
|
+
<%# app/views/order_messenger/shipped.text.erb %>
|
|
71
|
+
<%= t(".greeting", name: @order.customer_name) %> Order <%= @order.number %> has shipped.
|
|
72
|
+
Track it: <%= order_url(@order) %>
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
OrderMessenger.with(order:).shipped(user).deliver_later
|
|
77
|
+
result = OrderMessenger.with(order:).shipped(user).deliver_now
|
|
78
|
+
result.status # => :accepted, :failed, :rejected_invalid_number, ...
|
|
79
|
+
result.provider_id # => "SM..."
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Every message is normalized to E.164, checked for an empty body, passed
|
|
83
|
+
through interceptors, sent, and reported to observers and
|
|
84
|
+
`ActiveSupport::Notifications` (`deliver.smswire`, `reject.smswire`). Bodies
|
|
85
|
+
are never instrumented or logged by provider adapters.
|
|
86
|
+
|
|
87
|
+
### Deliveries and status callbacks
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
bin/rails smswire:install:migrations
|
|
91
|
+
bin/rails db:migrate
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
# config/routes.rb
|
|
96
|
+
mount Smswire::Engine => "/smswire"
|
|
97
|
+
|
|
98
|
+
# config/initializers/smswire.rb
|
|
99
|
+
config.callbacks_url = "https://app.example.com/smswire"
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Every send is recorded in `Smswire::Delivery` with the provider message id,
|
|
103
|
+
segments, price, and status. With `callbacks_url` set, Twilio posts status
|
|
104
|
+
updates to the engine, which verifies the signature and moves the row
|
|
105
|
+
forward: `pending`, `queued`, `sent`, `delivered` (or `undelivered` /
|
|
106
|
+
`failed`). Out-of-order callbacks never move a status backwards.
|
|
107
|
+
|
|
108
|
+
Identical messages to the same number inside `dedupe_window` (10 minutes by
|
|
109
|
+
default) are refused with status `:duplicate`. Pass `idempotency_key:` to
|
|
110
|
+
`text` to choose what counts as identical. Bodies are stored unless the
|
|
111
|
+
category opts out; `otp` does by default.
|
|
112
|
+
|
|
113
|
+
```ruby
|
|
114
|
+
result = OrderMessenger.with(order:).shipped(user).deliver_now
|
|
115
|
+
result.delivery.status # => "accepted"
|
|
116
|
+
Smswire::Delivery.for_number("+15551234567").recent
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Consent, keywords, quiet hours, and rate limits
|
|
120
|
+
|
|
121
|
+
Point your Twilio number's incoming message webhook at
|
|
122
|
+
`https://app.example.com/smswire/inbound/twilio`. Replies of STOP, START,
|
|
123
|
+
and HELP (and their synonyms) update `Smswire::Consent` and send the
|
|
124
|
+
configured confirmation. Record opt-ins from your own forms with evidence:
|
|
125
|
+
|
|
126
|
+
```ruby
|
|
127
|
+
Smswire::Consent.opt_in!(user.phone, source: :web_form,
|
|
128
|
+
metadata: {ip: request.remote_ip, form: "checkout", copy: terms_text})
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Each message has a category. The shipped rules are:
|
|
132
|
+
|
|
133
|
+
| Category | Consent | Quiet hours |
|
|
134
|
+
|---|---|---|
|
|
135
|
+
| `transactional` (default) | send unless opted out | none |
|
|
136
|
+
| `otp` | send unless opted out; body not stored | none |
|
|
137
|
+
| `marketing` | requires opt-in | 9pm to 8am recipient time |
|
|
138
|
+
| `compliance` | not checked; keyword replies | none |
|
|
139
|
+
|
|
140
|
+
Override any key, and add rate limits, in `config.categories`. Messages in
|
|
141
|
+
quiet hours or over a rate limit are re-enqueued for when they may send and
|
|
142
|
+
return `:deferred_quiet_hours` or `:deferred_rate_limited`. A carrier
|
|
143
|
+
opt-out error from the provider records an opt-out automatically.
|
|
144
|
+
|
|
145
|
+
Smswire provides these mechanisms. Your app is still responsible for its
|
|
146
|
+
messaging program's legal compliance.
|
|
147
|
+
|
|
148
|
+
### Development tools
|
|
149
|
+
|
|
150
|
+
In development, open `/smswire/previews` to see every preview rendered in a
|
|
151
|
+
phone frame with its encoding, segment count, and remaining characters, and
|
|
152
|
+
which characters force UCS-2. Open `/smswire/inbox` to see what was sent and
|
|
153
|
+
received, and to simulate a reply such as STOP.
|
|
154
|
+
|
|
155
|
+
```ruby
|
|
156
|
+
# test/messengers/previews/order_messenger_preview.rb
|
|
157
|
+
class OrderMessengerPreview < Smswire::Preview
|
|
158
|
+
def shipped
|
|
159
|
+
OrderMessenger.with(order: Order.first).shipped(User.first)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
For staging, only let messages through to known numbers:
|
|
165
|
+
|
|
166
|
+
```ruby
|
|
167
|
+
Smswire.register_interceptor(
|
|
168
|
+
Smswire::Interceptors::Allowlist.new(numbers: ENV.fetch("SMS_ALLOWLIST", "").split(","))
|
|
169
|
+
# or redirect_to: "+15555550100" to reroute everything else to one phone
|
|
170
|
+
)
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### Providers
|
|
174
|
+
|
|
175
|
+
| Name | Use |
|
|
176
|
+
|---|---|
|
|
177
|
+
| `:twilio` | Twilio Programmable Messaging over REST, no SDK |
|
|
178
|
+
| `:telnyx` | Telnyx Messaging API v2; `api_key`, `public_key` for webhooks |
|
|
179
|
+
| `:vonage` | Vonage SMS API; `api_key`, `api_secret`, `signature_secret` |
|
|
180
|
+
| `:log` | Writes messages to the logger; development default |
|
|
181
|
+
| `:test` | Records messages in memory; test default |
|
|
182
|
+
| `:null` | Accepts and discards |
|
|
183
|
+
|
|
184
|
+
Each adapter passes `Smswire::ProviderContract`, a test suite shipped in the
|
|
185
|
+
gem. Generate your own with `bin/rails generate smswire:provider Acme`, which
|
|
186
|
+
writes an adapter and a test that runs the contract against it, or
|
|
187
|
+
subclass `Smswire::Providers::Base` and call
|
|
188
|
+
`Smswire::Providers.register(:acme, "AcmeProvider")`.
|
|
189
|
+
|
|
190
|
+
### Noticed
|
|
191
|
+
|
|
192
|
+
```ruby
|
|
193
|
+
class OrderShippedNotifier < Noticed::Event
|
|
194
|
+
deliver_by :smswire do |config|
|
|
195
|
+
config.messenger = "OrderMessenger"
|
|
196
|
+
config.action = :shipped # called with the recipient by default
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
The messenger gets the event params plus `notification`, `record`, and
|
|
202
|
+
`recipient`, so consent, quiet hours, deduplication, retries, and status
|
|
203
|
+
tracking all apply. Each delivery stores the Noticed notification and event
|
|
204
|
+
ids in its metadata.
|
|
205
|
+
|
|
206
|
+
### Testing
|
|
207
|
+
|
|
208
|
+
```ruby
|
|
209
|
+
class OrderTest < ActiveSupport::TestCase
|
|
210
|
+
include Smswire::TestHelper
|
|
211
|
+
|
|
212
|
+
test "ships" do
|
|
213
|
+
assert_sms_delivered(to: "+15551234567", messenger: OrderMessenger, body: /shipped/) do
|
|
214
|
+
order.ship! # deliver_later jobs inside the block are performed
|
|
215
|
+
end
|
|
216
|
+
assert_enqueued_sms(OrderMessenger, :shipped) { order.ship! }
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
RSpec: `require "smswire/rspec"` for `deliver_sms(...)` and
|
|
222
|
+
`have_enqueued_sms(OrderMessenger, :shipped)`.
|
|
223
|
+
|
|
224
|
+
## Development
|
|
225
|
+
|
|
226
|
+
```
|
|
227
|
+
bin/setup
|
|
228
|
+
bundle exec rake # tests and Standard
|
|
229
|
+
RAILS_VERSION=7.1 bundle exec rake test
|
|
230
|
+
```
|
|
18
231
|
|
|
19
232
|
## License
|
|
20
233
|
|
data/RELEASING.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Releasing
|
|
2
|
+
|
|
3
|
+
Releases publish to RubyGems from GitHub Actions with
|
|
4
|
+
[trusted publishing](https://guides.rubygems.org/trusted-publishing/), so no
|
|
5
|
+
API key or one-time code is stored anywhere. The gem requires MFA for
|
|
6
|
+
manual pushes.
|
|
7
|
+
|
|
8
|
+
## One-time setup
|
|
9
|
+
|
|
10
|
+
1. On rubygems.org, open the `smswire` gem, then **Trusted publishers**, and
|
|
11
|
+
add a GitHub Actions publisher:
|
|
12
|
+
- Repository: `timimsms/smswire`
|
|
13
|
+
- Workflow filename: `release.yml`
|
|
14
|
+
- Environment: `rubygems`
|
|
15
|
+
2. In the GitHub repository settings, create an environment named
|
|
16
|
+
`rubygems`. Add required reviewers if releases should need approval.
|
|
17
|
+
|
|
18
|
+
## Before 1.0
|
|
19
|
+
|
|
20
|
+
- Confirm open decisions 4 to 6 in `docs/SPEC.md`.
|
|
21
|
+
- Review the known limitations in `CHANGELOG.md` and `docs/SPEC.md`.
|
|
22
|
+
- Try the getting-started guide in a new Rails app against a real provider
|
|
23
|
+
account, including a STOP reply and a delivery status callback.
|
|
24
|
+
|
|
25
|
+
## Each release
|
|
26
|
+
|
|
27
|
+
1. Open a pull request that:
|
|
28
|
+
- updates `lib/smswire/version.rb`;
|
|
29
|
+
- in `CHANGELOG.md`, moves the `[Unreleased]` entries under a new
|
|
30
|
+
`[X.Y.Z] - YYYY-MM-DD` heading, leaves `[Unreleased]` empty, and updates
|
|
31
|
+
the compare links at the bottom.
|
|
32
|
+
2. Merge it once CI is green.
|
|
33
|
+
3. In GitHub, open **Actions > Release > Run workflow** on `main`.
|
|
34
|
+
4. The workflow runs the tests, then `rake release` builds the gem, creates
|
|
35
|
+
and pushes the `vX.Y.Z` tag, and publishes with trusted publishing. It
|
|
36
|
+
waits until the version is available on RubyGems.
|
|
37
|
+
5. Create a GitHub release from the new tag with the changelog section:
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
gh release create vX.Y.Z --notes-file <(sed -n '/^## \[X.Y.Z\]/,/^## \[/p' CHANGELOG.md | sed '$d')
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
If the workflow fails before publishing, fix the problem and run it again;
|
|
44
|
+
`rake release` skips tagging when the tag already exists.
|
|
45
|
+
|
|
46
|
+
## If a release goes wrong
|
|
47
|
+
|
|
48
|
+
`gem yank smswire -v X.Y.Z` removes a version from installs, but the version
|
|
49
|
+
number cannot be reused. Fix forward with a patch release.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module Smswire
|
|
2
|
+
# Base for the development tools. Each subclass names the config flag
|
|
3
|
+
# that enables it; when the flag is off the tool answers 404.
|
|
4
|
+
class DevelopmentController < ActionController::Base
|
|
5
|
+
layout "smswire/application"
|
|
6
|
+
|
|
7
|
+
class_attribute :enabled_by
|
|
8
|
+
|
|
9
|
+
before_action :require_enabled
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def require_enabled
|
|
14
|
+
head :not_found unless Smswire.config.public_send(enabled_by)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
module Smswire
|
|
2
|
+
# POST <mount>/inbound/:provider. Apps can build two-way messaging with
|
|
3
|
+
# an observer that implements +sms_received(inbound_message)+.
|
|
4
|
+
class InboundMessagesController < WebhooksController
|
|
5
|
+
def create
|
|
6
|
+
handle_inbound || handle_status || head(:no_content)
|
|
7
|
+
end
|
|
8
|
+
end
|
|
9
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
require "securerandom"
|
|
2
|
+
|
|
3
|
+
module Smswire
|
|
4
|
+
# Development inbox: recent deliveries and inbound messages, plus a form
|
|
5
|
+
# to simulate a reply such as STOP without a real provider.
|
|
6
|
+
class InboxController < DevelopmentController
|
|
7
|
+
self.enabled_by = :show_inbox
|
|
8
|
+
|
|
9
|
+
LIMIT = 50
|
|
10
|
+
|
|
11
|
+
def index
|
|
12
|
+
@deliveries = Delivery.recent.limit(LIMIT)
|
|
13
|
+
@inbound_messages = InboundMessage.recent.limit(LIMIT)
|
|
14
|
+
@default_to = Smswire.config.senders.values.filter_map { |sender| sender.to_h.symbolize_keys[:number] }.first
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def receive
|
|
18
|
+
from = PhoneNumber.normalize(params[:from])
|
|
19
|
+
if from.nil? || params[:body].blank?
|
|
20
|
+
flash[:alert] = "Enter a valid From number and a message body."
|
|
21
|
+
return redirect_to(inbox_path)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
inbound = Inbound.new(provider_id: "inbox-#{SecureRandom.hex(8)}", from:, to: params[:to].presence, body: params[:body])
|
|
25
|
+
record = InboundHandler.call(inbound, provider: Smswire.config.default_provider || :log)
|
|
26
|
+
keyword = record&.reload&.keyword
|
|
27
|
+
flash[:notice] = keyword ? "Received from #{from}; handled keyword #{keyword.upcase}." : "Received from #{from}."
|
|
28
|
+
redirect_to inbox_path
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module Smswire
|
|
2
|
+
class PreviewsController < DevelopmentController
|
|
3
|
+
self.enabled_by = :show_previews
|
|
4
|
+
|
|
5
|
+
def index
|
|
6
|
+
@previews = Preview.all
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def show
|
|
10
|
+
preview_name, _, message_name = params[:path].rpartition("/")
|
|
11
|
+
@preview = Preview.find(preview_name)
|
|
12
|
+
return head(:not_found) unless @preview&.message_exists?(message_name)
|
|
13
|
+
|
|
14
|
+
@message_name = message_name
|
|
15
|
+
@message = @preview.call(message_name)
|
|
16
|
+
return render(plain: @message&.body.to_s) if params[:raw].present?
|
|
17
|
+
|
|
18
|
+
@analysis = @message && Segments.analyze(@message.body)
|
|
19
|
+
@non_gsm = @message ? Segments.non_gsm_characters(@message.body) : []
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
module Smswire
|
|
2
|
+
# Shared provider webhook handling. Each endpoint tries its own kind of
|
|
3
|
+
# event first and falls back to the other, so a provider that posts every
|
|
4
|
+
# event to one URL (Telnyx messaging profiles do) is handled either way.
|
|
5
|
+
# Signed, well-formed requests always get a 2xx so providers do not retry.
|
|
6
|
+
class WebhooksController < ActionController::Base
|
|
7
|
+
skip_forgery_protection
|
|
8
|
+
|
|
9
|
+
before_action :load_provider
|
|
10
|
+
before_action :verify_signature
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def load_provider
|
|
15
|
+
@provider = Providers.resolve(params[:provider])
|
|
16
|
+
head(:not_found) unless (@provider.capabilities & %i[status_callbacks inbound]).any?
|
|
17
|
+
rescue ConfigurationError
|
|
18
|
+
head :not_found
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def verify_signature
|
|
22
|
+
return unless Smswire.config.verify_callback_signatures
|
|
23
|
+
|
|
24
|
+
@provider.verify_signature!(request, url: Callbacks.request_url(request))
|
|
25
|
+
rescue SignatureError => error
|
|
26
|
+
Smswire.logger.warn("[Smswire] Rejected #{@provider.name} webhook: #{error.message}")
|
|
27
|
+
head :forbidden
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def handle_status
|
|
31
|
+
return false unless @provider.capabilities.include?(:status_callbacks)
|
|
32
|
+
|
|
33
|
+
update = @provider.parse_status_callback(request) or return false
|
|
34
|
+
StatusHandler.call(update, provider: @provider.name, delivery_id: params[:delivery_id])
|
|
35
|
+
head :no_content
|
|
36
|
+
true
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def handle_inbound
|
|
40
|
+
return false unless @provider.capabilities.include?(:inbound)
|
|
41
|
+
|
|
42
|
+
inbound = @provider.parse_inbound(request) or return false
|
|
43
|
+
InboundHandler.call(inbound, provider: @provider.name)
|
|
44
|
+
body, content_type = @provider.inbound_response
|
|
45
|
+
body ? render(plain: body, content_type:) : head(:no_content)
|
|
46
|
+
true
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
module Smswire
|
|
2
|
+
# A phone number's messaging consent within a scope. The scope is
|
|
3
|
+
# "default" unless a sender sets +consent_scope+, which lets separate
|
|
4
|
+
# programs (for example two brands on two numbers) keep separate consent.
|
|
5
|
+
#
|
|
6
|
+
# Smswire::Consent.opt_in!("+15551234567", source: :web_form,
|
|
7
|
+
# metadata: {ip: request.remote_ip, form: "checkout", copy: terms_text})
|
|
8
|
+
# Smswire::Consent.status_for("+15551234567") # => "opted_in"
|
|
9
|
+
class Consent < ApplicationRecord
|
|
10
|
+
DEFAULT_SCOPE = "default".freeze
|
|
11
|
+
STATUSES = %w[opted_in opted_out pending unknown].freeze
|
|
12
|
+
|
|
13
|
+
enum :status, STATUSES.index_with(&:itself)
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
def status_for(phone, scope: DEFAULT_SCOPE)
|
|
17
|
+
find_by(phone: normalize!(phone), scope: scope.to_s)&.status || "unknown"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def opt_in!(phone, source:, scope: DEFAULT_SCOPE, keyword: nil, metadata: {})
|
|
21
|
+
record!(phone, scope, {status: "opted_in", opted_in_at: Time.current}, source:, keyword:, metadata:)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def opt_out!(phone, source:, scope: DEFAULT_SCOPE, keyword: nil, metadata: {})
|
|
25
|
+
record!(phone, scope, {status: "opted_out", opted_out_at: Time.current}, source:, keyword:, metadata:)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# STOPALL: opt out of every scope this number has, plus every scope
|
|
29
|
+
# the configured senders use.
|
|
30
|
+
def opt_out_everywhere!(phone, source:, keyword: nil)
|
|
31
|
+
number = normalize!(phone)
|
|
32
|
+
scopes = (where(phone: number).pluck(:scope) + Smswire.config.consent_scopes).uniq
|
|
33
|
+
scopes.map { |scope| opt_out!(number, scope:, source:, keyword:) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The consent scope of the configured sender that owns +number+ or
|
|
37
|
+
# +messaging_service+, used for inbound keywords.
|
|
38
|
+
def scope_for_sender(number: nil, messaging_service: nil)
|
|
39
|
+
Smswire.config.senders.each_value do |options|
|
|
40
|
+
options = options.to_h.symbolize_keys
|
|
41
|
+
matched = (number.present? && options[:number] == number) ||
|
|
42
|
+
(messaging_service.present? && options[:messaging_service] == messaging_service)
|
|
43
|
+
return (options[:consent_scope] || DEFAULT_SCOPE).to_s if matched
|
|
44
|
+
end
|
|
45
|
+
DEFAULT_SCOPE
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def normalize!(phone)
|
|
51
|
+
PhoneNumber.normalize(phone) || raise(ArgumentError, "Invalid phone number: #{phone.inspect}")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def record!(phone, scope, attributes, source:, keyword:, metadata:)
|
|
55
|
+
retried = false
|
|
56
|
+
begin
|
|
57
|
+
consent = find_or_initialize_by(phone: normalize!(phone), scope: scope.to_s)
|
|
58
|
+
consent.assign_attributes(attributes.merge(source: source.to_s))
|
|
59
|
+
consent.assign_attributes(last_keyword: keyword, last_keyword_at: Time.current) if keyword
|
|
60
|
+
consent.metadata = (consent.metadata || {}).merge(metadata.deep_stringify_keys) if metadata.present?
|
|
61
|
+
consent.save!
|
|
62
|
+
consent
|
|
63
|
+
rescue ActiveRecord::RecordNotUnique
|
|
64
|
+
raise if retried
|
|
65
|
+
retried = true
|
|
66
|
+
retry
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|