diff --git a/.husky/pre-commit b/.husky/pre-commit index c46dfe1..fa5c531 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,3 +1,3 @@ #!/bin/sh . "$(dirname "$0")/_/husky.sh" -npm run prettify +npm run tidy diff --git a/LICENSE b/LICENSE index 0e05bf5..b77b6af 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,37 @@ -ANTI-CAPITALIST SOFTWARE LICENSE (v 1.4) +THE HUMANE SOFTWARE LICENSE (v 1.0) -Copyright © 2022 Cowoding-Jams +Copyright © 2023 Cowoding-Jams -This is anti-capitalist software, released for free use by individuals and organizations that do not operate by capitalist principles. +This is humane software, released for free use by individuals and organizations +that do not operate by unmoral or for-profit principles and follow the Universal +Declaration of Human Rights by the United Nations. -Permission is hereby granted, free of charge, to any person or organization (the "User") obtaining a copy of this software and associated documentation files (the "Software"), to use, copy, modify, merge, distribute, and/or sell copies of the Software, subject to the following conditions: +Permission is hereby granted, free of charge, to any person or organization +(the "User") obtaining a copy of this software and associated documentation +files (the "Software"), to use, copy, modify, merge and/or distribute +copies of the Software, subject to the following conditions: -1. The above copyright notice and this permission notice shall be included in all copies or modified versions of the Software. +1. The above copyright notice and this permission notice shall be + included in all copies or modified versions of the Software. 2. The User is one of the following: -a. An individual person, laboring for themselves -b. A non-profit organization -c. An educational institution -d. An organization that seeks shared profit for all of its members, and allows non-members to set the cost of their labor - -3. If the User is an organization with owners, then all owners are workers and all workers are owners with equal equity and/or equal vote. - -4. If the User is an organization, then the User is not law enforcement or military, or working for or under either. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT EXPRESS OR IMPLIED WARRANTY OF ANY KIND, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + a. An individual person, laboring for themselves + b. A non-profit organization + c. An educational institution + d. An organization that seeks shared profit for all of its members, + and allows non-members to set the cost of their labor + +3. The User must act under intentions that do not violate + the Universal Declaration of Human Rights by the United Nations. + +4. If the User is an organization, then the User is not + law enforcement or military, or working for or under either. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT EXPRESS OR IMPLIED WARRANTY OF ANY KIND, +INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR +A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE LIABLE +FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE +OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +See: diff --git a/README.md b/README.md index fb8dd85..cc4888c 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,14 @@ -This bot is made to autonomously manage a discord server with regular game jams/hackathons and make it a plasent and happy enviroment. +This bot is made to autonomously manage a discord server with regular game jams/hackathons and make it a pleasant and happy environment. It will collect the proposals for the jam, create polls for everyone to participate (with reactions directly on the server) and will give the server some more glamour with color roles for example. You can read about the full set of features in our lovely wiki [here](https://github.com/Cowoding-Jams/Jambo/wiki). ## The team :) -Made with love by: Antti, Lucy, Leonie, Murphy, Floschy, Amber, Wilson, Riley, Kryal, Ian ❤️ -We're all part of a game jam/hackothon server with monthly events and wanted to make our and your lives easier. Have fun coding! +Made with love by: [Antti](https://github.com/Chicken), [Lucy](https://github.com/BlackDemonFire), [Leonie](https://github.com/KommentatorForAll), [Murphy](https://github.com/StrangeGirlMurph), [Floschy](https://github.com/flloschy), [Amber](https://github.com/uselessamber), [Wilson](https://github.com/WilsontheWolf) ❤️ +We're all part of a game jam/hackathon server with monthly events and wanted to make our and your lives easier. Have fun coding! ## Usage & Development @@ -18,4 +18,4 @@ To see how to use this bot on your own visit our [wiki](https://github.com/Cowod ## License -This project is licensed under the [Anti-Capitalist Software License](https://anticapitalist.software/). See [LICENSE](LICENSE). +This project is licensed under the [Humane Software License](https://github.com/StrangeGirlMurph/The-Humane-Software-License). See [LICENSE](LICENSE). diff --git a/package-lock.json b/package-lock.json index 8c18d44..bf3ad8b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "SEE LICENSE IN LICENSE", "dependencies": { "@napi-rs/canvas": "^0.1.34", - "discord.js": "^14.7.1", + "discord.js": "^14.11.0", "dotenv": "^16.0.3", "enmap": "github:Cowoding-Jams/enmap", "luxon": "^3.2.1", @@ -31,6 +31,15 @@ "typescript": "^4.9.4" } }, + "node_modules/@aashutoshrathi/word-wrap": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz", + "integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -50,51 +59,82 @@ } }, "node_modules/@discordjs/builders": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.4.0.tgz", - "integrity": "sha512-nEeTCheTTDw5kO93faM1j8ZJPonAX86qpq/QVoznnSa8WWcCgJpjlu6GylfINTDW6o7zZY0my2SYdxx2mfNwGA==", + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.6.3.tgz", + "integrity": "sha512-CTCh8NqED3iecTNuiz49mwSsrc2iQb4d0MjMdmS/8pb69Y4IlzJ/DIy/p5GFlgOrFbNO2WzMHkWKQSiJ3VNXaw==", "dependencies": { - "@discordjs/util": "^0.1.0", - "@sapphire/shapeshift": "^3.7.1", - "discord-api-types": "^0.37.20", + "@discordjs/formatters": "^0.3.1", + "@discordjs/util": "^0.3.1", + "@sapphire/shapeshift": "^3.8.2", + "discord-api-types": "^0.37.41", "fast-deep-equal": "^3.1.3", - "ts-mixer": "^6.0.2", - "tslib": "^2.4.1" + "ts-mixer": "^6.0.3", + "tslib": "^2.5.0" }, "engines": { "node": ">=16.9.0" } }, "node_modules/@discordjs/collection": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.3.0.tgz", - "integrity": "sha512-ylt2NyZ77bJbRij4h9u/wVy7qYw/aDqQLWnadjvDqW/WoWCxrsX6M3CIw9GVP5xcGCDxsrKj5e0r5evuFYwrKg==", + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.1.tgz", + "integrity": "sha512-aWEc9DCf3TMDe9iaJoOnO2+JVAjeRNuRxPZQA6GVvBf+Z3gqUuWYBy2NWh4+5CLYq5uoc3MOvUQ5H5m8CJBqOA==", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/@discordjs/formatters": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.3.1.tgz", + "integrity": "sha512-M7X4IGiSeh4znwcRGcs+49B5tBkNDn4k5bmhxJDAUhRxRHTiFAOTVUNQ6yAKySu5jZTnCbSvTYHW3w0rAzV1MA==", + "dependencies": { + "discord-api-types": "^0.37.41" + }, "engines": { "node": ">=16.9.0" } }, "node_modules/@discordjs/rest": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-1.5.0.tgz", - "integrity": "sha512-lXgNFqHnbmzp5u81W0+frdXN6Etf4EUi8FAPcWpSykKd8hmlWh1xy6BmE0bsJypU1pxohaA8lQCgp70NUI3uzA==", + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-1.7.1.tgz", + "integrity": "sha512-Ofa9UqT0U45G/eX86cURQnX7gzOJLG2oC28VhIk/G6IliYgQF7jFByBJEykPSHE4MxPhqCleYvmsrtfKh1nYmQ==", "dependencies": { - "@discordjs/collection": "^1.3.0", - "@discordjs/util": "^0.1.0", + "@discordjs/collection": "^1.5.1", + "@discordjs/util": "^0.3.0", "@sapphire/async-queue": "^1.5.0", - "@sapphire/snowflake": "^3.2.2", - "discord-api-types": "^0.37.23", - "file-type": "^18.0.0", - "tslib": "^2.4.1", - "undici": "^5.13.0" + "@sapphire/snowflake": "^3.4.2", + "discord-api-types": "^0.37.41", + "file-type": "^18.3.0", + "tslib": "^2.5.0", + "undici": "^5.22.0" }, "engines": { "node": ">=16.9.0" } }, "node_modules/@discordjs/util": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-0.1.0.tgz", - "integrity": "sha512-e7d+PaTLVQav6rOc2tojh2y6FE8S7REkqLldq1XF4soCx74XB/DIjbVbVLtBemf0nLW77ntz0v+o5DytKwFNLQ==", + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-0.3.1.tgz", + "integrity": "sha512-HxXKYKg7vohx2/OupUN/4Sd02Ev3PBJ5q0gtjdcvXb0ErCva8jNHWfe/v5sU3UKjIB/uxOhc+TDOnhqffj9pRA==", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/@discordjs/ws": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-0.8.3.tgz", + "integrity": "sha512-hcYtppanjHecbdNyCKQNH2I4RP9UrphDgmRgLYrATEQF1oo4sYSve7ZmGsBEXSzH72MO2tBPdWSThunbxUVk0g==", + "dependencies": { + "@discordjs/collection": "^1.5.1", + "@discordjs/rest": "^1.7.1", + "@discordjs/util": "^0.3.1", + "@sapphire/async-queue": "^1.5.0", + "@types/ws": "^8.5.4", + "@vladfrangu/async_event_emitter": "^2.2.1", + "discord-api-types": "^0.37.41", + "tslib": "^2.5.0", + "ws": "^8.13.0" + }, "engines": { "node": ">=16.9.0" } @@ -358,9 +398,9 @@ } }, "node_modules/@sapphire/shapeshift": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-3.8.1.tgz", - "integrity": "sha512-xG1oXXBhCjPKbxrRTlox9ddaZTvVpOhYLmKmApD/vIWOV1xEYXnpoFs68zHIZBGbqztq6FrUPNPerIrO1Hqeaw==", + "version": "3.9.2", + "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-3.9.2.tgz", + "integrity": "sha512-YRbCXWy969oGIdqR/wha62eX8GNHsvyYi0Rfd4rNW6tSVVa8p0ELiMEuOH/k8rgtvRoM+EMV7Csqz77YdwiDpA==", "dependencies": { "fast-deep-equal": "^3.1.3", "lodash": "^4.17.21" @@ -371,9 +411,9 @@ } }, "node_modules/@sapphire/snowflake": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.4.0.tgz", - "integrity": "sha512-zZxymtVO6zeXVMPds+6d7gv/OfnCc25M1Z+7ZLB0oPmeMTPeRWVPQSS16oDJy5ZsyCOLj7M6mbZml5gWXcVRNw==", + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.1.tgz", + "integrity": "sha512-BxcYGzgEsdlG0dKAyOm0ehLGm2CafIrfQTZGWgkfKYbj+pNNsorZ7EotuZukc2MT70E0UbppVbtpBrqpzVzjNA==", "engines": { "node": ">=v14.0.0", "npm": ">=7.0.0" @@ -608,6 +648,15 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@vladfrangu/async_event_emitter": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.2.2.tgz", + "integrity": "sha512-HIzRG7sy88UZjBJamssEczH5q7t5+axva19UbZLO6u0ySbYPrwzWiXBcC0WuHyhKKoeCyneH+FvYzKQq/zTtkQ==", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, "node_modules/acorn": { "version": "8.8.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.1.tgz", @@ -979,27 +1028,29 @@ } }, "node_modules/discord-api-types": { - "version": "0.37.28", - "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.37.28.tgz", - "integrity": "sha512-K0fw7m7km9th3dCQ2AR90q/FwX3uAj+OLc+Zuo39VY9vCn0Ux/iObM4y1zJYIH3vTc+QlrksVErUvyeONjOKMQ==" + "version": "0.37.45", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.37.45.tgz", + "integrity": "sha512-r9m/g+YQfo7XWMrl645jvMlYoWF8lvns/ch4NCxsz/FbingrECu97LFSD2zKOvgHaSc90BHP8wgshaMcA2/c6Q==" }, "node_modules/discord.js": { - "version": "14.7.1", - "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.7.1.tgz", - "integrity": "sha512-1FECvqJJjjeYcjSm0IGMnPxLqja/pmG1B0W2l3lUY2Gi4KXiyTeQmU1IxWcbXHn2k+ytP587mMWqva2IA87EbA==", + "version": "14.11.0", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.11.0.tgz", + "integrity": "sha512-CkueWYFQ28U38YPR8HgsBR/QT35oPpMbEsTNM30Fs8loBIhnA4s70AwQEoy6JvLcpWWJO7GY0y2BUzZmuBMepQ==", "dependencies": { - "@discordjs/builders": "^1.4.0", - "@discordjs/collection": "^1.3.0", - "@discordjs/rest": "^1.4.0", - "@discordjs/util": "^0.1.0", - "@sapphire/snowflake": "^3.2.2", - "@types/ws": "^8.5.3", - "discord-api-types": "^0.37.20", + "@discordjs/builders": "^1.6.3", + "@discordjs/collection": "^1.5.1", + "@discordjs/formatters": "^0.3.1", + "@discordjs/rest": "^1.7.1", + "@discordjs/util": "^0.3.1", + "@discordjs/ws": "^0.8.3", + "@sapphire/snowflake": "^3.4.2", + "@types/ws": "^8.5.4", + "discord-api-types": "^0.37.41", "fast-deep-equal": "^3.1.3", "lodash.snakecase": "^4.1.1", - "tslib": "^2.4.1", - "undici": "^5.13.0", - "ws": "^8.11.0" + "tslib": "^2.5.0", + "undici": "^5.22.0", + "ws": "^8.13.0" }, "engines": { "node": ">=16.9.0" @@ -1379,9 +1430,9 @@ } }, "node_modules/file-type": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-18.2.0.tgz", - "integrity": "sha512-M3RQMWY3F2ykyWZ+IHwNCjpnUmukYhtdkGGC1ZVEUb0ve5REGF7NNJ4Q9ehCUabtQKtSVFOMbFTXgJlFb0DQIg==", + "version": "18.5.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-18.5.0.tgz", + "integrity": "sha512-yvpl5U868+V6PqXHMmsESpg6unQ5GfnPssl4dxdJudBrr9qy7Fddt7EVX1VLlddFfe8Gj9N7goCZH22FXuSQXQ==", "dependencies": { "readable-web-to-node-stream": "^3.0.2", "strtok3": "^7.0.0", @@ -1988,17 +2039,17 @@ } }, "node_modules/optionator": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", - "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==", + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", + "integrity": "sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==", "dev": true, "dependencies": { + "@aashutoshrathi/word-wrap": "^1.2.3", "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.3" + "type-check": "^0.4.0" }, "engines": { "node": ">= 0.8.0" @@ -2348,9 +2399,9 @@ } }, "node_modules/semver": { - "version": "7.3.8", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", - "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", + "version": "7.5.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.3.tgz", + "integrity": "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ==", "dependencies": { "lru-cache": "^6.0.0" }, @@ -2597,14 +2648,14 @@ "integrity": "sha512-XrHUvV5HpdLmIj4uVMxHggLbFSZYIn7HEWsqePZcI50pco+MPqJ50wMGY794X7AOOhxOBAjbkqfAbEe/QMp2Lw==" }, "node_modules/ts-mixer": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.2.tgz", - "integrity": "sha512-zvHx3VM83m2WYCE8XL99uaM7mFwYSkjR2OZti98fabHrwkjsCvgwChda5xctein3xGOyaQhtTeDq/1H/GNvF3A==" + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.3.tgz", + "integrity": "sha512-k43M7uCG1AkTyxgnmI5MPwKoUvS/bRvLvUb7+Pgpdlmok8AoqmUaZxUUw8zKM5B1lqZrt41GjYgnvAi0fppqgQ==" }, "node_modules/tslib": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.1.tgz", - "integrity": "sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==" + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.3.tgz", + "integrity": "sha512-mSxlJJwl3BMEQCUNnxXBU9jP4JBktcEGhURcPR6VQVlnP0FdDEsIaz0C35dXNGLyRfrATNofF0F5p2KPxQgB+w==" }, "node_modules/tsutils": { "version": "3.21.0", @@ -2676,14 +2727,14 @@ } }, "node_modules/undici": { - "version": "5.15.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-5.15.0.tgz", - "integrity": "sha512-wCAZJDyjw9Myv+Ay62LAoB+hZLPW9SmKbQkbHIhMw/acKSlpn7WohdMUc/Vd4j1iSMBO0hWwU8mjB7a5p5bl8g==", + "version": "5.22.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.22.1.tgz", + "integrity": "sha512-Ji2IJhFXZY0x/0tVBXeQwgPlLWw13GVzpsWPQ3rV50IFMMof2I55PZZxtm4P6iNq+L5znYN9nSTAq0ZyE6lSJw==", "dependencies": { "busboy": "^1.6.0" }, "engines": { - "node": ">=12.18" + "node": ">=14.0" } }, "node_modules/uri-js": { @@ -2765,24 +2816,15 @@ "node": ">= 6.4.0" } }, - "node_modules/word-wrap": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", - "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, "node_modules/ws": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.12.0.tgz", - "integrity": "sha512-kU62emKIdKVeEIOIKVegvqpXMSTAMLJozpHZaJNDYqBjzlSYXQGviYwN1osDLJ9av68qHd4a2oSjd7yD4pacig==", + "version": "8.13.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.13.0.tgz", + "integrity": "sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==", "engines": { "node": ">=10.0.0" }, @@ -2818,6 +2860,12 @@ } }, "dependencies": { + "@aashutoshrathi/word-wrap": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz", + "integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==", + "dev": true + }, "@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -2834,42 +2882,67 @@ } }, "@discordjs/builders": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.4.0.tgz", - "integrity": "sha512-nEeTCheTTDw5kO93faM1j8ZJPonAX86qpq/QVoznnSa8WWcCgJpjlu6GylfINTDW6o7zZY0my2SYdxx2mfNwGA==", + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.6.3.tgz", + "integrity": "sha512-CTCh8NqED3iecTNuiz49mwSsrc2iQb4d0MjMdmS/8pb69Y4IlzJ/DIy/p5GFlgOrFbNO2WzMHkWKQSiJ3VNXaw==", "requires": { - "@discordjs/util": "^0.1.0", - "@sapphire/shapeshift": "^3.7.1", - "discord-api-types": "^0.37.20", + "@discordjs/formatters": "^0.3.1", + "@discordjs/util": "^0.3.1", + "@sapphire/shapeshift": "^3.8.2", + "discord-api-types": "^0.37.41", "fast-deep-equal": "^3.1.3", - "ts-mixer": "^6.0.2", - "tslib": "^2.4.1" + "ts-mixer": "^6.0.3", + "tslib": "^2.5.0" } }, "@discordjs/collection": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.3.0.tgz", - "integrity": "sha512-ylt2NyZ77bJbRij4h9u/wVy7qYw/aDqQLWnadjvDqW/WoWCxrsX6M3CIw9GVP5xcGCDxsrKj5e0r5evuFYwrKg==" + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.1.tgz", + "integrity": "sha512-aWEc9DCf3TMDe9iaJoOnO2+JVAjeRNuRxPZQA6GVvBf+Z3gqUuWYBy2NWh4+5CLYq5uoc3MOvUQ5H5m8CJBqOA==" + }, + "@discordjs/formatters": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.3.1.tgz", + "integrity": "sha512-M7X4IGiSeh4znwcRGcs+49B5tBkNDn4k5bmhxJDAUhRxRHTiFAOTVUNQ6yAKySu5jZTnCbSvTYHW3w0rAzV1MA==", + "requires": { + "discord-api-types": "^0.37.41" + } }, "@discordjs/rest": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-1.5.0.tgz", - "integrity": "sha512-lXgNFqHnbmzp5u81W0+frdXN6Etf4EUi8FAPcWpSykKd8hmlWh1xy6BmE0bsJypU1pxohaA8lQCgp70NUI3uzA==", + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-1.7.1.tgz", + "integrity": "sha512-Ofa9UqT0U45G/eX86cURQnX7gzOJLG2oC28VhIk/G6IliYgQF7jFByBJEykPSHE4MxPhqCleYvmsrtfKh1nYmQ==", "requires": { - "@discordjs/collection": "^1.3.0", - "@discordjs/util": "^0.1.0", + "@discordjs/collection": "^1.5.1", + "@discordjs/util": "^0.3.0", "@sapphire/async-queue": "^1.5.0", - "@sapphire/snowflake": "^3.2.2", - "discord-api-types": "^0.37.23", - "file-type": "^18.0.0", - "tslib": "^2.4.1", - "undici": "^5.13.0" + "@sapphire/snowflake": "^3.4.2", + "discord-api-types": "^0.37.41", + "file-type": "^18.3.0", + "tslib": "^2.5.0", + "undici": "^5.22.0" } }, "@discordjs/util": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-0.1.0.tgz", - "integrity": "sha512-e7d+PaTLVQav6rOc2tojh2y6FE8S7REkqLldq1XF4soCx74XB/DIjbVbVLtBemf0nLW77ntz0v+o5DytKwFNLQ==" + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-0.3.1.tgz", + "integrity": "sha512-HxXKYKg7vohx2/OupUN/4Sd02Ev3PBJ5q0gtjdcvXb0ErCva8jNHWfe/v5sU3UKjIB/uxOhc+TDOnhqffj9pRA==" + }, + "@discordjs/ws": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-0.8.3.tgz", + "integrity": "sha512-hcYtppanjHecbdNyCKQNH2I4RP9UrphDgmRgLYrATEQF1oo4sYSve7ZmGsBEXSzH72MO2tBPdWSThunbxUVk0g==", + "requires": { + "@discordjs/collection": "^1.5.1", + "@discordjs/rest": "^1.7.1", + "@discordjs/util": "^0.3.1", + "@sapphire/async-queue": "^1.5.0", + "@types/ws": "^8.5.4", + "@vladfrangu/async_event_emitter": "^2.2.1", + "discord-api-types": "^0.37.41", + "tslib": "^2.5.0", + "ws": "^8.13.0" + } }, "@eslint/eslintrc": { "version": "1.4.1", @@ -3013,18 +3086,18 @@ "integrity": "sha512-JkLdIsP8fPAdh9ZZjrbHWR/+mZj0wvKS5ICibcLrRI1j84UmLMshx5n9QmL8b95d4onJ2xxiyugTgSAX7AalmA==" }, "@sapphire/shapeshift": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-3.8.1.tgz", - "integrity": "sha512-xG1oXXBhCjPKbxrRTlox9ddaZTvVpOhYLmKmApD/vIWOV1xEYXnpoFs68zHIZBGbqztq6FrUPNPerIrO1Hqeaw==", + "version": "3.9.2", + "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-3.9.2.tgz", + "integrity": "sha512-YRbCXWy969oGIdqR/wha62eX8GNHsvyYi0Rfd4rNW6tSVVa8p0ELiMEuOH/k8rgtvRoM+EMV7Csqz77YdwiDpA==", "requires": { "fast-deep-equal": "^3.1.3", "lodash": "^4.17.21" } }, "@sapphire/snowflake": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.4.0.tgz", - "integrity": "sha512-zZxymtVO6zeXVMPds+6d7gv/OfnCc25M1Z+7ZLB0oPmeMTPeRWVPQSS16oDJy5ZsyCOLj7M6mbZml5gWXcVRNw==" + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.1.tgz", + "integrity": "sha512-BxcYGzgEsdlG0dKAyOm0ehLGm2CafIrfQTZGWgkfKYbj+pNNsorZ7EotuZukc2MT70E0UbppVbtpBrqpzVzjNA==" }, "@tokenizer/token": { "version": "0.3.0", @@ -3166,6 +3239,11 @@ "eslint-visitor-keys": "^3.3.0" } }, + "@vladfrangu/async_event_emitter": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.2.2.tgz", + "integrity": "sha512-HIzRG7sy88UZjBJamssEczH5q7t5+axva19UbZLO6u0ySbYPrwzWiXBcC0WuHyhKKoeCyneH+FvYzKQq/zTtkQ==" + }, "acorn": { "version": "8.8.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.1.tgz", @@ -3439,27 +3517,29 @@ } }, "discord-api-types": { - "version": "0.37.28", - "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.37.28.tgz", - "integrity": "sha512-K0fw7m7km9th3dCQ2AR90q/FwX3uAj+OLc+Zuo39VY9vCn0Ux/iObM4y1zJYIH3vTc+QlrksVErUvyeONjOKMQ==" + "version": "0.37.45", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.37.45.tgz", + "integrity": "sha512-r9m/g+YQfo7XWMrl645jvMlYoWF8lvns/ch4NCxsz/FbingrECu97LFSD2zKOvgHaSc90BHP8wgshaMcA2/c6Q==" }, "discord.js": { - "version": "14.7.1", - "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.7.1.tgz", - "integrity": "sha512-1FECvqJJjjeYcjSm0IGMnPxLqja/pmG1B0W2l3lUY2Gi4KXiyTeQmU1IxWcbXHn2k+ytP587mMWqva2IA87EbA==", + "version": "14.11.0", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.11.0.tgz", + "integrity": "sha512-CkueWYFQ28U38YPR8HgsBR/QT35oPpMbEsTNM30Fs8loBIhnA4s70AwQEoy6JvLcpWWJO7GY0y2BUzZmuBMepQ==", "requires": { - "@discordjs/builders": "^1.4.0", - "@discordjs/collection": "^1.3.0", - "@discordjs/rest": "^1.4.0", - "@discordjs/util": "^0.1.0", - "@sapphire/snowflake": "^3.2.2", - "@types/ws": "^8.5.3", - "discord-api-types": "^0.37.20", + "@discordjs/builders": "^1.6.3", + "@discordjs/collection": "^1.5.1", + "@discordjs/formatters": "^0.3.1", + "@discordjs/rest": "^1.7.1", + "@discordjs/util": "^0.3.1", + "@discordjs/ws": "^0.8.3", + "@sapphire/snowflake": "^3.4.2", + "@types/ws": "^8.5.4", + "discord-api-types": "^0.37.41", "fast-deep-equal": "^3.1.3", "lodash.snakecase": "^4.1.1", - "tslib": "^2.4.1", - "undici": "^5.13.0", - "ws": "^8.11.0" + "tslib": "^2.5.0", + "undici": "^5.22.0", + "ws": "^8.13.0" } }, "doctrine": { @@ -3746,9 +3826,9 @@ } }, "file-type": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-18.2.0.tgz", - "integrity": "sha512-M3RQMWY3F2ykyWZ+IHwNCjpnUmukYhtdkGGC1ZVEUb0ve5REGF7NNJ4Q9ehCUabtQKtSVFOMbFTXgJlFb0DQIg==", + "version": "18.5.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-18.5.0.tgz", + "integrity": "sha512-yvpl5U868+V6PqXHMmsESpg6unQ5GfnPssl4dxdJudBrr9qy7Fddt7EVX1VLlddFfe8Gj9N7goCZH22FXuSQXQ==", "requires": { "readable-web-to-node-stream": "^3.0.2", "strtok3": "^7.0.0", @@ -4185,17 +4265,17 @@ } }, "optionator": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", - "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==", + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", + "integrity": "sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==", "dev": true, "requires": { + "@aashutoshrathi/word-wrap": "^1.2.3", "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.3" + "type-check": "^0.4.0" } }, "p-limit": { @@ -4403,9 +4483,9 @@ "integrity": "sha512-gMxvPJYhP0O9n2pvcfYfIuYgbledAOJFcqRThtPRmjscaipiwcwPPKLytpVzMkG2HAN87Qmo2d4PtGiri1dSLA==" }, "semver": { - "version": "7.3.8", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", - "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", + "version": "7.5.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.3.tgz", + "integrity": "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ==", "requires": { "lru-cache": "^6.0.0" } @@ -4571,14 +4651,14 @@ "integrity": "sha512-XrHUvV5HpdLmIj4uVMxHggLbFSZYIn7HEWsqePZcI50pco+MPqJ50wMGY794X7AOOhxOBAjbkqfAbEe/QMp2Lw==" }, "ts-mixer": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.2.tgz", - "integrity": "sha512-zvHx3VM83m2WYCE8XL99uaM7mFwYSkjR2OZti98fabHrwkjsCvgwChda5xctein3xGOyaQhtTeDq/1H/GNvF3A==" + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.3.tgz", + "integrity": "sha512-k43M7uCG1AkTyxgnmI5MPwKoUvS/bRvLvUb7+Pgpdlmok8AoqmUaZxUUw8zKM5B1lqZrt41GjYgnvAi0fppqgQ==" }, "tslib": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.1.tgz", - "integrity": "sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==" + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.3.tgz", + "integrity": "sha512-mSxlJJwl3BMEQCUNnxXBU9jP4JBktcEGhURcPR6VQVlnP0FdDEsIaz0C35dXNGLyRfrATNofF0F5p2KPxQgB+w==" }, "tsutils": { "version": "3.21.0", @@ -4627,9 +4707,9 @@ "dev": true }, "undici": { - "version": "5.15.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-5.15.0.tgz", - "integrity": "sha512-wCAZJDyjw9Myv+Ay62LAoB+hZLPW9SmKbQkbHIhMw/acKSlpn7WohdMUc/Vd4j1iSMBO0hWwU8mjB7a5p5bl8g==", + "version": "5.22.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.22.1.tgz", + "integrity": "sha512-Ji2IJhFXZY0x/0tVBXeQwgPlLWw13GVzpsWPQ3rV50IFMMof2I55PZZxtm4P6iNq+L5znYN9nSTAq0ZyE6lSJw==", "requires": { "busboy": "^1.6.0" } @@ -4695,21 +4775,15 @@ "triple-beam": "^1.3.0" } }, - "word-wrap": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", - "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", - "dev": true - }, "wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, "ws": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.12.0.tgz", - "integrity": "sha512-kU62emKIdKVeEIOIKVegvqpXMSTAMLJozpHZaJNDYqBjzlSYXQGviYwN1osDLJ9av68qHd4a2oSjd7yD4pacig==", + "version": "8.13.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.13.0.tgz", + "integrity": "sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==", "requires": {} }, "yallist": { diff --git a/package.json b/package.json index 2663dac..ce28b00 100644 --- a/package.json +++ b/package.json @@ -6,10 +6,14 @@ "private": "true", "type": "module", "scripts": { - "prettify": "eslint ./src/ --fix && npx prettier -w .", + "rmDist": "rm ./dist -r", + "lint": "eslint ./src/", + "format": "npx prettier -w .", + "tidy": "npm run lint && npm run format", "build": "tsc -p tsconfig.json", "run": "node --experimental-specifier-resolution=node .", "start": "npm run build && npm run run", + "freshStart": "npm run rmDist && npm run start", "prepare": "husky install" }, "repository": { @@ -47,7 +51,7 @@ }, "dependencies": { "@napi-rs/canvas": "^0.1.34", - "discord.js": "^14.7.1", + "discord.js": "^14.11.0", "dotenv": "^16.0.3", "enmap": "github:Cowoding-Jams/enmap", "luxon": "^3.2.1", diff --git a/src/commands/ActivityTrackerCommand.ts b/src/commands/ActivityTrackerCommand.ts deleted file mode 100644 index c17bccf..0000000 --- a/src/commands/ActivityTrackerCommand.ts +++ /dev/null @@ -1,222 +0,0 @@ -import { - ChatInputCommandInteraction, - SlashCommandBuilder, - SlashCommandSubcommandsOnlyBuilder, -} from "discord.js"; -import { Command } from "../interactions/interactionClasses"; -import { blacklistAdd, blacklistRemove, blacklistShow } from "../util/activity-tracker/blacklist"; -import { statsAll, statsGame, statsMy } from "../util/activity-tracker/statistics"; -import { - adminBlacklistGame, - adminLook, - adminReset, - adminShow, - adminWhitelistGame, -} from "../util/activity-tracker/admin"; -import { list } from "../util/activity-tracker/list"; -import { hasAdminRole } from "../util/misc/permissions"; - -class ActivityTrackerCommand extends Command { - constructor() { - super("activity-tracker"); - } - - async execute(interaction: ChatInputCommandInteraction): Promise { - const group: string | null = interaction.options.getSubcommandGroup(); - const sub: string | null = interaction.options.getSubcommand(); - - if (group === "blacklist") { - if (sub === "add") { - await blacklistAdd(interaction); - } else if (sub === "remove") { - await blacklistRemove(interaction); - } else if (sub === "show") { - await blacklistShow(interaction); - } - } else if (group === "stats") { - if (sub === "my") { - await statsMy(interaction); - } else if (sub === "game") { - await statsGame(interaction); - } else if (sub === "all") { - await statsAll(interaction); - } - } else if (group === "admin") { - if (!(await hasAdminRole(interaction))) { - await interaction.reply({ - content: "You don't have the required permissions to use this command.", - ephemeral: true, - }); - } else if (sub === "reset") { - await adminReset(interaction); - } else if (sub === "blacklist") { - await adminBlacklistGame(interaction); - } else if (sub === "whitelist") { - await adminWhitelistGame(interaction); - } else if (sub === "look") { - await adminLook(interaction); - } else if (sub == "show") { - await adminShow(interaction); - } - } else if (sub == "list") { - await list(interaction); - } - } - - register(): - | SlashCommandSubcommandsOnlyBuilder - | Omit { - return new SlashCommandBuilder() - .setName("activity-tracker") - .setDescription("A game activity tracker to provide interesting insights in the people on this server.") - .addSubcommand((sub) => - sub - .setName("list") - .setDescription("Generates a list of the played games based on a selected sorting method.") - .addStringOption((opt) => - opt - .setName("sort") - .setDescription("Criteria to sort by. (default: logs chronologically)") - .addChoices( - { name: "logs chronologically", value: "log-history" }, - { name: "playtime per game", value: "playtime-per-game" }, - { name: "number of logs per game", value: "logs-per-game" }, - { name: "last log date per game", value: "log-date-per-game" } - ) - ) - .addStringOption((opt) => - opt - .setName("order") - .setDescription("Ordering. (default: decreasing)") - .addChoices( - { name: "decreasing", value: "decreasing" }, - { name: "increasing", value: "increasing" } - ) - ) - ) - .addSubcommandGroup((group) => - group - .setName("stats") - .setDescription("Gives you statistics based on the game activity.") - .addSubcommand((sub) => - sub - .setName("my") - .setDescription("Show statistics about your own logs.") - .addStringOption((opt) => - opt - .setName("game") - .setDescription("A single game to use for the stats. (default: all games)") - .setAutocomplete(true) - ) - ) - .addSubcommand((sub) => - sub - .setName("game") - .setDescription("Show statistics about a game across all logs.") - .addStringOption((opt) => - opt - .setName("game") - .setDescription("The game to use for the stats.") - .setAutocomplete(true) - .setRequired(true) - ) - .addBooleanOption((opt) => - opt - .setName("show-playtime") - .setDescription("Show the playtime of the game per player. (default: false)") - ) - ) - .addSubcommand((sub) => sub.setName("all").setDescription("Show statistics across all logs.")) - ) - .addSubcommandGroup((group) => - group - .setName("blacklist") - .setDescription( - "Manage your activity blacklist. (The activity of a blacklisted game will not be logged)" - ) - .addSubcommand((sub) => - sub - .setName("add") - .setDescription("Add a game to your blacklist.") - .addStringOption((opt) => - opt - .setName("game") - .setDescription("The game to blacklist.") - .setAutocomplete(true) - .setRequired(true) - ) - ) - .addSubcommand((sub) => - sub - .setName("remove") - .setDescription("Remove a game from your blacklist.") - .addStringOption((opt) => - opt - .setName("game") - .setDescription("The game to whitelist.") - .setAutocomplete(true) - .setRequired(true) - ) - ) - .addSubcommand((sub) => sub.setName("show").setDescription("See what is on your blacklist.")) - ) - .addSubcommandGroup((group) => - group - .setName("admin") - .setDescription("Admin commands to manage the activity tracker.") - .addSubcommand((sub) => - sub - .setName("reset") - .setDescription("Reset every log and blacklist entry.") - .addBooleanOption((opt) => - opt.setName("sure").setDescription("Are you really sure?").setRequired(true) - ) - .addStringOption((opt) => - opt - .setName("really") - .setDescription("Are you really sure you want to delete every entry?") - .addChoices( - { name: "No. I don't want to delete every log and blacklist entry!", value: "no" }, - { name: "Yes. I am sure. I want to delete every log and blacklist entry!", value: "yes" } - ) - .setRequired(true) - ) - ) - .addSubcommand((sub) => - sub - .setName("blacklist") - .setDescription("Add a game to the global blacklist.") - .addStringOption((opt) => - opt - .setName("game") - .setDescription("The game to blacklist.") - .setRequired(true) - .setAutocomplete(true) - ) - ) - .addSubcommand((sub) => - sub - .setName("whitelist") - .setDescription("Remove a game from the global blacklist.") - .addStringOption((opt) => - opt - .setName("game") - .setDescription("The game to whitelist.") - .setRequired(true) - .setAutocomplete(true) - ) - ) - .addSubcommand((sub) => sub.setName("show").setDescription("See what is on the global blacklist.")) - .addSubcommand((sub) => - sub - .setName("look") - .setDescription("View a users personal blacklist.") - .addUserOption((opt) => - opt.setName("user").setDescription("The user to view the blacklist from.").setRequired(true) - ) - ) - ); - } -} - -export default new ActivityTrackerCommand(); diff --git a/src/commands/TrackerCommands.ts b/src/commands/TrackerCommands.ts new file mode 100644 index 0000000..1dca492 --- /dev/null +++ b/src/commands/TrackerCommands.ts @@ -0,0 +1,160 @@ +import { Command } from "../interactions/interactionClasses"; +import { + ChatInputCommandInteraction, + SlashCommandBuilder, + SlashCommandSubcommandsOnlyBuilder, +} from "discord.js"; +import { latest, logs, playtime, stats } from "../util/tracker/subCommands"; +import { userStats } from "../util/tracker/userCommands"; +import { gameStats } from "../util/tracker/gameCommands"; +import { addBlacklist, remBlacklist } from "../util/tracker/blacklistCommands"; +import { config } from "../config"; +import { logger } from "../logger"; + +class Tracker extends Command { + constructor() { + super("tracker"); + } + + async execute(interaction: ChatInputCommandInteraction): Promise { + if (!config.tracking) { + await interaction.reply({ + content: + "Tracking is disabled. No game activity's will be logged and tracking commands are disabled.", + ephemeral: true, + }); + return; + } + const subCommand = interaction.options.getSubcommand(); + const statistics = interaction.options.getString("statistic") ?? "general statistics"; + const action = interaction.options.getString("action"); + let err = false; + + try { + switch (subCommand) { + case "user": + await userStats(interaction); + return; + case "game": + await gameStats(interaction); + return; + case "general": + if (statistics == "playtime") await playtime(interaction); + else if (statistics == "logs") await logs(interaction); + return; + case "blacklist": + if (action == "add") await addBlacklist(interaction); + else if (action == "rem") await remBlacklist(interaction); + return; + case "latest": + await latest(interaction); + return; + case "statistics": + await stats(interaction); + return; + } + } catch (e) { + logger.error(e); + err = true; + } + + if (!interaction.replied || err) { + await interaction.reply({ + content: + "The command you where trying to executed failed... This can happen because there are no logs fitting the given criteria, or you used the wrong order of inputs, which might result in options being available although they shouldn't. Sadly, this is an issue on discords side.", + ephemeral: true, + }); + } + } + + register(): SlashCommandSubcommandsOnlyBuilder { + if (!config.tracking) + return new SlashCommandBuilder() + .setName("tracker") + .setDescription( + "The tracking system is disabled which means commands are removed and tracking is paused." + ); + return new SlashCommandBuilder() + .setName("tracker") + .setDescription("The gateway to some cool stats about the people on this server.") + .addSubcommand((sub) => + sub + .setName("user") + .setDescription("Get tracking stats about a user.") + .addUserOption((opt) => opt.setName("user").setDescription("The target user. default: you")) + .addStringOption((opt) => + opt + .setName("game") + .setDescription("The game of which to get playtime/logs from. default: every game") + .setAutocomplete(true) + ) + ) + .addSubcommand((sub) => + sub + .setName("game") + .setDescription("Get tracking stats about a game.") + .addStringOption((opt) => + opt + .setName("game") + .setDescription("The game of which to get statistics from.") + .setRequired(true) + .setAutocomplete(true) + ) + ) + .addSubcommand((sub) => + sub + .setName("general") + .setDescription("overall statistics without the need to specify a user or game") + .addStringOption((opt) => + opt + .setName("statistic") + .setDescription("Choose the statistic which should get shown.") + .addChoices({ name: "playtime", value: "playtime" }, { name: "logs", value: "logs" }) + .setRequired(true) + ) + .addUserOption((opt) => opt.setName("user").setDescription("The Target User")) + .addStringOption((opt) => + opt.setName("game").setDescription("The target Game").setAutocomplete(true) + ) + ) + + .addSubcommand((sub) => + sub + .setName("blacklist") + .setDescription("[ADMIN ONLY] edit the tracking blacklist to allow or block certain games.") + .addStringOption((opt) => + opt + .setName("action") + .setDescription("Select which action should get executed.") + .addChoices({ name: "add", value: "add" }, { name: "remove", value: "rem" }) + .setRequired(true) + ) + .addStringOption((opt) => + opt + .setName("game") + .setDescription("The game on which the action should be performed.") + .setRequired(true) + .setAutocomplete(true) + ) + ) + .addSubcommand((sub) => + sub + .setName("latest") + .setDescription("See the latest logs across the whole system.") + .addStringOption((opt) => + opt + .setName("game") + .setDescription("The game of which the latest logs should get shown.") // bad wording and it sounds bad, ik + .setAutocomplete(true) + ) + .addUserOption( + (opt) => opt.setName("user").setDescription("The user who's latest logs should get show.") // bad wording and it sounds bad, ik + ) + ) + .addSubcommand((sub) => + sub.setName("statistics").setDescription("See general statistics across the whole system.") + ); + } +} + +export default new Tracker(); diff --git a/src/config.ts b/src/config.ts index 1bdc51a..0c564f6 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,4 +1,4 @@ -import { ColorResolvable, ComponentEmojiResolvable } from "discord.js"; +import { BotConfig } from "./util/config/configInterface"; // Configure your bot here. export const config: BotConfig = { @@ -49,81 +49,9 @@ export const config: BotConfig = { ["Sea Serpent", "#5db9cf"], ], - // --- Activity Tracker --- - logActivity: true, - // --- Birthday feature --- birthdayNotificationAt: 9, -}; - -interface BotConfig { - // --- General --- - /** Name of the bot. */ - botName: string; - /** URL to a png or jpg of the logo of the bot. */ - iconURL: string; - /** URL to the github repository of the bot. Please share your code! */ - githubURL: string; - /** A description of the server that will be displayed in the welcome message. */ - serverDescription: string; - /** The main color the bot will use. */ - color: ColorResolvable; - - // --- Development --- - /** Also available: error, warn, info, http, verbose, debug, silly. */ - logLevel: "debug" | "info" | "warn" | "error" | "verbose"; - - // --- Management --- - /** Moderators of the server (to manage proposals) */ - moderatorRoleId: string; - /** Admins of the server (to manage the game activity tracker) */ - adminRoleId: string; - - // --- Polls & Coding Jams --- - /** Active Jam participants role name (will be pinged for announcements) */ - jamRoleName: string; - /** The text channel where the poll system will post polls. */ - pollChannelId: string; - /** The text channel where the jam system will post announcements to jams. */ - jamChannelId: string; - /** The category where the jam system will create channels for jams. */ - resultCategoryId: string; - // --- Roles --- - /** Pronoun roles to pick from. First argument is the name of the role, second argument is the emoji id (You get the id by typing "\\[insert emoji]" in Discord). */ - pronounRoles: [string, ComponentEmojiResolvable | null][]; - /** Max 25 color roles to pick from. First argument is the name of the role, second argument is the color. */ - colorRoles: [string, ColorResolvable][]; - - // --- Activity Tracker --- - /** If User Game Activity should get logged */ - logActivity: boolean; - - // --- Birthday Feature --- - /** Can be any integer between 0 and 23. Defines at what time the birthday message should get send. */ - birthdayNotificationAt: - | 0 - | 1 - | 2 - | 3 - | 4 - | 5 - | 6 - | 7 - | 8 - | 9 - | 10 - | 11 - | 12 - | 13 - | 14 - | 15 - | 16 - | 17 - | 18 - | 19 - | 20 - | 21 - | 22 - | 23; -} + // --- Tracker --- + tracking: true, +}; diff --git a/src/db.ts b/src/db.ts index eee9212..869cedd 100644 --- a/src/db.ts +++ b/src/db.ts @@ -1,10 +1,33 @@ import Enmap from "enmap"; import { DateTime, Duration } from "luxon"; -// -- LaTeX Database -- +/* +-------------- +- MISC typed - +-------------- +*/ + +export type ISODate = string; // ISO Date +export type ISODuration = string; // ISO Duration +export type jamID = string; // key of jamDb +export type pollID = string; // key of pollDb +export type proposalID = string; // key of proposalDb +export type userID = string; // Discord user id + +/* +------------------ +- LaTeX Database - +------------------ +*/ + export const latexDb = new Enmap("latex"); // key: reply id -// -- Reminder Database -- +/* +--------------------- +- Reminder Database - +--------------------- +*/ + export const reminderTimeoutCache = new Map(); // key: unique id export const reminderDb = new Enmap({ @@ -28,42 +51,134 @@ interface InternalReminder { ping: string | null; } -// -- Activity Tracker Database -- -export const activityTrackerBlacklistDb = new Enmap("trackerBlacklist"); -activityTrackerBlacklistDb.ensure("general-user", []); -activityTrackerBlacklistDb.ensure("general-game", []); - -// key: "[user id]-[game]" -export const activityTrackerLogDb = new Enmap({ - name: "trackerLog", - serializer: (data) => data.map((e) => ({ duration: e.duration.toISO(), date: e.date.toISO() })), - deserializer: (data) => - data.map((e) => ({ - duration: Duration.fromISO(e.duration), - date: DateTime.fromISO(e.date, { setZone: true }), - })), -}); +/* +-------------------- +- Tracker Database - +-------------------- +*/ -export interface ActivityLogEntry { - duration: Duration; - date: DateTime; +/** Interface for tracker logs */ +export interface TrackerLog { + /** id of the log */ + id: string; + /** When a log gets logged (Date.now())*/ + date: number; + /** How long a game got played in seconds*/ + playtime: number; + /** User id of the User who just got logged */ + userID: userID; + /** Game name of the game which just got logged */ + gameName: string; } -interface InternalActivityLogEntry { - duration: ISODuration; +/** Interface for stripped down tracking logs */ +export interface TrackerSublog { + /** User- or Game- ID */ + name: userID; + /** How often a User or Game got logged */ + logs: number; + /** How long a User played a certain game in seconds + * (Or the other way around) */ + playtime: number; +} + +/** Interface for tracking users */ +export interface TrackerUser { + /** How long a user played a game in seconds */ + playtime: number; + /** ID of the first log associated with the user */ + firstlog: TrackerLog; + /** How often a user got logged */ + logs: number; + /** List of the latest 5 logs in form of the log ID*/ + lastlogs: TrackerLog[]; + /** Little infos about the game which the user played (id, log amount, time played in total)*/ + games: TrackerSublog[]; +} + +/** Interface for tracking games */ +export interface TrackerGame { + /** How long a game got played in seconds */ + playtime: number; + /** ID of the first log associated with the game */ + firstlog: TrackerLog; + /** How often a game got logged */ + logs: number; + /** List of the latest 5 logs in form of the log ID*/ + lastlogs: TrackerLog[]; + /** Little infos about the users who played the game (id, log amount, time played in total)*/ + users: TrackerSublog[]; +} + +interface internalTrackerLog { + id: string; date: ISODate; + playtime: number; + userID: userID; + gameName: string; +} +interface internalTrackerGameAndUser { + playtime: number; + firstlog: string; + logs: number; + lastlogs: string[]; + games: TrackerSublog[]; + users: TrackerSublog[]; } -// -- MISC -- -// Types -export type ISODate = string; // ISO Date -export type ISODuration = string; // ISO Duration -export type jamID = string; // key of jamDb -export type pollID = string; // key of pollDb -export type proposalID = string; // key of proposalDb -export type userID = string; // Discord user id +// Just in case a referenced log wasnt found. +// Although there really isnt any reason why there should accrue such a case. +// Better safe than sorry and to make ts happy of course! +const unknownTrackerLog: TrackerLog = { + id: "-1", + date: 404, + playtime: -1, + userID: "0000000000000000000", + gameName: "[unknown log]", +}; + +export const trackerLogs = new Enmap({ + name: "trackerLogs", + serializer: (data) => ({ ...data, date: new Date(data.date).toISOString() }), + deserializer: (data) => ({ ...data, date: new Date(data.date).getTime() }), +}); +export const trackerUsers = new Enmap({ + name: "trackerUsers", + serializer: (data) => ({ + ...data, + firstlog: data.firstlog.id, + lastlogs: data.lastlogs.map((log) => log.id), + users: [], + }), + deserializer: (data) => ({ + ...data, + firstlog: trackerLogs.get(data.firstlog) ?? unknownTrackerLog, + lastlogs: data.lastlogs.map((logID) => trackerLogs.get(logID) ?? unknownTrackerLog), + }), +}); +export const trackerGames = new Enmap({ + name: "trackerGames", + serializer: (data) => ({ + ...data, + firstlog: data.firstlog.id, + lastlogs: data.lastlogs.map((log) => log.id), + games: [], + }), + deserializer: (data) => ({ + ...data, + firstlog: trackerLogs.get(data.firstlog) ?? unknownTrackerLog, + lastlogs: data.lastlogs.map((logID) => trackerLogs.get(logID) ?? unknownTrackerLog), + }), +}); +export const trackerBlacklist = new Enmap({ name: "trackerBlacklist" }); +trackerBlacklist.ensure("", []); + +/* +--------------------- +- Proposal Database - +--------------------- +*/ -// -- Proposal Database -- // key: unique id export const proposalDb = new Enmap({ name: "proposal", @@ -101,7 +216,12 @@ interface InternalProposal { created: ISODate; } -// -- Poll Database -- +/* +----------------- +- Poll Database - +----------------- +*/ + // key: unique id export const pollDb = new Enmap({ name: "poll", @@ -166,7 +286,12 @@ interface InternalPollEvent { date: ISODate; } -// -- Jam Database -- +/* +---------------- +- Jam Database - +---------------- +*/ + // key: unique id export const jamDb = new Enmap({ name: "jam", @@ -222,7 +347,12 @@ interface InternalJamEvent { date: ISODate; } -// -- Birthday Database -- +/* +--------------------- +- Birthday Database - +--------------------- +*/ + // key: user id export const birthdayDb = new Enmap({ name: "birthday", diff --git a/src/events/presenceUpdate.ts b/src/events/presenceUpdate.ts index 1852389..1d2a45c 100644 --- a/src/events/presenceUpdate.ts +++ b/src/events/presenceUpdate.ts @@ -1,31 +1,31 @@ import { Presence } from "discord.js"; +import { getChangedActivities, isBlacklisted, logIt } from "../util/tracker/presence"; import { config } from "../config"; -import { blacklistCheck, getChangedActivities, logTime } from "../util/activity-tracker/presence"; const userCache = new Map>(); export default async function presenceUpdate(oldPresence: Presence | null, newPresence: Presence) { - if (!config.logActivity || newPresence.user?.bot) return; + if (!config.tracking || newPresence.user?.bot) return; const { started, stopped } = await getChangedActivities(oldPresence, newPresence); if (!started.length && !stopped.length) return; - const userid = newPresence.userId; + const userID = newPresence.userId; started.forEach(async (activity) => { - if (await blacklistCheck(userid, activity.name.toLowerCase())) return; - userCache.set(userid, { ...userCache.get(userid), [activity.name]: Date.now() }); + if (isBlacklisted(activity.name)) return; + userCache.set(userID, { ...userCache.get(userID), [activity.name]: Date.now() }); }); stopped.forEach(async (activity) => { - const cachedUser = userCache.get(userid); + const cachedUser = userCache.get(userID); if (!cachedUser) return; const start = cachedUser[activity.name]; if (!start) return; delete cachedUser[activity.name]; - const timePlayed = Date.now() - start; - if (timePlayed < 20000) return; - if (await blacklistCheck(userid, activity.name.toLowerCase())) return; - await logTime(userid, activity.name.toLowerCase(), timePlayed); + // Played time in seconds ("~~" makes float to int) + const timePlayed = ~~((Date.now() - start) / 1000); + + await logIt(activity.name, userID, timePlayed); }); } diff --git a/src/events/ready.ts b/src/events/ready.ts index 3ff0d70..8ef4d98 100644 --- a/src/events/ready.ts +++ b/src/events/ready.ts @@ -3,7 +3,7 @@ import { ActivityType, Client } from "discord.js"; import { ctx } from "../ctx"; import { logger } from "../logger"; import CodingJamsCommand from "../commands/CodingJamsCommand"; -import { validateConfigParameters } from "../config-validate"; +import { validateConfigParameters } from "../util/config/config-validate"; import BirthdayCommand from "../commands/BirthdayCommand"; export default async function ready(client: Client) { @@ -22,19 +22,24 @@ export default async function ready(client: Client) { type: ActivityType.Watching, }); - logger.info("Publishing commands..."); + logger.debug("Publishing commands..."); await client.application?.commands.set( ctx.commands.map((cmd) => cmd.register().toJSON()), ctx.defaultGuild ); - logger.info("Starting coding-jam event scheduler..."); + if (!ctx.debugMode) { + logger.debug("Validating config parameters..."); + await validateConfigParameters(guild); + } + + logger.debug("Starting coding-jam event scheduler..."); CodingJamsCommand.startScheduler(client); - logger.info("Starting reminder scheduler..."); + logger.debug("Starting reminder scheduler..."); ReminderCommand.startScheduler(client); - logger.info("Starting birthday scheduler..."); + logger.debug("Starting birthday scheduler..."); BirthdayCommand.startScheduler(client); if (!guild.systemChannel) { @@ -44,10 +49,4 @@ export default async function ready(client: Client) { } logger.info("Setup successful!"); - - if (!ctx.debugMode) { - logger.debug("Validating config parameters..."); - await validateConfigParameters(guild); - logger.debug("Config parameters validated!"); - } } diff --git a/src/interactions/autocompleters/ActivityTrackerAutocomplete.ts b/src/interactions/autocompleters/ActivityTrackerAutocomplete.ts deleted file mode 100644 index 4e277ad..0000000 --- a/src/interactions/autocompleters/ActivityTrackerAutocomplete.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Autocompleter } from "../interactionClasses"; -import { AutocompleteInteraction } from "discord.js"; -import { - blacklistAdd, - blacklistAutocompletion, - blacklistRemove, - gamesAutocompletion, - statsMy, -} from "../../util/activity-tracker/autocompletes"; - -class ActivityTrackerAutocompleter extends Autocompleter { - constructor() { - super("activity-tracker"); - } - - async execute(interaction: AutocompleteInteraction): Promise { - const group = interaction.options.getSubcommandGroup(); - const sub = interaction.options.getSubcommand(); - - if (group === "admin") { - if (sub == "whitelist") await blacklistAutocompletion(interaction); - else if (sub == "blacklist") await gamesAutocompletion(interaction); - } else if (group === "stats") { - if (sub == "my") await statsMy(interaction); - else if (sub == "game") await gamesAutocompletion(interaction); - } else if (group === "blacklist") { - if (sub == "remove") await blacklistRemove(interaction); - else if (sub == "add") await blacklistAdd(interaction); - } - } -} - -export default new ActivityTrackerAutocompleter(); diff --git a/src/interactions/autocompleters/TrackerAutocomple.ts b/src/interactions/autocompleters/TrackerAutocomple.ts new file mode 100644 index 0000000..1c25202 --- /dev/null +++ b/src/interactions/autocompleters/TrackerAutocomple.ts @@ -0,0 +1,50 @@ +import { Autocompleter } from "../interactionClasses"; +import { AutocompleteInteraction } from "discord.js"; +import { trackerGames, trackerUsers } from "../../db"; +import { blacklist, user } from "../../util/tracker/autocompleteHelper"; + +class Tracker extends Autocompleter { + constructor() { + super("tracker"); + } + + async execute(interaction: AutocompleteInteraction): Promise { + const sub = interaction.options.getSubcommand(); + const focus = (interaction.options.getFocused() as string).toLowerCase(); + const option = interaction.options.getFocused(true).name; + + // if user-option is given, use it, else default to executer + const userId = interaction.options.get("user")?.value ?? interaction.user.id; + if (typeof userId !== "string") return; + + // if action-option is given, use it, else default to add + const action = interaction.options.get("action")?.value ?? "add"; + + // make ts happy... + if (typeof action != "string") return; + + let options: string[] = []; + + if (sub == "user") { + options = user(option, userId); + } else if (sub == "game") { + options = trackerGames.keyArray(); + } else if (sub == "blacklist") { + options = blacklist(action); + } else if (sub == "general") { + if (interaction.options.get("user")) { + options = trackerUsers.get(userId)?.games.map((g) => g.name) ?? ["[no games played yet]"]; + } else { + options = trackerGames.keyArray(); + } + } else if (option == "game") { + options = user(option, false); + } + + const filteredOptions = options.filter((o) => o.toLowerCase().startsWith(focus)).slice(0, 25); + + await interaction.respond(filteredOptions.map((c) => ({ name: c, value: c }))); + } +} + +export default new Tracker(); diff --git a/src/interactions/buttons/ActivityTrackerButton.ts b/src/interactions/buttons/ActivityTrackerButton.ts deleted file mode 100644 index 85164a6..0000000 --- a/src/interactions/buttons/ActivityTrackerButton.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Button } from "../interactionClasses"; -import { ButtonInteraction } from "discord.js"; -import { gameActivityTrackerEmbed } from "../../util/activity-tracker/trackerEmbed"; - -class ActivityTrackerButton extends Button { - constructor() { - super("activity-tracker"); - } - async execute(interaction: ButtonInteraction, args: string[]): Promise { - let offset = parseInt(args[1]); - const pages = parseInt(args[2]); - - const jump = Math.round(pages / 4); // jump by a quarter of the pages - - if (args[0] == "left") offset -= 1; - else if (args[0] == "jump-left") offset -= jump; - else if (args[0] == "right") offset += 1; - else if (args[0] == "jump-right") offset += jump; - - if (offset < 0) offset = pages == 1 ? 0 : pages + (offset % pages); - else offset = offset % pages; - - const sort = args[3]; - const order = args[4]; - - const [embed, row] = await gameActivityTrackerEmbed(sort, order, offset); - if (!embed) return; - - await interaction.update({ embeds: [embed], components: row ? [row] : [] }); - } -} - -export default new ActivityTrackerButton(); diff --git a/src/interactions/interactionLoader.ts b/src/interactions/interactionLoader.ts index e47a684..2108346 100644 --- a/src/interactions/interactionLoader.ts +++ b/src/interactions/interactionLoader.ts @@ -1,7 +1,6 @@ import { Autocompleter, Button, Command, Modal, SelectMenu } from "./interactionClasses"; import { logger } from "../logger"; import { Client, Collection } from "discord.js"; -import { config } from "../config"; import fs from "fs"; export async function loadEvents(client: Client) { @@ -30,10 +29,7 @@ export async function loadCommands(): Promise> { .filter(isActive) .map(async (filename) => { const _command = (await import(`../commands/${filename}`)).default as Command; - if (config.logActivity || _command.name !== "activity-tracker") { - // When logActivity from the config is false every command except the activity-tracker should be loaded. If not this will always be true and all the commands will be loaded. - loadedCommands.set(_command.name, _command); - } + loadedCommands.set(_command.name, _command); }) ); return loadedCommands; diff --git a/src/start.ts b/src/start.ts index f7fab38..cb97793 100644 --- a/src/start.ts +++ b/src/start.ts @@ -10,12 +10,12 @@ import { import { Client } from "discord.js"; import { ctx } from "./ctx"; import { logger } from "./logger"; -import { config } from "./config"; import { Settings } from "luxon"; +import { config } from "./config"; logger.debug("Creating client..."); const client = new Client({ - intents: ["Guilds", "GuildMembers", config.logActivity ? "GuildPresences" : "0"], + intents: ["Guilds", "GuildMembers", config.tracking ? "GuildPresences" : "0"], }); function shutdown(info: unknown) { diff --git a/src/util/activity-tracker/admin.ts b/src/util/activity-tracker/admin.ts deleted file mode 100644 index fab1bf6..0000000 --- a/src/util/activity-tracker/admin.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { ChatInputCommandInteraction, EmbedBuilder } from "discord.js"; -import { addEmbedColor, addEmbedFooter } from "../misc/embeds"; -import { activityTrackerBlacklistDb, activityTrackerLogDb } from "../../db"; -import { getBlacklist } from "./help"; -import { logger } from "../../logger"; -import { blacklistCodes } from "./blacklist"; - -export async function adminReset(interaction: ChatInputCommandInteraction): Promise { - const sure: boolean = interaction.options.getBoolean("sure", true); - const really: string = interaction.options.getString("really", true); - - if (!(sure && really == "yes")) { - await interaction.reply({ - content: "It seems like your are not really sure...\nThe reset wasn't executed.", - ephemeral: true, - }); - return; - } else { - activityTrackerLogDb.clear(); - activityTrackerBlacklistDb.clear(); - activityTrackerBlacklistDb.ensure("general-user", []); - activityTrackerBlacklistDb.ensure("general-game", []); - - await interaction.reply({ - content: "Reset done! I deleted all the logs and cleared the blacklist.", - ephemeral: true, - }); - } -} - -export async function adminBlacklistGame(interaction: ChatInputCommandInteraction): Promise { - const game = interaction.options.getString("game", true); - activityTrackerBlacklistDb.push("general-game", game.toLowerCase()); - - const embed = new EmbedBuilder() - .setTitle("Added a game to blacklist!") - .setDescription(`No activity about "${game}" will be logged anymore.`); - await interaction.reply({ embeds: [addEmbedColor(embed)], ephemeral: true }); -} - -export async function adminWhitelistGame(interaction: ChatInputCommandInteraction): Promise { - const game = interaction.options.getString("game", true).toLowerCase(); - - if (game == blacklistCodes.emptyGlobalBlacklist) { - // blacklistCodes.emptyGlobalBlacklist is returned by the autocomplete when global blacklist is empty - interaction.reply({ content: "The global blacklist is empty...", ephemeral: true }); - } - - let blacklistedGames: string[] | undefined = activityTrackerBlacklistDb.get("general-game"); - - if (!blacklistedGames) { - await interaction.reply({ content: "Something went wrong...", ephemeral: true }); - logger.error( - "Something went wrong while trying to get the global blacklist. Couldn't get the 'general-game' key." - ); - return; - } - - blacklistedGames = blacklistedGames?.filter((e) => e !== game); - - activityTrackerBlacklistDb.set("general-game", blacklistedGames); - - const embed = new EmbedBuilder() - .setTitle("Removed a game from the global blacklist!") - .setDescription(`Removed "${game}" from global blacklist.`); - await interaction.reply({ embeds: [addEmbedColor(embed)], ephemeral: true }); -} - -export async function adminLook(interaction: ChatInputCommandInteraction): Promise { - const user = interaction.options.getUser("user", true); - const blacklist = await getBlacklist(user.id); - - if (blacklist?.length == 0 || blacklist == undefined) { - const embed = new EmbedBuilder() - .setTitle(`${user.tag}'s blacklist`) - .setDescription( - "Tracking status: `" + - (activityTrackerBlacklistDb.get("general-user")?.includes(user.id) ? "disabled" : "enabled") + - "`\nBlacklisted games: No game is blacklisted. Every game gets logged" - ); - await interaction.reply({ embeds: [addEmbedColor(embed)], ephemeral: true }); - return; - } - - const str = "`" + blacklist.join("`, `") + "`"; - - const embed = new EmbedBuilder() - .setTitle(`${user.tag}'s blacklist`) - .setDescription( - "Trackstatus: `" + - (activityTrackerBlacklistDb.get("general-user")?.includes(user.id) ? "disabled" : "enabled") + - "`\nBlacklisted games: " + - str - ); - await interaction.reply({ embeds: [addEmbedColor(embed)], ephemeral: true }); -} - -export async function adminShow(interaction: ChatInputCommandInteraction) { - const blacklist: string[] | undefined = activityTrackerBlacklistDb.get("general-game"); - - if (!blacklist || blacklist.length == 0) { - let embed = new EmbedBuilder().setTitle("The global blacklist is empty..."); - embed = addEmbedFooter(embed); - await interaction.reply({ embeds: [embed], ephemeral: true }); - return; - } - - const str = "`" + blacklist?.join("`, `") + "`"; - const embed = new EmbedBuilder().setTitle("Global Blacklist").setDescription(str); - await interaction.reply({ embeds: [addEmbedColor(embed)], ephemeral: true }); -} diff --git a/src/util/activity-tracker/autocompletes.ts b/src/util/activity-tracker/autocompletes.ts deleted file mode 100644 index e2ca233..0000000 --- a/src/util/activity-tracker/autocompletes.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { AutocompleteInteraction } from "discord.js"; -import { activityTrackerBlacklistDb, activityTrackerLogDb } from "../../db"; -import { splitId } from "./help"; -import { hasAdminRole } from "../misc/permissions"; -import { blacklistCodes } from "./blacklist"; - -export async function blacklistAutocompletion(interaction: AutocompleteInteraction) { - if (await hasAdminRole(interaction)) { - await interaction.respond([ - { - name: "[Admin permissions are required to use this command!]", - value: blacklistCodes.missingPermission, - }, - ]); - return; - } - - let options: string[] | undefined = activityTrackerBlacklistDb.get("general-game"); - - options = [...new Set(options)]; - - if (options?.length === 0 || !options) { - await interaction.respond([ - { - name: "[The global blacklist is empty...]", - value: blacklistCodes.emptyGlobalBlacklist, - }, - ]); - return; - } - - let map = filterAndMapAutocompletion(interaction, options); - map = map.slice(0, 25); - - await interaction.respond(map); -} - -export async function statsMy(interaction: AutocompleteInteraction) { - const allKeys = activityTrackerLogDb.keyArray(); - let games: string[] = []; - allKeys.forEach((e) => { - const { user, game } = splitId(e); - if (user !== interaction.user.id) return; - games.push(game); - }); - - games = [...new Set(games)]; - - if (games.length == 0) { - await interaction.respond(noLogsYet); - return; - } - - let map = filterAndMapAutocompletion(interaction, games); - map = map.slice(0, 25); - - await interaction.respond(map); -} - -export async function gamesAutocompletion(interaction: AutocompleteInteraction) { - const allKeys = activityTrackerLogDb.keyArray(); - let games: string[] = []; - allKeys.forEach((e) => { - const gameEntry = splitId(e).game; - games.push(gameEntry); - }); - - games = [...new Set(games)]; - - if (games.length == 0) { - await interaction.respond(noLogsYet); - return; - } - - let map = filterAndMapAutocompletion(interaction, games); - map = map.slice(0, 25); - - await interaction.respond(map); -} - -export async function blacklistRemove(interaction: AutocompleteInteraction) { - let options: string[] | undefined = activityTrackerBlacklistDb.get(interaction.user.id); - - options = [...new Set(options)]; - - if (activityTrackerBlacklistDb.get("general-user")?.includes(interaction.user.id)) { - await interaction.respond([ - { - name: "[Tracking is disabled for you. Select this to activate it again]", - value: blacklistCodes.trackingDisabled, - }, - ]); - return; - } - - if (options?.length === 0 || !options) { - await interaction.respond([{ name: "No games are on your blacklist", value: "empty-blacklist" }]); - return; - } - - let map = filterAndMapAutocompletion(interaction, options); - map = map.slice(0, 25); - - await interaction.respond(map); -} - -export async function blacklistAdd(interaction: AutocompleteInteraction) { - const allKeys = activityTrackerLogDb.keyArray(); - let games: string[] = []; - allKeys.forEach((e) => { - const { user, game } = splitId(e); - if (user !== interaction.user.id) return; - games.push(game); - }); - - games = [...new Set(games)]; - - if (games.length == 0) { - await interaction.respond(noLogsYet); - return; - } - - let map = filterAndMapAutocompletion(interaction, games); - map = map.slice(0, 24); - - map.push({ name: "[Disable your tracking for all games]", value: blacklistCodes.disableTracking }); - - await interaction.respond(map); -} - -const noLogsYet = [{ name: "Nothing has been logged yet...", value: "Nothing has been logged yet..." }]; - -function filterAndMapAutocompletion(interaction: AutocompleteInteraction, input: string[]) { - return input - .filter((c) => c.startsWith(interaction.options.getFocused().toLowerCase() as string)) - .map((c) => ({ - name: c - .replace(/(\b\w)/g, (e) => e.toUpperCase()) - .trim() - .slice(0, 100), - value: c.slice(0, 100), - })); -} diff --git a/src/util/activity-tracker/blacklist.ts b/src/util/activity-tracker/blacklist.ts deleted file mode 100644 index 5a24af8..0000000 --- a/src/util/activity-tracker/blacklist.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { ChatInputCommandInteraction, EmbedBuilder } from "discord.js"; -import { addEmbedColor, addEmbedFooter } from "../misc/embeds"; -import { activityTrackerBlacklistDb, activityTrackerLogDb } from "../../db"; -import { getBlacklist, splitId } from "./help"; - -export const blacklistCodes = { - emptyBlacklist: "empty-blacklist", - missingPermission: "missing-permission", - emptyGlobalBlacklist: "empty-global-blacklist", - trackingDisabled: "tracking-disabled", - disableTracking: "disable-tracking", -}; - -export async function blacklistAdd(interaction: ChatInputCommandInteraction): Promise { - const game = interaction.options.getString("game", true); - if (game == "Disable Tracking") { - activityTrackerBlacklistDb.push("general-user", interaction.user.id); - let embed = new EmbedBuilder() - .setTitle("Your game activity won't get logged anymore!") - .setDescription( - "Tracking is now disabled for you.\nTo activate it again use `/tracking blacklist remove`" - ); - embed = addEmbedFooter(embed); - await interaction.reply({ embeds: [embed], ephemeral: true }); - return; - } - - if (!activityTrackerBlacklistDb.has(interaction.user.id)) { - activityTrackerBlacklistDb.set(interaction.user.id, []); - } - activityTrackerBlacklistDb.push(interaction.user.id, game.toLowerCase()); - await interaction.reply({ content: `"${game}" is now on your blacklist!`, ephemeral: true }); - - if ( - !activityTrackerLogDb - .keyArray() - .map(splitId) - .map((e) => e.game) - .find((e) => e === game.toLowerCase()) - ) { - await interaction.followUp({ - content: - "No logs across all users of that game exists. Maybe double check if you didn't make a spelling error (capitalization doesn't matter) (use the autocomplete!).", - }); - } -} - -export async function blacklistRemove(interaction: ChatInputCommandInteraction): Promise { - const game = interaction.options.getString("game", true); - - if (game === blacklistCodes.trackingDisabled) { - let blacklistedUser = activityTrackerBlacklistDb.get("general-user"); - if (!blacklistedUser) { - await interaction.reply({ content: "Something went wrong", ephemeral: true }); - return; - } - - blacklistedUser = blacklistedUser.filter((e) => e !== interaction.user.id); - - activityTrackerBlacklistDb.set("general-user", blacklistedUser); - interaction.reply({ content: "Your tracking is activated again!", ephemeral: true }); - return; - } - - let blacklistedGames = activityTrackerBlacklistDb.get(interaction.user.id); - - if (game === blacklistCodes.emptyBlacklist) { - interaction.reply({ content: "You didn't blacklist any games yet.", ephemeral: true }); - return; - } - - if (!blacklistedGames) { - await interaction.reply({ - content: "Cant remove any games from your blacklist because no games are blacklisted!", - ephemeral: true, - }); - return; - } - - blacklistedGames = blacklistedGames.filter((e) => e !== game); - - activityTrackerBlacklistDb.set(interaction.user.id, blacklistedGames); - - let embed = new EmbedBuilder().setTitle(`"${game}" is now removed from the blacklist`); - embed = addEmbedColor(embed); - await interaction.reply({ embeds: [embed], ephemeral: true }); -} - -export async function blacklistShow(interaction: ChatInputCommandInteraction): Promise { - const blacklist = await getBlacklist(interaction.user.id); - - if (blacklist?.length == 0 || blacklist == undefined) { - let embed = new EmbedBuilder() - .setTitle("Your Blacklist") - .setDescription( - "Tracking status: `" + - (activityTrackerBlacklistDb.get("general-user")?.includes(interaction.user.id) - ? "disabled" - : "enabled") + - "`\nBlacklisted games: No game is blacklisted. Logging every game." - ); - - embed = addEmbedFooter(embed); - await interaction.reply({ embeds: [embed], ephemeral: true }); - return; - } - - const str = "`" + blacklist.join("`, `") + "`"; - - let embed = new EmbedBuilder() - .setTitle("Your blacklist") - .setDescription( - "Trackstatus: `" + - (activityTrackerBlacklistDb.get("general-user")?.includes(interaction.user.id) - ? "disabled" - : "enabled") + - "`\nBlacklisted games: " + - str - ); - embed = addEmbedFooter(embed); - - await interaction.reply({ embeds: [embed], ephemeral: true }); -} diff --git a/src/util/activity-tracker/help.ts b/src/util/activity-tracker/help.ts deleted file mode 100644 index c24599f..0000000 --- a/src/util/activity-tracker/help.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { activityTrackerBlacklistDb, activityTrackerLogDb } from "../../db"; -import { EmbedField } from "discord.js"; -import { discordRelativeTimestamp, durationToReadable } from "../misc/time"; -import { DateTime, Duration } from "luxon"; - -export async function getBlacklist(userid: string): Promise { - if (!activityTrackerBlacklistDb.has(userid)) return []; - return activityTrackerBlacklistDb.get(userid); -} - -export function getEntries(user?: string, game?: string): string[] { - let entries = activityTrackerLogDb.keyArray().map((e) => splitId(e)); - - if (user) { - entries = entries.filter((e) => e.user === user); - } - - if (game) { - entries = entries.filter((e) => e.game === game); - } - - return entries.map((e) => joinId(e)); -} - -export async function makeStats(entries: string[]): Promise> { - if (entries.length == 0) { - return []; - } - - let firstEntry = DateTime.now().plus({ years: 1000 }); - let lastEntry = DateTime.now().minus({ years: 1000 }); - let playTime = Duration.fromObject({ seconds: 0 }); - let longestRecord = Duration.fromObject({ seconds: 0 }); - let totalRecords = 0; - - entries.forEach((key) => { - const logs = activityTrackerLogDb.get(key); - logs?.forEach((log) => { - totalRecords += 1; - playTime = playTime.plus(log.duration); - if (log.date < firstEntry) firstEntry = log.date; - if (log.date > lastEntry) lastEntry = log.date; - if (log.duration > longestRecord) longestRecord = log.duration; - }); - }); - - let dayDifference = lastEntry.diff(firstEntry).as("days"); - dayDifference = dayDifference < 1 ? 1 : dayDifference; - - const fields = [ - { name: "Record Range", value: `≈ ${Math.round(dayDifference)} days`, inline: true }, - { name: "Total Playtime", value: durationToReadable(playTime, true), inline: true }, - { - name: "Playtime/Day", - value: durationToReadable( - Duration.fromObject({ seconds: Math.floor(playTime.as("seconds") / dayDifference) }), - true - ), - inline: true, - }, - { - name: "First Record", - value: `${discordRelativeTimestamp(firstEntry)}`, - inline: true, - }, - { - name: "Last Record", - value: `${discordRelativeTimestamp(lastEntry)}`, - inline: true, - }, - { name: "Longest Record", value: durationToReadable(longestRecord, true), inline: true }, - { name: "Total Records", value: totalRecords.toString(), inline: true }, - ]; - - return fields; -} - -/** Splits the key into the userID and game */ -export function splitId(id: string): { user: string; game: string } { - const [user, ...gameParts] = id.split("-"); - const game = gameParts.join("-"); - return { user: user, game: game }; -} - -function joinId(element: { user: string; game: string }): string { - return `${element.user}-${element.game}`; -} diff --git a/src/util/activity-tracker/list.ts b/src/util/activity-tracker/list.ts deleted file mode 100644 index b557317..0000000 --- a/src/util/activity-tracker/list.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { activityTrackerLogDb } from "../../db"; -import { ChatInputCommandInteraction } from "discord.js"; -import { splitId } from "./help"; -import { gameActivityTrackerEmbed } from "./trackerEmbed"; -import { discordRelativeTimestamp, discordTimestamp, durationToReadable } from "../misc/time"; -import { DateTime, Duration } from "luxon"; - -interface log { - user: string; - game: string; - date: DateTime; -} - -export async function logHistoryList(offset: number, order: string) { - const keys = activityTrackerLogDb.keyArray(); - const logs: log[] = []; - - keys.forEach((e) => { - const log = activityTrackerLogDb.get(e); - if (!log) return; - - const key = splitId(e); - const user = key.user; - let game = key.game; - - if (game.length > 14) { - game = game.slice(0, 13); - game = game + "…"; - } - - log.forEach((l) => { - logs.push({ user: `<@!${user}>`, game: game, date: l.date }); - }); - }); - - logs.sort((a, b) => (a.date > b.date ? -1 : 1)); // decreasing - if (order == "increasing") logs.reverse(); - - const games: string[] = []; - const timeAndUser: string[] = []; - - logs.forEach((e) => { - games.push(e.game.replace(/(\b\w)/g, (e) => e.toUpperCase())); - timeAndUser.push(`${discordTimestamp(e.date)} ⁘ ${e.user}`); - }); - - return [ - games.slice(offset, offset + 10), // games - timeAndUser.slice(offset, offset + 10), // values - Math.ceil(games.length / 10), // pages - ]; -} - -export async function playtimePerGameList(offset: number, order: string) { - offset *= 10; - - const keys = activityTrackerLogDb.keyArray(); - - const playtimePerGame = new Map(); - - keys.forEach((e) => { - const log = activityTrackerLogDb.get(e); - if (!log) return; - - const game = e.split("-")[1]; - - const logs = log.length; - const playtime = Duration.fromObject({ seconds: 0 }); - - if (logs == 0) return; - - log.forEach((entry) => { - playtime.plus(entry.duration); - }); - - if (!playtimePerGame.has(game)) { - playtimePerGame.set(game, playtime); - } else { - const time = playtimePerGame.get(game); - if (time) time.plus(playtime); - else return; - playtimePerGame.set(game, time); - } - }); - - const entries = Array.from(playtimePerGame.entries()); - - entries.sort((a, b) => (a[1] > b[1] ? -1 : 1)); // decreasing - if (order == "increasing") entries.reverse(); - - const sorted = new Map(entries); - - const games: string[] = []; - const values: string[] = []; - sorted.forEach(async (v, k) => { - k = k.replace(/(\b\w)/g, (e) => e.toUpperCase()); - if (k.length > 14) { - k = k.slice(0, 13); - k = k + "…"; - } - - games.push(k); - values.push(durationToReadable(v, true)); - }); - - return [ - games.slice(offset, offset + 10), // games - values.slice(offset, offset + 10), // values - Math.ceil(values.length / 10), // pages - ]; -} - -export async function logDatePerGameList(offset: number, order: string) { - offset *= 10; - - const keys = activityTrackerLogDb.keyArray(); - - const logDatePerGame = new Map(); - - keys.forEach((e) => { - const log = activityTrackerLogDb.get(e); - if (!log) return; - - const game = e.split("-")[1]; - - const logs = log.length; - let lastplayed = DateTime.now().minus({ years: 1000 }); - - if (logs == 0) return; - - log.forEach((entry) => { - if (entry.date > lastplayed) lastplayed = entry.date; - }); - - if (!logDatePerGame.has(game)) { - logDatePerGame.set(game, lastplayed); - } else { - const time = logDatePerGame.get(game); - if (!time) return; - if (lastplayed > time) { - logDatePerGame.set(game, lastplayed); - } - } - }); - - const entries = Array.from(logDatePerGame.entries()); - - entries.sort((a, b) => (a[1] > b[1] ? -1 : 1)); // decreasing - if (order == "increasing") entries.reverse(); - - const sorted = new Map(entries); - - const games: string[] = []; - const values: string[] = []; - sorted.forEach(async (v, k) => { - k = k.replace(/(\b\w)/g, (e) => e.toUpperCase()); - if (k.length > 14) { - k = k.slice(0, 13); - k = k + "…"; - } - - games.push(k); - values.push(`${discordTimestamp(v)} ⁘ ${discordRelativeTimestamp(v)}`); - }); - - return [ - games.slice(offset, offset + 10), // games - values.slice(offset, offset + 10), // values - Math.ceil(values.length / 10), // pages - ]; -} - -export async function logsPerGameList(offset: number, order: string) { - offset *= 10; - - const keys = activityTrackerLogDb.keyArray(); - - const logsPerGame = new Map(); - - keys.forEach((e) => { - const log = activityTrackerLogDb.get(e); - if (!log) return; - - const game = e.split("-")[1]; - - const logs = log.length; - if (logs == 0) return; - - if (!logsPerGame.has(game)) { - logsPerGame.set(game, logs); - } else { - let time = logsPerGame.get(game); - if (time) time += logs; - else return; - logsPerGame.set(game, time); - } - return; - }); - - const entries = Array.from(logsPerGame.entries()); - - entries.sort((a, b) => b[1] - a[1]); // decreasing - if (order == "increasing") entries.reverse(); - - const sorted = new Map(entries); - - const games: string[] = []; - const values: string[] = []; - sorted.forEach(async (v, k) => { - k = k.replace(/(\b\w)/g, (e) => e.toUpperCase()); - if (k.length > 14) { - k = k.slice(0, 13); - k = k + "…"; - } - - games.push(k); - values.push(`${v}`); - }); - - return [ - games.slice(offset, offset + 10), // games - values.slice(offset, offset + 10), // values - Math.ceil(values.length / 10), // pages - ]; -} - -export async function list(interaction: ChatInputCommandInteraction) { - await interaction.deferReply(); - const sort = interaction.options.getString("sort") || "log-history"; - const order = interaction.options.getString("order") || "decreasing"; - - const [embed, row] = await gameActivityTrackerEmbed(sort, order); - if (!embed) return; - - await interaction.editReply({ - embeds: [embed], - components: row ? [row] : [], - }); -} diff --git a/src/util/activity-tracker/presence.ts b/src/util/activity-tracker/presence.ts deleted file mode 100644 index e6ae578..0000000 --- a/src/util/activity-tracker/presence.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Activity, ActivityType, Presence } from "discord.js"; -import { activityTrackerBlacklistDb, activityTrackerLogDb } from "../../db"; -import { DateTime, Duration } from "luxon"; - -export async function getChangedActivities( - oldPresence: Presence | null, - newPresence: Presence -): Promise<{ started: Activity[]; stopped: Activity[] }> { - const oldActivities = oldPresence?.activities.filter((value) => value.type === ActivityType.Playing); - const newActivities = newPresence.activities.filter((value) => value.type === ActivityType.Playing); - - const stopped: Activity[] = []; - oldActivities?.forEach((activity) => { - if (!newActivities.some((newActivity) => newActivity.name === activity.name)) stopped.push(activity); - }); - - const started: Activity[] = []; - newActivities.forEach((activity) => { - if (!oldActivities?.some((oldActivity) => oldActivity.name === activity.name)) started.push(activity); - }); - - return { started, stopped }; -} - -export async function blacklistCheck(userid: string, elementName: string): Promise { - if (activityTrackerBlacklistDb.get("general-user")?.includes(userid)) return true; - if (activityTrackerBlacklistDb.get("general-game")?.includes(elementName)) return true; - if (activityTrackerBlacklistDb.has(userid)) { - if (activityTrackerBlacklistDb.get(userid)?.includes(elementName)) return true; - } - return false; -} - -export async function logTime(userid: string, elementName: string, timePlayed: number): Promise { - const name = elementName.replace(/[^\x20-\x7F]/g, "").trim(); - if (name.length == 0) return; - if (!activityTrackerLogDb.has(`${userid}-${name}`)) activityTrackerLogDb.set(`${userid}-${name}`, []); - activityTrackerLogDb.push(`${userid}-${name}`, { - duration: Duration.fromMillis(timePlayed), - date: DateTime.now(), - }); -} diff --git a/src/util/activity-tracker/statistics.ts b/src/util/activity-tracker/statistics.ts deleted file mode 100644 index abf8a1a..0000000 --- a/src/util/activity-tracker/statistics.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { ChatInputCommandInteraction, EmbedBuilder } from "discord.js"; -import { getEntries, makeStats, splitId } from "./help"; -import { addEmbedFooter } from "../misc/embeds"; -import { activityTrackerLogDb } from "../../db"; -import { durationToReadable } from "../misc/time"; -import { Duration } from "luxon"; - -export async function statsMy(interaction: ChatInputCommandInteraction): Promise { - let game = interaction.options.getString("game")?.toLowerCase(); - - const entries = getEntries(interaction.user.id, game); - - if (entries.length == 0) { - await interaction.reply({ - content: "No logs found...", - ephemeral: true, - }); - return; - } - - const fields = await makeStats(entries); - - if (game === null) { - const games: string[] = []; - entries.forEach((e) => { - games.push(splitId(e).game); - }); - - let embed = new EmbedBuilder() - .setTitle("Your stats across all games") - .addFields(fields) - .addFields({ name: "Games", value: `${games} unique games`, inline: true }); - embed = addEmbedFooter(embed); - await interaction.reply({ embeds: [embed] }); - return; - } - - if (game === undefined) { - game = "every logged game"; - } else { - game = game.replace(/(\b\w)/g, (e) => e.toUpperCase()); - } - - let embed = new EmbedBuilder().setTitle(`Your stats about ${game}`).addFields(fields); - embed = addEmbedFooter(embed); - await interaction.reply({ embeds: [embed] }); -} - -export async function statsGame(interaction: ChatInputCommandInteraction): Promise { - const game = interaction.options.getString("game", true).toLowerCase(); - const showPlaytime = interaction.options.getBoolean("show-playtime") ?? false; - - const entries = await getEntries(undefined, game); - const fields = await makeStats(entries); - - if (fields.length == 0) { - let embed = new EmbedBuilder().setTitle(`No logs found for ${game}...`); - embed = addEmbedFooter(embed); - await interaction.reply({ embeds: [embed] }); - return; - } - - const users: string[] = []; - entries.forEach((e) => { - users.push(splitId(e).user); - }); - if (users.length === 0) { - await interaction.reply({ content: "No records found...", ephemeral: true }); - return; - } - - let embed = new EmbedBuilder() - .setTitle(`Stats across all users for ${game.replace(/(\b\w)/g, (e) => e.toUpperCase())}!`) - .addFields(fields) - .addFields({ name: "Users", value: `${users.length} unique gaymers :)`, inline: true }); - - if (showPlaytime) { - const entries = activityTrackerLogDb.filter((val, key) => splitId(key).game === game); - let playtime = new Map(); - users.forEach((u) => { - playtime.set( - u, - Array.from(entries.filter((val, key) => splitId(key).user === u).values()) - .flat() - .reduce((a, b) => a.plus(b.duration), Duration.fromObject({ seconds: 0 })) - ); - }); - - playtime = new Map( - Array.from(playtime.entries()) - .filter((u) => u[1]) - .sort((a, b) => (a[1] > b[1] ? -1 : 1)) - .slice(0, 10) - ); - - embed.addFields({ - name: "Playtime top 10", - value: Array.from(playtime.entries()) - .map((e) => `${durationToReadable(e[1], true)} ⁘ <@${e[0]}>`) - .join("\n"), - }); - } - - embed = addEmbedFooter(embed); - await interaction.reply({ embeds: [embed] }); - return; -} - -export async function statsAll(interaction: ChatInputCommandInteraction): Promise { - const entries = getEntries(); - const fields = await makeStats(entries); - - if (fields.length == 0) { - await interaction.reply({ content: "No logs found...", ephemeral: true }); - return; - } - - const users: string[] = []; - const games: string[] = []; - entries.forEach((e) => { - const { user, game } = splitId(e); - if (!users.some((e) => e === user)) users.push(user); - if (!games.some((e) => e === game)) games.push(game); - }); - - let embed = new EmbedBuilder() - .setTitle("Stats across all users and games") - .addFields(fields) - .addFields( - { name: "Users", value: `${users.length} unique users`, inline: true }, - { name: "Games", value: `${games.length} unique games`, inline: true } - ); - - embed = addEmbedFooter(embed); - await interaction.reply({ embeds: [embed] }); -} diff --git a/src/util/activity-tracker/trackerEmbed.ts b/src/util/activity-tracker/trackerEmbed.ts deleted file mode 100644 index acd42a1..0000000 --- a/src/util/activity-tracker/trackerEmbed.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from "discord.js"; -import { logDatePerGameList, logHistoryList, logsPerGameList, playtimePerGameList } from "./list"; -import { addEmbedFooter } from "../../util/misc/embeds"; - -export async function gameActivityTrackerEmbed( - sort: string, - order: string, - offset = 0 -): Promise<[EmbedBuilder | null, ActionRowBuilder | null]> { - let games; - let values; - let pages; - - if (sort == "playtime-per-game") { - [games, values, pages] = await playtimePerGameList(offset, order); - } else if (sort == "logs-per-game") { - [games, values, pages] = await logsPerGameList(offset, order); - } else if (sort == "log-date-per-game") { - [games, values, pages] = await logDatePerGameList(offset, order); - } else { - [games, values, pages] = await logHistoryList(offset, order); - } - - if (!Array.isArray(values) || !Array.isArray(games) || typeof pages !== "number") return [null, null]; - - if (values.length == 0) { - const embed = new EmbedBuilder() - .setTitle("Nothing to list...") - .setDescription("No activity has been logged yet."); - return [embed, null]; - } - - const text: { [key: string]: string } = { - "log-history": "in order they were logged", - "playtime-per-game": "by the playtime per game", - "logs-per-game": "by the number of logs per game", - "log-date-per-game": "last log date per game", - }; - - let embed = new EmbedBuilder() - .setTitle("Listing/Ranking") - .setDescription(`Logs sorted ${text[sort]}.\nList order is ${order}.`) - .addFields( - { - name: "#", - value: [...Array(games.length).keys()] - .map((i) => - (i + 10 * offset).toString().padStart((games.length + 10 * offset).toString().length, "0") - ) - .join("\n"), - inline: true, - }, - { name: "Game", value: games.join("\n"), inline: true }, - { name: "Value", value: values.join("\n"), inline: true } - ) - .setFooter({ text: `Page ${offset + 1}/${pages}` }); - - embed = addEmbedFooter(embed); - - const row = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(`game-activity-tracker.jump-left.${offset}.${pages}.${sort}.${order}`) - .setLabel("◀◀") - .setStyle(ButtonStyle.Primary) - .setDisabled(pages == 1), - new ButtonBuilder() - .setCustomId(`game-activity-tracker.left.${offset}.${pages}.${sort}.${order}`) - .setLabel("◀") - .setStyle(ButtonStyle.Primary) - .setDisabled(pages == 1), - new ButtonBuilder() - .setCustomId(`game-activity-tracker.right.${offset}.${pages}.${sort}.${order}`) - .setLabel("▶") - .setStyle(ButtonStyle.Primary) - .setDisabled(pages == 1), - new ButtonBuilder() - .setCustomId(`game-activity-tracker.jump-right.${offset}.${pages}.${sort}.${order}`) - .setLabel("▶▶") - .setStyle(ButtonStyle.Primary) - .setDisabled(pages == 1), - new ButtonBuilder() - .setCustomId(`game-activity-tracker.reload.${offset}.${pages}.${sort}.${order}`) - .setLabel("↺") - .setStyle(ButtonStyle.Success) - ); - - return [embed, row]; -} diff --git a/src/config-validate.ts b/src/util/config/config-validate.ts similarity index 63% rename from src/config-validate.ts rename to src/util/config/config-validate.ts index 0a00fbe..caa68fe 100644 --- a/src/config-validate.ts +++ b/src/util/config/config-validate.ts @@ -1,5 +1,5 @@ import { Guild } from "discord.js"; -import { config } from "./config"; +import { config } from "../../config"; import { channelExists, emojiExists, @@ -7,7 +7,7 @@ import { isValidHexColor, isValidURl, roleExists, -} from "./util/misc/verify"; +} from "../misc/verify"; export async function validateConfigParameters(guild: Guild) { // URLs @@ -15,7 +15,7 @@ export async function validateConfigParameters(guild: Guild) { urls .map((url) => ({ url, valid: isValidURl(url) })) .forEach((url) => { - if (!url.valid) throw new Error(`Invalid URL: ${url.url}`); + if (!url.valid) throw new Error(`iconURL || githubURL: ${url} is not a valid URL`); }); // Colors @@ -23,7 +23,7 @@ export async function validateConfigParameters(guild: Guild) { colors .map((color) => ({ color, valid: isValidHexColor(color as string) })) .forEach((color) => { - if (!color.valid) throw new Error(`Invalid color: ${color.color}`); + if (!color.valid) throw new Error(`color || colorRoles: ${color} is not a valid Color`); }); // Strings @@ -33,7 +33,12 @@ export async function validateConfigParameters(guild: Guild) { str .map((string) => ({ string, valid: isNotEmpty(string) })) .forEach((string) => { - if (!string.valid) throw new Error(`Invalid string: ${string.string || "[empty string]"}`); + if (!string.valid) + throw new Error( + `botName || serverDescription || jamRoleName || colorRoles || pronounRoles: ${ + string.string || "[empty string]" + } is not a valid String` + ); }); // Roles @@ -41,7 +46,8 @@ export async function validateConfigParameters(guild: Guild) { roles .map((role) => ({ role, valid: roleExists(guild, role) })) .forEach(async (role) => { - if (!(await role.valid)) throw new Error(`Invalid role: ${role.role}`); + if (!(await role.valid)) + throw new Error(`adminRoleId || moderatorRoleId: ${role.role} is not a valid role ID`); }); // Channels @@ -49,7 +55,10 @@ export async function validateConfigParameters(guild: Guild) { channels .map((channel) => ({ channel, valid: channelExists(guild, channel) })) .forEach(async (channel) => { - if (!(await channel.valid)) throw new Error(`Invalid channel/category: ${channel.channel}`); + if (!(await channel.valid)) + throw new Error( + `hamChannelId || pollChannelId || resultCategoryId: ${channel.channel} is not a valid channel/category` + ); }); // Emojis @@ -60,6 +69,6 @@ export async function validateConfigParameters(guild: Guild) { valid: typeof emoji === "string" ? emojiExists(guild, emoji) : emoji === null, })) .forEach(async (emoji) => { - if (!(await emoji.valid)) throw new Error(`Invalid emoji: ${emoji.emoji}`); + if (!(await emoji.valid)) throw new Error(`pronounRoles: ${emoji.emoji} is not a valid emoji`); }); } diff --git a/src/util/config/configInterface.ts b/src/util/config/configInterface.ts new file mode 100644 index 0000000..3f137d3 --- /dev/null +++ b/src/util/config/configInterface.ts @@ -0,0 +1,74 @@ +import { ColorResolvable, ComponentEmojiResolvable } from "discord.js"; + +/** Interface used in ./src/config.ts */ +export interface BotConfig { + // --- General --- + /** Name of the bot. */ + botName: string; + /** URL to a png or jpg of the logo of the bot. */ + iconURL: string; + /** URL to the github repository of the bot. Please share your code! */ + githubURL: string; + /** A description of the server that will be displayed in the welcome message. */ + serverDescription: string; + /** The main color the bot will use. */ + color: ColorResolvable; + + // --- Development --- + /** Also available: error, warn, info, http, verbose, debug, silly. */ + logLevel: "debug" | "info" | "warn" | "error" | "verbose"; + + // --- Management --- + /** Moderators of the server (to manage proposals) */ + moderatorRoleId: string; + /** Admins of the server (to manage the game activity tracker) */ + adminRoleId: string; + + // --- Polls & Coding Jams --- + /** Active Jam participants role name (will be pinged for announcements) */ + jamRoleName: string; + /** The text channel where the poll system will post polls. */ + pollChannelId: string; + /** The text channel where the jam system will post announcements to jams. */ + jamChannelId: string; + /** The category where the jam system will create channels for jams. */ + resultCategoryId: string; + + // --- Roles --- + /** Pronoun roles to pick from. First argument is the name of the role, second argument is the emoji id (You get the id by typing "\\[insert emoji]" in Discord). */ + pronounRoles: [string, ComponentEmojiResolvable | null][]; + /** Max 25 color roles to pick from. First argument is the name of the role, second argument is the color. */ + colorRoles: [string, ColorResolvable][]; + + // --- Birthday Feature --- + /** Can be any integer between 0 and 23. Defines at what time the birthday message should get send. */ + birthdayNotificationAt: + | 0 + | 1 + | 2 + | 3 + | 4 + | 5 + | 6 + | 7 + | 8 + | 9 + | 10 + | 11 + | 12 + | 13 + | 14 + | 15 + | 16 + | 17 + | 18 + | 19 + | 20 + | 21 + | 22 + | 23; + + // --- Tracker --- + /** Enable or Disable the tracking feature (if disabled, tracking-commands and logging wont be available anymore, but tracking-Database wont be cleared!) */ + tracking: boolean; +} diff --git a/src/util/misc/embeds.ts b/src/util/misc/embeds.ts index d3fa4a7..a4f0195 100644 --- a/src/util/misc/embeds.ts +++ b/src/util/misc/embeds.ts @@ -1,8 +1,8 @@ import { EmbedBuilder } from "discord.js"; import { config } from "../../config"; -export function addEmbedFooter(embed: EmbedBuilder, timestamp = false): EmbedBuilder { - embed = embed.setColor(config.color).setAuthor({ +export function addEmbedFooter(embed: EmbedBuilder, timestamp = false, color = config.color): EmbedBuilder { + embed = embed.setColor(color).setAuthor({ name: `Made by me, ${config.botName} :)`, iconURL: config.iconURL, url: config.githubURL, diff --git a/src/util/misc/time.ts b/src/util/misc/time.ts index 7669486..7c1b796 100644 --- a/src/util/misc/time.ts +++ b/src/util/misc/time.ts @@ -1,12 +1,65 @@ import { ChatInputCommandInteraction, CommandInteraction, ModalSubmitInteraction } from "discord.js"; import { DateTime, Duration } from "luxon"; -export function discordTimestamp(datetime: DateTime | number): string { - return ``; +function makeTimestamp(datetime: DateTime | number, end?: string): string { + return ``; } +/** + * Make a discord Timestamp which displays like this: `1 January 1970 01:00` \ + * @param datetime DateTime object or number in Seconds */ +export function discordTimestamp(datetime: DateTime | number): string { + return makeTimestamp(datetime); +} +/** + * Make a discord Timestamp which displays like this: `53 years ago` \ + * @param datetime DateTime object or number in Seconds */ export function discordRelativeTimestamp(datetime: DateTime | number): string { - return ``; + return makeTimestamp(datetime, "R"); +} +/** + * Make a discord Timestamp which displays like this: `01:00` \ + * @param datetime DateTime object or number in Seconds */ +export function discordShortTimeTimestamp(datetime: DateTime | number): string { + return makeTimestamp(datetime, "t"); +} +/** + * Make a discord Timestamp which displays like this: `01:00:00` \ + * @param datetime DateTime object or number in Seconds */ +export function discordLongTimeTimestamp(datetime: DateTime | number): string { + return makeTimestamp(datetime, "T"); +} +/** + * Make a discord Timestamp which displays like this: `01/01/1970` \ + * @param datetime DateTime object or number as Seconds */ +export function discordShortDateTimestamp(datetime: DateTime | number): string { + return makeTimestamp(datetime, "d"); +} +/** + * Make a discord Timestamp which displays like this: `1 January 1970` \ + * @param datetime DateTime object or number in Seconds */ +export function discordLongDateTimestamp(datetime: DateTime | number): string { + return makeTimestamp(datetime, "D"); +} +/** + * Make a discord Timestamp which displays like this: `1 January 1970 01:00` \ + * @param datetime DateTime object or number in Seconds */ +export function discordLongDateWithShortTimeTimestamp(datetime: DateTime | number): string { + return makeTimestamp(datetime, "f"); +} +/** + * Make a discord Timestamp which displays like this: `Thursday, 1 January 1970 01:00` \ + * @param datetime DateTime object or number in Seconds */ +export function discordLongDateWithDateOfWeekAndShortTimeTimestamp(datetime: DateTime | number): string { + return makeTimestamp(datetime, "F"); +} +/** + * Make a custom Timestamp which displays like this: `01/01/1970 01:00` \ + * @param datetime DateTime object or number in Seconds */ +export function shortDateAndShortTimeTimestamp(datetime: DateTime | number): string { + return discordShortDateTimestamp(datetime) + discordShortTimeTimestamp(datetime); } export function durationToReadable(duration: Duration, short = false): string { @@ -56,3 +109,16 @@ export async function checkDuration( return null; } else return iso; } + +// 60 seconds * 60minutes +export const hourInSeconds = 60 * 60; +export const hourInMillis = hourInSeconds * 1000; +// 1 hour * 24 hours = One day +export const dayInSeconds = hourInSeconds * 24; +export const dayInMillis = hourInMillis * 24; +// 7 times a day = week +export const weekInSeconds = dayInSeconds * 7; +export const weekInMillis = dayInMillis * 7; +// 4 times a week + 2.4 days = (average) month +export const monthInSeconds = weekInSeconds * 4 + dayInSeconds * 2.4167; +export const monthInMillis = weekInMillis * 4 + dayInMillis * 2.4167; diff --git a/src/util/tracker/autocompleteHelper.ts b/src/util/tracker/autocompleteHelper.ts new file mode 100644 index 0000000..98f24e3 --- /dev/null +++ b/src/util/tracker/autocompleteHelper.ts @@ -0,0 +1,47 @@ +import { trackerBlacklist, trackerGames, trackerUsers } from "../../db"; + +export function user(option: string, userid: string | number | boolean | undefined) { + const results: string[] = []; + + if (option == "game") { + if (typeof userid == "string") { + if (trackerUsers.get(userid)?.games) { + // list games a user has played (if user has played anything) + trackerUsers.get(userid)?.games.forEach((g) => results.push(g.name)); + } else { + // show this when user didnt play any games + results.push("[user has not played any games yet]"); + } + } else { + // list all games in case user hasnt filled in the user-option yet + results.push(...trackerGames.keyArray()); + } + } else if (option == "statistic") { + // only show playtime and logs if a game is given, else show standard options + results.push("general statistics", "top 5 most played games", "top 5 most logged games"); + } + + return results; +} + +export function blacklist(action: string) { + const results = []; + + if (action == "add" || action == "rem") { + // only show not blacklisted games when "add" is chosen as the action, else only show blacklisted games + results.push( + ...trackerGames + .keyArray() + .filter((e) => + action == "add" + ? !trackerBlacklist.get("")?.find((e2) => e2 == e) + : !!trackerBlacklist.get("")?.find((e2) => e2 == e) + ) + ); + } else { + // show all games in case action-option isnt filled in yet + results.push(...trackerGames.keyArray()); + } + + return results; +} diff --git a/src/util/tracker/blacklistCommands.ts b/src/util/tracker/blacklistCommands.ts new file mode 100644 index 0000000..ee919f3 --- /dev/null +++ b/src/util/tracker/blacklistCommands.ts @@ -0,0 +1,52 @@ +import { ChatInputCommandInteraction } from "discord.js"; +import { trackerBlacklist } from "../../db"; +import { hasAdminPerms } from "../misc/permissions"; +import { + adminOnly, + gameNotOnBlacklist, + gameOnBlacklist, + makeGameAddedMessage, + makeGameRemovedMessage, +} from "./messages"; + +export async function addBlacklist(interaction: ChatInputCommandInteraction) { + // check for admin permissions + if (!(await hasAdminPerms(interaction))) { + interaction.reply(adminOnly); + return; + } + + // get game option + const game = interaction.options.getString("game", true); + // check if game is already on blacklist + if (trackerBlacklist.get("")?.find((g) => g.toLowerCase() == game.toLowerCase())) { + interaction.reply(gameOnBlacklist); + return; + } + // add game to blacklist + trackerBlacklist.push("", game.toLowerCase()); + interaction.reply(makeGameAddedMessage(game)); +} +export async function remBlacklist(interaction: ChatInputCommandInteraction) { + // check for admin permissions + if (!(await hasAdminPerms(interaction))) { + interaction.reply(adminOnly); + return; + } + + // get game option + const game = interaction.options.getString("game", true); + const db = trackerBlacklist.get(""); + if (!db) return; // make ts happy... + if (db?.find((g) => g.toLowerCase() == game.toLowerCase())) { + // remove game + trackerBlacklist.set( + "", + db.filter((g) => g.toLowerCase() != game.toLowerCase()) + ); + interaction.reply(makeGameRemovedMessage(game)); + return; + } + // send error + interaction.reply(gameNotOnBlacklist); +} diff --git a/src/util/tracker/gameCommands.ts b/src/util/tracker/gameCommands.ts new file mode 100644 index 0000000..5d859d8 --- /dev/null +++ b/src/util/tracker/gameCommands.ts @@ -0,0 +1,170 @@ +import { APIEmbedField, ChatInputCommandInteraction, EmbedBuilder } from "discord.js"; +import { config } from "../../config"; +import { trackerGames, trackerLogs, TrackerSublog } from "../../db"; +import { addEmbedFooter } from "../misc/embeds"; +import { + dayInSeconds, + discordLongDateTimestamp, + discordLongDateWithShortTimeTimestamp, + discordShortDateTimestamp, + monthInSeconds, + weekInSeconds, +} from "../misc/time"; +import { makeTimeString, sortDbToString } from "./helper"; +import { gameNoEntry } from "./messages"; + +export async function gameStats(interaction: ChatInputCommandInteraction) { + // get game option + const targetGame = interaction.options.getString("game", true); + + // load tracker Games db + const db = trackerGames.get(targetGame.toLocaleLowerCase()); + if (!db) { + await interaction.reply(gameNoEntry); + return; + } + + // get top 5 played games and make a string + const mostPlayed = sortDbToString( + db.users, + (a, b) => b.playtime - a.playtime, + (user) => `<@${user.name}>: ${makeTimeString(user.playtime)}` + ); + + // get top 5 logged games and make a string + const mostLogged = sortDbToString( + db.users, + (a, b) => b.logs - a.logs, + (user) => `<@${user.name}>: ${user.logs} logs` + ); + + // format latest logs into a string + const latestLogs = db.lastlogs + .reverse() + .map((log) => `${discordShortDateTimestamp(log.date / 1000)} <@${trackerLogs.get(log.id)?.userID}>`) + .join("\n"); + // get total playtime, logs and users + const totalPlaytime = db.playtime; + const totalLogs = db.logs; + const users = db.users.length; + // get first log of game + const firstSeen = db.firstlog.date; + // calculate the range from first log to now + const range = ~~((Date.now() - firstSeen) / 1000); + + const playtimePerDay = makeTimeString(totalPlaytime / (range / dayInSeconds)); + const playtimePerWeek = makeTimeString(totalPlaytime / (range / weekInSeconds)); + const playtimePerMonth = makeTimeString(totalPlaytime / (range / monthInSeconds)); + const playtimePerUser = makeTimeString(totalPlaytime / users); + const playtimePer = `day: ${playtimePerDay}\nweek: ${playtimePerWeek}\nmonth: ${playtimePerMonth}\nuser: ${playtimePerUser}`; + + const logsPerDay = Math.round(totalLogs / (range / dayInSeconds)); + const logsPerWeek = Math.round(totalLogs / (range / weekInSeconds)); + const logsPerMonth = Math.round(totalLogs / (range / monthInSeconds)); + const logsPerUser = Math.round(totalLogs / users); + const logsPer = `day: ${logsPerDay}\nweek: ${logsPerWeek}\nmonth: ${logsPerMonth}\nuser: ${logsPerUser}`; + + const embed = new EmbedBuilder() + .setColor(config.color) + .setTitle(`Tracking stats about ${targetGame}`) + .addFields( + { inline: true, name: "Most playtime", value: mostPlayed }, + { inline: true, name: "Most logs", value: mostLogged }, + { inline: true, name: "\u200B", value: "\u200B" }, + { inline: true, name: "(Average) playtime per", value: playtimePer }, + { inline: true, name: "(Average) logs per", value: logsPer }, + { inline: true, name: "\u200B", value: "\u200B" }, + { + inline: false, + name: "In total", + value: `Time range: ${discordLongDateTimestamp( + Math.floor(firstSeen / 1000) + )} -> ${discordLongDateTimestamp(db.lastlogs.reverse()[0].date / 1000)}\nPlaytime: ${makeTimeString( + totalPlaytime + )}\nLogs: ${totalLogs}, Users: ${users}`, + }, + { inline: false, name: "Latest logs", value: latestLogs } + ); + + await interaction.reply({ embeds: [addEmbedFooter(embed)] }); +} +export async function gameLast(interaction: ChatInputCommandInteraction) { + // get the target game + const targetGame = interaction.options.getString("game", true); + // load games db + const db = trackerGames.get(targetGame); + if (!db) { + await interaction.reply(gameNoEntry); + return; + } + + // get latest logs + const logs = db.lastlogs.reverse(); + // store future embed fields + const fields: APIEmbedField[] = []; + + // make embed field for every log + await logs.forEach(async (log) => { + // get and validate log (db.lastlogs is a list of numbers as strings, not the actual log) + if (!log) return; + // get user who owns the log + const user = await interaction.client.users.fetch(log.userID); + // skip if user doesnt exist anymore + if (!user) return; + + // make field + fields.push({ + inline: true, + name: user.username, + value: `${discordLongDateWithShortTimeTimestamp(log.date)}\n${makeTimeString(log.playtime)}`, + }); + }); + + // add a last field so embed looks better formatted + fields.push({ inline: true, name: "_ _", value: "_ _" }); + + const embed = addEmbedFooter( + new EmbedBuilder().setTitle(`Latest logs of ${targetGame}`).addFields(...fields) + ); + + await interaction.reply({ embeds: [embed] }); +} +export async function gameTop(interaction: ChatInputCommandInteraction, filter: string) { + // get target game + const targetGame = interaction.options.getString("game", true); + // load games db + const db = trackerGames.get(targetGame); + if (!db) { + await interaction.reply(gameNoEntry); + return; + } + + // get users who played the game and sort them based of the filter and limit range to 0..5 + const users = db.users + .sort((a, b) => (filter == "playtime" ? b.playtime - a.playtime : b.logs - a.logs)) + .splice(0, 5); + + // future embed fields + const fields: APIEmbedField[] = []; + + // add a field per user + await users.forEach(async (user) => { + fields.push({ + inline: true, + name: (await interaction.client.users.fetch(user.name)).username, + value: + filter == "playtime" + ? makeTimeString(user.playtime) + "\n" + user.logs + " logs" + : user.logs + " logs\n" + makeTimeString(user.playtime), + }); + }); + + // add one final field for formatting purposes + fields.push({ inline: true, name: "_ _", value: "_ _" }); + + const embed = addEmbedFooter( + new EmbedBuilder().setTitle(`Top user (${filter}) by ${targetGame}`).addFields(...fields) + ); + + await interaction.reply({ embeds: [embed] }); +} diff --git a/src/util/tracker/helper.ts b/src/util/tracker/helper.ts new file mode 100644 index 0000000..d0da45a --- /dev/null +++ b/src/util/tracker/helper.ts @@ -0,0 +1,43 @@ +import { InteractionReplyOptions } from "discord.js"; +import { Duration } from "luxon"; +import { trackerUsers } from "../../db"; +import { durationToReadable } from "../misc/time"; + +/** Uses `durationToReadable` from src/util/misc/time.ts but without the need to parse a Duration object */ +export function makeTimeString(timeSeconds: number) { + const str = durationToReadable(Duration.fromMillis(Math.round(timeSeconds) * 1000), true); + return str.length >= 1 ? str : "0s"; +} +/** Sorts users games after playtime of a user (most playtime at index 0)*/ +export function sortGamesPlaytime(userId: string) { + const data = trackerUsers.get(userId); + return data?.games.sort((a, b) => b.playtime - a.playtime); +} +/** Sorts users games after amount of logs of a user (most logs at index 0) */ +export function sortGamesLogs(userId: string) { + const data = trackerUsers.get(userId); + return data?.games.sort((a, b) => b.logs - a.logs); +} +/** Make an error embed */ +export function errorMessage(title: string, info: string): InteractionReplyOptions { + return { + content: `${title}: ${info}`, + ephemeral: true, + }; +} +/** Make an success embed */ +export function confirmMessage(title: string): InteractionReplyOptions { + return { + content: title, + ephemeral: true, + }; +} + +/** Sort database entries, given a sorting and maping callback */ +export function sortDbToString(db: T[], sortFn: (a: T, b: T) => number, mapFn: (log: T) => string) { + return db + .sort((a, b) => sortFn(a, b)) + .slice(0, 5) + .map((log) => mapFn(log)) + .join("\n"); +} diff --git a/src/util/tracker/messages.ts b/src/util/tracker/messages.ts new file mode 100644 index 0000000..3b03077 --- /dev/null +++ b/src/util/tracker/messages.ts @@ -0,0 +1,33 @@ +import { confirmMessage, errorMessage } from "./helper"; + +/** Used when a user cant be fetched by `interaction.options.getUser("user")` or `interaction.user` */ +export const userNotFound = errorMessage("user not found", "Cant get user option or interaction user."); +/** Used when `interaction.guild.members.get(userID)` does work with given user */ +export const memberNotFound = errorMessage( + "member not found", + "Cant get guild member, user may not be on this server anymore." +); +/** Used when a given user is not found in the tracker database */ +export const userNoEntry = errorMessage("no database entry", "Given user has no entries in the database."); +/** Used when a given game is not found in users tracker database entry */ +export const userNoGameEntry = errorMessage( + "no database entry", + "Given user has no entrys in the database associated with given the game." +); +/** Used when a given game is not found in the tracker database */ +export const gameNoEntry = errorMessage("no database entry", "Given game has no entry in the database."); +/** Used when a non admin user tryes to use a admin only command */ +export const adminOnly = errorMessage("admin only", "This command can only be executed by an admin."); +/** Used when there is an attempt to add a game to the blacklist while its already on there */ +export const gameOnBlacklist = errorMessage( + "already on blacklist", + "Given game is already on the blacklist." +); +/** User when there is an attempt to remove a game from the blacklist whole its not on there */ +export const gameNotOnBlacklist = errorMessage("not on blacklist", "Given game is not on the blacklist."); +/** Used to confirm the adding of a given game to the blacklist */ +export const makeGameAddedMessage = (game: string) => + confirmMessage(`"${game}" has been added to the blacklist!`); +/** Used to confirm the removal of a given game from the blacklist */ +export const makeGameRemovedMessage = (game: string) => + confirmMessage(`"${game}" has been removed from the blacklist!`); diff --git a/src/util/tracker/presence.ts b/src/util/tracker/presence.ts new file mode 100644 index 0000000..d92ba57 --- /dev/null +++ b/src/util/tracker/presence.ts @@ -0,0 +1,128 @@ +import { Activity, ActivityType, Presence } from "discord.js"; +import { + trackerBlacklist, + TrackerGame, + trackerGames, + TrackerLog, + trackerLogs, + TrackerUser, + trackerUsers, +} from "../../db"; + +export async function getChangedActivities( + oldPresence: Presence | null, + newPresence: Presence +): Promise<{ started: Activity[]; stopped: Activity[] }> { + const oldActivities = oldPresence?.activities.filter((value) => value.type === ActivityType.Playing); + const newActivities = newPresence.activities.filter((value) => value.type === ActivityType.Playing); + + const stopped: Activity[] = []; + oldActivities?.forEach((activity) => { + if (!newActivities.some((newActivity) => newActivity.name === activity.name)) stopped.push(activity); + }); + + const started: Activity[] = []; + newActivities.forEach((activity) => { + if (!oldActivities?.some((oldActivity) => oldActivity.name === activity.name)) started.push(activity); + }); + + return { started, stopped }; +} + +/** Checks if the name of an game is on the blacklist (case gets ignored) */ +export function isBlacklisted(gameName: string): boolean { + return trackerBlacklist.get("")?.includes(gameName.toLowerCase()) || false; +} +/** Logs a new entry to the logs, user and games db */ +export async function logIt(gameName: string, userID: string, timePlayed: number): Promise { + gameName = gameName.toLowerCase(); + if (isBlacklisted(gameName)) return; + + const logID = (trackerLogs.count + 1).toString(); // make new a unique ID for the new log + const log = addLog(gameName, userID, timePlayed, logID); // make a db entry for this new log + ensure(gameName, userID, log); // make sure user and game exist + updateUser(gameName, userID, timePlayed, log); // update users db entry + updateGame(gameName, userID, timePlayed, log); // update games db entry +} +/** Ensures that there is a entry in the user and game db before continuing*/ +function ensure(gameName: string, userID: string, log: TrackerLog) { + if (!trackerGames.has(gameName.toLowerCase())) { + const data: TrackerGame = { + playtime: 0, + firstlog: log, + logs: 0, + lastlogs: [], + users: [], + }; + trackerGames.set(gameName.toLowerCase(), data); + } + if (!trackerUsers.has(userID)) { + const data: TrackerUser = { + playtime: 0, + firstlog: log, + logs: 0, + lastlogs: [], + games: [], + }; + trackerUsers.set(userID, data); + } +} +/** updates the latest logs, logs, game and playtime of a user */ +function updateUser(gameName: string, userID: string, timePlayed: number, log: TrackerLog) { + gameName = gameName.toLowerCase(); + + const data: TrackerUser | undefined = trackerUsers.get(userID); + + if (!data) return; + + // remove oldest log from the latest log history + if (data.lastlogs.length >= 5) data.lastlogs.shift(); + data.lastlogs.push(log); // add newest log to the log history + + data.logs += 1; + data.playtime += timePlayed; + + let gamelog = data.games.find((g) => g.name == gameName); + if (!gamelog) { + gamelog = data.games[data.games.push({ name: gameName, logs: 0, playtime: 0 }) - 1]; + } + gamelog.logs += 1; + gamelog.playtime += timePlayed; + + trackerUsers.set(userID, data); +} +/** updates the latest logs, logs, users and playtime of a game*/ +function updateGame(gameName: string, userID: string, timePlayed: number, log: TrackerLog) { + gameName = gameName.toLowerCase(); + + const data: TrackerGame | undefined = trackerGames.get(gameName); + if (!data) return; + + // remove oldest log from the latest log history + if (data.lastlogs.length >= 5) data.lastlogs.shift(); + data.lastlogs.push(log); // add newest log to the log history + + data.logs += 1; + data.playtime += timePlayed; + + let gamelog = data.users.find((e) => e.name == userID); + if (!gamelog) { + gamelog = data.users[data.users.push({ name: userID, logs: 0, playtime: 0 }) - 1]; + } + gamelog.logs += 1; + gamelog.playtime += timePlayed; + + trackerGames.set(gameName, data); +} +/** Make a new log */ +function addLog(gameName: string, userID: string, timePlayed: number, logID: string) { + const data: TrackerLog = { + id: logID, + gameName: gameName, + userID: userID, + date: Date.now(), + playtime: timePlayed, + }; + trackerLogs.set(logID, data); + return data; +} diff --git a/src/util/tracker/subCommands.ts b/src/util/tracker/subCommands.ts new file mode 100644 index 0000000..44a3a59 --- /dev/null +++ b/src/util/tracker/subCommands.ts @@ -0,0 +1,269 @@ +import { EmbedBuilder } from "discord.js"; +import { APIEmbedField, ChatInputCommandInteraction } from "discord.js"; +import { + dayInSeconds, + discordLongDateWithShortTimeTimestamp, + discordTimestamp, + hourInSeconds, + monthInSeconds, + shortDateAndShortTimeTimestamp, + weekInSeconds, +} from "../misc/time"; +import { TrackerGame, trackerGames, trackerLogs, TrackerUser, trackerUsers } from "../../db"; +import { config } from "../../config"; +import { makeTimeString, sortDbToString } from "./helper"; +import { gameNoEntry, userNoEntry, userNoGameEntry } from "./messages"; +import { addEmbedFooter } from "../misc/embeds"; +import { userLast } from "./userCommands"; +import { gameLast } from "./gameCommands"; + +export async function playtime(interaction: ChatInputCommandInteraction) { + // get target user and game + const targetUser = interaction.options.getUser("user"); + const targetGame = interaction.options.getString("game"); + let text = ""; // used later in the final embed + + // make matching text for each case + if (!targetUser && !targetGame) { + // no user, no game + let playtime = 0; + // count all played times of all games together + trackerGames.array().forEach((game) => (playtime += game.playtime)); + text = `The whole system has tracked ${makeTimeString(playtime)} of playtime`; + } else if (!targetUser && targetGame) { + // no user, game + // load db and get target game + const db = trackerGames.get(targetGame.toLowerCase()); + if (!db) { + await interaction.reply(gameNoEntry); + return; + } + text = `${targetGame} has ${makeTimeString(db.playtime)} of playtime`; + } else if (targetUser && !targetGame) { + // user, no game + // load db and get target user + const db = trackerUsers.get(targetUser.id); + if (!db) { + await interaction.reply(userNoEntry); + return; + } + text = `${targetUser} has ${makeTimeString(db.playtime)} of playtime`; + } else if (targetUser && targetGame) { + // user, game + // load db and get user.games and find target game in there + const db = trackerUsers + .get(targetUser.id) + ?.games.find((g) => g.name.toLowerCase() == targetGame.toLowerCase()); + if (!db) { + await interaction.reply(userNoGameEntry); + return; + } + text = `${targetUser} has ${makeTimeString(db.playtime)} of playtime in ${targetGame}`; + } else { + text = "error"; + } + + await interaction.reply({ + embeds: [new EmbedBuilder().setTitle("playtime").setDescription(text).setColor(config.color)], + }); +} +export async function logs(interaction: ChatInputCommandInteraction) { + // get target user and game + const targetUser = interaction.options.getUser("user"); + const targetGame = interaction.options.getString("game"); + let text = ""; // used later in the final embed + + // make matching text for each case + if (!targetUser && !targetGame) { + // no user, no game + let logs = 0; + // count all logs of all games together + trackerGames.array().forEach((game) => (logs += game.logs)); + text = `The whole system has tracked ${logs} times`; + } else if (!targetUser && targetGame) { + // no user, game + // get target db game entry + const db = trackerGames.get(targetGame.toLowerCase()); + if (!db) { + await interaction.reply(gameNoEntry); + return; + } + text = `${targetGame} was logged ${db.logs} times`; + } else if (targetUser && !targetGame) { + // user, no game + // get target db user entry + const db = trackerUsers.get(targetUser.id); + if (!db) { + await interaction.reply(userNoEntry); + return; + } + text = `${targetUser.toString()} was logged ${db.logs} times`; + } else if (targetUser && targetGame) { + // user, game + // load db and get user.games and find target game in there + const db = trackerUsers + .get(targetUser.id) + ?.games.find((g) => g.name.toLowerCase() == targetGame.toLowerCase()); + if (!db) { + await interaction.reply(userNoGameEntry); + return; + } + text = `${targetUser.toString()} has played ${targetGame} ${db.logs} times`; + } else { + text = "error"; + } + + await interaction.reply({ + embeds: [new EmbedBuilder().setTitle("logs").setDescription(text).setColor(config.color)], + }); +} +export async function latest(interaction: ChatInputCommandInteraction) { + const user = interaction.options.getUser("user"); + const game = interaction.options.getString("game"); + + if (user) { + await userLast(interaction); + } else if (game) { + await gameLast(interaction); + } else { + // future embed fields + const fields: APIEmbedField[] = []; + // latest system logs + const logs = trackerLogs + .array() + .sort((a, b) => b.date - a.date) + .slice(0, 5); + + // make embed for each log + logs.forEach((log) => + fields.push({ + inline: true, + name: log.gameName, + value: `<@${log.userID}>\n${discordLongDateWithShortTimeTimestamp(log.date)}\n${makeTimeString( + log.playtime + )}`, + }) + ); + // add empty field for better formatting + fields.push({ inline: true, name: "_ _", value: "_ _" }); + + const embed = new EmbedBuilder() + .setTitle("Latest logs") + .setColor(config.color) + .addFields(...fields); + + await interaction.reply({ embeds: [embed] }); + } +} +export async function stats(interaction: ChatInputCommandInteraction) { + // get 5 most logged games and make string + const mostLoggedGame = sortDbToString( + trackerGames.array(), + (a, b) => b.logs - a.logs, + (game) => `${game.lastlogs[0].gameName}: ${game.logs}` + ); + + // get 5 most played games and make string + const mostPlayedGame = sortDbToString( + trackerGames.array(), + (a, b) => b.playtime - a.playtime, + (game) => `${game.lastlogs[0].gameName}: ${makeTimeString(game.playtime)}` + ); + + // get 5 most logged users and make string + const mostLoggedUser = sortDbToString( + trackerUsers.array(), + (a, b) => b.logs - a.logs, + (user) => `<@${user.lastlogs[0].userID}>: ${user.logs} logs` + ); + + // get 5 most playtime users and make string + const mostPlayedUser = sortDbToString( + trackerUsers.array(), + (a, b) => b.playtime - a.playtime, + (user) => `<@${user.lastlogs[0].userID}>: ${makeTimeString(user.playtime)}` + ); + + // get latest system wide logs and make string + const latestLogs = trackerLogs + .array() + .reverse() + .slice(0, 5) + .map( + (log) => + `${shortDateAndShortTimeTimestamp(log.date)} <@${log.userID}> ${log.gameName}: ${makeTimeString( + log.playtime + )}` + ) + .join("\n"); + // get total playtime of all games + const totalPlaytime = trackerGames + .array() + .map((game) => game.playtime) + .reduce((partialSum, a) => partialSum + a, 0); + // get amount of logs + const totalLogs = trackerLogs.count; + // get amount of games + const games = trackerGames.count; + // get amount of users + const users = trackerUsers.count; + // get first log + const firstSeen = trackerLogs.array()[0].date; + // make range from first log to now + const range = ~~((Date.now() - firstSeen) / 1000); + + const playtimePerDay = makeTimeString(totalPlaytime / (range / dayInSeconds)); + const playtimePerWeek = makeTimeString(totalPlaytime / (range / weekInSeconds)); + const playtimePerMonth = makeTimeString(totalPlaytime / (range / monthInSeconds)); + const playtimePerGame = makeTimeString(totalPlaytime / games); + const playtimePerUser = makeTimeString(totalPlaytime / users); + const playtimePerLog = makeTimeString(totalPlaytime / totalLogs); + const playtimePer = `day: ${playtimePerDay}\nweek: ${playtimePerWeek}\nmonth: ${playtimePerMonth}\ngame: ${playtimePerGame}\nuser: ${playtimePerUser}\nlog: ${playtimePerLog}`; + + const logsPerDay = Math.round(totalLogs / (range / dayInSeconds)); + const logsPerWeek = Math.round(totalLogs / (range / weekInSeconds)); + const logsPerMonth = Math.round(totalLogs / (range / monthInSeconds)); + const logsPerUser = Math.round(totalLogs / users); + const logsPerGame = Math.round(totalLogs / games); + const logsPerHour = Math.round(totalLogs / (totalPlaytime / hourInSeconds)); + const logsPer = `day: ${logsPerDay}\nweek: ${logsPerWeek}\nmonth: ${logsPerMonth}\ngame: ${logsPerGame}\nuser: ${logsPerUser}\nhour: ${logsPerHour}`; + + const embed = addEmbedFooter( + new EmbedBuilder().setTitle("System stats").addFields( + { inline: true, name: "Most played games", value: mostPlayedGame }, + { inline: true, name: "Most logged games", value: mostLoggedGame }, + { inline: false, name: "_ _", value: "_ _" }, + { inline: true, name: "Most playtime", value: mostPlayedUser }, + { inline: true, name: "Most logs", value: mostLoggedUser }, + { inline: false, name: "_ _", value: "_ _" }, + { inline: true, name: "Latest logs", value: latestLogs }, + { inline: false, name: "_ _", value: "_ _" }, + { inline: true, name: "(Average) playtime per", value: playtimePer }, + { inline: true, name: "(Average) logs per", value: logsPer }, + { inline: false, name: "_ _", value: "_ _" }, + { + inline: true, + name: "Record range", + value: `${discordTimestamp(Math.floor(firstSeen / 1000))} -> ${discordTimestamp( + Math.floor(Date.now() / 1000) + )}(now)\n${makeTimeString(Math.floor((Date.now() - firstSeen) / 100))}`, + }, + { inline: false, name: "_ _", value: "_ _" }, + { + inline: true, + name: "Total...", + value: + "logs: " + + totalLogs.toString() + + "\nplaytime: " + + makeTimeString(totalPlaytime) + + "\ngames: " + + games.toString() + + "\nusers: " + + users.toString(), + } + ) + ); + + await interaction.reply({ embeds: [embed] }); +} diff --git a/src/util/tracker/userCommands.ts b/src/util/tracker/userCommands.ts new file mode 100644 index 0000000..72f3752 --- /dev/null +++ b/src/util/tracker/userCommands.ts @@ -0,0 +1,230 @@ +import { APIEmbedField, ChatInputCommandInteraction, EmbedBuilder } from "discord.js"; +import { config } from "../../config"; +import { TrackerSublog, trackerUsers } from "../../db"; +import { addEmbedFooter } from "../misc/embeds"; +import { + dayInSeconds, + discordLongDateWithShortTimeTimestamp, + discordTimestamp, + hourInSeconds, + monthInSeconds, + weekInSeconds, +} from "../misc/time"; +import { makeTimeString, sortDbToString, sortGamesLogs, sortGamesPlaytime } from "./helper"; +import { memberNotFound, userNoEntry, userNotFound } from "./messages"; + +export async function userStats(interaction: ChatInputCommandInteraction) { + // get target user and default to command executer if not given + const target = interaction.options.getUser("user") ?? interaction.user; + const targetGame = interaction.options.getString("game"); + if (!target) { + await interaction.reply(userNotFound); + return; + } + // load db and get target user + const db = trackerUsers.get(target.id); + if (!db) { + await interaction.reply(userNoEntry); + return; + } + // get the member (to use their display name and color) + const member = await interaction.guild?.members.fetch(target.id); + if (!member) { + await interaction.reply(memberNotFound); + return; + } + // make sorted list of most played games and make string + const mostPlayed = sortDbToString( + db.games.filter((v) => (targetGame ? v.name == targetGame : true)), + (a, b) => b.playtime - a.playtime, + (game) => `${game.name}: ${makeTimeString(game.playtime)}` + ); + + // make sorted list of most logged games and make string + const mostLogged = sortDbToString( + db.games.filter((v) => (targetGame ? v.name == targetGame : true)), + (a, b) => b.logs - a.logs, + (game) => `${game.name}: ${game.logs} logs` + ); + + // get latest logs and make string + const latestLogs = db.lastlogs + .reverse() + .map((log) => `${discordLongDateWithShortTimeTimestamp(log.date)} ${log.gameName}`) + .join("\n"); + // get total users playtime, logs and games + const totalPlaytime = targetGame + ? db.games + .filter((v) => v.name == targetGame) + .map((v) => v.playtime) + .reduce((accumulator, currentValue) => accumulator + currentValue, 0) + : db.playtime; + const games = db.games.length; + const totalLogs = targetGame + ? db.games + .filter((v) => v.name == targetGame) + .map((v) => v.logs) + .reduce((accumulator, currentValue) => accumulator + currentValue, 0) + : db.logs; + // get first log + const firstSeen = db.firstlog.date; + // make range from first log to now + const range = ~~((Date.now() - firstSeen) / 1000); + + const playtimePerDay = makeTimeString(totalPlaytime / (range / dayInSeconds)); + const playtimePerWeek = makeTimeString(totalPlaytime / (range / weekInSeconds)); + const playtimePerMonth = makeTimeString(totalPlaytime / (range / monthInSeconds)); + const playtimePerGame = makeTimeString(totalPlaytime / games); + const playtimePerLog = makeTimeString(totalPlaytime / totalLogs); + const playtimePer = `day: ${playtimePerDay}\nweek: ${playtimePerWeek}\nmonth: ${playtimePerMonth}\ngame: ${playtimePerGame}\nlog: ${playtimePerLog}`; + + const logsPerDay = Math.round(totalLogs / (range / dayInSeconds)); + const logsPerWeek = Math.round(totalLogs / (range / weekInSeconds)); + const logsPerMonth = Math.round(totalLogs / (range / monthInSeconds)); + const logsPerGame = Math.round(totalLogs / games); + const logsPerHour = Math.round(totalLogs / (totalPlaytime / hourInSeconds)); + const logsPer = `day: ${logsPerDay}\nweek: ${logsPerWeek}\nmonth: ${logsPerMonth}\ngame: ${logsPerGame}\nhour: ${logsPerHour}`; + + const embed = new EmbedBuilder() + .setTitle( + targetGame + ? `${member.displayName}'s tracking stats about ${targetGame}` + : `Tracking stats about ${member.displayName}` + ) + .setColor(config.color); + if (!targetGame) + embed.addFields( + { inline: true, name: "Most playtime", value: mostPlayed }, + { inline: true, name: "Most logs", value: mostLogged }, + { inline: false, name: "_ _", value: "_ _" } + ); + embed.addFields( + { inline: true, name: "(Average) playtime per", value: playtimePer }, + { inline: true, name: "(Average) logs per", value: logsPer }, + { inline: false, name: "_ _", value: "_ _" }, + { + inline: true, + name: "Record range", + value: `${discordTimestamp(Math.floor(firstSeen / 1000))} -> ${discordTimestamp( + Math.floor(Date.now() / 1000) + )}(now)\n${makeTimeString(Math.floor((Date.now() - firstSeen) / 1000))}`, + } + ); + if (!targetGame) + embed.addFields( + { inline: false, name: "_ _", value: "_ _" }, + { inline: true, name: "Latest logs", value: latestLogs } + ); + + embed.addFields({ + inline: false, + name: "Total...", + value: + "playtime: " + + makeTimeString(totalPlaytime) + + "\nlogs: " + + totalLogs.toString() + + (targetGame ? "" : "\ngames: " + games.toString()), + }); + + await interaction.reply({ embeds: [embed] }); +} +export async function userLast(interaction: ChatInputCommandInteraction) { + // get target user, if not given default to executer + const target = interaction.options.getUser("user") ?? interaction.user; + if (!target) { + await interaction.reply(userNotFound); + return; + } + // get member from target + const member = await interaction.guild?.members.fetch(target.id); + if (member == undefined) { + await interaction.reply(memberNotFound); + return; + } + // get targets db entry + const db = trackerUsers.get(member.id); + if (!db) { + await interaction.reply(userNoEntry); + return; + } + // get latest logs + const logs = db.lastlogs.reverse(); + // future embed fields + const fields: APIEmbedField[] = []; + + // make field for every log + logs.forEach((log) => { + if (!log) return; + fields.push({ + inline: true, + name: log.gameName, + value: `${discordLongDateWithShortTimeTimestamp(log.date)}\n${makeTimeString(log.playtime)}`, + }); + }); + + // add extra field for better formatting + fields.push({ inline: true, name: "_ _", value: "_ _" }); + + const embed = addEmbedFooter( + new EmbedBuilder().setTitle(`Latest logs by ${member?.displayName}`).addFields(...fields) + ); + + await interaction.reply({ embeds: [embed] }); +} +export async function userTop(interaction: ChatInputCommandInteraction, filter: string) { + // get target user, default to executer if not given + const target = interaction.options.getUser("user") ?? interaction.user; + if (!target) { + await interaction.reply(userNotFound); + return; + } + // get the member from target + const member = await interaction.guild?.members.fetch(target.id); + if (member == undefined) { + await interaction.reply(memberNotFound); + return; + } + + // load sorted list based on filter + let games: TrackerSublog[] | undefined = []; + if (filter == "logs") { + games = sortGamesLogs(member.id); + } else if (filter == "playtime") { + games = sortGamesPlaytime(member.id); + } else { + return; + } + + // return if no games are being listed + if (!games) { + await interaction.reply(userNoEntry); + return; + } + + // limit list to a range from 0 to 5 + games = games.slice(0, 5); + + // future embed fields + const fields: APIEmbedField[] = []; + + // add one extra field for better formatting + // make field for every game based on filter + games.forEach((game) => { + fields.push({ + inline: true, + name: game.name, + value: + filter == "playtime" + ? `${makeTimeString(game.playtime)}\n${game.logs} logs` + : `${game.logs} logs\n${makeTimeString(game.playtime)}`, + }); + }); + fields.push({ inline: true, name: "_ _", value: "_ _" }); + + const embed = addEmbedFooter( + new EmbedBuilder().setTitle(`Top games (${filter}) by ${member?.displayName}`).addFields(...fields) + ); + + await interaction.reply({ embeds: [embed] }); +}