flow_chat 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/pages.yml +43 -0
  3. data/CHANGELOG.md +26 -0
  4. data/README.md +166 -295
  5. data/Rakefile +12 -1
  6. data/SECURITY.md +1 -1
  7. data/docs/architecture.md +52 -479
  8. data/docs/async-background-processing.md +31 -265
  9. data/docs/configuration.md +106 -613
  10. data/docs/factory-pattern.md +27 -324
  11. data/docs/gateway-context-variables.md +140 -143
  12. data/docs/gateway-development.md +86 -650
  13. data/docs/getting-started.md +40 -379
  14. data/docs/instrumentation.md +88 -279
  15. data/docs/platforms/instagram.md +278 -0
  16. data/docs/platforms/messenger.md +205 -0
  17. data/docs/platforms/telegram.md +47 -951
  18. data/docs/platforms/ussd.md +38 -653
  19. data/docs/platforms/whatsapp.md +73 -1321
  20. data/docs/superpowers/plans/2026-07-09-inbound-media-support.md +732 -0
  21. data/docs/superpowers/plans/2026-07-09-inbound-media-support.md.tasks.json +58 -0
  22. data/docs/superpowers/plans/2026-08-10-messenger-instagram.md +4064 -0
  23. data/docs/superpowers/plans/2026-08-10-messenger-instagram.md.tasks.json +226 -0
  24. data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md +972 -0
  25. data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md.tasks.json +88 -0
  26. data/docs/superpowers/specs/2026-07-09-inbound-media-support-design.md +195 -0
  27. data/docs/superpowers/specs/2026-08-10-messenger-instagram-design.md +391 -0
  28. data/docs/testing.md +26 -297
  29. data/examples/http_controller.rb +12 -10
  30. data/examples/intercom_configuration_example.rb +19 -24
  31. data/examples/intercom_controller.rb +8 -20
  32. data/examples/multi_tenant_whatsapp_controller.rb +61 -166
  33. data/examples/simulator_controller.rb +0 -1
  34. data/examples/ussd_controller.rb +86 -158
  35. data/examples/whatsapp_controller.rb +16 -15
  36. data/examples/whatsapp_media_examples.rb +27 -79
  37. data/lib/flow_chat/app.rb +129 -11
  38. data/lib/flow_chat/choice_titles.rb +95 -0
  39. data/lib/flow_chat/config.rb +124 -1
  40. data/lib/flow_chat/delivery_error.rb +9 -0
  41. data/lib/flow_chat/http/configuration_error.rb +9 -0
  42. data/lib/flow_chat/http/gateway/simple.rb +24 -4
  43. data/lib/flow_chat/http/middleware/choice_mapper.rb +94 -0
  44. data/lib/flow_chat/input.rb +86 -0
  45. data/lib/flow_chat/instagram/client.rb +32 -0
  46. data/lib/flow_chat/instagram/configuration.rb +147 -0
  47. data/lib/flow_chat/instagram/configuration_error.rb +7 -0
  48. data/lib/flow_chat/instagram/gateway/send_api.rb +63 -0
  49. data/lib/flow_chat/instagram/middleware/choice_mapper.rb +22 -0
  50. data/lib/flow_chat/instagram/renderer.rb +23 -0
  51. data/lib/flow_chat/instrumentation/metrics_collector.rb +6 -1
  52. data/lib/flow_chat/instrumentation.rb +160 -1
  53. data/lib/flow_chat/intercom/client.rb +34 -28
  54. data/lib/flow_chat/intercom/configuration.rb +2 -49
  55. data/lib/flow_chat/intercom/configuration_error.rb +9 -0
  56. data/lib/flow_chat/intercom/gateway/intercom_api.rb +81 -57
  57. data/lib/flow_chat/intercom/middleware/choice_mapper.rb +101 -0
  58. data/lib/flow_chat/intercom/renderer.rb +57 -5
  59. data/lib/flow_chat/media.rb +121 -0
  60. data/lib/flow_chat/messenger/client.rb +264 -0
  61. data/lib/flow_chat/messenger/configuration.rb +103 -0
  62. data/lib/flow_chat/messenger/configuration_error.rb +9 -0
  63. data/lib/flow_chat/messenger/gateway/send_api.rb +42 -0
  64. data/lib/flow_chat/messenger/middleware/choice_mapper.rb +185 -0
  65. data/lib/flow_chat/messenger/renderer.rb +150 -0
  66. data/lib/flow_chat/meta/challenge.rb +24 -0
  67. data/lib/flow_chat/meta/choice_ladder.rb +37 -0
  68. data/lib/flow_chat/meta/configuration_error.rb +7 -0
  69. data/lib/flow_chat/meta/gateway_identity.rb +38 -0
  70. data/lib/flow_chat/meta/messaging_gateway.rb +468 -0
  71. data/lib/flow_chat/meta/signature.rb +30 -0
  72. data/lib/flow_chat/meta/signature_validation.rb +66 -0
  73. data/lib/flow_chat/meta/webhook_verification.rb +43 -0
  74. data/lib/flow_chat/named_configuration.rb +65 -0
  75. data/lib/flow_chat/prompt.rb +13 -16
  76. data/lib/flow_chat/renderers/markdown_support.rb +109 -0
  77. data/lib/flow_chat/security.rb +76 -0
  78. data/lib/flow_chat/session/middleware.rb +11 -2
  79. data/lib/flow_chat/simulator/controller.rb +31 -15
  80. data/lib/flow_chat/simulator/views/simulator.html.erb +184 -20
  81. data/lib/flow_chat/telegram/client.rb +47 -4
  82. data/lib/flow_chat/telegram/configuration.rb +2 -42
  83. data/lib/flow_chat/telegram/configuration_error.rb +9 -0
  84. data/lib/flow_chat/telegram/gateway/bot_api.rb +46 -28
  85. data/lib/flow_chat/telegram/middleware/choice_mapper.rb +77 -16
  86. data/lib/flow_chat/telegram/renderer.rb +10 -2
  87. data/lib/flow_chat/text_truncator.rb +75 -0
  88. data/lib/flow_chat/ussd/middleware/choice_mapper.rb +10 -0
  89. data/lib/flow_chat/version.rb +1 -1
  90. data/lib/flow_chat/whatsapp/client.rb +14 -7
  91. data/lib/flow_chat/whatsapp/configuration.rb +12 -51
  92. data/lib/flow_chat/whatsapp/configuration_error.rb +9 -0
  93. data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +254 -203
  94. data/lib/flow_chat/whatsapp/middleware/choice_mapper.rb +144 -57
  95. data/lib/flow_chat/whatsapp/renderer.rb +121 -60
  96. data/lib/flow_chat.rb +0 -10
  97. data/site/.nojekyll +0 -0
  98. data/site/.og-card.html +89 -0
  99. data/site/favicon.svg +6 -0
  100. data/site/index.html +209 -0
  101. data/site/og.png +0 -0
  102. metadata +51 -3
  103. data/lib/flow_chat/whatsapp/id_generator.rb +0 -124
@@ -3,13 +3,15 @@ module FlowChat
3
3
  attr_reader :user_input
4
4
 
5
5
  def initialize(input)
6
- @user_input = input
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.present?
11
- input = user_input
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
- input = transform.call(input) if transform.present?
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
- validate_media_choices_compatibility(media, choices)
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!("&amp;", "&")
155
+ result.gsub!("&lt;", "<")
156
+ result.gsub!("&gt;", ">")
157
+ result.gsub!("&quot;", '"')
158
+ result.gsub!("&#39;", "'")
159
+ result.gsub!("&nbsp;", " ")
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
- # Use SHA256 but only take first 8 characters for reasonable session IDs
165
- Digest::SHA256.hexdigest(identifier.to_s)[0, 8]
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
- # Get global simulator secret
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
- # Generate timestamp-based signed cookie
99
- timestamp = Time.now.to_i
100
- message = "simulator:#{timestamp}"
101
- signature = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), simulator_secret, message)
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 === 'whatsapp') {
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 isWhatsApp = processorType === 'whatsapp'
1138
+ const isChat = isChatPlatform(processorType)
1131
1139
  const isHttp = processorType === 'http'
1132
-
1133
- // Show/hide platform-specific elements
1134
- elements.contactNameGroup.style.display = isWhatsApp ? 'block' : 'none'
1135
- elements.ussdScreen.classList.toggle('hidden', isWhatsApp || isHttp)
1136
- elements.whatsappScreen.classList.toggle('hidden', !isWhatsApp)
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 (isWhatsApp) {
1142
- placeholder = 'Type your WhatsApp message...'
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 === 'whatsapp') {
1190
- await makeWhatsAppRequest()
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 === 'whatsapp') {
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 === 'whatsapp') {
1224
- await makeWhatsAppRequest(message)
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