flow_chat 0.8.1 → 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 +154 -0
  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/simulator_controller.rb +21 -1
  31. data/examples/ussd_controller.rb +10 -10
  32. data/examples/whatsapp_controller.rb +2 -2
  33. data/flow_chat.gemspec +4 -0
  34. data/lib/flow_chat/{base_app.rb → app.rb} +28 -9
  35. data/lib/flow_chat/async_job.rb +176 -0
  36. data/lib/flow_chat/config.rb +23 -28
  37. data/lib/flow_chat/{base_executor.rb → executor.rb} +7 -12
  38. data/lib/flow_chat/factory.rb +94 -0
  39. data/lib/flow_chat/gateway_async_support.rb +106 -0
  40. data/lib/flow_chat/generic_async_job.rb +30 -0
  41. data/lib/flow_chat/http/gateway/simple.rb +125 -0
  42. data/lib/flow_chat/http/renderer.rb +41 -0
  43. data/lib/flow_chat/instrumentation/setup.rb +1 -1
  44. data/lib/flow_chat/instrumentation.rb +25 -0
  45. data/lib/flow_chat/intercom/client.rb +155 -0
  46. data/lib/flow_chat/intercom/configuration.rb +149 -0
  47. data/lib/flow_chat/intercom/gateway/intercom_api.rb +396 -0
  48. data/lib/flow_chat/intercom/renderer.rb +71 -0
  49. data/lib/flow_chat/phone_number_util.rb +49 -0
  50. data/lib/flow_chat/processor.rb +188 -0
  51. data/lib/flow_chat/renderers/markdown_support.rb +58 -0
  52. data/lib/flow_chat/session/cache_session_store.rb +1 -17
  53. data/lib/flow_chat/session/middleware.rb +43 -26
  54. data/lib/flow_chat/simulator/controller.rb +17 -5
  55. data/lib/flow_chat/simulator/views/simulator.html.erb +220 -8
  56. data/lib/flow_chat/telegram/client.rb +240 -0
  57. data/lib/flow_chat/telegram/configuration.rb +118 -0
  58. data/lib/flow_chat/telegram/gateway/bot_api.rb +300 -0
  59. data/lib/flow_chat/telegram/middleware/choice_mapper.rb +35 -0
  60. data/lib/flow_chat/telegram/renderer.rb +125 -0
  61. data/lib/flow_chat/telegram.rb +7 -0
  62. data/lib/flow_chat/ussd/gateway/nalo.rb +27 -11
  63. data/lib/flow_chat/ussd/middleware/pagination.rb +9 -5
  64. data/lib/flow_chat/ussd/renderer.rb +1 -1
  65. data/lib/flow_chat/version.rb +1 -1
  66. data/lib/flow_chat/whatsapp/client.rb +144 -13
  67. data/lib/flow_chat/whatsapp/configuration.rb +1 -1
  68. data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +144 -106
  69. data/lib/flow_chat/whatsapp/id_generator.rb +124 -0
  70. data/lib/flow_chat/whatsapp/middleware/choice_mapper.rb +147 -0
  71. data/lib/flow_chat/whatsapp/renderer.rb +145 -11
  72. data/lib/flow_chat.rb +11 -1
  73. data/lib/tasks/release.rake +165 -0
  74. metadata +88 -21
  75. data/docs/flows.md +0 -320
  76. data/docs/images/simulator.png +0 -0
  77. data/docs/media.md +0 -153
  78. data/docs/sessions.md +0 -433
  79. data/docs/ussd-setup.md +0 -322
  80. data/docs/whatsapp-setup.md +0 -162
  81. data/examples/whatsapp_message_job.rb +0 -113
  82. data/lib/flow_chat/base_processor.rb +0 -145
  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 -98
  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
@@ -1,6 +1,5 @@
1
1
  require "net/http"
2
2
  require "json"
3
- require "phonelib"
4
3
  require "openssl"
5
4
 
6
5
  module FlowChat
@@ -11,6 +10,7 @@ module FlowChat
11
10
  module Gateway
12
11
  class CloudApi
13
12
  include FlowChat::Instrumentation
13
+ include FlowChat::GatewayAsyncSupport
14
14
 
15
15
  attr_reader :context
16
16
 
@@ -25,48 +25,59 @@ module FlowChat
25
25
 
26
26
  def call(context)
27
27
  @context = context
28
- controller = context.controller
29
- request = controller.request
28
+ @controller = context.controller
29
+ request = @controller.request
30
30
 
31
31
  FlowChat.logger.debug { "CloudApi: Processing #{request.request_method} request to #{request.path}" }
32
32
 
33
- # Handle webhook verification
34
- if request.get? && request.params["hub.mode"] == "subscribe"
35
- FlowChat.logger.info { "CloudApi: Handling webhook verification request" }
36
- return handle_verification(context)
33
+ # Skip webhook-specific handling in background mode
34
+ unless in_background?
35
+ # Handle webhook verification
36
+ if request.get? && request.params["hub.mode"] == "subscribe"
37
+ FlowChat.logger.info { "CloudApi: Handling webhook verification request" }
38
+ return handle_verification(context)
39
+ end
37
40
  end
38
41
 
39
42
  # Handle webhook messages
40
43
  if request.post?
41
- FlowChat.logger.info { "CloudApi: Handling webhook message" }
44
+ FlowChat.logger.info { "CloudApi: Handling webhook message (background: #{in_background?})" }
42
45
  return handle_webhook(context)
43
46
  end
44
47
 
45
48
  FlowChat.logger.warn { "CloudApi: Invalid request method or parameters - returning bad request" }
46
- controller.head :bad_request
49
+ @controller.head :bad_request
47
50
  end
48
51
 
49
52
  # Expose client for out-of-band messaging
50
53
  attr_reader :client
51
54
 
55
+ # Configure WhatsApp-specific middleware stack
56
+ def self.configure_middleware_stack(builder, custom_middleware)
57
+ FlowChat.logger.debug { "CloudApi: Configuring WhatsApp middleware stack" }
58
+
59
+ builder.use custom_middleware
60
+ FlowChat.logger.debug { "CloudApi: Added custom middleware" }
61
+
62
+ builder.use FlowChat::Whatsapp::Middleware::ChoiceMapper
63
+ FlowChat.logger.debug { "CloudApi: Added Whatsapp::Middleware::ChoiceMapper" }
64
+ end
65
+
52
66
  private
53
67
 
54
68
  def determine_message_handler(context)
55
- # Check if simulator mode was already detected and set in context
69
+ # Use simulator mode if enabled, otherwise always use inline
56
70
  if context["simulator_mode"]
57
71
  FlowChat.logger.debug { "CloudApi: Using simulator message handler" }
58
- return :simulator
72
+ :simulator
73
+ else
74
+ FlowChat.logger.debug { "CloudApi: Using inline message handler" }
75
+ :inline
59
76
  end
60
-
61
- # Use global WhatsApp configuration
62
- mode = FlowChat::Config.whatsapp.message_handling_mode
63
- FlowChat.logger.debug { "CloudApi: Using #{mode} message handling mode" }
64
- mode
65
77
  end
66
78
 
67
79
  def handle_verification(context)
68
- controller = context.controller
69
- params = controller.request.params
80
+ params = @controller.request.params
70
81
 
71
82
  verify_token = @config.verify_token
72
83
  provided_token = params["hub.verify_token"]
@@ -81,7 +92,7 @@ module FlowChat
81
92
  platform: :whatsapp
82
93
  })
83
94
 
84
- controller.render plain: challenge
95
+ @controller.render plain: challenge
85
96
  else
86
97
  # Use instrumentation for webhook verification failure
87
98
  instrument(Events::WEBHOOK_FAILED, {
@@ -89,20 +100,18 @@ module FlowChat
89
100
  platform: :whatsapp
90
101
  })
91
102
 
92
- controller.head :forbidden
103
+ @controller.head :forbidden
93
104
  end
94
105
  end
95
106
 
96
107
  def handle_webhook(context)
97
- controller = context.controller
98
-
99
108
  # Parse body
100
109
  begin
101
- parse_request_body(controller.request)
110
+ parse_request_body(@controller.request)
102
111
  FlowChat.logger.debug { "CloudApi: Successfully parsed webhook request body" }
103
112
  rescue JSON::ParserError => e
104
113
  FlowChat.logger.error { "CloudApi: Failed to parse webhook body: #{e.message}" }
105
- return controller.head :bad_request
114
+ return @controller.head :bad_request
106
115
  end
107
116
 
108
117
  # Check for simulator mode parameter in request (before validation)
@@ -113,10 +122,11 @@ module FlowChat
113
122
  context["simulator_mode"] = true
114
123
  end
115
124
 
116
- # Validate webhook signature for security (skip for simulator mode)
117
- unless is_simulator_mode || valid_webhook_signature?(controller.request)
118
- FlowChat.logger.warn { "CloudApi: Invalid webhook signature received - rejecting request" }
119
- return controller.head :unauthorized
125
+ # Validate webhook signature for security (skip for simulator mode and background)
126
+ # Return 200 OK even for invalid signatures to prevent WhatsApp from retrying
127
+ unless in_background? || is_simulator_mode || valid_webhook_signature?(@controller.request)
128
+ FlowChat.logger.warn { "CloudApi: Invalid webhook signature - dropping request" }
129
+ return @controller.head :ok
120
130
  end
121
131
 
122
132
  FlowChat.logger.debug { "CloudApi: Webhook signature validation passed" }
@@ -125,19 +135,19 @@ module FlowChat
125
135
  entry = @body.dig("entry", 0)
126
136
  unless entry
127
137
  FlowChat.logger.debug { "CloudApi: No entry found in webhook body - returning OK" }
128
- return controller.head :ok
138
+ return @controller.head :ok
129
139
  end
130
140
 
131
141
  changes = entry.dig("changes", 0)
132
142
  unless changes
133
143
  FlowChat.logger.debug { "CloudApi: No changes found in webhook entry - returning OK" }
134
- return controller.head :ok
144
+ return @controller.head :ok
135
145
  end
136
146
 
137
147
  value = changes["value"]
138
148
  unless value
139
149
  FlowChat.logger.debug { "CloudApi: No value found in webhook changes - returning OK" }
140
- return controller.head :ok
150
+ return @controller.head :ok
141
151
  end
142
152
 
143
153
  # Handle incoming messages
@@ -145,44 +155,65 @@ module FlowChat
145
155
  message = value["messages"].first
146
156
  contact = value["contacts"]&.first
147
157
 
148
- phone_number = message["from"]
158
+ phone_number = FlowChat::PhoneNumberUtil.to_e164(message["from"])
149
159
  message_id = message["id"]
150
160
  contact_name = contact&.dig("profile", "name")
161
+ business_phone_number = value.dig("metadata", "display_phone_number")
162
+ business_phone_number_id = value.dig("metadata", "phone_number_id")
151
163
 
152
- # Use instrumentation for message received
153
- instrument(Events::MESSAGE_RECEIVED, {
154
- from: phone_number,
155
- message: context.input,
156
- message_type: message["type"],
157
- message_id: message_id,
158
- platform: :whatsapp
159
- })
164
+ # Validate that webhook is for our configured phone number
165
+ if business_phone_number_id != @config.phone_number_id
166
+ FlowChat.logger.warn { "CloudApi: Webhook received for phone_number_id '#{business_phone_number_id}' but configured for '#{@config.phone_number_id}' - rejecting" }
167
+ return @controller.head :forbidden
168
+ end
160
169
 
161
170
  context["request.id"] = phone_number
171
+ context["request.user_id"] = phone_number
172
+ context["request.user_name"] = contact_name if contact_name
173
+ context["request.msisdn"] = phone_number
162
174
  context["request.gateway"] = :whatsapp_cloud_api
163
175
  context["request.platform"] = :whatsapp
164
176
  context["request.message_id"] = message_id
165
- context["request.msisdn"] = Phonelib.parse(phone_number).e164
166
- context["request.contact_name"] = contact_name
167
- context["request.timestamp"] = message["timestamp"]
177
+ context["request.timestamp"] = Time.current.iso8601
178
+ context["request.body"] = @body
179
+
180
+ context["whatsapp.business.phone_number"] = FlowChat::PhoneNumberUtil.to_e164(business_phone_number)
181
+ context["whatsapp.business.phone_number_id"] = business_phone_number_id
182
+ context["whatsapp.client"] = @client
168
183
 
169
184
  # Extract message content based on type
170
- extract_message_content(message, context)
185
+ extract_message_content!(message, context)
186
+
187
+ if context.input.present?
188
+ # Use instrumentation for message received
189
+ instrument(Events::MESSAGE_RECEIVED, {
190
+ from: phone_number,
191
+ message: context.input,
192
+ message_type: message["type"],
193
+ message_id: message_id
194
+ })
195
+ end
171
196
 
172
197
  FlowChat.logger.debug { "CloudApi: Message content extracted - Type: #{message["type"]}, Input: '#{context.input}'" }
173
198
 
174
- # Determine message handling mode
175
- handler_mode = determine_message_handler(context)
176
-
177
- # Process the message based on handling mode
178
- case handler_mode
179
- when :inline
180
- handle_message_inline(context, controller)
181
- when :background
182
- handle_message_background(context, controller)
183
- when :simulator
184
- # Return early from simulator mode to preserve the JSON response
185
- return handle_message_simulator(context, controller)
199
+ # Determine routing: async enqueue, background execute, or inline
200
+ if should_enqueue_async?
201
+ # Webhook with async enabled → enqueue job and return immediately
202
+ enqueue_async_job
203
+ return @controller.head :ok
204
+ else
205
+ # Background OR inline → process message
206
+ # Determine message handling mode (simulator vs inline)
207
+ handler_mode = determine_message_handler(context)
208
+
209
+ # Process the message based on handling mode
210
+ case handler_mode
211
+ when :inline
212
+ handle_message_inline(context, @controller)
213
+ when :simulator
214
+ # Return early from simulator mode to preserve the JSON response
215
+ return handle_message_simulator(context, @controller)
216
+ end
186
217
  end
187
218
  end
188
219
 
@@ -193,7 +224,7 @@ module FlowChat
193
224
  FlowChat.logger.debug { "CloudApi: Status updates: #{statuses.inspect}" }
194
225
  end
195
226
 
196
- controller.head :ok
227
+ @controller.head :ok
197
228
  end
198
229
 
199
230
  # Validate webhook signature to ensure request comes from WhatsApp
@@ -260,14 +291,14 @@ module FlowChat
260
291
  res == 0
261
292
  end
262
293
 
263
- def extract_message_content(message, context)
294
+ def extract_message_content!(message, context)
264
295
  message_type = message["type"]
265
296
  FlowChat.logger.debug { "CloudApi: Extracting content from #{message_type} message" }
266
297
 
267
298
  case message_type
268
299
  when "text"
269
300
  content = message.dig("text", "body")
270
- context.input = content
301
+ context.input = content.presence || ""
271
302
  FlowChat.logger.debug { "CloudApi: Text message content: '#{content}'" }
272
303
  when "interactive"
273
304
  # Handle button/list replies
@@ -288,57 +319,57 @@ module FlowChat
288
319
  address: message.dig("location", "address")
289
320
  }
290
321
  context["request.location"] = location
291
- context.input = "$location$"
322
+ context.input = FlowChat::Input::LOCATION
292
323
  FlowChat.logger.debug { "CloudApi: Location received - Lat: #{location[:latitude]}, Lng: #{location[:longitude]}" }
293
- when "image", "document", "audio", "video"
324
+ when "image", "document", "audio", "video", "sticker"
325
+ media_data = message[message["type"]]
294
326
  context["request.media"] = {
295
- type: message["type"],
296
- id: message.dig(message["type"], "id"),
297
- mime_type: message.dig(message["type"], "mime_type"),
298
- caption: message.dig(message["type"], "caption")
327
+ type: message["type"].to_sym,
328
+ id: media_data["id"],
329
+ mime_type: media_data["mime_type"],
330
+ caption: media_data["caption"],
331
+ filename: media_data["filename"],
332
+ sha256: media_data["sha256"],
333
+ animated: media_data["animated"]
299
334
  }
300
- context.input = "$media$"
335
+ context.input = FlowChat::Input::MEDIA
336
+ FlowChat.logger.debug { "CloudApi: Media received - Type: #{message["type"]}, ID: #{media_data["id"]}" }
337
+ when "contacts"
338
+ # WhatsApp sends contacts as an array, take the first one
339
+ contact_data = message.dig("contacts", 0)
340
+ if contact_data
341
+ phones = contact_data.dig("phones") || []
342
+ context["request.contact"] = {
343
+ name: contact_data.dig("name", "formatted_name"),
344
+ first_name: contact_data.dig("name", "first_name"),
345
+ last_name: contact_data.dig("name", "last_name"),
346
+ phones: phones.map { |p| p["phone"] },
347
+ phone_number: phones.first&.dig("phone")
348
+ }
349
+ context.input = FlowChat::Input::CONTACT
350
+ FlowChat.logger.debug { "CloudApi: Contact received - Name: #{context["request.contact"][:name]}" }
351
+ end
301
352
  end
302
353
  end
303
354
 
304
355
  def handle_message_inline(context, controller)
305
356
  response = @app.call(context)
306
357
  if response
307
- _type, prompt, choices, media = response
308
- rendered_message = render_response(prompt, choices, media)
309
- result = @client.send_message(context["request.msisdn"], rendered_message)
358
+ type, prompt, choices, media = response
359
+ result = @client.send_message(context["request.msisdn"], prompt, choices: choices, media: media)
310
360
  context["whatsapp.message_result"] = result
311
- end
312
- end
313
361
 
314
- def handle_message_background(context, controller)
315
- # Process the flow synchronously (maintaining controller context)
316
- response = @app.call(context)
317
-
318
- if response
319
- _type, prompt, choices, media = response
320
- rendered_message = render_response(prompt, choices, media)
321
-
322
- # Queue only the response delivery asynchronously
323
- send_data = {
324
- msisdn: context["request.msisdn"],
325
- response: rendered_message,
326
- config_name: @config.name
327
- }
328
-
329
- # Get job class from configuration
330
- job_class_name = FlowChat::Config.whatsapp.background_job_class
331
-
332
- # Enqueue background job for sending only
333
- begin
334
- job_class = job_class_name.constantize
335
- job_class.perform_later(send_data)
336
- rescue NameError
337
- # Fallback to inline sending if no job system
338
- Rails.logger.warn "Background mode requested but no #{job_class_name} found. Falling back to inline sending."
339
- result = @client.send_message(context["request.msisdn"], rendered_message)
340
- context["whatsapp.message_result"] = result
341
- end
362
+ # Instrument message sent
363
+ instrument(Events::MESSAGE_SENT, {
364
+ to: context["request.msisdn"],
365
+ session_id: context["request.id"],
366
+ message: prompt,
367
+ message_type: (type == :prompt) ? "prompt" : "terminal",
368
+ gateway: :whatsapp_cloud_api,
369
+ platform: :whatsapp,
370
+ content_length: prompt.to_s.length,
371
+ timestamp: context["request.timestamp"]
372
+ })
342
373
  end
343
374
  end
344
375
 
@@ -346,12 +377,12 @@ module FlowChat
346
377
  response = @app.call(context)
347
378
 
348
379
  if response
349
- _type, prompt, choices, media = response
350
- rendered_message = render_response(prompt, choices, media)
380
+ _, prompt, choices, media = response
381
+ response_data = render_response(prompt, choices, media)
351
382
 
352
383
  # For simulator mode, return the response data in the HTTP response
353
384
  # instead of actually sending via WhatsApp API
354
- message_payload = @client.build_message_payload(rendered_message, context["request.msisdn"])
385
+ message_payload = @client.build_message_payload(response_data, context["request.msisdn"])
355
386
 
356
387
  simulator_response = {
357
388
  mode: "simulator",
@@ -359,7 +390,7 @@ module FlowChat
359
390
  would_send: message_payload,
360
391
  message_info: {
361
392
  to: context["request.msisdn"],
362
- contact_name: context["request.contact_name"],
393
+ contact_name: context["request.user_name"],
363
394
  timestamp: Time.now.iso8601
364
395
  }
365
396
  }
@@ -382,8 +413,7 @@ module FlowChat
382
413
  return false unless simulator_secret && !simulator_secret.empty?
383
414
 
384
415
  # Check for simulator cookie
385
- request = context.controller.request
386
- simulator_cookie = request.cookies["flowchat_simulator"]
416
+ simulator_cookie = @controller.request.cookies["flowchat_simulator"]
387
417
  return false unless simulator_cookie
388
418
 
389
419
  # Verify the cookie is a valid HMAC signature
@@ -410,7 +440,15 @@ module FlowChat
410
440
  end
411
441
 
412
442
  def parse_request_body(request)
413
- @body ||= JSON.parse(request.body.read)
443
+ return @body if @body
444
+
445
+ if request.body.nil?
446
+ FlowChat.logger.debug { "CloudApi: Request body is nil, returning empty hash" }
447
+ @body = {}
448
+ else
449
+ request.body.rewind if request.body.respond_to?(:rewind)
450
+ @body = JSON.parse(request.body.read)
451
+ end
414
452
  end
415
453
 
416
454
  def render_response(prompt, choices, media)
@@ -0,0 +1,124 @@
1
+ require "digest"
2
+
3
+ module FlowChat
4
+ module Whatsapp
5
+ # Generates WhatsApp-safe IDs from choice labels
6
+ #
7
+ # WhatsApp Cloud API requires button and list item IDs to be:
8
+ # - Maximum 256 characters
9
+ # - Unique within the message
10
+ # - Non-empty strings
11
+ #
12
+ # This generator:
13
+ # - Sanitizes labels to alphanumeric + underscore/hyphen
14
+ # - Truncates to fit within 256-char limit
15
+ # - Appends hash suffix for duplicate labels (similar to Rails index naming)
16
+ # - Ensures readability while maintaining uniqueness
17
+ #
18
+ # @example Basic usage
19
+ # generator = IdGenerator.new
20
+ # id = generator.generate_id("Create Account") # => "create_account"
21
+ #
22
+ # @example Handling duplicates
23
+ # generator = IdGenerator.new
24
+ # id1 = generator.generate_id("Accept") # => "accept"
25
+ # id2 = generator.generate_id("Accept") # => "accept_a1b2c3"
26
+ #
27
+ # @example Special characters
28
+ # generator = IdGenerator.new
29
+ # id = generator.generate_id("Yes! 👍 (recommended)") # => "yes_recommended"
30
+ #
31
+ class IdGenerator
32
+ MAX_ID_LENGTH = 256
33
+ HASH_SUFFIX_LENGTH = 3
34
+
35
+ def initialize
36
+ @generated_ids = []
37
+ end
38
+
39
+ # Generate a WhatsApp-safe ID from a label
40
+ #
41
+ # @param label [String] The choice label to convert
42
+ # @return [String] A sanitized, unique ID
43
+ def generate_id(label)
44
+ # Normalize the label
45
+ normalized = normalize_label(label)
46
+
47
+ # If normalized label is empty, use a fallback
48
+ normalized = "choice" if normalized.empty?
49
+
50
+ # Truncate to limit first
51
+ truncated = truncate_to_limit(normalized)
52
+
53
+ # Check if we need a hash suffix for uniqueness
54
+ final_id = if @generated_ids.include?(truncated)
55
+ add_hash_suffix(truncated, label)
56
+ else
57
+ truncated
58
+ end
59
+
60
+ # Track this ID
61
+ @generated_ids << final_id
62
+
63
+ final_id
64
+ end
65
+
66
+ # Reset the generator state (useful for testing or starting a new message)
67
+ def reset
68
+ @generated_ids = []
69
+ end
70
+
71
+ # Get all generated IDs (useful for debugging)
72
+ def generated_ids
73
+ @generated_ids.dup
74
+ end
75
+
76
+ private
77
+
78
+ # Normalize a label into a WhatsApp-safe identifier
79
+ # Keeps readability by preserving spaces and basic punctuation
80
+ #
81
+ # @param label [String] The original label
82
+ # @return [String] Normalized identifier
83
+ def normalize_label(label)
84
+ label
85
+ .to_s
86
+ .gsub(/[^\w\s\-']/, "") # remove special chars (keep word chars, spaces, hyphens, apostrophes)
87
+ .gsub(/\s+/, " ") # collapse multiple spaces to single space (after removing chars)
88
+ .strip # trim leading/trailing whitespace
89
+ end
90
+
91
+ # Add a hash suffix to make the ID unique
92
+ #
93
+ # The hash is generated from the original label to ensure
94
+ # the same label always produces the same hash.
95
+ #
96
+ # @param base_id [String] The base identifier
97
+ # @param original_label [String] The original label for hash generation
98
+ # @return [String] ID with hash suffix
99
+ def add_hash_suffix(base_id, original_label)
100
+ # Generate a short hash from the original label + timestamp for uniqueness
101
+ # We use the current generated_ids count to ensure different duplicates get different hashes
102
+ hash_input = "#{original_label}_#{@generated_ids.count { |id| id.start_with?(base_id) }}"
103
+ hash = Digest::SHA256.hexdigest(hash_input)[0...HASH_SUFFIX_LENGTH]
104
+
105
+ # Calculate max base length to fit: base + "_" + hash
106
+ max_base_length = MAX_ID_LENGTH - HASH_SUFFIX_LENGTH - 1
107
+
108
+ # Truncate base if needed
109
+ truncated_base = base_id[0...max_base_length]
110
+
111
+ "#{truncated_base} #{hash}"
112
+ end
113
+
114
+ # Truncate ID to maximum allowed length
115
+ #
116
+ # @param id [String] The ID to truncate
117
+ # @return [String] Truncated ID
118
+ def truncate_to_limit(id)
119
+ return id if id.length <= MAX_ID_LENGTH
120
+ id[0...MAX_ID_LENGTH]
121
+ end
122
+ end
123
+ end
124
+ end