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
|
@@ -3,34 +3,71 @@ module FlowChat
|
|
|
3
3
|
module Gateway
|
|
4
4
|
class Simple
|
|
5
5
|
include FlowChat::Instrumentation
|
|
6
|
+
include FlowChat::GatewayAsyncSupport
|
|
6
7
|
|
|
7
8
|
attr_reader :context
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
# Configure HTTP specific middleware stack
|
|
11
|
+
def self.configure_middleware_stack(builder, custom_middleware)
|
|
12
|
+
FlowChat.logger.debug { "Simple: Configuring HTTP middleware stack" }
|
|
13
|
+
|
|
14
|
+
builder.use custom_middleware
|
|
15
|
+
FlowChat.logger.debug { "Simple: Added custom middleware" }
|
|
16
|
+
|
|
17
|
+
builder.use FlowChat::Http::Middleware::ChoiceMapper
|
|
18
|
+
FlowChat.logger.debug { "Simple: Added Http::Middleware::ChoiceMapper" }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize(app, user_params)
|
|
10
22
|
@app = app
|
|
23
|
+
@user_params = user_params
|
|
24
|
+
|
|
25
|
+
validate_user_params!
|
|
11
26
|
end
|
|
12
27
|
|
|
13
28
|
def call(context)
|
|
14
29
|
@context = context
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
30
|
+
@controller = context.controller
|
|
31
|
+
params = @controller.request.params
|
|
32
|
+
request = @controller.request
|
|
33
|
+
|
|
34
|
+
# Validate request method
|
|
35
|
+
unless request.get? || request.post?
|
|
36
|
+
@controller.head :bad_request
|
|
37
|
+
return
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Set request information from user_params
|
|
41
|
+
context["request.id"] = @user_params[:session_id]
|
|
42
|
+
context["request.user_id"] = @user_params[:user_id]
|
|
43
|
+
context["request.user_name"] = @user_params[:name] if @user_params[:name]
|
|
44
|
+
context["request.msisdn"] = @user_params[:msisdn] if @user_params[:msisdn]
|
|
45
|
+
context["request.email"] = @user_params[:email] if @user_params[:email]
|
|
46
|
+
context["request.message_id"] = SecureRandom.uuid
|
|
23
47
|
context["request.timestamp"] = Time.current.iso8601
|
|
24
48
|
context["request.gateway"] = :http_simple
|
|
25
49
|
context["request.platform"] = :http
|
|
26
|
-
context["request.
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
context["
|
|
30
|
-
context
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
50
|
+
context["request.body"] = (params.respond_to?(:to_unsafe_h) ? params.to_unsafe_h : params.to_h).transform_keys(&:to_s)
|
|
51
|
+
|
|
52
|
+
# HTTP-specific request metadata
|
|
53
|
+
context["http.method"] = request.method
|
|
54
|
+
context["http.path"] = request.path
|
|
55
|
+
context["http.user_agent"] = request.user_agent
|
|
56
|
+
context.input = params["input"].presence || ""
|
|
57
|
+
|
|
58
|
+
# Inbound media (optional): callers may submit a media URL
|
|
59
|
+
if params["media_url"].present?
|
|
60
|
+
media_type = params["media_type"].presence&.to_sym
|
|
61
|
+
media_type = :document unless FlowChat::Media::CANONICAL_TYPES.include?(media_type)
|
|
62
|
+
context["request.media"] = {
|
|
63
|
+
type: media_type,
|
|
64
|
+
url: params["media_url"],
|
|
65
|
+
mime_type: params["mime_type"].presence
|
|
66
|
+
}
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Instrument message received when user provides input (text or media)
|
|
70
|
+
if inbound_message?(context)
|
|
34
71
|
instrument(Events::MESSAGE_RECEIVED, {
|
|
35
72
|
from: context["request.user_id"],
|
|
36
73
|
message: context.input,
|
|
@@ -38,31 +75,62 @@ module FlowChat
|
|
|
38
75
|
})
|
|
39
76
|
end
|
|
40
77
|
|
|
41
|
-
#
|
|
42
|
-
|
|
78
|
+
# Determine routing: async enqueue, background execute, or inline
|
|
79
|
+
if should_enqueue_async?
|
|
80
|
+
# HTTP request with async enabled → enqueue job and return immediately
|
|
81
|
+
enqueue_async_job
|
|
82
|
+
@controller.render json: {status: "processing"}
|
|
83
|
+
else
|
|
84
|
+
# Background OR inline → process message
|
|
85
|
+
# Process the request
|
|
86
|
+
response = @app.call(context)
|
|
43
87
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
88
|
+
# Handle nil response (e.g., from middleware that handles the response itself)
|
|
89
|
+
unless response
|
|
90
|
+
return @controller.render json: {
|
|
91
|
+
type: :skip,
|
|
92
|
+
session_id: context["request.id"],
|
|
93
|
+
user_id: context["request.user_id"],
|
|
94
|
+
timestamp: context["request.timestamp"]
|
|
95
|
+
}
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
type, prompt, choices, media = response
|
|
99
|
+
|
|
100
|
+
# Instrument message sent
|
|
101
|
+
instrument(Events::MESSAGE_SENT, {
|
|
102
|
+
to: context["request.user_id"],
|
|
103
|
+
session_id: context["request.id"],
|
|
104
|
+
message: context.input || "",
|
|
105
|
+
message_type: (type == :prompt) ? "prompt" : "terminal",
|
|
106
|
+
gateway: :http_simple,
|
|
107
|
+
platform: :http,
|
|
108
|
+
content_length: prompt.to_s.length,
|
|
109
|
+
timestamp: context["request.timestamp"]
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
# Render response as JSON
|
|
113
|
+
response_data = render_response(type, prompt, choices, media)
|
|
114
|
+
@controller.render json: response_data
|
|
115
|
+
end
|
|
59
116
|
end
|
|
60
117
|
|
|
61
118
|
private
|
|
62
119
|
|
|
120
|
+
def validate_user_params!
|
|
121
|
+
required_keys = [:session_id, :user_id]
|
|
122
|
+
|
|
123
|
+
required_keys.each do |key|
|
|
124
|
+
unless @user_params.key?(key)
|
|
125
|
+
raise FlowChat::Http::ConfigurationError,
|
|
126
|
+
"HTTP Simple gateway requires :#{key} in user_params"
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
63
131
|
def render_response(type, prompt, choices, media)
|
|
64
132
|
rendered = FlowChat::Http::Renderer.new(prompt, choices: choices, media: media).render
|
|
65
|
-
|
|
133
|
+
|
|
66
134
|
{
|
|
67
135
|
type: type,
|
|
68
136
|
session_id: context["request.id"],
|
|
@@ -74,4 +142,4 @@ module FlowChat
|
|
|
74
142
|
end
|
|
75
143
|
end
|
|
76
144
|
end
|
|
77
|
-
end
|
|
145
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
module FlowChat
|
|
2
|
+
module Http
|
|
3
|
+
module Middleware
|
|
4
|
+
# Maps a web client's reply back to the choice it belongs to.
|
|
5
|
+
#
|
|
6
|
+
# A web client renders the choices itself, so what it sends back is
|
|
7
|
+
# whatever it decided to send: the key it was given, or the label it
|
|
8
|
+
# actually put on the button. Sending the label is the better of the two,
|
|
9
|
+
# because a transcript then reads back as what the visitor pressed rather
|
|
10
|
+
# than as an internal id, but the flow branches on keys.
|
|
11
|
+
#
|
|
12
|
+
# So both are accepted. Nothing is truncated here - the client decides
|
|
13
|
+
# its own widths - so a set of distinct labels is passed through exactly
|
|
14
|
+
# as the flow wrote it.
|
|
15
|
+
#
|
|
16
|
+
# Matching is exact, with no normalization on either side. A client
|
|
17
|
+
# echoes back the string it was handed rather than a person typing it,
|
|
18
|
+
# so nothing drifts on the way - and every transform that could have
|
|
19
|
+
# absorbed such a drift can also merge two choices into one entry, which
|
|
20
|
+
# is how the second of a pair became unpickable in the first place.
|
|
21
|
+
#
|
|
22
|
+
# The one thing that does get rewritten is a set whose labels are
|
|
23
|
+
# identical. Two choices both labelled "Savings" cannot be told apart by
|
|
24
|
+
# a visitor reading them either, so FlowChat::ChoiceTitles numbers the
|
|
25
|
+
# whole set. Previously the second simply lost to the first
|
|
26
|
+
# (`mapping[label] ||= key`) and could not be picked at all.
|
|
27
|
+
class ChoiceMapper
|
|
28
|
+
SESSION_KEY = "http.choice_mapping"
|
|
29
|
+
|
|
30
|
+
# The client renders these, so there is no platform width to fit.
|
|
31
|
+
UNCAPPED = Float::INFINITY
|
|
32
|
+
|
|
33
|
+
def initialize(app)
|
|
34
|
+
@app = app
|
|
35
|
+
FlowChat.logger.debug { "Http::ChoiceMapper: Initialized HTTP choice mapping middleware" }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def call(context)
|
|
39
|
+
resolve_input(context)
|
|
40
|
+
|
|
41
|
+
type, prompt, choices, media = @app.call(context)
|
|
42
|
+
|
|
43
|
+
choices = remember(context, choices)
|
|
44
|
+
|
|
45
|
+
[type, prompt, choices, media]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def resolve_input(context)
|
|
51
|
+
return if context.input.blank?
|
|
52
|
+
|
|
53
|
+
mapping = context.session.get(SESSION_KEY) || {}
|
|
54
|
+
return if mapping.empty?
|
|
55
|
+
|
|
56
|
+
matched = mapping[context.input.to_s]
|
|
57
|
+
return unless matched
|
|
58
|
+
|
|
59
|
+
FlowChat.logger.info { "Http::ChoiceMapper: Resolving #{context.input} to #{matched}" }
|
|
60
|
+
context.input = matched
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Displayed title to key. Cleared once a question carries no choices, so
|
|
64
|
+
# an answer to a later question is never read as a choice from an
|
|
65
|
+
# earlier one.
|
|
66
|
+
#
|
|
67
|
+
# Labels that survive the fold distinctly are returned untouched, which
|
|
68
|
+
# is the ordinary case. Only a set that would collide under the fold is
|
|
69
|
+
# numbered, and then the numbered titles are what the client is given -
|
|
70
|
+
# so what the visitor reads is what this resolves.
|
|
71
|
+
#
|
|
72
|
+
# @return [Hash, nil] the choices to render
|
|
73
|
+
def remember(context, choices)
|
|
74
|
+
if choices.blank?
|
|
75
|
+
context.session.delete(SESSION_KEY)
|
|
76
|
+
return choices
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
mapping = {}
|
|
80
|
+
titled = {}
|
|
81
|
+
|
|
82
|
+
FlowChat::ChoiceTitles.build(choices, UNCAPPED).each do |key, _label, title, _truncated|
|
|
83
|
+
titled[key] = title
|
|
84
|
+
mapping[title] = key
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
context.session.set(SESSION_KEY, mapping)
|
|
88
|
+
FlowChat.logger.debug { "Http::ChoiceMapper: Created mapping: #{mapping}" }
|
|
89
|
+
titled
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -24,18 +24,18 @@ module FlowChat
|
|
|
24
24
|
def format_choices
|
|
25
25
|
return unless choices.present?
|
|
26
26
|
|
|
27
|
-
choices.map { |key, value| {
|
|
27
|
+
choices.map { |key, value| {key: key, value: value} }
|
|
28
28
|
end
|
|
29
29
|
|
|
30
30
|
def format_media
|
|
31
31
|
return unless media.present?
|
|
32
32
|
|
|
33
33
|
{
|
|
34
|
-
url: media[:url]
|
|
34
|
+
url: media[:url],
|
|
35
35
|
type: media[:type] || :image,
|
|
36
36
|
caption: media[:caption]
|
|
37
37
|
}.compact
|
|
38
38
|
end
|
|
39
39
|
end
|
|
40
40
|
end
|
|
41
|
-
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
module FlowChat
|
|
2
|
+
# A single inbound turn.
|
|
3
|
+
#
|
|
4
|
+
# Replaces the old "$media$"/"$location$"/"$contact$" sentinels: instead of
|
|
5
|
+
# overloading the input string with a control signal, a turn carries its text
|
|
6
|
+
# and (at most one) structured attachment as first-class fields.
|
|
7
|
+
#
|
|
8
|
+
# It behaves like its text wherever a string method is expected — so
|
|
9
|
+
# validators/transforms written for text (`input.strip`, `input.to_i`,
|
|
10
|
+
# `input.blank?`) keep working — while also exposing `#media`, `#location`,
|
|
11
|
+
# `#contact`, and `#attachment_type` for the attachment. The prompt gates on
|
|
12
|
+
# `#submitted?` (text OR attachment), so a caption-less photo still answers a
|
|
13
|
+
# screen even though its text is blank.
|
|
14
|
+
class Input
|
|
15
|
+
# Session marker for the "first message" gate. Not a turn signal — kept as a
|
|
16
|
+
# namespaced constant so `FlowChat::Input::START` continues to resolve.
|
|
17
|
+
START = "$start$"
|
|
18
|
+
|
|
19
|
+
# #media is always an Array<FlowChat::Media> (empty when none). It is a list
|
|
20
|
+
# even on single-media platforms so callers iterate uniformly and never
|
|
21
|
+
# silently drop the extra attachments a message can carry (e.g. Intercom).
|
|
22
|
+
attr_reader :text, :media, :location, :contact
|
|
23
|
+
|
|
24
|
+
def initialize(text: nil, media: nil, location: nil, contact: nil)
|
|
25
|
+
@text = text.nil? ? "" : text.to_s
|
|
26
|
+
@media = media || []
|
|
27
|
+
@location = location
|
|
28
|
+
@contact = contact
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Did the user send anything this turn — text OR an attachment? The prompt
|
|
32
|
+
# gates on this so a caption-less photo answers a screen. Distinct from
|
|
33
|
+
# #present?/#blank?, which follow the text (so text validators behave).
|
|
34
|
+
def submitted?
|
|
35
|
+
!@text.empty? || attachment?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def attachment?
|
|
39
|
+
!attachment_type.nil?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The structured payload on this turn — the media list, or the location /
|
|
43
|
+
# contact hash — or nil. Pair with #attachment_type to know which.
|
|
44
|
+
def attachment
|
|
45
|
+
case attachment_type
|
|
46
|
+
when :media then media
|
|
47
|
+
when :location then location
|
|
48
|
+
when :contact then contact
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The kind of structured payload on this turn, or nil. At most one is ever
|
|
53
|
+
# present in a single message, so this is a safe discriminator.
|
|
54
|
+
def attachment_type
|
|
55
|
+
return :media if @media.any?
|
|
56
|
+
return :location if @location
|
|
57
|
+
return :contact if @contact
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def to_s
|
|
62
|
+
@text
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def ==(other)
|
|
66
|
+
other = other.to_s if other.is_a?(FlowChat::Input)
|
|
67
|
+
@text == other
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Behave like the text for any other PUBLIC string method (strip, to_i,
|
|
71
|
+
# match?, length, empty?, ...), so text-oriented validators/transforms keep
|
|
72
|
+
# working. Private String methods are not exposed — respond_to_missing? and
|
|
73
|
+
# method_missing agree on public-only so `respond_to?` never lies.
|
|
74
|
+
def respond_to_missing?(name, include_private = false)
|
|
75
|
+
@text.respond_to?(name) || super
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def method_missing(name, *args, &block)
|
|
79
|
+
if @text.respond_to?(name)
|
|
80
|
+
@text.send(name, *args, &block)
|
|
81
|
+
else
|
|
82
|
+
super
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
module FlowChat
|
|
2
|
+
module Instagram
|
|
3
|
+
class Client < FlowChat::Messenger::Client
|
|
4
|
+
private
|
|
5
|
+
|
|
6
|
+
def renderer_class
|
|
7
|
+
FlowChat::Instagram::Renderer
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def platform
|
|
11
|
+
:instagram
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def limits
|
|
15
|
+
FlowChat::Config.instagram
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Meta: "Message text must be UTF-8 and be 1,000 bytes or less." A
|
|
19
|
+
# character count would let multibyte text through to be rejected.
|
|
20
|
+
def measure(string)
|
|
21
|
+
string.bytesize
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Meta's Instagram send reference documents recipient and message only,
|
|
25
|
+
# with no messaging_type. Inheriting Messenger's would put an undocumented
|
|
26
|
+
# parameter on every Instagram send.
|
|
27
|
+
def messaging_type?
|
|
28
|
+
false
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
module FlowChat
|
|
2
|
+
module Instagram
|
|
3
|
+
class Configuration
|
|
4
|
+
include FlowChat::NamedConfiguration
|
|
5
|
+
|
|
6
|
+
# :facebook is the Instagram API with Facebook Login: the linked Page
|
|
7
|
+
# speaks through graph.facebook.com. :instagram is the Instagram API
|
|
8
|
+
# with Instagram Login: the Instagram professional account speaks for
|
|
9
|
+
# itself through graph.instagram.com, with no Page in the picture.
|
|
10
|
+
LOGIN_PATHS = [:facebook, :instagram].freeze
|
|
11
|
+
|
|
12
|
+
attr_accessor :access_token, :page_id, :instagram_account_id, :verify_token,
|
|
13
|
+
:app_id, :app_secret, :name, :skip_signature_validation
|
|
14
|
+
attr_reader :login
|
|
15
|
+
|
|
16
|
+
def initialize(name)
|
|
17
|
+
@name = name
|
|
18
|
+
@access_token = nil
|
|
19
|
+
@page_id = nil
|
|
20
|
+
@instagram_account_id = nil
|
|
21
|
+
@verify_token = nil
|
|
22
|
+
@app_id = nil
|
|
23
|
+
@app_secret = nil
|
|
24
|
+
@skip_signature_validation = false
|
|
25
|
+
@login = :facebook
|
|
26
|
+
|
|
27
|
+
FlowChat.logger.debug { "Instagram::Configuration: Initialized configuration with name: #{name || "anonymous"}" }
|
|
28
|
+
|
|
29
|
+
register_as(name) if name.present?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Rejected rather than coerced: a typo here would otherwise silently
|
|
33
|
+
# pick the wrong host and the wrong account identifier, and fail only
|
|
34
|
+
# once a real send or webhook hits the wrong Meta product.
|
|
35
|
+
def login=(value)
|
|
36
|
+
symbol = value&.to_sym
|
|
37
|
+
unless LOGIN_PATHS.include?(symbol)
|
|
38
|
+
raise ArgumentError, "login must be one of #{LOGIN_PATHS.inspect}, got #{value.inspect}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
@login = symbol
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.from_credentials
|
|
45
|
+
FlowChat.logger.info { "Instagram::Configuration: Loading configuration from credentials/environment" }
|
|
46
|
+
|
|
47
|
+
config = new(nil)
|
|
48
|
+
|
|
49
|
+
if defined?(Rails) && Rails.respond_to?(:application) && Rails.application&.credentials&.instagram
|
|
50
|
+
FlowChat.logger.debug { "Instagram::Configuration: Loading from Rails credentials" }
|
|
51
|
+
credentials = Rails.application.credentials.instagram
|
|
52
|
+
config.access_token = credentials[:access_token]
|
|
53
|
+
config.page_id = credentials[:page_id]
|
|
54
|
+
config.instagram_account_id = credentials[:instagram_account_id]
|
|
55
|
+
config.verify_token = credentials[:verify_token]
|
|
56
|
+
config.app_id = credentials[:app_id]
|
|
57
|
+
config.app_secret = credentials[:app_secret]
|
|
58
|
+
config.skip_signature_validation = credentials[:skip_signature_validation] || false
|
|
59
|
+
config.login = (credentials[:login] || "facebook").to_sym
|
|
60
|
+
else
|
|
61
|
+
FlowChat.logger.debug { "Instagram::Configuration: Loading from environment variables" }
|
|
62
|
+
config.access_token = ENV["INSTAGRAM_ACCESS_TOKEN"]
|
|
63
|
+
config.page_id = ENV["INSTAGRAM_PAGE_ID"]
|
|
64
|
+
config.instagram_account_id = ENV["INSTAGRAM_ACCOUNT_ID"]
|
|
65
|
+
config.verify_token = ENV["INSTAGRAM_VERIFY_TOKEN"]
|
|
66
|
+
config.app_id = ENV["INSTAGRAM_APP_ID"]
|
|
67
|
+
config.app_secret = ENV["INSTAGRAM_APP_SECRET"]
|
|
68
|
+
config.skip_signature_validation = ENV["INSTAGRAM_SKIP_SIGNATURE_VALIDATION"] == "true"
|
|
69
|
+
config.login = (ENV["INSTAGRAM_LOGIN"] || "facebook").to_sym
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
if config.valid?
|
|
73
|
+
FlowChat.logger.info { "Instagram::Configuration: Configuration loaded successfully - #{config.login} login, account #{config.account_id}" }
|
|
74
|
+
else
|
|
75
|
+
FlowChat.logger.warn { "Instagram::Configuration: Incomplete configuration loaded - missing required fields" }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
config
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def valid?
|
|
82
|
+
# Both ids are required, because sending and receiving key on
|
|
83
|
+
# different ones and only on the :facebook path do they differ.
|
|
84
|
+
# account_id is what a send is addressed as (the Page there);
|
|
85
|
+
# instagram_account_id is what an inbound delivery names in entry.id
|
|
86
|
+
# on both paths, which is what webhook_account_id encodes.
|
|
87
|
+
#
|
|
88
|
+
# Checking only account_id passed a :facebook configuration that had
|
|
89
|
+
# never been given an instagram_account_id, and the gateway then
|
|
90
|
+
# rejected every delivery it received: the id it compares against was
|
|
91
|
+
# blank, and a blank expectation matches nothing. A configuration that
|
|
92
|
+
# answers the handshake and then refuses all traffic is worse than one
|
|
93
|
+
# that admits up front it is incomplete.
|
|
94
|
+
#
|
|
95
|
+
# Wrapped so a predicate answers true or false rather than nil, which
|
|
96
|
+
# the bare && chain returns for a missing first field. Intercom and
|
|
97
|
+
# Telegram already do this and pin it in their tests.
|
|
98
|
+
is_valid = !!(access_token && !access_token.to_s.empty? &&
|
|
99
|
+
verify_token && !verify_token.to_s.empty? &&
|
|
100
|
+
account_id && !account_id.to_s.empty? &&
|
|
101
|
+
webhook_account_id && !webhook_account_id.to_s.empty?)
|
|
102
|
+
|
|
103
|
+
FlowChat.logger.debug { "Instagram::Configuration: Configuration valid: #{is_valid}" }
|
|
104
|
+
is_valid
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# What a send is addressed to. An account reached through a Page answers
|
|
108
|
+
# as that Page over graph.facebook.com; an account with no Page answers
|
|
109
|
+
# for itself over graph.instagram.com. This is not the id an inbound
|
|
110
|
+
# delivery names, which is webhook_account_id below.
|
|
111
|
+
def account_id
|
|
112
|
+
(login == :instagram) ? instagram_account_id : page_id
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# The id an inbound webhook's entry.id names, which is a different
|
|
116
|
+
# question from account_id above and has a different answer on the
|
|
117
|
+
# Facebook Login path.
|
|
118
|
+
#
|
|
119
|
+
# The top-level object of a delivery decides the id space, and this
|
|
120
|
+
# gateway only ever handles `instagram` (see expected_webhook_object),
|
|
121
|
+
# which names the Instagram professional account. That holds on both
|
|
122
|
+
# paths, so unlike account_id this does not depend on login.
|
|
123
|
+
def webhook_account_id
|
|
124
|
+
instagram_account_id
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def messages_url
|
|
128
|
+
"#{api_base_url}/#{account_id}/messages"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def attachment_upload_url
|
|
132
|
+
"#{api_base_url}/#{account_id}/message_attachments"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def api_base_url
|
|
136
|
+
(login == :instagram) ? FlowChat::Config.instagram.instagram_login_api_base_url : FlowChat::Config.instagram.api_base_url
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def api_headers
|
|
140
|
+
{
|
|
141
|
+
"Authorization" => "Bearer #{access_token}",
|
|
142
|
+
"Content-Type" => "application/json"
|
|
143
|
+
}
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
module FlowChat
|
|
2
|
+
module Instagram
|
|
3
|
+
module Gateway
|
|
4
|
+
# Instagram DMs, on the shared Messenger Platform envelope.
|
|
5
|
+
#
|
|
6
|
+
# A sibling of the Messenger gateway rather than a subclass of it: the
|
|
7
|
+
# two differ in credentials, limits and subscription object, and
|
|
8
|
+
# neither owns the other.
|
|
9
|
+
class SendApi < FlowChat::Meta::MessagingGateway
|
|
10
|
+
def platform
|
|
11
|
+
:instagram
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def gateway_name
|
|
15
|
+
:instagram_send_api
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def configuration_class
|
|
19
|
+
FlowChat::Instagram::Configuration
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def client_class
|
|
23
|
+
FlowChat::Instagram::Client
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def renderer_class
|
|
27
|
+
FlowChat::Instagram::Renderer
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.choice_mapper_class
|
|
31
|
+
FlowChat::Instagram::Middleware::ChoiceMapper
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Confirmed against a live delivery for a page-linked account: Meta's
|
|
35
|
+
# docs are ambiguous about whether these arrive under "page" or
|
|
36
|
+
# "instagram", and they arrive under "instagram". The delivery named
|
|
37
|
+
# the Instagram professional account in entry.id, not the linked Page,
|
|
38
|
+
# which is what webhook_account_id encodes.
|
|
39
|
+
FACEBOOK_LOGIN_WEBHOOK_OBJECT = "instagram"
|
|
40
|
+
|
|
41
|
+
# Kept as its own constant rather than sharing one with the path above:
|
|
42
|
+
# the two integrations are configured independently in Meta's
|
|
43
|
+
# dashboard, so a correction to one path's value must not silently
|
|
44
|
+
# change the other's.
|
|
45
|
+
INSTAGRAM_LOGIN_WEBHOOK_OBJECT = "instagram"
|
|
46
|
+
|
|
47
|
+
def expected_webhook_object
|
|
48
|
+
(@config.login == :instagram) ? INSTAGRAM_LOGIN_WEBHOOK_OBJECT : FACEBOOK_LOGIN_WEBHOOK_OBJECT
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def configuration_error_class
|
|
54
|
+
FlowChat::Instagram::ConfigurationError
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def platform_label
|
|
58
|
+
"Instagram"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module FlowChat
|
|
2
|
+
module Instagram
|
|
3
|
+
module Middleware
|
|
4
|
+
class ChoiceMapper < FlowChat::Messenger::Middleware::ChoiceMapper
|
|
5
|
+
ID_KEY = "instagram.choice_mapping"
|
|
6
|
+
POSITION_KEY = "instagram.position_mapping"
|
|
7
|
+
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def platform_limits
|
|
11
|
+
FlowChat::Config.instagram
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# The body always carries numbers here, so a typed number must
|
|
15
|
+
# always resolve, not only above the carousel capacity.
|
|
16
|
+
def always_number?
|
|
17
|
+
true
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module FlowChat
|
|
2
|
+
module Instagram
|
|
3
|
+
# Instagram's ladder is Messenger's algorithm with different constants.
|
|
4
|
+
# The gateways are siblings (Instagram's does not depend on Messenger's),
|
|
5
|
+
# but the renderers really are the same shape, so this is the one place
|
|
6
|
+
# Instagram inherits from Messenger rather than mirroring it.
|
|
7
|
+
class Renderer < FlowChat::Messenger::Renderer
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def limits
|
|
11
|
+
FlowChat::Config.instagram
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Quick replies and carousels are mobile only on Instagram, so the
|
|
15
|
+
# options are always listed in the body as well. A user on desktop
|
|
16
|
+
# sees the prompt and nothing tappable, and without the list has no
|
|
17
|
+
# way to reply at all.
|
|
18
|
+
def always_number?
|
|
19
|
+
true
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|