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.
Files changed (147) hide show
  1. checksums.yaml +4 -4
  2. data/.cliff.toml +74 -0
  3. data/.github/workflows/ci.yml +2 -3
  4. data/.github/workflows/pages.yml +43 -0
  5. data/.github/workflows/release.yml +56 -0
  6. data/.standard.yml +4 -0
  7. data/CHANGELOG.md +48 -0
  8. data/CLAUDE.md +327 -0
  9. data/CONTRIBUTING.md +134 -0
  10. data/Gemfile +1 -0
  11. data/README.md +189 -133
  12. data/Rakefile +17 -2
  13. data/SECURITY.md +42 -349
  14. data/docs/architecture.md +83 -0
  15. data/docs/async-background-processing.md +64 -0
  16. data/docs/configuration.md +110 -287
  17. data/docs/factory-pattern.md +58 -0
  18. data/docs/gateway-context-variables.md +168 -0
  19. data/docs/gateway-development.md +159 -0
  20. data/docs/getting-started.md +90 -0
  21. data/docs/instrumentation.md +95 -175
  22. data/docs/platforms/instagram.md +278 -0
  23. data/docs/platforms/messenger.md +205 -0
  24. data/docs/platforms/telegram.md +109 -0
  25. data/docs/platforms/ussd.md +78 -0
  26. data/docs/platforms/whatsapp.md +147 -0
  27. data/docs/superpowers/plans/2026-07-09-inbound-media-support.md +732 -0
  28. data/docs/superpowers/plans/2026-07-09-inbound-media-support.md.tasks.json +58 -0
  29. data/docs/superpowers/plans/2026-08-10-messenger-instagram.md +4064 -0
  30. data/docs/superpowers/plans/2026-08-10-messenger-instagram.md.tasks.json +226 -0
  31. data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md +972 -0
  32. data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md.tasks.json +88 -0
  33. data/docs/superpowers/specs/2026-07-09-inbound-media-support-design.md +195 -0
  34. data/docs/superpowers/specs/2026-08-10-messenger-instagram-design.md +391 -0
  35. data/docs/testing.md +33 -426
  36. data/examples/custom_session_id_example.rb +119 -0
  37. data/examples/http_controller.rb +22 -20
  38. data/examples/intercom_configuration_example.rb +113 -0
  39. data/examples/intercom_controller.rb +182 -0
  40. data/examples/multi_tenant_whatsapp_controller.rb +63 -168
  41. data/examples/simulator_controller.rb +0 -1
  42. data/examples/ussd_controller.rb +88 -160
  43. data/examples/whatsapp_controller.rb +18 -17
  44. data/examples/whatsapp_media_examples.rb +27 -79
  45. data/flow_chat.gemspec +4 -0
  46. data/lib/flow_chat/app.rb +211 -0
  47. data/lib/flow_chat/async_job.rb +176 -0
  48. data/lib/flow_chat/choice_titles.rb +95 -0
  49. data/lib/flow_chat/config.rb +126 -23
  50. data/lib/flow_chat/delivery_error.rb +9 -0
  51. data/lib/flow_chat/{base_executor.rb → executor.rb} +6 -11
  52. data/lib/flow_chat/factory.rb +94 -0
  53. data/lib/flow_chat/gateway_async_support.rb +106 -0
  54. data/lib/flow_chat/generic_async_job.rb +30 -0
  55. data/lib/flow_chat/http/configuration_error.rb +9 -0
  56. data/lib/flow_chat/http/gateway/simple.rb +104 -36
  57. data/lib/flow_chat/http/middleware/choice_mapper.rb +94 -0
  58. data/lib/flow_chat/http/renderer.rb +3 -3
  59. data/lib/flow_chat/input.rb +86 -0
  60. data/lib/flow_chat/instagram/client.rb +32 -0
  61. data/lib/flow_chat/instagram/configuration.rb +147 -0
  62. data/lib/flow_chat/instagram/configuration_error.rb +7 -0
  63. data/lib/flow_chat/instagram/gateway/send_api.rb +63 -0
  64. data/lib/flow_chat/instagram/middleware/choice_mapper.rb +22 -0
  65. data/lib/flow_chat/instagram/renderer.rb +23 -0
  66. data/lib/flow_chat/instrumentation/metrics_collector.rb +6 -1
  67. data/lib/flow_chat/instrumentation/setup.rb +1 -1
  68. data/lib/flow_chat/instrumentation.rb +182 -0
  69. data/lib/flow_chat/intercom/client.rb +161 -0
  70. data/lib/flow_chat/intercom/configuration.rb +102 -0
  71. data/lib/flow_chat/intercom/configuration_error.rb +9 -0
  72. data/lib/flow_chat/intercom/gateway/intercom_api.rb +420 -0
  73. data/lib/flow_chat/intercom/middleware/choice_mapper.rb +101 -0
  74. data/lib/flow_chat/intercom/renderer.rb +123 -0
  75. data/lib/flow_chat/media.rb +121 -0
  76. data/lib/flow_chat/messenger/client.rb +264 -0
  77. data/lib/flow_chat/messenger/configuration.rb +103 -0
  78. data/lib/flow_chat/messenger/configuration_error.rb +9 -0
  79. data/lib/flow_chat/messenger/gateway/send_api.rb +42 -0
  80. data/lib/flow_chat/messenger/middleware/choice_mapper.rb +185 -0
  81. data/lib/flow_chat/messenger/renderer.rb +150 -0
  82. data/lib/flow_chat/meta/challenge.rb +24 -0
  83. data/lib/flow_chat/meta/choice_ladder.rb +37 -0
  84. data/lib/flow_chat/meta/configuration_error.rb +7 -0
  85. data/lib/flow_chat/meta/gateway_identity.rb +38 -0
  86. data/lib/flow_chat/meta/messaging_gateway.rb +468 -0
  87. data/lib/flow_chat/meta/signature.rb +30 -0
  88. data/lib/flow_chat/meta/signature_validation.rb +66 -0
  89. data/lib/flow_chat/meta/webhook_verification.rb +43 -0
  90. data/lib/flow_chat/named_configuration.rb +65 -0
  91. data/lib/flow_chat/phone_number_util.rb +37 -35
  92. data/lib/flow_chat/processor.rb +188 -0
  93. data/lib/flow_chat/prompt.rb +13 -16
  94. data/lib/flow_chat/renderers/markdown_support.rb +167 -0
  95. data/lib/flow_chat/security.rb +76 -0
  96. data/lib/flow_chat/session/middleware.rb +36 -11
  97. data/lib/flow_chat/simulator/controller.rb +31 -15
  98. data/lib/flow_chat/simulator/views/simulator.html.erb +184 -20
  99. data/lib/flow_chat/telegram/client.rb +283 -0
  100. data/lib/flow_chat/telegram/configuration.rb +78 -0
  101. data/lib/flow_chat/telegram/configuration_error.rb +9 -0
  102. data/lib/flow_chat/telegram/gateway/bot_api.rb +318 -0
  103. data/lib/flow_chat/telegram/middleware/choice_mapper.rb +96 -0
  104. data/lib/flow_chat/telegram/renderer.rb +133 -0
  105. data/lib/flow_chat/telegram.rb +7 -0
  106. data/lib/flow_chat/text_truncator.rb +75 -0
  107. data/lib/flow_chat/ussd/gateway/nalo.rb +24 -4
  108. data/lib/flow_chat/ussd/middleware/choice_mapper.rb +10 -0
  109. data/lib/flow_chat/ussd/middleware/pagination.rb +9 -5
  110. data/lib/flow_chat/ussd/renderer.rb +1 -1
  111. data/lib/flow_chat/version.rb +1 -1
  112. data/lib/flow_chat/whatsapp/client.rb +158 -20
  113. data/lib/flow_chat/whatsapp/configuration.rb +13 -52
  114. data/lib/flow_chat/whatsapp/configuration_error.rb +9 -0
  115. data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +335 -248
  116. data/lib/flow_chat/whatsapp/middleware/choice_mapper.rb +234 -0
  117. data/lib/flow_chat/whatsapp/renderer.rb +259 -64
  118. data/lib/flow_chat.rb +1 -1
  119. data/lib/tasks/release.rake +165 -0
  120. data/site/.nojekyll +0 -0
  121. data/site/.og-card.html +89 -0
  122. data/site/favicon.svg +6 -0
  123. data/site/index.html +209 -0
  124. data/site/og.png +0 -0
  125. metadata +132 -25
  126. data/docs/flows.md +0 -320
  127. data/docs/http-gateway-protocol.md +0 -432
  128. data/docs/images/simulator.png +0 -0
  129. data/docs/media.md +0 -153
  130. data/docs/sessions.md +0 -433
  131. data/docs/ussd-setup.md +0 -322
  132. data/docs/whatsapp-setup.md +0 -162
  133. data/examples/whatsapp_message_job.rb +0 -113
  134. data/lib/flow_chat/base_app.rb +0 -86
  135. data/lib/flow_chat/base_processor.rb +0 -146
  136. data/lib/flow_chat/http/app.rb +0 -6
  137. data/lib/flow_chat/http/middleware/executor.rb +0 -24
  138. data/lib/flow_chat/http/processor.rb +0 -33
  139. data/lib/flow_chat/session/rails_session_store.rb +0 -68
  140. data/lib/flow_chat/ussd/app.rb +0 -6
  141. data/lib/flow_chat/ussd/gateway/nsano.rb +0 -96
  142. data/lib/flow_chat/ussd/middleware/executor.rb +0 -24
  143. data/lib/flow_chat/ussd/processor.rb +0 -39
  144. data/lib/flow_chat/whatsapp/app.rb +0 -29
  145. data/lib/flow_chat/whatsapp/middleware/executor.rb +0 -24
  146. data/lib/flow_chat/whatsapp/processor.rb +0 -32
  147. 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::Whatsapp::Processor.new(self, enable_simulator: Rails.env.development?) do |config|
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 WelcomeFlow, :main_page
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::Whatsapp::Processor.new(self, enable_simulator: !Rails.env.production?) do |config|
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 WelcomeFlow, :main_page
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
- config = FlowChat::Whatsapp::Configuration.new
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 WelcomeFlow < FlowChat::Flow
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" => "📋 Get Information",
79
- "support" => "🆘 Contact Support",
80
- "feedback" => "💬 Give 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 "📍 Located at 123 Main Street\n🕒 Hours: Mon-Fri 9AM-6PM\n📞 Call: (555) 123-4567"
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" => "📞 Call Us",
104
- "email" => "📧 Email Us"
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 "📞 Call us at (555) 123-4567"
111
+ app.say "Call us at (555) 123-4567"
111
112
  when "email"
112
- app.say "📧 Email us at support@example.com"
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.phone_number, rating, feedback)
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
- # This file demonstrates media usage with FlowChat's WhatsApp integration
2
+ # Demonstrates sending and receiving media with FlowChat's WhatsApp integration.
3
3
 
4
- # Basic media sending with WhatsApp Client
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
- # Using media in flows
14
+ # Sending and receiving media inside a flow.
16
15
  class MediaFlow < FlowChat::Flow
17
16
  def main_page
18
- # Handle incoming media
19
- if app.media
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
- # Send media with prompts
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
- # Send media responses
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
- media_type = app.media["type"]
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 media_type
47
- when "image"
42
+ case media.type
43
+ when :image
48
44
  app.say "Thanks for the image! Processing..."
49
- when "document"
45
+ when :document
50
46
  app.say "Document received. Reviewing..."
51
- when "audio"
47
+ when :audio
52
48
  app.say "Got your voice message!"
53
- when "video"
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
- # Media service for out-of-band messaging
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
- @config = FlowChat::Whatsapp::Configuration.from_credentials
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!", "invoice.pdf")
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 example for media notifications
78
+ # Controller that sends media notifications on demand.
114
79
  class NotificationController < ApplicationController
115
80
  def send_media_notification
116
- service = MediaService.new
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