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.
- checksums.yaml +4 -4
- data/.cliff.toml +74 -0
- data/.github/workflows/ci.yml +2 -3
- data/.github/workflows/pages.yml +43 -0
- data/.github/workflows/release.yml +56 -0
- data/.standard.yml +4 -0
- data/CHANGELOG.md +48 -0
- data/CLAUDE.md +327 -0
- data/CONTRIBUTING.md +134 -0
- data/Gemfile +1 -0
- data/README.md +189 -133
- data/Rakefile +17 -2
- data/SECURITY.md +42 -349
- data/docs/architecture.md +83 -0
- data/docs/async-background-processing.md +64 -0
- data/docs/configuration.md +110 -287
- data/docs/factory-pattern.md +58 -0
- data/docs/gateway-context-variables.md +168 -0
- data/docs/gateway-development.md +159 -0
- data/docs/getting-started.md +90 -0
- data/docs/instrumentation.md +95 -175
- data/docs/platforms/instagram.md +278 -0
- data/docs/platforms/messenger.md +205 -0
- data/docs/platforms/telegram.md +109 -0
- data/docs/platforms/ussd.md +78 -0
- data/docs/platforms/whatsapp.md +147 -0
- 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 +33 -426
- data/examples/custom_session_id_example.rb +119 -0
- data/examples/http_controller.rb +22 -20
- data/examples/intercom_configuration_example.rb +113 -0
- data/examples/intercom_controller.rb +182 -0
- data/examples/multi_tenant_whatsapp_controller.rb +63 -168
- data/examples/simulator_controller.rb +0 -1
- data/examples/ussd_controller.rb +88 -160
- data/examples/whatsapp_controller.rb +18 -17
- data/examples/whatsapp_media_examples.rb +27 -79
- data/flow_chat.gemspec +4 -0
- data/lib/flow_chat/app.rb +211 -0
- data/lib/flow_chat/async_job.rb +176 -0
- data/lib/flow_chat/choice_titles.rb +95 -0
- data/lib/flow_chat/config.rb +126 -23
- data/lib/flow_chat/delivery_error.rb +9 -0
- data/lib/flow_chat/{base_executor.rb → executor.rb} +6 -11
- data/lib/flow_chat/factory.rb +94 -0
- data/lib/flow_chat/gateway_async_support.rb +106 -0
- data/lib/flow_chat/generic_async_job.rb +30 -0
- data/lib/flow_chat/http/configuration_error.rb +9 -0
- data/lib/flow_chat/http/gateway/simple.rb +104 -36
- data/lib/flow_chat/http/middleware/choice_mapper.rb +94 -0
- data/lib/flow_chat/http/renderer.rb +3 -3
- 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/setup.rb +1 -1
- data/lib/flow_chat/instrumentation.rb +182 -0
- data/lib/flow_chat/intercom/client.rb +161 -0
- data/lib/flow_chat/intercom/configuration.rb +102 -0
- data/lib/flow_chat/intercom/configuration_error.rb +9 -0
- data/lib/flow_chat/intercom/gateway/intercom_api.rb +420 -0
- data/lib/flow_chat/intercom/middleware/choice_mapper.rb +101 -0
- data/lib/flow_chat/intercom/renderer.rb +123 -0
- 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 +468 -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/phone_number_util.rb +37 -35
- data/lib/flow_chat/processor.rb +188 -0
- data/lib/flow_chat/prompt.rb +13 -16
- data/lib/flow_chat/renderers/markdown_support.rb +167 -0
- data/lib/flow_chat/security.rb +76 -0
- data/lib/flow_chat/session/middleware.rb +36 -11
- 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 +283 -0
- data/lib/flow_chat/telegram/configuration.rb +78 -0
- data/lib/flow_chat/telegram/configuration_error.rb +9 -0
- data/lib/flow_chat/telegram/gateway/bot_api.rb +318 -0
- data/lib/flow_chat/telegram/middleware/choice_mapper.rb +96 -0
- data/lib/flow_chat/telegram/renderer.rb +133 -0
- data/lib/flow_chat/telegram.rb +7 -0
- data/lib/flow_chat/text_truncator.rb +75 -0
- data/lib/flow_chat/ussd/gateway/nalo.rb +24 -4
- data/lib/flow_chat/ussd/middleware/choice_mapper.rb +10 -0
- data/lib/flow_chat/ussd/middleware/pagination.rb +9 -5
- data/lib/flow_chat/ussd/renderer.rb +1 -1
- data/lib/flow_chat/version.rb +1 -1
- data/lib/flow_chat/whatsapp/client.rb +158 -20
- data/lib/flow_chat/whatsapp/configuration.rb +13 -52
- data/lib/flow_chat/whatsapp/configuration_error.rb +9 -0
- data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +335 -248
- data/lib/flow_chat/whatsapp/middleware/choice_mapper.rb +234 -0
- data/lib/flow_chat/whatsapp/renderer.rb +259 -64
- data/lib/flow_chat.rb +1 -1
- data/lib/tasks/release.rake +165 -0
- 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 +132 -25
- data/docs/flows.md +0 -320
- data/docs/http-gateway-protocol.md +0 -432
- data/docs/images/simulator.png +0 -0
- data/docs/media.md +0 -153
- data/docs/sessions.md +0 -433
- data/docs/ussd-setup.md +0 -322
- data/docs/whatsapp-setup.md +0 -162
- data/examples/whatsapp_message_job.rb +0 -113
- data/lib/flow_chat/base_app.rb +0 -86
- data/lib/flow_chat/base_processor.rb +0 -146
- data/lib/flow_chat/http/app.rb +0 -6
- data/lib/flow_chat/http/middleware/executor.rb +0 -24
- data/lib/flow_chat/http/processor.rb +0 -33
- data/lib/flow_chat/session/rails_session_store.rb +0 -68
- data/lib/flow_chat/ussd/app.rb +0 -6
- data/lib/flow_chat/ussd/gateway/nsano.rb +0 -96
- data/lib/flow_chat/ussd/middleware/executor.rb +0 -24
- data/lib/flow_chat/ussd/processor.rb +0 -39
- data/lib/flow_chat/whatsapp/app.rb +0 -29
- data/lib/flow_chat/whatsapp/middleware/executor.rb +0 -24
- data/lib/flow_chat/whatsapp/processor.rb +0 -32
- 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
|