diff --git a/.env b/.env index f365365..d81a6c5 100644 --- a/.env +++ b/.env @@ -6,6 +6,8 @@ BOT_LANGUAGE=en BOT_MAX_SONGS_IN_QUEUE=500 BOT_MAX_SONGS_HISTORY_SIZE=60 +BOT_MAX_PLAYLISTS_PER_USER=25 +BOT_MAX_SONGS_IN_USER_PLAYLIST=500 BOT_DISCORD_TOKEN=undefined BOT_DISCORD_CLIENT_ID=undefined diff --git a/.prettierrc b/.prettierrc index 262579f..6aea180 100644 --- a/.prettierrc +++ b/.prettierrc @@ -2,6 +2,6 @@ "semi": true, "trailingComma": "none", "singleQuote": true, - "printWidth": 100, + "printWidth": 120, "endOfLine": "auto" } diff --git a/README.md b/README.md index 123d67e..58669cf 100644 --- a/README.md +++ b/README.md @@ -5,13 +5,17 @@

Cool audiobot for Discord created by @AlexInCube

-## 🌟 Features -- Command /alcotest which shows your alcohol count in blood -- Audioplayer based on [Distube](https://github.com/skick1234/DisTube) with buttons +## 🖥️ Setup +- Go to [Wiki Setup Section](https://github.com/AlexInCube/AlCoTest/wiki/Setup) +## 🌟 Features ![play-audioplayer](/wiki/images/commands/play-audioplayer.png) - +- Audioplayer based on [Distube](https://github.com/skick1234/DisTube) with buttons +- Playlists for songs +- Lyrics for songs +- Downloading of songs via /download command - Support YouTube, Spotify, Soundcloud, Apple Music, any HTTP-stream and Discord Attachments (/playfile support MP3/WAV/OGG) -- Support Slash and Text commands (with customizable prefix per server using /setprefix) +- Support Slash and Text commands system (with customizable prefix per server using /setprefix) - Localization (English and Russian are currently supported) -- Go to [Wiki](https://github.com/AlexInCube/AlCoTest/wiki) to get more information about features and other. +- Command /alcotest which shows your alcohol count in blood +- Go to [Wiki](https://github.com/AlexInCube/AlCoTest/wiki) to get more information about features, commands, and others. diff --git a/icons/audioplayer/player/favorite.png b/icons/audioplayer/player/favorite.png new file mode 100644 index 0000000..b28a537 Binary files /dev/null and b/icons/audioplayer/player/favorite.png differ diff --git a/package.json b/package.json index 3d0676a..9e46319 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aicbot", - "version": "3.6.0", + "version": "3.7.0", "description": "Discord Bot for playing music", "main": "build/main.js", "scripts": { @@ -61,7 +61,7 @@ "@typescript-eslint/eslint-plugin": "^8.0.1", "@typescript-eslint/parser": "^8.0.1", "@eslint/js": "^9.9.0", - "typescript-eslint": "7.18.0", + "typescript-eslint": "^8.2.0", "eslint": "^9.9.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.2.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0188dae..ab9474b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -142,8 +142,8 @@ importers: specifier: ^5.5.4 version: 5.5.4 typescript-eslint: - specifier: 7.18.0 - version: 7.18.0(eslint@9.9.0)(typescript@5.5.4) + specifier: ^8.2.0 + version: 8.2.0(eslint@9.9.0)(typescript@5.5.4) packages: @@ -348,19 +348,19 @@ packages: '@types/yauzl@2.10.3': resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} - '@typescript-eslint/eslint-plugin@7.18.0': - resolution: {integrity: sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==} - engines: {node: ^18.18.0 || >=20.0.0} + '@typescript-eslint/eslint-plugin@8.0.1': + resolution: {integrity: sha512-5g3Y7GDFsJAnY4Yhvk8sZtFfV6YNF2caLzjrRPUBzewjPCaj0yokePB4LJSobyCzGMzjZZYFbwuzbfDHlimXbQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^7.0.0 - eslint: ^8.56.0 + '@typescript-eslint/parser': ^8.0.0 || ^8.0.0-alpha.0 + eslint: ^8.57.0 || ^9.0.0 typescript: '*' peerDependenciesMeta: typescript: optional: true - '@typescript-eslint/eslint-plugin@8.0.1': - resolution: {integrity: sha512-5g3Y7GDFsJAnY4Yhvk8sZtFfV6YNF2caLzjrRPUBzewjPCaj0yokePB4LJSobyCzGMzjZZYFbwuzbfDHlimXbQ==} + '@typescript-eslint/eslint-plugin@8.2.0': + resolution: {integrity: sha512-02tJIs655em7fvt9gps/+4k4OsKULYGtLBPJfOsmOq1+3cdClYiF0+d6mHu6qDnTcg88wJBkcPLpQhq7FyDz0A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: '@typescript-eslint/parser': ^8.0.0 || ^8.0.0-alpha.0 @@ -370,18 +370,18 @@ packages: typescript: optional: true - '@typescript-eslint/parser@7.18.0': - resolution: {integrity: sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==} - engines: {node: ^18.18.0 || >=20.0.0} + '@typescript-eslint/parser@8.0.1': + resolution: {integrity: sha512-5IgYJ9EO/12pOUwiBKFkpU7rS3IU21mtXzB81TNwq2xEybcmAZrE9qwDtsb5uQd9aVO9o0fdabFyAmKveXyujg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.56.0 + eslint: ^8.57.0 || ^9.0.0 typescript: '*' peerDependenciesMeta: typescript: optional: true - '@typescript-eslint/parser@8.0.1': - resolution: {integrity: sha512-5IgYJ9EO/12pOUwiBKFkpU7rS3IU21mtXzB81TNwq2xEybcmAZrE9qwDtsb5uQd9aVO9o0fdabFyAmKveXyujg==} + '@typescript-eslint/parser@8.2.0': + resolution: {integrity: sha512-j3Di+o0lHgPrb7FxL3fdEy6LJ/j2NE8u+AP/5cQ9SKb+JLH6V6UHDqJ+e0hXBkHP1wn1YDFjYCS9LBQsZDlDEg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 @@ -390,26 +390,25 @@ packages: typescript: optional: true - '@typescript-eslint/scope-manager@7.18.0': - resolution: {integrity: sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==} - engines: {node: ^18.18.0 || >=20.0.0} - '@typescript-eslint/scope-manager@8.0.1': resolution: {integrity: sha512-NpixInP5dm7uukMiRyiHjRKkom5RIFA4dfiHvalanD2cF0CLUuQqxfg8PtEUo9yqJI2bBhF+pcSafqnG3UBnRQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/type-utils@7.18.0': - resolution: {integrity: sha512-XL0FJXuCLaDuX2sYqZUUSOJ2sG5/i1AAze+axqmLnSkNEVMVYLF+cbwlB2w8D1tinFuSikHmFta+P+HOofrLeA==} - engines: {node: ^18.18.0 || >=20.0.0} + '@typescript-eslint/scope-manager@8.2.0': + resolution: {integrity: sha512-OFn80B38yD6WwpoHU2Tz/fTz7CgFqInllBoC3WP+/jLbTb4gGPTy9HBSTsbDWkMdN55XlVU0mMDYAtgvlUspGw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/type-utils@8.0.1': + resolution: {integrity: sha512-+/UT25MWvXeDX9YaHv1IS6KI1fiuTto43WprE7pgSMswHbn1Jm9GEM4Txp+X74ifOWV8emu2AWcbLhpJAvD5Ng==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.56.0 typescript: '*' peerDependenciesMeta: typescript: optional: true - '@typescript-eslint/type-utils@8.0.1': - resolution: {integrity: sha512-+/UT25MWvXeDX9YaHv1IS6KI1fiuTto43WprE7pgSMswHbn1Jm9GEM4Txp+X74ifOWV8emu2AWcbLhpJAvD5Ng==} + '@typescript-eslint/type-utils@8.2.0': + resolution: {integrity: sha512-g1CfXGFMQdT5S+0PSO0fvGXUaiSkl73U1n9LTK5aRAFnPlJ8dLKkXr4AaLFvPedW8lVDoMgLLE3JN98ZZfsj0w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '*' @@ -417,25 +416,25 @@ packages: typescript: optional: true - '@typescript-eslint/types@7.18.0': - resolution: {integrity: sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==} - engines: {node: ^18.18.0 || >=20.0.0} - '@typescript-eslint/types@8.0.1': resolution: {integrity: sha512-PpqTVT3yCA/bIgJ12czBuE3iBlM3g4inRSC5J0QOdQFAn07TYrYEQBBKgXH1lQpglup+Zy6c1fxuwTk4MTNKIw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@7.18.0': - resolution: {integrity: sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==} - engines: {node: ^18.18.0 || >=20.0.0} + '@typescript-eslint/types@8.2.0': + resolution: {integrity: sha512-6a9QSK396YqmiBKPkJtxsgZZZVjYQ6wQ/TlI0C65z7vInaETuC6HAHD98AGLC8DyIPqHytvNuS8bBVvNLKyqvQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.0.1': + resolution: {integrity: sha512-8V9hriRvZQXPWU3bbiUV4Epo7EvgM6RTs+sUmxp5G//dBGy402S7Fx0W0QkB2fb4obCF8SInoUzvTYtc3bkb5w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '*' peerDependenciesMeta: typescript: optional: true - '@typescript-eslint/typescript-estree@8.0.1': - resolution: {integrity: sha512-8V9hriRvZQXPWU3bbiUV4Epo7EvgM6RTs+sUmxp5G//dBGy402S7Fx0W0QkB2fb4obCF8SInoUzvTYtc3bkb5w==} + '@typescript-eslint/typescript-estree@8.2.0': + resolution: {integrity: sha512-kiG4EDUT4dImplOsbh47B1QnNmXSoUqOjWDvCJw/o8LgfD0yr7k2uy54D5Wm0j4t71Ge1NkynGhpWdS0dEIAUA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '*' @@ -443,26 +442,26 @@ packages: typescript: optional: true - '@typescript-eslint/utils@7.18.0': - resolution: {integrity: sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw==} - engines: {node: ^18.18.0 || >=20.0.0} - peerDependencies: - eslint: ^8.56.0 - '@typescript-eslint/utils@8.0.1': resolution: {integrity: sha512-CBFR0G0sCt0+fzfnKaciu9IBsKvEKYwN9UZ+eeogK1fYHg4Qxk1yf/wLQkLXlq8wbU2dFlgAesxt8Gi76E8RTA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 - '@typescript-eslint/visitor-keys@7.18.0': - resolution: {integrity: sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==} - engines: {node: ^18.18.0 || >=20.0.0} + '@typescript-eslint/utils@8.2.0': + resolution: {integrity: sha512-O46eaYKDlV3TvAVDNcoDzd5N550ckSe8G4phko++OCSC1dYIb9LTc3HDGYdWqWIAT5qDUKphO6sd9RrpIJJPfg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 '@typescript-eslint/visitor-keys@8.0.1': resolution: {integrity: sha512-W5E+o0UfUcK5EgchLZsyVWqARmsM7v54/qEq6PY3YI5arkgmCzHiuk0zKSJJbm71V0xdRna4BGomkCTXz2/LkQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/visitor-keys@8.2.0': + resolution: {integrity: sha512-sbgsPMW9yLvS7IhCi8IpuK1oBmtbWUNP+hBdwl/I9nzqVsszGnNGti5r9dUtF5RLivHUFFIdRvLiTsPhzSyJ3Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@vladfrangu/async_event_emitter@2.4.5': resolution: {integrity: sha512-J7T3gUr3Wz0l7Ni1f9upgBZ7+J22/Q1B7dl0X6fG+fTsD+H+31DIosMHj4Um1dWQwqbcQ3oQf+YS2foYkDc9cQ==} engines: {node: '>=v14.0.0', npm: '>=7.0.0'} @@ -1728,11 +1727,10 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} - typescript-eslint@7.18.0: - resolution: {integrity: sha512-PonBkP603E3tt05lDkbOMyaxJjvKqQrXsnow72sVeOFINDE/qNmnnd+f9b4N+U7W6MXnnYyrhtmF2t08QWwUbA==} - engines: {node: ^18.18.0 || >=20.0.0} + typescript-eslint@8.2.0: + resolution: {integrity: sha512-DmnqaPcML0xYwUzgNbM1XaKXpEb7BShYf2P1tkUmmcl8hyeG7Pj08Er7R9bNy6AufabywzJcOybQAtnD/c9DGw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.56.0 typescript: '*' peerDependenciesMeta: typescript: @@ -2127,24 +2125,6 @@ snapshots: '@types/node': 22.2.0 optional: true - '@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@9.9.0)(typescript@5.5.4))(eslint@9.9.0)(typescript@5.5.4)': - dependencies: - '@eslint-community/regexpp': 4.11.0 - '@typescript-eslint/parser': 7.18.0(eslint@9.9.0)(typescript@5.5.4) - '@typescript-eslint/scope-manager': 7.18.0 - '@typescript-eslint/type-utils': 7.18.0(eslint@9.9.0)(typescript@5.5.4) - '@typescript-eslint/utils': 7.18.0(eslint@9.9.0)(typescript@5.5.4) - '@typescript-eslint/visitor-keys': 7.18.0 - eslint: 9.9.0 - graphemer: 1.4.0 - ignore: 5.3.1 - natural-compare: 1.4.0 - ts-api-utils: 1.3.0(typescript@5.5.4) - optionalDependencies: - typescript: 5.5.4 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/eslint-plugin@8.0.1(@typescript-eslint/parser@8.0.1(eslint@9.9.0)(typescript@5.5.4))(eslint@9.9.0)(typescript@5.5.4)': dependencies: '@eslint-community/regexpp': 4.11.0 @@ -2163,14 +2143,19 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@7.18.0(eslint@9.9.0)(typescript@5.5.4)': + '@typescript-eslint/eslint-plugin@8.2.0(@typescript-eslint/parser@8.2.0(eslint@9.9.0)(typescript@5.5.4))(eslint@9.9.0)(typescript@5.5.4)': dependencies: - '@typescript-eslint/scope-manager': 7.18.0 - '@typescript-eslint/types': 7.18.0 - '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.5.4) - '@typescript-eslint/visitor-keys': 7.18.0 - debug: 4.3.6 + '@eslint-community/regexpp': 4.11.0 + '@typescript-eslint/parser': 8.2.0(eslint@9.9.0)(typescript@5.5.4) + '@typescript-eslint/scope-manager': 8.2.0 + '@typescript-eslint/type-utils': 8.2.0(eslint@9.9.0)(typescript@5.5.4) + '@typescript-eslint/utils': 8.2.0(eslint@9.9.0)(typescript@5.5.4) + '@typescript-eslint/visitor-keys': 8.2.0 eslint: 9.9.0 + graphemer: 1.4.0 + ignore: 5.3.1 + natural-compare: 1.4.0 + ts-api-utils: 1.3.0(typescript@5.5.4) optionalDependencies: typescript: 5.5.4 transitivePeerDependencies: @@ -2189,32 +2174,45 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@7.18.0': + '@typescript-eslint/parser@8.2.0(eslint@9.9.0)(typescript@5.5.4)': dependencies: - '@typescript-eslint/types': 7.18.0 - '@typescript-eslint/visitor-keys': 7.18.0 + '@typescript-eslint/scope-manager': 8.2.0 + '@typescript-eslint/types': 8.2.0 + '@typescript-eslint/typescript-estree': 8.2.0(typescript@5.5.4) + '@typescript-eslint/visitor-keys': 8.2.0 + debug: 4.3.6 + eslint: 9.9.0 + optionalDependencies: + typescript: 5.5.4 + transitivePeerDependencies: + - supports-color '@typescript-eslint/scope-manager@8.0.1': dependencies: '@typescript-eslint/types': 8.0.1 '@typescript-eslint/visitor-keys': 8.0.1 - '@typescript-eslint/type-utils@7.18.0(eslint@9.9.0)(typescript@5.5.4)': + '@typescript-eslint/scope-manager@8.2.0': + dependencies: + '@typescript-eslint/types': 8.2.0 + '@typescript-eslint/visitor-keys': 8.2.0 + + '@typescript-eslint/type-utils@8.0.1(eslint@9.9.0)(typescript@5.5.4)': dependencies: - '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.5.4) - '@typescript-eslint/utils': 7.18.0(eslint@9.9.0)(typescript@5.5.4) + '@typescript-eslint/typescript-estree': 8.0.1(typescript@5.5.4) + '@typescript-eslint/utils': 8.0.1(eslint@9.9.0)(typescript@5.5.4) debug: 4.3.6 - eslint: 9.9.0 ts-api-utils: 1.3.0(typescript@5.5.4) optionalDependencies: typescript: 5.5.4 transitivePeerDependencies: + - eslint - supports-color - '@typescript-eslint/type-utils@8.0.1(eslint@9.9.0)(typescript@5.5.4)': + '@typescript-eslint/type-utils@8.2.0(eslint@9.9.0)(typescript@5.5.4)': dependencies: - '@typescript-eslint/typescript-estree': 8.0.1(typescript@5.5.4) - '@typescript-eslint/utils': 8.0.1(eslint@9.9.0)(typescript@5.5.4) + '@typescript-eslint/typescript-estree': 8.2.0(typescript@5.5.4) + '@typescript-eslint/utils': 8.2.0(eslint@9.9.0)(typescript@5.5.4) debug: 4.3.6 ts-api-utils: 1.3.0(typescript@5.5.4) optionalDependencies: @@ -2223,14 +2221,14 @@ snapshots: - eslint - supports-color - '@typescript-eslint/types@7.18.0': {} - '@typescript-eslint/types@8.0.1': {} - '@typescript-eslint/typescript-estree@7.18.0(typescript@5.5.4)': + '@typescript-eslint/types@8.2.0': {} + + '@typescript-eslint/typescript-estree@8.0.1(typescript@5.5.4)': dependencies: - '@typescript-eslint/types': 7.18.0 - '@typescript-eslint/visitor-keys': 7.18.0 + '@typescript-eslint/types': 8.0.1 + '@typescript-eslint/visitor-keys': 8.0.1 debug: 4.3.6 globby: 11.1.0 is-glob: 4.0.3 @@ -2242,10 +2240,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/typescript-estree@8.0.1(typescript@5.5.4)': + '@typescript-eslint/typescript-estree@8.2.0(typescript@5.5.4)': dependencies: - '@typescript-eslint/types': 8.0.1 - '@typescript-eslint/visitor-keys': 8.0.1 + '@typescript-eslint/types': 8.2.0 + '@typescript-eslint/visitor-keys': 8.2.0 debug: 4.3.6 globby: 11.1.0 is-glob: 4.0.3 @@ -2257,36 +2255,36 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@7.18.0(eslint@9.9.0)(typescript@5.5.4)': + '@typescript-eslint/utils@8.0.1(eslint@9.9.0)(typescript@5.5.4)': dependencies: '@eslint-community/eslint-utils': 4.4.0(eslint@9.9.0) - '@typescript-eslint/scope-manager': 7.18.0 - '@typescript-eslint/types': 7.18.0 - '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.5.4) + '@typescript-eslint/scope-manager': 8.0.1 + '@typescript-eslint/types': 8.0.1 + '@typescript-eslint/typescript-estree': 8.0.1(typescript@5.5.4) eslint: 9.9.0 transitivePeerDependencies: - supports-color - typescript - '@typescript-eslint/utils@8.0.1(eslint@9.9.0)(typescript@5.5.4)': + '@typescript-eslint/utils@8.2.0(eslint@9.9.0)(typescript@5.5.4)': dependencies: '@eslint-community/eslint-utils': 4.4.0(eslint@9.9.0) - '@typescript-eslint/scope-manager': 8.0.1 - '@typescript-eslint/types': 8.0.1 - '@typescript-eslint/typescript-estree': 8.0.1(typescript@5.5.4) + '@typescript-eslint/scope-manager': 8.2.0 + '@typescript-eslint/types': 8.2.0 + '@typescript-eslint/typescript-estree': 8.2.0(typescript@5.5.4) eslint: 9.9.0 transitivePeerDependencies: - supports-color - typescript - '@typescript-eslint/visitor-keys@7.18.0': + '@typescript-eslint/visitor-keys@8.0.1': dependencies: - '@typescript-eslint/types': 7.18.0 + '@typescript-eslint/types': 8.0.1 eslint-visitor-keys: 3.4.3 - '@typescript-eslint/visitor-keys@8.0.1': + '@typescript-eslint/visitor-keys@8.2.0': dependencies: - '@typescript-eslint/types': 8.0.1 + '@typescript-eslint/types': 8.2.0 eslint-visitor-keys: 3.4.3 '@vladfrangu/async_event_emitter@2.4.5': {} @@ -3562,15 +3560,15 @@ snapshots: dependencies: prelude-ls: 1.2.1 - typescript-eslint@7.18.0(eslint@9.9.0)(typescript@5.5.4): + typescript-eslint@8.2.0(eslint@9.9.0)(typescript@5.5.4): dependencies: - '@typescript-eslint/eslint-plugin': 7.18.0(@typescript-eslint/parser@7.18.0(eslint@9.9.0)(typescript@5.5.4))(eslint@9.9.0)(typescript@5.5.4) - '@typescript-eslint/parser': 7.18.0(eslint@9.9.0)(typescript@5.5.4) - '@typescript-eslint/utils': 7.18.0(eslint@9.9.0)(typescript@5.5.4) - eslint: 9.9.0 + '@typescript-eslint/eslint-plugin': 8.2.0(@typescript-eslint/parser@8.2.0(eslint@9.9.0)(typescript@5.5.4))(eslint@9.9.0)(typescript@5.5.4) + '@typescript-eslint/parser': 8.2.0(eslint@9.9.0)(typescript@5.5.4) + '@typescript-eslint/utils': 8.2.0(eslint@9.9.0)(typescript@5.5.4) optionalDependencies: typescript: 5.5.4 transitivePeerDependencies: + - eslint - supports-color typescript@5.5.4: {} diff --git a/src/CommandTypes.ts b/src/CommandTypes.ts index 6156092..309f065 100644 --- a/src/CommandTypes.ts +++ b/src/CommandTypes.ts @@ -1,6 +1,7 @@ import { AutocompleteInteraction, ChatInputCommandInteraction, + CommandInteraction, Message, PermissionResolvable, SlashCommandBuilder, @@ -65,3 +66,6 @@ interface IGuildData { voice_required?: boolean; voice_with_bot_only?: boolean; // Property enabled only if voice_required is true } + +export type InteractionReplyContext = ChatInputCommandInteraction | CommandInteraction; +export type ReplyContext = Message | InteractionReplyContext; diff --git a/src/EnvironmentVariables.ts b/src/EnvironmentVariables.ts index 3ee6a8f..4196cbe 100644 --- a/src/EnvironmentVariables.ts +++ b/src/EnvironmentVariables.ts @@ -57,6 +57,8 @@ const envVariables = z.object({ BOT_MAX_SONGS_IN_QUEUE: z.coerce.number().positive().min(1).optional().default(500), BOT_MAX_SONGS_HISTORY_SIZE: z.coerce.number().nonnegative().optional().default(60), + BOT_MAX_PLAYLISTS_PER_USER: z.coerce.number().positive().min(1).max(50).optional().default(25), + BOT_MAX_SONGS_IN_USER_PLAYLIST: z.coerce.number().positive().min(1).optional().default(500), MONGO_URI: z.string(), MONGO_DATABASE_NAME: z.string(), @@ -85,8 +87,5 @@ export const ENV = envVariables.parse(process.env); if (fs.existsSync(envPath)) { loggerSend(`Environment variables is loaded from ${envPath}`, loggerPrefixEnv); } else { - loggerSend( - `Environment variables is loaded from OS / Docker environment variables`, - loggerPrefixEnv - ); + loggerSend(`Environment variables is loaded from OS / Docker environment variables`, loggerPrefixEnv); } diff --git a/src/audioplayer/AudioPlayerTypes.ts b/src/audioplayer/AudioPlayerIcons.ts similarity index 91% rename from src/audioplayer/AudioPlayerTypes.ts rename to src/audioplayer/AudioPlayerIcons.ts index 4126c2e..3642db4 100644 --- a/src/audioplayer/AudioPlayerTypes.ts +++ b/src/audioplayer/AudioPlayerIcons.ts @@ -10,7 +10,8 @@ export enum AudioPlayerIcons { skip = '<:skipbutton:1092107438234275900>', shuffle = '<:shufflebutton:1092107651384614912>', list = '<:songlistwhite:1014551771705782405>', - lyrics = '<:lyrics:1260156581794811974>' + lyrics = '<:lyrics:1260156581794811974>', + favorite = '<:favorite:1275199463631093860>' } export enum AudioSourceIcons { diff --git a/src/audioplayer/AudioPlayersManager.ts b/src/audioplayer/AudioPlayersManager.ts index 93d4c44..b5bb938 100644 --- a/src/audioplayer/AudioPlayersManager.ts +++ b/src/audioplayer/AudioPlayersManager.ts @@ -1,15 +1,5 @@ -import { - DisTube, - PlayOptions, - Queue, - RepeatMode, - Song, - Events as DistubeEvents, - Playlist -} from 'distube'; +import { DisTube, Events as DistubeEvents, Playlist, PlayOptions, Queue, RepeatMode, Song } from 'distube'; import { AudioPlayersStore } from './AudioPlayersStore.js'; -import { pagination } from '../utilities/pagination/pagination.js'; -import { ButtonStyles, ButtonTypes } from '../utilities/pagination/paginationTypes.js'; import { clamp } from '../utilities/clamp.js'; import { generateErrorEmbed } from '../utilities/generateErrorEmbed.js'; import i18next from 'i18next'; @@ -22,7 +12,6 @@ import { ButtonInteraction, Client, CommandInteraction, - Embed, EmbedBuilder, Guild, Interaction, @@ -34,6 +23,7 @@ import { generateWarningEmbed } from '../utilities/generateWarningEmbed.js'; import { generateLyricsEmbed } from './Lyrics.js'; import { getGuildOptionLeaveOnEmpty, setGuildOptionLeaveOnEmpty } from '../schemas/SchemaGuild.js'; import { addSongToGuildSongsHistory } from '../schemas/SchemaSongsHistory.js'; +import { PaginationList } from './PaginationList.js'; export const loggerPrefixAudioplayer = `Audioplayer`; @@ -60,11 +50,11 @@ export class AudioPlayersManager { async play( voiceChannel: VoiceBasedChannel, textChannel: TextChannel, - song: string | Song, + query: string | Song | Playlist, options?: PlayOptions ) { try { - const playableThing: Song | Playlist = await this.distube.handler.resolve(song); + const playableThing: Song | Playlist = await this.distube.handler.resolve(query); // I am need manual connect user to a voice channel, because when I am using only Distube "play" // method, getVoiceConnection in @discordjs/voice is not working @@ -78,9 +68,7 @@ export class AudioPlayersManager { } catch (e) { if (ENV.BOT_VERBOSE_LOGGING) loggerError(e); await textChannel.send({ - embeds: [ - generateErrorEmbed(`${song}\n${e.message}`, i18next.t('audioplayer:play_error') as string) - ] + embeds: [generateErrorEmbed(`${query}\n${e.message}`, i18next.t('audioplayer:play_error') as string)] }); const queue = this.distube.getQueue(voiceChannel.guildId); @@ -261,22 +249,15 @@ export class AudioPlayersManager { const startingIndex = pageNumber * entriesPerPage; - for ( - let i = startingIndex; - i < Math.min(startingIndex + entriesPerPage, queue.songs.length); - i++ - ) { + for (let i = startingIndex; i < Math.min(startingIndex + entriesPerPage, queue.songs.length); i++) { const song = queue.songs[i]; - queueList += - `${i + 1}. ` + `[${song.name}](${song.url})` + ` - \`${song.formattedDuration}\`\n`; + queueList += `${i + 1}. ` + `[${song.name}](${song.url})` + ` - \`${song.formattedDuration}\`\n`; } const page = new EmbedBuilder() .setAuthor({ name: `${i18next.t('audioplayer:show_queue_songs_in_queue')}: ` }) .setTitle(queue.songs[0].name!) - .setDescription( - `**${i18next.t('audioplayer:show_queue_title')}: **\n${queueList}`.slice(0, 4096) - ); + .setDescription(`**${i18next.t('audioplayer:show_queue_title')}: **\n${queueList}`.slice(0, 4096)); if (queue.songs[0].url) { page.setURL(queue.songs[0].url); @@ -293,36 +274,7 @@ export class AudioPlayersManager { arrayEmbeds.push(buildPage(queue, i, entriesPerPage)); } - await pagination({ - embeds: arrayEmbeds as unknown as Embed[], - author: interaction.user, - interaction: interaction as CommandInteraction, - ephemeral: true, - fastSkip: true, - pageTravel: false, - buttons: [ - { - type: ButtonTypes.first, - emoji: '⬅️', - style: ButtonStyles.Secondary - }, - { - type: ButtonTypes.previous, - emoji: '◀️', - style: ButtonStyles.Secondary - }, - { - type: ButtonTypes.next, - emoji: '▶️', - style: ButtonStyles.Secondary - }, - { - type: ButtonTypes.last, - emoji: '➡️', - style: ButtonStyles.Secondary - } - ] - }); + await PaginationList(interaction as CommandInteraction, arrayEmbeds, interaction.user); } async setLeaveOnEmpty(guild: Guild, mode: boolean) { diff --git a/src/audioplayer/AudioPlayersStore.ts b/src/audioplayer/AudioPlayersStore.ts index 5987dec..ddce13d 100644 --- a/src/audioplayer/AudioPlayersStore.ts +++ b/src/audioplayer/AudioPlayersStore.ts @@ -8,11 +8,7 @@ export class AudioPlayersStore { constructor(_client: Client) { this.client = _client; } - async add( - guildId: string, - textChannel: GuildTextBasedChannel, - queue: Queue - ): Promise { + async add(guildId: string, textChannel: GuildTextBasedChannel, queue: Queue): Promise { if (this.client.guilds.cache.get(guildId)) { if (!this.collection.has(guildId)) { this.collection.set(guildId, new PlayerInstance(this.client, textChannel, queue)); diff --git a/src/audioplayer/LoadPlugins.ts b/src/audioplayer/LoadPlugins.ts index 0177c6b..311f817 100644 --- a/src/audioplayer/LoadPlugins.ts +++ b/src/audioplayer/LoadPlugins.ts @@ -92,18 +92,12 @@ export async function LoadPlugins(): Promise> { function setupYtCookieSchedule() { if (ENV.BOT_GOOGLE_EMAIL && ENV.BOT_GOOGLE_PASSWORD) { - loggerSend( - 'Google data is provided, setup cron job for cookies fetching', - loggerPrefixAudioplayerPluginsLoader - ); + loggerSend('Google data is provided, setup cron job for cookies fetching', loggerPrefixAudioplayerPluginsLoader); Cron.schedule('0 0 * * *', async () => { const cookies = await getYoutubeCookie(); if (!cookies) return; YtPlugin.cookies = cookies; - loggerSend( - 'Cookies is fetched again through Google Auth', - loggerPrefixAudioplayerPluginsLoader - ); + loggerSend('Cookies is fetched by cron job through Google Auth', loggerPrefixAudioplayerPluginsLoader); }); } } @@ -115,9 +109,7 @@ async function loadPluginsPartYoutube(plugins: Array) { if (fs.existsSync('yt-cookies.json')) { try { - YtPlugin.cookies = JSON.parse( - fs.readFileSync('yt-cookies.json', { encoding: 'utf8', flag: 'r' }) - ); + YtPlugin.cookies = JSON.parse(fs.readFileSync('yt-cookies.json', { encoding: 'utf8', flag: 'r' })); loggerSend("'yt-cookies.json' is loaded", loggerPrefixAudioplayerPluginsLoader); } catch (e) { loggerError("'yt-cookies.json' error when parsing", loggerPrefixAudioplayerPluginsLoader); diff --git a/src/audioplayer/PaginationList.ts b/src/audioplayer/PaginationList.ts new file mode 100644 index 0000000..fb51432 --- /dev/null +++ b/src/audioplayer/PaginationList.ts @@ -0,0 +1,38 @@ +import { pagination } from '../utilities/pagination/pagination.js'; +import { Embed, EmbedBuilder, Message, User } from 'discord.js'; +import { ButtonStyles, ButtonTypes } from '../utilities/pagination/paginationTypes.js'; +import { ReplyContext } from '../CommandTypes.js'; + +export async function PaginationList(ctx: ReplyContext, pages: Array, user: User) { + await pagination({ + embeds: pages as unknown as Array, + author: user, + message: ctx instanceof Message ? ctx : undefined, + interaction: ctx instanceof Message ? undefined : ctx, + ephemeral: true, + fastSkip: true, + pageTravel: false, + buttons: [ + { + type: ButtonTypes.first, + emoji: '⬅️', + style: ButtonStyles.Secondary + }, + { + type: ButtonTypes.previous, + emoji: '◀️', + style: ButtonStyles.Secondary + }, + { + type: ButtonTypes.next, + emoji: '▶️', + style: ButtonStyles.Secondary + }, + { + type: ButtonTypes.last, + emoji: '➡️', + style: ButtonStyles.Secondary + } + ] + }); +} diff --git a/src/audioplayer/PlayerButtons.ts b/src/audioplayer/PlayerButtons.ts index 2ec7602..bb5a360 100644 --- a/src/audioplayer/PlayerButtons.ts +++ b/src/audioplayer/PlayerButtons.ts @@ -1,14 +1,14 @@ import { ActionRowBuilder, ButtonBuilder, + ButtonInteraction, ButtonStyle, - InteractionCollector, - ComponentType, Client, - GuildMember, - ButtonInteraction, + ComponentType, Guild, - GuildTextBasedChannel + GuildMember, + GuildTextBasedChannel, + InteractionCollector } from 'discord.js'; import { checkMemberInVoiceWithBot } from '../utilities/checkMemberInVoiceWithBot.js'; import { generateErrorEmbed } from '../utilities/generateErrorEmbed.js'; @@ -18,13 +18,16 @@ import { generateEmbedAudioPlayerShuffle, generateEmbedAudioPlayerShuffleFailure } from '../commands/audio/shuffle.command.js'; -import { AudioPlayerIcons, AudioPlayerState } from './AudioPlayerTypes.js'; +import { AudioPlayerIcons, AudioPlayerState } from './AudioPlayerIcons.js'; import { generateEmbedAudioPlayerStop } from '../commands/audio/stop.command.js'; import { generateEmbedAudioPlayerPrevious, generateEmbedAudioPlayerPreviousFailure } from '../commands/audio/previous.command.js'; import { ENV } from '../EnvironmentVariables.js'; +import { UserPlaylistAddFavoriteSong } from '../schemas/SchemaPlaylist.js'; +import { generateSimpleEmbed } from '../utilities/generateSimpleEmbed.js'; +import i18next from 'i18next'; enum ButtonIDs { stopMusic = 'stopMusic', @@ -35,18 +38,13 @@ enum ButtonIDs { //downloadSong = 'downloadSong', shuffle = 'shuffle', showQueue = 'showQueue', - lyrics = 'lyrics' + lyrics = 'lyrics', + favorite = 'favorite' } const rowPrimary = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(ButtonIDs.stopMusic) - .setStyle(ButtonStyle.Danger) - .setEmoji(AudioPlayerIcons.stop), - new ButtonBuilder() - .setCustomId(ButtonIDs.pauseMusic) - .setStyle(ButtonStyle.Primary) - .setEmoji(AudioPlayerIcons.pause), + new ButtonBuilder().setCustomId(ButtonIDs.stopMusic).setStyle(ButtonStyle.Danger).setEmoji(AudioPlayerIcons.stop), + new ButtonBuilder().setCustomId(ButtonIDs.pauseMusic).setStyle(ButtonStyle.Primary).setEmoji(AudioPlayerIcons.pause), new ButtonBuilder() .setCustomId(ButtonIDs.toggleLoopMode) .setStyle(ButtonStyle.Primary) @@ -55,21 +53,12 @@ const rowPrimary = new ActionRowBuilder().addComponents( .setCustomId(ButtonIDs.previousSong) .setStyle(ButtonStyle.Primary) .setEmoji(AudioPlayerIcons.previous), - new ButtonBuilder() - .setCustomId(ButtonIDs.skipSong) - .setStyle(ButtonStyle.Primary) - .setEmoji(AudioPlayerIcons.skip) + new ButtonBuilder().setCustomId(ButtonIDs.skipSong).setStyle(ButtonStyle.Primary).setEmoji(AudioPlayerIcons.skip) ); const rowPrimaryPaused = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(ButtonIDs.stopMusic) - .setStyle(ButtonStyle.Danger) - .setEmoji(AudioPlayerIcons.stop), - new ButtonBuilder() - .setCustomId(ButtonIDs.pauseMusic) - .setStyle(ButtonStyle.Success) - .setEmoji(AudioPlayerIcons.play), + new ButtonBuilder().setCustomId(ButtonIDs.stopMusic).setStyle(ButtonStyle.Danger).setEmoji(AudioPlayerIcons.stop), + new ButtonBuilder().setCustomId(ButtonIDs.pauseMusic).setStyle(ButtonStyle.Success).setEmoji(AudioPlayerIcons.play), new ButtonBuilder() .setCustomId(ButtonIDs.toggleLoopMode) .setStyle(ButtonStyle.Primary) @@ -78,38 +67,27 @@ const rowPrimaryPaused = new ActionRowBuilder().addComponents( .setCustomId(ButtonIDs.previousSong) .setStyle(ButtonStyle.Primary) .setEmoji(AudioPlayerIcons.previous), - new ButtonBuilder() - .setCustomId(ButtonIDs.skipSong) - .setStyle(ButtonStyle.Primary) - .setEmoji(AudioPlayerIcons.skip) + new ButtonBuilder().setCustomId(ButtonIDs.skipSong).setStyle(ButtonStyle.Primary).setEmoji(AudioPlayerIcons.skip) ); const rowSecondary = new ActionRowBuilder().addComponents( //new ButtonBuilder().setCustomId(ButtonIDs.downloadSong).setStyle(ButtonStyle.Success).setEmoji('<:downloadwhite:1014553027614617650>'), + new ButtonBuilder().setCustomId(ButtonIDs.shuffle).setStyle(ButtonStyle.Primary).setEmoji(AudioPlayerIcons.shuffle), + new ButtonBuilder().setCustomId(ButtonIDs.showQueue).setStyle(ButtonStyle.Secondary).setEmoji(AudioPlayerIcons.list), new ButtonBuilder() - .setCustomId(ButtonIDs.shuffle) - .setStyle(ButtonStyle.Primary) - .setEmoji(AudioPlayerIcons.shuffle), - new ButtonBuilder() - .setCustomId(ButtonIDs.showQueue) + .setCustomId(ButtonIDs.favorite) .setStyle(ButtonStyle.Secondary) - .setEmoji(AudioPlayerIcons.list) + .setEmoji(AudioPlayerIcons.favorite) ); if (ENV.BOT_GENIUS_TOKEN) { rowSecondary.addComponents( - new ButtonBuilder() - .setCustomId(ButtonIDs.lyrics) - .setStyle(ButtonStyle.Secondary) - .setEmoji(AudioPlayerIcons.lyrics) + new ButtonBuilder().setCustomId(ButtonIDs.lyrics).setStyle(ButtonStyle.Secondary).setEmoji(AudioPlayerIcons.lyrics) ); } const rowWithOnlyStop = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(ButtonIDs.stopMusic) - .setStyle(ButtonStyle.Danger) - .setEmoji(AudioPlayerIcons.stop) + new ButtonBuilder().setCustomId(ButtonIDs.stopMusic).setStyle(ButtonStyle.Danger).setEmoji(AudioPlayerIcons.stop) ); export class PlayerButtons { @@ -162,9 +140,7 @@ export class PlayerButtons { const song = await this.client.audioPlayer.previous(ButtonInteraction.guild as Guild); if (song) { await ButtonInteraction.reply({ - embeds: [ - generateEmbedAudioPlayerPrevious(ButtonInteraction.member as GuildMember, song) - ] + embeds: [generateEmbedAudioPlayerPrevious(ButtonInteraction.member as GuildMember, song)] }); } else { await ButtonInteraction.reply({ @@ -224,6 +200,38 @@ export class PlayerButtons { case ButtonIDs.lyrics: { await this.client.audioPlayer.showLyrics(ButtonInteraction); + break; + } + + case ButtonIDs.favorite: { + try { + const queue = ButtonInteraction.client.audioPlayer.distube.getQueue(ButtonInteraction.guild as Guild); + + if (!queue || queue.songs.length === 0) { + await ButtonInteraction.deferUpdate(); + return; + } + + await UserPlaylistAddFavoriteSong(ButtonInteraction.user.id, queue.songs[0]); + + await ButtonInteraction.reply({ + embeds: [ + generateSimpleEmbed( + i18next.t('audioplayer:song_added_to_favorite', { + name: queue.songs[0].name!, + interpolation: { escapeValue: false } + }) + ) + ], + ephemeral: true + }); + } catch (e) { + await ButtonInteraction.reply({ + embeds: [generateErrorEmbed(e.message, e.name)], + ephemeral: true + }); + } + break; } } } catch (e) { diff --git a/src/audioplayer/PlayerEmbed.ts b/src/audioplayer/PlayerEmbed.ts index 81210b2..d1a8165 100644 --- a/src/audioplayer/PlayerEmbed.ts +++ b/src/audioplayer/PlayerEmbed.ts @@ -1,10 +1,10 @@ import { EmbedBuilder, User } from 'discord.js'; -import { AudioPlayerLoopMode, AudioPlayerState, AudioSourceIcons } from './AudioPlayerTypes.js'; -import { getNoun } from '../utilities/getNoun.js'; +import { AudioPlayerLoopMode, AudioPlayerState, AudioSourceIcons } from './AudioPlayerIcons.js'; import { formatSecondsToTime } from '../utilities/formatSecondsToTime.js'; import i18next from 'i18next'; import { Playlist, Song } from 'distube'; import { getIconFromSource } from './util/getIconFromSource.js'; +import { getSongsNoun } from './util/getSongsNoun.js'; export class PlayerEmbed extends EmbedBuilder { private playerState: AudioPlayerState = 'loading'; @@ -51,12 +51,7 @@ export class PlayerEmbed extends EmbedBuilder { this.addFields({ name: i18next.t('audioplayer:player_embed_queue'), value: ` - \`${this.songsCount} ${getNoun( - this.songsCount, - i18next.t('audioplayer:player_embed_queue_noun_one'), - i18next.t('audioplayer:player_embed_queue_noun_two'), - i18next.t('audioplayer:player_embed_queue_noun_five') - )}\` + \`${this.songsCount} ${getSongsNoun(this.songsCount)}\` \`${this.queueDuration}\` `, inline: true diff --git a/src/audioplayer/PlayerInstance.ts b/src/audioplayer/PlayerInstance.ts index 40c4ff3..9c9a9e7 100644 --- a/src/audioplayer/PlayerInstance.ts +++ b/src/audioplayer/PlayerInstance.ts @@ -2,7 +2,7 @@ import { Client, GuildTextBasedChannel, Message } from 'discord.js'; import { PlayerEmbed } from './PlayerEmbed.js'; import { Queue, Song } from 'distube'; import { PlayerButtons } from './PlayerButtons.js'; -import { AudioPlayerState } from './AudioPlayerTypes.js'; +import { AudioPlayerState } from './AudioPlayerIcons.js'; import { checkBotInVoice } from '../utilities/checkBotInVoice.js'; import i18next from 'i18next'; import { ENV } from '../EnvironmentVariables.js'; @@ -100,9 +100,7 @@ export class PlayerInstance { } // Update embed interface to represent the current state of player, BUT THIS NOT PUSHES UPDATED EMBED TO MESSAGE private updateEmbedState() { - const queue: Queue | undefined = this.client.audioPlayer.distube.getQueue( - this.textChannel.guild.id - ); + const queue: Queue | undefined = this.client.audioPlayer.distube.getQueue(this.textChannel.guild.id); if (queue) { this.queue = queue; } diff --git a/src/audioplayer/eventsHandlers/AudioPlayerEventVoiceChannelUpdate.ts b/src/audioplayer/eventsHandlers/AudioPlayerEventVoiceChannelUpdate.ts index 5ad0703..4d5f27b 100644 --- a/src/audioplayer/eventsHandlers/AudioPlayerEventVoiceChannelUpdate.ts +++ b/src/audioplayer/eventsHandlers/AudioPlayerEventVoiceChannelUpdate.ts @@ -2,11 +2,7 @@ import { Client, VoiceState } from 'discord.js'; import { isVoiceChannelEmpty } from 'distube'; import { getGuildOptionLeaveOnEmpty } from '../../schemas/SchemaGuild.js'; -export async function AudioPlayerEventVoiceChannelUpdate( - client: Client, - oldState: VoiceState, - newState: VoiceState -) { +export async function AudioPlayerEventVoiceChannelUpdate(client: Client, oldState: VoiceState, newState: VoiceState) { const messagePlayer = client.audioPlayer.playersManager.get(oldState.guild.id); if (!messagePlayer) return; diff --git a/src/audioplayer/plugins/soundcloud.ts b/src/audioplayer/plugins/soundcloud.ts index 8b3f783..930f2a9 100644 --- a/src/audioplayer/plugins/soundcloud.ts +++ b/src/audioplayer/plugins/soundcloud.ts @@ -20,12 +20,7 @@ export class SoundCloudPlugin extends ExtractorPlugin { constructor(options: SoundCloudPluginOptions = {}) { super(); if (typeof options !== 'object' || Array.isArray(options)) { - throw new DisTubeError( - 'INVALID_TYPE', - ['object', 'undefined'], - options, - 'SoundCloudPluginOptions' - ); + throw new DisTubeError('INVALID_TYPE', ['object', 'undefined'], options, 'SoundCloudPluginOptions'); } checkInvalidKey(options, ['clientId', 'oauthToken'], 'SoundCloudPluginOptions'); if (options.clientId && typeof options.clientId !== 'string') { @@ -37,12 +32,7 @@ export class SoundCloudPlugin extends ExtractorPlugin { this.soundcloud = new Soundcloud(options.clientId, options.oauthToken); } - search( - query: string, - type?: SearchType.Track, - limit?: number, - options?: ResolveOptions - ): Promise[]>; + search(query: string, type?: SearchType.Track, limit?: number, options?: ResolveOptions): Promise[]>; search( query: string, type: SearchType.Playlist, @@ -55,12 +45,7 @@ export class SoundCloudPlugin extends ExtractorPlugin { limit?: number, options?: ResolveOptions ): Promise[] | Playlist[]>; - async search( - query: string, - type: SearchType = SearchType.Track, - limit = 10, - options: ResolveOptions = {} - ) { + async search(query: string, type: SearchType = SearchType.Track, limit = 10, options: ResolveOptions = {}) { if (typeof query !== 'string') { throw new DisTubeError('INVALID_TYPE', 'string', query, 'query'); } @@ -85,10 +70,7 @@ export class SoundCloudPlugin extends ExtractorPlugin { case SearchType.Track: { const data = await this.soundcloud.tracks.searchV2({ q: query, limit }); if (!data?.collection?.length) { - throw new DisTubeError( - 'SOUNDCLOUD_PLUGIN_NO_RESULT', - `Cannot find any "${query}" ${type} on SoundCloud!` - ); + throw new DisTubeError('SOUNDCLOUD_PLUGIN_NO_RESULT', `Cannot find any "${query}" ${type} on SoundCloud!`); } return data.collection.map((t: any) => new SoundCloudSong(this, t, options)); } @@ -98,17 +80,13 @@ export class SoundCloudPlugin extends ExtractorPlugin { return ( await Promise.all( playlists.map( - async (p: any) => - new SoundCloudPlaylist(this, await this.soundcloud.playlists.fetch(p), options) + async (p: any) => new SoundCloudPlaylist(this, await this.soundcloud.playlists.fetch(p), options) ) ) ).filter(isTruthy); } default: - throw new DisTubeError( - 'SOUNDCLOUD_PLUGIN_UNSUPPORTED_TYPE', - `${type} search is not supported!` - ); + throw new DisTubeError('SOUNDCLOUD_PLUGIN_UNSUPPORTED_TYPE', `${type} search is not supported!`); } } @@ -129,10 +107,7 @@ export class SoundCloudPlugin extends ExtractorPlugin { throw new DisTubeError('SOUNDCLOUD_PLUGIN_RESOLVE_ERROR', e.message); }); if (!data || !['track', 'playlist'].includes(data.kind)) { - throw new DisTubeError( - 'SOUNDCLOUD_PLUGIN_NOT_SUPPORTED', - 'Only public tracks and playlists are supported.' - ); + throw new DisTubeError('SOUNDCLOUD_PLUGIN_NOT_SUPPORTED', 'Only public tracks and playlists are supported.'); } return data.kind === 'playlist' @@ -142,23 +117,15 @@ export class SoundCloudPlugin extends ExtractorPlugin { async getRelatedSongs(song: SoundCloudSong) { if (!song.url) { - throw new DisTubeError( - 'SOUNDCLOUD_PLUGIN_INVALID_SONG', - 'Cannot get related songs from invalid song.' - ); + throw new DisTubeError('SOUNDCLOUD_PLUGIN_INVALID_SONG', 'Cannot get related songs from invalid song.'); } const related = await this.soundcloud.tracks.relatedV2(song.url, 10); - return related - .filter((t: { title: any }) => t.title) - .map((t: any) => new SoundCloudSong(this, t)); + return related.filter((t: { title: any }) => t.title).map((t: any) => new SoundCloudSong(this, t)); } async getStreamURL(song: SoundCloudSong) { if (!song.url) { - throw new DisTubeError( - 'SOUNDCLOUD_PLUGIN_INVALID_SONG', - 'Cannot get stream url from invalid song.' - ); + throw new DisTubeError('SOUNDCLOUD_PLUGIN_INVALID_SONG', 'Cannot get stream url from invalid song.'); } const stream = await this.soundcloud.util.streamLink(song.url); if (!stream) { @@ -202,11 +169,7 @@ class SoundCloudSong extends Song { } class SoundCloudPlaylist extends Playlist { - constructor( - plugin: SoundCloudPlugin, - info: SoundcloudPlaylistV2, - options: ResolveOptions = {} - ) { + constructor(plugin: SoundCloudPlugin, info: SoundcloudPlaylistV2, options: ResolveOptions = {}) { super( { source: 'soundcloud', diff --git a/src/audioplayer/tests/AudioServices.test.ts b/src/audioplayer/tests/AudioServices.test.ts index 13b4050..5943267 100644 --- a/src/audioplayer/tests/AudioServices.test.ts +++ b/src/audioplayer/tests/AudioServices.test.ts @@ -63,17 +63,13 @@ describe('Audio Services', () => { describe(`SoundCloud`, () => { it('Song', async () => { - const song = await distube.handler.resolve( - 'https://soundcloud.com/u6lg5vfbfely/ninja-gaiden-2-ost-a-long-way' - ); + const song = await distube.handler.resolve('https://soundcloud.com/u6lg5vfbfely/ninja-gaiden-2-ost-a-long-way'); assert.ok(song); }); it('Playlist', async () => { - const playlist = await distube.handler.resolve( - 'https://soundcloud.com/u6lg5vfbfely/sets/music' - ); + const playlist = await distube.handler.resolve('https://soundcloud.com/u6lg5vfbfely/sets/music'); assert.ok(playlist); }); @@ -81,17 +77,13 @@ describe('Audio Services', () => { describe(`Yandex Music`, () => { it('Song', async () => { - const song = await distube.handler.resolve( - 'https://music.yandex.com/album/10030/track/38634572' - ); + const song = await distube.handler.resolve('https://music.yandex.com/album/10030/track/38634572'); assert.ok(song); }); it('Playlist', async () => { - const song = await distube.handler.resolve( - 'https://music.yandex.ru/users/alexander.tsimbalistiy/playlists/1000' - ); + const song = await distube.handler.resolve('https://music.yandex.ru/users/alexander.tsimbalistiy/playlists/1000'); assert.ok(song); }); @@ -105,9 +97,7 @@ describe('Audio Services', () => { describe('Apple Music', () => { it('Song', async () => { - const song = await distube.handler.resolve( - 'https://music.apple.com/us/album/v/1544457960?i=1544457962' - ); + const song = await distube.handler.resolve('https://music.apple.com/us/album/v/1544457960?i=1544457962'); assert.ok(song); }); diff --git a/src/audioplayer/util/AudioCommandWrappers.ts b/src/audioplayer/util/AudioCommandWrappers.ts index c7b9633..7787d23 100644 --- a/src/audioplayer/util/AudioCommandWrappers.ts +++ b/src/audioplayer/util/AudioCommandWrappers.ts @@ -7,9 +7,7 @@ export async function AudioCommandWrapperText(message: Message, callback: () => if (player) { if (player.getState() == 'loading') { await message.reply({ - embeds: [ - generateErrorEmbed(i18next.t('audioplayer:audio_commands_wrapper_song_processing')) - ] + embeds: [generateErrorEmbed(i18next.t('audioplayer:audio_commands_wrapper_song_processing'))] }); return; } @@ -20,17 +18,12 @@ export async function AudioCommandWrapperText(message: Message, callback: () => }); } } -export async function AudioCommandWrapperInteraction( - interaction: ChatInputCommandInteraction, - callback: () => void -) { +export async function AudioCommandWrapperInteraction(interaction: ChatInputCommandInteraction, callback: () => void) { const player = interaction.client.audioPlayer.playersManager.get(interaction.guildId!); if (player) { if (player.getState() == 'loading') { await interaction.reply({ - embeds: [ - generateErrorEmbed(i18next.t('audioplayer:audio_commands_wrapper_song_processing')) - ], + embeds: [generateErrorEmbed(i18next.t('audioplayer:audio_commands_wrapper_song_processing'))], ephemeral: true }); return; @@ -38,9 +31,7 @@ export async function AudioCommandWrapperInteraction( callback(); } else { await interaction.reply({ - embeds: [ - generateErrorEmbed(i18next.t('audioplayer:audio_commands_wrapper_player_not_exist')) - ], + embeds: [generateErrorEmbed(i18next.t('audioplayer:audio_commands_wrapper_player_not_exist'))], ephemeral: true }); } diff --git a/src/audioplayer/util/downloadSong.ts b/src/audioplayer/util/downloadSong.ts index ec8cf56..9c9d783 100644 --- a/src/audioplayer/util/downloadSong.ts +++ b/src/audioplayer/util/downloadSong.ts @@ -31,20 +31,12 @@ class DownloadSongError extends Error { } } -type DownloadSongMessage = - | 'is_not_url' - | 'not_found' - | 'song_is_too_large' - | 'failed_loading' - | 'this_is_playlist'; +type DownloadSongMessage = 'is_not_url' | 'not_found' | 'song_is_too_large' | 'failed_loading' | 'this_is_playlist'; const maxDownloadSize = 2.5e7; //bytes const maxDownloadSizeMB = maxDownloadSize / 1000000; -export async function downloadSong( - client: Client, - request: string -): Promise { +export async function downloadSong(client: Client, request: string): Promise { let streamUrl: string | undefined = ''; if (!isURL(request)) { @@ -90,10 +82,7 @@ async function convertWebmToMp3(webmStream: ReadableStream) { return createReadStream(file_name); } -export async function getSongFileAttachment( - client: Client, - query: string -): Promise { +export async function getSongFileAttachment(client: Client, query: string): Promise { const file = await downloadSong(client, query); if (!file) throw new DownloadSongError('failed_loading'); return new AttachmentBuilder(file); diff --git a/src/audioplayer/util/getIconFromSource.ts b/src/audioplayer/util/getIconFromSource.ts index 584a765..6cbaa2c 100644 --- a/src/audioplayer/util/getIconFromSource.ts +++ b/src/audioplayer/util/getIconFromSource.ts @@ -1,4 +1,4 @@ -import { AudioSourceIcons } from '../AudioPlayerTypes.js'; +import { AudioSourceIcons } from '../AudioPlayerIcons.js'; export function getIconFromSource(source: string): AudioSourceIcons { switch (source) { diff --git a/src/audioplayer/util/getSongsNoun.ts b/src/audioplayer/util/getSongsNoun.ts new file mode 100644 index 0000000..9364c94 --- /dev/null +++ b/src/audioplayer/util/getSongsNoun.ts @@ -0,0 +1,11 @@ +import { getNoun } from '../../utilities/getNoun.js'; +import i18next from 'i18next'; + +export function getSongsNoun(songsSize: number) { + return getNoun( + songsSize, + i18next.t('audioplayer:player_embed_queue_noun_one'), + i18next.t('audioplayer:player_embed_queue_noun_two'), + i18next.t('audioplayer:player_embed_queue_noun_five') + ); +} diff --git a/src/commands/audio/247.command.ts b/src/commands/audio/247.command.ts index eaf7577..f083b0a 100644 --- a/src/commands/audio/247.command.ts +++ b/src/commands/audio/247.command.ts @@ -8,9 +8,7 @@ import { generateSimpleEmbed } from '../../utilities/generateSimpleEmbed.js'; export default function (): ICommand { return { slash_data: { - slash_builder: new SlashCommandBuilder() - .setName('247') - .setDescription(i18next.t('commands:247_desc')), + slash_builder: new SlashCommandBuilder().setName('247').setDescription(i18next.t('commands:247_desc')), execute: async (interaction) => { const newMode = await toggleLeaveOnEmpty(interaction.guild as Guild); await interaction.reply({ embeds: [generateToggleLeaveOnEmptyEmbed(newMode)] }); @@ -34,9 +32,7 @@ export default function (): ICommand { } function generateToggleLeaveOnEmptyEmbed(newMode: boolean) { - return generateSimpleEmbed( - `${newMode ? i18next.t('commands:247_disabled') : i18next.t('commands:247_enabled')}` - ); + return generateSimpleEmbed(`${newMode ? i18next.t('commands:247_disabled') : i18next.t('commands:247_enabled')}`); } async function toggleLeaveOnEmpty(guild: Guild) { diff --git a/src/commands/audio/download.command.ts b/src/commands/audio/download.command.ts index d6720e8..0be21e4 100644 --- a/src/commands/audio/download.command.ts +++ b/src/commands/audio/download.command.ts @@ -16,9 +16,7 @@ export default function (): ICommand { text_data: { name: 'download', description: i18next.t('commands:download_desc'), - arguments: [ - new CommandArgument(i18next.t('commands:play_arg_link', { services: services }), true) - ], + arguments: [new CommandArgument(i18next.t('commands:play_arg_link', { services: services }), true)], execute: async (message, args) => { const songQuery = args.join(' '); diff --git a/src/commands/audio/history.command.ts b/src/commands/audio/history.command.ts index 1f80abe..e1ae96d 100644 --- a/src/commands/audio/history.command.ts +++ b/src/commands/audio/history.command.ts @@ -1,22 +1,10 @@ -import { ICommand } from '../../CommandTypes.js'; +import { ReplyContext, ICommand } from '../../CommandTypes.js'; import i18next from 'i18next'; -import { - CommandInteraction, - Embed, - EmbedBuilder, - Guild, - Message, - PermissionsBitField, - SlashCommandBuilder -} from 'discord.js'; +import { EmbedBuilder, Guild, PermissionsBitField, SlashCommandBuilder, User } from 'discord.js'; import { GroupAudio } from './AudioTypes.js'; -import { - getOrCreateGuildSongsHistory, - ISchemaSongsHistory -} from '../../schemas/SchemaSongsHistory.js'; -import { pagination } from '../../utilities/pagination/pagination.js'; -import { ButtonStyles, ButtonTypes } from '../../utilities/pagination/paginationTypes.js'; +import { getOrCreateGuildSongsHistory, ISchemaSongsHistory } from '../../schemas/SchemaSongsHistory.js'; import { ENV } from '../../EnvironmentVariables.js'; +import { PaginationList } from '../../audioplayer/PaginationList.js'; export default function (): ICommand { return { @@ -25,15 +13,13 @@ export default function (): ICommand { name: 'history', description: i18next.t('commands:history_desc'), execute: async (message) => { - await replyWithSongHistory(message.guild as Guild, undefined, message); + await replyWithSongHistory(message.guild as Guild, message, message.author); } }, slash_data: { - slash_builder: new SlashCommandBuilder() - .setName('history') - .setDescription(i18next.t('commands:history_desc')), + slash_builder: new SlashCommandBuilder().setName('history').setDescription(i18next.t('commands:history_desc')), execute: async (interaction) => { - await replyWithSongHistory(interaction.guild as Guild, interaction); + await replyWithSongHistory(interaction.guild as Guild, interaction, interaction.user); } }, guild_data: { @@ -44,20 +30,13 @@ export default function (): ICommand { }; } -async function replyWithSongHistory( - guild: Guild, - interaction?: CommandInteraction, - message?: Message -): Promise { +async function replyWithSongHistory(guild: Guild, ctx: ReplyContext, user: User): Promise { const history: ISchemaSongsHistory | null = await getOrCreateGuildSongsHistory(guild.id); if (!history) throw Error(`Can't find guild songs history: ${guild.id}`); if (history.songsHistory.length === 0) { - await interaction?.reply({ - embeds: [new EmbedBuilder().setTitle(i18next.t('commands:history_embed_no_songs'))] - }); - await message?.reply({ + await ctx.reply({ embeds: [new EmbedBuilder().setTitle(i18next.t('commands:history_embed_no_songs'))] }); } @@ -67,23 +46,12 @@ async function replyWithSongHistory( const startingIndex = pageNumber * entriesPerPage; - for ( - let i = startingIndex; - i < Math.min(startingIndex + entriesPerPage, history.songsHistory.length); - i++ - ) { + for (let i = startingIndex; i < Math.min(startingIndex + entriesPerPage, history.songsHistory.length); i++) { const song = history.songsHistory[i]; - const songDate = song.createdAt - ? `` - : ''; + const songDate = song.createdAt ? `` : ''; - songsList += - `${i + 1}. ` + - `[${song.name}](${song.url})` + - ` - <@${song.requester}>` + - ` - ${songDate}` + - '\n'; + songsList += `${i + 1}. ` + `[${song.name}](${song.url})` + ` - <@${song.requester}>` + ` - ${songDate}` + '\n'; } return new EmbedBuilder() @@ -99,36 +67,5 @@ async function replyWithSongHistory( arrayEmbeds.push(buildPage(history, i, entriesPerPage)); } - await pagination({ - embeds: arrayEmbeds as unknown as Embed[], - // @ts-expect-error I need to provide Interaction or Message for different command systems. - author: interaction?.user ?? message?.author, - message: message, - interaction: interaction, - ephemeral: true, - fastSkip: true, - pageTravel: false, - buttons: [ - { - type: ButtonTypes.first, - emoji: '⬅️', - style: ButtonStyles.Secondary - }, - { - type: ButtonTypes.previous, - emoji: '◀️', - style: ButtonStyles.Secondary - }, - { - type: ButtonTypes.next, - emoji: '▶️', - style: ButtonStyles.Secondary - }, - { - type: ButtonTypes.last, - emoji: '➡️', - style: ButtonStyles.Secondary - } - ] - }); + await PaginationList(ctx, arrayEmbeds, user); } diff --git a/src/commands/audio/jump.command.ts b/src/commands/audio/jump.command.ts index d4e341b..f489f80 100644 --- a/src/commands/audio/jump.command.ts +++ b/src/commands/audio/jump.command.ts @@ -45,10 +45,7 @@ export default function (): ICommand { .setName('jump') .setDescription(i18next.t('commands:jump_desc')) .addNumberOption((option) => - option - .setName('position') - .setDescription(i18next.t('commands:jump_arg_position')) - .setRequired(true) + option.setName('position').setDescription(i18next.t('commands:jump_arg_position')).setRequired(true) ), execute: async (interaction) => { const pos = interaction.options.getNumber('position')! - 1; diff --git a/src/commands/audio/lyrics.command.ts b/src/commands/audio/lyrics.command.ts index 26d00fa..8ab6477 100644 --- a/src/commands/audio/lyrics.command.ts +++ b/src/commands/audio/lyrics.command.ts @@ -12,9 +12,7 @@ export default function (): ICommand { text_data: { name: 'lyrics', description: i18next.t('commands:lyrics_desc'), - arguments: [ - new CommandArgument(i18next.t('commands:lyrics_arg_query', { services: services }), true) - ], + arguments: [new CommandArgument(i18next.t('commands:lyrics_arg_query', { services: services }), true)], execute: async (message: Message, args: string[]) => { const songQuery = args.join(' '); diff --git a/src/commands/audio/pl-add.command.ts b/src/commands/audio/pl-add.command.ts new file mode 100644 index 0000000..beb281c --- /dev/null +++ b/src/commands/audio/pl-add.command.ts @@ -0,0 +1,151 @@ +import { CommandArgument, ICommand, ReplyContext } from '../../CommandTypes.js'; +import { GroupAudio } from './AudioTypes.js'; +import { Message, PermissionsBitField, SlashCommandBuilder, User } from 'discord.js'; +import i18next from 'i18next'; +import { + PlaylistIsNotExists, + PlaylistMaxSongsLimit, + UserPlaylistAddSong, + UserPlaylistNamesAutocomplete +} from '../../schemas/SchemaPlaylist.js'; +import { Playlist } from 'distube'; +import { generateErrorEmbed } from '../../utilities/generateErrorEmbed.js'; +import { ENV } from '../../EnvironmentVariables.js'; +import { loggerError } from '../../utilities/logger.js'; +import { isValidURL } from '../../utilities/isValidURL.js'; +import { generateSimpleEmbed } from '../../utilities/generateSimpleEmbed.js'; + +export default function (): ICommand { + return { + text_data: { + name: 'pl-add', + description: i18next.t('commands:pl-add_desc'), + arguments: [ + new CommandArgument(i18next.t('commands:pl_arg_name'), true), + new CommandArgument(i18next.t('commands:pl_arg_song_url'), true) + ], + execute: async (message: Message, args: Array) => { + // With this we modify "args" to remove last argument and extract url + const url = args.pop() as string; + // Join all words for playlist name, when there is not url + const playlistName = args.join(' '); + + await plAddAndReply(playlistName, url, message, message.author); + } + }, + slash_data: { + slash_builder: new SlashCommandBuilder() + .setName('pl-add') + .setDescription(i18next.t('commands:pl-add_desc')) + .addStringOption((option) => + option + .setName('playlist_name') + .setDescription(i18next.t('commands:pl_arg_name')) + .setAutocomplete(true) + .setRequired(true) + ) + .addStringOption((option) => + option.setName('song_url').setDescription(i18next.t('commands:pl_arg_song_url')).setRequired(true) + ), + execute: async (interaction) => { + const playlistName = interaction.options.getString('playlist_name')!; + const url = interaction.options.getString('song_url')!; + + await plAddAndReply(playlistName, url, interaction, interaction.user); + }, + autocomplete: UserPlaylistNamesAutocomplete + }, + group: GroupAudio, + bot_permissions: [PermissionsBitField.Flags.SendMessages] + }; +} + +async function plAddAndReply(playlistName: string, url: string, ctx: ReplyContext, user: User) { + try { + if (!isValidURL(url)) { + await ctx.reply({ + embeds: [generateErrorEmbed(i18next.t('commands:pl-add_error_song_must_be_link'))], + ephemeral: true + }); + return; + } + + const song = await ctx.client.audioPlayer.distube.handler + .resolve(url) + .then((result) => result) + .catch((err) => loggerError(err)); + + if (!song) { + await ctx.reply({ + embeds: [generateErrorEmbed(i18next.t('commands:pl-add_error_song_must_be_support_in_bot_player'))], + ephemeral: true + }); + return; + } + + if (song instanceof Playlist) { + await ctx.reply({ + embeds: [generateErrorEmbed(i18next.t('commands:pl-add_error_song_must_not_be_playlist'))], + ephemeral: true + }); + return; + } + + if (song.isLive) { + await ctx.reply({ + embeds: [generateErrorEmbed(i18next.t('commands:pl-add_error_song_must_not_be_live_stream'))], + ephemeral: true + }); + return; + } + + await UserPlaylistAddSong(user.id, playlistName, song); + + await ctx.reply({ + embeds: [ + generateSimpleEmbed( + i18next.t('commands:pl-add_success', { + song: song.name, + playlist: playlistName, + interpolation: { escapeValue: false } + }) + ) + ], + ephemeral: true + }); + } catch (e) { + if (e instanceof PlaylistIsNotExists) { + await ctx.reply({ + embeds: [ + generateErrorEmbed( + i18next.t('commands:pl_error_playlist_not_exists', { + name: playlistName, + interpolation: { escapeValue: false } + }) + ) + ], + ephemeral: true + }); + return; + } + + if (e instanceof PlaylistMaxSongsLimit) { + await ctx.reply({ + embeds: [ + generateErrorEmbed( + i18next.t('commands:pl-add_error_playlist_max_songs_limit', { + name: playlistName, + count: ENV.BOT_MAX_SONGS_IN_USER_PLAYLIST, + interpolation: { escapeValue: false } + }) + ) + ], + ephemeral: true + }); + return; + } + + await ctx.reply({ embeds: [generateErrorEmbed(i18next.t('commands:pl-add_error_unknown'))], ephemeral: true }); + if (ENV.BOT_VERBOSE_LOGGING) loggerError(e); + } +} diff --git a/src/commands/audio/pl-create.command.ts b/src/commands/audio/pl-create.command.ts new file mode 100644 index 0000000..fd3fbed --- /dev/null +++ b/src/commands/audio/pl-create.command.ts @@ -0,0 +1,112 @@ +import { CommandArgument, ICommand, ReplyContext } from '../../CommandTypes.js'; +import { GroupAudio } from './AudioTypes.js'; +import { Message, PermissionsBitField, SlashCommandBuilder } from 'discord.js'; +import i18next from 'i18next'; +import { + PlaylistAlreadyExists, + PlaylistMaxPlaylistsCount, + PlaylistNameMaxLength, + PlaylistNameMinLength, + UserPlaylistCreate +} from '../../schemas/SchemaPlaylist.js'; +import { generateSimpleEmbed } from '../../utilities/generateSimpleEmbed.js'; +import { generateErrorEmbed } from '../../utilities/generateErrorEmbed.js'; +import { ENV } from '../../EnvironmentVariables.js'; +import { loggerError } from '../../utilities/logger.js'; + +export default function (): ICommand { + return { + text_data: { + name: 'pl-create', + description: i18next.t('commands:pl-create_desc'), + arguments: [new CommandArgument(i18next.t('commands:pl_arg_name'), true)], + execute: async (message: Message, args: Array) => { + const songQuery = args.join(' '); + + await plCreateAndReply(songQuery, message, message.author.id); + } + }, + slash_data: { + slash_builder: new SlashCommandBuilder() + .setName('pl-create') + .setDescription(i18next.t('commands:pl-create_desc')) + .addStringOption((option) => + option + .setName('playlist_name') + .setDescription(i18next.t('commands:pl_arg_name')) + .setRequired(true) + .setMinLength(PlaylistNameMinLength) + .setMaxLength(PlaylistNameMaxLength) + ), + execute: async (interaction) => { + const playlistName = interaction.options.getString('playlist_name')!; + + await plCreateAndReply(playlistName, interaction, interaction.user.id); + } + }, + group: GroupAudio, + bot_permissions: [PermissionsBitField.Flags.SendMessages] + }; +} + +async function plCreateAndReply(playlistName: string, ctx: ReplyContext, userID: string) { + try { + await UserPlaylistCreate(userID, playlistName); + + await ctx.reply({ + embeds: [ + generateSimpleEmbed( + i18next.t('commands:pl-create_success', { name: playlistName, interpolation: { escapeValue: false } }) + ) + ], + ephemeral: true + }); + } catch (e) { + if (e instanceof PlaylistAlreadyExists) { + await ctx.reply({ + embeds: [ + generateErrorEmbed( + i18next.t('commands:pl-create_error_duplicate', { + name: playlistName, + interpolation: { escapeValue: false } + }) + ) + ], + ephemeral: true + }); + return; + } + + if (e instanceof PlaylistMaxPlaylistsCount) { + await ctx.reply({ + embeds: [ + generateErrorEmbed( + i18next.t('commands:pl-create_error_max_playlists_count', { + count: ENV.BOT_MAX_PLAYLISTS_PER_USER + }) + ) + ], + ephemeral: true + }); + return; + } + + if (e.name === 'ValidationError') { + await ctx.reply({ + embeds: [ + generateErrorEmbed( + i18next.t('commands:pl-create_error_validation', { + min: PlaylistNameMinLength, + max: PlaylistNameMaxLength + }) + ) + ], + ephemeral: true + }); + return; + } + + await ctx.reply({ embeds: [generateErrorEmbed(i18next.t('commands:pl-create_error'))], ephemeral: true }); + if (ENV.BOT_VERBOSE_LOGGING) loggerError(e); + } +} diff --git a/src/commands/audio/pl-delete.command.ts b/src/commands/audio/pl-delete.command.ts new file mode 100644 index 0000000..6e3c052 --- /dev/null +++ b/src/commands/audio/pl-delete.command.ts @@ -0,0 +1,77 @@ +import { CommandArgument, ICommand, ReplyContext } from '../../CommandTypes.js'; +import { GroupAudio } from './AudioTypes.js'; +import { Message, PermissionsBitField, SlashCommandBuilder } from 'discord.js'; +import i18next from 'i18next'; +import { + PlaylistIsNotExists, + UserPlaylistDelete, + UserPlaylistNamesAutocomplete +} from '../../schemas/SchemaPlaylist.js'; +import { generateSimpleEmbed } from '../../utilities/generateSimpleEmbed.js'; +import { generateErrorEmbed } from '../../utilities/generateErrorEmbed.js'; +import { ENV } from '../../EnvironmentVariables.js'; +import { loggerError } from '../../utilities/logger.js'; + +export default function (): ICommand { + return { + text_data: { + name: 'pl-delete', + description: i18next.t('commands:pl-delete_desc'), + arguments: [new CommandArgument(i18next.t('commands:pl_arg_name'), true)], + execute: async (message: Message, args: Array) => { + const playlistsName = args[0]; + + await plDeleteAndReply(message, message.author.id, playlistsName); + } + }, + slash_data: { + slash_builder: new SlashCommandBuilder() + .setName('pl-delete') + .setDescription(i18next.t('commands:pl-delete_desc')) + .addStringOption((option) => + option + .setName('playlist_name') + .setDescription(i18next.t('commands:pl_arg_name')) + .setAutocomplete(true) + .setRequired(true) + ), + execute: async (interaction) => { + const playlistsName = interaction.options.getString('playlist_name')!; + + await plDeleteAndReply(interaction, interaction.user.id, playlistsName); + }, + autocomplete: UserPlaylistNamesAutocomplete + }, + group: GroupAudio, + bot_permissions: [PermissionsBitField.Flags.SendMessages] + }; +} + +async function plDeleteAndReply(ctx: ReplyContext, userID: string, playlistName: string) { + try { + await UserPlaylistDelete(userID, playlistName); + + await ctx.reply({ + embeds: [generateSimpleEmbed(i18next.t('commands:pl-delete_embed_deleted', { name: playlistName }))], + ephemeral: true + }); + } catch (e) { + if (e instanceof PlaylistIsNotExists) { + await ctx.reply({ + embeds: [ + generateErrorEmbed( + i18next.t('commands:pl_error_playlist_not_exists', { + name: playlistName, + interpolation: { escapeValue: false } + }) + ) + ], + ephemeral: true + }); + return; + } + + await ctx.reply({ embeds: [generateErrorEmbed(i18next.t('commands:pl-delete_error'))], ephemeral: true }); + if (ENV.BOT_VERBOSE_LOGGING) loggerError(e); + } +} diff --git a/src/commands/audio/pl-display.command.ts b/src/commands/audio/pl-display.command.ts new file mode 100644 index 0000000..94da183 --- /dev/null +++ b/src/commands/audio/pl-display.command.ts @@ -0,0 +1,104 @@ +import { CommandArgument, ICommand, ReplyContext } from '../../CommandTypes.js'; +import { GroupAudio } from './AudioTypes.js'; +import { EmbedBuilder, Message, PermissionsBitField, SlashCommandBuilder, User } from 'discord.js'; +import i18next from 'i18next'; +import { + ISchemaPlaylist, + PlaylistNameMaxLength, + PlaylistNameMinLength, + UserPlaylistGet, + UserPlaylistNamesAutocomplete +} from '../../schemas/SchemaPlaylist.js'; +import { generateErrorEmbed } from '../../utilities/generateErrorEmbed.js'; +import { PaginationList } from '../../audioplayer/PaginationList.js'; +import { getSongsNoun } from '../../audioplayer/util/getSongsNoun.js'; + +export default function (): ICommand { + return { + text_data: { + name: 'pl-display', + description: i18next.t('commands:pl-display_desc'), + arguments: [new CommandArgument(i18next.t('commands:pl_arg_name'), true)], + execute: async (message: Message, args: Array) => { + const playlistName = args[0]; + + await plDisplayAndReply(playlistName, message, message.author); + } + }, + slash_data: { + slash_builder: new SlashCommandBuilder() + .setName('pl-display') + .setDescription(i18next.t('commands:pl-display_desc')) + .addStringOption((option) => + option + .setName('playlist_name') + .setDescription(i18next.t('commands:pl_arg_name')) + .setRequired(true) + .setAutocomplete(true) + .setMinLength(PlaylistNameMinLength) + .setMaxLength(PlaylistNameMaxLength) + ), + execute: async (interaction) => { + const playlistName = interaction.options.getString('playlist_name')!; + + await plDisplayAndReply(playlistName, interaction, interaction.user); + }, + autocomplete: UserPlaylistNamesAutocomplete + }, + group: GroupAudio, + bot_permissions: [PermissionsBitField.Flags.SendMessages] + }; +} + +async function plDisplayAndReply(playlistName: string, ctx: ReplyContext, user: User) { + const playlist = await UserPlaylistGet(user.id, playlistName, true); + + if (!playlist) { + await ctx.reply({ + embeds: [generateErrorEmbed(i18next.t('commands:pl_error_playlist_not_exists'))], + ephemeral: true + }); + return; + } + + const playlistEmbed = new EmbedBuilder().setAuthor({ name: user.displayName, iconURL: user.displayAvatarURL() }); + + function buildPage(playlist: ISchemaPlaylist, pageNumber: number, entriesPerPage: number) { + let songsList = ''; + + const startingIndex = pageNumber * entriesPerPage; + + for (let i = startingIndex; i < Math.min(startingIndex + entriesPerPage, playlist.songs.length); i++) { + const song = playlist.songs[i]; + + const songDate = song.createdAt ? `` : ''; + + songsList += `${i + 1}. [${song.name}](${song.url}) - ${songDate} \n`; + } + + return ( + new EmbedBuilder() + .setAuthor({ + name: `${user.displayName} - ${playlist.name} - ${playlist.songs.length} ${getSongsNoun(playlist.songs.length)}`, + iconURL: user.displayAvatarURL() + }) + //.setTitle(`${i18next.t('commands:history_embed_title')} ${guild.name}`) + .setDescription(`${songsList}`.slice(0, 4096)) + ); + } + + if (playlist.songsSize === 0) { + playlistEmbed.setDescription(i18next.t('commands:pl-display_embed_no_songs')); + await ctx.reply({ embeds: [playlistEmbed], ephemeral: true }); + } else { + const arrayEmbeds: Array = []; + const entriesPerPage = 20; + const pages = Math.ceil(playlist.songs.length / entriesPerPage); + + for (let i = 0; i < pages; i++) { + arrayEmbeds.push(buildPage(playlist, i, entriesPerPage)); + } + + await PaginationList(ctx, arrayEmbeds, user); + } +} diff --git a/src/commands/audio/pl-my.command.ts b/src/commands/audio/pl-my.command.ts new file mode 100644 index 0000000..50af28d --- /dev/null +++ b/src/commands/audio/pl-my.command.ts @@ -0,0 +1,47 @@ +import { ICommand, ReplyContext } from '../../CommandTypes.js'; +import { GroupAudio } from './AudioTypes.js'; +import { EmbedBuilder, Message, PermissionsBitField, SlashCommandBuilder } from 'discord.js'; +import i18next from 'i18next'; +import { UserPlaylistGetPlaylists } from '../../schemas/SchemaPlaylist.js'; +import { generateErrorEmbed } from '../../utilities/generateErrorEmbed.js'; + +export default function (): ICommand { + return { + text_data: { + name: 'pl-my', + description: i18next.t('commands:pl-my_desc'), + execute: async (message: Message) => { + await plMyAndReply(message, message.author.id); + } + }, + slash_data: { + slash_builder: new SlashCommandBuilder().setName('pl-my').setDescription(i18next.t('commands:pl-my_desc')), + execute: async (interaction) => { + await plMyAndReply(interaction, interaction.user.id); + } + }, + group: GroupAudio, + bot_permissions: [PermissionsBitField.Flags.SendMessages] + }; +} + +async function plMyAndReply(ctx: ReplyContext, userID: string) { + const playlists = await UserPlaylistGetPlaylists(userID); + + if (playlists && playlists.length > 0) { + let playlistList = ''; + + playlists.forEach((playlist, index) => { + playlistList += `${index + 1}. ` + `${playlist.name}` + '\n'; + }); + + const playlistEmbed = new EmbedBuilder() + .setTitle(`${i18next.t('commands:pl-my_embed_title')}`) + .setDescription(`${playlistList}`.slice(0, 4096)); + + await ctx.reply({ embeds: [playlistEmbed], ephemeral: true }); + return; + } + + await ctx.reply({ embeds: [generateErrorEmbed(`${i18next.t('commands:pl-my_embed_error')}`)], ephemeral: true }); +} diff --git a/src/commands/audio/pl-play.command.ts b/src/commands/audio/pl-play.command.ts new file mode 100644 index 0000000..deca9f4 --- /dev/null +++ b/src/commands/audio/pl-play.command.ts @@ -0,0 +1,143 @@ +import { CommandArgument, ICommand, ReplyContext } from '../../CommandTypes.js'; +import { GroupAudio } from './AudioTypes.js'; +import { + Guild, + GuildMember, + Message, + PermissionsBitField, + SlashCommandBuilder, + TextChannel, + VoiceChannel +} from 'discord.js'; +import i18next from 'i18next'; +import { PlaylistIsNotExists, UserPlaylistGet, UserPlaylistNamesAutocomplete } from '../../schemas/SchemaPlaylist.js'; +import { queueSongsIsFull } from '../../audioplayer/util/queueSongsIsFull.js'; +import { generateWarningEmbed } from '../../utilities/generateWarningEmbed.js'; +import { ENV } from '../../EnvironmentVariables.js'; +import { Song } from 'distube'; +import { generateErrorEmbed } from '../../utilities/generateErrorEmbed.js'; +import { loggerError } from '../../utilities/logger.js'; +import { commandEmptyReply } from '../../utilities/commandEmptyReply.js'; + +export default function (): ICommand { + return { + text_data: { + name: 'pl-play', + description: i18next.t('commands:pl-play_desc'), + arguments: [new CommandArgument(i18next.t('commands:pl_arg_name'), true)], + execute: async (message: Message, args: Array) => { + const playlistName = args.join(' '); + + await plPlayAndReply(message, playlistName, message.author.id); + } + }, + slash_data: { + slash_builder: new SlashCommandBuilder() + .setName('pl-play') + .setDescription(i18next.t('commands:pl-play_desc')) + .addStringOption((option) => + option + .setName('playlist_name') + .setDescription(i18next.t('commands:pl_arg_name')) + .setAutocomplete(true) + .setRequired(true) + ), + execute: async (interaction) => { + const playlistName = interaction.options.getString('playlist_name')!; + + await plPlayAndReply(interaction, playlistName, interaction.user.id); + }, + autocomplete: UserPlaylistNamesAutocomplete + }, + group: GroupAudio, + guild_data: { + guild_only: true, + voice_required: true + }, + bot_permissions: [ + PermissionsBitField.Flags.SendMessages, + PermissionsBitField.Flags.Connect, + PermissionsBitField.Flags.ViewChannel, + PermissionsBitField.Flags.Speak, + PermissionsBitField.Flags.ManageMessages + ] + }; +} + +async function plPlayAndReply(ctx: ReplyContext, playlistName: string, userID: string) { + try { + if (queueSongsIsFull(ctx.client, ctx.guild as Guild)) { + await ctx.reply({ + embeds: [ + generateWarningEmbed( + i18next.t('commands:play_error_songs_limit', { + queueLimit: ENV.BOT_MAX_SONGS_IN_QUEUE + }) as string + ) + ], + ephemeral: true + }); + return; + } + + const userPlaylist = await UserPlaylistGet(userID, playlistName, true); + + const songs: Array = await Promise.all( + userPlaylist.songs.map(async (userSong) => { + return (await ctx.client.audioPlayer.distube.handler.resolve(userSong.url)) as Song; + }) + ); + + if (songs.length === 0) { + await ctx.reply({ + embeds: [ + generateErrorEmbed( + i18next.t('commands:pl-play_error_empty_playlist', { + name: playlistName, + interpolation: { escapeValue: false } + }) + ) + ], + ephemeral: true + }); + return; + } + + await commandEmptyReply(ctx); + + const member = ctx.member as GuildMember; + + const DistubePlaylist = await ctx.client.audioPlayer.distube.createCustomPlaylist(songs, { + member, + name: playlistName + }); + + await ctx.client.audioPlayer.play( + member.voice.channel as VoiceChannel, + ctx.channel as TextChannel, + DistubePlaylist, + { + member, + textChannel: ctx.channel as TextChannel + } + ); + } catch (e) { + if (e instanceof PlaylistIsNotExists) { + await ctx.reply({ + embeds: [ + generateErrorEmbed( + i18next.t('commands:pl_error_playlist_not_exists', { + name: playlistName, + interpolation: { escapeValue: false } + }) + ) + ], + ephemeral: true + }); + return; + } + + await ctx.reply({ embeds: [generateErrorEmbed(i18next.t('commands:pl-play_error_unknown'))], ephemeral: true }); + if (ENV.BOT_VERBOSE_LOGGING) loggerError(e); + } +} diff --git a/src/commands/audio/pl-remove.command.ts b/src/commands/audio/pl-remove.command.ts new file mode 100644 index 0000000..786e346 --- /dev/null +++ b/src/commands/audio/pl-remove.command.ts @@ -0,0 +1,115 @@ +import { CommandArgument, ICommand, ReplyContext } from '../../CommandTypes.js'; +import { GroupAudio } from './AudioTypes.js'; +import { Message, PermissionsBitField, SlashCommandBuilder } from 'discord.js'; +import i18next from 'i18next'; +import { + PlaylistIsNotExists, + PlaylistSongNotExists, + UserPlaylistNamesAutocomplete, + UserPlaylistRemoveSong +} from '../../schemas/SchemaPlaylist.js'; +import { generateSimpleEmbed } from '../../utilities/generateSimpleEmbed.js'; +import { generateErrorEmbed } from '../../utilities/generateErrorEmbed.js'; +import { ENV } from '../../EnvironmentVariables.js'; +import { loggerError } from '../../utilities/logger.js'; + +export default function (): ICommand { + return { + text_data: { + name: 'pl-remove', + description: i18next.t('commands:pl-remove_desc'), + arguments: [ + new CommandArgument(i18next.t('commands:pl_arg_name'), true), + new CommandArgument(i18next.t('commands:pl_arg_song_id'), true) + ], + execute: async (message: Message, args: Array) => { + // With this we modify "args" to extract songID and remain only words for playlist name + const songID = Number(args.pop()); + // Join all words, when there is no song id + const playlistName = args.join(' '); + + await plRemoveAndReply(playlistName, songID, message, message.author.id); + } + }, + slash_data: { + slash_builder: new SlashCommandBuilder() + .setName('pl-remove') + .setDescription(i18next.t('commands:pl-remove_desc')) + .addStringOption((option) => + option + .setName('playlist_name') + .setDescription(i18next.t('commands:pl_arg_name')) + .setAutocomplete(true) + .setRequired(true) + ) + .addNumberOption((option) => + option.setName('song_id').setDescription(i18next.t('commands:pl_arg_song_id')).setRequired(true) + ), + execute: async (interaction) => { + const songID = interaction.options.getNumber('song_id')! - 1; + const playlistName = interaction.options.getString('playlist_name')!; + + await plRemoveAndReply(playlistName, songID, interaction, interaction.user.id); + }, + autocomplete: UserPlaylistNamesAutocomplete + }, + group: GroupAudio, + bot_permissions: [PermissionsBitField.Flags.SendMessages] + }; +} + +async function plRemoveAndReply(playlistName: string, songID: number, ctx: ReplyContext, userID: string) { + try { + const playlistSong = await UserPlaylistRemoveSong(userID, playlistName, Number(songID)); + + await ctx.reply({ + embeds: [ + generateSimpleEmbed( + i18next.t('commands:pl-remove_success', { + song: playlistSong.name, + playlist: playlistName, + interpolation: { escapeValue: false } + }) + ) + ], + ephemeral: true + }); + } catch (e) { + if (e instanceof PlaylistIsNotExists) { + await ctx.reply({ + embeds: [ + generateErrorEmbed( + i18next.t('commands:pl_error_playlist_not_exists', { + name: playlistName, + interpolation: { escapeValue: false } + }) + ) + ], + ephemeral: true + }); + return; + } + + if (e instanceof PlaylistSongNotExists) { + await ctx.reply({ + embeds: [ + generateErrorEmbed( + i18next.t('commands:pl_error_song_is_not_exists_in_playlist', { + name: playlistName, + id: songID + 1, + interpolation: { escapeValue: false } + }) + ) + ], + ephemeral: true + }); + return; + } + + await ctx.reply({ + embeds: [generateErrorEmbed(i18next.t('commands:pl-remove_error_unknown'))], + ephemeral: true + }); + if (ENV.BOT_VERBOSE_LOGGING) loggerError(e); + } +} diff --git a/src/commands/audio/play.command.ts b/src/commands/audio/play.command.ts index 380b741..8230da7 100644 --- a/src/commands/audio/play.command.ts +++ b/src/commands/audio/play.command.ts @@ -1,4 +1,4 @@ -import { CommandArgument, ICommand } from '../../CommandTypes.js'; +import { CommandArgument, ICommand, ReplyContext } from '../../CommandTypes.js'; import { ApplicationCommandOptionChoiceData, AutocompleteInteraction, @@ -8,7 +8,6 @@ import { PermissionsBitField, SlashCommandBuilder, TextChannel, - VoiceBasedChannel, VoiceChannel } from 'discord.js'; import { GroupAudio } from './AudioTypes.js'; @@ -19,6 +18,7 @@ import ytsr from '@distube/ytsr'; import { generateWarningEmbed } from '../../utilities/generateWarningEmbed.js'; import { ENV } from '../../EnvironmentVariables.js'; import { queueSongsIsFull } from '../../audioplayer/util/queueSongsIsFull.js'; +import { commandEmptyReply } from '../../utilities/commandEmptyReply.js'; export const services = 'Youtube, Spotify, Soundcloud, Yandex Music, Apple Music, HTTP-stream'; export default function (): ICommand { @@ -26,41 +26,13 @@ export default function (): ICommand { text_data: { name: 'play', description: i18next.t('commands:play_desc'), - arguments: [ - new CommandArgument(i18next.t('commands:play_arg_link', { services: services }), true) - ], + arguments: [new CommandArgument(i18next.t('commands:play_arg_link', { services: services }), true)], execute: async (message: Message, args: string[]) => { // Play command accept only one arg is a query string. // In text command system we need to merge all words for request in one string const songQuery = args.join(' '); - const member = message.member as GuildMember; - const channel = message.channel as TextChannel; - - if (queueSongsIsFull(message.client, message.guild as Guild)) { - await message.reply({ - embeds: [ - generateWarningEmbed( - i18next.t('commands:play_error_songs_limit', { - queueLimit: ENV.BOT_MAX_SONGS_IN_QUEUE - }) as string - ) - ] - }); - return; - } - - await message.client.audioPlayer.play( - member.voice.channel as VoiceBasedChannel, - channel, - songQuery, - { - member: member, - textChannel: channel - } - ); - - await message.delete(); + await playAndReply(message, songQuery); } }, slash_data: { @@ -76,40 +48,9 @@ export default function (): ICommand { ), autocomplete: songSearchAutocomplete, execute: async (interaction) => { - if (queueSongsIsFull(interaction.client, interaction.guild as Guild)) { - await interaction.reply({ - embeds: [ - generateWarningEmbed( - i18next.t('commands:play_error_songs_limit', { - queueLimit: ENV.BOT_MAX_SONGS_IN_QUEUE - }) as string - ) - ], - ephemeral: true - }); - return; - } + const songQuery = interaction.options.getString('request')!; - const songQuery = interaction.options.getString('request'); - - await interaction.reply({ - content: i18next.t('general:thinking') as string - }); - await interaction.deleteReply(); - - const member = interaction.member as GuildMember; - - if (songQuery) { - await interaction.client.audioPlayer.play( - member.voice.channel as VoiceChannel, - interaction.channel as TextChannel, - songQuery, - { - member: interaction.member as GuildMember, - textChannel: interaction.channel as TextChannel - } - ); - } + await playAndReply(interaction, songQuery); } }, group: GroupAudio, @@ -122,8 +63,7 @@ export default function (): ICommand { PermissionsBitField.Flags.Connect, PermissionsBitField.Flags.ViewChannel, PermissionsBitField.Flags.Speak, - PermissionsBitField.Flags.ManageMessages, - PermissionsBitField.Flags.AttachFiles + PermissionsBitField.Flags.ManageMessages ] }; } @@ -140,7 +80,7 @@ export async function songSearchAutocomplete(interaction: AutocompleteInteractio type: SearchResultType.VIDEO }); - const finalResult = choices.items.map((video: ytsr.Video) => { + const finalResult: Array = choices.items.map((video: ytsr.Video) => { const duration = video.isLive ? liveText : video.duration; let choiceString = `${duration} | ${truncateString(video.author?.name ?? ' ', 20)} | `; choiceString += truncateString(video.name, 100 - choiceString.length); @@ -150,9 +90,34 @@ export async function songSearchAutocomplete(interaction: AutocompleteInteractio }; }); - await interaction.respond(finalResult as Array); + await interaction.respond(finalResult); return; } await interaction.respond([]); } + +async function playAndReply(ctx: ReplyContext, songQuery: string) { + if (queueSongsIsFull(ctx.client, ctx.guild as Guild)) { + await ctx.reply({ + embeds: [ + generateWarningEmbed( + i18next.t('commands:play_error_songs_limit', { + queueLimit: ENV.BOT_MAX_SONGS_IN_QUEUE + }) as string + ) + ], + ephemeral: true + }); + return; + } + + await commandEmptyReply(ctx); + + const member = ctx.member as GuildMember; + + await ctx.client.audioPlayer.play(member.voice.channel as VoiceChannel, ctx.channel as TextChannel, songQuery, { + member, + textChannel: ctx.channel as TextChannel + }); +} diff --git a/src/commands/audio/playfile.command.ts b/src/commands/audio/playfile.command.ts index 61a38f7..48a37f7 100644 --- a/src/commands/audio/playfile.command.ts +++ b/src/commands/audio/playfile.command.ts @@ -41,9 +41,7 @@ export default function (): ICommand { if (!musicFile) { await message.reply({ - embeds: [ - generateErrorEmbed(i18next.t('commands:play_file_missing_attachment', audioFormats)) - ] + embeds: [generateErrorEmbed(i18next.t('commands:play_file_missing_attachment', audioFormats))] }); return; } @@ -74,10 +72,7 @@ export default function (): ICommand { .setName('playfile') .setDescription(i18next.t('commands:play_file_desc')) .addAttachmentOption((option) => - option - .setName('file') - .setDescription(i18next.t('commands:play_file_arg_file')) - .setRequired(true) + option.setName('file').setDescription(i18next.t('commands:play_file_arg_file')).setRequired(true) ), execute: async (interaction) => { if (queueSongsIsFull(interaction.client, interaction.guild as Guild)) { @@ -98,9 +93,7 @@ export default function (): ICommand { if (!isAudioFile(musicFile.name)) { await interaction.reply({ - embeds: [ - generateErrorEmbed(i18next.t('commands:play_file_wrong_format', audioFormats)) - ], + embeds: [generateErrorEmbed(i18next.t('commands:play_file_wrong_format', audioFormats))], ephemeral: true }); return; diff --git a/src/commands/audio/playing.command.ts b/src/commands/audio/playing.command.ts index db06604..35cb092 100644 --- a/src/commands/audio/playing.command.ts +++ b/src/commands/audio/playing.command.ts @@ -21,9 +21,7 @@ export default function (): ICommand { } }, slash_data: { - slash_builder: new SlashCommandBuilder() - .setName('playing') - .setDescription(i18next.t('commands:playing_desc')), + slash_builder: new SlashCommandBuilder().setName('playing').setDescription(i18next.t('commands:playing_desc')), execute: async (interaction) => { await AudioCommandWrapperInteraction(interaction, async () => { await interaction.reply({ diff --git a/src/commands/audio/previous.command.ts b/src/commands/audio/previous.command.ts index 0254766..869df32 100644 --- a/src/commands/audio/previous.command.ts +++ b/src/commands/audio/previous.command.ts @@ -28,9 +28,7 @@ export default function (): ICommand { } }, slash_data: { - slash_builder: new SlashCommandBuilder() - .setName('previous') - .setDescription(i18next.t('commands:previous_desc')), + slash_builder: new SlashCommandBuilder().setName('previous').setDescription(i18next.t('commands:previous_desc')), execute: async (interaction) => { await AudioCommandWrapperInteraction(interaction, async () => { const song = await interaction.client.audioPlayer.previous(interaction.guild!); diff --git a/src/commands/audio/rewind.command.ts b/src/commands/audio/rewind.command.ts index 3641e4a..d69e19c 100644 --- a/src/commands/audio/rewind.command.ts +++ b/src/commands/audio/rewind.command.ts @@ -95,9 +95,7 @@ function hmsToSeconds(str: string): number | undefined { } export function generateEmbedAudioPlayerRewind(member: GuildMember, time: number): EmbedBuilder { - return generateSimpleEmbed( - `${member} ${i18next.t('commands:rewind_success')} ${formatSecondsToTime(time)}` - ); + return generateSimpleEmbed(`${member} ${i18next.t('commands:rewind_success')} ${formatSecondsToTime(time)}`); } export function generateEmbedAudioPlayerRewindFailure(): EmbedBuilder { diff --git a/src/commands/audio/shuffle.command.ts b/src/commands/audio/shuffle.command.ts index 2728029..1342ec4 100644 --- a/src/commands/audio/shuffle.command.ts +++ b/src/commands/audio/shuffle.command.ts @@ -1,11 +1,5 @@ import { ICommand } from '../../CommandTypes.js'; -import { - EmbedBuilder, - GuildMember, - Message, - PermissionsBitField, - SlashCommandBuilder -} from 'discord.js'; +import { EmbedBuilder, GuildMember, Message, PermissionsBitField, SlashCommandBuilder } from 'discord.js'; import { GroupAudio } from './AudioTypes.js'; import { AudioCommandWrapperInteraction, @@ -29,9 +23,7 @@ export default function (): ICommand { } }, slash_data: { - slash_builder: new SlashCommandBuilder() - .setName('shuffle') - .setDescription(i18next.t('commands:shuffle_desc')), + slash_builder: new SlashCommandBuilder().setName('shuffle').setDescription(i18next.t('commands:shuffle_desc')), execute: async (interaction): Promise => { await AudioCommandWrapperInteraction(interaction, async (): Promise => { if (await interaction.client.audioPlayer.shuffle(interaction.guild!)) { diff --git a/src/commands/audio/skip.command.ts b/src/commands/audio/skip.command.ts index b43b856..9e34ac6 100644 --- a/src/commands/audio/skip.command.ts +++ b/src/commands/audio/skip.command.ts @@ -1,11 +1,5 @@ import { ICommand } from '../../CommandTypes.js'; -import { - EmbedBuilder, - GuildMember, - Message, - PermissionsBitField, - SlashCommandBuilder -} from 'discord.js'; +import { EmbedBuilder, GuildMember, Message, PermissionsBitField, SlashCommandBuilder } from 'discord.js'; import { GroupAudio } from './AudioTypes.js'; import { AudioCommandWrapperInteraction, @@ -32,9 +26,7 @@ export default function (): ICommand { } }, slash_data: { - slash_builder: new SlashCommandBuilder() - .setName('skip') - .setDescription(i18next.t('commands:skip_desc')), + slash_builder: new SlashCommandBuilder().setName('skip').setDescription(i18next.t('commands:skip_desc')), execute: async (interaction) => { await AudioCommandWrapperInteraction(interaction, async () => { const song = await interaction.client.audioPlayer.skip(interaction.guild!); diff --git a/src/commands/audio/stop.command.ts b/src/commands/audio/stop.command.ts index 7ff7289..3a73517 100644 --- a/src/commands/audio/stop.command.ts +++ b/src/commands/audio/stop.command.ts @@ -1,12 +1,5 @@ import { ICommand } from '../../CommandTypes.js'; -import { - EmbedBuilder, - Guild, - GuildMember, - Message, - PermissionsBitField, - SlashCommandBuilder -} from 'discord.js'; +import { EmbedBuilder, Guild, GuildMember, Message, PermissionsBitField, SlashCommandBuilder } from 'discord.js'; import { GroupAudio } from './AudioTypes.js'; import { AudioCommandWrapperInteraction, @@ -28,9 +21,7 @@ export default function (): ICommand { } }, slash_data: { - slash_builder: new SlashCommandBuilder() - .setName('stop') - .setDescription(i18next.t('commands:stop_desc')), + slash_builder: new SlashCommandBuilder().setName('stop').setDescription(i18next.t('commands:stop_desc')), execute: async (interaction) => { await AudioCommandWrapperInteraction(interaction, async () => { await interaction.client.audioPlayer.stop((interaction.guild as Guild).id); diff --git a/src/commands/fun/alcotest.command.ts b/src/commands/fun/alcotest.command.ts index a46279a..7de69be 100644 --- a/src/commands/fun/alcotest.command.ts +++ b/src/commands/fun/alcotest.command.ts @@ -16,9 +16,7 @@ export default function (): ICommand { } }, slash_data: { - slash_builder: new SlashCommandBuilder() - .setName('alcotest') - .setDescription(i18next.t('commands:alcotest_desc')), + slash_builder: new SlashCommandBuilder().setName('alcotest').setDescription(i18next.t('commands:alcotest_desc')), execute: async (interaction) => { await interaction.reply({ content: generateAlcoTestMessage(), diff --git a/src/commands/info/help.command.ts b/src/commands/info/help.command.ts index dda014e..61e3082 100644 --- a/src/commands/info/help.command.ts +++ b/src/commands/info/help.command.ts @@ -132,9 +132,7 @@ export function generateSpecificCommandHelp( }); } - helpEmbed - .setTitle(`/${command.text_data.name} ${argument_string}`) - .setDescription(command.text_data.description); + helpEmbed.setTitle(`/${command.text_data.name} ${argument_string}`).setDescription(command.text_data.description); helpEmbed.addFields({ name: `✉️ ${i18next.t('commands:help_allowed_in_dm')}`, @@ -190,16 +188,12 @@ export function generateSpecificCommandHelp( return helpEmbed; } -export async function generateCommandsEmbedList( - client: Client, - guild: Guild | null -): Promise { +export async function generateCommandsEmbedList(client: Client, guild: Guild | null): Promise { let guildPrefix: string | undefined = undefined; if (guild) guildPrefix = await getGuildOptionPrefix(guild.id); - const helpEmbed = new EmbedBuilder() - .setColor('#436df7') - .setTitle(i18next.t('commands:help_about_commands')).setDescription(` + const helpEmbed = new EmbedBuilder().setColor('#436df7').setTitle(i18next.t('commands:help_about_commands')) + .setDescription(` ${i18next.t('commands:help_embed_description', { prefix: ENV.BOT_COMMAND_PREFIX, interpolation: { escapeValue: false } })} ${guildPrefix ? i18next.t('commands:help_embed_description_server_prefix', { prefix: guildPrefix, interpolation: { escapeValue: false } }) : ''} \n GitHub: https://github.com/AlexInCube/AlCoTest diff --git a/src/commands/info/inviteLink.command.ts b/src/commands/info/inviteLink.command.ts index 7d17c1a..0b473b2 100644 --- a/src/commands/info/inviteLink.command.ts +++ b/src/commands/info/inviteLink.command.ts @@ -17,9 +17,7 @@ export default function (): ICommand { } }, slash_data: { - slash_builder: new SlashCommandBuilder() - .setName('invite') - .setDescription(i18next.t('commands:invite_desc')), + slash_builder: new SlashCommandBuilder().setName('invite').setDescription(i18next.t('commands:invite_desc')), execute: async (interaction) => { await interaction.reply({ content: generateLinkMessage(), diff --git a/src/commands/info/report.command.ts b/src/commands/info/report.command.ts index ee33878..2c92d8f 100644 --- a/src/commands/info/report.command.ts +++ b/src/commands/info/report.command.ts @@ -1,10 +1,5 @@ import { ICommand } from '../../CommandTypes.js'; -import { - ChatInputCommandInteraction, - Message, - PermissionsBitField, - SlashCommandBuilder -} from 'discord.js'; +import { ChatInputCommandInteraction, Message, PermissionsBitField, SlashCommandBuilder } from 'discord.js'; import { GroupInfo } from './InfoTypes.js'; import i18next from 'i18next'; import { generateSimpleEmbed } from '../../utilities/generateSimpleEmbed.js'; @@ -19,9 +14,7 @@ export default function (): ICommand { } }, slash_data: { - slash_builder: new SlashCommandBuilder() - .setName('report') - .setDescription(i18next.t('commands:report_desc')), + slash_builder: new SlashCommandBuilder().setName('report').setDescription(i18next.t('commands:report_desc')), execute: async (interaction: ChatInputCommandInteraction) => { await interaction.reply({ embeds: [generateReportEmbed()], ephemeral: true }); } diff --git a/src/commands/info/status.command.ts b/src/commands/info/status.command.ts index a79afa1..21262df 100644 --- a/src/commands/info/status.command.ts +++ b/src/commands/info/status.command.ts @@ -26,9 +26,7 @@ export default function (): ICommand { } }, slash_data: { - slash_builder: new SlashCommandBuilder() - .setName('status') - .setDescription(i18next.t('commands:status_desc')), + slash_builder: new SlashCommandBuilder().setName('status').setDescription(i18next.t('commands:status_desc')), execute: async (interaction: ChatInputCommandInteraction) => { await interaction.reply({ embeds: [await generateStatusEmbed(interaction.client)], @@ -52,10 +50,7 @@ export async function generateStatusEmbed(client: Client): Promise } addState('Github', 'https://github.com/AlexInCube/AlCoTest'); - addState( - i18next.t('commands:status_embed_bot_version'), - `\`${process.env.npm_package_version}\`` - ); + addState(i18next.t('commands:status_embed_bot_version'), `\`${process.env.npm_package_version}\``); // addState("Websocket Heartbeat", `\`${client.ws.ping}\``) addState(i18next.t('commands:status_embed_os'), `\`${os.platform()}\``); addState(i18next.t('commands:status_embed_cpu'), `\`${cpu.model()}\``); diff --git a/src/events/interactionHandlers/slashCommandHandler.ts b/src/events/interactionHandlers/slashCommandHandler.ts index 7a91790..95b666b 100644 --- a/src/events/interactionHandlers/slashCommandHandler.ts +++ b/src/events/interactionHandlers/slashCommandHandler.ts @@ -71,7 +71,6 @@ export async function slashCommandHandler(interaction: Interaction) { await command.slash_data.execute(interaction); } catch (e) { - if (ENV.BOT_VERBOSE_LOGGING) - loggerError(`Error when executing slash command: ${e}`, loggerPrefixCommandHandler); + if (ENV.BOT_VERBOSE_LOGGING) loggerError(`Error when executing slash command: ${e}`, loggerPrefixCommandHandler); } } diff --git a/src/events/messageHandlers/textCommandsHandler.ts b/src/events/messageHandlers/textCommandsHandler.ts index c2ff7fe..223c3e2 100644 --- a/src/events/messageHandlers/textCommandsHandler.ts +++ b/src/events/messageHandlers/textCommandsHandler.ts @@ -119,7 +119,6 @@ export async function textCommandsHandler(client: Client, message: Message) { await command.text_data.execute(message, args); } catch (e) { - if (ENV.BOT_VERBOSE_LOGGING) - loggerError(`Error when executing text command: ${e}`, loggerPrefixCommandHandler); + if (ENV.BOT_VERBOSE_LOGGING) loggerError(`Error when executing text command: ${e}`, loggerPrefixCommandHandler); } } diff --git a/src/events/onGuildCreate.event.ts b/src/events/onGuildCreate.event.ts index 381387c..9f9bb0c 100644 --- a/src/events/onGuildCreate.event.ts +++ b/src/events/onGuildCreate.event.ts @@ -1,11 +1,5 @@ import { BotEvent } from '../DiscordTypes.js'; -import { - Client, - Guild, - GuildBasedChannel, - GuildTextBasedChannel, - PermissionsBitField -} from 'discord.js'; +import { Client, Guild, GuildBasedChannel, GuildTextBasedChannel, PermissionsBitField } from 'discord.js'; import { getOrCreateGuildSettings } from '../schemas/SchemaGuild.js'; import { Events } from 'discord.js'; import { CheckBotPermissions } from '../utilities/checkPermissions.js'; diff --git a/src/handlers/Command.handler.ts b/src/handlers/Command.handler.ts index b0bdd92..e582f34 100644 --- a/src/handlers/Command.handler.ts +++ b/src/handlers/Command.handler.ts @@ -25,8 +25,7 @@ const handler = async (client: Client) => { for (const filePath of scanResult) { const importPath = `file:///${filePath}`; - if (ENV.BOT_VERBOSE_LOGGING) - loggerSend(`Try to load command from: ${importPath}`, loggerPrefixCommandHandler); + if (ENV.BOT_VERBOSE_LOGGING) loggerSend(`Try to load command from: ${importPath}`, loggerPrefixCommandHandler); const commandModule = await import(importPath); @@ -61,10 +60,7 @@ const handler = async (client: Client) => { } if (ENV.BOT_VERBOSE_LOGGING) - loggerSend( - `Command ${command.text_data.name} is loaded from: ${importPath}`, - loggerPrefixCommandHandler - ); + loggerSend(`Command ${command.text_data.name} is loaded from: ${importPath}`, loggerPrefixCommandHandler); } const rest = new REST({ version: '10' }).setToken(ENV.BOT_DISCORD_TOKEN); diff --git a/src/handlers/Event.handler.ts b/src/handlers/Event.handler.ts index 76d3e5a..c79e7c0 100644 --- a/src/handlers/Event.handler.ts +++ b/src/handlers/Event.handler.ts @@ -18,8 +18,7 @@ const handler = async (client: Client) => { if (!file.endsWith('.event.js')) return; const importPath = pathToFileURL(path.resolve(eventsDir, file)).toString(); - if (ENV.BOT_VERBOSE_LOGGING) - loggerSend(`Try to load event from: ${importPath}`, loggerPrefixEventHandler); + if (ENV.BOT_VERBOSE_LOGGING) loggerSend(`Try to load event from: ${importPath}`, loggerPrefixEventHandler); const eventModule = await import(importPath); const event: BotEvent = eventModule.default; diff --git a/src/handlers/Mongo.handler.ts b/src/handlers/Mongo.handler.ts index dda4d98..86b4789 100644 --- a/src/handlers/Mongo.handler.ts +++ b/src/handlers/Mongo.handler.ts @@ -7,7 +7,9 @@ export const loggerPrefixMongo = 'MongoDB'; export default async function mongoHandler() { const MONGO_URI = ENV.MONGO_URI; mongoose.set('strictQuery', 'throw'); - mongoose.pluralize(null); + mongoose.pluralize(function (name) { + return name; + }); loggerSend('Connecting to MongoDB, please wait', loggerPrefixMongo); diff --git a/src/handlersLoad.ts b/src/handlersLoad.ts index 1445096..a6cd158 100644 --- a/src/handlersLoad.ts +++ b/src/handlersLoad.ts @@ -13,25 +13,20 @@ export async function handlersLoad(client: Client): Promise { const handlersFiles = await getAllHandlersFilesInDir(handlersDir); for (const file of handlersFiles) { - if (ENV.BOT_VERBOSE_LOGGING) - loggerSend(`Try to load handler from: ${file}`, loggerPrefixHandlersManager); + if (ENV.BOT_VERBOSE_LOGGING) loggerSend(`Try to load handler from: ${file}`, loggerPrefixHandlersManager); const handlerPath = pathToFileURL(path.join(handlersDir, file)).href; const handler = await import(handlerPath); await handler.default(client); - if (ENV.BOT_VERBOSE_LOGGING) - loggerSend(`Handler is loaded from: ${file}`, loggerPrefixHandlersManager); + if (ENV.BOT_VERBOSE_LOGGING) loggerSend(`Handler is loaded from: ${file}`, loggerPrefixHandlersManager); } loggerSend(`Loaded handlers: ${handlersFiles.length} total`, loggerPrefixHandlersManager); } catch (e) { loggerError(e); - loggerSend( - 'Bot is shutting down, because handler loading throw error', - loggerPrefixHandlersManager - ); + loggerSend('Bot is shutting down, because handler loading throw error', loggerPrefixHandlersManager); process.exit(); } } diff --git a/src/locales/en/audioplayer.json b/src/locales/en/audioplayer.json index 1a43bc9..205c3aa 100644 --- a/src/locales/en/audioplayer.json +++ b/src/locales/en/audioplayer.json @@ -1,6 +1,7 @@ { "audio_commands_wrapper_song_processing": "Song is processing, please wait", "audio_commands_wrapper_player_not_exist": "Player is not exists", + "song_added_to_favorite": "Song ``{{name}}`` added to favorite songs", "play_error": "With this song is something wrong", "download_song_error": "Something went wrong, when retrieving link to audio", "show_queue_title": "Now playing", diff --git a/src/locales/en/commands.json b/src/locales/en/commands.json index 04d826b..8877806 100644 --- a/src/locales/en/commands.json +++ b/src/locales/en/commands.json @@ -77,5 +77,38 @@ "247_disabled": "Mode 24/7 is disabled", "history_desc": "View the history of last played songs/playlists", "history_embed_no_songs": "No songs have been played on this server yet, be the first who play the song", - "history_embed_title": "Songs history for server" + "history_embed_title": "Songs history for server", + "pl_arg_name": "playlist name", + "pl_arg_song_id": "song id in playlist", + "pl_arg_song_url": "link to the song", + "pl_error_playlist_not_exists": "Playlist ``{{name}}`` is not exists", + "pl_error_song_is_not_exists_in_playlist": "Song with ID ``{{id}}`` is not exists in playlist ``{{name}}``", + "pl-create_desc": "Create a empty playlist", + "pl-create_success": "Playlist ``{{name}}`` created", + "pl-create_error_duplicate": "Playlist with name ``{{name}}`` already exists", + "pl-create_error_max_playlists_count": "You already created maximum playlists ({{count}}), delete previous playlists to create new", + "pl-create_error_validation": "Name must have length from {{min}} to {{max}}", + "pl-create_error": "Unknown error when creating playlists", + "pl-my_desc": "List of created playlists", + "pl-my_embed_title": "List of your playlists", + "pl-my_embed_error": "You dont have any playlists, use /pl-create to create your first playlist", + "pl-delete_desc": "Delete playlist by its name", + "pl-delete_embed_deleted": "Playlist {{name}} is deleted", + "pl-delete_error": "Unknown error when deleting playlist", + "pl-display_desc": "Display songs in playlist", + "pl-display_embed_no_songs": "This playlist not have any songs", + "pl-add_desc": "Adds song to a playlist", + "pl-add_error_song_must_be_link": "You must pass the LINK to the song", + "pl-add_error_song_must_be_support_in_bot_player": "You can add only songs that supports in audioplayer", + "pl-add_error_song_must_not_be_playlist": "Links to playlists of services cannot be used in bot playlists", + "pl-add_error_song_must_not_be_live_stream": "You cannot use links to live streams in bot playlists", + "pl-add_error_playlist_max_songs_limit": "The playlist ``{{name}}`` has reached the maximum number of songs ({{count}}), remove past songs using /pl-remove", + "pl-add_error_unknown": "Unknown error when adding a song to a playlist", + "pl-add_success": "The song ``{{song}}`` has been successfully added to the playlist ``{{playlist}}``", + "pl-remove_desc": "Removes a song from a playlist", + "pl-remove_success": "The song ``{{song}}`` has been successfully removed from the playlist ``{{playlist}}``", + "pl-remove_error_unknown": "Unknown error when deleting a song from a playlist", + "pl-play_desc": "Adds a custom playlist to the play queue", + "pl-play_error_unknown": "Unknown error while adding a playlist to the queue", + "pl-play_error_empty_playlist": "You cannot play empty playlists" } diff --git a/src/locales/ru/audioplayer.json b/src/locales/ru/audioplayer.json index 155a8a7..a0565f9 100644 --- a/src/locales/ru/audioplayer.json +++ b/src/locales/ru/audioplayer.json @@ -1,6 +1,7 @@ { "audio_commands_wrapper_song_processing": "Песни всё ещё обрабатываются, подожди", "audio_commands_wrapper_player_not_exist": "Плеера не существует", + "song_added_to_favorite": "Песня ``{{name}}`` добавлена в любимые", "play_error": "Попробуйте другую песню, с этой какая-то ерунда происходит.", "download_song_error": "Что-то пошло не так при получении ссылки на скачивание", "show_queue_title": "Сейчас играет", diff --git a/src/locales/ru/commands.json b/src/locales/ru/commands.json index c68836c..ecf401b 100644 --- a/src/locales/ru/commands.json +++ b/src/locales/ru/commands.json @@ -77,5 +77,38 @@ "247_disabled": "Режим 24/7 отключён", "history_desc": "Просмотр истории последних сыгранных песен и плейлистов", "history_embed_no_songs": "На этом сервере ещё не было отыграно ещё ни одной песни, станьте первым!", - "history_embed_title": "История песен для сервера" + "history_embed_title": "История песен для сервера", + "pl_arg_name": "имя плейлиста", + "pl_arg_song_id": "номер песни в плейлисте", + "pl_arg_song_url": "ссылка на песню", + "pl_error_playlist_not_exists": "Плейлист ``{{name}}`` не существует", + "pl_error_song_is_not_exists_in_playlist": "Песня с ID ``{{id}}`` не существует в плейлисте ``{{name}}``", + "pl-create_desc": "Создание пустого плейлиста", + "pl-create_success": "Плейлист ``{{name}}`` создан", + "pl-create_error_duplicate": "Плейлист с именем ``{{name}}`` уже существует", + "pl-create_error_max_playlists_count": "Вы уже создали максимальное количество плейлистов ({{count}}), удалите прошлые плейлисты чтобы создать новые", + "pl-create_error_validation": "Название должно иметь длину от {{min}} до {{max}}", + "pl-create_error": "Неизвестная ошибка при создании плейлиста", + "pl-my_desc": "Список созданных плейлистов", + "pl-my_embed_title": "Список ваших плейлистов", + "pl-my_embed_error": "У вас нет никаких плейлистов, используйте /pl-create чтобы создать ваш первый плейлист", + "pl-delete_desc": "Удаляет плейлист по его имени", + "pl-delete_embed_deleted": "Плейлист ``{{name}}`` удалён", + "pl-delete_error": "Неизвестная ошибка при удалении плейлиста", + "pl-display_desc": "Показывает содержимое плейлиста", + "pl-display_embed_no_songs": "В этом плейлисте ещё нет никаких песен", + "pl-add_desc": "Добавляет песню в плейлист", + "pl-add_error_song_must_be_link": "Вы должны передать ССЫЛКУ на песню", + "pl-add_error_song_must_not_be_playlist": "Нельзя ссылки на плейлисты сервисов использовать в плейлистах бота", + "pl-add_error_song_must_be_support_in_bot_player": "Вы можете добавлять только те песни, которые поддерживаются в аудиоплеере", + "pl-add_error_song_must_not_be_live_stream": "Нельзя использовать ссылки на прямые эфиры в плейлистах бота", + "pl-add_error_playlist_max_songs_limit": "В плейлисте ``{{name}}`` достигнуто максимальное количество песен ({{count}}), удалите прошлые песни с помощью /pl-remove", + "pl-add_error_unknown": "Неизвестная ошибка при добавлении песни в плейлист", + "pl-add_success": "Песня ``{{song}}`` успешно добавлена в плейлист ``{{playlist}}``", + "pl-remove_desc": "Удаляет песню из плейлиста", + "pl-remove_success": "Песня ``{{song}}`` успешно удалена из плейлиста ``{{playlist}}``", + "pl-remove_error_unknown": "Неизвестная ошибка во время удаление песни из плейлиста", + "pl-play_desc": "Добавляет пользовательский плейлист в очередь на воспроизведение", + "pl-play_error_unknown": "Неизвестная ошибка во время добавления плейлиста в очередь", + "pl-play_error_empty_playlist": "Вы не можете проигрывать пустые плейлисты" } diff --git a/src/schemas/SchemaGuild.ts b/src/schemas/SchemaGuild.ts index e128751..f1a946a 100644 --- a/src/schemas/SchemaGuild.ts +++ b/src/schemas/SchemaGuild.ts @@ -1,4 +1,4 @@ -import mongoose, { Document, model, Schema } from 'mongoose'; +import { Document, model, Schema } from 'mongoose'; import { ENV } from '../EnvironmentVariables.js'; import { deleteGuildSongsHistory, SongsHistoryListModelClass } from './SchemaSongsHistory.js'; @@ -20,7 +20,7 @@ const SchemaGuild = new Schema({ prefix: { type: String, default: ENV.BOT_COMMAND_PREFIX }, leaveOnEmpty: { type: Boolean, default: true } }, - songsHistory: { type: mongoose.Schema.Types.ObjectId, ref: 'songHistory', required: false } + songsHistory: { type: Schema.Types.ObjectId, ref: 'songHistory', required: false } }); const GuildModel = model('guild', SchemaGuild); diff --git a/src/schemas/SchemaPlaylist.ts b/src/schemas/SchemaPlaylist.ts new file mode 100644 index 0000000..14b7a1d --- /dev/null +++ b/src/schemas/SchemaPlaylist.ts @@ -0,0 +1,253 @@ +import { Document, model, Schema } from 'mongoose'; +import { Song } from 'distube'; +import { ENV } from '../EnvironmentVariables.js'; +import { getOrCreateUser } from './SchemaUser.js'; +import { ApplicationCommandOptionChoiceData, AutocompleteInteraction } from 'discord.js'; +import { getSongsNoun } from '../audioplayer/util/getSongsNoun.js'; + +interface ISchemaSongPlaylistUnit { + name: string; + url: string; + createdAt?: Date; +} + +const SchemaSongPlaylistUnit = new Schema( + { + name: { type: String, required: true }, + url: { type: String, required: true, unique: true, sparse: true } + }, + { + timestamps: { + createdAt: true + } + } +); + +export interface ISchemaPlaylist extends Document { + name: string; + songs: Array; + songsSize: number; + createdAt: Date; + updatedAt: Date; +} + +export const PlaylistNameMinLength = 1; +export const PlaylistNameMaxLength = 50; + +export const SchemaPlaylist = new Schema( + { + name: { type: String, required: true, maxlength: PlaylistNameMaxLength, minlength: PlaylistNameMinLength }, + songs: { type: [SchemaSongPlaylistUnit], default: [], select: false }, + songsSize: { type: Number, default: 0 } + }, + { + timestamps: { + createdAt: true, + updatedAt: true + } + } +); + +SchemaPlaylist.pre('save', function (next) { + this.songsSize = this.songs.length; + next(); +}); + +const PlaylistModel = model('playlist', SchemaPlaylist); + +export class PlaylistModelClass extends PlaylistModel {} // This workaround required for better TypeScript support + +export class PlaylistAlreadyExists extends Error { + constructor(playlistName: string) { + super(); + this.name = 'PlaylistAlreadyExistsError'; + this.message = `Playlist with name ${playlistName} already exists`; + } +} + +export class PlaylistIsNotExists extends Error { + constructor(playlistName: string) { + super(); + this.name = 'PlaylistIsNotExistsError'; + this.message = `Playlist with name ${playlistName} is not exists`; + } +} + +export class PlaylistMaxSongsLimit extends Error { + constructor(playlistName: string) { + super(); + this.name = 'PlaylistSongsLimitExistsError'; + this.message = `You cant add more songs to playlist ${playlistName}, max songs limit is ${ENV.BOT_MAX_SONGS_IN_USER_PLAYLIST}`; + } +} + +export class PlaylistMaxPlaylistsCount extends Error { + constructor() { + super(); + this.name = 'PlaylistMaxPlaylistsCountError'; + this.message = `You cant create more playlists, max playlists count for user is ${ENV.BOT_MAX_PLAYLISTS_PER_USER}`; + } +} + +export class PlaylistSongIsNotValid extends Error { + constructor() { + super(); + this.name = 'PlaylistSongIsNotValid'; + this.message = 'Cannot validate song url in any service'; + } +} + +export class PlaylistSongNotExists extends Error { + constructor(playlistName: string, songID: number) { + super(); + this.name = 'PlaylistSongNotExists'; + this.message = `Song with id ${songID} not exists in playlist ${playlistName}`; + } +} +/* +export class PlaylistSongAlreadyInPlaylist extends Error { + constructor(playlistName: string, songName: string) { + super(); + this.name = 'PlaylistSongAlreadyInPlaylist'; + this.message = `Song ${songName} already in playlist ${playlistName}`; + } +} +*/ + +export async function UserPlaylistCreate(userID: string, name: string, bypassPlaylistsLimit = false): Promise { + let playlist; + + try { + playlist = await UserPlaylistGet(userID, name); + } catch (e) { + if (!(e instanceof PlaylistIsNotExists)) { + throw e; + } + } + + if (playlist) throw new PlaylistAlreadyExists(name); + + const user = await getOrCreateUser(userID); + + if (!user.playlists) { + user.playlists = []; + } + + if (!bypassPlaylistsLimit) { + if (user.playlists.length >= ENV.BOT_MAX_PLAYLISTS_PER_USER) { + throw new PlaylistMaxPlaylistsCount(); + } + } + + const newPlaylist = new PlaylistModelClass({ + name: name, + songs: [] + }); + + await newPlaylist.save(); + + user.playlists.push(newPlaylist); + + await user.save(); +} + +export async function UserPlaylistGet( + userID: string, + name: string, + withSongs: boolean = false +): Promise { + const user = await getOrCreateUser(userID); + const userWithPlaylists = await user.populate({ + path: 'playlists', + select: withSongs ? ['name', 'songs', 'createdAt', 'updatedAt', 'songsSize'] : undefined + }); + + if (!userWithPlaylists.playlists) throw new PlaylistIsNotExists(name); + + const playlist = userWithPlaylists.playlists.find((playlist) => playlist.name === name); + if (!playlist) throw new PlaylistIsNotExists(name); + return playlist; +} + +export async function UserPlaylistGetPlaylists(userID: string): Promise | null> { + const user = await (await getOrCreateUser(userID)).populate('playlists'); + + if (!user.playlists) return null; + return user.playlists; +} + +export async function UserPlaylistDelete(userID: string, name: string): Promise { + const user = await (await getOrCreateUser(userID)).populate('playlists'); + const playlist = await UserPlaylistGet(userID, name); + + if (!playlist || !user.playlists) throw new PlaylistIsNotExists(name); + + await playlist.deleteOne({ name }); + + user.playlists = user.playlists.filter((playlist) => playlist.name !== name); + + await user.save(); +} + +export async function UserPlaylistAddSong(userID: string, name: string, song: Song): Promise { + const playlist = await UserPlaylistGet(userID, name, true); + if (!playlist) throw new PlaylistIsNotExists(name); + + if (playlist.songs.length > ENV.BOT_MAX_SONGS_IN_USER_PLAYLIST) { + throw new PlaylistMaxSongsLimit(name); + } + + playlist.songs.push({ name: song.name!, url: song.url! }); + + await playlist.save(); +} + +export async function UserPlaylistRemoveSong( + userID: string, + name: string, + index: number +): Promise { + const playlist = await UserPlaylistGet(userID, name, true); + if (!playlist) throw new PlaylistIsNotExists(name); + + const song = playlist.songs[index]; + if (!song) throw new PlaylistSongNotExists(name, index); + playlist.songs.splice(index, 1); + + await playlist.save(); + return song; +} + +export async function UserPlaylistNamesAutocomplete(interaction: AutocompleteInteraction) { + // const focusedValue = interaction.options.getFocused(true); + + const playlists = await UserPlaylistGetPlaylists(interaction.user.id); + let finalResult: Array = []; + + if (playlists) { + finalResult = playlists?.map((playlist) => { + return { + name: `${playlist.name} - ${playlist.songsSize} ${getSongsNoun(playlist.songsSize)}`, + value: playlist.name + }; + }); + } + + await interaction.respond(finalResult); +} + +export async function UserPlaylistAddFavoriteSong(userID: string, song: Song): Promise { + try { + await UserPlaylistAddSong(userID, 'favorite-songs', song); + } catch (e) { + if (e instanceof PlaylistIsNotExists) { + await UserPlaylistCreate(userID, 'favorite-songs', true); + + await UserPlaylistAddSong(userID, 'favorite-songs', song); + + return; + } + + throw e; + } +} diff --git a/src/schemas/SchemaSongsHistory.ts b/src/schemas/SchemaSongsHistory.ts index 79e2f24..f9398e8 100644 --- a/src/schemas/SchemaSongsHistory.ts +++ b/src/schemas/SchemaSongsHistory.ts @@ -1,4 +1,4 @@ -import { model, Schema } from 'mongoose'; +import { Document, model, Schema } from 'mongoose'; import { getOrCreateGuildSettings, GuildModelClass } from './SchemaGuild.js'; import { Playlist, Song } from 'distube'; import { ENV } from '../EnvironmentVariables.js'; @@ -23,11 +23,11 @@ const SchemaSongsHistoryUnit = new Schema( } ); -export interface ISchemaSongsHistory { +export interface ISchemaSongsHistory extends Document { songsHistory: Array; } -export const SchemaSongsHistoryList = new Schema( +export const SchemaSongsHistory = new Schema( { songsHistory: { type: [SchemaSongsHistoryUnit], default: [] } }, @@ -39,7 +39,7 @@ export const SchemaSongsHistoryList = new Schema( } ); -const SongsHistoryListModel = model('songHistory', SchemaSongsHistoryList); +const SongsHistoryListModel = model('songHistory', SchemaSongsHistory); export class SongsHistoryListModelClass extends SongsHistoryListModel {} // This workaround required for better TypeScript support @@ -59,14 +59,12 @@ export async function deleteGuildSongsHistory(guildID: string) { await SongsHistoryListModelClass.deleteOne({ _id: guild.songsHistory }); } -export async function addSongToGuildSongsHistory( - guildID: string, - resource: Song | Playlist -): Promise { +export async function addSongToGuildSongsHistory(guildID: string, resource: Song | Playlist): Promise { const history = await getOrCreateGuildSongsHistory(guildID); if (!history) return; + // Users' playlists cannot be added to history, because they don't have url if (resource.name && resource.member?.id && resource.url) { history.songsHistory.push({ name: resource.name ?? 'unknown', diff --git a/src/schemas/SchemaUser.ts b/src/schemas/SchemaUser.ts new file mode 100644 index 0000000..5bee8e5 --- /dev/null +++ b/src/schemas/SchemaUser.ts @@ -0,0 +1,26 @@ +import { Document, model, Schema } from 'mongoose'; +import { PlaylistModelClass } from './SchemaPlaylist.js'; + +interface ISchemaUser extends Document { + userID: string; + playlists?: Array; +} + +const SchemaUser = new Schema({ + userID: { type: String, required: true, unique: true }, + playlists: [{ type: Schema.Types.ObjectId, ref: 'playlist' }] +}); + +const UserModel = model('user', SchemaUser); + +export class UserModelClass extends UserModel {} + +export async function getOrCreateUser(userID: string): Promise { + const user = await UserModelClass.findOne({ userID }); + if (user) return user; + const newUser = new UserModelClass({ + userID + }); + await newUser.save(); + return newUser; +} diff --git a/src/utilities/checkMemberInVoiceWithBot.ts b/src/utilities/checkMemberInVoiceWithBot.ts index 5e99f10..ff3fa11 100644 --- a/src/utilities/checkMemberInVoiceWithBot.ts +++ b/src/utilities/checkMemberInVoiceWithBot.ts @@ -14,8 +14,7 @@ export async function checkMemberInVoiceWithBot( const connection = checkBotInVoice(member.guild); if (connection) { if (member.voice.channel) { - response.channelTheSame = - member.guild.members.me?.voice.channel?.id === member.voice?.channel.id; + response.channelTheSame = member.guild.members.me?.voice.channel?.id === member.voice?.channel.id; if (response.channelTheSame) { return response; } @@ -24,15 +23,13 @@ export async function checkMemberInVoiceWithBot( return response; } - await member.guild.client.channels - .fetch(member.guild.members.me?.voice.channel?.id) - .then((channel) => { - if (channel) { - if (channel instanceof VoiceChannel) { - response.errorMessage = `${i18next.t('commandsHandlers:voice_join_in_channel')} ${channel.name}`; - } + await member.guild.client.channels.fetch(member.guild.members.me?.voice.channel?.id).then((channel) => { + if (channel) { + if (channel instanceof VoiceChannel) { + response.errorMessage = `${i18next.t('commandsHandlers:voice_join_in_channel')} ${channel.name}`; } - }); + } + }); } } catch (e) { return response; diff --git a/src/utilities/checkPermissions.ts b/src/utilities/checkPermissions.ts index 38587a7..1e2e5d4 100644 --- a/src/utilities/checkPermissions.ts +++ b/src/utilities/checkPermissions.ts @@ -1,9 +1,6 @@ import { GuildMember, PermissionResolvable, PermissionsBitField, TextChannel } from 'discord.js'; -export function CheckBotPermissions( - channel: TextChannel, - permissionsRequired: Array -): boolean { +export function CheckBotPermissions(channel: TextChannel, permissionsRequired: Array): boolean { const bot = channel.guild.members.me; if (!bot) return false; diff --git a/src/utilities/commandEmptyReply.ts b/src/utilities/commandEmptyReply.ts new file mode 100644 index 0000000..979b3d6 --- /dev/null +++ b/src/utilities/commandEmptyReply.ts @@ -0,0 +1,12 @@ +import { ChatInputCommandInteraction } from 'discord.js'; +import i18next from 'i18next'; +import { ReplyContext } from '../CommandTypes.js'; + +// Every chat interaction must be replied, but I don't want to reply, +// So the code below is a workaround +export async function commandEmptyReply(ctx: ReplyContext) { + if (ctx instanceof ChatInputCommandInteraction) { + await ctx.reply(i18next.t('general:thinking')); + await ctx.deleteReply(); + } +} diff --git a/src/utilities/generateErrorEmbed.ts b/src/utilities/generateErrorEmbed.ts index 38da149..538ef70 100644 --- a/src/utilities/generateErrorEmbed.ts +++ b/src/utilities/generateErrorEmbed.ts @@ -1,10 +1,7 @@ import { EmbedBuilder } from 'discord.js'; import i18next from 'i18next'; -export function generateErrorEmbed( - errorMessage: string, - errorName = i18next.t('general:error') -): EmbedBuilder { +export function generateErrorEmbed(errorMessage: string, errorName = i18next.t('general:error')): EmbedBuilder { return new EmbedBuilder() .setTitle(`<:error:1257892426790731786> ${errorName}`) .setColor('Red') diff --git a/src/utilities/generateNewGuildEmbed.ts b/src/utilities/generateNewGuildEmbed.ts index 93dd695..1eb01f6 100644 --- a/src/utilities/generateNewGuildEmbed.ts +++ b/src/utilities/generateNewGuildEmbed.ts @@ -18,7 +18,5 @@ export function generateNewGuildEmbed(): EmbedBuilder { i18next.t('welcomeMessage:row_4') ) .setColor(Colors.Yellow) - .setImage( - 'https://github.com/AlexInCube/AlCoTest/blob/master/icons/repository-social.png?raw=true' - ); + .setImage('https://github.com/AlexInCube/AlCoTest/blob/master/icons/repository-social.png?raw=true'); } diff --git a/src/utilities/logger.ts b/src/utilities/logger.ts index d186e05..1e71fc3 100644 --- a/src/utilities/logger.ts +++ b/src/utilities/logger.ts @@ -10,25 +10,14 @@ export function getCurrentTimestamp(): string { return `${dd + '/' + mm + '/' + yyyy + ' | ' + hour + ':' + minute + ':' + seconds}`; } -export function loggerSend( - message: unknown, - prefix?: string, - isError?: boolean, - isWarn?: boolean -): void { +export function loggerSend(message: unknown, prefix?: string, isError?: boolean, isWarn?: boolean): void { if (message instanceof Error || isError) { - console.error( - `[ ${getCurrentTimestamp()} ] [ ${prefix ? `${prefix} | ERROR` : 'ERROR'} ]`, - message - ); + console.error(`[ ${getCurrentTimestamp()} ] [ ${prefix ? `${prefix} | ERROR` : 'ERROR'} ]`, message); return; } if (isWarn) { - console.warn( - `[ ${getCurrentTimestamp()} ] [ ${prefix ? `${prefix} | WARN` : 'WARN'} ]`, - message - ); + console.warn(`[ ${getCurrentTimestamp()} ] [ ${prefix ? `${prefix} | WARN` : 'WARN'} ]`, message); return; } diff --git a/src/utilities/pagination/pagination.ts b/src/utilities/pagination/pagination.ts index f698a4f..f0068ef 100644 --- a/src/utilities/pagination/pagination.ts +++ b/src/utilities/pagination/pagination.ts @@ -50,8 +50,7 @@ export const pagination = async (options: PaginationOptions) => { const disableB = disableButtons || false; const ephemeralMessage = ephemeral !== null ? ephemeral : false; - if (!interaction && !message) - throw new Error('Pagination requires either an interaction or a message object'); + if (!interaction && !message) throw new Error('Pagination requires either an interaction or a message object'); const type = interaction ? 'interaction' : 'message'; const getButtonData = (type: ButtonsTypes) => { @@ -88,9 +87,7 @@ export const pagination = async (options: PaginationOptions) => { }, []); }; - const components = (state?: boolean) => [ - new ActionRowBuilder().addComponents(generateButtons(state)) - ]; + const components = (state?: boolean) => [new ActionRowBuilder().addComponents(generateButtons(state))]; const changeFooter = () => { const embed = embeds[currentPage - 1]; @@ -143,9 +140,7 @@ export const pagination = async (options: PaginationOptions) => { if (pageTravel) { collectorModal = initialMessage.createMessageComponentCollector( - collectorOptions( - (_i: ModalSubmitInteraction) => _i.user.id === author.id && parseInt(_i.customId) === 5 - ) + collectorOptions((_i: ModalSubmitInteraction) => _i.user.id === author.id && parseInt(_i.customId) === 5) ); collectorModal.on('collect', async (btnInteraction) => { // Show modal @@ -156,16 +151,13 @@ export const pagination = async (options: PaginationOptions) => { .setLabel('Enter Page Number') .setStyle(TextInputStyle.Short); - const buildModal = new ActionRowBuilder().addComponents( - inputPageNumber - ); + const buildModal = new ActionRowBuilder().addComponents(inputPageNumber); modal.addComponents(buildModal); await btnInteraction.showModal(modal); await btnInteraction .awaitModalSubmit({ - filter: (_i: ButtonInteraction) => - _i.user.id === author.id && _i.customId === 'choose_page_modal', + filter: (_i: ButtonInteraction) => _i.user.id === author.id && _i.customId === 'choose_page_modal', time: 30000 }) .then(async (i) => { diff --git a/wiki/Commands.md b/wiki/Commands.md index 4e0891e..108ac3d 100644 --- a/wiki/Commands.md +++ b/wiki/Commands.md @@ -6,6 +6,10 @@ Bot supports slash and text command systems. Nothing special, start writing / and select command from the list +> [!NOTE] +> Recommend using slash commands because most of these commands have an "invisible/ephemeral" messages, +> so no one can see except you the output of command + ## Text commands If a bot owner does not change the default prefix in .env.production file, the prefix is // @@ -122,6 +126,46 @@ And when all users leave a voice channel with bot Return song history of the current server where the command is executed. +### pl-create + +Example: /pl-create Funny Music + +Create a playlist linked to user with given name + +### pl-play + +Example: /pl-play Funny Music + +Do the same things as a /play command, but add songs from playlist + +### pl-add + +Example: /pl-add Funny Music https://www.youtube.com/watch?v=dQw4w9WgXcQ + +Add a song to playlist + +### pl-display + +Example: /pl-display Funny Music + +Display the list of songs of given playlist + +### pl-remove + +Example: /pl-remove Funny Music 1 + +Remove the song from playlist by ID, to get song ID in playlist, use the /pl-display + +### pl-my + +Display the list of playlists + +### pl-delete + +Example: /pl-delete Funny Music + +Delete the playlist + ### audiodebug Give the current count of spawned audioplayers diff --git a/wiki/images/commands/play-audioplayer.png b/wiki/images/commands/play-audioplayer.png index ad93649..69226c6 100644 Binary files a/wiki/images/commands/play-audioplayer.png and b/wiki/images/commands/play-audioplayer.png differ