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,157 @@
|
|
|
1
|
+
module Smswire
|
|
2
|
+
# One outbound SMS, from claim through provider status callbacks.
|
|
3
|
+
#
|
|
4
|
+
# Status lifecycle: pending, then queued / accepted / sent from the
|
|
5
|
+
# provider, then delivered, undelivered, or failed, and optionally read.
|
|
6
|
+
# suppressed means an interceptor cancelled it. Status only moves forward,
|
|
7
|
+
# so out-of-order callbacks are ignored.
|
|
8
|
+
class Delivery < ApplicationRecord
|
|
9
|
+
STATUSES = %w[pending suppressed queued accepted sent delivered undelivered failed read].freeze
|
|
10
|
+
PROGRESS = {
|
|
11
|
+
"pending" => 0, "suppressed" => 0, "queued" => 1, "accepted" => 2, "sent" => 3,
|
|
12
|
+
"delivered" => 4, "undelivered" => 4, "failed" => 4, "read" => 5
|
|
13
|
+
}.freeze
|
|
14
|
+
RECLAIMABLE = %w[pending failed suppressed].freeze
|
|
15
|
+
TERMINAL_FAILURES = %w[undelivered failed].freeze
|
|
16
|
+
|
|
17
|
+
# A claim older than this is treated as abandoned by a crashed worker.
|
|
18
|
+
CLAIM_TIMEOUT = 2.minutes
|
|
19
|
+
|
|
20
|
+
enum :status, STATUSES.index_with(&:itself)
|
|
21
|
+
|
|
22
|
+
belongs_to :recipient, polymorphic: true, optional: true
|
|
23
|
+
|
|
24
|
+
scope :recent, -> { order(created_at: :desc) }
|
|
25
|
+
scope :for_number, ->(number) { where(to_number: number) }
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
# Create or reuse the delivery row for +message+ and claim it for
|
|
29
|
+
# sending. Returns nil when an identical message was already sent
|
|
30
|
+
# inside the dedupe window, or another worker holds the claim.
|
|
31
|
+
def claim_for(message, provider:)
|
|
32
|
+
key = Smswire.config.dedupe_window ? message.idempotency_key : nil
|
|
33
|
+
|
|
34
|
+
if key && (existing = find_by(idempotency_key: key))
|
|
35
|
+
if existing.created_at < Smswire.config.dedupe_window.ago
|
|
36
|
+
where(id: existing.id, idempotency_key: key).update_all(idempotency_key: nil)
|
|
37
|
+
else
|
|
38
|
+
return existing.claim
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
create!(attributes_for(message, provider:).merge(
|
|
43
|
+
idempotency_key: key, status: "pending", attempts: 1, claimed_at: Time.current
|
|
44
|
+
))
|
|
45
|
+
rescue ActiveRecord::RecordNotUnique
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Apply a provider status callback. Looks up by delivery id when the
|
|
50
|
+
# callback URL carried one, falling back to the provider message id.
|
|
51
|
+
def apply_status_update(update, provider:, delivery_id: nil)
|
|
52
|
+
delivery = find_by(id: delivery_id, provider: provider.to_s) if delivery_id.present?
|
|
53
|
+
if delivery.nil? || (delivery.provider_id.present? && delivery.provider_id != update.provider_id)
|
|
54
|
+
delivery = find_by(provider: provider.to_s, provider_id: update.provider_id)
|
|
55
|
+
end
|
|
56
|
+
delivery&.advance!(update.status, provider_id: update.provider_id,
|
|
57
|
+
error_code: update.error_code, error_message: update.error_message)
|
|
58
|
+
delivery
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def attributes_for(message, provider:)
|
|
62
|
+
record = message.recipient&.record
|
|
63
|
+
{
|
|
64
|
+
messenger: message.messenger,
|
|
65
|
+
action: message.action,
|
|
66
|
+
to_number: message.to,
|
|
67
|
+
from_number: message.from,
|
|
68
|
+
messaging_service: message.messaging_service,
|
|
69
|
+
recipient: (record if record.is_a?(ActiveRecord::Base) && record.persisted?),
|
|
70
|
+
category: message.category.to_s,
|
|
71
|
+
body: (message.body if Smswire.config.store_body?(message.category)),
|
|
72
|
+
segments: message.segments,
|
|
73
|
+
encoding: message.encoding.to_s,
|
|
74
|
+
provider: provider.to_s,
|
|
75
|
+
metadata: message.metadata.presence
|
|
76
|
+
}
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Atomically claim a reusable row for another send attempt.
|
|
81
|
+
def claim
|
|
82
|
+
return nil unless RECLAIMABLE.include?(status)
|
|
83
|
+
|
|
84
|
+
now = Time.current
|
|
85
|
+
claimed = self.class
|
|
86
|
+
.where(id:, status: RECLAIMABLE)
|
|
87
|
+
.where("claimed_at IS NULL OR claimed_at < ?", now - CLAIM_TIMEOUT)
|
|
88
|
+
.update_all(["claimed_at = ?, attempts = attempts + 1, status = 'pending', " \
|
|
89
|
+
"error_code = NULL, error_message = NULL, failed_at = NULL, updated_at = ?", now, now])
|
|
90
|
+
reload if claimed == 1
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def record_receipt!(receipt, message)
|
|
94
|
+
now = Time.current
|
|
95
|
+
with_lock do
|
|
96
|
+
assign_attributes(final_message_attributes(message))
|
|
97
|
+
assign_attributes(provider_id: receipt.provider_id, segments: receipt.segments || message.segments,
|
|
98
|
+
price_amount: receipt.price_amount, price_currency: receipt.price_currency,
|
|
99
|
+
sent_at: now, claimed_at: nil)
|
|
100
|
+
apply_status(receipt.status.to_s, now) if progresses_to?(receipt.status)
|
|
101
|
+
save!
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def record_failure!(error, message)
|
|
106
|
+
update!(final_message_attributes(message).merge(
|
|
107
|
+
status: "failed", error_code: error.provider_code, error_message: error.message,
|
|
108
|
+
failed_at: Time.current, status_updated_at: Time.current, claimed_at: nil
|
|
109
|
+
))
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Release the claim after a transient error so a retry can reclaim it.
|
|
113
|
+
def release!(error)
|
|
114
|
+
update!(claimed_at: nil, error_code: error.provider_code, error_message: error.message)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def record_suppression!(message)
|
|
118
|
+
update!(final_message_attributes(message).merge(status: "suppressed", claimed_at: nil, status_updated_at: Time.current))
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def advance!(new_status, provider_id: nil, error_code: nil, error_message: nil)
|
|
122
|
+
with_lock do
|
|
123
|
+
self.provider_id ||= provider_id
|
|
124
|
+
if progresses_to?(new_status)
|
|
125
|
+
apply_status(new_status.to_s, Time.current)
|
|
126
|
+
self.error_code = error_code if error_code.present?
|
|
127
|
+
self.error_message = error_message if error_message.present?
|
|
128
|
+
end
|
|
129
|
+
save!
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def progresses_to?(new_status)
|
|
134
|
+
PROGRESS.fetch(new_status.to_s) > PROGRESS.fetch(status)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
private
|
|
138
|
+
|
|
139
|
+
def apply_status(new_status, at)
|
|
140
|
+
self.status = new_status
|
|
141
|
+
self.status_updated_at = at
|
|
142
|
+
self.delivered_at = at if new_status == "delivered"
|
|
143
|
+
self.failed_at = at if TERMINAL_FAILURES.include?(new_status)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def final_message_attributes(message)
|
|
147
|
+
{
|
|
148
|
+
to_number: message.to,
|
|
149
|
+
from_number: message.from,
|
|
150
|
+
messaging_service: message.messaging_service,
|
|
151
|
+
body: (message.body if Smswire.config.store_body?(message.category)),
|
|
152
|
+
segments: message.segments,
|
|
153
|
+
encoding: message.encoding.to_s
|
|
154
|
+
}
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module Smswire
|
|
2
|
+
# A message received from a provider webhook. Webhook retries for the
|
|
3
|
+
# same provider message id are recorded once.
|
|
4
|
+
class InboundMessage < ApplicationRecord
|
|
5
|
+
KEYWORDS = %w[stop start help].freeze
|
|
6
|
+
|
|
7
|
+
scope :recent, -> { order(created_at: :desc) }
|
|
8
|
+
|
|
9
|
+
# Returns the new record, or nil when this provider message id was
|
|
10
|
+
# already received.
|
|
11
|
+
def self.receive(inbound, provider:)
|
|
12
|
+
create!(
|
|
13
|
+
provider: provider.to_s,
|
|
14
|
+
provider_id: inbound.provider_id,
|
|
15
|
+
from_number: PhoneNumber.normalize(inbound.from) || inbound.from,
|
|
16
|
+
to_number: inbound.to,
|
|
17
|
+
messaging_service: inbound.messaging_service,
|
|
18
|
+
body: inbound.body,
|
|
19
|
+
raw: inbound.raw
|
|
20
|
+
)
|
|
21
|
+
rescue ActiveRecord::RecordNotUnique
|
|
22
|
+
nil
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title><%= content_for?(:title) ? yield(:title) : "Smswire" %></title>
|
|
7
|
+
<%= csrf_meta_tags %>
|
|
8
|
+
<style>
|
|
9
|
+
:root { --bg: #f6f7f9; --panel: #fff; --text: #1d2330; --muted: #667085; --line: #e3e6ea;
|
|
10
|
+
--accent: #2f6fed; --bubble: #e9ecf1; --warn-bg: #fff4e5; --warn: #8a4b00; --ok: #137333; --bad: #b3261e; --on-accent: #fff; }
|
|
11
|
+
@media (prefers-color-scheme: dark) {
|
|
12
|
+
:root { --bg: #14161a; --panel: #1c1f25; --text: #e6e8eb; --muted: #9aa3af; --line: #2c313a;
|
|
13
|
+
--accent: #7aa2ff; --bubble: #2a2f38; --warn-bg: #3a2a12; --warn: #ffcc80; --ok: #81c995; --bad: #f28b82; --on-accent: #10131a; }
|
|
14
|
+
}
|
|
15
|
+
* { box-sizing: border-box; }
|
|
16
|
+
body { margin: 0; background: var(--bg); color: var(--text); font: 15px/1.5 system-ui, -apple-system, sans-serif; }
|
|
17
|
+
header { display: flex; gap: 20px; align-items: baseline; padding: 14px 24px; border-bottom: 1px solid var(--line); background: var(--panel); }
|
|
18
|
+
header strong { font-size: 17px; }
|
|
19
|
+
header a { color: var(--muted); text-decoration: none; }
|
|
20
|
+
header a.active, header a:hover { color: var(--accent); }
|
|
21
|
+
main { max-width: 1040px; margin: 0 auto; padding: 24px 16px 48px; }
|
|
22
|
+
h1 { font-size: 22px; margin: 0 0 16px; }
|
|
23
|
+
h2 { font-size: 16px; margin: 28px 0 10px; }
|
|
24
|
+
a { color: var(--accent); }
|
|
25
|
+
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 16px; }
|
|
26
|
+
.muted { color: var(--muted); }
|
|
27
|
+
.flash { padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; background: var(--warn-bg); color: var(--warn); }
|
|
28
|
+
table { width: 100%; border-collapse: collapse; }
|
|
29
|
+
th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--line); vertical-align: top; }
|
|
30
|
+
th { font-weight: 600; color: var(--muted); font-size: 13px; }
|
|
31
|
+
.table-wrap { overflow-x: auto; }
|
|
32
|
+
.table-wrap table { min-width: 680px; }
|
|
33
|
+
.preview { display: grid; grid-template-columns: minmax(0, 340px) minmax(0, 1fr); gap: 24px; align-items: start; }
|
|
34
|
+
@media (max-width: 720px) { .preview { grid-template-columns: 1fr; } }
|
|
35
|
+
.phone { background: var(--panel); border: 1px solid var(--line); border-radius: 28px; padding: 24px 16px; min-height: 320px; }
|
|
36
|
+
.bubble { background: var(--bubble); border-radius: 18px 18px 18px 4px; padding: 10px 14px; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
37
|
+
.warn { background: var(--warn-bg); color: var(--warn); border-radius: 8px; padding: 10px 14px; margin-top: 16px; }
|
|
38
|
+
.status { font-weight: 600; }
|
|
39
|
+
.status-delivered, .status-accepted, .status-queued, .status-sent, .status-read { color: var(--ok); }
|
|
40
|
+
.status-failed, .status-undelivered { color: var(--bad); }
|
|
41
|
+
.body { white-space: pre-wrap; overflow-wrap: anywhere; max-width: 420px; }
|
|
42
|
+
form.inline { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(180px, 100%), 1fr)); gap: 10px; align-items: end; }
|
|
43
|
+
label { display: grid; gap: 4px; font-size: 13px; color: var(--muted); }
|
|
44
|
+
input { font: inherit; padding: 8px 10px; border: 1px solid var(--line); border-radius: 6px; background: var(--bg); color: var(--text); }
|
|
45
|
+
button, input[type="submit"] { font: inherit; padding: 8px 14px; border: 0; border-radius: 6px; background: var(--accent); color: var(--on-accent); cursor: pointer; }
|
|
46
|
+
code { font-size: 13px; }
|
|
47
|
+
</style>
|
|
48
|
+
</head>
|
|
49
|
+
<body>
|
|
50
|
+
<header>
|
|
51
|
+
<strong>Smswire</strong>
|
|
52
|
+
<% if Smswire.config.show_previews %><%= link_to "Previews", previews_path, class: ("active" if controller_name == "previews") %><% end %>
|
|
53
|
+
<% if Smswire.config.show_inbox %><%= link_to "Inbox", inbox_path, class: ("active" if controller_name == "inbox") %><% end %>
|
|
54
|
+
</header>
|
|
55
|
+
<main>
|
|
56
|
+
<% flash.each do |_type, text| %><div class="flash"><%= text %></div><% end %>
|
|
57
|
+
<%= yield %>
|
|
58
|
+
</main>
|
|
59
|
+
</body>
|
|
60
|
+
</html>
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
<% content_for :title, "SMS inbox" %>
|
|
2
|
+
<h1>SMS inbox</h1>
|
|
3
|
+
|
|
4
|
+
<div class="panel">
|
|
5
|
+
<strong>Simulate a reply</strong>
|
|
6
|
+
<p class="muted">Runs the same handling as the inbound webhook, including STOP, START, and HELP.</p>
|
|
7
|
+
<%= form_with url: inbox_inbound_path, class: "inline" do |form| %>
|
|
8
|
+
<label>From <%= form.text_field :from, placeholder: "+15555550100" %></label>
|
|
9
|
+
<label>To <%= form.text_field :to, value: @default_to %></label>
|
|
10
|
+
<label>Body <%= form.text_field :body, placeholder: "STOP" %></label>
|
|
11
|
+
<%= form.submit "Receive" %>
|
|
12
|
+
<% end %>
|
|
13
|
+
</div>
|
|
14
|
+
|
|
15
|
+
<h2>Sent</h2>
|
|
16
|
+
<div class="panel table-wrap">
|
|
17
|
+
<% if @deliveries.empty? %>
|
|
18
|
+
<span class="muted">Nothing sent yet.</span>
|
|
19
|
+
<% else %>
|
|
20
|
+
<table>
|
|
21
|
+
<tr><th>When</th><th>To</th><th>Message</th><th>Status</th><th>Details</th></tr>
|
|
22
|
+
<% @deliveries.each do |delivery| %>
|
|
23
|
+
<tr id="delivery_<%= delivery.id %>">
|
|
24
|
+
<td><%= time_ago_in_words(delivery.created_at) %> ago</td>
|
|
25
|
+
<td><%= delivery.to_number %></td>
|
|
26
|
+
<td class="body"><%= delivery.body || content_tag(:span, "(body not stored)", class: "muted") %></td>
|
|
27
|
+
<td class="status status-<%= delivery.status %>"><%= delivery.status %></td>
|
|
28
|
+
<td class="muted">
|
|
29
|
+
<%= [delivery.messenger, delivery.action].compact.join("#") %><br>
|
|
30
|
+
<%= delivery.category %> · <%= pluralize(delivery.segments.to_i, "segment") %> · <%= delivery.provider %>
|
|
31
|
+
<% if delivery.error_message.present? %><br><%= delivery.error_code %> <%= delivery.error_message %><% end %>
|
|
32
|
+
</td>
|
|
33
|
+
</tr>
|
|
34
|
+
<% end %>
|
|
35
|
+
</table>
|
|
36
|
+
<% end %>
|
|
37
|
+
</div>
|
|
38
|
+
|
|
39
|
+
<h2>Received</h2>
|
|
40
|
+
<div class="panel table-wrap">
|
|
41
|
+
<% if @inbound_messages.empty? %>
|
|
42
|
+
<span class="muted">Nothing received yet.</span>
|
|
43
|
+
<% else %>
|
|
44
|
+
<table>
|
|
45
|
+
<tr><th>When</th><th>From</th><th>Message</th><th>Keyword</th></tr>
|
|
46
|
+
<% @inbound_messages.each do |message| %>
|
|
47
|
+
<tr>
|
|
48
|
+
<td><%= time_ago_in_words(message.created_at) %> ago</td>
|
|
49
|
+
<td><%= message.from_number %></td>
|
|
50
|
+
<td class="body"><%= message.body %></td>
|
|
51
|
+
<td><%= message.keyword&.upcase %></td>
|
|
52
|
+
</tr>
|
|
53
|
+
<% end %>
|
|
54
|
+
</table>
|
|
55
|
+
<% end %>
|
|
56
|
+
</div>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<% content_for :title, "SMS previews" %>
|
|
2
|
+
<h1>SMS previews</h1>
|
|
3
|
+
<% if @previews.empty? %>
|
|
4
|
+
<p class="muted">No previews found. Add classes ending in <code>Preview</code> under
|
|
5
|
+
<%= safe_join(Smswire.config.preview_paths.map { |path| content_tag(:code, path) }, " or ") %>,
|
|
6
|
+
or run <code>bin/rails generate smswire:messenger</code>.</p>
|
|
7
|
+
<% end %>
|
|
8
|
+
<% @previews.each do |preview| %>
|
|
9
|
+
<h2><%= preview.preview_name.camelize %></h2>
|
|
10
|
+
<div class="panel">
|
|
11
|
+
<% if preview.messages.empty? %>
|
|
12
|
+
<span class="muted">No public methods.</span>
|
|
13
|
+
<% else %>
|
|
14
|
+
<ul>
|
|
15
|
+
<% preview.messages.each do |name| %>
|
|
16
|
+
<li><%= link_to name, preview_path(path: "#{preview.preview_name}/#{name}") %></li>
|
|
17
|
+
<% end %>
|
|
18
|
+
</ul>
|
|
19
|
+
<% end %>
|
|
20
|
+
</div>
|
|
21
|
+
<% end %>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<% content_for :title, "#{@preview.preview_name.camelize}##{@message_name}" %>
|
|
2
|
+
<p><%= link_to "All previews", previews_path %></p>
|
|
3
|
+
<h1><%= @preview.preview_name.camelize %>#<%= @message_name %></h1>
|
|
4
|
+
|
|
5
|
+
<% if @message.nil? %>
|
|
6
|
+
<div class="panel muted">This action returned without calling <code>text</code>, so nothing would be sent.</div>
|
|
7
|
+
<% else %>
|
|
8
|
+
<div class="preview">
|
|
9
|
+
<div class="phone">
|
|
10
|
+
<div class="bubble"><%= @message.body %></div>
|
|
11
|
+
<% @message.media_urls.each do |url| %>
|
|
12
|
+
<p class="muted">Media: <%= link_to url, url %></p>
|
|
13
|
+
<% end %>
|
|
14
|
+
</div>
|
|
15
|
+
<div class="panel">
|
|
16
|
+
<table>
|
|
17
|
+
<tr><th>To</th><td><%= @message.to %></td></tr>
|
|
18
|
+
<tr><th>From</th><td><%= @message.from || @message.messaging_service || "(default)" %></td></tr>
|
|
19
|
+
<tr><th>Category</th><td><%= @message.category %></td></tr>
|
|
20
|
+
<tr><th>Provider</th><td><%= @message.provider || Smswire.config.default_provider %></td></tr>
|
|
21
|
+
<tr><th>Encoding</th><td data-encoding><%= @analysis.encoding_label %></td></tr>
|
|
22
|
+
<tr><th>Segments</th><td data-segments><%= pluralize(@analysis.segments, "segment") %></td></tr>
|
|
23
|
+
<tr><th>Length</th><td><%= @analysis.units %> units, <%= @analysis.remaining %> left in this segment</td></tr>
|
|
24
|
+
</table>
|
|
25
|
+
<p><%= link_to "Raw text", preview_path(path: params[:path], raw: 1) %></p>
|
|
26
|
+
</div>
|
|
27
|
+
</div>
|
|
28
|
+
<% if @non_gsm.any? %>
|
|
29
|
+
<div class="warn" data-non-gsm>
|
|
30
|
+
These characters switch the message to UCS-2, which fits 70 units per segment instead of 160:
|
|
31
|
+
<code><%= @non_gsm.join(" ") %></code>
|
|
32
|
+
</div>
|
|
33
|
+
<% end %>
|
|
34
|
+
<% end %>
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Smswire::Engine.routes.draw do
|
|
2
|
+
post "status/:provider", to: "status_callbacks#create", as: :status_callback
|
|
3
|
+
post "inbound/:provider", to: "inbound_messages#create", as: :inbound_message
|
|
4
|
+
|
|
5
|
+
# Development tools; each answers 404 unless enabled.
|
|
6
|
+
get "previews", to: "previews#index", as: :previews
|
|
7
|
+
get "previews/*path", to: "previews#show", as: :preview, format: false
|
|
8
|
+
get "inbox", to: "inbox#index", as: :inbox
|
|
9
|
+
post "inbox/inbound", to: "inbox#receive", as: :inbox_inbound
|
|
10
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
class CreateSmswireDeliveries < ActiveRecord::Migration[7.1]
|
|
2
|
+
def change
|
|
3
|
+
primary_key_type, foreign_key_type = primary_and_foreign_key_types
|
|
4
|
+
|
|
5
|
+
create_table :smswire_deliveries, id: primary_key_type do |t|
|
|
6
|
+
t.string :messenger
|
|
7
|
+
t.string :action
|
|
8
|
+
t.string :to_number, null: false
|
|
9
|
+
t.string :from_number
|
|
10
|
+
t.string :messaging_service
|
|
11
|
+
t.references :recipient, polymorphic: true, type: foreign_key_type, index: true
|
|
12
|
+
t.string :category, null: false
|
|
13
|
+
t.text :body
|
|
14
|
+
t.integer :segments
|
|
15
|
+
t.string :encoding
|
|
16
|
+
t.string :provider, null: false
|
|
17
|
+
t.string :provider_id
|
|
18
|
+
t.string :status, null: false, default: "pending"
|
|
19
|
+
t.string :error_code
|
|
20
|
+
t.text :error_message
|
|
21
|
+
t.string :idempotency_key
|
|
22
|
+
t.decimal :price_amount, precision: 12, scale: 5
|
|
23
|
+
t.string :price_currency
|
|
24
|
+
t.json :metadata
|
|
25
|
+
t.integer :attempts, null: false, default: 0
|
|
26
|
+
t.datetime :claimed_at
|
|
27
|
+
t.datetime :sent_at
|
|
28
|
+
t.datetime :delivered_at
|
|
29
|
+
t.datetime :failed_at
|
|
30
|
+
t.datetime :status_updated_at
|
|
31
|
+
t.timestamps
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
add_index :smswire_deliveries, [:provider, :provider_id], unique: true
|
|
35
|
+
add_index :smswire_deliveries, :idempotency_key, unique: true
|
|
36
|
+
add_index :smswire_deliveries, [:to_number, :created_at]
|
|
37
|
+
add_index :smswire_deliveries, [:status, :created_at]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def primary_and_foreign_key_types
|
|
43
|
+
config = Rails.configuration.generators
|
|
44
|
+
setting = config.options[config.orm][:primary_key_type]
|
|
45
|
+
[setting || :primary_key, setting || :bigint]
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
class CreateSmswireConsentsAndInboundMessages < ActiveRecord::Migration[7.1]
|
|
2
|
+
def change
|
|
3
|
+
primary_key_type = Rails.configuration.generators.options[Rails.configuration.generators.orm][:primary_key_type]
|
|
4
|
+
|
|
5
|
+
create_table :smswire_consents, id: primary_key_type || :primary_key do |t|
|
|
6
|
+
t.string :phone, null: false
|
|
7
|
+
t.string :scope, null: false, default: "default"
|
|
8
|
+
t.string :status, null: false, default: "unknown"
|
|
9
|
+
t.string :source
|
|
10
|
+
t.datetime :opted_in_at
|
|
11
|
+
t.datetime :opted_out_at
|
|
12
|
+
t.string :last_keyword
|
|
13
|
+
t.datetime :last_keyword_at
|
|
14
|
+
t.json :metadata
|
|
15
|
+
t.timestamps
|
|
16
|
+
end
|
|
17
|
+
add_index :smswire_consents, [:phone, :scope], unique: true
|
|
18
|
+
|
|
19
|
+
create_table :smswire_inbound_messages, id: primary_key_type || :primary_key do |t|
|
|
20
|
+
t.string :provider, null: false
|
|
21
|
+
t.string :provider_id, null: false
|
|
22
|
+
t.string :from_number, null: false
|
|
23
|
+
t.string :to_number
|
|
24
|
+
t.string :messaging_service
|
|
25
|
+
t.text :body
|
|
26
|
+
t.string :keyword
|
|
27
|
+
t.datetime :handled_at
|
|
28
|
+
t.json :raw
|
|
29
|
+
t.timestamps
|
|
30
|
+
end
|
|
31
|
+
add_index :smswire_inbound_messages, [:provider, :provider_id], unique: true
|
|
32
|
+
add_index :smswire_inbound_messages, [:from_number, :created_at]
|
|
33
|
+
end
|
|
34
|
+
end
|