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.
Files changed (147) 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/pages.yml +43 -0
  5. data/.github/workflows/release.yml +56 -0
  6. data/.standard.yml +4 -0
  7. data/CHANGELOG.md +48 -0
  8. data/CLAUDE.md +327 -0
  9. data/CONTRIBUTING.md +134 -0
  10. data/Gemfile +1 -0
  11. data/README.md +189 -133
  12. data/Rakefile +17 -2
  13. data/SECURITY.md +42 -349
  14. data/docs/architecture.md +83 -0
  15. data/docs/async-background-processing.md +64 -0
  16. data/docs/configuration.md +110 -287
  17. data/docs/factory-pattern.md +58 -0
  18. data/docs/gateway-context-variables.md +168 -0
  19. data/docs/gateway-development.md +159 -0
  20. data/docs/getting-started.md +90 -0
  21. data/docs/instrumentation.md +95 -175
  22. data/docs/platforms/instagram.md +278 -0
  23. data/docs/platforms/messenger.md +205 -0
  24. data/docs/platforms/telegram.md +109 -0
  25. data/docs/platforms/ussd.md +78 -0
  26. data/docs/platforms/whatsapp.md +147 -0
  27. data/docs/superpowers/plans/2026-07-09-inbound-media-support.md +732 -0
  28. data/docs/superpowers/plans/2026-07-09-inbound-media-support.md.tasks.json +58 -0
  29. data/docs/superpowers/plans/2026-08-10-messenger-instagram.md +4064 -0
  30. data/docs/superpowers/plans/2026-08-10-messenger-instagram.md.tasks.json +226 -0
  31. data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md +972 -0
  32. data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md.tasks.json +88 -0
  33. data/docs/superpowers/specs/2026-07-09-inbound-media-support-design.md +195 -0
  34. data/docs/superpowers/specs/2026-08-10-messenger-instagram-design.md +391 -0
  35. data/docs/testing.md +33 -426
  36. data/examples/custom_session_id_example.rb +119 -0
  37. data/examples/http_controller.rb +22 -20
  38. data/examples/intercom_configuration_example.rb +113 -0
  39. data/examples/intercom_controller.rb +182 -0
  40. data/examples/multi_tenant_whatsapp_controller.rb +63 -168
  41. data/examples/simulator_controller.rb +0 -1
  42. data/examples/ussd_controller.rb +88 -160
  43. data/examples/whatsapp_controller.rb +18 -17
  44. data/examples/whatsapp_media_examples.rb +27 -79
  45. data/flow_chat.gemspec +4 -0
  46. data/lib/flow_chat/app.rb +211 -0
  47. data/lib/flow_chat/async_job.rb +176 -0
  48. data/lib/flow_chat/choice_titles.rb +95 -0
  49. data/lib/flow_chat/config.rb +126 -23
  50. data/lib/flow_chat/delivery_error.rb +9 -0
  51. data/lib/flow_chat/{base_executor.rb → executor.rb} +6 -11
  52. data/lib/flow_chat/factory.rb +94 -0
  53. data/lib/flow_chat/gateway_async_support.rb +106 -0
  54. data/lib/flow_chat/generic_async_job.rb +30 -0
  55. data/lib/flow_chat/http/configuration_error.rb +9 -0
  56. data/lib/flow_chat/http/gateway/simple.rb +104 -36
  57. data/lib/flow_chat/http/middleware/choice_mapper.rb +94 -0
  58. data/lib/flow_chat/http/renderer.rb +3 -3
  59. data/lib/flow_chat/input.rb +86 -0
  60. data/lib/flow_chat/instagram/client.rb +32 -0
  61. data/lib/flow_chat/instagram/configuration.rb +147 -0
  62. data/lib/flow_chat/instagram/configuration_error.rb +7 -0
  63. data/lib/flow_chat/instagram/gateway/send_api.rb +63 -0
  64. data/lib/flow_chat/instagram/middleware/choice_mapper.rb +22 -0
  65. data/lib/flow_chat/instagram/renderer.rb +23 -0
  66. data/lib/flow_chat/instrumentation/metrics_collector.rb +6 -1
  67. data/lib/flow_chat/instrumentation/setup.rb +1 -1
  68. data/lib/flow_chat/instrumentation.rb +182 -0
  69. data/lib/flow_chat/intercom/client.rb +161 -0
  70. data/lib/flow_chat/intercom/configuration.rb +102 -0
  71. data/lib/flow_chat/intercom/configuration_error.rb +9 -0
  72. data/lib/flow_chat/intercom/gateway/intercom_api.rb +420 -0
  73. data/lib/flow_chat/intercom/middleware/choice_mapper.rb +101 -0
  74. data/lib/flow_chat/intercom/renderer.rb +123 -0
  75. data/lib/flow_chat/media.rb +121 -0
  76. data/lib/flow_chat/messenger/client.rb +264 -0
  77. data/lib/flow_chat/messenger/configuration.rb +103 -0
  78. data/lib/flow_chat/messenger/configuration_error.rb +9 -0
  79. data/lib/flow_chat/messenger/gateway/send_api.rb +42 -0
  80. data/lib/flow_chat/messenger/middleware/choice_mapper.rb +185 -0
  81. data/lib/flow_chat/messenger/renderer.rb +150 -0
  82. data/lib/flow_chat/meta/challenge.rb +24 -0
  83. data/lib/flow_chat/meta/choice_ladder.rb +37 -0
  84. data/lib/flow_chat/meta/configuration_error.rb +7 -0
  85. data/lib/flow_chat/meta/gateway_identity.rb +38 -0
  86. data/lib/flow_chat/meta/messaging_gateway.rb +468 -0
  87. data/lib/flow_chat/meta/signature.rb +30 -0
  88. data/lib/flow_chat/meta/signature_validation.rb +66 -0
  89. data/lib/flow_chat/meta/webhook_verification.rb +43 -0
  90. data/lib/flow_chat/named_configuration.rb +65 -0
  91. data/lib/flow_chat/phone_number_util.rb +37 -35
  92. data/lib/flow_chat/processor.rb +188 -0
  93. data/lib/flow_chat/prompt.rb +13 -16
  94. data/lib/flow_chat/renderers/markdown_support.rb +167 -0
  95. data/lib/flow_chat/security.rb +76 -0
  96. data/lib/flow_chat/session/middleware.rb +36 -11
  97. data/lib/flow_chat/simulator/controller.rb +31 -15
  98. data/lib/flow_chat/simulator/views/simulator.html.erb +184 -20
  99. data/lib/flow_chat/telegram/client.rb +283 -0
  100. data/lib/flow_chat/telegram/configuration.rb +78 -0
  101. data/lib/flow_chat/telegram/configuration_error.rb +9 -0
  102. data/lib/flow_chat/telegram/gateway/bot_api.rb +318 -0
  103. data/lib/flow_chat/telegram/middleware/choice_mapper.rb +96 -0
  104. data/lib/flow_chat/telegram/renderer.rb +133 -0
  105. data/lib/flow_chat/telegram.rb +7 -0
  106. data/lib/flow_chat/text_truncator.rb +75 -0
  107. data/lib/flow_chat/ussd/gateway/nalo.rb +24 -4
  108. data/lib/flow_chat/ussd/middleware/choice_mapper.rb +10 -0
  109. data/lib/flow_chat/ussd/middleware/pagination.rb +9 -5
  110. data/lib/flow_chat/ussd/renderer.rb +1 -1
  111. data/lib/flow_chat/version.rb +1 -1
  112. data/lib/flow_chat/whatsapp/client.rb +158 -20
  113. data/lib/flow_chat/whatsapp/configuration.rb +13 -52
  114. data/lib/flow_chat/whatsapp/configuration_error.rb +9 -0
  115. data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +335 -248
  116. data/lib/flow_chat/whatsapp/middleware/choice_mapper.rb +234 -0
  117. data/lib/flow_chat/whatsapp/renderer.rb +259 -64
  118. data/lib/flow_chat.rb +1 -1
  119. data/lib/tasks/release.rake +165 -0
  120. data/site/.nojekyll +0 -0
  121. data/site/.og-card.html +89 -0
  122. data/site/favicon.svg +6 -0
  123. data/site/index.html +209 -0
  124. data/site/og.png +0 -0
  125. metadata +132 -25
  126. data/docs/flows.md +0 -320
  127. data/docs/http-gateway-protocol.md +0 -432
  128. data/docs/images/simulator.png +0 -0
  129. data/docs/media.md +0 -153
  130. data/docs/sessions.md +0 -433
  131. data/docs/ussd-setup.md +0 -322
  132. data/docs/whatsapp-setup.md +0 -162
  133. data/examples/whatsapp_message_job.rb +0 -113
  134. data/lib/flow_chat/base_app.rb +0 -86
  135. data/lib/flow_chat/base_processor.rb +0 -146
  136. data/lib/flow_chat/http/app.rb +0 -6
  137. data/lib/flow_chat/http/middleware/executor.rb +0 -24
  138. data/lib/flow_chat/http/processor.rb +0 -33
  139. data/lib/flow_chat/session/rails_session_store.rb +0 -68
  140. data/lib/flow_chat/ussd/app.rb +0 -6
  141. data/lib/flow_chat/ussd/gateway/nsano.rb +0 -96
  142. data/lib/flow_chat/ussd/middleware/executor.rb +0 -24
  143. data/lib/flow_chat/ussd/processor.rb +0 -39
  144. data/lib/flow_chat/whatsapp/app.rb +0 -29
  145. data/lib/flow_chat/whatsapp/middleware/executor.rb +0 -24
  146. data/lib/flow_chat/whatsapp/processor.rb +0 -32
  147. 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
- # 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
@@ -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