flow_chat 0.8.1 → 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 +154 -0
  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/simulator_controller.rb +21 -1
  31. data/examples/ussd_controller.rb +10 -10
  32. data/examples/whatsapp_controller.rb +2 -2
  33. data/flow_chat.gemspec +4 -0
  34. data/lib/flow_chat/{base_app.rb → app.rb} +28 -9
  35. data/lib/flow_chat/async_job.rb +176 -0
  36. data/lib/flow_chat/config.rb +23 -28
  37. data/lib/flow_chat/{base_executor.rb → executor.rb} +7 -12
  38. data/lib/flow_chat/factory.rb +94 -0
  39. data/lib/flow_chat/gateway_async_support.rb +106 -0
  40. data/lib/flow_chat/generic_async_job.rb +30 -0
  41. data/lib/flow_chat/http/gateway/simple.rb +125 -0
  42. data/lib/flow_chat/http/renderer.rb +41 -0
  43. data/lib/flow_chat/instrumentation/setup.rb +1 -1
  44. data/lib/flow_chat/instrumentation.rb +25 -0
  45. data/lib/flow_chat/intercom/client.rb +155 -0
  46. data/lib/flow_chat/intercom/configuration.rb +149 -0
  47. data/lib/flow_chat/intercom/gateway/intercom_api.rb +396 -0
  48. data/lib/flow_chat/intercom/renderer.rb +71 -0
  49. data/lib/flow_chat/phone_number_util.rb +49 -0
  50. data/lib/flow_chat/processor.rb +188 -0
  51. data/lib/flow_chat/renderers/markdown_support.rb +58 -0
  52. data/lib/flow_chat/session/cache_session_store.rb +1 -17
  53. data/lib/flow_chat/session/middleware.rb +43 -26
  54. data/lib/flow_chat/simulator/controller.rb +17 -5
  55. data/lib/flow_chat/simulator/views/simulator.html.erb +220 -8
  56. data/lib/flow_chat/telegram/client.rb +240 -0
  57. data/lib/flow_chat/telegram/configuration.rb +118 -0
  58. data/lib/flow_chat/telegram/gateway/bot_api.rb +300 -0
  59. data/lib/flow_chat/telegram/middleware/choice_mapper.rb +35 -0
  60. data/lib/flow_chat/telegram/renderer.rb +125 -0
  61. data/lib/flow_chat/telegram.rb +7 -0
  62. data/lib/flow_chat/ussd/gateway/nalo.rb +27 -11
  63. data/lib/flow_chat/ussd/middleware/pagination.rb +9 -5
  64. data/lib/flow_chat/ussd/renderer.rb +1 -1
  65. data/lib/flow_chat/version.rb +1 -1
  66. data/lib/flow_chat/whatsapp/client.rb +144 -13
  67. data/lib/flow_chat/whatsapp/configuration.rb +1 -1
  68. data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +144 -106
  69. data/lib/flow_chat/whatsapp/id_generator.rb +124 -0
  70. data/lib/flow_chat/whatsapp/middleware/choice_mapper.rb +147 -0
  71. data/lib/flow_chat/whatsapp/renderer.rb +145 -11
  72. data/lib/flow_chat.rb +11 -1
  73. data/lib/tasks/release.rake +165 -0
  74. metadata +88 -21
  75. data/docs/flows.md +0 -320
  76. data/docs/images/simulator.png +0 -0
  77. data/docs/media.md +0 -153
  78. data/docs/sessions.md +0 -433
  79. data/docs/ussd-setup.md +0 -322
  80. data/docs/whatsapp-setup.md +0 -162
  81. data/examples/whatsapp_message_job.rb +0 -113
  82. data/lib/flow_chat/base_processor.rb +0 -145
  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 -98
  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
@@ -5,10 +5,10 @@ class UssdController < ApplicationController
5
5
  skip_forgery_protection
6
6
 
7
7
  def process_request
8
- processor = FlowChat::Ussd::Processor.new(self) do |config|
8
+ processor = FlowChat::Processor.new(self) do |config|
9
9
  config.use_gateway FlowChat::Ussd::Gateway::Nalo
10
- # Use Rails session for USSD (shorter sessions)
11
- config.use_session_store FlowChat::Session::RailsSessionStore
10
+ # Use cache session store for USSD
11
+ config.use_session_store FlowChat::Session::CacheSessionStore
12
12
 
13
13
  # Enable durable sessions (optional)
14
14
  config.use_durable_sessions # Configures flow+platform isolation with durable sessions
@@ -194,9 +194,9 @@ class UssdController < ApplicationController
194
194
  FlowChat::Config.ussd.pagination_next_option = "#"
195
195
  FlowChat::Config.ussd.pagination_back_option = "*"
196
196
 
197
- processor = FlowChat::Ussd::Processor.new(self) do |config|
197
+ processor = FlowChat::Processor.new(self) do |config|
198
198
  config.use_gateway FlowChat::Ussd::Gateway::Nalo
199
- config.use_session_store FlowChat::Session::RailsSessionStore
199
+ config.use_session_store FlowChat::Session::CacheSessionStore
200
200
  end
201
201
 
202
202
  processor.run WelcomeFlow, :main_page
@@ -228,16 +228,16 @@ class UssdController < ApplicationController
228
228
  skip_forgery_protection
229
229
 
230
230
  def process_request
231
- processor = FlowChat::Ussd::Processor.new(self) do |config|
231
+ processor = FlowChat::Processor.new(self) do |config|
232
232
  config.use_gateway FlowChat::Ussd::Gateway::Nalo
233
- config.use_session_store FlowChat::Session::RailsSessionStore
233
+ config.use_session_store FlowChat::Session::CacheSessionStore
234
234
  config.use_middleware LoggingMiddleware # Add custom logging
235
235
  config.use_durable_sessions # Enable durable sessions
236
-
236
+
237
237
  # Or configure session boundaries explicitly:
238
238
  # config.use_session_config(
239
239
  # boundaries: [:flow, :platform], # which boundaries to enforce
240
- # hash_phone_numbers: true # hash phone numbers for privacy
240
+ # hash_identifiers: true # hash phone numbers for privacy
241
241
  # )
242
242
  end
243
243
 
@@ -252,7 +252,7 @@ class UssdController < ApplicationController
252
252
  skip_forgery_protection
253
253
 
254
254
  def process_request
255
- processor = FlowChat::Ussd::Processor.new(self) do |config|
255
+ processor = FlowChat::Processor.new(self) do |config|
256
256
  config.use_gateway FlowChat::Ussd::Gateway::Nalo
257
257
  # Use cache store for longer session persistence
258
258
  config.use_session_store FlowChat::Session::CacheSessionStore
@@ -6,7 +6,7 @@ class WhatsappController < ApplicationController
6
6
  skip_forgery_protection
7
7
 
8
8
  def webhook
9
- processor = FlowChat::Whatsapp::Processor.new(self, enable_simulator: Rails.env.development?) do |config|
9
+ processor = FlowChat::Processor.new(self, enable_simulator: Rails.env.development?) do |config|
10
10
  config.use_gateway FlowChat::Whatsapp::Gateway::CloudApi
11
11
  config.use_session_store FlowChat::Session::CacheSessionStore
12
12
  end
@@ -25,7 +25,7 @@ class CustomWhatsappController < ApplicationController
25
25
  def webhook
26
26
  custom_config = build_whatsapp_config
27
27
 
28
- processor = FlowChat::Whatsapp::Processor.new(self, enable_simulator: !Rails.env.production?) do |config|
28
+ processor = FlowChat::Processor.new(self, enable_simulator: !Rails.env.production?) do |config|
29
29
  config.use_gateway FlowChat::Whatsapp::Gateway::CloudApi, custom_config
30
30
  config.use_session_store FlowChat::Session::CacheSessionStore
31
31
  end
data/flow_chat.gemspec CHANGED
@@ -17,6 +17,7 @@ Gem::Specification.new do |spec|
17
17
  spec.required_ruby_version = Gem::Requirement.new(">= 2.3.0")
18
18
 
19
19
  spec.metadata["allowed_push_host"] = "https://rubygems.org"
20
+ spec.metadata["rubygems_mfa_required"] = "true"
20
21
 
21
22
  spec.metadata["homepage_uri"] = spec.homepage
22
23
  spec.metadata["source_code_uri"] = spec.homepage
@@ -36,4 +37,7 @@ Gem::Specification.new do |spec|
36
37
  spec.add_dependency "actionpack", ">= 6"
37
38
  spec.add_dependency "phonelib"
38
39
  spec.add_dependency "ibsciss-middleware", "~> 0.4.2"
40
+ spec.add_dependency "intercom", "~> 4.2"
41
+ spec.add_dependency "reverse_markdown", "~> 3.0"
42
+ spec.add_dependency "kramdown", "~> 2.4"
39
43
  end
@@ -1,10 +1,9 @@
1
1
  module FlowChat
2
- class BaseApp
3
- attr_reader :session, :input, :context, :navigation_stack
2
+ class App
3
+ attr_reader :input, :context, :navigation_stack
4
4
 
5
5
  def initialize(context)
6
6
  @context = context
7
- @session = context.session
8
7
  @input = context.input
9
8
  @navigation_stack = []
10
9
  end
@@ -27,11 +26,11 @@ module FlowChat
27
26
 
28
27
  def go_back
29
28
  return false if navigation_stack.empty?
30
-
29
+
31
30
  @context.input = nil
32
31
  current_screen = navigation_stack.last
33
32
  session.delete(current_screen)
34
-
33
+
35
34
  # Restart the flow from the beginning
36
35
  raise FlowChat::Interrupt::RestartFlow.new
37
36
  end
@@ -40,7 +39,19 @@ module FlowChat
40
39
  raise FlowChat::Interrupt::Terminate.new(msg, media: media)
41
40
  end
42
41
 
43
- def phone_number
42
+ def platform
43
+ context["request.platform"]
44
+ end
45
+
46
+ def gateway
47
+ context["request.gateway"]
48
+ end
49
+
50
+ def user_id
51
+ context["request.user_id"]
52
+ end
53
+
54
+ def msisdn
44
55
  context["request.msisdn"]
45
56
  end
46
57
 
@@ -64,11 +75,19 @@ module FlowChat
64
75
  nil
65
76
  end
66
77
 
78
+ def session
79
+ @context.session
80
+ end
81
+
67
82
  protected
68
83
 
69
- # Platform-specific methods to be overridden
70
84
  def prepare_user_input
71
- input
85
+ user_input = input
86
+ if platform != :ussd && session.get(FlowChat::Input::START).nil?
87
+ session.set(FlowChat::Input::START, user_input)
88
+ user_input = nil
89
+ end
90
+ user_input
72
91
  end
73
92
  end
74
- end
93
+ end
@@ -0,0 +1,176 @@
1
+ begin
2
+ require "active_job"
3
+ rescue LoadError
4
+ # ActiveJob not available - async features will not be available
5
+ end
6
+ require "ostruct"
7
+
8
+ module FlowChat
9
+ # Base class for background flow processing jobs
10
+ # Users inherit from this and implement execute(controller, **job_params)
11
+ if defined?(ActiveJob::Base)
12
+ class AsyncJob < ActiveJob::Base
13
+ queue_as :default
14
+
15
+ def perform(request_context:, **job_params)
16
+ FlowChat.logger.debug { "AsyncJob: Starting background job with params: #{job_params.inspect}" }
17
+
18
+ # Create BackgroundController from serialized request
19
+ controller = BackgroundController.new(request_context)
20
+
21
+ # User implements execute and calls processor.run themselves
22
+ # Pass job_params as keyword arguments to execute
23
+ execute(controller, **job_params)
24
+
25
+ FlowChat.logger.debug { "AsyncJob: Background job completed successfully" }
26
+ end
27
+
28
+ # Abstract method - user must implement
29
+ # User builds processor AND calls processor.run themselves
30
+ # Job params from use_async(JobClass, key: value) are passed as keyword arguments
31
+ def execute(controller, **job_params)
32
+ raise NotImplementedError, "Subclasses must implement #execute(controller, **job_params)"
33
+ end
34
+ end
35
+ else
36
+ # Fallback when ActiveJob is not available
37
+ class AsyncJob
38
+ def perform(request_context:, **job_params)
39
+ FlowChat.logger.debug { "AsyncJob: Starting background job with params: #{job_params.inspect}" }
40
+
41
+ # Create BackgroundController from serialized request
42
+ controller = BackgroundController.new(request_context)
43
+
44
+ # User implements execute and calls processor.run themselves
45
+ # Pass job_params as keyword arguments to execute
46
+ execute(controller, **job_params)
47
+
48
+ FlowChat.logger.debug { "AsyncJob: Background job completed successfully" }
49
+ end
50
+
51
+ # Abstract method - user must implement
52
+ # User builds processor AND calls processor.run themselves
53
+ # Job params from use_async(JobClass, key: value) are passed as keyword arguments
54
+ def execute(controller, **job_params)
55
+ raise NotImplementedError, "Subclasses must implement #execute(controller, **job_params)"
56
+ end
57
+
58
+ # Stub perform_later for testing when ActiveJob is not available
59
+ def self.perform_later(args)
60
+ new.perform(**args)
61
+ end
62
+ end
63
+ end
64
+
65
+ # Duck-type controller for background jobs
66
+ # Provides render/head no-ops and request interface
67
+ class BackgroundController
68
+ attr_reader :request, :response
69
+
70
+ def initialize(request_data)
71
+ FlowChat.logger.debug { "BackgroundController: Initializing with request data" }
72
+ @request = BackgroundRequest.new(request_data)
73
+ @response = nil
74
+ end
75
+
76
+ # Delegate params to request (mimics Rails controller behavior)
77
+ def params
78
+ request.params
79
+ end
80
+
81
+ def render(options)
82
+ FlowChat.logger.debug { "BackgroundController: render called (no-op): #{options.inspect}" }
83
+ @response = options
84
+ nil # No-op in background
85
+ end
86
+
87
+ def head(status)
88
+ FlowChat.logger.debug { "BackgroundController: head called (no-op): #{status}" }
89
+ @response = {status: status}
90
+ nil # No-op in background
91
+ end
92
+
93
+ def is_a?(klass)
94
+ return true if klass == FlowChat::BackgroundController
95
+ super
96
+ end
97
+
98
+ def kind_of?(klass)
99
+ return true if klass == FlowChat::BackgroundController
100
+ super
101
+ end
102
+ end
103
+
104
+ # Request object for background jobs
105
+ # Reconstructed from serialized webhook request data
106
+ class BackgroundRequest
107
+ attr_reader :params, :method, :headers, :host, :path, :remote_ip
108
+
109
+ def initialize(request_data)
110
+ @params = (request_data[:params] || {}).with_indifferent_access
111
+ @method = request_data[:method] || "POST"
112
+ @headers = OpenStruct.new(request_data[:headers] || {})
113
+ @host = request_data[:host]
114
+ @path = request_data[:path]
115
+ @body_content = request_data[:body]
116
+ @remote_ip = request_data[:remote_ip]
117
+
118
+ FlowChat.logger.debug { "BackgroundRequest: Initialized with method=#{@method}, params keys=#{@params.keys.inspect}" }
119
+ end
120
+
121
+ # Rails request interface compatibility
122
+ def request_method
123
+ method.upcase
124
+ end
125
+
126
+ def user_agent
127
+ @headers["User-Agent"]
128
+ end
129
+
130
+ def ssl?
131
+ # Background jobs don't have SSL context
132
+ false
133
+ end
134
+
135
+ def post?
136
+ method.upcase == "POST"
137
+ end
138
+
139
+ def get?
140
+ method.upcase == "GET"
141
+ end
142
+
143
+ def head?
144
+ method.upcase == "HEAD"
145
+ end
146
+
147
+ def body
148
+ # Return StringIO-like object if body content exists
149
+ @body_content ? BackgroundRequestBody.new(@body_content) : nil
150
+ end
151
+
152
+ def cookies
153
+ # Background jobs don't have cookies
154
+ {}
155
+ end
156
+ end
157
+
158
+ # Body wrapper for BackgroundRequest
159
+ # Provides read() method that Rails expects
160
+ class BackgroundRequestBody
161
+ def initialize(content)
162
+ @content = content
163
+ @read = false
164
+ end
165
+
166
+ def read
167
+ return "" if @read
168
+ @read = true
169
+ @content
170
+ end
171
+
172
+ def rewind
173
+ @read = false
174
+ end
175
+ end
176
+ end
@@ -7,6 +7,8 @@ module FlowChat
7
7
  # When true (default), validation errors are combined with the original message.
8
8
  # When false, only the validation error message is shown to the user.
9
9
  mattr_accessor :combine_validation_error_with_message, default: true
10
+ # When true, inject logger into middleware stack. Defaults to true in Rails development.
11
+ mattr_accessor :inject_middleware_logger, default: defined?(Rails) && Rails.env.development?
10
12
 
11
13
  # Session configuration object
12
14
  def self.session
@@ -23,8 +25,13 @@ module FlowChat
23
25
  @whatsapp ||= WhatsappConfig.new
24
26
  end
25
27
 
28
+ # HTTP-specific configuration object
29
+ def self.http
30
+ @http ||= HttpConfig.new
31
+ end
32
+
26
33
  class SessionConfig
27
- attr_accessor :boundaries, :hash_phone_numbers, :identifier
34
+ attr_accessor :boundaries, :hash_identifiers, :identifier, :session_id_proc
28
35
 
29
36
  def initialize
30
37
  # Session boundaries control how session IDs are constructed
@@ -32,14 +39,17 @@ module FlowChat
32
39
  # :gateway = separate sessions per gateway
33
40
  # :platform = separate sessions per platform (ussd, whatsapp)
34
41
  @boundaries = [:flow, :gateway, :platform]
35
-
42
+
36
43
  # Always hash phone numbers for privacy
37
- @hash_phone_numbers = true
38
-
44
+ @hash_identifiers = true
45
+
39
46
  # Session identifier type (nil = let platforms choose their default)
40
47
  # :msisdn = durable sessions (durable across timeouts)
41
48
  # :request_id = ephemeral sessions (new session each time)
42
49
  @identifier = nil
50
+
51
+ # Proc for custom session ID generation (overrides default behavior when set)
52
+ @session_id_proc = nil
43
53
  end
44
54
  end
45
55
 
@@ -57,35 +67,20 @@ module FlowChat
57
67
  end
58
68
 
59
69
  class WhatsappConfig
60
- attr_accessor :background_job_class
61
- attr_reader :message_handling_mode, :api_base_url
70
+ attr_reader :api_base_url
62
71
 
63
72
  def initialize
64
- @message_handling_mode = :inline
65
- @background_job_class = "WhatsappMessageJob"
66
- @api_base_url = "https://graph.facebook.com/v22.0"
67
- end
68
-
69
- # Validate message handling mode
70
- def message_handling_mode=(mode)
71
- valid_modes = [:inline, :background, :simulator]
72
- unless valid_modes.include?(mode.to_sym)
73
- raise ArgumentError, "Invalid message handling mode: #{mode}. Valid modes: #{valid_modes.join(", ")}"
74
- end
75
- @message_handling_mode = mode.to_sym
76
- end
77
-
78
- # Helper methods for mode checking
79
- def inline_mode?
80
- @message_handling_mode == :inline
73
+ @api_base_url = "https://graph.facebook.com/v23.0"
81
74
  end
75
+ end
82
76
 
83
- def background_mode?
84
- @message_handling_mode == :background
85
- end
77
+ class HttpConfig
78
+ attr_accessor :default_gateway, :request_timeout, :response_format
86
79
 
87
- def simulator_mode?
88
- @message_handling_mode == :simulator
80
+ def initialize
81
+ @default_gateway = :simple
82
+ @request_timeout = 30
83
+ @response_format = :json
89
84
  end
90
85
  end
91
86
  end
@@ -1,8 +1,8 @@
1
1
  module FlowChat
2
- class BaseExecutor
2
+ class Executor
3
3
  def initialize(app)
4
4
  @app = app
5
- FlowChat.logger.debug { "#{log_prefix}: Initialized #{platform_name} executor middleware" }
5
+ FlowChat.logger.debug { "#{log_prefix}: Initialized executor middleware" }
6
6
  end
7
7
 
8
8
  def call(context)
@@ -13,7 +13,7 @@ module FlowChat
13
13
  FlowChat.logger.info { "#{log_prefix}: Executing flow #{flow_class.name}##{action} for session #{session_id}" }
14
14
 
15
15
  platform_app = build_platform_app(context)
16
- FlowChat.logger.debug { "#{log_prefix}: #{platform_name} app built for flow execution" }
16
+ FlowChat.logger.debug { "#{log_prefix}: app built for flow execution" }
17
17
 
18
18
  flow = flow_class.new platform_app
19
19
  FlowChat.logger.debug { "#{log_prefix}: Flow instance created, invoking #{action} method" }
@@ -32,7 +32,7 @@ module FlowChat
32
32
  FlowChat.logger.info { "#{log_prefix}: Flow terminated - Session: #{session_id}, Message: '#{e.prompt&.truncate(100)}'" }
33
33
  FlowChat.logger.debug { "#{log_prefix}: Destroying session #{session_id}" }
34
34
  context.session.destroy
35
- [:terminate, e.prompt, nil, e.media]
35
+ [:terminal, e.prompt, nil, e.media]
36
36
  rescue => error
37
37
  FlowChat.logger.error { "#{log_prefix}: Flow execution failed - #{flow_class.name}##{action}, Session: #{session_id}, Error: #{error.class.name}: #{error.message}" }
38
38
  FlowChat.logger.debug { "#{log_prefix}: Stack trace: #{error.backtrace.join("\n")}" }
@@ -41,17 +41,12 @@ module FlowChat
41
41
 
42
42
  protected
43
43
 
44
- # Subclasses must implement these methods
45
- def platform_name
46
- raise NotImplementedError, "Subclasses must implement platform_name"
47
- end
48
-
49
44
  def log_prefix
50
- raise NotImplementedError, "Subclasses must implement log_prefix"
45
+ "Executor"
51
46
  end
52
47
 
53
48
  def build_platform_app(context)
54
- raise NotImplementedError, "Subclasses must implement build_platform_app"
49
+ FlowChat::App.new(context)
55
50
  end
56
51
  end
57
- end
52
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowChat
4
+ # Factory provides centralized processor configuration for consistent setup across
5
+ # webhook and background contexts.
6
+ #
7
+ # Example:
8
+ # # In config/initializers/flow_chat.rb
9
+ # FlowChat::Factory.register :whatsapp do |controller|
10
+ # processor = FlowChat::Processor.new(controller) do |config|
11
+ # config.use_gateway FlowChat::Whatsapp::Gateway::CloudApi
12
+ # config.use_session_store FlowChat::Session::CacheSessionStore
13
+ # config.use_session_config(boundaries: [:flow])
14
+ # config.use_async(WhatsAppFlowJob)
15
+ # end
16
+ # processor.run(WhatsAppFlow, :start)
17
+ # end
18
+ #
19
+ # # In webhook controller
20
+ # FlowChat::Factory.execute(:whatsapp, controller: self)
21
+ #
22
+ # # In background job
23
+ # FlowChat::Factory.execute(:whatsapp, controller: controller)
24
+ class Factory
25
+ class << self
26
+ # Register a processor factory with a given name
27
+ #
28
+ # @param name [Symbol] The factory name (e.g., :whatsapp, :intercom)
29
+ # @param block [Proc] The factory block that receives controller
30
+ # @return [void]
31
+ #
32
+ # @example
33
+ # FlowChat::Factory.register :whatsapp do |controller|
34
+ # processor = FlowChat::Processor.new(controller) do |config|
35
+ # config.use_gateway FlowChat::Whatsapp::Gateway::CloudApi
36
+ # end
37
+ # processor.run(WhatsAppFlow, :start)
38
+ # end
39
+ def register(name, &block)
40
+ FlowChat.logger.debug { "Factory: Registering factory '#{name}'" }
41
+ factories[name] = block
42
+ end
43
+
44
+ # Execute a registered factory
45
+ #
46
+ # @param name [Symbol] The factory name
47
+ # @param controller [Object] The controller instance (webhook or background)
48
+ # @return [void]
49
+ # @raise [FactoryNotFoundError] If factory is not registered
50
+ #
51
+ # @example
52
+ # FlowChat::Factory.execute(:whatsapp, controller: self)
53
+ def execute(name, controller:)
54
+ factory = factories[name]
55
+ raise FactoryNotFoundError, "Factory '#{name}' not registered" unless factory
56
+
57
+ FlowChat.logger.debug { "Factory: Executing factory '#{name}'" }
58
+ factory.call(controller)
59
+ end
60
+
61
+ # Check if a factory is registered
62
+ #
63
+ # @param name [Symbol] The factory name
64
+ # @return [Boolean]
65
+ def registered?(name)
66
+ factories.key?(name)
67
+ end
68
+
69
+ # Get all registered factory names
70
+ #
71
+ # @return [Array<Symbol>]
72
+ def registered_factories
73
+ factories.keys
74
+ end
75
+
76
+ # Clear all registered factories (primarily for testing)
77
+ #
78
+ # @return [void]
79
+ def clear!
80
+ FlowChat.logger.debug { "Factory: Clearing all registered factories" }
81
+ factories.clear
82
+ end
83
+
84
+ private
85
+
86
+ def factories
87
+ @factories ||= {}
88
+ end
89
+ end
90
+
91
+ # Error raised when attempting to execute an unregistered factory
92
+ class FactoryNotFoundError < StandardError; end
93
+ end
94
+ end
@@ -0,0 +1,106 @@
1
+ require_relative "async_job"
2
+
3
+ module FlowChat
4
+ # Concern for gateways to support async background processing
5
+ # Mix this into gateway classes to enable async detection and job enqueueing
6
+ module GatewayAsyncSupport
7
+ attr_reader :controller, :context
8
+
9
+ # Check if gateway supports async processing
10
+ # Override in gateways that don't support async (e.g., USSD)
11
+ def async_supported?
12
+ true
13
+ end
14
+
15
+ # Detect if we're currently in background mode
16
+ def in_background?
17
+ @controller.is_a?(::FlowChat::BackgroundController)
18
+ end
19
+
20
+ # Check if async processing should be used
21
+ # Returns true if:
22
+ # - Not already in background mode
23
+ # - Processor has async enabled
24
+ # - Gateway supports async
25
+ def should_enqueue_async?
26
+ processor = @context["processor"]
27
+
28
+ !in_background? &&
29
+ processor&.async_enabled? &&
30
+ async_supported?
31
+ end
32
+
33
+ # Enqueue background job with serialized request context
34
+ # Returns true if job was enqueued, false otherwise
35
+ def enqueue_async_job
36
+ return false unless should_enqueue_async?
37
+
38
+ processor = @context["processor"]
39
+
40
+ FlowChat.logger.info { "#{self.class.name}: Async enabled - enqueuing background job" }
41
+
42
+ # Serialize request data for BackgroundController
43
+ request_data = {
44
+ params: @controller.request.params.to_h,
45
+ method: @controller.request.method,
46
+ headers: extract_headers_for_background(@controller.request),
47
+ host: extract_host(@controller.request),
48
+ path: extract_path(@controller.request),
49
+ body: extract_body_for_background(@controller.request),
50
+ remote_ip: extract_remote_ip(@controller.request)
51
+ }
52
+
53
+ # Enqueue user's job with request context and job params
54
+ processor.async_job_class.perform_later(
55
+ request_context: request_data,
56
+ **processor.async_job_params
57
+ )
58
+
59
+ FlowChat.logger.info { "#{self.class.name}: Background job enqueued successfully" }
60
+
61
+ true
62
+ end
63
+
64
+ # Extract serializable headers needed for background processing
65
+ # Override in gateways that need additional headers
66
+ def extract_headers_for_background(request)
67
+ {
68
+ "Content-Type" => request.headers["Content-Type"],
69
+ "User-Agent" => request.headers["User-Agent"]
70
+ }.compact
71
+ end
72
+
73
+ # Extract host from request for URL boundary support
74
+ def extract_host(request)
75
+ request.host
76
+ rescue
77
+ nil
78
+ end
79
+
80
+ # Extract path from request for URL boundary support
81
+ def extract_path(request)
82
+ request.path
83
+ rescue
84
+ nil
85
+ end
86
+
87
+ # Extract request body for background processing
88
+ # Override in gateways that need the request body
89
+ def extract_body_for_background(request)
90
+ return nil unless request.body
91
+
92
+ body_content = request.body.read
93
+ request.body.rewind # Reset for subsequent reads
94
+ body_content
95
+ rescue
96
+ nil
97
+ end
98
+
99
+ # Extract remote IP from request
100
+ def extract_remote_ip(request)
101
+ request.remote_ip
102
+ rescue
103
+ nil
104
+ end
105
+ end
106
+ end