flow_chat 0.9.0 → 0.10.1
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/ci.yml +29 -0
- data/.github/workflows/pages.yml +43 -0
- data/.gitignore +4 -1
- data/CHANGELOG.md +49 -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/flow_chat.gemspec +22 -2
- data/gemfiles/rails_main.gemfile +18 -0
- data/lib/flow_chat/app.rb +129 -11
- data/lib/flow_chat/async_job.rb +10 -0
- 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/gateway_async_support.rb +19 -1
- 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 +471 -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 +259 -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/lib/tasks/release.rake +11 -1
- 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 +60 -11
- 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,237 @@ 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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
+
# Announced by whichever pass is first to see the delivery, and
|
|
151
|
+
# only once. With async enabled the gem publishes here, enqueues,
|
|
152
|
+
# and the job re-enters this method on the same body, so
|
|
153
|
+
# publishing in both double-counted every receipt. The job the
|
|
154
|
+
# gem enqueues carries word that they are already out and skips
|
|
155
|
+
# them, which leaves the request publishing: it already holds
|
|
156
|
+
# the delivery, so a receipt is announced when it arrives rather
|
|
157
|
+
# than when the queue reaches it, and survives a job that is
|
|
158
|
+
# never picked up. A background pass with no foreground pass
|
|
159
|
+
# behind it, as an application enqueuing its own jobs has,
|
|
160
|
+
# publishes them itself.
|
|
161
|
+
handle_statuses(value) if value["statuses"].present? && !side_events_already_published?
|
|
162
|
+
|
|
163
|
+
next if value["messages"].blank?
|
|
164
|
+
|
|
165
|
+
if flow_ran
|
|
166
|
+
FlowChat.logger.warn { "CloudApi: A second messages change arrived in the same delivery and was not processed" }
|
|
167
|
+
next
|
|
168
|
+
end
|
|
169
|
+
flow_ran = true
|
|
170
|
+
|
|
171
|
+
case handle_messages(context, value)
|
|
172
|
+
when :rejected then return @controller.head :forbidden
|
|
173
|
+
when :enqueued then return @controller.head :ok
|
|
174
|
+
when :rendered then return nil # simulator already wrote the response
|
|
175
|
+
end
|
|
176
|
+
when "statuses"
|
|
177
|
+
# Only reachable for a payload built without a field name, which
|
|
178
|
+
# our own fixtures do and Meta does not.
|
|
179
|
+
handle_statuses(value) unless side_events_already_published?
|
|
180
|
+
else
|
|
181
|
+
# Anything that is not a message or its delivery. Coexistence
|
|
182
|
+
# echoes, contact syncs, imported history, account bans, template
|
|
183
|
+
# approvals: all of it is the application's domain, so it is
|
|
184
|
+
# published rather than interpreted here.
|
|
185
|
+
handle_unmodelled_field(change["field"], value, entry["id"]) unless side_events_already_published?
|
|
216
186
|
end
|
|
217
187
|
end
|
|
218
188
|
end
|
|
219
189
|
|
|
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
190
|
@controller.head :ok
|
|
228
191
|
end
|
|
229
192
|
|
|
230
|
-
#
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
if
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
193
|
+
# Meta names the change in `field`. Older payloads, and the ones our own
|
|
194
|
+
# tests build, leave it out, so fall back to what the value carries.
|
|
195
|
+
def webhook_field(change, value)
|
|
196
|
+
return change["field"] if change["field"].present?
|
|
197
|
+
return "messages" if value["messages"].is_a?(Array) && value["messages"].any?
|
|
198
|
+
return "statuses" if value["statuses"].is_a?(Array) && value["statuses"].any?
|
|
199
|
+
|
|
200
|
+
nil
|
|
201
|
+
end
|
|
237
202
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
203
|
+
# Returns what happened, so the caller can decide whether it still owns the
|
|
204
|
+
# response: :rejected, :enqueued, :rendered, :processed or :skipped.
|
|
205
|
+
def handle_messages(context, value)
|
|
206
|
+
message = value["messages"]&.first
|
|
207
|
+
return :skipped unless message
|
|
208
|
+
|
|
209
|
+
contact = value["contacts"]&.first
|
|
210
|
+
|
|
211
|
+
phone_number = FlowChat::PhoneNumberUtil.to_e164(message["from"])
|
|
212
|
+
message_id = message["id"]
|
|
213
|
+
contact_name = contact&.dig("profile", "name")
|
|
214
|
+
business_phone_number = value.dig("metadata", "display_phone_number")
|
|
215
|
+
business_phone_number_id = value.dig("metadata", "phone_number_id")
|
|
216
|
+
|
|
217
|
+
# Validate that webhook is for our configured phone number. Skipped
|
|
218
|
+
# in simulator mode rather than requiring the simulator to send an
|
|
219
|
+
# id that matches a real configuration: the simulator's whole
|
|
220
|
+
# point is running a turn with no live credentials at hand. Safe
|
|
221
|
+
# to skip, not a hole for real traffic, because
|
|
222
|
+
# context["simulator_mode"] is only ever true once simulate? has
|
|
223
|
+
# already checked the signed simulator cookie, above in
|
|
224
|
+
# handle_webhook.
|
|
225
|
+
if !context["simulator_mode"] && business_phone_number_id != @config.phone_number_id
|
|
226
|
+
FlowChat.logger.warn { "CloudApi: Webhook received for phone_number_id '#{business_phone_number_id}' but configured for '#{@config.phone_number_id}' - rejecting" }
|
|
227
|
+
return :rejected
|
|
244
228
|
end
|
|
245
229
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
230
|
+
context["request.id"] = phone_number
|
|
231
|
+
context["request.user_id"] = phone_number
|
|
232
|
+
context["request.user_name"] = contact_name if contact_name
|
|
233
|
+
context["request.msisdn"] = phone_number
|
|
234
|
+
context["request.gateway"] = :whatsapp_cloud_api
|
|
235
|
+
context["request.platform"] = :whatsapp
|
|
236
|
+
context["request.message_id"] = message_id
|
|
237
|
+
context["request.timestamp"] = Time.current.iso8601
|
|
238
|
+
context["request.body"] = @body
|
|
239
|
+
|
|
240
|
+
context["whatsapp.business.phone_number"] = FlowChat::PhoneNumberUtil.to_e164(business_phone_number)
|
|
241
|
+
context["whatsapp.business.phone_number_id"] = business_phone_number_id
|
|
242
|
+
context["whatsapp.client"] = @client
|
|
243
|
+
|
|
244
|
+
# Extract message content based on type
|
|
245
|
+
extract_message_content!(message, context)
|
|
246
|
+
|
|
247
|
+
if inbound_message?(context)
|
|
248
|
+
# Use instrumentation for message received
|
|
249
|
+
instrument(Events::MESSAGE_RECEIVED, {
|
|
250
|
+
from: phone_number,
|
|
251
|
+
message: context.input,
|
|
252
|
+
message_type: message["type"],
|
|
253
|
+
message_id: message_id
|
|
254
|
+
})
|
|
250
255
|
end
|
|
251
256
|
|
|
252
|
-
|
|
253
|
-
expected_signature = signature_header.sub("sha256=", "")
|
|
257
|
+
FlowChat.logger.debug { "CloudApi: Message content extracted - Type: #{message["type"]}, Input: '#{context.input}'" }
|
|
254
258
|
|
|
255
|
-
#
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
+
# Determine routing: async enqueue, background execute, or inline
|
|
260
|
+
if should_enqueue_async?
|
|
261
|
+
# Webhook with async enabled → enqueue job and return immediately
|
|
262
|
+
enqueue_async_job
|
|
263
|
+
return :enqueued
|
|
264
|
+
end
|
|
259
265
|
|
|
260
|
-
#
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
@
|
|
264
|
-
|
|
265
|
-
|
|
266
|
+
# Background OR inline → process message
|
|
267
|
+
case determine_message_handler(context)
|
|
268
|
+
when :inline
|
|
269
|
+
handle_message_inline(context, @controller)
|
|
270
|
+
:processed
|
|
271
|
+
when :simulator
|
|
272
|
+
handle_message_simulator(context, @controller)
|
|
273
|
+
:rendered
|
|
274
|
+
end
|
|
275
|
+
end
|
|
266
276
|
|
|
267
|
-
|
|
268
|
-
|
|
277
|
+
def handle_statuses(value)
|
|
278
|
+
statuses = value["statuses"]
|
|
279
|
+
return if statuses.blank?
|
|
269
280
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
else
|
|
273
|
-
FlowChat.logger.warn { "CloudApi: Webhook signature validation failed - signatures do not match" }
|
|
274
|
-
end
|
|
281
|
+
FlowChat.logger.info { "CloudApi: Received #{statuses.size} status update(s)" }
|
|
282
|
+
FlowChat.logger.debug { "CloudApi: Status updates: #{statuses.inspect}" }
|
|
275
283
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
284
|
+
statuses.each do |status|
|
|
285
|
+
instrument(Events::MESSAGE_STATUS, {
|
|
286
|
+
platform: :whatsapp,
|
|
287
|
+
gateway: :whatsapp_cloud_api,
|
|
288
|
+
business_phone_number_id: value.dig("metadata", "phone_number_id"),
|
|
289
|
+
message_id: status["id"],
|
|
290
|
+
recipient: status["recipient_id"],
|
|
291
|
+
status: status["status"],
|
|
292
|
+
timestamp: status["timestamp"],
|
|
293
|
+
errors: status["errors"],
|
|
294
|
+
# Meta reports more about a delivery than a status and a time: what
|
|
295
|
+
# it billed the conversation as, and its own view of the window.
|
|
296
|
+
# None of it is this gem's business to interpret, and all of it is
|
|
297
|
+
# gone if the named keys are the only way through.
|
|
298
|
+
value: status
|
|
299
|
+
})
|
|
300
|
+
end
|
|
282
301
|
end
|
|
283
302
|
|
|
284
|
-
#
|
|
285
|
-
|
|
286
|
-
|
|
303
|
+
# Everything that is not a message or its delivery. Verified, named, and
|
|
304
|
+
# handed on whole for the application to make sense of.
|
|
305
|
+
#
|
|
306
|
+
# The account id comes from the entry rather than the value because a change
|
|
307
|
+
# about the account itself names no phone number: a ban, a review outcome, a
|
|
308
|
+
# template approval. Without it those arrive identifying nothing, and an
|
|
309
|
+
# application holding several businesses cannot tell whose they are.
|
|
310
|
+
def handle_unmodelled_field(field, value, business_account_id)
|
|
311
|
+
FlowChat.logger.info {
|
|
312
|
+
"CloudApi: Publishing webhook field '#{field}' (value keys: #{value.keys.join(", ")})"
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
payload = {
|
|
316
|
+
platform: :whatsapp,
|
|
317
|
+
gateway: :whatsapp_cloud_api,
|
|
318
|
+
field: field,
|
|
319
|
+
business_account_id: business_account_id,
|
|
320
|
+
business_phone_number: value.dig("metadata", "display_phone_number"),
|
|
321
|
+
business_phone_number_id: value.dig("metadata", "phone_number_id"),
|
|
322
|
+
value: value
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
origin = echo_origin(field, value)
|
|
326
|
+
payload[:echo_origin] = origin if origin
|
|
327
|
+
|
|
328
|
+
instrument(Events::WEBHOOK_RECEIVED, payload)
|
|
329
|
+
end
|
|
287
330
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
331
|
+
# An echo reports a message sent on the thread by someone other than the
|
|
332
|
+
# user we are talking to. Which someone matters: a human replying from the
|
|
333
|
+
# business inbox usually means the application should stand the flow
|
|
334
|
+
# down, while our own send coming back means nothing at all. Only the
|
|
335
|
+
# app_id separates them, and only this gateway knows our own, so it is
|
|
336
|
+
# derived here rather than left for every subscriber to work out.
|
|
337
|
+
#
|
|
338
|
+
# Meta names the coexistence echo field "smb_message_echoes" and nests the
|
|
339
|
+
# echoes themselves under "message_echoes"; both are matched precisely
|
|
340
|
+
# rather than by a loose "echo" substring or "first array in the value",
|
|
341
|
+
# since a field this gateway does not otherwise interpret could carry
|
|
342
|
+
# other arrays under other names.
|
|
343
|
+
def echo_origin(field, value)
|
|
344
|
+
return nil unless field == "smb_message_echoes"
|
|
345
|
+
|
|
346
|
+
app_id = value["message_echoes"]&.first&.dig("app_id")
|
|
347
|
+
|
|
348
|
+
return :human_agent if app_id.blank?
|
|
349
|
+
return :self if app_id.to_s == @config.app_id.to_s
|
|
350
|
+
|
|
351
|
+
:other_app
|
|
292
352
|
end
|
|
293
353
|
|
|
294
354
|
def extract_message_content!(message, context)
|
|
@@ -319,7 +379,7 @@ module FlowChat
|
|
|
319
379
|
address: message.dig("location", "address")
|
|
320
380
|
}
|
|
321
381
|
context["request.location"] = location
|
|
322
|
-
context.input =
|
|
382
|
+
context.input = ""
|
|
323
383
|
FlowChat.logger.debug { "CloudApi: Location received - Lat: #{location[:latitude]}, Lng: #{location[:longitude]}" }
|
|
324
384
|
when "image", "document", "audio", "video", "sticker"
|
|
325
385
|
media_data = message[message["type"]]
|
|
@@ -332,7 +392,8 @@ module FlowChat
|
|
|
332
392
|
sha256: media_data["sha256"],
|
|
333
393
|
animated: media_data["animated"]
|
|
334
394
|
}
|
|
335
|
-
|
|
395
|
+
# The caption (if any) is the turn's text; a text-less media message has blank input.
|
|
396
|
+
context.input = media_data["caption"].presence || ""
|
|
336
397
|
FlowChat.logger.debug { "CloudApi: Media received - Type: #{message["type"]}, ID: #{media_data["id"]}" }
|
|
337
398
|
when "contacts"
|
|
338
399
|
# WhatsApp sends contacts as an array, take the first one
|
|
@@ -346,7 +407,7 @@ module FlowChat
|
|
|
346
407
|
phones: phones.map { |p| p["phone"] },
|
|
347
408
|
phone_number: phones.first&.dig("phone")
|
|
348
409
|
}
|
|
349
|
-
context.input =
|
|
410
|
+
context.input = ""
|
|
350
411
|
FlowChat.logger.debug { "CloudApi: Contact received - Name: #{context["request.contact"][:name]}" }
|
|
351
412
|
end
|
|
352
413
|
end
|
|
@@ -356,9 +417,24 @@ module FlowChat
|
|
|
356
417
|
response = @app.call(context)
|
|
357
418
|
if response
|
|
358
419
|
type, prompt, choices, media = response
|
|
359
|
-
result =
|
|
420
|
+
result = report_delivery_failure(
|
|
421
|
+
context,
|
|
422
|
+
to: context["request.msisdn"],
|
|
423
|
+
session_id: context["request.id"],
|
|
424
|
+
message: prompt,
|
|
425
|
+
message_type: (type == :prompt) ? "prompt" : "terminal",
|
|
426
|
+
gateway: :whatsapp_cloud_api,
|
|
427
|
+
platform: :whatsapp
|
|
428
|
+
) do
|
|
429
|
+
@client.send_message(context["request.msisdn"], prompt, choices: choices, media: media)
|
|
430
|
+
end
|
|
360
431
|
context["whatsapp.message_result"] = result
|
|
361
432
|
|
|
433
|
+
# report_delivery_failure already reported this; a nil result
|
|
434
|
+
# means the platform did not accept the message, and instrumenting
|
|
435
|
+
# MESSAGE_SENT anyway counted a delivery that never happened.
|
|
436
|
+
return unless result
|
|
437
|
+
|
|
362
438
|
# Instrument message sent
|
|
363
439
|
instrument(Events::MESSAGE_SENT, {
|
|
364
440
|
to: context["request.msisdn"],
|
|
@@ -368,11 +444,21 @@ module FlowChat
|
|
|
368
444
|
gateway: :whatsapp_cloud_api,
|
|
369
445
|
platform: :whatsapp,
|
|
370
446
|
content_length: prompt.to_s.length,
|
|
447
|
+
# What Meta called it, so this and message.status can be joined.
|
|
448
|
+
platform_message_id: platform_message_id_from(result),
|
|
449
|
+
duration_ms: context[FlowChat::Instrumentation::DELIVERY_DURATION_KEY],
|
|
371
450
|
timestamp: context["request.timestamp"]
|
|
372
451
|
})
|
|
373
452
|
end
|
|
374
453
|
end
|
|
375
454
|
|
|
455
|
+
# Meta answers a send with the ids it assigned.
|
|
456
|
+
def platform_message_id_from(result)
|
|
457
|
+
return nil unless result.is_a?(Hash)
|
|
458
|
+
|
|
459
|
+
result.dig("messages", 0, "id")
|
|
460
|
+
end
|
|
461
|
+
|
|
376
462
|
def handle_message_simulator(context, controller)
|
|
377
463
|
response = @app.call(context)
|
|
378
464
|
|
|
@@ -404,39 +490,9 @@ module FlowChat
|
|
|
404
490
|
# Check if simulator mode is enabled for this processor
|
|
405
491
|
return false unless context["enable_simulator"]
|
|
406
492
|
|
|
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
|
|
493
|
+
# Then check if simulator mode is requested and authorized
|
|
494
|
+
@body.dig("simulator_mode") &&
|
|
495
|
+
FlowChat::Security.valid_simulator_cookie?(@controller.request.cookies[FlowChat::Security::SIMULATOR_COOKIE_NAME])
|
|
440
496
|
end
|
|
441
497
|
|
|
442
498
|
def parse_request_body(request)
|