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
|
@@ -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
|
data/lib/flow_chat/config.rb
CHANGED
|
@@ -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
|
-
|
|
66
|
-
|
|
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
|
-
@
|
|
70
|
-
|
|
71
|
-
@
|
|
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
|
-
#
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
84
|
-
|
|
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
|
-
|
|
89
|
-
|
|
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
|
|
93
|
-
|
|
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
|
|
2
|
+
class Executor
|
|
3
3
|
def initialize(app)
|
|
4
4
|
@app = app
|
|
5
|
-
FlowChat.logger.debug { "#{log_prefix}: Initialized
|
|
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}:
|
|
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
|
-
|
|
45
|
+
"Executor"
|
|
51
46
|
end
|
|
52
47
|
|
|
53
48
|
def build_platform_app(context)
|
|
54
|
-
|
|
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
|