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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +44 -0
- data/.gitignore +2 -1
- data/README.md +84 -1229
- data/docs/configuration.md +337 -0
- data/docs/flows.md +320 -0
- data/docs/images/simulator.png +0 -0
- data/docs/instrumentation.md +216 -0
- data/docs/media.md +153 -0
- data/docs/testing.md +475 -0
- data/docs/ussd-setup.md +306 -0
- data/docs/whatsapp-setup.md +162 -0
- data/examples/multi_tenant_whatsapp_controller.rb +9 -37
- data/examples/simulator_controller.rb +9 -18
- data/examples/ussd_controller.rb +32 -38
- data/examples/whatsapp_controller.rb +32 -125
- data/examples/whatsapp_media_examples.rb +68 -336
- data/examples/whatsapp_message_job.rb +5 -3
- data/flow_chat.gemspec +6 -2
- data/lib/flow_chat/base_processor.rb +48 -2
- data/lib/flow_chat/config.rb +5 -0
- data/lib/flow_chat/context.rb +13 -1
- data/lib/flow_chat/instrumentation/log_subscriber.rb +176 -0
- data/lib/flow_chat/instrumentation/metrics_collector.rb +197 -0
- data/lib/flow_chat/instrumentation/setup.rb +155 -0
- data/lib/flow_chat/instrumentation.rb +70 -0
- data/lib/flow_chat/prompt.rb +20 -20
- data/lib/flow_chat/session/cache_session_store.rb +73 -7
- data/lib/flow_chat/session/middleware.rb +37 -4
- data/lib/flow_chat/session/rails_session_store.rb +36 -1
- data/lib/flow_chat/simulator/controller.rb +6 -6
- data/lib/flow_chat/ussd/gateway/nalo.rb +30 -0
- data/lib/flow_chat/ussd/gateway/nsano.rb +33 -0
- data/lib/flow_chat/ussd/middleware/choice_mapper.rb +109 -0
- data/lib/flow_chat/ussd/middleware/executor.rb +24 -2
- data/lib/flow_chat/ussd/middleware/pagination.rb +87 -7
- data/lib/flow_chat/ussd/processor.rb +14 -0
- data/lib/flow_chat/ussd/renderer.rb +1 -1
- data/lib/flow_chat/version.rb +1 -1
- data/lib/flow_chat/whatsapp/client.rb +99 -12
- data/lib/flow_chat/whatsapp/configuration.rb +35 -4
- data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +120 -34
- data/lib/flow_chat/whatsapp/middleware/executor.rb +24 -2
- data/lib/flow_chat/whatsapp/processor.rb +8 -0
- data/lib/flow_chat/whatsapp/renderer.rb +4 -9
- data/lib/flow_chat.rb +23 -0
- metadata +22 -11
- data/.travis.yml +0 -6
- data/app/controllers/demo_controller.rb +0 -101
- data/app/flow_chat/demo_restaurant_flow.rb +0 -889
- data/config/routes_demo.rb +0 -59
- data/examples/initializer.rb +0 -86
- data/examples/media_prompts_examples.rb +0 -27
- 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
|
-
|
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
|
-
|
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
|
-
|
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
|
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
|
-
|
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
|
-
|
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
|
-
|
12
|
-
|
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
|