flow_chat 0.8.2 → 0.9.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.
Files changed (91) hide show
  1. checksums.yaml +4 -4
  2. data/.cliff.toml +74 -0
  3. data/.github/workflows/ci.yml +2 -3
  4. data/.github/workflows/release.yml +56 -0
  5. data/.standard.yml +4 -0
  6. data/CHANGELOG.md +22 -0
  7. data/CLAUDE.md +327 -0
  8. data/CONTRIBUTING.md +134 -0
  9. data/Gemfile +1 -0
  10. data/README.md +290 -105
  11. data/Rakefile +5 -1
  12. data/SECURITY.md +42 -349
  13. data/docs/architecture.md +510 -0
  14. data/docs/async-background-processing.md +298 -0
  15. data/docs/configuration.md +556 -226
  16. data/docs/factory-pattern.md +355 -0
  17. data/docs/gateway-context-variables.md +171 -0
  18. data/docs/gateway-development.md +723 -0
  19. data/docs/getting-started.md +429 -0
  20. data/docs/instrumentation.md +264 -153
  21. data/docs/platforms/telegram.md +1013 -0
  22. data/docs/platforms/ussd.md +693 -0
  23. data/docs/platforms/whatsapp.md +1395 -0
  24. data/docs/testing.md +243 -365
  25. data/examples/custom_session_id_example.rb +119 -0
  26. data/examples/http_controller.rb +11 -11
  27. data/examples/intercom_configuration_example.rb +118 -0
  28. data/examples/intercom_controller.rb +194 -0
  29. data/examples/multi_tenant_whatsapp_controller.rb +4 -4
  30. data/examples/ussd_controller.rb +9 -9
  31. data/examples/whatsapp_controller.rb +2 -2
  32. data/flow_chat.gemspec +4 -0
  33. data/lib/flow_chat/{base_app.rb → app.rb} +16 -9
  34. data/lib/flow_chat/async_job.rb +176 -0
  35. data/lib/flow_chat/config.rb +10 -30
  36. data/lib/flow_chat/{base_executor.rb → executor.rb} +6 -11
  37. data/lib/flow_chat/factory.rb +94 -0
  38. data/lib/flow_chat/gateway_async_support.rb +106 -0
  39. data/lib/flow_chat/generic_async_job.rb +30 -0
  40. data/lib/flow_chat/http/gateway/simple.rb +81 -33
  41. data/lib/flow_chat/http/renderer.rb +3 -3
  42. data/lib/flow_chat/instrumentation/setup.rb +1 -1
  43. data/lib/flow_chat/instrumentation.rb +23 -0
  44. data/lib/flow_chat/intercom/client.rb +155 -0
  45. data/lib/flow_chat/intercom/configuration.rb +149 -0
  46. data/lib/flow_chat/intercom/gateway/intercom_api.rb +396 -0
  47. data/lib/flow_chat/intercom/renderer.rb +71 -0
  48. data/lib/flow_chat/phone_number_util.rb +37 -35
  49. data/lib/flow_chat/processor.rb +188 -0
  50. data/lib/flow_chat/renderers/markdown_support.rb +58 -0
  51. data/lib/flow_chat/session/middleware.rb +25 -9
  52. data/lib/flow_chat/telegram/client.rb +240 -0
  53. data/lib/flow_chat/telegram/configuration.rb +118 -0
  54. data/lib/flow_chat/telegram/gateway/bot_api.rb +300 -0
  55. data/lib/flow_chat/telegram/middleware/choice_mapper.rb +35 -0
  56. data/lib/flow_chat/telegram/renderer.rb +125 -0
  57. data/lib/flow_chat/telegram.rb +7 -0
  58. data/lib/flow_chat/ussd/gateway/nalo.rb +24 -4
  59. data/lib/flow_chat/ussd/middleware/pagination.rb +9 -5
  60. data/lib/flow_chat/ussd/renderer.rb +1 -1
  61. data/lib/flow_chat/version.rb +1 -1
  62. data/lib/flow_chat/whatsapp/client.rb +144 -13
  63. data/lib/flow_chat/whatsapp/configuration.rb +1 -1
  64. data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +132 -96
  65. data/lib/flow_chat/whatsapp/id_generator.rb +124 -0
  66. data/lib/flow_chat/whatsapp/middleware/choice_mapper.rb +147 -0
  67. data/lib/flow_chat/whatsapp/renderer.rb +145 -11
  68. data/lib/flow_chat.rb +11 -1
  69. data/lib/tasks/release.rake +165 -0
  70. metadata +84 -25
  71. data/docs/flows.md +0 -320
  72. data/docs/http-gateway-protocol.md +0 -432
  73. data/docs/images/simulator.png +0 -0
  74. data/docs/media.md +0 -153
  75. data/docs/sessions.md +0 -433
  76. data/docs/ussd-setup.md +0 -322
  77. data/docs/whatsapp-setup.md +0 -162
  78. data/examples/whatsapp_message_job.rb +0 -113
  79. data/lib/flow_chat/base_processor.rb +0 -146
  80. data/lib/flow_chat/http/app.rb +0 -6
  81. data/lib/flow_chat/http/middleware/executor.rb +0 -24
  82. data/lib/flow_chat/http/processor.rb +0 -33
  83. data/lib/flow_chat/session/rails_session_store.rb +0 -68
  84. data/lib/flow_chat/ussd/app.rb +0 -6
  85. data/lib/flow_chat/ussd/gateway/nsano.rb +0 -96
  86. data/lib/flow_chat/ussd/middleware/executor.rb +0 -24
  87. data/lib/flow_chat/ussd/processor.rb +0 -39
  88. data/lib/flow_chat/whatsapp/app.rb +0 -29
  89. data/lib/flow_chat/whatsapp/middleware/executor.rb +0 -24
  90. data/lib/flow_chat/whatsapp/processor.rb +0 -32
  91. data/lib/flow_chat/whatsapp/send_job_support.rb +0 -79
@@ -0,0 +1,396 @@
1
+ require "json"
2
+ require "openssl"
3
+
4
+ module FlowChat
5
+ module Intercom
6
+ # Configuration-related errors
7
+ class ConfigurationError < StandardError; end
8
+
9
+ module Gateway
10
+ class IntercomApi
11
+ include FlowChat::Instrumentation
12
+ include FlowChat::GatewayAsyncSupport
13
+
14
+ attr_reader :client
15
+
16
+ # Default webhook topics to process
17
+ DEFAULT_WEBHOOK_TOPICS = ["conversation.user.created", "conversation.user.replied"].freeze
18
+
19
+ def initialize(app, config = nil, additional_webhook_topics = nil)
20
+ @app = app
21
+ @config = config || FlowChat::Intercom::Configuration.from_credentials
22
+ @client = FlowChat::Intercom::Client.new(@config)
23
+ # Always include default topics, plus any additional ones
24
+ @allowed_webhook_topics = DEFAULT_WEBHOOK_TOPICS + Array(additional_webhook_topics)
25
+
26
+ FlowChat.logger.info { "IntercomApi: Initialized Intercom API gateway" }
27
+ FlowChat.logger.debug { "IntercomApi: Gateway configuration - API base URL: #{@config.api_base_url}" }
28
+ FlowChat.logger.debug { "IntercomApi: Allowed webhook topics: #{@allowed_webhook_topics.inspect}" }
29
+ end
30
+
31
+ def call(context)
32
+ @context = context
33
+ @controller = context.controller
34
+ request = @controller.request
35
+
36
+ FlowChat.logger.debug { "IntercomApi: Processing #{request.request_method} request to #{request.path}" }
37
+
38
+ # Skip webhook-specific handling in background mode
39
+ unless in_background?
40
+ # Handle webhook URL validation (HEAD request)
41
+ if request.head?
42
+ FlowChat.logger.info { "IntercomApi: Handling webhook URL validation request" }
43
+ return @controller.head :ok
44
+ end
45
+ end
46
+
47
+ # Handle webhook notifications (POST request)
48
+ if request.post?
49
+ FlowChat.logger.info { "IntercomApi: Handling webhook notification (background: #{in_background?})" }
50
+ return handle_webhook(context)
51
+ end
52
+
53
+ FlowChat.logger.warn { "IntercomApi: Invalid request method or parameters - returning bad request" }
54
+ @controller.head :bad_request
55
+ end
56
+
57
+ private
58
+
59
+ def handle_webhook(context)
60
+ # Parse body
61
+ begin
62
+ parse_request_body(@controller.request)
63
+ @client.app_id = @body["app_id"]
64
+ FlowChat.logger.debug { "IntercomApi: Successfully parsed webhook request body" }
65
+ rescue JSON::ParserError => e
66
+ FlowChat.logger.error { "IntercomApi: Failed to parse webhook body: #{e.message}" }
67
+ return @controller.head :bad_request
68
+ end
69
+
70
+ # Check for simulator mode parameter in request (before validation)
71
+ # But only enable if valid simulator token is provided
72
+ is_simulator_mode = simulate?(context)
73
+ if is_simulator_mode
74
+ FlowChat.logger.info { "IntercomApi: Simulator mode enabled for this request" }
75
+ context["simulator_mode"] = true
76
+ end
77
+
78
+ # Validate webhook signature for security (skip for simulator mode and background)
79
+ # Return 200 OK even for invalid signatures to prevent Intercom from retrying
80
+ unless in_background? || is_simulator_mode || valid_webhook_signature?(@controller.request)
81
+ FlowChat.logger.warn { "IntercomApi: Invalid webhook signature - dropping request" }
82
+ return @controller.head :ok
83
+ end
84
+
85
+ FlowChat.logger.debug { "IntercomApi: Webhook signature validation passed" }
86
+
87
+ # Extract event data from Intercom webhook
88
+ event_type = @body["topic"]
89
+ unless event_type
90
+ FlowChat.logger.debug { "IntercomApi: No topic found in webhook body - returning OK" }
91
+ return @controller.head :ok
92
+ end
93
+
94
+ # Only process conversation events we care about
95
+ unless @allowed_webhook_topics.include?(event_type)
96
+ FlowChat.logger.debug { "IntercomApi: Ignoring event type '#{event_type}' (not in allowed topics) - returning OK" }
97
+ return @controller.head :ok
98
+ end
99
+
100
+ # Extract conversation data
101
+ data_item = @body.dig("data", "item")
102
+ unless data_item
103
+ FlowChat.logger.debug { "IntercomApi: No data.item found in webhook body - returning OK" }
104
+ return @controller.head :ok
105
+ end
106
+
107
+ # Process conversation event
108
+ if data_item["type"] == "conversation"
109
+ conversation = data_item
110
+ conversation_id = conversation["id"]
111
+
112
+ # Get the user ID from contacts (always the actual user/contact)
113
+ # contacts.contacts[0] contains the actual user, not the admin
114
+ contact = conversation.dig("contacts", "contacts", 0)
115
+ unless contact
116
+ FlowChat.logger.error { "IntercomApi: No contact found in conversation" }
117
+ return @controller.head :ok
118
+ end
119
+
120
+ # Get user ID, name, email, and phone from contact (most reliable)
121
+ user_id = contact["id"]
122
+ user_name = contact["name"]
123
+ user_email = contact["email"]
124
+ user_phone = contact["phone"]
125
+
126
+ context["request.id"] = conversation_id
127
+ context["request.user_id"] = user_id
128
+ context["request.user_name"] = user_name if user_name
129
+ context["request.email"] = user_email if user_email
130
+ context["request.msisdn"] = user_phone if user_phone
131
+ context["request.gateway"] = :intercom_api
132
+ context["request.platform"] = :intercom
133
+ context["request.timestamp"] = Time.now.iso8601
134
+ context["request.body"] = @body
135
+
136
+ context["intercom.client"] = @client
137
+ context["intercom.topic"] = event_type
138
+
139
+ # Try to extract latest message for user events
140
+ latest_message = extract_latest_user_message(conversation, event_type)
141
+
142
+ if latest_message
143
+ context["request.message_id"] = latest_message[:id]
144
+ # Convert HTML to markdown for message body
145
+ raw_body = latest_message[:body] || ""
146
+ context.input = @client.parse_message(raw_body)
147
+ FlowChat.logger.debug { "IntercomApi: Message content extracted - Event: #{event_type}, Input: '#{context.input}'" }
148
+ elsif @allowed_webhook_topics.include?(event_type)
149
+ # No message but event is explicitly allowed - process without message
150
+ context.input = nil
151
+ FlowChat.logger.debug { "IntercomApi: Processing #{event_type} event without user message" }
152
+ else
153
+ # No message and event not in allowed topics - skip
154
+ # (This case shouldn't happen as we already filtered above, but safety check)
155
+ FlowChat.logger.error { "IntercomApi: No message found for unexpected event type '#{event_type}'" }
156
+ return @controller.head :ok
157
+ end
158
+
159
+ # Instrument message received (with or without message content)
160
+ instrument(Events::MESSAGE_RECEIVED, {
161
+ from: user_id,
162
+ conversation_id: conversation_id,
163
+ message: context.input,
164
+ event_type: event_type
165
+ })
166
+
167
+ # Determine routing: async enqueue, background execute, or inline
168
+ if should_enqueue_async?
169
+ # Webhook with async enabled → enqueue job and return immediately
170
+ enqueue_async_job
171
+ return @controller.head :ok
172
+ else
173
+ # Background OR inline → process message
174
+ # Determine message handling mode (simulator vs inline)
175
+ handler_mode = determine_message_handler(context)
176
+
177
+ # Process the message based on handling mode
178
+ case handler_mode
179
+ when :inline
180
+ handle_message_inline(context, @controller)
181
+ when :simulator
182
+ # Return early from simulator mode to preserve the JSON response
183
+ return handle_message_simulator(context, @controller)
184
+ end
185
+ end
186
+ end
187
+
188
+ @controller.head :ok
189
+ end
190
+
191
+ def determine_message_handler(context)
192
+ # Use simulator mode if enabled, otherwise always use inline
193
+ if context["simulator_mode"]
194
+ FlowChat.logger.debug { "IntercomApi: Using simulator message handler" }
195
+ :simulator
196
+ else
197
+ FlowChat.logger.debug { "IntercomApi: Using inline message handler" }
198
+ :inline
199
+ end
200
+ end
201
+
202
+ # Validate webhook signature to ensure request comes from Intercom
203
+ def valid_webhook_signature?(request)
204
+ # Check if signature validation is explicitly disabled
205
+ if @config.skip_signature_validation
206
+ FlowChat.logger.debug { "IntercomApi: Webhook signature validation is disabled" }
207
+ return true
208
+ end
209
+
210
+ # Require client_secret for signature validation
211
+ unless @config.client_secret && !@config.client_secret.empty?
212
+ error_msg = "Intercom client_secret is required for webhook signature validation. " \
213
+ "Either configure client_secret or set skip_signature_validation=true to explicitly disable validation."
214
+ FlowChat.logger.error { "IntercomApi: #{error_msg}" }
215
+ raise FlowChat::Intercom::ConfigurationError, error_msg
216
+ end
217
+
218
+ signature_header = request.headers["X-Hub-Signature"]
219
+ unless signature_header
220
+ FlowChat.logger.warn { "IntercomApi: No X-Hub-Signature header found in request" }
221
+ return false
222
+ end
223
+
224
+ # Extract signature from header (format: "sha1=<signature>")
225
+ expected_signature = signature_header.sub("sha1=", "")
226
+
227
+ # Get raw request body
228
+ request.body.rewind
229
+ body = request.body.read
230
+ request.body.rewind
231
+
232
+ # Calculate HMAC signature using SHA1 (Intercom uses SHA1, not SHA256)
233
+ calculated_signature = OpenSSL::HMAC.hexdigest(
234
+ OpenSSL::Digest.new("sha1"),
235
+ @config.client_secret,
236
+ body
237
+ )
238
+
239
+ # Compare signatures using secure comparison to prevent timing attacks
240
+ signature_valid = secure_compare(expected_signature, calculated_signature)
241
+
242
+ if signature_valid
243
+ FlowChat.logger.debug { "IntercomApi: Webhook signature validation successful" }
244
+ else
245
+ FlowChat.logger.warn { "IntercomApi: Webhook signature validation failed - signatures do not match" }
246
+ end
247
+
248
+ signature_valid
249
+ rescue FlowChat::Intercom::ConfigurationError
250
+ raise
251
+ rescue => e
252
+ FlowChat.logger.error { "IntercomApi: Error validating webhook signature: #{e.class.name}: #{e.message}" }
253
+ false
254
+ end
255
+
256
+ # Secure string comparison to prevent timing attacks
257
+ def secure_compare(a, b)
258
+ return false unless a.bytesize == b.bytesize
259
+
260
+ l = a.unpack("C*")
261
+ res = 0
262
+ b.each_byte { |byte| res |= byte ^ l.shift }
263
+ res == 0
264
+ end
265
+
266
+ def extract_latest_user_message(conversation, event_type)
267
+ FlowChat.logger.debug { "IntercomApi: Extracting latest user message from #{event_type} event" }
268
+
269
+ case event_type
270
+ when "conversation.user.created"
271
+ # For new conversations, get the initial message from source
272
+ source = conversation["source"]
273
+ if source && source["body"]
274
+ {
275
+ id: source["id"],
276
+ body: source["body"]
277
+ }
278
+ end
279
+ when "conversation.user.replied"
280
+ # For replies, get the latest user message from conversation_parts
281
+ parts = conversation.dig("conversation_parts", "conversation_parts") || []
282
+
283
+ # Find the most recent part from a user (not admin)
284
+ # Note: user type can be "user", "lead", or "contact"
285
+ user_parts = parts.select do |part|
286
+ part["part_type"] == "comment" &&
287
+ %w[user lead contact].include?(part.dig("author", "type"))
288
+ end
289
+
290
+ if user_parts.any?
291
+ latest_part = user_parts.last
292
+ {
293
+ id: latest_part["id"],
294
+ body: latest_part["body"]
295
+ }
296
+ end
297
+ end
298
+ end
299
+
300
+ def handle_message_inline(context, controller)
301
+ response = @app.call(context)
302
+ if response
303
+ _type, prompt, choices, media = response
304
+ result = @client.send_message(context["request.id"], prompt, choices: choices, media: media)
305
+ context["intercom.message_result"] = result
306
+
307
+ # Instrument message sent
308
+ instrument(Events::MESSAGE_SENT, {
309
+ to: context["request.user_id"],
310
+ conversation_id: context["request.id"],
311
+ message: prompt,
312
+ gateway: :intercom_api,
313
+ platform: :intercom,
314
+ content_length: prompt.to_s.length,
315
+ timestamp: context["request.timestamp"]
316
+ })
317
+ end
318
+ end
319
+
320
+ def handle_message_simulator(context, controller)
321
+ response = @app.call(context)
322
+
323
+ if response
324
+ _type, prompt, choices, media = response
325
+ rendered_message = render_response(prompt, choices, media)
326
+
327
+ # For simulator mode, return the response data in the HTTP response
328
+ # instead of actually sending via Intercom API
329
+ message_payload = @client.build_reply_payload(rendered_message, context["request.id"])
330
+
331
+ simulator_response = {
332
+ mode: "simulator",
333
+ webhook_processed: true,
334
+ would_send: message_payload,
335
+ message_info: {
336
+ to: context["request.id"],
337
+ user_id: context["request.user_id"],
338
+ timestamp: Time.now.iso8601
339
+ }
340
+ }
341
+
342
+ @controller.render json: simulator_response
343
+ nil
344
+ end
345
+ end
346
+
347
+ def simulate?(context)
348
+ # Check if simulator mode is enabled for this processor
349
+ return false unless context["enable_simulator"]
350
+
351
+ # Then check if simulator mode is requested and valid
352
+ @body.dig("simulator_mode") && valid_simulator_cookie?(context)
353
+ end
354
+
355
+ def valid_simulator_cookie?(context)
356
+ simulator_secret = FlowChat::Config.simulator_secret
357
+ return false unless simulator_secret && !simulator_secret.empty?
358
+
359
+ # Check for simulator cookie
360
+ simulator_cookie = @controller.request.cookies["flowchat_simulator"]
361
+ return false unless simulator_cookie
362
+
363
+ # Verify the cookie is a valid HMAC signature
364
+ # Cookie format: "timestamp:signature" where signature = HMAC(simulator_secret, "simulator:timestamp")
365
+ begin
366
+ timestamp_str, signature = simulator_cookie.split(":", 2)
367
+ return false unless timestamp_str && signature
368
+
369
+ # Check timestamp is recent (within 24 hours for reasonable session duration)
370
+ timestamp = timestamp_str.to_i
371
+ return false if timestamp <= 0
372
+ return false if (Time.now.to_i - timestamp).abs > 86400 # 24 hours
373
+
374
+ # Calculate expected signature
375
+ message = "simulator:#{timestamp_str}"
376
+ expected_signature = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), simulator_secret, message)
377
+
378
+ # Use secure comparison
379
+ secure_compare(signature, expected_signature)
380
+ rescue => e
381
+ Rails.logger.warn "Invalid simulator cookie format: #{e.message}"
382
+ false
383
+ end
384
+ end
385
+
386
+ def parse_request_body(request)
387
+ @body ||= JSON.parse(request.body.read)
388
+ end
389
+
390
+ def render_response(prompt, choices, media)
391
+ FlowChat::Intercom::Renderer.new(prompt, choices: choices, media: media).render
392
+ end
393
+ end
394
+ end
395
+ end
396
+ end
@@ -0,0 +1,71 @@
1
+ require "flow_chat/renderers/markdown_support"
2
+
3
+ module FlowChat
4
+ module Intercom
5
+ class Renderer
6
+ include FlowChat::Renderers::MarkdownSupport
7
+
8
+ attr_reader :message, :choices, :media
9
+
10
+ def initialize(message, choices: nil, media: nil)
11
+ @message = message
12
+ @choices = choices
13
+ @media = media
14
+ end
15
+
16
+ def render
17
+ if choices
18
+ build_selection_message
19
+ else
20
+ build_text_message
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def build_text_message
27
+ [:text, to_html(message), {}]
28
+ end
29
+
30
+ def build_selection_message
31
+ if choices.is_a?(Hash)
32
+ build_interactive_message(choices)
33
+ else
34
+ raise ArgumentError, "choices must be a Hash"
35
+ end
36
+ end
37
+
38
+ def build_interactive_message(choice_hash)
39
+ # For Intercom, we'll present choices as a formatted text message
40
+ # since Intercom doesn't have the same interactive elements as WhatsApp
41
+
42
+ formatted_message = message.to_s
43
+
44
+ unless formatted_message.empty?
45
+ formatted_message += "\n\n"
46
+ end
47
+
48
+ # Add numbered choices
49
+ formatted_message += "Please choose:\n"
50
+ choice_hash.each_with_index do |(key, value), index|
51
+ formatted_message += "#{index + 1}. #{value}\n"
52
+ end
53
+
54
+ formatted_message += "\nReply with the number of your choice."
55
+
56
+ [:text, to_html(formatted_message), {choices: choice_hash}]
57
+ end
58
+
59
+ # MarkdownSupport overrides for Intercom-specific behavior
60
+
61
+ def allowed_tags
62
+ # Tags supported by Intercom messenger
63
+ %w[p br b strong i em a ul ol li h1 h2 h3 h4 h5 h6]
64
+ end
65
+
66
+ def allowed_attributes
67
+ %w[href target]
68
+ end
69
+ end
70
+ end
71
+ end
@@ -1,47 +1,49 @@
1
1
  module FlowChat
2
2
  module PhoneNumberUtil
3
- def self.to_e164(phone_number)
4
- return phone_number if phone_number.nil? || phone_number.empty?
5
-
6
- begin
7
- # Try to load phonelib without Rails dependency
8
- require_phonelib_safely
9
- Phonelib.parse(phone_number).e164
10
- rescue => e
11
- FlowChat.logger.warn { "PhoneNumberUtil: Failed to parse phone number '#{phone_number}': #{e.message}" }
12
- # Fallback to simple formatting if phonelib fails
13
- fallback_e164_format(phone_number)
3
+ class << self
4
+ def to_e164(phone_number)
5
+ return "" if phone_number.nil? || phone_number.empty?
6
+
7
+ begin
8
+ # Try to load phonelib without Rails dependency
9
+ require_phonelib_safely
10
+ Phonelib.parse(phone_number).e164
11
+ rescue => e
12
+ FlowChat.logger.warn { "PhoneNumberUtil: Failed to parse phone number '#{phone_number}': #{e.message}" }
13
+ # Fallback to simple formatting if phonelib fails
14
+ fallback_e164_format(phone_number)
15
+ end
14
16
  end
15
- end
16
17
 
17
- private
18
+ private
18
19
 
19
- def self.require_phonelib_safely
20
- return if defined?(Phonelib)
21
-
22
- # Temporarily stub Rails if it doesn't exist
23
- unless defined?(Rails)
24
- stub_rails = Module.new do
25
- def self.const_missing(name)
26
- if name == :Railtie
27
- Class.new
28
- else
29
- super
20
+ def require_phonelib_safely
21
+ return if defined?(Phonelib)
22
+
23
+ # Temporarily stub Rails if it doesn't exist
24
+ if defined?(Rails)
25
+ require "phonelib"
26
+ else
27
+ stub_rails = Module.new do
28
+ def const_missing(name)
29
+ if name == :Railtie
30
+ Class.new
31
+ else
32
+ super
33
+ end
30
34
  end
31
35
  end
36
+ Object.const_set(:Rails, stub_rails)
37
+ require "phonelib"
38
+ Object.send(:remove_const, :Rails)
32
39
  end
33
- Object.const_set(:Rails, stub_rails)
34
- require "phonelib"
35
- Object.send(:remove_const, :Rails)
36
- else
37
- require "phonelib"
38
40
  end
39
- end
40
41
 
41
- def self.fallback_e164_format(phone_number)
42
- # Simple fallback - ensure it starts with + and looks like a phone number
43
- cleaned = phone_number.to_s.gsub(/[^\d+]/, '')
44
- cleaned.start_with?('+') ? cleaned : "+#{cleaned}"
42
+ def fallback_e164_format(phone_number)
43
+ # Simple fallback - ensure it starts with + and looks like a phone number
44
+ cleaned = phone_number.to_s.gsub(/[^\d+]/, "")
45
+ cleaned.start_with?("+") ? cleaned : "+#{cleaned}"
46
+ end
45
47
  end
46
48
  end
47
- end
49
+ end