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
@@ -0,0 +1,420 @@
1
+ require "json"
2
+ require "openssl"
3
+
4
+ module FlowChat
5
+ module Intercom
6
+ module Gateway
7
+ class IntercomApi
8
+ include FlowChat::Instrumentation
9
+ include FlowChat::GatewayAsyncSupport
10
+
11
+ attr_reader :client
12
+
13
+ # Default webhook topics to process
14
+ DEFAULT_WEBHOOK_TOPICS = ["conversation.user.created", "conversation.user.replied"].freeze
15
+
16
+ # Configure Intercom specific middleware stack
17
+ def self.configure_middleware_stack(builder, custom_middleware)
18
+ FlowChat.logger.debug { "IntercomApi: Configuring Intercom middleware stack" }
19
+
20
+ builder.use custom_middleware
21
+ FlowChat.logger.debug { "IntercomApi: Added custom middleware" }
22
+
23
+ builder.use FlowChat::Intercom::Middleware::ChoiceMapper
24
+ FlowChat.logger.debug { "IntercomApi: Added Intercom::Middleware::ChoiceMapper" }
25
+ end
26
+
27
+ def initialize(app, config = nil, additional_webhook_topics = nil)
28
+ @app = app
29
+ @config = config || FlowChat::Intercom::Configuration.from_credentials
30
+ @client = FlowChat::Intercom::Client.new(@config)
31
+ # Always include default topics, plus any additional ones
32
+ @allowed_webhook_topics = DEFAULT_WEBHOOK_TOPICS + Array(additional_webhook_topics)
33
+
34
+ FlowChat.logger.info { "IntercomApi: Initialized Intercom API gateway" }
35
+ FlowChat.logger.debug { "IntercomApi: Gateway configuration - API base URL: #{@config.api_base_url}" }
36
+ FlowChat.logger.debug { "IntercomApi: Allowed webhook topics: #{@allowed_webhook_topics.inspect}" }
37
+ end
38
+
39
+ def call(context)
40
+ @context = context
41
+ @controller = context.controller
42
+ request = @controller.request
43
+
44
+ FlowChat.logger.debug { "IntercomApi: Processing #{request.request_method} request to #{request.path}" }
45
+
46
+ # Skip webhook-specific handling in background mode
47
+ unless in_background?
48
+ # Handle webhook URL validation (HEAD request)
49
+ if request.head?
50
+ FlowChat.logger.info { "IntercomApi: Handling webhook URL validation request" }
51
+ return @controller.head :ok
52
+ end
53
+ end
54
+
55
+ # Handle webhook notifications (POST request)
56
+ if request.post?
57
+ FlowChat.logger.info { "IntercomApi: Handling webhook notification (background: #{in_background?})" }
58
+ return handle_webhook(context)
59
+ end
60
+
61
+ FlowChat.logger.warn { "IntercomApi: Invalid request method or parameters - returning bad request" }
62
+ @controller.head :bad_request
63
+ end
64
+
65
+ private
66
+
67
+ def handle_webhook(context)
68
+ # Parse body
69
+ begin
70
+ parse_request_body(@controller.request)
71
+ @client.app_id = @body["app_id"]
72
+ FlowChat.logger.debug { "IntercomApi: Successfully parsed webhook request body" }
73
+ rescue JSON::ParserError => e
74
+ FlowChat.logger.error { "IntercomApi: Failed to parse webhook body: #{e.message}" }
75
+ return @controller.head :bad_request
76
+ end
77
+
78
+ # Check for simulator mode parameter in request (before validation)
79
+ # But only enable if valid simulator token is provided
80
+ is_simulator_mode = simulate?(context)
81
+ if is_simulator_mode
82
+ FlowChat.logger.info { "IntercomApi: Simulator mode enabled for this request" }
83
+ context["simulator_mode"] = true
84
+ end
85
+
86
+ # Validate webhook signature for security (skip for simulator mode and background)
87
+ # Return 200 OK even for invalid signatures to prevent Intercom from retrying
88
+ unless in_background? || is_simulator_mode || valid_webhook_signature?(@controller.request)
89
+ FlowChat.logger.warn { "IntercomApi: Invalid webhook signature - dropping request" }
90
+ return @controller.head :ok
91
+ end
92
+
93
+ FlowChat.logger.debug { "IntercomApi: Webhook signature validation passed" }
94
+
95
+ # Extract event data from Intercom webhook
96
+ event_type = @body["topic"]
97
+ unless event_type
98
+ FlowChat.logger.debug { "IntercomApi: No topic found in webhook body - returning OK" }
99
+ return @controller.head :ok
100
+ end
101
+
102
+ # Only process conversation events we care about
103
+ unless @allowed_webhook_topics.include?(event_type)
104
+ FlowChat.logger.debug { "IntercomApi: Ignoring event type '#{event_type}' (not in allowed topics) - returning OK" }
105
+ return @controller.head :ok
106
+ end
107
+
108
+ # Extract conversation data
109
+ data_item = @body.dig("data", "item")
110
+ unless data_item
111
+ FlowChat.logger.debug { "IntercomApi: No data.item found in webhook body - returning OK" }
112
+ return @controller.head :ok
113
+ end
114
+
115
+ # Process conversation event
116
+ if data_item["type"] == "conversation"
117
+ conversation = data_item
118
+ conversation_id = conversation["id"]
119
+
120
+ # Get the user ID from contacts (always the actual user/contact)
121
+ # contacts.contacts[0] contains the actual user, not the admin
122
+ contact = conversation.dig("contacts", "contacts", 0)
123
+ unless contact
124
+ FlowChat.logger.error { "IntercomApi: No contact found in conversation" }
125
+ return @controller.head :ok
126
+ end
127
+
128
+ # Get user ID, name, email, and phone from contact (most reliable)
129
+ user_id = contact["id"]
130
+ user_name = contact["name"]
131
+ user_email = contact["email"]
132
+ user_phone = contact["phone"]
133
+
134
+ context["request.id"] = conversation_id
135
+ context["request.user_id"] = user_id
136
+ context["request.user_name"] = user_name if user_name
137
+ context["request.email"] = user_email if user_email
138
+ context["request.msisdn"] = user_phone if user_phone
139
+ context["request.gateway"] = :intercom_api
140
+ context["request.platform"] = :intercom
141
+ context["request.timestamp"] = Time.now.iso8601
142
+ context["request.body"] = @body
143
+
144
+ context["intercom.client"] = @client
145
+ context["intercom.topic"] = event_type
146
+
147
+ # Try to extract latest message for user events
148
+ latest_message = extract_latest_user_message(conversation, event_type)
149
+
150
+ if latest_message
151
+ context["request.message_id"] = latest_message[:id]
152
+ if latest_message[:media]
153
+ media = latest_message[:media]
154
+ body = latest_message[:body]
155
+ context["request.media"] = media
156
+ context.input = body.present? ? @client.parse_message(body) : ""
157
+ FlowChat.logger.debug { "IntercomApi: Media received - #{media.size} attachment(s)" }
158
+ else
159
+ # Convert HTML to markdown for message body
160
+ raw_body = latest_message[:body] || ""
161
+ context.input = @client.parse_message(raw_body)
162
+ FlowChat.logger.debug { "IntercomApi: Message content extracted - Event: #{event_type}, Input: '#{context.input}'" }
163
+ end
164
+ elsif @allowed_webhook_topics.include?(event_type)
165
+ # No message but event is explicitly allowed - process without message
166
+ context.input = nil
167
+ FlowChat.logger.debug { "IntercomApi: Processing #{event_type} event without user message" }
168
+ else
169
+ # No message and event not in allowed topics - skip
170
+ # (This case shouldn't happen as we already filtered above, but safety check)
171
+ FlowChat.logger.error { "IntercomApi: No message found for unexpected event type '#{event_type}'" }
172
+ return @controller.head :ok
173
+ end
174
+
175
+ # Instrument message received (with or without message content)
176
+ instrument(Events::MESSAGE_RECEIVED, {
177
+ from: user_id,
178
+ conversation_id: conversation_id,
179
+ message: context.input,
180
+ event_type: event_type
181
+ })
182
+
183
+ # Determine routing: async enqueue, background execute, or inline
184
+ if should_enqueue_async?
185
+ # Webhook with async enabled → enqueue job and return immediately
186
+ enqueue_async_job
187
+ return @controller.head :ok
188
+ else
189
+ # Background OR inline → process message
190
+ # Determine message handling mode (simulator vs inline)
191
+ handler_mode = determine_message_handler(context)
192
+
193
+ # Process the message based on handling mode
194
+ case handler_mode
195
+ when :inline
196
+ handle_message_inline(context, @controller)
197
+ when :simulator
198
+ # Return early from simulator mode to preserve the JSON response
199
+ return handle_message_simulator(context, @controller)
200
+ end
201
+ end
202
+ end
203
+
204
+ @controller.head :ok
205
+ end
206
+
207
+ def determine_message_handler(context)
208
+ # Use simulator mode if enabled, otherwise always use inline
209
+ if context["simulator_mode"]
210
+ FlowChat.logger.debug { "IntercomApi: Using simulator message handler" }
211
+ :simulator
212
+ else
213
+ FlowChat.logger.debug { "IntercomApi: Using inline message handler" }
214
+ :inline
215
+ end
216
+ end
217
+
218
+ # Validate webhook signature to ensure request comes from Intercom
219
+ def valid_webhook_signature?(request)
220
+ # Check if signature validation is explicitly disabled
221
+ if @config.skip_signature_validation
222
+ FlowChat.logger.debug { "IntercomApi: Webhook signature validation is disabled" }
223
+ return true
224
+ end
225
+
226
+ # Require client_secret for signature validation
227
+ unless @config.client_secret && !@config.client_secret.empty?
228
+ error_msg = "Intercom client_secret is required for webhook signature validation. " \
229
+ "Either configure client_secret or set skip_signature_validation=true to explicitly disable validation."
230
+ FlowChat.logger.error { "IntercomApi: #{error_msg}" }
231
+ raise FlowChat::Intercom::ConfigurationError, error_msg
232
+ end
233
+
234
+ signature_header = request.headers["X-Hub-Signature"]
235
+ unless signature_header
236
+ FlowChat.logger.warn { "IntercomApi: No X-Hub-Signature header found in request" }
237
+ return false
238
+ end
239
+
240
+ # Extract signature from header (format: "sha1=<signature>")
241
+ expected_signature = signature_header.sub("sha1=", "")
242
+
243
+ # Get raw request body
244
+ request.body.rewind
245
+ body = request.body.read
246
+ request.body.rewind
247
+
248
+ # Calculate HMAC signature using SHA1 (Intercom uses SHA1, not SHA256)
249
+ calculated_signature = OpenSSL::HMAC.hexdigest(
250
+ OpenSSL::Digest.new("sha1"),
251
+ @config.client_secret,
252
+ body
253
+ )
254
+
255
+ # Compare signatures using secure comparison to prevent timing attacks
256
+ signature_valid = FlowChat::Security.secure_compare(expected_signature, calculated_signature)
257
+
258
+ if signature_valid
259
+ FlowChat.logger.debug { "IntercomApi: Webhook signature validation successful" }
260
+ else
261
+ FlowChat.logger.warn { "IntercomApi: Webhook signature validation failed - signatures do not match" }
262
+ end
263
+
264
+ signature_valid
265
+ rescue FlowChat::Intercom::ConfigurationError
266
+ raise
267
+ rescue => e
268
+ FlowChat.logger.error { "IntercomApi: Error validating webhook signature: #{e.class.name}: #{e.message}" }
269
+ false
270
+ end
271
+
272
+ def extract_latest_user_message(conversation, event_type)
273
+ FlowChat.logger.debug { "IntercomApi: Extracting latest user message from #{event_type} event" }
274
+
275
+ case event_type
276
+ when "conversation.user.created"
277
+ # For new conversations, get the initial message from source
278
+ source = conversation["source"]
279
+ if source && (source["body"] || source["attachments"]&.any?)
280
+ {
281
+ id: source["id"],
282
+ body: source["body"],
283
+ media: extract_attachments(source)
284
+ }.compact
285
+ end
286
+ when "conversation.user.replied"
287
+ # For replies, get the latest user message from conversation_parts
288
+ parts = conversation.dig("conversation_parts", "conversation_parts") || []
289
+
290
+ # Find the most recent part from a user (not admin)
291
+ # Note: user type can be "user", "lead", or "contact"
292
+ user_parts = parts.select do |part|
293
+ part["part_type"] == "comment" &&
294
+ %w[user lead contact].include?(part.dig("author", "type"))
295
+ end
296
+
297
+ if user_parts.any?
298
+ latest_part = user_parts.last
299
+ {
300
+ id: latest_part["id"],
301
+ body: latest_part["body"],
302
+ media: extract_attachments(latest_part)
303
+ }.compact
304
+ end
305
+ end
306
+ end
307
+
308
+ def extract_attachments(raw)
309
+ attachments = raw["attachments"] || []
310
+ return nil if attachments.empty?
311
+
312
+ attachments.map do |a|
313
+ {
314
+ type: intercom_media_type(a["content_type"]),
315
+ url: a["url"],
316
+ mime_type: a["content_type"],
317
+ filename: a["name"]
318
+ }
319
+ end
320
+ end
321
+
322
+ def intercom_media_type(content_type)
323
+ case content_type
324
+ when %r{\Aimage/} then :image
325
+ when %r{\Avideo/} then :video
326
+ when %r{\Aaudio/} then :audio
327
+ else :document
328
+ end
329
+ end
330
+
331
+ def handle_message_inline(context, controller)
332
+ response = @app.call(context)
333
+ if response
334
+ _type, prompt, choices, media = response
335
+ result = report_delivery_failure(
336
+ context,
337
+ to: context["request.user_id"],
338
+ conversation_id: context["request.id"],
339
+ message: prompt,
340
+ gateway: :intercom_api,
341
+ platform: :intercom
342
+ ) do
343
+ @client.send_message(context["request.id"], prompt, choices: choices, media: media)
344
+ end
345
+ context["intercom.message_result"] = result
346
+
347
+ # report_delivery_failure already reported this; a nil result
348
+ # means the platform did not accept the message, and instrumenting
349
+ # MESSAGE_SENT anyway counted a delivery that never happened.
350
+ return unless result
351
+
352
+ # Instrument message sent
353
+ instrument(Events::MESSAGE_SENT, {
354
+ to: context["request.user_id"],
355
+ conversation_id: context["request.id"],
356
+ message: prompt,
357
+ gateway: :intercom_api,
358
+ platform: :intercom,
359
+ content_length: prompt.to_s.length,
360
+ platform_message_id: platform_message_id_from(result),
361
+ duration_ms: context[FlowChat::Instrumentation::DELIVERY_DURATION_KEY],
362
+ timestamp: context["request.timestamp"]
363
+ })
364
+ end
365
+ end
366
+
367
+ # A reply comes back as the conversation part it created.
368
+ def platform_message_id_from(result)
369
+ return nil unless result.is_a?(Hash)
370
+
371
+ result["id"]
372
+ end
373
+
374
+ def handle_message_simulator(context, controller)
375
+ response = @app.call(context)
376
+
377
+ if response
378
+ _type, prompt, choices, media = response
379
+ rendered_message = render_response(prompt, choices, media)
380
+
381
+ # For simulator mode, return the response data in the HTTP response
382
+ # instead of actually sending via Intercom API
383
+ message_payload = @client.build_reply_payload(rendered_message, context["request.id"])
384
+
385
+ simulator_response = {
386
+ mode: "simulator",
387
+ webhook_processed: true,
388
+ would_send: message_payload,
389
+ message_info: {
390
+ to: context["request.id"],
391
+ user_id: context["request.user_id"],
392
+ timestamp: Time.now.iso8601
393
+ }
394
+ }
395
+
396
+ @controller.render json: simulator_response
397
+ nil
398
+ end
399
+ end
400
+
401
+ def simulate?(context)
402
+ # Check if simulator mode is enabled for this processor
403
+ return false unless context["enable_simulator"]
404
+
405
+ # Then check if simulator mode is requested and authorized
406
+ @body.dig("simulator_mode") &&
407
+ FlowChat::Security.valid_simulator_cookie?(@controller.request.cookies[FlowChat::Security::SIMULATOR_COOKIE_NAME])
408
+ end
409
+
410
+ def parse_request_body(request)
411
+ @body ||= JSON.parse(request.body.read)
412
+ end
413
+
414
+ def render_response(prompt, choices, media)
415
+ FlowChat::Intercom::Renderer.new(prompt, choices: choices, media: media).render
416
+ end
417
+ end
418
+ end
419
+ end
420
+ end
@@ -0,0 +1,101 @@
1
+ module FlowChat
2
+ module Intercom
3
+ module Middleware
4
+ # Maps an Intercom reply back to the choice it belongs to.
5
+ #
6
+ # Intercom has no interactive buttons, so the renderer writes the options
7
+ # out as a numbered list and asks the reader to reply with a number. That
8
+ # number has to come back to the choice key the flow branches on, the same
9
+ # job USSD's mapper does for a handset.
10
+ #
11
+ # A number is the only thing that resolves, as on USSD, and that is what
12
+ # the message asks for in as many words. It is also what makes this
13
+ # mapper safe by construction rather than by care: positions are unique
14
+ # whatever the labels say, so there is no equivalence under which two
15
+ # choices could collapse and nothing to check them against.
16
+ #
17
+ # Labels were matched too, case insensitively. That is what needed the
18
+ # care - two choices reading the same, or differing only in case, folded
19
+ # onto one entry and the second could not be picked by name at all. On a
20
+ # screen that prints a number beside every option and asks for one, the
21
+ # number already does that job unambiguously.
22
+ #
23
+ # Flow:
24
+ # 1. Flow returns choices with their own keys (e.g. {"sales" => "Sales"})
25
+ # 2. This middleware numbers them and remembers what each number meant
26
+ # 3. The renderer writes "1. Sales" and asks for a number
27
+ # 4. The reader replies "1"
28
+ # 5. This middleware turns it back into "sales"
29
+ # 6. The flow sees its own key
30
+ class ChoiceMapper
31
+ SESSION_KEY = "intercom.choice_mapping"
32
+
33
+ def initialize(app)
34
+ @app = app
35
+ FlowChat.logger.debug { "Intercom::ChoiceMapper: Initialized Intercom choice mapping middleware" }
36
+ end
37
+
38
+ def call(context)
39
+ @context = context
40
+ @session = context.session
41
+
42
+ resolve_input
43
+
44
+ type, prompt, choices, media = @app.call(context)
45
+
46
+ choices = remember(choices)
47
+
48
+ [type, prompt, choices, media]
49
+ end
50
+
51
+ private
52
+
53
+ def resolve_input
54
+ return if @context.input.blank?
55
+
56
+ mapping = @session.get(SESSION_KEY) || {}
57
+ return if mapping.empty?
58
+
59
+ # Surrounding whitespace is trimmed off a chat message before the
60
+ # lookup. Unlike a fold over labels, this cannot merge two choices:
61
+ # trimming leaves "1" and "2" as distinct as it found them.
62
+ matched = mapping[@context.input.to_s.strip]
63
+ return unless matched
64
+
65
+ FlowChat.logger.info { "Intercom::ChoiceMapper: Resolving #{@context.input} to #{matched}" }
66
+ @context.input = matched
67
+ end
68
+
69
+ # Numbers the choices and stores what each number meant.
70
+ #
71
+ # The renderer is what prints the number beside each label, so nothing
72
+ # is prefixed here - doing both would read as "1. 1. Savings".
73
+ #
74
+ # Cleared when a screen carries no choices, so an answer typed into a
75
+ # later free-text question is never read as a choice from an earlier
76
+ # menu. WhatsApp and Messenger each had this same bug fixed twice.
77
+ #
78
+ # @return [Hash, nil] the choices to render
79
+ def remember(choices)
80
+ if choices.blank?
81
+ @session.delete(SESSION_KEY)
82
+ return choices
83
+ end
84
+
85
+ numbered = {}
86
+ mapping = {}
87
+
88
+ choices.each_with_index do |(key, label), index|
89
+ number = (index + 1).to_s
90
+ numbered[number] = label.to_s
91
+ mapping[number] = key.to_s
92
+ end
93
+
94
+ @session.set(SESSION_KEY, mapping)
95
+ FlowChat.logger.debug { "Intercom::ChoiceMapper: Created mapping: #{mapping}" }
96
+ numbered
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,123 @@
1
+ require "flow_chat/renderers/markdown_support"
2
+
3
+ module FlowChat
4
+ module Intercom
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 choices
18
+ build_selection_message
19
+ else
20
+ build_text_message
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def build_text_message
27
+ link, options = render_media
28
+ [:text, to_html(message.to_s + link), options]
29
+ end
30
+
31
+ def build_selection_message
32
+ if choices.is_a?(Hash)
33
+ build_interactive_message(choices)
34
+ else
35
+ raise ArgumentError, "choices must be a Hash"
36
+ end
37
+ end
38
+
39
+ # Choices are a numbered list rather than Intercom's own quick replies,
40
+ # and that is a decision rather than an oversight. Intercom does document
41
+ # reply_options with message_type "quick_reply" on an admin reply, and it
42
+ # was built here and reverted. Three reasons, in the order they matter:
43
+ #
44
+ # - The uuid of a clicked option comes back as quick_reply_option_uuid,
45
+ # and only if the Intercom app is set to the *Unstable* API version.
46
+ # Webhooks inherit that setting, so an app on a stable version receives
47
+ # no metadata at all. Requiring an unstable API version for something as
48
+ # basic as reading which option was chosen is not a thing a library can
49
+ # ask of its users.
50
+ # - body is forbidden on a quick_reply, so a choice screen becomes two
51
+ # conversation parts: one for the prompt, one for the buttons. In an
52
+ # inbox a human reads, that doubles the length of every flow.
53
+ # - Intercom's own community reports the endpoint returning errors for
54
+ # this shape.
55
+ #
56
+ # A numbered list needs none of that and works on every API version. If
57
+ # Intercom stabilises quick replies, the git history has the
58
+ # implementation; check those three things before restoring it.
59
+ def build_interactive_message(choice_hash)
60
+ formatted_message = message.to_s
61
+
62
+ unless formatted_message.empty?
63
+ formatted_message += "\n\n"
64
+ end
65
+
66
+ # Add numbered choices
67
+ formatted_message += "Please choose:\n"
68
+ choice_hash.each_with_index do |(key, value), index|
69
+ formatted_message += "#{index + 1}. #{value}\n"
70
+ end
71
+
72
+ formatted_message += "\nReply with the number of your choice."
73
+
74
+ link, options = render_media
75
+ [:text, to_html(formatted_message + link), options.merge(choices: choice_hash)]
76
+ end
77
+
78
+ # Intercom's admin reply only takes real attachments as image URLs
79
+ # (attachment_urls, documented specifically for images, max 10). Any
80
+ # other media type with a url - document, video, audio, sticker -
81
+ # becomes a markdown link in the body instead, since attachment_urls
82
+ # is documented for images and a non-image URL there may not render.
83
+ # An id with no url is another platform's upload handle (a WhatsApp
84
+ # media id, say) and means nothing to Intercom, so it is logged and
85
+ # dropped rather than raised: a multi-platform flow legitimately sets
86
+ # an id for whichever platform uploaded it, and one platform lacking
87
+ # the media should not fail the whole turn.
88
+ #
89
+ # Returns [markdown_suffix, options] - the suffix is appended to the
90
+ # message before markdown-to-HTML conversion so a link goes through
91
+ # the same sanitizer and allowed_tags as the rest of the body.
92
+ def render_media
93
+ return ["", {}] unless media
94
+
95
+ url = media[:url]
96
+ media_type = (media[:type] || :image).to_sym
97
+
98
+ unless url
99
+ FlowChat.logger.warn { "Intercom::Renderer: media id #{media[:id].inspect} is another platform's upload handle and means nothing to Intercom (no url given); sending the message without it" }
100
+ return ["", {}]
101
+ end
102
+
103
+ if media_type == :image
104
+ ["", {attachment_urls: [url]}]
105
+ else
106
+ label = media[:filename] || media_type.to_s.capitalize
107
+ ["\n\n[#{label}](#{url})", {}]
108
+ end
109
+ end
110
+
111
+ # MarkdownSupport overrides for Intercom-specific behavior
112
+
113
+ def allowed_tags
114
+ # Tags supported by Intercom messenger
115
+ %w[p br b strong i em a ul ol li h1 h2 h3 h4 h5 h6]
116
+ end
117
+
118
+ def allowed_attributes
119
+ %w[href target]
120
+ end
121
+ end
122
+ end
123
+ end