flow_chat 0.7.0 → 0.8.1
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/README.md +1 -0
- data/docs/configuration.md +29 -6
- data/docs/sessions.md +433 -0
- data/docs/testing.md +2 -2
- data/docs/ussd-setup.md +20 -4
- data/examples/simulator_controller.rb +4 -4
- data/examples/ussd_controller.rb +9 -3
- data/lib/flow_chat/base_app.rb +74 -0
- data/lib/flow_chat/base_executor.rb +57 -0
- data/lib/flow_chat/base_processor.rb +33 -2
- data/lib/flow_chat/config.rb +26 -5
- data/lib/flow_chat/instrumentation/setup.rb +0 -2
- data/lib/flow_chat/interrupt.rb +6 -0
- data/lib/flow_chat/session/middleware.rb +102 -17
- data/lib/flow_chat/simulator/controller.rb +2 -2
- data/lib/flow_chat/simulator/views/simulator.html.erb +5 -5
- data/lib/flow_chat/ussd/app.rb +1 -53
- data/lib/flow_chat/ussd/gateway/nalo.rb +1 -0
- data/lib/flow_chat/ussd/gateway/nsano.rb +3 -2
- data/lib/flow_chat/ussd/middleware/executor.rb +11 -37
- data/lib/flow_chat/ussd/processor.rb +5 -7
- data/lib/flow_chat/version.rb +1 -1
- data/lib/flow_chat/whatsapp/app.rb +11 -46
- data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +1 -0
- data/lib/flow_chat/whatsapp/middleware/executor.rb +11 -39
- data/lib/flow_chat/whatsapp/processor.rb +0 -2
- data/lib/flow_chat.rb +1 -11
- metadata +5 -3
- data/lib/flow_chat/ussd/middleware/resumable_session.rb +0 -39
@@ -0,0 +1,57 @@
|
|
1
|
+
module FlowChat
|
2
|
+
class BaseExecutor
|
3
|
+
def initialize(app)
|
4
|
+
@app = app
|
5
|
+
FlowChat.logger.debug { "#{log_prefix}: Initialized #{platform_name} executor middleware" }
|
6
|
+
end
|
7
|
+
|
8
|
+
def call(context)
|
9
|
+
flow_class = context.flow
|
10
|
+
action = context["flow.action"]
|
11
|
+
session_id = context["session.id"]
|
12
|
+
|
13
|
+
FlowChat.logger.info { "#{log_prefix}: Executing flow #{flow_class.name}##{action} for session #{session_id}" }
|
14
|
+
|
15
|
+
platform_app = build_platform_app(context)
|
16
|
+
FlowChat.logger.debug { "#{log_prefix}: #{platform_name} app built for flow execution" }
|
17
|
+
|
18
|
+
flow = flow_class.new platform_app
|
19
|
+
FlowChat.logger.debug { "#{log_prefix}: Flow instance created, invoking #{action} method" }
|
20
|
+
|
21
|
+
flow.send action
|
22
|
+
FlowChat.logger.warn { "#{log_prefix}: Flow execution failed to interact with user for #{flow_class.name}##{action}" }
|
23
|
+
raise FlowChat::Interrupt::Terminate, "Unexpected end of flow."
|
24
|
+
rescue FlowChat::Interrupt::RestartFlow => e
|
25
|
+
FlowChat.logger.info { "#{log_prefix}: Flow restart requested - Session: #{session_id}, restarting #{action}" }
|
26
|
+
retry
|
27
|
+
rescue FlowChat::Interrupt::Prompt => e
|
28
|
+
FlowChat.logger.info { "#{log_prefix}: Flow prompted user - Session: #{session_id}, Prompt: '#{e.prompt&.truncate(100)}'" }
|
29
|
+
FlowChat.logger.debug { "#{log_prefix}: Prompt details - Choices: #{e.choices&.size || 0}, Has media: #{!e.media.nil?}" }
|
30
|
+
[:prompt, e.prompt, e.choices, e.media]
|
31
|
+
rescue FlowChat::Interrupt::Terminate => e
|
32
|
+
FlowChat.logger.info { "#{log_prefix}: Flow terminated - Session: #{session_id}, Message: '#{e.prompt&.truncate(100)}'" }
|
33
|
+
FlowChat.logger.debug { "#{log_prefix}: Destroying session #{session_id}" }
|
34
|
+
context.session.destroy
|
35
|
+
[:terminate, e.prompt, nil, e.media]
|
36
|
+
rescue => error
|
37
|
+
FlowChat.logger.error { "#{log_prefix}: Flow execution failed - #{flow_class.name}##{action}, Session: #{session_id}, Error: #{error.class.name}: #{error.message}" }
|
38
|
+
FlowChat.logger.debug { "#{log_prefix}: Stack trace: #{error.backtrace.join("\n")}" }
|
39
|
+
raise
|
40
|
+
end
|
41
|
+
|
42
|
+
protected
|
43
|
+
|
44
|
+
# Subclasses must implement these methods
|
45
|
+
def platform_name
|
46
|
+
raise NotImplementedError, "Subclasses must implement platform_name"
|
47
|
+
end
|
48
|
+
|
49
|
+
def log_prefix
|
50
|
+
raise NotImplementedError, "Subclasses must implement log_prefix"
|
51
|
+
end
|
52
|
+
|
53
|
+
def build_platform_app(context)
|
54
|
+
raise NotImplementedError, "Subclasses must implement build_platform_app"
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
@@ -13,6 +13,7 @@ module FlowChat
|
|
13
13
|
@context["controller"] = controller
|
14
14
|
@context["enable_simulator"] = enable_simulator.nil? ? (defined?(Rails) && Rails.env.local?) : enable_simulator
|
15
15
|
@middleware = ::Middleware::Builder.new(name: middleware_name)
|
16
|
+
@session_options = FlowChat::Config.session
|
16
17
|
|
17
18
|
FlowChat.logger.debug { "BaseProcessor: Simulator mode #{@context["enable_simulator"] ? "enabled" : "disabled"}" }
|
18
19
|
|
@@ -29,17 +30,46 @@ module FlowChat
|
|
29
30
|
end
|
30
31
|
|
31
32
|
def use_session_store(session_store)
|
32
|
-
|
33
|
+
raise "Session store must be a class" unless session_store.is_a?(Class)
|
34
|
+
FlowChat.logger.debug { "BaseProcessor: Configuring session store #{session_store.name}" }
|
33
35
|
@context["session.store"] = session_store
|
34
36
|
self
|
35
37
|
end
|
36
38
|
|
39
|
+
def use_session_config(boundaries: nil, hash_phone_numbers: nil, identifier: nil)
|
40
|
+
FlowChat.logger.debug { "BaseProcessor: Configuring session config: boundaries=#{boundaries.inspect}, hash_phone_numbers=#{hash_phone_numbers}, identifier=#{identifier}" }
|
41
|
+
|
42
|
+
# Update the session options directly
|
43
|
+
@session_options = @session_options.dup
|
44
|
+
@session_options.boundaries = Array(boundaries) if boundaries
|
45
|
+
@session_options.hash_phone_numbers = hash_phone_numbers if hash_phone_numbers
|
46
|
+
@session_options.identifier = identifier if identifier
|
47
|
+
|
48
|
+
self
|
49
|
+
end
|
50
|
+
|
37
51
|
def use_middleware(middleware)
|
38
|
-
|
52
|
+
raise "Middleware must be a class" unless middleware.is_a?(Class)
|
53
|
+
FlowChat.logger.debug { "BaseProcessor: Adding middleware #{middleware.name}" }
|
39
54
|
@middleware.use middleware
|
40
55
|
self
|
41
56
|
end
|
42
57
|
|
58
|
+
def use_cross_platform_sessions
|
59
|
+
FlowChat.logger.debug { "BaseProcessor: Enabling cross-platform sessions via session configuration" }
|
60
|
+
use_session_config(
|
61
|
+
boundaries: [:flow]
|
62
|
+
)
|
63
|
+
self
|
64
|
+
end
|
65
|
+
|
66
|
+
def use_url_isolation
|
67
|
+
FlowChat.logger.debug { "BaseProcessor: Enabling URL-based session isolation" }
|
68
|
+
current_boundaries = @session_options.boundaries.dup
|
69
|
+
current_boundaries << :url unless current_boundaries.include?(:url)
|
70
|
+
use_session_config(boundaries: current_boundaries)
|
71
|
+
end
|
72
|
+
|
43
73
|
def run(flow_class, action)
|
44
74
|
# Instrument flow execution (this will log via LogSubscriber)
|
45
75
|
instrument(Events::FLOW_EXECUTION_START, {
|
@@ -101,6 +131,7 @@ module FlowChat
|
|
101
131
|
|
102
132
|
::Middleware::Builder.new(name: name) do |b|
|
103
133
|
b.use @gateway_class, *@gateway_args
|
134
|
+
b.use FlowChat::Session::Middleware, @session_options
|
104
135
|
configure_middleware_stack(b)
|
105
136
|
end.inject_logger(FlowChat.logger)
|
106
137
|
end
|
data/lib/flow_chat/config.rb
CHANGED
@@ -8,6 +8,11 @@ module FlowChat
|
|
8
8
|
# When false, only the validation error message is shown to the user.
|
9
9
|
mattr_accessor :combine_validation_error_with_message, default: true
|
10
10
|
|
11
|
+
# Session configuration object
|
12
|
+
def self.session
|
13
|
+
@session ||= SessionConfig.new
|
14
|
+
end
|
15
|
+
|
11
16
|
# USSD-specific configuration object
|
12
17
|
def self.ussd
|
13
18
|
@ussd ||= UssdConfig.new
|
@@ -18,10 +23,29 @@ module FlowChat
|
|
18
23
|
@whatsapp ||= WhatsappConfig.new
|
19
24
|
end
|
20
25
|
|
26
|
+
class SessionConfig
|
27
|
+
attr_accessor :boundaries, :hash_phone_numbers, :identifier
|
28
|
+
|
29
|
+
def initialize
|
30
|
+
# Session boundaries control how session IDs are constructed
|
31
|
+
# :flow = separate sessions per flow
|
32
|
+
# :gateway = separate sessions per gateway
|
33
|
+
# :platform = separate sessions per platform (ussd, whatsapp)
|
34
|
+
@boundaries = [:flow, :gateway, :platform]
|
35
|
+
|
36
|
+
# Always hash phone numbers for privacy
|
37
|
+
@hash_phone_numbers = true
|
38
|
+
|
39
|
+
# Session identifier type (nil = let platforms choose their default)
|
40
|
+
# :msisdn = durable sessions (durable across timeouts)
|
41
|
+
# :request_id = ephemeral sessions (new session each time)
|
42
|
+
@identifier = nil
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
21
46
|
class UssdConfig
|
22
47
|
attr_accessor :pagination_page_size, :pagination_back_option, :pagination_back_text,
|
23
|
-
:pagination_next_option, :pagination_next_text
|
24
|
-
:resumable_sessions_enabled, :resumable_sessions_global, :resumable_sessions_timeout_seconds
|
48
|
+
:pagination_next_option, :pagination_next_text
|
25
49
|
|
26
50
|
def initialize
|
27
51
|
@pagination_page_size = 140
|
@@ -29,9 +53,6 @@ module FlowChat
|
|
29
53
|
@pagination_back_text = "Back"
|
30
54
|
@pagination_next_option = "#"
|
31
55
|
@pagination_next_text = "More"
|
32
|
-
@resumable_sessions_enabled = false
|
33
|
-
@resumable_sessions_global = true
|
34
|
-
@resumable_sessions_timeout_seconds = 300
|
35
56
|
end
|
36
57
|
end
|
37
58
|
|
@@ -22,7 +22,6 @@ module FlowChat
|
|
22
22
|
def setup_logging!(options = {})
|
23
23
|
return if @log_subscriber_setup
|
24
24
|
|
25
|
-
require_relative "log_subscriber"
|
26
25
|
setup_log_subscriber(options)
|
27
26
|
@log_subscriber_setup = true
|
28
27
|
end
|
@@ -31,7 +30,6 @@ module FlowChat
|
|
31
30
|
def setup_metrics!(options = {})
|
32
31
|
return if @metrics_collector_setup
|
33
32
|
|
34
|
-
require_relative "metrics_collector"
|
35
33
|
setup_metrics_collector(options)
|
36
34
|
@metrics_collector_setup = true
|
37
35
|
end
|
data/lib/flow_chat/interrupt.rb
CHANGED
@@ -5,8 +5,9 @@ module FlowChat
|
|
5
5
|
|
6
6
|
attr_reader :context
|
7
7
|
|
8
|
-
def initialize(app)
|
8
|
+
def initialize(app, session_options)
|
9
9
|
@app = app
|
10
|
+
@session_options = session_options
|
10
11
|
FlowChat.logger.debug { "Session::Middleware: Initialized session middleware" }
|
11
12
|
end
|
12
13
|
|
@@ -19,9 +20,10 @@ module FlowChat
|
|
19
20
|
context.session = context["session.store"].new(context)
|
20
21
|
|
21
22
|
# Use instrumentation instead of direct logging for session creation
|
23
|
+
store_type = context["session.store"].name || "$Anonymous"
|
22
24
|
instrument(Events::SESSION_CREATED, {
|
23
25
|
session_id: session_id,
|
24
|
-
store_type:
|
26
|
+
store_type: store_type,
|
25
27
|
gateway: context["request.gateway"]
|
26
28
|
})
|
27
29
|
|
@@ -40,28 +42,111 @@ module FlowChat
|
|
40
42
|
|
41
43
|
def session_id(context)
|
42
44
|
gateway = context["request.gateway"]
|
45
|
+
platform = context["request.platform"]
|
43
46
|
flow_name = context["flow.name"]
|
44
47
|
|
45
|
-
|
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}" }
|
46
56
|
|
47
|
-
|
48
|
-
|
49
|
-
|
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
|
50
86
|
phone = context["request.msisdn"]
|
51
|
-
|
52
|
-
FlowChat.logger.debug { "Session::Middleware: WhatsApp session ID created for phone #{phone}" }
|
53
|
-
session_id
|
54
|
-
# when :nalo, :nsano
|
55
|
-
# # For USSD, use the request ID from the gateway
|
56
|
-
# "#{gateway}:#{flow_name}:#{context["request.id"]}"
|
87
|
+
@session_options.hash_phone_numbers ? hash_phone_number(phone) : phone
|
57
88
|
else
|
58
|
-
|
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
|
89
|
+
raise "Invalid session identifier type: #{identifier_type}"
|
63
90
|
end
|
64
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
|
65
150
|
end
|
66
151
|
end
|
67
152
|
end
|
@@ -32,7 +32,7 @@ module FlowChat
|
|
32
32
|
name: "USSD (Nalo)",
|
33
33
|
description: "USSD integration using Nalo",
|
34
34
|
processor_type: "ussd",
|
35
|
-
|
35
|
+
gateway: "nalo",
|
36
36
|
endpoint: "/ussd",
|
37
37
|
icon: "📱",
|
38
38
|
color: "#28a745",
|
@@ -45,7 +45,7 @@ module FlowChat
|
|
45
45
|
name: "WhatsApp (Cloud API)",
|
46
46
|
description: "WhatsApp integration using Cloud API",
|
47
47
|
processor_type: "whatsapp",
|
48
|
-
|
48
|
+
gateway: "cloud_api",
|
49
49
|
endpoint: "/whatsapp/webhook",
|
50
50
|
icon: "💬",
|
51
51
|
color: "#25D366",
|
@@ -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">
|
1061
|
-
<span class="config-detail-value">${config.
|
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.
|
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
|
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.
|
1345
|
+
switch (config.gateway) {
|
1346
1346
|
case 'nalo':
|
1347
1347
|
displayUSSDResponse(data.MSG)
|
1348
1348
|
state.isRunning = data.MSGTYPE
|
data/lib/flow_chat/ussd/app.rb
CHANGED
@@ -1,58 +1,6 @@
|
|
1
1
|
module FlowChat
|
2
2
|
module Ussd
|
3
|
-
class App
|
4
|
-
attr_reader :session, :input, :context, :navigation_stack
|
5
|
-
|
6
|
-
def initialize(context)
|
7
|
-
@context = context
|
8
|
-
@session = context.session
|
9
|
-
@input = context.input
|
10
|
-
@navigation_stack = []
|
11
|
-
end
|
12
|
-
|
13
|
-
def screen(key)
|
14
|
-
raise ArgumentError, "a block is expected" unless block_given?
|
15
|
-
raise ArgumentError, "screen has already been presented" if navigation_stack.include?(key)
|
16
|
-
|
17
|
-
navigation_stack << key
|
18
|
-
return session.get(key) if session.get(key).present?
|
19
|
-
|
20
|
-
prompt = FlowChat::Prompt.new input
|
21
|
-
@input = nil # input is being submitted to prompt so we clear it
|
22
|
-
|
23
|
-
value = yield prompt
|
24
|
-
session.set(key, value)
|
25
|
-
value
|
26
|
-
end
|
27
|
-
|
28
|
-
def say(msg, media: nil)
|
29
|
-
raise FlowChat::Interrupt::Terminate.new(msg)
|
30
|
-
end
|
31
|
-
|
32
|
-
# WhatsApp-specific data accessors (not supported in USSD)
|
33
|
-
def contact_name
|
34
|
-
nil
|
35
|
-
end
|
36
|
-
|
37
|
-
def message_id
|
38
|
-
context["request.message_id"]
|
39
|
-
end
|
40
|
-
|
41
|
-
def timestamp
|
42
|
-
context["request.timestamp"]
|
43
|
-
end
|
44
|
-
|
45
|
-
def location
|
46
|
-
nil
|
47
|
-
end
|
48
|
-
|
49
|
-
def media
|
50
|
-
nil
|
51
|
-
end
|
52
|
-
|
53
|
-
def phone_number
|
54
|
-
context["request.msisdn"]
|
55
|
-
end
|
3
|
+
class App < FlowChat::BaseApp
|
56
4
|
end
|
57
5
|
end
|
58
6
|
end
|
@@ -20,6 +20,7 @@ module FlowChat
|
|
20
20
|
context["request.message_id"] = SecureRandom.uuid
|
21
21
|
context["request.timestamp"] = Time.current.iso8601
|
22
22
|
context["request.gateway"] = :nalo
|
23
|
+
context["request.platform"] = :ussd
|
23
24
|
context["request.network"] = nil
|
24
25
|
context["request.msisdn"] = Phonelib.parse(params["MSISDN"]).e164
|
25
26
|
# context["request.type"] = params["MSGTYPE"] ? :initial : :response
|
@@ -22,6 +22,7 @@ module FlowChat
|
|
22
22
|
|
23
23
|
# Set a basic message_id (can be enhanced based on actual Nsano implementation)
|
24
24
|
context["request.message_id"] = SecureRandom.uuid
|
25
|
+
context["request.platform"] = :ussd
|
25
26
|
|
26
27
|
# TODO: Implement Nsano-specific parameter parsing
|
27
28
|
# For now, add basic instrumentation structure for when this is implemented
|
@@ -58,7 +59,7 @@ module FlowChat
|
|
58
59
|
# if params["network"].present? && params["UserSessionID"].present?
|
59
60
|
# request_id = "nsano::request_id::#{params["UserSessionID"]}"
|
60
61
|
# context["ussd.request"] = {
|
61
|
-
#
|
62
|
+
# gateway: :nsano,
|
62
63
|
# network: params["network"].to_sym,
|
63
64
|
# msisdn: Phonelib.parse(params["msisdn"]).e164,
|
64
65
|
# type: Config.cache&.read(request_id).present? ? :response : :initial,
|
@@ -70,7 +71,7 @@ module FlowChat
|
|
70
71
|
|
71
72
|
# status, headers, response = @app.call(context)
|
72
73
|
|
73
|
-
# if context["ussd.response"].present? && context["ussd.request"][:
|
74
|
+
# if context["ussd.response"].present? && context["ussd.request"][:gateway] == :nsano
|
74
75
|
# if context["ussd.response"][:type] == :terminal
|
75
76
|
# Config.cache&.write(request_id, nil)
|
76
77
|
# else
|
@@ -1,47 +1,21 @@
|
|
1
|
+
require_relative "../../base_executor"
|
2
|
+
|
1
3
|
module FlowChat
|
2
4
|
module Ussd
|
3
5
|
module Middleware
|
4
|
-
class Executor
|
5
|
-
|
6
|
-
@app = app
|
7
|
-
FlowChat.logger.debug { "Ussd::Executor: Initialized USSD executor middleware" }
|
8
|
-
end
|
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}" }
|
6
|
+
class Executor < FlowChat::BaseExecutor
|
7
|
+
protected
|
16
8
|
|
17
|
-
|
18
|
-
|
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."
|
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?}" }
|
29
|
-
[:prompt, e.prompt, e.choices, e.media]
|
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}" }
|
33
|
-
context.session.destroy
|
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
|
9
|
+
def platform_name
|
10
|
+
"USSD"
|
39
11
|
end
|
40
12
|
|
41
|
-
|
13
|
+
def log_prefix
|
14
|
+
"Ussd::Executor"
|
15
|
+
end
|
42
16
|
|
43
|
-
def
|
44
|
-
FlowChat.logger.debug { "
|
17
|
+
def build_platform_app(context)
|
18
|
+
FlowChat.logger.debug { "#{log_prefix}: Building USSD app instance" }
|
45
19
|
FlowChat::Ussd::App.new(context)
|
46
20
|
end
|
47
21
|
end
|
@@ -1,10 +1,11 @@
|
|
1
1
|
module FlowChat
|
2
2
|
module Ussd
|
3
3
|
class Processor < FlowChat::BaseProcessor
|
4
|
-
def
|
5
|
-
FlowChat.logger.debug { "Ussd::Processor: Enabling
|
6
|
-
|
7
|
-
|
4
|
+
def use_durable_sessions(cross_gateway: false)
|
5
|
+
FlowChat.logger.debug { "Ussd::Processor: Enabling durable sessions via session configuration" }
|
6
|
+
use_session_config(
|
7
|
+
identifier: :msisdn # Use MSISDN for durable sessions
|
8
|
+
)
|
8
9
|
end
|
9
10
|
|
10
11
|
protected
|
@@ -21,9 +22,6 @@ module FlowChat
|
|
21
22
|
def configure_middleware_stack(builder)
|
22
23
|
FlowChat.logger.debug { "Ussd::Processor: Configuring USSD middleware stack" }
|
23
24
|
|
24
|
-
builder.use FlowChat::Session::Middleware
|
25
|
-
FlowChat.logger.debug { "Ussd::Processor: Added Session::Middleware" }
|
26
|
-
|
27
25
|
builder.use FlowChat::Ussd::Middleware::Pagination
|
28
26
|
FlowChat.logger.debug { "Ussd::Processor: Added Ussd::Middleware::Pagination" }
|
29
27
|
|
data/lib/flow_chat/version.rb
CHANGED
@@ -1,53 +1,10 @@
|
|
1
1
|
module FlowChat
|
2
2
|
module Whatsapp
|
3
|
-
class App
|
4
|
-
attr_reader :session, :input, :context, :navigation_stack
|
5
|
-
|
6
|
-
def initialize(context)
|
7
|
-
@context = context
|
8
|
-
@session = context.session
|
9
|
-
@input = context.input
|
10
|
-
@navigation_stack = []
|
11
|
-
end
|
12
|
-
|
13
|
-
def screen(key)
|
14
|
-
raise ArgumentError, "a block is expected" unless block_given?
|
15
|
-
raise ArgumentError, "screen has already been presented" if navigation_stack.include?(key)
|
16
|
-
|
17
|
-
navigation_stack << key
|
18
|
-
return session.get(key) if session.get(key).present?
|
19
|
-
|
20
|
-
user_input = input
|
21
|
-
if session.get("$started_at$").nil?
|
22
|
-
session.set("$started_at$", Time.current.iso8601)
|
23
|
-
user_input = nil
|
24
|
-
end
|
25
|
-
|
26
|
-
prompt = FlowChat::Prompt.new user_input
|
27
|
-
@input = nil # input is being submitted to prompt so we clear it
|
28
|
-
|
29
|
-
value = yield prompt
|
30
|
-
session.set(key, value)
|
31
|
-
value
|
32
|
-
end
|
33
|
-
|
34
|
-
def say(msg, media: nil)
|
35
|
-
raise FlowChat::Interrupt::Terminate.new(msg, media: media)
|
36
|
-
end
|
37
|
-
|
38
|
-
# WhatsApp-specific data accessors (read-only)
|
3
|
+
class App < FlowChat::BaseApp
|
39
4
|
def contact_name
|
40
5
|
context["request.contact_name"]
|
41
6
|
end
|
42
7
|
|
43
|
-
def message_id
|
44
|
-
context["request.message_id"]
|
45
|
-
end
|
46
|
-
|
47
|
-
def timestamp
|
48
|
-
context["request.timestamp"]
|
49
|
-
end
|
50
|
-
|
51
8
|
def location
|
52
9
|
context["request.location"]
|
53
10
|
end
|
@@ -56,8 +13,16 @@ module FlowChat
|
|
56
13
|
context["request.media"]
|
57
14
|
end
|
58
15
|
|
59
|
-
|
60
|
-
|
16
|
+
protected
|
17
|
+
|
18
|
+
# WhatsApp has special startup logic and supports media
|
19
|
+
def prepare_user_input
|
20
|
+
user_input = input
|
21
|
+
if session.get("$started_at$").nil?
|
22
|
+
session.set("$started_at$", Time.current.iso8601)
|
23
|
+
user_input = nil
|
24
|
+
end
|
25
|
+
user_input
|
61
26
|
end
|
62
27
|
end
|
63
28
|
end
|