From 2d302551f9edd2c69cb0c252171dc558e0eeac24 Mon Sep 17 00:00:00 2001 From: F04C Date: Sat, 26 Oct 2024 20:33:18 +0800 Subject: [PATCH] Apollo Initial Files --- .dockerignore | 16 + .github/ISSUE_TEMPLATE/---bug-report.md | 25 + .github/ISSUE_TEMPLATE/---feature-request.md | 20 + .github/ISSUE_TEMPLATE/config.yml | 7 + .github/dependabot.yml | 30 + .github/stale.yml | 14 + .github/workflows/docker.yml | 60 + .github/workflows/node.yml | 26 + .gitignore | 66 + .prettierrc | 7 + .replit | 2 + Dockerfile | 32 + README.md | 137 + app.json | 7 + commands/help.ts | 27 + commands/invite.ts | 30 + commands/loop.ts | 25 + commands/lyrics.ts | 34 + commands/move.ts | 46 + commands/nowplaying.ts | 45 + commands/pause.ts | 25 + commands/ping.ts | 12 + commands/play.ts | 98 + commands/playlist.ts | 102 + commands/queue.ts | 125 + commands/remove.ts | 57 + commands/resume.ts | 32 + commands/search.ts | 86 + commands/shuffle.ts | 31 + commands/skip.ts | 21 + commands/skipto.ts | 51 + commands/stop.ts | 20 + commands/uptime.ts | 21 + commands/volume.ts | 38 + config.json.example | 8 + index.ts | 15 + interfaces/Command.ts | 8 + interfaces/Config.ts | 8 + interfaces/QueueOptions.ts | 8 + locales/ar.json | 167 + locales/bg.json | 181 + locales/cs.json | 167 + locales/de.json | 167 + locales/el.json | 167 + locales/en.json | 180 + locales/es.json | 167 + locales/fa.json | 167 + locales/fr.json | 180 + locales/id.json | 167 + locales/it.json | 167 + locales/ja.json | 167 + locales/ko.json | 167 + locales/mi.json | 167 + locales/nb.json | 179 + locales/nl.json | 167 + locales/pl.json | 167 + locales/pt_br.json | 167 + locales/ro.json | 165 + locales/ru.json | 180 + locales/sv.json | 167 + locales/th.json | 167 + locales/tr.json | 167 + locales/uk.json | 180 + locales/vi.json | 180 + locales/zh_cn.json | 167 + locales/zh_sg.json | 167 + locales/zh_tw.json | 167 + nodemon.json | 5 + package-lock.json | 3649 ++++++++++++++++++ package.json | 57 + structs/Bot.ts | 114 + structs/MusicQueue.ts | 356 ++ structs/Playlist.ts | 40 + structs/Song.ts | 80 + tsconfig.json | 21 + utils/MissingPermissionsException.ts | 9 + utils/checkPermissions.ts | 21 + utils/config.ts | 19 + utils/i18n.ts | 62 + utils/patterns.ts | 6 + utils/queue.ts | 4 + utils/safeReply.ts | 13 + 82 files changed, 10643 insertions(+) create mode 100644 .dockerignore create mode 100644 .github/ISSUE_TEMPLATE/---bug-report.md create mode 100644 .github/ISSUE_TEMPLATE/---feature-request.md create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/dependabot.yml create mode 100644 .github/stale.yml create mode 100644 .github/workflows/docker.yml create mode 100644 .github/workflows/node.yml create mode 100644 .gitignore create mode 100644 .prettierrc create mode 100644 .replit create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app.json create mode 100644 commands/help.ts create mode 100644 commands/invite.ts create mode 100644 commands/loop.ts create mode 100644 commands/lyrics.ts create mode 100644 commands/move.ts create mode 100644 commands/nowplaying.ts create mode 100644 commands/pause.ts create mode 100644 commands/ping.ts create mode 100644 commands/play.ts create mode 100644 commands/playlist.ts create mode 100644 commands/queue.ts create mode 100644 commands/remove.ts create mode 100644 commands/resume.ts create mode 100644 commands/search.ts create mode 100644 commands/shuffle.ts create mode 100644 commands/skip.ts create mode 100644 commands/skipto.ts create mode 100644 commands/stop.ts create mode 100644 commands/uptime.ts create mode 100644 commands/volume.ts create mode 100644 config.json.example create mode 100644 index.ts create mode 100644 interfaces/Command.ts create mode 100644 interfaces/Config.ts create mode 100644 interfaces/QueueOptions.ts create mode 100644 locales/ar.json create mode 100644 locales/bg.json create mode 100644 locales/cs.json create mode 100644 locales/de.json create mode 100644 locales/el.json create mode 100644 locales/en.json create mode 100644 locales/es.json create mode 100644 locales/fa.json create mode 100644 locales/fr.json create mode 100644 locales/id.json create mode 100644 locales/it.json create mode 100644 locales/ja.json create mode 100644 locales/ko.json create mode 100644 locales/mi.json create mode 100644 locales/nb.json create mode 100644 locales/nl.json create mode 100644 locales/pl.json create mode 100644 locales/pt_br.json create mode 100644 locales/ro.json create mode 100644 locales/ru.json create mode 100644 locales/sv.json create mode 100644 locales/th.json create mode 100644 locales/tr.json create mode 100644 locales/uk.json create mode 100644 locales/vi.json create mode 100644 locales/zh_cn.json create mode 100644 locales/zh_sg.json create mode 100644 locales/zh_tw.json create mode 100644 nodemon.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 structs/Bot.ts create mode 100644 structs/MusicQueue.ts create mode 100644 structs/Playlist.ts create mode 100644 structs/Song.ts create mode 100644 tsconfig.json create mode 100644 utils/MissingPermissionsException.ts create mode 100644 utils/checkPermissions.ts create mode 100644 utils/config.ts create mode 100644 utils/i18n.ts create mode 100644 utils/patterns.ts create mode 100644 utils/queue.ts create mode 100644 utils/safeReply.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d751884 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +.vscode +.github +node_modules +npm-debug.log +.env +.git +.gitignore +.node-version +.prettierrc +.replit +config.json +CODE_OF_CONDUCT.md +CONTRIBUTING.md +Procfile +Dockerfile +.dockerignore diff --git a/.github/ISSUE_TEMPLATE/---bug-report.md b/.github/ISSUE_TEMPLATE/---bug-report.md new file mode 100644 index 0000000..db9661a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/---bug-report.md @@ -0,0 +1,25 @@ +--- +name: "\U0001F41B Bug Report" +about: Report a suspected bug or problem +title: '🐛 ' +labels: 'bug: unconfirmed' +assignees: '' + +--- + +**Describe the bug** +A description of what the bug is. + +**How To Reproduce** +Steps to reproduce the behavior: +1. +2. + +**Expected behavior** +A description of what you expected to happen. + +**Environment (add if possible)** +* Node.js version: + +**Additional information & screenshots** +Add any other context or screenshots about the problem here. diff --git a/.github/ISSUE_TEMPLATE/---feature-request.md b/.github/ISSUE_TEMPLATE/---feature-request.md new file mode 100644 index 0000000..c32588c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/---feature-request.md @@ -0,0 +1,20 @@ +--- +name: "\U0001F680 Feature Request" +about: Suggest an idea for this project +title: '🚀 ' +labels: enhancement +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A description of what you want to happen. + +**Describe alternatives you've considered** +A description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..b5ae78f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,7 @@ +contact_links: + - name: ❔ Questions / Help + url: https://github.com/eritislami/evobot/discussions + about: Please use GitHub Discussions for help and questions, do not start an issue. + - name: 📃 Discord.js Guide + url: https://discordjs.guide/ + about: The official guide for discord.js diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..b6acb36 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,30 @@ +version: 2 +updates: +- package-ecosystem: npm + directory: "/" + schedule: + interval: daily + time: "04:00" + open-pull-requests-limit: 10 + ignore: + - dependency-name: soundcloud-downloader + versions: + - ">= 1.a, < 2" + - dependency-name: discord.js + versions: + - 12.5.2 + - 12.5.3 + - dependency-name: ffmpeg-static + versions: + - 4.2.8 + - 4.3.0 + - dependency-name: ytdl-core-discord + versions: + - 1.3.0 + - dependency-name: "@discordjs/opus" + versions: + - 0.4.0 + - 0.5.0 + - dependency-name: ytdl-core + versions: + - 4.5.0 diff --git a/.github/stale.yml b/.github/stale.yml new file mode 100644 index 0000000..67c97fe --- /dev/null +++ b/.github/stale.yml @@ -0,0 +1,14 @@ +only: issues +daysUntilStale: 30 +daysUntilClose: 7 +exemptLabels: + - "suspected bug" + - "in progress" + - "enhancement" + - "dependencies" +staleLabel: stale +markComment: > + This issue has been automatically marked as stale because it has not had + recent activity. It will be closed if no further activity occurs. Thank you + for your contributions. +closeComment: false diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..8a9a2f0 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,60 @@ +name: Docker Hub + +on: + release: + types: [published] + +env: + # Use docker.io for Docker Hub if empty + REGISTRY: docker.io + # github.repository as / + IMAGE_NAME: ${{ github.repository }} + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + # QEMU for emulation + - name: Set up QEMU + uses: docker/setup-qemu-action@v1 + # Docker Buildx for creating multi arch docker images + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v1 + + # Login against a Docker registry when not a PR + # https://github.com/docker/login-action + - name: Log into registry ${{ env.REGISTRY }} + if: github.event_name != 'pull_request' + uses: docker/login-action@v1 + with: + registry: ${{ env.REGISTRY }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + # Extract metadata (tags, labels) for Docker + # https://github.com/docker/metadata-action + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v3 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + # Build and push Docker image with Buildx (don't push on PR) + # https://github.com/docker/build-push-action + - name: Build and push Docker image + uses: docker/build-push-action@v2 + with: + context: . + platforms: linux/amd64,linux/arm64,linux/arm/v7 + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + - name: Image digest + run: echo ${{ steps.docker_build.outputs.digest }} diff --git a/.github/workflows/node.yml b/.github/workflows/node.yml new file mode 100644 index 0000000..94ed309 --- /dev/null +++ b/.github/workflows/node.yml @@ -0,0 +1,26 @@ +name: Node.js CI + +on: + push: + branches: [master] + paths-ignore: + - README.md + - .github/** + - locales/** + - sounds/** + - package.json + pull_request: + branches: [master] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Use Node.js 16.11.0 + uses: actions/setup-node@v3 + with: + node-version: "16.11.0" + cache: "npm" + - run: npm ci + - run: npm run build --if-present diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..36612d4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,66 @@ +config.json +.env +/.idea +/.vscode +/sounds/** +!/sounds/putmusichere.mp3 +/dist + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# TypeScript v1 declaration files +typings/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# Temp ignore +config.json \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..95acd4a --- /dev/null +++ b/.prettierrc @@ -0,0 +1,7 @@ +{ + "printWidth": 120, + "tabWidth": 2, + "useTabs": false, + "trailingComma": "none", + "endOfLine": "lf" +} diff --git a/.replit b/.replit new file mode 100644 index 0000000..e97f244 --- /dev/null +++ b/.replit @@ -0,0 +1,2 @@ +language = "nodejs" +run = "npm run prod" \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..852834c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,32 @@ +ARG NODE_VERSION=18.18.2-slim +FROM node:${NODE_VERSION} as base + +ENV USER=evobot + +RUN apt-get update && \ + apt-get install -y --no-install-recommends python3 build-essential && \ + apt-get purge -y --auto-remove && \ + rm -rf /var/lib/apt/lists/* + +RUN groupadd -r ${USER} && \ + useradd --create-home --home /home/evobot -r -g ${USER} ${USER} + +USER ${USER} +WORKDIR /home/evobot + +FROM base as build + +COPY --chown=${USER}:${USER} . . +RUN npm ci +RUN npm run build + +RUN rm -rf node_modules && \ + npm ci --omit=dev + +FROM node:${NODE_VERSION} as prod + +COPY --chown=${USER}:${USER} package*.json ./ +COPY --from=build --chown=${USER}:${USER} /home/evobot/node_modules ./node_modules +COPY --from=build --chown=${USER}:${USER} /home/evobot/dist ./dist + +CMD [ "node", "./dist/index.js" ] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..4432c34 --- /dev/null +++ b/README.md @@ -0,0 +1,137 @@ +![Node build](https://github.com/eritislami/evobot/actions/workflows/node.yml/badge.svg) +![Docker build](https://github.com/eritislami/evobot/actions/workflows/docker.yml/badge.svg) +[![Commitizen friendly](https://img.shields.io/badge/commitizen-friendly-brightgreen.svg)](http://commitizen.github.io/cz-cli/) + +![logo](https://repository-images.githubusercontent.com/186841818/8aa95700-7730-11e9-84be-e80f28520325) + +# 🤖 EvoBot (Discord Music Bot) + +> EvoBot is a Discord Music Bot built with TypeScript, discord.js & uses Command Handler from [discordjs.guide](https://discordjs.guide) + +## Requirements + +1. Discord Bot Token **[Guide](https://discordjs.guide/preparations/setting-up-a-bot-application.html#creating-your-bot)** + 1.1. Enable 'Message Content Intent' in Discord Developer Portal +2. Node.js 16.11.0 or newer + +## 🚀 Getting Started + +```sh +git clone https://github.com/eritislami/evobot.git +cd evobot +npm install +``` + +After installation finishes follow configuration instructions then run `npm run start` to start the bot. + +## ⚙️ Configuration + +Copy or Rename `config.json.example` to `config.json` and fill out the values: + +⚠️ **Note: Never commit or share your token or api keys publicly** ⚠️ + +```json +{ + "TOKEN": "", + "MAX_PLAYLIST_SIZE": 10, + "PRUNING": false, + "LOCALE": "en", + "DEFAULT_VOLUME": 100, + "STAY_TIME": 30 +} +``` + +## 🐬 Docker Configuration + +For those who would prefer to use our [Docker container](https://hub.docker.com/repository/docker/eritislami/evobot), you may provide values from `config.json` as environment variables. + +```shell +docker run -e "TOKEN=" eritislami/evobot +``` + +## 📝 Features & Commands + +- 🎶 Play music from YouTube via url + +`/play https://www.youtube.com/watch?v=GLvohMXgcBo` + +- 🔎 Play music from YouTube via search query + +`/play under the bridge red hot chili peppers` + +- 🔎 Search and select music to play + +`/search Pearl Jam` + +- 📃 Play youtube playlists via url + +`/playlist https://www.youtube.com/watch?v=YlUKcNNmywk&list=PL5RNCwK3GIO13SR_o57bGJCEmqFAwq82c` + +- 🔎 Play youtube playlists via search query + +`/playlist linkin park meteora` + +- Now Playing (/nowplaying) +- Queue system (/queue) +- Loop / Repeat (/loop) +- Shuffle (/shuffle) +- Volume control (/volume) +- Lyrics (/lyrics) +- Pause (/pause) +- Resume (/resume) +- Skip (/skip) +- Skip to song # in queue (/skipto) +- Move a song in the queue (/move) +- Remove song # from queue (/remove) +- Show ping to Discord API (/ping) +- Show bot uptime (/uptime) +- Toggle pruning of bot messages (/pruning) +- Help (/help) +- Command Handler from [discordjs.guide](https://discordjs.guide/) +- Media Controls via Buttons + +![buttons](https://i.imgur.com/67TGY0c.png) + +## 🌎 Locales + +Currently available locales are: + +- English (en) +- Arabic (ar) +- Brazilian Portuguese (pt_br) +- Bulgarian (bg) +- Romanian (ro) +- Czech (cs) +- Dutch (nl) +- French (fr) +- German (de) +- Greek (el) +- Indonesian (id) +- Italian (it) +- Japanese (ja) +- Korean (ko) +- Minionese (mi) +- Persian (fa) +- Polish (pl) +- Russian (ru) +- Simplified Chinese (zh_cn) +- Singaporean Mandarin (zh_sg) +- Spanish (es) +- Swedish (sv) +- Traditional Chinese (zh_tw) +- Thai (th) +- Turkish (tr) +- Ukrainian (uk) +- Vietnamese (vi) +- Check [Contributing](#-contributing) if you wish to help add more languages! +- For languages please use [ISO 639-1](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) two letter format + +## 🤝 Contributing + +1. [Fork the repository](https://github.com/eritislami/evobot/fork) +2. Clone your fork: `git clone https://github.com/your-username/evobot.git` +3. Create your feature branch: `git checkout -b my-new-feature` +4. Stage changes `git add .` +5. Commit your changes: `cz` OR `npm run commit` do not use `git commit` +6. Push to the branch: `git push origin my-new-feature` +7. Submit a pull request diff --git a/app.json b/app.json new file mode 100644 index 0000000..f95c5d6 --- /dev/null +++ b/app.json @@ -0,0 +1,7 @@ +{ + "name": "EvoBot", + "description": "🤖 Discord Music Bot built with discord.js", + "repository": "https://github.com/eritislami/evobot", + "logo": "https://i.imgur.com/JFxgbWH.png", + "keywords": ["discord", "discordjs", "typescript", "music", "bot"] +} diff --git a/commands/help.ts b/commands/help.ts new file mode 100644 index 0000000..159e407 --- /dev/null +++ b/commands/help.ts @@ -0,0 +1,27 @@ +import { CommandInteraction, EmbedBuilder, SlashCommandBuilder } from "discord.js"; +import { i18n } from "../utils/i18n"; +import { bot } from "../index"; + +export default { + data: new SlashCommandBuilder().setName("help").setDescription(i18n.__("help.description")), + async execute(interaction: CommandInteraction) { + let commands = bot.slashCommandsMap; + + let helpEmbed = new EmbedBuilder() + .setTitle(i18n.__mf("help.embedTitle", { botname: interaction.client.user!.username })) + .setDescription(i18n.__("help.embedDescription")) + .setColor("#F8AA2A"); + + commands.forEach((cmd) => { + helpEmbed.addFields({ + name: `**${cmd.data.name}**`, + value: `${cmd.data.description}`, + inline: true + }); + }); + + helpEmbed.setTimestamp(); + + return interaction.reply({ embeds: [helpEmbed] }).catch(console.error); + } +}; diff --git a/commands/invite.ts b/commands/invite.ts new file mode 100644 index 0000000..d945255 --- /dev/null +++ b/commands/invite.ts @@ -0,0 +1,30 @@ +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + ChatInputCommandInteraction, + EmbedBuilder, + SlashCommandBuilder +} from "discord.js"; +import { i18n } from "../utils/i18n"; + +export default { + data: new SlashCommandBuilder().setName("invite").setDescription(i18n.__("invite.description")), + execute(interaction: ChatInputCommandInteraction) { + const inviteEmbed = new EmbedBuilder().setTitle(i18n.__mf("Invite me to your server!")); + + // return interaction with embed and button to invite the bot + const actionRow = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setLabel(i18n.__mf("Invite")) + .setStyle(ButtonStyle.Link) + .setURL( + `https://discord.com/api/oauth2/authorize?client_id=${ + interaction.client.user!.id + }&permissions=8&scope=bot%20applications.commands` + ) + ); + + return interaction.reply({ embeds: [inviteEmbed], components: [actionRow] }).catch(console.error); + } +}; diff --git a/commands/loop.ts b/commands/loop.ts new file mode 100644 index 0000000..5a721fd --- /dev/null +++ b/commands/loop.ts @@ -0,0 +1,25 @@ +import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js"; +import { bot } from "../index"; +import { i18n } from "../utils/i18n"; +import { canModifyQueue } from "../utils/queue"; +import { safeReply } from "../utils/safeReply"; + +export default { + data: new SlashCommandBuilder().setName("loop").setDescription(i18n.__("loop.description")), + execute(interaction: ChatInputCommandInteraction) { + const queue = bot.queues.get(interaction.guild!.id); + + const guildMemer = interaction.guild!.members.cache.get(interaction.user.id); + + if (!queue) + return interaction.reply({ content: i18n.__("loop.errorNotQueue"), ephemeral: true }).catch(console.error); + + if (!guildMemer || !canModifyQueue(guildMemer)) return i18n.__("common.errorNotChannel"); + + queue.loop = !queue.loop; + + const content = i18n.__mf("loop.result", { loop: queue.loop ? i18n.__("common.on") : i18n.__("common.off") }); + + safeReply(interaction, content); + } +}; diff --git a/commands/lyrics.ts b/commands/lyrics.ts new file mode 100644 index 0000000..a0fd94b --- /dev/null +++ b/commands/lyrics.ts @@ -0,0 +1,34 @@ +import { ChatInputCommandInteraction, EmbedBuilder, SlashCommandBuilder } from "discord.js"; +import { i18n } from "../utils/i18n"; +// @ts-ignore +import lyricsFinder from "lyrics-finder"; +import { bot } from "../index"; + +export default { + data: new SlashCommandBuilder().setName("lyrics").setDescription(i18n.__("lyrics.description")), + async execute(interaction: ChatInputCommandInteraction) { + const queue = bot.queues.get(interaction.guild!.id); + + if (!queue || !queue.songs.length) return interaction.reply(i18n.__("lyrics.errorNotQueue")).catch(console.error); + + await interaction.reply("⏳ Loading...").catch(console.error); + + let lyrics = null; + const title = queue.songs[0].title; + + try { + lyrics = await lyricsFinder(queue.songs[0].title, ""); + if (!lyrics) lyrics = i18n.__mf("lyrics.lyricsNotFound", { title: title }); + } catch (error) { + lyrics = i18n.__mf("lyrics.lyricsNotFound", { title: title }); + } + + let lyricsEmbed = new EmbedBuilder() + .setTitle(i18n.__mf("lyrics.embedTitle", { title: title })) + .setDescription(lyrics.length >= 4096 ? `${lyrics.substr(0, 4093)}...` : lyrics) + .setColor("#F8AA2A") + .setTimestamp(); + + return interaction.editReply({ content: "", embeds: [lyricsEmbed] }).catch(console.error); + } +}; diff --git a/commands/move.ts b/commands/move.ts new file mode 100644 index 0000000..77ac009 --- /dev/null +++ b/commands/move.ts @@ -0,0 +1,46 @@ +import move from "array-move"; +import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js"; +import { bot } from "../index"; +import { i18n } from "../utils/i18n"; +import { canModifyQueue } from "../utils/queue"; + +export default { + data: new SlashCommandBuilder() + .setName("move") + .setDescription(i18n.__("move.description")) + .addIntegerOption((option) => + option.setName("movefrom").setDescription(i18n.__("move.args.movefrom")).setRequired(true) + ) + .addIntegerOption((option) => + option.setName("moveto").setDescription(i18n.__("move.args.moveto")).setRequired(true) + ), + execute(interaction: ChatInputCommandInteraction) { + const movefromArg = interaction.options.getInteger("movefrom"); + const movetoArg = interaction.options.getInteger("moveto"); + + const guildMemer = interaction.guild!.members.cache.get(interaction.user.id); + const queue = bot.queues.get(interaction.guild!.id); + + if (!queue) return interaction.reply(i18n.__("move.errorNotQueue")).catch(console.error); + + if (!canModifyQueue(guildMemer!)) return; + + if (!movefromArg || !movetoArg) + return interaction.reply({ content: i18n.__mf("move.usagesReply", { prefix: bot.prefix }), ephemeral: true }); + + if (isNaN(movefromArg) || movefromArg <= 1) + return interaction.reply({ content: i18n.__mf("move.usagesReply", { prefix: bot.prefix }), ephemeral: true }); + + let song = queue.songs[movefromArg - 1]; + + queue.songs = move(queue.songs, movefromArg - 1, movetoArg == 1 ? 1 : movetoArg - 1); + + interaction.reply({ + content: i18n.__mf("move.result", { + author: interaction.user.id, + title: song.title, + index: movetoArg == 1 ? 1 : movetoArg + }) + }); + } +}; diff --git a/commands/nowplaying.ts b/commands/nowplaying.ts new file mode 100644 index 0000000..2243474 --- /dev/null +++ b/commands/nowplaying.ts @@ -0,0 +1,45 @@ +import { ChatInputCommandInteraction, EmbedBuilder, SlashCommandBuilder } from "discord.js"; +import { splitBar } from "string-progressbar"; +import { bot } from "../index"; +import { i18n } from "../utils/i18n"; + +export default { + data: new SlashCommandBuilder().setName("nowplaying").setDescription(i18n.__("nowplaying.description")), + cooldown: 10, + execute(interaction: ChatInputCommandInteraction) { + const queue = bot.queues.get(interaction.guild!.id); + + if (!queue || !queue.songs.length) + return interaction.reply({ content: i18n.__("nowplaying.errorNotQueue"), ephemeral: true }).catch(console.error); + + const song = queue.songs[0]; + const seek = queue.resource.playbackDuration / 1000; + const left = song.duration - seek; + + let nowPlaying = new EmbedBuilder() + .setTitle(i18n.__("nowplaying.embedTitle")) + .setDescription(`${song.title}\n${song.url}`) + .setColor("#F8AA2A"); + + if (song.duration > 0) { + nowPlaying.addFields({ + name: "\u200b", + value: + new Date(seek * 1000).toISOString().substr(11, 8) + + "[" + + splitBar(song.duration == 0 ? seek : song.duration, seek, 20)[0] + + "]" + + (song.duration == 0 ? " ◉ LIVE" : new Date(song.duration * 1000).toISOString().substr(11, 8)), + inline: false + }); + + nowPlaying.setFooter({ + text: i18n.__mf("nowplaying.timeRemaining", { + time: new Date(left * 1000).toISOString().substr(11, 8) + }) + }); + } + + return interaction.reply({ embeds: [nowPlaying] }); + } +}; diff --git a/commands/pause.ts b/commands/pause.ts new file mode 100644 index 0000000..1bed928 --- /dev/null +++ b/commands/pause.ts @@ -0,0 +1,25 @@ +import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js"; +import { bot } from "../index"; +import { i18n } from "../utils/i18n"; +import { canModifyQueue } from "../utils/queue"; +import { safeReply } from "../utils/safeReply"; + +export default { + data: new SlashCommandBuilder().setName("pause").setDescription(i18n.__("pause.description")), + execute(interaction: ChatInputCommandInteraction) { + const guildMemer = interaction.guild!.members.cache.get(interaction.user.id); + const queue = bot.queues.get(interaction.guild!.id); + + if (!queue) return interaction.reply({ content: i18n.__("pause.errorNotQueue") }).catch(console.error); + + if (!canModifyQueue(guildMemer!)) return i18n.__("common.errorNotChannel"); + + if (queue.player.pause()) { + const content = i18n.__mf("pause.result", { author: interaction.user.id }); + + safeReply(interaction, content); + + return true; + } + } +}; diff --git a/commands/ping.ts b/commands/ping.ts new file mode 100644 index 0000000..9ef0dcf --- /dev/null +++ b/commands/ping.ts @@ -0,0 +1,12 @@ +import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js"; +import { i18n } from "../utils/i18n"; + +export default { + data: new SlashCommandBuilder().setName("ping").setDescription(i18n.__("ping.description")), + cooldown: 10, + execute(interaction: ChatInputCommandInteraction) { + interaction + .reply({ content: i18n.__mf("ping.result", { ping: Math.round(interaction.client.ws.ping) }), ephemeral: true }) + .catch(console.error); + } +}; diff --git a/commands/play.ts b/commands/play.ts new file mode 100644 index 0000000..485acbb --- /dev/null +++ b/commands/play.ts @@ -0,0 +1,98 @@ +import { DiscordGatewayAdapterCreator, joinVoiceChannel } from "@discordjs/voice"; +import { ChatInputCommandInteraction, PermissionsBitField, SlashCommandBuilder, TextChannel } from "discord.js"; +import { bot } from "../index"; +import { MusicQueue } from "../structs/MusicQueue"; +import { Song } from "../structs/Song"; +import { i18n } from "../utils/i18n"; +import { playlistPattern } from "../utils/patterns"; + +export default { + data: new SlashCommandBuilder() + .setName("play") + .setDescription(i18n.__("play.description")) + .addStringOption((option) => option.setName("song").setDescription("The song you want to play").setRequired(true)), + cooldown: 3, + permissions: [PermissionsBitField.Flags.Connect, PermissionsBitField.Flags.Speak], + async execute(interaction: ChatInputCommandInteraction, input: string) { + let argSongName = interaction.options.getString("song"); + if (!argSongName) argSongName = input; + + const guildMember = interaction.guild!.members.cache.get(interaction.user.id); + const { channel } = guildMember!.voice; + + if (!channel) + return interaction.reply({ content: i18n.__("play.errorNotChannel"), ephemeral: true }).catch(console.error); + + const queue = bot.queues.get(interaction.guild!.id); + + if (queue && channel.id !== queue.connection.joinConfig.channelId) + return interaction + .reply({ + content: i18n.__mf("play.errorNotInSameChannel", { user: bot.client.user!.username }), + ephemeral: true + }) + .catch(console.error); + + if (!argSongName) + return interaction + .reply({ content: i18n.__mf("play.usageReply", { prefix: bot.prefix }), ephemeral: true }) + .catch(console.error); + + const url = argSongName; + + if (interaction.replied) await interaction.editReply("⏳ Loading...").catch(console.error); + else await interaction.reply("⏳ Loading..."); + + // Start the playlist if playlist url was provided + if (playlistPattern.test(url)) { + await interaction.editReply("🔗 Link is playlist").catch(console.error); + + return bot.slashCommandsMap.get("playlist")!.execute(interaction, "song"); + } + + let song; + + try { + song = await Song.from(url, url); + } catch (error: any) { + console.error(error); + + if (error.name == "NoResults") + return interaction + .reply({ content: i18n.__mf("play.errorNoResults", { url: `<${url}>` }), ephemeral: true }) + .catch(console.error); + + if (error.name == "InvalidURL") + return interaction + .reply({ content: i18n.__mf("play.errorInvalidURL", { url: `<${url}>` }), ephemeral: true }) + .catch(console.error); + + if (interaction.replied) + return await interaction.editReply({ content: i18n.__("common.errorCommand") }).catch(console.error); + else return interaction.reply({ content: i18n.__("common.errorCommand"), ephemeral: true }).catch(console.error); + } + + if (queue) { + queue.enqueue(song); + + return (interaction.channel as TextChannel) + .send({ content: i18n.__mf("play.queueAdded", { title: song.title, author: interaction.user.id }) }) + .catch(console.error); + } + + const newQueue = new MusicQueue({ + interaction, + textChannel: interaction.channel! as TextChannel, + connection: joinVoiceChannel({ + channelId: channel.id, + guildId: channel.guild.id, + adapterCreator: channel.guild.voiceAdapterCreator as DiscordGatewayAdapterCreator + }) + }); + + bot.queues.set(interaction.guild!.id, newQueue); + + newQueue.enqueue(song); + interaction.deleteReply().catch(console.error); + } +}; diff --git a/commands/playlist.ts b/commands/playlist.ts new file mode 100644 index 0000000..da82a29 --- /dev/null +++ b/commands/playlist.ts @@ -0,0 +1,102 @@ +import { DiscordGatewayAdapterCreator, joinVoiceChannel } from "@discordjs/voice"; +import { + ChatInputCommandInteraction, + EmbedBuilder, + PermissionsBitField, + SlashCommandBuilder, + TextChannel +} from "discord.js"; +import { bot } from "../index"; +import { MusicQueue } from "../structs/MusicQueue"; +import { Playlist } from "../structs/Playlist"; +import { Song } from "../structs/Song"; +import { i18n } from "../utils/i18n"; + +export default { + data: new SlashCommandBuilder() + .setName("playlist") + .setDescription(i18n.__("playlist.description")) + .addStringOption((option) => option.setName("playlist").setDescription("Playlist name or link").setRequired(true)), + cooldown: 5, + permissions: [PermissionsBitField.Flags.Connect, PermissionsBitField.Flags.Speak], + async execute(interaction: ChatInputCommandInteraction, queryOptionName = "playlist") { + let argSongName = interaction.options.getString(queryOptionName); + + const guildMemer = interaction.guild!.members.cache.get(interaction.user.id); + const { channel } = guildMemer!.voice; + + const queue = bot.queues.get(interaction.guild!.id); + + if (!channel) + return interaction.reply({ content: i18n.__("playlist.errorNotChannel"), ephemeral: true }).catch(console.error); + + if (queue && channel.id !== queue.connection.joinConfig.channelId) + if (interaction.replied) + return interaction + .editReply({ content: i18n.__mf("play.errorNotInSameChannel", { user: interaction.client.user!.username }) }) + .catch(console.error); + else + return interaction + .reply({ + content: i18n.__mf("play.errorNotInSameChannel", { user: interaction.client.user!.username }), + ephemeral: true + }) + .catch(console.error); + + let playlist; + + try { + playlist = await Playlist.from(argSongName!.split(" ")[0], argSongName!); + } catch (error) { + console.error(error); + + if (interaction.replied) + return interaction.editReply({ content: i18n.__("playlist.errorNotFoundPlaylist") }).catch(console.error); + else + return interaction + .reply({ content: i18n.__("playlist.errorNotFoundPlaylist"), ephemeral: true }) + .catch(console.error); + } + + if (queue) { + queue.songs.push(...playlist.videos); + } else { + const newQueue = new MusicQueue({ + interaction, + textChannel: interaction.channel! as TextChannel, + connection: joinVoiceChannel({ + channelId: channel.id, + guildId: channel.guild.id, + adapterCreator: channel.guild.voiceAdapterCreator as DiscordGatewayAdapterCreator + }) + }); + + bot.queues.set(interaction.guild!.id, newQueue); + newQueue.enqueue(...playlist.videos); + } + + let playlistEmbed = new EmbedBuilder() + .setTitle(`${playlist.data.title}`) + .setDescription( + playlist.videos + .map((song: Song, index: number) => `${index + 1}. ${song.title}`) + .join("\n") + .slice(0, 4095) + ) + .setURL(playlist.data.url!) + .setColor("#F8AA2A") + .setTimestamp(); + + if (interaction.replied) + return interaction.editReply({ + content: i18n.__mf("playlist.startedPlaylist", { author: interaction.user.id }), + embeds: [playlistEmbed] + }); + interaction + .reply({ + content: i18n.__mf("playlist.startedPlaylist", { author: interaction.user.id }), + embeds: [playlistEmbed] + }) + .catch(console.error); + } +}; diff --git a/commands/queue.ts b/commands/queue.ts new file mode 100644 index 0000000..b6ad863 --- /dev/null +++ b/commands/queue.ts @@ -0,0 +1,125 @@ +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + ChatInputCommandInteraction, + CommandInteraction, + EmbedBuilder, + Interaction, + SlashCommandBuilder +} from "discord.js"; +import { bot } from "../index"; +import { Song } from "../structs/Song"; +import { i18n } from "../utils/i18n"; + +export default { + data: new SlashCommandBuilder().setName("queue").setDescription(i18n.__("queue.description")), + cooldown: 5, + async execute(interaction: ChatInputCommandInteraction) { + const queue = bot.queues.get(interaction.guild!.id); + if (!queue || !queue.songs.length) return interaction.reply({ content: i18n.__("queue.errorNotQueue") }); + + let currentPage = 0; + const embeds = generateQueueEmbed(interaction, queue.songs); + + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder().setCustomId("previous").setLabel("⬅️").setStyle(ButtonStyle.Secondary), + new ButtonBuilder().setCustomId("stop").setLabel("⏹").setStyle(ButtonStyle.Secondary), + new ButtonBuilder().setCustomId("next").setLabel("➡️").setStyle(ButtonStyle.Secondary) + ); + + await interaction.reply("⏳ Loading queue..."); + + if (interaction.replied) + await interaction.editReply({ + content: `**${i18n.__mf("queue.currentPage")} ${currentPage + 1}/${embeds.length}**`, + embeds: [embeds[currentPage]], + components: [row] + }); + + const queueEmbed = await interaction.fetchReply(); + + const filter = (buttonInteraction: Interaction) => + buttonInteraction.isButton() && buttonInteraction.user.id === interaction.user.id; + + const collector = queueEmbed.createMessageComponentCollector({ filter, time: 60000 }); + + const buttonHandlers = { + next: async () => { + if (currentPage >= embeds.length - 1) return; + + currentPage++; + + await interaction.editReply({ + content: `**${i18n.__mf("queue.currentPage", { + page: currentPage + 1, + length: embeds.length + })}**`, + embeds: [embeds[currentPage]], + components: [row] + }); + }, + previous: async () => { + if (currentPage === 0) return; + + currentPage--; + await interaction.editReply({ + content: `**${i18n.__mf("queue.currentPage", { + page: currentPage + 1, + length: embeds.length + })}**`, + embeds: [embeds[currentPage]], + components: [row] + }); + }, + stop: async () => { + await interaction.editReply({ + components: [] + }); + + collector.stop(); + } + }; + + collector.on("collect", async (buttonInteraction) => { + buttonInteraction.deferUpdate(); + + const handler = buttonHandlers[buttonInteraction.customId as keyof typeof buttonHandlers]; + + if (handler) { + await handler(); + } + }); + + collector.on("end", () => { + queueEmbed + .edit({ + components: [] + }) + .catch(console.error); + }); + } +}; + +function generateQueueEmbed(interaction: CommandInteraction, songs: Song[]) { + let embeds = []; + let k = 10; + + for (let i = 0; i < songs.length; i += 10) { + const current = songs.slice(i, k); + let j = i; + k += 10; + + const info = current.map((track) => `${++j} - [${track.title}](${track.url})`).join("\n"); + + const embed = new EmbedBuilder() + .setTitle(i18n.__("queue.embedTitle")) + .setThumbnail(interaction.guild?.iconURL()!) + .setColor("#F8AA2A") + .setDescription(i18n.__mf("queue.embedCurrentSong", { title: songs[0].title, url: songs[0].url, info: info })) + .setTimestamp(); + embeds.push(embed); + } + + return embeds; +} diff --git a/commands/remove.ts b/commands/remove.ts new file mode 100644 index 0000000..f6e86b6 --- /dev/null +++ b/commands/remove.ts @@ -0,0 +1,57 @@ +import { SlashCommandBuilder, CommandInteraction, ChatInputCommandInteraction } from "discord.js"; +import { bot } from "../index"; +import { Song } from "../structs/Song"; +import { i18n } from "../utils/i18n"; +import { canModifyQueue } from "../utils/queue"; + +const pattern = /^[0-9]{1,2}(\s*,\s*[0-9]{1,2})*$/; + +export default { + data: new SlashCommandBuilder() + .setName("remove") + .setDescription(i18n.__("remove.description")) + .addStringOption((option) => + option.setName("slot").setDescription(i18n.__("remove.description")).setRequired(true) + ), + execute(interaction: ChatInputCommandInteraction) { + const guildMemer = interaction.guild!.members.cache.get(interaction.user.id); + const removeArgs = interaction.options.getString("slot"); + + const queue = bot.queues.get(interaction.guild!.id); + + if (!queue) + return interaction.reply({ content: i18n.__("remove.errorNotQueue"), ephemeral: true }).catch(console.error); + + if (!canModifyQueue(guildMemer!)) return i18n.__("common.errorNotChannel"); + + if (!removeArgs) + return interaction.reply({ content: i18n.__mf("remove.usageReply", { prefix: bot.prefix }), ephemeral: true }); + + const songs = removeArgs.split(",").map((arg) => parseInt(arg)); + + let removed: Song[] = []; + + if (pattern.test(removeArgs)) { + queue.songs = queue.songs.filter((item, index) => { + if (songs.find((songIndex) => songIndex - 1 === index)) removed.push(item); + else return true; + }); + + interaction.reply( + i18n.__mf("remove.result", { + title: removed.map((song) => song.title).join("\n"), + author: interaction.user.id + }) + ); + } else if (!isNaN(+removeArgs) && +removeArgs >= 1 && +removeArgs <= queue.songs.length) { + return interaction.reply( + i18n.__mf("remove.result", { + title: queue.songs.splice(+removeArgs - 1, 1)[0].title, + author: interaction.user.id + }) + ); + } else { + return interaction.reply({ content: i18n.__mf("remove.usageReply", { prefix: bot.prefix }) }); + } + } +}; diff --git a/commands/resume.ts b/commands/resume.ts new file mode 100644 index 0000000..8a9adb6 --- /dev/null +++ b/commands/resume.ts @@ -0,0 +1,32 @@ +import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js"; +import { bot } from "../index"; +import { i18n } from "../utils/i18n"; +import { canModifyQueue } from "../utils/queue"; +import { safeReply } from "../utils/safeReply"; + +export default { + data: new SlashCommandBuilder().setName("resume").setDescription(i18n.__("resume.description")), + execute(interaction: ChatInputCommandInteraction) { + const queue = bot.queues.get(interaction.guild!.id); + const guildMemer = interaction.guild!.members.cache.get(interaction.user.id); + + if (!queue) + return interaction.reply({ content: i18n.__("resume.errorNotQueue"), ephemeral: true }).catch(console.error); + + if (!canModifyQueue(guildMemer!)) return i18n.__("common.errorNotChannel"); + + if (queue.player.unpause()) { + const content = i18n.__mf("resume.resultNotPlaying", { author: interaction.user.id }); + + safeReply(interaction, content); + + return true; + } + + const content = i18n.__("resume.errorPlaying"); + + safeReply(interaction, content); + + return false; + } +}; diff --git a/commands/search.ts b/commands/search.ts new file mode 100644 index 0000000..7bbb233 --- /dev/null +++ b/commands/search.ts @@ -0,0 +1,86 @@ +import { + ActionRowBuilder, + ChatInputCommandInteraction, + SlashCommandBuilder, + StringSelectMenuBuilder, + StringSelectMenuInteraction +} from "discord.js"; +import youtube, { Video } from "youtube-sr"; +import { bot } from ".."; +import { i18n } from "../utils/i18n"; + +export default { + data: new SlashCommandBuilder() + .setName("search") + .setDescription(i18n.__("search.description")) + .addStringOption((option) => + option.setName("query").setDescription(i18n.__("search.optionQuery")).setRequired(true) + ), + async execute(interaction: ChatInputCommandInteraction) { + const query = interaction.options.getString("query", true); + const member = interaction.guild!.members.cache.get(interaction.user.id); + + if (!member?.voice.channel) + return interaction.reply({ content: i18n.__("search.errorNotChannel"), ephemeral: true }).catch(console.error); + + const search = query; + + await interaction.reply("⏳ Loading...").catch(console.error); + + let results: Video[] = []; + + try { + results = await youtube.search(search, { limit: 10, type: "video" }); + } catch (error) { + console.error(error); + interaction.editReply({ content: i18n.__("common.errorCommand") }).catch(console.error); + return; + } + + if (!results || !results[0]) { + interaction.editReply({ content: i18n.__("search.noResults") }); + return; + } + + const options = results!.map((video) => { + return { + label: video.title ?? "", + value: video.url + }; + }); + + const row = new ActionRowBuilder().addComponents( + new StringSelectMenuBuilder() + .setCustomId("search-select") + .setPlaceholder("Nothing selected") + .setMinValues(1) + .setMaxValues(10) + .addOptions(options) + ); + + const followUp = await interaction.followUp({ + content: "Choose songs to play", + components: [row] + }); + + followUp + .awaitMessageComponent({ + time: 30000 + }) + .then((selectInteraction) => { + if (!(selectInteraction instanceof StringSelectMenuInteraction)) return; + + selectInteraction.update({ content: "⏳ Loading the selected songs...", components: [] }); + + bot.slashCommandsMap + .get("play")! + .execute(interaction, selectInteraction.values[0]) + .then(() => { + selectInteraction.values.slice(1).forEach((url) => { + bot.slashCommandsMap.get("play")!.execute(interaction, url); + }); + }); + }) + .catch(console.error); + } +}; diff --git a/commands/shuffle.ts b/commands/shuffle.ts new file mode 100644 index 0000000..73ee057 --- /dev/null +++ b/commands/shuffle.ts @@ -0,0 +1,31 @@ +import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js"; +import { bot } from "../index"; +import { i18n } from "../utils/i18n"; +import { canModifyQueue } from "../utils/queue"; +import { safeReply } from "../utils/safeReply"; + +export default { + data: new SlashCommandBuilder().setName("shuffle").setDescription(i18n.__("shuffle.description")), + execute(interaction: ChatInputCommandInteraction) { + const queue = bot.queues.get(interaction.guild!.id); + const guildMemer = interaction.guild!.members.cache.get(interaction.user.id); + + if (!queue) + return interaction.reply({ content: i18n.__("shuffle.errorNotQueue"), ephemeral: true }).catch(console.error); + + if (!guildMemer || !canModifyQueue(guildMemer)) return i18n.__("common.errorNotChannel"); + + let songs = queue.songs; + + for (let i = songs.length - 1; i > 1; i--) { + let j = 1 + Math.floor(Math.random() * i); + [songs[i], songs[j]] = [songs[j], songs[i]]; + } + + queue.songs = songs; + + const content = i18n.__mf("shuffle.result", { author: interaction.user.id }); + + safeReply(interaction, content); + } +}; diff --git a/commands/skip.ts b/commands/skip.ts new file mode 100644 index 0000000..948dd61 --- /dev/null +++ b/commands/skip.ts @@ -0,0 +1,21 @@ +import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js"; +import { bot } from "../index"; +import { i18n } from "../utils/i18n"; +import { canModifyQueue } from "../utils/queue"; +import { safeReply } from "../utils/safeReply"; + +export default { + data: new SlashCommandBuilder().setName("skip").setDescription(i18n.__("skip.description")), + execute(interaction: ChatInputCommandInteraction) { + const queue = bot.queues.get(interaction.guild!.id); + const guildMemer = interaction.guild!.members.cache.get(interaction.user.id); + + if (!queue) return interaction.reply(i18n.__("skip.errorNotQueue")).catch(console.error); + + if (!canModifyQueue(guildMemer!)) return i18n.__("common.errorNotChannel"); + + queue.player.stop(true); + + safeReply(interaction, i18n.__mf("skip.result", { author: interaction.user.id })); + } +}; diff --git a/commands/skipto.ts b/commands/skipto.ts new file mode 100644 index 0000000..01a0f25 --- /dev/null +++ b/commands/skipto.ts @@ -0,0 +1,51 @@ +import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js"; +import { bot } from "../index"; +import { i18n } from "../utils/i18n"; +import { canModifyQueue } from "../utils/queue"; + +export default { + data: new SlashCommandBuilder() + .setName("skipto") + .setDescription(i18n.__("skipto.description")) + .addIntegerOption((option) => + option.setName("number").setDescription(i18n.__("skipto.args.number")).setRequired(true) + ), + execute(interaction: ChatInputCommandInteraction) { + const playlistSlotArg = interaction.options.getInteger("number"); + const guildMemer = interaction.guild!.members.cache.get(interaction.user.id); + + if (!playlistSlotArg || isNaN(playlistSlotArg)) + return interaction + .reply({ + content: i18n.__mf("skipto.usageReply", { prefix: bot.prefix, name: module.exports.name }), + ephemeral: true + }) + .catch(console.error); + + const queue = bot.queues.get(interaction.guild!.id); + + if (!queue) + return interaction.reply({ content: i18n.__("skipto.errorNotQueue"), ephemeral: true }).catch(console.error); + + if (!canModifyQueue(guildMemer!)) return i18n.__("common.errorNotChannel"); + + if (playlistSlotArg > queue.songs.length) + return interaction + .reply({ content: i18n.__mf("skipto.errorNotValid", { length: queue.songs.length }), ephemeral: true }) + .catch(console.error); + + if (queue.loop) { + for (let i = 0; i < playlistSlotArg - 2; i++) { + queue.songs.push(queue.songs.shift()!); + } + } else { + queue.songs = queue.songs.slice(playlistSlotArg - 2); + } + + queue.player.stop(); + + interaction + .reply({ content: i18n.__mf("skipto.result", { author: interaction.user.id, arg: playlistSlotArg - 1 }) }) + .catch(console.error); + } +}; diff --git a/commands/stop.ts b/commands/stop.ts new file mode 100644 index 0000000..e060441 --- /dev/null +++ b/commands/stop.ts @@ -0,0 +1,20 @@ +import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js"; +import { bot } from "../index"; +import { i18n } from "../utils/i18n"; +import { canModifyQueue } from "../utils/queue"; +import { safeReply } from "../utils/safeReply"; + +export default { + data: new SlashCommandBuilder().setName("stop").setDescription(i18n.__("stop.description")), + execute(interaction: ChatInputCommandInteraction) { + const queue = bot.queues.get(interaction.guild!.id); + const guildMemer = interaction.guild!.members.cache.get(interaction.user.id); + + if (!queue) return interaction.reply(i18n.__("stop.errorNotQueue")).catch(console.error); + if (!guildMemer || !canModifyQueue(guildMemer)) return i18n.__("common.errorNotChannel"); + + queue.stop(); + + safeReply(interaction, i18n.__mf("stop.result", { author: interaction.user.id })); + } +}; diff --git a/commands/uptime.ts b/commands/uptime.ts new file mode 100644 index 0000000..2dee121 --- /dev/null +++ b/commands/uptime.ts @@ -0,0 +1,21 @@ +import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js"; +import { bot } from "../index"; +import { i18n } from "../utils/i18n"; + +export default { + data: new SlashCommandBuilder().setName("uptime").setDescription(i18n.__("uptime.description")), + execute(interaction: ChatInputCommandInteraction) { + let seconds = Math.floor(bot.client.uptime! / 1000); + let minutes = Math.floor(seconds / 60); + let hours = Math.floor(minutes / 60); + let days = Math.floor(hours / 24); + + seconds %= 60; + minutes %= 60; + hours %= 24; + + return interaction + .reply({ content: i18n.__mf("uptime.result", { days: days, hours: hours, minutes: minutes, seconds: seconds }) }) + .catch(console.error); + } +}; diff --git a/commands/volume.ts b/commands/volume.ts new file mode 100644 index 0000000..e54c28c --- /dev/null +++ b/commands/volume.ts @@ -0,0 +1,38 @@ +import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js"; +import { bot } from "../index"; +import { i18n } from "../utils/i18n"; +import { canModifyQueue } from "../utils/queue"; + +export default { + data: new SlashCommandBuilder() + .setName("volume") + .setDescription(i18n.__("volume.description")) + .addIntegerOption((option) => option.setName("volume").setDescription(i18n.__("volume.description"))), + execute(interaction: ChatInputCommandInteraction) { + const queue = bot.queues.get(interaction.guild!.id); + const guildMemer = interaction.guild!.members.cache.get(interaction.user.id); + const volumeArg = interaction.options.getInteger("volume"); + + if (!queue) + return interaction.reply({ content: i18n.__("volume.errorNotQueue"), ephemeral: true }).catch(console.error); + + if (!canModifyQueue(guildMemer!)) + return interaction.reply({ content: i18n.__("volume.errorNotChannel"), ephemeral: true }).catch(console.error); + + if (!volumeArg || volumeArg === queue.volume) + return interaction + .reply({ content: i18n.__mf("volume.currentVolume", { volume: queue.volume }) }) + .catch(console.error); + + if (isNaN(volumeArg)) + return interaction.reply({ content: i18n.__("volume.errorNotNumber"), ephemeral: true }).catch(console.error); + + if (Number(volumeArg) > 100 || Number(volumeArg) < 0) + return interaction.reply({ content: i18n.__("volume.errorNotValid"), ephemeral: true }).catch(console.error); + + queue.volume = volumeArg; + queue.resource.volume?.setVolumeLogarithmic(volumeArg / 100); + + return interaction.reply({ content: i18n.__mf("volume.result", { arg: volumeArg }) }).catch(console.error); + } +}; diff --git a/config.json.example b/config.json.example new file mode 100644 index 0000000..5f7f39c --- /dev/null +++ b/config.json.example @@ -0,0 +1,8 @@ +{ + "TOKEN": "", + "MAX_PLAYLIST_SIZE": 10, + "PRUNING": false, + "LOCALE": "en", + "STAY_TIME": 30, + "DEFAULT_VOLUME": 100 +} \ No newline at end of file diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..ebc4fca --- /dev/null +++ b/index.ts @@ -0,0 +1,15 @@ +import { Client, GatewayIntentBits } from "discord.js"; +import { Bot } from "./structs/Bot"; + +export const bot = new Bot( + new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildVoiceStates, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.GuildMessageReactions, + GatewayIntentBits.MessageContent, + GatewayIntentBits.DirectMessages + ] + }) +); diff --git a/interfaces/Command.ts b/interfaces/Command.ts new file mode 100644 index 0000000..b5d2cce --- /dev/null +++ b/interfaces/Command.ts @@ -0,0 +1,8 @@ +import { SlashCommandBuilder } from "discord.js"; + +export interface Command { + permissions?: string[]; + cooldown?: number; + data: SlashCommandBuilder; + execute(...args: any): any; +} diff --git a/interfaces/Config.ts b/interfaces/Config.ts new file mode 100644 index 0000000..7d5ad2e --- /dev/null +++ b/interfaces/Config.ts @@ -0,0 +1,8 @@ +export interface Config { + TOKEN: string; + MAX_PLAYLIST_SIZE: number; + PRUNING: boolean; + STAY_TIME: number; + DEFAULT_VOLUME: number; + LOCALE: string; +} diff --git a/interfaces/QueueOptions.ts b/interfaces/QueueOptions.ts new file mode 100644 index 0000000..956d34a --- /dev/null +++ b/interfaces/QueueOptions.ts @@ -0,0 +1,8 @@ +import { VoiceConnection } from "@discordjs/voice"; +import { CommandInteraction, TextChannel } from "discord.js"; + +export interface QueueOptions { + interaction: CommandInteraction; + textChannel: TextChannel; + connection: VoiceConnection; +} diff --git a/locales/ar.json b/locales/ar.json new file mode 100644 index 0000000..a841d0e --- /dev/null +++ b/locales/ar.json @@ -0,0 +1,167 @@ +{ + "clip": { + "description": "تشغيل مقطع صوتي", + "usagesReply": "إستعمال: {prefix}clip <اسم>", + "errorQueue": "لا يمكن تشغيل المقطع نظرًا لوجود قائمة اغاني تعمل...", + "errorNotChannel": "تحتاج إلى الانضمام إلى قناة صوتية أولاً!" + }, + "clips": { + "description": "قائمة المقاطع" + }, + "help": { + "description": "يعرض جميع الاوامر", + "embedTitle": "{botname} مساعدة", + "embedDescription": "قائمة الاوامر" + }, + "invite": { + "description": "يرسل رابط دعوة البوت" + }, + "loop": { + "description": "تغير وضع التكرار", + "errorNotQueue": "لا توجد أغنية قيد التشغيل حاليا", + "result": "التكرار الان {loop}" + }, + "lyrics": { + "description": "احصل على كلمات الأغنية التي يتم تشغيلها حاليًا", + "errorNotQueue": "لا توجد أغنية قيد التشغيل حاليا", + "lyricsNotFound": "{title} لم يتم العثور على كلمات لـ", + "embedTitle": "{title} - كلمات" + }, + "move": { + "description": "نقل الأغاني في القائمة", + "errorNotQueue": "لا توجد قائمة حاليًا", + "usagesReply": "{prefix}move استعمال: رقم", + "result": "<@{author}> انتقل **{title}** الي {index} في القائمة 🚚" + }, + "nowplaying": { + "description": "يظهر الاغنية التي تكون قيد التشغيل", + "errorNotQueue": "لا توجد أغنية قيد التشغيل حاليا", + "embedTitle": "تعمل الان", + "live": " ◉ مباشر", + "timeRemaining": "الوقت المتبقي {time}" + }, + "pause": { + "description": "إيقاف الموسيقى التي قيد التشغيل", + "errorNotQueue": "لا توجد أغنية قيد التشغيل حاليا", + "result": "<@{author}> أوقف الموسيقى مؤقتًا ⏸" + }, + "ping": { + "description": "يظهر معدل السرعة", + "result": "`{ping}ms` متوسط السرعة هو 📈" + }, + "play": { + "description": "يشغل اغنية من يوتيوب او ساوندكلود", + "errorNotChannel": "تحتاج إلى الانضمام إلى قناة صوتية أولاً!", + "errorNotInSameChannel": "{user} يجب أن تكون في نفس القناة مثل", + "usageReply": "الاستعمال: {prefix}play <رابط يوتيوب | اسم مقطع | رابط سواندكلود>", + "missingPermissionConnect": "لا يمكن الاتصال بقناة صوتية ، أذونات مفقودة", + "missingPermissionSpeak": "لا يمكنني التحدث في هذه القناة الصوتية ، تأكد من أن لدي الأذونات المناسبة!", + "queueAdded": "تمت اضافة **{title}** الي القائمة بواسطة <@{author}> ✅", + "cantJoinChannel": "تعذر الانضمام إلى القناة: {error}", + "queueEnded": "قائمة الاغاني انتهت ❌", + "queueError": "خطأ: {error}", + "startedPlaying": "🎶 {url} **{title}** بدأ تشغيل", + "skipSong": "<@{author}> تخطى الأغنية ⏩", + "pauseSong": "<@{author}> أوقف الموسيقى مؤقتًا ⏸", + "resumeSong": "<@{author}> استأنف الموسيقى ▶", + "unmutedSong": "<@{author}> فك كتم الصوت 🔊", + "mutedSong": "<@{author}> كتم صوت! 🔇", + "decreasedVolume": "<@{author}> 🔉 **{volume}%** خفض الصوت, مستوي الصوت الان هو", + "increasedVolume": "<@{author}> 🔊 **{volume}%** زاد الصوت, مستوي الصوت الان هو", + "loopSong": "<@{author}> التكرار الان {loop}", + "stopSong": "<@{author}> أوقف الموسيقى ⏹", + "leaveChannel": "جارٍ ترك القناة الصوتية...", + "songNotFound": "Audio Not Found", + "songAccessErr": "Video is age restricted, private or unavailable" + }, + "playlist": { + "description": "تشغيل قائمة تشغيل من يوتيوب", + "usagesReply": "الاستعمال: {prefix}playlist <رابط قائمة يوتيوب | اسم قائمة>", + "errorNotChannel": "تحتاج إلى الانضمام إلى قناة صوتية أولاً!", + "errorNotInSameChannel": "{user} يجب أن تكون في نفس القناة مثل", + "missingPermissionConnect": "لا يمكن الاتصال بلقناة الصوتية ، أذونات مفقودة", + "missingPermissionSpeak": "لا يمكنني التحدث في هذه القناة الصوتية ، تأكد من أن لدي الأذونات المناسبة!", + "errorNotFoundPlaylist": "قائمة التشغيل غير موجودة :(", + "fetchingPlaylist": "جاري إحضار قائمة التشغيل... ⌛", + "playlistCharLimit": "\nقائمة التشغيل أكبر من عدد الأحرف المسموح به...", + "startedPlaylist": "<@{author}> بدأ قائمة تشغيل", + "cantJoinChannel": "لم استطيع الانضمام الي القناة: {error}" + }, + "pruning": { + "description": "تغير وضع حذف الرسائل التلقائي", + "errorWritingFile": "حدث خطأ اثناء الكتابة إلى الملف.", + "result": "الوضع الان {result}" + }, + "queue": { + "description": "يظهر قائمة الاغاني", + "missingPermissionMessage": "لا يوجد إذن لإدارة الرسائل أو إضافة ردود الفعل", + "errorNotQueue": "لا توجد أغنية قيد التشغيل حاليا ❌", + "currentPage": "الصفحة الحالية - ", + "embedTitle": "قائمة الاغاني\n", + "embedCurrentSong": "**الاغنية الحالية - [{title}]({url})**\n\n{info}" + }, + "remove": { + "description": "يحذف اغنية من القائمة", + "errorNotQueue": "لا توجد قائمة حاليًا", + "usageReply": "استعمال: {prefix}remove <رقم>", + "result": "<@{author}> تمت إزالة **{title}** من قائمة الانتظار ❌" + }, + "resume": { + "description": "استئناف تشغيل الموسيقى الحالية", + "errorNotQueue": "لا توجد أغنية قيد التشغيل حاليا", + "resultNotPlaying": "<@{author}> استأنف الموسيقى ▶", + "errorPlaying": "القائمة لم يتم إيقافها." + }, + "search": { + "description": "ابحث عن مقاطع الفيديو وحددها لتشغيلها", + "usageReply": "استعمال: {prefix}{name} <اسم مقطع>", + "errorAlreadyCollector": "مُجمع الرسائل نشط بالفعل في هذه القناة.", + "errorNotChannel": "تحتاج إلى الانضمام إلى قناة صوتية أولاً!", + "resultEmbedTitle": "**رد برقم الأغنية التي تريد تشغيلها**", + "resultEmbedDesc": "{search} نتائج لـ" + }, + "shuffle": { + "description": "خلط القائمة", + "errorNotQueue": "لا توجد قائمة موجودة حاليا", + "result": "<@{author}> تم خلط القائمة 🔀" + }, + "skip": { + "description": "تخطي الأغنية التي يتم تشغيلها حاليًا", + "errorNotQueue": "لا يوجد أي شيء يمكنني تخطيه من أجلك.", + "result": "<@{author}> تخطى الأغنية ⏭" + }, + "skipto": { + "description": "تخطي إلى الرقم المحدد", + "usageReply": "استعمال: {prefix}{name} <رقم>", + "errorNotQueue": "لا توجد قائمة حاليًا", + "errorNotValid": "قائمة الانتظار تتكون من {length} أغنية فقط!", + "result": "<@{author}> تخطي {arg} اغنية ⏭" + }, + "stop": { + "description": "يوقف الموسيقى", + "errorNotQueue": "لا توجد أغنية قيد التشغيل حاليا", + "result": "<@{author}> أوقف الموسيقى ⏹" + }, + "uptime": { + "description": "تحقق من مدة وقت التشغيل", + "result": "وقت التشغيل: `{days} يوم,{hours} ساعة, {minutes} دقيقة, {seconds} ثانية`" + }, + "volume": { + "description": "تغير مستوي الصوت", + "errorNotQueue": "لا توجد أغنية قيد التشغيل حاليا", + "errorNotChannel": "تحتاج إلى الانضمام إلى قناة صوتية أولاً!", + "currentVolume": "🔊 **{volume}%** مستوي الصوت الحالي هو", + "errorNotNumber": "رجاء اختر رقم لتغير مستوي الصوت", + "errorNotValid": "رجاء استخدم رقم بين الصفر والمئة", + "result": "تم ضبط الصوت على **{arg}%**" + }, + "common": { + "on": "**تشغيل**", + "off": "**ايقاف**", + "enabled": "**مفعل**", + "disabled": "**مقفل**", + "errorNotChannel": "تحتاج إلى الانضمام إلى قناة صوتية أولاً!", + "cooldownMessage": "يرجى الانتظار {time} ثانية أخرى قبل إعادة استخدام الأمر `{name}`.", + "errorCommand": "حدث خطأ أثناء تنفيذ هذا الأمر." + } +} diff --git a/locales/bg.json b/locales/bg.json new file mode 100644 index 0000000..442c7c1 --- /dev/null +++ b/locales/bg.json @@ -0,0 +1,181 @@ +{ + "clip": { + "description": "Възпроизвеждане на звук от клип", + "usagesReply": "Употреба: {prefix}clip ", + "errorQueue": "Не може да се възпроизвежда клип, поради активна опашка.", + "errorNotChannel": "Първо трябва да се присъедините към гласов канал!" + }, + "clips": { + "description": "Списък на всички клипове" + }, + "help": { + "description": "Показва всички команди и описания", + "embedTitle": "{botname} Помощ", + "embedDescription": "Списък на всички команди" + }, + "invite": { + "description": "Изпращане на линк за покана на бота" + }, + "loop": { + "description": "Превключване за повтаране на музикален цикъл", + "errorNotQueue": "Няма нищо за възпроизвеждане.", + "result": "Цикълът сега е {loop}" + }, + "lyrics": { + "description": "Получаване на текст за текущата песен", + "errorNotQueue": "Няма нищо за възпроизвеждане.", + "lyricsNotFound": "Не е намерен текст на песента за {title}.", + "embedTitle": "{title} - Текстове на песни" + }, + "move": { + "description": "Преместване на песни в опашката", + "errorNotQueue": "Няма опашка.", + "usagesReply": "Употреба: {prefix}move ", + "result": "<@{author}> 🚚 премести **{title}** на {index} в опашката.", + "args": { + "movefrom": "Слот за преместване от", + "moveto": "Слот за преместване към" + } + }, + "nowplaying": { + "description": "Покажи сегашната песен", + "errorNotQueue": "Няма нищо за възпроизвеждане.", + "embedTitle": "Сега се изпълнява", + "live": " ◉ НА ЖИВО", + "timeRemaining": "Оставащото време: {time}" + }, + "pause": { + "description": "Пауза на възпроизвежданата в момента музика", + "errorNotQueue": "Няма нищо за възпроизвеждане.", + "result": "<@{author}> ⏸ паузира музиката." + }, + "ping": { + "description": "Показва средния пинг на бота", + "result": "📈 Среден пинг към API: {ping} ms" + }, + "play": { + "description": "Възпроизвежда аудио от YouTube", + "errorNotChannel": "Първо трябва да се присъедините към гласов канал!", + "errorNotInSameChannel": "Трябва да сте в същия канал като {user}", + "usageReply": "Употреба: {prefix}play ", + "missingPermissionConnect": "Не мога да се свържа с гласов канал, липсват разрешения", + "missingPermissionSpeak": "Не мога да говоря в този гласов канал, уверете се, че имам правилните разрешения!", + "queueAdded": "✅ **{title}** е добавено към опашката от <@{author}>", + "cantJoinChannel": "Не може да се присъедини към канала: {error}", + "queueEnded": "❌ Опашката за музика приключи.", + "queueError": "Грешка: {error}", + "startedPlaying": "🎶 Започна да се възпроизвежда: **{title}** {url}", + "skipSong": "<@{author}> ⏩ прескочи песента", + "pauseSong": "<@{author}> ⏸ паузира музиката.", + "resumeSong": "<@{author}> ▶ възобнови музиката!", + "unmutedSong": "<@{author}> 🔊 включи музиката!", + "mutedSong": "<@{author}> 🔇 заглуши музиката!", + "decreasedVolume": "<@{author}> 🔉 намали силата на звука, сега силата на звука е {volume}%", + "increasedVolume": "<@{author}> 🔊 увеличи силата на звука, силата на звука сега е {volume}%", + "loopSong": "<@{author}> Цикълът сега е {loop}", + "stopSong": "<@{author}> ⏹ спря музиката!", + "leaveChannel": "Напускане на гласов канал...", + "songNotFound": "Аудиото не е намерено", + "songAccessErr": "Видеото е ограничено по възраст, частно или недостъпно", + "errorNoResults": "Не са намерени резултати за {url}", + "errorInvalidURL": "Невалиден URL адрес, моля, опитайте с търсене или youtube url адрес" + }, + "playlist": { + "description": "Възпроизвеждане на плейлист от youtube", + "usagesReply": "Употреба: {prefix}playlist ", + "errorNotChannel": "Първо трябва да се присъедините към гласов канал!", + "errorNotInSameChannel": "Трябва да сте в същия канал като {user}", + "missingPermissionConnect": "Не мога да се свържа с гласов канал, липсват разрешения", + "missingPermissionSpeak": "Не мога да говоря в този гласов канал, уверете се, че имам правилните разрешения!", + "errorNotFoundPlaylist": "Плейлист не намерен :(", + "fetchingPlaylist": "⌛ набиране на плейлист...", + "playlistCharLimit": "\nПлейлист по-голям от ограничението на писмени знаци...", + "startedPlaylist": "<@{author}> Стартира списък за възпроизвеждане", + "cantJoinChannel": "Не може да се присъедини към канала: {error}" + }, + "pruning": { + "description": "Превключване на изрязването на съобщенията на бота", + "errorWritingFile": "Имаше грешка при записването на файла.", + "result": "Орязването на съобщенията е {result}" + }, + "queue": { + "description": "Покажете музикалната опашка и сега изпълняваната музика.", + "missingPermissionMessage": "Липсва разрешение за управление на съобщения или добавяне на реакции", + "errorNotQueue": "❌ **Нищо не се възпроизвежда в този сървър**", + "currentPage": "Текуща Страница - ", + "embedTitle": "Опашка на Песни\n", + "embedCurrentSong": "**Текуща Песен - [{title}]({url})**\n\n{info}" + }, + "remove": { + "description": "Премахване на песен от опашката", + "errorNotQueue": "Няма опашка.", + "usageReply": "Употреба: {prefix}remove ", + "result": "<@{author}> ❌ премахна **{title}** от опашката." + }, + "resume": { + "description": "Възобнови възпроизвежданата в момента музика", + "errorNotQueue": "Няма нищо за възпроизвеждане.", + "resultNotPlaying": "<@{author}> ▶ възобнови музиката!", + "errorPlaying": "Опашката не е спряна." + }, + "search": { + "description": "Търсене и избиране на видеоклипове за възпроизвеждане", + "usageReply": "Употреба: {prefix}{name}