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
@@ -0,0 +1,188 @@
1
+ require "middleware"
2
+
3
+ module FlowChat
4
+ class Processor
5
+ include FlowChat::Instrumentation
6
+
7
+ attr_reader :custom_middleware_builder, :context, :async_job_class, :async_job_params
8
+
9
+ def initialize(controller, enable_simulator: nil)
10
+ FlowChat.logger.debug { "Processor: Initializing processor for controller #{controller.class.name}" }
11
+
12
+ @context = FlowChat::Context.new
13
+ @context["controller"] = controller
14
+ @context["enable_simulator"] = enable_simulator.nil? ? (defined?(Rails) && Rails.env.local?) : enable_simulator
15
+ @custom_middleware_builder = ::Middleware::Builder.new(name: "processor.custom_middleware_builder")
16
+ @session_options = FlowChat::Config.session
17
+ @async_job_class = nil
18
+ @async_job_params = {}
19
+
20
+ FlowChat.logger.debug { "Processor: Simulator mode #{@context["enable_simulator"] ? "enabled" : "disabled"}" }
21
+
22
+ yield self if block_given?
23
+
24
+ FlowChat.logger.debug { "Processor: Initialized #{self.class.name} successfully" }
25
+ end
26
+
27
+ def use_gateway(gateway_class, *args)
28
+ FlowChat.logger.debug { "Processor: Configuring gateway #{gateway_class.name} with args: #{args.inspect}" }
29
+ @gateway_class = gateway_class
30
+ @gateway_args = args
31
+ self
32
+ end
33
+
34
+ def use_session_store(session_store)
35
+ raise "Session store must be a class" unless session_store.is_a?(Class)
36
+ FlowChat.logger.debug { "Processor: Configuring session store #{session_store.name}" }
37
+ @context["session.store"] = session_store
38
+ self
39
+ end
40
+
41
+ def use_session_config(boundaries: nil, hash_identifiers: nil, identifier: nil, &block)
42
+ if block_given?
43
+ FlowChat.logger.debug { "Processor: Configuring session config with custom proc" }
44
+ @session_options = @session_options.dup
45
+ @session_options.session_id_proc = block
46
+ else
47
+ FlowChat.logger.debug { "Processor: Configuring session config: boundaries=#{boundaries.inspect}, hash_identifiers=#{hash_identifiers}, identifier=#{identifier}" }
48
+
49
+ # Update the session options directly
50
+ @session_options = @session_options.dup
51
+ @session_options.boundaries = Array(boundaries) unless boundaries.nil?
52
+ @session_options.hash_identifiers = hash_identifiers unless hash_identifiers.nil?
53
+ @session_options.identifier = identifier unless identifier.nil?
54
+ end
55
+
56
+ self
57
+ end
58
+
59
+ def use_middleware(middleware)
60
+ if block_given?
61
+ yield custom_middleware_builder
62
+ return self
63
+ end
64
+
65
+ raise "Middleware must be a class" unless middleware.is_a?(Class)
66
+ FlowChat.logger.debug { "Processor: Adding custom middleware: #{middleware.name}" }
67
+ custom_middleware_builder.use middleware
68
+ self
69
+ end
70
+
71
+ def use_cross_platform_sessions
72
+ FlowChat.logger.debug { "Processor: Enabling cross-platform sessions via session configuration" }
73
+ use_session_config(
74
+ boundaries: [:flow]
75
+ )
76
+ end
77
+
78
+ def use_url_isolation
79
+ FlowChat.logger.debug { "Processor: Enabling URL-based session isolation" }
80
+ current_boundaries = @session_options.boundaries.dup
81
+ current_boundaries << :url unless current_boundaries.include?(:url)
82
+ use_session_config(boundaries: current_boundaries)
83
+ end
84
+
85
+ def use_durable_sessions(cross_gateway: false)
86
+ FlowChat.logger.debug { "Processor: Enabling durable sessions via session configuration" }
87
+ use_session_config(
88
+ identifier: :user_id
89
+ )
90
+ end
91
+
92
+ def use_async(job_class = nil, **job_params)
93
+ # If no job class provided, use GenericAsyncJob with factory param
94
+ if job_class.nil?
95
+ unless job_params.key?(:factory)
96
+ raise ArgumentError, "When use_async is called without a job class, factory: parameter is required"
97
+ end
98
+
99
+ FlowChat.logger.debug { "Processor: Configuring async processing with GenericAsyncJob for factory '#{job_params[:factory]}'" }
100
+ @async_job_class = FlowChat::GenericAsyncJob
101
+ else
102
+ FlowChat.logger.debug { "Processor: Configuring async processing with job class #{job_class.name} and params: #{job_params.inspect}" }
103
+ @async_job_class = job_class
104
+ end
105
+ @async_job_params = job_params
106
+
107
+ self
108
+ end
109
+
110
+ def async_enabled?
111
+ !@async_job_class.nil?
112
+ end
113
+
114
+ def run(flow_class, action, **options)
115
+ # Instrument flow execution (this will log via LogSubscriber)
116
+ instrument(Events::FLOW_EXECUTION_START, {
117
+ flow_name: flow_class.name.underscore,
118
+ action: action.to_s,
119
+ processor_type: self.class.name
120
+ })
121
+
122
+ @context["processor"] = self
123
+ @context["flow.name"] = flow_class.name.underscore
124
+ @context["flow.class"] = flow_class
125
+ @context["flow.action"] = action
126
+ @context["flow.options"] = options
127
+
128
+ FlowChat.logger.debug { "Processor: Context prepared for flow #{flow_class.name}" }
129
+
130
+ stack = create_middleware_stack
131
+ yield stack if block_given?
132
+
133
+ FlowChat.logger.debug { "Processor: Executing middleware stack for #{flow_class.name}##{action}" }
134
+
135
+ # Instrument flow execution with timing (this will log completion via LogSubscriber)
136
+ instrument(Events::FLOW_EXECUTION_END, {
137
+ flow_name: flow_class.name.underscore,
138
+ action: action.to_s
139
+ }) do
140
+ stack.call(@context)
141
+ end
142
+ rescue => error
143
+ FlowChat.logger.error { "Processor: Flow execution failed - #{flow_class.name}##{action}, Error: #{error.class.name}: #{error.message}" }
144
+ FlowChat.logger.debug { "Processor: Stack trace: #{error.backtrace.join("\n")}" }
145
+
146
+ # Instrument flow execution error (this will log error via LogSubscriber)
147
+ instrument(Events::FLOW_EXECUTION_ERROR, {
148
+ flow_name: flow_class.name.underscore,
149
+ action: action.to_s,
150
+ error_class: error.class.name,
151
+ error_message: error.message,
152
+ backtrace: error.backtrace&.first(10)
153
+ })
154
+
155
+ raise
156
+ end
157
+
158
+ protected
159
+
160
+ # Helper method for building stacks
161
+ def create_middleware_stack
162
+ raise ArgumentError, "Gateway is required. Call use_gateway(gateway_class, *args) before running." unless @gateway_class
163
+
164
+ middleware_stack = ::Middleware::Builder.new(name: @gateway_class.name) do |b|
165
+ # Gateway always comes first
166
+ b.use @gateway_class, *@gateway_args
167
+ # Session middleware next. We need to setup our session identifiers
168
+ b.use FlowChat::Session::Middleware, @session_options
169
+
170
+ if @gateway_class.respond_to?(:configure_middleware_stack)
171
+ FlowChat.logger.debug { "Processor: Using platform specific middleware configuration" }
172
+ @gateway_class.configure_middleware_stack(b, custom_middleware_builder)
173
+ else
174
+ b.use custom_middleware_builder
175
+ FlowChat.logger.debug { "Processor: Added custom middleware" }
176
+ end
177
+
178
+ # Executor always goes last.
179
+ # Nothing can execute after it.
180
+ b.use FlowChat::Executor
181
+ end
182
+
183
+ middleware_stack.inject_logger(FlowChat.logger) if FlowChat::Config.inject_middleware_logger
184
+
185
+ middleware_stack
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,58 @@
1
+ require "kramdown"
2
+ require "rails-html-sanitizer"
3
+
4
+ module FlowChat
5
+ module Renderers
6
+ module MarkdownSupport
7
+ def to_html(text)
8
+ return "" if text.nil?
9
+
10
+ html = Kramdown::Document.new(text.to_s, **kramdown_options).to_html.strip
11
+ sanitize_html(html)
12
+ end
13
+
14
+ def sanitize_html(html)
15
+ sanitized = self.class.sanitizer.sanitize(
16
+ html,
17
+ tags: allowed_tags,
18
+ attributes: allowed_attributes
19
+ )
20
+
21
+ post_process_html(sanitized)
22
+ end
23
+
24
+ def self.included(base)
25
+ base.extend(ClassMethods)
26
+ end
27
+
28
+ module ClassMethods
29
+ def sanitizer
30
+ @sanitizer ||= Rails::Html::SafeListSanitizer.new
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ # Override in subclasses to customize Kramdown options
37
+ # Default uses straight quotes (ASCII 39/34) instead of curly smart quotes
38
+ def kramdown_options
39
+ {smart_quotes: [39, 39, 34, 34]}
40
+ end
41
+
42
+ # Override in subclasses to specify allowed HTML tags
43
+ def allowed_tags
44
+ %w[b strong i em a code pre]
45
+ end
46
+
47
+ # Override in subclasses to specify allowed HTML attributes
48
+ def allowed_attributes
49
+ %w[href]
50
+ end
51
+
52
+ # Override in subclasses to post-process sanitized HTML
53
+ def post_process_html(html)
54
+ html
55
+ end
56
+ end
57
+ end
58
+ end
@@ -111,23 +111,7 @@ module FlowChat
111
111
  private
112
112
 
113
113
  def session_key
114
- return "flow_chat:session:nil_context" unless @context
115
-
116
- gateway = @context["request.gateway"]
117
- msisdn = @context["request.msisdn"]
118
-
119
- key = case gateway
120
- when :whatsapp_cloud_api
121
- "flow_chat:session:whatsapp:#{msisdn}"
122
- when :nalo, :nsano
123
- session_id = @context["request.id"]
124
- "flow_chat:session:ussd:#{session_id}:#{msisdn}"
125
- else
126
- "flow_chat:session:unknown:#{msisdn}"
127
- end
128
-
129
- FlowChat.logger.debug { "CacheSessionStore: Generated session key: #{key}" }
130
- key
114
+ "flow_chat:cached_session:#{@context["session.id"]}"
131
115
  end
132
116
 
133
117
  def session_ttl
@@ -1,3 +1,5 @@
1
+ require "digest"
2
+
1
3
  module FlowChat
2
4
  module Session
3
5
  class Middleware
@@ -52,6 +54,14 @@ module FlowChat
52
54
  return session_id
53
55
  end
54
56
 
57
+ # Check for custom session ID proc
58
+ if @session_options.session_id_proc
59
+ FlowChat.logger.debug { "Session::Middleware: Using custom session ID proc" }
60
+ session_id = @session_options.session_id_proc.call(context)
61
+ FlowChat.logger.debug { "Session::Middleware: Generated custom session ID: #{session_id}" }
62
+ return session_id
63
+ end
64
+
55
65
  FlowChat.logger.debug { "Session::Middleware: Building session ID for platform=#{platform}, gateway=#{gateway}, flow=#{flow_name}" }
56
66
 
57
67
  # Get identifier based on configuration
@@ -64,32 +74,33 @@ module FlowChat
64
74
  end
65
75
 
66
76
  def get_session_identifier(context)
67
- identifier_type = @session_options.identifier
68
-
69
- # If no identifier specified, use platform defaults
70
- if identifier_type.nil?
71
- platform = context["request.platform"]
72
- identifier_type = case platform
73
- when :ussd
74
- :request_id # USSD defaults to ephemeral sessions
75
- when :whatsapp
76
- :msisdn # WhatsApp defaults to durable sessions
77
- else
78
- :msisdn # Default fallback to durable
79
- end
80
- end
81
-
77
+ identifier_type = @session_options.identifier || platform_default_identifier(context)
78
+
82
79
  case identifier_type
83
80
  when :request_id
84
81
  context["request.id"]
82
+ when :user_id
83
+ user_id = context["request.user_id"]
84
+ @session_options.hash_identifiers ? hash_identifier(user_id) : user_id
85
85
  when :msisdn
86
- phone = context["request.msisdn"]
87
- @session_options.hash_phone_numbers ? hash_phone_number(phone) : phone
86
+ msisdn = context["request.msisdn"]
87
+ @session_options.hash_identifiers ? hash_identifier(msisdn) : msisdn
88
88
  else
89
89
  raise "Invalid session identifier type: #{identifier_type}"
90
90
  end
91
91
  end
92
92
 
93
+ def platform_default_identifier(context)
94
+ platform = context["request.platform"]
95
+
96
+ case platform
97
+ when :whatsapp
98
+ :msisdn
99
+ else
100
+ :request_id
101
+ end
102
+ end
103
+
93
104
  def build_session_id(flow_name, platform, gateway, identifier)
94
105
  parts = []
95
106
 
@@ -120,19 +131,26 @@ module FlowChat
120
131
  return nil unless request
121
132
 
122
133
  # Extract host and path for URL boundary
123
- host = request.host rescue nil
124
- path = request.path rescue nil
134
+ host = begin
135
+ request.host
136
+ rescue
137
+ nil
138
+ end
139
+ path = begin
140
+ request.path
141
+ rescue
142
+ nil
143
+ end
125
144
 
126
- # Create a normalized URL identifier: host + path
145
+ # Create a normalized URL identifier: host + path
127
146
  # e.g., "example.com/api/v1/ussd" or "tenant1.example.com/ussd"
128
147
  url_parts = []
129
148
  url_parts << host if host.present?
130
- url_parts << path.sub(/^\//, '') if path.present? && path != '/'
149
+ url_parts << path.sub(/^\//, "") if path.present? && path != "/"
131
150
 
132
151
  # For long URLs, use first part + hash suffix instead of full hash
133
- url_identifier = url_parts.join('/').gsub(/[^a-zA-Z0-9._-]/, '_')
152
+ url_identifier = url_parts.join("/").gsub(/[^a-zA-Z0-9._-]/, "_")
134
153
  if url_identifier.length > 50
135
- require 'digest'
136
154
  # Take first 41 chars + hash suffix to keep it manageable but recognizable
137
155
  first_part = url_identifier[0, 41]
138
156
  hash_suffix = Digest::SHA256.hexdigest(url_identifier)[0, 8]
@@ -142,10 +160,9 @@ module FlowChat
142
160
  url_identifier
143
161
  end
144
162
 
145
- def hash_phone_number(phone)
163
+ def hash_identifier(identifier)
146
164
  # Use SHA256 but only take first 8 characters for reasonable session IDs
147
- require 'digest'
148
- Digest::SHA256.hexdigest(phone.to_s)[0, 8]
165
+ Digest::SHA256.hexdigest(identifier.to_s)[0, 8]
149
166
  end
150
167
  end
151
168
  end
@@ -23,12 +23,12 @@ module FlowChat
23
23
  end
24
24
 
25
25
  def default_config_key
26
- "ussd"
26
+ :ussd
27
27
  end
28
28
 
29
- def simulator_configurations
29
+ def configurations
30
30
  {
31
- "ussd" => {
31
+ ussd: {
32
32
  name: "USSD (Nalo)",
33
33
  description: "USSD integration using Nalo",
34
34
  processor_type: "ussd",
@@ -41,7 +41,7 @@ module FlowChat
41
41
  session_timeout: 300
42
42
  }
43
43
  },
44
- "whatsapp" => {
44
+ whatsapp: {
45
45
  name: "WhatsApp (Cloud API)",
46
46
  description: "WhatsApp integration using Cloud API",
47
47
  processor_type: "whatsapp",
@@ -53,6 +53,18 @@ module FlowChat
53
53
  phone_number: default_phone_number,
54
54
  contact_name: default_contact_name
55
55
  }
56
+ },
57
+ http: {
58
+ name: "HTTP API",
59
+ description: "HTTP integration with JSON request/response",
60
+ processor_type: "http",
61
+ gateway: "http_simple",
62
+ endpoint: "/http/webhook",
63
+ icon: "🌐",
64
+ color: "#0066cc",
65
+ settings: {
66
+ user_id: default_phone_number
67
+ }
56
68
  }
57
69
  }
58
70
  end
@@ -71,7 +83,7 @@ module FlowChat
71
83
  default_phone_number: default_phone_number,
72
84
  default_contact_name: default_contact_name,
73
85
  default_config_key: default_config_key,
74
- configurations: simulator_configurations
86
+ configurations: configurations
75
87
  }
76
88
  end
77
89