Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,6 @@
/.rspec_status
/Gemfile.lock
.env
/tmp/
/test/tmp/
/test/dummy/tmp/
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ All notable changes to this project are documented here. The format follows

### Added

- Messenger previews at `<mount>/previews` with a phone frame, encoding,
segment count, remaining characters, and the characters that force UCS-2.
- Development inbox at `<mount>/inbox` listing deliveries and inbound
messages, with a form that simulates replies through keyword handling.
- Generators: `smswire:install`, `smswire:messenger`, and `smswire:provider`.
- `Smswire::Interceptors::Allowlist` for staging, suppressing or redirecting
messages to unlisted numbers.

- `Smswire::Consent` with per-sender consent scopes, evidence metadata, and
`opt_in!`, `opt_out!`, `opt_out_everywhere!`, and `status_for`.
- Inbound webhook endpoint storing `Smswire::InboundMessage` once per
Expand Down
50 changes: 47 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,31 @@ consent and STOP / HELP / START handling, quiet hours, previews, and a
[Noticed](https://github.com/excid3/noticed) delivery adapter follow in later
phases of the [specification](docs/SPEC.md).

**Status:** pre-release. Phases 1 to 3 of the spec are implemented; the
**Status:** pre-release. Phases 1 to 4 of the spec are implemented; the
published `0.0.1.pre` gem only reserves the name.

## Requirements

Ruby 3.2+ and Rails 7.1 through 8.1.

## Installation

```ruby
# Gemfile
gem "smswire"
```

```
bin/rails generate smswire:install
bin/rails db:migrate
bin/rails generate smswire:messenger Order shipped
```

The install generator copies the migrations, writes
`config/initializers/smswire.rb` and `ApplicationMessenger`, and mounts the
engine at `/smswire`. The messenger generator writes the class, a template per
action, a preview, and a test.

## Usage

```ruby
Expand Down Expand Up @@ -119,6 +137,31 @@ opt-out error from the provider records an opt-out automatically.
Smswire provides these mechanisms. Your app is still responsible for its
messaging program's legal compliance.

### Development tools

In development, open `/smswire/previews` to see every preview rendered in a
phone frame with its encoding, segment count, and remaining characters, and
which characters force UCS-2. Open `/smswire/inbox` to see what was sent and
received, and to simulate a reply such as STOP.

```ruby
# test/messengers/previews/order_messenger_preview.rb
class OrderMessengerPreview < Smswire::Preview
def shipped
OrderMessenger.with(order: Order.first).shipped(User.first)
end
end
```

For staging, only let messages through to known numbers:

```ruby
Smswire.register_interceptor(
Smswire::Interceptors::Allowlist.new(numbers: ENV.fetch("SMS_ALLOWLIST", "").split(","))
# or redirect_to: "+15555550100" to reroute everything else to one phone
)
```

### Providers

| Name | Use |
Expand All @@ -128,8 +171,9 @@ messaging program's legal compliance.
| `:test` | Records messages in memory; test default |
| `:null` | Accepts and discards |

Register your own with `Smswire::Providers.register(:acme, AcmeProvider)`,
subclassing `Smswire::Providers::Base`.
Generate your own with `bin/rails generate smswire:provider Acme`, or
subclass `Smswire::Providers::Base` and call
`Smswire::Providers.register(:acme, "AcmeProvider")`.

### Testing

Expand Down
17 changes: 17 additions & 0 deletions app/controllers/smswire/development_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
module Smswire
# Base for the development tools. Each subclass names the config flag
# that enables it; when the flag is off the tool answers 404.
class DevelopmentController < ActionController::Base
layout "smswire/application"

class_attribute :enabled_by

before_action :require_enabled

private

def require_enabled
head :not_found unless Smswire.config.public_send(enabled_by)
end
end
end
20 changes: 1 addition & 19 deletions app/controllers/smswire/inbound_messages_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,7 @@ def create
provider.verify_signature!(request, url: Callbacks.request_url(request))
end

inbound = provider.parse_inbound(request)
payload = {provider: provider.name, provider_id: inbound.provider_id, from: inbound.from}
ActiveSupport::Notifications.instrument("inbound.smswire", payload) do
record = InboundMessage.receive(inbound, provider: provider.name)
payload[:duplicate] = record.nil?
if record
payload[:keyword] = Keywords.handle(record, inbound, provider: provider.name) if Smswire.config.enforce_consent
notify_observers(record)
end
end
InboundHandler.call(provider.parse_inbound(request), provider: provider.name)

body, content_type = provider.inbound_response
body ? render(plain: body, content_type:) : head(:no_content)
Expand All @@ -32,14 +23,5 @@ def create
rescue SignatureError
head :forbidden
end

private

def notify_observers(record)
Smswire.config.observers.each do |observer|
observer = observer.is_a?(String) ? observer.constantize : observer
observer.sms_received(record) if observer.respond_to?(:sms_received)
end
end
end
end
31 changes: 31 additions & 0 deletions app/controllers/smswire/inbox_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
require "securerandom"

module Smswire
# Development inbox: recent deliveries and inbound messages, plus a form
# to simulate a reply such as STOP without a real provider.
class InboxController < DevelopmentController
self.enabled_by = :show_inbox

LIMIT = 50

def index
@deliveries = Delivery.recent.limit(LIMIT)
@inbound_messages = InboundMessage.recent.limit(LIMIT)
@default_to = Smswire.config.senders.values.filter_map { |sender| sender.to_h.symbolize_keys[:number] }.first
end

def receive
from = PhoneNumber.normalize(params[:from])
if from.nil? || params[:body].blank?
flash[:alert] = "Enter a valid From number and a message body."
return redirect_to(inbox_path)
end

inbound = Inbound.new(provider_id: "inbox-#{SecureRandom.hex(8)}", from:, to: params[:to].presence, body: params[:body])
record = InboundHandler.call(inbound, provider: Smswire.config.default_provider || :log)
keyword = record&.reload&.keyword
flash[:notice] = keyword ? "Received from #{from}; handled keyword #{keyword.upcase}." : "Received from #{from}."
redirect_to inbox_path
end
end
end
22 changes: 22 additions & 0 deletions app/controllers/smswire/previews_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
module Smswire
class PreviewsController < DevelopmentController
self.enabled_by = :show_previews

def index
@previews = Preview.all
end

def show
preview_name, _, message_name = params[:path].rpartition("/")
@preview = Preview.find(preview_name)
return head(:not_found) unless @preview&.message_exists?(message_name)

@message_name = message_name
@message = @preview.call(message_name)
return render(plain: @message&.body.to_s) if params[:raw].present?

@analysis = @message && Segments.analyze(@message.body)
@non_gsm = @message ? Segments.non_gsm_characters(@message.body) : []
end
end
end
60 changes: 60 additions & 0 deletions app/views/layouts/smswire/application.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><%= content_for?(:title) ? yield(:title) : "Smswire" %></title>
<%= csrf_meta_tags %>
<style>
:root { --bg: #f6f7f9; --panel: #fff; --text: #1d2330; --muted: #667085; --line: #e3e6ea;
--accent: #2f6fed; --bubble: #e9ecf1; --warn-bg: #fff4e5; --warn: #8a4b00; --ok: #137333; --bad: #b3261e; --on-accent: #fff; }
@media (prefers-color-scheme: dark) {
:root { --bg: #14161a; --panel: #1c1f25; --text: #e6e8eb; --muted: #9aa3af; --line: #2c313a;
--accent: #7aa2ff; --bubble: #2a2f38; --warn-bg: #3a2a12; --warn: #ffcc80; --ok: #81c995; --bad: #f28b82; --on-accent: #10131a; }
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--text); font: 15px/1.5 system-ui, -apple-system, sans-serif; }
header { display: flex; gap: 20px; align-items: baseline; padding: 14px 24px; border-bottom: 1px solid var(--line); background: var(--panel); }
header strong { font-size: 17px; }
header a { color: var(--muted); text-decoration: none; }
header a.active, header a:hover { color: var(--accent); }
main { max-width: 1040px; margin: 0 auto; padding: 24px 16px 48px; }
h1 { font-size: 22px; margin: 0 0 16px; }
h2 { font-size: 16px; margin: 28px 0 10px; }
a { color: var(--accent); }
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 16px; }
.muted { color: var(--muted); }
.flash { padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; background: var(--warn-bg); color: var(--warn); }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--line); vertical-align: top; }
th { font-weight: 600; color: var(--muted); font-size: 13px; }
.table-wrap { overflow-x: auto; }
.table-wrap table { min-width: 680px; }
.preview { display: grid; grid-template-columns: minmax(0, 340px) minmax(0, 1fr); gap: 24px; align-items: start; }
@media (max-width: 720px) { .preview { grid-template-columns: 1fr; } }
.phone { background: var(--panel); border: 1px solid var(--line); border-radius: 28px; padding: 24px 16px; min-height: 320px; }
.bubble { background: var(--bubble); border-radius: 18px 18px 18px 4px; padding: 10px 14px; white-space: pre-wrap; overflow-wrap: anywhere; }
.warn { background: var(--warn-bg); color: var(--warn); border-radius: 8px; padding: 10px 14px; margin-top: 16px; }
.status { font-weight: 600; }
.status-delivered, .status-accepted, .status-queued, .status-sent, .status-read { color: var(--ok); }
.status-failed, .status-undelivered { color: var(--bad); }
.body { white-space: pre-wrap; overflow-wrap: anywhere; max-width: 420px; }
form.inline { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(180px, 100%), 1fr)); gap: 10px; align-items: end; }
label { display: grid; gap: 4px; font-size: 13px; color: var(--muted); }
input { font: inherit; padding: 8px 10px; border: 1px solid var(--line); border-radius: 6px; background: var(--bg); color: var(--text); }
button, input[type="submit"] { font: inherit; padding: 8px 14px; border: 0; border-radius: 6px; background: var(--accent); color: var(--on-accent); cursor: pointer; }
code { font-size: 13px; }
</style>
</head>
<body>
<header>
<strong>Smswire</strong>
<% if Smswire.config.show_previews %><%= link_to "Previews", previews_path, class: ("active" if controller_name == "previews") %><% end %>
<% if Smswire.config.show_inbox %><%= link_to "Inbox", inbox_path, class: ("active" if controller_name == "inbox") %><% end %>
</header>
<main>
<% flash.each do |_type, text| %><div class="flash"><%= text %></div><% end %>
<%= yield %>
</main>
</body>
</html>
56 changes: 56 additions & 0 deletions app/views/smswire/inbox/index.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<% content_for :title, "SMS inbox" %>
<h1>SMS inbox</h1>

<div class="panel">
<strong>Simulate a reply</strong>
<p class="muted">Runs the same handling as the inbound webhook, including STOP, START, and HELP.</p>
<%= form_with url: inbox_inbound_path, class: "inline" do |form| %>
<label>From <%= form.text_field :from, placeholder: "+15555550100" %></label>
<label>To <%= form.text_field :to, value: @default_to %></label>
<label>Body <%= form.text_field :body, placeholder: "STOP" %></label>
<%= form.submit "Receive" %>
<% end %>
</div>

<h2>Sent</h2>
<div class="panel table-wrap">
<% if @deliveries.empty? %>
<span class="muted">Nothing sent yet.</span>
<% else %>
<table>
<tr><th>When</th><th>To</th><th>Message</th><th>Status</th><th>Details</th></tr>
<% @deliveries.each do |delivery| %>
<tr id="delivery_<%= delivery.id %>">
<td><%= time_ago_in_words(delivery.created_at) %> ago</td>
<td><%= delivery.to_number %></td>
<td class="body"><%= delivery.body || content_tag(:span, "(body not stored)", class: "muted") %></td>
<td class="status status-<%= delivery.status %>"><%= delivery.status %></td>
<td class="muted">
<%= [delivery.messenger, delivery.action].compact.join("#") %><br>
<%= delivery.category %> · <%= pluralize(delivery.segments.to_i, "segment") %> · <%= delivery.provider %>
<% if delivery.error_message.present? %><br><%= delivery.error_code %> <%= delivery.error_message %><% end %>
</td>
</tr>
<% end %>
</table>
<% end %>
</div>

<h2>Received</h2>
<div class="panel table-wrap">
<% if @inbound_messages.empty? %>
<span class="muted">Nothing received yet.</span>
<% else %>
<table>
<tr><th>When</th><th>From</th><th>Message</th><th>Keyword</th></tr>
<% @inbound_messages.each do |message| %>
<tr>
<td><%= time_ago_in_words(message.created_at) %> ago</td>
<td><%= message.from_number %></td>
<td class="body"><%= message.body %></td>
<td><%= message.keyword&.upcase %></td>
</tr>
<% end %>
</table>
<% end %>
</div>
21 changes: 21 additions & 0 deletions app/views/smswire/previews/index.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<% content_for :title, "SMS previews" %>
<h1>SMS previews</h1>
<% if @previews.empty? %>
<p class="muted">No previews found. Add classes ending in <code>Preview</code> under
<%= safe_join(Smswire.config.preview_paths.map { |path| content_tag(:code, path) }, " or ") %>,
or run <code>bin/rails generate smswire:messenger</code>.</p>
<% end %>
<% @previews.each do |preview| %>
<h2><%= preview.preview_name.camelize %></h2>
<div class="panel">
<% if preview.messages.empty? %>
<span class="muted">No public methods.</span>
<% else %>
<ul>
<% preview.messages.each do |name| %>
<li><%= link_to name, preview_path(path: "#{preview.preview_name}/#{name}") %></li>
<% end %>
</ul>
<% end %>
</div>
<% end %>
34 changes: 34 additions & 0 deletions app/views/smswire/previews/show.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<% content_for :title, "#{@preview.preview_name.camelize}##{@message_name}" %>
<p><%= link_to "All previews", previews_path %></p>
<h1><%= @preview.preview_name.camelize %>#<%= @message_name %></h1>

<% if @message.nil? %>
<div class="panel muted">This action returned without calling <code>text</code>, so nothing would be sent.</div>
<% else %>
<div class="preview">
<div class="phone">
<div class="bubble"><%= @message.body %></div>
<% @message.media_urls.each do |url| %>
<p class="muted">Media: <%= link_to url, url %></p>
<% end %>
</div>
<div class="panel">
<table>
<tr><th>To</th><td><%= @message.to %></td></tr>
<tr><th>From</th><td><%= @message.from || @message.messaging_service || "(default)" %></td></tr>
<tr><th>Category</th><td><%= @message.category %></td></tr>
<tr><th>Provider</th><td><%= @message.provider || Smswire.config.default_provider %></td></tr>
<tr><th>Encoding</th><td data-encoding><%= @analysis.encoding_label %></td></tr>
<tr><th>Segments</th><td data-segments><%= pluralize(@analysis.segments, "segment") %></td></tr>
<tr><th>Length</th><td><%= @analysis.units %> units, <%= @analysis.remaining %> left in this segment</td></tr>
</table>
<p><%= link_to "Raw text", preview_path(path: params[:path], raw: 1) %></p>
</div>
</div>
<% if @non_gsm.any? %>
<div class="warn" data-non-gsm>
These characters switch the message to UCS-2, which fits 70 units per segment instead of 160:
<code><%= @non_gsm.join(" ") %></code>
</div>
<% end %>
<% end %>
6 changes: 6 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
Smswire::Engine.routes.draw do
post "status/:provider", to: "status_callbacks#create", as: :status_callback
post "inbound/:provider", to: "inbound_messages#create", as: :inbound_message

# Development tools; each answers 404 unless enabled.
get "previews", to: "previews#index", as: :previews
get "previews/*path", to: "previews#show", as: :preview, format: false
get "inbox", to: "inbox#index", as: :inbox
post "inbox/inbound", to: "inbox#receive", as: :inbox_inbound
end
Loading
Loading