diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..afd1cc26 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "editor.tabSize": 2, + "editor.insertSpaces": true +} diff --git a/package.json b/package.json index a64d3b44..c977bee6 100644 --- a/package.json +++ b/package.json @@ -2,51 +2,51 @@ "type": "module", "version": "0.0.0", "scripts": { - "dev": "pnpm migrate && tsx watch src/index.ts", - "start": "pnpm migrate && tsx src/index.ts", + "dev": "pnpm migrate && tsx watch --env-file=.env src/index.ts", + "start": "pnpm migrate && tsx --env-file=.env src/index.ts", "build": "tsc", "lint": "eslint . --ext .js,.cjs,.mjs,.ts,.cts,.mts --fix --ignore-path .gitignore", "lint:ci": "eslint . --ext .js,.cjs,.mjs,.ts,.cts,.mts --ignore-path .gitignore", - "test": "ava", - "coverage": "c8 --all --src=dist --skip-full ava", + "test": "ava src/", + "coverage": "c8 --all --src=dist --skip-full ava src/", "migrate": "knex migrate:latest", "make": "knex migrate:make" }, "dependencies": { - "@twurple/api": "6.2.1", - "@twurple/auth": "6.2.1", - "@zeepkist/gtr-api": "3.12.1", - "date-fns": "3.0.6", - "discord.js": "14.14.1", - "dotenv": "16.3.1", - "knex": "3.1.0", - "ky": "1.1.3", - "mysql2": "3.6.5", - "winston": "3.11.0", - "winston-daily-rotate-file": "4.7.1" + "@twurple/api": "~7.0.6", + "@twurple/auth": "~7.0.6", + "@zeepkist/graphql": "~1.8.0", + "date-fns": "~2.30.0", + "discord.js": "~14.14.1", + "dotenv": "~16.3.1", + "knex": "~3.0.1", + "ky": "~1.1.3", + "mysql2": "~3.6.5", + "winston": "~3.11.0", + "winston-daily-rotate-file": "~4.7.1" }, "devDependencies": { "@ava/typescript": "~4.1.0", "@rushstack/eslint-patch": "~1.6.0", "@semantic-release/changelog": "~6.0.3", "@semantic-release/commit-analyzer": "~11.1.0", - "@semantic-release/github": "~9.2.0", + "@semantic-release/github": "~9.2.4", "@semantic-release/release-notes-generator": "~12.1.0", - "@types/node": "~20.10.0", - "@typescript-eslint/eslint-plugin": "~6.15.0", - "@typescript-eslint/parser": "~6.15.0", + "@types/node": "~20.10.3", + "@typescript-eslint/eslint-plugin": "~6.13.2", + "@typescript-eslint/parser": "~6.13.2", "ava": "~6.0.0", - "c8": "~8.0.0", - "eslint": "~8.56.0", + "c8": "~8.0.1", + "eslint": "~8.55.0", "eslint-config-prettier": "~9.1.0", "eslint-plugin-import": "~2.29.0", - "eslint-plugin-prettier": "~5.1.0", + "eslint-plugin-prettier": "~5.0.1", "eslint-plugin-simple-import-sort": "~10.0.0", "eslint-plugin-unicorn": "~49.0.0", "prettier": "~3.1.0", - "semantic-release": "~22.0.0", - "tsx": "~4.7.0", - "typescript": "~5.3.0" + "semantic-release": "~22.0.10", + "tsx": "~4.6.2", + "typescript": "~5.3.3" }, "ava": { "utilizeParallelBuilds": true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dc063ab7..4b9a97a0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,37 +6,37 @@ settings: dependencies: '@twurple/api': - specifier: 6.2.1 - version: 6.2.1(@twurple/auth@6.2.1) + specifier: ~7.0.6 + version: 7.0.6(@twurple/auth@7.0.6) '@twurple/auth': - specifier: 6.2.1 - version: 6.2.1 - '@zeepkist/gtr-api': - specifier: 3.12.1 - version: 3.12.1 + specifier: ~7.0.6 + version: 7.0.6 + '@zeepkist/graphql': + specifier: ~1.8.0 + version: 1.8.0 date-fns: - specifier: 3.0.6 - version: 3.0.6 + specifier: ~2.30.0 + version: 2.30.0 discord.js: - specifier: 14.14.1 + specifier: ~14.14.1 version: 14.14.1 dotenv: - specifier: 16.3.1 + specifier: ~16.3.1 version: 16.3.1 knex: - specifier: 3.1.0 - version: 3.1.0(mysql2@3.6.5) + specifier: ~3.0.1 + version: 3.0.1(mysql2@3.6.5) ky: - specifier: 1.1.3 + specifier: ~1.1.3 version: 1.1.3 mysql2: - specifier: 3.6.5 + specifier: ~3.6.5 version: 3.6.5 winston: - specifier: 3.11.0 + specifier: ~3.11.0 version: 3.11.0 winston-daily-rotate-file: - specifier: 4.7.1 + specifier: ~4.7.1 version: 4.7.1(winston@3.11.0) devDependencies: @@ -48,30 +48,30 @@ devDependencies: version: 1.6.1 '@semantic-release/changelog': specifier: ~6.0.3 - version: 6.0.3(semantic-release@22.0.12) + version: 6.0.3(semantic-release@22.0.10) '@semantic-release/commit-analyzer': specifier: ~11.1.0 - version: 11.1.0(semantic-release@22.0.12) + version: 11.1.0(semantic-release@22.0.10) '@semantic-release/github': - specifier: ~9.2.0 - version: 9.2.6(semantic-release@22.0.12) + specifier: ~9.2.4 + version: 9.2.4(semantic-release@22.0.10) '@semantic-release/release-notes-generator': specifier: ~12.1.0 - version: 12.1.0(semantic-release@22.0.12) + version: 12.1.0(semantic-release@22.0.10) '@types/node': - specifier: ~20.10.0 - version: 20.10.7 + specifier: ~20.10.3 + version: 20.10.3 '@typescript-eslint/eslint-plugin': - specifier: ~6.15.0 - version: 6.15.0(@typescript-eslint/parser@6.15.0)(eslint@8.56.0)(typescript@5.3.3) + specifier: ~6.13.2 + version: 6.13.2(@typescript-eslint/parser@6.13.2)(eslint@8.55.0)(typescript@5.3.3) '@typescript-eslint/parser': - specifier: ~6.15.0 - version: 6.15.0(eslint@8.56.0)(typescript@5.3.3) + specifier: ~6.13.2 + version: 6.13.2(eslint@8.55.0)(typescript@5.3.3) ava: specifier: ~6.0.0 version: 6.0.1(@ava/typescript@4.1.0) c8: - specifier: ~8.0.0 + specifier: ~8.0.1 version: 8.0.1 eslint: specifier: ~8.56.0 @@ -81,10 +81,10 @@ devDependencies: version: 9.1.0(eslint@8.56.0) eslint-plugin-import: specifier: ~2.29.0 - version: 2.29.1(@typescript-eslint/parser@6.15.0)(eslint@8.56.0) + version: 2.29.0(@typescript-eslint/parser@6.13.2)(eslint@8.55.0) eslint-plugin-prettier: - specifier: ~5.1.0 - version: 5.1.2(eslint-config-prettier@9.1.0)(eslint@8.56.0)(prettier@3.1.1) + specifier: ~5.0.1 + version: 5.0.1(eslint-config-prettier@9.1.0)(eslint@8.55.0)(prettier@3.1.0) eslint-plugin-simple-import-sort: specifier: ~10.0.0 version: 10.0.0(eslint@8.56.0) @@ -95,13 +95,13 @@ devDependencies: specifier: ~3.1.0 version: 3.1.1 semantic-release: - specifier: ~22.0.0 - version: 22.0.12(typescript@5.3.3) + specifier: ~22.0.10 + version: 22.0.10(typescript@5.3.3) tsx: - specifier: ~4.7.0 - version: 4.7.0 + specifier: ~4.6.2 + version: 4.6.2 typescript: - specifier: ~5.3.0 + specifier: ~5.3.3 version: 5.3.3 packages: @@ -164,8 +164,8 @@ packages: tslib: 2.6.2 dev: false - /@d-fischer/cross-fetch@4.2.1: - resolution: {integrity: sha512-/tvOWaOFBW2NyLCuJ0Tf2wFaEqZudT9osF/2A7/K4NU+g7MAQfOAEMUizKtg3TTrEfwWLjGic3oOBdbmR3WBKg==} + /@d-fischer/cross-fetch@5.0.3: + resolution: {integrity: sha512-PAxxY2MJff3DUZP6uYWAo0gvp7lGry8SjZ07H661RBnJviy91o+NWR5C7E67dMvrSGvfA1kZC0xrwk+v4eTcMA==} dependencies: node-fetch: 2.7.0 transitivePeerDependencies: @@ -236,7 +236,7 @@ packages: dependencies: '@discordjs/formatters': 0.3.3 '@discordjs/util': 1.0.2 - '@sapphire/shapeshift': 3.9.5 + '@sapphire/shapeshift': 3.9.4 discord-api-types: 0.37.61 fast-deep-equal: 3.1.3 ts-mixer: 6.0.3 @@ -767,9 +767,9 @@ packages: engines: {node: '>=v14.0.0', npm: '>=7.0.0'} dev: false - /@sapphire/shapeshift@3.9.5: - resolution: {integrity: sha512-AGdHe+51gF7D3W8hBfuSFLBocURDCXVQczScTHXDS3RpNjNgrktIx/amlz5y8nHhm8SAdFt/X8EF8ZSfjJ0tnA==} - engines: {node: '>=v18'} + /@sapphire/shapeshift@3.9.4: + resolution: {integrity: sha512-SiOoCBmm8O7QuadLJnX4V0tAkhC54NIOZJtmvw+5zwnHaiulGkjY02wxCuK8Gf4V540ILmGz+UulC0U8mrOZjg==} + engines: {node: '>=v14.0.0', npm: '>=7.0.0'} dependencies: fast-deep-equal: 3.1.3 lodash: 4.17.21 @@ -780,7 +780,7 @@ packages: engines: {node: '>=v14.0.0', npm: '>=7.0.0'} dev: false - /@semantic-release/changelog@6.0.3(semantic-release@22.0.12): + /@semantic-release/changelog@6.0.3(semantic-release@22.0.10): resolution: {integrity: sha512-dZuR5qByyfe3Y03TpmCvAxCyTnp7r5XwtHRf/8vD9EAn4ZWbavUX8adMtXYzE86EVh0gyLA7lm5yW4IV30XUag==} engines: {node: '>=14.17'} peerDependencies: @@ -790,10 +790,10 @@ packages: aggregate-error: 3.1.0 fs-extra: 11.2.0 lodash: 4.17.21 - semantic-release: 22.0.12(typescript@5.3.3) + semantic-release: 22.0.10(typescript@5.3.3) dev: true - /@semantic-release/commit-analyzer@11.1.0(semantic-release@22.0.12): + /@semantic-release/commit-analyzer@11.1.0(semantic-release@22.0.10): resolution: {integrity: sha512-cXNTbv3nXR2hlzHjAMgbuiQVtvWHTlwwISt60B+4NZv01y/QRY7p2HcJm8Eh2StzcTJoNnflvKjHH/cjFS7d5g==} engines: {node: ^18.17 || >=20.6.1} peerDependencies: @@ -806,7 +806,7 @@ packages: import-from-esm: 1.3.3 lodash-es: 4.17.21 micromatch: 4.0.5 - semantic-release: 22.0.12(typescript@5.3.3) + semantic-release: 22.0.10(typescript@5.3.3) transitivePeerDependencies: - supports-color dev: true @@ -821,8 +821,8 @@ packages: engines: {node: '>=18'} dev: true - /@semantic-release/github@9.2.6(semantic-release@22.0.12): - resolution: {integrity: sha512-shi+Lrf6exeNZF+sBhK+P011LSbhmIAoUEgEY6SsxF8irJ+J2stwI5jkyDQ+4gzYyDImzV6LCKdYB9FXnQRWKA==} + /@semantic-release/github@9.2.4(semantic-release@22.0.10): + resolution: {integrity: sha512-VMzqiuSLhHc0/1Q8M/FmWnOaclh5aXL2pQWceldWBYSWLNzQu8GOR4bkGl57ciUtvm+MCMi4FaStZxSDJGEfUg==} engines: {node: '>=18'} peerDependencies: semantic-release: '>=20.1.0' @@ -840,16 +840,16 @@ packages: https-proxy-agent: 7.0.2 issue-parser: 6.0.0 lodash-es: 4.17.21 - mime: 4.0.1 - p-filter: 4.1.0 - semantic-release: 22.0.12(typescript@5.3.3) + mime: 4.0.0 + p-filter: 3.0.0 + semantic-release: 22.0.10(typescript@5.3.3) url-join: 5.0.0 transitivePeerDependencies: - supports-color dev: true - /@semantic-release/npm@11.0.2(semantic-release@22.0.12): - resolution: {integrity: sha512-owtf3RjyPvRE63iUKZ5/xO4uqjRpVQDUB9+nnXj0xwfIeM9pRl+cG+zGDzdftR4m3f2s4Wyf3SexW+kF5DFtWA==} + /@semantic-release/npm@11.0.1(semantic-release@22.0.10): + resolution: {integrity: sha512-nFcT0pgVwpXsPkzjqP3ObH+pILeN1AbYscCDuYwgZEPZukL+RsGhrtdT4HA1Gjb/y1bVbE90JNtMIcgRi5z/Fg==} engines: {node: ^18.17 || >=20} peerDependencies: semantic-release: '>=20.1.0' @@ -865,12 +865,12 @@ packages: rc: 1.2.8 read-pkg: 9.0.1 registry-auth-token: 5.0.2 - semantic-release: 22.0.12(typescript@5.3.3) + semantic-release: 22.0.10(typescript@5.3.3) semver: 7.5.4 tempy: 3.1.0 dev: true - /@semantic-release/release-notes-generator@12.1.0(semantic-release@22.0.12): + /@semantic-release/release-notes-generator@12.1.0(semantic-release@22.0.10): resolution: {integrity: sha512-g6M9AjUKAZUZnxaJZnouNBeDNTCUrJ5Ltj+VJ60gJeDaRRahcHsry9HW8yKrnKkKNkx5lbWiEP1FPMqVNQz8Kg==} engines: {node: ^18.17 || >=20.6.1} peerDependencies: @@ -886,7 +886,7 @@ packages: into-stream: 7.0.0 lodash-es: 4.17.21 read-pkg-up: 11.0.0 - semantic-release: 22.0.12(typescript@5.3.3) + semantic-release: 22.0.10(typescript@5.3.3) transitivePeerDependencies: - supports-color dev: true @@ -901,53 +901,54 @@ packages: engines: {node: '>=18'} dev: true - /@twurple/api-call@6.2.1: - resolution: {integrity: sha512-w5SdEKbb6O7tScEo2FymlHMJ0hMYic9PJ8Gy43XzuNa7fbBpR06Um6aG9FUY7OmxnP7pWqhB3p1wSBV3+19b0Q==} + /@twurple/api-call@7.0.6: + resolution: {integrity: sha512-3E9IAJRyRLji9bjmZzicWv/wP9aGVdIDkzaX4WaK3sTRC9EkZ3nWIxSj0WmJ977O2lDsUkpgxzwmskuDqdaTrQ==} dependencies: - '@d-fischer/cross-fetch': 4.2.1 + '@d-fischer/cross-fetch': 5.0.3 '@d-fischer/qs': 7.0.2 '@d-fischer/shared-utils': 3.6.3 - '@twurple/common': 6.2.1 - '@types/node-fetch': 2.6.10 + '@twurple/common': 7.0.6 tslib: 2.6.2 transitivePeerDependencies: - encoding dev: false - /@twurple/api@6.2.1(@twurple/auth@6.2.1): - resolution: {integrity: sha512-ewyoQiuAmqcmg/ZKwilqqnIQMGZgH7bdCDc/S3T49LcpzykYacyVhjyhHsPNHFZM7C+tcJUVSFMd1OSAjpVoIw==} + /@twurple/api@7.0.6(@twurple/auth@7.0.6): + resolution: {integrity: sha512-Fe8haADUI+m4juCuNxtkWBX2HkCU6FA2+2Biq2/KRTp50FVRCeOBdDQpkdM4r+T2KE0NaiTpiH2rtaRodkR/gQ==} peerDependencies: - '@twurple/auth': 6.2.1 + '@twurple/auth': 7.0.6 dependencies: '@d-fischer/cache-decorators': 3.0.3 + '@d-fischer/cross-fetch': 5.0.3 '@d-fischer/detect-node': 3.0.1 '@d-fischer/logger': 4.2.3 '@d-fischer/rate-limiter': 0.7.5 '@d-fischer/shared-utils': 3.6.3 - '@d-fischer/typed-event-emitter': 3.3.3 - '@twurple/api-call': 6.2.1 - '@twurple/auth': 6.2.1 - '@twurple/common': 6.2.1 + '@d-fischer/typed-event-emitter': 3.3.2 + '@twurple/api-call': 7.0.6 + '@twurple/auth': 7.0.6 + '@twurple/common': 7.0.6 retry: 0.13.1 tslib: 2.6.2 transitivePeerDependencies: - encoding dev: false - /@twurple/auth@6.2.1: - resolution: {integrity: sha512-WrjasxjYOSfx5F42yt5Uxry/q2+EQQPhtOFOVZgienrmLXg+xv0uQ1ZdSXJiRSgzd0qaEj+a+ezWWf37NtBK8Q==} + /@twurple/auth@7.0.6: + resolution: {integrity: sha512-kgjSdLRW9NKk9LD8dySkgjXDxzJQFAiNIBZFbQNgTqSn7A2ZwaxmapIxy7FcHyGV0MdRtimScnRbKK9Nm089Xw==} dependencies: '@d-fischer/logger': 4.2.3 '@d-fischer/shared-utils': 3.6.3 - '@twurple/api-call': 6.2.1 - '@twurple/common': 6.2.1 + '@d-fischer/typed-event-emitter': 3.3.2 + '@twurple/api-call': 7.0.6 + '@twurple/common': 7.0.6 tslib: 2.6.2 transitivePeerDependencies: - encoding dev: false - /@twurple/common@6.2.1: - resolution: {integrity: sha512-ek9AYxnRUE86veaF1uQZYQOrZDZCSXLuSPryPItha7JlSwEeIYQQm0Y746cHDVKwcPQiNYJuzeDdnA6OYkNs4w==} + /@twurple/common@7.0.6: + resolution: {integrity: sha512-38cufsx5k1ruUNrorXsiyTVBnFCXPFgr+MMkpMoFnh3T5G9drT3RkmE6Ss7oeaK6Glr/ihQNIPL6NzTJkGmXhA==} dependencies: '@d-fischer/shared-utils': 3.6.3 klona: 2.0.6 @@ -966,15 +967,8 @@ packages: resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} dev: true - /@types/node-fetch@2.6.10: - resolution: {integrity: sha512-PPpPK6F9ALFTn59Ka3BaL+qGuipRfxNE8qVgkp0bVixeiR2c2/L+IVOiBdu9JhhT22sWnQEp6YyHGI2b2+CMcA==} - dependencies: - '@types/node': 20.10.7 - form-data: 4.0.0 - dev: false - - /@types/node@20.10.7: - resolution: {integrity: sha512-fRbIKb8C/Y2lXxB5eVMj4IU7xpdox0Lh8bUPEdtLysaylsml1hOOx1+STloRs/B9nf7C6kPRmmg/V7aQW7usNg==} + /@types/node@20.10.3: + resolution: {integrity: sha512-XJavIpZqiXID5Yxnxv3RUDKTN5b81ddNC3ecsA0SoFXz/QU8OGBwZGMomiq0zw+uuqbL/krztv/DINAQ/EV4gg==} dependencies: undici-types: 5.26.5 @@ -996,8 +990,8 @@ packages: '@types/node': 20.10.7 dev: false - /@typescript-eslint/eslint-plugin@6.15.0(@typescript-eslint/parser@6.15.0)(eslint@8.56.0)(typescript@5.3.3): - resolution: {integrity: sha512-j5qoikQqPccq9QoBAupOP+CBu8BaJ8BLjaXSioDISeTZkVO3ig7oSIKh3H+rEpee7xCXtWwSB4KIL5l6hWZzpg==} + /@typescript-eslint/eslint-plugin@6.13.2(@typescript-eslint/parser@6.13.2)(eslint@8.55.0)(typescript@5.3.3): + resolution: {integrity: sha512-3+9OGAWHhk4O1LlcwLBONbdXsAhLjyCFogJY/cWy2lxdVJ2JrcTF2pTGMaLl2AE7U1l31n8Py4a8bx5DLf/0dQ==} engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: '@typescript-eslint/parser': ^6.0.0 || ^6.0.0-alpha @@ -1008,11 +1002,11 @@ packages: optional: true dependencies: '@eslint-community/regexpp': 4.10.0 - '@typescript-eslint/parser': 6.15.0(eslint@8.56.0)(typescript@5.3.3) - '@typescript-eslint/scope-manager': 6.15.0 - '@typescript-eslint/type-utils': 6.15.0(eslint@8.56.0)(typescript@5.3.3) - '@typescript-eslint/utils': 6.15.0(eslint@8.56.0)(typescript@5.3.3) - '@typescript-eslint/visitor-keys': 6.15.0 + '@typescript-eslint/parser': 6.13.2(eslint@8.55.0)(typescript@5.3.3) + '@typescript-eslint/scope-manager': 6.13.2 + '@typescript-eslint/type-utils': 6.13.2(eslint@8.55.0)(typescript@5.3.3) + '@typescript-eslint/utils': 6.13.2(eslint@8.55.0)(typescript@5.3.3) + '@typescript-eslint/visitor-keys': 6.13.2 debug: 4.3.4 eslint: 8.56.0 graphemer: 1.4.0 @@ -1025,8 +1019,8 @@ packages: - supports-color dev: true - /@typescript-eslint/parser@6.15.0(eslint@8.56.0)(typescript@5.3.3): - resolution: {integrity: sha512-MkgKNnsjC6QwcMdlNAel24jjkEO/0hQaMDLqP4S9zq5HBAUJNQB6y+3DwLjX7b3l2b37eNAxMPLwb3/kh8VKdA==} + /@typescript-eslint/parser@6.13.2(eslint@8.55.0)(typescript@5.3.3): + resolution: {integrity: sha512-MUkcC+7Wt/QOGeVlM8aGGJZy1XV5YKjTpq9jK6r6/iLsGXhBVaGP5N0UYvFsu9BFlSpwY9kMretzdBH01rkRXg==} engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: eslint: ^7.0.0 || ^8.0.0 @@ -1035,27 +1029,27 @@ packages: typescript: optional: true dependencies: - '@typescript-eslint/scope-manager': 6.15.0 - '@typescript-eslint/types': 6.15.0 - '@typescript-eslint/typescript-estree': 6.15.0(typescript@5.3.3) - '@typescript-eslint/visitor-keys': 6.15.0 + '@typescript-eslint/scope-manager': 6.13.2 + '@typescript-eslint/types': 6.13.2 + '@typescript-eslint/typescript-estree': 6.13.2(typescript@5.3.3) + '@typescript-eslint/visitor-keys': 6.13.2 debug: 4.3.4 - eslint: 8.56.0 + eslint: 8.55.0 typescript: 5.3.3 transitivePeerDependencies: - supports-color dev: true - /@typescript-eslint/scope-manager@6.15.0: - resolution: {integrity: sha512-+BdvxYBltqrmgCNu4Li+fGDIkW9n//NrruzG9X1vBzaNK+ExVXPoGB71kneaVw/Jp+4rH/vaMAGC6JfMbHstVg==} + /@typescript-eslint/scope-manager@6.13.2: + resolution: {integrity: sha512-CXQA0xo7z6x13FeDYCgBkjWzNqzBn8RXaE3QVQVIUm74fWJLkJkaHmHdKStrxQllGh6Q4eUGyNpMe0b1hMkXFA==} engines: {node: ^16.0.0 || >=18.0.0} dependencies: - '@typescript-eslint/types': 6.15.0 - '@typescript-eslint/visitor-keys': 6.15.0 + '@typescript-eslint/types': 6.13.2 + '@typescript-eslint/visitor-keys': 6.13.2 dev: true - /@typescript-eslint/type-utils@6.15.0(eslint@8.56.0)(typescript@5.3.3): - resolution: {integrity: sha512-CnmHKTfX6450Bo49hPg2OkIm/D/TVYV7jO1MCfPYGwf6x3GO0VU8YMO5AYMn+u3X05lRRxA4fWCz87GFQV6yVQ==} + /@typescript-eslint/type-utils@6.13.2(eslint@8.55.0)(typescript@5.3.3): + resolution: {integrity: sha512-Qr6ssS1GFongzH2qfnWKkAQmMUyZSyOr0W54nZNU1MDfo+U4Mv3XveeLZzadc/yq8iYhQZHYT+eoXJqnACM1tw==} engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: eslint: ^7.0.0 || ^8.0.0 @@ -1064,23 +1058,23 @@ packages: typescript: optional: true dependencies: - '@typescript-eslint/typescript-estree': 6.15.0(typescript@5.3.3) - '@typescript-eslint/utils': 6.15.0(eslint@8.56.0)(typescript@5.3.3) + '@typescript-eslint/typescript-estree': 6.13.2(typescript@5.3.3) + '@typescript-eslint/utils': 6.13.2(eslint@8.55.0)(typescript@5.3.3) debug: 4.3.4 - eslint: 8.56.0 + eslint: 8.55.0 ts-api-utils: 1.0.3(typescript@5.3.3) typescript: 5.3.3 transitivePeerDependencies: - supports-color dev: true - /@typescript-eslint/types@6.15.0: - resolution: {integrity: sha512-yXjbt//E4T/ee8Ia1b5mGlbNj9fB9lJP4jqLbZualwpP2BCQ5is6BcWwxpIsY4XKAhmdv3hrW92GdtJbatC6dQ==} + /@typescript-eslint/types@6.13.2: + resolution: {integrity: sha512-7sxbQ+EMRubQc3wTfTsycgYpSujyVbI1xw+3UMRUcrhSy+pN09y/lWzeKDbvhoqcRbHdc+APLs/PWYi/cisLPg==} engines: {node: ^16.0.0 || >=18.0.0} dev: true - /@typescript-eslint/typescript-estree@6.15.0(typescript@5.3.3): - resolution: {integrity: sha512-7mVZJN7Hd15OmGuWrp2T9UvqR2Ecg+1j/Bp1jXUEY2GZKV6FXlOIoqVDmLpBiEiq3katvj/2n2mR0SDwtloCew==} + /@typescript-eslint/typescript-estree@6.13.2(typescript@5.3.3): + resolution: {integrity: sha512-SuD8YLQv6WHnOEtKv8D6HZUzOub855cfPnPMKvdM/Bh1plv1f7Q/0iFUDLKKlxHcEstQnaUU4QZskgQq74t+3w==} engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: typescript: '*' @@ -1088,8 +1082,8 @@ packages: typescript: optional: true dependencies: - '@typescript-eslint/types': 6.15.0 - '@typescript-eslint/visitor-keys': 6.15.0 + '@typescript-eslint/types': 6.13.2 + '@typescript-eslint/visitor-keys': 6.13.2 debug: 4.3.4 globby: 11.1.0 is-glob: 4.0.3 @@ -1100,8 +1094,8 @@ packages: - supports-color dev: true - /@typescript-eslint/utils@6.15.0(eslint@8.56.0)(typescript@5.3.3): - resolution: {integrity: sha512-eF82p0Wrrlt8fQSRL0bGXzK5nWPRV2dYQZdajcfzOD9+cQz9O7ugifrJxclB+xVOvWvagXfqS4Es7vpLP4augw==} + /@typescript-eslint/utils@6.13.2(eslint@8.55.0)(typescript@5.3.3): + resolution: {integrity: sha512-b9Ptq4eAZUym4idijCRzl61oPCwwREcfDI8xGk751Vhzig5fFZR9CyzDz4Sp/nxSLBYxUPyh4QdIDqWykFhNmQ==} engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: eslint: ^7.0.0 || ^8.0.0 @@ -1109,21 +1103,21 @@ packages: '@eslint-community/eslint-utils': 4.4.0(eslint@8.56.0) '@types/json-schema': 7.0.15 '@types/semver': 7.5.6 - '@typescript-eslint/scope-manager': 6.15.0 - '@typescript-eslint/types': 6.15.0 - '@typescript-eslint/typescript-estree': 6.15.0(typescript@5.3.3) - eslint: 8.56.0 + '@typescript-eslint/scope-manager': 6.13.2 + '@typescript-eslint/types': 6.13.2 + '@typescript-eslint/typescript-estree': 6.13.2(typescript@5.3.3) + eslint: 8.55.0 semver: 7.5.4 transitivePeerDependencies: - supports-color - typescript dev: true - /@typescript-eslint/visitor-keys@6.15.0: - resolution: {integrity: sha512-1zvtdC1a9h5Tb5jU9x3ADNXO9yjP8rXlaoChu0DQX40vf5ACVpYIVIZhIMZ6d5sDXH7vq4dsZBT1fEGj8D2n2w==} + /@typescript-eslint/visitor-keys@6.13.2: + resolution: {integrity: sha512-OGznFs0eAQXJsp+xSd6k/O1UbFi/K/L7WjqeRoFE7vadjAF9y0uppXhYNQNEqygjou782maGClOoZwPqF0Drlw==} engines: {node: ^16.0.0 || >=18.0.0} dependencies: - '@typescript-eslint/types': 6.15.0 + '@typescript-eslint/types': 6.13.2 eslint-visitor-keys: 3.4.3 dev: true @@ -1157,12 +1151,8 @@ packages: engines: {node: '>=v14.0.0', npm: '>=7.0.0'} dev: false - /@zeepkist/gtr-api@3.12.1: - resolution: {integrity: sha512-4/LL5Fw11xl3ZKa4Im9kkoaVznrC2KqYlDzZlYk6p3Yo1rqPxY+91W8NWtnCKI84e/Y9Vc/ED8Ug4W6d64+ARg==} - deprecated: This package has been renamed to @zeepkist/api. Please migrate to the new package for future updates - dependencies: - ky: 1.0.1 - pako: 2.1.0 + /@zeepkist/graphql@1.8.0: + resolution: {integrity: sha512-dFwFCO9kbcmA8gVW2SyiwW0UeGirMVclWvLDnfWKtyqr02iNyiYh/OvSd/+0FT+WRajKU1f3d6ebGrU7a8Ndxw==} dev: false /JSONStream@1.3.5: @@ -1408,12 +1398,8 @@ packages: resolution: {integrity: sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==} dev: false - /asynckit@0.4.0: - resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - dev: false - - /ava@6.0.1(@ava/typescript@4.1.0): - resolution: {integrity: sha512-9zR0wOwlcJdOWwHOKnpi0GrPRLTlxDFapGalP4rGD0oQRKxDVoucBBWvxVQ/2cPv10Hx1PkDXLJH5iUzhPn0/g==} + /ava@6.0.0(@ava/typescript@4.1.0): + resolution: {integrity: sha512-sgruhDqX9QwW3iuivRgvHNZsE/3oc2yMpANUms2WIN3YGLmhng6aFoDDEFFtwKaaw2H7KteAOuNzv9ml1UO2QQ==} engines: {node: ^18.18 || ^20.8 || ^21} hasBin: true peerDependencies: @@ -1424,7 +1410,7 @@ packages: dependencies: '@ava/typescript': 4.1.0 '@vercel/nft': 0.24.4 - acorn: 8.11.3 + acorn: 8.11.2 acorn-walk: 8.3.1 ansi-styles: 6.2.1 arrgv: 1.0.2 @@ -1714,13 +1700,6 @@ packages: text-hex: 1.0.0 dev: false - /combined-stream@1.0.8: - resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} - engines: {node: '>= 0.8'} - dependencies: - delayed-stream: 1.0.0 - dev: false - /commander@10.0.1: resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} engines: {node: '>=14'} @@ -1912,11 +1891,6 @@ packages: has-property-descriptors: 1.0.1 object-keys: 1.1.1 - /delayed-stream@1.0.0: - resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} - engines: {node: '>=0.4.0'} - dev: false - /delegates@1.0.0: resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==} dev: true @@ -2194,7 +2168,7 @@ packages: - supports-color dev: true - /eslint-module-utils@2.8.0(@typescript-eslint/parser@6.15.0)(eslint-import-resolver-node@0.3.9)(eslint@8.56.0): + /eslint-module-utils@2.8.0(@typescript-eslint/parser@6.13.2)(eslint-import-resolver-node@0.3.9)(eslint@8.55.0): resolution: {integrity: sha512-aWajIYfsqCKRDgUfjEXNN/JlrzauMuSEy5sbd7WXbtW3EH6A6MpwEh42c7qD+MqQo9QMJ6fWLAeIJynx0g6OAw==} engines: {node: '>=4'} peerDependencies: @@ -2215,7 +2189,7 @@ packages: eslint-import-resolver-webpack: optional: true dependencies: - '@typescript-eslint/parser': 6.15.0(eslint@8.56.0)(typescript@5.3.3) + '@typescript-eslint/parser': 6.13.2(eslint@8.55.0)(typescript@5.3.3) debug: 3.2.7 eslint: 8.56.0 eslint-import-resolver-node: 0.3.9 @@ -2223,8 +2197,8 @@ packages: - supports-color dev: true - /eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.15.0)(eslint@8.56.0): - resolution: {integrity: sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==} + /eslint-plugin-import@2.29.0(@typescript-eslint/parser@6.13.2)(eslint@8.55.0): + resolution: {integrity: sha512-QPOO5NO6Odv5lpoTkddtutccQjysJuFxoPS7fAHO+9m9udNHvTCPSAMW9zGAYj8lAIdr40I8yPCdUYrncXtrwg==} engines: {node: '>=4'} peerDependencies: '@typescript-eslint/parser': '*' @@ -2233,7 +2207,7 @@ packages: '@typescript-eslint/parser': optional: true dependencies: - '@typescript-eslint/parser': 6.15.0(eslint@8.56.0)(typescript@5.3.3) + '@typescript-eslint/parser': 6.13.2(eslint@8.55.0)(typescript@5.3.3) array-includes: 3.1.7 array.prototype.findlastindex: 1.2.3 array.prototype.flat: 1.3.2 @@ -2242,7 +2216,7 @@ packages: doctrine: 2.1.0 eslint: 8.56.0 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.15.0)(eslint-import-resolver-node@0.3.9)(eslint@8.56.0) + eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.13.2)(eslint-import-resolver-node@0.3.9)(eslint@8.55.0) hasown: 2.0.0 is-core-module: 2.13.1 is-glob: 4.0.3 @@ -2587,15 +2561,6 @@ packages: signal-exit: 3.0.7 dev: true - /form-data@4.0.0: - resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==} - engines: {node: '>= 6'} - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - mime-types: 2.1.35 - dev: false - /from2@2.3.0: resolution: {integrity: sha512-OMcX/4IC/uqEPVgGeyfN22LJk6AZrMkRZHxcHBMBvHScDGgwTm2GT2Wkgtocyd3JfZffjj2kYUDXXII0Fk9W0g==} dependencies: @@ -3409,11 +3374,6 @@ packages: resolution: {integrity: sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==} dev: false - /ky@1.0.1: - resolution: {integrity: sha512-UvcwpQO0LOuZwG0Ti3VDo6w57KYt+r4bWEYlNaMt82hgyFtse86QtOGum1RzsZni31FndXQl6NvtDArfunt2JQ==} - engines: {node: '>=18'} - dev: false - /ky@1.1.3: resolution: {integrity: sha512-t7q8sJfazzHbfYxiCtuLIH4P+pWoCgunDll17O/GBZBqMt2vHjGSx5HzSxhOc2BDEg3YN/EmeA7VKrHnwuWDag==} engines: {node: '>=18'} @@ -3623,20 +3583,8 @@ packages: picomatch: 2.3.1 dev: true - /mime-db@1.52.0: - resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} - engines: {node: '>= 0.6'} - dev: false - - /mime-types@2.1.35: - resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} - engines: {node: '>= 0.6'} - dependencies: - mime-db: 1.52.0 - dev: false - - /mime@4.0.1: - resolution: {integrity: sha512-5lZ5tyrIfliMXzFtkYyekWbtRXObT9OWa8IwQ5uxTBDHucNNwniRqo0yInflj+iYi5CBa6qxadGzGarDfuEOxA==} + /mime@4.0.0: + resolution: {integrity: sha512-pzhgdeqU5pJ9t5WK9m4RT4GgGWqYJylxUf62Yb9datXRwdcw5MjiD1BYI5evF8AgTXN9gtKX3CFLvCUL5fAhEA==} engines: {node: '>=16'} hasBin: true dev: true @@ -4068,10 +4016,6 @@ packages: load-json-file: 7.0.1 dev: true - /pako@2.1.0: - resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==} - dev: false - /parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -4428,16 +4372,16 @@ packages: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} dev: false - /semantic-release@22.0.12(typescript@5.3.3): - resolution: {integrity: sha512-0mhiCR/4sZb00RVFJIUlMuiBkW3NMpVIW2Gse7noqEMoFGkvfPPAImEQbkBV8xga4KOPP4FdTRYuLLy32R1fPw==} + /semantic-release@22.0.10(typescript@5.3.3): + resolution: {integrity: sha512-4ahPaOX+0UYpYlosjc/tfCzB/cqlnjN0/xSKGryEC4bOpuYSkoK+QHw7xDPmAuiMNBBvkFD+m3aVMENUr++CIg==} engines: {node: ^18.17 || >=20.6.1} hasBin: true dependencies: - '@semantic-release/commit-analyzer': 11.1.0(semantic-release@22.0.12) + '@semantic-release/commit-analyzer': 11.1.0(semantic-release@22.0.10) '@semantic-release/error': 4.0.0 - '@semantic-release/github': 9.2.6(semantic-release@22.0.12) - '@semantic-release/npm': 11.0.2(semantic-release@22.0.12) - '@semantic-release/release-notes-generator': 12.1.0(semantic-release@22.0.12) + '@semantic-release/github': 9.2.4(semantic-release@22.0.10) + '@semantic-release/npm': 11.0.1(semantic-release@22.0.10) + '@semantic-release/release-notes-generator': 12.1.0(semantic-release@22.0.10) aggregate-error: 5.0.0 cosmiconfig: 8.3.6(typescript@5.3.3) debug: 4.3.4 diff --git a/src/button.ts b/src-legacy/button.ts similarity index 100% rename from src/button.ts rename to src-legacy/button.ts diff --git a/src/buttons.ts b/src-legacy/buttons.ts similarity index 100% rename from src/buttons.ts rename to src-legacy/buttons.ts diff --git a/src-legacy/buttons/pagination.ts b/src-legacy/buttons/pagination.ts new file mode 100644 index 00000000..3a7d0279 --- /dev/null +++ b/src-legacy/buttons/pagination.ts @@ -0,0 +1,36 @@ +import { ButtonInteraction } from 'discord.js' + +import { PaginatedButton, PaginatedButtonAction } from '../button.js' +import { paginatedLevel } from '../components/paginated/paginatedLevel.js' +import { paginatedLevels } from '../components/paginated/paginatedLevels.js' +import { paginatedRankings } from '../components/paginated/paginatedRankings.js' +import { paginatedRecent } from '../components/paginated/paginatedRecent.js' + +export const pagination: PaginatedButton = { + name: 'paginationButton', + type: 'pagination', + run: async ( + interaction: ButtonInteraction, + command: string, + action: PaginatedButtonAction + ): Promise => { + switch (command) { + case 'recent': { + await paginatedRecent({ interaction, action }) + break + } + case 'level': { + await paginatedLevel({ interaction, action }) + break + } + case 'levels': { + await paginatedLevels({ interaction, action }) + break + } + case 'rankings': { + await paginatedRankings({ interaction, action }) + break + } + } + } +} diff --git a/src/command.ts b/src-legacy/command.ts similarity index 100% rename from src/command.ts rename to src-legacy/command.ts diff --git a/src/commands.ts b/src-legacy/commands.ts similarity index 100% rename from src/commands.ts rename to src-legacy/commands.ts diff --git a/src-legacy/commands/about.ts b/src-legacy/commands/about.ts new file mode 100644 index 00000000..00c908c2 --- /dev/null +++ b/src-legacy/commands/about.ts @@ -0,0 +1,107 @@ +import { + ActionRowBuilder, + ApplicationCommandType, + bold, + ButtonBuilder, + ButtonStyle, + EmbedBuilder, + hyperlink, + inlineCode +} from 'discord.js' + +import { Command } from '../command.js' +import { getReleases } from '../services/github.js' +import { inviteUrl } from '../utils/index.js' + +export const about: Command = { + name: 'about', + description: 'Useful information and how to support development', + type: ApplicationCommandType.ChatInput, + ephemeral: false, + run: async interaction => { + const commands = [ + `${bold(inlineCode('/about'))} to see this message again`, + `${bold( + inlineCode('/level') + )} to get information about a level (or list levels)`, + `${bold(inlineCode('/random'))} to get information about a random level`, + `${bold(inlineCode('/rankings'))} to get the world record leaderboard`, + `${bold( + inlineCode('/recent') + )} to get the most recent personal best records`, + `${bold(inlineCode('/user'))} to get information about a user` + ] + + const embed = new EmbedBuilder() + .setTitle('About') + .setColor(0xff_92_00) + .setDescription( + `This is a bot to show live user times and rankings for ${hyperlink( + 'Zeepkist', + 'https://store.steampowered.com/app/1440670' + )}\n\nAll data is provided by the Zeepkist GTR API and the Steam API.\n\nThe bot is not affiliated with Steam and only uses the Steam API to get ${bold( + 'public' + )} user information not provided by Zeepkist GTR. Data obtained from the Steam API is never stored by the bot.` + ) + .addFields( + { + name: 'Commands', + value: commands.join('\n') + }, + { + inline: true, + name: 'About the Bot', + value: `The discord bot is built by <@104736549081468928> and is open-source on ${hyperlink( + 'GitHub', + 'https://github.com/zeepkist/zeepkist-bot' + )}\n\nYou can support the development of the bot and their other open-source projects on ${hyperlink( + 'Ko-fi/wopian', + 'https://ko-fi.com/wopian' + )}` + }, + { + inline: true, + name: 'About Zeepkist GTR', + value: `Zeepkist GTR is a mod built by <@217779716289986560> to add a global time ranking system to ${hyperlink( + 'Zeepkist', + 'https://store.steampowered.com/app/1440670' + )}\n\nYou can support the development of Zeepkist GTR on ${hyperlink( + 'Ko-fi/thundernerd', + 'https://ko-fi.com/thundernerd' + )}` + } + ) + .setTimestamp() + .setFooter({ + text: `Version 1` + }) + + const latestRelease = await getReleases() + if (latestRelease.length > 0) { + embed.setTimestamp(new Date(latestRelease[0].published_at)) + embed.setFooter({ + text: latestRelease[0].tag_name + }) + } + + const buttons = new ActionRowBuilder().addComponents([ + new ButtonBuilder() + .setStyle(ButtonStyle.Link) + .setLabel('Invite the Bot') + .setURL(inviteUrl), + new ButtonBuilder() + .setStyle(ButtonStyle.Link) + .setLabel('Support the Bot') + .setURL('https://ko-fi.com/wopian'), + new ButtonBuilder() + .setStyle(ButtonStyle.Link) + .setLabel('Support Zeepkist GTR') + .setURL('https://ko-fi.com/thundernerd') + ]) + + await interaction.editReply({ + embeds: [embed], + components: [buttons] + }) + } +} diff --git a/src-legacy/commands/level.ts b/src-legacy/commands/level.ts new file mode 100644 index 00000000..8820c852 --- /dev/null +++ b/src-legacy/commands/level.ts @@ -0,0 +1,153 @@ +import { getLevel, getLevels, searchLevels } from '@zeepkist/gtr-api' +import { + ApplicationCommandOptionType, + ApplicationCommandType, + CommandInteraction, + EmbedBuilder +} from 'discord.js' + +import { Command } from '../command.js' +import { errorReply } from '../components/errorReply.js' +import { paginatedLevel } from '../components/paginated/paginatedLevel.js' +import { paginatedLevels } from '../components/paginated/paginatedLevels.js' +import { log } from '../utils/log.js' + +const getOptions = (interaction: CommandInteraction) => { + const id = interaction.options.data.find(option => option.name === 'id') + ?.value as number + const workshopId = interaction.options.data.find( + option => option.name === 'workshopid' + )?.value as string + const author = interaction.options.data.find( + option => option.name === 'author' + )?.value as string + const name = interaction.options.data.find(option => option.name === 'name') + ?.value as string + + const search = interaction.options.data.find( + option => option.name === 'search' + )?.value as string + + return { id, workshopId, author, name, search } +} + +const replyNoLevels = async ( + interaction: CommandInteraction, + invalidArguments = false +) => { + const embed = new EmbedBuilder() + .setColor(0xff_00_00) + .setTitle(invalidArguments ? 'Missing Arguments' : 'No level found') + .setDescription( + invalidArguments + ? 'You must provide either a level ID, workshop ID, author or name of a level.' + : 'No level found with the provided arguments.' + ) + .setTimestamp() + + await interaction.editReply({ embeds: [embed] }) +} + +export const level: Command = { + name: 'level', + description: 'Get records for a level', + type: ApplicationCommandType.ChatInput, + options: [ + { + name: 'search', + description: 'Search for a level by name or author', + type: ApplicationCommandOptionType.String, + required: false + }, + { + name: 'id', + description: 'The id of the level', + type: ApplicationCommandOptionType.String, + required: false + }, + { + name: 'workshopid', + description: 'The workshop id of the level(s)', + type: ApplicationCommandOptionType.String, + required: false + }, + { + name: 'author', + description: 'The exact author of the level(s)', + type: ApplicationCommandOptionType.String, + required: false + }, + { + name: 'name', + description: 'The exact name of the level(s)', + type: ApplicationCommandOptionType.String, + required: false + } + ], + ephemeral: false, + run: async (interaction: CommandInteraction) => { + const { id, workshopId, author, name, search } = getOptions(interaction) + log.info(`${id} ${workshopId} ${author} ${name} ${search}`, interaction) + + if (!id && !workshopId && !author && !name && !search) { + log.info('No arguments provided', interaction) + await replyNoLevels(interaction, true) + return + } + + if (id) { + try { + const level = await getLevel(id) + if (level) { + await paginatedLevel({ + interaction, + action: 'first', + query: { id } + }) + return + } + } catch (error: unknown) { + errorReply(interaction, level ? level.name : 'Unknown level', error) + return + } + } + + try { + const levels = await (search + ? searchLevels({ Query: search, Limit: 1 }) + : getLevels({ + WorkshopId: workshopId, + Author: author, + Name: name, + Limit: 1 + })) + + if (levels.totalAmount === 0) { + log.info('No levels found', interaction) + await replyNoLevels(interaction) + return + } + + log.info(`Found ${levels.totalAmount} levels`, interaction) + + if (levels.totalAmount > 1 && !search) { + await paginatedLevels({ + interaction, + action: 'first', + query: { id, workshopId, author, name } + }) + return + } + + if (levels.totalAmount === 1 || search) { + await paginatedLevel({ + interaction, + action: 'first', + query: { id: levels.levels[0].id } + }) + } + } catch { + await replyNoLevels(interaction, true) + } + } +} diff --git a/src-legacy/commands/random.ts b/src-legacy/commands/random.ts new file mode 100644 index 00000000..034bd27a --- /dev/null +++ b/src-legacy/commands/random.ts @@ -0,0 +1,38 @@ +import { getRandomLevels } from '@zeepkist/gtr-api' +import { ApplicationCommandType, CommandInteraction } from 'discord.js' + +import { Command } from '../command.js' +import { errorReply } from '../components/errorReply.js' +import { paginatedLevel } from '../components/paginated/paginatedLevel.js' +import { log } from '../utils/log.js' + +export const random: Command = { + name: 'random', + description: 'Get a random Zeepkist level', + type: ApplicationCommandType.ChatInput, + ephemeral: false, + run: async (interaction: CommandInteraction) => { + const levels = await getRandomLevels({ + Limit: 1 + }) + + if (levels.levels.length === 0) { + return errorReply( + interaction, + 'No levels found', + 'We could not find any levels.' + ) + } + + const level = levels.levels[0] + + log.info(`Got random level ${level.id} (${level.name})`) + + await paginatedLevel({ + interaction, + action: 'first', + query: { id: level.id } + }) + return + } +} diff --git a/src/commands/rankings.ts b/src-legacy/commands/rankings.ts similarity index 100% rename from src/commands/rankings.ts rename to src-legacy/commands/rankings.ts diff --git a/src/commands/recent.ts b/src-legacy/commands/recent.ts similarity index 100% rename from src/commands/recent.ts rename to src-legacy/commands/recent.ts diff --git a/src-legacy/commands/user.ts b/src-legacy/commands/user.ts new file mode 100644 index 00000000..18129d29 --- /dev/null +++ b/src-legacy/commands/user.ts @@ -0,0 +1,98 @@ +import { + getUser, + getUserByDiscordId, + getUserBySteamId +} from '@zeepkist/gtr-api' +import { + ApplicationCommandOptionType, + ApplicationCommandType, + CommandInteraction +} from 'discord.js' + +import { Command } from '../command.js' +import { userEmbed } from '../components/embeds/userEmbed.js' +import { userNotFoundEmbed } from '../components/embeds/userNotFoundEmbed.js' +import { log } from '../utils/index.js' + +export const user: Command = { + name: 'user', + description: 'Get information about a user.', + type: ApplicationCommandType.ChatInput, + options: [ + { + name: 'user', + description: 'Discord User', + type: ApplicationCommandOptionType.User, + required: false + }, + { + name: 'steamid', + description: "User's Steam ID.", + type: ApplicationCommandOptionType.String, + required: false, + minLength: 17, + maxLength: 17 + }, + { + name: 'id', + description: "User's internal ID.", + type: ApplicationCommandOptionType.String, + required: false, + minLength: 0 + }, + { + name: 'page', + description: 'Select the page to show', + type: ApplicationCommandOptionType.String, + required: false, + choices: [ + { + name: 'Stats', + value: 'stats' + }, + { + name: 'Records', + value: 'records' + } + ] + } + ], + ephemeral: false, + run: async (interaction: CommandInteraction) => { + let discordUser = interaction.options.data.find( + option => option.name === 'user' + )?.user + + const steamId = interaction.options.data.find( + option => option.name === 'steamid' + )?.value as string + + const id = interaction.options.data.find(option => option.name === 'id') + ?.value as number + + const page = interaction.options.data.find(option => option.name === 'page') + ?.value as string + + log.info( + `Discord ID: ${discordUser?.id}, Steam ID: ${steamId}, ID: ${id}, Page: ${page}`, + interaction + ) + + if (!discordUser?.id && !steamId && !id) { + discordUser = interaction.user + } + + try { + const user = discordUser?.id + ? await getUserByDiscordId(discordUser.id) + : steamId + ? await getUserBySteamId(steamId) + : await getUser(id) + log.info(`Found user: ${user.steamName}`, interaction) + await userEmbed(interaction, user, discordUser, page) + } catch (error) { + log.error(String(error), interaction) + userNotFoundEmbed(interaction) + } + } +} diff --git a/src/components/embeds/user/records.ts b/src-legacy/components/embeds/user/records.ts similarity index 100% rename from src/components/embeds/user/records.ts rename to src-legacy/components/embeds/user/records.ts diff --git a/src/components/embeds/user/stats.ts b/src-legacy/components/embeds/user/stats.ts similarity index 100% rename from src/components/embeds/user/stats.ts rename to src-legacy/components/embeds/user/stats.ts diff --git a/src/components/embeds/userEmbed.ts b/src-legacy/components/embeds/userEmbed.ts similarity index 100% rename from src/components/embeds/userEmbed.ts rename to src-legacy/components/embeds/userEmbed.ts diff --git a/src/components/embeds/userNotFoundEmbed.ts b/src-legacy/components/embeds/userNotFoundEmbed.ts similarity index 100% rename from src/components/embeds/userNotFoundEmbed.ts rename to src-legacy/components/embeds/userNotFoundEmbed.ts diff --git a/src/components/errorReply.ts b/src-legacy/components/errorReply.ts similarity index 100% rename from src/components/errorReply.ts rename to src-legacy/components/errorReply.ts diff --git a/src/components/fields/addMedalTimes.ts b/src-legacy/components/fields/addMedalTimes.ts similarity index 100% rename from src/components/fields/addMedalTimes.ts rename to src-legacy/components/fields/addMedalTimes.ts diff --git a/src/components/fields/addPersonalBest.ts b/src-legacy/components/fields/addPersonalBest.ts similarity index 100% rename from src/components/fields/addPersonalBest.ts rename to src-legacy/components/fields/addPersonalBest.ts diff --git a/src/components/getChannelMessage.ts b/src-legacy/components/getChannelMessage.ts similarity index 100% rename from src/components/getChannelMessage.ts rename to src-legacy/components/getChannelMessage.ts diff --git a/src/components/lists/listLevels.ts b/src-legacy/components/lists/listLevels.ts similarity index 100% rename from src/components/lists/listLevels.ts rename to src-legacy/components/lists/listLevels.ts diff --git a/src/components/lists/listRankings.ts b/src-legacy/components/lists/listRankings.ts similarity index 100% rename from src/components/lists/listRankings.ts rename to src-legacy/components/lists/listRankings.ts diff --git a/src/components/lists/listRecords.ts b/src-legacy/components/lists/listRecords.ts similarity index 100% rename from src/components/lists/listRecords.ts rename to src-legacy/components/lists/listRecords.ts diff --git a/src-legacy/components/paginated.ts b/src-legacy/components/paginated.ts new file mode 100644 index 00000000..ef4b84e8 --- /dev/null +++ b/src-legacy/components/paginated.ts @@ -0,0 +1,162 @@ +import { + ActionRowBuilder, + ButtonBuilder, + ButtonInteraction, + CommandInteraction, + EmbedBuilder +} from 'discord.js' + +import { PaginatedButtonAction } from '../button.js' +import { PAGINATION_LIMIT } from '../constants.js' +import { + PaginatedMessage, + PaginatedMessageQuery +} from '../models/database/paginatedMessage.js' +import { database } from '../services/database.js' +import { extractPages, log, providedBy } from '../utils/index.js' +import { paginationButtons } from './paginationButtons.js' + +export interface PaginatedData { + interaction: CommandInteraction | ButtonInteraction + action: PaginatedButtonAction + query?: PaginatedMessageQuery + limit?: number +} + +const setCurrentPage = ( + action: PaginatedButtonAction, + currentPage: number, + totalPages: number +) => { + switch (action) { + case 'first': { + return 1 + } + case 'previous': { + return currentPage - 1 + } + case 'next': { + return currentPage + 1 + } + case 'last': { + return totalPages + } + default: { + return currentPage + } + } +} + +export const getPaginatedData = async (properties: PaginatedData) => { + const { interaction, action, query, limit } = properties + + const isUpdatingMessage = interaction.isMessageComponent() + + const paginatedMessage = isUpdatingMessage + ? await database('paginated_messages') + .where({ + messageId: interaction.message.interaction?.id + }) + .select('query') + .first() + : undefined + + const activeQuery = paginatedMessage + ? (JSON.parse(paginatedMessage.query as string) as PaginatedMessageQuery) + : query ?? {} + + const pages = extractPages( + isUpdatingMessage ? interaction.message.embeds[0].footer?.text : undefined + ) + + const currentPage = setCurrentPage( + action, + pages.currentPage, + pages.totalPages + ) + const offset = (currentPage - 1) * (limit ?? PAGINATION_LIMIT) + + return { + interactionId: interaction.id, + query: activeQuery, + currentPage, + totalPages: pages.totalPages, + offset + } +} + +interface SendPaginatedMessage { + customId: string + interaction: CommandInteraction | ButtonInteraction + embed: EmbedBuilder + components?: ActionRowBuilder[] + query: PaginatedMessageQuery + currentPage: number + totalAmount: number + limit?: number +} + +export const sendPaginatedMessage = async ({ + customId, + interaction, + embed, + components, + currentPage, + totalAmount, + query, + limit +}: SendPaginatedMessage) => { + let totalPages = Math.ceil(totalAmount / (limit ?? PAGINATION_LIMIT)) + if (totalPages === 0) totalPages = 1 + + log.info( + `Obtained ${totalAmount} ${customId}. Showing page ${currentPage} of ${totalPages}`, + interaction + ) + + embed + .setColor(0xff_92_00) + .setFooter({ + text: `Page ${currentPage} of ${totalPages}. ${providedBy}` + }) + .setTimestamp() + + const pagination = paginationButtons( + interaction, + customId, + currentPage, + totalPages + ) + + const isUpdatingMessage = interaction.isMessageComponent() + const messageContent = { + embeds: [embed], + components: [ + ...(pagination ? [pagination] : []), + ...(components && components?.length > 0 ? components : []) + ] + } + + if (isUpdatingMessage) { + log.info( + `Updating message ${interaction.message.interaction?.id}`, + interaction + ) + await interaction.update(messageContent) + await database('paginated_messages') + .update({ + currentPage, + updatedAt: new Date(Date.now()) + }) + .where({ + messageId: interaction.message.interaction?.id + }) + } else { + log.info(`Sending new message`, interaction) + const response = await interaction.editReply(messageContent) + await database('paginated_messages').insert({ + messageId: response.interaction?.id, + query: JSON.stringify(query) + }) + } +} diff --git a/src/components/paginated/paginatedLevel.ts b/src-legacy/components/paginated/paginatedLevel.ts similarity index 100% rename from src/components/paginated/paginatedLevel.ts rename to src-legacy/components/paginated/paginatedLevel.ts diff --git a/src/components/paginated/paginatedLevels.ts b/src-legacy/components/paginated/paginatedLevels.ts similarity index 100% rename from src/components/paginated/paginatedLevels.ts rename to src-legacy/components/paginated/paginatedLevels.ts diff --git a/src/components/paginated/paginatedRankings.ts b/src-legacy/components/paginated/paginatedRankings.ts similarity index 100% rename from src/components/paginated/paginatedRankings.ts rename to src-legacy/components/paginated/paginatedRankings.ts diff --git a/src/components/paginated/paginatedRecent.ts b/src-legacy/components/paginated/paginatedRecent.ts similarity index 100% rename from src/components/paginated/paginatedRecent.ts rename to src-legacy/components/paginated/paginatedRecent.ts diff --git a/src/components/paginationButtons.ts b/src-legacy/components/paginationButtons.ts similarity index 100% rename from src/components/paginationButtons.ts rename to src-legacy/components/paginationButtons.ts diff --git a/src/components/trackCommandUsage.ts b/src-legacy/components/trackCommandUsage.ts similarity index 100% rename from src/components/trackCommandUsage.ts rename to src-legacy/components/trackCommandUsage.ts diff --git a/src/components/twitch/component.ts b/src-legacy/components/twitch/component.ts similarity index 100% rename from src/components/twitch/component.ts rename to src-legacy/components/twitch/component.ts diff --git a/src/components/twitch/createMessage.ts b/src-legacy/components/twitch/createMessage.ts similarity index 100% rename from src/components/twitch/createMessage.ts rename to src-legacy/components/twitch/createMessage.ts diff --git a/src/components/twitch/embed.ts b/src-legacy/components/twitch/embed.ts similarity index 100% rename from src/components/twitch/embed.ts rename to src-legacy/components/twitch/embed.ts diff --git a/src/components/twitch/embedEnded.ts b/src-legacy/components/twitch/embedEnded.ts similarity index 100% rename from src/components/twitch/embedEnded.ts rename to src-legacy/components/twitch/embedEnded.ts diff --git a/src/components/twitch/monthlyStats.ts b/src-legacy/components/twitch/monthlyStats.ts similarity index 100% rename from src/components/twitch/monthlyStats.ts rename to src-legacy/components/twitch/monthlyStats.ts diff --git a/src/components/twitch/statsEmbed.ts b/src-legacy/components/twitch/statsEmbed.ts similarity index 100% rename from src/components/twitch/statsEmbed.ts rename to src-legacy/components/twitch/statsEmbed.ts diff --git a/src/components/twitch/updateMessage.ts b/src-legacy/components/twitch/updateMessage.ts similarity index 100% rename from src/components/twitch/updateMessage.ts rename to src-legacy/components/twitch/updateMessage.ts diff --git a/src/constants.ts b/src-legacy/constants.ts similarity index 100% rename from src/constants.ts rename to src-legacy/constants.ts diff --git a/src/contextMenu.ts b/src-legacy/contextMenu.ts similarity index 100% rename from src/contextMenu.ts rename to src-legacy/contextMenu.ts diff --git a/src/contextMenus.ts b/src-legacy/contextMenus.ts similarity index 100% rename from src/contextMenus.ts rename to src-legacy/contextMenus.ts diff --git a/src/contextMenus/user.ts b/src-legacy/contextMenus/user.ts similarity index 100% rename from src/contextMenus/user.ts rename to src-legacy/contextMenus/user.ts diff --git a/src-legacy/index.ts b/src-legacy/index.ts new file mode 100644 index 00000000..08cb43d0 --- /dev/null +++ b/src-legacy/index.ts @@ -0,0 +1,32 @@ +import { Client, Events, GatewayIntentBits } from 'discord.js' +import { config } from 'dotenv' + +import interactionCreate from './listeners/interactionCreate.js' +import ready from './listeners/ready.js' +import { twitchStreams } from './listeners/twitchStreams.js' +import { log } from './utils/index.js' + +config() + +log.info('Bot is starting') + +const client = new Client({ + intents: [GatewayIntentBits.Guilds] +}) + +client.once(Events.ClientReady, ready) +interactionCreate(client) + +client.login(process.env.DISCORD_TOKEN) + +client.on('disconnect', () => { + log.info('Bot has disconnected, logging back in') + client.login(process.env.DISCORD_TOKEN) + log.info('Bot has reconnected') +}) + +client.on('error', (error: Error) => { + log.error(`discord.js encountered an error: ${String(error)}`) +}) + +twitchStreams(client) diff --git a/src/listeners/interactionCreate.ts b/src-legacy/listeners/interactionCreate.ts similarity index 100% rename from src/listeners/interactionCreate.ts rename to src-legacy/listeners/interactionCreate.ts diff --git a/src/listeners/ready.ts b/src-legacy/listeners/ready.ts similarity index 100% rename from src/listeners/ready.ts rename to src-legacy/listeners/ready.ts diff --git a/src/listeners/twitchStreams.ts b/src-legacy/listeners/twitchStreams.ts similarity index 100% rename from src/listeners/twitchStreams.ts rename to src-legacy/listeners/twitchStreams.ts diff --git a/src/models/collector.ts b/src-legacy/models/collector.ts similarity index 100% rename from src/models/collector.ts rename to src-legacy/models/collector.ts diff --git a/src/models/database/paginatedMessage.ts b/src-legacy/models/database/paginatedMessage.ts similarity index 100% rename from src/models/database/paginatedMessage.ts rename to src-legacy/models/database/paginatedMessage.ts diff --git a/src/models/github.ts b/src-legacy/models/github.ts similarity index 100% rename from src/models/github.ts rename to src-legacy/models/github.ts diff --git a/src/models/level.ts b/src-legacy/models/level.ts similarity index 100% rename from src/models/level.ts rename to src-legacy/models/level.ts diff --git a/src/models/record.ts b/src-legacy/models/record.ts similarity index 100% rename from src/models/record.ts rename to src-legacy/models/record.ts diff --git a/src/models/steam.ts b/src-legacy/models/steam.ts similarity index 100% rename from src/models/steam.ts rename to src-legacy/models/steam.ts diff --git a/src/models/twitch.ts b/src-legacy/models/twitch.ts similarity index 100% rename from src/models/twitch.ts rename to src-legacy/models/twitch.ts diff --git a/src/models/user.ts b/src-legacy/models/user.ts similarity index 100% rename from src/models/user.ts rename to src-legacy/models/user.ts diff --git a/src-legacy/services/database.ts b/src-legacy/services/database.ts new file mode 100644 index 00000000..35068efd --- /dev/null +++ b/src-legacy/services/database.ts @@ -0,0 +1,103 @@ +import { config } from 'dotenv' +import knex from 'knex' + +import { log } from '../utils/index.js' + +config() + +export const database = knex.knex({ + client: 'mysql2', + connection: { + host: process.env.DATABASE_HOST, + port: Number.parseInt(process.env.DATABASE_PORT ?? ''), + user: process.env.DATABASE_USER, + password: process.env.DATABASE_PASSWORD, + database: process.env.DATABASE_NAME + } +}) + +const initialiseDatabase = async () => { + const commandUsage = await database.schema.hasTable('command_usage') + if (!commandUsage) { + log.info('Creating table: command_usage') + await database.schema.createTable('command_usage', table => { + table.string('commandName', 64).notNullable().primary().unique().index() + table.integer('invocations').notNullable().defaultTo(0) + table.timestamp('lastUsed').notNullable().defaultTo(database.fn.now()) + }) + } + + const paginatedMessages = await database.schema.hasTable('paginated_messages') + if (!paginatedMessages) { + log.info('Creating table: paginated_messages') + await database.schema.createTable('paginated_messages', table => { + table.string('messageId', 19).notNullable().primary().unique().index() + table.integer('currentPage').notNullable().defaultTo(1) + table.json('query').notNullable().defaultTo({}) + table.timestamp('createdAt').notNullable().defaultTo(database.fn.now()) + table.timestamp('updatedAt').notNullable().defaultTo(database.fn.now()) + }) + } + + const twitchStreams = await database.schema.hasTable('twitch_streams') + if (!twitchStreams) { + log.info('Creating table: twitch_streams') + await database.schema.createTable('twitch_streams', table => { + table.string('messageId').notNullable().unique().index().primary() + table.string('streamId', 36).notNullable().index().unique() + table.string('userId', 36).notNullable().index() + table.string('userName', 64).notNullable().index() + table.boolean('isLive').notNullable().defaultTo(true) + table.integer('viewers').notNullable().defaultTo(0) + table.integer('peakViewers').notNullable().defaultTo(0) + table + .text('profilePictureUrl') + .notNullable() + .defaultTo( + 'https://res.cloudinary.com/startup-grind/image/upload/c_fill,f_auto,g_center,q_auto:good/v1/gcs/platform-data-twitch/contentbuilder/community-meetups_event-thumbnail_400x400.png' + ) + table.timestamp('createdAt').notNullable().defaultTo(database.fn.now()) + table.timestamp('updatedAt').notNullable().defaultTo(database.fn.now()) + }) + } + + /* + const twitchUserStats = await database.schema.hasTable('twitch_user_stats') + if (!twitchUserStats) { + log.info('Creating table: twitch_user_stats') + await database.schema.createTable('twitch_stats', table => { + table.string('userId', 36).notNullable().index().primary() + table.foreign('statPeriodId').references('twitch_stats.id') + table.integer('averageViewers').notNullable().defaultTo(0) + table.integer('peakViewers').notNullable().defaultTo(0) + table.integer('totalViewers').notNullable().defaultTo(0) + table.integer('totalStreams').notNullable().defaultTo(0) + table.boolean('newStreamer').notNullable().defaultTo(false) + table.timestamp('createdAt').notNullable().defaultTo(database.fn.now()) + }) + } + */ + + const twitchStats = await database.schema.hasTable('twitch_stats') + if (!twitchStats) { + log.info('Creating table: twitch_stats') + await database.schema.createTable('twitch_stats', table => { + table.integer('totalStreams').notNullable() + table.integer('totalStreamers').notNullable() + table.integer('totalViewers').notNullable() + table.integer('mostDailyViewers').notNullable() + table.integer('mostDailyViewersDay').notNullable() + table.integer('averageViewers').notNullable() + table.integer('averageStreamsStreamer').notNullable() + table.string('streamerMostStreamsUserName').notNullable() + table.integer('streamerMostStreamsValue').notNullable() + table.string('streamerPeakViewersUserName').notNullable() + table.integer('streamerPeakViewersValue').notNullable() + table.string('streamerAverageViewersUserName').notNullable() + table.integer('streamerAverageViewersValue').notNullable() + table.timestamp('createdAt').notNullable().defaultTo(database.fn.now()) + }) + } +} + +initialiseDatabase() diff --git a/src/services/database/twitchStreams.ts b/src-legacy/services/database/twitchStreams.ts similarity index 100% rename from src/services/database/twitchStreams.ts rename to src-legacy/services/database/twitchStreams.ts diff --git a/src/services/github.ts b/src-legacy/services/github.ts similarity index 100% rename from src/services/github.ts rename to src-legacy/services/github.ts diff --git a/src/services/steam.ts b/src-legacy/services/steam.ts similarity index 100% rename from src/services/steam.ts rename to src-legacy/services/steam.ts diff --git a/src/services/twitch.ts b/src-legacy/services/twitch.ts similarity index 100% rename from src/services/twitch.ts rename to src-legacy/services/twitch.ts diff --git a/src-legacy/utils/bestMedal.spec.ts b/src-legacy/utils/bestMedal.spec.ts new file mode 100644 index 00000000..0fad4364 --- /dev/null +++ b/src-legacy/utils/bestMedal.spec.ts @@ -0,0 +1,81 @@ +import test from 'ava' + +import { LevelRecord } from '../models/record.js' +import { bestMedal } from './index.js' + +const macro = test.macro((t, input: LevelRecord, expected: string) => + t.is(bestMedal(input), expected) +) + +const level = { + id: 1, + uniqueId: '1', + workshopId: '1', + name: 'Test Level', + author: 'Test User', + timeAuthor: 30.4532, + timeGold: 30.6, + timeSilver: 32, + timeBronze: 36, + thumbnailUrl: '' +} + +const createLevelRecord = ( + time: number, + isWorldRecord = false +): LevelRecord => ({ + id: 1, + dateCreated: '2021-01-01T00:00:00.000Z', + time, + splits: [] as number[], + ghostUrl: '', + screenshotUrl: '', + isBest: false, + isValid: true, + isWorldRecord, + gameVersion: '', + user: { id: 1, steamId: '1', steamName: 'Test User' }, + level +}) + +test( + 'displays world record medal', + macro, + createLevelRecord(30.4, true), + '<:wr:1065822034090799135>' +) + +test( + 'displays author medal', + macro, + createLevelRecord(30.4), + '<:author:1065842677020626944>' +) + +test( + 'displays gold medal', + macro, + createLevelRecord(30.5), + '<:gold:1065842710365351947>' +) + +test( + 'displays silver medal', + macro, + createLevelRecord(31), + '<:silver:1065842724433051748>' +) + +test( + 'displays bronze medal', + macro, + createLevelRecord(33), + '<:bronze:1065842732259606578>' +) + +test( + 'displays no medal', + macro, + createLevelRecord(38), + '<:blank:1065818232734351390>' +) diff --git a/src-legacy/utils/bestMedal.ts b/src-legacy/utils/bestMedal.ts new file mode 100644 index 00000000..a6529f1d --- /dev/null +++ b/src-legacy/utils/bestMedal.ts @@ -0,0 +1,11 @@ +import { LevelRecord } from '../models/record.js' +import { MEDAL } from './medal.js' + +export const bestMedal = (record: LevelRecord) => { + if (record.isWorldRecord) return MEDAL.WR + else if (record.time < record.level.timeAuthor) return MEDAL.AUTHOR + else if (record.time < record.level.timeGold) return MEDAL.GOLD + else if (record.time < record.level.timeSilver) return MEDAL.SILVER + else if (record.time < record.level.timeBronze) return MEDAL.BRONZE + else return MEDAL.NONE +} diff --git a/src-legacy/utils/extractPages.spec.ts b/src-legacy/utils/extractPages.spec.ts new file mode 100644 index 00000000..b51ffe22 --- /dev/null +++ b/src-legacy/utils/extractPages.spec.ts @@ -0,0 +1,29 @@ +import test from 'ava' + +import { extractPages } from './index.js' + +interface Expected { + currentPage: number + totalPages: number +} + +const macro = test.macro((t, input: string, expected: Expected) => + t.deepEqual(extractPages(input), expected) +) + +test('extracts pages 0/0', macro, 'Page 0 of 0', { + currentPage: 0, + totalPages: 0 +}) +test('extracts pages 1/2', macro, 'Page 1 of 2', { + currentPage: 1, + totalPages: 2 +}) +test('extracts pages 2/2', macro, 'Page 2 of 2', { + currentPage: 2, + totalPages: 2 +}) +test('extracts pages 53/743', macro, 'Page 53 of 743', { + currentPage: 53, + totalPages: 743 +}) diff --git a/src-legacy/utils/extractPages.ts b/src-legacy/utils/extractPages.ts new file mode 100644 index 00000000..9450f998 --- /dev/null +++ b/src-legacy/utils/extractPages.ts @@ -0,0 +1,10 @@ +export const extractPages = (string?: string) => { + if (!string) return { currentPage: 0, totalPages: 0 } + const pages = string.split('Page ')[1].split('.')[0] + const [currentPage, totalPages] = pages.split(' of ') + + return { + currentPage: Number.parseInt(currentPage), + totalPages: Number.parseInt(totalPages) + } +} diff --git a/src-legacy/utils/format.spec.ts b/src-legacy/utils/format.spec.ts new file mode 100644 index 00000000..ccc3ef4b --- /dev/null +++ b/src-legacy/utils/format.spec.ts @@ -0,0 +1,75 @@ +import test from 'ava' + +import { Level } from '../models/level.js' +import { User } from '../models/user.js' +import { formatLevel, formatOrdinal, formatRank, formatUser } from './index.js' + +const macro = test.macro((t, input: string, expected: string) => + t.is(input, expected) +) + +// formatRank + +test('displays rank 1', macro, formatRank(1), '**β€‡πŸ·**') +test('displays rank 10', macro, formatRank(2), '**β€‡πŸΈ**') +test('displays rank 100', macro, formatRank(100), '**𝟷𝟢𝟢**') +test('displays rank 1000', macro, formatRank(1000), '**𝟷𝟢𝟢𝟢**') + +// formatLevel + +const level: Level = { + id: 1, + uniqueId: '1', + workshopId: '1', + name: 'Level 1', + author: 'Author Name', + timeAuthor: 30.4532, + timeGold: 30.6, + timeSilver: 32, + timeBronze: 36, + thumbnailUrl: '' +} +test( + 'displays level link', + macro, + formatLevel(level), + '[Level 1](https://zeepki.st/level/1) by _Author Name_' +) + +// formatUser + +const user: User = { + id: 1, + steamId: '2', + steamName: 'User Name' +} +test( + 'displays user link', + macro, + formatUser(user), + '[User Name](https://zeepki.st/user/2)' +) + +// formatOrdinal + +test('displays ordinal 1', macro, formatOrdinal(1), '1st') +test('displays ordinal 2', macro, formatOrdinal(2), '2nd') +test('displays ordinal 3', macro, formatOrdinal(3), '3rd') +test('displays ordinal 4', macro, formatOrdinal(4), '4th') + +test('displays ordinal 11', macro, formatOrdinal(11), '11th') +test('displays ordinal 12', macro, formatOrdinal(12), '12th') +test('displays ordinal 13', macro, formatOrdinal(13), '13th') +test('displays ordinal 21', macro, formatOrdinal(21), '21st') +test('displays ordinal 22', macro, formatOrdinal(22), '22nd') +test('displays ordinal 23', macro, formatOrdinal(23), '23rd') + +test('displays ordinal 101', macro, formatOrdinal(101), '101st') +test('displays ordinal 102', macro, formatOrdinal(102), '102nd') +test('displays ordinal 103', macro, formatOrdinal(103), '103rd') +test('displays ordinal 111', macro, formatOrdinal(111), '111th') +test('displays ordinal 112', macro, formatOrdinal(112), '112th') +test('displays ordinal 113', macro, formatOrdinal(113), '113th') +test('displays ordinal 121', macro, formatOrdinal(121), '121st') +test('displays ordinal 122', macro, formatOrdinal(122), '122nd') +test('displays ordinal 123', macro, formatOrdinal(123), '123rd') diff --git a/src-legacy/utils/format.ts b/src-legacy/utils/format.ts new file mode 100644 index 00000000..c6232bcd --- /dev/null +++ b/src-legacy/utils/format.ts @@ -0,0 +1,52 @@ +import { formatDistanceToNowStrict } from 'date-fns' +import { bold, hyperlink, italic } from 'discord.js' + +import { ZEEPKIST_URL } from '../constants.js' +import { Level } from '../models/level.js' +import { User } from '../models/user.js' +import { numberToMonospace } from './index.js' + +export const formatRank = (rank: number): string => + bold(`${numberToMonospace(rank)}`.padStart(3, ' ')) + +export const formatLevel = (level: Level): string => + `${hyperlink(level.name, `${ZEEPKIST_URL}/level/${level.id}`)} by ${italic( + level.author + )}` + +export const formatUser = (user: User): string => + hyperlink(user.steamName, `${ZEEPKIST_URL}/user/${user.steamId}`) + +export const formatRelativeDate = (date: string) => { + return formatDistanceToNowStrict(new Date(date), { + addSuffix: true + }) + .replaceAll('second', 'sec') + .replaceAll('minute', 'min') +} + +const pad = (number: number, size: number) => + ('00000' + number).slice(size * -1) + +export const formatResultTime = (input: number, precision = 4) => { + const time = Number.parseFloat(input.toFixed(precision)) + const hours = Math.floor(time / 60 / 60) + const minutes = Math.floor(time / 60) % 60 + const seconds = Math.floor(time - minutes * 60) + const milliseconds = Number.parseInt(input.toFixed(precision).split('.')[1]) + + let string = '' + if (hours) string += `${pad(hours, 2)}:` + return (string += `${pad(minutes, 2)}:${pad(seconds, 2)}.${pad( + milliseconds, + precision + )}`) +} + +export const formatOrdinal = (number: number) => { + const ordinals = ['th', 'st', 'nd', 'rd'] + const modulo = number % 100 + return ( + number + (ordinals[(modulo - 20) % 10] || ordinals[modulo] || ordinals[0]) + ) +} diff --git a/src-legacy/utils/formatThumbnailEmbed.ts b/src-legacy/utils/formatThumbnailEmbed.ts new file mode 100644 index 00000000..5826ce01 --- /dev/null +++ b/src-legacy/utils/formatThumbnailEmbed.ts @@ -0,0 +1,37 @@ +import { URL } from 'node:url' + +/** + * Converts the following URL: + * + * https://storage.googleapis.com/download/storage/v1/b/zeepkist-gtr/o/thumbnails%2F28032023-213839968-Happydr-791691801402-543.jpeg?generation=1680194142366761&alt=media + * + * to this URL that can be used as an embed thumbnail: + * + * https://storage.googleapis.com/zeepkist-gtr/thumbnails/28032023-213839968-Happydr-791691801402-543.jpeg + */ +export const formatThumbnailEmbed = (url: string): string => { + if ( + url.startsWith('https://storage.googleapis.com/zeepkist-gtr/thumbnails/') + ) { + return new URL(url).toString() + } + + const baseUrlRegex = + /^(https?:\/\/[^/]+)\/download\/storage\/v1\/b\/([^/]+)\/o\// + const queryParametersRegex = /\?.*$/ + const match = url.match(baseUrlRegex) + if (!match) { + throw new Error('Invalid URL format') + } + const baseUrl = match[1] + '/' + match[2] + '/' + const path = url.replace(baseUrlRegex, '').replace(queryParametersRegex, '') + const decodedPath = decodeURIComponent(path) + const parts = decodedPath.split('/') + const newParts = parts.map(part => { + return part.startsWith('thumbnails%2F') + ? part.replace('thumbnails%2F', '') + : part + }) + const newPath = newParts.join('/') + return new URL(baseUrl + newPath).toString() +} diff --git a/src-legacy/utils/index.ts b/src-legacy/utils/index.ts new file mode 100644 index 00000000..1a3b543e --- /dev/null +++ b/src-legacy/utils/index.ts @@ -0,0 +1,11 @@ +export * from './bestMedal.js' +export * from './extractPages.js' +export * from './format.js' +export * from './formatThumbnailEmbed.js' +export * from './inviteUrl.js' +export * from './log.js' +export * from './medal.js' +export * from './numberToMonospace.js' +export * from './providedBy.js' +export * from './toDistance.js' +export * from './toDuration.js' diff --git a/src-legacy/utils/inviteUrl.spec.ts b/src-legacy/utils/inviteUrl.spec.ts new file mode 100644 index 00000000..e8ccee5f --- /dev/null +++ b/src-legacy/utils/inviteUrl.spec.ts @@ -0,0 +1,14 @@ +import test from 'ava' + +import { inviteUrl } from './index.js' + +const macro = test.macro((t, input: string, expected: string) => + t.is(input, expected) +) + +test( + 'returns canary invite url', + macro, + inviteUrl, + 'https://discord.com/oauth2/authorize?client_id=1014233853147230308&permissions=0&scope=bot%20applications.commands' +) diff --git a/src-legacy/utils/inviteUrl.ts b/src-legacy/utils/inviteUrl.ts new file mode 100644 index 00000000..71a7ebd3 --- /dev/null +++ b/src-legacy/utils/inviteUrl.ts @@ -0,0 +1,13 @@ +// https://discord.com/developers/applications/1014233853147230308/bot + +import { IS_PRODUCTION } from '../constants.js' + +const clientIdCanary = '1014233853147230308' +const clientIdProduction = '1064354910612762674' + +const getLink = (clientId: string) => + `https://discord.com/oauth2/authorize?client_id=${clientId}&permissions=0&scope=bot%20applications.commands` + +export const inviteUrl = getLink( + IS_PRODUCTION ? clientIdProduction : clientIdCanary +) diff --git a/src-legacy/utils/log.ts b/src-legacy/utils/log.ts new file mode 100644 index 00000000..84a7222e --- /dev/null +++ b/src-legacy/utils/log.ts @@ -0,0 +1,82 @@ +import { + ButtonInteraction, + CommandInteraction, + ModalSubmitInteraction +} from 'discord.js' +import { createLogger, format, transports } from 'winston' +import DailyRotateFile from 'winston-daily-rotate-file' + +const logFormat = format.printf(({ level, message, label, timestamp }) => { + return label + ? `${timestamp} ${level}: ${label} ${message}` + : `${timestamp} ${level}: ${message}` +}) + +const rotatingTransport: DailyRotateFile = new DailyRotateFile({ + filename: '%DATE%.log', + dirname: 'logs', + datePattern: 'YYYY-MM-DD', + handleExceptions: true, + handleRejections: true, + // zippedArchive: true, + maxSize: '5m', + maxFiles: '14d' +}) + +const logger = createLogger({ + level: 'info', + format: format.combine(format.timestamp(), logFormat), + transports: [ + rotatingTransport, + new transports.File({ + filename: 'logs/error.log', + handleExceptions: true, + handleRejections: true, + level: 'error' + }), + new transports.Console({ + handleExceptions: true, + handleRejections: true, + format: format.combine(format.colorize(), format.timestamp(), logFormat) + }) + ] +}) + +interface LogProperties { + interaction: CommandInteraction | ButtonInteraction | ModalSubmitInteraction +} + +const guildName = (interaction: LogProperties['interaction']) => + interaction.guild?.name || 'Unknown guild' + +const interactionName = (interaction: LogProperties['interaction']) => + interaction instanceof CommandInteraction + ? interaction.commandName + : interaction.customId + +export const log = { + info: (message: string, interaction?: LogProperties['interaction']) => { + if (interaction) { + const guild = guildName(interaction) + const name = interactionName(interaction) + + logger.info(message, { + label: `[${guild}][${name}]` + }) + } else { + logger.info(message) + } + }, + error: (message: string, interaction?: LogProperties['interaction']) => { + if (interaction) { + const guild = guildName(interaction) + const name = interactionName(interaction) + + logger.error(message, { + label: `[${guild}][${name}]` + }) + } else { + logger.error(message) + } + } +} diff --git a/src-legacy/utils/medal.spec.ts b/src-legacy/utils/medal.spec.ts new file mode 100644 index 00000000..d62c6cf1 --- /dev/null +++ b/src-legacy/utils/medal.spec.ts @@ -0,0 +1,29 @@ +import test from 'ava' + +import { MEDAL } from './index.js' + +const macro = test.macro((t, input: string, expected: string) => + t.is(input, expected) +) + +test('returns world record medal', macro, MEDAL.WR, '<:wr:1065822034090799135>') +test( + 'returns author medal', + macro, + MEDAL.AUTHOR, + '<:author:1065842677020626944>' +) +test('returns gold medal', macro, MEDAL.GOLD, '<:gold:1065842710365351947>') +test( + 'returns silver medal', + macro, + MEDAL.SILVER, + '<:silver:1065842724433051748>' +) +test( + 'returns bronze medal', + macro, + MEDAL.BRONZE, + '<:bronze:1065842732259606578>' +) +test('returns no medal', macro, MEDAL.NONE, '<:blank:1065818232734351390>') diff --git a/src-legacy/utils/medal.ts b/src-legacy/utils/medal.ts new file mode 100644 index 00000000..83a0c814 --- /dev/null +++ b/src-legacy/utils/medal.ts @@ -0,0 +1,18 @@ +import { IS_PRODUCTION } from '../constants.js' + +export const MEDAL = { + WR: '<:wr:1065822034090799135>', + AUTHOR: IS_PRODUCTION + ? '<:zeepkist_author:1008786679173234688>' + : '<:author:1065842677020626944>', + GOLD: IS_PRODUCTION + ? '<:zeepkist_gold:1008786743706783826>' + : '<:gold:1065842710365351947>', + SILVER: IS_PRODUCTION + ? '<:zeepkist_silver:1008786769380130959>' + : '<:silver:1065842724433051748>', + BRONZE: IS_PRODUCTION + ? '<:zeepkist_bronze:1008786713688166400>' + : '<:bronze:1065842732259606578>', + NONE: '<:blank:1065818232734351390>' +} diff --git a/src-legacy/utils/numberToMonospace.spec.ts b/src-legacy/utils/numberToMonospace.spec.ts new file mode 100644 index 00000000..7a941a98 --- /dev/null +++ b/src-legacy/utils/numberToMonospace.spec.ts @@ -0,0 +1,24 @@ +import test from 'ava' + +import { numberToMonospace } from './index.js' + +const macro = test.macro((t, input: number, expected: string) => + t.is(numberToMonospace(input), expected) +) + +test('displays 0', macro, 0, '𝟢') +test('displays 1', macro, 1, '𝟷') +test('displays 2', macro, 2, '𝟸') +test('displays 3', macro, 3, '𝟹') +test('displays 4', macro, 4, '𝟺') +test('displays 5', macro, 5, '𝟻') +test('displays 6', macro, 6, '𝟼') +test('displays 7', macro, 7, '𝟽') +test('displays 8', macro, 8, '𝟾') +test('displays 9', macro, 9, '𝟿') +test('displays 10', macro, 10, '𝟷𝟢') +test('displays 23', macro, 23, '𝟸𝟹') +test('displays 100', macro, 100, '𝟷𝟢𝟢') +test('displays 456', macro, 456, '𝟺𝟻𝟼') +test('displays 1000', macro, 1000, '𝟷𝟢𝟢𝟢') +test('displays 7890', macro, 7890, '𝟽𝟾𝟿𝟢') diff --git a/src-legacy/utils/numberToMonospace.ts b/src-legacy/utils/numberToMonospace.ts new file mode 100644 index 00000000..ccfbb4c2 --- /dev/null +++ b/src-legacy/utils/numberToMonospace.ts @@ -0,0 +1,4 @@ +export const numberToMonospace = (number: number) => { + const digits = ['𝟢', '𝟷', '𝟸', '𝟹', '𝟺', '𝟻', '𝟼', '𝟽', '𝟾', '𝟿'] + return [...number.toString()].map(digit => digits[Number(digit)]).join('') +} diff --git a/src-legacy/utils/providedBy.spec.ts b/src-legacy/utils/providedBy.spec.ts new file mode 100644 index 00000000..2fb5634a --- /dev/null +++ b/src-legacy/utils/providedBy.spec.ts @@ -0,0 +1,7 @@ +import test from 'ava' + +import { providedBy } from './index.js' + +const macro = test.macro((t, expected: string) => t.is(providedBy, expected)) + +test('displays provided by', macro, 'Data provided by Zeepkist GTR') diff --git a/src-legacy/utils/providedBy.ts b/src-legacy/utils/providedBy.ts new file mode 100644 index 00000000..535877e9 --- /dev/null +++ b/src-legacy/utils/providedBy.ts @@ -0,0 +1 @@ +export const providedBy = 'Data provided by Zeepkist GTR' diff --git a/src-legacy/utils/toDistance.ts b/src-legacy/utils/toDistance.ts new file mode 100644 index 00000000..f81524ce --- /dev/null +++ b/src-legacy/utils/toDistance.ts @@ -0,0 +1,11 @@ +export const toDistance = (metres = 0) => { + if (metres < 0.01) { + return '0m' + } else if (metres < 1000) { + return `${metres.toFixed(2)}m` + } else if (metres < 149_597_870_700) { + return `${(metres / 1000).toFixed(2)}km` + } else { + return `${(metres / 149_597_870_700).toFixed(2)}au` + } +} diff --git a/src-legacy/utils/toDuration.ts b/src-legacy/utils/toDuration.ts new file mode 100644 index 00000000..fd85bf7a --- /dev/null +++ b/src-legacy/utils/toDuration.ts @@ -0,0 +1,15 @@ +export const toDuration = (seconds = 0) => { + const hours = Math.floor(seconds / 3600) + const minutes = Math.floor((seconds - hours * 3600) / 60) + const remainingSeconds = seconds - hours * 3600 - minutes * 60 + + const hoursString = hours ? `${hours}h ` : '' + const minutesString = minutes ? `${minutes}m ` : '' + const secondsString = remainingSeconds + ? `${remainingSeconds.toFixed(0)}s` + : '' + + const duration = `${hoursString}${minutesString}${secondsString}` + + return duration === '' ? '0s' : duration +} diff --git a/src/buttons/pagination.ts b/src/buttons/pagination.ts index 3a7d0279..39ed0b05 100644 --- a/src/buttons/pagination.ts +++ b/src/buttons/pagination.ts @@ -1,10 +1,10 @@ import { ButtonInteraction } from 'discord.js' -import { PaginatedButton, PaginatedButtonAction } from '../button.js' -import { paginatedLevel } from '../components/paginated/paginatedLevel.js' -import { paginatedLevels } from '../components/paginated/paginatedLevels.js' -import { paginatedRankings } from '../components/paginated/paginatedRankings.js' -import { paginatedRecent } from '../components/paginated/paginatedRecent.js' +import { createLeaderboard } from '../components/createLeaderboard.js' +import { createRecords } from '../components/createRecords.js' +import { PaginatedButtonTypeEnum } from '../enums/index.js' +import { PaginatedButton, PaginatedButtonAction } from '../types/index.js' +import { log } from '../utils/index.js' export const pagination: PaginatedButton = { name: 'paginationButton', @@ -15,20 +15,16 @@ export const pagination: PaginatedButton = { action: PaginatedButtonAction ): Promise => { switch (command) { - case 'recent': { - await paginatedRecent({ interaction, action }) + case PaginatedButtonTypeEnum.Leaderboard: { + await createLeaderboard({ interaction, action }) break } - case 'level': { - await paginatedLevel({ interaction, action }) + case PaginatedButtonTypeEnum.Records: { + await createRecords({ interaction, action }) break } - case 'levels': { - await paginatedLevels({ interaction, action }) - break - } - case 'rankings': { - await paginatedRankings({ interaction, action }) + default: { + log.error(`Unknown pagination button command: ${command}`) break } } diff --git a/src/client/createClient.ts b/src/client/createClient.ts new file mode 100644 index 00000000..bd32fcc9 --- /dev/null +++ b/src/client/createClient.ts @@ -0,0 +1,45 @@ +import { Client, Events, GatewayIntentBits } from 'discord.js' + +import { DISCORD_TOKEN } from '../config/index.js' +import { + onDisconnect, + onError, + onInteractionCreate, + onReady +} from './listeners/index.js' + +const client = new Client({ + intents: [GatewayIntentBits.Guilds] +}) + +client.once(Events.ClientReady, onReady) + +client.on(Events.InteractionCreate, onInteractionCreate) + +client.on(Events.Error, onError) + +client.on(Events.Warn, console.warn) + +client.on(Events.ShardReconnecting, () => console.log('Reconnecting...')) + +client.on(Events.ShardResume, (shardId, replayed) => + console.log(`Resumed Shard ${shardId} | Replayed ${replayed} events.`) +) + +client.on(Events.Invalidated, () => console.log('Invalidated')) + +client.on(Events.Debug, console.log) + +client.on(Events.ShardDisconnect, console.log) + +client.on(Events.ShardError, console.log) + +client.on(Events.ShardReady, console.log) + +client.on('disconnect', () => onDisconnect(client)) + +console.log('Logging in', DISCORD_TOKEN) + +client.login(DISCORD_TOKEN) + +export const createClient = (): Client => client diff --git a/src/client/interactions/handleButton.ts b/src/client/interactions/handleButton.ts new file mode 100644 index 00000000..777bb5eb --- /dev/null +++ b/src/client/interactions/handleButton.ts @@ -0,0 +1,29 @@ +import { ButtonInteraction } from 'discord.js' + +import { PaginatedButtonAction } from '../../types/index.js' +import { createButtons } from '../../utils/createButtons.js' +import { findCommand, log } from '../../utils/index.js' + +const buttons = createButtons() + +export const handleButton = async (interaction: ButtonInteraction) => { + log.info('Handling request as a pagination button', interaction) + + const [buttonName, action, type] = interaction.customId.split('-') + + const button = findCommand(buttons, buttonName) + + if (!button) { + log.error( + `Unknown button interaction "${interaction.customId}"`, + interaction + ) + interaction.reply({ + content: 'Unknown button interaction', + ephemeral: true + }) + return + } + + button.run(interaction, type, action as PaginatedButtonAction) +} diff --git a/src/client/interactions/handleMessageContextMenuCommand.ts b/src/client/interactions/handleMessageContextMenuCommand.ts new file mode 100644 index 00000000..7463621a --- /dev/null +++ b/src/client/interactions/handleMessageContextMenuCommand.ts @@ -0,0 +1,31 @@ +import { CommandInteraction } from 'discord.js' + +import { createContextMenus } from '../../utils/createContextMenus.js' +import { log } from '../../utils/log.js' + +const contextMenus = createContextMenus() + +export const handleMessageContextMenuCommand = async ( + interaction: CommandInteraction +) => { + log.info('Handling request as a message context menu command', interaction) + + if (!interaction.isUserContextMenuCommand()) return + + const contextMenu = contextMenus.find( + command => command.name === interaction.commandName + ) + + if (!contextMenu) { + interaction.reply({ content: 'Unknown command', ephemeral: true }) + return + } + + // await trackCommandUsage(interaction.commandName) + + await interaction.deferReply({ + ephemeral: true + }) + + contextMenu.run(interaction, interaction.targetUser) +} diff --git a/src/client/interactions/handleSlashCommand.ts b/src/client/interactions/handleSlashCommand.ts new file mode 100644 index 00000000..02c2ec84 --- /dev/null +++ b/src/client/interactions/handleSlashCommand.ts @@ -0,0 +1,25 @@ +import { CommandInteraction } from 'discord.js' + +import { createCommands } from '../../utils/createCommands.js' +import { findCommand, log } from '../../utils/index.js' + +const commands = createCommands() + +export const handleSlashCommand = async (interaction: CommandInteraction) => { + log.info('Handling request as a slash command', interaction) + + const command = findCommand(commands, interaction.commandName) + + if (!command) { + interaction.reply({ content: 'Unknown command', ephemeral: true }) + return + } + + // await trackCommandUsage(interaction.commandName) + + await interaction.deferReply({ + ephemeral: command.ephemeral + }) + + command.run(interaction) +} diff --git a/src/client/interactions/handleUserContextMenuCommand.ts b/src/client/interactions/handleUserContextMenuCommand.ts new file mode 100644 index 00000000..e16b265f --- /dev/null +++ b/src/client/interactions/handleUserContextMenuCommand.ts @@ -0,0 +1,33 @@ +import { CommandInteraction } from 'discord.js' + +import { createContextMenus } from '../../utils/createContextMenus.js' +import { findCommand, log } from '../../utils/index.js' + +const contextMenus = createContextMenus() + +export const handleUserContextMenuCommand = async ( + interaction: CommandInteraction +) => { + log.info('Handling request as a user context menu command', interaction) + + if (!interaction.isUserContextMenuCommand()) return + + const { username, id } = interaction.targetUser + + log.info(`Got user context menu command for ${username} (${id})`) + + const command = findCommand(contextMenus, interaction.commandName) + + if (!command) { + interaction.reply({ content: 'Unknown command', ephemeral: true }) + return + } + + // await trackCommandUsage(interaction.commandName) + + await interaction.deferReply({ + ephemeral: true + }) + + command.run(interaction, interaction.targetUser) +} diff --git a/src/client/listeners/index.ts b/src/client/listeners/index.ts new file mode 100644 index 00000000..be9b3f7b --- /dev/null +++ b/src/client/listeners/index.ts @@ -0,0 +1,4 @@ +export * from './onDisconnect.js' +export * from './onError.js' +export * from './onInteractionCreate.js' +export * from './onReady.js' diff --git a/src/client/listeners/onDisconnect.ts b/src/client/listeners/onDisconnect.ts new file mode 100644 index 00000000..295310f6 --- /dev/null +++ b/src/client/listeners/onDisconnect.ts @@ -0,0 +1,12 @@ +import { Client } from 'discord.js' + +import { DISCORD_TOKEN } from '../../config/index.js' +import { log } from '../../utils/log.js' + +export const onDisconnect = (client: Client) => { + log.warn('Bot has disconnected. Attempting to reconnect') + + client.login(DISCORD_TOKEN) + + log.info('Bot has reconnected') +} diff --git a/src/client/listeners/onError.ts b/src/client/listeners/onError.ts new file mode 100644 index 00000000..f4d530bf --- /dev/null +++ b/src/client/listeners/onError.ts @@ -0,0 +1,5 @@ +import { log } from '../../utils/index.js' + +export const onError = (error: Error): void => { + log.error(error.message) +} diff --git a/src/client/listeners/onInteractionCreate.ts b/src/client/listeners/onInteractionCreate.ts new file mode 100644 index 00000000..cde560ca --- /dev/null +++ b/src/client/listeners/onInteractionCreate.ts @@ -0,0 +1,18 @@ +import { Interaction } from 'discord.js' + +import { handleButton } from '../interactions/handleButton.js' +import { handleMessageContextMenuCommand } from '../interactions/handleMessageContextMenuCommand.js' +import { handleSlashCommand } from '../interactions/handleSlashCommand.js' +import { handleUserContextMenuCommand } from '../interactions/handleUserContextMenuCommand.js' + +export const onInteractionCreate = async (interaction: Interaction) => { + if (interaction.isUserContextMenuCommand()) { + handleUserContextMenuCommand(interaction) + } else if (interaction.isMessageContextMenuCommand()) { + handleMessageContextMenuCommand(interaction) + } else if (interaction.isCommand()) { + handleSlashCommand(interaction) + } else if (interaction.isButton()) { + handleButton(interaction) + } +} diff --git a/src/client/listeners/onReady.ts b/src/client/listeners/onReady.ts new file mode 100644 index 00000000..dc8f9e71 --- /dev/null +++ b/src/client/listeners/onReady.ts @@ -0,0 +1,34 @@ +import { ActivityType, Client } from 'discord.js' + +import { ZEEPKIST_URL } from '../../config/index.js' +import { createCommands } from '../../utils/createCommands.js' +import { createContextMenus } from '../../utils/createContextMenus.js' +import { log } from '../../utils/log.js' + +export const onReady = async (client: Client) => { + if (!client.user || !client.application) return + + const commands = createCommands() + const contextMenus = createContextMenus() + + await client.application.commands.set([...commands, ...contextMenus]) + + client.user?.setPresence({ + activities: [ + { + type: ActivityType.Watching, + name: 'Zeepkist', + url: ZEEPKIST_URL + } + ], + status: 'online' + }) + + log.info( + `${client.user.username} is online and listening to ${client.guilds.cache.size} servers:` + ) + + for (const [, guild] of client.guilds.cache) { + log.info(` - ${guild.name} (${guild.id})`) + } +} diff --git a/src/commands/about.ts b/src/commands/about.ts index 00c908c2..9e5c545f 100644 --- a/src/commands/about.ts +++ b/src/commands/about.ts @@ -1,17 +1,7 @@ -import { - ActionRowBuilder, - ApplicationCommandType, - bold, - ButtonBuilder, - ButtonStyle, - EmbedBuilder, - hyperlink, - inlineCode -} from 'discord.js' +import { ApplicationCommandType } from 'discord.js' -import { Command } from '../command.js' -import { getReleases } from '../services/github.js' -import { inviteUrl } from '../utils/index.js' +import { embed } from '../components/about.js' +import { Command } from '../types/index.js' export const about: Command = { name: 'about', @@ -19,89 +9,8 @@ export const about: Command = { type: ApplicationCommandType.ChatInput, ephemeral: false, run: async interaction => { - const commands = [ - `${bold(inlineCode('/about'))} to see this message again`, - `${bold( - inlineCode('/level') - )} to get information about a level (or list levels)`, - `${bold(inlineCode('/random'))} to get information about a random level`, - `${bold(inlineCode('/rankings'))} to get the world record leaderboard`, - `${bold( - inlineCode('/recent') - )} to get the most recent personal best records`, - `${bold(inlineCode('/user'))} to get information about a user` - ] - - const embed = new EmbedBuilder() - .setTitle('About') - .setColor(0xff_92_00) - .setDescription( - `This is a bot to show live user times and rankings for ${hyperlink( - 'Zeepkist', - 'https://store.steampowered.com/app/1440670' - )}\n\nAll data is provided by the Zeepkist GTR API and the Steam API.\n\nThe bot is not affiliated with Steam and only uses the Steam API to get ${bold( - 'public' - )} user information not provided by Zeepkist GTR. Data obtained from the Steam API is never stored by the bot.` - ) - .addFields( - { - name: 'Commands', - value: commands.join('\n') - }, - { - inline: true, - name: 'About the Bot', - value: `The discord bot is built by <@104736549081468928> and is open-source on ${hyperlink( - 'GitHub', - 'https://github.com/zeepkist/zeepkist-bot' - )}\n\nYou can support the development of the bot and their other open-source projects on ${hyperlink( - 'Ko-fi/wopian', - 'https://ko-fi.com/wopian' - )}` - }, - { - inline: true, - name: 'About Zeepkist GTR', - value: `Zeepkist GTR is a mod built by <@217779716289986560> to add a global time ranking system to ${hyperlink( - 'Zeepkist', - 'https://store.steampowered.com/app/1440670' - )}\n\nYou can support the development of Zeepkist GTR on ${hyperlink( - 'Ko-fi/thundernerd', - 'https://ko-fi.com/thundernerd' - )}` - } - ) - .setTimestamp() - .setFooter({ - text: `Version 1` - }) - - const latestRelease = await getReleases() - if (latestRelease.length > 0) { - embed.setTimestamp(new Date(latestRelease[0].published_at)) - embed.setFooter({ - text: latestRelease[0].tag_name - }) - } - - const buttons = new ActionRowBuilder().addComponents([ - new ButtonBuilder() - .setStyle(ButtonStyle.Link) - .setLabel('Invite the Bot') - .setURL(inviteUrl), - new ButtonBuilder() - .setStyle(ButtonStyle.Link) - .setLabel('Support the Bot') - .setURL('https://ko-fi.com/wopian'), - new ButtonBuilder() - .setStyle(ButtonStyle.Link) - .setLabel('Support Zeepkist GTR') - .setURL('https://ko-fi.com/thundernerd') - ]) - await interaction.editReply({ - embeds: [embed], - components: [buttons] + embeds: [embed] }) } } diff --git a/src/commands/leaderboard.ts b/src/commands/leaderboard.ts new file mode 100644 index 00000000..08f6006d --- /dev/null +++ b/src/commands/leaderboard.ts @@ -0,0 +1,18 @@ +import { ApplicationCommandType } from 'discord.js' + +import { createLeaderboard } from '../components/createLeaderboard.js' +import { PaginatedButtonActionEnum } from '../enums/index.js' +import { Command } from '../types/index.js' + +export const leaderboard: Command = { + name: 'leaderboard', + description: 'View the Zeepkist GTR points leaderboard', + type: ApplicationCommandType.ChatInput, + ephemeral: false, + run: async interaction => { + await createLeaderboard({ + interaction, + action: PaginatedButtonActionEnum.First + }) + } +} diff --git a/src/commands/level.ts b/src/commands/level.ts index 8820c852..837a70ea 100644 --- a/src/commands/level.ts +++ b/src/commands/level.ts @@ -1,153 +1,20 @@ -import { getLevel, getLevels, searchLevels } from '@zeepkist/gtr-api' -import { - ApplicationCommandOptionType, - ApplicationCommandType, - CommandInteraction, - EmbedBuilder -} from 'discord.js' +import { ApplicationCommandType, EmbedBuilder } from 'discord.js' -import { Command } from '../command.js' -import { errorReply } from '../components/errorReply.js' -import { paginatedLevel } from '../components/paginated/paginatedLevel.js' -import { paginatedLevels } from '../components/paginated/paginatedLevels.js' -import { log } from '../utils/log.js' - -const getOptions = (interaction: CommandInteraction) => { - const id = interaction.options.data.find(option => option.name === 'id') - ?.value as number - const workshopId = interaction.options.data.find( - option => option.name === 'workshopid' - )?.value as string - const author = interaction.options.data.find( - option => option.name === 'author' - )?.value as string - const name = interaction.options.data.find(option => option.name === 'name') - ?.value as string - - const search = interaction.options.data.find( - option => option.name === 'search' - )?.value as string - - return { id, workshopId, author, name, search } -} - -const replyNoLevels = async ( - interaction: CommandInteraction, - invalidArguments = false -) => { - const embed = new EmbedBuilder() - .setColor(0xff_00_00) - .setTitle(invalidArguments ? 'Missing Arguments' : 'No level found') - .setDescription( - invalidArguments - ? 'You must provide either a level ID, workshop ID, author or name of a level.' - : 'No level found with the provided arguments.' - ) - .setTimestamp() - - await interaction.editReply({ embeds: [embed] }) -} +import { Command } from '../types/index.js' export const level: Command = { name: 'level', - description: 'Get records for a level', + description: 'Placeholder', type: ApplicationCommandType.ChatInput, - options: [ - { - name: 'search', - description: 'Search for a level by name or author', - type: ApplicationCommandOptionType.String, - required: false - }, - { - name: 'id', - description: 'The id of the level', - type: ApplicationCommandOptionType.String, - required: false - }, - { - name: 'workshopid', - description: 'The workshop id of the level(s)', - type: ApplicationCommandOptionType.String, - required: false - }, - { - name: 'author', - description: 'The exact author of the level(s)', - type: ApplicationCommandOptionType.String, - required: false - }, - { - name: 'name', - description: 'The exact name of the level(s)', - type: ApplicationCommandOptionType.String, - required: false - } - ], ephemeral: false, - run: async (interaction: CommandInteraction) => { - const { id, workshopId, author, name, search } = getOptions(interaction) - log.info(`${id} ${workshopId} ${author} ${name} ${search}`, interaction) - - if (!id && !workshopId && !author && !name && !search) { - log.info('No arguments provided', interaction) - await replyNoLevels(interaction, true) - return - } - - if (id) { - try { - const level = await getLevel(id) - if (level) { - await paginatedLevel({ - interaction, - action: 'first', - query: { id } - }) - return - } - } catch (error: unknown) { - errorReply(interaction, level ? level.name : 'Unknown level', error) - return - } - } - - try { - const levels = await (search - ? searchLevels({ Query: search, Limit: 1 }) - : getLevels({ - WorkshopId: workshopId, - Author: author, - Name: name, - Limit: 1 - })) - - if (levels.totalAmount === 0) { - log.info('No levels found', interaction) - await replyNoLevels(interaction) - return - } - - log.info(`Found ${levels.totalAmount} levels`, interaction) - - if (levels.totalAmount > 1 && !search) { - await paginatedLevels({ - interaction, - action: 'first', - query: { id, workshopId, author, name } - }) - return - } - - if (levels.totalAmount === 1 || search) { - await paginatedLevel({ - interaction, - action: 'first', - query: { id: levels.levels[0].id } - }) - } - } catch { - await replyNoLevels(interaction, true) - } + run: async interaction => { + const embed = new EmbedBuilder() + .setTitle('Placeholder') + .setColor(0xff_92_00) + .setDescription('Placeholder') + + await interaction.editReply({ + embeds: [embed] + }) } } diff --git a/src/commands/playlist.ts b/src/commands/playlist.ts new file mode 100644 index 00000000..601b7a79 --- /dev/null +++ b/src/commands/playlist.ts @@ -0,0 +1,20 @@ +import { ApplicationCommandType, EmbedBuilder } from 'discord.js' + +import { Command } from '../types/index.js' + +export const playlist: Command = { + name: 'playlist', + description: 'Placeholder', + type: ApplicationCommandType.ChatInput, + ephemeral: true, + run: async interaction => { + const embed = new EmbedBuilder() + .setTitle('Placeholder') + .setColor(0xff_92_00) + .setDescription('Placeholder') + + await interaction.editReply({ + embeds: [embed] + }) + } +} diff --git a/src/commands/random.ts b/src/commands/random.ts index 034bd27a..fbc2cdcc 100644 --- a/src/commands/random.ts +++ b/src/commands/random.ts @@ -1,38 +1,20 @@ -import { getRandomLevels } from '@zeepkist/gtr-api' -import { ApplicationCommandType, CommandInteraction } from 'discord.js' +import { ApplicationCommandType, EmbedBuilder } from 'discord.js' -import { Command } from '../command.js' -import { errorReply } from '../components/errorReply.js' -import { paginatedLevel } from '../components/paginated/paginatedLevel.js' -import { log } from '../utils/log.js' +import { Command } from '../types/index.js' export const random: Command = { name: 'random', - description: 'Get a random Zeepkist level', + description: 'Placeholder', type: ApplicationCommandType.ChatInput, ephemeral: false, - run: async (interaction: CommandInteraction) => { - const levels = await getRandomLevels({ - Limit: 1 - }) - - if (levels.levels.length === 0) { - return errorReply( - interaction, - 'No levels found', - 'We could not find any levels.' - ) - } - - const level = levels.levels[0] - - log.info(`Got random level ${level.id} (${level.name})`) + run: async interaction => { + const embed = new EmbedBuilder() + .setTitle('Placeholder') + .setColor(0xff_92_00) + .setDescription('Placeholder') - await paginatedLevel({ - interaction, - action: 'first', - query: { id: level.id } + await interaction.editReply({ + embeds: [embed] }) - return } } diff --git a/src/commands/records.ts b/src/commands/records.ts new file mode 100644 index 00000000..a64b7243 --- /dev/null +++ b/src/commands/records.ts @@ -0,0 +1,69 @@ +import { + ApplicationCommandNonOptionsData, + ApplicationCommandOptionType, + ApplicationCommandType +} from 'discord.js' + +import { createRecords } from '../components/createRecords.js' +import { PaginatedButtonActionEnum, RecordType } from '../enums/index.js' +import { Command } from '../types/index.js' + +type SubcommandOptions = readonly ApplicationCommandNonOptionsData[] + +const options: SubcommandOptions = [ + { + name: 'user', + description: 'View recent records by a user', + type: ApplicationCommandOptionType.User + } +] + +export const records: Command = { + name: 'records', + description: 'View recent World Records, Personal Bests other records', + type: ApplicationCommandType.ChatInput, + ephemeral: false, + options: [ + { + name: 'wr', + description: 'View recent World Records', + type: ApplicationCommandOptionType.Subcommand, + options + }, + { + name: 'pb', + description: 'View recent Personal Bests', + type: ApplicationCommandOptionType.Subcommand, + options + }, + { + name: 'all', + description: 'View all recent records', + type: ApplicationCommandOptionType.Subcommand, + options + } + ], + run: async interaction => { + const user = interaction.options.getUser('user') + + const subcommand = interaction.options.data.find( + option => option.type === ApplicationCommandOptionType.Subcommand + )?.name + + const recordType = + subcommand === 'wr' + ? RecordType.WorldRecord + : subcommand === 'pb' + ? RecordType.PersonalBest + : RecordType.All + + await createRecords({ + interaction, + action: PaginatedButtonActionEnum.First, + query: { + user, + recordType + } + }) + } +} diff --git a/src/commands/user.ts b/src/commands/user.ts index b4a76e42..1beb9250 100644 --- a/src/commands/user.ts +++ b/src/commands/user.ts @@ -1,98 +1,20 @@ -import { - getUser, - getUserByDiscordId, - getUserBySteamId -} from '@zeepkist/gtr-api' -import { - ApplicationCommandOptionType, - ApplicationCommandType, - CommandInteraction -} from 'discord.js' +import { ApplicationCommandType, EmbedBuilder } from 'discord.js' -import { Command } from '../command.js' -import { userEmbed } from '../components/embeds/userEmbed.js' -import { userNotFoundEmbed } from '../components/embeds/userNotFoundEmbed.js' -import { log } from '../utils/index.js' +import { Command } from '../types/index.js' export const user: Command = { name: 'user', - description: 'Get information about a user.', + description: 'Placeholder', type: ApplicationCommandType.ChatInput, - options: [ - { - name: 'user', - description: 'Discord User', - type: ApplicationCommandOptionType.User, - required: false - }, - { - name: 'steamid', - description: "User's Steam ID.", - type: ApplicationCommandOptionType.String, - required: false, - minLength: 17, - maxLength: 17 - }, - { - name: 'id', - description: "User's internal ID.", - type: ApplicationCommandOptionType.String, - required: false, - minLength: 0 - }, - { - name: 'page', - description: 'Select the page to show', - type: ApplicationCommandOptionType.String, - required: false, - choices: [ - { - name: 'Stats', - value: 'stats' - }, - { - name: 'Records', - value: 'records' - } - ] - } - ], ephemeral: false, - run: async (interaction: CommandInteraction) => { - let discordUser = interaction.options.data.find( - option => option.name === 'user' - )?.user - - const steamId = interaction.options.data.find( - option => option.name === 'steamid' - )?.value as string - - const id = interaction.options.data.find(option => option.name === 'id') - ?.value as number - - const page = interaction.options.data.find(option => option.name === 'page') - ?.value as string - - log.info( - `Discord ID: ${discordUser?.id}, Steam ID: ${steamId}, ID: ${id}, Page: ${page}`, - interaction - ) - - if (!discordUser?.id && !steamId && !id) { - discordUser = interaction.user - } - - try { - const user = discordUser?.id - ? await getUserByDiscordId(discordUser.id) - : steamId - ? await getUserBySteamId(steamId) - : await getUser(id) - log.info(`Found user: ${user.steamName}`, interaction) - await userEmbed(interaction, user, discordUser, page) - } catch (error) { - log.error(String(error), interaction) - userNotFoundEmbed(interaction) - } + run: async interaction => { + const embed = new EmbedBuilder() + .setTitle('Placeholder') + .setColor(0xff_92_00) + .setDescription('Placeholder') + + await interaction.editReply({ + embeds: [embed] + }) } } diff --git a/src/components/about.ts b/src/components/about.ts new file mode 100644 index 00000000..2cb3457c --- /dev/null +++ b/src/components/about.ts @@ -0,0 +1,90 @@ +import { bold, hyperlink, inlineCode } from 'discord.js' + +import { createEmbed } from '../utils/index.js' + +export const embed = createEmbed('About') + +const akane = '<@104736549081468928>' +const thundernerd = '<@217779716289986560>' +const spacer = '\n<:blank:1065818232734351390>' + +const akaneKofiLink = hyperlink('Ko-fi/wopian', 'https://ko-fi.com/wopian') +const thundernerdKofiLink = hyperlink( + 'Ko-fi/thundernerd', + 'https://ko-fi.com/thundernerd' +) + +const botGithubLink = hyperlink( + 'GitHub', + 'https://github.com/zeepkist/zeepkist-bot' +) + +const zeepkistSteamLink = hyperlink( + 'Zeepkist', + 'https://store.steampowered.com/app/1440670' +) + +const zeepkistRecordsLink = hyperlink('zeepki.st', 'https://zeepki.st') + +const zeepkistSuperLeagueLink = hyperlink( + 'Super League results', + 'https://zeepki.st/super-league' +) + +const modkistDownloadLink = hyperlink( + 'Modkist (Zeepkist Mod Manager)', + 'https://github.com/tnrd-org/ModkistRevamped/releases/latest/download/Modkist.-.Revamped.zip' +) + +const gtrGithubLink = hyperlink('GitHub', 'https://github.com/tnrd-org') + +const gtrApiLink = hyperlink( + 'GTR API', + 'https://graphql.zeepkist-gtr.com/graphiql' +) + +const zworpshopApiLink = hyperlink( + 'Zworpshop API', + 'https://graphql.zworpshop.com/graphiql' +) + +const zeepkistApiLink = hyperlink('zeepki.st API', 'https://zeepki.st/graphiql') + +const steamApiLink = hyperlink( + 'Steam API', + 'https://developer.valvesoftware.com/wiki/Steam_Web_API' +) + +const commands = [ + `${inlineCode('/leaderboard')} to view the GTR points leaderboard`, + `${inlineCode('/level')} to get info about a level (or list of levels)`, + `${inlineCode('/random')} to get a random Zeepkist level`, + `${inlineCode('/records')} to see the most recent PBs and WRs`, + `${inlineCode('/user')} to get stats about a user` +] + +const commandsField = { + inline: false, + name: 'Commands', + value: commands.join('\n') + spacer +} + +const aboutBotField = { + inline: false, + name: 'About the Bot', + value: `The bot is built by ${akane} and is open-source on ${botGithubLink}\n\nYou can support the development of the bot and ${zeepkistRecordsLink} on ${akaneKofiLink}${spacer}` +} + +const aboutGtrField = { + inline: false, + name: 'About Zeepkist GTR', + value: `Zeepkist GTR is a mod built by ${thundernerd} to add a global time ranking system to ${zeepkistSteamLink} and is open-source on ${gtrGithubLink}\n\nDownload the mod with ${modkistDownloadLink}\n\nYou can support the development of GTR, Zworpshop and Modkist on ${thundernerdKofiLink}` +} + +embed.setDescription( + `A Discord bot for ${zeepkistSteamLink} records (GTR PBs/WRs), ${zeepkistSuperLeagueLink} and playlist generation.\n\nAll data is provided by the ${gtrApiLink}, the ${zworpshopApiLink}, the ${zeepkistApiLink} and the ${steamApiLink}.\n\nThe bot is not affiliated with Steam and only uses the Steam API to get ${bold( + 'public' + )} user information not provided by GTR. Data obtained from the Steam API is never stored.${spacer}` +) + +embed.addFields(commandsField, aboutBotField, aboutGtrField) diff --git a/src/components/createLeaderboard.ts b/src/components/createLeaderboard.ts new file mode 100644 index 00000000..73e33950 --- /dev/null +++ b/src/components/createLeaderboard.ts @@ -0,0 +1,50 @@ +import { PAGINATION_LIMIT } from '../config/index.js' +import { PaginatedButtonTypeEnum } from '../enums/index.js' +import { getRankings } from '../services/getRankings.js' +import { createEmbed, formatRank, formatUser } from '../utils/index.js' +import { + getPaginatedData, + PaginatedData, + sendPaginatedMessage +} from './paginated.js' + +export const createLeaderboard = async (properties: PaginatedData) => { + const { interaction } = properties + const data = await getPaginatedData(properties) + const rankings = await getRankings(data.offset) + + const totalPages = Math.ceil( + (rankings?.totalCount ?? PAGINATION_LIMIT) / PAGINATION_LIMIT + ) + + const embed = createEmbed(`Zeepkist GTR Leaderboard`) + + const description = rankings?.nodes.map(ranking => { + if (!ranking || !ranking.userByUser) return + + const { userByUser: user, rank, points } = ranking + const discordTag = user.discordId ? `<@${user.discordId}>` : '' + const formattedPoints = points.toLocaleString() + + return `${formatRank(rank)} ${formatUser( + user + )} ${discordTag} (${formattedPoints} points)` + }) + + embed.setFooter({ + text: `Page ${data.currentPage} of ${totalPages}` + }) + + embed.setDescription(description?.join('\n') ?? 'No rankings found') + + console.debug(data) + + await sendPaginatedMessage({ + customId: PaginatedButtonTypeEnum.Leaderboard, + interaction, + embed, + query: data.query, + currentPage: data.currentPage, + totalAmount: rankings?.totalCount ?? PAGINATION_LIMIT + }) +} diff --git a/src/components/createRecords.ts b/src/components/createRecords.ts new file mode 100644 index 00000000..400716dc --- /dev/null +++ b/src/components/createRecords.ts @@ -0,0 +1,71 @@ +import { PAGINATION_LIMIT } from '../config/index.js' +import { PaginatedButtonTypeEnum, RecordType } from '../enums/index.js' +import { getRecords } from '../services/getRecords.js' +import { + bestMedal, + createEmbed, + formatDiscordDate, + formatLevel, + formatResultTime, + formatUser +} from '../utils/index.js' +import { + getPaginatedData, + PaginatedData, + sendPaginatedMessage +} from './paginated.js' + +export const createRecords = async (properties: PaginatedData) => { + const { interaction } = properties + const data = await getPaginatedData(properties) + const records = await getRecords( + data.offset, + data.query.recordType, + data.query.user + ) + + const totalPages = Math.ceil( + (records?.totalCount ?? PAGINATION_LIMIT) / PAGINATION_LIMIT + ) + + const titleUser = data.query.user + ? ` by ${data.query.user.displayName ?? data.query.user.tag}` + : '' + const titleRecordType = + data.query.recordType === RecordType.WorldRecord + ? 'World Records' + : data.query.recordType === RecordType.PersonalBest + ? 'Personal Bests' + : 'Records' + + const embed = createEmbed(`Recent ${titleRecordType}${titleUser}`) + + const description = records?.nodes.map(record => { + if (!record || !record.userByUser) return + + const { userByUser: user, dateCreated, time, level, isWorldRecord } = record + + return `${bestMedal(time, level, isWorldRecord)} ${formatResultTime( + time + )} ${formatUser(user)} - ${formatLevel(level)} (${formatDiscordDate( + dateCreated + )})` + }) + + embed.setFooter({ + text: `Page ${data.currentPage} of ${totalPages}` + }) + + embed.setDescription(description?.join('\n') ?? 'No records found') + + console.debug(data) + + await sendPaginatedMessage({ + customId: PaginatedButtonTypeEnum.Records, + interaction, + embed, + query: data.query, + currentPage: data.currentPage, + totalAmount: records?.totalCount ?? PAGINATION_LIMIT + }) +} diff --git a/src/components/paginated.ts b/src/components/paginated.ts index ef4b84e8..c88a66a0 100644 --- a/src/components/paginated.ts +++ b/src/components/paginated.ts @@ -6,15 +6,16 @@ import { EmbedBuilder } from 'discord.js' -import { PaginatedButtonAction } from '../button.js' -import { PAGINATION_LIMIT } from '../constants.js' +import { PAGINATION_LIMIT } from '../config/index.js' +import { PaginatedButtonActionEnum } from '../enums/index.js' +import { database } from '../services/database.js' import { + PaginatedButtonAction, PaginatedMessage, PaginatedMessageQuery -} from '../models/database/paginatedMessage.js' -import { database } from '../services/database.js' +} from '../types/index.js' import { extractPages, log, providedBy } from '../utils/index.js' -import { paginationButtons } from './paginationButtons.js' +import { paginatedButtons } from './paginatedButtons.js' export interface PaginatedData { interaction: CommandInteraction | ButtonInteraction @@ -29,16 +30,16 @@ const setCurrentPage = ( totalPages: number ) => { switch (action) { - case 'first': { + case PaginatedButtonActionEnum.First: { return 1 } - case 'previous': { + case PaginatedButtonActionEnum.Previous: { return currentPage - 1 } - case 'next': { + case PaginatedButtonActionEnum.Next: { return currentPage + 1 } - case 'last': { + case PaginatedButtonActionEnum.Last: { return totalPages } default: { @@ -121,7 +122,7 @@ export const sendPaginatedMessage = async ({ }) .setTimestamp() - const pagination = paginationButtons( + const pagination = paginatedButtons( interaction, customId, currentPage, diff --git a/src/components/paginatedButtons.ts b/src/components/paginatedButtons.ts new file mode 100644 index 00000000..d744af8f --- /dev/null +++ b/src/components/paginatedButtons.ts @@ -0,0 +1,66 @@ +import { + ActionRowBuilder, + ButtonBuilder, + ButtonInteraction, + ButtonStyle, + CommandInteraction +} from 'discord.js' + +import { PaginatedButtonActionEnum } from '../enums/index.js' +import { CollectorFilterValue } from '../types/index.js' + +export const paginatedButtons = ( + interaction: CommandInteraction | ButtonInteraction, + customId: string, + page: number, + maxPages: number +) => { + if (maxPages === 1) return + const prefix = 'paginationButton-' + + const buttons = new ActionRowBuilder().addComponents([ + new ButtonBuilder() + .setCustomId(`${prefix}${PaginatedButtonActionEnum.First}-${customId}`) + .setLabel('First') + .setStyle(ButtonStyle.Primary) + .setDisabled(page === 1), + new ButtonBuilder() + .setCustomId(`${prefix}${PaginatedButtonActionEnum.Previous}-${customId}`) + .setLabel('Previous') + .setStyle(ButtonStyle.Primary) + .setDisabled(page === 1), + new ButtonBuilder() + .setCustomId(`${prefix}${PaginatedButtonActionEnum.Next}-${customId}`) + .setLabel('Next') + .setStyle(ButtonStyle.Primary) + .setDisabled(page === maxPages), + new ButtonBuilder() + .setCustomId(`${prefix}${PaginatedButtonActionEnum.Last}-${customId}`) + .setLabel('Last') + .setStyle(ButtonStyle.Primary) + .setDisabled(page === maxPages) + ]) + + const collector = interaction.channel?.createMessageComponentCollector({ + filter: (m: CollectorFilterValue) => + [ + PaginatedButtonActionEnum.First, + PaginatedButtonActionEnum.Previous, + PaginatedButtonActionEnum.Next, + PaginatedButtonActionEnum.Last + ].includes( + m.customId.split(prefix)[1].split('-')[0] as PaginatedButtonActionEnum + ), + time: 3 * 1000 * 60 // 3 minutes + }) + + collector?.on('end', () => { + buttons.components[0].setDisabled(true) + buttons.components[1].setDisabled(true) + buttons.components[2].setDisabled(true) + buttons.components[3].setDisabled(true) + interaction.editReply({ components: [buttons] }) + }) + + return buttons +} diff --git a/src/config/index.ts b/src/config/index.ts new file mode 100644 index 00000000..8c419c9b --- /dev/null +++ b/src/config/index.ts @@ -0,0 +1,17 @@ +export const IS_PRODUCTION = Boolean(process.env.ZEEPKIST_BOT_PRODUCTION) +export const STEAM_KEY = process.env.STEAM_KEY +export const GITHUB_TOKEN = process.env.GITHUB_TOKEN +export const TWITCH_CLIENT_ID = process.env.TWITCH_CLIENT_ID +export const TWITCH_CLIENT_SECRET = process.env.TWITCH_CLIENT_SECRET +export const DISCORD_TOKEN = process.env.DISCORD_TOKEN +export const DISCORD_ZEEPKIST_GUILD_ID = process.env.DISCORD_ZEEPKIST_GUILD_ID +export const DISCORD_ZEEPKIST_BOT_CHANNEL_ID = + process.env.DISCORD_ZEEPKIST_BOT_CHANNEL_ID + +export const ZEEPKIST_URL = 'https://zeepki.st' +export const STEAM_URL = 'https://steamcommunity.com' + +export const API_URL = 'https://api.zeepkist-gtr.com/' +export const STEAM_API_URL = 'https://api.steampowered.com/' +export const GITHUB_API_URL = 'https://api.github.com/' +export const PAGINATION_LIMIT = 10 diff --git a/src/enums/index.ts b/src/enums/index.ts new file mode 100644 index 00000000..8a6a0580 --- /dev/null +++ b/src/enums/index.ts @@ -0,0 +1,32 @@ +export const enum CommandName { + Command = 'command', + ContextMenu = 'contextMenu' +} + +export const enum PaginatedButtonActionEnum { + First = 'first', + Previous = 'previous', + Next = 'next', + Last = 'last' +} + +export const enum PaginatedButtonTypeEnum { + Leaderboard = 'leaderboard', + Records = 'records' +} + +export const enum RecordType { + WorldRecord = 'wr', + PersonalBest = 'pb', + All = 'all' +} + +export const enum DiscordDateEnum { + ShortTime = 't', + LongTime = 'T', + ShortDate = 'd', + LongDate = 'D', + LongDateShortTime = 'f', + LongDateShortTimeDayOfWeek = 'F', + Relative = 'R' +} diff --git a/src/index.ts b/src/index.ts index 08cb43d0..90425712 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,32 +1,6 @@ -import { Client, Events, GatewayIntentBits } from 'discord.js' -import { config } from 'dotenv' - -import interactionCreate from './listeners/interactionCreate.js' -import ready from './listeners/ready.js' -import { twitchStreams } from './listeners/twitchStreams.js' +import { createClient } from './client/createClient.js' import { log } from './utils/index.js' -config() - -log.info('Bot is starting') - -const client = new Client({ - intents: [GatewayIntentBits.Guilds] -}) - -client.once(Events.ClientReady, ready) -interactionCreate(client) - -client.login(process.env.DISCORD_TOKEN) - -client.on('disconnect', () => { - log.info('Bot has disconnected, logging back in') - client.login(process.env.DISCORD_TOKEN) - log.info('Bot has reconnected') -}) - -client.on('error', (error: Error) => { - log.error(`discord.js encountered an error: ${String(error)}`) -}) +log.info('Starting bot') -twitchStreams(client) +createClient() diff --git a/src/services/database.ts b/src/services/database.ts index 35068efd..a77ce942 100644 --- a/src/services/database.ts +++ b/src/services/database.ts @@ -1,10 +1,7 @@ -import { config } from 'dotenv' import knex from 'knex' import { log } from '../utils/index.js' -config() - export const database = knex.knex({ client: 'mysql2', connection: { diff --git a/src/services/getLevel.ts b/src/services/getLevel.ts new file mode 100644 index 00000000..f51798a1 --- /dev/null +++ b/src/services/getLevel.ts @@ -0,0 +1,23 @@ +import { zworpshop } from '@zeepkist/graphql' +import { LevelGenqlSelection } from '@zeepkist/graphql/zworpshop' + +export const getLevel = async ( + fileHash: string, + nodes?: LevelGenqlSelection +) => { + const response = await zworpshop.query({ + allLevels: { + __args: { + condition: { + fileHash + } + }, + nodes: nodes ?? { + id: true, + name: true + } + } + }) + + return response.allLevels?.nodes[0] +} diff --git a/src/services/getRankings.ts b/src/services/getRankings.ts new file mode 100644 index 00000000..f59776d6 --- /dev/null +++ b/src/services/getRankings.ts @@ -0,0 +1,32 @@ +import { gtr } from '@zeepkist/graphql' +import { enumPlayerPointsOrderBy } from '@zeepkist/graphql/gtr' + +import { PAGINATION_LIMIT } from '../config/index.js' + +export const getRankings = async (offset: number) => { + const response = await gtr.query({ + allPlayerPoints: { + __args: { + first: PAGINATION_LIMIT, + offset: offset, + orderBy: [enumPlayerPointsOrderBy.POINTS_DESC] + }, + totalCount: true, + pageInfo: { + hasNextPage: true, + hasPreviousPage: true + }, + nodes: { + points: true, + rank: true, + userByUser: { + discordId: true, + steamId: true, + steamName: true + } + } + } + }) + + return response.allPlayerPoints +} diff --git a/src/services/getRecords.ts b/src/services/getRecords.ts new file mode 100644 index 00000000..08f3f60e --- /dev/null +++ b/src/services/getRecords.ts @@ -0,0 +1,192 @@ +import { gtr } from '@zeepkist/graphql' +import { + enumRecordsOrderBy, + isPersonalBest, + isRecord, + isWorldRecord, + Record +} from '@zeepkist/graphql/gtr' +import { Level } from '@zeepkist/graphql/zworpshop' +import { User } from 'discord.js' + +import { PAGINATION_LIMIT } from '../config/index.js' +import { RecordType } from '../enums/index.js' +import { getLevel } from './getLevel.js' +import { getUserByDiscordId } from './getUsers.js' + +interface ExtendedRecord + extends Omit< + Record, + 'level' | 'worldRecordsByRecord' | 'personalBestsByRecord' + > { + level: Level + isPersonalBest?: boolean + isWorldRecord?: boolean +} + +const getValidRecords = async (offset: number, user?: number) => { + const response = await gtr.query({ + allRecords: { + __args: { + first: PAGINATION_LIMIT, + offset, + orderBy: [enumRecordsOrderBy.DATE_CREATED_DESC], + condition: { + user + } + }, + totalCount: true, + pageInfo: { + hasNextPage: true, + hasPreviousPage: true + }, + nodes: { + time: true, + dateCreated: true, + level: true, + userByUser: { + discordId: true, + steamId: true, + steamName: true + }, + worldRecordsByRecord: { + totalCount: true + }, + personalBestsByRecord: { + totalCount: true + }, + __typename: true + } + } + }) + + return response.allRecords +} + +const getPersonalBests = async (offset: number, user?: number) => { + const response = await gtr.query({ + allPersonalBests: { + __args: { + first: PAGINATION_LIMIT, + offset, + orderBy: [enumRecordsOrderBy.DATE_CREATED_DESC], + condition: { + user + } + }, + totalCount: true, + pageInfo: { + hasNextPage: true, + hasPreviousPage: true + }, + nodes: { + dateCreated: true, + level: true, + userByUser: { + discordId: true, + steamId: true, + steamName: true + }, + recordByRecord: { + time: true + }, + __typename: true + } + } + }) + + return response.allPersonalBests +} + +const getWorldRecords = async (offset: number, user?: number) => { + const response = await gtr.query({ + allWorldRecords: { + __args: { + first: PAGINATION_LIMIT, + offset, + orderBy: [enumRecordsOrderBy.DATE_CREATED_DESC], + condition: { + user + } + }, + totalCount: true, + pageInfo: { + hasNextPage: true, + hasPreviousPage: true + }, + nodes: { + dateCreated: true, + level: true, + userByUser: { + discordId: true, + steamId: true, + steamName: true + }, + recordByRecord: { + time: true + }, + __typename: true + } + } + }) + + return response.allWorldRecords +} + +export const getRecords = async ( + offset: number, + type: RecordType = RecordType.All, + discordUser?: User | null +) => { + const user = discordUser + ? await getUserByDiscordId(discordUser.id) + : undefined + + const response = await (type === RecordType.WorldRecord + ? getWorldRecords(offset, user?.id) + : type === RecordType.PersonalBest + ? getPersonalBests(offset, user?.id) + : getValidRecords(offset, user?.id)) + + if (!response) return + + const nodes = await Promise.all( + response.nodes.map(async record => { + if (!record) return + + const level = await getLevel(record.level, { + id: true, + fileAuthor: true, + name: true, + validation: true, + gold: true, + silver: true, + bronze: true + }) + + if (!level) return + + const newRecord = { + ...record, + level + } as ExtendedRecord + + if (isRecord(record)) { + newRecord.isPersonalBest = record?.personalBestsByRecord.totalCount > 0 + newRecord.isWorldRecord = record?.worldRecordsByRecord.totalCount > 0 + } + + if (isPersonalBest(record) || isWorldRecord(record)) { + newRecord.time = record.recordByRecord?.time ?? 0 + } + + return newRecord + }) + ) + + return { + totalCount: response?.totalCount, + pageInfo: response?.pageInfo, + nodes + } +} diff --git a/src/services/getUsers.ts b/src/services/getUsers.ts new file mode 100644 index 00000000..008d7888 --- /dev/null +++ b/src/services/getUsers.ts @@ -0,0 +1,36 @@ +import { gtr } from '@zeepkist/graphql' +import { UserCondition, UserGenqlSelection } from '@zeepkist/graphql/gtr' + +export const getUserByDiscordId = async (discordId: string) => { + const users = await getUsers({ + nodes: { + id: true + }, + condition: { + discordId + } + }) + + return users?.nodes[0] +} + +export const getUsers = async (options?: { + nodes: UserGenqlSelection + condition: UserCondition +}) => { + const { nodes, condition } = options ?? {} + + const response = await gtr.query({ + allUsers: { + __args: { + condition + }, + nodes: nodes ?? { + id: true, + name: true + } + } + }) + + return response.allUsers +} diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 00000000..2b50d27c --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,64 @@ +import { + ButtonInteraction, + ChatInputApplicationCommandData, + CommandInteraction, + User, + UserApplicationCommandData +} from 'discord.js' + +import { PaginatedButtonActionEnum, RecordType } from '../enums/index.js' + +export interface Command extends ChatInputApplicationCommandData { + ephemeral: boolean + run: (interaction: CommandInteraction, discordId?: string) => void +} + +export interface ContextMenu extends UserApplicationCommandData { + ephemeral: boolean + run: (interaction: CommandInteraction, user: User) => void +} + +export interface Button { + name: string + type?: 'pagination' | undefined + run: (interaction: ButtonInteraction) => void +} + +export type PaginatedButtonAction = + | PaginatedButtonActionEnum.First + | PaginatedButtonActionEnum.Previous + | PaginatedButtonActionEnum.Next + | PaginatedButtonActionEnum.Last + +export interface PaginatedButton extends Omit { + type: 'pagination' + run: ( + interaction: ButtonInteraction, + command: string, + action: PaginatedButtonAction + ) => void +} + +/** + * Pagination buttons + */ +export interface CollectorFilterValue { + customId: string +} + +export interface PaginatedMessageQuery { + id?: number + workshopId?: string + author?: string + name?: string + user?: User | null + recordType?: RecordType +} + +export interface PaginatedMessage { + messageId: string + currentPage: number + query: string | PaginatedMessageQuery + createdAt: Date + updatedAt: Date +} diff --git a/src/utils/bestMedal.spec.ts b/src/utils/bestMedal.spec.ts index 0fad4364..4af6194a 100644 --- a/src/utils/bestMedal.spec.ts +++ b/src/utils/bestMedal.spec.ts @@ -1,81 +1,81 @@ +import { Level } from '@zeepkist/graphql/zworpshop' import test from 'ava' -import { LevelRecord } from '../models/record.js' import { bestMedal } from './index.js' -const macro = test.macro((t, input: LevelRecord, expected: string) => - t.is(bestMedal(input), expected) +interface MacroOptions { + time: number + level: Level + isWorldRecord?: boolean +} + +const macro = test.macro( + (t, { time, level, isWorldRecord }: MacroOptions, expected: string) => + t.is(bestMedal(time, level, isWorldRecord), expected) ) -const level = { +const level: Level = { id: 1, - uniqueId: '1', workshopId: '1', name: 'Test Level', - author: 'Test User', - timeAuthor: 30.4532, - timeGold: 30.6, - timeSilver: 32, - timeBronze: 36, - thumbnailUrl: '' + nodeId: '1', + imageUrl: '', + createdAt: '2021-01-01T00:00:00.000Z', + updatedAt: '2021-01-01T00:00:00.000Z', + valid: true, + validation: 30, + gold: 32, + silver: 34, + bronze: 36, + authorId: 1, + fileHash: '', + fileUrl: '', + fileAuthor: '', + fileUid: '', + // eslint-disable-next-line unicorn/no-null + replacedBy: null, + deleted: false, + __typename: 'Level' } -const createLevelRecord = ( - time: number, - isWorldRecord = false -): LevelRecord => ({ - id: 1, - dateCreated: '2021-01-01T00:00:00.000Z', - time, - splits: [] as number[], - ghostUrl: '', - screenshotUrl: '', - isBest: false, - isValid: true, - isWorldRecord, - gameVersion: '', - user: { id: 1, steamId: '1', steamName: 'Test User' }, - level -}) - test( 'displays world record medal', macro, - createLevelRecord(30.4, true), + { time: 30.5, level, isWorldRecord: true }, '<:wr:1065822034090799135>' ) test( 'displays author medal', macro, - createLevelRecord(30.4), + { time: 29.5, level }, '<:author:1065842677020626944>' ) test( 'displays gold medal', macro, - createLevelRecord(30.5), + { time: 31, level }, '<:gold:1065842710365351947>' ) test( 'displays silver medal', macro, - createLevelRecord(31), + { time: 33, level }, '<:silver:1065842724433051748>' ) test( 'displays bronze medal', macro, - createLevelRecord(33), + { time: 35, level }, '<:bronze:1065842732259606578>' ) test( 'displays no medal', macro, - createLevelRecord(38), + { time: 38, level }, '<:blank:1065818232734351390>' ) diff --git a/src/utils/bestMedal.ts b/src/utils/bestMedal.ts index a6529f1d..72eb1c52 100644 --- a/src/utils/bestMedal.ts +++ b/src/utils/bestMedal.ts @@ -1,11 +1,16 @@ -import { LevelRecord } from '../models/record.js' +import { Level } from '@zeepkist/graphql/zworpshop' + import { MEDAL } from './medal.js' -export const bestMedal = (record: LevelRecord) => { - if (record.isWorldRecord) return MEDAL.WR - else if (record.time < record.level.timeAuthor) return MEDAL.AUTHOR - else if (record.time < record.level.timeGold) return MEDAL.GOLD - else if (record.time < record.level.timeSilver) return MEDAL.SILVER - else if (record.time < record.level.timeBronze) return MEDAL.BRONZE +export const bestMedal = ( + time: number, + level: Level, + isWorldRecord?: boolean +) => { + if (isWorldRecord) return MEDAL.WR + else if (time < level.validation) return MEDAL.AUTHOR + else if (time < level.gold) return MEDAL.GOLD + else if (time < level.silver) return MEDAL.SILVER + else if (time < level.bronze) return MEDAL.BRONZE else return MEDAL.NONE } diff --git a/src/utils/createButtons.ts b/src/utils/createButtons.ts new file mode 100644 index 00000000..26cb903b --- /dev/null +++ b/src/utils/createButtons.ts @@ -0,0 +1,6 @@ +import { pagination } from '../buttons/pagination.js' +import { Button, PaginatedButton } from '../types/index.js' + +const buttons: (Button | PaginatedButton)[] = [pagination] + +export const createButtons = () => buttons diff --git a/src/utils/createCommands.ts b/src/utils/createCommands.ts new file mode 100644 index 00000000..71a6999c --- /dev/null +++ b/src/utils/createCommands.ts @@ -0,0 +1,16 @@ +import { about } from '../commands/about.js' +import { leaderboard } from '../commands/leaderboard.js' +import { level } from '../commands/level.js' +import { random } from '../commands/random.js' +import { records } from '../commands/records.js' +import { user } from '../commands/user.js' +import type { Command } from '../types/index.js' + +export const createCommands = (): Command[] => [ + about, + leaderboard, + level, + random, + records, + user +] diff --git a/src/utils/createContextMenus.ts b/src/utils/createContextMenus.ts new file mode 100644 index 00000000..0648ba05 --- /dev/null +++ b/src/utils/createContextMenus.ts @@ -0,0 +1,3 @@ +import { ContextMenu } from '../types/index.js' + +export const createContextMenus = (): ContextMenu[] => [] diff --git a/src/utils/createEmbed.ts b/src/utils/createEmbed.ts new file mode 100644 index 00000000..fd303873 --- /dev/null +++ b/src/utils/createEmbed.ts @@ -0,0 +1,4 @@ +import { EmbedBuilder } from 'discord.js' + +export const createEmbed = (title: string): EmbedBuilder => + new EmbedBuilder().setTitle(title).setColor(0xff_92_00).setTimestamp() diff --git a/src/utils/extractPages.ts b/src/utils/extractPages.ts index 9450f998..4750f404 100644 --- a/src/utils/extractPages.ts +++ b/src/utils/extractPages.ts @@ -1,6 +1,14 @@ export const extractPages = (string?: string) => { - if (!string) return { currentPage: 0, totalPages: 0 } + console.debug('string', string) + + if (!string || !string.startsWith('Page ')) { + return { currentPage: 0, totalPages: 0 } + } + const pages = string.split('Page ')[1].split('.')[0] + + console.debug('pages', pages) + const [currentPage, totalPages] = pages.split(' of ') return { diff --git a/src/utils/findCommand.ts b/src/utils/findCommand.ts new file mode 100644 index 00000000..49530bc9 --- /dev/null +++ b/src/utils/findCommand.ts @@ -0,0 +1,13 @@ +import { + Button, + Command, + ContextMenu, + PaginatedButton +} from '../types/index.js' + +export const findCommand = < + T extends Command | ContextMenu | Button | PaginatedButton +>( + array: T[], + name: string +): T | undefined => array.find(command => command.name === name) diff --git a/src/utils/format.spec.ts b/src/utils/format.spec.ts index ccc3ef4b..efa15822 100644 --- a/src/utils/format.spec.ts +++ b/src/utils/format.spec.ts @@ -1,7 +1,7 @@ +import { User } from '@zeepkist/graphql/gtr' +import { Level } from '@zeepkist/graphql/zworpshop' import test from 'ava' -import { Level } from '../models/level.js' -import { User } from '../models/user.js' import { formatLevel, formatOrdinal, formatRank, formatUser } from './index.js' const macro = test.macro((t, input: string, expected: string) => @@ -10,25 +10,37 @@ const macro = test.macro((t, input: string, expected: string) => // formatRank -test('displays rank 1', macro, formatRank(1), '**β€‡πŸ·**') -test('displays rank 10', macro, formatRank(2), '**β€‡πŸΈ**') -test('displays rank 100', macro, formatRank(100), '**𝟷𝟢𝟢**') -test('displays rank 1000', macro, formatRank(1000), '**𝟷𝟢𝟢𝟢**') +test('displays rank 1', macro, formatRank(1), '**β€‡πŸ·)**') +test('displays rank 10', macro, formatRank(2), '**β€‡πŸΈ)**') +test('displays rank 100', macro, formatRank(100), '**𝟷𝟢𝟢)**') +test('displays rank 1000', macro, formatRank(1000), '**𝟷𝟢𝟢𝟢)**') // formatLevel const level: Level = { id: 1, - uniqueId: '1', workshopId: '1', name: 'Level 1', - author: 'Author Name', - timeAuthor: 30.4532, - timeGold: 30.6, - timeSilver: 32, - timeBronze: 36, - thumbnailUrl: '' + nodeId: '1', + imageUrl: '', + createdAt: '2021-01-01T00:00:00.000Z', + updatedAt: '2021-01-01T00:00:00.000Z', + valid: true, + validation: 30, + gold: 32, + silver: 34, + bronze: 36, + authorId: 1, + fileHash: '', + fileUrl: '', + fileAuthor: 'Author Name', + fileUid: '', + // eslint-disable-next-line unicorn/no-null + replacedBy: null, + deleted: false, + __typename: 'Level' } + test( 'displays level link', macro, @@ -38,11 +50,12 @@ test( // formatUser -const user: User = { +const user: Pick = { id: 1, steamId: '2', steamName: 'User Name' } + test( 'displays user link', macro, diff --git a/src/utils/format.ts b/src/utils/format.ts index c6232bcd..04077fef 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -1,21 +1,27 @@ +import { User } from '@zeepkist/graphql/gtr' +import { Level } from '@zeepkist/graphql/zworpshop' import { formatDistanceToNowStrict } from 'date-fns' -import { bold, hyperlink, italic } from 'discord.js' +import { bold, hyperlink, inlineCode, italic } from 'discord.js' -import { ZEEPKIST_URL } from '../constants.js' -import { Level } from '../models/level.js' -import { User } from '../models/user.js' +import { ZEEPKIST_URL } from '../config/index.js' +import { DiscordDateEnum } from '../enums/index.js' import { numberToMonospace } from './index.js' export const formatRank = (rank: number): string => - bold(`${numberToMonospace(rank)}`.padStart(3, ' ')) + bold(`${numberToMonospace(rank)})`.padStart(4, ' ')) export const formatLevel = (level: Level): string => `${hyperlink(level.name, `${ZEEPKIST_URL}/level/${level.id}`)} by ${italic( - level.author + level.fileAuthor )}` -export const formatUser = (user: User): string => - hyperlink(user.steamName, `${ZEEPKIST_URL}/user/${user.steamId}`) +export const formatUser = ( + user: Pick +): string => { + if (!user.steamName) return bold('Unknown') + + return hyperlink(user.steamName, `${ZEEPKIST_URL}/user/${user.steamId}`) +} export const formatRelativeDate = (date: string) => { return formatDistanceToNowStrict(new Date(date), { @@ -25,6 +31,15 @@ export const formatRelativeDate = (date: string) => { .replaceAll('minute', 'min') } +export const formatDiscordDate = ( + date: string, + type: DiscordDateEnum = DiscordDateEnum.Relative +) => { + const timestamp = new Date(date).getTime() + + return `` +} + const pad = (number: number, size: number) => ('00000' + number).slice(size * -1) @@ -37,10 +52,13 @@ export const formatResultTime = (input: number, precision = 4) => { let string = '' if (hours) string += `${pad(hours, 2)}:` - return (string += `${pad(minutes, 2)}:${pad(seconds, 2)}.${pad( + + const result = (string += `${pad(minutes, 2)}:${pad(seconds, 2)}.${pad( milliseconds, precision )}`) + + return inlineCode(result) } export const formatOrdinal = (number: number) => { diff --git a/src/utils/index.ts b/src/utils/index.ts index 1a3b543e..849ae9af 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,5 +1,7 @@ export * from './bestMedal.js' +export * from './createEmbed.js' export * from './extractPages.js' +export * from './findCommand.js' export * from './format.js' export * from './formatThumbnailEmbed.js' export * from './inviteUrl.js' diff --git a/src/utils/inviteUrl.ts b/src/utils/inviteUrl.ts index 71a7ebd3..d24ac1c3 100644 --- a/src/utils/inviteUrl.ts +++ b/src/utils/inviteUrl.ts @@ -1,6 +1,6 @@ // https://discord.com/developers/applications/1014233853147230308/bot -import { IS_PRODUCTION } from '../constants.js' +import { IS_PRODUCTION } from '../config/index.js' const clientIdCanary = '1014233853147230308' const clientIdProduction = '1064354910612762674' diff --git a/src/utils/log.ts b/src/utils/log.ts index 84a7222e..f9495b60 100644 --- a/src/utils/log.ts +++ b/src/utils/log.ts @@ -67,6 +67,18 @@ export const log = { logger.info(message) } }, + warn: (message: string, interaction?: LogProperties['interaction']) => { + if (interaction) { + const guild = guildName(interaction) + const name = interactionName(interaction) + + logger.warn(message, { + label: `[${guild}][${name}]` + }) + } else { + logger.warn(message) + } + }, error: (message: string, interaction?: LogProperties['interaction']) => { if (interaction) { const guild = guildName(interaction) diff --git a/src/utils/medal.ts b/src/utils/medal.ts index 83a0c814..7e78b695 100644 --- a/src/utils/medal.ts +++ b/src/utils/medal.ts @@ -1,4 +1,4 @@ -import { IS_PRODUCTION } from '../constants.js' +import { IS_PRODUCTION } from '../config/index.js' export const MEDAL = { WR: '<:wr:1065822034090799135>',