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,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
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
require "flow_chat/renderers/markdown_support"
|
|
2
|
+
|
|
3
|
+
module FlowChat
|
|
4
|
+
module Messenger
|
|
5
|
+
class Renderer
|
|
6
|
+
include FlowChat::Renderers::MarkdownSupport
|
|
7
|
+
|
|
8
|
+
attr_reader :message, :choices, :media
|
|
9
|
+
|
|
10
|
+
def initialize(message, choices: nil, media: nil)
|
|
11
|
+
@message = message
|
|
12
|
+
@choices = choices
|
|
13
|
+
@media = media
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def render
|
|
17
|
+
return build_attachment if media && choices.blank?
|
|
18
|
+
|
|
19
|
+
result = case FlowChat::Meta::ChoiceLadder.rung_for(choice_count, limits)
|
|
20
|
+
when :none then build_text
|
|
21
|
+
when :quick_replies then build_quick_replies
|
|
22
|
+
when :carousel then build_carousel
|
|
23
|
+
when :numbered then build_text
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
attach_media(result)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
# Instagram overrides both hooks: its own limits, and always_number
|
|
32
|
+
# because its interactive surfaces render on mobile only.
|
|
33
|
+
def limits
|
|
34
|
+
FlowChat::Config.messenger
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def always_number?
|
|
38
|
+
false
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# nil (no choices at all) is the only non-Hash tolerated: Prompt
|
|
42
|
+
# normalizes an Array upstream in the flow path, so the only way an
|
|
43
|
+
# Array reaches here is a direct client send, e.g.
|
|
44
|
+
# `context["messenger.client"].send_message(psid, msg, choices: [...])`.
|
|
45
|
+
# Silently returning 0 for that made the choices vanish from the render
|
|
46
|
+
# with no error at all; raising matches what WhatsApp's own
|
|
47
|
+
# build_selection_message does for the same input instead of
|
|
48
|
+
# disagreeing with it.
|
|
49
|
+
def choice_count
|
|
50
|
+
return 0 if choices.nil?
|
|
51
|
+
raise ArgumentError, "choices must be a Hash" unless choices.is_a?(Hash)
|
|
52
|
+
|
|
53
|
+
choices.length
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Neither Messenger nor Instagram renders markup, so the prompt is
|
|
57
|
+
# flattened rather than translated.
|
|
58
|
+
def body
|
|
59
|
+
text = to_plain_text(message)
|
|
60
|
+
return text unless FlowChat::Meta::ChoiceLadder.numbers_in_body?(choice_count, limits, always_number: always_number?)
|
|
61
|
+
|
|
62
|
+
"#{text}\n\n#{numbered_options}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def numbered_options
|
|
66
|
+
choices.values.map.with_index(1) { |label, i| "#{i}. #{label}" }.join("\n")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Covers both the :none rung and the :numbered rung above the carousel
|
|
70
|
+
# capacity: body already appends the numbered options when the ladder
|
|
71
|
+
# calls for it, so there is nothing left for a separate method to add.
|
|
72
|
+
def build_text
|
|
73
|
+
[:text, body, {}]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Whether titles are numbered, and the enumeration order positions come
|
|
77
|
+
# from, are both decided by FlowChat::ChoiceTitles over this same
|
|
78
|
+
# `choices` hash - the choice mapper goes through the same module over
|
|
79
|
+
# the same hash, so the two can never disagree on which titles are
|
|
80
|
+
# shown, which is what lets the mapper use a title as the wire value.
|
|
81
|
+
def build_quick_replies
|
|
82
|
+
replies = FlowChat::ChoiceTitles.build(choices, limits.max_quick_reply_title).map do |key, _label, title, _truncated|
|
|
83
|
+
{
|
|
84
|
+
content_type: "text",
|
|
85
|
+
title: title,
|
|
86
|
+
payload: key
|
|
87
|
+
}
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
[:quick_replies, body, {quick_replies: replies}]
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# One option is one button, and buttons live on elements, so the options
|
|
94
|
+
# are packed across elements rather than one element per option.
|
|
95
|
+
#
|
|
96
|
+
# FlowChat::ChoiceTitles.build runs once over the whole choice set,
|
|
97
|
+
# before slicing into elements: both the ambiguity decision and the
|
|
98
|
+
# resulting position numbers have to consider every choice together,
|
|
99
|
+
# not each element's slice in isolation. Two different slices could
|
|
100
|
+
# each hold a "Foo" that only collides once the whole set is in view,
|
|
101
|
+
# and the numbering an element's buttons carry has to continue where
|
|
102
|
+
# the previous element's left off (button 14 reads "14.", not "2." of
|
|
103
|
+
# its own element) - both are only correct computed globally.
|
|
104
|
+
def build_carousel
|
|
105
|
+
numbered_choices = FlowChat::ChoiceTitles.build(choices, limits.max_button_title)
|
|
106
|
+
|
|
107
|
+
elements = numbered_choices.each_slice(limits.max_buttons_per_element).map.with_index(1) do |slice, index|
|
|
108
|
+
first = (index - 1) * limits.max_buttons_per_element + 1
|
|
109
|
+
last = first + slice.length - 1
|
|
110
|
+
|
|
111
|
+
{
|
|
112
|
+
title: FlowChat::TextTruncator.truncate("Options #{first} to #{last}", limits.max_element_title),
|
|
113
|
+
buttons: slice.map do |key, _label, title, _truncated|
|
|
114
|
+
{
|
|
115
|
+
type: "postback",
|
|
116
|
+
title: title,
|
|
117
|
+
payload: key
|
|
118
|
+
}
|
|
119
|
+
end
|
|
120
|
+
}
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
[:carousel, body, {elements: elements}]
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def build_attachment
|
|
127
|
+
[:attachment, to_plain_text(message), media_attachment_options]
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Media is additive: it does not change which choice surface is used.
|
|
131
|
+
# It rides along in options[:media] so the client can post it as its
|
|
132
|
+
# own message ahead of whichever rung the choice count would render
|
|
133
|
+
# with no media at all.
|
|
134
|
+
def attach_media(result)
|
|
135
|
+
return result unless media
|
|
136
|
+
|
|
137
|
+
type, content, options = result
|
|
138
|
+
[type, content, options.merge(media: media_attachment_options)]
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def media_attachment_options
|
|
142
|
+
type = (media[:type] || :image).to_sym
|
|
143
|
+
options = {type: type}
|
|
144
|
+
options[:url] = media[:url] if media[:url]
|
|
145
|
+
options[:attachment_id] = media[:id] if media[:id]
|
|
146
|
+
options
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
module FlowChat
|
|
2
|
+
module Meta
|
|
3
|
+
# The GET handshake Meta performs when a webhook URL is registered, as a
|
|
4
|
+
# decision rather than a response.
|
|
5
|
+
#
|
|
6
|
+
# Returns the challenge to echo, or nil when the request has not proved it
|
|
7
|
+
# is Meta. What to do with either answer belongs to the caller: a gateway
|
|
8
|
+
# renders it through its controller, an application receiving Meta's
|
|
9
|
+
# webhooks outside a gateway renders it its own way.
|
|
10
|
+
#
|
|
11
|
+
# WebhookVerification is the gateway's way in, and calls this.
|
|
12
|
+
module Challenge
|
|
13
|
+
# A configuration with no verify token must not verify anything. Without
|
|
14
|
+
# the presence check a missing token on both sides compares equal, and
|
|
15
|
+
# anyone could claim the endpoint by asking for the challenge.
|
|
16
|
+
def self.answer(params, verify_token)
|
|
17
|
+
return nil if verify_token.to_s.strip.empty?
|
|
18
|
+
return nil unless FlowChat::Security.secure_compare(params["hub.verify_token"].to_s, verify_token)
|
|
19
|
+
|
|
20
|
+
params["hub.challenge"]
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
module FlowChat
|
|
2
|
+
module Meta
|
|
3
|
+
# Which interactive surface renders a given number of choices.
|
|
4
|
+
#
|
|
5
|
+
# The renderer and the choice mapper both need this answer, and they must
|
|
6
|
+
# agree: the renderer decides what the user sees, the mapper decides what a
|
|
7
|
+
# reply is allowed to mean. Two copies of the arithmetic would drift into a
|
|
8
|
+
# screen whose replies cannot be resolved.
|
|
9
|
+
module ChoiceLadder
|
|
10
|
+
def self.rung_for(count, limits)
|
|
11
|
+
return :none if count.zero?
|
|
12
|
+
return :quick_replies if count <= limits.max_quick_replies
|
|
13
|
+
return :carousel if count <= carousel_capacity(limits)
|
|
14
|
+
|
|
15
|
+
:numbered
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# The carousel holds elements, each holding buttons, and one option is one
|
|
19
|
+
# button.
|
|
20
|
+
def self.carousel_capacity(limits)
|
|
21
|
+
limits.max_carousel_elements * limits.max_buttons_per_element
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Whether the options are also listed, numbered, in the message body.
|
|
25
|
+
#
|
|
26
|
+
# always_number is for platforms whose interactive surfaces do not render
|
|
27
|
+
# everywhere. Without it a user who cannot see the buttons has no way to
|
|
28
|
+
# answer at all.
|
|
29
|
+
def self.numbers_in_body?(count, limits, always_number: false)
|
|
30
|
+
return false if count.zero?
|
|
31
|
+
return true if always_number
|
|
32
|
+
|
|
33
|
+
rung_for(count, limits) == :numbered
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module FlowChat
|
|
2
|
+
module Meta
|
|
3
|
+
# What a Meta gateway must say about itself.
|
|
4
|
+
#
|
|
5
|
+
# The behavior modules in this namespace need a handful of the same values:
|
|
6
|
+
# which platform this is, how to name it to a developer, which error class to
|
|
7
|
+
# raise, what to tag logs with. Declaring them here rather than in whichever
|
|
8
|
+
# behavior module happens to be included first means a gateway can include
|
|
9
|
+
# one behavior without the other, and a gateway that forgets a value fails
|
|
10
|
+
# loudly rather than borrowing another platform's.
|
|
11
|
+
#
|
|
12
|
+
# NotImplementedError rather than a default: it descends from ScriptError,
|
|
13
|
+
# not StandardError, so it travels through the bare rescue in
|
|
14
|
+
# SignatureValidation instead of being swallowed into a false return that
|
|
15
|
+
# would read as "invalid signature" and drop every webhook.
|
|
16
|
+
module GatewayIdentity
|
|
17
|
+
def platform
|
|
18
|
+
raise NotImplementedError, "#{self.class.name} must define #platform"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# The product's name as a developer reading an error message expects it,
|
|
22
|
+
# which is not always the constant: Whatsapp the module, WhatsApp the product.
|
|
23
|
+
def platform_label
|
|
24
|
+
raise NotImplementedError, "#{self.class.name} must define #platform_label"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def configuration_error_class
|
|
28
|
+
raise NotImplementedError, "#{self.class.name} must define #configuration_error_class"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Derived, because every gateway's class name already ends in the tag its
|
|
32
|
+
# logs use. Override only to pin the tag against a class rename.
|
|
33
|
+
def log_tag
|
|
34
|
+
self.class.name.split("::").last
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|