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
|
@@ -11,7 +11,7 @@ class WhatsappController < ApplicationController
|
|
|
11
11
|
config.use_session_store FlowChat::Session::CacheSessionStore
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
-
processor.run
|
|
14
|
+
processor.run WhatsappWelcomeFlow, :main_page
|
|
15
15
|
rescue => e
|
|
16
16
|
Rails.logger.error "Error processing WhatsApp webhook: #{e.message}"
|
|
17
17
|
head :internal_server_error
|
|
@@ -30,7 +30,7 @@ class CustomWhatsappController < ApplicationController
|
|
|
30
30
|
config.use_session_store FlowChat::Session::CacheSessionStore
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
-
processor.run
|
|
33
|
+
processor.run WhatsappWelcomeFlow, :main_page
|
|
34
34
|
rescue => e
|
|
35
35
|
Rails.logger.error "Error processing WhatsApp webhook: #{e.message}"
|
|
36
36
|
head :internal_server_error
|
|
@@ -39,7 +39,8 @@ class CustomWhatsappController < ApplicationController
|
|
|
39
39
|
private
|
|
40
40
|
|
|
41
41
|
def build_whatsapp_config
|
|
42
|
-
|
|
42
|
+
# Configuration#initialize requires a name; pass nil for an anonymous one.
|
|
43
|
+
config = FlowChat::Whatsapp::Configuration.new(nil)
|
|
43
44
|
|
|
44
45
|
case Rails.env
|
|
45
46
|
when "development", "test"
|
|
@@ -66,7 +67,7 @@ class CustomWhatsappController < ApplicationController
|
|
|
66
67
|
end
|
|
67
68
|
|
|
68
69
|
# Example flow for WhatsApp
|
|
69
|
-
class
|
|
70
|
+
class WhatsappWelcomeFlow < FlowChat::Flow
|
|
70
71
|
def main_page
|
|
71
72
|
name = app.screen(:name) do |prompt|
|
|
72
73
|
prompt.ask "Hello! What's your name?",
|
|
@@ -75,9 +76,9 @@ class WelcomeFlow < FlowChat::Flow
|
|
|
75
76
|
|
|
76
77
|
choice = app.screen(:main_menu) do |prompt|
|
|
77
78
|
prompt.select "Hi #{name}! How can I help?", {
|
|
78
|
-
"info" => "
|
|
79
|
-
"support" => "
|
|
80
|
-
"feedback" => "
|
|
79
|
+
"info" => "Get Information",
|
|
80
|
+
"support" => "Contact Support",
|
|
81
|
+
"feedback" => "Give Feedback"
|
|
81
82
|
}
|
|
82
83
|
end
|
|
83
84
|
|
|
@@ -94,36 +95,36 @@ class WelcomeFlow < FlowChat::Flow
|
|
|
94
95
|
private
|
|
95
96
|
|
|
96
97
|
def show_info
|
|
97
|
-
app.say "
|
|
98
|
+
app.say "Located at 123 Main Street\nHours: Mon-Fri 9AM-6PM\nCall: (555) 123-4567"
|
|
98
99
|
end
|
|
99
100
|
|
|
100
101
|
def contact_support
|
|
101
102
|
method = app.screen(:contact_method) do |prompt|
|
|
102
103
|
prompt.select "How would you like to contact us?", {
|
|
103
|
-
"call" => "
|
|
104
|
-
"email" => "
|
|
104
|
+
"call" => "Call Us",
|
|
105
|
+
"email" => "Email Us"
|
|
105
106
|
}
|
|
106
107
|
end
|
|
107
108
|
|
|
108
109
|
case method
|
|
109
110
|
when "call"
|
|
110
|
-
app.say "
|
|
111
|
+
app.say "Call us at (555) 123-4567"
|
|
111
112
|
when "email"
|
|
112
|
-
app.say "
|
|
113
|
+
app.say "Email us at support@example.com"
|
|
113
114
|
end
|
|
114
115
|
end
|
|
115
116
|
|
|
116
117
|
def collect_feedback
|
|
117
118
|
rating = app.screen(:rating) do |prompt|
|
|
118
|
-
prompt.select "Rate our service:", ["
|
|
119
|
+
prompt.select "Rate our service (1-5):", ["1", "2", "3", "4", "5"]
|
|
119
120
|
end
|
|
120
121
|
|
|
121
122
|
feedback = app.screen(:feedback_text) do |prompt|
|
|
122
123
|
prompt.ask "Any additional comments?"
|
|
123
124
|
end
|
|
124
125
|
|
|
125
|
-
save_feedback(app.
|
|
126
|
-
app.say "Thank you for your feedback!
|
|
126
|
+
save_feedback(app.msisdn, rating, feedback)
|
|
127
|
+
app.say "Thank you for your feedback!"
|
|
127
128
|
end
|
|
128
129
|
|
|
129
130
|
def save_feedback(phone, rating, feedback)
|
|
@@ -1,66 +1,64 @@
|
|
|
1
1
|
# WhatsApp Media Examples
|
|
2
|
-
#
|
|
2
|
+
# Demonstrates sending and receiving media with FlowChat's WhatsApp integration.
|
|
3
3
|
|
|
4
|
-
#
|
|
4
|
+
# Sending media directly with the WhatsApp client, out of band.
|
|
5
5
|
config = FlowChat::Whatsapp::Configuration.from_credentials
|
|
6
6
|
client = FlowChat::Whatsapp::Client.new(config)
|
|
7
7
|
|
|
8
|
-
# Send different media types
|
|
9
8
|
client.send_image("+1234567890", "https://example.com/image.jpg", "Caption")
|
|
10
9
|
client.send_document("+1234567890", "https://example.com/doc.pdf", "Document title", "filename.pdf")
|
|
11
10
|
client.send_audio("+1234567890", "https://example.com/audio.mp3")
|
|
12
11
|
client.send_video("+1234567890", "https://example.com/video.mp4", "Video caption")
|
|
13
12
|
client.send_sticker("+1234567890", "https://example.com/sticker.webp")
|
|
14
13
|
|
|
15
|
-
#
|
|
14
|
+
# Sending and receiving media inside a flow.
|
|
16
15
|
class MediaFlow < FlowChat::Flow
|
|
17
16
|
def main_page
|
|
18
|
-
#
|
|
19
|
-
|
|
17
|
+
# app.media is always an Array<FlowChat::Media> (empty when the turn carried
|
|
18
|
+
# no attachment), so branch on whether it has any items.
|
|
19
|
+
if app.media.any?
|
|
20
20
|
handle_user_media
|
|
21
21
|
return
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
-
#
|
|
24
|
+
# Attach media to a prompt.
|
|
25
25
|
app.screen(:feedback) do |prompt|
|
|
26
26
|
prompt.ask "What do you think?",
|
|
27
|
-
media: {
|
|
28
|
-
type: :image,
|
|
29
|
-
url: "https://example.com/product.jpg"
|
|
30
|
-
}
|
|
27
|
+
media: {type: :image, url: "https://example.com/product.jpg"}
|
|
31
28
|
end
|
|
32
29
|
|
|
33
|
-
#
|
|
30
|
+
# Attach media to a terminal message.
|
|
34
31
|
app.say "Thanks for your feedback!",
|
|
35
|
-
media: {
|
|
36
|
-
type: :video,
|
|
37
|
-
url: "https://example.com/response.mp4"
|
|
38
|
-
}
|
|
32
|
+
media: {type: :video, url: "https://example.com/response.mp4"}
|
|
39
33
|
end
|
|
40
34
|
|
|
41
35
|
private
|
|
42
36
|
|
|
43
37
|
def handle_user_media
|
|
44
|
-
|
|
38
|
+
# Each item is a FlowChat::Media; item.type is a normalized Symbol
|
|
39
|
+
# (:image, :video, :audio, :document, :sticker).
|
|
40
|
+
media = app.media.first
|
|
45
41
|
|
|
46
|
-
case
|
|
47
|
-
when
|
|
42
|
+
case media.type
|
|
43
|
+
when :image
|
|
48
44
|
app.say "Thanks for the image! Processing..."
|
|
49
|
-
when
|
|
45
|
+
when :document
|
|
50
46
|
app.say "Document received. Reviewing..."
|
|
51
|
-
when
|
|
47
|
+
when :audio
|
|
52
48
|
app.say "Got your voice message!"
|
|
53
|
-
when
|
|
49
|
+
when :video
|
|
54
50
|
app.say "Video received. Analyzing..."
|
|
51
|
+
else
|
|
52
|
+
app.say "Attachment received."
|
|
55
53
|
end
|
|
56
54
|
end
|
|
57
55
|
end
|
|
58
56
|
|
|
59
|
-
#
|
|
57
|
+
# An out-of-band service for sending media outside a conversation, for example
|
|
58
|
+
# from a background job or another controller.
|
|
60
59
|
class MediaService
|
|
61
60
|
def initialize
|
|
62
|
-
@
|
|
63
|
-
@client = FlowChat::Whatsapp::Client.new(@config)
|
|
61
|
+
@client = FlowChat::Whatsapp::Client.new(FlowChat::Whatsapp::Configuration.from_credentials)
|
|
64
62
|
end
|
|
65
63
|
|
|
66
64
|
def send_welcome_package(phone_number, user_name)
|
|
@@ -69,68 +67,18 @@ class MediaService
|
|
|
69
67
|
end
|
|
70
68
|
|
|
71
69
|
def send_order_confirmation(phone_number, order_id, invoice_url)
|
|
72
|
-
@client.send_document(phone_number, invoice_url, "Order ##{order_id} confirmed
|
|
73
|
-
@client.send_buttons(phone_number, "Order confirmed
|
|
70
|
+
@client.send_document(phone_number, invoice_url, "Order ##{order_id} confirmed", "invoice.pdf")
|
|
71
|
+
@client.send_buttons(phone_number, "Order confirmed", [
|
|
74
72
|
{id: "track", title: "Track Order"},
|
|
75
73
|
{id: "support", title: "Contact Support"}
|
|
76
74
|
])
|
|
77
75
|
end
|
|
78
|
-
|
|
79
|
-
def process_user_media(media_id, media_type, user_phone)
|
|
80
|
-
# Download and process media
|
|
81
|
-
@client.get_media_url(media_id)
|
|
82
|
-
media_content = @client.download_media(media_id)
|
|
83
|
-
|
|
84
|
-
# Process based on type
|
|
85
|
-
case media_type
|
|
86
|
-
when "image"
|
|
87
|
-
process_image(media_content, user_phone)
|
|
88
|
-
when "document"
|
|
89
|
-
process_document(media_content, user_phone)
|
|
90
|
-
when "audio"
|
|
91
|
-
process_audio(media_content, user_phone)
|
|
92
|
-
end
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
private
|
|
96
|
-
|
|
97
|
-
def process_image(content, phone)
|
|
98
|
-
# Your image processing logic
|
|
99
|
-
@client.send_text(phone, "Image processed successfully! ✅")
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
def process_document(content, phone)
|
|
103
|
-
# Your document processing logic
|
|
104
|
-
@client.send_text(phone, "Document processed! 📄")
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
def process_audio(content, phone)
|
|
108
|
-
# Your audio processing logic
|
|
109
|
-
@client.send_text(phone, "Audio processed! 🎵")
|
|
110
|
-
end
|
|
111
76
|
end
|
|
112
77
|
|
|
113
|
-
# Controller
|
|
78
|
+
# Controller that sends media notifications on demand.
|
|
114
79
|
class NotificationController < ApplicationController
|
|
115
80
|
def send_media_notification
|
|
116
|
-
|
|
117
|
-
service.send_welcome_package(params[:phone], params[:name])
|
|
118
|
-
render json: {status: "sent"}
|
|
119
|
-
end
|
|
120
|
-
|
|
121
|
-
def send_order_confirmation
|
|
122
|
-
service = MediaService.new
|
|
123
|
-
service.send_order_confirmation(
|
|
124
|
-
params[:phone],
|
|
125
|
-
params[:order_id],
|
|
126
|
-
generate_invoice_url(params[:order_id])
|
|
127
|
-
)
|
|
81
|
+
MediaService.new.send_welcome_package(params[:phone], params[:name])
|
|
128
82
|
render json: {status: "sent"}
|
|
129
83
|
end
|
|
130
|
-
|
|
131
|
-
private
|
|
132
|
-
|
|
133
|
-
def generate_invoice_url(order_id)
|
|
134
|
-
"https://storage.example.com/invoices/#{order_id}.pdf"
|
|
135
|
-
end
|
|
136
84
|
end
|
data/lib/flow_chat/app.rb
CHANGED
|
@@ -4,7 +4,7 @@ module FlowChat
|
|
|
4
4
|
|
|
5
5
|
def initialize(context)
|
|
6
6
|
@context = context
|
|
7
|
-
@input =
|
|
7
|
+
@input = build_input
|
|
8
8
|
@navigation_stack = []
|
|
9
9
|
end
|
|
10
10
|
|
|
@@ -13,21 +13,57 @@ module FlowChat
|
|
|
13
13
|
raise ArgumentError, "screen has already been presented" if navigation_stack.include?(key)
|
|
14
14
|
|
|
15
15
|
navigation_stack << key
|
|
16
|
-
|
|
16
|
+
# A screen is answered once its key is stored — even when the stored value is
|
|
17
|
+
# blank (a caption-less attachment yields ""). Guard on presence-in-session
|
|
18
|
+
# (non-nil), not truthiness, so media-only (and false/blank) answers stick
|
|
19
|
+
# instead of re-asking every turn.
|
|
20
|
+
cached = session.get(key)
|
|
21
|
+
return cached unless cached.nil?
|
|
17
22
|
|
|
18
23
|
user_input = prepare_user_input
|
|
19
24
|
prompt = FlowChat::Prompt.new user_input
|
|
20
|
-
|
|
25
|
+
# The turn has been handed to a screen; later screens in this run must not
|
|
26
|
+
# re-consume it. We mark it consumed rather than discarding it, so the
|
|
27
|
+
# read accessors (text/media/...) stay available for the rest of the run.
|
|
28
|
+
@input_consumed = true
|
|
21
29
|
|
|
22
30
|
value = yield prompt
|
|
23
31
|
session.set(key, value)
|
|
24
32
|
value
|
|
25
33
|
end
|
|
26
34
|
|
|
35
|
+
# Consume this turn's message, so no screen answers it later.
|
|
36
|
+
#
|
|
37
|
+
# A flow that reads the inbound outside the screen system says so with this.
|
|
38
|
+
# Routing on the opening message is the usual reason: the flow consumes the
|
|
39
|
+
# message itself, and FlowChat cannot see that read, so #screen would hand
|
|
40
|
+
# the same message to the first prompt the run reaches. That prompt is then
|
|
41
|
+
# answered without ever being asked, and its message never goes out.
|
|
42
|
+
#
|
|
43
|
+
# Records the first-message marker that #screen would have written, so the
|
|
44
|
+
# gate it guards does not fire a turn late and swallow the customer's reply
|
|
45
|
+
# as though it were the opener.
|
|
46
|
+
#
|
|
47
|
+
# Returns the turn, so the caller can read its text and any attachment. The
|
|
48
|
+
# context keeps both: what is given up is only this turn's right to answer a
|
|
49
|
+
# screen, not the message itself. Contrast #clear_turn!, which discards the
|
|
50
|
+
# turn outright because a restart has to rebuild the App without it.
|
|
51
|
+
def consume_turn!
|
|
52
|
+
taken = input
|
|
53
|
+
maybe_note_first_message!(taken)
|
|
54
|
+
@input_consumed = true
|
|
55
|
+
taken
|
|
56
|
+
end
|
|
57
|
+
|
|
27
58
|
def go_back
|
|
28
59
|
return false if navigation_stack.empty?
|
|
29
60
|
|
|
30
|
-
|
|
61
|
+
# go_back raises RestartFlow, which the executor handles by rebuilding a
|
|
62
|
+
# fresh App from this same context. A per-instance @input_consumed flag
|
|
63
|
+
# would be lost on that rebuild, so clear the turn on the shared context
|
|
64
|
+
# too — otherwise the restarted screen re-consumes the back-trigger
|
|
65
|
+
# input/attachment as its answer instead of re-prompting.
|
|
66
|
+
clear_turn!
|
|
31
67
|
current_screen = navigation_stack.last
|
|
32
68
|
session.delete(current_screen)
|
|
33
69
|
|
|
@@ -63,16 +99,38 @@ module FlowChat
|
|
|
63
99
|
context["request.timestamp"]
|
|
64
100
|
end
|
|
65
101
|
|
|
102
|
+
# The sender's display name — distinct from a contact card they may share
|
|
103
|
+
# (that's #contact).
|
|
66
104
|
def contact_name
|
|
67
|
-
|
|
105
|
+
context["request.user_name"]
|
|
68
106
|
end
|
|
69
107
|
|
|
70
|
-
|
|
71
|
-
|
|
108
|
+
# Read accessors for the turn delegate to the Input value object, which is
|
|
109
|
+
# the single source of truth for this turn's text and attachments.
|
|
110
|
+
def text
|
|
111
|
+
input.text
|
|
72
112
|
end
|
|
73
113
|
|
|
114
|
+
# Always an Array<FlowChat::Media> (empty when none) — a list even on
|
|
115
|
+
# single-media platforms, so callers iterate uniformly.
|
|
74
116
|
def media
|
|
75
|
-
|
|
117
|
+
input.media
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def location
|
|
121
|
+
input.location
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def contact
|
|
125
|
+
input.contact
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def attachment
|
|
129
|
+
input.attachment
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def attachment_type
|
|
133
|
+
input.attachment_type
|
|
76
134
|
end
|
|
77
135
|
|
|
78
136
|
def session
|
|
@@ -81,13 +139,73 @@ module FlowChat
|
|
|
81
139
|
|
|
82
140
|
protected
|
|
83
141
|
|
|
142
|
+
# Discard the current turn so a rebuilt App (after RestartFlow) sees an empty
|
|
143
|
+
# inbound message instead of re-consuming it. Clears both this instance and
|
|
144
|
+
# the shared context, since the restart builds a new instance from context.
|
|
145
|
+
def clear_turn!
|
|
146
|
+
@input_consumed = true
|
|
147
|
+
@input = FlowChat::Input.new
|
|
148
|
+
context.input = nil
|
|
149
|
+
context["request.media"] = nil
|
|
150
|
+
context["request.location"] = nil
|
|
151
|
+
context["request.contact"] = nil
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# The turn as a FlowChat::Input value object. Its #present? accounts for
|
|
155
|
+
# attachments, so a caption-less photo still answers a screen even though its
|
|
156
|
+
# text is blank. Built once and kept for the whole run (see #screen).
|
|
157
|
+
def build_input
|
|
158
|
+
FlowChat::Input.new(
|
|
159
|
+
text: context.input,
|
|
160
|
+
media: wrap_media(context["request.media"]),
|
|
161
|
+
location: context["request.location"],
|
|
162
|
+
contact: context["request.contact"]
|
|
163
|
+
)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def wrap_media(raw)
|
|
167
|
+
return [] unless raw
|
|
168
|
+
|
|
169
|
+
items = raw.is_a?(Array) ? raw : [raw]
|
|
170
|
+
items.map { |data| FlowChat::Media.new(data, platform: platform, client: media_client) }
|
|
171
|
+
end
|
|
172
|
+
|
|
84
173
|
def prepare_user_input
|
|
174
|
+
return nil if @input_consumed
|
|
175
|
+
|
|
85
176
|
user_input = input
|
|
86
|
-
if
|
|
87
|
-
|
|
88
|
-
|
|
177
|
+
if maybe_note_first_message!(user_input)
|
|
178
|
+
# Swallow a text-only opener: the classic "wake the flow / show the first
|
|
179
|
+
# screen" behavior. An opener that carries an attachment is let through so
|
|
180
|
+
# the first screen can consume it rather than silently dropping the
|
|
181
|
+
# media/location/contact.
|
|
182
|
+
return nil unless user_input.attachment?
|
|
89
183
|
end
|
|
90
184
|
user_input
|
|
91
185
|
end
|
|
186
|
+
|
|
187
|
+
# Records this session's opening message if it has not been recorded yet, and
|
|
188
|
+
# returns whether this turn was it. Whoever meets the turn first records it, a
|
|
189
|
+
# screen or a flow consuming it itself, so the marker does not depend on which
|
|
190
|
+
# of them got there. Stores the text, a serializable string, not the Input.
|
|
191
|
+
#
|
|
192
|
+
# USSD has no use for the marker: its gateway replays the whole session on
|
|
193
|
+
# every request, so there is no opener to distinguish.
|
|
194
|
+
def maybe_note_first_message!(turn)
|
|
195
|
+
return false if platform == :ussd
|
|
196
|
+
return false unless session.get(FlowChat::Input::START).nil?
|
|
197
|
+
|
|
198
|
+
session.set(FlowChat::Input::START, turn.to_s)
|
|
199
|
+
true
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Every gateway parks its client on the context under its own platform name,
|
|
203
|
+
# so the lookup is the platform rather than a case per platform. Media on
|
|
204
|
+
# Messenger and Instagram arrives as a direct CDN url and needs no client to
|
|
205
|
+
# fetch, but a nil client would still deny a Media object anything the client
|
|
206
|
+
# is the only route to.
|
|
207
|
+
def media_client
|
|
208
|
+
context["#{platform}.client"]
|
|
209
|
+
end
|
|
92
210
|
end
|
|
93
211
|
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
module FlowChat
|
|
2
|
+
# Decides, once per choice set rather than once per choice, whether every
|
|
3
|
+
# title in that set needs a 1-based position prefix ("1. ", "2. ", and so
|
|
4
|
+
# on), then returns the on-screen title FlowChat will render for each
|
|
5
|
+
# choice.
|
|
6
|
+
#
|
|
7
|
+
# The trigger is ambiguity, not truncation specifically: a screen is
|
|
8
|
+
# ambiguous when, having computed each choice's title at the rung's cap,
|
|
9
|
+
# either any title had to be truncated, or two choices land on the same
|
|
10
|
+
# title. Both mean the titles as displayed cannot identify a choice on
|
|
11
|
+
# their own:
|
|
12
|
+
#
|
|
13
|
+
# - Truncation can make two different labels ("Transfer to savings
|
|
14
|
+
# account" / "Transfer to salary account") land on the same displayed
|
|
15
|
+
# text ("Transfer to sa...").
|
|
16
|
+
# - Two choices can share a label outright with no truncation involved at
|
|
17
|
+
# all (two accounts both nicknamed "Savings", a menu with two literal
|
|
18
|
+
# "Accept" options) - the keys behind them differ, but the titles a user
|
|
19
|
+
# would type back are identical.
|
|
20
|
+
#
|
|
21
|
+
# Numbering is decided for the whole set, never per choice: prefixing only
|
|
22
|
+
# the affected title would produce "Yes" / "2. Transfer to savi...", a
|
|
23
|
+
# stray number with no "1." next to it to make sense of.
|
|
24
|
+
#
|
|
25
|
+
# The renderer and every choice mapper share this one decision, for the
|
|
26
|
+
# same reason FlowChat::TextTruncator is shared: two independent
|
|
27
|
+
# reimplementations could disagree about which rung is ambiguous, and a
|
|
28
|
+
# disagreement here means a title on screen that nothing resolves.
|
|
29
|
+
module ChoiceTitles
|
|
30
|
+
IDENTITY = ->(string) { string }
|
|
31
|
+
|
|
32
|
+
# @param choices [Hash] original choice key => label, in the order the
|
|
33
|
+
# caller numbers positions in - the renderer and the mapper must
|
|
34
|
+
# enumerate the same choices in the same order, or the titles and
|
|
35
|
+
# aliases they compute will not match
|
|
36
|
+
# @param cap [Integer] the rung's title length limit
|
|
37
|
+
# @return [Array<[String, String, String, Boolean]>] one
|
|
38
|
+
# [key, original_label, displayed_title, label_was_truncated] tuple per
|
|
39
|
+
# choice, in the same order as `choices`
|
|
40
|
+
# @param fold [Proc] the normalization the resolver applies to input
|
|
41
|
+
# before matching it. Two titles that fold to the same string cannot
|
|
42
|
+
# be told apart by that resolver, so the set is ambiguous and gets
|
|
43
|
+
# numbered. USSD is the one mapper that needs no fold: it resolves on
|
|
44
|
+
# position, which is injective by construction.
|
|
45
|
+
# @param measure [Symbol] :characters or :bytes, whichever unit the
|
|
46
|
+
# platform sizes the field in
|
|
47
|
+
def self.build(choices, cap, fold: IDENTITY, measure: :characters)
|
|
48
|
+
reason = ambiguity_reason(choices, cap, fold: fold, measure: measure)
|
|
49
|
+
prefixed = !reason.nil?
|
|
50
|
+
|
|
51
|
+
if prefixed
|
|
52
|
+
FlowChat.logger.debug { "#{name}: numbering choices, titles are ambiguous (#{reason})" }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
choices.map.with_index(1) do |(key, label), position|
|
|
56
|
+
label = label.to_s
|
|
57
|
+
width = (measure == :bytes) ? label.bytesize : label.length
|
|
58
|
+
|
|
59
|
+
if prefixed
|
|
60
|
+
prefix = "#{position}. "
|
|
61
|
+
prefix_width = (measure == :bytes) ? prefix.bytesize : prefix.length
|
|
62
|
+
title = FlowChat::TextTruncator.number(label, position, cap, measure: measure)
|
|
63
|
+
truncated = width > (cap - prefix_width)
|
|
64
|
+
else
|
|
65
|
+
title = FlowChat::TextTruncator.truncate(label, cap, measure: measure)
|
|
66
|
+
truncated = width > cap
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
[key.to_s, label, title, truncated]
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# @return [Boolean] whether this choice set is ambiguous at this cap
|
|
74
|
+
def self.ambiguous?(choices, cap, fold: IDENTITY, measure: :characters)
|
|
75
|
+
!ambiguity_reason(choices, cap, fold: fold, measure: measure).nil?
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# @return [String, nil] a description of why the set is ambiguous, for
|
|
79
|
+
# logging, or nil when it is not
|
|
80
|
+
def self.ambiguity_reason(choices, cap, fold: IDENTITY, measure: :characters)
|
|
81
|
+
labels = choices.map { |_, label| label.to_s }
|
|
82
|
+
titles = labels.map { |label| FlowChat::TextTruncator.truncate(label, cap, measure: measure) }
|
|
83
|
+
|
|
84
|
+
truncated_labels = labels.zip(titles).select { |label, title| title != label }.map(&:first)
|
|
85
|
+
duplicate_titles = titles.map { |title| fold.call(title) }.tally.select { |_, count| count > 1 }.keys
|
|
86
|
+
|
|
87
|
+
return nil if truncated_labels.empty? && duplicate_titles.empty?
|
|
88
|
+
|
|
89
|
+
parts = []
|
|
90
|
+
parts << "truncated: #{truncated_labels.inspect}" unless truncated_labels.empty?
|
|
91
|
+
parts << "duplicate titles: #{duplicate_titles.inspect}" unless duplicate_titles.empty?
|
|
92
|
+
parts.join(", ")
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|