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.
@@ -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