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
|
@@ -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
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "json"
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module FlowChat
|
|
6
|
+
module Telegram
|
|
7
|
+
class Client
|
|
8
|
+
include FlowChat::Instrumentation
|
|
9
|
+
|
|
10
|
+
def initialize(config)
|
|
11
|
+
@config = config
|
|
12
|
+
FlowChat.logger.info { "Telegram::Client: Initialized Telegram client" }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Main send_message method matching FlowChat pattern
|
|
16
|
+
def send_message(chat_id, prompt, choices: nil, media: nil)
|
|
17
|
+
FlowChat.logger.info { "Telegram::Client: Sending message to chat #{chat_id}" }
|
|
18
|
+
|
|
19
|
+
response = FlowChat::Telegram::Renderer.new(prompt, choices: choices, media: media).render
|
|
20
|
+
type, content, options = response
|
|
21
|
+
|
|
22
|
+
case type
|
|
23
|
+
when :text
|
|
24
|
+
send_text(chat_id, content)
|
|
25
|
+
when :inline_keyboard
|
|
26
|
+
send_text_with_keyboard(chat_id, content, options[:keyboard])
|
|
27
|
+
when :photo
|
|
28
|
+
send_photo(chat_id, options[:url], caption: content)
|
|
29
|
+
when :photo_with_keyboard
|
|
30
|
+
send_photo_with_keyboard(chat_id, options[:url], caption: content, keyboard: options[:keyboard])
|
|
31
|
+
when :document
|
|
32
|
+
send_document(chat_id, options[:url], caption: content)
|
|
33
|
+
when :video
|
|
34
|
+
send_video(chat_id, options[:url], caption: content)
|
|
35
|
+
when :audio
|
|
36
|
+
send_audio(chat_id, options[:url], caption: content)
|
|
37
|
+
when :voice
|
|
38
|
+
send_voice(chat_id, options[:url])
|
|
39
|
+
else
|
|
40
|
+
send_text(chat_id, content.to_s)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def send_text(chat_id, text, parse_mode: "HTML")
|
|
45
|
+
api_request("sendMessage", {
|
|
46
|
+
chat_id: chat_id,
|
|
47
|
+
text: text,
|
|
48
|
+
parse_mode: parse_mode
|
|
49
|
+
}.compact)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def send_text_with_keyboard(chat_id, text, keyboard, parse_mode: "HTML")
|
|
53
|
+
api_request("sendMessage", {
|
|
54
|
+
chat_id: chat_id,
|
|
55
|
+
text: text,
|
|
56
|
+
parse_mode: parse_mode,
|
|
57
|
+
reply_markup: {inline_keyboard: keyboard}
|
|
58
|
+
}.compact)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def send_photo(chat_id, photo_url_or_id, caption: nil)
|
|
62
|
+
api_request("sendPhoto", {
|
|
63
|
+
chat_id: chat_id,
|
|
64
|
+
photo: photo_url_or_id,
|
|
65
|
+
caption: caption
|
|
66
|
+
}.compact)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def send_photo_with_keyboard(chat_id, photo_url_or_id, caption: nil, keyboard: nil)
|
|
70
|
+
api_request("sendPhoto", {
|
|
71
|
+
chat_id: chat_id,
|
|
72
|
+
photo: photo_url_or_id,
|
|
73
|
+
caption: caption,
|
|
74
|
+
reply_markup: keyboard ? {inline_keyboard: keyboard} : nil
|
|
75
|
+
}.compact)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def send_document(chat_id, document_url_or_id, caption: nil)
|
|
79
|
+
api_request("sendDocument", {
|
|
80
|
+
chat_id: chat_id,
|
|
81
|
+
document: document_url_or_id,
|
|
82
|
+
caption: caption
|
|
83
|
+
}.compact)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def send_video(chat_id, video_url_or_id, caption: nil)
|
|
87
|
+
api_request("sendVideo", {
|
|
88
|
+
chat_id: chat_id,
|
|
89
|
+
video: video_url_or_id,
|
|
90
|
+
caption: caption
|
|
91
|
+
}.compact)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def send_audio(chat_id, audio_url_or_id, caption: nil)
|
|
95
|
+
api_request("sendAudio", {
|
|
96
|
+
chat_id: chat_id,
|
|
97
|
+
audio: audio_url_or_id,
|
|
98
|
+
caption: caption
|
|
99
|
+
}.compact)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def send_voice(chat_id, voice_url_or_id)
|
|
103
|
+
api_request("sendVoice", {
|
|
104
|
+
chat_id: chat_id,
|
|
105
|
+
voice: voice_url_or_id
|
|
106
|
+
})
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def answer_callback_query(callback_query_id, text: nil, show_alert: false)
|
|
110
|
+
api_request("answerCallbackQuery", {
|
|
111
|
+
callback_query_id: callback_query_id,
|
|
112
|
+
text: text,
|
|
113
|
+
show_alert: show_alert
|
|
114
|
+
}.compact)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def edit_message_text(chat_id, message_id, text, keyboard: nil, parse_mode: "HTML")
|
|
118
|
+
api_request("editMessageText", {
|
|
119
|
+
chat_id: chat_id,
|
|
120
|
+
message_id: message_id,
|
|
121
|
+
text: text,
|
|
122
|
+
parse_mode: parse_mode,
|
|
123
|
+
reply_markup: keyboard ? {inline_keyboard: keyboard} : nil
|
|
124
|
+
}.compact)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def delete_message(chat_id, message_id)
|
|
128
|
+
api_request("deleteMessage", {
|
|
129
|
+
chat_id: chat_id,
|
|
130
|
+
message_id: message_id
|
|
131
|
+
})
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Send a chat action (e.g. typing indicator) to a Telegram chat.
|
|
135
|
+
#
|
|
136
|
+
# The action lasts ~5 seconds or until the next outbound message.
|
|
137
|
+
# Valid actions per Telegram Bot API: "typing", "upload_photo",
|
|
138
|
+
# "record_video", "upload_video", "record_voice", "upload_voice",
|
|
139
|
+
# "upload_document", "choose_sticker", "find_location",
|
|
140
|
+
# "record_video_note", "upload_video_note".
|
|
141
|
+
#
|
|
142
|
+
# @param chat_id [Integer, String] the target chat id
|
|
143
|
+
# @param action [String] the chat action to broadcast (default: "typing")
|
|
144
|
+
# @return [Hash] parsed Telegram API response
|
|
145
|
+
def send_chat_action(chat_id, action: "typing")
|
|
146
|
+
api_request("sendChatAction", chat_id: chat_id, action: action)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Show a typing indicator in a Telegram chat.
|
|
150
|
+
#
|
|
151
|
+
# Convenience wrapper around `send_chat_action(chat_id, action: "typing")`.
|
|
152
|
+
# The indicator lasts ~5 seconds or until the next outbound message;
|
|
153
|
+
# there is no stop-typing call.
|
|
154
|
+
#
|
|
155
|
+
# @param chat_id [Integer, String] the target chat id
|
|
156
|
+
# @return [Hash] parsed Telegram API response
|
|
157
|
+
def indicate_typing(chat_id)
|
|
158
|
+
send_chat_action(chat_id, action: "typing")
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Webhook management
|
|
162
|
+
def set_webhook(url, secret_token: nil, allowed_updates: nil)
|
|
163
|
+
api_request("setWebhook", {
|
|
164
|
+
url: url,
|
|
165
|
+
secret_token: secret_token,
|
|
166
|
+
allowed_updates: allowed_updates || ["message", "callback_query"]
|
|
167
|
+
}.compact)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def delete_webhook
|
|
171
|
+
api_request("deleteWebhook")
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def get_webhook_info
|
|
175
|
+
api_request("getWebhookInfo")
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def get_me
|
|
179
|
+
api_request("getMe")
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Get file metadata (including file_path) for an inbound file_id
|
|
183
|
+
def get_file(file_id)
|
|
184
|
+
api_request("getFile", {file_id: file_id})
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Build the download URL for an inbound file_id
|
|
188
|
+
def file_url(file_id)
|
|
189
|
+
# get_file answers nil when the API refused, so this cannot assume a
|
|
190
|
+
# hash back the way it did while every request returned its envelope.
|
|
191
|
+
file_path = get_file(file_id)&.dig("result", "file_path")
|
|
192
|
+
return nil unless file_path
|
|
193
|
+
|
|
194
|
+
"https://api.telegram.org/file/bot#{@config.bot_token}/#{file_path}"
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Download the raw bytes for an inbound file_id
|
|
198
|
+
def download_file(file_id)
|
|
199
|
+
url = file_url(file_id)
|
|
200
|
+
return nil unless url
|
|
201
|
+
|
|
202
|
+
uri = URI(url)
|
|
203
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
204
|
+
http.use_ssl = true
|
|
205
|
+
response = http.get(uri.request_uri)
|
|
206
|
+
|
|
207
|
+
if response.is_a?(Net::HTTPSuccess)
|
|
208
|
+
response.body
|
|
209
|
+
else
|
|
210
|
+
FlowChat.logger.error { "Telegram::Client: File download error: #{response.code}" }
|
|
211
|
+
nil
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
private
|
|
216
|
+
|
|
217
|
+
def api_request(method, params = {})
|
|
218
|
+
uri = URI("#{@config.api_base_url}/#{method}")
|
|
219
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
220
|
+
http.use_ssl = true
|
|
221
|
+
|
|
222
|
+
request = Net::HTTP::Post.new(uri)
|
|
223
|
+
request["Content-Type"] = "application/json"
|
|
224
|
+
request.body = params.to_json
|
|
225
|
+
|
|
226
|
+
FlowChat.logger.debug { "Telegram::Client: API request to #{method}" }
|
|
227
|
+
|
|
228
|
+
response = http.request(request)
|
|
229
|
+
result = JSON.parse(response.body)
|
|
230
|
+
|
|
231
|
+
unless result["ok"]
|
|
232
|
+
FlowChat.logger.error { "Telegram::Client: API error - #{result["description"]}" }
|
|
233
|
+
report_api_error(
|
|
234
|
+
"Telegram API error: #{result["description"]}",
|
|
235
|
+
api_method: method,
|
|
236
|
+
error_code: result["error_code"],
|
|
237
|
+
error_description: result["description"],
|
|
238
|
+
chat_id: params[:chat_id]
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
# nil on a refused send, the contract every other client here keeps.
|
|
242
|
+
# Answering with the parsed error envelope instead made the failure
|
|
243
|
+
# indistinguishable from a success to anything upstream:
|
|
244
|
+
# report_delivery_failure tests the result for nil, so a Telegram
|
|
245
|
+
# send that Meta refused was reported as delivered and could never
|
|
246
|
+
# reach on_delivery_failure.
|
|
247
|
+
return nil
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
FlowChat.logger.debug { "Telegram::Client: API request successful" }
|
|
251
|
+
result
|
|
252
|
+
rescue Net::OpenTimeout, Net::ReadTimeout => network_error
|
|
253
|
+
FlowChat.logger.error { "Telegram::Client: Network timeout: #{network_error.class.name}: #{network_error.message}" }
|
|
254
|
+
raise network_error
|
|
255
|
+
rescue => error
|
|
256
|
+
FlowChat.logger.error { "Telegram::Client: API request exception: #{error.class.name}: #{error.message}" }
|
|
257
|
+
report_api_error(
|
|
258
|
+
"Telegram API request exception: #{error.class.name}",
|
|
259
|
+
api_method: method,
|
|
260
|
+
error: error,
|
|
261
|
+
chat_id: params[:chat_id]
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
# nil for the same reason a refused send answers nil above: an
|
|
265
|
+
# envelope here reads as a delivery to everything upstream.
|
|
266
|
+
nil
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def report_api_error(message, api_method: nil, error_code: nil, error_description: nil, error: nil, chat_id: nil)
|
|
270
|
+
FlowChat::Instrumentation.report_api_error(
|
|
271
|
+
message,
|
|
272
|
+
error: error,
|
|
273
|
+
platform: :telegram,
|
|
274
|
+
bot_id: @config.bot_id,
|
|
275
|
+
api_method: api_method,
|
|
276
|
+
error_code: error_code,
|
|
277
|
+
error_description: error_description,
|
|
278
|
+
chat_id: chat_id
|
|
279
|
+
)
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
module FlowChat
|
|
2
|
+
module Telegram
|
|
3
|
+
class Configuration
|
|
4
|
+
include FlowChat::NamedConfiguration
|
|
5
|
+
|
|
6
|
+
attr_accessor :bot_token, :secret_token, :name, :skip_signature_validation
|
|
7
|
+
|
|
8
|
+
def initialize(name)
|
|
9
|
+
@name = name
|
|
10
|
+
@bot_token = nil
|
|
11
|
+
@secret_token = nil
|
|
12
|
+
@skip_signature_validation = false
|
|
13
|
+
|
|
14
|
+
FlowChat.logger.debug { "Telegram::Configuration: Initialized configuration with name: #{name || "anonymous"}" }
|
|
15
|
+
|
|
16
|
+
register_as(name) if name.present?
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.from_credentials
|
|
20
|
+
FlowChat.logger.info { "Telegram::Configuration: Loading configuration from credentials/environment" }
|
|
21
|
+
|
|
22
|
+
config = new(nil)
|
|
23
|
+
|
|
24
|
+
if defined?(Rails) && Rails.respond_to?(:application) && Rails.application.credentials.telegram
|
|
25
|
+
FlowChat.logger.debug { "Telegram::Configuration: Loading from Rails credentials" }
|
|
26
|
+
credentials = Rails.application.credentials.telegram
|
|
27
|
+
config.bot_token = credentials[:bot_token]
|
|
28
|
+
config.secret_token = credentials[:secret_token]
|
|
29
|
+
config.skip_signature_validation = credentials[:skip_signature_validation] || false
|
|
30
|
+
else
|
|
31
|
+
FlowChat.logger.debug { "Telegram::Configuration: Loading from environment variables" }
|
|
32
|
+
config.bot_token = ENV["TELEGRAM_BOT_TOKEN"]
|
|
33
|
+
config.secret_token = ENV["TELEGRAM_SECRET_TOKEN"]
|
|
34
|
+
config.skip_signature_validation = ENV["TELEGRAM_SKIP_SIGNATURE_VALIDATION"] == "true"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
if config.valid?
|
|
38
|
+
FlowChat.logger.info { "Telegram::Configuration: Configuration loaded successfully" }
|
|
39
|
+
else
|
|
40
|
+
FlowChat.logger.warn { "Telegram::Configuration: Incomplete configuration loaded - missing required fields" }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
config
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def valid?
|
|
47
|
+
is_valid = !!(bot_token && !bot_token.to_s.empty?)
|
|
48
|
+
FlowChat.logger.debug { "Telegram::Configuration: Configuration valid: #{is_valid}" }
|
|
49
|
+
is_valid
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def api_base_url
|
|
53
|
+
return nil unless bot_token
|
|
54
|
+
"https://api.telegram.org/bot#{bot_token}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def bot_id
|
|
58
|
+
bot_token&.split(":")&.first
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def send_message_url
|
|
62
|
+
"#{api_base_url}/sendMessage"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def set_webhook_url
|
|
66
|
+
"#{api_base_url}/setWebhook"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def get_webhook_info_url
|
|
70
|
+
"#{api_base_url}/getWebhookInfo"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def delete_webhook_url
|
|
74
|
+
"#{api_base_url}/deleteWebhook"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|