flow_chat 0.6.1 → 0.8.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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +44 -0
  3. data/.gitignore +2 -1
  4. data/README.md +85 -1229
  5. data/docs/configuration.md +360 -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/sessions.md +433 -0
  11. data/docs/testing.md +475 -0
  12. data/docs/ussd-setup.md +322 -0
  13. data/docs/whatsapp-setup.md +162 -0
  14. data/examples/multi_tenant_whatsapp_controller.rb +9 -37
  15. data/examples/simulator_controller.rb +13 -22
  16. data/examples/ussd_controller.rb +41 -41
  17. data/examples/whatsapp_controller.rb +32 -125
  18. data/examples/whatsapp_media_examples.rb +68 -336
  19. data/examples/whatsapp_message_job.rb +5 -3
  20. data/flow_chat.gemspec +6 -2
  21. data/lib/flow_chat/base_processor.rb +79 -2
  22. data/lib/flow_chat/config.rb +31 -5
  23. data/lib/flow_chat/context.rb +13 -1
  24. data/lib/flow_chat/instrumentation/log_subscriber.rb +176 -0
  25. data/lib/flow_chat/instrumentation/metrics_collector.rb +197 -0
  26. data/lib/flow_chat/instrumentation/setup.rb +155 -0
  27. data/lib/flow_chat/instrumentation.rb +70 -0
  28. data/lib/flow_chat/prompt.rb +20 -20
  29. data/lib/flow_chat/session/cache_session_store.rb +73 -7
  30. data/lib/flow_chat/session/middleware.rb +130 -12
  31. data/lib/flow_chat/session/rails_session_store.rb +36 -1
  32. data/lib/flow_chat/simulator/controller.rb +8 -8
  33. data/lib/flow_chat/simulator/views/simulator.html.erb +5 -5
  34. data/lib/flow_chat/ussd/gateway/nalo.rb +31 -0
  35. data/lib/flow_chat/ussd/gateway/nsano.rb +36 -2
  36. data/lib/flow_chat/ussd/middleware/choice_mapper.rb +109 -0
  37. data/lib/flow_chat/ussd/middleware/executor.rb +24 -2
  38. data/lib/flow_chat/ussd/middleware/pagination.rb +87 -7
  39. data/lib/flow_chat/ussd/processor.rb +16 -4
  40. data/lib/flow_chat/ussd/renderer.rb +1 -1
  41. data/lib/flow_chat/version.rb +1 -1
  42. data/lib/flow_chat/whatsapp/client.rb +99 -12
  43. data/lib/flow_chat/whatsapp/configuration.rb +35 -4
  44. data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +121 -34
  45. data/lib/flow_chat/whatsapp/middleware/executor.rb +24 -2
  46. data/lib/flow_chat/whatsapp/processor.rb +7 -1
  47. data/lib/flow_chat/whatsapp/renderer.rb +4 -9
  48. data/lib/flow_chat.rb +23 -0
  49. metadata +23 -12
  50. data/.travis.yml +0 -6
  51. data/app/controllers/demo_controller.rb +0 -101
  52. data/app/flow_chat/demo_restaurant_flow.rb +0 -889
  53. data/config/routes_demo.rb +0 -59
  54. data/examples/initializer.rb +0 -86
  55. data/examples/media_prompts_examples.rb +0 -27
  56. data/images/ussd_simulator.png +0 -0
  57. data/lib/flow_chat/ussd/middleware/resumable_session.rb +0 -39
@@ -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
@@ -1,45 +1,101 @@
1
1
  module FlowChat
2
2
  module Session
3
3
  class CacheSessionStore
4
+ include FlowChat::Instrumentation
5
+
6
+ # Make context available for instrumentation enrichment
7
+ attr_reader :context
8
+
4
9
  def initialize(context, cache = nil)
5
10
  @context = context
6
11
  @cache = cache || FlowChat::Config.cache
7
12
 
8
13
  raise ArgumentError, "Cache is required. Set FlowChat::Config.cache or pass a cache instance." unless @cache
14
+
15
+ FlowChat.logger.debug { "CacheSessionStore: Initialized cache session store for session #{session_key}" }
16
+ FlowChat.logger.debug { "CacheSessionStore: Cache backend: #{@cache.class.name}" }
9
17
  end
10
18
 
11
19
  def get(key)
12
20
  return nil unless @context
13
21
 
22
+ FlowChat.logger.debug { "CacheSessionStore: Getting key '#{key}' from session #{session_key}" }
23
+
14
24
  data = @cache.read(session_key)
15
- return nil unless data
25
+ session_id = @context["session.id"]
26
+
27
+ unless data
28
+ # Use instrumentation for cache miss
29
+ instrument(Events::SESSION_CACHE_MISS, {
30
+ session_id: session_id,
31
+ key: key.to_s
32
+ })
33
+ return nil
34
+ end
16
35
 
17
- data[key.to_s]
36
+ value = data[key.to_s]
37
+
38
+ # Use instrumentation for cache hit and data get
39
+ instrument(Events::SESSION_CACHE_HIT, {
40
+ session_id: session_id,
41
+ key: key.to_s
42
+ })
43
+
44
+ instrument(Events::SESSION_DATA_GET, {
45
+ session_id: session_id,
46
+ key: key.to_s,
47
+ value: value
48
+ })
49
+
50
+ value
18
51
  end
19
52
 
20
53
  def set(key, value)
21
54
  return unless @context
22
55
 
56
+ FlowChat.logger.debug { "CacheSessionStore: Setting key '#{key}' = #{value.inspect} in session #{session_key}" }
57
+
23
58
  data = @cache.read(session_key) || {}
24
59
  data[key.to_s] = value
25
60
 
26
- @cache.write(session_key, data, expires_in: session_ttl)
61
+ ttl = session_ttl
62
+ @cache.write(session_key, data, expires_in: ttl)
63
+
64
+ # Use instrumentation for data set
65
+ instrument(Events::SESSION_DATA_SET, {
66
+ session_id: @context["session.id"],
67
+ key: key.to_s
68
+ })
69
+
70
+ FlowChat.logger.debug { "CacheSessionStore: Session data saved with TTL #{ttl.inspect}" }
27
71
  value
28
72
  end
29
73
 
30
74
  def delete(key)
31
75
  return unless @context
32
76
 
77
+ FlowChat.logger.debug { "CacheSessionStore: Deleting key '#{key}' from session #{session_key}" }
78
+
33
79
  data = @cache.read(session_key)
34
- return unless data
80
+ unless data
81
+ FlowChat.logger.debug { "CacheSessionStore: No session data found for deletion" }
82
+ return
83
+ end
35
84
 
36
85
  data.delete(key.to_s)
37
86
  @cache.write(session_key, data, expires_in: session_ttl)
87
+
88
+ FlowChat.logger.debug { "CacheSessionStore: Key '#{key}' deleted from session" }
38
89
  end
39
90
 
40
91
  def clear
41
92
  return unless @context
42
93
 
94
+ # Use instrumentation for session destruction
95
+ instrument(Events::SESSION_DESTROYED, {
96
+ session_id: @context["session.id"]
97
+ })
98
+
43
99
  @cache.delete(session_key)
44
100
  end
45
101
 
@@ -47,16 +103,20 @@ module FlowChat
47
103
  alias_method :destroy, :clear
48
104
 
49
105
  def exists?
50
- @cache.exist?(session_key)
106
+ exists = @cache.exist?(session_key)
107
+ FlowChat.logger.debug { "CacheSessionStore: Session #{session_key} exists: #{exists}" }
108
+ exists
51
109
  end
52
110
 
53
111
  private
54
112
 
55
113
  def session_key
114
+ return "flow_chat:session:nil_context" unless @context
115
+
56
116
  gateway = @context["request.gateway"]
57
117
  msisdn = @context["request.msisdn"]
58
118
 
59
- case gateway
119
+ key = case gateway
60
120
  when :whatsapp_cloud_api
61
121
  "flow_chat:session:whatsapp:#{msisdn}"
62
122
  when :nalo, :nsano
@@ -65,12 +125,15 @@ module FlowChat
65
125
  else
66
126
  "flow_chat:session:unknown:#{msisdn}"
67
127
  end
128
+
129
+ FlowChat.logger.debug { "CacheSessionStore: Generated session key: #{key}" }
130
+ key
68
131
  end
69
132
 
70
133
  def session_ttl
71
134
  gateway = @context["request.gateway"]
72
135
 
73
- case gateway
136
+ ttl = case gateway
74
137
  when :whatsapp_cloud_api
75
138
  7.days # WhatsApp conversations can be long-lived
76
139
  when :nalo, :nsano
@@ -78,6 +141,9 @@ module FlowChat
78
141
  else
79
142
  1.day # Default
80
143
  end
144
+
145
+ FlowChat.logger.debug { "CacheSessionStore: Session TTL for #{gateway}: #{ttl.inspect}" }
146
+ ttl
81
147
  end
82
148
  end
83
149
  end
@@ -1,34 +1,152 @@
1
1
  module FlowChat
2
2
  module Session
3
3
  class Middleware
4
- def initialize(app)
4
+ include FlowChat::Instrumentation
5
+
6
+ attr_reader :context
7
+
8
+ def initialize(app, session_options)
5
9
  @app = app
10
+ @session_options = session_options
11
+ FlowChat.logger.debug { "Session::Middleware: Initialized session middleware" }
6
12
  end
7
13
 
8
14
  def call(context)
9
- context["session.id"] = session_id context
15
+ @context = context
16
+ session_id = session_id(context)
17
+ FlowChat.logger.debug { "Session::Middleware: Generated session ID: #{session_id}" }
18
+
19
+ context["session.id"] = session_id
10
20
  context.session = context["session.store"].new(context)
11
- @app.call(context)
21
+
22
+ # Use instrumentation instead of direct logging for session creation
23
+ store_type = context["session.store"].name || "$Anonymous"
24
+ instrument(Events::SESSION_CREATED, {
25
+ session_id: session_id,
26
+ store_type: store_type,
27
+ gateway: context["request.gateway"]
28
+ })
29
+
30
+ FlowChat.logger.debug { "Session::Middleware: Session store: #{context["session.store"].class.name}" }
31
+
32
+ result = @app.call(context)
33
+
34
+ FlowChat.logger.debug { "Session::Middleware: Session processing completed for #{session_id}" }
35
+ result
36
+ rescue => error
37
+ FlowChat.logger.error { "Session::Middleware: Error in session processing for #{session_id}: #{error.class.name}: #{error.message}" }
38
+ raise
12
39
  end
13
40
 
14
41
  private
15
42
 
16
43
  def session_id(context)
17
44
  gateway = context["request.gateway"]
45
+ platform = context["request.platform"]
18
46
  flow_name = context["flow.name"]
19
- case gateway
20
- when :whatsapp_cloud_api
21
- # For WhatsApp, use phone number + flow name for consistent sessions
47
+
48
+ # Check for explicit session ID first (for manual session management)
49
+ if context["session.id"].present?
50
+ session_id = context["session.id"]
51
+ FlowChat.logger.debug { "Session::Middleware: Using explicit session ID: #{session_id}" }
52
+ return session_id
53
+ end
54
+
55
+ FlowChat.logger.debug { "Session::Middleware: Building session ID for platform=#{platform}, gateway=#{gateway}, flow=#{flow_name}" }
56
+
57
+ # Get identifier based on configuration
58
+ identifier = get_session_identifier(context)
59
+
60
+ # Build session ID based on configuration
61
+ session_id = build_session_id(flow_name, platform, gateway, identifier)
62
+ FlowChat.logger.debug { "Session::Middleware: Generated session ID: #{session_id}" }
63
+ session_id
64
+ end
65
+
66
+ 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
+
82
+ case identifier_type
83
+ when :request_id
84
+ context["request.id"]
85
+ when :msisdn
22
86
  phone = context["request.msisdn"]
23
- "#{gateway}:#{flow_name}:#{phone}"
24
- # when :nalo, :nsano
25
- # # For USSD, use the request ID from the gateway
26
- # "#{gateway}:#{flow_name}:#{context["request.id"]}"
87
+ @session_options.hash_phone_numbers ? hash_phone_number(phone) : phone
27
88
  else
28
- # Fallback to request ID
29
- "#{gateway}:#{flow_name}:#{context["request.id"]}"
89
+ raise "Invalid session identifier type: #{identifier_type}"
30
90
  end
31
91
  end
92
+
93
+ def build_session_id(flow_name, platform, gateway, identifier)
94
+ parts = []
95
+
96
+ # Add flow name if flow isolation is enabled
97
+ parts << flow_name if @session_options.boundaries.include?(:flow)
98
+
99
+ # Add platform if platform isolation is enabled
100
+ parts << platform.to_s if @session_options.boundaries.include?(:platform)
101
+
102
+ # Add gateway if gateway isolation is enabled
103
+ parts << gateway.to_s if @session_options.boundaries.include?(:gateway)
104
+
105
+ # Add URL if URL isolation is enabled
106
+ if @session_options.boundaries.include?(:url)
107
+ url_identifier = get_url_identifier(context)
108
+ parts << url_identifier if url_identifier.present?
109
+ end
110
+
111
+ # Add the session identifier
112
+ parts << identifier if identifier.present?
113
+
114
+ # Join parts with colons
115
+ parts.join(":")
116
+ end
117
+
118
+ def get_url_identifier(context)
119
+ request = context.controller&.request
120
+ return nil unless request
121
+
122
+ # Extract host and path for URL boundary
123
+ host = request.host rescue nil
124
+ path = request.path rescue nil
125
+
126
+ # Create a normalized URL identifier: host + path
127
+ # e.g., "example.com/api/v1/ussd" or "tenant1.example.com/ussd"
128
+ url_parts = []
129
+ url_parts << host if host.present?
130
+ url_parts << path.sub(/^\//, '') if path.present? && path != '/'
131
+
132
+ # For long URLs, use first part + hash suffix instead of full hash
133
+ url_identifier = url_parts.join('/').gsub(/[^a-zA-Z0-9._-]/, '_')
134
+ if url_identifier.length > 50
135
+ require 'digest'
136
+ # Take first 41 chars + hash suffix to keep it manageable but recognizable
137
+ first_part = url_identifier[0, 41]
138
+ hash_suffix = Digest::SHA256.hexdigest(url_identifier)[0, 8]
139
+ url_identifier = "#{first_part}_#{hash_suffix}"
140
+ end
141
+
142
+ url_identifier
143
+ end
144
+
145
+ def hash_phone_number(phone)
146
+ # Use SHA256 but only take first 8 characters for reasonable session IDs
147
+ require 'digest'
148
+ Digest::SHA256.hexdigest(phone.to_s)[0, 8]
149
+ end
32
150
  end
33
151
  end
34
152
  end
@@ -1,27 +1,62 @@
1
1
  module FlowChat
2
2
  module Session
3
3
  class RailsSessionStore
4
+ include FlowChat::Instrumentation
5
+
6
+ # Make context available for instrumentation enrichment
7
+ attr_reader :context
8
+
4
9
  def initialize(context)
10
+ @context = context
5
11
  @session_id = context["session.id"]
6
12
  @session_store = context.controller.session
7
13
  @session_data = (session_store[session_id] || {}).with_indifferent_access
14
+
15
+ FlowChat.logger.debug { "RailsSessionStore: Initialized Rails session store for session #{session_id}" }
16
+ FlowChat.logger.debug { "RailsSessionStore: Loaded session data with #{session_data.keys.size} keys" }
8
17
  end
9
18
 
10
19
  def get(key)
11
- session_data[key]
20
+ value = session_data[key]
21
+
22
+ # Use instrumentation for data get
23
+ instrument(Events::SESSION_DATA_GET, {
24
+ session_id: session_id,
25
+ key: key.to_s,
26
+ value: value
27
+ })
28
+
29
+ value
12
30
  end
13
31
 
14
32
  def set(key, value)
33
+ FlowChat.logger.debug { "RailsSessionStore: Setting key '#{key}' = #{value.inspect} in session #{session_id}" }
34
+
15
35
  session_data[key] = value
16
36
  session_store[session_id] = session_data
37
+
38
+ # Use instrumentation for data set
39
+ instrument(Events::SESSION_DATA_SET, {
40
+ session_id: session_id,
41
+ key: key.to_s
42
+ })
43
+
44
+ FlowChat.logger.debug { "RailsSessionStore: Session data saved to Rails session store" }
17
45
  value
18
46
  end
19
47
 
20
48
  def delete(key)
49
+ FlowChat.logger.debug { "RailsSessionStore: Deleting key '#{key}' from session #{session_id}" }
21
50
  set key, nil
22
51
  end
23
52
 
24
53
  def destroy
54
+ # Use instrumentation for session destruction
55
+ instrument(Events::SESSION_DESTROYED, {
56
+ session_id: session_id,
57
+ gateway: "rails" # Rails doesn't have a specific gateway context
58
+ })
59
+
25
60
  session_store[session_id] = nil
26
61
  end
27
62
 
@@ -4,7 +4,7 @@ module FlowChat
4
4
  def flowchat_simulator
5
5
  # Set simulator cookie for authentication
6
6
  set_simulator_cookie
7
-
7
+
8
8
  respond_to do |format|
9
9
  format.html do
10
10
  render inline: simulator_view_template, layout: false, locals: simulator_locals
@@ -32,7 +32,7 @@ module FlowChat
32
32
  name: "USSD (Nalo)",
33
33
  description: "USSD integration using Nalo",
34
34
  processor_type: "ussd",
35
- provider: "nalo",
35
+ gateway: "nalo",
36
36
  endpoint: "/ussd",
37
37
  icon: "📱",
38
38
  color: "#28a745",
@@ -45,13 +45,13 @@ module FlowChat
45
45
  name: "WhatsApp (Cloud API)",
46
46
  description: "WhatsApp integration using Cloud API",
47
47
  processor_type: "whatsapp",
48
- provider: "cloud_api",
48
+ gateway: "cloud_api",
49
49
  endpoint: "/whatsapp/webhook",
50
50
  icon: "💬",
51
51
  color: "#25D366",
52
52
  settings: {
53
53
  phone_number: default_phone_number,
54
- contact_name: default_contact_name,
54
+ contact_name: default_contact_name
55
55
  }
56
56
  }
57
57
  }
@@ -78,18 +78,18 @@ module FlowChat
78
78
  def set_simulator_cookie
79
79
  # Get global simulator secret
80
80
  simulator_secret = FlowChat::Config.simulator_secret
81
-
81
+
82
82
  unless simulator_secret && !simulator_secret.empty?
83
83
  raise StandardError, "Simulator secret not configured. Please set FlowChat::Config.simulator_secret to enable simulator mode."
84
84
  end
85
-
85
+
86
86
  # Generate timestamp-based signed cookie
87
87
  timestamp = Time.now.to_i
88
88
  message = "simulator:#{timestamp}"
89
89
  signature = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), simulator_secret, message)
90
-
90
+
91
91
  cookie_value = "#{timestamp}:#{signature}"
92
-
92
+
93
93
  # Set secure cookie (valid for 24 hours)
94
94
  cookies[:flowchat_simulator] = {
95
95
  value: cookie_value,
@@ -1057,8 +1057,8 @@
1057
1057
  <span class="config-detail-value">${config.endpoint}</span>
1058
1058
  </div>
1059
1059
  <div class="config-detail-item">
1060
- <span class="config-detail-label">Provider:</span>
1061
- <span class="config-detail-value">${config.provider}</span>
1060
+ <span class="config-detail-label">gateway:</span>
1061
+ <span class="config-detail-value">${config.gateway}</span>
1062
1062
  </div>
1063
1063
  <div class="config-detail-item">
1064
1064
  <span class="config-detail-label">Type:</span>
@@ -1304,7 +1304,7 @@
1304
1304
 
1305
1305
  let requestData = {}
1306
1306
 
1307
- switch (config.provider) {
1307
+ switch (config.gateway) {
1308
1308
  case 'nalo':
1309
1309
  requestData = {
1310
1310
  USERID: state.sessionId,
@@ -1322,7 +1322,7 @@
1322
1322
  }
1323
1323
  break
1324
1324
  default:
1325
- throw new Error(`Unsupported USSD provider: ${config.provider}`)
1325
+ throw new Error(`Unsupported USSD gateway: ${config.gateway}`)
1326
1326
  }
1327
1327
 
1328
1328
  try {
@@ -1342,7 +1342,7 @@
1342
1342
  throw new Error(`HTTP ${response.status}: ${response.statusText}`)
1343
1343
  }
1344
1344
 
1345
- switch (config.provider) {
1345
+ switch (config.gateway) {
1346
1346
  case 'nalo':
1347
1347
  displayUSSDResponse(data.MSG)
1348
1348
  state.isRunning = data.MSGTYPE
@@ -4,24 +4,55 @@ module FlowChat
4
4
  module Ussd
5
5
  module Gateway
6
6
  class Nalo
7
+ include FlowChat::Instrumentation
8
+
9
+ attr_reader :context
10
+
7
11
  def initialize(app)
8
12
  @app = app
9
13
  end
10
14
 
11
15
  def call(context)
16
+ @context = context
12
17
  params = context.controller.request.params
13
18
 
14
19
  context["request.id"] = params["USERID"]
15
20
  context["request.message_id"] = SecureRandom.uuid
16
21
  context["request.timestamp"] = Time.current.iso8601
17
22
  context["request.gateway"] = :nalo
23
+ context["request.platform"] = :ussd
18
24
  context["request.network"] = nil
19
25
  context["request.msisdn"] = Phonelib.parse(params["MSISDN"]).e164
20
26
  # context["request.type"] = params["MSGTYPE"] ? :initial : :response
21
27
  context.input = params["USERDATA"].presence
22
28
 
29
+ # Instrument message received when user provides input using new scalable approach
30
+ if context.input.present?
31
+ instrument(Events::MESSAGE_RECEIVED, {
32
+ from: context["request.msisdn"],
33
+ message: context.input,
34
+ session_id: context["request.id"],
35
+ gateway: :nalo,
36
+ platform: :ussd,
37
+ timestamp: context["request.timestamp"]
38
+ })
39
+ end
40
+
41
+ # Process the request and instrument the response
23
42
  type, prompt, choices, media = @app.call(context)
24
43
 
44
+ # Instrument message sent using new scalable approach
45
+ instrument(Events::MESSAGE_SENT, {
46
+ to: context["request.msisdn"],
47
+ session_id: context["request.id"],
48
+ message: context.input || "",
49
+ message_type: (type == :prompt) ? "prompt" : "terminal",
50
+ gateway: :nalo,
51
+ platform: :ussd,
52
+ content_length: prompt.to_s.length,
53
+ timestamp: context["request.timestamp"]
54
+ })
55
+
25
56
  context.controller.render json: {
26
57
  USERID: params["USERID"],
27
58
  MSISDN: params["MSISDN"],