sms_opener 0.1.1
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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +154 -0
- data/Rakefile +8 -0
- data/app/controllers/sms_opener/messages_controller.rb +105 -0
- data/app/views/sms_opener/messages/index.html.erb +431 -0
- data/config/routes.rb +14 -0
- data/lib/sms_opener/configuration.rb +29 -0
- data/lib/sms_opener/delivery_method.rb +121 -0
- data/lib/sms_opener/engine.rb +9 -0
- data/lib/sms_opener/interceptors/twilio_interceptor.rb +118 -0
- data/lib/sms_opener/message.rb +236 -0
- data/lib/sms_opener/railtie.rb +23 -0
- data/lib/sms_opener/templates/sms.html.erb +350 -0
- data/lib/sms_opener/version.rb +5 -0
- data/lib/sms_opener.rb +46 -0
- metadata +212 -0
|
@@ -0,0 +1,431 @@
|
|
|
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.0">
|
|
6
|
+
<title>SmsOpener — Development SMS Inbox (<%= @messages.size %>)</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
--bg: #0b1120;
|
|
10
|
+
--sidebar-bg: #0f172a;
|
|
11
|
+
--card-bg: #1e293b;
|
|
12
|
+
--border: #1e293b;
|
|
13
|
+
--border-light: #334155;
|
|
14
|
+
--text: #f8fafc;
|
|
15
|
+
--muted: #94a3b8;
|
|
16
|
+
--accent: #38bdf8;
|
|
17
|
+
--primary: #2563eb;
|
|
18
|
+
--danger: #ef4444;
|
|
19
|
+
--success: #10b981;
|
|
20
|
+
}
|
|
21
|
+
* { box-sizing: border-box; }
|
|
22
|
+
body {
|
|
23
|
+
margin: 0;
|
|
24
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
25
|
+
background: var(--bg);
|
|
26
|
+
color: var(--text);
|
|
27
|
+
height: 100vh;
|
|
28
|
+
display: flex;
|
|
29
|
+
flex-direction: column;
|
|
30
|
+
overflow: hidden;
|
|
31
|
+
}
|
|
32
|
+
/* Top Navbar */
|
|
33
|
+
.navbar {
|
|
34
|
+
height: 58px;
|
|
35
|
+
background: var(--sidebar-bg);
|
|
36
|
+
border-bottom: 1px solid var(--border-light);
|
|
37
|
+
padding: 0 20px;
|
|
38
|
+
display: flex;
|
|
39
|
+
align-items: center;
|
|
40
|
+
justify-content: space-between;
|
|
41
|
+
flex-shrink: 0;
|
|
42
|
+
}
|
|
43
|
+
.brand {
|
|
44
|
+
display: flex;
|
|
45
|
+
align-items: center;
|
|
46
|
+
gap: 10px;
|
|
47
|
+
font-weight: 700;
|
|
48
|
+
font-size: 16px;
|
|
49
|
+
color: var(--text);
|
|
50
|
+
text-decoration: none;
|
|
51
|
+
}
|
|
52
|
+
.brand-icon {
|
|
53
|
+
width: 30px;
|
|
54
|
+
height: 30px;
|
|
55
|
+
border-radius: 8px;
|
|
56
|
+
background: linear-gradient(135deg, #2563eb, #38bdf8);
|
|
57
|
+
display: inline-flex;
|
|
58
|
+
align-items: center;
|
|
59
|
+
justify-content: center;
|
|
60
|
+
font-size: 14px;
|
|
61
|
+
font-weight: 800;
|
|
62
|
+
color: #fff;
|
|
63
|
+
}
|
|
64
|
+
.count-pill {
|
|
65
|
+
background: rgba(56, 189, 248, 0.16);
|
|
66
|
+
color: var(--accent);
|
|
67
|
+
font-size: 12px;
|
|
68
|
+
padding: 2px 9px;
|
|
69
|
+
border-radius: 999px;
|
|
70
|
+
font-weight: 600;
|
|
71
|
+
}
|
|
72
|
+
.nav-actions {
|
|
73
|
+
display: flex;
|
|
74
|
+
align-items: center;
|
|
75
|
+
gap: 10px;
|
|
76
|
+
}
|
|
77
|
+
.btn {
|
|
78
|
+
display: inline-flex;
|
|
79
|
+
align-items: center;
|
|
80
|
+
gap: 6px;
|
|
81
|
+
padding: 7px 13px;
|
|
82
|
+
border-radius: 8px;
|
|
83
|
+
font-size: 13px;
|
|
84
|
+
font-weight: 600;
|
|
85
|
+
border: 1px solid var(--border-light);
|
|
86
|
+
background: var(--card-bg);
|
|
87
|
+
color: var(--text);
|
|
88
|
+
cursor: pointer;
|
|
89
|
+
text-decoration: none;
|
|
90
|
+
transition: all 0.15s ease;
|
|
91
|
+
}
|
|
92
|
+
.btn:hover { border-color: var(--accent); }
|
|
93
|
+
.btn-primary {
|
|
94
|
+
background: var(--primary);
|
|
95
|
+
border-color: var(--primary);
|
|
96
|
+
color: #fff;
|
|
97
|
+
}
|
|
98
|
+
.btn-primary:hover { opacity: 0.92; border-color: var(--accent); }
|
|
99
|
+
.btn-danger {
|
|
100
|
+
background: rgba(239, 68, 68, 0.12);
|
|
101
|
+
border-color: rgba(239, 68, 68, 0.35);
|
|
102
|
+
color: #fca5a5;
|
|
103
|
+
}
|
|
104
|
+
.btn-danger:hover {
|
|
105
|
+
background: rgba(239, 68, 68, 0.22);
|
|
106
|
+
border-color: var(--danger);
|
|
107
|
+
}
|
|
108
|
+
/* Main Workspace */
|
|
109
|
+
.workspace {
|
|
110
|
+
flex: 1;
|
|
111
|
+
display: grid;
|
|
112
|
+
grid-template-columns: 360px 1fr;
|
|
113
|
+
min-height: 0;
|
|
114
|
+
}
|
|
115
|
+
.sidebar {
|
|
116
|
+
background: var(--sidebar-bg);
|
|
117
|
+
border-right: 1px solid var(--border-light);
|
|
118
|
+
display: flex;
|
|
119
|
+
flex-direction: column;
|
|
120
|
+
min-height: 0;
|
|
121
|
+
}
|
|
122
|
+
.search-box {
|
|
123
|
+
padding: 12px 14px;
|
|
124
|
+
border-bottom: 1px solid var(--border-light);
|
|
125
|
+
}
|
|
126
|
+
.search-input {
|
|
127
|
+
width: 100%;
|
|
128
|
+
padding: 8px 12px;
|
|
129
|
+
border-radius: 8px;
|
|
130
|
+
border: 1px solid var(--border-light);
|
|
131
|
+
background: #0b1120;
|
|
132
|
+
color: var(--text);
|
|
133
|
+
font-size: 13px;
|
|
134
|
+
outline: none;
|
|
135
|
+
}
|
|
136
|
+
.search-input:focus { border-color: var(--accent); }
|
|
137
|
+
.message-list {
|
|
138
|
+
flex: 1;
|
|
139
|
+
overflow-y: auto;
|
|
140
|
+
list-style: none;
|
|
141
|
+
margin: 0;
|
|
142
|
+
padding: 0;
|
|
143
|
+
}
|
|
144
|
+
.message-item {
|
|
145
|
+
display: block;
|
|
146
|
+
padding: 14px 16px;
|
|
147
|
+
border-bottom: 1px solid rgba(51, 65, 85, 0.5);
|
|
148
|
+
text-decoration: none;
|
|
149
|
+
color: inherit;
|
|
150
|
+
transition: background 0.12s;
|
|
151
|
+
}
|
|
152
|
+
.message-item:hover {
|
|
153
|
+
background: rgba(30, 41, 59, 0.6);
|
|
154
|
+
}
|
|
155
|
+
.message-item.active {
|
|
156
|
+
background: rgba(37, 99, 235, 0.18);
|
|
157
|
+
border-left: 3px solid var(--accent);
|
|
158
|
+
}
|
|
159
|
+
.item-top {
|
|
160
|
+
display: flex;
|
|
161
|
+
justify-content: space-between;
|
|
162
|
+
align-items: center;
|
|
163
|
+
margin-bottom: 4px;
|
|
164
|
+
}
|
|
165
|
+
.item-to {
|
|
166
|
+
font-weight: 600;
|
|
167
|
+
font-size: 13.5px;
|
|
168
|
+
color: var(--text);
|
|
169
|
+
}
|
|
170
|
+
.item-time {
|
|
171
|
+
font-size: 11px;
|
|
172
|
+
color: var(--muted);
|
|
173
|
+
}
|
|
174
|
+
.item-body {
|
|
175
|
+
font-size: 13px;
|
|
176
|
+
color: #cbd5e1;
|
|
177
|
+
white-space: nowrap;
|
|
178
|
+
overflow: hidden;
|
|
179
|
+
text-overflow: ellipsis;
|
|
180
|
+
margin-bottom: 8px;
|
|
181
|
+
}
|
|
182
|
+
.item-tags {
|
|
183
|
+
display: flex;
|
|
184
|
+
align-items: center;
|
|
185
|
+
gap: 6px;
|
|
186
|
+
flex-wrap: wrap;
|
|
187
|
+
}
|
|
188
|
+
.tag {
|
|
189
|
+
font-size: 10.5px;
|
|
190
|
+
font-weight: 600;
|
|
191
|
+
padding: 2px 7px;
|
|
192
|
+
border-radius: 6px;
|
|
193
|
+
background: rgba(148, 163, 184, 0.14);
|
|
194
|
+
color: var(--muted);
|
|
195
|
+
text-transform: uppercase;
|
|
196
|
+
}
|
|
197
|
+
.tag-otp {
|
|
198
|
+
background: rgba(16, 185, 129, 0.18);
|
|
199
|
+
color: #34d399;
|
|
200
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
201
|
+
}
|
|
202
|
+
/* Detail Pane */
|
|
203
|
+
.detail-pane {
|
|
204
|
+
display: flex;
|
|
205
|
+
flex-direction: column;
|
|
206
|
+
min-height: 0;
|
|
207
|
+
background: var(--bg);
|
|
208
|
+
}
|
|
209
|
+
.detail-toolbar {
|
|
210
|
+
padding: 12px 20px;
|
|
211
|
+
background: rgba(15, 23, 42, 0.75);
|
|
212
|
+
border-bottom: 1px solid var(--border-light);
|
|
213
|
+
display: flex;
|
|
214
|
+
align-items: center;
|
|
215
|
+
justify-content: space-between;
|
|
216
|
+
}
|
|
217
|
+
.detail-frame {
|
|
218
|
+
flex: 1;
|
|
219
|
+
width: 100%;
|
|
220
|
+
border: none;
|
|
221
|
+
background: var(--bg);
|
|
222
|
+
}
|
|
223
|
+
.empty-state {
|
|
224
|
+
flex: 1;
|
|
225
|
+
display: flex;
|
|
226
|
+
flex-direction: column;
|
|
227
|
+
align-items: center;
|
|
228
|
+
justify-content: center;
|
|
229
|
+
padding: 40px;
|
|
230
|
+
text-align: center;
|
|
231
|
+
color: var(--muted);
|
|
232
|
+
}
|
|
233
|
+
.empty-state h2 {
|
|
234
|
+
color: var(--text);
|
|
235
|
+
margin: 12px 0 6px;
|
|
236
|
+
font-size: 18px;
|
|
237
|
+
}
|
|
238
|
+
.empty-state p {
|
|
239
|
+
max-width: 420px;
|
|
240
|
+
font-size: 14px;
|
|
241
|
+
line-height: 1.5;
|
|
242
|
+
margin: 0 0 20px;
|
|
243
|
+
}
|
|
244
|
+
/* Modal */
|
|
245
|
+
.modal-backdrop {
|
|
246
|
+
display: none;
|
|
247
|
+
position: fixed;
|
|
248
|
+
inset: 0;
|
|
249
|
+
background: rgba(0, 0, 0, 0.65);
|
|
250
|
+
align-items: center;
|
|
251
|
+
justify-content: center;
|
|
252
|
+
z-index: 50;
|
|
253
|
+
}
|
|
254
|
+
.modal-backdrop.open { display: flex; }
|
|
255
|
+
.modal {
|
|
256
|
+
background: var(--sidebar-bg);
|
|
257
|
+
border: 1px solid var(--border-light);
|
|
258
|
+
border-radius: 14px;
|
|
259
|
+
width: 100%;
|
|
260
|
+
max-width: 460px;
|
|
261
|
+
padding: 22px;
|
|
262
|
+
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5);
|
|
263
|
+
}
|
|
264
|
+
.modal h3 { margin: 0 0 14px; font-size: 16px; }
|
|
265
|
+
.form-group { margin-bottom: 14px; }
|
|
266
|
+
.form-group label {
|
|
267
|
+
display: block;
|
|
268
|
+
font-size: 12px;
|
|
269
|
+
font-weight: 600;
|
|
270
|
+
color: var(--muted);
|
|
271
|
+
margin-bottom: 5px;
|
|
272
|
+
}
|
|
273
|
+
.form-control {
|
|
274
|
+
width: 100%;
|
|
275
|
+
padding: 9px 12px;
|
|
276
|
+
border-radius: 8px;
|
|
277
|
+
border: 1px solid var(--border-light);
|
|
278
|
+
background: #0b1120;
|
|
279
|
+
color: var(--text);
|
|
280
|
+
font-size: 13.5px;
|
|
281
|
+
font-family: inherit;
|
|
282
|
+
}
|
|
283
|
+
.preset-bar {
|
|
284
|
+
display: flex;
|
|
285
|
+
gap: 6px;
|
|
286
|
+
margin-bottom: 14px;
|
|
287
|
+
flex-wrap: wrap;
|
|
288
|
+
}
|
|
289
|
+
.preset-btn {
|
|
290
|
+
font-size: 11px;
|
|
291
|
+
padding: 4px 8px;
|
|
292
|
+
border-radius: 6px;
|
|
293
|
+
border: 1px solid var(--border-light);
|
|
294
|
+
background: var(--card-bg);
|
|
295
|
+
color: var(--accent);
|
|
296
|
+
cursor: pointer;
|
|
297
|
+
}
|
|
298
|
+
</style>
|
|
299
|
+
</head>
|
|
300
|
+
<body>
|
|
301
|
+
<header class="navbar">
|
|
302
|
+
<a href="<%= root_path %>" class="brand">
|
|
303
|
+
<span class="brand-icon">SMS</span>
|
|
304
|
+
<span>SmsOpener</span>
|
|
305
|
+
<span class="count-pill"><%= @messages.size %></span>
|
|
306
|
+
</a>
|
|
307
|
+
<div class="nav-actions">
|
|
308
|
+
<button type="button" class="btn btn-primary" onclick="document.getElementById('test-sms-modal').classList.add('open')">
|
|
309
|
+
+ Send Test SMS
|
|
310
|
+
</button>
|
|
311
|
+
<a href="<%= root_path %>" class="btn">Refresh</a>
|
|
312
|
+
<% unless @messages.empty? -%>
|
|
313
|
+
<form action="<%= clear_messages_path %>" method="post" style="margin:0;" onsubmit="return confirm('Clear all captured SMS messages?');">
|
|
314
|
+
<button type="submit" class="btn btn-danger">Clear All</button>
|
|
315
|
+
</form>
|
|
316
|
+
<% end -%>
|
|
317
|
+
</div>
|
|
318
|
+
</header>
|
|
319
|
+
|
|
320
|
+
<% if @messages.empty? -%>
|
|
321
|
+
<div class="empty-state">
|
|
322
|
+
<div class="brand-icon" style="width:48px;height:48px;font-size:18px;border-radius:14px;">SMS</div>
|
|
323
|
+
<h2>No SMS messages captured yet</h2>
|
|
324
|
+
<p>
|
|
325
|
+
Any SMS sent via <code>Twilio::REST::Client</code> or <code>SmsOpener.deliver</code> in development will appear here automatically.
|
|
326
|
+
</p>
|
|
327
|
+
<button type="button" class="btn btn-primary" onclick="document.getElementById('test-sms-modal').classList.add('open')">
|
|
328
|
+
Send a Sample 2FA Text
|
|
329
|
+
</button>
|
|
330
|
+
</div>
|
|
331
|
+
<% else -%>
|
|
332
|
+
<div class="workspace">
|
|
333
|
+
<aside class="sidebar">
|
|
334
|
+
<div class="search-box">
|
|
335
|
+
<input type="text" id="sms-search" class="search-input" placeholder="Filter by phone, body, or OTP..." oninput="filterMessages(this.value)">
|
|
336
|
+
</div>
|
|
337
|
+
<div class="message-list" id="message-list">
|
|
338
|
+
<% @messages.each do |msg| -%>
|
|
339
|
+
<% is_active = @selected_message && @selected_message.id == msg.id -%>
|
|
340
|
+
<a href="<%= root_path(id: msg.id) %>"
|
|
341
|
+
class="message-item <%= 'active' if is_active %>"
|
|
342
|
+
data-search="<%= CGI.escapeHTML("#{msg.to_display} #{msg.from} #{msg.body} #{msg.otp_code}".downcase) %>">
|
|
343
|
+
<div class="item-top">
|
|
344
|
+
<span class="item-to"><%= CGI.escapeHTML(msg.to_display) %></span>
|
|
345
|
+
<span class="item-time"><%= msg.created_at.strftime("%H:%M:%S") %></span>
|
|
346
|
+
</div>
|
|
347
|
+
<div class="item-body"><%= CGI.escapeHTML(msg.body) %></div>
|
|
348
|
+
<div class="item-tags">
|
|
349
|
+
<span class="tag"><%= CGI.escapeHTML(msg.provider) %></span>
|
|
350
|
+
<span class="tag"><%= msg.segments %> seg</span>
|
|
351
|
+
<% if msg.otp_code -%>
|
|
352
|
+
<span class="tag tag-otp">OTP: <%= CGI.escapeHTML(msg.otp_code) %></span>
|
|
353
|
+
<% end -%>
|
|
354
|
+
</div>
|
|
355
|
+
</a>
|
|
356
|
+
<% end -%>
|
|
357
|
+
</div>
|
|
358
|
+
</aside>
|
|
359
|
+
|
|
360
|
+
<main class="detail-pane">
|
|
361
|
+
<% if @selected_message -%>
|
|
362
|
+
<div class="detail-toolbar">
|
|
363
|
+
<div>
|
|
364
|
+
<strong>To:</strong> <%= CGI.escapeHTML(@selected_message.to_display) %>
|
|
365
|
+
<span style="color:var(--muted); margin: 0 8px;">|</span>
|
|
366
|
+
<strong>From:</strong> <%= CGI.escapeHTML(@selected_message.from) %>
|
|
367
|
+
</div>
|
|
368
|
+
<div style="display:flex; gap:8px; align-items:center;">
|
|
369
|
+
<% if @selected_message.otp_code -%>
|
|
370
|
+
<button type="button" class="btn" style="border-color:#10b981;color:#34d399;"
|
|
371
|
+
onclick="navigator.clipboard.writeText('<%= CGI.escapeHTML(@selected_message.otp_code) %>'); this.textContent='Copied <%= CGI.escapeHTML(@selected_message.otp_code) %>!';">
|
|
372
|
+
Copy OTP (<%= CGI.escapeHTML(@selected_message.otp_code) %>)
|
|
373
|
+
</button>
|
|
374
|
+
<% end -%>
|
|
375
|
+
<a href="<%= message_path(@selected_message.id) %>" target="_blank" class="btn">Open Standalone</a>
|
|
376
|
+
<form action="<%= delete_message_path(@selected_message.id) %>" method="post" style="margin:0;">
|
|
377
|
+
<button type="submit" class="btn btn-danger">Delete</button>
|
|
378
|
+
</form>
|
|
379
|
+
</div>
|
|
380
|
+
</div>
|
|
381
|
+
<iframe class="detail-frame" src="<%= message_path(@selected_message.id) %>" title="SMS Preview"></iframe>
|
|
382
|
+
<% end -%>
|
|
383
|
+
</main>
|
|
384
|
+
</div>
|
|
385
|
+
<% end -%>
|
|
386
|
+
|
|
387
|
+
<!-- Send Test SMS Modal -->
|
|
388
|
+
<div class="modal-backdrop" id="test-sms-modal" onclick="if(event.target===this)this.classList.remove('open')">
|
|
389
|
+
<div class="modal">
|
|
390
|
+
<h3>Send Test SMS</h3>
|
|
391
|
+
<div class="preset-bar">
|
|
392
|
+
<button type="button" class="preset-btn" onclick="fillPreset('Your verification code is 849201. Valid for 10 minutes.')">2FA OTP Preset</button>
|
|
393
|
+
<button type="button" class="preset-btn" onclick="fillPreset('Sign in to your account: https://localhost:3000/session/magic?token=xyz987')">Magic Link Preset</button>
|
|
394
|
+
<button type="button" class="preset-btn" onclick="fillPreset('Your order #4821 has shipped! 📦 Track at https://localhost:3000/orders/4821')">Unicode MMS/Emoji</button>
|
|
395
|
+
</div>
|
|
396
|
+
<form action="<%= messages_path %>" method="post">
|
|
397
|
+
<div class="form-group">
|
|
398
|
+
<label for="sms-to">To Phone Number</label>
|
|
399
|
+
<input type="text" id="sms-to" name="to" class="form-control" value="+15558675309" required>
|
|
400
|
+
</div>
|
|
401
|
+
<div class="form-group">
|
|
402
|
+
<label for="sms-from">From Phone Number / Sender ID</label>
|
|
403
|
+
<input type="text" id="sms-from" name="from" class="form-control" value="<%= CGI.escapeHTML(SmsOpener.configuration.default_from) %>">
|
|
404
|
+
</div>
|
|
405
|
+
<div class="form-group">
|
|
406
|
+
<label for="sms-body-input">Message Body</label>
|
|
407
|
+
<textarea id="sms-body-input" name="body" rows="4" class="form-control" required>Your verification code is 849201. Valid for 10 minutes.</textarea>
|
|
408
|
+
</div>
|
|
409
|
+
<div style="display:flex; justify-content:flex-end; gap:8px;">
|
|
410
|
+
<button type="button" class="btn" onclick="document.getElementById('test-sms-modal').classList.remove('open')">Cancel</button>
|
|
411
|
+
<button type="submit" class="btn btn-primary">Deliver SMS</button>
|
|
412
|
+
</div>
|
|
413
|
+
</form>
|
|
414
|
+
</div>
|
|
415
|
+
</div>
|
|
416
|
+
|
|
417
|
+
<script>
|
|
418
|
+
function filterMessages(query) {
|
|
419
|
+
var normalized = (query || "").toLowerCase().trim();
|
|
420
|
+
var items = document.querySelectorAll("#message-list .message-item");
|
|
421
|
+
items.forEach(function(item) {
|
|
422
|
+
var text = item.getAttribute("data-search") || "";
|
|
423
|
+
item.style.display = (!normalized || text.indexOf(normalized) !== -1) ? "block" : "none";
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
function fillPreset(bodyText) {
|
|
427
|
+
document.getElementById("sms-body-input").value = bodyText;
|
|
428
|
+
}
|
|
429
|
+
</script>
|
|
430
|
+
</body>
|
|
431
|
+
</html>
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
SmsOpener::Engine.routes.draw do
|
|
4
|
+
root to: "messages#index"
|
|
5
|
+
|
|
6
|
+
resources :messages, only: %i[index show create destroy] do
|
|
7
|
+
collection do
|
|
8
|
+
match :clear, via: %i[post delete]
|
|
9
|
+
end
|
|
10
|
+
member do
|
|
11
|
+
post :delete, action: :destroy
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SmsOpener
|
|
4
|
+
class Configuration
|
|
5
|
+
attr_accessor :location, :launch_browser, :intercept_twilio, :default_from, :file_uri_scheme
|
|
6
|
+
|
|
7
|
+
def initialize
|
|
8
|
+
@location = default_location
|
|
9
|
+
@launch_browser = ENV.fetch("SMS_OPENER_LAUNCH_BROWSER", "true") != "false"
|
|
10
|
+
@intercept_twilio = true
|
|
11
|
+
@default_from = "+15550000000"
|
|
12
|
+
@file_uri_scheme = nil
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def resolved_location
|
|
16
|
+
@location || default_location
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def default_location
|
|
22
|
+
if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
|
23
|
+
Rails.root.join("tmp", "sms_opener").to_s
|
|
24
|
+
else
|
|
25
|
+
File.join(Dir.pwd, "tmp", "sms_opener")
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "launchy"
|
|
4
|
+
|
|
5
|
+
module SmsOpener
|
|
6
|
+
class DeliveryMethod
|
|
7
|
+
class InvalidOption < StandardError; end
|
|
8
|
+
|
|
9
|
+
attr_reader :settings
|
|
10
|
+
|
|
11
|
+
def initialize(options = {})
|
|
12
|
+
@settings = {
|
|
13
|
+
location: SmsOpener.configuration.resolved_location,
|
|
14
|
+
launch_browser: SmsOpener.configuration.launch_browser
|
|
15
|
+
}.merge(options)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def deliver!(message_or_attributes = nil, **kwargs)
|
|
19
|
+
message = build_message(message_or_attributes, kwargs)
|
|
20
|
+
|
|
21
|
+
instrument_delivery(message) do
|
|
22
|
+
message.render!
|
|
23
|
+
log_delivery(message)
|
|
24
|
+
open_in_browser(message) if should_launch_browser?
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
message
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def build_message(message_or_attributes, kwargs)
|
|
33
|
+
return message_or_attributes if message_or_attributes.is_a?(SmsOpener::Message)
|
|
34
|
+
|
|
35
|
+
attrs = if message_or_attributes.is_a?(Hash)
|
|
36
|
+
symbolize_keys(message_or_attributes).merge(kwargs)
|
|
37
|
+
elsif message_or_attributes.respond_to?(:to) && message_or_attributes.respond_to?(:body)
|
|
38
|
+
{
|
|
39
|
+
to: message_or_attributes.to,
|
|
40
|
+
from: message_or_attributes.respond_to?(:from) ? message_or_attributes.from : nil,
|
|
41
|
+
body: message_or_attributes.body,
|
|
42
|
+
media_urls: message_or_attributes.respond_to?(:media_urls) ? message_or_attributes.media_urls : []
|
|
43
|
+
}.merge(kwargs)
|
|
44
|
+
else
|
|
45
|
+
kwargs
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
to = attrs.delete(:to)
|
|
49
|
+
body = attrs.delete(:body)
|
|
50
|
+
raise InvalidOption, "Both :to and :body are required to deliver an SMS" if to.nil? || body.nil?
|
|
51
|
+
|
|
52
|
+
from = attrs.delete(:from)
|
|
53
|
+
media_urls = attrs.delete(:media_urls) || attrs.delete(:media_url) || []
|
|
54
|
+
provider = attrs.delete(:provider) || "direct"
|
|
55
|
+
id = attrs.delete(:id)
|
|
56
|
+
created_at = attrs.delete(:created_at)
|
|
57
|
+
explicit_metadata = attrs.delete(:metadata) || {}
|
|
58
|
+
metadata = explicit_metadata.merge(attrs)
|
|
59
|
+
|
|
60
|
+
SmsOpener::Message.new(
|
|
61
|
+
to: to,
|
|
62
|
+
from: from,
|
|
63
|
+
body: body,
|
|
64
|
+
media_urls: media_urls,
|
|
65
|
+
provider: provider,
|
|
66
|
+
metadata: metadata,
|
|
67
|
+
id: id,
|
|
68
|
+
created_at: created_at,
|
|
69
|
+
base_location: @settings[:location]
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def should_launch_browser?
|
|
74
|
+
Boolean(@settings[:launch_browser])
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def open_in_browser(message)
|
|
78
|
+
Launchy.open(message.file_uri)
|
|
79
|
+
rescue StandardError => e
|
|
80
|
+
warn "[SmsOpener] Failed to open browser via Launchy: #{e.message} (file saved at #{message.filepath})"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def log_delivery(message)
|
|
84
|
+
return unless defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
85
|
+
|
|
86
|
+
otp_info = message.otp_code ? " [OTP: #{message.otp_code}]" : ""
|
|
87
|
+
Rails.logger.info(
|
|
88
|
+
"[SmsOpener] Captured SMS (#{message.provider}) From: #{message.from} -> To: #{message.to_display}#{otp_info} | " \
|
|
89
|
+
"Body: #{message.body.inspect} | Saved to: #{message.filepath}"
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def instrument_delivery(message, &block)
|
|
94
|
+
if defined?(ActiveSupport::Notifications)
|
|
95
|
+
ActiveSupport::Notifications.instrument(
|
|
96
|
+
"deliver.sms_opener",
|
|
97
|
+
message: message,
|
|
98
|
+
to: message.to,
|
|
99
|
+
from: message.from,
|
|
100
|
+
body: message.body,
|
|
101
|
+
provider: message.provider,
|
|
102
|
+
&block
|
|
103
|
+
)
|
|
104
|
+
else
|
|
105
|
+
yield
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def symbolize_keys(hash)
|
|
110
|
+
hash.each_with_object({}) do |(k, v), acc|
|
|
111
|
+
acc[k.to_sym] = v
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def Boolean(val) # rubocop:disable Naming/MethodName
|
|
116
|
+
return false if val.nil? || val == false || val.to_s.strip.casecmp("false").zero?
|
|
117
|
+
|
|
118
|
+
true
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|