flow_chat 0.8.1 → 0.9.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 (91) hide show
  1. checksums.yaml +4 -4
  2. data/.cliff.toml +74 -0
  3. data/.github/workflows/ci.yml +2 -3
  4. data/.github/workflows/release.yml +56 -0
  5. data/.standard.yml +4 -0
  6. data/CHANGELOG.md +22 -0
  7. data/CLAUDE.md +327 -0
  8. data/CONTRIBUTING.md +134 -0
  9. data/Gemfile +1 -0
  10. data/README.md +290 -105
  11. data/Rakefile +5 -1
  12. data/SECURITY.md +42 -349
  13. data/docs/architecture.md +510 -0
  14. data/docs/async-background-processing.md +298 -0
  15. data/docs/configuration.md +556 -226
  16. data/docs/factory-pattern.md +355 -0
  17. data/docs/gateway-context-variables.md +171 -0
  18. data/docs/gateway-development.md +723 -0
  19. data/docs/getting-started.md +429 -0
  20. data/docs/instrumentation.md +264 -153
  21. data/docs/platforms/telegram.md +1013 -0
  22. data/docs/platforms/ussd.md +693 -0
  23. data/docs/platforms/whatsapp.md +1395 -0
  24. data/docs/testing.md +243 -365
  25. data/examples/custom_session_id_example.rb +119 -0
  26. data/examples/http_controller.rb +154 -0
  27. data/examples/intercom_configuration_example.rb +118 -0
  28. data/examples/intercom_controller.rb +194 -0
  29. data/examples/multi_tenant_whatsapp_controller.rb +4 -4
  30. data/examples/simulator_controller.rb +21 -1
  31. data/examples/ussd_controller.rb +10 -10
  32. data/examples/whatsapp_controller.rb +2 -2
  33. data/flow_chat.gemspec +4 -0
  34. data/lib/flow_chat/{base_app.rb → app.rb} +28 -9
  35. data/lib/flow_chat/async_job.rb +176 -0
  36. data/lib/flow_chat/config.rb +23 -28
  37. data/lib/flow_chat/{base_executor.rb → executor.rb} +7 -12
  38. data/lib/flow_chat/factory.rb +94 -0
  39. data/lib/flow_chat/gateway_async_support.rb +106 -0
  40. data/lib/flow_chat/generic_async_job.rb +30 -0
  41. data/lib/flow_chat/http/gateway/simple.rb +125 -0
  42. data/lib/flow_chat/http/renderer.rb +41 -0
  43. data/lib/flow_chat/instrumentation/setup.rb +1 -1
  44. data/lib/flow_chat/instrumentation.rb +25 -0
  45. data/lib/flow_chat/intercom/client.rb +155 -0
  46. data/lib/flow_chat/intercom/configuration.rb +149 -0
  47. data/lib/flow_chat/intercom/gateway/intercom_api.rb +396 -0
  48. data/lib/flow_chat/intercom/renderer.rb +71 -0
  49. data/lib/flow_chat/phone_number_util.rb +49 -0
  50. data/lib/flow_chat/processor.rb +188 -0
  51. data/lib/flow_chat/renderers/markdown_support.rb +58 -0
  52. data/lib/flow_chat/session/cache_session_store.rb +1 -17
  53. data/lib/flow_chat/session/middleware.rb +43 -26
  54. data/lib/flow_chat/simulator/controller.rb +17 -5
  55. data/lib/flow_chat/simulator/views/simulator.html.erb +220 -8
  56. data/lib/flow_chat/telegram/client.rb +240 -0
  57. data/lib/flow_chat/telegram/configuration.rb +118 -0
  58. data/lib/flow_chat/telegram/gateway/bot_api.rb +300 -0
  59. data/lib/flow_chat/telegram/middleware/choice_mapper.rb +35 -0
  60. data/lib/flow_chat/telegram/renderer.rb +125 -0
  61. data/lib/flow_chat/telegram.rb +7 -0
  62. data/lib/flow_chat/ussd/gateway/nalo.rb +27 -11
  63. data/lib/flow_chat/ussd/middleware/pagination.rb +9 -5
  64. data/lib/flow_chat/ussd/renderer.rb +1 -1
  65. data/lib/flow_chat/version.rb +1 -1
  66. data/lib/flow_chat/whatsapp/client.rb +144 -13
  67. data/lib/flow_chat/whatsapp/configuration.rb +1 -1
  68. data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +144 -106
  69. data/lib/flow_chat/whatsapp/id_generator.rb +124 -0
  70. data/lib/flow_chat/whatsapp/middleware/choice_mapper.rb +147 -0
  71. data/lib/flow_chat/whatsapp/renderer.rb +145 -11
  72. data/lib/flow_chat.rb +11 -1
  73. data/lib/tasks/release.rake +165 -0
  74. metadata +88 -21
  75. data/docs/flows.md +0 -320
  76. data/docs/images/simulator.png +0 -0
  77. data/docs/media.md +0 -153
  78. data/docs/sessions.md +0 -433
  79. data/docs/ussd-setup.md +0 -322
  80. data/docs/whatsapp-setup.md +0 -162
  81. data/examples/whatsapp_message_job.rb +0 -113
  82. data/lib/flow_chat/base_processor.rb +0 -145
  83. data/lib/flow_chat/session/rails_session_store.rb +0 -68
  84. data/lib/flow_chat/ussd/app.rb +0 -6
  85. data/lib/flow_chat/ussd/gateway/nsano.rb +0 -98
  86. data/lib/flow_chat/ussd/middleware/executor.rb +0 -24
  87. data/lib/flow_chat/ussd/processor.rb +0 -39
  88. data/lib/flow_chat/whatsapp/app.rb +0 -29
  89. data/lib/flow_chat/whatsapp/middleware/executor.rb +0 -24
  90. data/lib/flow_chat/whatsapp/processor.rb +0 -32
  91. data/lib/flow_chat/whatsapp/send_job_support.rb +0 -79
@@ -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 isWhatsApp = state.currentConfig.processor_type === 'whatsapp'
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
- elements.messageInput.placeholder = isWhatsApp ?
1103
- 'Type your WhatsApp message...' :
1104
- 'Enter USSD input...'
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 WhatsApp chat
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
 
@@ -0,0 +1,240 @@
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
+ private
183
+
184
+ def api_request(method, params = {})
185
+ uri = URI("#{@config.api_base_url}/#{method}")
186
+ http = Net::HTTP.new(uri.host, uri.port)
187
+ http.use_ssl = true
188
+
189
+ request = Net::HTTP::Post.new(uri)
190
+ request["Content-Type"] = "application/json"
191
+ request.body = params.to_json
192
+
193
+ FlowChat.logger.debug { "Telegram::Client: API request to #{method}" }
194
+
195
+ response = http.request(request)
196
+ result = JSON.parse(response.body)
197
+
198
+ if result["ok"]
199
+ FlowChat.logger.debug { "Telegram::Client: API request successful" }
200
+ else
201
+ FlowChat.logger.error { "Telegram::Client: API error - #{result["description"]}" }
202
+ report_api_error(
203
+ "Telegram API error: #{result["description"]}",
204
+ api_method: method,
205
+ error_code: result["error_code"],
206
+ error_description: result["description"],
207
+ chat_id: params[:chat_id]
208
+ )
209
+ end
210
+
211
+ result
212
+ rescue Net::OpenTimeout, Net::ReadTimeout => network_error
213
+ FlowChat.logger.error { "Telegram::Client: Network timeout: #{network_error.class.name}: #{network_error.message}" }
214
+ raise network_error
215
+ rescue => error
216
+ FlowChat.logger.error { "Telegram::Client: API request exception: #{error.class.name}: #{error.message}" }
217
+ report_api_error(
218
+ "Telegram API request exception: #{error.class.name}",
219
+ api_method: method,
220
+ error: error,
221
+ chat_id: params[:chat_id]
222
+ )
223
+ {"ok" => false, "description" => error.message}
224
+ end
225
+
226
+ def report_api_error(message, api_method: nil, error_code: nil, error_description: nil, error: nil, chat_id: nil)
227
+ FlowChat::Instrumentation.report_api_error(
228
+ message,
229
+ error: error,
230
+ platform: :telegram,
231
+ bot_id: @config.bot_id,
232
+ api_method: api_method,
233
+ error_code: error_code,
234
+ error_description: error_description,
235
+ chat_id: chat_id
236
+ )
237
+ end
238
+ end
239
+ end
240
+ end
@@ -0,0 +1,118 @@
1
+ module FlowChat
2
+ module Telegram
3
+ class Configuration
4
+ attr_accessor :bot_token, :secret_token, :name, :skip_signature_validation
5
+
6
+ @@configurations = {}
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 self.register(name, config)
47
+ FlowChat.logger.debug { "Telegram::Configuration: Registering configuration '#{name}'" }
48
+ @@configurations[name.to_sym] = config
49
+ end
50
+
51
+ def self.get(name)
52
+ config = @@configurations[name.to_sym]
53
+ if config
54
+ FlowChat.logger.debug { "Telegram::Configuration: Retrieved configuration '#{name}'" }
55
+ config
56
+ else
57
+ FlowChat.logger.error { "Telegram::Configuration: Configuration '#{name}' not found" }
58
+ raise ArgumentError, "Telegram configuration '#{name}' not found"
59
+ end
60
+ end
61
+
62
+ def self.exists?(name)
63
+ exists = @@configurations.key?(name.to_sym)
64
+ FlowChat.logger.debug { "Telegram::Configuration: Configuration '#{name}' exists: #{exists}" }
65
+ exists
66
+ end
67
+
68
+ def self.configuration_names
69
+ names = @@configurations.keys
70
+ FlowChat.logger.debug { "Telegram::Configuration: Available configurations: #{names}" }
71
+ names
72
+ end
73
+
74
+ def self.clear_all!
75
+ FlowChat.logger.debug { "Telegram::Configuration: Clearing all registered configurations" }
76
+ @@configurations.clear
77
+ end
78
+
79
+ def register_as(name)
80
+ FlowChat.logger.debug { "Telegram::Configuration: Registering configuration as '#{name}'" }
81
+ @name = name.to_sym
82
+ self.class.register(@name, self)
83
+ self
84
+ end
85
+
86
+ def valid?
87
+ is_valid = !!(bot_token && !bot_token.to_s.empty?)
88
+ FlowChat.logger.debug { "Telegram::Configuration: Configuration valid: #{is_valid}" }
89
+ is_valid
90
+ end
91
+
92
+ def api_base_url
93
+ return nil unless bot_token
94
+ "https://api.telegram.org/bot#{bot_token}"
95
+ end
96
+
97
+ def bot_id
98
+ bot_token&.split(":")&.first
99
+ end
100
+
101
+ def send_message_url
102
+ "#{api_base_url}/sendMessage"
103
+ end
104
+
105
+ def set_webhook_url
106
+ "#{api_base_url}/setWebhook"
107
+ end
108
+
109
+ def get_webhook_info_url
110
+ "#{api_base_url}/getWebhookInfo"
111
+ end
112
+
113
+ def delete_webhook_url
114
+ "#{api_base_url}/deleteWebhook"
115
+ end
116
+ end
117
+ end
118
+ end