flow_chat 0.8.2 → 0.9.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 (91) 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/release.yml +56 -0
  5. data/.standard.yml +4 -0
  6. data/CHANGELOG.md +22 -0
  7. data/CLAUDE.md +327 -0
  8. data/CONTRIBUTING.md +134 -0
  9. data/Gemfile +1 -0
  10. data/README.md +290 -105
  11. data/Rakefile +5 -1
  12. data/SECURITY.md +42 -349
  13. data/docs/architecture.md +510 -0
  14. data/docs/async-background-processing.md +298 -0
  15. data/docs/configuration.md +556 -226
  16. data/docs/factory-pattern.md +355 -0
  17. data/docs/gateway-context-variables.md +171 -0
  18. data/docs/gateway-development.md +723 -0
  19. data/docs/getting-started.md +429 -0
  20. data/docs/instrumentation.md +264 -153
  21. data/docs/platforms/telegram.md +1013 -0
  22. data/docs/platforms/ussd.md +693 -0
  23. data/docs/platforms/whatsapp.md +1395 -0
  24. data/docs/testing.md +243 -365
  25. data/examples/custom_session_id_example.rb +119 -0
  26. data/examples/http_controller.rb +11 -11
  27. data/examples/intercom_configuration_example.rb +118 -0
  28. data/examples/intercom_controller.rb +194 -0
  29. data/examples/multi_tenant_whatsapp_controller.rb +4 -4
  30. data/examples/ussd_controller.rb +9 -9
  31. data/examples/whatsapp_controller.rb +2 -2
  32. data/flow_chat.gemspec +4 -0
  33. data/lib/flow_chat/{base_app.rb → app.rb} +16 -9
  34. data/lib/flow_chat/async_job.rb +176 -0
  35. data/lib/flow_chat/config.rb +10 -30
  36. data/lib/flow_chat/{base_executor.rb → executor.rb} +6 -11
  37. data/lib/flow_chat/factory.rb +94 -0
  38. data/lib/flow_chat/gateway_async_support.rb +106 -0
  39. data/lib/flow_chat/generic_async_job.rb +30 -0
  40. data/lib/flow_chat/http/gateway/simple.rb +81 -33
  41. data/lib/flow_chat/http/renderer.rb +3 -3
  42. data/lib/flow_chat/instrumentation/setup.rb +1 -1
  43. data/lib/flow_chat/instrumentation.rb +23 -0
  44. data/lib/flow_chat/intercom/client.rb +155 -0
  45. data/lib/flow_chat/intercom/configuration.rb +149 -0
  46. data/lib/flow_chat/intercom/gateway/intercom_api.rb +396 -0
  47. data/lib/flow_chat/intercom/renderer.rb +71 -0
  48. data/lib/flow_chat/phone_number_util.rb +37 -35
  49. data/lib/flow_chat/processor.rb +188 -0
  50. data/lib/flow_chat/renderers/markdown_support.rb +58 -0
  51. data/lib/flow_chat/session/middleware.rb +25 -9
  52. data/lib/flow_chat/telegram/client.rb +240 -0
  53. data/lib/flow_chat/telegram/configuration.rb +118 -0
  54. data/lib/flow_chat/telegram/gateway/bot_api.rb +300 -0
  55. data/lib/flow_chat/telegram/middleware/choice_mapper.rb +35 -0
  56. data/lib/flow_chat/telegram/renderer.rb +125 -0
  57. data/lib/flow_chat/telegram.rb +7 -0
  58. data/lib/flow_chat/ussd/gateway/nalo.rb +24 -4
  59. data/lib/flow_chat/ussd/middleware/pagination.rb +9 -5
  60. data/lib/flow_chat/ussd/renderer.rb +1 -1
  61. data/lib/flow_chat/version.rb +1 -1
  62. data/lib/flow_chat/whatsapp/client.rb +144 -13
  63. data/lib/flow_chat/whatsapp/configuration.rb +1 -1
  64. data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +132 -96
  65. data/lib/flow_chat/whatsapp/id_generator.rb +124 -0
  66. data/lib/flow_chat/whatsapp/middleware/choice_mapper.rb +147 -0
  67. data/lib/flow_chat/whatsapp/renderer.rb +145 -11
  68. data/lib/flow_chat.rb +11 -1
  69. data/lib/tasks/release.rake +165 -0
  70. metadata +84 -25
  71. data/docs/flows.md +0 -320
  72. data/docs/http-gateway-protocol.md +0 -432
  73. data/docs/images/simulator.png +0 -0
  74. data/docs/media.md +0 -153
  75. data/docs/sessions.md +0 -433
  76. data/docs/ussd-setup.md +0 -322
  77. data/docs/whatsapp-setup.md +0 -162
  78. data/examples/whatsapp_message_job.rb +0 -113
  79. data/lib/flow_chat/base_processor.rb +0 -146
  80. data/lib/flow_chat/http/app.rb +0 -6
  81. data/lib/flow_chat/http/middleware/executor.rb +0 -24
  82. data/lib/flow_chat/http/processor.rb +0 -33
  83. data/lib/flow_chat/session/rails_session_store.rb +0 -68
  84. data/lib/flow_chat/ussd/app.rb +0 -6
  85. data/lib/flow_chat/ussd/gateway/nsano.rb +0 -96
  86. data/lib/flow_chat/ussd/middleware/executor.rb +0 -24
  87. data/lib/flow_chat/ussd/processor.rb +0 -39
  88. data/lib/flow_chat/whatsapp/app.rb +0 -29
  89. data/lib/flow_chat/whatsapp/middleware/executor.rb +0 -24
  90. data/lib/flow_chat/whatsapp/processor.rb +0 -32
  91. data/lib/flow_chat/whatsapp/send_job_support.rb +0 -79
@@ -0,0 +1,300 @@
1
+ require "json"
2
+
3
+ module FlowChat
4
+ module Telegram
5
+ class ConfigurationError < StandardError; end
6
+
7
+ module Gateway
8
+ class BotApi
9
+ include FlowChat::Instrumentation
10
+ include FlowChat::GatewayAsyncSupport
11
+
12
+ attr_reader :client, :context
13
+
14
+ def initialize(app, config = nil)
15
+ @app = app
16
+ @config = config || FlowChat::Telegram::Configuration.from_credentials
17
+ @client = FlowChat::Telegram::Client.new(@config)
18
+
19
+ FlowChat.logger.info { "BotApi: Initialized Telegram Bot API gateway" }
20
+ end
21
+
22
+ def call(context)
23
+ @context = context
24
+ @controller = context.controller
25
+ request = @controller.request
26
+
27
+ FlowChat.logger.debug {
28
+ "BotApi: Processing #{begin
29
+ request.request_method
30
+ rescue
31
+ "POST"
32
+ end} request"
33
+ }
34
+
35
+ # Handle POST webhooks only
36
+ if request.post?
37
+ return handle_webhook(context)
38
+ end
39
+
40
+ @controller.head :bad_request
41
+ end
42
+
43
+ # Platform-specific middleware configuration
44
+ def self.configure_middleware_stack(builder, custom_middleware)
45
+ FlowChat.logger.debug { "BotApi: Configuring Telegram middleware stack" }
46
+ builder.use custom_middleware
47
+ builder.use FlowChat::Telegram::Middleware::ChoiceMapper
48
+ end
49
+
50
+ private
51
+
52
+ def handle_webhook(context)
53
+ begin
54
+ parse_request_body(@controller.request)
55
+ FlowChat.logger.debug { "BotApi: Successfully parsed webhook request body" }
56
+ rescue JSON::ParserError => e
57
+ FlowChat.logger.error { "BotApi: Failed to parse webhook body: #{e.message}" }
58
+ return @controller.head :bad_request
59
+ end
60
+
61
+ # Validate webhook signature (skip in background)
62
+ # Return 200 OK even for invalid signatures to prevent Telegram from retrying
63
+ unless in_background? || valid_webhook_signature?(@controller.request)
64
+ FlowChat.logger.warn { "BotApi: Invalid webhook signature - dropping request" }
65
+ return @controller.head :ok
66
+ end
67
+
68
+ # Process update
69
+ if @body["message"]
70
+ process_message(@body["message"], context)
71
+ elsif @body["callback_query"]
72
+ process_callback_query(@body["callback_query"], context)
73
+ else
74
+ FlowChat.logger.debug { "BotApi: No message or callback_query in update - returning OK" }
75
+ return @controller.head :ok
76
+ end
77
+
78
+ # Routing: async or inline
79
+ if should_enqueue_async?
80
+ enqueue_async_job
81
+ return @controller.head :ok
82
+ end
83
+
84
+ handle_message_inline(context, @controller)
85
+ @controller.head :ok
86
+ end
87
+
88
+ def process_message(message, context)
89
+ chat = message["chat"]
90
+ from = message["from"]
91
+
92
+ context["request.id"] = chat["id"].to_s
93
+ context["request.user_id"] = from["id"].to_s
94
+ context["request.user_name"] = [from["first_name"], from["last_name"]].compact.join(" ")
95
+ context["request.username"] = from["username"]
96
+ context["request.gateway"] = :telegram_bot_api
97
+ context["request.platform"] = :telegram
98
+ context["request.message_id"] = message["message_id"].to_s
99
+ context["request.timestamp"] = Time.at(message["date"]).iso8601
100
+ context["request.body"] = @body
101
+
102
+ context["telegram.client"] = @client
103
+ context["telegram.chat_type"] = chat["type"]
104
+
105
+ extract_message_content!(message, context)
106
+
107
+ if context.input.present?
108
+ instrument(Events::MESSAGE_RECEIVED, {
109
+ from: from["id"].to_s,
110
+ message: context.input,
111
+ message_type: detect_message_type(message),
112
+ chat_type: chat["type"]
113
+ })
114
+ end
115
+ end
116
+
117
+ def process_callback_query(callback_query, context)
118
+ from = callback_query["from"]
119
+ message = callback_query["message"]
120
+ chat = message["chat"]
121
+
122
+ context["request.id"] = chat["id"].to_s
123
+ context["request.user_id"] = from["id"].to_s
124
+ context["request.user_name"] = [from["first_name"], from["last_name"]].compact.join(" ")
125
+ context["request.username"] = from["username"]
126
+ context["request.gateway"] = :telegram_bot_api
127
+ context["request.platform"] = :telegram
128
+ context["request.message_id"] = message["message_id"].to_s
129
+ context["request.timestamp"] = Time.current.iso8601
130
+ context["request.body"] = @body
131
+
132
+ context["telegram.client"] = @client
133
+ context["telegram.callback_query_id"] = callback_query["id"]
134
+ context["telegram.original_message_id"] = message["message_id"]
135
+ context["telegram.chat_type"] = chat["type"]
136
+
137
+ # Input is the callback_data
138
+ context.input = callback_query["data"]
139
+
140
+ # Auto-answer callback query to remove loading indicator
141
+ @client.answer_callback_query(callback_query["id"])
142
+
143
+ instrument(Events::MESSAGE_RECEIVED, {
144
+ from: from["id"].to_s,
145
+ message: context.input,
146
+ message_type: "callback_query"
147
+ })
148
+ end
149
+
150
+ def extract_message_content!(message, context)
151
+ if message["text"]
152
+ context.input = message["text"]
153
+ elsif message["location"]
154
+ context["request.location"] = {
155
+ "latitude" => message["location"]["latitude"],
156
+ "longitude" => message["location"]["longitude"]
157
+ }
158
+ context.input = FlowChat::Input::LOCATION
159
+ elsif message["photo"]
160
+ # Photos come as array, take highest resolution (last)
161
+ photo = message["photo"].last
162
+ context["request.media"] = {
163
+ type: :photo,
164
+ file_id: photo["file_id"],
165
+ file_unique_id: photo["file_unique_id"],
166
+ width: photo["width"],
167
+ height: photo["height"]
168
+ }
169
+ context.input = FlowChat::Input::MEDIA
170
+ elsif message["video"]
171
+ video = message["video"]
172
+ context["request.media"] = {
173
+ type: :video,
174
+ file_id: video["file_id"],
175
+ file_unique_id: video["file_unique_id"],
176
+ width: video["width"],
177
+ height: video["height"],
178
+ duration: video["duration"],
179
+ mime_type: video["mime_type"]
180
+ }
181
+ context.input = FlowChat::Input::MEDIA
182
+ elsif message["audio"]
183
+ audio = message["audio"]
184
+ context["request.media"] = {
185
+ type: :audio,
186
+ file_id: audio["file_id"],
187
+ file_unique_id: audio["file_unique_id"],
188
+ duration: audio["duration"],
189
+ mime_type: audio["mime_type"],
190
+ title: audio["title"],
191
+ performer: audio["performer"]
192
+ }
193
+ context.input = FlowChat::Input::MEDIA
194
+ elsif message["document"]
195
+ doc = message["document"]
196
+ context["request.media"] = {
197
+ type: :document,
198
+ file_id: doc["file_id"],
199
+ file_unique_id: doc["file_unique_id"],
200
+ file_name: doc["file_name"],
201
+ mime_type: doc["mime_type"]
202
+ }
203
+ context.input = FlowChat::Input::MEDIA
204
+ elsif message["voice"]
205
+ voice = message["voice"]
206
+ context["request.media"] = {
207
+ type: :voice,
208
+ file_id: voice["file_id"],
209
+ file_unique_id: voice["file_unique_id"],
210
+ duration: voice["duration"],
211
+ mime_type: voice["mime_type"]
212
+ }
213
+ context.input = FlowChat::Input::MEDIA
214
+ elsif message["sticker"]
215
+ sticker = message["sticker"]
216
+ context["request.media"] = {
217
+ type: :sticker,
218
+ file_id: sticker["file_id"],
219
+ file_unique_id: sticker["file_unique_id"],
220
+ width: sticker["width"],
221
+ height: sticker["height"],
222
+ is_animated: sticker["is_animated"],
223
+ is_video: sticker["is_video"],
224
+ emoji: sticker["emoji"],
225
+ set_name: sticker["set_name"]
226
+ }
227
+ context.input = FlowChat::Input::MEDIA
228
+ elsif message["contact"]
229
+ context["request.contact"] = {
230
+ phone_number: message["contact"]["phone_number"],
231
+ first_name: message["contact"]["first_name"],
232
+ last_name: message["contact"]["last_name"],
233
+ user_id: message["contact"]["user_id"]
234
+ }
235
+ context.input = FlowChat::Input::CONTACT
236
+ else
237
+ context.input = ""
238
+ end
239
+ end
240
+
241
+ def detect_message_type(message)
242
+ return "text" if message["text"]
243
+ return "location" if message["location"]
244
+ return "photo" if message["photo"]
245
+ return "video" if message["video"]
246
+ return "audio" if message["audio"]
247
+ return "document" if message["document"]
248
+ return "voice" if message["voice"]
249
+ return "sticker" if message["sticker"]
250
+ return "contact" if message["contact"]
251
+ "unknown"
252
+ end
253
+
254
+ def valid_webhook_signature?(request)
255
+ return true if @config.skip_signature_validation
256
+ return true unless @config.secret_token
257
+
258
+ provided_token = request.headers["X-Telegram-Bot-Api-Secret-Token"]
259
+ return false unless provided_token
260
+
261
+ secure_compare(@config.secret_token, provided_token.to_s)
262
+ end
263
+
264
+ def secure_compare(a, b)
265
+ return false unless a.bytesize == b.bytesize
266
+ l = a.unpack("C*")
267
+ res = 0
268
+ b.each_byte { |byte| res |= byte ^ l.shift }
269
+ res == 0
270
+ end
271
+
272
+ def handle_message_inline(context, controller)
273
+ response = @app.call(context)
274
+ return unless response
275
+
276
+ _type, prompt, choices, media = response
277
+ @client.send_message(context["request.id"], prompt, choices: choices, media: media)
278
+
279
+ instrument(Events::MESSAGE_SENT, {
280
+ to: context["request.id"],
281
+ message: prompt,
282
+ gateway: :telegram_bot_api,
283
+ platform: :telegram
284
+ })
285
+ end
286
+
287
+ def parse_request_body(request)
288
+ return @body if @body
289
+
290
+ if request.body.nil?
291
+ @body = {}
292
+ else
293
+ request.body.rewind if request.body.respond_to?(:rewind)
294
+ @body = JSON.parse(request.body.read)
295
+ end
296
+ end
297
+ end
298
+ end
299
+ end
300
+ end
@@ -0,0 +1,35 @@
1
+ module FlowChat
2
+ module Telegram
3
+ module Middleware
4
+ class ChoiceMapper
5
+ def initialize(app)
6
+ @app = app
7
+ end
8
+
9
+ def call(context)
10
+ input = context.input
11
+
12
+ # Check if input matches a stored choice key (for validation/logging)
13
+ if context.session && input.present?
14
+ choices = context.session.get("telegram_choices")
15
+ if choices&.key?(input)
16
+ FlowChat.logger.debug { "ChoiceMapper: Input '#{input}' is a valid choice key" }
17
+ end
18
+ end
19
+
20
+ response = @app.call(context)
21
+
22
+ # Store choices in session for validation
23
+ if response
24
+ _, _, choices, _ = response
25
+ if choices.is_a?(Hash)
26
+ context.session&.set("telegram_choices", choices)
27
+ end
28
+ end
29
+
30
+ response
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,125 @@
1
+ require "flow_chat/renderers/markdown_support"
2
+
3
+ module FlowChat
4
+ module Telegram
5
+ class Renderer
6
+ include FlowChat::Renderers::MarkdownSupport
7
+
8
+ attr_reader :message, :choices, :media
9
+
10
+ def initialize(message, choices: nil, media: nil)
11
+ @message = message
12
+ @choices = choices
13
+ @media = media
14
+ end
15
+
16
+ def render
17
+ if media && choices
18
+ build_media_with_keyboard
19
+ elsif media
20
+ build_media_message
21
+ elsif choices
22
+ build_keyboard_message
23
+ else
24
+ build_text_message
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def build_text_message
31
+ [:text, to_html(message), {}]
32
+ end
33
+
34
+ def build_keyboard_message
35
+ validate_choices!
36
+ keyboard = build_inline_keyboard(choices)
37
+ [:inline_keyboard, to_html(message), {keyboard: keyboard}]
38
+ end
39
+
40
+ def build_media_message
41
+ media_type = media[:type] || :photo
42
+ url = media[:url] || media[:file_id]
43
+
44
+ case media_type.to_sym
45
+ when :photo
46
+ [:photo, to_html(message), {url: url}]
47
+ when :document
48
+ [:document, to_html(message), {url: url, filename: media[:filename]}]
49
+ when :video
50
+ [:video, to_html(message), {url: url}]
51
+ when :audio
52
+ [:audio, to_html(message), {url: url}]
53
+ when :voice
54
+ [:voice, to_html(message), {url: url}]
55
+ else
56
+ # Fallback to text for unsupported types
57
+ [:text, to_html(message), {}]
58
+ end
59
+ end
60
+
61
+ def build_media_with_keyboard
62
+ validate_choices!
63
+ keyboard = build_inline_keyboard(choices)
64
+ media_type = media[:type] || :photo
65
+ url = media[:url] || media[:file_id]
66
+
67
+ [:photo_with_keyboard, to_html(message), {
68
+ url: url,
69
+ keyboard: keyboard,
70
+ media_type: media_type.to_sym
71
+ }]
72
+ end
73
+
74
+ def build_inline_keyboard(choice_hash)
75
+ buttons = choice_hash.map do |key, value|
76
+ {
77
+ text: truncate_text(value.to_s, 64),
78
+ callback_data: key.to_s[0, 64]
79
+ }
80
+ end
81
+
82
+ # Layout: 2 buttons per row for <=4 choices, 1 per row for >4
83
+ if buttons.length <= 4
84
+ buttons.each_slice(2).to_a
85
+ else
86
+ buttons.zip
87
+ end
88
+ end
89
+
90
+ def validate_choices!
91
+ unless choices.is_a?(Hash)
92
+ raise ArgumentError, "choices must be a Hash"
93
+ end
94
+ end
95
+
96
+ def truncate_text(text, length)
97
+ return text if text.length <= length
98
+ text[0, length - 3] + "..."
99
+ end
100
+
101
+ # MarkdownSupport overrides for Telegram-specific behavior
102
+
103
+ def allowed_tags
104
+ # Tags supported by Telegram Bot API HTML mode
105
+ # Note: p and br are allowed through sanitization but converted to newlines in post_process_html
106
+ %w[b strong i em u s strike del a code pre blockquote p br]
107
+ end
108
+
109
+ def allowed_attributes
110
+ %w[href]
111
+ end
112
+
113
+ def post_process_html(html)
114
+ # Convert <p> tags to text with double newlines (Telegram doesn't support <p>)
115
+ result = html.gsub(%r{<p>(.*?)</p>}m, '\1' + "\n\n")
116
+
117
+ # Convert <br> and <br/> to newlines (Telegram doesn't support <br>)
118
+ result = result.gsub(/<br\s*\/?>/, "\n")
119
+
120
+ # Clean up excessive newlines
121
+ result.gsub(/\n{3,}/, "\n\n").strip
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,7 @@
1
+ # Telegram integration for FlowChat
2
+ # All files are autoloaded by Zeitwerk based on file path conventions
3
+
4
+ module FlowChat
5
+ module Telegram
6
+ end
7
+ end
@@ -3,6 +3,7 @@ module FlowChat
3
3
  module Gateway
4
4
  class Nalo
5
5
  include FlowChat::Instrumentation
6
+ include FlowChat::GatewayAsyncSupport
6
7
 
7
8
  attr_reader :context
8
9
 
@@ -10,9 +11,15 @@ module FlowChat
10
11
  @app = app
11
12
  end
12
13
 
14
+ # USSD does not support async processing due to synchronous protocol requirements
15
+ def async_supported?
16
+ false
17
+ end
18
+
13
19
  def call(context)
14
20
  @context = context
15
- params = context.controller.request.params
21
+ @controller = context.controller
22
+ params = @controller.request.params
16
23
 
17
24
  context["request.id"] = params["USERID"]
18
25
  context["request.msisdn"] = FlowChat::PhoneNumberUtil.to_e164(params["MSISDN"])
@@ -21,9 +28,9 @@ module FlowChat
21
28
  context["request.timestamp"] = Time.current.iso8601
22
29
  context["request.gateway"] = :nalo
23
30
  context["request.platform"] = :ussd
24
- context["request.network"] = nil
31
+ context["request.body"] = (params.respond_to?(:to_unsafe_h) ? params.to_unsafe_h : params.to_h).transform_keys(&:to_s)
25
32
  # context["request.type"] = params["MSGTYPE"] ? :initial : :response
26
- context.input = params["USERDATA"].presence
33
+ context.input = params["USERDATA"].presence || ""
27
34
 
28
35
  # Instrument message received when user provides input using new scalable approach
29
36
  if context.input.present?
@@ -49,7 +56,7 @@ module FlowChat
49
56
  timestamp: context["request.timestamp"]
50
57
  })
51
58
 
52
- context.controller.render json: {
59
+ @controller.render json: {
53
60
  USERID: params["USERID"],
54
61
  MSISDN: params["MSISDN"],
55
62
  MSG: render_prompt(prompt, choices, media),
@@ -57,6 +64,19 @@ module FlowChat
57
64
  }
58
65
  end
59
66
 
67
+ def self.configure_middleware_stack(builder, custom_middleware)
68
+ FlowChat.logger.debug { "FlowChat::Ussd::Gateway::Nalo: Configuring middleware stack" }
69
+
70
+ builder.use FlowChat::Ussd::Middleware::Pagination
71
+ FlowChat.logger.debug { "FlowChat::Ussd::Gateway::Nalo: Added Ussd::Middleware::Pagination" }
72
+
73
+ builder.use custom_middleware
74
+ FlowChat.logger.debug { "FlowChat::Ussd::Gateway::Nalo: Added custom middleware" }
75
+
76
+ builder.use FlowChat::Ussd::Middleware::ChoiceMapper
77
+ FlowChat.logger.debug { "FlowChat::Ussd::Gateway::Nalo: Added Ussd::Middleware::ChoiceMapper" }
78
+ end
79
+
60
80
  private
61
81
 
62
82
  def render_prompt(prompt, choices, media)
@@ -21,7 +21,6 @@ module FlowChat
21
21
  if intercept?
22
22
  FlowChat.logger.info { "Ussd::Pagination: Intercepting request for pagination handling - session #{session_id}" }
23
23
  type, prompt = handle_intercepted_request
24
- [type, prompt, []]
25
24
  else
26
25
  # Clear pagination state for new flows
27
26
  if pagination_state.present?
@@ -41,8 +40,8 @@ module FlowChat
41
40
  end
42
41
  end
43
42
 
44
- [type, prompt, []]
45
43
  end
44
+ [type, prompt, []]
46
45
  end
47
46
 
48
47
  private
@@ -61,15 +60,17 @@ module FlowChat
61
60
 
62
61
  def handle_intercepted_request
63
62
  FlowChat.logger.info { "Ussd::Pagination: Handling paginated request" }
64
- start, finish, has_more = calculate_offsets
63
+ # Cache current_page to avoid multiple calculations
64
+ page = current_page
65
+ start, finish, has_more = calculate_offsets_for_page(page)
65
66
  type = (pagination_state["type"].to_sym == :terminal && !has_more) ? :terminal : :prompt
66
67
  prompt = pagination_state["prompt"][start..finish] + build_pagination_options(type, has_more)
67
- set_pagination_state(current_page, start, finish)
68
+ set_pagination_state(page, start, finish)
68
69
 
69
70
  # Instrument pagination navigation
70
71
  instrument(Events::PAGINATION_TRIGGERED, {
71
72
  session_id: @context["session.id"],
72
- current_page: current_page,
73
+ current_page: page,
73
74
  total_pages: calculate_total_pages,
74
75
  content_length: pagination_state["prompt"].length,
75
76
  page_limit: FlowChat::Config.ussd.pagination_page_size,
@@ -113,7 +114,10 @@ module FlowChat
113
114
 
114
115
  def calculate_offsets
115
116
  page = current_page
117
+ calculate_offsets_for_page(page)
118
+ end
116
119
 
120
+ def calculate_offsets_for_page(page)
117
121
  FlowChat.logger.debug { "Ussd::Pagination: Calculating offsets for page #{page}" }
118
122
 
119
123
  offset = pagination_state["offsets"][page.to_s]
@@ -27,7 +27,7 @@ module FlowChat
27
27
  def build_media
28
28
  return unless media.present?
29
29
 
30
- media_url = media[:url] || media[:path]
30
+ media_url = media[:url]
31
31
  media_type = media[:type] || :image
32
32
 
33
33
  # For USSD, we append the media URL to the message
@@ -1,3 +1,3 @@
1
1
  module FlowChat
2
- VERSION = "0.8.2"
2
+ VERSION = "0.9.0"
3
3
  end