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
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module FlowChat
|
|
4
|
+
module Meta
|
|
5
|
+
# The Messenger Platform envelope, shared by Facebook Messenger and
|
|
6
|
+
# Instagram DMs. Both deliver entry[].messaging[] and both send through the
|
|
7
|
+
# same Send API, so the envelope is implemented once and each platform
|
|
8
|
+
# supplies only what actually differs.
|
|
9
|
+
class MessagingGateway
|
|
10
|
+
include FlowChat::Instrumentation
|
|
11
|
+
include FlowChat::GatewayAsyncSupport
|
|
12
|
+
include FlowChat::Meta::SignatureValidation
|
|
13
|
+
include FlowChat::Meta::WebhookVerification
|
|
14
|
+
|
|
15
|
+
attr_reader :context, :client
|
|
16
|
+
|
|
17
|
+
def initialize(app, config = nil)
|
|
18
|
+
@app = app
|
|
19
|
+
@config = config || configuration_class.from_credentials
|
|
20
|
+
@client = client_class.new(@config)
|
|
21
|
+
|
|
22
|
+
FlowChat.logger.info { "#{log_tag}: Initialized #{platform} gateway for account #{@config.account_id}" }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def call(context)
|
|
26
|
+
@context = context
|
|
27
|
+
@controller = context.controller
|
|
28
|
+
request = @controller.request
|
|
29
|
+
|
|
30
|
+
unless in_background?
|
|
31
|
+
if request.get? && request.params["hub.mode"] == "subscribe"
|
|
32
|
+
return handle_verification(context)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
return handle_webhook(context) if request.post?
|
|
37
|
+
|
|
38
|
+
FlowChat.logger.warn { "#{log_tag}: Invalid request method or parameters - returning bad request" }
|
|
39
|
+
@controller.head :bad_request
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.configure_middleware_stack(builder, custom_middleware)
|
|
43
|
+
builder.use custom_middleware
|
|
44
|
+
builder.use choice_mapper_class
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# --- Hooks each platform overrides ---
|
|
48
|
+
|
|
49
|
+
def gateway_name
|
|
50
|
+
raise NotImplementedError
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def configuration_class
|
|
54
|
+
raise NotImplementedError
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def client_class
|
|
58
|
+
raise NotImplementedError
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def renderer_class
|
|
62
|
+
raise NotImplementedError
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Meta names the subscription this delivery came from. Messenger uses
|
|
66
|
+
# "page". Instagram's value depends on how the app is set up, so each
|
|
67
|
+
# platform states its own rather than sharing a guess.
|
|
68
|
+
def expected_webhook_object
|
|
69
|
+
"page"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def handle_webhook(context)
|
|
75
|
+
begin
|
|
76
|
+
parse_request_body(@controller.request)
|
|
77
|
+
rescue JSON::ParserError => e
|
|
78
|
+
FlowChat.logger.error { "#{log_tag}: Failed to parse webhook body: #{e.message}" }
|
|
79
|
+
return @controller.head :bad_request
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
is_simulator_mode = simulate?(context)
|
|
83
|
+
context["simulator_mode"] = true if is_simulator_mode
|
|
84
|
+
|
|
85
|
+
unless in_background? || is_simulator_mode || valid_webhook_signature?(@controller.request)
|
|
86
|
+
FlowChat.logger.warn { "#{log_tag}: Invalid webhook signature - dropping request" }
|
|
87
|
+
return @controller.head :ok
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Warn rather than debug. A gateway whose expected_webhook_object does not
|
|
91
|
+
# match what the app is actually subscribed to drops every delivery here
|
|
92
|
+
# and answers 200, so the symptom is a bot that receives nothing while the
|
|
93
|
+
# dashboard reports successful deliveries. That is worth a line in
|
|
94
|
+
# production logs, not one only visible at debug level.
|
|
95
|
+
if @body["object"].present? && @body["object"] != expected_webhook_object
|
|
96
|
+
FlowChat.logger.warn {
|
|
97
|
+
"#{log_tag}: Ignoring webhook for object '#{@body["object"]}', expected " \
|
|
98
|
+
"'#{expected_webhook_object}'. If every delivery lands here, this gateway's " \
|
|
99
|
+
"expected_webhook_object does not match the app's webhook subscription."
|
|
100
|
+
}
|
|
101
|
+
return @controller.head :ok
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
entries = @body["entry"]
|
|
105
|
+
unless entries.is_a?(Array) && entries.any?
|
|
106
|
+
return @controller.head :ok
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Only one event per delivery can drive a flow, because only one can own
|
|
110
|
+
# the response to this request.
|
|
111
|
+
flow_ran = false
|
|
112
|
+
|
|
113
|
+
entries.each do |entry|
|
|
114
|
+
# Published in the foreground only, and always - whether this turn
|
|
115
|
+
# goes on to run a flow inline or hand one to a background job.
|
|
116
|
+
#
|
|
117
|
+
# With async enabled the job re-enters this method on the same body,
|
|
118
|
+
# so publishing here as well as there announced every receipt, echo
|
|
119
|
+
# and standby event twice: once by the request, once by the job.
|
|
120
|
+
# Skipping them in the background leaves exactly one publisher, and
|
|
121
|
+
# leaves it the one that is already holding the delivery - so a
|
|
122
|
+
# receipt is announced when it arrives rather than whenever the queue
|
|
123
|
+
# gets to it, and survives a job that is never picked up.
|
|
124
|
+
#
|
|
125
|
+
# Nothing changes for an app that does not use async: it never runs
|
|
126
|
+
# in the background, so it takes this branch every time.
|
|
127
|
+
publish_side_events(entry) unless in_background?
|
|
128
|
+
|
|
129
|
+
events = entry["messaging"]
|
|
130
|
+
next unless events.is_a?(Array)
|
|
131
|
+
|
|
132
|
+
events.each do |event|
|
|
133
|
+
# Only a message or a postback drives a flow, which also rules out
|
|
134
|
+
# a receipt: it carries neither. An echo is the one thing that has
|
|
135
|
+
# to be named, because it does carry a message and would otherwise
|
|
136
|
+
# run a flow against our own outbound reply.
|
|
137
|
+
next unless drives_flow?(event)
|
|
138
|
+
next if echo?(event)
|
|
139
|
+
|
|
140
|
+
if flow_ran
|
|
141
|
+
FlowChat.logger.warn { "#{log_tag}: A second message arrived in the same delivery and was not processed" }
|
|
142
|
+
next
|
|
143
|
+
end
|
|
144
|
+
flow_ran = true
|
|
145
|
+
|
|
146
|
+
case handle_message(context, entry, event)
|
|
147
|
+
when :rejected then return @controller.head :forbidden
|
|
148
|
+
when :enqueued then return @controller.head :ok
|
|
149
|
+
when :rendered then return nil
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
@controller.head :ok
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Everything in a delivery that is not the one event driving a flow.
|
|
158
|
+
#
|
|
159
|
+
# Standby carries the same events for a thread another app owns, which is
|
|
160
|
+
# what a secondary receiver sees under the handover protocol. It is
|
|
161
|
+
# published whole and never run: a flow answering here would be talking
|
|
162
|
+
# over whoever Meta handed the thread to, and the send would be refused in
|
|
163
|
+
# any case. Whether to record it, and what to do about it, is the
|
|
164
|
+
# application's to decide.
|
|
165
|
+
#
|
|
166
|
+
# Nothing has to be subscribed for standby to arrive. A delivery switches
|
|
167
|
+
# to it the moment a business names another app the primary receiver, on
|
|
168
|
+
# the same subscription that was already in place.
|
|
169
|
+
def publish_side_events(entry)
|
|
170
|
+
Array(entry["standby"]).each do |event|
|
|
171
|
+
publish_standby(entry, event) if event.is_a?(Hash)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
events = entry["messaging"]
|
|
175
|
+
return unless events.is_a?(Array)
|
|
176
|
+
|
|
177
|
+
# Receipts first, as they always were: a receipt arriving ahead of a
|
|
178
|
+
# message in the same batch must not be read as the message.
|
|
179
|
+
events.each { |event| handle_status(entry, event) if status_event?(event) }
|
|
180
|
+
|
|
181
|
+
events.each do |event|
|
|
182
|
+
next if status_event?(event)
|
|
183
|
+
|
|
184
|
+
if echo?(event)
|
|
185
|
+
publish_echo(entry, event)
|
|
186
|
+
elsif !drives_flow?(event)
|
|
187
|
+
publish_unmodelled(entry, event)
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def status_event?(event)
|
|
193
|
+
event.key?("delivery") || event.key?("read")
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def echo?(event)
|
|
197
|
+
event.dig("message", "is_echo") == true
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def drives_flow?(event)
|
|
201
|
+
event.key?("message") || event.key?("postback")
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def handle_message(context, entry, event)
|
|
205
|
+
account_id = entry["id"]
|
|
206
|
+
|
|
207
|
+
# Skipped in simulator mode rather than requiring the simulator to
|
|
208
|
+
# send an id that matches a real configuration: the simulator's
|
|
209
|
+
# whole point is running a turn with no live credentials at hand.
|
|
210
|
+
# Safe to skip, not a hole for real traffic, because
|
|
211
|
+
# context["simulator_mode"] is only ever true once simulate? has
|
|
212
|
+
# already checked the signed simulator cookie, above in
|
|
213
|
+
# handle_webhook.
|
|
214
|
+
expected = @config.webhook_account_id
|
|
215
|
+
ours = expected.present? && account_id.to_s == expected.to_s
|
|
216
|
+
|
|
217
|
+
unless context["simulator_mode"] || ours
|
|
218
|
+
FlowChat.logger.warn { "#{log_tag}: Webhook for account '#{account_id}' but configured for #{expected.inspect} - rejecting" }
|
|
219
|
+
return :rejected
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
sender_id = event.dig("sender", "id")
|
|
223
|
+
message = event["message"] || event["postback"]
|
|
224
|
+
|
|
225
|
+
context["request.id"] = sender_id
|
|
226
|
+
context["request.user_id"] = sender_id
|
|
227
|
+
context["request.msisdn"] = nil
|
|
228
|
+
context["request.message_id"] = message["mid"]
|
|
229
|
+
context["request.gateway"] = gateway_name
|
|
230
|
+
context["request.platform"] = platform
|
|
231
|
+
context["request.timestamp"] = Time.current.iso8601
|
|
232
|
+
context["request.body"] = @body
|
|
233
|
+
|
|
234
|
+
context["#{platform}.account.id"] = account_id
|
|
235
|
+
context["#{platform}.client"] = @client
|
|
236
|
+
|
|
237
|
+
extract_message_content!(event, context)
|
|
238
|
+
|
|
239
|
+
instrument(FlowChat::Instrumentation::Events::MESSAGE_RECEIVED, {
|
|
240
|
+
from: sender_id,
|
|
241
|
+
message: context.input,
|
|
242
|
+
message_type: event.key?("postback") ? "postback" : "message",
|
|
243
|
+
message_id: message["mid"]
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
if should_enqueue_async?
|
|
247
|
+
enqueue_async_job
|
|
248
|
+
return :enqueued
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
if context["simulator_mode"]
|
|
252
|
+
handle_message_simulator(context)
|
|
253
|
+
:rendered
|
|
254
|
+
else
|
|
255
|
+
handle_message_inline(context)
|
|
256
|
+
:processed
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# A postback's payload, a quick reply's payload, and otherwise the text.
|
|
261
|
+
# An attachment-only turn has blank input, matching the media contract the
|
|
262
|
+
# other gateways follow.
|
|
263
|
+
def extract_message_content!(event, context)
|
|
264
|
+
if event.key?("postback")
|
|
265
|
+
context.input = event.dig("postback", "payload").to_s
|
|
266
|
+
return
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
message = event["message"]
|
|
270
|
+
|
|
271
|
+
if message["quick_reply"]
|
|
272
|
+
context.input = message.dig("quick_reply", "payload").to_s
|
|
273
|
+
return
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
attachments = message["attachments"]
|
|
277
|
+
if attachments.is_a?(Array) && attachments.any?
|
|
278
|
+
attachment = attachments.first
|
|
279
|
+
|
|
280
|
+
# Meta delivers a shared location as an attachment like any image, but
|
|
281
|
+
# every other gateway here puts one on request.location, and a flow
|
|
282
|
+
# reading app.location should not have to know which platform it is on.
|
|
283
|
+
if attachment["type"].to_s == "location"
|
|
284
|
+
coordinates = attachment.dig("payload", "coordinates") || {}
|
|
285
|
+
context["request.location"] = {
|
|
286
|
+
latitude: coordinates["lat"],
|
|
287
|
+
longitude: coordinates["long"],
|
|
288
|
+
name: attachment["title"]
|
|
289
|
+
}.compact
|
|
290
|
+
else
|
|
291
|
+
context["request.media"] = {
|
|
292
|
+
type: normalize_attachment_type(attachment["type"]),
|
|
293
|
+
url: attachment.dig("payload", "url")
|
|
294
|
+
}
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
context.input = message["text"].presence || ""
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# "file" is Meta's name for what every other gateway here calls a document.
|
|
302
|
+
def normalize_attachment_type(type)
|
|
303
|
+
case type.to_s
|
|
304
|
+
when "file" then :document
|
|
305
|
+
when "" then nil
|
|
306
|
+
else type.to_s.to_sym
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def handle_status(entry, event)
|
|
311
|
+
%w[delivery read].each do |kind|
|
|
312
|
+
payload = event[kind]
|
|
313
|
+
next unless payload
|
|
314
|
+
|
|
315
|
+
instrument(FlowChat::Instrumentation::Events::MESSAGE_STATUS, {
|
|
316
|
+
platform: platform,
|
|
317
|
+
gateway: gateway_name,
|
|
318
|
+
account_id: entry["id"],
|
|
319
|
+
recipient: event.dig("sender", "id"),
|
|
320
|
+
status: kind,
|
|
321
|
+
timestamp: event["timestamp"],
|
|
322
|
+
value: payload
|
|
323
|
+
})
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# An echo reports a message sent on this thread by someone other than the
|
|
328
|
+
# user. Which someone decides what the application does about it: a human
|
|
329
|
+
# replying from the page inbox usually means the flow should stand down.
|
|
330
|
+
def publish_echo(entry, event)
|
|
331
|
+
instrument(FlowChat::Instrumentation::Events::WEBHOOK_RECEIVED, {
|
|
332
|
+
platform: platform,
|
|
333
|
+
gateway: gateway_name,
|
|
334
|
+
field: "message_echoes",
|
|
335
|
+
account_id: entry["id"],
|
|
336
|
+
echo_origin: echo_origin(event),
|
|
337
|
+
value: event
|
|
338
|
+
})
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# Named for the channel rather than the event, because that is the part
|
|
342
|
+
# the application cannot work out for itself: a standby message looks
|
|
343
|
+
# exactly like an owned one, and only where it arrived says otherwise.
|
|
344
|
+
def publish_standby(entry, event)
|
|
345
|
+
FlowChat.logger.info { "#{log_tag}: Publishing standby event for account #{entry["id"]}" }
|
|
346
|
+
|
|
347
|
+
instrument(FlowChat::Instrumentation::Events::WEBHOOK_RECEIVED, {
|
|
348
|
+
platform: platform,
|
|
349
|
+
gateway: gateway_name,
|
|
350
|
+
field: "standby",
|
|
351
|
+
account_id: entry["id"],
|
|
352
|
+
value: event
|
|
353
|
+
})
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def echo_origin(event)
|
|
357
|
+
app_id = event.dig("message", "app_id")
|
|
358
|
+
|
|
359
|
+
return :human_agent if app_id.blank?
|
|
360
|
+
return :self if app_id.to_s == @config.app_id.to_s
|
|
361
|
+
|
|
362
|
+
:other_app
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
# Everything that is not a message, its receipt, or an echo. Reactions,
|
|
366
|
+
# referrals, opt-ins, handovers, policy enforcement: all of it is the
|
|
367
|
+
# application's domain, so it is published whole rather than interpreted.
|
|
368
|
+
def publish_unmodelled(entry, event)
|
|
369
|
+
field = (event.keys - %w[sender recipient timestamp]).first
|
|
370
|
+
|
|
371
|
+
FlowChat.logger.info { "#{log_tag}: Publishing webhook event '#{field}'" }
|
|
372
|
+
|
|
373
|
+
instrument(FlowChat::Instrumentation::Events::WEBHOOK_RECEIVED, {
|
|
374
|
+
platform: platform,
|
|
375
|
+
gateway: gateway_name,
|
|
376
|
+
field: field,
|
|
377
|
+
account_id: entry["id"],
|
|
378
|
+
value: event
|
|
379
|
+
})
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def handle_message_inline(context)
|
|
383
|
+
response = @app.call(context)
|
|
384
|
+
return unless response
|
|
385
|
+
|
|
386
|
+
type, prompt, choices, media = response
|
|
387
|
+
|
|
388
|
+
result = report_delivery_failure(
|
|
389
|
+
context,
|
|
390
|
+
to: context["request.user_id"],
|
|
391
|
+
session_id: context["request.id"],
|
|
392
|
+
message: prompt,
|
|
393
|
+
message_type: (type == :prompt) ? "prompt" : "terminal",
|
|
394
|
+
gateway: gateway_name,
|
|
395
|
+
platform: platform
|
|
396
|
+
) do
|
|
397
|
+
@client.send_message(context["request.user_id"], prompt, choices: choices, media: media)
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
context["#{platform}.message_result"] = result
|
|
401
|
+
|
|
402
|
+
# report_delivery_failure already reported this; a nil result here
|
|
403
|
+
# means the platform did not accept the message, and instrumenting
|
|
404
|
+
# MESSAGE_SENT anyway counted a delivery that never happened.
|
|
405
|
+
return unless result
|
|
406
|
+
|
|
407
|
+
instrument(FlowChat::Instrumentation::Events::MESSAGE_SENT, {
|
|
408
|
+
to: context["request.user_id"],
|
|
409
|
+
session_id: context["request.id"],
|
|
410
|
+
message: prompt,
|
|
411
|
+
message_type: (type == :prompt) ? "prompt" : "terminal",
|
|
412
|
+
gateway: gateway_name,
|
|
413
|
+
platform: platform,
|
|
414
|
+
content_length: prompt.to_s.length,
|
|
415
|
+
platform_message_id: platform_message_id_from(result),
|
|
416
|
+
duration_ms: context[FlowChat::Instrumentation::DELIVERY_DURATION_KEY],
|
|
417
|
+
timestamp: context["request.timestamp"]
|
|
418
|
+
})
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
# The Send API answers with the id it assigned, flatter than WhatsApp's
|
|
422
|
+
# messages[0].id.
|
|
423
|
+
def platform_message_id_from(result)
|
|
424
|
+
return nil unless result.is_a?(Hash)
|
|
425
|
+
|
|
426
|
+
result["message_id"]
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def handle_message_simulator(context)
|
|
430
|
+
response = @app.call(context)
|
|
431
|
+
return unless response
|
|
432
|
+
|
|
433
|
+
_, prompt, choices, media = response
|
|
434
|
+
rendered = renderer_class.new(prompt, choices: choices, media: media).render
|
|
435
|
+
|
|
436
|
+
@controller.render json: {
|
|
437
|
+
mode: "simulator",
|
|
438
|
+
webhook_processed: true,
|
|
439
|
+
would_send: rendered,
|
|
440
|
+
message_info: {
|
|
441
|
+
to: context["request.user_id"],
|
|
442
|
+
timestamp: Time.now.iso8601
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
nil
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
def simulate?(context)
|
|
450
|
+
return false unless context["enable_simulator"]
|
|
451
|
+
|
|
452
|
+
@body.dig("simulator_mode") &&
|
|
453
|
+
FlowChat::Security.valid_simulator_cookie?(@controller.request.cookies[FlowChat::Security::SIMULATOR_COOKIE_NAME])
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
def parse_request_body(request)
|
|
457
|
+
return @body if @body
|
|
458
|
+
|
|
459
|
+
@body = if request.body.nil?
|
|
460
|
+
{}
|
|
461
|
+
else
|
|
462
|
+
request.body.rewind if request.body.respond_to?(:rewind)
|
|
463
|
+
JSON.parse(request.body.read)
|
|
464
|
+
end
|
|
465
|
+
end
|
|
466
|
+
end
|
|
467
|
+
end
|
|
468
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
require "openssl"
|
|
2
|
+
|
|
3
|
+
module FlowChat
|
|
4
|
+
module Meta
|
|
5
|
+
# Whether a body carries Meta's signature for a given secret.
|
|
6
|
+
#
|
|
7
|
+
# The decision on its own, with no configuration, logging or gateway around
|
|
8
|
+
# it. Meta signs every product's webhook the same way, and an application
|
|
9
|
+
# that receives one outside a gateway needs this answer without the rest:
|
|
10
|
+
# a single endpoint serving several tenants has to verify the delivery
|
|
11
|
+
# before it can know whose it is, which is before it has a gateway to ask.
|
|
12
|
+
#
|
|
13
|
+
# SignatureValidation is the gateway's way in, and calls this.
|
|
14
|
+
module Signature
|
|
15
|
+
HEADER = "X-Hub-Signature-256"
|
|
16
|
+
|
|
17
|
+
# Total, rather than raising on a missing secret. A caller with no secret
|
|
18
|
+
# configured is not asking a different question; it is asking this one and
|
|
19
|
+
# the answer is no. A gateway that would rather treat that as the
|
|
20
|
+
# developer's mistake checks for it before asking.
|
|
21
|
+
def self.valid?(body, header, secret)
|
|
22
|
+
return false if secret.to_s.strip.empty? || header.to_s.empty?
|
|
23
|
+
|
|
24
|
+
expected = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), secret, body.to_s)
|
|
25
|
+
|
|
26
|
+
FlowChat::Security.secure_compare(header.to_s.delete_prefix("sha256="), expected)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
require "openssl"
|
|
2
|
+
|
|
3
|
+
module FlowChat
|
|
4
|
+
module Meta
|
|
5
|
+
# X-Hub-Signature-256 validation, shared by every Meta webhook gateway.
|
|
6
|
+
#
|
|
7
|
+
# The including gateway must have @config responding to #app_secret and
|
|
8
|
+
# #skip_signature_validation, and must include FlowChat::Meta::GatewayIdentity
|
|
9
|
+
# (directly or via another Meta:: behavior module) to supply platform_label
|
|
10
|
+
# and configuration_error_class.
|
|
11
|
+
#
|
|
12
|
+
# What it adds over FlowChat::Meta::Signature is the gateway's share: the
|
|
13
|
+
# opt out, treating a missing secret as the developer's mistake rather than
|
|
14
|
+
# an answer, reading the body off a Rack request, and the logging. An
|
|
15
|
+
# application holding Meta's webhooks outside a gateway wants Signature.
|
|
16
|
+
module SignatureValidation
|
|
17
|
+
include FlowChat::Meta::GatewayIdentity
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def valid_webhook_signature?(request)
|
|
22
|
+
if @config.skip_signature_validation
|
|
23
|
+
FlowChat.logger.debug { "#{log_tag}: Webhook signature validation is disabled" }
|
|
24
|
+
return true
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Deliberately wider than a nil-or-empty check: a whitespace-only secret is
|
|
28
|
+
# never intentional, and computing an HMAC with it would silently accept
|
|
29
|
+
# traffic under a "secret" that offers no protection.
|
|
30
|
+
if @config.app_secret.blank?
|
|
31
|
+
error_msg = "#{platform_label} app_secret is required for webhook signature validation. " \
|
|
32
|
+
"Either configure app_secret or set skip_signature_validation=true to explicitly disable validation."
|
|
33
|
+
FlowChat.logger.error { "#{log_tag}: #{error_msg}" }
|
|
34
|
+
raise configuration_error_class, error_msg
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
signature_header = request.headers[FlowChat::Meta::Signature::HEADER]
|
|
38
|
+
unless signature_header
|
|
39
|
+
FlowChat.logger.warn { "#{log_tag}: No #{FlowChat::Meta::Signature::HEADER} header found in request" }
|
|
40
|
+
return false
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
request.body.rewind
|
|
44
|
+
body = request.body.read
|
|
45
|
+
request.body.rewind
|
|
46
|
+
|
|
47
|
+
signature_valid = FlowChat::Meta::Signature.valid?(body, signature_header, @config.app_secret)
|
|
48
|
+
|
|
49
|
+
if signature_valid
|
|
50
|
+
FlowChat.logger.debug { "#{log_tag}: Webhook signature validation successful" }
|
|
51
|
+
else
|
|
52
|
+
FlowChat.logger.warn { "#{log_tag}: Webhook signature validation failed - signatures do not match" }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
signature_valid
|
|
56
|
+
rescue => e
|
|
57
|
+
# A misconfiguration is the developer's problem and must not be swallowed
|
|
58
|
+
# into a plain "invalid signature".
|
|
59
|
+
raise if e.is_a?(configuration_error_class)
|
|
60
|
+
|
|
61
|
+
FlowChat.logger.error { "#{log_tag}: Error validating webhook signature: #{e.class.name}: #{e.message}" }
|
|
62
|
+
false
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
module FlowChat
|
|
2
|
+
module Meta
|
|
3
|
+
# The GET handshake Meta performs when a webhook URL is registered.
|
|
4
|
+
#
|
|
5
|
+
# The including gateway must have @config responding to #verify_token,
|
|
6
|
+
# @controller, and must include FlowChat::Meta::GatewayIdentity (directly or
|
|
7
|
+
# via another Meta:: behavior module) to supply #platform.
|
|
8
|
+
#
|
|
9
|
+
# What it adds over FlowChat::Meta::Challenge is the gateway's share:
|
|
10
|
+
# instrumenting the outcome and answering through the controller. An
|
|
11
|
+
# application holding Meta's webhooks outside a gateway wants Challenge.
|
|
12
|
+
module WebhookVerification
|
|
13
|
+
include FlowChat::Meta::GatewayIdentity
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def handle_verification(context)
|
|
18
|
+
params = @controller.request.params
|
|
19
|
+
|
|
20
|
+
challenge = FlowChat::Meta::Challenge.answer(params, @config.verify_token)
|
|
21
|
+
verified = !challenge.nil?
|
|
22
|
+
|
|
23
|
+
FlowChat.logger.debug { "#{log_tag}: Webhook verification - provided token matches: #{verified}" }
|
|
24
|
+
|
|
25
|
+
if verified
|
|
26
|
+
instrument(FlowChat::Instrumentation::Events::WEBHOOK_VERIFIED, {
|
|
27
|
+
challenge: challenge,
|
|
28
|
+
platform: platform
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
@controller.render plain: challenge
|
|
32
|
+
else
|
|
33
|
+
instrument(FlowChat::Instrumentation::Events::WEBHOOK_FAILED, {
|
|
34
|
+
reason: "Invalid verify token",
|
|
35
|
+
platform: platform
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
@controller.head :forbidden
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
module FlowChat
|
|
2
|
+
# The named-configuration registry every platform's Configuration shares.
|
|
3
|
+
#
|
|
4
|
+
# Storage is a class-level ivar on the including class, not a class variable.
|
|
5
|
+
# A @@configurations in a shared module would give every platform one merged
|
|
6
|
+
# registry, so a name registered for Messenger would resolve for WhatsApp.
|
|
7
|
+
module NamedConfiguration
|
|
8
|
+
def self.included(base)
|
|
9
|
+
base.extend(ClassMethods)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
module ClassMethods
|
|
13
|
+
def register(name, config)
|
|
14
|
+
FlowChat.logger.debug { "#{self.name}: Registering configuration '#{name}'" }
|
|
15
|
+
configurations[name.to_sym] = config
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def get(name)
|
|
19
|
+
config = configurations[name.to_sym]
|
|
20
|
+
unless config
|
|
21
|
+
FlowChat.logger.error { "#{self.name}: Configuration '#{name}' not found" }
|
|
22
|
+
raise ArgumentError, "#{configuration_label} configuration '#{name}' not found"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
FlowChat.logger.debug { "#{self.name}: Retrieved configuration '#{name}'" }
|
|
26
|
+
config
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def exists?(name)
|
|
30
|
+
configurations.key?(name.to_sym)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def configuration_names
|
|
34
|
+
configurations.keys
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def clear_all!
|
|
38
|
+
FlowChat.logger.debug { "#{name}: Clearing all registered configurations" }
|
|
39
|
+
configurations.clear
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The platform's name as it appears in the not-found message. Overridden
|
|
43
|
+
# where the constant name and the product name differ, as with WhatsApp.
|
|
44
|
+
def configuration_label
|
|
45
|
+
name.split("::")[-2]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
# The registry itself is not API. It replaced a @@configurations class
|
|
51
|
+
# variable, which was equally internal, and configuration_names is the
|
|
52
|
+
# public way to ask what is registered.
|
|
53
|
+
def configurations
|
|
54
|
+
@configurations ||= {}
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def register_as(name)
|
|
59
|
+
FlowChat.logger.debug { "#{self.class.name}: Registering configuration as '#{name}'" }
|
|
60
|
+
@name = name.to_sym
|
|
61
|
+
self.class.register(@name, self)
|
|
62
|
+
self
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|