diff --git a/TODO.md b/TODO.md index 5e73f7f..706decf 100644 --- a/TODO.md +++ b/TODO.md @@ -15,9 +15,9 @@ - [x] delete group from backend when the bot leaves a group - [x] search - [ ] advanced moderation - - [ ] ban_all - - [ ] unban_all - - [x] audit log + - [x] ban_all + - [x] unban_all + - [ ] audit log (implemented, need to audit every mod action) - [x] /report to allow user to report (@admin is not implemented) - [x] track ban, mute and kick done via telegram UI (not by command) - [ ] send in-chat action log (deprived of chat ids and stuff) diff --git a/package.json b/package.json index c8178cc..2e5ab7c 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@t3-oss/env-core": "^0.13.4", "@trpc/client": "^11.5.1", "@types/ssdeep.js": "^0.0.2", + "bullmq": "^5.59.0", "croner": "^9.0.0", "grammy": "^1.37.0", "nanoid": "^5.1.5", @@ -54,7 +55,7 @@ "superjson": "^2.2.2", "zod": "^4.1.11" }, - "packageManager": "pnpm@10.6.5+sha512.cdf928fca20832cd59ec53826492b7dc25dc524d4370b6b4adbf65803d32efaa6c1c88147c0ae4e8d579a6c9eec715757b50d4fa35eea179d868eada4ed043af", + "packageManager": "pnpm@10.17.1+sha512.17c560fca4867ae9473a3899ad84a88334914f379be46d455cbf92e5cf4b39d34985d452d2583baf19967fa76cb5c17bc9e245529d0b98745721aa7200ecaf7a", "engines": { "npm": ">=10.9.2", "node": ">=22.14.0" @@ -62,6 +63,7 @@ "pnpm": { "onlyBuiltDependencies": [ "esbuild", + "msgpackr-extract", "unrs-resolver" ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ab1ba7b..0e6ef22 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: '@types/ssdeep.js': specifier: ^0.0.2 version: 0.0.2 + bullmq: + specifier: ^5.59.0 + version: 5.59.0 croner: specifier: ^9.0.0 version: 9.0.0 @@ -360,6 +363,9 @@ packages: '@grammyjs/types@3.21.0': resolution: {integrity: sha512-IMj0EpmglPCICuyfGRx4ENKPSuzS2xMSoPgSPzHC6FtnWKDEmJLBP/GbPv/h3TAeb27txqxm/BUld+gbJk6ccQ==} + '@ioredis/commands@1.4.0': + resolution: {integrity: sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==} + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -385,6 +391,36 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': + resolution: {integrity: sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==} + cpu: [arm64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': + resolution: {integrity: sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==} + cpu: [x64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': + resolution: {integrity: sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==} + cpu: [arm64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': + resolution: {integrity: sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==} + cpu: [arm] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': + resolution: {integrity: sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==} + cpu: [x64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': + resolution: {integrity: sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==} + cpu: [x64] + os: [win32] + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -666,6 +702,9 @@ packages: brace-expansion@2.0.2: resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + bullmq@5.59.0: + resolution: {integrity: sha512-RmqUIvNKWQ5bTBnMo4ttCNqWs+IzTHfkRbPS95r8Ba2uCZQKe/xXbZbIiwx5FAMhckab03slIKKAYHto/M223Q==} + bundle-require@5.1.0: resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -725,6 +764,10 @@ packages: create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + cron-parser@4.9.0: + resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==} + engines: {node: '>=12.0.0'} + croner@9.0.0: resolution: {integrity: sha512-onMB0OkDjkXunhdW9htFjEhqrD54+M94i6ackoUkjHKbRnXdyEyKRelp4nJ1kAz32+s27jP1FsebpJCVl0BsvA==} engines: {node: '>=18.0'} @@ -762,6 +805,14 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + + detect-libc@2.1.1: + resolution: {integrity: sha512-ecqj/sy1jcK1uWrwpR67UhYrIFQ+5WlGxth34WquCbamhFA6hkkwiu37o6J5xCHdo1oixJRfVRw+ywV+Hq/0Aw==} + engines: {node: '>=8'} + diff@4.0.2: resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} engines: {node: '>=0.3.1'} @@ -923,6 +974,10 @@ packages: humanize-ms@1.2.1: resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} + ioredis@5.8.0: + resolution: {integrity: sha512-AUXbKn9gvo9hHKvk6LbZJQSKn/qIfkWXrnsyL9Yrf+oeXmla9Nmf6XEumOddyhM8neynpK5oAV6r9r99KBuwzA==} + engines: {node: '>=12.22.0'} + is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} @@ -952,6 +1007,12 @@ packages: resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + lodash.defaults@4.2.0: + resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + + lodash.isarguments@3.1.0: + resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} + lodash.sortby@4.7.0: resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==} @@ -961,6 +1022,10 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} + engines: {node: '>=12'} + magic-string@0.30.17: resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} @@ -993,6 +1058,13 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + msgpackr-extract@3.0.3: + resolution: {integrity: sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==} + hasBin: true + + msgpackr@1.11.5: + resolution: {integrity: sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==} + mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} @@ -1010,6 +1082,9 @@ packages: resolution: {integrity: sha512-kOCT/1MCPAxY5iUV3wytNFUMUolzuwd/VF/1KCx7kf6CutrOsTie+84zTGTpgQycjvfLdBBdvBvFLqFD2c0wkQ==} engines: {node: '>=18'} + node-abort-controller@3.1.1: + resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} + node-domexception@1.0.0: resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} engines: {node: '>=10.5.0'} @@ -1024,6 +1099,10 @@ packages: encoding: optional: true + node-gyp-build-optional-packages@5.2.2: + resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} + hasBin: true + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -1137,6 +1216,14 @@ packages: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} engines: {node: '>= 12.13.0'} + redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + + redis-parser@3.0.0: + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} + engines: {node: '>=4'} + redis@4.7.0: resolution: {integrity: sha512-zvmkHEAdGMn+hMRXuMBtu4Vo5P6rHQjLoHftu+lBqq8ZTA3RCVC/WzD790bkKKiNFp7d5/9PcSD19fJyyRvOdQ==} @@ -1162,6 +1249,11 @@ packages: secure-json-parse@4.0.0: resolution: {integrity: sha512-dxtLJO6sc35jWidmLxo7ij+Eg48PM/kleBsxpC8QJE0qJICe+KawkDQmvCMZUr9u7WKVHgMW6vy3fQ7zMiFZMA==} + semver@7.7.2: + resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} + engines: {node: '>=10'} + hasBin: true + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -1207,6 +1299,9 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + std-env@3.9.0: resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==} @@ -1302,6 +1397,9 @@ packages: '@swc/wasm': optional: true + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsup@8.4.0: resolution: {integrity: sha512-b+eZbPCjz10fRryaAA7C8xlIHnf8VnsaRqydheLIqwG/Mcpfk8Z5zp3HayX7GaTygkigHl5cBUs+IhcySiIexQ==} engines: {node: '>=18'} @@ -1337,6 +1435,10 @@ packages: undici-types@6.20.0: resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==} + uuid@11.1.0: + resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} + hasBin: true + v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} @@ -1629,6 +1731,8 @@ snapshots: '@grammyjs/types@3.21.0': {} + '@ioredis/commands@1.4.0': {} + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -1660,6 +1764,24 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.0 + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': + optional: true + '@pkgjs/parseargs@0.11.0': optional: true @@ -1879,6 +2001,18 @@ snapshots: dependencies: balanced-match: 1.0.2 + bullmq@5.59.0: + dependencies: + cron-parser: 4.9.0 + ioredis: 5.8.0 + msgpackr: 1.11.5 + node-abort-controller: 3.1.1 + semver: 7.7.2 + tslib: 2.8.1 + uuid: 11.1.0 + transitivePeerDependencies: + - supports-color + bundle-require@5.1.0(esbuild@0.25.1): dependencies: esbuild: 0.25.1 @@ -1929,6 +2063,10 @@ snapshots: create-require@1.1.1: {} + cron-parser@4.9.0: + dependencies: + luxon: 3.7.2 + croner@9.0.0: {} cross-spawn@7.0.6: @@ -1951,6 +2089,11 @@ snapshots: delayed-stream@1.0.0: {} + denque@2.1.0: {} + + detect-libc@2.1.1: + optional: true + diff@4.0.2: {} dunder-proto@1.0.1: @@ -2138,6 +2281,20 @@ snapshots: dependencies: ms: 2.1.3 + ioredis@5.8.0: + dependencies: + '@ioredis/commands': 1.4.0 + cluster-key-slot: 1.1.2 + debug: 4.4.0 + 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 + transitivePeerDependencies: + - supports-color + is-fullwidth-code-point@3.0.0: {} is-what@4.1.16: {} @@ -2158,12 +2315,18 @@ snapshots: load-tsconfig@0.2.5: {} + lodash.defaults@4.2.0: {} + + lodash.isarguments@3.1.0: {} + lodash.sortby@4.7.0: {} loupe@3.1.3: {} lru-cache@10.4.3: {} + luxon@3.7.2: {} + magic-string@0.30.17: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 @@ -2188,6 +2351,22 @@ snapshots: ms@2.1.3: {} + msgpackr-extract@3.0.3: + dependencies: + node-gyp-build-optional-packages: 5.2.2 + optionalDependencies: + '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.3 + optional: true + + msgpackr@1.11.5: + optionalDependencies: + msgpackr-extract: 3.0.3 + mz@2.7.0: dependencies: any-promise: 1.3.0 @@ -2202,12 +2381,19 @@ snapshots: optionalDependencies: '@rollup/rollup-linux-x64-gnu': 4.37.0 + node-abort-controller@3.1.1: {} + node-domexception@1.0.0: {} node-fetch@2.7.0: dependencies: whatwg-url: 5.0.0 + node-gyp-build-optional-packages@5.2.2: + dependencies: + detect-libc: 2.1.1 + optional: true + object-assign@4.1.1: {} on-exit-leak-free@2.1.2: {} @@ -2315,6 +2501,12 @@ snapshots: real-require@0.2.0: {} + redis-errors@1.2.0: {} + + redis-parser@3.0.0: + dependencies: + redis-errors: 1.2.0 + redis@4.7.0: dependencies: '@redis/bloom': 1.2.0(@redis/client@1.6.0) @@ -2360,6 +2552,8 @@ snapshots: secure-json-parse@4.0.0: {} + semver@7.7.2: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -2404,6 +2598,8 @@ snapshots: stackback@0.0.2: {} + standard-as-callback@2.1.0: {} + std-env@3.9.0: {} string-width@4.2.3: @@ -2502,6 +2698,8 @@ snapshots: v8-compile-cache-lib: 3.0.1 yn: 3.1.1 + tslib@2.8.1: {} + tsup@8.4.0(postcss@8.5.3)(tsx@4.19.3)(typescript@5.7.3): dependencies: bundle-require: 5.1.0(esbuild@0.25.1) @@ -2542,6 +2740,8 @@ snapshots: undici-types@6.20.0: {} + uuid@11.1.0: {} + v8-compile-cache-lib@3.0.1: {} vite-node@3.1.1(@types/node@22.13.1)(tsx@4.19.3): diff --git a/src/bot.ts b/src/bot.ts index 42ee1a2..6cfd4da 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -8,7 +8,6 @@ import { apiTestQuery } from "./backend" import { commands } from "./commands" import { env } from "./env" import { MenuGenerator } from "./lib/menu" -import { TgLogger } from "./lib/tg-logger" import { logger } from "./logger" import { AutoModerationStack } from "./middlewares/auto-moderation-stack" import { BotMembershipHandler } from "./middlewares/bot-membership-handler" @@ -16,11 +15,11 @@ import { checkUsername } from "./middlewares/check-username" import { messageLink } from "./middlewares/message-link" import { MessageUserStorage } from "./middlewares/message-user-storage" import { UIActionsLogger } from "./middlewares/ui-actions-logger" +import { modules, sharedDataInit } from "./modules" import { redis } from "./redis" import { once } from "./utils/once" import { setTelegramId } from "./utils/telegram-id" -import type { Context } from "./utils/types" -import { WebSocketClient } from "./websocket" +import type { Context, ModuleShared } from "./utils/types" const TEST_CHAT_ID = -1002669533277 const ALLOWED_UPDATES: ReadonlyArray> = [ @@ -63,16 +62,16 @@ bot.use( }) ) -export const tgLogger = new TgLogger(bot, -1002685849173, { - banAll: 13, - exceptions: 3, - autoModeration: 7, - adminActions: 5, - actionRequired: 10, - groupManagement: 33, - deletedMessages: 130, +bot.init().then(() => { + const sharedData: ModuleShared = { + api: bot.api, + botInfo: bot.botInfo, + } + sharedDataInit.resolve(sharedData) }) +const tgLogger = modules.get("tgLogger") + bot.use(MenuGenerator.getInstance()) bot.use(commands) bot.use(new BotMembershipHandler()) @@ -108,8 +107,6 @@ bot.catch(async (err) => { logger.error(e) }) -new WebSocketClient(bot) - const runner = run(bot, { runner: { fetch: { @@ -123,7 +120,8 @@ const terminate = once(async (signal: NodeJS.Signals) => { const p1 = MessageUserStorage.getInstance().sync() const p2 = redis.quit() const p3 = runner.isRunning() && runner.stop() - await Promise.all([p1, p2, p3]) + const p4 = modules.stop() + await Promise.all([p1, p2, p3, p4]) logger.info("Bot stopped!") process.exit(0) }) diff --git a/src/commands/ban.ts b/src/commands/ban.ts index f61ef85..f336d28 100644 --- a/src/commands/ban.ts +++ b/src/commands/ban.ts @@ -1,5 +1,5 @@ -import { ban, unban } from "@/lib/moderation" import { logger } from "@/logger" +import { ban, unban } from "@/modules/moderation" import { duration } from "@/utils/duration" import { fmt } from "@/utils/format" import { getTelegramId } from "@/utils/telegram-id" diff --git a/src/commands/banall.ts b/src/commands/banall.ts new file mode 100644 index 0000000..9423360 --- /dev/null +++ b/src/commands/banall.ts @@ -0,0 +1,114 @@ +import type { User } from "grammy/types" +import z from "zod" +import { api } from "@/backend" +import { modules } from "@/modules" +import { getTelegramId } from "@/utils/telegram-id" +import type { Role } from "@/utils/types" +import { _commandsBase } from "./_base" + +const numberOrString = z.string().transform((s) => { + const n = Number(s) + if (!Number.isNaN(n) && s.trim() !== "") return n + return s +}) + +const BYPASS_ROLES: Role[] = ["president", "owner", "direttivo"] + +_commandsBase + .createCommand({ + trigger: "ban_all", + description: "PREMA BAN a user from all the Network's groups", + scope: "private", + permissions: { + allowedRoles: ["owner", "direttivo"], + }, + args: [ + { + key: "username", + type: numberOrString, + description: "The username or the user id of the user you want to update the role", + }, + { + key: "reason", + type: z.string(), + description: "The reason why you ban the user", + }, + ], + handler: async ({ args, context }) => { + await context.deleteMessage() + + const userId: number | null = + typeof args.username === "string" ? await getTelegramId(args.username.replaceAll("@", "")) : args.username + + if (userId === null) { + await context.reply("Not a valid userId or username not in our cache") + return + } + + const dbUser = await api.tg.users.get.query({ userId }) + const { roles } = await api.tg.permissions.getRoles.query({ userId }) + if (roles?.some((r) => BYPASS_ROLES.includes(r))) { + await context.reply("This user has special roles so cannot be banned.") + return + } + + if (!dbUser || dbUser.error) { + await context.reply("This user is not in our cache, we cannot proceed.") + return + } + + const target: User = { + id: userId, + first_name: dbUser.user.firstName, + last_name: dbUser.user.lastName, + username: dbUser.user.username, + is_bot: dbUser.user.isBot, + language_code: dbUser.user.langCode, + } + + await modules.get("tgLogger").banAll(target, context.from, "BAN", args.reason) + }, + }) + .createCommand({ + trigger: "unban_all", + description: "UNBAN a user from all the Network's groups", + scope: "private", + permissions: { + allowedRoles: ["owner", "direttivo"], + }, + args: [ + { + key: "username", + type: numberOrString, + description: "The username or the user id of the user you want to update the role", + }, + ], + handler: async ({ args, context }) => { + await context.deleteMessage() + + const userId: number | null = + typeof args.username === "string" ? await getTelegramId(args.username.replaceAll("@", "")) : args.username + + if (userId === null) { + await context.reply("Not a valid userId or username not in our cache") + return + } + + const dbUser = await api.tg.users.get.query({ userId }) + if (!dbUser || dbUser.error) { + await context.reply("This user is not in our cache, we cannot proceed.") + return + } + + const target: User = { + id: userId, + first_name: dbUser.user.firstName, + last_name: dbUser.user.lastName, + username: dbUser.user.username, + is_bot: dbUser.user.isBot, + language_code: dbUser.user.langCode, + } + + await modules.get("tgLogger").banAll(target, context.from, "UNBAN") + }, + }) diff --git a/src/commands/del.ts b/src/commands/del.ts index ef038d9..db13e1f 100644 --- a/src/commands/del.ts +++ b/src/commands/del.ts @@ -1,7 +1,6 @@ -import { tgLogger } from "@/bot" import { logger } from "@/logger" +import { modules } from "@/modules" import { getText } from "@/utils/messages" - import { _commandsBase } from "./_base" _commandsBase.createCommand({ @@ -22,7 +21,7 @@ _commandsBase.createCommand({ sender: repliedTo.from?.username, }) - await tgLogger.delete([repliedTo], "Command /del", context.from) // actual message to delete + await modules.get("tgLogger").delete([repliedTo], "Command /del", context.from) // actual message to delete await context.deleteMessage() // /del message }, }) diff --git a/src/commands/index.ts b/src/commands/index.ts index 09ce95a..602da23 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -2,6 +2,7 @@ import "./test" import "./mute" import "./audit" import "./ban" +import "./banall" import "./kick" import "./del" import "./search" diff --git a/src/commands/kick.ts b/src/commands/kick.ts index e577adc..f23dcce 100644 --- a/src/commands/kick.ts +++ b/src/commands/kick.ts @@ -1,5 +1,5 @@ -import { kick } from "@/lib/moderation" import { logger } from "@/logger" +import { kick } from "@/modules/moderation" import { wait } from "@/utils/wait" import { _commandsBase } from "./_base" diff --git a/src/commands/mute.ts b/src/commands/mute.ts index ce93bdf..c8791f2 100644 --- a/src/commands/mute.ts +++ b/src/commands/mute.ts @@ -1,5 +1,5 @@ -import { mute, unmute } from "@/lib/moderation" import { logger } from "@/logger" +import { mute, unmute } from "@/modules/moderation" import { duration } from "@/utils/duration" import { fmt } from "@/utils/format" import { getTelegramId } from "@/utils/telegram-id" diff --git a/src/commands/report.ts b/src/commands/report.ts index 8a998e7..7df8a9d 100644 --- a/src/commands/report.ts +++ b/src/commands/report.ts @@ -1,6 +1,5 @@ -import { tgLogger } from "@/bot" import { logger } from "@/logger" - +import { modules } from "@/modules" import { _commandsBase } from "./_base" _commandsBase.createCommand({ @@ -15,6 +14,6 @@ _commandsBase.createCommand({ return } - await tgLogger.report(repliedTo, context.from) + await modules.get("tgLogger").report(repliedTo, context.from) }, }) diff --git a/src/commands/test/menu.ts b/src/commands/test/menu.ts index 717ab00..4de52b4 100644 --- a/src/commands/test/menu.ts +++ b/src/commands/test/menu.ts @@ -12,7 +12,7 @@ const generateMenu = MenuGenerator.getInstance().create<{ cb: async ({ ctx, data }) => { await ctx.editMessageText(`${ctx.msg?.text ?? ""}\nBAN`, { reply_markup: ctx.msg?.reply_markup ?? undefined }) logger.info({ data }, "TESTSTESTSTSTE") - return "Deleted + Banned" + return { feedback: "Deleted + Banned" } }, }, ], @@ -21,12 +21,14 @@ const generateMenu = MenuGenerator.getInstance().create<{ text: "TEST 1", cb: () => { logger.info("TEST 1") + return null }, }, { text: "TEST 2", cb: () => { logger.info("TEST 2") + return null }, }, ], diff --git a/src/env.ts b/src/env.ts index 2360057..1e592f3 100644 --- a/src/env.ts +++ b/src/env.ts @@ -11,7 +11,6 @@ export const env = createEnv({ REDIS_USERNAME: z.string().min(1).optional(), REDIS_PASSWORD: z.string().min(1).optional(), NODE_ENV: z.enum(["development", "production"]).default("development"), - LOG_LEVEL: z.string().default("DEBUG"), OPENAI_API_KEY: z.string().optional(), }, diff --git a/src/lib/menu/index.ts b/src/lib/menu/index.ts index 18431d6..a544457 100644 --- a/src/lib/menu/index.ts +++ b/src/lib/menu/index.ts @@ -16,8 +16,10 @@ const CONSTANTS = { } export type CallbackCtx = Filter -// biome-ignore lint/suspicious/noConfusingVoidType: literally a bug in Biome -type Callback = (params: { data: T; ctx: CallbackCtx }) => MaybePromise +type Callback = (params: { + data: T + ctx: CallbackCtx +}) => MaybePromise<{ feedback?: string; newData?: T } | null> class Menu { private dataStorage: RedisFallbackAdapter @@ -63,6 +65,10 @@ class Menu { return keyboard } + async updateData(keyboardId: string, data: T): Promise { + await this.dataStorage.write(keyboardId, data) + } + async call(ctx: CallbackCtx, row: number, col: number, keyboardId: string) { const buttonId = `${row}:${col}` const callback = this.callbacks.get(buttonId) @@ -136,14 +142,18 @@ export class MenuGenerator implements MiddlewareObj { return menu .call(ctx, row, col, keyboardId) - .then((result) => { - return ctx.answerCallbackQuery({ text: result ?? undefined }) + .then(async (result) => { + if (result?.newData) await menu.updateData(keyboardId, result.newData) + return ctx.answerCallbackQuery({ text: result?.feedback }) }) .catch(async (e: unknown) => { logger.error({ e }, "ERROR WHILE CALLING MENU CB") await ctx.editMessageReplyMarkup().catch(() => {}) - const feedback = menu.onExpiredButtonPress && (await menu.onExpiredButtonPress({ data: null, ctx })) - await ctx.answerCallbackQuery({ text: feedback ?? "This button is no longer available", show_alert: true }) + const result = await menu.onExpiredButtonPress?.({ data: null, ctx }) + await ctx.answerCallbackQuery({ + text: result?.feedback ?? "This button is no longer available", + show_alert: true, + }) }) }) } diff --git a/src/lib/modules/index.ts b/src/lib/modules/index.ts new file mode 100644 index 0000000..6ad6734 --- /dev/null +++ b/src/lib/modules/index.ts @@ -0,0 +1,131 @@ +import type { MaybePromise } from "@/utils/types" +import { Awaiter } from "@/utils/wait" + +const SHARED_GETTER = Symbol("__internal_shared_getter") + +type WithGetter = { + [SHARED_GETTER]?: () => Readonly +} + +/** + * @deprecated ## VERY PRIVATE FUNCTION, IF EXPORTED I'LL LITERALLY START CRYING SO DON'T + * + * Get the shared value getter for a module instance. + * + * _comments as visibility modifiers: βœ…_ + * @param self The module instance + * @returns A function that returns the shared value, or null if not available + */ +function magicGetter(self: Module): WithGetter { + return self as unknown as WithGetter // dont tell typescript! +} + +/** + * Base class for modules that can share immutable data via a ModuleCoordinator. + * The shared data is accessible via the `shared` getter. + * + * This abstract class provides overridable lifecycle methods `start` and `stop` + * for initialization and cleanup when used with a `ModuleCoordinator`. + */ +export abstract class Module { + /** + * The concrete getter is stored under a symbol property that is NOT exposed. + * It's `private` so subclasses cannot directly touch it. We still access it + * via a symbol to allow ModuleCoordinator (in same module/file) to bind it. + * biome-ignore lint/correctness/noUnusedPrivateClassMembers: it's a kind of magic + */ + private [SHARED_GETTER]?: () => Readonly + + /** + * Protected accessor for the shared, immutable data. If a module tries to use + * it before the coordinator has bound it, we throw an error. + */ + protected get shared(): Readonly { + const getter = magicGetter(this)[SHARED_GETTER] + if (!getter) { + throw new Error("Module not bound to a ModuleCoordinator or coordinator hasn't started yet.") + } + return getter() + } + + /** + * Called upon initialization, here the shared value is guaranteed to be bound. + */ + public start?(): void | Promise + /** + * Called when the coordinator is stopped, for cleanup. + */ + public stop?(): void | Promise +} + +/** + * Coordinator which owns a Readonly and binds it to all modules. + * The data is frozen (immutable) and isolated per-coordinator instance. + */ +export class ModuleCoordinator>> { + private sharedValue?: Readonly + private started = false + private starting = new Awaiter() + + constructor( + private readonly modules: ModuleMap, + sharedValue: () => MaybePromise + ) { + void this.init(sharedValue) + } + + private async init(sharedValue: () => MaybePromise) { + const resolved = await sharedValue() + this.sharedValue = Object.freeze(resolved) // make it immutable + + // Bind the internal getter to each module. Because SHARED_GETTER is a symbol + // private to this file, external code won't know how to access it. + for (const m of Object.values(this.modules)) { + // `as any` is required because symbols on classes are not part of the + // public Module shape. This is internal wiring only. + magicGetter(m)[SHARED_GETTER] = () => resolved + } + await this.start() + this.starting.resolve() + } + + public async ready(): Promise { + await this.starting + } + + /** Returns the shared value owned by the coordinator. */ + public get shared(): Readonly { + if (!this.sharedValue) { + throw new Error("ModuleCoordinator hasn't been initialized yet.") + } + return this.sharedValue + } + + public get(module: K): ModuleMap[K] { + return this.modules[module] + } + + private async start(): Promise { + if (this.started) return + this.started = true + await Promise.all( + Object.values(this.modules) + .map((m) => m.start?.()) + .filter(Boolean) + ) + } + + /** + * Stops all modules for a graceful shutdown. + * @returns A promise that resolves when all modules have been stopped. + */ + public async stop(): Promise { + if (!this.started) return + await Promise.all( + Object.values(this.modules) + .map((m) => m.stop?.()) + .filter(Boolean) + ) + this.started = false + } +} diff --git a/src/logger.ts b/src/logger.ts index 721e808..18bc9df 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -1,7 +1,8 @@ import pino from "pino" -import { env } from "./env" - export const logger = pino({ - level: env.LOG_LEVEL, + // the reason why we use process.env instead of @/env is that + // we want the logger to be working also in tests where we do not have + // environment variables set. If we used @/env it would throw an error + level: process.env.LOG_LEVEL || "debug", }) diff --git a/src/middlewares/auto-moderation-stack/index.ts b/src/middlewares/auto-moderation-stack/index.ts index dd82dc3..daf2a78 100644 --- a/src/middlewares/auto-moderation-stack/index.ts +++ b/src/middlewares/auto-moderation-stack/index.ts @@ -2,8 +2,8 @@ import type { Filter, MiddlewareObj } from "grammy" import { Composer } from "grammy" import type { Message } from "grammy/types" import ssdeep from "ssdeep.js" -import { tgLogger } from "@/bot" -import { mute } from "@/lib/moderation" +import { modules } from "@/modules" +import { mute } from "@/modules/moderation" import { redis } from "@/redis" import { groupMessagesByChat, RestrictPermissions } from "@/utils/chat" import { defer } from "@/utils/deferred-middleware" @@ -145,7 +145,7 @@ export class AutoModerationStack implements MiddlewareObj await msg.delete() } else { // no flagged category is above the threshold, still log it for manual review - await tgLogger.moderationAction({ + await modules.get("tgLogger").moderationAction({ action: "SILENT", from: ctx.me, chat: ctx.chat, @@ -226,7 +226,7 @@ export class AutoModerationStack implements MiddlewareObj ) ) - await tgLogger.moderationAction({ + await modules.get("tgLogger").moderationAction({ action: "MULTI_CHAT_SPAM", from: ctx.me, chat: ctx.chat, diff --git a/src/middlewares/bot-membership-handler.ts b/src/middlewares/bot-membership-handler.ts index a148183..8230b9f 100644 --- a/src/middlewares/bot-membership-handler.ts +++ b/src/middlewares/bot-membership-handler.ts @@ -1,8 +1,8 @@ import { Composer, type Filter, InlineKeyboard, type MiddlewareObj } from "grammy" import { api } from "@/backend" -import { tgLogger } from "@/bot" import { GroupManagement } from "@/lib/group-management" import { logger } from "@/logger" +import { modules } from "@/modules" import type { Context } from "@/utils/types" type ChatType = "group" | "supergroup" | "private" | "channel" @@ -41,8 +41,8 @@ export class BotMembershipHandler implements MiddlewareObj if (this.isJoin(ctx)) { // joined event - await this.checkAdderPermission(ctx) - return next() + // go next, if adder has no permission + if (!(await this.checkAdderPermission(ctx))) return next() } if (newStatus === "administrator") { @@ -73,10 +73,12 @@ export class BotMembershipHandler implements MiddlewareObj if (!allowed) { const left = await ctx.leaveChat().catch(() => false) if (left) { - await tgLogger.groupManagement({ type: "LEAVE", chat: ctx.myChatMember.chat, addedBy: ctx.myChatMember.from }) + await modules + .get("tgLogger") + .groupManagement({ type: "LEAVE", chat: ctx.myChatMember.chat, addedBy: ctx.myChatMember.from }) logger.info({ chat: ctx.myChatMember.chat, from: ctx.myChatMember.from }, `[BCE] Left unauthorized group`) } else { - await tgLogger.groupManagement({ + await modules.get("tgLogger").groupManagement({ type: "LEAVE_FAIL", chat: ctx.myChatMember.chat, addedBy: ctx.myChatMember.from, @@ -95,7 +97,7 @@ export class BotMembershipHandler implements MiddlewareObj const res = await GroupManagement.delete(chat) await res.match( async () => { - await tgLogger.groupManagement({ type: "DELETE", chat }) + await modules.get("tgLogger").groupManagement({ type: "DELETE", chat }) logger.info({ chat }, `[BCE] Deleted a group`) }, (e) => { @@ -107,16 +109,26 @@ export class BotMembershipHandler implements MiddlewareObj private async createGroup(ctx: MemberContext): Promise { const chat = await ctx.getChat() const res = await GroupManagement.create(chat) + const logChat = { + id: chat.id, + title: chat.title, + is_forum: chat.is_forum, + type: chat.type, + invite_link: chat.invite_link, + } + await res.match( async (g) => { - await tgLogger.groupManagement({ type: "CREATE", chat, inviteLink: g.link, addedBy: ctx.from }) - logger.info({ chat }, `[BCE] Created a new group`) + await modules.get("tgLogger").groupManagement({ type: "CREATE", chat, inviteLink: g.link, addedBy: ctx.from }) + logger.info({ chat: logChat }, `[BCE] Created a new group`) }, async (e) => { const ik = new InlineKeyboard() if (chat.invite_link) ik.url("Join Group", chat.invite_link) - await tgLogger.groupManagement({ type: "CREATE_FAIL", chat, inviteLink: chat.invite_link, reason: e }) - logger.error({ chat }, `[BCE] Cannot create group into DB. Reason: ${e}`) + await modules + .get("tgLogger") + .groupManagement({ type: "CREATE_FAIL", chat, inviteLink: chat.invite_link, reason: e }) + logger.error({ chat: logChat }, `[BCE] Cannot create group into DB. Reason: ${e}`) } ) } diff --git a/src/middlewares/ui-actions-logger.ts b/src/middlewares/ui-actions-logger.ts index 6f171c6..c973c1f 100644 --- a/src/middlewares/ui-actions-logger.ts +++ b/src/middlewares/ui-actions-logger.ts @@ -1,5 +1,5 @@ import { Composer, type MiddlewareObj } from "grammy" -import { tgLogger } from "@/bot" +import { modules } from "@/modules" import { duration } from "@/utils/duration" import type { Context } from "@/utils/types" @@ -30,7 +30,7 @@ export class UIActionsLogger implements MiddlewareObj { if (prev === "member" && curr === "left") return // skip left event if (prev === "kicked" && curr === "left") { - await tgLogger.moderationAction({ + await modules.get("tgLogger").moderationAction({ action: "UNBAN", from: admin, target, @@ -40,7 +40,7 @@ export class UIActionsLogger implements MiddlewareObj { } if (prev === "member" && curr === "kicked") { - await tgLogger.moderationAction({ + await modules.get("tgLogger").moderationAction({ action: "BAN", from: admin, target, @@ -50,7 +50,7 @@ export class UIActionsLogger implements MiddlewareObj { } if (prev === "member" && curr === "restricted" && !new_chat_member.can_send_messages) { - await tgLogger.moderationAction({ + await modules.get("tgLogger").moderationAction({ action: "MUTE", duration: duration.fromUntilDate(new_chat_member.until_date), from: admin, @@ -63,7 +63,7 @@ export class UIActionsLogger implements MiddlewareObj { if (prev === "restricted" && curr === "restricted") { if (old_chat_member.can_send_messages && !new_chat_member.can_send_messages) { // mute - await tgLogger.moderationAction({ + await modules.get("tgLogger").moderationAction({ action: "MUTE", duration: duration.fromUntilDate(new_chat_member.until_date), from: admin, @@ -71,7 +71,7 @@ export class UIActionsLogger implements MiddlewareObj { chat, }) } else if (!old_chat_member.can_send_messages && new_chat_member.can_send_messages) { - await tgLogger.moderationAction({ + await modules.get("tgLogger").moderationAction({ action: "UNMUTE", from: admin, target, @@ -82,7 +82,7 @@ export class UIActionsLogger implements MiddlewareObj { } if (prev === "restricted" && curr === "member") { - await tgLogger.moderationAction({ + await modules.get("tgLogger").moderationAction({ action: "UNMUTE", from: admin, target, diff --git a/src/modules/index.ts b/src/modules/index.ts new file mode 100644 index 0000000..ab24a97 --- /dev/null +++ b/src/modules/index.ts @@ -0,0 +1,27 @@ +import { ModuleCoordinator } from "@/lib/modules" +import type { ModuleShared } from "@/utils/types" +import { Awaiter } from "@/utils/wait" +import { WebSocketClient } from "@/websocket" +import { BanAllQueue } from "./moderation/ban-all" +import { TgLogger } from "./tg-logger" + +export const sharedDataInit = new Awaiter() + +export const modules = new ModuleCoordinator( + { + tgLogger: new TgLogger(-1002685849173, { + banAll: 13, + exceptions: 3, + autoModeration: 7, + adminActions: 5, + actionRequired: 10, + groupManagement: 33, + deletedMessages: 130, + }), + webSocket: new WebSocketClient(), + banAll: new BanAllQueue(), + }, + async () => { + return await sharedDataInit + } +) diff --git a/src/modules/moderation/ban-all.ts b/src/modules/moderation/ban-all.ts new file mode 100644 index 0000000..171f80f --- /dev/null +++ b/src/modules/moderation/ban-all.ts @@ -0,0 +1,261 @@ +import { type ConnectionOptions, type FlowJob, FlowProducer, type Job, Queue, Worker } from "bullmq" +import { api } from "@/backend" +import { env } from "@/env" +import { Module } from "@/lib/modules" +import { logger } from "@/logger" +import { throttle } from "@/utils/throttle" +import type { ModuleShared } from "@/utils/types" +import { modules } from ".." +import { type BanAll, type BanAllState, isBanAllState } from "../tg-logger/ban-all" + +/** + * Utility type that get the Worker type for a Job + */ +type WorkerFor = J extends Job ? Worker : never + +/** + * Utility type that get the Job type for a FlowJob + */ +type JobForFlow = J extends FlowJob + ? J extends { name: infer N extends string; data: infer D } + ? Job + : never + : never + +/** Configuration for the BanAll queue system */ +const CONFIG = { + ORCHESTRATOR_QUEUE: "[ban_all.orchestrator]", + EXECUTOR_QUEUE: "[ban_all.exec]", + UPDATE_MESSAGE_THROTTLE_MS: 5000, +} + +/** Possible commands for ban jobs */ +type BanJobCommand = "ban" | "unban" +/** Possible commands for ban all jobs, each child will have the equivalent command */ +type BanAllCommand = `${BanJobCommand}_all` + +/** Data for a single ban job */ +type BanJobData = { + chatId: number + targetId: number +} + +/** Flow description for a single ban job */ +interface BanFlow extends FlowJob { + name: BanJobCommand + queueName: typeof CONFIG.EXECUTOR_QUEUE + data: BanJobData + children?: undefined +} +/** Flow description for a ban all job */ +interface BanAllFlow extends FlowJob { + name: BanAllCommand + queueName: typeof CONFIG.ORCHESTRATOR_QUEUE + data: { + banAll: BanAll // entire BanAll data, to re-render the message with progress + messageId: number // message ID to update the progress message + } + children: BanFlow[] +} + +/** Job type for a single ban job */ +type BanJob = JobForFlow +/** Job type for a ban all job, only executed when all child jobs are completed (every ban executed) */ +type BanAllJob = JobForFlow + +// redis connection options +const connection: ConnectionOptions = { + host: env.REDIS_HOST, + port: env.REDIS_PORT, + username: env.REDIS_USERNAME, + password: env.REDIS_PASSWORD, +} + +/** + * # BanAll Queue + * + * ### A queue system to handle `/ban_all` commands. + * + * Each command is a job in the orchestrator queue, which spawns a child job for + * each PoliNetwork group in the executor queue. + * + * - [X] **Completely persistent**: all jobs are stored in Redis + * - [X] **Resilient to crashes**: if the bot crashes or is restarted, + * both jobs and side-effects will continue from where they left off + * - [X] **Atomicity**: `ban_all`s are guaranteed to only be marked as completed + * when all bans are executed + */ +export class BanAllQueue extends Module { + /** + * Worker that executes the actual ban/unban commands + * + * Has no context about the ban all, just executes the commands it receives + */ + private executor: WorkerFor = new Worker( + CONFIG.EXECUTOR_QUEUE, + async (job) => { + switch (job.name) { + case "ban": { + const success = await this.shared.api.banChatMember(job.data.chatId, job.data.targetId, { + revoke_messages: true, + }) + logger.debug({ chatId: job.data.chatId, targetId: job.data.targetId, success }, "[BanAllQueue] ban result") + if (!success) { + throw new Error("Failed to ban user") + } + return + } + case "unban": { + const success = await this.shared.api.unbanChatMember(job.data.chatId, job.data.targetId) + if (!success) { + throw new Error("Failed to unban user") + } + logger.debug({ chatId: job.data.chatId, targetId: job.data.targetId, success }, "[BanAllQueue] unban result") + return + } + default: + throw new Error("Unknown job command") + } + }, + { connection, concurrency: 3 } + ) + + /** + * Worker that orchestrates the ban all jobs + * + * Listens for completed child jobs and updates the parent job progress + * When all child jobs are completed, the parent job is marked as completed + */ + private orchestrator: WorkerFor = new Worker( + CONFIG.ORCHESTRATOR_QUEUE, + async (job) => { + const { failed, ignored, processed } = await job.getDependenciesCount() + logger.info( + `[BanAllQueue] Finished executing ${job.name} job for target ${job.data.banAll.target.id} in ${processed} chats (ignored: ${ignored}, failed: ${failed})` + ) + }, + { connection } + ) + + /** + * Queue used to add new ban jobs, each ban_all command will dispatch a batch in this queue + */ + private execQueue = new Queue(CONFIG.EXECUTOR_QUEUE, { + connection, + defaultJobOptions: { + attempts: 3, + backoff: { + type: "exponential", + delay: 1000, // start with 1 second + }, + removeOnComplete: { + age: 60 * 60, // keep for 1 hour + count: 1000, // keep only the last 1000 + }, + removeOnFail: { + age: 24 * 60 * 60, // keep for 24 hours + count: 1000, // keep only the last 1000 + }, + }, + }) + + /** queue for the orchestrator, each ban_all command is a job in this queue */ + private orchestrateQueue = new Queue(CONFIG.ORCHESTRATOR_QUEUE, { connection }) + + /** Flow producer to create parent/child job batch in a single ban_all command */ + private flowProducer = new FlowProducer({ connection }) + + public async initiateBanAll(banAll: BanAll, messageId: number) { + if (banAll.outcome !== "approved") { + throw new Error("Cannot initiate ban all for a non-approved BanAll") + } + + const allGroups = await api.tg.groups.getAll.query() + const chats = allGroups.map((g) => g.telegramId) + const banType = banAll.type === "BAN" ? "ban" : "unban" + + const job = await this.flowProducer.add({ + name: `${banType}_all`, + queueName: CONFIG.ORCHESTRATOR_QUEUE, + data: { banAll, messageId }, + children: chats.map((chat) => ({ + name: banType, + queueName: CONFIG.EXECUTOR_QUEUE, + data: { + chatId: chat, + targetId: banAll.target.id, + }, + })), + } satisfies BanAllFlow) + return job + } + + /** + * Register event listeners when the module is loaded + */ + override async start() { + // set the listener to update the parent job progress + this.executor.on("completed", async (job) => { + // this listener recomputes the progress for the parent job every time a child job is completed + const parentID = job.parent?.id + if (!parentID) return + const parent = await this.orchestrateQueue.getJob(parentID) + if (!parent) return + const rawNumbers = await parent.getDependenciesCount({ + processed: true, + failed: true, + ignored: true, + unprocessed: true, + }) + // get child counts + const { failed, ignored, processed, unprocessed } = { + failed: 0, + ignored: 0, + processed: 0, + unprocessed: 0, + ...rawNumbers, + } + + const successCount = processed - (failed + ignored) + const total = processed + unprocessed + await parent.updateProgress({ + jobCount: total, + successCount, + failedCount: failed, + } satisfies BanAllState) + }) + + // throttled call to update the message, to avoid spamming Telegram API + const updateMessage = throttle((banAll: BanAll, messageId: number) => { + logger.debug("[BanAllQueue] Updating ban all progress message") + void modules + .get("tgLogger") + .banAllProgress(banAll, messageId) + .catch(() => { + logger.warn("[BanAllQueue] Failed to update ban all progress message") + }) + }, CONFIG.UPDATE_MESSAGE_THROTTLE_MS) + + this.orchestrateQueue.on("progress", async (job, progress) => { + // on progress of a ban_all job (in the orchestrator queue), + // update the message with the new progress (throttled) + if (!isBanAllState(progress)) return + const banAll = { ...job.data.banAll, state: progress } + updateMessage(banAll, job.data.messageId) + await job.updateData({ ...job.data, banAll }) // update data just to be sure + }) + } + + /** + * Gracefully close all the queues and workers + */ + override async stop() { + await Promise.all([ + this.executor.close(), + this.orchestrator.close(), + this.execQueue.close(), + this.orchestrateQueue.close(), + this.flowProducer.close(), + ]) + } +} diff --git a/src/lib/moderation/ban.ts b/src/modules/moderation/ban.ts similarity index 85% rename from src/lib/moderation/ban.ts rename to src/modules/moderation/ban.ts index b811344..e5e84b7 100644 --- a/src/lib/moderation/ban.ts +++ b/src/modules/moderation/ban.ts @@ -2,7 +2,7 @@ import type { Message, User } from "grammy/types" import { err, ok, type Result } from "neverthrow" import type { z } from "zod" import { api } from "@/backend" -import { tgLogger } from "@/bot" +import { modules } from "@/modules" import type { duration } from "@/utils/duration" import { fmt } from "@/utils/format" import type { ContextWith } from "@/utils/types" @@ -33,7 +33,11 @@ export async function ban({ ctx, target, from, reason, duration, message }: BanP reason, type: "ban", }) - return ok(await tgLogger.moderationAction({ action: "BAN", from, message, target, duration, reason, chat: ctx.chat })) + return ok( + await modules + .get("tgLogger") + .moderationAction({ action: "BAN", from, message, target, duration, reason, chat: ctx.chat }) + ) } interface UnbanProps { @@ -51,5 +55,7 @@ export async function unban({ ctx, targetId, from }: UnbanProps): Promise b`@${from.username} this user is not banned in this chat`)) await ctx.unbanChatMember(target.user.id) - return ok(await tgLogger.moderationAction({ action: "UNBAN", from: from, target: target.user, chat: ctx.chat })) + return ok( + await modules.get("tgLogger").moderationAction({ action: "UNBAN", from: from, target: target.user, chat: ctx.chat }) + ) } diff --git a/src/lib/moderation/index.ts b/src/modules/moderation/index.ts similarity index 100% rename from src/lib/moderation/index.ts rename to src/modules/moderation/index.ts diff --git a/src/lib/moderation/kick.ts b/src/modules/moderation/kick.ts similarity index 88% rename from src/lib/moderation/kick.ts rename to src/modules/moderation/kick.ts index 988f303..39822d5 100644 --- a/src/lib/moderation/kick.ts +++ b/src/modules/moderation/kick.ts @@ -1,7 +1,7 @@ import type { Message, User } from "grammy/types" import { err, ok, type Result } from "neverthrow" import { api } from "@/backend" -import { tgLogger } from "@/bot" +import { modules } from "@/modules" import { duration } from "@/utils/duration" import { fmt } from "@/utils/format" import type { ContextWith } from "@/utils/types" @@ -32,5 +32,7 @@ export async function kick({ ctx, target, from, reason, message }: KickProps): P reason, type: "kick", }) - return ok(await tgLogger.moderationAction({ action: "KICK", from, target, reason, message, chat: ctx.chat })) + return ok( + await modules.get("tgLogger").moderationAction({ action: "KICK", from, target, reason, message, chat: ctx.chat }) + ) } diff --git a/src/lib/moderation/mute.ts b/src/modules/moderation/mute.ts similarity index 91% rename from src/lib/moderation/mute.ts rename to src/modules/moderation/mute.ts index 6e16d42..f2ecd21 100644 --- a/src/lib/moderation/mute.ts +++ b/src/modules/moderation/mute.ts @@ -2,7 +2,7 @@ import type { Message, User } from "grammy/types" import { err, ok, type Result } from "neverthrow" import type { z } from "zod" import { api } from "@/backend" -import { tgLogger } from "@/bot" +import { modules } from "@/modules" import { RestrictPermissions } from "@/utils/chat" import type { duration } from "@/utils/duration" import { fmt, fmtUser } from "@/utils/format" @@ -47,7 +47,7 @@ export async function mute({ type: "mute", }) - const res = await tgLogger.moderationAction({ + const res = await modules.get("tgLogger").moderationAction({ action: "MUTE", chat: ctx.chat, from, @@ -77,5 +77,7 @@ export async function unmute({ ctx, targetId, from }: UnmuteProps): Promise b`@${from.username} this user is not muted`)) await ctx.restrictChatMember(target.user.id, RestrictPermissions.unmute) - return ok(await tgLogger.moderationAction({ action: "UNMUTE", from, target: target.user, chat: ctx.chat })) + return ok( + await modules.get("tgLogger").moderationAction({ action: "UNMUTE", from, target: target.user, chat: ctx.chat }) + ) } diff --git a/src/modules/tg-logger/ban-all.ts b/src/modules/tg-logger/ban-all.ts new file mode 100644 index 0000000..1df190b --- /dev/null +++ b/src/modules/tg-logger/ban-all.ts @@ -0,0 +1,181 @@ +import type { Context } from "grammy" +import type { User } from "grammy/types" +import { type CallbackCtx, MenuGenerator } from "@/lib/menu" +import { logger } from "@/logger" +import { fmt, fmtUser } from "@/utils/format" +import { unicodeProgressBar } from "@/utils/progress" +import { calculateOutcome, type Outcome, type Vote, type Voter } from "@/utils/vote" +import { modules } from ".." + +export type BanAllState = { + jobCount: number + successCount: number + failedCount: number +} + +const spaces = (n: number) => " ".repeat(n) + +export function isBanAllState(obj: unknown): obj is BanAllState { + return !!( + obj && + typeof obj === "object" && + "jobCount" in obj && + "successCount" in obj && + "failedCount" in obj && + typeof obj.jobCount === "number" && + typeof obj.successCount === "number" && + typeof obj.failedCount === "number" + ) +} + +export type BanAll = { + type: "BAN" | "UNBAN" + target: User + reporter: User + reason?: string + outcome: Outcome + voters: Voter[] + state: BanAllState +} + +const VOTE_EMOJI: Record = { + inFavor: "βœ…", + against: "❌", + abstained: "πŸ«₯", +} + +const OUTCOME_STR: Record = { + waiting: "⏳ Waiting for votes", + approved: "βœ… APPROVED", + denied: "❌ DENIED", +} + +export const getProgressText = (state: BanAll["state"]): string => { + if (state.jobCount === 0) return fmt(({ i }) => i`\nFetching groups...`) + + const progress = (state.successCount + state.failedCount) / state.jobCount + const percent = (progress * 100).toFixed(1) + const barLength = 18 + + const stateEmoji = `🟒 ${state.successCount}${spaces(10)}πŸ”΄ ${state.failedCount}${spaces(10)}⏸️ ${state.jobCount - state.successCount - state.failedCount}` + return fmt( + ({ n, b, i }) => [ + n`\n${b`Progress`} ${i`(${state.jobCount} groups)`}`, + n`${unicodeProgressBar(progress, barLength)} ${percent}% `, + n`${stateEmoji}`, + ], + { sep: "\n" } + ) +} + +/** + * Generate the message text of the BanAll case, based on current voting situation. + * + * @param data - The BanAll data including message and reporter. + * @returns A formatted string of the message text. + */ +export const getBanAllText = (data: BanAll) => + fmt( + ({ n, b, skip, strikethrough, i }) => [ + data.type === "BAN" ? b`🚨 BAN ALL 🚨` : b`πŸ•Š UN-BAN ALL πŸ•Š`, + "", + n`${b`🎯 Target:`} ${fmtUser(data.target)} `, + n`${b`πŸ“£ Reporter:`} ${fmtUser(data.reporter)} `, + data.type === "BAN" ? n`${b`πŸ“‹ Reason:`} ${data.reason ? data.reason : i`N/A`}` : undefined, + "", + b`${OUTCOME_STR[data.outcome]} `, + data.outcome === "approved" ? skip`${getProgressText(data.state)}` : undefined, + "", + b`Voters`, + ...data.voters.map((v) => + data.outcome !== "waiting" && !v.vote + ? strikethrough`βž– ${fmtUser(v.user)} ${v.isPresident ? b`PRES` : ""} ` + : n`${v.vote ? VOTE_EMOJI[v.vote] : "⏳"} ${fmtUser(v.user)} ${v.isPresident ? b`PRES` : ""} ` + ), + ], + { sep: "\n" } + ) + +async function vote( + ctx: CallbackCtx, + data: BanAll, + vote: Vote +): Promise<{ feedback?: string; newData?: BanAll }> { + const voterId = ctx.callbackQuery.from.id + const voter = data.voters.find((v) => v.user.id === voterId) + if (!voter) + return { + feedback: "❌ You cannot vote", + } + if (voter.vote !== undefined) + return { + feedback: "⚠️ You cannot change your vote!", + } + + voter.vote = vote + const outcome = calculateOutcome(data.voters) + logger.debug({ outcome: data.outcome, voters: data.voters }, "[VOTE] new vote, calculating...") + if (outcome === null) { + logger.fatal({ banAll: data }, "ERROR WHILE VOTING FOR BAN_ALL, Outcome is null") + return { + feedback: "There was an error, check logs", + } + } + data.outcome = outcome + + if (outcome === "approved") { + try { + if (ctx.msgId) await modules.get("banAll").initiateBanAll(data, ctx.msgId) + else { + logger.error( + { callbackQuery: ctx.callbackQuery }, + "Message ID is undefined, cannot initiate ban all. How did this happen?" + ) + } + } catch (error) { + await modules + .get("tgLogger") + .exception({ error, type: "UNKNOWN" }, "There was an error while initializing BanAll queue, check logs") + } + } + + // remove buttons if there is an outcome (not waiting) + const reply_markup = outcome === "waiting" ? ctx.msg?.reply_markup : undefined + + await ctx.editMessageText(getBanAllText(data), { reply_markup }).catch(() => { + // throws if message is not modified - we don't care + }) + + return { + newData: data, + feedback: "βœ… Thanks for voting!", + } +} + +/** + * Interactive menu for handling voting. + * + * @param data - {@link BanAll} initial BanAll + */ +export const banAllMenu = MenuGenerator.getInstance().create("ban-all-voting", [ + [ + { + text: VOTE_EMOJI.inFavor, + cb: async ({ ctx, data }) => { + return await vote(ctx, data, "inFavor") + }, + }, + { + text: VOTE_EMOJI.abstained, + cb: async ({ ctx, data }) => { + return await vote(ctx, data, "abstained") + }, + }, + { + text: VOTE_EMOJI.against, + cb: async ({ ctx, data }) => { + return await vote(ctx, data, "against") + }, + }, + ], +]) diff --git a/src/lib/tg-logger/index.ts b/src/modules/tg-logger/index.ts similarity index 77% rename from src/lib/tg-logger/index.ts rename to src/modules/tg-logger/index.ts index d05cc50..855cf2e 100644 --- a/src/lib/tg-logger/index.ts +++ b/src/modules/tg-logger/index.ts @@ -1,8 +1,12 @@ -import { type Bot, type Context, GrammyError, InlineKeyboard } from "grammy" +import { GrammyError, InlineKeyboard } from "grammy" import type { Message, User } from "grammy/types" +import { api } from "@/backend" +import { Module } from "@/lib/modules" import { logger } from "@/logger" import { groupMessagesByChat, stripChatId } from "@/utils/chat" import { fmt, fmtChat, fmtUser } from "@/utils/format" +import type { ModuleShared } from "@/utils/types" +import { type BanAll, banAllMenu, getBanAllText } from "./ban-all" import { getReportText, type Report, reportMenu } from "./report" import type * as Types from "./types" @@ -16,19 +20,20 @@ type Topics = { groupManagement: number } -export class TgLogger { +export class TgLogger extends Module { constructor( - private bot: Bot, private groupId: number, private topics: Topics - ) {} + ) { + super() + } private async log( topicId: number, fmtString: string, - opts?: Parameters[2] + opts?: Parameters[2] ): Promise { - return await this.bot.api + return await this.shared.api .sendMessage(this.groupId, fmtString, { message_thread_id: topicId, disable_notification: true, @@ -45,7 +50,7 @@ export class TgLogger { } private async forward(topicId: number, chatId: number, messageIds: number[]): Promise { - await this.bot.api + await this.shared.api .forwardMessages(this.groupId, chatId, messageIds, { message_thread_id: topicId, disable_notification: true, @@ -71,7 +76,7 @@ export class TgLogger { public async report(message: Message, reporter: User): Promise { if (message.from === undefined) return false // should be impossible - const { invite_link } = await this.bot.api.getChat(message.chat.id) + const { invite_link } = await this.shared.api.getChat(message.chat.id) const report: Report = { message, reporter } as Report const reportText = getReportText(report, invite_link) @@ -90,7 +95,7 @@ export class TgLogger { async delete( messages: Message[], reason: string, - deleter: User = this.bot.botInfo + deleter: User = this.shared.botInfo ): Promise { if (!messages.length) return null const sendersMap = new Map() @@ -112,7 +117,7 @@ export class TgLogger { ? n`${b`Senders:`} \n - ${senders.map(fmtUser).join("\n - ")}` : n`${b`Sender:`} ${fmtUser(senders[0])}`, - deleter.id === this.bot.botInfo.id ? i`Automatic deletion by BOT` : n`${b`Deleter:`} ${fmtUser(deleter)}`, + deleter.id === this.shared.botInfo.id ? i`Automatic deletion by BOT` : n`${b`Deleter:`} ${fmtUser(deleter)}`, n`${b`Count:`} ${code`${messages.length}`}`, reason ? n`${b`Reason:`} ${reason}` : undefined, @@ -124,7 +129,7 @@ export class TgLogger { for (const [chatId, mIds] of groupMessagesByChat(messages)) { await this.forward(this.topics.deletedMessages, chatId, mIds) - await this.bot.api.deleteMessages(chatId, mIds) + await this.shared.api.deleteMessages(chatId, mIds) } return { @@ -133,42 +138,91 @@ export class TgLogger { } } - public async banAll(props: Types.BanAllLog): Promise { - let msg: string - if (props.type === "BAN") { - msg = fmt( - ({ b, n }) => [ - b`🚫 Ban ALL`, - n`${b`Target:`} ${fmtUser(props.target)}`, - n`${b`Admin:`} ${fmtUser(props.from)}`, - props.reason ? n`${b`Reason:`} ${props.reason}` : undefined, - ], - { sep: "\n" } - ) - } else { - msg = fmt( - ({ b, n }) => [ - b`βœ… Unban ALL`, - n`${b`Target:`} ${fmtUser(props.target)}`, - n`${b`Admin:`} ${fmtUser(props.from)}`, - ], + public async banAll(target: User, reporter: User, type: "BAN" | "UNBAN", reason?: string): Promise { + const direttivo = await api.tg.permissions.getDirettivo.query() + + switch (direttivo.error) { + case "EMPTY": + return fmt(({ n }) => n`Error: Direttivo is not set`) + + case "NOT_ENOUGH_MEMBERS": + return fmt(({ n }) => n`Error: Direttivo has not enough members!`) + + case "TOO_MANY_MEMBERS": + return fmt(({ n }) => n`Error: Direttivo has too many members!`) + + case "INTERNAL_SERVER_ERROR": + return fmt(({ n }) => n`Error: there was an internal error while fetching members of Direttivo.`) + + case null: + break + } + + const voters = direttivo.members.map((m) => ({ + user: m.user + ? { + id: m.userId, + first_name: m.user.firstName, + last_name: m.user.lastName, + username: m.user.username, + is_bot: m.user.isBot, + language_code: m.user.langCode, + } + : { id: m.userId }, + isPresident: m.isPresident, + vote: undefined, + })) + + if (!voters.some((v) => v.isPresident)) + return fmt( + ({ n, b }) => [b`Error: No member is President!`, n`${b`Members:`} ${voters.map((v) => v.user.id).join(" ")}`], { sep: "\n", } ) + + const banAll: BanAll = { + type, + outcome: "waiting", + reporter: reporter, + reason, + target, + voters, + state: { + successCount: 0, + failedCount: 0, + jobCount: 0, + }, } - await this.log(this.topics.banAll, msg) - return msg + const menu = await banAllMenu(banAll) + await this.log(this.topics.banAll, "β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”") + const msg = await this.log(this.topics.banAll, getBanAllText(banAll), { reply_markup: menu }) + return fmt( + ({ n, b, link }) => [ + b`${type} All requested!`, + msg + ? n`Check ${link("here", `https://t.me/c/${this.groupId}/${this.topics.banAll}/${msg.message_id}`)}` + : undefined, + ], + { sep: "\n" } + ) + } + + public async banAllProgress(banAll: BanAll, messageId: number): Promise { + await this.shared.api.editMessageText(this.groupId, messageId, getBanAllText(banAll), { + reply_markup: undefined, + link_preview_options: { is_disabled: true }, + }) } public async moderationAction(props: Types.ModerationAction): Promise { - const isAutoModeration = props.from.id === this.bot.botInfo.id + const isAutoModeration = props.from.id === this.shared.botInfo.id let title: string const others: string[] = [] let deleteRes: Types.DeleteResult | null = null - const { invite_link } = await this.bot.api.getChat(props.chat.id) + const { invite_link } = await this.shared.api.getChat(props.chat.id) const delReason = `${props.action}${"reason" in props && props.reason ? ` -- ${props.reason}` : ""}` switch (props.action) { @@ -193,11 +247,11 @@ export class TgLogger { const groupByChat = groupMessagesByChat(props.messages) others.push(fmt(({ b }) => b`\nChats involved:`)) for (const [chatId, mIds] of groupByChat) { - const chat = await this.bot.api.getChat(chatId) + const chat = await this.shared.api.getChat(chatId) others.push(fmt(({ n, i }) => n`${fmtChat(chat, chat.invite_link)} \n${i`Messages: ${mIds.length}`}`)) } - deleteRes = await this.delete(props.messages, delReason, this.bot.botInfo) + deleteRes = await this.delete(props.messages, delReason, this.shared.botInfo) break } diff --git a/src/lib/tg-logger/report.ts b/src/modules/tg-logger/report.ts similarity index 88% rename from src/lib/tg-logger/report.ts rename to src/modules/tg-logger/report.ts index 9d888fe..5142ac8 100644 --- a/src/lib/tg-logger/report.ts +++ b/src/modules/tg-logger/report.ts @@ -1,8 +1,9 @@ import type { Context } from "grammy" import type { Message, User } from "grammy/types" +import { type CallbackCtx, MenuGenerator } from "@/lib/menu" import { duration } from "@/utils/duration" import { fmt, fmtChat, fmtDate, fmtUser } from "@/utils/format" -import { type CallbackCtx, MenuGenerator } from "../menu" +import { modules } from ".." export type Report = { message: Message & { from: User } @@ -74,6 +75,7 @@ export const reportMenu = MenuGenerator.getInstance().create("r text: "βœ… Ignore", cb: async ({ data, ctx }) => { await editReportMessage(data, ctx, "βœ… Ignore") + return null }, }, { @@ -81,6 +83,7 @@ export const reportMenu = MenuGenerator.getInstance().create("r cb: async ({ data, ctx }) => { await ctx.api.deleteMessage(data.message.chat.id, data.message.message_id) await editReportMessage(data, ctx, "πŸ—‘ Delete") + return null }, }, ], @@ -94,6 +97,7 @@ export const reportMenu = MenuGenerator.getInstance().create("r until_date: Math.floor(Date.now() / 1000) + duration.values.m, }) await editReportMessage(data, ctx, "πŸ‘’ Kick") + return null }, }, { @@ -102,6 +106,7 @@ export const reportMenu = MenuGenerator.getInstance().create("r await ctx.api.deleteMessage(data.message.chat.id, data.message.message_id) await ctx.api.banChatMember(data.message.chat.id, data.message.from.id) await editReportMessage(data, ctx, "🚫 Ban") + return null }, }, ], @@ -109,9 +114,16 @@ export const reportMenu = MenuGenerator.getInstance().create("r { text: "🚨 Start BAN ALL 🚨", cb: async ({ data, ctx }) => { - // TODO: connect ban all when implemented - await editReportMessage(data, ctx, "🚨 Start BAN ALL (not implemented yet)") - return "❌ Not implemented yet" + modules + .get("tgLogger") + .banAll( + data.message.from, + ctx.from, + "BAN", + `Started after report by ${data.reporter.username ?? data.reporter.id}` + ) + await editReportMessage(data, ctx, "🚨 Start BAN ALL") + return null }, }, ], diff --git a/src/lib/tg-logger/types.ts b/src/modules/tg-logger/types.ts similarity index 100% rename from src/lib/tg-logger/types.ts rename to src/modules/tg-logger/types.ts diff --git a/src/utils/format.ts b/src/utils/format.ts index 5cee82e..fd0ce17 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -138,9 +138,9 @@ export function fmt(cb: (formatters: Formatters) => string | (string | undefined ) } -export function fmtUser(user: User): string { +export function fmtUser(user: Partial> & { id: number }): string { const fullname = user.last_name ? `${user.first_name} ${user.last_name}` : user.first_name - return formatters.n`${formatters.link(fullname, `tg://user?id=${user.id}`)} [${formatters.code`${user.id}`}]` + return formatters.n`${formatters.link(fullname ?? "[no-name]", `tg://user?id=${user.id}`)} [${formatters.code`${user.id}`}]` } export function fmtChat(chat: Chat, inviteLink?: string): string { diff --git a/src/utils/progress.ts b/src/utils/progress.ts new file mode 100644 index 0000000..77da2bf --- /dev/null +++ b/src/utils/progress.ts @@ -0,0 +1,23 @@ +/** + * Clamps a number between a minimum and maximum value. + * @param num The number to clamp. + * @param min The minimum value. + * @param max The maximum value. + * @returns The clamped value. + */ +export function clamp(num: number, min: number, max: number) { + return Math.min(Math.max(num, min), max) +} + +/** + * Generates a unicode progress bar string. + * @param progress A number between 0 and 1 representing the progress. + * @param size The length of the progress bar (default is 10). + * @returns A string representing the progress bar. + */ +export function unicodeProgressBar(progress: number, size = 10) { + const clamped = clamp(progress, 0, 1) + const filledBars = Math.round(clamped * size) + const emptyBars = size - filledBars + return `ο½’${"β–°".repeat(filledBars)}${"β–±".repeat(emptyBars)}ο½£` +} diff --git a/src/utils/throttle.ts b/src/utils/throttle.ts new file mode 100644 index 0000000..99a45a5 --- /dev/null +++ b/src/utils/throttle.ts @@ -0,0 +1,35 @@ +/** + * Throttles a function to limit the rate at which it can be called. + * + * The function will get called at most once every `limit` milliseconds. + * If the function is called again before the `limit` has passed, + * the call will be ignored, but a new call will be scheduled at the end of the + * limit period, with the last arguments provided. + * + * @param func The function to throttle + * @param limit The time limit in milliseconds + * @returns A throttled version of the function + */ +export function throttle(func: (...args: A) => void, limit: number): (...args: A) => void { + let timeout: NodeJS.Timeout | null = null + let lastArgs: A + let again: boolean = false + + return (...args: A): void => { + lastArgs = args + if (timeout === null) { + // first call + const handler = () => { + if (again) { + // if called again during the timeout, schedule another call + timeout = setTimeout(handler, limit) + func(...lastArgs) + } else timeout = null // if not called again, clear the timeout + again = false // reset the again flag + } + + timeout = setTimeout(handler, limit) + func(...args) + } else again = true + } +} diff --git a/src/utils/types.ts b/src/utils/types.ts index ea77e0a..0cc4327 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -1,4 +1,5 @@ -import type { Context as TContext } from "grammy" +import type { Api, Context as TContext } from "grammy" +import type { UserFromGetMe } from "grammy/types" import type { ApiInput } from "@/backend" import type { ManagedCommandsFlavor } from "@/lib/managed-commands" @@ -16,3 +17,8 @@ export type MaybePromise = T | Promise export type Context = ManagedCommandsFlavor export type Role = ApiInput["tg"]["permissions"]["addRole"]["role"] + +export type ModuleShared = { + api: Api + botInfo: UserFromGetMe +} diff --git a/src/utils/vote.ts b/src/utils/vote.ts new file mode 100644 index 0000000..4e20c46 --- /dev/null +++ b/src/utils/vote.ts @@ -0,0 +1,99 @@ +import type { User } from "grammy/types" +import { logger } from "@/logger" + +export type Vote = "inFavor" | "against" | "abstained" +export type Outcome = "approved" | "denied" | "waiting" +export type Voter = { + user: Partial> & { id: number } + isPresident: boolean + vote?: Vote +} + +/** + * WARNING: This function is specific to Direttivo voting, do NOT use it for generic voting. + * + * This function calculates the voting outcome based on the votes collected so far. + * To determine the outcome, we refer to Article 13.8 of the Statute: + * > Le riunioni del Direttivo sono valide quando Γ¨ presente la maggioranza assoluta + * > dei componenti. Il Direttivo delibera a maggioranza dei voti dei presenti. + * > In caso di paritΓ  prevale il voto del Presidente. + * + * In this context, the β€œmaggioranza assoluta” (absolute majority) + * is always respected because it is an asynchronous vote, + * so it means the absolute majority of votes. + * + * Here is an example to help devs better understand the voting system. + * e.g. Direttivo of 8 + * - 4 inFavor, 4 against. President inFavor => βœ… Approved + * - 3 inFavor, 5 against. President inFavor => ❌ Denied + * - 5 inFavor, 3 against. President against => βœ… Approved + * - 2 inFavor, 2 against, 3 abstained, President absteined => TIE => ❌ Denied + * (this is an unregulated case, for the sake of banall this should + * not ever happen, so we consider it denied anyway) + * Note: the same mechanisms apply to a Direttivo composed of an odd number of members + * + * The rule of thumb is: + * 1) absolute majority of members: + * 8-9 => 5 voters || 6-7 => 4 voters || 4-5 => 3 voters || 3 => 2 voters + * 2) in case of TIE, the President's vote counts twice + * 3) in case of TIE where the President is abstained, we consider the voting denied. + * + * This function is unit-tested to ensure correct handling of edge-cases. + */ +export function calculateOutcome(voters: Voter[]): Outcome | null { + if (voters.length < 3 || voters.length > 9) { + logger.error({ length: voters.length }, "[VOTE] received a voters array with invalid length (must be 3<=l<=9)") + return null + } + + const membersCount = voters.length + const majority = Math.floor(membersCount / 2) + 1 // absolute majority + const votes = voters.filter((v): v is Voter & { vote: Vote } => v.vote !== undefined) + + const presVote = votes.find((v) => v.isPresident) + if (votes.length === membersCount && !presVote) { + logger.error({ length: voters.length }, "[VOTE] every member voted but no member is flagged as president!") + return null + } + + if (votes.length < majority) return "waiting" // not enough votes + + const results = votes.reduce( + (results, voter) => { + results[voter.vote]++ + return results + }, + { + inFavor: 0, + against: 0, + abstained: 0, + } + ) + + // there are enough votes, but do we have a majority? + if (results.inFavor >= majority) return "approved" // majority voted in-favor + if (results.against >= majority) return "denied" // majority voted against + + // in the following cases we don't have a majority + if (votes.length === membersCount) { + if (!presVote) return null // we already checked above, but TS wants it again + if (results.abstained === membersCount) return "denied" // everyone abstained (crazy) + if (results.inFavor > results.against) return "approved" + if (results.against > results.inFavor) return "denied" + + // against === inFavor => TIE => the Pres decides + // abstained === against for the reasons stated in the docs comment + if (presVote.vote === "abstained" || presVote.vote === "against") return "denied" + return "approved" + } + + // some special cases + if (votes.length === membersCount - 1 && presVote && presVote.vote !== "abstained") { + if (results.inFavor > results.against && presVote.vote === "inFavor") return "approved" + if (results.inFavor < results.against && presVote.vote === "against") return "denied" + } + + // we have not reached enough votes to determine the outcome + // we wait for the remaining votes + return "waiting" +} diff --git a/src/utils/wait.ts b/src/utils/wait.ts index 476c3e4..172a3de 100644 --- a/src/utils/wait.ts +++ b/src/utils/wait.ts @@ -15,3 +15,35 @@ export function wait(time_ms: number): Promise { }, time_ms) }) } + +/** + * A utility class that implements PromiseLike and allows manual resolution of the promise. + * This is useful when you need to await a value that will be provided later, outside of the + * current execution context. + */ +export class Awaiter implements PromiseLike { + private promise: Promise + + // biome-ignore lint/suspicious/noThenProperty: Literally needed to implement PromiseLike + then( + onfulfilled?: ((value: T) => TResult1 | PromiseLike) | null | undefined, + // biome-ignore lint/suspicious/noExplicitAny: This is needed for the PromiseLike implementation + onrejected?: ((reason: any) => TResult2 | PromiseLike) | null | undefined + ): PromiseLike { + return this.promise.then(onfulfilled, onrejected) + } + + private promiseResolve: (value: T) => void = () => { + throw new Error("Promise not initialized. How did you even get here?") + } + + constructor() { + this.promise = new Promise((res) => { + this.promiseResolve = res + }) + } + + public resolve(value: T): void { + this.promiseResolve(value) + } +} diff --git a/src/websocket.ts b/src/websocket.ts index 2ade9da..dcf9935 100644 --- a/src/websocket.ts +++ b/src/websocket.ts @@ -1,9 +1,10 @@ import { type TelegramSocket, WS_PATH } from "@polinetwork/backend" -import type { Bot, Context } from "grammy" import { io } from "socket.io-client" import { env } from "./env" +import { Module } from "./lib/modules" import { logger } from "./logger" import { duration } from "./utils/duration" +import type { ModuleShared } from "./utils/types" type SocketError = { name: string @@ -30,16 +31,20 @@ type SocketError = { * * @param bot - The telegram bot instance */ -export class WebSocketClient { +export class WebSocketClient extends Module { private io: TelegramSocket private lastErrorCode: string | null = null - constructor(private bot: Bot) { + constructor() { + super() this.io = io(`http://${env.BACKEND_URL}`, { path: WS_PATH, query: { type: "telegram" } }) + } + override async start() { this.io.on("connect", () => { logger.info("[WS] connected") this.lastErrorCode = null }) + this.io.on("connect_error", (error: Error) => { if (WebSocketClient.isSocketError(error)) { const code = error.context.statusText.code @@ -56,7 +61,7 @@ export class WebSocketClient { }) this.io.on("ban", async ({ chatId, userId, durationInSeconds }, cb) => { - const error = await this.bot.api + const error = await this.shared.api .banChatMember(chatId, userId, { until_date: durationInSeconds ? duration.zod.parse(`${durationInSeconds}s`).timestamp_s : undefined, }) @@ -73,6 +78,11 @@ export class WebSocketClient { }) } + override async stop() { + this.io.close() + logger.info("[WS] disconnected") + } + static isSocketError(e: Error): e is SocketError { if ("context" in e) return true return false diff --git a/tests/throttle.test.ts b/tests/throttle.test.ts new file mode 100644 index 0000000..e6b5536 --- /dev/null +++ b/tests/throttle.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from "vitest" +import { throttle } from "@/utils/throttle" +import { wait } from "@/utils/wait" + +async function callNTimes(n: number, ms: number, fn: () => void) { + for (let i = 0; i < n; i++) { + fn() + await wait(ms) + } +} + +const testobj = { + foo(i: number = 0) { + return 42 + i + }, +} + +describe("throttle function", () => { + it("test 1", async () => { + const spy = vi.spyOn(testobj, "foo") + const limitms = 100 + const throttled = throttle(() => testobj.foo(), limitms) + await callNTimes(11, 10, throttled) + await wait(limitms + 20) + expect(spy).toHaveBeenCalledTimes(3) + }) + it("test 2", async () => { + const spy = vi.spyOn(testobj, "foo") + const limitms = 50 + const throttled = throttle(() => testobj.foo(), limitms) + await callNTimes(3, 100, throttled) + await wait(limitms + 20) + expect(spy).toHaveBeenCalledTimes(3) + }) + it("test 3", async () => { + const spy = vi.spyOn(testobj, "foo") + const limitms = 500 + const throttled = throttle((i: number) => testobj.foo(i), limitms) + for (let i = 0; i < 50; i++) { + throttled(i) + } + await wait(limitms + 20) + expect(spy).toHaveBeenCalledTimes(2) + expect(spy).toHaveBeenNthCalledWith(1, 0) + expect(spy).toHaveBeenLastCalledWith(49) + }) + it("test 4", async () => { + const spy = vi.spyOn(testobj, "foo") + const limitms = 10 + const throttled = throttle(() => testobj.foo(), limitms) + throttled() + await wait(limitms + 20) + expect(spy).toHaveBeenCalledTimes(1) + }) +}) diff --git a/tests/vote.test.ts b/tests/vote.test.ts new file mode 100644 index 0000000..37dbe12 --- /dev/null +++ b/tests/vote.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest" +import { calculateOutcome, type Outcome, type Vote, type Voter } from "@/utils/vote" + +function makeTest( + pres: Vote | undefined, + inFavor: number, + against: number, + abstained: number, + empty: number +): Outcome | null { + const voters: Voter[] = [{ user: fakeUser, isPresident: true, vote: pres }] + + for (let i = 0; i < inFavor; i++) { + voters.push({ + user: fakeUser, + isPresident: false, + vote: "inFavor", + }) + } + for (let i = 0; i < against; i++) { + voters.push({ + user: fakeUser, + isPresident: false, + vote: "against", + }) + } + for (let i = 0; i < abstained; i++) { + voters.push({ + user: fakeUser, + isPresident: false, + vote: "abstained", + }) + } + for (let i = 0; i < empty; i++) { + voters.push({ + user: fakeUser, + isPresident: false, + vote: undefined, + }) + } + + return calculateOutcome(voters) +} + +const fakeUser: Voter["user"] = { first_name: "First", last_name: "Last", id: 1000000000 } +describe("voting utility", () => { + it("limits breaking", () => { + expect(calculateOutcome([])).toBe(null) + expect(makeTest(undefined, 0, 0, 0, 0)).toBe(null) + expect(makeTest(undefined, 0, 0, 0, 10)).toBe(null) + }) + + it("everyone votes the same", () => { + expect(makeTest("abstained", 0, 0, 6, 0)).toBe("denied") + expect(makeTest("against", 0, 6, 0, 0)).toBe("denied") + expect(makeTest("inFavor", 6, 0, 0, 0)).toBe("approved") + }) + + it("no majority of votes reached", () => { + expect(makeTest(undefined, 2, 3, 0, 3)).toBe("waiting") + expect(makeTest("inFavor", 0, 1, 0, 5)).toBe("waiting") + expect(makeTest(undefined, 1, 2, 0, 4)).toBe("waiting") + expect(makeTest(undefined, 1, 1, 0, 3)).toBe("waiting") + expect(makeTest("abstained", 0, 0, 3, 5)).toBe("waiting") + }) + + it("everyone voted, different combinations", () => { + expect(makeTest("abstained", 1, 0, 1, 0)).toBe("approved") + expect(makeTest("abstained", 4, 2, 0, 0)).toBe("approved") + expect(makeTest("inFavor", 3, 3, 0, 0)).toBe("approved") + expect(makeTest("against", 4, 2, 0, 0)).toBe("approved") + expect(makeTest("against", 2, 4, 0, 0)).toBe("denied") + expect(makeTest("abstained", 2, 4, 0, 0)).toBe("denied") + expect(makeTest("abstained", 1, 5, 0, 0)).toBe("denied") + expect(makeTest("abstained", 0, 6, 0, 0)).toBe("denied") + expect(makeTest("inFavor", 0, 6, 0, 0)).toBe("denied") + expect(makeTest("inFavor", 1, 2, 3, 0)).toBe("approved") + }) + + it("not everyone voted, but still a majority is reached", () => { + expect(makeTest(undefined, 4, 1, 0, 1)).toBe("approved") + expect(makeTest(undefined, 5, 0, 0, 1)).toBe("approved") + expect(makeTest("inFavor", 4, 1, 0, 1)).toBe("approved") + expect(makeTest("abstained", 4, 1, 0, 1)).toBe("approved") + expect(makeTest("inFavor", 3, 0, 2, 1)).toBe("approved") + expect(makeTest(undefined, 4, 2, 0, 0)).toBe("approved") + expect(makeTest("inFavor", 1, 4, 0, 1)).toBe("denied") + expect(makeTest("against", 1, 4, 0, 1)).toBe("denied") + expect(makeTest("inFavor", 1, 4, 0, 1)).toBe("denied") + }) + + it("tie cases", () => { + expect(makeTest("abstained", 3, 3, 0, 0)).toBe("denied") + expect(makeTest("inFavor", 3, 4, 0, 0)).toBe("approved") + expect(makeTest("abstained", 3, 4, 0, 0)).toBe("denied") // not a proper tie + expect(makeTest("against", 3, 2, 1, 0)).toBe("denied") + }) + + it("some tricky cases", () => { + expect(makeTest("inFavor", 2, 3, 0, 1)).toBe("waiting") + expect(makeTest("abstained", 2, 3, 0, 1)).toBe("waiting") + expect(makeTest(undefined, 3, 3, 0, 0)).toBe("waiting") + expect(makeTest("against", 3, 2, 0, 1)).toBe("waiting") + expect(makeTest("inFavor", 2, 2, 0, 1)).toBe("approved") + expect(makeTest("against", 2, 2, 0, 1)).toBe("denied") + expect(makeTest("inFavor", 3, 3, 0, 1)).toBe("approved") + expect(makeTest("inFavor", 0, 2, 0, 0)).toBe("denied") + expect(makeTest("inFavor", 1, 1, 0, 0)).toBe("approved") + expect(makeTest("abstained", 1, 1, 0, 0)).toBe("denied") + }) +})