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,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
@@ -29,7 +31,7 @@ module FlowChat
29
31
  end
30
32
 
31
33
  class SessionConfig
32
- attr_accessor :boundaries, :hash_identifiers, :identifier
34
+ attr_accessor :boundaries, :hash_identifiers, :identifier, :session_id_proc
33
35
 
34
36
  def initialize
35
37
  # Session boundaries control how session IDs are constructed
@@ -37,14 +39,17 @@ module FlowChat
37
39
  # :gateway = separate sessions per gateway
38
40
  # :platform = separate sessions per platform (ussd, whatsapp)
39
41
  @boundaries = [:flow, :gateway, :platform]
40
-
42
+
41
43
  # Always hash phone numbers for privacy
42
44
  @hash_identifiers = true
43
-
45
+
44
46
  # Session identifier type (nil = let platforms choose their default)
45
47
  # :msisdn = durable sessions (durable across timeouts)
46
48
  # :request_id = ephemeral sessions (new session each time)
47
49
  @identifier = nil
50
+
51
+ # Proc for custom session ID generation (overrides default behavior when set)
52
+ @session_id_proc = nil
48
53
  end
49
54
  end
50
55
 
@@ -62,35 +67,10 @@ module FlowChat
62
67
  end
63
68
 
64
69
  class WhatsappConfig
65
- attr_accessor :background_job_class
66
- attr_reader :message_handling_mode, :api_base_url
70
+ attr_reader :api_base_url
67
71
 
68
72
  def initialize
69
- @message_handling_mode = :inline
70
- @background_job_class = "WhatsappMessageJob"
71
- @api_base_url = "https://graph.facebook.com/v22.0"
72
- end
73
-
74
- # Validate message handling mode
75
- def message_handling_mode=(mode)
76
- valid_modes = [:inline, :background, :simulator]
77
- unless valid_modes.include?(mode.to_sym)
78
- raise ArgumentError, "Invalid message handling mode: #{mode}. Valid modes: #{valid_modes.join(", ")}"
79
- end
80
- @message_handling_mode = mode.to_sym
81
- end
82
-
83
- # Helper methods for mode checking
84
- def inline_mode?
85
- @message_handling_mode == :inline
86
- end
87
-
88
- def background_mode?
89
- @message_handling_mode == :background
90
- end
91
-
92
- def simulator_mode?
93
- @message_handling_mode == :simulator
73
+ @api_base_url = "https://graph.facebook.com/v23.0"
94
74
  end
95
75
  end
96
76
 
@@ -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" }
@@ -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
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowChat
4
+ # Generic background job that uses the Factory pattern
5
+ # Automatically used when use_async is called without a job class
6
+ #
7
+ # Example:
8
+ # # In webhook controller
9
+ # processor = FlowChat::Processor.new(self) do |config|
10
+ # config.use_gateway FlowChat::Whatsapp::Gateway::CloudApi
11
+ # config.use_session_store FlowChat::Session::CacheSessionStore
12
+ # config.use_async(factory: :whatsapp) # No job class - uses GenericAsyncJob
13
+ # end
14
+ #
15
+ # # Background job executes:
16
+ # FlowChat::Factory.execute(:whatsapp, controller: controller)
17
+ class GenericAsyncJob < AsyncJob
18
+ def execute(controller, factory:, **job_params)
19
+ FlowChat.logger.debug { "GenericAsyncJob: Executing factory '#{factory}' with params: #{job_params.inspect}" }
20
+
21
+ unless FlowChat::Factory.registered?(factory)
22
+ raise FlowChat::Factory::FactoryNotFoundError, "Factory '#{factory}' not registered"
23
+ end
24
+
25
+ FlowChat::Factory.execute(factory, controller: controller)
26
+
27
+ FlowChat.logger.debug { "GenericAsyncJob: Factory '#{factory}' executed successfully" }
28
+ end
29
+ end
30
+ end
@@ -1,33 +1,50 @@
1
1
  module FlowChat
2
2
  module Http
3
+ class ConfigurationError < StandardError; end
4
+
3
5
  module Gateway
4
6
  class Simple
5
7
  include FlowChat::Instrumentation
8
+ include FlowChat::GatewayAsyncSupport
6
9
 
7
10
  attr_reader :context
8
11
 
9
- def initialize(app)
12
+ def initialize(app, user_params)
10
13
  @app = app
14
+ @user_params = user_params
15
+
16
+ validate_user_params!
11
17
  end
12
18
 
13
19
  def call(context)
14
20
  @context = context
15
- params = context.controller.request.params
16
- request = context.controller.request
17
-
18
- # Extract basic request information
19
- context["request.id"] = params["session_id"] || SecureRandom.uuid
20
- context["request.msisdn"] = FlowChat::PhoneNumberUtil.to_e164(params["msisdn"])
21
- context["request.user_id"] = params["user_id"] || context["request.msisdn"] || context["request.id"]
22
- context["request.message_id"] = params["message_id"] || SecureRandom.uuid
21
+ @controller = context.controller
22
+ params = @controller.request.params
23
+ request = @controller.request
24
+
25
+ # Validate request method
26
+ unless request.get? || request.post?
27
+ @controller.head :bad_request
28
+ return
29
+ end
30
+
31
+ # Set request information from user_params
32
+ context["request.id"] = @user_params[:session_id]
33
+ context["request.user_id"] = @user_params[:user_id]
34
+ context["request.user_name"] = @user_params[:name] if @user_params[:name]
35
+ context["request.msisdn"] = @user_params[:msisdn] if @user_params[:msisdn]
36
+ context["request.email"] = @user_params[:email] if @user_params[:email]
37
+ context["request.message_id"] = SecureRandom.uuid
23
38
  context["request.timestamp"] = Time.current.iso8601
24
39
  context["request.gateway"] = :http_simple
25
40
  context["request.platform"] = :http
26
- context["request.network"] = nil
27
- context["request.method"] = request.method
28
- context["request.path"] = request.path
29
- context["request.user_agent"] = request.user_agent
30
- context.input = params["input"] || params["message"]
41
+ context["request.body"] = (params.respond_to?(:to_unsafe_h) ? params.to_unsafe_h : params.to_h).transform_keys(&:to_s)
42
+
43
+ # HTTP-specific request metadata
44
+ context["http.method"] = request.method
45
+ context["http.path"] = request.path
46
+ context["http.user_agent"] = request.user_agent
47
+ context.input = params["input"].presence || ""
31
48
 
32
49
  # Instrument message received when user provides input
33
50
  if context.input.present?
@@ -38,31 +55,62 @@ module FlowChat
38
55
  })
39
56
  end
40
57
 
41
- # Process the request
42
- type, prompt, choices, media = @app.call(context)
58
+ # Determine routing: async enqueue, background execute, or inline
59
+ if should_enqueue_async?
60
+ # HTTP request with async enabled → enqueue job and return immediately
61
+ enqueue_async_job
62
+ @controller.render json: {status: "processing"}
63
+ else
64
+ # Background OR inline → process message
65
+ # Process the request
66
+ response = @app.call(context)
43
67
 
44
- # Instrument message sent
45
- instrument(Events::MESSAGE_SENT, {
46
- to: context["request.user_id"],
47
- session_id: context["request.id"],
48
- message: context.input || "",
49
- message_type: (type == :prompt) ? "prompt" : "terminal",
50
- gateway: :http_simple,
51
- platform: :http,
52
- content_length: prompt.to_s.length,
53
- timestamp: context["request.timestamp"]
54
- })
55
-
56
- # Render response as JSON
57
- response_data = render_response(type, prompt, choices, media)
58
- context.controller.render json: response_data
68
+ # Handle nil response (e.g., from middleware that handles the response itself)
69
+ unless response
70
+ return @controller.render json: {
71
+ type: :skip,
72
+ session_id: context["request.id"],
73
+ user_id: context["request.user_id"],
74
+ timestamp: context["request.timestamp"]
75
+ }
76
+ end
77
+
78
+ type, prompt, choices, media = response
79
+
80
+ # Instrument message sent
81
+ instrument(Events::MESSAGE_SENT, {
82
+ to: context["request.user_id"],
83
+ session_id: context["request.id"],
84
+ message: context.input || "",
85
+ message_type: (type == :prompt) ? "prompt" : "terminal",
86
+ gateway: :http_simple,
87
+ platform: :http,
88
+ content_length: prompt.to_s.length,
89
+ timestamp: context["request.timestamp"]
90
+ })
91
+
92
+ # Render response as JSON
93
+ response_data = render_response(type, prompt, choices, media)
94
+ @controller.render json: response_data
95
+ end
59
96
  end
60
97
 
61
98
  private
62
99
 
100
+ def validate_user_params!
101
+ required_keys = [:session_id, :user_id]
102
+
103
+ required_keys.each do |key|
104
+ unless @user_params.key?(key)
105
+ raise FlowChat::Http::ConfigurationError,
106
+ "HTTP Simple gateway requires :#{key} in user_params"
107
+ end
108
+ end
109
+ end
110
+
63
111
  def render_response(type, prompt, choices, media)
64
112
  rendered = FlowChat::Http::Renderer.new(prompt, choices: choices, media: media).render
65
-
113
+
66
114
  {
67
115
  type: type,
68
116
  session_id: context["request.id"],
@@ -74,4 +122,4 @@ module FlowChat
74
122
  end
75
123
  end
76
124
  end
77
- end
125
+ end