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
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "json"
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module FlowChat
|
|
6
|
+
module Messenger
|
|
7
|
+
class Client
|
|
8
|
+
include FlowChat::Instrumentation
|
|
9
|
+
|
|
10
|
+
def initialize(config)
|
|
11
|
+
@config = config
|
|
12
|
+
FlowChat.logger.info { "Messenger::Client: Initialized for account #{@config.account_id}" }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# @param tag [String, nil] a Meta message tag (e.g. "HUMAN_AGENT") that
|
|
16
|
+
# extends the free-form send window beyond 24 hours. The application
|
|
17
|
+
# decides when a send qualifies; this only carries the value through
|
|
18
|
+
# to every part of the send. Passed through unvalidated: Meta accepts
|
|
19
|
+
# only HUMAN_AGENT as of 27 April 2026 and rejects anything else with
|
|
20
|
+
# error 100, clearly enough that an allowlist here would only be one
|
|
21
|
+
# more thing to keep in sync with Meta's own set.
|
|
22
|
+
def send_message(recipient_id, prompt, choices: nil, media: nil, tag: nil)
|
|
23
|
+
response = renderer_class.new(prompt, choices: choices, media: media).render
|
|
24
|
+
type, content, options = response
|
|
25
|
+
|
|
26
|
+
# MESSAGE_SENT is instrumented by the gateway, not here. This wrapped
|
|
27
|
+
# the send in its own instrument block, and ActiveSupport::Notifications
|
|
28
|
+
# publishes a block event once the block returns whatever it returned -
|
|
29
|
+
# so the event fired even when the send had failed and this method was
|
|
30
|
+
# about to answer nil, and fired a second time when the gateway
|
|
31
|
+
# instrumented the same send.
|
|
32
|
+
deliver(recipient_id, type, content, options, tag)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def send_text(recipient_id, text, tag: nil)
|
|
36
|
+
send_message(recipient_id, text, tag: tag)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Shows the person a typing bubble while a turn is being worked out.
|
|
40
|
+
#
|
|
41
|
+
# Meta clears it when the next message arrives or after about twenty
|
|
42
|
+
# seconds, whichever comes first, so a caller holding one open for longer
|
|
43
|
+
# has to repeat it.
|
|
44
|
+
#
|
|
45
|
+
# Inherited by Instagram rather than overridden there. Meta documents
|
|
46
|
+
# sender actions under the Messenger Platform and lists only react and
|
|
47
|
+
# unreact for Instagram, but an Instagram send of typing_on is accepted
|
|
48
|
+
# and answered with the recipient id, same as Messenger. Confirmed
|
|
49
|
+
# against a live account, since the reference does not settle it.
|
|
50
|
+
def indicate_typing(recipient_id)
|
|
51
|
+
post_json(@config.messages_url, {recipient: {id: recipient_id}, sender_action: "typing_on"})
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Uploads a file for reuse and returns the id Meta assigned it.
|
|
55
|
+
def upload_media(url, type: :image)
|
|
56
|
+
payload = {
|
|
57
|
+
message: {
|
|
58
|
+
attachment: {
|
|
59
|
+
type: type.to_s,
|
|
60
|
+
payload: {url: url, is_reusable: true}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
result = post_json(@config.attachment_upload_url, payload)
|
|
66
|
+
result && result["attachment_id"]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def renderer_class
|
|
72
|
+
FlowChat::Messenger::Renderer
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def platform
|
|
76
|
+
:messenger
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def limits
|
|
80
|
+
FlowChat::Config.messenger
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Anything over the platform's cap is rejected whole rather than trimmed by
|
|
84
|
+
# Meta, so long text goes as several messages. Only the last result is
|
|
85
|
+
# returned: it carries the id of the message the user ends up looking at.
|
|
86
|
+
#
|
|
87
|
+
# Media rides in options[:media] on every rung except :attachment, where
|
|
88
|
+
# it already is the message: it goes out as its own send, ahead of the
|
|
89
|
+
# choice message, because the choice surface is rendered exactly as it
|
|
90
|
+
# would be with no media at all. Every part - the media and every chunk
|
|
91
|
+
# of the choice message - carries the same tag.
|
|
92
|
+
def deliver(recipient_id, type, content, options, tag)
|
|
93
|
+
post_media(recipient_id, options[:media], tag) if options[:media]
|
|
94
|
+
|
|
95
|
+
case type
|
|
96
|
+
when :text
|
|
97
|
+
split_text(content).map { |chunk| post_message(recipient_id, {text: chunk}, tag) }.last
|
|
98
|
+
when :quick_replies
|
|
99
|
+
chunks = split_text(content)
|
|
100
|
+
# Quick replies belong on the final chunk, next to the question.
|
|
101
|
+
chunks[0..-2].each { |chunk| post_message(recipient_id, {text: chunk}, tag) }
|
|
102
|
+
post_message(recipient_id, {text: chunks.last, quick_replies: options[:quick_replies]}, tag)
|
|
103
|
+
when :carousel
|
|
104
|
+
post_body_text(recipient_id, content, tag)
|
|
105
|
+
post_message(recipient_id, {
|
|
106
|
+
attachment: {
|
|
107
|
+
type: "template",
|
|
108
|
+
payload: {template_type: "generic", elements: options[:elements]}
|
|
109
|
+
}
|
|
110
|
+
}, tag)
|
|
111
|
+
when :attachment
|
|
112
|
+
post_body_text(recipient_id, content, tag)
|
|
113
|
+
post_media(recipient_id, options, tag)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# content on these two branches is the numbered body Instagram's
|
|
118
|
+
# always_number? forces (or, on :attachment, just the flow's own
|
|
119
|
+
# message) - either can run over the platform's text cap on its own,
|
|
120
|
+
# with no relation to the template or attachment that follows, so it
|
|
121
|
+
# is split exactly like a plain text send rather than posted whole.
|
|
122
|
+
def post_body_text(recipient_id, content, tag)
|
|
123
|
+
return unless content.present?
|
|
124
|
+
|
|
125
|
+
split_text(content).each { |chunk| post_message(recipient_id, {text: chunk}, tag) }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def post_media(recipient_id, media_options, tag)
|
|
129
|
+
attachment_payload = media_options[:url] ? {url: media_options[:url], is_reusable: true} : {attachment_id: media_options[:attachment_id]}
|
|
130
|
+
post_message(recipient_id, {
|
|
131
|
+
attachment: {type: media_options[:type].to_s, payload: attachment_payload}
|
|
132
|
+
}, tag)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def post_message(recipient_id, message, tag)
|
|
136
|
+
payload = {recipient: {id: recipient_id}, message: message}
|
|
137
|
+
|
|
138
|
+
# The tag branch is not gated on messaging_type?: Instagram never
|
|
139
|
+
# documents RESPONSE, but it does document MESSAGE_TAG with
|
|
140
|
+
# HUMAN_AGENT, so a tagged send needs the field there too even
|
|
141
|
+
# though an untagged Instagram send omits it entirely.
|
|
142
|
+
if tag
|
|
143
|
+
payload[:messaging_type] = "MESSAGE_TAG"
|
|
144
|
+
payload[:tag] = tag
|
|
145
|
+
elsif messaging_type?
|
|
146
|
+
payload[:messaging_type] = "RESPONSE"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
post_json(@config.messages_url, payload)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Messenger documents messaging_type as required on a send. Instagram's
|
|
153
|
+
# reference does not mention it at all, so Instagram omits it rather than
|
|
154
|
+
# sending a parameter Meta never documented for that surface.
|
|
155
|
+
def messaging_type?
|
|
156
|
+
true
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Splits on whitespace so a word is never cut in half. Measured with the
|
|
160
|
+
# platform's own unit, which is bytes on Instagram and characters here.
|
|
161
|
+
#
|
|
162
|
+
# A single piece that is itself too large to fit in one chunk (a long
|
|
163
|
+
# URL, most often) cannot be handled by that whitespace splitting
|
|
164
|
+
# alone: with no smaller boundary inside it to break on, it would
|
|
165
|
+
# otherwise ride through untouched as one chunk over the cap, exactly
|
|
166
|
+
# the case this method exists to prevent. #hard_split below breaks it
|
|
167
|
+
# up directly.
|
|
168
|
+
def split_text(text)
|
|
169
|
+
limit = limits.max_text_length
|
|
170
|
+
return [text.to_s] if measure(text.to_s) <= limit
|
|
171
|
+
|
|
172
|
+
chunks = []
|
|
173
|
+
current = ""
|
|
174
|
+
|
|
175
|
+
text.to_s.split(/(\s+)/).each do |piece|
|
|
176
|
+
if measure(piece) > limit
|
|
177
|
+
chunks << current.strip if current.present?
|
|
178
|
+
oversized_chunks = hard_split(piece.strip, limit)
|
|
179
|
+
chunks.concat(oversized_chunks[0..-2])
|
|
180
|
+
current = oversized_chunks.last.to_s
|
|
181
|
+
elsif measure(current + piece) > limit && current.present?
|
|
182
|
+
chunks << current.strip
|
|
183
|
+
current = piece.lstrip
|
|
184
|
+
else
|
|
185
|
+
current += piece
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
chunks << current.strip if current.strip.present?
|
|
190
|
+
chunks
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Breaks a single oversized piece into cap-sized chunks one character
|
|
194
|
+
# at a time, so the cut always lands on a character boundary. Never a
|
|
195
|
+
# byte offset: on Instagram, where the cap is measured in bytes, a
|
|
196
|
+
# multibyte character sliced by byte position would leave one half a
|
|
197
|
+
# valid UTF-8 sequence and the other invalid, and #measure has no way
|
|
198
|
+
# to tell that apart from a character that legitimately does not fit.
|
|
199
|
+
def hard_split(piece, limit)
|
|
200
|
+
chunks = []
|
|
201
|
+
current = ""
|
|
202
|
+
|
|
203
|
+
piece.each_char do |char|
|
|
204
|
+
if current.present? && measure(current + char) > limit
|
|
205
|
+
chunks << current
|
|
206
|
+
current = char
|
|
207
|
+
else
|
|
208
|
+
current += char
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
chunks << current if current.present?
|
|
213
|
+
chunks
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def measure(string)
|
|
217
|
+
string.length
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def post_json(url, payload)
|
|
221
|
+
uri = URI(url)
|
|
222
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
223
|
+
http.use_ssl = true
|
|
224
|
+
|
|
225
|
+
request = Net::HTTP::Post.new(uri)
|
|
226
|
+
@config.api_headers.each { |key, value| request[key] = value }
|
|
227
|
+
request.body = payload.to_json
|
|
228
|
+
|
|
229
|
+
response = http.request(request)
|
|
230
|
+
|
|
231
|
+
if response.is_a?(Net::HTTPSuccess)
|
|
232
|
+
JSON.parse(response.body)
|
|
233
|
+
else
|
|
234
|
+
FlowChat.logger.error { "#{self.class.name}: API request failed - #{response.code}: #{response.body}" }
|
|
235
|
+
report_api_error(
|
|
236
|
+
"#{platform} API request failed",
|
|
237
|
+
response_code: response.code,
|
|
238
|
+
response_body: response.body
|
|
239
|
+
)
|
|
240
|
+
nil
|
|
241
|
+
end
|
|
242
|
+
rescue Net::OpenTimeout, Net::ReadTimeout => network_error
|
|
243
|
+
FlowChat.logger.error { "#{self.class.name}: Network timeout: #{network_error.class.name}" }
|
|
244
|
+
raise network_error
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# FlowChat::Instrumentation only defines report_api_error at the module
|
|
248
|
+
# level (FlowChat::Instrumentation.report_api_error), not as an instance
|
|
249
|
+
# method, so it is not inherited through `include`. Every client that
|
|
250
|
+
# wants the shorthand defines its own wrapper; this mirrors the one in
|
|
251
|
+
# whatsapp/client.rb.
|
|
252
|
+
def report_api_error(message, response_code: nil, response_body: nil, error: nil)
|
|
253
|
+
FlowChat::Instrumentation.report_api_error(
|
|
254
|
+
message,
|
|
255
|
+
error: error,
|
|
256
|
+
platform: platform,
|
|
257
|
+
account_id: @config.account_id,
|
|
258
|
+
response_code: response_code,
|
|
259
|
+
response_body: response_body
|
|
260
|
+
)
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
module FlowChat
|
|
2
|
+
module Messenger
|
|
3
|
+
class Configuration
|
|
4
|
+
include FlowChat::NamedConfiguration
|
|
5
|
+
|
|
6
|
+
attr_accessor :access_token, :page_id, :verify_token, :app_id, :app_secret,
|
|
7
|
+
:name, :skip_signature_validation
|
|
8
|
+
|
|
9
|
+
def initialize(name)
|
|
10
|
+
@name = name
|
|
11
|
+
@access_token = nil
|
|
12
|
+
@page_id = nil
|
|
13
|
+
@verify_token = nil
|
|
14
|
+
@app_id = nil
|
|
15
|
+
@app_secret = nil
|
|
16
|
+
@skip_signature_validation = false
|
|
17
|
+
|
|
18
|
+
FlowChat.logger.debug { "Messenger::Configuration: Initialized configuration with name: #{name || "anonymous"}" }
|
|
19
|
+
|
|
20
|
+
register_as(name) if name.present?
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.from_credentials
|
|
24
|
+
FlowChat.logger.info { "Messenger::Configuration: Loading configuration from credentials/environment" }
|
|
25
|
+
|
|
26
|
+
config = new(nil)
|
|
27
|
+
|
|
28
|
+
if defined?(Rails) && Rails.respond_to?(:application) && Rails.application&.credentials&.messenger
|
|
29
|
+
FlowChat.logger.debug { "Messenger::Configuration: Loading from Rails credentials" }
|
|
30
|
+
credentials = Rails.application.credentials.messenger
|
|
31
|
+
config.access_token = credentials[:access_token]
|
|
32
|
+
config.page_id = credentials[:page_id]
|
|
33
|
+
config.verify_token = credentials[:verify_token]
|
|
34
|
+
config.app_id = credentials[:app_id]
|
|
35
|
+
config.app_secret = credentials[:app_secret]
|
|
36
|
+
config.skip_signature_validation = credentials[:skip_signature_validation] || false
|
|
37
|
+
else
|
|
38
|
+
FlowChat.logger.debug { "Messenger::Configuration: Loading from environment variables" }
|
|
39
|
+
config.access_token = ENV["MESSENGER_ACCESS_TOKEN"]
|
|
40
|
+
config.page_id = ENV["MESSENGER_PAGE_ID"]
|
|
41
|
+
config.verify_token = ENV["MESSENGER_VERIFY_TOKEN"]
|
|
42
|
+
config.app_id = ENV["MESSENGER_APP_ID"]
|
|
43
|
+
config.app_secret = ENV["MESSENGER_APP_SECRET"]
|
|
44
|
+
config.skip_signature_validation = ENV["MESSENGER_SKIP_SIGNATURE_VALIDATION"] == "true"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
if config.valid?
|
|
48
|
+
FlowChat.logger.info { "Messenger::Configuration: Configuration loaded successfully - page_id: #{config.page_id}" }
|
|
49
|
+
else
|
|
50
|
+
FlowChat.logger.warn { "Messenger::Configuration: Incomplete configuration loaded - missing required fields" }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
config
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def valid?
|
|
57
|
+
# Wrapped so a predicate answers true or false rather than nil, which
|
|
58
|
+
# the bare && chain returns for a missing first field. Intercom and
|
|
59
|
+
# Telegram already do this and pin it in their tests.
|
|
60
|
+
is_valid = !!(access_token && !access_token.to_s.empty? &&
|
|
61
|
+
page_id && !page_id.to_s.empty? &&
|
|
62
|
+
verify_token && !verify_token.to_s.empty?)
|
|
63
|
+
|
|
64
|
+
FlowChat.logger.debug { "Messenger::Configuration: Configuration valid: #{is_valid}" }
|
|
65
|
+
is_valid
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# The account this configuration speaks for. Named generically, not
|
|
69
|
+
# page_id, so the shared gateway can check an inbound event's account
|
|
70
|
+
# without knowing which platform it holds.
|
|
71
|
+
def account_id
|
|
72
|
+
page_id
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The id an inbound webhook's entry.id names. Messenger only ever handles
|
|
76
|
+
# `page`, which names the Page, so this is the same id a send is
|
|
77
|
+
# addressed to. Instagram's two differ, which is why the gateway asks for
|
|
78
|
+
# this rather than reusing account_id.
|
|
79
|
+
def webhook_account_id
|
|
80
|
+
account_id
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def messages_url
|
|
84
|
+
"#{api_base_url}/#{page_id}/messages"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def attachment_upload_url
|
|
88
|
+
"#{api_base_url}/#{page_id}/message_attachments"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def api_base_url
|
|
92
|
+
FlowChat::Config.messenger.api_base_url
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def api_headers
|
|
96
|
+
{
|
|
97
|
+
"Authorization" => "Bearer #{access_token}",
|
|
98
|
+
"Content-Type" => "application/json"
|
|
99
|
+
}
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
module FlowChat
|
|
2
|
+
module Messenger
|
|
3
|
+
# Its own file so Zeitwerk can resolve it by name. Declared inside
|
|
4
|
+
# configuration.rb it existed only once that file had loaded for some other
|
|
5
|
+
# reason, so an application rescuing it, or a test naming it before anything
|
|
6
|
+
# touched the configuration class, got an uninitialized constant instead.
|
|
7
|
+
class ConfigurationError < StandardError; end
|
|
8
|
+
end
|
|
9
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
module FlowChat
|
|
2
|
+
module Messenger
|
|
3
|
+
module Gateway
|
|
4
|
+
# Facebook Messenger, on the shared Messenger Platform envelope.
|
|
5
|
+
class SendApi < FlowChat::Meta::MessagingGateway
|
|
6
|
+
def platform
|
|
7
|
+
:messenger
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def gateway_name
|
|
11
|
+
:messenger_send_api
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def configuration_class
|
|
15
|
+
FlowChat::Messenger::Configuration
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def client_class
|
|
19
|
+
FlowChat::Messenger::Client
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def renderer_class
|
|
23
|
+
FlowChat::Messenger::Renderer
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.choice_mapper_class
|
|
27
|
+
FlowChat::Messenger::Middleware::ChoiceMapper
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def configuration_error_class
|
|
33
|
+
FlowChat::Messenger::ConfigurationError
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def platform_label
|
|
37
|
+
"Messenger"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
module FlowChat
|
|
2
|
+
module Messenger
|
|
3
|
+
module Middleware
|
|
4
|
+
# Maps a reply back to the choice key the flow used.
|
|
5
|
+
#
|
|
6
|
+
# Two key spaces can be live at once. A tap sends the payload the
|
|
7
|
+
# renderer put on the button, which is the title shown on it, and a user
|
|
8
|
+
# who types what they read sends that same string - so both resolve
|
|
9
|
+
# through one map. Only when a number is genuinely on screen does a
|
|
10
|
+
# typed digit mean a position, which is the second space.
|
|
11
|
+
#
|
|
12
|
+
# The title is the payload rather than a separately generated id
|
|
13
|
+
# because FlowChat::ChoiceTitles already guarantees the titles in a set
|
|
14
|
+
# are distinct, numbering the set when they would not be. A generated
|
|
15
|
+
# id needed its own uniqueness rule, and the one it had was lossy: it
|
|
16
|
+
# stripped punctuation, so "Yes!" produced the id "Yes", which was
|
|
17
|
+
# another choice's label exactly.
|
|
18
|
+
class ChoiceMapper
|
|
19
|
+
ID_KEY = "messenger.choice_mapping"
|
|
20
|
+
POSITION_KEY = "messenger.position_mapping"
|
|
21
|
+
|
|
22
|
+
def initialize(app)
|
|
23
|
+
@app = app
|
|
24
|
+
FlowChat.logger.debug { "#{self.class.name}: Initialized" }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def call(context)
|
|
28
|
+
@context = context
|
|
29
|
+
@session = context.session
|
|
30
|
+
|
|
31
|
+
handle_choice_input if intercept?
|
|
32
|
+
|
|
33
|
+
# The maps belong to exactly one screen: this turn's, if it had a
|
|
34
|
+
# resolvable answer, or one that already fell out of use otherwise.
|
|
35
|
+
# Either way nothing here is still owed to the next screen, so they
|
|
36
|
+
# are cleared unconditionally rather than asked whether they still
|
|
37
|
+
# look "live" - create_mappings immediately below repopulates them
|
|
38
|
+
# whenever the app actually returns choices.
|
|
39
|
+
#
|
|
40
|
+
# An earlier version asked stale_mappings? that question after
|
|
41
|
+
# handle_choice_input had already rewritten @context.input to the
|
|
42
|
+
# *resolved* value, which can equal one of the map's own keys (an
|
|
43
|
+
# Array choice's key is its label, and the wire value is the title
|
|
44
|
+
# built from that label), so the check answered "still live"
|
|
45
|
+
# about a value that was never a fresh reply. That let the maps
|
|
46
|
+
# survive into a free-text screen and reinterpret a typed answer
|
|
47
|
+
# there as the previous menu's choice. This was fixed once for
|
|
48
|
+
# WhatsApp in Task 6 and once here in Task 13; both fixes had the
|
|
49
|
+
# same shape and the same blind spot, which is why the guard is
|
|
50
|
+
# gone rather than patched a third time.
|
|
51
|
+
clear_mappings
|
|
52
|
+
|
|
53
|
+
type, prompt, choices, media = @app.call(context)
|
|
54
|
+
|
|
55
|
+
choices = create_mappings(choices) if choices.present?
|
|
56
|
+
|
|
57
|
+
[type, prompt, choices, media]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def platform_limits
|
|
63
|
+
FlowChat::Config.messenger
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def always_number?
|
|
67
|
+
false
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def id_key
|
|
71
|
+
self.class::ID_KEY
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def position_key
|
|
75
|
+
self.class::POSITION_KEY
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def get_id_mapping
|
|
79
|
+
@session.get(id_key) || {}
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def get_position_mapping
|
|
83
|
+
@session.get(position_key) || {}
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Titles first, then positions. A tap sends the title as its payload
|
|
87
|
+
# and a user typing what they read sends the same string, so both
|
|
88
|
+
# land on the same entry. A position must lose to a title, because a
|
|
89
|
+
# choice labelled "1" would otherwise be unreachable: its title is
|
|
90
|
+
# "1", and a bare digit is only a position when the screen was
|
|
91
|
+
# numbered at all.
|
|
92
|
+
def resolved_choice
|
|
93
|
+
input = @context.input.to_s
|
|
94
|
+
return nil if input.empty?
|
|
95
|
+
|
|
96
|
+
get_id_mapping[input] || get_position_mapping[input]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def intercept?
|
|
100
|
+
@context.input.present? && resolved_choice.present?
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def handle_choice_input
|
|
104
|
+
original = resolved_choice
|
|
105
|
+
FlowChat.logger.info { "#{self.class.name}: Resolving input #{@context.input} to #{original}" }
|
|
106
|
+
@context.input = original
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def clear_mappings
|
|
110
|
+
@session.delete(id_key)
|
|
111
|
+
@session.delete(position_key)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def create_mappings(choices)
|
|
115
|
+
cap = display_title_cap(choices.length)
|
|
116
|
+
return passthrough_mapping(choices) if cap.nil?
|
|
117
|
+
|
|
118
|
+
title_choices = {}
|
|
119
|
+
id_mapping = {}
|
|
120
|
+
|
|
121
|
+
FlowChat::ChoiceTitles.build(choices, cap).each do |key, label, title, _truncated|
|
|
122
|
+
title_choices[title] = label
|
|
123
|
+
id_mapping[title] = key
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
@session.set(id_key, id_mapping)
|
|
127
|
+
|
|
128
|
+
if number_choices?(choices)
|
|
129
|
+
@session.set(position_key, choices.keys.map.with_index(1) { |key, i| [i.to_s, key.to_s] }.to_h)
|
|
130
|
+
else
|
|
131
|
+
@session.delete(position_key)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
title_choices
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# On the :numbered rung the body already prints each full label beside
|
|
138
|
+
# its number, so the label itself is what is on screen and stays
|
|
139
|
+
# resolvable - nothing is truncated, so there is no shortened form to
|
|
140
|
+
# key on instead.
|
|
141
|
+
#
|
|
142
|
+
# A label shared by two choices is dropped rather than resolved to the
|
|
143
|
+
# first of them. It identifies neither on a screen that shows both, and
|
|
144
|
+
# the number printed next to each is the reply that does.
|
|
145
|
+
def passthrough_mapping(choices)
|
|
146
|
+
labels = choices.map { |key, label| [label.to_s, key.to_s] }
|
|
147
|
+
repeated = labels.map(&:first).tally
|
|
148
|
+
|
|
149
|
+
@session.set(id_key, labels.reject { |label, _| repeated[label] > 1 }.to_h)
|
|
150
|
+
@session.set(position_key, choices.keys.map.with_index(1) { |key, i| [i.to_s, key.to_s] }.to_h)
|
|
151
|
+
choices
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# The title cap the renderer will use for these choices, or nil on
|
|
155
|
+
# the :none and :numbered rungs, where there is no separate title to
|
|
156
|
+
# alias: :none has no choices, and :numbered already lists each full
|
|
157
|
+
# label next to its number straight in the body, with nothing more
|
|
158
|
+
# to truncate. This calls the same FlowChat::Meta::ChoiceLadder the
|
|
159
|
+
# renderer consults, so the two cannot drift on which rung a given
|
|
160
|
+
# count lands on.
|
|
161
|
+
def display_title_cap(count)
|
|
162
|
+
case FlowChat::Meta::ChoiceLadder.rung_for(count, platform_limits)
|
|
163
|
+
when :quick_replies then platform_limits.max_quick_reply_title
|
|
164
|
+
when :carousel then platform_limits.max_button_title
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# A position number is only worth resolving when one is genuinely on
|
|
169
|
+
# screen: on the :numbered rung, or wherever always_number? forces
|
|
170
|
+
# the renderer's #body to list one regardless of rung (Instagram,
|
|
171
|
+
# for a desktop user with no tappable surface at all), or on a
|
|
172
|
+
# quick-reply/carousel rung whose titles FlowChat::ChoiceTitles
|
|
173
|
+
# decided were ambiguous and prefixed with a number. always_number?
|
|
174
|
+
# only ever governs that body listing; it says nothing about whether
|
|
175
|
+
# a title itself was numbered, so it cannot answer this alone.
|
|
176
|
+
def number_choices?(choices)
|
|
177
|
+
count = choices.length
|
|
178
|
+
return true if FlowChat::Meta::ChoiceLadder.numbers_in_body?(count, platform_limits, always_number: always_number?)
|
|
179
|
+
|
|
180
|
+
FlowChat::ChoiceTitles.ambiguous?(choices, display_title_cap(count))
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|