flow_chat 0.6.1 → 0.7.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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +44 -0
  3. data/.gitignore +2 -1
  4. data/README.md +84 -1229
  5. data/docs/configuration.md +337 -0
  6. data/docs/flows.md +320 -0
  7. data/docs/images/simulator.png +0 -0
  8. data/docs/instrumentation.md +216 -0
  9. data/docs/media.md +153 -0
  10. data/docs/testing.md +475 -0
  11. data/docs/ussd-setup.md +306 -0
  12. data/docs/whatsapp-setup.md +162 -0
  13. data/examples/multi_tenant_whatsapp_controller.rb +9 -37
  14. data/examples/simulator_controller.rb +9 -18
  15. data/examples/ussd_controller.rb +32 -38
  16. data/examples/whatsapp_controller.rb +32 -125
  17. data/examples/whatsapp_media_examples.rb +68 -336
  18. data/examples/whatsapp_message_job.rb +5 -3
  19. data/flow_chat.gemspec +6 -2
  20. data/lib/flow_chat/base_processor.rb +48 -2
  21. data/lib/flow_chat/config.rb +5 -0
  22. data/lib/flow_chat/context.rb +13 -1
  23. data/lib/flow_chat/instrumentation/log_subscriber.rb +176 -0
  24. data/lib/flow_chat/instrumentation/metrics_collector.rb +197 -0
  25. data/lib/flow_chat/instrumentation/setup.rb +155 -0
  26. data/lib/flow_chat/instrumentation.rb +70 -0
  27. data/lib/flow_chat/prompt.rb +20 -20
  28. data/lib/flow_chat/session/cache_session_store.rb +73 -7
  29. data/lib/flow_chat/session/middleware.rb +37 -4
  30. data/lib/flow_chat/session/rails_session_store.rb +36 -1
  31. data/lib/flow_chat/simulator/controller.rb +6 -6
  32. data/lib/flow_chat/ussd/gateway/nalo.rb +30 -0
  33. data/lib/flow_chat/ussd/gateway/nsano.rb +33 -0
  34. data/lib/flow_chat/ussd/middleware/choice_mapper.rb +109 -0
  35. data/lib/flow_chat/ussd/middleware/executor.rb +24 -2
  36. data/lib/flow_chat/ussd/middleware/pagination.rb +87 -7
  37. data/lib/flow_chat/ussd/processor.rb +14 -0
  38. data/lib/flow_chat/ussd/renderer.rb +1 -1
  39. data/lib/flow_chat/version.rb +1 -1
  40. data/lib/flow_chat/whatsapp/client.rb +99 -12
  41. data/lib/flow_chat/whatsapp/configuration.rb +35 -4
  42. data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +120 -34
  43. data/lib/flow_chat/whatsapp/middleware/executor.rb +24 -2
  44. data/lib/flow_chat/whatsapp/processor.rb +8 -0
  45. data/lib/flow_chat/whatsapp/renderer.rb +4 -9
  46. data/lib/flow_chat.rb +23 -0
  47. metadata +22 -11
  48. data/.travis.yml +0 -6
  49. data/app/controllers/demo_controller.rb +0 -101
  50. data/app/flow_chat/demo_restaurant_flow.rb +0 -889
  51. data/config/routes_demo.rb +0 -59
  52. data/examples/initializer.rb +0 -86
  53. data/examples/media_prompts_examples.rb +0 -27
  54. data/images/ussd_simulator.png +0 -0
@@ -0,0 +1,176 @@
1
+ module FlowChat
2
+ module Instrumentation
3
+ class LogSubscriber
4
+ # Flow execution events
5
+ def flow_execution_start(event)
6
+ payload = event.payload
7
+ FlowChat.logger.info { "Flow Execution Started: #{payload[:flow_name]}##{payload[:action]} [Session: #{payload[:session_id]}]" }
8
+ end
9
+
10
+ def flow_execution_end(event)
11
+ payload = event.payload
12
+ duration = event.duration.round(2)
13
+ FlowChat.logger.info { "Flow Execution Completed: #{payload[:flow_name]}##{payload[:action]} (#{duration}ms) [Session: #{payload[:session_id]}]" }
14
+ end
15
+
16
+ def flow_execution_error(event)
17
+ payload = event.payload
18
+ duration = event.duration.round(2)
19
+ FlowChat.logger.error { "Flow Execution Failed: #{payload[:flow_name]}##{payload[:action]} (#{duration}ms) - #{payload[:error_class]}: #{payload[:error_message]} [Session: #{payload[:session_id]}]" }
20
+ end
21
+
22
+ # Session events
23
+ def session_created(event)
24
+ payload = event.payload
25
+ FlowChat.logger.info { "Session Created: #{payload[:session_id]} [Store: #{payload[:store_type]}, Gateway: #{payload[:gateway]}]" }
26
+ end
27
+
28
+ def session_destroyed(event)
29
+ payload = event.payload
30
+ FlowChat.logger.info { "Session Destroyed: #{payload[:session_id]} [Gateway: #{payload[:gateway]}]" }
31
+ end
32
+
33
+ def session_cache_hit(event)
34
+ payload = event.payload
35
+ FlowChat.logger.debug { "Session Cache Hit: #{payload[:session_id]} - Key: #{payload[:key]}" }
36
+ end
37
+
38
+ def session_cache_miss(event)
39
+ payload = event.payload
40
+ FlowChat.logger.debug { "Session Cache Miss: #{payload[:session_id]} - Key: #{payload[:key]}" }
41
+ end
42
+
43
+ def session_data_set(event)
44
+ payload = event.payload
45
+ FlowChat.logger.debug { "Session Data Set: #{payload[:session_id]} - Key: #{payload[:key]}" }
46
+ end
47
+
48
+ def session_data_get(event)
49
+ payload = event.payload
50
+ FlowChat.logger.debug { "Session Data Get: #{payload[:session_id]} - Key: #{payload[:key]} = #{payload[:value].inspect}" }
51
+ end
52
+
53
+ # Middleware events
54
+ def middleware_before(event)
55
+ payload = event.payload
56
+ FlowChat.logger.debug { "Middleware Before: #{payload[:middleware_name]} [Session: #{payload[:session_id]}]" }
57
+ end
58
+
59
+ def middleware_after(event)
60
+ payload = event.payload
61
+ duration = event.duration.round(2)
62
+ FlowChat.logger.debug { "Middleware After: #{payload[:middleware_name]} (#{duration}ms) [Session: #{payload[:session_id]}]" }
63
+ end
64
+
65
+ # Platform-agnostic events (new scalable approach)
66
+ def message_received(event)
67
+ payload = event.payload
68
+ platform = payload[:platform] || "unknown"
69
+ platform_name = format_platform_name(platform)
70
+
71
+ case platform.to_sym
72
+ when :whatsapp
73
+ contact_info = payload[:contact_name] ? " (#{payload[:contact_name]})" : ""
74
+ FlowChat.logger.info { "#{platform_name} Message Received: #{payload[:from]}#{contact_info} - Type: #{payload[:message_type]} [ID: #{payload[:message_id]}]" }
75
+ when :ussd
76
+ FlowChat.logger.info { "#{platform_name} Message Received: #{payload[:from]} - Input: '#{payload[:message]}' [Session: #{payload[:session_id]}]" }
77
+ else
78
+ FlowChat.logger.info { "#{platform_name} Message Received: #{payload[:from]} - Message: '#{payload[:message]}' [Session: #{payload[:session_id]}]" }
79
+ end
80
+ end
81
+
82
+ def message_sent(event)
83
+ payload = event.payload
84
+ platform = payload[:platform] || "unknown"
85
+ platform_name = format_platform_name(platform)
86
+ duration = event.duration.round(2)
87
+
88
+ case platform.to_sym
89
+ when :whatsapp
90
+ FlowChat.logger.info { "#{platform_name} Message Sent: #{payload[:to]} - Type: #{payload[:message_type]} (#{duration}ms) [Length: #{payload[:content_length]} chars]" }
91
+ when :ussd
92
+ FlowChat.logger.info { "#{platform_name} Message Sent: #{payload[:to]} - Type: #{payload[:message_type]} (#{duration}ms) [Session: #{payload[:session_id]}]" }
93
+ else
94
+ FlowChat.logger.info { "#{platform_name} Message Sent: #{payload[:to]} - Type: #{payload[:message_type]} (#{duration}ms)" }
95
+ end
96
+ end
97
+
98
+ def webhook_verified(event)
99
+ payload = event.payload
100
+ platform = payload[:platform] || "unknown"
101
+ platform_name = format_platform_name(platform)
102
+ FlowChat.logger.info { "#{platform_name} Webhook Verified Successfully [Challenge: #{payload[:challenge]}]" }
103
+ end
104
+
105
+ def webhook_failed(event)
106
+ payload = event.payload
107
+ platform = payload[:platform] || "unknown"
108
+ platform_name = format_platform_name(platform)
109
+ FlowChat.logger.warn { "#{platform_name} Webhook Verification Failed: #{payload[:reason]}" }
110
+ end
111
+
112
+ def media_upload(event)
113
+ payload = event.payload
114
+ platform = payload[:platform] || "unknown"
115
+ platform_name = format_platform_name(platform)
116
+ duration = event.duration.round(2)
117
+
118
+ if payload[:success] != false # Check for explicit false, not just falsy
119
+ FlowChat.logger.info { "#{platform_name} Media Upload: #{payload[:filename]} (#{format_bytes(payload[:size])}, #{duration}ms) - Success" }
120
+ else
121
+ FlowChat.logger.error { "#{platform_name} Media Upload Failed: #{payload[:filename]} (#{duration}ms) - #{payload[:error]}" }
122
+ end
123
+ end
124
+
125
+ def pagination_triggered(event)
126
+ payload = event.payload
127
+ platform = payload[:platform] || "unknown"
128
+ platform_name = format_platform_name(platform)
129
+ FlowChat.logger.info { "#{platform_name} Pagination Triggered: Page #{payload[:current_page]}/#{payload[:total_pages]} (#{payload[:content_length]} chars) [Session: #{payload[:session_id]}]" }
130
+ end
131
+
132
+ def api_request(event)
133
+ payload = event.payload
134
+ platform = payload[:platform] || "unknown"
135
+ platform_name = format_platform_name(platform)
136
+ duration = event.duration.round(2)
137
+
138
+ if payload[:success]
139
+ FlowChat.logger.debug { "#{platform_name} API Request: #{payload[:method]} #{payload[:endpoint]} (#{duration}ms) - Success" }
140
+ else
141
+ FlowChat.logger.error { "#{platform_name} API Request: #{payload[:method]} #{payload[:endpoint]} (#{duration}ms) - Failed: #{payload[:status]} #{payload[:error]}" }
142
+ end
143
+ end
144
+
145
+ # Context events
146
+ def context_created(event)
147
+ payload = event.payload
148
+ FlowChat.logger.debug { "Context Created [Gateway: #{payload[:gateway] || "unknown"}]" }
149
+ end
150
+
151
+ private
152
+
153
+ # Format platform name for display
154
+ def format_platform_name(platform)
155
+ case platform.to_sym
156
+ when :whatsapp then "WhatsApp"
157
+ when :ussd then "USSD"
158
+ else platform.to_s.capitalize
159
+ end
160
+ end
161
+
162
+ # Format bytes in a human-readable way
163
+ def format_bytes(bytes)
164
+ return "unknown size" unless bytes
165
+
166
+ if bytes < 1024
167
+ "#{bytes} bytes"
168
+ elsif bytes < 1024 * 1024
169
+ "#{(bytes / 1024.0).round(1)} KB"
170
+ else
171
+ "#{(bytes / (1024.0 * 1024.0)).round(1)} MB"
172
+ end
173
+ end
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,197 @@
1
+ module FlowChat
2
+ module Instrumentation
3
+ class MetricsCollector
4
+ attr_reader :metrics
5
+
6
+ def initialize
7
+ @metrics = {}
8
+ @mutex = Mutex.new
9
+ subscribe_to_events
10
+ end
11
+
12
+ # Get current metrics snapshot
13
+ def snapshot
14
+ @mutex.synchronize { @metrics.dup }
15
+ end
16
+
17
+ # Reset all metrics
18
+ def reset!
19
+ @mutex.synchronize { @metrics.clear }
20
+ end
21
+
22
+ # Get metrics for a specific category
23
+ def get_category(category)
24
+ @mutex.synchronize do
25
+ @metrics.select { |key, _| key.to_s.start_with?("#{category}.") }
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def subscribe_to_events
32
+ # Flow execution metrics
33
+ ActiveSupport::Notifications.subscribe("flow.execution.end.flow_chat") do |event|
34
+ increment_counter("flows.executed")
35
+ track_timing("flows.execution_time", event.duration)
36
+ increment_counter("flows.by_name.#{event.payload[:flow_name]}")
37
+ end
38
+
39
+ ActiveSupport::Notifications.subscribe("flow.execution.error.flow_chat") do |event|
40
+ increment_counter("flows.errors")
41
+ increment_counter("flows.errors.by_class.#{event.payload[:error_class]}")
42
+ increment_counter("flows.errors.by_flow.#{event.payload[:flow_name]}")
43
+ end
44
+
45
+ # Session metrics
46
+ ActiveSupport::Notifications.subscribe("session.created.flow_chat") do |event|
47
+ increment_counter("sessions.created")
48
+ increment_counter("sessions.created.by_gateway.#{event.payload[:gateway]}")
49
+ end
50
+
51
+ ActiveSupport::Notifications.subscribe("session.destroyed.flow_chat") do |event|
52
+ increment_counter("sessions.destroyed")
53
+ end
54
+
55
+ ActiveSupport::Notifications.subscribe("session.cache.hit.flow_chat") do |event|
56
+ increment_counter("sessions.cache.hits")
57
+ end
58
+
59
+ ActiveSupport::Notifications.subscribe("session.cache.miss.flow_chat") do |event|
60
+ increment_counter("sessions.cache.misses")
61
+ end
62
+
63
+ ActiveSupport::Notifications.subscribe("session.data.get.flow_chat") do |event|
64
+ increment_counter("sessions.data.get")
65
+ end
66
+
67
+ ActiveSupport::Notifications.subscribe("session.data.set.flow_chat") do |event|
68
+ increment_counter("sessions.data.set")
69
+ end
70
+
71
+ ActiveSupport::Notifications.subscribe("message.received.flow_chat") do |event|
72
+ platform = event.payload[:platform] || "unknown"
73
+ increment_counter("#{platform}.messages.received")
74
+ if event.payload[:message_type]
75
+ increment_counter("#{platform}.messages.received.by_type.#{event.payload[:message_type]}")
76
+ end
77
+ end
78
+
79
+ ActiveSupport::Notifications.subscribe("message.sent.flow_chat") do |event|
80
+ platform = event.payload[:platform] || "unknown"
81
+ increment_counter("#{platform}.messages.sent")
82
+ if event.payload[:message_type]
83
+ increment_counter("#{platform}.messages.sent.by_type.#{event.payload[:message_type]}")
84
+ end
85
+ track_timing("#{platform}.api.response_time", event.duration)
86
+ end
87
+
88
+ ActiveSupport::Notifications.subscribe("webhook.verified.flow_chat") do |event|
89
+ platform = event.payload[:platform] || "unknown"
90
+ increment_counter("#{platform}.webhook.verified")
91
+ end
92
+
93
+ ActiveSupport::Notifications.subscribe("webhook.failed.flow_chat") do |event|
94
+ platform = event.payload[:platform] || "unknown"
95
+ increment_counter("#{platform}.webhook.failures")
96
+ if event.payload[:reason]
97
+ increment_counter("#{platform}.webhook.failures.by_reason.#{event.payload[:reason]}")
98
+ end
99
+ end
100
+
101
+ ActiveSupport::Notifications.subscribe("media.upload.flow_chat") do |event|
102
+ platform = event.payload[:platform] || "unknown"
103
+ if event.payload[:success] != false
104
+ increment_counter("#{platform}.media.uploads.success")
105
+ if event.payload[:size]
106
+ track_histogram("#{platform}.media.upload_size", event.payload[:size])
107
+ end
108
+ else
109
+ increment_counter("#{platform}.media.uploads.failure")
110
+ end
111
+ track_timing("#{platform}.media.upload_time", event.duration)
112
+ end
113
+
114
+ ActiveSupport::Notifications.subscribe("pagination.triggered.flow_chat") do |event|
115
+ platform = event.payload[:platform] || "unknown"
116
+ increment_counter("#{platform}.pagination.triggered")
117
+ if event.payload[:content_length]
118
+ track_histogram("#{platform}.pagination.content_length", event.payload[:content_length])
119
+ end
120
+ end
121
+
122
+ ActiveSupport::Notifications.subscribe("api.request.flow_chat") do |event|
123
+ platform = event.payload[:platform] || "unknown"
124
+ if event.payload[:success]
125
+ increment_counter("#{platform}.api.requests.success")
126
+ else
127
+ increment_counter("#{platform}.api.requests.failure")
128
+ if event.payload[:status]
129
+ increment_counter("#{platform}.api.requests.failure.by_status.#{event.payload[:status]}")
130
+ end
131
+ end
132
+ track_timing("#{platform}.api.request_time", event.duration)
133
+ end
134
+ end
135
+
136
+ def increment_counter(key, value = 1)
137
+ @mutex.synchronize do
138
+ @metrics[key] ||= 0
139
+ @metrics[key] += value
140
+ end
141
+ end
142
+
143
+ def track_timing(key, duration_ms)
144
+ timing_key = "#{key}.timings"
145
+ @mutex.synchronize do
146
+ @metrics[timing_key] ||= []
147
+ @metrics[timing_key] << duration_ms
148
+
149
+ # Keep only last 1000 measurements for memory efficiency
150
+ @metrics[timing_key] = @metrics[timing_key].last(1000) if @metrics[timing_key].size > 1000
151
+
152
+ # Calculate and store aggregates
153
+ timings = @metrics[timing_key]
154
+ @metrics["#{key}.avg"] = timings.sum / timings.size
155
+ @metrics["#{key}.min"] = timings.min
156
+ @metrics["#{key}.max"] = timings.max
157
+ @metrics["#{key}.p50"] = percentile(timings, 50)
158
+ @metrics["#{key}.p95"] = percentile(timings, 95)
159
+ @metrics["#{key}.p99"] = percentile(timings, 99)
160
+ end
161
+ end
162
+
163
+ def track_histogram(key, value)
164
+ histogram_key = "#{key}.histogram"
165
+ @mutex.synchronize do
166
+ @metrics[histogram_key] ||= []
167
+ @metrics[histogram_key] << value
168
+
169
+ # Keep only last 1000 measurements
170
+ @metrics[histogram_key] = @metrics[histogram_key].last(1000) if @metrics[histogram_key].size > 1000
171
+
172
+ # Calculate aggregates
173
+ values = @metrics[histogram_key]
174
+ @metrics["#{key}.total"] = values.sum
175
+ @metrics["#{key}.avg"] = values.sum / values.size
176
+ @metrics["#{key}.min"] = values.min
177
+ @metrics["#{key}.max"] = values.max
178
+ end
179
+ end
180
+
181
+ def percentile(array, percentile)
182
+ return nil if array.empty?
183
+
184
+ sorted = array.sort
185
+ k = (percentile / 100.0) * (sorted.length - 1)
186
+ f = k.floor
187
+ c = k.ceil
188
+
189
+ return sorted[k] if f == c
190
+
191
+ d0 = sorted[f] * (c - k)
192
+ d1 = sorted[c] * (k - f)
193
+ d0 + d1
194
+ end
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,155 @@
1
+ module FlowChat
2
+ module Instrumentation
3
+ module Setup
4
+ class << self
5
+ attr_accessor :log_subscriber, :metrics_collector
6
+
7
+ # Initialize instrumentation with default subscribers
8
+ def initialize!
9
+ setup_log_subscriber if FlowChat::Config.logger
10
+ setup_metrics_collector
11
+
12
+ FlowChat.logger&.info { "FlowChat::Instrumentation: Initialized with logging and metrics collection" }
13
+ end
14
+
15
+ # Set up both logging and metrics collection
16
+ def setup_instrumentation!(options = {})
17
+ setup_logging!(options)
18
+ setup_metrics!(options)
19
+ end
20
+
21
+ # Set up logging (LogSubscriber)
22
+ def setup_logging!(options = {})
23
+ return if @log_subscriber_setup
24
+
25
+ require_relative "log_subscriber"
26
+ setup_log_subscriber(options)
27
+ @log_subscriber_setup = true
28
+ end
29
+
30
+ # Set up metrics collection (MetricsCollector)
31
+ def setup_metrics!(options = {})
32
+ return if @metrics_collector_setup
33
+
34
+ require_relative "metrics_collector"
35
+ setup_metrics_collector(options)
36
+ @metrics_collector_setup = true
37
+ end
38
+
39
+ # Cleanup all subscribers
40
+ def cleanup!
41
+ @log_subscriber = nil
42
+ @metrics_collector = nil
43
+
44
+ # Note: ActiveSupport::Notifications doesn't provide an easy way to
45
+ # unsubscribe all subscribers, so this is mainly for reference cleanup
46
+ FlowChat.logger&.info { "FlowChat::Instrumentation: Cleaned up instrumentation" }
47
+ end
48
+
49
+ # Get current metrics (thread-safe)
50
+ def metrics
51
+ @metrics_collector&.snapshot || {}
52
+ end
53
+
54
+ # Reset metrics
55
+ def reset_metrics!
56
+ @metrics_collector&.reset!
57
+ end
58
+
59
+ # Subscribe to custom events
60
+ def subscribe(event_pattern, &block)
61
+ ActiveSupport::Notifications.subscribe(event_pattern, &block)
62
+ end
63
+
64
+ # Instrument a one-off event
65
+ def instrument(event_name, payload = {}, &block)
66
+ full_event_name = "#{event_name}.flow_chat"
67
+
68
+ enriched_payload = {
69
+ timestamp: Time.current
70
+ }.merge(payload).compact
71
+
72
+ ActiveSupport::Notifications.instrument(full_event_name, enriched_payload, &block)
73
+ end
74
+
75
+ # Access the metrics collector instance
76
+ def metrics_collector
77
+ @metrics_collector ||= FlowChat::Instrumentation::MetricsCollector.new
78
+ end
79
+
80
+ # Reset instrumentation (useful for testing)
81
+ def reset!
82
+ @log_subscriber_setup = false
83
+ @metrics_collector_setup = false
84
+ @log_subscriber = nil
85
+ @metrics_collector = nil
86
+ end
87
+
88
+ private
89
+
90
+ def setup_log_subscriber(options = {})
91
+ # Check if Rails is available and use its initialization callback
92
+ if defined?(Rails) && Rails.respond_to?(:application) && Rails.application
93
+ Rails.application.config.after_initialize do
94
+ initialize_log_subscriber
95
+ end
96
+ else
97
+ # Initialize immediately for non-Rails environments
98
+ initialize_log_subscriber
99
+ end
100
+ end
101
+
102
+ def initialize_log_subscriber
103
+ return if @log_subscriber
104
+
105
+ @log_subscriber = FlowChat::Instrumentation::LogSubscriber.new
106
+
107
+ # Manually subscribe to all FlowChat events
108
+ subscribe_to_events
109
+ end
110
+
111
+ def subscribe_to_events
112
+ # Core framework events
113
+ subscribe_event("flow.execution.start.flow_chat", :flow_execution_start)
114
+ subscribe_event("flow.execution.end.flow_chat", :flow_execution_end)
115
+ subscribe_event("flow.execution.error.flow_chat", :flow_execution_error)
116
+
117
+ # Session events
118
+ subscribe_event("session.created.flow_chat", :session_created)
119
+ subscribe_event("session.destroyed.flow_chat", :session_destroyed)
120
+ subscribe_event("session.data.get.flow_chat", :session_data_get)
121
+ subscribe_event("session.data.set.flow_chat", :session_data_set)
122
+ subscribe_event("session.cache.hit.flow_chat", :session_cache_hit)
123
+ subscribe_event("session.cache.miss.flow_chat", :session_cache_miss)
124
+
125
+ # Platform-agnostic events (new scalable approach)
126
+ subscribe_event("message.received.flow_chat", :message_received)
127
+ subscribe_event("message.sent.flow_chat", :message_sent)
128
+ subscribe_event("webhook.verified.flow_chat", :webhook_verified)
129
+ subscribe_event("webhook.failed.flow_chat", :webhook_failed)
130
+ subscribe_event("api.request.flow_chat", :api_request)
131
+ subscribe_event("media.upload.flow_chat", :media_upload)
132
+ subscribe_event("pagination.triggered.flow_chat", :pagination_triggered)
133
+
134
+ # Middleware events
135
+ subscribe_event("middleware.before.flow_chat", :middleware_before)
136
+ subscribe_event("middleware.after.flow_chat", :middleware_after)
137
+
138
+ # Context events
139
+ subscribe_event("context.created.flow_chat", :context_created)
140
+ end
141
+
142
+ def subscribe_event(event_name, method_name)
143
+ ActiveSupport::Notifications.subscribe(event_name) do |*args|
144
+ event = ActiveSupport::Notifications::Event.new(*args)
145
+ @log_subscriber.send(method_name, event) if @log_subscriber.respond_to?(method_name)
146
+ end
147
+ end
148
+
149
+ def setup_metrics_collector(options = {})
150
+ @metrics_collector = FlowChat::Instrumentation::MetricsCollector.new
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,70 @@
1
+ require "active_support/notifications"
2
+
3
+ module FlowChat
4
+ module Instrumentation
5
+ extend ActiveSupport::Concern
6
+
7
+ # Instrument a block of code with the given event name and payload
8
+ def instrument(event_name, payload = {}, &block)
9
+ enriched_payload = payload&.dup || {}
10
+ if respond_to?(:context) && context
11
+ enriched_payload[:session_id] = context["session.id"] if context["session.id"]
12
+ enriched_payload[:flow_name] = context["flow.name"] if context["flow.name"]
13
+ enriched_payload[:gateway] = context["request.gateway"] if context["request.gateway"]
14
+ end
15
+
16
+ self.class.instrument(event_name, enriched_payload, &block)
17
+ end
18
+
19
+ class_methods do
20
+ def instrument(event_name, payload = {}, &block)
21
+ FlowChat::Instrumentation.instrument(event_name, payload, &block)
22
+ end
23
+ end
24
+
25
+ # Module-level method for direct calls like FlowChat::Instrumentation.instrument
26
+ def self.instrument(event_name, payload = {}, &block)
27
+ full_event_name = "#{event_name}.flow_chat"
28
+
29
+ enriched_payload = {
30
+ timestamp: Time.current
31
+ }.merge(payload || {}).compact
32
+
33
+ ActiveSupport::Notifications.instrument(full_event_name, enriched_payload, &block)
34
+ end
35
+
36
+ # Predefined event names for consistency
37
+ module Events
38
+ # Core framework events
39
+ FLOW_EXECUTION_START = "flow.execution.start"
40
+ FLOW_EXECUTION_END = "flow.execution.end"
41
+ FLOW_EXECUTION_ERROR = "flow.execution.error"
42
+
43
+ # Context events
44
+ CONTEXT_CREATED = "context.created"
45
+
46
+ # Session events
47
+ SESSION_CREATED = "session.created"
48
+ SESSION_DESTROYED = "session.destroyed"
49
+ SESSION_DATA_GET = "session.data.get"
50
+ SESSION_DATA_SET = "session.data.set"
51
+ SESSION_CACHE_HIT = "session.cache.hit"
52
+ SESSION_CACHE_MISS = "session.cache.miss"
53
+
54
+ # Platform-agnostic messaging events
55
+ # Gateway/platform information is included in the payload
56
+ MESSAGE_RECEIVED = "message.received"
57
+ MESSAGE_SENT = "message.sent"
58
+ WEBHOOK_VERIFIED = "webhook.verified"
59
+ WEBHOOK_FAILED = "webhook.failed"
60
+ API_REQUEST = "api.request"
61
+ MEDIA_UPLOAD = "media.upload"
62
+
63
+ PAGINATION_TRIGGERED = "pagination.triggered"
64
+
65
+ # Middleware events
66
+ MIDDLEWARE_BEFORE = "middleware.before"
67
+ MIDDLEWARE_AFTER = "middleware.after"
68
+ end
69
+ end
70
+ end
@@ -6,13 +6,9 @@ module FlowChat
6
6
  @user_input = input
7
7
  end
8
8
 
9
- def ask(msg, choices: nil, convert: nil, validate: nil, transform: nil, media: nil)
10
- # Validate media and choices compatibility
11
- validate_media_choices_compatibility(media, choices)
12
-
9
+ def ask(msg, choices: nil, transform: nil, validate: nil, media: nil)
13
10
  if user_input.present?
14
11
  input = user_input
15
- input = convert.call(input) if convert.present?
16
12
  validation_error = validate.call(input) if validate.present?
17
13
 
18
14
  if validation_error.present?
@@ -38,17 +34,18 @@ module FlowChat
38
34
  terminate! message, media: media
39
35
  end
40
36
 
41
- def select(msg, choices, media: nil)
42
- # Validate media and choices compatibility
43
- validate_media_choices_compatibility(media, choices)
37
+ def select(msg, choices, media: nil, error_message: "Invalid selection:")
38
+ raise ArgumentError, "choices must be an array or hash" unless choices.is_a?(Array) || choices.is_a?(Hash)
44
39
 
45
- choices, choices_prompt = build_select_choices choices
40
+ normalized_choices = normalize_choices(choices)
46
41
  ask(
47
42
  msg,
48
- choices: choices_prompt,
49
- convert: lambda { |choice| choice.to_i },
50
- validate: lambda { |choice| "Invalid selection:" unless (1..choices.size).cover?(choice) },
51
- transform: lambda { |choice| choices[choice - 1] },
43
+ choices: choices,
44
+ validate: lambda { |choice| error_message unless normalized_choices.key?(choice.to_s) },
45
+ transform: lambda do |choice|
46
+ choices = choices.keys if choices.is_a?(Hash)
47
+ choices.index_by { |choice| choice.to_s }[choice.to_s]
48
+ end,
52
49
  media: media
53
50
  )
54
51
  end
@@ -67,20 +64,23 @@ module FlowChat
67
64
  end
68
65
  end
69
66
 
70
- def build_select_choices(choices)
67
+ def normalize_choices(choices)
71
68
  case choices
72
- when Array
73
- choices_prompt = choices.map.with_index { |c, i| [i + 1, c] }.to_h
69
+ when nil
70
+ nil
74
71
  when Hash
75
- choices_prompt = choices.values.map.with_index { |c, i| [i + 1, c] }.to_h
76
- choices = choices.keys
72
+ choices.map { |k, v| [k.to_s, v] }.to_h
73
+ when Array
74
+ choices.map { |c| [c.to_s, c] }.to_h
77
75
  else
78
76
  raise ArgumentError, "choices must be an array or hash"
79
77
  end
80
- [choices, choices_prompt]
81
78
  end
82
79
 
83
80
  def prompt!(msg, choices: nil, media: nil)
81
+ validate_media_choices_compatibility(media, choices)
82
+
83
+ choices = normalize_choices(choices)
84
84
  raise FlowChat::Interrupt::Prompt.new(msg, choices: choices, media: media)
85
85
  end
86
86
 
@@ -88,4 +88,4 @@ module FlowChat
88
88
  raise FlowChat::Interrupt::Terminate.new(msg, media: media)
89
89
  end
90
90
  end
91
- end
91
+ end