flow_chat 0.9.0 → 0.10.1

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 (110) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +29 -0
  3. data/.github/workflows/pages.yml +43 -0
  4. data/.gitignore +4 -1
  5. data/CHANGELOG.md +49 -0
  6. data/README.md +166 -295
  7. data/Rakefile +12 -1
  8. data/SECURITY.md +1 -1
  9. data/docs/architecture.md +52 -479
  10. data/docs/async-background-processing.md +31 -265
  11. data/docs/configuration.md +106 -613
  12. data/docs/factory-pattern.md +27 -324
  13. data/docs/gateway-context-variables.md +140 -143
  14. data/docs/gateway-development.md +86 -650
  15. data/docs/getting-started.md +40 -379
  16. data/docs/instrumentation.md +88 -279
  17. data/docs/platforms/instagram.md +278 -0
  18. data/docs/platforms/messenger.md +205 -0
  19. data/docs/platforms/telegram.md +47 -951
  20. data/docs/platforms/ussd.md +38 -653
  21. data/docs/platforms/whatsapp.md +73 -1321
  22. data/docs/superpowers/plans/2026-07-09-inbound-media-support.md +732 -0
  23. data/docs/superpowers/plans/2026-07-09-inbound-media-support.md.tasks.json +58 -0
  24. data/docs/superpowers/plans/2026-08-10-messenger-instagram.md +4064 -0
  25. data/docs/superpowers/plans/2026-08-10-messenger-instagram.md.tasks.json +226 -0
  26. data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md +972 -0
  27. data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md.tasks.json +88 -0
  28. data/docs/superpowers/specs/2026-07-09-inbound-media-support-design.md +195 -0
  29. data/docs/superpowers/specs/2026-08-10-messenger-instagram-design.md +391 -0
  30. data/docs/testing.md +26 -297
  31. data/examples/http_controller.rb +12 -10
  32. data/examples/intercom_configuration_example.rb +19 -24
  33. data/examples/intercom_controller.rb +8 -20
  34. data/examples/multi_tenant_whatsapp_controller.rb +61 -166
  35. data/examples/simulator_controller.rb +0 -1
  36. data/examples/ussd_controller.rb +86 -158
  37. data/examples/whatsapp_controller.rb +16 -15
  38. data/examples/whatsapp_media_examples.rb +27 -79
  39. data/flow_chat.gemspec +22 -2
  40. data/gemfiles/rails_main.gemfile +18 -0
  41. data/lib/flow_chat/app.rb +129 -11
  42. data/lib/flow_chat/async_job.rb +10 -0
  43. data/lib/flow_chat/choice_titles.rb +95 -0
  44. data/lib/flow_chat/config.rb +124 -1
  45. data/lib/flow_chat/delivery_error.rb +9 -0
  46. data/lib/flow_chat/gateway_async_support.rb +19 -1
  47. data/lib/flow_chat/http/configuration_error.rb +9 -0
  48. data/lib/flow_chat/http/gateway/simple.rb +24 -4
  49. data/lib/flow_chat/http/middleware/choice_mapper.rb +94 -0
  50. data/lib/flow_chat/input.rb +86 -0
  51. data/lib/flow_chat/instagram/client.rb +32 -0
  52. data/lib/flow_chat/instagram/configuration.rb +147 -0
  53. data/lib/flow_chat/instagram/configuration_error.rb +7 -0
  54. data/lib/flow_chat/instagram/gateway/send_api.rb +63 -0
  55. data/lib/flow_chat/instagram/middleware/choice_mapper.rb +22 -0
  56. data/lib/flow_chat/instagram/renderer.rb +23 -0
  57. data/lib/flow_chat/instrumentation/metrics_collector.rb +6 -1
  58. data/lib/flow_chat/instrumentation.rb +160 -1
  59. data/lib/flow_chat/intercom/client.rb +34 -28
  60. data/lib/flow_chat/intercom/configuration.rb +2 -49
  61. data/lib/flow_chat/intercom/configuration_error.rb +9 -0
  62. data/lib/flow_chat/intercom/gateway/intercom_api.rb +81 -57
  63. data/lib/flow_chat/intercom/middleware/choice_mapper.rb +101 -0
  64. data/lib/flow_chat/intercom/renderer.rb +57 -5
  65. data/lib/flow_chat/media.rb +121 -0
  66. data/lib/flow_chat/messenger/client.rb +264 -0
  67. data/lib/flow_chat/messenger/configuration.rb +103 -0
  68. data/lib/flow_chat/messenger/configuration_error.rb +9 -0
  69. data/lib/flow_chat/messenger/gateway/send_api.rb +42 -0
  70. data/lib/flow_chat/messenger/middleware/choice_mapper.rb +185 -0
  71. data/lib/flow_chat/messenger/renderer.rb +150 -0
  72. data/lib/flow_chat/meta/challenge.rb +24 -0
  73. data/lib/flow_chat/meta/choice_ladder.rb +37 -0
  74. data/lib/flow_chat/meta/configuration_error.rb +7 -0
  75. data/lib/flow_chat/meta/gateway_identity.rb +38 -0
  76. data/lib/flow_chat/meta/messaging_gateway.rb +471 -0
  77. data/lib/flow_chat/meta/signature.rb +30 -0
  78. data/lib/flow_chat/meta/signature_validation.rb +66 -0
  79. data/lib/flow_chat/meta/webhook_verification.rb +43 -0
  80. data/lib/flow_chat/named_configuration.rb +65 -0
  81. data/lib/flow_chat/prompt.rb +13 -16
  82. data/lib/flow_chat/renderers/markdown_support.rb +109 -0
  83. data/lib/flow_chat/security.rb +76 -0
  84. data/lib/flow_chat/session/middleware.rb +11 -2
  85. data/lib/flow_chat/simulator/controller.rb +31 -15
  86. data/lib/flow_chat/simulator/views/simulator.html.erb +184 -20
  87. data/lib/flow_chat/telegram/client.rb +47 -4
  88. data/lib/flow_chat/telegram/configuration.rb +2 -42
  89. data/lib/flow_chat/telegram/configuration_error.rb +9 -0
  90. data/lib/flow_chat/telegram/gateway/bot_api.rb +46 -28
  91. data/lib/flow_chat/telegram/middleware/choice_mapper.rb +77 -16
  92. data/lib/flow_chat/telegram/renderer.rb +10 -2
  93. data/lib/flow_chat/text_truncator.rb +75 -0
  94. data/lib/flow_chat/ussd/middleware/choice_mapper.rb +10 -0
  95. data/lib/flow_chat/version.rb +1 -1
  96. data/lib/flow_chat/whatsapp/client.rb +14 -7
  97. data/lib/flow_chat/whatsapp/configuration.rb +12 -51
  98. data/lib/flow_chat/whatsapp/configuration_error.rb +9 -0
  99. data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +259 -203
  100. data/lib/flow_chat/whatsapp/middleware/choice_mapper.rb +144 -57
  101. data/lib/flow_chat/whatsapp/renderer.rb +121 -60
  102. data/lib/flow_chat.rb +0 -10
  103. data/lib/tasks/release.rake +11 -1
  104. data/site/.nojekyll +0 -0
  105. data/site/.og-card.html +89 -0
  106. data/site/favicon.svg +6 -0
  107. data/site/index.html +209 -0
  108. data/site/og.png +0 -0
  109. metadata +60 -11
  110. data/lib/flow_chat/whatsapp/id_generator.rb +0 -124
@@ -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