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,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module SmsOpener
|
|
7
|
+
module Interceptors
|
|
8
|
+
module TwilioInterceptor
|
|
9
|
+
FallbackMessageInstance = Struct.new(
|
|
10
|
+
:sid, :account_sid, :to, :from, :messaging_service_sid, :body,
|
|
11
|
+
:status, :direction, :num_segments, :num_media, :date_created, :date_updated,
|
|
12
|
+
keyword_init: true
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
module MessageListPatch
|
|
16
|
+
def create(to: nil, from: nil, body: nil, media_url: nil, messaging_service_sid: nil, **kwargs)
|
|
17
|
+
return super unless SmsOpener.configuration.intercept_twilio
|
|
18
|
+
|
|
19
|
+
media_list = Array(media_url).compact.map(&:to_s).reject(&:empty?)
|
|
20
|
+
sid_prefix = media_list.empty? ? "SM" : "MM"
|
|
21
|
+
sid = "#{sid_prefix}#{SecureRandom.hex(16)}"
|
|
22
|
+
|
|
23
|
+
solution = instance_variable_defined?(:@solution) ? instance_variable_get(:@solution) : {}
|
|
24
|
+
account_sid = (solution && solution[:account_sid]) || "AC00000000000000000000000000000000"
|
|
25
|
+
|
|
26
|
+
sender = from || messaging_service_sid || SmsOpener.configuration.default_from
|
|
27
|
+
effective_body = body || (kwargs[:content_sid] ? "[Twilio Content Template: #{kwargs[:content_sid]}]" : "")
|
|
28
|
+
|
|
29
|
+
extra_metadata = {
|
|
30
|
+
"sid" => sid,
|
|
31
|
+
"account_sid" => account_sid,
|
|
32
|
+
"status" => "queued",
|
|
33
|
+
"direction" => "outbound-api"
|
|
34
|
+
}
|
|
35
|
+
extra_metadata["messaging_service_sid"] = messaging_service_sid if messaging_service_sid
|
|
36
|
+
kwargs.each do |k, v|
|
|
37
|
+
extra_metadata[k.to_s] = v unless v.nil?
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
captured_message = SmsOpener.deliver(
|
|
41
|
+
to: to,
|
|
42
|
+
from: sender,
|
|
43
|
+
body: effective_body,
|
|
44
|
+
media_urls: media_list,
|
|
45
|
+
provider: "twilio",
|
|
46
|
+
metadata: extra_metadata
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
now_rfc2822 = Time.now.rfc2822
|
|
50
|
+
payload = {
|
|
51
|
+
"sid" => sid,
|
|
52
|
+
"account_sid" => account_sid,
|
|
53
|
+
"to" => captured_message.to_display,
|
|
54
|
+
"from" => sender,
|
|
55
|
+
"messaging_service_sid" => messaging_service_sid,
|
|
56
|
+
"body" => captured_message.body,
|
|
57
|
+
"status" => "queued",
|
|
58
|
+
"direction" => "outbound-api",
|
|
59
|
+
"num_segments" => captured_message.segments.to_s,
|
|
60
|
+
"num_media" => media_list.size.to_s,
|
|
61
|
+
"date_created" => now_rfc2822,
|
|
62
|
+
"date_updated" => now_rfc2822,
|
|
63
|
+
"date_sent" => now_rfc2822,
|
|
64
|
+
"error_code" => nil,
|
|
65
|
+
"error_message" => nil
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if defined?(::Twilio::REST::Api::V2010::AccountContext::MessageInstance) && instance_variable_defined?(:@version)
|
|
69
|
+
::Twilio::REST::Api::V2010::AccountContext::MessageInstance.new(
|
|
70
|
+
instance_variable_get(:@version),
|
|
71
|
+
payload,
|
|
72
|
+
account_sid: account_sid
|
|
73
|
+
)
|
|
74
|
+
else
|
|
75
|
+
FallbackMessageInstance.new(
|
|
76
|
+
sid: sid,
|
|
77
|
+
account_sid: account_sid,
|
|
78
|
+
to: captured_message.to_display,
|
|
79
|
+
from: sender,
|
|
80
|
+
messaging_service_sid: messaging_service_sid,
|
|
81
|
+
body: captured_message.body,
|
|
82
|
+
status: "queued",
|
|
83
|
+
direction: "outbound-api",
|
|
84
|
+
num_segments: captured_message.segments.to_s,
|
|
85
|
+
num_media: media_list.size.to_s,
|
|
86
|
+
date_created: Time.now,
|
|
87
|
+
date_updated: Time.now
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
class << self
|
|
94
|
+
def install!
|
|
95
|
+
return false unless defined?(::Twilio::REST)
|
|
96
|
+
|
|
97
|
+
target = ::Twilio::REST::Api::V2010::AccountContext::MessageList
|
|
98
|
+
unless target.ancestors.include?(MessageListPatch)
|
|
99
|
+
target.prepend(MessageListPatch)
|
|
100
|
+
end
|
|
101
|
+
true
|
|
102
|
+
rescue NameError
|
|
103
|
+
false
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def installed?
|
|
107
|
+
return false unless defined?(::Twilio::REST)
|
|
108
|
+
|
|
109
|
+
::Twilio::REST::Api::V2010::AccountContext::MessageList.ancestors.include?(MessageListPatch)
|
|
110
|
+
rescue NameError
|
|
111
|
+
false
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
SmsOpener::Interceptors::TwilioInterceptor.install!
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi"
|
|
4
|
+
require "erb"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require "json"
|
|
7
|
+
require "securerandom"
|
|
8
|
+
require "time"
|
|
9
|
+
require "uri"
|
|
10
|
+
|
|
11
|
+
module SmsOpener
|
|
12
|
+
class Message
|
|
13
|
+
GSM_7_BASIC_CHARS = (
|
|
14
|
+
"@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞ\eÆæßÉ !\"#¤%&'()*+,-./0123456789:;<=>?" \
|
|
15
|
+
"¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà"
|
|
16
|
+
).chars.freeze
|
|
17
|
+
GSM_7_EXTENDED_CHARS = "^{}\\[~]|€\f".chars.freeze
|
|
18
|
+
|
|
19
|
+
attr_reader :id, :to, :from, :body, :media_urls, :provider, :metadata, :created_at, :location
|
|
20
|
+
|
|
21
|
+
def initialize(
|
|
22
|
+
to:,
|
|
23
|
+
body:,
|
|
24
|
+
from: nil,
|
|
25
|
+
media_urls: [],
|
|
26
|
+
provider: "direct",
|
|
27
|
+
metadata: {},
|
|
28
|
+
id: nil,
|
|
29
|
+
created_at: nil,
|
|
30
|
+
location: nil,
|
|
31
|
+
base_location: nil
|
|
32
|
+
)
|
|
33
|
+
@created_at = parse_time(created_at)
|
|
34
|
+
@id = id || generate_id(@created_at)
|
|
35
|
+
@to = normalize_phone_list(to)
|
|
36
|
+
@from = (from.nil? || from.to_s.strip.empty?) ? SmsOpener.configuration.default_from : from.to_s
|
|
37
|
+
@body = body.to_s
|
|
38
|
+
@media_urls = Array(media_urls).compact.map(&:to_s).reject(&:empty?)
|
|
39
|
+
@provider = provider.to_s
|
|
40
|
+
@metadata = stringify_keys(metadata || {})
|
|
41
|
+
root_dir = base_location || SmsOpener.configuration.resolved_location
|
|
42
|
+
@location = location || File.join(root_dir, @id)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def to_display
|
|
46
|
+
@to.is_a?(Array) ? @to.join(", ") : @to.to_s
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def otp_code
|
|
50
|
+
# Remove URLs first so we don't match numbers inside links
|
|
51
|
+
cleaned = @body.gsub(%r{https?://\S+}, " ")
|
|
52
|
+
# Remove phone numbers like +15551234567 or 555-123-4567
|
|
53
|
+
cleaned = cleaned.gsub(/\+?\d{1,3}[-.\s]?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}/, " ")
|
|
54
|
+
|
|
55
|
+
# Look for 4 to 8 digit codes near common verification keywords, or standalone codes
|
|
56
|
+
if (match = cleaned.match(/(?:code|otp|pin|passcode|verification|token|is|:)\s*[:\-]?\s*\b(\d{4,8})\b/i))
|
|
57
|
+
return match[1]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
if (match = cleaned.match(/\b(\d{4,8})\b\s*(?:is your|verification|code|otp|pin)/i))
|
|
61
|
+
return match[1]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def urls
|
|
68
|
+
@body.scan(%r{https?://[^\s<>"')]+}).map { |u| u.sub(/[.,;!?]+$/, "") }.uniq
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def encoding
|
|
72
|
+
@body.chars.all? { |ch| GSM_7_BASIC_CHARS.include?(ch) || GSM_7_EXTENDED_CHARS.include?(ch) } ? "GSM-7" : "UCS-2"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def character_count
|
|
76
|
+
if encoding == "GSM-7"
|
|
77
|
+
@body.chars.sum { |ch| GSM_7_EXTENDED_CHARS.include?(ch) ? 2 : 1 }
|
|
78
|
+
else
|
|
79
|
+
@body.length
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def segments
|
|
84
|
+
count = character_count
|
|
85
|
+
return 0 if count.zero?
|
|
86
|
+
|
|
87
|
+
if encoding == "GSM-7"
|
|
88
|
+
count <= 160 ? 1 : (count / 153.0).ceil
|
|
89
|
+
else
|
|
90
|
+
count <= 70 ? 1 : (count / 67.0).ceil
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def filepath
|
|
95
|
+
File.join(@location, "sms.html")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def json_filepath
|
|
99
|
+
File.join(@location, "message.json")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def file_uri
|
|
103
|
+
scheme = SmsOpener.configuration.file_uri_scheme || "file"
|
|
104
|
+
"#{scheme}://#{File.expand_path(filepath)}"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def render!
|
|
108
|
+
FileUtils.mkdir_p(@location)
|
|
109
|
+
File.write(json_filepath, JSON.pretty_generate(to_h))
|
|
110
|
+
File.write(filepath, rendered_html)
|
|
111
|
+
self
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def rendered_html
|
|
115
|
+
template = File.read(template_path)
|
|
116
|
+
ERB.new(template, trim_mode: "-").result(binding)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def formatted_body_html
|
|
120
|
+
escaped = CGI.escapeHTML(@body)
|
|
121
|
+
# Convert URLs into clickable anchor links
|
|
122
|
+
escaped.gsub(%r{https?://[^\s<>"')]+}) do |match|
|
|
123
|
+
clean_url = match.sub(/[.,;!?]+$/, "")
|
|
124
|
+
trailing = match[clean_url.length..]
|
|
125
|
+
%(<a href="#{clean_url}" target="_blank" rel="noopener noreferrer" class="sms-link">#{clean_url}</a>#{trailing})
|
|
126
|
+
end.gsub("\n", "<br>")
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def to_h
|
|
130
|
+
{
|
|
131
|
+
"id" => @id,
|
|
132
|
+
"to" => @to,
|
|
133
|
+
"to_display" => to_display,
|
|
134
|
+
"from" => @from,
|
|
135
|
+
"body" => @body,
|
|
136
|
+
"media_urls" => @media_urls,
|
|
137
|
+
"provider" => @provider,
|
|
138
|
+
"metadata" => @metadata,
|
|
139
|
+
"otp_code" => otp_code,
|
|
140
|
+
"urls" => urls,
|
|
141
|
+
"encoding" => encoding,
|
|
142
|
+
"character_count" => character_count,
|
|
143
|
+
"segments" => segments,
|
|
144
|
+
"created_at" => @created_at.iso8601(3),
|
|
145
|
+
"filepath" => filepath
|
|
146
|
+
}
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def delete!
|
|
150
|
+
FileUtils.rm_rf(@location) if @location && Dir.exist?(@location)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
class << self
|
|
154
|
+
def all
|
|
155
|
+
base_dir = SmsOpener.configuration.resolved_location
|
|
156
|
+
return [] unless Dir.exist?(base_dir)
|
|
157
|
+
|
|
158
|
+
Dir.glob(File.join(base_dir, "*", "message.json"))
|
|
159
|
+
.filter_map { |json_path| load_from_json(json_path) }
|
|
160
|
+
.sort_by { |msg| [msg.created_at, msg.id] }
|
|
161
|
+
.reverse
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def find(id)
|
|
165
|
+
safe_id = File.basename(id.to_s)
|
|
166
|
+
return nil if safe_id.empty? || safe_id == "." || safe_id == ".."
|
|
167
|
+
|
|
168
|
+
json_path = File.join(SmsOpener.configuration.resolved_location, safe_id, "message.json")
|
|
169
|
+
return nil unless File.exist?(json_path)
|
|
170
|
+
|
|
171
|
+
load_from_json(json_path)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def clear!
|
|
175
|
+
base_dir = SmsOpener.configuration.resolved_location
|
|
176
|
+
return unless Dir.exist?(base_dir)
|
|
177
|
+
|
|
178
|
+
Dir.children(base_dir).each do |entry|
|
|
179
|
+
path = File.join(base_dir, entry)
|
|
180
|
+
FileUtils.rm_rf(path) if File.directory?(path)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def load_from_json(json_path)
|
|
185
|
+
data = JSON.parse(File.read(json_path))
|
|
186
|
+
new(
|
|
187
|
+
id: data["id"],
|
|
188
|
+
to: data["to"],
|
|
189
|
+
from: data["from"],
|
|
190
|
+
body: data["body"],
|
|
191
|
+
media_urls: data["media_urls"] || [],
|
|
192
|
+
provider: data["provider"] || "direct",
|
|
193
|
+
metadata: data["metadata"] || {},
|
|
194
|
+
created_at: data["created_at"],
|
|
195
|
+
location: File.dirname(json_path)
|
|
196
|
+
)
|
|
197
|
+
rescue JSON::ParserError, StandardError
|
|
198
|
+
nil
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
private
|
|
203
|
+
|
|
204
|
+
def template_path
|
|
205
|
+
File.expand_path("templates/sms.html.erb", __dir__)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def parse_time(value)
|
|
209
|
+
return Time.now if value.nil?
|
|
210
|
+
return value if value.is_a?(Time)
|
|
211
|
+
|
|
212
|
+
Time.parse(value.to_s)
|
|
213
|
+
rescue ArgumentError
|
|
214
|
+
Time.now
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def generate_id(time)
|
|
218
|
+
"#{time.strftime('%Y%m%d_%H%M%S_%L')}_#{SecureRandom.hex(4)}"
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def normalize_phone_list(to)
|
|
222
|
+
case to
|
|
223
|
+
when Array
|
|
224
|
+
to.map(&:to_s)
|
|
225
|
+
else
|
|
226
|
+
[to.to_s]
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def stringify_keys(hash)
|
|
231
|
+
hash.each_with_object({}) do |(k, v), acc|
|
|
232
|
+
acc[k.to_s] = v
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/railtie"
|
|
4
|
+
require "active_support/ordered_options"
|
|
5
|
+
|
|
6
|
+
module SmsOpener
|
|
7
|
+
class Railtie < ::Rails::Railtie
|
|
8
|
+
config.sms_opener = ActiveSupport::OrderedOptions.new
|
|
9
|
+
|
|
10
|
+
initializer "sms_opener.configure" do |app|
|
|
11
|
+
app.config.sms_opener.each do |key, value|
|
|
12
|
+
setter = :"#{key}="
|
|
13
|
+
SmsOpener.configuration.public_send(setter, value) if SmsOpener.configuration.respond_to?(setter)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
config.after_initialize do
|
|
18
|
+
if SmsOpener.configuration.intercept_twilio
|
|
19
|
+
SmsOpener::Interceptors::TwilioInterceptor.install!
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|