├── src ├── context-menus │ ├── user │ │ └── .gitkeep │ └── message │ │ └── .gitkeep ├── listeners │ ├── interaction │ │ ├── pingInteraction.js │ │ ├── autoCompleteInteraction.js │ │ └── interactionCreate.js │ ├── guild │ │ ├── guildDelete.js │ │ └── guildCreate.js │ └── client │ │ └── ready.js ├── config │ ├── emojis.json │ └── colors.json ├── commands │ ├── .sample.js │ ├── developer │ │ ├── test.js │ │ ├── deploy.js │ │ ├── set-name.js │ │ ├── eval.js │ │ ├── set-avatar.js │ │ ├── reload.js │ │ └── exec.js │ ├── system │ │ ├── permlevel.js │ │ ├── invite.js │ │ ├── support.js │ │ ├── help.js │ │ └── stats.js │ ├── music-dj │ │ ├── replay.js │ │ ├── shuffle-queue.js │ │ ├── stop.js │ │ ├── pause.js │ │ ├── clear-queue.js │ │ ├── play-previous-song.js │ │ ├── skip.js │ │ ├── queue-previous-song.js │ │ ├── jump-to.js │ │ ├── skip-to.js │ │ ├── play-next.js │ │ ├── remove-song.js │ │ ├── seek.js │ │ ├── repeat-mode.js │ │ ├── biquad.js │ │ ├── move-song.js │ │ ├── swap-songs.js │ │ ├── equalizer.js │ │ └── audio-filters.js │ ├── music │ │ ├── queue.js │ │ ├── history.js │ │ ├── now-playing.js │ │ ├── save-song.js │ │ ├── vote-skip.js │ │ ├── lyrics.js │ │ ├── play.js │ │ └── search.js │ └── music-admin │ │ ├── volume.js │ │ ├── use-thread-sessions.js │ │ ├── use-strict-thread-sessions.js │ │ ├── leave-on-end-cooldown.js │ │ ├── leave-on-empty-cooldown.js │ │ ├── dj-roles.js │ │ └── music-channels.js ├── interactions │ ├── buttons │ │ ├── .index.js │ │ ├── eval │ │ │ ├── declineEval.js │ │ │ └── acceptEval.js │ │ └── play-button.js │ ├── autocomplete │ │ ├── audio-filter.js │ │ ├── query-lyrics.js │ │ ├── query.js │ │ └── command.js │ ├── select-menus │ │ └── help.js │ └── modals │ │ └── evalSubmit.js ├── server │ └── index.js ├── constants.js ├── client.js ├── modules │ └── db.js ├── music-player.js └── handlers │ └── permissions.js ├── Procfile ├── assets ├── logo.png └── showcase │ ├── 13-seek.png │ ├── 4-vote-skip.png │ ├── 5-skip-to.png │ ├── 7-dj-roles.png │ ├── 8-equalizer.png │ ├── thumbnail.gif │ ├── 0-24-7-music.png │ ├── 12-save-song.png │ ├── 2-now-playing.png │ ├── 1-shuffle-queue.png │ ├── 10-repeat-mode.png │ ├── 3-play-sessions.png │ ├── 6-audio-filters.png │ ├── 9-music-channels.png │ └── 11-biquad-queue-previous.png ├── .gitattributes ├── .markdownlint.json ├── docker-compose.yml ├── vendor └── @mirasaki │ └── logger │ ├── package.json │ ├── README.md │ └── index.js ├── .github ├── workflows │ ├── docker-image.yml │ ├── release.yml │ ├── test.yml │ ├── publish.yml │ └── codeql.yml ├── dependabot.yml ├── FUNDING.yml └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── .dockerignore ├── Dockerfile ├── release.config.js ├── .devcontainer └── Dockerfile ├── LICENSE ├── .env.example ├── .gitignore ├── config.example.js ├── package.json ├── CHANGELOG.md └── CODE_OF_CONDUCT.md /src/context-menus/user/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/context-menus/message/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | worker: node src/index.js 2 | 3 | -------------------------------------------------------------------------------- /assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ariastarcos/mirasaki-music-bot/HEAD/assets/logo.png -------------------------------------------------------------------------------- /src/listeners/interaction/pingInteraction.js: -------------------------------------------------------------------------------- 1 | module.exports = (client, interaction) => { 2 | // ... 3 | }; 4 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | *.{cmd,[cC][mM][dD]} text eol=crlf 3 | *.{bat,[bB][aA][tT]} text eol=crlf 4 | -------------------------------------------------------------------------------- /assets/showcase/13-seek.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ariastarcos/mirasaki-music-bot/HEAD/assets/showcase/13-seek.png -------------------------------------------------------------------------------- /assets/showcase/4-vote-skip.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ariastarcos/mirasaki-music-bot/HEAD/assets/showcase/4-vote-skip.png -------------------------------------------------------------------------------- /assets/showcase/5-skip-to.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ariastarcos/mirasaki-music-bot/HEAD/assets/showcase/5-skip-to.png -------------------------------------------------------------------------------- /assets/showcase/7-dj-roles.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ariastarcos/mirasaki-music-bot/HEAD/assets/showcase/7-dj-roles.png -------------------------------------------------------------------------------- /assets/showcase/8-equalizer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ariastarcos/mirasaki-music-bot/HEAD/assets/showcase/8-equalizer.png -------------------------------------------------------------------------------- /assets/showcase/thumbnail.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ariastarcos/mirasaki-music-bot/HEAD/assets/showcase/thumbnail.gif -------------------------------------------------------------------------------- /assets/showcase/0-24-7-music.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ariastarcos/mirasaki-music-bot/HEAD/assets/showcase/0-24-7-music.png -------------------------------------------------------------------------------- /assets/showcase/12-save-song.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ariastarcos/mirasaki-music-bot/HEAD/assets/showcase/12-save-song.png -------------------------------------------------------------------------------- /assets/showcase/2-now-playing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ariastarcos/mirasaki-music-bot/HEAD/assets/showcase/2-now-playing.png -------------------------------------------------------------------------------- /src/config/emojis.json: -------------------------------------------------------------------------------- 1 | { 2 | "success": "☑️", 3 | "error": "❌", 4 | "wait": "⏳", 5 | "info": "ℹ", 6 | "separator": "•" 7 | } -------------------------------------------------------------------------------- /assets/showcase/1-shuffle-queue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ariastarcos/mirasaki-music-bot/HEAD/assets/showcase/1-shuffle-queue.png -------------------------------------------------------------------------------- /assets/showcase/10-repeat-mode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ariastarcos/mirasaki-music-bot/HEAD/assets/showcase/10-repeat-mode.png -------------------------------------------------------------------------------- /assets/showcase/3-play-sessions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ariastarcos/mirasaki-music-bot/HEAD/assets/showcase/3-play-sessions.png -------------------------------------------------------------------------------- /assets/showcase/6-audio-filters.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ariastarcos/mirasaki-music-bot/HEAD/assets/showcase/6-audio-filters.png -------------------------------------------------------------------------------- /assets/showcase/9-music-channels.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ariastarcos/mirasaki-music-bot/HEAD/assets/showcase/9-music-channels.png -------------------------------------------------------------------------------- /src/config/colors.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "#ffffff", 3 | "invisible": "#36393f", 4 | "success": "#00b105", 5 | "error": "#d91d1d" 6 | } 7 | -------------------------------------------------------------------------------- /assets/showcase/11-biquad-queue-previous.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ariastarcos/mirasaki-music-bot/HEAD/assets/showcase/11-biquad-queue-previous.png -------------------------------------------------------------------------------- /.markdownlint.json: -------------------------------------------------------------------------------- 1 | { 2 | "line-length": false, 3 | "no-inline-html": { 4 | "allowed_elements": [ 5 | "div", 6 | "h2", 7 | "h3", 8 | "h4" 9 | ] 10 | } 11 | } -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | client: 3 | build: . 4 | restart: unless-stopped 5 | env_file: 6 | - .env 7 | environment: 8 | - NODE_ENV=production 9 | volumes: 10 | - ./config.js:/app/config.js 11 | - ./mirasaki-music-bot.db:/app/mirasaki-music-bot.db -------------------------------------------------------------------------------- /vendor/@mirasaki/logger/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@mirasaki/logger", 3 | "version": "1.0.5", 4 | "description": "[removed] My first NPM package with logging functions I use in most of my personal projects.", 5 | "main": "index.js", 6 | "dependencies": { 7 | "chalk": "^4.1.2", 8 | "moment": "^2.29.3" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.github/workflows/docker-image.yml: -------------------------------------------------------------------------------- 1 | name: Docker Image CI 2 | 3 | on: 4 | pull_request: 5 | branches: [ "main" ] 6 | 7 | jobs: 8 | 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - name: Build the Docker image 14 | run: docker build . --file Dockerfile --tag mirasaki-music-bot:$(date +%s) 15 | -------------------------------------------------------------------------------- /src/commands/.sample.js: -------------------------------------------------------------------------------- 1 | // JavaScript files that start with the "." character 2 | // are ignored by our command file handler 3 | const { ChatInputCommand } = require('../classes/Commands'); 4 | 5 | // Windows (ctrl+space) for auto-complete IntelliSense options 6 | module.exports = new ChatInputCommand({ 7 | run: async (client, interaction) => { 8 | // ... 9 | } 10 | }); 11 | -------------------------------------------------------------------------------- /src/interactions/buttons/.index.js: -------------------------------------------------------------------------------- 1 | // JavaScript files that start with the "." character 2 | // are ignored by our command file handler 3 | 4 | const { ComponentCommand } = require('../../classes/Commands'); 5 | 6 | // Windows (ctrl+space) for auto-complete IntelliSense options 7 | module.exports = new ComponentCommand({ 8 | run: async (client, interaction) => { 9 | 10 | } 11 | }); 12 | -------------------------------------------------------------------------------- /src/listeners/guild/guildDelete.js: -------------------------------------------------------------------------------- 1 | const logger = require('@mirasaki/logger'); 2 | const chalk = require('chalk'); 3 | 4 | module.exports = (client, guild) => { 5 | // Always check to make sure the guild is available 6 | if (!guild?.available) return; 7 | // Logging the event to our console 8 | logger.success(`${ chalk.redBright('[GUILD REMOVE]') } ${ guild.name } has removed the bot!`); 9 | }; 10 | -------------------------------------------------------------------------------- /src/listeners/guild/guildCreate.js: -------------------------------------------------------------------------------- 1 | const logger = require('@mirasaki/logger'); 2 | const chalk = require('chalk'); 3 | 4 | module.exports = (client, guild) => { 5 | // Always check to make sure the guild is available 6 | if (!guild?.available) return; 7 | 8 | // Logging the event to our console 9 | logger.success(`${ chalk.greenBright('[GUILD JOIN]') } ${ guild.name } has added the bot! Members: ${ guild.memberCount }`); 10 | }; 11 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | # Dispatch manually 5 | workflow_dispatch: 6 | 7 | jobs: 8 | release: 9 | permissions: 10 | contents: write 11 | issues: write 12 | pull-requests: write 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v2 16 | - uses: actions/setup-node@v2 17 | with: 18 | node-version: 20 19 | - run: npm ci 20 | - run: npx semantic-release 21 | env: 22 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: 4 | pull_request: 5 | branches: [ main ] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | node-version: [18.x, 19.x] 13 | steps: 14 | - uses: actions/checkout@v3 15 | - name: Use Node.js ${{ matrix.node-version }} 16 | uses: actions/setup-node@v3 17 | with: 18 | node-version: ${{ matrix.node-version }} 19 | - run: npm ci 20 | - run: npm run build --if-present 21 | - run: npm test -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | target-branch: dev 11 | schedule: 12 | interval: "weekly" 13 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Config 2 | .env 3 | *.env 4 | Procfile 5 | release.config.js 6 | 7 | # NPM & dependencies 8 | node_modules 9 | npm-debug.log 10 | .cache 11 | 12 | # Ignoring all markdown files 13 | *.md 14 | 15 | # Github 16 | .git 17 | .github 18 | .gitattributes 19 | .gitignore 20 | .gitkeep 21 | 22 | # Docker 23 | Dockerfile 24 | *.Dockerfile 25 | docker-compose.yml 26 | 27 | # Linter files 28 | .markdownlint.json 29 | .eslintrc.json 30 | 31 | # Etc 32 | LICENSE 33 | linter-output.txt 34 | .env.example 35 | # .config.example.js used as fallback in test script 36 | tsconfig.json # Only used to generate types in development 37 | typings.d.ts # exported typings file -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:19-slim 2 | 3 | # Create app/working/bot directory 4 | RUN mkdir -p /app 5 | WORKDIR /app 6 | 7 | # Before installing ytdl mod, install ffmpeg 8 | RUN apt-get update && apt-get install 'ffmpeg' -y --no-install-recommends \ 9 | && apt-get clean \ 10 | && rm -rf /var/lib/apt/lists/* 11 | 12 | # Install app production dependencies 13 | # A wildcard is used to ensure both package.json AND package-lock.json are copied 14 | # where available (npm@5+) 15 | COPY package*.json ./ 16 | RUN npm ci --omit=dev 17 | 18 | # Bundle app source 19 | COPY . ./ 20 | 21 | # Optional API/Backend port 22 | EXPOSE 3000 23 | 24 | # Run the start command 25 | CMD [ "npm", "run", "start" ] 26 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [mirasaki] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: mirasaki 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: ['https://paypal.me/mirasaki'] 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '[Feature] - ' 5 | labels: 'enhancement' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /src/commands/developer/test.js: -------------------------------------------------------------------------------- 1 | const { ApplicationCommandOptionType } = require('discord.js'); 2 | const { ChatInputCommand } = require('../../classes/Commands'); 3 | 4 | module.exports = new ChatInputCommand({ 5 | enabled: process.env.NODE_ENV !== 'production', 6 | permLevel: 'Developer', 7 | data: { 8 | description: 'Test command for the developers', 9 | options: [ 10 | { 11 | name: 'value', 12 | description: 'input', 13 | type: ApplicationCommandOptionType.String, 14 | required: true 15 | } 16 | ], 17 | // Unavailable to non-admins in guilds 18 | default_member_permissions: 0 19 | }, 20 | 21 | run: (client, interaction) => { 22 | // ... 23 | } 24 | }); 25 | 26 | -------------------------------------------------------------------------------- /src/interactions/autocomplete/audio-filter.js: -------------------------------------------------------------------------------- 1 | const { ComponentCommand } = require('../../classes/Commands'); 2 | const { audioFilters } = require('../../modules/music'); 3 | const allAudioFilters = audioFilters(); 4 | 5 | module.exports = new ComponentCommand({ run: async (client, interaction, query) => { 6 | if (!query) return allAudioFilters.slice(0, 25).map((e) => ({ 7 | name: e, 8 | value: e.toLowerCase() 9 | })); 10 | 11 | // Format tracks for Discord API 12 | return allAudioFilters 13 | .filter((e) => e.toLowerCase().indexOf(query.toLowerCase()) >= 0) 14 | .slice(0, 25) 15 | .map((e) => ({ 16 | name: e, 17 | value: e.toLowerCase() 18 | })) 19 | .sort((a, b) => a.name.localeCompare(b.name)); 20 | } }); 21 | -------------------------------------------------------------------------------- /release.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-template-curly-in-string */ 2 | const config = { 3 | branches: [ 'main' ], 4 | plugins: [ 5 | '@semantic-release/commit-analyzer', 6 | '@semantic-release/release-notes-generator', 7 | '@semantic-release/changelog', 8 | [ '@semantic-release/npm', { 'npmPublish': false } ], 9 | [ 10 | '@semantic-release/git', 11 | { 12 | 'assets': [ 13 | 'CHANGELOG.md', 14 | 'package.json', 15 | 'package-lock.json', 16 | 'npm-shrinkwrap.json' 17 | ], 18 | 'message': 'chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}' 19 | } 20 | ], 21 | '@semantic-release/github' 22 | ] 23 | }; 24 | 25 | module.exports = config; 26 | -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:19 2 | 3 | # Create app/working/bot directory 4 | RUN mkdir -p /app 5 | WORKDIR /app 6 | 7 | # Before installing ytdl mod, install ffmpeg 8 | RUN apt-get update && apt-get install 'ffmpeg' -y --no-install-recommends \ 9 | && apt-get clean \ 10 | && rm -rf /var/lib/apt/lists/* 11 | 12 | # Install app development dependencies 13 | # A wildcard is used to ensure both package.json AND package-lock.json are copied 14 | # where available (npm@5+) 15 | COPY package*.json ./ 16 | RUN npm install --include=dev 17 | 18 | # Bundle app source 19 | COPY . ./ 20 | 21 | # API port 22 | EXPOSE 3000 23 | 24 | # Show current folder structure in logs 25 | # RUN ls -al -R 26 | 27 | # Run the start command 28 | CMD [ "npx", "nodemon", "--inspect=0.0.0.0:9229", "src/index.js" ] 29 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG] - " 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Version [e.g. 22] 29 | 30 | **Additional context** 31 | Add any other context about the problem here. 32 | -------------------------------------------------------------------------------- /src/interactions/buttons/eval/declineEval.js: -------------------------------------------------------------------------------- 1 | const { ComponentCommand } = require('../../../classes/Commands'); 2 | const { DECLINE_EVAL_CODE_EXECUTION } = require('../../../constants'); 3 | 4 | module.exports = new ComponentCommand({ 5 | // Overwriting the default file name with our owm custom component id 6 | data: { name: DECLINE_EVAL_CODE_EXECUTION }, 7 | 8 | run: async (client, interaction) => { 9 | const { member, message } = interaction; 10 | const { emojis } = client.container; 11 | 12 | // Reply to button interaction 13 | interaction.reply({ content: `${ emojis.error } ${ member }, cancelling code execution.` }); 14 | 15 | // Update the original message 16 | await message.edit({ 17 | content: `${ emojis.error } ${ member }, this code block has been discarded, unevaluated.`, 18 | components: [] 19 | }); 20 | } 21 | }); 22 | -------------------------------------------------------------------------------- /src/commands/system/permlevel.js: -------------------------------------------------------------------------------- 1 | const { ChatInputCommand } = require('../../classes/Commands'); 2 | const { permConfig } = require('../../handlers/permissions'); 3 | 4 | module.exports = new ChatInputCommand({ 5 | global: true, 6 | // Default member type cooldown 7 | cooldown: { 8 | usages: 1, 9 | duration: 10 10 | }, 11 | data: { description: 'Display your bot permission level' }, 12 | 13 | run: async (client, interaction) => { 14 | // Destructure 15 | const { member } = interaction; 16 | const { emojis } = client.container; 17 | 18 | // Definition/Variables 19 | const memberPermLevelName = permConfig 20 | .find(({ level }) => level === member.permLevel).name; 21 | 22 | // User feedback 23 | interaction.reply({ content: `${ emojis.success } ${ member }, your permission level is **${ member.permLevel } | ${ memberPermLevelName }**` }); 24 | } 25 | }); 26 | -------------------------------------------------------------------------------- /src/commands/music-dj/replay.js: -------------------------------------------------------------------------------- 1 | const { useQueue } = require('discord-player'); 2 | const { ChatInputCommand } = require('../../classes/Commands'); 3 | const { requireSessionConditions } = require('../../modules/music'); 4 | 5 | module.exports = new ChatInputCommand({ 6 | global: true, 7 | data: { description: 'Replay the current track' }, 8 | run: async (client, interaction) => { 9 | const { emojis } = client.container; 10 | const { member, guild } = interaction; 11 | 12 | // Check state 13 | if (!requireSessionConditions(interaction, true)) return; 14 | 15 | try { 16 | // Rewind to 0:00 17 | const queue = useQueue(guild.id); 18 | queue.node.seek(0); 19 | await interaction.reply(`${ emojis.success } ${ member }, replaying current track!`); 20 | } 21 | catch (e) { 22 | interaction.reply(`${ emojis.error } ${ member }, something went wrong:\n\n${ e.message }`); 23 | } 24 | } 25 | }); 26 | -------------------------------------------------------------------------------- /src/commands/music-dj/shuffle-queue.js: -------------------------------------------------------------------------------- 1 | const { useQueue } = require('discord-player'); 2 | const { ChatInputCommand } = require('../../classes/Commands'); 3 | const { requireSessionConditions, queueEmbedResponse } = require('../../modules/music'); 4 | 5 | module.exports = new ChatInputCommand({ 6 | global: true, 7 | data: { description: 'Shuffle the current queue' }, 8 | run: async (client, interaction) => { 9 | const { emojis } = client.container; 10 | const { member } = interaction; 11 | 12 | // Check state 13 | if (!requireSessionConditions(interaction, true)) return; 14 | 15 | try { 16 | const queue = useQueue(interaction.guild.id); 17 | queue.tracks.shuffle(); 18 | 19 | // Show queue, interactive 20 | queueEmbedResponse(interaction, queue); 21 | } 22 | catch (e) { 23 | interaction.reply(`${ emojis.error } ${ member }, something went wrong:\n\n${ e.message }`); 24 | } 25 | } 26 | }); 27 | -------------------------------------------------------------------------------- /src/listeners/client/ready.js: -------------------------------------------------------------------------------- 1 | const logger = require('@mirasaki/logger'); 2 | const chalk = require('chalk'); 3 | 4 | module.exports = (client) => { 5 | // Logging our process uptime to the developer 6 | const upTimeStr = chalk.yellow(`${ Math.floor(process.uptime()) || 1 } second(s)`); 7 | 8 | logger.success(`Client logged in as ${ 9 | chalk.cyanBright(client.user.username) 10 | }${ 11 | chalk.grey(`#${ client.user.discriminator }`) 12 | } after ${ upTimeStr }`); 13 | 14 | // Calculating the membercount 15 | const memberCount = client.guilds.cache.reduce( 16 | (previousValue, currentValue) => previousValue += currentValue.memberCount, 0 17 | ).toLocaleString('en-US'); 18 | 19 | // Getting the server count 20 | const serverCount = (client.guilds.cache.size).toLocaleString('en-US'); 21 | 22 | // Logging counts to developers 23 | logger.info(`Ready to serve ${ memberCount } members across ${ serverCount } servers!`); 24 | }; 25 | -------------------------------------------------------------------------------- /src/commands/music/queue.js: -------------------------------------------------------------------------------- 1 | const { useQueue } = require('discord-player'); 2 | const { ChatInputCommand } = require('../../classes/Commands'); 3 | const { queueEmbedResponse, requireSessionConditions } = require('../../modules/music'); 4 | 5 | module.exports = new ChatInputCommand({ 6 | global: true, 7 | data: { description: 'Display the current queue' }, 8 | run: async (client, interaction) => { 9 | const { emojis } = client.container; 10 | const { member, guild } = interaction; 11 | 12 | // Check conditions/state 13 | if (!requireSessionConditions(interaction, true, false, false)) return; 14 | 15 | // Check has queue 16 | const queue = useQueue(guild.id); 17 | if (!queue) { 18 | interaction.reply({ content: `${ emojis.error } ${ member }, queue is currently empty. You should totally \`/play\` something - but that's just my opinion.` }); 19 | return; 20 | } 21 | 22 | // Show queue, interactive 23 | queueEmbedResponse(interaction, queue); 24 | } 25 | }); 26 | -------------------------------------------------------------------------------- /src/commands/music/history.js: -------------------------------------------------------------------------------- 1 | const { useHistory } = require('discord-player'); 2 | const { ChatInputCommand } = require('../../classes/Commands'); 3 | const { queueEmbedResponse, requireSessionConditions } = require('../../modules/music'); 4 | 5 | module.exports = new ChatInputCommand({ 6 | global: true, 7 | data: { description: 'Display the current history' }, 8 | run: async (client, interaction) => { 9 | const { emojis } = client.container; 10 | const { member, guild } = interaction; 11 | 12 | // Check conditions/state 13 | if (!requireSessionConditions(interaction, true, false, false)) return; 14 | 15 | // Check has history 16 | const history = useHistory(guild.id); 17 | if (!history) { 18 | interaction.reply({ content: `${ emojis.error } ${ member }, history is currently empty. You should totally \`/play\` something - but that's just my opinion.` }); 19 | return; 20 | } 21 | 22 | // Show history, interactive 23 | queueEmbedResponse(interaction, history, 'History'); 24 | } 25 | }); 26 | -------------------------------------------------------------------------------- /src/commands/music-dj/stop.js: -------------------------------------------------------------------------------- 1 | const { useQueue } = require('discord-player'); 2 | const { ChatInputCommand } = require('../../classes/Commands'); 3 | const { requireSessionConditions } = require('../../modules/music'); 4 | 5 | module.exports = new ChatInputCommand({ 6 | global: true, 7 | aliases: [ 8 | 'leave', 9 | 'disconnect', 10 | 'f-off' 11 | ], 12 | data: { description: 'Stop the music player and leave the voice channel' }, 13 | run: async (client, interaction) => { 14 | const { emojis } = client.container; 15 | const { guild, member } = interaction; 16 | 17 | // Check state 18 | if (!requireSessionConditions(interaction, true)) return; 19 | 20 | try { 21 | const queue = useQueue(guild.id); 22 | queue.delete(); 23 | await interaction.reply(`${ emojis.success } ${ member }, the queue has been cleared and the player was disconnected.`); 24 | } 25 | catch (e) { 26 | interaction.reply(`${ emojis.error } ${ member }, something went wrong:\n\n${ e.message }`); 27 | } 28 | } 29 | }); 30 | -------------------------------------------------------------------------------- /src/commands/system/invite.js: -------------------------------------------------------------------------------- 1 | const { getBotInviteLink, colorResolver } = require('../../util'); 2 | const { ChatInputCommand } = require('../../classes/Commands'); 3 | 4 | // Not really needed with the release of the button on bot profiles in Discord 5 | // and soon, Bot/App Discovery 6 | 7 | module.exports = new ChatInputCommand({ 8 | global: true, 9 | cooldown: { 10 | // Use guild/server cooldown instead of default member 11 | type: 'guild', 12 | usages: 3, 13 | duration: 10 14 | }, 15 | clientPerms: [ 'EmbedLinks' ], 16 | data: { description: 'Add the bot to your server!' }, 17 | 18 | run: async (client, interaction) => { 19 | // Replying to the interaction with the bot-invite link 20 | // Not a top-level static variable to take /reload 21 | // changes into consideration 22 | interaction.reply({ embeds: [ 23 | { 24 | color: colorResolver(client.container.colors.invisible), 25 | description: `[Add me to your server](${ getBotInviteLink(client) })` 26 | } 27 | ] }); 28 | } 29 | }); 30 | -------------------------------------------------------------------------------- /src/interactions/autocomplete/query-lyrics.js: -------------------------------------------------------------------------------- 1 | const { useMainPlayer } = require('discord-player'); 2 | const { ComponentCommand } = require('../../classes/Commands'); 3 | 4 | module.exports = new ComponentCommand({ run: async (client, interaction, query) => { 5 | const player = useMainPlayer(); 6 | if (!query) return []; 7 | const result = await player.search(query); 8 | 9 | const returnData = []; 10 | // Explicit ignore playlist 11 | 12 | // Format tracks for Discord API 13 | result.tracks 14 | .slice(0, 25) 15 | .forEach((track) => { 16 | let name = `${ track.title } by ${ track.author ?? 'Unknown' } (${ track.duration ?? 'n/a' })`; 17 | if (name.length > 100) name = `${ name.slice(0, 97) }...`; 18 | return returnData.push({ 19 | name, 20 | value: `${ track.author ? track.author + ' ' : '' }${ track.title }` 21 | .toLowerCase() 22 | .replace(/(lyrics|extended|topic|vevo|video|official|music|audio)/g, '') 23 | .slice(0, 100) 24 | }); 25 | }); 26 | return returnData; 27 | } }); 28 | -------------------------------------------------------------------------------- /src/commands/developer/deploy.js: -------------------------------------------------------------------------------- 1 | const { stripIndents } = require('common-tags'); 2 | const { ChatInputCommand } = require('../../classes/Commands'); 3 | const { refreshSlashCommandData } = require('../../handlers/commands'); 4 | 5 | module.exports = new ChatInputCommand({ 6 | permLevel: 'Developer', 7 | data: { description: 'Re-deploy ApplicationCommand API data' }, 8 | run: async (client, interaction) => { 9 | const { member } = interaction; 10 | const { emojis } = client.container; 11 | 12 | // Calling our command handler function 13 | refreshSlashCommandData(client); 14 | 15 | // Sending user feedback 16 | interaction.reply({ content: stripIndents` 17 | ${ emojis.success } ${ member }, [ApplicationCommandData](https://discord.com/developers/docs/interactions/application-commands#application-command-object-application-command-structure "ApplicationCommandData on discord.com/developers") has been refreshed. 18 | ${ emojis.wait } - changes to global commands can take up to an hour to take effect... 19 | ` }); 20 | } 21 | }); 22 | -------------------------------------------------------------------------------- /src/commands/music-dj/pause.js: -------------------------------------------------------------------------------- 1 | const { usePlayer } = require('discord-player'); 2 | const { ChatInputCommand } = require('../../classes/Commands'); 3 | const { requireSessionConditions } = require('../../modules/music'); 4 | 5 | module.exports = new ChatInputCommand({ 6 | global: true, 7 | aliases: [ 'resume' ], 8 | data: { description: 'Pause/resume the playback, this is a toggle' }, 9 | run: async (client, interaction) => { 10 | const { emojis } = client.container; 11 | const { member, guild } = interaction; 12 | 13 | // Check state 14 | if (!requireSessionConditions(interaction, true)) return; 15 | 16 | try { 17 | const guildPlayerNode = usePlayer(guild.id); 18 | const newPauseState = !guildPlayerNode.isPaused(); 19 | guildPlayerNode.setPaused(newPauseState); 20 | await interaction.reply(`${ emojis.success } ${ member }, ${ newPauseState ? 'paused' : 'resumed' } playback`); 21 | } 22 | catch (e) { 23 | interaction.reply(`${ emojis.error } ${ member }, something went wrong:\n\n${ e.message }`); 24 | } 25 | } 26 | }); 27 | -------------------------------------------------------------------------------- /src/commands/music-dj/clear-queue.js: -------------------------------------------------------------------------------- 1 | const { useQueue } = require('discord-player'); 2 | const { ChatInputCommand } = require('../../classes/Commands'); 3 | const { requireSessionConditions } = require('../../modules/music'); 4 | 5 | module.exports = new ChatInputCommand({ 6 | global: true, 7 | data: { description: 'Clear the entire queue' }, 8 | run: async (client, interaction) => { 9 | const { emojis } = client.container; 10 | const { member, guild } = interaction; 11 | 12 | // Check state 13 | if (!requireSessionConditions(interaction, true)) return; 14 | 15 | try { 16 | const queue = useQueue(guild.id); 17 | if (!queue) { 18 | interaction.reply({ content: `${ emojis.error } ${ member }, no music is being played - this command has been cancelled` }); 19 | return; 20 | } 21 | queue.clear(); 22 | await interaction.reply({ content: `${ emojis.success } ${ member }, the queue has been cleared.` }); 23 | } 24 | catch (e) { 25 | interaction.reply(`${ emojis.error } ${ member }, something went wrong:\n\n${ e.message }`); 26 | } 27 | } 28 | }); 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Richard Hillebrand 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /src/interactions/autocomplete/query.js: -------------------------------------------------------------------------------- 1 | const { useMainPlayer } = require('discord-player'); 2 | const { ComponentCommand } = require('../../classes/Commands'); 3 | 4 | module.exports = new ComponentCommand({ run: async (client, interaction, query) => { 5 | const player = useMainPlayer(); 6 | if (!query) return []; 7 | const result = await player.search(query); 8 | 9 | // Identify playlists 10 | const returnData = []; 11 | if (result.playlist) { 12 | returnData.push({ 13 | name: 'Playlist | ' + result.playlist.title, value: query 14 | }); 15 | } 16 | 17 | // Format tracks for Discord API 18 | result.tracks 19 | .slice(0, 25) 20 | .forEach((track) => { 21 | let name = `${ track.title } | ${ track.author ?? 'Unknown' } (${ track.duration ?? 'n/a' })`; 22 | if (name.length > 100) name = `${ name.slice(0, 97) }...`; 23 | // Throws API error if we don't try and remove any query params 24 | let url = track.url; 25 | if (url.length > 100) url = url.slice(0, 100); 26 | return returnData.push({ 27 | name, 28 | value: url 29 | }); 30 | }); 31 | return returnData; 32 | } }); 33 | -------------------------------------------------------------------------------- /src/commands/developer/set-name.js: -------------------------------------------------------------------------------- 1 | const { ApplicationCommandOptionType } = require('discord.js'); 2 | const { ChatInputCommand } = require('../../classes/Commands'); 3 | 4 | module.exports = new ChatInputCommand({ 5 | permLevel: 'Developer', 6 | // Global rate limit of 2 requests per hour 7 | cooldown: { 8 | usages: 2, 9 | duration: 3600, 10 | type: 'global' 11 | }, 12 | data: { 13 | description: 'Update the bot\'s username', 14 | options: [ 15 | { 16 | type: ApplicationCommandOptionType.String, 17 | name: 'name', 18 | description: 'The bot\'s new username', 19 | required: true 20 | } 21 | ] 22 | }, 23 | 24 | run: async (client, interaction) => { 25 | const { member, options } = interaction; 26 | const { emojis } = client.container; 27 | const name = options.getString('name'); 28 | client.user 29 | .setUsername(name) 30 | .then(() => interaction.reply(`${ emojis.success } ${ member }, name was successfully updated!`)) 31 | .catch((err) => interaction.reply(`${ emojis.error } ${ member }, couldn't update bot's username:\n\n${ err.message }`)); 32 | } 33 | }); 34 | -------------------------------------------------------------------------------- /src/commands/music-dj/play-previous-song.js: -------------------------------------------------------------------------------- 1 | const { useHistory } = require('discord-player'); 2 | const { ChatInputCommand } = require('../../classes/Commands'); 3 | const { requireSessionConditions } = require('../../modules/music'); 4 | 5 | module.exports = new ChatInputCommand({ 6 | global: true, 7 | data: { description: 'Play the previous song right away' }, 8 | run: async (client, interaction) => { 9 | const { emojis } = client.container; 10 | const { member, guild } = interaction; 11 | 12 | // Check state 13 | if (!requireSessionConditions(interaction, true)) return; 14 | 15 | try { 16 | // No prev track 17 | const history = useHistory(guild.id); 18 | if (!history?.previousTrack) { 19 | interaction.reply({ content: `${ emojis.error } ${ member }, no tracks in history - this command has been cancelled` }); 20 | return; 21 | } 22 | 23 | // Ok 24 | await history.previous(); 25 | await interaction.reply({ content: `:arrow_backward: ${ member }, playing previous song` }); 26 | } 27 | catch (e) { 28 | interaction.reply(`${ emojis.error } ${ member }, something went wrong:\n\n${ e.message }`); 29 | } 30 | } 31 | }); 32 | -------------------------------------------------------------------------------- /src/commands/developer/eval.js: -------------------------------------------------------------------------------- 1 | const { 2 | ModalBuilder, TextInputBuilder, ActionRowBuilder 3 | } = require('@discordjs/builders'); 4 | const { TextInputStyle } = require('discord.js'); 5 | const { ChatInputCommand } = require('../../classes/Commands'); 6 | // Unique identifiers for our components' customIds 7 | const { EVAL_CODE_MODAL, EVAL_CODE_INPUT } = require('../../constants'); 8 | 9 | module.exports = new ChatInputCommand({ 10 | enabled: process.env.NODE_ENV !== 'production', 11 | permLevel: 'Developer', 12 | clientPerms: [ 'EmbedLinks', 'AttachFiles' ], 13 | data: { description: 'Evaluate arbitrary JavaScript code' }, 14 | run: async (client, interaction) => { 15 | // Code Modal 16 | const codeModal = new ModalBuilder() 17 | .setCustomId(EVAL_CODE_MODAL) 18 | .setTitle('JavaScript code'); 19 | 20 | // Code Input 21 | const codeInput = new TextInputBuilder() 22 | .setCustomId(EVAL_CODE_INPUT) 23 | .setLabel('The JavaScript code to evaluate') 24 | .setStyle(TextInputStyle.Paragraph); 25 | 26 | // Modal Rows 27 | const codeInputRow = new ActionRowBuilder().addComponents(codeInput); 28 | 29 | // Adding the components to our modal 30 | codeModal.addComponents(codeInputRow); 31 | 32 | // Showing the modal to the user 33 | await interaction.showModal(codeModal); 34 | } 35 | }); 36 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # ------------------------- Required variables ------------------------- # 2 | NODE_ENV=production 3 | DISCORD_BOT_TOKEN= 4 | DISCORD_CLIENT_ID= 5 | 6 | # ------------------------- 7 | # Everything else is optional - continue if you know what you're doing 8 | # ------------------------- 9 | 10 | # If you need to define a custom path for the ffmpeg binary, you can do so here. 11 | # FFMPEG_PATH= 12 | 13 | # ------------------------- Debug variables ------------------------- # 14 | DEBUG_ENABLED=false 15 | DEBUG_SLASH_COMMAND_API_DATA=false 16 | DEBUG_INTERACTIONS=false 17 | DEBUG_AUTOCOMPLETE_RESPONSE_TIME=true 18 | DEBUG_MODAL_SUBMIT_RESPONSE_TIME=true 19 | DEBUG_COMMAND_THROTTLING=false 20 | 21 | # ------------------------- API Application Command Data ------------------------- # 22 | REFRESH_SLASH_COMMAND_API_DATA=true 23 | CLEAR_SLASH_COMMAND_API_DATA=false 24 | 25 | # ------------------------- API / Server ------------------------- # 26 | USE_API=false 27 | PORT=3000 28 | 29 | # ------------------------- File Paths ------------------------- # 30 | CHAT_INPUT_COMMAND_DIR=src/commands 31 | CONTEXT_MENU_COMMAND_DIR=src/context-menus 32 | AUTO_COMPLETE_INTERACTION_DIR=src/interactions/autocomplete 33 | BUTTON_INTERACTION_DIR=src/interactions/buttons 34 | MODAL_INTERACTION_DIR=src/interactions/modals 35 | SELECT_MENU_INTERACTION_DIR=src/interactions/select-menus 36 | 37 | -------------------------------------------------------------------------------- /src/commands/developer/set-avatar.js: -------------------------------------------------------------------------------- 1 | const { ApplicationCommandOptionType } = require('discord.js'); 2 | const { ChatInputCommand } = require('../../classes/Commands'); 3 | 4 | module.exports = new ChatInputCommand({ 5 | permLevel: 'Developer', 6 | // Global rate limit of 5 requests per hour 7 | cooldown: { 8 | usages: 5, 9 | duration: 3600, 10 | type: 'global' 11 | }, 12 | data: { 13 | description: 'Update the bot\'s avatar', 14 | options: [ 15 | { 16 | type: ApplicationCommandOptionType.Attachment, 17 | name: 'avatar', 18 | description: 'The bot\'s new avatar', 19 | required: true 20 | } 21 | ] 22 | }, 23 | 24 | run: async (client, interaction) => { 25 | const { member, options } = interaction; 26 | const { emojis } = client.container; 27 | const attachment = options.getAttachment('avatar'); 28 | 29 | // Check content type 30 | if (!attachment.contentType.startsWith('image/')) { 31 | interaction.reply(`${ emojis.error } ${ member }, expected an image - you provided **\`${ attachment.contentType }\`** instead, this command has been cancelled`); 32 | return; 33 | } 34 | 35 | client.user 36 | .setAvatar(attachment.url) 37 | .then(() => interaction.reply(`${ emojis.success } ${ member }, avatar was successfully updated!`)) 38 | .catch((err) => interaction.reply(`${ emojis.error } ${ member }, couldn't update bot's avatar:\n\n${ err.message }`)); 39 | } 40 | }); 41 | -------------------------------------------------------------------------------- /src/commands/music-dj/skip.js: -------------------------------------------------------------------------------- 1 | const { usePlayer } = require('discord-player'); 2 | const { ChatInputCommand } = require('../../classes/Commands'); 3 | const { requireSessionConditions } = require('../../modules/music'); 4 | 5 | module.exports = new ChatInputCommand({ 6 | global: true, 7 | aliases: [ 'next' ], 8 | data: { description: 'Skip the currently playing song' }, 9 | run: async (client, interaction) => { 10 | const { emojis } = client.container; 11 | const { member } = interaction; 12 | 13 | // Check state 14 | if (!requireSessionConditions(interaction, true)) return; 15 | 16 | try { 17 | const guildPlayerNode = usePlayer(interaction.guild.id); 18 | // #requireVoiceSession doesn't check current track, 19 | // only session/player state 20 | const currentTrack = guildPlayerNode?.queue?.currentTrack; 21 | if (!currentTrack) { 22 | interaction.reply({ content: `${ emojis.error } ${ member }, no music is currently being played - this command has been cancelled` }); 23 | return; 24 | } 25 | const success = guildPlayerNode.skip(); 26 | await interaction.reply(success 27 | ? `${ emojis.success } ${ member }, skipped **\`${ currentTrack }\`**` 28 | : `${ emojis.error } ${ member }, something went wrong - couldn't skip current playing song`); 29 | } 30 | catch (e) { 31 | interaction.reply(`${ emojis.error } ${ member }, something went wrong:\n\n${ e.message }`); 32 | } 33 | } 34 | }); 35 | -------------------------------------------------------------------------------- /src/commands/music/now-playing.js: -------------------------------------------------------------------------------- 1 | const { useQueue } = require('discord-player'); 2 | const { ChatInputCommand } = require('../../classes/Commands'); 3 | const { nowPlayingEmbed, requireSessionConditions } = require('../../modules/music'); 4 | 5 | module.exports = new ChatInputCommand({ 6 | global: true, 7 | aliases: [ 'np' ], 8 | data: { description: 'Display detailed information on the song that is currently playing' }, 9 | run: async (client, interaction) => { 10 | const { emojis } = client.container; 11 | const { member, guild } = interaction; 12 | 13 | // Check conditions/state 14 | if (!requireSessionConditions(interaction, true, false, false)) return; 15 | 16 | try { 17 | const queue = useQueue(guild.id); 18 | if (!queue) { 19 | interaction.reply({ content: `${ emojis.error } ${ member }, queue is currently empty. You should totally \`/play\` something - but that's just my opinion.` }); 20 | return; 21 | } 22 | 23 | // Ok, display the queue! 24 | const { currentTrack } = queue; 25 | if (!currentTrack) { 26 | interaction.reply(`${ emojis.error } ${ member }, can't fetch information on currently displaying song - please try again later`); 27 | return; 28 | } 29 | 30 | const npEmbed = nowPlayingEmbed(queue); 31 | interaction.reply({ embeds: [ npEmbed ] }); 32 | } 33 | catch (e) { 34 | interaction.reply(`${ emojis.error } ${ member }, something went wrong:\n\n${ e.message }`); 35 | } 36 | } 37 | }); 38 | -------------------------------------------------------------------------------- /src/server/index.js: -------------------------------------------------------------------------------- 1 | // Require our shared environmental file as early as possible 2 | require('dotenv').config(); 3 | 4 | // Importing from packages 5 | const chalk = require('chalk'); 6 | const logger = require('@mirasaki/logger'); 7 | let express; 8 | 9 | // Try to import express 10 | try { 11 | express = require('express'); 12 | } 13 | catch (err) { 14 | logger.syserr('You have enabled "USE_API" in the .env file, but missing the "express" dependency, to address this - run the "npm install express" command. This is done to minimize dependencies as most users don\'t require the command API'); 15 | process.exit(1); 16 | } 17 | 18 | // Importing our routes 19 | const commandRoutes = require('./commands.routes'); 20 | 21 | // Destructure from our environmental file 22 | // Set our default port to 3000 if it's missing from environmental file 23 | const { NODE_ENV, PORT = 3000 } = process.env; 24 | 25 | /*** 26 | * Initialize our express app 27 | */ 28 | const app = express(); 29 | 30 | // Routes Middleware 31 | app.get('/', (req, res) => res.sendStatus(200)); 32 | app.use('/api/commands', commandRoutes); 33 | 34 | // Serving our generated client documentation as root 35 | app.use( 36 | '/', 37 | express.static('docs', { extensions: [ 'html' ] }) 38 | ); 39 | 40 | // Serving our static public files 41 | app.use(express.static('public')); 42 | 43 | // Actively listen for requests to our API/backend 44 | app.listen( 45 | PORT, 46 | logger.success(chalk.yellow.bold(`API running in ${ NODE_ENV }-mode on port ${ PORT }`)) 47 | ); 48 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | 6 | # GitHub recommends pinning actions to a commit SHA. 7 | # To get a newer version, you will need to update the SHA. 8 | # You can also reference a tag or branch, but the action may change without warning. 9 | 10 | name: Publish Docker image 11 | 12 | on: 13 | workflow_run: 14 | workflows: [Release] 15 | types: 16 | - completed 17 | 18 | jobs: 19 | push_to_registry: 20 | name: Push Docker image to Docker Hub 21 | runs-on: ubuntu-latest 22 | steps: 23 | - name: Check out the repo 24 | uses: actions/checkout@v3 25 | 26 | - name: Log in to Docker Hub 27 | uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 28 | with: 29 | username: ${{ secrets.DOCKER_USERNAME }} 30 | password: ${{ secrets.DOCKER_PASSWORD }} 31 | 32 | - name: Extract metadata (tags, labels) for Docker 33 | id: meta 34 | uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 35 | with: 36 | images: mirasaki/mirasaki-music-bot 37 | 38 | - name: Build and push Docker image 39 | uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc 40 | with: 41 | context: . 42 | push: true 43 | tags: ${{ steps.meta.outputs.tags }} 44 | labels: ${{ steps.meta.outputs.labels }} -------------------------------------------------------------------------------- /src/commands/system/support.js: -------------------------------------------------------------------------------- 1 | const { stripIndents } = require('common-tags'); 2 | const { ChatInputCommand } = require('../../classes/Commands'); 3 | const { colorResolver } = require('../../util'); 4 | 5 | module.exports = new ChatInputCommand({ 6 | global: true, 7 | cooldown: { 8 | // Use channel cooldown type instead of default member, 9 | type: 'channel', 10 | usages: 1, 11 | duration: 15 12 | }, 13 | clientPerms: [ 'EmbedLinks' ], 14 | data: { 15 | name: 'support', 16 | description: 'Get a link to this bot\'s support server' 17 | }, 18 | 19 | run: (client, interaction) => { 20 | interaction.reply({ embeds: [ 21 | { 22 | // Not passing an parameter to colorResolver 23 | // will fall-back to client.container.colors.main 24 | color: colorResolver(), 25 | author: { 26 | name: client.user.username, 27 | iconURL: client.user.avatarURL({ dynamic: true }) 28 | }, 29 | // Strip our indentation using common-tags 30 | description: stripIndents` 31 | [${ client.user.username } Support Server](${ client.container.config.supportServerInviteLink } "${ client.user.username } Support Server") 32 | 33 | **__Use this server for:__** 34 | \`\`\`diff 35 | + Any issues you need support with 36 | + Bug reports 37 | + Giving feedback 38 | + Feature requests & suggestions 39 | + Testing beta features & commands 40 | + Be notified of updates 41 | \`\`\` 42 | ` 43 | } 44 | ] }); 45 | } 46 | }); 47 | -------------------------------------------------------------------------------- /vendor/@mirasaki/logger/README.md: -------------------------------------------------------------------------------- 1 | # logger 2 | 3 | ## Require the package 4 | ```js 5 | const logger = require('@mirasaki/logger'); 6 | ``` 7 | 8 | ## Example Usage 9 | ```js 10 | logger.syslog('Start initializing...'); 11 | logger.syserr('Encountered error while trying to connect to database'); 12 | logger.success(`Client initialized after ${logger.getExecutionTime(process.hrtime())}`); 13 | logger.info('Fetching data from API...'); 14 | logger.debug(`Execution time: ${logger.getExecutionTime(process.hrtime())}`); 15 | logger.startLog('Application Command Data'); 16 | console.table( 17 | [ 18 | { 19 | name: 'help', 20 | description: 'Display general information' 21 | }, 22 | { 23 | name: 'start', 24 | description: 'Start task' 25 | } 26 | ] 27 | ); 28 | logger.endLog('Application Command Data'); 29 | ``` 30 | 31 | ### Outputs: 32 | ![](https://i.postimg.cc/BZLdKP0N/Windows-Terminal-5-KQj-Dfpp-KR.png "Preview unavailable") 33 | 34 | ## Example error logging 35 | ```js 36 | // catch (err) {} or .catch((err) => {}) 37 | logger.syserr(`An error has occurred while executing the /zz command`); 38 | logger.printErr(err); 39 | ``` 40 | 41 | ## Outputs: 42 | ![](https://i.postimg.cc/L5X4mf77/Code-8-Qs-Tu-WF23-Z.png "Preview unavailable") 43 | 44 | ## Functions 45 | - `syslog` 46 | - `syserr` 47 | - `success` 48 | - `info` 49 | - `debug` 50 | - `database` 51 | - `startLog` & `endLog` 52 | - `timestamp`: Returns the formatted timestamp for consistency 53 | - `getExecutionTime`: Pass `process.hrtime()` to get precise, formatted `timeSince` output 54 | - `printErr`: print an error object to the console in color -------------------------------------------------------------------------------- /src/interactions/autocomplete/command.js: -------------------------------------------------------------------------------- 1 | const { ApplicationCommandOptionType } = require('discord.js'); 2 | const { ComponentCommand } = require('../../classes/Commands'); 3 | const { isAppropriateCommandFilter } = require('../../handlers/commands'); 4 | 5 | module.exports = new ComponentCommand({ run: async (client, interaction, query) => { 6 | const { member } = interaction; 7 | // Filtering out unusable commands 8 | const { commands, contextMenus } = client.container; 9 | const workingCmdMap = commands.concat(contextMenus) 10 | .filter((cmd) => isAppropriateCommandFilter(member, cmd)); 11 | 12 | // Getting our search query's results 13 | const queryResult = workingCmdMap.filter( 14 | (cmd) => cmd.data.name.toLowerCase().indexOf(query) >= 0 15 | // Filtering matches by category 16 | || cmd.category.toLowerCase().indexOf(query) >= 0 17 | ); 18 | 19 | // Structuring our result for Discord's API 20 | return queryResult 21 | .map((cmd) => ({ 22 | name: cmd.data.name, value: cmd.data.name 23 | })) 24 | .sort((a, b) => a.name.localeCompare(b.name)); 25 | } }); 26 | 27 | // Can't spread in required option if directly exported 28 | // because the type will have been resolved 29 | const commandAutoCompleteOption = { 30 | type: ApplicationCommandOptionType.String, 31 | name: 'command', 32 | description: 'Command name or category', 33 | autocomplete: true, 34 | required: false 35 | }; 36 | module.exports.commandAutoCompleteOption = commandAutoCompleteOption; 37 | 38 | module.exports.requiredCommandAutoCompleteOption = { 39 | ...commandAutoCompleteOption, 40 | required: true 41 | }; 42 | -------------------------------------------------------------------------------- /src/interactions/select-menus/help.js: -------------------------------------------------------------------------------- 1 | const logger = require('@mirasaki/logger'); 2 | const { generateCommandInfoEmbed, generateCommandOverviewEmbed } = require('../../handlers/commands'); 3 | const { HELP_COMMAND_SELECT_MENU, HELP_SELECT_MENU_SEE_MORE_OPTIONS } = require('../../constants'); 4 | const { ComponentCommand } = require('../../classes/Commands'); 5 | 6 | module.exports = new ComponentCommand({ 7 | data: { name: HELP_COMMAND_SELECT_MENU }, 8 | 9 | run: async (client, interaction) => { 10 | const { 11 | commands, contextMenus, emojis 12 | } = client.container; 13 | const selectTargetValue = interaction.values[0]; 14 | const { member } = interaction; 15 | 16 | // Check max entries notifier - show default page 17 | if (selectTargetValue === HELP_SELECT_MENU_SEE_MORE_OPTIONS) { 18 | // Reply to the interaction with our embed 19 | interaction.update({ embeds: [ generateCommandOverviewEmbed(commands, interaction) ] }); 20 | return; 21 | } 22 | 23 | // Check valid command 24 | const clientCmd = commands.get(selectTargetValue) 25 | || contextMenus.get(selectTargetValue) 26 | || undefined; 27 | 28 | if (!clientCmd) { 29 | interaction.update({ 30 | content: `${ emojis.error } ${ member }, I couldn't find the command **\`/${ selectTargetValue }\`**`, 31 | ephemeral: true 32 | }); 33 | logger.syserr(`Unknown Select Menu Target Value received for Help Command Select Menu: "${ selectTargetValue }"`); 34 | return; 35 | } 36 | 37 | // Update the interaction with the requested command data 38 | const embedData = generateCommandInfoEmbed(clientCmd, interaction); 39 | 40 | interaction.update({ embeds: [ embedData ] }); 41 | } 42 | }); 43 | -------------------------------------------------------------------------------- /src/interactions/modals/evalSubmit.js: -------------------------------------------------------------------------------- 1 | const { stripIndents } = require('common-tags/lib'); 2 | const { ActionRowBuilder, ButtonBuilder } = require('discord.js'); 3 | const { ComponentCommand } = require('../../classes/Commands'); 4 | const { 5 | EVAL_CODE_INPUT, ACCEPT_EVAL_CODE_EXECUTION, DECLINE_EVAL_CODE_EXECUTION, EVAL_CODE_MODAL 6 | } = require('../../constants'); 7 | const { colorResolver } = require('../../util'); 8 | 9 | module.exports = new ComponentCommand({ 10 | // Overwriting the default file name with our owm custom component id 11 | data: { name: EVAL_CODE_MODAL }, 12 | run: async (client, interaction) => { 13 | const { member } = interaction; 14 | const { emojis } = client.container; 15 | 16 | // Defer our reply 17 | await interaction.deferReply(); 18 | 19 | // Code Input 20 | const codeInput = interaction.fields.getTextInputValue(EVAL_CODE_INPUT); 21 | 22 | // Verification prompt 23 | await interaction.editReply({ 24 | content: `${ emojis.wait } ${ member }, are you sure you want to evaluate the following code:`, 25 | embeds: [ 26 | { 27 | color: colorResolver(), 28 | description: stripIndents` 29 | \`\`\`js 30 | ${ codeInput } 31 | \`\`\` 32 | ` 33 | } 34 | ], 35 | components: [ 36 | new ActionRowBuilder().addComponents( 37 | new ButtonBuilder() 38 | .setCustomId(ACCEPT_EVAL_CODE_EXECUTION) 39 | .setLabel('Accept') 40 | .setStyle('Success'), 41 | new ButtonBuilder() 42 | .setCustomId(DECLINE_EVAL_CODE_EXECUTION) 43 | .setLabel('Decline') 44 | .setStyle('Danger') 45 | ) 46 | ] 47 | }); 48 | } 49 | }); 50 | -------------------------------------------------------------------------------- /src/commands/music-dj/queue-previous-song.js: -------------------------------------------------------------------------------- 1 | const { useQueue, useHistory } = require('discord-player'); 2 | const { ChatInputCommand } = require('../../classes/Commands'); 3 | const { requireSessionConditions } = require('../../modules/music'); 4 | const { ApplicationCommandOptionType } = require('discord.js'); 5 | 6 | module.exports = new ChatInputCommand({ 7 | global: true, 8 | data: { 9 | description: 'Add the previously played song to the queue, by default - adds the song to the front of the queue', 10 | options: [ 11 | { 12 | name: 'add-to-back-of-queue', 13 | description: 'Should the previous song be added to the back of queue instead of the front?', 14 | type: ApplicationCommandOptionType.Boolean, 15 | required: false 16 | } 17 | ] 18 | }, 19 | run: async (client, interaction) => { 20 | const { emojis } = client.container; 21 | const { 22 | member, guild, options 23 | } = interaction; 24 | const addToBackOfQueue = options.getBoolean('add-to-back-of-queue') ?? false; 25 | 26 | // Check state 27 | if (!requireSessionConditions(interaction, true)) return; 28 | 29 | try { 30 | // No prev track 31 | const history = useHistory(guild.id); 32 | const prevTrack = history?.previousTrack; 33 | if (!prevTrack) { 34 | interaction.reply({ content: `${ emojis.error } ${ member }, no tracks in history - this command has been cancelled` }); 35 | return; 36 | } 37 | 38 | // Ok 39 | const queue = useQueue(guild.id); 40 | queue.addTrack(prevTrack); 41 | 42 | // Swap first and last conditionally 43 | if (!addToBackOfQueue) queue.swapTracks(0, queue.tracks.data.length - 1); 44 | interaction.reply(`${ emojis.success } ${ member }, **\`${ prevTrack }\`** has been added to the ${ 45 | addToBackOfQueue ? 'back' : 'front' 46 | } of the queue`); 47 | } 48 | catch (e) { 49 | interaction.reply(`${ emojis.error } ${ member }, something went wrong:\n\n${ e.message }`); 50 | } 51 | } 52 | }); 53 | -------------------------------------------------------------------------------- /src/commands/music-dj/jump-to.js: -------------------------------------------------------------------------------- 1 | const { ApplicationCommandOptionType } = require('discord.js'); 2 | const { ChatInputCommand } = require('../../classes/Commands'); 3 | const { requireSessionConditions } = require('../../modules/music'); 4 | const { useQueue } = require('discord-player'); 5 | 6 | module.exports = new ChatInputCommand({ 7 | global: true, 8 | data: { 9 | description: 'Jump to a specific track without removing other tracks', 10 | options: [ 11 | { 12 | name: 'position', 13 | description: 'The song/track position to jump to', 14 | type: ApplicationCommandOptionType.Integer, 15 | required: true, 16 | min_value: 2, 17 | max_value: 999_999 18 | } 19 | ] 20 | }, 21 | run: async (client, interaction) => { 22 | const { emojis } = client.container; 23 | const { 24 | member, guild, options 25 | } = interaction; 26 | // Js 0 indexing offset 27 | const jumpToIndex = Number(options.getInteger('position')) - 1; 28 | 29 | // Check state 30 | if (!requireSessionConditions(interaction, true)) return; 31 | 32 | // Check has queue 33 | const queue = useQueue(guild.id); 34 | if (queue.isEmpty()) { 35 | interaction.reply(`${ emojis.error } ${ member }, queue is currently empty - this command has been cancelled`); 36 | return; 37 | } 38 | 39 | // Check bounds 40 | const queueSizeZeroOffset = queue.size - 1; 41 | if (jumpToIndex > queueSizeZeroOffset) { 42 | interaction.reply(`${ emojis.error } ${ member }, there is nothing at track position ${ jumpToIndex + 1 }, the highest position is ${ queue.size } - this command has been cancelled`); 43 | return; 44 | } 45 | 46 | // Try to jump to new position/queue 47 | try { 48 | queue.node.jump(jumpToIndex); 49 | await interaction.reply(`${ emojis.success } ${ member }, jumping to **\`${ jumpToIndex + 1 }\`**!`); 50 | } 51 | catch (e) { 52 | interaction.reply(`${ emojis.error } ${ member }, something went wrong:\n\n${ e.message }`); 53 | } 54 | } 55 | }); 56 | -------------------------------------------------------------------------------- /src/commands/music-dj/skip-to.js: -------------------------------------------------------------------------------- 1 | const { useQueue } = require('discord-player'); 2 | const { ChatInputCommand } = require('../../classes/Commands'); 3 | const { requireSessionConditions } = require('../../modules/music'); 4 | const { ApplicationCommandOptionType } = require('discord.js'); 5 | 6 | module.exports = new ChatInputCommand({ 7 | global: true, 8 | data: { 9 | description: 'Skip to provided /queue song position, removing everything up to the song', 10 | options: [ 11 | { 12 | name: 'position', 13 | description: 'The song/track position to skip to', 14 | type: ApplicationCommandOptionType.Integer, 15 | required: true, 16 | min_value: 2, 17 | max_value: 999_999 18 | } 19 | ] 20 | }, 21 | run: async (client, interaction) => { 22 | const { emojis } = client.container; 23 | const { 24 | guild, member, options 25 | } = interaction; 26 | const skipToIndex = Number(options.getInteger('position')) - 1; 27 | 28 | // Check state 29 | if (!requireSessionConditions(interaction, true)) return; 30 | 31 | // Check has queue 32 | const queue = useQueue(guild.id); 33 | if (queue.isEmpty()) { 34 | interaction.reply(`${ emojis.error } ${ member }, queue is currently empty - this command has been cancelled`); 35 | return; 36 | } 37 | 38 | // Check bounds 39 | const queueSizeZeroOffset = queue.size - 1; 40 | if (skipToIndex > queueSizeZeroOffset) { 41 | interaction.reply(`${ emojis.error } ${ member }, there is nothing at track position ${ skipToIndex + 1 }, the highest position is ${ queue.size } - this command has been cancelled`); 42 | return; 43 | } 44 | 45 | try { 46 | // Jump to position 47 | const track = queue.tracks.at(skipToIndex); 48 | queue.node.skipTo(skipToIndex); 49 | await interaction.reply(`⏩ ${ member }, skipping to **\`${ track.title }\`**`); 50 | } 51 | catch (e) { 52 | interaction.reply(`${ emojis.error } ${ member }, something went wrong:\n\n${ e.message }`); 53 | } 54 | } 55 | }); 56 | -------------------------------------------------------------------------------- /src/commands/music-dj/play-next.js: -------------------------------------------------------------------------------- 1 | const { ApplicationCommandOptionType } = require('discord.js'); 2 | const { ChatInputCommand } = require('../../classes/Commands'); 3 | const { requireSessionConditions } = require('../../modules/music'); 4 | const { useMainPlayer, useQueue } = require('discord-player'); 5 | 6 | module.exports = new ChatInputCommand({ 7 | global: true, 8 | data: { 9 | description: 'Same as /play, but adds it to the front of the queue', 10 | options: [ 11 | { 12 | name: 'query', 13 | type: ApplicationCommandOptionType.String, 14 | autocomplete: true, 15 | description: 'The music to search/query', 16 | required: true 17 | } 18 | ] 19 | }, 20 | run: async (client, interaction) => { 21 | const player = useMainPlayer(); 22 | const { emojis } = client.container; 23 | const { member, guild } = interaction; 24 | const query = interaction.options.getString('query', true); // we need input/query to play 25 | 26 | // Check state 27 | if (!requireSessionConditions(interaction, true)) return; 28 | 29 | // Let's defer the interaction as things can take time to process 30 | await interaction.deferReply(); 31 | 32 | try { 33 | // Check is valid 34 | const searchResult = await player 35 | .search(query, { requestedBy: interaction.user }) 36 | .catch(() => null); 37 | if (!searchResult.hasTracks()) { 38 | interaction.editReply(`${ emojis.error } ${ member }, no tracks found for query \`${ query }\` - this command has been cancelled`); 39 | return; 40 | } 41 | 42 | // Ok 43 | const firstMatchTrack = searchResult.tracks.at(0); 44 | const queue = useQueue(guild.id); 45 | queue.addTrack(firstMatchTrack); 46 | 47 | // Swap first and last conditionally 48 | queue.swapTracks(0, queue.tracks.data.length - 1); 49 | interaction.editReply(`${ emojis.success } ${ member }, **\`${ firstMatchTrack.title }\`** has been added to the front of the queue`); 50 | } 51 | catch (e) { 52 | interaction.editReply(`${ emojis.error } ${ member }, something went wrong:\n\n${ e.message }`); 53 | } 54 | } 55 | }); 56 | -------------------------------------------------------------------------------- /src/commands/music/save-song.js: -------------------------------------------------------------------------------- 1 | const { ChatInputCommand } = require('../../classes/Commands'); 2 | const { requireSessionConditions, nowPlayingEmbed } = require('../../modules/music'); 3 | const { useQueue } = require('discord-player'); 4 | 5 | module.exports = new ChatInputCommand({ 6 | global: true, 7 | aliases: [ 'dm-song' ], 8 | data: { description: 'Save a song, I\'ll send it to your DMs' }, 9 | // eslint-disable-next-line sonarjs/cognitive-complexity 10 | run: async (client, interaction) => { 11 | const { emojis } = client.container; 12 | const { 13 | member, guild, user 14 | } = interaction; 15 | 16 | // Check state 17 | if (!requireSessionConditions(interaction, true, false, false)) return; 18 | 19 | try { 20 | const queue = useQueue(guild.id); 21 | if (!queue || !queue.isPlaying()) { 22 | interaction.reply(`${ emojis.error } ${ member }, not currently playing - this command has been cancelled`); 23 | return; 24 | } 25 | 26 | const { currentTrack } = queue; 27 | if (!currentTrack) { 28 | interaction.reply(`${ emojis.error } ${ member }, can't fetch information on currently displaying song - please try again later`); 29 | return; 30 | } 31 | 32 | // Resolve embed and create DM 33 | const npEmbed = nowPlayingEmbed(queue, false); 34 | const channel = await user.createDM().catch(() => null); 35 | if (!channel) { 36 | interaction.reply(`${ emojis.error } ${ member }, I don't have permission to DM you - this command has been cancelled`); 37 | return; 38 | } 39 | 40 | // Try to send dm 41 | try { 42 | await channel.send({ embeds: [ npEmbed ] }); 43 | } 44 | catch { 45 | interaction.reply(`${ emojis.error } ${ member }, I don't have permission to DM you - this command has been cancelled`); 46 | return; 47 | } 48 | 49 | // Feedback 50 | await interaction.reply(`${ emojis.success } ${ member }, saved **\`${ currentTrack.title }\`**!`); 51 | } 52 | catch (e) { 53 | interaction.reply(`${ emojis.error } ${ member }, something went wrong:\n\n${ e.message }`); 54 | } 55 | } 56 | }); 57 | -------------------------------------------------------------------------------- /src/commands/music-admin/volume.js: -------------------------------------------------------------------------------- 1 | const { usePlayer } = require('discord-player'); 2 | const { ApplicationCommandOptionType } = require('discord.js'); 3 | const { ChatInputCommand } = require('../../classes/Commands'); 4 | const { requireSessionConditions } = require('../../modules/music'); 5 | const { 6 | getGuildSettings, db, saveDb 7 | } = require('../../modules/db'); 8 | const { clientConfig } = require('../../util'); 9 | 10 | module.exports = new ChatInputCommand({ 11 | global: true, 12 | permLevel: 'Administrator', 13 | data: { 14 | description: 'Change the playback/player\'s volume', 15 | options: [ 16 | { 17 | name: 'volume', 18 | description: 'The volume level to apply', 19 | type: ApplicationCommandOptionType.Integer, 20 | min_value: 1, 21 | max_value: 100, 22 | required: false 23 | } 24 | ] 25 | }, 26 | run: async (client, interaction) => { 27 | const { emojis } = client.container; 28 | const { member, guild } = interaction; 29 | const volume = interaction.options.getInteger('volume'); 30 | const guilds = db.getCollection('guilds'); 31 | 32 | // Check conditions/state 33 | if (!requireSessionConditions(interaction, false)) return; 34 | 35 | // Resolve settings 36 | const settings = getGuildSettings(guild.id); 37 | if (!volume) { // Yes, that includes 0 38 | interaction.reply(`${ emojis.success } ${ member }, volume is currently set to **\`${ settings.volume ?? clientConfig.defaultVolume }\`**`); 39 | return; 40 | } 41 | 42 | try { 43 | // Check if current player should be updated 44 | const guildPlayerNode = usePlayer(interaction.guild.id); 45 | if (guildPlayerNode?.isPlaying()) guildPlayerNode.setVolume(volume); 46 | 47 | // Perform and notify collection that the document has changed 48 | settings.volume = volume; 49 | guilds.update(settings); 50 | saveDb(); 51 | 52 | // Feedback 53 | await interaction.reply({ content: `${ emojis.success } ${ member }, volume set to \`${ volume }\`` }); 54 | } 55 | catch (e) { 56 | interaction.reply(`${ emojis.error } ${ member }, something went wrong:\n\n${ e.message }`); 57 | } 58 | } 59 | }); 60 | -------------------------------------------------------------------------------- /src/commands/music-admin/use-thread-sessions.js: -------------------------------------------------------------------------------- 1 | const { ApplicationCommandOptionType } = require('discord.js'); 2 | const { ChatInputCommand } = require('../../classes/Commands'); 3 | const { requireSessionConditions } = require('../../modules/music'); 4 | const { 5 | getGuildSettings, db, saveDb 6 | } = require('../../modules/db'); 7 | 8 | module.exports = new ChatInputCommand({ 9 | global: true, 10 | permLevel: 'Administrator', 11 | data: { 12 | description: 'Use a dedicated thread for all events and commands when a music session is started', 13 | options: [ 14 | { 15 | name: 'set', 16 | description: 'Enable or disable this setting', 17 | type: ApplicationCommandOptionType.Boolean, 18 | required: false 19 | } 20 | ] 21 | }, 22 | run: async (client, interaction) => { 23 | const { emojis } = client.container; 24 | const { member, guild } = interaction; 25 | const newSetting = interaction.options.getBoolean('set'); 26 | const guilds = db.getCollection('guilds'); 27 | 28 | // Check conditions/state 29 | if (!requireSessionConditions(interaction, false)) return; 30 | 31 | // Resolve settings 32 | const settings = getGuildSettings(guild.id); 33 | if (typeof newSetting === 'undefined' || newSetting === null) { 34 | // eslint-disable-next-line sonarjs/no-nested-template-literals 35 | interaction.reply(`${ emojis.success } ${ member }, \`Use Thread Sessions\` is currently **${ settings.useThreadSessions ? `${ emojis.success } Enabled` : `${ emojis.error } Disabled` }**`); 36 | return; 37 | } 38 | 39 | try { 40 | // Perform and notify collection that the document has changed 41 | settings.useThreadSessions = newSetting; 42 | guilds.update(settings); 43 | saveDb(); 44 | 45 | // Feedback 46 | // eslint-disable-next-line sonarjs/no-nested-template-literals 47 | await interaction.reply({ content: `${ emojis.success } ${ member }, \`Use Thread Sessions\` has been **${ newSetting === true ? `${ emojis.success } Enabled` : `${ emojis.error } Disabled` }**` }); 48 | } 49 | catch (e) { 50 | interaction.reply(`${ emojis.error } ${ member }, something went wrong:\n\n${ e.message }`); 51 | } 52 | } 53 | }); 54 | -------------------------------------------------------------------------------- /src/commands/music-dj/remove-song.js: -------------------------------------------------------------------------------- 1 | const { useQueue } = require('discord-player'); 2 | const { ChatInputCommand } = require('../../classes/Commands'); 3 | const { requireSessionConditions } = require('../../modules/music'); 4 | const { ApplicationCommandOptionType } = require('discord.js'); 5 | 6 | const SONG_POSITION_OPTION_ID = 'song-position'; 7 | 8 | module.exports = new ChatInputCommand({ 9 | global: true, 10 | data: { 11 | description: 'Remove a song that is current in /queue', 12 | options: [ 13 | { 14 | name: SONG_POSITION_OPTION_ID, 15 | description: 'The position of the source song', 16 | type: ApplicationCommandOptionType.Integer, 17 | required: true, 18 | min_value: 1, 19 | max_value: 999_999 20 | } 21 | ] 22 | }, 23 | run: async (client, interaction) => { 24 | const { emojis } = client.container; 25 | const { 26 | member, guild, options 27 | } = interaction; 28 | const songPosition = Number(options.getInteger(SONG_POSITION_OPTION_ID)) - 1; 29 | 30 | // Check state 31 | if (!requireSessionConditions(interaction, true)) return; 32 | 33 | try { 34 | const queue = useQueue(guild.id); 35 | 36 | // Not enough songs in queue 37 | if ((queue?.size ?? 0) < 2) { 38 | interaction.reply(`${ emojis.error } ${ member }, not enough songs in queue to perform any move action - this command has been cancelled`); 39 | return; 40 | } 41 | 42 | // Check bounds/constraints 43 | const queueSizeZeroOffset = queue.size - 1; 44 | if (songPosition > queueSizeZeroOffset) { 45 | interaction.reply(`${ emojis.error } ${ member }, the \`${ 46 | SONG_POSITION_OPTION_ID + '` parameter is' 47 | } not within valid range of 1-${ queue.size } - this command has been cancelled`); 48 | return; 49 | } 50 | 51 | // Remove song - assign track before #removeTrack 52 | const track = queue.tracks.data.at(songPosition); 53 | queue.removeTrack(songPosition); 54 | interaction.reply(`${ emojis.success } ${ member }, **\`${ track.title }\`** has been removed from the queue`); 55 | } 56 | catch (e) { 57 | interaction.reply(`${ emojis.error } ${ member }, something went wrong:\n\n${ e.message }`); 58 | } 59 | } 60 | }); 61 | -------------------------------------------------------------------------------- /src/commands/music-admin/use-strict-thread-sessions.js: -------------------------------------------------------------------------------- 1 | const { ApplicationCommandOptionType } = require('discord.js'); 2 | const { ChatInputCommand } = require('../../classes/Commands'); 3 | const { requireSessionConditions } = require('../../modules/music'); 4 | const { 5 | getGuildSettings, db, saveDb 6 | } = require('../../modules/db'); 7 | 8 | module.exports = new ChatInputCommand({ 9 | global: true, 10 | permLevel: 'Administrator', 11 | data: { 12 | description: 'When using Thread Sessions, requires all related music commands to be in the dedicated thread', 13 | options: [ 14 | { 15 | name: 'set', 16 | description: 'Enable or disable this setting', 17 | type: ApplicationCommandOptionType.Boolean, 18 | required: false 19 | } 20 | ] 21 | }, 22 | run: async (client, interaction) => { 23 | const { emojis } = client.container; 24 | const { member, guild } = interaction; 25 | const newSetting = interaction.options.getBoolean('set'); 26 | const guilds = db.getCollection('guilds'); 27 | 28 | // Check conditions/state 29 | if (!requireSessionConditions(interaction, false)) return; 30 | 31 | // Resolve settings 32 | const settings = getGuildSettings(guild.id); 33 | if (typeof newSetting === 'undefined' || newSetting === null) { 34 | // eslint-disable-next-line sonarjs/no-nested-template-literals 35 | interaction.reply(`${ emojis.success } ${ member }, \`Use Strict Thread Sessions\` is currently **${ settings.threadSessionStrictCommandChannel ? `${ emojis.success } Enabled` : `${ emojis.error } Disabled` }**`); 36 | return; 37 | } 38 | 39 | try { 40 | // Perform and notify collection that the document has changed 41 | settings.threadSessionStrictCommandChannel = newSetting; 42 | guilds.update(settings); 43 | saveDb(); 44 | 45 | // Feedback 46 | // eslint-disable-next-line sonarjs/no-nested-template-literals 47 | await interaction.reply({ content: `${ emojis.success } ${ member }, \`Use Strict Thread Sessions\` has been **${ newSetting === true ? `${ emojis.success } Enabled` : `${ emojis.error } Disabled` }**` }); 48 | } 49 | catch (e) { 50 | interaction.reply(`${ emojis.error } ${ member }, something went wrong:\n\n${ e.message }`); 51 | } 52 | } 53 | }); 54 | -------------------------------------------------------------------------------- /src/commands/system/help.js: -------------------------------------------------------------------------------- 1 | const { ChatInputCommand } = require('../../classes/Commands'); 2 | const { 3 | getCommandSelectMenu, 4 | generateCommandOverviewEmbed, 5 | generateCommandInfoEmbed 6 | } = require('../../handlers/commands'); 7 | const { commandAutoCompleteOption } = require('../../interactions/autocomplete/command'); 8 | 9 | module.exports = new ChatInputCommand({ 10 | global: true, 11 | aliases: [ 'commands' ], 12 | cooldown: { 13 | // Use user cooldown type instead of default member 14 | type: 'user', 15 | usages: 2, 16 | duration: 10 17 | }, 18 | clientPerms: [ 'EmbedLinks' ], 19 | data: { 20 | description: 'Receive detailed command information', 21 | options: [ commandAutoCompleteOption ] 22 | }, 23 | 24 | run: (client, interaction) => { 25 | // Destructuring 26 | const { member } = interaction; 27 | const { 28 | commands, contextMenus, emojis 29 | } = client.container; 30 | 31 | // Check for optional autocomplete focus 32 | const commandName = interaction.options.getString('command'); 33 | const hasCommandArg = commandName !== null && typeof commandName !== 'undefined'; 34 | 35 | // Show command overview if no command parameter is supplied 36 | if (!hasCommandArg) { 37 | // Getting our command select menu, re-used 38 | const cmdSelectMenu = getCommandSelectMenu(member); 39 | 40 | // Reply to the interaction with our embed 41 | interaction.reply({ 42 | embeds: [ generateCommandOverviewEmbed(commands, interaction) ], 43 | components: [ cmdSelectMenu ] 44 | }); 45 | return; 46 | } 47 | 48 | // Request HAS optional command argument 49 | // Assigning our data 50 | const clientCmd = commands.get(commandName) 51 | || contextMenus.get(commandName); 52 | 53 | // Checking if the commandName is a valid client command 54 | if (!clientCmd) { 55 | interaction.reply({ 56 | content: `${ emojis.error } ${ member }, I couldn't find the command **\`/${ commandName }\`**`, 57 | ephemeral: true 58 | }); 59 | return; 60 | } 61 | 62 | // Replying with our command information embed 63 | interaction.reply({ embeds: [ 64 | generateCommandInfoEmbed( 65 | clientCmd, 66 | interaction 67 | ) 68 | ] }); 69 | } 70 | }); 71 | -------------------------------------------------------------------------------- /src/commands/music-admin/leave-on-end-cooldown.js: -------------------------------------------------------------------------------- 1 | const { ApplicationCommandOptionType } = require('discord.js'); 2 | const { ChatInputCommand } = require('../../classes/Commands'); 3 | const { requireSessionConditions } = require('../../modules/music'); 4 | const { 5 | getGuildSettings, db, saveDb 6 | } = require('../../modules/db'); 7 | const { clientConfig, msToHumanReadableTime } = require('../../util'); 8 | const { MS_IN_ONE_SECOND } = require('../../constants'); 9 | 10 | module.exports = new ChatInputCommand({ 11 | global: true, 12 | permLevel: 'Administrator', 13 | data: { 14 | description: 'Change the amount of seconds to wait before leaving channel when playback finishes', 15 | options: [ 16 | { 17 | name: 'seconds', 18 | description: 'The amount of seconds to wait', 19 | type: ApplicationCommandOptionType.Integer, 20 | min_value: 1, 21 | max_value: Number.MAX_SAFE_INTEGER, 22 | required: false 23 | } 24 | ] 25 | }, 26 | run: async (client, interaction) => { 27 | const { emojis } = client.container; 28 | const { member, guild } = interaction; 29 | const seconds = interaction.options.getInteger('seconds'); 30 | const guilds = db.getCollection('guilds'); 31 | 32 | // Check conditions/state 33 | if (!requireSessionConditions(interaction, false)) return; 34 | 35 | // Resolve settings 36 | const settings = getGuildSettings(guild.id); 37 | if (!seconds) { 38 | interaction.reply(`${ emojis.success } ${ member }, the amount of seconds to wait before leaving channel when playback finishes is currently set to **\`${ settings.leaveOnEndCooldown ?? clientConfig.defaultLeaveOnEndCooldown }\`**`); 39 | return; 40 | } 41 | 42 | try { 43 | // Perform and notify collection that the document has changed 44 | settings.leaveOnEndCooldown = seconds; 45 | guilds.update(settings); 46 | saveDb(); 47 | 48 | // Feedback 49 | await interaction.reply({ content: `${ emojis.success } ${ member }, leave-on-end cooldown set to \`${ msToHumanReadableTime(seconds * MS_IN_ONE_SECOND) }\`\nThis change will take effect the next time playback is initialized` }); 50 | } 51 | catch (e) { 52 | interaction.reply(`${ emojis.error } ${ member }, something went wrong:\n\n${ e.message }`); 53 | } 54 | } 55 | }); 56 | -------------------------------------------------------------------------------- /src/commands/music-dj/seek.js: -------------------------------------------------------------------------------- 1 | const { useQueue } = require('discord-player'); 2 | const { ChatInputCommand } = require('../../classes/Commands'); 3 | const { requireSessionConditions } = require('../../modules/music'); 4 | const { ApplicationCommandOptionType } = require('discord.js'); 5 | const { MS_IN_ONE_SECOND } = require('../../constants'); 6 | 7 | module.exports = new ChatInputCommand({ 8 | global: true, 9 | data: { 10 | description: 'Jump to a specific time in the current song', 11 | options: [ 12 | { 13 | name: 'minutes', 14 | description: 'The minute to jump to', 15 | type: ApplicationCommandOptionType.Integer, 16 | required: false, 17 | min_value: 0, 18 | max_value: 999_999 19 | }, 20 | { 21 | name: 'seconds', 22 | description: 'The seconds to jump to', 23 | type: ApplicationCommandOptionType.Integer, 24 | required: false, 25 | min_value: 0, 26 | max_value: 59 27 | } 28 | ] 29 | }, 30 | run: async (client, interaction) => { 31 | const { emojis } = client.container; 32 | const { 33 | guild, member, options 34 | } = interaction; 35 | const minutes = Number(options.getInteger('minutes') ?? 0); 36 | const seconds = Number(options.getInteger('seconds') ?? 0); 37 | const totalMs = (minutes * 60 + seconds) * MS_IN_ONE_SECOND; 38 | 39 | // Check is default params 40 | if (totalMs === 0) { 41 | interaction.reply(`${ emojis.error } ${ member }, default command options provided, if you want to replay a track, use \`/replay\` - this command has been cancelled`); 42 | return; 43 | } 44 | 45 | 46 | // Check state 47 | if (!requireSessionConditions(interaction, true)) return; 48 | 49 | // Not a point in duration 50 | if (totalMs > useQueue(guild.id).currentTrack?.durationMS) { 51 | interaction.reply(`${ emojis.error } ${ member }, not a valid timestamp for song - this action has been cancelled`); 52 | return; 53 | } 54 | 55 | try { 56 | const queue = useQueue(guild.id); 57 | queue.node.seek(totalMs); 58 | await interaction.reply(`🔍 ${ member }, setting playback timestamp to ${ String(minutes).padStart(2, '0') }:${ String(seconds).padStart(2, '0') }`); 59 | } 60 | catch (e) { 61 | interaction.reply(`${ emojis.error } ${ member }, something went wrong:\n\n${ e.message }`); 62 | } 63 | } 64 | }); 65 | -------------------------------------------------------------------------------- /src/listeners/interaction/autoCompleteInteraction.js: -------------------------------------------------------------------------------- 1 | const logger = require('@mirasaki/logger'); 2 | const { AUTOCOMPLETE_MAX_DATA_OPTIONS } = require('../../constants'); 3 | const { getRuntime } = require('../../util'); 4 | const chalk = require('chalk'); 5 | 6 | // Destructure from env 7 | const { DEBUG_AUTOCOMPLETE_RESPONSE_TIME } = process.env; 8 | 9 | module.exports = async (client, interaction) => { 10 | // guild property is present and available, 11 | // we check in the main interactionCreate.js file 12 | 13 | // Destructure from interaction and client container 14 | const { commandName } = interaction; 15 | const { autoCompletes } = client.container; 16 | 17 | // Start our timer for performance logging 18 | const autoResponseQueryStart = process.hrtime.bigint(); 19 | 20 | // Get our command name query 21 | const query = interaction.options.getFocused()?.toLowerCase() || ''; 22 | const activeOption = interaction.options._hoistedOptions.find(({ focused }) => focused === true)?.name; 23 | const autoCompleteQueryHandler = autoCompletes.get(activeOption); 24 | 25 | // Check if a query handler is found 26 | if (!autoCompleteQueryHandler) { 27 | logger.syserr(`Missing AutoComplete query handler for the "${ activeOption }" option in the ${ commandName } command`); 28 | return; 29 | } 30 | 31 | // Getting the result 32 | const result = await autoCompleteQueryHandler.run(client, interaction, query); 33 | 34 | // Returning our query result 35 | interaction.respond( 36 | // Slicing of the first 25 results, which is max allowed by Discord 37 | result?.slice(0, AUTOCOMPLETE_MAX_DATA_OPTIONS) || [] 38 | ).catch((err) => { 39 | // Unknown Interaction Error 40 | if (err.code === 10062) { 41 | logger.debug(`Error code 10062 (UNKNOWN_INTERACTION) encountered while responding to autocomplete query in ${ commandName } - this interaction probably expired.`); 42 | } 43 | 44 | // Handle unexpected errors 45 | else { 46 | logger.syserr(`Unknown error encountered while responding to autocomplete query in ${ commandName }`); 47 | console.error(err.stack || err); 48 | } 49 | }); 50 | 51 | // Performance logging if requested depending on environment 52 | if (DEBUG_AUTOCOMPLETE_RESPONSE_TIME === 'true') { 53 | logger.debug(`<${ chalk.cyanBright(commandName) }> | Auto Complete | Queried "${ chalk.green(query) }" in ${ getRuntime(autoResponseQueryStart).ms } ms`); 54 | } 55 | }; 56 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | const EMBED_MAX_FIELDS_LENGTH = 25; 4 | const EMBED_MAX_CHARACTER_LENGTH = 6000; 5 | const EMBED_TITLE_MAX_LENGTH = 256; 6 | const EMBED_DESCRIPTION_MAX_LENGTH = 4096; 7 | const EMBED_FIELD_NAME_MAX_LENGTH = 256; 8 | const EMBED_FIELD_VALUE_MAX_LENGTH = 1024; 9 | const EMBED_FOOTER_TEXT_MAX_LENGTH = 2048; 10 | const EMBED_AUTHOR_NAME_MAX_LENGTH = 256; 11 | 12 | const MESSAGE_CONTENT_MAX_LENGTH = 2000; 13 | const SELECT_MENU_MAX_OPTIONS = 25; 14 | const AUTOCOMPLETE_MAX_DATA_OPTIONS = 25; 15 | 16 | const BYTES_IN_KIB = 1024; 17 | const BYTES_IN_MIB = 1048576; 18 | const BYTES_IN_GIB = BYTES_IN_MIB * 1024; 19 | 20 | const NS_IN_ONE_MS = 1000000; 21 | const NS_IN_ONE_SECOND = 1e9; 22 | 23 | const MS_IN_ONE_SECOND = 1000; 24 | const MS_IN_ONE_MINUTE = 60000; 25 | const MS_IN_ONE_HOUR = 3600000; 26 | const MS_IN_ONE_DAY = 864e5; 27 | 28 | const SECONDS_IN_ONE_MINUTE = 60; 29 | const MINUTES_IN_ONE_HOUR = 60; 30 | const HOURS_IN_ONE_DAY = 24; 31 | 32 | const DEFAULT_DECIMAL_PRECISION = 2; 33 | 34 | /*** 35 | * Commands & Components 36 | */ 37 | 38 | // Help 39 | const HELP_COMMAND_SELECT_MENU = 'help_select_command'; 40 | const HELP_SELECT_MENU_SEE_MORE_OPTIONS = 'help_see_more'; 41 | 42 | // Eval / Evaluate 43 | const EVAL_CODE_MODAL = 'eval_code_modal'; 44 | const EVAL_CODE_INPUT = 'eval_code_input'; 45 | const ACCEPT_EVAL_CODE_EXECUTION = 'accept-eval-code-execution'; 46 | const DECLINE_EVAL_CODE_EXECUTION = 'decline-eval-code-execution'; 47 | 48 | const ZERO_WIDTH_SPACE_CHAR_CODE = 8203; 49 | 50 | 51 | module.exports = { 52 | EMBED_MAX_FIELDS_LENGTH, 53 | EMBED_MAX_CHARACTER_LENGTH, 54 | 55 | EMBED_TITLE_MAX_LENGTH, 56 | EMBED_DESCRIPTION_MAX_LENGTH, 57 | EMBED_FIELD_NAME_MAX_LENGTH, 58 | EMBED_FIELD_VALUE_MAX_LENGTH, 59 | EMBED_FOOTER_TEXT_MAX_LENGTH, 60 | EMBED_AUTHOR_NAME_MAX_LENGTH, 61 | 62 | MESSAGE_CONTENT_MAX_LENGTH, 63 | SELECT_MENU_MAX_OPTIONS, 64 | AUTOCOMPLETE_MAX_DATA_OPTIONS, 65 | 66 | BYTES_IN_KIB, 67 | BYTES_IN_MIB, 68 | BYTES_IN_GIB, 69 | 70 | NS_IN_ONE_MS, 71 | NS_IN_ONE_SECOND, 72 | 73 | MS_IN_ONE_SECOND, 74 | MS_IN_ONE_MINUTE, 75 | MS_IN_ONE_HOUR, 76 | MS_IN_ONE_DAY, 77 | 78 | SECONDS_IN_ONE_MINUTE, 79 | MINUTES_IN_ONE_HOUR, 80 | HOURS_IN_ONE_DAY, 81 | 82 | DEFAULT_DECIMAL_PRECISION, 83 | 84 | HELP_COMMAND_SELECT_MENU, 85 | HELP_SELECT_MENU_SEE_MORE_OPTIONS, 86 | 87 | EVAL_CODE_MODAL, 88 | EVAL_CODE_INPUT, 89 | ACCEPT_EVAL_CODE_EXECUTION, 90 | DECLINE_EVAL_CODE_EXECUTION, 91 | 92 | ZERO_WIDTH_SPACE_CHAR_CODE 93 | }; 94 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # JSON config file 10 | config.js 11 | 12 | # Eslint output 13 | linter-output.txt 14 | 15 | # Diagnostic reports (https://nodejs.org/api/report.html) 16 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 17 | 18 | # Runtime data 19 | pids 20 | *.pid 21 | *.seed 22 | *.pid.lock 23 | 24 | # Directory for instrumented libs generated by jscoverage/JSCover 25 | lib-cov 26 | 27 | # Coverage directory used by tools like istanbul 28 | coverage 29 | *.lcov 30 | 31 | # nyc test coverage 32 | .nyc_output 33 | 34 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 35 | .grunt 36 | 37 | # Bower dependency directory (https://bower.io/) 38 | bower_components 39 | 40 | # node-waf configuration 41 | .lock-wscript 42 | 43 | # Compiled binary addons (https://nodejs.org/api/addons.html) 44 | build/Release 45 | 46 | # Dependency directories 47 | node_modules/ 48 | jspm_packages/ 49 | 50 | # TypeScript v1 declaration files 51 | typings/ 52 | 53 | # TypeScript cache 54 | *.tsbuildinfo 55 | 56 | # Optional npm cache directory 57 | .npm 58 | 59 | # Optional eslint cache 60 | .eslintcache 61 | 62 | # Microbundle cache 63 | .rpt2_cache/ 64 | .rts2_cache_cjs/ 65 | .rts2_cache_es/ 66 | .rts2_cache_umd/ 67 | 68 | # Optional REPL history 69 | .node_repl_history 70 | 71 | # Output of 'npm pack' 72 | *.tgz 73 | 74 | # Yarn Integrity file 75 | .yarn-integrity 76 | 77 | # dotenv environment variables file 78 | .env 79 | .env.test 80 | 81 | # parcel-bundler cache (https://parceljs.org/) 82 | .cache 83 | 84 | # Next.js build output 85 | .next 86 | 87 | # Nuxt.js build / generate output 88 | .nuxt 89 | dist 90 | 91 | # Gatsby files 92 | .cache/ 93 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 94 | # https://nextjs.org/blog/next-9-1#public-directory-support 95 | # public 96 | 97 | # vuepress build output 98 | .vuepress/dist 99 | 100 | # Serverless directories 101 | .serverless/ 102 | 103 | # FuseBox cache 104 | .fusebox/ 105 | 106 | # DynamoDB Local files 107 | .dynamodb/ 108 | 109 | # TernJS port file 110 | .tern-port 111 | 112 | # Ignore our local development test files 113 | src/commands/testing 114 | 115 | # Documentation: JSDoc 116 | /docs 117 | 118 | # Personal TODO file 119 | TODO.md 120 | 121 | # In-development, Intellisense for events/listeners 122 | typings.d.ts 123 | 124 | # DB and db save 125 | *.db 126 | *.db~ 127 | 128 | # Development todo list 129 | TODO 130 | -------------------------------------------------------------------------------- /src/commands/music-admin/leave-on-empty-cooldown.js: -------------------------------------------------------------------------------- 1 | const { ApplicationCommandOptionType } = require('discord.js'); 2 | const { ChatInputCommand } = require('../../classes/Commands'); 3 | const { requireSessionConditions } = require('../../modules/music'); 4 | const { 5 | getGuildSettings, db, saveDb 6 | } = require('../../modules/db'); 7 | const { clientConfig, msToHumanReadableTime } = require('../../util'); 8 | const { MS_IN_ONE_SECOND } = require('../../constants'); 9 | 10 | module.exports = new ChatInputCommand({ 11 | global: true, 12 | permLevel: 'Administrator', 13 | data: { 14 | description: 'Change the amount of seconds to wait before leaving when channel is empty', 15 | options: [ 16 | { 17 | name: 'seconds', 18 | description: 'The amount of seconds to wait', 19 | type: ApplicationCommandOptionType.Integer, 20 | min_value: 1, 21 | max_value: Number.MAX_SAFE_INTEGER, 22 | required: false 23 | }, 24 | { 25 | name: 'status', 26 | description: 'Enable/disable leave-on-empty, uses a lot more bandwidth when disabled', 27 | type: ApplicationCommandOptionType.Boolean, 28 | required: false 29 | } 30 | ] 31 | }, 32 | run: async (client, interaction) => { 33 | const { emojis } = client.container; 34 | const { member, guild } = interaction; 35 | const seconds = interaction.options.getInteger('seconds'); 36 | const status = interaction.options.getBoolean('status') ?? true; 37 | const guilds = db.getCollection('guilds'); 38 | 39 | // Check conditions/state 40 | if (!requireSessionConditions(interaction, false)) return; 41 | 42 | // Resolve settings 43 | const settings = getGuildSettings(guild.id); 44 | if (!seconds) { 45 | interaction.reply(`${ emojis.success } ${ member }, the amount of seconds to wait before leaving when channel is empty is currently set to **\`${ settings.leaveOnEmptyCooldown ?? clientConfig.defaultLeaveOnEmptyCooldown }\`**`); 46 | return; 47 | } 48 | 49 | try { 50 | // Perform and notify collection that the document has changed 51 | settings.leaveOnEmpty = status; 52 | settings.leaveOnEmptyCooldown = seconds; 53 | guilds.update(settings); 54 | saveDb(); 55 | 56 | // Feedback 57 | if (status !== true) interaction.reply({ content: `${ emojis.success } ${ member }, leave-on-empty has been disabled` }); 58 | else interaction.reply({ content: `${ emojis.success } ${ member }, leave-on-empty cooldown set to \`${ msToHumanReadableTime(seconds * MS_IN_ONE_SECOND) }\`\nThis change will take effect the next time playback is initialized` }); 59 | } 60 | catch (e) { 61 | interaction.reply(`${ emojis.error } ${ member }, something went wrong:\n\n${ e.message }`); 62 | } 63 | } 64 | }); 65 | -------------------------------------------------------------------------------- /src/commands/music/vote-skip.js: -------------------------------------------------------------------------------- 1 | const { usePlayer } = require('discord-player'); 2 | const { ChatInputCommand } = require('../../classes/Commands'); 3 | const { requireSessionConditions } = require('../../modules/music'); 4 | 5 | const voteSkipCache = new Map(); 6 | 7 | module.exports = new ChatInputCommand({ 8 | global: true, 9 | data: { description: 'Vote to skip the currently playing song, requires strict majority to pass' }, 10 | run: async (client, interaction) => { 11 | const { emojis } = client.container; 12 | const { guild, member } = interaction; 13 | 14 | if (!requireSessionConditions(interaction, true, false, false)) return; 15 | 16 | try { 17 | const guildPlayerNode = usePlayer(interaction.guild.id); 18 | 19 | // Get curr track - and update cache 20 | const currentTrack = guildPlayerNode.queue.currentTrack; 21 | let voteCacheEntry = voteSkipCache.get(guild.id); 22 | 23 | // Initialize 24 | if (!voteCacheEntry) { 25 | voteSkipCache.set(guild.id, { 26 | track: currentTrack.url, 27 | votes: [] 28 | }); 29 | voteCacheEntry = voteSkipCache.get(guild.id); 30 | } 31 | 32 | // Reset, different/new track 33 | else if (voteCacheEntry.track !== currentTrack.url) { 34 | voteCacheEntry.track = currentTrack.url; 35 | voteCacheEntry.votes = []; 36 | } 37 | 38 | // Check has voted 39 | if (voteCacheEntry.votes.includes(member.id)) { 40 | interaction.reply(`${ emojis.error } ${ member }, you have already voted - this command has been cancelled`); 41 | return; 42 | } 43 | // Increment votes 44 | else voteCacheEntry.votes.push(member.id); 45 | 46 | // Resolve threshold 47 | const channel = guildPlayerNode.queue.channel; 48 | const memberCount = channel.members.size - 1; // - 1 for client 49 | const threshold = Math.min(memberCount, Math.ceil(memberCount / 2) + 1); // + 1 require strict majority 50 | if (voteCacheEntry.votes.length < threshold) { 51 | interaction.reply(`${ emojis.success } ${ member }, registered your vote - current votes: ${ voteCacheEntry.votes.length } / ${ threshold }`); 52 | return; 53 | } 54 | 55 | // Skip song, reached threshold 56 | const success = guildPlayerNode.skip(); 57 | if (success) { 58 | voteSkipCache.delete(guild.id); 59 | interaction.reply(`${ emojis.success } ${ member }, skipped **\`${ currentTrack.title }\`**, vote threshold was reached`); 60 | } 61 | else interaction.reply(`${ emojis.error } ${ member }, something went wrong - couldn't skip current playing song`); 62 | } 63 | catch (e) { 64 | interaction.reply(`${ emojis.error } ${ member }, something went wrong:\n\n${ e.message }`); 65 | } 66 | } 67 | }); 68 | -------------------------------------------------------------------------------- /src/commands/music-dj/repeat-mode.js: -------------------------------------------------------------------------------- 1 | const { useQueue } = require('discord-player'); 2 | const { ApplicationCommandOptionType } = require('discord.js'); 3 | const { ChatInputCommand } = require('../../classes/Commands'); 4 | const { repeatModeEmoji, requireSessionConditions } = require('../../modules/music'); 5 | const { 6 | getGuildSettings, db, saveDb 7 | } = require('../../modules/db'); 8 | 9 | module.exports = new ChatInputCommand({ 10 | global: true, 11 | data: { 12 | description: 'Configure specific repeat-type, or disable repeat altogether', 13 | options: [ 14 | { 15 | name: 'mode', 16 | description: 'The mode to set', 17 | required: true, 18 | type: ApplicationCommandOptionType.String, 19 | choices: [ 20 | { 21 | name: 'off', value: '0' 22 | }, 23 | { 24 | name: 'song', value: '1' 25 | }, 26 | { 27 | name: 'queue', value: '2' 28 | }, 29 | { 30 | name: 'autoplay', value: '3' 31 | } 32 | ] 33 | }, 34 | { 35 | name: 'persistent', 36 | description: 'Save the selected repeat mode. Applies selected repeat mode to new sessions.', 37 | type: ApplicationCommandOptionType.Boolean, 38 | required: false 39 | } 40 | ] 41 | }, 42 | // eslint-disable-next-line sonarjs/cognitive-complexity 43 | run: async (client, interaction) => { 44 | const { emojis } = client.container; 45 | const { 46 | guild, member, options 47 | } = interaction; 48 | const repeatMode = Number(options.getString('mode') ?? 0); 49 | const shouldSave = options.getBoolean('persistent') ?? false; 50 | 51 | // Check state 52 | if (!requireSessionConditions(interaction)) return; 53 | 54 | try { 55 | const queue = useQueue(interaction.guild.id); 56 | if (!queue) { 57 | interaction.reply({ content: `${ emojis.error } ${ member }, no music is being played - initialize a session with \`/play\` first and try again, this command has been cancelled` }); 58 | return; 59 | } 60 | 61 | // Resolve repeat mode 62 | queue.setRepeatMode(repeatMode); 63 | const modeEmoji = repeatModeEmoji(repeatMode); 64 | 65 | // Save for persistency 66 | if (shouldSave) { 67 | // Perform and notify collection that the document has changed 68 | const guilds = db.getCollection('guilds'); 69 | const settings = getGuildSettings(guild.id); 70 | settings.repeatMode = repeatMode; 71 | guilds.update(settings); 72 | saveDb(); 73 | } 74 | 75 | // Feedback 76 | interaction.reply({ content: `${ emojis.success } ${ member }, updated repeat mode to: ${ modeEmoji }` }); 77 | } 78 | catch (e) { 79 | interaction.reply(`${ emojis.error } ${ member }, something went wrong:\n\n${ e.message }`); 80 | } 81 | } 82 | }); 83 | -------------------------------------------------------------------------------- /src/commands/music-dj/biquad.js: -------------------------------------------------------------------------------- 1 | const { BiquadFilterType, useQueue } = require('discord-player'); 2 | const { ApplicationCommandOptionType } = require('discord.js'); 3 | const { ChatInputCommand } = require('../../classes/Commands'); 4 | const { requireSessionConditions } = require('../../modules/music'); 5 | 6 | module.exports = new ChatInputCommand({ 7 | global: true, 8 | data: { 9 | description: 'Configure the biquad filter', 10 | options: [ 11 | { 12 | name: 'filter', 13 | description: 'The biquad filter to use', 14 | type: ApplicationCommandOptionType.String, 15 | choices: [ 16 | { 17 | name: 'Disable', 18 | value: 'null' 19 | }, 20 | ...Object.keys(BiquadFilterType) 21 | .map((e) => ({ 22 | name: e, 23 | value: e 24 | })) 25 | ], 26 | required: true 27 | }, 28 | { 29 | name: 'gain', 30 | description: 'The gain level to apply', 31 | type: ApplicationCommandOptionType.Integer, 32 | min_value: -100, 33 | max_value: 100, 34 | required: false 35 | } 36 | ] 37 | }, 38 | run: async (client, interaction) => { 39 | const { emojis } = client.container; 40 | const { 41 | member, guild, options 42 | } = interaction; 43 | const filter = options.getString('filter'); 44 | const gain = options.getInteger('gain'); 45 | 46 | // Check conditions 47 | if (!requireSessionConditions(interaction, true)) return; 48 | 49 | // Check is playing 50 | const queue = useQueue(guild.id); 51 | if (!queue.isPlaying()) { 52 | interaction.reply(`${ emojis.error } ${ member }, please initialize playback/start a music session first - this command has been cancelled`); 53 | return; 54 | } 55 | 56 | // Check can be applied 57 | if (!queue.filters.biquad) { 58 | interaction.reply(`${ emojis.error } ${ member }, the biquad filter can't be applied to this queue - this command has been cancelled`); 59 | return; 60 | } 61 | 62 | try { 63 | if (filter === 'null' && queue.filters.biquad) queue.filters.biquad.disable(); 64 | else if (queue.filters.biquad) { 65 | queue.filters.biquad.setFilter(BiquadFilterType[filter]); 66 | if (typeof gain === 'number') queue.filters.biquad.setGain(gain); 67 | queue.filters.biquad.enable(); 68 | } 69 | 70 | // Feedback 71 | await interaction.reply({ content: `${ emojis.success } ${ member }, new biquad filter configuration applied (**\`${ filter === 'null' ? 'Disabled' : filter }\`**)\nBiquad filters apply on a per-session basis - this is intended behavior, the next time playback is initialized, the biquad filter wil always be disabled` }); 72 | } 73 | catch (e) { 74 | interaction.reply(`${ emojis.error } ${ member }, something went wrong:\n\n${ e.message }`); 75 | } 76 | } 77 | }); 78 | -------------------------------------------------------------------------------- /src/commands/developer/reload.js: -------------------------------------------------------------------------------- 1 | const { ChatInputCommand } = require('../../classes/Commands'); 2 | const { requiredCommandAutoCompleteOption } = require('../../interactions/autocomplete/command'); 3 | const { colorResolver } = require('../../util'); 4 | 5 | /* 6 | I don't really see the value in having a reload command 7 | when there's options like nodemon available 8 | for active development, but still, it's here 9 | */ 10 | 11 | module.exports = new ChatInputCommand({ 12 | permLevel: 'Developer', 13 | data: { 14 | description: 'Reload an active, existing command', 15 | options: [ requiredCommandAutoCompleteOption ] 16 | }, 17 | 18 | run: async (client, interaction) => { 19 | // Destructure 20 | const { member, options } = interaction; 21 | const { 22 | emojis, colors, commands 23 | } = client.container; 24 | 25 | // Variables definitions 26 | const commandName = options.getString('command'); 27 | const command = commands.get(commandName); 28 | 29 | // Check is valid command 30 | if (!command) { 31 | interaction.reply({ content: `${ emojis.error } ${ member }, couldn't find any commands named \`${ commandName }\`.` }); 32 | return; 33 | } 34 | 35 | // Deferring our reply 36 | await interaction.deferReply(); 37 | 38 | // Try to reload the command 39 | try { 40 | // Calling class#unload() doesn't refresh the collection 41 | // To avoid code repetition in Commands class file 42 | // We'll re-create the function in our /reload command 43 | 44 | // Removing from our collection 45 | commands.delete(commandName); 46 | 47 | // Getting and deleting our current cmd module cache 48 | const filePath = command.filePath; 49 | const module = require.cache[require.resolve(filePath)]; 50 | 51 | delete require.cache[require.resolve(filePath)]; 52 | for (let i = 0; i < module.children?.length; i++) { 53 | if (!module.children) break; 54 | if (module.children[i] === module) { 55 | module.children.splice(i, 1); 56 | break; 57 | } 58 | } 59 | 60 | const newCommand = require(filePath); 61 | 62 | newCommand.load(filePath, commands); 63 | } 64 | catch (err) { 65 | // Properly handling errors 66 | interaction.editReply({ content: `${ emojis.error } ${ member }, error encountered while reloading the command \`${ commandName }\`, click spoiler-block below to reveal.\n\n||${ err.stack || err }||` }); 67 | return; 68 | } 69 | 70 | // Command successfully reloaded 71 | interaction.editReply({ 72 | content: `${ emojis.success } ${ member }, reloaded the \`/${ commandName }\` command`, 73 | embeds: [ 74 | { 75 | color: colorResolver(colors.invisible), 76 | footer: { text: 'Don\'t forget to use the /deploy command if you made any changes to the command data object' } 77 | } 78 | ] 79 | }); 80 | } 81 | }); 82 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ main ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ main ] 20 | schedule: 21 | - cron: '41 4 * * 6' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v3 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v2 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | 52 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 53 | # queries: security-extended,security-and-quality 54 | 55 | 56 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 57 | # If this step fails, then you should remove it and run the build manually (see below) 58 | - name: Autobuild 59 | uses: github/codeql-action/autobuild@v2 60 | 61 | # ℹ️ Command-line programs to run using the OS shell. 62 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 63 | 64 | # If the Autobuild fails above, remove it and uncomment the following three lines. 65 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 66 | 67 | # - run: | 68 | # echo "Run, Build Application using script" 69 | # ./location_of_script_within_repo/buildscript.sh 70 | 71 | - name: Perform CodeQL Analysis 72 | uses: github/codeql-action/analyze@v2 73 | -------------------------------------------------------------------------------- /vendor/@mirasaki/logger/index.js: -------------------------------------------------------------------------------- 1 | const chalk = require('chalk'); 2 | const moment = require('moment'); 3 | 4 | const tagList = { 5 | SYSLOG: chalk.grey('[SYSLOG]'), 6 | SYSERR: chalk.red('[SYSERR]'), 7 | SUCCESS: chalk.green('[SUCCESS]'), 8 | INFO: chalk.blue('[INFO]'), 9 | DEBUG: chalk.magenta('[DEBUG]'), 10 | DATA: chalk.yellow('[DATA]'), 11 | COMMAND: chalk.white('[CMD]') 12 | }; 13 | 14 | const longestTagLength = Math.max(...Object.values(tagList).map(t => t.length)); 15 | const getTag = (tag) => `${tagList[tag]}${' '.repeat(longestTagLength - tagList[tag].length)}:`; 16 | const timestamp = () => `${chalk.cyan.bold(`[${moment.utc().format('HH:mm:ss')}]`)}`; 17 | 18 | module.exports = { 19 | syslog: (str) => console.info(`${timestamp()} ${getTag('SYSLOG')} ${str}`), 20 | syserr: (str) => console.error(`${timestamp()} ${getTag('SYSERR')} ${str}`), 21 | success: (str) => console.log(`${timestamp()} ${getTag('SUCCESS')} ${str}`), 22 | info: (str) => console.info(`${timestamp()} ${getTag('INFO')} ${str}`), 23 | debug: (str) => console.log(`${timestamp()} ${getTag('DEBUG')} ${str}`), 24 | data: (str) => console.log(`${timestamp()} ${getTag('DATA')} ${str}`), 25 | 26 | startLog: (identifier) => console.log(`${timestamp()} ${getTag('DEBUG')} ${chalk.greenBright('[START]')} ${identifier}`), 27 | endLog: (identifier) => console.log(`${timestamp()} ${getTag('DEBUG')} ${chalk.redBright('[ END ]')} ${identifier}`), 28 | 29 | timestamp, 30 | getExecutionTime: (hrtime) => { 31 | const timeSinceHrMs = ( 32 | process.hrtime(hrtime)[0] * 1000 33 | + hrtime[1] / 1000000 34 | ).toFixed(2); 35 | return `${chalk.yellowBright( 36 | (timeSinceHrMs / 1000).toFixed(2)) 37 | } seconds (${chalk.yellowBright(timeSinceHrMs)} ms)`; 38 | }, 39 | 40 | printErr: (err) => { 41 | if (!(err instanceof Error)) { 42 | console.error(err) 43 | return; 44 | } 45 | 46 | console.error( 47 | !err.stack 48 | ? chalk.red(err) 49 | : err.stack 50 | .split('\n') 51 | .map((msg, index) => { 52 | if (index === 0) { 53 | return chalk.red(msg); 54 | } 55 | 56 | const isFailedFunctionCall = index === 1; 57 | const traceStartIndex = msg.indexOf('('); 58 | const traceEndIndex = msg.lastIndexOf(')'); 59 | const hasTrace = traceStartIndex !== -1; 60 | const functionCall = msg.slice( 61 | msg.indexOf('at') + 3, 62 | hasTrace ? traceStartIndex - 1 : msg.length 63 | ); 64 | const trace = msg.slice(traceStartIndex, traceEndIndex + 1); 65 | 66 | return ` ${chalk.grey('at')} ${ 67 | isFailedFunctionCall 68 | ? `${chalk.redBright(functionCall)} ${chalk.red.underline(trace)}` 69 | : `${chalk.greenBright(functionCall)} ${chalk.grey(trace)}` 70 | }`; 71 | }) 72 | .join('\n') 73 | ) 74 | } 75 | }; 76 | -------------------------------------------------------------------------------- /src/commands/music-dj/move-song.js: -------------------------------------------------------------------------------- 1 | const { useQueue } = require('discord-player'); 2 | const { ChatInputCommand } = require('../../classes/Commands'); 3 | const { requireSessionConditions } = require('../../modules/music'); 4 | const { ApplicationCommandOptionType } = require('discord.js'); 5 | 6 | const FROM_OPTION_ID = 'from-position'; 7 | const TO_OPTION_ID = 'to-position'; 8 | 9 | module.exports = new ChatInputCommand({ 10 | global: true, 11 | data: { 12 | description: 'Move a song that is current in /queue', 13 | options: [ 14 | { 15 | name: FROM_OPTION_ID, 16 | description: 'The position of the source song', 17 | type: ApplicationCommandOptionType.Integer, 18 | required: true, 19 | min_value: 1, 20 | max_value: 999_999 21 | }, 22 | { 23 | name: TO_OPTION_ID, 24 | description: 'The destination position', 25 | type: ApplicationCommandOptionType.Integer, 26 | required: true, 27 | min_value: 1, 28 | max_value: 999_999 29 | } 30 | ] 31 | }, 32 | run: async (client, interaction) => { 33 | const { emojis } = client.container; 34 | const { 35 | member, guild, options 36 | } = interaction; 37 | const fromPosition = Number(options.getInteger(FROM_OPTION_ID)) - 1; 38 | const toPosition = Number(options.getInteger(TO_OPTION_ID)) - 1; 39 | 40 | // Check state 41 | if (!requireSessionConditions(interaction, true)) return; 42 | 43 | try { 44 | const queue = useQueue(guild.id); 45 | 46 | // Not enough songs in queue 47 | if ((queue?.size ?? 0) < 2) { 48 | interaction.reply(`${ emojis.error } ${ member }, not enough songs in queue to perform any move action - this command has been cancelled`); 49 | return; 50 | } 51 | 52 | // Check bounds/constraints 53 | const queueSizeZeroOffset = queue.size - 1; 54 | if ( 55 | fromPosition > queueSizeZeroOffset 56 | || toPosition > queueSizeZeroOffset 57 | ) { 58 | interaction.reply(`${ emojis.error } ${ member }, the \`${ 59 | fromPosition > queueSizeZeroOffset 60 | ? toPosition > queueSizeZeroOffset 61 | ? `${ FROM_OPTION_ID } and ${ TO_OPTION_ID }\` parameters are both` 62 | : FROM_OPTION_ID + '` parameter is' 63 | : TO_OPTION_ID + '` parameter is' 64 | } not within valid range of 1-${ queue.size } - this command has been cancelled`); 65 | return; 66 | } 67 | 68 | // Is same 69 | if (fromPosition === toPosition) { 70 | interaction.reply(`${ emojis.error } ${ member }, \`${ FROM_OPTION_ID }\` and \`${ TO_OPTION_ID }\` are identical - this command has been cancelled`); 71 | return; 72 | } 73 | 74 | // Swap src and dest 75 | queue.moveTrack(fromPosition, toPosition); 76 | // use toPosition, because it's after #swap 77 | const firstTrack = queue.tracks.data.at(toPosition); 78 | interaction.reply(`${ emojis.success } ${ member }, **\`${ firstTrack.title }\`** has been moved to position **\`${ toPosition + 1 }\`**`); 79 | } 80 | catch (e) { 81 | interaction.reply(`${ emojis.error } ${ member }, something went wrong:\n\n${ e.message }`); 82 | } 83 | } 84 | }); 85 | -------------------------------------------------------------------------------- /config.example.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {@type {module:Client~ClientConfiguration}} 3 | */ 4 | 5 | const { PermissionsBitField } = require('discord.js'); 6 | 7 | const config = { 8 | // Note: all the default# properties all configurable by commands 9 | // This is here so that you can configure everything in one go 10 | // without having to figure out different commands, 11 | // if you're not comfortable editing this, use the commands 12 | 13 | // Note: default# properties only take affect the first time 14 | // playback is initialized in your server/guild 15 | 16 | // Between 0 and 100 17 | // 100 is obnoxiously loud and will f*** your ears 18 | defaultVolume: 5, 19 | 20 | // The default repeat mode 21 | // 0 - Off | Don't repeat 22 | // 1 - Track | Repeat current track, always - until skipped 23 | // 2 - Queue | Repeat the entire queue, finished songs get added back at the end of the current queue 24 | // 3 - Autoplay | Autoplay recommended music when queue is empty 25 | // 26 | // 3 = 24/7 autoplay/continuous radio if uninterrupted - only use if you have 27 | // bandwidth for days 28 | defaultRepeatMode: 0, 29 | 30 | // Amount of seconds to stay in the voice channel 31 | // when playback is finished 32 | // Default: 2 minutes 33 | defaultLeaveOnEndCooldown: 120, 34 | 35 | // Should the bot leave the voice-channel if there's no other members 36 | defaultLeaveOnEmpty: true, 37 | 38 | // Time amount of seconds to stay in the voice channel 39 | // when channel is empty/no other members aside from bot 40 | // Only active when leaveOnEmpty is true 41 | // Default: 2 minutes 42 | defaultLeaveOnEmptyCooldown: 120, 43 | 44 | // When true, will create a thread when the voice session is first initialized 45 | // and continue to send music/queue events in that thread instead of flooding 46 | // the channel 47 | defaultUseThreadSessions: true, 48 | 49 | // When true, and defaultUseThreadSessions is true, will only allow commands involving 50 | // the current session to be used in the created session Thread channel 51 | defaultThreadSessionStrictCommandChannel: true, 52 | 53 | // Plugins/Music source extractors 54 | plugins: { 55 | fileAttachments: true, 56 | soundCloud: false, 57 | appleMusic: true, 58 | vimeo: true, 59 | reverbNation: true, 60 | }, 61 | 62 | // Bot activity 63 | presence: { 64 | // One of online, idle, invisible, dnd 65 | status: 'online', 66 | activities: [ 67 | { 68 | name: '/play', 69 | // One of Playing, Streaming, Listening, Watching 70 | type: 'Listening' 71 | } 72 | ] 73 | }, 74 | 75 | // Permission config 76 | permissions: { 77 | // Bot Owner, highest permission level (5) 78 | ownerId: '290182686365188096', 79 | 80 | // Bot developers, second to highest permission level (4) 81 | developers: [ '' ] 82 | }, 83 | 84 | // The Discord server invite to your Support server 85 | supportServerInviteLink: 'https://discord.gg/mirasaki', 86 | 87 | // Additional permissions that are considered required when generating 88 | // the bot invite link with /invite 89 | permissionsBase: [ 90 | PermissionsBitField.Flags.ViewChannel, 91 | PermissionsBitField.Flags.SendMessages, 92 | PermissionsBitField.Flags.SendMessagesInThreads 93 | ] 94 | }; 95 | 96 | module.exports = config; 97 | -------------------------------------------------------------------------------- /src/commands/music-dj/swap-songs.js: -------------------------------------------------------------------------------- 1 | const { useQueue } = require('discord-player'); 2 | const { ChatInputCommand } = require('../../classes/Commands'); 3 | const { requireSessionConditions } = require('../../modules/music'); 4 | const { ApplicationCommandOptionType } = require('discord.js'); 5 | 6 | const FIRST_POSITION_OPTION_ID = 'first-position'; 7 | const SECOND_POSITION_OPTION_ID = 'second-position'; 8 | 9 | module.exports = new ChatInputCommand({ 10 | global: true, 11 | data: { 12 | description: 'Swap songs that are current in /queue around by position', 13 | options: [ 14 | { 15 | name: FIRST_POSITION_OPTION_ID, 16 | description: 'The position of the first track to swap / source song', 17 | type: ApplicationCommandOptionType.Integer, 18 | required: true, 19 | min_value: 1, 20 | max_value: 999_999 21 | }, 22 | { 23 | name: SECOND_POSITION_OPTION_ID, 24 | description: 'The position of the second track to swap / destination song', 25 | type: ApplicationCommandOptionType.Integer, 26 | required: true, 27 | min_value: 1, 28 | max_value: 999_999 29 | } 30 | ] 31 | }, 32 | run: async (client, interaction) => { 33 | const { emojis } = client.container; 34 | const { 35 | member, guild, options 36 | } = interaction; 37 | const firstPosition = Number(options.getInteger(FIRST_POSITION_OPTION_ID)) - 1; 38 | const secondPosition = Number(options.getInteger(SECOND_POSITION_OPTION_ID)) - 1; 39 | 40 | // Check state 41 | if (!requireSessionConditions(interaction, true)) return; 42 | 43 | try { 44 | const queue = useQueue(guild.id); 45 | 46 | // Not enough songs in queue 47 | if ((queue?.size ?? 0) < 2) { 48 | interaction.reply(`${ emojis.error } ${ member }, not enough songs in queue to perform any swap action - this command has been cancelled`); 49 | return; 50 | } 51 | 52 | // Check bounds/constraints 53 | const queueSizeZeroOffset = queue.size - 1; 54 | if ( 55 | firstPosition > queueSizeZeroOffset 56 | || secondPosition > queueSizeZeroOffset 57 | ) { 58 | interaction.reply(`${ emojis.error } ${ member }, the \`${ 59 | firstPosition > queueSizeZeroOffset 60 | ? secondPosition > queueSizeZeroOffset 61 | ? `${ FIRST_POSITION_OPTION_ID } and ${ SECOND_POSITION_OPTION_ID }\` parameters are both` 62 | : FIRST_POSITION_OPTION_ID + '` parameter is' 63 | : SECOND_POSITION_OPTION_ID + '` parameter is' 64 | } not within valid range of 1-${ queue.size } - this command has been cancelled`); 65 | return; 66 | } 67 | 68 | // Is same 69 | if (firstPosition === secondPosition) { 70 | interaction.reply(`${ emojis.error } ${ member }, \`${ FIRST_POSITION_OPTION_ID }\` and \`${ SECOND_POSITION_OPTION_ID }\` are identical - this command has been cancelled`); 71 | return; 72 | } 73 | 74 | // Swap src and dest 75 | queue.swapTracks(firstPosition, secondPosition); 76 | // Reversed, they've been switched 77 | const firstTrack = queue.tracks.data.at(secondPosition); 78 | const secondTrack = queue.tracks.data.at(firstPosition); 79 | interaction.reply(`${ emojis.success } ${ member }, **\`${ firstTrack.title }\`** has been swapped with **\`${ secondTrack.title }\`**`); 80 | } 81 | catch (e) { 82 | interaction.reply(`${ emojis.error } ${ member }, something went wrong:\n\n${ e.message }`); 83 | } 84 | } 85 | }); 86 | -------------------------------------------------------------------------------- /src/commands/music-dj/equalizer.js: -------------------------------------------------------------------------------- 1 | const { EqualizerConfigurationPreset, useQueue } = require('discord-player'); 2 | const { ApplicationCommandOptionType } = require('discord.js'); 3 | const { ChatInputCommand } = require('../../classes/Commands'); 4 | const { requireSessionConditions } = require('../../modules/music'); 5 | const { 6 | db, getGuildSettings, saveDb 7 | } = require('../../modules/db'); 8 | 9 | module.exports = new ChatInputCommand({ 10 | global: true, 11 | data: { 12 | description: 'Configure the equalizer preset', 13 | options: [ 14 | { 15 | name: 'equalizer', 16 | description: 'The equalizer preset to use', 17 | type: ApplicationCommandOptionType.String, 18 | choices: [ 19 | { 20 | name: 'Disable', 21 | value: 'null' 22 | }, 23 | ...Object.keys(EqualizerConfigurationPreset) 24 | .map((e) => ({ 25 | name: e, 26 | value: e 27 | })) 28 | ], 29 | required: true 30 | }, 31 | { 32 | name: 'persistent', 33 | description: 'Persist the selected equalizer preset. Applies selected preset to new playback sessions', 34 | type: ApplicationCommandOptionType.Boolean, 35 | required: false 36 | } 37 | ] 38 | }, 39 | run: async (client, interaction) => { 40 | const { emojis } = client.container; 41 | const { 42 | member, guild, options 43 | } = interaction; 44 | const equalizer = options.getString('equalizer') ?? 'null'; 45 | const shouldSave = options.getBoolean('persistent') ?? false; 46 | 47 | // Check conditions 48 | if (!requireSessionConditions(interaction, true)) return; 49 | 50 | // Check is playing 51 | const queue = useQueue(guild.id); 52 | if (!queue.isPlaying()) { 53 | interaction.reply(`${ emojis.error } ${ member }, please initialize playback/start a music session first - this command has been cancelled`); 54 | return; 55 | } 56 | 57 | // Check can be applied 58 | if (!queue.filters.equalizer) { 59 | interaction.reply(`${ emojis.error } ${ member }, equalizers can't be applied to this queue - this command has been cancelled`); 60 | return; 61 | } 62 | 63 | try { 64 | if (equalizer === 'null' && queue.filters.equalizer) queue.filters.equalizer.disable(); 65 | else if (queue.filters.equalizer) { 66 | queue.filters.equalizer.setEQ(EqualizerConfigurationPreset[equalizer]); 67 | queue.filters.equalizer.enable(); 68 | } 69 | 70 | // Save for persistency 71 | if (shouldSave) { 72 | // Perform and notify collection that the document has changed 73 | const guilds = db.getCollection('guilds'); 74 | const settings = getGuildSettings(guild.id); 75 | settings.equalizer = equalizer; 76 | guilds.update(settings); 77 | saveDb(); 78 | } 79 | 80 | // Feedback 81 | await interaction.reply({ content: `${ emojis.success } ${ member }, new equalizer preset applied (**\`${ equalizer === 'null' ? 'Disabled' : equalizer }\`**)\n${ shouldSave 82 | ? 'Equalizer preset configuration will be persisted, meaning the preset is automatically applied when a new playback session is initialized' 83 | : 'Equalizer preset configuration will not be persisted, meaning the next time a playback session is initialized, your previous **persistent** equalizer settings will be used' }` }); 84 | } 85 | catch (e) { 86 | interaction.reply(`${ emojis.error } ${ member }, something went wrong:\n\n${ e.message }`); 87 | } 88 | } 89 | }); 90 | -------------------------------------------------------------------------------- /src/commands/music/lyrics.js: -------------------------------------------------------------------------------- 1 | const { ApplicationCommandOptionType, EmbedBuilder } = require('discord.js'); 2 | const { ChatInputCommand } = require('../../classes/Commands'); 3 | const { lyricsExtractor: lyricsExtractorSuper } = require('@discord-player/extractor'); 4 | const { useQueue } = require('discord-player'); 5 | const { colorResolver } = require('../../util'); 6 | const { EMBED_DESCRIPTION_MAX_LENGTH } = require('../../constants'); 7 | const { requireSessionConditions } = require('../../modules/music'); 8 | const lyricsExtractor = lyricsExtractorSuper(); 9 | 10 | module.exports = new ChatInputCommand({ 11 | global: true, 12 | cooldown: { 13 | usages: 5, 14 | duration: 30, 15 | type: 'guild' 16 | }, 17 | data: { 18 | description: 'Display the lyrics for a specific song', 19 | options: [ 20 | { 21 | name: 'query-lyrics', 22 | type: ApplicationCommandOptionType.String, 23 | autocomplete: true, 24 | description: 'The music to search/query', 25 | required: false 26 | }, 27 | { 28 | name: 'query-lyrics-no-auto-complete', 29 | type: ApplicationCommandOptionType.String, 30 | description: 'The music to search/query - doesn\'t utilize auto-complete, meaning your query won\'t be modified', 31 | required: false 32 | } 33 | ] 34 | }, 35 | // eslint-disable-next-line sonarjs/cognitive-complexity 36 | run: async (client, interaction) => { 37 | const { emojis } = client.container; 38 | const { member, guild } = interaction; 39 | let query = interaction.options.getString('query-lyrics') ?? interaction.options.getString('query-lyrics-no-auto-complete') ?? useQueue(guild.id)?.currentTrack?.title; 40 | if (!query) { 41 | interaction.reply(`${ emojis.error } ${ member }, please provide a query, currently playing song can only be used when playback is active - this command has been cancelled`); 42 | return; 43 | } 44 | 45 | // Check state 46 | if (!requireSessionConditions(interaction, false, false, false)) return; 47 | 48 | // Let's defer the interaction as things can take time to process 49 | await interaction.deferReply(); 50 | 51 | query &&= query.toLowerCase(); 52 | 53 | try { 54 | const res = await lyricsExtractor 55 | .search(query) 56 | .catch(() => null); 57 | 58 | if (!res) { 59 | interaction.editReply(`${ emojis.error } ${ member }, could not find lyrics for **\`${ query }\`**, please try a different query`); 60 | return; 61 | } 62 | 63 | const { 64 | title, 65 | fullTitle, 66 | thumbnail, 67 | image, 68 | url, 69 | artist, 70 | lyrics 71 | } = res; 72 | 73 | let description = lyrics; 74 | if (description && description.length > EMBED_DESCRIPTION_MAX_LENGTH) description = description.slice(0, EMBED_DESCRIPTION_MAX_LENGTH - 3) + '...'; 75 | 76 | const lyricsEmbed = new EmbedBuilder() 77 | .setColor(colorResolver()) 78 | .setTitle(title ?? 'Unknown') 79 | .setAuthor({ 80 | name: artist.name ?? 'Unknown', 81 | url: artist.url ?? null, 82 | iconURL: artist.image ?? null 83 | }) 84 | .setDescription(description ?? 'Instrumental') 85 | .setURL(url); 86 | 87 | if (image || thumbnail) lyricsEmbed.setImage(image ?? thumbnail); 88 | if (fullTitle) lyricsEmbed.setFooter({ text: fullTitle }); 89 | 90 | // Feedback 91 | await interaction.editReply({ embeds: [ lyricsEmbed ] }); 92 | } 93 | catch (e) { 94 | interaction.editReply(`${ emojis.error } ${ member }, something went wrong:\n\n${ e.message }`); 95 | } 96 | } 97 | }); 98 | -------------------------------------------------------------------------------- /src/interactions/buttons/play-button.js: -------------------------------------------------------------------------------- 1 | const { ComponentCommand } = require('../../classes/Commands'); 2 | const { MS_IN_ONE_SECOND } = require('../../constants'); 3 | const { getGuildSettings } = require('../../modules/db'); 4 | const { requireSessionConditions, musicEventChannel } = require('../../modules/music'); 5 | const { clientConfig } = require('../../util'); 6 | const { 7 | useMainPlayer, useQueue, EqualizerConfigurationPreset 8 | } = require('discord-player'); 9 | const player = useMainPlayer(); 10 | 11 | module.exports = new ComponentCommand({ run: async (client, interaction) => { 12 | const { 13 | guild, customId, member 14 | } = interaction; 15 | const { emojis } = client.container; 16 | const [ 17 | , // @ char, marks dynamic command/action 18 | , // command name 19 | componentMemberId, 20 | url 21 | ] = customId.split('@'); 22 | if (member.id !== componentMemberId) { 23 | interaction.reply(`${ emojis.error } ${ member }, this component isn't meant for you, use the \`/search\` command yourself - this action has been cancelled`); 24 | return; 25 | } 26 | 27 | // Check state 28 | if (!requireSessionConditions(interaction, false, true, false)) return; 29 | 30 | // Ok, safe to access voice channel and initialize 31 | const channel = member.voice?.channel; 32 | 33 | // Let's defer the interaction as things can take time to process 34 | await interaction.deferReply(); 35 | 36 | try { 37 | // Resolve settings 38 | const settings = getGuildSettings(guild.id); 39 | 40 | // Use thread channels 41 | let eventChannel = interaction.channel; 42 | if (settings.useThreadSessions) { 43 | eventChannel = await musicEventChannel(client, interaction); 44 | if (eventChannel === false) return; 45 | } 46 | 47 | // Resolve volume for this session - clamp max 100 48 | let volume = settings.volume ?? clientConfig.defaultVolume; 49 | // Note: Don't increase volume for attachments as having to check 50 | // and adjust on every song end isn't perfect 51 | volume = Math.min(100, volume); 52 | 53 | // Resolve leave on end cooldown 54 | const leaveOnEndCooldown = ((settings.leaveOnEndCooldown ?? 2) * MS_IN_ONE_SECOND); 55 | const leaveOnEmptyCooldown = ((settings.leaveOnEmptyCooldown ?? 2) * MS_IN_ONE_SECOND); 56 | 57 | // nodeOptions are the options for guild node (aka your queue in simple word) 58 | // we can access this metadata object using queue.metadata later on 59 | const { track } = await player.play( 60 | channel, 61 | url, 62 | { 63 | requestedBy: interaction.user, 64 | nodeOptions: { 65 | skipOnNoStream: true, 66 | leaveOnEnd: true, 67 | leaveOnEndCooldown, 68 | leaveOnEmpty: settings.leaveOnEmpty, 69 | leaveOnEmptyCooldown, 70 | volume, 71 | metadata: { 72 | channel: eventChannel, 73 | member, 74 | timestamp: interaction.createdTimestamp 75 | } 76 | } 77 | } 78 | ); 79 | 80 | // Use queue 81 | const queue = useQueue(guild.id); 82 | 83 | // Now that we have a queue initialized, 84 | // let's check if we should set our default repeat-mode 85 | if (Number.isInteger(settings.repeatMode)) queue.setRepeatMode(settings.repeatMode); 86 | 87 | // Set persistent equalizer preset 88 | if ( 89 | queue.filters.equalizer 90 | && settings.equalizer 91 | && settings.equalizer !== 'null' 92 | ) { 93 | queue.filters.equalizer.setEQ(EqualizerConfigurationPreset[settings.equalizer]); 94 | queue.filters.equalizer.enable(); 95 | } 96 | else if (queue.filters.equalizer) queue.filters.equalizer.disable(); 97 | 98 | // Feedback 99 | await interaction.editReply(`${ emojis.success } ${ member }, enqueued **\`${ track.title }\`**!`); 100 | } 101 | catch (e) { 102 | interaction.editReply(`${ emojis.error } ${ member }, something went wrong:\n\n${ e.message }`); 103 | } 104 | } }); 105 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mirasaki-music-bot", 3 | "description": "A free, open-source JavaScript music bot created with discord.js and discord-player", 4 | "version": "1.2.2", 5 | "main": "src/index.js", 6 | "scripts": { 7 | "start": "node .", 8 | "dev": "nodemon run node --trace-warnings .", 9 | "test": "node . mode=test", 10 | "commit": "cz", 11 | "docker:build": "docker build --tag mirasaki-music-bot .", 12 | "docker:shell": "docker run -it --rm mirasaki-music-bot sh", 13 | "docker:start": "docker run -it -p 3000:3000 --env-file ./.env -d --name mirasaki-music-bot mirasaki-music-bot", 14 | "docker:restart": "docker restart mirasaki-music-bot", 15 | "docker:stop": "docker stop mirasaki-music-bot", 16 | "docker:kill": "docker rm -f mirasaki-music-bot", 17 | "docker:purge": "docker rm -fv mirasaki-music-bot", 18 | "docker:logs": "docker logs mirasaki-music-bot -f", 19 | "docker:image": "docker image tag mirasaki-music-bot mirasaki/mirasaki-music-bot", 20 | "docker:push": "docker push mirasaki/mirasaki-music-bot", 21 | "docker:update": "git pull && npm install && npm run docker:stop && npm run docker:kill && npm run docker:build && npm run docker:start", 22 | "docker:dev:build": "docker build --tag mirasaki-music-bot-dev -f ./.devcontainer/.Dockerfile .", 23 | "docker:dev:start": "docker run -it --rm -v $(pwd):/app -v /app/node_modules -p 3000:3000 -p 9229:9229 -w /app mirasaki-music-bot-dev", 24 | "pm2:start": "pm2 start --name=mirasaki-music-bot npm -- run start", 25 | "pm2:stop": "pm2 stop mirasaki-music-bot", 26 | "pm2:purge": "pm2 stop mirasaki-music-bot && pm2 delete mirasaki-music-bot && pm2 reset mirasaki-music-bot", 27 | "pm2:logs": "pm2 logs --lines 300 mirasaki-music-bot", 28 | "pm2:logsError": "pm2 logs --err --lines 300 mirasaki-music-bot", 29 | "lint": "eslint src", 30 | "linter": "eslint src --fix", 31 | "writeLinter": "eslint src --output-file linter-output.txt" 32 | }, 33 | "dependencies": { 34 | "@discord-player/extractor": "^4.5.1", 35 | "@discordjs/rest": "^2.4.0", 36 | "@mirasaki/logger": "file:./vendor/@mirasaki/logger", 37 | "chalk": "^4.1.2", 38 | "common-tags": "^1.8.2", 39 | "discord-player": "^6.7.1", 40 | "discord.js": "^14.16.3", 41 | "dotenv": "^16.4.7", 42 | "lokijs": "^1.5.12", 43 | "mediaplex": "^1.0.0" 44 | }, 45 | "devDependencies": { 46 | "@semantic-release/changelog": "^6.0.3", 47 | "@semantic-release/commit-analyzer": "^13.0.0", 48 | "@semantic-release/git": "^10.0.1", 49 | "@semantic-release/github": "^11.0.1", 50 | "@semantic-release/npm": "^12.0.1", 51 | "@semantic-release/release-notes-generator": "^14.0.1", 52 | "commitizen": "^4.3.1", 53 | "cz-conventional-changelog": "^3.3.0", 54 | "eslint": "^8.56.0", 55 | "eslint-plugin-sonarjs": "^3.0.1", 56 | "nodemon": "^3.1.7", 57 | "semantic-release": "^24.2.1" 58 | }, 59 | "repository": { 60 | "type": "git", 61 | "url": "git+https://github.com/Mirasaki/mirasaki-music-bot.git" 62 | }, 63 | "keywords": [ 64 | "nodejs", 65 | "bot-template", 66 | "template", 67 | "boilerplate", 68 | "discord-api", 69 | "typings", 70 | "discord", 71 | "discordjs", 72 | "v14", 73 | "discord-bot", 74 | "music-bot", 75 | "music", 76 | "slash-commands", 77 | "buttons", 78 | "modals", 79 | "autocomplete", 80 | "context-menus", 81 | "select-menus", 82 | "documented" 83 | ], 84 | "author": "Richard Hillebrand (Mirasaki)", 85 | "license": "MIT", 86 | "bugs": { 87 | "url": "https://github.com/Mirasaki/mirasaki-music-bot/issues" 88 | }, 89 | "homepage": "https://github.com/Mirasaki/mirasaki-music-bot#readme", 90 | "optionalDependencies": { 91 | "fsevents": "^2.3.3" 92 | }, 93 | "config": { 94 | "commitizen": { 95 | "path": "./node_modules/cz-conventional-changelog" 96 | } 97 | }, 98 | "engineStrict": true, 99 | "engines": { 100 | "node": ">=20.0.0 <22.0.0" 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/commands/developer/exec.js: -------------------------------------------------------------------------------- 1 | const { exec } = require('child_process'); 2 | const { EMBED_FIELD_VALUE_MAX_LENGTH } = require('../../constants'); 3 | const { ChatInputCommand } = require('../../classes/Commands'); 4 | const { colorResolver, getRuntime } = require('../../util'); 5 | 6 | module.exports = new ChatInputCommand({ 7 | enabled: process.env.NODE_ENV !== 'production', 8 | permLevel: 'Developer', 9 | clientPerms: [ 'EmbedLinks', 'AttachFiles' ], 10 | data: { 11 | description: 'Execute console commands', 12 | options: [ 13 | { 14 | // STRING 15 | type: 3, 16 | name: 'command', 17 | description: 'The command to execute', 18 | required: true, 19 | min_length: 1, 20 | // API max 21 | max_length: 6000 22 | } 23 | ] 24 | }, 25 | 26 | run: async (client, interaction) => { 27 | // Destructuring 28 | const { member, options } = interaction; 29 | const { emojis, colors } = client.container; 30 | 31 | // Definitions 32 | const commandToExec = options.getString('command'); 33 | const execStartTime = process.hrtime.bigint(); 34 | 35 | // Deferring our reply (3 seconds threshold) 36 | await interaction.deferReply(); 37 | 38 | // Execute the user provided command 39 | exec(commandToExec, (err, stdout) => { 40 | // Get runtime 41 | const timeSinceHr = getRuntime(execStartTime); 42 | const timeSinceStr = `${ timeSinceHr.seconds } seconds (${ timeSinceHr.ms } ms)`; 43 | 44 | // Building our embed object 45 | let outputStr = undefined; 46 | const files = []; 47 | const execEmbed = { 48 | description: `:inbox_tray: **Input:**\n\`\`\`bash\n${ commandToExec }\n\`\`\``, 49 | fields: [ 50 | { 51 | name: 'Time taken', 52 | value: `\`\`\`fix\n${ timeSinceStr }\`\`\``, 53 | inline: false 54 | } 55 | ] 56 | }; 57 | 58 | // Properly handle potential errors 59 | if (err) { 60 | outputStr = `${ emojis.error } ${ member }, error encountered while executing console command.`; 61 | execEmbed.color = colorResolver(colors.error); 62 | 63 | // Add output embed field to the start of the Array 64 | const activeOutput = err.stack || err; 65 | 66 | execEmbed.fields.unshift({ 67 | name: ':outbox_tray: Output:', 68 | value: `\`\`\`js\n${ 69 | activeOutput.length <= EMBED_FIELD_VALUE_MAX_LENGTH 70 | ? activeOutput 71 | : `Error trace over ${ EMBED_FIELD_VALUE_MAX_LENGTH } characters, uploaded as attachment instead` 72 | }\`\`\``, 73 | inline: false 74 | }); 75 | 76 | // Upload as file attachment if output exceeds max length 77 | if (activeOutput.length > EMBED_FIELD_VALUE_MAX_LENGTH) { 78 | files.push({ 79 | attachment: Buffer.from(activeOutput), 80 | name: 'error-trace.txt' 81 | }); 82 | } 83 | } 84 | 85 | // No error encountered 86 | else { 87 | outputStr = `${ emojis.success } ${ member }, console command executed.`; 88 | execEmbed.color = colorResolver(colors.success); 89 | 90 | // Add output embed field to the start of the Array 91 | execEmbed.fields.unshift({ 92 | name: ':outbox_tray: Output:', 93 | value: `\`\`\`js\n${ 94 | stdout.length <= EMBED_FIELD_VALUE_MAX_LENGTH 95 | ? stdout 96 | : `Output over ${ EMBED_FIELD_VALUE_MAX_LENGTH } characters, uploaded as attachment instead` 97 | }\`\`\``, 98 | inline: false 99 | }); 100 | 101 | // Upload as file attachment if output exceeds max length 102 | if (stdout.length > EMBED_FIELD_VALUE_MAX_LENGTH) { 103 | files.push({ 104 | attachment: Buffer.from(stdout), 105 | name: 'stdout.txt' 106 | }); 107 | } 108 | } 109 | 110 | // Final user feedback 111 | interaction.editReply({ 112 | content: outputStr, 113 | embeds: [ execEmbed ], 114 | files 115 | }); 116 | }); 117 | } 118 | }); 119 | -------------------------------------------------------------------------------- /src/commands/system/stats.js: -------------------------------------------------------------------------------- 1 | const { ChatInputCommand } = require('../../classes/Commands'); 2 | const { stripIndents } = require('common-tags'); 3 | const { version } = require('discord.js'); 4 | const { BYTES_IN_KIB } = require('../../constants'); 5 | const { colorResolver, msToHumanReadableTime } = require('../../util'); 6 | 7 | const discordVersion = version.indexOf('dev') < 0 ? version : version.slice(0, version.indexOf('dev') + 3); 8 | const discordVersionDocLink = `https://discord.js.org/#/docs/discord.js/v${ discordVersion.split('.')[0] }/general/welcome`; 9 | const nodeVersionDocLink = `https://nodejs.org/docs/latest-${ process.version.split('.')[0] }.x/api/#`; 10 | 11 | module.exports = new ChatInputCommand({ 12 | global: true, 13 | cooldown: { 14 | // Use channel cooldown type instead of default member 15 | type: 'channel', 16 | usages: 1, 17 | duration: 30 18 | }, 19 | clientPerms: [ 'EmbedLinks' ], 20 | alias: [ 'ping' ], 21 | data: { description: 'Displays bot stats' }, 22 | 23 | run: async (client, interaction) => { 24 | const { emojis } = client.container; 25 | 26 | // Calculating our API latency 27 | const latency = Math.round(client.ws.ping); 28 | const sent = await interaction.reply({ 29 | content: 'Pinging...', 30 | fetchReply: true 31 | }); 32 | const fcLatency = sent.createdTimestamp - interaction.createdTimestamp; 33 | 34 | // Utility function for getting appropriate status emojis 35 | const getMsEmoji = (ms) => { 36 | let emoji = undefined; 37 | 38 | for (const [ key, value ] of Object.entries({ 39 | 250: '🟢', 40 | 500: '🟡', 41 | 1000: '🟠' 42 | })) if (ms <= key) { 43 | emoji = value; 44 | break; 45 | } 46 | return (emoji ??= '🔴'); 47 | }; 48 | 49 | // Memory Variables 50 | const memoryUsage = process.memoryUsage(); 51 | const memoryUsedInMB = memoryUsage.heapUsed / BYTES_IN_KIB / BYTES_IN_KIB; 52 | const memoryAvailableInMB = memoryUsage.heapTotal 53 | / BYTES_IN_KIB / BYTES_IN_KIB; 54 | const objCacheSizeInMB = memoryUsage.external / BYTES_IN_KIB / BYTES_IN_KIB; 55 | 56 | // Replying to the interaction with our embed data 57 | interaction.editReply({ 58 | content: '\u200b', 59 | embeds: [ 60 | { 61 | color: colorResolver(), 62 | author: { 63 | name: `${ client.user.username }`, 64 | iconURL: client.user.displayAvatarURL() 65 | }, 66 | fields: [ 67 | { 68 | name: 'Latency', 69 | value: stripIndents` 70 | ${ getMsEmoji(latency) } **API Latency:** ${ latency } ms 71 | ${ getMsEmoji(fcLatency) } **Full Circle Latency:** ${ fcLatency } ms 72 | `, 73 | inline: true 74 | }, 75 | { 76 | name: 'Memory', 77 | value: stripIndents` 78 | 💾 **Memory Usage:** ${ memoryUsedInMB.toFixed(2) }/${ memoryAvailableInMB.toFixed(2) } MB 79 | ♻️ **Cache Size:** ${ objCacheSizeInMB.toFixed(2) } MB 80 | `, 81 | inline: true 82 | }, 83 | { 84 | name: 'Uptime', 85 | value: stripIndents`**📊 I've been online for ${ msToHumanReadableTime(Date.now() - client.readyTimestamp) }**`, 86 | inline: false 87 | }, 88 | { 89 | name: 'System', 90 | value: stripIndents` 91 | ⚙️ **Discord.js Version:** [v${ discordVersion }](${ discordVersionDocLink }) 92 | ⚙️ **Node Version:** [${ process.version }](${ nodeVersionDocLink }) 93 | `, 94 | inline: true 95 | }, 96 | { 97 | name: 'Stats', 98 | value: stripIndents` 99 | 👪 **Servers:** ${ client.guilds.cache.size.toLocaleString('en-US') } 100 | 🙋 **Users:** ${ client.guilds.cache.reduce((previousValue, currentValue) => previousValue += currentValue.memberCount, 0).toLocaleString('en-US') } 101 | `, 102 | inline: true 103 | } 104 | ], 105 | footer: { text: `Made with ❤️ by Mirasaki#0001 ${ emojis.separator } Open to collaborate ${ emojis.separator } me@mirasaki.dev` } 106 | } 107 | ] 108 | }); 109 | } 110 | }); 111 | -------------------------------------------------------------------------------- /src/client.js: -------------------------------------------------------------------------------- 1 | /** 2 | * A collection of client-extensions, containerized under `client.container`. Exported from the `/src/client.js` file 3 | * @module Client 4 | */ 5 | 6 | const { Collection } = require('discord.js'); 7 | 8 | // Local imports 9 | const { clientConfig } = require('./util'); 10 | const config = clientConfig; 11 | const emojis = require('./config/emojis'); 12 | const colors = require('./config/colors'); 13 | 14 | // Building collections 15 | const Commands = new Collection(); 16 | const ContextMenus = new Collection(); 17 | const Buttons = new Collection(); 18 | const Modals = new Collection(); 19 | const AutoCompletes = new Collection(); 20 | const SelectMenus = new Collection(); 21 | 22 | /** 23 | * The `discord.js` Client class/object 24 | * @external DiscordClient 25 | * @see {@link https://discord.js.org/#/docs/discord.js/main/class/Client} 26 | */ 27 | 28 | /** 29 | * Discord API Gateway Intents Bits 30 | * @external DiscordGatewayIntentBits 31 | * @see {@link https://discord-api-types.dev/api/discord-api-types-v10/enum/GatewayIntentBits} 32 | */ 33 | 34 | /** 35 | * The status of this presence, online, idle or dnd 36 | * @external DiscordClientPresenceStatus 37 | * @see {@link https://discord.js.org/#/docs/discord.js/main/typedef/ClientPresenceStatus} 38 | */ 39 | 40 | /** 41 | * @external DiscordActivityOptions 42 | * @see {@link https://discord.js.org/#/docs/discord.js/main/typedef/ActivityOptions} 43 | */ 44 | 45 | /** 46 | * @typedef {Object} ClientConfigPresence 47 | * @property {external:DiscordClientPresenceStatus} status The client's status (online, busy, dnd, offline) 48 | * @property {Array} activities Array of client activities 49 | */ 50 | 51 | /** 52 | * @typedef {Object} ClientConfigPermissions 53 | * @property {string} ownerId The bot owners's Discord user id 54 | * @property {Array} developers Array of Discord user id's representing active developers 55 | */ 56 | 57 | /** 58 | * @typedef {Object} ClientConfiguration 59 | * @property {module:Client~ClientConfigPresence} presence Client presence configuration 60 | * @property {module:Client~ClientConfigPermissions} permissions Internal permission configuration 61 | * @property {string} supportServerInviteLink The link to the Discord server where bot support is offered 62 | */ 63 | 64 | /** 65 | * @typedef {Object} ClientEmojiConfiguration 66 | * @property {string} success Emoji prefix that indicates a successful operation/action 67 | * @property {string} error Emoji prefix that indicates something went wrong 68 | * @property {string} wait Emojis prefix that indicates the client is processing 69 | * @property {string} info Emoji prefix that indicates a general tip 70 | * @property {string} separator Emoji prefix used as a separator 71 | */ 72 | 73 | /** 74 | * @typedef {Object} ClientColorConfiguration 75 | * @property {string} main The main color/primary color. Used in most embeds. 76 | * @property {string} invisible The color that appears invisible in Discord dark mode 77 | * @property {string} success The color used in embeds that display a success message 78 | * @property {string} error The color used in embeds that display an error 79 | */ 80 | 81 | /** 82 | * @typedef {Object} ClientContainer 83 | * @property {module:Client~ClientConfiguration} config The discord client configuration 84 | * @property {module:Client~ClientEmojiConfiguration} emojis An object with defined emoji keys 85 | * @property {module:Client~ClientColorConfiguration} colors An object with defined color keys 86 | * @property {Collection} commands Chat Input commands 87 | * @property {Collection} contextMenus Context Menu commands 88 | * @property {Collection} buttons Button commands 89 | * @property {Collection} modals Modal commands 90 | * @property {Collection} autoCompletes Autocomplete commands 91 | * @property {Collection} selectMenus Select Menu commands 92 | */ 93 | 94 | /** 95 | * @typedef {external:DiscordClient} Client 96 | * @property {module:Client~ClientContainer} container Our containerized client extensions 97 | */ 98 | 99 | /** 100 | * @type {module:Client~ClientContainer} 101 | */ 102 | module.exports = { 103 | // Config 104 | config, 105 | emojis, 106 | colors, 107 | 108 | // Collections 109 | commands: Commands, 110 | contextMenus: ContextMenus, 111 | buttons: Buttons, 112 | modals: Modals, 113 | autoCompletes: AutoCompletes, 114 | selectMenus: SelectMenus 115 | }; 116 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [1.2.2](https://github.com/Mirasaki/mirasaki-music-bot/compare/v1.2.1...v1.2.2) (2024-08-10) 2 | 3 | 4 | ### Bug Fixes 5 | 6 | * remove support for YouTube and Spotify ([872d2e5](https://github.com/Mirasaki/mirasaki-music-bot/commit/872d2e5bf340dec7836c349f354d46256465d866)) 7 | 8 | ## [1.1.9](https://github.com/Mirasaki/mirasaki-music-bot/compare/v1.1.8...v1.1.9) (2024-07-16) 9 | 10 | 11 | ### Bug Fixes 12 | 13 | * catch ffmpeg toggle exception ([e34e06f](https://github.com/Mirasaki/mirasaki-music-bot/commit/e34e06f059e14fed5f29214942f2d917ca7cc2d9)) 14 | 15 | ## [1.1.8](https://github.com/Mirasaki/mirasaki-music-bot/compare/v1.1.7...v1.1.8) (2024-07-11) 16 | 17 | 18 | ### Bug Fixes 19 | 20 | * regenerate package-lock ([6f8cdd5](https://github.com/Mirasaki/mirasaki-music-bot/commit/6f8cdd5af02075bc52db5f52a9396492388a9a05)) 21 | * set min node version to 18 ([d56cd61](https://github.com/Mirasaki/mirasaki-music-bot/commit/d56cd615888340c2fdf21e3da57e987e4dfb03ff)) 22 | * use new youtube extractor provider ([2e98af1](https://github.com/Mirasaki/mirasaki-music-bot/commit/2e98af11217b22c0103449cbbf837d3b8b629c2a)) 23 | 24 | ## [1.1.7](https://github.com/Mirasaki/mirasaki-music-bot/compare/v1.1.6...v1.1.7) (2024-06-21) 25 | 26 | 27 | ### Bug Fixes 28 | 29 | * correct bot id ([33e1c37](https://github.com/Mirasaki/mirasaki-music-bot/commit/33e1c37e16a7138e66f284f83d62982a6b479c66)) 30 | 31 | ## [1.1.6](https://github.com/Mirasaki/mirasaki-music-bot/compare/v1.1.5...v1.1.6) (2024-06-21) 32 | 33 | 34 | ### Bug Fixes 35 | 36 | * remove tem db file from docker compose ([593e0e9](https://github.com/Mirasaki/mirasaki-music-bot/commit/593e0e957c163da7956cbb7e7a32f3c89d899365)) 37 | 38 | ## [1.1.4](https://github.com/Mirasaki/mirasaki-music-bot/compare/v1.1.3...v1.1.4) (2024-03-29) 39 | 40 | 41 | ### Bug Fixes 42 | 43 | * **Docker:** more unique container name for client ([4a07b4d](https://github.com/Mirasaki/mirasaki-music-bot/commit/4a07b4dbc17ac4bd08a4483b7f4c36d978dece38)) 44 | * **docker:** remove empty environmental value declaration in docker-compose ([8d86017](https://github.com/Mirasaki/mirasaki-music-bot/commit/8d8601714ed5801bdd55ee631f869621e0a9610d)) 45 | * remove bad docker compose volumes ([ebaa7f4](https://github.com/Mirasaki/mirasaki-music-bot/commit/ebaa7f4b1450f90028cbd9b8d6902c9945ebc79e)) 46 | * update docker-compose file ([e8819e2](https://github.com/Mirasaki/mirasaki-music-bot/commit/e8819e2e48feedf18c03e460b11bc875d1927eb7)) 47 | 48 | ## [1.1.3](https://github.com/Mirasaki/mirasaki-music-bot/compare/v1.1.2...v1.1.3) (2023-12-20) 49 | 50 | 51 | ### Bug Fixes 52 | 53 | * add error listener to discord-player ([6d28619](https://github.com/Mirasaki/mirasaki-music-bot/commit/6d28619710c160f257f90f2cef17dc0913a20c92)) 54 | * check DEBUG_ENABLED environmental for player debugging ([8d45d7a](https://github.com/Mirasaki/mirasaki-music-bot/commit/8d45d7a1f5575d225df1a0239c48086436bdf5a5)) 55 | * crash when extracting stream fails ([0264817](https://github.com/Mirasaki/mirasaki-music-bot/commit/026481723624e291d75f9a0b1710f70a98de987f)) 56 | * logic for checking isMusicChannel when threadSessions are enabled ([e46ecb6](https://github.com/Mirasaki/mirasaki-music-bot/commit/e46ecb69212d86e71efe1d6197fbd0f4583361fa)) 57 | * remove preview dependencies ([4da4826](https://github.com/Mirasaki/mirasaki-music-bot/commit/4da4826eb0deb55eb45e730863edc3a0a08326a3)) 58 | 59 | ## [1.1.1](https://github.com/Mirasaki/mirasaki-music-bot/compare/v1.1.0...v1.1.1) (2023-07-21) 60 | 61 | 62 | ### Bug Fixes 63 | 64 | * discriminator deprecation ([78c9545](https://github.com/Mirasaki/mirasaki-music-bot/commit/78c9545b1dc789337c9470afef929b76df7bf32e)) 65 | * wrong command data being display on command API errors ([61de626](https://github.com/Mirasaki/mirasaki-music-bot/commit/61de6265389455e1f27277286457d92d19b711bb)) 66 | 67 | # [1.1.0](https://github.com/Mirasaki/mirasaki-music-bot/compare/v1.0.0...v1.1.0) (2023-06-14) 68 | 69 | 70 | ### Bug Fixes 71 | 72 | * check threads availability ([a8f3095](https://github.com/Mirasaki/mirasaki-music-bot/commit/a8f3095c713b133bd4adddd213c09a33cb2a489c)) 73 | 74 | 75 | ### Features 76 | 77 | * configurable leaveOnEmpty status + cooldown ([dbad3d0](https://github.com/Mirasaki/mirasaki-music-bot/commit/dbad3d0009ece308ae274f732ca6cee5a4e37916)) 78 | 79 | # 1.0.0 (2023-06-12) 80 | 81 | 82 | ### Bug Fixes 83 | 84 | * avoid additional packages and delete apt-get lists ([8477ec3](https://github.com/Mirasaki/mirasaki-music-bot/commit/8477ec3fe212a7caf3d6d25a295ddfba96e35f72)) 85 | * check dotenv file path ([cc71231](https://github.com/Mirasaki/mirasaki-music-bot/commit/cc7123115a97d6b1572e2373f0828dbdfc6e04d0)) 86 | 87 | 88 | ### Features 89 | 90 | * initial commit 🥳 ([5a70e0e](https://github.com/Mirasaki/mirasaki-music-bot/commit/5a70e0ee3c715256d8e5c9ee2f591496b3f51f0d)) 91 | -------------------------------------------------------------------------------- /src/modules/db.js: -------------------------------------------------------------------------------- 1 | // Use lokijs for database, a super fast in-memory 2 | // javascript document oriented database with fs persistency. 3 | const loki = require('lokijs'); 4 | const fsAdapter = new loki.LokiFsAdapter(); 5 | const pkg = require('../../package.json'); 6 | const logger = require('@mirasaki/logger'); 7 | const chalk = require('chalk'); 8 | const { clientConfig } = require('../util'); 9 | 10 | // Initialize our db + collections 11 | const db = new loki(`${ pkg.name }.db`, { 12 | adapter: fsAdapter, 13 | env: 'NODEJS', 14 | autosave: true, 15 | autosaveInterval: 3600, 16 | autoload: true, 17 | autoloadCallback: initializeDatabase 18 | }); 19 | 20 | // Implement the autoLoadCallback referenced in loki constructor 21 | function initializeDatabase (err) { 22 | if (err) { 23 | logger.syserr('Error encountered while loading database from disk persistence:'); 24 | logger.printErr(err); 25 | return; 26 | } 27 | 28 | // Resolve guilds collection 29 | db.getCollection('guilds') 30 | ?? db.addCollection('guilds', { unique: [ 'guildId' ] }); 31 | 32 | // Kick off any program logic or start listening to external events 33 | runProgramLogic(); 34 | } 35 | 36 | // example method with any bootstrap logic to run after database initialized 37 | const runProgramLogic = () => { 38 | const guildCount = db.getCollection('guilds').count(); 39 | logger.success(`Initialized ${ chalk.yellowBright(guildCount) } guild setting document${ guildCount === 1 ? '' : 's' }`); 40 | }; 41 | 42 | // Utility function so save database as a reusable function 43 | const saveDb = (cb) => db 44 | .saveDatabase((err) => { 45 | if (err) { 46 | logger.syserr('Error encountered while saving database to disk:'); 47 | logger.printErr(err); 48 | } 49 | if (typeof cb === 'function') cb(); 50 | }); 51 | 52 | // Utility function for resolving guild settings 53 | const getGuildSettings = (guildId) => { 54 | const guilds = db.getCollection('guilds'); 55 | let settings = guilds.by('guildId', guildId); 56 | if (!settings) { 57 | // [DEV] - Add config validation 58 | guilds.insertOne({ 59 | guildId, 60 | volume: clientConfig.defaultVolume, 61 | repeatMode: clientConfig.defaultRepeatMode, 62 | musicChannelIds: [], 63 | useThreadSessions: clientConfig.defaultUseThreadSessions, 64 | threadSessionStrictCommandChannel: clientConfig.defaultThreadSessionStrictCommandChannel, 65 | leaveOnEndCooldown: clientConfig.defaultLeaveOnEndCooldown, 66 | leaveOnEmpty: clientConfig.defaultLeaveOnEmpty, 67 | leaveOnEmptyCooldown: clientConfig.defaultLeaveOnEmptyCooldown, 68 | djRoleIds: [], 69 | equalizer: 'null' 70 | }); 71 | settings = guilds.by('guildId', guildId); 72 | } 73 | 74 | // Update settings 75 | if (typeof settings.volume === 'undefined') { 76 | settings.volume = clientConfig.defaultVolume; 77 | guilds.update(settings); 78 | saveDb(); 79 | } 80 | 81 | if (typeof settings.repeatMode === 'undefined') { 82 | settings.repeatMode = clientConfig.defaultRepeatMode; 83 | guilds.update(settings); 84 | saveDb(); 85 | } 86 | 87 | if (typeof settings.useThreadSessions === 'undefined') { 88 | settings.useThreadSessions = clientConfig.defaultUseThreadSessions; 89 | guilds.update(settings); 90 | saveDb(); 91 | } 92 | 93 | if (typeof settings.threadSessionStrictCommandChannel === 'undefined') { 94 | settings.threadSessionStrictCommandChannel = clientConfig.defaultThreadSessionStrictCommandChannel; 95 | guilds.update(settings); 96 | saveDb(); 97 | } 98 | 99 | if (typeof settings.leaveOnEndCooldown === 'undefined') { 100 | settings.leaveOnEndCooldown = clientConfig.defaultLeaveOnEndCooldown; 101 | guilds.update(settings); 102 | saveDb(); 103 | } 104 | 105 | if (typeof settings.djRoleIds === 'undefined') { 106 | settings.djRoleIds = []; 107 | guilds.update(settings); 108 | saveDb(); 109 | } 110 | 111 | if (typeof settings.equalizer === 'undefined') { 112 | settings.equalizer = 'null'; 113 | guilds.update(settings); 114 | saveDb(); 115 | } 116 | 117 | if (typeof settings.leaveOnEmpty === 'undefined') { 118 | settings.leaveOnEmpty = clientConfig.defaultLeaveOnEmpty; 119 | guilds.update(settings); 120 | saveDb(); 121 | } 122 | 123 | if (typeof settings.leaveOnEmptyCooldown === 'undefined') { 124 | settings.leaveOnEmptyCooldown = clientConfig.defaultLeaveOnEmptyCooldown; 125 | guilds.update(settings); 126 | saveDb(); 127 | } 128 | 129 | if (process.env.NODE_ENV !== 'production') console.dir(settings); 130 | return settings; 131 | }; 132 | 133 | module.exports = { 134 | db, 135 | saveDb, 136 | getGuildSettings 137 | }; 138 | -------------------------------------------------------------------------------- /src/commands/music-dj/audio-filters.js: -------------------------------------------------------------------------------- 1 | const { ApplicationCommandOptionType } = require('discord.js'); 2 | const { ChatInputCommand } = require('../../classes/Commands'); 3 | const { colorResolver } = require('../../util'); 4 | const { useQueue } = require('discord-player'); 5 | const { stripIndents } = require('common-tags'); 6 | const { audioFilters, requireSessionConditions } = require('../../modules/music'); 7 | const allAudioFilters = audioFilters(); 8 | 9 | module.exports = new ChatInputCommand({ 10 | global: true, 11 | data: { 12 | description: 'Configure audio filters for playback in your server', 13 | options: [ 14 | { 15 | name: 'list', 16 | description: 'List all audio filters that are currently configured', 17 | type: ApplicationCommandOptionType.Subcommand 18 | }, 19 | { 20 | name: 'toggle', 21 | description: 'Toggle a specific audio filter for your server', 22 | type: ApplicationCommandOptionType.Subcommand, 23 | options: [ 24 | { 25 | type: ApplicationCommandOptionType.String, 26 | name: 'audio-filter', 27 | description: 'The audio filter to toggle', 28 | required: true, 29 | autocomplete: true 30 | } 31 | ] 32 | }, 33 | { 34 | name: 'reset', 35 | description: 'Completely reset all configured audio filters', 36 | type: ApplicationCommandOptionType.Subcommand, 37 | options: [ 38 | { 39 | name: 'verification', 40 | description: 'Are you sure you want to reset your configured audio filters?', 41 | type: ApplicationCommandOptionType.Boolean, 42 | required: true 43 | } 44 | ] 45 | } 46 | ] 47 | }, 48 | run: async (client, interaction) => { 49 | const { 50 | member, guild, options 51 | } = interaction; 52 | const { emojis } = client.container; 53 | const action = options.getSubcommand(); 54 | const queue = useQueue(guild.id); 55 | 56 | // Check conditions 57 | if (!requireSessionConditions(interaction, true)) return; 58 | 59 | // Check is active 60 | if (!queue || !queue.isPlaying()) { 61 | interaction.reply(`${ emojis.error } ${ member }, initialize playback/a music session first - this command has been cancelled`); 62 | return; 63 | } 64 | 65 | // Check action/subcommand 66 | const guildAudioFilters = queue?.filters.ffmpeg.getFiltersEnabled(); 67 | switch (action) { 68 | case 'toggle': { 69 | const audioFilter = allAudioFilters.find((e) => e.toLowerCase() === options.getString('audio-filter')); 70 | if (!audioFilter) { 71 | interaction.reply(`${ emojis.error } ${ member }, that is not a valid audio filter - this command has been cancelled`); 72 | return; 73 | } 74 | await interaction.deferReply(); 75 | let isEnabled; 76 | try { 77 | isEnabled = await queue.filters.ffmpeg.toggle(audioFilter); 78 | } catch (err) { 79 | console.error('Error toggling audio filter:', err); 80 | interaction.editReply(`${ emojis.error } ${ member }, an error occurred while toggling the audio filter - this command has been cancelled.`); 81 | return; 82 | } 83 | 84 | // Feedback 85 | interaction.editReply(`${ emojis.success } ${ member }, ${ audioFilter } has been toggled, it is now **${ isEnabled ? emojis.success + ' Enabled' : emojis.error + ' Disabled' }**`); 86 | break; 87 | } 88 | 89 | case 'reset': { 90 | // Check verification prompt 91 | const verification = options.getBoolean('verification'); 92 | if (!verification) { 93 | interaction.reply(`${ emojis.error } ${ member }, you didn't select \`true\` on the confirmation prompt - this command has been cancelled`); 94 | return; 95 | } 96 | 97 | // Check any is active 98 | if (guildAudioFilters.length === 0) { 99 | interaction.reply(`${ emojis.error } ${ member }, you are already using default audio filters (all disabled) - this command has been cancelled`); 100 | return; 101 | } 102 | 103 | // Disable all filters 104 | queue.filters.ffmpeg.setFilters(false); 105 | 106 | // Feedback 107 | interaction.reply(`${ emojis.success } ${ member }, all audio filters have been disabled`); 108 | break; 109 | } 110 | 111 | // Default list action 112 | case 'list': 113 | default: { 114 | // Reply with overview 115 | const guildDisabledAudioFilters = queue.filters.ffmpeg.getFiltersDisabled(); 116 | await interaction.reply({ embeds: [ 117 | { 118 | color: colorResolver(), 119 | author: { 120 | name: `Configured audio filters for ${ guild.name }`, 121 | icon_url: guild.iconURL({ dynamic: true }) 122 | }, 123 | description: stripIndents` 124 | ${ guildAudioFilters.map((e) => `${ emojis.success } ${ e }`).join('\n') } 125 | ${ guildDisabledAudioFilters.map((e) => `${ emojis.error } ${ e }`).join('\n') } 126 | `, 127 | footer: { text: 'These audio effects only apply on a per-session basis - this is intended behavior. We may add persistent audio filters in the future if enough people show interest' } 128 | } 129 | ] }); 130 | break; 131 | } 132 | } 133 | } 134 | }); 135 | -------------------------------------------------------------------------------- /src/commands/music/play.js: -------------------------------------------------------------------------------- 1 | const { ApplicationCommandOptionType } = require('discord.js'); 2 | const { ChatInputCommand } = require('../../classes/Commands'); 3 | const { 4 | requireSessionConditions, ALLOWED_CONTENT_TYPE, musicEventChannel 5 | } = require('../../modules/music'); 6 | const { clientConfig, isAllowedContentType } = require('../../util'); 7 | const { getGuildSettings } = require('../../modules/db'); 8 | const { 9 | useMainPlayer, useQueue, EqualizerConfigurationPreset 10 | } = require('discord-player'); 11 | const { MS_IN_ONE_SECOND } = require('../../constants'); 12 | const player = useMainPlayer(); 13 | 14 | module.exports = new ChatInputCommand({ 15 | global: true, 16 | data: { 17 | description: 'Play a song. Query SoundCloud, search Vimeo, provide a direct link, etc.', 18 | options: [ 19 | { 20 | name: 'query', 21 | type: ApplicationCommandOptionType.String, 22 | autocomplete: true, 23 | description: 'The music to search/query', 24 | required: true 25 | }, 26 | { 27 | name: 'file', 28 | type: ApplicationCommandOptionType.Attachment, 29 | description: 'The audio file to play', 30 | required: false 31 | } 32 | ] 33 | }, 34 | // eslint-disable-next-line sonarjs/cognitive-complexity 35 | run: async (client, interaction) => { 36 | const { emojis } = client.container; 37 | const { member, guild } = interaction; 38 | const query = interaction.options.getString('query', true); // we need input/query to play 39 | const attachment = interaction.options.getAttachment('file'); 40 | 41 | // Check state 42 | if (!requireSessionConditions(interaction, false, true, false)) return; 43 | 44 | // Return if attachment content type is not allowed 45 | if (attachment) { 46 | const contentIsAllowed = isAllowedContentType(ALLOWED_CONTENT_TYPE, attachment?.contentType ?? 'unknown'); 47 | if (!contentIsAllowed.strict) { 48 | interaction.reply({ content: `${ emojis.error } ${ member }, file rejected. Content type is not **\`${ ALLOWED_CONTENT_TYPE }\`**, received **\`${ attachment.contentType ?? 'unknown' }\`** instead.` }); 49 | return; 50 | } 51 | } 52 | 53 | // Ok, safe to access voice channel and initialize 54 | const channel = member.voice?.channel; 55 | 56 | // Let's defer the interaction as things can take time to process 57 | await interaction.deferReply(); 58 | 59 | try { 60 | // Check is valid 61 | const searchResult = await player 62 | .search(attachment?.url ?? query, { requestedBy: interaction.user }) 63 | .catch(() => null); 64 | if (!searchResult.hasTracks()) { 65 | interaction.editReply(`${ emojis.error } ${ member }, no tracks found for query \`${ query }\` - this command has been cancelled`); 66 | return; 67 | } 68 | 69 | // Resolve settings 70 | const settings = getGuildSettings(guild.id); 71 | 72 | // Use thread channels 73 | let eventChannel = interaction.channel; 74 | if (settings.useThreadSessions) { 75 | eventChannel = await musicEventChannel(client, interaction); 76 | if (eventChannel === false) return; 77 | } 78 | 79 | // Resolve volume for this session - clamp max 100 80 | let volume = settings.volume ?? clientConfig.defaultVolume; 81 | // Note: Don't increase volume for attachments as having to check 82 | // and adjust on every song end isn't perfect 83 | volume = Math.min(100, volume); 84 | 85 | // Resolve cooldown 86 | const leaveOnEndCooldown = ((settings.leaveOnEndCooldown ?? 2) * MS_IN_ONE_SECOND); 87 | const leaveOnEmptyCooldown = ((settings.leaveOnEmptyCooldown ?? 2) * MS_IN_ONE_SECOND); 88 | 89 | // nodeOptions are the options for guild node (aka your queue in simple word) 90 | // we can access this metadata object using queue.metadata later on 91 | const { track } = await player.play( 92 | channel, 93 | searchResult, 94 | { 95 | requestedBy: interaction.user, 96 | nodeOptions: { 97 | skipOnNoStream: true, 98 | leaveOnEnd: true, 99 | leaveOnEndCooldown, 100 | leaveOnEmpty: settings.leaveOnEmpty, 101 | leaveOnEmptyCooldown, 102 | volume, 103 | metadata: { 104 | channel: eventChannel, 105 | member, 106 | timestamp: interaction.createdTimestamp 107 | } 108 | } 109 | } 110 | ); 111 | 112 | // Use queue 113 | const queue = useQueue(guild.id); 114 | 115 | // Now that we have a queue initialized, 116 | // let's check if we should set our default repeat-mode 117 | if (Number.isInteger(settings.repeatMode)) queue.setRepeatMode(settings.repeatMode); 118 | 119 | // Set persistent equalizer preset 120 | if ( 121 | queue.filters.equalizer 122 | && settings.equalizer 123 | && settings.equalizer !== 'null' 124 | ) { 125 | queue.filters.equalizer.setEQ(EqualizerConfigurationPreset[settings.equalizer]); 126 | queue.filters.equalizer.enable(); 127 | } 128 | else if (queue.filters?.equalizer) queue.filters.equalizer.disable(); 129 | 130 | // Feedback 131 | await interaction.editReply(`${ emojis.success } ${ member }, enqueued **\`${ track.title }\`**!`); 132 | } 133 | catch (e) { 134 | interaction.editReply(`${ emojis.error } ${ member }, something went wrong:\n\n${ e.message }`); 135 | } 136 | } 137 | }); 138 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | me@mirasaki.dev. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /src/music-player.js: -------------------------------------------------------------------------------- 1 | const logger = require('@mirasaki/logger'); 2 | const { 3 | colorResolver, msToHumanReadableTime, clientConfig 4 | } = require('./util'); 5 | const { EmbedBuilder, escapeMarkdown } = require('discord.js'); 6 | const { getGuildSettings } = require('./modules/db'); 7 | const { MS_IN_ONE_SECOND, EMBED_DESCRIPTION_MAX_LENGTH } = require('./constants'); 8 | 9 | module.exports = (player) => { 10 | // this event is emitted whenever discord-player starts to play a track 11 | player.events.on('playerStart', (queue, track) => { 12 | queue.metadata.channel.send({ embeds: [ 13 | new EmbedBuilder({ 14 | color: colorResolver(), 15 | title: 'Started Playing', 16 | description: `[${ escapeMarkdown(track.title) }](${ track.url })`, 17 | thumbnail: { url: track.thumbnail }, 18 | footer: { text: `${ track.duration } - by ${ track.author }\nRequested by: ${ 19 | queue.metadata.member?.user?.username 20 | }` } 21 | }).setTimestamp(queue.metadata.timestamp) 22 | ] }); 23 | }); 24 | 25 | player.events.on('error', (queue, error) => { 26 | // Emitted when the player encounters an error 27 | queue.metadata.channel.send({ embeds: [ 28 | { 29 | color: colorResolver(), 30 | title: 'Player Error', 31 | description: error.message.slice(0, EMBED_DESCRIPTION_MAX_LENGTH) 32 | } 33 | ] }); 34 | }); 35 | 36 | player.events.on('playerError', (err) => { 37 | logger.syserr('Music Player encountered unexpected error:'); 38 | logger.printErr(err); 39 | }); 40 | 41 | player.events.on('audioTrackAdd', (queue, track) => { 42 | // Emitted when the player adds a single song to its queue 43 | queue.metadata.channel.send({ embeds: [ 44 | { 45 | color: colorResolver(), 46 | title: 'Track Enqueued', 47 | description: `[${ escapeMarkdown(track.title) }](${ track.url })` 48 | } 49 | ] }); 50 | }); 51 | 52 | player.events.on('audioTracksAdd', (queue, tracks) => { 53 | // Emitted when the player adds multiple songs to its queue 54 | queue.metadata.channel.send({ embeds: [ 55 | { 56 | color: colorResolver(), 57 | title: 'Multiple Tracks Enqueued', 58 | description: `**${ tracks.length }** Tracks\nFirst entry: [${ escapeMarkdown(tracks[1].title) }](${ tracks[1].url })` 59 | } 60 | ] }); 61 | }); 62 | 63 | player.events.on('audioTrackRemove', (queue, track) => { 64 | // Emitted when the player adds multiple songs to its queue 65 | queue.metadata.channel.send({ embeds: [ 66 | { 67 | color: colorResolver(), 68 | title: 'Track Removed', 69 | description: `[${ escapeMarkdown(track.title) }](${ track.url })` 70 | } 71 | ] }); 72 | }); 73 | 74 | player.events.on('audioTracksRemove', (queue, tracks) => { 75 | // Emitted when the player adds multiple songs to its queue 76 | queue.metadata.channel.send({ embeds: [ 77 | { 78 | color: colorResolver(), 79 | title: 'Multiple Tracks Removed', 80 | description: `**${ tracks.length }** Tracks\nFirst entry: [${ escapeMarkdown(tracks[0].title) }](${ tracks[0].url })` 81 | } 82 | ] }); 83 | }); 84 | 85 | player.events.on('playerSkip', (queue, track) => { 86 | // Emitted when the audio player fails to load the stream for a song 87 | queue.metadata.channel.send({ embeds: [ 88 | { 89 | color: colorResolver(), 90 | title: 'Player Skip', 91 | description: `Track skipped because the audio stream couldn't be extracted: [${ escapeMarkdown(track.title) }](${ track.url })` 92 | } 93 | ] }); 94 | }); 95 | 96 | player.events.on('disconnect', (queue) => { 97 | // Emitted when the bot leaves the voice channel 98 | queue.metadata.channel.send({ embeds: [ 99 | { 100 | color: colorResolver(), 101 | title: 'Finished Playing', 102 | description: 'Queue is now empty, leaving the channel' 103 | } 104 | ] }); 105 | }); 106 | 107 | player.events.on('emptyChannel', (queue) => { 108 | const settings = getGuildSettings(queue.guild.id); 109 | if (!settings) return; 110 | const ctx = { embeds: [ 111 | { 112 | color: colorResolver(), 113 | title: 'Channel Empty' 114 | } 115 | ] }; 116 | if (!settings.leaveOnEmpty) ctx.embeds[0].description = 'Staying in channel as leaveOnEnd is disabled'; 117 | else ctx.embeds[0].description = `Leaving empty channel in ${ msToHumanReadableTime( 118 | (settings.leaveOnEmptyCooldown ?? clientConfig.defaultLeaveOnEndCooldown) * MS_IN_ONE_SECOND 119 | ) }`; 120 | // Emitted when the voice channel has been empty for the set threshold 121 | // Bot will automatically leave the voice channel with this event 122 | queue.metadata.channel.send(ctx); 123 | }); 124 | 125 | player.events.on('emptyQueue', (queue) => { 126 | const settings = getGuildSettings(queue.guild.id); 127 | // Emitted when the player queue has finished 128 | queue.metadata.channel.send({ embeds: [ 129 | { 130 | color: colorResolver(), 131 | title: 'Queue Empty', 132 | description: `Queue is now empty, use **\`/play\`** to add something\nLeaving channel in ${ msToHumanReadableTime((settings.leaveOnEndCooldown ?? clientConfig.defaultLeaveOnEndCooldown) * MS_IN_ONE_SECOND) } if no songs are added/enqueued` 133 | } 134 | ] }); 135 | }); 136 | 137 | if (process.env.DEBUG_ENABLED === 'true') { 138 | player.events.on('debug', async (queue, message) => { 139 | // Emitted when the player queue sends debug info 140 | // Useful for seeing what state the current queue is at 141 | console.log(`Player debug event: ${ message }`); 142 | }); 143 | } 144 | }; 145 | -------------------------------------------------------------------------------- /src/interactions/buttons/eval/acceptEval.js: -------------------------------------------------------------------------------- 1 | const { ActionRowBuilder, ButtonBuilder } = require('discord.js'); 2 | const { colorResolver, getRuntime } = require('../../../util'); 3 | const util = require('util'); 4 | const { 5 | EMBED_FIELD_VALUE_MAX_LENGTH, ACCEPT_EVAL_CODE_EXECUTION, ZERO_WIDTH_SPACE_CHAR_CODE 6 | } = require('../../../constants'); 7 | const logger = require('@mirasaki/logger'); 8 | const { ComponentCommand } = require('../../../classes/Commands'); 9 | 10 | const clean = (text) => { 11 | if (typeof (text) === 'string') { 12 | return text.replace(/`/g, '`' 13 | + String.fromCharCode(ZERO_WIDTH_SPACE_CHAR_CODE)).replace(/@/g, '@' 14 | + String.fromCharCode(ZERO_WIDTH_SPACE_CHAR_CODE)) 15 | .replace(new RegExp(process.env.DISCORD_BOT_TOKEN), ''); 16 | } 17 | else return text; 18 | }; 19 | 20 | module.exports = new ComponentCommand({ 21 | // Additional layer of protection 22 | permLevel: 'Developer', 23 | // Discord API data 24 | // Overwriting the default file name with our owm custom component id 25 | data: { name: ACCEPT_EVAL_CODE_EXECUTION }, 26 | 27 | run: async (client, interaction) => { 28 | // Destructure from interaction and client 29 | const { member, message } = interaction; 30 | const { emojis, colors } = client.container; 31 | 32 | // Editing original command interaction 33 | const originalEmbed = message.embeds[0].data; 34 | 35 | await message.edit({ 36 | content: `${ emojis.success } ${ member }, this code is now executing...`, 37 | embeds: [ 38 | { 39 | // Keep the original embed but change color 40 | ...originalEmbed, 41 | color: colorResolver(colors.error) 42 | } 43 | ], 44 | // Remove decline button and disable accept button 45 | components: [ 46 | new ActionRowBuilder().addComponents( 47 | new ButtonBuilder() 48 | .setCustomId(ACCEPT_EVAL_CODE_EXECUTION) 49 | .setDisabled(true) 50 | .setLabel('Executing...') 51 | .setStyle('Success') 52 | ) 53 | ] 54 | }); 55 | 56 | // Slicing our code input 57 | const CODEBLOCK_CHAR_OFFSET_START = 6; 58 | const CODEBLOCK_CHAR_OFFSET_END = 4; 59 | const codeInput = originalEmbed.description.slice(CODEBLOCK_CHAR_OFFSET_START, -CODEBLOCK_CHAR_OFFSET_END); 60 | 61 | // Defer our reply 62 | await interaction.deferReply(); 63 | 64 | // Performance measuring 65 | let evaluated; 66 | const startEvalTime = process.hrtime.bigint(); 67 | 68 | try { 69 | // eslint-disable-next-line no-eval 70 | evaluated = eval(codeInput); 71 | if (evaluated instanceof Promise) evaluated = await evaluated; 72 | 73 | // Get execution time 74 | const timeSinceHr = getRuntime(startEvalTime); 75 | const timeSinceStr = `${ timeSinceHr.seconds } seconds (${ timeSinceHr.ms } ms)`; 76 | 77 | // String response 78 | const codeOutput = clean(util.inspect(evaluated, { depth: 0 })); 79 | const response = [ `\`\`\`js\n${ codeOutput }\`\`\``, `\`\`\`fix\n${ timeSinceStr }\`\`\`` ]; 80 | 81 | // Building the embed 82 | const evalEmbed = { 83 | color: colorResolver(), 84 | description: `:inbox_tray: **Input:**\n\`\`\`js\n${ codeInput }\n\`\`\``, 85 | fields: [ 86 | { 87 | name: ':outbox_tray: Output:', 88 | value: `${ response[0] }`, 89 | inline: false 90 | }, 91 | { 92 | name: 'Time taken', 93 | value: `${ response[1] }`, 94 | inline: false 95 | } 96 | ] 97 | }; 98 | 99 | // Result fits within character limit 100 | if (response[0].length <= EMBED_FIELD_VALUE_MAX_LENGTH) { 101 | await message.edit({ 102 | content: `${ emojis.success } ${ member }, this code has been evaluated.`, 103 | embeds: [ evalEmbed ], 104 | components: [ 105 | new ActionRowBuilder().addComponents( 106 | new ButtonBuilder() 107 | .setCustomId('accept_eval_code') 108 | .setDisabled(true) 109 | .setLabel('Evaluated') 110 | .setStyle('Success') 111 | ) 112 | ] 113 | }); 114 | } 115 | 116 | // Output is too many characters 117 | else { 118 | const output = Buffer.from(codeOutput); 119 | 120 | await message.edit({ 121 | content: `${ emojis.success } ${ member }, this code has been evaluated.`, 122 | components: [ 123 | new ActionRowBuilder().addComponents( 124 | new ButtonBuilder() 125 | .setCustomId('accept_eval_code') 126 | .setDisabled(true) 127 | .setLabel('Evaluated') 128 | .setStyle('Success') 129 | ) 130 | ], 131 | files: [ 132 | { 133 | attachment: output, 134 | name: 'evalOutput.txt' 135 | } 136 | ] 137 | }); 138 | } 139 | 140 | // Reply to button interaction 141 | interaction.editReply({ content: `${ emojis.success } ${ member }, finished code execution.` }); 142 | } 143 | catch (err) { 144 | const timeSinceHr = getRuntime(startEvalTime); 145 | 146 | // Log potential errors 147 | logger.syserr('Encountered error while executing /eval code'); 148 | console.error(err); 149 | 150 | // Update button interaction 151 | interaction.editReply({ content: `${ emojis.error } ${ member }, code execution error, check original embed for output.` }); 152 | 153 | // Format time stamps 154 | const timeSinceStr = `${ timeSinceHr.seconds } seconds (${ timeSinceHr.ms } ms)`; 155 | 156 | // Update original embed interaction 157 | message.edit({ embeds: [ 158 | { 159 | color: colorResolver(), 160 | description: `:inbox_tray: **Input:**\n\`\`\`js\n${ codeInput }\n\`\`\``, 161 | fields: [ 162 | { 163 | name: ':outbox_tray: Output:', 164 | value: `\`\`\`js\n${ err.stack || err }\n\`\`\``, 165 | inline: false 166 | }, 167 | { 168 | name: 'Time taken', 169 | value: `\`\`\`fix\n${ timeSinceStr }\n\`\`\``, 170 | inline: false 171 | } 172 | ] 173 | } 174 | ] }); 175 | } 176 | } 177 | }); 178 | -------------------------------------------------------------------------------- /src/commands/music-admin/dj-roles.js: -------------------------------------------------------------------------------- 1 | const { ApplicationCommandOptionType, AttachmentBuilder } = require('discord.js'); 2 | const { ChatInputCommand } = require('../../classes/Commands'); 3 | const { 4 | getGuildSettings, saveDb, db 5 | } = require('../../modules/db'); 6 | const { EMBED_DESCRIPTION_MAX_LENGTH } = require('../../constants'); 7 | const { colorResolver } = require('../../util'); 8 | 9 | module.exports = new ChatInputCommand({ 10 | global: true, 11 | permLevel: 'Administrator', 12 | data: { 13 | description: 'Configure the roles that can use playback control commands / use intrusive commands', 14 | options: [ 15 | { 16 | name: 'list', 17 | description: 'List all DJ roles that are currently configured', 18 | type: ApplicationCommandOptionType.Subcommand 19 | }, 20 | { 21 | name: 'add', 22 | description: 'Add a role to the list of music power users', 23 | type: ApplicationCommandOptionType.Subcommand, 24 | options: [ 25 | { 26 | name: 'role', 27 | description: 'The role which marks users as power users', 28 | type: ApplicationCommandOptionType.Role, 29 | required: true 30 | } 31 | ] 32 | }, 33 | { 34 | name: 'remove', 35 | description: 'Remove a role from the list of music power users', 36 | type: ApplicationCommandOptionType.Subcommand, 37 | options: [ 38 | { 39 | name: 'role', 40 | description: 'The role to remove', 41 | type: ApplicationCommandOptionType.Role, 42 | required: true 43 | } 44 | ] 45 | }, 46 | { 47 | name: 'reset', 48 | description: 'Completely reset the list of roles that have elevated permission levels', 49 | type: ApplicationCommandOptionType.Subcommand, 50 | options: [ 51 | { 52 | name: 'verification', 53 | description: 'Are you sure you want to reset your DJ role list?', 54 | type: ApplicationCommandOptionType.Boolean, 55 | required: true 56 | } 57 | ] 58 | } 59 | ] 60 | }, 61 | // eslint-disable-next-line sonarjs/cognitive-complexity 62 | run: async (client, interaction) => { 63 | const { 64 | member, guild, options 65 | } = interaction; 66 | const { emojis } = client.container; 67 | const action = options.getSubcommand(); 68 | const settings = getGuildSettings(guild.id); 69 | const { djRoleIds = [] } = settings; 70 | // Note: Don't define in outer scope, will be uninitialized 71 | const guilds = db.getCollection('guilds'); 72 | 73 | // Check action/subcommand 74 | switch (action) { 75 | case 'add': { 76 | // Check already exists 77 | const role = options.getRole('role'); 78 | if (djRoleIds.includes(role.id)) { 79 | interaction.reply(`${ emojis.error } ${ member }, ${ role } is already configured as a DJ role - this command has been cancelled`); 80 | return; 81 | } 82 | 83 | // Perform and notify collection that the document has changed 84 | settings.djRoleIds = [ ...djRoleIds, role.id ]; 85 | guilds.update(settings); 86 | saveDb(); 87 | 88 | // Feedback 89 | interaction.reply(`${ emojis.success } ${ member }, ${ role } has been added to the list of DJ roles`); 90 | break; 91 | } 92 | 93 | case 'remove': { 94 | // Check doesn't exist 95 | const role = options.getRole('role'); 96 | if (!djRoleIds.includes(role.id)) { 97 | interaction.reply(`${ emojis.error } ${ member }, ${ role } isn't currently configured as a DJ role - this command has been cancelled`); 98 | return; 99 | } 100 | 101 | // Perform and notify collection that the document has changed 102 | settings.djRoleIds = djRoleIds.filter((e) => e !== role.id); 103 | guilds.update(settings); 104 | saveDb(); 105 | 106 | // Feedback 107 | interaction.reply(`${ emojis.success } ${ member }, ${ role } has been removed from the list of DJ roles`); 108 | break; 109 | } 110 | 111 | case 'reset': { 112 | // Check verification prompt 113 | const verification = options.getBoolean('verification'); 114 | if (!verification) { 115 | interaction.reply(`${ emojis.error } ${ member }, you didn't select \`true\` on the confirmation prompt - this command has been cancelled`); 116 | return; 117 | } 118 | 119 | // Perform and notify collection that the document has changed 120 | settings.djRoleIds = []; 121 | guilds.update(settings); 122 | saveDb(); 123 | 124 | // Feedback 125 | interaction.reply(`${ emojis.success } ${ member }, your list of DJ roles has been reset - DJ commands are now reserved for the Administrator permission level and up`); 126 | break; 127 | } 128 | 129 | // Default list action 130 | case 'list': 131 | default: { 132 | // Resolve output 133 | const outputStr = djRoleIds[0] 134 | ? djRoleIds.map((e) => `<@&${ e }>`).join(` ${ emojis.separator } `) 135 | : 'None configured yet, restrict powerful music commands to specific roles by using the `/dj-roles add` command. If empty/none, these commands are reserved for the Administrator permission level and up'; 136 | 137 | // Attach as file instead if too long kek W 138 | const files = []; 139 | if (outputStr.length >= EMBED_DESCRIPTION_MAX_LENGTH) { 140 | files.push(new AttachmentBuilder(Buffer.from( 141 | djRoleIds.map((e) => guild.roles.cache.get(e)?.name ?? `Unknown/Deleted (${ e })`).join('\n') 142 | )).setName(`dj-role-list-${ guild.id }.txt`)); 143 | } 144 | 145 | // Reply with overview 146 | interaction.reply({ 147 | embeds: [ 148 | { 149 | color: colorResolver(), 150 | author: { 151 | name: `Configured DJ roles for ${ guild.name }`, 152 | icon_url: guild.iconURL({ dynamic: true }) 153 | }, 154 | description: files[0] ? null : outputStr, 155 | footer: { text: djRoleIds[0] ? 'This list represents roles that can use powerful music commands to control the state of the player and queue. If empty/none, these commands are reserved for the Administrator permission level and up' : null } 156 | } 157 | ], 158 | files 159 | }); 160 | break; 161 | } 162 | } 163 | } 164 | }); 165 | -------------------------------------------------------------------------------- /src/commands/music-admin/music-channels.js: -------------------------------------------------------------------------------- 1 | const { 2 | ApplicationCommandOptionType, ChannelType, AttachmentBuilder 3 | } = require('discord.js'); 4 | const { ChatInputCommand } = require('../../classes/Commands'); 5 | const { 6 | getGuildSettings, saveDb, db 7 | } = require('../../modules/db'); 8 | const { EMBED_DESCRIPTION_MAX_LENGTH } = require('../../constants'); 9 | const { colorResolver } = require('../../util'); 10 | 11 | module.exports = new ChatInputCommand({ 12 | global: true, 13 | permLevel: 'Administrator', 14 | data: { 15 | description: 'Configure the channels where music commands are allowed to be used', 16 | options: [ 17 | { 18 | name: 'list', 19 | description: 'List all music channels that are currently configured', 20 | type: ApplicationCommandOptionType.Subcommand 21 | }, 22 | { 23 | name: 'add', 24 | description: 'Add a channel to the list of channels where music commands are allowed', 25 | type: ApplicationCommandOptionType.Subcommand, 26 | options: [ 27 | { 28 | name: 'channel', 29 | description: 'The channel where commands can be used', 30 | type: ApplicationCommandOptionType.Channel, 31 | channel_types: [ ChannelType.GuildText ], 32 | required: true 33 | } 34 | ] 35 | }, 36 | { 37 | name: 'remove', 38 | description: 'Remove a channel from the list of channels where music commands are allowed', 39 | type: ApplicationCommandOptionType.Subcommand, 40 | options: [ 41 | { 42 | name: 'channel', 43 | description: 'The channel to remove from the list of allowed channels', 44 | type: ApplicationCommandOptionType.Channel, 45 | channel_types: [ ChannelType.GuildText ], 46 | required: true 47 | } 48 | ] 49 | }, 50 | { 51 | name: 'reset', 52 | description: 'Completely reset the list of channels where music commands are allowed', 53 | type: ApplicationCommandOptionType.Subcommand, 54 | options: [ 55 | { 56 | name: 'verification', 57 | description: 'Are you sure you want to reset your music channel list?', 58 | type: ApplicationCommandOptionType.Boolean, 59 | required: true 60 | } 61 | ] 62 | } 63 | ] 64 | }, 65 | run: async (client, interaction) => { 66 | const { 67 | member, guild, options 68 | } = interaction; 69 | const { emojis } = client.container; 70 | const action = options.getSubcommand(); 71 | const settings = getGuildSettings(guild.id); 72 | const { musicChannelIds = [] } = settings; 73 | // Note: Don't define in outer scope, will be uninitialized 74 | const guilds = db.getCollection('guilds'); 75 | 76 | // Check action/subcommand 77 | switch (action) { 78 | case 'add': { 79 | // Check already exists 80 | const channel = options.getChannel('channel'); 81 | if (musicChannelIds.includes(channel.id)) { 82 | interaction.reply(`${ emojis.error } ${ member }, ${ channel } is already configured as a music channel - this command has been cancelled`); 83 | return; 84 | } 85 | 86 | // Perform and notify collection that the document has changed 87 | settings.musicChannelIds = [ ...musicChannelIds, channel.id ]; 88 | guilds.update(settings); 89 | saveDb(); 90 | 91 | // Feedback 92 | interaction.reply(`${ emojis.success } ${ member }, ${ channel } has been added to the list of music channels`); 93 | break; 94 | } 95 | 96 | case 'remove': { 97 | // Check doesn't exist 98 | const channel = options.getChannel('channel'); 99 | if (!musicChannelIds.includes(channel.id)) { 100 | interaction.reply(`${ emojis.error } ${ member }, ${ channel } isn't currently configured as a music channel - this command has been cancelled`); 101 | return; 102 | } 103 | 104 | // Perform and notify collection that the document has changed 105 | settings.musicChannelIds = musicChannelIds.filter((e) => e !== channel.id); 106 | guilds.update(settings); 107 | saveDb(); 108 | 109 | // Feedback 110 | interaction.reply(`${ emojis.success } ${ member }, ${ channel } has been removed from the list of music channels`); 111 | break; 112 | } 113 | 114 | case 'reset': { 115 | // Check verification prompt 116 | const verification = options.getBoolean('verification'); 117 | if (!verification) { 118 | interaction.reply(`${ emojis.error } ${ member }, you didn't select \`true\` on the confirmation prompt - this command has been cancelled`); 119 | return; 120 | } 121 | 122 | // Perform and notify collection that the document has changed 123 | settings.musicChannelIds = []; 124 | guilds.update(settings); 125 | saveDb(); 126 | 127 | // Feedback 128 | interaction.reply(`${ emojis.success } ${ member }, your list of music channels has been reset`); 129 | break; 130 | } 131 | 132 | // Default list action 133 | case 'list': 134 | default: { 135 | // Resolve output 136 | const outputStr = musicChannelIds[0] 137 | ? musicChannelIds.map((e) => `<#${ e }>`).join(` ${ emojis.separator } `) 138 | : 'None configured yet, restrict music commands to specific channels by using the `/music-channels add` command'; 139 | 140 | // Attach as file instead if too long kek W 141 | const files = []; 142 | if (outputStr.length >= EMBED_DESCRIPTION_MAX_LENGTH) { 143 | files.push(new AttachmentBuilder(Buffer.from( 144 | musicChannelIds.map((e) => guild.channels.cache.get(e)?.name ?? `Unknown/Deleted (${ e })`).join('\n') 145 | )).setName(`music-channel-list-${ guild.id }.txt`)); 146 | } 147 | 148 | // Reply with overview 149 | interaction.reply({ 150 | embeds: [ 151 | { 152 | color: colorResolver(), 153 | author: { 154 | name: `Configured music channels for ${ guild.name }`, 155 | icon_url: guild.iconURL({ dynamic: true }) 156 | }, 157 | description: files[0] ? null : outputStr, 158 | footer: { text: 'This list represents channels where music commands are allowed to be used. Music commands can be used anywhere if this list is empty' } 159 | } 160 | ], 161 | files 162 | }); 163 | break; 164 | } 165 | } 166 | } 167 | }); 168 | -------------------------------------------------------------------------------- /src/handlers/permissions.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Our permission handler, holds utility functions and everything to do with 3 | * handling permissions in this template. Exported from `/src/handlers/permissions.js`. 4 | * See {@tutorial permissions} for an overview. 5 | * @module Handler/Permissions 6 | */ 7 | 8 | const { PermissionsBitField } = require('discord.js'); 9 | 10 | // NOTE: 11 | // This can't use clientConfig from Util module because 12 | // it would create a circular dependency 13 | // Instead, provide it as a argument 14 | 15 | /** 16 | * The `discord.js` GuildMember object 17 | * @external DiscordGuildMember 18 | * @see {@link https://discord.js.org/#/docs/discord.js/main/class/GuildMember} 19 | */ 20 | 21 | /** 22 | * The `discord.js` GuildChannel object 23 | * @external DiscordGuildChannel 24 | * @see {@link https://discord.js.org/#/docs/discord.js/main/class/GuildChannel} 25 | */ 26 | 27 | /** 28 | * Check if the command invoker has this level 29 | * @typedef {Function} hasLevel 30 | * @param {external:DiscordGuildMember} member The Discord API member object 31 | * @param {external:DiscordGuildChannel} [channel] The Discord API channel object 32 | * @returns {boolean} Indicates if the invoke has this permission level 33 | */ 34 | 35 | /** 36 | * Represents a valid permission configuration data entry 37 | * @typedef {Object} PermConfigEntry 38 | * @property {string} name The name of the permission level 39 | * @property {number} level The level of the permission level 40 | * @property {module:Handler/Permissions~hasLevel} hasLevel Indicates if the invoker has this permission level 41 | */ 42 | 43 | const validPermValues = Object.values(PermissionsBitField.Flags); 44 | 45 | /** 46 | * Check if an array of permission strings has any invalid API permissions 47 | * Allows both String and BigInt 48 | * @param {Array} permArr Array of permission in string form 49 | * @returns {Array} Array of invalid permissions 50 | */ 51 | const getInvalidPerms = (permArr) => permArr.filter((perm) => typeof PermissionsBitField.Flags[perm] === 'undefined' && !validPermValues.includes(perm)); 52 | 53 | /** 54 | * Check if a user has specific permissions in a channel 55 | * @param {string} userId The ID of the user 56 | * @param {DiscordGuildChannel} channel The channel to check permissions in 57 | * @param {Array} permArr The array of permissions to check for 58 | * @returns {true | Array} True if the member has all permissions, 59 | * or the array of missing permissions 60 | */ 61 | const hasChannelPerms = (userId, channel, permArr) => { 62 | // Convert string to array 63 | if (typeof permArr === 'string') permArr = [ permArr ]; 64 | 65 | // Making sure all our perms are valid 66 | const invalidPerms = getInvalidPerms(permArr); 67 | 68 | if (invalidPerms.length >= 1) { 69 | throw new Error(`Invalid Discord permissions were provided: ${ invalidPerms.join(', ') }`); 70 | } 71 | 72 | // Return the entire array if no permissions are found 73 | if (!channel.permissionsFor(userId)) return permArr; 74 | 75 | // Filter missing permissions 76 | const missingPerms = permArr.filter( 77 | (perm) => !channel.permissionsFor(userId).has( 78 | PermissionsBitField.Flags[perm] ?? validPermValues.find((e) => e === perm) 79 | ) 80 | ); 81 | 82 | return missingPerms.length >= 1 ? missingPerms : true; 83 | }; 84 | 85 | const permMap = Object.entries(PermissionsBitField.Flags); 86 | /** 87 | * Transforms permission BigInts back into readable permissions 88 | * @param {string[]} perms Array of permissions 89 | * @returns {string[]} Resolved array of permissions 90 | */ 91 | const resolvePermissionArray = (perms) => { 92 | return perms.map((perm) => typeof perm === 'bigint' ? permMap.find((([ k, v ]) => v === perm))[0] : perm); 93 | }; 94 | 95 | /** 96 | * Our ordered permission level configuration 97 | * @member {Array} permConfig 98 | */ 99 | const permConfig = [ 100 | { 101 | name: 'User', 102 | level: 0, 103 | hasLevel: () => true 104 | }, 105 | 106 | { 107 | name: 'Moderator', 108 | level: 1, 109 | hasLevel: (config, member, channel) => hasChannelPerms( 110 | member.id, channel, [ 'KickMembers', 'BanMembers' ] 111 | ) === true 112 | }, 113 | 114 | { 115 | name: 'Administrator', 116 | level: 2, 117 | hasLevel: (config, member, channel) => hasChannelPerms(member.id, channel, [ 'Administrator' ]) === true 118 | }, 119 | 120 | { 121 | name: 'Server Owner', 122 | level: 3, 123 | hasLevel: (config, member, channel) => { 124 | // Shorthand 125 | // hasLevel: (config, member, channel) => (channel.guild?.ownerId === member.user?.id) 126 | // COULD result in (undefined === undefined) 127 | if (channel.guild && channel.guild.ownerId) { 128 | return (channel.guild.ownerId === member.id); 129 | } 130 | return false; 131 | } 132 | }, 133 | 134 | { 135 | name: 'Developer', 136 | level: 4, 137 | hasLevel: (config, member) => config.permissions.developers.includes(member.id) 138 | }, 139 | 140 | { 141 | name: 'Bot Owner', 142 | level: 5, 143 | hasLevel: (config, member) => config.permissions.ownerId === member.id 144 | } 145 | ]; 146 | 147 | /** 148 | * Enum for our permission levels/names 149 | * @readonly 150 | * @enum {string} 151 | */ 152 | const permLevelMap = { ...permConfig.map(({ name }) => name) }; 153 | 154 | /** 155 | * Resolve a permission level integer 156 | * @param {number} integer The permission level integer to resolve 157 | * @returns {string} The resolved permission level name 158 | */ 159 | const getPermLevelName = (integer) => permConfig.find((cfg) => cfg.level === integer)?.name; 160 | 161 | 162 | /** 163 | * Our {@link Handler/Permissions~PermConfig} sorted by perm level, highest first 164 | * @member {Array} sortedPermConfig 165 | */ 166 | const sortedPermConfig = permConfig.sort((a, b) => { 167 | return b.level - a.level; 168 | }); 169 | 170 | /** 171 | * Resolves someone's permission level 172 | * @method getPermissionLevel 173 | * @param {module:Client~ClientConfiguration} config Our client configuration object 174 | * @param {external:DiscordGuildMember} member The Discord API member object 175 | * @param {external:DiscordGuildChannel} channel The Discord API channel object 176 | * @returns {number} The member's permission level 177 | */ 178 | const getPermissionLevel = (config, member, channel) => { 179 | for (const currLvl of sortedPermConfig) { 180 | if (currLvl.hasLevel(config, member, channel)) { 181 | return currLvl.level; 182 | } 183 | } 184 | }; 185 | 186 | module.exports = { 187 | permConfig, 188 | sortedPermConfig, 189 | permLevelMap, 190 | validPermValues, 191 | permMap, 192 | resolvePermissionArray, 193 | getPermLevelName, 194 | getPermissionLevel, 195 | getInvalidPerms, 196 | hasChannelPerms 197 | }; 198 | -------------------------------------------------------------------------------- /src/listeners/interaction/interactionCreate.js: -------------------------------------------------------------------------------- 1 | const logger = require('@mirasaki/logger'); 2 | const chalk = require('chalk'); 3 | const { InteractionType } = require('discord.js'); 4 | const { checkCommandCanExecute, 5 | throttleCommand } = require('../../handlers/commands'); 6 | const { getPermissionLevel } = require('../../handlers/permissions'); 7 | const { 8 | titleCase, getRuntime, clientConfig 9 | } = require('../../util'); 10 | 11 | // Destructure from origin file because it's 12 | // used in multiple functions 13 | const { emojis } = require('../../client'); 14 | 15 | const { DEBUG_ENABLED, 16 | DEBUG_INTERACTIONS } = process.env; 17 | 18 | const checkInteractionAvailability = (interaction) => { 19 | const { member, guild } = interaction; 20 | 21 | // Check for DM interactions 22 | // Planning on adding support later down the road 23 | if (!interaction.inGuild()) { 24 | if (interaction.isRepliable()) { 25 | interaction.reply({ 26 | content: `${ emojis.error } ${ member }, I don't currently support DM interactions. Please try again in a server.`, 27 | ephemeral: true 28 | }); 29 | } 30 | return false; 31 | } 32 | 33 | // Check for outages 34 | if (guild?.available !== true) { 35 | const { guild } = interaction; 36 | 37 | logger.debug(`Interaction returned, server unavailable.\nServer: ${ guild.name } (${ guild.id })`); 38 | return false; 39 | } 40 | 41 | // Check for missing 'bot' scope 42 | if (!interaction.guild) { 43 | logger.debug('Interaction returned, missing \'bot\' scope / missing guild object in interaction.'); 44 | return false; 45 | } 46 | 47 | // Return true if all checks pass 48 | return true; 49 | }; 50 | 51 | // Resolves the active command 52 | const getCommand = (client, activeId) => { 53 | const { 54 | commands, contextMenus, buttons, modals, selectMenus 55 | } = client.container; 56 | 57 | return commands.get(activeId) 58 | || contextMenus.get(activeId) 59 | || buttons.get(activeId) 60 | || modals.get(activeId) 61 | || selectMenus.get(activeId); 62 | }; 63 | 64 | // eslint-disable-next-line sonarjs/cognitive-complexity 65 | const runCommand = (client, interaction, activeId, cmdRunTimeStart) => { 66 | const { 67 | member, guild, channel 68 | } = interaction; 69 | let clientCmd = getCommand(client, activeId); 70 | 71 | // Early escape hatch for in-command components 72 | if (activeId.startsWith('@')) { 73 | const secondAtSignIndex = activeId.indexOf('@', 1); 74 | const sliceEndIndex = secondAtSignIndex >= 0 ? secondAtSignIndex : activeId.length; 75 | const dynamicCmd = clientCmd = getCommand(client, activeId.slice(1, sliceEndIndex)); 76 | if (!dynamicCmd) return; // Should be ignored 77 | } 78 | 79 | // Check if we can reply to this interaction 80 | const clientCanReply = interaction.isRepliable(); 81 | 82 | // Check for late API changes 83 | if (!clientCmd) { 84 | if (clientCanReply) interaction.reply({ 85 | content: `${ emojis.error } ${ member }, this command currently isn't available.`, 86 | ephemeral: true 87 | }); 88 | logger.syserr(`Missing interaction listener for "${ activeId }" (name for commands, customId for components - ignored if starts with "@")`); 89 | return; 90 | } 91 | 92 | // Grab our data object from the client command 93 | const { data } = clientCmd; 94 | 95 | // Return if we can't reply to the interaction 96 | if (!clientCanReply) { 97 | logger.debug(`Interaction returned - Can't reply to interaction\nCommand: ${ data.name }\nServer: ${ guild.name }\nChannel: #${ channel.name }\nMember: ${ member }`); 98 | return; 99 | } 100 | 101 | // Perform our additional checks 102 | // Like permissions, NSFW, status, availability 103 | if (checkCommandCanExecute(client, interaction, clientCmd) === false) { 104 | // If individual checks fail 105 | // the function returns false and provides user feedback 106 | return; 107 | } 108 | 109 | // Throttle the command 110 | // permLevel 4 = Developer 111 | if (member.permLevel < 4) { 112 | const onCooldown = throttleCommand(clientCmd, interaction); 113 | 114 | if (onCooldown !== false) { 115 | interaction.reply({ 116 | content: onCooldown.replace('{{user}}', `${ member }`), 117 | ephemeral: true 118 | }); 119 | return; 120 | } 121 | } 122 | 123 | /* 124 | All checks have passed 125 | Run the command 126 | While catching possible errors 127 | */ 128 | (async () => { 129 | try { 130 | await clientCmd.run(client, interaction); 131 | } 132 | catch (err) { 133 | logger.syserr(`An error has occurred while executing the /${ chalk.whiteBright(activeId) } command`); 134 | console.error(err); 135 | } 136 | 137 | // Log command execution time 138 | if (DEBUG_ENABLED === 'true') { 139 | logger.debug(`${ chalk.white(activeId) } executed in ${ getRuntime(cmdRunTimeStart).ms } ms`); 140 | } 141 | })(); 142 | 143 | // Logging the Command to our console 144 | const aliasTag = clientCmd.isAlias ? `(Alias for: ${ clientCmd.aliasFor })` : ''; 145 | 146 | console.log([ 147 | `${ logger.timestamp() } ${ chalk.white('[CMD]') } : ${ chalk.bold(titleCase(activeId)) } ${ aliasTag } (${ InteractionType[interaction.type] })`, 148 | guild.name, 149 | `#${ channel.name }`, 150 | member.user.username 151 | ].join(chalk.magentaBright(` ${ emojis.separator } `))); 152 | }; 153 | 154 | module.exports = (client, interaction) => { 155 | // Definitions 156 | const { 157 | member, channel, commandName, customId 158 | } = interaction; 159 | 160 | // Initial performance measuring timer 161 | const cmdRunTimeStart = process.hrtime.bigint(); 162 | 163 | // Conditional Debug logging 164 | if (DEBUG_INTERACTIONS === 'true') { 165 | logger.startLog('New Interaction'); 166 | console.dir(interaction, { 167 | showHidden: false, depth: 0, colors: true 168 | }); 169 | logger.endLog('New Interaction'); 170 | } 171 | 172 | // Check interaction/command availability 173 | // API availability, guild object, etc 174 | // Replies to the interaction in function 175 | if (checkInteractionAvailability(interaction) === false) return; 176 | 177 | // Setting the permLevel on the member object before we do anything else 178 | const permLevel = getPermissionLevel(clientConfig, member, channel); 179 | 180 | interaction.member.permLevel = permLevel; 181 | 182 | // Handle ping interactions in separate file 183 | if (interaction.type === InteractionType.Ping) { 184 | client.emit('pingInteraction', (interaction)); 185 | return; 186 | } 187 | 188 | // Search the client.container.collections for the command 189 | const activeId = commandName || customId; 190 | const isAutoComplete = interaction.type === InteractionType.ApplicationCommandAutocomplete; 191 | 192 | // Execute early if autocomplete, 193 | // avoiding the permission checks 194 | // (as this is managed through default_member_permissions) 195 | if (isAutoComplete) { 196 | client.emit('autoCompleteInteraction', (interaction)); 197 | return; 198 | } 199 | 200 | // Run the command 201 | // Has additional checks inside 202 | runCommand(client, interaction, activeId, cmdRunTimeStart); 203 | }; 204 | -------------------------------------------------------------------------------- /src/commands/music/search.js: -------------------------------------------------------------------------------- 1 | const { 2 | ApplicationCommandOptionType, EmbedBuilder, ComponentType, ActionRowBuilder, ButtonBuilder, ButtonStyle 3 | } = require('discord.js'); 4 | const { ChatInputCommand } = require('../../classes/Commands'); 5 | const { useMainPlayer } = require('discord-player'); 6 | const { 7 | colorResolver, dynamicInteractionReplyFn, handlePaginationButtons, getPaginationComponents 8 | } = require('../../util'); 9 | const { queueTrackCb, requireSessionConditions } = require('../../modules/music'); 10 | const { MS_IN_ONE_MINUTE } = require('../../constants'); 11 | const logger = require('@mirasaki/logger'); 12 | 13 | module.exports = new ChatInputCommand({ 14 | global: true, 15 | data: { 16 | description: 'Play a song. Query SoundCloud, search Vimeo, provide a direct link, etc.', 17 | options: [ 18 | { 19 | name: 'query', 20 | type: ApplicationCommandOptionType.String, 21 | autocomplete: true, 22 | description: 'The music to search/query', 23 | required: true 24 | } 25 | ] 26 | }, 27 | // eslint-disable-next-line sonarjs/cognitive-complexity 28 | run: async (client, interaction) => { 29 | const player = useMainPlayer(); 30 | const { emojis } = client.container; 31 | const { 32 | member, guild, options 33 | } = interaction; 34 | const query = options.getString('query', true); // we need input/query to play 35 | 36 | // Check state 37 | if (!requireSessionConditions(interaction, false, false, false)) return; 38 | 39 | // Let's defer the interaction as things can take time to process 40 | await interaction.deferReply(); 41 | 42 | try { 43 | // Check is valid 44 | const searchResult = await player 45 | .search(query, { requestedBy: interaction.user }) 46 | .catch(() => null); 47 | if (!searchResult.hasTracks()) { 48 | interaction.editReply(`${ emojis.error } ${ member }, no tracks found for query \`${ query }\` - this command has been cancelled`); 49 | return; 50 | } 51 | 52 | // Ok, display the search results! 53 | const { tracks } = searchResult.toJSON(); 54 | const usableCtx = []; 55 | const chunkSize = 10; 56 | for (let i = 0; i < tracks.length; i += chunkSize) { 57 | // Cut chunk 58 | const chunk = tracks.slice(i, i + chunkSize); 59 | const embed = new EmbedBuilder() 60 | .setColor(colorResolver()) 61 | .setAuthor({ 62 | name: `Search results for "${ query }"`, 63 | iconURL: guild.iconURL({ dynamic: true }) 64 | }); 65 | 66 | // Resolve string output 67 | const chunkOutput = chunk.map((e, ind) => queueTrackCb(e, ind + i)).join('\n'); 68 | 69 | // Construct our embed 70 | embed 71 | .setDescription(chunkOutput ?? 'No results') 72 | .setImage(chunk[0]?.thumbnail) 73 | .setFooter({ text: `Page ${ Math.ceil((i + chunkSize) / chunkSize) } of ${ 74 | Math.ceil(tracks.length / chunkSize) 75 | } (${ i + 1 }-${ Math.min(i + chunkSize, tracks.length) } / ${ tracks.length })\nClick any of the numbered buttons to directly play a song` }); 76 | 77 | // Construct button rows 78 | const rows = []; 79 | chunk.forEach((track, ind) => { 80 | const rowIndex = Math.floor(ind / 5) + chunk.slice(0, ind + 1).filter((e) => e === null).length; 81 | 82 | // 5 components per row 83 | if (!rows[rowIndex]) rows[rowIndex] = new ActionRowBuilder(); 84 | const row = rows[rowIndex]; 85 | 86 | const { url } = track; 87 | row.addComponents( 88 | new ButtonBuilder() 89 | .setCustomId(`@play-button@${ member.id }@${ url.slice(0, ( 90 | 100 // max allowed len 91 | - 13 // @play...@ 92 | - member.id.length // identifier 93 | - 1 // @ 94 | )) }`) 95 | .setLabel(`${ ind + 1 }`) 96 | .setStyle(ButtonStyle.Primary) 97 | ); 98 | }); 99 | 100 | // Always push to usable embeds 101 | usableCtx.push({ 102 | embeds: [ embed ], 103 | components: rows 104 | }); 105 | } 106 | 107 | // Reply to the interaction with the SINGLE embed 108 | if (usableCtx.length === 1) interaction.editReply(usableCtx[0]).catch(() => { /* Void */ }); 109 | // Properly handle pagination for multiple embeds 110 | else handlePagination(interaction, member, usableCtx); 111 | } 112 | catch (e) { 113 | interaction.editReply(`${ emojis.error } ${ member }, something went wrong:\n\n${ e.message }`); 114 | } 115 | } 116 | }); 117 | 118 | async function handlePagination ( 119 | interaction, 120 | member, 121 | usableCtx, 122 | activeDurationMs = MS_IN_ONE_MINUTE * 3, 123 | shouldFollowUpIfReplied = false 124 | ) { 125 | let pageNow = 1; 126 | const prevCustomId = `@page-prev@${ member.id }@${ Date.now() }`; 127 | const nextCustomId = `@page-next@${ member.id }@${ Date.now() }`; 128 | 129 | const initialCtx = { 130 | embeds: usableCtx[pageNow - 1].embeds, 131 | components: [ 132 | ...getPaginationComponents( 133 | pageNow, 134 | usableCtx.length, 135 | prevCustomId, 136 | nextCustomId 137 | ), 138 | ...usableCtx[pageNow - 1].components 139 | ], 140 | fetchReply: true 141 | }; 142 | const replyFunction = dynamicInteractionReplyFn(interaction, shouldFollowUpIfReplied); 143 | const interactionMessage = await replyFunction 144 | .call(interaction, initialCtx) 145 | .catch((err) => { 146 | logger.syserr('Error encountered while responding to interaction with dynamic reply function:'); 147 | console.dir({ 148 | pageNow, 149 | prevCustomId, 150 | nextCustomId, 151 | initialCtx 152 | }); 153 | console.error(err); 154 | }); 155 | 156 | // Button reply/input collector 157 | const paginationCollector = interactionMessage.createMessageComponentCollector({ 158 | filter: (i) => ( 159 | // Filter out custom ids 160 | i.customId === prevCustomId || i.customId === nextCustomId 161 | ), // Filter out people without access to the command 162 | componentType: ComponentType.Button, 163 | time: activeDurationMs 164 | }); 165 | 166 | // Reusable update 167 | const updateEmbedReply = (i) => i.update({ 168 | embeds: usableCtx[pageNow - 1].embeds, 169 | components: [ 170 | ...getPaginationComponents( 171 | pageNow, 172 | usableCtx.length, 173 | prevCustomId, 174 | nextCustomId 175 | ), 176 | ...usableCtx[pageNow - 1].components 177 | ] 178 | }); 179 | 180 | // And finally, running code when it collects an interaction (defined as "i" in this callback) 181 | paginationCollector.on('collect', (i) => { 182 | if (handlePaginationButtons( 183 | i, 184 | member, 185 | pageNow, 186 | prevCustomId, 187 | nextCustomId, 188 | usableCtx 189 | ) !== true) return; 190 | 191 | // Prev Button - Go to previous page 192 | if (i.customId === prevCustomId) pageNow--; 193 | // Next Button - Go to next page 194 | else if (i.customId === nextCustomId) pageNow++; 195 | 196 | // Update reply with new page index 197 | updateEmbedReply(i); 198 | }); 199 | 200 | paginationCollector.on('end', () => { 201 | interaction.editReply({ components: getPaginationComponents( 202 | pageNow, 203 | usableCtx.length, 204 | prevCustomId, 205 | nextCustomId, 206 | true 207 | ) }).catch(() => { /* Void */ }); 208 | }); 209 | } 210 | 211 | --------------------------------------------------------------------------------