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,234 @@
|
|
|
1
|
+
module FlowChat
|
|
2
|
+
module Whatsapp
|
|
3
|
+
module Middleware
|
|
4
|
+
# Maps what WhatsApp sends back to the choice key the flow branches on.
|
|
5
|
+
#
|
|
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.
|
|
9
|
+
#
|
|
10
|
+
# Flow:
|
|
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
|
|
17
|
+
#
|
|
18
|
+
# @example
|
|
19
|
+
# # Flow provides: {"create" => "Create Account"}
|
|
20
|
+
# # Titles built: "Create Account"
|
|
21
|
+
# # Transformed: {"Create Account" => "Create Account"}
|
|
22
|
+
# # Mapping: {"Create Account" => "create"}
|
|
23
|
+
#
|
|
24
|
+
# # With duplicates: {"yes" => "Accept", "no" => "Accept"}
|
|
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.
|
|
35
|
+
#
|
|
36
|
+
class ChoiceMapper
|
|
37
|
+
def initialize(app)
|
|
38
|
+
@app = app
|
|
39
|
+
FlowChat.logger.debug { "Whatsapp::ChoiceMapper: Initialized WhatsApp choice mapping middleware" }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def call(context)
|
|
43
|
+
@context = context
|
|
44
|
+
@session = context.session
|
|
45
|
+
|
|
46
|
+
session_id = context["session.id"]
|
|
47
|
+
FlowChat.logger.debug { "Whatsapp::ChoiceMapper: Processing request for session #{session_id}" }
|
|
48
|
+
|
|
49
|
+
if intercept?
|
|
50
|
+
FlowChat.logger.info { "Whatsapp::ChoiceMapper: Intercepting request for choice resolution - session #{session_id}" }
|
|
51
|
+
handle_choice_input
|
|
52
|
+
end
|
|
53
|
+
|
|
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
|
|
73
|
+
|
|
74
|
+
# Call the app (executor -> flow)
|
|
75
|
+
type, prompt, choices, media = @app.call(context)
|
|
76
|
+
|
|
77
|
+
# Transform choices if present (like USSD does)
|
|
78
|
+
if choices.present?
|
|
79
|
+
FlowChat.logger.debug { "Whatsapp::ChoiceMapper: Found choices, creating ID mapping" }
|
|
80
|
+
choices = create_id_mapping(choices)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
[type, prompt, choices, media]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
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
|
+
|
|
99
|
+
def intercept?
|
|
100
|
+
# Intercept if user input matches a generated id or a stored position
|
|
101
|
+
should_intercept = @context.input.present? && resolved_choice.present?
|
|
102
|
+
|
|
103
|
+
if should_intercept
|
|
104
|
+
FlowChat.logger.debug { "Whatsapp::ChoiceMapper: Intercepting - input: #{@context.input}, mapped to: #{resolved_choice}" }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
should_intercept
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def handle_choice_input
|
|
111
|
+
original_choice = resolved_choice
|
|
112
|
+
|
|
113
|
+
FlowChat.logger.info { "Whatsapp::ChoiceMapper: Resolving choice input #{@context.input} to #{original_choice}" }
|
|
114
|
+
|
|
115
|
+
# Replace the generated ID (or typed position) with the original choice key
|
|
116
|
+
@context.input = original_choice
|
|
117
|
+
end
|
|
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.
|
|
130
|
+
def create_id_mapping(choices)
|
|
131
|
+
cap = display_title_cap(choices.length)
|
|
132
|
+
return passthrough_mapping(choices) if cap.nil?
|
|
133
|
+
|
|
134
|
+
title_choices = {}
|
|
135
|
+
choice_mapping = {}
|
|
136
|
+
|
|
137
|
+
FlowChat::ChoiceTitles.build(choices, cap).each do |key, label, title, _truncated|
|
|
138
|
+
title_choices[title] = label
|
|
139
|
+
choice_mapping[title] = key
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
store_choice_mapping(choice_mapping)
|
|
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)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def store_choice_mapping(mapping)
|
|
201
|
+
@session.set("whatsapp.choice_mapping", mapping)
|
|
202
|
+
FlowChat.logger.debug { "Whatsapp::ChoiceMapper: Stored choice mapping: #{mapping}" }
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def get_choice_mapping
|
|
206
|
+
@session.get("whatsapp.choice_mapping") || {}
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def clear_choice_mapping
|
|
210
|
+
@session.delete("whatsapp.choice_mapping")
|
|
211
|
+
FlowChat.logger.debug { "Whatsapp::ChoiceMapper: Cleared choice mapping" }
|
|
212
|
+
end
|
|
213
|
+
|
|
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") || {}
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def clear_position_mapping
|
|
224
|
+
@session.delete("whatsapp.position_mapping")
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def clear_choice_state
|
|
228
|
+
clear_choice_mapping
|
|
229
|
+
clear_position_mapping
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
@@ -1,6 +1,24 @@
|
|
|
1
|
+
require "flow_chat/renderers/markdown_support"
|
|
2
|
+
|
|
1
3
|
module FlowChat
|
|
2
4
|
module Whatsapp
|
|
3
5
|
class Renderer
|
|
6
|
+
include FlowChat::Renderers::MarkdownSupport
|
|
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
|
+
|
|
4
22
|
attr_reader :message, :choices, :media
|
|
5
23
|
|
|
6
24
|
def initialize(message, choices: nil, media: nil)
|
|
@@ -24,25 +42,66 @@ module FlowChat
|
|
|
24
42
|
private
|
|
25
43
|
|
|
26
44
|
def build_text_message
|
|
27
|
-
[:text, message, {}]
|
|
45
|
+
[:text, to_whatsapp(message), {}]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def formatted_message
|
|
49
|
+
to_whatsapp(message)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def formatted_caption
|
|
53
|
+
message.present? ? to_whatsapp(message) : nil
|
|
28
54
|
end
|
|
29
55
|
|
|
30
|
-
|
|
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)
|
|
31
61
|
media_type = media[:type] || :image
|
|
32
|
-
url = media[:url]
|
|
62
|
+
url = media[:url]
|
|
63
|
+
id = media[:id]
|
|
33
64
|
filename = media[:filename]
|
|
34
65
|
|
|
35
66
|
case media_type.to_sym
|
|
36
67
|
when :image
|
|
37
|
-
|
|
68
|
+
options = {}
|
|
69
|
+
options[:url] = url if url
|
|
70
|
+
options[:id] = id if id
|
|
71
|
+
options[:caption] = caption if caption
|
|
72
|
+
[:media_image, "", options]
|
|
38
73
|
when :document
|
|
39
|
-
|
|
74
|
+
options = {}
|
|
75
|
+
options[:url] = url if url
|
|
76
|
+
options[:id] = id if id
|
|
77
|
+
options[:caption] = caption if caption
|
|
78
|
+
options[:filename] = filename if filename
|
|
79
|
+
[:media_document, "", options]
|
|
40
80
|
when :audio
|
|
41
|
-
|
|
81
|
+
options = {}
|
|
82
|
+
options[:url] = url if url
|
|
83
|
+
options[:id] = id if id
|
|
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.
|
|
87
|
+
[:media_audio, "", options]
|
|
42
88
|
when :video
|
|
43
|
-
|
|
89
|
+
options = {}
|
|
90
|
+
options[:url] = url if url
|
|
91
|
+
options[:id] = id if id
|
|
92
|
+
options[:caption] = caption if caption
|
|
93
|
+
[:media_video, "", options]
|
|
44
94
|
when :sticker
|
|
45
|
-
|
|
95
|
+
options = {}
|
|
96
|
+
options[:url] = url if url
|
|
97
|
+
options[:id] = id if id
|
|
98
|
+
[:media_sticker, "", options] # Stickers don't support captions
|
|
99
|
+
when :template
|
|
100
|
+
[:template, "", {
|
|
101
|
+
template_name: media[:template_name],
|
|
102
|
+
components: media[:components] || [],
|
|
103
|
+
language: media[:language] || "en_US"
|
|
104
|
+
}]
|
|
46
105
|
else
|
|
47
106
|
raise ArgumentError, "Unsupported media type: #{media_type}"
|
|
48
107
|
end
|
|
@@ -56,56 +115,110 @@ module FlowChat
|
|
|
56
115
|
end
|
|
57
116
|
end
|
|
58
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.
|
|
59
131
|
def build_selection_message_with_media
|
|
60
|
-
|
|
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
|
|
61
158
|
if choices.is_a?(Array)
|
|
62
|
-
|
|
63
|
-
build_buttons_message_with_media(choice_hash)
|
|
159
|
+
choices.each_with_index.to_h { |choice, index| [index.to_s, choice] }
|
|
64
160
|
elsif choices.is_a?(Hash)
|
|
65
|
-
|
|
161
|
+
choices
|
|
66
162
|
else
|
|
67
163
|
raise ArgumentError, "choices must be an Array or Hash"
|
|
68
164
|
end
|
|
69
165
|
end
|
|
70
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.
|
|
71
173
|
def build_interactive_message(choice_hash)
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
# Use list for more than 3 choices
|
|
77
|
-
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)
|
|
78
178
|
end
|
|
79
179
|
end
|
|
80
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.
|
|
81
200
|
def build_buttons_message(choices)
|
|
82
|
-
buttons = choices.map do |key,
|
|
83
|
-
{
|
|
84
|
-
id: key.to_s,
|
|
85
|
-
title: truncate_text(value.to_s, 20) # WhatsApp button titles have a 20 character limit
|
|
86
|
-
}
|
|
201
|
+
buttons = FlowChat::ChoiceTitles.build(choices, BUTTON_TITLE_LENGTH).map do |key, _label, title, _truncated|
|
|
202
|
+
{id: key, title: title}
|
|
87
203
|
end
|
|
88
204
|
|
|
89
|
-
[:interactive_buttons,
|
|
205
|
+
[:interactive_buttons, formatted_message, {buttons: buttons}]
|
|
90
206
|
end
|
|
91
207
|
|
|
92
208
|
def build_buttons_message_with_media(choices)
|
|
93
|
-
buttons = choices.map do |key,
|
|
94
|
-
{
|
|
95
|
-
id: key.to_s,
|
|
96
|
-
title: truncate_text(value.to_s, 20) # WhatsApp button titles have a 20 character limit
|
|
97
|
-
}
|
|
209
|
+
buttons = FlowChat::ChoiceTitles.build(choices, BUTTON_TITLE_LENGTH).map do |key, _label, title, _truncated|
|
|
210
|
+
{id: key, title: title}
|
|
98
211
|
end
|
|
99
212
|
|
|
100
213
|
# Build media header
|
|
101
214
|
header = build_media_header
|
|
102
215
|
|
|
103
|
-
[:interactive_buttons,
|
|
216
|
+
[:interactive_buttons, formatted_message, {buttons: buttons, header: header}]
|
|
104
217
|
end
|
|
105
218
|
|
|
106
219
|
def build_media_header
|
|
107
220
|
media_type = media[:type] || :image
|
|
108
|
-
url = media[:url]
|
|
221
|
+
url = media[:url]
|
|
109
222
|
filename = media[:filename]
|
|
110
223
|
|
|
111
224
|
case media_type.to_sym
|
|
@@ -136,50 +249,132 @@ module FlowChat
|
|
|
136
249
|
end
|
|
137
250
|
end
|
|
138
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.
|
|
139
262
|
def build_list_message(choices)
|
|
140
|
-
items = choices.map do |key,
|
|
141
|
-
|
|
142
|
-
truncated_title = truncate_text(original_text, 24)
|
|
143
|
-
|
|
144
|
-
# If title was truncated, put full text in description (up to 72 chars)
|
|
145
|
-
description = if original_text.length > 24
|
|
146
|
-
truncate_text(original_text, 72)
|
|
147
|
-
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
|
|
148
265
|
|
|
149
266
|
{
|
|
150
|
-
id: key
|
|
151
|
-
title:
|
|
267
|
+
id: key,
|
|
268
|
+
title: title,
|
|
152
269
|
description: description
|
|
153
270
|
}.compact
|
|
154
271
|
end
|
|
155
272
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
273
|
+
[:interactive_list, formatted_message, {sections: [{title: "Options", rows: items}]}]
|
|
274
|
+
end
|
|
275
|
+
|
|
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}", {}]
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Convert text to WhatsApp format
|
|
286
|
+
# Processes markdown through HTML, then converts HTML tags to WhatsApp syntax
|
|
287
|
+
def to_whatsapp(text)
|
|
288
|
+
return "" if text.nil?
|
|
289
|
+
|
|
290
|
+
# Pre-process: handle markdown features not supported by standard kramdown
|
|
291
|
+
processed = preprocess_markdown(text.to_s)
|
|
292
|
+
|
|
293
|
+
# Convert markdown to HTML
|
|
294
|
+
html = Kramdown::Document.new(processed, **kramdown_options).to_html.strip
|
|
295
|
+
html_to_whatsapp(html)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Handle markdown features not natively supported by kramdown
|
|
299
|
+
def preprocess_markdown(text)
|
|
300
|
+
result = text.dup
|
|
301
|
+
|
|
302
|
+
# Convert fenced code blocks to indented code blocks (kramdown native format)
|
|
303
|
+
# ```lang\ncode\n``` → indented with 4 spaces
|
|
304
|
+
result.gsub!(/^```\w*\n(.*?)^```/m) do
|
|
305
|
+
code = $1
|
|
306
|
+
code.lines.map { |line| " #{line}" }.join
|
|
175
307
|
end
|
|
176
308
|
|
|
177
|
-
|
|
309
|
+
# Convert ~~strikethrough~~ to HTML <del> tags (kramdown will pass through)
|
|
310
|
+
result.gsub!(/~~([^~]+)~~/, '<del>\1</del>')
|
|
311
|
+
|
|
312
|
+
result
|
|
178
313
|
end
|
|
179
314
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
315
|
+
# Convert HTML to WhatsApp formatting syntax
|
|
316
|
+
def html_to_whatsapp(html)
|
|
317
|
+
result = html.dup
|
|
318
|
+
|
|
319
|
+
# Convert code blocks first (before inline code)
|
|
320
|
+
# <pre><code>...</code></pre> → ```...```
|
|
321
|
+
result.gsub!(%r{<pre[^>]*><code[^>]*>(.*?)</code></pre>}m) { "```#{$1}```" }
|
|
322
|
+
|
|
323
|
+
# Convert inline code: <code>...</code> → `...`
|
|
324
|
+
result.gsub!(%r{<code[^>]*>(.*?)</code>}m) { "`#{$1}`" }
|
|
325
|
+
|
|
326
|
+
# Convert bold: <strong>...</strong> or <b>...</b> → *...*
|
|
327
|
+
result.gsub!(%r{<(?:strong|b)[^>]*>(.*?)</(?:strong|b)>}m) { "*#{$1}*" }
|
|
328
|
+
|
|
329
|
+
# Convert italic: <em>...</em> or <i>...</i> → _..._
|
|
330
|
+
result.gsub!(%r{<(?:em|i)[^>]*>(.*?)</(?:em|i)>}m) { "_#{$1}_" }
|
|
331
|
+
|
|
332
|
+
# Convert strikethrough: <s>...</s>, <del>...</del>, <strike>...</strike> → ~...~
|
|
333
|
+
result.gsub!(%r{<(?:s|del|strike)[^>]*>(.*?)</(?:s|del|strike)>}m) { "~#{$1}~" }
|
|
334
|
+
|
|
335
|
+
# Convert paragraphs to double newlines
|
|
336
|
+
result.gsub!(%r{<p[^>]*>(.*?)</p>}m) { "#{$1}\n\n" }
|
|
337
|
+
|
|
338
|
+
# Convert line breaks
|
|
339
|
+
result.gsub!(/<br\s*\/?>/, "\n")
|
|
340
|
+
|
|
341
|
+
# Convert links: <a href="url">text</a> → text (url)
|
|
342
|
+
# WhatsApp auto-links URLs, so we just show text and URL
|
|
343
|
+
result.gsub!(%r{<a[^>]*href=["']([^"']+)["'][^>]*>(.*?)</a>}m) do
|
|
344
|
+
url, text = $1, $2
|
|
345
|
+
(text == url) ? url : "#{text} (#{url})"
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# Convert blockquotes (WhatsApp doesn't have native support, use > prefix)
|
|
349
|
+
result.gsub!(%r{<blockquote[^>]*>(.*?)</blockquote>}m) do
|
|
350
|
+
$1.lines.map { |line| "> #{line.strip}" }.join("\n")
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# Convert lists
|
|
354
|
+
result.gsub!(%r{<ul[^>]*>(.*?)</ul>}m) do
|
|
355
|
+
items = $1.scan(%r{<li[^>]*>(.*?)</li>}m).flatten
|
|
356
|
+
items.map { |item| "• #{item.strip}" }.join("\n")
|
|
357
|
+
end
|
|
358
|
+
result.gsub!(%r{<ol[^>]*>(.*?)</ol>}m) do
|
|
359
|
+
items = $1.scan(%r{<li[^>]*>(.*?)</li>}m).flatten
|
|
360
|
+
items.map.with_index(1) { |item, i| "#{i}. #{item.strip}" }.join("\n")
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# Strip any remaining HTML tags
|
|
364
|
+
result.gsub!(/<[^>]+>/, "")
|
|
365
|
+
|
|
366
|
+
# Decode HTML entities
|
|
367
|
+
result.gsub!("&", "&")
|
|
368
|
+
result.gsub!("<", "<")
|
|
369
|
+
result.gsub!(">", ">")
|
|
370
|
+
result.gsub!(""", '"')
|
|
371
|
+
result.gsub!("'", "'")
|
|
372
|
+
result.gsub!(" ", " ")
|
|
373
|
+
|
|
374
|
+
# Clean up excessive newlines
|
|
375
|
+
result.gsub!(/\n{3,}/, "\n\n")
|
|
376
|
+
|
|
377
|
+
result.strip
|
|
183
378
|
end
|
|
184
379
|
end
|
|
185
380
|
end
|
data/lib/flow_chat.rb
CHANGED