flow_chat 0.9.0 → 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 (103) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/pages.yml +43 -0
  3. data/CHANGELOG.md +26 -0
  4. data/README.md +166 -295
  5. data/Rakefile +12 -1
  6. data/SECURITY.md +1 -1
  7. data/docs/architecture.md +52 -479
  8. data/docs/async-background-processing.md +31 -265
  9. data/docs/configuration.md +106 -613
  10. data/docs/factory-pattern.md +27 -324
  11. data/docs/gateway-context-variables.md +140 -143
  12. data/docs/gateway-development.md +86 -650
  13. data/docs/getting-started.md +40 -379
  14. data/docs/instrumentation.md +88 -279
  15. data/docs/platforms/instagram.md +278 -0
  16. data/docs/platforms/messenger.md +205 -0
  17. data/docs/platforms/telegram.md +47 -951
  18. data/docs/platforms/ussd.md +38 -653
  19. data/docs/platforms/whatsapp.md +73 -1321
  20. data/docs/superpowers/plans/2026-07-09-inbound-media-support.md +732 -0
  21. data/docs/superpowers/plans/2026-07-09-inbound-media-support.md.tasks.json +58 -0
  22. data/docs/superpowers/plans/2026-08-10-messenger-instagram.md +4064 -0
  23. data/docs/superpowers/plans/2026-08-10-messenger-instagram.md.tasks.json +226 -0
  24. data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md +972 -0
  25. data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md.tasks.json +88 -0
  26. data/docs/superpowers/specs/2026-07-09-inbound-media-support-design.md +195 -0
  27. data/docs/superpowers/specs/2026-08-10-messenger-instagram-design.md +391 -0
  28. data/docs/testing.md +26 -297
  29. data/examples/http_controller.rb +12 -10
  30. data/examples/intercom_configuration_example.rb +19 -24
  31. data/examples/intercom_controller.rb +8 -20
  32. data/examples/multi_tenant_whatsapp_controller.rb +61 -166
  33. data/examples/simulator_controller.rb +0 -1
  34. data/examples/ussd_controller.rb +86 -158
  35. data/examples/whatsapp_controller.rb +16 -15
  36. data/examples/whatsapp_media_examples.rb +27 -79
  37. data/lib/flow_chat/app.rb +129 -11
  38. data/lib/flow_chat/choice_titles.rb +95 -0
  39. data/lib/flow_chat/config.rb +124 -1
  40. data/lib/flow_chat/delivery_error.rb +9 -0
  41. data/lib/flow_chat/http/configuration_error.rb +9 -0
  42. data/lib/flow_chat/http/gateway/simple.rb +24 -4
  43. data/lib/flow_chat/http/middleware/choice_mapper.rb +94 -0
  44. data/lib/flow_chat/input.rb +86 -0
  45. data/lib/flow_chat/instagram/client.rb +32 -0
  46. data/lib/flow_chat/instagram/configuration.rb +147 -0
  47. data/lib/flow_chat/instagram/configuration_error.rb +7 -0
  48. data/lib/flow_chat/instagram/gateway/send_api.rb +63 -0
  49. data/lib/flow_chat/instagram/middleware/choice_mapper.rb +22 -0
  50. data/lib/flow_chat/instagram/renderer.rb +23 -0
  51. data/lib/flow_chat/instrumentation/metrics_collector.rb +6 -1
  52. data/lib/flow_chat/instrumentation.rb +160 -1
  53. data/lib/flow_chat/intercom/client.rb +34 -28
  54. data/lib/flow_chat/intercom/configuration.rb +2 -49
  55. data/lib/flow_chat/intercom/configuration_error.rb +9 -0
  56. data/lib/flow_chat/intercom/gateway/intercom_api.rb +81 -57
  57. data/lib/flow_chat/intercom/middleware/choice_mapper.rb +101 -0
  58. data/lib/flow_chat/intercom/renderer.rb +57 -5
  59. data/lib/flow_chat/media.rb +121 -0
  60. data/lib/flow_chat/messenger/client.rb +264 -0
  61. data/lib/flow_chat/messenger/configuration.rb +103 -0
  62. data/lib/flow_chat/messenger/configuration_error.rb +9 -0
  63. data/lib/flow_chat/messenger/gateway/send_api.rb +42 -0
  64. data/lib/flow_chat/messenger/middleware/choice_mapper.rb +185 -0
  65. data/lib/flow_chat/messenger/renderer.rb +150 -0
  66. data/lib/flow_chat/meta/challenge.rb +24 -0
  67. data/lib/flow_chat/meta/choice_ladder.rb +37 -0
  68. data/lib/flow_chat/meta/configuration_error.rb +7 -0
  69. data/lib/flow_chat/meta/gateway_identity.rb +38 -0
  70. data/lib/flow_chat/meta/messaging_gateway.rb +468 -0
  71. data/lib/flow_chat/meta/signature.rb +30 -0
  72. data/lib/flow_chat/meta/signature_validation.rb +66 -0
  73. data/lib/flow_chat/meta/webhook_verification.rb +43 -0
  74. data/lib/flow_chat/named_configuration.rb +65 -0
  75. data/lib/flow_chat/prompt.rb +13 -16
  76. data/lib/flow_chat/renderers/markdown_support.rb +109 -0
  77. data/lib/flow_chat/security.rb +76 -0
  78. data/lib/flow_chat/session/middleware.rb +11 -2
  79. data/lib/flow_chat/simulator/controller.rb +31 -15
  80. data/lib/flow_chat/simulator/views/simulator.html.erb +184 -20
  81. data/lib/flow_chat/telegram/client.rb +47 -4
  82. data/lib/flow_chat/telegram/configuration.rb +2 -42
  83. data/lib/flow_chat/telegram/configuration_error.rb +9 -0
  84. data/lib/flow_chat/telegram/gateway/bot_api.rb +46 -28
  85. data/lib/flow_chat/telegram/middleware/choice_mapper.rb +77 -16
  86. data/lib/flow_chat/telegram/renderer.rb +10 -2
  87. data/lib/flow_chat/text_truncator.rb +75 -0
  88. data/lib/flow_chat/ussd/middleware/choice_mapper.rb +10 -0
  89. data/lib/flow_chat/version.rb +1 -1
  90. data/lib/flow_chat/whatsapp/client.rb +14 -7
  91. data/lib/flow_chat/whatsapp/configuration.rb +12 -51
  92. data/lib/flow_chat/whatsapp/configuration_error.rb +9 -0
  93. data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +254 -203
  94. data/lib/flow_chat/whatsapp/middleware/choice_mapper.rb +144 -57
  95. data/lib/flow_chat/whatsapp/renderer.rb +121 -60
  96. data/lib/flow_chat.rb +0 -10
  97. data/site/.nojekyll +0 -0
  98. data/site/.og-card.html +89 -0
  99. data/site/favicon.svg +6 -0
  100. data/site/index.html +209 -0
  101. data/site/og.png +0 -0
  102. metadata +51 -3
  103. data/lib/flow_chat/whatsapp/id_generator.rb +0 -124
@@ -0,0 +1,101 @@
1
+ module FlowChat
2
+ module Intercom
3
+ module Middleware
4
+ # Maps an Intercom reply back to the choice it belongs to.
5
+ #
6
+ # Intercom has no interactive buttons, so the renderer writes the options
7
+ # out as a numbered list and asks the reader to reply with a number. That
8
+ # number has to come back to the choice key the flow branches on, the same
9
+ # job USSD's mapper does for a handset.
10
+ #
11
+ # A number is the only thing that resolves, as on USSD, and that is what
12
+ # the message asks for in as many words. It is also what makes this
13
+ # mapper safe by construction rather than by care: positions are unique
14
+ # whatever the labels say, so there is no equivalence under which two
15
+ # choices could collapse and nothing to check them against.
16
+ #
17
+ # Labels were matched too, case insensitively. That is what needed the
18
+ # care - two choices reading the same, or differing only in case, folded
19
+ # onto one entry and the second could not be picked by name at all. On a
20
+ # screen that prints a number beside every option and asks for one, the
21
+ # number already does that job unambiguously.
22
+ #
23
+ # Flow:
24
+ # 1. Flow returns choices with their own keys (e.g. {"sales" => "Sales"})
25
+ # 2. This middleware numbers them and remembers what each number meant
26
+ # 3. The renderer writes "1. Sales" and asks for a number
27
+ # 4. The reader replies "1"
28
+ # 5. This middleware turns it back into "sales"
29
+ # 6. The flow sees its own key
30
+ class ChoiceMapper
31
+ SESSION_KEY = "intercom.choice_mapping"
32
+
33
+ def initialize(app)
34
+ @app = app
35
+ FlowChat.logger.debug { "Intercom::ChoiceMapper: Initialized Intercom choice mapping middleware" }
36
+ end
37
+
38
+ def call(context)
39
+ @context = context
40
+ @session = context.session
41
+
42
+ resolve_input
43
+
44
+ type, prompt, choices, media = @app.call(context)
45
+
46
+ choices = remember(choices)
47
+
48
+ [type, prompt, choices, media]
49
+ end
50
+
51
+ private
52
+
53
+ def resolve_input
54
+ return if @context.input.blank?
55
+
56
+ mapping = @session.get(SESSION_KEY) || {}
57
+ return if mapping.empty?
58
+
59
+ # Surrounding whitespace is trimmed off a chat message before the
60
+ # lookup. Unlike a fold over labels, this cannot merge two choices:
61
+ # trimming leaves "1" and "2" as distinct as it found them.
62
+ matched = mapping[@context.input.to_s.strip]
63
+ return unless matched
64
+
65
+ FlowChat.logger.info { "Intercom::ChoiceMapper: Resolving #{@context.input} to #{matched}" }
66
+ @context.input = matched
67
+ end
68
+
69
+ # Numbers the choices and stores what each number meant.
70
+ #
71
+ # The renderer is what prints the number beside each label, so nothing
72
+ # is prefixed here - doing both would read as "1. 1. Savings".
73
+ #
74
+ # Cleared when a screen carries no choices, so an answer typed into a
75
+ # later free-text question is never read as a choice from an earlier
76
+ # menu. WhatsApp and Messenger each had this same bug fixed twice.
77
+ #
78
+ # @return [Hash, nil] the choices to render
79
+ def remember(choices)
80
+ if choices.blank?
81
+ @session.delete(SESSION_KEY)
82
+ return choices
83
+ end
84
+
85
+ numbered = {}
86
+ mapping = {}
87
+
88
+ choices.each_with_index do |(key, label), index|
89
+ number = (index + 1).to_s
90
+ numbered[number] = label.to_s
91
+ mapping[number] = key.to_s
92
+ end
93
+
94
+ @session.set(SESSION_KEY, mapping)
95
+ FlowChat.logger.debug { "Intercom::ChoiceMapper: Created mapping: #{mapping}" }
96
+ numbered
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -24,7 +24,8 @@ module FlowChat
24
24
  private
25
25
 
26
26
  def build_text_message
27
- [:text, to_html(message), {}]
27
+ link, options = render_media
28
+ [:text, to_html(message.to_s + link), options]
28
29
  end
29
30
 
30
31
  def build_selection_message
@@ -35,10 +36,27 @@ module FlowChat
35
36
  end
36
37
  end
37
38
 
39
+ # Choices are a numbered list rather than Intercom's own quick replies,
40
+ # and that is a decision rather than an oversight. Intercom does document
41
+ # reply_options with message_type "quick_reply" on an admin reply, and it
42
+ # was built here and reverted. Three reasons, in the order they matter:
43
+ #
44
+ # - The uuid of a clicked option comes back as quick_reply_option_uuid,
45
+ # and only if the Intercom app is set to the *Unstable* API version.
46
+ # Webhooks inherit that setting, so an app on a stable version receives
47
+ # no metadata at all. Requiring an unstable API version for something as
48
+ # basic as reading which option was chosen is not a thing a library can
49
+ # ask of its users.
50
+ # - body is forbidden on a quick_reply, so a choice screen becomes two
51
+ # conversation parts: one for the prompt, one for the buttons. In an
52
+ # inbox a human reads, that doubles the length of every flow.
53
+ # - Intercom's own community reports the endpoint returning errors for
54
+ # this shape.
55
+ #
56
+ # A numbered list needs none of that and works on every API version. If
57
+ # Intercom stabilises quick replies, the git history has the
58
+ # implementation; check those three things before restoring it.
38
59
  def build_interactive_message(choice_hash)
39
- # For Intercom, we'll present choices as a formatted text message
40
- # since Intercom doesn't have the same interactive elements as WhatsApp
41
-
42
60
  formatted_message = message.to_s
43
61
 
44
62
  unless formatted_message.empty?
@@ -53,7 +71,41 @@ module FlowChat
53
71
 
54
72
  formatted_message += "\nReply with the number of your choice."
55
73
 
56
- [:text, to_html(formatted_message), {choices: choice_hash}]
74
+ link, options = render_media
75
+ [:text, to_html(formatted_message + link), options.merge(choices: choice_hash)]
76
+ end
77
+
78
+ # Intercom's admin reply only takes real attachments as image URLs
79
+ # (attachment_urls, documented specifically for images, max 10). Any
80
+ # other media type with a url - document, video, audio, sticker -
81
+ # becomes a markdown link in the body instead, since attachment_urls
82
+ # is documented for images and a non-image URL there may not render.
83
+ # An id with no url is another platform's upload handle (a WhatsApp
84
+ # media id, say) and means nothing to Intercom, so it is logged and
85
+ # dropped rather than raised: a multi-platform flow legitimately sets
86
+ # an id for whichever platform uploaded it, and one platform lacking
87
+ # the media should not fail the whole turn.
88
+ #
89
+ # Returns [markdown_suffix, options] - the suffix is appended to the
90
+ # message before markdown-to-HTML conversion so a link goes through
91
+ # the same sanitizer and allowed_tags as the rest of the body.
92
+ def render_media
93
+ return ["", {}] unless media
94
+
95
+ url = media[:url]
96
+ media_type = (media[:type] || :image).to_sym
97
+
98
+ unless url
99
+ FlowChat.logger.warn { "Intercom::Renderer: media id #{media[:id].inspect} is another platform's upload handle and means nothing to Intercom (no url given); sending the message without it" }
100
+ return ["", {}]
101
+ end
102
+
103
+ if media_type == :image
104
+ ["", {attachment_urls: [url]}]
105
+ else
106
+ label = media[:filename] || media_type.to_s.capitalize
107
+ ["\n\n[#{label}](#{url})", {}]
108
+ end
57
109
  end
58
110
 
59
111
  # MarkdownSupport overrides for Intercom-specific behavior
@@ -0,0 +1,121 @@
1
+ require "net/http"
2
+ require "uri"
3
+
4
+ module FlowChat
5
+ # Value object wrapping a single inbound media item parsed by a gateway.
6
+ # Normalizes cross-platform differences (WhatsApp media-id, Telegram file_id,
7
+ # Intercom/HTTP direct URL) behind #url and #download.
8
+ class Media
9
+ # Maps platform-native media types to a canonical, cross-platform set.
10
+ # Telegram uses :photo/:voice where WhatsApp uses :image/:audio.
11
+ NORMALIZED_TYPES = {photo: :image, voice: :audio}.freeze
12
+
13
+ # The canonical media types FlowChat recognizes across platforms. Gateways
14
+ # that accept a caller-supplied type (e.g. HTTP) validate against this set.
15
+ CANONICAL_TYPES = %i[image video audio document sticker].freeze
16
+
17
+ attr_reader :platform, :client
18
+
19
+ def initialize(data, platform:, client: nil)
20
+ @data = data
21
+ @platform = platform
22
+ @client = client
23
+ end
24
+
25
+ # Canonical, cross-platform media type (:image, :video, :audio, :document, :sticker).
26
+ def type
27
+ NORMALIZED_TYPES.fetch(raw_type, raw_type)
28
+ end
29
+
30
+ # The platform-native type as parsed by the gateway (e.g. :photo, :voice on Telegram).
31
+ def raw_type
32
+ @data[:type]
33
+ end
34
+
35
+ def mime_type
36
+ @data[:mime_type]
37
+ end
38
+
39
+ def caption
40
+ @data[:caption]
41
+ end
42
+
43
+ def filename
44
+ @data[:filename] || @data[:file_name]
45
+ end
46
+
47
+ def id
48
+ @data[:id]
49
+ end
50
+
51
+ def file_id
52
+ @data[:file_id]
53
+ end
54
+
55
+ def [](key)
56
+ @data[key]
57
+ end
58
+
59
+ def to_h
60
+ @data.dup
61
+ end
62
+
63
+ # Serialize without the live platform client (a network object that may not
64
+ # marshal). This keeps a Media — and any FlowChat::Input holding it — safe to
65
+ # store in a session store. A deserialized Media has no client, so #url and
66
+ # #download degrade to nil rather than raising.
67
+ def marshal_dump
68
+ {data: @data, platform: @platform}
69
+ end
70
+
71
+ def marshal_load(state)
72
+ @data = state[:data]
73
+ @platform = state[:platform]
74
+ @client = nil
75
+ end
76
+
77
+ # Resolve a fetchable URL for the media. Memoized so repeated reads don't
78
+ # re-issue the platform lookup (WhatsApp get_media_url / Telegram getFile).
79
+ # Returns nil (rather than raising) if the lookup fails, matching #download.
80
+ def url
81
+ @url ||= case platform
82
+ when :whatsapp then client.get_media_url(id)
83
+ when :telegram then client.file_url(file_id)
84
+ else @data[:url]
85
+ end
86
+ rescue => e
87
+ FlowChat.logger.warn { "Media: url resolution failed: #{e.message}" }
88
+ nil
89
+ end
90
+
91
+ # Fetch the raw bytes of the media, or nil on failure (uniform across
92
+ # platforms). WhatsApp needs the client to attach auth headers; every other
93
+ # platform (Telegram, Intercom, HTTP) exposes a token-in-URL or public link,
94
+ # so we fetch the already-memoized #url directly — avoiding a second getFile
95
+ # round-trip on Telegram.
96
+ def download
97
+ case platform
98
+ when :whatsapp then client.download_media(id)
99
+ else fetch(url)
100
+ end
101
+ rescue => e
102
+ FlowChat.logger.warn { "Media: download failed: #{e.message}" }
103
+ nil
104
+ end
105
+
106
+ private
107
+
108
+ def fetch(resource_url)
109
+ return nil unless resource_url
110
+
111
+ uri = URI(resource_url)
112
+ http = Net::HTTP.new(uri.host, uri.port)
113
+ http.use_ssl = (uri.scheme == "https")
114
+ response = http.get(uri.request_uri)
115
+ response.body if response.is_a?(Net::HTTPSuccess)
116
+ rescue => e
117
+ FlowChat.logger.warn { "Media: download failed for #{resource_url}: #{e.message}" }
118
+ nil
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,264 @@
1
+ require "net/http"
2
+ require "json"
3
+ require "uri"
4
+
5
+ module FlowChat
6
+ module Messenger
7
+ class Client
8
+ include FlowChat::Instrumentation
9
+
10
+ def initialize(config)
11
+ @config = config
12
+ FlowChat.logger.info { "Messenger::Client: Initialized for account #{@config.account_id}" }
13
+ end
14
+
15
+ # @param tag [String, nil] a Meta message tag (e.g. "HUMAN_AGENT") that
16
+ # extends the free-form send window beyond 24 hours. The application
17
+ # decides when a send qualifies; this only carries the value through
18
+ # to every part of the send. Passed through unvalidated: Meta accepts
19
+ # only HUMAN_AGENT as of 27 April 2026 and rejects anything else with
20
+ # error 100, clearly enough that an allowlist here would only be one
21
+ # more thing to keep in sync with Meta's own set.
22
+ def send_message(recipient_id, prompt, choices: nil, media: nil, tag: nil)
23
+ response = renderer_class.new(prompt, choices: choices, media: media).render
24
+ type, content, options = response
25
+
26
+ # MESSAGE_SENT is instrumented by the gateway, not here. This wrapped
27
+ # the send in its own instrument block, and ActiveSupport::Notifications
28
+ # publishes a block event once the block returns whatever it returned -
29
+ # so the event fired even when the send had failed and this method was
30
+ # about to answer nil, and fired a second time when the gateway
31
+ # instrumented the same send.
32
+ deliver(recipient_id, type, content, options, tag)
33
+ end
34
+
35
+ def send_text(recipient_id, text, tag: nil)
36
+ send_message(recipient_id, text, tag: tag)
37
+ end
38
+
39
+ # Shows the person a typing bubble while a turn is being worked out.
40
+ #
41
+ # Meta clears it when the next message arrives or after about twenty
42
+ # seconds, whichever comes first, so a caller holding one open for longer
43
+ # has to repeat it.
44
+ #
45
+ # Inherited by Instagram rather than overridden there. Meta documents
46
+ # sender actions under the Messenger Platform and lists only react and
47
+ # unreact for Instagram, but an Instagram send of typing_on is accepted
48
+ # and answered with the recipient id, same as Messenger. Confirmed
49
+ # against a live account, since the reference does not settle it.
50
+ def indicate_typing(recipient_id)
51
+ post_json(@config.messages_url, {recipient: {id: recipient_id}, sender_action: "typing_on"})
52
+ end
53
+
54
+ # Uploads a file for reuse and returns the id Meta assigned it.
55
+ def upload_media(url, type: :image)
56
+ payload = {
57
+ message: {
58
+ attachment: {
59
+ type: type.to_s,
60
+ payload: {url: url, is_reusable: true}
61
+ }
62
+ }
63
+ }
64
+
65
+ result = post_json(@config.attachment_upload_url, payload)
66
+ result && result["attachment_id"]
67
+ end
68
+
69
+ private
70
+
71
+ def renderer_class
72
+ FlowChat::Messenger::Renderer
73
+ end
74
+
75
+ def platform
76
+ :messenger
77
+ end
78
+
79
+ def limits
80
+ FlowChat::Config.messenger
81
+ end
82
+
83
+ # Anything over the platform's cap is rejected whole rather than trimmed by
84
+ # Meta, so long text goes as several messages. Only the last result is
85
+ # returned: it carries the id of the message the user ends up looking at.
86
+ #
87
+ # Media rides in options[:media] on every rung except :attachment, where
88
+ # it already is the message: it goes out as its own send, ahead of the
89
+ # choice message, because the choice surface is rendered exactly as it
90
+ # would be with no media at all. Every part - the media and every chunk
91
+ # of the choice message - carries the same tag.
92
+ def deliver(recipient_id, type, content, options, tag)
93
+ post_media(recipient_id, options[:media], tag) if options[:media]
94
+
95
+ case type
96
+ when :text
97
+ split_text(content).map { |chunk| post_message(recipient_id, {text: chunk}, tag) }.last
98
+ when :quick_replies
99
+ chunks = split_text(content)
100
+ # Quick replies belong on the final chunk, next to the question.
101
+ chunks[0..-2].each { |chunk| post_message(recipient_id, {text: chunk}, tag) }
102
+ post_message(recipient_id, {text: chunks.last, quick_replies: options[:quick_replies]}, tag)
103
+ when :carousel
104
+ post_body_text(recipient_id, content, tag)
105
+ post_message(recipient_id, {
106
+ attachment: {
107
+ type: "template",
108
+ payload: {template_type: "generic", elements: options[:elements]}
109
+ }
110
+ }, tag)
111
+ when :attachment
112
+ post_body_text(recipient_id, content, tag)
113
+ post_media(recipient_id, options, tag)
114
+ end
115
+ end
116
+
117
+ # content on these two branches is the numbered body Instagram's
118
+ # always_number? forces (or, on :attachment, just the flow's own
119
+ # message) - either can run over the platform's text cap on its own,
120
+ # with no relation to the template or attachment that follows, so it
121
+ # is split exactly like a plain text send rather than posted whole.
122
+ def post_body_text(recipient_id, content, tag)
123
+ return unless content.present?
124
+
125
+ split_text(content).each { |chunk| post_message(recipient_id, {text: chunk}, tag) }
126
+ end
127
+
128
+ def post_media(recipient_id, media_options, tag)
129
+ attachment_payload = media_options[:url] ? {url: media_options[:url], is_reusable: true} : {attachment_id: media_options[:attachment_id]}
130
+ post_message(recipient_id, {
131
+ attachment: {type: media_options[:type].to_s, payload: attachment_payload}
132
+ }, tag)
133
+ end
134
+
135
+ def post_message(recipient_id, message, tag)
136
+ payload = {recipient: {id: recipient_id}, message: message}
137
+
138
+ # The tag branch is not gated on messaging_type?: Instagram never
139
+ # documents RESPONSE, but it does document MESSAGE_TAG with
140
+ # HUMAN_AGENT, so a tagged send needs the field there too even
141
+ # though an untagged Instagram send omits it entirely.
142
+ if tag
143
+ payload[:messaging_type] = "MESSAGE_TAG"
144
+ payload[:tag] = tag
145
+ elsif messaging_type?
146
+ payload[:messaging_type] = "RESPONSE"
147
+ end
148
+
149
+ post_json(@config.messages_url, payload)
150
+ end
151
+
152
+ # Messenger documents messaging_type as required on a send. Instagram's
153
+ # reference does not mention it at all, so Instagram omits it rather than
154
+ # sending a parameter Meta never documented for that surface.
155
+ def messaging_type?
156
+ true
157
+ end
158
+
159
+ # Splits on whitespace so a word is never cut in half. Measured with the
160
+ # platform's own unit, which is bytes on Instagram and characters here.
161
+ #
162
+ # A single piece that is itself too large to fit in one chunk (a long
163
+ # URL, most often) cannot be handled by that whitespace splitting
164
+ # alone: with no smaller boundary inside it to break on, it would
165
+ # otherwise ride through untouched as one chunk over the cap, exactly
166
+ # the case this method exists to prevent. #hard_split below breaks it
167
+ # up directly.
168
+ def split_text(text)
169
+ limit = limits.max_text_length
170
+ return [text.to_s] if measure(text.to_s) <= limit
171
+
172
+ chunks = []
173
+ current = ""
174
+
175
+ text.to_s.split(/(\s+)/).each do |piece|
176
+ if measure(piece) > limit
177
+ chunks << current.strip if current.present?
178
+ oversized_chunks = hard_split(piece.strip, limit)
179
+ chunks.concat(oversized_chunks[0..-2])
180
+ current = oversized_chunks.last.to_s
181
+ elsif measure(current + piece) > limit && current.present?
182
+ chunks << current.strip
183
+ current = piece.lstrip
184
+ else
185
+ current += piece
186
+ end
187
+ end
188
+
189
+ chunks << current.strip if current.strip.present?
190
+ chunks
191
+ end
192
+
193
+ # Breaks a single oversized piece into cap-sized chunks one character
194
+ # at a time, so the cut always lands on a character boundary. Never a
195
+ # byte offset: on Instagram, where the cap is measured in bytes, a
196
+ # multibyte character sliced by byte position would leave one half a
197
+ # valid UTF-8 sequence and the other invalid, and #measure has no way
198
+ # to tell that apart from a character that legitimately does not fit.
199
+ def hard_split(piece, limit)
200
+ chunks = []
201
+ current = ""
202
+
203
+ piece.each_char do |char|
204
+ if current.present? && measure(current + char) > limit
205
+ chunks << current
206
+ current = char
207
+ else
208
+ current += char
209
+ end
210
+ end
211
+
212
+ chunks << current if current.present?
213
+ chunks
214
+ end
215
+
216
+ def measure(string)
217
+ string.length
218
+ end
219
+
220
+ def post_json(url, payload)
221
+ uri = URI(url)
222
+ http = Net::HTTP.new(uri.host, uri.port)
223
+ http.use_ssl = true
224
+
225
+ request = Net::HTTP::Post.new(uri)
226
+ @config.api_headers.each { |key, value| request[key] = value }
227
+ request.body = payload.to_json
228
+
229
+ response = http.request(request)
230
+
231
+ if response.is_a?(Net::HTTPSuccess)
232
+ JSON.parse(response.body)
233
+ else
234
+ FlowChat.logger.error { "#{self.class.name}: API request failed - #{response.code}: #{response.body}" }
235
+ report_api_error(
236
+ "#{platform} API request failed",
237
+ response_code: response.code,
238
+ response_body: response.body
239
+ )
240
+ nil
241
+ end
242
+ rescue Net::OpenTimeout, Net::ReadTimeout => network_error
243
+ FlowChat.logger.error { "#{self.class.name}: Network timeout: #{network_error.class.name}" }
244
+ raise network_error
245
+ end
246
+
247
+ # FlowChat::Instrumentation only defines report_api_error at the module
248
+ # level (FlowChat::Instrumentation.report_api_error), not as an instance
249
+ # method, so it is not inherited through `include`. Every client that
250
+ # wants the shorthand defines its own wrapper; this mirrors the one in
251
+ # whatsapp/client.rb.
252
+ def report_api_error(message, response_code: nil, response_body: nil, error: nil)
253
+ FlowChat::Instrumentation.report_api_error(
254
+ message,
255
+ error: error,
256
+ platform: platform,
257
+ account_id: @config.account_id,
258
+ response_code: response_code,
259
+ response_body: response_body
260
+ )
261
+ end
262
+ end
263
+ end
264
+ end
@@ -0,0 +1,103 @@
1
+ module FlowChat
2
+ module Messenger
3
+ class Configuration
4
+ include FlowChat::NamedConfiguration
5
+
6
+ attr_accessor :access_token, :page_id, :verify_token, :app_id, :app_secret,
7
+ :name, :skip_signature_validation
8
+
9
+ def initialize(name)
10
+ @name = name
11
+ @access_token = nil
12
+ @page_id = nil
13
+ @verify_token = nil
14
+ @app_id = nil
15
+ @app_secret = nil
16
+ @skip_signature_validation = false
17
+
18
+ FlowChat.logger.debug { "Messenger::Configuration: Initialized configuration with name: #{name || "anonymous"}" }
19
+
20
+ register_as(name) if name.present?
21
+ end
22
+
23
+ def self.from_credentials
24
+ FlowChat.logger.info { "Messenger::Configuration: Loading configuration from credentials/environment" }
25
+
26
+ config = new(nil)
27
+
28
+ if defined?(Rails) && Rails.respond_to?(:application) && Rails.application&.credentials&.messenger
29
+ FlowChat.logger.debug { "Messenger::Configuration: Loading from Rails credentials" }
30
+ credentials = Rails.application.credentials.messenger
31
+ config.access_token = credentials[:access_token]
32
+ config.page_id = credentials[:page_id]
33
+ config.verify_token = credentials[:verify_token]
34
+ config.app_id = credentials[:app_id]
35
+ config.app_secret = credentials[:app_secret]
36
+ config.skip_signature_validation = credentials[:skip_signature_validation] || false
37
+ else
38
+ FlowChat.logger.debug { "Messenger::Configuration: Loading from environment variables" }
39
+ config.access_token = ENV["MESSENGER_ACCESS_TOKEN"]
40
+ config.page_id = ENV["MESSENGER_PAGE_ID"]
41
+ config.verify_token = ENV["MESSENGER_VERIFY_TOKEN"]
42
+ config.app_id = ENV["MESSENGER_APP_ID"]
43
+ config.app_secret = ENV["MESSENGER_APP_SECRET"]
44
+ config.skip_signature_validation = ENV["MESSENGER_SKIP_SIGNATURE_VALIDATION"] == "true"
45
+ end
46
+
47
+ if config.valid?
48
+ FlowChat.logger.info { "Messenger::Configuration: Configuration loaded successfully - page_id: #{config.page_id}" }
49
+ else
50
+ FlowChat.logger.warn { "Messenger::Configuration: Incomplete configuration loaded - missing required fields" }
51
+ end
52
+
53
+ config
54
+ end
55
+
56
+ def valid?
57
+ # Wrapped so a predicate answers true or false rather than nil, which
58
+ # the bare && chain returns for a missing first field. Intercom and
59
+ # Telegram already do this and pin it in their tests.
60
+ is_valid = !!(access_token && !access_token.to_s.empty? &&
61
+ page_id && !page_id.to_s.empty? &&
62
+ verify_token && !verify_token.to_s.empty?)
63
+
64
+ FlowChat.logger.debug { "Messenger::Configuration: Configuration valid: #{is_valid}" }
65
+ is_valid
66
+ end
67
+
68
+ # The account this configuration speaks for. Named generically, not
69
+ # page_id, so the shared gateway can check an inbound event's account
70
+ # without knowing which platform it holds.
71
+ def account_id
72
+ page_id
73
+ end
74
+
75
+ # The id an inbound webhook's entry.id names. Messenger only ever handles
76
+ # `page`, which names the Page, so this is the same id a send is
77
+ # addressed to. Instagram's two differ, which is why the gateway asks for
78
+ # this rather than reusing account_id.
79
+ def webhook_account_id
80
+ account_id
81
+ end
82
+
83
+ def messages_url
84
+ "#{api_base_url}/#{page_id}/messages"
85
+ end
86
+
87
+ def attachment_upload_url
88
+ "#{api_base_url}/#{page_id}/message_attachments"
89
+ end
90
+
91
+ def api_base_url
92
+ FlowChat::Config.messenger.api_base_url
93
+ end
94
+
95
+ def api_headers
96
+ {
97
+ "Authorization" => "Bearer #{access_token}",
98
+ "Content-Type" => "application/json"
99
+ }
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,9 @@
1
+ module FlowChat
2
+ module Messenger
3
+ # Its own file so Zeitwerk can resolve it by name. Declared inside
4
+ # configuration.rb it existed only once that file had loaded for some other
5
+ # reason, so an application rescuing it, or a test naming it before anything
6
+ # touched the configuration class, got an uninitialized constant instead.
7
+ class ConfigurationError < StandardError; end
8
+ end
9
+ end