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
data/lib/flow_chat/prompt.rb
CHANGED
|
@@ -3,13 +3,15 @@ module FlowChat
|
|
|
3
3
|
attr_reader :user_input
|
|
4
4
|
|
|
5
5
|
def initialize(input)
|
|
6
|
-
|
|
6
|
+
# Always work with a FlowChat::Input so `submitted?` (text OR attachment)
|
|
7
|
+
# gates the turn, and validate/transform receive the rich turn. A bare
|
|
8
|
+
# string (or nil) is wrapped as text.
|
|
9
|
+
@user_input = input.is_a?(FlowChat::Input) ? input : FlowChat::Input.new(text: input)
|
|
7
10
|
end
|
|
8
11
|
|
|
9
12
|
def ask(msg, choices: nil, transform: nil, validate: nil, media: nil)
|
|
10
|
-
if user_input.
|
|
11
|
-
|
|
12
|
-
validation_error = validate.call(input) if validate.present?
|
|
13
|
+
if user_input.submitted?
|
|
14
|
+
validation_error = validate.call(user_input) if validate.present?
|
|
13
15
|
|
|
14
16
|
if validation_error.present?
|
|
15
17
|
# Use config to determine whether to combine validation error with original message
|
|
@@ -21,8 +23,7 @@ module FlowChat
|
|
|
21
23
|
prompt!(message, choices: choices, media: media)
|
|
22
24
|
end
|
|
23
25
|
|
|
24
|
-
|
|
25
|
-
return input
|
|
26
|
+
return transform.present? ? transform.call(user_input) : user_input.to_s
|
|
26
27
|
end
|
|
27
28
|
|
|
28
29
|
# Pass raw message and media separately to the renderer
|
|
@@ -56,14 +57,6 @@ module FlowChat
|
|
|
56
57
|
|
|
57
58
|
private
|
|
58
59
|
|
|
59
|
-
def validate_media_choices_compatibility(media, choices)
|
|
60
|
-
return unless media && choices
|
|
61
|
-
|
|
62
|
-
if choices.length > 3
|
|
63
|
-
raise ArgumentError, "Media with more than 3 choices is not supported. Please use either media OR choices for more than 3 options."
|
|
64
|
-
end
|
|
65
|
-
end
|
|
66
|
-
|
|
67
60
|
def normalize_choices(choices)
|
|
68
61
|
case choices
|
|
69
62
|
when nil
|
|
@@ -78,8 +71,12 @@ module FlowChat
|
|
|
78
71
|
end
|
|
79
72
|
|
|
80
73
|
def prompt!(msg, choices: nil, media: nil)
|
|
81
|
-
|
|
82
|
-
|
|
74
|
+
# Media plus any number of choices is a supported combination: every
|
|
75
|
+
# renderer either carries the media as its own message ahead of the
|
|
76
|
+
# choice surface, or (WhatsApp, <=3 choices) as a header on the same
|
|
77
|
+
# message. There is no platform-level reason to cap the choice count
|
|
78
|
+
# here - that decision, if one is ever needed, belongs to a specific
|
|
79
|
+
# renderer, not to this shared entry point.
|
|
83
80
|
choices = normalize_choices(choices)
|
|
84
81
|
raise FlowChat::Interrupt::Prompt.new(msg, choices: choices, media: media)
|
|
85
82
|
end
|
|
@@ -21,6 +21,16 @@ module FlowChat
|
|
|
21
21
|
post_process_html(sanitized)
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
+
# Markdown rendered as plain text, for platforms with no rich text at all.
|
|
25
|
+
# Messenger and Instagram both fall here: they display exactly the
|
|
26
|
+
# characters sent, so any leftover markup is noise the user reads.
|
|
27
|
+
def to_plain_text(text)
|
|
28
|
+
return "" if text.nil?
|
|
29
|
+
|
|
30
|
+
html = Kramdown::Document.new(text.to_s, **kramdown_options).to_html.strip
|
|
31
|
+
html_to_plain_text(html)
|
|
32
|
+
end
|
|
33
|
+
|
|
24
34
|
def self.included(base)
|
|
25
35
|
base.extend(ClassMethods)
|
|
26
36
|
end
|
|
@@ -53,6 +63,105 @@ module FlowChat
|
|
|
53
63
|
def post_process_html(html)
|
|
54
64
|
html
|
|
55
65
|
end
|
|
66
|
+
|
|
67
|
+
# A list whose items hold no further list, which is the only kind that
|
|
68
|
+
# can be turned into text without looking inside itself first.
|
|
69
|
+
INNERMOST_UNORDERED = %r{<ul[^>]*>((?:(?!<[uo]l\b).)*?)</ul>}m
|
|
70
|
+
INNERMOST_ORDERED = %r{<ol[^>]*>((?:(?!<[uo]l\b).)*?)</ol>}m
|
|
71
|
+
|
|
72
|
+
# Renders lists from the inside out.
|
|
73
|
+
#
|
|
74
|
+
# A single non-greedy pass over <ul>(.*?)</ul> pairs an outer opening tag
|
|
75
|
+
# with the *inner* list's closing tag, so on a nested list only the first
|
|
76
|
+
# item kept its bullet and the leftover </li></ul> was later stripped as
|
|
77
|
+
# a bare tag - leaving stray indented lines, and on some inputs raw
|
|
78
|
+
# markup, in a message a user reads.
|
|
79
|
+
#
|
|
80
|
+
# Innermost lists are replaced first and the loop repeats, so by the time
|
|
81
|
+
# an outer list is matched its children are already plain text and it
|
|
82
|
+
# contains no list markup to mispair with. Continuation lines are
|
|
83
|
+
# indented, which is what makes the nesting legible once the tags are
|
|
84
|
+
# gone.
|
|
85
|
+
def replace_lists(html)
|
|
86
|
+
# The markdown's own indentation survives into the HTML as whitespace
|
|
87
|
+
# around the list tags. Removed once, up front, so that from here on the
|
|
88
|
+
# only indentation in play is the kind this method adds - otherwise the
|
|
89
|
+
# two compound and each level steps further right than the last.
|
|
90
|
+
result = html.gsub(%r{\s*(</?(?:ul|ol|li)\b[^>]*>)\s*}m) { $1 }
|
|
91
|
+
|
|
92
|
+
loop do
|
|
93
|
+
changed = false
|
|
94
|
+
|
|
95
|
+
# Each rendered list opens on its own line. Without it, a nested list
|
|
96
|
+
# would run straight on from the text of the item holding it, and the
|
|
97
|
+
# parent pass would see one line where there are two.
|
|
98
|
+
result = result.gsub(INNERMOST_UNORDERED) do
|
|
99
|
+
changed = true
|
|
100
|
+
"\n" + list_items($1) { |item, _index| "• #{item}" }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
result = result.gsub(INNERMOST_ORDERED) do
|
|
104
|
+
changed = true
|
|
105
|
+
"\n" + list_items($1) { |item, index| "#{index}. #{item}" }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
break unless changed
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
result
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def list_items(html)
|
|
115
|
+
html.scan(%r{<li[^>]*>(.*?)</li>}m).flatten.map.with_index(1) do |item, index|
|
|
116
|
+
# Continuation lines are left exactly as they are: they were produced
|
|
117
|
+
# by an earlier pass over a nested list and already carry that level's
|
|
118
|
+
# indentation, which stripping would flatten. Source whitespace is
|
|
119
|
+
# gone by now - replace_lists removes it around the list tags before
|
|
120
|
+
# any of this runs.
|
|
121
|
+
lines = item.strip.split("\n").reject { |line| line.strip.empty? }
|
|
122
|
+
marked = yield(lines.shift.to_s.strip, index)
|
|
123
|
+
|
|
124
|
+
# Anything after the first line is an already-rendered nested list.
|
|
125
|
+
[marked, *lines.map { |line| " #{line}" }].join("\n")
|
|
126
|
+
end.join("\n")
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def html_to_plain_text(html)
|
|
130
|
+
result = html.dup
|
|
131
|
+
|
|
132
|
+
# Code blocks and inline code keep their content, lose their markers.
|
|
133
|
+
result.gsub!(%r{<pre[^>]*><code[^>]*>(.*?)</code></pre>}m) { $1.strip }
|
|
134
|
+
result.gsub!(%r{<code[^>]*>(.*?)</code>}m) { $1 }
|
|
135
|
+
|
|
136
|
+
# Links first: the anchor text is needed before tags are stripped.
|
|
137
|
+
result.gsub!(%r{<a[^>]*href=["']([^"']+)["'][^>]*>(.*?)</a>}m) do
|
|
138
|
+
url, text = $1, $2
|
|
139
|
+
(text == url) ? url : "#{text} (#{url})"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
result = replace_lists(result)
|
|
143
|
+
|
|
144
|
+
result.gsub!(%r{<blockquote[^>]*>(.*?)</blockquote>}m) do
|
|
145
|
+
$1.lines.map { |line| "> #{line.strip}" }.join("\n")
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
result.gsub!(%r{<p[^>]*>(.*?)</p>}m) { "#{$1}\n\n" }
|
|
149
|
+
result.gsub!(/<br\s*\/?>/, "\n")
|
|
150
|
+
|
|
151
|
+
# Every remaining tag, emphasis included, goes without replacement.
|
|
152
|
+
result.gsub!(/<[^>]+>/, "")
|
|
153
|
+
|
|
154
|
+
result.gsub!("&", "&")
|
|
155
|
+
result.gsub!("<", "<")
|
|
156
|
+
result.gsub!(">", ">")
|
|
157
|
+
result.gsub!(""", '"')
|
|
158
|
+
result.gsub!("'", "'")
|
|
159
|
+
result.gsub!(" ", " ")
|
|
160
|
+
|
|
161
|
+
result.gsub!(/\n{3,}/, "\n\n")
|
|
162
|
+
|
|
163
|
+
result.strip
|
|
164
|
+
end
|
|
56
165
|
end
|
|
57
166
|
end
|
|
58
167
|
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
require "openssl"
|
|
2
|
+
|
|
3
|
+
begin
|
|
4
|
+
require "active_support/security_utils"
|
|
5
|
+
rescue LoadError
|
|
6
|
+
# Older Active Support, or an install without it. secure_compare falls back
|
|
7
|
+
# to its own implementation below.
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
module FlowChat
|
|
11
|
+
# Shared security helpers: constant-time comparison for webhook secrets and
|
|
12
|
+
# signatures, and the signed cookie that authorizes simulator mode.
|
|
13
|
+
module Security
|
|
14
|
+
SIMULATOR_COOKIE_NAME = "flowchat_simulator"
|
|
15
|
+
|
|
16
|
+
# How long a simulator cookie stays valid.
|
|
17
|
+
SIMULATOR_COOKIE_TTL = 24 * 60 * 60
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
# Compare two strings without leaking their contents through timing.
|
|
21
|
+
def secure_compare(a, b)
|
|
22
|
+
a = a.to_s
|
|
23
|
+
b = b.to_s
|
|
24
|
+
|
|
25
|
+
if defined?(ActiveSupport::SecurityUtils)
|
|
26
|
+
ActiveSupport::SecurityUtils.secure_compare(a, b)
|
|
27
|
+
else
|
|
28
|
+
fallback_secure_compare(a, b)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The value to store in the simulator cookie: "timestamp:signature".
|
|
33
|
+
def simulator_cookie(timestamp = Time.now.to_i)
|
|
34
|
+
"#{timestamp}:#{simulator_signature(timestamp)}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# A simulator cookie is valid when it carries a recent timestamp signed
|
|
38
|
+
# with the configured simulator secret.
|
|
39
|
+
def valid_simulator_cookie?(cookie)
|
|
40
|
+
return false if FlowChat::Config.simulator_secret.blank? || cookie.blank?
|
|
41
|
+
|
|
42
|
+
timestamp_str, signature = cookie.to_s.split(":", 2)
|
|
43
|
+
return false unless timestamp_str && signature
|
|
44
|
+
|
|
45
|
+
timestamp = timestamp_str.to_i
|
|
46
|
+
return false if timestamp <= 0
|
|
47
|
+
return false if (Time.now.to_i - timestamp).abs > SIMULATOR_COOKIE_TTL
|
|
48
|
+
|
|
49
|
+
secure_compare(signature, simulator_signature(timestamp_str))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def simulator_signature(timestamp)
|
|
53
|
+
OpenSSL::HMAC.hexdigest(
|
|
54
|
+
OpenSSL::Digest.new("sha256"),
|
|
55
|
+
FlowChat::Config.simulator_secret,
|
|
56
|
+
"simulator:#{timestamp}"
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
# What Active Support does, for installs that do not have it: compare
|
|
63
|
+
# digests rather than the inputs, so the comparison runs over a fixed
|
|
64
|
+
# length and times neither the contents nor the length of a secret. The
|
|
65
|
+
# equality check afterwards is what makes a digest collision harmless.
|
|
66
|
+
def fallback_secure_compare(a, b)
|
|
67
|
+
digest_a = OpenSSL::Digest.digest("SHA256", a)
|
|
68
|
+
digest_b = OpenSSL::Digest.digest("SHA256", b)
|
|
69
|
+
|
|
70
|
+
result = 0
|
|
71
|
+
digest_a.bytes.zip(digest_b.bytes) { |byte_a, byte_b| result |= byte_a ^ byte_b }
|
|
72
|
+
result == 0 && a == b
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -96,6 +96,10 @@ module FlowChat
|
|
|
96
96
|
case platform
|
|
97
97
|
when :whatsapp
|
|
98
98
|
:msisdn
|
|
99
|
+
when :messenger, :instagram
|
|
100
|
+
# Neither platform exposes a phone number. The sender id is scoped to
|
|
101
|
+
# the app and the account, and is stable per user.
|
|
102
|
+
:user_id
|
|
99
103
|
else
|
|
100
104
|
:request_id
|
|
101
105
|
end
|
|
@@ -161,8 +165,13 @@ module FlowChat
|
|
|
161
165
|
end
|
|
162
166
|
|
|
163
167
|
def hash_identifier(identifier)
|
|
164
|
-
#
|
|
165
|
-
|
|
168
|
+
# First 32 hex chars of SHA256 = 128 bits. The old 8-char (32-bit)
|
|
169
|
+
# truncation was far too short: distinct users started colliding
|
|
170
|
+
# (birthday bound) around ~77k identifiers, and a collision means two
|
|
171
|
+
# users share a session. 128 bits pushes that boundary past ~2^64
|
|
172
|
+
# identifiers (collision-safe in practice) at half the length of the
|
|
173
|
+
# full 64-char digest.
|
|
174
|
+
Digest::SHA256.hexdigest(identifier.to_s)[0, 32]
|
|
166
175
|
end
|
|
167
176
|
end
|
|
168
177
|
end
|
|
@@ -54,6 +54,32 @@ module FlowChat
|
|
|
54
54
|
contact_name: default_contact_name
|
|
55
55
|
}
|
|
56
56
|
},
|
|
57
|
+
messenger: {
|
|
58
|
+
name: "Messenger (Send API)",
|
|
59
|
+
description: "Facebook Messenger integration using the Send API",
|
|
60
|
+
processor_type: "messenger",
|
|
61
|
+
gateway: "send_api",
|
|
62
|
+
endpoint: "/messenger/webhook",
|
|
63
|
+
icon: "💬",
|
|
64
|
+
color: "#0084FF",
|
|
65
|
+
settings: {
|
|
66
|
+
user_id: default_phone_number,
|
|
67
|
+
contact_name: default_contact_name
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
instagram: {
|
|
71
|
+
name: "Instagram (Send API)",
|
|
72
|
+
description: "Instagram DM integration using the Send API",
|
|
73
|
+
processor_type: "instagram",
|
|
74
|
+
gateway: "send_api",
|
|
75
|
+
endpoint: "/instagram/webhook",
|
|
76
|
+
icon: "📷",
|
|
77
|
+
color: "#E1306C",
|
|
78
|
+
settings: {
|
|
79
|
+
user_id: default_phone_number,
|
|
80
|
+
contact_name: default_contact_name
|
|
81
|
+
}
|
|
82
|
+
},
|
|
57
83
|
http: {
|
|
58
84
|
name: "HTTP API",
|
|
59
85
|
description: "HTTP integration with JSON request/response",
|
|
@@ -88,24 +114,14 @@ module FlowChat
|
|
|
88
114
|
end
|
|
89
115
|
|
|
90
116
|
def set_simulator_cookie
|
|
91
|
-
|
|
92
|
-
simulator_secret = FlowChat::Config.simulator_secret
|
|
93
|
-
|
|
94
|
-
unless simulator_secret && !simulator_secret.empty?
|
|
117
|
+
if FlowChat::Config.simulator_secret.blank?
|
|
95
118
|
raise StandardError, "Simulator secret not configured. Please set FlowChat::Config.simulator_secret to enable simulator mode."
|
|
96
119
|
end
|
|
97
120
|
|
|
98
|
-
#
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
cookie_value = "#{timestamp}:#{signature}"
|
|
104
|
-
|
|
105
|
-
# Set secure cookie (valid for 24 hours)
|
|
106
|
-
cookies[:flowchat_simulator] = {
|
|
107
|
-
value: cookie_value,
|
|
108
|
-
expires: 24.hours.from_now,
|
|
121
|
+
# Set secure cookie (valid for as long as the gateways will accept it)
|
|
122
|
+
cookies[FlowChat::Security::SIMULATOR_COOKIE_NAME] = {
|
|
123
|
+
value: FlowChat::Security.simulator_cookie,
|
|
124
|
+
expires: FlowChat::Security::SIMULATOR_COOKIE_TTL.seconds.from_now,
|
|
109
125
|
secure: request.ssl?, # Only send over HTTPS in production
|
|
110
126
|
httponly: true, # Prevent XSS access
|
|
111
127
|
same_site: :lax # CSRF protection while allowing normal navigation
|
|
@@ -1019,6 +1019,14 @@
|
|
|
1019
1019
|
// Configuration Data
|
|
1020
1020
|
const configurations = <%= configurations.to_json.html_safe %>;
|
|
1021
1021
|
|
|
1022
|
+
// Platforms that render as chat bubbles, sharing the WhatsApp screen
|
|
1023
|
+
// chrome rather than each growing its own copy of it.
|
|
1024
|
+
const CHAT_PLATFORMS = ['whatsapp', 'messenger', 'instagram']
|
|
1025
|
+
|
|
1026
|
+
function isChatPlatform(processorType) {
|
|
1027
|
+
return CHAT_PLATFORMS.includes(processorType)
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1022
1030
|
// DOM Elements
|
|
1023
1031
|
const elements = {
|
|
1024
1032
|
configSelect: document.getElementById('config-select'),
|
|
@@ -1106,7 +1114,7 @@
|
|
|
1106
1114
|
}
|
|
1107
1115
|
|
|
1108
1116
|
function updateUserSettings() {
|
|
1109
|
-
if (state.currentConfig && state.currentConfig.processor_type
|
|
1117
|
+
if (state.currentConfig && isChatPlatform(state.currentConfig.processor_type)) {
|
|
1110
1118
|
updateContactInfo()
|
|
1111
1119
|
}
|
|
1112
1120
|
}
|
|
@@ -1127,19 +1135,20 @@
|
|
|
1127
1135
|
if (!state.currentConfig) return
|
|
1128
1136
|
|
|
1129
1137
|
const processorType = state.currentConfig.processor_type
|
|
1130
|
-
const
|
|
1138
|
+
const isChat = isChatPlatform(processorType)
|
|
1131
1139
|
const isHttp = processorType === 'http'
|
|
1132
|
-
|
|
1133
|
-
// Show/hide platform-specific elements
|
|
1134
|
-
|
|
1135
|
-
elements.
|
|
1136
|
-
elements.
|
|
1140
|
+
|
|
1141
|
+
// Show/hide platform-specific elements. The chat screen is shared by
|
|
1142
|
+
// every chat platform rather than each growing its own copy of it.
|
|
1143
|
+
elements.contactNameGroup.style.display = isChat ? 'block' : 'none'
|
|
1144
|
+
elements.ussdScreen.classList.toggle('hidden', isChat || isHttp)
|
|
1145
|
+
elements.whatsappScreen.classList.toggle('hidden', !isChat)
|
|
1137
1146
|
elements.httpScreen.classList.toggle('hidden', !isHttp)
|
|
1138
|
-
|
|
1147
|
+
|
|
1139
1148
|
// Update input placeholder
|
|
1140
1149
|
let placeholder = 'Enter USSD input...'
|
|
1141
|
-
if (
|
|
1142
|
-
placeholder =
|
|
1150
|
+
if (isChat) {
|
|
1151
|
+
placeholder = `Type your ${state.currentConfig.name} message...`
|
|
1143
1152
|
} else if (isHttp) {
|
|
1144
1153
|
placeholder = 'Type your HTTP message...'
|
|
1145
1154
|
}
|
|
@@ -1186,12 +1195,12 @@
|
|
|
1186
1195
|
|
|
1187
1196
|
if (state.currentConfig.processor_type === 'ussd') {
|
|
1188
1197
|
await makeUSSDRequest()
|
|
1189
|
-
} else if (state.currentConfig.processor_type
|
|
1190
|
-
await
|
|
1198
|
+
} else if (isChatPlatform(state.currentConfig.processor_type)) {
|
|
1199
|
+
await makeChatPlatformRequest()
|
|
1191
1200
|
} else if (state.currentConfig.processor_type === 'http') {
|
|
1192
1201
|
await makeHTTPRequest()
|
|
1193
1202
|
}
|
|
1194
|
-
|
|
1203
|
+
|
|
1195
1204
|
updateStatus('Connected', 'connected')
|
|
1196
1205
|
|
|
1197
1206
|
} catch (error) {
|
|
@@ -1209,19 +1218,19 @@
|
|
|
1209
1218
|
updateStatus('Sending...', 'connecting')
|
|
1210
1219
|
|
|
1211
1220
|
// Add outgoing message to appropriate chat
|
|
1212
|
-
if (state.currentConfig.processor_type
|
|
1221
|
+
if (isChatPlatform(state.currentConfig.processor_type)) {
|
|
1213
1222
|
addMessage(message, true)
|
|
1214
1223
|
} else if (state.currentConfig.processor_type === 'http') {
|
|
1215
1224
|
addHttpMessage(message, true)
|
|
1216
1225
|
}
|
|
1217
|
-
|
|
1226
|
+
|
|
1218
1227
|
elements.messageInput.value = ''
|
|
1219
1228
|
updateCharCount()
|
|
1220
|
-
|
|
1229
|
+
|
|
1221
1230
|
if (state.currentConfig.processor_type === 'ussd') {
|
|
1222
1231
|
await makeUSSDRequest(message)
|
|
1223
|
-
} else if (state.currentConfig.processor_type
|
|
1224
|
-
await
|
|
1232
|
+
} else if (isChatPlatform(state.currentConfig.processor_type)) {
|
|
1233
|
+
await makeChatPlatformRequest(message)
|
|
1225
1234
|
} else if (state.currentConfig.processor_type === 'http') {
|
|
1226
1235
|
await makeHTTPRequest(message)
|
|
1227
1236
|
}
|
|
@@ -1565,6 +1574,131 @@
|
|
|
1565
1574
|
}
|
|
1566
1575
|
}
|
|
1567
1576
|
|
|
1577
|
+
// Dispatches to the request builder for whichever chat platform is
|
|
1578
|
+
// selected. WhatsApp keeps its own Cloud API envelope; Messenger and
|
|
1579
|
+
// Instagram share the Messenger Platform envelope instead.
|
|
1580
|
+
async function makeChatPlatformRequest(userInput = null) {
|
|
1581
|
+
if (state.currentConfig.processor_type === 'whatsapp') {
|
|
1582
|
+
await makeWhatsAppRequest(userInput)
|
|
1583
|
+
} else {
|
|
1584
|
+
await makeMessagingRequest(userInput)
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
// Messenger and Instagram request handler: builds the entry[].messaging[]
|
|
1589
|
+
// envelope both gateways parse, with simulator_mode enabled.
|
|
1590
|
+
async function makeMessagingRequest(userInput = null) {
|
|
1591
|
+
const config = state.currentConfig
|
|
1592
|
+
const userId = elements.phoneNumber.value
|
|
1593
|
+
|
|
1594
|
+
let isInitialMessage = false
|
|
1595
|
+
if (userInput === null) {
|
|
1596
|
+
userInput = 'hi'
|
|
1597
|
+
isInitialMessage = true
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
if (isInitialMessage) {
|
|
1601
|
+
addMessage(userInput, true)
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
const webhookData = {
|
|
1605
|
+
object: config.processor_type === 'instagram' ? 'instagram' : 'page',
|
|
1606
|
+
entry: [{
|
|
1607
|
+
id: config.settings.page_id || 'page_1',
|
|
1608
|
+
messaging: [{
|
|
1609
|
+
sender: {id: userId},
|
|
1610
|
+
recipient: {id: config.settings.page_id || 'page_1'},
|
|
1611
|
+
timestamp: Date.now(),
|
|
1612
|
+
message: {mid: 'mid.' + Date.now(), text: userInput}
|
|
1613
|
+
}]
|
|
1614
|
+
}],
|
|
1615
|
+
simulator_mode: true
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
try {
|
|
1619
|
+
const response = await fetch(config.endpoint, {
|
|
1620
|
+
method: 'POST',
|
|
1621
|
+
headers: {'Content-Type': 'application/json'},
|
|
1622
|
+
body: JSON.stringify(webhookData),
|
|
1623
|
+
credentials: 'include'
|
|
1624
|
+
})
|
|
1625
|
+
|
|
1626
|
+
const responseText = await response.text()
|
|
1627
|
+
let responseData = null
|
|
1628
|
+
|
|
1629
|
+
if (response.headers.get('content-type')?.includes('application/json')) {
|
|
1630
|
+
try {
|
|
1631
|
+
responseData = JSON.parse(responseText)
|
|
1632
|
+
|
|
1633
|
+
if (responseData.mode === 'simulator') {
|
|
1634
|
+
displaySimulatorResponse(responseData)
|
|
1635
|
+
addRequestLog('POST', config.endpoint, webhookData, responseData, response.status)
|
|
1636
|
+
return
|
|
1637
|
+
}
|
|
1638
|
+
} catch (jsonError) {
|
|
1639
|
+
console.warn('Failed to parse JSON response:', jsonError)
|
|
1640
|
+
console.warn('Response text:', responseText)
|
|
1641
|
+
responseData = responseText
|
|
1642
|
+
}
|
|
1643
|
+
} else {
|
|
1644
|
+
responseData = responseText
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
addRequestLog('POST', config.endpoint, webhookData, responseData, response.status)
|
|
1648
|
+
|
|
1649
|
+
if (!response.ok) {
|
|
1650
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
setTimeout(() => {
|
|
1654
|
+
addInfoMessage(
|
|
1655
|
+
`✅ Webhook delivered successfully (${response.status})\n\n` +
|
|
1656
|
+
`📱 In a real ${config.name} integration:\n` +
|
|
1657
|
+
`• Your endpoint processes this webhook\n` +
|
|
1658
|
+
`• Response messages are sent via the Send API\n` +
|
|
1659
|
+
`• Messages appear in the actual ${config.name} chat\n\n` +
|
|
1660
|
+
`💡 This simulator shows the webhook delivery only.\n` +
|
|
1661
|
+
`To enable full simulator mode, configure your endpoint\n` +
|
|
1662
|
+
`to handle simulator_mode parameter and return JSON responses.`
|
|
1663
|
+
)
|
|
1664
|
+
}, 500)
|
|
1665
|
+
} catch (error) {
|
|
1666
|
+
addRequestLog('POST', config.endpoint, webhookData, null, 0, error.message)
|
|
1667
|
+
|
|
1668
|
+
let errorMessage = error.message
|
|
1669
|
+
if (error.message.includes('Failed to fetch')) {
|
|
1670
|
+
errorMessage = 'Cannot connect to endpoint. Please check:\n' +
|
|
1671
|
+
'• Endpoint URL is correct\n' +
|
|
1672
|
+
'• Server is running\n' +
|
|
1673
|
+
'• CORS is configured if cross-origin\n' +
|
|
1674
|
+
'• SSL certificate is valid (for HTTPS)'
|
|
1675
|
+
} else if (error.message.includes('404')) {
|
|
1676
|
+
errorMessage = 'Endpoint not found (404). Please verify:\n' +
|
|
1677
|
+
'• The webhook URL is correct\n' +
|
|
1678
|
+
'• The route is properly configured\n' +
|
|
1679
|
+
'• The controller/handler exists'
|
|
1680
|
+
} else if (error.message.includes('500')) {
|
|
1681
|
+
errorMessage = 'Server error (500). Check server logs for:\n' +
|
|
1682
|
+
'• Application errors\n' +
|
|
1683
|
+
'• Missing dependencies\n' +
|
|
1684
|
+
'• Configuration issues'
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
setTimeout(() => {
|
|
1688
|
+
addInfoMessage(
|
|
1689
|
+
`❌ Request Failed\n\n` +
|
|
1690
|
+
`Error: ${errorMessage}\n\n` +
|
|
1691
|
+
`💡 For simulator mode support, ensure your endpoint:\n` +
|
|
1692
|
+
`• Accepts POST requests with simulator_mode parameter\n` +
|
|
1693
|
+
`• Returns JSON with mode: "simulator" for simulator requests\n` +
|
|
1694
|
+
`• Handles webhook verification (if required by your setup)`
|
|
1695
|
+
)
|
|
1696
|
+
}, 500)
|
|
1697
|
+
|
|
1698
|
+
throw error
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1568
1702
|
// HTTP API request handler
|
|
1569
1703
|
async function makeHTTPRequest(userInput = null) {
|
|
1570
1704
|
const config = state.currentConfig
|
|
@@ -1640,15 +1774,45 @@
|
|
|
1640
1774
|
updateCharCount()
|
|
1641
1775
|
}
|
|
1642
1776
|
|
|
1777
|
+
// Renders a Messenger/Instagram [type, content, options] tuple as a chat
|
|
1778
|
+
// bubble, with quick replies and carousel buttons tappable the same way
|
|
1779
|
+
// a WhatsApp interactive button is.
|
|
1780
|
+
function displayMessagingPlatformResponse(messagePayload) {
|
|
1781
|
+
const [type, content, options] = messagePayload
|
|
1782
|
+
let interactive = null
|
|
1783
|
+
|
|
1784
|
+
if (type === 'quick_replies' && options && options.quick_replies) {
|
|
1785
|
+
interactive = {
|
|
1786
|
+
buttons: options.quick_replies.map(reply => ({id: reply.payload, title: reply.title}))
|
|
1787
|
+
}
|
|
1788
|
+
} else if (type === 'carousel' && options && options.elements) {
|
|
1789
|
+
interactive = {
|
|
1790
|
+
buttons: options.elements.flatMap(element =>
|
|
1791
|
+
(element.buttons || []).map(button => ({id: button.payload, title: button.title}))
|
|
1792
|
+
)
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
addMessage(content, false, type, interactive, null)
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1643
1799
|
function displaySimulatorResponse(simulatorData) {
|
|
1644
1800
|
const messagePayload = simulatorData.would_send
|
|
1801
|
+
|
|
1802
|
+
// Messenger and Instagram render [type, content, options], not the
|
|
1803
|
+
// WhatsApp Cloud API envelope this function otherwise expects.
|
|
1804
|
+
if (Array.isArray(messagePayload)) {
|
|
1805
|
+
displayMessagingPlatformResponse(messagePayload)
|
|
1806
|
+
return
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1645
1809
|
const messageInfo = simulatorData.message_info
|
|
1646
|
-
|
|
1810
|
+
|
|
1647
1811
|
// Extract message content based on type
|
|
1648
1812
|
let messageText = ''
|
|
1649
1813
|
let interactive = null
|
|
1650
1814
|
let mediaContent = null
|
|
1651
|
-
|
|
1815
|
+
|
|
1652
1816
|
switch (messagePayload.type) {
|
|
1653
1817
|
case 'text':
|
|
1654
1818
|
messageText = messagePayload.text.body
|