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
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
require "net/http"
|
|
2
2
|
require "json"
|
|
3
|
-
require "openssl"
|
|
4
3
|
|
|
5
4
|
module FlowChat
|
|
6
5
|
module Whatsapp
|
|
7
|
-
# Configuration-related errors
|
|
8
|
-
class ConfigurationError < StandardError; end
|
|
9
|
-
|
|
10
6
|
module Gateway
|
|
11
7
|
class CloudApi
|
|
12
8
|
include FlowChat::Instrumentation
|
|
9
|
+
include FlowChat::GatewayAsyncSupport
|
|
10
|
+
include FlowChat::Meta::SignatureValidation
|
|
11
|
+
include FlowChat::Meta::WebhookVerification
|
|
13
12
|
|
|
14
13
|
attr_reader :context
|
|
15
14
|
|
|
@@ -24,84 +23,82 @@ module FlowChat
|
|
|
24
23
|
|
|
25
24
|
def call(context)
|
|
26
25
|
@context = context
|
|
27
|
-
controller = context.controller
|
|
28
|
-
request = controller.request
|
|
26
|
+
@controller = context.controller
|
|
27
|
+
request = @controller.request
|
|
29
28
|
|
|
30
29
|
FlowChat.logger.debug { "CloudApi: Processing #{request.request_method} request to #{request.path}" }
|
|
31
30
|
|
|
32
|
-
#
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
31
|
+
# Skip webhook-specific handling in background mode
|
|
32
|
+
unless in_background?
|
|
33
|
+
# Handle webhook verification
|
|
34
|
+
if request.get? && request.params["hub.mode"] == "subscribe"
|
|
35
|
+
FlowChat.logger.info { "CloudApi: Handling webhook verification request" }
|
|
36
|
+
return handle_verification(context)
|
|
37
|
+
end
|
|
36
38
|
end
|
|
37
39
|
|
|
38
40
|
# Handle webhook messages
|
|
39
41
|
if request.post?
|
|
40
|
-
FlowChat.logger.info { "CloudApi: Handling webhook message" }
|
|
42
|
+
FlowChat.logger.info { "CloudApi: Handling webhook message (background: #{in_background?})" }
|
|
41
43
|
return handle_webhook(context)
|
|
42
44
|
end
|
|
43
45
|
|
|
44
46
|
FlowChat.logger.warn { "CloudApi: Invalid request method or parameters - returning bad request" }
|
|
45
|
-
controller.head :bad_request
|
|
47
|
+
@controller.head :bad_request
|
|
46
48
|
end
|
|
47
49
|
|
|
48
50
|
# Expose client for out-of-band messaging
|
|
49
51
|
attr_reader :client
|
|
50
52
|
|
|
51
|
-
|
|
53
|
+
# Configure WhatsApp-specific middleware stack
|
|
54
|
+
def self.configure_middleware_stack(builder, custom_middleware)
|
|
55
|
+
FlowChat.logger.debug { "CloudApi: Configuring WhatsApp middleware stack" }
|
|
52
56
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
if context["simulator_mode"]
|
|
56
|
-
FlowChat.logger.debug { "CloudApi: Using simulator message handler" }
|
|
57
|
-
return :simulator
|
|
58
|
-
end
|
|
57
|
+
builder.use custom_middleware
|
|
58
|
+
FlowChat.logger.debug { "CloudApi: Added custom middleware" }
|
|
59
59
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
FlowChat.logger.debug { "CloudApi: Using #{mode} message handling mode" }
|
|
63
|
-
mode
|
|
60
|
+
builder.use FlowChat::Whatsapp::Middleware::ChoiceMapper
|
|
61
|
+
FlowChat.logger.debug { "CloudApi: Added Whatsapp::Middleware::ChoiceMapper" }
|
|
64
62
|
end
|
|
65
63
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
64
|
+
# What this gateway is, for the shared Meta:: modules. Public because
|
|
65
|
+
# FlowChat::Meta::GatewayIdentity declares them public: a gateway saying
|
|
66
|
+
# which platform it speaks for is not a secret, unlike how it validates a
|
|
67
|
+
# signature. Keeping them here rather than below `private` means every
|
|
68
|
+
# Meta gateway answers these the same way.
|
|
69
|
+
def platform
|
|
70
|
+
:whatsapp
|
|
71
|
+
end
|
|
69
72
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
+
def platform_label
|
|
74
|
+
"WhatsApp"
|
|
75
|
+
end
|
|
73
76
|
|
|
74
|
-
|
|
77
|
+
def configuration_error_class
|
|
78
|
+
FlowChat::Whatsapp::ConfigurationError
|
|
79
|
+
end
|
|
75
80
|
|
|
76
|
-
|
|
77
|
-
# Use instrumentation for webhook verification success
|
|
78
|
-
instrument(Events::WEBHOOK_VERIFIED, {
|
|
79
|
-
challenge: challenge,
|
|
80
|
-
platform: :whatsapp
|
|
81
|
-
})
|
|
81
|
+
private
|
|
82
82
|
|
|
83
|
-
|
|
83
|
+
def determine_message_handler(context)
|
|
84
|
+
# Use simulator mode if enabled, otherwise always use inline
|
|
85
|
+
if context["simulator_mode"]
|
|
86
|
+
FlowChat.logger.debug { "CloudApi: Using simulator message handler" }
|
|
87
|
+
:simulator
|
|
84
88
|
else
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
reason: "Invalid verify token",
|
|
88
|
-
platform: :whatsapp
|
|
89
|
-
})
|
|
90
|
-
|
|
91
|
-
controller.head :forbidden
|
|
89
|
+
FlowChat.logger.debug { "CloudApi: Using inline message handler" }
|
|
90
|
+
:inline
|
|
92
91
|
end
|
|
93
92
|
end
|
|
94
93
|
|
|
95
94
|
def handle_webhook(context)
|
|
96
|
-
controller = context.controller
|
|
97
|
-
|
|
98
95
|
# Parse body
|
|
99
96
|
begin
|
|
100
|
-
parse_request_body(controller.request)
|
|
97
|
+
parse_request_body(@controller.request)
|
|
101
98
|
FlowChat.logger.debug { "CloudApi: Successfully parsed webhook request body" }
|
|
102
99
|
rescue JSON::ParserError => e
|
|
103
100
|
FlowChat.logger.error { "CloudApi: Failed to parse webhook body: #{e.message}" }
|
|
104
|
-
return controller.head :bad_request
|
|
101
|
+
return @controller.head :bad_request
|
|
105
102
|
end
|
|
106
103
|
|
|
107
104
|
# Check for simulator mode parameter in request (before validation)
|
|
@@ -112,154 +109,241 @@ module FlowChat
|
|
|
112
109
|
context["simulator_mode"] = true
|
|
113
110
|
end
|
|
114
111
|
|
|
115
|
-
# Validate webhook signature for security (skip for simulator mode)
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
112
|
+
# Validate webhook signature for security (skip for simulator mode and background)
|
|
113
|
+
# Return 200 OK even for invalid signatures to prevent WhatsApp from retrying
|
|
114
|
+
unless in_background? || is_simulator_mode || valid_webhook_signature?(@controller.request)
|
|
115
|
+
FlowChat.logger.warn { "CloudApi: Invalid webhook signature - dropping request" }
|
|
116
|
+
return @controller.head :ok
|
|
119
117
|
end
|
|
120
118
|
|
|
121
119
|
FlowChat.logger.debug { "CloudApi: Webhook signature validation passed" }
|
|
122
120
|
|
|
123
|
-
#
|
|
124
|
-
|
|
125
|
-
|
|
121
|
+
# A delivery can carry several accounts and several kinds of change at
|
|
122
|
+
# once, so every entry and every change gets looked at rather than only
|
|
123
|
+
# the first of each.
|
|
124
|
+
entries = @body["entry"]
|
|
125
|
+
unless entries.is_a?(Array) && entries.any?
|
|
126
126
|
FlowChat.logger.debug { "CloudApi: No entry found in webhook body - returning OK" }
|
|
127
|
-
return controller.head :ok
|
|
128
|
-
end
|
|
129
|
-
|
|
130
|
-
changes = entry.dig("changes", 0)
|
|
131
|
-
unless changes
|
|
132
|
-
FlowChat.logger.debug { "CloudApi: No changes found in webhook entry - returning OK" }
|
|
133
|
-
return controller.head :ok
|
|
127
|
+
return @controller.head :ok
|
|
134
128
|
end
|
|
135
129
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
130
|
+
# Only one change per delivery can drive a flow, because only one can own
|
|
131
|
+
# the response to this request.
|
|
132
|
+
flow_ran = false
|
|
133
|
+
|
|
134
|
+
entries.each do |entry|
|
|
135
|
+
changes = entry["changes"]
|
|
136
|
+
next unless changes.is_a?(Array)
|
|
137
|
+
|
|
138
|
+
changes.each do |change|
|
|
139
|
+
value = change["value"]
|
|
140
|
+
next unless value.is_a?(Hash)
|
|
141
|
+
|
|
142
|
+
case webhook_field(change, value)
|
|
143
|
+
when "messages"
|
|
144
|
+
# There is no separate `statuses` field to subscribe to: Meta
|
|
145
|
+
# reports delivery status under `messages` as well, in a change
|
|
146
|
+
# carrying `statuses` and no `messages`. Handled before the flow
|
|
147
|
+
# slot is claimed, or a status arriving ahead of a message in the
|
|
148
|
+
# same delivery would spend the slot and drop the message.
|
|
149
|
+
#
|
|
150
|
+
# Foreground only. With async enabled the job re-enters this
|
|
151
|
+
# method on the same body, so publishing in both announced every
|
|
152
|
+
# status twice and delivery receipts double-counted. The request
|
|
153
|
+
# publishes them: it already holds the delivery, so a receipt is
|
|
154
|
+
# announced when it arrives rather than when the queue reaches
|
|
155
|
+
# it, and survives a job that is never picked up.
|
|
156
|
+
handle_statuses(value) if value["statuses"].present? && !in_background?
|
|
157
|
+
|
|
158
|
+
next if value["messages"].blank?
|
|
159
|
+
|
|
160
|
+
if flow_ran
|
|
161
|
+
FlowChat.logger.warn { "CloudApi: A second messages change arrived in the same delivery and was not processed" }
|
|
162
|
+
next
|
|
163
|
+
end
|
|
164
|
+
flow_ran = true
|
|
165
|
+
|
|
166
|
+
case handle_messages(context, value)
|
|
167
|
+
when :rejected then return @controller.head :forbidden
|
|
168
|
+
when :enqueued then return @controller.head :ok
|
|
169
|
+
when :rendered then return nil # simulator already wrote the response
|
|
170
|
+
end
|
|
171
|
+
when "statuses"
|
|
172
|
+
# Only reachable for a payload built without a field name, which
|
|
173
|
+
# our own fixtures do and Meta does not.
|
|
174
|
+
handle_statuses(value) unless in_background?
|
|
175
|
+
else
|
|
176
|
+
# Anything that is not a message or its delivery. Coexistence
|
|
177
|
+
# echoes, contact syncs, imported history, account bans, template
|
|
178
|
+
# approvals: all of it is the application's domain, so it is
|
|
179
|
+
# published rather than interpreted here.
|
|
180
|
+
handle_unmodelled_field(change["field"], value, entry["id"]) unless in_background?
|
|
181
|
+
end
|
|
171
182
|
end
|
|
183
|
+
end
|
|
172
184
|
|
|
185
|
+
@controller.head :ok
|
|
186
|
+
end
|
|
173
187
|
|
|
174
|
-
|
|
188
|
+
# Meta names the change in `field`. Older payloads, and the ones our own
|
|
189
|
+
# tests build, leave it out, so fall back to what the value carries.
|
|
190
|
+
def webhook_field(change, value)
|
|
191
|
+
return change["field"] if change["field"].present?
|
|
192
|
+
return "messages" if value["messages"].is_a?(Array) && value["messages"].any?
|
|
193
|
+
return "statuses" if value["statuses"].is_a?(Array) && value["statuses"].any?
|
|
175
194
|
|
|
176
|
-
|
|
177
|
-
|
|
195
|
+
nil
|
|
196
|
+
end
|
|
178
197
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
198
|
+
# Returns what happened, so the caller can decide whether it still owns the
|
|
199
|
+
# response: :rejected, :enqueued, :rendered, :processed or :skipped.
|
|
200
|
+
def handle_messages(context, value)
|
|
201
|
+
message = value["messages"]&.first
|
|
202
|
+
return :skipped unless message
|
|
203
|
+
|
|
204
|
+
contact = value["contacts"]&.first
|
|
205
|
+
|
|
206
|
+
phone_number = FlowChat::PhoneNumberUtil.to_e164(message["from"])
|
|
207
|
+
message_id = message["id"]
|
|
208
|
+
contact_name = contact&.dig("profile", "name")
|
|
209
|
+
business_phone_number = value.dig("metadata", "display_phone_number")
|
|
210
|
+
business_phone_number_id = value.dig("metadata", "phone_number_id")
|
|
211
|
+
|
|
212
|
+
# Validate that webhook is for our configured phone number. Skipped
|
|
213
|
+
# in simulator mode rather than requiring the simulator to send an
|
|
214
|
+
# id that matches a real configuration: the simulator's whole
|
|
215
|
+
# point is running a turn with no live credentials at hand. Safe
|
|
216
|
+
# to skip, not a hole for real traffic, because
|
|
217
|
+
# context["simulator_mode"] is only ever true once simulate? has
|
|
218
|
+
# already checked the signed simulator cookie, above in
|
|
219
|
+
# handle_webhook.
|
|
220
|
+
if !context["simulator_mode"] && business_phone_number_id != @config.phone_number_id
|
|
221
|
+
FlowChat.logger.warn { "CloudApi: Webhook received for phone_number_id '#{business_phone_number_id}' but configured for '#{@config.phone_number_id}' - rejecting" }
|
|
222
|
+
return :rejected
|
|
189
223
|
end
|
|
190
224
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
225
|
+
context["request.id"] = phone_number
|
|
226
|
+
context["request.user_id"] = phone_number
|
|
227
|
+
context["request.user_name"] = contact_name if contact_name
|
|
228
|
+
context["request.msisdn"] = phone_number
|
|
229
|
+
context["request.gateway"] = :whatsapp_cloud_api
|
|
230
|
+
context["request.platform"] = :whatsapp
|
|
231
|
+
context["request.message_id"] = message_id
|
|
232
|
+
context["request.timestamp"] = Time.current.iso8601
|
|
233
|
+
context["request.body"] = @body
|
|
234
|
+
|
|
235
|
+
context["whatsapp.business.phone_number"] = FlowChat::PhoneNumberUtil.to_e164(business_phone_number)
|
|
236
|
+
context["whatsapp.business.phone_number_id"] = business_phone_number_id
|
|
237
|
+
context["whatsapp.client"] = @client
|
|
238
|
+
|
|
239
|
+
# Extract message content based on type
|
|
240
|
+
extract_message_content!(message, context)
|
|
241
|
+
|
|
242
|
+
if inbound_message?(context)
|
|
243
|
+
# Use instrumentation for message received
|
|
244
|
+
instrument(Events::MESSAGE_RECEIVED, {
|
|
245
|
+
from: phone_number,
|
|
246
|
+
message: context.input,
|
|
247
|
+
message_type: message["type"],
|
|
248
|
+
message_id: message_id
|
|
249
|
+
})
|
|
196
250
|
end
|
|
197
251
|
|
|
198
|
-
|
|
199
|
-
end
|
|
200
|
-
|
|
201
|
-
# Validate webhook signature to ensure request comes from WhatsApp
|
|
202
|
-
def valid_webhook_signature?(request)
|
|
203
|
-
# Check if signature validation is explicitly disabled
|
|
204
|
-
if @config.skip_signature_validation
|
|
205
|
-
FlowChat.logger.debug { "CloudApi: Webhook signature validation is disabled" }
|
|
206
|
-
return true
|
|
207
|
-
end
|
|
252
|
+
FlowChat.logger.debug { "CloudApi: Message content extracted - Type: #{message["type"]}, Input: '#{context.input}'" }
|
|
208
253
|
|
|
209
|
-
#
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
raise FlowChat::Whatsapp::ConfigurationError, error_msg
|
|
254
|
+
# Determine routing: async enqueue, background execute, or inline
|
|
255
|
+
if should_enqueue_async?
|
|
256
|
+
# Webhook with async enabled → enqueue job and return immediately
|
|
257
|
+
enqueue_async_job
|
|
258
|
+
return :enqueued
|
|
215
259
|
end
|
|
216
260
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
261
|
+
# Background OR inline → process message
|
|
262
|
+
case determine_message_handler(context)
|
|
263
|
+
when :inline
|
|
264
|
+
handle_message_inline(context, @controller)
|
|
265
|
+
:processed
|
|
266
|
+
when :simulator
|
|
267
|
+
handle_message_simulator(context, @controller)
|
|
268
|
+
:rendered
|
|
221
269
|
end
|
|
270
|
+
end
|
|
222
271
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
272
|
+
def handle_statuses(value)
|
|
273
|
+
statuses = value["statuses"]
|
|
274
|
+
return if statuses.blank?
|
|
275
|
+
|
|
276
|
+
FlowChat.logger.info { "CloudApi: Received #{statuses.size} status update(s)" }
|
|
277
|
+
FlowChat.logger.debug { "CloudApi: Status updates: #{statuses.inspect}" }
|
|
278
|
+
|
|
279
|
+
statuses.each do |status|
|
|
280
|
+
instrument(Events::MESSAGE_STATUS, {
|
|
281
|
+
platform: :whatsapp,
|
|
282
|
+
gateway: :whatsapp_cloud_api,
|
|
283
|
+
business_phone_number_id: value.dig("metadata", "phone_number_id"),
|
|
284
|
+
message_id: status["id"],
|
|
285
|
+
recipient: status["recipient_id"],
|
|
286
|
+
status: status["status"],
|
|
287
|
+
timestamp: status["timestamp"],
|
|
288
|
+
errors: status["errors"],
|
|
289
|
+
# Meta reports more about a delivery than a status and a time: what
|
|
290
|
+
# it billed the conversation as, and its own view of the window.
|
|
291
|
+
# None of it is this gem's business to interpret, and all of it is
|
|
292
|
+
# gone if the named keys are the only way through.
|
|
293
|
+
value: status
|
|
294
|
+
})
|
|
245
295
|
end
|
|
246
|
-
|
|
247
|
-
signature_valid
|
|
248
|
-
rescue FlowChat::Whatsapp::ConfigurationError
|
|
249
|
-
raise
|
|
250
|
-
rescue => e
|
|
251
|
-
FlowChat.logger.error { "CloudApi: Error validating webhook signature: #{e.class.name}: #{e.message}" }
|
|
252
|
-
false
|
|
253
296
|
end
|
|
254
297
|
|
|
255
|
-
#
|
|
256
|
-
|
|
257
|
-
|
|
298
|
+
# Everything that is not a message or its delivery. Verified, named, and
|
|
299
|
+
# handed on whole for the application to make sense of.
|
|
300
|
+
#
|
|
301
|
+
# The account id comes from the entry rather than the value because a change
|
|
302
|
+
# about the account itself names no phone number: a ban, a review outcome, a
|
|
303
|
+
# template approval. Without it those arrive identifying nothing, and an
|
|
304
|
+
# application holding several businesses cannot tell whose they are.
|
|
305
|
+
def handle_unmodelled_field(field, value, business_account_id)
|
|
306
|
+
FlowChat.logger.info {
|
|
307
|
+
"CloudApi: Publishing webhook field '#{field}' (value keys: #{value.keys.join(", ")})"
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
payload = {
|
|
311
|
+
platform: :whatsapp,
|
|
312
|
+
gateway: :whatsapp_cloud_api,
|
|
313
|
+
field: field,
|
|
314
|
+
business_account_id: business_account_id,
|
|
315
|
+
business_phone_number: value.dig("metadata", "display_phone_number"),
|
|
316
|
+
business_phone_number_id: value.dig("metadata", "phone_number_id"),
|
|
317
|
+
value: value
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
origin = echo_origin(field, value)
|
|
321
|
+
payload[:echo_origin] = origin if origin
|
|
322
|
+
|
|
323
|
+
instrument(Events::WEBHOOK_RECEIVED, payload)
|
|
324
|
+
end
|
|
258
325
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
326
|
+
# An echo reports a message sent on the thread by someone other than the
|
|
327
|
+
# user we are talking to. Which someone matters: a human replying from the
|
|
328
|
+
# business inbox usually means the application should stand the flow
|
|
329
|
+
# down, while our own send coming back means nothing at all. Only the
|
|
330
|
+
# app_id separates them, and only this gateway knows our own, so it is
|
|
331
|
+
# derived here rather than left for every subscriber to work out.
|
|
332
|
+
#
|
|
333
|
+
# Meta names the coexistence echo field "smb_message_echoes" and nests the
|
|
334
|
+
# echoes themselves under "message_echoes"; both are matched precisely
|
|
335
|
+
# rather than by a loose "echo" substring or "first array in the value",
|
|
336
|
+
# since a field this gateway does not otherwise interpret could carry
|
|
337
|
+
# other arrays under other names.
|
|
338
|
+
def echo_origin(field, value)
|
|
339
|
+
return nil unless field == "smb_message_echoes"
|
|
340
|
+
|
|
341
|
+
app_id = value["message_echoes"]&.first&.dig("app_id")
|
|
342
|
+
|
|
343
|
+
return :human_agent if app_id.blank?
|
|
344
|
+
return :self if app_id.to_s == @config.app_id.to_s
|
|
345
|
+
|
|
346
|
+
:other_app
|
|
263
347
|
end
|
|
264
348
|
|
|
265
349
|
def extract_message_content!(message, context)
|
|
@@ -269,7 +353,7 @@ module FlowChat
|
|
|
269
353
|
case message_type
|
|
270
354
|
when "text"
|
|
271
355
|
content = message.dig("text", "body")
|
|
272
|
-
context.input = content
|
|
356
|
+
context.input = content.presence || ""
|
|
273
357
|
FlowChat.logger.debug { "CloudApi: Text message content: '#{content}'" }
|
|
274
358
|
when "interactive"
|
|
275
359
|
# Handle button/list replies
|
|
@@ -290,70 +374,96 @@ module FlowChat
|
|
|
290
374
|
address: message.dig("location", "address")
|
|
291
375
|
}
|
|
292
376
|
context["request.location"] = location
|
|
293
|
-
context.input = "
|
|
377
|
+
context.input = ""
|
|
294
378
|
FlowChat.logger.debug { "CloudApi: Location received - Lat: #{location[:latitude]}, Lng: #{location[:longitude]}" }
|
|
295
|
-
when "image", "document", "audio", "video"
|
|
379
|
+
when "image", "document", "audio", "video", "sticker"
|
|
380
|
+
media_data = message[message["type"]]
|
|
296
381
|
context["request.media"] = {
|
|
297
|
-
type: message["type"],
|
|
298
|
-
id:
|
|
299
|
-
mime_type:
|
|
300
|
-
caption:
|
|
382
|
+
type: message["type"].to_sym,
|
|
383
|
+
id: media_data["id"],
|
|
384
|
+
mime_type: media_data["mime_type"],
|
|
385
|
+
caption: media_data["caption"],
|
|
386
|
+
filename: media_data["filename"],
|
|
387
|
+
sha256: media_data["sha256"],
|
|
388
|
+
animated: media_data["animated"]
|
|
301
389
|
}
|
|
302
|
-
|
|
390
|
+
# The caption (if any) is the turn's text; a text-less media message has blank input.
|
|
391
|
+
context.input = media_data["caption"].presence || ""
|
|
392
|
+
FlowChat.logger.debug { "CloudApi: Media received - Type: #{message["type"]}, ID: #{media_data["id"]}" }
|
|
393
|
+
when "contacts"
|
|
394
|
+
# WhatsApp sends contacts as an array, take the first one
|
|
395
|
+
contact_data = message.dig("contacts", 0)
|
|
396
|
+
if contact_data
|
|
397
|
+
phones = contact_data.dig("phones") || []
|
|
398
|
+
context["request.contact"] = {
|
|
399
|
+
name: contact_data.dig("name", "formatted_name"),
|
|
400
|
+
first_name: contact_data.dig("name", "first_name"),
|
|
401
|
+
last_name: contact_data.dig("name", "last_name"),
|
|
402
|
+
phones: phones.map { |p| p["phone"] },
|
|
403
|
+
phone_number: phones.first&.dig("phone")
|
|
404
|
+
}
|
|
405
|
+
context.input = ""
|
|
406
|
+
FlowChat.logger.debug { "CloudApi: Contact received - Name: #{context["request.contact"][:name]}" }
|
|
407
|
+
end
|
|
303
408
|
end
|
|
304
409
|
end
|
|
305
410
|
|
|
306
411
|
def handle_message_inline(context, controller)
|
|
307
412
|
response = @app.call(context)
|
|
308
413
|
if response
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
414
|
+
type, prompt, choices, media = response
|
|
415
|
+
result = report_delivery_failure(
|
|
416
|
+
context,
|
|
417
|
+
to: context["request.msisdn"],
|
|
418
|
+
session_id: context["request.id"],
|
|
419
|
+
message: prompt,
|
|
420
|
+
message_type: (type == :prompt) ? "prompt" : "terminal",
|
|
421
|
+
gateway: :whatsapp_cloud_api,
|
|
422
|
+
platform: :whatsapp
|
|
423
|
+
) do
|
|
424
|
+
@client.send_message(context["request.msisdn"], prompt, choices: choices, media: media)
|
|
425
|
+
end
|
|
312
426
|
context["whatsapp.message_result"] = result
|
|
427
|
+
|
|
428
|
+
# report_delivery_failure already reported this; a nil result
|
|
429
|
+
# means the platform did not accept the message, and instrumenting
|
|
430
|
+
# MESSAGE_SENT anyway counted a delivery that never happened.
|
|
431
|
+
return unless result
|
|
432
|
+
|
|
433
|
+
# Instrument message sent
|
|
434
|
+
instrument(Events::MESSAGE_SENT, {
|
|
435
|
+
to: context["request.msisdn"],
|
|
436
|
+
session_id: context["request.id"],
|
|
437
|
+
message: prompt,
|
|
438
|
+
message_type: (type == :prompt) ? "prompt" : "terminal",
|
|
439
|
+
gateway: :whatsapp_cloud_api,
|
|
440
|
+
platform: :whatsapp,
|
|
441
|
+
content_length: prompt.to_s.length,
|
|
442
|
+
# What Meta called it, so this and message.status can be joined.
|
|
443
|
+
platform_message_id: platform_message_id_from(result),
|
|
444
|
+
duration_ms: context[FlowChat::Instrumentation::DELIVERY_DURATION_KEY],
|
|
445
|
+
timestamp: context["request.timestamp"]
|
|
446
|
+
})
|
|
313
447
|
end
|
|
314
448
|
end
|
|
315
449
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
if response
|
|
321
|
-
_type, prompt, choices, media = response
|
|
322
|
-
rendered_message = render_response(prompt, choices, media)
|
|
323
|
-
|
|
324
|
-
# Queue only the response delivery asynchronously
|
|
325
|
-
send_data = {
|
|
326
|
-
msisdn: context["request.msisdn"],
|
|
327
|
-
response: rendered_message,
|
|
328
|
-
config_name: @config.name
|
|
329
|
-
}
|
|
450
|
+
# Meta answers a send with the ids it assigned.
|
|
451
|
+
def platform_message_id_from(result)
|
|
452
|
+
return nil unless result.is_a?(Hash)
|
|
330
453
|
|
|
331
|
-
|
|
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
|
|
344
|
-
end
|
|
454
|
+
result.dig("messages", 0, "id")
|
|
345
455
|
end
|
|
346
456
|
|
|
347
457
|
def handle_message_simulator(context, controller)
|
|
348
458
|
response = @app.call(context)
|
|
349
459
|
|
|
350
460
|
if response
|
|
351
|
-
|
|
352
|
-
|
|
461
|
+
_, prompt, choices, media = response
|
|
462
|
+
response_data = render_response(prompt, choices, media)
|
|
353
463
|
|
|
354
464
|
# For simulator mode, return the response data in the HTTP response
|
|
355
465
|
# instead of actually sending via WhatsApp API
|
|
356
|
-
message_payload = @client.build_message_payload(
|
|
466
|
+
message_payload = @client.build_message_payload(response_data, context["request.msisdn"])
|
|
357
467
|
|
|
358
468
|
simulator_response = {
|
|
359
469
|
mode: "simulator",
|
|
@@ -361,7 +471,7 @@ module FlowChat
|
|
|
361
471
|
would_send: message_payload,
|
|
362
472
|
message_info: {
|
|
363
473
|
to: context["request.msisdn"],
|
|
364
|
-
contact_name: context["request.
|
|
474
|
+
contact_name: context["request.user_name"],
|
|
365
475
|
timestamp: Time.now.iso8601
|
|
366
476
|
}
|
|
367
477
|
}
|
|
@@ -375,46 +485,23 @@ module FlowChat
|
|
|
375
485
|
# Check if simulator mode is enabled for this processor
|
|
376
486
|
return false unless context["enable_simulator"]
|
|
377
487
|
|
|
378
|
-
# Then check if simulator mode is requested and
|
|
379
|
-
@body.dig("simulator_mode") &&
|
|
488
|
+
# Then check if simulator mode is requested and authorized
|
|
489
|
+
@body.dig("simulator_mode") &&
|
|
490
|
+
FlowChat::Security.valid_simulator_cookie?(@controller.request.cookies[FlowChat::Security::SIMULATOR_COOKIE_NAME])
|
|
380
491
|
end
|
|
381
492
|
|
|
382
|
-
def
|
|
383
|
-
|
|
384
|
-
return false unless simulator_secret && !simulator_secret.empty?
|
|
385
|
-
|
|
386
|
-
# Check for simulator cookie
|
|
387
|
-
request = context.controller.request
|
|
388
|
-
simulator_cookie = request.cookies["flowchat_simulator"]
|
|
389
|
-
return false unless simulator_cookie
|
|
493
|
+
def parse_request_body(request)
|
|
494
|
+
return @body if @body
|
|
390
495
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
# Check timestamp is recent (within 24 hours for reasonable session duration)
|
|
398
|
-
timestamp = timestamp_str.to_i
|
|
399
|
-
return false if timestamp <= 0
|
|
400
|
-
return false if (Time.now.to_i - timestamp).abs > 86400 # 24 hours
|
|
401
|
-
|
|
402
|
-
# Calculate expected signature
|
|
403
|
-
message = "simulator:#{timestamp_str}"
|
|
404
|
-
expected_signature = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), simulator_secret, message)
|
|
405
|
-
|
|
406
|
-
# Use secure comparison
|
|
407
|
-
secure_compare(signature, expected_signature)
|
|
408
|
-
rescue => e
|
|
409
|
-
Rails.logger.warn "Invalid simulator cookie format: #{e.message}"
|
|
410
|
-
false
|
|
496
|
+
if request.body.nil?
|
|
497
|
+
FlowChat.logger.debug { "CloudApi: Request body is nil, returning empty hash" }
|
|
498
|
+
@body = {}
|
|
499
|
+
else
|
|
500
|
+
request.body.rewind if request.body.respond_to?(:rewind)
|
|
501
|
+
@body = JSON.parse(request.body.read)
|
|
411
502
|
end
|
|
412
503
|
end
|
|
413
504
|
|
|
414
|
-
def parse_request_body(request)
|
|
415
|
-
@body ||= JSON.parse(request.body.read)
|
|
416
|
-
end
|
|
417
|
-
|
|
418
505
|
def render_response(prompt, choices, media)
|
|
419
506
|
FlowChat::Whatsapp::Renderer.new(prompt, choices: choices, media: media).render
|
|
420
507
|
end
|