diff --git a/.env b/.env index 99d56c0..64f6d58 100644 --- a/.env +++ b/.env @@ -4,6 +4,8 @@ BOT_FFMPEG_LOGGING=false BOT_COMMAND_PREFIX=// BOT_LANGUAGE=en +BOT_MAX_SONGS_IN_QUEUE=500 + BOT_DISCORD_TOKEN=undefined BOT_DISCORD_CLIENT_ID=undefined BOT_DISCORD_OVERPOWERED_ID=undefined diff --git a/.github/workflows/publish-docker-image.yaml b/.github/workflows/publish-docker-image.yaml index d22d8bc..326f756 100644 --- a/.github/workflows/publish-docker-image.yaml +++ b/.github/workflows/publish-docker-image.yaml @@ -1,20 +1,17 @@ name: Publish Docker image on: - push: + workflow_run: + workflows: [ "tests", "linters" ] + types: ['completed'] branches: ['master'] - paths-ignore: - - '**/wiki/**' - - '/wiki/**' - - 'docker-compose.yml' - - '.env' - - '*.sh' env: IMAGE_TAG: alexincube/aicotest jobs: push_to_registry: + if: github.event.workflow_run.event == 'push' name: Push Docker image to Docker Hub runs-on: ubuntu-latest steps: diff --git a/.github/workflows/quality.yaml b/.github/workflows/quality.yaml new file mode 100644 index 0000000..9d7f041 --- /dev/null +++ b/.github/workflows/quality.yaml @@ -0,0 +1,36 @@ +name: Code Quality + +on: + push: + branches: ['**'] + pull_request: + branches: ['**'] + +jobs: + linters: + name: Run linters + runs-on: ubuntu-latest + + steps: + - name: Check out Git repository + uses: actions/checkout@v4 + + - name: Setup PNPM + uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22.x + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run Prettier + run: pnpm run prettier:check + + - name: Run ESLint + run: pnpm run eslint diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml new file mode 100644 index 0000000..475f557 --- /dev/null +++ b/.github/workflows/tests.yaml @@ -0,0 +1,55 @@ +name: Tests + +on: + push: + branches: ['**'] + paths-ignore: + - '**/wiki/**' + - '/wiki/**' + - 'docker-compose.yml' + - '.env' + - '*.sh' + pull_request: + branches: ['**'] + paths-ignore: + - '**/wiki/**' + - '/wiki/**' + - 'docker-compose.yml' + - '.env' + - '*.sh' + +jobs: + tests: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PNPM + uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22.x + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Make env file + run: | + pwd + touch .env.development + echo '${{ secrets.ENV_DEVELOPMENT }}' > .env.development + + - name: Make yt-cookies.json + run: | + touch yt-cookies.json + echo '${{ secrets.YOUTUBE_COOKIES }}' > yt-cookies.json + + - name: Run tests + run: pnpm run test diff --git a/eslint.config.js b/eslint.config.js index 9364915..de11cd7 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -9,7 +9,8 @@ export default tseslint.config(eslint.configs.recommended, ...tseslint.configs.r plugins: { 'typescript-eslint': ParserTypeScript, prettier: prettierPlugin - }, ignores: ['build', 'node_modules', 'coverage', 'eslint.config.js'], + }, + ignores: ['build', 'node_modules', 'coverage', 'eslint.config.js'], languageOptions: { globals: { ...globals.node, @@ -20,12 +21,12 @@ export default tseslint.config(eslint.configs.recommended, ...tseslint.configs.r } }, rules: { - ...prettierPlugin.configs.recommended.rules, - ...eslintConfigPrettier.rules, + ...prettierPlugin.configs.recommended.rules, + ...eslintConfigPrettier.rules, '@typescript-eslint/no-var-requires': 0, '@typescript-eslint/no-non-null-assertion': 0, - '@typescript-eslint/no-explicit-any': "warn", + '@typescript-eslint/no-explicit-any': 'warn', 'prettier/prettier': ['error', { endOfLine: 'auto' }], - 'no-constant-binary-expression': "off" + 'no-constant-binary-expression': 'off' } }); diff --git a/package.json b/package.json index f25166b..f7d9edf 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,17 @@ { "name": "aicbot", - "version": "3.3.1", + "version": "3.4.0", "description": "Discord Bot for playing music", "main": "build/main.js", "scripts": { "build": "tsc", "development": "tsc&& cross-env NODE_ENV=development node build/main.js", "production": "cross-env NODE_ENV=production node build/main.js", - "cookies_grabbing": "cross-env NODE_ENV=development node build/Script_getCookie.js" + "cookies_grabbing": "cross-env NODE_ENV=development node build/Script_getCookie.js", + "test": "tsc&& cross-env NODE_ENV=development node --test", + "eslint": "eslint \"src/**/*.{ts,js}\"", + "prettier:format": "prettier --write \"**/*.{ts,js}\"", + "prettier:check": "prettier --check \"**/*.{ts,js}\"" }, "type": "module", "keywords": [], diff --git a/src/ClientIntents.ts b/src/ClientIntents.ts new file mode 100644 index 0000000..2507409 --- /dev/null +++ b/src/ClientIntents.ts @@ -0,0 +1,13 @@ +import { GatewayIntentBits } from 'discord.js'; + +export const clientIntents: Array = [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildPresences, + GatewayIntentBits.GuildMembers, + GatewayIntentBits.GuildVoiceStates, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.MessageContent, + GatewayIntentBits.DirectMessages, + GatewayIntentBits.DirectMessageTyping, + GatewayIntentBits.GuildModeration +]; diff --git a/src/CookiesAutomation.ts b/src/CookiesAutomation.ts index a1a2ded..c4b3614 100644 --- a/src/CookiesAutomation.ts +++ b/src/CookiesAutomation.ts @@ -21,7 +21,6 @@ export async function getYoutubeCookie() { const browser = await puppeteer.launch({ headless: true, - slowMo: 2000, args: ['--remote-debugging-port=9222', '--remote-debugging-address=0.0.0.0', '--no-sandbox'] }); const page = await browser.newPage(); diff --git a/src/EnvironmentVariables.ts b/src/EnvironmentVariables.ts index 34348ed..95dbf4a 100644 --- a/src/EnvironmentVariables.ts +++ b/src/EnvironmentVariables.ts @@ -1,13 +1,26 @@ import { z } from 'zod'; import * as dotenv from 'dotenv'; import { loggerSend } from './utilities/logger.js'; +import path from 'path'; +import fs from 'fs'; const loggerPrefixEnv = 'ENV'; -dotenv.config({ path: `.env.${process.env.NODE_ENV}` }); +const envPath = path.resolve(process.cwd(), `.env.${process.env.NODE_ENV}`); + +loggerSend(`Checking environment variables in ${envPath}`, loggerPrefixEnv); +if (fs.existsSync(envPath)) { + dotenv.config({ path: envPath }); + loggerSend(`Environment variables is found in ${envPath}`, loggerPrefixEnv); +} else { + loggerSend( + `Environment variables are not found in ${envPath}, trying to load variables from OS environment variables`, + loggerPrefixEnv + ); +} const envVariables = z.object({ - NODE_ENV: z.enum(['development', 'production']), + NODE_ENV: z.enum(['development', 'production']).default('development'), BOT_VERBOSE_LOGGING: z .preprocess( @@ -37,6 +50,8 @@ const envVariables = z.object({ BOT_LANGUAGE: z.enum(['en', 'ru']).optional().default('en'), BOT_COMMAND_PREFIX: z.string().min(1), + BOT_MAX_SONGS_IN_QUEUE: z.coerce.number().positive().min(1).optional().default(500), + MONGO_URI: z.string(), MONGO_DATABASE_NAME: z.string(), @@ -61,4 +76,8 @@ const envVariables = z.object({ export const ENV = envVariables.parse(process.env); -loggerSend(`Loaded .env.${process.env.NODE_ENV}`, loggerPrefixEnv); +if (fs.existsSync(envPath)) { + loggerSend(`Environment variables is loaded from ${envPath}`, loggerPrefixEnv); +} else { + loggerSend(`Environment variables is loaded from OS environment variables`, loggerPrefixEnv); +} diff --git a/src/audioplayer/AudioPlayerCore.ts b/src/audioplayer/AudioPlayerCore.ts index 98704d0..e5c5a8c 100644 --- a/src/audioplayer/AudioPlayerCore.ts +++ b/src/audioplayer/AudioPlayerCore.ts @@ -1,4 +1,12 @@ -import { DisTube, PlayOptions, Queue, RepeatMode, Song, Events as DistubeEvents, Playlist } from 'distube'; +import { + DisTube, + PlayOptions, + Queue, + RepeatMode, + Song, + Events as DistubeEvents, + Playlist +} from 'distube'; import { AudioPlayersManager } from './AudioPlayersManager.js'; import { pagination } from '../utilities/pagination/pagination.js'; import { ButtonStyles, ButtonTypes } from '../utilities/pagination/paginationTypes.js'; @@ -25,8 +33,6 @@ import { joinVoiceChannel } from '@discordjs/voice'; import { generateWarningEmbed } from '../utilities/generateWarningEmbed.js'; import { generateLyricsEmbed } from './Lyrics.js'; -export const queueSongsLimit = 500; - export const loggerPrefixAudioplayer = `Audioplayer`; const plugins = await LoadPlugins(); @@ -58,7 +64,7 @@ export class AudioPlayerCore { options?: PlayOptions ) { try { - const playableThing: Song | Playlist = await this.distube.handler.resolve(song) + const playableThing: Song | Playlist = await this.distube.handler.resolve(song); // 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 @@ -72,13 +78,15 @@ export class AudioPlayerCore { } 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(`${song}\n${e.message}`, i18next.t('audioplayer:play_error') as string) + ] }); - const queue = this.distube.getQueue(voiceChannel.guildId) + const queue = this.distube.getQueue(voiceChannel.guildId); - if (!queue) return - if (queue.songs.length === 0) await this.stop(voiceChannel.guild) + if (!queue) return; + if (queue.songs.length === 0) await this.stop(voiceChannel.guild); } } @@ -362,17 +370,17 @@ export class AudioPlayerCore { if (!queue.textChannel) return; await queue.textChannel.send({ embeds: [generateAddedPlaylistMessage(playlist)] }); - if (queue.songs.length >= queueSongsLimit) { + if (queue.songs.length >= ENV.BOT_MAX_SONGS_IN_QUEUE) { await queue.textChannel.send({ embeds: [ generateWarningEmbed( i18next.t('audioplayer:event_add_list_limit', { - queueLimit: queueSongsLimit + queueLimit: ENV.BOT_MAX_SONGS_IN_QUEUE }) as string ) ] }); - queue.songs.length = queueSongsLimit; + queue.songs.length = ENV.BOT_MAX_SONGS_IN_QUEUE; } const player = this.playersManager.get(queue.id); diff --git a/src/audioplayer/tests/AudioServices.test.ts b/src/audioplayer/tests/AudioServices.test.ts new file mode 100644 index 0000000..13b4050 --- /dev/null +++ b/src/audioplayer/tests/AudioServices.test.ts @@ -0,0 +1,133 @@ +import * as assert from 'node:assert'; +import { describe, it, before, after } from 'node:test'; +import { DisTube } from 'distube'; +import { Client } from 'discord.js'; +import { LoadPlugins } from '../LoadPlugins.js'; +import '../../EnvironmentVariables.js'; +import { loggerWarn } from '../../utilities/logger.js'; +import * as process from 'node:process'; +import { clientIntents } from '../../ClientIntents.js'; + +let distube: DisTube; +const djsClient: Client = new Client({ intents: clientIntents }); + +before(async () => { + loggerWarn('If you want to run all this tests successfully, provide all optional .env variables'); + + distube = new DisTube(djsClient, { + nsfw: true, + plugins: await LoadPlugins() + }); +}); + +describe('Audio Services', () => { + describe('Youtube', () => { + it('Video', async () => { + const song = await distube.handler.resolve('https://www.youtube.com/watch?v=atgjKEgSqSU'); + + assert.ok(song); + }); + + it('Video 18+', async () => { + const song = await distube.handler.resolve('https://www.youtube.com/watch?v=T1UbWo70Uto'); + + assert.ok(song); + }); + + it('Playlist', async () => { + const playlist = await distube.handler.resolve( + 'https://www.youtube.com/watch?v=qq-RGFyaq0U&list=PLefKpFQ8Pvy5aCLAGHD8Zmzsdljos-t2l' + ); + + assert.ok(playlist); + }); + }); + + describe(`Spotify`, () => { + it('Song', async () => { + const song = await distube.handler.resolve( + 'https://open.spotify.com/track/2vBIOyCqBAoZ4Fxc4JOKL3?si=7088fe2f13c840cd' + ); + + assert.ok(song); + }); + + it('Playlist', async () => { + const playlist = await distube.handler.resolve( + 'https://open.spotify.com/playlist/4Ip3oQJlyl9Zvzp33h1GSe?si=ce13625f480e44ff' + ); + + assert.ok(playlist); + }); + }); + + describe(`SoundCloud`, () => { + it('Song', async () => { + 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' + ); + + assert.ok(playlist); + }); + }); + + describe(`Yandex Music`, () => { + it('Song', async () => { + 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' + ); + + assert.ok(song); + }); + + it('Album', async () => { + const song = await distube.handler.resolve('https://music.yandex.ru/album/5307396'); + + assert.ok(song); + }); + }); + + describe('Apple Music', () => { + it('Song', async () => { + const song = await distube.handler.resolve( + 'https://music.apple.com/us/album/v/1544457960?i=1544457962' + ); + + assert.ok(song); + }); + + it( + 'Playlist', + { skip: 'Playlists in Apple Music is not correct implemented right now, so skip this test' }, + async () => { + const playlist = await distube.handler.resolve( + 'https://music.apple.com/us/album/cyberpunk-2077-original-score/1544457960' + ); + + assert.ok(playlist); + } + ); + }); +}); + +after(() => { + setTimeout(() => { + process.exit(0); + }, 1000); +}); diff --git a/src/commands/audio/play.command.ts b/src/commands/audio/play.command.ts index d202c8b..ceedbe4 100644 --- a/src/commands/audio/play.command.ts +++ b/src/commands/audio/play.command.ts @@ -17,8 +17,8 @@ import { truncateString } from '../../utilities/truncateString.js'; import i18next from 'i18next'; import { SearchResultType } from '@distube/youtube'; import ytsr from '@distube/ytsr'; -import { queueSongsLimit } from '../../audioplayer/AudioPlayerCore.js'; import { generateWarningEmbed } from '../../utilities/generateWarningEmbed.js'; +import { ENV } from '../../EnvironmentVariables.js'; export const services = 'Youtube, Spotify, Soundcloud, Yandex Music, Apple Music, HTTP-stream'; export default function (): ICommand { @@ -42,7 +42,7 @@ export default function (): ICommand { embeds: [ generateWarningEmbed( i18next.t('commands:play_error_songs_limit', { - queueLimit: queueSongsLimit + queueLimit: ENV.BOT_MAX_SONGS_IN_QUEUE }) as string ) ] @@ -83,7 +83,7 @@ export default function (): ICommand { embeds: [ generateWarningEmbed( i18next.t('commands:play_error_songs_limit', { - queueLimit: queueSongsLimit + queueLimit: ENV.BOT_MAX_SONGS_IN_QUEUE }) as string ) ], @@ -162,5 +162,5 @@ function queueSongsIsFull(client: Client, guild: Guild): boolean { if (!queue) return false; - return queue.songs.length >= queueSongsLimit; + return queue.songs.length >= ENV.BOT_MAX_SONGS_IN_QUEUE; } diff --git a/src/main.ts b/src/main.ts index 85db499..0632094 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,8 @@ +import { clientIntents } from './ClientIntents.js'; + loggerSend(`Starting bot on version ${process.env.npm_package_version}`); -import { Client, GatewayIntentBits, Partials } from 'discord.js'; +import { Client, Partials } from 'discord.js'; import { loggerError, loggerSend } from './utilities/logger.js'; import { loginBot } from './utilities/loginBot.js'; import { AudioPlayerCore } from './audioplayer/AudioPlayerCore.js'; @@ -11,25 +13,18 @@ await loadLocale(); import { handlersLoad } from './handlers/handlersLoad.js'; const client = new Client({ - intents: [ - GatewayIntentBits.Guilds, - GatewayIntentBits.GuildPresences, - GatewayIntentBits.GuildMembers, - GatewayIntentBits.GuildVoiceStates, - GatewayIntentBits.GuildMessages, - GatewayIntentBits.MessageContent, - GatewayIntentBits.DirectMessages, - GatewayIntentBits.DirectMessageTyping, - GatewayIntentBits.GuildModeration - ], + intents: clientIntents, partials: [Partials.Message, Partials.Channel, Partials.Reaction] }); client.rest.on('rateLimited', (args) => { loggerError(`Client encountered a rate limit: ${JSON.stringify(args)}`); }); + new AudioPlayerCore(client); + await handlersLoad(client); + loginBot(client); process.on('uncaughtException', (err) => { diff --git a/wiki/Setup.md b/wiki/Setup.md index dd12bb5..45f07a8 100644 --- a/wiki/Setup.md +++ b/wiki/Setup.md @@ -6,7 +6,7 @@ But in both cases, you need to configure .env file. Also you need retrieve token, client id and enable intents on Discord Developer Portal. - Create file .env.production (if you developer create .env.development) -- Fill all fields in .env.* If the field is marked as (Optional), you can skip it. +- Fill all fields in .env.\* If the field is marked as (Optional), you can skip it. - (Required) To get Discord Token and enable intents, follow the [Discord Developer Portal](https://github.com/AlexInCube/AlCoTest/wiki/API-Configure#discord-developer-portal-required) section. - (Optional) To get YouTube cookies and bypass different errors with YouTube, follow the [YouTube](https://github.com/AlexInCube/AlCoTest/wiki/API-Configure#-youtube-cookie-optional) section - (Optional) To get Spotify Secret and ID, follow the [Spotify](https://github.com/AlexInCube/AlCoTest/wiki/API-Configure#spotify-optional) section. @@ -19,6 +19,7 @@ Also you need retrieve token, client id and enable intents on Discord Developer | `BOT_VERBOSE_LOGGING` | false | The bot will give more info to the console, useful for debugging | ❌ | | `BOT_FFMPEG_LOGGING=false` | false | The bot will give info about FFMPEGto the console, useful for debugging | | | `BOT_COMMAND_PREFIX` | // | Used only for text commands | ✔️ | +| `BOT_MAX_SONGS_IN_QUEUE` | 500 | Define max songs count per queue | ❌ | | `BOT_LANGUAGE` | en | Supported values: en ru | ❌ | | `MONGO_URI` | mongodb://mongo:27017 | The public key for sending notifications | ✔️ | | `MONGO_DATABASE_NAME` | aicbot | Database name in MongoDB | ✔️ | @@ -55,7 +56,7 @@ AICoTest/ > [!NOTE] > If you use terminal, Linux or Git Bash etc..., > you can copy runInDocker.sh or updateAndRunInDocker.sh to folder with other files. -> And run command ```sh updateAndRunInDocker.sh``` to update bot image and restart containers. +> And run command `sh updateAndRunInDocker.sh` to update bot image and restart containers. # 🖥️ Run locally (if you are not a developer, this way is no sense)