flow_chat 0.9.0 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +29 -0
- data/.github/workflows/pages.yml +43 -0
- data/.gitignore +4 -1
- data/CHANGELOG.md +49 -0
- data/README.md +166 -295
- data/Rakefile +12 -1
- data/SECURITY.md +1 -1
- data/docs/architecture.md +52 -479
- data/docs/async-background-processing.md +31 -265
- data/docs/configuration.md +106 -613
- data/docs/factory-pattern.md +27 -324
- data/docs/gateway-context-variables.md +140 -143
- data/docs/gateway-development.md +86 -650
- data/docs/getting-started.md +40 -379
- data/docs/instrumentation.md +88 -279
- data/docs/platforms/instagram.md +278 -0
- data/docs/platforms/messenger.md +205 -0
- data/docs/platforms/telegram.md +47 -951
- data/docs/platforms/ussd.md +38 -653
- data/docs/platforms/whatsapp.md +73 -1321
- data/docs/superpowers/plans/2026-07-09-inbound-media-support.md +732 -0
- data/docs/superpowers/plans/2026-07-09-inbound-media-support.md.tasks.json +58 -0
- data/docs/superpowers/plans/2026-08-10-messenger-instagram.md +4064 -0
- data/docs/superpowers/plans/2026-08-10-messenger-instagram.md.tasks.json +226 -0
- data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md +972 -0
- data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md.tasks.json +88 -0
- data/docs/superpowers/specs/2026-07-09-inbound-media-support-design.md +195 -0
- data/docs/superpowers/specs/2026-08-10-messenger-instagram-design.md +391 -0
- data/docs/testing.md +26 -297
- data/examples/http_controller.rb +12 -10
- data/examples/intercom_configuration_example.rb +19 -24
- data/examples/intercom_controller.rb +8 -20
- data/examples/multi_tenant_whatsapp_controller.rb +61 -166
- data/examples/simulator_controller.rb +0 -1
- data/examples/ussd_controller.rb +86 -158
- data/examples/whatsapp_controller.rb +16 -15
- data/examples/whatsapp_media_examples.rb +27 -79
- data/flow_chat.gemspec +22 -2
- data/gemfiles/rails_main.gemfile +18 -0
- data/lib/flow_chat/app.rb +129 -11
- data/lib/flow_chat/async_job.rb +10 -0
- data/lib/flow_chat/choice_titles.rb +95 -0
- data/lib/flow_chat/config.rb +124 -1
- data/lib/flow_chat/delivery_error.rb +9 -0
- data/lib/flow_chat/gateway_async_support.rb +19 -1
- data/lib/flow_chat/http/configuration_error.rb +9 -0
- data/lib/flow_chat/http/gateway/simple.rb +24 -4
- data/lib/flow_chat/http/middleware/choice_mapper.rb +94 -0
- data/lib/flow_chat/input.rb +86 -0
- data/lib/flow_chat/instagram/client.rb +32 -0
- data/lib/flow_chat/instagram/configuration.rb +147 -0
- data/lib/flow_chat/instagram/configuration_error.rb +7 -0
- data/lib/flow_chat/instagram/gateway/send_api.rb +63 -0
- data/lib/flow_chat/instagram/middleware/choice_mapper.rb +22 -0
- data/lib/flow_chat/instagram/renderer.rb +23 -0
- data/lib/flow_chat/instrumentation/metrics_collector.rb +6 -1
- data/lib/flow_chat/instrumentation.rb +160 -1
- data/lib/flow_chat/intercom/client.rb +34 -28
- data/lib/flow_chat/intercom/configuration.rb +2 -49
- data/lib/flow_chat/intercom/configuration_error.rb +9 -0
- data/lib/flow_chat/intercom/gateway/intercom_api.rb +81 -57
- data/lib/flow_chat/intercom/middleware/choice_mapper.rb +101 -0
- data/lib/flow_chat/intercom/renderer.rb +57 -5
- data/lib/flow_chat/media.rb +121 -0
- data/lib/flow_chat/messenger/client.rb +264 -0
- data/lib/flow_chat/messenger/configuration.rb +103 -0
- data/lib/flow_chat/messenger/configuration_error.rb +9 -0
- data/lib/flow_chat/messenger/gateway/send_api.rb +42 -0
- data/lib/flow_chat/messenger/middleware/choice_mapper.rb +185 -0
- data/lib/flow_chat/messenger/renderer.rb +150 -0
- data/lib/flow_chat/meta/challenge.rb +24 -0
- data/lib/flow_chat/meta/choice_ladder.rb +37 -0
- data/lib/flow_chat/meta/configuration_error.rb +7 -0
- data/lib/flow_chat/meta/gateway_identity.rb +38 -0
- data/lib/flow_chat/meta/messaging_gateway.rb +471 -0
- data/lib/flow_chat/meta/signature.rb +30 -0
- data/lib/flow_chat/meta/signature_validation.rb +66 -0
- data/lib/flow_chat/meta/webhook_verification.rb +43 -0
- data/lib/flow_chat/named_configuration.rb +65 -0
- data/lib/flow_chat/prompt.rb +13 -16
- data/lib/flow_chat/renderers/markdown_support.rb +109 -0
- data/lib/flow_chat/security.rb +76 -0
- data/lib/flow_chat/session/middleware.rb +11 -2
- data/lib/flow_chat/simulator/controller.rb +31 -15
- data/lib/flow_chat/simulator/views/simulator.html.erb +184 -20
- data/lib/flow_chat/telegram/client.rb +47 -4
- data/lib/flow_chat/telegram/configuration.rb +2 -42
- data/lib/flow_chat/telegram/configuration_error.rb +9 -0
- data/lib/flow_chat/telegram/gateway/bot_api.rb +46 -28
- data/lib/flow_chat/telegram/middleware/choice_mapper.rb +77 -16
- data/lib/flow_chat/telegram/renderer.rb +10 -2
- data/lib/flow_chat/text_truncator.rb +75 -0
- data/lib/flow_chat/ussd/middleware/choice_mapper.rb +10 -0
- data/lib/flow_chat/version.rb +1 -1
- data/lib/flow_chat/whatsapp/client.rb +14 -7
- data/lib/flow_chat/whatsapp/configuration.rb +12 -51
- data/lib/flow_chat/whatsapp/configuration_error.rb +9 -0
- data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +259 -203
- data/lib/flow_chat/whatsapp/middleware/choice_mapper.rb +144 -57
- data/lib/flow_chat/whatsapp/renderer.rb +121 -60
- data/lib/flow_chat.rb +0 -10
- data/lib/tasks/release.rake +11 -1
- data/site/.nojekyll +0 -0
- data/site/.og-card.html +89 -0
- data/site/favicon.svg +6 -0
- data/site/index.html +209 -0
- data/site/og.png +0 -0
- metadata +60 -11
- data/lib/flow_chat/whatsapp/id_generator.rb +0 -124
|
@@ -3,9 +3,6 @@ require "openssl"
|
|
|
3
3
|
|
|
4
4
|
module FlowChat
|
|
5
5
|
module Intercom
|
|
6
|
-
# Configuration-related errors
|
|
7
|
-
class ConfigurationError < StandardError; end
|
|
8
|
-
|
|
9
6
|
module Gateway
|
|
10
7
|
class IntercomApi
|
|
11
8
|
include FlowChat::Instrumentation
|
|
@@ -16,6 +13,17 @@ module FlowChat
|
|
|
16
13
|
# Default webhook topics to process
|
|
17
14
|
DEFAULT_WEBHOOK_TOPICS = ["conversation.user.created", "conversation.user.replied"].freeze
|
|
18
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
|
+
|
|
19
27
|
def initialize(app, config = nil, additional_webhook_topics = nil)
|
|
20
28
|
@app = app
|
|
21
29
|
@config = config || FlowChat::Intercom::Configuration.from_credentials
|
|
@@ -141,10 +149,18 @@ module FlowChat
|
|
|
141
149
|
|
|
142
150
|
if latest_message
|
|
143
151
|
context["request.message_id"] = latest_message[:id]
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
|
148
164
|
elsif @allowed_webhook_topics.include?(event_type)
|
|
149
165
|
# No message but event is explicitly allowed - process without message
|
|
150
166
|
context.input = nil
|
|
@@ -237,7 +253,7 @@ module FlowChat
|
|
|
237
253
|
)
|
|
238
254
|
|
|
239
255
|
# Compare signatures using secure comparison to prevent timing attacks
|
|
240
|
-
signature_valid = secure_compare(expected_signature, calculated_signature)
|
|
256
|
+
signature_valid = FlowChat::Security.secure_compare(expected_signature, calculated_signature)
|
|
241
257
|
|
|
242
258
|
if signature_valid
|
|
243
259
|
FlowChat.logger.debug { "IntercomApi: Webhook signature validation successful" }
|
|
@@ -253,16 +269,6 @@ module FlowChat
|
|
|
253
269
|
false
|
|
254
270
|
end
|
|
255
271
|
|
|
256
|
-
# Secure string comparison to prevent timing attacks
|
|
257
|
-
def secure_compare(a, b)
|
|
258
|
-
return false unless a.bytesize == b.bytesize
|
|
259
|
-
|
|
260
|
-
l = a.unpack("C*")
|
|
261
|
-
res = 0
|
|
262
|
-
b.each_byte { |byte| res |= byte ^ l.shift }
|
|
263
|
-
res == 0
|
|
264
|
-
end
|
|
265
|
-
|
|
266
272
|
def extract_latest_user_message(conversation, event_type)
|
|
267
273
|
FlowChat.logger.debug { "IntercomApi: Extracting latest user message from #{event_type} event" }
|
|
268
274
|
|
|
@@ -270,11 +276,12 @@ module FlowChat
|
|
|
270
276
|
when "conversation.user.created"
|
|
271
277
|
# For new conversations, get the initial message from source
|
|
272
278
|
source = conversation["source"]
|
|
273
|
-
if source && source["body"]
|
|
279
|
+
if source && (source["body"] || source["attachments"]&.any?)
|
|
274
280
|
{
|
|
275
281
|
id: source["id"],
|
|
276
|
-
body: source["body"]
|
|
277
|
-
|
|
282
|
+
body: source["body"],
|
|
283
|
+
media: extract_attachments(source)
|
|
284
|
+
}.compact
|
|
278
285
|
end
|
|
279
286
|
when "conversation.user.replied"
|
|
280
287
|
# For replies, get the latest user message from conversation_parts
|
|
@@ -291,19 +298,57 @@ module FlowChat
|
|
|
291
298
|
latest_part = user_parts.last
|
|
292
299
|
{
|
|
293
300
|
id: latest_part["id"],
|
|
294
|
-
body: latest_part["body"]
|
|
295
|
-
|
|
301
|
+
body: latest_part["body"],
|
|
302
|
+
media: extract_attachments(latest_part)
|
|
303
|
+
}.compact
|
|
296
304
|
end
|
|
297
305
|
end
|
|
298
306
|
end
|
|
299
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
|
+
|
|
300
331
|
def handle_message_inline(context, controller)
|
|
301
332
|
response = @app.call(context)
|
|
302
333
|
if response
|
|
303
334
|
_type, prompt, choices, media = response
|
|
304
|
-
result =
|
|
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
|
|
305
345
|
context["intercom.message_result"] = result
|
|
306
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
|
+
|
|
307
352
|
# Instrument message sent
|
|
308
353
|
instrument(Events::MESSAGE_SENT, {
|
|
309
354
|
to: context["request.user_id"],
|
|
@@ -312,11 +357,20 @@ module FlowChat
|
|
|
312
357
|
gateway: :intercom_api,
|
|
313
358
|
platform: :intercom,
|
|
314
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],
|
|
315
362
|
timestamp: context["request.timestamp"]
|
|
316
363
|
})
|
|
317
364
|
end
|
|
318
365
|
end
|
|
319
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
|
+
|
|
320
374
|
def handle_message_simulator(context, controller)
|
|
321
375
|
response = @app.call(context)
|
|
322
376
|
|
|
@@ -348,39 +402,9 @@ module FlowChat
|
|
|
348
402
|
# Check if simulator mode is enabled for this processor
|
|
349
403
|
return false unless context["enable_simulator"]
|
|
350
404
|
|
|
351
|
-
# Then check if simulator mode is requested and
|
|
352
|
-
@body.dig("simulator_mode") &&
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
def valid_simulator_cookie?(context)
|
|
356
|
-
simulator_secret = FlowChat::Config.simulator_secret
|
|
357
|
-
return false unless simulator_secret && !simulator_secret.empty?
|
|
358
|
-
|
|
359
|
-
# Check for simulator cookie
|
|
360
|
-
simulator_cookie = @controller.request.cookies["flowchat_simulator"]
|
|
361
|
-
return false unless simulator_cookie
|
|
362
|
-
|
|
363
|
-
# Verify the cookie is a valid HMAC signature
|
|
364
|
-
# Cookie format: "timestamp:signature" where signature = HMAC(simulator_secret, "simulator:timestamp")
|
|
365
|
-
begin
|
|
366
|
-
timestamp_str, signature = simulator_cookie.split(":", 2)
|
|
367
|
-
return false unless timestamp_str && signature
|
|
368
|
-
|
|
369
|
-
# Check timestamp is recent (within 24 hours for reasonable session duration)
|
|
370
|
-
timestamp = timestamp_str.to_i
|
|
371
|
-
return false if timestamp <= 0
|
|
372
|
-
return false if (Time.now.to_i - timestamp).abs > 86400 # 24 hours
|
|
373
|
-
|
|
374
|
-
# Calculate expected signature
|
|
375
|
-
message = "simulator:#{timestamp_str}"
|
|
376
|
-
expected_signature = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), simulator_secret, message)
|
|
377
|
-
|
|
378
|
-
# Use secure comparison
|
|
379
|
-
secure_compare(signature, expected_signature)
|
|
380
|
-
rescue => e
|
|
381
|
-
Rails.logger.warn "Invalid simulator cookie format: #{e.message}"
|
|
382
|
-
false
|
|
383
|
-
end
|
|
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])
|
|
384
408
|
end
|
|
385
409
|
|
|
386
410
|
def parse_request_body(request)
|
|
@@ -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
|
|
@@ -24,7 +24,8 @@ module FlowChat
|
|
|
24
24
|
private
|
|
25
25
|
|
|
26
26
|
def build_text_message
|
|
27
|
-
|
|
27
|
+
link, options = render_media
|
|
28
|
+
[:text, to_html(message.to_s + link), options]
|
|
28
29
|
end
|
|
29
30
|
|
|
30
31
|
def build_selection_message
|
|
@@ -35,10 +36,27 @@ module FlowChat
|
|
|
35
36
|
end
|
|
36
37
|
end
|
|
37
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.
|
|
38
59
|
def build_interactive_message(choice_hash)
|
|
39
|
-
# For Intercom, we'll present choices as a formatted text message
|
|
40
|
-
# since Intercom doesn't have the same interactive elements as WhatsApp
|
|
41
|
-
|
|
42
60
|
formatted_message = message.to_s
|
|
43
61
|
|
|
44
62
|
unless formatted_message.empty?
|
|
@@ -53,7 +71,41 @@ module FlowChat
|
|
|
53
71
|
|
|
54
72
|
formatted_message += "\nReply with the number of your choice."
|
|
55
73
|
|
|
56
|
-
|
|
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
|
|
57
109
|
end
|
|
58
110
|
|
|
59
111
|
# MarkdownSupport overrides for Intercom-specific behavior
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "uri"
|
|
3
|
+
|
|
4
|
+
module FlowChat
|
|
5
|
+
# Value object wrapping a single inbound media item parsed by a gateway.
|
|
6
|
+
# Normalizes cross-platform differences (WhatsApp media-id, Telegram file_id,
|
|
7
|
+
# Intercom/HTTP direct URL) behind #url and #download.
|
|
8
|
+
class Media
|
|
9
|
+
# Maps platform-native media types to a canonical, cross-platform set.
|
|
10
|
+
# Telegram uses :photo/:voice where WhatsApp uses :image/:audio.
|
|
11
|
+
NORMALIZED_TYPES = {photo: :image, voice: :audio}.freeze
|
|
12
|
+
|
|
13
|
+
# The canonical media types FlowChat recognizes across platforms. Gateways
|
|
14
|
+
# that accept a caller-supplied type (e.g. HTTP) validate against this set.
|
|
15
|
+
CANONICAL_TYPES = %i[image video audio document sticker].freeze
|
|
16
|
+
|
|
17
|
+
attr_reader :platform, :client
|
|
18
|
+
|
|
19
|
+
def initialize(data, platform:, client: nil)
|
|
20
|
+
@data = data
|
|
21
|
+
@platform = platform
|
|
22
|
+
@client = client
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Canonical, cross-platform media type (:image, :video, :audio, :document, :sticker).
|
|
26
|
+
def type
|
|
27
|
+
NORMALIZED_TYPES.fetch(raw_type, raw_type)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The platform-native type as parsed by the gateway (e.g. :photo, :voice on Telegram).
|
|
31
|
+
def raw_type
|
|
32
|
+
@data[:type]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def mime_type
|
|
36
|
+
@data[:mime_type]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def caption
|
|
40
|
+
@data[:caption]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def filename
|
|
44
|
+
@data[:filename] || @data[:file_name]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def id
|
|
48
|
+
@data[:id]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def file_id
|
|
52
|
+
@data[:file_id]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def [](key)
|
|
56
|
+
@data[key]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def to_h
|
|
60
|
+
@data.dup
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Serialize without the live platform client (a network object that may not
|
|
64
|
+
# marshal). This keeps a Media — and any FlowChat::Input holding it — safe to
|
|
65
|
+
# store in a session store. A deserialized Media has no client, so #url and
|
|
66
|
+
# #download degrade to nil rather than raising.
|
|
67
|
+
def marshal_dump
|
|
68
|
+
{data: @data, platform: @platform}
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def marshal_load(state)
|
|
72
|
+
@data = state[:data]
|
|
73
|
+
@platform = state[:platform]
|
|
74
|
+
@client = nil
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Resolve a fetchable URL for the media. Memoized so repeated reads don't
|
|
78
|
+
# re-issue the platform lookup (WhatsApp get_media_url / Telegram getFile).
|
|
79
|
+
# Returns nil (rather than raising) if the lookup fails, matching #download.
|
|
80
|
+
def url
|
|
81
|
+
@url ||= case platform
|
|
82
|
+
when :whatsapp then client.get_media_url(id)
|
|
83
|
+
when :telegram then client.file_url(file_id)
|
|
84
|
+
else @data[:url]
|
|
85
|
+
end
|
|
86
|
+
rescue => e
|
|
87
|
+
FlowChat.logger.warn { "Media: url resolution failed: #{e.message}" }
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Fetch the raw bytes of the media, or nil on failure (uniform across
|
|
92
|
+
# platforms). WhatsApp needs the client to attach auth headers; every other
|
|
93
|
+
# platform (Telegram, Intercom, HTTP) exposes a token-in-URL or public link,
|
|
94
|
+
# so we fetch the already-memoized #url directly — avoiding a second getFile
|
|
95
|
+
# round-trip on Telegram.
|
|
96
|
+
def download
|
|
97
|
+
case platform
|
|
98
|
+
when :whatsapp then client.download_media(id)
|
|
99
|
+
else fetch(url)
|
|
100
|
+
end
|
|
101
|
+
rescue => e
|
|
102
|
+
FlowChat.logger.warn { "Media: download failed: #{e.message}" }
|
|
103
|
+
nil
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
def fetch(resource_url)
|
|
109
|
+
return nil unless resource_url
|
|
110
|
+
|
|
111
|
+
uri = URI(resource_url)
|
|
112
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
113
|
+
http.use_ssl = (uri.scheme == "https")
|
|
114
|
+
response = http.get(uri.request_uri)
|
|
115
|
+
response.body if response.is_a?(Net::HTTPSuccess)
|
|
116
|
+
rescue => e
|
|
117
|
+
FlowChat.logger.warn { "Media: download failed for #{resource_url}: #{e.message}" }
|
|
118
|
+
nil
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|