flow_chat 0.8.2 → 0.10.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/.cliff.toml +74 -0
- data/.github/workflows/ci.yml +2 -3
- data/.github/workflows/pages.yml +43 -0
- data/.github/workflows/release.yml +56 -0
- data/.standard.yml +4 -0
- data/CHANGELOG.md +48 -0
- data/CLAUDE.md +327 -0
- data/CONTRIBUTING.md +134 -0
- data/Gemfile +1 -0
- data/README.md +189 -133
- data/Rakefile +17 -2
- data/SECURITY.md +42 -349
- data/docs/architecture.md +83 -0
- data/docs/async-background-processing.md +64 -0
- data/docs/configuration.md +110 -287
- data/docs/factory-pattern.md +58 -0
- data/docs/gateway-context-variables.md +168 -0
- data/docs/gateway-development.md +159 -0
- data/docs/getting-started.md +90 -0
- data/docs/instrumentation.md +95 -175
- data/docs/platforms/instagram.md +278 -0
- data/docs/platforms/messenger.md +205 -0
- data/docs/platforms/telegram.md +109 -0
- data/docs/platforms/ussd.md +78 -0
- data/docs/platforms/whatsapp.md +147 -0
- data/docs/superpowers/plans/2026-07-09-inbound-media-support.md +732 -0
- data/docs/superpowers/plans/2026-07-09-inbound-media-support.md.tasks.json +58 -0
- data/docs/superpowers/plans/2026-08-10-messenger-instagram.md +4064 -0
- data/docs/superpowers/plans/2026-08-10-messenger-instagram.md.tasks.json +226 -0
- data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md +972 -0
- data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md.tasks.json +88 -0
- data/docs/superpowers/specs/2026-07-09-inbound-media-support-design.md +195 -0
- data/docs/superpowers/specs/2026-08-10-messenger-instagram-design.md +391 -0
- data/docs/testing.md +33 -426
- data/examples/custom_session_id_example.rb +119 -0
- data/examples/http_controller.rb +22 -20
- data/examples/intercom_configuration_example.rb +113 -0
- data/examples/intercom_controller.rb +182 -0
- data/examples/multi_tenant_whatsapp_controller.rb +63 -168
- data/examples/simulator_controller.rb +0 -1
- data/examples/ussd_controller.rb +88 -160
- data/examples/whatsapp_controller.rb +18 -17
- data/examples/whatsapp_media_examples.rb +27 -79
- data/flow_chat.gemspec +4 -0
- data/lib/flow_chat/app.rb +211 -0
- data/lib/flow_chat/async_job.rb +176 -0
- data/lib/flow_chat/choice_titles.rb +95 -0
- data/lib/flow_chat/config.rb +126 -23
- data/lib/flow_chat/delivery_error.rb +9 -0
- data/lib/flow_chat/{base_executor.rb → executor.rb} +6 -11
- data/lib/flow_chat/factory.rb +94 -0
- data/lib/flow_chat/gateway_async_support.rb +106 -0
- data/lib/flow_chat/generic_async_job.rb +30 -0
- data/lib/flow_chat/http/configuration_error.rb +9 -0
- data/lib/flow_chat/http/gateway/simple.rb +104 -36
- data/lib/flow_chat/http/middleware/choice_mapper.rb +94 -0
- data/lib/flow_chat/http/renderer.rb +3 -3
- data/lib/flow_chat/input.rb +86 -0
- data/lib/flow_chat/instagram/client.rb +32 -0
- data/lib/flow_chat/instagram/configuration.rb +147 -0
- data/lib/flow_chat/instagram/configuration_error.rb +7 -0
- data/lib/flow_chat/instagram/gateway/send_api.rb +63 -0
- data/lib/flow_chat/instagram/middleware/choice_mapper.rb +22 -0
- data/lib/flow_chat/instagram/renderer.rb +23 -0
- data/lib/flow_chat/instrumentation/metrics_collector.rb +6 -1
- data/lib/flow_chat/instrumentation/setup.rb +1 -1
- data/lib/flow_chat/instrumentation.rb +182 -0
- data/lib/flow_chat/intercom/client.rb +161 -0
- data/lib/flow_chat/intercom/configuration.rb +102 -0
- data/lib/flow_chat/intercom/configuration_error.rb +9 -0
- data/lib/flow_chat/intercom/gateway/intercom_api.rb +420 -0
- data/lib/flow_chat/intercom/middleware/choice_mapper.rb +101 -0
- data/lib/flow_chat/intercom/renderer.rb +123 -0
- data/lib/flow_chat/media.rb +121 -0
- data/lib/flow_chat/messenger/client.rb +264 -0
- data/lib/flow_chat/messenger/configuration.rb +103 -0
- data/lib/flow_chat/messenger/configuration_error.rb +9 -0
- data/lib/flow_chat/messenger/gateway/send_api.rb +42 -0
- data/lib/flow_chat/messenger/middleware/choice_mapper.rb +185 -0
- data/lib/flow_chat/messenger/renderer.rb +150 -0
- data/lib/flow_chat/meta/challenge.rb +24 -0
- data/lib/flow_chat/meta/choice_ladder.rb +37 -0
- data/lib/flow_chat/meta/configuration_error.rb +7 -0
- data/lib/flow_chat/meta/gateway_identity.rb +38 -0
- data/lib/flow_chat/meta/messaging_gateway.rb +468 -0
- data/lib/flow_chat/meta/signature.rb +30 -0
- data/lib/flow_chat/meta/signature_validation.rb +66 -0
- data/lib/flow_chat/meta/webhook_verification.rb +43 -0
- data/lib/flow_chat/named_configuration.rb +65 -0
- data/lib/flow_chat/phone_number_util.rb +37 -35
- data/lib/flow_chat/processor.rb +188 -0
- data/lib/flow_chat/prompt.rb +13 -16
- data/lib/flow_chat/renderers/markdown_support.rb +167 -0
- data/lib/flow_chat/security.rb +76 -0
- data/lib/flow_chat/session/middleware.rb +36 -11
- data/lib/flow_chat/simulator/controller.rb +31 -15
- data/lib/flow_chat/simulator/views/simulator.html.erb +184 -20
- data/lib/flow_chat/telegram/client.rb +283 -0
- data/lib/flow_chat/telegram/configuration.rb +78 -0
- data/lib/flow_chat/telegram/configuration_error.rb +9 -0
- data/lib/flow_chat/telegram/gateway/bot_api.rb +318 -0
- data/lib/flow_chat/telegram/middleware/choice_mapper.rb +96 -0
- data/lib/flow_chat/telegram/renderer.rb +133 -0
- data/lib/flow_chat/telegram.rb +7 -0
- data/lib/flow_chat/text_truncator.rb +75 -0
- data/lib/flow_chat/ussd/gateway/nalo.rb +24 -4
- data/lib/flow_chat/ussd/middleware/choice_mapper.rb +10 -0
- data/lib/flow_chat/ussd/middleware/pagination.rb +9 -5
- data/lib/flow_chat/ussd/renderer.rb +1 -1
- data/lib/flow_chat/version.rb +1 -1
- data/lib/flow_chat/whatsapp/client.rb +158 -20
- data/lib/flow_chat/whatsapp/configuration.rb +13 -52
- data/lib/flow_chat/whatsapp/configuration_error.rb +9 -0
- data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +335 -248
- data/lib/flow_chat/whatsapp/middleware/choice_mapper.rb +234 -0
- data/lib/flow_chat/whatsapp/renderer.rb +259 -64
- data/lib/flow_chat.rb +1 -1
- data/lib/tasks/release.rake +165 -0
- data/site/.nojekyll +0 -0
- data/site/.og-card.html +89 -0
- data/site/favicon.svg +6 -0
- data/site/index.html +209 -0
- data/site/og.png +0 -0
- metadata +132 -25
- data/docs/flows.md +0 -320
- data/docs/http-gateway-protocol.md +0 -432
- data/docs/images/simulator.png +0 -0
- data/docs/media.md +0 -153
- data/docs/sessions.md +0 -433
- data/docs/ussd-setup.md +0 -322
- data/docs/whatsapp-setup.md +0 -162
- data/examples/whatsapp_message_job.rb +0 -113
- data/lib/flow_chat/base_app.rb +0 -86
- data/lib/flow_chat/base_processor.rb +0 -146
- data/lib/flow_chat/http/app.rb +0 -6
- data/lib/flow_chat/http/middleware/executor.rb +0 -24
- data/lib/flow_chat/http/processor.rb +0 -33
- data/lib/flow_chat/session/rails_session_store.rb +0 -68
- data/lib/flow_chat/ussd/app.rb +0 -6
- data/lib/flow_chat/ussd/gateway/nsano.rb +0 -96
- data/lib/flow_chat/ussd/middleware/executor.rb +0 -24
- data/lib/flow_chat/ussd/processor.rb +0 -39
- data/lib/flow_chat/whatsapp/app.rb +0 -29
- data/lib/flow_chat/whatsapp/middleware/executor.rb +0 -24
- data/lib/flow_chat/whatsapp/processor.rb +0 -32
- data/lib/flow_chat/whatsapp/send_job_support.rb +0 -79
|
@@ -1,47 +1,49 @@
|
|
|
1
1
|
module FlowChat
|
|
2
2
|
module PhoneNumberUtil
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
3
|
+
class << self
|
|
4
|
+
def to_e164(phone_number)
|
|
5
|
+
return "" if phone_number.nil? || phone_number.empty?
|
|
6
|
+
|
|
7
|
+
begin
|
|
8
|
+
# Try to load phonelib without Rails dependency
|
|
9
|
+
require_phonelib_safely
|
|
10
|
+
Phonelib.parse(phone_number).e164
|
|
11
|
+
rescue => e
|
|
12
|
+
FlowChat.logger.warn { "PhoneNumberUtil: Failed to parse phone number '#{phone_number}': #{e.message}" }
|
|
13
|
+
# Fallback to simple formatting if phonelib fails
|
|
14
|
+
fallback_e164_format(phone_number)
|
|
15
|
+
end
|
|
14
16
|
end
|
|
15
|
-
end
|
|
16
17
|
|
|
17
|
-
|
|
18
|
+
private
|
|
18
19
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
20
|
+
def require_phonelib_safely
|
|
21
|
+
return if defined?(Phonelib)
|
|
22
|
+
|
|
23
|
+
# Temporarily stub Rails if it doesn't exist
|
|
24
|
+
if defined?(Rails)
|
|
25
|
+
require "phonelib"
|
|
26
|
+
else
|
|
27
|
+
stub_rails = Module.new do
|
|
28
|
+
def const_missing(name)
|
|
29
|
+
if name == :Railtie
|
|
30
|
+
Class.new
|
|
31
|
+
else
|
|
32
|
+
super
|
|
33
|
+
end
|
|
30
34
|
end
|
|
31
35
|
end
|
|
36
|
+
Object.const_set(:Rails, stub_rails)
|
|
37
|
+
require "phonelib"
|
|
38
|
+
Object.send(:remove_const, :Rails)
|
|
32
39
|
end
|
|
33
|
-
Object.const_set(:Rails, stub_rails)
|
|
34
|
-
require "phonelib"
|
|
35
|
-
Object.send(:remove_const, :Rails)
|
|
36
|
-
else
|
|
37
|
-
require "phonelib"
|
|
38
40
|
end
|
|
39
|
-
end
|
|
40
41
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
42
|
+
def fallback_e164_format(phone_number)
|
|
43
|
+
# Simple fallback - ensure it starts with + and looks like a phone number
|
|
44
|
+
cleaned = phone_number.to_s.gsub(/[^\d+]/, "")
|
|
45
|
+
cleaned.start_with?("+") ? cleaned : "+#{cleaned}"
|
|
46
|
+
end
|
|
45
47
|
end
|
|
46
48
|
end
|
|
47
|
-
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
require "middleware"
|
|
2
|
+
|
|
3
|
+
module FlowChat
|
|
4
|
+
class Processor
|
|
5
|
+
include FlowChat::Instrumentation
|
|
6
|
+
|
|
7
|
+
attr_reader :custom_middleware_builder, :context, :async_job_class, :async_job_params
|
|
8
|
+
|
|
9
|
+
def initialize(controller, enable_simulator: nil)
|
|
10
|
+
FlowChat.logger.debug { "Processor: Initializing processor for controller #{controller.class.name}" }
|
|
11
|
+
|
|
12
|
+
@context = FlowChat::Context.new
|
|
13
|
+
@context["controller"] = controller
|
|
14
|
+
@context["enable_simulator"] = enable_simulator.nil? ? (defined?(Rails) && Rails.env.local?) : enable_simulator
|
|
15
|
+
@custom_middleware_builder = ::Middleware::Builder.new(name: "processor.custom_middleware_builder")
|
|
16
|
+
@session_options = FlowChat::Config.session
|
|
17
|
+
@async_job_class = nil
|
|
18
|
+
@async_job_params = {}
|
|
19
|
+
|
|
20
|
+
FlowChat.logger.debug { "Processor: Simulator mode #{@context["enable_simulator"] ? "enabled" : "disabled"}" }
|
|
21
|
+
|
|
22
|
+
yield self if block_given?
|
|
23
|
+
|
|
24
|
+
FlowChat.logger.debug { "Processor: Initialized #{self.class.name} successfully" }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def use_gateway(gateway_class, *args)
|
|
28
|
+
FlowChat.logger.debug { "Processor: Configuring gateway #{gateway_class.name} with args: #{args.inspect}" }
|
|
29
|
+
@gateway_class = gateway_class
|
|
30
|
+
@gateway_args = args
|
|
31
|
+
self
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def use_session_store(session_store)
|
|
35
|
+
raise "Session store must be a class" unless session_store.is_a?(Class)
|
|
36
|
+
FlowChat.logger.debug { "Processor: Configuring session store #{session_store.name}" }
|
|
37
|
+
@context["session.store"] = session_store
|
|
38
|
+
self
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def use_session_config(boundaries: nil, hash_identifiers: nil, identifier: nil, &block)
|
|
42
|
+
if block_given?
|
|
43
|
+
FlowChat.logger.debug { "Processor: Configuring session config with custom proc" }
|
|
44
|
+
@session_options = @session_options.dup
|
|
45
|
+
@session_options.session_id_proc = block
|
|
46
|
+
else
|
|
47
|
+
FlowChat.logger.debug { "Processor: Configuring session config: boundaries=#{boundaries.inspect}, hash_identifiers=#{hash_identifiers}, identifier=#{identifier}" }
|
|
48
|
+
|
|
49
|
+
# Update the session options directly
|
|
50
|
+
@session_options = @session_options.dup
|
|
51
|
+
@session_options.boundaries = Array(boundaries) unless boundaries.nil?
|
|
52
|
+
@session_options.hash_identifiers = hash_identifiers unless hash_identifiers.nil?
|
|
53
|
+
@session_options.identifier = identifier unless identifier.nil?
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
self
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def use_middleware(middleware)
|
|
60
|
+
if block_given?
|
|
61
|
+
yield custom_middleware_builder
|
|
62
|
+
return self
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
raise "Middleware must be a class" unless middleware.is_a?(Class)
|
|
66
|
+
FlowChat.logger.debug { "Processor: Adding custom middleware: #{middleware.name}" }
|
|
67
|
+
custom_middleware_builder.use middleware
|
|
68
|
+
self
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def use_cross_platform_sessions
|
|
72
|
+
FlowChat.logger.debug { "Processor: Enabling cross-platform sessions via session configuration" }
|
|
73
|
+
use_session_config(
|
|
74
|
+
boundaries: [:flow]
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def use_url_isolation
|
|
79
|
+
FlowChat.logger.debug { "Processor: Enabling URL-based session isolation" }
|
|
80
|
+
current_boundaries = @session_options.boundaries.dup
|
|
81
|
+
current_boundaries << :url unless current_boundaries.include?(:url)
|
|
82
|
+
use_session_config(boundaries: current_boundaries)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def use_durable_sessions(cross_gateway: false)
|
|
86
|
+
FlowChat.logger.debug { "Processor: Enabling durable sessions via session configuration" }
|
|
87
|
+
use_session_config(
|
|
88
|
+
identifier: :user_id
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def use_async(job_class = nil, **job_params)
|
|
93
|
+
# If no job class provided, use GenericAsyncJob with factory param
|
|
94
|
+
if job_class.nil?
|
|
95
|
+
unless job_params.key?(:factory)
|
|
96
|
+
raise ArgumentError, "When use_async is called without a job class, factory: parameter is required"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
FlowChat.logger.debug { "Processor: Configuring async processing with GenericAsyncJob for factory '#{job_params[:factory]}'" }
|
|
100
|
+
@async_job_class = FlowChat::GenericAsyncJob
|
|
101
|
+
else
|
|
102
|
+
FlowChat.logger.debug { "Processor: Configuring async processing with job class #{job_class.name} and params: #{job_params.inspect}" }
|
|
103
|
+
@async_job_class = job_class
|
|
104
|
+
end
|
|
105
|
+
@async_job_params = job_params
|
|
106
|
+
|
|
107
|
+
self
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def async_enabled?
|
|
111
|
+
!@async_job_class.nil?
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def run(flow_class, action, **options)
|
|
115
|
+
# Instrument flow execution (this will log via LogSubscriber)
|
|
116
|
+
instrument(Events::FLOW_EXECUTION_START, {
|
|
117
|
+
flow_name: flow_class.name.underscore,
|
|
118
|
+
action: action.to_s,
|
|
119
|
+
processor_type: self.class.name
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
@context["processor"] = self
|
|
123
|
+
@context["flow.name"] = flow_class.name.underscore
|
|
124
|
+
@context["flow.class"] = flow_class
|
|
125
|
+
@context["flow.action"] = action
|
|
126
|
+
@context["flow.options"] = options
|
|
127
|
+
|
|
128
|
+
FlowChat.logger.debug { "Processor: Context prepared for flow #{flow_class.name}" }
|
|
129
|
+
|
|
130
|
+
stack = create_middleware_stack
|
|
131
|
+
yield stack if block_given?
|
|
132
|
+
|
|
133
|
+
FlowChat.logger.debug { "Processor: Executing middleware stack for #{flow_class.name}##{action}" }
|
|
134
|
+
|
|
135
|
+
# Instrument flow execution with timing (this will log completion via LogSubscriber)
|
|
136
|
+
instrument(Events::FLOW_EXECUTION_END, {
|
|
137
|
+
flow_name: flow_class.name.underscore,
|
|
138
|
+
action: action.to_s
|
|
139
|
+
}) do
|
|
140
|
+
stack.call(@context)
|
|
141
|
+
end
|
|
142
|
+
rescue => error
|
|
143
|
+
FlowChat.logger.error { "Processor: Flow execution failed - #{flow_class.name}##{action}, Error: #{error.class.name}: #{error.message}" }
|
|
144
|
+
FlowChat.logger.debug { "Processor: Stack trace: #{error.backtrace.join("\n")}" }
|
|
145
|
+
|
|
146
|
+
# Instrument flow execution error (this will log error via LogSubscriber)
|
|
147
|
+
instrument(Events::FLOW_EXECUTION_ERROR, {
|
|
148
|
+
flow_name: flow_class.name.underscore,
|
|
149
|
+
action: action.to_s,
|
|
150
|
+
error_class: error.class.name,
|
|
151
|
+
error_message: error.message,
|
|
152
|
+
backtrace: error.backtrace&.first(10)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
raise
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
protected
|
|
159
|
+
|
|
160
|
+
# Helper method for building stacks
|
|
161
|
+
def create_middleware_stack
|
|
162
|
+
raise ArgumentError, "Gateway is required. Call use_gateway(gateway_class, *args) before running." unless @gateway_class
|
|
163
|
+
|
|
164
|
+
middleware_stack = ::Middleware::Builder.new(name: @gateway_class.name) do |b|
|
|
165
|
+
# Gateway always comes first
|
|
166
|
+
b.use @gateway_class, *@gateway_args
|
|
167
|
+
# Session middleware next. We need to setup our session identifiers
|
|
168
|
+
b.use FlowChat::Session::Middleware, @session_options
|
|
169
|
+
|
|
170
|
+
if @gateway_class.respond_to?(:configure_middleware_stack)
|
|
171
|
+
FlowChat.logger.debug { "Processor: Using platform specific middleware configuration" }
|
|
172
|
+
@gateway_class.configure_middleware_stack(b, custom_middleware_builder)
|
|
173
|
+
else
|
|
174
|
+
b.use custom_middleware_builder
|
|
175
|
+
FlowChat.logger.debug { "Processor: Added custom middleware" }
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Executor always goes last.
|
|
179
|
+
# Nothing can execute after it.
|
|
180
|
+
b.use FlowChat::Executor
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
middleware_stack.inject_logger(FlowChat.logger) if FlowChat::Config.inject_middleware_logger
|
|
184
|
+
|
|
185
|
+
middleware_stack
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
data/lib/flow_chat/prompt.rb
CHANGED
|
@@ -3,13 +3,15 @@ module FlowChat
|
|
|
3
3
|
attr_reader :user_input
|
|
4
4
|
|
|
5
5
|
def initialize(input)
|
|
6
|
-
|
|
6
|
+
# Always work with a FlowChat::Input so `submitted?` (text OR attachment)
|
|
7
|
+
# gates the turn, and validate/transform receive the rich turn. A bare
|
|
8
|
+
# string (or nil) is wrapped as text.
|
|
9
|
+
@user_input = input.is_a?(FlowChat::Input) ? input : FlowChat::Input.new(text: input)
|
|
7
10
|
end
|
|
8
11
|
|
|
9
12
|
def ask(msg, choices: nil, transform: nil, validate: nil, media: nil)
|
|
10
|
-
if user_input.
|
|
11
|
-
|
|
12
|
-
validation_error = validate.call(input) if validate.present?
|
|
13
|
+
if user_input.submitted?
|
|
14
|
+
validation_error = validate.call(user_input) if validate.present?
|
|
13
15
|
|
|
14
16
|
if validation_error.present?
|
|
15
17
|
# Use config to determine whether to combine validation error with original message
|
|
@@ -21,8 +23,7 @@ module FlowChat
|
|
|
21
23
|
prompt!(message, choices: choices, media: media)
|
|
22
24
|
end
|
|
23
25
|
|
|
24
|
-
|
|
25
|
-
return input
|
|
26
|
+
return transform.present? ? transform.call(user_input) : user_input.to_s
|
|
26
27
|
end
|
|
27
28
|
|
|
28
29
|
# Pass raw message and media separately to the renderer
|
|
@@ -56,14 +57,6 @@ module FlowChat
|
|
|
56
57
|
|
|
57
58
|
private
|
|
58
59
|
|
|
59
|
-
def validate_media_choices_compatibility(media, choices)
|
|
60
|
-
return unless media && choices
|
|
61
|
-
|
|
62
|
-
if choices.length > 3
|
|
63
|
-
raise ArgumentError, "Media with more than 3 choices is not supported. Please use either media OR choices for more than 3 options."
|
|
64
|
-
end
|
|
65
|
-
end
|
|
66
|
-
|
|
67
60
|
def normalize_choices(choices)
|
|
68
61
|
case choices
|
|
69
62
|
when nil
|
|
@@ -78,8 +71,12 @@ module FlowChat
|
|
|
78
71
|
end
|
|
79
72
|
|
|
80
73
|
def prompt!(msg, choices: nil, media: nil)
|
|
81
|
-
|
|
82
|
-
|
|
74
|
+
# Media plus any number of choices is a supported combination: every
|
|
75
|
+
# renderer either carries the media as its own message ahead of the
|
|
76
|
+
# choice surface, or (WhatsApp, <=3 choices) as a header on the same
|
|
77
|
+
# message. There is no platform-level reason to cap the choice count
|
|
78
|
+
# here - that decision, if one is ever needed, belongs to a specific
|
|
79
|
+
# renderer, not to this shared entry point.
|
|
83
80
|
choices = normalize_choices(choices)
|
|
84
81
|
raise FlowChat::Interrupt::Prompt.new(msg, choices: choices, media: media)
|
|
85
82
|
end
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
require "kramdown"
|
|
2
|
+
require "rails-html-sanitizer"
|
|
3
|
+
|
|
4
|
+
module FlowChat
|
|
5
|
+
module Renderers
|
|
6
|
+
module MarkdownSupport
|
|
7
|
+
def to_html(text)
|
|
8
|
+
return "" if text.nil?
|
|
9
|
+
|
|
10
|
+
html = Kramdown::Document.new(text.to_s, **kramdown_options).to_html.strip
|
|
11
|
+
sanitize_html(html)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def sanitize_html(html)
|
|
15
|
+
sanitized = self.class.sanitizer.sanitize(
|
|
16
|
+
html,
|
|
17
|
+
tags: allowed_tags,
|
|
18
|
+
attributes: allowed_attributes
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
post_process_html(sanitized)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Markdown rendered as plain text, for platforms with no rich text at all.
|
|
25
|
+
# Messenger and Instagram both fall here: they display exactly the
|
|
26
|
+
# characters sent, so any leftover markup is noise the user reads.
|
|
27
|
+
def to_plain_text(text)
|
|
28
|
+
return "" if text.nil?
|
|
29
|
+
|
|
30
|
+
html = Kramdown::Document.new(text.to_s, **kramdown_options).to_html.strip
|
|
31
|
+
html_to_plain_text(html)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.included(base)
|
|
35
|
+
base.extend(ClassMethods)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
module ClassMethods
|
|
39
|
+
def sanitizer
|
|
40
|
+
@sanitizer ||= Rails::Html::SafeListSanitizer.new
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
# Override in subclasses to customize Kramdown options
|
|
47
|
+
# Default uses straight quotes (ASCII 39/34) instead of curly smart quotes
|
|
48
|
+
def kramdown_options
|
|
49
|
+
{smart_quotes: [39, 39, 34, 34]}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Override in subclasses to specify allowed HTML tags
|
|
53
|
+
def allowed_tags
|
|
54
|
+
%w[b strong i em a code pre]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Override in subclasses to specify allowed HTML attributes
|
|
58
|
+
def allowed_attributes
|
|
59
|
+
%w[href]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Override in subclasses to post-process sanitized HTML
|
|
63
|
+
def post_process_html(html)
|
|
64
|
+
html
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# A list whose items hold no further list, which is the only kind that
|
|
68
|
+
# can be turned into text without looking inside itself first.
|
|
69
|
+
INNERMOST_UNORDERED = %r{<ul[^>]*>((?:(?!<[uo]l\b).)*?)</ul>}m
|
|
70
|
+
INNERMOST_ORDERED = %r{<ol[^>]*>((?:(?!<[uo]l\b).)*?)</ol>}m
|
|
71
|
+
|
|
72
|
+
# Renders lists from the inside out.
|
|
73
|
+
#
|
|
74
|
+
# A single non-greedy pass over <ul>(.*?)</ul> pairs an outer opening tag
|
|
75
|
+
# with the *inner* list's closing tag, so on a nested list only the first
|
|
76
|
+
# item kept its bullet and the leftover </li></ul> was later stripped as
|
|
77
|
+
# a bare tag - leaving stray indented lines, and on some inputs raw
|
|
78
|
+
# markup, in a message a user reads.
|
|
79
|
+
#
|
|
80
|
+
# Innermost lists are replaced first and the loop repeats, so by the time
|
|
81
|
+
# an outer list is matched its children are already plain text and it
|
|
82
|
+
# contains no list markup to mispair with. Continuation lines are
|
|
83
|
+
# indented, which is what makes the nesting legible once the tags are
|
|
84
|
+
# gone.
|
|
85
|
+
def replace_lists(html)
|
|
86
|
+
# The markdown's own indentation survives into the HTML as whitespace
|
|
87
|
+
# around the list tags. Removed once, up front, so that from here on the
|
|
88
|
+
# only indentation in play is the kind this method adds - otherwise the
|
|
89
|
+
# two compound and each level steps further right than the last.
|
|
90
|
+
result = html.gsub(%r{\s*(</?(?:ul|ol|li)\b[^>]*>)\s*}m) { $1 }
|
|
91
|
+
|
|
92
|
+
loop do
|
|
93
|
+
changed = false
|
|
94
|
+
|
|
95
|
+
# Each rendered list opens on its own line. Without it, a nested list
|
|
96
|
+
# would run straight on from the text of the item holding it, and the
|
|
97
|
+
# parent pass would see one line where there are two.
|
|
98
|
+
result = result.gsub(INNERMOST_UNORDERED) do
|
|
99
|
+
changed = true
|
|
100
|
+
"\n" + list_items($1) { |item, _index| "• #{item}" }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
result = result.gsub(INNERMOST_ORDERED) do
|
|
104
|
+
changed = true
|
|
105
|
+
"\n" + list_items($1) { |item, index| "#{index}. #{item}" }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
break unless changed
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
result
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def list_items(html)
|
|
115
|
+
html.scan(%r{<li[^>]*>(.*?)</li>}m).flatten.map.with_index(1) do |item, index|
|
|
116
|
+
# Continuation lines are left exactly as they are: they were produced
|
|
117
|
+
# by an earlier pass over a nested list and already carry that level's
|
|
118
|
+
# indentation, which stripping would flatten. Source whitespace is
|
|
119
|
+
# gone by now - replace_lists removes it around the list tags before
|
|
120
|
+
# any of this runs.
|
|
121
|
+
lines = item.strip.split("\n").reject { |line| line.strip.empty? }
|
|
122
|
+
marked = yield(lines.shift.to_s.strip, index)
|
|
123
|
+
|
|
124
|
+
# Anything after the first line is an already-rendered nested list.
|
|
125
|
+
[marked, *lines.map { |line| " #{line}" }].join("\n")
|
|
126
|
+
end.join("\n")
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def html_to_plain_text(html)
|
|
130
|
+
result = html.dup
|
|
131
|
+
|
|
132
|
+
# Code blocks and inline code keep their content, lose their markers.
|
|
133
|
+
result.gsub!(%r{<pre[^>]*><code[^>]*>(.*?)</code></pre>}m) { $1.strip }
|
|
134
|
+
result.gsub!(%r{<code[^>]*>(.*?)</code>}m) { $1 }
|
|
135
|
+
|
|
136
|
+
# Links first: the anchor text is needed before tags are stripped.
|
|
137
|
+
result.gsub!(%r{<a[^>]*href=["']([^"']+)["'][^>]*>(.*?)</a>}m) do
|
|
138
|
+
url, text = $1, $2
|
|
139
|
+
(text == url) ? url : "#{text} (#{url})"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
result = replace_lists(result)
|
|
143
|
+
|
|
144
|
+
result.gsub!(%r{<blockquote[^>]*>(.*?)</blockquote>}m) do
|
|
145
|
+
$1.lines.map { |line| "> #{line.strip}" }.join("\n")
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
result.gsub!(%r{<p[^>]*>(.*?)</p>}m) { "#{$1}\n\n" }
|
|
149
|
+
result.gsub!(/<br\s*\/?>/, "\n")
|
|
150
|
+
|
|
151
|
+
# Every remaining tag, emphasis included, goes without replacement.
|
|
152
|
+
result.gsub!(/<[^>]+>/, "")
|
|
153
|
+
|
|
154
|
+
result.gsub!("&", "&")
|
|
155
|
+
result.gsub!("<", "<")
|
|
156
|
+
result.gsub!(">", ">")
|
|
157
|
+
result.gsub!(""", '"')
|
|
158
|
+
result.gsub!("'", "'")
|
|
159
|
+
result.gsub!(" ", " ")
|
|
160
|
+
|
|
161
|
+
result.gsub!(/\n{3,}/, "\n\n")
|
|
162
|
+
|
|
163
|
+
result.strip
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
require "openssl"
|
|
2
|
+
|
|
3
|
+
begin
|
|
4
|
+
require "active_support/security_utils"
|
|
5
|
+
rescue LoadError
|
|
6
|
+
# Older Active Support, or an install without it. secure_compare falls back
|
|
7
|
+
# to its own implementation below.
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
module FlowChat
|
|
11
|
+
# Shared security helpers: constant-time comparison for webhook secrets and
|
|
12
|
+
# signatures, and the signed cookie that authorizes simulator mode.
|
|
13
|
+
module Security
|
|
14
|
+
SIMULATOR_COOKIE_NAME = "flowchat_simulator"
|
|
15
|
+
|
|
16
|
+
# How long a simulator cookie stays valid.
|
|
17
|
+
SIMULATOR_COOKIE_TTL = 24 * 60 * 60
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
# Compare two strings without leaking their contents through timing.
|
|
21
|
+
def secure_compare(a, b)
|
|
22
|
+
a = a.to_s
|
|
23
|
+
b = b.to_s
|
|
24
|
+
|
|
25
|
+
if defined?(ActiveSupport::SecurityUtils)
|
|
26
|
+
ActiveSupport::SecurityUtils.secure_compare(a, b)
|
|
27
|
+
else
|
|
28
|
+
fallback_secure_compare(a, b)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The value to store in the simulator cookie: "timestamp:signature".
|
|
33
|
+
def simulator_cookie(timestamp = Time.now.to_i)
|
|
34
|
+
"#{timestamp}:#{simulator_signature(timestamp)}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# A simulator cookie is valid when it carries a recent timestamp signed
|
|
38
|
+
# with the configured simulator secret.
|
|
39
|
+
def valid_simulator_cookie?(cookie)
|
|
40
|
+
return false if FlowChat::Config.simulator_secret.blank? || cookie.blank?
|
|
41
|
+
|
|
42
|
+
timestamp_str, signature = cookie.to_s.split(":", 2)
|
|
43
|
+
return false unless timestamp_str && signature
|
|
44
|
+
|
|
45
|
+
timestamp = timestamp_str.to_i
|
|
46
|
+
return false if timestamp <= 0
|
|
47
|
+
return false if (Time.now.to_i - timestamp).abs > SIMULATOR_COOKIE_TTL
|
|
48
|
+
|
|
49
|
+
secure_compare(signature, simulator_signature(timestamp_str))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def simulator_signature(timestamp)
|
|
53
|
+
OpenSSL::HMAC.hexdigest(
|
|
54
|
+
OpenSSL::Digest.new("sha256"),
|
|
55
|
+
FlowChat::Config.simulator_secret,
|
|
56
|
+
"simulator:#{timestamp}"
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
# What Active Support does, for installs that do not have it: compare
|
|
63
|
+
# digests rather than the inputs, so the comparison runs over a fixed
|
|
64
|
+
# length and times neither the contents nor the length of a secret. The
|
|
65
|
+
# equality check afterwards is what makes a digest collision harmless.
|
|
66
|
+
def fallback_secure_compare(a, b)
|
|
67
|
+
digest_a = OpenSSL::Digest.digest("SHA256", a)
|
|
68
|
+
digest_b = OpenSSL::Digest.digest("SHA256", b)
|
|
69
|
+
|
|
70
|
+
result = 0
|
|
71
|
+
digest_a.bytes.zip(digest_b.bytes) { |byte_a, byte_b| result |= byte_a ^ byte_b }
|
|
72
|
+
result == 0 && a == b
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
|
|
1
3
|
module FlowChat
|
|
2
4
|
module Session
|
|
3
5
|
class Middleware
|
|
@@ -52,6 +54,14 @@ module FlowChat
|
|
|
52
54
|
return session_id
|
|
53
55
|
end
|
|
54
56
|
|
|
57
|
+
# Check for custom session ID proc
|
|
58
|
+
if @session_options.session_id_proc
|
|
59
|
+
FlowChat.logger.debug { "Session::Middleware: Using custom session ID proc" }
|
|
60
|
+
session_id = @session_options.session_id_proc.call(context)
|
|
61
|
+
FlowChat.logger.debug { "Session::Middleware: Generated custom session ID: #{session_id}" }
|
|
62
|
+
return session_id
|
|
63
|
+
end
|
|
64
|
+
|
|
55
65
|
FlowChat.logger.debug { "Session::Middleware: Building session ID for platform=#{platform}, gateway=#{gateway}, flow=#{flow_name}" }
|
|
56
66
|
|
|
57
67
|
# Get identifier based on configuration
|
|
@@ -65,7 +75,7 @@ module FlowChat
|
|
|
65
75
|
|
|
66
76
|
def get_session_identifier(context)
|
|
67
77
|
identifier_type = @session_options.identifier || platform_default_identifier(context)
|
|
68
|
-
|
|
78
|
+
|
|
69
79
|
case identifier_type
|
|
70
80
|
when :request_id
|
|
71
81
|
context["request.id"]
|
|
@@ -82,10 +92,14 @@ module FlowChat
|
|
|
82
92
|
|
|
83
93
|
def platform_default_identifier(context)
|
|
84
94
|
platform = context["request.platform"]
|
|
85
|
-
|
|
95
|
+
|
|
86
96
|
case platform
|
|
87
97
|
when :whatsapp
|
|
88
98
|
:msisdn
|
|
99
|
+
when :messenger, :instagram
|
|
100
|
+
# Neither platform exposes a phone number. The sender id is scoped to
|
|
101
|
+
# the app and the account, and is stable per user.
|
|
102
|
+
:user_id
|
|
89
103
|
else
|
|
90
104
|
:request_id
|
|
91
105
|
end
|
|
@@ -121,19 +135,26 @@ module FlowChat
|
|
|
121
135
|
return nil unless request
|
|
122
136
|
|
|
123
137
|
# Extract host and path for URL boundary
|
|
124
|
-
host =
|
|
125
|
-
|
|
138
|
+
host = begin
|
|
139
|
+
request.host
|
|
140
|
+
rescue
|
|
141
|
+
nil
|
|
142
|
+
end
|
|
143
|
+
path = begin
|
|
144
|
+
request.path
|
|
145
|
+
rescue
|
|
146
|
+
nil
|
|
147
|
+
end
|
|
126
148
|
|
|
127
|
-
# Create a normalized URL identifier: host + path
|
|
149
|
+
# Create a normalized URL identifier: host + path
|
|
128
150
|
# e.g., "example.com/api/v1/ussd" or "tenant1.example.com/ussd"
|
|
129
151
|
url_parts = []
|
|
130
152
|
url_parts << host if host.present?
|
|
131
|
-
url_parts << path.sub(/^\//,
|
|
153
|
+
url_parts << path.sub(/^\//, "") if path.present? && path != "/"
|
|
132
154
|
|
|
133
155
|
# For long URLs, use first part + hash suffix instead of full hash
|
|
134
|
-
url_identifier = url_parts.join(
|
|
156
|
+
url_identifier = url_parts.join("/").gsub(/[^a-zA-Z0-9._-]/, "_")
|
|
135
157
|
if url_identifier.length > 50
|
|
136
|
-
require 'digest'
|
|
137
158
|
# Take first 41 chars + hash suffix to keep it manageable but recognizable
|
|
138
159
|
first_part = url_identifier[0, 41]
|
|
139
160
|
hash_suffix = Digest::SHA256.hexdigest(url_identifier)[0, 8]
|
|
@@ -144,9 +165,13 @@ module FlowChat
|
|
|
144
165
|
end
|
|
145
166
|
|
|
146
167
|
def hash_identifier(identifier)
|
|
147
|
-
#
|
|
148
|
-
|
|
149
|
-
|
|
168
|
+
# First 32 hex chars of SHA256 = 128 bits. The old 8-char (32-bit)
|
|
169
|
+
# truncation was far too short: distinct users started colliding
|
|
170
|
+
# (birthday bound) around ~77k identifiers, and a collision means two
|
|
171
|
+
# users share a session. 128 bits pushes that boundary past ~2^64
|
|
172
|
+
# identifiers (collision-safe in practice) at half the length of the
|
|
173
|
+
# full 64-char digest.
|
|
174
|
+
Digest::SHA256.hexdigest(identifier.to_s)[0, 32]
|
|
150
175
|
end
|
|
151
176
|
end
|
|
152
177
|
end
|