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.
Files changed (147) hide show
  1. checksums.yaml +4 -4
  2. data/.cliff.toml +74 -0
  3. data/.github/workflows/ci.yml +2 -3
  4. data/.github/workflows/pages.yml +43 -0
  5. data/.github/workflows/release.yml +56 -0
  6. data/.standard.yml +4 -0
  7. data/CHANGELOG.md +48 -0
  8. data/CLAUDE.md +327 -0
  9. data/CONTRIBUTING.md +134 -0
  10. data/Gemfile +1 -0
  11. data/README.md +189 -133
  12. data/Rakefile +17 -2
  13. data/SECURITY.md +42 -349
  14. data/docs/architecture.md +83 -0
  15. data/docs/async-background-processing.md +64 -0
  16. data/docs/configuration.md +110 -287
  17. data/docs/factory-pattern.md +58 -0
  18. data/docs/gateway-context-variables.md +168 -0
  19. data/docs/gateway-development.md +159 -0
  20. data/docs/getting-started.md +90 -0
  21. data/docs/instrumentation.md +95 -175
  22. data/docs/platforms/instagram.md +278 -0
  23. data/docs/platforms/messenger.md +205 -0
  24. data/docs/platforms/telegram.md +109 -0
  25. data/docs/platforms/ussd.md +78 -0
  26. data/docs/platforms/whatsapp.md +147 -0
  27. data/docs/superpowers/plans/2026-07-09-inbound-media-support.md +732 -0
  28. data/docs/superpowers/plans/2026-07-09-inbound-media-support.md.tasks.json +58 -0
  29. data/docs/superpowers/plans/2026-08-10-messenger-instagram.md +4064 -0
  30. data/docs/superpowers/plans/2026-08-10-messenger-instagram.md.tasks.json +226 -0
  31. data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md +972 -0
  32. data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md.tasks.json +88 -0
  33. data/docs/superpowers/specs/2026-07-09-inbound-media-support-design.md +195 -0
  34. data/docs/superpowers/specs/2026-08-10-messenger-instagram-design.md +391 -0
  35. data/docs/testing.md +33 -426
  36. data/examples/custom_session_id_example.rb +119 -0
  37. data/examples/http_controller.rb +22 -20
  38. data/examples/intercom_configuration_example.rb +113 -0
  39. data/examples/intercom_controller.rb +182 -0
  40. data/examples/multi_tenant_whatsapp_controller.rb +63 -168
  41. data/examples/simulator_controller.rb +0 -1
  42. data/examples/ussd_controller.rb +88 -160
  43. data/examples/whatsapp_controller.rb +18 -17
  44. data/examples/whatsapp_media_examples.rb +27 -79
  45. data/flow_chat.gemspec +4 -0
  46. data/lib/flow_chat/app.rb +211 -0
  47. data/lib/flow_chat/async_job.rb +176 -0
  48. data/lib/flow_chat/choice_titles.rb +95 -0
  49. data/lib/flow_chat/config.rb +126 -23
  50. data/lib/flow_chat/delivery_error.rb +9 -0
  51. data/lib/flow_chat/{base_executor.rb → executor.rb} +6 -11
  52. data/lib/flow_chat/factory.rb +94 -0
  53. data/lib/flow_chat/gateway_async_support.rb +106 -0
  54. data/lib/flow_chat/generic_async_job.rb +30 -0
  55. data/lib/flow_chat/http/configuration_error.rb +9 -0
  56. data/lib/flow_chat/http/gateway/simple.rb +104 -36
  57. data/lib/flow_chat/http/middleware/choice_mapper.rb +94 -0
  58. data/lib/flow_chat/http/renderer.rb +3 -3
  59. data/lib/flow_chat/input.rb +86 -0
  60. data/lib/flow_chat/instagram/client.rb +32 -0
  61. data/lib/flow_chat/instagram/configuration.rb +147 -0
  62. data/lib/flow_chat/instagram/configuration_error.rb +7 -0
  63. data/lib/flow_chat/instagram/gateway/send_api.rb +63 -0
  64. data/lib/flow_chat/instagram/middleware/choice_mapper.rb +22 -0
  65. data/lib/flow_chat/instagram/renderer.rb +23 -0
  66. data/lib/flow_chat/instrumentation/metrics_collector.rb +6 -1
  67. data/lib/flow_chat/instrumentation/setup.rb +1 -1
  68. data/lib/flow_chat/instrumentation.rb +182 -0
  69. data/lib/flow_chat/intercom/client.rb +161 -0
  70. data/lib/flow_chat/intercom/configuration.rb +102 -0
  71. data/lib/flow_chat/intercom/configuration_error.rb +9 -0
  72. data/lib/flow_chat/intercom/gateway/intercom_api.rb +420 -0
  73. data/lib/flow_chat/intercom/middleware/choice_mapper.rb +101 -0
  74. data/lib/flow_chat/intercom/renderer.rb +123 -0
  75. data/lib/flow_chat/media.rb +121 -0
  76. data/lib/flow_chat/messenger/client.rb +264 -0
  77. data/lib/flow_chat/messenger/configuration.rb +103 -0
  78. data/lib/flow_chat/messenger/configuration_error.rb +9 -0
  79. data/lib/flow_chat/messenger/gateway/send_api.rb +42 -0
  80. data/lib/flow_chat/messenger/middleware/choice_mapper.rb +185 -0
  81. data/lib/flow_chat/messenger/renderer.rb +150 -0
  82. data/lib/flow_chat/meta/challenge.rb +24 -0
  83. data/lib/flow_chat/meta/choice_ladder.rb +37 -0
  84. data/lib/flow_chat/meta/configuration_error.rb +7 -0
  85. data/lib/flow_chat/meta/gateway_identity.rb +38 -0
  86. data/lib/flow_chat/meta/messaging_gateway.rb +468 -0
  87. data/lib/flow_chat/meta/signature.rb +30 -0
  88. data/lib/flow_chat/meta/signature_validation.rb +66 -0
  89. data/lib/flow_chat/meta/webhook_verification.rb +43 -0
  90. data/lib/flow_chat/named_configuration.rb +65 -0
  91. data/lib/flow_chat/phone_number_util.rb +37 -35
  92. data/lib/flow_chat/processor.rb +188 -0
  93. data/lib/flow_chat/prompt.rb +13 -16
  94. data/lib/flow_chat/renderers/markdown_support.rb +167 -0
  95. data/lib/flow_chat/security.rb +76 -0
  96. data/lib/flow_chat/session/middleware.rb +36 -11
  97. data/lib/flow_chat/simulator/controller.rb +31 -15
  98. data/lib/flow_chat/simulator/views/simulator.html.erb +184 -20
  99. data/lib/flow_chat/telegram/client.rb +283 -0
  100. data/lib/flow_chat/telegram/configuration.rb +78 -0
  101. data/lib/flow_chat/telegram/configuration_error.rb +9 -0
  102. data/lib/flow_chat/telegram/gateway/bot_api.rb +318 -0
  103. data/lib/flow_chat/telegram/middleware/choice_mapper.rb +96 -0
  104. data/lib/flow_chat/telegram/renderer.rb +133 -0
  105. data/lib/flow_chat/telegram.rb +7 -0
  106. data/lib/flow_chat/text_truncator.rb +75 -0
  107. data/lib/flow_chat/ussd/gateway/nalo.rb +24 -4
  108. data/lib/flow_chat/ussd/middleware/choice_mapper.rb +10 -0
  109. data/lib/flow_chat/ussd/middleware/pagination.rb +9 -5
  110. data/lib/flow_chat/ussd/renderer.rb +1 -1
  111. data/lib/flow_chat/version.rb +1 -1
  112. data/lib/flow_chat/whatsapp/client.rb +158 -20
  113. data/lib/flow_chat/whatsapp/configuration.rb +13 -52
  114. data/lib/flow_chat/whatsapp/configuration_error.rb +9 -0
  115. data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +335 -248
  116. data/lib/flow_chat/whatsapp/middleware/choice_mapper.rb +234 -0
  117. data/lib/flow_chat/whatsapp/renderer.rb +259 -64
  118. data/lib/flow_chat.rb +1 -1
  119. data/lib/tasks/release.rake +165 -0
  120. data/site/.nojekyll +0 -0
  121. data/site/.og-card.html +89 -0
  122. data/site/favicon.svg +6 -0
  123. data/site/index.html +209 -0
  124. data/site/og.png +0 -0
  125. metadata +132 -25
  126. data/docs/flows.md +0 -320
  127. data/docs/http-gateway-protocol.md +0 -432
  128. data/docs/images/simulator.png +0 -0
  129. data/docs/media.md +0 -153
  130. data/docs/sessions.md +0 -433
  131. data/docs/ussd-setup.md +0 -322
  132. data/docs/whatsapp-setup.md +0 -162
  133. data/examples/whatsapp_message_job.rb +0 -113
  134. data/lib/flow_chat/base_app.rb +0 -86
  135. data/lib/flow_chat/base_processor.rb +0 -146
  136. data/lib/flow_chat/http/app.rb +0 -6
  137. data/lib/flow_chat/http/middleware/executor.rb +0 -24
  138. data/lib/flow_chat/http/processor.rb +0 -33
  139. data/lib/flow_chat/session/rails_session_store.rb +0 -68
  140. data/lib/flow_chat/ussd/app.rb +0 -6
  141. data/lib/flow_chat/ussd/gateway/nsano.rb +0 -96
  142. data/lib/flow_chat/ussd/middleware/executor.rb +0 -24
  143. data/lib/flow_chat/ussd/processor.rb +0 -39
  144. data/lib/flow_chat/whatsapp/app.rb +0 -29
  145. data/lib/flow_chat/whatsapp/middleware/executor.rb +0 -24
  146. data/lib/flow_chat/whatsapp/processor.rb +0 -32
  147. data/lib/flow_chat/whatsapp/send_job_support.rb +0 -79
@@ -1,47 +1,49 @@
1
1
  module FlowChat
2
2
  module PhoneNumberUtil
3
- def self.to_e164(phone_number)
4
- return phone_number if phone_number.nil? || phone_number.empty?
5
-
6
- begin
7
- # Try to load phonelib without Rails dependency
8
- require_phonelib_safely
9
- Phonelib.parse(phone_number).e164
10
- rescue => e
11
- FlowChat.logger.warn { "PhoneNumberUtil: Failed to parse phone number '#{phone_number}': #{e.message}" }
12
- # Fallback to simple formatting if phonelib fails
13
- fallback_e164_format(phone_number)
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
- private
18
+ private
18
19
 
19
- def self.require_phonelib_safely
20
- return if defined?(Phonelib)
21
-
22
- # Temporarily stub Rails if it doesn't exist
23
- unless defined?(Rails)
24
- stub_rails = Module.new do
25
- def self.const_missing(name)
26
- if name == :Railtie
27
- Class.new
28
- else
29
- super
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
- def self.fallback_e164_format(phone_number)
42
- # Simple fallback - ensure it starts with + and looks like a phone number
43
- cleaned = phone_number.to_s.gsub(/[^\d+]/, '')
44
- cleaned.start_with?('+') ? cleaned : "+#{cleaned}"
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
@@ -3,13 +3,15 @@ module FlowChat
3
3
  attr_reader :user_input
4
4
 
5
5
  def initialize(input)
6
- @user_input = input
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.present?
11
- input = user_input
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
- input = transform.call(input) if transform.present?
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
- validate_media_choices_compatibility(media, choices)
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!("&amp;", "&")
155
+ result.gsub!("&lt;", "<")
156
+ result.gsub!("&gt;", ">")
157
+ result.gsub!("&quot;", '"')
158
+ result.gsub!("&#39;", "'")
159
+ result.gsub!("&nbsp;", " ")
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 = request.host rescue nil
125
- path = request.path rescue nil
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(/^\//, '') if path.present? && path != '/'
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('/').gsub(/[^a-zA-Z0-9._-]/, '_')
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
- # Use SHA256 but only take first 8 characters for reasonable session IDs
148
- require 'digest'
149
- Digest::SHA256.hexdigest(identifier.to_s)[0, 8]
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