commit a52dbbc103b34445cd45e817ea1cad1d978600d4 Author: uzurka Date: Fri Oct 27 10:31:20 2023 +0200 initial commit: fork diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1308c22 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,22 @@ +# Nextjs Files +.next + +# Docker Files +Dockerfile +docker-compose.yml +docker-compose.yaml +.dockerignore + +# Git Files +.git +.github +.gitignore +LICENSE +README.md + +# Node Modules and lint settings +node_modules +.prettierrc +dist +logs +Lavalink.jar \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..cd6d5ea --- /dev/null +++ b/.env.example @@ -0,0 +1,34 @@ +# DB URL +DATABASE_URL="postgresql://john:doe@localhost:5432/master-bot?schema=public" + +# Bot Token +DISCORD_TOKEN="" + +NEXTAUTH_SECRET="somesupersecrettwelvelengthword" +NEXTAUTH_URL= +NEXTAUTH_URL_INTERNAL=http://localhost:3000 +NEXT_PUBLIC_INVITE_URL="https://discord.com/api/oauth2/authorize?client_id=yourclientid&permissions=8&scope=bot" + +# Next Auth Discord Provider +DISCORD_CLIENT_ID="" +DISCORD_CLIENT_SECRET="" + +# Lavalink +LAVA_HOST="0.0.0.0" +LAVA_PASS="youshallnotpass" +LAVA_PORT=2333 +LAVA_SECURE=false + +# Spotify +SPOTIFY_CLIENT_ID="" +SPOTIFY_CLIENT_SECRET="" + +# Twitch +TWITCH_CLIENT_ID="" +TWITCH_CLIENT_SECRET="" + +# Other APIs +TENOR_API="" +NEWS_API="" +GENIUS_API="" +RAWG_API="" diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..18daf29 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,36 @@ +--- +name: Bug report +about: Create a report +title: '' +labels: 'bug' +assignees: '' +--- + +### IMPORTANT : _DO NOT SKIP THIS STEPS AND DO NOT DELETE THEM. WE CAN NOT HELP YOU IF YOU DO NOT PROVIDE INFORMATION AND STEPS TO REPRODUCE_ + +Do not open an issue if you simply "copied" code over to your bot/another bot. This is absolutely not recommended and will cause bugs. Also do not open an issue if you modified code and added features and now it's not working right. This is because I can't figure it out and don't have the time to read your code and find out what you did wrong. + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: + +1. Use 'x' command +2. provide 'y' argument + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Desktop (please complete the following information):** + +- OS: [e.g. Windows, Ubuntu...]: +- Node.js Version(Should be v16 at least): +- Is python 2.7 installed?: +- How are you hosting the bot(Locally, on a vps, heroku, glitch...): + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..c38d541 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,9 @@ +--- +name: Feature request +about: Suggest/request a new bot feature +title: '' +labels: 'enhancement' +assignees: '' +--- + +**Explain your suggestion** diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..a99ff8c --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,11 @@ +on: [pull_request] + +jobs: + prettier: + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + - uses: actions/checkout@v3 + + - name: Build App + run: npx prettier . --check diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8630e8a --- /dev/null +++ b/.gitignore @@ -0,0 +1,57 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# Personal +*.pem +.env +.env*.local + +# Turbo +.turbo + +# Testing +/coverage + +# Database +*/migrations/* + +# Production +build +dist + +# next.js +.next +out + +# Vercel +.vercel + +# Lavalink +Lavalink.jar +application.yml +application.yaml + +# Typescript +*.tsbuildinfo + +# Dev utils and Dependencies +node_modules +*.vscode +.DS_Store +*.bat + +# Debug +logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# Legacy +db.sqlite +db.sqlite-journal +*/.pnp +.pnp.js +logs +config.json +test.js +json.sqlite \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..96e2221 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,8 @@ +{ + "trailingComma": "none", + "useTabs": true, + "tabWidth": 2, + "semi": true, + "singleQuote": true, + "arrowParens": "avoid" +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..30e1811 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +FROM --platform=linux/amd64 node:18-slim +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" +ENV NEXT_TELEMETRY_DISABLED 1 +WORKDIR "/Master-Bot" + +# Ports for the Dashboard +EXPOSE 3000 +ENV PORT 3000 + +# Install prerequisites and register fonts +RUN apt-get update && apt-get upgrade -y -q && \ + apt-get install -y -q openssl && \ + apt-get install -y -q --no-install-recommends libfontconfig1 && \ + npm install -g pnpm + +# Copy files to Container (Excluding whats in .dockerignore) +COPY ./ ./ +RUN pnpm install --ignore-scripts && pnpm -F * build + +# If you are running Master-Bot in a Standalone Container and need to connect to a service on localhost uncomment the following ENV for each service running on the containers host +# ENV POSTGRES_HOST="host.docker.internal" +# ENV REDIS_HOST="host.docker.internal" +# ENV LAVA_HOST="host.docker.internal" + +# Uncomment the following for Standalone Master-Bot Docker Container Build +# RUN pnpm db:push +# CMD ["pnpm", "-r", "start"] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..435503e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Julius Marminge + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ef92bb0 --- /dev/null +++ b/README.md @@ -0,0 +1,260 @@ +# A Discord Music Bot written in TypeScript using Sapphire, discord.js, Next.js and React + +[![image](https://img.shields.io/badge/language-typescript-blue)](https://www.typescriptlang.org) +[![image](https://img.shields.io/badge/node-%3E%3D%2016.0.0-blue)](https://nodejs.org/) + +## System dependencies + +- [Node.js LTS or latest](https://nodejs.org/en/download/) +- [Java 13](https://www.azul.com/downloads/?package=jdk#download-openjdk) (other versions have some issues with Lavalink) + +## Setup bot + +Create an [application.yml](https://github.com/freyacodes/lavalink/blob/master/LavalinkServer/application.yml.example) file root folder. + +Download the latest Lavalink jar from [here](https://github.com/Cog-Creators/Lavalink-Jars/releases) and also place it in the root folder. + +### PostgreSQL + +#### Linux + +Either from the official site or follow the tutorial for your [distro](https://www.digitalocean.com/community/tutorial_collections/how-to-install-and-use-postgresql). + +#### MacOS + +Get [brew](https://brew.sh), then enter 'brew install postgresql'. + +#### Windows + +Getting Postgres and Prisma to work together on Windows is not worth the hassle. Create an account on [heroku](https://dashboard.heroku.com/apps) and follow these steps: + +1. Open the dashboard and click on 'New' > 'Create new app', give it a name and select the closest region to you then click on 'Create app'. +2. Go to 'Resources' tab, under 'Add-ons' search for 'Heroku Postgres' and select it. Click 'Submit Order Form' and then do the same step again (create another postgres instance). +3. Click on each 'Heroku Postgres' addon you created, go to 'Settings' tab > Database Credentials > View Credentials and copy the each one's URI to either `DATABASE_URL` or `SHADOW_DB_URL` in the .env file you will be creating in the settings section. +4. Done! + +### Redis + +#### MacOS + +`brew install redis`. + +#### Windows + +Download from [here](https://redis.io/download/). + +#### Linux + +Follow the instructions [here](https://redis.io/docs/getting-started/installation/install-redis-on-linux/). + +### Settings (env) + +Create a `.env` file in the root directory and copy the contents of .env.example to it. +Note: if you are not hosting postgres on Heroku you do not need the SHADOW_DB_URL variable. + +```env +# DB URL +DATABASE_URL="postgresql://john:doe@localhost:5432/master-bot?schema=public" + +# Bot Token +DISCORD_TOKEN="" + +NEXTAUTH_SECRET="somesupersecrettwelvelengthword" +NEXTAUTH_URL= +NEXTAUTH_URL_INTERNAL=http://localhost:3000 +NEXT_PUBLIC_INVITE_URL="https://discord.com/api/oauth2/authorize?client_id=yourclientid&permissions=8&scope=bot" + +# Next Auth Discord Provider +DISCORD_CLIENT_ID="" +DISCORD_CLIENT_SECRET="" + +# Lavalink +LAVA_HOST="0.0.0.0" +LAVA_PASS="youshallnotpass" +LAVA_PORT=2333 +LAVA_SECURE=false + +# Spotify +SPOTIFY_CLIENT_ID="" +SPOTIFY_CLIENT_SECRET="" + +# Twitch +TWITCH_CLIENT_ID="" +TWITCH_CLIENT_SECRET="" + +# Other APIs +TENOR_API="" +NEWS_API="" +GENIUS_API="" +RAWG_API="" + +``` + +#### Gif features + +If you have no use in the gif commands, leave everything under 'Other APIs' empty. Same applies for Twitch, everything else is needed. + +#### DB URL + +Change 'john' to your pc username and 'doe' to some password, or set the name and password you created when you installed Postgres. + +#### Bot Token + +Generate a token in your Discord developer portal. + +#### Next Auth + +You can leave everything as is, just change 'yourclientid' in NEXT_PUBLIC_INVITE_URL to your Discord bot id and then change 'domain' in NEXTAUTH_URL to your domain or public ip. You can find your public ip by going to [www.whatismyip.com](https://www.whatismyip.com/). + +#### Next Auth Discord Provider + +Go to the OAuth2 tab in the developer portal, copy the Client ID to DISCORD_CLIENT_ID and generate a secret to place in DISCORD_CLIENT_SECRET. Also, set the following URLs under 'Redirects': + +- http://localhost:3000/api/auth/callback/discord +- http://domain:3000/api/auth/callback/discord + +Make sure to change 'domain' in http://domain:3000/api/auth/callback/discord to your domain or public ip. + +#### Lavalink + +You can leave this as long as the values match your application.yml. + +#### Spotify and Twitch + +Create an application in each platform's developer portal and paste the relevant values. + +#### Pnpm +Install pnpm: +`npm install -g pnpm` or on Windows `iwr https://get.pnpm.io/install.ps1 -useb | iex` or on Mac using Homebrew `brew install pnpm` + +# Running the bot + +1. If you followed everything right, hit `pnpm i` in the root folder. When it finishes make sure prisma didn't error. +2. Open a separate terminal in the root folder and run 'java -jar Lavalink.jar' (must be running all the time). +3. Wait a few seconds and run `pnpm dev` in the root folder in another terminal window. +4. If everything works, your bot and dashboard should be running. +5. Enjoy! + +# Commands + +A full list of commands for use with Master Bot + +## Music + +| Command | Description | Usage | +| --------------------- | ------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | +| /play | Play any song or playlist from youtube, you can do it by searching for a song by name or song url or playlist url | /play darude sandstorm | +| /pause | Pause the current playing song | /pause | +| /resume | Resume the current paused song | /resume | +| /leave | Leaves voice channel if in one | /leave | +| /remove | Remove a specific song from queue by its number in queue | /remove 4 | +| /queue | Display the song queue | /queue | +| /shuffle | Shuffle the song queue | /shuffle | +| /skip | Skip the current playing song | /skip | +| /skipall | Skip all songs in queue | /skipall | +| /skipto | Skip to a specific song in the queue, provide the song number as an argument | /skipto 5 | +| /volume | Adjust song volume | /volume 80 | +| /music-trivia | Engage in a music trivia with your friends. You can add more songs to the trivia pool in resources/music/musictrivia.json | /music-trivia | +| /loop | Loop the currently playing song or queue | /loop | +| /lyrics | Get lyrics of any song or the lyrics of the currently playing song | /lyrics song-name | +| /now-playing | Display the current playing song with a playback bar | /now-playing | +| /move | Move song to a desired position in queue | /move 8 1 | +| /queue-history | Display the queue history | /queue-history | +| /create-playlist | Create a custom playlist | /create-playlist 'playlistname' | +| /save-to-playlist | Add a song or playlist to a custom playlist | /save-to-playlist 'playlistname' 'yt or spotify url' | +| /remove-from-playlist | Remove a track from a custom playlist | /remove-from-playlist 'playlistname' 'track location' | +| /my-playlists | Display your custom playlists | /my-playlists | +| /display-playlist | Display a custom playlist | /display-playlist 'playlistname' | +| /delete-playlist | remove a custom playlist | /delete-playlist 'playlistname' | + +## Gifs + +| Command | Description | Usage | +| ---------- | -------------------------- | ---------- | +| /gif | Get a random gif | /gif | +| /jojo | Get a random jojo gif | /jojo | +| /gintama | Get a random gintama gif | /gintama | +| /anime | Get a random anime gif | /anime | +| /baka | Get a random baka gif | /baka | +| /cat | Get a cute cat picture | /cat | +| /doggo | Get a cute dog picture | /doggo | +| /hug | Get a random hug gif | /hug | +| /slap | Get a random slap gif | /slap | +| /pat | Get a random pat gif | /pat | +| /triggered | Get a random triggered gif | /triggered | +| /amongus | Get a random Among Us gif | /amongus | + +## Other + +| Command | Description | Usage | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------- | +| /fortune | Get a fortune cookie tip | /fortune | +| /insult | Generate an evil insult | /insult | +| /chucknorris | Get a satirical fact about Chuck Norris | /chucknorris | +| /motivation | Get a random motivational quote | /motivation | +| /random | Generate a random number between two provided numbers | /random 0 100 | +| /8ball | Get the answer to anything! | /8ball Is this bot awesome? | +| /rps | Rock Paper Scissors | /rps | +| /bored | Generate a random activity! | /bored | +| /advice | Get some advice! | /advice | +| /game-search | Search for game information. | /game-search super-metroid | +| /kanye | Get a random Kanye quote | /kanye | +| /world-news | Latest headlines from reuters, you can change the news source to whatever news source you want, just change the source in line 13 in world-news.js or ynet-news.js | /world-news | +| /translate | Translate to any language using Google translate.(only supported languages) | /translate english ありがとう | +| /about | Info about me and the repo | /about | +| /urban dictionary | Get definitions from urban dictionary | /urban javascript | +| /activity | Generate an invite link to your voice channel's activity | /activity voicechannel Chill | +| /twitch-status | Check the status of a Twitch steamer | /twitch-status streamer: bacon_fixation | + +## Resources + +[Getting a Tenor API key](https://developers.google.com/tenor/guides/quickstart) + +[Getting a NewsAPI API key](https://newsapi.org/) + +[Getting a Genius API key](https://genius.com/api-clients/new) + +[Getting a rawg API key](https://rawg.io/apidocs) + +[Getting a Twitch API key](https://github.com/Bacon-Fixation/Master-Bot/wiki/Getting-Your-Twitch-API-Info) + +[Installing Node.js on Debian](https://www.digitalocean.com/community/tutorials/how-to-set-up-a-node-js-application-for-production-on-debian-9) + +[Installing Node.js on Windows](https://treehouse.github.io/installation-guides/windows/node-windows.html) + +[Installing on a Raspberry Pi](https://github.com/galnir/Master-Bot/wiki/Running-the-bot-on-a-Raspberry-Pi) + +[Using a Repl.it LavaLink server](https://github.com/galnir/Master-Bot/wiki/Setting-Up-LavaLink-with-a-Replit-server) + +[Using a public LavaLink server](https://github.com/galnir/Master-Bot/wiki/Setting-Up-LavaLink-with-a-public-LavaLink-Server) + +[Using an Internal LavaLink server](https://github.com/galnir/Master-Bot/wiki/Setting-up-LavaLink-with-an-Internal-LavaLink-server) + +## Contributing + +Fork it and submit a pull request! +Anyone is welcome to suggest new features and improve code quality! + +## Contributors ❤️ + +**⭐ [Bacon Fixation](https://github.com/Bacon-Fixation) ⭐ - Countless contributions** + +[ModoSN](https://github.com/ModoSN) - 'resolve-ip', 'rps', '8ball', 'bored', 'trump', 'advice', 'kanye', 'urban dictionary' commands and visual updates + +[PhantomNimbi](https://github.com/PhantomNimbi) - bring back gif commands, lavalink config tweaks + +[Natemo6348](https://github.com/Natemo6348) - 'mute', 'unmute' + +[kfirmeg](https://github.com/kfirmeg) - play command flags, dockerization, docker wiki + +[rafaeldamasceno](https://github.com/rafaeldamasceno) - 'music-trivia' and Dockerfile improvements, minor tweaks + +[navidmafi](https://github.com/navidmafi) - 'LeaveTimeOut' and 'MaxResponseTime' options, update issue template, fix leave command + +[Kyoyo](https://github.com/NotKyoyo) - added back 'now-playing' + +[MontejoJorge](https://github.com/MontejoJorge) - added back 'remind' + +[malokdev](https://github.com/malokdev) - 'uptime' command + +[chimaerra](https://github.com/chimaerra) - minor command tweaks diff --git a/apps/bot/.sapphirerc.json b/apps/bot/.sapphirerc.json new file mode 100644 index 0000000..018867f --- /dev/null +++ b/apps/bot/.sapphirerc.json @@ -0,0 +1,15 @@ +{ + "projectLanguage": "ts", + "locations": { + "base": "src", + "arguments": "arguments", + "commands": "commands", + "listeners": "listeners", + "preconditions": "preconditions", + "interaction-handlers": "interaction-handlers" + }, + "customFileTemplates": { + "enabled": false, + "location": "" + } +} diff --git a/apps/bot/package.json b/apps/bot/package.json new file mode 100644 index 0000000..301b027 --- /dev/null +++ b/apps/bot/package.json @@ -0,0 +1,73 @@ +{ + "name": "@master-bot/bot", + "version": "1.0.0", + "private": true, + "description": "a discord music bot with guild , gifs and misc commands", + "author": "Nir Gal", + "license": "ISC", + "main": "dist/index.js", + "scripts": { + "build": "pnpm with-env tsc", + "watch": "tsc --watch", + "copy-scripts": "pnpx ncp ./scripts ./dist/", + "dev": "pnpm build && pnpm copy-scripts && run-p watch start", + "start": "pnpm with-env node dist/index.js", + "with-env": "dotenv -e ../../.env --" + }, + "engines": { + "node": ">=v18.16.1" + }, + "dependencies": { + "@discordjs/collection": "^1.5.2", + "@lavaclient/spotify": "^3.1.0", + "@lavalink/encoding": "^0.1.2", + "@master-bot/api": "^0.1.0", + "@napi-rs/canvas": "^0.1.41", + "@prisma/client": "^5.1.1", + "@sapphire/decorators": "^6.0.2", + "@sapphire/discord.js-utilities": "^7.0.1", + "@sapphire/framework": "^4.5.1", + "@sapphire/plugin-hmr": "^2.0.1", + "@sapphire/time-utilities": "^1.7.10", + "@sapphire/utilities": "^3.13.0", + "@t3-oss/env-core": "^0.6.0", + "@trpc/client": "^10.37.1", + "@trpc/server": "^10.37.1", + "axios": "^1.4.0", + "colorette": "^2.0.20", + "discord.js": "^14.12.1", + "genius-discord-lyrics": "1.0.5", + "google-translate-api-x": "^10.6.7", + "ioredis": "^5.3.2", + "iso-639-1": "^2.1.15", + "lavaclient": "^4.1.1", + "metadata-filter": "^1.3.0", + "ncp": "^2.0.0", + "node-fetch": "^3.3.2", + "npm-run-all": "^4.1.5", + "string-progressbar": "^1.0.4", + "superjson": "1.13.1", + "winston": "^3.10.0", + "winston-daily-rotate-file": "^4.7.1", + "zod": "^3.21.4" + }, + "devDependencies": { + "@lavaclient/types": "^2.1.1", + "@sapphire/ts-config": "^4.0.1", + "@types/ioredis": "^4.28.10", + "@types/node": "^20.4.6", + "@typescript-eslint/eslint-plugin": "^6.2.1", + "@typescript-eslint/parser": "^6.2.1", + "dotenv": "^16.3.1", + "dotenv-cli": "^7.2.1", + "prettier": "^3.0.1", + "tslib": "^2.6.1", + "typescript": "^5.1.6" + }, + "eslintConfig": { + "root": true, + "extends": [ + "@master-bot/eslint-config/base" + ] + } +} diff --git a/apps/bot/scripts/audio/lmove.lua b/apps/bot/scripts/audio/lmove.lua new file mode 100644 index 0000000..c8b850b --- /dev/null +++ b/apps/bot/scripts/audio/lmove.lua @@ -0,0 +1,20 @@ +local KEY = KEYS[1] +local FROM = tonumber(ARGV[1]) +local TO = tonumber(ARGV[2]) + +if FROM == nil then return redis.redis_error('origin must be a number') end +if TO == nil then return redis.redis_error('destination must be a number') end + +local list = redis.call('lrange', KEY, 0, -1) + +if FROM == TO then return 'OK' end +if FROM < 0 then FROM = #list + FROM end +if TO < 0 then TO = #list + TO end + +-- provided indexes are 0-based +local val = table.remove(list, FROM + 1) +table.insert(list, TO + 1, val) + +redis.call('del', KEY) +redis.call('rpush', KEY, unpack(list)) +return 'OK' diff --git a/apps/bot/scripts/audio/lremat.lua b/apps/bot/scripts/audio/lremat.lua new file mode 100644 index 0000000..cf58a37 --- /dev/null +++ b/apps/bot/scripts/audio/lremat.lua @@ -0,0 +1,20 @@ +local KEY = KEYS[1] +local INDEX = tonumber(ARGV[1]) + +if INDEX == nil then return redis.redis_error('origin must be a number') end + +local list = redis.call('lrange', KEY, 0, -1) + +if INDEX < 0 then INDEX = #list + INDEX end + +-- provided indexes are 0-based +table.remove(list, INDEX + 1) + +redis.call('del', KEY) + +-- If there is at least one element, call rpush +if (next(list) ~= nil) then + redis.call('rpush', KEY, unpack(list)) +end + +return 'OK' diff --git a/apps/bot/scripts/audio/lshuffle.lua b/apps/bot/scripts/audio/lshuffle.lua new file mode 100644 index 0000000..14a8d1a --- /dev/null +++ b/apps/bot/scripts/audio/lshuffle.lua @@ -0,0 +1,19 @@ +math.randomseed(tonumber(ARGV[1])) +local function shuffle(t) + for i = #t, 1, -1 do + local rand = math.random(i) + t[i], t[rand] = t[rand], t[i] + end + return t +end + +local KEY = KEYS[1] +local list = redis.call('lrange', KEY, 0, -1) + +if #list > 0 then + shuffle(list) + redis.call('del', KEY) + redis.call('lpush', KEY, unpack(list)) +end + +return 'OK' diff --git a/apps/bot/scripts/audio/rpopset.lua b/apps/bot/scripts/audio/rpopset.lua new file mode 100644 index 0000000..72beb2a --- /dev/null +++ b/apps/bot/scripts/audio/rpopset.lua @@ -0,0 +1,12 @@ +local SOURCE = KEYS[1] +local DESTINATION = KEYS[2] + +local value = redis.call('rpop', SOURCE) + +if value then + redis.call('set', DESTINATION, value) + return value +end + +redis.call('del', DESTINATION) +return nil diff --git a/apps/bot/src/commands/gifs/amongus.ts b/apps/bot/src/commands/gifs/amongus.ts new file mode 100644 index 0000000..a910fc2 --- /dev/null +++ b/apps/bot/src/commands/gifs/amongus.ts @@ -0,0 +1,37 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Command } from '@sapphire/framework'; +import { env } from '../../env'; + +@ApplyOptions({ + name: 'amongus', + description: 'Replies with a random Among Us gif!', + preconditions: ['isCommandDisabled'] +}) +export class AmongUsCommand extends Command { + public override registerApplicationCommands(registry: Command.Registry) { + registry.registerChatInputCommand(builder => + builder.setName(this.name).setDescription(this.description) + ); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + try { + const response = await fetch( + `https://tenor.googleapis.com/v2/search?key=${env.TENOR_API}&q=amongus&limit=1&random=true` + ); + const json = await response.json(); + if (!json.results) + return await interaction.reply({ + content: 'Something went wrong! Please try again later.' + }); + + return await interaction.reply({ content: json.results[0].url }); + } catch (e) { + return await interaction.reply({ + content: 'Something went wrong! Please try again later.' + }); + } + } +} diff --git a/apps/bot/src/commands/gifs/anime.ts b/apps/bot/src/commands/gifs/anime.ts new file mode 100644 index 0000000..2f1bafb --- /dev/null +++ b/apps/bot/src/commands/gifs/anime.ts @@ -0,0 +1,37 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Command } from '@sapphire/framework'; +import { env } from '../../env'; + +@ApplyOptions({ + name: 'anime', + description: 'Replies with a random anime gif!', + preconditions: ['isCommandDisabled'] +}) +export class AnimeCommand extends Command { + public override registerApplicationCommands(registry: Command.Registry) { + registry.registerChatInputCommand(builder => + builder.setName(this.name).setDescription(this.description) + ); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + try { + const response = await fetch( + `https://tenor.googleapis.com/v2/search?key=${env.TENOR_API}&q=anime&limit=1&random=true` + ); + const json = await response.json(); + if (!json.results) + return await interaction.reply({ + content: 'Something went wrong! Please try again later.' + }); + + return await interaction.reply({ content: json.results[0].url }); + } catch (e) { + return await interaction.reply({ + content: 'Something went wrong! Please try again later.' + }); + } + } +} diff --git a/apps/bot/src/commands/gifs/baka.ts b/apps/bot/src/commands/gifs/baka.ts new file mode 100644 index 0000000..14053b5 --- /dev/null +++ b/apps/bot/src/commands/gifs/baka.ts @@ -0,0 +1,37 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Command } from '@sapphire/framework'; +import { env } from '../../env'; + +@ApplyOptions({ + name: 'baka', + description: 'Replies with a random baka gif!', + preconditions: ['isCommandDisabled'] +}) +export class BakaCommand extends Command { + public override registerApplicationCommands(registry: Command.Registry) { + registry.registerChatInputCommand(builder => + builder.setName(this.name).setDescription(this.description) + ); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + try { + const response = await fetch( + `https://tenor.googleapis.com/v2/search?key=${env.TENOR_API}&q=baka&limit=1&random=true` + ); + const json = await response.json(); + if (!json.results) + return await interaction.reply({ + content: 'Something went wrong! Please try again later.' + }); + + return await interaction.reply({ content: json.results[0].url }); + } catch (e) { + return await interaction.reply({ + content: 'Something went wrong! Please try again later.' + }); + } + } +} diff --git a/apps/bot/src/commands/gifs/cat.ts b/apps/bot/src/commands/gifs/cat.ts new file mode 100644 index 0000000..0f22e74 --- /dev/null +++ b/apps/bot/src/commands/gifs/cat.ts @@ -0,0 +1,37 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Command } from '@sapphire/framework'; +import { env } from '../../env'; + +@ApplyOptions({ + name: 'cat', + description: 'Replies with a random cat gif!', + preconditions: ['isCommandDisabled'] +}) +export class CatCommand extends Command { + public override registerApplicationCommands(registry: Command.Registry) { + registry.registerChatInputCommand(builder => + builder.setName(this.name).setDescription(this.description) + ); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + try { + const response = await fetch( + `https://tenor.googleapis.com/v2/search?key=${env.TENOR_API}&q=cat&limit=1&random=true` + ); + const json = await response.json(); + if (!json.results) + return await interaction.reply({ + content: 'Something went wrong! Please try again later.' + }); + + return await interaction.reply({ content: json.results[0].url }); + } catch (e) { + return await interaction.reply({ + content: 'Something went wrong! Please try again later.' + }); + } + } +} diff --git a/apps/bot/src/commands/gifs/doggo.ts b/apps/bot/src/commands/gifs/doggo.ts new file mode 100644 index 0000000..e1fb397 --- /dev/null +++ b/apps/bot/src/commands/gifs/doggo.ts @@ -0,0 +1,37 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Command } from '@sapphire/framework'; +import { env } from '../../env'; + +@ApplyOptions({ + name: 'doggo', + description: 'Replies with a random doggo gif!', + preconditions: ['isCommandDisabled'] +}) +export class DoggoCommand extends Command { + public override registerApplicationCommands(registry: Command.Registry) { + registry.registerChatInputCommand(builder => + builder.setName(this.name).setDescription(this.description) + ); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + try { + const response = await fetch( + `https://tenor.googleapis.com/v2/search?key=${env.TENOR_API}&q=doggo&limit=1&random=true` + ); + const json = await response.json(); + if (!json.results) + return await interaction.reply({ + content: 'Something went wrong! Please try again later.' + }); + + return await interaction.reply({ content: json.results[0].url }); + } catch (e) { + return await interaction.reply({ + content: 'Something went wrong! Please try again later.' + }); + } + } +} diff --git a/apps/bot/src/commands/gifs/gif.ts b/apps/bot/src/commands/gifs/gif.ts new file mode 100644 index 0000000..f73d8ff --- /dev/null +++ b/apps/bot/src/commands/gifs/gif.ts @@ -0,0 +1,37 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Command } from '@sapphire/framework'; +import { env } from '../../env'; + +@ApplyOptions({ + name: 'gif', + description: 'Replies with a random gif gif!', + preconditions: ['isCommandDisabled'] +}) +export class GifCommand extends Command { + public override registerApplicationCommands(registry: Command.Registry) { + registry.registerChatInputCommand(builder => + builder.setName(this.name).setDescription(this.description) + ); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + try { + const response = await fetch( + `https://tenor.googleapis.com/v2/search?key=${env.TENOR_API}&q=gif&limit=1&random=true` + ); + const json = await response.json(); + if (!json.results) + return await interaction.reply({ + content: 'Something went wrong! Please try again later.' + }); + + return await interaction.reply({ content: json.results[0].url }); + } catch (e) { + return await interaction.reply({ + content: 'Something went wrong! Please try again later.' + }); + } + } +} diff --git a/apps/bot/src/commands/gifs/gintama.ts b/apps/bot/src/commands/gifs/gintama.ts new file mode 100644 index 0000000..7a9be81 --- /dev/null +++ b/apps/bot/src/commands/gifs/gintama.ts @@ -0,0 +1,37 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Command } from '@sapphire/framework'; +import { env } from '../../env'; + +@ApplyOptions({ + name: 'gintama', + description: 'Replies with a random gintama gif!', + preconditions: ['isCommandDisabled'] +}) +export class GintamaCommand extends Command { + public override registerApplicationCommands(registry: Command.Registry) { + registry.registerChatInputCommand(builder => + builder.setName(this.name).setDescription(this.description) + ); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + try { + const response = await fetch( + `https://tenor.googleapis.com/v2/search?key=${env.TENOR_API}&q=gintama&limit=1&random=true` + ); + const json = await response.json(); + if (!json.results) + return await interaction.reply({ + content: 'Something went wrong! Please try again later.' + }); + + return await interaction.reply({ content: json.results[0].url }); + } catch (e) { + return await interaction.reply({ + content: 'Something went wrong! Please try again later.' + }); + } + } +} diff --git a/apps/bot/src/commands/gifs/hug.ts b/apps/bot/src/commands/gifs/hug.ts new file mode 100644 index 0000000..819cda1 --- /dev/null +++ b/apps/bot/src/commands/gifs/hug.ts @@ -0,0 +1,37 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Command } from '@sapphire/framework'; +import { env } from '../../env'; + +@ApplyOptions({ + name: 'hug', + description: 'Replies with a random hug gif!', + preconditions: ['isCommandDisabled'] +}) +export class HugCommand extends Command { + public override registerApplicationCommands(registry: Command.Registry) { + registry.registerChatInputCommand(builder => + builder.setName(this.name).setDescription(this.description) + ); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + try { + const response = await fetch( + `https://tenor.googleapis.com/v2/search?key=${env.TENOR_API}&q=hug&limit=1&random=true` + ); + const json = await response.json(); + if (!json.results) + return await interaction.reply({ + content: 'Something went wrong! Please try again later.' + }); + + return await interaction.reply({ content: json.results[0].url }); + } catch (e) { + return await interaction.reply({ + content: 'Something went wrong! Please try again later.' + }); + } + } +} diff --git a/apps/bot/src/commands/gifs/jojo.ts b/apps/bot/src/commands/gifs/jojo.ts new file mode 100644 index 0000000..afa6a15 --- /dev/null +++ b/apps/bot/src/commands/gifs/jojo.ts @@ -0,0 +1,37 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Command } from '@sapphire/framework'; +import { env } from '../../env'; + +@ApplyOptions({ + name: 'jojo', + description: 'Replies with a random jojo gif!', + preconditions: ['isCommandDisabled'] +}) +export class JojoCommand extends Command { + public override registerApplicationCommands(registry: Command.Registry) { + registry.registerChatInputCommand(builder => + builder.setName(this.name).setDescription(this.description) + ); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + try { + const response = await fetch( + `https://tenor.googleapis.com/v2/search?key=${env.TENOR_API}&q=jojo&limit=1&random=true` + ); + const json = await response.json(); + if (!json.results) + return await interaction.reply({ + content: 'Something went wrong! Please try again later.' + }); + + return await interaction.reply({ content: json.results[0].url }); + } catch (e) { + return await interaction.reply({ + content: 'Something went wrong! Please try again later.' + }); + } + } +} diff --git a/apps/bot/src/commands/gifs/slap.ts b/apps/bot/src/commands/gifs/slap.ts new file mode 100644 index 0000000..479ab4d --- /dev/null +++ b/apps/bot/src/commands/gifs/slap.ts @@ -0,0 +1,37 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Command } from '@sapphire/framework'; +import { env } from '../../env'; + +@ApplyOptions({ + name: 'slap', + description: 'Replies with a random slap gif!', + preconditions: ['isCommandDisabled'] +}) +export class SlapCommand extends Command { + public override registerApplicationCommands(registry: Command.Registry) { + registry.registerChatInputCommand(builder => + builder.setName(this.name).setDescription(this.description) + ); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + try { + const response = await fetch( + `https://tenor.googleapis.com/v2/search?key=${env.TENOR_API}&q=slap&limit=1&random=true` + ); + const json = await response.json(); + if (!json.results) + return await interaction.reply({ + content: 'Something went wrong! Please try again later.' + }); + + return await interaction.reply({ content: json.results[0].url }); + } catch (e) { + return await interaction.reply({ + content: 'Something went wrong! Please try again later.' + }); + } + } +} diff --git a/apps/bot/src/commands/gifs/waifu.ts b/apps/bot/src/commands/gifs/waifu.ts new file mode 100644 index 0000000..51a3268 --- /dev/null +++ b/apps/bot/src/commands/gifs/waifu.ts @@ -0,0 +1,37 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Command } from '@sapphire/framework'; +import { env } from '../../env'; + +@ApplyOptions({ + name: 'waifu', + description: 'Replies with a random waifu gif!', + preconditions: ['isCommandDisabled'] +}) +export class WaifuCommand extends Command { + public override registerApplicationCommands(registry: Command.Registry) { + registry.registerChatInputCommand(builder => + builder.setName(this.name).setDescription(this.description) + ); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + try { + const response = await fetch( + `https://tenor.googleapis.com/v2/search?key=${env.TENOR_API}&q=waifu&limit=1&random=true` + ); + const json = await response.json(); + if (!json.results) + return await interaction.reply({ + content: 'Something went wrong! Please try again later.' + }); + + return await interaction.reply({ content: json.results[0].url }); + } catch (e) { + return await interaction.reply({ + content: 'Something went wrong! Please try again later.' + }); + } + } +} diff --git a/apps/bot/src/commands/music/bassboost.ts b/apps/bot/src/commands/music/bassboost.ts new file mode 100644 index 0000000..8a55855 --- /dev/null +++ b/apps/bot/src/commands/music/bassboost.ts @@ -0,0 +1,52 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Command, CommandOptions } from '@sapphire/framework'; +import { container } from '@sapphire/framework'; +import type { Node, Player } from 'lavaclient'; + +@ApplyOptions({ + name: 'bassboost', + description: 'Boost the bass of the playing track', + preconditions: [ + 'GuildOnly', + 'isCommandDisabled', + 'inVoiceChannel', + 'playerIsPlaying', + 'inPlayerVoiceChannel' + ] +}) +export class BassboostCommand extends Command { + public override registerApplicationCommands( + registry: Command.Registry + ): void { + registry.registerChatInputCommand({ + name: this.name, + description: this.description + }); + } + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + const { client } = container; + + const player = client.music.players.get( + interaction.guild!.id + ) as Player; + + player.filters.equalizer = (player.bassboost = !player.bassboost) + ? [ + { band: 0, gain: 0.55 }, + { band: 1, gain: 0.45 }, + { band: 2, gain: 0.4 }, + { band: 3, gain: 0.3 }, + { band: 4, gain: 0.15 }, + { band: 5, gain: 0 }, + { band: 6, gain: 0 } + ] + : undefined; + + await player.setFilters(); + return await interaction.reply( + `Bassboost ${player.bassboost ? 'enabled' : 'disabled'}` + ); + } +} diff --git a/apps/bot/src/commands/music/create-playlist.ts b/apps/bot/src/commands/music/create-playlist.ts new file mode 100644 index 0000000..6fa5838 --- /dev/null +++ b/apps/bot/src/commands/music/create-playlist.ts @@ -0,0 +1,63 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Command, CommandOptions } from '@sapphire/framework'; +import { trpcNode } from '../../trpc'; + +@ApplyOptions({ + name: 'create-playlist', + description: 'Create a custom playlist that you can play anytime', + preconditions: [ + 'GuildOnly', + 'isCommandDisabled', + 'userInDB', + 'playlistNotDuplicate' + ] +}) +export class CreatePlaylistCommand extends Command { + public override registerApplicationCommands( + registry: Command.Registry + ): void { + registry.registerChatInputCommand(builder => + builder + .setName(this.name) + .setDescription(this.description) + .addStringOption(option => + option + .setName('playlist-name') + .setDescription( + 'What is the name of the playlist you want to create?' + ) + .setRequired(true) + ) + ); + } + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + const playlistName = interaction.options.getString('playlist-name', true); + + const interactionMember = interaction.member?.user; + + if (!interactionMember) { + return await interaction.reply({ + content: ':x: Something went wrong! Please try again later' + }); + } + + try { + const playlist = await trpcNode.playlist.create.mutate({ + name: playlistName, + userId: interactionMember.id + }); + + if (!playlist) throw new Error(); + } catch (error) { + await interaction.reply({ + content: `:x: You already have a playlist named **${playlistName}**` + }); + return; + } + + await interaction.reply(`Created a playlist named **${playlistName}**`); + return; + } +} diff --git a/apps/bot/src/commands/music/delete-playlist.ts b/apps/bot/src/commands/music/delete-playlist.ts new file mode 100644 index 0000000..9e0ffec --- /dev/null +++ b/apps/bot/src/commands/music/delete-playlist.ts @@ -0,0 +1,65 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Command, CommandOptions } from '@sapphire/framework'; +import { trpcNode } from '../../trpc'; +import Logger from '../../lib/logger'; + +@ApplyOptions({ + name: 'delete-playlist', + description: 'Delete a playlist from your saved playlists', + preconditions: [ + 'GuildOnly', + 'isCommandDisabled', + 'userInDB', + 'playlistExists' + ] +}) +export class DeletePlaylistCommand extends Command { + public override registerApplicationCommands( + registry: Command.Registry + ): void { + registry.registerChatInputCommand(builder => + builder + .setName(this.name) + .setDescription(this.description) + .addStringOption(option => + option + .setName('playlist-name') + .setDescription( + 'What is the name of the playlist you want to delete?' + ) + .setRequired(true) + ) + ); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + const playlistName = interaction.options.getString('playlist-name', true); + + const interactionMember = interaction.member?.user; + + if (!interactionMember) { + return await interaction.reply( + ':x: Something went wrong! Please try again later' + ); + } + + try { + const playlist = await trpcNode.playlist.delete.mutate({ + name: playlistName, + userId: interactionMember.id + }); + + if (!playlist) throw new Error(); + } catch (error) { + console.log(error); + Logger.error(error); + return await interaction.reply( + ':x: Something went wrong! Please try again later' + ); + } + + return await interaction.reply(`:wastebasket: Deleted **${playlistName}**`); + } +} diff --git a/apps/bot/src/commands/music/display-playlist.ts b/apps/bot/src/commands/music/display-playlist.ts new file mode 100644 index 0000000..08b445e --- /dev/null +++ b/apps/bot/src/commands/music/display-playlist.ts @@ -0,0 +1,78 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Command, CommandOptions } from '@sapphire/framework'; +import { EmbedBuilder } from 'discord.js'; +import { PaginatedFieldMessageEmbed } from '@sapphire/discord.js-utilities'; +import { trpcNode } from '../../trpc'; + +@ApplyOptions({ + name: 'display-playlist', + description: 'Display a saved playlist', + preconditions: [ + 'GuildOnly', + 'isCommandDisabled', + 'userInDB', + 'playlistExists' + ] +}) +export class DisplayPlaylistCommand extends Command { + public override registerApplicationCommands( + registry: Command.Registry + ): void { + registry.registerChatInputCommand(builder => + builder + .setName(this.name) + .setDescription(this.description) + .addStringOption(option => + option + .setName('playlist-name') + .setDescription( + 'What is the name of the playlist you want to display?' + ) + .setRequired(true) + ) + ); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + const playlistName = interaction.options.getString('playlist-name', true); + + const interactionMember = interaction.member?.user; + + if (!interactionMember) { + return await interaction.reply({ + content: ':x: Something went wrong! Please try again later' + }); + } + + const playlistQuery = await trpcNode.playlist.getPlaylist.query({ + name: playlistName, + userId: interactionMember.id + }); + + const { playlist } = playlistQuery; + + if (!playlist) { + return await interaction.reply( + ':x: Something went wrong! Please try again soon' + ); + } + + const baseEmbed = new EmbedBuilder().setColor('Purple').setAuthor({ + name: interactionMember.username, + iconURL: interactionMember.avatar || undefined + }); + + new PaginatedFieldMessageEmbed() + .setTitleField(`${playlistName} - Songs`) + .setTemplate(baseEmbed) + .setItems(playlist.songs) + .formatItems((item: any) => `[${item.title}](${item.uri})`) + .setItemsPerPage(5) + .make() + .run(interaction); + + return; + } +} diff --git a/apps/bot/src/commands/music/karaoke.ts b/apps/bot/src/commands/music/karaoke.ts new file mode 100644 index 0000000..c3ca1a0 --- /dev/null +++ b/apps/bot/src/commands/music/karaoke.ts @@ -0,0 +1,50 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Command, CommandOptions } from '@sapphire/framework'; +import { container } from '@sapphire/framework'; +import type { Node, Player } from 'lavaclient'; + +@ApplyOptions({ + name: 'karaoke', + description: 'Turn the playing track to karaoke', + preconditions: [ + 'GuildOnly', + 'isCommandDisabled', + 'inVoiceChannel', + 'playerIsPlaying', + 'inPlayerVoiceChannel' + ] +}) +export class KaraokeCommand extends Command { + public override registerApplicationCommands( + registry: Command.Registry + ): void { + registry.registerChatInputCommand({ + name: this.name, + description: this.description + }); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + const { client } = container; + + const player = client.music.players.get( + interaction.guild!.id + ) as Player; + + player.filters.karaoke = (player.karaoke = !player.karaoke) + ? { + level: 1, + monoLevel: 1, + filterBand: 220, + filterWidth: 100 + } + : undefined; + + await player.setFilters(); + return await interaction.reply( + `Karaoke ${player.karaoke ? 'enabled' : 'disabled'}` + ); + } +} diff --git a/apps/bot/src/commands/music/leave.ts b/apps/bot/src/commands/music/leave.ts new file mode 100644 index 0000000..036f94b --- /dev/null +++ b/apps/bot/src/commands/music/leave.ts @@ -0,0 +1,37 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Command, CommandOptions } from '@sapphire/framework'; +import { container } from '@sapphire/framework'; + +@ApplyOptions({ + name: 'leave', + description: 'Make the bot leave its voice channel and stop playing music', + preconditions: [ + 'GuildOnly', + 'isCommandDisabled', + 'inVoiceChannel', + 'playerIsPlaying', + 'inPlayerVoiceChannel' + ] +}) +export class LeaveCommand extends Command { + public override registerApplicationCommands( + registry: Command.Registry + ): void { + registry.registerChatInputCommand({ + name: this.name, + description: this.description + }); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + const { client } = container; + + const queue = client.music.queues.get(interaction.guildId!); + + await queue.leave(); + + await interaction.reply({ content: 'Left the voice channel.' }); + } +} diff --git a/apps/bot/src/commands/music/lyrics.ts b/apps/bot/src/commands/music/lyrics.ts new file mode 100644 index 0000000..7b1c6c8 --- /dev/null +++ b/apps/bot/src/commands/music/lyrics.ts @@ -0,0 +1,83 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Command, CommandOptions } from '@sapphire/framework'; +import { EmbedBuilder } from 'discord.js'; +import { container } from '@sapphire/framework'; +import { GeniusLyrics } from 'genius-discord-lyrics'; +import { PaginatedMessage } from '@sapphire/discord.js-utilities'; +import Logger from '../../lib/logger'; + +const genius = new GeniusLyrics(process.env.GENIUS_API || ''); + +@ApplyOptions({ + name: 'lyrics', + description: + 'Get the lyrics of any song or the lyrics of the currently playing song!', + preconditions: ['GuildOnly', 'isCommandDisabled'] +}) +export class LyricsCommand extends Command { + public override registerApplicationCommands( + registry: Command.Registry + ): void { + registry.registerChatInputCommand(builder => + builder + .setName(this.name) + .setDescription(this.description) + .addStringOption(option => + option + .setName('title') + .setDescription(':mag: What song lyrics would you like to get?') + .setRequired(true) + ) + ); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + const { client } = container; + let title = interaction.options.getString('title'); + + const player = client.music.players.get(interaction.guild!.id); + + await interaction.deferReply(); + + if (!title) { + if (!player) { + return await interaction.followUp( + 'Please provide a valid song name or start playing one and try again!' + ); + } + //title = player.queue.current?.title as string; + title = 'hi'; + } + + try { + const lyrics = (await genius.fetchLyrics(title)) as string; + const lyricsIndex = Math.round(lyrics.length / 4096) + 1; + const paginatedLyrics = new PaginatedMessage({ + template: new EmbedBuilder().setColor('Red').setTitle(title).setFooter({ + text: 'Provided by genius.com', + iconURL: + 'https://assets.genius.com/images/apple-touch-icon.png?1652977688' // Genius Lyrics Icon + }) + }); + + for (let i = 1; i <= lyricsIndex; ++i) { + let b = i - 1; + if (lyrics.trim().slice(b * 4096, i * 4096).length !== 0) { + paginatedLyrics.addPageEmbed(embed => { + return embed.setDescription(lyrics.slice(b * 4096, i * 4096)); + }); + } + } + + await interaction.followUp('Lyrics generated'); + return paginatedLyrics.run(interaction); + } catch (e) { + Logger.error(e); + return interaction.followUp( + 'Something when wrong when trying to fetch lyrics :(' + ); + } + } +} diff --git a/apps/bot/src/commands/music/move.ts b/apps/bot/src/commands/music/move.ts new file mode 100644 index 0000000..233729e --- /dev/null +++ b/apps/bot/src/commands/music/move.ts @@ -0,0 +1,70 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Command, CommandOptions } from '@sapphire/framework'; +import { container } from '@sapphire/framework'; + +@ApplyOptions({ + name: 'move', + description: 'Move a track to a different position in queue', + preconditions: [ + 'GuildOnly', + 'isCommandDisabled', + 'inVoiceChannel', + 'playerIsPlaying', + 'inPlayerVoiceChannel' + ] +}) +export class MoveCommand extends Command { + public override registerApplicationCommands( + registry: Command.Registry + ): void { + registry.registerChatInputCommand(builder => + builder + .setName(this.name) + .setDescription(this.description) + .addIntegerOption(option => + option + .setName('current-position') + .setDescription( + 'What is the position of the song you want to move?' + ) + .setRequired(true) + ) + .addIntegerOption(option => + option + .setName('new-position') + .setDescription( + 'What is the position you want to move the song to?' + ) + .setRequired(true) + ) + ); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + const { client } = container; + const currentPosition = interaction.options.getInteger( + 'current-position', + true + ); + const newPosition = interaction.options.getInteger('new-position', true); + + const queue = client.music.queues.get(interaction.guildId!); + const length = await queue.count(); + if ( + currentPosition < 1 || + currentPosition > length || + newPosition < 1 || + newPosition > length || + currentPosition == newPosition + ) { + return await interaction.reply( + ':x: Please enter valid position numbers!' + ); + } + + await queue.moveTracks(currentPosition - 1, newPosition - 1); + return; + } +} diff --git a/apps/bot/src/commands/music/my-playlists.ts b/apps/bot/src/commands/music/my-playlists.ts new file mode 100644 index 0000000..5e1eaee --- /dev/null +++ b/apps/bot/src/commands/music/my-playlists.ts @@ -0,0 +1,62 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Command, CommandOptions } from '@sapphire/framework'; +import { PaginatedFieldMessageEmbed } from '@sapphire/discord.js-utilities'; +import { EmbedBuilder } from 'discord.js'; +import { trpcNode } from '../../trpc'; + +@ApplyOptions({ + name: 'my-playlists', + description: "Display your custom playlists' names", + preconditions: [ + 'GuildOnly', + 'isCommandDisabled', + 'inVoiceChannel', + 'userInDB' + ] +}) +export class MyPlaylistsCommand extends Command { + public override registerApplicationCommands( + registry: Command.Registry + ): void { + registry.registerChatInputCommand({ + name: this.name, + description: this.description + }); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + const interactionMember = interaction.member?.user; + + if (!interactionMember) { + return await interaction.reply({ + content: ':x: Something went wrong! Please try again later' + }); + } + + const baseEmbed = new EmbedBuilder().setColor('Purple').setAuthor({ + name: `${interactionMember.username}`, + iconURL: interactionMember.avatar || undefined + }); + + const playlistsQuery = await trpcNode.playlist.getAll.query({ + userId: interactionMember.id + }); + + if (!playlistsQuery || !playlistsQuery.playlists.length) { + return await interaction.reply(':x: You have no custom playlists'); + } + + new PaginatedFieldMessageEmbed() + .setTitleField('Custom Playlists') + .setTemplate(baseEmbed) + .setItems(playlistsQuery.playlists) + .formatItems((playlist: any) => playlist.name) + .setItemsPerPage(5) + .make() + .run(interaction); + + return; + } +} diff --git a/apps/bot/src/commands/music/nightcore.ts b/apps/bot/src/commands/music/nightcore.ts new file mode 100644 index 0000000..1c295f4 --- /dev/null +++ b/apps/bot/src/commands/music/nightcore.ts @@ -0,0 +1,45 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Command, CommandOptions } from '@sapphire/framework'; +import { container } from '@sapphire/framework'; +import type { Node, Player } from 'lavaclient'; + +@ApplyOptions({ + name: 'nightcore', + description: 'Enable/Disable Nightcore filter', + preconditions: [ + 'GuildOnly', + 'isCommandDisabled', + 'inVoiceChannel', + 'playerIsPlaying', + 'inPlayerVoiceChannel' + ] +}) +export class NightcoreCommand extends Command { + public override registerApplicationCommands( + registry: Command.Registry + ): void { + registry.registerChatInputCommand({ + name: this.name, + description: this.description + }); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + const { client } = container; + + const player = client.music.players.get( + interaction.guild!.id + ) as Player; + + player.filters.timescale = (player.nightcore = !player.nightcore) + ? { speed: 1.125, pitch: 1.125, rate: 1 } + : undefined; + + await player.setFilters(); + return await interaction.reply( + `Nightcore ${player.nightcore ? 'enabled' : 'disabled'}` + ); + } +} diff --git a/apps/bot/src/commands/music/pause.ts b/apps/bot/src/commands/music/pause.ts new file mode 100644 index 0000000..424043e --- /dev/null +++ b/apps/bot/src/commands/music/pause.ts @@ -0,0 +1,35 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Command, CommandOptions } from '@sapphire/framework'; +import { container } from '@sapphire/framework'; + +@ApplyOptions({ + name: 'pause', + description: 'Pause the music', + preconditions: [ + 'GuildOnly', + 'isCommandDisabled', + 'inVoiceChannel', + 'playerIsPlaying', + 'inPlayerVoiceChannel' + ] +}) +export class PauseCommand extends Command { + public override registerApplicationCommands( + registry: Command.Registry + ): void { + registry.registerChatInputCommand({ + name: this.name, + description: this.description + }); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + const { client } = container; + + const queue = client.music.queues.get(interaction.guildId!); + + await queue.pause(interaction); + } +} diff --git a/apps/bot/src/commands/music/play.ts b/apps/bot/src/commands/music/play.ts new file mode 100644 index 0000000..d602562 --- /dev/null +++ b/apps/bot/src/commands/music/play.ts @@ -0,0 +1,157 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Command, CommandOptions } from '@sapphire/framework'; +import { container } from '@sapphire/framework'; +import searchSong from '../../lib/music/searchSong'; +import type { Song } from '../../lib/music/classes/Song'; +import { trpcNode } from '../../trpc'; +import { GuildMember } from 'discord.js'; + +@ApplyOptions({ + name: 'play', + description: 'Play any song or playlist from YouTube, Spotify and more!', + preconditions: [ + 'GuildOnly', + 'isCommandDisabled', + 'inVoiceChannel', + 'inPlayerVoiceChannel' + ] +}) +export class PlayCommand extends Command { + public override registerApplicationCommands( + registry: Command.Registry + ): void { + registry.registerChatInputCommand(builder => + builder + .setName(this.name) + .setDescription(this.description) + .addStringOption(option => + option + .setName('query') + .setDescription( + 'What song or playlist would you like to listen to?' + ) + .setRequired(true) + ) + .addStringOption(option => + option + .setName('is-custom-playlist') + .setDescription('Is it a custom playlist?') + .addChoices( + { + name: 'Yes', + value: 'Yes' + }, + { + name: 'No', + value: 'No' + } + ) + ) + .addStringOption(option => + option + .setName('shuffle-playlist') + .setDescription('Would you like to shuffle the playlist?') + .addChoices( + { + name: 'Yes', + value: 'Yes' + }, + { + name: 'No', + value: 'No' + } + ) + ) + ); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + await interaction.deferReply(); + + const { client } = container; + + const query = interaction.options.getString('query', true); + const isCustomPlaylist = + interaction.options.getString('is-custom-playlist'); + + const shufflePlaylist = interaction.options.getString('shuffle-playlist'); + + const interactionMember = interaction.member?.user; + + if (!interactionMember) { + return await interaction.followUp( + ':x: Something went wrong! Please try again later' + ); + } + + const { music } = client; + + const voiceChannel = (interaction.member as GuildMember).voice.channel; + + // edge case - someome initiated the command but left the voice channel + if (!voiceChannel) { + return interaction.followUp({ + content: ':x: You need to be in a voice channel to use this command!' + }); + } + + let queue = music.queues.get(interaction.guildId!); + await queue.setTextChannelID(interaction.channel!.id); + + if (!queue.player) { + const player = queue.createPlayer(); + await player.connect(voiceChannel.id, { deafened: true }); + } + + let tracks: Song[] = []; + let message: string = ''; + + if (isCustomPlaylist == 'Yes') { + const data = await trpcNode.playlist.getPlaylist.query({ + userId: interactionMember.id, + name: query + }); + + const { playlist } = data; + + if (!playlist) { + return await interaction.followUp(`:x: You have no such playlist!`); + } + if (!playlist.songs.length) { + return await interaction.followUp(`:x: **${query}** is empty!`); + } + + const { songs } = playlist; + tracks.push(...songs); + message = `Added songs from **${playlist}** to the queue!`; + } else { + const trackTuple = await searchSong(query, interaction.user); + if (!trackTuple[1].length) { + return await interaction.followUp({ content: trackTuple[0] as string }); // error + } + message = trackTuple[0]; + tracks.push(...trackTuple[1]); + } + + await queue.add(tracks); + if (shufflePlaylist == 'Yes') { + await queue.shuffleTracks(); + } + + const current = await queue.getCurrentTrack(); + if (current) { + client.emit( + 'musicSongPlayMessage', + interaction.channel, + await queue.getCurrentTrack() + ); + return; + } + + queue.start(); + + return await interaction.followUp({ content: message }); + } +} diff --git a/apps/bot/src/commands/music/queue.ts b/apps/bot/src/commands/music/queue.ts new file mode 100644 index 0000000..9554da6 --- /dev/null +++ b/apps/bot/src/commands/music/queue.ts @@ -0,0 +1,50 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Command, CommandOptions } from '@sapphire/framework'; +import { EmbedBuilder } from 'discord.js'; +import { container } from '@sapphire/framework'; +import { PaginatedFieldMessageEmbed } from '@sapphire/discord.js-utilities'; + +@ApplyOptions({ + name: 'queue', + description: 'Get a List of the Music Queue', + preconditions: [ + 'GuildOnly', + 'isCommandDisabled', + 'inVoiceChannel', + 'playerIsPlaying', + 'inPlayerVoiceChannel' + ] +}) +export class QueueCommand extends Command { + public override registerApplicationCommands( + registry: Command.Registry + ): void { + registry.registerChatInputCommand({ + name: this.name, + description: this.description + }); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + const { client } = container; + const queue = client.music.queues.get(interaction.guildId!); + const baseEmbed = new EmbedBuilder().setColor('Red').setAuthor({ + name: `${interaction.user.username}`, + iconURL: interaction.user.displayAvatarURL() + }); + let index = 1; + new PaginatedFieldMessageEmbed() + .setTitleField('Queue') + .setTemplate(baseEmbed) + .setItems(await queue.tracks()) + .formatItems( + (queueList: any) => + `${index++}) ***[${queueList.title}](${queueList.uri})***` + ) + .setItemsPerPage(10) + .make() + .run(interaction); + } +} diff --git a/apps/bot/src/commands/music/remove-from-playlist.ts b/apps/bot/src/commands/music/remove-from-playlist.ts new file mode 100644 index 0000000..7e71c83 --- /dev/null +++ b/apps/bot/src/commands/music/remove-from-playlist.ts @@ -0,0 +1,94 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Command, CommandOptions } from '@sapphire/framework'; +import { trpcNode } from '../../trpc'; + +@ApplyOptions({ + name: 'remove-from-playlist', + description: 'Remove a song from a saved playlist', + preconditions: [ + 'GuildOnly', + 'isCommandDisabled', + 'userInDB', + 'playlistExists' + ] +}) +export class RemoveFromPlaylistCommand extends Command { + public override registerApplicationCommands( + registry: Command.Registry + ): void { + registry.registerChatInputCommand(builder => + builder + .setName(this.name) + .setDescription(this.description) + .addStringOption(option => + option + .setName('playlist-name') + .setDescription( + 'What is the name of the playlist you want to remove from?' + ) + .setRequired(true) + ) + .addIntegerOption(option => + option + .setName('location') + .setDescription( + 'What is the index of the video you would like to delete from your saved playlist?' + ) + .setRequired(true) + ) + ); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + await interaction.deferReply(); + const playlistName = interaction.options.getString('playlist-name', true); + const location = interaction.options.getInteger('location', true); + + const interactionMember = interaction.member?.user; + + if (!interactionMember) { + return await interaction.followUp( + ':x: Something went wrong! Please try again later' + ); + } + + let playlist; + try { + const playlistQuery = await trpcNode.playlist.getPlaylist.query({ + name: playlistName, + userId: interactionMember.id + }); + + playlist = playlistQuery.playlist; + } catch (error) { + return await interaction.followUp(':x: Something went wrong!'); + } + + const songs = playlist?.songs; + + if (!songs?.length) { + return await interaction.followUp(`:x: **${playlistName}** is empty!`); + } + + if (location > songs.length || location < 0) { + return await interaction.followUp(':x: Please enter a valid index!'); + } + + const id = songs[location - 1].id; + + const song = await trpcNode.song.delete.mutate({ + id + }); + + if (!song) { + return await interaction.followUp(':x: Something went wrong!'); + } + + await interaction.followUp( + `:wastebasket: Deleted **${song.song.title}** from **${playlistName}**` + ); + return; + } +} diff --git a/apps/bot/src/commands/music/remove.ts b/apps/bot/src/commands/music/remove.ts new file mode 100644 index 0000000..e62cdee --- /dev/null +++ b/apps/bot/src/commands/music/remove.ts @@ -0,0 +1,52 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Command, CommandOptions } from '@sapphire/framework'; +import { container } from '@sapphire/framework'; + +@ApplyOptions({ + name: 'remove', + description: 'Remove a track from the queue', + preconditions: [ + 'GuildOnly', + 'isCommandDisabled', + 'inVoiceChannel', + 'playerIsPlaying', + 'inPlayerVoiceChannel' + ] +}) +export class RemoveCommand extends Command { + public override registerApplicationCommands( + registry: Command.Registry + ): void { + registry.registerChatInputCommand(builder => + builder + .setName(this.name) + .setDescription(this.description) + .addIntegerOption(option => + option + .setName('position') + .setDescription( + 'What is the position of the song you want to remove from the queue?' + ) + .setRequired(true) + ) + ); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + const { client } = container; + const position = interaction.options.getInteger('position', true); + + const queue = client.music.queues.get(interaction.guildId!); + const length = await queue.count(); + if (position < 1 || position > length) { + return interaction.reply(':x: Please enter a valid position number!'); + } + + await queue.removeAt(position - 1); + return await interaction.reply({ + content: `Removed track at position ${position}` + }); + } +} diff --git a/apps/bot/src/commands/music/resume.ts b/apps/bot/src/commands/music/resume.ts new file mode 100644 index 0000000..9e2195c --- /dev/null +++ b/apps/bot/src/commands/music/resume.ts @@ -0,0 +1,35 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Command, CommandOptions } from '@sapphire/framework'; +import { container } from '@sapphire/framework'; + +@ApplyOptions({ + name: 'resume', + description: 'Resume the music', + preconditions: [ + 'GuildOnly', + 'isCommandDisabled', + 'inVoiceChannel', + 'playerIsPlaying', + 'inPlayerVoiceChannel' + ] +}) +export class ResumeCommand extends Command { + public override registerApplicationCommands( + registry: Command.Registry + ): void { + registry.registerChatInputCommand({ + name: this.name, + description: this.description + }); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + const { client } = container; + + const queue = client.music.queues.get(interaction.guildId!); + + await queue.resume(interaction); + } +} diff --git a/apps/bot/src/commands/music/save-to-playlist.ts b/apps/bot/src/commands/music/save-to-playlist.ts new file mode 100644 index 0000000..f0c5ac5 --- /dev/null +++ b/apps/bot/src/commands/music/save-to-playlist.ts @@ -0,0 +1,96 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Command, CommandOptions } from '@sapphire/framework'; +import searchSong from '../../lib/music/searchSong'; +import { trpcNode } from '../../trpc'; +import Logger from '../../lib/logger'; + +@ApplyOptions({ + name: 'save-to-playlist', + description: 'Save a song or a playlist to a custom playlist', + preconditions: [ + 'GuildOnly', + 'isCommandDisabled', + 'userInDB', + 'playlistExists' + ] +}) +export class SaveToPlaylistCommand extends Command { + public override registerApplicationCommands( + registry: Command.Registry + ): void { + registry.registerChatInputCommand(builder => + builder + .setName(this.name) + .setDescription(this.description) + .addStringOption(option => + option + .setName('playlist-name') + .setDescription( + 'What is the name of the playlist you want to save to?' + ) + .setRequired(true) + ) + .addStringOption(option => + option + .setName('url') + .setDescription('What do you want to save to the custom playlist?') + .setRequired(true) + ) + ); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + await interaction.deferReply(); + const playlistName = interaction.options.getString('playlist-name', true); + const url = interaction.options.getString('url', true); + + const interactionMember = interaction.member?.user; + + if (!interactionMember) { + return await interaction.followUp( + ':x: Something went wrong! Please try again later' + ); + } + + const playlistQuery = await trpcNode.playlist.getPlaylist.query({ + name: playlistName, + userId: interactionMember.id + }); + + if (!playlistQuery.playlist) { + return await interaction.followUp('Playlist does not exist'); + } + + const playlistId = playlistQuery.playlist.id; + + const songTuple = await searchSong(url, interaction.user); + if (!songTuple[1].length) { + return await interaction.followUp(songTuple[0]); + } + + const songArray = songTuple[1]; + const songsToAdd: any[] = []; + + for (let i = 0; i < songArray.length; i++) { + const song = songArray[i]; + delete song['requester']; + songsToAdd.push({ + ...song, + playlistId: +playlistId + }); + } + + try { + await trpcNode.song.createMany.mutate({ + songs: songsToAdd + }); + + return await interaction.followUp(`Added tracks to **${playlistName}**`); + } catch (error) { + Logger.error(error); + return await interaction.followUp(':x: Something went wrong!'); + } + } +} diff --git a/apps/bot/src/commands/music/seek.ts b/apps/bot/src/commands/music/seek.ts new file mode 100644 index 0000000..a8878ac --- /dev/null +++ b/apps/bot/src/commands/music/seek.ts @@ -0,0 +1,58 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Command, CommandOptions } from '@sapphire/framework'; +import { container } from '@sapphire/framework'; + +@ApplyOptions({ + name: 'seek', + description: 'Seek to a desired point in a track', + preconditions: [ + 'GuildOnly', + 'isCommandDisabled', + 'inVoiceChannel', + 'playerIsPlaying', + 'inPlayerVoiceChannel' + ] +}) +export class SeekCommand extends Command { + public override registerApplicationCommands( + registry: Command.Registry + ): void { + registry.registerChatInputCommand(builder => + builder + .setName(this.name) + .setDescription(this.description) + .addIntegerOption(option => + option + .setName('seconds') + .setDescription( + 'To what point in the track do you want to seek? (in seconds)' + ) + .setRequired(true) + ) + ); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + const { client } = container; + const seconds = interaction.options.getInteger('seconds', true); + const milliseconds = seconds * 1000; + + const queue = client.music.queues.get(interaction.guildId!); + const track = await queue.getCurrentTrack(); + if (!track) + return await interaction.reply(':x: There is no track playing!'); // should never happen + if (!track.isSeekable) + return await interaction.reply(':x: This track is not seekable!'); + + if (milliseconds > track.length || milliseconds < 0) { + return await interaction.reply(':x: Please enter a valid number!'); + } + + const player = queue.player; + await player.seek(milliseconds); + + return await interaction.reply(`Seeked to ${seconds} seconds`); + } +} diff --git a/apps/bot/src/commands/music/shuffle.ts b/apps/bot/src/commands/music/shuffle.ts new file mode 100644 index 0000000..319402c --- /dev/null +++ b/apps/bot/src/commands/music/shuffle.ts @@ -0,0 +1,41 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Command, CommandOptions } from '@sapphire/framework'; +import { container } from '@sapphire/framework'; + +@ApplyOptions({ + name: 'shuffle', + description: 'Shuffle the music queue', + preconditions: [ + 'GuildOnly', + 'isCommandDisabled', + 'inVoiceChannel', + 'playerIsPlaying', + 'inPlayerVoiceChannel' + ] +}) +export class LeaveCommand extends Command { + public override registerApplicationCommands( + registry: Command.Registry + ): void { + registry.registerChatInputCommand({ + name: this.name, + description: this.description + }); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + const { client } = container; + + const queue = client.music.queues.get(interaction.guildId!); + + if (!(await queue.count())) { + return await interaction.reply(':x: There are no songs in queue!'); + } + + await queue.shuffleTracks(); + + return await interaction.reply(':white_check_mark: Shuffled queue!'); + } +} diff --git a/apps/bot/src/commands/music/skip.ts b/apps/bot/src/commands/music/skip.ts new file mode 100644 index 0000000..af54626 --- /dev/null +++ b/apps/bot/src/commands/music/skip.ts @@ -0,0 +1,40 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Command, CommandOptions } from '@sapphire/framework'; +import { container } from '@sapphire/framework'; + +@ApplyOptions({ + name: 'skip', + description: 'Skip the current song playing', + preconditions: [ + 'GuildOnly', + 'isCommandDisabled', + 'inVoiceChannel', + 'playerIsPlaying', + 'inPlayerVoiceChannel' + ] +}) +export class SkipCommand extends Command { + public override registerApplicationCommands( + registry: Command.Registry + ): void { + registry.registerChatInputCommand({ + name: this.name, + description: this.description + }); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + const { client } = container; + const { music } = client; + const queue = music.queues.get(interaction.guildId!); + + const track = await queue.getCurrentTrack(); + await queue.next({ skipped: true }); + + client.emit('musicSongSkipNotify', interaction, track); + + return; + } +} diff --git a/apps/bot/src/commands/music/skipto.ts b/apps/bot/src/commands/music/skipto.ts new file mode 100644 index 0000000..7496b44 --- /dev/null +++ b/apps/bot/src/commands/music/skipto.ts @@ -0,0 +1,57 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Command, CommandOptions } from '@sapphire/framework'; +import { container } from '@sapphire/framework'; + +@ApplyOptions({ + name: 'skipto', + description: 'Skip to a track in queue', + preconditions: [ + 'GuildOnly', + 'isCommandDisabled', + 'inVoiceChannel', + 'playerIsPlaying', + 'inPlayerVoiceChannel' + ] +}) +export class SkipToCommand extends Command { + public override registerApplicationCommands( + registry: Command.Registry + ): void { + registry.registerChatInputCommand(builder => + builder + .setName(this.name) + .setDescription(this.description) + .addIntegerOption(option => + option + .setName('position') + .setDescription( + 'What is the position of the song you want to skip to in queue?' + ) + .setRequired(true) + ) + ); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + const { client } = container; + const position = interaction.options.getInteger('position', true); + + const queue = client.music.queues.get(interaction.guildId!); + const length = await queue.count(); + if (position > length || position < 1) { + return await interaction.reply( + ':x: Please enter a valid track position.' + ); + } + + await queue.skipTo(position); + + await interaction.reply( + `:white_check_mark: Skipped to track number ${position}!` + ); + + return; + } +} diff --git a/apps/bot/src/commands/music/vaporwave.ts b/apps/bot/src/commands/music/vaporwave.ts new file mode 100644 index 0000000..e48f264 --- /dev/null +++ b/apps/bot/src/commands/music/vaporwave.ts @@ -0,0 +1,58 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Command, CommandOptions } from '@sapphire/framework'; +import { container } from '@sapphire/framework'; +import type { Node, Player } from 'lavaclient'; + +@ApplyOptions({ + name: 'vaporwave', + description: 'Apply vaporwave on the playing track!', + preconditions: [ + 'GuildOnly', + 'isCommandDisabled', + 'inVoiceChannel', + 'playerIsPlaying', + 'inPlayerVoiceChannel' + ] +}) +export class VaporWaveCommand extends Command { + public override registerApplicationCommands( + registry: Command.Registry + ): void { + registry.registerChatInputCommand({ + name: this.name, + description: this.description + }); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + const { client } = container; + + const player = client.music.players.get( + interaction.guild!.id + ) as Player; + + player.filters = (player.vaporwave = !player.vaporwave) + ? { + ...player.filters, + equalizer: [ + { band: 1, gain: 0.7 }, + { band: 0, gain: 0.6 } + ], + timescale: { pitch: 0.7, speed: 1, rate: 1 }, + tremolo: { depth: 0.6, frequency: 14 } + } + : { + ...player.filters, + equalizer: undefined, + timescale: undefined, + tremolo: undefined + }; + + await player.setFilters(); + return await interaction.reply( + `Vaporwave ${player.vaporwave ? 'enabled' : 'disabled'}` + ); + } +} diff --git a/apps/bot/src/commands/music/volume.ts b/apps/bot/src/commands/music/volume.ts new file mode 100644 index 0000000..23d4e3b --- /dev/null +++ b/apps/bot/src/commands/music/volume.ts @@ -0,0 +1,51 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Command, CommandOptions } from '@sapphire/framework'; +import { container } from '@sapphire/framework'; + +@ApplyOptions({ + name: 'volume', + description: 'Set the Volume', + preconditions: [ + 'GuildOnly', + 'isCommandDisabled', + 'inVoiceChannel', + 'playerIsPlaying', + 'inPlayerVoiceChannel' + ] +}) +export class VolumeCommand extends Command { + public override registerApplicationCommands( + registry: Command.Registry + ): void { + registry.registerChatInputCommand(builder => + builder + .setName(this.name) + .setDescription(this.description) + .addNumberOption(option => + option + .setName('setting') + .setDescription('What Volume? (0 to 200)') + .setRequired(true) + ) + ); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + const { client } = container; + const query = interaction.options.getNumber('setting', true); + + const queue = client.music.queues.get(interaction.guildId!); + + if (query > 200 || query < 0) { + return await interaction.reply(':x: Volume must be between 0 and 200!'); + } + + await queue.setVolume(query); + + return await interaction.reply( + `:white_check_mark: Volume set to ${query}!` + ); + } +} diff --git a/apps/bot/src/commands/other/8ball.ts b/apps/bot/src/commands/other/8ball.ts new file mode 100644 index 0000000..56a3070 --- /dev/null +++ b/apps/bot/src/commands/other/8ball.ts @@ -0,0 +1,72 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Command } from '@sapphire/framework'; +import { EmbedBuilder } from 'discord.js'; + +@ApplyOptions({ + name: '8ball', + description: 'Get the answer to anything!', + preconditions: ['isCommandDisabled'] +}) +export class EightBallCommand extends Command { + public override registerApplicationCommands(registry: Command.Registry) { + registry.registerChatInputCommand(builder => + builder // + .setName(this.name) + .setDescription(this.description) + .addStringOption(option => + option + .setName('question') + .setDescription('The question you want to ask the 8ball') + .setRequired(true) + ) + ); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + const question = interaction.options.getString('question', true); + if (question.length > 255) { + return await interaction.reply({ + content: + 'Your question is too long! Please keep it under 255 characters.' + }); + } + + const randomAnswer = answers[Math.floor(Math.random() * answers.length)]; + + const embed = new EmbedBuilder() + .setTitle(question) + .setAuthor({ + name: 'Magic 8ball', + iconURL: 'https://i.imgur.com/HbwMhWM.png' + }) + .setDescription(randomAnswer) + .setColor('DarkButNotBlack') + .setTimestamp(); + + return interaction.reply({ embeds: [embed] }); + } +} + +const answers = [ + 'Yes.', + 'No.', + 'My sources say yes!', + 'Most likely.', + "I don't know.", + 'Maybe, sometimes.', + 'Outlook is good.', + 'Signs point to yes.', + 'Definitely!', + 'Absolutely!', + 'Nope.', + "No thanks, I won't be able to make it.", + 'No Way!', + "It's certain.", + "It's decidedly so.", + 'Without a doubt.', + 'Yes - definitely.', + 'You can rely on it.', + 'As I see it, yes.' +]; diff --git a/apps/bot/src/commands/other/about.ts b/apps/bot/src/commands/other/about.ts new file mode 100644 index 0000000..a4202ce --- /dev/null +++ b/apps/bot/src/commands/other/about.ts @@ -0,0 +1,31 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Command } from '@sapphire/framework'; +import { EmbedBuilder } from 'discord.js'; + +@ApplyOptions({ + name: 'about', + description: 'Display info about the bot!', + preconditions: ['isCommandDisabled'] +}) +export class AboutCommand extends Command { + public override registerApplicationCommands(registry: Command.Registry) { + registry.registerChatInputCommand(builder => + builder // + .setName(this.name) + .setDescription(this.description) + ); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + const embed = new EmbedBuilder() + .setTitle('About') + .setDescription( + 'A Discord bot with slash commands, playlist support, Spotify, music quiz, saved playlists, lyrics, gifs and more.\n\n :white_small_square: [Commands](https://github.com/galnir/Master-Bot#commands)\n :white_small_square: [Contributors](https://github.com/galnir/Master-Bot#contributors-%EF%B8%8F)' + ) + .setColor('Aqua'); + + return interaction.reply({ embeds: [embed] }); + } +} diff --git a/apps/bot/src/commands/other/activity.ts b/apps/bot/src/commands/other/activity.ts new file mode 100644 index 0000000..9f7a7f3 --- /dev/null +++ b/apps/bot/src/commands/other/activity.ts @@ -0,0 +1,74 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Command } from '@sapphire/framework'; +import { GuildMember, VoiceChannel } from 'discord.js'; + +@ApplyOptions({ + name: 'activity', + description: "Generate an invite link to your voice channel's activity", + preconditions: ['isCommandDisabled', 'GuildOnly', 'inVoiceChannel'] +}) +export class ActivityCommand extends Command { + public override registerApplicationCommands(registry: Command.Registry) { + registry.registerChatInputCommand(builder => + builder // + .setName(this.name) + .setDescription(this.description) + .addChannelOption(option => + option + .setName('channel') + .setDescription('Channel to invite to') + .setRequired(true) + ) + .addStringOption(option => + option + .setName('activity') + .setDescription('Activity to invite to') + .setRequired(true) + ) + ); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + const channel = interaction.options.getChannel('channel', true); + const activity = interaction.options.getString('activity', true); + + if ( + channel.type.toString() !== 'GUILD_VOICE' || + channel.type.toString() === 'GUILD_CATEGORY' + ) { + return interaction.reply({ + content: 'You can only invite to voice channels!' + }); + } + + const member = interaction.member as GuildMember; + + if (!member) { + return interaction.reply({ + content: 'You must be in a voice channel to use this command!' + }); + } + + if (member.voice.channelId !== channel.id) { + return interaction.reply({ + content: 'You must be in the same voice channel to use this command!' + }); + } + + try { + const invite = await (channel as VoiceChannel).createInvite({ + reason: 'Activity invite' + }); + + return interaction.reply({ + content: `[Click to join ${activity} in ${channel.name}](${invite.url})` + }); + } catch { + return interaction.reply({ + content: 'Something went wrong!' + }); + } + } +} diff --git a/apps/bot/src/commands/other/advice.ts b/apps/bot/src/commands/other/advice.ts new file mode 100644 index 0000000..3e2ec6f --- /dev/null +++ b/apps/bot/src/commands/other/advice.ts @@ -0,0 +1,48 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Command } from '@sapphire/framework'; +import { EmbedBuilder } from 'discord.js'; + +@ApplyOptions({ + name: 'advice', + description: 'Get some advice!', + preconditions: ['isCommandDisabled'] +}) +export class AdviceCommand extends Command { + public override registerApplicationCommands(registry: Command.Registry) { + registry.registerChatInputCommand(builder => + builder.setName(this.name).setDescription(this.description) + ); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + try { + const response = await fetch('https://api.adviceslip.com/advice'); + const data = await response.json(); + + const advice = data.slip?.advice; + + if (!advice) { + return interaction.reply({ content: 'Something went wrong!' }); + } + + const embed = new EmbedBuilder() + .setColor('NotQuiteBlack') + .setAuthor({ + name: 'Advice Slip', + url: 'https://adviceslip.com/', + iconURL: 'https://i.imgur.com/8pIvnmD.png' + }) + .setDescription(advice) + .setTimestamp() + .setFooter({ + text: `Powered by adviceslip.com` + }); + + return interaction.reply({ embeds: [embed] }); + } catch { + return interaction.reply({ content: 'Something went wrong!' }); + } + } +} diff --git a/apps/bot/src/commands/other/avatar.ts b/apps/bot/src/commands/other/avatar.ts new file mode 100644 index 0000000..92b4485 --- /dev/null +++ b/apps/bot/src/commands/other/avatar.ts @@ -0,0 +1,36 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Command } from '@sapphire/framework'; +import { EmbedBuilder } from 'discord.js'; + +@ApplyOptions({ + name: 'avatar', + description: "Responds with a user's avatar", + preconditions: ['isCommandDisabled', 'GuildOnly'] +}) +export class AvatarCommand extends Command { + public override registerApplicationCommands(registry: Command.Registry) { + registry.registerChatInputCommand(builder => + builder + .setName(this.name) + .setDescription(this.description) + .addUserOption(option => + option + .setName('user') + .setDescription('The user to get the avatar of') + .setRequired(true) + ) + ); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + const user = interaction.options.getUser('user', true); + const embed = new EmbedBuilder() + .setTitle(user.username) + .setImage(user.displayAvatarURL({ size: 4096 })) + .setColor('Aqua'); + + return interaction.reply({ embeds: [embed] }); + } +} diff --git a/apps/bot/src/commands/other/chucknorris.ts b/apps/bot/src/commands/other/chucknorris.ts new file mode 100644 index 0000000..763ffe8 --- /dev/null +++ b/apps/bot/src/commands/other/chucknorris.ts @@ -0,0 +1,51 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Command } from '@sapphire/framework'; +import { EmbedBuilder } from 'discord.js'; + +@ApplyOptions({ + name: 'chucknorris', + description: 'Get a satirical fact about Chuck Norris!', + preconditions: ['isCommandDisabled'] +}) +export class ChuckNorrisCommand extends Command { + public override registerApplicationCommands(registry: Command.Registry) { + registry.registerChatInputCommand(builder => + builder.setName(this.name).setDescription(this.description) + ); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + try { + const response = await fetch('https://api.chucknorris.io/jokes/random'); + const data = await response.json(); + + const joke = data; + + if (!joke) { + return interaction.reply({ + content: ':x: An error occured, Chuck is investigating this!' + }); + } + + const embed = new EmbedBuilder() + .setColor('Orange') + .setAuthor({ + name: 'Chuck Norris', + url: 'https://chucknorris.io', + iconURL: joke.icon_url + }) + .setDescription(joke.value) + .setTimestamp() + .setFooter({ + text: 'Powered by chucknorris.io' + }); + return interaction.reply({ embeds: [embed] }); + } catch { + return interaction.reply({ + content: ':x: An error occured, Chuck is investigating this!' + }); + } + } +} diff --git a/apps/bot/src/commands/other/fortune.ts b/apps/bot/src/commands/other/fortune.ts new file mode 100644 index 0000000..7504ed7 --- /dev/null +++ b/apps/bot/src/commands/other/fortune.ts @@ -0,0 +1,51 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Command } from '@sapphire/framework'; +import { EmbedBuilder } from 'discord.js'; + +@ApplyOptions({ + name: 'fortune', + description: 'Replies with a fortune cookie tip!', + preconditions: ['isCommandDisabled'] +}) +export class FortuneCommand extends Command { + public override registerApplicationCommands(registry: Command.Registry) { + registry.registerChatInputCommand(builder => + builder.setName(this.name).setDescription(this.description) + ); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + try { + const response = await fetch('http://yerkee.com/api/fortune'); + const data = await response.json(); + + const tip = data.fortune; + + if (!tip) { + return interaction.reply({ + content: 'Something went wrong!' + }); + } + + const embed = new EmbedBuilder() + .setColor('Orange') + .setAuthor({ + name: 'Fortune Cookie', + url: 'https://yerkee.com', + iconURL: 'https://i.imgur.com/58wIjK0.png' + }) + .setDescription(tip) + .setTimestamp() + .setFooter({ + text: 'Powered by yerkee.com' + }); + return interaction.reply({ embeds: [embed] }); + } catch { + return interaction.reply({ + content: 'Something went wrong!' + }); + } + } +} diff --git a/apps/bot/src/commands/other/game-search.ts b/apps/bot/src/commands/other/game-search.ts new file mode 100644 index 0000000..8357ef3 --- /dev/null +++ b/apps/bot/src/commands/other/game-search.ts @@ -0,0 +1,234 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Command } from '@sapphire/framework'; +import { PaginatedMessage } from '@sapphire/discord.js-utilities'; +import { env } from '../../env'; +import axios from 'axios'; + +@ApplyOptions({ + name: 'game-search', + description: 'Search for video game information', + preconditions: ['isCommandDisabled'] +}) +export class ChuckNorrisCommand extends Command { + public override registerApplicationCommands(registry: Command.Registry) { + registry.registerChatInputCommand(builder => + builder + .setName(this.name) + .setDescription(this.description) + .addStringOption(option => + option + .setName('game') + .setDescription('The game you want to look up?') + .setRequired(true) + ) + ); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + if (!env.RAWG_API) { + return interaction.reply({ + content: 'This command is disabled because the RAWG API key is not set.' + }); + } + + const title = interaction.options.getString('game', true); + const filteredTitle = this.filterTitle(title); + + const game = await this.getGameDetails(filteredTitle); + + if (!game) { + return interaction.reply({ + content: 'No game found with that name' + }); + } + + const PaginatedEmbed = new PaginatedMessage(); + + const firstPageTuple: string[] = []; // releaseDate, esrbRating, userRating + + if (game.tba) { + firstPageTuple.push('TBA'); + } else if (!game.released) { + firstPageTuple.push('None Listed'); + } else { + firstPageTuple.push(game.released); + } + + if (!game.esrb_rating) { + firstPageTuple.push('None Listed'); + } else { + firstPageTuple.push(game.esrb_rating.name); + } + + if (!game.rating) { + firstPageTuple.push('None Listed'); + } else { + firstPageTuple.push(game.rating + '/5'); + } + + PaginatedEmbed.addPageEmbed(embed => + embed + .setTitle(`Game Info: ${game.name}`) + .setDescription( + '>>> ' + + '**Game Description**\n' + + game.description_raw.slice(0, 2000) + + '...' + ) + .setColor('Grey') + .setThumbnail(game.background_image) + .addFields( + { name: 'Released', value: '> ' + firstPageTuple[0], inline: true }, + { + name: 'ESRB Rating', + value: '> ' + firstPageTuple[1], + inline: true + }, + { name: 'Score', value: '> ' + firstPageTuple[2], inline: true } + ) + .setTimestamp() + ); + + const developerArray: string[] = []; + if (game.developers.length) { + for (let i = 0; i < game.developers.length; ++i) { + developerArray.push(game.developers[i].name); + } + } else { + developerArray.push('None Listed'); + } + + const publisherArray: string[] = []; + if (game.publishers.length) { + for (let i = 0; i < game.publishers.length; ++i) { + publisherArray.push(game.publishers[i].name); + } + } else { + publisherArray.push('None Listed'); + } + + const platformArray: string[] = []; + if (game.platforms.length) { + for (let i = 0; i < game.platforms.length; ++i) { + platformArray.push(game.platforms[i].platform.name); + } + } else { + platformArray.push('None Listed'); + } + + const genreArray: string[] = []; + if (game.genres.length) { + for (let i = 0; i < game.genres.length; ++i) { + genreArray.push(game.genres[i].name); + } + } else { + genreArray.push('None Listed'); + } + + const retailerArray: string[] = []; + if (game.stores.length) { + for (let i = 0; i < game.stores.length; ++i) { + retailerArray.push( + `[${game.stores[i].store.name}](${game.stores[i].url})` + ); + } + } else { + retailerArray.push('None Listed'); + } + + PaginatedEmbed.addPageEmbed(embed => + embed + .setTitle(`Game Info: ${game.name}`) + .setColor('Grey') + .setThumbnail(game.background_image_additional ?? game.background_image) + // Row 1 + .addFields( + { + name: developerArray.length == 1 ? 'Developer' : 'Developers', + value: '> ' + developerArray.toString().replace(/,/g, ', '), + inline: true + }, + { + name: publisherArray.length == 1 ? 'Publisher' : 'Publishers', + value: '> ' + publisherArray.toString().replace(/,/g, ', '), + inline: true + }, + { + name: platformArray.length == 1 ? 'Platform' : 'Platforms', + value: '> ' + platformArray.toString().replace(/,/g, ', '), + inline: true + } + ) + // Row 2 + .addFields( + { + name: genreArray.length == 1 ? 'Genre' : 'Genres', + value: '> ' + genreArray.toString().replace(/,/g, ', '), + inline: true + }, + { + name: retailerArray.length == 1 ? 'Retailer' : 'Retailers', + value: + '> ' + + retailerArray.toString().replace(/,/g, ', ').replace(/`/g, '') + } + ) + .setTimestamp() + ); + if (PaginatedEmbed.actions.size > 0) + PaginatedEmbed.actions.delete('@sapphire/paginated-messages.goToPage'); + return PaginatedEmbed.run(interaction); + } + + private filterTitle(title: string) { + return title.replace(/ /g, '-').replace(/' /g, '').toLowerCase(); + } + + private getGameDetails(query: string): Promise { + return new Promise(async function (resolve, reject) { + const url = `https://api.rawg.io/api/games/${encodeURIComponent( + query + )}?key=${env.RAWG_API}`; + try { + const response = await axios.get(url); + if (response.status === 429) { + reject(':x: Rate Limit exceeded. Please try again in a few minutes.'); + } + if (response.status === 503) { + reject( + ':x: The service is currently unavailable. Please try again later.' + ); + } + if (response.status === 404) { + reject(`:x: Error: ${query} was not found`); + } + if (response.status !== 200) { + reject( + ':x: There was a problem getting game from the API, make sure you entered a valid game tittle' + ); + } + + let body = response.data; + if (body.redirect) { + const redirect = await axios.get( + `https://api.rawg.io/api/games/${body.slug}?key=${env.RAWG_API}` + ); + body = redirect.data; + } + // 'id' is the only value that must be present to all valid queries + if (!body.id) { + reject( + ':x: There was a problem getting data from the API, make sure you entered a valid game title' + ); + } + resolve(body); + } catch (e) { + reject( + 'There was a problem getting data from the API, make sure you entered a valid game title' + ); + } + }); + } +} diff --git a/apps/bot/src/commands/other/help.ts b/apps/bot/src/commands/other/help.ts new file mode 100644 index 0000000..7e7330d --- /dev/null +++ b/apps/bot/src/commands/other/help.ts @@ -0,0 +1,161 @@ +import { + PaginatedMessage, + PaginatedFieldMessageEmbed +} from '@sapphire/discord.js-utilities'; +import { ApplyOptions } from '@sapphire/decorators'; +import { Command, CommandOptions, container } from '@sapphire/framework'; +import { + ApplicationCommandOption, + AutocompleteInteraction, + EmbedBuilder +} from 'discord.js'; + +@ApplyOptions({ + name: 'help', + description: 'Get the Command List or add a command-name to get more info.', + preconditions: ['isCommandDisabled'] +}) +export class HelpCommand extends Command { + public override registerApplicationCommands( + registry: Command.Registry + ): void { + registry.registerChatInputCommand(builder => + builder + .setName(this.name) + .setDescription(this.description) + .addStringOption(option => + option + .setName('command-name') + .setDescription('Which command would you like to know about?') + .setRequired(false) + ) + ); + } + + public override autocompleteRun(interaction: AutocompleteInteraction) { + const commands = interaction.client.application?.commands.cache; + const focusedOption = interaction.options.getFocused(true); + const result = commands + ?.sorted((a, b) => a.name.localeCompare(b.name)) + .filter(choice => choice.name.startsWith(focusedOption.value.toString())) + .map(choice => ({ name: choice.name, value: choice.name })) + .slice(0, 10); + interaction; + return interaction.respond(result!); + } + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + const { client } = container; + + const query = interaction.options.getString('command-name')?.toLowerCase(); + const array: CommandInfo[] = []; + + const app = client.application; + app?.commands.cache.each(command => { + array.push({ + name: command.name, + options: command.options, + details: command.description + }); + }); + + // Sort the array by name + const sortedList = array.sort((a, b) => { + let fa = a.name.toLowerCase(), + fb = b.name.toLowerCase(); + + if (fa < fb) { + return -1; + } + if (fa > fb) { + return 1; + } + return 0; + }); + + if (!query) { + let characters = 0; + let page = 0; + let message: string[] = []; + const PaginatedEmbed = new PaginatedMessage(); + sortedList.forEach((command, index) => { + characters += command.details.length + command.details.length; + message.push(`> **/${command.name}** - ${command.details}\n`); + + if (characters > 1500 || index == sortedList.length - 1) { + page++; + characters = 0; + PaginatedEmbed.addPageEmbed( + new EmbedBuilder() + .setTitle(`Command List - Page ${page}`) + .setThumbnail(app?.iconURL()!) + .setColor('Purple') + .setAuthor({ + name: interaction.user.username + ' - Help Command', + iconURL: interaction.user.displayAvatarURL() + }) + .setDescription(message.toString().replaceAll(',> **/', '> **/')) + ); + message = []; + } + }); + + return PaginatedEmbed.run(interaction); + } else { + const commandMap = new Map(); + sortedList.reduce( + (obj, command) => commandMap.set(command.name, command), + {} + ); + if (commandMap.has(query)) { + const command: CommandInfo = commandMap.get(query); + const optionsList: any[] = []; + command.options.forEach(option => { + optionsList.push({ + name: option.name, + description: option.description + }); + }); + const DetailedPagination = new PaginatedFieldMessageEmbed(); + + const commandDetails = new EmbedBuilder() + .setAuthor({ + name: interaction.user.username + ' - Help Command', + iconURL: interaction.user.displayAvatarURL() + }) + .setThumbnail(app?.iconURL()!) + .setTitle( + `${ + command.name.charAt(0).toUpperCase() + + command.name.slice(1).toLowerCase() + } - Details` + ) + .setColor('Purple') + .setDescription(`**Description**\n> ${command.details}`); + + if (!command.options.length) + return await interaction.reply({ embeds: [commandDetails] }); + + DetailedPagination.setTemplate(commandDetails) + .setTitleField('Options') + .setItems(command.options) + .formatItems( + (option: any) => `**${option.name}**\n> ${option.description}` + ) + .setItemsPerPage(5) + .make(); + + return DetailedPagination.run(interaction); + } else + return await interaction.reply( + `:x: Command: **${query}** was not found` + ); + } + interface CommandInfo { + name: string; + options: ApplicationCommandOption[]; + details: string; + } + } +} diff --git a/apps/bot/src/commands/other/insult.ts b/apps/bot/src/commands/other/insult.ts new file mode 100644 index 0000000..9de4633 --- /dev/null +++ b/apps/bot/src/commands/other/insult.ts @@ -0,0 +1,52 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Command, CommandOptions } from '@sapphire/framework'; +import { EmbedBuilder } from 'discord.js'; + +@ApplyOptions({ + name: 'insult', + description: 'Replies with a mean insult', + preconditions: ['isCommandDisabled'] +}) +export class InsultCommand extends Command { + public override registerApplicationCommands( + registry: Command.Registry + ): void { + registry.registerChatInputCommand({ + name: this.name, + description: this.description + }); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + try { + const response = await fetch( + 'https://evilinsult.com/generate_insult.php?lang=en&type=json' + ); + const data = await response.json(); + + if (!data.insult) + return interaction.reply({ content: 'Something went wrong!' }); + + const embed = new EmbedBuilder() + .setColor('Red') + .setAuthor({ + name: 'Evil Insult', + url: 'https://evilinsult.com', + iconURL: 'https://i.imgur.com/bOVpNAX.png' + }) + .setDescription(data.insult) + .setTimestamp() + .setFooter({ + text: 'Powered by evilinsult.com' + }); + + return interaction.reply({ embeds: [embed] }); + } catch { + return interaction.reply({ + content: 'Something went wrong!' + }); + } + } +} diff --git a/apps/bot/src/commands/other/kanye.ts b/apps/bot/src/commands/other/kanye.ts new file mode 100644 index 0000000..08c1c7f --- /dev/null +++ b/apps/bot/src/commands/other/kanye.ts @@ -0,0 +1,50 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Command, CommandOptions } from '@sapphire/framework'; +import { EmbedBuilder } from 'discord.js'; + +@ApplyOptions({ + name: 'kanye', + description: 'Replies with a random Kanye quote', + preconditions: ['isCommandDisabled'] +}) +export class KanyeCommand extends Command { + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + try { + const response = await fetch('https://api.kanye.rest/?format=json'); + const data = await response.json(); + + if (!data.quote) + return interaction.reply({ content: 'Something went wrong!' }); + + const embed = new EmbedBuilder() + .setColor('Orange') + .setAuthor({ + name: 'Kanye Omari West', + url: 'https://kanye.rest', + iconURL: 'https://i.imgur.com/SsNoHVh.png' + }) + .setDescription(data.quote) + .setTimestamp() + .setFooter({ + text: 'Powered by kanye.rest' + }); + + return interaction.reply({ embeds: [embed] }); + } catch { + return interaction.reply({ + content: 'Something went wrong!' + }); + } + } + + public override registerApplicationCommands( + registry: Command.Registry + ): void { + registry.registerChatInputCommand({ + name: this.name, + description: this.description + }); + } +} diff --git a/apps/bot/src/commands/other/motivation.ts b/apps/bot/src/commands/other/motivation.ts new file mode 100644 index 0000000..b126b9a --- /dev/null +++ b/apps/bot/src/commands/other/motivation.ts @@ -0,0 +1,51 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Command, CommandOptions } from '@sapphire/framework'; +import { EmbedBuilder } from 'discord.js'; + +@ApplyOptions({ + name: 'motivation', + description: 'Replies with a motivational quote!', + preconditions: ['isCommandDisabled'] +}) +export class MotivationCommand extends Command { + public override registerApplicationCommands( + registry: Command.Registry + ): void { + registry.registerChatInputCommand({ + name: this.name, + description: this.description + }); + } + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + try { + const response = await fetch('https://type.fit/api/quotes'); + const data = await response.json(); + + if (!data) + return await interaction.reply({ content: 'Something went wrong!' }); + + const randomQuote = data[Math.floor(Math.random() * data.length)]; + + const embed = new EmbedBuilder() + .setColor('Yellow') + .setAuthor({ + name: 'Motivational Quote', + url: 'https://type.fit', + iconURL: 'https://i.imgur.com/Cnr6cQb.png' + }) + .setDescription(`*"${randomQuote.text}*"\n\n-${randomQuote.author}`) + .setTimestamp() + .setFooter({ + text: 'Powered by type.fit' + }); + + return await interaction.reply({ embeds: [embed] }); + } catch { + return await interaction.reply({ + content: 'Something went wrong!' + }); + } + } +} diff --git a/apps/bot/src/commands/other/ping.ts b/apps/bot/src/commands/other/ping.ts new file mode 100644 index 0000000..a79967c --- /dev/null +++ b/apps/bot/src/commands/other/ping.ts @@ -0,0 +1,21 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Command } from '@sapphire/framework'; + +@ApplyOptions({ + name: 'ping', + description: 'Replies with pong!', + preconditions: ['isCommandDisabled'] +}) +export class PingCommand extends Command { + public override registerApplicationCommands(registry: Command.Registry) { + registry.registerChatInputCommand(builder => + builder.setName(this.name).setDescription(this.description) + ); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + return interaction.reply({ content: 'Pong!' }); + } +} diff --git a/apps/bot/src/commands/other/random.ts b/apps/bot/src/commands/other/random.ts new file mode 100644 index 0000000..d9e8ad7 --- /dev/null +++ b/apps/bot/src/commands/other/random.ts @@ -0,0 +1,45 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Command, CommandOptions } from '@sapphire/framework'; +import { EmbedBuilder } from 'discord.js'; + +@ApplyOptions({ + name: 'random', + description: 'Generate a random number between two inputs!', + preconditions: ['isCommandDisabled'] +}) +export class RandomCommand extends Command { + public override registerApplicationCommands( + registry: Command.Registry + ): void { + registry.registerChatInputCommand(builder => + builder + .setName(this.name) + .setDescription(this.description) + .addIntegerOption(option => + option + .setName('min') + .setDescription('What is the minimum number?') + .setRequired(true) + ) + .addIntegerOption(option => + option + .setName('max') + .setDescription('What is the maximum number?') + .setRequired(true) + ) + ); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + const min = Math.ceil(interaction.options.getInteger('min', true)); + const max = Math.floor(interaction.options.getInteger('max', true)); + + const rngEmbed = new EmbedBuilder().setTitle( + `${Math.floor(Math.random() * (max - min + 1)) + min}` + ); + + return await interaction.reply({ embeds: [rngEmbed] }); + } +} diff --git a/apps/bot/src/commands/other/reddit.ts b/apps/bot/src/commands/other/reddit.ts new file mode 100644 index 0000000..7de5a23 --- /dev/null +++ b/apps/bot/src/commands/other/reddit.ts @@ -0,0 +1,215 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Command, CommandOptions } from '@sapphire/framework'; +import { + ColorResolvable, + StringSelectMenuBuilder, + ComponentType +} from 'discord.js'; +import { PaginatedMessage } from '@sapphire/discord.js-utilities'; +import axios from 'axios'; +import Logger from '../../lib/logger'; + +@ApplyOptions({ + name: 'reddit', + description: 'Get posts from reddit by specifying a subreddit', + preconditions: ['GuildOnly', 'isCommandDisabled'] +}) +export class RedditCommand extends Command { + public override registerApplicationCommands( + registry: Command.Registry + ): void { + registry.registerChatInputCommand(builder => + builder + .setName(this.name) + .setDescription(this.description) + .addStringOption(option => + option + .setName('subreddit') + .setDescription('Subreddit name') + .setRequired(true) + ) + .addStringOption(option => + option + .setName('sort') + .setDescription( + 'What posts do you want to see? Select from best/hot/top/new/controversial/rising' + ) + .setRequired(true) + .addChoices( + { + name: 'Best', + value: 'best' + }, + { + name: 'Hot', + value: 'hot' + }, + { + name: 'New', + value: 'new' + }, + { + name: 'Top', + value: 'top' + }, + { + name: 'Controversial', + value: 'controversial' + }, + { + name: 'Rising', + value: 'rising' + } + ) + ) + ); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + await interaction.deferReply(); + const channel = interaction.channel; + if (!channel) return await interaction.reply('Something went wrong :('); // type guard + const subreddit = interaction.options.getString('subreddit', true); + const sort = interaction.options.getString('sort', true); + + if (['controversial', 'top'].some(val => val === sort)) { + const row = new StringSelectMenuBuilder() + .setCustomId('top_or_controversial') + .setPlaceholder('Please select an option') + .addOptions(optionsArray); + + const menu = await channel.send({ + content: `:loud_sound: Do you want to get the ${sort} posts from past hour/week/month/year or all?`, + components: [ + { + type: ComponentType.ActionRow, + + components: [row] + } + ] + }); + + const collector = menu.createMessageComponentCollector({ + componentType: ComponentType.StringSelect, + time: 30000 // 30 sec + }); + + collector.on('end', () => { + if (menu) menu.delete().catch(Logger.error); + }); + + collector.on('collect', async i => { + if (i.user.id !== interaction.user.id) { + i.reply({ + content: 'This element is not for you!', + ephemeral: true + }); + return; + } else { + collector.stop(); + const timeFilter = i.values[0]; + this.fetchFromReddit(interaction, subreddit, sort, timeFilter); + return; + } + }); + } else { + this.fetchFromReddit(interaction, subreddit, sort); + return; + } + return; + } + + private async fetchFromReddit( + interaction: Command.ChatInputCommandInteraction, + subreddit: string, + sort: string, + timeFilter = 'day' + ) { + try { + var data = await this.getData(subreddit, sort, timeFilter); + } catch (error: any) { + return interaction.followUp(error); + } + + // interaction.followUp('Fetching data from reddit'); + + const paginatedEmbed = new PaginatedMessage(); + for (let i = 1; i <= data.children.length; i++) { + let color: ColorResolvable = 'Orange'; + let redditPost = data.children[i - 1]; + + if (redditPost.data.title.length > 255) { + redditPost.data.title = redditPost.data.title.substring(0, 252) + '...'; // max title length is 256 + } + + if (redditPost.data.selftext.length > 1024) { + redditPost.data.selftext = + redditPost.data.selftext.substring(0, 1024) + + `[Read More...](https://www.reddit.com${redditPost.data.permalink})`; + } + + if (redditPost.data.over_18) color = 'Red'; // red - nsfw + + paginatedEmbed.addPageEmbed(embed => + embed + .setColor(color) + .setTitle(redditPost.data.title) + .setURL(`https://www.reddit.com${redditPost.data.permalink}`) + .setDescription( + `${ + redditPost.data.over_18 ? '' : redditPost.data.selftext + '\n\n' + }Upvotes: ${redditPost.data.score} :thumbsup: ` + ) + .setAuthor({ name: redditPost.data.author }) + ); + } + + return paginatedEmbed.run(interaction); + } + + private getData( + subreddit: string, + sort: string, + timeFilter: string + ): Promise { + return new Promise(async function (resolve, reject) { + const response = await axios.get( + `https://www.reddit.com/r/${subreddit}/${sort}/.json?limit=10&t=${ + timeFilter ? timeFilter : 'day' + }` + ); + const data = response.data.data; + if (!data) { + reject(`**${subreddit}** is a private subreddit!`); + } else if (!data.children.length) { + reject('Please provide a valid subreddit name!'); + } + resolve(data); + }); + } +} + +const optionsArray = [ + { + label: 'hour', + value: 'hour' + }, + { + label: 'week', + value: 'week' + }, + { + label: 'month', + value: 'month' + }, + { + label: 'year', + value: 'year' + }, + { + label: 'all', + value: 'all' + } +]; diff --git a/apps/bot/src/commands/other/rockpaperscissors.ts b/apps/bot/src/commands/other/rockpaperscissors.ts new file mode 100644 index 0000000..91259dc --- /dev/null +++ b/apps/bot/src/commands/other/rockpaperscissors.ts @@ -0,0 +1,80 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Command, CommandOptions } from '@sapphire/framework'; +import { Colors, EmbedBuilder } from 'discord.js'; + +@ApplyOptions({ + name: 'rockpaperscissors', + description: 'Play rock paper scissors with me!', + preconditions: ['isCommandDisabled'] +}) +export class RockPaperScissorsCommand extends Command { + public override registerApplicationCommands( + registry: Command.Registry + ): void { + registry.registerChatInputCommand(builder => + builder + .setName(this.name) + .setDescription(this.description) + .addStringOption(option => + option + .setName('move') + .setDescription('What is your move?') + .setRequired(true) + .addChoices( + { name: 'Rock', value: 'rock' }, + { name: 'Paper', value: 'paper' }, + { name: 'Scissors', value: 'scissors' } + ) + ) + ); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + const move = interaction.options.getString('move', true) as + | 'rock' + | 'paper' + | 'scissors'; + const resultMessage = this.rpsLogic(move); + + const embed = new EmbedBuilder() + .setColor(Colors.White) + .setTitle('Rock, Paper, Scissors') + .setDescription(`**${resultMessage[0]}**, I formed ${resultMessage[1]}`); + + return await interaction.reply({ embeds: [embed] }); + } + + private rpsLogic(player_move: string) { + const bot_move = ['rock', 'paper', 'scissors'][ + Math.floor(Math.random() * 3) + ]; + + if (player_move === 'rock') { + if (bot_move === 'rock') { + return ['Tie!', 'Rock']; + } + if (bot_move === 'paper') { + return ['I win!', 'Paper']; + } + return ['You win!', 'Scissors']; + } else if (player_move === 'paper') { + if (bot_move === 'rock') { + return ['You win!', 'Rock']; + } + if (bot_move === 'paper') { + return ['Tie!', 'Paper']; + } + return ['I win!', 'Scissors']; + } else { + if (bot_move === 'rock') { + return ['I win!', 'Rock']; + } + if (bot_move === 'paper') { + return ['You win!', 'Paper']; + } + return ['Tie!', 'Scissors']; + } + } +} diff --git a/apps/bot/src/commands/other/speedrun.ts b/apps/bot/src/commands/other/speedrun.ts new file mode 100644 index 0000000..38edac0 --- /dev/null +++ b/apps/bot/src/commands/other/speedrun.ts @@ -0,0 +1,342 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { PaginatedMessage } from '@sapphire/discord.js-utilities'; +import { Command, CommandOptions } from '@sapphire/framework'; +import axios from 'axios'; +import { EmbedBuilder, Colors, ButtonStyle, ComponentType } from 'discord.js'; +import Logger from '../../lib/logger'; + +@ApplyOptions({ + name: 'speedrun', + description: 'Look for the world record of a game!', + preconditions: ['isCommandDisabled'] +}) +export class SpeedRunCommand extends Command { + public override registerApplicationCommands( + registry: Command.Registry + ): void { + registry.registerChatInputCommand(builder => + builder + .setName(this.name) + .setDescription(this.description) + .addStringOption(option => + option + .setName('game') + .setDescription('Video Game Title?') + .setRequired(true) + ) + .addStringOption(option => + option + .setName('category') + .setDescription('speed run Category?') + .setRequired(false) + ) + ); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + const query = interaction.options.getString('game', true); + let queryCat = interaction.options.getString('category', false); + + let initialRaw; + + try { + initialRaw = await axios.get( + `https://www.speedrun.com/api/v1/games?name=${query}` + ); + } catch { + return interaction.reply({ + content: 'Something went wrong, please try again later' + }); + } + const initial = initialRaw.data; + + if (!initial.data.length) { + return interaction.reply({ content: 'No game was found.' }); + } + + let gameID: string = initial.data[0].id; + + let response; + try { + response = await axios.get( + `https://www.speedrun.com/api/v1/games/${gameID}/records?miscellaneous=no&scope=full-game&top=10&embed=game,category,players,platforms,regions` + ); + } catch (error) { + Logger.error(`${this.name} Command - ${JSON.stringify(error)}`); + return interaction.reply({ + content: 'Something went wrong, please try again later' + }); + } + const body = response.data; + + if (!body.data.length) { + const gameNameArr: string[] = []; + initial.data.slice(0, 6).forEach((id: any) => { + gameNameArr.push(id.names.international); + }); + let gameName = new EmbedBuilder() + .setColor('Green') + .setTitle(':mag: Search Results') + .setThumbnail(initial.data[0].assets['cover-medium'].uri) + .addFields({ + name: ':x: Try searching again with the following suggestions.', + value: initial.data[0].names.international + ` doesn't have any runs.` + }) + .setTimestamp() + .setFooter({ text: 'Powered by www.speedrun.com' }); + + gameNameArr.forEach((game, i) => { + gameName.addFields({ + name: `:video_game: Result ${i + 1}`, + value: game + }); + }); + + interaction.reply({ embeds: [gameName] }); + } else { + const categories = body.data; + queryCat = !queryCat ? categories[0].category.data.name : queryCat; + for (let i = 0; i <= categories.length; ++i) { + if ( + categories[i]?.category.data.name.toLowerCase() == + queryCat?.toLowerCase() + ) { + break; + } else if (i == categories.length) + queryCat = categories[0].category.data.name; + } + return await interaction + .reply({ + embeds: [ + new EmbedBuilder().setColor('Green').setDescription('Getting Data') + ], + fetchReply: true + }) + .then(async () => { + SpeedRunCommand.embedGenerator( + categories, + queryCat ?? categories[0].category.data.name + ) + .setIdle(30 * 1000) + .setIndex(0) + .run(interaction); + }); + } + } + + static myButtons( + message: PaginatedMessage, + categories: any, + queryCat: string + ) { + categories.forEach((value: null, index: number) => { + message.addAction({ + style: + categories[index].category.data.name.toLowerCase() == + queryCat.toLowerCase() + ? ButtonStyle.Success + : ButtonStyle.Primary, + customId: `Category-${index}`, + label: categories[index].category.data.name, + type: ComponentType.Button, + run: async ({ interaction }) => { + // message = new PaginatedMessage(); + queryCat = categories[index].category.data.name; + message = SpeedRunCommand.embedGenerator(categories, queryCat); + try { + SpeedRunCommand.myButtons( + message.setIndex(0).setIdle(30 * 1000), + categories, + queryCat + ); + } catch (error: any) { + new PaginatedMessage() + .addPageEmbed( + new EmbedBuilder() + .setColor(Colors.Red) + .setTitle('Error') + .setDescription(error.toString()) + ) + .run(interaction); + } + await interaction + .update({ + embeds: [ + new EmbedBuilder() + .setColor('Green') + .setDescription('Getting Data') + ], + fetchReply: true + }) + .then(async () => { + message.run(interaction); + }); + } + }); + }); + return message; + } + static embedGenerator(categories: any, queryCat: string) { + const PaginatedEmbed = new PaginatedMessage(); + try { + categories.forEach((category: any) => { + if ( + category.category.data.name.toLowerCase() == queryCat?.toLowerCase() + ) { + const catRules = new EmbedBuilder() + .setDescription( + category.category.data.rules.toString().length + ? `**${category.category.data.name} Rules**:\n` + + category.category.data.rules.toString() + : 'No Data' + ) + .setColor('Green') + .setThumbnail(category.game.data.assets['cover-medium'].uri) + .setAuthor({ + name: + category.game.data.names.international + + ' - ' + + category.category.data.name, + url: 'http://speedrun.com/' + }); + PaginatedEmbed.addPageEmbed(catRules); + for (let i = 0; i <= category.players.data.length; ++i) { + const platform: string = + category.platforms.data.length > 0 + ? category.platforms.data[0].name + : ''; + const region: string = + category.regions.data.length > 0 + ? ' - ' + category.regions.data[0].name + : ''; + let emu: string = 'No Data'; + let runnerName: string = 'No Data'; + let trophyIcon: string = ''; + if (category.runs[i]) { + emu = category.runs[i].run.system.emulated ? ' [EMU]' : ''; + runnerName = + category.players.data[i].rel === 'user' + ? category.players.data[i].names.international + : category.players.data[i].name; + + if (i == 0) trophyIcon = '🏆 WR: '; + if (i == 1) trophyIcon = '🥈 2nd: '; + if (i == 2) trophyIcon = '🥉 3rd: '; + if (i >= 3) trophyIcon = `${i + 1}th: `; + } + if (category.runs[i]) { + PaginatedEmbed.addPageEmbed(embeds => + embeds + .setColor('Green') + .setTitle( + category.runs[i] + ? trophyIcon + + SpeedRunCommand.convertTime( + category.runs[i].run.times.primary_t + ) + + ' by ' + + runnerName + : 'No Data' + ) + .setThumbnail(category.game.data.assets['cover-medium'].uri) + .setURL( + category.runs[i] + ? category.runs[i].run.weblink + : category.weblink + ) + .setAuthor({ + name: + category.game.data.names.international + + ' - ' + + category.category.data.name, + url: 'http://speedrun.com/' + }) + .addFields( + { + name: ':calendar_spiral: Date Played:', + value: category.runs[i] + ? category.runs[i].run.date + : 'No Data' + }, + { + name: ':video_game: Played On:', + value: platform + region + emu + } + ) + .setFooter({ + text: 'Powered by www.speedrun.com', + iconURL: 'https://i.imgur.com/PpxR9E1.png' + }) + ); + } + } + } + }); + PaginatedEmbed.setIdle(30 * 1000).setIndex(0); + if (PaginatedEmbed.actions.size > 0) + PaginatedEmbed.actions.delete('@sapphire/paginated-messages.goToPage'); + + SpeedRunCommand.myButtons(PaginatedEmbed, categories, queryCat); + + return PaginatedEmbed; + } catch (error: any) { + Logger.error(`${this.name} Command - ${JSON.stringify(error)}`); + return new PaginatedMessage().addPageEmbed( + new EmbedBuilder() + .setColor(Colors.Red) + .setTitle('Error') + .setDescription(error.toString()) + ); + } + } + + static convertTime(time: number) { + let str, hr, min: any, sec, ms: number | string | undefined; + let parts = time.toString().split('.'); + ms = parts.length > 1 ? parseInt((parts[1] + '00').slice(0, 3)) : undefined; + sec = parseInt(parts[0]); + if (sec >= 60) { + min = Math.floor(sec / 60); + sec = sec % 60; + sec = sec < 10 ? '0' + sec : sec; + } + if (min >= 60) { + hr = Math.floor(min / 60); + min = min % 60; + min = min < 10 ? '0' + min : min; + } + if (ms && ms < 10) ms = '00' + ms; + else if (ms && ms < 100) ms = '0' + ms; + if (min == undefined) { + str = + ms == undefined + ? sec.toString() + 's' + : sec.toString() + 's ' + ms.toString() + 'ms'; + } else if (hr === undefined) { + str = + ms === undefined + ? min.toString() + 'm ' + sec.toString() + 's' + : min.toString() + + 'm ' + + sec.toString() + + 's ' + + ms.toString() + + 'ms'; + } else { + str = + ms === undefined + ? hr.toString() + 'h ' + min.toString() + 'm ' + sec.toString() + 's' + : hr.toString() + + 'h ' + + min.toString() + + 'm ' + + sec.toString() + + 's ' + + ms.toString() + + 'ms'; + } + return str; + } +} diff --git a/apps/bot/src/commands/other/translate.ts b/apps/bot/src/commands/other/translate.ts new file mode 100644 index 0000000..c719f9f --- /dev/null +++ b/apps/bot/src/commands/other/translate.ts @@ -0,0 +1,68 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Command, CommandOptions } from '@sapphire/framework'; +import axios from 'axios'; +import { EmbedBuilder } from 'discord.js'; +import translate from 'google-translate-api-x'; +import Logger from '../../lib/logger'; +@ApplyOptions({ + name: 'translate', + description: + 'Translate from any language to any language using Google Translate', + preconditions: ['GuildOnly', 'isCommandDisabled', 'validateLanguageCode'] +}) +export class TranslateCommand extends Command { + public override registerApplicationCommands( + registry: Command.Registry + ): void { + registry.registerChatInputCommand(builder => + builder + .setName(this.name) + .setDescription(this.description) + .addStringOption(option => + option + .setName('target') + .setDescription( + 'What is the target language?(language you want to translate to)' + ) + .setRequired(true) + ) + .addStringOption(option => + option + .setName('text') + .setDescription('What text do you want to translate?') + .setRequired(true) + ) + ); + } + + public override chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + const targetLang = interaction.options.getString('target', true); + + const text = interaction.options.getString('text', true); + translate(text, { + to: targetLang, + requestFunction: axios + }) + .then(async (response: any) => { + const embed = new EmbedBuilder() + .setColor('DarkRed') + .setTitle('Google Translate') + .setURL('https://translate.google.com/') + .setDescription(response.text) + .setFooter({ + iconURL: 'https://i.imgur.com/ZgFxIwe.png', // Google Translate Icon + text: 'Powered by Google Translate' + }); + + return await interaction.reply({ embeds: [embed] }); + }) + .catch(async error => { + Logger.error(error); + return await interaction.reply( + ':x: Something went wrong when trying to translate the text' + ); + }); + } +} diff --git a/apps/bot/src/commands/other/trump.ts b/apps/bot/src/commands/other/trump.ts new file mode 100644 index 0000000..7575da1 --- /dev/null +++ b/apps/bot/src/commands/other/trump.ts @@ -0,0 +1,50 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Command, CommandOptions } from '@sapphire/framework'; +import { EmbedBuilder } from 'discord.js'; +import axios from 'axios'; +import Logger from '../../lib/logger'; +@ApplyOptions({ + name: 'trump', + description: 'Replies with a random Trump quote', + preconditions: ['isCommandDisabled'] +}) +export class TrumpCommand extends Command { + public override registerApplicationCommands( + registry: Command.Registry + ): void { + registry.registerChatInputCommand({ + name: this.name, + description: this.description + }); + } + + public override chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + axios + .get('https://api.tronalddump.io/random/quote') + .then(async response => { + const quote: string = response.data.value; + const embed = new EmbedBuilder() + .setColor('Orange') + .setAuthor({ + name: 'Donald Trump', + url: 'https://api.tronalddump.io/random/quote', + iconURL: + 'https://www.whitehouse.gov/wp-content/uploads/2021/01/45_donald_trump.jpg' + }) + .setDescription(quote) + .setTimestamp(response.data.appeared_at) + .setFooter({ + text: 'Powered by api.tronalddump.io' + }); + return interaction.reply({ embeds: [embed] }); + }) + .catch(async error => { + Logger.error(error); + return interaction.reply({ + content: 'Something went wrong when fetching a Trump quote :(' + }); + }); + } +} diff --git a/apps/bot/src/commands/other/tv-show-search.ts b/apps/bot/src/commands/other/tv-show-search.ts new file mode 100644 index 0000000..0f79c0e --- /dev/null +++ b/apps/bot/src/commands/other/tv-show-search.ts @@ -0,0 +1,190 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Command, CommandOptions } from '@sapphire/framework'; +import { PaginatedMessage } from '@sapphire/discord.js-utilities'; +import axios from 'axios'; +import Logger from '../../lib/logger'; + +@ApplyOptions({ + name: 'tv-show-search', + description: 'Get TV shows information', + preconditions: ['GuildOnly', 'isCommandDisabled'] +}) +export class TVShowSearchCommand extends Command { + public override registerApplicationCommands( + registry: Command.Registry + ): void { + registry.registerChatInputCommand(builder => + builder + .setName(this.name) + .setDescription(this.description) + .addStringOption(option => + option + .setName('query') + .setDescription('What TV show do you want to look up?') + .setRequired(true) + ) + ); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + const query = interaction.options.getString('query', true); + + try { + var data = await this.getData(query); + } catch (error: any) { + return interaction.reply({ content: error }); + } + + const PaginatedEmbed = new PaginatedMessage(); + + for (let i = 0; i < data.length; i++) { + const showInfo = this.constructInfoObject(data[i].show); + + PaginatedEmbed.addPageEmbed(embed => + embed + .setTitle(showInfo.name) + .setURL(showInfo.url) + .setColor('DarkAqua') + .setThumbnail(showInfo.thumbnail) + .setDescription(showInfo.summary) + .addFields( + { name: 'Language', value: showInfo.language, inline: true }, + { + name: 'Genre(s)', + value: showInfo.genres, + inline: true + }, + { + name: 'Show Type', + value: showInfo.type, + inline: true + }, + { + name: 'Premiered', + value: showInfo.premiered, + inline: true + }, + { name: 'Network', value: showInfo.network, inline: true }, + + { name: 'Runtime', value: showInfo.runtime, inline: true }, + { name: 'Average Rating', value: showInfo.rating } + ) + .setFooter({ + text: `(Page ${i}/${data.length}) Powered by tvmaze.com`, + iconURL: 'https://static.tvmaze.com/images/favico/favicon-32x32.png' + }) + ); + } + + await interaction.reply('Show info'); + return PaginatedEmbed.run(interaction); + } + + private getData(query: string): Promise { + return new Promise(async function (resolve, reject) { + const url = `http://api.tvmaze.com/search/shows?q=${encodeURI(query)}`; + try { + const response = await axios.get(url); + if (response.status == 429) { + reject(':x: Rate Limit exceeded. Please try again in a few minutes.'); + } + if (response.status == 503) { + reject( + ':x: The service is currently unavailable. Please try again later.' + ); + } + if (response.status !== 200) { + reject( + 'There was a problem getting data from the API, make sure you entered a valid TV show name' + ); + } + const data = response.data; + if (!data.length) { + reject( + 'There was a problem getting data from the API, make sure you entered a valid TV show name' + ); + } + resolve(data); + } catch (e) { + Logger.error(e); + reject( + 'There was a problem getting data from the API, make sure you entered a valid TV show name' + ); + } + }); + } + + private constructInfoObject(show: any): InfoObject { + return { + name: show.name, + url: show.url, + summary: this.filterSummary(show.summary), + language: this.checkIfNull(show.language), + genres: this.checkGenres(show.genres), + type: this.checkIfNull(show.type), + premiered: this.checkIfNull(show.premiered), + network: this.checkNetwork(show.network), + runtime: show.runtime ? show.runtime + ' Minutes' : 'None Listed', + rating: show.ratings ? show.rating.average : 'None Listed', + thumbnail: show.image + ? show.image.original + : 'https://static.tvmaze.com/images/no-img/no-img-portrait-text.png' + }; + } + + private filterSummary(summary: string) { + return summary + .replace(/<(\/)?b>/g, '**') + .replace(/<(\/)?i>/g, '*') + .replace(/<(\/)?p>/g, '') + .replace(/
/g, '\n') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/'/g, "'") + .replace(/"/g, '"') + .replace(/&/g, '&') + .replace(/'/g, "'"); + } + + private checkGenres(genres: Genres) { + if (Array.isArray(genres)) { + if (genres.join(' ').trim().length == 0) return 'None Listed'; + return genres.join(' '); + } else if (!genres.length) { + return 'None Listed'; + } + return genres; + } + + private checkIfNull(value: string) { + if (!value) { + return 'None Listed'; + } + return value; + } + + private checkNetwork(network: any) { + if (!network) return 'None Listed'; + return `(**${network.country.code}**) ${network.name}`; + } +} + +type InfoObject = { + name: string; + url: string; + summary: string; + language: string; + genres: string; + type: string; + premiered: string; + network: string; + runtime: string; + rating: string; + thumbnail: string; +}; + +type Genres = string | Array; + +type ResponseData = string | Array; diff --git a/apps/bot/src/commands/other/urban.ts b/apps/bot/src/commands/other/urban.ts new file mode 100644 index 0000000..ca4f76a --- /dev/null +++ b/apps/bot/src/commands/other/urban.ts @@ -0,0 +1,59 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Command, CommandOptions } from '@sapphire/framework'; +import { EmbedBuilder } from 'discord.js'; +import axios from 'axios'; +import Logger from '../../lib/logger'; + +@ApplyOptions({ + name: 'urban', + description: 'Get definitions from urban dictionary', + preconditions: ['GuildOnly', 'isCommandDisabled'] +}) +export class UrbanCommand extends Command { + public override registerApplicationCommands( + registry: Command.Registry + ): void { + registry.registerChatInputCommand(builder => + builder + .setName(this.name) + .setDescription(this.description) + .addStringOption(option => + option + .setName('query') + .setDescription('What term do you want to look up?') + .setRequired(true) + ) + ); + } + + public override chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + const query = interaction.options.getString('query', true); + axios + .get(`https://api.urbandictionary.com/v0/define?term=${query}`) + .then(async response => { + const definition: string = response.data.list[0].definition; + const embed = new EmbedBuilder() + .setColor('DarkOrange') + .setAuthor({ + name: 'Urban Dictionary', + url: 'https://urbandictionary.com', + iconURL: 'https://i.imgur.com/vdoosDm.png' + }) + .setDescription(definition) + .setURL(response.data.list[0].permalink) + .setTimestamp() + .setFooter({ + text: 'Powered by UrbanDictionary' + }); + return interaction.reply({ embeds: [embed] }); + }) + .catch(async error => { + Logger.error(error); + return interaction.reply({ + content: 'Failed to deliver definition :sob:' + }); + }); + } +} diff --git a/apps/bot/src/env.ts b/apps/bot/src/env.ts new file mode 100644 index 0000000..2985eec --- /dev/null +++ b/apps/bot/src/env.ts @@ -0,0 +1,33 @@ +import { createEnv } from '@t3-oss/env-core'; +import { z } from 'zod'; + +export const env = createEnv({ + /* + * Specify what prefix the client-side variables must have. + * This is enforced both on type-level and at runtime. + */ + clientPrefix: 'PUBLIC_', + server: { + DISCORD_TOKEN: z.string(), + TENOR_API: z.string(), + RAWG_API: z.string().optional(), + // Redis + REDIS_HOST: z.string().optional(), + REDIS_PORT: z.string().optional(), + REDIS_PASSWORD: z.string().optional(), + REDIS_DB: z.string().optional(), + // Lavalink + LAVA_HOST: z.string().optional(), + LAVA_PORT: z.string().optional(), + LAVA_PASS: z.string().optional(), + LAVA_SECURE: z.string().optional(), + SPOTIFY_CLIENT_ID: z.string().optional(), + SPOTIFY_CLIENT_SECRET: z.string().optional() + }, + client: {}, + /** + * What object holds the environment variables at runtime. + * Often `process.env` or `import.meta.env` + */ + runtimeEnv: process.env +}); diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts new file mode 100644 index 0000000..8130ca6 --- /dev/null +++ b/apps/bot/src/index.ts @@ -0,0 +1,76 @@ +import { ExtendedClient } from './lib/structures/ExtendedClient'; +import { env } from './env'; +import { load } from '@lavaclient/spotify'; +import { + ApplicationCommandRegistries, + RegisterBehavior +} from '@sapphire/framework'; +import { ActivityType } from 'discord.js'; + +ApplicationCommandRegistries.setDefaultBehaviorWhenNotIdentical( + RegisterBehavior.Overwrite +); + +if (env.SPOTIFY_CLIENT_ID && env.SPOTIFY_CLIENT_SECRET) { + load({ + client: { + id: env.SPOTIFY_CLIENT_ID, + secret: env.SPOTIFY_CLIENT_SECRET + }, + autoResolveYoutubeTracks: true + }); +} + +const client = new ExtendedClient(); + +client.on('ready', () => { + client.music.connect(client.user!.id); + client.user?.setActivity('/', { + type: ActivityType.Watching + }); + + client.user?.setStatus('online'); +}); + +client.on('chatInputCommandError', err => { + console.log('Command Chat Input ' + err); +}); +client.on('contextMenuCommandError', err => { + console.log('Command Context Menu ' + err); +}); +client.on('commandAutocompleteInteractionError', err => { + console.log('Command Autocomplete ' + err); +}); +client.on('commandApplicationCommandRegistryError', err => { + console.log('Command Registry ' + err); +}); +client.on('messageCommandError', err => { + console.log('Command ' + err); +}); +client.on('interactionHandlerError', err => { + console.log('Interaction ' + err); +}); +client.on('interactionHandlerParseError', err => { + console.log('Interaction Parse ' + err); +}); + +client.on('listenerError', err => { + console.log('Client Listener ' + err); +}); + +// LavaLink +client.music.on('error', err => { + console.log('LavaLink ' + err); +}); + +const main = async () => { + try { + await client.login(env.DISCORD_TOKEN); + } catch (error) { + console.log('Bot errored out', error); + client.destroy(); + process.exit(1); + } +}; + +void main(); diff --git a/apps/bot/src/lib/constants.ts b/apps/bot/src/lib/constants.ts new file mode 100644 index 0000000..97147f0 --- /dev/null +++ b/apps/bot/src/lib/constants.ts @@ -0,0 +1,4 @@ +import { join } from 'path'; + +export const rootDir = join(__dirname, '..', '..'); +export const srcDir = join(rootDir, 'src'); diff --git a/apps/bot/src/lib/logger.ts b/apps/bot/src/lib/logger.ts new file mode 100644 index 0000000..04c2e2f --- /dev/null +++ b/apps/bot/src/lib/logger.ts @@ -0,0 +1,54 @@ +import winston from 'winston'; +import 'winston-daily-rotate-file'; + +const levels = { + error: 0, + warn: 1, + info: 2, + http: 3, + debug: 4 +}; + +const level = () => { + const env = process.env.NODE_ENV || 'development'; + const isDevelopment = env === 'development'; + return isDevelopment ? 'debug' : 'warn'; +}; + +const colors = { + error: 'red', + warn: 'yellow', + info: 'green', + http: 'magenta', + debug: 'white' +}; + +winston.addColors(colors); + +const format = winston.format.combine( + winston.format.timestamp({ format: 'MM-DD-YYYY HH:mm:ss' }), + // winston.format.colorize({ level: true }), + winston.format.printf( + info => `${info.timestamp} ${info.level}: ${info.message}` + ) +); +const transports = [ + new winston.transports.Console(), + new winston.transports.DailyRotateFile({ + dirname: './logs', + filename: 'Master-Bot-%DATE%.log', + datePattern: 'MM-DD-YYYY', + zippedArchive: true, + maxSize: '20m', + maxFiles: '14d' + }) +]; + +const Logger = winston.createLogger({ + level: level(), + levels, + format, + transports +}); + +export default Logger; diff --git a/apps/bot/src/lib/music/buttonHandler.ts b/apps/bot/src/lib/music/buttonHandler.ts new file mode 100644 index 0000000..0c8f2f4 --- /dev/null +++ b/apps/bot/src/lib/music/buttonHandler.ts @@ -0,0 +1,64 @@ +import type { Song } from './classes/Song'; +import { container } from '@sapphire/framework'; +import type { Queue } from './classes/Queue'; +import { + Message, + ActionRowBuilder, + ButtonBuilder, + EmbedBuilder, + ButtonStyle +} from 'discord.js'; +import buttonsCollector, { deletePlayerEmbed } from './buttonsCollector'; + +export async function embedButtons( + embed: EmbedBuilder, + queue: Queue, + song: Song, + message?: string +) { + await deletePlayerEmbed(queue); + + const { client } = container; + const tracks = await queue.tracks(); + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('playPause') + .setLabel('Play/Pause') + .setStyle(ButtonStyle.Primary), + new ButtonBuilder() + .setCustomId('stop') + .setLabel('Stop') + .setStyle(ButtonStyle.Danger), + new ButtonBuilder() + .setCustomId('next') + .setLabel('Next') + .setStyle(ButtonStyle.Primary) + .setDisabled(!tracks.length ? true : false), + new ButtonBuilder() + .setCustomId('volumeUp') + .setLabel('Vol+') + .setStyle(ButtonStyle.Primary), + new ButtonBuilder() + .setCustomId('volumeDown') + .setLabel('Vol-') + .setStyle(ButtonStyle.Primary) + ); + + const channel = await queue.getTextChannel(); + if (!channel) return; + + return await channel + .send({ + embeds: [embed], + components: [row], + content: message + }) + .then(async (message: Message) => { + const queue = client.music.queues.get(message.guild!.id); + await queue.setEmbed(message.id); + + if (queue.player) { + await buttonsCollector(message, song); + } + }); +} diff --git a/apps/bot/src/lib/music/buttonsCollector.ts b/apps/bot/src/lib/music/buttonsCollector.ts new file mode 100644 index 0000000..4ff232a --- /dev/null +++ b/apps/bot/src/lib/music/buttonsCollector.ts @@ -0,0 +1,131 @@ +import { Time } from '@sapphire/time-utilities'; +import type { Message, MessageComponentInteraction } from 'discord.js'; +import { container } from '@sapphire/framework'; +import type { Queue } from './classes/Queue'; +import { NowPlayingEmbed } from './nowPlayingEmbed'; +import type { Song } from './classes/Song'; +import Logger from '../logger'; + +export default async function buttonsCollector(message: Message, song: Song) { + const { client } = container; + + const queue = client.music.queues.get(message.guildId!); + const channel = await queue.getTextChannel(); + + const collector = message.createMessageComponentCollector(); + if (!channel) return; + + const maxLimit = Time.Minute * 30; + let timer: NodeJS.Timer; + + collector.on('collect', async (i: MessageComponentInteraction) => { + if (!message.member?.voice.channel?.members.has(i.user.id)) { + await i.reply({ + content: `:x: Only available to members in ${message.member?.voice.channel} <-- Click To Join`, + ephemeral: true + }); + return; + } + + if (i.customId === 'playPause') { + if (queue.paused) { + await queue.resume(); + clearTimeout(client.leaveTimers[queue.guildID]!); + } else { + client.leaveTimers[queue.guildID] = setTimeout(async () => { + await channel.send(':zzz: Leaving due to inactivity'); + await queue.leave(); + }, maxLimit); + await queue.pause(); + } + + const tracks = await queue.tracks(); + const NowPlaying = new NowPlayingEmbed( + song, + queue.player.accuratePosition, + queue.player.trackData?.length ?? 0, + queue.player.volume, + tracks, + tracks.at(-1), + queue.player.paused + ); + collector.empty(); + await i.update({ + embeds: [await NowPlaying.NowPlayingEmbed()] + }); + return; + } + if (i.customId === 'stop') { + clearTimeout(timer); + await queue.leave(); + return; + } + if (i.customId === 'next') { + clearTimeout(timer); + await queue.next({ skipped: true }); + return; + } + if (i.customId === 'volumeUp') { + const currentVolume = await queue.getVolume(); + const volume = currentVolume + 10 > 200 ? 200 : currentVolume + 10; + await queue.setVolume(volume); + const tracks = await queue.tracks(); + const NowPlaying = new NowPlayingEmbed( + song, + queue.player.accuratePosition, + queue.player.trackData?.length ?? 0, + queue.player.volume, + tracks, + tracks.at(-1), + queue.player.paused + ); + collector.empty(); + await i.update({ + embeds: [await NowPlaying.NowPlayingEmbed()] + }); + return; + } + if (i.customId === 'volumeDown') { + const currentVolume = await queue.getVolume(); + const volume = currentVolume - 10 < 0 ? 0 : currentVolume - 10; + await queue.setVolume(volume); + const tracks = await queue.tracks(); + const NowPlaying = new NowPlayingEmbed( + song, + queue.player.accuratePosition, + queue.player.trackData?.length ?? 0, + queue.player.volume, + tracks, + tracks.at(-1), + queue.player.paused + ); + collector.empty(); + await i.update({ embeds: [await NowPlaying.NowPlayingEmbed()] }); + return; + } + }); + + collector.on('end', async () => { + clearTimeout(timer); + }); + + return collector; +} + +export async function deletePlayerEmbed(queue: Queue) { + try { + const embedID = await queue.getEmbed(); + if (embedID) { + const channel = await queue.getTextChannel(); + await channel?.messages.fetch(embedID).then(async oldMessage => { + if (oldMessage) + await oldMessage.delete().catch(error => { + Logger.error('Failed to Delete Old Message. ' + error); + }); + await queue.deleteEmbed(); + }); + } + } catch (error) { + Logger.error('Failed to Delete Player Embed. ' + error); + } +} diff --git a/apps/bot/src/lib/music/channelHandler.ts b/apps/bot/src/lib/music/channelHandler.ts new file mode 100644 index 0000000..4439fd5 --- /dev/null +++ b/apps/bot/src/lib/music/channelHandler.ts @@ -0,0 +1,58 @@ +import type { Queue } from './classes/Queue'; +import { Channel, GuildMember, ChannelType } from 'discord.js'; +import Logger from '../logger'; + +export async function manageStageChannel( + voiceChannel: Channel, + botUser: GuildMember, + instance: Queue +) { + if (voiceChannel.type !== ChannelType.GuildStageVoice) return; + // Stage Channel Permissions From Discord.js Doc's + if ( + !botUser?.permissions.has( + ('ManageChannels' && 'MuteMembers' && 'MoveMembers') || 'ADMINISTRATOR' + ) + ) + if (botUser.voice.suppress) + return await instance.getTextChannel().then( + async msg => + await msg?.send({ + content: `:interrobang: Please make promote me to a Speaker in ${voiceChannel.name}, Missing permissions "Administrator" ***OR*** "Manage Channels, Mute Members, and Move Members" for Full Stage Channel Features.` + }) + ); + const tracks = await instance.tracks(); + const title = + instance.player.trackData?.title.length! > 114 + ? `🎶 ${ + instance.player.trackData?.title.slice(0, 114) ?? + tracks.at(0)?.title.slice(0, 114) + }...` + : `🎶 ${instance.player.trackData?.title ?? tracks.at(0)?.title ?? ''}`; + + if (!voiceChannel.stageInstance) { + await voiceChannel + .createStageInstance({ + topic: title, + privacyLevel: 2 // Guild Only + }) + .catch(error => { + Logger.error('Failed to Create a Stage Instance. ' + error); + }); + } + + if (botUser?.voice.suppress) + await botUser?.voice.setSuppressed(false).catch((error: string) => { + Logger.error('Failed to Set Suppressed to False. ' + error); + }); + + if ( + voiceChannel.stageInstance?.topic.startsWith('🎶') && + voiceChannel.stageInstance?.topic !== title + ) { + await voiceChannel.stageInstance?.setTopic(title).catch(error => { + Logger.error('Failed to Set Topic. ' + error); + }); + } + return; +} diff --git a/apps/bot/src/lib/music/classes/Queue.ts b/apps/bot/src/lib/music/classes/Queue.ts new file mode 100644 index 0000000..95eb498 --- /dev/null +++ b/apps/bot/src/lib/music/classes/Queue.ts @@ -0,0 +1,471 @@ +// Inspired from skyra's queue(when it had a music feature) +import type { + CommandInteraction, + Guild, + GuildMember, + TextChannel, + VoiceChannel +} from 'discord.js'; +import type { Song } from './Song'; +import type { Track } from '@lavaclient/types/v3'; +import type { DiscordResource, Player, Snowflake } from 'lavaclient'; +import { container } from '@sapphire/framework'; +import type { QueueStore } from './QueueStore'; +import { Time } from '@sapphire/time-utilities'; +import { isNullish } from '@sapphire/utilities'; +import { deletePlayerEmbed } from '../buttonsCollector'; +import { trpcNode } from '../../../trpc'; +import Logger from '../../logger'; + +export enum LoopType { + None, + Queue, + Song +} + +const kExpireTime = Time.Day * 2; + +export interface QueueEvents { + trackStart: (song: Song) => void; + trackEnd: (song: Song) => void; + finish: () => void; +} + +export interface Loop { + type: LoopType; + current: number; + max: number; +} + +export interface AddOptions { + requester?: Snowflake | DiscordResource; + userInfo?: GuildMember; + added?: number; + next?: boolean; +} + +export type Addable = string | Track | Song; + +interface NowPlaying { + song: Song; + position: number; +} + +interface QueueKeys { + readonly next: string; + readonly position: string; + readonly current: string; + readonly skips: string; + readonly systemPause: string; + readonly replay: string; + readonly volume: string; + readonly text: string; + readonly embed: string; +} + +export class Queue { + public readonly keys: QueueKeys; + private skipped: boolean; + + public constructor( + public readonly store: QueueStore, + public readonly guildID: string + ) { + this.keys = { + current: `music.${this.guildID}.current`, + next: `music.${this.guildID}.next`, + position: `music.${this.guildID}.position`, + skips: `music.${this.guildID}.skips`, + systemPause: `music.${this.guildID}.systemPause`, + replay: `music.${this.guildID}.replay`, + volume: `music.${this.guildID}.volume`, + text: `music.${this.guildID}.text`, + embed: `music.${this.guildID}.embed` + }; + + this.skipped = false; + } + + public get client() { + return container.client; + } + + public get player(): Player { + return this.store.client.players.get(this.guildID)!; + } + + public get playing(): boolean { + return this.player.playing; + } + + public get paused(): boolean { + return this.player.paused; + } + + public get guild(): Guild { + return this.client.guilds.cache.get(this.guildID) as Guild; + } + + public get voiceChannel(): VoiceChannel | null { + const id = this.voiceChannelID; + return id + ? (this.guild.channels.cache.get(id) as VoiceChannel) ?? null + : null; + } + + public get voiceChannelID(): string | null { + if (!this.player) return null; + return this.player.channelId ?? null; + } + + public createPlayer(): Player { + let player = this.player; + if (!player) { + player = this.store.client.createPlayer(this.guildID); + player.on('trackEnd', async () => { + if (!this.skipped) { + await this.next(); + } + this.skipped = false; + }); + } + return player; + } + + public destroyPlayer(): void { + if (this.player) { + this.store.client.destroyPlayer(this.guildID); + } + } + + // Start the queue + public async start(replaying = false): Promise { + const np = await this.nowPlaying(); + if (!np) return this.next(); + + try { + this.player.setVolume(await this.getVolume()); + await this.player.play(np.song as Song); + } catch (err) { + Logger.error(err); + await this.leave(); + } + + this.client.emit( + replaying ? 'musicSongReplay' : 'musicSongPlay', + this, + np.song as Song + ); + return true; + } + + // Returns whether or not there are songs that can be played + public async canStart(): Promise { + return ( + (await this.store.redis.exists(this.keys.current, this.keys.next)) > 0 + ); + } + + // add tracks to queue + public async add( + songs: Song | Array, + options: AddOptions = {} + ): Promise { + songs = Array.isArray(songs) ? songs : [songs]; + if (!songs.length) return 0; + + await this.store.redis.lpush( + this.keys.next, + ...songs.map(song => this.stringifySong(song)) + ); + await this.refresh(); + return songs.length; + } + + public async pause(interaction?: CommandInteraction) { + await this.player.pause(true); + await this.setSystemPaused(false); + if (interaction) { + this.client.emit('musicSongPause', interaction); + } + } + + public async resume(interaction?: CommandInteraction) { + await this.player.pause(false); + await this.setSystemPaused(false); + if (interaction) { + this.client.emit('musicSongResume', interaction); + } + } + + public async getSystemPaused(): Promise { + return await this.store.redis + .get(this.keys.systemPause) + .then(d => d === '1'); + } + + public async setSystemPaused(value: boolean): Promise { + await this.store.redis.set(this.keys.systemPause, value ? '1' : '0'); + await this.refresh(); + return value; + } + + /** + * Retrieves whether or not the system should repeat the current track. + */ + public async getReplay(): Promise { + return await this.store.redis.get(this.keys.replay).then(d => d === '1'); + } + + public async setReplay(value: boolean): Promise { + await this.store.redis.set(this.keys.replay, value ? '1' : '0'); + await this.refresh(); + this.client.emit('musicReplayUpdate', this, value); + return value; + } + + /** + * Retrieves the volume of the track in the queue. + */ + + public async getVolume(): Promise { + let data = await this.store.redis.get(this.keys.volume); + + if (!data) { + const guildQuery = await trpcNode.guild.getGuild.query({ + id: this.guildID + }); + + if (!guildQuery || !guildQuery.guild) + await this.setVolume(this.player.volume ?? 100); // saves to both + + if (guildQuery.guild) + data = + guildQuery.guild.volume.toString() || this.player.volume.toString(); + } + + return data ? Number(data) : 100; + } + + // set the volume of the track in the queue + public async setVolume( + value: number + ): Promise<{ previous: number; next: number }> { + await this.player.setVolume(value); + const previous = await this.store.redis.getset(this.keys.volume, value); + await this.refresh(); + + await trpcNode.guild.updateVolume.mutate({ + guildId: this.guildID, + volume: this.player.volume + }); + + this.client.emit('musicSongVolumeUpdate', this, value); + return { + previous: previous === null ? 100 : Number(previous), + next: value + }; + } + + public async seek(position: number): Promise { + await this.player.seek(position); + } + + // connect to a voice channel + public async connect(channelID: string): Promise { + await this.player.connect(channelID, { deafened: true }); + } + + // leave the voice channel + public async leave(): Promise { + if (await this.getEmbed()) { + await deletePlayerEmbed(this); + } + if (this.client.leaveTimers[this.guildID]) { + clearTimeout(this.client.leaveTimers[this.player.guildId]); + delete this.client.leaveTimers[this.player.guildId]; + } + if (!this.player) return; + await this.player.disconnect(); + await this.destroyPlayer(); + await this.setTextChannelID(null); + await this.clear(); + } + + public async getTextChannel(): Promise { + const id = await this.getTextChannelID(); + if (id === null) return null; + + const channel = this.guild.channels.cache.get(id) ?? null; + if (channel === null) { + await this.setTextChannelID(null); + return null; + } + + return channel as TextChannel; + } + + public getTextChannelID(): Promise { + return this.store.redis.get(this.keys.text); + } + + public setTextChannelID(channelID: null): Promise; + + public async setTextChannelID(channelID: string): Promise; + public async setTextChannelID( + channelID: string | null + ): Promise { + if (channelID === null) { + await this.store.redis.del(this.keys.text); + } else { + await this.store.redis.set(this.keys.text, channelID); + await this.refresh(); + } + + return channelID; + } + + public async getCurrentTrack(): Promise { + const value = await this.store.redis.get(this.keys.current); + return value ? this.parseSongString(value) : null; + } + + public async getAt(index: number): Promise { + const value = await this.store.redis.lindex(this.keys.next, -index - 1); + return value ? this.parseSongString(value) : undefined; + } + + public async removeAt(position: number): Promise { + await this.store.redis.lremat(this.keys.next, -position - 1); + await this.refresh(); + } + + public async next({ skipped = false } = {}): Promise { + if (skipped) this.skipped = true; + // Sets the current position to 0. + await this.store.redis.del(this.keys.position); + + // Get whether or not the queue is on replay mode. + const replaying = await this.getReplay(); + + // If not skipped (song ended) and is replaying, replay. + if (!skipped && replaying) { + return await this.start(true); + } + + // If it was skipped, set replay back to false. + if (replaying) await this.setReplay(false); + + // Removes the next entry from the list and sets it as the current track. + const entry = await this.store.redis.rpopset( + this.keys.next, + this.keys.current + ); + // If there was an entry to play, refresh the state and start playing. + if (entry) { + await this.refresh(); + return this.start(false); + } else { + // If there was no entry, disconnect from the voice channel. + await this.leave(); + this.client.emit('musicFinish', this, true); + return false; + } + } + + public count(): Promise { + return this.store.redis.llen(this.keys.next); + } + + public async moveTracks(from: number, to: number): Promise { + await this.store.redis.lmove(this.keys.next, -from - 1, -to - 1); // work from the end of the list, since it's reversed + await this.refresh(); + } + + public async shuffleTracks(): Promise { + await this.store.redis.lshuffle(this.keys.next, Date.now()); + await this.refresh(); + } + + public async stop(): Promise { + await this.player.stop(); + } + + public async clearTracks(): Promise { + await this.store.redis.del(this.keys.next); + } + + public async skipTo(position: number): Promise { + await this.store.redis.ltrim(this.keys.next, 0, position - 1); + await this.next({ skipped: true }); + } + + public refresh() { + return this.store.redis + .pipeline() + .pexpire(this.keys.next, kExpireTime) + .pexpire(this.keys.position, kExpireTime) + .pexpire(this.keys.current, kExpireTime) + .pexpire(this.keys.skips, kExpireTime) + .pexpire(this.keys.systemPause, kExpireTime) + .pexpire(this.keys.replay, kExpireTime) + .pexpire(this.keys.volume, kExpireTime) + .pexpire(this.keys.text, kExpireTime) + .pexpire(this.keys.embed, kExpireTime) + .exec(); + } + + public clear(): Promise { + return this.store.redis.del( + this.keys.next, + this.keys.position, + this.keys.current, + this.keys.skips, + this.keys.systemPause, + this.keys.replay, + this.keys.volume, + this.keys.text, + this.keys.embed + ); + } + + public async nowPlaying(): Promise { + const [entry, position] = await Promise.all([ + this.getCurrentTrack(), + this.store.redis.get(this.keys.position) + ]); + if (entry === null) return null; + + return { + song: entry, + position: isNullish(position) ? 0 : parseInt(position, 10) + }; + } + + public async tracks(start = 0, end = -1): Promise { + if (end === Infinity) end = -1; + + const tracks = await this.store.redis.lrange(this.keys.next, start, end); + return [...tracks].map(this.parseSongString).reverse(); + } + + public async setEmbed(id: string): Promise { + await this.store.redis.set(this.keys.embed, id); + } + + public async getEmbed(): Promise { + return this.store.redis.get(this.keys.embed); + } + + public async deleteEmbed(): Promise { + await this.store.redis.del(this.keys.embed); + } + + public stringifySong(song: Song): string { + return JSON.stringify(song); + } + + public parseSongString(song: string): Song { + return JSON.parse(song); + } +} diff --git a/apps/bot/src/lib/music/classes/QueueClient.ts b/apps/bot/src/lib/music/classes/QueueClient.ts new file mode 100644 index 0000000..55191d8 --- /dev/null +++ b/apps/bot/src/lib/music/classes/QueueClient.ts @@ -0,0 +1,30 @@ +import Redis from 'ioredis'; +import type { RedisOptions } from 'ioredis'; +import { ConnectionInfo, Node, SendGatewayPayload } from 'lavaclient'; +import { QueueStore } from './QueueStore'; + +export interface QueueClientOptions { + redis: Redis | RedisOptions; +} + +export interface ConstructorTypes { + options: QueueClientOptions; + sendGatewayPayload: SendGatewayPayload; + connection: ConnectionInfo; +} + +export class QueueClient extends Node { + public readonly queues: QueueStore; + + public constructor({ + options, + sendGatewayPayload, + connection + }: ConstructorTypes) { + super({ ...options, sendGatewayPayload, connection }); + this.queues = new QueueStore( + this, + options.redis instanceof Redis ? options.redis : new Redis(options.redis) + ); + } +} diff --git a/apps/bot/src/lib/music/classes/QueueStore.ts b/apps/bot/src/lib/music/classes/QueueStore.ts new file mode 100644 index 0000000..2d00adc --- /dev/null +++ b/apps/bot/src/lib/music/classes/QueueStore.ts @@ -0,0 +1,107 @@ +import { Collection } from 'discord.js'; +import { readFileSync } from 'fs'; +import type { Redis, RedisKey } from 'ioredis'; +import { join, resolve } from 'path'; +import { Queue } from './Queue'; +import type { QueueClient } from './QueueClient'; +import Logger from '../../logger'; + +interface RedisCommand { + name: string; + keys: number; +} + +const commands: RedisCommand[] = [ + { + name: 'lmove', + keys: 1 + }, + { + name: 'lremat', + keys: 1 + }, + { + name: 'lshuffle', + keys: 1 + }, + { + name: 'rpopset', + keys: 2 + } +]; + +//@ts-ignore +export interface ExtendedRedis extends Redis { + lmove: (key: RedisKey, from: number, to: number) => Promise<'OK'>; + lremat: (key: RedisKey, index: number) => Promise<'OK'>; + lshuffle: (key: RedisKey, seed: number) => Promise<'OK'>; + rpopset: (source: RedisKey, destination: RedisKey) => Promise; +} + +export class QueueStore extends Collection { + public redis: ExtendedRedis; + + public constructor( + public readonly client: QueueClient, + redis: Redis + ) { + super(); + this.redis = redis as any; + // Redis Errors + redis.on('error', err => { + Logger.error('Redis ' + err); + }); + + for (const command of commands) { + this.redis.defineCommand(command.name, { + numberOfKeys: command.keys, + lua: readFileSync( + resolve( + join(__dirname, '..', '..', '..'), + 'audio', + `${command.name}.lua` + ) + ).toString() + }); + } + } + + public get(key: string): Queue { + let queue = super.get(key); + if (!queue) { + queue = new Queue(this, key); + this.set(key, queue); + } + return queue; + } + + public async start() { + const guilds = await this.getPlayingEntries(); + await Promise.all(guilds.map(guild => this.get(guild).start())); + } + + private async getPlayingEntries(): Promise { + const guilds = new Set(); + + let cursor = '0'; + do { + // `scan` returns a tuple with the next cursor (which must be used for the + // next iteration) and an array of the matching keys. The iterations end when + // cursor becomes '0' again. + const response = await this.redis.scan( + cursor, + 'MATCH', + 'music.*.position' + ); + [cursor] = response; + + for (const key of response[1]) { + // Slice 'skyra.a.' from the start, and '.p' from the end: + const id = key.slice(8, -2); + guilds.add(id); + } + } while (cursor !== '0'); + + return [...guilds]; + } +} diff --git a/apps/bot/src/lib/music/classes/Song.ts b/apps/bot/src/lib/music/classes/Song.ts new file mode 100644 index 0000000..75e70e4 --- /dev/null +++ b/apps/bot/src/lib/music/classes/Song.ts @@ -0,0 +1,94 @@ +import { decode } from '@lavalink/encoding'; +import type { Track, TrackInfo } from '@lavaclient/types/v3'; +import * as MetadataFilter from 'metadata-filter'; + +export class Song implements TrackInfo { + readonly track: string; + requester?: RequesterInfo; + length: number; + identifier: string; + author: string; + isStream: boolean; + position: number; + title: string; + uri: string; + isSeekable: boolean; + sourceName: string; + thumbnail: string; + added: number; + + constructor( + track: string | Track, + added?: number, + requester?: RequesterInfo + ) { + this.track = typeof track === 'string' ? track : track.track; + this.requester = requester; + this.added = added ?? Date.now(); + const filterSet = { + song: [ + MetadataFilter.removeVersion, + MetadataFilter.removeRemastered, + MetadataFilter.fixTrackSuffix, + MetadataFilter.removeLive, + MetadataFilter.youtube, + MetadataFilter.normalizeFeature + ] + }; + const filter = MetadataFilter.createFilter(filterSet); + + // TODO: make this less shitty + if (typeof track !== 'string') { + this.length = track.info.length; + this.identifier = track.info.identifier; + this.author = track.info.author; + this.isStream = track.info.isStream; + this.position = track.info.position; + this.title = filter.filterField('song', track.info.title); + this.uri = track.info.uri; + this.isSeekable = track.info.isSeekable; + this.sourceName = track.info.sourceName; + } else { + const decoded = decode(this.track); + this.length = Number(decoded.length); + this.identifier = decoded.identifier; + this.author = decoded.author; + this.isStream = decoded.isStream; + this.position = Number(decoded.position); + this.title = filter.filterField('song', decoded.title); + this.uri = decoded.uri!; + this.isSeekable = !decoded.isStream; + this.sourceName = decoded.source; + } + + // Thumbnails + switch (this.sourceName) { + case 'soundcloud': { + this.thumbnail = + 'https://a-v2.sndcdn.com/assets/images/sc-icons/fluid-b4e7a64b8b.png'; // SoundCloud Logo + break; + } + + case 'youtube': { + this.thumbnail = `https://img.youtube.com/vi/${this.identifier}/hqdefault.jpg`; // Track Thumbnail + break; + } + case 'twitch': { + this.thumbnail = 'https://i.imgur.com/nO3f4jq.png'; // large Twitch Logo + break; + } + + default: { + this.thumbnail = 'https://cdn.discordapp.com/embed/avatars/1.png'; // Discord Default Avatar + break; + } + } + } +} + +interface RequesterInfo { + avatar?: string | null; + defaultAvatarURL?: string; + id?: string; + name?: string; +} diff --git a/apps/bot/src/lib/music/nowPlayingEmbed.ts b/apps/bot/src/lib/music/nowPlayingEmbed.ts new file mode 100644 index 0000000..879fda6 --- /dev/null +++ b/apps/bot/src/lib/music/nowPlayingEmbed.ts @@ -0,0 +1,205 @@ +// import { container } from '@sapphire/framework'; +import { ColorResolvable, EmbedBuilder } from 'discord.js'; +import progressbar from 'string-progressbar'; +import type { Song } from './classes/Song'; + +type PositionType = number | undefined; + +export class NowPlayingEmbed { + track: Song; + position: PositionType; + length: number; + volume: number; + queue?: Song[]; + last?: Song; + paused?: Boolean; + + public constructor( + track: Song, + position: PositionType, + length: number, + volume: number, + queue?: Song[], + last?: Song, + paused?: Boolean + ) { + this.track = track; + this.position = position; + this.length = length; + this.volume = volume; + this.queue = queue; + this.last = last; + this.paused = paused; + } + + public async NowPlayingEmbed(): Promise { + let trackLength = this.timeString( + this.millisecondsToTimeObject(this.length) + ); + + const durationText = this.track.isSeekable + ? `:stopwatch: ${trackLength}` + : `:red_circle: Live Stream`; + const userAvatar = this.track.requester?.avatar + ? `https://cdn.discordapp.com/avatars/${this.track.requester?.id}/${this.track.requester?.avatar}.png` + : this.track.requester?.defaultAvatarURL ?? + 'https://cdn.discordapp.com/embed/avatars/1.png'; // default Discord Avatar + + let embedColor: ColorResolvable; + let sourceTxt: string; + let sourceIcon: string; + let streamData; + + switch (this.track.sourceName) { + case 'soundcloud': { + sourceTxt = 'SoundCloud'; + sourceIcon = + 'https://a-v2.sndcdn.com/assets/images/sc-icons/fluid-b4e7a64b8b.png'; + embedColor = '#F26F23'; + break; + } + + // case 'twitch': { + // sourceTxt = 'Twitch'; + // sourceIcon = + // 'https://static.twitchcdn.net/assets/favicon-32-e29e246c157142c94346.png'; + // embedColor = 'Purple'; + // const twitch = container.client.twitch; + // if (twitch.auth.access_token) { + // try { + // streamData = await twitch.api.getStream({ + // login: this.track.author.toLowerCase(), + // token: twitch.auth.access_token + // }); + // } catch { + // streamData = undefined; + // } + // } + // break; + // } + + case 'youtube': { + sourceTxt = 'YouTube'; + sourceIcon = + 'https://www.youtube.com/s/desktop/acce624e/img/favicon_32x32.png'; + embedColor = '#FF0000'; + break; + } + + default: { + sourceTxt = 'Somewhere'; + sourceIcon = 'https://cdn.discordapp.com/embed/avatars/1.png'; + embedColor = 'DarkRed'; + break; + } + } + + const vol = this.volume; + let volumeIcon: string = ':speaker: '; + if (vol > 50) volumeIcon = ':loud_sound: '; + if (vol <= 50 && vol > 20) volumeIcon = ':sound: '; + const embedFieldData = [ + { + name: 'Volume', + value: `${volumeIcon} ${this.volume}%`, + inline: true + }, + { name: 'Duration', value: durationText, inline: true } + ]; + + if (this.queue?.length) { + embedFieldData.push( + { + name: 'Queue', + value: `:notes: ${this.queue.length} ${ + this.queue.length == 1 ? 'Song' : 'Songs' + }`, + inline: true + }, + { + name: 'Next', + value: `[${this.queue[0].title}](${this.queue[0].uri})`, + inline: false + } + ); + } + const baseEmbed = new EmbedBuilder() + .setTitle( + `${this.paused ? ':pause_button: ' : ':arrow_forward: '} ${ + this.track.title + }` + ) + .setAuthor({ + name: sourceTxt, + iconURL: sourceIcon + }) + .setURL(this.track.uri) + .setThumbnail(this.track.thumbnail) + .setColor(embedColor) + .addFields(embedFieldData) + .setTimestamp(this.track.added ?? Date.now()) + .setFooter({ + text: `Requested By ${this.track.requester?.name}`, + iconURL: userAvatar + }); + + if (!this.track.isSeekable || this.track.isStream) { + if (streamData && this.track.sourceName == 'twitch') { + const game = `[${ + streamData.game_name + }](https://www.twitch.tv/directory/game/${encodeURIComponent( + streamData.game_name + )})`; + const upTime = this.timeString( + this.millisecondsToTimeObject( + Date.now() - new Date(streamData.started_at).getTime() + ) + ); + return baseEmbed + .setDescription( + `**Game**: ${game}\n**Viewers**: ${ + streamData.viewer_count + }\n**Uptime**: ${upTime}\n **Started**: ` + ) + .setImage( + streamData.thumbnail_url.replace('{width}x{height}', '852x480') + + `?${new Date(streamData.started_at).getTime()}` + ); + } else return baseEmbed; + } + + // song just started embed + if (this.position == undefined) this.position = 0; + const bar = progressbar.splitBar(this.length, this.position, 22)[0]; + baseEmbed.setDescription( + `${this.timeString( + this.millisecondsToTimeObject(this.position) + )} ${bar} ${trackLength}` + ); + + return baseEmbed; + } + + private timeString(timeObject: any) { + if (timeObject[1] === true) return timeObject[0]; + return `${timeObject.hours ? timeObject.hours + ':' : ''}${ + timeObject.minutes ? timeObject.minutes : '00' + }:${ + timeObject.seconds < 10 + ? '0' + timeObject.seconds + : timeObject.seconds + ? timeObject.seconds + : '00' + }`; + } + + private millisecondsToTimeObject(milliseconds: number) { + return { + seconds: Math.floor((milliseconds / 1000) % 60), + minutes: Math.floor((milliseconds / (1000 * 60)) % 60), + hours: Math.floor((milliseconds / (1000 * 60 * 60)) % 24) + }; + } +} diff --git a/apps/bot/src/lib/music/searchSong.ts b/apps/bot/src/lib/music/searchSong.ts new file mode 100644 index 0000000..ba58db4 --- /dev/null +++ b/apps/bot/src/lib/music/searchSong.ts @@ -0,0 +1,109 @@ +import { container } from '@sapphire/framework'; +import { SpotifyItemType } from '@lavaclient/spotify'; +import { Song } from './classes/Song'; +import type { User } from 'discord.js'; + +export default async function searchSong( + query: string, + user: User +): Promise<[string, Song[]]> { + const { client } = container; + let tracks: Song[] = []; + let response; + let displayMessage = ''; + const { avatar, defaultAvatarURL, id, username } = user; + + if (client.music.spotify.isSpotifyUrl(query)) { + const item = await client.music.spotify.load(query); + switch (item?.type) { + case SpotifyItemType.Track: + const track = await item.resolveYoutubeTrack(); + tracks = [ + new Song(track, Date.now(), { + avatar, + defaultAvatarURL, + id, + name: username + }) + ]; + displayMessage = `Queued track [**${item.name}**](${query}).`; + break; + case SpotifyItemType.Artist: + response = await item.resolveYoutubeTracks(); + response.forEach(track => + tracks.push( + new Song(track, Date.now(), { + avatar, + defaultAvatarURL, + id, + name: username + }) + ) + ); + displayMessage = `Queued the **Top ${tracks.length} tracks** for [**${item.name}**](${query}).`; + break; + case SpotifyItemType.Album: + case SpotifyItemType.Playlist: + response = await item.resolveYoutubeTracks(); + response.forEach(track => + tracks.push( + new Song(track, Date.now(), { + avatar, + defaultAvatarURL, + id, + name: username + }) + ) + ); + displayMessage = `Queued **${ + tracks.length + } tracks** from ${SpotifyItemType[item.type].toLowerCase()} [**${ + item.name + }**](${query}).`; + break; + default: + displayMessage = ":x: Couldn't find what you were looking for :("; + return [displayMessage, tracks]; + } + return [displayMessage, tracks]; + } else { + const results = await client.music.rest.loadTracks( + /^https?:\/\//.test(query) ? query : `ytsearch:${query}` + ); + + switch (results.loadType) { + case 'LOAD_FAILED': + case 'NO_MATCHES': + displayMessage = ":x: Couldn't find what you were looking for :("; + return [displayMessage, tracks]; + case 'PLAYLIST_LOADED': + results.tracks.forEach((track: any) => + tracks.push( + new Song(track, Date.now(), { + avatar, + defaultAvatarURL, + id, + name: username + }) + ) + ); + displayMessage = `Queued playlist [**${results.playlistInfo.name}**](${query}), it has a total of **${tracks.length}** tracks.`; + break; + case 'TRACK_LOADED': + case 'SEARCH_RESULT': + const [track] = results.tracks; + tracks = [ + new Song(track, Date.now(), { + avatar, + defaultAvatarURL, + id, + name: username + }) + ]; + displayMessage = `Queued [**${track.info.title}**](${track.info.uri})`; + break; + } + + return [displayMessage, tracks]; + } +} diff --git a/apps/bot/src/lib/setup.ts b/apps/bot/src/lib/setup.ts new file mode 100644 index 0000000..8598206 --- /dev/null +++ b/apps/bot/src/lib/setup.ts @@ -0,0 +1,20 @@ +import { + ApplicationCommandRegistries, + RegisterBehavior +} from '@sapphire/framework'; +import '@sapphire/plugin-api/register'; +import '@sapphire/plugin-editable-commands/register'; +import '@sapphire/plugin-subcommands/register'; +import * as colorette from 'colorette'; +import { inspect } from 'util'; + +// Set default behavior to bulk overwrite +ApplicationCommandRegistries.setDefaultBehaviorWhenNotIdentical( + RegisterBehavior.BulkOverwrite +); + +// Set default inspection depth +inspect.defaultOptions.depth = 1; + +// Enable colorette +colorette.createColors({ useColor: true }); diff --git a/apps/bot/src/lib/structures/ExtendedClient.ts b/apps/bot/src/lib/structures/ExtendedClient.ts new file mode 100644 index 0000000..7d65f76 --- /dev/null +++ b/apps/bot/src/lib/structures/ExtendedClient.ts @@ -0,0 +1,79 @@ +import { SapphireClient } from '@sapphire/framework'; +import '@sapphire/plugin-hmr/register'; +import { QueueClient } from '../music/classes/QueueClient'; +import Redis from 'ioredis'; +import { GatewayDispatchEvents, IntentsBitField } from 'discord.js'; +import { deletePlayerEmbed } from '../music/buttonsCollector'; + +export class ExtendedClient extends SapphireClient { + readonly music: QueueClient; + leaveTimers: { [key: string]: NodeJS.Timer }; + public constructor() { + super({ + intents: [ + IntentsBitField.Flags.Guilds, + IntentsBitField.Flags.GuildMembers, + IntentsBitField.Flags.GuildMessages, + IntentsBitField.Flags.GuildMessageReactions, + IntentsBitField.Flags.GuildVoiceStates + ], + logger: { level: 100 }, + loadMessageCommandListeners: true, + hmr: { + enabled: process.env.NODE_ENV === 'development' + } + }); + + this.music = new QueueClient({ + sendGatewayPayload: (id, payload) => + this.guilds.cache.get(id)?.shard?.send(payload), + options: { + redis: new Redis({ + host: process.env.REDIS_HOST || 'localhost', + port: Number.parseInt(process.env.REDIS_PORT!) || 6379, + password: process.env.REDIS_PASSWORD || '', + db: Number.parseInt(process.env.REDIS_DB!) || 0 + }) + }, + connection: { + host: process.env.LAVA_HOST || '', + password: process.env.LAVA_PASS || '', + port: process.env.LAVA_PORT ? +process.env.LAVA_PORT : 1339, + secure: process.env.LAVA_SECURE === 'true' ? true : false + } + }); + + this.ws.on(GatewayDispatchEvents.VoiceServerUpdate, async data => { + await this.music.handleVoiceUpdate(data); + }); + + this.ws.on(GatewayDispatchEvents.VoiceStateUpdate, async data => { + // handle if a mod right-clicks disconnect on the bot + if (!data.channel_id && data.user_id == this.application?.id) { + const queue = this.music.queues.get(data.guild_id); + await deletePlayerEmbed(queue); + await queue.clear(); + queue.destroyPlayer(); + } + await this.music.handleVoiceUpdate(data); + }); + + this.leaveTimers = {}; + } +} + +declare module '@sapphire/framework' { + interface SapphireClient { + readonly music: QueueClient; + leaveTimers: { [key: string]: NodeJS.Timer }; + } +} + +declare module 'lavaclient' { + interface Player { + nightcore: boolean; + vaporwave: boolean; + karaoke: boolean; + bassboost: boolean; + } +} diff --git a/apps/bot/src/listeners/commandDenied.ts b/apps/bot/src/listeners/commandDenied.ts new file mode 100644 index 0000000..1b38931 --- /dev/null +++ b/apps/bot/src/listeners/commandDenied.ts @@ -0,0 +1,24 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { + type ChatInputCommandDeniedPayload, + Listener, + type ListenerOptions, + UserError +} from '@sapphire/framework'; + +@ApplyOptions({ + name: 'chatInputCommandDenied' +}) +export class CommandDeniedListener extends Listener { + public override async run( + { context, message: content }: UserError, + { interaction }: ChatInputCommandDeniedPayload + ): Promise { + await interaction.reply({ + ephemeral: true, + content: content + }); + + return; + } +} diff --git a/apps/bot/src/listeners/guild/guildCreate.ts b/apps/bot/src/listeners/guild/guildCreate.ts new file mode 100644 index 0000000..a3da05f --- /dev/null +++ b/apps/bot/src/listeners/guild/guildCreate.ts @@ -0,0 +1,24 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Listener, type ListenerOptions } from '@sapphire/framework'; +import type { Guild } from 'discord.js'; +import { trpcNode } from '../../trpc'; + +@ApplyOptions({ + name: 'guildCreate' +}) +export class GuildCreateListener extends Listener { + public override async run(guild: Guild): Promise { + const owner = await guild.fetchOwner(); + + await trpcNode.user.create.mutate({ + id: owner.id, + name: owner.user.username + }); + + await trpcNode.guild.create.mutate({ + id: guild.id, + name: guild.name, + ownerId: owner.id + }); + } +} diff --git a/apps/bot/src/listeners/guild/guildDelete.ts b/apps/bot/src/listeners/guild/guildDelete.ts new file mode 100644 index 0000000..cf90a64 --- /dev/null +++ b/apps/bot/src/listeners/guild/guildDelete.ts @@ -0,0 +1,15 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Listener, type ListenerOptions } from '@sapphire/framework'; +import type { Guild } from 'discord.js'; +import { trpcNode } from '../../trpc'; + +@ApplyOptions({ + name: 'guildDelete' +}) +export class GuildDeleteListener extends Listener { + public override async run(guild: Guild): Promise { + await trpcNode.guild.delete.mutate({ + id: guild.id + }); + } +} diff --git a/apps/bot/src/listeners/guild/guildMemberAdd.ts b/apps/bot/src/listeners/guild/guildMemberAdd.ts new file mode 100644 index 0000000..9d63108 --- /dev/null +++ b/apps/bot/src/listeners/guild/guildMemberAdd.ts @@ -0,0 +1,38 @@ +//import type { Guild } from '@prisma/client'; +import { ApplyOptions } from '@sapphire/decorators'; +import { Listener, type ListenerOptions } from '@sapphire/framework'; +import type { GuildMember, TextChannel } from 'discord.js'; +import { trpcNode } from '../../trpc'; + +@ApplyOptions({ + name: 'guildMemberAdd' +}) +export class GuildMemberListener extends Listener { + public override async run(member: GuildMember): Promise { + const guildQuery = await trpcNode.guild.getGuild.query({ + id: member.guild.id + }); + + if (!guildQuery || !guildQuery.guild) return; + + const { welcomeMessage, welcomeMessageEnabled, welcomeMessageChannel } = + guildQuery.guild; + + if ( + !welcomeMessageEnabled || + !welcomeMessage || + !welcomeMessage.length || + !welcomeMessageChannel + ) { + return; + } + + const channel = (await member.guild.channels.fetch( + welcomeMessageChannel + )) as TextChannel; + + if (channel) { + await channel.send({ content: `@${member.id} ${welcomeMessage}` }); + } + } +} diff --git a/apps/bot/src/listeners/music/musicFinish.ts b/apps/bot/src/listeners/music/musicFinish.ts new file mode 100644 index 0000000..0e009fb --- /dev/null +++ b/apps/bot/src/listeners/music/musicFinish.ts @@ -0,0 +1,25 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Listener, type ListenerOptions, container } from '@sapphire/framework'; +import { deletePlayerEmbed } from '../../lib/music/buttonsCollector'; +import type { Queue } from '../../lib/music/classes/Queue'; +// import { inactivityTime } from '../../lib/music/handleOptions'; + +@ApplyOptions({ + name: 'musicFinish' +}) +export class MusicFinishListener extends Listener { + public override async run( + queue: Queue, + skipped: boolean = false + ): Promise { + const channel = await queue.getTextChannel(); + const { client } = container; + await deletePlayerEmbed(queue); + if (skipped) return; + client.leaveTimers[queue.player.guildId] = setTimeout(async () => { + if (channel) queue.client.emit('musicFinishNotify', channel); + await queue.leave(); + // }, inactivityTime()); + }, 30000); + } +} diff --git a/apps/bot/src/listeners/music/musicFinishNotify.ts b/apps/bot/src/listeners/music/musicFinishNotify.ts new file mode 100644 index 0000000..6779671 --- /dev/null +++ b/apps/bot/src/listeners/music/musicFinishNotify.ts @@ -0,0 +1,12 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Listener, type ListenerOptions } from '@sapphire/framework'; +import type { TextChannel } from 'discord.js'; + +@ApplyOptions({ + name: 'musicFinishNotify' +}) +export class MusicFinishNotifyListener extends Listener { + public override async run(channel: TextChannel): Promise { + await channel.send({ content: ':zzz: Leaving due to inactivity' }); + } +} diff --git a/apps/bot/src/listeners/music/musicSongPause.ts b/apps/bot/src/listeners/music/musicSongPause.ts new file mode 100644 index 0000000..97b8bc2 --- /dev/null +++ b/apps/bot/src/listeners/music/musicSongPause.ts @@ -0,0 +1,15 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Listener, type ListenerOptions } from '@sapphire/framework'; +import type { ChatInputCommandInteraction } from 'discord.js'; + +@ApplyOptions({ + name: 'musicSongPause' +}) +export class MusicSongPauseListener extends Listener { + public override async run( + interaction: ChatInputCommandInteraction + ): Promise { + await interaction.reply({ content: `Track paused.` }); + return; + } +} diff --git a/apps/bot/src/listeners/music/musicSongPlay.ts b/apps/bot/src/listeners/music/musicSongPlay.ts new file mode 100644 index 0000000..6edcf52 --- /dev/null +++ b/apps/bot/src/listeners/music/musicSongPlay.ts @@ -0,0 +1,26 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { container, Listener, type ListenerOptions } from '@sapphire/framework'; +import type { Queue } from '../../lib/music/classes/Queue'; +import type { Song } from '../../lib/music/classes/Song'; + +@ApplyOptions({ + name: 'musicSongPlay' +}) +export class MusicSongPlayListener extends Listener { + public override async run(queue: Queue, track: Song): Promise { + const channel = await queue.getTextChannel(); + if (channel) { + const { client } = container; + + clearTimeout(client.leaveTimers[queue.player.guildId]); + delete client.leaveTimers[queue.player.guildId]; + // Leave Voice Channel when attempting to stream to an empty channel + + if (channel.guild.members.me?.voice.channel?.members.size == 1) { + await queue.leave(); + return; + } + queue.client.emit('musicSongPlayMessage', channel, track); + } + } +} diff --git a/apps/bot/src/listeners/music/musicSongPlayMessage.ts b/apps/bot/src/listeners/music/musicSongPlayMessage.ts new file mode 100644 index 0000000..19a9cb8 --- /dev/null +++ b/apps/bot/src/listeners/music/musicSongPlayMessage.ts @@ -0,0 +1,37 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { container, Listener, type ListenerOptions } from '@sapphire/framework'; +import type { TextChannel } from 'discord.js'; +import { embedButtons } from '../../lib/music/buttonHandler'; +import { NowPlayingEmbed } from '../../lib/music/nowPlayingEmbed'; +import type { Song } from '../../lib/music/classes/Song'; +import { manageStageChannel } from '../../lib/music/channelHandler'; + +@ApplyOptions({ + name: 'musicSongPlayMessage' +}) +export class MusicSongPlayMessageListener extends Listener { + public override async run(channel: TextChannel, track: Song): Promise { + const { client } = container; + const queue = client.music.queues.get(channel.guild.id); + const tracks = await queue.tracks(); + const NowPlaying = new NowPlayingEmbed( + track, + queue.player.accuratePosition, + track.length ?? 0, + queue.player.volume, + tracks, + tracks.at(-1), + queue.paused + ); + try { + await manageStageChannel( + channel.guild.members.me?.voice.channel!, + channel.guild.members.me!, + queue + ); + await embedButtons(await NowPlaying.NowPlayingEmbed(), queue, track); + } catch (error) { + console.log(error); + } + } +} diff --git a/apps/bot/src/listeners/music/musicSongResume.ts b/apps/bot/src/listeners/music/musicSongResume.ts new file mode 100644 index 0000000..30712f5 --- /dev/null +++ b/apps/bot/src/listeners/music/musicSongResume.ts @@ -0,0 +1,14 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Listener, type ListenerOptions } from '@sapphire/framework'; +import type { ChatInputCommandInteraction } from 'discord.js'; + +@ApplyOptions({ + name: 'musicSongResume' +}) +export class MusicSongResumeListener extends Listener { + public override async run( + interaction: ChatInputCommandInteraction + ): Promise { + await interaction.reply({ content: `Track resumed.` }); + } +} diff --git a/apps/bot/src/listeners/music/musicSongSkipNotify.ts b/apps/bot/src/listeners/music/musicSongSkipNotify.ts new file mode 100644 index 0000000..a8c9ba5 --- /dev/null +++ b/apps/bot/src/listeners/music/musicSongSkipNotify.ts @@ -0,0 +1,17 @@ +import type { Song } from '../../lib/music/classes/Song'; +import { ApplyOptions } from '@sapphire/decorators'; +import { Listener, type ListenerOptions } from '@sapphire/framework'; +import { ChatInputCommandInteraction } from 'discord.js'; + +@ApplyOptions({ + name: 'musicSongSkipNotify' +}) +export class MusicSongSkipNotifyListener extends Listener { + public override async run( + interaction: ChatInputCommandInteraction, + track: Song + ): Promise { + if (!track) return; + await interaction.reply({ content: `${track.title} has been skipped.` }); + } +} diff --git a/apps/bot/src/listeners/tempchannels/voiceStateUpdate.ts b/apps/bot/src/listeners/tempchannels/voiceStateUpdate.ts new file mode 100644 index 0000000..6d1703d --- /dev/null +++ b/apps/bot/src/listeners/tempchannels/voiceStateUpdate.ts @@ -0,0 +1,104 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Listener, ListenerOptions } from '@sapphire/framework'; +import type { VoiceChannel, VoiceState } from 'discord.js'; +import { trpcNode } from '../../trpc'; +import { ChannelType } from 'discord.js'; + +@ApplyOptions({ + name: 'voiceStateUpdate' +}) +export class VoiceStateUpdateListener extends Listener { + public override async run( + oldState: VoiceState, + newState: VoiceState + ): Promise { + const { guild: guildDB } = await trpcNode.guild.getGuild.query({ + id: newState.guild.id + }); + + // now user is in hub channel, create him a new voice channel and move him there + if (newState.channelId) { + if (!newState.member) return; // should not happen but just in case + + if (newState.channelId === guildDB?.hubChannel && guildDB.hub) { + const { tempChannel } = await trpcNode.hub.getTempChannel.query({ + guildId: newState.guild.id, + ownerId: newState.member.id + }); + // user entered hub channel but he already has a temp channel, so move him there + if (tempChannel) { + await newState.setChannel(tempChannel.id); + return; + } + + const guild = newState.guild; + const channels = guild.channels; + + const channel = await channels.create({ + name: `${newState.member.user.username}'s channel`, + type: ChannelType.GuildVoice, + parent: guildDB?.hub, + permissionOverwrites: [ + { + id: newState.member.id, + allow: [ + 'MoveMembers', + 'MuteMembers', + 'DeafenMembers', + 'ManageChannels', + 'Stream' + ] + } + ] + }); + + await trpcNode.hub.createTempChannel.mutate({ + guildId: newState.guild.id, + ownerId: newState.member.id, + channelId: channel.id + }); + + await newState.member.voice.setChannel(channel); + } else { + const { tempChannel } = await trpcNode.hub.getTempChannel.query({ + guildId: newState.guild.id, + ownerId: newState.member.id + }); + if (!tempChannel) return; + + if (tempChannel.id === newState.channelId) return; + + const channel = (await newState.guild.channels.fetch( + tempChannel.id + )) as VoiceChannel; + if (!channel) return; + + Promise.all([ + channel.delete(), + trpcNode.hub.deleteTempChannel.mutate({ + channelId: tempChannel.id + }) + ]); + } + } else if (!newState.channelId) { + // user left hub channel, delete his temp channel + deleteChannel(oldState); + } + } +} + +async function deleteChannel(state: VoiceState) { + const { tempChannel } = await trpcNode.hub.getTempChannel.query({ + guildId: state.guild.id, + ownerId: state.member!.id + }); + + if (tempChannel) { + Promise.all([ + state.channel?.delete(), + trpcNode.hub.deleteTempChannel.mutate({ + channelId: tempChannel.id + }) + ]); + } +} diff --git a/apps/bot/src/preconditions/inPlayerVoiceChannel.ts b/apps/bot/src/preconditions/inPlayerVoiceChannel.ts new file mode 100644 index 0000000..84e5999 --- /dev/null +++ b/apps/bot/src/preconditions/inPlayerVoiceChannel.ts @@ -0,0 +1,43 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { + Precondition, + type PreconditionOptions, + type PreconditionResult +} from '@sapphire/framework'; +import type { + ChatInputCommandInteraction, + GuildMember, + VoiceBasedChannel +} from 'discord.js'; +import { container } from '@sapphire/framework'; + +@ApplyOptions({ + name: 'inPlayerVoiceChannel' +}) +export class inPlayerVoiceChannel extends Precondition { + public override chatInputRun( + interaction: ChatInputCommandInteraction + ): PreconditionResult { + const member = interaction.member as GuildMember; + // this precondition comes after a precondition that makes sure the user is in a voice channel + const voiceChannel = member.voice!.channel as VoiceBasedChannel; + + const { client } = container; + const queue = client.music.queues.get(interaction.guildId!); + + const queueVoiceChannel = queue.voiceChannel; + if (queueVoiceChannel && queueVoiceChannel.id !== voiceChannel.id) { + return this.error({ + message: `You're in the wrong channel! Join <#${queueVoiceChannel?.id}>` + }); + } + + return this.ok(); + } +} + +declare module '@sapphire/framework' { + export interface Preconditions { + inPlayerVoiceChannel: never; + } +} diff --git a/apps/bot/src/preconditions/inVoiceChannel.ts b/apps/bot/src/preconditions/inVoiceChannel.ts new file mode 100644 index 0000000..dae4b7a --- /dev/null +++ b/apps/bot/src/preconditions/inVoiceChannel.ts @@ -0,0 +1,32 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { + Precondition, + PreconditionOptions, + PreconditionResult +} from '@sapphire/framework'; +import type { ChatInputCommandInteraction, GuildMember } from 'discord.js'; + +@ApplyOptions({ + name: 'inVoiceChannel' +}) +export class inVoiceChannel extends Precondition { + public override chatInputRun( + interaction: ChatInputCommandInteraction + ): PreconditionResult { + const member = interaction.member as GuildMember; + const voiceChannel = member!.voice!.channel; + + if (!voiceChannel) { + return this.error({ + message: 'You must be in a voice channel in order to use this command!' + }); + } + return this.ok(); + } +} + +declare module '@sapphire/framework' { + export interface Preconditions { + inVoiceChannel: never; + } +} diff --git a/apps/bot/src/preconditions/isCommandDisabled.ts b/apps/bot/src/preconditions/isCommandDisabled.ts new file mode 100644 index 0000000..e330169 --- /dev/null +++ b/apps/bot/src/preconditions/isCommandDisabled.ts @@ -0,0 +1,41 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { + AsyncPreconditionResult, + Precondition, + PreconditionOptions +} from '@sapphire/framework'; +import { ChatInputCommandInteraction } from 'discord.js'; +import { trpcNode } from '../trpc'; + +@ApplyOptions({ + name: 'isCommandDisabled' +}) +export class IsCommandDisabledPrecondition extends Precondition { + public override async chatInputRun( + interaction: ChatInputCommandInteraction + ): AsyncPreconditionResult { + const commandID = interaction.commandId; + const guildID = interaction.guildId as string; + // Most likly a DM + if (!interaction.guildId && interaction.user.id) { + return this.ok(); + } + const data = await trpcNode.command.getDisabledCommands.query({ + guildId: guildID + }); + + if (data.disabledCommands.includes(commandID)) { + return this.error({ + message: 'This command is disabled' + }); + } + + return this.ok(); + } +} + +declare module '@sapphire/framework' { + export interface Preconditions { + isCommandDisabled: never; + } +} diff --git a/apps/bot/src/preconditions/playerIsPlaying.ts b/apps/bot/src/preconditions/playerIsPlaying.ts new file mode 100644 index 0000000..f8c3899 --- /dev/null +++ b/apps/bot/src/preconditions/playerIsPlaying.ts @@ -0,0 +1,31 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { + Precondition, + PreconditionOptions, + PreconditionResult +} from '@sapphire/framework'; +import { container } from '@sapphire/framework'; +import { ChatInputCommandInteraction } from 'discord.js'; + +@ApplyOptions({ + name: 'playerIsPlaying' +}) +export class PlayerIsPlaying extends Precondition { + public override chatInputRun( + interaction: ChatInputCommandInteraction + ): PreconditionResult { + const { client } = container; + const player = client.music.players.get(interaction.guildId as string); + + if (!player) { + return this.error({ message: 'There is nothing playing at the moment!' }); + } + return this.ok(); + } +} + +declare module '@sapphire/framework' { + export interface Preconditions { + playerIsPlaying: never; + } +} diff --git a/apps/bot/src/preconditions/playlistExists.ts b/apps/bot/src/preconditions/playlistExists.ts new file mode 100644 index 0000000..5ec27fc --- /dev/null +++ b/apps/bot/src/preconditions/playlistExists.ts @@ -0,0 +1,38 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { + AsyncPreconditionResult, + Precondition, + PreconditionOptions +} from '@sapphire/framework'; +import type { ChatInputCommandInteraction, GuildMember } from 'discord.js'; +import { trpcNode } from '../trpc'; + +@ApplyOptions({ + name: 'playlistExists' +}) +export class PlaylistExists extends Precondition { + public override async chatInputRun( + interaction: ChatInputCommandInteraction + ): AsyncPreconditionResult { + const playlistName = interaction.options.getString('playlist-name', true); + + const guildMember = interaction.member as GuildMember; + + const playlist = await trpcNode.playlist.getPlaylist.query({ + name: playlistName, + userId: guildMember.id + }); + + return playlist + ? this.ok() + : this.error({ + message: `You have no playlist named **${playlistName}**` + }); + } +} + +declare module '@sapphire/framework' { + export interface Preconditions { + playlistExists: never; + } +} diff --git a/apps/bot/src/preconditions/playlistNotDuplicate.ts b/apps/bot/src/preconditions/playlistNotDuplicate.ts new file mode 100644 index 0000000..8a1159d --- /dev/null +++ b/apps/bot/src/preconditions/playlistNotDuplicate.ts @@ -0,0 +1,42 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { + AsyncPreconditionResult, + Precondition, + PreconditionOptions +} from '@sapphire/framework'; +import type { ChatInputCommandInteraction, GuildMember } from 'discord.js'; +import { trpcNode } from '../trpc'; + +@ApplyOptions({ + name: 'playlistNotDuplicate' +}) +export class PlaylistNotDuplicate extends Precondition { + public override async chatInputRun( + interaction: ChatInputCommandInteraction + ): AsyncPreconditionResult { + const playlistName = interaction.options.getString('playlist-name', true); + + const guildMember = interaction.member as GuildMember; + + try { + const playlist = await trpcNode.playlist.getPlaylist.query({ + name: playlistName, + userId: guildMember.id + }); + + if (playlist) throw new Error(); + } catch { + return this.error({ + message: `There is already a playlist named **${playlistName}** in your saved playlists!` + }); + } + + return this.ok(); + } +} + +declare module '@sapphire/framework' { + export interface Preconditions { + playlistNotDuplicate: never; + } +} diff --git a/apps/bot/src/preconditions/userInDB.ts b/apps/bot/src/preconditions/userInDB.ts new file mode 100644 index 0000000..1f31f23 --- /dev/null +++ b/apps/bot/src/preconditions/userInDB.ts @@ -0,0 +1,40 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { + AsyncPreconditionResult, + Precondition, + PreconditionOptions +} from '@sapphire/framework'; +import type { ChatInputCommandInteraction, GuildMember } from 'discord.js'; +import { trpcNode } from '../trpc'; +import Logger from '../lib/logger'; + +@ApplyOptions({ + name: 'userInDB' +}) +export class UserInDB extends Precondition { + public override async chatInputRun( + interaction: ChatInputCommandInteraction + ): AsyncPreconditionResult { + const guildMember = interaction.member as GuildMember; + + try { + const user = await trpcNode.user.create.mutate({ + id: guildMember.id, + name: guildMember.user.username + }); + + if (!user) throw new Error(); + } catch (error) { + Logger.error(error); + return this.error({ message: 'Something went wrong!' }); + } + + return this.ok(); + } +} + +declare module '@sapphire/framework' { + export interface Preconditions { + userInDB: never; + } +} diff --git a/apps/bot/src/preconditions/validateLanguageCode.ts b/apps/bot/src/preconditions/validateLanguageCode.ts new file mode 100644 index 0000000..90abfe3 --- /dev/null +++ b/apps/bot/src/preconditions/validateLanguageCode.ts @@ -0,0 +1,31 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { + Precondition, + PreconditionOptions, + PreconditionResult +} from '@sapphire/framework'; +import { ChatInputCommandInteraction } from 'discord.js'; +import ISO6391 from 'iso-639-1'; + +@ApplyOptions({ + name: 'validateLanguageCode' +}) +export class ValidLanguageCode extends Precondition { + public override chatInputRun( + interaction: ChatInputCommandInteraction + ): PreconditionResult { + const targetLang = interaction.options.getString('target', true); + const languageCode = ISO6391.getCode(targetLang); + + if (!languageCode) { + return this.error({ message: ':x: Please enter a valid language!' }); + } + return this.ok(); + } +} + +declare module '@sapphire/framework' { + export interface Preconditions { + validateLanguageCode: never; + } +} diff --git a/apps/bot/src/trpc.ts b/apps/bot/src/trpc.ts new file mode 100644 index 0000000..1d6f104 --- /dev/null +++ b/apps/bot/src/trpc.ts @@ -0,0 +1,25 @@ +import type { AppRouter } from '@master-bot/api/index'; +import { createTRPCProxyClient, httpBatchLink } from '@trpc/client'; +import superjson from 'superjson'; +// @ts-ignore +import * as trpcServer from '@trpc/server'; +// @ts-ignore +import * as PrismaClient from '@prisma/client'; +const _importDynamic = new Function('modulePath', 'return import(modulePath)'); + +const fetch = async function (...args: any) { + const { default: fetch } = await _importDynamic('node-fetch'); + return fetch(...args); +}; + +const globalAny = global as any; +globalAny.fetch = fetch; + +export const trpcNode = createTRPCProxyClient({ + links: [ + httpBatchLink({ + url: 'http://localhost:3000/api/trpc' + }) + ], + transformer: superjson +}); diff --git a/apps/bot/tsconfig.json b/apps/bot/tsconfig.json new file mode 100644 index 0000000..b08cfc0 --- /dev/null +++ b/apps/bot/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "@sapphire/ts-config", + "compilerOptions": { + "rootDir": "src", + "experimentalDecorators": true, + "incremental": true, + "outDir": "dist", + "strict": true, + "tsBuildInfoFile": "dist/.tsbuildinfo", + "resolveJsonModule": true, + "noUnusedParameters": false, + "skipLibCheck": true, + "allowSyntheticDefaultImports": true, + "verbatimModuleSyntax": false, + "esModuleInterop": true, + "noImplicitAny": false + }, + "include": ["src", "scripts", "src/env.ts"], + "exclude": ["node_modules"] +} diff --git a/apps/dashboard/README.md b/apps/dashboard/README.md new file mode 100644 index 0000000..cc40526 --- /dev/null +++ b/apps/dashboard/README.md @@ -0,0 +1,28 @@ +# Create T3 App + +This is a [T3 Stack](https://create.t3.gg/) project bootstrapped with `create-t3-app`. + +## What's next? How do I make an app with this? + +We try to keep this project as simple as possible, so you can start with just the scaffolding we set up for you, and add additional things later when they become necessary. + +If you are not familiar with the different technologies used in this project, please refer to the respective docs. If you still are in the wind, please join our [Discord](https://t3.gg/discord) and ask for help. + +- [Next.js](https://nextjs.org) +- [NextAuth.js](https://next-auth.js.org) +- [Prisma](https://prisma.io) +- [Tailwind CSS](https://tailwindcss.com) +- [tRPC](https://trpc.io) + +## Learn More + +To learn more about the [T3 Stack](https://create.t3.gg/), take a look at the following resources: + +- [Documentation](https://create.t3.gg/) +- [Learn the T3 Stack](https://create.t3.gg/en/faq#what-learning-resources-are-currently-available) — Check out these awesome tutorials + +You can check out the [create-t3-app GitHub repository](https://github.com/t3-oss/create-t3-app) — your feedback and contributions are welcome! + +## How do I deploy this? + +Follow our deployment guides for [Vercel](https://create.t3.gg/en/deployment/vercel) and [Docker](https://create.t3.gg/en/deployment/docker) for more information. diff --git a/apps/dashboard/components.json b/apps/dashboard/components.json new file mode 100644 index 0000000..684d9ef --- /dev/null +++ b/apps/dashboard/components.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/app/styles/globals.css", + "baseColor": "slate", + "cssVariables": true + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils" + } +} diff --git a/apps/dashboard/next-env.d.ts b/apps/dashboard/next-env.d.ts new file mode 100644 index 0000000..4f11a03 --- /dev/null +++ b/apps/dashboard/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/apps/dashboard/next.config.mjs b/apps/dashboard/next.config.mjs new file mode 100644 index 0000000..b297594 --- /dev/null +++ b/apps/dashboard/next.config.mjs @@ -0,0 +1,21 @@ +// Importing env files here to validate on build +import './src/env.mjs'; +import '@master-bot/auth/env.mjs'; + +/** @type {import("next").NextConfig} */ +const config = { + reactStrictMode: true, + /** Enables hot reloading for local packages without a build step */ + transpilePackages: ['@master-bot/api', '@master-bot/auth', '@master-bot/db'], + /** We already do linting and typechecking as separate tasks in CI */ + eslint: { ignoreDuringBuilds: true }, + typescript: { ignoreBuildErrors: true }, + images: { + domains: ['cdn.discordapp.com'] + }, + experimental: { + serverActions: true + } +}; + +export default config; diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json new file mode 100644 index 0000000..a45ba7a --- /dev/null +++ b/apps/dashboard/package.json @@ -0,0 +1,66 @@ +{ + "name": "@master-bot/dashboard", + "version": "0.1.0", + "private": true, + "scripts": { + "build": "pnpm with-env next build", + "clean": "git clean -xdf .next .turbo node_modules", + "dev": "pnpm with-env next dev", + "lint": "dotenv -v SKIP_ENV_VALIDATION=1 next lint", + "lint:fix": "pnpm lint --fix", + "start": "pnpm with-env next start", + "type-check": "tsc --noEmit", + "with-env": "dotenv -e ../../.env --" + }, + "dependencies": { + "@master-bot/api": "^0.1.0", + "@master-bot/auth": "^0.1.0", + "@master-bot/db": "^0.1.0", + "@radix-ui/react-dropdown-menu": "^2.0.5", + "@radix-ui/react-select": "^1.2.2", + "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-switch": "^1.0.3", + "@radix-ui/react-toast": "^1.1.4", + "@t3-oss/env-nextjs": "^0.6.0", + "@tanstack/react-query": "^4.32.1", + "@tanstack/react-query-devtools": "^4.32.1", + "@tanstack/react-query-next-experimental": "5.0.0-alpha.80", + "@trpc/client": "^10.37.1", + "@trpc/next": "^10.37.1", + "@trpc/react-query": "^10.37.1", + "@trpc/server": "^10.37.1", + "class-variance-authority": "^0.7.0", + "clsx": "^2.0.0", + "discord-api-types": "^0.37.51", + "lucide-react": "^0.263.1", + "next": "^13.4.12", + "next-themes": "^0.2.1", + "react": "18.2.0", + "react-dom": "18.2.0", + "superjson": "1.13.1", + "tailwind-merge": "^1.14.0", + "tailwindcss-animate": "^1.0.6", + "zod": "^3.21.4" + }, + "devDependencies": { + "@master-bot/eslint-config": "^0.2.0", + "@master-bot/tailwind-config": "^0.1.0", + "@types/node": "^20.4.6", + "@types/react": "^18.2.18", + "@types/react-dom": "^18.2.7", + "autoprefixer": "^10.4.14", + "dotenv-cli": "^7.2.1", + "eslint": "^8.46.0", + "postcss": "^8.4.27", + "tailwindcss": "^3.3.3", + "typescript": "^5.1.6" + }, + "eslintConfig": { + "root": true, + "extends": [ + "@master-bot/eslint-config/base", + "@master-bot/eslint-config/nextjs", + "@master-bot/eslint-config/react" + ] + } +} diff --git a/apps/dashboard/postcss.config.cjs b/apps/dashboard/postcss.config.cjs new file mode 100644 index 0000000..25fc243 --- /dev/null +++ b/apps/dashboard/postcss.config.cjs @@ -0,0 +1,2 @@ +// @ts-expect-error - No types for postcss +module.exports = require('@master-bot/tailwind-config/postcss'); diff --git a/apps/dashboard/public/favicon.ico b/apps/dashboard/public/favicon.ico new file mode 100644 index 0000000..f0058b4 Binary files /dev/null and b/apps/dashboard/public/favicon.ico differ diff --git a/apps/dashboard/public/t3-icon.svg b/apps/dashboard/public/t3-icon.svg new file mode 100644 index 0000000..e377165 --- /dev/null +++ b/apps/dashboard/public/t3-icon.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/apps/dashboard/src/app/api/auth/[...nextauth]/route.ts b/apps/dashboard/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..b3d4e31 --- /dev/null +++ b/apps/dashboard/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,6 @@ +export { GET, POST } from '@master-bot/auth'; + +// @note If you wanna enable edge runtime, either +// - https://auth-docs-git-feat-nextjs-auth-authjs.vercel.app/guides/upgrade-to-v5#edge-compatibility +// - swap prisma for kysely / drizzle +// export const runtime = "edge"; diff --git a/apps/dashboard/src/app/api/trpc/[trpc]/route.ts b/apps/dashboard/src/app/api/trpc/[trpc]/route.ts new file mode 100644 index 0000000..5deba89 --- /dev/null +++ b/apps/dashboard/src/app/api/trpc/[trpc]/route.ts @@ -0,0 +1,38 @@ +import { appRouter, createTRPCContext } from '@master-bot/api'; +import { fetchRequestHandler } from '@trpc/server/adapters/fetch'; +import { auth } from '@master-bot/auth'; + +export const runtime = 'nodejs'; + +/** + * Configure basic CORS headers + * You should extend this to match your needs + */ +function setCorsHeaders(res: Response) { + res.headers.set('Access-Control-Allow-Origin', '*'); + res.headers.set('Access-Control-Request-Method', '*'); + res.headers.set('Access-Control-Allow-Methods', 'OPTIONS, GET, POST'); + res.headers.set('Access-Control-Allow-Headers', '*'); +} + +export function OPTIONS() { + const response = new Response(null, { + status: 204 + }); + setCorsHeaders(response); + return response; +} + +const handler = auth(async req => { + const response = await fetchRequestHandler({ + endpoint: '/api/trpc', + router: appRouter, + req, + createContext: () => createTRPCContext({ auth: req.auth, req }) + }); + + setCorsHeaders(response); + return response; +}); + +export { handler as GET, handler as POST }; diff --git a/apps/dashboard/src/app/dashboard/[server_id]/commands/[command_id]/page.tsx b/apps/dashboard/src/app/dashboard/[server_id]/commands/[command_id]/page.tsx new file mode 100644 index 0000000..81b0727 --- /dev/null +++ b/apps/dashboard/src/app/dashboard/[server_id]/commands/[command_id]/page.tsx @@ -0,0 +1,412 @@ +'use client'; +import { + type APIRole, + type APIApplicationCommandPermission, + ApplicationCommandPermissionType +} from 'discord-api-types/v10'; +import { useState } from 'react'; +import { api } from '~/utils/api'; +import { useToast } from '~/components/ui/use-toast'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuTrigger +} from '~/components/ui/dropdown'; + +interface Role { + name: string; + id: string; + color: number; +} + +export default function CommandPage({ + params +}: { + params: { + server_id: string; + command_id: string; + }; +}) { + const { data, isLoading } = api.command.getCommandAndGuildChannels.useQuery( + { + guildId: params.server_id, + commandId: params.command_id + }, + { + refetchOnReconnect: false, + retryOnMount: false, + refetchOnWindowFocus: false + } + ); + + if (isLoading) return
Loading...
; + + if (!data?.command) return
Command not found
; + + return ( + <> +

Edit {data.command.name}

+ + + ); +} + +const PermissionsEdit = ({ + roles, + allRoles, + guildId, + commandId +}: { + roles: { + allowedRoles: Role[]; + deniedRoles: Role[]; + }; + allRoles: APIRole[]; + guildId: string; + commandId: string; +}) => { + const { toast } = useToast(); + + const allowedIds = roles.allowedRoles.map(r => r.id); + const deniedIds = roles.deniedRoles.map(r => r.id); + + const [allowedRoles, setAllowedRoles] = useState(roles.allowedRoles); + const [deniedRoles, setDeniedRoles] = useState(roles.deniedRoles); + + const [disableSave, setDisableSave] = useState(false); + + const [selectedRadio, setSelectedRadio] = useState( + allowedIds.length ? 'deny' : 'allow' + ); + const isRadioSelected = (value: string) => selectedRadio === value; + + const handleRadioClick = (e: React.ChangeEvent): void => + setSelectedRadio(e.currentTarget.value); + + const { mutate } = api.command.editCommandPermissions.useMutation(); + const utils = api.useContext(); + + function handleRoleChange({ id, type }: { id: string; type: string }) { + if (type === 'allow') { + const newAllowedRoles = allowedRoles.filter(role => role.id !== id); + setAllowedRoles(newAllowedRoles); + } else if (type === 'deny') { + const newDeniedRoles = deniedRoles.filter(role => role.id !== id); + setDeniedRoles(newDeniedRoles); + } + } + + function handleSave() { + setDisableSave(true); + const allowedPerms = allowedRoles.map(role => ({ + id: role.id, + type: 1, + permission: true + })); + + const deniedPerms = deniedRoles.map(role => ({ + id: role.id, + type: 1, + permission: false + })); + + mutate( + { + guildId, + commandId, + permissions: selectedRadio === 'allow' ? deniedPerms : allowedPerms, + type: selectedRadio + }, + { + onSuccess: async () => { + await utils.command.getCommandAndGuildChannels.invalidate(); + setDisableSave(false); + toast({ + title: 'Permissions updated' + }); + }, + onError: () => { + setDisableSave(false); + toast({ + title: 'An error occurred while updating permissions.' + }); + }, + onSettled: () => { + setDisableSave(false); + } + } + ); + } + + return ( +
+
+

Permissions

+ +
+
+

Role permissions

+
+
+ +

Allow for everyone except

+
+ {selectedRadio === 'deny' ? null : ( +
+ {deniedRoles.map(role => { + if (role.name === '@everyone') return null; + return ( +
+
+ {role.name == '@everyone' ? '@everyone' : `@${role.name}`} +
+ + handleRoleChange({ id: role.id, type: 'deny' }) + } + > + + +
+ ); + })} + + + + + + + {allRoles + .filter(role => !deniedIds.includes(role.id)) + .map(role => { + if (role.name === '@everyone') return; + + return ( + { + setDeniedRoles(state => [ + ...state, + { + id: role.id, + name: role.name, + color: role.color + } + ]); + + if (allowedIds.includes(role.id)) { + setAllowedRoles(state => + state.filter(r => r.id !== role.id) + ); + } + }} + > + {role.name} + + ); + })} + + + +
+ )} +
+
+
+ +

Deny for everyone except

+
+ {selectedRadio === 'deny' ? ( +
+ {allowedRoles.map(role => { + if (role.name == '@everyone') return null; + return ( +
+ {role.name == '@everyone' ? '@everyone' : `@${role.name}`} + + handleRoleChange({ id: role.id, type: 'allow' }) + } + > + + +
+ ); + })} + + + + + + + {allRoles + .filter(role => !allowedIds.includes(role.id)) + .map(role => { + if (role.name === '@everyone') return; + + return ( + { + setAllowedRoles(state => [ + ...state, + { + id: role.id, + name: role.name, + color: role.color + } + ]); + + if (deniedIds.includes(role.id)) { + setDeniedRoles(state => + state.filter(r => r.id !== role.id) + ); + } + }} + > + {role.name} + + ); + })} + + + +
+ ) : null} +
+
+
+ ); +}; + +function sortRolePermissions({ + roles, + permissions +}: { + roles: APIRole[]; + permissions: any; +}) { + if (permissions.code) { + return { + allowedRoles: [], + deniedRoles: [] + }; + } + + const allowedRoles: Role[] = permissions.permissions + .filter( + (permission: APIApplicationCommandPermission) => + permission.type === ApplicationCommandPermissionType.Role && + permission.permission + ) + .map((permission: APIApplicationCommandPermission) => { + const role = roles.find(roles => roles.id === permission.id); + + return { + name: role?.name, + id: role?.id, + color: role?.color + }; + }); + + const deniedRoles: Role[] = permissions.permissions + .filter( + (permission: APIApplicationCommandPermission) => + permission.type === ApplicationCommandPermissionType.Role && + !permission.permission + ) + .map((permission: APIApplicationCommandPermission) => { + const role = roles.find(roles => roles.id === permission.id); + + return { + name: role?.name, + id: role?.id, + color: role?.color + }; + }); + + return { + allowedRoles, + deniedRoles + }; +} diff --git a/apps/dashboard/src/app/dashboard/[server_id]/commands/actions.ts b/apps/dashboard/src/app/dashboard/[server_id]/commands/actions.ts new file mode 100644 index 0000000..4d21ba6 --- /dev/null +++ b/apps/dashboard/src/app/dashboard/[server_id]/commands/actions.ts @@ -0,0 +1,48 @@ +'use server'; +import { prisma } from '@master-bot/db'; +import { revalidatePath } from 'next/cache'; + +export async function toggleCommand( + guildId: string, + commandId: string, + newStatus: boolean +) { + const guild = await prisma.guild.findUnique({ + where: { + id: guildId + }, + select: { + disabledCommands: true + } + }); + + if (!guild) { + throw new Error('Guild not found'); + } + + if (newStatus) { + await prisma.guild.update({ + where: { + id: guildId + }, + data: { + disabledCommands: { + set: guild.disabledCommands.filter(id => id !== commandId) + } + } + }); + } else { + await prisma.guild.update({ + where: { + id: guildId + }, + data: { + disabledCommands: { + push: commandId + } + } + }); + } + + revalidatePath(`/dashboard/${guildId}/commands`); +} diff --git a/apps/dashboard/src/app/dashboard/[server_id]/commands/loading.tsx b/apps/dashboard/src/app/dashboard/[server_id]/commands/loading.tsx new file mode 100644 index 0000000..fa42e23 --- /dev/null +++ b/apps/dashboard/src/app/dashboard/[server_id]/commands/loading.tsx @@ -0,0 +1,4 @@ +export default function Loading() { + // You can add any UI inside Loading, including a Skeleton. + return
Loading...
; +} diff --git a/apps/dashboard/src/app/dashboard/[server_id]/commands/page.tsx b/apps/dashboard/src/app/dashboard/[server_id]/commands/page.tsx new file mode 100644 index 0000000..cc851fd --- /dev/null +++ b/apps/dashboard/src/app/dashboard/[server_id]/commands/page.tsx @@ -0,0 +1,78 @@ +import { env } from '~/env.mjs'; +import { prisma } from '@master-bot/db'; +import type { APIApplicationCommand } from 'discord-api-types/v10'; +import CommandToggleSwitch from './toggle-command'; +import Link from 'next/link'; + +async function getApplicationCommands() { + // get all commands + const response = await fetch( + `https://discordapp.com/api/applications/${env.DISCORD_CLIENT_ID}/commands`, + { + headers: { + Authorization: `Bot ${env.DISCORD_TOKEN}` + } + } + ); + + return (await response.json()) as APIApplicationCommand[]; +} + +export default async function CommandsPage({ + params +}: { + params: { server_id: string }; +}) { + // get disabled commands + const guild = await prisma.guild.findUnique({ + where: { id: params.server_id }, + select: { disabledCommands: true } + }); + + const commands = await getApplicationCommands(); + + return ( +
+

+ Enable / Disable Commands Panel +

+ {commands ? ( +
+ {commands.map(command => { + const isCommandEnabled = !guild?.disabledCommands.includes( + command.id + ); + return ( +
+
+ +

{command.name}

+ +

{command.description}

+
+
+ +
+
+ ); + })} +
+ ) : ( +
Error loading commands
+ )} +
+ ); +} diff --git a/apps/dashboard/src/app/dashboard/[server_id]/commands/toggle-command.tsx b/apps/dashboard/src/app/dashboard/[server_id]/commands/toggle-command.tsx new file mode 100644 index 0000000..ff5b8bd --- /dev/null +++ b/apps/dashboard/src/app/dashboard/[server_id]/commands/toggle-command.tsx @@ -0,0 +1,36 @@ +'use client'; + +import { Switch } from '~/components/ui/switch'; +import { startTransition } from 'react'; +import { toggleCommand } from './actions'; +import { useToast } from '~/components/ui/use-toast'; +import { ToastAction } from '~/components/ui/toast'; + +export default function CommandToggleSwitch({ + commandEnabled, + serverId, + commandId +}: { + commandEnabled: boolean; + serverId: string; + commandId: string; +}) { + const { toast } = useToast(); + + return ( + + startTransition(() => + // @ts-ignore + toggleCommand(serverId, commandId, !commandEnabled).then(() => { + toast({ + title: `Command ${commandEnabled ? 'disabled' : 'enabled'}`, + action: Okay + }); + }) + ) + } + /> + ); +} diff --git a/apps/dashboard/src/app/dashboard/[server_id]/layout.tsx b/apps/dashboard/src/app/dashboard/[server_id]/layout.tsx new file mode 100644 index 0000000..e1af265 --- /dev/null +++ b/apps/dashboard/src/app/dashboard/[server_id]/layout.tsx @@ -0,0 +1,46 @@ +import { auth } from '@master-bot/auth'; +import { redirect } from 'next/navigation'; +import { prisma } from '@master-bot/db'; +import Sidebar from './sidebar'; +import HeaderButtons from '~/components/header-buttons'; + +export default async function Layout({ + params, + children +}: { + params: { server_id: string }; + children: React.ReactNode; +}) { + const session = await auth(); + + if (!session?.user) { + redirect('/'); + } + + const guild = await prisma.guild.findUnique({ + where: { + id: params.server_id, + ownerId: session.user.discordId + } + }); + + if (!guild) { + redirect('/'); + } + + return ( +
+
+ +
+
+
+ +
+
+ {children} +
+
+
+ ); +} diff --git a/apps/dashboard/src/app/dashboard/[server_id]/page.tsx b/apps/dashboard/src/app/dashboard/[server_id]/page.tsx new file mode 100644 index 0000000..3cbf57e --- /dev/null +++ b/apps/dashboard/src/app/dashboard/[server_id]/page.tsx @@ -0,0 +1,7 @@ +export default function ServerIndexPage() { + return ( +
+

Guild index page

+
+ ); +} diff --git a/apps/dashboard/src/app/dashboard/[server_id]/sidebar.tsx b/apps/dashboard/src/app/dashboard/[server_id]/sidebar.tsx new file mode 100644 index 0000000..309ca90 --- /dev/null +++ b/apps/dashboard/src/app/dashboard/[server_id]/sidebar.tsx @@ -0,0 +1,38 @@ +import Link from 'next/link'; +import { MessageCircle, ChevronRightSquare } from 'lucide-react'; +import Logo from '~/components/logo'; + +const links = [ + { + href: 'commands', + label: 'Commands', + icon: ChevronRightSquare + }, + { + href: 'welcome-message', + label: 'Welcome Message', + icon: MessageCircle + } +]; + +export default function Sidebar({ server_id }: { server_id: string }) { + return ( + + ); +} diff --git a/apps/dashboard/src/app/dashboard/[server_id]/welcome-message/actions.ts b/apps/dashboard/src/app/dashboard/[server_id]/welcome-message/actions.ts new file mode 100644 index 0000000..8622c56 --- /dev/null +++ b/apps/dashboard/src/app/dashboard/[server_id]/welcome-message/actions.ts @@ -0,0 +1,32 @@ +'use server'; +import { prisma } from '@master-bot/db'; +import { revalidatePath } from 'next/cache'; + +export async function toggleWelcomeMessage(status: boolean, server_id: string) { + await prisma.guild.update({ + where: { + id: server_id + }, + data: { + welcomeMessageEnabled: status + } + }); + + revalidatePath(`/dashboard/${server_id}/welcome-message`); +} + +export async function setWelcomeMessage(data: FormData) { + const guildId = data.get('guildId') as string; + const message = data.get('message') as string; + + await prisma.guild.update({ + where: { + id: guildId + }, + data: { + welcomeMessage: message + } + }); + + revalidatePath(`/dashboard/${guildId}/welcome-message`); +} diff --git a/apps/dashboard/src/app/dashboard/[server_id]/welcome-message/loading.tsx b/apps/dashboard/src/app/dashboard/[server_id]/welcome-message/loading.tsx new file mode 100644 index 0000000..fa42e23 --- /dev/null +++ b/apps/dashboard/src/app/dashboard/[server_id]/welcome-message/loading.tsx @@ -0,0 +1,4 @@ +export default function Loading() { + // You can add any UI inside Loading, including a Skeleton. + return
Loading...
; +} diff --git a/apps/dashboard/src/app/dashboard/[server_id]/welcome-message/page.tsx b/apps/dashboard/src/app/dashboard/[server_id]/welcome-message/page.tsx new file mode 100644 index 0000000..24562a7 --- /dev/null +++ b/apps/dashboard/src/app/dashboard/[server_id]/welcome-message/page.tsx @@ -0,0 +1,60 @@ +import { prisma } from '@master-bot/db'; +import WelcomeMessageToggle from './switch'; +import { setWelcomeMessage } from './actions'; +import { Button } from '~/components/ui/button'; +import WelcomeMessageChannelSet from './set-channel'; + +function getGuildById(id: string) { + return prisma.guild.findUnique({ + where: { + id + } + }); +} + +export default async function WelcomeMessagePage({ + params +}: { + params: { server_id: string }; +}) { + const guild = await getGuildById(params.server_id); + + if (!guild) { + return
Error loading guild
; + } + + return ( + <> +

Welcome Message Settings

+
+

Welcome new users with a custom message

+
+ {guild.welcomeMessageEnabled ? ( +

Enabled

+ ) : ( +

Disabled

+ )} + +
+ {guild.welcomeMessageEnabled && ( +
+
+ +