onbuzz 4.9.13 → 4.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 (451) hide show
  1. package/node_modules/glob/README.md +31 -5
  2. package/node_modules/glob/dist/commonjs/glob.d.ts +8 -0
  3. package/node_modules/glob/dist/commonjs/glob.d.ts.map +1 -1
  4. package/node_modules/glob/dist/commonjs/glob.js +2 -1
  5. package/node_modules/glob/dist/commonjs/glob.js.map +1 -1
  6. package/node_modules/glob/dist/commonjs/index.min.js +3 -3
  7. package/node_modules/glob/dist/commonjs/index.min.js.map +4 -4
  8. package/node_modules/glob/dist/commonjs/pattern.d.ts +3 -0
  9. package/node_modules/glob/dist/commonjs/pattern.d.ts.map +1 -1
  10. package/node_modules/glob/dist/commonjs/pattern.js +4 -0
  11. package/node_modules/glob/dist/commonjs/pattern.js.map +1 -1
  12. package/node_modules/glob/dist/esm/glob.d.ts +8 -0
  13. package/node_modules/glob/dist/esm/glob.d.ts.map +1 -1
  14. package/node_modules/glob/dist/esm/glob.js +2 -1
  15. package/node_modules/glob/dist/esm/glob.js.map +1 -1
  16. package/node_modules/glob/dist/esm/index.min.js +3 -3
  17. package/node_modules/glob/dist/esm/index.min.js.map +4 -4
  18. package/node_modules/glob/dist/esm/pattern.d.ts +3 -0
  19. package/node_modules/glob/dist/esm/pattern.d.ts.map +1 -1
  20. package/node_modules/glob/dist/esm/pattern.js +4 -0
  21. package/node_modules/glob/dist/esm/pattern.js.map +1 -1
  22. package/node_modules/{@isaacs → glob/node_modules}/balanced-match/README.md +7 -10
  23. package/node_modules/{@isaacs → glob/node_modules}/balanced-match/package.json +7 -18
  24. package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/README.md +3 -6
  25. package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/dist/commonjs/index.js +6 -4
  26. package/node_modules/glob/node_modules/brace-expansion/dist/commonjs/index.js.map +1 -0
  27. package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/dist/esm/index.js +6 -4
  28. package/node_modules/glob/node_modules/brace-expansion/dist/esm/index.js.map +1 -0
  29. package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/package.json +11 -7
  30. package/node_modules/glob/node_modules/minimatch/README.md +76 -1
  31. package/node_modules/glob/node_modules/minimatch/dist/commonjs/assert-valid-pattern.d.ts +1 -1
  32. package/node_modules/glob/node_modules/minimatch/dist/commonjs/assert-valid-pattern.d.ts.map +1 -1
  33. package/node_modules/glob/node_modules/minimatch/dist/commonjs/assert-valid-pattern.js.map +1 -1
  34. package/node_modules/glob/node_modules/minimatch/dist/commonjs/ast.d.ts +4 -2
  35. package/node_modules/glob/node_modules/minimatch/dist/commonjs/ast.d.ts.map +1 -1
  36. package/node_modules/glob/node_modules/minimatch/dist/commonjs/ast.js +309 -55
  37. package/node_modules/glob/node_modules/minimatch/dist/commonjs/ast.js.map +1 -1
  38. package/node_modules/glob/node_modules/minimatch/dist/commonjs/brace-expressions.d.ts.map +1 -1
  39. package/node_modules/glob/node_modules/minimatch/dist/commonjs/brace-expressions.js +2 -4
  40. package/node_modules/glob/node_modules/minimatch/dist/commonjs/brace-expressions.js.map +1 -1
  41. package/node_modules/glob/node_modules/minimatch/dist/commonjs/escape.d.ts +1 -1
  42. package/node_modules/glob/node_modules/minimatch/dist/commonjs/escape.d.ts.map +1 -1
  43. package/node_modules/glob/node_modules/minimatch/dist/commonjs/escape.js +4 -4
  44. package/node_modules/glob/node_modules/minimatch/dist/commonjs/escape.js.map +1 -1
  45. package/node_modules/glob/node_modules/minimatch/dist/commonjs/index.d.ts +81 -1
  46. package/node_modules/glob/node_modules/minimatch/dist/commonjs/index.d.ts.map +1 -1
  47. package/node_modules/glob/node_modules/minimatch/dist/commonjs/index.js +232 -134
  48. package/node_modules/glob/node_modules/minimatch/dist/commonjs/index.js.map +1 -1
  49. package/node_modules/glob/node_modules/minimatch/dist/commonjs/unescape.d.ts +1 -1
  50. package/node_modules/glob/node_modules/minimatch/dist/commonjs/unescape.d.ts.map +1 -1
  51. package/node_modules/glob/node_modules/minimatch/dist/commonjs/unescape.js +8 -8
  52. package/node_modules/glob/node_modules/minimatch/dist/commonjs/unescape.js.map +1 -1
  53. package/node_modules/glob/node_modules/minimatch/dist/esm/assert-valid-pattern.d.ts +1 -1
  54. package/node_modules/glob/node_modules/minimatch/dist/esm/assert-valid-pattern.d.ts.map +1 -1
  55. package/node_modules/glob/node_modules/minimatch/dist/esm/assert-valid-pattern.js.map +1 -1
  56. package/node_modules/glob/node_modules/minimatch/dist/esm/ast.d.ts +4 -2
  57. package/node_modules/glob/node_modules/minimatch/dist/esm/ast.d.ts.map +1 -1
  58. package/node_modules/glob/node_modules/minimatch/dist/esm/ast.js +309 -55
  59. package/node_modules/glob/node_modules/minimatch/dist/esm/ast.js.map +1 -1
  60. package/node_modules/glob/node_modules/minimatch/dist/esm/brace-expressions.d.ts.map +1 -1
  61. package/node_modules/glob/node_modules/minimatch/dist/esm/brace-expressions.js +2 -4
  62. package/node_modules/glob/node_modules/minimatch/dist/esm/brace-expressions.js.map +1 -1
  63. package/node_modules/glob/node_modules/minimatch/dist/esm/escape.d.ts +1 -1
  64. package/node_modules/glob/node_modules/minimatch/dist/esm/escape.d.ts.map +1 -1
  65. package/node_modules/glob/node_modules/minimatch/dist/esm/escape.js +4 -4
  66. package/node_modules/glob/node_modules/minimatch/dist/esm/escape.js.map +1 -1
  67. package/node_modules/glob/node_modules/minimatch/dist/esm/index.d.ts +81 -1
  68. package/node_modules/glob/node_modules/minimatch/dist/esm/index.d.ts.map +1 -1
  69. package/node_modules/glob/node_modules/minimatch/dist/esm/index.js +232 -134
  70. package/node_modules/glob/node_modules/minimatch/dist/esm/index.js.map +1 -1
  71. package/node_modules/glob/node_modules/minimatch/dist/esm/unescape.d.ts +1 -1
  72. package/node_modules/glob/node_modules/minimatch/dist/esm/unescape.d.ts.map +1 -1
  73. package/node_modules/glob/node_modules/minimatch/dist/esm/unescape.js +8 -8
  74. package/node_modules/glob/node_modules/minimatch/dist/esm/unescape.js.map +1 -1
  75. package/node_modules/glob/node_modules/minimatch/package.json +17 -11
  76. package/node_modules/glob/package.json +10 -13
  77. package/node_modules/minipass/LICENSE.md +55 -0
  78. package/node_modules/minipass/dist/commonjs/index.d.ts +12 -16
  79. package/node_modules/minipass/dist/commonjs/index.d.ts.map +1 -1
  80. package/node_modules/minipass/dist/commonjs/index.js +13 -3
  81. package/node_modules/minipass/dist/commonjs/index.js.map +1 -1
  82. package/node_modules/minipass/dist/esm/index.d.ts +12 -16
  83. package/node_modules/minipass/dist/esm/index.d.ts.map +1 -1
  84. package/node_modules/minipass/dist/esm/index.js +3 -1
  85. package/node_modules/minipass/dist/esm/index.js.map +1 -1
  86. package/node_modules/minipass/package.json +9 -14
  87. package/node_modules/path-scurry/node_modules/lru-cache/README.md +96 -10
  88. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/diagnostics-channel-browser.d.ts.map +1 -0
  89. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/diagnostics-channel-browser.js.map +1 -0
  90. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/diagnostics-channel.d.ts +5 -0
  91. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/diagnostics-channel.js +7 -0
  92. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/index.d.ts +1400 -0
  93. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/index.d.ts.map +1 -0
  94. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/index.js +1726 -0
  95. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/index.js.map +1 -0
  96. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/index.min.js +2 -0
  97. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/index.min.js.map +7 -0
  98. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/perf.d.ts +12 -0
  99. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/perf.d.ts.map +1 -0
  100. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/perf.js +10 -0
  101. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/perf.js.map +1 -0
  102. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/diagnostics-channel-cjs.cjs.map +1 -0
  103. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/diagnostics-channel-cjs.d.cts.map +1 -0
  104. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/diagnostics-channel.d.ts +5 -0
  105. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/diagnostics-channel.js +7 -0
  106. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/index.d.ts +109 -32
  107. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/index.d.ts.map +1 -1
  108. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/index.js +334 -197
  109. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/index.js.map +1 -1
  110. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/index.min.js +1 -1
  111. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/index.min.js.map +4 -4
  112. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/diagnostics-channel-node.d.ts.map +1 -0
  113. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/diagnostics-channel-node.js.map +1 -0
  114. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/diagnostics-channel.d.ts +5 -0
  115. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/diagnostics-channel.js +9 -0
  116. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/index.d.ts +1400 -0
  117. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/index.d.ts.map +1 -0
  118. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/index.js +1726 -0
  119. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/index.js.map +1 -0
  120. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/index.min.js +2 -0
  121. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/index.min.js.map +7 -0
  122. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/perf.d.ts +12 -0
  123. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/perf.d.ts.map +1 -0
  124. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/perf.js +10 -0
  125. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/perf.js.map +1 -0
  126. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/perf.d.ts +12 -0
  127. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/perf.d.ts.map +1 -0
  128. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/perf.js +10 -0
  129. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/perf.js.map +1 -0
  130. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/diagnostics-channel-browser.d.ts.map +1 -0
  131. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/diagnostics-channel-browser.js.map +1 -0
  132. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/diagnostics-channel.d.ts +5 -0
  133. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/diagnostics-channel.js +4 -0
  134. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/index.d.ts +1400 -0
  135. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/index.d.ts.map +1 -0
  136. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/index.js +1722 -0
  137. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/index.js.map +1 -0
  138. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/index.min.js +2 -0
  139. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/index.min.js.map +7 -0
  140. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/perf.d.ts +12 -0
  141. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/perf.d.ts.map +1 -0
  142. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/perf.js +7 -0
  143. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/perf.js.map +1 -0
  144. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/diagnostics-channel-esm.d.mts.map +1 -0
  145. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/diagnostics-channel-esm.mjs.map +1 -0
  146. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/diagnostics-channel.d.ts +5 -0
  147. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/diagnostics-channel.js +19 -0
  148. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/index.d.ts +109 -32
  149. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/index.d.ts.map +1 -1
  150. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/index.js +333 -196
  151. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/index.js.map +1 -1
  152. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/index.min.js +1 -1
  153. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/index.min.js.map +4 -4
  154. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/diagnostics-channel-node.d.ts.map +1 -0
  155. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/diagnostics-channel-node.js.map +1 -0
  156. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/diagnostics-channel.d.ts +5 -0
  157. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/diagnostics-channel.js +6 -0
  158. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/index.d.ts +1400 -0
  159. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/index.d.ts.map +1 -0
  160. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/index.js +1722 -0
  161. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/index.js.map +1 -0
  162. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/index.min.js +2 -0
  163. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/index.min.js.map +7 -0
  164. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/perf.d.ts +12 -0
  165. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/perf.d.ts.map +1 -0
  166. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/perf.js +7 -0
  167. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/perf.js.map +1 -0
  168. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/perf.d.ts +12 -0
  169. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/perf.d.ts.map +1 -0
  170. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/perf.js +7 -0
  171. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/perf.js.map +1 -0
  172. package/node_modules/path-scurry/node_modules/lru-cache/package.json +71 -18
  173. package/node_modules/path-scurry/package.json +8 -24
  174. package/package.json +1 -1
  175. package/scripts/debug-balance-probe.mjs +35 -35
  176. package/scripts/push-image.sh +43 -43
  177. package/scripts/setup-acr.sh +65 -65
  178. package/scripts/verify-optional-deps.js +96 -1
  179. package/src/__tests__/composioCliFlags.test.js +239 -239
  180. package/src/analyzers/CSSAnalyzer.js +298 -297
  181. package/src/analyzers/ConfigValidator.js +691 -690
  182. package/src/analyzers/ESLintAnalyzer.js +320 -320
  183. package/src/analyzers/JavaScriptAnalyzer.js +260 -261
  184. package/src/analyzers/PrettierFormatter.js +246 -247
  185. package/src/analyzers/PythonAnalyzer.js +283 -283
  186. package/src/analyzers/SecurityAnalyzer.js +729 -729
  187. package/src/analyzers/SparrowAnalyzer.js +341 -341
  188. package/src/analyzers/TypeScriptAnalyzer.js +247 -247
  189. package/src/analyzers/__tests__/CSSAnalyzer.test.js +41 -41
  190. package/src/analyzers/__tests__/ConfigValidator.test.js +362 -362
  191. package/src/analyzers/__tests__/JavaScriptAnalyzer.test.js +40 -40
  192. package/src/analyzers/__tests__/PythonAnalyzer.test.js +205 -208
  193. package/src/analyzers/__tests__/SecurityAnalyzer.test.js +303 -303
  194. package/src/analyzers/__tests__/TypeScriptAnalyzer.test.js +187 -187
  195. package/src/analyzers/codeCloneDetector/analyzer.js +344 -344
  196. package/src/analyzers/codeCloneDetector/detector.js +250 -250
  197. package/src/analyzers/codeCloneDetector/index.js +194 -192
  198. package/src/analyzers/codeCloneDetector/parser.js +199 -199
  199. package/src/core/__tests__/agentPool.test.js +866 -866
  200. package/src/core/__tests__/agentPoolAutoResume.test.js +209 -209
  201. package/src/core/__tests__/agentPoolWakeOnMessage.test.js +315 -315
  202. package/src/core/__tests__/agentScheduler.emptyResponseChatStall.test.js +213 -213
  203. package/src/core/__tests__/agentScheduler.errorCategorisation.test.js +246 -246
  204. package/src/core/__tests__/agentScheduler.firstChunkTimeout.test.js +138 -138
  205. package/src/core/__tests__/agentScheduler.modeTransitions.test.js +233 -233
  206. package/src/core/__tests__/agentScheduler.nativePromptPick.test.js +319 -319
  207. package/src/core/__tests__/agentScheduler.taskLifecycleInstruction.test.js +78 -78
  208. package/src/core/__tests__/agentScheduler.visualizer.test.js +258 -258
  209. package/src/core/__tests__/flowCheckpointStore.test.js +140 -140
  210. package/src/core/__tests__/flowEndToEnd.test.js +565 -565
  211. package/src/core/__tests__/flowFieldMapping.test.js +188 -189
  212. package/src/core/__tests__/flowLintClientMirror.test.js +96 -98
  213. package/src/core/__tests__/flowSavePayload.test.js +170 -169
  214. package/src/core/__tests__/flowTemplates.test.js +311 -311
  215. package/src/core/__tests__/flowVersionStore.test.js +123 -123
  216. package/src/core/__tests__/messageProcessor.test.js +669 -669
  217. package/src/core/__tests__/stateManager.test.js +0 -1
  218. package/src/core/agentPool.js +2474 -2475
  219. package/src/core/agentScheduler.js +1 -4
  220. package/src/core/contextManager.js +708 -708
  221. package/src/core/flowExecutor.js +1510 -1510
  222. package/src/core/flowFieldMapping.js +136 -138
  223. package/src/core/messageProcessor.js +953 -954
  224. package/src/core/orchestrator.js +593 -595
  225. package/src/core/stateManager.js +1765 -1752
  226. package/src/index.js +1221 -1221
  227. package/src/interfaces/__tests__/archivedAgentDelete.test.js +207 -207
  228. package/src/interfaces/__tests__/bulkAgentRoute.test.js +361 -361
  229. package/src/interfaces/__tests__/imageServing.test.js +228 -228
  230. package/src/interfaces/__tests__/remoteSessionAuth.test.js +308 -308
  231. package/src/interfaces/__tests__/videoJobsRoutes.test.js +178 -179
  232. package/src/interfaces/__tests__/webServer.marketplace.test.js +629 -629
  233. package/src/interfaces/schedulerRoutes.js +50 -50
  234. package/src/interfaces/terminal/__tests__/smoke/connection.test.js +341 -350
  235. package/src/interfaces/terminal/__tests__/smoke/enhancements.test.js +156 -156
  236. package/src/interfaces/terminal/__tests__/smoke/imports.test.js +325 -330
  237. package/src/interfaces/terminal/__tests__/smoke/tools.test.js +385 -388
  238. package/src/interfaces/terminal/api/session.js +265 -266
  239. package/src/interfaces/terminal/api/websocket.js +496 -497
  240. package/src/interfaces/terminal/components/AgentCreator.js +691 -705
  241. package/src/interfaces/terminal/components/AgentEditor.js +676 -678
  242. package/src/interfaces/terminal/components/AgentSwitcher.js +331 -330
  243. package/src/interfaces/terminal/components/ErrorPanel.js +263 -264
  244. package/src/interfaces/terminal/components/Header.js +28 -28
  245. package/src/interfaces/terminal/components/Layout.js +598 -603
  246. package/src/interfaces/terminal/components/MessageList.js +280 -281
  247. package/src/interfaces/terminal/components/SettingsPanel.js +410 -415
  248. package/src/interfaces/terminal/components/StatusBar.js +2 -0
  249. package/src/interfaces/terminal/index.js +168 -168
  250. package/src/interfaces/terminal/state/useAgentControl.js +496 -496
  251. package/src/interfaces/terminal/state/useAgents.js +537 -537
  252. package/src/interfaces/terminal/state/useMessages.js +629 -630
  253. package/src/interfaces/terminal/state/useTools.js +554 -554
  254. package/src/interfaces/terminal/utils/debugLogger.js +44 -44
  255. package/src/interfaces/terminal/utils/settingsStorage.js +232 -232
  256. package/src/interfaces/webServer.js +7578 -7579
  257. package/src/interfaces/webServer.js.bak +7046 -7046
  258. package/src/modules/fileExplorer/__tests__/zipDownload.test.js +237 -237
  259. package/src/modules/fileExplorer/controller.js +470 -469
  260. package/src/modules/fileExplorer/routes.js +285 -286
  261. package/src/modules/widget/__tests__/isDisabled.test.js +41 -41
  262. package/src/modules/widget/__tests__/routes.test.js +677 -678
  263. package/src/modules/widget/__tests__/runtime.test.js +401 -401
  264. package/src/modules/widget/__tests__/versioning.test.js +309 -309
  265. package/src/modules/widget/__tests__/webComponentRuntime.test.js +565 -565
  266. package/src/modules/widget/__tests__/widgetTool.test.js +316 -316
  267. package/src/modules/widget/routes.js +435 -435
  268. package/src/modules/widget/runtime/bundle.js +640 -640
  269. package/src/modules/widget/runtime/webComponentBundle.js +470 -470
  270. package/src/modules/widget/schema.js +182 -181
  271. package/src/modules/widget/widgetTool.js +1389 -1389
  272. package/src/services/__tests__/agentActivityService.test.js +401 -402
  273. package/src/services/__tests__/benchmarkService.test.js +184 -184
  274. package/src/services/__tests__/contextInjectionService.test.js +246 -246
  275. package/src/services/__tests__/conversationQuery.test.js +721 -723
  276. package/src/services/__tests__/credentialVault.test.js +469 -469
  277. package/src/services/__tests__/discordService.integration.test.js +638 -639
  278. package/src/services/__tests__/flowContextService.test.js +590 -590
  279. package/src/services/__tests__/memoryService.test.js +1 -1
  280. package/src/services/__tests__/messageSource.test.js +380 -380
  281. package/src/services/__tests__/modelRouterNaming.test.js +111 -111
  282. package/src/services/__tests__/projectDetector.test.js +34 -34
  283. package/src/services/__tests__/promptService.test.js +242 -242
  284. package/src/services/__tests__/telegramService.test.js +941 -941
  285. package/src/services/__tests__/tokenCountingService.test.js +48 -48
  286. package/src/services/agentActivityService.js +419 -420
  287. package/src/services/aiService.js +2997 -3001
  288. package/src/services/apiKeyManager.js +359 -359
  289. package/src/services/benchmarkService.js +196 -196
  290. package/src/services/codebaseKnowledgeService.js +2 -2
  291. package/src/services/composioService.js +738 -738
  292. package/src/services/conversationCompactionService.js +1258 -1257
  293. package/src/services/credentialVault.js +685 -685
  294. package/src/services/discordService.js +792 -793
  295. package/src/services/embeddings/__tests__/azureCustomProvider.test.js +232 -232
  296. package/src/services/embeddings/__tests__/embeddingService.test.js +417 -417
  297. package/src/services/embeddings/__tests__/localProvider.test.js +263 -263
  298. package/src/services/embeddings/autoRecall.js +218 -219
  299. package/src/services/embeddings/indexers/__tests__/agentIndexer.test.js +232 -232
  300. package/src/services/embeddings/indexers/__tests__/memoryIndexer.test.js +418 -418
  301. package/src/services/embeddings/indexers/__tests__/reminisceIndexer.test.js +356 -357
  302. package/src/services/embeddings/indexers/__tests__/skillsIndexer.test.js +145 -145
  303. package/src/services/embeddings/indexers/__tests__/taskIndexer.test.js +146 -146
  304. package/src/services/embeddings/indexers/composioIndexer.js +279 -279
  305. package/src/services/embeddings/providerInterface.js +206 -206
  306. package/src/services/embeddings/providers/localProvider.js +11 -7
  307. package/src/services/embeddings/providers/openaiProvider.js +101 -101
  308. package/src/services/embeddings/vectorStore/inMemoryJsonStore.js +356 -356
  309. package/src/services/errorHandler.js +809 -809
  310. package/src/services/flowContextService.js +586 -586
  311. package/src/services/grounding/MockAdapter.js +125 -125
  312. package/src/services/modelRouterService.js +26 -31
  313. package/src/services/modelsService.js +322 -322
  314. package/src/services/ollamaService.js +452 -452
  315. package/src/services/projectDetector.js +403 -404
  316. package/src/services/promptService.js +418 -418
  317. package/src/services/qualityInspector.js +795 -795
  318. package/src/services/scheduleService.js +726 -726
  319. package/src/services/serviceRegistry.js +386 -386
  320. package/src/services/telegrafBot.js +174 -174
  321. package/src/services/telegramService.js +1972 -1972
  322. package/src/services/visualEditorBridge.js +1033 -1033
  323. package/src/services/visualEditorServer.js +1769 -1774
  324. package/src/services/whatsappService.js +667 -668
  325. package/src/tools/__tests__/agentCommunicationTool.findAgent.test.js +226 -226
  326. package/src/tools/__tests__/agentCommunicationTool.test.js +3 -3
  327. package/src/tools/__tests__/agentDelayTool.test.js +342 -342
  328. package/src/tools/__tests__/baseTool.test.js +3 -3
  329. package/src/tools/__tests__/codeMapTool.test.js +915 -915
  330. package/src/tools/__tests__/fileContentReplaceTool.test.js +309 -309
  331. package/src/tools/__tests__/fileTreeTool.test.js +274 -274
  332. package/src/tools/__tests__/filesystemTool.test.js +815 -815
  333. package/src/tools/__tests__/foundryWebSearchTool.test.js +252 -252
  334. package/src/tools/__tests__/imageTool.validator.test.js +194 -194
  335. package/src/tools/__tests__/jobDoneTool.test.js +580 -581
  336. package/src/tools/__tests__/memoryTool.forgetStale.test.js +272 -272
  337. package/src/tools/__tests__/memoryTool.reminisce.test.js +2 -2
  338. package/src/tools/__tests__/memoryTool.reminisceSemanticSearch.test.js +301 -301
  339. package/src/tools/__tests__/memoryTool.semanticSearch.test.js +405 -405
  340. package/src/tools/__tests__/memoryTool.teamPool.test.js +293 -293
  341. package/src/tools/__tests__/memoryTool.test.js +1 -1
  342. package/src/tools/__tests__/seekTool.test.js +282 -282
  343. package/src/tools/__tests__/skillsTool.search.test.js +164 -164
  344. package/src/tools/__tests__/skillsTool.test.js +226 -226
  345. package/src/tools/__tests__/staticAnalysisTool.test.js +509 -509
  346. package/src/tools/__tests__/taskManagerTool.discipline.test.js +137 -137
  347. package/src/tools/__tests__/taskManagerTool.search.test.js +143 -143
  348. package/src/tools/__tests__/taskManagerTool.test.js +866 -866
  349. package/src/tools/__tests__/terminalTool.test.js +448 -448
  350. package/src/tools/__tests__/toolShapeForgiveness.test.js +259 -260
  351. package/src/tools/__tests__/userPromptTool.test.js +297 -297
  352. package/src/tools/__tests__/videoTool.jobs.test.js +147 -147
  353. package/src/tools/__tests__/webTool.e2e.test.js +609 -603
  354. package/src/tools/__tests__/webTool.unit.test.js +195 -195
  355. package/src/tools/__tests__/webTool.visionModel.test.js +75 -75
  356. package/src/tools/agentCommunicationTool.js +8 -10
  357. package/src/tools/agentDelayTool.js +496 -497
  358. package/src/tools/asyncToolManager.js +602 -603
  359. package/src/tools/baseTool.js +12 -11
  360. package/src/tools/cloneDetectionTool.js +576 -581
  361. package/src/tools/codeMapTool.js +0 -6
  362. package/src/tools/composioTool.js +617 -617
  363. package/src/tools/dependencyResolverTool.js +1211 -1212
  364. package/src/tools/desktop/DesktopTool.js +629 -638
  365. package/src/tools/desktop/__tests__/DesktopTool.e2e.test.js +306 -306
  366. package/src/tools/desktop/__tests__/DesktopTool.test.js +507 -507
  367. package/src/tools/desktop/__tests__/osController.test.js +364 -364
  368. package/src/tools/desktop/osController.js +491 -491
  369. package/src/tools/docxTool.js +623 -623
  370. package/src/tools/excelTool.js +636 -636
  371. package/src/tools/fileContentReplaceTool.js +5 -7
  372. package/src/tools/fileSystemTool.js +12 -19
  373. package/src/tools/fileTreeTool.js +840 -840
  374. package/src/tools/foundryWebSearchTool.js +273 -273
  375. package/src/tools/helpTool.js +198 -198
  376. package/src/tools/imageTool.js +1397 -1397
  377. package/src/tools/importAnalyzerTool.js +1056 -1056
  378. package/src/tools/jobDoneTool.js +495 -495
  379. package/src/tools/memoryTool.js +1 -1
  380. package/src/tools/office/pres/__tests__/presSystem.test.js +365 -365
  381. package/src/tools/office/pres/archetypes/agenda.js +61 -61
  382. package/src/tools/office/pres/archetypes/bentoGrid.js +218 -219
  383. package/src/tools/office/pres/archetypes/bigStat.js +140 -142
  384. package/src/tools/office/pres/archetypes/closing.js +70 -70
  385. package/src/tools/office/pres/archetypes/hero.js +70 -70
  386. package/src/tools/office/pres/archetypes/productHero.js +93 -94
  387. package/src/tools/office/pres/archetypes/table.js +73 -74
  388. package/src/tools/office/pres/backgrounds/orb.js +66 -66
  389. package/src/tools/office/pres/components.js +422 -423
  390. package/src/tools/officeTool.js +441 -441
  391. package/src/tools/pdfTool.js +625 -627
  392. package/src/tools/platformControlTool.js +1081 -1081
  393. package/src/tools/seekTool.js +917 -918
  394. package/src/tools/skillsTool.js +1 -1
  395. package/src/tools/staticAnalysisTool.js +2143 -2146
  396. package/src/tools/taskManagerTool.js +3324 -3324
  397. package/src/tools/terminalTool.js +2615 -2618
  398. package/src/tools/videoTool.js +1303 -1303
  399. package/src/tools/visionTool.js +508 -508
  400. package/src/tools/visualEditorTool.js +1289 -1290
  401. package/src/tools/webTool.js +3368 -3368
  402. package/src/tools/whatsappTool.js +464 -464
  403. package/src/types/__tests__/agent.test.js +499 -499
  404. package/src/types/__tests__/contextReference.test.js +606 -606
  405. package/src/types/__tests__/conversation.test.js +555 -555
  406. package/src/types/__tests__/toolCommand.test.js +584 -584
  407. package/src/types/contextReference.js +974 -971
  408. package/src/types/conversation.js +729 -729
  409. package/src/types/toolCommand.js +746 -746
  410. package/src/utilities/__tests__/attachmentValidator.test.js +80 -80
  411. package/src/utilities/__tests__/auditReport.test.js +328 -328
  412. package/src/utilities/__tests__/directoryAccessManager.test.js +388 -388
  413. package/src/utilities/__tests__/jsonRepair.test.js +103 -104
  414. package/src/utilities/__tests__/modeTransitionReasons.test.js +105 -105
  415. package/src/utilities/__tests__/platformUtils.test.js +80 -87
  416. package/src/utilities/__tests__/structuredFileValidator.test.js +261 -263
  417. package/src/utilities/__tests__/toolConstants.test.js +92 -94
  418. package/src/utilities/__tests__/useIsTouchDevice.detect.test.js +114 -114
  419. package/src/utilities/__tests__/webUiUtilSync.test.js +117 -117
  420. package/src/utilities/attachmentValidator.js +284 -288
  421. package/src/utilities/authCache.js.backup-1779570472481 +121 -121
  422. package/src/utilities/browserStealth.js +631 -630
  423. package/src/utilities/configManager.js +616 -617
  424. package/src/utilities/directoryAccessManager.js +564 -565
  425. package/src/utilities/fileProcessor.js +308 -307
  426. package/src/utilities/humanBehavior.js +454 -453
  427. package/src/utilities/logger.js +479 -479
  428. package/src/utilities/structuredFileValidator.js +696 -699
  429. package/src/utilities/tagParser.js +5 -10
  430. package/src/utilities/userDataDir.js +308 -308
  431. package/node_modules/@isaacs/brace-expansion/dist/commonjs/index.js.map +0 -1
  432. package/node_modules/@isaacs/brace-expansion/dist/esm/index.js.map +0 -1
  433. package/node_modules/minipass/LICENSE +0 -15
  434. /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/LICENSE.md +0 -0
  435. /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/commonjs/index.d.ts +0 -0
  436. /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/commonjs/index.d.ts.map +0 -0
  437. /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/commonjs/index.js +0 -0
  438. /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/commonjs/index.js.map +0 -0
  439. /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/commonjs/package.json +0 -0
  440. /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/esm/index.d.ts +0 -0
  441. /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/esm/index.d.ts.map +0 -0
  442. /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/esm/index.js +0 -0
  443. /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/esm/index.js.map +0 -0
  444. /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/esm/package.json +0 -0
  445. /package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/LICENSE +0 -0
  446. /package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/dist/commonjs/index.d.ts +0 -0
  447. /package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/dist/commonjs/index.d.ts.map +0 -0
  448. /package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/dist/commonjs/package.json +0 -0
  449. /package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/dist/esm/index.d.ts +0 -0
  450. /package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/dist/esm/index.d.ts.map +0 -0
  451. /package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/dist/esm/package.json +0 -0
@@ -1,1972 +1,1972 @@
1
- /**
2
- * TelegramService — Conversational agent interface over Telegram.
3
- *
4
- * Public surface (unchanged):
5
- * getTelegramService(logger) → singleton
6
- * .setOrchestrator() .setAgentPool() .setWebSocketManager() .setFlowExecutor()
7
- * .autoConnect() / .connect(botToken) / .disconnect()
8
- * .getStatus() / .sendTestMessage()
9
- * .getBridgedChannels(agentId) / .isAgentBridged(agentId)
10
- *
11
- * Capabilities (UX-focused):
12
- * • Multi-chat — any number of private chats can /start the bot; the
13
- * first-chat-wins lock is gone. Each chat keeps its own sticky agent
14
- * + watch state. (`chats: Map<chatId, ChatState>`)
15
- * • Group chat — bots in Telegram groups respond ONLY when @mentioned
16
- * by their username (Telegram convention; prevents bot spam).
17
- * • Voice notes — downloaded, base64-posted to the backend's
18
- * /llm/transcribe endpoint (which proxies to whichever Azure AI
19
- * Foundry speech-to-text deployment is in the model catalog —
20
- * gpt-4o-mini-transcribe by default), then handled as text. Falls
21
- * back to a friendly "voice not configured" message when the
22
- * backend route returns 503.
23
- * • Typing indicator + "thinking" placeholder — emitted before the
24
- * orchestrator call so the user sees instant feedback; replaced
25
- * with the real reply via editMessageText.
26
- * • Rich reply blocks via `<actions>` / `<image>` / `<attachment>`
27
- * tags inside `<external>` — see telegramBlockParser.js.
28
- * • Errors and timeouts always relayed (not gated by /watch).
29
- * • Prompt-request timeout sends a "your prompt expired" message
30
- * instead of silently dropping the request.
31
- * • Paginated `/agents` and `/flows` for large pools.
32
- * • Long code-only replies are sent as a `.txt` document so
33
- * Telegram's MarkdownV2 escaping never mangles them.
34
- *
35
- * Config persistence (backward-compatible):
36
- * Old: `{ chatId, watchEnabled }` (scalar)
37
- * New: `{ chats: [{ chatId, addedAt, watchEnabled, lastAgentId }], … }`
38
- * On load, a legacy scalar `chatId` is migrated to a single-entry
39
- * chats array. Writes always emit the new shape; the legacy field
40
- * is dropped silently.
41
- */
42
-
43
- import { promises as fs } from 'fs';
44
- import path from 'path';
45
- import { getUserDataPaths, ensureUserDataDirs } from '../utilities/userDataDir.js';
46
- import { filterContentForExternalRelay, resolveBlockTargets } from './channelFilter.js';
47
- import { createTelegramSource } from './messageSource.js';
48
- import { parseTelegramBlock } from './telegramBlockParser.js';
49
-
50
- // Error-broadcast dedup window. The scheduler's "unknown error" branch
51
- // retries every 60s; with a 5-min window we collapse 5 identical fires
52
- // into one notification + one optional "still happening" summary.
53
- const ERROR_DEDUP_WINDOW_MS = 5 * 60 * 1000;
54
- // Memory cap on the dedup table — if a misbehaving system somehow
55
- // produces 50+ distinct (agent, type, message) combinations within a
56
- // window, we evict the oldest. In normal operation the table is tiny.
57
- const ERROR_DEDUP_MAX_KEYS = 50;
58
-
59
- /**
60
- * Collapse runs of whitespace to a single space and trim leading/trailing
61
- * whitespace — used to normalise error messages before hashing them for
62
- * dedup. Implemented with plain character iteration to avoid the
63
- * /\s+/g regex (the codebase convention is "string ops over regex").
64
- *
65
- * Treats ASCII control chars (0x00–0x20) and NBSP (0xA0) as whitespace.
66
- *
67
- * @param {string} s
68
- * @returns {string}
69
- */
70
- function _collapseWhitespace(s) {
71
- let out = '';
72
- let prevWasWs = false;
73
- for (let i = 0; i < s.length; i++) {
74
- const code = s.charCodeAt(i);
75
- const isWs = code <= 32 || code === 160;
76
- if (isWs) {
77
- prevWasWs = true;
78
- } else {
79
- if (prevWasWs && out.length > 0) out += ' ';
80
- out += s[i];
81
- prevWasWs = false;
82
- }
83
- }
84
- return out;
85
- }
86
-
87
- const TELEGRAM_STATUS = {
88
- DISCONNECTED: 'disconnected',
89
- CONNECTING: 'connecting',
90
- CONNECTED: 'connected',
91
- FAILED: 'failed',
92
- };
93
-
94
- const MAX_MESSAGE_LENGTH = 4000; // Telegram limit is 4096 — leave room for headers
95
- const NOTIFICATION_BATCH_INTERVAL_MS = 10000;
96
- const PROMPT_REMINDER_MS = 180000; // 3 min — "still waiting" nudge
97
- const PROMPT_TIMEOUT_MS = 600000; // 10 min — hard timeout, send cancel
98
- const PAGE_SIZE = 8; // /agents and /flows page size
99
- const CODE_AS_FILE_THRESHOLD = 3200; // chars; above this, send code as .txt
100
- const IMAGE_PENDING_TTL_MS = 5 * 60 * 1000; // 5 min — buffer window for imageGenerated → stream_complete
101
-
102
- class TelegramService {
103
- constructor(logger = null) {
104
- this.logger = logger;
105
-
106
- // Dependencies (set via setters)
107
- this.orchestrator = null;
108
- this.agentPool = null;
109
- this.webSocketManager = null;
110
- this.flowExecutor = null;
111
-
112
- // Bot state
113
- this.bot = null;
114
- this.status = TELEGRAM_STATUS.DISCONNECTED;
115
- this.botUsername = null; // populated by getMe() at connect
116
-
117
- /**
118
- * Per-chat state. Keyed by chatId (string). Each value:
119
- * {
120
- * type: 'private' | 'group' | 'supergroup' | 'channel',
121
- * title?: string,
122
- * addedAt: ISO string,
123
- * watchEnabled: boolean,
124
- * lastAgentId: string|null,
125
- * activeAgentIds: Set<string>,
126
- * // Tracking for in-flight "🔄 Thinking" placeholders:
127
- * thinkingByAgentId: Map<agentId, messageId>,
128
- * }
129
- */
130
- this.chats = new Map();
131
-
132
- /**
133
- * Dedup table for error broadcasts (agent_error / agent_timeout /
134
- * criticalError / flow_run_failed).
135
- *
136
- * The scheduler can loop on the same uncategorised error every 60s
137
- * forever; without dedup, every linked Telegram chat sees a fresh
138
- * "⚠️ Agent Error" message on every retry. We coalesce identical
139
- * errors within ERROR_DEDUP_WINDOW_MS and emit one "still happening"
140
- * summary at the end of the window if the loop didn't clear.
141
- *
142
- * Keyed by `${agentId}:${type}:${hashedMessage}`. Each entry:
143
- * {
144
- * count: how many times this exact error fired in the window
145
- * firstAt: epoch ms of the first occurrence
146
- * agentName: last-seen agent name (for the summary)
147
- * timer: setTimeout handle that emits the summary on close
148
- * }
149
- *
150
- * Bounded by ERROR_DEDUP_MAX_KEYS so a runaway never blows memory.
151
- */
152
- this._errorBroadcastDedup = new Map();
153
-
154
- // Relay state (global — keyed by requestId)
155
- this.pendingRelays = new Map(); // requestId → { type, agentId, chatId, timeoutId, reminderId }
156
- this.replyContext = new Map(); // chatId → { type, requestId, agentId }
157
-
158
- // Notifications (batched per-chat)
159
- this.notificationQueue = new Map(); // chatId → string[]
160
- this.notificationTimers = new Map(); // chatId → timeout handle
161
-
162
- // Config
163
- this.dataDir = null;
164
- this.configPath = null;
165
- this.config = {};
166
-
167
- // Backend (for transcription) — falls back to the same default
168
- // the rest of the CLI uses. webServer.js sets this via
169
- // setBackendBaseUrl().
170
- this.backendBaseUrl = 'https://autopilot-api.azurewebsites.net';
171
- // Platform API key resolver. May be set as either:
172
- // a) a function () => string|null (preferred — re-resolves on
173
- // every call, so signin/signout after boot is handled
174
- // automatically)
175
- // b) a plain string (legacy — captured once)
176
- // Both are exposed through `_resolvePlatformKey()` below.
177
- this._platformApiKey = null;
178
-
179
- // Original broadcast (saved before wrapping)
180
- this._originalBroadcast = null;
181
-
182
- /**
183
- * Pending generated images, keyed by agentId. The imageTool emits
184
- * its own `imageGenerated` broadcast independently of the agent's
185
- * text completion; we buffer those URLs here and flush them in
186
- * `_relayAgentResponse` so the image reaches Telegram even if the
187
- * agent's final reply text doesn't embed the image markdown.
188
- *
189
- * Shape: Map<agentId, Array<{ imageUrl, prompt, addedAt }>>
190
- *
191
- * Entries older than IMAGE_PENDING_TTL_MS are pruned at flush time
192
- * — guards against a stranded image surfacing later in an unrelated
193
- * conversation turn. The TTL is long enough for slow generations
194
- * (DALL-E 3, video previews) but short enough to keep the buffer
195
- * from accumulating across hours of idle.
196
- */
197
- this._pendingImagesByAgent = new Map();
198
- }
199
-
200
- // ── Dependency Injection ───────────────────────────────────────────
201
-
202
- setOrchestrator(orchestrator) { this.orchestrator = orchestrator; }
203
- setAgentPool(agentPool) { this.agentPool = agentPool; }
204
- setWebSocketManager(wsManager) {
205
- this.webSocketManager = wsManager;
206
- this._interceptBroadcasts(wsManager);
207
- }
208
- setFlowExecutor(flowExecutor) { this.flowExecutor = flowExecutor; }
209
- setBackendBaseUrl(url) { if (url) this.backendBaseUrl = String(url).replace(/\/$/, ''); }
210
- /** Accept a string OR a () => string|null getter. The getter form is
211
- * preferred so a sign-in / sign-out cycle after boot is reflected
212
- * without re-wiring. */
213
- setPlatformApiKey(keyOrGetter) { this._platformApiKey = keyOrGetter || null; }
214
- _resolvePlatformKey() {
215
- const k = this._platformApiKey;
216
- if (typeof k === 'function') {
217
- try { return k() || null; } catch { return null; }
218
- }
219
- return k || null;
220
- }
221
-
222
- // ── Config Persistence ─────────────────────────────────────────────
223
-
224
- async _ensureDataDir() {
225
- if (!this.dataDir) {
226
- await ensureUserDataDirs();
227
- const paths = getUserDataPaths();
228
- this.dataDir = path.join(paths.base, 'telegram');
229
- this.configPath = path.join(this.dataDir, 'telegram-config.json');
230
- await fs.mkdir(this.dataDir, { recursive: true });
231
- }
232
- }
233
-
234
- async _loadConfig() {
235
- await this._ensureDataDir();
236
- try {
237
- const data = await fs.readFile(this.configPath, 'utf8');
238
- this.config = JSON.parse(data);
239
- } catch {
240
- this.config = {};
241
- }
242
-
243
- // Migration: legacy `{ chatId, watchEnabled }` → `{ chats: [...] }`.
244
- // We intentionally do NOT delete the legacy field from this.config
245
- // until the next save so a hand-edited file roundtrips cleanly.
246
- if (this.config.chatId && !Array.isArray(this.config.chats)) {
247
- this.config.chats = [{
248
- chatId: String(this.config.chatId),
249
- type: 'private',
250
- title: null,
251
- addedAt: this.config.updatedAt || new Date().toISOString(),
252
- watchEnabled: !!this.config.watchEnabled,
253
- lastAgentId: null,
254
- }];
255
- }
256
-
257
- this.chats = new Map();
258
- for (const entry of (this.config.chats || [])) {
259
- if (!entry?.chatId) continue;
260
- this.chats.set(String(entry.chatId), {
261
- type: entry.type || 'private',
262
- title: entry.title || null,
263
- addedAt: entry.addedAt || new Date().toISOString(),
264
- watchEnabled: !!entry.watchEnabled,
265
- lastAgentId: entry.lastAgentId || null,
266
- activeAgentIds: new Set(entry.activeAgentIds || []),
267
- thinkingByAgentId: new Map(),
268
- });
269
- }
270
- }
271
-
272
- async _saveConfig() {
273
- await this._ensureDataDir();
274
- // Re-shape in-memory state for persistence.
275
- this.config.chats = [...this.chats.entries()].map(([chatId, s]) => ({
276
- chatId,
277
- type: s.type,
278
- title: s.title,
279
- addedAt: s.addedAt,
280
- watchEnabled: s.watchEnabled,
281
- lastAgentId: s.lastAgentId,
282
- activeAgentIds: [...s.activeAgentIds],
283
- }));
284
- delete this.config.chatId;
285
- delete this.config.watchEnabled;
286
- this.config.updatedAt = new Date().toISOString();
287
- await fs.writeFile(this.configPath, JSON.stringify(this.config, null, 2), 'utf8');
288
- }
289
-
290
- // ── Lifecycle ──────────────────────────────────────────────────────
291
-
292
- async autoConnect() {
293
- await this._loadConfig();
294
- if (this.config.botToken) {
295
- try {
296
- await this.connect(this.config.botToken);
297
- } catch (error) {
298
- this.logger?.warn('[TelegramService] Auto-connect failed', { error: error.message });
299
- }
300
- }
301
- }
302
-
303
- async connect(botToken) {
304
- if (this.status === TELEGRAM_STATUS.CONNECTED) {
305
- await this.disconnect();
306
- }
307
- this.status = TELEGRAM_STATUS.CONNECTING;
308
- this.logger?.info('[TelegramService] Connecting...');
309
-
310
- try {
311
- // Migrated from node-telegram-bot-api → telegraf via the local
312
- // compat wrapper (src/services/telegrafBot.js). The wrapper
313
- // preserves the entire node-telegram-bot-api surface this file is
314
- // written against, so the body below — onText, on('message'),
315
- // sendMessage, editMessageText, sendChatAction, sendDocument, etc.
316
- // — keeps working unchanged. The motivation was 9 CVEs (incl. 2
317
- // critical) in the deprecated `request`/`form-data`/`@cypress/request`
318
- // chain that node-telegram-bot-api's latest version still hauls in.
319
- const { createTelegrafBot } = await import('./telegrafBot.js');
320
- this.bot = await createTelegrafBot(botToken, { polling: true });
321
-
322
- const me = await this.bot.getMe();
323
- this.botUsername = me.username || null;
324
- this.logger?.info('[TelegramService] Connected', { botName: this.botUsername });
325
-
326
- this.config.botToken = botToken;
327
- this.config.botUsername = this.botUsername;
328
- await this._saveConfig();
329
-
330
- this._setupHandlers();
331
- this.status = TELEGRAM_STATUS.CONNECTED;
332
-
333
- return { username: this.botUsername, id: me.id };
334
- } catch (error) {
335
- this.status = TELEGRAM_STATUS.FAILED;
336
- this.logger?.error('[TelegramService] Connection failed', { error: error.message });
337
- throw error;
338
- }
339
- }
340
-
341
- async disconnect() {
342
- if (this.bot) {
343
- try { await this.bot.stopPolling(); } catch { /* ignore */ }
344
- this.bot = null;
345
- }
346
- this.status = TELEGRAM_STATUS.DISCONNECTED;
347
- for (const t of this.notificationTimers.values()) clearTimeout(t);
348
- this.notificationTimers.clear();
349
- this.notificationQueue.clear();
350
- this.logger?.info('[TelegramService] Disconnected');
351
- }
352
-
353
- getStatus() {
354
- return {
355
- status: this.status,
356
- connected: this.status === TELEGRAM_STATUS.CONNECTED,
357
- botUsername: this.botUsername,
358
- chatCount: this.chats.size,
359
- // Surface the legacy `chatId` as the first chat for UI back-compat;
360
- // any new UI should switch to `chats`.
361
- chatId: this.chats.size > 0 ? [...this.chats.keys()][0] : null,
362
- chats: [...this.chats.entries()].map(([chatId, s]) => ({
363
- chatId,
364
- type: s.type,
365
- title: s.title,
366
- watchEnabled: s.watchEnabled,
367
- })),
368
- watchEnabled: [...this.chats.values()].some(s => s.watchEnabled),
369
- };
370
- }
371
-
372
- // ── Command & Message Handlers ─────────────────────────────────────
373
-
374
- _setupHandlers() {
375
- if (!this.bot) return;
376
-
377
- this.bot.onText(/^\/start(@\w+)?(\s|$)/, (msg) => this._cmdStart(msg));
378
- this.bot.onText(/^\/help(@\w+)?(\s|$)/, (msg) => this._cmdHelp(msg));
379
- this.bot.onText(/^\/status(@\w+)?(\s|$)/, (msg) => this._cmdStatus(msg));
380
- this.bot.onText(/^\/agents(@\w+)?(\s|$)/, (msg) => this._cmdAgents(msg, 0));
381
- this.bot.onText(/^\/agent(@\w+)?\s+(.+)/, (msg, m) => this._cmdAgentDetail(msg, m[2].trim()));
382
- this.bot.onText(/^\/flows(@\w+)?(\s|$)/, (msg) => this._cmdFlows(msg, 0));
383
- this.bot.onText(/^\/run(@\w+)?\s+(.+)/, (msg, m) => this._cmdRunFlow(msg, m[2].trim()));
384
- this.bot.onText(/^\/stop(@\w+)?\s+(.+)/, (msg, m) => this._cmdStopAgent(msg, m[2].trim()));
385
- this.bot.onText(/^\/following(@\w+)?(\s|$)/, (msg) => this._cmdFollowing(msg));
386
- this.bot.onText(/^\/unfollow(@\w+)?\s+(.+)/, (msg, m) => this._cmdUnfollow(msg, m[2].trim()));
387
- this.bot.onText(/^\/watch(@\w+)?(\s|$)/, (msg) => this._cmdWatch(msg));
388
- this.bot.onText(/^\/unwatch(@\w+)?(\s|$)/, (msg) => this._cmdUnwatch(msg));
389
- this.bot.onText(/^\/watching(@\w+)?(\s|$)/, (msg) => this._cmdWatching(msg));
390
- this.bot.onText(/^\/reset(@\w+)?(\s|$)/, (msg) => this._cmdReset(msg));
391
- this.bot.onText(/^\/new(@\w+)?\s+(.+)/, (msg, m) => this._cmdNew(msg, m[2].trim()));
392
- this.bot.onText(/^\/logout(@\w+)?(\s|$)/, (msg) => this._cmdLogout(msg));
393
-
394
- // Generic message handler — branches on type.
395
- this.bot.on('message', (msg) => this._onMessage(msg));
396
-
397
- // Inline-keyboard callbacks.
398
- this.bot.on('callback_query', (q) => this._handleCallbackQuery(q));
399
- }
400
-
401
- async _onMessage(msg) {
402
- // Skip slash commands; their handlers fire via onText.
403
- if (typeof msg.text === 'string' && msg.text.startsWith('/')) return;
404
-
405
- if (msg.voice) {
406
- await this._handleVoiceMessage(msg);
407
- return;
408
- }
409
- if (msg.text) {
410
- await this._handleTextMessage(msg);
411
- return;
412
- }
413
- // Other media types (photos, documents, stickers) are intentionally
414
- // ignored for now — agents can't yet consume them. Future work:
415
- // forward image_url to a vision-capable agent.
416
- }
417
-
418
- _isAuthorized(msg) {
419
- const chatId = String(msg.chat.id);
420
- return this.chats.has(chatId);
421
- }
422
-
423
- /**
424
- * Parse `@agent-name ...` (or just `agent-name ...`) at the start of
425
- * a message, doing longest-prefix matching against the live agent
426
- * list so names with spaces work without quoting.
427
- *
428
- * Accepts:
429
- * "@Cody hello" → { agentName: 'Cody', matchedAgent: <agent>, messageText: 'hello' }
430
- * "@John Smith report" → { agentName: 'John Smith', matchedAgent: <agent>, messageText: 'report' }
431
- * "@John_Smith report" → same — underscore + dash are accepted as a
432
- * space-substitute for slug-style addressing
433
- * "no prefix" → { agentName: null, matchedAgent: null, messageText: 'no prefix' }
434
- * "@Unknown msg" → { agentName: 'Unknown', matchedAgent: null, messageText: 'msg' }
435
- * (caller emits a friendly "not found" reply)
436
- *
437
- * Algorithm:
438
- * 1. Strip leading `@` if present. Remember whether one was there.
439
- * 2. For each agent, see if the remaining text starts with the
440
- * agent's name (case-insensitive). Try TWO normalisations:
441
- * a) literal name with all spaces
442
- * b) name with spaces replaced by [ _\-]+ (slug form)
443
- * Treat a match as valid only when followed by whitespace or
444
- * end-of-string (so "@John" doesn't accidentally match "Johnson").
445
- * 3. Among valid matches, pick the LONGEST agent name — handles
446
- * the "@John Smith" vs "@John" ambiguity correctly.
447
- * 4. If no agent name matched but there WAS an `@` prefix, fall
448
- * back to the legacy single-token capture so the caller can
449
- * surface a "not found" error with the user-typed text.
450
- *
451
- * @param {string} text
452
- * @param {Array<{id:string, name:string}>} agents
453
- * @returns {{ agentName: string|null, matchedAgent: object|null, messageText: string }}
454
- */
455
- _resolveAtPrefix(text, agents) {
456
- if (typeof text !== 'string' || text.length === 0) {
457
- return { agentName: null, matchedAgent: null, messageText: text || '' };
458
- }
459
-
460
- const hasAt = text.startsWith('@');
461
- const body = hasAt ? text.slice(1) : text;
462
-
463
- // Try to find the longest agent name that the body starts with.
464
- let best = null; // { agent, matchedRaw, restStart }
465
- for (const agent of agents || []) {
466
- const name = String(agent?.name || '');
467
- if (!name) continue;
468
- // Build a regex that matches either the literal name or its slug
469
- // variant (spaces → any of `[ _\-]+`). Case-insensitive. Must be
470
- // followed by whitespace or end-of-string so partial-word
471
- // collisions don't false-match.
472
- const slugPattern = name
473
- .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // escape regex metas
474
- .replace(/\s+/g, '[ _\\-]+'); // any space substitute
475
- const re = new RegExp(`^(${slugPattern})(?:\\s|$)`, 'i');
476
- const m = body.match(re);
477
- if (m) {
478
- if (!best || m[1].length > best.matchedRaw.length) {
479
- best = { agent, matchedRaw: m[1], restStart: m[1].length };
480
- }
481
- }
482
- }
483
-
484
- if (best) {
485
- const rest = body.slice(best.restStart).replace(/^\s+/, '');
486
- return {
487
- agentName: best.agent.name,
488
- matchedAgent: best.agent,
489
- messageText: rest,
490
- };
491
- }
492
-
493
- if (!hasAt) {
494
- return { agentName: null, matchedAgent: null, messageText: text };
495
- }
496
-
497
- // No agent matched but the user clearly intended an @-prefix
498
- // (typo, agent renamed, etc.). Fall back to the legacy "first
499
- // whitespace-free token after @" capture so the caller can quote
500
- // the user-typed name in the "not found" reply.
501
- const legacy = body.match(/^(\S+)(?:\s+([\s\S]+))?/);
502
- if (legacy) {
503
- return {
504
- agentName: legacy[1],
505
- matchedAgent: null,
506
- messageText: (legacy[2] || '').trim(),
507
- };
508
- }
509
- return { agentName: null, matchedAgent: null, messageText: text };
510
- }
511
-
512
- /**
513
- * In a group chat, the bot should only respond to a text message
514
- * when @-mentioned. This prevents bot spam in shared channels.
515
- * Returns the message text with the @mention stripped when matched.
516
- * In private chats, the text is returned unchanged.
517
- */
518
- _stripMentionOrSkip(msg) {
519
- const isGroup = msg.chat.type === 'group' || msg.chat.type === 'supergroup';
520
- const text = (msg.text || msg.caption || '').trim();
521
- if (!isGroup) return { addressed: true, text };
522
- if (!this.botUsername) return { addressed: false, text };
523
-
524
- const mention = `@${this.botUsername.toLowerCase()}`;
525
- const lower = text.toLowerCase();
526
- if (lower.includes(mention)) {
527
- const stripped = text.replace(new RegExp(`@${this.botUsername}\\b`, 'gi'), '').replace(/\s+/g, ' ').trim();
528
- return { addressed: true, text: stripped };
529
- }
530
- return { addressed: false, text };
531
- }
532
-
533
- // ── Commands ───────────────────────────────────────────────────────
534
-
535
- async _cmdStart(msg) {
536
- const chatId = String(msg.chat.id);
537
- const type = msg.chat.type || 'private';
538
- const title = msg.chat.title || null;
539
-
540
- if (!this.chats.has(chatId)) {
541
- this.chats.set(chatId, {
542
- type, title,
543
- addedAt: new Date().toISOString(),
544
- watchEnabled: false,
545
- lastAgentId: null,
546
- activeAgentIds: new Set(),
547
- thinkingByAgentId: new Map(),
548
- });
549
- await this._saveConfig();
550
- const greeting = type === 'private'
551
- ? this._md`*Loxia Autopilot connected\\!* 🚀\n\nThis chat is now linked\\. Use /help for commands\\.\n\nAddress agents with \`@agent\\-name your message\` or just start typing once you’ve picked one\\.`
552
- : this._md`*Loxia Autopilot connected\\!* 🚀\n\nGroup registered: *${title || chatId}*\\. Mention me with @${this.botUsername || 'loxia\\_bot'} to address an agent\\.`;
553
- await this._send(chatId, greeting);
554
- } else {
555
- await this._send(chatId, this._md`Already connected\\. Use /help for commands\\.`);
556
- }
557
- }
558
-
559
- async _cmdHelp(msg) {
560
- if (!this._isAuthorized(msg)) return;
561
- // Hand-written MarkdownV2. Special chars (`.`, `-`, `!`, `(`, `)`,
562
- // `>`) MUST be escaped per Telegram's MarkdownV2 spec. The
563
- // _escapeMarkdown wrapper that used to wrap this whole thing
564
- // turned every `*` and backtick into literal text — that's why
565
- // the help message displayed asterisks instead of bold.
566
- const help =
567
- `*Loxia Autopilot — Telegram*
568
-
569
- *Chat with agents:*
570
- \`@agent\\-name message\` — send to a specific agent
571
- Names with spaces work too: \`@John Smith hello\` or \`@John\\_Smith hello\`
572
- Type without prefix — sends to your last\\-used agent
573
-
574
- *Commands:*
575
- /agents — list all agents \\(paginated\\)
576
- /agent <name> — agent detail card
577
- /status — system overview
578
- /following — agents you have spoken with from this chat
579
- /unfollow <name> — stop receiving replies from an agent
580
- /flows — list flows \\(paginated\\)
581
- /run <flow> — start a flow
582
- /stop <agent> — halt agent execution
583
- /reset — clear the conversation with your active agent
584
- /new <agent> — switch to a different agent with fresh context
585
- /watch — subscribe to event notifications
586
- /unwatch — unsubscribe
587
- /watching — show notification status
588
- /logout — disconnect this chat from Loxia
589
- /help — this message
590
-
591
- _Voice notes are transcribed automatically\\._`;
592
- await this._send(msg.chat.id, help);
593
- }
594
-
595
- async _cmdStatus(msg) {
596
- if (!this._isAuthorized(msg)) return;
597
- try {
598
- const agents = this.agentPool ? await this.agentPool.getAllAgents() : [];
599
- const active = agents.filter(a => a.status === 'active' || a.mode === 'auto');
600
- const idle = agents.filter(a => a.mode === 'chat' || a.status === 'idle');
601
- const state = this._chatState(msg);
602
- const watch = state?.watchEnabled ? '🔔 On' : '🔕 Off';
603
-
604
- const text = [
605
- '*System Status*',
606
- '',
607
- `Agents: ${agents.length} total, ${active.length} active, ${idle.length} idle`,
608
- `Notifications: ${watch}`,
609
- `Linked chats: ${this.chats.size}`,
610
- ].join('\n');
611
- await this._send(msg.chat.id, this._escapeMarkdown(text));
612
- } catch (error) {
613
- await this._send(msg.chat.id, this._escapeMarkdown(`❌ Error: ${error.message}`));
614
- }
615
- }
616
-
617
- async _cmdAgents(msg, page = 0) {
618
- if (!this._isAuthorized(msg)) return;
619
- try {
620
- const agents = this.agentPool ? await this.agentPool.getAllAgents() : [];
621
- if (agents.length === 0) {
622
- await this._send(msg.chat.id, this._escapeMarkdown('No agents loaded.'));
623
- return;
624
- }
625
- await this._renderAgentsPage(msg.chat.id, agents, page);
626
- } catch (error) {
627
- await this._send(msg.chat.id, this._escapeMarkdown(`❌ Error: ${error.message}`));
628
- }
629
- }
630
-
631
- async _renderAgentsPage(chatId, agents, page) {
632
- const pages = Math.max(1, Math.ceil(agents.length / PAGE_SIZE));
633
- const safePage = Math.min(Math.max(0, page), pages - 1);
634
- const slice = agents.slice(safePage * PAGE_SIZE, (safePage + 1) * PAGE_SIZE);
635
-
636
- let text = `*Agents (${agents.length})* — page ${safePage + 1}/${pages}\n\n`;
637
- const buttons = [];
638
- for (const agent of slice) {
639
- const dot = agent.mode === 'auto' ? '🟢' : agent.status === 'active' ? '🟡' : '⚪';
640
- const mode = agent.mode === 'auto' ? 'autonomous' : 'chat';
641
- text += `${dot} *${this._escapeMarkdown(agent.name)}* — ${mode}\n`;
642
- buttons.push([{ text: agent.name, callback_data: `agent_detail:${agent.id}` }]);
643
- }
644
- if (pages > 1) {
645
- const nav = [];
646
- if (safePage > 0) nav.push({ text: '‹ Prev', callback_data: `agents_page:${safePage - 1}` });
647
- nav.push({ text: `${safePage + 1}/${pages}`, callback_data: 'noop' });
648
- if (safePage < pages - 1) nav.push({ text: 'Next ›', callback_data: `agents_page:${safePage + 1}` });
649
- buttons.push(nav);
650
- }
651
- await this._send(chatId, text, { reply_markup: { inline_keyboard: buttons } });
652
- }
653
-
654
- async _cmdAgentDetail(msg, agentName) {
655
- if (!this._isAuthorized(msg)) return;
656
- await this._showAgentDetail(msg.chat.id, agentName);
657
- }
658
-
659
- async _showAgentDetail(chatId, agentNameOrId) {
660
- try {
661
- const agents = this.agentPool ? await this.agentPool.getAllAgents() : [];
662
- const agent = agents.find(a =>
663
- a.name.toLowerCase() === agentNameOrId.toLowerCase() ||
664
- a.id === agentNameOrId
665
- );
666
- if (!agent) {
667
- await this._send(chatId, this._escapeMarkdown(`Agent "${agentNameOrId}" not found.`));
668
- return;
669
- }
670
- const status = agent.mode === 'auto' ? '🟢 Autonomous' : '🟡 Chat';
671
- let text = `*${this._escapeMarkdown(agent.name)}*\n\n`;
672
- text += `Status: ${status}\n`;
673
- text += `Model: \`${this._escapeMarkdown(agent.currentModel || 'unknown')}\`\n`;
674
- if (agent.lastActivity) {
675
- text += `Last active: ${new Date(agent.lastActivity).toLocaleTimeString()}\n`;
676
- }
677
- const buttons = [[
678
- { text: '💬 Send Message', callback_data: `msg_agent:${agent.id}` },
679
- { text: '⏹ Stop', callback_data: `stop_agent:${agent.id}` },
680
- ]];
681
- await this._send(chatId, text, { reply_markup: { inline_keyboard: buttons } });
682
- } catch (error) {
683
- await this._send(chatId, this._escapeMarkdown(`❌ Error: ${error.message}`));
684
- }
685
- }
686
-
687
- async _cmdFlows(msg, page = 0) {
688
- if (!this._isAuthorized(msg)) return;
689
- try {
690
- if (!this.orchestrator?.stateManager) {
691
- await this._send(msg.chat.id, this._escapeMarkdown('Flows not available.'));
692
- return;
693
- }
694
- const projectDir = this.orchestrator.config?.project?.directory || process.cwd();
695
- const flowIndex = await this.orchestrator.stateManager.loadFlowIndex?.(projectDir) || {};
696
- const flows = Object.entries(flowIndex);
697
- if (flows.length === 0) {
698
- await this._send(msg.chat.id, this._escapeMarkdown('No flows defined.'));
699
- return;
700
- }
701
- await this._renderFlowsPage(msg.chat.id, flows, page);
702
- } catch (error) {
703
- await this._send(msg.chat.id, this._escapeMarkdown(`❌ Error: ${error.message}`));
704
- }
705
- }
706
-
707
- async _renderFlowsPage(chatId, flows, page) {
708
- const pages = Math.max(1, Math.ceil(flows.length / PAGE_SIZE));
709
- const safePage = Math.min(Math.max(0, page), pages - 1);
710
- const slice = flows.slice(safePage * PAGE_SIZE, (safePage + 1) * PAGE_SIZE);
711
- let text = `*Flows (${flows.length})* — page ${safePage + 1}/${pages}\n\n`;
712
- const buttons = [];
713
- for (const [id, flow] of slice) {
714
- text += `📋 *${this._escapeMarkdown(flow.name || id)}*\n`;
715
- buttons.push([{ text: `▶️ Run ${flow.name || id}`, callback_data: `run_flow:${id}` }]);
716
- }
717
- if (pages > 1) {
718
- const nav = [];
719
- if (safePage > 0) nav.push({ text: '‹ Prev', callback_data: `flows_page:${safePage - 1}` });
720
- nav.push({ text: `${safePage + 1}/${pages}`, callback_data: 'noop' });
721
- if (safePage < pages - 1) nav.push({ text: 'Next ›', callback_data: `flows_page:${safePage + 1}` });
722
- buttons.push(nav);
723
- }
724
- await this._send(chatId, text, { reply_markup: { inline_keyboard: buttons } });
725
- }
726
-
727
- async _cmdRunFlow(msg, flowName) {
728
- if (!this._isAuthorized(msg)) return;
729
- await this._runFlow(msg.chat.id, flowName);
730
- }
731
-
732
- async _runFlow(chatId, flowNameOrId) {
733
- try {
734
- if (!this.flowExecutor) {
735
- await this._send(chatId, this._escapeMarkdown('Flow executor not available.'));
736
- return;
737
- }
738
- await this._send(chatId, this._escapeMarkdown(`▶️ Starting flow: ${flowNameOrId}...`));
739
- const projectDir = this.orchestrator?.config?.project?.directory || process.cwd();
740
- await this.flowExecutor.executeFlow(flowNameOrId, { projectDir });
741
- } catch (error) {
742
- await this._send(chatId, this._escapeMarkdown(`❌ Flow error: ${error.message}`));
743
- }
744
- }
745
-
746
- async _cmdStopAgent(msg, agentName) {
747
- if (!this._isAuthorized(msg)) return;
748
- try {
749
- const agents = this.agentPool ? await this.agentPool.getAllAgents() : [];
750
- const agent = agents.find(a => a.name.toLowerCase() === agentName.toLowerCase());
751
- if (!agent) {
752
- await this._send(msg.chat.id, this._escapeMarkdown(`Agent "${agentName}" not found.`));
753
- return;
754
- }
755
- if (this.orchestrator?.messageProcessor) {
756
- await this.orchestrator.messageProcessor.stopAutonomousExecution(agent.id);
757
- }
758
- await this._send(msg.chat.id, this._escapeMarkdown(`⏹ Stopped ${agent.name}`));
759
- } catch (error) {
760
- await this._send(msg.chat.id, this._escapeMarkdown(`❌ Error: ${error.message}`));
761
- }
762
- }
763
-
764
- async _cmdFollowing(msg) {
765
- if (!this._isAuthorized(msg)) return;
766
- const state = this._chatState(msg);
767
- if (!state || state.activeAgentIds.size === 0) {
768
- await this._send(msg.chat.id, this._escapeMarkdown('Not following any agents in this chat. Send @agent-name to start.'));
769
- return;
770
- }
771
- const agents = this.agentPool ? await this.agentPool.getAllAgents() : [];
772
- let text = `*Following ${state.activeAgentIds.size} agent\\(s\\):*\n\n`;
773
- const buttons = [];
774
- for (const id of state.activeAgentIds) {
775
- const agent = agents.find(a => a.id === id);
776
- const name = agent?.name || id;
777
- const isLast = id === state.lastAgentId;
778
- text += `${isLast ? '💬' : '👁'} *${this._escapeMarkdown(name)}*${isLast ? ' \\(active\\)' : ''}\n`;
779
- buttons.push([{ text: `❌ Unfollow ${name}`, callback_data: `unfollow:${id}` }]);
780
- }
781
- text += '\n_Active = default for messages without @prefix_';
782
- await this._send(msg.chat.id, text, { reply_markup: { inline_keyboard: buttons } });
783
- }
784
-
785
- async _cmdUnfollow(msg, agentName) {
786
- if (!this._isAuthorized(msg)) return;
787
- const state = this._chatState(msg);
788
- const agents = this.agentPool ? await this.agentPool.getAllAgents() : [];
789
- const agent = agents.find(a => a.name.toLowerCase() === agentName.toLowerCase());
790
- if (!agent || !state.activeAgentIds.has(agent.id)) {
791
- await this._send(msg.chat.id, this._escapeMarkdown(`Not following "${agentName}".`));
792
- return;
793
- }
794
- state.activeAgentIds.delete(agent.id);
795
- if (state.lastAgentId === agent.id) {
796
- state.lastAgentId = state.activeAgentIds.size > 0
797
- ? [...state.activeAgentIds][state.activeAgentIds.size - 1]
798
- : null;
799
- }
800
- await this._saveConfig();
801
- await this._send(msg.chat.id, this._escapeMarkdown(`Unfollowed ${agent.name}.`));
802
- }
803
-
804
- async _cmdWatch(msg) {
805
- if (!this._isAuthorized(msg)) return;
806
- const state = this._chatState(msg);
807
- state.watchEnabled = true;
808
- await this._saveConfig();
809
- await this._send(msg.chat.id, this._escapeMarkdown('🔔 Notifications enabled. You\'ll receive alerts for completions and prompts.'));
810
- }
811
-
812
- async _cmdUnwatch(msg) {
813
- if (!this._isAuthorized(msg)) return;
814
- const state = this._chatState(msg);
815
- state.watchEnabled = false;
816
- await this._saveConfig();
817
- await this._send(msg.chat.id, this._escapeMarkdown('🔕 Notifications disabled. (Errors and timeouts are still relayed.)'));
818
- }
819
-
820
- async _cmdWatching(msg) {
821
- if (!this._isAuthorized(msg)) return;
822
- const state = this._chatState(msg);
823
- await this._send(msg.chat.id, this._escapeMarkdown(state.watchEnabled
824
- ? '🔔 Notifications are ON. Use /unwatch to disable.'
825
- : '🔕 Notifications are OFF. Use /watch to enable.'
826
- ));
827
- }
828
-
829
- async _cmdReset(msg) {
830
- if (!this._isAuthorized(msg)) return;
831
- const state = this._chatState(msg);
832
- if (!state.lastAgentId) {
833
- await this._send(msg.chat.id, this._escapeMarkdown('No active agent to reset. Use /agents to pick one.'));
834
- return;
835
- }
836
- const agentId = state.lastAgentId;
837
- const agent = this.agentPool ? await this.agentPool.getAgent(agentId) : null;
838
- const name = agent?.name || agentId;
839
- try {
840
- // Reset agent conversation if the orchestrator exposes it.
841
- if (this.orchestrator?.messageProcessor?.resetAgentConversation) {
842
- await this.orchestrator.messageProcessor.resetAgentConversation(agentId);
843
- } else if (this.agentPool?.clearAgentMessages) {
844
- await this.agentPool.clearAgentMessages(agentId);
845
- }
846
- await this._send(msg.chat.id, this._escapeMarkdown(`🔄 Cleared conversation with ${name}.`));
847
- } catch (error) {
848
- await this._send(msg.chat.id, this._escapeMarkdown(`❌ Reset failed: ${error.message}`));
849
- }
850
- }
851
-
852
- async _cmdNew(msg, agentName) {
853
- if (!this._isAuthorized(msg)) return;
854
- const state = this._chatState(msg);
855
- const agents = this.agentPool ? await this.agentPool.getAllAgents() : [];
856
- const agent = agents.find(a => a.name.toLowerCase() === agentName.toLowerCase());
857
- if (!agent) {
858
- await this._send(msg.chat.id, this._escapeMarkdown(`Agent "${agentName}" not found.`));
859
- return;
860
- }
861
- state.lastAgentId = agent.id;
862
- state.activeAgentIds.add(agent.id);
863
- // Reset their context.
864
- try {
865
- if (this.orchestrator?.messageProcessor?.resetAgentConversation) {
866
- await this.orchestrator.messageProcessor.resetAgentConversation(agent.id);
867
- } else if (this.agentPool?.clearAgentMessages) {
868
- await this.agentPool.clearAgentMessages(agent.id);
869
- }
870
- } catch { /* non-fatal */ }
871
- await this._saveConfig();
872
- await this._send(msg.chat.id, this._escapeMarkdown(`✨ Switched to ${agent.name} with a fresh context.`));
873
- }
874
-
875
- async _cmdLogout(msg) {
876
- const chatId = String(msg.chat.id);
877
- if (!this.chats.has(chatId)) {
878
- await this._send(chatId, this._escapeMarkdown('Not registered.'));
879
- return;
880
- }
881
- this.chats.delete(chatId);
882
- await this._saveConfig();
883
- await this._send(chatId, this._escapeMarkdown('👋 This chat has been disconnected from Loxia. /start to re-link.'));
884
- }
885
-
886
- _chatState(msg) {
887
- return this.chats.get(String(msg?.chat?.id));
888
- }
889
-
890
- // ── Agent Conversation ─────────────────────────────────────────────
891
-
892
- async _handleTextMessage(msg) {
893
- if (!this._isAuthorized(msg)) return;
894
- const state = this._chatState(msg);
895
- if (!state) return;
896
-
897
- // Pending prompt reply check uses chat-local context.
898
- if (this.replyContext.has(String(msg.chat.id))) {
899
- await this._handlePromptReply(msg);
900
- return;
901
- }
902
-
903
- // Group chats: require @mention.
904
- const { addressed, text } = this._stripMentionOrSkip(msg);
905
- if (!addressed) return;
906
- if (!text) return;
907
-
908
- await this._dispatchToAgent(msg, text);
909
- }
910
-
911
- async _handleVoiceMessage(msg) {
912
- if (!this._isAuthorized(msg)) return;
913
- const state = this._chatState(msg);
914
- if (!state) return;
915
-
916
- // Groups: skip voice unless the chat has had at least one @mention
917
- // (we can't transcribe-and-then-check; too costly to transcribe
918
- // every voice). For now, ignore voice in groups entirely.
919
- if (msg.chat.type !== 'private') return;
920
-
921
- try {
922
- await this.bot.sendChatAction(msg.chat.id, 'typing');
923
- } catch { /* ignore */ }
924
-
925
- let transcribed;
926
- try {
927
- transcribed = await this._transcribeVoice(msg.voice, msg);
928
- } catch (err) {
929
- // Log at ERROR level (not warn) so operators see the failure
930
- // in the local server console. Then surface a friendly but
931
- // diagnostic message to the user — include the upstream status
932
- // / code when present so the cause is visible without server
933
- // log access.
934
- this.logger?.error?.('[TelegramService] voice transcription failed', {
935
- error: err.message, code: err.code, status: err.status,
936
- });
937
- let userHint;
938
- if (err.code === 'not_configured') {
939
- userHint = 'Voice transcription needs an Azure speech-to-text deployment (e.g. `gpt-4o-mini-transcribe`). Please type your message for now.';
940
- } else if (err.status === 401 || err.status === 403) {
941
- userHint = 'Your account isn\'t authorised to call the transcription endpoint. Sign out and back in, then try again.';
942
- } else if (err.status === 502 || err.status === 503) {
943
- userHint = 'The transcription service is temporarily unavailable. Please try again in a moment.';
944
- } else {
945
- // Surface the upstream message itself so subtle bugs (audio
946
- // format, file size, etc.) self-diagnose. Truncated to keep
947
- // the Telegram bubble readable.
948
- const tail = (err.message || 'unknown error').slice(0, 200);
949
- userHint = `Please try again, or type your message. (debug: ${tail})`;
950
- }
951
- await this._send(msg.chat.id, this._escapeMarkdown(`🎤 Couldn't transcribe that voice note. ${userHint}`));
952
- return;
953
- }
954
-
955
- if (!transcribed) {
956
- await this._send(msg.chat.id, this._escapeMarkdown('🎤 Voice note was empty. Please try again or type your message.'));
957
- return;
958
- }
959
-
960
- // Show the user what we heard, then act on it as if they had typed.
961
- // `_Heard:_` is MarkdownV2 italic markup — must stay UNESCAPED, or
962
- // the user just sees literal underscores. Only the transcribed
963
- // body gets escaped, since it can contain arbitrary characters
964
- // that would otherwise break the parse_mode.
965
- await this._send(msg.chat.id, `🎤 _Heard:_ ${this._escapeMarkdown(transcribed)}`);
966
- await this._dispatchToAgent(msg, transcribed);
967
- }
968
-
969
- /**
970
- * Common path: resolve target agent and submit to the orchestrator.
971
- * Emits a typing indicator + a "🔄 Thinking…" placeholder which the
972
- * broadcast handler will edit with the real reply.
973
- */
974
- async _dispatchToAgent(msg, text) {
975
- const state = this._chatState(msg);
976
-
977
- // Parse @agent-name prefix. Supports names with spaces by doing
978
- // longest-prefix matching against the live agent list — see
979
- // _resolveAtPrefix() for the contract.
980
- const agents = this.agentPool ? await this.agentPool.getAllAgents() : [];
981
- const parsed = this._resolveAtPrefix(text, agents);
982
- const agentName = parsed.agentName; // null when message has no @prefix
983
- const messageText = parsed.messageText;
984
-
985
- // Resolve agent.
986
- let targetAgent = null;
987
- if (agentName) {
988
- targetAgent = parsed.matchedAgent || null;
989
- if (!targetAgent) {
990
- await this._send(msg.chat.id, this._escapeMarkdown(
991
- `❌ Agent "${agentName}" not found. Use /agents to see available agents. ` +
992
- 'Tip: names with spaces can be written either as `@John Smith` or `@John_Smith`.'
993
- ));
994
- return;
995
- }
996
- state.lastAgentId = targetAgent.id;
997
- state.activeAgentIds.add(targetAgent.id);
998
- await this._saveConfig();
999
- } else if (state.lastAgentId) {
1000
- targetAgent = this.agentPool ? await this.agentPool.getAgent(state.lastAgentId) : null;
1001
- if (!targetAgent) {
1002
- await this._send(msg.chat.id, this._escapeMarkdown('No agent selected. Use @agent-name message to address one.'));
1003
- return;
1004
- }
1005
- } else {
1006
- await this._send(msg.chat.id, this._escapeMarkdown('No agent selected. Use @agent-name message to address one.'));
1007
- return;
1008
- }
1009
-
1010
- // Begin typing + thinking placeholder. Saved so the broadcast
1011
- // handler can edit it with the agent's actual reply.
1012
- try { await this.bot.sendChatAction(msg.chat.id, 'typing'); } catch { /* ignore */ }
1013
- try {
1014
- const placeholder = await this.bot.sendMessage(
1015
- msg.chat.id,
1016
- `🔄 *${this._escapeMarkdown(targetAgent.name)}* is thinking…`,
1017
- { parse_mode: 'MarkdownV2' }
1018
- );
1019
- state.thinkingByAgentId.set(targetAgent.id, placeholder.message_id);
1020
- } catch (err) {
1021
- this.logger?.debug?.('[TelegramService] thinking placeholder send failed', { error: err.message });
1022
- }
1023
-
1024
- // Dispatch to orchestrator.
1025
- try {
1026
- if (this.orchestrator) {
1027
- const sessionId = `telegram-${msg.chat.id}`;
1028
- const source = createTelegramSource(msg);
1029
- await this.orchestrator.processRequest({
1030
- interface: 'telegram',
1031
- sessionId,
1032
- action: 'send_message',
1033
- payload: {
1034
- agentId: targetAgent.id,
1035
- message: messageText,
1036
- streamingEnabled: false,
1037
- source,
1038
- },
1039
- projectDir: this.orchestrator.config?.project?.directory || process.cwd(),
1040
- });
1041
- }
1042
- } catch (error) {
1043
- // Clear the placeholder, surface the failure.
1044
- await this._clearThinking(msg.chat.id, targetAgent.id);
1045
- await this._send(msg.chat.id, this._escapeMarkdown(`❌ Failed to send: ${error.message}`));
1046
- }
1047
- }
1048
-
1049
- async _clearThinking(chatId, agentId) {
1050
- const state = this.chats.get(String(chatId));
1051
- if (!state) return;
1052
- const messageId = state.thinkingByAgentId.get(agentId);
1053
- if (!messageId) return;
1054
- state.thinkingByAgentId.delete(agentId);
1055
- try {
1056
- await this.bot.deleteMessage(chatId, messageId);
1057
- } catch { /* if user deleted it, that's fine */ }
1058
- }
1059
-
1060
- // ── Voice transcription (via backend) ──────────────────────────────
1061
-
1062
- async _transcribeVoice(voice, msg = null) {
1063
- if (!voice?.file_id) throw Object.assign(new Error('no file_id'), { code: 'bad_request' });
1064
- const platformKey = this._resolvePlatformKey();
1065
- if (!platformKey) {
1066
- throw Object.assign(new Error('platform key not set'), { code: 'not_configured' });
1067
- }
1068
- // 1) Get the file URL from Telegram + download bytes.
1069
- const fileLink = await this.bot.getFileLink(voice.file_id);
1070
- const audioRes = await fetch(fileLink);
1071
- if (!audioRes.ok) throw new Error(`audio download failed: ${audioRes.status}`);
1072
- const audioBuf = Buffer.from(await audioRes.arrayBuffer());
1073
- const audioBase64 = audioBuf.toString('base64');
1074
-
1075
- // 2) POST to backend /llm/transcribe. Telegram knows the audio
1076
- // duration upfront (voice.duration); pass it so the backend can
1077
- // price the call correctly even when the model's response shape
1078
- // doesn't include duration (gpt-4o-*-transcribe with json format).
1079
- // The Telegram message id is a natural idempotency key — a retry
1080
- // of the same voice note dedups at the recorder layer.
1081
- const url = `${this.backendBaseUrl}/llm/transcribe`;
1082
- const apiRes = await fetch(url, {
1083
- method: 'POST',
1084
- headers: {
1085
- 'Authorization': `Bearer ${platformKey}`,
1086
- 'Content-Type': 'application/json',
1087
- },
1088
- body: JSON.stringify({
1089
- audioBase64,
1090
- mimeType: voice.mime_type || 'audio/ogg',
1091
- filename: `voice_${Date.now()}.ogg`,
1092
- durationSeconds: typeof voice.duration === 'number' ? voice.duration : undefined,
1093
- idempotencyKey: msg?.message_id
1094
- ? `tg-${msg.chat?.id}-${msg.message_id}`
1095
- : undefined,
1096
- }),
1097
- });
1098
- if (apiRes.status === 503) {
1099
- throw Object.assign(new Error('not configured'), { code: 'not_configured', status: 503 });
1100
- }
1101
- if (!apiRes.ok) {
1102
- const body = await apiRes.text().catch(() => '');
1103
- let parsedMsg = '';
1104
- try { parsedMsg = JSON.parse(body)?.error || ''; } catch { /* not JSON */ }
1105
- const detail = parsedMsg || body.slice(0, 200);
1106
- throw Object.assign(
1107
- new Error(`backend transcribe ${apiRes.status}: ${detail}`),
1108
- { status: apiRes.status }
1109
- );
1110
- }
1111
- const data = await apiRes.json();
1112
- return (data?.text || '').trim();
1113
- }
1114
-
1115
- // ── Callback Query Handler ─────────────────────────────────────────
1116
-
1117
- async _handleCallbackQuery(query) {
1118
- const chatId = String(query.message.chat.id);
1119
- if (!this.chats.has(chatId)) return;
1120
- const state = this.chats.get(chatId);
1121
-
1122
- const data = query.data || '';
1123
- try { await this.bot.answerCallbackQuery(query.id); } catch { /* ignore */ }
1124
-
1125
- if (data === 'noop') return;
1126
-
1127
- if (data.startsWith('agents_page:')) {
1128
- const page = parseInt(data.split(':')[1], 10) || 0;
1129
- const agents = this.agentPool ? await this.agentPool.getAllAgents() : [];
1130
- await this._renderAgentsPage(chatId, agents, page);
1131
- return;
1132
- }
1133
- if (data.startsWith('flows_page:')) {
1134
- const page = parseInt(data.split(':')[1], 10) || 0;
1135
- if (!this.orchestrator?.stateManager) return;
1136
- const projectDir = this.orchestrator.config?.project?.directory || process.cwd();
1137
- const flowIndex = await this.orchestrator.stateManager.loadFlowIndex?.(projectDir) || {};
1138
- await this._renderFlowsPage(chatId, Object.entries(flowIndex), page);
1139
- return;
1140
- }
1141
- if (data.startsWith('agent_detail:')) {
1142
- await this._showAgentDetail(chatId, data.slice('agent_detail:'.length));
1143
- return;
1144
- }
1145
- if (data.startsWith('msg_agent:')) {
1146
- const agentId = data.slice('msg_agent:'.length);
1147
- state.lastAgentId = agentId;
1148
- state.activeAgentIds.add(agentId);
1149
- await this._saveConfig();
1150
- const agent = this.agentPool ? await this.agentPool.getAgent(agentId) : null;
1151
- await this._send(chatId, this._escapeMarkdown(`💬 Now chatting with ${agent?.name || agentId}. Type your message.`));
1152
- return;
1153
- }
1154
- if (data.startsWith('stop_agent:')) {
1155
- const agentId = data.slice('stop_agent:'.length);
1156
- if (this.orchestrator?.messageProcessor) {
1157
- await this.orchestrator.messageProcessor.stopAutonomousExecution(agentId);
1158
- }
1159
- await this._send(chatId, this._escapeMarkdown('⏹ Agent stopped.'));
1160
- return;
1161
- }
1162
- if (data.startsWith('run_flow:')) {
1163
- await this._runFlow(chatId, data.slice('run_flow:'.length));
1164
- return;
1165
- }
1166
- if (data.startsWith('unfollow:')) {
1167
- const agentId = data.slice('unfollow:'.length);
1168
- state.activeAgentIds.delete(agentId);
1169
- if (state.lastAgentId === agentId) {
1170
- state.lastAgentId = state.activeAgentIds.size > 0 ? [...state.activeAgentIds][0] : null;
1171
- }
1172
- await this._saveConfig();
1173
- const agent = this.agentPool ? await this.agentPool.getAgent(agentId) : null;
1174
- await this._send(chatId, this._escapeMarkdown(`Unfollowed ${agent?.name || agentId}.`));
1175
- return;
1176
- }
1177
- if (data.startsWith('prompt_reply:')) {
1178
- const m = data.match(/prompt_reply:(.+):(\d+)/);
1179
- if (m && this.pendingRelays.has(m[1])) {
1180
- await this._submitPromptReply(m[1], m[2]);
1181
- }
1182
- return;
1183
- }
1184
- if (data.startsWith('act:')) {
1185
- // <actions> button tapped — treat value as a user message to the
1186
- // sticky agent. Format: `act:<agentId>:<value>` where value may
1187
- // contain colons; split on first two.
1188
- const idx1 = data.indexOf(':');
1189
- const idx2 = data.indexOf(':', idx1 + 1);
1190
- const agentId = data.slice(idx1 + 1, idx2);
1191
- const value = data.slice(idx2 + 1);
1192
- if (!agentId || !value) return;
1193
-
1194
- // Synthesize a message that mirrors what a real user input would
1195
- // look like in the same handler. Forwarding the tap as a
1196
- // synthetic user turn keeps the agent's transcript honest.
1197
- const synthetic = {
1198
- chat: query.message.chat,
1199
- from: query.from,
1200
- message_id: query.message.message_id,
1201
- text: `@${(await this._agentName(agentId))} ${value}`,
1202
- };
1203
- await this._dispatchToAgent(synthetic, synthetic.text);
1204
- }
1205
- }
1206
-
1207
- async _agentName(agentId) {
1208
- const agent = this.agentPool ? await this.agentPool.getAgent(agentId) : null;
1209
- return agent?.name || agentId;
1210
- }
1211
-
1212
- // ── Broadcast Interceptor ──────────────────────────────────────────
1213
-
1214
- _interceptBroadcasts(wsManager) {
1215
- if (!wsManager || this._originalBroadcast) return;
1216
- const originalBroadcast = wsManager.broadcastToSession.bind(wsManager);
1217
- this._originalBroadcast = originalBroadcast;
1218
- wsManager.broadcastToSession = (sessionId, message) => {
1219
- originalBroadcast(sessionId, message);
1220
- this._handleBroadcastEvent(message);
1221
- };
1222
- }
1223
-
1224
- async _handleBroadcastEvent(message) {
1225
- if (!this.bot || this.chats.size === 0 || this.status !== TELEGRAM_STATUS.CONNECTED) return;
1226
- const type = message?.type;
1227
- if (!type) return;
1228
-
1229
- if (type === 'user_prompt_request') {
1230
- await this._relayPromptRequest(message);
1231
- return;
1232
- }
1233
- if (type === 'credential_request') {
1234
- await this._relayCredentialRequest(message);
1235
- return;
1236
- }
1237
- if (type === 'stream_complete') {
1238
- await this._relayAgentResponse(message);
1239
- return;
1240
- }
1241
- if (type === 'imageGenerated') {
1242
- // Buffer the URL keyed by agentId. The actual send happens when
1243
- // the matching stream_complete arrives (typical case — agent
1244
- // says "here's your image: ..." with the image markdown), or as
1245
- // a standalone send if no text reply ever lands (image-only
1246
- // turn, e.g. an automated render flow).
1247
- this._bufferGeneratedImage(message);
1248
- return;
1249
- }
1250
-
1251
- // Errors and timeouts ALWAYS reach the user. Other completions
1252
- // require /watch.
1253
- const ALWAYS_RELAY = new Set(['agent_error', 'agent_timeout', 'criticalError', 'flow_run_failed']);
1254
- const WATCH_GATED = new Set(['execution_stopped', 'flow_run_completed']);
1255
-
1256
- const headers = {
1257
- agent_error: '⚠️ *Agent Error*',
1258
- agent_timeout: '⏰ *Agent Timeout*',
1259
- criticalError: '🔴 *Critical Error*',
1260
- flow_run_failed: '❌ *Flow Failed*',
1261
- execution_stopped: '✅ *Agent Finished*',
1262
- flow_run_completed: '✅ *Flow Completed*',
1263
- };
1264
- if (!headers[type]) return;
1265
-
1266
- let text = `${headers[type]}\n`;
1267
- if (message.agentName || message.data?.agentName) {
1268
- text += `Agent: \`${message.agentName || message.data?.agentName}\`\n`;
1269
- }
1270
- if (message.message || message.data?.message || message.error) {
1271
- text += `${message.message || message.data?.message || message.error}\n`;
1272
- }
1273
-
1274
- if (ALWAYS_RELAY.has(type)) {
1275
- // Dedup: collapse identical (agent, type, message) bursts within
1276
- // ERROR_DEDUP_WINDOW_MS into a single first-message + an optional
1277
- // "still happening" summary when the window closes.
1278
- const agentId = message.agentId || message.data?.agentId || '_unknown_';
1279
- const errorMsg = message.message || message.data?.message || message.error || '';
1280
- const decision = this._registerErrorBroadcast({
1281
- agentId,
1282
- type,
1283
- errorMsg,
1284
- agentName: message.agentName || message.data?.agentName,
1285
- });
1286
- if (decision.shouldEmit) {
1287
- this._fanoutNotification(text);
1288
- }
1289
- // else: suppressed; counter incremented; the summary timer will
1290
- // fire at the end of the window if count > 1.
1291
- } else if (WATCH_GATED.has(type)) {
1292
- for (const [chatId, state] of this.chats.entries()) {
1293
- if (state.watchEnabled) this._queueNotification(chatId, text);
1294
- }
1295
- }
1296
- }
1297
-
1298
- /**
1299
- * Register an error broadcast for dedup tracking.
1300
- *
1301
- * First occurrence in window → returns { shouldEmit: true }, schedules a
1302
- * close-of-window summary if more arrive.
1303
- * Subsequent occurrences → returns { shouldEmit: false }, increments
1304
- * the counter so the summary knows the magnitude.
1305
- *
1306
- * The dedup key normalises the error message (lower-case, whitespace-
1307
- * collapsed, length-capped) so trivial textual variations of the same
1308
- * underlying failure still coalesce.
1309
- *
1310
- * @param {{agentId: string, type: string, errorMsg: string, agentName?: string}} args
1311
- * @returns {{ shouldEmit: boolean }}
1312
- * @private
1313
- */
1314
- _registerErrorBroadcast({ agentId, type, errorMsg, agentName }) {
1315
- // Plain string ops, no regex — collapse whitespace then truncate.
1316
- const normalised = _collapseWhitespace(String(errorMsg || '').toLowerCase()).slice(0, 200);
1317
- const key = `${agentId}:${type}:${normalised}`;
1318
-
1319
- const existing = this._errorBroadcastDedup.get(key);
1320
- if (existing) {
1321
- existing.count++;
1322
- existing.lastAt = Date.now();
1323
- if (agentName) existing.agentName = agentName;
1324
- return { shouldEmit: false };
1325
- }
1326
-
1327
- // Capacity guard — evict the oldest entry if we're at the cap.
1328
- if (this._errorBroadcastDedup.size >= ERROR_DEDUP_MAX_KEYS) {
1329
- const oldestKey = this._errorBroadcastDedup.keys().next().value;
1330
- const oldest = this._errorBroadcastDedup.get(oldestKey);
1331
- if (oldest?.timer) clearTimeout(oldest.timer);
1332
- this._errorBroadcastDedup.delete(oldestKey);
1333
- }
1334
-
1335
- const now = Date.now();
1336
- const timer = setTimeout(() => {
1337
- const entry = this._errorBroadcastDedup.get(key);
1338
- this._errorBroadcastDedup.delete(key);
1339
- if (entry && entry.count > 1) {
1340
- // Emit a brief "still happening" summary. We don't replay the
1341
- // full error text — the user already has the first message;
1342
- // this just tells them it kept happening so they know it's not
1343
- // a one-off.
1344
- const extra = entry.count - 1;
1345
- const who = entry.agentName ? `\\\`${entry.agentName}\\\` ` : '';
1346
- const summary = `⚠️ *Agent Error · still happening*\nAgent: ${who}\n${extra} more similar error${extra === 1 ? '' : 's'} in the last ${Math.round(ERROR_DEDUP_WINDOW_MS / 60000)} min.`;
1347
- this._fanoutNotification(summary).catch(() => {});
1348
- }
1349
- }, ERROR_DEDUP_WINDOW_MS);
1350
- // Don't keep the event loop alive for a notification timer.
1351
- if (typeof timer.unref === 'function') timer.unref();
1352
-
1353
- this._errorBroadcastDedup.set(key, {
1354
- count: 1,
1355
- firstAt: now,
1356
- lastAt: now,
1357
- agentName,
1358
- timer,
1359
- });
1360
- return { shouldEmit: true };
1361
- }
1362
-
1363
- /** Send `text` to every linked chat regardless of /watch state. */
1364
- async _fanoutNotification(text) {
1365
- for (const chatId of this.chats.keys()) {
1366
- try { await this._send(chatId, text); } catch { /* per-chat failures shouldn't block others */ }
1367
- }
1368
- }
1369
-
1370
- async _relayAgentResponse(message) {
1371
- const agentId = message.agentId || message.data?.agentId;
1372
- if (!agentId) return;
1373
-
1374
- const content = message.content || message.data?.content ||
1375
- message.message?.content || message.data?.message?.content;
1376
-
1377
- // Skip user/tool roles.
1378
- const role = message.role || message.data?.role || message.message?.role;
1379
- if (role === 'user' || role === 'tool') return;
1380
-
1381
- // Drain any image buffered by the imageTool. We do this even when
1382
- // there's no text content — image-only completions still need to
1383
- // reach Telegram. We do it for every active chat with this agent.
1384
- const pendingImages = this._consumePendingImages(agentId);
1385
-
1386
- const { blocks } = content ? filterContentForExternalRelay(content) : { blocks: [] };
1387
-
1388
- if (blocks.length === 0) {
1389
- // No <external> text block. Clear thinking placeholders so the
1390
- // user isn't left looking at "🔄 …", and — critically — still
1391
- // ship any buffered images for this agent so "draw me a sunset"
1392
- // through Telegram doesn't go silent when the agent's text
1393
- // reply omits the image markdown.
1394
- for (const [chatId, state] of this.chats.entries()) {
1395
- await this._clearThinking(chatId, agentId);
1396
- if (pendingImages.length > 0 && state.activeAgentIds.has(agentId)) {
1397
- for (const img of pendingImages) {
1398
- await this._sendMedia(chatId, { kind: 'image', src: img.imageUrl, caption: img.prompt || undefined });
1399
- }
1400
- }
1401
- }
1402
- return;
1403
- }
1404
-
1405
- const agent = this.agentPool ? await this.agentPool.getAgent(agentId) : null;
1406
- const agentName = agent?.name || agentId;
1407
-
1408
- // For each chat where this agent is active, send the block(s).
1409
- for (const [chatId, state] of this.chats.entries()) {
1410
- if (!state.activeAgentIds.has(agentId)) continue;
1411
-
1412
- const ownedAliases = this._aliasesForChat(chatId);
1413
- let firstBlock = true;
1414
-
1415
- for (const block of blocks) {
1416
- const targets = resolveBlockTargets(block, ownedAliases);
1417
- if (targets.length === 0) continue;
1418
-
1419
- try {
1420
- const parsed = parseTelegramBlock(block.text);
1421
-
1422
- // Edit the thinking placeholder on the first block; subsequent
1423
- // blocks go as new messages so users still see them as separate
1424
- // turns when an agent emits multiple <external> blocks.
1425
- if (firstBlock) {
1426
- await this._replaceThinkingWithReply(chatId, agentId, agentName, parsed);
1427
- firstBlock = false;
1428
- } else {
1429
- await this._sendParsedPayload(chatId, agentName, parsed);
1430
- }
1431
-
1432
- // Media is sent after the text body in either path.
1433
- for (const media of parsed.media) {
1434
- await this._sendMedia(chatId, media);
1435
- }
1436
- } catch (error) {
1437
- this.logger?.warn?.('[TelegramService] block relay failed', {
1438
- chatId, agentId, error: error.message,
1439
- });
1440
- }
1441
- }
1442
-
1443
- // After the text blocks land, ship any imageTool-generated
1444
- // images that aren't already embedded in a parsed block. We
1445
- // dedupe by URL against the block parser's media list so we
1446
- // don't double-post an image the agent already referenced
1447
- // inline.
1448
- if (pendingImages.length > 0) {
1449
- const embeddedUrls = new Set();
1450
- for (const block of blocks) {
1451
- try {
1452
- for (const m of parseTelegramBlock(block.text).media) {
1453
- if (m?.src) embeddedUrls.add(m.src);
1454
- }
1455
- } catch { /* parser failures already logged above */ }
1456
- }
1457
- for (const img of pendingImages) {
1458
- if (embeddedUrls.has(img.imageUrl)) continue;
1459
- await this._sendMedia(chatId, { kind: 'image', src: img.imageUrl, caption: img.prompt || undefined });
1460
- }
1461
- }
1462
- }
1463
- }
1464
-
1465
- /**
1466
- * Stash an `imageGenerated` broadcast for later flush by
1467
- * `_relayAgentResponse`. Prunes stale entries (>5min) on insert so
1468
- * the buffer can't grow unboundedly if no stream_complete ever
1469
- * arrives for that agent.
1470
- */
1471
- _bufferGeneratedImage(message) {
1472
- const agentId = message.agentId || message.data?.agentId;
1473
- const imageUrl = message.imageUrl || message.data?.imageUrl;
1474
- if (!agentId || !imageUrl) return;
1475
- const prompt = message.prompt || message.data?.prompt || null;
1476
-
1477
- const arr = this._pendingImagesByAgent.get(agentId) || [];
1478
- const now = Date.now();
1479
- // Drop stale entries while we're touching this list.
1480
- const fresh = arr.filter(e => now - e.addedAt < IMAGE_PENDING_TTL_MS);
1481
- fresh.push({ imageUrl, prompt, addedAt: now });
1482
- this._pendingImagesByAgent.set(agentId, fresh);
1483
- }
1484
-
1485
- /** Atomically read-and-clear pending images for `agentId`. */
1486
- _consumePendingImages(agentId) {
1487
- const arr = this._pendingImagesByAgent.get(agentId);
1488
- if (!arr || arr.length === 0) return [];
1489
- this._pendingImagesByAgent.delete(agentId);
1490
- const now = Date.now();
1491
- return arr.filter(e => now - e.addedAt < IMAGE_PENDING_TTL_MS);
1492
- }
1493
-
1494
- _aliasesForChat(chatId) {
1495
- // Today we advertise a single chat-scoped alias; expanding later
1496
- // to per-channel aliases is a one-line change.
1497
- return ['telegram', `telegram:chat-${chatId}`];
1498
- }
1499
-
1500
- /**
1501
- * Edit the in-flight thinking placeholder to show the agent's real
1502
- * reply. Falls back to a fresh message if editing fails (e.g. the
1503
- * placeholder was deleted by the user).
1504
- */
1505
- async _replaceThinkingWithReply(chatId, agentId, agentName, parsed) {
1506
- const state = this.chats.get(String(chatId));
1507
- const placeholderId = state?.thinkingByAgentId.get(agentId);
1508
- state?.thinkingByAgentId.delete(agentId);
1509
-
1510
- const header = `*${this._escapeMarkdown(agentName)}:*\n\n`;
1511
- const formatted = this._formatAgentBody(parsed.text);
1512
- const replyMarkup = this._buildActionKeyboard(agentId, parsed.actions);
1513
-
1514
- if (formatted.kind === 'document') {
1515
- // Code-heavy reply: drop the placeholder, send the document.
1516
- if (placeholderId) {
1517
- try { await this.bot.deleteMessage(chatId, placeholderId); } catch {}
1518
- }
1519
- await this._sendCodeDocument(chatId, agentName, formatted.body);
1520
- return;
1521
- }
1522
-
1523
- const fullText = header + formatted.body;
1524
- const sendOpts = { parse_mode: formatted.parseMode };
1525
- if (replyMarkup) sendOpts.reply_markup = replyMarkup;
1526
-
1527
- if (placeholderId) {
1528
- try {
1529
- await this.bot.editMessageText(fullText, {
1530
- chat_id: chatId,
1531
- message_id: placeholderId,
1532
- ...sendOpts,
1533
- });
1534
- return;
1535
- } catch (err) {
1536
- this.logger?.debug?.('[TelegramService] edit placeholder failed, sending fresh', { error: err.message });
1537
- }
1538
- }
1539
- await this._sendRaw(chatId, fullText, sendOpts);
1540
- }
1541
-
1542
- /** Send a parsed payload (additional blocks after the first). */
1543
- async _sendParsedPayload(chatId, agentName, parsed) {
1544
- const header = `*${this._escapeMarkdown(agentName)}:*\n\n`;
1545
- const formatted = this._formatAgentBody(parsed.text);
1546
- if (formatted.kind === 'document') {
1547
- await this._sendCodeDocument(chatId, agentName, formatted.body);
1548
- return;
1549
- }
1550
- const replyMarkup = this._buildActionKeyboard(null, parsed.actions);
1551
- const opts = { parse_mode: formatted.parseMode };
1552
- if (replyMarkup) opts.reply_markup = replyMarkup;
1553
- await this._sendRaw(chatId, header + formatted.body, opts);
1554
- }
1555
-
1556
- _buildActionKeyboard(agentId, actions) {
1557
- if (!Array.isArray(actions) || actions.length === 0) return null;
1558
- if (!agentId) return null; // can't dispatch without an agent
1559
- return {
1560
- inline_keyboard: actions.map(a => ([{
1561
- text: a.label,
1562
- // Telegram caps callback_data at 64 bytes. The shape is
1563
- // `act:<agentId>:<value>` so the value is what's first to be
1564
- // truncated. agentId is typically a UUID (36 chars) + 'act:'
1565
- // prefix (4 chars) leaves 24 chars for the value.
1566
- callback_data: `act:${agentId}:${a.value}`.slice(0, 64),
1567
- }])),
1568
- };
1569
- }
1570
-
1571
- // ── Text formatting: HTML for code blocks, MarkdownV2 otherwise ────
1572
-
1573
- _formatAgentBody(body) {
1574
- if (!body || typeof body !== 'string' || body.trim() === '') {
1575
- return { kind: 'text', parseMode: 'MarkdownV2', body: this._escapeMarkdown('(empty response)') };
1576
- }
1577
-
1578
- const fenceCount = (body.match(/```/g) || []).length;
1579
- const hasFencedCode = fenceCount >= 2;
1580
- const isCodeHeavy = hasFencedCode && body.length > CODE_AS_FILE_THRESHOLD;
1581
-
1582
- if (isCodeHeavy) {
1583
- // Send as document — guarantees no markdown mangling for big payloads.
1584
- return { kind: 'document', body };
1585
- }
1586
-
1587
- if (hasFencedCode) {
1588
- // HTML mode is far more forgiving than MarkdownV2 for mixed-code text.
1589
- // Convert fenced blocks to <pre><code>…</code></pre>.
1590
- const htmlBody = body.replace(/```(\w+)?\n([\s\S]*?)```/g, (_full, _lang, code) => {
1591
- const escaped = String(code)
1592
- .replace(/&/g, '&amp;')
1593
- .replace(/</g, '&lt;')
1594
- .replace(/>/g, '&gt;');
1595
- return `<pre>${escaped}</pre>`;
1596
- });
1597
- // Inline-code backticks too.
1598
- const withInline = htmlBody.replace(/`([^`\n]+)`/g, (_m, c) => {
1599
- const esc = String(c).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
1600
- return `<code>${esc}</code>`;
1601
- });
1602
- // Escape everything else that HTML mode cares about (only <, >, &
1603
- // outside the tags). Easiest: split by <pre>/<code> tags and
1604
- // escape non-code parts.
1605
- const safe = this._escapeHtmlPreservingTags(withInline);
1606
- const truncated = safe.length > MAX_MESSAGE_LENGTH
1607
- ? safe.slice(0, MAX_MESSAGE_LENGTH - 100) + '\n\n… (truncated)'
1608
- : safe;
1609
- return { kind: 'text', parseMode: 'HTML', body: truncated };
1610
- }
1611
-
1612
- // Plain text: full MarkdownV2 escape.
1613
- const escaped = this._escapeMarkdown(body);
1614
- const truncated = escaped.length > MAX_MESSAGE_LENGTH
1615
- ? escaped.slice(0, MAX_MESSAGE_LENGTH - 100) + '\n\n… (truncated)'
1616
- : escaped;
1617
- return { kind: 'text', parseMode: 'MarkdownV2', body: truncated };
1618
- }
1619
-
1620
- _escapeHtmlPreservingTags(text) {
1621
- // Split into segments alternating between tag-segments and text.
1622
- // We only allow <pre>/<code>; anything else gets escaped.
1623
- const parts = text.split(/(<\/?(?:pre|code)>)/g);
1624
- return parts.map((p) => {
1625
- if (/^<\/?(pre|code)>$/i.test(p)) return p;
1626
- return p.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
1627
- }).join('');
1628
- }
1629
-
1630
- async _sendCodeDocument(chatId, agentName, body) {
1631
- if (!this.bot) return;
1632
- try {
1633
- const filename = `${(agentName || 'reply').replace(/[^A-Za-z0-9-]/g, '_')}-${Date.now()}.txt`;
1634
- // node-telegram-bot-api accepts a Buffer for sendDocument with
1635
- // options.filename for the displayed name.
1636
- await this.bot.sendDocument(
1637
- chatId,
1638
- Buffer.from(body, 'utf8'),
1639
- { caption: `*${this._escapeMarkdown(agentName)}* — full reply (sent as a file because it\\\'s code\\-heavy).`, parse_mode: 'MarkdownV2' },
1640
- { filename, contentType: 'text/plain' }
1641
- );
1642
- } catch (error) {
1643
- this.logger?.warn?.('[TelegramService] code-document send failed, falling back to truncated text', { error: error.message });
1644
- const fallback = body.slice(0, MAX_MESSAGE_LENGTH - 200) + '\n\n… (truncated; see local web UI for full reply)';
1645
- await this._sendRaw(chatId, `*${this._escapeMarkdown(agentName)}:*\n\n` + this._escapeMarkdown(fallback), { parse_mode: 'MarkdownV2' });
1646
- }
1647
- }
1648
-
1649
- async _sendMedia(chatId, media) {
1650
- if (!this.bot) return;
1651
- try {
1652
- const captionMd = media.caption ? this._escapeMarkdown(media.caption) : undefined;
1653
- if (media.kind === 'image') {
1654
- await this.bot.sendPhoto(chatId, media.src, {
1655
- caption: captionMd, parse_mode: 'MarkdownV2',
1656
- });
1657
- } else if (media.kind === 'document') {
1658
- await this.bot.sendDocument(chatId, media.src, {
1659
- caption: captionMd, parse_mode: 'MarkdownV2',
1660
- });
1661
- }
1662
- } catch (error) {
1663
- this.logger?.warn?.('[TelegramService] media send failed', { error: error.message, kind: media.kind });
1664
- }
1665
- }
1666
-
1667
- // ── Prompt Relay ───────────────────────────────────────────────────
1668
-
1669
- async _relayPromptRequest(message) {
1670
- if (this.chats.size === 0) return;
1671
- const data = message.data || message;
1672
- const requestId = data.requestId;
1673
- const agentId = data.agentId;
1674
- const questions = data.questions || [];
1675
- if (!requestId || questions.length === 0) return;
1676
-
1677
- const agent = this.agentPool ? await this.agentPool.getAgent(agentId) : null;
1678
- const agentName = agent?.name || agentId;
1679
-
1680
- // Fan out to every chat that has this agent active.
1681
- for (const [chatId, state] of this.chats.entries()) {
1682
- if (!state.activeAgentIds.has(agentId)) continue;
1683
-
1684
- this.pendingRelays.set(requestId, { type: 'user_prompt', agentId, chatId, questions, timestamp: Date.now() });
1685
-
1686
- for (const q of questions) {
1687
- let text = `🔔 *${this._escapeMarkdown(agentName)}* needs your input:\n\n`;
1688
- text += this._escapeMarkdown(q.question) + '\n';
1689
- const options = q.options || [];
1690
- if (options.length > 0) {
1691
- const buttons = options.map((opt, i) => ([{
1692
- text: opt.label,
1693
- callback_data: `prompt_reply:${requestId}:${i}`,
1694
- }]));
1695
- await this._send(chatId, text, { reply_markup: { inline_keyboard: buttons } });
1696
- } else {
1697
- text += '\n_Type your reply:_';
1698
- this.replyContext.set(chatId, { type: 'user_prompt', requestId, agentId });
1699
- await this._send(chatId, text);
1700
- }
1701
- }
1702
-
1703
- // Two timers per request:
1704
- // - A 3-min reminder ("still waiting")
1705
- // - A 10-min hard timeout ("expired — please re-trigger the action")
1706
- const reminderId = setTimeout(() => {
1707
- if (this.pendingRelays.has(requestId)) {
1708
- this._send(chatId, this._escapeMarkdown(`⏰ Reminder: ${agentName} is still waiting for your input.`)).catch(() => {});
1709
- }
1710
- }, PROMPT_REMINDER_MS);
1711
- const timeoutId = setTimeout(() => {
1712
- if (this.pendingRelays.has(requestId)) {
1713
- this.pendingRelays.delete(requestId);
1714
- this.replyContext.delete(chatId);
1715
- this._send(chatId, this._escapeMarkdown(`⏱ Prompt from ${agentName} expired without a reply. Re-run the action if you still need it.`)).catch(() => {});
1716
- }
1717
- }, PROMPT_TIMEOUT_MS);
1718
- const existing = this.pendingRelays.get(requestId);
1719
- if (existing) {
1720
- existing.reminderId = reminderId;
1721
- existing.timeoutId = timeoutId;
1722
- }
1723
- }
1724
- }
1725
-
1726
- async _relayCredentialRequest(message) {
1727
- if (this.chats.size === 0) return;
1728
- const data = message.data || message;
1729
- const requestId = data.requestId;
1730
- const agentId = data.agentId;
1731
- const agent = this.agentPool ? await this.agentPool.getAgent(agentId) : null;
1732
- const agentName = agent?.name || agentId;
1733
-
1734
- for (const [chatId, state] of this.chats.entries()) {
1735
- if (!state.activeAgentIds.has(agentId)) continue;
1736
- this.pendingRelays.set(requestId, { type: 'credential', agentId, chatId, timestamp: Date.now() });
1737
- this.replyContext.set(chatId, { type: 'credential', requestId, agentId });
1738
- const text = `🔐 *${this._escapeMarkdown(agentName)}* needs credentials:\n\n` +
1739
- this._escapeMarkdown(data.message || 'Please provide the requested credentials.') +
1740
- '\n\n_Type your reply:_';
1741
- await this._send(chatId, text);
1742
-
1743
- // Timeout for credential requests too.
1744
- const timeoutId = setTimeout(() => {
1745
- if (this.pendingRelays.has(requestId)) {
1746
- this.pendingRelays.delete(requestId);
1747
- this.replyContext.delete(chatId);
1748
- this._send(chatId, this._escapeMarkdown(`⏱ Credential request from ${agentName} expired. Re-trigger if needed.`)).catch(() => {});
1749
- }
1750
- }, PROMPT_TIMEOUT_MS);
1751
- const existing = this.pendingRelays.get(requestId);
1752
- if (existing) existing.timeoutId = timeoutId;
1753
- }
1754
- }
1755
-
1756
- async _handlePromptReply(msg) {
1757
- const chatId = String(msg.chat.id);
1758
- const ctx = this.replyContext.get(chatId);
1759
- if (!ctx) return;
1760
-
1761
- const { type, requestId } = ctx;
1762
- const text = msg.text?.trim();
1763
- if (!text) return;
1764
-
1765
- this.replyContext.delete(chatId);
1766
- const relay = this.pendingRelays.get(requestId);
1767
- if (relay) {
1768
- if (relay.reminderId) clearTimeout(relay.reminderId);
1769
- if (relay.timeoutId) clearTimeout(relay.timeoutId);
1770
- }
1771
- this.pendingRelays.delete(requestId);
1772
-
1773
- try {
1774
- if (type === 'user_prompt' && this.webSocketManager) {
1775
- this.webSocketManager._handleUserPromptResult?.({
1776
- requestId, answers: { default: text },
1777
- });
1778
- } else if (type === 'credential' && this.webSocketManager) {
1779
- this.webSocketManager._handleCredentialResponse?.({
1780
- requestId, credentials: { value: text }, saveForFuture: false,
1781
- });
1782
- }
1783
- await this._send(msg.chat.id, this._escapeMarkdown('✅ Response submitted.'));
1784
- } catch (error) {
1785
- await this._send(msg.chat.id, this._escapeMarkdown(`❌ Failed to submit: ${error.message}`));
1786
- }
1787
- }
1788
-
1789
- async _submitPromptReply(requestId, answerIndex) {
1790
- const relay = this.pendingRelays.get(requestId);
1791
- if (!relay) return;
1792
- if (relay.reminderId) clearTimeout(relay.reminderId);
1793
- if (relay.timeoutId) clearTimeout(relay.timeoutId);
1794
- this.pendingRelays.delete(requestId);
1795
- this.replyContext.delete(String(relay.chatId));
1796
-
1797
- try {
1798
- const question = relay.questions?.[0];
1799
- const option = question?.options?.[parseInt(answerIndex)];
1800
- const answer = option?.label || String(answerIndex);
1801
- if (this.webSocketManager?._handleUserPromptResult) {
1802
- this.webSocketManager._handleUserPromptResult({
1803
- requestId, answers: { [question.question]: answer },
1804
- });
1805
- }
1806
- await this._send(relay.chatId, this._escapeMarkdown(`✅ Selected: ${answer}`));
1807
- } catch (error) {
1808
- await this._send(relay.chatId, this._escapeMarkdown(`❌ Failed: ${error.message}`));
1809
- }
1810
- }
1811
-
1812
- // ── Notification Batching ──────────────────────────────────────────
1813
-
1814
- _queueNotification(chatId, text) {
1815
- let q = this.notificationQueue.get(chatId);
1816
- if (!q) { q = []; this.notificationQueue.set(chatId, q); }
1817
- q.push(text);
1818
- if (!this.notificationTimers.has(chatId)) {
1819
- this.notificationTimers.set(chatId, setTimeout(() => this._flushNotifications(chatId), NOTIFICATION_BATCH_INTERVAL_MS));
1820
- }
1821
- }
1822
-
1823
- async _flushNotifications(chatId) {
1824
- this.notificationTimers.delete(chatId);
1825
- const q = this.notificationQueue.get(chatId) || [];
1826
- this.notificationQueue.set(chatId, []);
1827
- if (q.length === 0) return;
1828
- const combined = q.join('\n\n');
1829
- if (combined.length <= MAX_MESSAGE_LENGTH) {
1830
- await this._send(chatId, combined);
1831
- } else {
1832
- await this._send(chatId, q[0] + `\n\n_…and ${q.length - 1} more events_`);
1833
- }
1834
- }
1835
-
1836
- // ── Telegram API Helpers ───────────────────────────────────────────
1837
-
1838
- async _send(chatId, text, options = {}) {
1839
- return this._sendRaw(chatId, text, { parse_mode: 'MarkdownV2', ...options });
1840
- }
1841
-
1842
- async _sendRaw(chatId, text, options) {
1843
- if (!this.bot || !chatId) return;
1844
- try {
1845
- return await this.bot.sendMessage(chatId, text, options);
1846
- } catch (error) {
1847
- // Fallback path: strip formatting and retry plain. This catches
1848
- // MarkdownV2-escape gaps the regex doesn't cover.
1849
- this.logger?.warn('[TelegramService] formatted send failed, retrying plain', { error: error.message });
1850
- try {
1851
- const plainOpts = { ...options };
1852
- delete plainOpts.parse_mode;
1853
- return await this.bot.sendMessage(
1854
- chatId,
1855
- String(text).replace(/[\\*_`\[\]()~>#+\-=|{}.!]/g, ''),
1856
- plainOpts
1857
- );
1858
- } catch (e2) {
1859
- this.logger?.error('[TelegramService] send failed', { error: e2.message });
1860
- }
1861
- }
1862
- }
1863
-
1864
- async sendPhoto(chatId, photoPath, caption = '') {
1865
- if (!this.bot || !chatId) return;
1866
- try { return await this.bot.sendPhoto(chatId, photoPath, { caption }); }
1867
- catch (error) { this.logger?.error('[TelegramService] sendPhoto failed', { error: error.message }); }
1868
- }
1869
-
1870
- async sendDocument(chatId, docPath, caption = '') {
1871
- if (!this.bot || !chatId) return;
1872
- try { return await this.bot.sendDocument(chatId, docPath, { caption }); }
1873
- catch (error) { this.logger?.error('[TelegramService] sendDocument failed', { error: error.message }); }
1874
- }
1875
-
1876
- async sendTestMessage() {
1877
- if (this.chats.size === 0) {
1878
- throw new Error('No chats registered. Send /start from Telegram first.');
1879
- }
1880
- for (const chatId of this.chats.keys()) {
1881
- await this._send(chatId, this._escapeMarkdown('✅ Loxia Autopilot — test message received!'));
1882
- }
1883
- }
1884
-
1885
- // ── Markdown Escaping ──────────────────────────────────────────────
1886
-
1887
- _escapeMarkdown(text) {
1888
- if (!text) return '';
1889
- return text.replace(/([_*\[\]()~`>#+\-=|{}.!\\])/g, '\\$1');
1890
- }
1891
-
1892
- /**
1893
- * Tagged-template helper for MarkdownV2 messages with safe interpolation.
1894
- *
1895
- * The LITERAL parts of the template (the bits between `${...}`) are
1896
- * passed through unchanged — author writes raw MarkdownV2 there
1897
- * (e.g. `*bold*`, `` `code` ``, `_italic_`, with `\.` / `\!` etc.
1898
- * spelled out where Telegram requires).
1899
- *
1900
- * The INTERPOLATED parts (`${value}`) are escaped automatically so a
1901
- * variable containing `_` or `*` can't accidentally break formatting
1902
- * or inject markup.
1903
- *
1904
- * The original `_escapeMarkdown(entireString)` pattern that wrapped
1905
- * a hand-written MarkdownV2 string in escapes is the bug we keep
1906
- * hitting — every intended `*bold*` ended up as literal `*bold*`.
1907
- *
1908
- * @example
1909
- * const text = this._md`*Welcome* ${userName}\\. Use /help for commands\\.`;
1910
- *
1911
- * @returns {string} ready to pass to this._send() with MarkdownV2 parse_mode.
1912
- */
1913
- _md(strings, ...values) {
1914
- let out = '';
1915
- for (let i = 0; i < strings.length; i++) {
1916
- out += strings[i];
1917
- if (i < values.length) out += this._escapeMarkdown(String(values[i] ?? ''));
1918
- }
1919
- return out;
1920
- }
1921
-
1922
- // ── Public bridge introspection ────────────────────────────────────
1923
-
1924
- /**
1925
- * Bridged channels for an agent: one alias per chat the agent has
1926
- * been addressed from. The scheduler injects the `<external to="…">`
1927
- * prompt guidance based on this list.
1928
- */
1929
- getBridgedChannels(agentId) {
1930
- const out = [];
1931
- for (const [chatId, state] of this.chats.entries()) {
1932
- if (state.activeAgentIds.has(agentId)) {
1933
- out.push({
1934
- alias: `telegram:chat-${chatId}`,
1935
- label: state.title ? `Telegram > ${state.title}` : 'Telegram chat',
1936
- });
1937
- }
1938
- }
1939
- if (out.length > 0) {
1940
- // Also expose the bare `telegram` alias for backward compat
1941
- // with agents that don't know about per-chat addressing.
1942
- out.push({ alias: 'telegram', label: 'Telegram (any chat)' });
1943
- }
1944
- return out;
1945
- }
1946
-
1947
- isAgentBridged(agentId) {
1948
- if (!agentId || this.status !== TELEGRAM_STATUS.CONNECTED) return false;
1949
- for (const state of this.chats.values()) {
1950
- if (state.activeAgentIds.has(agentId)) return true;
1951
- }
1952
- return false;
1953
- }
1954
- }
1955
-
1956
- // Singleton
1957
- let instance = null;
1958
-
1959
- export function getTelegramService(logger = null) {
1960
- if (!instance) {
1961
- instance = new TelegramService(logger);
1962
- }
1963
- return instance;
1964
- }
1965
-
1966
- // Tests need to drop the singleton between cases.
1967
- export function _resetTelegramSingletonForTests() {
1968
- instance = null;
1969
- }
1970
-
1971
- export { TelegramService, TELEGRAM_STATUS };
1972
- export default TelegramService;
1
+ /**
2
+ * TelegramService — Conversational agent interface over Telegram.
3
+ *
4
+ * Public surface (unchanged):
5
+ * getTelegramService(logger) → singleton
6
+ * .setOrchestrator() .setAgentPool() .setWebSocketManager() .setFlowExecutor()
7
+ * .autoConnect() / .connect(botToken) / .disconnect()
8
+ * .getStatus() / .sendTestMessage()
9
+ * .getBridgedChannels(agentId) / .isAgentBridged(agentId)
10
+ *
11
+ * Capabilities (UX-focused):
12
+ * • Multi-chat — any number of private chats can /start the bot; the
13
+ * first-chat-wins lock is gone. Each chat keeps its own sticky agent
14
+ * + watch state. (`chats: Map<chatId, ChatState>`)
15
+ * • Group chat — bots in Telegram groups respond ONLY when @mentioned
16
+ * by their username (Telegram convention; prevents bot spam).
17
+ * • Voice notes — downloaded, base64-posted to the backend's
18
+ * /llm/transcribe endpoint (which proxies to whichever Azure AI
19
+ * Foundry speech-to-text deployment is in the model catalog —
20
+ * gpt-4o-mini-transcribe by default), then handled as text. Falls
21
+ * back to a friendly "voice not configured" message when the
22
+ * backend route returns 503.
23
+ * • Typing indicator + "thinking" placeholder — emitted before the
24
+ * orchestrator call so the user sees instant feedback; replaced
25
+ * with the real reply via editMessageText.
26
+ * • Rich reply blocks via `<actions>` / `<image>` / `<attachment>`
27
+ * tags inside `<external>` — see telegramBlockParser.js.
28
+ * • Errors and timeouts always relayed (not gated by /watch).
29
+ * • Prompt-request timeout sends a "your prompt expired" message
30
+ * instead of silently dropping the request.
31
+ * • Paginated `/agents` and `/flows` for large pools.
32
+ * • Long code-only replies are sent as a `.txt` document so
33
+ * Telegram's MarkdownV2 escaping never mangles them.
34
+ *
35
+ * Config persistence (backward-compatible):
36
+ * Old: `{ chatId, watchEnabled }` (scalar)
37
+ * New: `{ chats: [{ chatId, addedAt, watchEnabled, lastAgentId }], … }`
38
+ * On load, a legacy scalar `chatId` is migrated to a single-entry
39
+ * chats array. Writes always emit the new shape; the legacy field
40
+ * is dropped silently.
41
+ */
42
+
43
+ import { promises as fs } from 'fs';
44
+ import path from 'path';
45
+ import { getUserDataPaths, ensureUserDataDirs } from '../utilities/userDataDir.js';
46
+ import { filterContentForExternalRelay, resolveBlockTargets } from './channelFilter.js';
47
+ import { createTelegramSource } from './messageSource.js';
48
+ import { parseTelegramBlock } from './telegramBlockParser.js';
49
+
50
+ // Error-broadcast dedup window. The scheduler's "unknown error" branch
51
+ // retries every 60s; with a 5-min window we collapse 5 identical fires
52
+ // into one notification + one optional "still happening" summary.
53
+ const ERROR_DEDUP_WINDOW_MS = 5 * 60 * 1000;
54
+ // Memory cap on the dedup table — if a misbehaving system somehow
55
+ // produces 50+ distinct (agent, type, message) combinations within a
56
+ // window, we evict the oldest. In normal operation the table is tiny.
57
+ const ERROR_DEDUP_MAX_KEYS = 50;
58
+
59
+ /**
60
+ * Collapse runs of whitespace to a single space and trim leading/trailing
61
+ * whitespace — used to normalise error messages before hashing them for
62
+ * dedup. Implemented with plain character iteration to avoid the
63
+ * /\s+/g regex (the codebase convention is "string ops over regex").
64
+ *
65
+ * Treats ASCII control chars (0x00–0x20) and NBSP (0xA0) as whitespace.
66
+ *
67
+ * @param {string} s
68
+ * @returns {string}
69
+ */
70
+ function _collapseWhitespace(s) {
71
+ let out = '';
72
+ let prevWasWs = false;
73
+ for (let i = 0; i < s.length; i++) {
74
+ const code = s.charCodeAt(i);
75
+ const isWs = code <= 32 || code === 160;
76
+ if (isWs) {
77
+ prevWasWs = true;
78
+ } else {
79
+ if (prevWasWs && out.length > 0) out += ' ';
80
+ out += s[i];
81
+ prevWasWs = false;
82
+ }
83
+ }
84
+ return out;
85
+ }
86
+
87
+ const TELEGRAM_STATUS = {
88
+ DISCONNECTED: 'disconnected',
89
+ CONNECTING: 'connecting',
90
+ CONNECTED: 'connected',
91
+ FAILED: 'failed',
92
+ };
93
+
94
+ const MAX_MESSAGE_LENGTH = 4000; // Telegram limit is 4096 — leave room for headers
95
+ const NOTIFICATION_BATCH_INTERVAL_MS = 10000;
96
+ const PROMPT_REMINDER_MS = 180000; // 3 min — "still waiting" nudge
97
+ const PROMPT_TIMEOUT_MS = 600000; // 10 min — hard timeout, send cancel
98
+ const PAGE_SIZE = 8; // /agents and /flows page size
99
+ const CODE_AS_FILE_THRESHOLD = 3200; // chars; above this, send code as .txt
100
+ const IMAGE_PENDING_TTL_MS = 5 * 60 * 1000; // 5 min — buffer window for imageGenerated → stream_complete
101
+
102
+ class TelegramService {
103
+ constructor(logger = null) {
104
+ this.logger = logger;
105
+
106
+ // Dependencies (set via setters)
107
+ this.orchestrator = null;
108
+ this.agentPool = null;
109
+ this.webSocketManager = null;
110
+ this.flowExecutor = null;
111
+
112
+ // Bot state
113
+ this.bot = null;
114
+ this.status = TELEGRAM_STATUS.DISCONNECTED;
115
+ this.botUsername = null; // populated by getMe() at connect
116
+
117
+ /**
118
+ * Per-chat state. Keyed by chatId (string). Each value:
119
+ * {
120
+ * type: 'private' | 'group' | 'supergroup' | 'channel',
121
+ * title?: string,
122
+ * addedAt: ISO string,
123
+ * watchEnabled: boolean,
124
+ * lastAgentId: string|null,
125
+ * activeAgentIds: Set<string>,
126
+ * // Tracking for in-flight "🔄 Thinking" placeholders:
127
+ * thinkingByAgentId: Map<agentId, messageId>,
128
+ * }
129
+ */
130
+ this.chats = new Map();
131
+
132
+ /**
133
+ * Dedup table for error broadcasts (agent_error / agent_timeout /
134
+ * criticalError / flow_run_failed).
135
+ *
136
+ * The scheduler can loop on the same uncategorised error every 60s
137
+ * forever; without dedup, every linked Telegram chat sees a fresh
138
+ * "⚠️ Agent Error" message on every retry. We coalesce identical
139
+ * errors within ERROR_DEDUP_WINDOW_MS and emit one "still happening"
140
+ * summary at the end of the window if the loop didn't clear.
141
+ *
142
+ * Keyed by `${agentId}:${type}:${hashedMessage}`. Each entry:
143
+ * {
144
+ * count: how many times this exact error fired in the window
145
+ * firstAt: epoch ms of the first occurrence
146
+ * agentName: last-seen agent name (for the summary)
147
+ * timer: setTimeout handle that emits the summary on close
148
+ * }
149
+ *
150
+ * Bounded by ERROR_DEDUP_MAX_KEYS so a runaway never blows memory.
151
+ */
152
+ this._errorBroadcastDedup = new Map();
153
+
154
+ // Relay state (global — keyed by requestId)
155
+ this.pendingRelays = new Map(); // requestId → { type, agentId, chatId, timeoutId, reminderId }
156
+ this.replyContext = new Map(); // chatId → { type, requestId, agentId }
157
+
158
+ // Notifications (batched per-chat)
159
+ this.notificationQueue = new Map(); // chatId → string[]
160
+ this.notificationTimers = new Map(); // chatId → timeout handle
161
+
162
+ // Config
163
+ this.dataDir = null;
164
+ this.configPath = null;
165
+ this.config = {};
166
+
167
+ // Backend (for transcription) — falls back to the same default
168
+ // the rest of the CLI uses. webServer.js sets this via
169
+ // setBackendBaseUrl().
170
+ this.backendBaseUrl = 'https://autopilot-api.azurewebsites.net';
171
+ // Platform API key resolver. May be set as either:
172
+ // a) a function () => string|null (preferred — re-resolves on
173
+ // every call, so signin/signout after boot is handled
174
+ // automatically)
175
+ // b) a plain string (legacy — captured once)
176
+ // Both are exposed through `_resolvePlatformKey()` below.
177
+ this._platformApiKey = null;
178
+
179
+ // Original broadcast (saved before wrapping)
180
+ this._originalBroadcast = null;
181
+
182
+ /**
183
+ * Pending generated images, keyed by agentId. The imageTool emits
184
+ * its own `imageGenerated` broadcast independently of the agent's
185
+ * text completion; we buffer those URLs here and flush them in
186
+ * `_relayAgentResponse` so the image reaches Telegram even if the
187
+ * agent's final reply text doesn't embed the image markdown.
188
+ *
189
+ * Shape: Map<agentId, Array<{ imageUrl, prompt, addedAt }>>
190
+ *
191
+ * Entries older than IMAGE_PENDING_TTL_MS are pruned at flush time
192
+ * — guards against a stranded image surfacing later in an unrelated
193
+ * conversation turn. The TTL is long enough for slow generations
194
+ * (DALL-E 3, video previews) but short enough to keep the buffer
195
+ * from accumulating across hours of idle.
196
+ */
197
+ this._pendingImagesByAgent = new Map();
198
+ }
199
+
200
+ // ── Dependency Injection ───────────────────────────────────────────
201
+
202
+ setOrchestrator(orchestrator) { this.orchestrator = orchestrator; }
203
+ setAgentPool(agentPool) { this.agentPool = agentPool; }
204
+ setWebSocketManager(wsManager) {
205
+ this.webSocketManager = wsManager;
206
+ this._interceptBroadcasts(wsManager);
207
+ }
208
+ setFlowExecutor(flowExecutor) { this.flowExecutor = flowExecutor; }
209
+ setBackendBaseUrl(url) { if (url) this.backendBaseUrl = String(url).replace(/\/$/, ''); }
210
+ /** Accept a string OR a () => string|null getter. The getter form is
211
+ * preferred so a sign-in / sign-out cycle after boot is reflected
212
+ * without re-wiring. */
213
+ setPlatformApiKey(keyOrGetter) { this._platformApiKey = keyOrGetter || null; }
214
+ _resolvePlatformKey() {
215
+ const k = this._platformApiKey;
216
+ if (typeof k === 'function') {
217
+ try { return k() || null; } catch { return null; }
218
+ }
219
+ return k || null;
220
+ }
221
+
222
+ // ── Config Persistence ─────────────────────────────────────────────
223
+
224
+ async _ensureDataDir() {
225
+ if (!this.dataDir) {
226
+ await ensureUserDataDirs();
227
+ const paths = getUserDataPaths();
228
+ this.dataDir = path.join(paths.base, 'telegram');
229
+ this.configPath = path.join(this.dataDir, 'telegram-config.json');
230
+ await fs.mkdir(this.dataDir, { recursive: true });
231
+ }
232
+ }
233
+
234
+ async _loadConfig() {
235
+ await this._ensureDataDir();
236
+ try {
237
+ const data = await fs.readFile(this.configPath, 'utf8');
238
+ this.config = JSON.parse(data);
239
+ } catch {
240
+ this.config = {};
241
+ }
242
+
243
+ // Migration: legacy `{ chatId, watchEnabled }` → `{ chats: [...] }`.
244
+ // We intentionally do NOT delete the legacy field from this.config
245
+ // until the next save so a hand-edited file roundtrips cleanly.
246
+ if (this.config.chatId && !Array.isArray(this.config.chats)) {
247
+ this.config.chats = [{
248
+ chatId: String(this.config.chatId),
249
+ type: 'private',
250
+ title: null,
251
+ addedAt: this.config.updatedAt || new Date().toISOString(),
252
+ watchEnabled: !!this.config.watchEnabled,
253
+ lastAgentId: null,
254
+ }];
255
+ }
256
+
257
+ this.chats = new Map();
258
+ for (const entry of (this.config.chats || [])) {
259
+ if (!entry?.chatId) continue;
260
+ this.chats.set(String(entry.chatId), {
261
+ type: entry.type || 'private',
262
+ title: entry.title || null,
263
+ addedAt: entry.addedAt || new Date().toISOString(),
264
+ watchEnabled: !!entry.watchEnabled,
265
+ lastAgentId: entry.lastAgentId || null,
266
+ activeAgentIds: new Set(entry.activeAgentIds || []),
267
+ thinkingByAgentId: new Map(),
268
+ });
269
+ }
270
+ }
271
+
272
+ async _saveConfig() {
273
+ await this._ensureDataDir();
274
+ // Re-shape in-memory state for persistence.
275
+ this.config.chats = [...this.chats.entries()].map(([chatId, s]) => ({
276
+ chatId,
277
+ type: s.type,
278
+ title: s.title,
279
+ addedAt: s.addedAt,
280
+ watchEnabled: s.watchEnabled,
281
+ lastAgentId: s.lastAgentId,
282
+ activeAgentIds: [...s.activeAgentIds],
283
+ }));
284
+ delete this.config.chatId;
285
+ delete this.config.watchEnabled;
286
+ this.config.updatedAt = new Date().toISOString();
287
+ await fs.writeFile(this.configPath, JSON.stringify(this.config, null, 2), 'utf8');
288
+ }
289
+
290
+ // ── Lifecycle ──────────────────────────────────────────────────────
291
+
292
+ async autoConnect() {
293
+ await this._loadConfig();
294
+ if (this.config.botToken) {
295
+ try {
296
+ await this.connect(this.config.botToken);
297
+ } catch (error) {
298
+ this.logger?.warn('[TelegramService] Auto-connect failed', { error: error.message });
299
+ }
300
+ }
301
+ }
302
+
303
+ async connect(botToken) {
304
+ if (this.status === TELEGRAM_STATUS.CONNECTED) {
305
+ await this.disconnect();
306
+ }
307
+ this.status = TELEGRAM_STATUS.CONNECTING;
308
+ this.logger?.info('[TelegramService] Connecting...');
309
+
310
+ try {
311
+ // Migrated from node-telegram-bot-api → telegraf via the local
312
+ // compat wrapper (src/services/telegrafBot.js). The wrapper
313
+ // preserves the entire node-telegram-bot-api surface this file is
314
+ // written against, so the body below — onText, on('message'),
315
+ // sendMessage, editMessageText, sendChatAction, sendDocument, etc.
316
+ // — keeps working unchanged. The motivation was 9 CVEs (incl. 2
317
+ // critical) in the deprecated `request`/`form-data`/`@cypress/request`
318
+ // chain that node-telegram-bot-api's latest version still hauls in.
319
+ const { createTelegrafBot } = await import('./telegrafBot.js');
320
+ this.bot = await createTelegrafBot(botToken, { polling: true });
321
+
322
+ const me = await this.bot.getMe();
323
+ this.botUsername = me.username || null;
324
+ this.logger?.info('[TelegramService] Connected', { botName: this.botUsername });
325
+
326
+ this.config.botToken = botToken;
327
+ this.config.botUsername = this.botUsername;
328
+ await this._saveConfig();
329
+
330
+ this._setupHandlers();
331
+ this.status = TELEGRAM_STATUS.CONNECTED;
332
+
333
+ return { username: this.botUsername, id: me.id };
334
+ } catch (error) {
335
+ this.status = TELEGRAM_STATUS.FAILED;
336
+ this.logger?.error('[TelegramService] Connection failed', { error: error.message });
337
+ throw error;
338
+ }
339
+ }
340
+
341
+ async disconnect() {
342
+ if (this.bot) {
343
+ try { await this.bot.stopPolling(); } catch { /* ignore */ }
344
+ this.bot = null;
345
+ }
346
+ this.status = TELEGRAM_STATUS.DISCONNECTED;
347
+ for (const t of this.notificationTimers.values()) clearTimeout(t);
348
+ this.notificationTimers.clear();
349
+ this.notificationQueue.clear();
350
+ this.logger?.info('[TelegramService] Disconnected');
351
+ }
352
+
353
+ getStatus() {
354
+ return {
355
+ status: this.status,
356
+ connected: this.status === TELEGRAM_STATUS.CONNECTED,
357
+ botUsername: this.botUsername,
358
+ chatCount: this.chats.size,
359
+ // Surface the legacy `chatId` as the first chat for UI back-compat;
360
+ // any new UI should switch to `chats`.
361
+ chatId: this.chats.size > 0 ? [...this.chats.keys()][0] : null,
362
+ chats: [...this.chats.entries()].map(([chatId, s]) => ({
363
+ chatId,
364
+ type: s.type,
365
+ title: s.title,
366
+ watchEnabled: s.watchEnabled,
367
+ })),
368
+ watchEnabled: [...this.chats.values()].some(s => s.watchEnabled),
369
+ };
370
+ }
371
+
372
+ // ── Command & Message Handlers ─────────────────────────────────────
373
+
374
+ _setupHandlers() {
375
+ if (!this.bot) return;
376
+
377
+ this.bot.onText(/^\/start(@\w+)?(\s|$)/, (msg) => this._cmdStart(msg));
378
+ this.bot.onText(/^\/help(@\w+)?(\s|$)/, (msg) => this._cmdHelp(msg));
379
+ this.bot.onText(/^\/status(@\w+)?(\s|$)/, (msg) => this._cmdStatus(msg));
380
+ this.bot.onText(/^\/agents(@\w+)?(\s|$)/, (msg) => this._cmdAgents(msg, 0));
381
+ this.bot.onText(/^\/agent(@\w+)?\s+(.+)/, (msg, m) => this._cmdAgentDetail(msg, m[2].trim()));
382
+ this.bot.onText(/^\/flows(@\w+)?(\s|$)/, (msg) => this._cmdFlows(msg, 0));
383
+ this.bot.onText(/^\/run(@\w+)?\s+(.+)/, (msg, m) => this._cmdRunFlow(msg, m[2].trim()));
384
+ this.bot.onText(/^\/stop(@\w+)?\s+(.+)/, (msg, m) => this._cmdStopAgent(msg, m[2].trim()));
385
+ this.bot.onText(/^\/following(@\w+)?(\s|$)/, (msg) => this._cmdFollowing(msg));
386
+ this.bot.onText(/^\/unfollow(@\w+)?\s+(.+)/, (msg, m) => this._cmdUnfollow(msg, m[2].trim()));
387
+ this.bot.onText(/^\/watch(@\w+)?(\s|$)/, (msg) => this._cmdWatch(msg));
388
+ this.bot.onText(/^\/unwatch(@\w+)?(\s|$)/, (msg) => this._cmdUnwatch(msg));
389
+ this.bot.onText(/^\/watching(@\w+)?(\s|$)/, (msg) => this._cmdWatching(msg));
390
+ this.bot.onText(/^\/reset(@\w+)?(\s|$)/, (msg) => this._cmdReset(msg));
391
+ this.bot.onText(/^\/new(@\w+)?\s+(.+)/, (msg, m) => this._cmdNew(msg, m[2].trim()));
392
+ this.bot.onText(/^\/logout(@\w+)?(\s|$)/, (msg) => this._cmdLogout(msg));
393
+
394
+ // Generic message handler — branches on type.
395
+ this.bot.on('message', (msg) => this._onMessage(msg));
396
+
397
+ // Inline-keyboard callbacks.
398
+ this.bot.on('callback_query', (q) => this._handleCallbackQuery(q));
399
+ }
400
+
401
+ async _onMessage(msg) {
402
+ // Skip slash commands; their handlers fire via onText.
403
+ if (typeof msg.text === 'string' && msg.text.startsWith('/')) return;
404
+
405
+ if (msg.voice) {
406
+ await this._handleVoiceMessage(msg);
407
+ return;
408
+ }
409
+ if (msg.text) {
410
+ await this._handleTextMessage(msg);
411
+ return;
412
+ }
413
+ // Other media types (photos, documents, stickers) are intentionally
414
+ // ignored for now — agents can't yet consume them. Future work:
415
+ // forward image_url to a vision-capable agent.
416
+ }
417
+
418
+ _isAuthorized(msg) {
419
+ const chatId = String(msg.chat.id);
420
+ return this.chats.has(chatId);
421
+ }
422
+
423
+ /**
424
+ * Parse `@agent-name ...` (or just `agent-name ...`) at the start of
425
+ * a message, doing longest-prefix matching against the live agent
426
+ * list so names with spaces work without quoting.
427
+ *
428
+ * Accepts:
429
+ * "@Cody hello" → { agentName: 'Cody', matchedAgent: <agent>, messageText: 'hello' }
430
+ * "@John Smith report" → { agentName: 'John Smith', matchedAgent: <agent>, messageText: 'report' }
431
+ * "@John_Smith report" → same — underscore + dash are accepted as a
432
+ * space-substitute for slug-style addressing
433
+ * "no prefix" → { agentName: null, matchedAgent: null, messageText: 'no prefix' }
434
+ * "@Unknown msg" → { agentName: 'Unknown', matchedAgent: null, messageText: 'msg' }
435
+ * (caller emits a friendly "not found" reply)
436
+ *
437
+ * Algorithm:
438
+ * 1. Strip leading `@` if present. Remember whether one was there.
439
+ * 2. For each agent, see if the remaining text starts with the
440
+ * agent's name (case-insensitive). Try TWO normalisations:
441
+ * a) literal name with all spaces
442
+ * b) name with spaces replaced by [ _\-]+ (slug form)
443
+ * Treat a match as valid only when followed by whitespace or
444
+ * end-of-string (so "@John" doesn't accidentally match "Johnson").
445
+ * 3. Among valid matches, pick the LONGEST agent name — handles
446
+ * the "@John Smith" vs "@John" ambiguity correctly.
447
+ * 4. If no agent name matched but there WAS an `@` prefix, fall
448
+ * back to the legacy single-token capture so the caller can
449
+ * surface a "not found" error with the user-typed text.
450
+ *
451
+ * @param {string} text
452
+ * @param {Array<{id:string, name:string}>} agents
453
+ * @returns {{ agentName: string|null, matchedAgent: object|null, messageText: string }}
454
+ */
455
+ _resolveAtPrefix(text, agents) {
456
+ if (typeof text !== 'string' || text.length === 0) {
457
+ return { agentName: null, matchedAgent: null, messageText: text || '' };
458
+ }
459
+
460
+ const hasAt = text.startsWith('@');
461
+ const body = hasAt ? text.slice(1) : text;
462
+
463
+ // Try to find the longest agent name that the body starts with.
464
+ let best = null; // { agent, matchedRaw, restStart }
465
+ for (const agent of agents || []) {
466
+ const name = String(agent?.name || '');
467
+ if (!name) continue;
468
+ // Build a regex that matches either the literal name or its slug
469
+ // variant (spaces → any of `[ _\-]+`). Case-insensitive. Must be
470
+ // followed by whitespace or end-of-string so partial-word
471
+ // collisions don't false-match.
472
+ const slugPattern = name
473
+ .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // escape regex metas
474
+ .replace(/\s+/g, '[ _\\-]+'); // any space substitute
475
+ const re = new RegExp(`^(${slugPattern})(?:\\s|$)`, 'i');
476
+ const m = body.match(re);
477
+ if (m) {
478
+ if (!best || m[1].length > best.matchedRaw.length) {
479
+ best = { agent, matchedRaw: m[1], restStart: m[1].length };
480
+ }
481
+ }
482
+ }
483
+
484
+ if (best) {
485
+ const rest = body.slice(best.restStart).replace(/^\s+/, '');
486
+ return {
487
+ agentName: best.agent.name,
488
+ matchedAgent: best.agent,
489
+ messageText: rest,
490
+ };
491
+ }
492
+
493
+ if (!hasAt) {
494
+ return { agentName: null, matchedAgent: null, messageText: text };
495
+ }
496
+
497
+ // No agent matched but the user clearly intended an @-prefix
498
+ // (typo, agent renamed, etc.). Fall back to the legacy "first
499
+ // whitespace-free token after @" capture so the caller can quote
500
+ // the user-typed name in the "not found" reply.
501
+ const legacy = body.match(/^(\S+)(?:\s+([\s\S]+))?/);
502
+ if (legacy) {
503
+ return {
504
+ agentName: legacy[1],
505
+ matchedAgent: null,
506
+ messageText: (legacy[2] || '').trim(),
507
+ };
508
+ }
509
+ return { agentName: null, matchedAgent: null, messageText: text };
510
+ }
511
+
512
+ /**
513
+ * In a group chat, the bot should only respond to a text message
514
+ * when @-mentioned. This prevents bot spam in shared channels.
515
+ * Returns the message text with the @mention stripped when matched.
516
+ * In private chats, the text is returned unchanged.
517
+ */
518
+ _stripMentionOrSkip(msg) {
519
+ const isGroup = msg.chat.type === 'group' || msg.chat.type === 'supergroup';
520
+ const text = (msg.text || msg.caption || '').trim();
521
+ if (!isGroup) return { addressed: true, text };
522
+ if (!this.botUsername) return { addressed: false, text };
523
+
524
+ const mention = `@${this.botUsername.toLowerCase()}`;
525
+ const lower = text.toLowerCase();
526
+ if (lower.includes(mention)) {
527
+ const stripped = text.replace(new RegExp(`@${this.botUsername}\\b`, 'gi'), '').replace(/\s+/g, ' ').trim();
528
+ return { addressed: true, text: stripped };
529
+ }
530
+ return { addressed: false, text };
531
+ }
532
+
533
+ // ── Commands ───────────────────────────────────────────────────────
534
+
535
+ async _cmdStart(msg) {
536
+ const chatId = String(msg.chat.id);
537
+ const type = msg.chat.type || 'private';
538
+ const title = msg.chat.title || null;
539
+
540
+ if (!this.chats.has(chatId)) {
541
+ this.chats.set(chatId, {
542
+ type, title,
543
+ addedAt: new Date().toISOString(),
544
+ watchEnabled: false,
545
+ lastAgentId: null,
546
+ activeAgentIds: new Set(),
547
+ thinkingByAgentId: new Map(),
548
+ });
549
+ await this._saveConfig();
550
+ const greeting = type === 'private'
551
+ ? this._md`*Loxia Autopilot connected\\!* 🚀\n\nThis chat is now linked\\. Use /help for commands\\.\n\nAddress agents with \`@agent\\-name your message\` or just start typing once you’ve picked one\\.`
552
+ : this._md`*Loxia Autopilot connected\\!* 🚀\n\nGroup registered: *${title || chatId}*\\. Mention me with @${this.botUsername || 'loxia\\_bot'} to address an agent\\.`;
553
+ await this._send(chatId, greeting);
554
+ } else {
555
+ await this._send(chatId, this._md`Already connected\\. Use /help for commands\\.`);
556
+ }
557
+ }
558
+
559
+ async _cmdHelp(msg) {
560
+ if (!this._isAuthorized(msg)) return;
561
+ // Hand-written MarkdownV2. Special chars (`.`, `-`, `!`, `(`, `)`,
562
+ // `>`) MUST be escaped per Telegram's MarkdownV2 spec. The
563
+ // _escapeMarkdown wrapper that used to wrap this whole thing
564
+ // turned every `*` and backtick into literal text — that's why
565
+ // the help message displayed asterisks instead of bold.
566
+ const help =
567
+ `*Loxia Autopilot — Telegram*
568
+
569
+ *Chat with agents:*
570
+ \`@agent\\-name message\` — send to a specific agent
571
+ Names with spaces work too: \`@John Smith hello\` or \`@John\\_Smith hello\`
572
+ Type without prefix — sends to your last\\-used agent
573
+
574
+ *Commands:*
575
+ /agents — list all agents \\(paginated\\)
576
+ /agent <name> — agent detail card
577
+ /status — system overview
578
+ /following — agents you have spoken with from this chat
579
+ /unfollow <name> — stop receiving replies from an agent
580
+ /flows — list flows \\(paginated\\)
581
+ /run <flow> — start a flow
582
+ /stop <agent> — halt agent execution
583
+ /reset — clear the conversation with your active agent
584
+ /new <agent> — switch to a different agent with fresh context
585
+ /watch — subscribe to event notifications
586
+ /unwatch — unsubscribe
587
+ /watching — show notification status
588
+ /logout — disconnect this chat from Loxia
589
+ /help — this message
590
+
591
+ _Voice notes are transcribed automatically\\._`;
592
+ await this._send(msg.chat.id, help);
593
+ }
594
+
595
+ async _cmdStatus(msg) {
596
+ if (!this._isAuthorized(msg)) return;
597
+ try {
598
+ const agents = this.agentPool ? await this.agentPool.getAllAgents() : [];
599
+ const active = agents.filter(a => a.status === 'active' || a.mode === 'auto');
600
+ const idle = agents.filter(a => a.mode === 'chat' || a.status === 'idle');
601
+ const state = this._chatState(msg);
602
+ const watch = state?.watchEnabled ? '🔔 On' : '🔕 Off';
603
+
604
+ const text = [
605
+ '*System Status*',
606
+ '',
607
+ `Agents: ${agents.length} total, ${active.length} active, ${idle.length} idle`,
608
+ `Notifications: ${watch}`,
609
+ `Linked chats: ${this.chats.size}`,
610
+ ].join('\n');
611
+ await this._send(msg.chat.id, this._escapeMarkdown(text));
612
+ } catch (error) {
613
+ await this._send(msg.chat.id, this._escapeMarkdown(`❌ Error: ${error.message}`));
614
+ }
615
+ }
616
+
617
+ async _cmdAgents(msg, page = 0) {
618
+ if (!this._isAuthorized(msg)) return;
619
+ try {
620
+ const agents = this.agentPool ? await this.agentPool.getAllAgents() : [];
621
+ if (agents.length === 0) {
622
+ await this._send(msg.chat.id, this._escapeMarkdown('No agents loaded.'));
623
+ return;
624
+ }
625
+ await this._renderAgentsPage(msg.chat.id, agents, page);
626
+ } catch (error) {
627
+ await this._send(msg.chat.id, this._escapeMarkdown(`❌ Error: ${error.message}`));
628
+ }
629
+ }
630
+
631
+ async _renderAgentsPage(chatId, agents, page) {
632
+ const pages = Math.max(1, Math.ceil(agents.length / PAGE_SIZE));
633
+ const safePage = Math.min(Math.max(0, page), pages - 1);
634
+ const slice = agents.slice(safePage * PAGE_SIZE, (safePage + 1) * PAGE_SIZE);
635
+
636
+ let text = `*Agents (${agents.length})* — page ${safePage + 1}/${pages}\n\n`;
637
+ const buttons = [];
638
+ for (const agent of slice) {
639
+ const dot = agent.mode === 'auto' ? '🟢' : agent.status === 'active' ? '🟡' : '⚪';
640
+ const mode = agent.mode === 'auto' ? 'autonomous' : 'chat';
641
+ text += `${dot} *${this._escapeMarkdown(agent.name)}* — ${mode}\n`;
642
+ buttons.push([{ text: agent.name, callback_data: `agent_detail:${agent.id}` }]);
643
+ }
644
+ if (pages > 1) {
645
+ const nav = [];
646
+ if (safePage > 0) nav.push({ text: '‹ Prev', callback_data: `agents_page:${safePage - 1}` });
647
+ nav.push({ text: `${safePage + 1}/${pages}`, callback_data: 'noop' });
648
+ if (safePage < pages - 1) nav.push({ text: 'Next ›', callback_data: `agents_page:${safePage + 1}` });
649
+ buttons.push(nav);
650
+ }
651
+ await this._send(chatId, text, { reply_markup: { inline_keyboard: buttons } });
652
+ }
653
+
654
+ async _cmdAgentDetail(msg, agentName) {
655
+ if (!this._isAuthorized(msg)) return;
656
+ await this._showAgentDetail(msg.chat.id, agentName);
657
+ }
658
+
659
+ async _showAgentDetail(chatId, agentNameOrId) {
660
+ try {
661
+ const agents = this.agentPool ? await this.agentPool.getAllAgents() : [];
662
+ const agent = agents.find(a =>
663
+ a.name.toLowerCase() === agentNameOrId.toLowerCase() ||
664
+ a.id === agentNameOrId
665
+ );
666
+ if (!agent) {
667
+ await this._send(chatId, this._escapeMarkdown(`Agent "${agentNameOrId}" not found.`));
668
+ return;
669
+ }
670
+ const status = agent.mode === 'auto' ? '🟢 Autonomous' : '🟡 Chat';
671
+ let text = `*${this._escapeMarkdown(agent.name)}*\n\n`;
672
+ text += `Status: ${status}\n`;
673
+ text += `Model: \`${this._escapeMarkdown(agent.currentModel || 'unknown')}\`\n`;
674
+ if (agent.lastActivity) {
675
+ text += `Last active: ${new Date(agent.lastActivity).toLocaleTimeString()}\n`;
676
+ }
677
+ const buttons = [[
678
+ { text: '💬 Send Message', callback_data: `msg_agent:${agent.id}` },
679
+ { text: '⏹ Stop', callback_data: `stop_agent:${agent.id}` },
680
+ ]];
681
+ await this._send(chatId, text, { reply_markup: { inline_keyboard: buttons } });
682
+ } catch (error) {
683
+ await this._send(chatId, this._escapeMarkdown(`❌ Error: ${error.message}`));
684
+ }
685
+ }
686
+
687
+ async _cmdFlows(msg, page = 0) {
688
+ if (!this._isAuthorized(msg)) return;
689
+ try {
690
+ if (!this.orchestrator?.stateManager) {
691
+ await this._send(msg.chat.id, this._escapeMarkdown('Flows not available.'));
692
+ return;
693
+ }
694
+ const projectDir = this.orchestrator.config?.project?.directory || process.cwd();
695
+ const flowIndex = await this.orchestrator.stateManager.loadFlowIndex?.(projectDir) || {};
696
+ const flows = Object.entries(flowIndex);
697
+ if (flows.length === 0) {
698
+ await this._send(msg.chat.id, this._escapeMarkdown('No flows defined.'));
699
+ return;
700
+ }
701
+ await this._renderFlowsPage(msg.chat.id, flows, page);
702
+ } catch (error) {
703
+ await this._send(msg.chat.id, this._escapeMarkdown(`❌ Error: ${error.message}`));
704
+ }
705
+ }
706
+
707
+ async _renderFlowsPage(chatId, flows, page) {
708
+ const pages = Math.max(1, Math.ceil(flows.length / PAGE_SIZE));
709
+ const safePage = Math.min(Math.max(0, page), pages - 1);
710
+ const slice = flows.slice(safePage * PAGE_SIZE, (safePage + 1) * PAGE_SIZE);
711
+ let text = `*Flows (${flows.length})* — page ${safePage + 1}/${pages}\n\n`;
712
+ const buttons = [];
713
+ for (const [id, flow] of slice) {
714
+ text += `📋 *${this._escapeMarkdown(flow.name || id)}*\n`;
715
+ buttons.push([{ text: `▶️ Run ${flow.name || id}`, callback_data: `run_flow:${id}` }]);
716
+ }
717
+ if (pages > 1) {
718
+ const nav = [];
719
+ if (safePage > 0) nav.push({ text: '‹ Prev', callback_data: `flows_page:${safePage - 1}` });
720
+ nav.push({ text: `${safePage + 1}/${pages}`, callback_data: 'noop' });
721
+ if (safePage < pages - 1) nav.push({ text: 'Next ›', callback_data: `flows_page:${safePage + 1}` });
722
+ buttons.push(nav);
723
+ }
724
+ await this._send(chatId, text, { reply_markup: { inline_keyboard: buttons } });
725
+ }
726
+
727
+ async _cmdRunFlow(msg, flowName) {
728
+ if (!this._isAuthorized(msg)) return;
729
+ await this._runFlow(msg.chat.id, flowName);
730
+ }
731
+
732
+ async _runFlow(chatId, flowNameOrId) {
733
+ try {
734
+ if (!this.flowExecutor) {
735
+ await this._send(chatId, this._escapeMarkdown('Flow executor not available.'));
736
+ return;
737
+ }
738
+ await this._send(chatId, this._escapeMarkdown(`▶️ Starting flow: ${flowNameOrId}...`));
739
+ const projectDir = this.orchestrator?.config?.project?.directory || process.cwd();
740
+ await this.flowExecutor.executeFlow(flowNameOrId, { projectDir });
741
+ } catch (error) {
742
+ await this._send(chatId, this._escapeMarkdown(`❌ Flow error: ${error.message}`));
743
+ }
744
+ }
745
+
746
+ async _cmdStopAgent(msg, agentName) {
747
+ if (!this._isAuthorized(msg)) return;
748
+ try {
749
+ const agents = this.agentPool ? await this.agentPool.getAllAgents() : [];
750
+ const agent = agents.find(a => a.name.toLowerCase() === agentName.toLowerCase());
751
+ if (!agent) {
752
+ await this._send(msg.chat.id, this._escapeMarkdown(`Agent "${agentName}" not found.`));
753
+ return;
754
+ }
755
+ if (this.orchestrator?.messageProcessor) {
756
+ await this.orchestrator.messageProcessor.stopAutonomousExecution(agent.id);
757
+ }
758
+ await this._send(msg.chat.id, this._escapeMarkdown(`⏹ Stopped ${agent.name}`));
759
+ } catch (error) {
760
+ await this._send(msg.chat.id, this._escapeMarkdown(`❌ Error: ${error.message}`));
761
+ }
762
+ }
763
+
764
+ async _cmdFollowing(msg) {
765
+ if (!this._isAuthorized(msg)) return;
766
+ const state = this._chatState(msg);
767
+ if (!state || state.activeAgentIds.size === 0) {
768
+ await this._send(msg.chat.id, this._escapeMarkdown('Not following any agents in this chat. Send @agent-name to start.'));
769
+ return;
770
+ }
771
+ const agents = this.agentPool ? await this.agentPool.getAllAgents() : [];
772
+ let text = `*Following ${state.activeAgentIds.size} agent\\(s\\):*\n\n`;
773
+ const buttons = [];
774
+ for (const id of state.activeAgentIds) {
775
+ const agent = agents.find(a => a.id === id);
776
+ const name = agent?.name || id;
777
+ const isLast = id === state.lastAgentId;
778
+ text += `${isLast ? '💬' : '👁'} *${this._escapeMarkdown(name)}*${isLast ? ' \\(active\\)' : ''}\n`;
779
+ buttons.push([{ text: `❌ Unfollow ${name}`, callback_data: `unfollow:${id}` }]);
780
+ }
781
+ text += '\n_Active = default for messages without @prefix_';
782
+ await this._send(msg.chat.id, text, { reply_markup: { inline_keyboard: buttons } });
783
+ }
784
+
785
+ async _cmdUnfollow(msg, agentName) {
786
+ if (!this._isAuthorized(msg)) return;
787
+ const state = this._chatState(msg);
788
+ const agents = this.agentPool ? await this.agentPool.getAllAgents() : [];
789
+ const agent = agents.find(a => a.name.toLowerCase() === agentName.toLowerCase());
790
+ if (!agent || !state.activeAgentIds.has(agent.id)) {
791
+ await this._send(msg.chat.id, this._escapeMarkdown(`Not following "${agentName}".`));
792
+ return;
793
+ }
794
+ state.activeAgentIds.delete(agent.id);
795
+ if (state.lastAgentId === agent.id) {
796
+ state.lastAgentId = state.activeAgentIds.size > 0
797
+ ? [...state.activeAgentIds][state.activeAgentIds.size - 1]
798
+ : null;
799
+ }
800
+ await this._saveConfig();
801
+ await this._send(msg.chat.id, this._escapeMarkdown(`Unfollowed ${agent.name}.`));
802
+ }
803
+
804
+ async _cmdWatch(msg) {
805
+ if (!this._isAuthorized(msg)) return;
806
+ const state = this._chatState(msg);
807
+ state.watchEnabled = true;
808
+ await this._saveConfig();
809
+ await this._send(msg.chat.id, this._escapeMarkdown('🔔 Notifications enabled. You\'ll receive alerts for completions and prompts.'));
810
+ }
811
+
812
+ async _cmdUnwatch(msg) {
813
+ if (!this._isAuthorized(msg)) return;
814
+ const state = this._chatState(msg);
815
+ state.watchEnabled = false;
816
+ await this._saveConfig();
817
+ await this._send(msg.chat.id, this._escapeMarkdown('🔕 Notifications disabled. (Errors and timeouts are still relayed.)'));
818
+ }
819
+
820
+ async _cmdWatching(msg) {
821
+ if (!this._isAuthorized(msg)) return;
822
+ const state = this._chatState(msg);
823
+ await this._send(msg.chat.id, this._escapeMarkdown(state.watchEnabled
824
+ ? '🔔 Notifications are ON. Use /unwatch to disable.'
825
+ : '🔕 Notifications are OFF. Use /watch to enable.'
826
+ ));
827
+ }
828
+
829
+ async _cmdReset(msg) {
830
+ if (!this._isAuthorized(msg)) return;
831
+ const state = this._chatState(msg);
832
+ if (!state.lastAgentId) {
833
+ await this._send(msg.chat.id, this._escapeMarkdown('No active agent to reset. Use /agents to pick one.'));
834
+ return;
835
+ }
836
+ const agentId = state.lastAgentId;
837
+ const agent = this.agentPool ? await this.agentPool.getAgent(agentId) : null;
838
+ const name = agent?.name || agentId;
839
+ try {
840
+ // Reset agent conversation if the orchestrator exposes it.
841
+ if (this.orchestrator?.messageProcessor?.resetAgentConversation) {
842
+ await this.orchestrator.messageProcessor.resetAgentConversation(agentId);
843
+ } else if (this.agentPool?.clearAgentMessages) {
844
+ await this.agentPool.clearAgentMessages(agentId);
845
+ }
846
+ await this._send(msg.chat.id, this._escapeMarkdown(`🔄 Cleared conversation with ${name}.`));
847
+ } catch (error) {
848
+ await this._send(msg.chat.id, this._escapeMarkdown(`❌ Reset failed: ${error.message}`));
849
+ }
850
+ }
851
+
852
+ async _cmdNew(msg, agentName) {
853
+ if (!this._isAuthorized(msg)) return;
854
+ const state = this._chatState(msg);
855
+ const agents = this.agentPool ? await this.agentPool.getAllAgents() : [];
856
+ const agent = agents.find(a => a.name.toLowerCase() === agentName.toLowerCase());
857
+ if (!agent) {
858
+ await this._send(msg.chat.id, this._escapeMarkdown(`Agent "${agentName}" not found.`));
859
+ return;
860
+ }
861
+ state.lastAgentId = agent.id;
862
+ state.activeAgentIds.add(agent.id);
863
+ // Reset their context.
864
+ try {
865
+ if (this.orchestrator?.messageProcessor?.resetAgentConversation) {
866
+ await this.orchestrator.messageProcessor.resetAgentConversation(agent.id);
867
+ } else if (this.agentPool?.clearAgentMessages) {
868
+ await this.agentPool.clearAgentMessages(agent.id);
869
+ }
870
+ } catch { /* non-fatal */ }
871
+ await this._saveConfig();
872
+ await this._send(msg.chat.id, this._escapeMarkdown(`✨ Switched to ${agent.name} with a fresh context.`));
873
+ }
874
+
875
+ async _cmdLogout(msg) {
876
+ const chatId = String(msg.chat.id);
877
+ if (!this.chats.has(chatId)) {
878
+ await this._send(chatId, this._escapeMarkdown('Not registered.'));
879
+ return;
880
+ }
881
+ this.chats.delete(chatId);
882
+ await this._saveConfig();
883
+ await this._send(chatId, this._escapeMarkdown('👋 This chat has been disconnected from Loxia. /start to re-link.'));
884
+ }
885
+
886
+ _chatState(msg) {
887
+ return this.chats.get(String(msg?.chat?.id));
888
+ }
889
+
890
+ // ── Agent Conversation ─────────────────────────────────────────────
891
+
892
+ async _handleTextMessage(msg) {
893
+ if (!this._isAuthorized(msg)) return;
894
+ const state = this._chatState(msg);
895
+ if (!state) return;
896
+
897
+ // Pending prompt reply check uses chat-local context.
898
+ if (this.replyContext.has(String(msg.chat.id))) {
899
+ await this._handlePromptReply(msg);
900
+ return;
901
+ }
902
+
903
+ // Group chats: require @mention.
904
+ const { addressed, text } = this._stripMentionOrSkip(msg);
905
+ if (!addressed) return;
906
+ if (!text) return;
907
+
908
+ await this._dispatchToAgent(msg, text);
909
+ }
910
+
911
+ async _handleVoiceMessage(msg) {
912
+ if (!this._isAuthorized(msg)) return;
913
+ const state = this._chatState(msg);
914
+ if (!state) return;
915
+
916
+ // Groups: skip voice unless the chat has had at least one @mention
917
+ // (we can't transcribe-and-then-check; too costly to transcribe
918
+ // every voice). For now, ignore voice in groups entirely.
919
+ if (msg.chat.type !== 'private') return;
920
+
921
+ try {
922
+ await this.bot.sendChatAction(msg.chat.id, 'typing');
923
+ } catch { /* ignore */ }
924
+
925
+ let transcribed;
926
+ try {
927
+ transcribed = await this._transcribeVoice(msg.voice, msg);
928
+ } catch (err) {
929
+ // Log at ERROR level (not warn) so operators see the failure
930
+ // in the local server console. Then surface a friendly but
931
+ // diagnostic message to the user — include the upstream status
932
+ // / code when present so the cause is visible without server
933
+ // log access.
934
+ this.logger?.error?.('[TelegramService] voice transcription failed', {
935
+ error: err.message, code: err.code, status: err.status,
936
+ });
937
+ let userHint;
938
+ if (err.code === 'not_configured') {
939
+ userHint = 'Voice transcription needs an Azure speech-to-text deployment (e.g. `gpt-4o-mini-transcribe`). Please type your message for now.';
940
+ } else if (err.status === 401 || err.status === 403) {
941
+ userHint = 'Your account isn\'t authorised to call the transcription endpoint. Sign out and back in, then try again.';
942
+ } else if (err.status === 502 || err.status === 503) {
943
+ userHint = 'The transcription service is temporarily unavailable. Please try again in a moment.';
944
+ } else {
945
+ // Surface the upstream message itself so subtle bugs (audio
946
+ // format, file size, etc.) self-diagnose. Truncated to keep
947
+ // the Telegram bubble readable.
948
+ const tail = (err.message || 'unknown error').slice(0, 200);
949
+ userHint = `Please try again, or type your message. (debug: ${tail})`;
950
+ }
951
+ await this._send(msg.chat.id, this._escapeMarkdown(`🎤 Couldn't transcribe that voice note. ${userHint}`));
952
+ return;
953
+ }
954
+
955
+ if (!transcribed) {
956
+ await this._send(msg.chat.id, this._escapeMarkdown('🎤 Voice note was empty. Please try again or type your message.'));
957
+ return;
958
+ }
959
+
960
+ // Show the user what we heard, then act on it as if they had typed.
961
+ // `_Heard:_` is MarkdownV2 italic markup — must stay UNESCAPED, or
962
+ // the user just sees literal underscores. Only the transcribed
963
+ // body gets escaped, since it can contain arbitrary characters
964
+ // that would otherwise break the parse_mode.
965
+ await this._send(msg.chat.id, `🎤 _Heard:_ ${this._escapeMarkdown(transcribed)}`);
966
+ await this._dispatchToAgent(msg, transcribed);
967
+ }
968
+
969
+ /**
970
+ * Common path: resolve target agent and submit to the orchestrator.
971
+ * Emits a typing indicator + a "🔄 Thinking…" placeholder which the
972
+ * broadcast handler will edit with the real reply.
973
+ */
974
+ async _dispatchToAgent(msg, text) {
975
+ const state = this._chatState(msg);
976
+
977
+ // Parse @agent-name prefix. Supports names with spaces by doing
978
+ // longest-prefix matching against the live agent list — see
979
+ // _resolveAtPrefix() for the contract.
980
+ const agents = this.agentPool ? await this.agentPool.getAllAgents() : [];
981
+ const parsed = this._resolveAtPrefix(text, agents);
982
+ const agentName = parsed.agentName; // null when message has no @prefix
983
+ const messageText = parsed.messageText;
984
+
985
+ // Resolve agent.
986
+ let targetAgent;
987
+ if (agentName) {
988
+ targetAgent = parsed.matchedAgent || null;
989
+ if (!targetAgent) {
990
+ await this._send(msg.chat.id, this._escapeMarkdown(
991
+ `❌ Agent "${agentName}" not found. Use /agents to see available agents. ` +
992
+ 'Tip: names with spaces can be written either as `@John Smith` or `@John_Smith`.'
993
+ ));
994
+ return;
995
+ }
996
+ state.lastAgentId = targetAgent.id;
997
+ state.activeAgentIds.add(targetAgent.id);
998
+ await this._saveConfig();
999
+ } else if (state.lastAgentId) {
1000
+ targetAgent = this.agentPool ? await this.agentPool.getAgent(state.lastAgentId) : null;
1001
+ if (!targetAgent) {
1002
+ await this._send(msg.chat.id, this._escapeMarkdown('No agent selected. Use @agent-name message to address one.'));
1003
+ return;
1004
+ }
1005
+ } else {
1006
+ await this._send(msg.chat.id, this._escapeMarkdown('No agent selected. Use @agent-name message to address one.'));
1007
+ return;
1008
+ }
1009
+
1010
+ // Begin typing + thinking placeholder. Saved so the broadcast
1011
+ // handler can edit it with the agent's actual reply.
1012
+ try { await this.bot.sendChatAction(msg.chat.id, 'typing'); } catch { /* ignore */ }
1013
+ try {
1014
+ const placeholder = await this.bot.sendMessage(
1015
+ msg.chat.id,
1016
+ `🔄 *${this._escapeMarkdown(targetAgent.name)}* is thinking…`,
1017
+ { parse_mode: 'MarkdownV2' }
1018
+ );
1019
+ state.thinkingByAgentId.set(targetAgent.id, placeholder.message_id);
1020
+ } catch (err) {
1021
+ this.logger?.debug?.('[TelegramService] thinking placeholder send failed', { error: err.message });
1022
+ }
1023
+
1024
+ // Dispatch to orchestrator.
1025
+ try {
1026
+ if (this.orchestrator) {
1027
+ const sessionId = `telegram-${msg.chat.id}`;
1028
+ const source = createTelegramSource(msg);
1029
+ await this.orchestrator.processRequest({
1030
+ interface: 'telegram',
1031
+ sessionId,
1032
+ action: 'send_message',
1033
+ payload: {
1034
+ agentId: targetAgent.id,
1035
+ message: messageText,
1036
+ streamingEnabled: false,
1037
+ source,
1038
+ },
1039
+ projectDir: this.orchestrator.config?.project?.directory || process.cwd(),
1040
+ });
1041
+ }
1042
+ } catch (error) {
1043
+ // Clear the placeholder, surface the failure.
1044
+ await this._clearThinking(msg.chat.id, targetAgent.id);
1045
+ await this._send(msg.chat.id, this._escapeMarkdown(`❌ Failed to send: ${error.message}`));
1046
+ }
1047
+ }
1048
+
1049
+ async _clearThinking(chatId, agentId) {
1050
+ const state = this.chats.get(String(chatId));
1051
+ if (!state) return;
1052
+ const messageId = state.thinkingByAgentId.get(agentId);
1053
+ if (!messageId) return;
1054
+ state.thinkingByAgentId.delete(agentId);
1055
+ try {
1056
+ await this.bot.deleteMessage(chatId, messageId);
1057
+ } catch { /* if user deleted it, that's fine */ }
1058
+ }
1059
+
1060
+ // ── Voice transcription (via backend) ──────────────────────────────
1061
+
1062
+ async _transcribeVoice(voice, msg = null) {
1063
+ if (!voice?.file_id) throw Object.assign(new Error('no file_id'), { code: 'bad_request' });
1064
+ const platformKey = this._resolvePlatformKey();
1065
+ if (!platformKey) {
1066
+ throw Object.assign(new Error('platform key not set'), { code: 'not_configured' });
1067
+ }
1068
+ // 1) Get the file URL from Telegram + download bytes.
1069
+ const fileLink = await this.bot.getFileLink(voice.file_id);
1070
+ const audioRes = await fetch(fileLink);
1071
+ if (!audioRes.ok) throw new Error(`audio download failed: ${audioRes.status}`);
1072
+ const audioBuf = Buffer.from(await audioRes.arrayBuffer());
1073
+ const audioBase64 = audioBuf.toString('base64');
1074
+
1075
+ // 2) POST to backend /llm/transcribe. Telegram knows the audio
1076
+ // duration upfront (voice.duration); pass it so the backend can
1077
+ // price the call correctly even when the model's response shape
1078
+ // doesn't include duration (gpt-4o-*-transcribe with json format).
1079
+ // The Telegram message id is a natural idempotency key — a retry
1080
+ // of the same voice note dedups at the recorder layer.
1081
+ const url = `${this.backendBaseUrl}/llm/transcribe`;
1082
+ const apiRes = await fetch(url, {
1083
+ method: 'POST',
1084
+ headers: {
1085
+ 'Authorization': `Bearer ${platformKey}`,
1086
+ 'Content-Type': 'application/json',
1087
+ },
1088
+ body: JSON.stringify({
1089
+ audioBase64,
1090
+ mimeType: voice.mime_type || 'audio/ogg',
1091
+ filename: `voice_${Date.now()}.ogg`,
1092
+ durationSeconds: typeof voice.duration === 'number' ? voice.duration : undefined,
1093
+ idempotencyKey: msg?.message_id
1094
+ ? `tg-${msg.chat?.id}-${msg.message_id}`
1095
+ : undefined,
1096
+ }),
1097
+ });
1098
+ if (apiRes.status === 503) {
1099
+ throw Object.assign(new Error('not configured'), { code: 'not_configured', status: 503 });
1100
+ }
1101
+ if (!apiRes.ok) {
1102
+ const body = await apiRes.text().catch(() => '');
1103
+ let parsedMsg = '';
1104
+ try { parsedMsg = JSON.parse(body)?.error || ''; } catch { /* not JSON */ }
1105
+ const detail = parsedMsg || body.slice(0, 200);
1106
+ throw Object.assign(
1107
+ new Error(`backend transcribe ${apiRes.status}: ${detail}`),
1108
+ { status: apiRes.status }
1109
+ );
1110
+ }
1111
+ const data = await apiRes.json();
1112
+ return (data?.text || '').trim();
1113
+ }
1114
+
1115
+ // ── Callback Query Handler ─────────────────────────────────────────
1116
+
1117
+ async _handleCallbackQuery(query) {
1118
+ const chatId = String(query.message.chat.id);
1119
+ if (!this.chats.has(chatId)) return;
1120
+ const state = this.chats.get(chatId);
1121
+
1122
+ const data = query.data || '';
1123
+ try { await this.bot.answerCallbackQuery(query.id); } catch { /* ignore */ }
1124
+
1125
+ if (data === 'noop') return;
1126
+
1127
+ if (data.startsWith('agents_page:')) {
1128
+ const page = parseInt(data.split(':')[1], 10) || 0;
1129
+ const agents = this.agentPool ? await this.agentPool.getAllAgents() : [];
1130
+ await this._renderAgentsPage(chatId, agents, page);
1131
+ return;
1132
+ }
1133
+ if (data.startsWith('flows_page:')) {
1134
+ const page = parseInt(data.split(':')[1], 10) || 0;
1135
+ if (!this.orchestrator?.stateManager) return;
1136
+ const projectDir = this.orchestrator.config?.project?.directory || process.cwd();
1137
+ const flowIndex = await this.orchestrator.stateManager.loadFlowIndex?.(projectDir) || {};
1138
+ await this._renderFlowsPage(chatId, Object.entries(flowIndex), page);
1139
+ return;
1140
+ }
1141
+ if (data.startsWith('agent_detail:')) {
1142
+ await this._showAgentDetail(chatId, data.slice('agent_detail:'.length));
1143
+ return;
1144
+ }
1145
+ if (data.startsWith('msg_agent:')) {
1146
+ const agentId = data.slice('msg_agent:'.length);
1147
+ state.lastAgentId = agentId;
1148
+ state.activeAgentIds.add(agentId);
1149
+ await this._saveConfig();
1150
+ const agent = this.agentPool ? await this.agentPool.getAgent(agentId) : null;
1151
+ await this._send(chatId, this._escapeMarkdown(`💬 Now chatting with ${agent?.name || agentId}. Type your message.`));
1152
+ return;
1153
+ }
1154
+ if (data.startsWith('stop_agent:')) {
1155
+ const agentId = data.slice('stop_agent:'.length);
1156
+ if (this.orchestrator?.messageProcessor) {
1157
+ await this.orchestrator.messageProcessor.stopAutonomousExecution(agentId);
1158
+ }
1159
+ await this._send(chatId, this._escapeMarkdown('⏹ Agent stopped.'));
1160
+ return;
1161
+ }
1162
+ if (data.startsWith('run_flow:')) {
1163
+ await this._runFlow(chatId, data.slice('run_flow:'.length));
1164
+ return;
1165
+ }
1166
+ if (data.startsWith('unfollow:')) {
1167
+ const agentId = data.slice('unfollow:'.length);
1168
+ state.activeAgentIds.delete(agentId);
1169
+ if (state.lastAgentId === agentId) {
1170
+ state.lastAgentId = state.activeAgentIds.size > 0 ? [...state.activeAgentIds][0] : null;
1171
+ }
1172
+ await this._saveConfig();
1173
+ const agent = this.agentPool ? await this.agentPool.getAgent(agentId) : null;
1174
+ await this._send(chatId, this._escapeMarkdown(`Unfollowed ${agent?.name || agentId}.`));
1175
+ return;
1176
+ }
1177
+ if (data.startsWith('prompt_reply:')) {
1178
+ const m = data.match(/prompt_reply:(.+):(\d+)/);
1179
+ if (m && this.pendingRelays.has(m[1])) {
1180
+ await this._submitPromptReply(m[1], m[2]);
1181
+ }
1182
+ return;
1183
+ }
1184
+ if (data.startsWith('act:')) {
1185
+ // <actions> button tapped — treat value as a user message to the
1186
+ // sticky agent. Format: `act:<agentId>:<value>` where value may
1187
+ // contain colons; split on first two.
1188
+ const idx1 = data.indexOf(':');
1189
+ const idx2 = data.indexOf(':', idx1 + 1);
1190
+ const agentId = data.slice(idx1 + 1, idx2);
1191
+ const value = data.slice(idx2 + 1);
1192
+ if (!agentId || !value) return;
1193
+
1194
+ // Synthesize a message that mirrors what a real user input would
1195
+ // look like in the same handler. Forwarding the tap as a
1196
+ // synthetic user turn keeps the agent's transcript honest.
1197
+ const synthetic = {
1198
+ chat: query.message.chat,
1199
+ from: query.from,
1200
+ message_id: query.message.message_id,
1201
+ text: `@${(await this._agentName(agentId))} ${value}`,
1202
+ };
1203
+ await this._dispatchToAgent(synthetic, synthetic.text);
1204
+ }
1205
+ }
1206
+
1207
+ async _agentName(agentId) {
1208
+ const agent = this.agentPool ? await this.agentPool.getAgent(agentId) : null;
1209
+ return agent?.name || agentId;
1210
+ }
1211
+
1212
+ // ── Broadcast Interceptor ──────────────────────────────────────────
1213
+
1214
+ _interceptBroadcasts(wsManager) {
1215
+ if (!wsManager || this._originalBroadcast) return;
1216
+ const originalBroadcast = wsManager.broadcastToSession.bind(wsManager);
1217
+ this._originalBroadcast = originalBroadcast;
1218
+ wsManager.broadcastToSession = (sessionId, message) => {
1219
+ originalBroadcast(sessionId, message);
1220
+ this._handleBroadcastEvent(message);
1221
+ };
1222
+ }
1223
+
1224
+ async _handleBroadcastEvent(message) {
1225
+ if (!this.bot || this.chats.size === 0 || this.status !== TELEGRAM_STATUS.CONNECTED) return;
1226
+ const type = message?.type;
1227
+ if (!type) return;
1228
+
1229
+ if (type === 'user_prompt_request') {
1230
+ await this._relayPromptRequest(message);
1231
+ return;
1232
+ }
1233
+ if (type === 'credential_request') {
1234
+ await this._relayCredentialRequest(message);
1235
+ return;
1236
+ }
1237
+ if (type === 'stream_complete') {
1238
+ await this._relayAgentResponse(message);
1239
+ return;
1240
+ }
1241
+ if (type === 'imageGenerated') {
1242
+ // Buffer the URL keyed by agentId. The actual send happens when
1243
+ // the matching stream_complete arrives (typical case — agent
1244
+ // says "here's your image: ..." with the image markdown), or as
1245
+ // a standalone send if no text reply ever lands (image-only
1246
+ // turn, e.g. an automated render flow).
1247
+ this._bufferGeneratedImage(message);
1248
+ return;
1249
+ }
1250
+
1251
+ // Errors and timeouts ALWAYS reach the user. Other completions
1252
+ // require /watch.
1253
+ const ALWAYS_RELAY = new Set(['agent_error', 'agent_timeout', 'criticalError', 'flow_run_failed']);
1254
+ const WATCH_GATED = new Set(['execution_stopped', 'flow_run_completed']);
1255
+
1256
+ const headers = {
1257
+ agent_error: '⚠️ *Agent Error*',
1258
+ agent_timeout: '⏰ *Agent Timeout*',
1259
+ criticalError: '🔴 *Critical Error*',
1260
+ flow_run_failed: '❌ *Flow Failed*',
1261
+ execution_stopped: '✅ *Agent Finished*',
1262
+ flow_run_completed: '✅ *Flow Completed*',
1263
+ };
1264
+ if (!headers[type]) return;
1265
+
1266
+ let text = `${headers[type]}\n`;
1267
+ if (message.agentName || message.data?.agentName) {
1268
+ text += `Agent: \`${message.agentName || message.data?.agentName}\`\n`;
1269
+ }
1270
+ if (message.message || message.data?.message || message.error) {
1271
+ text += `${message.message || message.data?.message || message.error}\n`;
1272
+ }
1273
+
1274
+ if (ALWAYS_RELAY.has(type)) {
1275
+ // Dedup: collapse identical (agent, type, message) bursts within
1276
+ // ERROR_DEDUP_WINDOW_MS into a single first-message + an optional
1277
+ // "still happening" summary when the window closes.
1278
+ const agentId = message.agentId || message.data?.agentId || '_unknown_';
1279
+ const errorMsg = message.message || message.data?.message || message.error || '';
1280
+ const decision = this._registerErrorBroadcast({
1281
+ agentId,
1282
+ type,
1283
+ errorMsg,
1284
+ agentName: message.agentName || message.data?.agentName,
1285
+ });
1286
+ if (decision.shouldEmit) {
1287
+ this._fanoutNotification(text);
1288
+ }
1289
+ // else: suppressed; counter incremented; the summary timer will
1290
+ // fire at the end of the window if count > 1.
1291
+ } else if (WATCH_GATED.has(type)) {
1292
+ for (const [chatId, state] of this.chats.entries()) {
1293
+ if (state.watchEnabled) this._queueNotification(chatId, text);
1294
+ }
1295
+ }
1296
+ }
1297
+
1298
+ /**
1299
+ * Register an error broadcast for dedup tracking.
1300
+ *
1301
+ * First occurrence in window → returns { shouldEmit: true }, schedules a
1302
+ * close-of-window summary if more arrive.
1303
+ * Subsequent occurrences → returns { shouldEmit: false }, increments
1304
+ * the counter so the summary knows the magnitude.
1305
+ *
1306
+ * The dedup key normalises the error message (lower-case, whitespace-
1307
+ * collapsed, length-capped) so trivial textual variations of the same
1308
+ * underlying failure still coalesce.
1309
+ *
1310
+ * @param {{agentId: string, type: string, errorMsg: string, agentName?: string}} args
1311
+ * @returns {{ shouldEmit: boolean }}
1312
+ * @private
1313
+ */
1314
+ _registerErrorBroadcast({ agentId, type, errorMsg, agentName }) {
1315
+ // Plain string ops, no regex — collapse whitespace then truncate.
1316
+ const normalised = _collapseWhitespace(String(errorMsg || '').toLowerCase()).slice(0, 200);
1317
+ const key = `${agentId}:${type}:${normalised}`;
1318
+
1319
+ const existing = this._errorBroadcastDedup.get(key);
1320
+ if (existing) {
1321
+ existing.count++;
1322
+ existing.lastAt = Date.now();
1323
+ if (agentName) existing.agentName = agentName;
1324
+ return { shouldEmit: false };
1325
+ }
1326
+
1327
+ // Capacity guard — evict the oldest entry if we're at the cap.
1328
+ if (this._errorBroadcastDedup.size >= ERROR_DEDUP_MAX_KEYS) {
1329
+ const oldestKey = this._errorBroadcastDedup.keys().next().value;
1330
+ const oldest = this._errorBroadcastDedup.get(oldestKey);
1331
+ if (oldest?.timer) clearTimeout(oldest.timer);
1332
+ this._errorBroadcastDedup.delete(oldestKey);
1333
+ }
1334
+
1335
+ const now = Date.now();
1336
+ const timer = setTimeout(() => {
1337
+ const entry = this._errorBroadcastDedup.get(key);
1338
+ this._errorBroadcastDedup.delete(key);
1339
+ if (entry && entry.count > 1) {
1340
+ // Emit a brief "still happening" summary. We don't replay the
1341
+ // full error text — the user already has the first message;
1342
+ // this just tells them it kept happening so they know it's not
1343
+ // a one-off.
1344
+ const extra = entry.count - 1;
1345
+ const who = entry.agentName ? `\\\`${entry.agentName}\\\` ` : '';
1346
+ const summary = `⚠️ *Agent Error · still happening*\nAgent: ${who}\n${extra} more similar error${extra === 1 ? '' : 's'} in the last ${Math.round(ERROR_DEDUP_WINDOW_MS / 60000)} min.`;
1347
+ this._fanoutNotification(summary).catch(() => {});
1348
+ }
1349
+ }, ERROR_DEDUP_WINDOW_MS);
1350
+ // Don't keep the event loop alive for a notification timer.
1351
+ if (typeof timer.unref === 'function') timer.unref();
1352
+
1353
+ this._errorBroadcastDedup.set(key, {
1354
+ count: 1,
1355
+ firstAt: now,
1356
+ lastAt: now,
1357
+ agentName,
1358
+ timer,
1359
+ });
1360
+ return { shouldEmit: true };
1361
+ }
1362
+
1363
+ /** Send `text` to every linked chat regardless of /watch state. */
1364
+ async _fanoutNotification(text) {
1365
+ for (const chatId of this.chats.keys()) {
1366
+ try { await this._send(chatId, text); } catch { /* per-chat failures shouldn't block others */ }
1367
+ }
1368
+ }
1369
+
1370
+ async _relayAgentResponse(message) {
1371
+ const agentId = message.agentId || message.data?.agentId;
1372
+ if (!agentId) return;
1373
+
1374
+ const content = message.content || message.data?.content ||
1375
+ message.message?.content || message.data?.message?.content;
1376
+
1377
+ // Skip user/tool roles.
1378
+ const role = message.role || message.data?.role || message.message?.role;
1379
+ if (role === 'user' || role === 'tool') return;
1380
+
1381
+ // Drain any image buffered by the imageTool. We do this even when
1382
+ // there's no text content — image-only completions still need to
1383
+ // reach Telegram. We do it for every active chat with this agent.
1384
+ const pendingImages = this._consumePendingImages(agentId);
1385
+
1386
+ const { blocks } = content ? filterContentForExternalRelay(content) : { blocks: [] };
1387
+
1388
+ if (blocks.length === 0) {
1389
+ // No <external> text block. Clear thinking placeholders so the
1390
+ // user isn't left looking at "🔄 …", and — critically — still
1391
+ // ship any buffered images for this agent so "draw me a sunset"
1392
+ // through Telegram doesn't go silent when the agent's text
1393
+ // reply omits the image markdown.
1394
+ for (const [chatId, state] of this.chats.entries()) {
1395
+ await this._clearThinking(chatId, agentId);
1396
+ if (pendingImages.length > 0 && state.activeAgentIds.has(agentId)) {
1397
+ for (const img of pendingImages) {
1398
+ await this._sendMedia(chatId, { kind: 'image', src: img.imageUrl, caption: img.prompt || undefined });
1399
+ }
1400
+ }
1401
+ }
1402
+ return;
1403
+ }
1404
+
1405
+ const agent = this.agentPool ? await this.agentPool.getAgent(agentId) : null;
1406
+ const agentName = agent?.name || agentId;
1407
+
1408
+ // For each chat where this agent is active, send the block(s).
1409
+ for (const [chatId, state] of this.chats.entries()) {
1410
+ if (!state.activeAgentIds.has(agentId)) continue;
1411
+
1412
+ const ownedAliases = this._aliasesForChat(chatId);
1413
+ let firstBlock = true;
1414
+
1415
+ for (const block of blocks) {
1416
+ const targets = resolveBlockTargets(block, ownedAliases);
1417
+ if (targets.length === 0) continue;
1418
+
1419
+ try {
1420
+ const parsed = parseTelegramBlock(block.text);
1421
+
1422
+ // Edit the thinking placeholder on the first block; subsequent
1423
+ // blocks go as new messages so users still see them as separate
1424
+ // turns when an agent emits multiple <external> blocks.
1425
+ if (firstBlock) {
1426
+ await this._replaceThinkingWithReply(chatId, agentId, agentName, parsed);
1427
+ firstBlock = false;
1428
+ } else {
1429
+ await this._sendParsedPayload(chatId, agentName, parsed);
1430
+ }
1431
+
1432
+ // Media is sent after the text body in either path.
1433
+ for (const media of parsed.media) {
1434
+ await this._sendMedia(chatId, media);
1435
+ }
1436
+ } catch (error) {
1437
+ this.logger?.warn?.('[TelegramService] block relay failed', {
1438
+ chatId, agentId, error: error.message,
1439
+ });
1440
+ }
1441
+ }
1442
+
1443
+ // After the text blocks land, ship any imageTool-generated
1444
+ // images that aren't already embedded in a parsed block. We
1445
+ // dedupe by URL against the block parser's media list so we
1446
+ // don't double-post an image the agent already referenced
1447
+ // inline.
1448
+ if (pendingImages.length > 0) {
1449
+ const embeddedUrls = new Set();
1450
+ for (const block of blocks) {
1451
+ try {
1452
+ for (const m of parseTelegramBlock(block.text).media) {
1453
+ if (m?.src) embeddedUrls.add(m.src);
1454
+ }
1455
+ } catch { /* parser failures already logged above */ }
1456
+ }
1457
+ for (const img of pendingImages) {
1458
+ if (embeddedUrls.has(img.imageUrl)) continue;
1459
+ await this._sendMedia(chatId, { kind: 'image', src: img.imageUrl, caption: img.prompt || undefined });
1460
+ }
1461
+ }
1462
+ }
1463
+ }
1464
+
1465
+ /**
1466
+ * Stash an `imageGenerated` broadcast for later flush by
1467
+ * `_relayAgentResponse`. Prunes stale entries (>5min) on insert so
1468
+ * the buffer can't grow unboundedly if no stream_complete ever
1469
+ * arrives for that agent.
1470
+ */
1471
+ _bufferGeneratedImage(message) {
1472
+ const agentId = message.agentId || message.data?.agentId;
1473
+ const imageUrl = message.imageUrl || message.data?.imageUrl;
1474
+ if (!agentId || !imageUrl) return;
1475
+ const prompt = message.prompt || message.data?.prompt || null;
1476
+
1477
+ const arr = this._pendingImagesByAgent.get(agentId) || [];
1478
+ const now = Date.now();
1479
+ // Drop stale entries while we're touching this list.
1480
+ const fresh = arr.filter(e => now - e.addedAt < IMAGE_PENDING_TTL_MS);
1481
+ fresh.push({ imageUrl, prompt, addedAt: now });
1482
+ this._pendingImagesByAgent.set(agentId, fresh);
1483
+ }
1484
+
1485
+ /** Atomically read-and-clear pending images for `agentId`. */
1486
+ _consumePendingImages(agentId) {
1487
+ const arr = this._pendingImagesByAgent.get(agentId);
1488
+ if (!arr || arr.length === 0) return [];
1489
+ this._pendingImagesByAgent.delete(agentId);
1490
+ const now = Date.now();
1491
+ return arr.filter(e => now - e.addedAt < IMAGE_PENDING_TTL_MS);
1492
+ }
1493
+
1494
+ _aliasesForChat(chatId) {
1495
+ // Today we advertise a single chat-scoped alias; expanding later
1496
+ // to per-channel aliases is a one-line change.
1497
+ return ['telegram', `telegram:chat-${chatId}`];
1498
+ }
1499
+
1500
+ /**
1501
+ * Edit the in-flight thinking placeholder to show the agent's real
1502
+ * reply. Falls back to a fresh message if editing fails (e.g. the
1503
+ * placeholder was deleted by the user).
1504
+ */
1505
+ async _replaceThinkingWithReply(chatId, agentId, agentName, parsed) {
1506
+ const state = this.chats.get(String(chatId));
1507
+ const placeholderId = state?.thinkingByAgentId.get(agentId);
1508
+ state?.thinkingByAgentId.delete(agentId);
1509
+
1510
+ const header = `*${this._escapeMarkdown(agentName)}:*\n\n`;
1511
+ const formatted = this._formatAgentBody(parsed.text);
1512
+ const replyMarkup = this._buildActionKeyboard(agentId, parsed.actions);
1513
+
1514
+ if (formatted.kind === 'document') {
1515
+ // Code-heavy reply: drop the placeholder, send the document.
1516
+ if (placeholderId) {
1517
+ try { await this.bot.deleteMessage(chatId, placeholderId); } catch { /* swallow */ }
1518
+ }
1519
+ await this._sendCodeDocument(chatId, agentName, formatted.body);
1520
+ return;
1521
+ }
1522
+
1523
+ const fullText = header + formatted.body;
1524
+ const sendOpts = { parse_mode: formatted.parseMode };
1525
+ if (replyMarkup) sendOpts.reply_markup = replyMarkup;
1526
+
1527
+ if (placeholderId) {
1528
+ try {
1529
+ await this.bot.editMessageText(fullText, {
1530
+ chat_id: chatId,
1531
+ message_id: placeholderId,
1532
+ ...sendOpts,
1533
+ });
1534
+ return;
1535
+ } catch (err) {
1536
+ this.logger?.debug?.('[TelegramService] edit placeholder failed, sending fresh', { error: err.message });
1537
+ }
1538
+ }
1539
+ await this._sendRaw(chatId, fullText, sendOpts);
1540
+ }
1541
+
1542
+ /** Send a parsed payload (additional blocks after the first). */
1543
+ async _sendParsedPayload(chatId, agentName, parsed) {
1544
+ const header = `*${this._escapeMarkdown(agentName)}:*\n\n`;
1545
+ const formatted = this._formatAgentBody(parsed.text);
1546
+ if (formatted.kind === 'document') {
1547
+ await this._sendCodeDocument(chatId, agentName, formatted.body);
1548
+ return;
1549
+ }
1550
+ const replyMarkup = this._buildActionKeyboard(null, parsed.actions);
1551
+ const opts = { parse_mode: formatted.parseMode };
1552
+ if (replyMarkup) opts.reply_markup = replyMarkup;
1553
+ await this._sendRaw(chatId, header + formatted.body, opts);
1554
+ }
1555
+
1556
+ _buildActionKeyboard(agentId, actions) {
1557
+ if (!Array.isArray(actions) || actions.length === 0) return null;
1558
+ if (!agentId) return null; // can't dispatch without an agent
1559
+ return {
1560
+ inline_keyboard: actions.map(a => ([{
1561
+ text: a.label,
1562
+ // Telegram caps callback_data at 64 bytes. The shape is
1563
+ // `act:<agentId>:<value>` so the value is what's first to be
1564
+ // truncated. agentId is typically a UUID (36 chars) + 'act:'
1565
+ // prefix (4 chars) leaves 24 chars for the value.
1566
+ callback_data: `act:${agentId}:${a.value}`.slice(0, 64),
1567
+ }])),
1568
+ };
1569
+ }
1570
+
1571
+ // ── Text formatting: HTML for code blocks, MarkdownV2 otherwise ────
1572
+
1573
+ _formatAgentBody(body) {
1574
+ if (!body || typeof body !== 'string' || body.trim() === '') {
1575
+ return { kind: 'text', parseMode: 'MarkdownV2', body: this._escapeMarkdown('(empty response)') };
1576
+ }
1577
+
1578
+ const fenceCount = (body.match(/```/g) || []).length;
1579
+ const hasFencedCode = fenceCount >= 2;
1580
+ const isCodeHeavy = hasFencedCode && body.length > CODE_AS_FILE_THRESHOLD;
1581
+
1582
+ if (isCodeHeavy) {
1583
+ // Send as document — guarantees no markdown mangling for big payloads.
1584
+ return { kind: 'document', body };
1585
+ }
1586
+
1587
+ if (hasFencedCode) {
1588
+ // HTML mode is far more forgiving than MarkdownV2 for mixed-code text.
1589
+ // Convert fenced blocks to <pre><code>…</code></pre>.
1590
+ const htmlBody = body.replace(/```(\w+)?\n([\s\S]*?)```/g, (_full, _lang, code) => {
1591
+ const escaped = String(code)
1592
+ .replace(/&/g, '&amp;')
1593
+ .replace(/</g, '&lt;')
1594
+ .replace(/>/g, '&gt;');
1595
+ return `<pre>${escaped}</pre>`;
1596
+ });
1597
+ // Inline-code backticks too.
1598
+ const withInline = htmlBody.replace(/`([^`\n]+)`/g, (_m, c) => {
1599
+ const esc = String(c).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
1600
+ return `<code>${esc}</code>`;
1601
+ });
1602
+ // Escape everything else that HTML mode cares about (only <, >, &
1603
+ // outside the tags). Easiest: split by <pre>/<code> tags and
1604
+ // escape non-code parts.
1605
+ const safe = this._escapeHtmlPreservingTags(withInline);
1606
+ const truncated = safe.length > MAX_MESSAGE_LENGTH
1607
+ ? safe.slice(0, MAX_MESSAGE_LENGTH - 100) + '\n\n… (truncated)'
1608
+ : safe;
1609
+ return { kind: 'text', parseMode: 'HTML', body: truncated };
1610
+ }
1611
+
1612
+ // Plain text: full MarkdownV2 escape.
1613
+ const escaped = this._escapeMarkdown(body);
1614
+ const truncated = escaped.length > MAX_MESSAGE_LENGTH
1615
+ ? escaped.slice(0, MAX_MESSAGE_LENGTH - 100) + '\n\n… (truncated)'
1616
+ : escaped;
1617
+ return { kind: 'text', parseMode: 'MarkdownV2', body: truncated };
1618
+ }
1619
+
1620
+ _escapeHtmlPreservingTags(text) {
1621
+ // Split into segments alternating between tag-segments and text.
1622
+ // We only allow <pre>/<code>; anything else gets escaped.
1623
+ const parts = text.split(/(<\/?(?:pre|code)>)/g);
1624
+ return parts.map((p) => {
1625
+ if (/^<\/?(pre|code)>$/i.test(p)) return p;
1626
+ return p.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
1627
+ }).join('');
1628
+ }
1629
+
1630
+ async _sendCodeDocument(chatId, agentName, body) {
1631
+ if (!this.bot) return;
1632
+ try {
1633
+ const filename = `${(agentName || 'reply').replace(/[^A-Za-z0-9-]/g, '_')}-${Date.now()}.txt`;
1634
+ // node-telegram-bot-api accepts a Buffer for sendDocument with
1635
+ // options.filename for the displayed name.
1636
+ await this.bot.sendDocument(
1637
+ chatId,
1638
+ Buffer.from(body, 'utf8'),
1639
+ { caption: `*${this._escapeMarkdown(agentName)}* — full reply (sent as a file because it\\'s code\\-heavy).`, parse_mode: 'MarkdownV2' },
1640
+ { filename, contentType: 'text/plain' }
1641
+ );
1642
+ } catch (error) {
1643
+ this.logger?.warn?.('[TelegramService] code-document send failed, falling back to truncated text', { error: error.message });
1644
+ const fallback = body.slice(0, MAX_MESSAGE_LENGTH - 200) + '\n\n… (truncated; see local web UI for full reply)';
1645
+ await this._sendRaw(chatId, `*${this._escapeMarkdown(agentName)}:*\n\n` + this._escapeMarkdown(fallback), { parse_mode: 'MarkdownV2' });
1646
+ }
1647
+ }
1648
+
1649
+ async _sendMedia(chatId, media) {
1650
+ if (!this.bot) return;
1651
+ try {
1652
+ const captionMd = media.caption ? this._escapeMarkdown(media.caption) : undefined;
1653
+ if (media.kind === 'image') {
1654
+ await this.bot.sendPhoto(chatId, media.src, {
1655
+ caption: captionMd, parse_mode: 'MarkdownV2',
1656
+ });
1657
+ } else if (media.kind === 'document') {
1658
+ await this.bot.sendDocument(chatId, media.src, {
1659
+ caption: captionMd, parse_mode: 'MarkdownV2',
1660
+ });
1661
+ }
1662
+ } catch (error) {
1663
+ this.logger?.warn?.('[TelegramService] media send failed', { error: error.message, kind: media.kind });
1664
+ }
1665
+ }
1666
+
1667
+ // ── Prompt Relay ───────────────────────────────────────────────────
1668
+
1669
+ async _relayPromptRequest(message) {
1670
+ if (this.chats.size === 0) return;
1671
+ const data = message.data || message;
1672
+ const requestId = data.requestId;
1673
+ const agentId = data.agentId;
1674
+ const questions = data.questions || [];
1675
+ if (!requestId || questions.length === 0) return;
1676
+
1677
+ const agent = this.agentPool ? await this.agentPool.getAgent(agentId) : null;
1678
+ const agentName = agent?.name || agentId;
1679
+
1680
+ // Fan out to every chat that has this agent active.
1681
+ for (const [chatId, state] of this.chats.entries()) {
1682
+ if (!state.activeAgentIds.has(agentId)) continue;
1683
+
1684
+ this.pendingRelays.set(requestId, { type: 'user_prompt', agentId, chatId, questions, timestamp: Date.now() });
1685
+
1686
+ for (const q of questions) {
1687
+ let text = `🔔 *${this._escapeMarkdown(agentName)}* needs your input:\n\n`;
1688
+ text += this._escapeMarkdown(q.question) + '\n';
1689
+ const options = q.options || [];
1690
+ if (options.length > 0) {
1691
+ const buttons = options.map((opt, i) => ([{
1692
+ text: opt.label,
1693
+ callback_data: `prompt_reply:${requestId}:${i}`,
1694
+ }]));
1695
+ await this._send(chatId, text, { reply_markup: { inline_keyboard: buttons } });
1696
+ } else {
1697
+ text += '\n_Type your reply:_';
1698
+ this.replyContext.set(chatId, { type: 'user_prompt', requestId, agentId });
1699
+ await this._send(chatId, text);
1700
+ }
1701
+ }
1702
+
1703
+ // Two timers per request:
1704
+ // - A 3-min reminder ("still waiting")
1705
+ // - A 10-min hard timeout ("expired — please re-trigger the action")
1706
+ const reminderId = setTimeout(() => {
1707
+ if (this.pendingRelays.has(requestId)) {
1708
+ this._send(chatId, this._escapeMarkdown(`⏰ Reminder: ${agentName} is still waiting for your input.`)).catch(() => {});
1709
+ }
1710
+ }, PROMPT_REMINDER_MS);
1711
+ const timeoutId = setTimeout(() => {
1712
+ if (this.pendingRelays.has(requestId)) {
1713
+ this.pendingRelays.delete(requestId);
1714
+ this.replyContext.delete(chatId);
1715
+ this._send(chatId, this._escapeMarkdown(`⏱ Prompt from ${agentName} expired without a reply. Re-run the action if you still need it.`)).catch(() => {});
1716
+ }
1717
+ }, PROMPT_TIMEOUT_MS);
1718
+ const existing = this.pendingRelays.get(requestId);
1719
+ if (existing) {
1720
+ existing.reminderId = reminderId;
1721
+ existing.timeoutId = timeoutId;
1722
+ }
1723
+ }
1724
+ }
1725
+
1726
+ async _relayCredentialRequest(message) {
1727
+ if (this.chats.size === 0) return;
1728
+ const data = message.data || message;
1729
+ const requestId = data.requestId;
1730
+ const agentId = data.agentId;
1731
+ const agent = this.agentPool ? await this.agentPool.getAgent(agentId) : null;
1732
+ const agentName = agent?.name || agentId;
1733
+
1734
+ for (const [chatId, state] of this.chats.entries()) {
1735
+ if (!state.activeAgentIds.has(agentId)) continue;
1736
+ this.pendingRelays.set(requestId, { type: 'credential', agentId, chatId, timestamp: Date.now() });
1737
+ this.replyContext.set(chatId, { type: 'credential', requestId, agentId });
1738
+ const text = `🔐 *${this._escapeMarkdown(agentName)}* needs credentials:\n\n` +
1739
+ this._escapeMarkdown(data.message || 'Please provide the requested credentials.') +
1740
+ '\n\n_Type your reply:_';
1741
+ await this._send(chatId, text);
1742
+
1743
+ // Timeout for credential requests too.
1744
+ const timeoutId = setTimeout(() => {
1745
+ if (this.pendingRelays.has(requestId)) {
1746
+ this.pendingRelays.delete(requestId);
1747
+ this.replyContext.delete(chatId);
1748
+ this._send(chatId, this._escapeMarkdown(`⏱ Credential request from ${agentName} expired. Re-trigger if needed.`)).catch(() => {});
1749
+ }
1750
+ }, PROMPT_TIMEOUT_MS);
1751
+ const existing = this.pendingRelays.get(requestId);
1752
+ if (existing) existing.timeoutId = timeoutId;
1753
+ }
1754
+ }
1755
+
1756
+ async _handlePromptReply(msg) {
1757
+ const chatId = String(msg.chat.id);
1758
+ const ctx = this.replyContext.get(chatId);
1759
+ if (!ctx) return;
1760
+
1761
+ const { type, requestId } = ctx;
1762
+ const text = msg.text?.trim();
1763
+ if (!text) return;
1764
+
1765
+ this.replyContext.delete(chatId);
1766
+ const relay = this.pendingRelays.get(requestId);
1767
+ if (relay) {
1768
+ if (relay.reminderId) clearTimeout(relay.reminderId);
1769
+ if (relay.timeoutId) clearTimeout(relay.timeoutId);
1770
+ }
1771
+ this.pendingRelays.delete(requestId);
1772
+
1773
+ try {
1774
+ if (type === 'user_prompt' && this.webSocketManager) {
1775
+ this.webSocketManager._handleUserPromptResult?.({
1776
+ requestId, answers: { default: text },
1777
+ });
1778
+ } else if (type === 'credential' && this.webSocketManager) {
1779
+ this.webSocketManager._handleCredentialResponse?.({
1780
+ requestId, credentials: { value: text }, saveForFuture: false,
1781
+ });
1782
+ }
1783
+ await this._send(msg.chat.id, this._escapeMarkdown('✅ Response submitted.'));
1784
+ } catch (error) {
1785
+ await this._send(msg.chat.id, this._escapeMarkdown(`❌ Failed to submit: ${error.message}`));
1786
+ }
1787
+ }
1788
+
1789
+ async _submitPromptReply(requestId, answerIndex) {
1790
+ const relay = this.pendingRelays.get(requestId);
1791
+ if (!relay) return;
1792
+ if (relay.reminderId) clearTimeout(relay.reminderId);
1793
+ if (relay.timeoutId) clearTimeout(relay.timeoutId);
1794
+ this.pendingRelays.delete(requestId);
1795
+ this.replyContext.delete(String(relay.chatId));
1796
+
1797
+ try {
1798
+ const question = relay.questions?.[0];
1799
+ const option = question?.options?.[parseInt(answerIndex)];
1800
+ const answer = option?.label || String(answerIndex);
1801
+ if (this.webSocketManager?._handleUserPromptResult) {
1802
+ this.webSocketManager._handleUserPromptResult({
1803
+ requestId, answers: { [question.question]: answer },
1804
+ });
1805
+ }
1806
+ await this._send(relay.chatId, this._escapeMarkdown(`✅ Selected: ${answer}`));
1807
+ } catch (error) {
1808
+ await this._send(relay.chatId, this._escapeMarkdown(`❌ Failed: ${error.message}`));
1809
+ }
1810
+ }
1811
+
1812
+ // ── Notification Batching ──────────────────────────────────────────
1813
+
1814
+ _queueNotification(chatId, text) {
1815
+ let q = this.notificationQueue.get(chatId);
1816
+ if (!q) { q = []; this.notificationQueue.set(chatId, q); }
1817
+ q.push(text);
1818
+ if (!this.notificationTimers.has(chatId)) {
1819
+ this.notificationTimers.set(chatId, setTimeout(() => this._flushNotifications(chatId), NOTIFICATION_BATCH_INTERVAL_MS));
1820
+ }
1821
+ }
1822
+
1823
+ async _flushNotifications(chatId) {
1824
+ this.notificationTimers.delete(chatId);
1825
+ const q = this.notificationQueue.get(chatId) || [];
1826
+ this.notificationQueue.set(chatId, []);
1827
+ if (q.length === 0) return;
1828
+ const combined = q.join('\n\n');
1829
+ if (combined.length <= MAX_MESSAGE_LENGTH) {
1830
+ await this._send(chatId, combined);
1831
+ } else {
1832
+ await this._send(chatId, q[0] + `\n\n_…and ${q.length - 1} more events_`);
1833
+ }
1834
+ }
1835
+
1836
+ // ── Telegram API Helpers ───────────────────────────────────────────
1837
+
1838
+ async _send(chatId, text, options = {}) {
1839
+ return this._sendRaw(chatId, text, { parse_mode: 'MarkdownV2', ...options });
1840
+ }
1841
+
1842
+ async _sendRaw(chatId, text, options) {
1843
+ if (!this.bot || !chatId) return;
1844
+ try {
1845
+ return await this.bot.sendMessage(chatId, text, options);
1846
+ } catch (error) {
1847
+ // Fallback path: strip formatting and retry plain. This catches
1848
+ // MarkdownV2-escape gaps the regex doesn't cover.
1849
+ this.logger?.warn('[TelegramService] formatted send failed, retrying plain', { error: error.message });
1850
+ try {
1851
+ const plainOpts = { ...options };
1852
+ delete plainOpts.parse_mode;
1853
+ return await this.bot.sendMessage(
1854
+ chatId,
1855
+ String(text).replace(/[\\*_`[\]()~>#+\-=|{}.!]/g, ''),
1856
+ plainOpts
1857
+ );
1858
+ } catch (e2) {
1859
+ this.logger?.error('[TelegramService] send failed', { error: e2.message });
1860
+ }
1861
+ }
1862
+ }
1863
+
1864
+ async sendPhoto(chatId, photoPath, caption = '') {
1865
+ if (!this.bot || !chatId) return;
1866
+ try { return await this.bot.sendPhoto(chatId, photoPath, { caption }); }
1867
+ catch (error) { this.logger?.error('[TelegramService] sendPhoto failed', { error: error.message }); }
1868
+ }
1869
+
1870
+ async sendDocument(chatId, docPath, caption = '') {
1871
+ if (!this.bot || !chatId) return;
1872
+ try { return await this.bot.sendDocument(chatId, docPath, { caption }); }
1873
+ catch (error) { this.logger?.error('[TelegramService] sendDocument failed', { error: error.message }); }
1874
+ }
1875
+
1876
+ async sendTestMessage() {
1877
+ if (this.chats.size === 0) {
1878
+ throw new Error('No chats registered. Send /start from Telegram first.');
1879
+ }
1880
+ for (const chatId of this.chats.keys()) {
1881
+ await this._send(chatId, this._escapeMarkdown('✅ Loxia Autopilot — test message received!'));
1882
+ }
1883
+ }
1884
+
1885
+ // ── Markdown Escaping ──────────────────────────────────────────────
1886
+
1887
+ _escapeMarkdown(text) {
1888
+ if (!text) return '';
1889
+ return text.replace(/([_*[\]()~`>#+\-=|{}.!\\])/g, '\\$1');
1890
+ }
1891
+
1892
+ /**
1893
+ * Tagged-template helper for MarkdownV2 messages with safe interpolation.
1894
+ *
1895
+ * The LITERAL parts of the template (the bits between `${...}`) are
1896
+ * passed through unchanged — author writes raw MarkdownV2 there
1897
+ * (e.g. `*bold*`, `` `code` ``, `_italic_`, with `\.` / `\!` etc.
1898
+ * spelled out where Telegram requires).
1899
+ *
1900
+ * The INTERPOLATED parts (`${value}`) are escaped automatically so a
1901
+ * variable containing `_` or `*` can't accidentally break formatting
1902
+ * or inject markup.
1903
+ *
1904
+ * The original `_escapeMarkdown(entireString)` pattern that wrapped
1905
+ * a hand-written MarkdownV2 string in escapes is the bug we keep
1906
+ * hitting — every intended `*bold*` ended up as literal `*bold*`.
1907
+ *
1908
+ * @example
1909
+ * const text = this._md`*Welcome* ${userName}\\. Use /help for commands\\.`;
1910
+ *
1911
+ * @returns {string} ready to pass to this._send() with MarkdownV2 parse_mode.
1912
+ */
1913
+ _md(strings, ...values) {
1914
+ let out = '';
1915
+ for (let i = 0; i < strings.length; i++) {
1916
+ out += strings[i];
1917
+ if (i < values.length) out += this._escapeMarkdown(String(values[i] ?? ''));
1918
+ }
1919
+ return out;
1920
+ }
1921
+
1922
+ // ── Public bridge introspection ────────────────────────────────────
1923
+
1924
+ /**
1925
+ * Bridged channels for an agent: one alias per chat the agent has
1926
+ * been addressed from. The scheduler injects the `<external to="…">`
1927
+ * prompt guidance based on this list.
1928
+ */
1929
+ getBridgedChannels(agentId) {
1930
+ const out = [];
1931
+ for (const [chatId, state] of this.chats.entries()) {
1932
+ if (state.activeAgentIds.has(agentId)) {
1933
+ out.push({
1934
+ alias: `telegram:chat-${chatId}`,
1935
+ label: state.title ? `Telegram > ${state.title}` : 'Telegram chat',
1936
+ });
1937
+ }
1938
+ }
1939
+ if (out.length > 0) {
1940
+ // Also expose the bare `telegram` alias for backward compat
1941
+ // with agents that don't know about per-chat addressing.
1942
+ out.push({ alias: 'telegram', label: 'Telegram (any chat)' });
1943
+ }
1944
+ return out;
1945
+ }
1946
+
1947
+ isAgentBridged(agentId) {
1948
+ if (!agentId || this.status !== TELEGRAM_STATUS.CONNECTED) return false;
1949
+ for (const state of this.chats.values()) {
1950
+ if (state.activeAgentIds.has(agentId)) return true;
1951
+ }
1952
+ return false;
1953
+ }
1954
+ }
1955
+
1956
+ // Singleton
1957
+ let instance = null;
1958
+
1959
+ export function getTelegramService(logger = null) {
1960
+ if (!instance) {
1961
+ instance = new TelegramService(logger);
1962
+ }
1963
+ return instance;
1964
+ }
1965
+
1966
+ // Tests need to drop the singleton between cases.
1967
+ export function _resetTelegramSingletonForTests() {
1968
+ instance = null;
1969
+ }
1970
+
1971
+ export { TelegramService, TELEGRAM_STATUS };
1972
+ export default TelegramService;