flow_chat 0.8.1 → 0.8.2
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/docs/configuration.md +2 -2
- data/docs/http-gateway-protocol.md +432 -0
- data/docs/sessions.md +7 -7
- data/docs/ussd-setup.md +1 -1
- data/examples/http_controller.rb +154 -0
- data/examples/simulator_controller.rb +21 -1
- data/examples/ussd_controller.rb +1 -1
- data/lib/flow_chat/base_app.rb +13 -1
- data/lib/flow_chat/base_executor.rb +1 -1
- data/lib/flow_chat/base_processor.rb +7 -6
- data/lib/flow_chat/config.rb +17 -2
- data/lib/flow_chat/http/app.rb +6 -0
- data/lib/flow_chat/http/gateway/simple.rb +77 -0
- data/lib/flow_chat/http/middleware/executor.rb +24 -0
- data/lib/flow_chat/http/processor.rb +33 -0
- data/lib/flow_chat/http/renderer.rb +41 -0
- data/lib/flow_chat/instrumentation.rb +2 -0
- data/lib/flow_chat/phone_number_util.rb +47 -0
- data/lib/flow_chat/session/cache_session_store.rb +1 -17
- data/lib/flow_chat/session/middleware.rb +19 -18
- data/lib/flow_chat/simulator/controller.rb +17 -5
- data/lib/flow_chat/simulator/views/simulator.html.erb +220 -8
- data/lib/flow_chat/ussd/gateway/nalo.rb +3 -7
- data/lib/flow_chat/ussd/gateway/nsano.rb +0 -2
- data/lib/flow_chat/version.rb +1 -1
- data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +16 -14
- metadata +10 -2
@@ -314,6 +314,15 @@
|
|
314
314
|
height: 100%;
|
315
315
|
}
|
316
316
|
|
317
|
+
.http-screen {
|
318
|
+
background: linear-gradient(180deg, #f8f9fa 0%, #e9ecef 100%);
|
319
|
+
flex: 1;
|
320
|
+
display: flex;
|
321
|
+
flex-direction: column;
|
322
|
+
min-height: 0;
|
323
|
+
height: 100%;
|
324
|
+
}
|
325
|
+
|
317
326
|
.whatsapp-header {
|
318
327
|
background: #075e54;
|
319
328
|
color: white;
|
@@ -324,6 +333,16 @@
|
|
324
333
|
flex-shrink: 0;
|
325
334
|
}
|
326
335
|
|
336
|
+
.http-header {
|
337
|
+
background: #0066cc;
|
338
|
+
color: white;
|
339
|
+
padding: 15px 20px;
|
340
|
+
display: flex;
|
341
|
+
align-items: center;
|
342
|
+
gap: 12px;
|
343
|
+
flex-shrink: 0;
|
344
|
+
}
|
345
|
+
|
327
346
|
.contact-avatar {
|
328
347
|
width: 40px;
|
329
348
|
height: 40px;
|
@@ -931,6 +950,20 @@
|
|
931
950
|
<!-- Messages will be displayed here -->
|
932
951
|
</div>
|
933
952
|
</div>
|
953
|
+
|
954
|
+
<!-- HTTP Screen -->
|
955
|
+
<div id="http-screen" class="http-screen hidden">
|
956
|
+
<div class="http-header">
|
957
|
+
<div class="config-icon" style="background: #0066cc;">🌐</div>
|
958
|
+
<div class="contact-info">
|
959
|
+
<h4>HTTP API</h4>
|
960
|
+
<p>JSON Request/Response</p>
|
961
|
+
</div>
|
962
|
+
</div>
|
963
|
+
<div class="messages-area" id="http-messages-area">
|
964
|
+
<!-- HTTP messages will be displayed here -->
|
965
|
+
</div>
|
966
|
+
</div>
|
934
967
|
</div>
|
935
968
|
</div>
|
936
969
|
|
@@ -1000,7 +1033,9 @@
|
|
1000
1033
|
|
1001
1034
|
ussdScreen: document.getElementById('ussd-screen'),
|
1002
1035
|
whatsappScreen: document.getElementById('whatsapp-screen'),
|
1036
|
+
httpScreen: document.getElementById('http-screen'),
|
1003
1037
|
messagesArea: document.getElementById('messages-area'),
|
1038
|
+
httpMessagesArea: document.getElementById('http-messages-area'),
|
1004
1039
|
contactAvatar: document.getElementById('contact-avatar'),
|
1005
1040
|
headerContactName: document.getElementById('header-contact-name'),
|
1006
1041
|
|
@@ -1091,17 +1126,24 @@
|
|
1091
1126
|
function updateUI() {
|
1092
1127
|
if (!state.currentConfig) return
|
1093
1128
|
|
1094
|
-
const
|
1129
|
+
const processorType = state.currentConfig.processor_type
|
1130
|
+
const isWhatsApp = processorType === 'whatsapp'
|
1131
|
+
const isHttp = processorType === 'http'
|
1095
1132
|
|
1096
1133
|
// Show/hide platform-specific elements
|
1097
1134
|
elements.contactNameGroup.style.display = isWhatsApp ? 'block' : 'none'
|
1098
|
-
elements.ussdScreen.classList.toggle('hidden', isWhatsApp)
|
1135
|
+
elements.ussdScreen.classList.toggle('hidden', isWhatsApp || isHttp)
|
1099
1136
|
elements.whatsappScreen.classList.toggle('hidden', !isWhatsApp)
|
1137
|
+
elements.httpScreen.classList.toggle('hidden', !isHttp)
|
1100
1138
|
|
1101
1139
|
// Update input placeholder
|
1102
|
-
|
1103
|
-
|
1104
|
-
'
|
1140
|
+
let placeholder = 'Enter USSD input...'
|
1141
|
+
if (isWhatsApp) {
|
1142
|
+
placeholder = 'Type your WhatsApp message...'
|
1143
|
+
} else if (isHttp) {
|
1144
|
+
placeholder = 'Type your HTTP message...'
|
1145
|
+
}
|
1146
|
+
elements.messageInput.placeholder = placeholder
|
1105
1147
|
|
1106
1148
|
// Update button states
|
1107
1149
|
const canStart = state.currentConfig && !state.isRunning
|
@@ -1144,8 +1186,10 @@
|
|
1144
1186
|
|
1145
1187
|
if (state.currentConfig.processor_type === 'ussd') {
|
1146
1188
|
await makeUSSDRequest()
|
1147
|
-
} else {
|
1189
|
+
} else if (state.currentConfig.processor_type === 'whatsapp') {
|
1148
1190
|
await makeWhatsAppRequest()
|
1191
|
+
} else if (state.currentConfig.processor_type === 'http') {
|
1192
|
+
await makeHTTPRequest()
|
1149
1193
|
}
|
1150
1194
|
|
1151
1195
|
updateStatus('Connected', 'connected')
|
@@ -1164,9 +1208,11 @@
|
|
1164
1208
|
try {
|
1165
1209
|
updateStatus('Sending...', 'connecting')
|
1166
1210
|
|
1167
|
-
// Add outgoing message to
|
1211
|
+
// Add outgoing message to appropriate chat
|
1168
1212
|
if (state.currentConfig.processor_type === 'whatsapp') {
|
1169
1213
|
addMessage(message, true)
|
1214
|
+
} else if (state.currentConfig.processor_type === 'http') {
|
1215
|
+
addHttpMessage(message, true)
|
1170
1216
|
}
|
1171
1217
|
|
1172
1218
|
elements.messageInput.value = ''
|
@@ -1174,8 +1220,10 @@
|
|
1174
1220
|
|
1175
1221
|
if (state.currentConfig.processor_type === 'ussd') {
|
1176
1222
|
await makeUSSDRequest(message)
|
1177
|
-
} else {
|
1223
|
+
} else if (state.currentConfig.processor_type === 'whatsapp') {
|
1178
1224
|
await makeWhatsAppRequest(message)
|
1225
|
+
} else if (state.currentConfig.processor_type === 'http') {
|
1226
|
+
await makeHTTPRequest(message)
|
1179
1227
|
}
|
1180
1228
|
|
1181
1229
|
updateStatus('Connected', 'connected')
|
@@ -1192,6 +1240,7 @@
|
|
1192
1240
|
elements.messageInput.value = ''
|
1193
1241
|
elements.ussdScreen.textContent = ''
|
1194
1242
|
elements.messagesArea.innerHTML = ''
|
1243
|
+
elements.httpMessagesArea.innerHTML = ''
|
1195
1244
|
|
1196
1245
|
updateCharCount()
|
1197
1246
|
updateStatus('Ready', 'disconnected')
|
@@ -1516,6 +1565,75 @@
|
|
1516
1565
|
}
|
1517
1566
|
}
|
1518
1567
|
|
1568
|
+
// HTTP API request handler
|
1569
|
+
async function makeHTTPRequest(userInput = null) {
|
1570
|
+
const config = state.currentConfig
|
1571
|
+
const userId = elements.phoneNumber.value
|
1572
|
+
|
1573
|
+
// Build HTTP request payload
|
1574
|
+
const requestData = {
|
1575
|
+
session_id: state.sessionId,
|
1576
|
+
user_id: userId,
|
1577
|
+
input: userInput || '',
|
1578
|
+
simulator_mode: true
|
1579
|
+
}
|
1580
|
+
|
1581
|
+
try {
|
1582
|
+
const response = await fetch(config.endpoint, {
|
1583
|
+
method: 'POST',
|
1584
|
+
headers: { 'Content-Type': 'application/json' },
|
1585
|
+
body: JSON.stringify(requestData),
|
1586
|
+
credentials: 'include'
|
1587
|
+
})
|
1588
|
+
|
1589
|
+
// Read and parse response
|
1590
|
+
const responseText = await response.text()
|
1591
|
+
let responseData = null
|
1592
|
+
|
1593
|
+
// Try to parse as JSON
|
1594
|
+
if (response.headers.get('content-type')?.includes('application/json')) {
|
1595
|
+
try {
|
1596
|
+
responseData = JSON.parse(responseText)
|
1597
|
+
|
1598
|
+
// Display the HTTP response
|
1599
|
+
displayHTTPResponse(responseData)
|
1600
|
+
addRequestLog('POST', config.endpoint, requestData, responseData, response.status)
|
1601
|
+
|
1602
|
+
} catch (jsonError) {
|
1603
|
+
console.warn('Failed to parse JSON response:', jsonError)
|
1604
|
+
responseData = responseText
|
1605
|
+
displayHTTPResponse({ message: responseText, type: 'text' })
|
1606
|
+
addRequestLog('POST', config.endpoint, requestData, responseData, response.status)
|
1607
|
+
}
|
1608
|
+
} else {
|
1609
|
+
responseData = responseText
|
1610
|
+
displayHTTPResponse({ message: responseText, type: 'text' })
|
1611
|
+
addRequestLog('POST', config.endpoint, requestData, responseData, response.status)
|
1612
|
+
}
|
1613
|
+
|
1614
|
+
if (!response.ok) {
|
1615
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
1616
|
+
}
|
1617
|
+
|
1618
|
+
} catch (error) {
|
1619
|
+
// Log the error
|
1620
|
+
addRequestLog('POST', config.endpoint, requestData, null, 0, error.message)
|
1621
|
+
|
1622
|
+
// Show error in HTTP chat
|
1623
|
+
setTimeout(() => {
|
1624
|
+
addHttpMessage(
|
1625
|
+
`❌ Request Failed: ${error.message}\n\n` +
|
1626
|
+
`💡 Ensure your HTTP endpoint:\n` +
|
1627
|
+
`• Accepts POST requests\n` +
|
1628
|
+
`• Returns JSON with message, type, choices (optional), media (optional)\n` +
|
1629
|
+
`• Handles CORS if cross-origin`
|
1630
|
+
, false, 'error')
|
1631
|
+
}, 500)
|
1632
|
+
|
1633
|
+
throw error
|
1634
|
+
}
|
1635
|
+
}
|
1636
|
+
|
1519
1637
|
// Display Functions
|
1520
1638
|
function displayUSSDResponse(content) {
|
1521
1639
|
elements.ussdScreen.textContent = content
|
@@ -1889,6 +2007,100 @@
|
|
1889
2007
|
elements.messagesArea.scrollTop = elements.messagesArea.scrollHeight
|
1890
2008
|
}
|
1891
2009
|
|
2010
|
+
function displayHTTPResponse(responseData) {
|
2011
|
+
// Handle the HTTP response data
|
2012
|
+
let message = responseData.message || 'Empty response'
|
2013
|
+
let messageType = responseData.type || 'text'
|
2014
|
+
let choices = responseData.choices || null
|
2015
|
+
let media = responseData.media || null
|
2016
|
+
|
2017
|
+
// Log choices for debugging if needed
|
2018
|
+
if (choices && console.log) {
|
2019
|
+
console.log('HTTP Response choices:', choices)
|
2020
|
+
}
|
2021
|
+
|
2022
|
+
// Add incoming HTTP message
|
2023
|
+
addHttpMessage(message, false, messageType, choices, media)
|
2024
|
+
|
2025
|
+
// Update session state based on response type
|
2026
|
+
state.isRunning = (messageType === 'prompt' || messageType === 'text')
|
2027
|
+
}
|
2028
|
+
|
2029
|
+
function addHttpMessage(content, isOutgoing = false, type = 'text', choices = null, media = null) {
|
2030
|
+
const messageDiv = document.createElement('div')
|
2031
|
+
messageDiv.className = `message ${isOutgoing ? 'outgoing' : 'incoming'}`
|
2032
|
+
|
2033
|
+
const bubbleDiv = document.createElement('div')
|
2034
|
+
bubbleDiv.className = 'message-bubble'
|
2035
|
+
|
2036
|
+
// Style differently for different message types
|
2037
|
+
if (type === 'error') {
|
2038
|
+
bubbleDiv.style.background = '#ffebee'
|
2039
|
+
bubbleDiv.style.borderLeft = '4px solid #f44336'
|
2040
|
+
bubbleDiv.style.whiteSpace = 'pre-line'
|
2041
|
+
} else if (type === 'terminal') {
|
2042
|
+
bubbleDiv.style.background = '#f3e5f5'
|
2043
|
+
bubbleDiv.style.borderLeft = '4px solid #9c27b0'
|
2044
|
+
}
|
2045
|
+
|
2046
|
+
// Handle media content if present
|
2047
|
+
if (media && !isOutgoing) {
|
2048
|
+
const mediaContainer = document.createElement('div')
|
2049
|
+
mediaContainer.className = 'media-container'
|
2050
|
+
mediaContainer.style.marginBottom = content ? '8px' : '0'
|
2051
|
+
|
2052
|
+
if (media.type === 'image' && media.url) {
|
2053
|
+
const img = document.createElement('img')
|
2054
|
+
img.src = media.url
|
2055
|
+
img.style.maxWidth = '100%'
|
2056
|
+
img.style.height = 'auto'
|
2057
|
+
img.style.borderRadius = '8px'
|
2058
|
+
img.style.display = 'block'
|
2059
|
+
img.alt = media.caption || 'Image'
|
2060
|
+
mediaContainer.appendChild(img)
|
2061
|
+
}
|
2062
|
+
|
2063
|
+
bubbleDiv.appendChild(mediaContainer)
|
2064
|
+
}
|
2065
|
+
|
2066
|
+
// Add text content
|
2067
|
+
if (content) {
|
2068
|
+
const textDiv = document.createElement('div')
|
2069
|
+
textDiv.textContent = content
|
2070
|
+
bubbleDiv.appendChild(textDiv)
|
2071
|
+
}
|
2072
|
+
|
2073
|
+
messageDiv.appendChild(bubbleDiv)
|
2074
|
+
|
2075
|
+
// Add choice buttons for incoming messages
|
2076
|
+
if (!isOutgoing && choices && Object.keys(choices).length > 0) {
|
2077
|
+
const buttonsDiv = document.createElement('div')
|
2078
|
+
buttonsDiv.className = 'interactive-buttons'
|
2079
|
+
|
2080
|
+
Object.entries(choices).forEach(([key, value]) => {
|
2081
|
+
const btn = document.createElement('div')
|
2082
|
+
btn.className = 'interactive-button'
|
2083
|
+
|
2084
|
+
const choiceKey = value.key || key
|
2085
|
+
const displayValue = value.value || 'Option'
|
2086
|
+
|
2087
|
+
btn.textContent = displayValue
|
2088
|
+
btn.onclick = () => selectHttpOption(choiceKey)
|
2089
|
+
buttonsDiv.appendChild(btn)
|
2090
|
+
})
|
2091
|
+
|
2092
|
+
bubbleDiv.appendChild(buttonsDiv)
|
2093
|
+
}
|
2094
|
+
|
2095
|
+
elements.httpMessagesArea.appendChild(messageDiv)
|
2096
|
+
elements.httpMessagesArea.scrollTop = elements.httpMessagesArea.scrollHeight
|
2097
|
+
}
|
2098
|
+
|
2099
|
+
function selectHttpOption(optionKey) {
|
2100
|
+
elements.messageInput.value = optionKey
|
2101
|
+
sendMessage()
|
2102
|
+
}
|
2103
|
+
|
1892
2104
|
function getDocumentIcon(filename) {
|
1893
2105
|
if (!filename) return '📄'
|
1894
2106
|
|
@@ -1,5 +1,3 @@
|
|
1
|
-
require "phonelib"
|
2
|
-
|
3
1
|
module FlowChat
|
4
2
|
module Ussd
|
5
3
|
module Gateway
|
@@ -17,23 +15,21 @@ module FlowChat
|
|
17
15
|
params = context.controller.request.params
|
18
16
|
|
19
17
|
context["request.id"] = params["USERID"]
|
18
|
+
context["request.msisdn"] = FlowChat::PhoneNumberUtil.to_e164(params["MSISDN"])
|
19
|
+
context["request.user_id"] = context["request.msisdn"]
|
20
20
|
context["request.message_id"] = SecureRandom.uuid
|
21
21
|
context["request.timestamp"] = Time.current.iso8601
|
22
22
|
context["request.gateway"] = :nalo
|
23
23
|
context["request.platform"] = :ussd
|
24
24
|
context["request.network"] = nil
|
25
|
-
context["request.msisdn"] = Phonelib.parse(params["MSISDN"]).e164
|
26
25
|
# context["request.type"] = params["MSGTYPE"] ? :initial : :response
|
27
26
|
context.input = params["USERDATA"].presence
|
28
27
|
|
29
28
|
# Instrument message received when user provides input using new scalable approach
|
30
29
|
if context.input.present?
|
31
30
|
instrument(Events::MESSAGE_RECEIVED, {
|
32
|
-
from: context["request.
|
31
|
+
from: context["request.user_id"],
|
33
32
|
message: context.input,
|
34
|
-
session_id: context["request.id"],
|
35
|
-
gateway: :nalo,
|
36
|
-
platform: :ussd,
|
37
33
|
timestamp: context["request.timestamp"]
|
38
34
|
})
|
39
35
|
end
|
data/lib/flow_chat/version.rb
CHANGED
@@ -1,6 +1,5 @@
|
|
1
1
|
require "net/http"
|
2
2
|
require "json"
|
3
|
-
require "phonelib"
|
4
3
|
require "openssl"
|
5
4
|
|
6
5
|
module FlowChat
|
@@ -145,29 +144,32 @@ module FlowChat
|
|
145
144
|
message = value["messages"].first
|
146
145
|
contact = value["contacts"]&.first
|
147
146
|
|
148
|
-
phone_number = message["from"]
|
147
|
+
phone_number = FlowChat::PhoneNumberUtil.to_e164(message["from"])
|
149
148
|
message_id = message["id"]
|
150
149
|
contact_name = contact&.dig("profile", "name")
|
151
150
|
|
152
|
-
# Use instrumentation for message received
|
153
|
-
instrument(Events::MESSAGE_RECEIVED, {
|
154
|
-
from: phone_number,
|
155
|
-
message: context.input,
|
156
|
-
message_type: message["type"],
|
157
|
-
message_id: message_id,
|
158
|
-
platform: :whatsapp
|
159
|
-
})
|
160
|
-
|
161
151
|
context["request.id"] = phone_number
|
152
|
+
context["request.msisdn"] = phone_number
|
153
|
+
context["request.user_id"] = context["request.msisdn"]
|
162
154
|
context["request.gateway"] = :whatsapp_cloud_api
|
163
155
|
context["request.platform"] = :whatsapp
|
164
156
|
context["request.message_id"] = message_id
|
165
|
-
context["request.msisdn"] = Phonelib.parse(phone_number).e164
|
166
157
|
context["request.contact_name"] = contact_name
|
167
158
|
context["request.timestamp"] = message["timestamp"]
|
168
159
|
|
169
160
|
# Extract message content based on type
|
170
|
-
extract_message_content(message, context)
|
161
|
+
extract_message_content!(message, context)
|
162
|
+
|
163
|
+
if context.input.present?
|
164
|
+
# Use instrumentation for message received
|
165
|
+
instrument(Events::MESSAGE_RECEIVED, {
|
166
|
+
from: phone_number,
|
167
|
+
message: context.input,
|
168
|
+
message_type: message["type"],
|
169
|
+
message_id: message_id,
|
170
|
+
})
|
171
|
+
end
|
172
|
+
|
171
173
|
|
172
174
|
FlowChat.logger.debug { "CloudApi: Message content extracted - Type: #{message["type"]}, Input: '#{context.input}'" }
|
173
175
|
|
@@ -260,7 +262,7 @@ module FlowChat
|
|
260
262
|
res == 0
|
261
263
|
end
|
262
264
|
|
263
|
-
def extract_message_content(message, context)
|
265
|
+
def extract_message_content!(message, context)
|
264
266
|
message_type = message["type"]
|
265
267
|
FlowChat.logger.debug { "CloudApi: Extracting content from #{message_type} message" }
|
266
268
|
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: flow_chat
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.8.
|
4
|
+
version: 0.8.2
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Stefan Froelich
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2025-06-
|
11
|
+
date: 2025-06-28 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: zeitwerk
|
@@ -104,6 +104,7 @@ files:
|
|
104
104
|
- bin/setup
|
105
105
|
- docs/configuration.md
|
106
106
|
- docs/flows.md
|
107
|
+
- docs/http-gateway-protocol.md
|
107
108
|
- docs/images/simulator.png
|
108
109
|
- docs/instrumentation.md
|
109
110
|
- docs/media.md
|
@@ -111,6 +112,7 @@ files:
|
|
111
112
|
- docs/testing.md
|
112
113
|
- docs/ussd-setup.md
|
113
114
|
- docs/whatsapp-setup.md
|
115
|
+
- examples/http_controller.rb
|
114
116
|
- examples/multi_tenant_whatsapp_controller.rb
|
115
117
|
- examples/simulator_controller.rb
|
116
118
|
- examples/ussd_controller.rb
|
@@ -125,11 +127,17 @@ files:
|
|
125
127
|
- lib/flow_chat/config.rb
|
126
128
|
- lib/flow_chat/context.rb
|
127
129
|
- lib/flow_chat/flow.rb
|
130
|
+
- lib/flow_chat/http/app.rb
|
131
|
+
- lib/flow_chat/http/gateway/simple.rb
|
132
|
+
- lib/flow_chat/http/middleware/executor.rb
|
133
|
+
- lib/flow_chat/http/processor.rb
|
134
|
+
- lib/flow_chat/http/renderer.rb
|
128
135
|
- lib/flow_chat/instrumentation.rb
|
129
136
|
- lib/flow_chat/instrumentation/log_subscriber.rb
|
130
137
|
- lib/flow_chat/instrumentation/metrics_collector.rb
|
131
138
|
- lib/flow_chat/instrumentation/setup.rb
|
132
139
|
- lib/flow_chat/interrupt.rb
|
140
|
+
- lib/flow_chat/phone_number_util.rb
|
133
141
|
- lib/flow_chat/prompt.rb
|
134
142
|
- lib/flow_chat/session/cache_session_store.rb
|
135
143
|
- lib/flow_chat/session/middleware.rb
|