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
This commit is contained in:
Michilis
2025-12-08 22:33:40 +00:00
parent dd6b26c524
commit 13fd2b8989
24 changed files with 3354 additions and 637 deletions

View File

@@ -14,6 +14,12 @@ logs/
*.log *.log
npm-debug.log* npm-debug.log*
# Database
data/
*.db
*.db-wal
*.db-shm
# IDE # IDE
.vscode/ .vscode/
.idea/ .idea/

View File

@@ -7,8 +7,8 @@ API_BASE_URL=http://localhost:3000
# Frontend URL (for generating ticket links) # Frontend URL (for generating ticket links)
FRONTEND_BASE_URL=http://localhost:3001 FRONTEND_BASE_URL=http://localhost:3001
# Redis Configuration (optional - falls back to in-memory if not set) # SQLite Database Path (optional - defaults to ./data/bot.db)
REDIS_URL=redis://localhost:6379 # BOT_DATABASE_PATH=./data/bot.db
# Bot Configuration # Bot Configuration
MAX_TICKETS_PER_PURCHASE=100 MAX_TICKETS_PER_PURCHASE=100

View File

@@ -10,13 +10,14 @@
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"axios": "^1.6.2", "axios": "^1.6.2",
"better-sqlite3": "^9.4.3",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"ioredis": "^5.3.2",
"node-telegram-bot-api": "^0.64.0", "node-telegram-bot-api": "^0.64.0",
"qrcode": "^1.5.3", "qrcode": "^1.5.3",
"winston": "^3.11.0" "winston": "^3.11.0"
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.8",
"@types/node": "^20.10.4", "@types/node": "^20.10.4",
"@types/node-telegram-bot-api": "^0.64.2", "@types/node-telegram-bot-api": "^0.64.2",
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",
@@ -136,12 +137,6 @@
"kuler": "^2.0.0" "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": { "node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
@@ -208,6 +203,16 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/caseless": {
"version": "0.12.5", "version": "0.12.5",
"resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.5.tgz", "resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.5.tgz",
@@ -535,6 +540,26 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/bcrypt-pbkdf": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
@@ -544,6 +569,17 @@
"tweetnacl": "^0.14.3" "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": { "node_modules/binary-extensions": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -557,6 +593,15 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/bl": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/bl/-/bl-1.2.3.tgz", "resolved": "https://registry.npmjs.org/bl/-/bl-1.2.3.tgz",
@@ -597,6 +642,30 @@
"node": ">=8" "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": { "node_modules/call-bind": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
@@ -684,6 +753,12 @@
"fsevents": "~2.3.2" "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": { "node_modules/cliui": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
@@ -695,15 +770,6 @@
"wrap-ansi": "^6.2.0" "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": { "node_modules/color": {
"version": "5.0.3", "version": "5.0.3",
"resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz", "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz",
@@ -849,6 +915,7 @@
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ms": "^2.1.3" "ms": "^2.1.3"
@@ -871,6 +938,30 @@
"node": ">=0.10.0" "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": { "node_modules/define-data-property": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
@@ -914,13 +1005,13 @@
"node": ">=0.4.0" "node": ">=0.4.0"
} }
}, },
"node_modules/denque": { "node_modules/detect-libc": {
"version": "2.1.0", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
"node": ">=0.10" "node": ">=8"
} }
}, },
"node_modules/diff": { "node_modules/diff": {
@@ -1144,6 +1235,15 @@
"integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==", "integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==",
"license": "MIT" "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": { "node_modules/extend": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
@@ -1188,6 +1288,12 @@
"node": ">=0.10.0" "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": { "node_modules/fill-range": {
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -1282,6 +1388,12 @@
"node": ">= 0.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": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1416,6 +1528,12 @@
"assert-plus": "^1.0.0" "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": { "node_modules/glob-parent": {
"version": "5.1.2", "version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
@@ -1584,6 +1702,26 @@
"node": ">=0.10" "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": { "node_modules/ignore-by-default": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", "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==", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC" "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": { "node_modules/internal-slot": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
@@ -1611,30 +1755,6 @@
"node": ">= 0.4" "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": { "node_modules/is-array-buffer": {
"version": "3.0.5", "version": "3.0.5",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@@ -2106,18 +2226,6 @@
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT" "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": { "node_modules/logform": {
"version": "2.7.0", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz",
@@ -2184,6 +2292,18 @@
"node": ">= 0.6" "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": { "node_modules/minimatch": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@@ -2197,12 +2317,45 @@
"node": "*" "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": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT" "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": { "node_modules/node-telegram-bot-api": {
"version": "0.64.0", "version": "0.64.0",
"resolved": "https://registry.npmjs.org/node-telegram-bot-api/-/node-telegram-bot-api-0.64.0.tgz", "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": ">= 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": { "node_modules/process-nextick-args": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
@@ -2527,6 +2716,21 @@
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
"license": "MIT" "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": { "node_modules/readable-stream": {
"version": "2.3.8", "version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
@@ -2561,27 +2765,6 @@
"node": ">=8.10.0" "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": { "node_modules/reflect.getprototypeof": {
"version": "1.0.10", "version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -2878,7 +3061,6 @@
"version": "7.7.3", "version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"dev": true,
"license": "ISC", "license": "ISC",
"bin": { "bin": {
"semver": "bin/semver.js" "semver": "bin/semver.js"
@@ -3011,6 +3193,51 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/simple-update-notifier": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
@@ -3058,12 +3285,6 @@
"node": "*" "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": { "node_modules/stealthy-require": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/stealthy-require/-/stealthy-require-1.1.1.tgz", "resolved": "https://registry.npmjs.org/stealthy-require/-/stealthy-require-1.1.1.tgz",
@@ -3183,6 +3404,15 @@
"node": ">=8" "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": { "node_modules/supports-color": {
"version": "5.5.0", "version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
@@ -3196,6 +3426,69 @@
"node": ">=4" "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": { "node_modules/text-hex": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz",

View File

@@ -19,13 +19,14 @@
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"axios": "^1.6.2", "axios": "^1.6.2",
"better-sqlite3": "^9.4.3",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"ioredis": "^5.3.2",
"node-telegram-bot-api": "^0.64.0", "node-telegram-bot-api": "^0.64.0",
"qrcode": "^1.5.3", "qrcode": "^1.5.3",
"winston": "^3.11.0" "winston": "^3.11.0"
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.8",
"@types/node": "^20.10.4", "@types/node": "^20.10.4",
"@types/node-telegram-bot-api": "^0.64.2", "@types/node-telegram-bot-api": "^0.64.2",
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",

View File

@@ -31,8 +31,8 @@ export const config = {
frontend: { frontend: {
baseUrl: optional('FRONTEND_BASE_URL', 'http://localhost:3001'), baseUrl: optional('FRONTEND_BASE_URL', 'http://localhost:3001'),
}, },
redis: { database: {
url: process.env.REDIS_URL || null, path: process.env.BOT_DATABASE_PATH || null, // Defaults to ./data/bot.db in database.ts
}, },
bot: { bot: {
maxTicketsPerPurchase: optionalInt('MAX_TICKETS_PER_PURCHASE', 100), maxTicketsPerPurchase: optionalInt('MAX_TICKETS_PER_PURCHASE', 100),

View File

@@ -1,8 +1,8 @@
import TelegramBot from 'node-telegram-bot-api'; import TelegramBot from 'node-telegram-bot-api';
import { stateManager } from '../services/state'; import { stateManager } from '../services/state';
import { logger, logUserAction } from '../services/logger'; import { logger, logUserAction } from '../services/logger';
import { getMainMenuKeyboard, getCancelKeyboard } from '../utils/keyboards'; import { getMainMenuKeyboard, getLightningAddressKeyboard, getCancelKeyboard } from '../utils/keyboards';
import { isValidLightningAddress } from '../utils/format'; import { isValidLightningAddress, verifyLightningAddress } from '../utils/format';
import { messages } from '../messages'; import { messages } from '../messages';
/** /**
@@ -14,6 +14,7 @@ export async function handleAddressCommand(
): Promise<void> { ): Promise<void> {
const chatId = msg.chat.id; const chatId = msg.chat.id;
const userId = msg.from?.id; const userId = msg.from?.id;
const username = msg.from?.username;
if (!userId) { if (!userId) {
await bot.sendMessage(chatId, messages.errors.userNotIdentified); await bot.sendMessage(chatId, messages.errors.userNotIdentified);
@@ -31,12 +32,12 @@ export async function handleAddressCommand(
} }
const message = user.lightningAddress const message = user.lightningAddress
? messages.address.currentAddress(user.lightningAddress) ? messages.address.currentAddressWithOptions(user.lightningAddress, username)
: messages.address.noAddressSet; : messages.address.noAddressSetWithOptions(username);
await bot.sendMessage(chatId, message, { await bot.sendMessage(chatId, message, {
parse_mode: 'Markdown', parse_mode: 'Markdown',
reply_markup: getCancelKeyboard(), reply_markup: getLightningAddressKeyboard(username),
}); });
await stateManager.updateUserState(userId, 'updating_address'); await stateManager.updateUserState(userId, 'updating_address');
@@ -55,6 +56,7 @@ export async function handleLightningAddressInput(
): Promise<boolean> { ): Promise<boolean> {
const chatId = msg.chat.id; const chatId = msg.chat.id;
const userId = msg.from?.id; const userId = msg.from?.id;
const username = msg.from?.username;
const text = msg.text?.trim(); const text = msg.text?.trim();
if (!userId || !text) return false; if (!userId || !text) return false;
@@ -73,7 +75,19 @@ export async function handleLightningAddressInput(
if (!isValidLightningAddress(text)) { if (!isValidLightningAddress(text)) {
await bot.sendMessage(chatId, messages.address.invalidFormat, { await bot.sendMessage(chatId, messages.address.invalidFormat, {
parse_mode: 'Markdown', 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; return true;
} }
@@ -81,7 +95,7 @@ export async function handleLightningAddressInput(
// Save the lightning address // Save the lightning address
await stateManager.updateLightningAddress(userId, text); await stateManager.updateLightningAddress(userId, text);
logUserAction(userId, 'Lightning address updated'); logUserAction(userId, 'Lightning address updated', { address: text });
const responseMessage = user.state === 'awaiting_lightning_address' const responseMessage = user.state === 'awaiting_lightning_address'
? messages.address.firstTimeSuccess(text) ? 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<void> {
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 { export default {
handleAddressCommand, handleAddressCommand,
handleLightningAddressInput, handleLightningAddressInput,
handleLightningAddressCallback,
}; };

View File

@@ -273,11 +273,11 @@ export async function handlePurchaseConfirmation(
logUserAction(userId, 'Confirmed purchase', { tickets: pendingData.ticketCount }); logUserAction(userId, 'Confirmed purchase', { tickets: pendingData.ticketCount });
// Create invoice // Create invoice with user's display name
const purchaseResult = await apiClient.buyTickets( const purchaseResult = await apiClient.buyTickets(
pendingData.ticketCount, pendingData.ticketCount,
user.lightningAddress, user.lightningAddress,
userId user.displayName || 'Anon'
); );
logPaymentEvent(userId, purchaseResult.ticket_purchase_id, 'created', { logPaymentEvent(userId, purchaseResult.ticket_purchase_id, 'created', {
@@ -295,21 +295,23 @@ export async function handlePurchaseConfirmation(
parse_mode: 'Markdown', parse_mode: 'Markdown',
}); });
// Send QR code // Send QR code with caption
await bot.sendPhoto(chatId, qrBuffer, { const qrMessage = await bot.sendPhoto(chatId, qrBuffer, {
caption: messages.buy.invoiceCaption( caption: messages.buy.invoiceCaption(
pendingData.ticketCount, pendingData.ticketCount,
formatSats(pendingData.totalAmount), formatSats(pendingData.totalAmount),
purchaseResult.invoice.payment_request,
config.bot.invoiceExpiryMinutes config.bot.invoiceExpiryMinutes
), ),
parse_mode: 'Markdown', 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 // Store purchase and start polling
const paymentData: AwaitingPaymentData = { const paymentData: AwaitingPaymentData = {
...pendingData, ...pendingData,
@@ -317,13 +319,16 @@ export async function handlePurchaseConfirmation(
paymentRequest: purchaseResult.invoice.payment_request, paymentRequest: purchaseResult.invoice.payment_request,
publicUrl: purchaseResult.public_url, publicUrl: purchaseResult.public_url,
pollStartTime: Date.now(), pollStartTime: Date.now(),
headerMessageId: messageId,
invoiceMessageId: invoiceMessage.message_id,
qrMessageId: qrMessage.message_id,
}; };
await stateManager.storePurchase(userId, purchaseResult.ticket_purchase_id, paymentData); await stateManager.storePurchase(userId, purchaseResult.ticket_purchase_id, paymentData);
await stateManager.updateUserState(userId, 'awaiting_invoice_payment', paymentData); await stateManager.updateUserState(userId, 'awaiting_invoice_payment', paymentData);
// Start payment polling // Start payment polling - pass all message IDs to delete on completion
pollPaymentStatus(bot, chatId, userId, purchaseResult.ticket_purchase_id); pollPaymentStatus(bot, chatId, userId, purchaseResult.ticket_purchase_id, messageId, qrMessage.message_id, invoiceMessage.message_id);
} catch (error) { } catch (error) {
logger.error('Error in handlePurchaseConfirmation', { error, userId }); logger.error('Error in handlePurchaseConfirmation', { error, userId });
await bot.sendMessage(chatId, messages.errors.invoiceCreationFailed, { await bot.sendMessage(chatId, messages.errors.invoiceCreationFailed, {
@@ -340,7 +345,10 @@ async function pollPaymentStatus(
bot: TelegramBot, bot: TelegramBot,
chatId: number, chatId: number,
userId: number, userId: number,
purchaseId: string purchaseId: string,
headerMessageId?: number,
qrMessageId?: number,
invoiceMessageId?: number
): Promise<void> { ): Promise<void> {
const pollInterval = config.bot.paymentPollIntervalMs; const pollInterval = config.bot.paymentPollIntervalMs;
const timeout = config.bot.paymentPollTimeoutMs; const timeout = config.bot.paymentPollTimeoutMs;
@@ -348,11 +356,41 @@ async function pollPaymentStatus(
logPaymentEvent(userId, purchaseId, 'polling'); 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<void> => { const checkPayment = async (): Promise<void> => {
try { try {
// Check if we've timed out // Check if we've timed out
if (Date.now() - startTime > timeout) { if (Date.now() - startTime > timeout) {
logPaymentEvent(userId, purchaseId, 'expired'); logPaymentEvent(userId, purchaseId, 'expired');
// Delete the invoice messages
await deleteInvoiceMessages();
await bot.sendMessage(chatId, messages.buy.invoiceExpired, { await bot.sendMessage(chatId, messages.buy.invoiceExpired, {
parse_mode: 'Markdown', parse_mode: 'Markdown',
reply_markup: getMainMenuKeyboard(), reply_markup: getMainMenuKeyboard(),
@@ -374,6 +412,12 @@ async function pollPaymentStatus(
tickets: status.tickets.length, 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! // Payment received!
const ticketNumbers = status.tickets const ticketNumbers = status.tickets
.map((t) => `#${t.serial_number.toString().padStart(4, '0')}`) .map((t) => `#${t.serial_number.toString().padStart(4, '0')}`)
@@ -397,6 +441,10 @@ async function pollPaymentStatus(
if (status.purchase.invoice_status === 'expired') { if (status.purchase.invoice_status === 'expired') {
logPaymentEvent(userId, purchaseId, 'expired'); logPaymentEvent(userId, purchaseId, 'expired');
// Delete the invoice messages
await deleteInvoiceMessages();
await bot.sendMessage(chatId, messages.buy.invoiceExpiredShort, { await bot.sendMessage(chatId, messages.buy.invoiceExpiredShort, {
parse_mode: 'Markdown', parse_mode: 'Markdown',
reply_markup: getMainMenuKeyboard(), reply_markup: getMainMenuKeyboard(),

View File

@@ -2,6 +2,19 @@ import TelegramBot from 'node-telegram-bot-api';
import { groupStateManager } from '../services/groupState'; import { groupStateManager } from '../services/groupState';
import { logger, logUserAction } from '../services/logger'; import { logger, logUserAction } from '../services/logger';
import { messages } from '../messages'; 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<string, NodeJS.Timeout> = new Map();
const SETTINGS_MESSAGE_TTL = 2 * 60 * 1000; // 2 minutes
/** /**
* Check if a user is an admin in a group * Check if a user is an admin in a group
@@ -106,7 +119,7 @@ export async function handleGroupSettings(
return; return;
} }
await bot.sendMessage( const sentMessage = await bot.sendMessage(
chatId, chatId,
messages.groups.settingsOverview(currentSettings), messages.groups.settingsOverview(currentSettings),
{ {
@@ -114,12 +127,45 @@ export async function handleGroupSettings(
reply_markup: getGroupSettingsKeyboard(currentSettings), reply_markup: getGroupSettingsKeyboard(currentSettings),
} }
); );
// Schedule auto-delete after 2 minutes
scheduleSettingsMessageDeletion(bot, chatId, sentMessage.message_id);
} catch (error) { } catch (error) {
logger.error('Error in handleGroupSettings', { error, chatId }); logger.error('Error in handleGroupSettings', { error, chatId });
await bot.sendMessage(chatId, messages.errors.generic); 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 * Handle group settings toggle callback
*/ */
@@ -144,8 +190,21 @@ export async function handleGroupSettingsCallback(
return; return;
} }
// Refresh auto-delete timer on any interaction
scheduleSettingsMessageDeletion(bot, chatId, messageId);
try { try {
let setting: 'enabled' | 'drawAnnouncements' | 'reminders' | 'ticketPurchaseAllowed'; const currentSettings = await groupStateManager.getGroup(chatId);
if (!currentSettings) {
await bot.answerCallbackQuery(query.id, { text: 'Group not found' });
return;
}
let updatedSettings: GroupSettings | null = null;
// Handle toggle actions
if (action.startsWith('toggle_')) {
let setting: 'enabled' | 'drawAnnouncements' | 'reminders' | 'ticketPurchaseAllowed' | 'newJackpotAnnouncement' | 'reminder1Enabled' | 'reminder2Enabled' | 'reminder3Enabled';
switch (action) { switch (action) {
case 'toggle_enabled': case 'toggle_enabled':
@@ -160,34 +219,102 @@ export async function handleGroupSettingsCallback(
case 'toggle_purchases': case 'toggle_purchases':
setting = 'ticketPurchaseAllowed'; setting = 'ticketPurchaseAllowed';
break; 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: default:
await bot.answerCallbackQuery(query.id); await bot.answerCallbackQuery(query.id);
return; return;
} }
const currentSettings = await groupStateManager.getGroup(chatId); const currentValue = currentSettings[setting] !== false; // Default true for new settings
if (!currentSettings) { const newValue = !currentValue;
await bot.answerCallbackQuery(query.id, { text: 'Group not found' }); updatedSettings = await groupStateManager.updateSetting(chatId, setting, newValue);
return;
}
const newValue = !currentSettings[setting];
const updatedSettings = await groupStateManager.updateSetting(chatId, setting, newValue);
if (!updatedSettings) {
await bot.answerCallbackQuery(query.id, { text: 'Failed to update' });
return;
}
if (updatedSettings) {
logUserAction(userId, 'Updated group setting', { logUserAction(userId, 'Updated group setting', {
groupId: chatId, groupId: chatId,
setting, setting,
newValue, newValue,
}); });
await bot.answerCallbackQuery(query.id, { await bot.answerCallbackQuery(query.id, {
text: `${setting} ${newValue ? 'enabled' : 'disabled'}`, 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<string, ReminderTime> = {
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;
}
// Update the message with new settings // Update the message with new settings
await bot.editMessageText( await bot.editMessageText(
@@ -205,31 +332,121 @@ 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 * Generate inline keyboard for group settings
*/ */
function getGroupSettingsKeyboard(settings: { function getGroupSettingsKeyboard(settings: GroupSettings): TelegramBot.InlineKeyboardMarkup {
enabled: boolean; const onOff = (val: boolean | undefined) => val !== false ? '✅' : '❌';
drawAnnouncements: boolean; const selected = (current: number, option: number) => current === option ? '●' : '○';
reminders: boolean;
ticketPurchaseAllowed: boolean;
}): TelegramBot.InlineKeyboardMarkup {
const onOff = (val: boolean) => val ? '✅' : '❌';
return { const keyboard: TelegramBot.InlineKeyboardButton[][] = [
inline_keyboard: [
[{ [{
text: `${onOff(settings.enabled)} Bot Enabled`, text: `${onOff(settings.enabled)} Bot Enabled`,
callback_data: 'group_toggle_enabled', callback_data: 'group_toggle_enabled',
}], }],
[{ [{
text: `${onOff(settings.drawAnnouncements)} Draw Announcements`, text: `${onOff(settings.newJackpotAnnouncement)} New Jackpot Announcement`,
callback_data: 'group_toggle_announcements', 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`, text: `${onOff(settings.reminders)} Draw Reminders`,
callback_data: 'group_toggle_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`, text: `${onOff(settings.ticketPurchaseAllowed)} Allow Ticket Purchases`,
callback_data: 'group_toggle_purchases', callback_data: 'group_toggle_purchases',
@@ -237,9 +454,10 @@ function getGroupSettingsKeyboard(settings: {
[{ [{
text: '🔄 Refresh', text: '🔄 Refresh',
callback_data: 'group_refresh', callback_data: 'group_refresh',
}], }]
], );
};
return { inline_keyboard: keyboard };
} }
/** /**
@@ -254,6 +472,9 @@ export async function handleGroupRefresh(
if (!chatId || !messageId) return; if (!chatId || !messageId) return;
// Refresh auto-delete timer
scheduleSettingsMessageDeletion(bot, chatId, messageId);
await bot.answerCallbackQuery(query.id, { text: 'Refreshed!' }); await bot.answerCallbackQuery(query.id, { text: 'Refreshed!' });
const settings = await groupStateManager.getGroup(chatId); const settings = await groupStateManager.getGroup(chatId);

View File

@@ -4,23 +4,32 @@ import { getMainMenuKeyboard } from '../utils/keyboards';
import { messages } from '../messages'; import { messages } from '../messages';
/** /**
* Handle /help command * Handle /lottohelp command
*/ */
export async function handleHelpCommand( export async function handleHelpCommand(
bot: TelegramBot, bot: TelegramBot,
msg: TelegramBot.Message msg: TelegramBot.Message,
isGroup: boolean = false
): Promise<void> { ): Promise<void> {
const chatId = msg.chat.id; const chatId = msg.chat.id;
const userId = msg.from?.id; const userId = msg.from?.id;
if (userId) { if (userId) {
logUserAction(userId, 'Viewed help'); logUserAction(userId, 'Viewed help', { isGroup });
} }
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, { await bot.sendMessage(chatId, messages.help.message, {
parse_mode: 'Markdown', parse_mode: 'Markdown',
reply_markup: getMainMenuKeyboard(), reply_markup: getMainMenuKeyboard(),
}); });
}
} }
export default handleHelpCommand; export default handleHelpCommand;

View File

@@ -2,6 +2,7 @@ export { handleStart } from './start';
export { export {
handleAddressCommand, handleAddressCommand,
handleLightningAddressInput, handleLightningAddressInput,
handleLightningAddressCallback,
} from './address'; } from './address';
export { export {
handleBuyCommand, handleBuyCommand,
@@ -11,6 +12,7 @@ export {
} from './buy'; } from './buy';
export { export {
handleTicketsCommand, handleTicketsCommand,
handleTicketsPage,
handleViewTicket, handleViewTicket,
handleStatusCheck, handleStatusCheck,
} from './tickets'; } from './tickets';
@@ -21,6 +23,11 @@ export {
handleCancel, handleCancel,
handleMenuCallback, handleMenuCallback,
} from './menu'; } from './menu';
export {
handleSettingsCommand,
handleSettingsCallback,
handleDisplayNameInput,
} from './settings';
export { export {
handleBotAddedToGroup, handleBotAddedToGroup,
handleBotRemovedFromGroup, handleBotRemovedFromGroup,

View File

@@ -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<void> {
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<void> {
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<void> {
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,
};

View File

@@ -1,7 +1,7 @@
import TelegramBot from 'node-telegram-bot-api'; import TelegramBot from 'node-telegram-bot-api';
import { stateManager } from '../services/state'; import { stateManager } from '../services/state';
import { logger, logUserAction } from '../services/logger'; import { logger, logUserAction } from '../services/logger';
import { getMainMenuKeyboard, getCancelKeyboard } from '../utils/keyboards'; import { getMainMenuKeyboard, getLightningAddressKeyboard } from '../utils/keyboards';
import { messages } from '../messages'; import { messages } from '../messages';
/** /**
@@ -10,6 +10,7 @@ import { messages } from '../messages';
export async function handleStart(bot: TelegramBot, msg: TelegramBot.Message): Promise<void> { export async function handleStart(bot: TelegramBot, msg: TelegramBot.Message): Promise<void> {
const chatId = msg.chat.id; const chatId = msg.chat.id;
const userId = msg.from?.id; const userId = msg.from?.id;
const username = msg.from?.username;
if (!userId) { if (!userId) {
await bot.sendMessage(chatId, messages.errors.userNotIdentified); 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', { logUserAction(userId, 'Started bot', {
username: msg.from?.username, username: username,
firstName: msg.from?.first_name, firstName: msg.from?.first_name,
}); });
@@ -29,7 +30,7 @@ export async function handleStart(bot: TelegramBot, msg: TelegramBot.Message): P
// Create new user // Create new user
user = await stateManager.createUser( user = await stateManager.createUser(
userId, userId,
msg.from?.username, username,
msg.from?.first_name, msg.from?.first_name,
msg.from?.last_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 // Check if lightning address is set
if (!user.lightningAddress) { if (!user.lightningAddress) {
await bot.sendMessage(chatId, messages.start.needAddress, { await bot.sendMessage(chatId, messages.start.needAddressWithOptions(username), {
parse_mode: 'Markdown', parse_mode: 'Markdown',
reply_markup: getCancelKeyboard(), reply_markup: getLightningAddressKeyboard(username),
}); });
await stateManager.updateUserState(userId, 'awaiting_lightning_address'); await stateManager.updateUserState(userId, 'awaiting_lightning_address');

View File

@@ -7,12 +7,25 @@ import { getMainMenuKeyboard, getViewTicketKeyboard } from '../utils/keyboards';
import { formatSats, formatDate } from '../utils/format'; import { formatSats, formatDate } from '../utils/format';
import { messages } from '../messages'; 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 * Handle /tickets command or "My Tickets" button
*/ */
export async function handleTicketsCommand( export async function handleTicketsCommand(
bot: TelegramBot, bot: TelegramBot,
msg: TelegramBot.Message msg: TelegramBot.Message,
page: number = 0
): Promise<void> { ): Promise<void> {
const chatId = msg.chat.id; const chatId = msg.chat.id;
const userId = msg.from?.id; const userId = msg.from?.id;
@@ -22,8 +35,46 @@ export async function handleTicketsCommand(
return; 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<void> {
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<void> {
try { try {
const user = await stateManager.getUser(userId); const user = await stateManager.getUser(userId);
@@ -32,8 +83,12 @@ export async function handleTicketsCommand(
return; return;
} }
// Get user's purchase IDs from state // Get current cycle to identify current round tickets
const purchaseIds = await stateManager.getUserPurchaseIds(userId, 10); 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) { if (purchaseIds.length === 0) {
await bot.sendMessage(chatId, messages.tickets.empty, { await bot.sendMessage(chatId, messages.tickets.empty, {
@@ -44,21 +99,15 @@ export async function handleTicketsCommand(
} }
// Fetch status for each purchase // Fetch status for each purchase
const purchases: Array<{ const allPurchases: PurchaseInfo[] = [];
id: string;
ticketCount: number;
scheduledAt: string;
invoiceStatus: string;
isWinner: boolean;
hasDrawn: boolean;
}> = [];
for (const purchaseId of purchaseIds) { for (const purchaseId of purchaseIds) {
try { try {
const status = await apiClient.getTicketStatus(purchaseId); const status = await apiClient.getTicketStatus(purchaseId);
if (status) { if (status) {
purchases.push({ allPurchases.push({
id: status.purchase.id, id: status.purchase.id,
cycleId: status.purchase.cycle_id,
ticketCount: status.purchase.number_of_tickets, ticketCount: status.purchase.number_of_tickets,
scheduledAt: status.cycle.scheduled_at, scheduledAt: status.cycle.scheduled_at,
invoiceStatus: status.purchase.invoice_status, 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, { await bot.sendMessage(chatId, messages.tickets.notFound, {
parse_mode: 'Markdown', parse_mode: 'Markdown',
reply_markup: getMainMenuKeyboard(), reply_markup: getMainMenuKeyboard(),
@@ -80,56 +129,134 @@ export async function handleTicketsCommand(
return; return;
} }
// Format purchases list // Separate current round and past tickets
let message = messages.tickets.header; 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++) { // Sort past tickets by date (newest first)
const p = purchases[i]; 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`;
for (const p of currentRoundTickets) {
const drawDate = new Date(p.scheduledAt); const drawDate = new Date(p.scheduledAt);
const statusInfo = getStatusInfo(p);
message += `${statusInfo.emoji} ${p.ticketCount} ticket${p.ticketCount > 1 ? 's' : ''} Draw: ${formatDate(drawDate)}\n`;
let statusEmoji: string; inlineKeyboard.push([{
let statusText: string; text: `🎟 View Current Tickets #${p.id.substring(0, 8)}...`,
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;
}
message += `${i + 1}. ${statusEmoji} ${p.ticketCount} ticket${p.ticketCount > 1 ? 's' : ''} ${formatDate(drawDate)} ${statusText}\n`;
}
message += messages.tickets.tapForDetails;
// 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}`, callback_data: `view_ticket_${p.id}`,
}]); }]);
}
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`;
}
// 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}`,
}]);
}
// 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, { await bot.sendMessage(chatId, message, {
parse_mode: 'Markdown', parse_mode: 'Markdown',
reply_markup: { inline_keyboard: inlineKeyboard }, reply_markup: { inline_keyboard: inlineKeyboard },
}); });
} catch (error) { } catch (error) {
logger.error('Error in handleTicketsCommand', { error, userId }); logger.error('Error in sendTicketsList', { error, userId });
await bot.sendMessage(chatId, messages.errors.fetchTicketsFailed, { await bot.sendMessage(chatId, messages.errors.fetchTicketsFailed, {
reply_markup: getMainMenuKeyboard(), 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 * Handle viewing a specific ticket
*/ */
@@ -254,6 +381,7 @@ export async function handleStatusCheck(
export default { export default {
handleTicketsCommand, handleTicketsCommand,
handleTicketsPage,
handleViewTicket, handleViewTicket,
handleStatusCheck, handleStatusCheck,
}; };

View File

@@ -1,18 +1,22 @@
import TelegramBot from 'node-telegram-bot-api'; import TelegramBot from 'node-telegram-bot-api';
import config from './config'; import config from './config';
import { botDatabase } from './services/database';
import { stateManager } from './services/state'; import { stateManager } from './services/state';
import { groupStateManager } from './services/groupState'; import { groupStateManager } from './services/groupState';
import { apiClient } from './services/api'; import { apiClient } from './services/api';
import { logger, logUserAction } from './services/logger'; import { logger, logUserAction } from './services/logger';
import { notificationScheduler } from './services/notificationScheduler';
import { import {
handleStart, handleStart,
handleAddressCommand, handleAddressCommand,
handleLightningAddressInput, handleLightningAddressInput,
handleLightningAddressCallback,
handleBuyCommand, handleBuyCommand,
handleTicketAmountSelection, handleTicketAmountSelection,
handleCustomTicketAmount, handleCustomTicketAmount,
handlePurchaseConfirmation, handlePurchaseConfirmation,
handleTicketsCommand, handleTicketsCommand,
handleTicketsPage,
handleViewTicket, handleViewTicket,
handleStatusCheck, handleStatusCheck,
handleWinsCommand, handleWinsCommand,
@@ -20,6 +24,9 @@ import {
handleMenuCommand, handleMenuCommand,
handleCancel, handleCancel,
handleMenuCallback, handleMenuCallback,
handleSettingsCommand,
handleSettingsCallback,
handleDisplayNameInput,
handleBotAddedToGroup, handleBotAddedToGroup,
handleBotRemovedFromGroup, handleBotRemovedFromGroup,
handleGroupSettings, handleGroupSettings,
@@ -90,7 +97,7 @@ bot.onText(/\/start/, async (msg) => {
if (isGroupChat(msg)) { if (isGroupChat(msg)) {
await bot.sendMessage( await bot.sendMessage(
msg.chat.id, 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' } { parse_mode: 'Markdown' }
); );
return; return;
@@ -99,8 +106,8 @@ bot.onText(/\/start/, async (msg) => {
await handleStart(bot, msg); await handleStart(bot, msg);
}); });
// Handle /buy command // Handle /buyticket command
bot.onText(/\/buy/, async (msg) => { bot.onText(/\/buyticket/, async (msg) => {
if (!shouldProcessMessage(msg.message_id)) return; if (!shouldProcessMessage(msg.message_id)) return;
// Check if in group // Check if in group
@@ -143,8 +150,8 @@ bot.onText(/\/wins/, async (msg) => {
await handleWinsCommand(bot, msg); await handleWinsCommand(bot, msg);
}); });
// Handle /address command // Handle /lottoaddress command
bot.onText(/\/address/, async (msg) => { bot.onText(/\/lottoaddress/, async (msg) => {
if (!shouldProcessMessage(msg.message_id)) return; if (!shouldProcessMessage(msg.message_id)) return;
// Only in private chat // Only in private chat
@@ -156,8 +163,8 @@ bot.onText(/\/address/, async (msg) => {
await handleAddressCommand(bot, msg); await handleAddressCommand(bot, msg);
}); });
// Handle /menu command // Handle /lottomenu command
bot.onText(/\/menu/, async (msg) => { bot.onText(/\/lottomenu/, async (msg) => {
if (!shouldProcessMessage(msg.message_id)) return; if (!shouldProcessMessage(msg.message_id)) return;
// Only in private chat // Only in private chat
@@ -169,10 +176,10 @@ bot.onText(/\/menu/, async (msg) => {
await handleMenuCommand(bot, msg); await handleMenuCommand(bot, msg);
}); });
// Handle /help command // Handle /lottohelp command
bot.onText(/\/help/, async (msg) => { bot.onText(/\/lottohelp/, async (msg) => {
if (!shouldProcessMessage(msg.message_id)) return; if (!shouldProcessMessage(msg.message_id)) return;
await handleHelpCommand(bot, msg); await handleHelpCommand(bot, msg, isGroupChat(msg));
}); });
// Handle /jackpot command (works in groups and DMs) // Handle /jackpot command (works in groups and DMs)
@@ -197,7 +204,7 @@ bot.onText(/\/jackpot/, async (msg) => {
⏰ *Draw at:* ${formatDate(drawTime)} ⏰ *Draw at:* ${formatDate(drawTime)}
⏳ *Time left:* ${formatTimeUntil(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' }); await bot.sendMessage(msg.chat.id, message, { parse_mode: 'Markdown' });
} catch (error) { } catch (error) {
@@ -206,8 +213,8 @@ Use /buy to get your tickets! 🍀`;
} }
}); });
// Handle /settings command (groups only, admin only) // Handle /lottosettings command (groups only, admin only)
bot.onText(/\/settings/, async (msg) => { bot.onText(/\/lottosettings/, async (msg) => {
if (!shouldProcessMessage(msg.message_id)) return; if (!shouldProcessMessage(msg.message_id)) return;
await handleGroupSettings(bot, msg); await handleGroupSettings(bot, msg);
}); });
@@ -243,6 +250,9 @@ bot.on('message', async (msg) => {
case '⚡ Lightning Address': case '⚡ Lightning Address':
await handleAddressCommand(bot, msg); await handleAddressCommand(bot, msg);
return; return;
case '⚙️ Settings':
await handleSettingsCommand(bot, msg);
return;
case ' Help': case ' Help':
await handleHelpCommand(bot, msg); await handleHelpCommand(bot, msg);
return; return;
@@ -264,6 +274,12 @@ bot.on('message', async (msg) => {
if (handled) return; if (handled) return;
} }
// Handle display name input
if (user.state === 'awaiting_display_name') {
await handleDisplayNameInput(bot, msg);
return;
}
// Handle custom ticket amount input // Handle custom ticket amount input
if (user.state === 'awaiting_ticket_amount') { if (user.state === 'awaiting_ticket_amount') {
const handled = await handleCustomTicketAmount(bot, msg); const handled = await handleCustomTicketAmount(bot, msg);
@@ -304,12 +320,60 @@ bot.on('callback_query', async (query) => {
return; 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 // Handle group refresh
if (data === 'group_refresh') { if (data === 'group_refresh') {
await handleGroupRefresh(bot, query); await handleGroupRefresh(bot, query);
return; 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 // Handle buy amount selection
if (data.startsWith('buy_')) { if (data.startsWith('buy_')) {
const amountStr = data.replace('buy_', ''); const amountStr = data.replace('buy_', '');
@@ -342,6 +406,15 @@ bot.on('callback_query', async (query) => {
return; 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 // Handle view ticket
if (data.startsWith('view_ticket_')) { if (data.startsWith('view_ticket_')) {
const purchaseId = data.replace('view_ticket_', ''); const purchaseId = data.replace('view_ticket_', '');
@@ -389,6 +462,7 @@ async function shutdown(): Promise<void> {
bot.stopPolling(); bot.stopPolling();
await stateManager.close(); await stateManager.close();
await groupStateManager.close(); await groupStateManager.close();
botDatabase.close();
logger.info('Shutdown complete'); logger.info('Shutdown complete');
process.exit(0); process.exit(0);
} }
@@ -399,28 +473,31 @@ process.on('SIGTERM', shutdown);
// Start bot // Start bot
async function start(): Promise<void> { async function start(): Promise<void> {
try { try {
// Initialize state managers // Initialize SQLite database
botDatabase.init();
// Initialize state managers (now use SQLite)
await stateManager.init(); await stateManager.init();
await groupStateManager.init(config.redis.url); await groupStateManager.init();
// Set bot commands for private chats // Set bot commands for private chats
await bot.setMyCommands([ await bot.setMyCommands([
{ command: 'start', description: 'Start the bot' }, { command: 'start', description: 'Start the bot' },
{ command: 'menu', description: 'Show main menu' }, { command: 'lottomenu', description: 'Show main menu' },
{ command: 'buy', description: 'Buy lottery tickets' }, { command: 'buyticket', description: 'Buy lottery tickets' },
{ command: 'tickets', description: 'View your tickets' }, { command: 'tickets', description: 'View your tickets' },
{ command: 'wins', description: 'View your past wins' }, { 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: 'jackpot', description: 'View current jackpot info' },
{ command: 'help', description: 'Help & information' }, { command: 'lottohelp', description: 'Help & information' },
]); ]);
// Set bot commands for groups (different scope) // Set bot commands for groups (different scope)
await bot.setMyCommands( await bot.setMyCommands(
[ [
{ command: 'jackpot', description: 'View current jackpot info' }, { command: 'jackpot', description: 'View current jackpot info' },
{ command: 'settings', description: 'Group settings (admin only)' }, { command: 'lottosettings', description: 'Group settings (admin only)' },
{ command: 'help', description: 'Help & information' }, { command: 'lottohelp', description: 'Help & information' },
], ],
{ scope: { type: 'all_group_chats' } } { scope: { type: 'all_group_chats' } }
); );
@@ -431,10 +508,15 @@ async function start(): Promise<void> {
username: botInfo.username, username: botInfo.username,
}); });
// Initialize and start notification scheduler
notificationScheduler.init(bot);
notificationScheduler.start();
logger.info('⚡ Lightning Jackpot Telegram Bot is running!'); logger.info('⚡ Lightning Jackpot Telegram Bot is running!');
logger.info(`📡 API URL: ${config.api.baseUrl}`); logger.info(`📡 API URL: ${config.api.baseUrl}`);
logger.info(`🌐 Frontend URL: ${config.frontend.baseUrl}`); logger.info(`🌐 Frontend URL: ${config.frontend.baseUrl}`);
logger.info('👥 Group support enabled'); logger.info('👥 Group support enabled');
logger.info('📢 Notification scheduler started');
} catch (error) { } catch (error) {
logger.error('Failed to start bot', { error }); logger.error('Failed to start bot', { error });
process.exit(1); process.exit(1);

View File

@@ -18,7 +18,7 @@ export const messages = {
ticketNotFound: '❌ Ticket not found.', ticketNotFound: '❌ Ticket not found.',
fetchTicketDetailsFailed: '❌ Failed to fetch ticket details.', fetchTicketDetailsFailed: '❌ Failed to fetch ticket details.',
checkStatusFailed: '❌ Failed to check status', 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.', 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:`, 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) => addressSet: (address: string) =>
`✅ Your payout address is set to: \`${address}\` `✅ 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:`, 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. noAddressSet: `⚡ You don't have a Lightning Address set yet.
Send me your Lightning Address now: Send me your Lightning Address now:
*Example:* \`yourname@getalby.com\``, *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. invalidFormat: `❌ That doesn't look like a valid Lightning Address.
*Format:* \`username@domain.com\` *Format:* \`username@domain.com\`
@@ -73,6 +116,31 @@ Send me your Lightning Address now:
Please try again:`, 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) => firstTimeSuccess: (address: string) =>
`✅ *Perfect!* I'll use \`${address}\` to send any winnings. `✅ *Perfect!* I'll use \`${address}\` to send any winnings.
@@ -153,16 +221,14 @@ Confirm this purchase?`,
invoiceCaption: ( invoiceCaption: (
ticketCount: number, ticketCount: number,
totalAmount: string, totalAmount: string,
paymentRequest: string,
expiryMinutes: number expiryMinutes: number
) => ) =>
`🎟 *${ticketCount} ticket${ticketCount > 1 ? 's' : ''}* `🎟 *${ticketCount} ticket${ticketCount > 1 ? 's' : ''}*
💰 *Amount:* ${totalAmount} sats 💰 *Amount:* ${totalAmount} sats
⏳ Expires in ${expiryMinutes} minutes`,
\`${paymentRequest}\` invoiceString: (paymentRequest: string) =>
`\`${paymentRequest}\``,
⏳ This invoice expires in ${expiryMinutes} minutes.
I'll notify you when payment is received!`,
paymentReceived: (ticketNumbers: string, drawTime: string) => paymentReceived: (ticketNumbers: string, drawTime: string) =>
`🎉 *Payment Received!* `🎉 *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. No payment was received in time. No tickets were issued.
Use /buy to try again.`, Use /buyticket to try again.`,
invoiceExpiredShort: `❌ *Invoice Expired* invoiceExpiredShort: `❌ *Invoice Expired*
This invoice has expired. No tickets were issued. This invoice has expired. No tickets were issued.
Use /buy to try again.`, Use /buyticket to try again.`,
jackpotUnavailable: '❌ Jackpot is no longer available.', jackpotUnavailable: '❌ Jackpot is no longer available.',
}, },
@@ -201,13 +267,13 @@ Use /buy to try again.`,
You haven't purchased any tickets yet! You haven't purchased any tickets yet!
Use /buy to get started! 🎟`, Use /buyticket to get started! 🎟`,
notFound: `🧾 *Your Tickets* notFound: `🧾 *Your Tickets*
No ticket purchases found. Purchase history may have expired. 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:`, tapForDetails: `\nTap a ticket below for details:`,
@@ -266,13 +332,13 @@ ${statusSection}`,
You haven't purchased any tickets yet, so no wins to show! 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* noWinsYet: `🏆 *Your Wins*
You haven't won any jackpots yet. Keep playing! 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) => header: (totalWinnings: string, paidWinnings: string) =>
`🏆 *Your Wins* `🏆 *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! 5⃣ If you win, sats are sent to your address instantly!
*Commands:* *Commands:*
• /buy — Buy lottery tickets • /buyticket — Buy lottery tickets
• /tickets — View your tickets • /tickets — View your tickets
• /wins — View your past wins • /wins — View your past wins
• /address — Update Lightning Address • /lottoaddress — Update Lightning Address
• /menu — Show main menu • /lottomenu — Show main menu
• /help — Show this help • /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:* *Tips:*
🎟 Each ticket is one chance to win 🎟 Each ticket is one chance to win
💰 Prize pool grows with each ticket sold 💰 Prize pool grows with each ticket sold
⚡ Winnings are paid instantly via Lightning ⚡ 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! 🍀`, 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! ⚡ 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) => drawCompleted: (potSats: number, hasWinner: boolean) =>
`⏰ *Draw Reminder!* 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 Use /buyticket to enter the next round! 🍀`
🕐 *Draw Time:* ${drawTime} : `🎰 *JACKPOT DRAW COMPLETE!* 🎰
⏳ *Time Left:* ${timeLeft}
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! I can announce lottery draws and remind you when jackpots are coming up!
*Group Admin Commands:* *Group Admin Commands:*
• /settings — Configure bot settings for this group • /lottosettings — Configure bot settings for this group
*User Commands:* *User Commands:*
• /buy — Buy lottery tickets (in DM) • /buyticket — Buy lottery tickets
• /jackpot — View current jackpot info • /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.', adminOnly: '⚠️ Only group administrators can change these settings.',
@@ -415,19 +594,59 @@ To buy tickets, message me directly @LightningLottoBot! 🎟`,
enabled: boolean; enabled: boolean;
drawAnnouncements: boolean; drawAnnouncements: boolean;
reminders: boolean; reminders: boolean;
newJackpotAnnouncement?: boolean;
ticketPurchaseAllowed: boolean; ticketPurchaseAllowed: boolean;
}) => reminder1Enabled?: boolean;
`⚙️ *Group Settings* 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} 📍 *Group:* ${settings.groupTitle}
*Current Configuration:* *Current Configuration:*
${settings.enabled ? '✅' : '❌'} Bot Enabled ${settings.enabled ? '✅' : '❌'} Bot Enabled
${settings.drawAnnouncements ? '✅' : '❌'} Draw Announcements ${newJackpot ? '✅' : '❌'} New Jackpot Announcements
${settings.reminders ? '✅' : '❌'} Draw Reminders ${settings.drawAnnouncements ? '✅' : '❌'} Draw Announcements ${settings.drawAnnouncements ? `_(${formatAnnounce})_` : ''}
${settings.reminders ? '✅' : '❌'} Draw Reminders ${settings.reminders ? `_(${formatReminderList})_` : ''}
${settings.ticketPurchaseAllowed ? '✅' : '❌'} Ticket Purchases in Group ${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) => settingUpdated: (setting: string, enabled: boolean) =>
`✅ *${setting}* has been ${enabled ? 'enabled' : 'disabled'}.`, `✅ *${setting}* has been ${enabled ? 'enabled' : 'disabled'}.`,

View File

@@ -70,7 +70,7 @@ class ApiClient {
async buyTickets( async buyTickets(
tickets: number, tickets: number,
lightningAddress: string, lightningAddress: string,
telegramUserId: number displayName: string = 'Anon'
): Promise<BuyTicketsResponse> { ): Promise<BuyTicketsResponse> {
try { try {
const response = await this.client.post<ApiResponse<BuyTicketsResponse>>( const response = await this.client.post<ApiResponse<BuyTicketsResponse>>(
@@ -78,7 +78,7 @@ class ApiClient {
{ {
tickets, tickets,
lightning_address: lightningAddress, lightning_address: lightningAddress,
buyer_name: `TG:${telegramUserId}`, buyer_name: displayName || 'Anon',
} }
); );
return response.data.data; return response.data.data;

View File

@@ -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<string, any>): 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<NotificationPreferences>): 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;

View File

@@ -1,225 +1,213 @@
import Redis from 'ioredis'; import { botDatabase } from './database';
import config from '../config';
import { logger } from './logger'; import { logger } from './logger';
import { GroupSettings, DEFAULT_GROUP_SETTINGS } from '../types/groups'; import { GroupSettings, ReminderTime, reminderTimeToMinutes } from '../types/groups';
const GROUP_PREFIX = 'tg_group:';
const GROUPS_LIST_KEY = 'tg_groups_list';
const STATE_TTL = 60 * 60 * 24 * 365; // 1 year
class GroupStateManager { class GroupStateManager {
private redis: Redis | null = null; async init(): Promise<void> {
private memoryStore: Map<string, string> = new Map(); // Database is initialized separately
private useRedis: boolean = false; logger.info('Group state manager initialized (using SQLite database)');
async init(redisUrl: string | null): Promise<void> {
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<string | null> {
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<void> {
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<void> {
if (this.useRedis && this.redis) {
await this.redis.del(key);
} else {
this.memoryStore.delete(key);
}
}
private async sadd(key: string, value: string): Promise<void> {
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<void> {
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<string[]> {
if (this.useRedis && this.redis) {
return await this.redis.smembers(key);
}
const existing = this.memoryStore.get(key);
return existing ? JSON.parse(existing) : [];
} }
/** /**
* Get group settings * Get group settings
*/ */
async getGroup(groupId: number): Promise<GroupSettings | null> { async getGroup(groupId: number): Promise<GroupSettings | null> {
const key = `${GROUP_PREFIX}${groupId}`; return botDatabase.getGroup(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;
}
} }
/** /**
* Create or update group settings * Register a new group
*/
async saveGroup(settings: GroupSettings): Promise<void> {
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
*/ */
async registerGroup( async registerGroup(
groupId: number, groupId: number,
groupTitle: string, groupTitle: string,
addedBy: number addedBy: number
): Promise<GroupSettings> { ): Promise<GroupSettings> {
const existing = await this.getGroup(groupId); return botDatabase.registerGroup(groupId, groupTitle, addedBy);
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;
} }
/** /**
* Remove group when bot is removed * Remove a group
*/ */
async removeGroup(groupId: number): Promise<void> { async removeGroup(groupId: number): Promise<void> {
const key = `${GROUP_PREFIX}${groupId}`; botDatabase.removeGroup(groupId);
await this.del(key);
await this.srem(GROUPS_LIST_KEY, groupId.toString());
logger.info('Group removed', { groupId });
} }
/** /**
* Update a specific setting * Save group settings
*/
async saveGroup(settings: GroupSettings): Promise<void> {
botDatabase.saveGroup(settings);
logger.debug('Group settings saved', { groupId: settings.groupId });
}
/**
* Update a group setting
*/ */
async updateSetting( async updateSetting(
groupId: number, groupId: number,
setting: keyof Pick<GroupSettings, 'enabled' | 'drawAnnouncements' | 'reminders' | 'ticketPurchaseAllowed'>, setting:
| 'enabled'
| 'drawAnnouncements'
| 'reminders'
| 'newJackpotAnnouncement'
| 'ticketPurchaseAllowed'
| 'reminder1Enabled'
| 'reminder2Enabled'
| 'reminder3Enabled',
value: boolean value: boolean
): Promise<GroupSettings | null> { ): Promise<GroupSettings | null> {
const settings = await this.getGroup(groupId); return botDatabase.updateGroupSetting(groupId, setting, value);
if (!settings) return null;
settings[setting] = value;
await this.saveGroup(settings);
return settings;
} }
/** /**
* 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<GroupSettings | null> {
return botDatabase.updateReminderTime(groupId, slot, time);
}
/**
* Update announcement delay
*/
async updateAnnouncementDelay(
groupId: number,
seconds: number
): Promise<GroupSettings | null> {
return botDatabase.updateAnnouncementDelay(groupId, seconds);
}
/**
* Get groups with specific feature enabled
*/ */
async getGroupsWithFeature( async getGroupsWithFeature(
feature: 'enabled' | 'drawAnnouncements' | 'reminders' feature: 'enabled' | 'drawAnnouncements' | 'reminders' | 'newJackpotAnnouncement'
): Promise<GroupSettings[]> { ): Promise<GroupSettings[]> {
const groupIds = await this.smembers(GROUPS_LIST_KEY); if (feature === 'newJackpotAnnouncement') {
const groups: GroupSettings[] = []; const allGroups = await this.getAllGroups();
return allGroups.filter(g => g.enabled && g.newJackpotAnnouncement);
for (const id of groupIds) {
const settings = await this.getGroup(parseInt(id, 10));
if (settings && settings.enabled && settings[feature]) {
groups.push(settings);
} }
} return botDatabase.getGroupsWithFeature(feature as 'enabled' | 'drawAnnouncements' | 'reminders');
return groups;
} }
/** /**
* Get all registered groups * Get all groups
*/ */
async getAllGroups(): Promise<GroupSettings[]> { async getAllGroups(): Promise<GroupSettings[]> {
const groupIds = await this.smembers(GROUPS_LIST_KEY); return botDatabase.getAllGroups();
const groups: GroupSettings[] = []; }
for (const id of groupIds) { /**
const settings = await this.getGroup(parseInt(id, 10)); * Get groups that need reminders for a specific draw time
if (settings) { */
groups.push(settings); async getGroupsNeedingReminders(drawTime: Date): Promise<Array<{
settings: GroupSettings;
reminderSlot: 1 | 2 | 3;
}>> {
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 });
} }
} }
return groups; 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 results;
}
/**
* Add time to a reminder
*/
async addReminderTime(
groupId: number,
slot: 1 | 2 | 3,
amount: number,
unit: 'minutes' | 'hours' | 'days'
): Promise<GroupSettings | null> {
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<GroupSettings | null> {
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<void> { async close(): Promise<void> {
if (this.redis) { // Database close is handled separately
await this.redis.quit(); logger.info('Group state manager closed');
}
} }
} }
export const groupStateManager = new GroupStateManager(); export const groupStateManager = new GroupStateManager();
export default groupStateManager; export default groupStateManager;

View File

@@ -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<string, ScheduledReminder> = new Map();
private lastCycleId: string | null = null;
private lastCycleStatus: string | null = null;
private isRunning = false;
private announcedCycles: Set<string> = 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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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;

View File

@@ -1,130 +1,31 @@
import Redis from 'ioredis'; import { botDatabase } from './database';
import config from '../config';
import { logger } from './logger'; import { logger } from './logger';
import { import {
TelegramUser, TelegramUser,
UserState, UserState,
AwaitingPaymentData, AwaitingPaymentData,
NotificationPreferences,
DEFAULT_NOTIFICATIONS,
} from '../types'; } 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 { class StateManager {
private redis: Redis | null = null;
private memoryStore: Map<string, string> = new Map();
private useRedis: boolean = false;
async init(): Promise<void> { async init(): Promise<void> {
if (config.redis.url) { // Database is initialized separately
try { logger.info('State manager initialized (using SQLite database)');
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<string | null> {
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<void> {
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<void> {
if (this.useRedis && this.redis) {
await this.redis.del(key);
} else {
this.memoryStore.delete(key);
}
}
private async lpush(key: string, value: string): Promise<void> {
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<string[]> {
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);
} }
/** /**
* Get or create user * Get user by Telegram ID
*/ */
async getUser(telegramId: number): Promise<TelegramUser | null> { async getUser(telegramId: number): Promise<TelegramUser | null> {
const key = `${STATE_PREFIX}${telegramId}`; return botDatabase.getUser(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;
}
} }
/** /**
* Create or update user * Save user
*/ */
async saveUser(user: TelegramUser): Promise<void> { async saveUser(user: TelegramUser): Promise<void> {
const key = `${STATE_PREFIX}${user.telegramId}`; botDatabase.saveUser(user);
user.updatedAt = new Date();
await this.set(key, JSON.stringify(user), STATE_TTL);
logger.debug('User saved', { telegramId: user.telegramId, state: user.state }); logger.debug('User saved', { telegramId: user.telegramId, state: user.state });
} }
@@ -137,18 +38,7 @@ class StateManager {
firstName?: string, firstName?: string,
lastName?: string lastName?: string
): Promise<TelegramUser> { ): Promise<TelegramUser> {
const user: TelegramUser = { return botDatabase.createUser(telegramId, username, firstName, lastName);
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;
} }
/** /**
@@ -159,14 +49,7 @@ class StateManager {
state: UserState, state: UserState,
stateData?: Record<string, any> stateData?: Record<string, any>
): Promise<void> { ): Promise<void> {
const user = await this.getUser(telegramId); botDatabase.updateUserState(telegramId, state, stateData);
if (!user) {
logger.warn('Attempted to update state for non-existent user', { telegramId });
return;
}
user.state = state;
user.stateData = stateData;
await this.saveUser(user);
} }
/** /**
@@ -176,15 +59,25 @@ class StateManager {
telegramId: number, telegramId: number,
lightningAddress: string lightningAddress: string
): Promise<void> { ): Promise<void> {
const user = await this.getUser(telegramId); botDatabase.updateLightningAddress(telegramId, lightningAddress);
if (!user) {
logger.warn('Attempted to update address for non-existent user', { telegramId });
return;
} }
user.lightningAddress = lightningAddress;
user.state = 'idle'; /**
user.stateData = undefined; * Update user display name
await this.saveUser(user); */
async updateDisplayName(telegramId: number, displayName: string): Promise<void> {
botDatabase.updateDisplayName(telegramId, displayName);
logger.info('Display name updated', { telegramId, displayName });
}
/**
* Update user notification preferences
*/
async updateNotifications(
telegramId: number,
updates: Partial<NotificationPreferences>
): Promise<TelegramUser | null> {
return botDatabase.updateNotifications(telegramId, updates);
} }
/** /**
@@ -195,33 +88,36 @@ class StateManager {
purchaseId: string, purchaseId: string,
data: AwaitingPaymentData data: AwaitingPaymentData
): Promise<void> { ): Promise<void> {
// Store purchase data botDatabase.storePurchase(telegramId, purchaseId, {
const purchaseKey = `${PURCHASE_PREFIX}${purchaseId}`; cycleId: data.cycleId,
await this.set(purchaseKey, JSON.stringify({ ticketCount: data.ticketCount,
telegramId, totalAmount: data.totalAmount,
...data, lightningAddress: data.paymentRequest ? '' : '', // Not storing sensitive data
createdAt: new Date().toISOString(), paymentRequest: data.paymentRequest,
}), STATE_TTL); publicUrl: data.publicUrl,
});
// Add to user's purchase list
const userPurchasesKey = `${USER_PURCHASES_PREFIX}${telegramId}`;
await this.lpush(userPurchasesKey, purchaseId);
} }
/** /**
* Get purchase data * Get purchase data
*/ */
async getPurchase(purchaseId: string): Promise<(AwaitingPaymentData & { telegramId: number }) | null> { async getPurchase(purchaseId: string): Promise<(AwaitingPaymentData & { telegramId: number }) | null> {
const key = `${PURCHASE_PREFIX}${purchaseId}`; const purchase = botDatabase.getPurchase(purchaseId);
const data = await this.get(key); if (!purchase) return null;
if (!data) return null;
try { return {
return JSON.parse(data); telegramId: purchase.telegram_id,
} catch (error) { cycleId: purchase.cycle_id,
logger.error('Failed to parse purchase data', { purchaseId, error }); ticketCount: purchase.ticket_count,
return null; 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, telegramId: number,
limit: number = 10 limit: number = 10
): Promise<string[]> { ): Promise<string[]> {
const key = `${USER_PURCHASES_PREFIX}${telegramId}`; return botDatabase.getUserPurchaseIds(telegramId, limit);
return await this.lrange(key, 0, limit - 1);
} }
/** /**
* Clear user state data (keeping lightning address) * Clear user state data (keeping lightning address)
*/ */
async clearUserStateData(telegramId: number): Promise<void> { async clearUserStateData(telegramId: number): Promise<void> {
const user = await this.getUser(telegramId); botDatabase.updateUserState(telegramId, 'idle', undefined);
if (!user) return; }
user.state = 'idle';
user.stateData = undefined; /**
await this.saveUser(user); * Add user as participant to a cycle
*/
async addCycleParticipant(cycleId: string, telegramId: number, purchaseId: string): Promise<void> {
botDatabase.addCycleParticipant(cycleId, telegramId, purchaseId);
logger.debug('Added cycle participant', { cycleId, telegramId });
}
/**
* Get all participants for a cycle
*/
async getCycleParticipants(cycleId: string): Promise<Array<{ telegramId: number; purchaseId: string }>> {
return botDatabase.getCycleParticipants(cycleId);
}
/**
* Check if user participated in a cycle
*/
async didUserParticipate(cycleId: string, telegramId: number): Promise<boolean> {
return botDatabase.didUserParticipate(cycleId, telegramId);
}
/**
* Get all users with specific notification preference enabled
*/
async getUsersWithNotification(
preference: keyof NotificationPreferences
): Promise<TelegramUser[]> {
return botDatabase.getUsersWithNotification(preference);
}
/**
* Get user's display name (for announcements)
*/
getDisplayName(user: TelegramUser): string {
return user.displayName || 'Anon';
} }
/** /**
* Shutdown * Shutdown
*/ */
async close(): Promise<void> { async close(): Promise<void> {
if (this.redis) { // Database close is handled separately
await this.redis.quit(); logger.info('State manager closed');
logger.info('Redis connection closed');
}
} }
} }
export const stateManager = new StateManager(); export const stateManager = new StateManager();
export default stateManager; export default stateManager;

View File

@@ -1,3 +1,11 @@
/**
* Reminder time with unit
*/
export interface ReminderTime {
value: number;
unit: 'minutes' | 'hours' | 'days';
}
/** /**
* Group settings for lottery features * Group settings for lottery features
*/ */
@@ -7,12 +15,75 @@ export interface GroupSettings {
enabled: boolean; enabled: boolean;
drawAnnouncements: boolean; drawAnnouncements: boolean;
reminders: boolean; reminders: boolean;
newJackpotAnnouncement: boolean; // Announce when a new jackpot starts
ticketPurchaseAllowed: boolean; 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; addedBy: number;
addedAt: Date; addedAt: Date;
updatedAt: 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 * Default group settings
*/ */
@@ -20,7 +91,16 @@ export const DEFAULT_GROUP_SETTINGS: Omit<GroupSettings, 'groupId' | 'groupTitle
enabled: true, enabled: true,
drawAnnouncements: true, drawAnnouncements: true,
reminders: true, reminders: true,
newJackpotAnnouncement: true,
ticketPurchaseAllowed: false, // Disabled by default for privacy - users should buy in DM ticketPurchaseAllowed: false, // Disabled by default for privacy - users should buy in DM
reminder1Enabled: true,
reminder1Time: { value: 1, unit: 'hours' }, // Default: 1 hour before
reminder2Enabled: false,
reminder2Time: { value: 1, unit: 'days' }, // Default: 1 day before
reminder3Enabled: false,
reminder3Time: { value: 6, unit: 'days' }, // Default: 6 days before
reminderTimes: [], // Legacy field
announcementDelaySeconds: 10, // Default: 10 seconds after draw
}; };

View File

@@ -4,7 +4,15 @@ export type UserState =
| 'awaiting_lightning_address' | 'awaiting_lightning_address'
| 'awaiting_ticket_amount' | 'awaiting_ticket_amount'
| 'awaiting_invoice_payment' | 'awaiting_invoice_payment'
| 'updating_address'; | 'updating_address'
| 'awaiting_display_name';
// User notification preferences
export interface NotificationPreferences {
drawReminders: boolean;
drawResults: boolean;
newJackpotAlerts: boolean;
}
// Telegram user data stored in state // Telegram user data stored in state
export interface TelegramUser { export interface TelegramUser {
@@ -12,13 +20,22 @@ export interface TelegramUser {
username?: string; username?: string;
firstName?: string; firstName?: string;
lastName?: string; lastName?: string;
displayName?: string; // Custom display name for announcements (default: "Anon")
lightningAddress?: string; lightningAddress?: string;
state: UserState; state: UserState;
stateData?: Record<string, any>; stateData?: Record<string, any>;
notifications: NotificationPreferences;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }
// Default notification preferences
export const DEFAULT_NOTIFICATIONS: NotificationPreferences = {
drawReminders: true,
drawResults: true,
newJackpotAlerts: true,
};
// API Response Types // API Response Types
export interface ApiResponse<T> { export interface ApiResponse<T> {
version: string; version: string;
@@ -131,6 +148,9 @@ export interface AwaitingPaymentData extends PendingPurchaseData {
paymentRequest: string; paymentRequest: string;
publicUrl: string; publicUrl: string;
pollStartTime: number; pollStartTime: number;
headerMessageId?: number;
qrMessageId?: number;
invoiceMessageId?: number;
} }
// Re-export group types // Re-export group types

View File

@@ -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 { export function isValidLightningAddress(address: string): boolean {
// Basic format: something@something.something // Basic format: something@something.something
@@ -54,6 +54,62 @@ export function isValidLightningAddress(address: string): boolean {
return regex.test(address); 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 * Escape markdown special characters for Telegram MarkdownV2
*/ */

View File

@@ -2,6 +2,7 @@ import TelegramBot, {
InlineKeyboardMarkup, InlineKeyboardMarkup,
ReplyKeyboardMarkup, ReplyKeyboardMarkup,
} from 'node-telegram-bot-api'; } from 'node-telegram-bot-api';
import { NotificationPreferences } from '../types';
/** /**
* Main menu reply keyboard * Main menu reply keyboard
@@ -11,7 +12,7 @@ export function getMainMenuKeyboard(): ReplyKeyboardMarkup {
keyboard: [ keyboard: [
[{ text: '🎟 Buy Tickets' }, { text: '🧾 My Tickets' }], [{ text: '🎟 Buy Tickets' }, { text: '🧾 My Tickets' }],
[{ text: '🏆 My Wins' }, { text: '⚡ Lightning Address' }], [{ text: '🏆 My Wins' }, { text: '⚡ Lightning Address' }],
[{ text: ' Help' }], [{ text: '⚙️ Settings' }, { text: ' Help' }],
], ],
resize_keyboard: true, resize_keyboard: true,
one_time_keyboard: false, 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( export function getViewTicketKeyboard(
purchaseId: string, purchaseId: string,
publicUrl?: string publicUrl?: string
): InlineKeyboardMarkup { ): InlineKeyboardMarkup {
const buttons: TelegramBot.InlineKeyboardButton[][] = [ const buttons: TelegramBot.InlineKeyboardButton[][] = [];
[{ text: '🔄 Check Status', callback_data: `status_${purchaseId}` }],
];
// Only add URL button if it's a valid Telegram URL (HTTPS, not localhost) // Only add URL button if it's a valid Telegram URL (HTTPS, not localhost)
if (publicUrl && isValidTelegramUrl(publicUrl)) { 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',
}],
],
};
}