flow_chat 0.9.0 → 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/.github/workflows/pages.yml +43 -0
- data/CHANGELOG.md +26 -0
- data/README.md +166 -295
- data/Rakefile +12 -1
- data/SECURITY.md +1 -1
- data/docs/architecture.md +52 -479
- data/docs/async-background-processing.md +31 -265
- data/docs/configuration.md +106 -613
- data/docs/factory-pattern.md +27 -324
- data/docs/gateway-context-variables.md +140 -143
- data/docs/gateway-development.md +86 -650
- data/docs/getting-started.md +40 -379
- data/docs/instrumentation.md +88 -279
- data/docs/platforms/instagram.md +278 -0
- data/docs/platforms/messenger.md +205 -0
- data/docs/platforms/telegram.md +47 -951
- data/docs/platforms/ussd.md +38 -653
- data/docs/platforms/whatsapp.md +73 -1321
- data/docs/superpowers/plans/2026-07-09-inbound-media-support.md +732 -0
- data/docs/superpowers/plans/2026-07-09-inbound-media-support.md.tasks.json +58 -0
- data/docs/superpowers/plans/2026-08-10-messenger-instagram.md +4064 -0
- data/docs/superpowers/plans/2026-08-10-messenger-instagram.md.tasks.json +226 -0
- data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md +972 -0
- data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md.tasks.json +88 -0
- data/docs/superpowers/specs/2026-07-09-inbound-media-support-design.md +195 -0
- data/docs/superpowers/specs/2026-08-10-messenger-instagram-design.md +391 -0
- data/docs/testing.md +26 -297
- data/examples/http_controller.rb +12 -10
- data/examples/intercom_configuration_example.rb +19 -24
- data/examples/intercom_controller.rb +8 -20
- data/examples/multi_tenant_whatsapp_controller.rb +61 -166
- data/examples/simulator_controller.rb +0 -1
- data/examples/ussd_controller.rb +86 -158
- data/examples/whatsapp_controller.rb +16 -15
- data/examples/whatsapp_media_examples.rb +27 -79
- data/lib/flow_chat/app.rb +129 -11
- data/lib/flow_chat/choice_titles.rb +95 -0
- data/lib/flow_chat/config.rb +124 -1
- data/lib/flow_chat/delivery_error.rb +9 -0
- data/lib/flow_chat/http/configuration_error.rb +9 -0
- data/lib/flow_chat/http/gateway/simple.rb +24 -4
- data/lib/flow_chat/http/middleware/choice_mapper.rb +94 -0
- data/lib/flow_chat/input.rb +86 -0
- data/lib/flow_chat/instagram/client.rb +32 -0
- data/lib/flow_chat/instagram/configuration.rb +147 -0
- data/lib/flow_chat/instagram/configuration_error.rb +7 -0
- data/lib/flow_chat/instagram/gateway/send_api.rb +63 -0
- data/lib/flow_chat/instagram/middleware/choice_mapper.rb +22 -0
- data/lib/flow_chat/instagram/renderer.rb +23 -0
- data/lib/flow_chat/instrumentation/metrics_collector.rb +6 -1
- data/lib/flow_chat/instrumentation.rb +160 -1
- data/lib/flow_chat/intercom/client.rb +34 -28
- data/lib/flow_chat/intercom/configuration.rb +2 -49
- data/lib/flow_chat/intercom/configuration_error.rb +9 -0
- data/lib/flow_chat/intercom/gateway/intercom_api.rb +81 -57
- data/lib/flow_chat/intercom/middleware/choice_mapper.rb +101 -0
- data/lib/flow_chat/intercom/renderer.rb +57 -5
- data/lib/flow_chat/media.rb +121 -0
- data/lib/flow_chat/messenger/client.rb +264 -0
- data/lib/flow_chat/messenger/configuration.rb +103 -0
- data/lib/flow_chat/messenger/configuration_error.rb +9 -0
- data/lib/flow_chat/messenger/gateway/send_api.rb +42 -0
- data/lib/flow_chat/messenger/middleware/choice_mapper.rb +185 -0
- data/lib/flow_chat/messenger/renderer.rb +150 -0
- data/lib/flow_chat/meta/challenge.rb +24 -0
- data/lib/flow_chat/meta/choice_ladder.rb +37 -0
- data/lib/flow_chat/meta/configuration_error.rb +7 -0
- data/lib/flow_chat/meta/gateway_identity.rb +38 -0
- data/lib/flow_chat/meta/messaging_gateway.rb +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/prompt.rb +13 -16
- data/lib/flow_chat/renderers/markdown_support.rb +109 -0
- data/lib/flow_chat/security.rb +76 -0
- data/lib/flow_chat/session/middleware.rb +11 -2
- data/lib/flow_chat/simulator/controller.rb +31 -15
- data/lib/flow_chat/simulator/views/simulator.html.erb +184 -20
- data/lib/flow_chat/telegram/client.rb +47 -4
- data/lib/flow_chat/telegram/configuration.rb +2 -42
- data/lib/flow_chat/telegram/configuration_error.rb +9 -0
- data/lib/flow_chat/telegram/gateway/bot_api.rb +46 -28
- data/lib/flow_chat/telegram/middleware/choice_mapper.rb +77 -16
- data/lib/flow_chat/telegram/renderer.rb +10 -2
- data/lib/flow_chat/text_truncator.rb +75 -0
- data/lib/flow_chat/ussd/middleware/choice_mapper.rb +10 -0
- data/lib/flow_chat/version.rb +1 -1
- data/lib/flow_chat/whatsapp/client.rb +14 -7
- data/lib/flow_chat/whatsapp/configuration.rb +12 -51
- data/lib/flow_chat/whatsapp/configuration_error.rb +9 -0
- data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +254 -203
- data/lib/flow_chat/whatsapp/middleware/choice_mapper.rb +144 -57
- data/lib/flow_chat/whatsapp/renderer.rb +121 -60
- data/lib/flow_chat.rb +0 -10
- data/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 +51 -3
- data/lib/flow_chat/whatsapp/id_generator.rb +0 -124
|
@@ -1,16 +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
|
|
13
9
|
include FlowChat::GatewayAsyncSupport
|
|
10
|
+
include FlowChat::Meta::SignatureValidation
|
|
11
|
+
include FlowChat::Meta::WebhookVerification
|
|
14
12
|
|
|
15
13
|
attr_reader :context
|
|
16
14
|
|
|
@@ -63,6 +61,23 @@ module FlowChat
|
|
|
63
61
|
FlowChat.logger.debug { "CloudApi: Added Whatsapp::Middleware::ChoiceMapper" }
|
|
64
62
|
end
|
|
65
63
|
|
|
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
|
|
72
|
+
|
|
73
|
+
def platform_label
|
|
74
|
+
"WhatsApp"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def configuration_error_class
|
|
78
|
+
FlowChat::Whatsapp::ConfigurationError
|
|
79
|
+
end
|
|
80
|
+
|
|
66
81
|
private
|
|
67
82
|
|
|
68
83
|
def determine_message_handler(context)
|
|
@@ -76,34 +91,6 @@ module FlowChat
|
|
|
76
91
|
end
|
|
77
92
|
end
|
|
78
93
|
|
|
79
|
-
def handle_verification(context)
|
|
80
|
-
params = @controller.request.params
|
|
81
|
-
|
|
82
|
-
verify_token = @config.verify_token
|
|
83
|
-
provided_token = params["hub.verify_token"]
|
|
84
|
-
challenge = params["hub.challenge"]
|
|
85
|
-
|
|
86
|
-
FlowChat.logger.debug { "CloudApi: Webhook verification - provided token matches: #{provided_token == verify_token}" }
|
|
87
|
-
|
|
88
|
-
if provided_token == verify_token
|
|
89
|
-
# Use instrumentation for webhook verification success
|
|
90
|
-
instrument(Events::WEBHOOK_VERIFIED, {
|
|
91
|
-
challenge: challenge,
|
|
92
|
-
platform: :whatsapp
|
|
93
|
-
})
|
|
94
|
-
|
|
95
|
-
@controller.render plain: challenge
|
|
96
|
-
else
|
|
97
|
-
# Use instrumentation for webhook verification failure
|
|
98
|
-
instrument(Events::WEBHOOK_FAILED, {
|
|
99
|
-
reason: "Invalid verify token",
|
|
100
|
-
platform: :whatsapp
|
|
101
|
-
})
|
|
102
|
-
|
|
103
|
-
@controller.head :forbidden
|
|
104
|
-
end
|
|
105
|
-
end
|
|
106
|
-
|
|
107
94
|
def handle_webhook(context)
|
|
108
95
|
# Parse body
|
|
109
96
|
begin
|
|
@@ -131,164 +118,232 @@ module FlowChat
|
|
|
131
118
|
|
|
132
119
|
FlowChat.logger.debug { "CloudApi: Webhook signature validation passed" }
|
|
133
120
|
|
|
134
|
-
#
|
|
135
|
-
|
|
136
|
-
|
|
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?
|
|
137
126
|
FlowChat.logger.debug { "CloudApi: No entry found in webhook body - returning OK" }
|
|
138
127
|
return @controller.head :ok
|
|
139
128
|
end
|
|
140
129
|
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
message_type: message["type"],
|
|
193
|
-
message_id: message_id
|
|
194
|
-
})
|
|
195
|
-
end
|
|
196
|
-
|
|
197
|
-
FlowChat.logger.debug { "CloudApi: Message content extracted - Type: #{message["type"]}, Input: '#{context.input}'" }
|
|
198
|
-
|
|
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)
|
|
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?
|
|
216
181
|
end
|
|
217
182
|
end
|
|
218
183
|
end
|
|
219
184
|
|
|
220
|
-
# Handle message status updates
|
|
221
|
-
if value["statuses"]&.any?
|
|
222
|
-
statuses = value["statuses"]
|
|
223
|
-
FlowChat.logger.info { "CloudApi: Received #{statuses.size} status update(s)" }
|
|
224
|
-
FlowChat.logger.debug { "CloudApi: Status updates: #{statuses.inspect}" }
|
|
225
|
-
end
|
|
226
|
-
|
|
227
185
|
@controller.head :ok
|
|
228
186
|
end
|
|
229
187
|
|
|
230
|
-
#
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
if
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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?
|
|
194
|
+
|
|
195
|
+
nil
|
|
196
|
+
end
|
|
237
197
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
|
244
223
|
end
|
|
245
224
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
+
})
|
|
250
250
|
end
|
|
251
251
|
|
|
252
|
-
|
|
253
|
-
expected_signature = signature_header.sub("sha256=", "")
|
|
252
|
+
FlowChat.logger.debug { "CloudApi: Message content extracted - Type: #{message["type"]}, Input: '#{context.input}'" }
|
|
254
253
|
|
|
255
|
-
#
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
|
259
|
+
end
|
|
259
260
|
|
|
260
|
-
#
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
@
|
|
264
|
-
|
|
265
|
-
|
|
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
|
|
269
|
+
end
|
|
270
|
+
end
|
|
266
271
|
|
|
267
|
-
|
|
268
|
-
|
|
272
|
+
def handle_statuses(value)
|
|
273
|
+
statuses = value["statuses"]
|
|
274
|
+
return if statuses.blank?
|
|
269
275
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
else
|
|
273
|
-
FlowChat.logger.warn { "CloudApi: Webhook signature validation failed - signatures do not match" }
|
|
274
|
-
end
|
|
276
|
+
FlowChat.logger.info { "CloudApi: Received #{statuses.size} status update(s)" }
|
|
277
|
+
FlowChat.logger.debug { "CloudApi: Status updates: #{statuses.inspect}" }
|
|
275
278
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
+
})
|
|
295
|
+
end
|
|
282
296
|
end
|
|
283
297
|
|
|
284
|
-
#
|
|
285
|
-
|
|
286
|
-
|
|
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
|
|
287
325
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
|
292
347
|
end
|
|
293
348
|
|
|
294
349
|
def extract_message_content!(message, context)
|
|
@@ -319,7 +374,7 @@ module FlowChat
|
|
|
319
374
|
address: message.dig("location", "address")
|
|
320
375
|
}
|
|
321
376
|
context["request.location"] = location
|
|
322
|
-
context.input =
|
|
377
|
+
context.input = ""
|
|
323
378
|
FlowChat.logger.debug { "CloudApi: Location received - Lat: #{location[:latitude]}, Lng: #{location[:longitude]}" }
|
|
324
379
|
when "image", "document", "audio", "video", "sticker"
|
|
325
380
|
media_data = message[message["type"]]
|
|
@@ -332,7 +387,8 @@ module FlowChat
|
|
|
332
387
|
sha256: media_data["sha256"],
|
|
333
388
|
animated: media_data["animated"]
|
|
334
389
|
}
|
|
335
|
-
|
|
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 || ""
|
|
336
392
|
FlowChat.logger.debug { "CloudApi: Media received - Type: #{message["type"]}, ID: #{media_data["id"]}" }
|
|
337
393
|
when "contacts"
|
|
338
394
|
# WhatsApp sends contacts as an array, take the first one
|
|
@@ -346,7 +402,7 @@ module FlowChat
|
|
|
346
402
|
phones: phones.map { |p| p["phone"] },
|
|
347
403
|
phone_number: phones.first&.dig("phone")
|
|
348
404
|
}
|
|
349
|
-
context.input =
|
|
405
|
+
context.input = ""
|
|
350
406
|
FlowChat.logger.debug { "CloudApi: Contact received - Name: #{context["request.contact"][:name]}" }
|
|
351
407
|
end
|
|
352
408
|
end
|
|
@@ -356,9 +412,24 @@ module FlowChat
|
|
|
356
412
|
response = @app.call(context)
|
|
357
413
|
if response
|
|
358
414
|
type, prompt, choices, media = response
|
|
359
|
-
result =
|
|
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
|
|
360
426
|
context["whatsapp.message_result"] = result
|
|
361
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
|
+
|
|
362
433
|
# Instrument message sent
|
|
363
434
|
instrument(Events::MESSAGE_SENT, {
|
|
364
435
|
to: context["request.msisdn"],
|
|
@@ -368,11 +439,21 @@ module FlowChat
|
|
|
368
439
|
gateway: :whatsapp_cloud_api,
|
|
369
440
|
platform: :whatsapp,
|
|
370
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],
|
|
371
445
|
timestamp: context["request.timestamp"]
|
|
372
446
|
})
|
|
373
447
|
end
|
|
374
448
|
end
|
|
375
449
|
|
|
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)
|
|
453
|
+
|
|
454
|
+
result.dig("messages", 0, "id")
|
|
455
|
+
end
|
|
456
|
+
|
|
376
457
|
def handle_message_simulator(context, controller)
|
|
377
458
|
response = @app.call(context)
|
|
378
459
|
|
|
@@ -404,39 +485,9 @@ module FlowChat
|
|
|
404
485
|
# Check if simulator mode is enabled for this processor
|
|
405
486
|
return false unless context["enable_simulator"]
|
|
406
487
|
|
|
407
|
-
# Then check if simulator mode is requested and
|
|
408
|
-
@body.dig("simulator_mode") &&
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
def valid_simulator_cookie?(context)
|
|
412
|
-
simulator_secret = FlowChat::Config.simulator_secret
|
|
413
|
-
return false unless simulator_secret && !simulator_secret.empty?
|
|
414
|
-
|
|
415
|
-
# Check for simulator cookie
|
|
416
|
-
simulator_cookie = @controller.request.cookies["flowchat_simulator"]
|
|
417
|
-
return false unless simulator_cookie
|
|
418
|
-
|
|
419
|
-
# Verify the cookie is a valid HMAC signature
|
|
420
|
-
# Cookie format: "timestamp:signature" where signature = HMAC(simulator_secret, "simulator:timestamp")
|
|
421
|
-
begin
|
|
422
|
-
timestamp_str, signature = simulator_cookie.split(":", 2)
|
|
423
|
-
return false unless timestamp_str && signature
|
|
424
|
-
|
|
425
|
-
# Check timestamp is recent (within 24 hours for reasonable session duration)
|
|
426
|
-
timestamp = timestamp_str.to_i
|
|
427
|
-
return false if timestamp <= 0
|
|
428
|
-
return false if (Time.now.to_i - timestamp).abs > 86400 # 24 hours
|
|
429
|
-
|
|
430
|
-
# Calculate expected signature
|
|
431
|
-
message = "simulator:#{timestamp_str}"
|
|
432
|
-
expected_signature = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), simulator_secret, message)
|
|
433
|
-
|
|
434
|
-
# Use secure comparison
|
|
435
|
-
secure_compare(signature, expected_signature)
|
|
436
|
-
rescue => e
|
|
437
|
-
Rails.logger.warn "Invalid simulator cookie format: #{e.message}"
|
|
438
|
-
false
|
|
439
|
-
end
|
|
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])
|
|
440
491
|
end
|
|
441
492
|
|
|
442
493
|
def parse_request_body(request)
|