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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +44 -0
- data/.gitignore +2 -1
- data/README.md +85 -1229
- data/docs/configuration.md +360 -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/sessions.md +433 -0
- data/docs/testing.md +475 -0
- data/docs/ussd-setup.md +322 -0
- data/docs/whatsapp-setup.md +162 -0
- data/examples/multi_tenant_whatsapp_controller.rb +9 -37
- data/examples/simulator_controller.rb +13 -22
- data/examples/ussd_controller.rb +41 -41
- 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 +79 -2
- data/lib/flow_chat/config.rb +31 -5
- 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 +130 -12
- data/lib/flow_chat/session/rails_session_store.rb +36 -1
- data/lib/flow_chat/simulator/controller.rb +8 -8
- data/lib/flow_chat/simulator/views/simulator.html.erb +5 -5
- data/lib/flow_chat/ussd/gateway/nalo.rb +31 -0
- data/lib/flow_chat/ussd/gateway/nsano.rb +36 -2
- 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 +16 -4
- 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 +121 -34
- data/lib/flow_chat/whatsapp/middleware/executor.rb +24 -2
- data/lib/flow_chat/whatsapp/processor.rb +7 -1
- data/lib/flow_chat/whatsapp/renderer.rb +4 -9
- data/lib/flow_chat.rb +23 -0
- metadata +23 -12
- 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
- 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
|
data/lib/flow_chat/prompt.rb
CHANGED
@@ -6,13 +6,9 @@ module FlowChat
|
|
6
6
|
@user_input = input
|
7
7
|
end
|
8
8
|
|
9
|
-
def ask(msg, choices: 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
|
-
|
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
|
-
|
40
|
+
normalized_choices = normalize_choices(choices)
|
46
41
|
ask(
|
47
42
|
msg,
|
48
|
-
choices:
|
49
|
-
|
50
|
-
|
51
|
-
|
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
|
67
|
+
def normalize_choices(choices)
|
71
68
|
case choices
|
72
|
-
when
|
73
|
-
|
69
|
+
when nil
|
70
|
+
nil
|
74
71
|
when Hash
|
75
|
-
|
76
|
-
|
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
|
-
|
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,34 +1,152 @@
|
|
1
1
|
module FlowChat
|
2
2
|
module Session
|
3
3
|
class Middleware
|
4
|
-
|
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
|
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
|
-
|
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
|
-
|
20
|
-
|
21
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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">
|
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
|
@@ -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"],
|