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:
6
telegram_bot/.gitignore
vendored
6
telegram_bot/.gitignore
vendored
@@ -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/
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
463
telegram_bot/package-lock.json
generated
463
telegram_bot/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
172
telegram_bot/src/handlers/settings.ts
Normal file
172
telegram_bot/src/handlers/settings.ts
Normal 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,
|
||||||
|
};
|
||||||
|
|
||||||
@@ -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');
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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'}.`,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
649
telegram_bot/src/services/database.ts
Normal file
649
telegram_bot/src/services/database.ts
Normal 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;
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
661
telegram_bot/src/services/notificationScheduler.ts
Normal file
661
telegram_bot/src/services/notificationScheduler.ts
Normal 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;
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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',
|
||||||
|
}],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user