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,185 @@
1
+ module FlowChat
2
+ module Messenger
3
+ module Middleware
4
+ # Maps a reply back to the choice key the flow used.
5
+ #
6
+ # Two key spaces can be live at once. A tap sends the payload the
7
+ # renderer put on the button, which is the title shown on it, and a user
8
+ # who types what they read sends that same string - so both resolve
9
+ # through one map. Only when a number is genuinely on screen does a
10
+ # typed digit mean a position, which is the second space.
11
+ #
12
+ # The title is the payload rather than a separately generated id
13
+ # because FlowChat::ChoiceTitles already guarantees the titles in a set
14
+ # are distinct, numbering the set when they would not be. A generated
15
+ # id needed its own uniqueness rule, and the one it had was lossy: it
16
+ # stripped punctuation, so "Yes!" produced the id "Yes", which was
17
+ # another choice's label exactly.
18
+ class ChoiceMapper
19
+ ID_KEY = "messenger.choice_mapping"
20
+ POSITION_KEY = "messenger.position_mapping"
21
+
22
+ def initialize(app)
23
+ @app = app
24
+ FlowChat.logger.debug { "#{self.class.name}: Initialized" }
25
+ end
26
+
27
+ def call(context)
28
+ @context = context
29
+ @session = context.session
30
+
31
+ handle_choice_input if intercept?
32
+
33
+ # The maps belong to exactly one screen: this turn's, if it had a
34
+ # resolvable answer, or one that already fell out of use otherwise.
35
+ # Either way nothing here is still owed to the next screen, so they
36
+ # are cleared unconditionally rather than asked whether they still
37
+ # look "live" - create_mappings immediately below repopulates them
38
+ # whenever the app actually returns choices.
39
+ #
40
+ # An earlier version asked stale_mappings? that question after
41
+ # handle_choice_input had already rewritten @context.input to the
42
+ # *resolved* value, which can equal one of the map's own keys (an
43
+ # Array choice's key is its label, and the wire value is the title
44
+ # built from that label), so the check answered "still live"
45
+ # about a value that was never a fresh reply. That let the maps
46
+ # survive into a free-text screen and reinterpret a typed answer
47
+ # there as the previous menu's choice. This was fixed once for
48
+ # WhatsApp in Task 6 and once here in Task 13; both fixes had the
49
+ # same shape and the same blind spot, which is why the guard is
50
+ # gone rather than patched a third time.
51
+ clear_mappings
52
+
53
+ type, prompt, choices, media = @app.call(context)
54
+
55
+ choices = create_mappings(choices) if choices.present?
56
+
57
+ [type, prompt, choices, media]
58
+ end
59
+
60
+ private
61
+
62
+ def platform_limits
63
+ FlowChat::Config.messenger
64
+ end
65
+
66
+ def always_number?
67
+ false
68
+ end
69
+
70
+ def id_key
71
+ self.class::ID_KEY
72
+ end
73
+
74
+ def position_key
75
+ self.class::POSITION_KEY
76
+ end
77
+
78
+ def get_id_mapping
79
+ @session.get(id_key) || {}
80
+ end
81
+
82
+ def get_position_mapping
83
+ @session.get(position_key) || {}
84
+ end
85
+
86
+ # Titles first, then positions. A tap sends the title as its payload
87
+ # and a user typing what they read sends the same string, so both
88
+ # land on the same entry. A position must lose to a title, because a
89
+ # choice labelled "1" would otherwise be unreachable: its title is
90
+ # "1", and a bare digit is only a position when the screen was
91
+ # numbered at all.
92
+ def resolved_choice
93
+ input = @context.input.to_s
94
+ return nil if input.empty?
95
+
96
+ get_id_mapping[input] || get_position_mapping[input]
97
+ end
98
+
99
+ def intercept?
100
+ @context.input.present? && resolved_choice.present?
101
+ end
102
+
103
+ def handle_choice_input
104
+ original = resolved_choice
105
+ FlowChat.logger.info { "#{self.class.name}: Resolving input #{@context.input} to #{original}" }
106
+ @context.input = original
107
+ end
108
+
109
+ def clear_mappings
110
+ @session.delete(id_key)
111
+ @session.delete(position_key)
112
+ end
113
+
114
+ def create_mappings(choices)
115
+ cap = display_title_cap(choices.length)
116
+ return passthrough_mapping(choices) if cap.nil?
117
+
118
+ title_choices = {}
119
+ id_mapping = {}
120
+
121
+ FlowChat::ChoiceTitles.build(choices, cap).each do |key, label, title, _truncated|
122
+ title_choices[title] = label
123
+ id_mapping[title] = key
124
+ end
125
+
126
+ @session.set(id_key, id_mapping)
127
+
128
+ if number_choices?(choices)
129
+ @session.set(position_key, choices.keys.map.with_index(1) { |key, i| [i.to_s, key.to_s] }.to_h)
130
+ else
131
+ @session.delete(position_key)
132
+ end
133
+
134
+ title_choices
135
+ end
136
+
137
+ # On the :numbered rung the body already prints each full label beside
138
+ # its number, so the label itself is what is on screen and stays
139
+ # resolvable - nothing is truncated, so there is no shortened form to
140
+ # key on instead.
141
+ #
142
+ # A label shared by two choices is dropped rather than resolved to the
143
+ # first of them. It identifies neither on a screen that shows both, and
144
+ # the number printed next to each is the reply that does.
145
+ def passthrough_mapping(choices)
146
+ labels = choices.map { |key, label| [label.to_s, key.to_s] }
147
+ repeated = labels.map(&:first).tally
148
+
149
+ @session.set(id_key, labels.reject { |label, _| repeated[label] > 1 }.to_h)
150
+ @session.set(position_key, choices.keys.map.with_index(1) { |key, i| [i.to_s, key.to_s] }.to_h)
151
+ choices
152
+ end
153
+
154
+ # The title cap the renderer will use for these choices, or nil on
155
+ # the :none and :numbered rungs, where there is no separate title to
156
+ # alias: :none has no choices, and :numbered already lists each full
157
+ # label next to its number straight in the body, with nothing more
158
+ # to truncate. This calls the same FlowChat::Meta::ChoiceLadder the
159
+ # renderer consults, so the two cannot drift on which rung a given
160
+ # count lands on.
161
+ def display_title_cap(count)
162
+ case FlowChat::Meta::ChoiceLadder.rung_for(count, platform_limits)
163
+ when :quick_replies then platform_limits.max_quick_reply_title
164
+ when :carousel then platform_limits.max_button_title
165
+ end
166
+ end
167
+
168
+ # A position number is only worth resolving when one is genuinely on
169
+ # screen: on the :numbered rung, or wherever always_number? forces
170
+ # the renderer's #body to list one regardless of rung (Instagram,
171
+ # for a desktop user with no tappable surface at all), or on a
172
+ # quick-reply/carousel rung whose titles FlowChat::ChoiceTitles
173
+ # decided were ambiguous and prefixed with a number. always_number?
174
+ # only ever governs that body listing; it says nothing about whether
175
+ # a title itself was numbered, so it cannot answer this alone.
176
+ def number_choices?(choices)
177
+ count = choices.length
178
+ return true if FlowChat::Meta::ChoiceLadder.numbers_in_body?(count, platform_limits, always_number: always_number?)
179
+
180
+ FlowChat::ChoiceTitles.ambiguous?(choices, display_title_cap(count))
181
+ end
182
+ end
183
+ end
184
+ end
185
+ end
@@ -0,0 +1,150 @@
1
+ require "flow_chat/renderers/markdown_support"
2
+
3
+ module FlowChat
4
+ module Messenger
5
+ class Renderer
6
+ include FlowChat::Renderers::MarkdownSupport
7
+
8
+ attr_reader :message, :choices, :media
9
+
10
+ def initialize(message, choices: nil, media: nil)
11
+ @message = message
12
+ @choices = choices
13
+ @media = media
14
+ end
15
+
16
+ def render
17
+ return build_attachment if media && choices.blank?
18
+
19
+ result = case FlowChat::Meta::ChoiceLadder.rung_for(choice_count, limits)
20
+ when :none then build_text
21
+ when :quick_replies then build_quick_replies
22
+ when :carousel then build_carousel
23
+ when :numbered then build_text
24
+ end
25
+
26
+ attach_media(result)
27
+ end
28
+
29
+ private
30
+
31
+ # Instagram overrides both hooks: its own limits, and always_number
32
+ # because its interactive surfaces render on mobile only.
33
+ def limits
34
+ FlowChat::Config.messenger
35
+ end
36
+
37
+ def always_number?
38
+ false
39
+ end
40
+
41
+ # nil (no choices at all) is the only non-Hash tolerated: Prompt
42
+ # normalizes an Array upstream in the flow path, so the only way an
43
+ # Array reaches here is a direct client send, e.g.
44
+ # `context["messenger.client"].send_message(psid, msg, choices: [...])`.
45
+ # Silently returning 0 for that made the choices vanish from the render
46
+ # with no error at all; raising matches what WhatsApp's own
47
+ # build_selection_message does for the same input instead of
48
+ # disagreeing with it.
49
+ def choice_count
50
+ return 0 if choices.nil?
51
+ raise ArgumentError, "choices must be a Hash" unless choices.is_a?(Hash)
52
+
53
+ choices.length
54
+ end
55
+
56
+ # Neither Messenger nor Instagram renders markup, so the prompt is
57
+ # flattened rather than translated.
58
+ def body
59
+ text = to_plain_text(message)
60
+ return text unless FlowChat::Meta::ChoiceLadder.numbers_in_body?(choice_count, limits, always_number: always_number?)
61
+
62
+ "#{text}\n\n#{numbered_options}"
63
+ end
64
+
65
+ def numbered_options
66
+ choices.values.map.with_index(1) { |label, i| "#{i}. #{label}" }.join("\n")
67
+ end
68
+
69
+ # Covers both the :none rung and the :numbered rung above the carousel
70
+ # capacity: body already appends the numbered options when the ladder
71
+ # calls for it, so there is nothing left for a separate method to add.
72
+ def build_text
73
+ [:text, body, {}]
74
+ end
75
+
76
+ # Whether titles are numbered, and the enumeration order positions come
77
+ # from, are both decided by FlowChat::ChoiceTitles over this same
78
+ # `choices` hash - the choice mapper goes through the same module over
79
+ # the same hash, so the two can never disagree on which titles are
80
+ # shown, which is what lets the mapper use a title as the wire value.
81
+ def build_quick_replies
82
+ replies = FlowChat::ChoiceTitles.build(choices, limits.max_quick_reply_title).map do |key, _label, title, _truncated|
83
+ {
84
+ content_type: "text",
85
+ title: title,
86
+ payload: key
87
+ }
88
+ end
89
+
90
+ [:quick_replies, body, {quick_replies: replies}]
91
+ end
92
+
93
+ # One option is one button, and buttons live on elements, so the options
94
+ # are packed across elements rather than one element per option.
95
+ #
96
+ # FlowChat::ChoiceTitles.build runs once over the whole choice set,
97
+ # before slicing into elements: both the ambiguity decision and the
98
+ # resulting position numbers have to consider every choice together,
99
+ # not each element's slice in isolation. Two different slices could
100
+ # each hold a "Foo" that only collides once the whole set is in view,
101
+ # and the numbering an element's buttons carry has to continue where
102
+ # the previous element's left off (button 14 reads "14.", not "2." of
103
+ # its own element) - both are only correct computed globally.
104
+ def build_carousel
105
+ numbered_choices = FlowChat::ChoiceTitles.build(choices, limits.max_button_title)
106
+
107
+ elements = numbered_choices.each_slice(limits.max_buttons_per_element).map.with_index(1) do |slice, index|
108
+ first = (index - 1) * limits.max_buttons_per_element + 1
109
+ last = first + slice.length - 1
110
+
111
+ {
112
+ title: FlowChat::TextTruncator.truncate("Options #{first} to #{last}", limits.max_element_title),
113
+ buttons: slice.map do |key, _label, title, _truncated|
114
+ {
115
+ type: "postback",
116
+ title: title,
117
+ payload: key
118
+ }
119
+ end
120
+ }
121
+ end
122
+
123
+ [:carousel, body, {elements: elements}]
124
+ end
125
+
126
+ def build_attachment
127
+ [:attachment, to_plain_text(message), media_attachment_options]
128
+ end
129
+
130
+ # Media is additive: it does not change which choice surface is used.
131
+ # It rides along in options[:media] so the client can post it as its
132
+ # own message ahead of whichever rung the choice count would render
133
+ # with no media at all.
134
+ def attach_media(result)
135
+ return result unless media
136
+
137
+ type, content, options = result
138
+ [type, content, options.merge(media: media_attachment_options)]
139
+ end
140
+
141
+ def media_attachment_options
142
+ type = (media[:type] || :image).to_sym
143
+ options = {type: type}
144
+ options[:url] = media[:url] if media[:url]
145
+ options[:attachment_id] = media[:id] if media[:id]
146
+ options
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,24 @@
1
+ module FlowChat
2
+ module Meta
3
+ # The GET handshake Meta performs when a webhook URL is registered, as a
4
+ # decision rather than a response.
5
+ #
6
+ # Returns the challenge to echo, or nil when the request has not proved it
7
+ # is Meta. What to do with either answer belongs to the caller: a gateway
8
+ # renders it through its controller, an application receiving Meta's
9
+ # webhooks outside a gateway renders it its own way.
10
+ #
11
+ # WebhookVerification is the gateway's way in, and calls this.
12
+ module Challenge
13
+ # A configuration with no verify token must not verify anything. Without
14
+ # the presence check a missing token on both sides compares equal, and
15
+ # anyone could claim the endpoint by asking for the challenge.
16
+ def self.answer(params, verify_token)
17
+ return nil if verify_token.to_s.strip.empty?
18
+ return nil unless FlowChat::Security.secure_compare(params["hub.verify_token"].to_s, verify_token)
19
+
20
+ params["hub.challenge"]
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,37 @@
1
+ module FlowChat
2
+ module Meta
3
+ # Which interactive surface renders a given number of choices.
4
+ #
5
+ # The renderer and the choice mapper both need this answer, and they must
6
+ # agree: the renderer decides what the user sees, the mapper decides what a
7
+ # reply is allowed to mean. Two copies of the arithmetic would drift into a
8
+ # screen whose replies cannot be resolved.
9
+ module ChoiceLadder
10
+ def self.rung_for(count, limits)
11
+ return :none if count.zero?
12
+ return :quick_replies if count <= limits.max_quick_replies
13
+ return :carousel if count <= carousel_capacity(limits)
14
+
15
+ :numbered
16
+ end
17
+
18
+ # The carousel holds elements, each holding buttons, and one option is one
19
+ # button.
20
+ def self.carousel_capacity(limits)
21
+ limits.max_carousel_elements * limits.max_buttons_per_element
22
+ end
23
+
24
+ # Whether the options are also listed, numbered, in the message body.
25
+ #
26
+ # always_number is for platforms whose interactive surfaces do not render
27
+ # everywhere. Without it a user who cannot see the buttons has no way to
28
+ # answer at all.
29
+ def self.numbers_in_body?(count, limits, always_number: false)
30
+ return false if count.zero?
31
+ return true if always_number
32
+
33
+ rung_for(count, limits) == :numbered
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,7 @@
1
+ module FlowChat
2
+ module Meta
3
+ # Raised when a Meta gateway cannot validate a signature because it was not
4
+ # configured to. Platforms override configuration_error_class to raise their own.
5
+ class ConfigurationError < StandardError; end
6
+ end
7
+ end
@@ -0,0 +1,38 @@
1
+ module FlowChat
2
+ module Meta
3
+ # What a Meta gateway must say about itself.
4
+ #
5
+ # The behavior modules in this namespace need a handful of the same values:
6
+ # which platform this is, how to name it to a developer, which error class to
7
+ # raise, what to tag logs with. Declaring them here rather than in whichever
8
+ # behavior module happens to be included first means a gateway can include
9
+ # one behavior without the other, and a gateway that forgets a value fails
10
+ # loudly rather than borrowing another platform's.
11
+ #
12
+ # NotImplementedError rather than a default: it descends from ScriptError,
13
+ # not StandardError, so it travels through the bare rescue in
14
+ # SignatureValidation instead of being swallowed into a false return that
15
+ # would read as "invalid signature" and drop every webhook.
16
+ module GatewayIdentity
17
+ def platform
18
+ raise NotImplementedError, "#{self.class.name} must define #platform"
19
+ end
20
+
21
+ # The product's name as a developer reading an error message expects it,
22
+ # which is not always the constant: Whatsapp the module, WhatsApp the product.
23
+ def platform_label
24
+ raise NotImplementedError, "#{self.class.name} must define #platform_label"
25
+ end
26
+
27
+ def configuration_error_class
28
+ raise NotImplementedError, "#{self.class.name} must define #configuration_error_class"
29
+ end
30
+
31
+ # Derived, because every gateway's class name already ends in the tag its
32
+ # logs use. Override only to pin the tag against a class rename.
33
+ def log_tag
34
+ self.class.name.split("::").last
35
+ end
36
+ end
37
+ end
38
+ end