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
|
@@ -19,10 +19,13 @@ module FlowChat
|
|
|
19
19
|
# @param to [String] Phone number in E.164 format
|
|
20
20
|
# @param response [Array] FlowChat response array [type, content, options]
|
|
21
21
|
# @return [Hash] API response or nil on error
|
|
22
|
-
def send_message(to,
|
|
23
|
-
|
|
24
|
-
FlowChat.logger.
|
|
25
|
-
|
|
22
|
+
def send_message(to, prompt, choices: nil, media: nil)
|
|
23
|
+
FlowChat.logger.info { "WhatsApp::Client: Sending message to #{to}" }
|
|
24
|
+
FlowChat.logger.debug { "WhatsApp::Client: Message content: '#{prompt.to_s.truncate(100)}'" }
|
|
25
|
+
|
|
26
|
+
# Use renderer to convert to structured response
|
|
27
|
+
response = FlowChat::Whatsapp::Renderer.new(prompt, choices: choices, media: media).render
|
|
28
|
+
type, content, _options = response
|
|
26
29
|
|
|
27
30
|
result = instrument(Events::MESSAGE_SENT, {
|
|
28
31
|
to: to,
|
|
@@ -50,7 +53,7 @@ module FlowChat
|
|
|
50
53
|
# @return [Hash] API response or nil on error
|
|
51
54
|
def send_text(to, text)
|
|
52
55
|
FlowChat.logger.debug { "WhatsApp::Client: Sending text message to #{to}" }
|
|
53
|
-
send_message(to,
|
|
56
|
+
send_message(to, text)
|
|
54
57
|
end
|
|
55
58
|
|
|
56
59
|
# Send interactive buttons
|
|
@@ -60,7 +63,8 @@ module FlowChat
|
|
|
60
63
|
# @return [Hash] API response or nil on error
|
|
61
64
|
def send_buttons(to, text, buttons)
|
|
62
65
|
FlowChat.logger.debug { "WhatsApp::Client: Sending interactive buttons to #{to} with #{buttons.size} buttons" }
|
|
63
|
-
|
|
66
|
+
choices = buttons.each_with_object({}) { |button, hash| hash[button[:id]] = button[:title] }
|
|
67
|
+
send_message(to, text, choices: choices)
|
|
64
68
|
end
|
|
65
69
|
|
|
66
70
|
# Send interactive list
|
|
@@ -72,7 +76,9 @@ module FlowChat
|
|
|
72
76
|
def send_list(to, text, sections, button_text = "Choose")
|
|
73
77
|
total_items = sections.sum { |section| section[:rows]&.size || 0 }
|
|
74
78
|
FlowChat.logger.debug { "WhatsApp::Client: Sending interactive list to #{to} with #{sections.size} sections, #{total_items} total items" }
|
|
75
|
-
|
|
79
|
+
choices = {}
|
|
80
|
+
sections.each { |section| section[:rows]&.each { |row| choices[row[:id]] = row[:title] } }
|
|
81
|
+
send_message(to, text, choices: choices)
|
|
76
82
|
end
|
|
77
83
|
|
|
78
84
|
# Send a template message
|
|
@@ -83,11 +89,13 @@ module FlowChat
|
|
|
83
89
|
# @return [Hash] API response or nil on error
|
|
84
90
|
def send_template(to, template_name, components = [], language = "en_US")
|
|
85
91
|
FlowChat.logger.debug { "WhatsApp::Client: Sending template '#{template_name}' to #{to} in #{language}" }
|
|
86
|
-
|
|
92
|
+
media = {
|
|
93
|
+
type: :template,
|
|
87
94
|
template_name: template_name,
|
|
88
95
|
components: components,
|
|
89
96
|
language: language
|
|
90
|
-
}
|
|
97
|
+
}
|
|
98
|
+
send_message(to, "", media: media)
|
|
91
99
|
end
|
|
92
100
|
|
|
93
101
|
# Send image message
|
|
@@ -98,7 +106,12 @@ module FlowChat
|
|
|
98
106
|
# @return [Hash] API response
|
|
99
107
|
def send_image(to, image_url_or_id, caption = nil, mime_type = nil)
|
|
100
108
|
FlowChat.logger.debug { "WhatsApp::Client: Sending image to #{to} - #{url?(image_url_or_id) ? "URL" : "Media ID"}" }
|
|
101
|
-
|
|
109
|
+
media = if url?(image_url_or_id)
|
|
110
|
+
{type: :image, url: image_url_or_id}
|
|
111
|
+
else
|
|
112
|
+
{type: :image, id: image_url_or_id}
|
|
113
|
+
end
|
|
114
|
+
send_message(to, caption, media: media)
|
|
102
115
|
end
|
|
103
116
|
|
|
104
117
|
# Send document message
|
|
@@ -145,6 +158,41 @@ module FlowChat
|
|
|
145
158
|
send_media_message(to, :sticker, sticker_url_or_id, mime_type: mime_type)
|
|
146
159
|
end
|
|
147
160
|
|
|
161
|
+
# Mark an inbound message as read, optionally showing a typing indicator.
|
|
162
|
+
#
|
|
163
|
+
# WhatsApp Cloud API ties the typing indicator to the mark-as-read call:
|
|
164
|
+
# passing `typing: true` adds a `typing_indicator` field to the same
|
|
165
|
+
# request. The indicator auto-dismisses on the next outbound message or
|
|
166
|
+
# after ~25 seconds; there is no separate "stop typing" call.
|
|
167
|
+
#
|
|
168
|
+
# @param message_id [String] the inbound message id (wamid.*) to mark as read
|
|
169
|
+
# @param typing [Boolean] when true, also show a typing indicator
|
|
170
|
+
# @return [Hash, nil] parsed API response, or nil on failure
|
|
171
|
+
def mark_as_read(message_id, typing: false)
|
|
172
|
+
payload = {
|
|
173
|
+
messaging_product: "whatsapp",
|
|
174
|
+
status: "read",
|
|
175
|
+
message_id: message_id
|
|
176
|
+
}
|
|
177
|
+
payload[:typing_indicator] = {type: "text"} if typing
|
|
178
|
+
|
|
179
|
+
send_read_receipt_payload(payload, message_id)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Show a typing indicator in response to an inbound message.
|
|
183
|
+
#
|
|
184
|
+
# Convenience wrapper around `mark_as_read(message_id, typing: true)`.
|
|
185
|
+
# Note that WhatsApp ties the typing indicator to read-receipt delivery,
|
|
186
|
+
# so calling this also marks the message as read. There is no stop-typing
|
|
187
|
+
# call — the indicator auto-dismisses on the next outbound message or
|
|
188
|
+
# after ~25 seconds.
|
|
189
|
+
#
|
|
190
|
+
# @param message_id [String] the inbound message id (wamid.*)
|
|
191
|
+
# @return [Hash, nil] parsed API response, or nil on failure
|
|
192
|
+
def indicate_typing(message_id)
|
|
193
|
+
mark_as_read(message_id, typing: true)
|
|
194
|
+
end
|
|
195
|
+
|
|
148
196
|
# Upload media file and return media ID
|
|
149
197
|
# @param file_path_or_io [String, IO] File path or IO object
|
|
150
198
|
# @param mime_type [String] MIME type of the file (required)
|
|
@@ -379,7 +427,7 @@ module FlowChat
|
|
|
379
427
|
data = JSON.parse(response.body)
|
|
380
428
|
data["url"]
|
|
381
429
|
else
|
|
382
|
-
|
|
430
|
+
FlowChat.logger.error { "WhatsApp::Client: Media API error: #{response.body}" }
|
|
383
431
|
nil
|
|
384
432
|
end
|
|
385
433
|
end
|
|
@@ -403,7 +451,7 @@ module FlowChat
|
|
|
403
451
|
if response.is_a?(Net::HTTPSuccess)
|
|
404
452
|
response.body
|
|
405
453
|
else
|
|
406
|
-
|
|
454
|
+
FlowChat.logger.error { "WhatsApp::Client: Media download error: #{response.body}" }
|
|
407
455
|
nil
|
|
408
456
|
end
|
|
409
457
|
end
|
|
@@ -420,7 +468,7 @@ module FlowChat
|
|
|
420
468
|
response = http.head(uri.path)
|
|
421
469
|
response["content-type"]
|
|
422
470
|
rescue => e
|
|
423
|
-
|
|
471
|
+
FlowChat.logger.warn { "WhatsApp::Client: Could not detect MIME type for #{url}: #{e.message}" }
|
|
424
472
|
nil
|
|
425
473
|
end
|
|
426
474
|
|
|
@@ -489,6 +537,13 @@ module FlowChat
|
|
|
489
537
|
result
|
|
490
538
|
else
|
|
491
539
|
FlowChat.logger.error { "WhatsApp::Client: API request failed - #{response.code}: #{response.body}" }
|
|
540
|
+
report_api_error(
|
|
541
|
+
"WhatsApp API request failed",
|
|
542
|
+
response_code: response.code,
|
|
543
|
+
response_body: response.body,
|
|
544
|
+
recipient: to,
|
|
545
|
+
message_type: message_type
|
|
546
|
+
)
|
|
492
547
|
nil
|
|
493
548
|
end
|
|
494
549
|
rescue Net::OpenTimeout, Net::ReadTimeout => network_error
|
|
@@ -497,9 +552,85 @@ module FlowChat
|
|
|
497
552
|
raise network_error
|
|
498
553
|
rescue => error
|
|
499
554
|
FlowChat.logger.error { "WhatsApp::Client: API request exception: #{error.class.name}: #{error.message}" }
|
|
555
|
+
report_api_error(
|
|
556
|
+
"WhatsApp API request exception: #{error.class.name}",
|
|
557
|
+
error: error,
|
|
558
|
+
recipient: to,
|
|
559
|
+
message_type: message_type
|
|
560
|
+
)
|
|
500
561
|
nil
|
|
501
562
|
end
|
|
502
563
|
|
|
564
|
+
def send_read_receipt_payload(payload, message_id)
|
|
565
|
+
FlowChat.logger.debug { "WhatsApp::Client: Marking message #{message_id} as read (typing=#{payload.key?(:typing_indicator)})" }
|
|
566
|
+
|
|
567
|
+
uri = URI("#{FlowChat::Config.whatsapp.api_base_url}/#{@config.phone_number_id}/messages")
|
|
568
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
569
|
+
http.use_ssl = true
|
|
570
|
+
|
|
571
|
+
request = Net::HTTP::Post.new(uri)
|
|
572
|
+
request["Authorization"] = "Bearer #{@config.access_token}"
|
|
573
|
+
request["Content-Type"] = "application/json"
|
|
574
|
+
request.body = payload.to_json
|
|
575
|
+
|
|
576
|
+
response = http.request(request)
|
|
577
|
+
|
|
578
|
+
if response.is_a?(Net::HTTPSuccess)
|
|
579
|
+
JSON.parse(response.body)
|
|
580
|
+
else
|
|
581
|
+
FlowChat.logger.error { "WhatsApp::Client: mark_as_read failed - #{response.code}: #{response.body}" }
|
|
582
|
+
report_api_error(
|
|
583
|
+
"WhatsApp mark_as_read failed",
|
|
584
|
+
response_code: response.code,
|
|
585
|
+
response_body: response.body,
|
|
586
|
+
message_type: "read_receipt"
|
|
587
|
+
)
|
|
588
|
+
nil
|
|
589
|
+
end
|
|
590
|
+
rescue Net::OpenTimeout, Net::ReadTimeout => network_error
|
|
591
|
+
FlowChat.logger.error { "WhatsApp::Client: Network timeout marking read: #{network_error.class.name}: #{network_error.message}" }
|
|
592
|
+
raise network_error
|
|
593
|
+
rescue => error
|
|
594
|
+
FlowChat.logger.error { "WhatsApp::Client: mark_as_read exception: #{error.class.name}: #{error.message}" }
|
|
595
|
+
report_api_error(
|
|
596
|
+
"WhatsApp mark_as_read exception: #{error.class.name}",
|
|
597
|
+
error: error,
|
|
598
|
+
message_type: "read_receipt"
|
|
599
|
+
)
|
|
600
|
+
nil
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
def report_api_error(message, response_code: nil, response_body: nil, error: nil, recipient: nil, message_type: nil)
|
|
604
|
+
error_details = parse_error_response(response_body)
|
|
605
|
+
|
|
606
|
+
FlowChat::Instrumentation.report_api_error(
|
|
607
|
+
message,
|
|
608
|
+
error: error,
|
|
609
|
+
platform: :whatsapp,
|
|
610
|
+
phone_number_id: @config.phone_number_id,
|
|
611
|
+
recipient: recipient,
|
|
612
|
+
message_type: message_type,
|
|
613
|
+
response_code: response_code,
|
|
614
|
+
**error_details
|
|
615
|
+
)
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
def parse_error_response(response_body)
|
|
619
|
+
return {} unless response_body
|
|
620
|
+
|
|
621
|
+
parsed = JSON.parse(response_body)
|
|
622
|
+
return {} unless parsed.is_a?(Hash) && parsed["error"]
|
|
623
|
+
|
|
624
|
+
{
|
|
625
|
+
error_type: parsed.dig("error", "type"),
|
|
626
|
+
error_code: parsed.dig("error", "code"),
|
|
627
|
+
error_subcode: parsed.dig("error", "error_subcode"),
|
|
628
|
+
error_message: parsed.dig("error", "message")
|
|
629
|
+
}.compact
|
|
630
|
+
rescue JSON::ParserError
|
|
631
|
+
{}
|
|
632
|
+
end
|
|
633
|
+
|
|
503
634
|
def send_media_message(to, media_type, url_or_id, caption: nil, filename: nil, mime_type: nil)
|
|
504
635
|
media_object = if url?(url_or_id)
|
|
505
636
|
{link: url_or_id}
|
|
@@ -29,7 +29,7 @@ module FlowChat
|
|
|
29
29
|
|
|
30
30
|
config = new(nil)
|
|
31
31
|
|
|
32
|
-
if defined?(Rails) && Rails.application.credentials
|
|
32
|
+
if defined?(Rails) && Rails.respond_to?(:application) && Rails.application&.credentials&.whatsapp
|
|
33
33
|
FlowChat.logger.debug { "WhatsApp::Configuration: Loading from Rails credentials" }
|
|
34
34
|
credentials = Rails.application.credentials.whatsapp
|
|
35
35
|
config.access_token = credentials[:access_token]
|
|
@@ -10,6 +10,7 @@ module FlowChat
|
|
|
10
10
|
module Gateway
|
|
11
11
|
class CloudApi
|
|
12
12
|
include FlowChat::Instrumentation
|
|
13
|
+
include FlowChat::GatewayAsyncSupport
|
|
13
14
|
|
|
14
15
|
attr_reader :context
|
|
15
16
|
|
|
@@ -24,48 +25,59 @@ module FlowChat
|
|
|
24
25
|
|
|
25
26
|
def call(context)
|
|
26
27
|
@context = context
|
|
27
|
-
controller = context.controller
|
|
28
|
-
request = controller.request
|
|
28
|
+
@controller = context.controller
|
|
29
|
+
request = @controller.request
|
|
29
30
|
|
|
30
31
|
FlowChat.logger.debug { "CloudApi: Processing #{request.request_method} request to #{request.path}" }
|
|
31
32
|
|
|
32
|
-
#
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
# Skip webhook-specific handling in background mode
|
|
34
|
+
unless in_background?
|
|
35
|
+
# Handle webhook verification
|
|
36
|
+
if request.get? && request.params["hub.mode"] == "subscribe"
|
|
37
|
+
FlowChat.logger.info { "CloudApi: Handling webhook verification request" }
|
|
38
|
+
return handle_verification(context)
|
|
39
|
+
end
|
|
36
40
|
end
|
|
37
41
|
|
|
38
42
|
# Handle webhook messages
|
|
39
43
|
if request.post?
|
|
40
|
-
FlowChat.logger.info { "CloudApi: Handling webhook message" }
|
|
44
|
+
FlowChat.logger.info { "CloudApi: Handling webhook message (background: #{in_background?})" }
|
|
41
45
|
return handle_webhook(context)
|
|
42
46
|
end
|
|
43
47
|
|
|
44
48
|
FlowChat.logger.warn { "CloudApi: Invalid request method or parameters - returning bad request" }
|
|
45
|
-
controller.head :bad_request
|
|
49
|
+
@controller.head :bad_request
|
|
46
50
|
end
|
|
47
51
|
|
|
48
52
|
# Expose client for out-of-band messaging
|
|
49
53
|
attr_reader :client
|
|
50
54
|
|
|
55
|
+
# Configure WhatsApp-specific middleware stack
|
|
56
|
+
def self.configure_middleware_stack(builder, custom_middleware)
|
|
57
|
+
FlowChat.logger.debug { "CloudApi: Configuring WhatsApp middleware stack" }
|
|
58
|
+
|
|
59
|
+
builder.use custom_middleware
|
|
60
|
+
FlowChat.logger.debug { "CloudApi: Added custom middleware" }
|
|
61
|
+
|
|
62
|
+
builder.use FlowChat::Whatsapp::Middleware::ChoiceMapper
|
|
63
|
+
FlowChat.logger.debug { "CloudApi: Added Whatsapp::Middleware::ChoiceMapper" }
|
|
64
|
+
end
|
|
65
|
+
|
|
51
66
|
private
|
|
52
67
|
|
|
53
68
|
def determine_message_handler(context)
|
|
54
|
-
#
|
|
69
|
+
# Use simulator mode if enabled, otherwise always use inline
|
|
55
70
|
if context["simulator_mode"]
|
|
56
71
|
FlowChat.logger.debug { "CloudApi: Using simulator message handler" }
|
|
57
|
-
|
|
72
|
+
:simulator
|
|
73
|
+
else
|
|
74
|
+
FlowChat.logger.debug { "CloudApi: Using inline message handler" }
|
|
75
|
+
:inline
|
|
58
76
|
end
|
|
59
|
-
|
|
60
|
-
# Use global WhatsApp configuration
|
|
61
|
-
mode = FlowChat::Config.whatsapp.message_handling_mode
|
|
62
|
-
FlowChat.logger.debug { "CloudApi: Using #{mode} message handling mode" }
|
|
63
|
-
mode
|
|
64
77
|
end
|
|
65
78
|
|
|
66
79
|
def handle_verification(context)
|
|
67
|
-
|
|
68
|
-
params = controller.request.params
|
|
80
|
+
params = @controller.request.params
|
|
69
81
|
|
|
70
82
|
verify_token = @config.verify_token
|
|
71
83
|
provided_token = params["hub.verify_token"]
|
|
@@ -80,7 +92,7 @@ module FlowChat
|
|
|
80
92
|
platform: :whatsapp
|
|
81
93
|
})
|
|
82
94
|
|
|
83
|
-
controller.render plain: challenge
|
|
95
|
+
@controller.render plain: challenge
|
|
84
96
|
else
|
|
85
97
|
# Use instrumentation for webhook verification failure
|
|
86
98
|
instrument(Events::WEBHOOK_FAILED, {
|
|
@@ -88,20 +100,18 @@ module FlowChat
|
|
|
88
100
|
platform: :whatsapp
|
|
89
101
|
})
|
|
90
102
|
|
|
91
|
-
controller.head :forbidden
|
|
103
|
+
@controller.head :forbidden
|
|
92
104
|
end
|
|
93
105
|
end
|
|
94
106
|
|
|
95
107
|
def handle_webhook(context)
|
|
96
|
-
controller = context.controller
|
|
97
|
-
|
|
98
108
|
# Parse body
|
|
99
109
|
begin
|
|
100
|
-
parse_request_body(controller.request)
|
|
110
|
+
parse_request_body(@controller.request)
|
|
101
111
|
FlowChat.logger.debug { "CloudApi: Successfully parsed webhook request body" }
|
|
102
112
|
rescue JSON::ParserError => e
|
|
103
113
|
FlowChat.logger.error { "CloudApi: Failed to parse webhook body: #{e.message}" }
|
|
104
|
-
return controller.head :bad_request
|
|
114
|
+
return @controller.head :bad_request
|
|
105
115
|
end
|
|
106
116
|
|
|
107
117
|
# Check for simulator mode parameter in request (before validation)
|
|
@@ -112,10 +122,11 @@ module FlowChat
|
|
|
112
122
|
context["simulator_mode"] = true
|
|
113
123
|
end
|
|
114
124
|
|
|
115
|
-
# Validate webhook signature for security (skip for simulator mode)
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
125
|
+
# Validate webhook signature for security (skip for simulator mode and background)
|
|
126
|
+
# Return 200 OK even for invalid signatures to prevent WhatsApp from retrying
|
|
127
|
+
unless in_background? || is_simulator_mode || valid_webhook_signature?(@controller.request)
|
|
128
|
+
FlowChat.logger.warn { "CloudApi: Invalid webhook signature - dropping request" }
|
|
129
|
+
return @controller.head :ok
|
|
119
130
|
end
|
|
120
131
|
|
|
121
132
|
FlowChat.logger.debug { "CloudApi: Webhook signature validation passed" }
|
|
@@ -124,19 +135,19 @@ module FlowChat
|
|
|
124
135
|
entry = @body.dig("entry", 0)
|
|
125
136
|
unless entry
|
|
126
137
|
FlowChat.logger.debug { "CloudApi: No entry found in webhook body - returning OK" }
|
|
127
|
-
return controller.head :ok
|
|
138
|
+
return @controller.head :ok
|
|
128
139
|
end
|
|
129
140
|
|
|
130
141
|
changes = entry.dig("changes", 0)
|
|
131
142
|
unless changes
|
|
132
143
|
FlowChat.logger.debug { "CloudApi: No changes found in webhook entry - returning OK" }
|
|
133
|
-
return controller.head :ok
|
|
144
|
+
return @controller.head :ok
|
|
134
145
|
end
|
|
135
146
|
|
|
136
147
|
value = changes["value"]
|
|
137
148
|
unless value
|
|
138
149
|
FlowChat.logger.debug { "CloudApi: No value found in webhook changes - returning OK" }
|
|
139
|
-
return controller.head :ok
|
|
150
|
+
return @controller.head :ok
|
|
140
151
|
end
|
|
141
152
|
|
|
142
153
|
# Handle incoming messages
|
|
@@ -147,15 +158,28 @@ module FlowChat
|
|
|
147
158
|
phone_number = FlowChat::PhoneNumberUtil.to_e164(message["from"])
|
|
148
159
|
message_id = message["id"]
|
|
149
160
|
contact_name = contact&.dig("profile", "name")
|
|
161
|
+
business_phone_number = value.dig("metadata", "display_phone_number")
|
|
162
|
+
business_phone_number_id = value.dig("metadata", "phone_number_id")
|
|
163
|
+
|
|
164
|
+
# Validate that webhook is for our configured phone number
|
|
165
|
+
if business_phone_number_id != @config.phone_number_id
|
|
166
|
+
FlowChat.logger.warn { "CloudApi: Webhook received for phone_number_id '#{business_phone_number_id}' but configured for '#{@config.phone_number_id}' - rejecting" }
|
|
167
|
+
return @controller.head :forbidden
|
|
168
|
+
end
|
|
150
169
|
|
|
151
170
|
context["request.id"] = phone_number
|
|
171
|
+
context["request.user_id"] = phone_number
|
|
172
|
+
context["request.user_name"] = contact_name if contact_name
|
|
152
173
|
context["request.msisdn"] = phone_number
|
|
153
|
-
context["request.user_id"] = context["request.msisdn"]
|
|
154
174
|
context["request.gateway"] = :whatsapp_cloud_api
|
|
155
175
|
context["request.platform"] = :whatsapp
|
|
156
176
|
context["request.message_id"] = message_id
|
|
157
|
-
context["request.
|
|
158
|
-
context["request.
|
|
177
|
+
context["request.timestamp"] = Time.current.iso8601
|
|
178
|
+
context["request.body"] = @body
|
|
179
|
+
|
|
180
|
+
context["whatsapp.business.phone_number"] = FlowChat::PhoneNumberUtil.to_e164(business_phone_number)
|
|
181
|
+
context["whatsapp.business.phone_number_id"] = business_phone_number_id
|
|
182
|
+
context["whatsapp.client"] = @client
|
|
159
183
|
|
|
160
184
|
# Extract message content based on type
|
|
161
185
|
extract_message_content!(message, context)
|
|
@@ -166,25 +190,30 @@ module FlowChat
|
|
|
166
190
|
from: phone_number,
|
|
167
191
|
message: context.input,
|
|
168
192
|
message_type: message["type"],
|
|
169
|
-
message_id: message_id
|
|
193
|
+
message_id: message_id
|
|
170
194
|
})
|
|
171
195
|
end
|
|
172
196
|
|
|
173
|
-
|
|
174
197
|
FlowChat.logger.debug { "CloudApi: Message content extracted - Type: #{message["type"]}, Input: '#{context.input}'" }
|
|
175
198
|
|
|
176
|
-
# Determine
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
#
|
|
187
|
-
|
|
199
|
+
# Determine routing: async enqueue, background execute, or inline
|
|
200
|
+
if should_enqueue_async?
|
|
201
|
+
# Webhook with async enabled → enqueue job and return immediately
|
|
202
|
+
enqueue_async_job
|
|
203
|
+
return @controller.head :ok
|
|
204
|
+
else
|
|
205
|
+
# Background OR inline → process message
|
|
206
|
+
# Determine message handling mode (simulator vs inline)
|
|
207
|
+
handler_mode = determine_message_handler(context)
|
|
208
|
+
|
|
209
|
+
# Process the message based on handling mode
|
|
210
|
+
case handler_mode
|
|
211
|
+
when :inline
|
|
212
|
+
handle_message_inline(context, @controller)
|
|
213
|
+
when :simulator
|
|
214
|
+
# Return early from simulator mode to preserve the JSON response
|
|
215
|
+
return handle_message_simulator(context, @controller)
|
|
216
|
+
end
|
|
188
217
|
end
|
|
189
218
|
end
|
|
190
219
|
|
|
@@ -195,7 +224,7 @@ module FlowChat
|
|
|
195
224
|
FlowChat.logger.debug { "CloudApi: Status updates: #{statuses.inspect}" }
|
|
196
225
|
end
|
|
197
226
|
|
|
198
|
-
controller.head :ok
|
|
227
|
+
@controller.head :ok
|
|
199
228
|
end
|
|
200
229
|
|
|
201
230
|
# Validate webhook signature to ensure request comes from WhatsApp
|
|
@@ -269,7 +298,7 @@ module FlowChat
|
|
|
269
298
|
case message_type
|
|
270
299
|
when "text"
|
|
271
300
|
content = message.dig("text", "body")
|
|
272
|
-
context.input = content
|
|
301
|
+
context.input = content.presence || ""
|
|
273
302
|
FlowChat.logger.debug { "CloudApi: Text message content: '#{content}'" }
|
|
274
303
|
when "interactive"
|
|
275
304
|
# Handle button/list replies
|
|
@@ -290,57 +319,57 @@ module FlowChat
|
|
|
290
319
|
address: message.dig("location", "address")
|
|
291
320
|
}
|
|
292
321
|
context["request.location"] = location
|
|
293
|
-
context.input =
|
|
322
|
+
context.input = FlowChat::Input::LOCATION
|
|
294
323
|
FlowChat.logger.debug { "CloudApi: Location received - Lat: #{location[:latitude]}, Lng: #{location[:longitude]}" }
|
|
295
|
-
when "image", "document", "audio", "video"
|
|
324
|
+
when "image", "document", "audio", "video", "sticker"
|
|
325
|
+
media_data = message[message["type"]]
|
|
296
326
|
context["request.media"] = {
|
|
297
|
-
type: message["type"],
|
|
298
|
-
id:
|
|
299
|
-
mime_type:
|
|
300
|
-
caption:
|
|
327
|
+
type: message["type"].to_sym,
|
|
328
|
+
id: media_data["id"],
|
|
329
|
+
mime_type: media_data["mime_type"],
|
|
330
|
+
caption: media_data["caption"],
|
|
331
|
+
filename: media_data["filename"],
|
|
332
|
+
sha256: media_data["sha256"],
|
|
333
|
+
animated: media_data["animated"]
|
|
301
334
|
}
|
|
302
|
-
context.input =
|
|
335
|
+
context.input = FlowChat::Input::MEDIA
|
|
336
|
+
FlowChat.logger.debug { "CloudApi: Media received - Type: #{message["type"]}, ID: #{media_data["id"]}" }
|
|
337
|
+
when "contacts"
|
|
338
|
+
# WhatsApp sends contacts as an array, take the first one
|
|
339
|
+
contact_data = message.dig("contacts", 0)
|
|
340
|
+
if contact_data
|
|
341
|
+
phones = contact_data.dig("phones") || []
|
|
342
|
+
context["request.contact"] = {
|
|
343
|
+
name: contact_data.dig("name", "formatted_name"),
|
|
344
|
+
first_name: contact_data.dig("name", "first_name"),
|
|
345
|
+
last_name: contact_data.dig("name", "last_name"),
|
|
346
|
+
phones: phones.map { |p| p["phone"] },
|
|
347
|
+
phone_number: phones.first&.dig("phone")
|
|
348
|
+
}
|
|
349
|
+
context.input = FlowChat::Input::CONTACT
|
|
350
|
+
FlowChat.logger.debug { "CloudApi: Contact received - Name: #{context["request.contact"][:name]}" }
|
|
351
|
+
end
|
|
303
352
|
end
|
|
304
353
|
end
|
|
305
354
|
|
|
306
355
|
def handle_message_inline(context, controller)
|
|
307
356
|
response = @app.call(context)
|
|
308
357
|
if response
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
result = @client.send_message(context["request.msisdn"], rendered_message)
|
|
358
|
+
type, prompt, choices, media = response
|
|
359
|
+
result = @client.send_message(context["request.msisdn"], prompt, choices: choices, media: media)
|
|
312
360
|
context["whatsapp.message_result"] = result
|
|
313
|
-
end
|
|
314
|
-
end
|
|
315
361
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
response: rendered_message,
|
|
328
|
-
config_name: @config.name
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
# Get job class from configuration
|
|
332
|
-
job_class_name = FlowChat::Config.whatsapp.background_job_class
|
|
333
|
-
|
|
334
|
-
# Enqueue background job for sending only
|
|
335
|
-
begin
|
|
336
|
-
job_class = job_class_name.constantize
|
|
337
|
-
job_class.perform_later(send_data)
|
|
338
|
-
rescue NameError
|
|
339
|
-
# Fallback to inline sending if no job system
|
|
340
|
-
Rails.logger.warn "Background mode requested but no #{job_class_name} found. Falling back to inline sending."
|
|
341
|
-
result = @client.send_message(context["request.msisdn"], rendered_message)
|
|
342
|
-
context["whatsapp.message_result"] = result
|
|
343
|
-
end
|
|
362
|
+
# Instrument message sent
|
|
363
|
+
instrument(Events::MESSAGE_SENT, {
|
|
364
|
+
to: context["request.msisdn"],
|
|
365
|
+
session_id: context["request.id"],
|
|
366
|
+
message: prompt,
|
|
367
|
+
message_type: (type == :prompt) ? "prompt" : "terminal",
|
|
368
|
+
gateway: :whatsapp_cloud_api,
|
|
369
|
+
platform: :whatsapp,
|
|
370
|
+
content_length: prompt.to_s.length,
|
|
371
|
+
timestamp: context["request.timestamp"]
|
|
372
|
+
})
|
|
344
373
|
end
|
|
345
374
|
end
|
|
346
375
|
|
|
@@ -348,12 +377,12 @@ module FlowChat
|
|
|
348
377
|
response = @app.call(context)
|
|
349
378
|
|
|
350
379
|
if response
|
|
351
|
-
|
|
352
|
-
|
|
380
|
+
_, prompt, choices, media = response
|
|
381
|
+
response_data = render_response(prompt, choices, media)
|
|
353
382
|
|
|
354
383
|
# For simulator mode, return the response data in the HTTP response
|
|
355
384
|
# instead of actually sending via WhatsApp API
|
|
356
|
-
message_payload = @client.build_message_payload(
|
|
385
|
+
message_payload = @client.build_message_payload(response_data, context["request.msisdn"])
|
|
357
386
|
|
|
358
387
|
simulator_response = {
|
|
359
388
|
mode: "simulator",
|
|
@@ -361,7 +390,7 @@ module FlowChat
|
|
|
361
390
|
would_send: message_payload,
|
|
362
391
|
message_info: {
|
|
363
392
|
to: context["request.msisdn"],
|
|
364
|
-
contact_name: context["request.
|
|
393
|
+
contact_name: context["request.user_name"],
|
|
365
394
|
timestamp: Time.now.iso8601
|
|
366
395
|
}
|
|
367
396
|
}
|
|
@@ -384,8 +413,7 @@ module FlowChat
|
|
|
384
413
|
return false unless simulator_secret && !simulator_secret.empty?
|
|
385
414
|
|
|
386
415
|
# Check for simulator cookie
|
|
387
|
-
|
|
388
|
-
simulator_cookie = request.cookies["flowchat_simulator"]
|
|
416
|
+
simulator_cookie = @controller.request.cookies["flowchat_simulator"]
|
|
389
417
|
return false unless simulator_cookie
|
|
390
418
|
|
|
391
419
|
# Verify the cookie is a valid HMAC signature
|
|
@@ -412,7 +440,15 @@ module FlowChat
|
|
|
412
440
|
end
|
|
413
441
|
|
|
414
442
|
def parse_request_body(request)
|
|
415
|
-
@body
|
|
443
|
+
return @body if @body
|
|
444
|
+
|
|
445
|
+
if request.body.nil?
|
|
446
|
+
FlowChat.logger.debug { "CloudApi: Request body is nil, returning empty hash" }
|
|
447
|
+
@body = {}
|
|
448
|
+
else
|
|
449
|
+
request.body.rewind if request.body.respond_to?(:rewind)
|
|
450
|
+
@body = JSON.parse(request.body.read)
|
|
451
|
+
end
|
|
416
452
|
end
|
|
417
453
|
|
|
418
454
|
def render_response(prompt, choices, media)
|