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
@@ -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
@@ -7,6 +7,39 @@ module FlowChat
7
7
  # When true (default), validation errors are combined with the original message.
8
8
  # When false, only the validation error message is shown to the user.
9
9
  mattr_accessor :combine_validation_error_with_message, default: true
10
+ # When true, inject logger into middleware stack. Defaults to true in Rails development.
11
+ mattr_accessor :inject_middleware_logger, default: defined?(Rails) && Rails.env.development?
12
+
13
+ # Called with the turn's context and the error when a gateway cannot
14
+ # deliver a reply the flow produced, before the error is re-raised.
15
+ #
16
+ # For the app that owns the turn, acting on records only it knows about: a
17
+ # gateway sends after the middleware stack has returned, so an app that
18
+ # recorded the reply has already recorded it as sent, and nothing
19
+ # downstream of the send can tell it otherwise.
20
+ #
21
+ # The whole context, because this is the app's own code reading what the
22
+ # app put there. Anything wanting only to watch deliveries fail should
23
+ # subscribe to message.delivery_failed instead, which carries no more than
24
+ # a successful send announces.
25
+ #
26
+ # Raising here would replace the delivery error with this one, so an
27
+ # exception is logged and dropped.
28
+ mattr_accessor :on_delivery_failure, default: nil
29
+
30
+ # Called with (context, result) once a reply has actually been delivered,
31
+ # where result is whatever the platform's client returned. The mirror of
32
+ # on_delivery_failure, and the only place an app can learn the id the platform
33
+ # gave a message: gateways deliver after the middleware stack has unwound, so
34
+ # a row written during the turn does not yet know it.
35
+ #
36
+ # Every gateway that delivers out of band names that id the same way, on the
37
+ # context as "delivery.platform_message_id", so an app does not have to know
38
+ # the shape of each platform's answer.
39
+ #
40
+ # Raising here would replace a successful send with an error, so an exception
41
+ # is logged and dropped.
42
+ mattr_accessor :on_delivery_success, default: nil
10
43
 
11
44
  # Session configuration object
12
45
  def self.session
@@ -28,8 +61,18 @@ module FlowChat
28
61
  @http ||= HttpConfig.new
29
62
  end
30
63
 
64
+ # Messenger-specific configuration object
65
+ def self.messenger
66
+ @messenger ||= MessengerConfig.new
67
+ end
68
+
69
+ # Instagram-specific configuration object
70
+ def self.instagram
71
+ @instagram ||= InstagramConfig.new
72
+ end
73
+
31
74
  class SessionConfig
32
- attr_accessor :boundaries, :hash_identifiers, :identifier
75
+ attr_accessor :boundaries, :hash_identifiers, :identifier, :session_id_proc
33
76
 
34
77
  def initialize
35
78
  # Session boundaries control how session IDs are constructed
@@ -37,14 +80,17 @@ module FlowChat
37
80
  # :gateway = separate sessions per gateway
38
81
  # :platform = separate sessions per platform (ussd, whatsapp)
39
82
  @boundaries = [:flow, :gateway, :platform]
40
-
83
+
41
84
  # Always hash phone numbers for privacy
42
85
  @hash_identifiers = true
43
-
86
+
44
87
  # Session identifier type (nil = let platforms choose their default)
45
88
  # :msisdn = durable sessions (durable across timeouts)
46
89
  # :request_id = ephemeral sessions (new session each time)
47
90
  @identifier = nil
91
+
92
+ # Proc for custom session ID generation (overrides default behavior when set)
93
+ @session_id_proc = nil
48
94
  end
49
95
  end
50
96
 
@@ -62,35 +108,92 @@ module FlowChat
62
108
  end
63
109
 
64
110
  class WhatsappConfig
65
- attr_accessor :background_job_class
66
- attr_reader :message_handling_mode, :api_base_url
111
+ # api_base_url is writable, unlike the limits beside it. Those are facts
112
+ # about the platform that an application cannot change by disagreeing. The
113
+ # version in the host is a choice, and one an application has to be able
114
+ # to make: Meta retires a version roughly every two years and pins the
115
+ # webhook payloads it sends to whatever the app's dashboard says, so an
116
+ # application straddling two versions must be able to close the gap
117
+ # without waiting for a release here.
118
+ attr_accessor :api_base_url
119
+ attr_reader :max_buttons, :max_list_rows
67
120
 
68
121
  def initialize
69
- @message_handling_mode = :inline
70
- @background_job_class = "WhatsappMessageJob"
71
- @api_base_url = "https://graph.facebook.com/v22.0"
122
+ @api_base_url = "https://graph.facebook.com/v23.0"
123
+ # Meta: "You cannot have more than 3 buttons in an interactive message."
124
+ @max_buttons = 3
125
+ # Meta: "up to 10 sections, with up to 10 rows for all sections combined".
126
+ @max_list_rows = 10
72
127
  end
73
128
 
74
- # Validate message handling mode
75
- def message_handling_mode=(mode)
76
- valid_modes = [:inline, :background, :simulator]
77
- unless valid_modes.include?(mode.to_sym)
78
- raise ArgumentError, "Invalid message handling mode: #{mode}. Valid modes: #{valid_modes.join(", ")}"
79
- end
80
- @message_handling_mode = mode.to_sym
129
+ # Bridges max_buttons/max_list_rows to the shape
130
+ # FlowChat::Meta::ChoiceLadder expects, so the renderer and the choice
131
+ # mapper can both ask it which rung a count lands on instead of each
132
+ # re-deriving the same two-threshold comparison independently.
133
+ #
134
+ # WhatsApp's list has no further structure the way Messenger's
135
+ # carousel has elements and buttons per element - it is just a flat
136
+ # row cap - so it is modelled as a single element holding every row
137
+ # (max_buttons_per_element: 1) purely to fit ChoiceLadder's
138
+ # carousel_capacity formula (elements * buttons_per_element). That
139
+ # shape stays private to this adapter rather than becoming
140
+ # max_buttons/max_list_rows' own public meaning, since "carousel" and
141
+ # "buttons per element" describe nothing WhatsApp actually has.
142
+ def ladder_limits
143
+ LADDER_LIMITS_SHAPE.new(max_buttons, max_list_rows, 1)
81
144
  end
82
145
 
83
- # Helper methods for mode checking
84
- def inline_mode?
85
- @message_handling_mode == :inline
86
- end
146
+ LADDER_LIMITS_SHAPE = Struct.new(:max_quick_replies, :max_carousel_elements, :max_buttons_per_element)
147
+ end
87
148
 
88
- def background_mode?
89
- @message_handling_mode == :background
149
+ class MessengerConfig
150
+ # Writable for the same reason as WhatsappConfig's, above.
151
+ attr_accessor :api_base_url
152
+ attr_reader :max_text_length, :max_quick_replies,
153
+ :max_quick_reply_title, :max_carousel_elements, :max_buttons_per_element,
154
+ :max_button_title, :max_element_title
155
+
156
+ def initialize
157
+ @api_base_url = "https://graph.facebook.com/v23.0"
158
+ # Meta does not state a text limit for Messenger on any current reference
159
+ # page, unlike Instagram's documented 1,000 bytes. 2000 is the long-cited
160
+ # figure and is safe to be wrong about in this direction: the client
161
+ # splits text at this value rather than truncating it, so a limit set too
162
+ # low sends an extra message and one set too high gets rejected. Raise it
163
+ # only against a documented figure.
164
+ @max_text_length = 2000
165
+ @max_quick_replies = 13
166
+ @max_quick_reply_title = 20
167
+ @max_carousel_elements = 10
168
+ @max_buttons_per_element = 3
169
+ @max_button_title = 20
170
+ @max_element_title = 80
90
171
  end
172
+ end
173
+
174
+ class InstagramConfig
175
+ # Both hosts writable, and separately: the two integration paths are
176
+ # configured independently at Meta, so an application moving one to a new
177
+ # version has not necessarily moved the other.
178
+ attr_accessor :api_base_url, :instagram_login_api_base_url
179
+ attr_reader :max_text_length, :max_quick_replies,
180
+ :max_quick_reply_title, :max_carousel_elements, :max_buttons_per_element,
181
+ :max_button_title, :max_element_title
91
182
 
92
- def simulator_mode?
93
- @message_handling_mode == :simulator
183
+ def initialize
184
+ # Facebook Login path: the linked Page speaks through the general Graph API host.
185
+ @api_base_url = "https://graph.facebook.com/v23.0"
186
+ # Instagram Login path: the Instagram professional account speaks through its
187
+ # own host instead, with no Facebook Page in the picture at all.
188
+ @instagram_login_api_base_url = "https://graph.instagram.com/v23.0"
189
+ # Meta: "Message text must be UTF-8 and be 1,000 bytes or less."
190
+ @max_text_length = 1000
191
+ @max_quick_replies = 13
192
+ @max_quick_reply_title = 20
193
+ @max_carousel_elements = 10
194
+ @max_buttons_per_element = 3
195
+ @max_button_title = 20
196
+ @max_element_title = 80
94
197
  end
95
198
  end
96
199
 
@@ -0,0 +1,9 @@
1
+ module FlowChat
2
+ # A reply the flow produced that the platform would not take.
3
+ #
4
+ # Raised by nothing: it exists so that a send which failed quietly, by
5
+ # answering nil rather than raising, still reaches on_delivery_failure and
6
+ # MESSAGE_DELIVERY_FAILED carrying something that names what happened. A
7
+ # subscriber written against a raising client sees the same shape either way.
8
+ class DeliveryError < StandardError; end
9
+ end
@@ -1,8 +1,8 @@
1
1
  module FlowChat
2
- class BaseExecutor
2
+ class Executor
3
3
  def initialize(app)
4
4
  @app = app
5
- FlowChat.logger.debug { "#{log_prefix}: Initialized #{platform_name} executor middleware" }
5
+ FlowChat.logger.debug { "#{log_prefix}: Initialized executor middleware" }
6
6
  end
7
7
 
8
8
  def call(context)
@@ -13,7 +13,7 @@ module FlowChat
13
13
  FlowChat.logger.info { "#{log_prefix}: Executing flow #{flow_class.name}##{action} for session #{session_id}" }
14
14
 
15
15
  platform_app = build_platform_app(context)
16
- FlowChat.logger.debug { "#{log_prefix}: #{platform_name} app built for flow execution" }
16
+ FlowChat.logger.debug { "#{log_prefix}: app built for flow execution" }
17
17
 
18
18
  flow = flow_class.new platform_app
19
19
  FlowChat.logger.debug { "#{log_prefix}: Flow instance created, invoking #{action} method" }
@@ -41,17 +41,12 @@ module FlowChat
41
41
 
42
42
  protected
43
43
 
44
- # Subclasses must implement these methods
45
- def platform_name
46
- raise NotImplementedError, "Subclasses must implement platform_name"
47
- end
48
-
49
44
  def log_prefix
50
- raise NotImplementedError, "Subclasses must implement log_prefix"
45
+ "Executor"
51
46
  end
52
47
 
53
48
  def build_platform_app(context)
54
- raise NotImplementedError, "Subclasses must implement build_platform_app"
49
+ FlowChat::App.new(context)
55
50
  end
56
51
  end
57
- end
52
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowChat
4
+ # Factory provides centralized processor configuration for consistent setup across
5
+ # webhook and background contexts.
6
+ #
7
+ # Example:
8
+ # # In config/initializers/flow_chat.rb
9
+ # FlowChat::Factory.register :whatsapp do |controller|
10
+ # processor = FlowChat::Processor.new(controller) do |config|
11
+ # config.use_gateway FlowChat::Whatsapp::Gateway::CloudApi
12
+ # config.use_session_store FlowChat::Session::CacheSessionStore
13
+ # config.use_session_config(boundaries: [:flow])
14
+ # config.use_async(WhatsAppFlowJob)
15
+ # end
16
+ # processor.run(WhatsAppFlow, :start)
17
+ # end
18
+ #
19
+ # # In webhook controller
20
+ # FlowChat::Factory.execute(:whatsapp, controller: self)
21
+ #
22
+ # # In background job
23
+ # FlowChat::Factory.execute(:whatsapp, controller: controller)
24
+ class Factory
25
+ class << self
26
+ # Register a processor factory with a given name
27
+ #
28
+ # @param name [Symbol] The factory name (e.g., :whatsapp, :intercom)
29
+ # @param block [Proc] The factory block that receives controller
30
+ # @return [void]
31
+ #
32
+ # @example
33
+ # FlowChat::Factory.register :whatsapp do |controller|
34
+ # processor = FlowChat::Processor.new(controller) do |config|
35
+ # config.use_gateway FlowChat::Whatsapp::Gateway::CloudApi
36
+ # end
37
+ # processor.run(WhatsAppFlow, :start)
38
+ # end
39
+ def register(name, &block)
40
+ FlowChat.logger.debug { "Factory: Registering factory '#{name}'" }
41
+ factories[name] = block
42
+ end
43
+
44
+ # Execute a registered factory
45
+ #
46
+ # @param name [Symbol] The factory name
47
+ # @param controller [Object] The controller instance (webhook or background)
48
+ # @return [void]
49
+ # @raise [FactoryNotFoundError] If factory is not registered
50
+ #
51
+ # @example
52
+ # FlowChat::Factory.execute(:whatsapp, controller: self)
53
+ def execute(name, controller:)
54
+ factory = factories[name]
55
+ raise FactoryNotFoundError, "Factory '#{name}' not registered" unless factory
56
+
57
+ FlowChat.logger.debug { "Factory: Executing factory '#{name}'" }
58
+ factory.call(controller)
59
+ end
60
+
61
+ # Check if a factory is registered
62
+ #
63
+ # @param name [Symbol] The factory name
64
+ # @return [Boolean]
65
+ def registered?(name)
66
+ factories.key?(name)
67
+ end
68
+
69
+ # Get all registered factory names
70
+ #
71
+ # @return [Array<Symbol>]
72
+ def registered_factories
73
+ factories.keys
74
+ end
75
+
76
+ # Clear all registered factories (primarily for testing)
77
+ #
78
+ # @return [void]
79
+ def clear!
80
+ FlowChat.logger.debug { "Factory: Clearing all registered factories" }
81
+ factories.clear
82
+ end
83
+
84
+ private
85
+
86
+ def factories
87
+ @factories ||= {}
88
+ end
89
+ end
90
+
91
+ # Error raised when attempting to execute an unregistered factory
92
+ class FactoryNotFoundError < StandardError; end
93
+ end
94
+ end
@@ -0,0 +1,106 @@
1
+ require_relative "async_job"
2
+
3
+ module FlowChat
4
+ # Concern for gateways to support async background processing
5
+ # Mix this into gateway classes to enable async detection and job enqueueing
6
+ module GatewayAsyncSupport
7
+ attr_reader :controller, :context
8
+
9
+ # Check if gateway supports async processing
10
+ # Override in gateways that don't support async (e.g., USSD)
11
+ def async_supported?
12
+ true
13
+ end
14
+
15
+ # Detect if we're currently in background mode
16
+ def in_background?
17
+ @controller.is_a?(::FlowChat::BackgroundController)
18
+ end
19
+
20
+ # Check if async processing should be used
21
+ # Returns true if:
22
+ # - Not already in background mode
23
+ # - Processor has async enabled
24
+ # - Gateway supports async
25
+ def should_enqueue_async?
26
+ processor = @context["processor"]
27
+
28
+ !in_background? &&
29
+ processor&.async_enabled? &&
30
+ async_supported?
31
+ end
32
+
33
+ # Enqueue background job with serialized request context
34
+ # Returns true if job was enqueued, false otherwise
35
+ def enqueue_async_job
36
+ return false unless should_enqueue_async?
37
+
38
+ processor = @context["processor"]
39
+
40
+ FlowChat.logger.info { "#{self.class.name}: Async enabled - enqueuing background job" }
41
+
42
+ # Serialize request data for BackgroundController
43
+ request_data = {
44
+ params: @controller.request.params.to_h,
45
+ method: @controller.request.method,
46
+ headers: extract_headers_for_background(@controller.request),
47
+ host: extract_host(@controller.request),
48
+ path: extract_path(@controller.request),
49
+ body: extract_body_for_background(@controller.request),
50
+ remote_ip: extract_remote_ip(@controller.request)
51
+ }
52
+
53
+ # Enqueue user's job with request context and job params
54
+ processor.async_job_class.perform_later(
55
+ request_context: request_data,
56
+ **processor.async_job_params
57
+ )
58
+
59
+ FlowChat.logger.info { "#{self.class.name}: Background job enqueued successfully" }
60
+
61
+ true
62
+ end
63
+
64
+ # Extract serializable headers needed for background processing
65
+ # Override in gateways that need additional headers
66
+ def extract_headers_for_background(request)
67
+ {
68
+ "Content-Type" => request.headers["Content-Type"],
69
+ "User-Agent" => request.headers["User-Agent"]
70
+ }.compact
71
+ end
72
+
73
+ # Extract host from request for URL boundary support
74
+ def extract_host(request)
75
+ request.host
76
+ rescue
77
+ nil
78
+ end
79
+
80
+ # Extract path from request for URL boundary support
81
+ def extract_path(request)
82
+ request.path
83
+ rescue
84
+ nil
85
+ end
86
+
87
+ # Extract request body for background processing
88
+ # Override in gateways that need the request body
89
+ def extract_body_for_background(request)
90
+ return nil unless request.body
91
+
92
+ body_content = request.body.read
93
+ request.body.rewind # Reset for subsequent reads
94
+ body_content
95
+ rescue
96
+ nil
97
+ end
98
+
99
+ # Extract remote IP from request
100
+ def extract_remote_ip(request)
101
+ request.remote_ip
102
+ rescue
103
+ nil
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowChat
4
+ # Generic background job that uses the Factory pattern
5
+ # Automatically used when use_async is called without a job class
6
+ #
7
+ # Example:
8
+ # # In webhook controller
9
+ # processor = FlowChat::Processor.new(self) do |config|
10
+ # config.use_gateway FlowChat::Whatsapp::Gateway::CloudApi
11
+ # config.use_session_store FlowChat::Session::CacheSessionStore
12
+ # config.use_async(factory: :whatsapp) # No job class - uses GenericAsyncJob
13
+ # end
14
+ #
15
+ # # Background job executes:
16
+ # FlowChat::Factory.execute(:whatsapp, controller: controller)
17
+ class GenericAsyncJob < AsyncJob
18
+ def execute(controller, factory:, **job_params)
19
+ FlowChat.logger.debug { "GenericAsyncJob: Executing factory '#{factory}' with params: #{job_params.inspect}" }
20
+
21
+ unless FlowChat::Factory.registered?(factory)
22
+ raise FlowChat::Factory::FactoryNotFoundError, "Factory '#{factory}' not registered"
23
+ end
24
+
25
+ FlowChat::Factory.execute(factory, controller: controller)
26
+
27
+ FlowChat.logger.debug { "GenericAsyncJob: Factory '#{factory}' executed successfully" }
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,9 @@
1
+ module FlowChat
2
+ module Http
3
+ # Its own file so Zeitwerk can resolve it by name. Declared inside
4
+ # gateway/simple.rb, which Zeitwerk maps to a different constant, it existed only
5
+ # once that file had loaded for some other reason, so an application
6
+ # rescuing it, or a test naming it first, got an uninitialized constant.
7
+ class ConfigurationError < StandardError; end
8
+ end
9
+ end