From aa3c50644e143cc5b29ac2dc9dfca18786eca7be Mon Sep 17 00:00:00 2001 From: TONresistor <240980241+TONresistor@users.noreply.github.com> Date: Fri, 19 Jun 2026 13:14:38 +0200 Subject: [PATCH 01/59] chore(deps): migrate @mariozechner/pi-ai to maintained @earendil-works/pi-ai --- package-lock.json | 654 ++++++++---------- package.json | 2 +- src/agent/client.ts | 2 +- src/agent/runtime-utils.ts | 2 +- src/agent/runtime.ts | 2 +- src/agent/schema-sanitizer.ts | 2 +- src/agent/tools/__tests__/registry.test.ts | 4 +- src/agent/tools/registry.ts | 4 +- src/agent/tools/search/tool-search.ts | 2 +- .../tools/telegram/media/vision-analyze.ts | 2 +- src/agent/tools/tool-index.ts | 2 +- .../__tests__/observation-masking.test.ts | 2 +- src/memory/ai-summarization.ts | 2 +- src/memory/compaction.ts | 2 +- src/memory/observation-masking.ts | 2 +- src/providers/model-resolver.ts | 2 +- src/session/memory-hook.ts | 2 +- src/session/transcript.ts | 2 +- src/telegram/task-executor.ts | 2 +- src/utils/pi-message.ts | 2 +- 20 files changed, 312 insertions(+), 384 deletions(-) diff --git a/package-lock.json b/package-lock.json index ab8546dc..36f7babf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,10 +13,10 @@ ], "dependencies": { "@dedust/sdk": "^0.8.7", + "@earendil-works/pi-ai": "^0.79.8", "@hono/node-server": "^2.0.4", "@huggingface/transformers": "^3.8.1", "@inquirer/prompts": "^8.3.2", - "@mariozechner/pi-ai": "^0.73.1", "@modelcontextprotocol/sdk": "^1.28.0", "@sinclair/typebox": "^0.34.48", "@tavily/core": "^0.7.2", @@ -176,17 +176,17 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.974.11", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.11.tgz", - "integrity": "sha512-QpnINq5FZH6EOaDEkmHdT7eUunbvD27pDNQypaWjFyYz7Zl1q3UCMQErBZxpmfGfI7MvI2TlK8KTkgNpv8b1ug==", + "version": "3.974.22", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.22.tgz", + "integrity": "sha512-YofH63shc6YRdXjz80BJkpJW+Bkn0Cuu2dn4Rv7s9G2Idt58tgtzQEWxrR2xVljlVfIBeUjPuULnSVYLke3sUQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@aws-sdk/xml-builder": "^3.972.24", + "@aws-sdk/types": "^3.973.13", + "@aws-sdk/xml-builder": "^3.972.30", "@aws/lambda-invoke-store": "^0.2.2", - "@smithy/core": "^3.24.2", - "@smithy/signature-v4": "^5.4.2", - "@smithy/types": "^4.14.1", + "@smithy/core": "^3.24.6", + "@smithy/signature-v4": "^5.4.6", + "@smithy/types": "^4.14.3", "bowser": "^2.11.0", "tslib": "^2.6.2" }, @@ -195,15 +195,15 @@ } }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.37", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.37.tgz", - "integrity": "sha512-/jpPvEh6f7ntmIzf7dNxoNX6Q8vt8UpesCjbW6mFfk4V1NW6bIy9qxcQ6WbA8As5yQhsZOe+xeNd4xHX8kdY2Q==", + "version": "3.972.48", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.48.tgz", + "integrity": "sha512-h6FEC95fbexUd6zxm4PdgS82bTcI2PRtUb2ZwMipb/Xr8bPwtf0G8rBo2jp7NA24Mbx2JA8/WingiYpA9RCCyw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.11", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", + "@aws-sdk/core": "^3.974.22", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -211,41 +211,55 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.39", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.39.tgz", - "integrity": "sha512-pIgTpisWyWg7X1bUbzSjuUYosYTD0Ghz2M0hkSTmb3a6i3qV3uU+NYJPI/E2XSC0HcsZh5rsLPzeXrkb2DS0Cg==", + "version": "3.972.50", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.50.tgz", + "integrity": "sha512-lJO3OLpjvz5m/RSBQmsG/CEUGsvCy5ruxKwPQaOCqxqCMuyYT2BZwQUTDZVVwqQ9LrZKuK24JSa6r31hL/tvkg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.11", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/fetch-http-handler": "^5.4.2", - "@smithy/node-http-handler": "^4.7.2", - "@smithy/types": "^4.14.1", + "@aws-sdk/core": "^3.974.22", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/fetch-http-handler": "^5.4.6", + "@smithy/node-http-handler": "^4.7.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/credential-provider-http/node_modules/@smithy/node-http-handler": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.8.1.tgz", + "integrity": "sha512-emtXvoky671puri18ETf64AFIQUGIEA093F2drXpBgB0OGnBLjcwNR3CA2mYu62IAqNsS56xa5lnTxAgPq7cjw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.25.1", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.972.41", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.41.tgz", - "integrity": "sha512-u2tyjaxJJzW8UtW4SM1ZcPMDwO6y+kV+llvou+Adts0FAKyzes5jG4izQN+KX3yE8ZROpS5y1LJ//xL2iSf76w==", + "version": "3.972.55", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.55.tgz", + "integrity": "sha512-TBoF4buBGYhXjdZAryayY2TrkQj2B2KfE/msG4V53XCt+w0EhEwM2JRjx8p2grJ2C6gtH5++SAwEvGMRdi0yyw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.11", - "@aws-sdk/credential-provider-env": "^3.972.37", - "@aws-sdk/credential-provider-http": "^3.972.39", - "@aws-sdk/credential-provider-login": "^3.972.41", - "@aws-sdk/credential-provider-process": "^3.972.37", - "@aws-sdk/credential-provider-sso": "^3.972.41", - "@aws-sdk/credential-provider-web-identity": "^3.972.41", - "@aws-sdk/nested-clients": "^3.997.9", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/credential-provider-imds": "^4.3.2", - "@smithy/types": "^4.14.1", + "@aws-sdk/core": "^3.974.22", + "@aws-sdk/credential-provider-env": "^3.972.48", + "@aws-sdk/credential-provider-http": "^3.972.50", + "@aws-sdk/credential-provider-login": "^3.972.54", + "@aws-sdk/credential-provider-process": "^3.972.48", + "@aws-sdk/credential-provider-sso": "^3.972.54", + "@aws-sdk/credential-provider-web-identity": "^3.972.54", + "@aws-sdk/nested-clients": "^3.997.22", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/credential-provider-imds": "^4.3.7", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -253,16 +267,16 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.41", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.41.tgz", - "integrity": "sha512-0LBitxXiAiaE5nlFPfpNIww/8FRY/I7WIndWsc9GmNFOM7cE1wNpVNQEGEk9Outg5l8xl+3vybxFyUy4l9q/LQ==", + "version": "3.972.54", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.54.tgz", + "integrity": "sha512-hBWI3wZTdTGiuMfmPts6AWbAjFfRniOQnqx68tc2cQvRKWawFbN9wkLOVPWM1FAOyowZU73mC6Fi+rHSHNyLFw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.11", - "@aws-sdk/nested-clients": "^3.997.9", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", + "@aws-sdk/core": "^3.974.22", + "@aws-sdk/nested-clients": "^3.997.22", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -270,21 +284,21 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.42", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.42.tgz", - "integrity": "sha512-D4oon2zbqqsWOJUM99Gm3/ZyJ0IJvTXVN3PyloGb3kQEyI36fjCZheZj422lAgTWWd6TSHgiImLt3RIaLdv3dQ==", + "version": "3.972.57", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.57.tgz", + "integrity": "sha512-u6dClpzNdWf1HGWz4wwhdXi1wiOofCLniM9S4BQQGlLAN9TW7VB+ld5V533GdKrYMaFeBGFqKnj0JCYvynLqwQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.37", - "@aws-sdk/credential-provider-http": "^3.972.39", - "@aws-sdk/credential-provider-ini": "^3.972.41", - "@aws-sdk/credential-provider-process": "^3.972.37", - "@aws-sdk/credential-provider-sso": "^3.972.41", - "@aws-sdk/credential-provider-web-identity": "^3.972.41", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/credential-provider-imds": "^4.3.2", - "@smithy/types": "^4.14.1", + "@aws-sdk/credential-provider-env": "^3.972.48", + "@aws-sdk/credential-provider-http": "^3.972.50", + "@aws-sdk/credential-provider-ini": "^3.972.55", + "@aws-sdk/credential-provider-process": "^3.972.48", + "@aws-sdk/credential-provider-sso": "^3.972.54", + "@aws-sdk/credential-provider-web-identity": "^3.972.54", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/credential-provider-imds": "^4.3.7", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -292,15 +306,15 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.37", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.37.tgz", - "integrity": "sha512-7nVaHBUaWIddASYfVaA9O4D5ZVjewU3sCol9WqZPGfW0nR+0WqE0xHZnD/U2L33PlOB8KNXGKZ6wOES/QijKzg==", + "version": "3.972.48", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.48.tgz", + "integrity": "sha512-w6VZwojPt12WnEkAUy6Nu4K6sWCbBmR7QX390b0nE6vRvkXbrYr9Lq9VySGkfjiMjpUA87op+J4EgvRmtWIDoQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.11", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", + "@aws-sdk/core": "^3.974.22", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -308,17 +322,34 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.972.41", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.41.tgz", - "integrity": "sha512-IOWAWEHe5LkjSKkkUUX9ciV6Y1scHTsnfEkdt5yyC4Slrc7AGbkLPrpntjqh18ksJAMOaVhoBsO8p2WyTcY2wQ==", + "version": "3.972.54", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.54.tgz", + "integrity": "sha512-23uZpIpF2SIFDCa1fcWa202tK4gGeyvX6GIIAjiB8WBsvsVRBMnJ/7dCxHzxf7eZT7GToJg837LDIBnZsl/VUg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.11", - "@aws-sdk/nested-clients": "^3.997.9", - "@aws-sdk/token-providers": "3.1048.0", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", + "@aws-sdk/core": "^3.974.22", + "@aws-sdk/nested-clients": "^3.997.22", + "@aws-sdk/token-providers": "3.1071.0", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { + "version": "3.1071.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1071.0.tgz", + "integrity": "sha512-4LDW2Qob6LoLFuqYSYZq2AyTE9koSE9+i+n5UZcm10GpmQOK0zRD9L4uYlzItiTKksIWgC/qMFChAi3RvKYtMg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.22", + "@aws-sdk/nested-clients": "^3.997.22", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -326,16 +357,16 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.41", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.41.tgz", - "integrity": "sha512-mbACk9Yypa8nm4iGZLs0PofOXEcTDOUw6wDnsPXNDNSd2WNXs1tSo+6nc/fh0jLYdfVZThhBL98PHW4aXFsG5A==", + "version": "3.972.54", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.54.tgz", + "integrity": "sha512-0Iv5QttS6wcATlodYKgvQj6B9Db51rx7NU9fqu0PoLeS4BIgdYMc/QK4smwLwpm5RFrs02V/eLyEFp3FklvlNQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.11", - "@aws-sdk/nested-clients": "^3.997.9", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", + "@aws-sdk/core": "^3.974.22", + "@aws-sdk/nested-clients": "^3.997.22", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -343,14 +374,14 @@ } }, "node_modules/@aws-sdk/eventstream-handler-node": { - "version": "3.972.16", - "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.16.tgz", - "integrity": "sha512-yedpPgKftqjU5SlPFHfqWpOw6xSCRieWRG1euWOlXn4WJxt2VX92VprCa2PpSOXjVCAeK6dTjW9eJRXVig9yGA==", + "version": "3.972.22", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.22.tgz", + "integrity": "sha512-tqPJv0dz4+O0hWGm1a6YekcMZyPhDFs/zH73Von7icaVT5n0Jqvm86typ3jRrG+qoUdPhALOnboRLTmnWQTlYQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -358,14 +389,14 @@ } }, "node_modules/@aws-sdk/middleware-eventstream": { - "version": "3.972.12", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.12.tgz", - "integrity": "sha512-tHTHHCHNrq6XklQvlzHBDJG4Iuhh7NVPRdtmvP+nHFA+5sxPlIDzlAHHgfoYHGvT3NXP1yVP/L5c3opUn6T3Qg==", + "version": "3.972.18", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.18.tgz", + "integrity": "sha512-OHpk8YoZi3yexPq8aFt1vN1IxA2zLKvsIR5GpWYylX/ve6kQmY7wxHNSFy/D3t2apMZ16rs76Co4dJWcDyIk3A==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -373,17 +404,17 @@ } }, "node_modules/@aws-sdk/middleware-websocket": { - "version": "3.972.19", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.19.tgz", - "integrity": "sha512-mkEhOGYozqKQkbFaVrjwr0faiwwZza1v5/jSY6Tucm3bD+uKTazIUH/4Yo6aMnQD2ua2W9cMP6s8mvwTcjtqHw==", + "version": "3.972.30", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.30.tgz", + "integrity": "sha512-kH6N4f/Fzi9r/dYap8EQ+Zk4NOz8pl4AtWKhzAoG2C1/4YkIHok9APp/e+75woreWQq264n+LkrJsJVZ0Q+M1Q==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.11", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/fetch-http-handler": "^5.4.2", - "@smithy/signature-v4": "^5.4.2", - "@smithy/types": "^4.14.1", + "@aws-sdk/core": "^3.974.22", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/fetch-http-handler": "^5.4.6", + "@smithy/signature-v4": "^5.4.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -391,36 +422,49 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.997.9", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.9.tgz", - "integrity": "sha512-jPR3rnmRI4hWYyzfmTGBr7NblMp8QYYeflHXba1H6+7CGrWVqWKQzaXFQ4qbExqPRsXN3T3L3JxFhr6aouXUGQ==", + "version": "3.997.22", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.22.tgz", + "integrity": "sha512-4IwtcYSxEIVw5hcp8ogq0CMbFNZFw7jJUetpfFUhFFeqsa1K8j2Ihg2hnxLyOp3stMZnXda6VzOmPi1AFZQXcg==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.11", - "@aws-sdk/signature-v4-multi-region": "^3.996.27", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/fetch-http-handler": "^5.4.2", - "@smithy/node-http-handler": "^4.7.2", - "@smithy/types": "^4.14.1", + "@aws-sdk/core": "^3.974.22", + "@aws-sdk/signature-v4-multi-region": "^3.996.35", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/fetch-http-handler": "^5.4.6", + "@smithy/node-http-handler": "^4.7.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/node-http-handler": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.8.1.tgz", + "integrity": "sha512-emtXvoky671puri18ETf64AFIQUGIEA093F2drXpBgB0OGnBLjcwNR3CA2mYu62IAqNsS56xa5lnTxAgPq7cjw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.25.1", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.27", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.27.tgz", - "integrity": "sha512-0Phbz4t6HI3D3skxvG2uI+VWU034/nSIw1T8d+FPzzQG9EQTrw94o9mOKO2Gv3n3Oc8P7JD7RAUxkoneLWv5Eg==", + "version": "3.996.35", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.35.tgz", + "integrity": "sha512-6L/VWs+Wch2stHemCGTmUNqKLMzURxQDK5boNG3Jn3kAOp71meDUuS5sbObpEvFxHDq0uWeSLFDNSYsjNt+Dlg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/signature-v4": "^5.4.2", - "@smithy/types": "^4.14.1", + "@aws-sdk/types": "^3.973.13", + "@smithy/signature-v4": "^5.4.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -445,12 +489,12 @@ } }, "node_modules/@aws-sdk/types": { - "version": "3.973.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.8.tgz", - "integrity": "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==", + "version": "3.973.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.13.tgz", + "integrity": "sha512-pEHZqRkAlHfnfAU9tK+WpKv/gBNjGJrHMgA3A0iYRGyswBS2t0pfez+lWlwktb3Bqa0ovh7w/QJTFwp3fDxLNg==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.1", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -458,9 +502,9 @@ } }, "node_modules/@aws-sdk/util-locate-window": { - "version": "3.965.5", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", - "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", + "version": "3.965.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.8.tgz", + "integrity": "sha512-uUbMs1cBZPafD0ohUj6EwNf0fPZ534NvBxHox4hjX+0Rxq5paSYUem7+hi833pYrzrcnBATKIYpR02MDXT5M9g==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -529,9 +573,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", - "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -613,6 +657,31 @@ "node": ">=14.17.0" } }, + "node_modules/@earendil-works/pi-ai": { + "version": "0.79.8", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.79.8.tgz", + "integrity": "sha512-ZpSwaD7oNpsjn9vtEatZQNT9PSdDJXi6rFeY5Qv+OHQGFDKlmcrfJE4ypm4SAc/fBECPs4Rdi3l+YjVtXYrkKw==", + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "0.91.1", + "@aws-sdk/client-bedrock-runtime": "3.1048.0", + "@google/genai": "1.52.0", + "@mistralai/mistralai": "2.2.6", + "@opentelemetry/api": "1.9.0", + "@smithy/node-http-handler": "4.7.3", + "http-proxy-agent": "7.0.2", + "https-proxy-agent": "7.0.6", + "openai": "6.26.0", + "partial-json": "0.1.7", + "typebox": "1.1.38" + }, + "bin": { + "pi-ai": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -2219,41 +2288,24 @@ "spark-md5": "^3.0.2" } }, - "node_modules/@mariozechner/pi-ai": { - "version": "0.73.1", - "resolved": "https://registry.npmjs.org/@mariozechner/pi-ai/-/pi-ai-0.73.1.tgz", - "integrity": "sha512-Jh4lXawZYuC83HzSIYuVum9NBqJD49i4JOt3H96cGW/924cwJMOyUs1Mv/e4QPzTXnzrqMoGviNQnvGgSu1LSg==", - "deprecated": "please use @earendil-works/pi-ai instead going forward", - "license": "MIT", - "dependencies": { - "@anthropic-ai/sdk": "^0.91.1", - "@aws-sdk/client-bedrock-runtime": "^3.1030.0", - "@google/genai": "^1.40.0", - "@mistralai/mistralai": "^2.2.0", - "chalk": "^5.6.2", - "openai": "6.26.0", - "partial-json": "^0.1.7", - "proxy-agent": "^6.5.0", - "typebox": "^1.1.24", - "undici": "^7.19.1", - "zod-to-json-schema": "^3.24.6" - }, - "bin": { - "pi-ai": "dist/cli.js" - }, - "engines": { - "node": ">=20.0.0" - } - }, "node_modules/@mistralai/mistralai": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.1.tgz", - "integrity": "sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ==", + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.6.tgz", + "integrity": "sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ==", "license": "Apache-2.0", "dependencies": { + "@opentelemetry/semantic-conventions": "^1.40.0", "ws": "^8.18.0", "zod": "^3.25.0 || ^4.0.0", "zod-to-json-schema": "^3.25.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } } }, "node_modules/@modelcontextprotocol/sdk": { @@ -2340,9 +2392,9 @@ } }, "node_modules/@nodable/entities": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", - "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==", "funding": [ { "type": "github", @@ -2389,6 +2441,24 @@ "node": ">= 8" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.41.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", + "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/@oxc-parser/binding-android-arm-eabi": { "version": "0.130.0", "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.130.0.tgz", @@ -3865,13 +3935,13 @@ "license": "MIT" }, "node_modules/@smithy/core": { - "version": "3.24.3", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.3.tgz", - "integrity": "sha512-Ep/7tPamGY8mgESE3LyLKtxJyy6U52WWAqr/3wial47Sj4u3PiIF73AOGI27UyLy9duTkhZbgzodOfLV4TduZg==", + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.25.1.tgz", + "integrity": "sha512-zpDbpXBCBsxfLtG2GEUyfgvHvSFrw5CwDZSNzL0v52gx/c3oPlPbm+7W7num8xs6vyiUBn+bvYPHcQDOXZynCQ==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/crc32": "5.2.0", - "@smithy/types": "^4.14.2", + "@smithy/types": "^4.15.0", "tslib": "^2.6.2" }, "engines": { @@ -3879,13 +3949,13 @@ } }, "node_modules/@smithy/credential-provider-imds": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.3.tgz", - "integrity": "sha512-I2Bti0DKFo2IJyN28ijCsx51BAumEYR4/1yZ1FXyBygy9MqbnMqCev4JPth/MbpRfBSRAX35hITSnAdJRo1u5w==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.1.tgz", + "integrity": "sha512-TSAF5NHgxEsllbErYWbK8aLnl5L601NGc5VYJlSPsKnf3YlkhdoBN+geGcaU00oiw2OK3QO5LA3QNXiiWhCidQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", + "@smithy/core": "^3.25.1", + "@smithy/types": "^4.15.0", "tslib": "^2.6.2" }, "engines": { @@ -3893,13 +3963,13 @@ } }, "node_modules/@smithy/fetch-http-handler": { - "version": "5.4.3", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.3.tgz", - "integrity": "sha512-F+DRf8IJazRJgYog2A/yJK7eYVc0rqTlRzO+5ZxjJd4WkZoKz0IJRncf7G6t1pdVT3kryJcwuTFhN1c5m6N47A==", + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.5.1.tgz", + "integrity": "sha512-96JrD1q71anokymx9Iblb+zKmNQYNstlV/25A9ZYIJ2A0rp1r7/GZAIm0bDWSmVvz3DpNOCZuabzsiL+w0UHhw==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", + "@smithy/core": "^3.25.1", + "@smithy/types": "^4.15.0", "tslib": "^2.6.2" }, "engines": { @@ -3933,13 +4003,13 @@ } }, "node_modules/@smithy/signature-v4": { - "version": "5.4.3", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.3.tgz", - "integrity": "sha512-53+75QuPl6DL+ct6vVEB51FDO5oulXr20TPV46VvJZg76lIlXNWfxi8j+G2V/t0I2qxCBOa3vX/8bmjrpFVo9g==", + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.5.1.tgz", + "integrity": "sha512-X9rVls3En0z3NtrmguTmpRM0/NqtWUxBjal6fcAkwtsub+gOdLZ6kD+V7xhUgFMGdG14bHbZ7M5QjaRI1+DatQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", + "@smithy/core": "^3.25.1", + "@smithy/types": "^4.15.0", "tslib": "^2.6.2" }, "engines": { @@ -3947,9 +4017,9 @@ } }, "node_modules/@smithy/types": { - "version": "4.14.2", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.2.tgz", - "integrity": "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==", + "version": "4.15.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.15.0.tgz", + "integrity": "sha512-Z5TAOxygoFvybJV3igo5SloFflSokHx2hu1eFA+DxDTcn+FtKxUSui+rbTRG1pAafMA888Z3MVvCWUuvCrTXjg==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -4135,12 +4205,6 @@ "@tonconnect/protocol": "2.4.0" } }, - "node_modules/@tootallnate/quickjs-emscripten": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", - "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", - "license": "MIT" - }, "node_modules/@ts-graphviz/adapter": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@ts-graphviz/adapter/-/adapter-2.0.6.tgz", @@ -4903,6 +4967,18 @@ "dev": true, "license": "MIT" }, + "node_modules/anynum": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", + "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/app-module-path": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/app-module-path/-/app-module-path-2.2.0.tgz", @@ -4958,18 +5034,6 @@ "node": ">=18" } }, - "node_modules/ast-types": { - "version": "0.13.4", - "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", - "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", - "license": "MIT", - "dependencies": { - "tslib": "^2.0.1" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/ast-v8-to-istanbul": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", @@ -5117,15 +5181,6 @@ ], "license": "MIT" }, - "node_modules/basic-ftp": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz", - "integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/better-sqlite3": { "version": "12.10.0", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.10.0.tgz", @@ -5817,12 +5872,12 @@ } }, "node_modules/data-uri-to-buffer": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", - "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", "license": "MIT", "engines": { - "node": ">= 14" + "node": ">= 12" } }, "node_modules/dataloader": { @@ -5993,20 +6048,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/degenerator": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", - "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", - "license": "MIT", - "dependencies": { - "ast-types": "^0.13.4", - "escodegen": "^2.1.0", - "esprima": "^4.0.1" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -6562,6 +6603,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "esprima": "^4.0.1", @@ -6764,6 +6806,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, "license": "BSD-2-Clause", "bin": { "esparse": "bin/esparse.js", @@ -6803,6 +6846,7 @@ "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=4.0" @@ -6822,6 +6866,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.10.0" @@ -7526,9 +7571,9 @@ } }, "node_modules/gaxios": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", - "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.5.tgz", + "integrity": "sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg==", "license": "Apache-2.0", "dependencies": { "extend": "^3.0.2", @@ -7539,15 +7584,6 @@ "node": ">=18" } }, - "node_modules/gaxios/node_modules/data-uri-to-buffer": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", - "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, "node_modules/gaxios/node_modules/node-fetch": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", @@ -7689,20 +7725,6 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, - "node_modules/get-uri": { - "version": "6.0.5", - "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", - "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", - "license": "MIT", - "dependencies": { - "basic-ftp": "^5.0.2", - "data-uri-to-buffer": "^6.0.2", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/github-from-package": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", @@ -7772,9 +7794,9 @@ } }, "node_modules/google-auth-library": { - "version": "10.6.2", - "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", - "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", + "version": "10.7.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.7.0.tgz", + "integrity": "sha512-QpTAbNJ36TliZLx3TTtahR8HG0hN9RllL1e3FymOvQSIKK8JmgV58H924ub2wa2DsS3ANjjP1Aw1N+Ramc8hqQ==", "license": "Apache-2.0", "dependencies": { "base64-js": "^1.3.0", @@ -9076,15 +9098,6 @@ "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", "license": "Apache-2.0" }, - "node_modules/lru-cache": { - "version": "7.18.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", - "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, "node_modules/madge": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/madge/-/madge-8.0.0.tgz", @@ -9708,15 +9721,6 @@ "node": ">= 0.6" } }, - "node_modules/netmask": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.1.1.tgz", - "integrity": "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==", - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/next-tick": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", @@ -10179,38 +10183,6 @@ "node": ">=8" } }, - "node_modules/pac-proxy-agent": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", - "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", - "license": "MIT", - "dependencies": { - "@tootallnate/quickjs-emscripten": "^0.23.0", - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "get-uri": "^6.0.1", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.6", - "pac-resolver": "^7.0.1", - "socks-proxy-agent": "^8.0.5" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/pac-resolver": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", - "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", - "license": "MIT", - "dependencies": { - "degenerator": "^5.0.0", - "netmask": "^2.0.2" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/pako": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", @@ -10725,31 +10697,6 @@ "node": ">= 0.10" } }, - "node_modules/proxy-agent": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", - "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "http-proxy-agent": "^7.0.1", - "https-proxy-agent": "^7.0.6", - "lru-cache": "^7.14.1", - "pac-proxy-agent": "^7.1.0", - "proxy-from-env": "^1.1.0", - "socks-proxy-agent": "^8.0.5" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/proxy-agent/node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" - }, "node_modules/proxy-from-env": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", @@ -11878,20 +11825,6 @@ "npm": ">= 3.0.0" } }, - "node_modules/socks-proxy-agent": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", - "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "socks": "^2.8.3" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/sonic-boom": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", @@ -11905,6 +11838,7 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, "license": "BSD-3-Clause", "optional": true, "engines": { @@ -12226,16 +12160,19 @@ } }, "node_modules/strnum": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", - "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz", + "integrity": "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/NaturalIntelligence" } ], - "license": "MIT" + "license": "MIT", + "dependencies": { + "anynum": "^1.0.1" + } }, "node_modules/stylus-lookup": { "version": "6.1.2", @@ -13419,15 +13356,6 @@ "node": ">=14" } }, - "node_modules/undici": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", - "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", - "license": "MIT", - "engines": { - "node": ">=20.18.1" - } - }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", diff --git a/package.json b/package.json index 763c40dd..fa8f01fc 100644 --- a/package.json +++ b/package.json @@ -63,10 +63,10 @@ }, "dependencies": { "@dedust/sdk": "^0.8.7", + "@earendil-works/pi-ai": "^0.79.8", "@hono/node-server": "^2.0.4", "@huggingface/transformers": "^3.8.1", "@inquirer/prompts": "^8.3.2", - "@mariozechner/pi-ai": "^0.73.1", "@modelcontextprotocol/sdk": "^1.28.0", "@sinclair/typebox": "^0.34.48", "@tavily/core": "^0.7.2", diff --git a/src/agent/client.ts b/src/agent/client.ts index 449c9727..17a55ff1 100644 --- a/src/agent/client.ts +++ b/src/agent/client.ts @@ -6,7 +6,7 @@ import { type Message, type Tool, type ProviderStreamOptions, -} from "@mariozechner/pi-ai"; +} from "@earendil-works/pi-ai"; import type { AgentConfig } from "../config/schema.js"; import { appendToTranscript, readTranscript } from "../session/transcript.js"; import type { SupportedProvider } from "../config/providers.js"; diff --git a/src/agent/runtime-utils.ts b/src/agent/runtime-utils.ts index a4374a5a..7f12a1c1 100644 --- a/src/agent/runtime-utils.ts +++ b/src/agent/runtime-utils.ts @@ -1,4 +1,4 @@ -import type { Context, TextContent, Usage } from "@mariozechner/pi-ai"; +import type { Context, TextContent, Usage } from "@earendil-works/pi-ai"; import { extractToolNames, stripEnvelopePrefix, truncate } from "../utils/pi-message.js"; /** Per-turn usage accumulator (input/output/cache + cost), summed across loop iterations. */ diff --git a/src/agent/runtime.ts b/src/agent/runtime.ts index a7ee66c4..d7835f00 100644 --- a/src/agent/runtime.ts +++ b/src/agent/runtime.ts @@ -45,7 +45,7 @@ import type { AssistantMessage, Message, ToolResultMessage, -} from "@mariozechner/pi-ai"; +} from "@earendil-works/pi-ai"; import { CompactionManager, DEFAULT_COMPACTION_CONFIG } from "../memory/compaction.js"; import { maskOldToolResults } from "../memory/observation-masking.js"; import { ContextBuilder } from "../memory/search/context.js"; diff --git a/src/agent/schema-sanitizer.ts b/src/agent/schema-sanitizer.ts index eb338b05..663b24ad 100644 --- a/src/agent/schema-sanitizer.ts +++ b/src/agent/schema-sanitizer.ts @@ -11,7 +11,7 @@ * without mutating the originals. */ -import type { Tool } from "@mariozechner/pi-ai"; +import type { Tool } from "@earendil-works/pi-ai"; const UNSUPPORTED_KEYS: ReadonlySet = new Set([ "$schema", diff --git a/src/agent/tools/__tests__/registry.test.ts b/src/agent/tools/__tests__/registry.test.ts index c042a03a..c9a3d645 100644 --- a/src/agent/tools/__tests__/registry.test.ts +++ b/src/agent/tools/__tests__/registry.test.ts @@ -3,10 +3,10 @@ import Database from "better-sqlite3"; import { ToolRegistry } from "../registry.js"; import { Type } from "@sinclair/typebox"; import type { Tool, ToolExecutor, ToolContext, ToolScope } from "../types.js"; -import type { ToolCall } from "@mariozechner/pi-ai"; +import type { ToolCall } from "@earendil-works/pi-ai"; // Mock modules -vi.mock("@mariozechner/pi-ai", () => ({ +vi.mock("@earendil-works/pi-ai", () => ({ validateToolCall: vi.fn((tools, toolCall) => toolCall.arguments), })); diff --git a/src/agent/tools/registry.ts b/src/agent/tools/registry.ts index b3e37089..8960fe93 100644 --- a/src/agent/tools/registry.ts +++ b/src/agent/tools/registry.ts @@ -1,5 +1,5 @@ -import { validateToolCall } from "@mariozechner/pi-ai"; -import type { Tool as PiAiTool, ToolCall } from "@mariozechner/pi-ai"; +import { validateToolCall } from "@earendil-works/pi-ai"; +import type { Tool as PiAiTool, ToolCall } from "@earendil-works/pi-ai"; import type { TSchema } from "@sinclair/typebox"; import type { RegisteredTool, diff --git a/src/agent/tools/search/tool-search.ts b/src/agent/tools/search/tool-search.ts index 62503b69..e1713ce9 100644 --- a/src/agent/tools/search/tool-search.ts +++ b/src/agent/tools/search/tool-search.ts @@ -1,6 +1,6 @@ import { Type } from "@sinclair/typebox"; import type { TSchema } from "@sinclair/typebox"; -import type { Tool as PiAiTool } from "@mariozechner/pi-ai"; +import type { Tool as PiAiTool } from "@earendil-works/pi-ai"; import type { Tool, ToolExecutor, ToolResult } from "../types.js"; import type { ToolRegistry } from "../registry.js"; import { createLogger } from "../../../utils/logger.js"; diff --git a/src/agent/tools/telegram/media/vision-analyze.ts b/src/agent/tools/telegram/media/vision-analyze.ts index 9e61d326..bea48936 100644 --- a/src/agent/tools/telegram/media/vision-analyze.ts +++ b/src/agent/tools/telegram/media/vision-analyze.ts @@ -5,7 +5,7 @@ import { type UserMessage, type ImageContent, type TextContent, -} from "@mariozechner/pi-ai"; +} from "@earendil-works/pi-ai"; import { getProviderModel, getEffectiveApiKey } from "../../../client.js"; import { getProviderMetadata, type SupportedProvider } from "../../../../config/providers.js"; import { readFileSync, existsSync } from "fs"; diff --git a/src/agent/tools/tool-index.ts b/src/agent/tools/tool-index.ts index cedd3458..da166c5c 100644 --- a/src/agent/tools/tool-index.ts +++ b/src/agent/tools/tool-index.ts @@ -1,5 +1,5 @@ import type Database from "better-sqlite3"; -import type { Tool as PiAiTool } from "@mariozechner/pi-ai"; +import type { Tool as PiAiTool } from "@earendil-works/pi-ai"; import type { EmbeddingProvider } from "../../memory/embeddings/provider.js"; import { serializeEmbedding } from "../../memory/embeddings/index.js"; import { diff --git a/src/memory/__tests__/observation-masking.test.ts b/src/memory/__tests__/observation-masking.test.ts index 33ac5015..119222bf 100644 --- a/src/memory/__tests__/observation-masking.test.ts +++ b/src/memory/__tests__/observation-masking.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import type { Message, ToolResultMessage, UserMessage } from "@mariozechner/pi-ai"; +import type { Message, ToolResultMessage, UserMessage } from "@earendil-works/pi-ai"; import { maskOldToolResults, DEFAULT_MASKING_CONFIG, diff --git a/src/memory/ai-summarization.ts b/src/memory/ai-summarization.ts index d0ce0493..bb75f12e 100644 --- a/src/memory/ai-summarization.ts +++ b/src/memory/ai-summarization.ts @@ -1,4 +1,4 @@ -import { complete, type Context, type Message, type TextContent } from "@mariozechner/pi-ai"; +import { complete, type Context, type Message, type TextContent } from "@earendil-works/pi-ai"; import { extractText, extractToolNames, stripEnvelopePrefix } from "../utils/pi-message.js"; import { getUtilityModel } from "../providers/model-resolver.js"; import type { SupportedProvider } from "../config/providers.js"; diff --git a/src/memory/compaction.ts b/src/memory/compaction.ts index 002702cb..706ce097 100644 --- a/src/memory/compaction.ts +++ b/src/memory/compaction.ts @@ -1,4 +1,4 @@ -import type { Context, Message, TextContent } from "@mariozechner/pi-ai"; +import type { Context, Message, TextContent } from "@earendil-works/pi-ai"; import { truncate } from "../utils/pi-message.js"; import { appendToTranscript, readTranscript } from "../session/transcript.js"; import { randomUUID } from "crypto"; diff --git a/src/memory/observation-masking.ts b/src/memory/observation-masking.ts index 5786b1bd..62780fb4 100644 --- a/src/memory/observation-masking.ts +++ b/src/memory/observation-masking.ts @@ -1,4 +1,4 @@ -import type { Message, ToolResultMessage, TextContent } from "@mariozechner/pi-ai"; +import type { Message, ToolResultMessage, TextContent } from "@earendil-works/pi-ai"; import type { ToolRegistry } from "../agent/tools/registry.js"; import { MASKING_KEEP_RECENT_COUNT, diff --git a/src/providers/model-resolver.ts b/src/providers/model-resolver.ts index 81eb79c3..3ca148e5 100644 --- a/src/providers/model-resolver.ts +++ b/src/providers/model-resolver.ts @@ -1,4 +1,4 @@ -import { getModel, type Model, type Api } from "@mariozechner/pi-ai"; +import { getModel, type Model, type Api } from "@earendil-works/pi-ai"; import { getProviderMetadata, type SupportedProvider } from "../config/providers.js"; import { createLogger } from "../utils/logger.js"; import { fetchWithTimeout } from "../utils/fetch.js"; diff --git a/src/session/memory-hook.ts b/src/session/memory-hook.ts index 3a94e071..2813f814 100644 --- a/src/session/memory-hook.ts +++ b/src/session/memory-hook.ts @@ -1,7 +1,7 @@ import { writeFile, mkdir, readdir, readFile } from "fs/promises"; import { mkdirSync, renameSync } from "fs"; import { join } from "path"; -import { complete, type Context } from "@mariozechner/pi-ai"; +import { complete, type Context } from "@earendil-works/pi-ai"; import { summarizeViaClaude, summarizeWithFallback, diff --git a/src/session/transcript.ts b/src/session/transcript.ts index 9ad2bd7d..df6f2553 100644 --- a/src/session/transcript.ts +++ b/src/session/transcript.ts @@ -9,7 +9,7 @@ import { } from "fs"; import { appendFile } from "fs/promises"; import { join } from "path"; -import type { Message, AssistantMessage } from "@mariozechner/pi-ai"; +import type { Message, AssistantMessage } from "@earendil-works/pi-ai"; import { TELETON_ROOT } from "../workspace/paths.js"; import { createLogger } from "../utils/logger.js"; diff --git a/src/telegram/task-executor.ts b/src/telegram/task-executor.ts index 17ab94f3..ae3b900f 100644 --- a/src/telegram/task-executor.ts +++ b/src/telegram/task-executor.ts @@ -1,7 +1,7 @@ import type { Task } from "../memory/agent/tasks.js"; import type { ToolContext } from "../agent/tools/types.js"; import type { ToolRegistry } from "../agent/tools/registry.js"; -import type { ToolCall } from "@mariozechner/pi-ai"; +import type { ToolCall } from "@earendil-works/pi-ai"; import type { AgentRuntime } from "../agent/runtime.js"; import { MAX_JSON_FIELD_CHARS, diff --git a/src/utils/pi-message.ts b/src/utils/pi-message.ts index c33991c0..f382e8cc 100644 --- a/src/utils/pi-message.ts +++ b/src/utils/pi-message.ts @@ -1,4 +1,4 @@ -import type { Message, TextContent, ToolCall } from "@mariozechner/pi-ai"; +import type { Message, TextContent, ToolCall } from "@earendil-works/pi-ai"; /** * Neutral, pure primitives for reading pi-ai `Message` content. Shared by the From ceb09d6ed33b68ec7c13a98a65703d8b5e689041 Mon Sep 17 00:00:00 2001 From: TONresistor <240980241+TONresistor@users.noreply.github.com> Date: Fri, 19 Jun 2026 13:20:26 +0200 Subject: [PATCH 02/59] feat(models): add Claude Opus 4.8 --- CHANGELOG.md | 8 ++++++++ src/config/model-catalog.ts | 14 ++++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9eaa06d..c97825ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Claude Opus 4.8 in the model catalog. + +### Changed + +- Migrated the LLM client from the deprecated `@mariozechner/pi-ai` to the maintained `@earendil-works/pi-ai` fork. + ## [0.9.0] - 2026-06-19 ### Added diff --git a/src/config/model-catalog.ts b/src/config/model-catalog.ts index 66b5e0c7..d0d0f56f 100644 --- a/src/config/model-catalog.ts +++ b/src/config/model-catalog.ts @@ -12,10 +12,15 @@ export interface ModelOption { export const MODEL_OPTIONS: Record = { anthropic: [ + { + value: "claude-opus-4-8", + name: "Claude Opus 4.8", + description: "Most capable available, 1M ctx, reasoning, $5/$25", + }, { value: "claude-opus-4-7", name: "Claude Opus 4.7", - description: "Most capable available, 1M ctx, reasoning, $5/$25", + description: "Previous gen, 1M ctx, reasoning, $5/$25", }, { value: "claude-opus-4-6", @@ -134,10 +139,15 @@ export const MODEL_OPTIONS: Record = { { value: "qwen/qwen3-32b", name: "Qwen3 32B", description: "Reasoning, 131K ctx, $0.29/M" }, ], openrouter: [ + { + value: "anthropic/claude-opus-4.8", + name: "Claude Opus 4.8", + description: "Latest, 1M ctx, reasoning, $5/M", + }, { value: "anthropic/claude-opus-4.7", name: "Claude Opus 4.7", - description: "Latest, 1M ctx, reasoning, $5/M", + description: "Previous gen, 1M ctx, reasoning, $5/M", }, { value: "anthropic/claude-sonnet-4.6", From b363fe0b1f49cd115f61cc771c06111a106a1a21 Mon Sep 17 00:00:00 2001 From: TONresistor <240980241+TONresistor@users.noreply.github.com> Date: Fri, 26 Jun 2026 05:49:08 +0200 Subject: [PATCH 03/59] chore(cleanup): remove dead code - drop unused MemoryDatabase methods (getStats/vacuum/optimize/getVectorDimensions/transaction) - drop unused loadSessionStore/saveSessionStore/SessionStore and their tests - delete orphan migration SQL (1.5.0/1.6.0) never loaded by runMigrations --- src/memory/database.ts | 59 ------------------- src/memory/migrations/1.5.0.sql | 63 -------------------- src/memory/migrations/1.6.0.sql | 90 ----------------------------- src/session/__tests__/store.test.ts | 35 +++-------- src/session/store.ts | 57 ------------------ 5 files changed, 7 insertions(+), 297 deletions(-) delete mode 100644 src/memory/migrations/1.5.0.sql delete mode 100644 src/memory/migrations/1.6.0.sql diff --git a/src/memory/database.ts b/src/memory/database.ts index 3ea5085e..1b1a659f 100644 --- a/src/memory/database.ts +++ b/src/memory/database.ts @@ -110,65 +110,6 @@ export class MemoryDatabase { return this._dimensionsChanged; } - getVectorDimensions(): number | undefined { - return this.config.vectorDimensions; - } - - transaction(fn: () => T): T { - return this.db.transaction(fn)(); - } - - getStats(): { - knowledge: number; - sessions: number; - tasks: number; - tgChats: number; - tgUsers: number; - tgMessages: number; - embeddingCache: number; - vectorSearchEnabled: boolean; - } { - const counts = this.db - .prepare( - `SELECT - (SELECT COUNT(*) FROM knowledge) as knowledge, - (SELECT COUNT(*) FROM sessions) as sessions, - (SELECT COUNT(*) FROM tasks) as tasks, - (SELECT COUNT(*) FROM tg_chats) as tg_chats, - (SELECT COUNT(*) FROM tg_users) as tg_users, - (SELECT COUNT(*) FROM tg_messages) as tg_messages, - (SELECT COUNT(*) FROM embedding_cache) as embedding_cache` - ) - .get() as { - knowledge: number; - sessions: number; - tasks: number; - tg_chats: number; - tg_users: number; - tg_messages: number; - embedding_cache: number; - }; - - return { - knowledge: counts.knowledge, - sessions: counts.sessions, - tasks: counts.tasks, - tgChats: counts.tg_chats, - tgUsers: counts.tg_users, - tgMessages: counts.tg_messages, - embeddingCache: counts.embedding_cache, - vectorSearchEnabled: this.vectorReady, - }; - } - - vacuum(): void { - this.db.exec("VACUUM"); - } - - optimize(): void { - this.db.exec("ANALYZE"); - } - /** * Rebuild FTS indexes from existing data. * Call this if FTS triggers didn't fire correctly. diff --git a/src/memory/migrations/1.5.0.sql b/src/memory/migrations/1.5.0.sql deleted file mode 100644 index 4d0bb957..00000000 --- a/src/memory/migrations/1.5.0.sql +++ /dev/null @@ -1,63 +0,0 @@ --- Migration 1.5.0: Deals system for secure gift/TON trading --- Enforces STRATEGY.md rules at code level --- User ALWAYS sends first (TON or gift) → verification → agent sends - -CREATE TABLE IF NOT EXISTS deals ( - id TEXT PRIMARY KEY, - status TEXT NOT NULL CHECK(status IN ( - 'proposed', 'accepted', 'verified', 'completed', - 'declined', 'expired', 'cancelled', 'failed' - )), - - -- Parties - user_telegram_id INTEGER NOT NULL, - user_username TEXT, - chat_id TEXT NOT NULL, - proposal_message_id INTEGER, - - -- What USER gives - user_gives_type TEXT NOT NULL CHECK(user_gives_type IN ('ton', 'gift')), - user_gives_ton_amount REAL, - user_gives_gift_id TEXT, - user_gives_gift_slug TEXT, - user_gives_value_ton REAL NOT NULL, - - -- What AGENT gives - agent_gives_type TEXT NOT NULL CHECK(agent_gives_type IN ('ton', 'gift')), - agent_gives_ton_amount REAL, - agent_gives_gift_id TEXT, - agent_gives_gift_slug TEXT, - agent_gives_value_ton REAL NOT NULL, - - -- Payment/Gift verification - user_payment_verified_at INTEGER, - user_payment_tx_hash TEXT, - user_payment_gift_msgid TEXT, - user_payment_wallet TEXT, - - -- Agent send tracking - agent_sent_at INTEGER, - agent_sent_tx_hash TEXT, - agent_sent_gift_msgid TEXT, - - -- Business logic - strategy_check TEXT, -- JSON: {floor_price, ratio, rule, profit} - profit_ton REAL, - - -- Timestamps - created_at INTEGER NOT NULL DEFAULT (unixepoch()), - expires_at INTEGER NOT NULL, -- 2 minutes from creation - completed_at INTEGER, - - notes TEXT -); - --- Performance indexes -CREATE INDEX IF NOT EXISTS idx_deals_status ON deals(status); -CREATE INDEX IF NOT EXISTS idx_deals_user ON deals(user_telegram_id); -CREATE INDEX IF NOT EXISTS idx_deals_chat ON deals(chat_id); -CREATE INDEX IF NOT EXISTS idx_deals_expires ON deals(expires_at) - WHERE status IN ('proposed', 'accepted'); - --- Update schema version -UPDATE meta SET value = '1.5.0' WHERE key = 'schema_version'; diff --git a/src/memory/migrations/1.6.0.sql b/src/memory/migrations/1.6.0.sql deleted file mode 100644 index b5705e9a..00000000 --- a/src/memory/migrations/1.6.0.sql +++ /dev/null @@ -1,90 +0,0 @@ --- Migration 1.6.0: Bot inline message tracking + payment_claimed status --- Recreates deals table to add 'payment_claimed' to CHECK constraint --- Adds inline_message_id and payment_claimed_at columns - --- Step 1: Rename old table -ALTER TABLE deals RENAME TO deals_old; - --- Step 2: Create new table with payment_claimed status + new columns -CREATE TABLE deals ( - id TEXT PRIMARY KEY, - status TEXT NOT NULL CHECK(status IN ( - 'proposed', 'accepted', 'payment_claimed', 'verified', 'completed', - 'declined', 'expired', 'cancelled', 'failed' - )), - user_telegram_id INTEGER NOT NULL, - user_username TEXT, - chat_id TEXT NOT NULL, - proposal_message_id INTEGER, - user_gives_type TEXT NOT NULL CHECK(user_gives_type IN ('ton', 'gift')), - user_gives_ton_amount REAL, - user_gives_gift_id TEXT, - user_gives_gift_slug TEXT, - user_gives_value_ton REAL NOT NULL, - agent_gives_type TEXT NOT NULL CHECK(agent_gives_type IN ('ton', 'gift')), - agent_gives_ton_amount REAL, - agent_gives_gift_id TEXT, - agent_gives_gift_slug TEXT, - agent_gives_value_ton REAL NOT NULL, - user_payment_verified_at INTEGER, - user_payment_tx_hash TEXT, - user_payment_gift_msgid TEXT, - user_payment_wallet TEXT, - agent_sent_at INTEGER, - agent_sent_tx_hash TEXT, - agent_sent_gift_msgid TEXT, - strategy_check TEXT, - profit_ton REAL, - created_at INTEGER NOT NULL DEFAULT (unixepoch()), - expires_at INTEGER NOT NULL, - completed_at INTEGER, - notes TEXT, - inline_message_id TEXT, - payment_claimed_at INTEGER -); - --- Step 3: Copy data from old table (new columns default to NULL) -INSERT INTO deals ( - id, status, user_telegram_id, user_username, chat_id, proposal_message_id, - user_gives_type, user_gives_ton_amount, user_gives_gift_id, user_gives_gift_slug, user_gives_value_ton, - agent_gives_type, agent_gives_ton_amount, agent_gives_gift_id, agent_gives_gift_slug, agent_gives_value_ton, - user_payment_verified_at, user_payment_tx_hash, user_payment_gift_msgid, user_payment_wallet, - agent_sent_at, agent_sent_tx_hash, agent_sent_gift_msgid, - strategy_check, profit_ton, created_at, expires_at, completed_at, notes -) -SELECT - id, status, user_telegram_id, user_username, chat_id, proposal_message_id, - user_gives_type, user_gives_ton_amount, user_gives_gift_id, user_gives_gift_slug, user_gives_value_ton, - agent_gives_type, agent_gives_ton_amount, agent_gives_gift_id, agent_gives_gift_slug, agent_gives_value_ton, - user_payment_verified_at, user_payment_tx_hash, user_payment_gift_msgid, user_payment_wallet, - agent_sent_at, agent_sent_tx_hash, agent_sent_gift_msgid, - strategy_check, profit_ton, created_at, expires_at, completed_at, notes -FROM deals_old; - --- Step 4: Drop old table -DROP TABLE deals_old; - --- Recreate indexes -CREATE INDEX IF NOT EXISTS idx_deals_status ON deals(status); -CREATE INDEX IF NOT EXISTS idx_deals_user ON deals(user_telegram_id); -CREATE INDEX IF NOT EXISTS idx_deals_chat ON deals(chat_id); -CREATE INDEX IF NOT EXISTS idx_deals_inline_msg ON deals(inline_message_id) WHERE inline_message_id IS NOT NULL; -CREATE INDEX IF NOT EXISTS idx_deals_payment_claimed ON deals(payment_claimed_at) WHERE payment_claimed_at IS NOT NULL; - --- User stats for trading history -CREATE TABLE IF NOT EXISTS user_trade_stats ( - telegram_id INTEGER PRIMARY KEY, - username TEXT, - first_trade_at INTEGER DEFAULT (unixepoch()), - total_deals INTEGER DEFAULT 0, - completed_deals INTEGER DEFAULT 0, - declined_deals INTEGER DEFAULT 0, - total_ton_sent REAL DEFAULT 0, - total_ton_received REAL DEFAULT 0, - total_gifts_sent INTEGER DEFAULT 0, - total_gifts_received INTEGER DEFAULT 0, - last_deal_at INTEGER -); - --- Update schema version -UPDATE meta SET value = '1.6.0' WHERE key = 'schema_version'; diff --git a/src/session/__tests__/store.test.ts b/src/session/__tests__/store.test.ts index a32dc621..4ab8d736 100644 --- a/src/session/__tests__/store.test.ts +++ b/src/session/__tests__/store.test.ts @@ -12,15 +12,8 @@ vi.mock("../../memory/index.js", () => ({ })); // Import after mock setup -const { - getOrCreateSession, - updateSession, - getSession, - saveSessionStore, - loadSessionStore, - pruneOldSessions, - shouldResetSession, -} = await import("../store.js"); +const { getOrCreateSession, updateSession, getSession, pruneOldSessions, shouldResetSession } = + await import("../store.js"); describe("Session Store", () => { beforeEach(() => { @@ -74,31 +67,17 @@ describe("Session Store", () => { expect(session!.outputTokens).toBe(1000); }); - it("loads token usage from database via loadSessionStore", () => { + it("loads token usage from database via getSession", () => { getOrCreateSession("123"); updateSession("123", { inputTokens: 9999, outputTokens: 4444, }); - const store = loadSessionStore(); - const session = store["telegram:123"]; - expect(session).toBeDefined(); - expect(session.inputTokens).toBe(9999); - expect(session.outputTokens).toBe(4444); - }); - - it("saves and restores token usage via saveSessionStore + loadSessionStore", () => { - const session = getOrCreateSession("456"); - session.inputTokens = 7777; - session.outputTokens = 3333; - - saveSessionStore({ "telegram:456": session }); - - const store = loadSessionStore(); - const restored = store["telegram:456"]; - expect(restored.inputTokens).toBe(7777); - expect(restored.outputTokens).toBe(3333); + const session = getSession("123"); + expect(session).not.toBeNull(); + expect(session!.inputTokens).toBe(9999); + expect(session!.outputTokens).toBe(4444); }); it("defaults to 0 for new sessions", () => { diff --git a/src/session/store.ts b/src/session/store.ts index 96778fe6..fb801c1f 100644 --- a/src/session/store.ts +++ b/src/session/store.ts @@ -28,8 +28,6 @@ export interface SessionEntry { outputTokens?: number; } -export type SessionStore = Record; - /** * camelCase field → snake_case column for the fields updateSession can write. * `updated_at` is always set separately; `chatId`/`createdAt` are immutable. @@ -94,61 +92,6 @@ function rowToSession(row: SessionRow): SessionEntry { outputTokens: row.output_tokens ?? undefined, }; } -export function loadSessionStore(): SessionStore { - try { - const db = getDb(); - const rows = db.prepare("SELECT * FROM sessions").all() as SessionRow[]; - - const store: SessionStore = {}; - for (const row of rows) { - const sessionKey = row.chat_id; - store[sessionKey] = rowToSession(row); - } - - return store; - } catch (error) { - log.warn({ err: error }, "Failed to load sessions from database"); - return {}; - } -} -export function saveSessionStore(store: SessionStore): void { - try { - const db = getDb(); - - const insertStmt = db.prepare(` - INSERT INTO sessions ( - id, chat_id, started_at, updated_at, message_count, - last_message_id, last_channel, last_to, context_tokens, - model, provider, last_reset_date, input_tokens, output_tokens - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `); - - db.transaction(() => { - db.prepare("DELETE FROM sessions").run(); - - for (const [chatId, session] of Object.entries(store)) { - insertStmt.run( - session.sessionId, - chatId, - session.createdAt, - session.updatedAt, - session.messageCount, - session.lastMessageId, - session.lastChannel, - session.lastTo, - session.contextTokens, - session.model, - session.provider, - session.lastResetDate, - session.inputTokens ?? 0, - session.outputTokens ?? 0 - ); - } - })(); - } catch (error) { - log.error({ err: error }, "Failed to save sessions to database"); - } -} export function getOrCreateSession(chatId: string): SessionEntry { const db = getDb(); const sessionKey = sessionKeyFor(chatId); From a83f51aab416c0752a49d83cbbca2643a20d9ce4 Mon Sep 17 00:00:00 2001 From: TONresistor <240980241+TONresistor@users.noreply.github.com> Date: Fri, 26 Jun 2026 05:52:20 +0200 Subject: [PATCH 04/59] refactor(dedup): share row mappers and media classification - extract rowToUser/rowToChat in memory/feed (was inlined 4x/2x) - add classifyMedia() in bridge-interface, used by both bridges - extract cachePeer() in user bridge (eviction was inlined twice) --- src/memory/feed/chats.ts | 46 ++++++++------------- src/memory/feed/users.ts | 71 +++++++++----------------------- src/telegram/bridge-interface.ts | 16 +++++++ src/telegram/bridges/bot.ts | 25 ++++------- src/telegram/bridges/user.ts | 49 +++++++++------------- 5 files changed, 82 insertions(+), 125 deletions(-) diff --git a/src/memory/feed/chats.ts b/src/memory/feed/chats.ts index 34751366..9ac4c3d7 100644 --- a/src/memory/feed/chats.ts +++ b/src/memory/feed/chats.ts @@ -15,6 +15,22 @@ export interface TelegramChat { updatedAt: Date; } +function rowToChat(row: TgChatRow): TelegramChat { + return { + id: row.id, + type: row.type as TelegramChat["type"], + title: row.title ?? undefined, + username: row.username ?? undefined, + memberCount: row.member_count ?? undefined, + isMonitored: Boolean(row.is_monitored), + isArchived: Boolean(row.is_archived), + lastMessageId: row.last_message_id ?? undefined, + lastMessageAt: row.last_message_at ? new Date(row.last_message_at * 1000) : undefined, + createdAt: new Date(row.created_at * 1000), + updatedAt: new Date(row.updated_at * 1000), + }; +} + export class ChatStore { constructor(private db: Database.Database) {} @@ -62,21 +78,7 @@ export class ChatStore { ) .get(id) as TgChatRow | undefined; - if (!row) return undefined; - - return { - id: row.id, - type: row.type as TelegramChat["type"], - title: row.title ?? undefined, - username: row.username ?? undefined, - memberCount: row.member_count ?? undefined, - isMonitored: Boolean(row.is_monitored), - isArchived: Boolean(row.is_archived), - lastMessageId: row.last_message_id ?? undefined, - lastMessageAt: row.last_message_at ? new Date(row.last_message_at * 1000) : undefined, - createdAt: new Date(row.created_at * 1000), - updatedAt: new Date(row.updated_at * 1000), - }; + return row ? rowToChat(row) : undefined; } getActiveChats(limit: number = 50): TelegramChat[] { @@ -91,19 +93,7 @@ export class ChatStore { ) .all(limit) as TgChatRow[]; - return rows.map((row) => ({ - id: row.id, - type: row.type as TelegramChat["type"], - title: row.title ?? undefined, - username: row.username ?? undefined, - memberCount: row.member_count ?? undefined, - isMonitored: Boolean(row.is_monitored), - isArchived: Boolean(row.is_archived), - lastMessageId: row.last_message_id ?? undefined, - lastMessageAt: row.last_message_at ? new Date(row.last_message_at * 1000) : undefined, - createdAt: new Date(row.created_at * 1000), - updatedAt: new Date(row.updated_at * 1000), - })); + return rows.map(rowToChat); } updateLastMessage(chatId: string, messageId: string, timestamp: Date): void { diff --git a/src/memory/feed/users.ts b/src/memory/feed/users.ts index ee1a6b44..18a941c5 100644 --- a/src/memory/feed/users.ts +++ b/src/memory/feed/users.ts @@ -14,6 +14,21 @@ export interface TelegramUser { messageCount: number; } +function rowToUser(row: TgUserRow): TelegramUser { + return { + id: row.id, + username: row.username ?? undefined, + firstName: row.first_name ?? undefined, + lastName: row.last_name ?? undefined, + isBot: Boolean(row.is_bot), + isAdmin: Boolean(row.is_admin), + isAllowed: Boolean(row.is_allowed), + firstSeenAt: new Date(row.first_seen_at * 1000), + lastSeenAt: new Date(row.last_seen_at * 1000), + messageCount: row.message_count, + }; +} + export class UserStore { constructor(private db: Database.Database) {} @@ -69,20 +84,7 @@ export class UserStore { | TgUserRow | undefined; - if (!row) return undefined; - - return { - id: row.id, - username: row.username ?? undefined, - firstName: row.first_name ?? undefined, - lastName: row.last_name ?? undefined, - isBot: Boolean(row.is_bot), - isAdmin: Boolean(row.is_admin), - isAllowed: Boolean(row.is_allowed), - firstSeenAt: new Date(row.first_seen_at * 1000), - lastSeenAt: new Date(row.last_seen_at * 1000), - messageCount: row.message_count, - }; + return row ? rowToUser(row) : undefined; } getUserByUsername(username: string): TelegramUser | undefined { @@ -90,20 +92,7 @@ export class UserStore { .prepare(`SELECT * FROM tg_users WHERE username = ?`) .get(username.replace("@", "")) as TgUserRow | undefined; - if (!row) return undefined; - - return { - id: row.id, - username: row.username ?? undefined, - firstName: row.first_name ?? undefined, - lastName: row.last_name ?? undefined, - isBot: Boolean(row.is_bot), - isAdmin: Boolean(row.is_admin), - isAllowed: Boolean(row.is_allowed), - firstSeenAt: new Date(row.first_seen_at * 1000), - lastSeenAt: new Date(row.last_seen_at * 1000), - messageCount: row.message_count, - }; + return row ? rowToUser(row) : undefined; } updateLastSeen(userId: string): void { @@ -165,18 +154,7 @@ export class UserStore { ) .all() as TgUserRow[]; - return rows.map((row) => ({ - id: row.id, - username: row.username ?? undefined, - firstName: row.first_name ?? undefined, - lastName: row.last_name ?? undefined, - isBot: Boolean(row.is_bot), - isAdmin: Boolean(row.is_admin), - isAllowed: Boolean(row.is_allowed), - firstSeenAt: new Date(row.first_seen_at * 1000), - lastSeenAt: new Date(row.last_seen_at * 1000), - messageCount: row.message_count, - })); + return rows.map(rowToUser); } getRecentUsers(limit: number = 50): TelegramUser[] { @@ -190,17 +168,6 @@ export class UserStore { ) .all(limit) as TgUserRow[]; - return rows.map((row) => ({ - id: row.id, - username: row.username ?? undefined, - firstName: row.first_name ?? undefined, - lastName: row.last_name ?? undefined, - isBot: Boolean(row.is_bot), - isAdmin: Boolean(row.is_admin), - isAllowed: Boolean(row.is_allowed), - firstSeenAt: new Date(row.first_seen_at * 1000), - lastSeenAt: new Date(row.last_seen_at * 1000), - messageCount: row.message_count, - })); + return rows.map(rowToUser); } } diff --git a/src/telegram/bridge-interface.ts b/src/telegram/bridge-interface.ts index b640a749..ba735dcf 100644 --- a/src/telegram/bridge-interface.ts +++ b/src/telegram/bridge-interface.ts @@ -20,6 +20,22 @@ export interface TelegramMessage { _rawMessage?: Api.Message; } +type MediaType = NonNullable; + +/** + * Classify a message's media from per-kind presence flags. Shared by both bridges + * (GramJS user, grammy bot) which extract the flags from their own message shapes. + * A message carries at most one media kind, so the check order is arbitrary. + */ +export function classifyMedia(present: Record): { + hasMedia: boolean; + mediaType?: MediaType; +} { + const order: MediaType[] = ["photo", "video", "audio", "voice", "sticker", "document"]; + const mediaType = order.find((k) => present[k]); + return { hasMedia: mediaType !== undefined, mediaType }; +} + export interface InlineButton { text: string; callback_data: string; diff --git a/src/telegram/bridges/bot.ts b/src/telegram/bridges/bot.ts index 74c2d605..de857664 100644 --- a/src/telegram/bridges/bot.ts +++ b/src/telegram/bridges/bot.ts @@ -1,6 +1,7 @@ import { Bot, InlineKeyboard, InputFile, type Context } from "grammy"; import { markdownToTelegramHtml } from "../formatting.js"; import { TELEGRAM_MAX_MESSAGE_LENGTH } from "../../constants/limits.js"; +import { classifyMedia } from "../bridge-interface.js"; import type { ITelegramBridge, SentMessage, @@ -434,22 +435,14 @@ export class GrammyBotBridge implements ITelegramBridge { mentionsMe = true; } - const hasMedia = !!( - msg.photo || - msg.voice || - msg.audio || - msg.document || - msg.video || - msg.sticker - ); - - let mediaType: TelegramMessage["mediaType"]; - if (msg.photo) mediaType = "photo"; - else if (msg.video) mediaType = "video"; - else if (msg.voice) mediaType = "voice"; - else if (msg.audio) mediaType = "audio"; - else if (msg.sticker) mediaType = "sticker"; - else if (msg.document) mediaType = "document"; + const { hasMedia, mediaType } = classifyMedia({ + photo: msg.photo, + video: msg.video, + audio: msg.audio, + voice: msg.voice, + sticker: msg.sticker, + document: msg.document, + }); return { id: msg.message_id, diff --git a/src/telegram/bridges/user.ts b/src/telegram/bridges/user.ts index f1f82fa5..634a383e 100644 --- a/src/telegram/bridges/user.ts +++ b/src/telegram/bridges/user.ts @@ -4,6 +4,7 @@ import type { NewMessageEvent } from "telegram/events/NewMessage.js"; import { createLogger } from "../../utils/logger.js"; import { withFloodRetry } from "../flood-retry.js"; import { randomLong } from "../../utils/gramjs-bigint.js"; +import { classifyMedia } from "../bridge-interface.js"; import type { ITelegramBridge, TelegramMessage, @@ -33,6 +34,15 @@ export class GramJSUserBridge implements ITelegramBridge { this.client = new TelegramUserClient(config); } + /** Cache a chat's peer for later resolution, evicting the oldest past a cap. */ + private cachePeer(chatId: string, peer: Api.TypePeer): void { + this.peerCache.set(chatId, peer); + if (this.peerCache.size > 5000) { + const oldest = this.peerCache.keys().next().value; + if (oldest !== undefined) this.peerCache.delete(oldest); + } + } + getMode(): "user" | "bot" { return "user"; } @@ -558,31 +568,18 @@ export class GramJSUserBridge implements ITelegramBridge { const isChannel = msg.post ?? false; const isGroup = !isChannel && chatId.startsWith("-"); - if (msg.peerId) { - this.peerCache.set(chatId, msg.peerId); - if (this.peerCache.size > 5000) { - const oldest = this.peerCache.keys().next().value; - if (oldest !== undefined) this.peerCache.delete(oldest); - } - } + if (msg.peerId) this.cachePeer(chatId, msg.peerId); const { senderUsername, senderFirstName, isBot } = await this.resolveSender(msg); - const hasMedia = !!( - msg.photo || - msg.document || - msg.video || - msg.audio || - msg.voice || - msg.sticker - ); - let mediaType: TelegramMessage["mediaType"]; - if (msg.photo) mediaType = "photo"; - else if (msg.video) mediaType = "video"; - else if (msg.audio) mediaType = "audio"; - else if (msg.voice) mediaType = "voice"; - else if (msg.sticker) mediaType = "sticker"; - else if (msg.document) mediaType = "document"; + const { hasMedia, mediaType } = classifyMedia({ + photo: msg.photo, + video: msg.video, + audio: msg.audio, + voice: msg.voice, + sticker: msg.sticker, + document: msg.document, + }); const replyToMsgId = msg.replyToMsgId; @@ -711,13 +708,7 @@ export class GramJSUserBridge implements ITelegramBridge { if (!text) return null; - if (msg.peerId) { - this.peerCache.set(chatId, msg.peerId); - if (this.peerCache.size > 5000) { - const oldest = this.peerCache.keys().next().value; - if (oldest !== undefined) this.peerCache.delete(oldest); - } - } + if (msg.peerId) this.cachePeer(chatId, msg.peerId); return { id: msg.id, From 558705f66b2570eb606ff5e6ccb0aa4b924662f5 Mon Sep 17 00:00:00 2001 From: TONresistor <240980241+TONresistor@users.noreply.github.com> Date: Fri, 26 Jun 2026 05:53:39 +0200 Subject: [PATCH 05/59] refactor(hooks): share per-hook execution in runner runModifyingHook/runObservingHook were ~90% duplicate. Extract canRun() guard and runOne() (timeout + catch policy); drop unused withTimeout log param. --- src/sdk/hooks/runner.ts | 102 +++++++++++++++------------------------- 1 file changed, 39 insertions(+), 63 deletions(-) diff --git a/src/sdk/hooks/runner.ts b/src/sdk/hooks/runner.ts index 08b866d8..db551fbf 100644 --- a/src/sdk/hooks/runner.ts +++ b/src/sdk/hooks/runner.ts @@ -7,8 +7,7 @@ const DEFAULT_TIMEOUT_MS = 5000; async function withTimeout( fn: () => void | Promise, ms: number, - label: string, - _log: HookRunnerOptions["logger"] + label: string ): Promise { let timer: ReturnType | undefined; try { @@ -35,88 +34,65 @@ export function createHookRunner(registry: HookRegistry, opts: HookRunnerOptions const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; const catchErrors = opts.catchErrors ?? true; - async function runModifyingHook( + /** Skip when no hooks or already inside a hook (reentrancy guard). */ + function canRun(name: HookName): boolean { + if (hookDepth > 0) { + opts.logger.debug(`Skipping ${name} hooks (reentrancy depth=${hookDepth})`); + return false; + } + return registry.hasHooks(name); + } + + /** Run one hook with timeout; absorb or rethrow per catchErrors. */ + async function runOne( + hook: ReturnType[number], name: K, event: Parameters[0] ): Promise { - if (!registry.hasHooks(name) || hookDepth > 0) { - if (hookDepth > 0) { - opts.logger.debug(`Skipping ${name} hooks (reentrancy depth=${hookDepth})`); - } - return; + const label = `${hook.pluginId}:${name}`; + const t0 = Date.now(); + try { + await withTimeout( + () => (hook.handler as (e: typeof event) => void | Promise)(event), + timeoutMs, + label + ); + } catch (error) { + if (!catchErrors) throw error; + opts.logger.error( + `Hook error [${label}]: ${getErrorMessage(error)} (after ${Date.now() - t0}ms)` + ); } + } + // Modifying hooks run sequentially (priority order) and can short-circuit via block=true. + async function runModifyingHook( + name: K, + event: Parameters[0] + ): Promise { + if (!canRun(name)) return; const hooks = registry.getHooks(name); // pre-sorted by effectivePriority in registry hookDepth++; try { for (const hook of hooks) { - const label = `${hook.pluginId}:${name}`; - const t0 = Date.now(); - try { - await withTimeout( - () => (hook.handler as (e: typeof event) => void | Promise)(event), - timeoutMs, - label, - opts.logger - ); - } catch (error) { - if (catchErrors) { - opts.logger.error( - `Hook error [${label}]: ${getErrorMessage(error)} (after ${Date.now() - t0}ms)` - ); - } else { - throw error; - } - } - - // Short-circuit for blockable hooks when block=true - if (BLOCKABLE_HOOKS.has(name) && (event as { block?: boolean }).block) { - break; - } + await runOne(hook, name, event); + if (BLOCKABLE_HOOKS.has(name) && (event as { block?: boolean }).block) break; } } finally { hookDepth--; } } + // Observing hooks run in parallel (no order guarantees). async function runObservingHook( name: K, event: Parameters[0] ): Promise { - if (!registry.hasHooks(name) || hookDepth > 0) { - if (hookDepth > 0) { - opts.logger.debug(`Skipping ${name} hooks (reentrancy depth=${hookDepth})`); - } - return; - } - - const hooks = registry.getHooks(name); // order irrelevant — parallel execution + if (!canRun(name)) return; + const hooks = registry.getHooks(name); hookDepth++; try { - // Observing hooks run in parallel (no order guarantees) - const results = await Promise.allSettled( - hooks.map(async (hook) => { - const label = `${hook.pluginId}:${name}`; - const t0 = Date.now(); - try { - await withTimeout( - () => (hook.handler as (e: typeof event) => void | Promise)(event), - timeoutMs, - label, - opts.logger - ); - } catch (error) { - if (catchErrors) { - opts.logger.error( - `Hook error [${label}]: ${getErrorMessage(error)} (after ${Date.now() - t0}ms)` - ); - } else { - throw error; - } - } - }) - ); - + const results = await Promise.allSettled(hooks.map((hook) => runOne(hook, name, event))); // When catchErrors=false, re-throw the first rejection that allSettled absorbed if (!catchErrors) { const firstRejected = results.find((r) => r.status === "rejected") as From 7994346f3ebc358f0fd6c539e62465d9e44c57fe Mon Sep 17 00:00:00 2001 From: TONresistor <240980241+TONresistor@users.noreply.github.com> Date: Fri, 26 Jun 2026 05:55:57 +0200 Subject: [PATCH 06/59] refactor(telegram-tools): drop dead named re-exports The 15 telegram tool index files re-exported every tool/executor by name, but nothing consumes them: the registry aggregates via the tools[] arrays and the two external callers import the executor file directly. Keep only the tools[] arrays. Removes the export*/export{} glue (~125 lines). --- src/agent/tools/telegram/chats/index.ts | 13 ------ src/agent/tools/telegram/contacts/index.ts | 6 --- src/agent/tools/telegram/folders/index.ts | 4 -- src/agent/tools/telegram/gifts/index.ts | 14 ------- src/agent/tools/telegram/groups/index.ts | 13 ------ src/agent/tools/telegram/index.ts | 42 ------------------- src/agent/tools/telegram/interactive/index.ts | 6 --- src/agent/tools/telegram/media/index.ts | 8 ---- src/agent/tools/telegram/memory/index.ts | 5 --- src/agent/tools/telegram/messaging/index.ts | 19 --------- src/agent/tools/telegram/profile/index.ts | 5 --- src/agent/tools/telegram/stars/index.ts | 3 -- src/agent/tools/telegram/stickers/index.ts | 5 --- src/agent/tools/telegram/stories/index.ts | 2 - src/agent/tools/telegram/tasks/index.ts | 2 - 15 files changed, 147 deletions(-) diff --git a/src/agent/tools/telegram/chats/index.ts b/src/agent/tools/telegram/chats/index.ts index 0703806a..628006ad 100644 --- a/src/agent/tools/telegram/chats/index.ts +++ b/src/agent/tools/telegram/chats/index.ts @@ -27,19 +27,6 @@ import { } from "./set-channel-username.js"; import type { ToolEntry } from "../../types.js"; -export { telegramGetDialogsTool, telegramGetDialogsExecutor }; -export { telegramGetHistoryTool, telegramGetHistoryExecutor }; -export { telegramGetChatInfoTool, telegramGetChatInfoExecutor }; -export { telegramMarkAsReadTool, telegramMarkAsReadExecutor }; -export { telegramJoinChannelTool, telegramJoinChannelExecutor }; -export { telegramLeaveChannelTool, telegramLeaveChannelExecutor }; -export { telegramCreateChannelTool, telegramCreateChannelExecutor }; -export { telegramEditChannelInfoTool, telegramEditChannelInfoExecutor }; -export { telegramInviteToChannelTool, telegramInviteToChannelExecutor }; -export { telegramGetAdminedChannelsTool, telegramGetAdminedChannelsExecutor }; -export { telegramCheckChannelUsernameTool, telegramCheckChannelUsernameExecutor }; -export { telegramSetChannelUsernameTool, telegramSetChannelUsernameExecutor }; - export const tools: ToolEntry[] = [ { tool: telegramGetDialogsTool, diff --git a/src/agent/tools/telegram/contacts/index.ts b/src/agent/tools/telegram/contacts/index.ts index 0fd2c088..c146ad3c 100644 --- a/src/agent/tools/telegram/contacts/index.ts +++ b/src/agent/tools/telegram/contacts/index.ts @@ -5,12 +5,6 @@ import { telegramGetUserInfoTool, telegramGetUserInfoExecutor } from "./get-user import { telegramCheckUsernameTool, telegramCheckUsernameExecutor } from "./check-username.js"; import type { ToolEntry } from "../../types.js"; -export { telegramBlockUserTool, telegramBlockUserExecutor }; -export { telegramGetBlockedTool, telegramGetBlockedExecutor }; -export { telegramGetCommonChatsTool, telegramGetCommonChatsExecutor }; -export { telegramGetUserInfoTool, telegramGetUserInfoExecutor }; -export { telegramCheckUsernameTool, telegramCheckUsernameExecutor }; - export const tools: ToolEntry[] = [ { tool: telegramBlockUserTool, diff --git a/src/agent/tools/telegram/folders/index.ts b/src/agent/tools/telegram/folders/index.ts index 0b1ca145..f5ab2a1a 100644 --- a/src/agent/tools/telegram/folders/index.ts +++ b/src/agent/tools/telegram/folders/index.ts @@ -6,10 +6,6 @@ import { } from "./add-chat-to-folder.js"; import type { ToolEntry } from "../../types.js"; -export { telegramGetFoldersTool, telegramGetFoldersExecutor }; -export { telegramCreateFolderTool, telegramCreateFolderExecutor }; -export { telegramAddChatToFolderTool, telegramAddChatToFolderExecutor }; - export const tools: ToolEntry[] = [ { tool: telegramGetFoldersTool, diff --git a/src/agent/tools/telegram/gifts/index.ts b/src/agent/tools/telegram/gifts/index.ts index ec95017c..49f2b19c 100644 --- a/src/agent/tools/telegram/gifts/index.ts +++ b/src/agent/tools/telegram/gifts/index.ts @@ -33,20 +33,6 @@ import { getUserGiftsEntry } from "./get-user-gifts.js"; import { getAvailableGiftsBotEntry } from "./get-available-gifts-bot.js"; import type { ToolEntry } from "../../types.js"; -export { telegramGetAvailableGiftsTool, telegramGetAvailableGiftsExecutor }; -export { telegramSendGiftTool, telegramSendGiftExecutor }; -export { telegramGetMyGiftsTool, telegramGetMyGiftsExecutor }; -export { telegramTransferCollectibleTool, telegramTransferCollectibleExecutor }; -export { telegramSetCollectiblePriceTool, telegramSetCollectiblePriceExecutor }; -export { telegramGetResaleGiftsTool, telegramGetResaleGiftsExecutor }; -export { telegramBuyResaleGiftTool, telegramBuyResaleGiftExecutor }; -export { telegramSetGiftStatusTool, telegramSetGiftStatusExecutor }; -export { telegramGetCollectibleInfoTool, telegramGetCollectibleInfoExecutor }; -export { telegramGetUniqueGiftTool, telegramGetUniqueGiftExecutor }; -export { telegramGetUniqueGiftValueTool, telegramGetUniqueGiftValueExecutor }; -export { telegramSendGiftOfferTool, telegramSendGiftOfferExecutor }; -export { telegramResolveGiftOfferTool, telegramResolveGiftOfferExecutor }; - export const tools: ToolEntry[] = [ { tool: telegramGetAvailableGiftsTool, diff --git a/src/agent/tools/telegram/groups/index.ts b/src/agent/tools/telegram/groups/index.ts index 42b28902..b5da7e7b 100644 --- a/src/agent/tools/telegram/groups/index.ts +++ b/src/agent/tools/telegram/groups/index.ts @@ -15,19 +15,6 @@ import { telegramCreateGroupTool, telegramCreateGroupExecutor } from "./create-g import { telegramSetChatPhotoTool, telegramSetChatPhotoExecutor } from "./set-chat-photo.js"; import type { ToolEntry } from "../../types.js"; -export { telegramGetMeTool, telegramGetMeExecutor }; -export { telegramGetParticipantsTool, telegramGetParticipantsExecutor }; -export { - telegramKickUserTool, - telegramKickUserExecutor, - telegramBanUserTool, - telegramBanUserExecutor, - telegramUnbanUserTool, - telegramUnbanUserExecutor, -}; -export { telegramCreateGroupTool, telegramCreateGroupExecutor }; -export { telegramSetChatPhotoTool, telegramSetChatPhotoExecutor }; - export const tools: ToolEntry[] = [ { tool: telegramGetMeTool, diff --git a/src/agent/tools/telegram/index.ts b/src/agent/tools/telegram/index.ts index 4869518d..027a07ad 100644 --- a/src/agent/tools/telegram/index.ts +++ b/src/agent/tools/telegram/index.ts @@ -15,48 +15,6 @@ import { tools as tasksTools } from "./tasks/index.js"; import { sendButtonsEntry } from "./send-buttons.js"; import type { ToolEntry } from "../types.js"; -// Messaging -export * from "./messaging/index.js"; - -// Media -export * from "./media/index.js"; - -// Chats -export * from "./chats/index.js"; - -// Groups & Members -export * from "./groups/index.js"; - -// Interactive (polls, quizzes, keyboards, reactions) -export * from "./interactive/index.js"; - -// Stickers & GIFs -export * from "./stickers/index.js"; - -// Folders -export * from "./folders/index.js"; - -// Profile -export * from "./profile/index.js"; - -// Stars & Payments -export * from "./stars/index.js"; - -// Gifts & Collectibles -export * from "./gifts/index.js"; - -// Contacts -export * from "./contacts/index.js"; - -// Stories -export * from "./stories/index.js"; - -// Memory (agent self-memory management) -export * from "./memory/index.js"; - -// Tasks (scheduled task management) -export * from "./tasks/index.js"; - export const tools: ToolEntry[] = [ ...messagingTools, ...mediaTools, diff --git a/src/agent/tools/telegram/interactive/index.ts b/src/agent/tools/telegram/interactive/index.ts index 947b9e43..f4ea6266 100644 --- a/src/agent/tools/telegram/interactive/index.ts +++ b/src/agent/tools/telegram/interactive/index.ts @@ -5,12 +5,6 @@ import { telegramReactTool, telegramReactExecutor } from "./react.js"; import { telegramSendDiceTool, telegramSendDiceExecutor } from "./send-dice.js"; import type { ToolEntry } from "../../types.js"; -export { telegramCreatePollTool, telegramCreatePollExecutor }; -export { telegramCreateQuizTool, telegramCreateQuizExecutor }; -export { telegramReplyKeyboardTool, telegramReplyKeyboardExecutor }; -export { telegramReactTool, telegramReactExecutor }; -export { telegramSendDiceTool, telegramSendDiceExecutor }; - export const tools: ToolEntry[] = [ { tool: telegramCreatePollTool, diff --git a/src/agent/tools/telegram/media/index.ts b/src/agent/tools/telegram/media/index.ts index 16c85280..f0be38ba 100644 --- a/src/agent/tools/telegram/media/index.ts +++ b/src/agent/tools/telegram/media/index.ts @@ -10,14 +10,6 @@ import { } from "./transcribe-audio.js"; import type { ToolEntry } from "../../types.js"; -export { telegramSendPhotoTool, telegramSendPhotoExecutor }; -export { telegramSendVoiceTool, telegramSendVoiceExecutor }; -export { telegramSendStickerTool, telegramSendStickerExecutor }; -export { telegramSendGifTool, telegramSendGifExecutor }; -export { telegramDownloadMediaTool, telegramDownloadMediaExecutor }; -export { visionAnalyzeTool, visionAnalyzeExecutor }; -export { telegramTranscribeAudioTool, telegramTranscribeAudioExecutor }; - export const tools: ToolEntry[] = [ { tool: telegramSendPhotoTool, diff --git a/src/agent/tools/telegram/memory/index.ts b/src/agent/tools/telegram/memory/index.ts index 28cb71c4..62928ec4 100644 --- a/src/agent/tools/telegram/memory/index.ts +++ b/src/agent/tools/telegram/memory/index.ts @@ -4,11 +4,6 @@ import { memorySearchTool, memorySearchExecutor } from "./memory-search.js"; import { sessionSearchTool, sessionSearchExecutor } from "./session-search.js"; import type { ToolEntry } from "../../types.js"; -export { memoryWriteTool, memoryWriteExecutor }; -export { memoryReadTool, memoryReadExecutor }; -export { memorySearchTool, memorySearchExecutor }; -export { sessionSearchTool, sessionSearchExecutor }; - export const tools: ToolEntry[] = [ { tool: memoryWriteTool, diff --git a/src/agent/tools/telegram/messaging/index.ts b/src/agent/tools/telegram/messaging/index.ts index bc523298..440888d5 100644 --- a/src/agent/tools/telegram/messaging/index.ts +++ b/src/agent/tools/telegram/messaging/index.ts @@ -30,25 +30,6 @@ import { import { botInlineSendTool, botInlineSendExecutor } from "./inline-send.js"; import type { ToolEntry } from "../../types.js"; -export { telegramSendMessageTool, telegramSendMessageExecutor }; -export { telegramEditMessageTool, telegramEditMessageExecutor }; -export { telegramDeleteMessageTool, telegramDeleteMessageExecutor }; -export { telegramForwardMessageTool, telegramForwardMessageExecutor }; -export { telegramScheduleMessageTool, telegramScheduleMessageExecutor }; -export { telegramSearchMessagesTool, telegramSearchMessagesExecutor }; -export { - telegramPinMessageTool, - telegramPinMessageExecutor, - telegramUnpinMessageTool, - telegramUnpinMessageExecutor, -}; -export { telegramQuoteReplyTool, telegramQuoteReplyExecutor }; -export { telegramGetRepliesTool, telegramGetRepliesExecutor }; -export { telegramGetScheduledMessagesTool, telegramGetScheduledMessagesExecutor }; -export { telegramDeleteScheduledMessageTool, telegramDeleteScheduledMessageExecutor }; -export { telegramSendScheduledNowTool, telegramSendScheduledNowExecutor }; -export { botInlineSendTool, botInlineSendExecutor }; - export const tools: ToolEntry[] = [ { tool: telegramSendMessageTool, diff --git a/src/agent/tools/telegram/profile/index.ts b/src/agent/tools/telegram/profile/index.ts index 15430ba3..3d7cf784 100644 --- a/src/agent/tools/telegram/profile/index.ts +++ b/src/agent/tools/telegram/profile/index.ts @@ -7,11 +7,6 @@ import { } from "./set-personal-channel.js"; import type { ToolEntry } from "../../types.js"; -export { telegramUpdateProfileTool, telegramUpdateProfileExecutor }; -export { telegramSetBioTool, telegramSetBioExecutor }; -export { telegramSetUsernameTool, telegramSetUsernameExecutor }; -export { telegramSetPersonalChannelTool, telegramSetPersonalChannelExecutor }; - export const tools: ToolEntry[] = [ { tool: telegramUpdateProfileTool, diff --git a/src/agent/tools/telegram/stars/index.ts b/src/agent/tools/telegram/stars/index.ts index ef2ec331..b7bb1e42 100644 --- a/src/agent/tools/telegram/stars/index.ts +++ b/src/agent/tools/telegram/stars/index.ts @@ -9,9 +9,6 @@ import { } from "./get-transactions.js"; import type { ToolEntry } from "../../types.js"; -export { telegramGetStarsBalanceTool, telegramGetStarsBalanceExecutor }; -export { telegramGetStarsTransactionsTool, telegramGetStarsTransactionsExecutor }; - export const tools: ToolEntry[] = [ { tool: telegramGetStarsBalanceTool, diff --git a/src/agent/tools/telegram/stickers/index.ts b/src/agent/tools/telegram/stickers/index.ts index 4bde04d4..bcffdb5c 100644 --- a/src/agent/tools/telegram/stickers/index.ts +++ b/src/agent/tools/telegram/stickers/index.ts @@ -4,11 +4,6 @@ import { telegramGetMyStickersTool, telegramGetMyStickersExecutor } from "./get- import { telegramAddStickerSetTool, telegramAddStickerSetExecutor } from "./add-sticker-set.js"; import type { ToolEntry } from "../../types.js"; -export { telegramSearchStickersTool, telegramSearchStickersExecutor }; -export { telegramSearchGifsTool, telegramSearchGifsExecutor }; -export { telegramGetMyStickersTool, telegramGetMyStickersExecutor }; -export { telegramAddStickerSetTool, telegramAddStickerSetExecutor }; - export const tools: ToolEntry[] = [ { tool: telegramSearchStickersTool, diff --git a/src/agent/tools/telegram/stories/index.ts b/src/agent/tools/telegram/stories/index.ts index 0a4f78ea..470f1f85 100644 --- a/src/agent/tools/telegram/stories/index.ts +++ b/src/agent/tools/telegram/stories/index.ts @@ -1,8 +1,6 @@ import { telegramSendStoryTool, telegramSendStoryExecutor } from "./send-story.js"; import type { ToolEntry } from "../../types.js"; -export { telegramSendStoryTool, telegramSendStoryExecutor }; - export const tools: ToolEntry[] = [ { tool: telegramSendStoryTool, diff --git a/src/agent/tools/telegram/tasks/index.ts b/src/agent/tools/telegram/tasks/index.ts index 9d84a1f3..43cc79a5 100644 --- a/src/agent/tools/telegram/tasks/index.ts +++ b/src/agent/tools/telegram/tasks/index.ts @@ -4,8 +4,6 @@ import { } from "./create-scheduled-task.js"; import type { ToolEntry } from "../../types.js"; -export { telegramCreateScheduledTaskTool, telegramCreateScheduledTaskExecutor }; - export const tools: ToolEntry[] = [ { tool: telegramCreateScheduledTaskTool, From ecd6690e79631760902ccd2487652265a23b3030 Mon Sep 17 00:00:00 2001 From: TONresistor <240980241+TONresistor@users.noreply.github.com> Date: Fri, 26 Jun 2026 05:59:04 +0200 Subject: [PATCH 07/59] refactor(tools): remove redundant withToolErrors wrapper registry.execute() already maps thrown errors to {success:false,error} and logs them, so the withToolErrors wrapper was a third error-mapping layer. Unwrap the 10 executors that used it and delete wrap.ts; behavior is unchanged (these executors run only through registry.execute). --- src/agent/tools/journal/log.ts | 111 +++++++++---------- src/agent/tools/journal/query.ts | 166 ++++++++++++++-------------- src/agent/tools/journal/update.ts | 138 ++++++++++++----------- src/agent/tools/web/fetch.ts | 93 ++++++++-------- src/agent/tools/web/search.ts | 57 +++++----- src/agent/tools/workspace/delete.ts | 72 ++++++------ src/agent/tools/workspace/list.ts | 44 ++++---- src/agent/tools/workspace/read.ts | 77 ++++++------- src/agent/tools/workspace/rename.ts | 72 ++++++------ src/agent/tools/workspace/write.ts | 102 +++++++++-------- src/agent/tools/wrap.ts | 17 --- 11 files changed, 453 insertions(+), 496 deletions(-) delete mode 100644 src/agent/tools/wrap.ts diff --git a/src/agent/tools/journal/log.ts b/src/agent/tools/journal/log.ts index fdcf0ee0..46ff1f87 100644 --- a/src/agent/tools/journal/log.ts +++ b/src/agent/tools/journal/log.ts @@ -8,7 +8,6 @@ import { getDatabase } from "../../../memory/database.js"; import { JournalStore } from "../../../memory/journal-store.js"; import type { JournalType, JournalOutcome } from "../../../memory/journal-store.js"; import type { Tool, ToolExecutor } from "../types.js"; -import { withToolErrors } from "../wrap.js"; import { formatAssetFlow, formatTxHash } from "./format.js"; interface JournalLogParams { @@ -77,69 +76,67 @@ export const journalLogTool: Tool = { }), }; -export const journalLogExecutor: ToolExecutor = withToolErrors( - async (params, context) => { - const db = getDatabase().getDb(); - const store = new JournalStore(db); +export const journalLogExecutor: ToolExecutor = async (params, context) => { + const db = getDatabase().getDb(); + const store = new JournalStore(db); - const entry = store.addEntry({ - type: params.type, - action: params.action, - asset_from: params.asset_from, - asset_to: params.asset_to, - amount_from: params.amount_from, - amount_to: params.amount_to, - price_ton: params.price_ton, - counterparty: params.counterparty, - platform: params.platform, - reasoning: params.reasoning, - outcome: params.outcome ?? "pending", - tx_hash: params.tx_hash, - tool_used: "journal_log", - chat_id: context.chatId?.toString(), - user_id: context.senderId, - }); + const entry = store.addEntry({ + type: params.type, + action: params.action, + asset_from: params.asset_from, + asset_to: params.asset_to, + amount_from: params.amount_from, + amount_to: params.amount_to, + price_ton: params.price_ton, + counterparty: params.counterparty, + platform: params.platform, + reasoning: params.reasoning, + outcome: params.outcome ?? "pending", + tx_hash: params.tx_hash, + tool_used: "journal_log", + chat_id: context.chatId?.toString(), + user_id: context.senderId, + }); - // Format output - const lines: string[] = [ - `📝 Journal Entry #${entry.id} logged`, - ``, - `**Type**: ${entry.type}`, - `**Action**: ${entry.action}`, - ]; + // Format output + const lines: string[] = [ + `📝 Journal Entry #${entry.id} logged`, + ``, + `**Type**: ${entry.type}`, + `**Action**: ${entry.action}`, + ]; - const assetFlow = formatAssetFlow(entry); - if (assetFlow) { - lines.push(`**Assets**: ${assetFlow}`); - } + const assetFlow = formatAssetFlow(entry); + if (assetFlow) { + lines.push(`**Assets**: ${assetFlow}`); + } - if (entry.price_ton) { - lines.push(`**Price**: ${entry.price_ton} TON`); - } + if (entry.price_ton) { + lines.push(`**Price**: ${entry.price_ton} TON`); + } - if (entry.counterparty) { - lines.push(`**Counterparty**: ${entry.counterparty}`); - } + if (entry.counterparty) { + lines.push(`**Counterparty**: ${entry.counterparty}`); + } - if (entry.platform) { - lines.push(`**Platform**: ${entry.platform}`); - } + if (entry.platform) { + lines.push(`**Platform**: ${entry.platform}`); + } - lines.push(`**Outcome**: ${entry.outcome}`); - lines.push(`**Reasoning**: ${entry.reasoning}`); + lines.push(`**Outcome**: ${entry.outcome}`); + lines.push(`**Reasoning**: ${entry.reasoning}`); - if (entry.tx_hash) { - lines.push(`**TX**: ${formatTxHash(entry.tx_hash)}`); - } + if (entry.tx_hash) { + lines.push(`**TX**: ${formatTxHash(entry.tx_hash)}`); + } - lines.push(``, `_Logged at ${new Date(entry.created_at * 1000).toISOString()}_`); + lines.push(``, `_Logged at ${new Date(entry.created_at * 1000).toISOString()}_`); - return { - success: true, - data: { - entry, - message: lines.join("\n"), - }, - }; - } -); + return { + success: true, + data: { + entry, + message: lines.join("\n"), + }, + }; +}; diff --git a/src/agent/tools/journal/query.ts b/src/agent/tools/journal/query.ts index 3eb119d9..76cd1f23 100644 --- a/src/agent/tools/journal/query.ts +++ b/src/agent/tools/journal/query.ts @@ -8,7 +8,6 @@ import { getDatabase } from "../../../memory/database.js"; import { JournalStore } from "../../../memory/journal-store.js"; import type { JournalType, JournalOutcome } from "../../../memory/journal-store.js"; import type { Tool, ToolExecutor } from "../types.js"; -import { withToolErrors } from "../wrap.js"; import { outcomeEmoji, formatAssetFlow, formatTxHash } from "./format.js"; interface JournalQueryParams { @@ -60,104 +59,103 @@ export const journalQueryTool: Tool = { }), }; -export const journalQueryExecutor: ToolExecutor = - withToolErrors(async (params) => { - const db = getDatabase().getDb(); - const store = new JournalStore(db); +export const journalQueryExecutor: ToolExecutor = async (params) => { + const db = getDatabase().getDb(); + const store = new JournalStore(db); - const entries = store.queryEntries({ + const entries = store.queryEntries({ + type: params.type, + asset: params.asset, + outcome: params.outcome, + days: params.days, + limit: params.limit ?? 20, + }); + + if (entries.length === 0) { + return { + success: true, + data: { + entries: [], + message: "No entries found matching your filters.", + }, + }; + } + + // Calculate P&L summary if filtering by outcome or type + let summary = ""; + if (params.type || params.days) { + const pnl = store.calculatePnL({ type: params.type, - asset: params.asset, - outcome: params.outcome, days: params.days, - limit: params.limit ?? 20, }); - if (entries.length === 0) { - return { - success: true, - data: { - entries: [], - message: "No entries found matching your filters.", - }, - }; + if (pnl.trades_count > 0) { + summary = [ + `**📊 Performance Summary**`, + ``, + `Trades: ${pnl.trades_count} (${pnl.profit_count} wins, ${pnl.loss_count} losses)`, + `Win Rate: ${pnl.win_rate.toFixed(1)}%`, + `Total P&L: ${pnl.total_pnl >= 0 ? "+" : ""}${pnl.total_pnl.toFixed(2)} TON`, + ``, + `---`, + ``, + ].join("\n"); } + } - // Calculate P&L summary if filtering by outcome or type - let summary = ""; - if (params.type || params.days) { - const pnl = store.calculatePnL({ - type: params.type, - days: params.days, - }); - - if (pnl.trades_count > 0) { - summary = [ - `**📊 Performance Summary**`, - ``, - `Trades: ${pnl.trades_count} (${pnl.profit_count} wins, ${pnl.loss_count} losses)`, - `Win Rate: ${pnl.win_rate.toFixed(1)}%`, - `Total P&L: ${pnl.total_pnl >= 0 ? "+" : ""}${pnl.total_pnl.toFixed(2)} TON`, - ``, - `---`, - ``, - ].join("\n"); - } - } + // Format entries + const lines: string[] = [`📖 Journal Entries (${entries.length} results)`, ``]; - // Format entries - const lines: string[] = [`📖 Journal Entries (${entries.length} results)`, ``]; + if (summary) { + lines.push(summary); + } - if (summary) { - lines.push(summary); - } + for (const entry of entries) { + const date = new Date(entry.timestamp * 1000).toISOString().split("T")[0]; + lines.push( + `**#${entry.id}** ${outcomeEmoji(entry.outcome)} ${entry.type} - ${entry.action} _[${date}]_` + ); - for (const entry of entries) { - const date = new Date(entry.timestamp * 1000).toISOString().split("T")[0]; - lines.push( - `**#${entry.id}** ${outcomeEmoji(entry.outcome)} ${entry.type} - ${entry.action} _[${date}]_` - ); - - const assetFlow = formatAssetFlow(entry); - if (assetFlow) { - lines.push(` ${assetFlow}`); - } - - if (entry.price_ton) { - lines.push(` Price: ${entry.price_ton} TON`); - } + const assetFlow = formatAssetFlow(entry); + if (assetFlow) { + lines.push(` ${assetFlow}`); + } - if (entry.counterparty) { - lines.push(` Party: ${entry.counterparty}`); - } + if (entry.price_ton) { + lines.push(` Price: ${entry.price_ton} TON`); + } - if (entry.platform) { - lines.push(` Platform: ${entry.platform}`); - } + if (entry.counterparty) { + lines.push(` Party: ${entry.counterparty}`); + } - if (entry.pnl_ton !== null && entry.pnl_ton !== undefined) { - const sign = entry.pnl_ton >= 0 ? "+" : ""; - lines.push( - ` P&L: ${sign}${entry.pnl_ton.toFixed(2)} TON (${sign}${entry.pnl_pct?.toFixed(1) ?? "?"}%)` - ); - } + if (entry.platform) { + lines.push(` Platform: ${entry.platform}`); + } - if (entry.reasoning) { - lines.push(` _"${entry.reasoning}"_`); - } + if (entry.pnl_ton !== null && entry.pnl_ton !== undefined) { + const sign = entry.pnl_ton >= 0 ? "+" : ""; + lines.push( + ` P&L: ${sign}${entry.pnl_ton.toFixed(2)} TON (${sign}${entry.pnl_pct?.toFixed(1) ?? "?"}%)` + ); + } - if (entry.tx_hash) { - lines.push(` TX: ${formatTxHash(entry.tx_hash)}`); - } + if (entry.reasoning) { + lines.push(` _"${entry.reasoning}"_`); + } - lines.push(``); + if (entry.tx_hash) { + lines.push(` TX: ${formatTxHash(entry.tx_hash)}`); } - return { - success: true, - data: { - entries, - message: lines.join("\n"), - }, - }; - }); + lines.push(``); + } + + return { + success: true, + data: { + entries, + message: lines.join("\n"), + }, + }; +}; diff --git a/src/agent/tools/journal/update.ts b/src/agent/tools/journal/update.ts index 2d350164..86942325 100644 --- a/src/agent/tools/journal/update.ts +++ b/src/agent/tools/journal/update.ts @@ -8,7 +8,6 @@ import { getDatabase } from "../../../memory/database.js"; import { JournalStore } from "../../../memory/journal-store.js"; import type { JournalOutcome } from "../../../memory/journal-store.js"; import type { Tool, ToolExecutor } from "../types.js"; -import { withToolErrors } from "../wrap.js"; import { outcomeEmoji, formatAssetFlow, formatTxHash } from "./format.js"; interface JournalUpdateParams { @@ -49,76 +48,75 @@ export const journalUpdateTool: Tool = { }), }; -export const journalUpdateExecutor: ToolExecutor = - withToolErrors(async (params) => { - const db = getDatabase().getDb(); - const store = new JournalStore(db); - - // Check if entry exists - const existing = store.getEntryById(params.id); - if (!existing) { - return { - success: false, - error: `Journal entry #${params.id} not found`, - }; - } - - // Auto-set closed_at if outcome is being changed to non-pending - const closed_at = - params.outcome && params.outcome !== "pending" && !existing.closed_at - ? Math.floor(Date.now() / 1000) - : undefined; - - const updated = store.updateEntry({ - id: params.id, - outcome: params.outcome, - pnl_ton: params.pnl_ton, - pnl_pct: params.pnl_pct, - tx_hash: params.tx_hash, - closed_at, - }); - - if (!updated) { - return { - success: false, - error: "Failed to update entry", - }; - } - - // Format output - const lines: string[] = [ - `✏️ Journal Entry #${updated.id} updated`, - ``, - `**Type**: ${updated.type} - ${updated.action}`, - ]; - - const assetFlow = formatAssetFlow(updated); - if (assetFlow) { - lines.push(`**Assets**: ${assetFlow}`); - } - - lines.push(`**Outcome**: ${outcomeEmoji(updated.outcome)} ${updated.outcome}`); - - if (updated.pnl_ton !== null && updated.pnl_ton !== undefined) { - const sign = updated.pnl_ton >= 0 ? "+" : ""; - lines.push( - `**P&L**: ${sign}${updated.pnl_ton.toFixed(2)} TON (${sign}${updated.pnl_pct?.toFixed(1) ?? "?"}%)` - ); - } - - if (updated.tx_hash) { - lines.push(`**TX**: ${formatTxHash(updated.tx_hash)}`); - } - - if (updated.closed_at) { - lines.push(``, `_Closed at ${new Date(updated.closed_at * 1000).toISOString()}_`); - } +export const journalUpdateExecutor: ToolExecutor = async (params) => { + const db = getDatabase().getDb(); + const store = new JournalStore(db); + // Check if entry exists + const existing = store.getEntryById(params.id); + if (!existing) { return { - success: true, - data: { - entry: updated, - message: lines.join("\n"), - }, + success: false, + error: `Journal entry #${params.id} not found`, }; + } + + // Auto-set closed_at if outcome is being changed to non-pending + const closed_at = + params.outcome && params.outcome !== "pending" && !existing.closed_at + ? Math.floor(Date.now() / 1000) + : undefined; + + const updated = store.updateEntry({ + id: params.id, + outcome: params.outcome, + pnl_ton: params.pnl_ton, + pnl_pct: params.pnl_pct, + tx_hash: params.tx_hash, + closed_at, }); + + if (!updated) { + return { + success: false, + error: "Failed to update entry", + }; + } + + // Format output + const lines: string[] = [ + `✏️ Journal Entry #${updated.id} updated`, + ``, + `**Type**: ${updated.type} - ${updated.action}`, + ]; + + const assetFlow = formatAssetFlow(updated); + if (assetFlow) { + lines.push(`**Assets**: ${assetFlow}`); + } + + lines.push(`**Outcome**: ${outcomeEmoji(updated.outcome)} ${updated.outcome}`); + + if (updated.pnl_ton !== null && updated.pnl_ton !== undefined) { + const sign = updated.pnl_ton >= 0 ? "+" : ""; + lines.push( + `**P&L**: ${sign}${updated.pnl_ton.toFixed(2)} TON (${sign}${updated.pnl_pct?.toFixed(1) ?? "?"}%)` + ); + } + + if (updated.tx_hash) { + lines.push(`**TX**: ${formatTxHash(updated.tx_hash)}`); + } + + if (updated.closed_at) { + lines.push(``, `_Closed at ${new Date(updated.closed_at * 1000).toISOString()}_`); + } + + return { + success: true, + data: { + entry: updated, + message: lines.join("\n"), + }, + }; +}; diff --git a/src/agent/tools/web/fetch.ts b/src/agent/tools/web/fetch.ts index ff697b59..89acc1a7 100644 --- a/src/agent/tools/web/fetch.ts +++ b/src/agent/tools/web/fetch.ts @@ -4,7 +4,6 @@ import { Type } from "@sinclair/typebox"; import type { Tool, ToolExecutor } from "../types.js"; import { WEB_FETCH_MAX_TEXT_LENGTH } from "../../../constants/limits.js"; import { sanitizeForContext } from "../../../utils/sanitize.js"; -import { withToolErrors } from "../wrap.js"; import { resolveTavily } from "./tavily.js"; interface WebFetchParams { @@ -28,60 +27,58 @@ export const webFetchTool: Tool = { }), }; -export const webFetchExecutor: ToolExecutor = withToolErrors( - async (params, context) => { - const tav = resolveTavily(context); - if (!tav.ok) return tav.error; +export const webFetchExecutor: ToolExecutor = async (params, context) => { + const tav = resolveTavily(context); + if (!tav.ok) return tav.error; - const { url, max_length = WEB_FETCH_MAX_TEXT_LENGTH } = params; + const { url, max_length = WEB_FETCH_MAX_TEXT_LENGTH } = params; - // Validate URL scheme - let parsed: URL; - try { - parsed = new URL(url); - } catch { - return { success: false, error: "Invalid URL" }; - } + // Validate URL scheme + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return { success: false, error: "Invalid URL" }; + } - if (!ALLOWED_SCHEMES.has(parsed.protocol)) { + if (!ALLOWED_SCHEMES.has(parsed.protocol)) { + return { + success: false, + error: `Blocked URL scheme: ${parsed.protocol} — only http/https allowed`, + }; + } + + const client = tav.client; + const response = await client.extract([url], { + extractDepth: "basic", + }); + + if (!response.results?.length) { + if (response.failedResults?.length) { return { success: false, - error: `Blocked URL scheme: ${parsed.protocol} — only http/https allowed`, + error: `Failed to extract: ${response.failedResults[0].error}`, }; } + return { success: false, error: "No content extracted from URL" }; + } - const client = tav.client; - const response = await client.extract([url], { - extractDepth: "basic", - }); - - if (!response.results?.length) { - if (response.failedResults?.length) { - return { - success: false, - error: `Failed to extract: ${response.failedResults[0].error}`, - }; - } - return { success: false, error: "No content extracted from URL" }; - } - - const result = response.results[0]; - let text = result.rawContent || ""; - - const truncated = text.length > max_length; - if (truncated) { - text = text.slice(0, max_length); - } + const result = response.results[0]; + let text = result.rawContent || ""; - return { - success: true, - data: { - title: sanitizeForContext(result.title || parsed.hostname), - text: sanitizeForContext(text), - url, - length: text.length, - truncated, - }, - }; + const truncated = text.length > max_length; + if (truncated) { + text = text.slice(0, max_length); } -); + + return { + success: true, + data: { + title: sanitizeForContext(result.title || parsed.hostname), + text: sanitizeForContext(text), + url, + length: text.length, + truncated, + }, + }; +}; diff --git a/src/agent/tools/web/search.ts b/src/agent/tools/web/search.ts index 24a9600e..f44f9e93 100644 --- a/src/agent/tools/web/search.ts +++ b/src/agent/tools/web/search.ts @@ -4,7 +4,6 @@ import { Type } from "@sinclair/typebox"; import type { Tool, ToolExecutor } from "../types.js"; import { WEB_SEARCH_MAX_RESULTS } from "../../../constants/limits.js"; import { sanitizeForContext } from "../../../utils/sanitize.js"; -import { withToolErrors } from "../wrap.js"; import { resolveTavily } from "./tavily.js"; interface WebSearchParams { @@ -33,36 +32,34 @@ export const webSearchTool: Tool = { }), }; -export const webSearchExecutor: ToolExecutor = withToolErrors( - async (params, context) => { - const tav = resolveTavily(context); - if (!tav.ok) return tav.error; +export const webSearchExecutor: ToolExecutor = async (params, context) => { + const tav = resolveTavily(context); + if (!tav.ok) return tav.error; - const { query, count = 5, topic = "general" } = params; - const maxResults = Math.min(Math.max(1, count), WEB_SEARCH_MAX_RESULTS); + const { query, count = 5, topic = "general" } = params; + const maxResults = Math.min(Math.max(1, count), WEB_SEARCH_MAX_RESULTS); - const client = tav.client; - const response = await client.search(query, { - maxResults, - topic, - searchDepth: "basic", - includeAnswer: true, - }); + const client = tav.client; + const response = await client.search(query, { + maxResults, + topic, + searchDepth: "basic", + includeAnswer: true, + }); - const results = response.results.map((r) => ({ - title: sanitizeForContext(r.title), - url: r.url, - content: sanitizeForContext(r.content), - score: r.score, - })); + const results = response.results.map((r) => ({ + title: sanitizeForContext(r.title), + url: r.url, + content: sanitizeForContext(r.content), + score: r.score, + })); - return { - success: true, - data: { - query, - answer: response.answer || undefined, - results, - }, - }; - } -); + return { + success: true, + data: { + query, + answer: response.answer || undefined, + results, + }, + }; +}; diff --git a/src/agent/tools/workspace/delete.ts b/src/agent/tools/workspace/delete.ts index 1b27b11c..7eb0d11a 100644 --- a/src/agent/tools/workspace/delete.ts +++ b/src/agent/tools/workspace/delete.ts @@ -4,7 +4,6 @@ import { Type } from "@sinclair/typebox"; import { unlinkSync, rmdirSync, readdirSync, rmSync } from "fs"; import type { Tool, ToolExecutor } from "../types.js"; import { validatePath, PROTECTED_WORKSPACE_FILES } from "../../../workspace/index.js"; -import { withToolErrors } from "../wrap.js"; interface WorkspaceDeleteParams { path: string; @@ -28,49 +27,48 @@ export const workspaceDeleteTool: Tool = { }), }; -export const workspaceDeleteExecutor: ToolExecutor = - withToolErrors(async (params) => { - const { path, recursive = false } = params; +export const workspaceDeleteExecutor: ToolExecutor = async (params) => { + const { path, recursive = false } = params; - // Validate the path - const validated = validatePath(path, false); + // Validate the path + const validated = validatePath(path, false); - // Check if it's a protected file - if (PROTECTED_WORKSPACE_FILES.includes(validated.filename)) { + // Check if it's a protected file + if (PROTECTED_WORKSPACE_FILES.includes(validated.filename)) { + return { + success: false, + error: + `Cannot delete protected file: ${validated.filename}. ` + + `This file is essential for the agent's operation.`, + }; + } + + if (validated.isDirectory) { + const contents = readdirSync(validated.absolutePath); + + if (contents.length > 0 && !recursive) { return { success: false, - error: - `Cannot delete protected file: ${validated.filename}. ` + - `This file is essential for the agent's operation.`, + error: `Directory is not empty. Use recursive=true to delete non-empty directories.`, }; } - if (validated.isDirectory) { - const contents = readdirSync(validated.absolutePath); - - if (contents.length > 0 && !recursive) { - return { - success: false, - error: `Directory is not empty. Use recursive=true to delete non-empty directories.`, - }; - } - - if (recursive) { - // Recursive delete - rmSync(validated.absolutePath, { recursive: true, force: true }); - } else { - rmdirSync(validated.absolutePath); - } + if (recursive) { + // Recursive delete + rmSync(validated.absolutePath, { recursive: true, force: true }); } else { - unlinkSync(validated.absolutePath); + rmdirSync(validated.absolutePath); } + } else { + unlinkSync(validated.absolutePath); + } - return { - success: true, - data: { - path: validated.relativePath, - type: validated.isDirectory ? "directory" : "file", - message: `Successfully deleted ${validated.isDirectory ? "directory" : "file"}`, - }, - }; - }); + return { + success: true, + data: { + path: validated.relativePath, + type: validated.isDirectory ? "directory" : "file", + message: `Successfully deleted ${validated.isDirectory ? "directory" : "file"}`, + }, + }; +}; diff --git a/src/agent/tools/workspace/list.ts b/src/agent/tools/workspace/list.ts index 3ed31984..a50db0ae 100644 --- a/src/agent/tools/workspace/list.ts +++ b/src/agent/tools/workspace/list.ts @@ -5,7 +5,6 @@ import { readdirSync, lstatSync } from "fs"; import { join } from "path"; import type { Tool, ToolExecutor } from "../types.js"; import { validateDirectory, WORKSPACE_ROOT } from "../../../workspace/index.js"; -import { withToolErrors } from "../wrap.js"; interface WorkspaceListParams { path?: string; @@ -83,33 +82,32 @@ function listDir(dirPath: string, recursive: boolean, filter: string): FileInfo[ return results; } -export const workspaceListExecutor: ToolExecutor = - withToolErrors(async (params) => { - const { path = "", recursive = false, filter = "all" } = params; - - // Validate the path - const validated = validateDirectory(path || WORKSPACE_ROOT); - - if (!validated.exists) { - return { - success: true, - data: { - path: validated.relativePath || "/", - files: [], - message: "Directory does not exist", - }, - }; - } +export const workspaceListExecutor: ToolExecutor = async (params) => { + const { path = "", recursive = false, filter = "all" } = params; - const files = listDir(validated.absolutePath, recursive, filter); + // Validate the path + const validated = validateDirectory(path || WORKSPACE_ROOT); + if (!validated.exists) { return { success: true, data: { path: validated.relativePath || "/", - files, - count: files.length, - workspaceRoot: WORKSPACE_ROOT, + files: [], + message: "Directory does not exist", }, }; - }); + } + + const files = listDir(validated.absolutePath, recursive, filter); + + return { + success: true, + data: { + path: validated.relativePath || "/", + files, + count: files.length, + workspaceRoot: WORKSPACE_ROOT, + }, + }; +}; diff --git a/src/agent/tools/workspace/read.ts b/src/agent/tools/workspace/read.ts index 9a7534b8..d7839bfe 100644 --- a/src/agent/tools/workspace/read.ts +++ b/src/agent/tools/workspace/read.ts @@ -4,7 +4,6 @@ import { Type } from "@sinclair/typebox"; import { readFileSync, lstatSync } from "fs"; import type { Tool, ToolExecutor } from "../types.js"; import { validateReadPath, TEXT_FILE_EXTENSIONS } from "../../../workspace/index.js"; -import { withToolErrors } from "../wrap.js"; interface WorkspaceReadParams { path: string; @@ -35,56 +34,52 @@ export const workspaceReadTool: Tool = { }), }; -export const workspaceReadExecutor: ToolExecutor = - withToolErrors(async (params) => { - const { path, encoding = "utf-8", maxSize = 1024 * 1024 } = params; +export const workspaceReadExecutor: ToolExecutor = async (params) => { + const { path, encoding = "utf-8", maxSize = 1024 * 1024 } = params; - // Validate the path - const validated = validateReadPath(path); + // Validate the path + const validated = validateReadPath(path); - // Check file size - const stats = lstatSync(validated.absolutePath); + // Check file size + const stats = lstatSync(validated.absolutePath); - if (stats.size > maxSize) { - return { - success: false, - error: `File too large: ${stats.size} bytes exceeds limit of ${maxSize} bytes`, - }; - } - - // Check if it's a text file or binary - const isTextFile = TEXT_FILE_EXTENSIONS.includes(validated.extension); - - if (!isTextFile && encoding === "utf-8") { - // Return metadata only for binary files - return { - success: true, - data: { - path: validated.relativePath, - type: "binary", - extension: validated.extension, - size: stats.size, - modified: stats.mtime.toISOString(), - message: - "Binary file - use encoding='base64' to read content, or this is media that can be sent directly", - }, - }; - } + if (stats.size > maxSize) { + return { + success: false, + error: `File too large: ${stats.size} bytes exceeds limit of ${maxSize} bytes`, + }; + } - // Read the file - const content = readFileSync( - validated.absolutePath, - encoding === "base64" ? "base64" : "utf-8" - ); + // Check if it's a text file or binary + const isTextFile = TEXT_FILE_EXTENSIONS.includes(validated.extension); + if (!isTextFile && encoding === "utf-8") { + // Return metadata only for binary files return { success: true, data: { path: validated.relativePath, - content, - encoding, + type: "binary", + extension: validated.extension, size: stats.size, modified: stats.mtime.toISOString(), + message: + "Binary file - use encoding='base64' to read content, or this is media that can be sent directly", }, }; - }); + } + + // Read the file + const content = readFileSync(validated.absolutePath, encoding === "base64" ? "base64" : "utf-8"); + + return { + success: true, + data: { + path: validated.relativePath, + content, + encoding, + size: stats.size, + modified: stats.mtime.toISOString(), + }, + }; +}; diff --git a/src/agent/tools/workspace/rename.ts b/src/agent/tools/workspace/rename.ts index 3c5b8a17..85b46d1a 100644 --- a/src/agent/tools/workspace/rename.ts +++ b/src/agent/tools/workspace/rename.ts @@ -6,7 +6,6 @@ import { dirname } from "path"; import { mkdirSync } from "fs"; import type { Tool, ToolExecutor } from "../types.js"; import { validatePath } from "../../../workspace/index.js"; -import { withToolErrors } from "../wrap.js"; interface WorkspaceRenameParams { from: string; @@ -34,46 +33,45 @@ export const workspaceRenameTool: Tool = { }), }; -export const workspaceRenameExecutor: ToolExecutor = - withToolErrors(async (params) => { - const { from, to, overwrite = false } = params; +export const workspaceRenameExecutor: ToolExecutor = async (params) => { + const { from, to, overwrite = false } = params; - // Validate source path (must exist) - const validatedFrom = validatePath(from, false); + // Validate source path (must exist) + const validatedFrom = validatePath(from, false); - if (validatedFrom.isDirectory) { - return { - success: false, - error: "Cannot rename directories. Use this tool for files only.", - }; - } + if (validatedFrom.isDirectory) { + return { + success: false, + error: "Cannot rename directories. Use this tool for files only.", + }; + } - // Validate destination path (may not exist yet) - const validatedTo = validatePath(to, true); + // Validate destination path (may not exist yet) + const validatedTo = validatePath(to, true); - // Check if destination already exists - if (validatedTo.exists && !overwrite) { - return { - success: false, - error: `Destination already exists: '${to}'. Use overwrite=true to replace.`, - }; - } + // Check if destination already exists + if (validatedTo.exists && !overwrite) { + return { + success: false, + error: `Destination already exists: '${to}'. Use overwrite=true to replace.`, + }; + } - // Create parent directory if needed - const parentDir = dirname(validatedTo.absolutePath); - if (!existsSync(parentDir)) { - mkdirSync(parentDir, { recursive: true }); - } + // Create parent directory if needed + const parentDir = dirname(validatedTo.absolutePath); + if (!existsSync(parentDir)) { + mkdirSync(parentDir, { recursive: true }); + } - // Perform the rename/move - renameSync(validatedFrom.absolutePath, validatedTo.absolutePath); + // Perform the rename/move + renameSync(validatedFrom.absolutePath, validatedTo.absolutePath); - return { - success: true, - data: { - from: validatedFrom.relativePath, - to: validatedTo.relativePath, - message: `File renamed successfully`, - }, - }; - }); + return { + success: true, + data: { + from: validatedFrom.relativePath, + to: validatedTo.relativePath, + message: `File renamed successfully`, + }, + }; +}; diff --git a/src/agent/tools/workspace/write.ts b/src/agent/tools/workspace/write.ts index 65abc43f..b24cc086 100644 --- a/src/agent/tools/workspace/write.ts +++ b/src/agent/tools/workspace/write.ts @@ -6,7 +6,6 @@ import { dirname } from "path"; import { MAX_WRITE_SIZE } from "../../../constants/limits.js"; import type { Tool, ToolExecutor } from "../types.js"; import { validateWritePath, MEMORY_SCAN_FILES } from "../../../workspace/index.js"; -import { withToolErrors } from "../wrap.js"; import { scanMemoryContent } from "../../../utils/memory-guard.js"; interface WorkspaceWriteParams { @@ -48,65 +47,64 @@ export const workspaceWriteTool: Tool = { }), }; -export const workspaceWriteExecutor: ToolExecutor = - withToolErrors(async (params) => { - const { path, content, encoding = "utf-8", append = false, createDirs = true } = params; +export const workspaceWriteExecutor: ToolExecutor = async (params) => { + const { path, content, encoding = "utf-8", append = false, createDirs = true } = params; - // Validate the path (no extension enforcement - fix from audit) - const validated = validateWritePath(path); + // Validate the path (no extension enforcement - fix from audit) + const validated = validateWritePath(path); - // Create parent directories if needed - const parentDir = dirname(validated.absolutePath); - if (createDirs && !existsSync(parentDir)) { - mkdirSync(parentDir, { recursive: true }); - } - - // SECURITY: Scan memory-sensitive files (and anything under memory/) for injection - const isMemoryFile = - MEMORY_SCAN_FILES.includes(validated.relativePath) || - validated.relativePath.startsWith("memory/"); - if (isMemoryFile && encoding !== "base64") { - const scan = scanMemoryContent(content); - if (!scan.safe) { - return { - success: false, - error: `Write blocked: suspicious content detected in ${validated.relativePath} (${scan.threats.join(", ")}).`, - }; - } - } - - // Prepare content - let writeContent: string | Buffer; - if (encoding === "base64") { - writeContent = Buffer.from(content, "base64"); - } else { - writeContent = content; - } + // Create parent directories if needed + const parentDir = dirname(validated.absolutePath); + if (createDirs && !existsSync(parentDir)) { + mkdirSync(parentDir, { recursive: true }); + } - // SECURITY: Enforce file size limits to prevent DoS attacks - const contentSize = Buffer.byteLength(writeContent); - if (contentSize > MAX_WRITE_SIZE) { + // SECURITY: Scan memory-sensitive files (and anything under memory/) for injection + const isMemoryFile = + MEMORY_SCAN_FILES.includes(validated.relativePath) || + validated.relativePath.startsWith("memory/"); + if (isMemoryFile && encoding !== "base64") { + const scan = scanMemoryContent(content); + if (!scan.safe) { return { success: false, - error: `File too large: ${contentSize} bytes exceeds maximum write size of ${MAX_WRITE_SIZE} bytes (50 MB)`, + error: `Write blocked: suspicious content detected in ${validated.relativePath} (${scan.threats.join(", ")}).`, }; } + } - // Write or append - if (append && validated.exists) { - appendFileSync(validated.absolutePath, writeContent, { mode: 0o600 }); - } else { - writeFileSync(validated.absolutePath, writeContent, { mode: 0o600 }); - } + // Prepare content + let writeContent: string | Buffer; + if (encoding === "base64") { + writeContent = Buffer.from(content, "base64"); + } else { + writeContent = content; + } + // SECURITY: Enforce file size limits to prevent DoS attacks + const contentSize = Buffer.byteLength(writeContent); + if (contentSize > MAX_WRITE_SIZE) { return { - success: true, - data: { - path: validated.relativePath, - absolutePath: validated.absolutePath, - size: Buffer.byteLength(writeContent), - append, - message: `File ${append ? "appended" : "written"} successfully`, - }, + success: false, + error: `File too large: ${contentSize} bytes exceeds maximum write size of ${MAX_WRITE_SIZE} bytes (50 MB)`, }; - }); + } + + // Write or append + if (append && validated.exists) { + appendFileSync(validated.absolutePath, writeContent, { mode: 0o600 }); + } else { + writeFileSync(validated.absolutePath, writeContent, { mode: 0o600 }); + } + + return { + success: true, + data: { + path: validated.relativePath, + absolutePath: validated.absolutePath, + size: Buffer.byteLength(writeContent), + append, + message: `File ${append ? "appended" : "written"} successfully`, + }, + }; +}; diff --git a/src/agent/tools/wrap.ts b/src/agent/tools/wrap.ts deleted file mode 100644 index 78b3bc69..00000000 --- a/src/agent/tools/wrap.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { ToolExecutor } from "./types.js"; -import { getErrorMessage } from "../../utils/errors.js"; - -/** - * Wrap a tool executor so any thrown error becomes the standard failed ToolResult - * rather than escaping the ToolResult contract. Single source for the generic - * try/catch → { success: false, error } mapping repeated across executors. - */ -export function withToolErrors

(fn: ToolExecutor

): ToolExecutor

{ - return async (params, context) => { - try { - return await fn(params, context); - } catch (error) { - return { success: false, error: getErrorMessage(error) }; - } - }; -} From 442d26c1f7ed5996b2109c1a6ed7bad475439e35 Mon Sep 17 00:00:00 2001 From: TONresistor <240980241+TONresistor@users.noreply.github.com> Date: Fri, 26 Jun 2026 06:05:06 +0200 Subject: [PATCH 08/59] refactor(sdk): dedupe jetton transfer and node-cache invalidation - extract prepareJettonTransfer() shared by sendJetton (broadcast) and createJettonTransfer (offline sign), which diverged only on delivery (~120 LOC) - extract invalidateOnTransientHttpError() for the 429/5xx node-cache drop repeated in 4 send/get paths - drop the 85-line dead type re-export barrel in sdk/index.ts; the one consumer (plugin-loader) imports PluginSDK directly from @teleton-agent/sdk Behavior unchanged; full SDK suite (491 tests) green. Public DexSDK pass-through methods kept (declared API surface for plugins). --- src/agent/tools/plugin-loader.ts | 2 +- src/sdk/index.ts | 88 --------- src/sdk/ton.ts | 296 +++++++++++++------------------ 3 files changed, 123 insertions(+), 263 deletions(-) diff --git a/src/agent/tools/plugin-loader.ts b/src/agent/tools/plugin-loader.ts index ab7f0593..2fd9bdaa 100644 --- a/src/agent/tools/plugin-loader.ts +++ b/src/agent/tools/plugin-loader.ts @@ -38,7 +38,7 @@ import { semverSatisfies, type SDKDependencies, } from "../../sdk/index.js"; -import type { PluginSDK } from "../../sdk/index.js"; +import type { PluginSDK } from "@teleton-agent/sdk"; import { HookRegistry } from "../../sdk/hooks/registry.js"; import { createSecretsSDK } from "../../sdk/secrets.js"; import type { diff --git a/src/sdk/index.ts b/src/sdk/index.ts index e8c3d360..fa901cee 100644 --- a/src/sdk/index.ts +++ b/src/sdk/index.ts @@ -22,94 +22,6 @@ import { createLogger as pinoCreateLogger } from "../utils/logger.js"; const sdkLog = pinoCreateLogger("SDK"); -// Re-export everything from @teleton-agent/sdk for internal consumers -export type { - PluginSDK, - TonSDK, - TelegramSDK, - SecretsSDK, - SecretDeclaration, - StorageSDK, - PluginLogger, - TonBalance, - TonPrice, - TonSendResult, - TonTransaction, - TransactionType, - JettonBalance, - JettonInfo, - JettonSendResult, - NftItem, - JettonPrice, - JettonHolder, - JettonHistory, - DexSDK, - DexQuoteParams, - DexQuoteResult, - DexSingleQuote, - DexSwapParams, - DexSwapResult, - DnsSDK, - DnsCheckResult, - DnsAuction, - DnsAuctionResult, - DnsBidResult, - DnsResolveResult, - SDKVerifyPaymentParams, - SDKPaymentVerification, - DiceResult, - TelegramUser, - SimpleMessage, - SendMessageOptions, - EditMessageOptions, - ChatInfo, - UserInfo, - ResolvedPeer, - MediaSendOptions, - PollOptions, - StarGift, - ReceivedGift, - StartContext, - Dialog, - StarsTransaction, - TransferResult, - CollectibleInfo, - UniqueGift, - GiftValue, - GiftOfferOptions, - SimpleToolDef, - PluginManifest, - PluginToolContext, - ToolResult, - ToolScope, - ToolCategory, - ButtonStyle, - ButtonDef, - InlineResultContent, - InlineResult, - InlineQueryContext, - CallbackContext, - ChosenResultContext, - BotManifest, - BotKeyboard, - BotSDK, - HookName, - HookHandlerMap, - BeforeToolCallEvent, - AfterToolCallEvent, - BeforePromptBuildEvent, - SessionStartEvent, - SessionEndEvent, - MessageReceiveEvent, - ResponseBeforeEvent, - ResponseAfterEvent, - ResponseErrorEvent, - ToolErrorEvent, - PromptAfterEvent, - AgentStartEvent, - AgentStopEvent, -} from "@teleton-agent/sdk"; - export { PluginSDKError, type SDKErrorCode, SDK_VERSION } from "@teleton-agent/sdk"; export interface SDKDependencies { diff --git a/src/sdk/ton.ts b/src/sdk/ton.ts index afa9f34f..94da788b 100644 --- a/src/sdk/ton.ts +++ b/src/sdk/ton.ts @@ -54,6 +54,18 @@ import { withTxLock } from "../ton/tx-lock.js"; import { formatTransactions } from "../ton/format-transactions.js"; import { isHttpError } from "../utils/errors.js"; +/** + * Drop the cached TON node when an error is a 429 or 5xx, so the next attempt + * picks a fresh node. No-op for non-HTTP errors. Shared by every send/get path. + */ +function invalidateOnTransientHttpError(error: unknown): void { + const httpErr = isHttpError(error) ? error : undefined; + const status = httpErr?.status || httpErr?.response?.status; + if (status === 429 || (status !== undefined && status >= 500)) { + invalidateTonClientCache(); + } +} + /** Format a raw BigInt token balance to a human-readable string. */ function formatTokenBalance(rawBalance: bigint, decimals: number): string { const divisor = BigInt(10) ** BigInt(decimals); @@ -220,16 +232,111 @@ async function signAndSend( } return { hash: sent.hash, seqno: sent.seqno }; } catch (error) { - const httpErr = isHttpError(error) ? error : undefined; - const status = httpErr?.status || httpErr?.response?.status; - if (status === 429 || (status !== undefined && status >= 500)) { - invalidateTonClientCache(); - } + invalidateOnTransientHttpError(error); if (error instanceof PluginSDKError) throw error; throw new PluginSDKError(`${opts.errorPrefix}: ${getErrorMessage(error)}`, "OPERATION_FAILED"); } } +/** + * Shared TEP-74 jetton-transfer preparation: validate inputs, resolve the + * sender's jetton wallet, check the balance, and build the transfer message + * body and signing key. The broadcast path (sendJetton) and the offline-sign + * path (createJettonTransfer) share everything up to here and diverge only on + * delivery. + */ +async function prepareJettonTransfer( + jettonAddress: string, + to: string, + amount: number, + opts?: { comment?: string } +): Promise<{ + walletData: NonNullable>; + senderJettonWallet: string; + messageBody: TonCell; + keyPair: WalletKeyPair; +}> { + const walletData = loadWallet(); + if (!walletData) { + throw new PluginSDKError("Wallet not initialized", "WALLET_NOT_INITIALIZED"); + } + + if (!Number.isFinite(amount) || amount <= 0) { + throw new PluginSDKError("Amount must be a positive number", "OPERATION_FAILED"); + } + + try { + TonAddress.parse(to); + } catch { + throw new PluginSDKError("Invalid recipient address", "INVALID_ADDRESS"); + } + + // Get sender's jetton wallet from balances + const jettonsResponse = await tonapiFetch( + `/accounts/${encodeURIComponent(walletData.address)}/jettons` + ); + if (!jettonsResponse.ok) { + throw new PluginSDKError( + `Failed to fetch jetton balances: ${jettonsResponse.status}`, + "OPERATION_FAILED" + ); + } + + const jettonsData = await jettonsResponse.json(); + const jettonBalance = findJettonBalance(jettonsData.balances ?? [], jettonAddress); + + if (!jettonBalance) { + throw new PluginSDKError( + `You don't own any of this jetton: ${jettonAddress}`, + "OPERATION_FAILED" + ); + } + + const senderJettonWallet = (jettonBalance.wallet_address ?? { address: "" }).address; + const decimals = jettonBalance.jetton.decimals ?? 9; + const currentBalance = BigInt(jettonBalance.balance); + const amountStr = amount.toFixed(decimals); + const [whole, frac = ""] = amountStr.split("."); + const amountInUnits = BigInt(whole + (frac + "0".repeat(decimals)).slice(0, decimals)); + + if (amountInUnits > currentBalance) { + const balStr = formatTokenBalance(currentBalance, decimals); + throw new PluginSDKError( + `Insufficient balance. Have ${balStr}, need ${amount}`, + "OPERATION_FAILED" + ); + } + + const comment = opts?.comment; + + // Build forward payload (comment) + let forwardPayload = beginCell().endCell(); + if (comment) { + forwardPayload = beginCell().storeUint(0, 32).storeStringTail(comment).endCell(); + } + + // TEP-74 transfer message body + const JETTON_TRANSFER_OP = 0xf8a7ea5; + const messageBody = beginCell() + .storeUint(JETTON_TRANSFER_OP, 32) + .storeUint(0, 64) // query_id + .storeCoins(amountInUnits) + .storeAddress(TonAddress.parse(to)) + .storeAddress(TonAddress.parse(walletData.address)) // response_destination + .storeBit(false) // no custom_payload + .storeCoins(comment ? tonToNano("0.01") : BigInt(1)) // forward_ton_amount + .storeBit(comment ? 1 : 0) + .storeRef(comment ? forwardPayload : beginCell().endCell()) + .endCell(); + + const keyPair = await getKeyPair(); + if (!keyPair) { + throw new PluginSDKError("Wallet key derivation failed", "OPERATION_FAILED"); + } + + return { walletData, senderJettonWallet, messageBody, keyPair }; +} + function cleanupOldTransactions( db: Database.Database, retentionDays: number, @@ -503,84 +610,13 @@ export function createTonSDK(log: PluginLogger, db: Database.Database | null): T amount: number, opts?: { comment?: string } ): Promise { - const walletData = loadWallet(); - if (!walletData) { - throw new PluginSDKError("Wallet not initialized", "WALLET_NOT_INITIALIZED"); - } - - if (!Number.isFinite(amount) || amount <= 0) { - throw new PluginSDKError("Amount must be a positive number", "OPERATION_FAILED"); - } - try { - TonAddress.parse(to); - } catch { - throw new PluginSDKError("Invalid recipient address", "INVALID_ADDRESS"); - } - - try { - // Get sender's jetton wallet from balances - const jettonsResponse = await tonapiFetch( - `/accounts/${encodeURIComponent(walletData.address)}/jettons` + const { senderJettonWallet, messageBody, keyPair } = await prepareJettonTransfer( + jettonAddress, + to, + amount, + opts ); - if (!jettonsResponse.ok) { - throw new PluginSDKError( - `Failed to fetch jetton balances: ${jettonsResponse.status}`, - "OPERATION_FAILED" - ); - } - - const jettonsData = await jettonsResponse.json(); - const jettonBalance = findJettonBalance(jettonsData.balances ?? [], jettonAddress); - - if (!jettonBalance) { - throw new PluginSDKError( - `You don't own any of this jetton: ${jettonAddress}`, - "OPERATION_FAILED" - ); - } - - const senderJettonWallet = (jettonBalance.wallet_address ?? { address: "" }).address; - const decimals = jettonBalance.jetton.decimals ?? 9; - const currentBalance = BigInt(jettonBalance.balance); - const amountStr = amount.toFixed(decimals); - const [whole, frac = ""] = amountStr.split("."); - const amountInUnits = BigInt(whole + (frac + "0".repeat(decimals)).slice(0, decimals)); - - if (amountInUnits > currentBalance) { - const balStr = formatTokenBalance(currentBalance, decimals); - throw new PluginSDKError( - `Insufficient balance. Have ${balStr}, need ${amount}`, - "OPERATION_FAILED" - ); - } - - const comment = opts?.comment; - - // Build forward payload (comment) - let forwardPayload = beginCell().endCell(); - if (comment) { - forwardPayload = beginCell().storeUint(0, 32).storeStringTail(comment).endCell(); - } - - // TEP-74 transfer message body - const JETTON_TRANSFER_OP = 0xf8a7ea5; - const messageBody = beginCell() - .storeUint(JETTON_TRANSFER_OP, 32) - .storeUint(0, 64) // query_id - .storeCoins(amountInUnits) - .storeAddress(TonAddress.parse(to)) - .storeAddress(TonAddress.parse(walletData.address)) // response_destination - .storeBit(false) // no custom_payload - .storeCoins(comment ? tonToNano("0.01") : BigInt(1)) // forward_ton_amount - .storeBit(comment ? 1 : 0) - .storeRef(comment ? forwardPayload : beginCell().endCell()) - .endCell(); - - const keyPair = await getKeyPair(); - if (!keyPair) { - throw new PluginSDKError("Wallet key derivation failed", "OPERATION_FAILED"); - } const client = await getCachedTonClient(); const sent = await withTxLock(async () => { @@ -607,12 +643,7 @@ export function createTonSDK(log: PluginLogger, db: Database.Database | null): T return { success: true, seqno: sent.seqno, txRef: sent.hash }; } catch (error) { - // Invalidate node cache on 429/5xx so next attempt picks a fresh node - const outerHttpErr = isHttpError(error) ? error : undefined; - const status = outerHttpErr?.status || outerHttpErr?.response?.status; - if (status === 429 || (status && status >= 500)) { - invalidateTonClientCache(); - } + invalidateOnTransientHttpError(error); if (error instanceof PluginSDKError) throw error; throw new PluginSDKError( `Failed to send jetton: ${getErrorMessage(error)}`, @@ -717,84 +748,9 @@ export function createTonSDK(log: PluginLogger, db: Database.Database | null): T amount: number, opts?: { comment?: string } ): Promise { - const walletData = loadWallet(); - if (!walletData) { - throw new PluginSDKError("Wallet not initialized", "WALLET_NOT_INITIALIZED"); - } - - if (!Number.isFinite(amount) || amount <= 0) { - throw new PluginSDKError("Amount must be a positive number", "OPERATION_FAILED"); - } - try { - TonAddress.parse(to); - } catch { - throw new PluginSDKError("Invalid recipient address", "INVALID_ADDRESS"); - } - - try { - // Get sender's jetton wallet from balances - const jettonsResponse = await tonapiFetch( - `/accounts/${encodeURIComponent(walletData.address)}/jettons` - ); - if (!jettonsResponse.ok) { - throw new PluginSDKError( - `Failed to fetch jetton balances: ${jettonsResponse.status}`, - "OPERATION_FAILED" - ); - } - - const jettonsData = await jettonsResponse.json(); - const jettonBalance = findJettonBalance(jettonsData.balances ?? [], jettonAddress); - - if (!jettonBalance) { - throw new PluginSDKError( - `You don't own any of this jetton: ${jettonAddress}`, - "OPERATION_FAILED" - ); - } - - const senderJettonWallet = (jettonBalance.wallet_address ?? { address: "" }).address; - const decimals = jettonBalance.jetton.decimals ?? 9; - const currentBalance = BigInt(jettonBalance.balance); - const amountStr = amount.toFixed(decimals); - const [whole, frac = ""] = amountStr.split("."); - const amountInUnits = BigInt(whole + (frac + "0".repeat(decimals)).slice(0, decimals)); - - if (amountInUnits > currentBalance) { - const balStr = formatTokenBalance(currentBalance, decimals); - throw new PluginSDKError( - `Insufficient balance. Have ${balStr}, need ${amount}`, - "OPERATION_FAILED" - ); - } - - const comment = opts?.comment; - - // Build forward payload (comment) - let forwardPayload = beginCell().endCell(); - if (comment) { - forwardPayload = beginCell().storeUint(0, 32).storeStringTail(comment).endCell(); - } - - // TEP-74 transfer message body - const JETTON_TRANSFER_OP = 0xf8a7ea5; - const messageBody = beginCell() - .storeUint(JETTON_TRANSFER_OP, 32) - .storeUint(0, 64) // query_id - .storeCoins(amountInUnits) - .storeAddress(TonAddress.parse(to)) - .storeAddress(TonAddress.parse(walletData.address)) // response_destination - .storeBit(false) // no custom_payload - .storeCoins(comment ? tonToNano("0.01") : BigInt(1)) // forward_ton_amount - .storeBit(comment ? 1 : 0) - .storeRef(comment ? forwardPayload : beginCell().endCell()) - .endCell(); - - const keyPair = await getKeyPair(); - if (!keyPair) { - throw new PluginSDKError("Wallet key derivation failed", "OPERATION_FAILED"); - } + const { walletData, senderJettonWallet, messageBody, keyPair } = + await prepareJettonTransfer(jettonAddress, to, amount, opts); const txResult = await withTxLock(async () => { const { wallet, seqno } = await buildWalletContext(keyPair); @@ -1088,11 +1044,7 @@ export function createTonSDK(log: PluginLogger, db: Database.Database | null): T const { seqno } = await buildWalletContext(keyPair); return seqno; } catch (error) { - const httpErr = isHttpError(error) ? error : undefined; - const status = httpErr?.status || httpErr?.response?.status; - if (status === 429 || (status !== undefined && status >= 500)) { - invalidateTonClientCache(); - } + invalidateOnTransientHttpError(error); if (error instanceof PluginSDKError) throw error; throw new PluginSDKError( `Failed to get seqno: ${getErrorMessage(error)}`, @@ -1128,11 +1080,7 @@ export function createTonSDK(log: PluginLogger, db: Database.Database | null): T return { exitCode: result.exit_code, stack: items }; } catch (error) { - const httpErr = isHttpError(error) ? error : undefined; - const status = httpErr?.status || httpErr?.response?.status; - if (status === 429 || (status !== undefined && status >= 500)) { - invalidateTonClientCache(); - } + invalidateOnTransientHttpError(error); if (error instanceof PluginSDKError) throw error; throw new PluginSDKError( `Failed to run get method: ${getErrorMessage(error)}`, From 6bc287f0767cd026d7722c05d5cdf4d00a71609a Mon Sep 17 00:00:00 2001 From: TONresistor <240980241+TONresistor@users.noreply.github.com> Date: Fri, 26 Jun 2026 06:36:32 +0200 Subject: [PATCH 09/59] fix(tools): enforce dm-only/group-only channel scope The dm-only/group-only tool scopes were collapsed to access-level 'all' and had no runtime effect, so finance tools marked dm-only (send gift, transfer collectible) were reachable in groups (group_policy defaults to 'open'). checkAccess now gates on the tool's declared channel scope, independent of the DB-overridable access level. Adds coverage; full suite (1510 tests) green. --- src/agent/tools/__tests__/registry.test.ts | 34 ++++++++++++++++++++++ src/agent/tools/registry.ts | 17 +++++++++++ src/agent/tools/scope.ts | 12 ++++---- 3 files changed, 57 insertions(+), 6 deletions(-) diff --git a/src/agent/tools/__tests__/registry.test.ts b/src/agent/tools/__tests__/registry.test.ts index c9a3d645..fbc33240 100644 --- a/src/agent/tools/__tests__/registry.test.ts +++ b/src/agent/tools/__tests__/registry.test.ts @@ -320,6 +320,40 @@ describe("ToolRegistry", () => { }); }); + describe("channel scope (dm-only / group-only)", () => { + beforeEach(() => { + registry.register(createMockTool("dm_tool"), createMockExecutor(), "dm-only"); + registry.register(createMockTool("group_tool"), createMockExecutor(), "group-only"); + }); + + it("excludes dm-only tools in groups and group-only tools in DMs", () => { + const dm = registry.getForContext(false, null, undefined, true).map((t) => t.name); + expect(dm).toContain("dm_tool"); + expect(dm).not.toContain("group_tool"); + + const group = registry.getForContext(true, null, undefined, true).map((t) => t.name); + expect(group).toContain("group_tool"); + expect(group).not.toContain("dm_tool"); + }); + + it("denies executing a dm-only tool in a group", async () => { + const result = await registry.execute({ name: "dm_tool", input: { message: "x" } } as any, { + ...mockContext, + isGroup: true, + }); + expect(result.success).toBe(false); + expect(result.error).toMatch(/direct message/i); + }); + + it("allows executing a dm-only tool in a DM", async () => { + const result = await registry.execute({ name: "dm_tool", input: { message: "x" } } as any, { + ...mockContext, + isGroup: false, + }); + expect(result.success).toBe(true); + }); + }); + // ---------- Tool execution ---------- describe("execute()", () => { diff --git a/src/agent/tools/registry.ts b/src/agent/tools/registry.ts index 8960fe93..4046f65e 100644 --- a/src/agent/tools/registry.ts +++ b/src/agent/tools/registry.ts @@ -34,6 +34,8 @@ type AccessDenial = | { kind: "disabled" } | { kind: "admin-only" } | { kind: "allowlist" } + | { kind: "dm-only" } + | { kind: "group-only" } | { kind: "module-disabled"; module: string } | { kind: "module-admin"; module: string }; @@ -168,6 +170,17 @@ export class ToolRegistry { return { ok: false, reason: { kind: "mode", mode: toolMode } }; } + // Channel restriction (code-declared, DB cannot override): a "dm-only" tool + // never runs in a group and a "group-only" tool never runs in a DM. Applies + // regardless of identity — it gates where the tool runs, not who runs it. + const channelScope = this.tools.get(name)?.scope; + if (channelScope === "dm-only" && ctx.isGroup) { + return { ok: false, reason: { kind: "dm-only" } }; + } + if (channelScope === "group-only" && !ctx.isGroup) { + return { ok: false, reason: { kind: "group-only" } }; + } + const level = this.getEffectiveLevel(name); if (level === "off") return { ok: false, reason: { kind: "disabled" } }; if (level === "admin" && !ctx.isAdmin) return { ok: false, reason: { kind: "admin-only" } }; @@ -202,6 +215,10 @@ export class ToolRegistry { return `Tool "${name}" is restricted to admin users`; case "allowlist": return `Tool "${name}" is restricted to allowed users`; + case "dm-only": + return `Tool "${name}" can only be used in a direct message`; + case "group-only": + return `Tool "${name}" can only be used in a group`; case "module-disabled": return `Module "${reason.module}" is disabled in this group`; case "module-admin": diff --git a/src/agent/tools/scope.ts b/src/agent/tools/scope.ts index 3545e400..3c7267d9 100644 --- a/src/agent/tools/scope.ts +++ b/src/agent/tools/scope.ts @@ -17,12 +17,12 @@ export function isToolAccessLevel(v: unknown): v is ToolAccessLevel { } /** - * Map a legacy single-value {@link ToolScope} (still the code-declared default - * for built-in / MCP / plugin tools) to an access level. Single source of truth - * for the old→new translation — reused by the DB migration, the runtime default - * seeding, and the backward-compatible API. The old context dimension - * (dm-only / group-only) collapses to "all"; the channel gating now lives in the - * global DM/Group policies. + * Map a single-value {@link ToolScope} (the code-declared default for built-in / + * MCP / plugin tools) to an access level. Single source of truth for the + * scope→level translation — reused by the DB migration, the runtime default + * seeding, and the API. Note the channel dimension (dm-only / group-only) maps + * to level "all" here because it is NOT an authority level: it is enforced + * separately in ToolRegistry.checkAccess from the tool's declared scope. */ export function scopeToLevel(scope: ToolScope | null | undefined): ToolAccessLevel { switch (scope) { From e8abfd73af748b7efd3f7114d5a903f06bc67304 Mon Sep 17 00:00:00 2001 From: TONresistor <240980241+TONresistor@users.noreply.github.com> Date: Fri, 26 Jun 2026 06:39:55 +0200 Subject: [PATCH 10/59] refactor(startup): break startAgent god-method into named phases Extract recreateForModeChange, installHookRunner, emitAgentStartHook, and installMessagePipeline from the ~250-line startAgent into private methods (now ~150). Pure reorganization; full suite (1510 tests) green. --- src/index.ts | 173 +++++++++++++++++++++++++++++---------------------- 1 file changed, 99 insertions(+), 74 deletions(-) diff --git a/src/index.ts b/src/index.ts index 673f07f8..dc4ada7a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,6 +24,7 @@ import { registerAllTools } from "./agent/tools/register-all.js"; import { type PluginModuleWithHooks } from "./agent/tools/plugin-loader.js"; import type { HookName, AgentStartEvent, AgentStopEvent } from "./sdk/hooks/types.js"; import { createHookRunner } from "./sdk/hooks/runner.js"; +import type { HookRegistry } from "./sdk/hooks/registry.js"; import type { SDKDependencies } from "./sdk/index.js"; import type { SupportedProvider } from "./config/providers.js"; import { readRawConfig, setNestedValue, writeRawConfig } from "./config/configurable-keys.js"; @@ -379,38 +380,7 @@ ${blue} ┌────────────────────── if (modeChanged) { log.info(`Mode changed to "${this.config.telegram.mode}", recreating bridge & registry`); - - // Recreate bridge for the new mode - this.bridge = createBridge(this.config); - this.sdkDeps.bridge = this.bridge; - - // Update tool registry mode (filters tools for user vs bot) - this.toolRegistry.setMode(this.config.telegram.mode); - if (this.config.telegram.allow_from?.length) { - this.toolRegistry.setAllowFrom(this.config.telegram.allow_from); - } - - // Swap bridge ref in handlers that hold it - this.messageHandler.setBridge(this.bridge); - - // Recreate handlers that don't support hot-swap - const db = getDatabase().getDb(); - const modulePermissions = new ModulePermissions(db); - this.toolRegistry.setPermissions(modulePermissions); - this.adminHandler = new AdminHandler( - this.bridge, - this.config.telegram, - this.agent, - this.configPath, - modulePermissions, - this.toolRegistry - ); - this.heartbeatRunner = new HeartbeatRunner(this.agent, this.bridge, this.config); - this.scheduledTaskHandler = new ScheduledTaskHandler(this.agent, this.bridge, this.config); - - // New bridge = new message listeners needed - this.messageHandlersRegistered = false; - this.callbackHandlerRegistered = false; + this.recreateForModeChange(); } // Truncate stale external plugins from previous run (keep builtins only) @@ -501,28 +471,7 @@ ${blue} ┌────────────────────── } // Create hook runner if any plugins registered hooks - if (hookRegistry.hasAnyHooks()) { - const hookRunner = createHookRunner(hookRegistry, { logger: log }); - this.agent.setHookRunner(hookRunner); - this.hookRunner = hookRunner; - const activeHooks: HookName[] = [ - "tool:before", - "tool:after", - "tool:error", - "prompt:before", - "prompt:after", - "session:start", - "session:end", - "message:receive", - "response:before", - "response:after", - "response:error", - "agent:start", - "agent:stop", - ]; - const active = activeHooks.filter((n) => hookRegistry.hasHooks(n)); - log.info(`🪝 Hook runner created (${active.join(", ")})`); - } + if (hookRegistry.hasAnyHooks()) this.installHookRunner(hookRegistry); this.wirePluginEventHooks(); @@ -560,25 +509,7 @@ ${blue} ┌────────────────────── // Hook: agent:start this.startTime = Date.now(); this.messagesProcessed = 0; - if (this.hookRunner) { - let version = "0.0.0"; - try { - const { createRequire } = await import("module"); - const req = createRequire(import.meta.url); - version = (req("../package.json") as { version: string }).version; - } catch { - /* ignore */ - } - const agentStartEvent: AgentStartEvent = { - version, - provider, - model: this.config.agent.model, - pluginCount: pluginNames.length, - toolCount: this.toolCount, - timestamp: Date.now(), - }; - await this.hookRunner.runObservingHook("agent:start", agentStartEvent); - } + await this.emitAgentStartHook(provider, pluginNames.length); // Start heartbeat timer if enabled if (this.config.heartbeat.enabled) { @@ -588,7 +519,63 @@ ${blue} ┌────────────────────── } } - // Initialize message debouncer + this.installMessagePipeline(); + } + + /** Create and install the plugin hook runner; log which hooks are active. */ + private installHookRunner(hookRegistry: HookRegistry): void { + const hookRunner = createHookRunner(hookRegistry, { logger: log }); + this.agent.setHookRunner(hookRunner); + this.hookRunner = hookRunner; + const activeHooks: HookName[] = [ + "tool:before", + "tool:after", + "tool:error", + "prompt:before", + "prompt:after", + "session:start", + "session:end", + "message:receive", + "response:before", + "response:after", + "response:error", + "agent:start", + "agent:stop", + ]; + const active = activeHooks.filter((n) => hookRegistry.hasHooks(n)); + log.info(`🪝 Hook runner created (${active.join(", ")})`); + } + + /** Emit the agent:start observing hook (no-op when no hook runner). */ + private async emitAgentStartHook( + provider: SupportedProvider, + pluginCount: number + ): Promise { + if (!this.hookRunner) return; + let version = "0.0.0"; + try { + const { createRequire } = await import("module"); + const req = createRequire(import.meta.url); + version = (req("../package.json") as { version: string }).version; + } catch { + /* ignore */ + } + const agentStartEvent: AgentStartEvent = { + version, + provider, + model: this.config.agent.model, + pluginCount, + toolCount: this.toolCount, + timestamp: Date.now(), + }; + await this.hookRunner.runObservingHook("agent:start", agentStartEvent); + } + + /** + * Initialize the message debouncer and register the (one-time) new-message + * listener that feeds it. Idempotent across agent restarts via the WebUI. + */ + private installMessagePipeline(): void { this.debouncer = new MessageDebouncer( { debounceMs: this.config.telegram.debounce_ms }, (msg) => { @@ -623,6 +610,44 @@ ${blue} ┌────────────────────── } } + /** + * Recreate the bridge, registry mode, and non-hot-swappable handlers after a + * user/bot mode switch. Caller logs the switch and guards on modeChanged. + */ + private recreateForModeChange(): void { + // Recreate bridge for the new mode + this.bridge = createBridge(this.config); + this.sdkDeps.bridge = this.bridge; + + // Update tool registry mode (filters tools for user vs bot) + this.toolRegistry.setMode(this.config.telegram.mode); + if (this.config.telegram.allow_from?.length) { + this.toolRegistry.setAllowFrom(this.config.telegram.allow_from); + } + + // Swap bridge ref in handlers that hold it + this.messageHandler.setBridge(this.bridge); + + // Recreate handlers that don't support hot-swap + const db = getDatabase().getDb(); + const modulePermissions = new ModulePermissions(db); + this.toolRegistry.setPermissions(modulePermissions); + this.adminHandler = new AdminHandler( + this.bridge, + this.config.telegram, + this.agent, + this.configPath, + modulePermissions, + this.toolRegistry + ); + this.heartbeatRunner = new HeartbeatRunner(this.agent, this.bridge, this.config); + this.scheduledTaskHandler = new ScheduledTaskHandler(this.agent, this.bridge, this.config); + + // New bridge = new message listeners needed + this.messageHandlersRegistered = false; + this.callbackHandlerRegistered = false; + } + // ─── Mode-specific wiring ────────────────────────────────────────────── /** From 208b02aad1a38114c38110df393fae0cb85a8542 Mon Sep 17 00:00:00 2001 From: TONresistor <240980241+TONresistor@users.noreply.github.com> Date: Fri, 26 Jun 2026 10:55:44 +0200 Subject: [PATCH 11/59] feat(deals): remove the deals trading module Remove the automated P2P gift/TON trading subsystem and everything that only existed to serve it: - src/deals/ and src/agent/tools/deals/ (propose/verify/cancel/status/list) - the DealBot and its services (bot/index.ts, deal-service, verification-poller, message-builder); the shared plugin bot infra (inline-router, rate-limiter, gramjs-bot, inline-transport, callback-router) is kept - telegram_send_gift / telegram_transfer_collectible (gated by hasVerifiedDeal, i.e. only usable inside an authorized deal) - deals config (schema, configurable-keys, validator, onboard, setup wizard) - the /strategy admin command and dead deal types/constants This also resolves the dm-only/deals interaction from the channel-scope fix. typecheck + lint + full suite (1502 tests) green. --- .../tools/__tests__/plugin-loader.test.ts | 16 - src/agent/tools/deals/cancel.ts | 89 --- src/agent/tools/deals/index.ts | 5 - src/agent/tools/deals/list.ts | 153 ------ src/agent/tools/deals/load-deal.ts | 25 - src/agent/tools/deals/propose.ts | 261 --------- src/agent/tools/deals/status.ts | 141 ----- src/agent/tools/deals/verify-payment.ts | 240 --------- src/agent/tools/module-loader.ts | 5 +- src/agent/tools/plugin-validator.ts | 1 - src/agent/tools/register-all.ts | 2 +- src/agent/tools/telegram/gifts/index.ts | 19 - src/agent/tools/telegram/gifts/send-gift.ts | 122 ----- .../telegram/gifts/transfer-collectible.ts | 170 ------ src/agent/tools/types.ts | 2 +- src/bot/index.ts | 510 ------------------ src/bot/inline-router.ts | 17 +- src/bot/services/deal-service.ts | 202 ------- src/bot/services/inline-transport.ts | 4 +- src/bot/services/message-builder.ts | 244 --------- src/bot/services/verification-poller.ts | 362 ------------- src/bot/types.ts | 82 --- src/cli/commands/onboard.ts | 7 +- .../__tests__/configurable-keys.test.ts | 36 -- src/config/__tests__/loader.test.ts | 17 - src/config/configurable-keys.ts | 47 -- src/config/schema.ts | 20 +- src/constants/limits.ts | 1 - src/constants/tools.ts | 1 - src/deals/config.ts | 49 -- src/deals/db.ts | 98 ---- src/deals/executor.ts | 346 ------------ src/deals/gift-detector.ts | 82 --- src/deals/module.ts | 176 ------ src/deals/strategy-checker.ts | 148 ----- src/deals/types.ts | 55 -- src/deals/utils.ts | 59 -- src/index.ts | 35 +- src/sdk/formatting.ts | 8 - src/sdk/index.ts | 2 +- src/telegram/admin.ts | 42 -- src/webui/routes/setup.ts | 6 +- 42 files changed, 25 insertions(+), 3882 deletions(-) delete mode 100644 src/agent/tools/deals/cancel.ts delete mode 100644 src/agent/tools/deals/index.ts delete mode 100644 src/agent/tools/deals/list.ts delete mode 100644 src/agent/tools/deals/load-deal.ts delete mode 100644 src/agent/tools/deals/propose.ts delete mode 100644 src/agent/tools/deals/status.ts delete mode 100644 src/agent/tools/deals/verify-payment.ts delete mode 100644 src/agent/tools/telegram/gifts/send-gift.ts delete mode 100644 src/agent/tools/telegram/gifts/transfer-collectible.ts delete mode 100644 src/bot/index.ts delete mode 100644 src/bot/services/deal-service.ts delete mode 100644 src/bot/services/message-builder.ts delete mode 100644 src/bot/services/verification-poller.ts delete mode 100644 src/deals/config.ts delete mode 100644 src/deals/db.ts delete mode 100644 src/deals/executor.ts delete mode 100644 src/deals/gift-detector.ts delete mode 100644 src/deals/module.ts delete mode 100644 src/deals/strategy-checker.ts delete mode 100644 src/deals/types.ts delete mode 100644 src/deals/utils.ts diff --git a/src/agent/tools/__tests__/plugin-loader.test.ts b/src/agent/tools/__tests__/plugin-loader.test.ts index debd753b..0f01dbb3 100644 --- a/src/agent/tools/__tests__/plugin-loader.test.ts +++ b/src/agent/tools/__tests__/plugin-loader.test.ts @@ -75,15 +75,6 @@ function makeConfig(overrides?: Partial): Config { history_limit: 100, }, embedding: { provider: "local" }, - deals: { - enabled: true, - expiry_seconds: 120, - buy_max_floor_percent: 95, - sell_min_floor_percent: 105, - poll_interval_ms: 5000, - max_verification_retries: 12, - expiry_check_interval_ms: 60000, - }, webui: { enabled: false, port: 7777, @@ -306,13 +297,6 @@ describe("sanitizeConfigForPlugins — config isolation", () => { expect(sanitized.telegram.admin_ids).toEqual([111, 222]); }); - it("T4d: preserves deals.enabled", () => { - const config = makeConfig(); - const sanitized = sanitizeConfigForPlugins(config) as any; - - expect(sanitized.deals.enabled).toBe(true); - }); - it("T4e: does not expose top-level secret keys", () => { const config = makeConfig(); const sanitized = sanitizeConfigForPlugins(config) as any; diff --git a/src/agent/tools/deals/cancel.ts b/src/agent/tools/deals/cancel.ts deleted file mode 100644 index 06e47624..00000000 --- a/src/agent/tools/deals/cancel.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { Type } from "@sinclair/typebox"; -import type { Tool, ToolExecutor, ToolResult } from "../types.js"; -import { loadDealForActor } from "./load-deal.js"; -import { getErrorMessage } from "../../../utils/errors.js"; -import { createLogger } from "../../../utils/logger.js"; - -const log = createLogger("Tools"); - -interface DealCancelParams { - dealId: string; - reason?: string; -} - -export const dealCancelTool: Tool = { - name: "deal_cancel", - description: "Cancel a deal. Only works for 'proposed' or 'accepted' status. Irreversible.", - parameters: Type.Object({ - dealId: Type.String({ description: "Deal ID to cancel" }), - reason: Type.Optional(Type.String({ description: "Reason for cancellation (optional)" })), - }), -}; - -export const dealCancelExecutor: ToolExecutor = async ( - params, - context -): Promise => { - try { - const { dealId, reason } = params; - - // Load deal + enforce owner/admin access - const adminIds = context.config?.telegram.admin_ids ?? []; - const loaded = loadDealForActor(context.db, dealId, context.senderId, adminIds, "cancel"); - if (!loaded.ok) { - return { success: false, error: loaded.error }; - } - const deal = loaded.deal; - - // Check if deal can be cancelled - const cancellableStatuses = ["proposed", "accepted"]; - if (!cancellableStatuses.includes(deal.status)) { - return { - success: false, - error: `Cannot cancel deal #${dealId} with status '${deal.status}'. Only 'proposed' and 'accepted' deals can be cancelled.`, - }; - } - - // Update deal status to cancelled - const notes = reason ? `Cancelled: ${reason}` : "Cancelled by agent"; - context.db - .prepare( - `UPDATE deals SET - status = 'cancelled', - notes = CASE WHEN notes IS NULL THEN ? ELSE notes || ' | ' || ? END - WHERE id = ?` - ) - .run(notes, notes, dealId); - - log.info(`[Deal] #${dealId} cancelled - reason: ${reason || "no reason given"}`); - - // Notify user in chat if deal was accepted - if (deal.status === "accepted") { - await context.bridge.sendMessage({ - chatId: deal.chat_id, - text: `🚫 **Deal #${dealId} cancelled** - -${reason ? `Reason: ${reason}` : "The deal has been cancelled."} - -No payment has been processed. You can propose a new deal if you'd like.`, - }); - } - - return { - success: true, - data: { - dealId, - previousStatus: deal.status, - newStatus: "cancelled", - reason: reason || null, - message: `Deal #${dealId} has been cancelled.`, - }, - }; - } catch (error) { - log.error({ err: error }, "Error cancelling deal"); - return { - success: false, - error: getErrorMessage(error), - }; - } -}; diff --git a/src/agent/tools/deals/index.ts b/src/agent/tools/deals/index.ts deleted file mode 100644 index 544764b4..00000000 --- a/src/agent/tools/deals/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from "./propose.js"; -export * from "./verify-payment.js"; -export * from "./status.js"; -export * from "./list.js"; -export * from "./cancel.js"; diff --git a/src/agent/tools/deals/list.ts b/src/agent/tools/deals/list.ts deleted file mode 100644 index d9617c8b..00000000 --- a/src/agent/tools/deals/list.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { Type } from "@sinclair/typebox"; -import type { Tool, ToolExecutor, ToolResult } from "../types.js"; -import type { Deal } from "../../../deals/types.js"; -import { formatAsset } from "../../../deals/utils.js"; -import { getErrorMessage } from "../../../utils/errors.js"; -import { createLogger } from "../../../utils/logger.js"; - -const log = createLogger("Tools"); - -interface DealListParams { - status?: string; - userId?: number; - limit?: number; -} - -export const dealListTool: Tool = { - name: "deal_list", - description: "List recent deals. Filter by status or user. Non-admins see only their own deals.", - category: "data-bearing", - parameters: Type.Object({ - status: Type.Optional( - Type.String({ - description: - "Filter by status: proposed, accepted, verified, completed, declined, expired, cancelled, failed", - }) - ), - userId: Type.Optional(Type.Number({ description: "Filter by user's Telegram ID" })), - limit: Type.Optional( - Type.Number({ description: "Max results to return (default 20)", minimum: 1, maximum: 100 }) - ), - }), -}; - -export const dealListExecutor: ToolExecutor = async ( - params, - context -): Promise => { - try { - const { status, limit = 20 } = params; - let { userId } = params; - - // User-scoping: non-admins can only list their own deals - const adminIds = context.config?.telegram.admin_ids ?? []; - if (!adminIds.includes(context.senderId)) { - userId = context.senderId; - } - - // Build query - let query = `SELECT * FROM deals WHERE 1=1`; - const queryParams: (string | number)[] = []; - - if (status) { - query += ` AND status = ?`; - queryParams.push(status); - } - - if (userId) { - query += ` AND user_telegram_id = ?`; - queryParams.push(userId); - } - - query += ` ORDER BY created_at DESC LIMIT ?`; - queryParams.push(limit); - - // Execute query - const deals = context.db.prepare(query).all(...queryParams) as Deal[]; - - if (deals.length === 0) { - return { - success: true, - data: { - count: 0, - deals: [], - message: "No deals found matching the criteria.", - }, - }; - } - - // Format deals - const formattedDeals = deals.map((deal) => { - const userGives = formatAsset( - deal.user_gives_type, - deal.user_gives_ton_amount, - deal.user_gives_gift_slug - ); - const agentGives = formatAsset( - deal.agent_gives_type, - deal.agent_gives_ton_amount, - deal.agent_gives_gift_slug - ); - - let statusEmoji = "⏳"; - if (deal.status === "completed") statusEmoji = "✅"; - else if (deal.status === "verified") statusEmoji = "🔄"; - else if (deal.status === "accepted") statusEmoji = "👍"; - else if (deal.status === "declined") statusEmoji = "❌"; - else if (deal.status === "expired") statusEmoji = "⏰"; - else if (deal.status === "failed") statusEmoji = "💥"; - else if (deal.status === "cancelled") statusEmoji = "🚫"; - - return { - id: deal.id, - status: deal.status, - statusEmoji, - userGives, - agentGives, - profit: deal.profit_ton, - userTelegramId: deal.user_telegram_id, - userUsername: deal.user_username, - createdAt: new Date(deal.created_at * 1000).toISOString(), - expiresAt: new Date(deal.expires_at * 1000).toISOString(), - completedAt: deal.completed_at ? new Date(deal.completed_at * 1000).toISOString() : null, - summary: `${statusEmoji} #${deal.id} - ${deal.status} - User gives: ${userGives}, Agent gives: ${agentGives}, Profit: ${deal.profit_ton?.toFixed(2) || 0} TON`, - }; - }); - - // Calculate totals - const totalProfit = deals - .filter((d) => d.status === "completed") - .reduce((sum, d) => sum + (d.profit_ton || 0), 0); - - const statusCounts = deals.reduce( - (acc, d) => { - acc[d.status] = (acc[d.status] || 0) + 1; - return acc; - }, - {} as Record - ); - - return { - success: true, - data: { - count: deals.length, - deals: formattedDeals, - stats: { - totalProfit, - statusCounts, - }, - filters: { - status: status || "all", - userId: userId || "all", - limit, - }, - }, - }; - } catch (error) { - log.error({ err: error }, "Error listing deals"); - return { - success: false, - error: getErrorMessage(error), - }; - } -}; diff --git a/src/agent/tools/deals/load-deal.ts b/src/agent/tools/deals/load-deal.ts deleted file mode 100644 index 7add0302..00000000 --- a/src/agent/tools/deals/load-deal.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type Database from "better-sqlite3"; -import type { Deal } from "../../../deals/types.js"; - -export type LoadDealResult = { ok: true; deal: Deal } | { ok: false; error: string }; - -/** - * Load a deal by id and enforce owner/admin access in one place. `action` fills - * the scoping error ("⛔ You can only your own deals."), e.g. "cancel". - */ -export function loadDealForActor( - db: Database.Database, - dealId: string, - senderId: number, - adminIds: number[], - action: string -): LoadDealResult { - const deal = db.prepare(`SELECT * FROM deals WHERE id = ?`).get(dealId) as Deal | undefined; - if (!deal) { - return { ok: false, error: `Deal #${dealId} not found` }; - } - if (senderId !== deal.user_telegram_id && !adminIds.includes(senderId)) { - return { ok: false, error: `⛔ You can only ${action} your own deals.` }; - } - return { ok: true, deal }; -} diff --git a/src/agent/tools/deals/propose.ts b/src/agent/tools/deals/propose.ts deleted file mode 100644 index 374c8964..00000000 --- a/src/agent/tools/deals/propose.ts +++ /dev/null @@ -1,261 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import type { TelegramClient } from "telegram"; -import { randomLong } from "../../../utils/gramjs-bigint.js"; -import { Type } from "@sinclair/typebox"; -import type { Tool, ToolExecutor, ToolResult } from "../types.js"; -import { generateDealId, calculateExpiry, formatDealProposal } from "../../../deals/utils.js"; -import { - checkStrategyCompliance, - formatStrategyCheckJSON, - type AssetValue, -} from "../../../deals/strategy-checker.js"; -import { getErrorMessage } from "../../../utils/errors.js"; -import { createLogger } from "../../../utils/logger.js"; - -const log = createLogger("Tools"); - -interface DealProposeParams { - chatId: string; - userId: number; - userGivesType: "ton" | "gift"; - userGivesTonAmount?: number; - userGivesGiftId?: string; - userGivesGiftSlug?: string; - userGivesValueTon: number; - agentGivesType: "ton" | "gift"; - agentGivesTonAmount?: number; - agentGivesGiftId?: string; - agentGivesGiftSlug?: string; - agentGivesValueTon: number; - userUsername?: string; -} - -export const dealProposeTool: Tool = { - name: "deal_propose", - description: - "Create a TON trade deal (gift↔TON, TON↔gift). Sends a deal card with Accept/Decline buttons. Strategy limits enforced automatically. User ALWAYS sends first. Expires in 2 min. Do NOT send follow-up message after calling this.", - parameters: Type.Object({ - chatId: Type.String({ description: "Chat ID where to send proposal" }), - userId: Type.Number({ description: "Telegram user ID" }), - userGivesType: Type.Union([Type.Literal("ton"), Type.Literal("gift")]), - userGivesTonAmount: Type.Optional( - Type.Number({ description: "TON amount user gives (if type=ton)" }) - ), - userGivesGiftId: Type.Optional( - Type.String({ description: "Gift msgId user gives (if type=gift)" }) - ), - userGivesGiftSlug: Type.Optional( - Type.String({ - description: - "Gift's slug field from telegram_get_my_gifts (e.g. 'LolPop-425402'), NOT the title", - }) - ), - userGivesValueTon: Type.Number({ description: "Estimated TON value of what user gives" }), - agentGivesType: Type.Union([Type.Literal("ton"), Type.Literal("gift")]), - agentGivesTonAmount: Type.Optional( - Type.Number({ description: "TON amount you give (if type=ton)" }) - ), - agentGivesGiftId: Type.Optional( - Type.String({ description: "Gift msgId you give (if type=gift)" }) - ), - agentGivesGiftSlug: Type.Optional( - Type.String({ - description: - "Gift's slug field from telegram_get_my_gifts (e.g. 'LolPop-425402'), NOT the title", - }) - ), - agentGivesValueTon: Type.Number({ description: "Estimated TON value of what you give" }), - userUsername: Type.Optional(Type.String({ description: "User's @username for display" })), - }), -}; - -export const dealProposeExecutor: ToolExecutor = async ( - params, - context -): Promise => { - try { - const userGives: AssetValue = { - type: params.userGivesType, - tonAmount: params.userGivesTonAmount, - giftSlug: params.userGivesGiftSlug, - valueTon: params.userGivesValueTon, - }; - - const agentGives: AssetValue = { - type: params.agentGivesType, - tonAmount: params.agentGivesTonAmount, - giftSlug: params.agentGivesGiftSlug, - valueTon: params.agentGivesValueTon, - }; - - // CRITICAL: Check strategy compliance - const strategyCheck = checkStrategyCompliance(userGives, agentGives); - - if (!strategyCheck.acceptable) { - return { - success: false, - error: `Deal rejected by strategy rules:\n${strategyCheck.reason}`, - }; - } - - // Generate deal ID and expiry - const dealId = generateDealId(); - const expiresAt = calculateExpiry(); - const createdAt = Math.floor(Date.now() / 1000); - - // Create deal in database - context.db - .prepare( - ` - INSERT INTO deals ( - id, status, user_telegram_id, user_username, chat_id, - user_gives_type, user_gives_ton_amount, user_gives_gift_id, user_gives_gift_slug, user_gives_value_ton, - agent_gives_type, agent_gives_ton_amount, agent_gives_gift_id, agent_gives_gift_slug, agent_gives_value_ton, - strategy_check, profit_ton, created_at, expires_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ` - ) - .run( - dealId, - "proposed", - params.userId, - params.userUsername || null, - params.chatId, - params.userGivesType, - params.userGivesTonAmount || null, - params.userGivesGiftId || null, - params.userGivesGiftSlug || null, - params.userGivesValueTon, - params.agentGivesType, - params.agentGivesTonAmount || null, - params.agentGivesGiftId || null, - params.agentGivesGiftSlug || null, - params.agentGivesValueTon, - formatStrategyCheckJSON(strategyCheck), - strategyCheck.profit, - createdAt, - expiresAt - ); - - log.info(`[Deal] Created deal #${dealId} - profit: ${strategyCheck.profit.toFixed(2)} TON`); - - // Send inline bot message with Accept/Decline buttons - const botUsername = context.config?.telegram?.bot_username; - let inlineSent = false; - - if (botUsername) { - try { - inlineSent = await sendInlineBotResult( - context.bridge as any, - params.chatId, - botUsername, - dealId - ); - } catch (inlineError) { - log.warn({ err: inlineError }, "[Deal] Failed to send inline bot result"); - } - } - - // Fallback: send plain text if inline bot failed - if (!inlineSent) { - const proposalText = formatDealProposal( - dealId, - { - type: params.userGivesType, - tonAmount: params.userGivesTonAmount, - giftSlug: params.userGivesGiftSlug, - valueTon: params.userGivesValueTon, - }, - { - type: params.agentGivesType, - tonAmount: params.agentGivesTonAmount, - giftSlug: params.agentGivesGiftSlug, - valueTon: params.agentGivesValueTon, - }, - strategyCheck.profit, - true - ); - - const fallbackText = botUsername - ? `${proposalText}\n\nTo confirm, type: @${botUsername} ${dealId}` - : proposalText; - - const sentMessage = await context.bridge.sendMessage({ - chatId: params.chatId, - text: fallbackText, - }); - - context.db - .prepare(`UPDATE deals SET proposal_message_id = ? WHERE id = ?`) - .run(sentMessage.id, dealId); - } - - return { - success: true, - data: { - dealId, - profit: strategyCheck.profit, - expiresAt: new Date(expiresAt * 1000).toISOString(), - strategyRule: strategyCheck.rule, - inlineSent, - note: "Deal card sent with buttons. STOP HERE — do NOT send any follow-up message. The user will click Accept/Decline on the card.", - }, - }; - } catch (error) { - log.error({ err: error }, "Error creating deal proposal"); - return { - success: false, - error: getErrorMessage(error), - }; - } -}; - -/** - * Send inline bot result via GramJS (userbot queries the bot, then sends the result) - * This makes the deal card with buttons appear directly in the chat. - */ -async function sendInlineBotResult( - bridge: { getClient(): { getClient(): TelegramClient } }, - chatId: string, - botUsername: string, - dealId: string -): Promise { - const gramJsClient = bridge.getClient().getClient(); - const Api = (await import("telegram")).Api; - - // Resolve bot and chat entities - const bot = await gramJsClient.getInputEntity(botUsername); - const peer = await gramJsClient.getInputEntity(chatId.startsWith("-") ? Number(chatId) : chatId); - - // Query the inline bot with the deal ID - const results = await gramJsClient.invoke( - new Api.messages.GetInlineBotResults({ - bot: bot, - peer: peer, - query: dealId, - offset: "", - }) - ); - - if (!results.results || results.results.length === 0) { - log.warn(`[Deal] No inline results returned for deal ${dealId}`); - return false; - } - - // Find the deal result (skip help/not_found/wrong_user results) - const dealResult = results.results.find((r) => r.id === dealId); - const resultToSend = dealResult || results.results[0]; - - // Send the inline result as a message in the chat - await gramJsClient.invoke( - new Api.messages.SendInlineBotResult({ - peer: peer, - queryId: results.queryId, - id: resultToSend.id, - randomId: randomLong(), - }) - ); - - log.info(`[Deal] Inline bot message sent for deal #${dealId}`); - return true; -} diff --git a/src/agent/tools/deals/status.ts b/src/agent/tools/deals/status.ts deleted file mode 100644 index f4c75013..00000000 --- a/src/agent/tools/deals/status.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { Type } from "@sinclair/typebox"; -import type { Tool, ToolExecutor, ToolResult } from "../types.js"; -import { loadDealForActor } from "./load-deal.js"; -import { formatAsset } from "../../../deals/utils.js"; -import { getErrorMessage } from "../../../utils/errors.js"; -import { createLogger } from "../../../utils/logger.js"; - -const log = createLogger("Tools"); - -interface DealStatusParams { - dealId: string; -} - -export const dealStatusTool: Tool = { - name: "deal_status", - description: - "Get full details of a deal by ID: status, parties, assets, payment tracking, profit.", - category: "data-bearing", - parameters: Type.Object({ - dealId: Type.String({ description: "Deal ID to check status for" }), - }), -}; - -export const dealStatusExecutor: ToolExecutor = async ( - params, - context -): Promise => { - try { - // Load deal + enforce owner/admin access - const adminIds = context.config?.telegram.admin_ids ?? []; - const loaded = loadDealForActor(context.db, params.dealId, context.senderId, adminIds, "view"); - if (!loaded.ok) { - return { success: false, error: loaded.error }; - } - const deal = loaded.deal; - - // Format timestamps - const createdAt = new Date(deal.created_at * 1000).toISOString(); - const expiresAt = new Date(deal.expires_at * 1000).toISOString(); - const verifiedAt = deal.user_payment_verified_at - ? new Date(deal.user_payment_verified_at * 1000).toISOString() - : null; - const completedAt = deal.completed_at ? new Date(deal.completed_at * 1000).toISOString() : null; - const sentAt = deal.agent_sent_at ? new Date(deal.agent_sent_at * 1000).toISOString() : null; - - // Format assets - const userGives = formatAsset( - deal.user_gives_type, - deal.user_gives_ton_amount, - deal.user_gives_gift_slug - ); - const agentGives = formatAsset( - deal.agent_gives_type, - deal.agent_gives_ton_amount, - deal.agent_gives_gift_slug - ); - - // Build status text - let statusEmoji = "⏳"; - if (deal.status === "completed") statusEmoji = "✅"; - else if (deal.status === "verified") statusEmoji = "🔄"; - else if (deal.status === "accepted") statusEmoji = "👍"; - else if (deal.status === "declined") statusEmoji = "❌"; - else if (deal.status === "expired") statusEmoji = "⏰"; - else if (deal.status === "failed") statusEmoji = "💥"; - else if (deal.status === "cancelled") statusEmoji = "🚫"; - - return { - success: true, - data: { - dealId: deal.id, - status: deal.status, - statusEmoji, - // Parties - user: { - telegramId: deal.user_telegram_id, - username: deal.user_username, - wallet: deal.user_payment_wallet, - }, - chatId: deal.chat_id, - // Trade details - userGives: { - type: deal.user_gives_type, - tonAmount: deal.user_gives_ton_amount, - giftId: deal.user_gives_gift_id, - giftSlug: deal.user_gives_gift_slug, - valueTon: deal.user_gives_value_ton, - formatted: userGives, - }, - agentGives: { - type: deal.agent_gives_type, - tonAmount: deal.agent_gives_ton_amount, - giftId: deal.agent_gives_gift_id, - giftSlug: deal.agent_gives_gift_slug, - valueTon: deal.agent_gives_value_ton, - formatted: agentGives, - }, - // Payment tracking - payment: { - verified: !!deal.user_payment_verified_at, - txHash: deal.user_payment_tx_hash, - giftMsgId: deal.user_payment_gift_msgid, - verifiedAt, - }, - // Agent send tracking - agentSent: { - sent: !!deal.agent_sent_at, - txHash: deal.agent_sent_tx_hash, - giftMsgId: deal.agent_sent_gift_msgid, - sentAt, - }, - // Business - profit: deal.profit_ton, - strategyCheck: deal.strategy_check ? JSON.parse(deal.strategy_check) : null, - // Timestamps - createdAt, - expiresAt, - completedAt, - notes: deal.notes, - // Formatted summary - summary: `${statusEmoji} **Deal #${deal.id}** - ${deal.status} - -**User gives:** ${userGives} -**Agent gives:** ${agentGives} -**Profit:** ${deal.profit_ton?.toFixed(2) || 0} TON - -**Created:** ${createdAt} -**Expires:** ${expiresAt} -${verifiedAt ? `**Verified:** ${verifiedAt}` : ""} -${completedAt ? `**Completed:** ${completedAt}` : ""} -${deal.notes ? `\n**Notes:** ${deal.notes}` : ""}`, - }, - }; - } catch (error) { - log.error({ err: error }, "Error checking deal status"); - return { - success: false, - error: getErrorMessage(error), - }; - } -}; diff --git a/src/agent/tools/deals/verify-payment.ts b/src/agent/tools/deals/verify-payment.ts deleted file mode 100644 index 75ab14e2..00000000 --- a/src/agent/tools/deals/verify-payment.ts +++ /dev/null @@ -1,240 +0,0 @@ -import { Type } from "@sinclair/typebox"; -import type { Tool, ToolExecutor, ToolResult } from "../types.js"; -import { loadDealForActor } from "./load-deal.js"; -import { verifyPayment } from "../../../ton/payment-verifier.js"; -import { GiftDetector } from "../../../deals/gift-detector.js"; -import { getWalletAddress } from "../../../ton/wallet-service.js"; -import { autoExecuteAfterVerification } from "../../../deals/executor.js"; -import { getErrorMessage } from "../../../utils/errors.js"; -import { createLogger } from "../../../utils/logger.js"; - -const log = createLogger("Tools"); - -interface DealVerifyPaymentParams { - dealId: string; -} - -export const dealVerifyPaymentTool: Tool = { - name: "deal_verify_payment", - description: - "Verify payment/gift for an accepted deal. Checks blockchain (TON) or gift inbox. Auto-executes on success. Only for status='accepted'.", - parameters: Type.Object({ - dealId: Type.String({ description: "Deal ID to verify payment for" }), - }), -}; - -export const dealVerifyPaymentExecutor: ToolExecutor = async ( - params, - context -): Promise => { - try { - // Load deal + enforce owner/admin access - const adminIds = context.config?.telegram.admin_ids ?? []; - const loaded = loadDealForActor( - context.db, - params.dealId, - context.senderId, - adminIds, - "verify payment for" - ); - if (!loaded.ok) { - return { success: false, error: loaded.error }; - } - const deal = loaded.deal; - - // Check deal status - if (deal.status !== "accepted") { - return { - success: false, - error: `Deal #${params.dealId} has status '${deal.status}', not 'accepted'. Cannot verify.`, - }; - } - - // Check expiry - const now = Math.floor(Date.now() / 1000); - if (now > deal.expires_at) { - // Mark as expired (atomic: only if still accepted) - const expireResult = context.db - .prepare(`UPDATE deals SET status = 'expired' WHERE id = ? AND status = 'accepted'`) - .run(params.dealId); - if (expireResult.changes !== 1) { - return { - success: false, - error: `Deal #${params.dealId} already transitioned by another process`, - }; - } - return { - success: false, - error: `Deal #${params.dealId} has expired (2 minutes elapsed)`, - }; - } - - // Case 1: User gives TON → verify blockchain transaction - if (deal.user_gives_type === "ton") { - if (!deal.user_gives_ton_amount) { - return { - success: false, - error: "Deal configuration error: user_gives_ton_amount is missing", - }; - } - - // Get bot wallet address - const botWallet = getWalletAddress(); - - if (!botWallet) { - return { - success: false, - error: "Wallet not initialized. Please run wallet setup first.", - }; - } - - log.info(`[Deal] Verifying TON payment for deal #${params.dealId}...`); - - // Verify TON payment with dealId as memo - const verification = await verifyPayment(context.db, { - botWalletAddress: botWallet, - betAmount: deal.user_gives_ton_amount, - requestTime: deal.created_at * 1000, // Convert to milliseconds - gameType: `deal:${params.dealId}`, - userId: params.dealId, // Use dealId as memo identifier - }); - - if (!verification.verified) { - return { - success: false, - error: `Payment verification failed: ${verification.error || "Transaction not found"}`, - }; - } - - // Update deal: store TX hash, player wallet, mark as verified (atomic: only if still accepted) - const verifyResult = context.db - .prepare( - `UPDATE deals SET - status = 'verified', - user_payment_tx_hash = ?, - user_payment_wallet = ?, - user_payment_verified_at = unixepoch() - WHERE id = ? AND status = 'accepted'` - ) - .run(verification.txHash, verification.playerWallet, params.dealId); - - if (verifyResult.changes !== 1) { - return { - success: false, - error: `Deal #${params.dealId} already transitioned by another process (expected 'accepted')`, - }; - } - - log.info( - `[Deal] Payment verified for #${params.dealId} - TX: ${verification.txHash?.slice(0, 8)}...` - ); - - // Auto-execute deal (send agent's part) - await autoExecuteAfterVerification(params.dealId, context.db, context.bridge); - - return { - success: true, - data: { - dealId: params.dealId, - verified: true, - txHash: verification.txHash, - amount: verification.amount, - playerWallet: verification.playerWallet, - date: verification.date, - autoExecuted: true, - }, - }; - } - - // Case 2: User gives gift → detect received gift - if (deal.user_gives_type === "gift") { - if (!deal.user_gives_gift_slug) { - return { - success: false, - error: "Deal configuration error: user_gives_gift_slug is missing", - }; - } - - log.info(`[Deal] Checking for gift receipt for deal #${params.dealId}...`); - - // Use GiftDetector to poll for new gifts — needs the agent's own Telegram ID - const ownUserId = context.bridge.getOwnUserId(); - - if (!ownUserId) { - return { - success: false, - error: "Failed to get bot user info. Bot may not be authenticated.", - }; - } - - const botUserId = Number(ownUserId); - - const giftDetector = new GiftDetector(); - const newGifts = await giftDetector.detectNewGifts(botUserId, context); - - // Find gift matching the expected slug from the deal's user - const matchingGift = newGifts.find( - (g) => - g.slug === deal.user_gives_gift_slug && - g.fromUserId === deal.user_telegram_id && - g.receivedAt >= deal.created_at * 1000 // Gift must be received after deal creation - ); - - if (!matchingGift) { - return { - success: false, - error: `Gift not received yet. Expected: ${deal.user_gives_gift_slug} from user ${deal.user_telegram_id}. Please ensure user has sent the gift.`, - }; - } - - // Update deal: store gift msgId, mark as verified (atomic: only if still accepted) - const giftVerifyResult = context.db - .prepare( - `UPDATE deals SET - status = 'verified', - user_payment_gift_msgid = ?, - user_payment_verified_at = unixepoch() - WHERE id = ? AND status = 'accepted'` - ) - .run(matchingGift.msgId, params.dealId); - - if (giftVerifyResult.changes !== 1) { - return { - success: false, - error: `Deal #${params.dealId} already transitioned by another process (expected 'accepted')`, - }; - } - - log.info(`[Deal] Gift verified for #${params.dealId} - msgId: ${matchingGift.msgId}`); - - // Auto-execute deal (send agent's part) - await autoExecuteAfterVerification(params.dealId, context.db, context.bridge); - - return { - success: true, - data: { - dealId: params.dealId, - verified: true, - giftMsgId: matchingGift.msgId, - giftSlug: matchingGift.slug, - giftName: matchingGift.name, - fromUserId: matchingGift.fromUserId, - receivedAt: new Date(matchingGift.receivedAt).toISOString(), - autoExecuted: true, - }, - }; - } - - // Edge case: shouldn't reach here - return { - success: false, - error: `Invalid deal configuration: user_gives_type = ${deal.user_gives_type}`, - }; - } catch (error) { - log.error({ err: error }, "Error verifying deal payment"); - return { - success: false, - error: getErrorMessage(error), - }; - } -}; diff --git a/src/agent/tools/module-loader.ts b/src/agent/tools/module-loader.ts index 68e5c20d..38118f78 100644 --- a/src/agent/tools/module-loader.ts +++ b/src/agent/tools/module-loader.ts @@ -1,21 +1,20 @@ /** * Built-in module loader — discovers and registers plugin modules. * Unlike plugin-loader.ts (external ~/.teleton/plugins/), this handles - * first-party modules that ship with the codebase (deals, etc.) + * first-party modules that ship with the codebase (ton-proxy, exec). */ import type { PluginModule } from "./types.js"; import type { ToolRegistry } from "./registry.js"; import type { Config } from "../../config/schema.js"; import type Database from "better-sqlite3"; -import dealsModule from "../../deals/module.js"; import tonProxyModule from "../../ton-proxy/module.js"; import { execModule } from "./exec/index.js"; import { createLogger } from "../../utils/logger.js"; const log = createLogger("ModuleLoader"); -const BUILTIN_MODULES: PluginModule[] = [dealsModule, tonProxyModule, execModule]; +const BUILTIN_MODULES: PluginModule[] = [tonProxyModule, execModule]; export function loadModules( registry: ToolRegistry, diff --git a/src/agent/tools/plugin-validator.ts b/src/agent/tools/plugin-validator.ts index f81dc48c..8ab9ab42 100644 --- a/src/agent/tools/plugin-validator.ts +++ b/src/agent/tools/plugin-validator.ts @@ -122,6 +122,5 @@ export function sanitizeConfigForPlugins(config: Config): Record = async ( - params, - context -): Promise => { - try { - const { userId, giftId, message, anonymous = false } = params; - - if (!hasVerifiedDeal(giftId, userId)) { - return { - success: false, - error: `Security restriction: Cannot send gifts without a verified deal. This tool is only available during authorized trades. If you want to trade, propose a deal first using deal_propose.`, - }; - } - - const gramJsClient = getClient(context.bridge); - - const user = await gramJsClient.getInputEntity(userId); - - const invoiceData = { - peer: user, - giftId: toLong(giftId), - hideName: anonymous, - message: message ? new Api.TextWithEntities({ text: message, entities: [] }) : undefined, - }; - - const form = await gramJsClient.invoke( - new Api.payments.GetPaymentForm({ - invoice: new Api.InputInvoiceStarGift(invoiceData), - }) - ); - - await gramJsClient.invoke( - new Api.payments.SendStarsForm({ - formId: form.formId, - invoice: new Api.InputInvoiceStarGift(invoiceData), - }) - ); - - return { - success: true, - data: { - recipient: userId, - giftId, - message, - anonymous, - }, - }; - } catch (error) { - log.error({ err: error }, "Error sending gift"); - - const errorMsg = getErrorMessage(error); - if (errorMsg.includes("BALANCE_TOO_LOW")) { - return { - success: false, - error: "Insufficient Stars balance to purchase this gift.", - }; - } - if (errorMsg.includes("STARGIFT_SOLDOUT")) { - return { - success: false, - error: "This limited gift is sold out.", - }; - } - - return { - success: false, - error: errorMsg, - }; - } -}; diff --git a/src/agent/tools/telegram/gifts/transfer-collectible.ts b/src/agent/tools/telegram/gifts/transfer-collectible.ts deleted file mode 100644 index 89ec68ae..00000000 --- a/src/agent/tools/telegram/gifts/transfer-collectible.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { Type } from "@sinclair/typebox"; -import { Api } from "telegram"; -import type { Tool, ToolExecutor, ToolResult } from "../../types.js"; -import { hasVerifiedDeal } from "../../../../deals/module.js"; -import { getErrorMessage } from "../../../../utils/errors.js"; -import { createLogger } from "../../../../utils/logger.js"; -import { getClient } from "../../../../sdk/telegram-utils.js"; - -const log = createLogger("Tools"); - -/** - * Parameters for transferring a collectible - */ -interface TransferCollectibleParams { - msgId: number; - toUserId: string; -} - -/** - * Tool definition for transferring a collectible gift - */ -export const telegramTransferCollectibleTool: Tool = { - name: "telegram_transfer_collectible", - description: - "Transfer a collectible gift to another user. ALWAYS confirm with the owner before transferring. May cost Stars (see transferStars in telegram_get_my_gifts). Collectibles only.", - parameters: Type.Object({ - msgId: Type.Number({ - description: - "The msgId of the collectible gift to transfer (from telegram_get_my_gifts). This is the message ID where the gift was received.", - }), - toUserId: Type.String({ - description: "User ID or @username of the recipient", - }), - }), -}; - -/** - * Executor for telegram_transfer_collectible tool - */ -export const telegramTransferCollectibleExecutor: ToolExecutor = async ( - params, - context -): Promise => { - try { - const { msgId, toUserId } = params; - - // SECURITY: Check if there's a verified deal authorizing this transfer - // This prevents social engineering attacks where users trick the agent into sending collectibles - if (!hasVerifiedDeal(msgId.toString(), toUserId)) { - return { - success: false, - error: `Security restriction: Cannot transfer collectibles without a verified deal. This tool is only available during authorized trades. If you want to trade, propose a deal first using deal_propose.`, - }; - } - - const gramJsClient = getClient(context.bridge); - - // Validate msgId - if (!msgId || typeof msgId !== "number") { - return { - success: false, - error: - "Invalid msgId. Use telegram_get_my_gifts to get the correct msgId of your collectible.", - }; - } - - // Get recipient as InputPeer (required by the API) - const toUser = await gramJsClient.getInputEntity(toUserId); - - // Build the stargift input reference - const stargiftInput = new Api.InputSavedStarGiftUser({ - msgId: msgId, - }); - - // First try free transfer - try { - await gramJsClient.invoke( - new Api.payments.TransferStarGift({ - stargift: stargiftInput, - toId: toUser, - }) - ); - - return { - success: true, - data: { - msgId, - transferredTo: toUserId, - paidTransfer: false, - message: "Collectible transferred successfully (free transfer)!", - }, - }; - } catch (freeTransferError: unknown) { - // If PAYMENT_REQUIRED, the transfer requires Stars - use payment flow - if (getErrorMessage(freeTransferError).includes("PAYMENT_REQUIRED")) { - log.info("Transfer requires payment, using payment flow..."); - - // Create invoice for paid transfer - const invoice = new Api.InputInvoiceStarGiftTransfer({ - stargift: stargiftInput, - toId: toUser, - }); - - // Get payment form - const form = await gramJsClient.invoke( - new Api.payments.GetPaymentForm({ - invoice: invoice, - }) - ); - - // Extract transfer cost from form - const transferCost = form.invoice?.prices?.[0]?.amount?.toString() || "unknown"; - - // Complete the payment - await gramJsClient.invoke( - new Api.payments.SendStarsForm({ - formId: form.formId, - invoice: invoice, - }) - ); - - return { - success: true, - data: { - msgId, - transferredTo: toUserId, - paidTransfer: true, - starsSpent: transferCost, - message: `Collectible transferred successfully! ${transferCost} Stars were deducted.`, - }, - }; - } - - // Re-throw if it's a different error - throw freeTransferError; - } - } catch (error: unknown) { - log.error({ err: error }, "Error transferring collectible"); - - const errorMsg = getErrorMessage(error); - - if (errorMsg.includes("STARGIFT_NOT_FOUND")) { - return { - success: false, - error: - "Collectible not found. Make sure you own it and it's a collectible (upgraded gift).", - }; - } - - if (errorMsg.includes("BALANCE_TOO_LOW")) { - return { - success: false, - error: - "Insufficient Stars balance to pay the transfer fee. Check your balance with telegram_get_stars_balance.", - }; - } - - if (errorMsg.includes("PEER_ID_INVALID")) { - return { - success: false, - error: "Invalid recipient. Make sure the user ID or username is correct.", - }; - } - - return { - success: false, - error: errorMsg, - }; - } -}; diff --git a/src/agent/tools/types.ts b/src/agent/tools/types.ts index 37aae7db..e564e306 100644 --- a/src/agent/tools/types.ts +++ b/src/agent/tools/types.ts @@ -128,7 +128,7 @@ export interface ToolEntry { /** * Built-in plugin module interface. - * Modules are self-contained feature packs (deals, etc.) + * Modules are self-contained feature packs (ton-proxy, exec, etc.) * that register their own tools, config, and migrations. */ export interface PluginModule { diff --git a/src/bot/index.ts b/src/bot/index.ts deleted file mode 100644 index 6a09a815..00000000 --- a/src/bot/index.ts +++ /dev/null @@ -1,510 +0,0 @@ -/** - * Telegram Bot for inline deal confirmations. - * Grammy (Bot API) + GramJS (MTProto styled buttons). - */ - -import { Bot, type MiddlewareFn, type Context } from "grammy"; -import type Database from "better-sqlite3"; -import { getGramJSErrorMessage, getGrammyErrorDescription } from "../utils/errors.js"; -import type { BotConfig, DealContext } from "./types.js"; -import { DEAL_VERIFICATION_WINDOW_SECONDS } from "../constants/limits.js"; -import { decodeCallback } from "./types.js"; -import { - getDeal, - acceptDeal, - declineDeal, - claimPayment, - setInlineMessageId, - isDealExpired, - expireDeal, -} from "./services/deal-service.js"; -import { - buildAcceptedMessage, - buildVerifyingMessage, - buildDeclinedMessage, - buildExpiredMessage, - buildNotFoundMessage, - buildMessageForState, -} from "./services/message-builder.js"; -import { - toGrammyKeyboard, - hasStyledButtons, - stripCustomEmoji, - type StyledButtonDef, -} from "../sdk/formatting.js"; -import { - answerInlineQueryStyled, - editInlineViaGramJS, - editInlineViaGramJSStrict, -} from "./services/inline-transport.js"; -import { GramJSBotClient } from "./gramjs-bot.js"; -import { getWalletAddress } from "../ton/wallet-service.js"; -import { createLogger } from "../utils/logger.js"; - -const log = createLogger("Bot"); - -export class DealBot { - private bot: Bot; - private db: Database.Database; - private config: BotConfig; - private gramjsBot: GramJSBotClient | null = null; - - constructor(config: BotConfig, db: Database.Database, preMiddleware?: MiddlewareFn) { - this.config = config; - this.db = db; - this.bot = new Bot(config.token); - - if (config.apiId && config.apiHash) { - this.gramjsBot = new GramJSBotClient(config.apiId, config.apiHash, config.gramjsSessionPath); - } - - // Install pre-middleware BEFORE DealBot handlers (e.g. plugin inline router) - if (preMiddleware) { - this.bot.use(preMiddleware); - } - - this.setupHandlers(); - } - - private setupHandlers(): void { - this.bot.on("inline_query", async (ctx) => { - const query = ctx.inlineQuery.query.trim(); - const queryId = ctx.inlineQuery.id; - const userId = ctx.from.id; - - log.info(`[Bot] Inline query from ${userId}: "${query}"`); - - const dealId = query; - - if (!dealId) { - await ctx.answerInlineQuery( - [ - { - type: "article", - id: "help", - title: "How to use", - description: "Type the deal ID to confirm it", - input_message_content: { - message_text: - "Type @" + this.config.username + " followed by the deal ID to confirm it.", - }, - }, - ], - { cache_time: 0 } - ); - return; - } - - const deal = getDeal(this.db, dealId); - - if (!deal) { - await ctx.answerInlineQuery( - [ - { - type: "article", - id: "not_found", - title: "❌ Deal not found", - description: `Deal #${dealId} does not exist`, - input_message_content: { - message_text: buildNotFoundMessage(dealId), - parse_mode: "HTML", - }, - }, - ], - { cache_time: 0 } - ); - return; - } - - if (isDealExpired(deal) && deal.status === "proposed") { - expireDeal(this.db, dealId); - deal.status = "expired"; - } - - const agentWallet = getWalletAddress() || ""; - const { text, buttons } = buildMessageForState(deal, agentWallet); - - if (this.gramjsBot?.isConnected() && hasStyledButtons(buttons)) { - try { - await answerInlineQueryStyled({ - gramjsBot: this.gramjsBot, - queryId, - resultId: dealId, - title: `📋 Deal #${dealId}`, - description: this.formatShortDescription(deal), - html: text, - buttons, - }); - return; - } catch (error) { - log.warn({ err: error }, "[Bot] GramJS styled answer failed, falling back to Grammy"); - } - } - - const keyboard = toGrammyKeyboard(buttons); - await ctx.answerInlineQuery( - [ - { - type: "article", - id: dealId, - title: `📋 Deal #${dealId}`, - description: this.formatShortDescription(deal), - input_message_content: { - message_text: stripCustomEmoji(text), - parse_mode: "HTML", - link_preview_options: { is_disabled: true }, - }, - reply_markup: hasStyledButtons(buttons) ? keyboard : undefined, - }, - ], - { cache_time: 0 } - ); - }); - - this.bot.on("chosen_inline_result", async (ctx) => { - const resultId = ctx.chosenInlineResult.result_id; - const inlineMessageId = ctx.chosenInlineResult.inline_message_id; - - if ( - inlineMessageId && - resultId !== "help" && - resultId !== "not_found" && - resultId !== "wrong_user" - ) { - setInlineMessageId(this.db, resultId, inlineMessageId); - - const deal = getDeal(this.db, resultId); - if (deal) { - const agentWallet = getWalletAddress() || ""; - const { text, buttons } = buildMessageForState(deal, agentWallet); - - let edited = false; - if (this.gramjsBot?.isConnected()) { - try { - await editInlineViaGramJSStrict({ - gramjsBot: this.gramjsBot, - inlineMessageId, - html: text, - buttons, - }); - edited = true; - } catch (error: unknown) { - const errMsg = getGramJSErrorMessage(error); - log.warn( - { err: error }, - `[Bot] chosen_inline_result GramJS edit failed: ${errMsg || error}` - ); - } - } - - if (!edited) { - try { - const keyboard = hasStyledButtons(buttons) ? toGrammyKeyboard(buttons) : undefined; - await this.bot.api.editMessageTextInline(inlineMessageId, stripCustomEmoji(text), { - parse_mode: "HTML", - link_preview_options: { is_disabled: true }, - reply_markup: keyboard, - }); - } catch (error: unknown) { - const errDesc = getGrammyErrorDescription(error); - log.error( - { err: error }, - `[Bot] chosen_inline_result Grammy fallback failed: ${errDesc || error}` - ); - } - } - } - } - }); - - this.bot.on("callback_query:data", async (ctx) => { - const data = decodeCallback(ctx.callbackQuery.data); - if (!data) { - await ctx.answerCallbackQuery({ text: "Invalid action" }); - return; - } - - const userId = ctx.from.id; - const { action, dealId } = data; - - log.info(`[Bot] Callback from ${userId}: ${action} on deal ${dealId}`); - - const inlineMsgId = ctx.callbackQuery.inline_message_id; - if (inlineMsgId) { - setInlineMessageId(this.db, dealId, inlineMsgId); - } - - const deal = getDeal(this.db, dealId); - if (!deal) { - await ctx.answerCallbackQuery({ text: "Deal not found" }); - return; - } - - if (inlineMsgId && !deal.inlineMessageId) { - deal.inlineMessageId = inlineMsgId; - } - - if (deal.userId !== userId) { - await ctx.answerCallbackQuery({ text: "This is not your deal!", show_alert: true }); - return; - } - - if (isDealExpired(deal) && ["proposed", "accepted"].includes(deal.status)) { - expireDeal(this.db, dealId); - const { text, buttons } = buildExpiredMessage(deal); - await this.editInlineMessage(ctx, text, buttons); - await ctx.answerCallbackQuery({ text: "Deal expired!" }); - return; - } - - switch (action) { - case "accept": - await this.handleAccept(ctx, deal); - break; - case "decline": - await this.handleDecline(ctx, deal); - break; - case "sent": - await this.handleSent(ctx, deal); - break; - case "copy_addr": - await this.handleCopyAddress(ctx); - break; - case "copy_memo": - await this.handleCopyMemo(ctx, deal); - break; - case "refresh": - await this.handleRefresh(ctx, deal); - break; - } - }); - - this.bot.catch((err) => { - log.error({ err }, "[Bot] Error"); - }); - } - - private async handleAccept(ctx: Context, deal: DealContext): Promise { - if (deal.status !== "proposed") { - await ctx.answerCallbackQuery({ text: "Already processed" }); - return; - } - - acceptDeal(this.db, deal.dealId); - deal.status = "accepted"; - deal.expiresAt = Math.floor(Date.now() / 1000) + DEAL_VERIFICATION_WINDOW_SECONDS; - - const agentWallet = getWalletAddress() || ""; - const { text, buttons } = buildAcceptedMessage(deal, agentWallet); - - await this.editInlineMessage(ctx, text, buttons); - await ctx.answerCallbackQuery({ text: "✅ Deal accepted!" }); - - log.info(`[Bot] Deal ${deal.dealId} accepted by ${deal.userId}`); - } - - private async handleDecline(ctx: Context, deal: DealContext): Promise { - if (deal.status !== "proposed") { - await ctx.answerCallbackQuery({ text: "Already processed" }); - return; - } - - declineDeal(this.db, deal.dealId); - deal.status = "declined"; - - const { text, buttons } = buildDeclinedMessage(deal); - - await this.editInlineMessage(ctx, text, buttons); - await ctx.answerCallbackQuery({ text: "❌ Deal declined" }); - - log.info(`[Bot] Deal ${deal.dealId} declined by ${deal.userId}`); - } - - private async handleSent(ctx: Context, deal: DealContext): Promise { - if (deal.status !== "accepted") { - await ctx.answerCallbackQuery({ text: "Not available" }); - return; - } - - claimPayment(this.db, deal.dealId); - deal.status = "payment_claimed"; - - const { text, buttons } = buildVerifyingMessage(deal); - - await this.editInlineMessage(ctx, text, buttons); - await ctx.answerCallbackQuery({ text: "⏳ Verifying..." }); - - log.info(`[Bot] Deal ${deal.dealId} payment claimed by ${deal.userId}`); - } - - private async handleCopyAddress(ctx: Context): Promise { - const agentWallet = getWalletAddress() || ""; - await ctx.answerCallbackQuery({ - text: `📋 Address: ${agentWallet}`, - show_alert: true, - }); - } - - private async handleCopyMemo(ctx: Context, deal: DealContext): Promise { - await ctx.answerCallbackQuery({ - text: `📋 Memo: ${deal.dealId}`, - show_alert: true, - }); - } - - private async handleRefresh(ctx: Context, deal: DealContext): Promise { - // Reload deal from DB - const freshDeal = getDeal(this.db, deal.dealId); - if (!freshDeal) { - await ctx.answerCallbackQuery({ text: "Deal not found" }); - return; - } - - // Update message with current state - const agentWallet = getWalletAddress() || ""; - const { text, buttons } = buildMessageForState(freshDeal, agentWallet); - - await this.editInlineMessage(ctx, text, buttons); - await ctx.answerCallbackQuery({ text: "🔄 Refreshed" }); - } - - private async editInlineMessage( - ctx: Context, - text: string, - buttons: StyledButtonDef[][] - ): Promise { - const inlineMsgId = ctx.callbackQuery?.inline_message_id; - if (!inlineMsgId) return; - - if (this.gramjsBot?.isConnected()) { - try { - await editInlineViaGramJS({ - gramjsBot: this.gramjsBot, - inlineMessageId: inlineMsgId, - html: text, - buttons, - }); - return; - } catch (error: unknown) { - log.warn( - { err: error }, - `[Bot] GramJS edit failed, falling back to Grammy: ${getGramJSErrorMessage(error) || error}` - ); - } - } - - try { - const keyboard = hasStyledButtons(buttons) ? toGrammyKeyboard(buttons) : undefined; - await ctx.editMessageText(stripCustomEmoji(text), { - parse_mode: "HTML", - link_preview_options: { is_disabled: true }, - reply_markup: keyboard, - }); - } catch (error: unknown) { - const desc = getGrammyErrorDescription(error); - if (desc?.includes("message is not modified")) return; - log.error({ err: error }, "[Bot] Failed to edit inline message"); - } - } - - async editMessageByInlineId( - inlineMessageId: string, - text: string, - buttons?: StyledButtonDef[][] - ): Promise { - if (this.gramjsBot?.isConnected() && buttons) { - try { - await editInlineViaGramJS({ - gramjsBot: this.gramjsBot, - inlineMessageId, - html: text, - buttons, - }); - return; - } catch (error: unknown) { - log.warn( - { err: error }, - `[Bot] GramJS edit failed, falling back to Grammy: ${getGramJSErrorMessage(error) || error}` - ); - } - } - - try { - const keyboard = buttons && hasStyledButtons(buttons) ? toGrammyKeyboard(buttons) : undefined; - await this.bot.api.editMessageTextInline(inlineMessageId, stripCustomEmoji(text), { - parse_mode: "HTML", - link_preview_options: { is_disabled: true }, - reply_markup: keyboard, - }); - } catch (error) { - log.error({ err: error }, "[Bot] Failed to edit message by inline ID"); - } - } - - private formatShortDescription(deal: DealContext): string { - const userGives = - deal.userGivesType === "ton" - ? `${deal.userGivesTonAmount} TON` - : deal.userGivesGiftSlug || "Gift"; - const agentGives = - deal.agentGivesType === "ton" - ? `${deal.agentGivesTonAmount} TON` - : deal.agentGivesGiftSlug || "Gift"; - return `${userGives} → ${agentGives}`; - } - - /** - * Start the bot (non-blocking - long polling runs in background) - */ - async start(): Promise { - log.info(`[Bot] Starting @${this.config.username}...`); - - // Connect GramJS bot for styled buttons (best-effort) - if (this.gramjsBot) { - try { - await this.gramjsBot.connect(this.config.token); - } catch { - log.warn("[Bot] GramJS MTProto connection failed, buttons will be unstyled"); - this.gramjsBot = null; - } - } - - // bot.init() fetches bot info without starting long polling - await this.bot.init(); - // bot.start() launches long polling - do NOT await (it blocks forever) - this.bot - .start({ - onStart: () => log.info(`[Bot] @${this.config.username} polling started`), - }) - .catch((err) => { - log.error({ err }, "[Bot] Polling error"); - }); - } - - /** - * Stop the bot - */ - async stop(): Promise { - log.info(`[Bot] Stopping @${this.config.username}...`); - await this.bot.stop(); - if (this.gramjsBot) { - await this.gramjsBot.disconnect(); - } - } - - /** - * Get Grammy bot instance for external access - */ - getBot(): Bot { - return this.bot; - } - - /** - * Get GramJS bot client for MTProto operations (styled buttons, inline edits) - */ - getGramJSBot(): GramJSBotClient | null { - return this.gramjsBot; - } -} - -export { VerificationPoller } from "./services/verification-poller.js"; diff --git a/src/bot/inline-router.ts b/src/bot/inline-router.ts index 665a8244..254a695a 100644 --- a/src/bot/inline-router.ts +++ b/src/bot/inline-router.ts @@ -2,8 +2,7 @@ * Inline router — Grammy middleware that routes inline queries and callbacks * to registered plugin handlers by prefix. * - * Installed BEFORE DealBot handlers so plugins get first crack. - * Queries/callbacks without a known prefix fall through to DealBot via next(). + * Queries/callbacks without a known plugin prefix fall through via next(). */ import type { Context, MiddlewareFn } from "grammy"; @@ -76,14 +75,10 @@ export class InlineRouter { } // ── Prefix routing contract ────────────────────────────────────────────── - // This middleware is installed BEFORE DealBot in the Grammy chain. It peels a - // `prefix:rest` segment off inline queries / callback data / chosen-result ids - // (via splitPrefix) and dispatches to the matching registered plugin. - // - // A prefix is RESERVED for DealBot — accept · decline · sent · copy_addr · - // copy_memo · refresh (see decodeCallback in types.ts) — and plugins must not - // register those names. Any prefix without a registered plugin handler (or no - // colon at all) falls through via next() to DealBot's own handlers. + // This middleware peels a `prefix:rest` segment off inline queries / callback + // data / chosen-result ids (via splitPrefix) and dispatches to the matching + // registered plugin. Any prefix without a registered plugin handler (or no + // colon at all) falls through via next(). middleware(): MiddlewareFn { return async (ctx, next) => { // ── Inline Query ───────────────────────────────── @@ -96,7 +91,7 @@ export class InlineRouter { return; // handled, don't fall through } } - // No match — fall through to DealBot + // No match — fall through return next(); } diff --git a/src/bot/services/deal-service.ts b/src/bot/services/deal-service.ts deleted file mode 100644 index 382be3e4..00000000 --- a/src/bot/services/deal-service.ts +++ /dev/null @@ -1,202 +0,0 @@ -/** - * Deal service - database operations for deals - */ - -import type Database from "better-sqlite3"; -import type { DealContext, DealStatus } from "../types.js"; -import { DEAL_VERIFICATION_WINDOW_SECONDS } from "../../constants/limits.js"; - -/** Column list selected by every deal query, mapped by rowToDeal. */ -const DEAL_COLUMNS = `id, user_telegram_id, user_username, chat_id, - user_gives_type, user_gives_ton_amount, user_gives_gift_slug, user_gives_value_ton, - agent_gives_type, agent_gives_ton_amount, agent_gives_gift_slug, agent_gives_value_ton, - profit_ton, status, created_at, expires_at, - inline_message_id, payment_claimed_at, user_payment_verified_at, completed_at`; - -interface DealRow { - id: string; - user_telegram_id: number; - user_username: string | null; - chat_id: string; - user_gives_type: string; - user_gives_ton_amount: number | null; - user_gives_gift_slug: string | null; - user_gives_value_ton: number | null; - agent_gives_type: string; - agent_gives_ton_amount: number | null; - agent_gives_gift_slug: string | null; - agent_gives_value_ton: number | null; - profit_ton: number | null; - status: string; - created_at: number; - expires_at: number; - inline_message_id: string | null; - payment_claimed_at: number | null; - user_payment_verified_at: number | null; - completed_at: number | null; -} - -function rowToDeal(row: DealRow): DealContext { - return { - dealId: row.id, - userId: row.user_telegram_id, - username: row.user_username ?? undefined, - chatId: row.chat_id, - userGivesType: row.user_gives_type as DealContext["userGivesType"], - userGivesTonAmount: row.user_gives_ton_amount ?? undefined, - userGivesGiftSlug: row.user_gives_gift_slug ?? undefined, - userGivesValueTon: row.user_gives_value_ton ?? 0, - agentGivesType: row.agent_gives_type as DealContext["agentGivesType"], - agentGivesTonAmount: row.agent_gives_ton_amount ?? undefined, - agentGivesGiftSlug: row.agent_gives_gift_slug ?? undefined, - agentGivesValueTon: row.agent_gives_value_ton ?? 0, - profitTon: row.profit_ton ?? 0, - status: row.status as DealStatus, - createdAt: row.created_at, - expiresAt: row.expires_at, - inlineMessageId: row.inline_message_id ?? undefined, - paymentClaimedAt: row.payment_claimed_at ?? undefined, - verifiedAt: row.user_payment_verified_at ?? undefined, - completedAt: row.completed_at ?? undefined, - }; -} - -/** - * Get deal by ID - */ -export function getDeal(db: Database.Database, dealId: string): DealContext | null { - const row = db - .prepare( - `SELECT - ${DEAL_COLUMNS} - FROM deals WHERE id = ?` - ) - .get(dealId) as DealRow | undefined; - - if (!row) return null; - - return rowToDeal(row); -} - -/** - * Mark deal as accepted and extend expiry to 5 minutes from now - */ -export function acceptDeal(db: Database.Database, dealId: string): boolean { - const newExpiry = Math.floor(Date.now() / 1000) + DEAL_VERIFICATION_WINDOW_SECONDS; - const r = db - .prepare( - `UPDATE deals SET status = 'accepted', expires_at = ? WHERE id = ? AND status = 'proposed'` - ) - .run(newExpiry, dealId); - return r.changes === 1; -} - -/** - * Mark deal as declined - */ -export function declineDeal(db: Database.Database, dealId: string): boolean { - const r = db - .prepare(`UPDATE deals SET status = 'declined' WHERE id = ? AND status = 'proposed'`) - .run(dealId); - return r.changes === 1; -} - -/** - * Mark payment as claimed (user clicked "I've sent") - */ -export function claimPayment(db: Database.Database, dealId: string): boolean { - const r = db - .prepare( - `UPDATE deals SET status = 'payment_claimed', payment_claimed_at = unixepoch() WHERE id = ? AND status = 'accepted'` - ) - .run(dealId); - return r.changes === 1; -} - -/** - * Store inline message ID for later editing - */ -export function setInlineMessageId( - db: Database.Database, - dealId: string, - inlineMessageId: string -): void { - db.prepare(`UPDATE deals SET inline_message_id = ? WHERE id = ?`).run(inlineMessageId, dealId); -} - -/** - * Check if deal is expired - */ -export function isDealExpired(deal: DealContext): boolean { - const now = Math.floor(Date.now() / 1000); - return now > deal.expiresAt; -} - -/** - * Mark deal as expired - */ -export function expireDeal(db: Database.Database, dealId: string): boolean { - const r = db - .prepare( - `UPDATE deals SET status = 'expired' WHERE id = ? AND status IN ('proposed', 'accepted')` - ) - .run(dealId); - return r.changes === 1; -} - -/** - * Get deals pending verification (payment_claimed status) - */ -export function getDealsAwaitingVerification(db: Database.Database): DealContext[] { - const rows = db - .prepare( - `SELECT - ${DEAL_COLUMNS} - FROM deals - WHERE status = 'payment_claimed' - ORDER BY payment_claimed_at ASC - LIMIT 10` - ) - .all() as DealRow[]; - - return rows.map(rowToDeal); -} - -/** - * Update user trade stats - */ -export function updateUserStats( - db: Database.Database, - userId: number, - username: string | undefined, - deal: DealContext, - completed: boolean -): void { - // Upsert user stats - db.prepare( - `INSERT INTO user_trade_stats (telegram_id, username, total_deals, last_deal_at) - VALUES (?, ?, 1, unixepoch()) - ON CONFLICT(telegram_id) DO UPDATE SET - username = COALESCE(?, username), - total_deals = total_deals + 1, - last_deal_at = unixepoch()` - ).run(userId, username, username); - - if (completed) { - // Update completed stats - const tonSent = deal.userGivesType === "ton" ? deal.userGivesTonAmount || 0 : 0; - const tonReceived = deal.agentGivesType === "ton" ? deal.agentGivesTonAmount || 0 : 0; - const giftsSent = deal.userGivesType === "gift" ? 1 : 0; - const giftsReceived = deal.agentGivesType === "gift" ? 1 : 0; - - db.prepare( - `UPDATE user_trade_stats SET - completed_deals = completed_deals + 1, - total_ton_sent = total_ton_sent + ?, - total_ton_received = total_ton_received + ?, - total_gifts_sent = total_gifts_sent + ?, - total_gifts_received = total_gifts_received + ? - WHERE telegram_id = ?` - ).run(tonSent, tonReceived, giftsSent, giftsReceived, userId); - } -} diff --git a/src/bot/services/inline-transport.ts b/src/bot/services/inline-transport.ts index 84c86413..d281aa40 100644 --- a/src/bot/services/inline-transport.ts +++ b/src/bot/services/inline-transport.ts @@ -3,10 +3,10 @@ * * Encapsulates every GramJS (MTProto) operation the bot performs on inline * messages — answering inline queries with styled buttons and editing inline - * messages — so DealBot and the plugin SDK stay free of transport plumbing. + * messages — so the plugin SDK stays free of transport plumbing. * * Depends only on a GramJSBotClient and the pure formatting helpers; it has no - * knowledge of deals or business logic. Each higher-level caller keeps its own + * knowledge of business logic. Each higher-level caller keeps its own * Grammy (Bot API) fallback. */ diff --git a/src/bot/services/message-builder.ts b/src/bot/services/message-builder.ts deleted file mode 100644 index 946adc7f..00000000 --- a/src/bot/services/message-builder.ts +++ /dev/null @@ -1,244 +0,0 @@ -/** - * Message builder for deal bot - * Generates formatted messages and styled button definitions for each deal state. - * Uses HTML parse mode (Telegram Bot API) for text formatting. - * Returns StyledButtonDef[][] for buttons, which callers convert to either: - * - GramJS TL objects (with colors, for MTProto) - * - Grammy InlineKeyboard (no colors, for Bot API fallback) - */ - -import type { DealContext } from "../types.js"; -import type { StyledButtonDef, DealMessage } from "../../sdk/formatting.js"; - -/** - * Escape HTML special characters - */ -function esc(text: string): string { - return text.replace(/&/g, "&").replace(//g, ">"); -} - -/** Custom emoji for animated hourglass (only one kept as premium) */ -const TIMER_EMOJI = ``; - -/** - * Format asset for display (with NFT link for gifts) - * giftSlug is the API slug (e.g. "LolPop-425402"), used directly in URL - */ -function formatAsset(type: "ton" | "gift", tonAmount?: number, giftSlug?: string): string { - if (type === "ton" && tonAmount) { - return `${tonAmount} TON`; - } - if (type === "gift" && giftSlug) { - return `${esc(giftSlug)}`; - } - return "???"; -} - -/** What the user gives in a deal, formatted. */ -function formatUserGives(deal: DealContext): string { - return formatAsset(deal.userGivesType, deal.userGivesTonAmount, deal.userGivesGiftSlug); -} - -/** What the agent gives in a deal, formatted. */ -function formatAgentGives(deal: DealContext): string { - return formatAsset(deal.agentGivesType, deal.agentGivesTonAmount, deal.agentGivesGiftSlug); -} - -/** - * Format remaining time - */ -function getRemainingTime(expiresAt: number): string { - const now = Math.floor(Date.now() / 1000); - const remaining = expiresAt - now; - if (remaining <= 0) return "Expired"; - const mins = Math.floor(remaining / 60); - const secs = remaining % 60; - return mins > 0 ? `${mins}m ${secs}s` : `${secs}s`; -} - -/** - * Proposal state - Accept/Decline - */ -export function buildProposalMessage(deal: DealContext): DealMessage { - const userGives = formatUserGives(deal); - const agentGives = formatAgentGives(deal); - const remaining = getRemainingTime(deal.expiresAt); - const user = deal.username ? `@${esc(deal.username)}` : `${deal.userId}`; - - const text = `👛 Deal #${esc(deal.dealId)} - -👤 ${user} -📤 Sends: ${userGives} -📥 Receives: ${agentGives} -${TIMER_EMOJI} Expires in ${remaining}`; - - const buttons: StyledButtonDef[][] = [ - [ - { text: "✅ Accept", callbackData: `accept:${deal.dealId}`, style: "success" }, - { text: "❌ Decline", callbackData: `decline:${deal.dealId}`, style: "danger" }, - ], - ]; - - return { text, buttons }; -} - -/** - * Accepted state - Payment/gift instructions - */ -export function buildAcceptedMessage(deal: DealContext, agentWallet: string): DealMessage { - const userGives = formatUserGives(deal); - const agentGives = formatAgentGives(deal); - - let instructions: string; - let buttons: StyledButtonDef[][]; - - if (deal.userGivesType === "ton") { - instructions = ` -💰 Send ${deal.userGivesTonAmount} TON -📍 ${esc(agentWallet)} -📝 Memo: ${esc(deal.dealId)} - -‼️ Memo is required!`; - - buttons = [ - [ - { text: "Copy Address", callbackData: `copy_addr:${deal.dealId}`, copyText: agentWallet }, - { text: "Copy Memo", callbackData: `copy_memo:${deal.dealId}`, copyText: deal.dealId }, - ], - [{ text: "✅ I've sent the payment", callbackData: `sent:${deal.dealId}`, style: "primary" }], - ]; - } else { - instructions = ` -🎁 Send your ${formatAsset("gift", undefined, deal.userGivesGiftSlug)} to the agent`; - - buttons = [ - [{ text: "✅ I've sent the gift", callbackData: `sent:${deal.dealId}`, style: "success" }], - ]; - } - - const remaining = getRemainingTime(deal.expiresAt); - - const text = `✅ Deal Accepted #${esc(deal.dealId)} - -📤 You send: ${userGives} -📥 You receive: ${agentGives} -${TIMER_EMOJI} ${remaining} to complete -${instructions}`; - - return { text, buttons }; -} - -/** - * Payment claimed - Verifying - */ -export function buildVerifyingMessage(deal: DealContext): DealMessage { - const itemType = deal.userGivesType === "ton" ? "payment" : "gift"; - - const text = `${TIMER_EMOJI} Verifying ${itemType}... - -Deal #${esc(deal.dealId)} - -This usually takes 10-30 seconds.`; - - const buttons: StyledButtonDef[][] = [ - [{ text: "🔄 Refresh", callbackData: `refresh:${deal.dealId}`, style: "primary" }], - ]; - - return { text, buttons }; -} - -/** - * Verified - Sending agent's part - */ -export function buildSendingMessage(deal: DealContext): DealMessage { - const agentGives = formatAgentGives(deal); - - const text = `✅ Payment Verified - -Deal #${esc(deal.dealId)} -Sending ${agentGives}...`; - - return { text, buttons: [] }; -} - -/** - * Completed - Final recap - */ -export function buildCompletedMessage(deal: DealContext): DealMessage { - const userGives = formatUserGives(deal); - const agentGives = formatAgentGives(deal); - const user = deal.username ? `@${esc(deal.username)}` : `${deal.userId}`; - - const duration = deal.completedAt - ? Math.floor(deal.completedAt - deal.createdAt) - : Math.floor(Date.now() / 1000 - deal.createdAt); - - const text = `🤝 Deal Complete #${esc(deal.dealId)} - -👤 ${user} -📤 Sent: ${userGives} ✓ -📥 Received: ${agentGives} ✓ -${TIMER_EMOJI} ${duration}s`; - - return { text, buttons: [] }; -} - -/** - * Declined - */ -export function buildDeclinedMessage(deal: DealContext): DealMessage { - const text = `❌ Deal Declined #${esc(deal.dealId)}`; - return { text, buttons: [] }; -} - -/** - * Expired - */ -export function buildExpiredMessage(deal: DealContext): DealMessage { - const text = `${TIMER_EMOJI} Deal Expired #${esc(deal.dealId)}`; - return { text, buttons: [] }; -} - -/** - * Failed - */ -export function buildFailedMessage(deal: DealContext, error?: string): DealMessage { - const text = `❌ Deal Failed #${esc(deal.dealId)} - -${esc(error || "Unknown error")}`; - - return { text, buttons: [] }; -} - -/** - * Not found - */ -export function buildNotFoundMessage(dealId: string): string { - return `❌ Deal #${esc(dealId)} not found.`; -} - -/** - * Route to correct message builder based on deal status - */ -export function buildMessageForState(deal: DealContext, agentWallet?: string): DealMessage { - switch (deal.status) { - case "proposed": - return buildProposalMessage(deal); - case "accepted": - return buildAcceptedMessage(deal, agentWallet || ""); - case "payment_claimed": - return buildVerifyingMessage(deal); - case "verified": - return buildSendingMessage(deal); - case "completed": - return buildCompletedMessage(deal); - case "declined": - return buildDeclinedMessage(deal); - case "expired": - return buildExpiredMessage(deal); - case "failed": - return buildFailedMessage(deal); - default: - return buildProposalMessage(deal); - } -} diff --git a/src/bot/services/verification-poller.ts b/src/bot/services/verification-poller.ts deleted file mode 100644 index fd97b153..00000000 --- a/src/bot/services/verification-poller.ts +++ /dev/null @@ -1,362 +0,0 @@ -/** - * Verification Poller - automatically verifies deals with payment_claimed status - * Runs in background, checking for TON payments and gift receipts - */ - -import type Database from "better-sqlite3"; -import type { ITelegramBridge } from "../../telegram/bridge-interface.js"; -import type { DealBot } from "../index.js"; -import type { DealContext } from "../types.js"; -import type { ToolContext } from "../../agent/tools/types.js"; -import { getDealsAwaitingVerification, updateUserStats } from "./deal-service.js"; -import { - buildSendingMessage, - buildCompletedMessage, - buildFailedMessage, -} from "./message-builder.js"; -import { verifyPayment } from "../../ton/payment-verifier.js"; -import { getWalletAddress } from "../../ton/wallet-service.js"; -import { executeDeal } from "../../deals/executor.js"; -import { DEALS_CONFIG } from "../../deals/config.js"; -import { createLogger } from "../../utils/logger.js"; - -interface VerifyGiftEntry { - slug: string; - fromId?: string; - date?: number; - msgId?: string; -} - -const log = createLogger("Poller"); - -interface PollerConfig { - pollIntervalMs: number; - maxRetries: number; - retryDelayMs: number; -} - -export class VerificationPoller { - private db: Database.Database; - private bridge: ITelegramBridge; - private bot: DealBot; - private config: PollerConfig; - private intervalId: NodeJS.Timeout | null = null; - private retryMap: Map = new Map(); // dealId → retry count - - constructor( - db: Database.Database, - bridge: ITelegramBridge, - bot: DealBot, - config: Partial = {} - ) { - this.db = db; - this.bridge = bridge; - this.bot = bot; - this.config = { ...DEALS_CONFIG.verification, ...config }; - } - - /** - * Start polling for deals awaiting verification - */ - start(): void { - if (this.intervalId) { - log.warn("[Poller] Already running"); - return; - } - - log.info(`[Poller] Started (interval: ${this.config.pollIntervalMs}ms)`); - - this.intervalId = setInterval(() => { - this.poll().catch((err) => log.error({ err }, "[Poller] Unhandled poll error")); - }, this.config.pollIntervalMs); - - // Run immediately - this.poll().catch((err) => log.error({ err }, "[Poller] Initial poll error")); - } - - /** - * Stop polling - */ - stop(): void { - if (this.intervalId) { - clearInterval(this.intervalId); - this.intervalId = null; - log.info("[Poller] Stopped"); - } - } - - /** - * Main poll cycle - */ - private async poll(): Promise { - try { - const deals = getDealsAwaitingVerification(this.db); - - if (deals.length === 0) return; - - for (const deal of deals) { - const retryCount = this.retryMap.get(deal.dealId) || 0; - if (retryCount === 0) { - log.info(`[Poller] Verifying deal ${deal.dealId}...`); - } - await this.verifyDeal(deal); - } - } catch (error) { - log.error({ err: error }, "[Poller] Error during poll"); - } - } - - /** - * Verify a single deal - */ - private async verifyDeal(deal: DealContext): Promise { - const retryCount = this.retryMap.get(deal.dealId) || 0; - - // Check max retries - if (retryCount >= this.config.maxRetries) { - log.info(`[Poller] Deal ${deal.dealId} verification timeout after ${retryCount} retries`); - await this.handleTimeout(deal); - return; - } - - try { - let verified = false; - let txHash: string | undefined; - let playerWallet: string | undefined; - let giftMsgId: string | undefined; - - // Case 1: User gives TON → verify blockchain - if (deal.userGivesType === "ton") { - const result = await this.verifyTonPayment(deal); - verified = result.verified; - txHash = result.txHash; - playerWallet = result.playerWallet; - } - // Case 2: User gives gift → check received gifts - else if (deal.userGivesType === "gift") { - const result = await this.verifyGiftReceipt(deal); - verified = result.verified; - giftMsgId = result.giftMsgId; - } - - if (verified) { - await this.handleVerified(deal, txHash, playerWallet, giftMsgId); - this.retryMap.delete(deal.dealId); - } else { - // Increment retry count - this.retryMap.set(deal.dealId, retryCount + 1); - } - } catch (error) { - log.error({ err: error }, `[Poller] Error verifying deal ${deal.dealId}`); - this.retryMap.set(deal.dealId, retryCount + 1); - } - } - - /** - * Verify TON payment on blockchain - */ - private async verifyTonPayment( - deal: DealContext - ): Promise<{ verified: boolean; txHash?: string; playerWallet?: string }> { - const botWallet = getWalletAddress(); - - if (!botWallet || !deal.userGivesTonAmount) { - return { verified: false }; - } - - const result = await verifyPayment(this.db, { - botWalletAddress: botWallet, - betAmount: deal.userGivesTonAmount, - requestTime: deal.createdAt * 1000, - gameType: `deal:${deal.dealId}`, - userId: deal.dealId, // memo = dealId - }); - - return { - verified: result.verified, - txHash: result.txHash, - playerWallet: result.playerWallet, - }; - } - - /** - * Verify gift receipt via Telegram API - */ - private async verifyGiftReceipt( - deal: DealContext - ): Promise<{ verified: boolean; giftMsgId?: string }> { - try { - // Get agent's own user ID - const ownUserId = this.bridge.getOwnUserId(); - if (!ownUserId) return { verified: false }; - - const botUserId = Number(ownUserId); - - // Import gift executor - const { telegramGetMyGiftsExecutor } = - await import("../../agent/tools/telegram/gifts/get-my-gifts.js"); - - const toolContext: ToolContext = { - bridge: this.bridge, - db: this.db, - chatId: deal.chatId, - isGroup: false, - senderId: deal.userId, - }; - - const result = await telegramGetMyGiftsExecutor( - { userId: botUserId.toString(), limit: 50 }, - toolContext - ); - - if (!result.success || !result.data) { - return { verified: false }; - } - - const verifyData = result.data as { gifts?: VerifyGiftEntry[] }; - const gifts = verifyData.gifts || []; - - // Find matching gift from user after deal creation - const matchingGift = gifts.find( - (g) => - g.slug === deal.userGivesGiftSlug && - Number(g.fromId) === deal.userId && - g.date && - g.date >= deal.createdAt - ); - - if (matchingGift) { - return { - verified: true, - giftMsgId: matchingGift.msgId, - }; - } - - return { verified: false }; - } catch (error) { - log.error({ err: error }, `[Poller] Gift verification error for deal ${deal.dealId}`); - return { verified: false }; - } - } - - /** - * Handle verified payment - update DB, execute deal, update bot message - */ - private async handleVerified( - deal: DealContext, - txHash?: string, - playerWallet?: string, - giftMsgId?: string - ): Promise { - log.info(`[Poller] Deal ${deal.dealId} payment verified!`); - - // Update deal status to 'verified' (atomic: only if still payment_claimed) - let transitioned: boolean; - if (deal.userGivesType === "ton") { - const result = this.db - .prepare( - `UPDATE deals SET - status = 'verified', - user_payment_tx_hash = ?, - user_payment_wallet = ?, - user_payment_verified_at = unixepoch() - WHERE id = ? AND status = 'payment_claimed'` - ) - .run(txHash, playerWallet, deal.dealId); - transitioned = result.changes === 1; - } else { - const result = this.db - .prepare( - `UPDATE deals SET - status = 'verified', - user_payment_gift_msgid = ?, - user_payment_verified_at = unixepoch() - WHERE id = ? AND status = 'payment_claimed'` - ) - .run(giftMsgId, deal.dealId); - transitioned = result.changes === 1; - } - - // Another poller already transitioned this deal — abort - if (!transitioned) { - log.warn(`[Poller] Deal ${deal.dealId} already transitioned by another poller, skipping`); - return; - } - - // Update bot inline message to show "Sending..." - if (deal.inlineMessageId) { - const { text, buttons } = buildSendingMessage(deal); - await this.bot.editMessageByInlineId(deal.inlineMessageId, text, buttons); - } - - // Execute deal (send agent's part) - const result = await executeDeal(deal.dealId, this.db, this.bridge); - - if (result.success) { - // Update user stats - updateUserStats(this.db, deal.userId, deal.username, deal, true); - - // Reload deal for completed message - const completedDeal = { ...deal, status: "completed" as const }; - - // Update bot message to final recap - if (deal.inlineMessageId) { - const { text, buttons } = buildCompletedMessage(completedDeal); - await this.bot.editMessageByInlineId(deal.inlineMessageId, text, buttons); - } - - log.info(`[Poller] Deal ${deal.dealId} completed successfully!`); - } else { - // Deal failed - if (deal.inlineMessageId) { - const { text, buttons } = buildFailedMessage(deal, result.error); - await this.bot.editMessageByInlineId(deal.inlineMessageId, text, buttons); - } - - log.error(`[Poller] Deal ${deal.dealId} execution failed: ${result.error}`); - } - } - - /** - * Handle verification timeout - */ - private async handleTimeout(deal: DealContext): Promise { - // Mark deal as failed (only if still payment_claimed) - const r = this.db - .prepare( - `UPDATE deals SET - status = 'failed', - notes = 'Payment verification timeout' - WHERE id = ? AND status = 'payment_claimed'` - ) - .run(deal.dealId); - - if (r.changes !== 1) { - // Already transitioned by another process — skip - this.retryMap.delete(deal.dealId); - return; - } - - // Update bot message - if (deal.inlineMessageId) { - const { text, buttons } = buildFailedMessage( - deal, - "Payment not detected after 60 seconds. Contact support if you have sent it." - ); - await this.bot.editMessageByInlineId(deal.inlineMessageId, text, buttons); - } - - // Notify in chat - await this.bridge.sendMessage({ - chatId: deal.chatId, - text: `⚠️ **Deal #${deal.dealId} - Timeout** - -Could not verify your payment after 60 seconds. - -If you have sent it, contact support with the deal ID.`, - }); - - // Clean up retry map - this.retryMap.delete(deal.dealId); - } -} diff --git a/src/bot/types.ts b/src/bot/types.ts index 152a90e0..2badea7a 100644 --- a/src/bot/types.ts +++ b/src/bot/types.ts @@ -1,55 +1,3 @@ -/** - * Types for the deals inline bot - */ - -export interface BotConfig { - token: string; - username: string; - apiId?: number; - apiHash?: string; - gramjsSessionPath?: string; -} - -export interface DealContext { - dealId: string; - userId: number; - username?: string; - chatId: string; - userGivesType: "ton" | "gift"; - userGivesTonAmount?: number; - userGivesGiftSlug?: string; - userGivesValueTon: number; - agentGivesType: "ton" | "gift"; - agentGivesTonAmount?: number; - agentGivesGiftSlug?: string; - agentGivesValueTon: number; - profitTon: number; - status: DealStatus; - createdAt: number; - expiresAt: number; - inlineMessageId?: string; - paymentClaimedAt?: number; - verifiedAt?: number; - completedAt?: number; - agentWallet?: string; -} - -export type DealStatus = - | "proposed" - | "accepted" - | "payment_claimed" - | "verified" - | "completed" - | "declined" - | "expired" - | "cancelled" - | "failed"; - -export interface CallbackData { - action: "accept" | "decline" | "sent" | "copy_addr" | "copy_memo" | "refresh"; - dealId: string; -} - /** * Split a `prefix:rest` string on its first colon. * Returns null if there is no colon, or the colon is the first character @@ -63,33 +11,3 @@ export function splitPrefix(raw: string): { prefix: string; rest: string } | nul if (colonIdx <= 0) return null; return { prefix: raw.slice(0, colonIdx), rest: raw.slice(colonIdx + 1) }; } - -/** - * Callback-data routing contract (Grammy `callback_query:data` middleware chain). - * - * Two prefix conventions co-route in the same Grammy dispatch, distinguished by - * the first `:`-delimited segment: - * - * - DealBot RESERVES these six action prefixes, decoded here as `action:dealId` - * (exactly one colon): accept · decline · sent · copy_addr · copy_memo · refresh. - * decodeCallback returns null for anything else, so DealBot ignores it. - * - Every OTHER prefix belongs to a registered plugin and is claimed by the - * InlineRouter middleware (installed BEFORE DealBot) via its `prefix:rest` - * split; unmatched prefixes fall through to DealBot. - * - * New DealBot actions must be added to BOTH the union below and the whitelist in - * decodeCallback; plugins must avoid these reserved prefixes to prevent collisions. - */ -export function decodeCallback(raw: string): CallbackData | null { - const parts = raw.split(":"); - if (parts.length !== 2) return null; - - const action = parts[0] as CallbackData["action"]; - const dealId = parts[1]; - - if (!["accept", "decline", "sent", "copy_addr", "copy_memo", "refresh"].includes(action)) { - return null; - } - - return { action, dealId }; -} diff --git a/src/cli/commands/onboard.ts b/src/cli/commands/onboard.ts index 01b1c48a..c8dbdf42 100644 --- a/src/cli/commands/onboard.ts +++ b/src/cli/commands/onboard.ts @@ -36,7 +36,7 @@ import { TELETON_ROOT } from "../../workspace/paths.js"; import { TelegramUserClient } from "../../telegram/client.js"; import { maskSecret } from "../../utils/mask.js"; import YAML from "yaml"; -import { type Config, DealsConfigSchema } from "../../config/schema.js"; +import { type Config } from "../../config/schema.js"; import { getModelsForProvider } from "../../config/model-catalog.js"; import { generateWallet, @@ -326,7 +326,6 @@ function buildConfig(input: BuildConfigInput): Config { history_limit: 100, }, embedding: { provider: "local" }, - deals: DealsConfigSchema.parse({ enabled: !!input.botToken }), webui: { enabled: false, port: 7777, @@ -894,11 +893,11 @@ async function stepModules( let botToken: string | undefined; let botUsername: string | undefined; - // Bot token (recommended — required for deals module; skipped in bot mode, handled at step 5) + // Bot token (optional — enables inline bot features; skipped in bot mode, handled at step 5) const setupBot = telegramMode === "user" ? await confirm({ - message: `Add a Telegram bot token? ${DIM("(recommended — enables deals & inline buttons)")}`, + message: `Add a Telegram bot token? ${DIM("(optional — enables inline buttons)")}`, default: true, theme, }) diff --git a/src/config/__tests__/configurable-keys.test.ts b/src/config/__tests__/configurable-keys.test.ts index 2b7be541..345fa28b 100644 --- a/src/config/__tests__/configurable-keys.test.ts +++ b/src/config/__tests__/configurable-keys.test.ts @@ -114,42 +114,6 @@ describe("CONFIGURABLE_KEYS — new scalar entries", () => { }); }); - describe("deals.expiry_seconds", () => { - const meta = CONFIGURABLE_KEYS["deals.expiry_seconds"]; - - it("accepts 10-3600", () => { - expect(meta.validate("120")).toBeUndefined(); - }); - - it("rejects below min", () => { - expect(meta.validate("5")).toBeDefined(); - }); - }); - - describe("deals.buy_max_floor_percent", () => { - const meta = CONFIGURABLE_KEYS["deals.buy_max_floor_percent"]; - - it("accepts 1-100", () => { - expect(meta.validate("95")).toBeUndefined(); - }); - - it("rejects above 100", () => { - expect(meta.validate("101")).toBeDefined(); - }); - }); - - describe("deals.sell_min_floor_percent", () => { - const meta = CONFIGURABLE_KEYS["deals.sell_min_floor_percent"]; - - it("accepts 100-500", () => { - expect(meta.validate("105")).toBeUndefined(); - }); - - it("rejects below 100", () => { - expect(meta.validate("99")).toBeDefined(); - }); - }); - describe("gocoon.port", () => { const meta = CONFIGURABLE_KEYS["gocoon.port"]; diff --git a/src/config/__tests__/loader.test.ts b/src/config/__tests__/loader.test.ts index 4d61de59..b25e59fc 100644 --- a/src/config/__tests__/loader.test.ts +++ b/src/config/__tests__/loader.test.ts @@ -78,15 +78,6 @@ storage: memory_file: "~/custom_memory.json" history_limit: 50 -deals: - enabled: false - expiry_seconds: 60 - buy_max_floor_percent: 90 - sell_min_floor_percent: 110 - poll_interval_ms: 3000 - max_verification_retries: 5 - expiry_check_interval_ms: 30000 - webui: enabled: true port: 8888 @@ -311,10 +302,6 @@ describe("Config Loader", () => { // Storage expect(config.storage.history_limit).toBe(50); - // Deals - expect(config.deals.enabled).toBe(false); - expect(config.deals.expiry_seconds).toBe(60); - // WebUI expect(config.webui.enabled).toBe(true); expect(config.webui.port).toBe(8888); @@ -383,10 +370,6 @@ describe("Config Loader", () => { // Storage defaults expect(config.storage.history_limit).toBe(100); - // Deals defaults - expect(config.deals.enabled).toBe(true); - expect(config.deals.expiry_seconds).toBe(120); - // WebUI defaults expect(config.webui.enabled).toBe(false); expect(config.webui.port).toBe(7777); diff --git a/src/config/configurable-keys.ts b/src/config/configurable-keys.ts index 6d77ffb7..7ddbbf99 100644 --- a/src/config/configurable-keys.ts +++ b/src/config/configurable-keys.ts @@ -15,7 +15,6 @@ export type ConfigCategory = | "Telegram" | "Embedding" | "WebUI" - | "Deals" | "TON Proxy" | "Coding Agent" | "Developer"; @@ -519,52 +518,6 @@ export const CONFIGURABLE_KEYS: Record = { parse: (v) => v === "true", }, - // ─── Deals ───────────────────────────────────────────────────────── - "deals.enabled": { - type: "boolean", - category: "Deals", - label: "Deals Enabled", - description: "Enable the deals/escrow module", - sensitive: false, - hotReload: "instant", - validate: enumValidator(["true", "false"]), - mask: identity, - parse: (v) => v === "true", - }, - "deals.expiry_seconds": { - type: "number", - category: "Deals", - label: "Deal Expiry", - description: "Deal expiry timeout in seconds", - sensitive: false, - hotReload: "instant", - validate: numberInRange(10, 3600), - mask: identity, - parse: (v) => Number(v), - }, - "deals.buy_max_floor_percent": { - type: "number", - category: "Deals", - label: "Buy Max Floor %", - description: "Maximum floor % for buy deals", - sensitive: false, - hotReload: "instant", - validate: numberInRange(1, 100), - mask: identity, - parse: (v) => Number(v), - }, - "deals.sell_min_floor_percent": { - type: "number", - category: "Deals", - label: "Sell Min Floor %", - description: "Minimum floor % for sell deals", - sensitive: false, - hotReload: "instant", - validate: numberInRange(100, 500), - mask: identity, - parse: (v) => Number(v), - }, - // ─── TON Proxy ──────────────────────────────────────────────────── "ton_proxy.enabled": { type: "boolean", diff --git a/src/config/schema.ts b/src/config/schema.ts index 9ee81129..7ab3281e 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -77,11 +77,8 @@ export const TelegramConfigSchema = z bot_token: z .string() .optional() - .describe("Telegram Bot token from @BotFather for inline deal buttons"), - bot_username: z - .string() - .optional() - .describe("Bot username without @ (e.g., 'teleton_deals_bot')"), + .describe("Telegram Bot token from @BotFather for inline bot features"), + bot_username: z.string().optional().describe("Bot username without @ (e.g., 'my_agent_bot')"), stream_mode: z .enum(["all", "replace", "off"]) .default("replace") @@ -143,17 +140,6 @@ export const MetaConfigSchema = z.object({ onboard_command: z.string().default("teleton setup"), }); -const _DealsObject = z.object({ - enabled: z.boolean().default(true), - expiry_seconds: z.number().default(120), - buy_max_floor_percent: z.number().default(95), - sell_min_floor_percent: z.number().default(105), - poll_interval_ms: z.number().default(5000), - max_verification_retries: z.number().default(12), - expiry_check_interval_ms: z.number().default(60000), -}); -export const DealsConfigSchema = _DealsObject.default(_DealsObject.parse({})); - const _WebUIObject = z.object({ enabled: z.boolean().default(false).describe("Enable WebUI server"), port: z.number().default(7777).describe("HTTP server port"), @@ -341,7 +327,6 @@ export const ConfigSchema = z.object({ telegram: TelegramConfigSchema, storage: StorageConfigSchema.default(StorageConfigSchema.parse({})), embedding: EmbeddingConfigSchema, - deals: DealsConfigSchema, webui: WebUIConfigSchema, logging: LoggingConfigSchema, dev: DevConfigSchema, @@ -389,7 +374,6 @@ export type Config = z.infer; export type AgentConfig = z.infer; export type TelegramConfig = z.infer; export type SessionResetPolicy = z.infer; -export type DealsConfig = z.infer; export type WebUIConfig = z.infer; export type McpConfig = z.infer; export type McpServerConfig = z.infer; diff --git a/src/constants/limits.ts b/src/constants/limits.ts index 26caab1f..f40348f4 100644 --- a/src/constants/limits.ts +++ b/src/constants/limits.ts @@ -3,7 +3,6 @@ export const MAX_TOOL_RESULT_SIZE = 50_000; export const MAX_FILENAME_LENGTH = 255; export const DEFAULT_GIFTS_QUERY_LIMIT = 50; export const MAX_POLL_QUESTION_LENGTH = 300; -export const DEAL_VERIFICATION_WINDOW_SECONDS = 300; export const TELEGRAM_MAX_MESSAGE_LENGTH = 4096; export const MAX_JSON_FIELD_CHARS = 8_000; export const MAX_TOTAL_PROMPT_CHARS = 32_000; diff --git a/src/constants/tools.ts b/src/constants/tools.ts index c81cfed7..43513c71 100644 --- a/src/constants/tools.ts +++ b/src/constants/tools.ts @@ -7,5 +7,4 @@ export const TELEGRAM_SEND_TOOLS = new Set([ "telegram_forward_message", "telegram_quote_reply", "telegram_send_buttons", - "deal_propose", ]); diff --git a/src/deals/config.ts b/src/deals/config.ts deleted file mode 100644 index d8648fc9..00000000 --- a/src/deals/config.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * Deals System Configuration - * All deals constants in one place for easy tuning - * - * Pattern: mutable object with defaults, initialized at startup via initDealsConfig() - */ - -import type { DealsConfig } from "../config/schema.js"; - -export const DEALS_CONFIG = { - // Deal expiry - expirySeconds: 120, // 2 minutes - - // Strategy enforcement (STRATEGY.md rules) - strategy: { - buyMaxMultiplier: 1.0, // Max 100% of floor when buying (never above floor) - sellMinMultiplier: 1.05, // Min 105% of floor when selling (floor + 5%) - }, - - // Verification poller - verification: { - pollIntervalMs: 5000, // Check every 5 seconds - maxRetries: 12, // 12 retries = 60 seconds max wait - retryDelayMs: 5000, - }, - - // Expiry background check - expiryCheckIntervalMs: 60_000, // Check for expired deals every 60s -}; - -/** - * Initialize deals config from YAML values (called at startup) - * Merges YAML overrides into the mutable DEALS_CONFIG object - */ -export function initDealsConfig(yaml?: DealsConfig): void { - if (!yaml) return; - - if (yaml.expiry_seconds !== undefined) DEALS_CONFIG.expirySeconds = yaml.expiry_seconds; - if (yaml.buy_max_floor_percent !== undefined) - DEALS_CONFIG.strategy.buyMaxMultiplier = yaml.buy_max_floor_percent / 100; - if (yaml.sell_min_floor_percent !== undefined) - DEALS_CONFIG.strategy.sellMinMultiplier = yaml.sell_min_floor_percent / 100; - if (yaml.poll_interval_ms !== undefined) - DEALS_CONFIG.verification.pollIntervalMs = yaml.poll_interval_ms; - if (yaml.max_verification_retries !== undefined) - DEALS_CONFIG.verification.maxRetries = yaml.max_verification_retries; - if (yaml.expiry_check_interval_ms !== undefined) - DEALS_CONFIG.expiryCheckIntervalMs = yaml.expiry_check_interval_ms; -} diff --git a/src/deals/db.ts b/src/deals/db.ts deleted file mode 100644 index 1260ac76..00000000 --- a/src/deals/db.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { join } from "path"; -import { TELETON_ROOT } from "../workspace/paths.js"; -import { - openModuleDb, - JOURNAL_SCHEMA, - USED_TRANSACTIONS_SCHEMA, - migrateFromMainDb, -} from "../utils/module-db.js"; -import type Database from "better-sqlite3"; - -const DB_PATH = join(TELETON_ROOT, "deals.db"); - -let db: Database.Database | null = null; - -export function openDealsDb(): Database.Database { - if (db) return db; - db = openModuleDb(DB_PATH); - - db.exec(` - CREATE TABLE IF NOT EXISTS deals ( - id TEXT PRIMARY KEY, - status TEXT NOT NULL CHECK(status IN ( - 'proposed', 'accepted', 'payment_claimed', 'verified', 'completed', - 'declined', 'expired', 'cancelled', 'failed' - )), - user_telegram_id INTEGER NOT NULL, - user_username TEXT, - chat_id TEXT NOT NULL, - proposal_message_id INTEGER, - user_gives_type TEXT NOT NULL CHECK(user_gives_type IN ('ton', 'gift')), - user_gives_ton_amount REAL, - user_gives_gift_id TEXT, - user_gives_gift_slug TEXT, - user_gives_value_ton REAL NOT NULL, - agent_gives_type TEXT NOT NULL CHECK(agent_gives_type IN ('ton', 'gift')), - agent_gives_ton_amount REAL, - agent_gives_gift_id TEXT, - agent_gives_gift_slug TEXT, - agent_gives_value_ton REAL NOT NULL, - user_payment_verified_at INTEGER, - user_payment_tx_hash TEXT, - user_payment_gift_msgid TEXT, - user_payment_wallet TEXT, - agent_sent_at INTEGER, - agent_sent_tx_hash TEXT, - agent_sent_gift_msgid TEXT, - strategy_check TEXT, - profit_ton REAL, - created_at INTEGER NOT NULL DEFAULT (unixepoch()), - expires_at INTEGER NOT NULL, - completed_at INTEGER, - notes TEXT, - inline_message_id TEXT, - payment_claimed_at INTEGER - ); - - CREATE INDEX IF NOT EXISTS idx_deals_status ON deals(status); - CREATE INDEX IF NOT EXISTS idx_deals_user ON deals(user_telegram_id); - CREATE INDEX IF NOT EXISTS idx_deals_chat ON deals(chat_id); - CREATE INDEX IF NOT EXISTS idx_deals_inline_msg ON deals(inline_message_id) WHERE inline_message_id IS NOT NULL; - CREATE INDEX IF NOT EXISTS idx_deals_payment_claimed ON deals(payment_claimed_at) WHERE payment_claimed_at IS NOT NULL; - CREATE INDEX IF NOT EXISTS idx_deals_expires ON deals(expires_at) WHERE status IN ('proposed', 'accepted'); - - CREATE TABLE IF NOT EXISTS user_trade_stats ( - telegram_id INTEGER PRIMARY KEY, - username TEXT, - first_trade_at INTEGER DEFAULT (unixepoch()), - total_deals INTEGER DEFAULT 0, - completed_deals INTEGER DEFAULT 0, - declined_deals INTEGER DEFAULT 0, - total_ton_sent REAL DEFAULT 0, - total_ton_received REAL DEFAULT 0, - total_gifts_sent INTEGER DEFAULT 0, - total_gifts_received INTEGER DEFAULT 0, - last_deal_at INTEGER - ); - - ${USED_TRANSACTIONS_SCHEMA} - - ${JOURNAL_SCHEMA} - `); - - // One-time migration from memory.db (existing users) - migrateFromMainDb(db, ["deals", "user_trade_stats", "used_transactions"]); - - return db; -} - -export function closeDealsDb(): void { - if (db) { - db.close(); - db = null; - } -} - -export function getDealsDb(): Database.Database | null { - return db; -} diff --git a/src/deals/executor.ts b/src/deals/executor.ts deleted file mode 100644 index 9f39c0b5..00000000 --- a/src/deals/executor.ts +++ /dev/null @@ -1,346 +0,0 @@ -/** - * Deal executor - automatically sends agent's part after verification - * Internal module, NOT exposed as a tool (prevents social engineering) - */ - -import type Database from "better-sqlite3"; -import type { ITelegramBridge } from "../telegram/bridge-interface.js"; -import type { Deal } from "./types.js"; -import { sendTon } from "../ton/transfer.js"; -import { tonExplorerTxUrl } from "../ton/confirm.js"; -import { formatAsset } from "./utils.js"; -import { JournalStore } from "../memory/journal-store.js"; -import { getErrorMessage } from "../utils/errors.js"; -import { getClient } from "../sdk/telegram-utils.js"; -import { createLogger } from "../utils/logger.js"; - -const log = createLogger("Deal"); - -export interface ExecutionResult { - success: boolean; - txHash?: string; - giftMsgId?: string; - error?: string; -} - -/** - * Execute a verified deal (send TON or gift to user) - * CRITICAL: Only call this AFTER payment verification - */ -export async function executeDeal( - dealId: string, - db: Database.Database, - bridge: ITelegramBridge -): Promise { - try { - // Load deal - const deal = db.prepare(`SELECT * FROM deals WHERE id = ?`).get(dealId) as Deal | undefined; - - if (!deal) { - return { - success: false, - error: `Deal #${dealId} not found`, - }; - } - - // Verify deal status is 'verified' and not already executed - if (deal.status !== "verified" || deal.agent_sent_at) { - return { - success: false, - error: deal.agent_sent_at - ? `Deal #${dealId} already executed at ${new Date(deal.agent_sent_at * 1000).toISOString()}` - : `Deal #${dealId} has status '${deal.status}', not 'verified'. Cannot execute.`, - }; - } - - // Atomic lock: claim execution (prevents double-spend from concurrent pollers) - const lockResult = db - .prepare( - `UPDATE deals SET agent_sent_at = unixepoch() WHERE id = ? AND status = 'verified' AND agent_sent_at IS NULL` - ) - .run(dealId); - - if (lockResult.changes !== 1) { - return { - success: false, - error: `Deal #${dealId} already claimed by another executor`, - }; - } - - log.info(`Executing deal #${dealId}...`); - - // Case 1: Agent sends TON - if (deal.agent_gives_type === "ton") { - if (!deal.agent_gives_ton_amount) { - return { - success: false, - error: "Deal configuration error: agent_gives_ton_amount is missing", - }; - } - - if (!deal.user_payment_wallet) { - return { - success: false, - error: "Cannot send TON: user wallet address not discovered from payment", - }; - } - - log.info( - `Sending ${deal.agent_gives_ton_amount} TON to ${deal.user_payment_wallet.slice(0, 8)}...` - ); - - // Send TON to user's wallet - const sendResult = await sendTon({ - toAddress: deal.user_payment_wallet, - amount: deal.agent_gives_ton_amount, - comment: `Deal #${dealId} - ${formatAsset(deal.agent_gives_type, deal.agent_gives_ton_amount, deal.agent_gives_gift_slug)}`, - }); - - if (!sendResult) { - throw new Error("TON transfer failed or could not be confirmed on-chain"); - } - const txHash = sendResult.hash; - - // Update deal: mark as completed (agent_sent_at already set by lock) - db.prepare( - `UPDATE deals SET - status = 'completed', - agent_sent_tx_hash = ?, - completed_at = unixepoch() - WHERE id = ?` - ).run(txHash, dealId); - - log.info(`Deal #${dealId} completed - TON sent - TX: ${txHash.slice(0, 8)}...`); - - // Log to business journal - logDealToJournal(deal, db, txHash); - - // Notify user in chat - await bridge.sendMessage({ - chatId: deal.chat_id, - text: `✅ **Deal #${dealId} completed!** - -I've sent **${deal.agent_gives_ton_amount} TON** to your wallet. - -TX Hash: \`${txHash}\` -${tonExplorerTxUrl(txHash)} - -Thank you for trading! 🎉`, - }); - - return { - success: true, - txHash, - }; - } - - // Case 2: Agent sends gift (must be a collectible to transfer) - if (deal.agent_gives_type === "gift") { - if (!deal.agent_gives_gift_id) { - return { - success: false, - error: "Deal configuration error: agent_gives_gift_id (msgId) is missing", - }; - } - - log.info( - `Sending gift ${deal.agent_gives_gift_slug} (msgId: ${deal.agent_gives_gift_id}) to user ${deal.user_telegram_id}...` - ); - - // Transfer collectible gift using Telegram API - const gramJsClient = getClient(bridge); - const Api = (await import("telegram")).Api; - - try { - // Get recipient as InputPeer - const toUser = await gramJsClient.getInputEntity(deal.user_telegram_id); - - // Build the stargift input reference - const stargiftInput = new Api.InputSavedStarGiftUser({ - msgId: parseInt(deal.agent_gives_gift_id, 10), - }); - - // Try free transfer first - try { - await gramJsClient.invoke( - new Api.payments.TransferStarGift({ - stargift: stargiftInput, - toId: toUser, - }) - ); - } catch (freeTransferError: unknown) { - // If PAYMENT_REQUIRED, use payment flow - if ( - freeTransferError instanceof Error && - "errorMessage" in freeTransferError && - (freeTransferError as { errorMessage?: string }).errorMessage === "PAYMENT_REQUIRED" - ) { - log.info("Transfer requires payment, using payment flow..."); - - const invoice = new Api.InputInvoiceStarGiftTransfer({ - stargift: stargiftInput, - toId: toUser, - }); - - const form = await gramJsClient.invoke( - new Api.payments.GetPaymentForm({ - invoice: invoice, - }) - ); - - await gramJsClient.invoke( - new Api.payments.SendStarsForm({ - formId: form.formId, - invoice: invoice, - }) - ); - } else { - throw freeTransferError; - } - } - - const sentMsgId = deal.agent_gives_gift_id; - - // Update deal: mark as completed (agent_sent_at already set by lock) - db.prepare( - `UPDATE deals SET - status = 'completed', - agent_sent_gift_msgid = ?, - completed_at = unixepoch() - WHERE id = ?` - ).run(sentMsgId, dealId); - - log.info(`Deal #${dealId} completed - Gift transferred`); - - // Log to business journal - logDealToJournal(deal, db); - - // Notify user in chat - await bridge.sendMessage({ - chatId: deal.chat_id, - text: `✅ **Deal #${dealId} completed!** - -I've sent you the gift: **${deal.agent_gives_gift_slug}** - -Thank you for trading! 🎉`, - }); - - return { - success: true, - giftMsgId: sentMsgId, - }; - } catch (error) { - log.error({ err: error }, `Failed to transfer gift for deal #${dealId}`); - - // Mark deal as failed (clear agent_sent_at lock since send didn't complete) - db.prepare( - `UPDATE deals SET - status = 'failed', - agent_sent_at = NULL, - notes = ? - WHERE id = ?` - ).run(`Gift transfer error: ${getErrorMessage(error)}`, dealId); - - return { - success: false, - error: `Gift transfer failed: ${getErrorMessage(error)}`, - }; - } - } - - // Edge case: shouldn't reach here - return { - success: false, - error: `Invalid deal configuration: agent_gives_type = ${deal.agent_gives_type}`, - }; - } catch (error) { - log.error({ err: error }, `Error executing deal #${dealId}`); - // Release lock on unexpected error - try { - db.prepare( - `UPDATE deals SET agent_sent_at = NULL, status = 'failed', notes = ? WHERE id = ? AND status = 'verified'` - ).run(`Execution error: ${getErrorMessage(error)}`, dealId); - } catch (rollbackErr) { - log.error({ err: rollbackErr }, `CRITICAL: Could not rollback deal #${dealId}`); - } - return { - success: false, - error: getErrorMessage(error), - }; - } -} - -/** - * Log completed deal to business journal for P&L tracking - */ -function logDealToJournal(deal: Deal, db: Database.Database, txHash?: string): void { - try { - const journal = new JournalStore(db); - - // Determine what agent gave vs received - const agentGave = formatAsset( - deal.agent_gives_type, - deal.agent_gives_ton_amount, - deal.agent_gives_gift_slug - ); - const agentReceived = formatAsset( - deal.user_gives_type, - deal.user_gives_ton_amount, - deal.user_gives_gift_slug - ); - - // Determine journal type: gift trade or TON trade - const isGiftTrade = deal.agent_gives_type === "gift" || deal.user_gives_type === "gift"; - - journal.addEntry({ - type: isGiftTrade ? "gift" : "trade", - action: deal.agent_gives_type === "gift" ? "sell_gift" : "buy_gift", - asset_from: agentGave, - asset_to: agentReceived, - amount_from: deal.agent_gives_ton_amount ?? undefined, - amount_to: deal.user_gives_ton_amount ?? undefined, - counterparty: String(deal.user_telegram_id), - platform: "telegram_deals", - outcome: "neutral", // P&L computed later when floor prices are known - tx_hash: txHash, - tool_used: "deal_executor", - chat_id: deal.chat_id, - user_id: deal.user_telegram_id, - }); - } catch (error) { - // Non-critical: don't let journal failure break deal execution - log.error({ err: error }, `Failed to log deal #${deal.id} to journal`); - } -} - -/** - * Auto-execute deal after verification (called internally) - */ -export async function autoExecuteAfterVerification( - dealId: string, - db: Database.Database, - bridge: ITelegramBridge -): Promise { - log.info(`Auto-executing deal #${dealId} after verification...`); - - const result = await executeDeal(dealId, db, bridge); - - if (!result.success) { - log.error(`Auto-execution failed for #${dealId}: ${result.error}`); - - // Notify user of failure - const deal = db.prepare(`SELECT * FROM deals WHERE id = ?`).get(dealId) as Deal | undefined; - if (deal) { - await bridge.sendMessage({ - chatId: deal.chat_id, - text: `⚠️ **Deal #${dealId} execution failed** - -Your payment was verified, but I encountered an error while sending my part: - -${result.error} - -Please contact support. Your deal is on record.`, - }); - } - } -} diff --git a/src/deals/gift-detector.ts b/src/deals/gift-detector.ts deleted file mode 100644 index 3cb3a16b..00000000 --- a/src/deals/gift-detector.ts +++ /dev/null @@ -1,82 +0,0 @@ -/** - * Gift detector - polls telegram_get_my_gifts to detect newly received gifts - */ - -import type { ToolContext } from "../agent/tools/types.js"; -import { telegramGetMyGiftsExecutor } from "../agent/tools/telegram/gifts/get-my-gifts.js"; -import type { ReceivedGift } from "./types.js"; -import { DEFAULT_GIFTS_QUERY_LIMIT } from "../constants/limits.js"; -import { createLogger } from "../utils/logger.js"; - -interface GiftEntry { - msgId: string; - slug: string; - title?: string; - fromId?: string; - fromUsername?: string; - date?: number; -} - -const log = createLogger("Deal"); - -export class GiftDetector { - private seenGifts: Map> = new Map(); // userId → Set - - /** - * Detect new gifts received by checking telegram_get_my_gifts - * Returns only gifts that weren't seen before - */ - async detectNewGifts(userId: number, context: ToolContext): Promise { - try { - // Get current gifts - const result = await telegramGetMyGiftsExecutor( - { - userId: userId.toString(), - limit: DEFAULT_GIFTS_QUERY_LIMIT, - }, - context - ); - - if (!result.success || !result.data) { - return []; - } - - const data = result.data as { gifts?: GiftEntry[] }; - const gifts = data.gifts || []; - - // Get cached set of seen gifts for this user - const seenSet = this.seenGifts.get(userId) || new Set(); - - // Find new gifts (not in cache) - const newGifts: ReceivedGift[] = []; - const currentMsgIds = new Set(); - - for (const gift of gifts) { - currentMsgIds.add(gift.msgId); - - if (!seenSet.has(gift.msgId)) { - newGifts.push({ - msgId: gift.msgId, - slug: gift.slug, - name: gift.title || gift.slug, - fromUserId: gift.fromId ? Number(gift.fromId) : undefined, - fromUsername: gift.fromUsername, - receivedAt: gift.date || Date.now(), - }); - } - } - - // Update cache with current state - this.seenGifts.set(userId, currentMsgIds); - - if (newGifts.length > 0) { - log.info(`Detected ${newGifts.length} new gift(s) for user ${userId}`); - } - - return newGifts; - } catch (error) { - log.error({ err: error }, `Error detecting gifts for user ${userId}`); - return []; - } - } -} diff --git a/src/deals/module.ts b/src/deals/module.ts deleted file mode 100644 index 8b2c19f5..00000000 --- a/src/deals/module.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { join } from "path"; -import type { PluginModule } from "../agent/tools/types.js"; -import { initDealsConfig, DEALS_CONFIG } from "./config.js"; -import { DealBot, VerificationPoller } from "../bot/index.js"; -import { createLogger } from "../utils/logger.js"; -import { openDealsDb, closeDealsDb, getDealsDb } from "./db.js"; -import { TELETON_ROOT } from "../workspace/paths.js"; -import type { MiddlewareFn, Context } from "grammy"; - -const log = createLogger("Deal"); -import { createDbWrapper } from "../utils/module-db.js"; -import { DEAL_VERIFICATION_WINDOW_SECONDS } from "../constants/limits.js"; -import { - dealProposeTool, - dealProposeExecutor, - dealVerifyPaymentTool, - dealVerifyPaymentExecutor, - dealStatusTool, - dealStatusExecutor, - dealListTool, - dealListExecutor, - dealCancelTool, - dealCancelExecutor, -} from "../agent/tools/deals/index.js"; - -let dealBot: DealBot | null = null; -let verificationPoller: VerificationPoller | null = null; -let expiryInterval: ReturnType | null = null; -let botPreMiddleware: MiddlewareFn | undefined; - -/** Set middleware to install on the Grammy bot BEFORE DealBot handlers */ -export function setBotPreMiddleware(mw: MiddlewareFn): void { - botPreMiddleware = mw; -} - -/** Get the active DealBot instance (for SDK wiring) */ -export function getDealBot(): DealBot | null { - return dealBot; -} - -const withDealsDb = createDbWrapper(getDealsDb, "Deals"); - -const dealsModule: PluginModule = { - name: "deals", - version: "1.0.0", - - configure(config) { - initDealsConfig(config.deals); - }, - - tools(config) { - if (!config.deals?.enabled) return []; - // Deals run on the GramJS DealBot — userbot only (skipped in bot mode). - return [ - { - tool: dealProposeTool, - executor: withDealsDb(dealProposeExecutor), - scope: "dm-only" as const, - mode: "user" as const, - }, - { - tool: dealVerifyPaymentTool, - executor: withDealsDb(dealVerifyPaymentExecutor), - scope: "dm-only" as const, - mode: "user" as const, - }, - { - tool: dealStatusTool, - executor: withDealsDb(dealStatusExecutor), - mode: "user" as const, - }, - { - tool: dealListTool, - executor: withDealsDb(dealListExecutor), - mode: "user" as const, - }, - { - tool: dealCancelTool, - executor: withDealsDb(dealCancelExecutor), - scope: "dm-only" as const, - mode: "user" as const, - }, - ]; - }, - - async start(context) { - if (!context.config.deals?.enabled) return; - - const dealsDb = openDealsDb(); - - const { config, bridge } = context; - const botToken = config.telegram.bot_token; - const botUsername = config.telegram.bot_username; - - if (botToken && botToken !== "YOUR_BOT_TOKEN_FROM_BOTFATHER") { - try { - dealBot = new DealBot( - { - token: botToken, - username: botUsername || "deals_bot", - apiId: config.telegram.api_id, - apiHash: config.telegram.api_hash, - gramjsSessionPath: join(TELETON_ROOT, "gramjs_bot_session.txt"), - }, - dealsDb, - botPreMiddleware - ); - await dealBot.start(); - - verificationPoller = new VerificationPoller(dealsDb, bridge, dealBot, { - pollIntervalMs: DEALS_CONFIG.verification.pollIntervalMs, - }); - verificationPoller.start(); - - log.info(`Deal Bot: @${botUsername} connected`); - } catch (botError) { - log.warn(`Deal Bot failed to start: ${botError}`); - } - } else { - log.warn(`Deal Bot: not configured (set bot_token in config)`); - } - - // Expire stale deals - expiryInterval = setInterval(() => { - const now = Math.floor(Date.now() / 1000); - const currentDb = getDealsDb(); - if (!currentDb) return; - const r = currentDb - .prepare( - `UPDATE deals SET status = 'expired' WHERE status IN ('proposed', 'accepted') AND expires_at < ?` - ) - .run(now); - if (r.changes > 0) log.debug(`Expired ${r.changes} stale deal(s)`); - }, DEALS_CONFIG.expiryCheckIntervalMs); - }, - - async stop() { - if (verificationPoller) { - verificationPoller.stop(); - verificationPoller = null; - } - if (dealBot) { - await dealBot.stop(); - dealBot = null; - } - if (expiryInterval) { - clearInterval(expiryInterval); - expiryInterval = null; - } - closeDealsDb(); - }, -}; - -export default dealsModule; - -/** - * Check if a verified deal exists authorizing a gift transfer. - * Used by gift tools (transfer-collectible, send-gift) for security checks. - */ -export function hasVerifiedDeal(giftId: string, userId: string): boolean { - const dealsDb = getDealsDb(); - if (!dealsDb) return false; - const deal = dealsDb - .prepare( - `SELECT id FROM deals - WHERE status = 'verified' - AND agent_gives_type = 'gift' - AND agent_gives_gift_id = ? - AND user_telegram_id = ? - AND user_payment_verified_at >= unixepoch() - ${DEAL_VERIFICATION_WINDOW_SECONDS} - AND agent_sent_at IS NULL - LIMIT 1` - ) - .get(giftId, userId); - return !!deal; -} diff --git a/src/deals/strategy-checker.ts b/src/deals/strategy-checker.ts deleted file mode 100644 index c4594599..00000000 --- a/src/deals/strategy-checker.ts +++ /dev/null @@ -1,148 +0,0 @@ -/** - * Strategy checker - enforces STRATEGY.md trading rules at code level - * - * RULES from STRATEGY.md: - * - When BUYING (agent buys gift): Pay max buyMaxMultiplier of floor price - * - When SELLING (agent sells gift): Charge min sellMinMultiplier of floor price - * - User ALWAYS sends first (TON or gift) - * - No exceptions without explicit admin approval - */ - -import { DEALS_CONFIG } from "./config.js"; - -export interface StrategyCheck { - acceptable: boolean; - reason?: string; - rule: string; - profit: number; - floorPriceUsed?: number; - percentageOfFloor?: number; -} - -export interface AssetValue { - type: "ton" | "gift"; - tonAmount?: number; - giftSlug?: string; - valueTon: number; // Estimated TON value -} - -/** - * Check if a deal complies with STRATEGY.md rules - */ -export function checkStrategyCompliance( - userGives: AssetValue, - agentGives: AssetValue -): StrategyCheck { - const userValue = userGives.valueTon; - const agentValue = agentGives.valueTon; - const profit = userValue - agentValue; - - // Case 1: Agent BUYS gift (user gives gift, agent gives TON) - if (userGives.type === "gift" && agentGives.type === "ton") { - const maxAllowed = userValue * DEALS_CONFIG.strategy.buyMaxMultiplier; - const percentageOfFloor = (agentValue / userValue) * 100; - - if (agentValue > maxAllowed) { - return { - acceptable: false, - reason: `Strategy violation: Cannot pay more than ${Math.round(DEALS_CONFIG.strategy.buyMaxMultiplier * 100)}% of floor price. Gift worth ${userValue} TON, offering ${agentValue} TON (${percentageOfFloor.toFixed(0)}%). Max allowed: ${maxAllowed.toFixed(2)} TON.`, - rule: `BUYING: max ${Math.round(DEALS_CONFIG.strategy.buyMaxMultiplier * 100)}% floor`, - profit, - floorPriceUsed: userValue, - percentageOfFloor, - }; - } - - return { - acceptable: true, - rule: `BUYING: ${percentageOfFloor.toFixed(0)}% of floor (compliant)`, - profit, - floorPriceUsed: userValue, - percentageOfFloor, - }; - } - - // Case 2: Agent SELLS gift (user gives TON, agent gives gift) - if (userGives.type === "ton" && agentGives.type === "gift") { - const minRequired = agentValue * DEALS_CONFIG.strategy.sellMinMultiplier; - const percentageOfFloor = (userValue / agentValue) * 100; - - if (userValue < minRequired) { - return { - acceptable: false, - reason: `Strategy violation: Must charge at least ${Math.round(DEALS_CONFIG.strategy.sellMinMultiplier * 100)}% of floor price. Gift worth ${agentValue} TON, receiving ${userValue} TON (${percentageOfFloor.toFixed(0)}%). Min required: ${minRequired.toFixed(2)} TON.`, - rule: `SELLING: min ${Math.round(DEALS_CONFIG.strategy.sellMinMultiplier * 100)}% floor`, - profit, - floorPriceUsed: agentValue, - percentageOfFloor, - }; - } - - return { - acceptable: true, - rule: `SELLING: ${percentageOfFloor.toFixed(0)}% of floor (compliant)`, - profit, - floorPriceUsed: agentValue, - percentageOfFloor, - }; - } - - // Case 3: Gift ↔ Gift swap - if (userGives.type === "gift" && agentGives.type === "gift") { - // Simple rule: Agent must receive equal or more value - if (userValue >= agentValue) { - return { - acceptable: true, - rule: `SWAP: Fair exchange (agent receives ${userValue} TON value for ${agentValue} TON value)`, - profit, - }; - } - - return { - acceptable: false, - reason: `Strategy violation: Gift swap would lose value. Giving ${agentValue} TON value, receiving ${userValue} TON value. Loss: ${Math.abs(profit).toFixed(2)} TON.`, - rule: "SWAP: no value loss", - profit, - }; - } - - // Case 4: TON ↔ TON (shouldn't happen, but handle it) - if (userGives.type === "ton" && agentGives.type === "ton") { - if (userValue >= agentValue) { - return { - acceptable: true, - rule: "TON swap: profit or neutral", - profit, - }; - } - - return { - acceptable: false, - reason: `TON swap would lose ${Math.abs(profit).toFixed(2)} TON.`, - rule: "TON swap: no loss", - profit, - }; - } - - // Edge case: shouldn't reach here - return { - acceptable: false, - reason: "Unknown deal type combination", - rule: "unknown", - profit, - }; -} - -/** - * Format strategy check as JSON for storage in deals table - */ -export function formatStrategyCheckJSON(check: StrategyCheck): string { - return JSON.stringify({ - acceptable: check.acceptable, - reason: check.reason, - rule: check.rule, - profit: check.profit, - floorPriceUsed: check.floorPriceUsed, - percentageOfFloor: check.percentageOfFloor, - }); -} diff --git a/src/deals/types.ts b/src/deals/types.ts deleted file mode 100644 index 60678f29..00000000 --- a/src/deals/types.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * Types for deals system - */ - -export interface Deal { - id: string; - status: DealStatus; - user_telegram_id: number; - user_username: string | null; - chat_id: string; - proposal_message_id: number | null; - user_gives_type: "ton" | "gift"; - user_gives_ton_amount: number | null; - user_gives_gift_id: string | null; - user_gives_gift_slug: string | null; - user_gives_value_ton: number; - agent_gives_type: "ton" | "gift"; - agent_gives_ton_amount: number | null; - agent_gives_gift_id: string | null; - agent_gives_gift_slug: string | null; - agent_gives_value_ton: number; - user_payment_verified_at: number | null; - user_payment_tx_hash: string | null; - user_payment_gift_msgid: string | null; - user_payment_wallet: string | null; - agent_sent_at: number | null; - agent_sent_tx_hash: string | null; - agent_sent_gift_msgid: string | null; - strategy_check: string | null; - profit_ton: number | null; - created_at: number; - expires_at: number; - completed_at: number | null; - notes: string | null; -} - -export type DealStatus = - | "proposed" - | "accepted" - | "payment_claimed" - | "verified" - | "completed" - | "declined" - | "expired" - | "cancelled" - | "failed"; - -export interface ReceivedGift { - msgId: string; - slug: string; - name: string; - fromUserId?: number; - fromUsername?: string; - receivedAt: number; -} diff --git a/src/deals/utils.ts b/src/deals/utils.ts deleted file mode 100644 index d08205eb..00000000 --- a/src/deals/utils.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * Utility functions for deals system - */ - -import { DEALS_CONFIG } from "./config.js"; - -/** - * Generate a random deal ID (8 characters) - */ -export function generateDealId(): string { - const chars = "abcdefghijklmnopqrstuvwxyz0123456789"; - let id = "deal_"; - for (let i = 0; i < 8; i++) { - id += chars[Math.floor(Math.random() * chars.length)]; - } - return id; -} - -/** - * Format asset for display - */ -export function formatAsset( - type: "ton" | "gift", - tonAmount?: number | null, - giftSlug?: string | null -): string { - if (type === "ton") { - return `${tonAmount || 0} TON`; - } else { - return `Gift: ${giftSlug || "Unknown"}`; - } -} - -/** - * Calculate deal expiry timestamp (2 minutes from now) - */ -export function calculateExpiry(): number { - return Math.floor(Date.now() / 1000) + DEALS_CONFIG.expirySeconds; -} - -/** - * Format deal proposal message with inline buttons - */ -export function formatDealProposal( - dealId: string, - userGives: { type: "ton" | "gift"; tonAmount?: number; giftSlug?: string; valueTon: number }, - agentGives: { type: "ton" | "gift"; tonAmount?: number; giftSlug?: string; valueTon: number }, - _profit: number, - _strategyCompliant: boolean -): string { - const userGivesStr = formatAsset(userGives.type, userGives.tonAmount, userGives.giftSlug); - const agentGivesStr = formatAsset(agentGives.type, agentGives.tonAmount, agentGives.giftSlug); - - return `📋 **Deal** #${dealId} - -📤 You send: ${userGivesStr} -📥 You receive: ${agentGivesStr} -⏱ Expires in ${Math.round(DEALS_CONFIG.expirySeconds / 60)} minutes`; -} diff --git a/src/index.ts b/src/index.ts index dc4ada7a..2c5c21d4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -43,7 +43,6 @@ import { createLogger, initLoggerFromConfig } from "./utils/logger.js"; import { AgentLifecycle } from "./agent/lifecycle.js"; import { InlineRouter } from "./bot/inline-router.js"; import { PluginRateLimiter } from "./bot/rate-limiter.js"; -import { setBotPreMiddleware, getDealBot } from "./deals/module.js"; import type { WebUIServer } from "./webui/server.js"; import type { ApiServer } from "./api/server.js"; import { HeartbeatRunner } from "./heartbeat.js"; @@ -454,11 +453,6 @@ ${blue} ┌────────────────────── const inlineRouter = new InlineRouter(); const rateLimiter = new PluginRateLimiter(); - // User mode: install DealBot middleware before modules start - if (isUserBridge(this.bridge)) { - setBotPreMiddleware(inlineRouter.middleware()); - } - // Start module background jobs (after bridge connect) const pluginContext = await this.startModules(); @@ -467,7 +461,7 @@ ${blue} ┌────────────────────── if (isBotBridge(this.bridge)) { this.wireBotMode(inlineRouter, rateLimiter, firstStart); } else { - this.wireUserMode(inlineRouter, rateLimiter, firstStart); + this.wireUserMode(firstStart); } // Create hook runner if any plugins registered hooks @@ -661,7 +655,7 @@ ${blue} ┌────────────────────── this.sdkDeps.inlineRouter = inlineRouter; this.sdkDeps.gramjsBot = null; this.sdkDeps.rateLimiter = rateLimiter; - log.info("Bot mode: using main Grammy bridge (no DealBot)"); + log.info("Bot mode: using main Grammy bridge"); if (isBotBridge(this.bridge)) { this.bridge.setCallbackHandler((msg) => { @@ -697,23 +691,10 @@ ${blue} ┌────────────────────── } /** - * Wire user-mode SDK deps from DealBot and register service message handler. + * Wire user-mode handlers. Registers the service message handler on first + * start. (User mode has no Grammy bot, so the plugin bot SDK stays inactive.) */ - private wireUserMode( - inlineRouter: InlineRouter, - rateLimiter: PluginRateLimiter, - firstStart: boolean - ): void { - const activeDealBot = getDealBot(); - if (activeDealBot) { - this.sdkDeps.inlineRouter = inlineRouter; - this.sdkDeps.gramjsBot = activeDealBot.getGramJSBot(); - this.sdkDeps.grammyBot = activeDealBot.getBot(); - this.sdkDeps.rateLimiter = rateLimiter; - inlineRouter.setGramJSBot(activeDealBot.getGramJSBot()); - log.info("Bot SDK: inline router installed"); - } - + private wireUserMode(firstStart: boolean): void { if (firstStart && isUserBridge(this.bridge)) { this.bridge.onServiceMessage(async (message) => { try { @@ -817,7 +798,7 @@ ${blue} ┌────────────────────── } /** - * Start module background jobs with timeout. Skips deals module in bot mode. + * Start module background jobs with timeout. */ private async startModules(): Promise { const moduleDb = getDatabase().getDb(); @@ -829,10 +810,6 @@ ${blue} ┌────────────────────── const startedModules: typeof this.modules = []; try { for (const mod of this.modules) { - if (isBotBridge(this.bridge) && mod.name === "deals") { - log.info("Bot mode: skipping deals module (uses separate Grammy polling)"); - continue; - } await Promise.race([ mod.start?.(pluginContext), new Promise((_, reject) => diff --git a/src/sdk/formatting.ts b/src/sdk/formatting.ts index 1b519305..a42ba7a6 100644 --- a/src/sdk/formatting.ts +++ b/src/sdk/formatting.ts @@ -26,14 +26,6 @@ export interface StyledButtonDef { copyText?: string; } -/** - * Result type for all message builders - */ -export interface DealMessage { - text: string; - buttons: StyledButtonDef[][]; -} - /** * Convert styled button definitions to GramJS TL markup (with colors + copy buttons) * Uses native Layer 223 constructors (KeyboardButtonStyle, KeyboardButtonCopy) diff --git a/src/sdk/index.ts b/src/sdk/index.ts index fa901cee..142acc9b 100644 --- a/src/sdk/index.ts +++ b/src/sdk/index.ts @@ -103,7 +103,7 @@ export function createPluginSDK(deps: SDKDependencies, opts: CreatePluginSDKOpti const frozenPluginConfig = Object.freeze(JSON.parse(JSON.stringify(opts.pluginConfig ?? {}))); // Lazy bot SDK — deps.inlineRouter/gramjsBot/grammyBot may not be available - // at plugin load time (plugins load before DealBot starts). The getter + // at plugin load time (the bot is wired after plugins load). The getter // retries until deps are wired and a non-null BotSDK is created. // Plugins without a botManifest get null cached immediately (no retry). let cachedBot: ReturnType | undefined; diff --git a/src/telegram/admin.ts b/src/telegram/admin.ts index 3e788c9b..0b439e4f 100644 --- a/src/telegram/admin.ts +++ b/src/telegram/admin.ts @@ -3,7 +3,6 @@ import type { AgentRuntime } from "../agent/runtime.js"; import type { ITelegramBridge } from "./bridge-interface.js"; import { getWalletAddress, getWalletBalance } from "../ton/wallet-service.js"; import { Address } from "@ton/core"; -import { DEALS_CONFIG } from "../deals/config.js"; import { loadTemplate } from "../workspace/manager.js"; import { isVerbose, setVerbose, createLogger } from "../utils/logger.js"; @@ -106,8 +105,6 @@ export class AdminHandler { return this.handleResumeCommand(); case "wallet": return await this.handleWalletCommand(); - case "strategy": - return this.handleStrategyCommand(command); case "stop": return await this.handleStopCommand(); case "verbose": @@ -226,42 +223,6 @@ export class AdminHandler { return "▶️ Agent resumed."; } - private handleStrategyCommand(command: AdminCommand): string { - if (command.args.length === 0) { - const buy = Math.round(DEALS_CONFIG.strategy.buyMaxMultiplier * 100); - const sell = Math.round(DEALS_CONFIG.strategy.sellMinMultiplier * 100); - return ( - `📊 **Trading Strategy**\n\n` + - `Buy: max **${buy}%** of floor\n` + - `Sell: min **${sell}%** of floor\n\n` + - `Usage:\n/strategy buy \n/strategy sell ` - ); - } - - const [target, valueStr] = command.args; - const value = parseInt(valueStr, 10); - - if (target === "buy") { - if (isNaN(value) || value < 50 || value > 150) { - return "❌ Buy threshold must be between 50 and 150"; - } - const old = Math.round(DEALS_CONFIG.strategy.buyMaxMultiplier * 100); - DEALS_CONFIG.strategy.buyMaxMultiplier = value / 100; - return `📊 Buy threshold: **${old}%** → **${value}%** of floor`; - } - - if (target === "sell") { - if (isNaN(value) || value < 100 || value > 200) { - return "❌ Sell threshold must be between 100 and 200"; - } - const old = Math.round(DEALS_CONFIG.strategy.sellMinMultiplier * 100); - DEALS_CONFIG.strategy.sellMinMultiplier = value / 100; - return `📊 Sell threshold: **${old}%** → **${value}%** of floor`; - } - - return `❌ Unknown target: ${target}. Use "buy" or "sell".`; - } - private async handleStopCommand(): Promise { log.info("🛑 [Admin] /stop command received - shutting down"); setTimeout(() => process.kill(process.pid, "SIGTERM"), 1000); @@ -566,9 +527,6 @@ Set max agentic iterations **/policy** Change access policy -**/strategy** [buy|sell ] -View or change trading thresholds - **/modules** [set|info|reset] Manage per-group module permissions diff --git a/src/webui/routes/setup.ts b/src/webui/routes/setup.ts index 898ff6e1..ed2d41d5 100644 --- a/src/webui/routes/setup.ts +++ b/src/webui/routes/setup.ts @@ -16,7 +16,7 @@ import { validateApiKeyFormat, type SupportedProvider, } from "../../config/providers.js"; -import { ConfigSchema, DealsConfigSchema } from "../../config/schema.js"; +import { ConfigSchema } from "../../config/schema.js"; import { ensureWorkspace, isNewWorkspace } from "../../workspace/manager.js"; import { TELETON_ROOT } from "../../workspace/paths.js"; import { @@ -505,10 +505,6 @@ export function createSetupRoutes(options?: { keyHash?: string }): Hono { history_limit: 100, }, embedding: { provider: "local" as const }, - deals: DealsConfigSchema.parse({ - enabled: true, - ...(input.deals ?? {}), - }), webui: { enabled: input.webui?.enabled ?? false, port: 7777, From 1aac9632a5b3866a14548c1881f6bee09046cb7a Mon Sep 17 00:00:00 2001 From: TONresistor <240980241+TONresistor@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:10:31 +0200 Subject: [PATCH 12/59] fix(tools): enforce minimum access floors --- .../tools/__tests__/builtin-access.test.ts | 51 +++++++++++++++ src/agent/tools/__tests__/registry.test.ts | 30 +++++++++ src/agent/tools/register-all.ts | 14 ++++- src/agent/tools/registry.ts | 62 +++++++++++++++---- src/agent/tools/scope.ts | 21 ++++++- src/agent/tools/security-policy.ts | 52 ++++++++++++++++ src/agent/tools/types.ts | 7 +++ 7 files changed, 220 insertions(+), 17 deletions(-) create mode 100644 src/agent/tools/__tests__/builtin-access.test.ts create mode 100644 src/agent/tools/security-policy.ts diff --git a/src/agent/tools/__tests__/builtin-access.test.ts b/src/agent/tools/__tests__/builtin-access.test.ts new file mode 100644 index 00000000..5839b499 --- /dev/null +++ b/src/agent/tools/__tests__/builtin-access.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { ToolRegistry } from "../registry.js"; +import { registerAllTools } from "../register-all.js"; + +const ADMIN_ONLY_TOOLS = [ + "ton_send", + "jetton_send", + "stonfi_swap", + "dedust_swap", + "telegram_get_dialogs", + "telegram_get_history", + "telegram_create_scheduled_task", + "workspace_list", + "workspace_read", + "workspace_write", + "workspace_delete", + "workspace_rename", + "workspace_info", + "memory_read", + "memory_search", + "session_search", +] as const; + +describe("built-in tool access policy", () => { + it("hides private and financial tools from a non-admin DM", () => { + const registry = new ToolRegistry("user"); + registerAllTools(registry); + + const visible = new Set( + registry.getForContext(false, null, "dm", false, 12345).map((t) => t.name) + ); + + for (const name of ADMIN_ONLY_TOOLS) { + expect(visible.has(name), `${name} must require admin authority`).toBe(false); + } + }); + + it("allows trusted social actions without exposing financial actions", () => { + const registry = new ToolRegistry("user"); + registerAllTools(registry); + registry.setAllowFrom([12345]); + + const visible = new Set( + registry.getForContext(false, null, "dm", false, 12345).map((t) => t.name) + ); + + expect(visible.has("telegram_send_message")).toBe(true); + expect(visible.has("ton_send")).toBe(false); + expect(visible.has("telegram_get_history")).toBe(false); + }); +}); diff --git a/src/agent/tools/__tests__/registry.test.ts b/src/agent/tools/__tests__/registry.test.ts index fbc33240..03242fcb 100644 --- a/src/agent/tools/__tests__/registry.test.ts +++ b/src/agent/tools/__tests__/registry.test.ts @@ -354,6 +354,36 @@ describe("ToolRegistry", () => { }); }); + describe("minimum access floor", () => { + it("does not let a persisted all-level override weaken an admin floor", () => { + const tool = createMockTool("wallet_action", "action"); + registry.register(tool, createMockExecutor(), "dm-only", "both", [], "admin"); + + db.prepare( + `INSERT INTO tool_config + (tool_name, enabled, scope, scope_level, updated_at, updated_by) + VALUES (?, 1, 'open', 'all', unixepoch(), NULL)` + ).run(tool.name); + registry.loadConfigFromDB(db); + + expect(registry.getToolConfig(tool.name)).toEqual({ level: "admin" }); + expect(registry.getForContext(false, null, undefined, false, 12345)).toEqual([]); + expect(registry.getForContext(false, null, undefined, true, 99999)).toContainEqual(tool); + }); + + it("clamps runtime updates below the declared minimum", () => { + const tool = createMockTool("private_data", "data-bearing"); + registry.register(tool, createMockExecutor(), "open", "both", [], "admin"); + registry.loadConfigFromDB(db); + + expect(registry.updateToolLevel(tool.name, "all")).toBe(true); + expect(registry.getToolConfig(tool.name)).toEqual({ level: "admin" }); + expect( + db.prepare("SELECT scope_level FROM tool_config WHERE tool_name = ?").get(tool.name) + ).toEqual({ scope_level: "admin" }); + }); + }); + // ---------- Tool execution ---------- describe("execute()", () => { diff --git a/src/agent/tools/register-all.ts b/src/agent/tools/register-all.ts index d3496241..b9036ebb 100644 --- a/src/agent/tools/register-all.ts +++ b/src/agent/tools/register-all.ts @@ -17,6 +17,7 @@ import { tools as journalTools } from "./journal/index.js"; import { tools as workspaceTools } from "./workspace/index.js"; import { tools as webTools } from "./web/index.js"; import { toolSearchTool, createToolSearchExecutor } from "./search/index.js"; +import { getBuiltinMinimumAccess } from "./security-policy.js"; const ALL_CATEGORIES: ToolEntry[][] = [ telegramTools, @@ -31,8 +32,15 @@ const ALL_CATEGORIES: ToolEntry[][] = [ export function registerAllTools(registry: ToolRegistry): void { for (const category of ALL_CATEGORIES) { - for (const { tool, executor, scope, mode, tags } of category) { - registry.register(tool, executor, scope, mode, tags); + for (const { tool, executor, scope, mode, tags, minimumAccess } of category) { + registry.register( + tool, + executor, + scope, + mode, + tags, + minimumAccess ?? getBuiltinMinimumAccess(tool, scope) + ); } } @@ -41,5 +49,5 @@ export function registerAllTools(registry: ToolRegistry): void { // The executor lazily reads registry.getToolIndex() + registry.getEmbedder() at call time, // both of which are set during startAgent() — after this registration. const toolSearchExecutor = createToolSearchExecutor(registry); - registry.register(toolSearchTool, toolSearchExecutor, "open", "both", ["core"]); + registry.register(toolSearchTool, toolSearchExecutor, "open", "both", ["core"], "all"); } diff --git a/src/agent/tools/registry.ts b/src/agent/tools/registry.ts index 4046f65e..819d160e 100644 --- a/src/agent/tools/registry.ts +++ b/src/agent/tools/registry.ts @@ -7,6 +7,7 @@ import type { Tool, ToolContext, ToolExecutor, + ToolAccessLevel, ToolMode, ToolResult, ToolScope, @@ -21,7 +22,7 @@ import { saveToolConfig, type ToolConfig, } from "../../memory/tool-config.js"; -import { scopeToLevel, levelToScope, type ToolAccessLevel } from "./scope.js"; +import { enforceMinimumAccess, scopeToLevel, levelToScope } from "./scope.js"; import type { ToolIndex } from "./tool-index.js"; import { getErrorMessage } from "../../utils/errors.js"; import { createLogger } from "../../utils/logger.js"; @@ -68,6 +69,7 @@ export class ToolRegistry { tool: Tool; executor: ToolExecutor; scope?: ToolScope; + minimumAccess?: ToolAccessLevel; mode: ToolMode; module: string; tags?: string[]; @@ -78,6 +80,7 @@ export class ToolRegistry { executor: entry.executor, scope: entry.scope && entry.scope !== "always" && entry.scope !== "open" ? entry.scope : undefined, + minimumAccess: entry.minimumAccess ?? scopeToLevel(entry.scope), mode: entry.mode, module: entry.module, tags: entry.tags && entry.tags.length > 0 ? entry.tags : undefined, @@ -89,7 +92,8 @@ export class ToolRegistry { executor: ToolExecutor, scope?: ToolScope, mode: ToolMode = "both", - tags?: string[] + tags?: string[], + minimumAccess?: ToolAccessLevel ): void { if (this.tools.has(tool.name)) { throw new Error(`Tool "${tool.name}" is already registered`); @@ -101,6 +105,7 @@ export class ToolRegistry { mode, module: tool.name.split("_")[0], tags, + minimumAccess, }); this.toolArrayCache = null; } @@ -326,7 +331,7 @@ export class ToolRegistry { let seeded = false; for (const name of names) { if (!this.toolConfigs.has(name)) { - initializeToolConfig(this.db, name, scopeToLevel(this.tools.get(name)?.scope)); + initializeToolConfig(this.db, name, this.getMinimumAccess(name)); seeded = true; } } @@ -340,6 +345,19 @@ export class ToolRegistry { this.toolConfigs = loadAllToolConfigs(db); // Seed DB with defaults for tools that don't have config yet this.seedConfigs(this.tools.keys()); + // Security floors are code invariants, not user-configurable defaults. Clamp + // stale rows created before the floor existed so restarts remain coherent. + let reconciled = false; + for (const name of this.tools.keys()) { + const config = this.toolConfigs.get(name); + if (!config) continue; + const clamped = enforceMinimumAccess(config.level, this.getMinimumAccess(name)); + if (clamped !== config.level) { + saveToolConfig(db, name, clamped, config.updatedBy ?? undefined); + reconciled = true; + } + } + if (reconciled) this.toolConfigs = loadAllToolConfigs(db); // Clear cache to force regeneration with new configs this.toolArrayCache = null; } @@ -348,10 +366,13 @@ export class ToolRegistry { * Effective access level for a tool: the DB override if present, else the * code-declared default scope mapped to a level. */ + private getMinimumAccess(toolName: string): ToolAccessLevel { + return this.tools.get(toolName)?.minimumAccess ?? "all"; + } + private getEffectiveLevel(toolName: string): ToolAccessLevel { const config = this.toolConfigs.get(toolName); - if (config) return config.level; - return scopeToLevel(this.tools.get(toolName)?.scope); + return enforceMinimumAccess(config?.level ?? "all", this.getMinimumAccess(toolName)); } /** @@ -375,7 +396,12 @@ export class ToolRegistry { updateToolLevel(toolName: string, level: ToolAccessLevel, updatedBy?: number): boolean { if (!this.tools.has(toolName) || !this.db) return false; - saveToolConfig(this.db, toolName, level, updatedBy); + saveToolConfig( + this.db, + toolName, + enforceMinimumAccess(level, this.getMinimumAccess(toolName)), + updatedBy + ); // Update in-memory cache this.toolConfigs = loadAllToolConfigs(this.db); @@ -395,7 +421,7 @@ export class ToolRegistry { if (!enabled) { next = "off"; } else if (this.getEffectiveLevel(toolName) === "off") { - next = scopeToLevel(this.tools.get(toolName)?.scope); + next = this.getMinimumAccess(toolName); } else { next = this.getEffectiveLevel(toolName); } @@ -422,10 +448,16 @@ export class ToolRegistry { */ registerPluginTools( pluginName: string, - tools: Array<{ tool: Tool; executor: ToolExecutor; scope?: ToolScope; mode?: ToolMode }> + tools: Array<{ + tool: Tool; + executor: ToolExecutor; + scope?: ToolScope; + mode?: ToolMode; + minimumAccess?: ToolAccessLevel; + }> ): number { const names: string[] = []; - for (const { tool, executor, scope, mode } of tools) { + for (const { tool, executor, scope, mode, minimumAccess } of tools) { if (this.tools.has(tool.name)) continue; this.insertTool(tool.name, { tool, @@ -433,6 +465,7 @@ export class ToolRegistry { scope, mode: mode ?? "both", module: pluginName, + minimumAccess, }); names.push(tool.name); } @@ -458,13 +491,19 @@ export class ToolRegistry { */ replacePluginTools( pluginName: string, - newTools: Array<{ tool: Tool; executor: ToolExecutor; scope?: ToolScope; mode?: ToolMode }> + newTools: Array<{ + tool: Tool; + executor: ToolExecutor; + scope?: ToolScope; + mode?: ToolMode; + minimumAccess?: ToolAccessLevel; + }> ): void { // Collect old tool names before removal (allowed to re-register these) const previousNames = new Set(this.pluginToolNames.get(pluginName) ?? []); this.removePluginTools(pluginName); const names: string[] = []; - for (const { tool, executor, scope, mode } of newTools) { + for (const { tool, executor, scope, mode, minimumAccess } of newTools) { // Prevent overwriting core/other-plugin tools if (this.tools.has(tool.name) && !previousNames.has(tool.name)) { log.warn( @@ -478,6 +517,7 @@ export class ToolRegistry { scope, mode: mode ?? "both", module: pluginName, + minimumAccess, }); names.push(tool.name); } diff --git a/src/agent/tools/scope.ts b/src/agent/tools/scope.ts index 3c7267d9..5883c04d 100644 --- a/src/agent/tools/scope.ts +++ b/src/agent/tools/scope.ts @@ -1,4 +1,6 @@ -import type { ToolScope } from "./types.js"; +import type { ToolAccessLevel, ToolScope } from "./types.js"; + +export type { ToolAccessLevel } from "./types.js"; /** * Per-tool access level — the authority ladder from most open to most @@ -10,12 +12,25 @@ import type { ToolScope } from "./types.js"; * - "admin": only `telegram.admin_ids` * - "off": nobody — the tool is disabled */ -export type ToolAccessLevel = "all" | "allowlist" | "admin" | "off"; - export function isToolAccessLevel(v: unknown): v is ToolAccessLevel { return v === "all" || v === "allowlist" || v === "admin" || v === "off"; } +const ACCESS_RANK: Record = { + all: 0, + allowlist: 1, + admin: 2, + off: 3, +}; + +/** Return the stricter of a requested level and a code-declared security floor. */ +export function enforceMinimumAccess( + requested: ToolAccessLevel, + minimum: ToolAccessLevel +): ToolAccessLevel { + return ACCESS_RANK[requested] >= ACCESS_RANK[minimum] ? requested : minimum; +} + /** * Map a single-value {@link ToolScope} (the code-declared default for built-in / * MCP / plugin tools) to an access level. Single source of truth for the diff --git a/src/agent/tools/security-policy.ts b/src/agent/tools/security-policy.ts new file mode 100644 index 00000000..54736df5 --- /dev/null +++ b/src/agent/tools/security-policy.ts @@ -0,0 +1,52 @@ +import type { Tool, ToolAccessLevel, ToolScope } from "./types.js"; +import { scopeToLevel } from "./scope.js"; + +/** + * Data-bearing tools that expose owner-only state rather than public network data. + * Keep this list narrow: public market, DNS and blockchain lookups remain available. + */ +const OWNER_PRIVATE_DATA = new Set([ + "journal_query", + "memory_read", + "memory_search", + "session_search", + "telegram_get_dialogs", + "telegram_get_history", + "telegram_get_replies", + "telegram_get_scheduled_messages", + "telegram_search_messages", + "ton_get_balance", + "ton_my_transactions", + "jetton_balances", +]); + +const OWNER_ONLY_ACTIONS = new Set(["telegram_create_scheduled_task"]); + +/** + * Built-in security defaults. Channel placement and authority are intentionally + * independent: a tool can be DM-only and still require an admin identity. + */ +export function getBuiltinMinimumAccess(tool: Tool, scope?: ToolScope): ToolAccessLevel { + if (scope === "dm-only" || scope === "group-only" || scope === "admin-only") { + return "admin"; + } + if (scope === "allowlist" || scope === "disabled") { + return scopeToLevel(scope); + } + + if ( + OWNER_ONLY_ACTIONS.has(tool.name) || + OWNER_PRIVATE_DATA.has(tool.name) || + tool.name.startsWith("workspace_") + ) { + return "admin"; + } + + // Telegram tools operate through the owner's account. Even harmless-looking + // reads/actions are not offered to arbitrary users when dm_policy is open. + if (tool.name.startsWith("telegram_")) return "allowlist"; + + // Uncategorised built-ins are actions by convention. Public read-only tools + // explicitly declare data-bearing and remain available to open conversations. + return tool.category === "data-bearing" ? "all" : "allowlist"; +} diff --git a/src/agent/tools/types.ts b/src/agent/tools/types.ts index e564e306..a2083fbf 100644 --- a/src/agent/tools/types.ts +++ b/src/agent/tools/types.ts @@ -38,6 +38,9 @@ export interface ToolResult { */ export type ToolCategory = "data-bearing" | "action"; +/** Runtime authority required to expose and execute a tool. */ +export type ToolAccessLevel = "all" | "allowlist" | "admin" | "off"; + /** * Tool scope for context-based filtering. * - "open": included in both DMs and groups (default, canonical form) @@ -103,6 +106,8 @@ export interface RegisteredTool { executor: ToolExecutor; /** Declared non-trivial scope (undefined for the default always/open). */ scope?: ToolScope; + /** Hard authority floor. Runtime configuration may only make access stricter. */ + minimumAccess: ToolAccessLevel; /** Telegram mode this tool runs in. */ mode: ToolMode; /** Module this tool belongs to (name prefix for built-ins, plugin name otherwise). */ @@ -120,6 +125,8 @@ export interface ToolEntry { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- tool executors accept varied param shapes executor: ToolExecutor; scope?: ToolScope; + /** Hard authority floor, independent from the DM/group channel scope. */ + minimumAccess?: ToolAccessLevel; /** Telegram mode(s) this tool runs in. Mandatory — every tool must declare it. */ mode: ToolMode; /** Toolset tags for profile-based filtering (e.g. "core", "finance", "social") */ From 9c96501d0c8f446292cc8223f5910488df090dd2 Mon Sep 17 00:00:00 2001 From: TONresistor <240980241+TONresistor@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:19:03 +0200 Subject: [PATCH 13/59] fix(tasks): preserve scheduled task authority --- .../telegram/tasks/create-scheduled-task.ts | 5 +- src/memory/__tests__/schema.test.ts | 16 ++- src/memory/__tests__/tasks-origin.test.ts | 36 ++++++ src/memory/agent/tasks.ts | 25 +++- src/memory/schema.ts | 30 ++++- src/memory/types/db-rows.ts | 3 + src/scheduled-tasks.ts | 27 +++- .../scheduled-task-authorization.test.ts | 117 ++++++++++++++++++ src/telegram/__tests__/task-executor.test.ts | 49 ++++++++ src/telegram/task-executor.ts | 14 +++ 10 files changed, 310 insertions(+), 12 deletions(-) create mode 100644 src/memory/__tests__/tasks-origin.test.ts create mode 100644 src/telegram/__tests__/scheduled-task-authorization.test.ts create mode 100644 src/telegram/__tests__/task-executor.test.ts diff --git a/src/agent/tools/telegram/tasks/create-scheduled-task.ts b/src/agent/tools/telegram/tasks/create-scheduled-task.ts index 331a15c3..21dff4a1 100644 --- a/src/agent/tools/telegram/tasks/create-scheduled-task.ts +++ b/src/agent/tools/telegram/tasks/create-scheduled-task.ts @@ -248,10 +248,13 @@ export const telegramCreateScheduledTaskExecutor: ToolExecutor { }); it("CURRENT_SCHEMA_VERSION is set to expected value", () => { - expect(CURRENT_SCHEMA_VERSION).toBe("1.19.0"); + expect(CURRENT_SCHEMA_VERSION).toBe("1.20.0"); }); }); @@ -1130,6 +1130,20 @@ describe("Memory Schema", () => { expect(columnNames).toContain("scheduled_message_id"); }); + it("runMigrations from version 1.19.0 adds scheduled task authority columns", () => { + ensureSchema(db); + setSchemaVersion(db, "1.19.0"); + + runMigrations(db); + + const info = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; + const columnNames = info.map((c) => c.name); + + expect(columnNames).toContain("origin_sender_id"); + expect(columnNames).toContain("origin_chat_id"); + expect(columnNames).toContain("origin_is_group"); + }); + it("runMigrations from version 1.1.0 extends sessions table", () => { ensureSchema(db); setSchemaVersion(db, "1.1.0"); diff --git a/src/memory/__tests__/tasks-origin.test.ts b/src/memory/__tests__/tasks-origin.test.ts new file mode 100644 index 00000000..bde1bdb7 --- /dev/null +++ b/src/memory/__tests__/tasks-origin.test.ts @@ -0,0 +1,36 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import Database from "better-sqlite3"; +import { ensureSchema } from "../schema.js"; +import { TaskStore } from "../agent/tasks.js"; + +describe("TaskStore origin authority", () => { + let db: Database.Database; + + beforeEach(() => { + db = new Database(":memory:"); + ensureSchema(db); + }); + + afterEach(() => db.close()); + + it("persists and restores the Telegram authority that created a task", () => { + const store = new TaskStore(db); + const created = store.createTask({ + description: "check later", + originSenderId: 12345, + originChatId: "telegram:group:-10042", + originIsGroup: true, + } as never); + + expect(created).toMatchObject({ + originSenderId: 12345, + originChatId: "telegram:group:-10042", + originIsGroup: true, + }); + expect(store.getTask(created.id)).toMatchObject({ + originSenderId: 12345, + originChatId: "telegram:group:-10042", + originIsGroup: true, + }); + }); +}); diff --git a/src/memory/agent/tasks.ts b/src/memory/agent/tasks.ts index 698ab507..43a869ea 100644 --- a/src/memory/agent/tasks.ts +++ b/src/memory/agent/tasks.ts @@ -19,6 +19,9 @@ export interface Task { payload?: string; reason?: string; scheduledMessageId?: number; + originSenderId?: number; + originChatId?: string; + originIsGroup?: boolean; } export class TaskStore { @@ -32,6 +35,9 @@ export class TaskStore { payload?: string; reason?: string; scheduledMessageId?: number; + originSenderId?: number; + originChatId?: string; + originIsGroup?: boolean; dependsOn?: string[]; }): Task { const id = randomUUID(); @@ -40,8 +46,12 @@ export class TaskStore { this.db .prepare( ` - INSERT INTO tasks (id, description, status, priority, created_by, created_at, scheduled_for, payload, reason, scheduled_message_id) - VALUES (?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?) + INSERT INTO tasks ( + id, description, status, priority, created_by, created_at, + scheduled_for, payload, reason, scheduled_message_id, + origin_sender_id, origin_chat_id, origin_is_group + ) + VALUES (?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` ) .run( @@ -53,7 +63,10 @@ export class TaskStore { task.scheduledFor ? Math.floor(task.scheduledFor.getTime() / 1000) : null, task.payload ?? null, task.reason ?? null, - task.scheduledMessageId ?? null + task.scheduledMessageId ?? null, + task.originSenderId ?? null, + task.originChatId ?? null, + task.originIsGroup === undefined ? null : task.originIsGroup ? 1 : 0 ); if (task.dependsOn && task.dependsOn.length > 0) { @@ -73,6 +86,9 @@ export class TaskStore { payload: task.payload, reason: task.reason, scheduledMessageId: task.scheduledMessageId, + originSenderId: task.originSenderId, + originChatId: task.originChatId, + originIsGroup: task.originIsGroup, }; } @@ -164,6 +180,9 @@ export class TaskStore { payload: row.payload ?? undefined, reason: row.reason ?? undefined, scheduledMessageId: row.scheduled_message_id ?? undefined, + originSenderId: row.origin_sender_id ?? undefined, + originChatId: row.origin_chat_id ?? undefined, + originIsGroup: row.origin_is_group === null ? undefined : row.origin_is_group === 1, }; } diff --git a/src/memory/schema.ts b/src/memory/schema.ts index 5e2a4577..4770fb32 100644 --- a/src/memory/schema.ts +++ b/src/memory/schema.ts @@ -198,7 +198,10 @@ export function ensureSchema(db: Database.Database): void { scheduled_for INTEGER, payload TEXT, reason TEXT, - scheduled_message_id INTEGER + scheduled_message_id INTEGER, + origin_sender_id INTEGER, + origin_chat_id TEXT, + origin_is_group INTEGER CHECK(origin_is_group IN (0, 1)) ); CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status); @@ -400,7 +403,7 @@ export function setSchemaVersion(db: Database.Database, version: string): void { ).run(version); } -export const CURRENT_SCHEMA_VERSION = "1.19.0"; +export const CURRENT_SCHEMA_VERSION = "1.20.0"; export function runMigrations(db: Database.Database): void { const currentVersion = getSchemaVersion(db); @@ -785,5 +788,28 @@ export function runMigrations(db: Database.Database): void { } } + if (!currentVersion || versionLessThan(currentVersion, "1.20.0")) { + log.info("Running migration 1.20.0: Persist scheduled task creator authority"); + try { + const tableExists = db + .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='tasks'") + .get(); + if (tableExists) { + addColumnIfNotExists(db, "tasks", "origin_sender_id", "INTEGER"); + addColumnIfNotExists(db, "tasks", "origin_chat_id", "TEXT"); + addColumnIfNotExists( + db, + "tasks", + "origin_is_group", + "INTEGER CHECK(origin_is_group IN (0, 1))" + ); + } + log.info("Migration 1.20.0 complete: Scheduled task authority persisted"); + } catch (error) { + log.error({ err: error }, "Migration 1.20.0 failed"); + throw error; + } + } + setSchemaVersion(db, CURRENT_SCHEMA_VERSION); } diff --git a/src/memory/types/db-rows.ts b/src/memory/types/db-rows.ts index 7f073a8f..683c58c6 100644 --- a/src/memory/types/db-rows.ts +++ b/src/memory/types/db-rows.ts @@ -18,6 +18,9 @@ export interface TaskRow { payload: string | null; reason: string | null; scheduled_message_id: number | null; + origin_sender_id: number | null; + origin_chat_id: string | null; + origin_is_group: number | null; } export interface TgUserRow { diff --git a/src/scheduled-tasks.ts b/src/scheduled-tasks.ts index 3192f036..135b9ebf 100644 --- a/src/scheduled-tasks.ts +++ b/src/scheduled-tasks.ts @@ -55,6 +55,23 @@ export class ScheduledTaskHandler { return; } + // Legacy rows did not persist their creator. Never substitute the Saved + // Messages sender (the owner account), because that elevates authority. + if ( + task.originSenderId === undefined || + task.originChatId === undefined || + task.originIsGroup === undefined + ) { + const reason = "Scheduled task is missing creator authority; recreate it before execution"; + taskStore.failTask(taskId, reason); + await this.bridge.sendMessage({ + chatId: message.chatId, + text: `⚠️ Task "${task.description}" was blocked: missing creator authority. Recreate the task.`, + replyToId: message.id, + }); + return; + } + // Check if all dependencies are satisfied if (!taskStore.canExecute(taskId)) { log.warn(`Task ${taskId} cannot execute yet - dependencies not satisfied`); @@ -76,9 +93,9 @@ export class ScheduledTaskHandler { const toolContext = { bridge: this.bridge, db, - chatId: message.chatId, - isGroup: message.isGroup, - senderId: message.senderId, + chatId: task.originChatId, + isGroup: task.originIsGroup, + senderId: task.originSenderId, config: this.config, }; @@ -100,11 +117,11 @@ export class ScheduledTaskHandler { // Feed prompt to agent (agent loop with full context) const response = await this.agent.processMessage({ - chatId: message.chatId, + chatId: task.originChatId, userMessage: agentPrompt, userName: "self-scheduled-task", timestamp: message.timestamp.getTime(), - isGroup: false, + isGroup: task.originIsGroup, toolContext, messageId: message.id, }); diff --git a/src/telegram/__tests__/scheduled-task-authorization.test.ts b/src/telegram/__tests__/scheduled-task-authorization.test.ts new file mode 100644 index 00000000..d0b3efac --- /dev/null +++ b/src/telegram/__tests__/scheduled-task-authorization.test.ts @@ -0,0 +1,117 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + db: {}, + taskStore: { + getTask: vi.fn(), + canExecute: vi.fn(() => true), + startTask: vi.fn(), + getParentResults: vi.fn(() => []), + completeTask: vi.fn(), + failTask: vi.fn(), + }, + executeScheduledTask: vi.fn(async () => "task complete"), +})); + +vi.mock("../../memory/index.js", () => ({ + getDatabase: () => ({ getDb: () => mocks.db }), +})); + +vi.mock("../../memory/agent/tasks.js", () => ({ + getTaskStore: () => mocks.taskStore, +})); + +vi.mock("../task-executor.js", () => ({ + executeScheduledTask: mocks.executeScheduledTask, +})); + +vi.mock("../task-dependency-resolver.js", () => ({ + TaskDependencyResolver: class { + async onTaskComplete() {} + async onTaskFail() {} + }, +})); + +import { ScheduledTaskHandler } from "../../scheduled-tasks.js"; + +describe("ScheduledTaskHandler authority", () => { + const registry = {}; + const bridge = { + sendMessage: vi.fn(async () => ({ id: 1, date: new Date() })), + }; + const agent = { + getToolRegistry: vi.fn(() => registry), + processMessage: vi.fn(async () => ({ content: "done" })), + }; + const config = { telegram: { admin_ids: [999] } }; + const savedMessage = { + id: 77, + chatId: "999", + senderId: 999, + text: "[TASK:task-1] run", + isGroup: false, + timestamp: new Date(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + mocks.taskStore.canExecute.mockReturnValue(true); + mocks.taskStore.getParentResults.mockReturnValue([]); + }); + + it("executes with the persisted creator authority, not the Saved Messages sender", async () => { + const task = { + id: "task-1", + description: "run", + status: "pending", + priority: 0, + createdAt: new Date(), + originSenderId: 12345, + originChatId: "telegram:group:-10042", + originIsGroup: true, + }; + mocks.taskStore.getTask.mockReturnValue(task); + + const handler = new ScheduledTaskHandler(agent as never, bridge as never, config as never); + await handler.execute(savedMessage as never); + + expect(mocks.executeScheduledTask).toHaveBeenCalledWith( + task, + agent, + expect.objectContaining({ + senderId: 12345, + chatId: "telegram:group:-10042", + isGroup: true, + }), + registry, + [] + ); + expect(agent.processMessage).toHaveBeenCalledWith( + expect.objectContaining({ + chatId: "telegram:group:-10042", + isGroup: true, + toolContext: expect.objectContaining({ senderId: 12345 }), + }) + ); + }); + + it("fails closed for legacy tasks that have no creator authority", async () => { + mocks.taskStore.getTask.mockReturnValue({ + id: "task-1", + description: "legacy", + status: "pending", + priority: 0, + createdAt: new Date(), + }); + + const handler = new ScheduledTaskHandler(agent as never, bridge as never, config as never); + await handler.execute(savedMessage as never); + + expect(mocks.taskStore.failTask).toHaveBeenCalledWith( + "task-1", + expect.stringMatching(/missing creator authority/i) + ); + expect(mocks.executeScheduledTask).not.toHaveBeenCalled(); + expect(agent.processMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/src/telegram/__tests__/task-executor.test.ts b/src/telegram/__tests__/task-executor.test.ts new file mode 100644 index 00000000..ee44a174 --- /dev/null +++ b/src/telegram/__tests__/task-executor.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, vi } from "vitest"; +import { executeScheduledTask } from "../task-executor.js"; + +function taskWithTool(tool: string) { + return { + id: "task-1", + description: "scheduled tool", + status: "pending" as const, + priority: 0, + createdAt: new Date(), + payload: JSON.stringify({ type: "tool_call", tool, params: {} }), + }; +} + +describe("executeScheduledTask", () => { + it("does not auto-execute action tools", async () => { + const registry = { + getToolCategory: vi.fn(() => undefined), + execute: vi.fn(), + }; + + const prompt = await executeScheduledTask( + taskWithTool("ton_send"), + {} as never, + {} as never, + registry as never + ); + + expect(registry.execute).not.toHaveBeenCalled(); + expect(prompt).toMatch(/refused.*action tool/i); + }); + + it("still auto-executes explicitly data-bearing tools", async () => { + const registry = { + getToolCategory: vi.fn(() => "data-bearing"), + execute: vi.fn(async () => ({ success: true, data: { price: 5 } })), + }; + + const prompt = await executeScheduledTask( + taskWithTool("ton_get_price"), + {} as never, + {} as never, + registry as never + ); + + expect(registry.execute).toHaveBeenCalledOnce(); + expect(prompt).toContain("ton_get_price"); + }); +}); diff --git a/src/telegram/task-executor.ts b/src/telegram/task-executor.ts index ae3b900f..c5489ed7 100644 --- a/src/telegram/task-executor.ts +++ b/src/telegram/task-executor.ts @@ -77,6 +77,20 @@ export async function executeScheduledTask( if (payload.type === "tool_call") { // Mode 1: Auto-execute tool, feed result to agent try { + // Scheduled direct calls are limited to read-only tools. Actions must run + // through an agent turn (and their normal approval/authorization gates). + if (toolRegistry.getToolCategory(payload.tool) !== "data-bearing") { + return buildAgentPrompt( + task, + { + toolExecuted: payload.tool, + toolParams: payload.params ?? {}, + toolError: `Refused scheduled action tool "${payload.tool}"; only data-bearing tools can auto-execute.`, + }, + parentResults + ); + } + const toolCall: ToolCall = { type: "toolCall", id: `scheduled-${task.id}`, From 7c20f62ba0325dd123b7a53c8492179377fa5250 Mon Sep 17 00:00:00 2001 From: TONresistor <240980241+TONresistor@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:28:07 +0200 Subject: [PATCH 14/59] fix(finance): require explicit action approval --- .../tools/__tests__/builtin-access.test.ts | 32 +++- .../financial-approval-policy.test.ts | 29 +++ src/agent/tools/__tests__/registry.test.ts | 160 +++++++++++++++++ src/agent/tools/register-all.ts | 9 +- src/agent/tools/registry.ts | 170 +++++++++++++++++- src/agent/tools/security-policy.ts | 25 +++ src/agent/tools/types.ts | 4 + src/telegram/__tests__/admin-approval.test.ts | 63 +++++++ src/telegram/admin.ts | 36 ++++ src/telegram/bridges/bot.ts | 2 + 10 files changed, 520 insertions(+), 10 deletions(-) create mode 100644 src/agent/tools/__tests__/financial-approval-policy.test.ts create mode 100644 src/telegram/__tests__/admin-approval.test.ts diff --git a/src/agent/tools/__tests__/builtin-access.test.ts b/src/agent/tools/__tests__/builtin-access.test.ts index 5839b499..5dcef78b 100644 --- a/src/agent/tools/__tests__/builtin-access.test.ts +++ b/src/agent/tools/__tests__/builtin-access.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { ToolRegistry } from "../registry.js"; import { registerAllTools } from "../register-all.js"; @@ -48,4 +48,34 @@ describe("built-in tool access policy", () => { expect(visible.has("ton_send")).toBe(false); expect(visible.has("telegram_get_history")).toBe(false); }); + + it("wires the approval gate into real financial tool registrations", async () => { + const registry = new ToolRegistry("user"); + registerAllTools(registry); + const sendMessage = vi.fn(async () => ({ id: 1, date: 1, chatId: "dm" })); + + const result = await registry.execute( + { + type: "toolCall", + id: "financial-call", + name: "ton_send", + arguments: { to: "EQrecipient", amount: 1 }, + }, + { + bridge: { + getMode: () => "user", + getOwnUserId: () => 999n, + sendMessage, + } as never, + db: {} as never, + chatId: "dm", + senderId: 42, + isGroup: false, + config: { telegram: { admin_ids: [42] } } as never, + } + ); + + expect(result).toMatchObject({ success: false, data: { approvalRequired: true } }); + expect(sendMessage).toHaveBeenCalledOnce(); + }); }); diff --git a/src/agent/tools/__tests__/financial-approval-policy.test.ts b/src/agent/tools/__tests__/financial-approval-policy.test.ts new file mode 100644 index 00000000..c477a75b --- /dev/null +++ b/src/agent/tools/__tests__/financial-approval-policy.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { requiresBuiltinApproval } from "../security-policy.js"; + +describe("built-in financial approval policy", () => { + it.each([ + "ton_send", + "jetton_send", + "stonfi_swap", + "dedust_swap", + "dns_bid", + "dns_start_auction", + "dns_link", + "dns_set_site", + "dns_unlink", + "telegram_buy_resale_gift", + "telegram_set_collectible_price", + "telegram_send_gift_offer", + "telegram_resolve_gift_offer", + ])("requires approval for %s", (toolName) => { + expect(requiresBuiltinApproval(toolName)).toBe(true); + }); + + it.each(["ton_get_balance", "stonfi_quote", "dns_check", "telegram_get_stars_balance"])( + "does not require approval for read-only tool %s", + (toolName) => { + expect(requiresBuiltinApproval(toolName)).toBe(false); + } + ); +}); diff --git a/src/agent/tools/__tests__/registry.test.ts b/src/agent/tools/__tests__/registry.test.ts index 03242fcb..c6a2a421 100644 --- a/src/agent/tools/__tests__/registry.test.ts +++ b/src/agent/tools/__tests__/registry.test.ts @@ -387,6 +387,166 @@ describe("ToolRegistry", () => { // ---------- Tool execution ---------- describe("execute()", () => { + it("should defer approval-required tools without calling their executor", async () => { + const tool = createMockTool("financial_tool", "action"); + const executor = createMockExecutor({ success: true }); + const sendMessage = vi.fn(async () => ({ id: 1, date: 1, chatId: "test-chat" })); + mockContext.bridge = { getMode: () => "user", sendMessage } as any; + mockContext.senderId = 99999; + + registry.register(tool, executor, "admin-only", "both", [], "admin", true); + + const result = await registry.execute( + { + type: "toolCall", + id: "call-approval", + name: tool.name, + arguments: { message: "send exactly 1 TON" }, + }, + mockContext + ); + + expect(result).toMatchObject({ + success: false, + data: { approvalRequired: true }, + }); + expect(result.error).toContain("owner approval"); + expect(executor).not.toHaveBeenCalled(); + expect(sendMessage).toHaveBeenCalledOnce(); + expect(sendMessage.mock.calls[0][0].text).toContain("send exactly 1 TON"); + expect(sendMessage.mock.calls[0][0].text).toMatch(/\/approve [a-f0-9-]+/); + expect(JSON.stringify(result)).not.toMatch(/\/approve [a-f0-9-]+/); + }); + + it("should execute an approved request once for the same admin and chat", async () => { + const tool = createMockTool("financial_tool", "action"); + const executor = createMockExecutor({ success: true, data: { tx: "abc" } }); + const sendMessage = vi.fn(async () => ({ id: 1, date: 1, chatId: "test-chat" })); + mockContext.bridge = { getMode: () => "user", sendMessage } as any; + mockContext.senderId = 99999; + + registry.register(tool, executor, "admin-only", "both", [], "admin", true); + await registry.execute( + { + type: "toolCall", + id: "call-approval", + name: tool.name, + arguments: { message: "send exactly 1 TON" }, + }, + mockContext + ); + const approvalId = sendMessage.mock.calls[0][0].text.match(/\/approve ([a-f0-9-]+)/)?.[1]; + expect(approvalId).toBeTruthy(); + + await expect( + registry.approvePendingAction(approvalId!, 11111, "test-chat") + ).resolves.toMatchObject({ success: false }); + await expect( + registry.approvePendingAction(approvalId!, 99999, "wrong-chat") + ).resolves.toMatchObject({ success: false }); + expect(executor).not.toHaveBeenCalled(); + + await expect(registry.approvePendingAction(approvalId!, 99999, "test-chat")).resolves.toEqual( + { success: true, data: { tx: "abc" } } + ); + expect(executor).toHaveBeenCalledOnce(); + expect(executor).toHaveBeenCalledWith({ message: "send exactly 1 TON" }, mockContext); + + await expect( + registry.approvePendingAction(approvalId!, 99999, "test-chat") + ).resolves.toMatchObject({ success: false }); + expect(executor).toHaveBeenCalledOnce(); + }); + + it("should reject a pending approval without executing it", async () => { + const tool = createMockTool("financial_tool", "action"); + const executor = createMockExecutor({ success: true }); + const sendMessage = vi.fn(async () => ({ id: 1, date: 1, chatId: "test-chat" })); + mockContext.bridge = { getMode: () => "user", sendMessage } as any; + mockContext.senderId = 99999; + + registry.register(tool, executor, "admin-only", "both", [], "admin", true); + await registry.execute( + { + type: "toolCall", + id: "call-approval", + name: tool.name, + arguments: { message: "send exactly 1 TON" }, + }, + mockContext + ); + const approvalId = sendMessage.mock.calls[0][0].text.match(/\/approve ([a-f0-9-]+)/)?.[1]; + + await expect( + registry.rejectPendingAction(approvalId!, 99999, "test-chat") + ).resolves.toMatchObject({ success: true }); + await expect( + registry.approvePendingAction(approvalId!, 99999, "test-chat") + ).resolves.toMatchObject({ success: false }); + expect(executor).not.toHaveBeenCalled(); + }); + + it("should fail closed for self-originated autonomous financial actions", async () => { + const tool = createMockTool("financial_tool", "action"); + const executor = createMockExecutor({ success: true }); + const sendMessage = vi.fn(); + mockContext.bridge = { + getMode: () => "user", + getOwnUserId: () => 99999n, + sendMessage, + } as any; + mockContext.senderId = 99999; + + registry.register(tool, executor, "admin-only", "both", [], "admin", true); + const result = await registry.execute( + { + type: "toolCall", + id: "call-self-approval", + name: tool.name, + arguments: { message: "send exactly 1 TON" }, + }, + mockContext + ); + + expect(result).toMatchObject({ success: false }); + expect(result.error).toContain("interactive admin request"); + expect(sendMessage).not.toHaveBeenCalled(); + expect(executor).not.toHaveBeenCalled(); + }); + + it("should expire pending approvals after five minutes", async () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(new Date("2026-07-09T00:00:00Z")); + const tool = createMockTool("financial_tool", "action"); + const executor = createMockExecutor({ success: true }); + const sendMessage = vi.fn(async () => ({ id: 1, date: 1, chatId: "test-chat" })); + mockContext.bridge = { getMode: () => "user", sendMessage } as any; + mockContext.senderId = 99999; + + registry.register(tool, executor, "admin-only", "both", [], "admin", true); + await registry.execute( + { + type: "toolCall", + id: "call-expiring-approval", + name: tool.name, + arguments: { message: "send exactly 1 TON" }, + }, + mockContext + ); + const approvalId = sendMessage.mock.calls[0][0].text.match(/\/approve ([a-f0-9-]+)/)?.[1]; + + await vi.advanceTimersByTimeAsync(5 * 60 * 1000 + 1); + + await expect( + registry.approvePendingAction(approvalId!, 99999, "test-chat") + ).resolves.toMatchObject({ success: false }); + expect(executor).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + it("should execute tool successfully", async () => { const tool = createMockTool("test_tool"); const mockResult = { success: true, data: "result" }; diff --git a/src/agent/tools/register-all.ts b/src/agent/tools/register-all.ts index b9036ebb..5e8d825f 100644 --- a/src/agent/tools/register-all.ts +++ b/src/agent/tools/register-all.ts @@ -17,7 +17,7 @@ import { tools as journalTools } from "./journal/index.js"; import { tools as workspaceTools } from "./workspace/index.js"; import { tools as webTools } from "./web/index.js"; import { toolSearchTool, createToolSearchExecutor } from "./search/index.js"; -import { getBuiltinMinimumAccess } from "./security-policy.js"; +import { getBuiltinMinimumAccess, requiresBuiltinApproval } from "./security-policy.js"; const ALL_CATEGORIES: ToolEntry[][] = [ telegramTools, @@ -32,14 +32,15 @@ const ALL_CATEGORIES: ToolEntry[][] = [ export function registerAllTools(registry: ToolRegistry): void { for (const category of ALL_CATEGORIES) { - for (const { tool, executor, scope, mode, tags, minimumAccess } of category) { + for (const { tool, executor, scope, mode, tags, minimumAccess, requiresApproval } of category) { registry.register( tool, executor, scope, mode, tags, - minimumAccess ?? getBuiltinMinimumAccess(tool, scope) + minimumAccess ?? getBuiltinMinimumAccess(tool, scope), + requiresApproval ?? requiresBuiltinApproval(tool.name) ); } } @@ -49,5 +50,5 @@ export function registerAllTools(registry: ToolRegistry): void { // The executor lazily reads registry.getToolIndex() + registry.getEmbedder() at call time, // both of which are set during startAgent() — after this registration. const toolSearchExecutor = createToolSearchExecutor(registry); - registry.register(toolSearchTool, toolSearchExecutor, "open", "both", ["core"], "all"); + registry.register(toolSearchTool, toolSearchExecutor, "open", "both", ["core"], "all", false); } diff --git a/src/agent/tools/registry.ts b/src/agent/tools/registry.ts index 819d160e..aa7484d3 100644 --- a/src/agent/tools/registry.ts +++ b/src/agent/tools/registry.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import { validateToolCall } from "@earendil-works/pi-ai"; import type { Tool as PiAiTool, ToolCall } from "@earendil-works/pi-ai"; import type { TSchema } from "@sinclair/typebox"; @@ -28,6 +29,18 @@ import { getErrorMessage } from "../../utils/errors.js"; import { createLogger } from "../../utils/logger.js"; const log = createLogger("Registry"); +const APPROVAL_TTL_MS = 5 * 60 * 1000; + +interface PendingApproval { + id: string; + toolName: string; + args: unknown; + context: ToolContext; + senderId: number; + chatId: string; + fingerprint: string; + createdAt: number; +} /** Reason a tool is denied for a context — mapped to a user message by execute(). */ type AccessDenial = @@ -53,6 +66,7 @@ export class ToolRegistry { private onToolsChangedCallbacks: Array<(removed: string[], added: PiAiTool[]) => void> = []; private mode: RuntimeMode; private allowFrom: Set = new Set(); + private pendingApprovals: Map = new Map(); constructor(mode: RuntimeMode = "user") { this.mode = mode; @@ -70,6 +84,7 @@ export class ToolRegistry { executor: ToolExecutor; scope?: ToolScope; minimumAccess?: ToolAccessLevel; + requiresApproval?: boolean; mode: ToolMode; module: string; tags?: string[]; @@ -81,6 +96,7 @@ export class ToolRegistry { scope: entry.scope && entry.scope !== "always" && entry.scope !== "open" ? entry.scope : undefined, minimumAccess: entry.minimumAccess ?? scopeToLevel(entry.scope), + requiresApproval: entry.requiresApproval ?? false, mode: entry.mode, module: entry.module, tags: entry.tags && entry.tags.length > 0 ? entry.tags : undefined, @@ -93,7 +109,8 @@ export class ToolRegistry { scope?: ToolScope, mode: ToolMode = "both", tags?: string[], - minimumAccess?: ToolAccessLevel + minimumAccess?: ToolAccessLevel, + requiresApproval = false ): void { if (this.tools.has(tool.name)) { throw new Error(`Tool "${tool.name}" is already registered`); @@ -106,6 +123,7 @@ export class ToolRegistry { module: tool.name.split("_")[0], tags, minimumAccess, + requiresApproval, }); this.toolArrayCache = null; } @@ -256,6 +274,150 @@ export class ToolRegistry { try { const validatedArgs = validateToolCall(this.getAll(), toolCall); + if (registered.requiresApproval) { + return await this.requestApproval(toolCall.name, validatedArgs, context); + } + + return await this.executeRegistered(toolCall.name, registered, validatedArgs, context); + } catch (error) { + log.error({ err: error }, `Error executing tool ${toolCall.name}`); + return { + success: false, + error: getErrorMessage(error), + }; + } + } + + /** Execute a single-use request after an authenticated admin command. */ + async approvePendingAction( + approvalId: string, + senderId: number, + chatId: string + ): Promise { + this.cleanupPendingApprovals(); + const pending = this.pendingApprovals.get(approvalId); + if (!pending) { + return { success: false, error: "Unknown or expired approval request" }; + } + if (pending.senderId !== senderId || pending.chatId !== chatId) { + return { success: false, error: "This approval request belongs to another admin or chat" }; + } + + // Consume before execution so concurrent/repeated commands cannot replay it. + this.pendingApprovals.delete(approvalId); + + const registered = this.tools.get(pending.toolName); + if (!registered) { + return { success: false, error: `Tool "${pending.toolName}" is no longer available` }; + } + + const isAdmin = pending.context.config?.telegram.admin_ids.includes(senderId) ?? false; + const access = this.checkAccess(pending.toolName, { + isGroup: pending.context.isGroup, + isAdmin, + senderId, + chatId, + }); + if (!access.ok) { + return { success: false, error: this.denialMessage(pending.toolName, access.reason) }; + } + + return this.executeRegistered(pending.toolName, registered, pending.args, pending.context); + } + + async rejectPendingAction( + approvalId: string, + senderId: number, + chatId: string + ): Promise { + this.cleanupPendingApprovals(); + const pending = this.pendingApprovals.get(approvalId); + if (!pending) { + return { success: false, error: "Unknown or expired approval request" }; + } + if (pending.senderId !== senderId || pending.chatId !== chatId) { + return { success: false, error: "This approval request belongs to another admin or chat" }; + } + this.pendingApprovals.delete(approvalId); + return { success: true, data: { rejected: true, tool: pending.toolName } }; + } + + private async requestApproval( + toolName: string, + args: unknown, + context: ToolContext + ): Promise { + const ownUserId = context.bridge.getOwnUserId?.(); + if (ownUserId !== undefined && context.senderId === Number(ownUserId)) { + return { + success: false, + error: + "Financial actions cannot be approved from a self-originated or autonomous context. Start an interactive admin request instead.", + }; + } + + this.cleanupPendingApprovals(); + const fingerprint = JSON.stringify([context.senderId, context.chatId, toolName, args]); + const existing = Array.from(this.pendingApprovals.values()).find( + (pending) => pending.fingerprint === fingerprint + ); + if (existing) return this.approvalRequiredResult(); + + const id = randomUUID(); + const pending: PendingApproval = { + id, + toolName, + args: structuredClone(args), + context, + senderId: context.senderId, + chatId: context.chatId, + fingerprint, + createdAt: Date.now(), + }; + this.pendingApprovals.set(id, pending); + + const serializedArgs = JSON.stringify(args, null, 2).slice(0, 3000); + try { + await context.bridge.sendMessage({ + chatId: context.chatId, + text: + `⚠️ Explicit approval required\n\n` + + `Tool: ${toolName}\n` + + `Parameters:\n${serializedArgs}\n\n` + + `Approve once: /approve ${id}\n` + + `Reject: /reject ${id}\n` + + `Expires in 5 minutes.`, + }); + } catch (error) { + this.pendingApprovals.delete(id); + throw error; + } + + return this.approvalRequiredResult(); + } + + private approvalRequiredResult(): ToolResult { + return { + success: false, + error: "Explicit owner approval is required. A separate approval request was sent.", + data: { approvalRequired: true }, + }; + } + + private cleanupPendingApprovals(): void { + const cutoff = Date.now() - APPROVAL_TTL_MS; + for (const [id, pending] of this.pendingApprovals) { + if (pending.createdAt < cutoff) this.pendingApprovals.delete(id); + } + } + + private async executeRegistered( + toolName: string, + registered: RegisteredTool, + validatedArgs: unknown, + context: ToolContext + ): Promise { + try { let timeoutHandle: ReturnType; const result = await Promise.race([ registered.executor(validatedArgs, context), @@ -263,9 +425,7 @@ export class ToolRegistry { timeoutHandle = setTimeout( () => reject( - new Error( - `Tool "${toolCall.name}" timed out after ${TOOL_EXECUTION_TIMEOUT_MS / 1000}s` - ) + new Error(`Tool "${toolName}" timed out after ${TOOL_EXECUTION_TIMEOUT_MS / 1000}s`) ), TOOL_EXECUTION_TIMEOUT_MS ); @@ -274,7 +434,7 @@ export class ToolRegistry { return result; } catch (error) { - log.error({ err: error }, `Error executing tool ${toolCall.name}`); + log.error({ err: error }, `Error executing tool ${toolName}`); return { success: false, error: getErrorMessage(error), diff --git a/src/agent/tools/security-policy.ts b/src/agent/tools/security-policy.ts index 54736df5..6fb4f383 100644 --- a/src/agent/tools/security-policy.ts +++ b/src/agent/tools/security-policy.ts @@ -22,6 +22,31 @@ const OWNER_PRIVATE_DATA = new Set([ const OWNER_ONLY_ACTIONS = new Set(["telegram_create_scheduled_task"]); +/** + * Built-ins that spend funds, sign an on-chain state change, or transfer a + * collectible. These must never rely on natural-language model instructions + * as proof of owner consent. + */ +const APPROVAL_REQUIRED = new Set([ + "ton_send", + "jetton_send", + "stonfi_swap", + "dedust_swap", + "dns_bid", + "dns_start_auction", + "dns_link", + "dns_set_site", + "dns_unlink", + "telegram_buy_resale_gift", + "telegram_set_collectible_price", + "telegram_send_gift_offer", + "telegram_resolve_gift_offer", +]); + +export function requiresBuiltinApproval(toolName: string): boolean { + return APPROVAL_REQUIRED.has(toolName); +} + /** * Built-in security defaults. Channel placement and authority are intentionally * independent: a tool can be DM-only and still require an admin identity. diff --git a/src/agent/tools/types.ts b/src/agent/tools/types.ts index a2083fbf..61a6a424 100644 --- a/src/agent/tools/types.ts +++ b/src/agent/tools/types.ts @@ -108,6 +108,8 @@ export interface RegisteredTool { scope?: ToolScope; /** Hard authority floor. Runtime configuration may only make access stricter. */ minimumAccess: ToolAccessLevel; + /** Whether execution requires a separate, human-authenticated approval step. */ + requiresApproval: boolean; /** Telegram mode this tool runs in. */ mode: ToolMode; /** Module this tool belongs to (name prefix for built-ins, plugin name otherwise). */ @@ -127,6 +129,8 @@ export interface ToolEntry { scope?: ToolScope; /** Hard authority floor, independent from the DM/group channel scope. */ minimumAccess?: ToolAccessLevel; + /** Require a separate owner command before executing this tool. */ + requiresApproval?: boolean; /** Telegram mode(s) this tool runs in. Mandatory — every tool must declare it. */ mode: ToolMode; /** Toolset tags for profile-based filtering (e.g. "core", "finance", "social") */ diff --git a/src/telegram/__tests__/admin-approval.test.ts b/src/telegram/__tests__/admin-approval.test.ts new file mode 100644 index 00000000..749fec68 --- /dev/null +++ b/src/telegram/__tests__/admin-approval.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from "vitest"; +import { AdminHandler } from "../admin.js"; + +function createHandler(ownUserId?: bigint) { + const registry = { + approvePendingAction: vi.fn(async () => ({ + success: true, + data: { message: "Sent 1 TON — tx abc" }, + })), + rejectPendingAction: vi.fn(async () => ({ success: true })), + }; + const handler = new AdminHandler( + { getOwnUserId: () => ownUserId } as never, + { admin_ids: [42] } as never, + {} as never, + "/tmp/config.yaml", + undefined, + registry as never + ); + return { handler, registry }; +} + +describe("financial approval admin commands", () => { + it("approves a pending action as the same admin and chat", async () => { + const { handler, registry } = createHandler(); + const command = handler.parseCommand("/approve abc-123"); + + const response = await handler.handleCommand(command!, "chat-1", 42, false); + + expect(registry.approvePendingAction).toHaveBeenCalledWith("abc-123", 42, "chat-1"); + expect(response).toContain("Sent 1 TON"); + }); + + it("rejects a pending action", async () => { + const { handler, registry } = createHandler(); + const command = handler.parseCommand("/reject abc-123"); + + const response = await handler.handleCommand(command!, "chat-1", 42, false); + + expect(registry.rejectPendingAction).toHaveBeenCalledWith("abc-123", 42, "chat-1"); + expect(response).toContain("rejected"); + }); + + it("never delegates approval commands from a non-admin", async () => { + const { handler, registry } = createHandler(); + const command = handler.parseCommand("/approve abc-123"); + + const response = await handler.handleCommand(command!, "chat-1", 7, false); + + expect(response).toContain("Admin access required"); + expect(registry.approvePendingAction).not.toHaveBeenCalled(); + }); + + it("never accepts a self-authored approval in user mode", async () => { + const { handler, registry } = createHandler(42n); + const command = handler.parseCommand("/approve abc-123"); + + const response = await handler.handleCommand(command!, "chat-1", 42, false); + + expect(response).toContain("separate interactive admin account"); + expect(registry.approvePendingAction).not.toHaveBeenCalled(); + }); +}); diff --git a/src/telegram/admin.ts b/src/telegram/admin.ts index 0b439e4f..40f9f912 100644 --- a/src/telegram/admin.ts +++ b/src/telegram/admin.ts @@ -105,6 +105,10 @@ export class AdminHandler { return this.handleResumeCommand(); case "wallet": return await this.handleWalletCommand(); + case "approve": + return await this.handleApprovalCommand(command, true); + case "reject": + return await this.handleApprovalCommand(command, false); case "stop": return await this.handleStopCommand(); case "verbose": @@ -240,6 +244,35 @@ export class AdminHandler { return `💎 **${result.balance} TON**\n📍 \`${friendly}\``; } + private async handleApprovalCommand(command: AdminCommand, approve: boolean): Promise { + const ownUserId = this.bridge.getOwnUserId?.(); + if (ownUserId !== undefined && command.senderId === Number(ownUserId)) { + return "❌ Approval must come from a separate interactive admin account"; + } + + const approvalId = command.args[0]; + if (!approvalId) { + return `❌ Usage: /${approve ? "approve" : "reject"} `; + } + if (!this.registry) return "❌ Tool registry unavailable"; + + const result = approve + ? await this.registry.approvePendingAction(approvalId, command.senderId, command.chatId) + : await this.registry.rejectPendingAction(approvalId, command.senderId, command.chatId); + + if (!result.success) return `❌ ${result.error ?? "Approval failed"}`; + if (!approve) return `🚫 Approval request ${approvalId} rejected.`; + + const data = result.data; + const message = + data && typeof data === "object" && "message" in data && typeof data.message === "string" + ? data.message + : data === undefined + ? "Action completed." + : JSON.stringify(data); + return `✅ Approved action completed\n\n${message}`; + } + getBootstrapContent(): string | null { try { return loadTemplate("BOOTSTRAP.md"); @@ -536,6 +569,9 @@ Manage plugin secrets (API keys, tokens) **/wallet** Check TON wallet balance +**/approve** / **/reject** +Approve or reject a pending financial action + **/verbose** Toggle verbose debug logging diff --git a/src/telegram/bridges/bot.ts b/src/telegram/bridges/bot.ts index de857664..f28aeaa7 100644 --- a/src/telegram/bridges/bot.ts +++ b/src/telegram/bridges/bot.ts @@ -575,6 +575,8 @@ export class GrammyBotBridge implements ITelegramBridge { { command: "modules", description: "Manage module permissions" }, { command: "plugin", description: "Manage plugin secrets" }, { command: "wallet", description: "Check TON wallet balance" }, + { command: "approve", description: "Approve a pending financial action" }, + { command: "reject", description: "Reject a pending financial action" }, { command: "verbose", description: "Toggle verbose logging" }, { command: "rag", description: "Toggle Tool RAG or view status" }, { command: "guest", description: "Toggle guest mode" }, From d98238ad4aceccfa6a1b38c84c37343561de5092 Mon Sep 17 00:00:00 2001 From: TONresistor <240980241+TONresistor@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:31:30 +0200 Subject: [PATCH 15/59] fix(runtime): keep side-effect operations serialized --- src/agent/tools/__tests__/registry.test.ts | 43 +++++++++++++++++++++- src/agent/tools/registry.ts | 7 ++++ src/ton/__tests__/tx-lock.test.ts | 36 +++++++++++++++--- src/ton/tx-lock.ts | 18 +++------ 4 files changed, 83 insertions(+), 21 deletions(-) diff --git a/src/agent/tools/__tests__/registry.test.ts b/src/agent/tools/__tests__/registry.test.ts index c6a2a421..0724d6d2 100644 --- a/src/agent/tools/__tests__/registry.test.ts +++ b/src/agent/tools/__tests__/registry.test.ts @@ -675,10 +675,10 @@ describe("ToolRegistry", () => { expect(result.error).toBe("Execution failed"); }); - it("should timeout long-running tools", async () => { + it("should timeout long-running data-bearing tools", async () => { vi.useFakeTimers(); - const tool = createMockTool("slow_tool"); + const tool = createMockTool("slow_tool", "data-bearing"); const executor = vi.fn(async () => { await new Promise((resolve) => setTimeout(resolve, 100_000)); return { success: true }; @@ -704,6 +704,45 @@ describe("ToolRegistry", () => { vi.useRealTimers(); }); + + it("should not report an action timeout while its side effect is still running", async () => { + vi.useFakeTimers(); + try { + let sideEffectCompleted = false; + let resultSettled = false; + const tool = createMockTool("slow_action", "action"); + const executor = vi.fn(async () => { + await new Promise((resolve) => setTimeout(resolve, 100_000)); + sideEffectCompleted = true; + return { success: true }; + }); + registry.register(tool, executor); + + const resultPromise = registry.execute( + { + type: "toolCall", + id: "slow-action-call", + name: tool.name, + arguments: { message: "perform once" }, + }, + mockContext + ); + void resultPromise.then(() => { + resultSettled = true; + }); + + await vi.advanceTimersByTimeAsync(90_000); + + expect(resultSettled).toBe(false); + expect(sideEffectCompleted).toBe(false); + + await vi.advanceTimersByTimeAsync(10_000); + await expect(resultPromise).resolves.toEqual({ success: true }); + expect(sideEffectCompleted).toBe(true); + } finally { + vi.useRealTimers(); + } + }); }); // ---------- Tool configuration ---------- diff --git a/src/agent/tools/registry.ts b/src/agent/tools/registry.ts index aa7484d3..230fffc4 100644 --- a/src/agent/tools/registry.ts +++ b/src/agent/tools/registry.ts @@ -418,6 +418,13 @@ export class ToolRegistry { context: ToolContext ): Promise { try { + // An action has no cancellation channel. Racing it against a timer would + // report failure while the side effect continues, inviting a duplicate + // retry. Only explicitly read-only tools may use the registry timeout. + if (registered.tool.category !== "data-bearing") { + return await registered.executor(validatedArgs, context); + } + let timeoutHandle: ReturnType; const result = await Promise.race([ registered.executor(validatedArgs, context), diff --git a/src/ton/__tests__/tx-lock.test.ts b/src/ton/__tests__/tx-lock.test.ts index 67952de2..009010aa 100644 --- a/src/ton/__tests__/tx-lock.test.ts +++ b/src/ton/__tests__/tx-lock.test.ts @@ -87,16 +87,40 @@ describe("TxLock", () => { ).rejects.toThrow("inner error"); }); - it("should timeout after 60 seconds", async () => { - // Use fake timers for timeout test - const neverResolves = withTxLock( - () => new Promise(() => {}) // never resolves + it("should hold the lock until the underlying transaction settles", async () => { + let finishFirst!: () => void; + let firstState: "pending" | "resolved" | "rejected" = "pending"; + let secondStarted = false; + + const first = withTxLock( + () => + new Promise((resolve) => { + finishFirst = resolve; + }) ); + void first.then( + () => { + firstState = "resolved"; + }, + () => { + firstState = "rejected"; + } + ); + const second = withTxLock(async () => { + secondStarted = true; + return "second"; + }); - // Advance past the 60s timeout — async version resolves microtasks await vi.advanceTimersByTimeAsync(61_000); - await expect(neverResolves).rejects.toThrow("TON tx-lock timeout (60s)"); + expect(firstState).toBe("pending"); + expect(secondStarted).toBe(false); + + finishFirst(); + await expect(first).resolves.toBeUndefined(); + await expect(second).resolves.toBe("second"); + expect(firstState).toBe("resolved"); + expect(secondStarted).toBe(true); }); }); }); diff --git a/src/ton/tx-lock.ts b/src/ton/tx-lock.ts index a81e922a..f692b026 100644 --- a/src/ton/tx-lock.ts +++ b/src/ton/tx-lock.ts @@ -5,20 +5,12 @@ */ let pending: Promise = Promise.resolve(); -const TX_LOCK_TIMEOUT_MS = 60_000; - export function withTxLock(fn: () => Promise): Promise { - const guarded = () => { - let timerId: ReturnType | undefined; - const timeoutPromise = new Promise((_, reject) => { - timerId = setTimeout( - () => reject(new Error("TON tx-lock timeout (60s)")), - TX_LOCK_TIMEOUT_MS - ); - }); - return Promise.race([fn(), timeoutPromise]).finally(() => clearTimeout(timerId)); - }; - const execute = pending.then(guarded, guarded); + // There is no safe way to time out fn(): wallet SDK calls do not expose a + // cancellation guarantee. Releasing the mutex early lets a second operation + // reuse seqno while the first one is still broadcasting. Hold it until the + // underlying operation actually settles; network calls own their timeouts. + const execute = pending.then(fn, fn); pending = execute.then( () => {}, () => {} From 5f967e0c78863bd85d8d0fe1a3277b4cc891a727 Mon Sep 17 00:00:00 2001 From: TONresistor <240980241+TONresistor@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:41:58 +0200 Subject: [PATCH 16/59] fix(finance): bind gift purchase approval to price --- .../gifts/__tests__/buy-resale-gift.test.ts | 64 +++++++++++++++++++ .../tools/telegram/gifts/buy-resale-gift.ts | 34 +++++++++- .../tools/telegram/gifts/get-resale-gifts.ts | 3 +- 3 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 src/agent/tools/telegram/gifts/__tests__/buy-resale-gift.test.ts diff --git a/src/agent/tools/telegram/gifts/__tests__/buy-resale-gift.test.ts b/src/agent/tools/telegram/gifts/__tests__/buy-resale-gift.test.ts new file mode 100644 index 00000000..3a332f11 --- /dev/null +++ b/src/agent/tools/telegram/gifts/__tests__/buy-resale-gift.test.ts @@ -0,0 +1,64 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const invoke = vi.fn(); + +vi.mock("../../../../../sdk/telegram-utils.js", () => ({ + getClient: vi.fn(() => ({ invoke })), +})); + +import { telegramBuyResaleGiftExecutor } from "../buy-resale-gift.js"; + +function paymentForm(price: number, currency = "XTR") { + return { + formId: 123n, + invoice: { + currency, + prices: [{ label: "gift", amount: BigInt(price) }], + }, + }; +} + +describe("telegram_buy_resale_gift", () => { + beforeEach(() => { + invoke.mockReset(); + }); + + it("fails closed when the current price exceeds the approved maximum", async () => { + invoke.mockResolvedValueOnce(paymentForm(101)); + + const result = await telegramBuyResaleGiftExecutor({ slug: "Plush-1", maxPriceStars: 100 }, { + bridge: {}, + } as never); + + expect(result.success).toBe(false); + expect(result.error).toContain("101 Stars"); + expect(result.error).toContain("approved maximum of 100 Stars"); + expect(invoke).toHaveBeenCalledOnce(); + }); + + it("purchases only after verifying the live Stars price", async () => { + invoke.mockResolvedValueOnce(paymentForm(100)).mockResolvedValueOnce({}); + + const result = await telegramBuyResaleGiftExecutor({ slug: "Plush-1", maxPriceStars: 100 }, { + bridge: {}, + } as never); + + expect(result).toMatchObject({ + success: true, + data: { slug: "Plush-1", purchased: true, priceStars: 100 }, + }); + expect(invoke).toHaveBeenCalledTimes(2); + }); + + it("rejects a non-Stars payment form", async () => { + invoke.mockResolvedValueOnce(paymentForm(1, "TON")); + + const result = await telegramBuyResaleGiftExecutor({ slug: "Plush-1", maxPriceStars: 100 }, { + bridge: {}, + } as never); + + expect(result.success).toBe(false); + expect(result.error).toContain("Unsupported resale currency"); + expect(invoke).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/agent/tools/telegram/gifts/buy-resale-gift.ts b/src/agent/tools/telegram/gifts/buy-resale-gift.ts index 7519f6a3..272c425f 100644 --- a/src/agent/tools/telegram/gifts/buy-resale-gift.ts +++ b/src/agent/tools/telegram/gifts/buy-resale-gift.ts @@ -12,6 +12,7 @@ const log = createLogger("Tools"); */ interface BuyResaleGiftParams { slug: string; + maxPriceStars: number; } /** @@ -20,11 +21,16 @@ interface BuyResaleGiftParams { export const telegramBuyResaleGiftTool: Tool = { name: "telegram_buy_resale_gift", description: - "Buy a collectible from the resale marketplace using Stars. Get slug from telegram_get_resale_gifts.", + "Buy a collectible from the resale marketplace using Stars. Get slug and priceStars from telegram_get_resale_gifts, then set maxPriceStars to the exact maximum approved by the owner.", parameters: Type.Object({ slug: Type.String({ description: "The slug of the listing to purchase (from telegram_get_resale_gifts)", }), + maxPriceStars: Type.Integer({ + description: + "Maximum Stars the owner approved for this purchase. The live payment form is checked before spending.", + minimum: 1, + }), }), }; @@ -36,7 +42,7 @@ export const telegramBuyResaleGiftExecutor: ToolExecutor = context ): Promise => { try { - const { slug } = params; + const { slug, maxPriceStars } = params; const gramJsClient = getClient(context.bridge); // Buy for self @@ -48,6 +54,27 @@ export const telegramBuyResaleGiftExecutor: ToolExecutor = }); const form = await gramJsClient.invoke(new Api.payments.GetPaymentForm({ invoice })); + if (form.invoice.currency !== "XTR") { + return { + success: false, + error: `Unsupported resale currency: ${form.invoice.currency}. This tool only purchases with Stars.`, + }; + } + + const livePrice = form.invoice.prices.reduce( + (total, price) => total + BigInt(price.amount.toString()), + 0n + ); + if (livePrice > BigInt(Number.MAX_SAFE_INTEGER)) { + return { success: false, error: "Live resale price is too large to verify safely" }; + } + const livePriceStars = Number(livePrice); + if (livePriceStars > maxPriceStars) { + return { + success: false, + error: `Live resale price is ${livePriceStars} Stars, above the approved maximum of ${maxPriceStars} Stars. Request a new approval.`, + }; + } // Complete the purchase await gramJsClient.invoke( @@ -62,7 +89,8 @@ export const telegramBuyResaleGiftExecutor: ToolExecutor = data: { slug, purchased: true, - message: "Collectible purchased successfully! It's now in your collection.", + priceStars: livePriceStars, + message: `Collectible purchased for ${livePriceStars} Stars. It's now in your collection.`, }, }; } catch (error) { diff --git a/src/agent/tools/telegram/gifts/get-resale-gifts.ts b/src/agent/tools/telegram/gifts/get-resale-gifts.ts index 47bc98fa..063e3a1d 100644 --- a/src/agent/tools/telegram/gifts/get-resale-gifts.ts +++ b/src/agent/tools/telegram/gifts/get-resale-gifts.ts @@ -120,7 +120,8 @@ export const telegramGetResaleGiftsExecutor: ToolExecutor listings, count: listings.length, totalCount: result.count, - usage: "Use telegram_buy_resale_gift(slug) to purchase", + usage: + "Use telegram_buy_resale_gift(slug, maxPriceStars) with the listing's priceStars as the owner-approved maximum.", }, }; } catch (error) { From a17b3f992599438fbfc3a97eca1fc07ce8021b6d Mon Sep 17 00:00:00 2001 From: TONresistor <240980241+TONresistor@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:13:17 +0200 Subject: [PATCH 17/59] fix(tools): protect owner-private reads --- .../tools/__tests__/builtin-access.test.ts | 38 +++++++++++++++++++ src/agent/tools/security-policy.ts | 12 ++++++ 2 files changed, 50 insertions(+) diff --git a/src/agent/tools/__tests__/builtin-access.test.ts b/src/agent/tools/__tests__/builtin-access.test.ts index 5dcef78b..077ee29d 100644 --- a/src/agent/tools/__tests__/builtin-access.test.ts +++ b/src/agent/tools/__tests__/builtin-access.test.ts @@ -3,6 +3,7 @@ import { ToolRegistry } from "../registry.js"; import { registerAllTools } from "../register-all.js"; const ADMIN_ONLY_TOOLS = [ + "ton_get_address", "ton_send", "jetton_send", "stonfi_swap", @@ -21,6 +22,29 @@ const ADMIN_ONLY_TOOLS = [ "session_search", ] as const; +const OWNER_PRIVATE_READS = [ + "ton_get_address", + "ton_get_balance", + "ton_my_transactions", + "jetton_balances", + "nft_list", + "telegram_get_admined_channels", + "telegram_get_blocked", + "telegram_get_common_chats", + "telegram_get_dialogs", + "telegram_get_folders", + "telegram_get_history", + "telegram_get_me", + "telegram_get_my_gifts", + "telegram_get_my_stickers", + "telegram_get_replies", + "telegram_get_scheduled_messages", + "telegram_get_stars_balance", + "telegram_get_stars_transactions", + "telegram_get_user_info", + "telegram_search_messages", +] as const; + describe("built-in tool access policy", () => { it("hides private and financial tools from a non-admin DM", () => { const registry = new ToolRegistry("user"); @@ -49,6 +73,20 @@ describe("built-in tool access policy", () => { expect(visible.has("telegram_get_history")).toBe(false); }); + it("keeps every owner-default and account-private read admin-only", () => { + const registry = new ToolRegistry("user"); + registerAllTools(registry); + registry.setAllowFrom([12345]); + + const visible = new Set( + registry.getForContext(false, null, "dm", false, 12345).map((t) => t.name) + ); + + for (const name of OWNER_PRIVATE_READS) { + expect(visible.has(name), `${name} must remain owner-only`).toBe(false); + } + }); + it("wires the approval gate into real financial tool registrations", async () => { const registry = new ToolRegistry("user"); registerAllTools(registry); diff --git a/src/agent/tools/security-policy.ts b/src/agent/tools/security-policy.ts index 6fb4f383..bc82c437 100644 --- a/src/agent/tools/security-policy.ts +++ b/src/agent/tools/security-policy.ts @@ -10,14 +10,26 @@ const OWNER_PRIVATE_DATA = new Set([ "memory_read", "memory_search", "session_search", + "telegram_get_admined_channels", + "telegram_get_blocked", + "telegram_get_common_chats", "telegram_get_dialogs", + "telegram_get_folders", "telegram_get_history", + "telegram_get_me", + "telegram_get_my_gifts", + "telegram_get_my_stickers", "telegram_get_replies", "telegram_get_scheduled_messages", + "telegram_get_stars_balance", + "telegram_get_stars_transactions", + "telegram_get_user_info", "telegram_search_messages", + "ton_get_address", "ton_get_balance", "ton_my_transactions", "jetton_balances", + "nft_list", ]); const OWNER_ONLY_ACTIONS = new Set(["telegram_create_scheduled_task"]); From e1a20db8fb8c7159fd6546c9b5910b90fd7e8006 Mon Sep 17 00:00:00 2001 From: TONresistor <240980241+TONresistor@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:16:15 +0200 Subject: [PATCH 18/59] fix(mcp): cancel timed-out tool requests --- src/agent/tools/__tests__/mcp-loader.test.ts | 61 ++++++++++++++++++++ src/agent/tools/mcp-loader.ts | 26 ++++----- 2 files changed, 71 insertions(+), 16 deletions(-) create mode 100644 src/agent/tools/__tests__/mcp-loader.test.ts diff --git a/src/agent/tools/__tests__/mcp-loader.test.ts b/src/agent/tools/__tests__/mcp-loader.test.ts new file mode 100644 index 00000000..a3fef941 --- /dev/null +++ b/src/agent/tools/__tests__/mcp-loader.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from "vitest"; +import { registerMcpTools } from "../mcp-loader.js"; + +describe("MCP tool execution", () => { + it("delegates timeout/cancellation to the MCP client without orphaning the call", async () => { + vi.useFakeTimers(); + try { + let registeredTools: Array<{ executor: (params: unknown) => Promise }> = []; + let sideEffectCompleted = false; + const callTool = vi.fn(async () => { + await new Promise((resolve) => setTimeout(resolve, 100_000)); + sideEffectCompleted = true; + return { isError: false, content: [{ type: "text", text: "done" }] }; + }); + const registry = { + registerPluginTools: vi.fn((_name, tools) => { + registeredTools = tools; + return tools.length; + }), + }; + const client = { + listTools: vi.fn(async () => ({ + tools: [ + { + name: "mutate", + description: "Perform one side effect", + inputSchema: { type: "object", properties: { id: { type: "string" } } }, + }, + ], + })), + callTool, + }; + + await registerMcpTools( + [{ serverName: "test", client: client as never, scope: "admin-only" }], + registry as never + ); + + let settled = false; + const resultPromise = registeredTools[0].executor({ id: "once" }); + void resultPromise.then(() => { + settled = true; + }); + + await vi.advanceTimersByTimeAsync(90_000); + expect(settled).toBe(false); + expect(sideEffectCompleted).toBe(false); + + await vi.advanceTimersByTimeAsync(10_000); + await expect(resultPromise).resolves.toEqual({ success: true, data: "done" }); + expect(sideEffectCompleted).toBe(true); + expect(callTool).toHaveBeenCalledWith( + { name: "mutate", arguments: { id: "once" } }, + undefined, + expect.objectContaining({ timeout: 90_000 }) + ); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/src/agent/tools/mcp-loader.ts b/src/agent/tools/mcp-loader.ts index 95c53da7..588ddbf1 100644 --- a/src/agent/tools/mcp-loader.ts +++ b/src/agent/tools/mcp-loader.ts @@ -192,24 +192,17 @@ export async function registerMcpTools( const executor: ToolExecutor = async (params): Promise => { try { - let timeoutHandle: ReturnType; - const result = await Promise.race([ - conn.client.callTool({ + // Use the MCP protocol's request timeout so the client sends a + // cancellation notification. A local Promise.race would return an + // error while leaving the remote operation orphaned and retryable. + const result = await conn.client.callTool( + { name: mcpTool.name, arguments: params as Record, - }), - new Promise((_, reject) => { - timeoutHandle = setTimeout( - () => - reject( - new Error( - `MCP tool "${mcpTool.name}" timed out after ${TOOL_EXECUTION_TIMEOUT_MS / 1000}s` - ) - ), - TOOL_EXECUTION_TIMEOUT_MS - ); - }), - ]).finally(() => clearTimeout(timeoutHandle)); + }, + undefined, + { timeout: TOOL_EXECUTION_TIMEOUT_MS } + ); if (result.isError) { const errorText = extractText( @@ -247,6 +240,7 @@ export async function registerMcpTools( name: prefixedName, description: mcpTool.description || `MCP tool from ${conn.serverName}`, parameters: schema as unknown as Tool["parameters"], + category: mcpTool.annotations?.readOnlyHint === true ? "data-bearing" : "action", }, executor, scope: conn.scope, From cff427b5a8307ce8b62685e962ae80b1cac85881 Mon Sep 17 00:00:00 2001 From: TONresistor <240980241+TONresistor@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:19:54 +0200 Subject: [PATCH 19/59] fix(mcp): treat remote tools as untrusted actions --- src/agent/tools/__tests__/mcp-loader.test.ts | 12 +++++++++--- src/agent/tools/mcp-loader.ts | 19 ++++++++++++------- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/src/agent/tools/__tests__/mcp-loader.test.ts b/src/agent/tools/__tests__/mcp-loader.test.ts index a3fef941..19f557e5 100644 --- a/src/agent/tools/__tests__/mcp-loader.test.ts +++ b/src/agent/tools/__tests__/mcp-loader.test.ts @@ -2,10 +2,13 @@ import { describe, expect, it, vi } from "vitest"; import { registerMcpTools } from "../mcp-loader.js"; describe("MCP tool execution", () => { - it("delegates timeout/cancellation to the MCP client without orphaning the call", async () => { + it("runs untrusted MCP tools without an operational retry-producing timeout", async () => { vi.useFakeTimers(); try { - let registeredTools: Array<{ executor: (params: unknown) => Promise }> = []; + let registeredTools: Array<{ + tool: { category?: string }; + executor: (params: unknown) => Promise; + }> = []; let sideEffectCompleted = false; const callTool = vi.fn(async () => { await new Promise((resolve) => setTimeout(resolve, 100_000)); @@ -24,6 +27,7 @@ describe("MCP tool execution", () => { { name: "mutate", description: "Perform one side effect", + annotations: { readOnlyHint: true }, inputSchema: { type: "object", properties: { id: { type: "string" } } }, }, ], @@ -36,6 +40,8 @@ describe("MCP tool execution", () => { registry as never ); + expect(registeredTools[0].tool.category).toBe("action"); + let settled = false; const resultPromise = registeredTools[0].executor({ id: "once" }); void resultPromise.then(() => { @@ -52,7 +58,7 @@ describe("MCP tool execution", () => { expect(callTool).toHaveBeenCalledWith( { name: "mutate", arguments: { id: "once" } }, undefined, - expect.objectContaining({ timeout: 90_000 }) + expect.objectContaining({ timeout: 2_147_483_647 }) ); } finally { vi.useRealTimers(); diff --git a/src/agent/tools/mcp-loader.ts b/src/agent/tools/mcp-loader.ts index 588ddbf1..70e9c839 100644 --- a/src/agent/tools/mcp-loader.ts +++ b/src/agent/tools/mcp-loader.ts @@ -24,9 +24,12 @@ export interface McpConnection { scope: ToolScope; } -import { TOOL_EXECUTION_TIMEOUT_MS } from "../../constants/timeouts.js"; - const MCP_CONNECT_TIMEOUT_MS = 30_000; +// Node's largest non-overflowing timer (~24.8 days). MCP tool metadata is +// remote-controlled, so no MCP tool is trusted read-only enough for a normal +// retry-producing timeout. This is effectively "no operational timeout" while +// preserving the SDK's required finite request timer. +const MCP_UNTRUSTED_TOOL_TIMEOUT_MS = 2_147_483_647; /** * Parse a command string into command + args. @@ -192,16 +195,16 @@ export async function registerMcpTools( const executor: ToolExecutor = async (params): Promise => { try { - // Use the MCP protocol's request timeout so the client sends a - // cancellation notification. A local Promise.race would return an - // error while leaving the remote operation orphaned and retryable. + // MCP cancellation is cooperative and cannot prove a remote side + // effect stopped. Keep untrusted tool calls pending instead of + // returning a normal timeout that invites a duplicate retry. const result = await conn.client.callTool( { name: mcpTool.name, arguments: params as Record, }, undefined, - { timeout: TOOL_EXECUTION_TIMEOUT_MS } + { timeout: MCP_UNTRUSTED_TOOL_TIMEOUT_MS } ); if (result.isError) { @@ -240,7 +243,9 @@ export async function registerMcpTools( name: prefixedName, description: mcpTool.description || `MCP tool from ${conn.serverName}`, parameters: schema as unknown as Tool["parameters"], - category: mcpTool.annotations?.readOnlyHint === true ? "data-bearing" : "action", + // readOnlyHint is supplied by the remote server and is not a local + // safety guarantee. Treat every MCP tool as an action. + category: "action", }, executor, scope: conn.scope, From 93b26b19db7f7272f448dc2f1e93ce51b2dc901d Mon Sep 17 00:00:00 2001 From: TONresistor <240980241+TONresistor@users.noreply.github.com> Date: Fri, 10 Jul 2026 12:54:36 +0200 Subject: [PATCH 20/59] feat(codex): support GPT-5.6 models --- package-lock.json | 8 ++--- package.json | 2 +- src/agent/client.ts | 2 +- .../tools/telegram/media/vision-analyze.ts | 2 +- src/config/model-catalog.ts | 17 +++++++++- src/memory/ai-summarization.ts | 7 ++++- src/providers/__tests__/codex-models.test.ts | 31 +++++++++++++++++++ src/providers/model-resolver.ts | 2 +- src/session/memory-hook.ts | 2 +- 9 files changed, 62 insertions(+), 11 deletions(-) create mode 100644 src/providers/__tests__/codex-models.test.ts diff --git a/package-lock.json b/package-lock.json index 36f7babf..12c15a6a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ ], "dependencies": { "@dedust/sdk": "^0.8.7", - "@earendil-works/pi-ai": "^0.79.8", + "@earendil-works/pi-ai": "^0.80.6", "@hono/node-server": "^2.0.4", "@huggingface/transformers": "^3.8.1", "@inquirer/prompts": "^8.3.2", @@ -658,9 +658,9 @@ } }, "node_modules/@earendil-works/pi-ai": { - "version": "0.79.8", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.79.8.tgz", - "integrity": "sha512-ZpSwaD7oNpsjn9vtEatZQNT9PSdDJXi6rFeY5Qv+OHQGFDKlmcrfJE4ypm4SAc/fBECPs4Rdi3l+YjVtXYrkKw==", + "version": "0.80.6", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.6.tgz", + "integrity": "sha512-7xfLk8sANBp+bpPEbjoOZTbPxsa+++b1JXAoSJsNa3vbs9AHHEclmvg54XLQcxH+fuwaeti/g2jeIfJ+mVYLpA==", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", diff --git a/package.json b/package.json index fa8f01fc..a454314b 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,7 @@ }, "dependencies": { "@dedust/sdk": "^0.8.7", - "@earendil-works/pi-ai": "^0.79.8", + "@earendil-works/pi-ai": "^0.80.6", "@hono/node-server": "^2.0.4", "@huggingface/transformers": "^3.8.1", "@inquirer/prompts": "^8.3.2", diff --git a/src/agent/client.ts b/src/agent/client.ts index 17a55ff1..a075e2cb 100644 --- a/src/agent/client.ts +++ b/src/agent/client.ts @@ -6,7 +6,7 @@ import { type Message, type Tool, type ProviderStreamOptions, -} from "@earendil-works/pi-ai"; +} from "@earendil-works/pi-ai/compat"; import type { AgentConfig } from "../config/schema.js"; import { appendToTranscript, readTranscript } from "../session/transcript.js"; import type { SupportedProvider } from "../config/providers.js"; diff --git a/src/agent/tools/telegram/media/vision-analyze.ts b/src/agent/tools/telegram/media/vision-analyze.ts index bea48936..daf73398 100644 --- a/src/agent/tools/telegram/media/vision-analyze.ts +++ b/src/agent/tools/telegram/media/vision-analyze.ts @@ -5,7 +5,7 @@ import { type UserMessage, type ImageContent, type TextContent, -} from "@earendil-works/pi-ai"; +} from "@earendil-works/pi-ai/compat"; import { getProviderModel, getEffectiveApiKey } from "../../../client.js"; import { getProviderMetadata, type SupportedProvider } from "../../../../config/providers.js"; import { readFileSync, existsSync } from "fs"; diff --git a/src/config/model-catalog.ts b/src/config/model-catalog.ts index d0d0f56f..e83508c5 100644 --- a/src/config/model-catalog.ts +++ b/src/config/model-catalog.ts @@ -70,7 +70,22 @@ export const MODEL_OPTIONS: Record = { { value: "gpt-4.1-mini", name: "GPT-4.1 Mini", description: "1M ctx, cheap, $0.40/$1.60" }, ], "openai-codex": [ - { value: "gpt-5.5", name: "GPT-5.5", description: "Latest frontier, reasoning, 272K ctx" }, + { value: "gpt-5.5", name: "GPT-5.5", description: "Latest stable, reasoning, 272K ctx" }, + { + value: "gpt-5.6-sol", + name: "GPT-5.6 Sol", + description: "Latest frontier agentic coding model, preview, 372K ctx", + }, + { + value: "gpt-5.6-terra", + name: "GPT-5.6 Terra", + description: "Balanced agentic coding model, preview, 372K ctx", + }, + { + value: "gpt-5.6-luna", + name: "GPT-5.6 Luna", + description: "Fast and affordable agentic coding model, preview, 372K ctx", + }, { value: "gpt-5.4", name: "GPT-5.4", description: "Reasoning, 272K ctx" }, { value: "gpt-5.4-mini", name: "GPT-5.4 Mini", description: "Fast & cheap, reasoning" }, { value: "gpt-5.3-codex", name: "GPT-5.3 Codex", description: "Coding specialist, 272K ctx" }, diff --git a/src/memory/ai-summarization.ts b/src/memory/ai-summarization.ts index bb75f12e..8d689769 100644 --- a/src/memory/ai-summarization.ts +++ b/src/memory/ai-summarization.ts @@ -1,4 +1,9 @@ -import { complete, type Context, type Message, type TextContent } from "@earendil-works/pi-ai"; +import { + complete, + type Context, + type Message, + type TextContent, +} from "@earendil-works/pi-ai/compat"; import { extractText, extractToolNames, stripEnvelopePrefix } from "../utils/pi-message.js"; import { getUtilityModel } from "../providers/model-resolver.js"; import type { SupportedProvider } from "../config/providers.js"; diff --git a/src/providers/__tests__/codex-models.test.ts b/src/providers/__tests__/codex-models.test.ts new file mode 100644 index 00000000..55cf2b4f --- /dev/null +++ b/src/providers/__tests__/codex-models.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { getModelsForProvider } from "../../config/model-catalog.js"; +import { getProviderMetadata } from "../../config/providers.js"; +import { getProviderModel } from "../model-resolver.js"; + +const GPT_56_CODEX_MODELS = ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"] as const; + +describe("Codex GPT-5.6 models", () => { + it.each(GPT_56_CODEX_MODELS)("resolves %s through the Codex Responses provider", (modelId) => { + const model = getProviderModel("codex", modelId); + + expect(model.id).toBe(modelId); + expect(model.provider).toBe("openai-codex"); + expect(model.api).toBe("openai-codex-responses"); + expect(model.contextWindow).toBe(372_000); + expect(model.maxTokens).toBe(128_000); + }); + + it("exposes every GPT-5.6 variant in the shared Codex catalog", () => { + const modelIds = getModelsForProvider("codex").map((model) => model.value); + + expect(modelIds).toEqual(expect.arrayContaining(GPT_56_CODEX_MODELS)); + }); + + it("keeps GPT-5.5 as the Codex default while GPT-5.6 is in preview", () => { + const defaultModel = getProviderMetadata("codex").defaultModel; + + expect(defaultModel).toBe("gpt-5.5"); + expect(getModelsForProvider("codex")[0]?.value).toBe(defaultModel); + }); +}); diff --git a/src/providers/model-resolver.ts b/src/providers/model-resolver.ts index 3ca148e5..af503d33 100644 --- a/src/providers/model-resolver.ts +++ b/src/providers/model-resolver.ts @@ -1,4 +1,4 @@ -import { getModel, type Model, type Api } from "@earendil-works/pi-ai"; +import { getModel, type Model, type Api } from "@earendil-works/pi-ai/compat"; import { getProviderMetadata, type SupportedProvider } from "../config/providers.js"; import { createLogger } from "../utils/logger.js"; import { fetchWithTimeout } from "../utils/fetch.js"; diff --git a/src/session/memory-hook.ts b/src/session/memory-hook.ts index 2813f814..46d5a31c 100644 --- a/src/session/memory-hook.ts +++ b/src/session/memory-hook.ts @@ -1,7 +1,7 @@ import { writeFile, mkdir, readdir, readFile } from "fs/promises"; import { mkdirSync, renameSync } from "fs"; import { join } from "path"; -import { complete, type Context } from "@earendil-works/pi-ai"; +import { complete, type Context } from "@earendil-works/pi-ai/compat"; import { summarizeViaClaude, summarizeWithFallback, From e29d39a692fea5820d60119e72f2025786d9fa76 Mon Sep 17 00:00:00 2001 From: TONresistor <240980241+TONresistor@users.noreply.github.com> Date: Fri, 10 Jul 2026 13:28:07 +0200 Subject: [PATCH 21/59] fix(security): restrict external action tools --- src/agent/tools/__tests__/registry.test.ts | 25 ++++++++++++++++++++++ src/agent/tools/registry.ts | 19 ++++++++++++++-- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/agent/tools/__tests__/registry.test.ts b/src/agent/tools/__tests__/registry.test.ts index 0724d6d2..48666709 100644 --- a/src/agent/tools/__tests__/registry.test.ts +++ b/src/agent/tools/__tests__/registry.test.ts @@ -919,6 +919,31 @@ describe("ToolRegistry", () => { }); describe("registerPluginTools()", () => { + it("defaults external action tools to the Telegram allowlist", () => { + const action = createMockTool("plugin_mutate", "action"); + + registry.registerPluginTools("test-plugin", [ + { tool: action, executor: createMockExecutor() }, + ]); + + expect(registry.getToolConfig(action.name)).toEqual({ level: "allowlist" }); + expect(registry.getForContext(false, null, "dm", false, 12345)).not.toContainEqual(action); + + registry.setAllowFrom([12345]); + expect(registry.getForContext(false, null, "dm", false, 12345)).toContainEqual(action); + }); + + it("keeps external data-bearing tools public by default", () => { + const readOnly = createMockTool("plugin_lookup", "data-bearing"); + + registry.registerPluginTools("test-plugin", [ + { tool: readOnly, executor: createMockExecutor() }, + ]); + + expect(registry.getToolConfig(readOnly.name)).toEqual({ level: "all" }); + expect(registry.getForContext(false, null, "dm", false, 12345)).toContainEqual(readOnly); + }); + it("should register multiple plugin tools", () => { const tools = [ { diff --git a/src/agent/tools/registry.ts b/src/agent/tools/registry.ts index 230fffc4..63d35091 100644 --- a/src/agent/tools/registry.ts +++ b/src/agent/tools/registry.ts @@ -31,6 +31,21 @@ import { createLogger } from "../../utils/logger.js"; const log = createLogger("Registry"); const APPROVAL_TTL_MS = 5 * 60 * 1000; +/** + * External plugins and MCP servers are not part of the reviewed built-in policy. + * Their actions therefore require an explicitly trusted Telegram identity even + * when the plugin omits a scope or declares the legacy "always" scope. Authors + * can keep genuinely read-only tools public by marking them data-bearing. + */ +function getExternalMinimumAccess( + tool: Tool, + scope?: ToolScope, + declaredMinimum?: ToolAccessLevel +): ToolAccessLevel { + const declared = declaredMinimum ?? scopeToLevel(scope); + return tool.category === "data-bearing" ? declared : enforceMinimumAccess(declared, "allowlist"); +} + interface PendingApproval { id: string; toolName: string; @@ -632,7 +647,7 @@ export class ToolRegistry { scope, mode: mode ?? "both", module: pluginName, - minimumAccess, + minimumAccess: getExternalMinimumAccess(tool, scope, minimumAccess), }); names.push(tool.name); } @@ -684,7 +699,7 @@ export class ToolRegistry { scope, mode: mode ?? "both", module: pluginName, - minimumAccess, + minimumAccess: getExternalMinimumAccess(tool, scope, minimumAccess), }); names.push(tool.name); } From 81385cf2d855ee91f64d2235839745e2a7a90268 Mon Sep 17 00:00:00 2001 From: TONresistor <240980241+TONresistor@users.noreply.github.com> Date: Fri, 10 Jul 2026 13:31:00 +0200 Subject: [PATCH 22/59] fix(mcp): bound stalled action calls --- src/agent/tools/__tests__/mcp-loader.test.ts | 45 +++++++++++++++- src/agent/tools/mcp-loader.ts | 56 ++++++++++++++------ 2 files changed, 83 insertions(+), 18 deletions(-) diff --git a/src/agent/tools/__tests__/mcp-loader.test.ts b/src/agent/tools/__tests__/mcp-loader.test.ts index 19f557e5..5430012e 100644 --- a/src/agent/tools/__tests__/mcp-loader.test.ts +++ b/src/agent/tools/__tests__/mcp-loader.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { registerMcpTools } from "../mcp-loader.js"; describe("MCP tool execution", () => { - it("runs untrusted MCP tools without an operational retry-producing timeout", async () => { + it("uses a bounded timeout for untrusted MCP actions", async () => { vi.useFakeTimers(); try { let registeredTools: Array<{ @@ -58,10 +58,51 @@ describe("MCP tool execution", () => { expect(callTool).toHaveBeenCalledWith( { name: "mutate", arguments: { id: "once" } }, undefined, - expect.objectContaining({ timeout: 2_147_483_647 }) + expect.objectContaining({ timeout: 125_000 }) ); } finally { vi.useRealTimers(); } }); + + it("releases a stalled MCP action with a non-retryable unknown outcome", async () => { + vi.useFakeTimers(); + try { + let registeredTools: Array<{ + executor: (params: unknown) => Promise; + }> = []; + const registry = { + registerPluginTools: vi.fn((_name, tools) => { + registeredTools = tools; + return tools.length; + }), + }; + const client = { + listTools: vi.fn(async () => ({ + tools: [{ name: "hang", inputSchema: { type: "object", properties: {} } }], + })), + callTool: vi.fn( + async () => + await new Promise((resolve) => + setTimeout(() => resolve({ isError: false, content: [] }), 300_000) + ) + ), + }; + + await registerMcpTools( + [{ serverName: "test", client: client as never, scope: "admin-only" }], + registry as never + ); + + const resultPromise = registeredTools[0].executor({}); + await vi.advanceTimersByTimeAsync(120_000); + + await expect(resultPromise).resolves.toMatchObject({ + success: false, + data: { outcome: "unknown", retryable: false }, + }); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/src/agent/tools/mcp-loader.ts b/src/agent/tools/mcp-loader.ts index 70e9c839..72a96118 100644 --- a/src/agent/tools/mcp-loader.ts +++ b/src/agent/tools/mcp-loader.ts @@ -25,11 +25,18 @@ export interface McpConnection { } const MCP_CONNECT_TIMEOUT_MS = 30_000; -// Node's largest non-overflowing timer (~24.8 days). MCP tool metadata is -// remote-controlled, so no MCP tool is trusted read-only enough for a normal -// retry-producing timeout. This is effectively "no operational timeout" while -// preserving the SDK's required finite request timer. -const MCP_UNTRUSTED_TOOL_TIMEOUT_MS = 2_147_483_647; +const MCP_TOOL_DEADLINE_MS = 120_000; +// Let our local deadline report an explicit unknown outcome before asking the +// SDK to abort the transport. This keeps chat capacity bounded while avoiding a +// misleading normal failure that could encourage duplicate side effects. +const MCP_SDK_TIMEOUT_MS = MCP_TOOL_DEADLINE_MS + 5_000; + +class McpToolDeadlineError extends Error { + constructor() { + super(`MCP tool exceeded the ${MCP_TOOL_DEADLINE_MS / 1000}s execution deadline`); + this.name = "McpToolDeadlineError"; + } +} /** * Parse a command string into command + args. @@ -194,18 +201,24 @@ export async function registerMcpTools( const prefixedName = `mcp_${conn.serverName}_${mcpTool.name}`; const executor: ToolExecutor = async (params): Promise => { + let deadline: ReturnType | undefined; try { - // MCP cancellation is cooperative and cannot prove a remote side - // effect stopped. Keep untrusted tool calls pending instead of - // returning a normal timeout that invites a duplicate retry. - const result = await conn.client.callTool( - { - name: mcpTool.name, - arguments: params as Record, - }, - undefined, - { timeout: MCP_UNTRUSTED_TOOL_TIMEOUT_MS } - ); + const result = await Promise.race([ + conn.client.callTool( + { + name: mcpTool.name, + arguments: params as Record, + }, + undefined, + { timeout: MCP_SDK_TIMEOUT_MS } + ), + new Promise((_, reject) => { + deadline = setTimeout( + () => reject(new McpToolDeadlineError()), + MCP_TOOL_DEADLINE_MS + ); + }), + ]); if (result.isError) { const errorText = extractText( @@ -220,10 +233,21 @@ export async function registerMcpTools( const text = extractText(result.content as Array<{ type: string; text?: string }>); return { success: true, data: sanitizeForContext(text) }; } catch (innerError: unknown) { + if (innerError instanceof McpToolDeadlineError) { + return { + success: false, + error: + `MCP tool "${mcpTool.name}" exceeded its execution deadline. ` + + "Its remote outcome is unknown; do not retry automatically.", + data: { outcome: "unknown", retryable: false }, + }; + } return { success: false, error: `MCP tool "${mcpTool.name}" failed: ${getErrorMessage(innerError)}`, }; + } finally { + if (deadline) clearTimeout(deadline); } }; From 18e26949d7d0aa681bf4d2eb01d9cc91ded58b46 Mon Sep 17 00:00:00 2001 From: TONresistor <240980241+TONresistor@users.noreply.github.com> Date: Fri, 10 Jul 2026 13:33:05 +0200 Subject: [PATCH 23/59] fix(workspace): reject parent symlink escapes --- src/workspace/__tests__/validator.test.ts | 30 +++++++++++++++++ src/workspace/validator.ts | 41 +++++++++++++++-------- 2 files changed, 57 insertions(+), 14 deletions(-) diff --git a/src/workspace/__tests__/validator.test.ts b/src/workspace/__tests__/validator.test.ts index 54bf977d..055e516d 100644 --- a/src/workspace/__tests__/validator.test.ts +++ b/src/workspace/__tests__/validator.test.ts @@ -221,6 +221,36 @@ describe("Workspace Path Validator", () => { rmSync(linkPath, { force: true }); } }); + + it("should reject paths traversing a symbolic-link directory", () => { + const externalDir = mkdtempSync(join(tmpdir(), "teleton-external-")); + const linkPath = join(tempWorkspace, "escape"); + + try { + writeFileSync(join(externalDir, "secret.txt"), "outside"); + symlinkSync(externalDir, linkPath, "dir"); + + expect(() => validatePath("escape/secret.txt")).toThrow(WorkspaceSecurityError); + expect(() => validatePath("escape/secret.txt")).toThrow("Symbolic links are not allowed"); + } finally { + rmSync(linkPath, { force: true }); + rmSync(externalDir, { recursive: true, force: true }); + } + }); + + it("should reject creating files through a symbolic-link directory", () => { + const externalDir = mkdtempSync(join(tmpdir(), "teleton-external-")); + const linkPath = join(tempWorkspace, "escape-create"); + + try { + symlinkSync(externalDir, linkPath, "dir"); + + expect(() => validatePath("escape-create/new.txt", true)).toThrow(WorkspaceSecurityError); + } finally { + rmSync(linkPath, { force: true }); + rmSync(externalDir, { recursive: true, force: true }); + } + }); }); describe("Non-existent path handling", () => { diff --git a/src/workspace/validator.ts b/src/workspace/validator.ts index 49b399e3..6db5e526 100644 --- a/src/workspace/validator.ts +++ b/src/workspace/validator.ts @@ -1,7 +1,7 @@ // src/workspace/validator.ts import { existsSync, lstatSync, readdirSync } from "fs"; -import { resolve, normalize, relative, extname, basename } from "path"; +import { resolve, normalize, relative, extname, basename, join, sep } from "path"; import { homedir } from "os"; import { WORKSPACE_ROOT, ALLOWED_EXTENSIONS, MAX_FILE_SIZES } from "./paths.js"; import { MAX_FILENAME_LENGTH } from "../constants/limits.js"; @@ -61,6 +61,27 @@ export interface ValidatedPath { filename: string; } +function assertNoSymlinkComponents(absolutePath: string, inputPath: string): void { + const components = relative(WORKSPACE_ROOT, absolutePath).split(sep).filter(Boolean); + let current = WORKSPACE_ROOT; + + for (const component of components) { + current = join(current, component); + try { + if (lstatSync(current).isSymbolicLink()) { + throw new WorkspaceSecurityError( + "Access denied: Symbolic links are not allowed for security reasons.", + inputPath + ); + } + } catch (error: unknown) { + if (error instanceof WorkspaceSecurityError) throw error; + if ((error as NodeJS.ErrnoException).code === "ENOENT") break; + throw error; + } + } +} + /** * Validate and resolve a path within the workspace * @@ -118,6 +139,11 @@ export function validatePath(inputPath: string, allowCreate: boolean = false): V ); } + // Reject symlinks in every existing path component. Checking only the final + // node allows `workspace/link/file` to escape when `link` points elsewhere. + // This also protects write targets whose final file does not exist yet. + assertNoSymlinkComponents(absolutePath, inputPath); + // Check if path exists const exists = existsSync(absolutePath); @@ -128,19 +154,6 @@ export function validatePath(inputPath: string, allowCreate: boolean = false): V ); } - // SECURITY FIX: Use lstatSync() instead of statSync() to detect symlinks - // (statSync follows symlinks, lstatSync does not) - if (exists) { - const stats = lstatSync(absolutePath); - - if (stats.isSymbolicLink()) { - throw new WorkspaceSecurityError( - `Access denied: Symbolic links are not allowed for security reasons.`, - inputPath - ); - } - } - return { absolutePath, relativePath, From b28273100333d55b65172f6a7aed7de296502912 Mon Sep 17 00:00:00 2001 From: TONresistor <240980241+TONresistor@users.noreply.github.com> Date: Fri, 10 Jul 2026 13:35:43 +0200 Subject: [PATCH 24/59] fix(exec): enforce dedicated sender allowlist --- src/agent/tools/__tests__/registry.test.ts | 29 +++++++++++++++++++ src/agent/tools/exec/__tests__/module.test.ts | 16 ++++++++++ src/agent/tools/exec/module.ts | 9 +++--- src/agent/tools/module-loader.ts | 4 +-- src/agent/tools/registry.ts | 9 ++++-- src/agent/tools/types.ts | 6 ++++ 6 files changed, 65 insertions(+), 8 deletions(-) diff --git a/src/agent/tools/__tests__/registry.test.ts b/src/agent/tools/__tests__/registry.test.ts index 48666709..9f290f00 100644 --- a/src/agent/tools/__tests__/registry.test.ts +++ b/src/agent/tools/__tests__/registry.test.ts @@ -384,6 +384,35 @@ describe("ToolRegistry", () => { }); }); + describe("tool-specific allowlist", () => { + it("uses a tool-specific allowlist instead of the global Telegram allowlist", async () => { + const tool = createMockTool("exec_run", "action"); + registry.setAllowFrom([222]); + registry.register( + tool, + createMockExecutor(), + "allowlist", + "both", + [], + "allowlist", + false, + [111] + ); + + expect(registry.getForContext(false, null, "dm", false, 111)).toContainEqual(tool); + expect(registry.getForContext(false, null, "dm", false, 222)).not.toContainEqual(tool); + + const denied = await registry.execute( + { type: "toolCall", id: "exec-denied", name: tool.name, arguments: { message: "x" } }, + { ...mockContext, senderId: 222 } + ); + expect(denied).toMatchObject({ + success: false, + error: expect.stringMatching(/allowed users/), + }); + }); + }); + // ---------- Tool execution ---------- describe("execute()", () => { diff --git a/src/agent/tools/exec/__tests__/module.test.ts b/src/agent/tools/exec/__tests__/module.test.ts index bba6da63..d4e2c3e0 100644 --- a/src/agent/tools/exec/__tests__/module.test.ts +++ b/src/agent/tools/exec/__tests__/module.test.ts @@ -101,4 +101,20 @@ describe("execModule", () => { if (origPlatform) Object.defineProperty(process, "platform", origPlatform); else Object.defineProperty(process, "platform", { value: "linux" }); }); + + it("attaches the dedicated exec allowlist to every exec tool", () => { + const origPlatform = Object.getOwnPropertyDescriptor(process, "platform"); + Object.defineProperty(process, "platform", { value: "linux" }); + + const config = makeConfig({ scope: "allowlist", allowlist: [111, 333] }); + execModule.configure!(config); + execModule.migrate!(db); + const tools = execModule.tools(config); + + expect(tools).toHaveLength(4); + expect(tools.every((tool) => tool.allowFrom?.join(",") === "111,333")).toBe(true); + + if (origPlatform) Object.defineProperty(process, "platform", origPlatform); + else Object.defineProperty(process, "platform", { value: "linux" }); + }); }); diff --git a/src/agent/tools/exec/module.ts b/src/agent/tools/exec/module.ts index b16776d3..e9b942ab 100644 --- a/src/agent/tools/exec/module.ts +++ b/src/agent/tools/exec/module.ts @@ -54,13 +54,14 @@ const execModule: PluginModule = { } const scope = resolveScope(execCfg.scope); + const allowFrom = execCfg.scope === "allowlist" ? execCfg.allowlist : undefined; const db = moduleDb; return [ - { tool: execRunTool, executor: createExecRunExecutor(db, execCfg), scope }, - { tool: execInstallTool, executor: createExecInstallExecutor(db, execCfg), scope }, - { tool: execServiceTool, executor: createExecServiceExecutor(db, execCfg), scope }, - { tool: execStatusTool, executor: createExecStatusExecutor(db, execCfg), scope }, + { tool: execRunTool, executor: createExecRunExecutor(db, execCfg), scope, allowFrom }, + { tool: execInstallTool, executor: createExecInstallExecutor(db, execCfg), scope, allowFrom }, + { tool: execServiceTool, executor: createExecServiceExecutor(db, execCfg), scope, allowFrom }, + { tool: execStatusTool, executor: createExecStatusExecutor(db, execCfg), scope, allowFrom }, ]; }, diff --git a/src/agent/tools/module-loader.ts b/src/agent/tools/module-loader.ts index 38118f78..6af4fd89 100644 --- a/src/agent/tools/module-loader.ts +++ b/src/agent/tools/module-loader.ts @@ -30,8 +30,8 @@ export function loadModules( mod.migrate?.(db); const tools = mod.tools(config); - for (const { tool, executor, scope, mode } of tools) { - registry.register(tool, executor, scope, mode); + for (const { tool, executor, scope, mode, allowFrom } of tools) { + registry.register(tool, executor, scope, mode, undefined, undefined, false, allowFrom); } loaded.push(mod); diff --git a/src/agent/tools/registry.ts b/src/agent/tools/registry.ts index 63d35091..69398295 100644 --- a/src/agent/tools/registry.ts +++ b/src/agent/tools/registry.ts @@ -99,6 +99,7 @@ export class ToolRegistry { executor: ToolExecutor; scope?: ToolScope; minimumAccess?: ToolAccessLevel; + allowFrom?: readonly number[]; requiresApproval?: boolean; mode: ToolMode; module: string; @@ -111,6 +112,7 @@ export class ToolRegistry { scope: entry.scope && entry.scope !== "always" && entry.scope !== "open" ? entry.scope : undefined, minimumAccess: entry.minimumAccess ?? scopeToLevel(entry.scope), + allowFrom: entry.allowFrom ? new Set(entry.allowFrom) : undefined, requiresApproval: entry.requiresApproval ?? false, mode: entry.mode, module: entry.module, @@ -125,7 +127,8 @@ export class ToolRegistry { mode: ToolMode = "both", tags?: string[], minimumAccess?: ToolAccessLevel, - requiresApproval = false + requiresApproval = false, + allowFrom?: readonly number[] ): void { if (this.tools.has(tool.name)) { throw new Error(`Tool "${tool.name}" is already registered`); @@ -139,6 +142,7 @@ export class ToolRegistry { tags, minimumAccess, requiresApproval, + allowFrom, }); this.toolArrayCache = null; } @@ -223,7 +227,8 @@ export class ToolRegistry { if (level === "off") return { ok: false, reason: { kind: "disabled" } }; if (level === "admin" && !ctx.isAdmin) return { ok: false, reason: { kind: "admin-only" } }; if (level === "allowlist" && !ctx.isAdmin) { - if (!ctx.senderId || !this.allowFrom.has(ctx.senderId)) { + const allowedSenders = this.tools.get(name)?.allowFrom ?? this.allowFrom; + if (!ctx.senderId || !allowedSenders.has(ctx.senderId)) { return { ok: false, reason: { kind: "allowlist" } }; } } diff --git a/src/agent/tools/types.ts b/src/agent/tools/types.ts index 61a6a424..f2c7bd0e 100644 --- a/src/agent/tools/types.ts +++ b/src/agent/tools/types.ts @@ -108,6 +108,8 @@ export interface RegisteredTool { scope?: ToolScope; /** Hard authority floor. Runtime configuration may only make access stricter. */ minimumAccess: ToolAccessLevel; + /** Optional per-tool allowlist. When present, it replaces telegram.allow_from. */ + allowFrom?: ReadonlySet; /** Whether execution requires a separate, human-authenticated approval step. */ requiresApproval: boolean; /** Telegram mode this tool runs in. */ @@ -129,6 +131,8 @@ export interface ToolEntry { scope?: ToolScope; /** Hard authority floor, independent from the DM/group channel scope. */ minimumAccess?: ToolAccessLevel; + /** Optional per-tool allowlist. When present, it replaces telegram.allow_from. */ + allowFrom?: readonly number[]; /** Require a separate owner command before executing this tool. */ requiresApproval?: boolean; /** Telegram mode(s) this tool runs in. Mandatory — every tool must declare it. */ @@ -155,6 +159,8 @@ export interface PluginModule { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- tool executors accept varied param shapes executor: ToolExecutor; scope?: ToolScope; + /** Optional per-tool allowlist. When present, it replaces telegram.allow_from. */ + allowFrom?: readonly number[]; /** Telegram mode(s) this module tool runs in. Defaults to "both" when omitted. */ mode?: ToolMode; }>; From 7d41524c3895db8dec83f34fdab4e3f911bd8bb9 Mon Sep 17 00:00:00 2001 From: TONresistor <240980241+TONresistor@users.noreply.github.com> Date: Fri, 10 Jul 2026 13:41:27 +0200 Subject: [PATCH 25/59] fix(telegram): preserve send confirmations --- src/agent/runtime.ts | 32 ++++++---- src/agent/telegram-send-state.ts | 38 ++++++++++++ src/telegram/__tests__/handlers.test.ts | 82 ++++++++++++++++++++++++- src/telegram/handlers.ts | 20 +++--- 4 files changed, 149 insertions(+), 23 deletions(-) create mode 100644 src/agent/telegram-send-state.ts diff --git a/src/agent/runtime.ts b/src/agent/runtime.ts index d7835f00..9c49a08c 100644 --- a/src/agent/runtime.ts +++ b/src/agent/runtime.ts @@ -15,6 +15,11 @@ import { EMBEDDING_QUERY_MAX_CHARS, } from "../constants/limits.js"; import { TELEGRAM_SEND_TOOLS } from "../constants/tools.js"; +import { + deliveredTelegramText, + sentSuccessfullyToChat, + type CompletedToolCall, +} from "./telegram-send-state.js"; import { chatWithContext, streamWithContext, @@ -109,10 +114,7 @@ export interface ProcessMessageOptions { export interface AgentResponse { content: string; - toolCalls?: Array<{ - name: string; - input: Record; - }>; + toolCalls?: CompletedToolCall[]; streamed?: boolean; } @@ -136,7 +138,7 @@ interface LoopResult { finalResponse: ChatResponse | null; session: ReturnType; context: Context; - totalToolCalls: Array<{ name: string; input: Record }>; + totalToolCalls: CompletedToolCall[]; accumulatedTexts: string[]; accumulatedUsage: UsageAccumulator; wasStreamed: boolean; @@ -968,7 +970,7 @@ export class AgentRuntime { toolPlans: ToolPlan[], execResults: ToolExecResult[], sink: { - totalToolCalls: Array<{ name: string; input: Record }>; + totalToolCalls: CompletedToolCall[]; iterationToolNames: string[]; sessionId: string; chatId: string; @@ -1023,6 +1025,11 @@ export class AgentRuntime { sink.totalToolCalls.push({ name: block.name, input: plan.params, + result: { + success: exec.result.success, + data: exec.result.data, + error: exec.result.error, + }, }); const resultText = truncateToolResult(exec.result, MAX_TOOL_RESULT_SIZE); @@ -1078,7 +1085,7 @@ export class AgentRuntime { let iteration = 0; const retry = { overflowResets: 0, rateLimitRetries: 0, serverErrorRetries: 0 }; let finalResponse: ChatResponse | null = null; - const totalToolCalls: Array<{ name: string; input: Record }> = []; + const totalToolCalls: CompletedToolCall[] = []; const accumulatedTexts: string[] = []; const accumulatedUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalCost: 0 }; const seenToolSignatures = new Set(); @@ -1302,13 +1309,13 @@ export class AgentRuntime { let content = accumulatedTexts.join("\n").trim() || finalResponse.text; - const usedTelegramSendTool = totalToolCalls.some((tc) => TELEGRAM_SEND_TOOLS.has(tc.name)); + const sentToCurrentChat = totalToolCalls.some((call) => sentSuccessfullyToChat(call, chatId)); - if (!content && totalToolCalls.length > 0 && !usedTelegramSendTool) { + if (!content && totalToolCalls.length > 0 && !sentToCurrentChat) { log.warn("Empty response after tool calls - generating fallback"); content = "I executed the requested action but couldn't generate a response. Please try again."; - } else if (!content && usedTelegramSendTool) { + } else if (!content && sentToCurrentChat) { log.info("Response sent via Telegram tool - no additional text needed"); content = ""; } else if (!content && accumulatedUsage.input === 0 && accumulatedUsage.output === 0) { @@ -1361,7 +1368,10 @@ export class AgentRuntime { if (wasStreamed && opts.streamToChat) { const bridge = opts.streamToChat.bridge; if (isBotBridge(bridge)) { - if (usedTelegramSendTool) { + if ( + (!content && sentToCurrentChat) || + deliveredTelegramText(totalToolCalls, chatId, content) + ) { // Agent already sent via tool — just clear the draft bubble await bridge.clearDraft(opts.streamToChat.chatId); } else { diff --git a/src/agent/telegram-send-state.ts b/src/agent/telegram-send-state.ts new file mode 100644 index 00000000..c3d98f32 --- /dev/null +++ b/src/agent/telegram-send-state.ts @@ -0,0 +1,38 @@ +import { TELEGRAM_SEND_TOOLS } from "../constants/tools.js"; + +export interface CompletedToolCall { + name: string; + input: Record; + result?: { success: boolean; data?: unknown; error?: string }; +} + +function targetChatId(call: CompletedToolCall): string | null { + const raw = call.name === "telegram_forward_message" ? call.input.toChatId : call.input.chatId; + return typeof raw === "string" || typeof raw === "number" ? String(raw) : null; +} + +export function sentSuccessfullyToChat(call: CompletedToolCall, chatId: string): boolean { + return ( + TELEGRAM_SEND_TOOLS.has(call.name) && + call.result?.success === true && + targetChatId(call) === String(chatId) + ); +} + +export function deliveredTelegramText( + calls: CompletedToolCall[] | undefined, + chatId: string, + text: string +): boolean { + const normalizedText = text.trim(); + if (!normalizedText) return false; + + return ( + calls?.some( + (call) => + sentSuccessfullyToChat(call, chatId) && + typeof call.input.text === "string" && + call.input.text.trim() === normalizedText + ) ?? false + ); +} diff --git a/src/telegram/__tests__/handlers.test.ts b/src/telegram/__tests__/handlers.test.ts index af125ccb..eb359235 100644 --- a/src/telegram/__tests__/handlers.test.ts +++ b/src/telegram/__tests__/handlers.test.ts @@ -488,11 +488,17 @@ describe("MessageHandler", () => { // ── T9: telegramSendCalled detection ───────────────────────────────────── describe("telegramSendCalled detection", () => { - it("when processMessage uses a telegram send tool → bridge.sendMessage NOT called", async () => { + it("does not duplicate text successfully delivered to the current chat", async () => { const agent = makeAgent(); agent.processMessage.mockResolvedValue({ content: "sent via tool", - toolCalls: [{ name: "telegram_send_message", args: {} }], + toolCalls: [ + { + name: "telegram_send_message", + input: { chatId: "chat1", text: "sent via tool" }, + result: { success: true }, + }, + ], }); const { handler, bridge } = createHandler({ dm_policy: "open" }, { agent }); @@ -501,6 +507,69 @@ describe("MessageHandler", () => { expect(bridge.sendMessage).not.toHaveBeenCalled(); }); + it("sends the final response when the Telegram tool targeted another chat", async () => { + const agent = makeAgent(); + agent.processMessage.mockResolvedValue({ + content: "Message sent to Alice", + toolCalls: [ + { + name: "telegram_send_message", + input: { chatId: "alice", text: "hello" }, + result: { success: true }, + }, + ], + }); + + const { handler, bridge } = createHandler({ dm_policy: "open" }, { agent }); + await handler.handleMessage(makeMessage({ id: 102, chatId: "chat1" })); + + expect(bridge.sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ chatId: "chat1", text: "Message sent to Alice" }) + ); + }); + + it("sends the final response when the Telegram tool failed", async () => { + const agent = makeAgent(); + agent.processMessage.mockResolvedValue({ + content: "I could not send the message", + toolCalls: [ + { + name: "telegram_send_message", + input: { chatId: "chat1", text: "hello" }, + result: { success: false, error: "forbidden" }, + }, + ], + }); + + const { handler, bridge } = createHandler({ dm_policy: "open" }, { agent }); + await handler.handleMessage(makeMessage({ id: 103, chatId: "chat1" })); + + expect(bridge.sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ chatId: "chat1", text: "I could not send the message" }) + ); + }); + + it("sends a distinct confirmation after a successful current-chat send", async () => { + const agent = makeAgent(); + agent.processMessage.mockResolvedValue({ + content: "Done", + toolCalls: [ + { + name: "telegram_send_message", + input: { chatId: "chat1", text: "hello" }, + result: { success: true }, + }, + ], + }); + + const { handler, bridge } = createHandler({ dm_policy: "open" }, { agent }); + await handler.handleMessage(makeMessage({ id: 104, chatId: "chat1" })); + + expect(bridge.sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ chatId: "chat1", text: "Done" }) + ); + }); + it("when processMessage has no send tool → bridge.sendMessage IS called", async () => { const agent = makeAgent(); agent.processMessage.mockResolvedValue({ @@ -547,11 +616,18 @@ describe("MessageHandler", () => { }); for (const toolName of TELEGRAM_SEND_TOOLS) { + if (toolName === "telegram_forward_message") continue; it(`recognizes ${toolName} as a send tool`, async () => { const agent = makeAgent(); agent.processMessage.mockResolvedValue({ content: "tool response", - toolCalls: [{ name: toolName, args: {} }], + toolCalls: [ + { + name: toolName, + input: { chatId: "chat1", text: "tool response" }, + result: { success: true }, + }, + ], }); const { handler, bridge } = createHandler({ dm_policy: "open" }, { agent }); diff --git a/src/telegram/handlers.ts b/src/telegram/handlers.ts index 448254bc..608b0290 100644 --- a/src/telegram/handlers.ts +++ b/src/telegram/handlers.ts @@ -8,8 +8,8 @@ import type { EmbeddingProvider } from "../memory/embeddings/provider.js"; import { readOffset, writeOffset } from "./offset-store.js"; import { PendingHistory } from "../memory/pending-history.js"; import type { ToolContext } from "../agent/tools/types.js"; -import { TELEGRAM_SEND_TOOLS } from "../constants/tools.js"; import { isSilentReply } from "../constants/tokens.js"; +import { deliveredTelegramText } from "../agent/telegram-send-state.js"; import { transcribeAudio } from "../sdk/telegram-utils.js"; import { TYPING_REFRESH_MS } from "../constants/timeouts.js"; import { createLogger } from "../utils/logger.js"; @@ -490,19 +490,21 @@ export class MessageHandler { streamToChat, }); - // 8. Handle response based on whether tools were used - const hasToolCalls = response.toolCalls && response.toolCalls.length > 0; - - // Check if agent used any Telegram send tool - it already sent the message - const telegramSendCalled = - hasToolCalls && response.toolCalls?.some((tc) => TELEGRAM_SEND_TOOLS.has(tc.name)); + // Suppress only an identical text that was confirmed delivered to this + // chat. Cross-chat sends, failed sends, and distinct confirmations must + // still produce a response to the requester. + const responseAlreadyDelivered = deliveredTelegramText( + response.toolCalls, + message.chatId, + response.content + ); if (isSilentReply(response.content)) { log.debug("Silent reply suppressed"); } else if (response.streamed) { log.debug("Response already streamed to chat"); } else if ( - !telegramSendCalled && + !responseAlreadyDelivered && response.content && response.content.trim().length > 0 ) { @@ -537,7 +539,7 @@ export class MessageHandler { true ); } else if ( - telegramSendCalled && + responseAlreadyDelivered && response.content && response.content.trim().length > 0 && !isSilentReply(response.content) From d3478ee4cd1fd500b4e6b3479dc1569b7ec2ca42 Mon Sep 17 00:00:00 2001 From: TONresistor <240980241+TONresistor@users.noreply.github.com> Date: Fri, 10 Jul 2026 13:47:23 +0200 Subject: [PATCH 26/59] fix(plugins): wire bot inline runtime --- src/bot/__tests__/inline-router.test.ts | 30 ++++++++ src/bot/callback-answer.ts | 23 ++++++ src/bot/inline-router.ts | 53 +++++++++++--- src/index.ts | 95 ++++++++++++++++--------- src/telegram/bridges/bot.ts | 13 +++- 5 files changed, 167 insertions(+), 47 deletions(-) create mode 100644 src/bot/callback-answer.ts diff --git a/src/bot/__tests__/inline-router.test.ts b/src/bot/__tests__/inline-router.test.ts index 599215e6..8a5d5c23 100644 --- a/src/bot/__tests__/inline-router.test.ts +++ b/src/bot/__tests__/inline-router.test.ts @@ -145,6 +145,28 @@ describe("InlineRouter", () => { }); describe("callback_query routing", () => { + it("dispatches the legacy callback observer and answers only once", async () => { + const observer = vi.fn(async (event: any) => { + await event.answer("observed"); + }); + const handler = vi.fn(async (ctx: any) => { + await ctx.answer("plugin"); + }); + router.setCallbackObserver(observer); + router.registerPlugin("cats", { + onCallback: [{ pattern: "like:*", regex: compileGlob("like:*"), handler }], + }); + + const ctx = createCallbackCtx("cats:like:42"); + await router.middleware()(ctx, vi.fn()); + + expect(observer).toHaveBeenCalledWith( + expect.objectContaining({ data: "cats:like:42", action: "cats", params: ["like", "42"] }) + ); + expect(handler).toHaveBeenCalled(); + expect(ctx.answerCallbackQuery).toHaveBeenCalledTimes(1); + }); + it("routes callback with known prefix to plugin", async () => { const handler = vi.fn(async (ctx: any) => { await ctx.answer("liked!"); @@ -265,5 +287,13 @@ describe("InlineRouter", () => { router.unregisterPlugin("cats"); expect(router.hasPlugin("cats")).toBe(false); }); + + it("clears stale plugin handlers before a runtime reload", () => { + router.registerPlugin("cats", { onInlineQuery: vi.fn(async () => []) }); + + router.clearPlugins(); + + expect(router.hasPlugin("cats")).toBe(false); + }); }); }); diff --git a/src/bot/callback-answer.ts b/src/bot/callback-answer.ts new file mode 100644 index 00000000..ee1cffdb --- /dev/null +++ b/src/bot/callback-answer.ts @@ -0,0 +1,23 @@ +import type { Context } from "grammy"; + +const answeredCallbacks = new WeakSet(); + +export function hasAnsweredCallback(ctx: Context): boolean { + return answeredCallbacks.has(ctx); +} + +export async function answerCallbackOnce( + ctx: Context, + options?: Parameters[0] +): Promise { + if (answeredCallbacks.has(ctx)) return; + answeredCallbacks.add(ctx); + + try { + if (options === undefined) await ctx.answerCallbackQuery(); + else await ctx.answerCallbackQuery(options); + } catch (error) { + answeredCallbacks.delete(ctx); + throw error; + } +} diff --git a/src/bot/inline-router.ts b/src/bot/inline-router.ts index 254a695a..6de36b31 100644 --- a/src/bot/inline-router.ts +++ b/src/bot/inline-router.ts @@ -14,12 +14,14 @@ import type { CallbackContext, ChosenResultContext, ButtonDef, + PluginCallbackEvent, } from "@teleton-agent/sdk"; import type { GramJSBotClient } from "./gramjs-bot.js"; import { splitPrefix } from "./types.js"; import { createLogger } from "../utils/logger.js"; import { toGrammyKeyboard, prefixButtons, stripCustomEmoji } from "../sdk/formatting.js"; import { editInlineViaGramJS } from "./services/inline-transport.js"; +import { answerCallbackOnce, hasAnsweredCallback } from "./callback-answer.js"; // Re-exported for callers that import the router's glob compiler (now shared). export { compileGlob } from "../sdk/formatting.js"; @@ -54,6 +56,7 @@ function globMatch(regex: RegExp, input: string): string[] | null { export class InlineRouter { private plugins = new Map(); private gramjsBot: GramJSBotClient | null = null; + private callbackObserver: ((event: PluginCallbackEvent) => Promise) | null = null; /** Set GramJS bot reference for styled button edits in callbacks */ setGramJSBot(bot: GramJSBotClient | null): void { @@ -70,6 +73,14 @@ export class InlineRouter { log.info(`Unregistered plugin "${name}" from inline routing`); } + clearPlugins(): void { + this.plugins.clear(); + } + + setCallbackObserver(observer: ((event: PluginCallbackEvent) => Promise) | null): void { + this.callbackObserver = observer; + } + hasPlugin(name: string): boolean { return this.plugins.has(name); } @@ -97,6 +108,7 @@ export class InlineRouter { // ── Callback Query ─────────────────────────────── if (ctx.callbackQuery?.data) { + await this.notifyCallbackObserver(ctx); const split = splitPrefix(ctx.callbackQuery.data); if (split) { const plugin = this.plugins.get(split.prefix); @@ -174,8 +186,6 @@ export class InlineRouter { strippedData: string, plugin: PluginBotHandlers ): Promise { - let answered = false; - try { // Find matching handler let matchedHandler: ((ctx: CallbackContext) => Promise) | undefined; @@ -192,7 +202,7 @@ export class InlineRouter { if (!matchedHandler) { // No pattern match — answer with empty and return - await ctx.answerCallbackQuery(); + await answerCallbackOnce(ctx); return; } @@ -210,10 +220,7 @@ export class InlineRouter { chatId: ctx.chat?.id?.toString(), messageId: callbackQuery.message?.message_id, async answer(text?: string, alert?: boolean) { - if (!answered) { - answered = true; - await ctx.answerCallbackQuery({ text, show_alert: alert }); - } + await answerCallbackOnce(ctx, { text, show_alert: alert }); }, async editMessage(text: string, opts?: { keyboard?: ButtonDef[][]; parseMode?: string }) { const styledButtons = opts?.keyboard @@ -255,14 +262,14 @@ export class InlineRouter { ); // Auto-answer if plugin didn't - if (!answered) { - await ctx.answerCallbackQuery(); + if (!hasAnsweredCallback(ctx)) { + await answerCallbackOnce(ctx); } } catch (error) { log.error({ err: error }, `Plugin "${pluginName}" callback handler failed`); - if (!answered) { + if (!hasAnsweredCallback(ctx)) { try { - await ctx.answerCallbackQuery({ text: "Error processing action" }); + await answerCallbackOnce(ctx, { text: "Error processing action" }); } catch { // ignore } @@ -270,6 +277,30 @@ export class InlineRouter { } } + private async notifyCallbackObserver(ctx: Context): Promise { + if (!this.callbackObserver || !ctx.callbackQuery?.data) return; + + const data = ctx.callbackQuery.data; + const parts = data.split(":"); + const event: PluginCallbackEvent = { + data, + action: parts[0], + params: parts.slice(1), + chatId: ctx.callbackQuery.message?.chat.id?.toString() ?? "", + messageId: ctx.callbackQuery.message?.message_id ?? 0, + userId: ctx.callbackQuery.from.id, + answer: async (text?: string, alert = false) => { + await answerCallbackOnce(ctx, { text, show_alert: alert }); + }, + }; + + try { + await this.callbackObserver(event); + } catch (error) { + log.error({ err: error }, "Plugin callback observer failed"); + } + } + private async handleChosenResult( ctx: Context, pluginName: string, diff --git a/src/index.ts b/src/index.ts index 2c5c21d4..159f6a74 100644 --- a/src/index.ts +++ b/src/index.ts @@ -80,6 +80,9 @@ export class TeletonApp { private messagesProcessed: number = 0; private heartbeatRunner: HeartbeatRunner; private scheduledTaskHandler: ScheduledTaskHandler; + private inlineRouter = new InlineRouter(); + private pluginRateLimiter = new PluginRateLimiter(); + private inlineMiddlewareBridge: ITelegramBridge | null = null; private configPath: string; @@ -384,6 +387,7 @@ ${blue} ┌────────────────────── // Truncate stale external plugins from previous run (keep builtins only) this.modules.length = this.builtinModuleCount; + this.preparePluginBotRuntime(); const builtinNames = this.modules.map((m) => m.name); const moduleNames = this.modules @@ -449,26 +453,22 @@ ${blue} ┌────────────────────── const username = await this.bridge.getUsername(); const walletAddress = getWalletAddress(); - // Set up inline router and rate limiter - const inlineRouter = new InlineRouter(); - const rateLimiter = new PluginRateLimiter(); - // Start module background jobs (after bridge connect) const pluginContext = await this.startModules(); - // Wire mode-specific SDK deps, handlers, and polling + // Register every middleware and dynamic plugin hook before polling starts. const firstStart = !this.messageHandlersRegistered; + this.installMessagePipeline(); + if (hookRegistry.hasAnyHooks()) this.installHookRunner(hookRegistry); + this.wirePluginEventHooks(); + + // Wire mode-specific handlers and start polling last. if (isBotBridge(this.bridge)) { - this.wireBotMode(inlineRouter, rateLimiter, firstStart); + this.wireBotMode(firstStart); } else { this.wireUserMode(firstStart); } - // Create hook runner if any plugins registered hooks - if (hookRegistry.hasAnyHooks()) this.installHookRunner(hookRegistry); - - this.wirePluginEventHooks(); - // Start plugin hot-reload watcher (dev mode) if (this.config.dev.hot_reload) { this.pluginWatcher = new PluginWatcher({ @@ -512,8 +512,30 @@ ${blue} ┌────────────────────── this.heartbeatRunner.start(adminChatId, this.config.heartbeat.interval_ms); } } + } + + private preparePluginBotRuntime(): void { + this.inlineRouter.clearPlugins(); + this.inlineRouter.setCallbackObserver(null); + this.pluginRateLimiter = new PluginRateLimiter(); + + if (isBotBridge(this.bridge)) { + this.sdkDeps.inlineRouter = this.inlineRouter; + this.sdkDeps.gramjsBot = null; + this.sdkDeps.grammyBot = this.bridge.getBot(); + this.sdkDeps.rateLimiter = this.pluginRateLimiter; + + if (this.inlineMiddlewareBridge !== this.bridge) { + this.bridge.useMiddleware(this.inlineRouter.middleware()); + this.inlineMiddlewareBridge = this.bridge; + } + return; + } - this.installMessagePipeline(); + this.sdkDeps.inlineRouter = null; + this.sdkDeps.gramjsBot = null; + this.sdkDeps.grammyBot = null; + this.sdkDeps.rateLimiter = null; } /** Create and install the plugin hook runner; log which hooks are active. */ @@ -640,6 +662,7 @@ ${blue} ┌────────────────────── // New bridge = new message listeners needed this.messageHandlersRegistered = false; this.callbackHandlerRegistered = false; + this.inlineMiddlewareBridge = null; } // ─── Mode-specific wiring ────────────────────────────────────────────── @@ -647,14 +670,7 @@ ${blue} ┌────────────────────── /** * Wire bot-mode SDK deps, callback handler, and Grammy polling. */ - private wireBotMode( - inlineRouter: InlineRouter, - rateLimiter: PluginRateLimiter, - firstStart: boolean - ): void { - this.sdkDeps.inlineRouter = inlineRouter; - this.sdkDeps.gramjsBot = null; - this.sdkDeps.rateLimiter = rateLimiter; + private wireBotMode(firstStart: boolean): void { log.info("Bot mode: using main Grammy bridge"); if (isBotBridge(this.bridge)) { @@ -684,8 +700,10 @@ ${blue} ┌────────────────────── }); return response.content; }); - this.bridge.startPolling(); } + // The middleware tree survives a WebUI stop/start, but long-polling does + // not. Restart polling on every successful agent start. + this.bridge.startPolling(); void this.bridge.syncCommands(); } } @@ -1074,16 +1092,7 @@ ${blue} ┌────────────────────── answer, }; - for (const mod of this.modules) { - const withHooks = mod as PluginModuleWithHooks; - if (withHooks.onCallbackQuery) { - try { - await withHooks.onCallbackQuery(event); - } catch (error: unknown) { - log.error(`❌ [${mod.name}] onCallbackQuery error: ${getErrorMessage(error)}`); - } - } - } + await this.dispatchPluginCallback(event); }); this.callbackHandlerRegistered = true; @@ -1093,10 +1102,28 @@ ${blue} ┌────────────────────── if (cbCount > 0) { log.info(`${cbCount} plugin onCallbackQuery hook(s) registered`); } - } else if (!this.callbackHandlerRegistered && this.bridge.getMode() === "bot") { - // In bot mode, callback queries are handled by GrammyBotBridge's callback_query:data handler - // TODO: dispatch plugin onCallbackQuery hooks from Grammy callback handler + } else if (!this.callbackHandlerRegistered && isBotBridge(this.bridge)) { + this.inlineRouter.setCallbackObserver((event) => this.dispatchPluginCallback(event)); this.callbackHandlerRegistered = true; + + const cbCount = this.modules.filter( + (m) => (m as PluginModuleWithHooks).onCallbackQuery + ).length; + if (cbCount > 0) { + log.info(`${cbCount} plugin onCallbackQuery hook(s) registered`); + } + } + } + + private async dispatchPluginCallback(event: PluginCallbackEvent): Promise { + for (const mod of this.modules) { + const withHooks = mod as PluginModuleWithHooks; + if (!withHooks.onCallbackQuery) continue; + try { + await withHooks.onCallbackQuery(event); + } catch (error: unknown) { + log.error(`❌ [${mod.name}] onCallbackQuery error: ${getErrorMessage(error)}`); + } } } diff --git a/src/telegram/bridges/bot.ts b/src/telegram/bridges/bot.ts index f28aeaa7..f8357875 100644 --- a/src/telegram/bridges/bot.ts +++ b/src/telegram/bridges/bot.ts @@ -1,4 +1,4 @@ -import { Bot, InlineKeyboard, InputFile, type Context } from "grammy"; +import { Bot, InlineKeyboard, InputFile, type Context, type MiddlewareFn } from "grammy"; import { markdownToTelegramHtml } from "../formatting.js"; import { TELEGRAM_MAX_MESSAGE_LENGTH } from "../../constants/limits.js"; import { classifyMedia } from "../bridge-interface.js"; @@ -14,6 +14,7 @@ import type { import type { TelegramMessage, InlineButton } from "../bridge.js"; import { createLogger } from "../../utils/logger.js"; import { callbackRouter } from "../../bot/callback-router.js"; +import { answerCallbackOnce } from "../../bot/callback-answer.js"; const log = createLogger("BotBridge"); @@ -92,6 +93,14 @@ export class GrammyBotBridge implements ITelegramBridge { return this.botInfo?.username; } + getBot(): Bot { + return this.bot; + } + + useMiddleware(middleware: MiddlewareFn): void { + this.bot.use(middleware); + } + /** * Convert a decimal-string chatId to the JS number the grammy/Bot API expects. * The Bot API types chat ids as JS numbers; bridge-interface keeps them as @@ -497,7 +506,7 @@ export class GrammyBotBridge implements ITelegramBridge { // Callback handler — resolves nonces from telegram_send_buttons, reinjects as synthetic messages this.bot.on("callback_query:data", async (ctx) => { - await ctx.answerCallbackQuery(); + await answerCallbackOnce(ctx); const data = ctx.callbackQuery.data; if (data?.startsWith("btn:") && this.callbackHandler) { From 0747679602d8f2e64206f53791919ce631dcee13 Mon Sep 17 00:00:00 2001 From: TONresistor <240980241+TONresistor@users.noreply.github.com> Date: Fri, 10 Jul 2026 13:52:45 +0200 Subject: [PATCH 27/59] fix(logging): redact sensitive runtime data --- src/agent/runtime.ts | 20 +++++++---- src/agent/tools/search/tool-search.ts | 4 +-- .../tools/telegram/chats/edit-channel-info.ts | 11 +++++- .../tools/telegram/media/transcribe-audio.ts | 5 ++- .../tools/telegram/messaging/inline-send.ts | 2 +- src/scheduled-tasks.ts | 7 ++-- src/telegram/client.ts | 3 +- src/telegram/handlers.ts | 3 +- src/telegram/task-dependency-resolver.ts | 4 +-- src/utils/__tests__/sensitive-logging.test.ts | 36 +++++++++++++++++++ src/webui/server.ts | 12 ++----- 11 files changed, 80 insertions(+), 27 deletions(-) create mode 100644 src/utils/__tests__/sensitive-logging.test.ts diff --git a/src/agent/runtime.ts b/src/agent/runtime.ts index 9c49a08c..e591a293 100644 --- a/src/agent/runtime.ts +++ b/src/agent/runtime.ts @@ -262,7 +262,10 @@ export class AgentRuntime { } const enrichedQuery = enrichRAGQuery(searchQuery); if (enrichedQuery !== searchQuery) { - log.debug({ original: searchQuery, enriched: enrichedQuery }, "RAG query enriched"); + log.debug( + { originalLength: searchQuery.length, enrichedLength: enrichedQuery.length }, + "RAG query enriched" + ); } // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guarded above return this.embedder!.embedQuery(enrichedQuery.slice(0, EMBEDDING_QUERY_MAX_CHARS)); @@ -483,12 +486,15 @@ export class AgentRuntime { log.debug(`Including ${pendingContext.split("\n").length - 1} pending messages`); } - log.debug(`Formatted message: ${formattedMessage.substring(0, 100)}...`); - - const preview = formattedMessage.slice(0, 50).replace(/\n/g, " "); - const who = senderUsername ? `@${senderUsername}` : userName; - const msgType = isGroup ? `Group ${chatId} ${who}` : `DM ${who}`; - log.info(`${msgType}: "${preview}${formattedMessage.length > 50 ? "..." : ""}"`); + log.info( + { + chatId, + isGroup, + messageLength: formattedMessage.length, + hasMedia: opts.hasMedia ?? false, + }, + "Telegram message received" + ); let relevantContext = ""; const isNonTrivial = !isTrivialMessage(effectiveMessage); diff --git a/src/agent/tools/search/tool-search.ts b/src/agent/tools/search/tool-search.ts index e1713ce9..22d5984d 100644 --- a/src/agent/tools/search/tool-search.ts +++ b/src/agent/tools/search/tool-search.ts @@ -105,11 +105,11 @@ export function createToolSearchExecutor( // ── 5. Logging (T8) ─────────────────────────────────────────────────── const latencyMs = Date.now() - start; if (tools.length === 0) { - log.warn(`tool_search: no results for query="${query}" (${latencyMs}ms)`); + log.warn(`tool_search: no results (queryLength=${query.length}, ${latencyMs}ms)`); } else { const names = tools.map((t) => t.name).join(", "); log.info( - `tool_search query="${query}" results=${tools.length} tools=[${names}] latency=${latencyMs}ms` + `tool_search queryLength=${query.length} results=${tools.length} tools=[${names}] latency=${latencyMs}ms` ); } diff --git a/src/agent/tools/telegram/chats/edit-channel-info.ts b/src/agent/tools/telegram/chats/edit-channel-info.ts index 4e680cc2..9de66c3f 100644 --- a/src/agent/tools/telegram/chats/edit-channel-info.ts +++ b/src/agent/tools/telegram/chats/edit-channel-info.ts @@ -84,7 +84,16 @@ export const telegramEditChannelInfoExecutor: ToolExecutor 50 ? "..." : ""}"`); } - log.info(`edit_channel_info: ${channel.title} - ${updates.join(", ")}`); + log.info( + { + channelId: channel.id.toString(), + updatedFields: [ + title !== undefined ? "title" : null, + about !== undefined ? "about" : null, + ].filter(Boolean), + }, + "Channel info edited" + ); return { success: true, diff --git a/src/agent/tools/telegram/media/transcribe-audio.ts b/src/agent/tools/telegram/media/transcribe-audio.ts index 56f3cf02..c5a2692c 100644 --- a/src/agent/tools/telegram/media/transcribe-audio.ts +++ b/src/agent/tools/telegram/media/transcribe-audio.ts @@ -46,7 +46,10 @@ export const telegramTranscribeAudioExecutor: ToolExecutor = async (p }) ); - log.info(`Inline bot result sent: plugin="${plugin}" query="${query}" index=${resultIndex}`); + log.info({ plugin, queryLength: query.length, resultIndex }, "Inline bot result sent"); return { success: true, diff --git a/src/scheduled-tasks.ts b/src/scheduled-tasks.ts index 135b9ebf..74e2960e 100644 --- a/src/scheduled-tasks.ts +++ b/src/scheduled-tasks.ts @@ -30,7 +30,10 @@ export class ScheduledTaskHandler { // Extract task ID from format: [TASK:uuid] description const match = message.text.match(/^\[TASK:([^\]]+)\]/); if (!match) { - log.warn(`Invalid task format: ${message.text}`); + log.warn( + { messageId: message.id, messageLength: message.text.length }, + "Invalid task format" + ); return; } @@ -138,7 +141,7 @@ export class ScheduledTaskHandler { // Mark task as done if agent responded successfully taskStore.completeTask(taskId, response.content); - log.info(`Executed scheduled task ${taskId}: ${task.description}`); + log.info({ taskId }, "Executed scheduled task"); // Initialize dependency resolver if needed if (!this.dependencyResolver) { diff --git a/src/telegram/client.ts b/src/telegram/client.ts index 3036c918..e8476c98 100644 --- a/src/telegram/client.ts +++ b/src/telegram/client.ts @@ -242,7 +242,8 @@ export class TelegramUserClient { const chatId = event.message.chatId?.toString() ?? "unknown"; const isGroup = chatId.startsWith("-"); log.debug( - `RAW EVENT: chatId=${chatId} isGroup=${isGroup} text="${event.message.message?.substring(0, 30) ?? ""}"` + { chatId, isGroup, messageLength: event.message.message?.length ?? 0 }, + "RAW EVENT" ); } await handler(event); diff --git a/src/telegram/handlers.ts b/src/telegram/handlers.ts index 608b0290..bf14b740 100644 --- a/src/telegram/handlers.ts +++ b/src/telegram/handlers.ts @@ -437,7 +437,8 @@ export class MessageHandler { if (transcribeResult.text) { transcriptionText = transcribeResult.text; log.info( - `Auto-transcribed voice msg ${message.id}: "${transcriptionText?.substring(0, 80)}..."` + { messageId: message.id, transcriptLength: transcriptionText.length }, + "Voice message auto-transcribed" ); } } catch (innerError) { diff --git a/src/telegram/task-dependency-resolver.ts b/src/telegram/task-dependency-resolver.ts index aee1259c..8326bf3e 100644 --- a/src/telegram/task-dependency-resolver.ts +++ b/src/telegram/task-dependency-resolver.ts @@ -150,7 +150,7 @@ export class TaskDependencyResolver { if (skipOnFailure) { this.taskStore.cancelTask(depId); - log.info(`↳ Cancelled task ${depId}: ${task.description}`); + log.info({ taskId: depId }, "Cancelled dependent task"); // Recursively cancel dependents await this.onTaskFail(depId); @@ -172,7 +172,7 @@ export class TaskDependencyResolver { return; } - log.info(`Triggering dependent task: ${task.description}`); + log.info({ taskId }, "Triggering dependent task"); if (this.bridge.getMode() === "bot") { // Bot mode: send to first admin via bridge diff --git a/src/utils/__tests__/sensitive-logging.test.ts b/src/utils/__tests__/sensitive-logging.test.ts new file mode 100644 index 00000000..2e3c362d --- /dev/null +++ b/src/utils/__tests__/sensitive-logging.test.ts @@ -0,0 +1,36 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const ROOT = fileURLToPath(new URL("../../../", import.meta.url)); + +const FORBIDDEN_LOG_FRAGMENTS: Record = { + "src/webui/server.ts": ["auth/exchange?token=${this.authToken}"], + "src/agent/runtime.ts": [ + "Formatted message: ${formattedMessage", + "const preview = formattedMessage", + "original: searchQuery", + ], + "src/agent/tools/search/tool-search.ts": ['query="${query}"'], + "src/telegram/client.ts": ["message?.substring(0, 30)"], + "src/telegram/handlers.ts": ["transcriptionText?.substring"], + "src/agent/tools/telegram/media/transcribe-audio.ts": ["result.text?.substring"], + "src/scheduled-tasks.ts": ["Invalid task format: ${message.text}"], + "src/telegram/task-dependency-resolver.ts": [ + "Cancelled task ${depId}: ${task.description}", + "Triggering dependent task: ${task.description}", + ], + "src/agent/tools/telegram/messaging/inline-send.ts": ['query="${query}" index'], + "src/agent/tools/telegram/chats/edit-channel-info.ts": ["edit_channel_info: ${channel.title}"], +}; + +describe("sensitive logging guard", () => { + for (const [relativePath, fragments] of Object.entries(FORBIDDEN_LOG_FRAGMENTS)) { + it(`${relativePath} does not log user content or credentials`, () => { + const source = readFileSync(`${ROOT}${relativePath}`, "utf8"); + for (const fragment of fragments) { + expect(source, `forbidden log fragment: ${fragment}`).not.toContain(fragment); + } + }); + } +}); diff --git a/src/webui/server.ts b/src/webui/server.ts index 95893287..33302230 100644 --- a/src/webui/server.ts +++ b/src/webui/server.ts @@ -10,13 +10,7 @@ import { startHonoServer, stopHonoServer } from "../utils/http-server.js"; import { createLogger } from "../utils/logger.js"; const log = createLogger("WebUI"); -import { - generateToken, - maskToken, - safeCompare, - COOKIE_NAME, - COOKIE_MAX_AGE, -} from "./middleware/auth.js"; +import { generateToken, safeCompare, COOKIE_NAME, COOKIE_MAX_AGE } from "./middleware/auth.js"; import { logInterceptor } from "./log-interceptor.js"; import { SHARED_ROUTE_FACTORIES } from "./routes/shared.js"; import { createAgentRoutes } from "../api/routes/agent.js"; @@ -300,8 +294,8 @@ export class WebUIServer { const url = `http://${info.address}:${info.port}`; log.info(`WebUI server running`); - log.info(`URL: ${url}/auth/exchange?token=${this.authToken}`); - log.info(`Token: ${maskToken(this.authToken)} (use Bearer header for API access)`); + log.info(`URL: ${url}`); + log.info("Authentication token loaded; use the configured Bearer token for API access"); }, }); } catch (error) { From f434d7a1f83156c249ae927e66996b4cf9a7b950 Mon Sep 17 00:00:00 2001 From: TONresistor <240980241+TONresistor@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:02:22 +0200 Subject: [PATCH 28/59] fix(runtime): align supported Node versions --- .github/workflows/ci.yml | 7 ++- .github/workflows/release.yml | 6 +-- .npmrc | 1 + .nvmrc | 2 +- CONTRIBUTING.md | 2 +- Dockerfile | 4 +- GETTING_STARTED.md | 2 +- README.md | 4 +- docs-sdk/pages/deploy-docker.html | 2 +- docs-sdk/pages/index.html | 2 +- docs-sdk/pages/installation.html | 2 +- docs-sdk/pages/quickstart.html | 2 +- docs/deployment.md | 6 +-- docs/guide.md | 2 +- package-lock.json | 2 +- package.json | 3 +- scripts/check-runtime-contract.mjs | 66 +++++++++++++++++++++++++ src/cli/commands/doctor.ts | 6 +-- src/constants/__tests__/runtime.test.ts | 23 +++++++++ src/constants/index.ts | 1 + src/constants/runtime.ts | 28 +++++++++++ tsup.config.ts | 2 +- 22 files changed, 149 insertions(+), 26 deletions(-) create mode 100644 .npmrc create mode 100644 scripts/check-runtime-contract.mjs create mode 100644 src/constants/__tests__/runtime.test.ts create mode 100644 src/constants/runtime.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d037700d..74b810a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,12 +26,15 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 20 + node-version-file: .nvmrc cache: npm - name: Install dependencies run: npm ci + - name: Verify runtime contract + run: npm run check:runtime + - name: Build SDK workspace run: npm run build -w packages/sdk @@ -62,7 +65,7 @@ jobs: timeout-minutes: 25 strategy: matrix: - node-version: [20, 22] + node-version: ["22.22.2", "24.15.0"] steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 16b8adb6..bd054cbc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 20 + node-version-file: .nvmrc cache: npm cache-dependency-path: | package-lock.json @@ -52,7 +52,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 20 + node-version-file: .nvmrc registry-url: https://registry.npmjs.org cache: npm cache-dependency-path: | @@ -100,7 +100,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 20 + node-version-file: .nvmrc registry-url: https://registry.npmjs.org cache: npm diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000..b6f27f13 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/.nvmrc b/.nvmrc index 209e3ef4..db49bb14 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20 +22.22.2 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 047bfdc3..f03c3bb8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,7 +42,7 @@ This starts the agent in watch mode with automatic restarts on file changes. ### Prerequisites -- **Node.js 20.0.0+** ([download](https://nodejs.org/)) +- **Node.js 22.22.2+ LTS** ([download](https://nodejs.org/)); Node 24 requires 24.15.0+ - **npm 9+** (ships with Node.js) - An LLM API key from any [supported provider](README.md#supported-providers) (Anthropic, OpenAI, Google, xAI, Groq, OpenRouter, Mistral, and more) - Telegram API credentials from [my.telegram.org/apps](https://my.telegram.org/apps) diff --git a/Dockerfile b/Dockerfile index 956a9c3f..509c8d8c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # ---- Build stage ---- -FROM node:20-slim AS build +FROM node:22.22.2-slim AS build WORKDIR /app @@ -30,7 +30,7 @@ RUN cd web && npm ci RUN npm run build # ---- Runtime stage ---- -FROM node:20-slim +FROM node:22.22.2-slim WORKDIR /app diff --git a/GETTING_STARTED.md b/GETTING_STARTED.md index f99105b0..cd6635ce 100644 --- a/GETTING_STARTED.md +++ b/GETTING_STARTED.md @@ -8,7 +8,7 @@ Complete guide to installing, configuring, and running your Teleton AI agent. | Requirement | Details | |-------------|---------| -| **Node.js 20+** | [Download](https://nodejs.org/) - check with `node --version` | +| **Node.js 22.22.2+ LTS** | [Download](https://nodejs.org/) - Node 24 requires 24.15.0+; check with `node --version` | | **LLM API Key** | [Anthropic](https://console.anthropic.com/) (recommended), [OpenAI](https://platform.openai.com/), [Google](https://aistudio.google.com/), [xAI](https://console.x.ai/), [Groq](https://console.groq.com/), [OpenRouter](https://openrouter.ai/), or any of 15 supported providers | | **Telegram Account** | Dedicated account recommended (agent has full control) | | **Telegram API Credentials** | `api_id` + `api_hash` from [my.telegram.org/apps](https://my.telegram.org/apps) | diff --git a/README.md b/README.md index 51a853ed..a97ebd30 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ ## Prerequisites -- **Node.js 20.0.0+** - [Download](https://nodejs.org/) +- **Node.js 22.22.2+ LTS** - [Download](https://nodejs.org/) (Node 24 requires 24.15.0+) - **LLM API Key** - One of: [Anthropic](https://console.anthropic.com/) (recommended), [OpenAI](https://platform.openai.com/), [Google](https://aistudio.google.com/), [xAI](https://console.x.ai/), [Groq](https://console.groq.com/), [OpenRouter](https://openrouter.ai/), [Moonshot](https://platform.moonshot.ai/), [Mistral](https://console.mistral.ai/), [Cerebras](https://cloud.cerebras.ai/), [ZAI](https://open.bigmodel.cn/), [MiniMax](https://platform.minimaxi.com/), [Hugging Face](https://huggingface.co/settings/tokens) — or keyless: Codex (auto-detect), Gocoon (TON), Local (Ollama/vLLM) - **Telegram Account** - Dedicated account recommended for security - **Telegram API Credentials** - From [my.telegram.org/apps](https://my.telegram.org/apps) @@ -486,7 +486,7 @@ The SDK provides namespaced access to core services: | Embeddings | [@huggingface/transformers](https://www.npmjs.com/package/@huggingface/transformers) (local ONNX) or Voyage AI | | MCP Client | [@modelcontextprotocol/sdk](https://modelcontextprotocol.io/) (stdio + SSE + Streamable HTTP) | | WebUI | [Hono](https://hono.dev/) (API) + React + Vite (frontend) | -| Language | TypeScript 5.7, Node.js 20+ | +| Language | TypeScript 5.7, Node.js 22.22.2+ LTS | ### Project Structure diff --git a/docs-sdk/pages/deploy-docker.html b/docs-sdk/pages/deploy-docker.html index 9a9bfa4b..057fb9fb 100644 --- a/docs-sdk/pages/deploy-docker.html +++ b/docs-sdk/pages/deploy-docker.html @@ -42,7 +42,7 @@

Deployment Guide

Prerequisites

    -
  • Node.js 20+ (required by engines field in package.json)
  • +
  • Node.js 22.22.2+ LTS (Node 24 requires 24.15.0+; enforced by package.json)
  • npm (ships with Node.js)
  • Telegram API credentials: api_id and api_hash from my.telegram.org/apps
  • LLM API key: from your chosen provider
  • diff --git a/docs-sdk/pages/index.html b/docs-sdk/pages/index.html index ddab6cc5..0c3fbbbf 100644 --- a/docs-sdk/pages/index.html +++ b/docs-sdk/pages/index.html @@ -122,7 +122,7 @@

    User Mode vs Bot Mode

    Prerequisites

      -
    • Node.js 20.0.0+: Download
    • +
    • Node.js 22.22.2+ LTS: Download (Node 24 requires 24.15.0+)
    • LLM API Key: Anthropic (recommended), OpenAI, Google, xAI, Groq, OpenRouter, Moonshot, Mistral, Cerebras, ZAI, MiniMax, Hugging Face, Cocoon, or Local
    • Telegram Account: Dedicated account recommended for security
    • Telegram API Credentials: From my.telegram.org/apps
    • diff --git a/docs-sdk/pages/installation.html b/docs-sdk/pages/installation.html index f07f6f02..ec188336 100644 --- a/docs-sdk/pages/installation.html +++ b/docs-sdk/pages/installation.html @@ -44,7 +44,7 @@

      Prerequisites

      - + diff --git a/docs-sdk/pages/quickstart.html b/docs-sdk/pages/quickstart.html index 596b671d..6003406b 100644 --- a/docs-sdk/pages/quickstart.html +++ b/docs-sdk/pages/quickstart.html @@ -42,7 +42,7 @@

      Quick Start

      What You Need

        -
      • Node.js 20+ (nodejs.org)
      • +
      • Node.js 22.22.2+ LTS; Node 24 requires 24.15.0+ (nodejs.org)
      • Telegram API credentials from my.telegram.org/apps (API ID + API Hash)
      • Your Telegram user ID (send /start to @userinfobot)
      • An LLM API key (Anthropic, OpenAI, Google, xAI, Groq, Mistral, or any other)
      • diff --git a/docs/deployment.md b/docs/deployment.md index 4c157d68..102ea4d9 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -24,7 +24,7 @@ This guide covers every method of deploying Teleton Agent, from a quick global i Before deploying, make sure you have: -1. **Node.js 20+** (required by the `engines` field in `package.json`) +1. **Node.js 22.22.2+ LTS** (Node 24 requires 24.15.0+; enforced by `package.json`) 2. **npm** (ships with Node.js) 3. **Telegram API credentials** -- obtain `api_id` and `api_hash` from [my.telegram.org/apps](https://my.telegram.org/apps) 4. **LLM API key** -- from your chosen provider (Anthropic, OpenAI, Google, xAI, Groq, OpenRouter, Moonshot, Mistral, Cerebras, ZAI, MiniMax, Hugging Face, or Gocoon) @@ -78,8 +78,8 @@ docker run -d \ The Docker image uses a multi-stage build: -- **Build stage**: Node.js 20-slim with build tools (`python3`, `make`, `g++`), compiles the full project (SDK, backend via tsup, frontend via Vite) -- **Runtime stage**: Node.js 20-slim with production dependencies only. Build tools are purged after native module compilation +- **Build stage**: Node.js 22.22.2-slim with build tools (`python3`, `make`, `g++`), compiles the full project (SDK, backend via tsup, frontend via Vite) +- **Runtime stage**: Node.js 22.22.2-slim with production dependencies only. Build tools are purged after native module compilation - **Data volume**: Mounted at `/data` (set via `TELETON_HOME=/data` in the image) - **Entrypoint**: `node dist/cli/index.js` - **Default command**: `start` diff --git a/docs/guide.md b/docs/guide.md index a6f83c47..11b7a3c2 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -8,7 +8,7 @@ Teleton is an autonomous AI agent that runs as a real Telegram user account. Not What You Need -- Node.js 20+ (nodejs.org) +- Node.js 22.22.2+ LTS (Node 24 requires 24.15.0+; nodejs.org) - Telegram API credentials from my.telegram.org/apps (API ID + API Hash) - Your Telegram user ID (send /start to @userinfobot) - An LLM API key (Anthropic, OpenAI, Google, xAI, Groq, Mistral, or any other) diff --git a/package-lock.json b/package-lock.json index 12c15a6a..354f3ded 100644 --- a/package-lock.json +++ b/package-lock.json @@ -64,7 +64,7 @@ "vitest": "^4.1.1" }, "engines": { - "node": ">=20.0.0" + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" } }, "node_modules/@anthropic-ai/sdk": { diff --git a/package.json b/package.json index a454314b..818aa5a6 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "circular": "madge --circular --no-spinner --no-color --extensions ts --ts-config tsconfig.json src/", "dupcheck": "jscpd --config .jscpd.json src/", "audit:ci": "audit-ci --config ./audit-ci.jsonc", + "check:runtime": "node scripts/check-runtime-contract.mjs", "prepublishOnly": "npm run build", "prepare": "husky" }, @@ -116,7 +117,7 @@ "vitest": "^4.1.1" }, "engines": { - "node": ">=20.0.0" + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" }, "lint-staged": { "src/**/*.ts": [ diff --git a/scripts/check-runtime-contract.mjs b/scripts/check-runtime-contract.mjs new file mode 100644 index 00000000..256e5d17 --- /dev/null +++ b/scripts/check-runtime-contract.mjs @@ -0,0 +1,66 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const read = (path) => readFileSync(join(root, path), "utf8"); +const minimum = read(".nvmrc").trim(); +const supportedRange = `^${minimum} || ^24.15.0 || >=26.0.0`; +const failures = []; + +function check(condition, message) { + if (!condition) failures.push(message); +} + +check(/^\d+\.\d+\.\d+$/.test(minimum), `.nvmrc must contain an exact version, got "${minimum}"`); + +const packageJson = JSON.parse(read("package.json")); +const packageLock = JSON.parse(read("package-lock.json")); +check(packageJson.engines?.node === supportedRange, `package.json engines.node must be ${supportedRange}`); +check( + packageLock.packages?.[""]?.engines?.node === supportedRange, + `package-lock.json root engines.node must be ${supportedRange}`, +); +check(read(".npmrc").split(/\r?\n/).includes("engine-strict=true"), ".npmrc must enable engine-strict"); + +const dockerfile = read("Dockerfile"); +const dockerImage = `node:${minimum}-slim`; +check( + [...dockerfile.matchAll(/^FROM\s+(node:[^\s]+).*$/gm)].every(([, image]) => image === dockerImage), + `every Docker stage must use ${dockerImage}`, +); +check((dockerfile.match(/^FROM\s+/gm) ?? []).length === 2, "Dockerfile must keep exactly two stages"); + +const runtimeConstants = read("src/constants/runtime.ts"); +check( + runtimeConstants.includes(`MINIMUM_NODE_VERSION = "${minimum}"`), + "runtime minimum must match .nvmrc", +); +check( + runtimeConstants.includes(`SUPPORTED_NODE_RANGE = "${supportedRange}"`), + "runtime supported range must match package.json", +); +check( + read("tsup.config.ts").includes(`target: "node${minimum.split(".")[0]}"`), + "backend build target must match the minimum Node major", +); + +const ci = read(".github/workflows/ci.yml"); +check(ci.includes("node-version-file: .nvmrc"), "CI checks must use .nvmrc"); +check( + ci.includes(`node-version: ["${minimum}", "24.15.0"]`), + "CI test matrix must cover both supported LTS minimums", +); + +const release = read(".github/workflows/release.yml"); +check( + (release.match(/node-version-file: \.nvmrc/g) ?? []).length === 3, + "every release setup-node step must use .nvmrc", +); + +if (failures.length > 0) { + for (const failure of failures) console.error(`runtime contract: ${failure}`); + process.exitCode = 1; +} else { + console.log(`Runtime contract verified (${supportedRange})`); +} diff --git a/src/cli/commands/doctor.ts b/src/cli/commands/doctor.ts index baebb666..1e54e202 100644 --- a/src/cli/commands/doctor.ts +++ b/src/cli/commands/doctor.ts @@ -9,6 +9,7 @@ import { type SupportedProvider, } from "../../config/providers.js"; import { getErrorMessage } from "../../utils/errors.js"; +import { SUPPORTED_NODE_RANGE, isNodeVersionSupported } from "../../constants/runtime.js"; import { GREEN, YELLOW, RED } from "../prompts.js"; interface CheckResult { @@ -397,13 +398,12 @@ async function checkAdmins(workspaceDir: string): Promise { async function checkNodeVersion(): Promise { const version = process.version; - const major = parseInt(version.slice(1).split(".")[0]); - if (major < 20) { + if (!isNodeVersionSupported(version)) { return { name: "Node.js", status: "error", - message: `${version} (requires >= 20.0.0)`, + message: `${version} (requires ${SUPPORTED_NODE_RANGE})`, }; } diff --git a/src/constants/__tests__/runtime.test.ts b/src/constants/__tests__/runtime.test.ts new file mode 100644 index 00000000..c5bf0e8a --- /dev/null +++ b/src/constants/__tests__/runtime.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { MINIMUM_NODE_VERSION, SUPPORTED_NODE_RANGE, isNodeVersionSupported } from "../runtime.js"; + +describe("Node.js runtime contract", () => { + it("pins the minimum version required by production dependencies", () => { + expect(MINIMUM_NODE_VERSION).toBe("22.22.2"); + expect(SUPPORTED_NODE_RANGE).toBe("^22.22.2 || ^24.15.0 || >=26.0.0"); + }); + + it.each([ + ["v22.22.1", false], + ["v22.22.2", true], + ["22.23.0", true], + ["v23.0.0", false], + ["v24.14.9", false], + ["v24.15.0", true], + ["v25.0.0", false], + ["v26.0.0", true], + ["invalid", false], + ])("evaluates %s", (version, expected) => { + expect(isNodeVersionSupported(version)).toBe(expected); + }); +}); diff --git a/src/constants/index.ts b/src/constants/index.ts index f8a62b1c..b6781696 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -1,3 +1,4 @@ export * from "./api-endpoints.js"; export * from "./timeouts.js"; export * from "./limits.js"; +export * from "./runtime.js"; diff --git a/src/constants/runtime.ts b/src/constants/runtime.ts new file mode 100644 index 00000000..50b9ee9f --- /dev/null +++ b/src/constants/runtime.ts @@ -0,0 +1,28 @@ +export const MINIMUM_NODE_VERSION = "22.22.2"; +export const SUPPORTED_NODE_RANGE = "^22.22.2 || ^24.15.0 || >=26.0.0"; + +type Version = readonly [major: number, minor: number, patch: number]; + +function parseVersion(value: string): Version | null { + const match = /^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(value.trim()); + if (!match) return null; + return [Number(match[1]), Number(match[2]), Number(match[3])]; +} + +function isAtLeast(version: Version, minimum: Version): boolean { + for (let index = 0; index < version.length; index += 1) { + if (version[index] !== minimum[index]) { + return version[index] > minimum[index]; + } + } + return true; +} + +export function isNodeVersionSupported(value: string): boolean { + const version = parseVersion(value); + if (!version) return false; + + if (version[0] === 22) return isAtLeast(version, [22, 22, 2]); + if (version[0] === 24) return isAtLeast(version, [24, 15, 0]); + return version[0] >= 26; +} diff --git a/tsup.config.ts b/tsup.config.ts index 133ae179..e4cdb211 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -31,7 +31,7 @@ export default defineConfig({ "cli/index": "src/cli/index.ts", }, format: "esm", - target: "node20", + target: "node22", platform: "node", splitting: true, clean: false, From 415aee56d79b94dd36acf9eac584daf441ad820c Mon Sep 17 00:00:00 2001 From: TONresistor <240980241+TONresistor@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:18:16 +0200 Subject: [PATCH 29/59] fix(ton-proxy): harden process lifecycle --- src/ton-proxy/__tests__/manager.test.ts | 197 +++++ src/ton-proxy/manager.ts | 713 +++++++++++++------ src/ton-proxy/module.ts | 2 +- src/webui/__tests__/ton-proxy-routes.test.ts | 80 +++ src/webui/routes/ton-proxy.ts | 21 +- 5 files changed, 802 insertions(+), 211 deletions(-) create mode 100644 src/ton-proxy/__tests__/manager.test.ts create mode 100644 src/webui/__tests__/ton-proxy-routes.test.ts diff --git a/src/ton-proxy/__tests__/manager.test.ts b/src/ton-proxy/__tests__/manager.test.ts new file mode 100644 index 00000000..e24b5f14 --- /dev/null +++ b/src/ton-proxy/__tests__/manager.test.ts @@ -0,0 +1,197 @@ +import { EventEmitter } from "node:events"; +import { createHash } from "node:crypto"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, dirname, join } from "node:path"; +import type { ChildProcess } from "node:child_process"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { TonProxyManager, type TonProxyManagerDependencies } from "../manager.js"; + +class FakeChild extends EventEmitter { + readonly pid: number; + exitCode: number | null = null; + readonly stdout = new EventEmitter(); + readonly stderr = new EventEmitter(); + readonly kill = vi.fn((signal: NodeJS.Signals = "SIGTERM") => { + if (this.exitCode !== null) return false; + this.exitCode = 0; + this.emit("exit", 0, signal); + return true; + }); + + constructor(pid: number) { + super(); + this.pid = pid; + } + + crash(): void { + this.exitCode = 1; + this.emit("exit", 1, null); + } +} + +const roots: string[] = []; + +function makeRoot(): string { + const root = mkdtempSync(join(tmpdir(), "teleton-ton-proxy-")); + roots.push(root); + return root; +} + +function makeDependencies( + root: string, + overrides: Partial = {} +): TonProxyManagerDependencies { + return { + binaryDir: join(root, "bin"), + pidFile: join(root, "ton-proxy.pid"), + release: { tag: "v1.2.3", assets: {} }, + fetch: vi.fn(), + spawn: vi.fn(), + killProcess: vi.fn(), + isProcessAlive: vi.fn(() => false), + isExpectedProcess: vi.fn(() => false), + isPortListening: vi.fn(async () => false), + sleep: vi.fn(async () => undefined), + ...overrides, + }; +} + +function installFakeBinary(manager: TonProxyManager): void { + const path = manager.getBinaryPath(); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, "fake binary", { mode: 0o755 }); + chmodSync(path, 0o755); +} + +afterEach(() => { + vi.useRealTimers(); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("TonProxyManager", () => { + it("verifies the release asset digest before atomically installing it", async () => { + const root = makeRoot(); + const content = Buffer.from("verified ton proxy binary"); + const digest = createHash("sha256").update(content).digest("hex"); + const fetchMock = vi.fn(); + const deps = makeDependencies(root, { fetch: fetchMock as typeof fetch }); + const manager = new TonProxyManager({ enabled: true, port: 8080 }, deps); + const binaryName = basename(manager.getBinaryPath()); + + deps.release.assets[binaryName] = { size: content.length, digest: `sha256:${digest}` }; + fetchMock.mockResolvedValueOnce(new Response(content, { status: 200 })); + + await manager.install(); + + expect(readFileSync(manager.getBinaryPath())).toEqual(content); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("removes a download whose digest does not match", async () => { + const root = makeRoot(); + const content = Buffer.from("tampered binary"); + const fetchMock = vi.fn(); + const deps = makeDependencies(root, { fetch: fetchMock as typeof fetch }); + const manager = new TonProxyManager({ enabled: true, port: 8080 }, deps); + const binaryName = basename(manager.getBinaryPath()); + + deps.release.assets[binaryName] = { + size: content.length, + digest: `sha256:${"0".repeat(64)}`, + }; + fetchMock.mockResolvedValueOnce(new Response(content, { status: 200 })); + + await expect(manager.install()).rejects.toThrow("digest mismatch"); + expect(existsSync(manager.getBinaryPath())).toBe(false); + }); + + it("never kills an unverified PID or an unrelated port owner", async () => { + const root = makeRoot(); + const killProcess = vi.fn(); + const spawn = vi.fn(); + const deps = makeDependencies(root, { + killProcess, + spawn, + isProcessAlive: vi.fn(() => true), + isExpectedProcess: vi.fn(() => false), + isPortListening: vi.fn(async () => true), + }); + const manager = new TonProxyManager({ enabled: true, port: 8080 }, deps); + installFakeBinary(manager); + writeFileSync(deps.pidFile, JSON.stringify({ pid: 4242 })); + + await expect(manager.start()).rejects.toThrow("port 8080 is already in use"); + + expect(killProcess).not.toHaveBeenCalled(); + expect(spawn).not.toHaveBeenCalled(); + }); + + it("terminates an unhealthy proxy after three failed TCP probes", async () => { + vi.useFakeTimers(); + const root = makeRoot(); + let spawned = false; + let healthy = true; + const child = new FakeChild(1001); + const deps = makeDependencies(root, { + spawn: vi.fn(() => { + spawned = true; + return child as unknown as ChildProcess; + }), + isPortListening: vi.fn(async () => spawned && healthy), + }); + const manager = new TonProxyManager({ enabled: true, port: 8080 }, deps); + installFakeBinary(manager); + await manager.start(); + + healthy = false; + const checkHealth = manager as unknown as { checkHealth(): Promise }; + await checkHealth.checkHealth(); + await checkHealth.checkHealth(); + expect(child.kill).not.toHaveBeenCalled(); + await checkHealth.checkHealth(); + + expect(child.kill).toHaveBeenCalledWith("SIGTERM"); + }); + + it("caps automatic restarts without resetting the attempt counter", async () => { + vi.useFakeTimers(); + const root = makeRoot(); + let spawned = false; + const children: FakeChild[] = []; + const spawn = vi.fn(() => { + const child = new FakeChild(2000 + children.length); + children.push(child); + spawned = true; + return child as unknown as ChildProcess; + }); + const deps = makeDependencies(root, { + spawn, + isPortListening: vi.fn(async () => spawned), + }); + const manager = new TonProxyManager({ enabled: true, port: 8080 }, deps); + installFakeBinary(manager); + await manager.start(); + + for (let attempt = 0; attempt < 3; attempt += 1) { + spawned = false; + children[attempt].crash(); + await vi.advanceTimersByTimeAsync(1_000); + } + + spawned = false; + children[3].crash(); + await vi.advanceTimersByTimeAsync(5_000); + + expect(spawn).toHaveBeenCalledTimes(4); + expect(manager.isRunning()).toBe(false); + }); +}); diff --git a/src/ton-proxy/manager.ts b/src/ton-proxy/manager.ts index 053beb68..5c775937 100644 --- a/src/ton-proxy/manager.ts +++ b/src/ton-proxy/manager.ts @@ -5,291 +5,463 @@ * The CLI binary exposes an HTTP proxy on 127.0.0.1: for .ton sites. */ -import { spawn, execSync, type ChildProcess } from "child_process"; +import { createHash, randomUUID } from "node:crypto"; +import { spawn, spawnSync, type ChildProcess } from "node:child_process"; import { - existsSync, chmodSync, createWriteStream, + existsSync, readFileSync, - writeFileSync, + realpathSync, + renameSync, unlinkSync, -} from "fs"; -import { mkdir } from "fs/promises"; -import { join } from "path"; -import { pipeline } from "stream/promises"; + writeFileSync, +} from "node:fs"; +import { mkdir } from "node:fs/promises"; +import { createConnection } from "node:net"; +import { basename, dirname, join } from "node:path"; +import { Transform } from "node:stream"; +import { pipeline } from "node:stream/promises"; +import type { TonProxyConfig } from "../config/schema.js"; import { createLogger } from "../utils/logger.js"; import { TELETON_ROOT } from "../workspace/paths.js"; -import type { TonProxyConfig } from "../config/schema.js"; const log = createLogger("TonProxy"); const GITHUB_REPO = "xssnick/Tonutils-Proxy"; -const BINARY_DIR = join(TELETON_ROOT, "bin"); -const PID_FILE = join(TELETON_ROOT, "ton-proxy.pid"); +const DEFAULT_BINARY_DIR = join(TELETON_ROOT, "bin"); +const DEFAULT_PID_FILE = join(TELETON_ROOT, "ton-proxy.pid"); const HEALTH_CHECK_INTERVAL_MS = 30_000; -const HEALTH_CHECK_TIMEOUT_MS = 5_000; +const HEALTH_FAILURE_LIMIT = 3; +const PORT_PROBE_TIMEOUT_MS = 500; +const STARTUP_TIMEOUT_MS = 10_000; const KILL_GRACE_MS = 5_000; +const RESTART_DELAY_MS = 1_000; +const RESTART_STABILITY_MS = 5 * 60_000; +const MAX_RESTARTS = 3; +const MAX_BINARY_SIZE_BYTES = 50 * 1024 * 1024; + +interface ReleaseAsset { + size: number; + digest: string; +} + +export interface TonProxyReleaseManifest { + tag: string; + assets: Record; +} + +const PINNED_RELEASE: TonProxyReleaseManifest = { + tag: "v1.8.3", + assets: { + "tonutils-proxy-cli-darwin-amd64": { + size: 18_080_160, + digest: "sha256:ae9e85bc909318606246a45325c20fa148300989b9405f6696dc298fc87fdcef", + }, + "tonutils-proxy-cli-darwin-arm64": { + size: 17_283_778, + digest: "sha256:d8d4720700a2c59cd2b5947a6d2f7119542b8baa51a2e6b2c247f3a099b8174b", + }, + "tonutils-proxy-cli-linux-amd64": { + size: 18_096_026, + digest: "sha256:8df4974e198db5c7de5883b6ec9b0d3e8e6fda90c0d42c8f6bc0164b9f94aa32", + }, + "tonutils-proxy-cli-windows-amd64.exe": { + size: 18_446_336, + digest: "sha256:4a3ef2798d65f76d62414d38bb53418e6fb33d11badac263ca5946f9359f6ba8", + }, + }, +}; + +export interface TonProxyManagerDependencies { + binaryDir: string; + pidFile: string; + release: TonProxyReleaseManifest; + fetch: typeof fetch; + spawn: ( + command: string, + args: string[], + options: { + cwd: string; + stdio: ["ignore", "pipe", "pipe"]; + detached: false; + } + ) => ChildProcess; + killProcess: (pid: number, signal: NodeJS.Signals | 0) => void; + isProcessAlive: (pid: number) => boolean; + isExpectedProcess: (pid: number, binaryPath: string) => boolean; + isPortListening: (port: number) => Promise; + sleep: (milliseconds: number) => Promise; +} + +function sleep(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "EPERM"; + } +} + +function isExpectedProcess(pid: number, binaryPath: string): boolean { + if (!isProcessAlive(pid)) return false; + + try { + const expectedPath = realpathSync(binaryPath); + + if (process.platform === "linux") { + return realpathSync(`/proc/${pid}/exe`) === expectedPath; + } + + if (process.platform === "darwin") { + const result = spawnSync("ps", ["-p", String(pid), "-o", "command="], { + encoding: "utf8", + timeout: 3_000, + }); + if (result.status !== 0) return false; + const command = result.stdout.trim(); + return command === expectedPath || command.startsWith(`${expectedPath} `); + } + } catch { + return false; + } + + // Fail closed on platforms where process identity cannot be established. + return false; +} + +function isPortListening(port: number): Promise { + return new Promise((resolve) => { + const socket = createConnection({ host: "127.0.0.1", port }); + let settled = false; + + const finish = (listening: boolean) => { + if (settled) return; + settled = true; + socket.destroy(); + resolve(listening); + }; + + socket.setTimeout(PORT_PROBE_TIMEOUT_MS); + socket.once("connect", () => finish(true)); + socket.once("error", () => finish(false)); + socket.once("timeout", () => finish(false)); + }); +} + +const DEFAULT_DEPENDENCIES: TonProxyManagerDependencies = { + binaryDir: DEFAULT_BINARY_DIR, + pidFile: DEFAULT_PID_FILE, + release: PINNED_RELEASE, + fetch: globalThis.fetch, + spawn: (command, args, options) => spawn(command, args, options), + killProcess: (pid, signal) => { + process.kill(pid, signal); + }, + isProcessAlive, + isExpectedProcess, + isPortListening, + sleep, +}; export class TonProxyManager { private process: ChildProcess | null = null; private healthInterval: ReturnType | null = null; - private config: TonProxyConfig; + private restartTimer: ReturnType | null = null; + private restartResetTimer: ReturnType | null = null; + private startPromise: Promise | null = null; + private readonly config: TonProxyConfig; + private readonly dependencies: TonProxyManagerDependencies; private restartCount = 0; - private maxRestarts = 3; + private healthFailures = 0; + private stopping = false; - constructor(config: TonProxyConfig) { + constructor(config: TonProxyConfig, dependencies: Partial = {}) { this.config = config; + this.dependencies = { ...DEFAULT_DEPENDENCIES, ...dependencies }; } - /** Resolve the binary path — user-specified or auto-detected */ + /** Resolve the binary path — user-specified or auto-detected. */ getBinaryPath(): string { if (this.config.binary_path) return this.config.binary_path; - return join(BINARY_DIR, getBinaryName()); + return join(this.dependencies.binaryDir, getBinaryName()); } - /** Check if the binary exists on disk */ + /** Check if the binary exists on disk. */ isInstalled(): boolean { return existsSync(this.getBinaryPath()); } - /** Whether the proxy process is currently running */ + /** Whether the proxy process is currently running. */ isRunning(): boolean { return this.process !== null && this.process.exitCode === null; } /** - * Download the latest CLI binary from GitHub releases. - * Fetches the latest release tag, then downloads the platform-appropriate binary. + * Download the reviewed CLI release and verify its pinned SHA-256 digest + * before atomically promoting it into place. */ async install(): Promise { + if (this.config.binary_path) { + throw new Error(`Configured TON Proxy binary does not exist: ${this.config.binary_path}`); + } + const binaryName = getBinaryName(); log.info(`Downloading TON Proxy binary (${binaryName})...`); + await mkdir(this.dependencies.binaryDir, { recursive: true }); - await mkdir(BINARY_DIR, { recursive: true }); - - // Fetch latest release tag - const releaseUrl = `https://api.github.com/repos/${GITHUB_REPO}/releases/latest`; - const releaseRes = await fetch(releaseUrl, { - headers: { Accept: "application/vnd.github.v3+json" }, - }); - if (!releaseRes.ok) { - throw new Error(`Failed to fetch latest release: ${releaseRes.status}`); + const release = this.dependencies.release; + const asset = release.assets[binaryName]; + if (!asset) { + throw new Error(`Pinned release ${release.tag} has no asset named ${binaryName}`); } - const release = (await releaseRes.json()) as { tag_name: string }; - const tag = release.tag_name; - - // Download binary - const downloadUrl = `https://github.com/${GITHUB_REPO}/releases/download/${tag}/${binaryName}`; - log.info(`Downloading ${downloadUrl}`); - - const res = await fetch(downloadUrl); - if (!res.ok || !res.body) { - throw new Error(`Download failed: ${res.status} ${res.statusText}`); + if ( + !Number.isSafeInteger(asset.size) || + asset.size <= 0 || + asset.size > MAX_BINARY_SIZE_BYTES + ) { + throw new Error(`Invalid TON Proxy asset size: ${asset.size}`); + } + if (!/^sha256:[a-f0-9]{64}$/i.test(asset.digest)) { + throw new Error(`Release asset ${binaryName} has no valid SHA-256 digest`); + } + const downloadUrl = `https://github.com/${GITHUB_REPO}/releases/download/${encodeURIComponent(release.tag)}/${encodeURIComponent(binaryName)}`; + const binaryResponse = await this.dependencies.fetch(downloadUrl, { + headers: { Accept: "application/octet-stream", "User-Agent": "teleton-agent" }, + }); + if (!binaryResponse.ok || !binaryResponse.body) { + throw new Error( + `Download failed: ${binaryResponse.status} ${binaryResponse.statusText}`.trim() + ); } - const dest = this.getBinaryPath(); - const fileStream = createWriteStream(dest); - // Node fetch body is a ReadableStream; pipe through to file - await pipeline(res.body as unknown as NodeJS.ReadableStream, fileStream); - - // Make executable - chmodSync(dest, 0o755); - - log.info(`TON Proxy installed: ${dest} (${tag})`); - } - - /** Kill any orphan proxy process from a previous session */ - private killOrphan(): void { - // Check PID file first - if (existsSync(PID_FILE)) { - try { - const pid = parseInt(readFileSync(PID_FILE, "utf-8").trim(), 10); - if (pid && !isNaN(pid)) { - try { - process.kill(pid, 0); // check if alive - log.warn(`Killing orphan TON Proxy (PID ${pid}) from previous session`); - process.kill(pid, "SIGTERM"); - } catch { - // Process already dead — clean up stale PID file - } + const destination = this.getBinaryPath(); + const temporaryPath = join( + dirname(destination), + `.${basename(destination)}.${process.pid}.${randomUUID()}.download` + ); + const hash = createHash("sha256"); + let downloadedBytes = 0; + const verifier = new Transform({ + transform(chunk: Buffer, _encoding, callback) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + downloadedBytes += buffer.length; + if (downloadedBytes > MAX_BINARY_SIZE_BYTES) { + callback(new Error("TON Proxy download exceeds 50 MB")); + return; } - unlinkSync(PID_FILE); - } catch { - // PID file read/parse error — ignore - } - } + hash.update(buffer); + callback(null, buffer); + }, + }); - // Also check if port is in use (belt & suspenders) try { - const out = execSync(`ss -tlnp 2>/dev/null | grep ':${this.config.port} ' || true`, { - encoding: "utf-8", - timeout: 3000, - }); - const pidMatch = out.match(/pid=(\d+)/); - if (pidMatch) { - const pid = parseInt(pidMatch[1], 10); - log.warn(`Port ${this.config.port} occupied by PID ${pid}, killing it`); - try { - process.kill(pid, "SIGTERM"); - } catch { - // Already dead - } - // Give it a moment to release the port - execSync("sleep 0.5"); + await pipeline( + binaryResponse.body as unknown as NodeJS.ReadableStream, + verifier, + createWriteStream(temporaryPath, { flags: "wx", mode: 0o600 }) + ); + + if (downloadedBytes !== asset.size) { + throw new Error( + `TON Proxy asset size mismatch: expected ${asset.size}, received ${downloadedBytes}` + ); + } + const expectedDigest = asset.digest.slice("sha256:".length).toLowerCase(); + const actualDigest = hash.digest("hex"); + if (actualDigest !== expectedDigest) { + throw new Error( + `TON Proxy asset digest mismatch: expected ${expectedDigest}, received ${actualDigest}` + ); } - } catch { - // ss not available or other error — skip - } - } - /** Write PID to file for orphan detection */ - private writePidFile(pid: number): void { - try { - writeFileSync(PID_FILE, String(pid), { mode: 0o600 }); - } catch { - log.warn("Failed to write TON Proxy PID file"); + chmodSync(temporaryPath, 0o755); + renameSync(temporaryPath, destination); + } catch (error) { + try { + if (existsSync(temporaryPath)) unlinkSync(temporaryPath); + } catch { + // Preserve the original verification error. + } + throw error; } - } - /** Remove PID file */ - private removePidFile(): void { - try { - if (existsSync(PID_FILE)) unlinkSync(PID_FILE); - } catch { - // ignore - } + log.info(`TON Proxy installed: ${destination} (${release.tag})`); } - /** Start the proxy process */ + /** Start the proxy process. Concurrent callers share the same startup. */ async start(): Promise { + if (this.startPromise) return this.startPromise; if (this.isRunning()) { log.warn("TON Proxy is already running"); return; } - // Reset restart counter for fresh start + this.stopping = false; this.restartCount = 0; - this.maxRestarts = 3; + this.clearRestartTimer(); - // Kill any orphan process from a previous session - this.killOrphan(); - - if (!this.isInstalled()) { - await this.install(); + const startup = this.startInitialProcess(); + this.startPromise = startup; + try { + await startup; + } finally { + if (this.startPromise === startup) this.startPromise = null; } + } + private async startInitialProcess(): Promise { + await this.cleanupOrphan(); + + if (!this.isInstalled()) await this.install(); + await this.assertPortAvailable(); + + await this.launchProcess(); + } + + private async launchProcess(): Promise { const binaryPath = this.getBinaryPath(); const port = String(this.config.port); - log.info(`Starting TON Proxy on 127.0.0.1:${port}`); - this.process = spawn(binaryPath, ["-addr", `127.0.0.1:${port}`], { - cwd: BINARY_DIR, + const child = this.dependencies.spawn(binaryPath, ["-addr", `127.0.0.1:${port}`], { + cwd: dirname(binaryPath), stdio: ["ignore", "pipe", "pipe"], detached: false, }); + this.process = child; + let ready = false; + let spawnError: Error | null = null; - this.process.stdout?.on("data", (chunk: Buffer) => { + child.stdout?.on("data", (chunk: Buffer) => { const line = chunk.toString().trim(); if (line) log.debug(`[proxy] ${line}`); }); - - this.process.stderr?.on("data", (chunk: Buffer) => { + child.stderr?.on("data", (chunk: Buffer) => { const line = chunk.toString().trim(); if (line) log.warn(`[proxy:err] ${line}`); }); - - this.process.on("exit", (code, signal) => { + child.once("error", (error) => { + spawnError = error; + log.error({ err: error }, "TON Proxy process error"); + }); + child.once("exit", (code, signal) => { + const wasCurrentProcess = this.process === child; + if (wasCurrentProcess) { + this.process = null; + this.stopHealthCheck(); + this.clearRestartResetTimer(); + this.removePidFile(); + } log.info(`TON Proxy exited (code=${code}, signal=${signal})`); - this.process = null; - this.removePidFile(); - // Auto-restart on unexpected exit (up to maxRestarts) - if (code !== 0 && code !== null && this.restartCount < this.maxRestarts) { - this.restartCount++; - log.warn(`Auto-restarting TON Proxy (attempt ${this.restartCount}/${this.maxRestarts})`); - // Fire-and-forget restart - this.start().catch((err) => log.error({ err }, "Failed to auto-restart TON Proxy")); - } + if (ready && wasCurrentProcess && !this.stopping) this.scheduleRestart(); }); - this.process.on("error", (err) => { - log.error({ err }, "TON Proxy process error"); - this.process = null; - }); + try { + await this.waitUntilReady(child, () => spawnError); + } catch (error) { + if (this.process === child) this.process = null; + this.removePidFile(); + if (child.exitCode === null) child.kill("SIGTERM"); + throw error; + } - // Start health check loop + if (this.process !== child || child.exitCode !== null) { + throw new Error("TON Proxy process exited as startup completed"); + } + + ready = true; + this.healthFailures = 0; + if (child.pid) this.writePidFile(child.pid); this.startHealthCheck(); + this.scheduleRestartReset(); + log.info(`TON Proxy running on 127.0.0.1:${port} (PID ${child.pid})`); + } - // Wait briefly for process to crash or start - await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - if (this.isRunning()) { - resolve(); - } else { - reject(new Error("TON Proxy process exited immediately")); - } - }, 1000); + private async waitUntilReady( + child: ChildProcess, + getSpawnError: () => Error | null + ): Promise { + const attempts = Math.ceil(STARTUP_TIMEOUT_MS / 100); + for (let attempt = 0; attempt < attempts; attempt += 1) { + const error = getSpawnError(); + if (error) throw new Error(`TON Proxy failed to spawn: ${error.message}`); + if (this.process !== child || child.exitCode !== null) { + throw new Error("TON Proxy process exited during startup"); + } + if (await this.dependencies.isPortListening(this.config.port)) return; + await this.dependencies.sleep(100); + } + throw new Error(`TON Proxy did not listen on port ${this.config.port} within 10 seconds`); + } - this.process?.on("exit", () => { - clearTimeout(timer); - reject(new Error("TON Proxy process exited during startup")); - }); - }); + private scheduleRestart(): void { + if (this.stopping || this.restartTimer) return; + if (this.restartCount >= MAX_RESTARTS) { + log.error(`TON Proxy restart limit reached (${MAX_RESTARTS})`); + return; + } - if (this.process?.pid) this.writePidFile(this.process.pid); - log.info(`TON Proxy running on 127.0.0.1:${port} (PID ${this.process?.pid})`); + this.restartCount += 1; + const attempt = this.restartCount; + log.warn(`Auto-restarting TON Proxy (attempt ${attempt}/${MAX_RESTARTS})`); + this.restartTimer = setTimeout(() => { + this.restartTimer = null; + if (this.stopping || this.isRunning()) return; + const restart = this.assertPortAvailable().then(() => this.launchProcess()); + this.startPromise = restart; + void restart + .catch((error) => { + log.error({ err: error }, `TON Proxy restart attempt ${attempt} failed`); + this.scheduleRestart(); + }) + .finally(() => { + if (this.startPromise === restart) this.startPromise = null; + }); + }, RESTART_DELAY_MS); } - /** Stop the proxy process gracefully */ + /** Stop the proxy process and cancel every pending restart. */ async stop(): Promise { + this.stopping = true; + this.clearRestartTimer(); + this.clearRestartResetTimer(); this.stopHealthCheck(); - if (!this.process) return; - - // Prevent auto-restart during intentional stop - this.maxRestarts = 0; + const child = this.process; + if (!child) { + this.removePidFile(); + return; + } log.info("Stopping TON Proxy..."); - - return new Promise((resolve) => { - if (!this.process) { - resolve(); - return; - } - - const forceKill = setTimeout(() => { - if (this.process) { - log.warn("TON Proxy did not exit gracefully, sending SIGKILL"); - this.process.kill("SIGKILL"); - } - }, KILL_GRACE_MS); - - this.process.on("exit", () => { - clearTimeout(forceKill); - this.process = null; - this.removePidFile(); - resolve(); - }); - - this.process.kill("SIGTERM"); - }); + await this.terminateChild(child); + if (this.process === child) this.process = null; + this.removePidFile(); } - /** Remove the downloaded binary from disk */ + /** Remove only a Teleton-managed download, never a user-owned custom binary. */ async uninstall(): Promise { - if (this.isRunning()) { - await this.stop(); + if (this.isRunning()) await this.stop(); + if (this.config.binary_path) { + log.info("Configured TON Proxy binary is externally managed and was not removed"); + return; } + const binaryPath = this.getBinaryPath(); if (existsSync(binaryPath)) { - const { unlink } = await import("fs/promises"); + const { unlink } = await import("node:fs/promises"); await unlink(binaryPath); log.info(`TON Proxy binary removed: ${binaryPath}`); } } - /** Get proxy status for WebUI / tools */ + /** Get proxy status for WebUI / tools. */ getStatus(): { running: boolean; port: number; installed: boolean; pid?: number } { return { running: this.isRunning(), @@ -299,6 +471,84 @@ export class TonProxyManager { }; } + private async cleanupOrphan(): Promise { + if (!existsSync(this.dependencies.pidFile)) return; + + const pid = this.readPidFile(); + if (!pid) { + log.warn("Removing invalid TON Proxy PID file"); + this.removePidFile(); + return; + } + if (!this.dependencies.isProcessAlive(pid)) { + this.removePidFile(); + return; + } + if (!this.dependencies.isExpectedProcess(pid, this.getBinaryPath())) { + log.warn(`Refusing to kill unverified process from TON Proxy PID file (PID ${pid})`); + this.removePidFile(); + return; + } + + log.warn(`Stopping orphan TON Proxy (PID ${pid}) from previous session`); + this.dependencies.killProcess(pid, "SIGTERM"); + if (!(await this.waitForPidExit(pid, KILL_GRACE_MS))) { + log.warn(`Orphan TON Proxy PID ${pid} did not exit gracefully, sending SIGKILL`); + this.dependencies.killProcess(pid, "SIGKILL"); + if (!(await this.waitForPidExit(pid, 1_000))) { + throw new Error(`Unable to stop verified orphan TON Proxy process ${pid}`); + } + } + this.removePidFile(); + } + + private async waitForPidExit(pid: number, timeoutMs: number): Promise { + const attempts = Math.ceil(timeoutMs / 100); + for (let attempt = 0; attempt < attempts; attempt += 1) { + if (!this.dependencies.isProcessAlive(pid)) return true; + await this.dependencies.sleep(100); + } + return !this.dependencies.isProcessAlive(pid); + } + + private readPidFile(): number | null { + try { + const value = readFileSync(this.dependencies.pidFile, "utf8").trim(); + const parsed = value.startsWith("{") ? (JSON.parse(value) as { pid?: unknown }).pid : value; + const pid = typeof parsed === "number" ? parsed : Number(parsed); + return Number.isSafeInteger(pid) && pid > 1 ? pid : null; + } catch { + return null; + } + } + + private writePidFile(pid: number): void { + const temporaryPath = `${this.dependencies.pidFile}.${process.pid}.tmp`; + try { + writeFileSync( + temporaryPath, + `${JSON.stringify({ pid, binaryPath: this.getBinaryPath(), startedAt: Date.now() })}\n`, + { mode: 0o600, flag: "w" } + ); + renameSync(temporaryPath, this.dependencies.pidFile); + } catch { + try { + if (existsSync(temporaryPath)) unlinkSync(temporaryPath); + } catch { + // Best-effort cleanup. + } + log.warn("Failed to write TON Proxy PID file"); + } + } + + private removePidFile(): void { + try { + if (existsSync(this.dependencies.pidFile)) unlinkSync(this.dependencies.pidFile); + } catch { + // Best-effort cleanup. + } + } + private startHealthCheck(): void { this.stopHealthCheck(); this.healthInterval = setInterval(() => { @@ -307,35 +557,88 @@ export class TonProxyManager { } private stopHealthCheck(): void { - if (this.healthInterval) { - clearInterval(this.healthInterval); - this.healthInterval = null; - } + if (!this.healthInterval) return; + clearInterval(this.healthInterval); + this.healthInterval = null; + } + + private clearRestartTimer(): void { + if (!this.restartTimer) return; + clearTimeout(this.restartTimer); + this.restartTimer = null; + } + + private scheduleRestartReset(): void { + this.clearRestartResetTimer(); + if (this.restartCount === 0) return; + this.restartResetTimer = setTimeout(() => { + this.restartResetTimer = null; + this.restartCount = 0; + log.info("TON Proxy restart counter reset after stable operation"); + }, RESTART_STABILITY_MS); + } + + private clearRestartResetTimer(): void { + if (!this.restartResetTimer) return; + clearTimeout(this.restartResetTimer); + this.restartResetTimer = null; + } + + private async assertPortAvailable(): Promise { + if (!(await this.dependencies.isPortListening(this.config.port))) return; + throw new Error( + `TON Proxy port ${this.config.port} is already in use; refusing to terminate its owner` + ); } private async checkHealth(): Promise { - if (!this.isRunning()) return; + const child = this.process; + if (!child || child.exitCode !== null) return; - try { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), HEALTH_CHECK_TIMEOUT_MS); - // Tonutils-Proxy responds to regular HTTP requests when used as proxy - // A simple connect to the port verifies it's alive - const res = await fetch(`http://127.0.0.1:${this.config.port}/`, { - signal: controller.signal, - }).catch(() => null); - clearTimeout(timeout); - - if (!res) { - log.warn("TON Proxy health check failed (no response)"); - } - } catch { - // Connection refused is normal for proxy (no upstream) — process liveness is the real check + if (await this.dependencies.isPortListening(this.config.port)) { + this.healthFailures = 0; + return; } + + this.healthFailures += 1; + log.warn(`TON Proxy health check failed (${this.healthFailures}/${HEALTH_FAILURE_LIMIT})`); + if (this.healthFailures < HEALTH_FAILURE_LIMIT) return; + + this.healthFailures = 0; + this.stopHealthCheck(); + log.error("TON Proxy remained unhealthy; restarting it"); + void this.terminateChild(child).catch((error) => { + log.error({ err: error }, "Failed to terminate unhealthy TON Proxy"); + }); + } + + private async terminateChild(child: ChildProcess): Promise { + if (child.exitCode !== null) return; + + await new Promise((resolve) => { + let settled = false; + const finish = () => { + if (settled) return; + settled = true; + clearTimeout(forceKill); + clearTimeout(safetyTimeout); + child.off("exit", finish); + resolve(); + }; + const forceKill = setTimeout(() => { + if (child.exitCode === null) { + log.warn("TON Proxy did not exit gracefully, sending SIGKILL"); + child.kill("SIGKILL"); + } + }, KILL_GRACE_MS); + const safetyTimeout = setTimeout(finish, KILL_GRACE_MS + 1_000); + child.once("exit", finish); + child.kill("SIGTERM"); + }); } } -/** Get the platform-specific binary name */ +/** Get the platform-specific binary name. */ function getBinaryName(): string { const platform = process.platform; const arch = process.arch; @@ -367,6 +670,6 @@ function getBinaryName(): string { throw new Error(`Unsupported architecture: ${arch}`); } - const ext = platform === "win32" ? ".exe" : ""; - return `tonutils-proxy-cli-${os}-${cpuArch}${ext}`; + const extension = platform === "win32" ? ".exe" : ""; + return `tonutils-proxy-cli-${os}-${cpuArch}${extension}`; } diff --git a/src/ton-proxy/module.ts b/src/ton-proxy/module.ts index 5e05c708..6f5fd5c9 100644 --- a/src/ton-proxy/module.ts +++ b/src/ton-proxy/module.ts @@ -54,7 +54,7 @@ const tonProxyModule: PluginModule = { } catch (error) { log.error({ err: error }, "Failed to start TON Proxy"); // Non-fatal: agent continues without proxy - manager = null; + setTonProxyManager(null); } }, diff --git a/src/webui/__tests__/ton-proxy-routes.test.ts b/src/webui/__tests__/ton-proxy-routes.test.ts new file mode 100644 index 00000000..2451ea89 --- /dev/null +++ b/src/webui/__tests__/ton-proxy-routes.test.ts @@ -0,0 +1,80 @@ +import { Hono } from "hono"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { WebUIServerDeps } from "../types.js"; + +const managerState = vi.hoisted(() => ({ + instances: [] as Array<{ + start: ReturnType; + stop: ReturnType; + getStatus: ReturnType; + isRunning: ReturnType; + }>, +})); +const configState = vi.hoisted(() => ({ + readRawConfig: vi.fn(), + writeRawConfig: vi.fn(), +})); + +vi.mock("../../ton-proxy/manager.js", () => ({ + TonProxyManager: class { + start = vi.fn(async () => undefined); + stop = vi.fn(async () => undefined); + getStatus = vi.fn(() => ({ running: true, installed: true, port: 8080, pid: 123 })); + isRunning = vi.fn(() => true); + + constructor() { + managerState.instances.push(this); + } + }, +})); + +vi.mock("../../config/configurable-keys.js", () => ({ + readRawConfig: configState.readRawConfig, + writeRawConfig: configState.writeRawConfig, + setNestedValue: vi.fn((target: Record) => target), +})); + +vi.mock("../../utils/logger.js", () => ({ + createLogger: vi.fn(() => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + })), +})); + +import { getTonProxyManager, setTonProxyManager } from "../../ton-proxy/module.js"; +import { createTonProxyRoutes } from "../routes/ton-proxy.js"; + +function createApp() { + const runtimeConfig = { ton_proxy: { enabled: false, port: 8080 } }; + const deps = { + configPath: "/tmp/teleton-test.yaml", + agent: { getConfig: () => runtimeConfig }, + } as unknown as WebUIServerDeps; + const app = new Hono(); + app.route("/api/ton-proxy", createTonProxyRoutes(deps)); + return app; +} + +describe("TON Proxy WebUI lifecycle", () => { + beforeEach(() => { + vi.clearAllMocks(); + managerState.instances.length = 0; + setTonProxyManager(null); + configState.readRawConfig.mockReturnValue({ ton_proxy: { enabled: false } }); + }); + + it("stops a newly started process if configuration persistence fails", async () => { + configState.writeRawConfig.mockImplementation(() => { + throw new Error("disk full"); + }); + + const response = await createApp().request("/api/ton-proxy/start", { method: "POST" }); + + expect(response.status).toBe(500); + expect(managerState.instances).toHaveLength(1); + expect(managerState.instances[0].stop).toHaveBeenCalledOnce(); + expect(getTonProxyManager()).toBeNull(); + }); +}); diff --git a/src/webui/routes/ton-proxy.ts b/src/webui/routes/ton-proxy.ts index 787242f6..1703afd2 100644 --- a/src/webui/routes/ton-proxy.ts +++ b/src/webui/routes/ton-proxy.ts @@ -28,6 +28,7 @@ export function createTonProxyRoutes(deps: WebUIServerDeps) { // POST /api/ton-proxy/start — enable + start (awaits download & startup) app.post("/start", async (c) => { + let candidate: TonProxyManager | null = null; try { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- runtime config is dynamic const runtimeConfig = deps.agent.getConfig() as Record; @@ -36,25 +37,35 @@ export function createTonProxyRoutes(deps: WebUIServerDeps) { // Stop existing if running const existing = getTonProxyManager(); - if (existing?.isRunning()) await existing.stop(); + if (existing) { + if (existing.isRunning()) await existing.stop(); + setTonProxyManager(null); + } - const mgr = new TonProxyManager({ enabled: true, port, binary_path: binaryPath }); - setTonProxyManager(mgr); - await mgr.start(); + candidate = new TonProxyManager({ enabled: true, port, binary_path: binaryPath }); + await candidate.start(); // Persist enabled=true to YAML const raw = readRawConfig(deps.configPath); setNestedValue(raw, "ton_proxy.enabled", true); writeRawConfig(raw, deps.configPath); setNestedValue(runtimeConfig, "ton_proxy.enabled", true); + setTonProxyManager(candidate); log.info(`TON Proxy started on port ${port} (WebUI)`); return c.json({ success: true, - data: { ...mgr.getStatus(), enabled: true }, + data: { ...candidate.getStatus(), enabled: true }, } as APIResponse); } catch (error: unknown) { + if (candidate) { + try { + await candidate.stop(); + } catch (stopError: unknown) { + log.error({ error: stopError }, "Failed to clean up TON Proxy after start failure"); + } + } log.error({ error }, "Failed to start TON Proxy"); return c.json( { From c8598a075b262348ef1ffca35c90f0f591baeae7 Mon Sep 17 00:00:00 2001 From: TONresistor <240980241+TONresistor@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:29:21 +0200 Subject: [PATCH 30/59] feat(providers): add validated Grok Build support --- GETTING_STARTED.md | 8 +- README.md | 15 +- config.example.yaml | 2 +- docs/configuration.md | 7 +- docs/guide.md | 2 +- src/agent/__tests__/client-grok-build.test.ts | 109 +++++++++ src/agent/client.ts | 46 +++- src/cli/commands/doctor.ts | 38 ++- src/cli/commands/onboard.ts | 40 +++- .../__tests__/configurable-keys.test.ts | 4 +- src/config/model-catalog.ts | 7 + src/config/providers.ts | 36 ++- .../__tests__/grok-build-credentials.test.ts | 176 ++++++++++++++ .../__tests__/grok-build-model.test.ts | 67 ++++++ src/providers/grok-build-credentials.ts | 219 ++++++++++++++++++ src/providers/model-resolver.ts | 36 +++ src/webui/__tests__/setup-routes.test.ts | 66 +++++- src/webui/routes/config.ts | 3 +- src/webui/routes/setup.ts | 5 +- web/src/components/ProviderControl.tsx | 4 +- web/src/components/setup/ProviderStep.tsx | 6 + web/src/components/setup/SetupContext.tsx | 1 + web/src/lib/api.ts | 1 + 23 files changed, 866 insertions(+), 32 deletions(-) create mode 100644 src/agent/__tests__/client-grok-build.test.ts create mode 100644 src/providers/__tests__/grok-build-credentials.test.ts create mode 100644 src/providers/__tests__/grok-build-model.test.ts create mode 100644 src/providers/grok-build-credentials.ts diff --git a/GETTING_STARTED.md b/GETTING_STARTED.md index cd6635ce..1d182094 100644 --- a/GETTING_STARTED.md +++ b/GETTING_STARTED.md @@ -9,7 +9,7 @@ Complete guide to installing, configuring, and running your Teleton AI agent. | Requirement | Details | |-------------|---------| | **Node.js 22.22.2+ LTS** | [Download](https://nodejs.org/) - Node 24 requires 24.15.0+; check with `node --version` | -| **LLM API Key** | [Anthropic](https://console.anthropic.com/) (recommended), [OpenAI](https://platform.openai.com/), [Google](https://aistudio.google.com/), [xAI](https://console.x.ai/), [Groq](https://console.groq.com/), [OpenRouter](https://openrouter.ai/), or any of 15 supported providers | +| **LLM API Key** | [Anthropic](https://console.anthropic.com/) (recommended), [OpenAI](https://platform.openai.com/), [Google](https://aistudio.google.com/), [xAI](https://console.x.ai/), [Groq](https://console.groq.com/), [OpenRouter](https://openrouter.ai/), or any of 16 supported providers | | **Telegram Account** | Dedicated account recommended (agent has full control) | | **Telegram API Credentials** | `api_id` + `api_hash` from [my.telegram.org/apps](https://my.telegram.org/apps) | | **Telegram User ID** | Message [@userinfobot](https://t.me/userinfobot) to get yours | @@ -52,7 +52,7 @@ teleton setup The interactive wizard configures everything: -1. **LLM Provider** - Choose between 15 providers (Anthropic, OpenAI, Google, xAI, Groq, OpenRouter, Moonshot, Mistral, Cerebras, ZAI, MiniMax, Hugging Face, and more) +1. **LLM Provider** - Choose between 16 providers (Anthropic, Codex, Grok Build, OpenAI, Google, xAI, Groq, OpenRouter, Moonshot, Mistral, Cerebras, ZAI, MiniMax, Hugging Face, and more) 2. **Telegram Auth** - API credentials, phone number, login code, 2FA password 3. **Access Policies** - DM policy (open/allowlist/pairing/disabled), group policy, mention rules 4. **Admin** - Your Telegram User ID, owner name/username @@ -113,7 +113,7 @@ Configuration is in `~/.teleton/config.yaml`. The setup wizard generates everyth ```yaml agent: - provider: "anthropic" # anthropic | openai | google | xai | groq | openrouter | moonshot | mistral | cerebras | zai | minimax | huggingface | gocoon | local + provider: "anthropic" # anthropic | codex | grok-build | openai | google | xai | groq | openrouter | moonshot | mistral | cerebras | zai | minimax | huggingface | gocoon | local model: "claude-opus-4-5-20251101" max_tokens: 4096 temperature: 0.7 @@ -142,7 +142,7 @@ agent: model: "gpt-4o" ``` -Supported: `anthropic`, `openai`, `google`, `xai`, `groq`, `openrouter`, `moonshot`, `mistral`, `cerebras`, `zai`, `minimax`, `huggingface`, `gocoon`, `local` +Supported: `anthropic`, `codex`, `grok-build`, `openai`, `google`, `xai`, `groq`, `openrouter`, `moonshot`, `mistral`, `cerebras`, `zai`, `minimax`, `huggingface`, `gocoon`, `local` --- diff --git a/README.md b/README.md index a97ebd30..d9c459d6 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ --- -

        Teleton is an autonomous AI agent platform that operates as a real Telegram user account or a Telegram Bot. It thinks through an agentic loop with tool calling, remembers conversations across sessions with hybrid RAG, and natively integrates the TON blockchain: send crypto, swap on DEXs, bid on domains, verify payments - all from a chat message. It can schedule tasks to run autonomously at any time. It ships with 135+ built-in tools, supports 15 LLM providers, and exposes a Plugin SDK so you can build your own tools on top of the platform.

        +

        Teleton is an autonomous AI agent platform that operates as a real Telegram user account or a Telegram Bot. It thinks through an agentic loop with tool calling, remembers conversations across sessions with hybrid RAG, and natively integrates the TON blockchain: send crypto, swap on DEXs, bid on domains, verify payments - all from a chat message. It can schedule tasks to run autonomously at any time. It ships with 135+ built-in tools, supports 16 LLM providers, and exposes a Plugin SDK so you can build your own tools on top of the platform.

        ### Key Highlights @@ -23,7 +23,7 @@
      - + @@ -61,7 +61,7 @@ | Capability | Description | | ----------------------- | ------------------------------------------------------------------------ | -| **Multi-Provider LLM** | 15 providers, hot-swap from dashboard or CLI | +| **Multi-Provider LLM** | 16 providers, hot-swap from dashboard or CLI | | **RAG + Hybrid Search** | Vector (sqlite-vec) + keyword (FTS5) fused search | | **Auto-Compaction** | AI summarizes old context, saves to `memory/*.md` | | **Observation Masking** | Compresses old tool results, saves ~90% context | @@ -85,7 +85,7 @@ ## Prerequisites - **Node.js 22.22.2+ LTS** - [Download](https://nodejs.org/) (Node 24 requires 24.15.0+) -- **LLM API Key** - One of: [Anthropic](https://console.anthropic.com/) (recommended), [OpenAI](https://platform.openai.com/), [Google](https://aistudio.google.com/), [xAI](https://console.x.ai/), [Groq](https://console.groq.com/), [OpenRouter](https://openrouter.ai/), [Moonshot](https://platform.moonshot.ai/), [Mistral](https://console.mistral.ai/), [Cerebras](https://cloud.cerebras.ai/), [ZAI](https://open.bigmodel.cn/), [MiniMax](https://platform.minimaxi.com/), [Hugging Face](https://huggingface.co/settings/tokens) — or keyless: Codex (auto-detect), Gocoon (TON), Local (Ollama/vLLM) +- **LLM API Key** - One of: [Anthropic](https://console.anthropic.com/) (recommended), [OpenAI](https://platform.openai.com/), [Google](https://aistudio.google.com/), [xAI](https://console.x.ai/), [Groq](https://console.groq.com/), [OpenRouter](https://openrouter.ai/), [Moonshot](https://platform.moonshot.ai/), [Mistral](https://console.mistral.ai/), [Cerebras](https://cloud.cerebras.ai/), [ZAI](https://open.bigmodel.cn/), [MiniMax](https://platform.minimaxi.com/), [Hugging Face](https://huggingface.co/settings/tokens) — or keyless: Codex and Grok Build (CLI auto-detect), Gocoon (TON), Local (Ollama/vLLM) - **Telegram Account** - Dedicated account recommended for security - **Telegram API Credentials** - From [my.telegram.org/apps](https://my.telegram.org/apps) - **Your Telegram User ID** - Message [@userinfobot](https://t.me/userinfobot) @@ -214,7 +214,7 @@ ton_proxy: # Optional: .ton domain proxy ### Supported Models -70+ models across 15 providers. Defined in `src/config/model-catalog.ts`, shared across CLI, WebUI, and Dashboard. +70+ models across 16 providers. Defined in `src/config/model-catalog.ts`, shared across CLI, WebUI, and Dashboard.
      RequirementDetails
      Node.js 20+Download: check with node --version
      Node.js 22.22.2+ LTSDownload: Node 24 requires 24.15.0+; check with node --version
      LLM API KeyAnthropic (recommended), OpenAI, Google, xAI, Groq, OpenRouter, Moonshot, Mistral, Cerebras, ZAI, MiniMax, Hugging Face, or any of 15 supported providers
      Telegram AccountDedicated account recommended (agent has full control)
      Telegram API Credentialsapi_id + api_hash from my.telegram.org/apps

      Full Telegram Access
      Real user via MTProto,
      not a bot


      Agentic Loop
      Think, act, observe, repeat
      until shit gets done


      15 LLM Providers
      Anthropic, OpenAI, Google, xAI, Groq, and more


      16 LLM Providers
      Anthropic, OpenAI, Google, xAI, Groq, and more


      TON Blockchain
      Wallet, jettons, DEX swaps, DNS, NFTs

      @@ -237,6 +237,7 @@ ton_proxy: # Optional: .ton domain proxy +

      Hugging Face
      DeepSeek V3.2


      Gocoon
      Decentralized (TON)


      Local
      Ollama, vLLM, LM Studio


      Grok Build
      CLI auto-detected

      @@ -475,7 +476,7 @@ The SDK provides namespaced access to core services: | Layer | Technology | |-------|------------| -| LLM | Multi-provider via [pi-ai](https://github.com/mariozechner/pi-ai) (15 providers: Anthropic, Codex, OpenAI, Google, xAI, Groq, OpenRouter, Moonshot, Mistral, Cerebras, ZAI, MiniMax, Hugging Face, Gocoon, Local) | +| LLM | Multi-provider via [pi-ai](https://github.com/mariozechner/pi-ai) (16 providers: Anthropic, Codex, Grok Build, OpenAI, Google, xAI, Groq, OpenRouter, Moonshot, Mistral, Cerebras, ZAI, MiniMax, Hugging Face, Gocoon, Local) | | Telegram Userbot | [GramJS](https://gram.js.org/) Layer 223 fork (MTProto) | | Inline Bot | [Grammy](https://grammy.dev/) (Bot API, for deals) | | Blockchain | [TON SDK](https://github.com/ton-org/ton) (W5R1 wallet) | @@ -556,7 +557,7 @@ src/ │ └── loader.ts # 10 sections: soul + security + strategy + memory + context + ... ├── config/ # Configuration │ ├── schema.ts # Zod schemas + validation -│ ├── providers.ts # Multi-provider LLM registry (15 providers) +│ ├── providers.ts # Multi-provider LLM registry (16 providers) │ └── model-catalog.ts # Shared model catalog (70+ models across all providers) ├── webui/ # Optional web dashboard │ ├── server.ts # Hono server, auth middleware, static serving diff --git a/config.example.yaml b/config.example.yaml index 4ef10dac..db9c4831 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -3,7 +3,7 @@ # Or run: teleton setup agent: - provider: "anthropic" # anthropic | openai | google | xai | groq | openrouter + provider: "anthropic" # anthropic | codex | grok-build | openai | google | xai | groq | openrouter api_key: "YOUR_API_KEY" model: "claude-haiku-4-5-20251001" # Model ID (varies by provider) # utility_model: "claude-3-5-haiku-20241022" # Optional: cheap model for summarization diff --git a/docs/configuration.md b/docs/configuration.md index b59220a2..8921ab33 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -38,7 +38,7 @@ LLM provider and agentic loop configuration. | Key | Type | Default | Description | |-----|------|---------|-------------| -| `agent.provider` | `enum` | `"anthropic"` | LLM provider. One of: `anthropic`, `codex`, `openai`, `google`, `xai`, `groq`, `openrouter`, `moonshot`, `mistral`, `cerebras`, `zai`, `minimax`, `huggingface`, `gocoon`, `local`. | +| `agent.provider` | `enum` | `"anthropic"` | LLM provider. One of: `anthropic`, `codex`, `grok-build`, `openai`, `google`, `xai`, `groq`, `openrouter`, `moonshot`, `mistral`, `cerebras`, `zai`, `minimax`, `huggingface`, `gocoon`, `local`. | | `agent.api_key` | `string` | `""` | API key for the chosen provider. Can be overridden with `TELETON_API_KEY` env var. | | `agent.model` | `string` | `"claude-haiku-4-5-20251001"` | Primary model ID. Auto-detected from provider if not set (only for non-Anthropic providers). | | `agent.utility_model` | `string` | *auto-detected* | Cheap/fast model used for summarization and compaction. If omitted, the platform selects one based on the provider (e.g., `claude-haiku-4-5-20251001` for Anthropic, `gpt-4o-mini` for OpenAI). | @@ -85,6 +85,7 @@ When you change the `provider` and omit `model`, the platform auto-selects: |----------|--------------|----------------------| | `anthropic` | `claude-haiku-4-5-20251001` | `claude-haiku-4-5-20251001` | | `codex` | `gpt-5.5` | `gpt-5.1-codex-mini` | +| `grok-build` | `grok-build` | `grok-build` | | `openai` | `gpt-5.5` | `gpt-4o-mini` | | `google` | `gemini-2.5-flash` | `gemini-2.0-flash-lite` | | `xai` | `grok-3` | `grok-3-mini-fast` | @@ -99,6 +100,10 @@ When you change the `provider` and omit `model`, the platform auto-selects: | `gocoon` | `Qwen/Qwen3-32B` | `Qwen/Qwen3-32B` | | `local` | `auto` | `auto` | +`grok-build` reads the browser/OIDC session created by `grok login` from +`$GROK_HOME/auth.json` (default: `~/.grok/auth.json`) and connects directly to +the Grok Build CLI proxy. The session token is not stored in Teleton's config. + --- ## telegram diff --git a/docs/guide.md b/docs/guide.md index 11b7a3c2..c767101e 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -1,7 +1,7 @@ Deploy Your Own AI Agent on Telegram in 5 Minutes -Teleton is an autonomous AI agent that runs as a real Telegram user account. Not a bot. It thinks, calls tools, remembers everything, and integrates the TON blockchain natively. 135+ tools, 15 LLM providers, open source. +Teleton is an autonomous AI agent that runs as a real Telegram user account. Not a bot. It thinks, calls tools, remembers everything, and integrates the TON blockchain natively. 135+ tools, 16 LLM providers, open source. 3 steps. Let's go. diff --git a/src/agent/__tests__/client-grok-build.test.ts b/src/agent/__tests__/client-grok-build.test.ts new file mode 100644 index 00000000..3a939093 --- /dev/null +++ b/src/agent/__tests__/client-grok-build.test.ts @@ -0,0 +1,109 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { AgentConfigSchema } from "../../config/schema.js"; + +const mocks = vi.hoisted(() => ({ + complete: vi.fn(), + stream: vi.fn(), + getGrokBuildApiKey: vi.fn(() => "grok-session-token"), + refreshGrokBuildApiKey: vi.fn(), +})); + +vi.mock("@earendil-works/pi-ai/compat", async () => { + const actual = + await vi.importActual("@earendil-works/pi-ai"); + return { + ...actual, + complete: mocks.complete, + stream: mocks.stream, + }; +}); + +vi.mock("../../providers/grok-build-credentials.js", () => ({ + getGrokBuildApiKey: mocks.getGrokBuildApiKey, + refreshGrokBuildApiKey: mocks.refreshGrokBuildApiKey, + getGrokBuildCliVersion: vi.fn(() => "0.2.77"), +})); + +import { chatWithContext, streamWithContext } from "../client.js"; + +function assistantMessage(stopReason: "stop" | "error", errorMessage?: string) { + return { + role: "assistant" as const, + content: stopReason === "stop" ? [{ type: "text" as const, text: "ok" }] : [], + api: "openai-responses" as const, + provider: "xai" as const, + model: "grok-build", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason, + errorMessage, + timestamp: Date.now(), + }; +} + +const config = AgentConfigSchema.parse({ + provider: "grok-build", + model: "grok-build", + api_key: "", +}); + +describe("Grok Build client", () => { + beforeEach(() => { + mocks.complete.mockReset(); + mocks.stream.mockReset(); + mocks.getGrokBuildApiKey.mockReset(); + mocks.getGrokBuildApiKey.mockReturnValue("grok-session-token"); + mocks.refreshGrokBuildApiKey.mockReset(); + }); + + it("uses the CLI token without temperature or long cache retention", async () => { + mocks.complete.mockResolvedValue(assistantMessage("stop")); + + await chatWithContext(config, { context: { messages: [] } }); + + const [, context, options] = mocks.complete.mock.calls[0]; + expect(options).toMatchObject({ + apiKey: "grok-session-token", + cacheRetention: "none", + }); + expect(options).not.toHaveProperty("temperature"); + expect(options.onPayload({ model: "grok-build" })).toMatchObject({ + model: "grok-build", + parallel_tool_calls: false, + }); + expect(context.messages).toEqual([]); + }); + + it("refreshes a rejected streaming credential and completes the same turn", async () => { + const unauthorized = assistantMessage("error", "OpenAI API error (401): Unauthorized"); + mocks.stream.mockReturnValue({ + async *[Symbol.asyncIterator]() {}, + result: vi.fn().mockResolvedValue(unauthorized), + }); + mocks.refreshGrokBuildApiKey.mockResolvedValue("refreshed-session-token"); + mocks.complete.mockResolvedValue(assistantMessage("stop")); + + const streamed = streamWithContext(config, { context: { messages: [] } }); + for await (const _chunk of streamed.textStream) { + // The initial 401 produces no text. + } + const result = await streamed.result; + + expect(mocks.refreshGrokBuildApiKey).toHaveBeenCalledOnce(); + expect(mocks.complete).toHaveBeenCalledOnce(); + expect(mocks.complete.mock.calls[0][2]).toMatchObject({ + apiKey: "refreshed-session-token", + cacheRetention: "none", + }); + expect(mocks.complete.mock.calls[0][2].onPayload({})).toEqual({ + parallel_tool_calls: false, + }); + expect(result.text).toBe("ok"); + }); +}); diff --git a/src/agent/client.ts b/src/agent/client.ts index a075e2cb..43898640 100644 --- a/src/agent/client.ts +++ b/src/agent/client.ts @@ -13,6 +13,7 @@ import type { SupportedProvider } from "../config/providers.js"; import { sanitizeToolsForGemini } from "./schema-sanitizer.js"; import { createLogger } from "../utils/logger.js"; import { getCodexApiKey, refreshCodexApiKey } from "../providers/codex-credentials.js"; +import { getGrokBuildApiKey, refreshGrokBuildApiKey } from "../providers/grok-build-credentials.js"; import { getProviderModel } from "../providers/model-resolver.js"; // Model resolution + provider model registration live in the neutral providers/ @@ -36,6 +37,7 @@ function isUnauthorizedError(errorMessage?: string): boolean { /** Providers whose credentials can be refreshed once on a 401, then the call retried. */ const RETRY_401_PROVIDERS: { provider: string; refresh: () => Promise }[] = [ { provider: "codex", refresh: refreshCodexApiKey }, + { provider: "grok-build", refresh: refreshGrokBuildApiKey }, ]; /** Resolve the effective API key for a provider (local/gocoon need no real key) */ @@ -43,9 +45,29 @@ export function getEffectiveApiKey(provider: string, rawKey: string): string { if (provider === "local") return "local"; if (provider === "gocoon") return "gocoon"; if (provider === "codex") return getCodexApiKey(rawKey); + if (provider === "grok-build") return getGrokBuildApiKey(); return rawKey; } +function providerSupportsTemperature(provider: string): boolean { + return provider !== "codex" && provider !== "grok-build"; +} + +function getCacheRetention(provider: string): "none" | "long" { + return provider === "grok-build" ? "none" : "long"; +} + +function getProviderPayloadOptions(provider: SupportedProvider): Record { + if (provider !== "grok-build") return {}; + + return { + onPayload: (payload: unknown) => ({ + ...(payload && typeof payload === "object" ? payload : {}), + parallel_tool_calls: false, + }), + }; +} + export interface ChatOptions { systemPrompt?: string; context: Context; @@ -117,9 +139,10 @@ export async function chatWithContext( const completeOptions: Record = { apiKey: getEffectiveApiKey(provider, config.api_key), maxTokens: options.maxTokens ?? config.max_tokens, - ...(provider !== "codex" && { temperature }), + ...(providerSupportsTemperature(provider) && { temperature }), sessionId: options.sessionId, - cacheRetention: "long", + cacheRetention: getCacheRetention(provider), + ...getProviderPayloadOptions(provider), }; let response = await complete(model, context, completeOptions as ProviderStreamOptions); @@ -163,9 +186,10 @@ export function streamWithContext(config: AgentConfig, options: ChatOptions): St const streamOptions: Record = { apiKey: getEffectiveApiKey(provider, config.api_key), maxTokens: options.maxTokens ?? config.max_tokens, - ...(provider !== "codex" && { temperature }), + ...(providerSupportsTemperature(provider) && { temperature }), sessionId: options.sessionId, - cacheRetention: "long", + cacheRetention: getCacheRetention(provider), + ...getProviderPayloadOptions(provider), }; const eventStream = stream(model, context, streamOptions as ProviderStreamOptions); @@ -185,7 +209,19 @@ export function streamWithContext(config: AgentConfig, options: ChatOptions): St // Result promise: wait for the stream to complete and build ChatResponse const resultPromise = (async (): Promise => { - const response = await eventStream.result(); + let response = await eventStream.result(); + + // A streaming auth failure happens before text is emitted. Refresh the CLI + // credential and finish this turn through the one-shot path. + const retry401 = RETRY_401_PROVIDERS.find((e) => e.provider === provider); + if (retry401 && response.stopReason === "error" && isUnauthorizedError(response.errorMessage)) { + log.warn(`${provider} token rejected (401), refreshing credentials and retrying...`); + const refreshedKey = await retry401.refresh(); + if (refreshedKey) { + streamOptions.apiKey = refreshedKey; + response = await complete(model, context, streamOptions as ProviderStreamOptions); + } + } return finalizeResponse(response, context, options); })(); diff --git a/src/cli/commands/doctor.ts b/src/cli/commands/doctor.ts index 1e54e202..f7ec3924 100644 --- a/src/cli/commands/doctor.ts +++ b/src/cli/commands/doctor.ts @@ -11,6 +11,12 @@ import { import { getErrorMessage } from "../../utils/errors.js"; import { SUPPORTED_NODE_RANGE, isNodeVersionSupported } from "../../constants/runtime.js"; import { GREEN, YELLOW, RED } from "../prompts.js"; +import { getCodexApiKey, isCodexTokenValid } from "../../providers/codex-credentials.js"; +import { + getGrokBuildCliVersion, + getGrokBuildApiKey, + isGrokBuildTokenValid, +} from "../../providers/grok-build-credentials.js"; interface CheckResult { name: string; @@ -141,7 +147,7 @@ async function checkApiKey(workspaceDir: string): Promise { }; } - if (provider === "gocoon" || provider === "local") { + if (meta.credentialMode === "none") { return { name: `${meta.displayName}`, status: "ok", @@ -149,6 +155,36 @@ async function checkApiKey(workspaceDir: string): Promise { }; } + if (meta.credentialMode === "cli-auto") { + try { + if (provider === "codex") { + getCodexApiKey(); + return { + name: meta.displayName, + status: isCodexTokenValid() ? "ok" : "warn", + message: isCodexTokenValid() ? "CLI credentials detected" : "CLI token expired", + }; + } + + const cliVersion = getGrokBuildCliVersion(); + getGrokBuildApiKey(); + const tokenValid = isGrokBuildTokenValid(); + return { + name: meta.displayName, + status: tokenValid ? "ok" : "warn", + message: tokenValid + ? `Grok CLI ${cliVersion}; credentials detected` + : `Grok CLI ${cliVersion}; token expired (run grok login)`, + }; + } catch (error) { + return { + name: meta.displayName, + status: "error", + message: getErrorMessage(error), + }; + } + } + if (!apiKey) { return { name: `${meta.displayName} API key`, diff --git a/src/cli/commands/onboard.ts b/src/cli/commands/onboard.ts index c8dbdf42..e62ab5a9 100644 --- a/src/cli/commands/onboard.ts +++ b/src/cli/commands/onboard.ts @@ -49,6 +49,7 @@ import { import { getSupportedProviders, getProviderMetadata, + providerNeedsApiKey, validateApiKeyFormat, type SupportedProvider, } from "../../config/providers.js"; @@ -57,6 +58,12 @@ import { fetchWithTimeout } from "../../utils/fetch.js"; import { getErrorMessage } from "../../utils/errors.js"; import ora from "ora"; import { getCodexApiKey, isCodexTokenValid } from "../../providers/codex-credentials.js"; +import { + assertGrokBuildReady, + getGrokBuildCliVersion, + getGrokBuildApiKey, + isGrokBuildTokenValid, +} from "../../providers/grok-build-credentials.js"; export interface OnboardOptions { workspace?: string; @@ -114,7 +121,7 @@ async function handleAutoDetectedProvider(opts: { statusExpiredMsg: string; noteFooterMsg: string; notFoundHint: string; - fallbackKeyLabel: string; + fallbackKeyLabel?: string; prompter: ReturnType; }): Promise<{ apiKey: string; stepValue: string }> { let apiKey = ""; @@ -141,6 +148,9 @@ async function handleAutoDetectedProvider(opts: { } catch (error) { if (error instanceof CancelledError) throw error; opts.prompter.warn(opts.notFoundHint); + if (!opts.fallbackKeyLabel) { + throw new CancelledError(); + } const useFallback = await confirm({ message: "Enter an API key manually instead?", default: true, @@ -695,6 +705,24 @@ async function stepProvider( }); apiKey = result.apiKey; STEPS[1].value = result.stepValue; + } else if (selectedProvider === "grok-build") { + const grokCliVersion = getGrokBuildCliVersion(); + const result = await handleAutoDetectedProvider({ + getKey: getGrokBuildApiKey, + isValid: isGrokBuildTokenValid, + displayName: providerMeta.displayName, + noteTitle: "Grok Build", + detectedFromMsg: "Credentials auto-detected from Grok CLI", + statusExpiredMsg: "expired (run grok login to re-authenticate)", + noteFooterMsg: + `Grok CLI ${grokCliVersion}\n` + + "Token read from $GROK_HOME/auth.json (default: ~/.grok/auth.json)", + notFoundHint: + "Grok Build credentials not found. Make sure Grok CLI is installed and authenticated.", + prompter, + }); + apiKey = result.apiKey; + STEPS[1].value = result.stepValue; } else { // Standard providers — API key required const envApiKey = process.env.TELETON_API_KEY; @@ -1418,7 +1446,7 @@ async function runNonInteractiveOnboarding( ): Promise { const selectedProvider = options.provider || "anthropic"; const nonInteractiveMode = options.mode || "user"; - const needsApiKey = selectedProvider !== "gocoon" && selectedProvider !== "local"; + const needsApiKey = providerNeedsApiKey(selectedProvider); if (nonInteractiveMode === "bot") { if (!options.botToken) { prompter.error("Non-interactive bot mode requires: --bot-token"); @@ -1446,6 +1474,14 @@ async function runNonInteractiveOnboarding( prompter.error("Non-interactive mode requires --base-url for local provider"); process.exit(1); } + if (selectedProvider === "grok-build") { + try { + assertGrokBuildReady(); + } catch (error: unknown) { + prompter.error(getErrorMessage(error)); + process.exit(1); + } + } const workspace = await ensureWorkspace({ workspaceDir: options.workspace, diff --git a/src/config/__tests__/configurable-keys.test.ts b/src/config/__tests__/configurable-keys.test.ts index 345fa28b..7e77c3b2 100644 --- a/src/config/__tests__/configurable-keys.test.ts +++ b/src/config/__tests__/configurable-keys.test.ts @@ -218,8 +218,8 @@ describe("existing keys unchanged", () => { expect(meta.validate("long-enough-key-here")).toBeUndefined(); }); - it("agent.provider still has all 15 options", () => { + it("agent.provider exposes all 16 options", () => { const meta = CONFIGURABLE_KEYS["agent.provider"]; - expect(meta.options).toHaveLength(15); + expect(meta.options).toHaveLength(16); }); }); diff --git a/src/config/model-catalog.ts b/src/config/model-catalog.ts index e83508c5..04808ada 100644 --- a/src/config/model-catalog.ts +++ b/src/config/model-catalog.ts @@ -95,6 +95,13 @@ export const MODEL_OPTIONS: Record = { description: "Coding, preview, free", }, ], + "grok-build": [ + { + value: "grok-build", + name: "Grok Build", + description: "Coding model via your Grok CLI subscription, 500K ctx", + }, + ], google: [ { value: "gemini-3.1-pro-preview", diff --git a/src/config/providers.ts b/src/config/providers.ts index c494b824..cba69216 100644 --- a/src/config/providers.ts +++ b/src/config/providers.ts @@ -1,6 +1,7 @@ export type SupportedProvider = | "anthropic" | "codex" + | "grok-build" | "openai" | "google" | "xai" @@ -18,6 +19,7 @@ export type SupportedProvider = export interface ProviderMetadata { id: SupportedProvider; displayName: string; + credentialMode: "api-key" | "cli-auto" | "none"; envVar: string; keyPrefix: string | null; keyHint: string; @@ -32,6 +34,7 @@ const PROVIDER_REGISTRY: Record = { codex: { id: "codex", displayName: "Codex (Auto)", + credentialMode: "cli-auto", envVar: "OPENAI_API_KEY", keyPrefix: null, keyHint: "Auto-detected from Codex CLI", @@ -41,9 +44,23 @@ const PROVIDER_REGISTRY: Record = { toolLimit: 128, piAiProvider: "openai-codex", }, + "grok-build": { + id: "grok-build", + displayName: "Grok Build (Auto)", + credentialMode: "cli-auto", + envVar: "", + keyPrefix: null, + keyHint: "Auto-detected from Grok CLI", + consoleUrl: "https://x.ai/cli", + defaultModel: "grok-build", + utilityModel: "grok-build", + toolLimit: 128, + piAiProvider: "grok-build", + }, zai: { id: "zai", displayName: "ZAI (Zhipu)", + credentialMode: "api-key", envVar: "ZAI_API_KEY", keyPrefix: null, keyHint: "...", @@ -56,6 +73,7 @@ const PROVIDER_REGISTRY: Record = { anthropic: { id: "anthropic", displayName: "Anthropic (Claude)", + credentialMode: "api-key", envVar: "ANTHROPIC_API_KEY", keyPrefix: "sk-ant-", keyHint: "sk-ant-api03-...", @@ -68,6 +86,7 @@ const PROVIDER_REGISTRY: Record = { openai: { id: "openai", displayName: "OpenAI (GPT-5.5)", + credentialMode: "api-key", envVar: "OPENAI_API_KEY", keyPrefix: "sk-", keyHint: "sk-proj-...", @@ -80,6 +99,7 @@ const PROVIDER_REGISTRY: Record = { google: { id: "google", displayName: "Google (Gemini)", + credentialMode: "api-key", envVar: "GOOGLE_API_KEY", keyPrefix: null, keyHint: "AIza...", @@ -92,6 +112,7 @@ const PROVIDER_REGISTRY: Record = { xai: { id: "xai", displayName: "xAI (Grok)", + credentialMode: "api-key", envVar: "XAI_API_KEY", keyPrefix: "xai-", keyHint: "xai-...", @@ -104,6 +125,7 @@ const PROVIDER_REGISTRY: Record = { groq: { id: "groq", displayName: "Groq", + credentialMode: "api-key", envVar: "GROQ_API_KEY", keyPrefix: "gsk_", keyHint: "gsk_...", @@ -116,6 +138,7 @@ const PROVIDER_REGISTRY: Record = { openrouter: { id: "openrouter", displayName: "OpenRouter", + credentialMode: "api-key", envVar: "OPENROUTER_API_KEY", keyPrefix: "sk-or-", keyHint: "sk-or-v1-...", @@ -128,6 +151,7 @@ const PROVIDER_REGISTRY: Record = { moonshot: { id: "moonshot", displayName: "Moonshot (Kimi)", + credentialMode: "api-key", envVar: "MOONSHOT_API_KEY", keyPrefix: "sk-", keyHint: "sk-...", @@ -140,6 +164,7 @@ const PROVIDER_REGISTRY: Record = { mistral: { id: "mistral", displayName: "Mistral AI", + credentialMode: "api-key", envVar: "MISTRAL_API_KEY", keyPrefix: null, keyHint: "...", @@ -152,6 +177,7 @@ const PROVIDER_REGISTRY: Record = { cerebras: { id: "cerebras", displayName: "Cerebras", + credentialMode: "api-key", envVar: "CEREBRAS_API_KEY", keyPrefix: "csk-", keyHint: "csk-...", @@ -164,6 +190,7 @@ const PROVIDER_REGISTRY: Record = { minimax: { id: "minimax", displayName: "MiniMax", + credentialMode: "api-key", envVar: "MINIMAX_API_KEY", keyPrefix: null, keyHint: "Save your key — shown only once!", @@ -176,6 +203,7 @@ const PROVIDER_REGISTRY: Record = { huggingface: { id: "huggingface", displayName: "HuggingFace", + credentialMode: "api-key", envVar: "HF_TOKEN", keyPrefix: "hf_", keyHint: "hf_...", @@ -188,6 +216,7 @@ const PROVIDER_REGISTRY: Record = { gocoon: { id: "gocoon", displayName: "Gocoon (Decentralized, TON)", + credentialMode: "none", envVar: "", keyPrefix: null, keyHint: "No API key, pays in TON", @@ -200,6 +229,7 @@ const PROVIDER_REGISTRY: Record = { local: { id: "local", displayName: "Local (Ollama, vLLM, LM Studio...)", + credentialMode: "none", envVar: "", keyPrefix: null, keyHint: "No API key needed", @@ -223,6 +253,10 @@ export function getSupportedProviders(): ProviderMetadata[] { return Object.values(PROVIDER_REGISTRY); } +export function providerNeedsApiKey(provider: SupportedProvider): boolean { + return getProviderMetadata(provider).credentialMode === "api-key"; +} + /** * Provider ids as a non-empty tuple, derived from the single registry so the * Zod `agent.provider` enum stays in sync with PROVIDER_REGISTRY (no 3rd copy). @@ -235,7 +269,7 @@ export const SUPPORTED_PROVIDER_IDS = Object.keys(PROVIDER_REGISTRY) as [ export function validateApiKeyFormat(provider: SupportedProvider, key: string): string | undefined { const meta = PROVIDER_REGISTRY[provider]; if (!meta) return `Unknown provider: ${provider}`; - if (provider === "gocoon" || provider === "local" || provider === "codex") return undefined; + if (!providerNeedsApiKey(provider)) return undefined; if (!key || key.trim().length === 0) return "API key is required"; if (meta.keyPrefix && !key.startsWith(meta.keyPrefix)) { return `Invalid format (should start with ${meta.keyPrefix})`; diff --git a/src/providers/__tests__/grok-build-credentials.test.ts b/src/providers/__tests__/grok-build-credentials.test.ts new file mode 100644 index 00000000..1dbcd3ea --- /dev/null +++ b/src/providers/__tests__/grok-build-credentials.test.ts @@ -0,0 +1,176 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; + +function jwtWithExpiry(expiresAtMs: number): string { + const encode = (value: object) => Buffer.from(JSON.stringify(value)).toString("base64url"); + return `${encode({ alg: "none" })}.${encode({ exp: Math.floor(expiresAtMs / 1000) })}.signature`; +} + +describe("Grok Build credentials", () => { + let grokHome: string; + + beforeEach(() => { + grokHome = mkdtempSync(join(tmpdir(), "teleton-grok-auth-")); + process.env.GROK_HOME = grokHome; + vi.resetModules(); + }); + + afterEach(() => { + vi.doUnmock("child_process"); + vi.resetModules(); + delete process.env.GROK_HOME; + delete process.env.GROK_CLIENT_VERSION; + rmSync(grokHome, { recursive: true, force: true }); + }); + + function writeAuth(auth: object): void { + writeFileSync(join(grokHome, "auth.json"), JSON.stringify(auth), { mode: 0o600 }); + } + + it("reads the current dynamic auth.x.ai scope", async () => { + const token = jwtWithExpiry(Date.now() + 60 * 60 * 1000); + writeAuth({ + "https://auth.x.ai::client-id": { + key: token, + auth_mode: "oidc", + expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + }, + }); + + const credentials = await import("../grok-build-credentials.js"); + + expect(credentials.getGrokBuildApiKey()).toBe(token); + expect(credentials.isGrokBuildTokenValid()).toBe(true); + }); + + it("supports the legacy accounts.x.ai sign-in scope", async () => { + const token = jwtWithExpiry(Date.now() + 60 * 60 * 1000); + writeAuth({ + "https://accounts.x.ai/sign-in": { + key: token, + expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + }, + }); + + const credentials = await import("../grok-build-credentials.js"); + + expect(credentials.getGrokBuildApiKey()).toBe(token); + }); + + it("ignores credentials outside the xAI auth hosts", async () => { + const validToken = jwtWithExpiry(Date.now() + 60 * 60 * 1000); + const maliciousToken = jwtWithExpiry(Date.now() + 24 * 60 * 60 * 1000); + writeAuth({ + "https://auth.x.ai::client-id": { + key: validToken, + expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + }, + "https://auth.x.ai.evil.example::client-id": { + key: maliciousToken, + expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), + }, + }); + + const credentials = await import("../grok-build-credentials.js"); + + expect(credentials.getGrokBuildApiKey()).toBe(validToken); + }); + + it("marks an expired token invalid while returning it for the 401 refresh path", async () => { + const token = jwtWithExpiry(Date.now() - 60 * 1000); + writeAuth({ + "https://auth.x.ai::client-id": { + key: token, + expires_at: new Date(Date.now() - 60 * 1000).toISOString(), + }, + }); + + const credentials = await import("../grok-build-credentials.js"); + + expect(credentials.getGrokBuildApiKey()).toBe(token); + expect(credentials.isGrokBuildTokenValid()).toBe(false); + }); + + it("fails with an actionable error when no CLI session exists", async () => { + const credentials = await import("../grok-build-credentials.js"); + + expect(() => credentials.getGrokBuildApiKey()).toThrow( + "No Grok Build credentials found. Run 'grok login' to authenticate." + ); + }); + + it("delegates refresh to the Grok CLI and reloads the updated token", async () => { + const expiredToken = jwtWithExpiry(Date.now() - 60 * 1000); + const refreshedToken = jwtWithExpiry(Date.now() + 60 * 60 * 1000); + writeAuth({ + "https://auth.x.ai::client-id": { + key: expiredToken, + expires_at: new Date(Date.now() - 60 * 1000).toISOString(), + }, + }); + + vi.doMock("child_process", () => ({ + execFileSync: vi.fn(), + execFile: ( + command: string, + args: string[], + _options: object, + callback: (error: null, stdout: string, stderr: string) => void + ) => { + expect(command).toBe("grok"); + expect(args).toEqual(["models"]); + writeAuth({ + "https://auth.x.ai::client-id": { + key: refreshedToken, + expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + }, + }); + callback(null, "", ""); + }, + })); + vi.resetModules(); + + const credentials = await import("../grok-build-credentials.js"); + + await expect(credentials.refreshGrokBuildApiKey()).resolves.toBe(refreshedToken); + expect(credentials.isGrokBuildTokenValid()).toBe(true); + }); + + it("rejects a Grok CLI version older than the supported protocol", async () => { + process.env.GROK_CLIENT_VERSION = "0.2.76"; + const credentials = await import("../grok-build-credentials.js"); + + expect(() => credentials.getGrokBuildCliVersion()).toThrow( + "Grok CLI 0.2.76 is unsupported; install 0.2.77 or newer" + ); + }); + + it("accepts the minimum supported Grok CLI version", async () => { + process.env.GROK_CLIENT_VERSION = "0.2.77"; + const credentials = await import("../grok-build-credentials.js"); + + expect(credentials.getGrokBuildCliVersion()).toBe("0.2.77"); + }); + + it("rejects an outdated version discovered from the Grok installation", async () => { + writeFileSync(join(grokHome, "version.json"), JSON.stringify({ version: "0.2.10" })); + const credentials = await import("../grok-build-credentials.js"); + + expect(() => credentials.getGrokBuildCliVersion()).toThrow( + "Grok CLI 0.2.10 is unsupported; install 0.2.77 or newer" + ); + }); + + it("falls back to the installed Grok binary when version metadata is absent", async () => { + vi.doMock("child_process", () => ({ + execFile: vi.fn(), + execFileSync: vi.fn(() => "grok 0.2.93 (f00f96316d4b) [stable]\n"), + })); + vi.resetModules(); + const credentials = await import("../grok-build-credentials.js"); + + expect(credentials.getGrokBuildCliVersion()).toBe("0.2.93"); + }); +}); diff --git a/src/providers/__tests__/grok-build-model.test.ts b/src/providers/__tests__/grok-build-model.test.ts new file mode 100644 index 00000000..dd1e5308 --- /dev/null +++ b/src/providers/__tests__/grok-build-model.test.ts @@ -0,0 +1,67 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { complete } from "@earendil-works/pi-ai/compat"; +import { getProviderModel } from "../model-resolver.js"; + +describe("Grok Build model", () => { + beforeEach(() => { + process.env.GROK_CLIENT_VERSION = "0.2.77"; + }); + + afterEach(() => { + delete process.env.GROK_CLIENT_VERSION; + vi.unstubAllGlobals(); + }); + + it("uses the CLI proxy Responses endpoint and required headers", () => { + const model = getProviderModel("grok-build", "grok-build"); + + expect(model.api).toBe("openai-responses"); + expect(model.baseUrl).toBe("https://cli-chat-proxy.grok.com/v1"); + expect(model.headers).toMatchObject({ + "X-XAI-Token-Auth": "xai-grok-cli", + "x-grok-model-override": "grok-build", + "x-grok-client-version": "0.2.77", + }); + expect(model.reasoning).toBe(false); + expect(model.contextWindow).toBe(500_000); + }); + + it("sends the CLI session headers to the Responses endpoint", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ error: { message: "test stop" } }), { + status: 400, + headers: { "content-type": "application/json" }, + }) + ); + vi.stubGlobal("fetch", fetchMock); + + const model = getProviderModel("grok-build", "grok-build"); + await complete( + model, + { messages: [{ role: "user", content: "hello", timestamp: 1 }] }, + { + apiKey: "local-session-token", + cacheRetention: "none", + } + ); + + expect(fetchMock).toHaveBeenCalledOnce(); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + const headers = new Headers(init.headers); + expect(url).toBe("https://cli-chat-proxy.grok.com/v1/responses"); + expect(headers.get("authorization")).toBe("Bearer local-session-token"); + expect(headers.get("x-xai-token-auth")).toBe("xai-grok-cli"); + expect(headers.get("x-grok-model-override")).toBe("grok-build"); + expect(headers.get("x-grok-client-version")).toBe("0.2.77"); + + const payload = JSON.parse(String(init.body)) as Record; + expect(payload).toMatchObject({ + model: "grok-build", + stream: true, + store: false, + }); + expect(payload).not.toHaveProperty("temperature"); + expect(payload).not.toHaveProperty("reasoning"); + expect(payload).not.toHaveProperty("prompt_cache_key"); + }); +}); diff --git a/src/providers/grok-build-credentials.ts b/src/providers/grok-build-credentials.ts new file mode 100644 index 00000000..798e2cf4 --- /dev/null +++ b/src/providers/grok-build-credentials.ts @@ -0,0 +1,219 @@ +/** + * Grok Build CLI credential reader. + * + * Reads the browser/OIDC session maintained by the Grok CLI from: + * $GROK_HOME/auth.json (default: ~/.grok/auth.json) + * + * The session token is never persisted in Teleton configuration. On a 401, + * `grok models` is invoked to let the official CLI perform its own silent + * OAuth refresh, then the credential file is read again. + */ + +import { execFile, execFileSync } from "child_process"; +import { existsSync, readFileSync } from "fs"; +import { homedir } from "os"; +import { join } from "path"; +import { promisify } from "util"; +import { createLogger } from "../utils/logger.js"; +import { extractJwtExpiry } from "../utils/jwt.js"; +import { createTokenCache } from "./token-cache.js"; + +const log = createLogger("GrokBuildCreds"); +const execFileAsync = promisify(execFile); +const tokenCache = createTokenCache(); +const REFRESH_SKEW_MS = 5 * 60 * 1000; +export const MINIMUM_GROK_BUILD_CLI_VERSION = "0.2.77"; + +interface GrokAuthEntry { + key?: string; + expires_at?: string; + auth_mode?: string; +} + +type GrokAuthFile = Record; +const GROK_VERSION_RE = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/; + +function parseVersion(version: string): [number, number, number] { + const [major, minor, patch] = version.split(/[+-]/, 1)[0].split(".").map(Number); + return [major, minor, patch]; +} + +function isVersionAtLeast(version: string, minimum: string): boolean { + const current = parseVersion(version); + const required = parseVersion(minimum); + for (let index = 0; index < current.length; index += 1) { + if (current[index] !== required[index]) return current[index] > required[index]; + } + return true; +} + +function validateCliVersion(version: string): string { + if (!GROK_VERSION_RE.test(version)) { + throw new Error(`Invalid Grok CLI version: ${version}`); + } + if (!isVersionAtLeast(version, MINIMUM_GROK_BUILD_CLI_VERSION)) { + throw new Error( + `Grok CLI ${version} is unsupported; install ${MINIMUM_GROK_BUILD_CLI_VERSION} or newer` + ); + } + return version; +} + +function getGrokHome(): string { + return process.env.GROK_HOME || join(homedir(), ".grok"); +} + +function getAuthFilePath(): string { + return join(getGrokHome(), "auth.json"); +} + +export function getGrokBuildCliVersion(): string { + const override = process.env.GROK_CLIENT_VERSION?.trim(); + if (override) return validateCliVersion(override); + + const candidates: Array<{ path: string; key: string }> = [ + { path: join(getGrokHome(), "version.json"), key: "version" }, + { path: join(getGrokHome(), "models_cache.json"), key: "grok_version" }, + ]; + + for (const candidate of candidates) { + if (!existsSync(candidate.path)) continue; + let parsed: Record; + try { + parsed = JSON.parse(readFileSync(candidate.path, "utf-8")) as Record; + } catch { + continue; + } + const version = parsed[candidate.key]; + if (typeof version === "string" && GROK_VERSION_RE.test(version)) { + return validateCliVersion(version); + } + } + + let output = ""; + try { + output = execFileSync(process.env.GROK_BIN || "grok", ["--version"], { + encoding: "utf8", + timeout: 3_000, + env: { ...process.env, GROK_HOME: getGrokHome() }, + }); + } catch { + // The actionable error below covers a missing or unusable CLI. + } + const match = /\bgrok\s+(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)/i.exec(output); + if (match) return validateCliVersion(match[1]); + + throw new Error( + "Could not detect the Grok CLI version. Run 'grok update' or set GROK_CLIENT_VERSION." + ); +} + +function isGrokAuthScope(scope: string): boolean { + const issuer = scope.split("::", 1)[0]; + try { + const url = new URL(issuer); + return ( + url.protocol === "https:" && + (url.hostname === "auth.x.ai" || url.hostname === "accounts.x.ai") + ); + } catch { + return false; + } +} + +function readAuthFile(): GrokAuthFile | null { + const filePath = getAuthFilePath(); + if (!existsSync(filePath)) return null; + + try { + return JSON.parse(readFileSync(filePath, "utf-8")) as GrokAuthFile; + } catch (error) { + log.warn({ err: error, path: filePath }, "Failed to parse Grok auth file"); + return null; + } +} + +function extractToken(auth: GrokAuthFile): { token: string; expiresAt: number } | null { + const candidates = Object.entries(auth) + .filter(([scope, value]) => isGrokAuthScope(scope) && value && typeof value === "object") + .map(([, value]) => value as GrokAuthEntry) + .filter((entry) => typeof entry.key === "string" && entry.key.length > 0) + .map((entry) => { + const parsedExpiry = entry.expires_at ? Date.parse(entry.expires_at) : Number.NaN; + const jwtExpiry = extractJwtExpiry(entry.key as string); + return { + token: entry.key as string, + expiresAt: Number.isFinite(parsedExpiry) ? parsedExpiry : jwtExpiry, + }; + }) + .filter((entry) => entry.expiresAt > 0) + .sort((a, b) => b.expiresAt - a.expiresAt); + + return candidates[0] ?? null; +} + +/** + * Resolve the current Grok Build session token. + * + * Expired tokens are returned once so the provider can receive a 401 and use + * the standard refresh-and-retry path. Valid tokens are cached with the same + * five-minute early invalidation window used by the Grok CLI. + */ +export function getGrokBuildApiKey(): string { + if (tokenCache.isValid()) { + return tokenCache.get() as string; + } + + const auth = readAuthFile(); + const extracted = auth ? extractToken(auth) : null; + if (extracted) { + tokenCache.set(extracted.token, extracted.expiresAt - REFRESH_SKEW_MS); + return extracted.token; + } + + throw new Error("No Grok Build credentials found. Run 'grok login' to authenticate."); +} + +/** + * Ask the official Grok CLI to silently refresh its OIDC session, then reload + * the resulting token from disk. No inference request is made by this command. + */ +export async function refreshGrokBuildApiKey(): Promise { + tokenCache.reset(); + + try { + await execFileAsync(process.env.GROK_BIN || "grok", ["models"], { + cwd: getGrokHome(), + env: { ...process.env, GROK_HOME: getGrokHome() }, + timeout: 30_000, + }); + } catch (error) { + log.warn({ err: error }, "Grok CLI credential refresh failed"); + return null; + } + + const auth = readAuthFile(); + const extracted = auth ? extractToken(auth) : null; + if (!extracted || extracted.expiresAt <= Date.now()) { + log.warn("Grok CLI refresh completed without a valid session token"); + return null; + } + + tokenCache.set(extracted.token, extracted.expiresAt - REFRESH_SKEW_MS); + log.info("Grok Build credentials refreshed by Grok CLI"); + return extracted.token; +} + +export function isGrokBuildTokenValid(): boolean { + return tokenCache.isValid(); +} + +/** Validate the complete local Grok Build runtime before persisting a setup. */ +export function assertGrokBuildReady(): string { + const version = getGrokBuildCliVersion(); + getGrokBuildApiKey(); + if (!isGrokBuildTokenValid()) { + throw new Error("Grok Build CLI session is expired. Run 'grok login' to re-authenticate."); + } + return version; +} diff --git a/src/providers/model-resolver.ts b/src/providers/model-resolver.ts index af503d33..f52fa779 100644 --- a/src/providers/model-resolver.ts +++ b/src/providers/model-resolver.ts @@ -2,6 +2,7 @@ import { getModel, type Model, type Api } from "@earendil-works/pi-ai/compat"; import { getProviderMetadata, type SupportedProvider } from "../config/providers.js"; import { createLogger } from "../utils/logger.js"; import { fetchWithTimeout } from "../utils/fetch.js"; +import { getGrokBuildCliVersion } from "./grok-build-credentials.js"; const log = createLogger("LLM"); @@ -9,6 +10,32 @@ const modelCache = new Map>(); const GOCOON_MODELS: Record> = {}; +const GROK_BUILD_MODEL_ID = "grok-build"; + +function createGrokBuildModel(): Model<"openai-responses"> { + return { + id: GROK_BUILD_MODEL_ID, + name: "Grok Build", + api: "openai-responses", + provider: "xai", + baseUrl: "https://cli-chat-proxy.grok.com/v1", + headers: { + "X-XAI-Token-Auth": "xai-grok-cli", + "x-grok-model-override": GROK_BUILD_MODEL_ID, + "x-grok-client-version": getGrokBuildCliVersion(), + }, + reasoning: false, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 500_000, + maxTokens: 128_000, + compat: { + sendSessionIdHeader: false, + supportsLongCacheRetention: false, + }, + }; +} + /** Register models discovered from a running gocoon-runner (native OpenAI-compatible API). */ export async function registerGocoonModels(httpPort: number): Promise { try { @@ -118,6 +145,15 @@ export function getProviderModel(provider: SupportedProvider, modelId: string): const meta = getProviderMetadata(provider); + if (meta.piAiProvider === "grok-build") { + const grokBuildModel = createGrokBuildModel(); + if (modelId !== grokBuildModel.id) { + log.warn(`Grok Build model "${modelId}" not found, using "${grokBuildModel.id}"`); + } + modelCache.set(cacheKey, grokBuildModel); + return grokBuildModel; + } + if (meta.piAiProvider === "gocoon") { let model = GOCOON_MODELS[modelId]; if (!model) { diff --git a/src/webui/__tests__/setup-routes.test.ts b/src/webui/__tests__/setup-routes.test.ts index 42f385a9..efbb093d 100644 --- a/src/webui/__tests__/setup-routes.test.ts +++ b/src/webui/__tests__/setup-routes.test.ts @@ -32,6 +32,24 @@ const mockAuthManager = { cancelSession: vi.fn(), }; +const mockGrokCredentials = vi.hoisted(() => ({ + getApiKey: vi.fn(() => "grok-session-token"), + isValid: vi.fn(() => true), + getVersion: vi.fn(() => "0.2.93"), +})); + +vi.mock("../../providers/grok-build-credentials.js", () => ({ + getGrokBuildApiKey: mockGrokCredentials.getApiKey, + isGrokBuildTokenValid: mockGrokCredentials.isValid, + getGrokBuildCliVersion: mockGrokCredentials.getVersion, + assertGrokBuildReady: () => { + const version = mockGrokCredentials.getVersion(); + mockGrokCredentials.getApiKey(); + if (!mockGrokCredentials.isValid()) throw new Error("Grok Build CLI session is expired"); + return version; + }, +})); + vi.mock("../setup-auth.js", () => ({ TelegramAuthManager: class { sendCode = mockAuthManager.sendCode; @@ -51,15 +69,27 @@ vi.mock("../../config/providers.js", () => ({ { id: "anthropic", displayName: "Anthropic (Claude)", + credentialMode: "api-key", defaultModel: "claude-haiku-4-5-20251001", utilityModel: "claude-haiku-4-5-20251001", toolLimit: null, keyPrefix: "sk-ant-", consoleUrl: "https://console.anthropic.com/", }, + { + id: "grok-build", + displayName: "Grok Build (Auto)", + credentialMode: "cli-auto", + defaultModel: "grok-build", + utilityModel: "grok-build", + toolLimit: 128, + keyPrefix: null, + consoleUrl: "https://x.ai/cli", + }, { id: "gocoon", displayName: "Gocoon", + credentialMode: "none", defaultModel: "auto", utilityModel: "auto", toolLimit: null, @@ -175,15 +205,27 @@ describe("Setup API Routes", () => { { id: "anthropic", displayName: "Anthropic (Claude)", + credentialMode: "api-key", defaultModel: "claude-haiku-4-5-20251001", utilityModel: "claude-haiku-4-5-20251001", toolLimit: null, keyPrefix: "sk-ant-", consoleUrl: "https://console.anthropic.com/", }, + { + id: "grok-build", + displayName: "Grok Build (Auto)", + credentialMode: "cli-auto", + defaultModel: "grok-build", + utilityModel: "grok-build", + toolLimit: 128, + keyPrefix: null, + consoleUrl: "https://x.ai/cli", + }, { id: "gocoon", displayName: "Gocoon", + credentialMode: "none", defaultModel: "auto", utilityModel: "auto", toolLimit: null, @@ -286,11 +328,14 @@ describe("Setup API Routes", () => { expect(res.status).toBe(200); const data = await res.json(); expect(data.success).toBe(true); - expect(data.data).toHaveLength(2); + expect(data.data).toHaveLength(3); expect(data.data[0].id).toBe("anthropic"); expect(data.data[0].requiresApiKey).toBe(true); - expect(data.data[1].id).toBe("gocoon"); + expect(data.data[1].id).toBe("grok-build"); expect(data.data[1].requiresApiKey).toBe(false); + expect(data.data[1].credentialMode).toBe("cli-auto"); + expect(data.data[2].id).toBe("gocoon"); + expect(data.data[2].requiresApiKey).toBe(false); }); }); @@ -941,5 +986,22 @@ describe("Setup API Routes", () => { const writeCall = (writeFileSync as Mock).mock.calls[0]; expect(writeCall[2]).toEqual({ encoding: "utf-8", mode: 0o600 }); }); + + it("rejects Grok Build when the local CLI session is unavailable", async () => { + mockGrokCredentials.getApiKey.mockImplementationOnce(() => { + throw new Error("No Grok Build credentials found. Run 'grok login' to authenticate."); + }); + const input = { + ...validInput, + agent: { ...validInput.agent, provider: "grok-build", api_key: "", model: "grok-build" }, + }; + + const res = await post(app, "/config/save", input); + + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toContain("grok login"); + expect(writeFileSync).not.toHaveBeenCalled(); + }); }); }); diff --git a/src/webui/routes/config.ts b/src/webui/routes/config.ts index ac17ba90..21b9eb68 100644 --- a/src/webui/routes/config.ts +++ b/src/webui/routes/config.ts @@ -335,8 +335,7 @@ export function createConfigRoutes(deps: WebUIServerDeps) { const provider = c.req.param("provider"); try { const meta = getProviderMetadata(provider as SupportedProvider); - // codex authenticates via the Codex CLI (~/.codex/auth.json), no key to paste. - const needsKey = provider !== "gocoon" && provider !== "local" && provider !== "codex"; + const needsKey = meta.credentialMode === "api-key"; return c.json({ success: true, data: { diff --git a/src/webui/routes/setup.ts b/src/webui/routes/setup.ts index ed2d41d5..8d75d907 100644 --- a/src/webui/routes/setup.ts +++ b/src/webui/routes/setup.ts @@ -31,6 +31,7 @@ import { TELEGRAM_MAX_MESSAGE_LENGTH } from "../../constants/limits.js"; import { TelegramAuthManager } from "../setup-auth.js"; import { createLogger } from "../../utils/logger.js"; import { getErrorMessage } from "../../utils/errors.js"; +import { assertGrokBuildReady } from "../../providers/grok-build-credentials.js"; const log = createLogger("Setup"); @@ -92,7 +93,8 @@ export function createSetupRoutes(options?: { keyHash?: string }): Hono { toolLimit: p.toolLimit, keyPrefix: p.keyPrefix, consoleUrl: p.consoleUrl, - requiresApiKey: p.id !== "gocoon" && p.id !== "local", + credentialMode: p.credentialMode, + requiresApiKey: p.credentialMode === "api-key", requiresBaseUrl: p.id === "local", })); return c.json({ success: true, data: providers }); @@ -449,6 +451,7 @@ export function createSetupRoutes(options?: { keyHash?: string }): Hono { app.post("/config/save", async (c) => { try { const input = await c.req.json(); + if (input?.agent?.provider === "grok-build") assertGrokBuildReady(); const workspace = await ensureWorkspace({ ensureTemplates: true }); // Resolve provider default model (same as CLI) diff --git a/web/src/components/ProviderControl.tsx b/web/src/components/ProviderControl.tsx index 3925809a..75c91d42 100644 --- a/web/src/components/ProviderControl.tsx +++ b/web/src/components/ProviderControl.tsx @@ -1,7 +1,7 @@ import type { ProviderMeta } from '../hooks/useConfigState'; -export const PROVIDER_OPTIONS = ['anthropic', 'openai', 'codex', 'google', 'xai', 'groq', 'openrouter', 'moonshot', 'mistral', 'cerebras', 'zai', 'minimax', 'huggingface', 'gocoon', 'local']; -export const PROVIDER_LABELS = ['Anthropic', 'OpenAI', 'Codex (Auto)', 'Google', 'xAI', 'Groq', 'OpenRouter', 'Moonshot', 'Mistral', 'Cerebras', 'ZAI (Zhipu)', 'MiniMax', 'HuggingFace', 'Gocoon', 'Local']; +export const PROVIDER_OPTIONS = ['anthropic', 'openai', 'codex', 'grok-build', 'google', 'xai', 'groq', 'openrouter', 'moonshot', 'mistral', 'cerebras', 'zai', 'minimax', 'huggingface', 'gocoon', 'local']; +export const PROVIDER_LABELS = ['Anthropic', 'OpenAI', 'Codex (Auto)', 'Grok Build (Auto)', 'Google', 'xAI', 'Groq', 'OpenRouter', 'Moonshot', 'Mistral', 'Cerebras', 'ZAI (Zhipu)', 'MiniMax', 'HuggingFace', 'Gocoon', 'Local']; interface ProviderSwitchZoneProps { pendingMeta: ProviderMeta; diff --git a/web/src/components/setup/ProviderStep.tsx b/web/src/components/setup/ProviderStep.tsx index 584742d8..46272179 100644 --- a/web/src/components/setup/ProviderStep.tsx +++ b/web/src/components/setup/ProviderStep.tsx @@ -139,6 +139,12 @@ export function ProviderStep({ data, onChange }: StepProps) {
      )} + {selected && selected.credentialMode === 'cli-auto' && ( +
      + Credentials are read automatically from your local {selected.id === 'codex' ? 'Codex' : 'Grok'} CLI session. +
      + )} + {selected && !selected.requiresApiKey && selected.id === 'gocoon' && (
      diff --git a/web/src/components/setup/SetupContext.tsx b/web/src/components/setup/SetupContext.tsx index aecd6c3f..58bde085 100644 --- a/web/src/components/setup/SetupContext.tsx +++ b/web/src/components/setup/SetupContext.tsx @@ -120,6 +120,7 @@ function validateStep(step: number, data: WizardData): boolean { try { new URL(data.localUrl); return true; } catch { return false; } } + if (data.provider === 'codex' || data.provider === 'grok-build') return true; return data.apiKey.length > 0; case 2: { // Config diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index ab444656..41fb2c08 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -25,6 +25,7 @@ export interface SetupProvider { toolLimit: number | null; keyPrefix: string | null; consoleUrl: string | null; + credentialMode: 'api-key' | 'cli-auto' | 'none'; requiresApiKey: boolean; } From a59383ce1315f723797917ae816d4c2d22cc6c71 Mon Sep 17 00:00:00 2001 From: TONresistor <240980241+TONresistor@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:35:14 +0200 Subject: [PATCH 31/59] fix(codex): gate unavailable Luna model --- docs/configuration.md | 4 ++ src/config/model-catalog.ts | 12 +++- src/config/schema.ts | 59 ++++++++++++-------- src/providers/__tests__/codex-models.test.ts | 57 +++++++++++++++---- src/providers/model-resolver.ts | 7 +++ 5 files changed, 104 insertions(+), 35 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 8921ab33..8bc1d452 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -104,6 +104,10 @@ When you change the `provider` and omit `model`, the platform auto-selects: `$GROK_HOME/auth.json` (default: `~/.grok/auth.json`) and connects directly to the Grok Build CLI proxy. The session token is not stored in Teleton's config. +Codex model `gpt-5.6-luna` is hidden and rejected by default because the live +backend does not currently advertise it. Keep production on `gpt-5.6-terra`; +`TELETON_ENABLE_CODEX_LUNA=true` exists only for controlled backend revalidation. + --- ## telegram diff --git a/src/config/model-catalog.ts b/src/config/model-catalog.ts index 04808ada..410e380f 100644 --- a/src/config/model-catalog.ts +++ b/src/config/model-catalog.ts @@ -10,6 +10,12 @@ export interface ModelOption { description: string; } +export const CODEX_LUNA_MODEL_ID = "gpt-5.6-luna"; + +export function isCodexLunaEnabled(): boolean { + return process.env.TELETON_ENABLE_CODEX_LUNA?.trim().toLowerCase() === "true"; +} + export const MODEL_OPTIONS: Record = { anthropic: [ { @@ -306,5 +312,9 @@ export const MODEL_OPTIONS: Record = { /** Get models for a provider (codex → openai-codex) */ export function getModelsForProvider(provider: string): ModelOption[] { const key = provider === "codex" ? "openai-codex" : provider; - return MODEL_OPTIONS[key] || []; + const models = MODEL_OPTIONS[key] || []; + if (provider === "codex" && !isCodexLunaEnabled()) { + return models.filter((model) => model.value !== CODEX_LUNA_MODEL_ID); + } + return models; } diff --git a/src/config/schema.ts b/src/config/schema.ts index 7ab3281e..b94ee06a 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { TELEGRAM_MAX_MESSAGE_LENGTH } from "../constants/limits.js"; import { SUPPORTED_PROVIDER_IDS } from "./providers.js"; +import { CODEX_LUNA_MODEL_ID, isCodexLunaEnabled } from "./model-catalog.js"; export const DMPolicy = z.enum(["allowlist", "open", "admin-only", "disabled"]); export const GroupPolicy = z.enum(["open", "allowlist", "admin-only", "disabled"]); @@ -25,28 +26,42 @@ export const SessionResetPolicySchema = z.object({ .describe("Minutes of inactivity before session reset (default: 24h)"), }); -export const AgentConfigSchema = z.object({ - provider: z.enum(SUPPORTED_PROVIDER_IDS).default("anthropic"), - api_key: z.string().default(""), - base_url: z - .string() - .url() - .optional() - .describe("Base URL for local LLM server (e.g. http://localhost:11434/v1)"), - model: z.string().default("claude-haiku-4-5-20251001"), - utility_model: z - .string() - .optional() - .describe("Cheap model for summarization (auto-detected if omitted)"), - max_tokens: z.number().default(4096), - temperature: z.number().default(0.7), - system_prompt: z.string().nullable().default(null), - max_agentic_iterations: z - .number() - .default(5) - .describe("Maximum number of agentic loop iterations (tool call → result → tool call cycles)"), - session_reset_policy: SessionResetPolicySchema.default(SessionResetPolicySchema.parse({})), -}); +export const AgentConfigSchema = z + .object({ + provider: z.enum(SUPPORTED_PROVIDER_IDS).default("anthropic"), + api_key: z.string().default(""), + base_url: z + .string() + .url() + .optional() + .describe("Base URL for local LLM server (e.g. http://localhost:11434/v1)"), + model: z.string().default("claude-haiku-4-5-20251001"), + utility_model: z + .string() + .optional() + .describe("Cheap model for summarization (auto-detected if omitted)"), + max_tokens: z.number().default(4096), + temperature: z.number().default(0.7), + system_prompt: z.string().nullable().default(null), + max_agentic_iterations: z + .number() + .default(5) + .describe( + "Maximum number of agentic loop iterations (tool call → result → tool call cycles)" + ), + session_reset_policy: SessionResetPolicySchema.default(SessionResetPolicySchema.parse({})), + }) + .superRefine((agent, context) => { + if (agent.provider !== "codex" || isCodexLunaEnabled()) return; + for (const field of ["model", "utility_model"] as const) { + if (agent[field] !== CODEX_LUNA_MODEL_ID) continue; + context.addIssue({ + code: "custom", + path: [field], + message: `${CODEX_LUNA_MODEL_ID} is not currently available; use gpt-5.6-terra`, + }); + } + }); export const TelegramConfigSchema = z .object({ diff --git a/src/providers/__tests__/codex-models.test.ts b/src/providers/__tests__/codex-models.test.ts index 55cf2b4f..1f83ce13 100644 --- a/src/providers/__tests__/codex-models.test.ts +++ b/src/providers/__tests__/codex-models.test.ts @@ -1,25 +1,58 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import { getModelsForProvider } from "../../config/model-catalog.js"; import { getProviderMetadata } from "../../config/providers.js"; +import { AgentConfigSchema } from "../../config/schema.js"; import { getProviderModel } from "../model-resolver.js"; -const GPT_56_CODEX_MODELS = ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"] as const; +const ENABLED_GPT_56_CODEX_MODELS = ["gpt-5.6-sol", "gpt-5.6-terra"] as const; +const LUNA_MODEL = "gpt-5.6-luna"; describe("Codex GPT-5.6 models", () => { - it.each(GPT_56_CODEX_MODELS)("resolves %s through the Codex Responses provider", (modelId) => { - const model = getProviderModel("codex", modelId); - - expect(model.id).toBe(modelId); - expect(model.provider).toBe("openai-codex"); - expect(model.api).toBe("openai-codex-responses"); - expect(model.contextWindow).toBe(372_000); - expect(model.maxTokens).toBe(128_000); + afterEach(() => { + delete process.env.TELETON_ENABLE_CODEX_LUNA; }); - it("exposes every GPT-5.6 variant in the shared Codex catalog", () => { + it.each(ENABLED_GPT_56_CODEX_MODELS)( + "resolves %s through the Codex Responses provider", + (modelId) => { + const model = getProviderModel("codex", modelId); + + expect(model.id).toBe(modelId); + expect(model.provider).toBe("openai-codex"); + expect(model.api).toBe("openai-codex-responses"); + expect(model.contextWindow).toBe(372_000); + expect(model.maxTokens).toBe(128_000); + } + ); + + it("hides and rejects Luna while the live backend does not advertise it", () => { const modelIds = getModelsForProvider("codex").map((model) => model.value); - expect(modelIds).toEqual(expect.arrayContaining(GPT_56_CODEX_MODELS)); + expect(modelIds).toEqual(expect.arrayContaining(ENABLED_GPT_56_CODEX_MODELS)); + expect(modelIds).not.toContain(LUNA_MODEL); + expect(() => getProviderModel("codex", LUNA_MODEL)).toThrow( + "gpt-5.6-luna is disabled because the Codex backend does not currently advertise it" + ); + expect(AgentConfigSchema.safeParse({ provider: "codex", model: LUNA_MODEL }).success).toBe( + false + ); + expect( + AgentConfigSchema.safeParse({ + provider: "codex", + model: "gpt-5.6-terra", + utility_model: LUNA_MODEL, + }).success + ).toBe(false); + }); + + it("allows a controlled Luna revalidation behind an explicit feature flag", () => { + process.env.TELETON_ENABLE_CODEX_LUNA = "true"; + + expect(getModelsForProvider("codex").map((model) => model.value)).toContain(LUNA_MODEL); + expect(getProviderModel("codex", LUNA_MODEL).id).toBe(LUNA_MODEL); + expect(AgentConfigSchema.safeParse({ provider: "codex", model: LUNA_MODEL }).success).toBe( + true + ); }); it("keeps GPT-5.5 as the Codex default while GPT-5.6 is in preview", () => { diff --git a/src/providers/model-resolver.ts b/src/providers/model-resolver.ts index f52fa779..7a1ce180 100644 --- a/src/providers/model-resolver.ts +++ b/src/providers/model-resolver.ts @@ -3,6 +3,7 @@ import { getProviderMetadata, type SupportedProvider } from "../config/providers import { createLogger } from "../utils/logger.js"; import { fetchWithTimeout } from "../utils/fetch.js"; import { getGrokBuildCliVersion } from "./grok-build-credentials.js"; +import { CODEX_LUNA_MODEL_ID, isCodexLunaEnabled } from "../config/model-catalog.js"; const log = createLogger("LLM"); @@ -139,6 +140,12 @@ const MOONSHOT_MODEL_ALIASES: Record = { }; export function getProviderModel(provider: SupportedProvider, modelId: string): Model { + if (provider === "codex" && modelId === CODEX_LUNA_MODEL_ID && !isCodexLunaEnabled()) { + throw new Error( + `${CODEX_LUNA_MODEL_ID} is disabled because the Codex backend does not currently advertise it; use gpt-5.6-terra or set TELETON_ENABLE_CODEX_LUNA=true only for controlled revalidation` + ); + } + const cacheKey = `${provider}:${modelId}`; const cached = modelCache.get(cacheKey); if (cached) return cached; From 31ad001af9f5c64bd02ba94337238d49df87ad67 Mon Sep 17 00:00:00 2001 From: TONresistor <240980241+TONresistor@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:45:36 +0200 Subject: [PATCH 32/59] fix(telegram): prevent self-replies and duplicate sends --- config.example.yaml | 4 - docs/configuration.md | 6 +- .../__tests__/client-telegram-tools.test.ts | 108 ++++++++++++++++++ src/agent/client.ts | 24 +++- .../__tests__/telegram-core-tools.test.ts | 27 +++++ src/agent/tools/telegram/interactive/index.ts | 2 +- src/agent/tools/telegram/messaging/index.ts | 8 +- src/agent/tools/telegram/send-buttons.ts | 2 +- src/cli/commands/onboard.ts | 11 +- src/config/schema.ts | 10 +- src/telegram/__tests__/handlers.test.ts | 10 ++ src/telegram/handlers.ts | 14 +++ src/webui/__tests__/setup-routes.test.ts | 13 +++ src/webui/routes/setup.ts | 11 +- 14 files changed, 209 insertions(+), 41 deletions(-) create mode 100644 src/agent/__tests__/client-telegram-tools.test.ts create mode 100644 src/agent/tools/__tests__/telegram-core-tools.test.ts diff --git a/config.example.yaml b/config.example.yaml index db9c4831..224406b0 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -79,10 +79,6 @@ webui: # top_k: 35 # Max tools to retrieve per LLM call # skip_unlimited_providers: false # RAG applies to ALL providers (including Anthropic) # always_include: # Tools always included regardless of query -# - telegram_send_message -# - telegram_reply_message -# - telegram_send_photo -# - telegram_send_document # - "journal_*" # Prefix glob: all journal_ tools # - "workspace_*" # - "web_*" diff --git a/docs/configuration.md b/docs/configuration.md index 8bc1d452..54cc66e6 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -323,7 +323,7 @@ Semantic tool retrieval configuration. When enabled, the agent uses embedding-ba |-----|------|---------|-------------| | `tool_rag.enabled` | `boolean` | `true` | Enable semantic tool retrieval (Tool RAG). | | `tool_rag.top_k` | `number` | `35` | Maximum number of tools to retrieve per LLM call. | -| `tool_rag.always_include` | `string[]` | `["telegram_send_message", "telegram_quote_reply", "telegram_send_photo", "journal_*", "workspace_*"]` | Tool name patterns always included regardless of relevance score. Supports prefix glob with `*`. | +| `tool_rag.always_include` | `string[]` | `["journal_*", "workspace_*", "web_*"]` | Tool name patterns always included regardless of relevance score. Supports prefix glob with `*`. Telegram send tools remain searchable but are excluded by default to prevent duplicate replies and progress messages. | | `tool_rag.skip_unlimited_providers` | `boolean` | `false` | Skip Tool RAG for providers with no tool limit (e.g., Anthropic). When `true`, all tools are sent to those providers. | ### Example @@ -333,11 +333,9 @@ tool_rag: enabled: true top_k: 35 always_include: - - "telegram_send_message" - - "telegram_quote_reply" - - "telegram_send_photo" - "journal_*" - "workspace_*" + - "web_*" skip_unlimited_providers: false ``` diff --git a/src/agent/__tests__/client-telegram-tools.test.ts b/src/agent/__tests__/client-telegram-tools.test.ts new file mode 100644 index 00000000..045b4439 --- /dev/null +++ b/src/agent/__tests__/client-telegram-tools.test.ts @@ -0,0 +1,108 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Tool } from "@earendil-works/pi-ai/compat"; +import { AgentConfigSchema } from "../../config/schema.js"; + +const mocks = vi.hoisted(() => ({ + complete: vi.fn(), + stream: vi.fn(), +})); + +vi.mock("@earendil-works/pi-ai/compat", async () => { + const actual = await vi.importActual( + "@earendil-works/pi-ai/compat" + ); + return { + ...actual, + complete: mocks.complete, + stream: mocks.stream, + }; +}); + +import { chatWithContext, streamWithContext } from "../client.js"; + +const config = AgentConfigSchema.parse({ + provider: "anthropic", + model: "claude-haiku-4-5-20251001", + api_key: "test-key", +}); + +const sendTool: Tool = { + name: "telegram_send_message", + description: "Send a Telegram message.", + parameters: { + type: "object", + properties: {}, + }, +}; + +const readTool: Tool = { + name: "telegram_get_history", + description: "Read Telegram history.", + parameters: { + type: "object", + properties: {}, + }, +}; + +function assistantMessage() { + return { + role: "assistant" as const, + content: [{ type: "text" as const, text: "ok" }], + api: "anthropic-messages" as const, + provider: "anthropic" as const, + model: "claude-haiku-4-5-20251001", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop" as const, + timestamp: Date.now(), + }; +} + +function expectPreparedTools(context: { tools?: Tool[] }) { + const preparedSend = context.tools?.find((tool) => tool.name === sendTool.name); + const preparedRead = context.tools?.find((tool) => tool.name === readTool.name); + + expect(preparedSend?.description).toContain("This action sends immediately"); + expect(preparedSend?.description).toContain("return normal assistant text instead"); + expect(preparedRead).toEqual(readTool); + expect(sendTool.description).toBe("Send a Telegram message."); +} + +describe("Telegram tool preparation", () => { + beforeEach(() => { + mocks.complete.mockReset(); + mocks.stream.mockReset(); + }); + + it("guards send tools in non-streaming calls without mutating registry definitions", async () => { + mocks.complete.mockResolvedValue(assistantMessage()); + + await chatWithContext(config, { + context: { messages: [] }, + tools: [sendTool, readTool], + }); + + expectPreparedTools(mocks.complete.mock.calls[0][1]); + }); + + it("applies the same guard to streaming calls", async () => { + mocks.stream.mockReturnValue({ + async *[Symbol.asyncIterator]() {}, + result: vi.fn().mockResolvedValue(assistantMessage()), + }); + + const streamed = streamWithContext(config, { + context: { messages: [] }, + tools: [sendTool, readTool], + }); + await streamed.result; + + expectPreparedTools(mocks.stream.mock.calls[0][1]); + }); +}); diff --git a/src/agent/client.ts b/src/agent/client.ts index 43898640..5df7db9a 100644 --- a/src/agent/client.ts +++ b/src/agent/client.ts @@ -15,6 +15,7 @@ import { createLogger } from "../utils/logger.js"; import { getCodexApiKey, refreshCodexApiKey } from "../providers/codex-credentials.js"; import { getGrokBuildApiKey, refreshGrokBuildApiKey } from "../providers/grok-build-credentials.js"; import { getProviderModel } from "../providers/model-resolver.js"; +import { TELEGRAM_SEND_TOOLS } from "../constants/tools.js"; // Model resolution + provider model registration live in the neutral providers/ // layer so non-agent consumers (e.g. memory) can resolve models without importing @@ -57,6 +58,23 @@ function getCacheRetention(provider: string): "none" | "long" { return provider === "grok-build" ? "none" : "long"; } +function prepareToolsForProvider(tools: Tool[] | undefined): Tool[] | undefined { + if (!tools) return tools; + + return tools.map((tool) => + TELEGRAM_SEND_TOOLS.has(tool.name) + ? { + ...tool, + description: + `${tool.description} This action sends immediately. ` + + "Do not use this tool to reply to the current inbound message or for progress updates; " + + "return normal assistant text instead, which Teleton delivers automatically. " + + "Use it for intentional separate Telegram messages.", + } + : tool + ); +} + function getProviderPayloadOptions(provider: SupportedProvider): Record { if (provider !== "grok-build") return {}; @@ -123,8 +141,9 @@ export async function chatWithContext( ): Promise { const provider = (config.provider || "anthropic") as SupportedProvider; const model = getProviderModel(provider, config.model); + const preparedTools = prepareToolsForProvider(options.tools); const tools = - provider === "google" && options.tools ? sanitizeToolsForGemini(options.tools) : options.tools; + provider === "google" && preparedTools ? sanitizeToolsForGemini(preparedTools) : preparedTools; const systemPrompt = options.systemPrompt || options.context.systemPrompt || ""; @@ -169,9 +188,10 @@ export interface StreamResult { export function streamWithContext(config: AgentConfig, options: ChatOptions): StreamResult { const provider = (config.provider || "anthropic") as SupportedProvider; const model = getProviderModel(provider, config.model); + const preparedTools = prepareToolsForProvider(options.tools); const tools = - provider === "google" && options.tools ? sanitizeToolsForGemini(options.tools) : options.tools; + provider === "google" && preparedTools ? sanitizeToolsForGemini(preparedTools) : preparedTools; const systemPrompt = options.systemPrompt || options.context.systemPrompt || ""; diff --git a/src/agent/tools/__tests__/telegram-core-tools.test.ts b/src/agent/tools/__tests__/telegram-core-tools.test.ts new file mode 100644 index 00000000..dcecae52 --- /dev/null +++ b/src/agent/tools/__tests__/telegram-core-tools.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { TELEGRAM_SEND_TOOLS } from "../../../constants/tools.js"; +import { tools as telegramTools } from "../telegram/index.js"; + +describe("Telegram core tools", () => { + function coreToolNames(): string[] { + return telegramTools + .filter((entry) => entry.tags?.includes("core")) + .map((entry) => entry.tool.name); + } + + it("does not include visible send tools in the default core set", () => { + const coreTools = coreToolNames(); + + for (const toolName of TELEGRAM_SEND_TOOLS) { + expect(coreTools).not.toContain(toolName); + } + }); + + it("does not include message mutation tools in the default core set", () => { + const coreTools = coreToolNames(); + + expect(coreTools).not.toContain("telegram_edit_message"); + expect(coreTools).not.toContain("telegram_delete_message"); + expect(coreTools).not.toContain("telegram_react"); + }); +}); diff --git a/src/agent/tools/telegram/interactive/index.ts b/src/agent/tools/telegram/interactive/index.ts index f4ea6266..4bfe9779 100644 --- a/src/agent/tools/telegram/interactive/index.ts +++ b/src/agent/tools/telegram/interactive/index.ts @@ -28,7 +28,7 @@ export const tools: ToolEntry[] = [ tool: telegramReactTool, executor: telegramReactExecutor, mode: "both", - tags: ["core"], + tags: ["social"], }, { tool: telegramSendDiceTool, diff --git a/src/agent/tools/telegram/messaging/index.ts b/src/agent/tools/telegram/messaging/index.ts index 440888d5..d07e1761 100644 --- a/src/agent/tools/telegram/messaging/index.ts +++ b/src/agent/tools/telegram/messaging/index.ts @@ -35,13 +35,13 @@ export const tools: ToolEntry[] = [ tool: telegramSendMessageTool, executor: telegramSendMessageExecutor, mode: "both", - tags: ["core"], + tags: ["social"], }, { tool: telegramQuoteReplyTool, executor: telegramQuoteReplyExecutor, mode: "user", - tags: ["core"], + tags: ["social"], }, { tool: telegramGetRepliesTool, @@ -53,7 +53,7 @@ export const tools: ToolEntry[] = [ tool: telegramEditMessageTool, executor: telegramEditMessageExecutor, mode: "both", - tags: ["core"], + tags: ["social"], }, { tool: telegramScheduleMessageTool, @@ -107,7 +107,7 @@ export const tools: ToolEntry[] = [ tool: telegramDeleteMessageTool, executor: telegramDeleteMessageExecutor, mode: "both", - tags: ["core"], + tags: ["admin"], }, { tool: botInlineSendTool, diff --git a/src/agent/tools/telegram/send-buttons.ts b/src/agent/tools/telegram/send-buttons.ts index ce070596..9abce639 100644 --- a/src/agent/tools/telegram/send-buttons.ts +++ b/src/agent/tools/telegram/send-buttons.ts @@ -59,5 +59,5 @@ export const sendButtonsEntry: ToolEntry = { executor, scope: "always", mode: "bot", - tags: ["core", "bot"], + tags: ["social", "bot"], }; diff --git a/src/cli/commands/onboard.ts b/src/cli/commands/onboard.ts index e62ab5a9..9243c666 100644 --- a/src/cli/commands/onboard.ts +++ b/src/cli/commands/onboard.ts @@ -36,7 +36,7 @@ import { TELETON_ROOT } from "../../workspace/paths.js"; import { TelegramUserClient } from "../../telegram/client.js"; import { maskSecret } from "../../utils/mask.js"; import YAML from "yaml"; -import { type Config } from "../../config/schema.js"; +import { DEFAULT_TOOL_RAG_ALWAYS_INCLUDE, type Config } from "../../config/schema.js"; import { getModelsForProvider } from "../../config/model-catalog.js"; import { generateWallet, @@ -347,14 +347,7 @@ function buildConfig(input: BuildConfigInput): Config { tool_rag: { enabled: true, top_k: 25, - always_include: [ - "telegram_send_message", - "telegram_quote_reply", - "telegram_send_photo", - "journal_*", - "workspace_*", - "web_*", - ], + always_include: [...DEFAULT_TOOL_RAG_ALWAYS_INCLUDE], skip_unlimited_providers: false, }, tool_search: { enabled: true }, diff --git a/src/config/schema.ts b/src/config/schema.ts index b94ee06a..5665421f 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -11,6 +11,8 @@ export const GroupPolicy = z.enum(["open", "allowlist", "admin-only", "disabled" export const ExecMode = z.enum(["off", "yolo"]); export const ExecScope = z.enum(["admin-only", "allowlist", "all"]); +export const DEFAULT_TOOL_RAG_ALWAYS_INCLUDE = ["journal_*", "workspace_*", "web_*"] as const; + export const SessionResetPolicySchema = z.object({ daily_reset_enabled: z.boolean().default(true).describe("Enable daily session reset"), daily_reset_hour: z @@ -273,13 +275,7 @@ const _ToolRagObject = z.object({ top_k: z.number().default(35).describe("Max tools to retrieve per LLM call"), always_include: z .array(z.string()) - .default([ - "telegram_send_message", - "telegram_quote_reply", - "telegram_send_photo", - "journal_*", - "workspace_*", - ]) + .default([...DEFAULT_TOOL_RAG_ALWAYS_INCLUDE]) .describe("Tool name patterns always included (prefix glob with *)"), skip_unlimited_providers: z .boolean() diff --git a/src/telegram/__tests__/handlers.test.ts b/src/telegram/__tests__/handlers.test.ts index eb359235..48f875e4 100644 --- a/src/telegram/__tests__/handlers.test.ts +++ b/src/telegram/__tests__/handlers.test.ts @@ -280,6 +280,16 @@ describe("MessageHandler", () => { expect(ctx.reason).toBe("Sender is a bot"); }); + it("own user messages → shouldRespond=false", () => { + const { handler } = createHandler({ dm_policy: "open" }); + handler.setOwnUserId("222"); + + const ctx = handler.analyzeMessage(makeMessage({ senderId: 222 })); + + expect(ctx.shouldRespond).toBe(false); + expect(ctx.reason).toBe("Sender is self"); + }); + it("message.id <= chatOffset → shouldRespond=false (already processed)", () => { mockReadOffset.mockReturnValue(100); const { handler } = createHandler({ dm_policy: "open" }); diff --git a/src/telegram/handlers.ts b/src/telegram/handlers.ts index bf14b740..13fe7814 100644 --- a/src/telegram/handlers.ts +++ b/src/telegram/handlers.ts @@ -214,6 +214,20 @@ export class MessageHandler { } } + const ownSenderId = this.ownUserId ? Number(this.ownUserId) : undefined; + if ( + ownSenderId !== undefined && + Number.isFinite(ownSenderId) && + message.senderId === ownSenderId + ) { + return { + message, + isAdmin, + shouldRespond: false, + reason: "Sender is self", + }; + } + if (message.isBot) { return { message, diff --git a/src/webui/__tests__/setup-routes.test.ts b/src/webui/__tests__/setup-routes.test.ts index efbb093d..6b82a789 100644 --- a/src/webui/__tests__/setup-routes.test.ts +++ b/src/webui/__tests__/setup-routes.test.ts @@ -131,6 +131,7 @@ vi.mock("../../utils/logger.js", () => ({ vi.mock("../../config/schema.js", () => ({ ConfigSchema: { parse: vi.fn((v: unknown) => v) }, + DEFAULT_TOOL_RAG_ALWAYS_INCLUDE: ["journal_*", "workspace_*", "web_*"], DealsConfigSchema: { parse: vi.fn((v: unknown) => v) }, })); @@ -932,6 +933,18 @@ describe("Setup API Routes", () => { expect(ConfigSchema.parse).toHaveBeenCalled(); }); + it("keeps Telegram send tools out of the generated default tool context", async () => { + await post(app, "/config/save", validInput); + + const yaml = (writeFileSync as Mock).mock.calls[0][1] as string; + expect(yaml).not.toContain("telegram_send_message"); + expect(yaml).not.toContain("telegram_quote_reply"); + expect(yaml).not.toContain("telegram_send_photo"); + expect(yaml).toContain("journal_*"); + expect(yaml).toContain("workspace_*"); + expect(yaml).toContain("web_*"); + }); + it("includes gocoon config when provided", async () => { const input = { ...validInput, diff --git a/src/webui/routes/setup.ts b/src/webui/routes/setup.ts index 8d75d907..103e6451 100644 --- a/src/webui/routes/setup.ts +++ b/src/webui/routes/setup.ts @@ -16,7 +16,7 @@ import { validateApiKeyFormat, type SupportedProvider, } from "../../config/providers.js"; -import { ConfigSchema } from "../../config/schema.js"; +import { ConfigSchema, DEFAULT_TOOL_RAG_ALWAYS_INCLUDE } from "../../config/schema.js"; import { ensureWorkspace, isNewWorkspace } from "../../workspace/manager.js"; import { TELETON_ROOT } from "../../workspace/paths.js"; import { @@ -520,14 +520,7 @@ export function createSetupRoutes(options?: { keyHash?: string }): Hono { tool_rag: { enabled: false, top_k: 25, - always_include: [ - "telegram_send_message", - "telegram_quote_reply", - "telegram_send_photo", - "journal_*", - "workspace_*", - "web_*", - ], + always_include: [...DEFAULT_TOOL_RAG_ALWAYS_INCLUDE], skip_unlimited_providers: false, }, capabilities: { From 09e1eead3d3496522de801ca7f17b6b0b35b088c Mon Sep 17 00:00:00 2001 From: TONresistor <240980241+TONresistor@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:02:42 +0200 Subject: [PATCH 33/59] fix(product): remove stale deals surfaces --- GETTING_STARTED.md | 58 +++----- README.md | 49 +++---- config.example.yaml | 14 +- docs-sdk/TEMPLATE.md | 2 +- docs-sdk/pages/agentic-loop.html | 2 +- docs-sdk/pages/architecture.html | 2 +- docs-sdk/pages/cli-reference.html | 2 +- docs-sdk/pages/configuration.html | 22 +-- docs-sdk/pages/deploy-docker.html | 2 +- docs-sdk/pages/index.html | 24 ++-- docs-sdk/pages/installation.html | 9 +- docs-sdk/pages/memory-system.html | 2 +- docs-sdk/pages/plugin-sdk.html | 4 +- docs-sdk/pages/quickstart.html | 2 +- docs-sdk/pages/sdk-bot.html | 2 +- docs-sdk/pages/sdk-dex.html | 2 +- docs-sdk/pages/sdk-dns.html | 2 +- docs-sdk/pages/sdk-errors.html | 2 +- docs-sdk/pages/sdk-overview.html | 6 +- docs-sdk/pages/sdk-telegram.html | 2 +- docs-sdk/pages/sdk-ton.html | 2 +- docs-sdk/pages/sdk-utilities.html | 2 +- docs-sdk/pages/security.html | 2 +- docs-sdk/pages/telegram-setup.html | 41 +++--- docs-sdk/pages/tools-deals.html | 118 --------------- docs-sdk/pages/tools-dex.html | 2 +- docs-sdk/pages/tools-dns.html | 2 +- docs-sdk/pages/tools-other.html | 4 +- docs-sdk/pages/tools-telegram.html | 34 +++-- docs-sdk/pages/tools-ton.html | 2 +- docs-sdk/pages/tutorial-dex-bot.html | 2 +- docs-sdk/pages/tutorial-inline-bot.html | 2 +- docs-sdk/pages/tutorial-payment-bot.html | 2 +- docs/configuration.md | 38 +---- docs/guide.md | 2 +- docs/plugins.md | 2 +- docs/telegram-setup.md | 54 +++---- docs/ton-wallet.md | 3 +- .../telegram/gifts/get-available-gifts.ts | 5 +- .../telegram/gifts/set-collectible-price.ts | 2 +- .../tools/telegram/messaging/inline-send.ts | 2 +- src/bot/__tests__/inline-router.test.ts | 4 +- src/config/__tests__/product-surfaces.test.ts | 134 ++++++++++++++++++ src/telegram/bridges/bot.ts | 1 - src/templates/SECURITY.md | 7 +- src/templates/STRATEGY.md | 7 +- src/webui/__tests__/setup-routes.test.ts | 4 +- web/src/components/setup/ConfigStep.tsx | 2 +- web/src/components/setup/SetupContext.tsx | 12 -- web/src/lib/api.ts | 1 - web/src/pages/Config.tsx | 1 - 51 files changed, 310 insertions(+), 396 deletions(-) delete mode 100644 docs-sdk/pages/tools-deals.html create mode 100644 src/config/__tests__/product-surfaces.test.ts diff --git a/GETTING_STARTED.md b/GETTING_STARTED.md index 1d182094..0d925783 100644 --- a/GETTING_STARTED.md +++ b/GETTING_STARTED.md @@ -13,7 +13,7 @@ Complete guide to installing, configuring, and running your Teleton AI agent. | **Telegram Account** | Dedicated account recommended (agent has full control) | | **Telegram API Credentials** | `api_id` + `api_hash` from [my.telegram.org/apps](https://my.telegram.org/apps) | | **Telegram User ID** | Message [@userinfobot](https://t.me/userinfobot) to get yours | -| **Bot Token** *(optional)* | From [@BotFather](https://t.me/BotFather) - required for the deals system | +| **Bot Token** *(optional in user mode)* | From [@BotFather](https://t.me/BotFather) for plugin inline cards and callbacks; required in bot mode | | **TonAPI Key** *(optional)* | From [@AntTonTechBot](https://t.me/AntTonTechBot) mini app - higher rate limits | --- @@ -57,7 +57,7 @@ The interactive wizard configures everything: 3. **Access Policies** - DM policy (open/allowlist/pairing/disabled), group policy, mention rules 4. **Admin** - Your Telegram User ID, owner name/username 5. **TON Wallet** - Generates a W5R1 wallet with 24-word mnemonic -6. **Deals** *(optional)* - Bot token for the deals system, trading thresholds +6. **Optional Integrations** - Bot token, TonAPI, Toncenter, and Tavily credentials 7. **Workspace** - Creates template files (SOUL.md, IDENTITY.md, STRATEGY.md, etc.) **Files created:** @@ -98,7 +98,7 @@ You should see: ✅ Knowledge indexed ✅ Telegram: @your_agent connected ✅ TON Blockchain: connected -✅ Agent is ready! (124 tools) +✅ Agent is ready! (128 base tools) ``` **Verify:** Send `/ping` to your agent on Telegram. @@ -125,11 +125,6 @@ telegram: admin_ids: [123456789] # Your Telegram User ID debounce_ms: 1500 # Group message batching delay -deals: - enabled: true - buy_max_floor_percent: 100 # Buy at or below floor price - sell_min_floor_percent: 105 # Sell at floor + 5% minimum - ``` ### Switching LLM Provider @@ -158,13 +153,16 @@ Admin commands are only available to users listed in `admin_ids`. All commands w | `/clear` | Clear current chat history | | `/clear ` | Clear specific chat history | | `/model ` | Switch LLM model at runtime | -| `/strategy` | View or change trading thresholds | -| `/strategy buy 95` | Set buy threshold to 95% of floor | -| `/strategy sell 110` | Set sell threshold to 110% of floor | | `/wallet` | Show wallet address and balance | +| `/approve ` / `/reject ` | Resolve a pending financial action | | `/policy dm open` | Change DM policy at runtime | +| `/modules set\|info\|reset` | Manage per-group tool permissions | +| `/plugin set\|unset\|keys` | Manage plugin secrets | | `/pause` / `/resume` | Pause/resume agent responses | | `/loop ` | Set max agentic iterations | +| `/verbose` | Toggle debug logging | +| `/rag [status\|topk ]` | Toggle Tool RAG or view status | +| `/guest [on\|off]` | View or toggle bot guest mode | | `/stop` | Emergency shutdown | | `/help` | List all commands | @@ -172,18 +170,20 @@ Admin commands are only available to users listed in `admin_ids`. All commands w ## Tool Categories -Teleton has **~124 tools** across these categories: +Teleton has **128 always-registered tools**, plus 5 optional system tools: | Category | Count | Highlights | |----------|-------|------------| -| **Telegram** | 77 | Messaging, media, chats, groups, polls, stickers, gifts, stars, stories, contacts, folders, profile, memory, tasks | +| **Telegram** | 83 | Messaging, media, chats, groups, polls, stickers, gifts, stars, stories, contacts, folders, profile, memory, tasks | | **TON & Jettons** | 15 | W5R1 wallet, send/receive TON & jettons, balances, prices, holders, history, charts, NFTs, DEX quotes | | **STON.fi DEX** | 5 | Swap, quote, search, trending tokens, liquidity pools | | **DeDust DEX** | 5 | Swap, quote, pools, prices, token info | -| **TON DNS** | 7 | Domain check, auctions, bidding, resolution | -| **Deals** | 5 | Secure gift/TON trading with strategy enforcement and inline bot confirmations | +| **TON DNS** | 8 | Domain check, auctions, bidding, resolution, TON Sites | | **Journal** | 3 | Log trades/operations with reasoning and P&L | | **Workspace** | 6 | Sandboxed file operations | +| **Web** | 2 | Search and page extraction | +| **Tool Search** | 1 | Semantic retrieval across the registry | +| **System** *(optional)* | 5 | Exec (4) and TON Proxy status (1) | --- @@ -205,27 +205,6 @@ During setup, you can import a wallet instead of generating a new one. --- -## Deals System - -The deals system enables secure gift/TON trading with strategy enforcement. - -**Requirements:** Bot token configured, deals enabled in config. - -**How it works:** -1. Agent proposes a deal (buy/sell gift) -2. Strategy rules are enforced automatically (buy below floor, sell above floor +5%) -3. Inline bot shows deal card with Accept/Decline buttons -4. User sends TON first, agent verifies on-chain -5. Gift is transferred after payment verification - -**Customize thresholds:** -``` -/strategy buy 90 # Only buy at 90% of floor or less -/strategy sell 115 # Only sell at 115% of floor or more -``` - ---- - ## Memory & RAG The agent uses a hybrid search system for context-aware responses: @@ -330,7 +309,7 @@ export const tools = [ Restart the agent — the plugin is auto-loaded: ``` 🔌 Plugin "hello.js": 1 tool registered -✅ 122 tools loaded (1 from plugins) +✅ Tool registry loaded (including 1 plugin tool) ``` Plugins receive a full SDK with 108 methods across 9 namespaces: `sdk.ton`, `sdk.telegram`, `sdk.bot`, `sdk.secrets`, `sdk.storage`, `sdk.log`, and more. This includes TON wallet operations like `createTransfer`, `createJettonTransfer`, `getPublicKey`, and `getWalletVersion` for signing transactions without broadcasting. @@ -344,13 +323,12 @@ For contributors, create a TypeScript tool in `src/agent/tools/` and register it ``` src/ ├── index.ts # Main application entry point (TeletonApp) -├── agent/ # LLM runtime, tool registry, ~124 tool implementations +├── agent/ # LLM runtime and tool registry │ └── tools/ # telegram/, ton/, stonfi/, dedust/, dns/, journal/, workspace/ ├── telegram/ # GramJS bridge, message handlers, admin commands, debouncing ├── memory/ # SQLite database, RAG search (FTS5 + vector), compaction ├── ton/ # Wallet operations, payment verification, TON blockchain -├── deals/ # Deal proposals, strategy checker, config -├── bot/ # Grammy + GramJS bot for styled inline deal buttons +├── bot/ # Plugin inline-query and callback routing ├── sdk/ # Plugin SDK (v1.0.0) — TON, Telegram services for plugins ├── ton-proxy/ # TON Proxy module (Tonutils-Proxy integration) ├── session/ # Session persistence, transcripts diff --git a/README.md b/README.md index d9c459d6..74accd13 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@

      License: MIT - Node.js + Node.js 22.22.2+ TypeScript Website Documentation @@ -15,7 +15,7 @@ --- -

      Teleton is an autonomous AI agent platform that operates as a real Telegram user account or a Telegram Bot. It thinks through an agentic loop with tool calling, remembers conversations across sessions with hybrid RAG, and natively integrates the TON blockchain: send crypto, swap on DEXs, bid on domains, verify payments - all from a chat message. It can schedule tasks to run autonomously at any time. It ships with 135+ built-in tools, supports 16 LLM providers, and exposes a Plugin SDK so you can build your own tools on top of the platform.

      +

      Teleton is an autonomous AI agent platform that operates as a real Telegram user account or a Telegram Bot. It thinks through an agentic loop with tool calling, remembers conversations across sessions with hybrid RAG, and natively integrates the TON blockchain: send crypto, swap on DEXs, bid on domains, verify payments - all from a chat message. It can schedule tasks to run autonomously at any time. It ships with 128 always-registered tools plus 5 optional system tools, supports 16 LLM providers, and exposes a Plugin SDK so you can build your own tools on top of the platform.

      ### Key Highlights @@ -28,7 +28,7 @@
      TON Blockchain
      Wallet, jettons, DEX swaps, DNS, NFTs


      Persistent Memory
      Hybrid RAG, vector + keyword, auto-compaction

      -
      135+ Built-in Tools
      Messaging, media, crypto, DEX, DNS, files

      +
      128+ Built-in Tools
      Messaging, media, crypto, DEX, DNS, files


      Plugin SDK
      Custom tools, isolated DBs, secrets, hooks

      @@ -45,17 +45,16 @@ | Category | Tools | Description | | ------------- | ----- | -------------------------------------------------------------- | -| Telegram | 80 | Messages, media, chats, polls, stickers, gifts, stars, stories | +| Telegram | 83 | Messages, media, chats, polls, stickers, gifts, stars, stories | | TON & Jettons | 15 | Wallet, send/receive, balances, prices, NFTs, DEX router | | STON.fi DEX | 5 | Swap, quote, search, trending, pools | | DeDust DEX | 5 | Swap, quote, pools, prices, token analytics | | TON DNS | 8 | Auctions, bidding, linking, TON Sites, resolution | -| Deals | 5 | P2P escrow, on-chain verification, anti double-spend | | Journal | 3 | Trade logging, P&L tracking, natural language queries | | Web | 2 | Search and page extraction via Tavily | | Workspace | 6 | Sandboxed file operations, path traversal protection | -| Exec | 4 | Shell, files, processes (off by default, admin-only) | -| Bot | 1 | Inline bot message sending for plugin interactions | +| Tool Search | 1 | Semantic retrieval across the complete tool registry | +| System | 5 | Exec (4) and TON Proxy status (1), both optional | ### Advanced Capabilities @@ -89,7 +88,7 @@ - **Telegram Account** - Dedicated account recommended for security - **Telegram API Credentials** - From [my.telegram.org/apps](https://my.telegram.org/apps) - **Your Telegram User ID** - Message [@userinfobot](https://t.me/userinfobot) -- **Bot Token** *(optional)* - From [@BotFather](https://t.me/BotFather) for inline bot features (deals) +- **Bot Token** *(optional in user mode)* - From [@BotFather](https://t.me/BotFather) for plugin inline cards and callbacks; required in bot mode > **Security Warning**: The agent will have full control over the Telegram account. Use a dedicated account, not your main one. @@ -154,7 +153,7 @@ Teleton can run as a **user account** (MTProto) or a **Telegram bot** (Bot API). |---|---|---| | **Auth** | Phone + api_id + api_hash | Bot token from @BotFather | | **Protocol** | MTProto (GramJS) | Bot API (Grammy) | -| **Tools** | 135+ | 67 (11 Telegram + 56 non-Telegram) | +| **Tools** | 128 base, up to 133 with optional system modules | Registry filtered automatically by bot-compatible capabilities | | **Risk** | Account ban possible | No ban risk | | **Dialogs/History** | Full access | Not available | | **Media sending** | All types | Photos only (v1) | @@ -172,7 +171,7 @@ The `teleton setup` wizard generates a fully configured `~/.teleton/config.yaml` ```yaml agent: - provider: "anthropic" # anthropic | openai | google | xai | groq | openrouter | moonshot | mistral | cerebras | zai | minimax | huggingface | gocoon | local + provider: "anthropic" # anthropic | codex | grok-build | openai | google | xai | groq | openrouter | moonshot | mistral | cerebras | zai | minimax | huggingface | gocoon | local api_key: "sk-ant-api03-..." model: "claude-haiku-4-5-20251001" utility_model: "claude-haiku-4-5-20251001" # for summarization, compaction, vision @@ -191,7 +190,7 @@ telegram: owner_username: "your_username" debounce_ms: 1500 # group message batching delay - # Optional: inline bot for interactive features (deals) + # Optional in user mode: plugin inline cards and callbacks bot_token: "123456:ABC-DEF..." bot_username: "your_bot" @@ -366,8 +365,8 @@ All admin commands support `/`, `!`, or `.` prefix: | `/model ` | Hot-swap LLM model at runtime | | `/policy ` | Change access policies live | | `/loop <1-50>` | Set max agentic iterations | -| `/strategy [buy\|sell ]` | View/change trading thresholds | | `/wallet` | Show wallet address + balance | +| `/approve ` / `/reject ` | Resolve a pending financial action | | `/modules set\|info\|reset` | Per-group tool permissions | | `/plugin set\|unset\|keys` | Manage plugin secrets | | `/task ` | Assign a task to the agent | @@ -376,6 +375,7 @@ All admin commands support `/`, `!`, or `.` prefix: | `/clear [chat_id]` | Clear conversation history | | `/verbose` | Toggle debug logging | | `/rag [status\|topk ]` | Toggle Tool RAG or view status | +| `/guest [on\|off]` | View or toggle bot guest mode | | `/stop` | Emergency shutdown | | `/ping` | Check responsiveness | | `/help` | Show all commands | @@ -478,7 +478,7 @@ The SDK provides namespaced access to core services: |-------|------------| | LLM | Multi-provider via [pi-ai](https://github.com/mariozechner/pi-ai) (16 providers: Anthropic, Codex, Grok Build, OpenAI, Google, xAI, Groq, OpenRouter, Moonshot, Mistral, Cerebras, ZAI, MiniMax, Hugging Face, Gocoon, Local) | | Telegram Userbot | [GramJS](https://gram.js.org/) Layer 223 fork (MTProto) | -| Inline Bot | [Grammy](https://grammy.dev/) (Bot API, for deals) | +| Inline Bot | [Grammy](https://grammy.dev/) (Bot API, plugin inline cards and callbacks) | | Blockchain | [TON SDK](https://github.com/ton-org/ton) (W5R1 wallet) | | DeFi | STON.fi SDK, DeDust SDK | | Database | [better-sqlite3](https://github.com/WiseLibs/better-sqlite3) with WAL mode | @@ -497,13 +497,13 @@ src/ ├── agent/ # Core agent runtime │ ├── runtime.ts # Agentic loop (5 iterations, tool calling, masking, compaction) │ ├── client.ts # Multi-provider LLM client -│ └── tools/ # 135+ built-in tools +│ └── tools/ # 128 base tools plus 5 optional system tools │ ├── register-all.ts # Central tool registration (9 categories) │ ├── registry.ts # Tool registry, scope filtering, provider limits -│ ├── module-loader.ts # Built-in module loading (deals + exec) +│ ├── module-loader.ts # Built-in module loading (TON Proxy + exec) │ ├── plugin-loader.ts # External plugin discovery, validation, hot-reload │ ├── mcp-loader.ts # MCP client (stdio/SSE), tool discovery, lifecycle -│ ├── telegram/ # Telegram operations (80 tools) +│ ├── telegram/ # Telegram operations (83 tools) │ ├── ton/ # TON blockchain + jettons + DEX router (15 tools) │ ├── stonfi/ # STON.fi DEX (5 tools) │ ├── dedust/ # DeDust DEX (5 tools) @@ -511,18 +511,15 @@ src/ │ ├── exec/ # System execution — YOLO mode (4 tools) │ ├── journal/ # Business journal (3 tools) │ └── workspace/ # File operations (6 tools) -├── deals/ # Deals module (5 tools, loaded via module-loader) -│ ├── module.ts # Module definition + lifecycle -│ ├── executor.ts # Deal execution logic -│ └── strategy-checker.ts # Trading strategy enforcement -├── bot/ # Deals inline bot (Grammy + GramJS) -│ ├── index.ts # DealBot (Grammy Bot API) -│ ├── gramjs-bot.ts # GramJS MTProto for styled buttons -│ └── services/ # Message builder, styled keyboard, verification +├── bot/ # Plugin inline-query and callback routing +│ ├── inline-router.ts # Namespaced plugin inline handlers +│ ├── callback-router.ts # Nonce-based callback dispatch +│ ├── gramjs-bot.ts # Userbot-to-bot inline transport +│ └── services/ # Shared inline transport ├── telegram/ # Telegram integration layer │ ├── bridge.ts # GramJS wrapper (peer cache, message parsing, keyboards) │ ├── handlers.ts # Message routing, rate limiting, ChatQueue, feed storage -│ ├── admin.ts # 17 admin commands +│ ├── admin.ts # Runtime admin commands │ ├── debounce.ts # Message batching for groups │ ├── formatting.ts # Markdown → Telegram HTML │ ├── task-executor.ts # Scheduled task runner @@ -591,7 +588,7 @@ packages/sdk/ # Published @teleton-agent/sdk | **Plugin isolation** | Frozen SDK objects, sanitized config (no API keys), isolated per-plugin databases, `npm ci --ignore-scripts` | | **Wallet protection** | File permissions `0o600`, KeyPair cached (single PBKDF2), mnemonic never exposed to plugins | | **Memory protection** | Memory writes blocked in group chats to prevent poisoning | -| **Payment security** | `INSERT OR IGNORE` on tx hashes prevents double-spend, atomic status transitions prevent race conditions | +| **Payment security** | Verified payment hashes are recorded with `INSERT OR IGNORE`, preventing replay of the same transaction | | **Exec audit** | All YOLO mode commands logged to `exec_audit` table with user, command, output, and timestamps | | **Pino redaction** | Structured logging with automatic redaction of apiKey, password, secret, token, mnemonic fields | | **Tool access control** | Per-tool access level (all, allow-list, admin, off), DM vs group gated by global policies, per-group module permissions, all runtime-configurable | diff --git a/config.example.yaml b/config.example.yaml index 224406b0..a2e2ba2d 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -40,9 +40,9 @@ telegram: # owner_id: 123456789 # Owner's Telegram user ID debounce_ms: 1500 # Group message batching delay (0 = disabled) - # Optional: inline bot for deals system + # Optional in user mode: plugin inline cards and callback handling # bot_token: "123456:ABC-DEF..." # From @BotFather - # bot_username: "your_deals_bot" + # bot_username: "your_agent_bot" # Optional: TonAPI key for higher rate limits # tonapi_key: "YOUR_TONAPI_KEY" # From @tonapi_bot @@ -83,16 +83,6 @@ webui: # - "workspace_*" # - "web_*" -# Deals / OTC trading module -# deals: -# enabled: true -# expiry_seconds: 120 # Seconds before a pending deal expires -# buy_max_floor_percent: 100 # Max price as % of floor for buy offers -# sell_min_floor_percent: 105 # Min price as % of floor for sell offers -# poll_interval_ms: 5000 # Payment verification polling interval -# max_verification_retries: 12 # Max payment verification attempts -# expiry_check_interval_ms: 60000 # Stale deal check interval - # Storage paths (defaults are fine for most users) # storage: # sessions_file: "~/.teleton/sessions.json" diff --git a/docs-sdk/TEMPLATE.md b/docs-sdk/TEMPLATE.md index 841851c6..fd12862d 100644 --- a/docs-sdk/TEMPLATE.md +++ b/docs-sdk/TEMPLATE.md @@ -7,7 +7,7 @@ Use this exact HTML structure for every page. Replace PLACEHOLDERS in CAPS. ```html - + diff --git a/docs-sdk/pages/agentic-loop.html b/docs-sdk/pages/agentic-loop.html index 92b92af8..8975445b 100644 --- a/docs-sdk/pages/agentic-loop.html +++ b/docs-sdk/pages/agentic-loop.html @@ -29,7 +29,7 @@
    -
    -

    deals

    -

    Configuration for the peer-to-peer deals/escrow system.

    -
    - - - - - - - -
    KeyTypeDefaultDescription
    deals.enabledbooleantrueEnable the deals module.
    deals.expiry_secondsnumber120Time before an unaccepted deal expires.
    deals.buy_max_floor_percentnumber95Maximum price as a percentage of floor for buy deals.
    deals.sell_min_floor_percentnumber105Minimum price as a percentage of floor for sell deals.
    -
    -

    heartbeat

    Periodic timer that triggers the agent to check for pending tasks.

    @@ -271,10 +257,6 @@

    Complete Example

    embedding: provider: "local" -deals: - enabled: true - expiry_seconds: 120 - webui: enabled: false port: 7777 diff --git a/docs-sdk/pages/deploy-docker.html b/docs-sdk/pages/deploy-docker.html index 057fb9fb..ae98f84e 100644 --- a/docs-sdk/pages/deploy-docker.html +++ b/docs-sdk/pages/deploy-docker.html @@ -29,7 +29,7 @@
    @@ -83,7 +83,7 @@

    Advanced Capabilities

    - + @@ -108,7 +108,7 @@

    User Mode vs Bot Mode

    - + @@ -123,11 +123,11 @@

    User Mode vs Bot Mode

    Prerequisites

    • Node.js 22.22.2+ LTS: Download (Node 24 requires 24.15.0+)
    • -
    • LLM API Key: Anthropic (recommended), OpenAI, Google, xAI, Groq, OpenRouter, Moonshot, Mistral, Cerebras, ZAI, MiniMax, Hugging Face, Cocoon, or Local
    • +
    • LLM credentials: An API key for a supported hosted provider, or local CLI credentials for Codex/Grok Build; Gocoon and Local are keyless
    • Telegram Account: Dedicated account recommended for security
    • Telegram API Credentials: From my.telegram.org/apps
    • Your Telegram User ID: Message @userinfobot
    • -
    • Bot Token (optional): From @BotFather for inline bot features (deals)
    • +
    • Bot Token (optional in user mode): From @BotFather for plugin inline cards and callbacks; required in bot mode

    Security Warning: The agent will have full control over the Telegram account. Use a dedicated account, not your main one.

    diff --git a/docs-sdk/pages/installation.html b/docs-sdk/pages/installation.html index ec188336..3246794c 100644 --- a/docs-sdk/pages/installation.html +++ b/docs-sdk/pages/installation.html @@ -29,7 +29,7 @@ - +
    CapabilityDescription
    Multi-Provider LLM15 providers, hot-swap from dashboard or CLI
    Multi-Provider LLM16 providers, hot-swap from dashboard or CLI
    RAG + Hybrid SearchVector (sqlite-vec) + keyword (FTS5) fused search
    Auto-CompactionAI summarizes old context, saves to memory/*.md
    Observation MaskingCompresses old tool results, saves ~90% context
    AuthPhone + api_id + api_hashBot token from @BotFather
    ProtocolMTProto (GramJS)Bot API (Grammy)
    Tools135+67 (11 Telegram + 56 non-Telegram)
    Tools128 base, up to 133 with optional system modulesRegistry filtered automatically by bot-compatible capabilities
    RiskAccount ban possibleNo ban risk
    Dialogs/HistoryFull accessNot available
    Media sendingAll typesPhotos only (v1)
    Telegram AccountDedicated account recommended (agent has full control)
    Telegram API Credentialsapi_id + api_hash from my.telegram.org/apps
    Telegram User IDMessage @userinfobot to get yours
    Bot Token (optional)From @BotFather: required for the deals system
    Bot Token (optional in user mode)From @BotFather for plugin inline cards and callbacks; required in bot mode
    TonAPI Key (optional)From @AntTonTechBot mini app: higher rate limits
    @@ -83,7 +83,7 @@

    Setup

  • Access Policies: DM policy (open/allowlist/pairing/disabled), group policy, mention rules
  • Admin: Your Telegram User ID, owner name/username
  • TON Wallet: Generates a W5R1 wallet with 24-word mnemonic
  • -
  • Deals (optional): Bot token for the deals system, trading thresholds
  • +
  • Optional integrations: Bot token, TonAPI, Toncenter, and Tavily credentials
  • Workspace: Creates template files (SOUL.md, IDENTITY.md, STRATEGY.md, etc.)
  • @@ -118,7 +118,7 @@

    Start

    Knowledge indexed Telegram: @your_agent connected TON Blockchain: connected -Agent is ready! (124 tools) +Agent is ready! (128 base tools)

    Verify: Send /ping to your agent on Telegram.

@@ -133,7 +133,6 @@

Admin Commands

/task <description>Give agent a task to execute /clearClear current chat history /model <name>Switch LLM model at runtime - /strategyView or change trading thresholds /walletShow wallet address and balance /policy dm openChange DM policy at runtime /pause / /resumePause/resume agent responses diff --git a/docs-sdk/pages/memory-system.html b/docs-sdk/pages/memory-system.html index 06d802f4..6512c28a 100644 --- a/docs-sdk/pages/memory-system.html +++ b/docs-sdk/pages/memory-system.html @@ -29,7 +29,7 @@