flow_chat 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/pages.yml +43 -0
- data/CHANGELOG.md +26 -0
- data/README.md +166 -295
- data/Rakefile +12 -1
- data/SECURITY.md +1 -1
- data/docs/architecture.md +52 -479
- data/docs/async-background-processing.md +31 -265
- data/docs/configuration.md +106 -613
- data/docs/factory-pattern.md +27 -324
- data/docs/gateway-context-variables.md +140 -143
- data/docs/gateway-development.md +86 -650
- data/docs/getting-started.md +40 -379
- data/docs/instrumentation.md +88 -279
- data/docs/platforms/instagram.md +278 -0
- data/docs/platforms/messenger.md +205 -0
- data/docs/platforms/telegram.md +47 -951
- data/docs/platforms/ussd.md +38 -653
- data/docs/platforms/whatsapp.md +73 -1321
- data/docs/superpowers/plans/2026-07-09-inbound-media-support.md +732 -0
- data/docs/superpowers/plans/2026-07-09-inbound-media-support.md.tasks.json +58 -0
- data/docs/superpowers/plans/2026-08-10-messenger-instagram.md +4064 -0
- data/docs/superpowers/plans/2026-08-10-messenger-instagram.md.tasks.json +226 -0
- data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md +972 -0
- data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md.tasks.json +88 -0
- data/docs/superpowers/specs/2026-07-09-inbound-media-support-design.md +195 -0
- data/docs/superpowers/specs/2026-08-10-messenger-instagram-design.md +391 -0
- data/docs/testing.md +26 -297
- data/examples/http_controller.rb +12 -10
- data/examples/intercom_configuration_example.rb +19 -24
- data/examples/intercom_controller.rb +8 -20
- data/examples/multi_tenant_whatsapp_controller.rb +61 -166
- data/examples/simulator_controller.rb +0 -1
- data/examples/ussd_controller.rb +86 -158
- data/examples/whatsapp_controller.rb +16 -15
- data/examples/whatsapp_media_examples.rb +27 -79
- data/lib/flow_chat/app.rb +129 -11
- data/lib/flow_chat/choice_titles.rb +95 -0
- data/lib/flow_chat/config.rb +124 -1
- data/lib/flow_chat/delivery_error.rb +9 -0
- data/lib/flow_chat/http/configuration_error.rb +9 -0
- data/lib/flow_chat/http/gateway/simple.rb +24 -4
- data/lib/flow_chat/http/middleware/choice_mapper.rb +94 -0
- data/lib/flow_chat/input.rb +86 -0
- data/lib/flow_chat/instagram/client.rb +32 -0
- data/lib/flow_chat/instagram/configuration.rb +147 -0
- data/lib/flow_chat/instagram/configuration_error.rb +7 -0
- data/lib/flow_chat/instagram/gateway/send_api.rb +63 -0
- data/lib/flow_chat/instagram/middleware/choice_mapper.rb +22 -0
- data/lib/flow_chat/instagram/renderer.rb +23 -0
- data/lib/flow_chat/instrumentation/metrics_collector.rb +6 -1
- data/lib/flow_chat/instrumentation.rb +160 -1
- data/lib/flow_chat/intercom/client.rb +34 -28
- data/lib/flow_chat/intercom/configuration.rb +2 -49
- data/lib/flow_chat/intercom/configuration_error.rb +9 -0
- data/lib/flow_chat/intercom/gateway/intercom_api.rb +81 -57
- data/lib/flow_chat/intercom/middleware/choice_mapper.rb +101 -0
- data/lib/flow_chat/intercom/renderer.rb +57 -5
- data/lib/flow_chat/media.rb +121 -0
- data/lib/flow_chat/messenger/client.rb +264 -0
- data/lib/flow_chat/messenger/configuration.rb +103 -0
- data/lib/flow_chat/messenger/configuration_error.rb +9 -0
- data/lib/flow_chat/messenger/gateway/send_api.rb +42 -0
- data/lib/flow_chat/messenger/middleware/choice_mapper.rb +185 -0
- data/lib/flow_chat/messenger/renderer.rb +150 -0
- data/lib/flow_chat/meta/challenge.rb +24 -0
- data/lib/flow_chat/meta/choice_ladder.rb +37 -0
- data/lib/flow_chat/meta/configuration_error.rb +7 -0
- data/lib/flow_chat/meta/gateway_identity.rb +38 -0
- data/lib/flow_chat/meta/messaging_gateway.rb +468 -0
- data/lib/flow_chat/meta/signature.rb +30 -0
- data/lib/flow_chat/meta/signature_validation.rb +66 -0
- data/lib/flow_chat/meta/webhook_verification.rb +43 -0
- data/lib/flow_chat/named_configuration.rb +65 -0
- data/lib/flow_chat/prompt.rb +13 -16
- data/lib/flow_chat/renderers/markdown_support.rb +109 -0
- data/lib/flow_chat/security.rb +76 -0
- data/lib/flow_chat/session/middleware.rb +11 -2
- data/lib/flow_chat/simulator/controller.rb +31 -15
- data/lib/flow_chat/simulator/views/simulator.html.erb +184 -20
- data/lib/flow_chat/telegram/client.rb +47 -4
- data/lib/flow_chat/telegram/configuration.rb +2 -42
- data/lib/flow_chat/telegram/configuration_error.rb +9 -0
- data/lib/flow_chat/telegram/gateway/bot_api.rb +46 -28
- data/lib/flow_chat/telegram/middleware/choice_mapper.rb +77 -16
- data/lib/flow_chat/telegram/renderer.rb +10 -2
- data/lib/flow_chat/text_truncator.rb +75 -0
- data/lib/flow_chat/ussd/middleware/choice_mapper.rb +10 -0
- data/lib/flow_chat/version.rb +1 -1
- data/lib/flow_chat/whatsapp/client.rb +14 -7
- data/lib/flow_chat/whatsapp/configuration.rb +12 -51
- data/lib/flow_chat/whatsapp/configuration_error.rb +9 -0
- data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +254 -203
- data/lib/flow_chat/whatsapp/middleware/choice_mapper.rb +144 -57
- data/lib/flow_chat/whatsapp/renderer.rb +121 -60
- data/lib/flow_chat.rb +0 -10
- data/site/.nojekyll +0 -0
- data/site/.og-card.html +89 -0
- data/site/favicon.svg +6 -0
- data/site/index.html +209 -0
- data/site/og.png +0 -0
- metadata +51 -3
- data/lib/flow_chat/whatsapp/id_generator.rb +0 -124
|
@@ -1,38 +1,37 @@
|
|
|
1
|
-
require_relative "../id_generator"
|
|
2
|
-
|
|
3
1
|
module FlowChat
|
|
4
2
|
module Whatsapp
|
|
5
3
|
module Middleware
|
|
6
|
-
# Maps WhatsApp
|
|
4
|
+
# Maps what WhatsApp sends back to the choice key the flow branches on.
|
|
7
5
|
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
6
|
+
# The value on the wire is the title the user was shown, so a tap and a
|
|
7
|
+
# user typing what they read arrive as the same string and resolve
|
|
8
|
+
# through the same map.
|
|
11
9
|
#
|
|
12
10
|
# Flow:
|
|
13
|
-
# 1. Flow returns choices with original keys (
|
|
14
|
-
# 2.
|
|
15
|
-
# 3.
|
|
16
|
-
# 4.
|
|
17
|
-
# 5.
|
|
18
|
-
# 6.
|
|
19
|
-
# 7. This middleware intercepts and replaces ID with original key
|
|
20
|
-
# 8. Flow sees the original choice key (not the generated ID)
|
|
11
|
+
# 1. Flow returns choices with original keys ({"create" => "Create Account"})
|
|
12
|
+
# 2. This middleware asks FlowChat::ChoiceTitles for each displayed title
|
|
13
|
+
# 3. It re-keys the choices by title and stores title => original key
|
|
14
|
+
# 4. Renderer receives the re-keyed choices and renders them
|
|
15
|
+
# 5. User taps a button, or types the title on it
|
|
16
|
+
# 6. This middleware resolves either back to the original key
|
|
21
17
|
#
|
|
22
18
|
# @example
|
|
23
19
|
# # Flow provides: {"create" => "Create Account"}
|
|
24
|
-
# #
|
|
25
|
-
# #
|
|
26
|
-
# #
|
|
27
|
-
# # User clicks, WhatsApp sends: "Create Account"
|
|
28
|
-
# # Middleware intercepts and maps back to: "create"
|
|
20
|
+
# # Titles built: "Create Account"
|
|
21
|
+
# # Transformed: {"Create Account" => "Create Account"}
|
|
22
|
+
# # Mapping: {"Create Account" => "create"}
|
|
29
23
|
#
|
|
30
24
|
# # With duplicates: {"yes" => "Accept", "no" => "Accept"}
|
|
31
|
-
# #
|
|
32
|
-
# #
|
|
33
|
-
#
|
|
34
|
-
#
|
|
35
|
-
#
|
|
25
|
+
# # Titles built: "1. Accept", "2. Accept"
|
|
26
|
+
# # Mapping: {"1. Accept" => "yes", "2. Accept" => "no"}
|
|
27
|
+
#
|
|
28
|
+
# A title is truncated to the rung's cap (see
|
|
29
|
+
# FlowChat::Whatsapp::Renderer::BUTTON_TITLE_LENGTH /
|
|
30
|
+
# LIST_ROW_TITLE_LENGTH). When truncation, or a duplicate label, would
|
|
31
|
+
# leave two titles indistinguishable, FlowChat::ChoiceTitles numbers
|
|
32
|
+
# every title in the set instead - which is what keeps them distinct,
|
|
33
|
+
# and why nothing here has to check for collisions itself. See its docs
|
|
34
|
+
# for why that decision is made once for the whole set.
|
|
36
35
|
#
|
|
37
36
|
class ChoiceMapper
|
|
38
37
|
def initialize(app)
|
|
@@ -52,8 +51,25 @@ module FlowChat
|
|
|
52
51
|
handle_choice_input
|
|
53
52
|
end
|
|
54
53
|
|
|
55
|
-
#
|
|
56
|
-
|
|
54
|
+
# The maps belong to exactly one screen: this turn's, if it had a
|
|
55
|
+
# resolvable answer, or one that already fell out of use otherwise.
|
|
56
|
+
# Either way nothing here is still owed to the next screen, so they
|
|
57
|
+
# are cleared unconditionally rather than asked whether they still
|
|
58
|
+
# look "live" - create_id_mapping immediately below repopulates
|
|
59
|
+
# them whenever the app actually returns choices.
|
|
60
|
+
#
|
|
61
|
+
# An earlier version asked should_clear_for_new_flow? that question
|
|
62
|
+
# after handle_choice_input had already rewritten @context.input to
|
|
63
|
+
# the *resolved* value, which can equal one of the map's own keys
|
|
64
|
+
# (an Array choice's key is its label, and the wire value is the
|
|
65
|
+
# title built from that label), so the check answered
|
|
66
|
+
# "still live" about a value that was never a fresh reply. That let
|
|
67
|
+
# the maps survive into a free-text screen and reinterpret a typed
|
|
68
|
+
# answer there as the previous menu's choice. This was fixed once
|
|
69
|
+
# here in Task 6 and once for Messenger in Task 13; both fixes had
|
|
70
|
+
# the same shape and the same blind spot, which is why the guard is
|
|
71
|
+
# gone rather than patched a third time.
|
|
72
|
+
clear_choice_state
|
|
57
73
|
|
|
58
74
|
# Call the app (executor -> flow)
|
|
59
75
|
type, prompt, choices, media = @app.call(context)
|
|
@@ -69,46 +85,116 @@ module FlowChat
|
|
|
69
85
|
|
|
70
86
|
private
|
|
71
87
|
|
|
88
|
+
# Titles first, then positions. A tap sends the title as its payload
|
|
89
|
+
# and a user who types what they read sends the same string, so both
|
|
90
|
+
# arrive at the same entry - which is why there is no separate alias
|
|
91
|
+
# map any more. A position is the fallback, and only means anything
|
|
92
|
+
# when a number is genuinely on screen; it loses to a title because a
|
|
93
|
+
# choice labelled "1" would otherwise be unreachable.
|
|
94
|
+
def resolved_choice
|
|
95
|
+
input = @context.input.to_s
|
|
96
|
+
get_choice_mapping[input] || get_position_mapping[input]
|
|
97
|
+
end
|
|
98
|
+
|
|
72
99
|
def intercept?
|
|
73
|
-
# Intercept if
|
|
74
|
-
|
|
75
|
-
should_intercept = choice_mapping.present? &&
|
|
76
|
-
@context.input.present? &&
|
|
77
|
-
choice_mapping.key?(@context.input.to_s)
|
|
100
|
+
# Intercept if user input matches a generated id or a stored position
|
|
101
|
+
should_intercept = @context.input.present? && resolved_choice.present?
|
|
78
102
|
|
|
79
103
|
if should_intercept
|
|
80
|
-
FlowChat.logger.debug { "Whatsapp::ChoiceMapper: Intercepting - input: #{@context.input}, mapped to: #{
|
|
104
|
+
FlowChat.logger.debug { "Whatsapp::ChoiceMapper: Intercepting - input: #{@context.input}, mapped to: #{resolved_choice}" }
|
|
81
105
|
end
|
|
82
106
|
|
|
83
107
|
should_intercept
|
|
84
108
|
end
|
|
85
109
|
|
|
86
110
|
def handle_choice_input
|
|
87
|
-
|
|
88
|
-
original_choice = choice_mapping[@context.input.to_s]
|
|
111
|
+
original_choice = resolved_choice
|
|
89
112
|
|
|
90
113
|
FlowChat.logger.info { "Whatsapp::ChoiceMapper: Resolving choice input #{@context.input} to #{original_choice}" }
|
|
91
114
|
|
|
92
|
-
# Replace the generated ID with the original choice key
|
|
115
|
+
# Replace the generated ID (or typed position) with the original choice key
|
|
93
116
|
@context.input = original_choice
|
|
94
117
|
end
|
|
95
118
|
|
|
119
|
+
# The value WhatsApp sends back IS the displayed title.
|
|
120
|
+
# FlowChat::ChoiceTitles guarantees the titles in a set are distinct -
|
|
121
|
+
# numbering the whole set when they would not be - so a title needs no
|
|
122
|
+
# separate id space to be unique in, and there is nothing left for a
|
|
123
|
+
# generated id to collide with.
|
|
124
|
+
#
|
|
125
|
+
# This replaces an id generated by normalizing the label, which was
|
|
126
|
+
# lossy: stripping "!" turned "Yes!" into the id "Yes", which was
|
|
127
|
+
# another choice's label verbatim, and the id map was consulted before
|
|
128
|
+
# the alias map. Titles are also bounded by the rung's cap (20 button,
|
|
129
|
+
# 24 list row), comfortably inside WhatsApp's id caps of 256 and 200.
|
|
96
130
|
def create_id_mapping(choices)
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
131
|
+
cap = display_title_cap(choices.length)
|
|
132
|
+
return passthrough_mapping(choices) if cap.nil?
|
|
133
|
+
|
|
134
|
+
title_choices = {}
|
|
100
135
|
choice_mapping = {}
|
|
101
136
|
|
|
102
|
-
choices.each do |key,
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
id_choices[generated_id] = value
|
|
106
|
-
choice_mapping[generated_id] = key.to_s
|
|
137
|
+
FlowChat::ChoiceTitles.build(choices, cap).each do |key, label, title, _truncated|
|
|
138
|
+
title_choices[title] = label
|
|
139
|
+
choice_mapping[title] = key
|
|
107
140
|
end
|
|
108
141
|
|
|
109
142
|
store_choice_mapping(choice_mapping)
|
|
110
|
-
|
|
111
|
-
|
|
143
|
+
|
|
144
|
+
if number_choices?(choices)
|
|
145
|
+
store_position_mapping(choices.keys.map.with_index(1) { |key, i| [i.to_s, key.to_s] }.to_h)
|
|
146
|
+
else
|
|
147
|
+
clear_position_mapping
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
title_choices
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Above the row cap the renderer numbers the body and prints each full
|
|
154
|
+
# label beside its number, so the label itself is what is on screen and
|
|
155
|
+
# stays resolvable - nothing is truncated, so there is no shortened
|
|
156
|
+
# form to key on instead.
|
|
157
|
+
#
|
|
158
|
+
# A label shared by two choices is dropped rather than resolved to the
|
|
159
|
+
# first of them. It identifies neither on a screen that shows both, and
|
|
160
|
+
# the number printed next to each is the reply that does.
|
|
161
|
+
def passthrough_mapping(choices)
|
|
162
|
+
labels = choices.map { |key, label| [label.to_s, key.to_s] }
|
|
163
|
+
repeated = labels.map(&:first).tally
|
|
164
|
+
|
|
165
|
+
store_choice_mapping(labels.reject { |label, _| repeated[label] > 1 }.to_h)
|
|
166
|
+
store_position_mapping(choices.keys.map.with_index(1) { |key, i| [i.to_s, key.to_s] }.to_h)
|
|
167
|
+
choices
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# The title cap the renderer will use for these choices, or nil above
|
|
171
|
+
# the list cap, where the renderer falls back to a numbered body and
|
|
172
|
+
# shows the full label (no truncation, so no alias is needed).
|
|
173
|
+
#
|
|
174
|
+
# Goes through FlowChat::Meta::ChoiceLadder, the same helper
|
|
175
|
+
# FlowChat::Whatsapp::Renderer#build_interactive_message consults
|
|
176
|
+
# (via FlowChat::Config.whatsapp#ladder_limits), rather than
|
|
177
|
+
# re-deriving the rung from its own count comparisons: the mapper
|
|
178
|
+
# runs before the renderer and has no way to ask it which rung it
|
|
179
|
+
# chose, so both asking the same shared helper is what keeps them
|
|
180
|
+
# from disagreeing, the same reason Messenger's mapper does this too.
|
|
181
|
+
def display_title_cap(count)
|
|
182
|
+
case FlowChat::Meta::ChoiceLadder.rung_for(count, FlowChat::Config.whatsapp.ladder_limits)
|
|
183
|
+
when :quick_replies then FlowChat::Whatsapp::Renderer::BUTTON_TITLE_LENGTH
|
|
184
|
+
when :carousel then FlowChat::Whatsapp::Renderer::LIST_ROW_TITLE_LENGTH
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# A position number is only worth resolving when one is genuinely on
|
|
189
|
+
# screen: above the row cap, where the renderer has no title left to
|
|
190
|
+
# show and numbers the body directly (display_title_cap is nil), or
|
|
191
|
+
# on a button/list rung whose titles FlowChat::ChoiceTitles decided
|
|
192
|
+
# were ambiguous and prefixed with a number. A short, unique set of
|
|
193
|
+
# titles (the common case) shows no number at all, so a typed digit
|
|
194
|
+
# there is free text, not a position.
|
|
195
|
+
def number_choices?(choices)
|
|
196
|
+
cap = display_title_cap(choices.length)
|
|
197
|
+
cap.nil? || FlowChat::ChoiceTitles.ambiguous?(choices, cap)
|
|
112
198
|
end
|
|
113
199
|
|
|
114
200
|
def store_choice_mapping(mapping)
|
|
@@ -125,21 +211,22 @@ module FlowChat
|
|
|
125
211
|
FlowChat.logger.debug { "Whatsapp::ChoiceMapper: Cleared choice mapping" }
|
|
126
212
|
end
|
|
127
213
|
|
|
128
|
-
def
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
214
|
+
def store_position_mapping(mapping)
|
|
215
|
+
@session.set("whatsapp.position_mapping", mapping)
|
|
216
|
+
FlowChat.logger.debug { "Whatsapp::ChoiceMapper: Stored position mapping: #{mapping}" }
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def get_position_mapping
|
|
220
|
+
@session.get("whatsapp.position_mapping") || {}
|
|
133
221
|
end
|
|
134
222
|
|
|
135
|
-
def
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
choice_mapping = get_choice_mapping
|
|
139
|
-
return false if choice_mapping.empty?
|
|
223
|
+
def clear_position_mapping
|
|
224
|
+
@session.delete("whatsapp.position_mapping")
|
|
225
|
+
end
|
|
140
226
|
|
|
141
|
-
|
|
142
|
-
|
|
227
|
+
def clear_choice_state
|
|
228
|
+
clear_choice_mapping
|
|
229
|
+
clear_position_mapping
|
|
143
230
|
end
|
|
144
231
|
end
|
|
145
232
|
end
|
|
@@ -5,6 +5,20 @@ module FlowChat
|
|
|
5
5
|
class Renderer
|
|
6
6
|
include FlowChat::Renderers::MarkdownSupport
|
|
7
7
|
|
|
8
|
+
# WhatsApp button and list row title limits. The choice mapper reads
|
|
9
|
+
# these too, to alias the truncated title it knows this renderer will
|
|
10
|
+
# display for a given choice count.
|
|
11
|
+
BUTTON_TITLE_LENGTH = 20
|
|
12
|
+
LIST_ROW_TITLE_LENGTH = 24
|
|
13
|
+
|
|
14
|
+
# Meta: "Media asset caption text. Maximum 1024 characters." Documented
|
|
15
|
+
# for image, video, and document messages. Audio and sticker messages'
|
|
16
|
+
# own schemas list only id/link (audio also has voice) - no caption
|
|
17
|
+
# field at all - so they can never carry the numbered body this way,
|
|
18
|
+
# however short it is.
|
|
19
|
+
MAX_CAPTION_LENGTH = 1024
|
|
20
|
+
CAPTIONABLE_MEDIA_TYPES = [:image, :video, :document].freeze
|
|
21
|
+
|
|
8
22
|
attr_reader :message, :choices, :media
|
|
9
23
|
|
|
10
24
|
def initialize(message, choices: nil, media: nil)
|
|
@@ -39,7 +53,11 @@ module FlowChat
|
|
|
39
53
|
message.present? ? to_whatsapp(message) : nil
|
|
40
54
|
end
|
|
41
55
|
|
|
42
|
-
|
|
56
|
+
# @param caption [String, nil] defaults to the prompt text. Pass nil when
|
|
57
|
+
# this media is a companion sent ahead of a choice message: the prompt
|
|
58
|
+
# is about to appear in that message's body, and a caption here would
|
|
59
|
+
# just duplicate it.
|
|
60
|
+
def build_media_message(caption: formatted_caption)
|
|
43
61
|
media_type = media[:type] || :image
|
|
44
62
|
url = media[:url]
|
|
45
63
|
id = media[:id]
|
|
@@ -50,26 +68,28 @@ module FlowChat
|
|
|
50
68
|
options = {}
|
|
51
69
|
options[:url] = url if url
|
|
52
70
|
options[:id] = id if id
|
|
53
|
-
options[:caption] =
|
|
71
|
+
options[:caption] = caption if caption
|
|
54
72
|
[:media_image, "", options]
|
|
55
73
|
when :document
|
|
56
74
|
options = {}
|
|
57
75
|
options[:url] = url if url
|
|
58
76
|
options[:id] = id if id
|
|
59
|
-
options[:caption] =
|
|
77
|
+
options[:caption] = caption if caption
|
|
60
78
|
options[:filename] = filename if filename
|
|
61
79
|
[:media_document, "", options]
|
|
62
80
|
when :audio
|
|
63
81
|
options = {}
|
|
64
82
|
options[:url] = url if url
|
|
65
83
|
options[:id] = id if id
|
|
66
|
-
|
|
84
|
+
# Meta's audio message schema carries id, link and voice, with no
|
|
85
|
+
# caption, so a caption here would claim a field the API does not
|
|
86
|
+
# accept. Same as stickers below.
|
|
67
87
|
[:media_audio, "", options]
|
|
68
88
|
when :video
|
|
69
89
|
options = {}
|
|
70
90
|
options[:url] = url if url
|
|
71
91
|
options[:id] = id if id
|
|
72
|
-
options[:caption] =
|
|
92
|
+
options[:caption] = caption if caption
|
|
73
93
|
[:media_video, "", options]
|
|
74
94
|
when :sticker
|
|
75
95
|
options = {}
|
|
@@ -95,45 +115,99 @@ module FlowChat
|
|
|
95
115
|
end
|
|
96
116
|
end
|
|
97
117
|
|
|
118
|
+
# Media is additive: it does not change which choice surface is used.
|
|
119
|
+
# 3 or fewer choices is the one case WhatsApp can carry both in a
|
|
120
|
+
# single message (buttons with a media header), so that stays as is.
|
|
121
|
+
# Above that, there is no interactive surface left that can carry
|
|
122
|
+
# media - Meta's interactive message reference documents header.type:
|
|
123
|
+
# text for list messages; image, video and document headers are only
|
|
124
|
+
# defined for button messages - so the media goes out as its own
|
|
125
|
+
# message and the list or numbered rendering follows unchanged.
|
|
126
|
+
#
|
|
127
|
+
# The one exception is the numbered rung above the list cap: there the
|
|
128
|
+
# choices are already nothing but text, and a captionable media
|
|
129
|
+
# message can carry that text itself, so a captioned single message
|
|
130
|
+
# replaces the media-then-text split whenever it fits.
|
|
98
131
|
def build_selection_message_with_media
|
|
99
|
-
|
|
132
|
+
choice_hash = normalized_choices
|
|
133
|
+
|
|
134
|
+
return build_buttons_message_with_media(choice_hash) if buttons_rung?(choice_hash.length)
|
|
135
|
+
|
|
136
|
+
type, content, options = build_interactive_message(choice_hash)
|
|
137
|
+
|
|
138
|
+
if type == :text
|
|
139
|
+
merged = media_caption_message(content)
|
|
140
|
+
return merged if merged
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
[type, content, options.merge(media: build_media_message(caption: nil))]
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Returns the single-message [type, content, options] triple when the
|
|
147
|
+
# media type can carry a caption and the combined text fits under
|
|
148
|
+
# Meta's cap, or nil when it does not - the caller falls back to the
|
|
149
|
+
# existing media-then-text split, which loses nothing.
|
|
150
|
+
def media_caption_message(content)
|
|
151
|
+
return nil unless CAPTIONABLE_MEDIA_TYPES.include?((media[:type] || :image).to_sym)
|
|
152
|
+
return nil if content.length > MAX_CAPTION_LENGTH
|
|
153
|
+
|
|
154
|
+
build_media_message(caption: content)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def normalized_choices
|
|
100
158
|
if choices.is_a?(Array)
|
|
101
|
-
|
|
102
|
-
build_buttons_message_with_media(choice_hash)
|
|
159
|
+
choices.each_with_index.to_h { |choice, index| [index.to_s, choice] }
|
|
103
160
|
elsif choices.is_a?(Hash)
|
|
104
|
-
|
|
161
|
+
choices
|
|
105
162
|
else
|
|
106
163
|
raise ArgumentError, "choices must be an Array or Hash"
|
|
107
164
|
end
|
|
108
165
|
end
|
|
109
166
|
|
|
167
|
+
# Goes through FlowChat::Meta::ChoiceLadder, shared with Messenger and
|
|
168
|
+
# Instagram, rather than its own count comparisons - see
|
|
169
|
+
# FlowChat::Config::WhatsappConfig#ladder_limits for how WhatsApp's two
|
|
170
|
+
# flat thresholds are bridged to the shape that helper expects. The
|
|
171
|
+
# choice mapper asks the same question the same way, so the two cannot
|
|
172
|
+
# drift on which rung a given count lands on.
|
|
110
173
|
def build_interactive_message(choice_hash)
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
# Use list for more than 3 choices
|
|
116
|
-
build_list_message(choice_hash)
|
|
174
|
+
case FlowChat::Meta::ChoiceLadder.rung_for(choice_hash.length, limits.ladder_limits)
|
|
175
|
+
when :none, :quick_replies then build_buttons_message(choice_hash)
|
|
176
|
+
when :carousel then build_list_message(choice_hash)
|
|
177
|
+
when :numbered then build_numbered_message(choice_hash)
|
|
117
178
|
end
|
|
118
179
|
end
|
|
119
180
|
|
|
181
|
+
# :none (zero choices) counts as a buttons-rung count here, matching
|
|
182
|
+
# what the plain `count <= max_buttons` comparison this replaced did
|
|
183
|
+
# for zero: there is no real-world case with zero choices, but this
|
|
184
|
+
# keeps the two call sites in build_selection_message_with_media and
|
|
185
|
+
# build_interactive_message agreeing with each other regardless.
|
|
186
|
+
def buttons_rung?(count)
|
|
187
|
+
rung = FlowChat::Meta::ChoiceLadder.rung_for(count, limits.ladder_limits)
|
|
188
|
+
rung == :none || rung == :quick_replies
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def limits
|
|
192
|
+
FlowChat::Config.whatsapp
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Whether titles are numbered, and the enumeration order positions come
|
|
196
|
+
# from, are both decided by FlowChat::ChoiceTitles over this same
|
|
197
|
+
# `choices` hash - the choice mapper goes through the same module over
|
|
198
|
+
# the same hash, so the two can never disagree on which titles are
|
|
199
|
+
# shown, which is what lets the mapper use a title as the wire value.
|
|
120
200
|
def build_buttons_message(choices)
|
|
121
|
-
buttons = choices.map do |key,
|
|
122
|
-
{
|
|
123
|
-
id: key.to_s,
|
|
124
|
-
title: truncate_text(value.to_s, 20) # WhatsApp button titles have a 20 character limit
|
|
125
|
-
}
|
|
201
|
+
buttons = FlowChat::ChoiceTitles.build(choices, BUTTON_TITLE_LENGTH).map do |key, _label, title, _truncated|
|
|
202
|
+
{id: key, title: title}
|
|
126
203
|
end
|
|
127
204
|
|
|
128
205
|
[:interactive_buttons, formatted_message, {buttons: buttons}]
|
|
129
206
|
end
|
|
130
207
|
|
|
131
208
|
def build_buttons_message_with_media(choices)
|
|
132
|
-
buttons = choices.map do |key,
|
|
133
|
-
{
|
|
134
|
-
id: key.to_s,
|
|
135
|
-
title: truncate_text(value.to_s, 20) # WhatsApp button titles have a 20 character limit
|
|
136
|
-
}
|
|
209
|
+
buttons = FlowChat::ChoiceTitles.build(choices, BUTTON_TITLE_LENGTH).map do |key, _label, title, _truncated|
|
|
210
|
+
{id: key, title: title}
|
|
137
211
|
end
|
|
138
212
|
|
|
139
213
|
# Build media header
|
|
@@ -175,50 +249,37 @@ module FlowChat
|
|
|
175
249
|
end
|
|
176
250
|
end
|
|
177
251
|
|
|
252
|
+
# See the comment on build_buttons_message: numbering and position both
|
|
253
|
+
# come from FlowChat::ChoiceTitles over this same `choices` hash.
|
|
254
|
+
#
|
|
255
|
+
# The description (a longer, secondary line WhatsApp renders below the
|
|
256
|
+
# title, up to 72 chars) is not numbered: nothing resolves a typed
|
|
257
|
+
# description back to a choice, only the title and the position are
|
|
258
|
+
# aliased, so a prefix there would just be noise. It is populated
|
|
259
|
+
# whenever the title's own label portion didn't fit - whether that's
|
|
260
|
+
# because the label alone exceeds the cap, or because a position
|
|
261
|
+
# prefix ate into the room left for it.
|
|
178
262
|
def build_list_message(choices)
|
|
179
|
-
items = choices.map do |key,
|
|
180
|
-
|
|
181
|
-
truncated_title = truncate_text(original_text, 24)
|
|
182
|
-
|
|
183
|
-
# If title was truncated, put full text in description (up to 72 chars)
|
|
184
|
-
description = if original_text.length > 24
|
|
185
|
-
truncate_text(original_text, 72)
|
|
186
|
-
end
|
|
263
|
+
items = FlowChat::ChoiceTitles.build(choices, LIST_ROW_TITLE_LENGTH).map do |key, label, title, truncated|
|
|
264
|
+
description = FlowChat::TextTruncator.truncate(label, 72) if truncated
|
|
187
265
|
|
|
188
266
|
{
|
|
189
|
-
id: key
|
|
190
|
-
title:
|
|
267
|
+
id: key,
|
|
268
|
+
title: title,
|
|
191
269
|
description: description
|
|
192
270
|
}.compact
|
|
193
271
|
end
|
|
194
272
|
|
|
195
|
-
|
|
196
|
-
sections = if items.length <= 10
|
|
197
|
-
[
|
|
198
|
-
{
|
|
199
|
-
title: "Options",
|
|
200
|
-
rows: items
|
|
201
|
-
}
|
|
202
|
-
]
|
|
203
|
-
else
|
|
204
|
-
# Paginate into multiple sections (max 10 items per section)
|
|
205
|
-
items.each_slice(10).with_index.map do |section_items, index|
|
|
206
|
-
start_num = (index * 10) + 1
|
|
207
|
-
end_num = start_num + section_items.length - 1
|
|
208
|
-
|
|
209
|
-
{
|
|
210
|
-
title: "#{start_num}-#{end_num}",
|
|
211
|
-
rows: section_items
|
|
212
|
-
}
|
|
213
|
-
end
|
|
214
|
-
end
|
|
215
|
-
|
|
216
|
-
[:interactive_list, formatted_message, {sections: sections}]
|
|
273
|
+
[:interactive_list, formatted_message, {sections: [{title: "Options", rows: items}]}]
|
|
217
274
|
end
|
|
218
275
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
276
|
+
# Above the row cap there is no interactive surface left, so the options go
|
|
277
|
+
# in the body and the user types a number. The choice mapper stores the
|
|
278
|
+
# positions for this rung so the digit resolves to the original key.
|
|
279
|
+
def build_numbered_message(choices)
|
|
280
|
+
numbered = choices.values.map.with_index(1) { |label, i| "#{i}. #{label}" }.join("\n")
|
|
281
|
+
|
|
282
|
+
[:text, "#{formatted_message}\n\n#{numbered}", {}]
|
|
222
283
|
end
|
|
223
284
|
|
|
224
285
|
# Convert text to WhatsApp format
|
data/lib/flow_chat.rb
CHANGED
|
@@ -10,16 +10,6 @@ loader.enable_reloading if defined?(Rails.env) && Rails.env.development?
|
|
|
10
10
|
loader.setup
|
|
11
11
|
|
|
12
12
|
module FlowChat
|
|
13
|
-
# Special input markers for non-text message types
|
|
14
|
-
module Input
|
|
15
|
-
LOCATION = "$location$"
|
|
16
|
-
MEDIA = "$media$"
|
|
17
|
-
CONTACT = "$contact$"
|
|
18
|
-
|
|
19
|
-
# Session markers
|
|
20
|
-
START = "$start$"
|
|
21
|
-
end
|
|
22
|
-
|
|
23
13
|
def self.root
|
|
24
14
|
Pathname.new __dir__
|
|
25
15
|
end
|
data/site/.nojekyll
ADDED
|
File without changes
|
data/site/.og-card.html
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<!--
|
|
3
|
+
Source for the social preview image (og.png), rendered at 1200x630.
|
|
4
|
+
Regenerate after editing this file:
|
|
5
|
+
|
|
6
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
|
|
7
|
+
--headless --disable-gpu --hide-scrollbars --force-device-scale-factor=1 \
|
|
8
|
+
--window-size=1200,630 \
|
|
9
|
+
--screenshot="site/og.png" "file://$PWD/site/.og-card.html"
|
|
10
|
+
|
|
11
|
+
Any headless Chromium works. Keep the window size at 1200x630 (the 1.91:1
|
|
12
|
+
ratio OpenGraph/Twitter scrapers expect). This file is a dotfile so it stays
|
|
13
|
+
out of normal listings; it is only ever opened locally to produce og.png.
|
|
14
|
+
-->
|
|
15
|
+
<html lang="en">
|
|
16
|
+
<head>
|
|
17
|
+
<meta charset="utf-8">
|
|
18
|
+
<style>
|
|
19
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
20
|
+
html, body { width: 1200px; height: 630px; }
|
|
21
|
+
:root {
|
|
22
|
+
--bg: #101317; --fg: #d9dde3; --soft: #8b93a0; --accent: #8b83f0;
|
|
23
|
+
--rule: #262b32; --code-bg: #191d23;
|
|
24
|
+
--mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
|
|
25
|
+
--sans: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
26
|
+
}
|
|
27
|
+
body {
|
|
28
|
+
background: var(--bg);
|
|
29
|
+
color: var(--fg);
|
|
30
|
+
font-family: var(--sans);
|
|
31
|
+
-webkit-font-smoothing: antialiased;
|
|
32
|
+
position: relative;
|
|
33
|
+
overflow: hidden;
|
|
34
|
+
}
|
|
35
|
+
.frame {
|
|
36
|
+
position: absolute; inset: 64px 72px;
|
|
37
|
+
display: flex; flex-direction: column; justify-content: space-between;
|
|
38
|
+
}
|
|
39
|
+
.top { display: flex; align-items: center; gap: 28px; }
|
|
40
|
+
.glyph { width: 108px; height: 108px; flex: none; display: block; }
|
|
41
|
+
.wordmark {
|
|
42
|
+
font-family: var(--mono);
|
|
43
|
+
font-size: 68px;
|
|
44
|
+
font-weight: 600;
|
|
45
|
+
letter-spacing: -0.01em;
|
|
46
|
+
line-height: 1;
|
|
47
|
+
}
|
|
48
|
+
.tagline {
|
|
49
|
+
font-size: 40px;
|
|
50
|
+
line-height: 1.32;
|
|
51
|
+
max-width: 1010px;
|
|
52
|
+
font-weight: 400;
|
|
53
|
+
}
|
|
54
|
+
.tagline b { font-weight: 600; }
|
|
55
|
+
.foot {
|
|
56
|
+
display: flex; align-items: center; justify-content: space-between;
|
|
57
|
+
font-family: var(--mono);
|
|
58
|
+
font-size: 24px;
|
|
59
|
+
color: var(--soft);
|
|
60
|
+
border-top: 1px solid var(--rule);
|
|
61
|
+
padding-top: 22px;
|
|
62
|
+
}
|
|
63
|
+
.foot .r { color: var(--accent); }
|
|
64
|
+
</style>
|
|
65
|
+
</head>
|
|
66
|
+
<body>
|
|
67
|
+
<div class="frame">
|
|
68
|
+
<div class="top">
|
|
69
|
+
<svg class="glyph" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
|
70
|
+
<rect width="32" height="32" rx="7" fill="#4f46c9"/>
|
|
71
|
+
<path d="M16 7h8a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-1v3l-3-3h-4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2z" fill="none" stroke="#fff" stroke-width="1.7" opacity="0.6"/>
|
|
72
|
+
<path d="M8 13h8a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-4l-3 3v-3H8a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2z" fill="#fff"/>
|
|
73
|
+
</svg>
|
|
74
|
+
<div class="wordmark">FlowChat</div>
|
|
75
|
+
</div>
|
|
76
|
+
|
|
77
|
+
<div class="tagline">
|
|
78
|
+
Write a conversation as an ordinary Ruby method. FlowChat replays it across
|
|
79
|
+
stateless webhooks, on <b>USSD</b>, <b>WhatsApp</b>, <b>Telegram</b>, and
|
|
80
|
+
<b>HTTP</b>.
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
<div class="foot">
|
|
84
|
+
<span>Conversational interfaces for Rails</span>
|
|
85
|
+
<span class="r">Radioactive Labs</span>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
</body>
|
|
89
|
+
</html>
|
data/site/favicon.svg
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
|
2
|
+
<rect width="32" height="32" rx="7" fill="#4f46c9"/>
|
|
3
|
+
<!-- two overlapping speech bubbles: a dialogue between two sides -->
|
|
4
|
+
<path d="M16 7h8a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-1v3l-3-3h-4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2z" fill="none" stroke="#fff" stroke-width="1.7" opacity="0.6"/>
|
|
5
|
+
<path d="M8 13h8a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-4l-3 3v-3H8a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2z" fill="#fff"/>
|
|
6
|
+
</svg>
|