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,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
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
module FlowChat
|
|
2
|
+
module Messenger
|
|
3
|
+
module Gateway
|
|
4
|
+
# Facebook Messenger, on the shared Messenger Platform envelope.
|
|
5
|
+
class SendApi < FlowChat::Meta::MessagingGateway
|
|
6
|
+
def platform
|
|
7
|
+
:messenger
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def gateway_name
|
|
11
|
+
:messenger_send_api
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def configuration_class
|
|
15
|
+
FlowChat::Messenger::Configuration
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def client_class
|
|
19
|
+
FlowChat::Messenger::Client
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def renderer_class
|
|
23
|
+
FlowChat::Messenger::Renderer
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.choice_mapper_class
|
|
27
|
+
FlowChat::Messenger::Middleware::ChoiceMapper
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def configuration_error_class
|
|
33
|
+
FlowChat::Messenger::ConfigurationError
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def platform_label
|
|
37
|
+
"Messenger"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|