diff --git a/.env.example b/.env.example index 6f36768..59eee18 100644 --- a/.env.example +++ b/.env.example @@ -91,13 +91,18 @@ RETRY_DELAY_MS=2000 # Delay before copying a trade in milliseconds COPY_DELAY_MS=0 -# === TELEGRAM NOTIFICATIONS === -# Get token from @BotFather on Telegram +# === TELEGRAM BOT === +# Get token from @BotFather on Telegram (REQUIRED for multi-user mode) TELEGRAM_BOT_TOKEN= -# Get your chat ID from @userinfobot on Telegram +# Get your chat ID from @userinfobot on Telegram (single-user mode only) TELEGRAM_CHAT_ID= +# === MULTI-USER ENCRYPTION === +# Master key for encrypting user private keys (recommended: generate a random 32+ char string) +# If not set, falls back to TELEGRAM_BOT_TOKEN (less secure) +ENCRYPTION_KEY= + # === HEALTH CHECK ENDPOINT === # Enable HTTP health check server HEALTH_CHECK_ENABLED=false diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..82c724c --- /dev/null +++ b/PLAN.md @@ -0,0 +1,62 @@ +# Polybot Multi-User Telegram Bot - Implementation Plan + +## Overview +Transform Polybot from a single-user CLI bot into a multi-user Telegram bot where each user can: +- Connect their Polymarket wallet (private key + wallet address) +- Configure their own trading settings via Telegram buttons +- Follow different trader wallets independently +- Manage risk settings (slippage, caps, stop-loss, take-profit, etc.) + +## Architecture Changes + +### 1. User Database (`src/db/userDb.ts`) +- SQLite database (via `better-sqlite3`) for persistent multi-user storage +- Tables: `users`, `user_wallets`, `user_settings`, `user_tracked_wallets`, `user_positions`, `user_trades` +- Each user identified by Telegram chat ID +- Encrypted private key storage (AES-256-GCM with master key from env) + +### 2. Telegram Bot Rewrite (`src/telegram/`) +- **bot.ts** - Main Telegram bot with callback query routing +- **menus.ts** - Inline keyboard menu builders +- **handlers/** - Command and callback handlers: + - `start.ts` - /start welcome + main menu + - `wallet.ts` - Connect/disconnect/view wallets + - `follow.ts` - Add/remove/list tracked wallets + - `settings.ts` - Trading settings (slippage, caps, sizing, risk) + - `positions.ts` - View positions, manual sell + - `stats.ts` - Trading stats and P&L + - `trading.ts` - Start/stop/pause trading + +### 3. Per-User Bot Instances (`src/services/userBotManager.ts`) +- Manages a lightweight bot instance per active user +- Each instance runs its own TradeMonitor, Trader, RiskManager +- Lazy initialization (only when user starts trading) +- Graceful shutdown per user + +### 4. Menu Flow +``` +/start โ†’ Main Menu + โ”œโ”€โ”€ ๐Ÿ”‘ Wallets โ†’ Connect Wallet | View Wallets | Disconnect + โ”œโ”€โ”€ ๐Ÿ‘ Follow Traders โ†’ Add Wallet | List | Remove + โ”œโ”€โ”€ โš™๏ธ Settings + โ”‚ โ”œโ”€โ”€ Position Sizing โ†’ Account Size | Max Position | Min Trade | Max % + โ”‚ โ”œโ”€โ”€ Risk Management โ†’ Stop Loss | Take Profit | Trailing Stop | Daily Loss Limit + โ”‚ โ”œโ”€โ”€ Trade Filters โ†’ Min/Max Probability | Blacklist | Whitelist + โ”‚ โ”œโ”€โ”€ Execution โ†’ Slippage | Copy Sells | Conflict Strategy + โ”‚ โ””โ”€โ”€ Time Exits โ†’ Max Hold Time + โ”œโ”€โ”€ ๐Ÿ“Š Positions โ†’ List | Sell Position | Sell All + โ”œโ”€โ”€ ๐Ÿ“ˆ Stats โ†’ Overview | Per-Trader | Reset + โ”œโ”€โ”€ โ–ถ๏ธ Start Trading / โธ Pause / โน Stop + โ””โ”€โ”€ โ“ Help +``` + +## Implementation Steps + +1. Add dependencies (better-sqlite3, encryption) +2. Create database schema and UserDb class +3. Build Telegram menu system with inline keyboards +4. Implement wallet connection flow (encrypted storage) +5. Implement settings management via Telegram +6. Create UserBotManager for per-user bot instances +7. Wire everything together +8. Update config for multi-user mode diff --git a/package-lock.json b/package-lock.json index 6f2bf09..b7f2b79 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@scure/bip39": "2.0.1", "@types/node": "25.3.5", "axios": "1.13.6", + "better-sqlite3": "^12.8.0", "dotenv": "17.3.1", "node-telegram-bot-api": "^0.67.0", "tsx": "4.21.0", @@ -22,6 +23,7 @@ "ws": "8.19.0" }, "devDependencies": { + "@types/better-sqlite3": "^7.6.13", "@types/node-telegram-bot-api": "^0.64.0", "@types/ws": "8.18.1" } @@ -665,6 +667,16 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/caseless": { "version": "0.12.5", "resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.5.tgz", @@ -906,6 +918,26 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/bcrypt-pbkdf": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", @@ -915,6 +947,29 @@ "tweetnacl": "^0.14.3" } }, + "node_modules/better-sqlite3": { + "version": "12.8.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.8.0.tgz", + "integrity": "sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, "node_modules/bl": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/bl/-/bl-1.2.3.tgz", @@ -937,6 +992,30 @@ "integrity": "sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg==", "license": "MIT" }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -990,6 +1069,12 @@ "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", "license": "Apache-2.0" }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -1080,6 +1165,30 @@ "ms": "^2.1.1" } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -1123,6 +1232,15 @@ "node": ">=0.4.0" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/dotenv": { "version": "17.3.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", @@ -1357,6 +1475,15 @@ "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", "license": "MIT" }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -1395,6 +1522,12 @@ "node": ">=0.10.0" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, "node_modules/follow-redirects": { "version": "1.15.11", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", @@ -1455,6 +1588,12 @@ "node": ">= 6" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1591,6 +1730,12 @@ "assert-plus": "^1.0.0" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, "node_modules/globalthis": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", @@ -1736,12 +1881,38 @@ "node": ">=0.10" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -2199,12 +2370,57 @@ "node": ">= 0.6" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/node-abi": { + "version": "3.89.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", + "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/node-telegram-bot-api": { "version": "0.67.0", "resolved": "https://registry.npmjs.org/node-telegram-bot-api/-/node-telegram-bot-api-0.67.0.tgz", @@ -2389,6 +2605,43 @@ "node": ">= 0.4" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prebuild-install/node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -2453,6 +2706,21 @@ "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", "license": "MIT" }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, "node_modules/readable-stream": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", @@ -2751,6 +3019,18 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -2869,6 +3149,51 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/sshpk": { "version": "1.18.0", "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", @@ -2987,6 +3312,78 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-fs/node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar-stream/node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/tar-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/tldts": { "version": "6.1.86", "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", diff --git a/package.json b/package.json index 8453bfb..9f702e1 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "build": "tsc", "start": "node dist/bot.js", "bot": "tsx src/bot.ts", + "telegram": "tsx src/multiUserBot.ts", "positions": "tsx src/index.ts positions", "trades": "tsx src/index.ts trades", "status": "tsx src/scripts/status.ts", @@ -56,6 +57,7 @@ "@scure/bip39": "2.0.1", "@types/node": "25.3.5", "axios": "1.13.6", + "better-sqlite3": "^12.8.0", "dotenv": "17.3.1", "node-telegram-bot-api": "^0.67.0", "tsx": "4.21.0", @@ -64,6 +66,7 @@ "ws": "8.19.0" }, "devDependencies": { + "@types/better-sqlite3": "^7.6.13", "@types/node-telegram-bot-api": "^0.64.0", "@types/ws": "8.18.1" } diff --git a/src/db/userDb.ts b/src/db/userDb.ts new file mode 100644 index 0000000..4ed5ada --- /dev/null +++ b/src/db/userDb.ts @@ -0,0 +1,378 @@ +import Database from 'better-sqlite3'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'crypto'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const DB_PATH = join(__dirname, '../../data/users.db'); + +// Encryption helpers for private keys +const ALGORITHM = 'aes-256-gcm'; + +function getEncryptionKey(): Buffer { + const masterKey = process.env.ENCRYPTION_KEY || process.env.TELEGRAM_BOT_TOKEN || 'polybot-default-key-change-me'; + return scryptSync(masterKey, 'polybot-salt', 32); +} + +function encrypt(text: string): string { + const key = getEncryptionKey(); + const iv = randomBytes(16); + const cipher = createCipheriv(ALGORITHM, key, iv); + let encrypted = cipher.update(text, 'utf8', 'hex'); + encrypted += cipher.final('hex'); + const authTag = cipher.getAuthTag().toString('hex'); + return `${iv.toString('hex')}:${authTag}:${encrypted}`; +} + +function decrypt(encryptedText: string): string { + const key = getEncryptionKey(); + const [ivHex, authTagHex, encrypted] = encryptedText.split(':'); + const iv = Buffer.from(ivHex, 'hex'); + const authTag = Buffer.from(authTagHex, 'hex'); + const decipher = createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(authTag); + let decrypted = decipher.update(encrypted, 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + return decrypted; +} + +export interface UserRecord { + chatId: string; + username: string | null; + createdAt: string; + isActive: boolean; +} + +export interface UserWallet { + id: number; + chatId: string; + walletAddress: string; + encryptedPrivateKey: string; + funderAddress: string | null; + signatureType: number; + alias: string; + isDefault: boolean; + createdAt: string; +} + +export interface UserTrackedWallet { + id: number; + chatId: string; + walletAddress: string; + alias: string; + allocation: number; + enabled: boolean; +} + +export interface UserSettings { + chatId: string; + enableTrading: boolean; + dryRun: boolean; + userAccountSize: number; + maxPositionSize: number; + minTradeSize: number; + maxPercentagePerTrade: number; + minProbability: number; + maxProbability: number; + maxOpenPositions: number; + stopLossPercent: number; + takeProfitPercent: number; + trailingStopPercent: number; + orderSlippagePercent: number; + copySells: boolean; + maxHoldTimeHours: number; + dailyLossLimit: number; + maxPositionValue: number; + conflictStrategy: string; + blacklistKeywords: string; + whitelistKeywords: string; +} + +const DEFAULT_SETTINGS: Omit = { + enableTrading: false, + dryRun: true, + userAccountSize: 30, + maxPositionSize: 10, + minTradeSize: 1, + maxPercentagePerTrade: 20, + minProbability: 0.05, + maxProbability: 0.95, + maxOpenPositions: 0, + stopLossPercent: -25, + takeProfitPercent: 100, + trailingStopPercent: 15, + orderSlippagePercent: 15, + copySells: true, + maxHoldTimeHours: 0, + dailyLossLimit: 0, + maxPositionValue: 0, + conflictStrategy: 'first', + blacklistKeywords: '', + whitelistKeywords: '', +}; + +export class UserDb { + private db: Database.Database; + + constructor() { + this.db = new Database(DB_PATH); + this.db.pragma('journal_mode = WAL'); + this.db.pragma('foreign_keys = ON'); + this.initSchema(); + } + + private initSchema(): void { + this.db.exec(` + CREATE TABLE IF NOT EXISTS users ( + chat_id TEXT PRIMARY KEY, + username TEXT, + created_at TEXT DEFAULT (datetime('now')), + is_active INTEGER DEFAULT 1 + ); + + CREATE TABLE IF NOT EXISTS user_wallets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + chat_id TEXT NOT NULL REFERENCES users(chat_id), + wallet_address TEXT NOT NULL, + encrypted_private_key TEXT NOT NULL, + funder_address TEXT, + signature_type INTEGER DEFAULT 2, + alias TEXT DEFAULT 'Main', + is_default INTEGER DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS user_tracked_wallets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + chat_id TEXT NOT NULL REFERENCES users(chat_id), + wallet_address TEXT NOT NULL, + alias TEXT NOT NULL, + allocation INTEGER DEFAULT 100, + enabled INTEGER DEFAULT 1, + UNIQUE(chat_id, wallet_address) + ); + + CREATE TABLE IF NOT EXISTS user_settings ( + chat_id TEXT PRIMARY KEY REFERENCES users(chat_id), + enable_trading INTEGER DEFAULT 0, + dry_run INTEGER DEFAULT 1, + user_account_size REAL DEFAULT 30, + max_position_size REAL DEFAULT 10, + min_trade_size REAL DEFAULT 1, + max_percentage_per_trade REAL DEFAULT 20, + min_probability REAL DEFAULT 0.05, + max_probability REAL DEFAULT 0.95, + max_open_positions INTEGER DEFAULT 0, + stop_loss_percent REAL DEFAULT -25, + take_profit_percent REAL DEFAULT 100, + trailing_stop_percent REAL DEFAULT 15, + order_slippage_percent REAL DEFAULT 15, + copy_sells INTEGER DEFAULT 1, + max_hold_time_hours REAL DEFAULT 0, + daily_loss_limit REAL DEFAULT 0, + max_position_value REAL DEFAULT 0, + conflict_strategy TEXT DEFAULT 'first', + blacklist_keywords TEXT DEFAULT '', + whitelist_keywords TEXT DEFAULT '' + ); + + CREATE TABLE IF NOT EXISTS user_state ( + chat_id TEXT PRIMARY KEY REFERENCES users(chat_id), + state_json TEXT DEFAULT '{}' + ); + `); + } + + // ===== Users ===== + + ensureUser(chatId: string, username?: string): void { + this.db.prepare(` + INSERT INTO users (chat_id, username) VALUES (?, ?) + ON CONFLICT(chat_id) DO UPDATE SET username = COALESCE(?, username) + `).run(chatId, username || null, username || null); + + // Ensure settings row exists + this.db.prepare(` + INSERT OR IGNORE INTO user_settings (chat_id) VALUES (?) + `).run(chatId); + + // Ensure state row exists + this.db.prepare(` + INSERT OR IGNORE INTO user_state (chat_id) VALUES (?) + `).run(chatId); + } + + getAllActiveUsers(): UserRecord[] { + return this.db.prepare(` + SELECT chat_id as chatId, username, created_at as createdAt, is_active as isActive + FROM users WHERE is_active = 1 + `).all() as UserRecord[]; + } + + // ===== Wallets ===== + + addWallet(chatId: string, privateKey: string, walletAddress: string, funderAddress?: string, alias?: string, signatureType?: number): number { + const encryptedKey = encrypt(privateKey); + const isDefault = this.getWallets(chatId).length === 0 ? 1 : 0; + + const result = this.db.prepare(` + INSERT INTO user_wallets (chat_id, wallet_address, encrypted_private_key, funder_address, signature_type, alias, is_default) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run(chatId, walletAddress.toLowerCase(), encryptedKey, funderAddress || null, signatureType ?? 2, alias || 'Main', isDefault); + + return result.lastInsertRowid as number; + } + + getWallets(chatId: string): UserWallet[] { + return this.db.prepare(` + SELECT id, chat_id as chatId, wallet_address as walletAddress, + encrypted_private_key as encryptedPrivateKey, funder_address as funderAddress, + signature_type as signatureType, alias, is_default as isDefault, created_at as createdAt + FROM user_wallets WHERE chat_id = ? + `).all(chatId) as UserWallet[]; + } + + getDefaultWallet(chatId: string): UserWallet | undefined { + return this.db.prepare(` + SELECT id, chat_id as chatId, wallet_address as walletAddress, + encrypted_private_key as encryptedPrivateKey, funder_address as funderAddress, + signature_type as signatureType, alias, is_default as isDefault, created_at as createdAt + FROM user_wallets WHERE chat_id = ? AND is_default = 1 + `).get(chatId) as UserWallet | undefined; + } + + decryptPrivateKey(encryptedKey: string): string { + return decrypt(encryptedKey); + } + + removeWallet(chatId: string, walletId: number): boolean { + const result = this.db.prepare(` + DELETE FROM user_wallets WHERE id = ? AND chat_id = ? + `).run(walletId, chatId); + return result.changes > 0; + } + + setDefaultWallet(chatId: string, walletId: number): void { + this.db.prepare(`UPDATE user_wallets SET is_default = 0 WHERE chat_id = ?`).run(chatId); + this.db.prepare(`UPDATE user_wallets SET is_default = 1 WHERE id = ? AND chat_id = ?`).run(walletId, chatId); + } + + // ===== Tracked Wallets ===== + + addTrackedWallet(chatId: string, address: string, alias: string, allocation: number = 100): number { + const result = this.db.prepare(` + INSERT INTO user_tracked_wallets (chat_id, wallet_address, alias, allocation) + VALUES (?, ?, ?, ?) + ON CONFLICT(chat_id, wallet_address) DO UPDATE SET alias = ?, allocation = ?, enabled = 1 + `).run(chatId, address.toLowerCase(), alias, allocation, alias, allocation); + return result.lastInsertRowid as number; + } + + getTrackedWallets(chatId: string): UserTrackedWallet[] { + return this.db.prepare(` + SELECT id, chat_id as chatId, wallet_address as walletAddress, alias, allocation, enabled + FROM user_tracked_wallets WHERE chat_id = ? + `).all(chatId) as UserTrackedWallet[]; + } + + removeTrackedWallet(chatId: string, walletId: number): boolean { + const result = this.db.prepare(` + DELETE FROM user_tracked_wallets WHERE id = ? AND chat_id = ? + `).run(walletId, chatId); + return result.changes > 0; + } + + toggleTrackedWallet(chatId: string, walletId: number): boolean { + this.db.prepare(` + UPDATE user_tracked_wallets SET enabled = CASE WHEN enabled = 1 THEN 0 ELSE 1 END + WHERE id = ? AND chat_id = ? + `).run(walletId, chatId); + const wallet = this.db.prepare(`SELECT enabled FROM user_tracked_wallets WHERE id = ?`).get(walletId) as any; + return wallet?.enabled === 1; + } + + // ===== Settings ===== + + getSettings(chatId: string): UserSettings { + const row = this.db.prepare(` + SELECT chat_id as chatId, enable_trading as enableTrading, dry_run as dryRun, + user_account_size as userAccountSize, max_position_size as maxPositionSize, + min_trade_size as minTradeSize, max_percentage_per_trade as maxPercentagePerTrade, + min_probability as minProbability, max_probability as maxProbability, + max_open_positions as maxOpenPositions, stop_loss_percent as stopLossPercent, + take_profit_percent as takeProfitPercent, trailing_stop_percent as trailingStopPercent, + order_slippage_percent as orderSlippagePercent, copy_sells as copySells, + max_hold_time_hours as maxHoldTimeHours, daily_loss_limit as dailyLossLimit, + max_position_value as maxPositionValue, conflict_strategy as conflictStrategy, + blacklist_keywords as blacklistKeywords, whitelist_keywords as whitelistKeywords + FROM user_settings WHERE chat_id = ? + `).get(chatId) as UserSettings | undefined; + + if (!row) { + return { chatId, ...DEFAULT_SETTINGS }; + } + + // Convert SQLite integers to booleans + return { + ...row, + enableTrading: !!row.enableTrading, + dryRun: !!row.dryRun, + copySells: !!row.copySells, + }; + } + + updateSetting(chatId: string, key: string, value: any): void { + // Map camelCase to snake_case column names + const columnMap: Record = { + enableTrading: 'enable_trading', + dryRun: 'dry_run', + userAccountSize: 'user_account_size', + maxPositionSize: 'max_position_size', + minTradeSize: 'min_trade_size', + maxPercentagePerTrade: 'max_percentage_per_trade', + minProbability: 'min_probability', + maxProbability: 'max_probability', + maxOpenPositions: 'max_open_positions', + stopLossPercent: 'stop_loss_percent', + takeProfitPercent: 'take_profit_percent', + trailingStopPercent: 'trailing_stop_percent', + orderSlippagePercent: 'order_slippage_percent', + copySells: 'copy_sells', + maxHoldTimeHours: 'max_hold_time_hours', + dailyLossLimit: 'daily_loss_limit', + maxPositionValue: 'max_position_value', + conflictStrategy: 'conflict_strategy', + blacklistKeywords: 'blacklist_keywords', + whitelistKeywords: 'whitelist_keywords', + }; + + const column = columnMap[key]; + if (!column) throw new Error(`Unknown setting: ${key}`); + + // Convert booleans to integers for SQLite + const dbValue = typeof value === 'boolean' ? (value ? 1 : 0) : value; + + this.db.prepare(`UPDATE user_settings SET ${column} = ? WHERE chat_id = ?`).run(dbValue, chatId); + } + + // ===== User State (positions, orders, stats) ===== + + getUserState(chatId: string): any { + const row = this.db.prepare(`SELECT state_json FROM user_state WHERE chat_id = ?`).get(chatId) as any; + try { + return row ? JSON.parse(row.state_json) : {}; + } catch { + return {}; + } + } + + saveUserState(chatId: string, state: any): void { + this.db.prepare(` + UPDATE user_state SET state_json = ? WHERE chat_id = ? + `).run(JSON.stringify(state), chatId); + } + + close(): void { + this.db.close(); + } +} diff --git a/src/multiUserBot.ts b/src/multiUserBot.ts new file mode 100644 index 0000000..d2b6e12 --- /dev/null +++ b/src/multiUserBot.ts @@ -0,0 +1,33 @@ +import 'dotenv/config'; +import { TelegramTradingBot } from './telegram/bot.js'; + +async function main() { + console.log('='.repeat(50)); + console.log(' Polybot - Multi-User Telegram Trading Bot'); + console.log('='.repeat(50)); + + const token = process.env.TELEGRAM_BOT_TOKEN; + if (!token) { + console.error('Error: TELEGRAM_BOT_TOKEN environment variable is required'); + console.error('Get a token from @BotFather on Telegram'); + process.exit(1); + } + + const bot = new TelegramTradingBot(token); + console.log('[Main] Telegram bot started. Send /start to your bot to begin.'); + + // Graceful shutdown + const shutdown = async () => { + console.log('\n[Main] Shutting down...'); + await bot.stop(); + process.exit(0); + }; + + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); +} + +main().catch((error) => { + console.error('[Main] Fatal error:', error); + process.exit(1); +}); diff --git a/src/services/notifier.ts b/src/services/notifier.ts index 5e68a5b..74a8980 100644 --- a/src/services/notifier.ts +++ b/src/services/notifier.ts @@ -124,8 +124,8 @@ export class TelegramNotifier { const message = ` โœ… Bot Status: ACTIVE -Uptime: ${hours}h ${minutes}m ${seconds}s -Started: ${new Date(this.startTime).toLocaleString()} +โฑ Uptime: ${hours}h ${minutes}m ${seconds}s +๐Ÿ“… Started: ${new Date(this.startTime).toLocaleString()} `.trim(); await this.sendMessage(message); @@ -145,9 +145,9 @@ export class TelegramNotifier { let message = '๐Ÿ“Š Open Positions\n\n'; positions.forEach((pos, i) => { message += `${i + 1}. ${pos.outcome}\n`; - message += ` Market: ${pos.title}\n`; - message += ` Size: ${pos.size.toFixed(4)} shares @ $${pos.avgPrice.toFixed(3)}\n`; - message += ` Value: $${(pos.size * pos.avgPrice).toFixed(2)}\n\n`; + message += ` ๐Ÿช Market: ${pos.title}\n`; + message += ` ๐Ÿ“ฆ Size: ${pos.size.toFixed(4)} shares @ $${pos.avgPrice.toFixed(3)}\n`; + message += ` ๐Ÿ’ต Value: $${(pos.size * pos.avgPrice).toFixed(2)}\n\n`; }); await this.sendMessage(message); @@ -165,11 +165,11 @@ export class TelegramNotifier { const message = ` ๐Ÿ“ˆ Trading Statistics -Total Trades: ${stats.totalTrades} -Successful: ${stats.successfulTrades} -Failed: ${stats.failedTrades} -Success Rate: ${successRate}% -Total Volume: $${stats.totalVolume.toFixed(2)} +๐Ÿ”ข Total Trades: ${stats.totalTrades} +โœ… Successful: ${stats.successfulTrades} +โŒ Failed: ${stats.failedTrades} +๐ŸŽฏ Success Rate: ${successRate}% +๐Ÿ’ฐ Total Volume: $${stats.totalVolume.toFixed(2)} `.trim(); await this.sendMessage(message); @@ -194,7 +194,7 @@ export class TelegramNotifier { return; } - await this.sendMessage(`Found ${summary.count} redeemable position(s) worth ~$${summary.totalValue.toFixed(2)}. Redeeming...`); + await this.sendMessage(`๐ŸŽฏ Found ${summary.count} redeemable position(s) worth ~$${summary.totalValue.toFixed(2)}. Redeeming...`); const results = await this.redeemer.redeemAll(); const succeeded = results.filter(r => r.success); @@ -273,13 +273,13 @@ export class TelegramNotifier { const message = ` ๐Ÿค– Available Commands -/status - Check if bot is active -/positions - Show open positions -/stats - Show trading statistics -/balance - Show balance & redeemable positions -/redeem - Redeem all resolved winning positions -/stop - Stop the bot (auto-deploy will restart) -/help - Show this help message +/status - โœ… Check if bot is active +/positions - ๐Ÿ“Š Show open positions +/stats - ๐Ÿ“ˆ Show trading statistics +/balance - ๐Ÿ’ฐ Show balance & redeemable positions +/redeem - ๐ŸŽฏ Redeem all resolved winning positions +/stop - ๐Ÿ›‘ Stop the bot (auto-deploy will restart) +/help - โ“ Show this help message `.trim(); await this.sendMessage(message); @@ -335,14 +335,14 @@ export class TelegramNotifier { const message = ` ${emoji} Trade Executed -Side: ${trade.side} -Market: ${trade.title} -Outcome: ${trade.outcome} -Amount: $${trade.amount.toFixed(2)} -Price: $${trade.price.toFixed(3)} (${(trade.price * 100).toFixed(1)}%) -${trade.size ? `Size: ${trade.size.toFixed(4)} shares` : ''} -${trade.walletAlias ? `Copied from: ${trade.walletAlias}` : ''} -${trade.orderId ? `Order ID: ${trade.orderId}` : ''} +๐Ÿ“Œ Side: ${trade.side} +๐Ÿช Market: ${trade.title} +๐ŸŽฒ Outcome: ${trade.outcome} +๐Ÿ’ต Amount: $${trade.amount.toFixed(2)} +๐Ÿ’ฒ Price: $${trade.price.toFixed(3)} (${(trade.price * 100).toFixed(1)}%) +${trade.size ? `๐Ÿ“ฆ Size: ${trade.size.toFixed(4)} shares` : ''} +${trade.walletAlias ? `๐Ÿ‘ค Copied from: ${trade.walletAlias}` : ''} +${trade.orderId ? `๐Ÿ†” Order ID: ${trade.orderId}` : ''} `.trim(); await this.sendMessage(message); @@ -355,12 +355,12 @@ ${trade.orderId ? `Order ID: ${trade.orderId}` : ''} const message = ` โŒ Trade Failed -Side: ${trade.side} -Market: ${trade.title} -Outcome: ${trade.outcome} -Amount: $${trade.amount.toFixed(2)} -${trade.walletAlias ? `Copied from: ${trade.walletAlias}` : ''} -Error: ${trade.error} +๐Ÿ“Œ Side: ${trade.side} +๐Ÿช Market: ${trade.title} +๐ŸŽฒ Outcome: ${trade.outcome} +๐Ÿ’ต Amount: $${trade.amount.toFixed(2)} +${trade.walletAlias ? `๐Ÿ‘ค Copied from: ${trade.walletAlias}` : ''} +โš ๏ธ Error: ${trade.error} `.trim(); await this.sendMessage(message); @@ -381,12 +381,12 @@ ${trade.walletAlias ? `Copied from: ${trade.walletAlias}` : ''} const message = ` ${emoji} ${typeLabel} Triggered -Market: ${trigger.title} -Outcome: ${trigger.outcome} -Entry Price: $${trigger.entryPrice.toFixed(3)} -Current Price: $${trigger.currentPrice.toFixed(3)} -P&L: ${pnlSign}${trigger.pnlPercent.toFixed(1)}% -Size: ${trigger.size.toFixed(4)} shares +๐Ÿช Market: ${trigger.title} +๐ŸŽฒ Outcome: ${trigger.outcome} +๐Ÿ“ฅ Entry Price: $${trigger.entryPrice.toFixed(3)} +๐Ÿ“Š Current Price: $${trigger.currentPrice.toFixed(3)} +๐Ÿ’น P&L: ${pnlSign}${trigger.pnlPercent.toFixed(1)}% +๐Ÿ“ฆ Size: ${trigger.size.toFixed(4)} shares ${statusLine} `.trim(); @@ -420,11 +420,11 @@ ${statusLine} const message = ` ๐Ÿš€ Polybot Started -Mode: ${modeEmoji} ${mode} -Tracking: ${config.wallets.join(', ') || 'None'} -Account Size: $${config.accountSize.toFixed(2)} -Max Per Trade: $${config.maxPositionSize.toFixed(2)} -Current Positions: ${config.positionCount} +๐Ÿ”ง Mode: ${modeEmoji} ${mode} +๐Ÿ‘ Tracking: ${config.wallets.join(', ') || 'None'} +๐Ÿ’ต Account Size: $${config.accountSize.toFixed(2)} +๐Ÿ’ฐ Max Per Trade: $${config.maxPositionSize.toFixed(2)} +๐Ÿ“ฆ Current Positions: ${config.positionCount} `.trim(); await this.sendMessage(message); @@ -448,12 +448,12 @@ ${statusLine} message += ` -Session Stats: -โ€ข Total Trades: ${stats.totalTrades} -โ€ข Successful: ${stats.successfulTrades} -โ€ข Failed: ${stats.failedTrades} -โ€ข Success Rate: ${successRate}% -โ€ข Volume: $${stats.totalVolume.toFixed(2)}`; +๐Ÿ“‹ Session Stats: +โ€ข ๐Ÿ”ข Total Trades: ${stats.totalTrades} +โ€ข โœ… Successful: ${stats.successfulTrades} +โ€ข โŒ Failed: ${stats.failedTrades} +โ€ข ๐ŸŽฏ Success Rate: ${successRate}% +โ€ข ๐Ÿ’ฐ Volume: $${stats.totalVolume.toFixed(2)}`; } await this.sendMessage(message); @@ -469,7 +469,7 @@ ${statusLine} let positionsText = ''; if (summary.positions.length === 0) { - positionsText = 'No open positions'; + positionsText = '๐Ÿ“ญ No open positions'; } else { positionsText = summary.positions.map(pos => { const pnlText = pos.pnlPercent !== undefined @@ -482,12 +482,12 @@ ${statusLine} const message = ` ๐Ÿ“Š Daily Summary -Trading Stats: -โ€ข Total Trades: ${summary.totalTrades} -โ€ข Successful: ${summary.successfulTrades} -โ€ข Failed: ${summary.failedTrades} -โ€ข Success Rate: ${successRate}% -โ€ข Volume: $${summary.totalVolume.toFixed(2)} +๐Ÿ“‹ Trading Stats: +โ€ข ๐Ÿ”ข Total Trades: ${summary.totalTrades} +โ€ข โœ… Successful: ${summary.successfulTrades} +โ€ข โŒ Failed: ${summary.failedTrades} +โ€ข ๐ŸŽฏ Success Rate: ${successRate}% +โ€ข ๐Ÿ’ฐ Volume: $${summary.totalVolume.toFixed(2)} Open Positions (${summary.positions.length}): ${positionsText} diff --git a/src/services/userBotManager.ts b/src/services/userBotManager.ts new file mode 100644 index 0000000..e91c2a4 --- /dev/null +++ b/src/services/userBotManager.ts @@ -0,0 +1,342 @@ +import { TradeMonitor } from './tradeMonitor.js'; +import { PositionSizer } from './positionSizer.js'; +import { OrderQueue, QueuedOrder } from './orderQueue.js'; +import { RiskManager } from './riskManager.js'; +import { Trader, TradeResult } from './trader.js'; +import { ClobApiClient } from '../api/clobApi.js'; +import { isMarketBlacklisted, isMarketWhitelisted } from './tradeFilter.js'; +import { UserDb, UserSettings } from '../db/userDb.js'; +import { TradeSignal, WalletConfig, CopyConfig } from '../types/index.js'; +import TelegramBot from 'node-telegram-bot-api'; + +export interface UserBotInstance { + chatId: string; + monitor: TradeMonitor | null; + trader: Trader | null; + orderQueue: OrderQueue; + positionSizer: PositionSizer; + isRunning: boolean; +} + +export class UserBotManager { + private userBots = new Map(); + private db: UserDb; + private telegramBot: TelegramBot; + + constructor(db: UserDb, telegramBot: TelegramBot) { + this.db = db; + this.telegramBot = telegramBot; + } + + isUserRunning(chatId: string): boolean { + return this.userBots.get(chatId)?.isRunning || false; + } + + getUserBot(chatId: string): UserBotInstance | undefined { + return this.userBots.get(chatId); + } + + async startUserBot(chatId: string): Promise { + if (this.userBots.get(chatId)?.isRunning) { + throw new Error('Already running'); + } + + const settings = this.db.getSettings(chatId); + const wallets = this.db.getWallets(chatId); + const trackedWallets = this.db.getTrackedWallets(chatId).filter(t => t.enabled); + + if (wallets.length === 0) throw new Error('No wallet connected'); + if (trackedWallets.length === 0) throw new Error('No traders to follow'); + + const defaultWallet = this.db.getDefaultWallet(chatId) || wallets[0]; + const privateKey = this.db.decryptPrivateKey(defaultWallet.encryptedPrivateKey); + + // Build wallet configs for TradeMonitor + const walletConfigs: WalletConfig[] = trackedWallets.map(tw => ({ + address: tw.walletAddress, + alias: tw.alias, + enabled: true, + allocation: tw.allocation, + })); + + // Create position sizer + const positionSizer = new PositionSizer({ + userAccountSize: settings.userAccountSize, + maxPositionSize: settings.maxPositionSize, + minTradeSize: settings.minTradeSize, + maxPercentage: settings.maxPercentagePerTrade, + }); + + // Create order queue + const orderQueue = new OrderQueue({ + maxConcurrent: 3, + orderDelayMs: 200, + }); + + // Create trader + const trader = new Trader({ + privateKey, + funderAddress: defaultWallet.funderAddress || undefined, + dryRun: settings.dryRun, + signatureType: defaultWallet.signatureType, + orderSlippagePercent: settings.orderSlippagePercent, + maxBuyPrice: settings.maxProbability, + }); + + await trader.initialize(); + + // Create trade monitor + const monitor = new TradeMonitor({ + wallets: walletConfigs, + pollingIntervalMs: 1000, + }); + + const instance: UserBotInstance = { + chatId, + monitor, + trader, + orderQueue, + positionSizer, + isRunning: true, + }; + + // Handle trade signals + monitor.on('trade', async (signal: TradeSignal) => { + await this.handleTradeSignal(chatId, instance, signal, settings); + }); + + // Handle order processing + orderQueue.on('process', async (order: QueuedOrder) => { + await this.executeOrder(chatId, instance, order); + }); + + this.userBots.set(chatId, instance); + + // Start monitoring + await monitor.start(); + + // Load persisted state (last seen trades) + const state = this.db.getUserState(chatId); + if (state.lastSeenTrades) { + for (const [addr, ts] of Object.entries(state.lastSeenTrades)) { + (monitor as any).lastSeenTrades?.set(addr, ts); + } + } + + await this.sendNotification(chatId, + `๐ŸŸข Trading Started!\n\n` + + `Mode: ${settings.dryRun ? '๐Ÿ”ถ Dry Run' : '๐ŸŸข LIVE'}\n` + + `๐Ÿ‘ Following: ${trackedWallets.map(t => t.alias).join(', ')}\n` + + `๐Ÿ’ฐ Max position: $${settings.maxPositionSize}` + ); + } + + async stopUserBot(chatId: string): Promise { + const instance = this.userBots.get(chatId); + if (!instance) return; + + instance.isRunning = false; + + if (instance.monitor) { + instance.monitor.stop(); + } + + // Save state before stopping + this.saveUserState(chatId, instance); + + this.userBots.delete(chatId); + + await this.sendNotification(chatId, 'โน Trading Stopped'); + } + + async stopAll(): Promise { + for (const chatId of this.userBots.keys()) { + await this.stopUserBot(chatId); + } + } + + private async handleTradeSignal( + chatId: string, + instance: UserBotInstance, + signal: TradeSignal, + settings: UserSettings + ): Promise { + const trade = signal.trade; + + // Apply filters + const blacklist = settings.blacklistKeywords ? settings.blacklistKeywords.split(',').map(k => k.trim()).filter(Boolean) : []; + const whitelist = settings.whitelistKeywords ? settings.whitelistKeywords.split(',').map(k => k.trim()).filter(Boolean) : []; + + if (blacklist.length > 0 && isMarketBlacklisted(trade.title, blacklist)) { + console.log(`[User ${chatId}] Skipping blacklisted market: ${trade.title}`); + return; + } + + if (whitelist.length > 0 && !isMarketWhitelisted(trade.title, whitelist)) { + console.log(`[User ${chatId}] Skipping non-whitelisted market: ${trade.title}`); + return; + } + + // Probability filter + const price = parseFloat(trade.price); + if (price < settings.minProbability || price > settings.maxProbability) { + console.log(`[User ${chatId}] Skipping: price ${price} outside range [${settings.minProbability}, ${settings.maxProbability}]`); + return; + } + + // Skip sells if copySells is disabled + if (trade.side === 'SELL' && !settings.copySells) { + console.log(`[User ${chatId}] Skipping sell (copySells disabled)`); + return; + } + + // Check max open positions + const state = this.db.getUserState(chatId); + const positions = state.positions || {}; + if (settings.maxOpenPositions > 0 && Object.keys(positions).length >= settings.maxOpenPositions) { + console.log(`[User ${chatId}] Skipping: max open positions reached`); + return; + } + + // Calculate position size + const trackedWallets = this.db.getTrackedWallets(chatId); + const trackerWallet = trackedWallets.find(w => w.walletAddress === trade.proxyWallet); + const allocation = trackerWallet?.allocation || 100; + const tradeUsd = parseFloat(trade.size) * parseFloat(trade.price); + const scaledAmount = Math.min( + tradeUsd * (allocation / 100), + settings.maxPositionSize, + settings.userAccountSize * (settings.maxPercentagePerTrade / 100) + ); + + if (scaledAmount < settings.minTradeSize) { + console.log(`[User ${chatId}] Skipping: scaled amount $${scaledAmount.toFixed(2)} below minimum`); + return; + } + + // Enqueue order + instance.orderQueue.enqueue( + trade, + trackerWallet?.alias || 'Unknown', + trade.proxyWallet, + scaledAmount, + ); + } + + private async executeOrder(chatId: string, instance: UserBotInstance, order: QueuedOrder): Promise { + if (!instance.trader) return; + + const trade = order.trade; + const price = parseFloat(trade.price); + + try { + let result: TradeResult; + + if (trade.side === 'BUY') { + result = await instance.trader.copyTrade(trade, order.amount); + } else { + result = await instance.trader.sellPosition( + trade.asset, + parseFloat(trade.size), + price, + trade.title + ); + } + + // Update state + const state = this.db.getUserState(chatId); + if (!state.positions) state.positions = {}; + if (!state.stats) state.stats = { totalTrades: 0, successfulTrades: 0, failedTrades: 0, totalVolume: 0, startTime: Date.now(), lastUpdateTime: Date.now() }; + if (!state.orders) state.orders = []; + + state.stats.totalTrades++; + state.stats.lastUpdateTime = Date.now(); + + if (result.success) { + state.stats.successfulTrades++; + state.stats.totalVolume += order.amount; + + if (trade.side === 'BUY') { + state.positions[trade.asset] = { + asset: trade.asset, + size: order.amount / price, + avgPrice: price, + title: trade.title, + outcome: trade.outcome, + slug: trade.slug, + entryTime: Date.now(), + walletAlias: order.walletAlias, + conditionId: trade.conditionId, + }; + } else { + delete state.positions[trade.asset]; + } + + // Notify success + const emoji = trade.side === 'BUY' ? '๐ŸŸข' : '๐Ÿ”ด'; + await this.sendNotification(chatId, + `${emoji} Trade Executed\n\n` + + `๐Ÿ“Œ Side: ${trade.side}\n` + + `๐Ÿช Market: ${trade.title}\n` + + `๐ŸŽฒ Outcome: ${trade.outcome}\n` + + `๐Ÿ’ต Amount: $${order.amount.toFixed(2)}\n` + + `๐Ÿ’ฒ Price: ${(price * 100).toFixed(1)}%\n` + + `๐Ÿ‘ค Copied from: ${order.walletAlias}` + ); + } else { + state.stats.failedTrades++; + await this.sendNotification(chatId, + `โŒ Trade Failed\n\n` + + `๐Ÿ“Œ Side: ${trade.side}\n` + + `๐Ÿช Market: ${trade.title}\n` + + `โš ๏ธ Error: ${result.error}` + ); + } + + // Keep last 100 orders + state.orders.unshift({ + id: order.id, + asset: trade.asset, + side: trade.side, + amount: order.amount, + status: result.success ? 'filled' : 'failed', + createdAt: Date.now(), + walletAlias: order.walletAlias, + }); + if (state.orders.length > 100) state.orders = state.orders.slice(0, 100); + + this.db.saveUserState(chatId, state); + } catch (error: any) { + console.error(`[User ${chatId}] Order execution error:`, error.message); + await this.sendNotification(chatId, `โŒ โš ๏ธ Order error: ${error.message}`); + } + } + + private saveUserState(chatId: string, instance: UserBotInstance): void { + try { + const state = this.db.getUserState(chatId); + // Save last seen trade timestamps + if (instance.monitor) { + const lastSeen: Record = {}; + const monitorMap = (instance.monitor as any).lastSeenTrades; + if (monitorMap) { + for (const [addr, ts] of monitorMap.entries()) { + lastSeen[addr] = ts as number; + } + } + state.lastSeenTrades = lastSeen; + } + this.db.saveUserState(chatId, state); + } catch (error: any) { + console.error(`[User ${chatId}] Failed to save state:`, error.message); + } + } + + private async sendNotification(chatId: string, message: string): Promise { + try { + await this.telegramBot.sendMessage(chatId, message, { parse_mode: 'HTML' }); + } catch (error: any) { + console.error(`[User ${chatId}] Failed to send notification:`, error.message); + } + } +} diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts new file mode 100644 index 0000000..d9f66d8 --- /dev/null +++ b/src/telegram/bot.ts @@ -0,0 +1,65 @@ +import TelegramBot from 'node-telegram-bot-api'; +import { UserDb } from '../db/userDb.js'; +import { UserBotManager } from '../services/userBotManager.js'; +import { registerStartHandler } from './handlers/start.js'; +import { registerWalletHandler } from './handlers/wallet.js'; +import { registerFollowHandler } from './handlers/follow.js'; +import { registerSettingsHandler } from './handlers/settings.js'; +import { registerPositionsHandler } from './handlers/positions.js'; +import { registerStatsHandler } from './handlers/stats.js'; +import { registerTradingHandler } from './handlers/trading.js'; +import { registerHelpHandler } from './handlers/help.js'; + +export class TelegramTradingBot { + private bot: TelegramBot; + private db: UserDb; + private botManager: UserBotManager; + + constructor(token: string) { + this.bot = new TelegramBot(token, { polling: true }); + this.db = new UserDb(); + this.botManager = new UserBotManager(this.db, this.bot); + + this.registerHandlers(); + this.setupErrorHandling(); + } + + private registerHandlers(): void { + const getBotManager = () => this.botManager; + + registerStartHandler(this.bot, this.db); + registerWalletHandler(this.bot, this.db); + registerFollowHandler(this.bot, this.db); + registerSettingsHandler(this.bot, this.db); + registerPositionsHandler(this.bot, this.db, getBotManager); + registerStatsHandler(this.bot, this.db); + registerTradingHandler(this.bot, this.db, getBotManager); + registerHelpHandler(this.bot); + } + + private setupErrorHandling(): void { + this.bot.on('polling_error', (error) => { + console.error('[TelegramBot] Polling error:', error.message); + }); + + this.bot.on('error', (error) => { + console.error('[TelegramBot] Error:', error.message); + }); + } + + async stop(): Promise { + console.log('[TelegramBot] Stopping...'); + await this.botManager.stopAll(); + await this.bot.stopPolling(); + this.db.close(); + console.log('[TelegramBot] Stopped'); + } + + getDb(): UserDb { + return this.db; + } + + getBotManager(): UserBotManager { + return this.botManager; + } +} diff --git a/src/telegram/handlers/follow.ts b/src/telegram/handlers/follow.ts new file mode 100644 index 0000000..319a6b7 --- /dev/null +++ b/src/telegram/handlers/follow.ts @@ -0,0 +1,213 @@ +import TelegramBot from 'node-telegram-bot-api'; +import { UserDb } from '../../db/userDb.js'; +import { followMenu, backButton } from '../menus.js'; + +const followFlowState = new Map(); + +export function registerFollowHandler(bot: TelegramBot, db: UserDb): void { + + bot.on('callback_query', async (query) => { + if (!query.data || !query.message) return; + const chatId = query.message.chat.id.toString(); + + if (query.data === 'menu:follow') { + const tracked = db.getTrackedWallets(chatId); + await bot.editMessageText( + '๐Ÿ‘ Follow Traders\n\n' + + 'Add Polymarket wallets to copy-trade from.\n' + + `Currently following: ${tracked.filter(t => t.enabled).length} trader(s)`, + { + chat_id: chatId, + message_id: query.message.message_id, + parse_mode: 'HTML', + reply_markup: followMenu(tracked.length > 0), + } + ); + await bot.answerCallbackQuery(query.id); + } + + if (query.data === 'follow:add') { + followFlowState.set(chatId, { step: 'address' }); + await bot.editMessageText( + 'โž• Add Trader\n\n' + + 'Send the Polymarket wallet address to follow (0x...):\n\n' + + 'You can find trader addresses on polymarket.com/profile/[address]', + { + chat_id: chatId, + message_id: query.message.message_id, + parse_mode: 'HTML', + reply_markup: backButton('menu:follow'), + } + ); + await bot.answerCallbackQuery(query.id); + } + + if (query.data === 'follow:list') { + const tracked = db.getTrackedWallets(chatId); + if (tracked.length === 0) { + await bot.editMessageText('๐Ÿ“ญ No traders being followed yet.', { + chat_id: chatId, + message_id: query.message.message_id, + reply_markup: followMenu(false), + }); + } else { + let text = '๐Ÿ“‹ Followed Traders\n\n'; + tracked.forEach((t, i) => { + const short = `${t.walletAddress.slice(0, 6)}...${t.walletAddress.slice(-4)}`; + const status = t.enabled ? 'โœ…' : 'โŒ'; + text += `${i + 1}. ${status} ${t.alias}\n`; + text += ` ๐Ÿ“ Address: ${short}\n`; + text += ` ๐Ÿ“Š Allocation: ${t.allocation}%\n\n`; + }); + + const buttons = tracked.map(t => [ + { + text: `${t.enabled ? 'โŒ Disable' : 'โœ… Enable'} ${t.alias}`, + callback_data: `follow:toggle:${t.id}`, + }, + ]); + buttons.push([{ text: 'โฌ…๏ธ Back', callback_data: 'menu:follow' }]); + + await bot.editMessageText(text, { + chat_id: chatId, + message_id: query.message.message_id, + parse_mode: 'HTML', + reply_markup: { inline_keyboard: buttons }, + }); + } + await bot.answerCallbackQuery(query.id); + } + + if (query.data?.startsWith('follow:toggle:')) { + const id = parseInt(query.data.split(':')[2]); + const enabled = db.toggleTrackedWallet(chatId, id); + await bot.answerCallbackQuery(query.id, { text: enabled ? 'โœ… Trader enabled' : 'โŒ Trader disabled' }); + // Refresh the list + const tracked = db.getTrackedWallets(chatId); + let text = '๐Ÿ“‹ Followed Traders\n\n'; + tracked.forEach((t, i) => { + const short = `${t.walletAddress.slice(0, 6)}...${t.walletAddress.slice(-4)}`; + const status = t.enabled ? 'โœ…' : 'โŒ'; + text += `${i + 1}. ${status} ${t.alias}\n`; + text += ` ๐Ÿ“ Address: ${short}\n`; + text += ` ๐Ÿ“Š Allocation: ${t.allocation}%\n\n`; + }); + const buttons = tracked.map(t => [ + { + text: `${t.enabled ? 'โŒ Disable' : 'โœ… Enable'} ${t.alias}`, + callback_data: `follow:toggle:${t.id}`, + }, + ]); + buttons.push([{ text: 'โฌ…๏ธ Back', callback_data: 'menu:follow' }]); + await bot.editMessageText(text, { + chat_id: chatId, + message_id: query.message.message_id, + parse_mode: 'HTML', + reply_markup: { inline_keyboard: buttons }, + }); + } + + if (query.data === 'follow:remove_list') { + const tracked = db.getTrackedWallets(chatId); + const buttons = tracked.map(t => { + const short = `${t.walletAddress.slice(0, 6)}...${t.walletAddress.slice(-4)}`; + return [{ text: `๐Ÿ—‘ ${t.alias} (${short})`, callback_data: `follow:remove:${t.id}` }]; + }); + buttons.push([{ text: 'โฌ…๏ธ Back', callback_data: 'menu:follow' }]); + await bot.editMessageText('๐Ÿ—‘ Remove Trader\n\nSelect trader to remove:', { + chat_id: chatId, + message_id: query.message.message_id, + parse_mode: 'HTML', + reply_markup: { inline_keyboard: buttons }, + }); + await bot.answerCallbackQuery(query.id); + } + + if (query.data?.startsWith('follow:remove:')) { + const id = parseInt(query.data.split(':')[2]); + db.removeTrackedWallet(chatId, id); + const tracked = db.getTrackedWallets(chatId); + await bot.editMessageText('โœ… Trader removed.', { + chat_id: chatId, + message_id: query.message.message_id, + parse_mode: 'HTML', + reply_markup: followMenu(tracked.length > 0), + }); + await bot.answerCallbackQuery(query.id); + } + }); + + // Handle text messages for follow flow + bot.on('message', async (msg) => { + if (!msg.text || msg.text.startsWith('/')) return; + const chatId = msg.chat.id.toString(); + const state = followFlowState.get(chatId); + if (!state) return; + + if (state.step === 'address') { + const addr = msg.text.trim(); + if (!addr.startsWith('0x') || addr.length !== 42) { + await bot.sendMessage(chatId, 'โŒ Invalid address. Must be 42 characters starting with 0x.\n\nTry again:', { + reply_markup: backButton('menu:follow'), + }); + return; + } + state.address = addr; + state.step = 'alias'; + await bot.sendMessage(chatId, + `โœ… Address: ${addr.slice(0, 6)}...${addr.slice(-4)}\n\n` + + `โœ๏ธ Send a name/alias for this trader (e.g., "Whale1", "TopTrader"):`, + { parse_mode: 'HTML', reply_markup: backButton('menu:follow') } + ); + return; + } + + if (state.step === 'alias') { + const alias = msg.text.trim().slice(0, 20); + state.step = 'allocation'; + followFlowState.set(chatId, { ...state, step: 'allocation' }); + await bot.sendMessage(chatId, + `๐Ÿท Name: ${alias}\n\n` + + `๐Ÿ“Š Send allocation percentage (1-100):\n` + + `This controls what % of calculated position size to use for this trader.\n` + + `(100 = full size, 50 = half size)`, + { parse_mode: 'HTML', reply_markup: backButton('menu:follow') } + ); + // Store alias temporarily + (state as any).alias = alias; + return; + } + + if (state.step === 'allocation') { + const allocation = parseInt(msg.text.trim()); + if (isNaN(allocation) || allocation < 1 || allocation > 100) { + await bot.sendMessage(chatId, 'โŒ Invalid allocation. Enter a number between 1 and 100:', { + reply_markup: backButton('menu:follow'), + }); + return; + } + + try { + db.addTrackedWallet(chatId, state.address!, (state as any).alias, allocation); + followFlowState.delete(chatId); + + const tracked = db.getTrackedWallets(chatId); + await bot.sendMessage(chatId, + `โœ… Trader Added!\n\n` + + `๐Ÿท Name: ${(state as any).alias}\n` + + `๐Ÿ“ Address: ${state.address!.slice(0, 6)}...${state.address!.slice(-4)}\n` + + `๐Ÿ“Š Allocation: ${allocation}%`, + { + parse_mode: 'HTML', + reply_markup: followMenu(tracked.length > 0), + } + ); + } catch (err: any) { + await bot.sendMessage(chatId, `โŒ Error: ${err.message}`, { + reply_markup: backButton('menu:follow'), + }); + } + return; + } + }); +} diff --git a/src/telegram/handlers/help.ts b/src/telegram/handlers/help.ts new file mode 100644 index 0000000..7dbcc95 --- /dev/null +++ b/src/telegram/handlers/help.ts @@ -0,0 +1,70 @@ +import TelegramBot from 'node-telegram-bot-api'; +import { mainMenu } from '../menus.js'; + +export function registerHelpHandler(bot: TelegramBot): void { + + bot.on('callback_query', async (query) => { + if (!query.data || !query.message) return; + const chatId = query.message.chat.id.toString(); + + if (query.data === 'menu:help') { + const helpText = ` +โ“ Polybot Help + +Getting Started: +1. ๐Ÿ”‘ Connect your Polymarket wallet +2. ๐Ÿ‘ Add traders to follow +3. โš™๏ธ Configure your settings +4. โ–ถ๏ธ Start trading! + +๐Ÿ”ง How it works: +โ€ข ๐Ÿ“ก Monitors followed traders in real-time +โ€ข ๐Ÿ”„ Copies their trades to your wallet +โ€ข โš–๏ธ Scales position sizes to your account +โ€ข ๐Ÿ›ก Manages exits via stop-loss, take-profit, trailing stops + +๐Ÿ’ผ Wallet Types: +โ€ข ๐ŸฆŠ Browser - MetaMask/Rabby with Polymarket proxy +โ€ข ๐Ÿ“ง Email - Magic Link exported key +โ€ข ๐Ÿ” EOA - Standalone wallet + +โš™๏ธ Key Settings: +โ€ข ๐ŸŽš Slippage - Max price deviation allowed +โ€ข ๐Ÿ”ป Stop Loss - Auto-sell at loss threshold +โ€ข ๐ŸŽฏ Take Profit - Auto-sell at profit target +โ€ข ๐Ÿ“‰ Trailing Stop - Lock in profits as price rises +โ€ข ๐Ÿงช Dry Run - Test without real trades + +๐Ÿ“‹ Commands: +/start - ๐Ÿ  Main menu +/menu - ๐Ÿ“ฑ Show menu +/help - โ“ This help message + +๐Ÿ’ก Tips: +โ€ข ๐Ÿงช Start with Dry Run mode ON +โ€ข ๐Ÿค Use small position sizes initially +โ€ข ๐ŸŒ Diversify across multiple traders +โ€ข ๐Ÿ›ก Set stop-loss to protect capital + `.trim(); + + await bot.editMessageText(helpText, { + chat_id: chatId, + message_id: query.message.message_id, + parse_mode: 'HTML', + reply_markup: mainMenu(), + }); + await bot.answerCallbackQuery(query.id); + } + + // Handle main menu callback + if (query.data === 'menu:main') { + await bot.editMessageText('๐Ÿค– Main Menu', { + chat_id: chatId, + message_id: query.message.message_id, + parse_mode: 'HTML', + reply_markup: mainMenu(), + }); + await bot.answerCallbackQuery(query.id); + } + }); +} diff --git a/src/telegram/handlers/positions.ts b/src/telegram/handlers/positions.ts new file mode 100644 index 0000000..5226797 --- /dev/null +++ b/src/telegram/handlers/positions.ts @@ -0,0 +1,197 @@ +import TelegramBot from 'node-telegram-bot-api'; +import { UserDb } from '../../db/userDb.js'; +import { positionsMenu, confirmMenu, backButton } from '../menus.js'; +import type { UserBotManager } from '../../services/userBotManager.js'; + +export function registerPositionsHandler(bot: TelegramBot, db: UserDb, getBotManager: () => UserBotManager): void { + + bot.on('callback_query', async (query) => { + if (!query.data || !query.message) return; + const chatId = query.message.chat.id.toString(); + + if (query.data === 'menu:positions') { + const state = db.getUserState(chatId); + const positions = state.positions ? Object.values(state.positions) : []; + const hasPositions = positions.length > 0; + + await bot.editMessageText( + `๐Ÿ“Š Positions\n\nOpen positions: ${positions.length}`, + { + chat_id: chatId, + message_id: query.message.message_id, + parse_mode: 'HTML', + reply_markup: positionsMenu(hasPositions), + } + ); + await bot.answerCallbackQuery(query.id); + } + + if (query.data === 'positions:list') { + const state = db.getUserState(chatId); + const positions = state.positions ? Object.entries(state.positions) : []; + + if (positions.length === 0) { + await bot.editMessageText('๐Ÿ“Š No open positions', { + chat_id: chatId, + message_id: query.message.message_id, + parse_mode: 'HTML', + reply_markup: positionsMenu(false), + }); + } else { + let text = '๐Ÿ“Š Open Positions\n\n'; + (positions as [string, any][]).forEach(([asset, pos], i) => { + text += `${i + 1}. ${pos.outcome}\n`; + text += ` ๐Ÿช Market: ${pos.title?.slice(0, 40) || 'Unknown'}\n`; + text += ` ๐Ÿ“ฆ Size: ${pos.size?.toFixed(4) || '?'} shares @ $${pos.avgPrice?.toFixed(3) || '?'}\n`; + text += ` ๐Ÿ’ต Value: $${((pos.size || 0) * (pos.avgPrice || 0)).toFixed(2)}\n`; + if (pos.walletAlias) text += ` ๐Ÿ‘ค Copied from: ${pos.walletAlias}\n`; + text += '\n'; + }); + + await bot.editMessageText(text, { + chat_id: chatId, + message_id: query.message.message_id, + parse_mode: 'HTML', + reply_markup: positionsMenu(true), + }); + } + await bot.answerCallbackQuery(query.id); + } + + if (query.data === 'positions:sell_list') { + const state = db.getUserState(chatId); + const positions = state.positions ? Object.entries(state.positions) : []; + + if (positions.length === 0) { + await bot.answerCallbackQuery(query.id, { text: '๐Ÿ“ญ No positions to sell' }); + return; + } + + const buttons = (positions as [string, any][]).map(([asset, pos]) => [{ + text: `๐Ÿ”ด ${pos.outcome} (${pos.size?.toFixed(2) || '?'} shares)`, + callback_data: `positions:sell:${asset.slice(0, 40)}`, + }]); + buttons.push([{ text: 'โฌ…๏ธ Back', callback_data: 'menu:positions' }]); + + await bot.editMessageText('๐Ÿ”ด Sell Position\n\nSelect position to sell:', { + chat_id: chatId, + message_id: query.message.message_id, + parse_mode: 'HTML', + reply_markup: { inline_keyboard: buttons }, + }); + await bot.answerCallbackQuery(query.id); + } + + if (query.data?.startsWith('positions:sell:')) { + const assetPrefix = query.data.slice(14); + const botManager = getBotManager(); + const userBot = botManager.getUserBot(chatId); + + if (!userBot) { + await bot.answerCallbackQuery(query.id, { text: 'โš ๏ธ Trading not active. Start trading first.' }); + return; + } + + await bot.editMessageText(`โณ Selling position...`, { + chat_id: chatId, + message_id: query.message.message_id, + }); + + try { + const state = db.getUserState(chatId); + const positions = state.positions || {}; + const matchingAsset = Object.keys(positions).find(a => a.startsWith(assetPrefix)); + + if (matchingAsset && userBot.trader) { + const pos = positions[matchingAsset]; + const result = await userBot.trader.sellPosition(matchingAsset, pos.size, pos.avgPrice || 0.5, pos.title || 'Unknown'); + if (result.success) { + delete positions[matchingAsset]; + state.positions = positions; + db.saveUserState(chatId, state); + await bot.editMessageText(`โœ… Position sold! Order: ${result.orderId || 'completed'}`, { + chat_id: chatId, + message_id: query.message.message_id, + reply_markup: positionsMenu(Object.keys(positions).length > 0), + }); + } else { + await bot.editMessageText(`โŒ Sell failed: ${result.error}`, { + chat_id: chatId, + message_id: query.message.message_id, + reply_markup: positionsMenu(true), + }); + } + } + } catch (err: any) { + await bot.editMessageText(`โŒ Error: ${err.message}`, { + chat_id: chatId, + message_id: query.message.message_id, + reply_markup: positionsMenu(true), + }); + } + await bot.answerCallbackQuery(query.id); + } + + if (query.data === 'positions:sell_all') { + await bot.editMessageText('โš ๏ธ Sell ALL positions?\n\nThis will sell all your open positions at market price.', { + chat_id: chatId, + message_id: query.message.message_id, + parse_mode: 'HTML', + reply_markup: confirmMenu('sell_all'), + }); + await bot.answerCallbackQuery(query.id); + } + + if (query.data === 'confirm:sell_all') { + const botManager = getBotManager(); + const userBot = botManager.getUserBot(chatId); + + if (!userBot || !userBot.trader) { + await bot.editMessageText('โŒ Trading not active.', { + chat_id: chatId, + message_id: query.message.message_id, + reply_markup: positionsMenu(false), + }); + await bot.answerCallbackQuery(query.id); + return; + } + + await bot.editMessageText('โณ Selling all positions...', { + chat_id: chatId, + message_id: query.message.message_id, + }); + + const state = db.getUserState(chatId); + const positions = state.positions || {}; + let sold = 0; + let failed = 0; + + for (const [asset, pos] of Object.entries(positions) as [string, any][]) { + try { + const result = await userBot.trader.sellPosition(asset, pos.size, pos.avgPrice || 0.5, pos.title || 'Unknown'); + if (result.success) { + delete positions[asset]; + sold++; + } else { + failed++; + } + } catch { + failed++; + } + } + + state.positions = positions; + db.saveUserState(chatId, state); + + await bot.editMessageText( + `โœ… Sold: ${sold}${failed > 0 ? `, โŒ Failed: ${failed}` : ''}`, + { + chat_id: chatId, + message_id: query.message.message_id, + reply_markup: positionsMenu(Object.keys(positions).length > 0), + } + ); + await bot.answerCallbackQuery(query.id); + } + }); +} diff --git a/src/telegram/handlers/settings.ts b/src/telegram/handlers/settings.ts new file mode 100644 index 0000000..2c4e834 --- /dev/null +++ b/src/telegram/handlers/settings.ts @@ -0,0 +1,265 @@ +import TelegramBot from 'node-telegram-bot-api'; +import { UserDb } from '../../db/userDb.js'; +import { + settingsMenu, sizingMenu, riskMenu, filtersMenu, + executionMenu, timeExitsMenu, keywordsMenu, conflictStrategyMenu, + backButton, +} from '../menus.js'; + +// Track which setting is being edited per user +const pendingInput = new Map(); + +// Setting descriptions and validation +const settingInfo: Record number | null; returnMenu: string }> = { + userAccountSize: { label: 'Account Size', unit: '$', parse: v => { const n = parseFloat(v); return n > 0 ? n : null; }, returnMenu: 'settings:sizing' }, + maxPositionSize: { label: 'Max Position Size', unit: '$', parse: v => { const n = parseFloat(v); return n > 0 ? n : null; }, returnMenu: 'settings:sizing' }, + minTradeSize: { label: 'Min Trade Size', unit: '$', parse: v => { const n = parseFloat(v); return n > 0 ? n : null; }, returnMenu: 'settings:sizing' }, + maxPercentagePerTrade: { label: 'Max % Per Trade', unit: '%', parse: v => { const n = parseFloat(v); return n > 0 && n <= 100 ? n : null; }, returnMenu: 'settings:sizing' }, + maxPositionValue: { label: 'Max Position Value', unit: '$ (0=unlimited)', parse: v => { const n = parseFloat(v); return n >= 0 ? n : null; }, returnMenu: 'settings:sizing' }, + stopLossPercent: { label: 'Stop Loss', unit: '% (negative, e.g. -25)', parse: v => { const n = parseFloat(v); return n < 0 ? n : null; }, returnMenu: 'settings:risk' }, + takeProfitPercent: { label: 'Take Profit', unit: '%', parse: v => { const n = parseFloat(v); return n > 0 ? n : null; }, returnMenu: 'settings:risk' }, + trailingStopPercent: { label: 'Trailing Stop', unit: '% (0=disabled)', parse: v => { const n = parseFloat(v); return n >= 0 ? n : null; }, returnMenu: 'settings:risk' }, + dailyLossLimit: { label: 'Daily Loss Limit', unit: '$ (0=disabled)', parse: v => { const n = parseFloat(v); return n >= 0 ? n : null; }, returnMenu: 'settings:risk' }, + maxOpenPositions: { label: 'Max Open Positions', unit: '(0=unlimited)', parse: v => { const n = parseInt(v); return n >= 0 ? n : null; }, returnMenu: 'settings:risk' }, + minProbability: { label: 'Min Probability', unit: '% (e.g. 5 for 5%)', parse: v => { const n = parseFloat(v); return n >= 0 && n <= 100 ? n / 100 : null; }, returnMenu: 'settings:filters' }, + maxProbability: { label: 'Max Probability', unit: '% (e.g. 95 for 95%)', parse: v => { const n = parseFloat(v); return n > 0 && n <= 100 ? n / 100 : null; }, returnMenu: 'settings:filters' }, + orderSlippagePercent: { label: 'Slippage', unit: '%', parse: v => { const n = parseFloat(v); return n >= 0 && n <= 50 ? n : null; }, returnMenu: 'settings:execution' }, + maxHoldTimeHours: { label: 'Max Hold Time', unit: 'hours (0=disabled)', parse: v => { const n = parseFloat(v); return n >= 0 ? n : null; }, returnMenu: 'settings:time' }, + blacklistKeywords: { label: 'Blacklist Keywords', unit: 'comma-separated', parse: v => null, returnMenu: 'settings:keywords' }, + whitelistKeywords: { label: 'Whitelist Keywords', unit: 'comma-separated', parse: v => null, returnMenu: 'settings:keywords' }, +}; + +export function registerSettingsHandler(bot: TelegramBot, db: UserDb): void { + + bot.on('callback_query', async (query) => { + if (!query.data || !query.message) return; + const chatId = query.message.chat.id.toString(); + const settings = db.getSettings(chatId); + + // Settings main menu + if (query.data === 'menu:settings') { + await bot.editMessageText('โš™๏ธ Settings\n\nConfigure your trading parameters:', { + chat_id: chatId, + message_id: query.message.message_id, + parse_mode: 'HTML', + reply_markup: settingsMenu(), + }); + await bot.answerCallbackQuery(query.id); + } + + // Sub-menus + if (query.data === 'settings:sizing') { + await bot.editMessageText('๐Ÿ’ฐ Position Sizing\n\nTap a setting to change it:', { + chat_id: chatId, + message_id: query.message.message_id, + parse_mode: 'HTML', + reply_markup: sizingMenu(settings), + }); + await bot.answerCallbackQuery(query.id); + } + + if (query.data === 'settings:risk') { + await bot.editMessageText('๐Ÿ›ก Risk Management\n\nTap a setting to change it:', { + chat_id: chatId, + message_id: query.message.message_id, + parse_mode: 'HTML', + reply_markup: riskMenu(settings), + }); + await bot.answerCallbackQuery(query.id); + } + + if (query.data === 'settings:filters') { + await bot.editMessageText('๐Ÿ” Trade Filters\n\nTap a setting to change it:', { + chat_id: chatId, + message_id: query.message.message_id, + parse_mode: 'HTML', + reply_markup: filtersMenu(settings), + }); + await bot.answerCallbackQuery(query.id); + } + + if (query.data === 'settings:execution') { + await bot.editMessageText('โšก Execution Settings\n\nTap a setting to change it:', { + chat_id: chatId, + message_id: query.message.message_id, + parse_mode: 'HTML', + reply_markup: executionMenu(settings), + }); + await bot.answerCallbackQuery(query.id); + } + + if (query.data === 'settings:time') { + await bot.editMessageText('โฐ Time-Based Exits\n\nTap a setting to change it:', { + chat_id: chatId, + message_id: query.message.message_id, + parse_mode: 'HTML', + reply_markup: timeExitsMenu(settings), + }); + await bot.answerCallbackQuery(query.id); + } + + if (query.data === 'settings:keywords') { + await bot.editMessageText( + '๐Ÿ“ Keywords\n\n' + + `Blacklist: ${settings.blacklistKeywords || 'none'}\n` + + `Whitelist: ${settings.whitelistKeywords || 'none'}`, + { + chat_id: chatId, + message_id: query.message.message_id, + parse_mode: 'HTML', + reply_markup: keywordsMenu(settings), + } + ); + await bot.answerCallbackQuery(query.id); + } + + // Set a specific value + if (query.data?.startsWith('set:')) { + const key = query.data.slice(4); + const info = settingInfo[key]; + if (!info) return; + + pendingInput.set(chatId, { key, returnTo: info.returnMenu }); + + await bot.editMessageText( + `โœ๏ธ Set ${info.label}\n\n` + + `Current value: ${(settings as any)[key]}\n` + + `Enter new value (${info.unit}):`, + { + chat_id: chatId, + message_id: query.message.message_id, + parse_mode: 'HTML', + reply_markup: backButton(info.returnMenu), + } + ); + await bot.answerCallbackQuery(query.id); + } + + // Toggle boolean settings + if (query.data?.startsWith('toggle:')) { + const key = query.data.slice(7); + const currentValue = (settings as any)[key]; + const newValue = !currentValue; + db.updateSetting(chatId, key, newValue); + + await bot.answerCallbackQuery(query.id, { text: `${key} = ${newValue ? 'ON' : 'OFF'}` }); + + // Refresh the appropriate menu + const updatedSettings = db.getSettings(chatId); + if (key === 'copySells') { + await bot.editMessageText('โšก Execution Settings\n\nTap a setting to change it:', { + chat_id: chatId, + message_id: query.message.message_id, + parse_mode: 'HTML', + reply_markup: executionMenu(updatedSettings), + }); + } else if (key === 'dryRun') { + const { tradingMenu } = await import('../menus.js'); + await bot.editMessageText( + `โ–ถ๏ธ Trading Control\n\nDry Run: ${updatedSettings.dryRun ? 'โœ… Simulated' : 'โŒ LIVE'}`, + { + chat_id: chatId, + message_id: query.message.message_id, + parse_mode: 'HTML', + reply_markup: tradingMenu(updatedSettings.enableTrading, updatedSettings.dryRun), + } + ); + } + } + + // Conflict strategy selection + if (query.data === 'set:conflictStrategy') { + await bot.editMessageText( + 'โš”๏ธ Conflict Strategy\n\n' + + 'When followed traders make opposite trades:\n\n' + + '๐Ÿฅ‡ First - Follow the first signal\n' + + 'โญ Skip - Don\'t trade on conflicts\n' + + '๐Ÿ‘ฅ Majority - Follow the majority\n' + + '๐Ÿ’Ž Highest Allocation - Follow the highest-allocated trader', + { + chat_id: chatId, + message_id: query.message.message_id, + parse_mode: 'HTML', + reply_markup: conflictStrategyMenu(), + } + ); + await bot.answerCallbackQuery(query.id); + } + + if (query.data?.startsWith('conflict:')) { + const strategy = query.data.slice(9); + db.updateSetting(chatId, 'conflictStrategy', strategy); + await bot.answerCallbackQuery(query.id, { text: `โœ… Strategy: ${strategy}` }); + const updatedSettings = db.getSettings(chatId); + await bot.editMessageText('โšก Execution Settings\n\nTap a setting to change it:', { + chat_id: chatId, + message_id: query.message.message_id, + parse_mode: 'HTML', + reply_markup: executionMenu(updatedSettings), + }); + } + }); + + // Handle text input for setting values + bot.on('message', async (msg) => { + if (!msg.text || msg.text.startsWith('/')) return; + const chatId = msg.chat.id.toString(); + const pending = pendingInput.get(chatId); + if (!pending) return; + + const info = settingInfo[pending.key]; + if (!info) { + pendingInput.delete(chatId); + return; + } + + // Handle string settings (keywords) + if (pending.key === 'blacklistKeywords' || pending.key === 'whitelistKeywords') { + const value = msg.text.trim(); + db.updateSetting(chatId, pending.key, value); + pendingInput.delete(chatId); + + const settings = db.getSettings(chatId); + await bot.sendMessage(chatId, + `โœ… ${info.label} updated to: ${value || 'none'}`, + { + parse_mode: 'HTML', + reply_markup: keywordsMenu(settings), + } + ); + return; + } + + // Handle numeric settings + const parsed = info.parse(msg.text.trim()); + if (parsed === null) { + await bot.sendMessage(chatId, `โŒ Invalid value. Expected: ${info.unit}\n\nTry again:`, { + reply_markup: backButton(pending.returnTo), + }); + return; + } + + db.updateSetting(chatId, pending.key, parsed); + pendingInput.delete(chatId); + + const settings = db.getSettings(chatId); + // Send confirmation and show the appropriate sub-menu + const menuFns: Record TelegramBot.InlineKeyboardMarkup> = { + 'settings:sizing': () => sizingMenu(settings), + 'settings:risk': () => riskMenu(settings), + 'settings:filters': () => filtersMenu(settings), + 'settings:execution': () => executionMenu(settings), + 'settings:time': () => timeExitsMenu(settings), + 'settings:keywords': () => keywordsMenu(settings), + }; + + const menuFn = menuFns[pending.returnTo]; + await bot.sendMessage(chatId, + `โœ… ${info.label} updated to: ${parsed}`, + { + parse_mode: 'HTML', + reply_markup: menuFn ? menuFn() : backButton('menu:settings'), + } + ); + }); +} diff --git a/src/telegram/handlers/start.ts b/src/telegram/handlers/start.ts new file mode 100644 index 0000000..a84e1b2 --- /dev/null +++ b/src/telegram/handlers/start.ts @@ -0,0 +1,40 @@ +import TelegramBot from 'node-telegram-bot-api'; +import { UserDb } from '../../db/userDb.js'; +import { mainMenu } from '../menus.js'; + +export function registerStartHandler(bot: TelegramBot, db: UserDb): void { + bot.onText(/\/start/, async (msg) => { + const chatId = msg.chat.id.toString(); + const username = msg.from?.username || msg.from?.first_name || null; + db.ensureUser(chatId, username || undefined); + + const wallets = db.getWallets(chatId); + const tracked = db.getTrackedWallets(chatId); + + let welcome = `๐Ÿค– Welcome to Polybot!\n\n`; + welcome += `๐Ÿค Multi-user Polymarket copy-trading bot.\n\n`; + welcome += `๐Ÿ“‹ Status:\n`; + welcome += `โ€ข ๐Ÿ’ผ Wallets: ${wallets.length} connected\n`; + welcome += `โ€ข ๐Ÿ‘ Following: ${tracked.filter(t => t.enabled).length} traders\n\n`; + + if (wallets.length === 0) { + welcome += `๐Ÿ‘‰ Start by connecting your wallet!\n`; + } + + await bot.sendMessage(chatId, welcome, { + parse_mode: 'HTML', + reply_markup: mainMenu(), + }); + }); + + // Also handle /menu to show main menu + bot.onText(/\/menu/, async (msg) => { + const chatId = msg.chat.id.toString(); + db.ensureUser(chatId, msg.from?.username || undefined); + + await bot.sendMessage(chatId, '๐Ÿค– Main Menu', { + parse_mode: 'HTML', + reply_markup: mainMenu(), + }); + }); +} diff --git a/src/telegram/handlers/stats.ts b/src/telegram/handlers/stats.ts new file mode 100644 index 0000000..8109705 --- /dev/null +++ b/src/telegram/handlers/stats.ts @@ -0,0 +1,68 @@ +import TelegramBot from 'node-telegram-bot-api'; +import { UserDb } from '../../db/userDb.js'; +import { backButton } from '../menus.js'; + +export function registerStatsHandler(bot: TelegramBot, db: UserDb): void { + + bot.on('callback_query', async (query) => { + if (!query.data || !query.message) return; + const chatId = query.message.chat.id.toString(); + + if (query.data === 'menu:stats') { + const state = db.getUserState(chatId); + const stats = state.stats || { + totalTrades: 0, + successfulTrades: 0, + failedTrades: 0, + totalVolume: 0, + }; + + const successRate = stats.totalTrades > 0 + ? ((stats.successfulTrades / stats.totalTrades) * 100).toFixed(1) + : '0.0'; + + const positions = state.positions ? Object.values(state.positions) : []; + const totalValue = (positions as any[]).reduce((sum: number, p: any) => sum + (p.size || 0) * (p.avgPrice || 0), 0); + + // Per-trader stats + let traderStatsText = ''; + if (state.traderStats) { + const entries = Object.entries(state.traderStats) as [string, any][]; + if (entries.length > 0) { + traderStatsText = '\n\n๐Ÿ‘ฅ Per-Trader Stats:\n'; + entries.forEach(([alias, ts]) => { + const winRate = ts.totalTrades > 0 ? ((ts.wins / ts.totalTrades) * 100).toFixed(0) : '0'; + traderStatsText += `โ€ข ${alias}: ${ts.totalTrades} trades, ${winRate}% win, P&L: $${ts.totalPnL?.toFixed(2) || '0.00'}\n`; + }); + } + } + + const message = ` +๐Ÿ“ˆ Trading Statistics + +๐Ÿ”ข Total Trades: ${stats.totalTrades} +โœ… Successful: ${stats.successfulTrades} +โŒ Failed: ${stats.failedTrades} +๐ŸŽฏ Success Rate: ${successRate}% +๐Ÿ’ฐ Total Volume: $${(stats.totalVolume || 0).toFixed(2)} + +๐Ÿ“ฆ Open Positions: ${positions.length} +๐Ÿ’ต Total Position Value: $${totalValue.toFixed(2)} +๐Ÿ“Š Daily P&L: $${(state.dailyPnL || 0).toFixed(2)}${traderStatsText} + `.trim(); + + const buttons = [ + [{ text: '๐Ÿ”„ Refresh', callback_data: 'menu:stats' }], + [{ text: 'โฌ…๏ธ Back', callback_data: 'menu:main' }], + ]; + + await bot.editMessageText(message, { + chat_id: chatId, + message_id: query.message.message_id, + parse_mode: 'HTML', + reply_markup: { inline_keyboard: buttons }, + }); + await bot.answerCallbackQuery(query.id); + } + }); +} diff --git a/src/telegram/handlers/trading.ts b/src/telegram/handlers/trading.ts new file mode 100644 index 0000000..46d0f9d --- /dev/null +++ b/src/telegram/handlers/trading.ts @@ -0,0 +1,96 @@ +import TelegramBot from 'node-telegram-bot-api'; +import { UserDb } from '../../db/userDb.js'; +import { tradingMenu, backButton } from '../menus.js'; +import type { UserBotManager } from '../../services/userBotManager.js'; + +export function registerTradingHandler(bot: TelegramBot, db: UserDb, getBotManager: () => UserBotManager): void { + + bot.on('callback_query', async (query) => { + if (!query.data || !query.message) return; + const chatId = query.message.chat.id.toString(); + + if (query.data === 'menu:trading') { + const settings = db.getSettings(chatId); + const botManager = getBotManager(); + const isRunning = botManager.isUserRunning(chatId); + + const wallets = db.getWallets(chatId); + const tracked = db.getTrackedWallets(chatId).filter(t => t.enabled); + + let statusText = 'โ–ถ๏ธ Trading Control\n\n'; + statusText += `Status: ${isRunning ? '๐ŸŸข RUNNING' : 'โน STOPPED'}\n`; + statusText += `Mode: ${settings.dryRun ? '๐Ÿ”ถ Dry Run' : '๐ŸŸข LIVE'}\n`; + statusText += `๐Ÿ’ผ Wallets: ${wallets.length}\n`; + statusText += `๐Ÿ‘ Following: ${tracked.length} traders\n`; + + if (wallets.length === 0) { + statusText += '\nโš ๏ธ Connect a wallet first!'; + } + if (tracked.length === 0) { + statusText += '\nโš ๏ธ Follow at least one trader first!'; + } + + await bot.editMessageText(statusText, { + chat_id: chatId, + message_id: query.message.message_id, + parse_mode: 'HTML', + reply_markup: tradingMenu(isRunning, settings.dryRun), + }); + await bot.answerCallbackQuery(query.id); + } + + if (query.data === 'trading:start') { + const wallets = db.getWallets(chatId); + const tracked = db.getTrackedWallets(chatId).filter(t => t.enabled); + + if (wallets.length === 0) { + await bot.answerCallbackQuery(query.id, { text: 'โš ๏ธ Connect a wallet first!', show_alert: true }); + return; + } + if (tracked.length === 0) { + await bot.answerCallbackQuery(query.id, { text: 'โš ๏ธ Follow at least one trader first!', show_alert: true }); + return; + } + + const botManager = getBotManager(); + try { + await botManager.startUserBot(chatId); + const settings = db.getSettings(chatId); + + await bot.editMessageText( + `๐ŸŸข Trading Started!\n\n` + + `Mode: ${settings.dryRun ? '๐Ÿ”ถ Dry Run (simulated)' : '๐ŸŸข LIVE'}\n` + + `๐Ÿ‘ Following: ${tracked.length} trader(s)\n` + + `๐Ÿ’ฐ Max position: $${settings.maxPositionSize}`, + { + chat_id: chatId, + message_id: query.message.message_id, + parse_mode: 'HTML', + reply_markup: tradingMenu(true, settings.dryRun), + } + ); + } catch (err: any) { + await bot.editMessageText(`โŒ Failed to start: ${err.message}`, { + chat_id: chatId, + message_id: query.message.message_id, + reply_markup: tradingMenu(false, db.getSettings(chatId).dryRun), + }); + } + await bot.answerCallbackQuery(query.id); + } + + if (query.data === 'trading:stop') { + const botManager = getBotManager(); + await botManager.stopUserBot(chatId); + const settings = db.getSettings(chatId); + + await bot.editMessageText('โน Trading Stopped', { + chat_id: chatId, + message_id: query.message.message_id, + parse_mode: 'HTML', + reply_markup: tradingMenu(false, settings.dryRun), + }); + await bot.answerCallbackQuery(query.id); + } + }); +} diff --git a/src/telegram/handlers/wallet.ts b/src/telegram/handlers/wallet.ts new file mode 100644 index 0000000..f8ce228 --- /dev/null +++ b/src/telegram/handlers/wallet.ts @@ -0,0 +1,238 @@ +import TelegramBot from 'node-telegram-bot-api'; +import { UserDb } from '../../db/userDb.js'; +import { walletsMenu, walletTypeMenu, backButton } from '../menus.js'; +import { privateKeyToAccount } from 'viem/accounts'; + +// Track conversation state for wallet connection flow +const walletFlowState = new Map(); + +export function registerWalletHandler(bot: TelegramBot, db: UserDb): void { + + // Handle menu:wallets callback + bot.on('callback_query', async (query) => { + if (!query.data || !query.message) return; + const chatId = query.message.chat.id.toString(); + + if (query.data === 'menu:wallets') { + const wallets = db.getWallets(chatId); + await bot.editMessageText('๐Ÿ”‘ Wallet Management\n\nConnect your Polymarket wallet to start trading.', { + chat_id: chatId, + message_id: query.message.message_id, + parse_mode: 'HTML', + reply_markup: walletsMenu(wallets.length > 0), + }); + await bot.answerCallbackQuery(query.id); + } + + if (query.data === 'wallet:connect') { + await bot.editMessageText( + '๐Ÿ”‘ Connect Wallet\n\n' + + 'Select your wallet type:\n\n' + + '๐ŸฆŠ Browser Wallet - MetaMask/Rabby with Polymarket proxy (most common)\n' + + '๐Ÿ“ง Email Wallet - Magic Link login, exported private key\n' + + '๐Ÿ” EOA - Standalone wallet, signer is funder', + { + chat_id: chatId, + message_id: query.message.message_id, + parse_mode: 'HTML', + reply_markup: walletTypeMenu(), + } + ); + await bot.answerCallbackQuery(query.id); + } + + // Wallet type selection + if (query.data?.startsWith('wallet:type:')) { + const signatureType = parseInt(query.data.split(':')[2]); + walletFlowState.set(chatId, { step: 'private_key', signatureType }); + + await bot.editMessageText( + '๐Ÿ” Step 1: Private Key\n\n' + + 'Send your private key (starts with 0x).\n\n' + + 'โš ๏ธ Your key will be encrypted and stored securely.\n' + + 'โš ๏ธ Delete your message after sending!', + { + chat_id: chatId, + message_id: query.message.message_id, + parse_mode: 'HTML', + reply_markup: backButton('menu:wallets'), + } + ); + await bot.answerCallbackQuery(query.id); + } + + // List wallets + if (query.data === 'wallet:list') { + const wallets = db.getWallets(chatId); + if (wallets.length === 0) { + await bot.editMessageText('๐Ÿ“ญ No wallets connected.', { + chat_id: chatId, + message_id: query.message.message_id, + reply_markup: walletsMenu(false), + }); + } else { + let text = '๐Ÿ“‹ Your Wallets\n\n'; + wallets.forEach((w, i) => { + const addr = w.walletAddress; + const short = `${addr.slice(0, 6)}...${addr.slice(-4)}`; + const defaultTag = w.isDefault ? ' โญ Default' : ''; + const typeNames: Record = { 0: 'EOA', 1: 'Email', 2: 'Browser' }; + text += `${i + 1}. ${w.alias}${defaultTag}\n`; + text += ` ๐Ÿ“ Address: ${short}\n`; + text += ` ๐Ÿท Type: ${typeNames[w.signatureType] || 'Unknown'}\n\n`; + }); + await bot.editMessageText(text, { + chat_id: chatId, + message_id: query.message.message_id, + parse_mode: 'HTML', + reply_markup: walletsMenu(true), + }); + } + await bot.answerCallbackQuery(query.id); + } + + // Remove wallet list + if (query.data === 'wallet:remove_list') { + const wallets = db.getWallets(chatId); + const buttons = wallets.map(w => { + const short = `${w.walletAddress.slice(0, 6)}...${w.walletAddress.slice(-4)}`; + return [{ text: `๐Ÿ—‘ ${w.alias} (${short})`, callback_data: `wallet:remove:${w.id}` }]; + }); + buttons.push([{ text: 'โฌ…๏ธ Back', callback_data: 'menu:wallets' }]); + await bot.editMessageText('๐Ÿ—‘ Remove Wallet\n\nSelect wallet to remove:', { + chat_id: chatId, + message_id: query.message.message_id, + parse_mode: 'HTML', + reply_markup: { inline_keyboard: buttons }, + }); + await bot.answerCallbackQuery(query.id); + } + + // Remove specific wallet + if (query.data?.startsWith('wallet:remove:')) { + const walletId = parseInt(query.data.split(':')[2]); + db.removeWallet(chatId, walletId); + const wallets = db.getWallets(chatId); + await bot.editMessageText('โœ… Wallet removed successfully.', { + chat_id: chatId, + message_id: query.message.message_id, + parse_mode: 'HTML', + reply_markup: walletsMenu(wallets.length > 0), + }); + await bot.answerCallbackQuery(query.id); + } + }); + + // Handle text messages for wallet connection flow + bot.on('message', async (msg) => { + if (!msg.text || msg.text.startsWith('/')) return; + const chatId = msg.chat.id.toString(); + const state = walletFlowState.get(chatId); + if (!state) return; + + if (state.step === 'private_key') { + const key = msg.text.trim(); + + // Try to delete the message containing the private key for security + try { await bot.deleteMessage(chatId, msg.message_id); } catch {} + + if (!key.startsWith('0x') || key.length < 64) { + await bot.sendMessage(chatId, 'โŒ Invalid private key. Must start with 0x and be 66 characters long.\n\nTry again or press Back.', { + parse_mode: 'HTML', + reply_markup: backButton('menu:wallets'), + }); + return; + } + + try { + // Derive address from private key + const account = privateKeyToAccount(key as `0x${string}`); + state.privateKey = key; + state.walletAddress = account.address; + + if (state.signatureType === 0) { + // EOA: signer is funder, no need for funder address + state.funderAddress = account.address; + state.step = 'alias'; + await bot.sendMessage(chatId, + `โœ… Key accepted!\n\n` + + `๐Ÿ“ Address: ${account.address}\n\n` + + `Step 2: โœ๏ธ Send a name/alias for this wallet (e.g., "Main", "Trading"):`, + { parse_mode: 'HTML', reply_markup: backButton('menu:wallets') } + ); + } else { + // Need funder/proxy address + state.step = 'funder_address'; + await bot.sendMessage(chatId, + `โœ… Key accepted!\n\n` + + `๐Ÿ“ Signer: ${account.address}\n\n` + + `Step 2: ๐Ÿ“ฌ Send your Polymarket proxy/funder wallet address:`, + { parse_mode: 'HTML', reply_markup: backButton('menu:wallets') } + ); + } + } catch (err: any) { + await bot.sendMessage(chatId, `โŒ Invalid private key: ${err.message}\n\nTry again:`, { + reply_markup: backButton('menu:wallets'), + }); + } + return; + } + + if (state.step === 'funder_address') { + const addr = msg.text.trim(); + if (!addr.startsWith('0x') || addr.length !== 42) { + await bot.sendMessage(chatId, 'โŒ Invalid address. Must be 42 characters starting with 0x.\n\nTry again:', { + reply_markup: backButton('menu:wallets'), + }); + return; + } + state.funderAddress = addr; + state.step = 'alias'; + await bot.sendMessage(chatId, + `โœ… Funder address set!\n\n` + + `Step 3: โœ๏ธ Send a name/alias for this wallet (e.g., "Main", "Trading"):`, + { parse_mode: 'HTML', reply_markup: backButton('menu:wallets') } + ); + return; + } + + if (state.step === 'alias') { + const alias = msg.text.trim().slice(0, 20); + + try { + db.addWallet( + chatId, + state.privateKey!, + state.walletAddress!, + state.funderAddress, + alias, + state.signatureType + ); + + walletFlowState.delete(chatId); + + const short = `${state.walletAddress!.slice(0, 6)}...${state.walletAddress!.slice(-4)}`; + await bot.sendMessage(chatId, + `โœ… Wallet Connected!\n\n` + + `๐Ÿท Name: ${alias}\n` + + `๐Ÿ“ Address: ${short}\n\n` + + `๐ŸŽ‰ You can now follow traders and start copy-trading!`, + { + parse_mode: 'HTML', + reply_markup: walletsMenu(true), + } + ); + } catch (err: any) { + await bot.sendMessage(chatId, `โŒ Error saving wallet: ${err.message}`, { + reply_markup: backButton('menu:wallets'), + }); + } + return; + } + }); +} + +// Export for cleanup +export function clearWalletFlowState(chatId: string): void { + walletFlowState.delete(chatId); +} diff --git a/src/telegram/menus.ts b/src/telegram/menus.ts new file mode 100644 index 0000000..3d2a8c0 --- /dev/null +++ b/src/telegram/menus.ts @@ -0,0 +1,182 @@ +import TelegramBot from 'node-telegram-bot-api'; + +type InlineButton = TelegramBot.InlineKeyboardButton; +type InlineMarkup = TelegramBot.InlineKeyboardMarkup; + +function btn(text: string, callbackData: string): InlineButton { + return { text, callback_data: callbackData }; +} + +export function mainMenu(): InlineMarkup { + return { + inline_keyboard: [ + [btn('๐Ÿ”‘ Wallets', 'menu:wallets'), btn('๐Ÿ‘ Follow Traders', 'menu:follow')], + [btn('โš™๏ธ Settings', 'menu:settings'), btn('๐Ÿ“Š Positions', 'menu:positions')], + [btn('๐Ÿ“ˆ Stats', 'menu:stats'), btn('โ–ถ๏ธ Trading', 'menu:trading')], + [btn('โ“ Help', 'menu:help')], + ], + }; +} + +export function walletsMenu(hasWallets: boolean): InlineMarkup { + const buttons: InlineButton[][] = [ + [btn('โž• Connect Wallet', 'wallet:connect')], + ]; + if (hasWallets) { + buttons.push([btn('๐Ÿ“‹ My Wallets', 'wallet:list'), btn('๐Ÿ—‘ Remove Wallet', 'wallet:remove_list')]); + } + buttons.push([btn('โฌ…๏ธ Back', 'menu:main')]); + return { inline_keyboard: buttons }; +} + +export function walletTypeMenu(): InlineMarkup { + return { + inline_keyboard: [ + [btn('๐ŸฆŠ Browser Wallet (MetaMask/Rabby)', 'wallet:type:2')], + [btn('๐Ÿ“ง Email Wallet (Magic Link)', 'wallet:type:1')], + [btn('๐Ÿ” EOA (Standalone)', 'wallet:type:0')], + [btn('โฌ…๏ธ Back', 'menu:wallets')], + ], + }; +} + +export function followMenu(hasTracked: boolean): InlineMarkup { + const buttons: InlineButton[][] = [ + [btn('โž• Add Trader', 'follow:add')], + ]; + if (hasTracked) { + buttons.push([btn('๐Ÿ“‹ List Traders', 'follow:list'), btn('๐Ÿ—‘ Remove Trader', 'follow:remove_list')]); + } + buttons.push([btn('โฌ…๏ธ Back', 'menu:main')]); + return { inline_keyboard: buttons }; +} + +export function settingsMenu(): InlineMarkup { + return { + inline_keyboard: [ + [btn('๐Ÿ’ฐ Position Sizing', 'settings:sizing'), btn('๐Ÿ›ก Risk Management', 'settings:risk')], + [btn('๐Ÿ” Trade Filters', 'settings:filters'), btn('โšก Execution', 'settings:execution')], + [btn('โฐ Time Exits', 'settings:time'), btn('๐Ÿ“ Keywords', 'settings:keywords')], + [btn('โฌ…๏ธ Back', 'menu:main')], + ], + }; +} + +export function sizingMenu(settings: any): InlineMarkup { + return { + inline_keyboard: [ + [btn(`๐Ÿ’ต Account Size: $${settings.userAccountSize}`, 'set:userAccountSize')], + [btn(`๐Ÿ“ Max Position: $${settings.maxPositionSize}`, 'set:maxPositionSize')], + [btn(`๐Ÿ”ป Min Trade: $${settings.minTradeSize}`, 'set:minTradeSize')], + [btn(`๐Ÿ“Š Max % Per Trade: ${settings.maxPercentagePerTrade}%`, 'set:maxPercentagePerTrade')], + [btn(`๐Ÿท Max Position Value: $${settings.maxPositionValue || 'โˆž'}`, 'set:maxPositionValue')], + [btn('โฌ…๏ธ Back', 'menu:settings')], + ], + }; +} + +export function riskMenu(settings: any): InlineMarkup { + return { + inline_keyboard: [ + [btn(`๐Ÿ”ป Stop Loss: ${settings.stopLossPercent}%`, 'set:stopLossPercent')], + [btn(`๐ŸŽฏ Take Profit: +${settings.takeProfitPercent}%`, 'set:takeProfitPercent')], + [btn(`๐Ÿ“‰ Trailing Stop: ${settings.trailingStopPercent}%`, 'set:trailingStopPercent')], + [btn(`๐Ÿšซ Daily Loss Limit: $${settings.dailyLossLimit || 'โˆž'}`, 'set:dailyLossLimit')], + [btn(`๐Ÿ“ฆ Max Open Positions: ${settings.maxOpenPositions || 'โˆž'}`, 'set:maxOpenPositions')], + [btn('โฌ…๏ธ Back', 'menu:settings')], + ], + }; +} + +export function filtersMenu(settings: any): InlineMarkup { + return { + inline_keyboard: [ + [btn(`โฌ‡๏ธ Min Probability: ${(settings.minProbability * 100).toFixed(0)}%`, 'set:minProbability')], + [btn(`โฌ†๏ธ Max Probability: ${(settings.maxProbability * 100).toFixed(0)}%`, 'set:maxProbability')], + [btn('โฌ…๏ธ Back', 'menu:settings')], + ], + }; +} + +export function executionMenu(settings: any): InlineMarkup { + return { + inline_keyboard: [ + [btn(`๐ŸŽš Slippage: ${settings.orderSlippagePercent}%`, 'set:orderSlippagePercent')], + [btn(`๐Ÿ“ค Copy Sells: ${settings.copySells ? 'โœ…' : 'โŒ'}`, 'toggle:copySells')], + [btn(`โš”๏ธ Conflict Strategy: ${settings.conflictStrategy}`, 'set:conflictStrategy')], + [btn('โฌ…๏ธ Back', 'menu:settings')], + ], + }; +} + +export function timeExitsMenu(settings: any): InlineMarkup { + return { + inline_keyboard: [ + [btn(`โฑ Max Hold Time: ${settings.maxHoldTimeHours || 'OFF'}h`, 'set:maxHoldTimeHours')], + [btn('โฌ…๏ธ Back', 'menu:settings')], + ], + }; +} + +export function keywordsMenu(settings: any): InlineMarkup { + const bl = settings.blacklistKeywords || 'none'; + const wl = settings.whitelistKeywords || 'none'; + return { + inline_keyboard: [ + [btn(`๐Ÿšซ Blacklist: ${bl.length > 20 ? bl.slice(0, 20) + '...' : bl}`, 'set:blacklistKeywords')], + [btn(`โœ… Whitelist: ${wl.length > 20 ? wl.slice(0, 20) + '...' : wl}`, 'set:whitelistKeywords')], + [btn('โฌ…๏ธ Back', 'menu:settings')], + ], + }; +} + +export function conflictStrategyMenu(): InlineMarkup { + return { + inline_keyboard: [ + [btn('๐Ÿฅ‡ First Signal Wins', 'conflict:first')], + [btn('โญ Skip Conflicts', 'conflict:skip')], + [btn('๐Ÿ‘ฅ Majority Rules', 'conflict:majority')], + [btn('๐Ÿ’Ž Highest Allocation', 'conflict:highest_allocation')], + [btn('โฌ…๏ธ Back', 'settings:execution')], + ], + }; +} + +export function tradingMenu(isRunning: boolean, isDryRun: boolean): InlineMarkup { + const buttons: InlineButton[][] = []; + + if (isRunning) { + buttons.push([btn('โน Stop Trading', 'trading:stop')]); + } else { + buttons.push([btn('โ–ถ๏ธ Start Trading', 'trading:start')]); + } + + buttons.push([btn(`๐Ÿงช Dry Run: ${isDryRun ? 'โœ… ON' : 'โŒ OFF'}`, 'toggle:dryRun')]); + buttons.push([btn('โฌ…๏ธ Back', 'menu:main')]); + + return { inline_keyboard: buttons }; +} + +export function positionsMenu(hasPositions: boolean): InlineMarkup { + const buttons: InlineButton[][] = []; + if (hasPositions) { + buttons.push([btn('๐Ÿ“‹ View All', 'positions:list')]); + buttons.push([btn('๐Ÿ”ด Sell Position', 'positions:sell_list'), btn('๐Ÿ”ด Sell All', 'positions:sell_all')]); + } + buttons.push([btn('โฌ…๏ธ Back', 'menu:main')]); + return { inline_keyboard: buttons }; +} + +export function confirmMenu(action: string): InlineMarkup { + return { + inline_keyboard: [ + [btn('โœ… Yes, confirm', `confirm:${action}`), btn('โŒ Cancel', 'menu:main')], + ], + }; +} + +export function backButton(target: string): InlineMarkup { + return { + inline_keyboard: [[btn('โฌ…๏ธ Back', target)]], + }; +}