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
@@ -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,14 +1,39 @@
1
1
  module FlowChat
2
2
  module Session
3
3
  class Middleware
4
+ include FlowChat::Instrumentation
5
+
6
+ attr_reader :context
7
+
4
8
  def initialize(app)
5
9
  @app = app
10
+ FlowChat.logger.debug { "Session::Middleware: Initialized session middleware" }
6
11
  end
7
12
 
8
13
  def call(context)
9
- context["session.id"] = session_id context
14
+ @context = context
15
+ session_id = session_id(context)
16
+ FlowChat.logger.debug { "Session::Middleware: Generated session ID: #{session_id}" }
17
+
18
+ context["session.id"] = session_id
10
19
  context.session = context["session.store"].new(context)
11
- @app.call(context)
20
+
21
+ # Use instrumentation instead of direct logging for session creation
22
+ instrument(Events::SESSION_CREATED, {
23
+ session_id: session_id,
24
+ store_type: context["session.store"].name,
25
+ gateway: context["request.gateway"]
26
+ })
27
+
28
+ FlowChat.logger.debug { "Session::Middleware: Session store: #{context["session.store"].class.name}" }
29
+
30
+ result = @app.call(context)
31
+
32
+ FlowChat.logger.debug { "Session::Middleware: Session processing completed for #{session_id}" }
33
+ result
34
+ rescue => error
35
+ FlowChat.logger.error { "Session::Middleware: Error in session processing for #{session_id}: #{error.class.name}: #{error.message}" }
36
+ raise
12
37
  end
13
38
 
14
39
  private
@@ -16,17 +41,25 @@ module FlowChat
16
41
  def session_id(context)
17
42
  gateway = context["request.gateway"]
18
43
  flow_name = context["flow.name"]
44
+
45
+ FlowChat.logger.debug { "Session::Middleware: Building session ID for gateway=#{gateway}, flow=#{flow_name}" }
46
+
19
47
  case gateway
20
48
  when :whatsapp_cloud_api
21
49
  # For WhatsApp, use phone number + flow name for consistent sessions
22
50
  phone = context["request.msisdn"]
23
- "#{gateway}:#{flow_name}:#{phone}"
51
+ session_id = "#{gateway}:#{flow_name}:#{phone}"
52
+ FlowChat.logger.debug { "Session::Middleware: WhatsApp session ID created for phone #{phone}" }
53
+ session_id
24
54
  # when :nalo, :nsano
25
55
  # # For USSD, use the request ID from the gateway
26
56
  # "#{gateway}:#{flow_name}:#{context["request.id"]}"
27
57
  else
28
58
  # Fallback to request ID
29
- "#{gateway}:#{flow_name}:#{context["request.id"]}"
59
+ request_id = context["request.id"]
60
+ session_id = "#{gateway}:#{flow_name}:#{request_id}"
61
+ FlowChat.logger.debug { "Session::Middleware: Generic session ID created for request #{request_id}" }
62
+ session_id
30
63
  end
31
64
  end
32
65
  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
@@ -51,7 +51,7 @@ module FlowChat
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,
@@ -4,11 +4,16 @@ 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"]
@@ -20,8 +25,33 @@ module FlowChat
20
25
  # context["request.type"] = params["MSGTYPE"] ? :initial : :response
21
26
  context.input = params["USERDATA"].presence
22
27
 
28
+ # Instrument message received when user provides input using new scalable approach
29
+ if context.input.present?
30
+ instrument(Events::MESSAGE_RECEIVED, {
31
+ from: context["request.msisdn"],
32
+ message: context.input,
33
+ session_id: context["request.id"],
34
+ gateway: :nalo,
35
+ platform: :ussd,
36
+ timestamp: context["request.timestamp"]
37
+ })
38
+ end
39
+
40
+ # Process the request and instrument the response
23
41
  type, prompt, choices, media = @app.call(context)
24
42
 
43
+ # Instrument message sent using new scalable approach
44
+ instrument(Events::MESSAGE_SENT, {
45
+ to: context["request.msisdn"],
46
+ session_id: context["request.id"],
47
+ message: context.input || "",
48
+ message_type: (type == :prompt) ? "prompt" : "terminal",
49
+ gateway: :nalo,
50
+ platform: :ussd,
51
+ content_length: prompt.to_s.length,
52
+ timestamp: context["request.timestamp"]
53
+ })
54
+
25
55
  context.controller.render json: {
26
56
  USERID: params["USERID"],
27
57
  MSISDN: params["MSISDN"],
@@ -4,11 +4,16 @@ module FlowChat
4
4
  module Ussd
5
5
  module Gateway
6
6
  class Nsano
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
  controller = context["controller"]
13
18
  controller.request
14
19
 
@@ -18,6 +23,34 @@ module FlowChat
18
23
  # Set a basic message_id (can be enhanced based on actual Nsano implementation)
19
24
  context["request.message_id"] = SecureRandom.uuid
20
25
 
26
+ # TODO: Implement Nsano-specific parameter parsing
27
+ # For now, add basic instrumentation structure for when this is implemented
28
+
29
+ # Placeholder instrumentation - indicates Nsano implementation is needed
30
+ instrument(Events::MESSAGE_RECEIVED, {
31
+ from: "TODO", # Would be parsed from Nsano params
32
+ message: "TODO", # Would be actual user input
33
+ session_id: "TODO", # Would be Nsano session ID
34
+ gateway: :nsano,
35
+ platform: :ussd,
36
+ timestamp: context["request.timestamp"]
37
+ })
38
+
39
+ # Process request with placeholder app call
40
+ _, _, _, _ = @app.call(context) if @app
41
+
42
+ # Placeholder response instrumentation
43
+ instrument(Events::MESSAGE_SENT, {
44
+ to: "TODO", # Would be actual phone number
45
+ session_id: "TODO", # Would be Nsano session ID
46
+ message: "TODO", # Would be actual response message
47
+ message_type: "prompt", # Would depend on actual response type
48
+ gateway: :nsano,
49
+ platform: :ussd,
50
+ content_length: 0, # Would be actual content length
51
+ timestamp: context["request.timestamp"]
52
+ })
53
+
21
54
  # input = context["rack.input"].read
22
55
  # context["rack.input"].rewind
23
56
  # if input.present?
@@ -0,0 +1,109 @@
1
+ module FlowChat
2
+ module Ussd
3
+ module Middleware
4
+ class ChoiceMapper
5
+ def initialize(app)
6
+ @app = app
7
+ FlowChat.logger.debug { "Ussd::ChoiceMapper: Initialized USSD choice mapping middleware" }
8
+ end
9
+
10
+ def call(context)
11
+ @context = context
12
+ @session = context.session
13
+
14
+ session_id = context["session.id"]
15
+ FlowChat.logger.debug { "Ussd::ChoiceMapper: Processing request for session #{session_id}" }
16
+
17
+ if intercept?
18
+ FlowChat.logger.info { "Ussd::ChoiceMapper: Intercepting request for choice resolution - session #{session_id}" }
19
+ handle_choice_input
20
+ end
21
+
22
+ # Clear choice mapping state for new flows
23
+ clear_choice_state_if_needed
24
+ type, prompt, choices, media = @app.call(context)
25
+
26
+ if choices.present?
27
+ FlowChat.logger.debug { "Ussd::ChoiceMapper: Found choices, creating number mapping" }
28
+ choices = create_numbered_mapping(choices)
29
+ end
30
+
31
+ [type, prompt, choices, media]
32
+ end
33
+
34
+ private
35
+
36
+ def intercept?
37
+ # Intercept if we have choice mapping state and user input is a number that maps to a choice
38
+ choice_mapping = get_choice_mapping
39
+ should_intercept = choice_mapping.present? &&
40
+ @context.input.present? &&
41
+ choice_mapping.key?(@context.input.to_s)
42
+
43
+ if should_intercept
44
+ FlowChat.logger.debug { "Ussd::ChoiceMapper: Intercepting - input: #{@context.input}, mapped to: #{choice_mapping[@context.input.to_s]}" }
45
+ end
46
+
47
+ should_intercept
48
+ end
49
+
50
+ def handle_choice_input
51
+ choice_mapping = get_choice_mapping
52
+ original_choice = choice_mapping[@context.input.to_s]
53
+
54
+ FlowChat.logger.info { "Ussd::ChoiceMapper: Resolving choice input #{@context.input} to #{original_choice}" }
55
+
56
+ # Replace the numeric input with the original choice
57
+ @context.input = original_choice
58
+ end
59
+
60
+ def create_numbered_mapping(choices)
61
+ # Choices are always a hash after normalize_choices
62
+ numbered_choices = {}
63
+ choice_mapping = {}
64
+
65
+ choices.each_with_index do |(key, value), index|
66
+ number = (index + 1).to_s
67
+ numbered_choices[number] = value
68
+ choice_mapping[number] = key.to_s
69
+ end
70
+
71
+ store_choice_mapping(choice_mapping)
72
+ FlowChat.logger.debug { "Ussd::ChoiceMapper: Created mapping: #{choice_mapping}" }
73
+ numbered_choices
74
+ end
75
+
76
+ def store_choice_mapping(mapping)
77
+ @session.set("ussd.choice_mapping", mapping)
78
+ FlowChat.logger.debug { "Ussd::ChoiceMapper: Stored choice mapping: #{mapping}" }
79
+ end
80
+
81
+ def get_choice_mapping
82
+ @session.get("ussd.choice_mapping") || {}
83
+ end
84
+
85
+ def clear_choice_mapping
86
+ @session.delete("ussd.choice_mapping")
87
+ FlowChat.logger.debug { "Ussd::ChoiceMapper: Cleared choice mapping" }
88
+ end
89
+
90
+ def clear_choice_state_if_needed
91
+ # Clear choice mapping if this is a new flow (no input or fresh start)
92
+ if @context.input.blank? || should_clear_for_new_flow?
93
+ clear_choice_mapping
94
+ end
95
+ end
96
+
97
+ def should_clear_for_new_flow?
98
+ # Clear mapping if this input doesn't match any stored mapping
99
+ # This indicates we're in a new flow step
100
+ choice_mapping = get_choice_mapping
101
+ return false if choice_mapping.empty?
102
+
103
+ # If input is present but doesn't match any mapping, we're in a new flow
104
+ @context.input.present? && !choice_mapping.key?(@context.input.to_s)
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -4,22 +4,44 @@ module FlowChat
4
4
  class Executor
5
5
  def initialize(app)
6
6
  @app = app
7
+ FlowChat.logger.debug { "Ussd::Executor: Initialized USSD executor middleware" }
7
8
  end
8
9
 
9
10
  def call(context)
11
+ flow_class = context.flow
12
+ action = context["flow.action"]
13
+ session_id = context["session.id"]
14
+
15
+ FlowChat.logger.info { "Ussd::Executor: Executing flow #{flow_class.name}##{action} for session #{session_id}" }
16
+
10
17
  ussd_app = build_ussd_app context
11
- flow = context.flow.new ussd_app
12
- flow.send context["flow.action"]
18
+ FlowChat.logger.debug { "Ussd::Executor: USSD app built for flow execution" }
19
+
20
+ flow = flow_class.new ussd_app
21
+ FlowChat.logger.debug { "Ussd::Executor: Flow instance created, invoking #{action} method" }
22
+
23
+ flow.send action
24
+ FlowChat.logger.warn { "Ussd::Executor: Flow execution failed to interact with user for #{flow_class.name}##{action}" }
25
+ raise FlowChat::Interrupt::Terminate, "Unexpected end of flow."
13
26
  rescue FlowChat::Interrupt::Prompt => e
27
+ FlowChat.logger.info { "Ussd::Executor: Flow prompted user - Session: #{session_id}, Prompt: '#{e.prompt.truncate(100)}'" }
28
+ FlowChat.logger.debug { "Ussd::Executor: Prompt details - Choices: #{e.choices&.size || 0}, Has media: #{!e.media.nil?}" }
14
29
  [:prompt, e.prompt, e.choices, e.media]
15
30
  rescue FlowChat::Interrupt::Terminate => e
31
+ FlowChat.logger.info { "Ussd::Executor: Flow terminated - Session: #{session_id}, Message: '#{e.prompt.truncate(100)}'" }
32
+ FlowChat.logger.debug { "Ussd::Executor: Destroying session #{session_id}" }
16
33
  context.session.destroy
17
34
  [:terminate, e.prompt, nil, e.media]
35
+ rescue => error
36
+ FlowChat.logger.error { "Ussd::Executor: Flow execution failed - #{flow_class.name}##{action}, Session: #{session_id}, Error: #{error.class.name}: #{error.message}" }
37
+ FlowChat.logger.debug { "Ussd::Executor: Stack trace: #{error.backtrace.join("\n")}" }
38
+ raise
18
39
  end
19
40
 
20
41
  private
21
42
 
22
43
  def build_ussd_app(context)
44
+ FlowChat.logger.debug { "Ussd::Executor: Building USSD app instance" }
23
45
  FlowChat::Ussd::App.new(context)
24
46
  end
25
47
  end