From 13fd2b8989c9773ad5b632552a6eb87fe68ec2c4 Mon Sep 17 00:00:00 2001 From: Michilis Date: Mon, 8 Dec 2025 22:33:40 +0000 Subject: [PATCH] Add SQLite database for Telegram bot user/group settings - Replace Redis/in-memory storage with SQLite for persistence - Add database.ts service with tables for users, groups, purchases, participants - Update state.ts and groupState.ts to use SQLite backend - Fix buyer_name to use display name instead of Telegram ID - Remove legacy reminder array handlers (now using 3-slot system) - Add better-sqlite3 dependency, remove ioredis - Update env.example with BOT_DATABASE_PATH option - Add data/ directory to .gitignore for database files --- telegram_bot/.gitignore | 6 + telegram_bot/env.example | 4 +- telegram_bot/package-lock.json | 463 +++++++++--- telegram_bot/package.json | 3 +- telegram_bot/src/config/index.ts | 4 +- telegram_bot/src/handlers/address.ts | 106 ++- telegram_bot/src/handlers/buy.ts | 72 +- telegram_bot/src/handlers/groups.ts | 349 +++++++-- telegram_bot/src/handlers/help.ts | 23 +- telegram_bot/src/handlers/index.ts | 7 + telegram_bot/src/handlers/settings.ts | 172 +++++ telegram_bot/src/handlers/start.ts | 11 +- telegram_bot/src/handlers/tickets.ts | 220 ++++-- telegram_bot/src/index.ts | 124 +++- telegram_bot/src/messages/index.ts | 289 +++++++- telegram_bot/src/services/api.ts | 4 +- telegram_bot/src/services/database.ts | 649 +++++++++++++++++ telegram_bot/src/services/groupState.ts | 328 +++++---- .../src/services/notificationScheduler.ts | 661 ++++++++++++++++++ telegram_bot/src/services/state.ts | 267 +++---- telegram_bot/src/types/groups.ts | 80 +++ telegram_bot/src/types/index.ts | 22 +- telegram_bot/src/utils/format.ts | 58 +- telegram_bot/src/utils/keyboards.ts | 69 +- 24 files changed, 3354 insertions(+), 637 deletions(-) create mode 100644 telegram_bot/src/handlers/settings.ts create mode 100644 telegram_bot/src/services/database.ts create mode 100644 telegram_bot/src/services/notificationScheduler.ts diff --git a/telegram_bot/.gitignore b/telegram_bot/.gitignore index 9609226..545103e 100644 --- a/telegram_bot/.gitignore +++ b/telegram_bot/.gitignore @@ -14,6 +14,12 @@ logs/ *.log npm-debug.log* +# Database +data/ +*.db +*.db-wal +*.db-shm + # IDE .vscode/ .idea/ diff --git a/telegram_bot/env.example b/telegram_bot/env.example index 5f9df68..e70b856 100644 --- a/telegram_bot/env.example +++ b/telegram_bot/env.example @@ -7,8 +7,8 @@ API_BASE_URL=http://localhost:3000 # Frontend URL (for generating ticket links) FRONTEND_BASE_URL=http://localhost:3001 -# Redis Configuration (optional - falls back to in-memory if not set) -REDIS_URL=redis://localhost:6379 +# SQLite Database Path (optional - defaults to ./data/bot.db) +# BOT_DATABASE_PATH=./data/bot.db # Bot Configuration MAX_TICKETS_PER_PURCHASE=100 diff --git a/telegram_bot/package-lock.json b/telegram_bot/package-lock.json index d7dd3dd..23063eb 100644 --- a/telegram_bot/package-lock.json +++ b/telegram_bot/package-lock.json @@ -10,13 +10,14 @@ "license": "MIT", "dependencies": { "axios": "^1.6.2", + "better-sqlite3": "^9.4.3", "dotenv": "^16.3.1", - "ioredis": "^5.3.2", "node-telegram-bot-api": "^0.64.0", "qrcode": "^1.5.3", "winston": "^3.11.0" }, "devDependencies": { + "@types/better-sqlite3": "^7.6.8", "@types/node": "^20.10.4", "@types/node-telegram-bot-api": "^0.64.2", "@types/qrcode": "^1.5.5", @@ -136,12 +137,6 @@ "kuler": "^2.0.0" } }, - "node_modules/@ioredis/commands": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.4.0.tgz", - "integrity": "sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==", - "license": "MIT" - }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -208,6 +203,16 @@ "dev": true, "license": "MIT" }, + "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", @@ -535,6 +540,26 @@ "dev": true, "license": "MIT" }, + "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", @@ -544,6 +569,17 @@ "tweetnacl": "^0.14.3" } }, + "node_modules/better-sqlite3": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-9.6.0.tgz", + "integrity": "sha512-yR5HATnqeYNVnkaUTf4bOP2dJSnyhP4puJN/QPRyx4YkBEEUxib422n2XzPqDEHjQQqazoYoADdAm5vE15+dAQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -557,6 +593,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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", @@ -597,6 +642,30 @@ "node": ">=8" } }, + "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", @@ -684,6 +753,12 @@ "fsevents": "~2.3.2" } }, + "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/cliui": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", @@ -695,15 +770,6 @@ "wrap-ansi": "^6.2.0" } }, - "node_modules/cluster-key-slot": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", - "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/color": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz", @@ -849,6 +915,7 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -871,6 +938,30 @@ "node": ">=0.10.0" } }, + "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", @@ -914,13 +1005,13 @@ "node": ">=0.4.0" } }, - "node_modules/denque": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", - "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "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": ">=0.10" + "node": ">=8" } }, "node_modules/diff": { @@ -1144,6 +1235,15 @@ "integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==", "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", @@ -1188,6 +1288,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/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -1282,6 +1388,12 @@ "node": ">= 0.12" } }, + "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", @@ -1416,6 +1528,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/glob-parent": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", @@ -1584,6 +1702,26 @@ "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/ignore-by-default": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", @@ -1597,6 +1735,12 @@ "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", @@ -1611,30 +1755,6 @@ "node": ">= 0.4" } }, - "node_modules/ioredis": { - "version": "5.8.2", - "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.8.2.tgz", - "integrity": "sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q==", - "license": "MIT", - "dependencies": { - "@ioredis/commands": "1.4.0", - "cluster-key-slot": "^1.1.0", - "debug": "^4.3.4", - "denque": "^2.1.0", - "lodash.defaults": "^4.2.0", - "lodash.isarguments": "^3.1.0", - "redis-errors": "^1.2.0", - "redis-parser": "^3.0.0", - "standard-as-callback": "^2.1.0" - }, - "engines": { - "node": ">=12.22.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/ioredis" - } - }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -2106,18 +2226,6 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "license": "MIT" }, - "node_modules/lodash.defaults": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", - "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", - "license": "MIT" - }, - "node_modules/lodash.isarguments": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", - "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", - "license": "MIT" - }, "node_modules/logform": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", @@ -2184,6 +2292,18 @@ "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/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -2197,12 +2317,45 @@ "node": "*" } }, + "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.85.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.85.0.tgz", + "integrity": "sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/node-telegram-bot-api": { "version": "0.64.0", "resolved": "https://registry.npmjs.org/node-telegram-bot-api/-/node-telegram-bot-api-0.64.0.tgz", @@ -2439,6 +2592,42 @@ "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==", + "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.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "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", @@ -2527,6 +2716,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", @@ -2561,27 +2765,6 @@ "node": ">=8.10.0" } }, - "node_modules/redis-errors": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", - "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/redis-parser": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", - "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", - "license": "MIT", - "dependencies": { - "redis-errors": "^1.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -2878,7 +3061,6 @@ "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -3011,6 +3193,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/simple-update-notifier": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", @@ -3058,12 +3285,6 @@ "node": "*" } }, - "node_modules/standard-as-callback": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", - "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", - "license": "MIT" - }, "node_modules/stealthy-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/stealthy-require/-/stealthy-require-1.1.1.tgz", @@ -3183,6 +3404,15 @@ "node": ">=8" } }, + "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/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -3196,6 +3426,69 @@ "node": ">=4" } }, + "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.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "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/text-hex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", diff --git a/telegram_bot/package.json b/telegram_bot/package.json index bcf696c..60656da 100644 --- a/telegram_bot/package.json +++ b/telegram_bot/package.json @@ -19,13 +19,14 @@ "license": "MIT", "dependencies": { "axios": "^1.6.2", + "better-sqlite3": "^9.4.3", "dotenv": "^16.3.1", - "ioredis": "^5.3.2", "node-telegram-bot-api": "^0.64.0", "qrcode": "^1.5.3", "winston": "^3.11.0" }, "devDependencies": { + "@types/better-sqlite3": "^7.6.8", "@types/node": "^20.10.4", "@types/node-telegram-bot-api": "^0.64.2", "@types/qrcode": "^1.5.5", diff --git a/telegram_bot/src/config/index.ts b/telegram_bot/src/config/index.ts index 5edf85d..7437fd0 100644 --- a/telegram_bot/src/config/index.ts +++ b/telegram_bot/src/config/index.ts @@ -31,8 +31,8 @@ export const config = { frontend: { baseUrl: optional('FRONTEND_BASE_URL', 'http://localhost:3001'), }, - redis: { - url: process.env.REDIS_URL || null, + database: { + path: process.env.BOT_DATABASE_PATH || null, // Defaults to ./data/bot.db in database.ts }, bot: { maxTicketsPerPurchase: optionalInt('MAX_TICKETS_PER_PURCHASE', 100), diff --git a/telegram_bot/src/handlers/address.ts b/telegram_bot/src/handlers/address.ts index 0799fe1..85dab7c 100644 --- a/telegram_bot/src/handlers/address.ts +++ b/telegram_bot/src/handlers/address.ts @@ -1,8 +1,8 @@ import TelegramBot from 'node-telegram-bot-api'; import { stateManager } from '../services/state'; import { logger, logUserAction } from '../services/logger'; -import { getMainMenuKeyboard, getCancelKeyboard } from '../utils/keyboards'; -import { isValidLightningAddress } from '../utils/format'; +import { getMainMenuKeyboard, getLightningAddressKeyboard, getCancelKeyboard } from '../utils/keyboards'; +import { isValidLightningAddress, verifyLightningAddress } from '../utils/format'; import { messages } from '../messages'; /** @@ -14,6 +14,7 @@ export async function handleAddressCommand( ): Promise { const chatId = msg.chat.id; const userId = msg.from?.id; + const username = msg.from?.username; if (!userId) { await bot.sendMessage(chatId, messages.errors.userNotIdentified); @@ -31,12 +32,12 @@ export async function handleAddressCommand( } const message = user.lightningAddress - ? messages.address.currentAddress(user.lightningAddress) - : messages.address.noAddressSet; + ? messages.address.currentAddressWithOptions(user.lightningAddress, username) + : messages.address.noAddressSetWithOptions(username); await bot.sendMessage(chatId, message, { parse_mode: 'Markdown', - reply_markup: getCancelKeyboard(), + reply_markup: getLightningAddressKeyboard(username), }); await stateManager.updateUserState(userId, 'updating_address'); @@ -55,6 +56,7 @@ export async function handleLightningAddressInput( ): Promise { const chatId = msg.chat.id; const userId = msg.from?.id; + const username = msg.from?.username; const text = msg.text?.trim(); if (!userId || !text) return false; @@ -73,7 +75,19 @@ export async function handleLightningAddressInput( if (!isValidLightningAddress(text)) { await bot.sendMessage(chatId, messages.address.invalidFormat, { parse_mode: 'Markdown', - reply_markup: getCancelKeyboard(), + reply_markup: getLightningAddressKeyboard(username), + }); + return true; + } + + // Verify the lightning address actually works + await bot.sendMessage(chatId, messages.address.verifying); + const verification = await verifyLightningAddress(text); + + if (!verification.valid) { + await bot.sendMessage(chatId, messages.address.verificationFailed(text, verification.error), { + parse_mode: 'Markdown', + reply_markup: getLightningAddressKeyboard(username), }); return true; } @@ -81,7 +95,7 @@ export async function handleLightningAddressInput( // Save the lightning address await stateManager.updateLightningAddress(userId, text); - logUserAction(userId, 'Lightning address updated'); + logUserAction(userId, 'Lightning address updated', { address: text }); const responseMessage = user.state === 'awaiting_lightning_address' ? messages.address.firstTimeSuccess(text) @@ -100,7 +114,85 @@ export async function handleLightningAddressInput( } } +/** + * Handle lightning address selection callback (21Tipbot/Bittip) + */ +export async function handleLightningAddressCallback( + bot: TelegramBot, + query: TelegramBot.CallbackQuery, + action: string +): Promise { + const chatId = query.message?.chat.id; + const userId = query.from.id; + const username = query.from.username; + + if (!chatId) return; + + await bot.answerCallbackQuery(query.id); + + try { + const user = await stateManager.getUser(userId); + if (!user) { + await bot.sendMessage(chatId, messages.errors.startFirst); + return; + } + + // Check if user has a username + if (!username) { + await bot.sendMessage(chatId, messages.address.noUsername, { + parse_mode: 'Markdown', + reply_markup: getCancelKeyboard(), + }); + return; + } + + // Generate address based on selection + let address: string; + let serviceName: string; + + if (action === '21tipbot') { + address = `${username}@twentyone.tips`; + serviceName = '21Tipbot'; + } else if (action === 'bittip') { + address = `${username}@btip.nl`; + serviceName = 'Bittip'; + } else { + return; + } + + // Verify the address + await bot.sendMessage(chatId, messages.address.verifyingService(serviceName, address)); + const verification = await verifyLightningAddress(address); + + if (!verification.valid) { + await bot.sendMessage(chatId, messages.address.serviceNotSetup(serviceName, verification.error), { + parse_mode: 'Markdown', + reply_markup: getLightningAddressKeyboard(username), + }); + return; + } + + // Save the address + await stateManager.updateLightningAddress(userId, address); + + logUserAction(userId, 'Lightning address set via tipbot', { address, serviceName }); + + const responseMessage = user.state === 'awaiting_lightning_address' + ? messages.address.firstTimeSuccess(address) + : messages.address.updateSuccess(address); + + await bot.sendMessage(chatId, responseMessage, { + parse_mode: 'Markdown', + reply_markup: getMainMenuKeyboard(), + }); + } catch (error) { + logger.error('Error in handleLightningAddressCallback', { error, userId, action }); + await bot.sendMessage(chatId, messages.errors.generic); + } +} + export default { handleAddressCommand, handleLightningAddressInput, + handleLightningAddressCallback, }; diff --git a/telegram_bot/src/handlers/buy.ts b/telegram_bot/src/handlers/buy.ts index 73a38f8..65fe659 100644 --- a/telegram_bot/src/handlers/buy.ts +++ b/telegram_bot/src/handlers/buy.ts @@ -273,11 +273,11 @@ export async function handlePurchaseConfirmation( logUserAction(userId, 'Confirmed purchase', { tickets: pendingData.ticketCount }); - // Create invoice + // Create invoice with user's display name const purchaseResult = await apiClient.buyTickets( pendingData.ticketCount, user.lightningAddress, - userId + user.displayName || 'Anon' ); logPaymentEvent(userId, purchaseResult.ticket_purchase_id, 'created', { @@ -295,21 +295,23 @@ export async function handlePurchaseConfirmation( parse_mode: 'Markdown', }); - // Send QR code - await bot.sendPhoto(chatId, qrBuffer, { + // Send QR code with caption + const qrMessage = await bot.sendPhoto(chatId, qrBuffer, { caption: messages.buy.invoiceCaption( pendingData.ticketCount, formatSats(pendingData.totalAmount), - purchaseResult.invoice.payment_request, config.bot.invoiceExpiryMinutes ), parse_mode: 'Markdown', - reply_markup: getViewTicketKeyboard( - purchaseResult.ticket_purchase_id, - purchaseResult.public_url - ), }); + // Send invoice string as separate message for easy copying + const invoiceMessage = await bot.sendMessage( + chatId, + messages.buy.invoiceString(purchaseResult.invoice.payment_request), + { parse_mode: 'Markdown' } + ); + // Store purchase and start polling const paymentData: AwaitingPaymentData = { ...pendingData, @@ -317,13 +319,16 @@ export async function handlePurchaseConfirmation( paymentRequest: purchaseResult.invoice.payment_request, publicUrl: purchaseResult.public_url, pollStartTime: Date.now(), + headerMessageId: messageId, + invoiceMessageId: invoiceMessage.message_id, + qrMessageId: qrMessage.message_id, }; await stateManager.storePurchase(userId, purchaseResult.ticket_purchase_id, paymentData); await stateManager.updateUserState(userId, 'awaiting_invoice_payment', paymentData); - // Start payment polling - pollPaymentStatus(bot, chatId, userId, purchaseResult.ticket_purchase_id); + // Start payment polling - pass all message IDs to delete on completion + pollPaymentStatus(bot, chatId, userId, purchaseResult.ticket_purchase_id, messageId, qrMessage.message_id, invoiceMessage.message_id); } catch (error) { logger.error('Error in handlePurchaseConfirmation', { error, userId }); await bot.sendMessage(chatId, messages.errors.invoiceCreationFailed, { @@ -340,7 +345,10 @@ async function pollPaymentStatus( bot: TelegramBot, chatId: number, userId: number, - purchaseId: string + purchaseId: string, + headerMessageId?: number, + qrMessageId?: number, + invoiceMessageId?: number ): Promise { const pollInterval = config.bot.paymentPollIntervalMs; const timeout = config.bot.paymentPollTimeoutMs; @@ -348,11 +356,41 @@ async function pollPaymentStatus( logPaymentEvent(userId, purchaseId, 'polling'); + // Helper to delete all invoice-related messages + const deleteInvoiceMessages = async () => { + // Delete in reverse order (bottom to top) + if (invoiceMessageId) { + try { + await bot.deleteMessage(chatId, invoiceMessageId); + } catch (e) { + // Ignore if message already deleted + } + } + if (qrMessageId) { + try { + await bot.deleteMessage(chatId, qrMessageId); + } catch (e) { + // Ignore if message already deleted + } + } + if (headerMessageId) { + try { + await bot.deleteMessage(chatId, headerMessageId); + } catch (e) { + // Ignore if message already deleted + } + } + }; + const checkPayment = async (): Promise => { try { // Check if we've timed out if (Date.now() - startTime > timeout) { logPaymentEvent(userId, purchaseId, 'expired'); + + // Delete the invoice messages + await deleteInvoiceMessages(); + await bot.sendMessage(chatId, messages.buy.invoiceExpired, { parse_mode: 'Markdown', reply_markup: getMainMenuKeyboard(), @@ -374,6 +412,12 @@ async function pollPaymentStatus( tickets: status.tickets.length, }); + // Delete the invoice messages + await deleteInvoiceMessages(); + + // Track user as cycle participant for notifications + await stateManager.addCycleParticipant(status.purchase.cycle_id, userId, purchaseId); + // Payment received! const ticketNumbers = status.tickets .map((t) => `#${t.serial_number.toString().padStart(4, '0')}`) @@ -397,6 +441,10 @@ async function pollPaymentStatus( if (status.purchase.invoice_status === 'expired') { logPaymentEvent(userId, purchaseId, 'expired'); + + // Delete the invoice messages + await deleteInvoiceMessages(); + await bot.sendMessage(chatId, messages.buy.invoiceExpiredShort, { parse_mode: 'Markdown', reply_markup: getMainMenuKeyboard(), diff --git a/telegram_bot/src/handlers/groups.ts b/telegram_bot/src/handlers/groups.ts index 4bd924e..1654ebc 100644 --- a/telegram_bot/src/handlers/groups.ts +++ b/telegram_bot/src/handlers/groups.ts @@ -2,6 +2,19 @@ import TelegramBot from 'node-telegram-bot-api'; import { groupStateManager } from '../services/groupState'; import { logger, logUserAction } from '../services/logger'; import { messages } from '../messages'; +import { + GroupSettings, + REMINDER_PRESETS, + ANNOUNCEMENT_DELAY_OPTIONS, + DEFAULT_GROUP_REMINDER_SLOTS, + ReminderTime, + formatReminderTime, + reminderTimeToMinutes +} from '../types/groups'; + +// Track settings messages for auto-deletion +const settingsMessageTimeouts: Map = new Map(); +const SETTINGS_MESSAGE_TTL = 2 * 60 * 1000; // 2 minutes /** * Check if a user is an admin in a group @@ -106,7 +119,7 @@ export async function handleGroupSettings( return; } - await bot.sendMessage( + const sentMessage = await bot.sendMessage( chatId, messages.groups.settingsOverview(currentSettings), { @@ -114,12 +127,45 @@ export async function handleGroupSettings( reply_markup: getGroupSettingsKeyboard(currentSettings), } ); + + // Schedule auto-delete after 2 minutes + scheduleSettingsMessageDeletion(bot, chatId, sentMessage.message_id); } catch (error) { logger.error('Error in handleGroupSettings', { error, chatId }); await bot.sendMessage(chatId, messages.errors.generic); } } +/** + * Schedule deletion of settings message after 2 minutes + */ +function scheduleSettingsMessageDeletion( + bot: TelegramBot, + chatId: number, + messageId: number +): void { + const key = `${chatId}:${messageId}`; + + // Clear any existing timeout for this message + const existingTimeout = settingsMessageTimeouts.get(key); + if (existingTimeout) { + clearTimeout(existingTimeout); + } + + // Schedule new deletion + const timeout = setTimeout(async () => { + try { + await bot.deleteMessage(chatId, messageId); + logger.debug('Auto-deleted settings message', { chatId, messageId }); + } catch (error) { + // Ignore errors (message might already be deleted) + } + settingsMessageTimeouts.delete(key); + }, SETTINGS_MESSAGE_TTL); + + settingsMessageTimeouts.set(key, timeout); +} + /** * Handle group settings toggle callback */ @@ -144,51 +190,132 @@ export async function handleGroupSettingsCallback( return; } - try { - let setting: 'enabled' | 'drawAnnouncements' | 'reminders' | 'ticketPurchaseAllowed'; - - switch (action) { - case 'toggle_enabled': - setting = 'enabled'; - break; - case 'toggle_announcements': - setting = 'drawAnnouncements'; - break; - case 'toggle_reminders': - setting = 'reminders'; - break; - case 'toggle_purchases': - setting = 'ticketPurchaseAllowed'; - break; - default: - await bot.answerCallbackQuery(query.id); - return; - } + // Refresh auto-delete timer on any interaction + scheduleSettingsMessageDeletion(bot, chatId, messageId); + try { const currentSettings = await groupStateManager.getGroup(chatId); if (!currentSettings) { await bot.answerCallbackQuery(query.id, { text: 'Group not found' }); return; } - const newValue = !currentSettings[setting]; - const updatedSettings = await groupStateManager.updateSetting(chatId, setting, newValue); + let updatedSettings: GroupSettings | null = null; + + // Handle toggle actions + if (action.startsWith('toggle_')) { + let setting: 'enabled' | 'drawAnnouncements' | 'reminders' | 'ticketPurchaseAllowed' | 'newJackpotAnnouncement' | 'reminder1Enabled' | 'reminder2Enabled' | 'reminder3Enabled'; + + switch (action) { + case 'toggle_enabled': + setting = 'enabled'; + break; + case 'toggle_announcements': + setting = 'drawAnnouncements'; + break; + case 'toggle_reminders': + setting = 'reminders'; + break; + case 'toggle_purchases': + setting = 'ticketPurchaseAllowed'; + break; + case 'toggle_newjackpot': + setting = 'newJackpotAnnouncement'; + break; + case 'toggle_reminder1': + setting = 'reminder1Enabled'; + break; + case 'toggle_reminder2': + setting = 'reminder2Enabled'; + break; + case 'toggle_reminder3': + setting = 'reminder3Enabled'; + break; + default: + await bot.answerCallbackQuery(query.id); + return; + } + + const currentValue = currentSettings[setting] !== false; // Default true for new settings + const newValue = !currentValue; + updatedSettings = await groupStateManager.updateSetting(chatId, setting, newValue); + + if (updatedSettings) { + logUserAction(userId, 'Updated group setting', { + groupId: chatId, + setting, + newValue, + }); + await bot.answerCallbackQuery(query.id, { + text: `${setting} ${newValue ? 'enabled' : 'disabled'}`, + }); + } + } + + // Legacy handlers removed - now using 3-slot reminder system with toggle_reminder1/2/3 and time adjustments + + // Handle announcement delay selection + if (action.startsWith('announce_delay_')) { + const seconds = parseInt(action.replace('announce_delay_', ''), 10); + if (!isNaN(seconds)) { + updatedSettings = await groupStateManager.updateAnnouncementDelay(chatId, seconds); + if (updatedSettings) { + logUserAction(userId, 'Updated announcement delay', { groupId: chatId, seconds }); + await bot.answerCallbackQuery(query.id, { + text: seconds === 0 ? 'Announce immediately' : `Announce ${seconds}s after draw` + }); + } + } + } + + // Handle reminder time adjustments (reminder1_add_1_hours, reminder2_sub_1_days, etc.) + const reminderTimeMatch = action.match(/^reminder(\d)_(add|sub)_(\d+)_(minutes|hours|days)$/); + if (reminderTimeMatch) { + const slot = parseInt(reminderTimeMatch[1], 10) as 1 | 2 | 3; + const operation = reminderTimeMatch[2] as 'add' | 'sub'; + const amount = parseInt(reminderTimeMatch[3], 10); + const unit = reminderTimeMatch[4] as 'minutes' | 'hours' | 'days'; + + // Get current time for this slot + const currentTimeKey = `reminder${slot}Time` as 'reminder1Time' | 'reminder2Time' | 'reminder3Time'; + const defaultTimes: Record = { + reminder1Time: { value: 1, unit: 'hours' }, + reminder2Time: { value: 1, unit: 'days' }, + reminder3Time: { value: 6, unit: 'days' }, + }; + const currentTime = currentSettings[currentTimeKey] || defaultTimes[currentTimeKey]; + + // Convert to minutes for calculation + const currentMinutes = reminderTimeToMinutes(currentTime); + const adjustMinutes = unit === 'minutes' ? amount : unit === 'hours' ? amount * 60 : amount * 60 * 24; + const newMinutes = operation === 'add' + ? currentMinutes + adjustMinutes + : Math.max(1, currentMinutes - adjustMinutes); // Minimum 1 minute + + // Convert back to best unit + let newTime: ReminderTime; + if (newMinutes >= 1440 && newMinutes % 1440 === 0) { + newTime = { value: newMinutes / 1440, unit: 'days' }; + } else if (newMinutes >= 60 && newMinutes % 60 === 0) { + newTime = { value: newMinutes / 60, unit: 'hours' }; + } else { + newTime = { value: newMinutes, unit: 'minutes' }; + } + + updatedSettings = await groupStateManager.updateReminderTime(chatId, slot, newTime); + if (updatedSettings) { + logUserAction(userId, 'Updated reminder time', { groupId: chatId, slot, newTime }); + await bot.answerCallbackQuery(query.id, { + text: `Reminder ${slot}: ${formatReminderTime(newTime)} before draw` + }); + } + } if (!updatedSettings) { await bot.answerCallbackQuery(query.id, { text: 'Failed to update' }); return; } - logUserAction(userId, 'Updated group setting', { - groupId: chatId, - setting, - newValue, - }); - - await bot.answerCallbackQuery(query.id, { - text: `${setting} ${newValue ? 'enabled' : 'disabled'}`, - }); - // Update the message with new settings await bot.editMessageText( messages.groups.settingsOverview(updatedSettings), @@ -205,41 +332,132 @@ export async function handleGroupSettingsCallback( } } + +/** + * Format delay option for display + */ +function formatDelayOption(seconds: number): string { + if (seconds === 0) return 'Instant'; + if (seconds >= 60) { + const minutes = seconds / 60; + return minutes === 1 ? '1 min' : `${minutes} min`; + } + return `${seconds}s`; +} + +/** + * Get time adjustment buttons for a reminder slot + */ +function getReminderTimeAdjustButtons(slot: number, currentTime: ReminderTime): TelegramBot.InlineKeyboardButton[] { + return [ + { text: '−1m', callback_data: `group_reminder${slot}_sub_1_minutes` }, + { text: '+1m', callback_data: `group_reminder${slot}_add_1_minutes` }, + { text: '−1h', callback_data: `group_reminder${slot}_sub_1_hours` }, + { text: '+1h', callback_data: `group_reminder${slot}_add_1_hours` }, + { text: '−1d', callback_data: `group_reminder${slot}_sub_1_days` }, + { text: '+1d', callback_data: `group_reminder${slot}_add_1_days` }, + ]; +} + +/** + * Check if a reminder time is already set + */ +function hasReminder(settings: GroupSettings, rt: ReminderTime): boolean { + if (!settings.reminderTimes) return false; + return settings.reminderTimes.some( + r => r.value === rt.value && r.unit === rt.unit + ); +} + /** * Generate inline keyboard for group settings */ -function getGroupSettingsKeyboard(settings: { - enabled: boolean; - drawAnnouncements: boolean; - reminders: boolean; - ticketPurchaseAllowed: boolean; -}): TelegramBot.InlineKeyboardMarkup { - const onOff = (val: boolean) => val ? '✅' : '❌'; +function getGroupSettingsKeyboard(settings: GroupSettings): TelegramBot.InlineKeyboardMarkup { + const onOff = (val: boolean | undefined) => val !== false ? '✅' : '❌'; + const selected = (current: number, option: number) => current === option ? '●' : '○'; - return { - inline_keyboard: [ - [{ - text: `${onOff(settings.enabled)} Bot Enabled`, - callback_data: 'group_toggle_enabled', - }], - [{ - text: `${onOff(settings.drawAnnouncements)} Draw Announcements`, - callback_data: 'group_toggle_announcements', - }], - [{ - text: `${onOff(settings.reminders)} Draw Reminders`, - callback_data: 'group_toggle_reminders', - }], - [{ - text: `${onOff(settings.ticketPurchaseAllowed)} Allow Ticket Purchases`, - callback_data: 'group_toggle_purchases', - }], - [{ - text: '🔄 Refresh', - callback_data: 'group_refresh', - }], - ], - }; + const keyboard: TelegramBot.InlineKeyboardButton[][] = [ + [{ + text: `${onOff(settings.enabled)} Bot Enabled`, + callback_data: 'group_toggle_enabled', + }], + [{ + text: `${onOff(settings.newJackpotAnnouncement)} New Jackpot Announcement`, + callback_data: 'group_toggle_newjackpot', + }], + [{ + text: `${onOff(settings.drawAnnouncements)} Draw Result Announcements`, + callback_data: 'group_toggle_announcements', + }], + ]; + + // Add announcement delay options if announcements are enabled + if (settings.drawAnnouncements) { + keyboard.push( + ANNOUNCEMENT_DELAY_OPTIONS.map(seconds => ({ + text: `${selected(settings.announcementDelaySeconds || 0, seconds)} ${formatDelayOption(seconds)}`, + callback_data: `group_announce_delay_${seconds}`, + })) + ); + } + + keyboard.push([{ + text: `${onOff(settings.reminders)} Draw Reminders`, + callback_data: 'group_toggle_reminders', + }]); + + // Add 3-tier reminder options if reminders are enabled + if (settings.reminders) { + // Get default values with fallback for migration + const r1Enabled = settings.reminder1Enabled !== false; + const r2Enabled = settings.reminder2Enabled === true; + const r3Enabled = settings.reminder3Enabled === true; + + // Get times with defaults + const r1Time = settings.reminder1Time || { value: 1, unit: 'hours' as const }; + const r2Time = settings.reminder2Time || { value: 1, unit: 'days' as const }; + const r3Time = settings.reminder3Time || { value: 6, unit: 'days' as const }; + + // Reminder 1 + keyboard.push([{ + text: `${onOff(r1Enabled)} Reminder 1: ${formatReminderTime(r1Time)} before`, + callback_data: 'group_toggle_reminder1', + }]); + if (r1Enabled) { + keyboard.push(getReminderTimeAdjustButtons(1, r1Time)); + } + + // Reminder 2 + keyboard.push([{ + text: `${onOff(r2Enabled)} Reminder 2: ${formatReminderTime(r2Time)} before`, + callback_data: 'group_toggle_reminder2', + }]); + if (r2Enabled) { + keyboard.push(getReminderTimeAdjustButtons(2, r2Time)); + } + + // Reminder 3 + keyboard.push([{ + text: `${onOff(r3Enabled)} Reminder 3: ${formatReminderTime(r3Time)} before`, + callback_data: 'group_toggle_reminder3', + }]); + if (r3Enabled) { + keyboard.push(getReminderTimeAdjustButtons(3, r3Time)); + } + } + + keyboard.push( + [{ + text: `${onOff(settings.ticketPurchaseAllowed)} Allow Ticket Purchases`, + callback_data: 'group_toggle_purchases', + }], + [{ + text: '🔄 Refresh', + callback_data: 'group_refresh', + }] + ); + + return { inline_keyboard: keyboard }; } /** @@ -254,6 +472,9 @@ export async function handleGroupRefresh( if (!chatId || !messageId) return; + // Refresh auto-delete timer + scheduleSettingsMessageDeletion(bot, chatId, messageId); + await bot.answerCallbackQuery(query.id, { text: 'Refreshed!' }); const settings = await groupStateManager.getGroup(chatId); diff --git a/telegram_bot/src/handlers/help.ts b/telegram_bot/src/handlers/help.ts index 26554a0..fa53cea 100644 --- a/telegram_bot/src/handlers/help.ts +++ b/telegram_bot/src/handlers/help.ts @@ -4,23 +4,32 @@ import { getMainMenuKeyboard } from '../utils/keyboards'; import { messages } from '../messages'; /** - * Handle /help command + * Handle /lottohelp command */ export async function handleHelpCommand( bot: TelegramBot, - msg: TelegramBot.Message + msg: TelegramBot.Message, + isGroup: boolean = false ): Promise { const chatId = msg.chat.id; const userId = msg.from?.id; if (userId) { - logUserAction(userId, 'Viewed help'); + logUserAction(userId, 'Viewed help', { isGroup }); } - await bot.sendMessage(chatId, messages.help.message, { - parse_mode: 'Markdown', - reply_markup: getMainMenuKeyboard(), - }); + if (isGroup) { + // Show group-specific help with admin commands + await bot.sendMessage(chatId, messages.help.groupMessage, { + parse_mode: 'Markdown', + }); + } else { + // Show user help in DM + await bot.sendMessage(chatId, messages.help.message, { + parse_mode: 'Markdown', + reply_markup: getMainMenuKeyboard(), + }); + } } export default handleHelpCommand; diff --git a/telegram_bot/src/handlers/index.ts b/telegram_bot/src/handlers/index.ts index 8ca20cd..83778c9 100644 --- a/telegram_bot/src/handlers/index.ts +++ b/telegram_bot/src/handlers/index.ts @@ -2,6 +2,7 @@ export { handleStart } from './start'; export { handleAddressCommand, handleLightningAddressInput, + handleLightningAddressCallback, } from './address'; export { handleBuyCommand, @@ -11,6 +12,7 @@ export { } from './buy'; export { handleTicketsCommand, + handleTicketsPage, handleViewTicket, handleStatusCheck, } from './tickets'; @@ -21,6 +23,11 @@ export { handleCancel, handleMenuCallback, } from './menu'; +export { + handleSettingsCommand, + handleSettingsCallback, + handleDisplayNameInput, +} from './settings'; export { handleBotAddedToGroup, handleBotRemovedFromGroup, diff --git a/telegram_bot/src/handlers/settings.ts b/telegram_bot/src/handlers/settings.ts new file mode 100644 index 0000000..1aff2cd --- /dev/null +++ b/telegram_bot/src/handlers/settings.ts @@ -0,0 +1,172 @@ +import TelegramBot from 'node-telegram-bot-api'; +import { stateManager } from '../services/state'; +import { logger, logUserAction } from '../services/logger'; +import { messages } from '../messages'; +import { getMainMenuKeyboard, getSettingsKeyboard } from '../utils/keyboards'; +import { NotificationPreferences, DEFAULT_NOTIFICATIONS } from '../types'; + +/** + * Handle /settings command (private chat only) + */ +export async function handleSettingsCommand( + bot: TelegramBot, + msg: TelegramBot.Message +): Promise { + const chatId = msg.chat.id; + const userId = msg.from?.id; + + if (!userId) return; + + // Only works in private chats + if (msg.chat.type !== 'private') { + await bot.sendMessage(chatId, '❌ This command only works in private chat. Message me directly!'); + return; + } + + logUserAction(userId, 'Viewed settings'); + + const user = await stateManager.getUser(userId); + if (!user) { + await bot.sendMessage(chatId, messages.errors.startFirst, { + reply_markup: getMainMenuKeyboard(), + }); + return; + } + + // Ensure notifications object exists + const notifications = user.notifications || { ...DEFAULT_NOTIFICATIONS }; + const displayName = user.displayName || 'Anon'; + + await bot.sendMessage( + chatId, + messages.settings.overview(displayName, notifications), + { + parse_mode: 'Markdown', + reply_markup: getSettingsKeyboard(displayName, notifications), + } + ); +} + +/** + * Handle settings callback + */ +export async function handleSettingsCallback( + bot: TelegramBot, + query: TelegramBot.CallbackQuery, + action: string +): Promise { + const chatId = query.message?.chat.id; + const userId = query.from.id; + const messageId = query.message?.message_id; + + if (!chatId || !messageId) return; + + const user = await stateManager.getUser(userId); + if (!user) { + await bot.answerCallbackQuery(query.id, { text: 'Please /start first' }); + return; + } + + try { + // Handle notification toggles + if (action.startsWith('toggle_notif_')) { + const setting = action.replace('toggle_notif_', '') as keyof NotificationPreferences; + const currentNotifications = user.notifications || { ...DEFAULT_NOTIFICATIONS }; + const newValue = !currentNotifications[setting]; + + const updatedUser = await stateManager.updateNotifications(userId, { [setting]: newValue }); + + if (updatedUser) { + logUserAction(userId, 'Updated notification setting', { setting, newValue }); + await bot.answerCallbackQuery(query.id, { + text: `${setting} ${newValue ? 'enabled' : 'disabled'}`, + }); + + // Update message + await bot.editMessageText( + messages.settings.overview(updatedUser.displayName || 'Anon', updatedUser.notifications), + { + chat_id: chatId, + message_id: messageId, + parse_mode: 'Markdown', + reply_markup: getSettingsKeyboard(updatedUser.displayName || 'Anon', updatedUser.notifications), + } + ); + } + return; + } + + // Handle display name change + if (action === 'change_name') { + await bot.answerCallbackQuery(query.id); + await stateManager.updateUserState(userId, 'awaiting_display_name'); + await bot.sendMessage( + chatId, + messages.settings.enterDisplayName, + { parse_mode: 'Markdown' } + ); + return; + } + + // Handle back to menu + if (action === 'back_menu') { + await bot.answerCallbackQuery(query.id); + await bot.deleteMessage(chatId, messageId); + await bot.sendMessage(chatId, messages.menu.header, { + parse_mode: 'Markdown', + reply_markup: getMainMenuKeyboard(), + }); + return; + } + + await bot.answerCallbackQuery(query.id); + } catch (error) { + logger.error('Error in handleSettingsCallback', { error, userId, action }); + await bot.answerCallbackQuery(query.id, { text: 'Error updating settings' }); + } +} + +/** + * Handle display name input + */ +export async function handleDisplayNameInput( + bot: TelegramBot, + msg: TelegramBot.Message +): Promise { + const chatId = msg.chat.id; + const userId = msg.from?.id; + const text = msg.text?.trim(); + + if (!userId || !text) return; + + // Validate display name (max 20 chars, alphanumeric + spaces + some symbols) + if (text.length > 20) { + await bot.sendMessage(chatId, messages.settings.nameTooLong); + return; + } + + // Clean the display name + const cleanName = text.replace(/[^\w\s\-_.]/g, '').trim() || 'Anon'; + + await stateManager.updateDisplayName(userId, cleanName); + logUserAction(userId, 'Set display name', { displayName: cleanName }); + + const user = await stateManager.getUser(userId); + const notifications = user?.notifications || { ...DEFAULT_NOTIFICATIONS }; + + await bot.sendMessage( + chatId, + messages.settings.nameUpdated(cleanName), + { + parse_mode: 'Markdown', + reply_markup: getSettingsKeyboard(cleanName, notifications), + } + ); +} + +export default { + handleSettingsCommand, + handleSettingsCallback, + handleDisplayNameInput, +}; + diff --git a/telegram_bot/src/handlers/start.ts b/telegram_bot/src/handlers/start.ts index f17df25..4df852f 100644 --- a/telegram_bot/src/handlers/start.ts +++ b/telegram_bot/src/handlers/start.ts @@ -1,7 +1,7 @@ import TelegramBot from 'node-telegram-bot-api'; import { stateManager } from '../services/state'; import { logger, logUserAction } from '../services/logger'; -import { getMainMenuKeyboard, getCancelKeyboard } from '../utils/keyboards'; +import { getMainMenuKeyboard, getLightningAddressKeyboard } from '../utils/keyboards'; import { messages } from '../messages'; /** @@ -10,6 +10,7 @@ import { messages } from '../messages'; export async function handleStart(bot: TelegramBot, msg: TelegramBot.Message): Promise { const chatId = msg.chat.id; const userId = msg.from?.id; + const username = msg.from?.username; if (!userId) { await bot.sendMessage(chatId, messages.errors.userNotIdentified); @@ -17,7 +18,7 @@ export async function handleStart(bot: TelegramBot, msg: TelegramBot.Message): P } logUserAction(userId, 'Started bot', { - username: msg.from?.username, + username: username, firstName: msg.from?.first_name, }); @@ -29,7 +30,7 @@ export async function handleStart(bot: TelegramBot, msg: TelegramBot.Message): P // Create new user user = await stateManager.createUser( userId, - msg.from?.username, + username, msg.from?.first_name, msg.from?.last_name ); @@ -40,9 +41,9 @@ export async function handleStart(bot: TelegramBot, msg: TelegramBot.Message): P // Check if lightning address is set if (!user.lightningAddress) { - await bot.sendMessage(chatId, messages.start.needAddress, { + await bot.sendMessage(chatId, messages.start.needAddressWithOptions(username), { parse_mode: 'Markdown', - reply_markup: getCancelKeyboard(), + reply_markup: getLightningAddressKeyboard(username), }); await stateManager.updateUserState(userId, 'awaiting_lightning_address'); diff --git a/telegram_bot/src/handlers/tickets.ts b/telegram_bot/src/handlers/tickets.ts index c7107ec..31e039c 100644 --- a/telegram_bot/src/handlers/tickets.ts +++ b/telegram_bot/src/handlers/tickets.ts @@ -7,12 +7,25 @@ import { getMainMenuKeyboard, getViewTicketKeyboard } from '../utils/keyboards'; import { formatSats, formatDate } from '../utils/format'; import { messages } from '../messages'; +const TICKETS_PER_PAGE = 5; + +interface PurchaseInfo { + id: string; + cycleId: string; + ticketCount: number; + scheduledAt: string; + invoiceStatus: string; + isWinner: boolean; + hasDrawn: boolean; +} + /** * Handle /tickets command or "My Tickets" button */ export async function handleTicketsCommand( bot: TelegramBot, - msg: TelegramBot.Message + msg: TelegramBot.Message, + page: number = 0 ): Promise { const chatId = msg.chat.id; const userId = msg.from?.id; @@ -22,8 +35,46 @@ export async function handleTicketsCommand( return; } - logUserAction(userId, 'Viewed tickets'); + logUserAction(userId, 'Viewed tickets', { page }); + await sendTicketsList(bot, chatId, userId, page); +} + +/** + * Handle tickets page navigation callback + */ +export async function handleTicketsPage( + bot: TelegramBot, + query: TelegramBot.CallbackQuery, + page: number +): Promise { + const chatId = query.message?.chat.id; + const userId = query.from.id; + const messageId = query.message?.message_id; + + if (!chatId || !messageId) return; + + await bot.answerCallbackQuery(query.id); + + // Delete old message and send new one + try { + await bot.deleteMessage(chatId, messageId); + } catch (e) { + // Ignore delete errors + } + + await sendTicketsList(bot, chatId, userId, page); +} + +/** + * Send tickets list with pagination + */ +async function sendTicketsList( + bot: TelegramBot, + chatId: number, + userId: number, + page: number +): Promise { try { const user = await stateManager.getUser(userId); @@ -32,8 +83,12 @@ export async function handleTicketsCommand( return; } - // Get user's purchase IDs from state - const purchaseIds = await stateManager.getUserPurchaseIds(userId, 10); + // Get current cycle to identify current round tickets + const currentJackpot = await apiClient.getNextJackpot(); + const currentCycleId = currentJackpot?.cycle?.id; + + // Get ALL user's purchase IDs from state (increase limit for pagination) + const purchaseIds = await stateManager.getUserPurchaseIds(userId, 100); if (purchaseIds.length === 0) { await bot.sendMessage(chatId, messages.tickets.empty, { @@ -44,21 +99,15 @@ export async function handleTicketsCommand( } // Fetch status for each purchase - const purchases: Array<{ - id: string; - ticketCount: number; - scheduledAt: string; - invoiceStatus: string; - isWinner: boolean; - hasDrawn: boolean; - }> = []; + const allPurchases: PurchaseInfo[] = []; for (const purchaseId of purchaseIds) { try { const status = await apiClient.getTicketStatus(purchaseId); if (status) { - purchases.push({ + allPurchases.push({ id: status.purchase.id, + cycleId: status.purchase.cycle_id, ticketCount: status.purchase.number_of_tickets, scheduledAt: status.cycle.scheduled_at, invoiceStatus: status.purchase.invoice_status, @@ -72,7 +121,7 @@ export async function handleTicketsCommand( } } - if (purchases.length === 0) { + if (allPurchases.length === 0) { await bot.sendMessage(chatId, messages.tickets.notFound, { parse_mode: 'Markdown', reply_markup: getMainMenuKeyboard(), @@ -80,56 +129,134 @@ export async function handleTicketsCommand( return; } - // Format purchases list - let message = messages.tickets.header; + // Separate current round and past tickets + const currentRoundTickets = allPurchases.filter(p => + p.cycleId === currentCycleId && !p.hasDrawn + ); + const pastTickets = allPurchases.filter(p => + p.cycleId !== currentCycleId || p.hasDrawn + ); - for (let i = 0; i < purchases.length; i++) { - const p = purchases[i]; - const drawDate = new Date(p.scheduledAt); + // Sort past tickets by date (newest first) + pastTickets.sort((a, b) => + new Date(b.scheduledAt).getTime() - new Date(a.scheduledAt).getTime() + ); + + // Build message + let message = ''; + const inlineKeyboard: TelegramBot.InlineKeyboardButton[][] = []; + + // Current round section (always shown on page 0) + if (page === 0 && currentRoundTickets.length > 0) { + message += `🎯 *Current Round*\n\n`; - let statusEmoji: string; - let statusText: string; - - if (p.invoiceStatus === 'pending') { - statusEmoji = '⏳'; - statusText = messages.tickets.statusPending; - } else if (p.invoiceStatus === 'expired') { - statusEmoji = '❌'; - statusText = messages.tickets.statusExpired; - } else if (!p.hasDrawn) { - statusEmoji = '🎟'; - statusText = messages.tickets.statusActive; - } else if (p.isWinner) { - statusEmoji = '🏆'; - statusText = messages.tickets.statusWon; - } else { - statusEmoji = '😔'; - statusText = messages.tickets.statusLost; + for (const p of currentRoundTickets) { + const drawDate = new Date(p.scheduledAt); + const statusInfo = getStatusInfo(p); + message += `${statusInfo.emoji} ${p.ticketCount} ticket${p.ticketCount > 1 ? 's' : ''} – Draw: ${formatDate(drawDate)}\n`; + + inlineKeyboard.push([{ + text: `🎟 View Current Tickets #${p.id.substring(0, 8)}...`, + callback_data: `view_ticket_${p.id}`, + }]); } - - message += `${i + 1}. ${statusEmoji} ${p.ticketCount} ticket${p.ticketCount > 1 ? 's' : ''} – ${formatDate(drawDate)} – ${statusText}\n`; + + if (pastTickets.length > 0) { + message += `\n📜 *Past Tickets*\n\n`; + } + } else if (page === 0) { + message += messages.tickets.header; + } else { + message += `📜 *Past Tickets (Page ${page + 1})*\n\n`; } - message += messages.tickets.tapForDetails; + // Calculate pagination for past tickets + const startIdx = page === 0 && currentRoundTickets.length > 0 + ? 0 + : page * TICKETS_PER_PAGE - (currentRoundTickets.length > 0 ? 0 : 0); + const adjustedStartIdx = page === 0 ? 0 : (page - 1) * TICKETS_PER_PAGE + (currentRoundTickets.length > 0 ? TICKETS_PER_PAGE : 0); + const pageStartIdx = page === 0 ? 0 : (page - (currentRoundTickets.length > 0 ? 1 : 0)) * TICKETS_PER_PAGE; + const ticketsToShow = pastTickets.slice(pageStartIdx, pageStartIdx + TICKETS_PER_PAGE); + + // Past tickets section + for (let i = 0; i < ticketsToShow.length; i++) { + const p = ticketsToShow[i]; + const drawDate = new Date(p.scheduledAt); + const statusInfo = getStatusInfo(p); + const globalIdx = pageStartIdx + i + 1; + + message += `${globalIdx}. ${statusInfo.emoji} ${p.ticketCount} ticket${p.ticketCount > 1 ? 's' : ''} – ${formatDate(drawDate)} – ${statusInfo.text}\n`; + + inlineKeyboard.push([{ + text: `${globalIdx}. ${statusInfo.emoji} View #${p.id.substring(0, 8)}...`, + callback_data: `view_ticket_${p.id}`, + }]); + } - // Create inline buttons for each purchase - const inlineKeyboard = purchases.map((p, i) => [{ - text: `${i + 1}. View Ticket #${p.id.substring(0, 8)}...`, - callback_data: `view_ticket_${p.id}`, - }]); + // Pagination buttons + const totalPastPages = Math.ceil(pastTickets.length / TICKETS_PER_PAGE); + const hasCurrentRound = currentRoundTickets.length > 0; + const effectivePage = hasCurrentRound ? page : page; + const maxPage = hasCurrentRound ? totalPastPages : totalPastPages - 1; + + const navButtons: TelegramBot.InlineKeyboardButton[] = []; + + if (page > 0) { + navButtons.push({ + text: '⬅️ Previous', + callback_data: `tickets_page_${page - 1}`, + }); + } + + if (pageStartIdx + TICKETS_PER_PAGE < pastTickets.length) { + navButtons.push({ + text: '➡️ Next', + callback_data: `tickets_page_${page + 1}`, + }); + } + + if (navButtons.length > 0) { + inlineKeyboard.push(navButtons); + } + + // Add page info if paginated + if (pastTickets.length > TICKETS_PER_PAGE) { + const currentPageNum = page + 1; + const totalPages = Math.ceil(pastTickets.length / TICKETS_PER_PAGE) + (hasCurrentRound ? 1 : 0); + message += `\n_Page ${currentPageNum} of ${totalPages}_`; + } + + message += `\n\nTap a ticket to view details.`; await bot.sendMessage(chatId, message, { parse_mode: 'Markdown', reply_markup: { inline_keyboard: inlineKeyboard }, }); } catch (error) { - logger.error('Error in handleTicketsCommand', { error, userId }); + logger.error('Error in sendTicketsList', { error, userId }); await bot.sendMessage(chatId, messages.errors.fetchTicketsFailed, { reply_markup: getMainMenuKeyboard(), }); } } +/** + * Get status emoji and text for a purchase + */ +function getStatusInfo(p: PurchaseInfo): { emoji: string; text: string } { + if (p.invoiceStatus === 'pending') { + return { emoji: '⏳', text: messages.tickets.statusPending }; + } else if (p.invoiceStatus === 'expired') { + return { emoji: '❌', text: messages.tickets.statusExpired }; + } else if (!p.hasDrawn) { + return { emoji: '🎟', text: messages.tickets.statusActive }; + } else if (p.isWinner) { + return { emoji: '🏆', text: messages.tickets.statusWon }; + } else { + return { emoji: '😔', text: messages.tickets.statusLost }; + } +} + /** * Handle viewing a specific ticket */ @@ -254,6 +381,7 @@ export async function handleStatusCheck( export default { handleTicketsCommand, + handleTicketsPage, handleViewTicket, handleStatusCheck, }; diff --git a/telegram_bot/src/index.ts b/telegram_bot/src/index.ts index 83be041..594b194 100644 --- a/telegram_bot/src/index.ts +++ b/telegram_bot/src/index.ts @@ -1,18 +1,22 @@ import TelegramBot from 'node-telegram-bot-api'; import config from './config'; +import { botDatabase } from './services/database'; import { stateManager } from './services/state'; import { groupStateManager } from './services/groupState'; import { apiClient } from './services/api'; import { logger, logUserAction } from './services/logger'; +import { notificationScheduler } from './services/notificationScheduler'; import { handleStart, handleAddressCommand, handleLightningAddressInput, + handleLightningAddressCallback, handleBuyCommand, handleTicketAmountSelection, handleCustomTicketAmount, handlePurchaseConfirmation, handleTicketsCommand, + handleTicketsPage, handleViewTicket, handleStatusCheck, handleWinsCommand, @@ -20,6 +24,9 @@ import { handleMenuCommand, handleCancel, handleMenuCallback, + handleSettingsCommand, + handleSettingsCallback, + handleDisplayNameInput, handleBotAddedToGroup, handleBotRemovedFromGroup, handleGroupSettings, @@ -90,7 +97,7 @@ bot.onText(/\/start/, async (msg) => { if (isGroupChat(msg)) { await bot.sendMessage( msg.chat.id, - `⚡ *Lightning Jackpot Bot*\n\nTo buy tickets and manage your account, message me directly!\n\nUse /jackpot to see current jackpot info.\nAdmins: Use /settings to configure the bot.`, + `⚡ *Lightning Jackpot Bot*\n\nTo buy tickets and manage your account, message me directly!\n\nUse /jackpot to see current jackpot info.\nUse /lottohelp for commands.\nAdmins: Use /lottosettings to configure the bot.`, { parse_mode: 'Markdown' } ); return; @@ -99,8 +106,8 @@ bot.onText(/\/start/, async (msg) => { await handleStart(bot, msg); }); -// Handle /buy command -bot.onText(/\/buy/, async (msg) => { +// Handle /buyticket command +bot.onText(/\/buyticket/, async (msg) => { if (!shouldProcessMessage(msg.message_id)) return; // Check if in group @@ -143,8 +150,8 @@ bot.onText(/\/wins/, async (msg) => { await handleWinsCommand(bot, msg); }); -// Handle /address command -bot.onText(/\/address/, async (msg) => { +// Handle /lottoaddress command +bot.onText(/\/lottoaddress/, async (msg) => { if (!shouldProcessMessage(msg.message_id)) return; // Only in private chat @@ -156,8 +163,8 @@ bot.onText(/\/address/, async (msg) => { await handleAddressCommand(bot, msg); }); -// Handle /menu command -bot.onText(/\/menu/, async (msg) => { +// Handle /lottomenu command +bot.onText(/\/lottomenu/, async (msg) => { if (!shouldProcessMessage(msg.message_id)) return; // Only in private chat @@ -169,10 +176,10 @@ bot.onText(/\/menu/, async (msg) => { await handleMenuCommand(bot, msg); }); -// Handle /help command -bot.onText(/\/help/, async (msg) => { +// Handle /lottohelp command +bot.onText(/\/lottohelp/, async (msg) => { if (!shouldProcessMessage(msg.message_id)) return; - await handleHelpCommand(bot, msg); + await handleHelpCommand(bot, msg, isGroupChat(msg)); }); // Handle /jackpot command (works in groups and DMs) @@ -197,7 +204,7 @@ bot.onText(/\/jackpot/, async (msg) => { ⏰ *Draw at:* ${formatDate(drawTime)} ⏳ *Time left:* ${formatTimeUntil(drawTime)} -Use /buy to get your tickets! 🍀`; +Use /buyticket to get your tickets! 🍀`; await bot.sendMessage(msg.chat.id, message, { parse_mode: 'Markdown' }); } catch (error) { @@ -206,8 +213,8 @@ Use /buy to get your tickets! 🍀`; } }); -// Handle /settings command (groups only, admin only) -bot.onText(/\/settings/, async (msg) => { +// Handle /lottosettings command (groups only, admin only) +bot.onText(/\/lottosettings/, async (msg) => { if (!shouldProcessMessage(msg.message_id)) return; await handleGroupSettings(bot, msg); }); @@ -243,6 +250,9 @@ bot.on('message', async (msg) => { case '⚡ Lightning Address': await handleAddressCommand(bot, msg); return; + case '⚙️ Settings': + await handleSettingsCommand(bot, msg); + return; case 'ℹ️ Help': await handleHelpCommand(bot, msg); return; @@ -264,6 +274,12 @@ bot.on('message', async (msg) => { if (handled) return; } + // Handle display name input + if (user.state === 'awaiting_display_name') { + await handleDisplayNameInput(bot, msg); + return; + } + // Handle custom ticket amount input if (user.state === 'awaiting_ticket_amount') { const handled = await handleCustomTicketAmount(bot, msg); @@ -304,12 +320,60 @@ bot.on('callback_query', async (query) => { return; } + // Handle group reminder time adjustment (reminder1_add_1_hours, etc.) + if (data.match(/^group_reminder\d_(add|sub)_\d+_(minutes|hours|days)$/)) { + const action = data.replace('group_', ''); + await handleGroupSettingsCallback(bot, query, action); + return; + } + + // Handle group add reminder (legacy) + if (data.startsWith('group_add_reminder_')) { + const action = data.replace('group_', ''); + await handleGroupSettingsCallback(bot, query, action); + return; + } + + // Handle group remove reminder (legacy) + if (data.startsWith('group_remove_reminder_')) { + const action = data.replace('group_', ''); + await handleGroupSettingsCallback(bot, query, action); + return; + } + + // Handle group clear reminders (legacy) + if (data === 'group_clear_reminders') { + await handleGroupSettingsCallback(bot, query, 'clear_reminders'); + return; + } + + // Handle group announcement delay selection + if (data.startsWith('group_announce_delay_')) { + const action = data.replace('group_', ''); + await handleGroupSettingsCallback(bot, query, action); + return; + } + // Handle group refresh if (data === 'group_refresh') { await handleGroupRefresh(bot, query); return; } + // Handle user settings callbacks + if (data.startsWith('settings_')) { + const action = data.replace('settings_', ''); + await handleSettingsCallback(bot, query, action); + return; + } + + // Handle lightning address selection (21Tipbot/Bittip) + if (data.startsWith('ln_addr_')) { + const action = data.replace('ln_addr_', ''); + await handleLightningAddressCallback(bot, query, action); + return; + } + // Handle buy amount selection if (data.startsWith('buy_')) { const amountStr = data.replace('buy_', ''); @@ -342,6 +406,15 @@ bot.on('callback_query', async (query) => { return; } + // Handle tickets pagination + if (data.startsWith('tickets_page_')) { + const page = parseInt(data.replace('tickets_page_', ''), 10); + if (!isNaN(page)) { + await handleTicketsPage(bot, query, page); + } + return; + } + // Handle view ticket if (data.startsWith('view_ticket_')) { const purchaseId = data.replace('view_ticket_', ''); @@ -389,6 +462,7 @@ async function shutdown(): Promise { bot.stopPolling(); await stateManager.close(); await groupStateManager.close(); + botDatabase.close(); logger.info('Shutdown complete'); process.exit(0); } @@ -399,28 +473,31 @@ process.on('SIGTERM', shutdown); // Start bot async function start(): Promise { try { - // Initialize state managers + // Initialize SQLite database + botDatabase.init(); + + // Initialize state managers (now use SQLite) await stateManager.init(); - await groupStateManager.init(config.redis.url); + await groupStateManager.init(); // Set bot commands for private chats await bot.setMyCommands([ { command: 'start', description: 'Start the bot' }, - { command: 'menu', description: 'Show main menu' }, - { command: 'buy', description: 'Buy lottery tickets' }, + { command: 'lottomenu', description: 'Show main menu' }, + { command: 'buyticket', description: 'Buy lottery tickets' }, { command: 'tickets', description: 'View your tickets' }, { command: 'wins', description: 'View your past wins' }, - { command: 'address', description: 'Update Lightning Address' }, + { command: 'lottoaddress', description: 'Update Lightning Address' }, { command: 'jackpot', description: 'View current jackpot info' }, - { command: 'help', description: 'Help & information' }, + { command: 'lottohelp', description: 'Help & information' }, ]); // Set bot commands for groups (different scope) await bot.setMyCommands( [ { command: 'jackpot', description: 'View current jackpot info' }, - { command: 'settings', description: 'Group settings (admin only)' }, - { command: 'help', description: 'Help & information' }, + { command: 'lottosettings', description: 'Group settings (admin only)' }, + { command: 'lottohelp', description: 'Help & information' }, ], { scope: { type: 'all_group_chats' } } ); @@ -431,10 +508,15 @@ async function start(): Promise { username: botInfo.username, }); + // Initialize and start notification scheduler + notificationScheduler.init(bot); + notificationScheduler.start(); + logger.info('⚡ Lightning Jackpot Telegram Bot is running!'); logger.info(`📡 API URL: ${config.api.baseUrl}`); logger.info(`🌐 Frontend URL: ${config.frontend.baseUrl}`); logger.info('👥 Group support enabled'); + logger.info('📢 Notification scheduler started'); } catch (error) { logger.error('Failed to start bot', { error }); process.exit(1); diff --git a/telegram_bot/src/messages/index.ts b/telegram_bot/src/messages/index.ts index bc61c19..35e2dd3 100644 --- a/telegram_bot/src/messages/index.ts +++ b/telegram_bot/src/messages/index.ts @@ -18,7 +18,7 @@ export const messages = { ticketNotFound: '❌ Ticket not found.', fetchTicketDetailsFailed: '❌ Failed to fetch ticket details.', checkStatusFailed: '❌ Failed to check status', - noPendingPurchase: '❌ No pending purchase. Please start again with /buy', + noPendingPurchase: '❌ No pending purchase. Please start again with /buyticket', setAddressFirst: '❌ Please set your Lightning Address first.', }, @@ -42,6 +42,21 @@ You can buy Bitcoin Lightning lottery tickets, and if you win, your prize is pai Please send your Lightning Address now:`, + needAddressWithOptions: (username?: string) => { + let msg = `Before you can play, I need your Lightning Address to send any winnings.\n\n`; + + if (username) { + msg += `🤖 *Quick Setup* - Choose a tip bot below:\n\n`; + msg += `Or send me your Lightning Address to set a custom one.\n`; + msg += `*Example:* \`yourname@getalby.com\``; + } else { + msg += `*Example:* \`yourname@getalby.com\`\n\n`; + msg += `Please send your Lightning Address now:`; + } + + return msg; + }, + addressSet: (address: string) => `✅ Your payout address is set to: \`${address}\` @@ -60,12 +75,40 @@ Use the menu below to get started! Good luck! 🍀`, Send me your new Lightning Address to update it:`, + currentAddressWithOptions: (address: string, username?: string) => { + let msg = `⚡ *Your Current Payout Address:*\n\`${address}\`\n\n`; + + if (username) { + msg += `🤖 *Quick Options* - Choose a tip bot below:\n\n`; + msg += `Or send me your Lightning Address to set a custom one.`; + } else { + msg += `Send me your new Lightning Address to update it:`; + } + + return msg; + }, + noAddressSet: `⚡ You don't have a Lightning Address set yet. Send me your Lightning Address now: *Example:* \`yourname@getalby.com\``, + noAddressSetWithOptions: (username?: string) => { + let msg = `⚡ You don't have a Lightning Address set yet.\n\n`; + + if (username) { + msg += `🤖 *Quick Setup* - Choose a tip bot below:\n\n`; + msg += `Or send me your Lightning Address to set a custom one.\n`; + msg += `*Example:* \`yourname@getalby.com\``; + } else { + msg += `Send me your Lightning Address now:\n\n`; + msg += `*Example:* \`yourname@getalby.com\``; + } + + return msg; + }, + invalidFormat: `❌ That doesn't look like a valid Lightning Address. *Format:* \`username@domain.com\` @@ -73,6 +116,31 @@ Send me your Lightning Address now: Please try again:`, + verifying: '🔍 Verifying your Lightning Address...', + + verificationFailed: (address: string, error?: string) => + `❌ *Could not verify Lightning Address* + +Address: \`${address}\` +${error ? `Error: ${error}\n` : ''} +Please check the address and try again, or choose a different option:`, + + verifyingService: (service: string, address: string) => + `🔍 Verifying your ${service} address: \`${address}\`...`, + + serviceNotSetup: (service: string, error?: string) => + `❌ *${service} address not found* + +It looks like you haven't set up ${service} yet, or your username doesn't match. + +${error ? `Error: ${error}\n\n` : ''}Please set up ${service} first, or enter a different Lightning Address:`, + + noUsername: `❌ You don't have a Telegram username set! + +To use 21Tipbot or Bittip, you need a Telegram username. + +Please set a username in Telegram settings, or enter a custom Lightning Address:`, + firstTimeSuccess: (address: string) => `✅ *Perfect!* I'll use \`${address}\` to send any winnings. @@ -153,16 +221,14 @@ Confirm this purchase?`, invoiceCaption: ( ticketCount: number, totalAmount: string, - paymentRequest: string, expiryMinutes: number ) => `🎟 *${ticketCount} ticket${ticketCount > 1 ? 's' : ''}* 💰 *Amount:* ${totalAmount} sats +⏳ Expires in ${expiryMinutes} minutes`, -\`${paymentRequest}\` - -⏳ This invoice expires in ${expiryMinutes} minutes. -I'll notify you when payment is received!`, + invoiceString: (paymentRequest: string) => + `\`${paymentRequest}\``, paymentReceived: (ticketNumbers: string, drawTime: string) => `🎉 *Payment Received!* @@ -180,13 +246,13 @@ Good luck! 🍀 I'll notify you after the draw!`, No payment was received in time. No tickets were issued. -Use /buy to try again.`, +Use /buyticket to try again.`, invoiceExpiredShort: `❌ *Invoice Expired* This invoice has expired. No tickets were issued. -Use /buy to try again.`, +Use /buyticket to try again.`, jackpotUnavailable: '❌ Jackpot is no longer available.', }, @@ -201,13 +267,13 @@ Use /buy to try again.`, You haven't purchased any tickets yet! -Use /buy to get started! 🎟`, +Use /buyticket to get started! 🎟`, notFound: `🧾 *Your Tickets* No ticket purchases found. Purchase history may have expired. -Use /buy to get new tickets! 🎟`, +Use /buyticket to get new tickets! 🎟`, tapForDetails: `\nTap a ticket below for details:`, @@ -266,13 +332,13 @@ ${statusSection}`, You haven't purchased any tickets yet, so no wins to show! -Use /buy to get started! 🎟`, +Use /buyticket to get started! 🎟`, noWinsYet: `🏆 *Your Wins* You haven't won any jackpots yet. Keep playing! -Use /buy to get more tickets! 🎟🍀`, +Use /buyticket to get more tickets! 🎟🍀`, header: (totalWinnings: string, paidWinnings: string) => `🏆 *Your Wins* @@ -300,20 +366,47 @@ This is the Lightning Jackpot lottery bot! Buy tickets with Bitcoin Lightning, a 5️⃣ If you win, sats are sent to your address instantly! *Commands:* -• /buy — Buy lottery tickets +• /buyticket — Buy lottery tickets • /tickets — View your tickets • /wins — View your past wins -• /address — Update Lightning Address -• /menu — Show main menu -• /help — Show this help +• /lottoaddress — Update Lightning Address +• /lottomenu — Show main menu +• /lottohelp — Show this help +• /jackpot — View current jackpot info + +*Settings (use ⚙️ Settings button):* +• Set your display name (shown if you win) +• Enable/disable draw reminders +• Enable/disable draw results notifications +• Enable/disable new jackpot alerts *Tips:* 🎟 Each ticket is one chance to win 💰 Prize pool grows with each ticket sold ⚡ Winnings are paid instantly via Lightning -🔔 You'll be notified after every draw +🔔 You'll be notified after every draw you participate in Good luck! 🍀`, + + groupMessage: `⚡🎰 *Lightning Jackpot Bot - Group Help* 🎰⚡ + +*User Commands:* +• /jackpot — View current jackpot info +• /buyticket — Buy lottery tickets +• /lottohelp — Show this help + +*Admin Commands:* +• /lottosettings — Configure bot settings for this group + +*Admin Settings:* +👉 *Bot Enabled* — Enable/disable the bot in this group +👉 *Draw Announcements* — Announce draw winners in this group +👉 *Draw Reminders* — Send reminders before draws +👉 *Ticket Purchases* — Allow /buyticket command in group + +💡 *Tip:* For privacy, ticket purchases in groups can be disabled. Users can always buy tickets by messaging the bot directly. + +To buy tickets privately, message me directly!`, }, // ═══════════════════════════════════════════════════════════════════════════ @@ -371,18 +464,104 @@ Good luck next round! 🍀`, Congratulations to the winner! ⚡ -Use /buy to enter the next draw! 🍀`, +Use /buyticket to enter the next draw! 🍀`, - drawReminder: (potSats: string, drawTime: string, timeLeft: string) => - `⏰ *Draw Reminder!* + drawCompleted: (potSats: number, hasWinner: boolean) => + hasWinner + ? `🎰 *JACKPOT DRAW COMPLETE!* 🎰 -🎰 The next Lightning Jackpot draw is coming up! +💰 *Jackpot:* ${potSats.toLocaleString()} sats +🏆 A winner has been selected! -💰 *Current Prize Pool:* ${potSats} sats -🕐 *Draw Time:* ${drawTime} -⏳ *Time Left:* ${timeLeft} +Use /buyticket to enter the next round! 🍀` + : `🎰 *JACKPOT DRAW COMPLETE!* 🎰 -Don't miss your chance to win! Use /buy to get your tickets! 🎟`, +No tickets were sold this round. +The jackpot rolls over to the next draw! + +Use /buyticket to be the first to enter! 🍀`, + + drawReminder: (value: number, unit: string, drawTime: Date, potSats: number) => { + const timeStr = unit === 'days' + ? `${value} day${value > 1 ? 's' : ''}` + : unit === 'hours' + ? `${value} hour${value > 1 ? 's' : ''}` + : `${value} minute${value > 1 ? 's' : ''}`; + + const drawTimeStr = drawTime.toLocaleString('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + timeZoneName: 'short', + }); + + return `⏰ *Draw Reminder!* + +🎰 The next Lightning Jackpot draw is in *${timeStr}*! + +💰 *Current Prize Pool:* ${potSats.toLocaleString()} sats +🕐 *Draw Time:* ${drawTimeStr} + +Don't miss your chance to win! Use /buyticket to get your tickets! 🎟`; + }, + + newJackpot: (lotteryName: string, ticketPrice: number, drawTime: Date) => { + const drawTimeStr = drawTime.toLocaleString('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + timeZoneName: 'short', + }); + + return `🎉 *NEW JACKPOT STARTED!* 🎉 + +⚡ *${lotteryName}* + +A new lottery round has begun! + +🎟 *Ticket Price:* ${ticketPrice} sats +🕐 *Draw Time:* ${drawTimeStr} + +Be first to enter! Use /buyticket to buy tickets! 🍀`; + }, + }, + + // ═══════════════════════════════════════════════════════════════════════════ + // USER SETTINGS + // ═══════════════════════════════════════════════════════════════════════════ + settings: { + overview: (displayName: string, notifications: { drawReminders: boolean; drawResults: boolean; newJackpotAlerts: boolean }) => + `⚙️ *Your Settings* + +👤 *Display Name:* ${displayName} +_(Used when announcing winners)_ + +*Notifications:* +${notifications.drawReminders ? '✅' : '❌'} Draw Reminders _(15 min before draws)_ +${notifications.drawResults ? '✅' : '❌'} Draw Results _(when your tickets are drawn)_ +${notifications.newJackpotAlerts ? '✅' : '❌'} New Jackpot Alerts _(when new rounds start)_ + +Tap buttons below to change settings:`, + + enterDisplayName: `👤 *Set Display Name* + +Enter your display name (max 20 characters). +This name will be shown if you win! + +_Send "Anon" to stay anonymous._`, + + nameTooLong: '❌ Display name must be 20 characters or less. Please try again:', + + nameUpdated: (name: string) => + `✅ *Display Name Updated!* + +Your display name is now: *${name}* + +This will be shown when announcing winners.`, }, // ═══════════════════════════════════════════════════════════════════════════ @@ -397,16 +576,16 @@ Hello *${groupName}*! I'm the Lightning Jackpot lottery bot. I can announce lottery draws and remind you when jackpots are coming up! *Group Admin Commands:* -• /settings — Configure bot settings for this group +• /lottosettings — Configure bot settings for this group *User Commands:* -• /buy — Buy lottery tickets (in DM) +• /buyticket — Buy lottery tickets • /jackpot — View current jackpot info -• /help — Get help +• /lottohelp — Get help -To buy tickets, message me directly @LightningLottoBot! 🎟`, +To buy tickets privately, message me directly! 🎟`, - privateChat: '❌ This command only works in groups. Use /menu to see available commands.', + privateChat: '❌ This command only works in groups. Use /lottomenu to see available commands.', adminOnly: '⚠️ Only group administrators can change these settings.', @@ -415,19 +594,59 @@ To buy tickets, message me directly @LightningLottoBot! 🎟`, enabled: boolean; drawAnnouncements: boolean; reminders: boolean; + newJackpotAnnouncement?: boolean; ticketPurchaseAllowed: boolean; - }) => - `⚙️ *Group Settings* + reminder1Enabled?: boolean; + reminder1Time?: { value: number; unit: string }; + reminder2Enabled?: boolean; + reminder2Time?: { value: number; unit: string }; + reminder3Enabled?: boolean; + reminder3Time?: { value: number; unit: string }; + announcementDelaySeconds?: number; + }) => { + const announceDelay = settings.announcementDelaySeconds ?? 10; + const formatAnnounce = announceDelay === 0 + ? 'Immediately' + : announceDelay >= 60 + ? `${announceDelay / 60} min after draw` + : `${announceDelay}s after draw`; + + // Format helper for reminder times + const formatTime = (t?: { value: number; unit: string }) => { + if (!t) return '?'; + if (t.unit === 'minutes') return `${t.value}m`; + if (t.unit === 'hours') return t.value === 1 ? '1h' : `${t.value}h`; + return t.value === 1 ? '1d' : `${t.value}d`; + }; + + // Format reminder times (3-tier system) + const r1 = settings.reminder1Enabled !== false; + const r2 = settings.reminder2Enabled === true; + const r3 = settings.reminder3Enabled === true; + const activeReminders: string[] = []; + if (r1) activeReminders.push(formatTime(settings.reminder1Time) || '1h'); + if (r2) activeReminders.push(formatTime(settings.reminder2Time) || '1d'); + if (r3) activeReminders.push(formatTime(settings.reminder3Time) || '6d'); + const formatReminderList = activeReminders.length > 0 + ? activeReminders.join(', ') + : 'None'; + + const newJackpot = settings.newJackpotAnnouncement !== false; + + return `⚙️ *Group Settings* 📍 *Group:* ${settings.groupTitle} *Current Configuration:* ${settings.enabled ? '✅' : '❌'} Bot Enabled -${settings.drawAnnouncements ? '✅' : '❌'} Draw Announcements -${settings.reminders ? '✅' : '❌'} Draw Reminders +${newJackpot ? '✅' : '❌'} New Jackpot Announcements +${settings.drawAnnouncements ? '✅' : '❌'} Draw Announcements ${settings.drawAnnouncements ? `_(${formatAnnounce})_` : ''} +${settings.reminders ? '✅' : '❌'} Draw Reminders ${settings.reminders ? `_(${formatReminderList})_` : ''} ${settings.ticketPurchaseAllowed ? '✅' : '❌'} Ticket Purchases in Group -Tap a button below to toggle settings:`, +_Tap buttons to toggle features or adjust times._ +_This message will auto-delete in 2 minutes._`; + }, settingUpdated: (setting: string, enabled: boolean) => `✅ *${setting}* has been ${enabled ? 'enabled' : 'disabled'}.`, diff --git a/telegram_bot/src/services/api.ts b/telegram_bot/src/services/api.ts index 8b12ae7..30b6067 100644 --- a/telegram_bot/src/services/api.ts +++ b/telegram_bot/src/services/api.ts @@ -70,7 +70,7 @@ class ApiClient { async buyTickets( tickets: number, lightningAddress: string, - telegramUserId: number + displayName: string = 'Anon' ): Promise { try { const response = await this.client.post>( @@ -78,7 +78,7 @@ class ApiClient { { tickets, lightning_address: lightningAddress, - buyer_name: `TG:${telegramUserId}`, + buyer_name: displayName || 'Anon', } ); return response.data.data; diff --git a/telegram_bot/src/services/database.ts b/telegram_bot/src/services/database.ts new file mode 100644 index 0000000..a2e423a --- /dev/null +++ b/telegram_bot/src/services/database.ts @@ -0,0 +1,649 @@ +import Database from 'better-sqlite3'; +import path from 'path'; +import { logger } from './logger'; +import { TelegramUser, NotificationPreferences, DEFAULT_NOTIFICATIONS } from '../types'; +import { GroupSettings, DEFAULT_GROUP_SETTINGS, ReminderTime } from '../types/groups'; + +const DB_PATH = process.env.BOT_DATABASE_PATH || path.join(__dirname, '../../data/bot.db'); + +class BotDatabase { + private db: Database.Database | null = null; + + /** + * Initialize the database + */ + init(): void { + try { + // Ensure data directory exists + const fs = require('fs'); + const dir = path.dirname(DB_PATH); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + this.db = new Database(DB_PATH); + this.db.pragma('journal_mode = WAL'); + + this.createTables(); + logger.info('Bot database initialized', { path: DB_PATH }); + } catch (error) { + logger.error('Failed to initialize bot database', { error }); + throw error; + } + } + + /** + * Create database tables + */ + private createTables(): void { + if (!this.db) return; + + // Users table + this.db.exec(` + CREATE TABLE IF NOT EXISTS users ( + telegram_id INTEGER PRIMARY KEY, + username TEXT, + first_name TEXT, + last_name TEXT, + display_name TEXT DEFAULT 'Anon', + lightning_address TEXT, + state TEXT DEFAULT 'idle', + state_data TEXT, + notif_draw_reminders INTEGER DEFAULT 1, + notif_draw_results INTEGER DEFAULT 1, + notif_new_jackpot_alerts INTEGER DEFAULT 1, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP + ) + `); + + // User purchases (tracking which purchases belong to which user) + this.db.exec(` + CREATE TABLE IF NOT EXISTS user_purchases ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + telegram_id INTEGER NOT NULL, + purchase_id TEXT NOT NULL UNIQUE, + cycle_id TEXT, + ticket_count INTEGER, + amount_sats INTEGER, + lightning_address TEXT, + payment_request TEXT, + public_url TEXT, + status TEXT DEFAULT 'pending', + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (telegram_id) REFERENCES users(telegram_id) + ) + `); + + // Cycle participants (for notifications) + this.db.exec(` + CREATE TABLE IF NOT EXISTS cycle_participants ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + cycle_id TEXT NOT NULL, + telegram_id INTEGER NOT NULL, + purchase_id TEXT NOT NULL, + joined_at TEXT DEFAULT CURRENT_TIMESTAMP, + UNIQUE(cycle_id, telegram_id, purchase_id), + FOREIGN KEY (telegram_id) REFERENCES users(telegram_id) + ) + `); + + // Groups table + this.db.exec(` + CREATE TABLE IF NOT EXISTS groups ( + group_id INTEGER PRIMARY KEY, + group_title TEXT, + enabled INTEGER DEFAULT 1, + draw_announcements INTEGER DEFAULT 1, + reminders INTEGER DEFAULT 1, + new_jackpot_announcement INTEGER DEFAULT 1, + ticket_purchase_allowed INTEGER DEFAULT 0, + reminder1_enabled INTEGER DEFAULT 1, + reminder1_time TEXT DEFAULT '{"value":1,"unit":"hours"}', + reminder2_enabled INTEGER DEFAULT 0, + reminder2_time TEXT DEFAULT '{"value":1,"unit":"days"}', + reminder3_enabled INTEGER DEFAULT 0, + reminder3_time TEXT DEFAULT '{"value":6,"unit":"days"}', + announcement_delay_seconds INTEGER DEFAULT 10, + added_by INTEGER, + added_at TEXT DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP + ) + `); + + // Create indexes + this.db.exec(` + CREATE INDEX IF NOT EXISTS idx_user_purchases_telegram_id ON user_purchases(telegram_id); + CREATE INDEX IF NOT EXISTS idx_user_purchases_cycle_id ON user_purchases(cycle_id); + CREATE INDEX IF NOT EXISTS idx_cycle_participants_cycle_id ON cycle_participants(cycle_id); + CREATE INDEX IF NOT EXISTS idx_cycle_participants_telegram_id ON cycle_participants(telegram_id); + `); + + logger.debug('Database tables created/verified'); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // USER METHODS + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Get user by Telegram ID + */ + getUser(telegramId: number): TelegramUser | null { + if (!this.db) return null; + + const row = this.db.prepare(` + SELECT * FROM users WHERE telegram_id = ? + `).get(telegramId) as any; + + if (!row) return null; + + return this.rowToUser(row); + } + + /** + * Create a new user + */ + createUser( + telegramId: number, + username?: string, + firstName?: string, + lastName?: string + ): TelegramUser { + if (!this.db) throw new Error('Database not initialized'); + + const now = new Date().toISOString(); + + this.db.prepare(` + INSERT INTO users (telegram_id, username, first_name, last_name, display_name, created_at, updated_at) + VALUES (?, ?, ?, ?, 'Anon', ?, ?) + `).run(telegramId, username || null, firstName || null, lastName || null, now, now); + + logger.info('New user created', { telegramId, username }); + + return this.getUser(telegramId)!; + } + + /** + * Update user + */ + saveUser(user: TelegramUser): void { + if (!this.db) return; + + const now = new Date().toISOString(); + const stateData = user.stateData ? JSON.stringify(user.stateData) : null; + + this.db.prepare(` + UPDATE users SET + username = ?, + first_name = ?, + last_name = ?, + display_name = ?, + lightning_address = ?, + state = ?, + state_data = ?, + notif_draw_reminders = ?, + notif_draw_results = ?, + notif_new_jackpot_alerts = ?, + updated_at = ? + WHERE telegram_id = ? + `).run( + user.username || null, + user.firstName || null, + user.lastName || null, + user.displayName || 'Anon', + user.lightningAddress || null, + user.state, + stateData, + user.notifications?.drawReminders ? 1 : 0, + user.notifications?.drawResults ? 1 : 0, + user.notifications?.newJackpotAlerts ? 1 : 0, + now, + user.telegramId + ); + } + + /** + * Update user state + */ + updateUserState(telegramId: number, state: string, stateData?: Record): void { + if (!this.db) return; + + const now = new Date().toISOString(); + const data = stateData ? JSON.stringify(stateData) : null; + + this.db.prepare(` + UPDATE users SET state = ?, state_data = ?, updated_at = ? WHERE telegram_id = ? + `).run(state, data, now, telegramId); + } + + /** + * Update lightning address + */ + updateLightningAddress(telegramId: number, address: string): void { + if (!this.db) return; + + const now = new Date().toISOString(); + + this.db.prepare(` + UPDATE users SET lightning_address = ?, state = 'idle', state_data = NULL, updated_at = ? + WHERE telegram_id = ? + `).run(address, now, telegramId); + } + + /** + * Update display name + */ + updateDisplayName(telegramId: number, displayName: string): void { + if (!this.db) return; + + const now = new Date().toISOString(); + + this.db.prepare(` + UPDATE users SET display_name = ?, state = 'idle', state_data = NULL, updated_at = ? + WHERE telegram_id = ? + `).run(displayName || 'Anon', now, telegramId); + } + + /** + * Update notification preferences + */ + updateNotifications(telegramId: number, updates: Partial): TelegramUser | null { + if (!this.db) return null; + + const user = this.getUser(telegramId); + if (!user) return null; + + const now = new Date().toISOString(); + const notifications = { ...user.notifications, ...updates }; + + this.db.prepare(` + UPDATE users SET + notif_draw_reminders = ?, + notif_draw_results = ?, + notif_new_jackpot_alerts = ?, + updated_at = ? + WHERE telegram_id = ? + `).run( + notifications.drawReminders ? 1 : 0, + notifications.drawResults ? 1 : 0, + notifications.newJackpotAlerts ? 1 : 0, + now, + telegramId + ); + + return this.getUser(telegramId); + } + + /** + * Get users with specific notification enabled + */ + getUsersWithNotification(preference: keyof NotificationPreferences): TelegramUser[] { + if (!this.db) return []; + + const column = preference === 'drawReminders' ? 'notif_draw_reminders' + : preference === 'drawResults' ? 'notif_draw_results' + : 'notif_new_jackpot_alerts'; + + const rows = this.db.prepare(` + SELECT * FROM users WHERE ${column} = 1 + `).all() as any[]; + + return rows.map(row => this.rowToUser(row)); + } + + /** + * Convert database row to TelegramUser + */ + private rowToUser(row: any): TelegramUser { + return { + telegramId: row.telegram_id, + username: row.username || undefined, + firstName: row.first_name || undefined, + lastName: row.last_name || undefined, + displayName: row.display_name || 'Anon', + lightningAddress: row.lightning_address || undefined, + state: row.state || 'idle', + stateData: row.state_data ? JSON.parse(row.state_data) : undefined, + notifications: { + drawReminders: row.notif_draw_reminders === 1, + drawResults: row.notif_draw_results === 1, + newJackpotAlerts: row.notif_new_jackpot_alerts === 1, + }, + createdAt: new Date(row.created_at), + updatedAt: new Date(row.updated_at), + }; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // PURCHASE METHODS + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Store a purchase + */ + storePurchase( + telegramId: number, + purchaseId: string, + data: { + cycleId: string; + ticketCount: number; + totalAmount: number; + lightningAddress: string; + paymentRequest: string; + publicUrl: string; + } + ): void { + if (!this.db) return; + + this.db.prepare(` + INSERT OR REPLACE INTO user_purchases + (telegram_id, purchase_id, cycle_id, ticket_count, amount_sats, lightning_address, payment_request, public_url, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending') + `).run( + telegramId, + purchaseId, + data.cycleId, + data.ticketCount, + data.totalAmount, + data.lightningAddress, + data.paymentRequest, + data.publicUrl + ); + } + + /** + * Update purchase status + */ + updatePurchaseStatus(purchaseId: string, status: string): void { + if (!this.db) return; + + this.db.prepare(` + UPDATE user_purchases SET status = ? WHERE purchase_id = ? + `).run(status, purchaseId); + } + + /** + * Get user's recent purchase IDs + */ + getUserPurchaseIds(telegramId: number, limit: number = 10): string[] { + if (!this.db) return []; + + const rows = this.db.prepare(` + SELECT purchase_id FROM user_purchases + WHERE telegram_id = ? + ORDER BY created_at DESC + LIMIT ? + `).all(telegramId, limit) as any[]; + + return rows.map(row => row.purchase_id); + } + + /** + * Get purchase by ID + */ + getPurchase(purchaseId: string): any | null { + if (!this.db) return null; + + return this.db.prepare(` + SELECT * FROM user_purchases WHERE purchase_id = ? + `).get(purchaseId); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // CYCLE PARTICIPANT METHODS + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Add user as cycle participant + */ + addCycleParticipant(cycleId: string, telegramId: number, purchaseId: string): void { + if (!this.db) return; + + try { + this.db.prepare(` + INSERT OR IGNORE INTO cycle_participants (cycle_id, telegram_id, purchase_id) + VALUES (?, ?, ?) + `).run(cycleId, telegramId, purchaseId); + } catch (error) { + // Ignore duplicate entries + } + } + + /** + * Get cycle participants + */ + getCycleParticipants(cycleId: string): Array<{ telegramId: number; purchaseId: string }> { + if (!this.db) return []; + + const rows = this.db.prepare(` + SELECT telegram_id, purchase_id FROM cycle_participants WHERE cycle_id = ? + `).all(cycleId) as any[]; + + return rows.map(row => ({ + telegramId: row.telegram_id, + purchaseId: row.purchase_id, + })); + } + + /** + * Check if user participated in cycle + */ + didUserParticipate(cycleId: string, telegramId: number): boolean { + if (!this.db) return false; + + const row = this.db.prepare(` + SELECT 1 FROM cycle_participants WHERE cycle_id = ? AND telegram_id = ? LIMIT 1 + `).get(cycleId, telegramId); + + return !!row; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // GROUP METHODS + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Get group settings + */ + getGroup(groupId: number): GroupSettings | null { + if (!this.db) return null; + + const row = this.db.prepare(` + SELECT * FROM groups WHERE group_id = ? + `).get(groupId) as any; + + if (!row) return null; + + return this.rowToGroup(row); + } + + /** + * Register or update group + */ + registerGroup(groupId: number, groupTitle: string, addedBy: number): GroupSettings { + if (!this.db) throw new Error('Database not initialized'); + + const existing = this.getGroup(groupId); + + if (existing) { + // Update title + this.db.prepare(` + UPDATE groups SET group_title = ?, updated_at = CURRENT_TIMESTAMP WHERE group_id = ? + `).run(groupTitle, groupId); + return this.getGroup(groupId)!; + } + + // Insert new group + this.db.prepare(` + INSERT INTO groups (group_id, group_title, added_by) + VALUES (?, ?, ?) + `).run(groupId, groupTitle, addedBy); + + logger.info('New group registered', { groupId, groupTitle, addedBy }); + return this.getGroup(groupId)!; + } + + /** + * Remove group + */ + removeGroup(groupId: number): void { + if (!this.db) return; + + this.db.prepare(`DELETE FROM groups WHERE group_id = ?`).run(groupId); + logger.info('Group removed', { groupId }); + } + + /** + * Save group settings + */ + saveGroup(settings: GroupSettings): void { + if (!this.db) return; + + this.db.prepare(` + UPDATE groups SET + group_title = ?, + enabled = ?, + draw_announcements = ?, + reminders = ?, + new_jackpot_announcement = ?, + ticket_purchase_allowed = ?, + reminder1_enabled = ?, + reminder1_time = ?, + reminder2_enabled = ?, + reminder2_time = ?, + reminder3_enabled = ?, + reminder3_time = ?, + announcement_delay_seconds = ?, + updated_at = CURRENT_TIMESTAMP + WHERE group_id = ? + `).run( + settings.groupTitle, + settings.enabled ? 1 : 0, + settings.drawAnnouncements ? 1 : 0, + settings.reminders ? 1 : 0, + settings.newJackpotAnnouncement ? 1 : 0, + settings.ticketPurchaseAllowed ? 1 : 0, + settings.reminder1Enabled ? 1 : 0, + JSON.stringify(settings.reminder1Time), + settings.reminder2Enabled ? 1 : 0, + JSON.stringify(settings.reminder2Time), + settings.reminder3Enabled ? 1 : 0, + JSON.stringify(settings.reminder3Time), + settings.announcementDelaySeconds, + settings.groupId + ); + } + + /** + * Update a boolean group setting + */ + updateGroupSetting( + groupId: number, + setting: string, + value: boolean + ): GroupSettings | null { + const group = this.getGroup(groupId); + if (!group) return null; + + (group as any)[setting] = value; + this.saveGroup(group); + return this.getGroup(groupId); + } + + /** + * Update reminder time + */ + updateReminderTime(groupId: number, slot: 1 | 2 | 3, time: ReminderTime): GroupSettings | null { + const group = this.getGroup(groupId); + if (!group) return null; + + switch (slot) { + case 1: group.reminder1Time = time; break; + case 2: group.reminder2Time = time; break; + case 3: group.reminder3Time = time; break; + } + + this.saveGroup(group); + return this.getGroup(groupId); + } + + /** + * Update announcement delay + */ + updateAnnouncementDelay(groupId: number, seconds: number): GroupSettings | null { + const group = this.getGroup(groupId); + if (!group) return null; + + group.announcementDelaySeconds = seconds; + this.saveGroup(group); + return this.getGroup(groupId); + } + + /** + * Get groups with a specific feature enabled + */ + getGroupsWithFeature(feature: 'enabled' | 'drawAnnouncements' | 'reminders'): GroupSettings[] { + if (!this.db) return []; + + const column = feature === 'enabled' ? 'enabled' + : feature === 'drawAnnouncements' ? 'draw_announcements' + : 'reminders'; + + const rows = this.db.prepare(` + SELECT * FROM groups WHERE enabled = 1 AND ${column} = 1 + `).all() as any[]; + + return rows.map(row => this.rowToGroup(row)); + } + + /** + * Get all groups + */ + getAllGroups(): GroupSettings[] { + if (!this.db) return []; + + const rows = this.db.prepare(`SELECT * FROM groups`).all() as any[]; + return rows.map(row => this.rowToGroup(row)); + } + + /** + * Convert database row to GroupSettings + */ + private rowToGroup(row: any): GroupSettings { + return { + groupId: row.group_id, + groupTitle: row.group_title || 'Group', + enabled: row.enabled === 1, + drawAnnouncements: row.draw_announcements === 1, + reminders: row.reminders === 1, + newJackpotAnnouncement: row.new_jackpot_announcement === 1, + ticketPurchaseAllowed: row.ticket_purchase_allowed === 1, + reminder1Enabled: row.reminder1_enabled === 1, + reminder1Time: row.reminder1_time ? JSON.parse(row.reminder1_time) : { value: 1, unit: 'hours' }, + reminder2Enabled: row.reminder2_enabled === 1, + reminder2Time: row.reminder2_time ? JSON.parse(row.reminder2_time) : { value: 1, unit: 'days' }, + reminder3Enabled: row.reminder3_enabled === 1, + reminder3Time: row.reminder3_time ? JSON.parse(row.reminder3_time) : { value: 6, unit: 'days' }, + reminderTimes: [], // Legacy field + announcementDelaySeconds: row.announcement_delay_seconds || 10, + addedBy: row.added_by, + addedAt: new Date(row.added_at), + updatedAt: new Date(row.updated_at), + }; + } + + /** + * Close database connection + */ + close(): void { + if (this.db) { + this.db.close(); + logger.info('Bot database connection closed'); + } + } +} + +export const botDatabase = new BotDatabase(); +export default botDatabase; + diff --git a/telegram_bot/src/services/groupState.ts b/telegram_bot/src/services/groupState.ts index ffc6244..524a8ff 100644 --- a/telegram_bot/src/services/groupState.ts +++ b/telegram_bot/src/services/groupState.ts @@ -1,225 +1,213 @@ -import Redis from 'ioredis'; -import config from '../config'; +import { botDatabase } from './database'; import { logger } from './logger'; -import { GroupSettings, DEFAULT_GROUP_SETTINGS } from '../types/groups'; - -const GROUP_PREFIX = 'tg_group:'; -const GROUPS_LIST_KEY = 'tg_groups_list'; -const STATE_TTL = 60 * 60 * 24 * 365; // 1 year +import { GroupSettings, ReminderTime, reminderTimeToMinutes } from '../types/groups'; class GroupStateManager { - private redis: Redis | null = null; - private memoryStore: Map = new Map(); - private useRedis: boolean = false; - - async init(redisUrl: string | null): Promise { - if (redisUrl) { - try { - this.redis = new Redis(redisUrl); - await this.redis.ping(); - this.useRedis = true; - logger.info('Group state manager initialized with Redis'); - } catch (error) { - logger.warn('Failed to connect to Redis for groups, using in-memory store'); - this.redis = null; - this.useRedis = false; - } - } - } - - private async get(key: string): Promise { - if (this.useRedis && this.redis) { - return await this.redis.get(key); - } - return this.memoryStore.get(key) || null; - } - - private async set(key: string, value: string, ttl?: number): Promise { - if (this.useRedis && this.redis) { - if (ttl) { - await this.redis.setex(key, ttl, value); - } else { - await this.redis.set(key, value); - } - } else { - this.memoryStore.set(key, value); - } - } - - private async del(key: string): Promise { - if (this.useRedis && this.redis) { - await this.redis.del(key); - } else { - this.memoryStore.delete(key); - } - } - - private async sadd(key: string, value: string): Promise { - if (this.useRedis && this.redis) { - await this.redis.sadd(key, value); - } else { - const existing = this.memoryStore.get(key); - const set = existing ? new Set(JSON.parse(existing)) : new Set(); - set.add(value); - this.memoryStore.set(key, JSON.stringify([...set])); - } - } - - private async srem(key: string, value: string): Promise { - if (this.useRedis && this.redis) { - await this.redis.srem(key, value); - } else { - const existing = this.memoryStore.get(key); - if (existing) { - const set = new Set(JSON.parse(existing)); - set.delete(value); - this.memoryStore.set(key, JSON.stringify([...set])); - } - } - } - - private async smembers(key: string): Promise { - if (this.useRedis && this.redis) { - return await this.redis.smembers(key); - } - const existing = this.memoryStore.get(key); - return existing ? JSON.parse(existing) : []; + async init(): Promise { + // Database is initialized separately + logger.info('Group state manager initialized (using SQLite database)'); } /** * Get group settings */ async getGroup(groupId: number): Promise { - const key = `${GROUP_PREFIX}${groupId}`; - const data = await this.get(key); - if (!data) return null; - - try { - const settings = JSON.parse(data); - return { - ...settings, - addedAt: new Date(settings.addedAt), - updatedAt: new Date(settings.updatedAt), - }; - } catch (error) { - logger.error('Failed to parse group settings', { groupId, error }); - return null; - } + return botDatabase.getGroup(groupId); } /** - * Create or update group settings - */ - async saveGroup(settings: GroupSettings): Promise { - const key = `${GROUP_PREFIX}${settings.groupId}`; - settings.updatedAt = new Date(); - await this.set(key, JSON.stringify(settings), STATE_TTL); - await this.sadd(GROUPS_LIST_KEY, settings.groupId.toString()); - logger.debug('Group settings saved', { groupId: settings.groupId }); - } - - /** - * Register a new group when bot is added + * Register a new group */ async registerGroup( groupId: number, groupTitle: string, addedBy: number ): Promise { - const existing = await this.getGroup(groupId); - - if (existing) { - // Update title if changed - existing.groupTitle = groupTitle; - existing.updatedAt = new Date(); - await this.saveGroup(existing); - return existing; - } - - const settings: GroupSettings = { - groupId, - groupTitle, - ...DEFAULT_GROUP_SETTINGS, - addedBy, - addedAt: new Date(), - updatedAt: new Date(), - }; - - await this.saveGroup(settings); - logger.info('New group registered', { groupId, groupTitle, addedBy }); - return settings; + return botDatabase.registerGroup(groupId, groupTitle, addedBy); } /** - * Remove group when bot is removed + * Remove a group */ async removeGroup(groupId: number): Promise { - const key = `${GROUP_PREFIX}${groupId}`; - await this.del(key); - await this.srem(GROUPS_LIST_KEY, groupId.toString()); - logger.info('Group removed', { groupId }); + botDatabase.removeGroup(groupId); } /** - * Update a specific setting + * Save group settings + */ + async saveGroup(settings: GroupSettings): Promise { + botDatabase.saveGroup(settings); + logger.debug('Group settings saved', { groupId: settings.groupId }); + } + + /** + * Update a group setting */ async updateSetting( groupId: number, - setting: keyof Pick, + setting: + | 'enabled' + | 'drawAnnouncements' + | 'reminders' + | 'newJackpotAnnouncement' + | 'ticketPurchaseAllowed' + | 'reminder1Enabled' + | 'reminder2Enabled' + | 'reminder3Enabled', value: boolean ): Promise { - const settings = await this.getGroup(groupId); - if (!settings) return null; - - settings[setting] = value; - await this.saveGroup(settings); - return settings; + return botDatabase.updateGroupSetting(groupId, setting, value); } /** - * Get all groups with a specific feature enabled + * Update reminder time for a slot + */ + async updateReminderTime( + groupId: number, + slot: 1 | 2 | 3, + time: ReminderTime + ): Promise { + return botDatabase.updateReminderTime(groupId, slot, time); + } + + /** + * Update announcement delay + */ + async updateAnnouncementDelay( + groupId: number, + seconds: number + ): Promise { + return botDatabase.updateAnnouncementDelay(groupId, seconds); + } + + /** + * Get groups with specific feature enabled */ async getGroupsWithFeature( - feature: 'enabled' | 'drawAnnouncements' | 'reminders' + feature: 'enabled' | 'drawAnnouncements' | 'reminders' | 'newJackpotAnnouncement' ): Promise { - const groupIds = await this.smembers(GROUPS_LIST_KEY); - const groups: GroupSettings[] = []; - - for (const id of groupIds) { - const settings = await this.getGroup(parseInt(id, 10)); - if (settings && settings.enabled && settings[feature]) { - groups.push(settings); - } + if (feature === 'newJackpotAnnouncement') { + const allGroups = await this.getAllGroups(); + return allGroups.filter(g => g.enabled && g.newJackpotAnnouncement); } - - return groups; + return botDatabase.getGroupsWithFeature(feature as 'enabled' | 'drawAnnouncements' | 'reminders'); } /** - * Get all registered groups + * Get all groups */ async getAllGroups(): Promise { - const groupIds = await this.smembers(GROUPS_LIST_KEY); - const groups: GroupSettings[] = []; + return botDatabase.getAllGroups(); + } - for (const id of groupIds) { - const settings = await this.getGroup(parseInt(id, 10)); - if (settings) { - groups.push(settings); + /** + * Get groups that need reminders for a specific draw time + */ + async getGroupsNeedingReminders(drawTime: Date): Promise> { + const allGroups = await this.getGroupsWithFeature('reminders'); + const now = new Date(); + const minutesUntilDraw = (drawTime.getTime() - now.getTime()) / (1000 * 60); + const results: Array<{ settings: GroupSettings; reminderSlot: 1 | 2 | 3 }> = []; + + for (const group of allGroups) { + // Check each reminder slot + if (group.reminder1Enabled) { + const reminderMinutes = reminderTimeToMinutes(group.reminder1Time); + if (Math.abs(minutesUntilDraw - reminderMinutes) < 1) { + results.push({ settings: group, reminderSlot: 1 }); + } + } + + if (group.reminder2Enabled) { + const reminderMinutes = reminderTimeToMinutes(group.reminder2Time); + if (Math.abs(minutesUntilDraw - reminderMinutes) < 1) { + results.push({ settings: group, reminderSlot: 2 }); + } + } + + if (group.reminder3Enabled) { + const reminderMinutes = reminderTimeToMinutes(group.reminder3Time); + if (Math.abs(minutesUntilDraw - reminderMinutes) < 1) { + results.push({ settings: group, reminderSlot: 3 }); + } } } - return groups; + return results; } - async close(): Promise { - if (this.redis) { - await this.redis.quit(); + /** + * Add time to a reminder + */ + async addReminderTime( + groupId: number, + slot: 1 | 2 | 3, + amount: number, + unit: 'minutes' | 'hours' | 'days' + ): Promise { + const group = await this.getGroup(groupId); + if (!group) return null; + + const timeKey = `reminder${slot}Time` as 'reminder1Time' | 'reminder2Time' | 'reminder3Time'; + const currentTime = group[timeKey]; + + // Convert everything to minutes, add, then convert back + let totalMinutes = reminderTimeToMinutes(currentTime); + + switch (unit) { + case 'minutes': totalMinutes += amount; break; + case 'hours': totalMinutes += amount * 60; break; + case 'days': totalMinutes += amount * 24 * 60; break; } + + // Ensure minimum of 1 minute + totalMinutes = Math.max(1, totalMinutes); + + // Convert back to best unit + const newTime = this.minutesToReminderTime(totalMinutes); + return this.updateReminderTime(groupId, slot, newTime); + } + + /** + * Remove time from a reminder + */ + async removeReminderTime( + groupId: number, + slot: 1 | 2 | 3, + amount: number, + unit: 'minutes' | 'hours' | 'days' + ): Promise { + return this.addReminderTime(groupId, slot, -amount, unit); + } + + /** + * Convert total minutes to the best ReminderTime representation + */ + private minutesToReminderTime(totalMinutes: number): ReminderTime { + // Use days if evenly divisible and >= 1 day + if (totalMinutes >= 1440 && totalMinutes % 1440 === 0) { + return { value: totalMinutes / 1440, unit: 'days' }; + } + // Use hours if evenly divisible and >= 1 hour + if (totalMinutes >= 60 && totalMinutes % 60 === 0) { + return { value: totalMinutes / 60, unit: 'hours' }; + } + // Use minutes + return { value: totalMinutes, unit: 'minutes' }; + } + + /** + * Shutdown + */ + async close(): Promise { + // Database close is handled separately + logger.info('Group state manager closed'); } } export const groupStateManager = new GroupStateManager(); export default groupStateManager; - - diff --git a/telegram_bot/src/services/notificationScheduler.ts b/telegram_bot/src/services/notificationScheduler.ts new file mode 100644 index 0000000..2ff15c9 --- /dev/null +++ b/telegram_bot/src/services/notificationScheduler.ts @@ -0,0 +1,661 @@ +import TelegramBot from 'node-telegram-bot-api'; +import { groupStateManager } from './groupState'; +import { stateManager } from './state'; +import { apiClient } from './api'; +import { logger } from './logger'; +import { messages } from '../messages'; +import { GroupSettings, reminderTimeToMinutes, formatReminderTime, ReminderTime, DEFAULT_GROUP_REMINDER_SLOTS } from '../types/groups'; +import { TelegramUser } from '../types'; + +interface CycleInfo { + id: string; + scheduled_at: string; + status: string; + pot_total_sats: number; +} + +interface ScheduledReminder { + groupId?: number; + telegramId?: number; + cycleId: string; + reminderKey: string; + scheduledFor: Date; + timeout: NodeJS.Timeout; +} + +class NotificationScheduler { + private bot: TelegramBot | null = null; + private pollInterval: NodeJS.Timeout | null = null; + private scheduledReminders: Map = new Map(); + private lastCycleId: string | null = null; + private lastCycleStatus: string | null = null; + private isRunning = false; + private announcedCycles: Set = new Set(); // Track announced cycles + + /** + * Initialize the scheduler with the bot instance + */ + init(bot: TelegramBot): void { + this.bot = bot; + logger.info('Notification scheduler initialized'); + } + + /** + * Start the scheduler + */ + start(): void { + if (this.isRunning || !this.bot) { + return; + } + + this.isRunning = true; + logger.info('Starting notification scheduler'); + + // Poll every 30 seconds + this.pollInterval = setInterval(() => this.poll(), 30 * 1000); + + // Run immediately + this.poll(); + } + + /** + * Stop the scheduler + */ + stop(): void { + if (this.pollInterval) { + clearInterval(this.pollInterval); + this.pollInterval = null; + } + + // Clear all scheduled reminders + for (const reminder of this.scheduledReminders.values()) { + clearTimeout(reminder.timeout); + } + this.scheduledReminders.clear(); + + this.isRunning = false; + logger.info('Notification scheduler stopped'); + } + + /** + * Main poll loop + */ + private async poll(): Promise { + try { + const jackpot = await apiClient.getNextJackpot(); + if (!jackpot?.cycle) { + return; + } + + const cycle = jackpot.cycle; + const lottery = jackpot.lottery; + + // Check for draw completion (same cycle, status changed to completed) + if (this.lastCycleId === cycle.id && + this.lastCycleStatus !== 'completed' && + cycle.status === 'completed') { + await this.handleDrawCompleted(cycle); + } + + // Check for new cycle (new jackpot started) + // IMPORTANT: When we detect a new cycle, the old one is completed + // Send draw completion for the old cycle BEFORE new cycle announcement + if (this.lastCycleId && this.lastCycleId !== cycle.id) { + // The previous cycle has completed - announce the draw result first + await this.handlePreviousCycleCompleted(this.lastCycleId); + + // Then announce the new cycle + await this.handleNewCycle(cycle, lottery); + } + + // Schedule reminders for current cycle + await this.scheduleGroupReminders(cycle); + await this.scheduleUserReminders(cycle); + + this.lastCycleId = cycle.id; + this.lastCycleStatus = cycle.status; + } catch (error) { + logger.error('Error in notification scheduler poll', { error }); + } + } + + /** + * Handle previous cycle completion when we detect a new cycle + */ + private async handlePreviousCycleCompleted(previousCycleId: string): Promise { + if (!this.bot) return; + + // Check if we've already announced this draw + if (this.announcedCycles.has(`draw:${previousCycleId}`)) { + return; + } + this.announcedCycles.add(`draw:${previousCycleId}`); + + // Clear reminders for the old cycle + this.clearRemindersForCycle(previousCycleId); + + // Get participants for the previous cycle + const participants = await stateManager.getCycleParticipants(previousCycleId); + const hasParticipants = participants.length > 0; + + logger.info('Processing previous cycle completion', { + cycleId: previousCycleId, + participantCount: participants.length + }); + + // Get draw result details for winner announcement + let winnerDisplayName = 'Anon'; + let winnerTicketNumber = '0000'; + let potSats = 0; + + // Notify each participant about their result + for (const participant of participants) { + try { + const user = await stateManager.getUser(participant.telegramId); + if (!user) continue; + + // Check if they won + const status = await apiClient.getTicketStatus(participant.purchaseId); + if (!status) continue; + + potSats = status.cycle.pot_total_sats || 0; + const isWinner = status.result.is_winner; + + if (isWinner) { + // Store winner info for group announcement + winnerDisplayName = user.displayName || 'Anon'; + const winningTicket = status.tickets.find(t => t.is_winning_ticket); + if (winningTicket) { + winnerTicketNumber = winningTicket.serial_number.toString().padStart(4, '0'); + } + + // Send winner notification if user has drawResults enabled + if (user.notifications?.drawResults !== false) { + const prizeSats = status.result.payout?.amount_sats || potSats; + const payoutStatus = status.result.payout?.status || 'processing'; + await this.bot.sendMessage( + participant.telegramId, + messages.notifications.winner( + prizeSats.toLocaleString(), + winnerTicketNumber, + payoutStatus + ), + { parse_mode: 'Markdown' } + ); + logger.info('Sent winner notification', { telegramId: participant.telegramId }); + } + } else { + // Send loser notification if user has drawResults enabled + if (user.notifications?.drawResults !== false) { + await this.bot.sendMessage( + participant.telegramId, + messages.notifications.loser( + winnerTicketNumber, + potSats.toLocaleString() + ), + { parse_mode: 'Markdown' } + ); + logger.debug('Sent draw result to participant', { telegramId: participant.telegramId }); + } + } + } catch (error) { + logger.error('Failed to notify participant', { + telegramId: participant.telegramId, + error + }); + } + } + + // Send group announcements (even if no participants - groups might want to know) + if (hasParticipants) { + await this.sendGroupDrawAnnouncementsImmediate( + previousCycleId, + winnerDisplayName, + winnerTicketNumber, + potSats, + participants.length + ); + } + } + + /** + * Send draw announcements to groups immediately (no delay - for cycle transition) + */ + private async sendGroupDrawAnnouncementsImmediate( + cycleId: string, + winnerDisplayName: string, + winnerTicketNumber: string, + potSats: number, + totalParticipants: number + ): Promise { + if (!this.bot) return; + + const groups = await groupStateManager.getGroupsWithFeature('drawAnnouncements'); + + for (const group of groups) { + try { + const message = messages.notifications.drawAnnouncement( + winnerDisplayName, + `#${winnerTicketNumber}`, + potSats.toLocaleString(), + totalParticipants + ); + + await this.bot.sendMessage(group.groupId, message, { parse_mode: 'Markdown' }); + logger.debug('Sent draw announcement to group', { groupId: group.groupId }); + } catch (error) { + this.handleSendError(error, group.groupId); + } + } + + logger.info('Draw announcements sent', { + cycleId, + groupCount: groups.length + }); + } + + /** + * Handle new cycle announcement + */ + private async handleNewCycle( + cycle: CycleInfo, + lottery: { name: string; ticket_price_sats: number } + ): Promise { + if (!this.bot) return; + + // Check if we've already announced this cycle + if (this.announcedCycles.has(`new:${cycle.id}`)) { + return; + } + this.announcedCycles.add(`new:${cycle.id}`); + + const drawTime = new Date(cycle.scheduled_at); + + // Send to groups + const groups = await groupStateManager.getGroupsWithFeature('enabled'); + for (const group of groups) { + if (group.newJackpotAnnouncement === false) continue; + + try { + const message = messages.notifications.newJackpot( + lottery.name, + lottery.ticket_price_sats, + drawTime + ); + await this.bot.sendMessage(group.groupId, message, { parse_mode: 'Markdown' }); + logger.debug('Sent new jackpot announcement to group', { groupId: group.groupId }); + } catch (error) { + this.handleSendError(error, group.groupId); + } + } + + // Send to users with new jackpot alerts enabled + const users = await stateManager.getUsersWithNotification('newJackpotAlerts'); + for (const user of users) { + try { + const message = messages.notifications.newJackpot( + lottery.name, + lottery.ticket_price_sats, + drawTime + ); + await this.bot.sendMessage(user.telegramId, message, { parse_mode: 'Markdown' }); + logger.debug('Sent new jackpot alert to user', { telegramId: user.telegramId }); + } catch (error) { + logger.error('Failed to send new jackpot alert', { telegramId: user.telegramId, error }); + } + } + + logger.info('New jackpot announcements sent', { cycleId: cycle.id }); + } + + /** + * Handle draw completed - send notifications to participants and groups + */ + private async handleDrawCompleted(cycle: CycleInfo): Promise { + if (!this.bot) return; + + // Check if we've already announced this draw + if (this.announcedCycles.has(`draw:${cycle.id}`)) { + return; + } + this.announcedCycles.add(`draw:${cycle.id}`); + + // Clear reminders for this cycle + this.clearRemindersForCycle(cycle.id); + + // Get participants for this cycle + const participants = await stateManager.getCycleParticipants(cycle.id); + const hasParticipants = participants.length > 0; + + logger.info('Processing draw completion', { + cycleId: cycle.id, + participantCount: participants.length, + potSats: cycle.pot_total_sats + }); + + // Only proceed if there were participants + if (!hasParticipants) { + logger.info('No participants in cycle, skipping notifications', { cycleId: cycle.id }); + return; + } + + // Get draw result details for winner announcement + let winnerDisplayName = 'Anon'; + let winnerTicketNumber = '0000'; + + // Notify each participant about their result + for (const participant of participants) { + try { + const user = await stateManager.getUser(participant.telegramId); + if (!user || !user.notifications?.drawResults) continue; + + // Check if they won + const status = await apiClient.getTicketStatus(participant.purchaseId); + if (!status) continue; + + const isWinner = status.result.is_winner; + + if (isWinner) { + // Store winner info for group announcement + winnerDisplayName = user.displayName || 'Anon'; + const winningTicket = status.tickets.find(t => t.is_winning_ticket); + if (winningTicket) { + winnerTicketNumber = winningTicket.serial_number.toString().padStart(4, '0'); + } + + // Send winner notification + const prizeSats = status.result.payout?.amount_sats || cycle.pot_total_sats; + const payoutStatus = status.result.payout?.status || 'processing'; + await this.bot.sendMessage( + participant.telegramId, + messages.notifications.winner( + prizeSats.toLocaleString(), + winnerTicketNumber, + payoutStatus + ), + { parse_mode: 'Markdown' } + ); + logger.info('Sent winner notification', { telegramId: participant.telegramId }); + } else { + // Send loser notification + await this.bot.sendMessage( + participant.telegramId, + messages.notifications.loser( + winnerTicketNumber, + cycle.pot_total_sats.toLocaleString() + ), + { parse_mode: 'Markdown' } + ); + logger.debug('Sent draw result to participant', { telegramId: participant.telegramId }); + } + } catch (error) { + logger.error('Failed to notify participant', { + telegramId: participant.telegramId, + error + }); + } + } + + // Send group announcements (only if there were participants) + await this.sendGroupDrawAnnouncements(cycle, winnerDisplayName, winnerTicketNumber, participants.length); + } + + /** + * Send draw announcements to groups + */ + private async sendGroupDrawAnnouncements( + cycle: CycleInfo, + winnerDisplayName: string, + winnerTicketNumber: string, + totalParticipants: number + ): Promise { + if (!this.bot) return; + + const groups = await groupStateManager.getGroupsWithFeature('drawAnnouncements'); + + for (const group of groups) { + const delay = (group.announcementDelaySeconds || 0) * 1000; + + setTimeout(async () => { + try { + const message = messages.notifications.drawAnnouncement( + winnerDisplayName, + `#${winnerTicketNumber}`, + cycle.pot_total_sats.toLocaleString(), + totalParticipants + ); + + if (this.bot) { + await this.bot.sendMessage(group.groupId, message, { parse_mode: 'Markdown' }); + logger.debug('Sent draw announcement to group', { groupId: group.groupId }); + } + } catch (error) { + this.handleSendError(error, group.groupId); + } + }, delay); + } + + logger.info('Draw announcements scheduled', { + cycleId: cycle.id, + groupCount: groups.length + }); + } + + /** + * Schedule reminders for groups (3-tier system with custom times) + */ + private async scheduleGroupReminders(cycle: CycleInfo): Promise { + if (!this.bot || cycle.status === 'completed' || cycle.status === 'cancelled') { + return; + } + + const drawTime = new Date(cycle.scheduled_at); + const now = new Date(); + + const groups = await groupStateManager.getGroupsWithFeature('reminders'); + + for (const group of groups) { + // Build list of enabled reminders from 3-tier system with custom times + const enabledReminders: { slot: number; time: ReminderTime }[] = []; + + // Check each of the 3 reminder slots with their custom times + if (group.reminder1Enabled !== false) { + const time = group.reminder1Time || DEFAULT_GROUP_REMINDER_SLOTS[0]; + enabledReminders.push({ slot: 1, time }); + } + if (group.reminder2Enabled === true) { + const time = group.reminder2Time || DEFAULT_GROUP_REMINDER_SLOTS[1]; + enabledReminders.push({ slot: 2, time }); + } + if (group.reminder3Enabled === true) { + const time = group.reminder3Time || DEFAULT_GROUP_REMINDER_SLOTS[2]; + enabledReminders.push({ slot: 3, time }); + } + + if (enabledReminders.length === 0) { + continue; + } + + for (const { slot, time: reminderTime } of enabledReminders) { + const reminderKey = `slot${slot}_${formatReminderTime(reminderTime)}`; + const uniqueKey = `group:${group.groupId}:${cycle.id}:${reminderKey}`; + + if (this.scheduledReminders.has(uniqueKey)) { + continue; + } + + const minutesBefore = reminderTimeToMinutes(reminderTime); + const reminderDate = new Date(drawTime.getTime() - minutesBefore * 60 * 1000); + + if (reminderDate <= now) { + continue; + } + + const delay = reminderDate.getTime() - now.getTime(); + + const timeout = setTimeout(async () => { + await this.sendGroupReminder(group, cycle, reminderTime, drawTime); + this.scheduledReminders.delete(uniqueKey); + }, delay); + + this.scheduledReminders.set(uniqueKey, { + groupId: group.groupId, + cycleId: cycle.id, + reminderKey, + scheduledFor: reminderDate, + timeout, + }); + + logger.debug('Scheduled group reminder', { + groupId: group.groupId, + cycleId: cycle.id, + slot, + reminderKey, + scheduledFor: reminderDate.toISOString(), + }); + } + } + } + + /** + * Schedule reminders for individual users with drawReminders enabled + */ + private async scheduleUserReminders(cycle: CycleInfo): Promise { + if (!this.bot || cycle.status === 'completed' || cycle.status === 'cancelled') { + return; + } + + const drawTime = new Date(cycle.scheduled_at); + const now = new Date(); + + // Get users with draw reminders enabled + const users = await stateManager.getUsersWithNotification('drawReminders'); + + // Default reminder: 15 minutes before + const defaultReminder: ReminderTime = { value: 15, unit: 'minutes' }; + const reminderKey = formatReminderTime(defaultReminder); + + for (const user of users) { + const uniqueKey = `user:${user.telegramId}:${cycle.id}:${reminderKey}`; + + if (this.scheduledReminders.has(uniqueKey)) { + continue; + } + + const minutesBefore = reminderTimeToMinutes(defaultReminder); + const reminderDate = new Date(drawTime.getTime() - minutesBefore * 60 * 1000); + + if (reminderDate <= now) { + continue; + } + + const delay = reminderDate.getTime() - now.getTime(); + + const timeout = setTimeout(async () => { + await this.sendUserReminder(user, cycle, defaultReminder, drawTime); + this.scheduledReminders.delete(uniqueKey); + }, delay); + + this.scheduledReminders.set(uniqueKey, { + telegramId: user.telegramId, + cycleId: cycle.id, + reminderKey, + scheduledFor: reminderDate, + timeout, + }); + } + } + + /** + * Send a reminder to a group + */ + private async sendGroupReminder( + group: GroupSettings, + cycle: CycleInfo, + reminderTime: ReminderTime, + drawTime: Date + ): Promise { + if (!this.bot) return; + + try { + const message = messages.notifications.drawReminder( + reminderTime.value, + reminderTime.unit, + drawTime, + cycle.pot_total_sats + ); + + await this.bot.sendMessage(group.groupId, message, { parse_mode: 'Markdown' }); + logger.info('Sent draw reminder to group', { + groupId: group.groupId, + reminderKey: formatReminderTime(reminderTime) + }); + } catch (error) { + this.handleSendError(error, group.groupId); + } + } + + /** + * Send a reminder to a user + */ + private async sendUserReminder( + user: TelegramUser, + cycle: CycleInfo, + reminderTime: ReminderTime, + drawTime: Date + ): Promise { + if (!this.bot) return; + + try { + const message = messages.notifications.drawReminder( + reminderTime.value, + reminderTime.unit, + drawTime, + cycle.pot_total_sats + ); + + await this.bot.sendMessage(user.telegramId, message, { parse_mode: 'Markdown' }); + logger.info('Sent draw reminder to user', { telegramId: user.telegramId }); + } catch (error) { + logger.error('Failed to send reminder to user', { telegramId: user.telegramId, error }); + } + } + + /** + * Handle send errors (remove group if bot was kicked) + */ + private handleSendError(error: any, groupId: number): void { + logger.error('Failed to send message to group', { groupId, error }); + if (error?.response?.statusCode === 403) { + groupStateManager.removeGroup(groupId); + } + } + + /** + * Clear all scheduled reminders for a cycle + */ + private clearRemindersForCycle(cycleId: string): void { + for (const [key, reminder] of this.scheduledReminders.entries()) { + if (reminder.cycleId === cycleId) { + clearTimeout(reminder.timeout); + this.scheduledReminders.delete(key); + } + } + } + + /** + * Get status info for debugging + */ + getStatus(): object { + return { + isRunning: this.isRunning, + lastCycleId: this.lastCycleId, + lastCycleStatus: this.lastCycleStatus, + scheduledReminders: this.scheduledReminders.size, + announcedCycles: this.announcedCycles.size, + }; + } +} + +export const notificationScheduler = new NotificationScheduler(); +export default notificationScheduler; diff --git a/telegram_bot/src/services/state.ts b/telegram_bot/src/services/state.ts index 696babe..2125e07 100644 --- a/telegram_bot/src/services/state.ts +++ b/telegram_bot/src/services/state.ts @@ -1,130 +1,31 @@ -import Redis from 'ioredis'; -import config from '../config'; +import { botDatabase } from './database'; import { logger } from './logger'; import { TelegramUser, UserState, AwaitingPaymentData, + NotificationPreferences, + DEFAULT_NOTIFICATIONS, } from '../types'; -const STATE_PREFIX = 'tg_user:'; -const PURCHASE_PREFIX = 'tg_purchase:'; -const USER_PURCHASES_PREFIX = 'tg_user_purchases:'; -const STATE_TTL = 60 * 60 * 24 * 30; // 30 days - class StateManager { - private redis: Redis | null = null; - private memoryStore: Map = new Map(); - private useRedis: boolean = false; - async init(): Promise { - if (config.redis.url) { - try { - this.redis = new Redis(config.redis.url); - - this.redis.on('error', (error) => { - logger.error('Redis connection error', { error: error.message }); - }); - - this.redis.on('connect', () => { - logger.info('Connected to Redis'); - }); - - // Test connection - await this.redis.ping(); - this.useRedis = true; - logger.info('State manager initialized with Redis'); - } catch (error) { - logger.warn('Failed to connect to Redis, falling back to in-memory store', { - error: (error as Error).message, - }); - this.redis = null; - this.useRedis = false; - } - } else { - logger.info('State manager initialized with in-memory store'); - this.useRedis = false; - } - } - - private async get(key: string): Promise { - if (this.useRedis && this.redis) { - return await this.redis.get(key); - } - return this.memoryStore.get(key) || null; - } - - private async set(key: string, value: string, ttl?: number): Promise { - if (this.useRedis && this.redis) { - if (ttl) { - await this.redis.setex(key, ttl, value); - } else { - await this.redis.set(key, value); - } - } else { - this.memoryStore.set(key, value); - } - } - - private async del(key: string): Promise { - if (this.useRedis && this.redis) { - await this.redis.del(key); - } else { - this.memoryStore.delete(key); - } - } - - private async lpush(key: string, value: string): Promise { - if (this.useRedis && this.redis) { - await this.redis.lpush(key, value); - await this.redis.ltrim(key, 0, 99); // Keep last 100 purchases - } else { - const existing = this.memoryStore.get(key); - const list = existing ? JSON.parse(existing) : []; - list.unshift(value); - if (list.length > 100) list.pop(); - this.memoryStore.set(key, JSON.stringify(list)); - } - } - - private async lrange(key: string, start: number, stop: number): Promise { - if (this.useRedis && this.redis) { - return await this.redis.lrange(key, start, stop); - } - const existing = this.memoryStore.get(key); - if (!existing) return []; - const list = JSON.parse(existing); - return list.slice(start, stop + 1); + // Database is initialized separately + logger.info('State manager initialized (using SQLite database)'); } /** - * Get or create user + * Get user by Telegram ID */ async getUser(telegramId: number): Promise { - const key = `${STATE_PREFIX}${telegramId}`; - const data = await this.get(key); - if (!data) return null; - - try { - const user = JSON.parse(data); - return { - ...user, - createdAt: new Date(user.createdAt), - updatedAt: new Date(user.updatedAt), - }; - } catch (error) { - logger.error('Failed to parse user data', { telegramId, error }); - return null; - } + return botDatabase.getUser(telegramId); } /** - * Create or update user + * Save user */ async saveUser(user: TelegramUser): Promise { - const key = `${STATE_PREFIX}${user.telegramId}`; - user.updatedAt = new Date(); - await this.set(key, JSON.stringify(user), STATE_TTL); + botDatabase.saveUser(user); logger.debug('User saved', { telegramId: user.telegramId, state: user.state }); } @@ -137,18 +38,7 @@ class StateManager { firstName?: string, lastName?: string ): Promise { - const user: TelegramUser = { - telegramId, - username, - firstName, - lastName, - state: 'awaiting_lightning_address', - createdAt: new Date(), - updatedAt: new Date(), - }; - await this.saveUser(user); - logger.info('New user created', { telegramId, username }); - return user; + return botDatabase.createUser(telegramId, username, firstName, lastName); } /** @@ -159,14 +49,7 @@ class StateManager { state: UserState, stateData?: Record ): Promise { - const user = await this.getUser(telegramId); - if (!user) { - logger.warn('Attempted to update state for non-existent user', { telegramId }); - return; - } - user.state = state; - user.stateData = stateData; - await this.saveUser(user); + botDatabase.updateUserState(telegramId, state, stateData); } /** @@ -176,15 +59,25 @@ class StateManager { telegramId: number, lightningAddress: string ): Promise { - const user = await this.getUser(telegramId); - if (!user) { - logger.warn('Attempted to update address for non-existent user', { telegramId }); - return; - } - user.lightningAddress = lightningAddress; - user.state = 'idle'; - user.stateData = undefined; - await this.saveUser(user); + botDatabase.updateLightningAddress(telegramId, lightningAddress); + } + + /** + * Update user display name + */ + async updateDisplayName(telegramId: number, displayName: string): Promise { + botDatabase.updateDisplayName(telegramId, displayName); + logger.info('Display name updated', { telegramId, displayName }); + } + + /** + * Update user notification preferences + */ + async updateNotifications( + telegramId: number, + updates: Partial + ): Promise { + return botDatabase.updateNotifications(telegramId, updates); } /** @@ -195,33 +88,36 @@ class StateManager { purchaseId: string, data: AwaitingPaymentData ): Promise { - // Store purchase data - const purchaseKey = `${PURCHASE_PREFIX}${purchaseId}`; - await this.set(purchaseKey, JSON.stringify({ - telegramId, - ...data, - createdAt: new Date().toISOString(), - }), STATE_TTL); - - // Add to user's purchase list - const userPurchasesKey = `${USER_PURCHASES_PREFIX}${telegramId}`; - await this.lpush(userPurchasesKey, purchaseId); + botDatabase.storePurchase(telegramId, purchaseId, { + cycleId: data.cycleId, + ticketCount: data.ticketCount, + totalAmount: data.totalAmount, + lightningAddress: data.paymentRequest ? '' : '', // Not storing sensitive data + paymentRequest: data.paymentRequest, + publicUrl: data.publicUrl, + }); } /** * Get purchase data */ async getPurchase(purchaseId: string): Promise<(AwaitingPaymentData & { telegramId: number }) | null> { - const key = `${PURCHASE_PREFIX}${purchaseId}`; - const data = await this.get(key); - if (!data) return null; - - try { - return JSON.parse(data); - } catch (error) { - logger.error('Failed to parse purchase data', { purchaseId, error }); - return null; - } + const purchase = botDatabase.getPurchase(purchaseId); + if (!purchase) return null; + + return { + telegramId: purchase.telegram_id, + cycleId: purchase.cycle_id, + ticketCount: purchase.ticket_count, + scheduledAt: '', // Not stored locally + ticketPrice: 0, // Not stored locally + totalAmount: purchase.amount_sats, + lotteryName: '', // Not stored locally + purchaseId: purchase.purchase_id, + paymentRequest: purchase.payment_request, + publicUrl: purchase.public_url, + pollStartTime: 0, + }; } /** @@ -231,33 +127,62 @@ class StateManager { telegramId: number, limit: number = 10 ): Promise { - const key = `${USER_PURCHASES_PREFIX}${telegramId}`; - return await this.lrange(key, 0, limit - 1); + return botDatabase.getUserPurchaseIds(telegramId, limit); } /** * Clear user state data (keeping lightning address) */ async clearUserStateData(telegramId: number): Promise { - const user = await this.getUser(telegramId); - if (!user) return; - user.state = 'idle'; - user.stateData = undefined; - await this.saveUser(user); + botDatabase.updateUserState(telegramId, 'idle', undefined); + } + + /** + * Add user as participant to a cycle + */ + async addCycleParticipant(cycleId: string, telegramId: number, purchaseId: string): Promise { + botDatabase.addCycleParticipant(cycleId, telegramId, purchaseId); + logger.debug('Added cycle participant', { cycleId, telegramId }); + } + + /** + * Get all participants for a cycle + */ + async getCycleParticipants(cycleId: string): Promise> { + return botDatabase.getCycleParticipants(cycleId); + } + + /** + * Check if user participated in a cycle + */ + async didUserParticipate(cycleId: string, telegramId: number): Promise { + return botDatabase.didUserParticipate(cycleId, telegramId); + } + + /** + * Get all users with specific notification preference enabled + */ + async getUsersWithNotification( + preference: keyof NotificationPreferences + ): Promise { + return botDatabase.getUsersWithNotification(preference); + } + + /** + * Get user's display name (for announcements) + */ + getDisplayName(user: TelegramUser): string { + return user.displayName || 'Anon'; } /** * Shutdown */ async close(): Promise { - if (this.redis) { - await this.redis.quit(); - logger.info('Redis connection closed'); - } + // Database close is handled separately + logger.info('State manager closed'); } } export const stateManager = new StateManager(); export default stateManager; - - diff --git a/telegram_bot/src/types/groups.ts b/telegram_bot/src/types/groups.ts index d477a90..805cb44 100644 --- a/telegram_bot/src/types/groups.ts +++ b/telegram_bot/src/types/groups.ts @@ -1,3 +1,11 @@ +/** + * Reminder time with unit + */ +export interface ReminderTime { + value: number; + unit: 'minutes' | 'hours' | 'days'; +} + /** * Group settings for lottery features */ @@ -7,12 +15,75 @@ export interface GroupSettings { enabled: boolean; drawAnnouncements: boolean; reminders: boolean; + newJackpotAnnouncement: boolean; // Announce when a new jackpot starts ticketPurchaseAllowed: boolean; + // Reminder slots (3 tiers - each with customizable time) + reminder1Enabled: boolean; + reminder1Time: ReminderTime; // Default: 1 hour before + reminder2Enabled: boolean; + reminder2Time: ReminderTime; // Default: 1 day before + reminder3Enabled: boolean; + reminder3Time: ReminderTime; // Default: 6 days before + // Legacy field (kept for backwards compat, no longer used) + reminderTimes: ReminderTime[]; + announcementDelaySeconds: number; // Delay after draw to send announcement (in seconds) addedBy: number; addedAt: Date; updatedAt: Date; } +/** + * Default reminder slots for groups (3 tiers) + * 1st: 1 Hour before draw + * 2nd: 1 Day before draw + * 3rd: 6 Days before draw + */ +export const DEFAULT_GROUP_REMINDER_SLOTS: ReminderTime[] = [ + { value: 1, unit: 'hours' }, // 1st reminder: 1 hour before + { value: 1, unit: 'days' }, // 2nd reminder: 1 day before + { value: 6, unit: 'days' }, // 3rd reminder: 6 days before +]; + +/** + * Available reminder time presets (for custom selection) + */ +export const REMINDER_PRESETS: ReminderTime[] = [ + { value: 5, unit: 'minutes' }, + { value: 15, unit: 'minutes' }, + { value: 30, unit: 'minutes' }, + { value: 1, unit: 'hours' }, + { value: 6, unit: 'hours' }, + { value: 12, unit: 'hours' }, + { value: 1, unit: 'days' }, + { value: 3, unit: 'days' }, + { value: 6, unit: 'days' }, +]; + +/** + * Convert reminder time to minutes + */ +export function reminderTimeToMinutes(rt: ReminderTime): number { + switch (rt.unit) { + case 'minutes': return rt.value; + case 'hours': return rt.value * 60; + case 'days': return rt.value * 60 * 24; + } +} + +/** + * Format reminder time for display + */ +export function formatReminderTime(rt: ReminderTime): string { + if (rt.unit === 'minutes') return `${rt.value}m`; + if (rt.unit === 'hours') return rt.value === 1 ? '1h' : `${rt.value}h`; + return rt.value === 1 ? '1d' : `${rt.value}d`; +} + +/** + * Available announcement delay options (seconds after draw) + */ +export const ANNOUNCEMENT_DELAY_OPTIONS = [0, 10, 30, 60, 120]; + /** * Default group settings */ @@ -20,7 +91,16 @@ export const DEFAULT_GROUP_SETTINGS: Omit; + notifications: NotificationPreferences; createdAt: Date; updatedAt: Date; } +// Default notification preferences +export const DEFAULT_NOTIFICATIONS: NotificationPreferences = { + drawReminders: true, + drawResults: true, + newJackpotAlerts: true, +}; + // API Response Types export interface ApiResponse { version: string; @@ -131,6 +148,9 @@ export interface AwaitingPaymentData extends PendingPurchaseData { paymentRequest: string; publicUrl: string; pollStartTime: number; + headerMessageId?: number; + qrMessageId?: number; + invoiceMessageId?: number; } // Re-export group types diff --git a/telegram_bot/src/utils/format.ts b/telegram_bot/src/utils/format.ts index f240ec9..4429f2a 100644 --- a/telegram_bot/src/utils/format.ts +++ b/telegram_bot/src/utils/format.ts @@ -46,7 +46,7 @@ export function formatTimeUntil(date: Date | string): string { } /** - * Validate Lightning Address format + * Validate Lightning Address format (basic regex check) */ export function isValidLightningAddress(address: string): boolean { // Basic format: something@something.something @@ -54,6 +54,62 @@ export function isValidLightningAddress(address: string): boolean { return regex.test(address); } +/** + * LNURL-pay response structure + */ +interface LnurlPayResponse { + status?: string; + reason?: string; + callback?: string; + minSendable?: number; + maxSendable?: number; + tag?: string; +} + +/** + * Verify Lightning Address is actually valid by checking LNURL endpoint + */ +export async function verifyLightningAddress(address: string): Promise<{ valid: boolean; error?: string }> { + // First check format + if (!isValidLightningAddress(address)) { + return { valid: false, error: 'Invalid format' }; + } + + try { + const [username, domain] = address.split('@'); + const url = `https://${domain}/.well-known/lnurlp/${username}`; + + const response = await fetch(url, { + method: 'GET', + headers: { 'Accept': 'application/json' }, + signal: AbortSignal.timeout(10000), // 10 second timeout + }); + + if (!response.ok) { + return { valid: false, error: `Address not found (${response.status})` }; + } + + const data = (await response.json()) as LnurlPayResponse; + + // Check for LNURL-pay response + if (data.status === 'ERROR') { + return { valid: false, error: data.reason || 'Invalid address' }; + } + + // Valid LNURL-pay response should have callback and minSendable + if (data.callback && data.minSendable !== undefined) { + return { valid: true }; + } + + return { valid: false, error: 'Invalid LNURL response' }; + } catch (error: any) { + if (error.name === 'TimeoutError' || error.name === 'AbortError') { + return { valid: false, error: 'Verification timed out' }; + } + return { valid: false, error: 'Could not verify address' }; + } +} + /** * Escape markdown special characters for Telegram MarkdownV2 */ diff --git a/telegram_bot/src/utils/keyboards.ts b/telegram_bot/src/utils/keyboards.ts index b3ddbb8..c6e5923 100644 --- a/telegram_bot/src/utils/keyboards.ts +++ b/telegram_bot/src/utils/keyboards.ts @@ -2,6 +2,7 @@ import TelegramBot, { InlineKeyboardMarkup, ReplyKeyboardMarkup, } from 'node-telegram-bot-api'; +import { NotificationPreferences } from '../types'; /** * Main menu reply keyboard @@ -11,7 +12,7 @@ export function getMainMenuKeyboard(): ReplyKeyboardMarkup { keyboard: [ [{ text: '🎟 Buy Tickets' }, { text: '🧾 My Tickets' }], [{ text: '🏆 My Wins' }, { text: '⚡ Lightning Address' }], - [{ text: 'ℹ️ Help' }], + [{ text: '⚙️ Settings' }, { text: 'ℹ️ Help' }], ], resize_keyboard: true, one_time_keyboard: false, @@ -68,15 +69,13 @@ function isValidTelegramUrl(url: string): boolean { } /** - * View ticket status button + * View ticket status button (after payment confirmed) */ export function getViewTicketKeyboard( purchaseId: string, publicUrl?: string ): InlineKeyboardMarkup { - const buttons: TelegramBot.InlineKeyboardButton[][] = [ - [{ text: '🔄 Check Status', callback_data: `status_${purchaseId}` }], - ]; + const buttons: TelegramBot.InlineKeyboardButton[][] = []; // Only add URL button if it's a valid Telegram URL (HTTPS, not localhost) if (publicUrl && isValidTelegramUrl(publicUrl)) { @@ -143,3 +142,63 @@ export function getCancelKeyboard(): InlineKeyboardMarkup { }; } +/** + * Lightning address selection keyboard (for registration) + */ +export function getLightningAddressKeyboard(username?: string): InlineKeyboardMarkup { + const buttons: InlineKeyboardButton[][] = []; + + if (username) { + // Add quick options for tipbots + buttons.push([{ + text: `⚡ Use 21Tipbot (${username}@twentyone.tips)`, + callback_data: 'ln_addr_21tipbot', + }]); + buttons.push([{ + text: `⚡ Use Bittip (${username}@btip.nl)`, + callback_data: 'ln_addr_bittip', + }]); + } + + buttons.push([{ text: '❌ Cancel', callback_data: 'cancel' }]); + + return { inline_keyboard: buttons }; +} + +type InlineKeyboardButton = TelegramBot.InlineKeyboardButton; + +/** + * User settings keyboard + */ +export function getSettingsKeyboard( + displayName: string, + notifications: NotificationPreferences +): InlineKeyboardMarkup { + const onOff = (val: boolean) => val ? '✅' : '❌'; + + return { + inline_keyboard: [ + [{ + text: `👤 Display Name: ${displayName}`, + callback_data: 'settings_change_name', + }], + [{ + text: `${onOff(notifications.drawReminders)} Draw Reminders`, + callback_data: 'settings_toggle_notif_drawReminders', + }], + [{ + text: `${onOff(notifications.drawResults)} Draw Results`, + callback_data: 'settings_toggle_notif_drawResults', + }], + [{ + text: `${onOff(notifications.newJackpotAlerts)} New Jackpot Alerts`, + callback_data: 'settings_toggle_notif_newJackpotAlerts', + }], + [{ + text: '🏠 Back to Menu', + callback_data: 'settings_back_menu', + }], + ], + }; +} +