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.
- package/node_modules/glob/README.md +31 -5
- package/node_modules/glob/dist/commonjs/glob.d.ts +8 -0
- package/node_modules/glob/dist/commonjs/glob.d.ts.map +1 -1
- package/node_modules/glob/dist/commonjs/glob.js +2 -1
- package/node_modules/glob/dist/commonjs/glob.js.map +1 -1
- package/node_modules/glob/dist/commonjs/index.min.js +3 -3
- package/node_modules/glob/dist/commonjs/index.min.js.map +4 -4
- package/node_modules/glob/dist/commonjs/pattern.d.ts +3 -0
- package/node_modules/glob/dist/commonjs/pattern.d.ts.map +1 -1
- package/node_modules/glob/dist/commonjs/pattern.js +4 -0
- package/node_modules/glob/dist/commonjs/pattern.js.map +1 -1
- package/node_modules/glob/dist/esm/glob.d.ts +8 -0
- package/node_modules/glob/dist/esm/glob.d.ts.map +1 -1
- package/node_modules/glob/dist/esm/glob.js +2 -1
- package/node_modules/glob/dist/esm/glob.js.map +1 -1
- package/node_modules/glob/dist/esm/index.min.js +3 -3
- package/node_modules/glob/dist/esm/index.min.js.map +4 -4
- package/node_modules/glob/dist/esm/pattern.d.ts +3 -0
- package/node_modules/glob/dist/esm/pattern.d.ts.map +1 -1
- package/node_modules/glob/dist/esm/pattern.js +4 -0
- package/node_modules/glob/dist/esm/pattern.js.map +1 -1
- package/node_modules/{@isaacs → glob/node_modules}/balanced-match/README.md +7 -10
- package/node_modules/{@isaacs → glob/node_modules}/balanced-match/package.json +7 -18
- package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/README.md +3 -6
- package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/dist/commonjs/index.js +6 -4
- package/node_modules/glob/node_modules/brace-expansion/dist/commonjs/index.js.map +1 -0
- package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/dist/esm/index.js +6 -4
- package/node_modules/glob/node_modules/brace-expansion/dist/esm/index.js.map +1 -0
- package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/package.json +11 -7
- package/node_modules/glob/node_modules/minimatch/README.md +76 -1
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/assert-valid-pattern.d.ts +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/assert-valid-pattern.d.ts.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/assert-valid-pattern.js.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/ast.d.ts +4 -2
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/ast.d.ts.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/ast.js +309 -55
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/ast.js.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/brace-expressions.d.ts.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/brace-expressions.js +2 -4
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/brace-expressions.js.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/escape.d.ts +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/escape.d.ts.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/escape.js +4 -4
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/escape.js.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/index.d.ts +81 -1
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/index.d.ts.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/index.js +232 -134
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/index.js.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/unescape.d.ts +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/unescape.d.ts.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/unescape.js +8 -8
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/unescape.js.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/esm/assert-valid-pattern.d.ts +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/esm/assert-valid-pattern.d.ts.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/esm/assert-valid-pattern.js.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/esm/ast.d.ts +4 -2
- package/node_modules/glob/node_modules/minimatch/dist/esm/ast.d.ts.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/esm/ast.js +309 -55
- package/node_modules/glob/node_modules/minimatch/dist/esm/ast.js.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/esm/brace-expressions.d.ts.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/esm/brace-expressions.js +2 -4
- package/node_modules/glob/node_modules/minimatch/dist/esm/brace-expressions.js.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/esm/escape.d.ts +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/esm/escape.d.ts.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/esm/escape.js +4 -4
- package/node_modules/glob/node_modules/minimatch/dist/esm/escape.js.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/esm/index.d.ts +81 -1
- package/node_modules/glob/node_modules/minimatch/dist/esm/index.d.ts.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/esm/index.js +232 -134
- package/node_modules/glob/node_modules/minimatch/dist/esm/index.js.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/esm/unescape.d.ts +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/esm/unescape.d.ts.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/esm/unescape.js +8 -8
- package/node_modules/glob/node_modules/minimatch/dist/esm/unescape.js.map +1 -1
- package/node_modules/glob/node_modules/minimatch/package.json +17 -11
- package/node_modules/glob/package.json +10 -13
- package/node_modules/minipass/LICENSE.md +55 -0
- package/node_modules/minipass/dist/commonjs/index.d.ts +12 -16
- package/node_modules/minipass/dist/commonjs/index.d.ts.map +1 -1
- package/node_modules/minipass/dist/commonjs/index.js +13 -3
- package/node_modules/minipass/dist/commonjs/index.js.map +1 -1
- package/node_modules/minipass/dist/esm/index.d.ts +12 -16
- package/node_modules/minipass/dist/esm/index.d.ts.map +1 -1
- package/node_modules/minipass/dist/esm/index.js +3 -1
- package/node_modules/minipass/dist/esm/index.js.map +1 -1
- package/node_modules/minipass/package.json +9 -14
- package/node_modules/path-scurry/node_modules/lru-cache/README.md +96 -10
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/diagnostics-channel-browser.d.ts.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/diagnostics-channel-browser.js.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/diagnostics-channel.d.ts +5 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/diagnostics-channel.js +7 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/index.d.ts +1400 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/index.d.ts.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/index.js +1726 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/index.js.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/index.min.js +2 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/index.min.js.map +7 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/perf.d.ts +12 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/perf.d.ts.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/perf.js +10 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/perf.js.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/diagnostics-channel-cjs.cjs.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/diagnostics-channel-cjs.d.cts.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/diagnostics-channel.d.ts +5 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/diagnostics-channel.js +7 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/index.d.ts +109 -32
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/index.d.ts.map +1 -1
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/index.js +334 -197
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/index.js.map +1 -1
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/index.min.js +1 -1
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/index.min.js.map +4 -4
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/diagnostics-channel-node.d.ts.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/diagnostics-channel-node.js.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/diagnostics-channel.d.ts +5 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/diagnostics-channel.js +9 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/index.d.ts +1400 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/index.d.ts.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/index.js +1726 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/index.js.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/index.min.js +2 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/index.min.js.map +7 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/perf.d.ts +12 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/perf.d.ts.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/perf.js +10 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/perf.js.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/perf.d.ts +12 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/perf.d.ts.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/perf.js +10 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/perf.js.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/diagnostics-channel-browser.d.ts.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/diagnostics-channel-browser.js.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/diagnostics-channel.d.ts +5 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/diagnostics-channel.js +4 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/index.d.ts +1400 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/index.d.ts.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/index.js +1722 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/index.js.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/index.min.js +2 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/index.min.js.map +7 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/perf.d.ts +12 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/perf.d.ts.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/perf.js +7 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/perf.js.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/diagnostics-channel-esm.d.mts.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/diagnostics-channel-esm.mjs.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/diagnostics-channel.d.ts +5 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/diagnostics-channel.js +19 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/index.d.ts +109 -32
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/index.d.ts.map +1 -1
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/index.js +333 -196
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/index.js.map +1 -1
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/index.min.js +1 -1
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/index.min.js.map +4 -4
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/diagnostics-channel-node.d.ts.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/diagnostics-channel-node.js.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/diagnostics-channel.d.ts +5 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/diagnostics-channel.js +6 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/index.d.ts +1400 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/index.d.ts.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/index.js +1722 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/index.js.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/index.min.js +2 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/index.min.js.map +7 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/perf.d.ts +12 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/perf.d.ts.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/perf.js +7 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/perf.js.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/perf.d.ts +12 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/perf.d.ts.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/perf.js +7 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/perf.js.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/package.json +71 -18
- package/node_modules/path-scurry/package.json +8 -24
- package/package.json +1 -1
- package/scripts/debug-balance-probe.mjs +35 -35
- package/scripts/push-image.sh +43 -43
- package/scripts/setup-acr.sh +65 -65
- package/scripts/verify-optional-deps.js +96 -1
- package/src/__tests__/composioCliFlags.test.js +239 -239
- package/src/analyzers/CSSAnalyzer.js +298 -297
- package/src/analyzers/ConfigValidator.js +691 -690
- package/src/analyzers/ESLintAnalyzer.js +320 -320
- package/src/analyzers/JavaScriptAnalyzer.js +260 -261
- package/src/analyzers/PrettierFormatter.js +246 -247
- package/src/analyzers/PythonAnalyzer.js +283 -283
- package/src/analyzers/SecurityAnalyzer.js +729 -729
- package/src/analyzers/SparrowAnalyzer.js +341 -341
- package/src/analyzers/TypeScriptAnalyzer.js +247 -247
- package/src/analyzers/__tests__/CSSAnalyzer.test.js +41 -41
- package/src/analyzers/__tests__/ConfigValidator.test.js +362 -362
- package/src/analyzers/__tests__/JavaScriptAnalyzer.test.js +40 -40
- package/src/analyzers/__tests__/PythonAnalyzer.test.js +205 -208
- package/src/analyzers/__tests__/SecurityAnalyzer.test.js +303 -303
- package/src/analyzers/__tests__/TypeScriptAnalyzer.test.js +187 -187
- package/src/analyzers/codeCloneDetector/analyzer.js +344 -344
- package/src/analyzers/codeCloneDetector/detector.js +250 -250
- package/src/analyzers/codeCloneDetector/index.js +194 -192
- package/src/analyzers/codeCloneDetector/parser.js +199 -199
- package/src/core/__tests__/agentPool.test.js +866 -866
- package/src/core/__tests__/agentPoolAutoResume.test.js +209 -209
- package/src/core/__tests__/agentPoolWakeOnMessage.test.js +315 -315
- package/src/core/__tests__/agentScheduler.emptyResponseChatStall.test.js +213 -213
- package/src/core/__tests__/agentScheduler.errorCategorisation.test.js +246 -246
- package/src/core/__tests__/agentScheduler.firstChunkTimeout.test.js +138 -138
- package/src/core/__tests__/agentScheduler.modeTransitions.test.js +233 -233
- package/src/core/__tests__/agentScheduler.nativePromptPick.test.js +319 -319
- package/src/core/__tests__/agentScheduler.taskLifecycleInstruction.test.js +78 -78
- package/src/core/__tests__/agentScheduler.visualizer.test.js +258 -258
- package/src/core/__tests__/flowCheckpointStore.test.js +140 -140
- package/src/core/__tests__/flowEndToEnd.test.js +565 -565
- package/src/core/__tests__/flowFieldMapping.test.js +188 -189
- package/src/core/__tests__/flowLintClientMirror.test.js +96 -98
- package/src/core/__tests__/flowSavePayload.test.js +170 -169
- package/src/core/__tests__/flowTemplates.test.js +311 -311
- package/src/core/__tests__/flowVersionStore.test.js +123 -123
- package/src/core/__tests__/messageProcessor.test.js +669 -669
- package/src/core/__tests__/stateManager.test.js +0 -1
- package/src/core/agentPool.js +2474 -2475
- package/src/core/agentScheduler.js +1 -4
- package/src/core/contextManager.js +708 -708
- package/src/core/flowExecutor.js +1510 -1510
- package/src/core/flowFieldMapping.js +136 -138
- package/src/core/messageProcessor.js +953 -954
- package/src/core/orchestrator.js +593 -595
- package/src/core/stateManager.js +1765 -1752
- package/src/index.js +1221 -1221
- package/src/interfaces/__tests__/archivedAgentDelete.test.js +207 -207
- package/src/interfaces/__tests__/bulkAgentRoute.test.js +361 -361
- package/src/interfaces/__tests__/imageServing.test.js +228 -228
- package/src/interfaces/__tests__/remoteSessionAuth.test.js +308 -308
- package/src/interfaces/__tests__/videoJobsRoutes.test.js +178 -179
- package/src/interfaces/__tests__/webServer.marketplace.test.js +629 -629
- package/src/interfaces/schedulerRoutes.js +50 -50
- package/src/interfaces/terminal/__tests__/smoke/connection.test.js +341 -350
- package/src/interfaces/terminal/__tests__/smoke/enhancements.test.js +156 -156
- package/src/interfaces/terminal/__tests__/smoke/imports.test.js +325 -330
- package/src/interfaces/terminal/__tests__/smoke/tools.test.js +385 -388
- package/src/interfaces/terminal/api/session.js +265 -266
- package/src/interfaces/terminal/api/websocket.js +496 -497
- package/src/interfaces/terminal/components/AgentCreator.js +691 -705
- package/src/interfaces/terminal/components/AgentEditor.js +676 -678
- package/src/interfaces/terminal/components/AgentSwitcher.js +331 -330
- package/src/interfaces/terminal/components/ErrorPanel.js +263 -264
- package/src/interfaces/terminal/components/Header.js +28 -28
- package/src/interfaces/terminal/components/Layout.js +598 -603
- package/src/interfaces/terminal/components/MessageList.js +280 -281
- package/src/interfaces/terminal/components/SettingsPanel.js +410 -415
- package/src/interfaces/terminal/components/StatusBar.js +2 -0
- package/src/interfaces/terminal/index.js +168 -168
- package/src/interfaces/terminal/state/useAgentControl.js +496 -496
- package/src/interfaces/terminal/state/useAgents.js +537 -537
- package/src/interfaces/terminal/state/useMessages.js +629 -630
- package/src/interfaces/terminal/state/useTools.js +554 -554
- package/src/interfaces/terminal/utils/debugLogger.js +44 -44
- package/src/interfaces/terminal/utils/settingsStorage.js +232 -232
- package/src/interfaces/webServer.js +7578 -7579
- package/src/interfaces/webServer.js.bak +7046 -7046
- package/src/modules/fileExplorer/__tests__/zipDownload.test.js +237 -237
- package/src/modules/fileExplorer/controller.js +470 -469
- package/src/modules/fileExplorer/routes.js +285 -286
- package/src/modules/widget/__tests__/isDisabled.test.js +41 -41
- package/src/modules/widget/__tests__/routes.test.js +677 -678
- package/src/modules/widget/__tests__/runtime.test.js +401 -401
- package/src/modules/widget/__tests__/versioning.test.js +309 -309
- package/src/modules/widget/__tests__/webComponentRuntime.test.js +565 -565
- package/src/modules/widget/__tests__/widgetTool.test.js +316 -316
- package/src/modules/widget/routes.js +435 -435
- package/src/modules/widget/runtime/bundle.js +640 -640
- package/src/modules/widget/runtime/webComponentBundle.js +470 -470
- package/src/modules/widget/schema.js +182 -181
- package/src/modules/widget/widgetTool.js +1389 -1389
- package/src/services/__tests__/agentActivityService.test.js +401 -402
- package/src/services/__tests__/benchmarkService.test.js +184 -184
- package/src/services/__tests__/contextInjectionService.test.js +246 -246
- package/src/services/__tests__/conversationQuery.test.js +721 -723
- package/src/services/__tests__/credentialVault.test.js +469 -469
- package/src/services/__tests__/discordService.integration.test.js +638 -639
- package/src/services/__tests__/flowContextService.test.js +590 -590
- package/src/services/__tests__/memoryService.test.js +1 -1
- package/src/services/__tests__/messageSource.test.js +380 -380
- package/src/services/__tests__/modelRouterNaming.test.js +111 -111
- package/src/services/__tests__/projectDetector.test.js +34 -34
- package/src/services/__tests__/promptService.test.js +242 -242
- package/src/services/__tests__/telegramService.test.js +941 -941
- package/src/services/__tests__/tokenCountingService.test.js +48 -48
- package/src/services/agentActivityService.js +419 -420
- package/src/services/aiService.js +2997 -3001
- package/src/services/apiKeyManager.js +359 -359
- package/src/services/benchmarkService.js +196 -196
- package/src/services/codebaseKnowledgeService.js +2 -2
- package/src/services/composioService.js +738 -738
- package/src/services/conversationCompactionService.js +1258 -1257
- package/src/services/credentialVault.js +685 -685
- package/src/services/discordService.js +792 -793
- package/src/services/embeddings/__tests__/azureCustomProvider.test.js +232 -232
- package/src/services/embeddings/__tests__/embeddingService.test.js +417 -417
- package/src/services/embeddings/__tests__/localProvider.test.js +263 -263
- package/src/services/embeddings/autoRecall.js +218 -219
- package/src/services/embeddings/indexers/__tests__/agentIndexer.test.js +232 -232
- package/src/services/embeddings/indexers/__tests__/memoryIndexer.test.js +418 -418
- package/src/services/embeddings/indexers/__tests__/reminisceIndexer.test.js +356 -357
- package/src/services/embeddings/indexers/__tests__/skillsIndexer.test.js +145 -145
- package/src/services/embeddings/indexers/__tests__/taskIndexer.test.js +146 -146
- package/src/services/embeddings/indexers/composioIndexer.js +279 -279
- package/src/services/embeddings/providerInterface.js +206 -206
- package/src/services/embeddings/providers/localProvider.js +11 -7
- package/src/services/embeddings/providers/openaiProvider.js +101 -101
- package/src/services/embeddings/vectorStore/inMemoryJsonStore.js +356 -356
- package/src/services/errorHandler.js +809 -809
- package/src/services/flowContextService.js +586 -586
- package/src/services/grounding/MockAdapter.js +125 -125
- package/src/services/modelRouterService.js +26 -31
- package/src/services/modelsService.js +322 -322
- package/src/services/ollamaService.js +452 -452
- package/src/services/projectDetector.js +403 -404
- package/src/services/promptService.js +418 -418
- package/src/services/qualityInspector.js +795 -795
- package/src/services/scheduleService.js +726 -726
- package/src/services/serviceRegistry.js +386 -386
- package/src/services/telegrafBot.js +174 -174
- package/src/services/telegramService.js +1972 -1972
- package/src/services/visualEditorBridge.js +1033 -1033
- package/src/services/visualEditorServer.js +1769 -1774
- package/src/services/whatsappService.js +667 -668
- package/src/tools/__tests__/agentCommunicationTool.findAgent.test.js +226 -226
- package/src/tools/__tests__/agentCommunicationTool.test.js +3 -3
- package/src/tools/__tests__/agentDelayTool.test.js +342 -342
- package/src/tools/__tests__/baseTool.test.js +3 -3
- package/src/tools/__tests__/codeMapTool.test.js +915 -915
- package/src/tools/__tests__/fileContentReplaceTool.test.js +309 -309
- package/src/tools/__tests__/fileTreeTool.test.js +274 -274
- package/src/tools/__tests__/filesystemTool.test.js +815 -815
- package/src/tools/__tests__/foundryWebSearchTool.test.js +252 -252
- package/src/tools/__tests__/imageTool.validator.test.js +194 -194
- package/src/tools/__tests__/jobDoneTool.test.js +580 -581
- package/src/tools/__tests__/memoryTool.forgetStale.test.js +272 -272
- package/src/tools/__tests__/memoryTool.reminisce.test.js +2 -2
- package/src/tools/__tests__/memoryTool.reminisceSemanticSearch.test.js +301 -301
- package/src/tools/__tests__/memoryTool.semanticSearch.test.js +405 -405
- package/src/tools/__tests__/memoryTool.teamPool.test.js +293 -293
- package/src/tools/__tests__/memoryTool.test.js +1 -1
- package/src/tools/__tests__/seekTool.test.js +282 -282
- package/src/tools/__tests__/skillsTool.search.test.js +164 -164
- package/src/tools/__tests__/skillsTool.test.js +226 -226
- package/src/tools/__tests__/staticAnalysisTool.test.js +509 -509
- package/src/tools/__tests__/taskManagerTool.discipline.test.js +137 -137
- package/src/tools/__tests__/taskManagerTool.search.test.js +143 -143
- package/src/tools/__tests__/taskManagerTool.test.js +866 -866
- package/src/tools/__tests__/terminalTool.test.js +448 -448
- package/src/tools/__tests__/toolShapeForgiveness.test.js +259 -260
- package/src/tools/__tests__/userPromptTool.test.js +297 -297
- package/src/tools/__tests__/videoTool.jobs.test.js +147 -147
- package/src/tools/__tests__/webTool.e2e.test.js +609 -603
- package/src/tools/__tests__/webTool.unit.test.js +195 -195
- package/src/tools/__tests__/webTool.visionModel.test.js +75 -75
- package/src/tools/agentCommunicationTool.js +8 -10
- package/src/tools/agentDelayTool.js +496 -497
- package/src/tools/asyncToolManager.js +602 -603
- package/src/tools/baseTool.js +12 -11
- package/src/tools/cloneDetectionTool.js +576 -581
- package/src/tools/codeMapTool.js +0 -6
- package/src/tools/composioTool.js +617 -617
- package/src/tools/dependencyResolverTool.js +1211 -1212
- package/src/tools/desktop/DesktopTool.js +629 -638
- package/src/tools/desktop/__tests__/DesktopTool.e2e.test.js +306 -306
- package/src/tools/desktop/__tests__/DesktopTool.test.js +507 -507
- package/src/tools/desktop/__tests__/osController.test.js +364 -364
- package/src/tools/desktop/osController.js +491 -491
- package/src/tools/docxTool.js +623 -623
- package/src/tools/excelTool.js +636 -636
- package/src/tools/fileContentReplaceTool.js +5 -7
- package/src/tools/fileSystemTool.js +12 -19
- package/src/tools/fileTreeTool.js +840 -840
- package/src/tools/foundryWebSearchTool.js +273 -273
- package/src/tools/helpTool.js +198 -198
- package/src/tools/imageTool.js +1397 -1397
- package/src/tools/importAnalyzerTool.js +1056 -1056
- package/src/tools/jobDoneTool.js +495 -495
- package/src/tools/memoryTool.js +1 -1
- package/src/tools/office/pres/__tests__/presSystem.test.js +365 -365
- package/src/tools/office/pres/archetypes/agenda.js +61 -61
- package/src/tools/office/pres/archetypes/bentoGrid.js +218 -219
- package/src/tools/office/pres/archetypes/bigStat.js +140 -142
- package/src/tools/office/pres/archetypes/closing.js +70 -70
- package/src/tools/office/pres/archetypes/hero.js +70 -70
- package/src/tools/office/pres/archetypes/productHero.js +93 -94
- package/src/tools/office/pres/archetypes/table.js +73 -74
- package/src/tools/office/pres/backgrounds/orb.js +66 -66
- package/src/tools/office/pres/components.js +422 -423
- package/src/tools/officeTool.js +441 -441
- package/src/tools/pdfTool.js +625 -627
- package/src/tools/platformControlTool.js +1081 -1081
- package/src/tools/seekTool.js +917 -918
- package/src/tools/skillsTool.js +1 -1
- package/src/tools/staticAnalysisTool.js +2143 -2146
- package/src/tools/taskManagerTool.js +3324 -3324
- package/src/tools/terminalTool.js +2615 -2618
- package/src/tools/videoTool.js +1303 -1303
- package/src/tools/visionTool.js +508 -508
- package/src/tools/visualEditorTool.js +1289 -1290
- package/src/tools/webTool.js +3368 -3368
- package/src/tools/whatsappTool.js +464 -464
- package/src/types/__tests__/agent.test.js +499 -499
- package/src/types/__tests__/contextReference.test.js +606 -606
- package/src/types/__tests__/conversation.test.js +555 -555
- package/src/types/__tests__/toolCommand.test.js +584 -584
- package/src/types/contextReference.js +974 -971
- package/src/types/conversation.js +729 -729
- package/src/types/toolCommand.js +746 -746
- package/src/utilities/__tests__/attachmentValidator.test.js +80 -80
- package/src/utilities/__tests__/auditReport.test.js +328 -328
- package/src/utilities/__tests__/directoryAccessManager.test.js +388 -388
- package/src/utilities/__tests__/jsonRepair.test.js +103 -104
- package/src/utilities/__tests__/modeTransitionReasons.test.js +105 -105
- package/src/utilities/__tests__/platformUtils.test.js +80 -87
- package/src/utilities/__tests__/structuredFileValidator.test.js +261 -263
- package/src/utilities/__tests__/toolConstants.test.js +92 -94
- package/src/utilities/__tests__/useIsTouchDevice.detect.test.js +114 -114
- package/src/utilities/__tests__/webUiUtilSync.test.js +117 -117
- package/src/utilities/attachmentValidator.js +284 -288
- package/src/utilities/authCache.js.backup-1779570472481 +121 -121
- package/src/utilities/browserStealth.js +631 -630
- package/src/utilities/configManager.js +616 -617
- package/src/utilities/directoryAccessManager.js +564 -565
- package/src/utilities/fileProcessor.js +308 -307
- package/src/utilities/humanBehavior.js +454 -453
- package/src/utilities/logger.js +479 -479
- package/src/utilities/structuredFileValidator.js +696 -699
- package/src/utilities/tagParser.js +5 -10
- package/src/utilities/userDataDir.js +308 -308
- package/node_modules/@isaacs/brace-expansion/dist/commonjs/index.js.map +0 -1
- package/node_modules/@isaacs/brace-expansion/dist/esm/index.js.map +0 -1
- package/node_modules/minipass/LICENSE +0 -15
- /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/LICENSE.md +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/commonjs/index.d.ts +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/commonjs/index.d.ts.map +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/commonjs/index.js +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/commonjs/index.js.map +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/commonjs/package.json +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/esm/index.d.ts +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/esm/index.d.ts.map +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/esm/index.js +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/esm/index.js.map +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/esm/package.json +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/LICENSE +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/dist/commonjs/index.d.ts +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/dist/commonjs/index.d.ts.map +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/dist/commonjs/package.json +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/dist/esm/index.d.ts +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/dist/esm/index.d.ts.map +0 -0
- /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
|
|
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, '&')
|
|
1593
|
-
.replace(/</g, '<')
|
|
1594
|
-
.replace(/>/g, '>');
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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
|
|
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(/[\\*_
|
|
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(/([_
|
|
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, '&')
|
|
1593
|
+
.replace(/</g, '<')
|
|
1594
|
+
.replace(/>/g, '>');
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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;
|