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.
- checksums.yaml +4 -4
- data/.cliff.toml +74 -0
- data/.github/workflows/ci.yml +2 -3
- data/.github/workflows/release.yml +56 -0
- data/.standard.yml +4 -0
- data/CHANGELOG.md +22 -0
- data/CLAUDE.md +327 -0
- data/CONTRIBUTING.md +134 -0
- data/Gemfile +1 -0
- data/README.md +290 -105
- data/Rakefile +5 -1
- data/SECURITY.md +42 -349
- data/docs/architecture.md +510 -0
- data/docs/async-background-processing.md +298 -0
- data/docs/configuration.md +556 -226
- data/docs/factory-pattern.md +355 -0
- data/docs/gateway-context-variables.md +171 -0
- data/docs/gateway-development.md +723 -0
- data/docs/getting-started.md +429 -0
- data/docs/instrumentation.md +264 -153
- data/docs/platforms/telegram.md +1013 -0
- data/docs/platforms/ussd.md +693 -0
- data/docs/platforms/whatsapp.md +1395 -0
- data/docs/testing.md +243 -365
- data/examples/custom_session_id_example.rb +119 -0
- data/examples/http_controller.rb +154 -0
- data/examples/intercom_configuration_example.rb +118 -0
- data/examples/intercom_controller.rb +194 -0
- data/examples/multi_tenant_whatsapp_controller.rb +4 -4
- data/examples/simulator_controller.rb +21 -1
- data/examples/ussd_controller.rb +10 -10
- data/examples/whatsapp_controller.rb +2 -2
- data/flow_chat.gemspec +4 -0
- data/lib/flow_chat/{base_app.rb → app.rb} +28 -9
- data/lib/flow_chat/async_job.rb +176 -0
- data/lib/flow_chat/config.rb +23 -28
- data/lib/flow_chat/{base_executor.rb → executor.rb} +7 -12
- data/lib/flow_chat/factory.rb +94 -0
- data/lib/flow_chat/gateway_async_support.rb +106 -0
- data/lib/flow_chat/generic_async_job.rb +30 -0
- data/lib/flow_chat/http/gateway/simple.rb +125 -0
- data/lib/flow_chat/http/renderer.rb +41 -0
- data/lib/flow_chat/instrumentation/setup.rb +1 -1
- data/lib/flow_chat/instrumentation.rb +25 -0
- data/lib/flow_chat/intercom/client.rb +155 -0
- data/lib/flow_chat/intercom/configuration.rb +149 -0
- data/lib/flow_chat/intercom/gateway/intercom_api.rb +396 -0
- data/lib/flow_chat/intercom/renderer.rb +71 -0
- data/lib/flow_chat/phone_number_util.rb +49 -0
- data/lib/flow_chat/processor.rb +188 -0
- data/lib/flow_chat/renderers/markdown_support.rb +58 -0
- data/lib/flow_chat/session/cache_session_store.rb +1 -17
- data/lib/flow_chat/session/middleware.rb +43 -26
- data/lib/flow_chat/simulator/controller.rb +17 -5
- data/lib/flow_chat/simulator/views/simulator.html.erb +220 -8
- data/lib/flow_chat/telegram/client.rb +240 -0
- data/lib/flow_chat/telegram/configuration.rb +118 -0
- data/lib/flow_chat/telegram/gateway/bot_api.rb +300 -0
- data/lib/flow_chat/telegram/middleware/choice_mapper.rb +35 -0
- data/lib/flow_chat/telegram/renderer.rb +125 -0
- data/lib/flow_chat/telegram.rb +7 -0
- data/lib/flow_chat/ussd/gateway/nalo.rb +27 -11
- data/lib/flow_chat/ussd/middleware/pagination.rb +9 -5
- data/lib/flow_chat/ussd/renderer.rb +1 -1
- data/lib/flow_chat/version.rb +1 -1
- data/lib/flow_chat/whatsapp/client.rb +144 -13
- data/lib/flow_chat/whatsapp/configuration.rb +1 -1
- data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +144 -106
- data/lib/flow_chat/whatsapp/id_generator.rb +124 -0
- data/lib/flow_chat/whatsapp/middleware/choice_mapper.rb +147 -0
- data/lib/flow_chat/whatsapp/renderer.rb +145 -11
- data/lib/flow_chat.rb +11 -1
- data/lib/tasks/release.rake +165 -0
- metadata +88 -21
- data/docs/flows.md +0 -320
- data/docs/images/simulator.png +0 -0
- data/docs/media.md +0 -153
- data/docs/sessions.md +0 -433
- data/docs/ussd-setup.md +0 -322
- data/docs/whatsapp-setup.md +0 -162
- data/examples/whatsapp_message_job.rb +0 -113
- data/lib/flow_chat/base_processor.rb +0 -145
- data/lib/flow_chat/session/rails_session_store.rb +0 -68
- data/lib/flow_chat/ussd/app.rb +0 -6
- data/lib/flow_chat/ussd/gateway/nsano.rb +0 -98
- data/lib/flow_chat/ussd/middleware/executor.rb +0 -24
- data/lib/flow_chat/ussd/processor.rb +0 -39
- data/lib/flow_chat/whatsapp/app.rb +0 -29
- data/lib/flow_chat/whatsapp/middleware/executor.rb +0 -24
- data/lib/flow_chat/whatsapp/processor.rb +0 -32
- data/lib/flow_chat/whatsapp/send_job_support.rb +0 -79
data/examples/ussd_controller.rb
CHANGED
|
@@ -5,10 +5,10 @@ class UssdController < ApplicationController
|
|
|
5
5
|
skip_forgery_protection
|
|
6
6
|
|
|
7
7
|
def process_request
|
|
8
|
-
processor = FlowChat::
|
|
8
|
+
processor = FlowChat::Processor.new(self) do |config|
|
|
9
9
|
config.use_gateway FlowChat::Ussd::Gateway::Nalo
|
|
10
|
-
# Use
|
|
11
|
-
config.use_session_store FlowChat::Session::
|
|
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::
|
|
197
|
+
processor = FlowChat::Processor.new(self) do |config|
|
|
198
198
|
config.use_gateway FlowChat::Ussd::Gateway::Nalo
|
|
199
|
-
config.use_session_store FlowChat::Session::
|
|
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::
|
|
231
|
+
processor = FlowChat::Processor.new(self) do |config|
|
|
232
232
|
config.use_gateway FlowChat::Ussd::Gateway::Nalo
|
|
233
|
-
config.use_session_store FlowChat::Session::
|
|
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
|
-
#
|
|
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::
|
|
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::
|
|
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::
|
|
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
|
|
3
|
-
attr_reader :
|
|
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
|
|
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
|
data/lib/flow_chat/config.rb
CHANGED
|
@@ -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, :
|
|
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
|
-
@
|
|
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
|
-
|
|
61
|
-
attr_reader :message_handling_mode, :api_base_url
|
|
70
|
+
attr_reader :api_base_url
|
|
62
71
|
|
|
63
72
|
def initialize
|
|
64
|
-
@
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
end
|
|
77
|
+
class HttpConfig
|
|
78
|
+
attr_accessor :default_gateway, :request_timeout, :response_format
|
|
86
79
|
|
|
87
|
-
def
|
|
88
|
-
@
|
|
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
|
|
2
|
+
class Executor
|
|
3
3
|
def initialize(app)
|
|
4
4
|
@app = app
|
|
5
|
-
FlowChat.logger.debug { "#{log_prefix}: Initialized
|
|
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}:
|
|
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
|
-
[:
|
|
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
|
-
|
|
45
|
+
"Executor"
|
|
51
46
|
end
|
|
52
47
|
|
|
53
48
|
def build_platform_app(context)
|
|
54
|
-
|
|
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
|