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.
- checksums.yaml +4 -4
- data/.github/workflows/pages.yml +43 -0
- data/CHANGELOG.md +26 -0
- data/README.md +166 -295
- data/Rakefile +12 -1
- data/SECURITY.md +1 -1
- data/docs/architecture.md +52 -479
- data/docs/async-background-processing.md +31 -265
- data/docs/configuration.md +106 -613
- data/docs/factory-pattern.md +27 -324
- data/docs/gateway-context-variables.md +140 -143
- data/docs/gateway-development.md +86 -650
- data/docs/getting-started.md +40 -379
- data/docs/instrumentation.md +88 -279
- data/docs/platforms/instagram.md +278 -0
- data/docs/platforms/messenger.md +205 -0
- data/docs/platforms/telegram.md +47 -951
- data/docs/platforms/ussd.md +38 -653
- data/docs/platforms/whatsapp.md +73 -1321
- 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 +26 -297
- data/examples/http_controller.rb +12 -10
- data/examples/intercom_configuration_example.rb +19 -24
- data/examples/intercom_controller.rb +8 -20
- data/examples/multi_tenant_whatsapp_controller.rb +61 -166
- data/examples/simulator_controller.rb +0 -1
- data/examples/ussd_controller.rb +86 -158
- data/examples/whatsapp_controller.rb +16 -15
- data/examples/whatsapp_media_examples.rb +27 -79
- data/lib/flow_chat/app.rb +129 -11
- data/lib/flow_chat/choice_titles.rb +95 -0
- data/lib/flow_chat/config.rb +124 -1
- data/lib/flow_chat/delivery_error.rb +9 -0
- data/lib/flow_chat/http/configuration_error.rb +9 -0
- data/lib/flow_chat/http/gateway/simple.rb +24 -4
- data/lib/flow_chat/http/middleware/choice_mapper.rb +94 -0
- 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.rb +160 -1
- data/lib/flow_chat/intercom/client.rb +34 -28
- data/lib/flow_chat/intercom/configuration.rb +2 -49
- data/lib/flow_chat/intercom/configuration_error.rb +9 -0
- data/lib/flow_chat/intercom/gateway/intercom_api.rb +81 -57
- data/lib/flow_chat/intercom/middleware/choice_mapper.rb +101 -0
- data/lib/flow_chat/intercom/renderer.rb +57 -5
- 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/prompt.rb +13 -16
- data/lib/flow_chat/renderers/markdown_support.rb +109 -0
- data/lib/flow_chat/security.rb +76 -0
- data/lib/flow_chat/session/middleware.rb +11 -2
- 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 +47 -4
- data/lib/flow_chat/telegram/configuration.rb +2 -42
- data/lib/flow_chat/telegram/configuration_error.rb +9 -0
- data/lib/flow_chat/telegram/gateway/bot_api.rb +46 -28
- data/lib/flow_chat/telegram/middleware/choice_mapper.rb +77 -16
- data/lib/flow_chat/telegram/renderer.rb +10 -2
- data/lib/flow_chat/text_truncator.rb +75 -0
- data/lib/flow_chat/ussd/middleware/choice_mapper.rb +10 -0
- data/lib/flow_chat/version.rb +1 -1
- data/lib/flow_chat/whatsapp/client.rb +14 -7
- data/lib/flow_chat/whatsapp/configuration.rb +12 -51
- data/lib/flow_chat/whatsapp/configuration_error.rb +9 -0
- data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +254 -203
- data/lib/flow_chat/whatsapp/middleware/choice_mapper.rb +144 -57
- data/lib/flow_chat/whatsapp/renderer.rb +121 -60
- data/lib/flow_chat.rb +0 -10
- 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 +51 -3
- 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
|
-
|
|
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
|
-
|
|
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
|