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
|
@@ -6,12 +6,12 @@ class WhatsappController < ApplicationController
|
|
|
6
6
|
skip_forgery_protection
|
|
7
7
|
|
|
8
8
|
def webhook
|
|
9
|
-
processor = FlowChat::
|
|
9
|
+
processor = FlowChat::Processor.new(self, enable_simulator: Rails.env.development?) do |config|
|
|
10
10
|
config.use_gateway FlowChat::Whatsapp::Gateway::CloudApi
|
|
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
|
|
@@ -25,12 +25,12 @@ class CustomWhatsappController < ApplicationController
|
|
|
25
25
|
def webhook
|
|
26
26
|
custom_config = build_whatsapp_config
|
|
27
27
|
|
|
28
|
-
processor = FlowChat::
|
|
28
|
+
processor = FlowChat::Processor.new(self, enable_simulator: !Rails.env.production?) do |config|
|
|
29
29
|
config.use_gateway FlowChat::Whatsapp::Gateway::CloudApi, custom_config
|
|
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/flow_chat.gemspec
CHANGED
|
@@ -17,6 +17,7 @@ Gem::Specification.new do |spec|
|
|
|
17
17
|
spec.required_ruby_version = Gem::Requirement.new(">= 2.3.0")
|
|
18
18
|
|
|
19
19
|
spec.metadata["allowed_push_host"] = "https://rubygems.org"
|
|
20
|
+
spec.metadata["rubygems_mfa_required"] = "true"
|
|
20
21
|
|
|
21
22
|
spec.metadata["homepage_uri"] = spec.homepage
|
|
22
23
|
spec.metadata["source_code_uri"] = spec.homepage
|
|
@@ -36,4 +37,7 @@ Gem::Specification.new do |spec|
|
|
|
36
37
|
spec.add_dependency "actionpack", ">= 6"
|
|
37
38
|
spec.add_dependency "phonelib"
|
|
38
39
|
spec.add_dependency "ibsciss-middleware", "~> 0.4.2"
|
|
40
|
+
spec.add_dependency "intercom", "~> 4.2"
|
|
41
|
+
spec.add_dependency "reverse_markdown", "~> 3.0"
|
|
42
|
+
spec.add_dependency "kramdown", "~> 2.4"
|
|
39
43
|
end
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
module FlowChat
|
|
2
|
+
class App
|
|
3
|
+
attr_reader :input, :context, :navigation_stack
|
|
4
|
+
|
|
5
|
+
def initialize(context)
|
|
6
|
+
@context = context
|
|
7
|
+
@input = build_input
|
|
8
|
+
@navigation_stack = []
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def screen(key)
|
|
12
|
+
raise ArgumentError, "a block is expected" unless block_given?
|
|
13
|
+
raise ArgumentError, "screen has already been presented" if navigation_stack.include?(key)
|
|
14
|
+
|
|
15
|
+
navigation_stack << key
|
|
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?
|
|
22
|
+
|
|
23
|
+
user_input = prepare_user_input
|
|
24
|
+
prompt = FlowChat::Prompt.new user_input
|
|
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
|
|
29
|
+
|
|
30
|
+
value = yield prompt
|
|
31
|
+
session.set(key, value)
|
|
32
|
+
value
|
|
33
|
+
end
|
|
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
|
+
|
|
58
|
+
def go_back
|
|
59
|
+
return false if navigation_stack.empty?
|
|
60
|
+
|
|
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!
|
|
67
|
+
current_screen = navigation_stack.last
|
|
68
|
+
session.delete(current_screen)
|
|
69
|
+
|
|
70
|
+
# Restart the flow from the beginning
|
|
71
|
+
raise FlowChat::Interrupt::RestartFlow.new
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def say(msg, media: nil)
|
|
75
|
+
raise FlowChat::Interrupt::Terminate.new(msg, media: media)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def platform
|
|
79
|
+
context["request.platform"]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def gateway
|
|
83
|
+
context["request.gateway"]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def user_id
|
|
87
|
+
context["request.user_id"]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def msisdn
|
|
91
|
+
context["request.msisdn"]
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def message_id
|
|
95
|
+
context["request.message_id"]
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def timestamp
|
|
99
|
+
context["request.timestamp"]
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# The sender's display name — distinct from a contact card they may share
|
|
103
|
+
# (that's #contact).
|
|
104
|
+
def contact_name
|
|
105
|
+
context["request.user_name"]
|
|
106
|
+
end
|
|
107
|
+
|
|
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
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Always an Array<FlowChat::Media> (empty when none) — a list even on
|
|
115
|
+
# single-media platforms, so callers iterate uniformly.
|
|
116
|
+
def media
|
|
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
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def session
|
|
137
|
+
@context.session
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
protected
|
|
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
|
+
|
|
173
|
+
def prepare_user_input
|
|
174
|
+
return nil if @input_consumed
|
|
175
|
+
|
|
176
|
+
user_input = input
|
|
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?
|
|
183
|
+
end
|
|
184
|
+
user_input
|
|
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
|
|
210
|
+
end
|
|
211
|
+
end
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
begin
|
|
2
|
+
require "active_job"
|
|
3
|
+
rescue LoadError
|
|
4
|
+
# ActiveJob not available - async features will not be available
|
|
5
|
+
end
|
|
6
|
+
require "ostruct"
|
|
7
|
+
|
|
8
|
+
module FlowChat
|
|
9
|
+
# Base class for background flow processing jobs
|
|
10
|
+
# Users inherit from this and implement execute(controller, **job_params)
|
|
11
|
+
if defined?(ActiveJob::Base)
|
|
12
|
+
class AsyncJob < ActiveJob::Base
|
|
13
|
+
queue_as :default
|
|
14
|
+
|
|
15
|
+
def perform(request_context:, **job_params)
|
|
16
|
+
FlowChat.logger.debug { "AsyncJob: Starting background job with params: #{job_params.inspect}" }
|
|
17
|
+
|
|
18
|
+
# Create BackgroundController from serialized request
|
|
19
|
+
controller = BackgroundController.new(request_context)
|
|
20
|
+
|
|
21
|
+
# User implements execute and calls processor.run themselves
|
|
22
|
+
# Pass job_params as keyword arguments to execute
|
|
23
|
+
execute(controller, **job_params)
|
|
24
|
+
|
|
25
|
+
FlowChat.logger.debug { "AsyncJob: Background job completed successfully" }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Abstract method - user must implement
|
|
29
|
+
# User builds processor AND calls processor.run themselves
|
|
30
|
+
# Job params from use_async(JobClass, key: value) are passed as keyword arguments
|
|
31
|
+
def execute(controller, **job_params)
|
|
32
|
+
raise NotImplementedError, "Subclasses must implement #execute(controller, **job_params)"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
else
|
|
36
|
+
# Fallback when ActiveJob is not available
|
|
37
|
+
class AsyncJob
|
|
38
|
+
def perform(request_context:, **job_params)
|
|
39
|
+
FlowChat.logger.debug { "AsyncJob: Starting background job with params: #{job_params.inspect}" }
|
|
40
|
+
|
|
41
|
+
# Create BackgroundController from serialized request
|
|
42
|
+
controller = BackgroundController.new(request_context)
|
|
43
|
+
|
|
44
|
+
# User implements execute and calls processor.run themselves
|
|
45
|
+
# Pass job_params as keyword arguments to execute
|
|
46
|
+
execute(controller, **job_params)
|
|
47
|
+
|
|
48
|
+
FlowChat.logger.debug { "AsyncJob: Background job completed successfully" }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Abstract method - user must implement
|
|
52
|
+
# User builds processor AND calls processor.run themselves
|
|
53
|
+
# Job params from use_async(JobClass, key: value) are passed as keyword arguments
|
|
54
|
+
def execute(controller, **job_params)
|
|
55
|
+
raise NotImplementedError, "Subclasses must implement #execute(controller, **job_params)"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Stub perform_later for testing when ActiveJob is not available
|
|
59
|
+
def self.perform_later(args)
|
|
60
|
+
new.perform(**args)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Duck-type controller for background jobs
|
|
66
|
+
# Provides render/head no-ops and request interface
|
|
67
|
+
class BackgroundController
|
|
68
|
+
attr_reader :request, :response
|
|
69
|
+
|
|
70
|
+
def initialize(request_data)
|
|
71
|
+
FlowChat.logger.debug { "BackgroundController: Initializing with request data" }
|
|
72
|
+
@request = BackgroundRequest.new(request_data)
|
|
73
|
+
@response = nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Delegate params to request (mimics Rails controller behavior)
|
|
77
|
+
def params
|
|
78
|
+
request.params
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def render(options)
|
|
82
|
+
FlowChat.logger.debug { "BackgroundController: render called (no-op): #{options.inspect}" }
|
|
83
|
+
@response = options
|
|
84
|
+
nil # No-op in background
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def head(status)
|
|
88
|
+
FlowChat.logger.debug { "BackgroundController: head called (no-op): #{status}" }
|
|
89
|
+
@response = {status: status}
|
|
90
|
+
nil # No-op in background
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def is_a?(klass)
|
|
94
|
+
return true if klass == FlowChat::BackgroundController
|
|
95
|
+
super
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def kind_of?(klass)
|
|
99
|
+
return true if klass == FlowChat::BackgroundController
|
|
100
|
+
super
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Request object for background jobs
|
|
105
|
+
# Reconstructed from serialized webhook request data
|
|
106
|
+
class BackgroundRequest
|
|
107
|
+
attr_reader :params, :method, :headers, :host, :path, :remote_ip
|
|
108
|
+
|
|
109
|
+
def initialize(request_data)
|
|
110
|
+
@params = (request_data[:params] || {}).with_indifferent_access
|
|
111
|
+
@method = request_data[:method] || "POST"
|
|
112
|
+
@headers = OpenStruct.new(request_data[:headers] || {})
|
|
113
|
+
@host = request_data[:host]
|
|
114
|
+
@path = request_data[:path]
|
|
115
|
+
@body_content = request_data[:body]
|
|
116
|
+
@remote_ip = request_data[:remote_ip]
|
|
117
|
+
|
|
118
|
+
FlowChat.logger.debug { "BackgroundRequest: Initialized with method=#{@method}, params keys=#{@params.keys.inspect}" }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Rails request interface compatibility
|
|
122
|
+
def request_method
|
|
123
|
+
method.upcase
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def user_agent
|
|
127
|
+
@headers["User-Agent"]
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def ssl?
|
|
131
|
+
# Background jobs don't have SSL context
|
|
132
|
+
false
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def post?
|
|
136
|
+
method.upcase == "POST"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def get?
|
|
140
|
+
method.upcase == "GET"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def head?
|
|
144
|
+
method.upcase == "HEAD"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def body
|
|
148
|
+
# Return StringIO-like object if body content exists
|
|
149
|
+
@body_content ? BackgroundRequestBody.new(@body_content) : nil
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def cookies
|
|
153
|
+
# Background jobs don't have cookies
|
|
154
|
+
{}
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Body wrapper for BackgroundRequest
|
|
159
|
+
# Provides read() method that Rails expects
|
|
160
|
+
class BackgroundRequestBody
|
|
161
|
+
def initialize(content)
|
|
162
|
+
@content = content
|
|
163
|
+
@read = false
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def read
|
|
167
|
+
return "" if @read
|
|
168
|
+
@read = true
|
|
169
|
+
@content
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def rewind
|
|
173
|
+
@read = false
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|