flow_chat 0.9.0 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +29 -0
- data/.github/workflows/pages.yml +43 -0
- data/.gitignore +4 -1
- data/CHANGELOG.md +49 -0
- data/README.md +166 -295
- data/Rakefile +12 -1
- data/SECURITY.md +1 -1
- data/docs/architecture.md +52 -479
- data/docs/async-background-processing.md +31 -265
- data/docs/configuration.md +106 -613
- data/docs/factory-pattern.md +27 -324
- data/docs/gateway-context-variables.md +140 -143
- data/docs/gateway-development.md +86 -650
- data/docs/getting-started.md +40 -379
- data/docs/instrumentation.md +88 -279
- data/docs/platforms/instagram.md +278 -0
- data/docs/platforms/messenger.md +205 -0
- data/docs/platforms/telegram.md +47 -951
- data/docs/platforms/ussd.md +38 -653
- data/docs/platforms/whatsapp.md +73 -1321
- data/docs/superpowers/plans/2026-07-09-inbound-media-support.md +732 -0
- data/docs/superpowers/plans/2026-07-09-inbound-media-support.md.tasks.json +58 -0
- data/docs/superpowers/plans/2026-08-10-messenger-instagram.md +4064 -0
- data/docs/superpowers/plans/2026-08-10-messenger-instagram.md.tasks.json +226 -0
- data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md +972 -0
- data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md.tasks.json +88 -0
- data/docs/superpowers/specs/2026-07-09-inbound-media-support-design.md +195 -0
- data/docs/superpowers/specs/2026-08-10-messenger-instagram-design.md +391 -0
- data/docs/testing.md +26 -297
- data/examples/http_controller.rb +12 -10
- data/examples/intercom_configuration_example.rb +19 -24
- data/examples/intercom_controller.rb +8 -20
- data/examples/multi_tenant_whatsapp_controller.rb +61 -166
- data/examples/simulator_controller.rb +0 -1
- data/examples/ussd_controller.rb +86 -158
- data/examples/whatsapp_controller.rb +16 -15
- data/examples/whatsapp_media_examples.rb +27 -79
- data/flow_chat.gemspec +22 -2
- data/gemfiles/rails_main.gemfile +18 -0
- data/lib/flow_chat/app.rb +129 -11
- data/lib/flow_chat/async_job.rb +10 -0
- data/lib/flow_chat/choice_titles.rb +95 -0
- data/lib/flow_chat/config.rb +124 -1
- data/lib/flow_chat/delivery_error.rb +9 -0
- data/lib/flow_chat/gateway_async_support.rb +19 -1
- data/lib/flow_chat/http/configuration_error.rb +9 -0
- data/lib/flow_chat/http/gateway/simple.rb +24 -4
- data/lib/flow_chat/http/middleware/choice_mapper.rb +94 -0
- data/lib/flow_chat/input.rb +86 -0
- data/lib/flow_chat/instagram/client.rb +32 -0
- data/lib/flow_chat/instagram/configuration.rb +147 -0
- data/lib/flow_chat/instagram/configuration_error.rb +7 -0
- data/lib/flow_chat/instagram/gateway/send_api.rb +63 -0
- data/lib/flow_chat/instagram/middleware/choice_mapper.rb +22 -0
- data/lib/flow_chat/instagram/renderer.rb +23 -0
- data/lib/flow_chat/instrumentation/metrics_collector.rb +6 -1
- data/lib/flow_chat/instrumentation.rb +160 -1
- data/lib/flow_chat/intercom/client.rb +34 -28
- data/lib/flow_chat/intercom/configuration.rb +2 -49
- data/lib/flow_chat/intercom/configuration_error.rb +9 -0
- data/lib/flow_chat/intercom/gateway/intercom_api.rb +81 -57
- data/lib/flow_chat/intercom/middleware/choice_mapper.rb +101 -0
- data/lib/flow_chat/intercom/renderer.rb +57 -5
- data/lib/flow_chat/media.rb +121 -0
- data/lib/flow_chat/messenger/client.rb +264 -0
- data/lib/flow_chat/messenger/configuration.rb +103 -0
- data/lib/flow_chat/messenger/configuration_error.rb +9 -0
- data/lib/flow_chat/messenger/gateway/send_api.rb +42 -0
- data/lib/flow_chat/messenger/middleware/choice_mapper.rb +185 -0
- data/lib/flow_chat/messenger/renderer.rb +150 -0
- data/lib/flow_chat/meta/challenge.rb +24 -0
- data/lib/flow_chat/meta/choice_ladder.rb +37 -0
- data/lib/flow_chat/meta/configuration_error.rb +7 -0
- data/lib/flow_chat/meta/gateway_identity.rb +38 -0
- data/lib/flow_chat/meta/messaging_gateway.rb +471 -0
- data/lib/flow_chat/meta/signature.rb +30 -0
- data/lib/flow_chat/meta/signature_validation.rb +66 -0
- data/lib/flow_chat/meta/webhook_verification.rb +43 -0
- data/lib/flow_chat/named_configuration.rb +65 -0
- data/lib/flow_chat/prompt.rb +13 -16
- data/lib/flow_chat/renderers/markdown_support.rb +109 -0
- data/lib/flow_chat/security.rb +76 -0
- data/lib/flow_chat/session/middleware.rb +11 -2
- data/lib/flow_chat/simulator/controller.rb +31 -15
- data/lib/flow_chat/simulator/views/simulator.html.erb +184 -20
- data/lib/flow_chat/telegram/client.rb +47 -4
- data/lib/flow_chat/telegram/configuration.rb +2 -42
- data/lib/flow_chat/telegram/configuration_error.rb +9 -0
- data/lib/flow_chat/telegram/gateway/bot_api.rb +46 -28
- data/lib/flow_chat/telegram/middleware/choice_mapper.rb +77 -16
- data/lib/flow_chat/telegram/renderer.rb +10 -2
- data/lib/flow_chat/text_truncator.rb +75 -0
- data/lib/flow_chat/ussd/middleware/choice_mapper.rb +10 -0
- data/lib/flow_chat/version.rb +1 -1
- data/lib/flow_chat/whatsapp/client.rb +14 -7
- data/lib/flow_chat/whatsapp/configuration.rb +12 -51
- data/lib/flow_chat/whatsapp/configuration_error.rb +9 -0
- data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +259 -203
- data/lib/flow_chat/whatsapp/middleware/choice_mapper.rb +144 -57
- data/lib/flow_chat/whatsapp/renderer.rb +121 -60
- data/lib/flow_chat.rb +0 -10
- data/lib/tasks/release.rake +11 -1
- data/site/.nojekyll +0 -0
- data/site/.og-card.html +89 -0
- data/site/favicon.svg +6 -0
- data/site/index.html +209 -0
- data/site/og.png +0 -0
- metadata +60 -11
- data/lib/flow_chat/whatsapp/id_generator.rb +0 -124
|
@@ -0,0 +1,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
|