flow_chat 0.8.2 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.cliff.toml +74 -0
- data/.github/workflows/ci.yml +2 -3
- data/.github/workflows/release.yml +56 -0
- data/.standard.yml +4 -0
- data/CHANGELOG.md +22 -0
- data/CLAUDE.md +327 -0
- data/CONTRIBUTING.md +134 -0
- data/Gemfile +1 -0
- data/README.md +290 -105
- data/Rakefile +5 -1
- data/SECURITY.md +42 -349
- data/docs/architecture.md +510 -0
- data/docs/async-background-processing.md +298 -0
- data/docs/configuration.md +556 -226
- data/docs/factory-pattern.md +355 -0
- data/docs/gateway-context-variables.md +171 -0
- data/docs/gateway-development.md +723 -0
- data/docs/getting-started.md +429 -0
- data/docs/instrumentation.md +264 -153
- data/docs/platforms/telegram.md +1013 -0
- data/docs/platforms/ussd.md +693 -0
- data/docs/platforms/whatsapp.md +1395 -0
- data/docs/testing.md +243 -365
- data/examples/custom_session_id_example.rb +119 -0
- data/examples/http_controller.rb +11 -11
- data/examples/intercom_configuration_example.rb +118 -0
- data/examples/intercom_controller.rb +194 -0
- data/examples/multi_tenant_whatsapp_controller.rb +4 -4
- data/examples/ussd_controller.rb +9 -9
- data/examples/whatsapp_controller.rb +2 -2
- data/flow_chat.gemspec +4 -0
- data/lib/flow_chat/{base_app.rb → app.rb} +16 -9
- data/lib/flow_chat/async_job.rb +176 -0
- data/lib/flow_chat/config.rb +10 -30
- 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/gateway/simple.rb +81 -33
- data/lib/flow_chat/http/renderer.rb +3 -3
- data/lib/flow_chat/instrumentation/setup.rb +1 -1
- data/lib/flow_chat/instrumentation.rb +23 -0
- data/lib/flow_chat/intercom/client.rb +155 -0
- data/lib/flow_chat/intercom/configuration.rb +149 -0
- data/lib/flow_chat/intercom/gateway/intercom_api.rb +396 -0
- data/lib/flow_chat/intercom/renderer.rb +71 -0
- data/lib/flow_chat/phone_number_util.rb +37 -35
- data/lib/flow_chat/processor.rb +188 -0
- data/lib/flow_chat/renderers/markdown_support.rb +58 -0
- data/lib/flow_chat/session/middleware.rb +25 -9
- data/lib/flow_chat/telegram/client.rb +240 -0
- data/lib/flow_chat/telegram/configuration.rb +118 -0
- data/lib/flow_chat/telegram/gateway/bot_api.rb +300 -0
- data/lib/flow_chat/telegram/middleware/choice_mapper.rb +35 -0
- data/lib/flow_chat/telegram/renderer.rb +125 -0
- data/lib/flow_chat/telegram.rb +7 -0
- data/lib/flow_chat/ussd/gateway/nalo.rb +24 -4
- 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 +144 -13
- data/lib/flow_chat/whatsapp/configuration.rb +1 -1
- data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +132 -96
- data/lib/flow_chat/whatsapp/id_generator.rb +124 -0
- data/lib/flow_chat/whatsapp/middleware/choice_mapper.rb +147 -0
- data/lib/flow_chat/whatsapp/renderer.rb +145 -11
- data/lib/flow_chat.rb +11 -1
- data/lib/tasks/release.rake +165 -0
- metadata +84 -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_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,396 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "openssl"
|
|
3
|
+
|
|
4
|
+
module FlowChat
|
|
5
|
+
module Intercom
|
|
6
|
+
# Configuration-related errors
|
|
7
|
+
class ConfigurationError < StandardError; end
|
|
8
|
+
|
|
9
|
+
module Gateway
|
|
10
|
+
class IntercomApi
|
|
11
|
+
include FlowChat::Instrumentation
|
|
12
|
+
include FlowChat::GatewayAsyncSupport
|
|
13
|
+
|
|
14
|
+
attr_reader :client
|
|
15
|
+
|
|
16
|
+
# Default webhook topics to process
|
|
17
|
+
DEFAULT_WEBHOOK_TOPICS = ["conversation.user.created", "conversation.user.replied"].freeze
|
|
18
|
+
|
|
19
|
+
def initialize(app, config = nil, additional_webhook_topics = nil)
|
|
20
|
+
@app = app
|
|
21
|
+
@config = config || FlowChat::Intercom::Configuration.from_credentials
|
|
22
|
+
@client = FlowChat::Intercom::Client.new(@config)
|
|
23
|
+
# Always include default topics, plus any additional ones
|
|
24
|
+
@allowed_webhook_topics = DEFAULT_WEBHOOK_TOPICS + Array(additional_webhook_topics)
|
|
25
|
+
|
|
26
|
+
FlowChat.logger.info { "IntercomApi: Initialized Intercom API gateway" }
|
|
27
|
+
FlowChat.logger.debug { "IntercomApi: Gateway configuration - API base URL: #{@config.api_base_url}" }
|
|
28
|
+
FlowChat.logger.debug { "IntercomApi: Allowed webhook topics: #{@allowed_webhook_topics.inspect}" }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def call(context)
|
|
32
|
+
@context = context
|
|
33
|
+
@controller = context.controller
|
|
34
|
+
request = @controller.request
|
|
35
|
+
|
|
36
|
+
FlowChat.logger.debug { "IntercomApi: Processing #{request.request_method} request to #{request.path}" }
|
|
37
|
+
|
|
38
|
+
# Skip webhook-specific handling in background mode
|
|
39
|
+
unless in_background?
|
|
40
|
+
# Handle webhook URL validation (HEAD request)
|
|
41
|
+
if request.head?
|
|
42
|
+
FlowChat.logger.info { "IntercomApi: Handling webhook URL validation request" }
|
|
43
|
+
return @controller.head :ok
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Handle webhook notifications (POST request)
|
|
48
|
+
if request.post?
|
|
49
|
+
FlowChat.logger.info { "IntercomApi: Handling webhook notification (background: #{in_background?})" }
|
|
50
|
+
return handle_webhook(context)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
FlowChat.logger.warn { "IntercomApi: Invalid request method or parameters - returning bad request" }
|
|
54
|
+
@controller.head :bad_request
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def handle_webhook(context)
|
|
60
|
+
# Parse body
|
|
61
|
+
begin
|
|
62
|
+
parse_request_body(@controller.request)
|
|
63
|
+
@client.app_id = @body["app_id"]
|
|
64
|
+
FlowChat.logger.debug { "IntercomApi: Successfully parsed webhook request body" }
|
|
65
|
+
rescue JSON::ParserError => e
|
|
66
|
+
FlowChat.logger.error { "IntercomApi: Failed to parse webhook body: #{e.message}" }
|
|
67
|
+
return @controller.head :bad_request
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Check for simulator mode parameter in request (before validation)
|
|
71
|
+
# But only enable if valid simulator token is provided
|
|
72
|
+
is_simulator_mode = simulate?(context)
|
|
73
|
+
if is_simulator_mode
|
|
74
|
+
FlowChat.logger.info { "IntercomApi: Simulator mode enabled for this request" }
|
|
75
|
+
context["simulator_mode"] = true
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Validate webhook signature for security (skip for simulator mode and background)
|
|
79
|
+
# Return 200 OK even for invalid signatures to prevent Intercom from retrying
|
|
80
|
+
unless in_background? || is_simulator_mode || valid_webhook_signature?(@controller.request)
|
|
81
|
+
FlowChat.logger.warn { "IntercomApi: Invalid webhook signature - dropping request" }
|
|
82
|
+
return @controller.head :ok
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
FlowChat.logger.debug { "IntercomApi: Webhook signature validation passed" }
|
|
86
|
+
|
|
87
|
+
# Extract event data from Intercom webhook
|
|
88
|
+
event_type = @body["topic"]
|
|
89
|
+
unless event_type
|
|
90
|
+
FlowChat.logger.debug { "IntercomApi: No topic found in webhook body - returning OK" }
|
|
91
|
+
return @controller.head :ok
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Only process conversation events we care about
|
|
95
|
+
unless @allowed_webhook_topics.include?(event_type)
|
|
96
|
+
FlowChat.logger.debug { "IntercomApi: Ignoring event type '#{event_type}' (not in allowed topics) - returning OK" }
|
|
97
|
+
return @controller.head :ok
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Extract conversation data
|
|
101
|
+
data_item = @body.dig("data", "item")
|
|
102
|
+
unless data_item
|
|
103
|
+
FlowChat.logger.debug { "IntercomApi: No data.item found in webhook body - returning OK" }
|
|
104
|
+
return @controller.head :ok
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Process conversation event
|
|
108
|
+
if data_item["type"] == "conversation"
|
|
109
|
+
conversation = data_item
|
|
110
|
+
conversation_id = conversation["id"]
|
|
111
|
+
|
|
112
|
+
# Get the user ID from contacts (always the actual user/contact)
|
|
113
|
+
# contacts.contacts[0] contains the actual user, not the admin
|
|
114
|
+
contact = conversation.dig("contacts", "contacts", 0)
|
|
115
|
+
unless contact
|
|
116
|
+
FlowChat.logger.error { "IntercomApi: No contact found in conversation" }
|
|
117
|
+
return @controller.head :ok
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Get user ID, name, email, and phone from contact (most reliable)
|
|
121
|
+
user_id = contact["id"]
|
|
122
|
+
user_name = contact["name"]
|
|
123
|
+
user_email = contact["email"]
|
|
124
|
+
user_phone = contact["phone"]
|
|
125
|
+
|
|
126
|
+
context["request.id"] = conversation_id
|
|
127
|
+
context["request.user_id"] = user_id
|
|
128
|
+
context["request.user_name"] = user_name if user_name
|
|
129
|
+
context["request.email"] = user_email if user_email
|
|
130
|
+
context["request.msisdn"] = user_phone if user_phone
|
|
131
|
+
context["request.gateway"] = :intercom_api
|
|
132
|
+
context["request.platform"] = :intercom
|
|
133
|
+
context["request.timestamp"] = Time.now.iso8601
|
|
134
|
+
context["request.body"] = @body
|
|
135
|
+
|
|
136
|
+
context["intercom.client"] = @client
|
|
137
|
+
context["intercom.topic"] = event_type
|
|
138
|
+
|
|
139
|
+
# Try to extract latest message for user events
|
|
140
|
+
latest_message = extract_latest_user_message(conversation, event_type)
|
|
141
|
+
|
|
142
|
+
if latest_message
|
|
143
|
+
context["request.message_id"] = latest_message[:id]
|
|
144
|
+
# Convert HTML to markdown for message body
|
|
145
|
+
raw_body = latest_message[:body] || ""
|
|
146
|
+
context.input = @client.parse_message(raw_body)
|
|
147
|
+
FlowChat.logger.debug { "IntercomApi: Message content extracted - Event: #{event_type}, Input: '#{context.input}'" }
|
|
148
|
+
elsif @allowed_webhook_topics.include?(event_type)
|
|
149
|
+
# No message but event is explicitly allowed - process without message
|
|
150
|
+
context.input = nil
|
|
151
|
+
FlowChat.logger.debug { "IntercomApi: Processing #{event_type} event without user message" }
|
|
152
|
+
else
|
|
153
|
+
# No message and event not in allowed topics - skip
|
|
154
|
+
# (This case shouldn't happen as we already filtered above, but safety check)
|
|
155
|
+
FlowChat.logger.error { "IntercomApi: No message found for unexpected event type '#{event_type}'" }
|
|
156
|
+
return @controller.head :ok
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Instrument message received (with or without message content)
|
|
160
|
+
instrument(Events::MESSAGE_RECEIVED, {
|
|
161
|
+
from: user_id,
|
|
162
|
+
conversation_id: conversation_id,
|
|
163
|
+
message: context.input,
|
|
164
|
+
event_type: event_type
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
# Determine routing: async enqueue, background execute, or inline
|
|
168
|
+
if should_enqueue_async?
|
|
169
|
+
# Webhook with async enabled → enqueue job and return immediately
|
|
170
|
+
enqueue_async_job
|
|
171
|
+
return @controller.head :ok
|
|
172
|
+
else
|
|
173
|
+
# Background OR inline → process message
|
|
174
|
+
# Determine message handling mode (simulator vs inline)
|
|
175
|
+
handler_mode = determine_message_handler(context)
|
|
176
|
+
|
|
177
|
+
# Process the message based on handling mode
|
|
178
|
+
case handler_mode
|
|
179
|
+
when :inline
|
|
180
|
+
handle_message_inline(context, @controller)
|
|
181
|
+
when :simulator
|
|
182
|
+
# Return early from simulator mode to preserve the JSON response
|
|
183
|
+
return handle_message_simulator(context, @controller)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
@controller.head :ok
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def determine_message_handler(context)
|
|
192
|
+
# Use simulator mode if enabled, otherwise always use inline
|
|
193
|
+
if context["simulator_mode"]
|
|
194
|
+
FlowChat.logger.debug { "IntercomApi: Using simulator message handler" }
|
|
195
|
+
:simulator
|
|
196
|
+
else
|
|
197
|
+
FlowChat.logger.debug { "IntercomApi: Using inline message handler" }
|
|
198
|
+
:inline
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Validate webhook signature to ensure request comes from Intercom
|
|
203
|
+
def valid_webhook_signature?(request)
|
|
204
|
+
# Check if signature validation is explicitly disabled
|
|
205
|
+
if @config.skip_signature_validation
|
|
206
|
+
FlowChat.logger.debug { "IntercomApi: Webhook signature validation is disabled" }
|
|
207
|
+
return true
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Require client_secret for signature validation
|
|
211
|
+
unless @config.client_secret && !@config.client_secret.empty?
|
|
212
|
+
error_msg = "Intercom client_secret is required for webhook signature validation. " \
|
|
213
|
+
"Either configure client_secret or set skip_signature_validation=true to explicitly disable validation."
|
|
214
|
+
FlowChat.logger.error { "IntercomApi: #{error_msg}" }
|
|
215
|
+
raise FlowChat::Intercom::ConfigurationError, error_msg
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
signature_header = request.headers["X-Hub-Signature"]
|
|
219
|
+
unless signature_header
|
|
220
|
+
FlowChat.logger.warn { "IntercomApi: No X-Hub-Signature header found in request" }
|
|
221
|
+
return false
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Extract signature from header (format: "sha1=<signature>")
|
|
225
|
+
expected_signature = signature_header.sub("sha1=", "")
|
|
226
|
+
|
|
227
|
+
# Get raw request body
|
|
228
|
+
request.body.rewind
|
|
229
|
+
body = request.body.read
|
|
230
|
+
request.body.rewind
|
|
231
|
+
|
|
232
|
+
# Calculate HMAC signature using SHA1 (Intercom uses SHA1, not SHA256)
|
|
233
|
+
calculated_signature = OpenSSL::HMAC.hexdigest(
|
|
234
|
+
OpenSSL::Digest.new("sha1"),
|
|
235
|
+
@config.client_secret,
|
|
236
|
+
body
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
# Compare signatures using secure comparison to prevent timing attacks
|
|
240
|
+
signature_valid = secure_compare(expected_signature, calculated_signature)
|
|
241
|
+
|
|
242
|
+
if signature_valid
|
|
243
|
+
FlowChat.logger.debug { "IntercomApi: Webhook signature validation successful" }
|
|
244
|
+
else
|
|
245
|
+
FlowChat.logger.warn { "IntercomApi: Webhook signature validation failed - signatures do not match" }
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
signature_valid
|
|
249
|
+
rescue FlowChat::Intercom::ConfigurationError
|
|
250
|
+
raise
|
|
251
|
+
rescue => e
|
|
252
|
+
FlowChat.logger.error { "IntercomApi: Error validating webhook signature: #{e.class.name}: #{e.message}" }
|
|
253
|
+
false
|
|
254
|
+
end
|
|
255
|
+
|
|
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
|
+
def extract_latest_user_message(conversation, event_type)
|
|
267
|
+
FlowChat.logger.debug { "IntercomApi: Extracting latest user message from #{event_type} event" }
|
|
268
|
+
|
|
269
|
+
case event_type
|
|
270
|
+
when "conversation.user.created"
|
|
271
|
+
# For new conversations, get the initial message from source
|
|
272
|
+
source = conversation["source"]
|
|
273
|
+
if source && source["body"]
|
|
274
|
+
{
|
|
275
|
+
id: source["id"],
|
|
276
|
+
body: source["body"]
|
|
277
|
+
}
|
|
278
|
+
end
|
|
279
|
+
when "conversation.user.replied"
|
|
280
|
+
# For replies, get the latest user message from conversation_parts
|
|
281
|
+
parts = conversation.dig("conversation_parts", "conversation_parts") || []
|
|
282
|
+
|
|
283
|
+
# Find the most recent part from a user (not admin)
|
|
284
|
+
# Note: user type can be "user", "lead", or "contact"
|
|
285
|
+
user_parts = parts.select do |part|
|
|
286
|
+
part["part_type"] == "comment" &&
|
|
287
|
+
%w[user lead contact].include?(part.dig("author", "type"))
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
if user_parts.any?
|
|
291
|
+
latest_part = user_parts.last
|
|
292
|
+
{
|
|
293
|
+
id: latest_part["id"],
|
|
294
|
+
body: latest_part["body"]
|
|
295
|
+
}
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def handle_message_inline(context, controller)
|
|
301
|
+
response = @app.call(context)
|
|
302
|
+
if response
|
|
303
|
+
_type, prompt, choices, media = response
|
|
304
|
+
result = @client.send_message(context["request.id"], prompt, choices: choices, media: media)
|
|
305
|
+
context["intercom.message_result"] = result
|
|
306
|
+
|
|
307
|
+
# Instrument message sent
|
|
308
|
+
instrument(Events::MESSAGE_SENT, {
|
|
309
|
+
to: context["request.user_id"],
|
|
310
|
+
conversation_id: context["request.id"],
|
|
311
|
+
message: prompt,
|
|
312
|
+
gateway: :intercom_api,
|
|
313
|
+
platform: :intercom,
|
|
314
|
+
content_length: prompt.to_s.length,
|
|
315
|
+
timestamp: context["request.timestamp"]
|
|
316
|
+
})
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def handle_message_simulator(context, controller)
|
|
321
|
+
response = @app.call(context)
|
|
322
|
+
|
|
323
|
+
if response
|
|
324
|
+
_type, prompt, choices, media = response
|
|
325
|
+
rendered_message = render_response(prompt, choices, media)
|
|
326
|
+
|
|
327
|
+
# For simulator mode, return the response data in the HTTP response
|
|
328
|
+
# instead of actually sending via Intercom API
|
|
329
|
+
message_payload = @client.build_reply_payload(rendered_message, context["request.id"])
|
|
330
|
+
|
|
331
|
+
simulator_response = {
|
|
332
|
+
mode: "simulator",
|
|
333
|
+
webhook_processed: true,
|
|
334
|
+
would_send: message_payload,
|
|
335
|
+
message_info: {
|
|
336
|
+
to: context["request.id"],
|
|
337
|
+
user_id: context["request.user_id"],
|
|
338
|
+
timestamp: Time.now.iso8601
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
@controller.render json: simulator_response
|
|
343
|
+
nil
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def simulate?(context)
|
|
348
|
+
# Check if simulator mode is enabled for this processor
|
|
349
|
+
return false unless context["enable_simulator"]
|
|
350
|
+
|
|
351
|
+
# Then check if simulator mode is requested and valid
|
|
352
|
+
@body.dig("simulator_mode") && valid_simulator_cookie?(context)
|
|
353
|
+
end
|
|
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
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def parse_request_body(request)
|
|
387
|
+
@body ||= JSON.parse(request.body.read)
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def render_response(prompt, choices, media)
|
|
391
|
+
FlowChat::Intercom::Renderer.new(prompt, choices: choices, media: media).render
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
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
|
+
[:text, to_html(message), {}]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def build_selection_message
|
|
31
|
+
if choices.is_a?(Hash)
|
|
32
|
+
build_interactive_message(choices)
|
|
33
|
+
else
|
|
34
|
+
raise ArgumentError, "choices must be a Hash"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
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
|
+
formatted_message = message.to_s
|
|
43
|
+
|
|
44
|
+
unless formatted_message.empty?
|
|
45
|
+
formatted_message += "\n\n"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Add numbered choices
|
|
49
|
+
formatted_message += "Please choose:\n"
|
|
50
|
+
choice_hash.each_with_index do |(key, value), index|
|
|
51
|
+
formatted_message += "#{index + 1}. #{value}\n"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
formatted_message += "\nReply with the number of your choice."
|
|
55
|
+
|
|
56
|
+
[:text, to_html(formatted_message), {choices: choice_hash}]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# MarkdownSupport overrides for Intercom-specific behavior
|
|
60
|
+
|
|
61
|
+
def allowed_tags
|
|
62
|
+
# Tags supported by Intercom messenger
|
|
63
|
+
%w[p br b strong i em a ul ol li h1 h2 h3 h4 h5 h6]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def allowed_attributes
|
|
67
|
+
%w[href target]
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -1,47 +1,49 @@
|
|
|
1
1
|
module FlowChat
|
|
2
2
|
module PhoneNumberUtil
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
3
|
+
class << self
|
|
4
|
+
def to_e164(phone_number)
|
|
5
|
+
return "" if phone_number.nil? || phone_number.empty?
|
|
6
|
+
|
|
7
|
+
begin
|
|
8
|
+
# Try to load phonelib without Rails dependency
|
|
9
|
+
require_phonelib_safely
|
|
10
|
+
Phonelib.parse(phone_number).e164
|
|
11
|
+
rescue => e
|
|
12
|
+
FlowChat.logger.warn { "PhoneNumberUtil: Failed to parse phone number '#{phone_number}': #{e.message}" }
|
|
13
|
+
# Fallback to simple formatting if phonelib fails
|
|
14
|
+
fallback_e164_format(phone_number)
|
|
15
|
+
end
|
|
14
16
|
end
|
|
15
|
-
end
|
|
16
17
|
|
|
17
|
-
|
|
18
|
+
private
|
|
18
19
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
20
|
+
def require_phonelib_safely
|
|
21
|
+
return if defined?(Phonelib)
|
|
22
|
+
|
|
23
|
+
# Temporarily stub Rails if it doesn't exist
|
|
24
|
+
if defined?(Rails)
|
|
25
|
+
require "phonelib"
|
|
26
|
+
else
|
|
27
|
+
stub_rails = Module.new do
|
|
28
|
+
def const_missing(name)
|
|
29
|
+
if name == :Railtie
|
|
30
|
+
Class.new
|
|
31
|
+
else
|
|
32
|
+
super
|
|
33
|
+
end
|
|
30
34
|
end
|
|
31
35
|
end
|
|
36
|
+
Object.const_set(:Rails, stub_rails)
|
|
37
|
+
require "phonelib"
|
|
38
|
+
Object.send(:remove_const, :Rails)
|
|
32
39
|
end
|
|
33
|
-
Object.const_set(:Rails, stub_rails)
|
|
34
|
-
require "phonelib"
|
|
35
|
-
Object.send(:remove_const, :Rails)
|
|
36
|
-
else
|
|
37
|
-
require "phonelib"
|
|
38
40
|
end
|
|
39
|
-
end
|
|
40
41
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
42
|
+
def fallback_e164_format(phone_number)
|
|
43
|
+
# Simple fallback - ensure it starts with + and looks like a phone number
|
|
44
|
+
cleaned = phone_number.to_s.gsub(/[^\d+]/, "")
|
|
45
|
+
cleaned.start_with?("+") ? cleaned : "+#{cleaned}"
|
|
46
|
+
end
|
|
45
47
|
end
|
|
46
48
|
end
|
|
47
|
-
end
|
|
49
|
+
end
|