initial commit: fork

This commit is contained in:
2023-10-27 10:31:20 +02:00
commit a52dbbc103
195 changed files with 17484 additions and 0 deletions

22
.dockerignore Normal file
View File

@ -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

34
.env.example Normal file
View File

@ -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=""

36
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@ -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.

View File

@ -0,0 +1,9 @@
---
name: Feature request
about: Suggest/request a new bot feature
title: ''
labels: 'enhancement'
assignees: ''
---
**Explain your suggestion**

11
.github/workflows/main.yml vendored Normal file
View File

@ -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

57
.gitignore vendored Normal file
View File

@ -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

8
.prettierrc Normal file
View File

@ -0,0 +1,8 @@
{
"trailingComma": "none",
"useTabs": true,
"tabWidth": 2,
"semi": true,
"singleQuote": true,
"arrowParens": "avoid"
}

28
Dockerfile Normal file
View File

@ -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"]

21
LICENSE Normal file
View File

@ -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.

260
README.md Normal file
View File

@ -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

15
apps/bot/.sapphirerc.json Normal file
View File

@ -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": ""
}
}

73
apps/bot/package.json Normal file
View File

@ -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"
]
}
}

View File

@ -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'

View File

@ -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'

View File

@ -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'

View File

@ -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

View File

@ -0,0 +1,37 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command } from '@sapphire/framework';
import { env } from '../../env';
@ApplyOptions<Command.Options>({
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.'
});
}
}
}

View File

@ -0,0 +1,37 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command } from '@sapphire/framework';
import { env } from '../../env';
@ApplyOptions<Command.Options>({
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.'
});
}
}
}

View File

@ -0,0 +1,37 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command } from '@sapphire/framework';
import { env } from '../../env';
@ApplyOptions<Command.Options>({
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.'
});
}
}
}

View File

@ -0,0 +1,37 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command } from '@sapphire/framework';
import { env } from '../../env';
@ApplyOptions<Command.Options>({
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.'
});
}
}
}

View File

@ -0,0 +1,37 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command } from '@sapphire/framework';
import { env } from '../../env';
@ApplyOptions<Command.Options>({
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.'
});
}
}
}

View File

@ -0,0 +1,37 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command } from '@sapphire/framework';
import { env } from '../../env';
@ApplyOptions<Command.Options>({
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.'
});
}
}
}

View File

@ -0,0 +1,37 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command } from '@sapphire/framework';
import { env } from '../../env';
@ApplyOptions<Command.Options>({
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.'
});
}
}
}

View File

@ -0,0 +1,37 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command } from '@sapphire/framework';
import { env } from '../../env';
@ApplyOptions<Command.Options>({
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.'
});
}
}
}

View File

@ -0,0 +1,37 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command } from '@sapphire/framework';
import { env } from '../../env';
@ApplyOptions<Command.Options>({
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.'
});
}
}
}

View File

@ -0,0 +1,37 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command } from '@sapphire/framework';
import { env } from '../../env';
@ApplyOptions<Command.Options>({
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.'
});
}
}
}

View File

@ -0,0 +1,37 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command } from '@sapphire/framework';
import { env } from '../../env';
@ApplyOptions<Command.Options>({
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.'
});
}
}
}

View File

@ -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<CommandOptions>({
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<Node>;
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'}`
);
}
}

View File

@ -0,0 +1,63 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command, CommandOptions } from '@sapphire/framework';
import { trpcNode } from '../../trpc';
@ApplyOptions<CommandOptions>({
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;
}
}

View File

@ -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<CommandOptions>({
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}**`);
}
}

View File

@ -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<CommandOptions>({
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;
}
}

View File

@ -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<CommandOptions>({
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<Node>;
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'}`
);
}
}

View File

@ -0,0 +1,37 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command, CommandOptions } from '@sapphire/framework';
import { container } from '@sapphire/framework';
@ApplyOptions<CommandOptions>({
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.' });
}
}

View File

@ -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<CommandOptions>({
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 :('
);
}
}
}

View File

@ -0,0 +1,70 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command, CommandOptions } from '@sapphire/framework';
import { container } from '@sapphire/framework';
@ApplyOptions<CommandOptions>({
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;
}
}

View File

@ -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<CommandOptions>({
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;
}
}

View File

@ -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<CommandOptions>({
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<Node>;
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'}`
);
}
}

View File

@ -0,0 +1,35 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command, CommandOptions } from '@sapphire/framework';
import { container } from '@sapphire/framework';
@ApplyOptions<CommandOptions>({
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);
}
}

View File

@ -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<CommandOptions>({
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 });
}
}

View File

@ -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<CommandOptions>({
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);
}
}

View File

@ -0,0 +1,94 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command, CommandOptions } from '@sapphire/framework';
import { trpcNode } from '../../trpc';
@ApplyOptions<CommandOptions>({
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;
}
}

View File

@ -0,0 +1,52 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command, CommandOptions } from '@sapphire/framework';
import { container } from '@sapphire/framework';
@ApplyOptions<CommandOptions>({
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}`
});
}
}

View File

@ -0,0 +1,35 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command, CommandOptions } from '@sapphire/framework';
import { container } from '@sapphire/framework';
@ApplyOptions<CommandOptions>({
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);
}
}

View File

@ -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<CommandOptions>({
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!');
}
}
}

View File

@ -0,0 +1,58 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command, CommandOptions } from '@sapphire/framework';
import { container } from '@sapphire/framework';
@ApplyOptions<CommandOptions>({
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`);
}
}

View File

@ -0,0 +1,41 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command, CommandOptions } from '@sapphire/framework';
import { container } from '@sapphire/framework';
@ApplyOptions<CommandOptions>({
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!');
}
}

View File

@ -0,0 +1,40 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command, CommandOptions } from '@sapphire/framework';
import { container } from '@sapphire/framework';
@ApplyOptions<CommandOptions>({
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;
}
}

View File

@ -0,0 +1,57 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command, CommandOptions } from '@sapphire/framework';
import { container } from '@sapphire/framework';
@ApplyOptions<CommandOptions>({
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;
}
}

View File

@ -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<CommandOptions>({
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<Node>;
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'}`
);
}
}

View File

@ -0,0 +1,51 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command, CommandOptions } from '@sapphire/framework';
import { container } from '@sapphire/framework';
@ApplyOptions<CommandOptions>({
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}!`
);
}
}

View File

@ -0,0 +1,72 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command } from '@sapphire/framework';
import { EmbedBuilder } from 'discord.js';
@ApplyOptions<Command.Options>({
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.'
];

View File

@ -0,0 +1,31 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command } from '@sapphire/framework';
import { EmbedBuilder } from 'discord.js';
@ApplyOptions<Command.Options>({
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] });
}
}

View File

@ -0,0 +1,74 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command } from '@sapphire/framework';
import { GuildMember, VoiceChannel } from 'discord.js';
@ApplyOptions<Command.Options>({
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!'
});
}
}
}

View File

@ -0,0 +1,48 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command } from '@sapphire/framework';
import { EmbedBuilder } from 'discord.js';
@ApplyOptions<Command.Options>({
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!' });
}
}
}

View File

@ -0,0 +1,36 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command } from '@sapphire/framework';
import { EmbedBuilder } from 'discord.js';
@ApplyOptions<Command.Options>({
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] });
}
}

View File

@ -0,0 +1,51 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command } from '@sapphire/framework';
import { EmbedBuilder } from 'discord.js';
@ApplyOptions<Command.Options>({
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!'
});
}
}
}

View File

@ -0,0 +1,51 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command } from '@sapphire/framework';
import { EmbedBuilder } from 'discord.js';
@ApplyOptions<Command.Options>({
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!'
});
}
}
}

View File

@ -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<Command.Options>({
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<any> {
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'
);
}
});
}
}

View File

@ -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<CommandOptions>({
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;
}
}
}

View File

@ -0,0 +1,52 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command, CommandOptions } from '@sapphire/framework';
import { EmbedBuilder } from 'discord.js';
@ApplyOptions<CommandOptions>({
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!'
});
}
}
}

View File

@ -0,0 +1,50 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command, CommandOptions } from '@sapphire/framework';
import { EmbedBuilder } from 'discord.js';
@ApplyOptions<CommandOptions>({
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
});
}
}

View File

@ -0,0 +1,51 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command, CommandOptions } from '@sapphire/framework';
import { EmbedBuilder } from 'discord.js';
@ApplyOptions<CommandOptions>({
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!'
});
}
}
}

View File

@ -0,0 +1,21 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command } from '@sapphire/framework';
@ApplyOptions<Command.Options>({
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!' });
}
}

View File

@ -0,0 +1,45 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command, CommandOptions } from '@sapphire/framework';
import { EmbedBuilder } from 'discord.js';
@ApplyOptions<CommandOptions>({
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] });
}
}

View File

@ -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<CommandOptions>({
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<any> {
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'
}
];

View File

@ -0,0 +1,80 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Command, CommandOptions } from '@sapphire/framework';
import { Colors, EmbedBuilder } from 'discord.js';
@ApplyOptions<CommandOptions>({
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'];
}
}
}

View File

@ -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<CommandOptions>({
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;
}
}

View File

@ -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<CommandOptions>({
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'
);
});
}
}

View File

@ -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<CommandOptions>({
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 :('
});
});
}
}

View File

@ -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<CommandOptions>({
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<ResponseData> {
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(/<br>/g, '\n')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&apos;/g, "'")
.replace(/&quot;/g, '"')
.replace(/&amp;/g, '&')
.replace(/&#39;/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<string>;
type ResponseData = string | Array<any>;

View File

@ -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<CommandOptions>({
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:'
});
});
}
}

33
apps/bot/src/env.ts Normal file
View File

@ -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
});

76
apps/bot/src/index.ts Normal file
View File

@ -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();

View File

@ -0,0 +1,4 @@
import { join } from 'path';
export const rootDir = join(__dirname, '..', '..');
export const srcDir = join(rootDir, 'src');

View File

@ -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;

View File

@ -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<ButtonBuilder>().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);
}
});
}

View File

@ -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);
}
}

View File

@ -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;
}

View File

@ -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<boolean> {
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<boolean> {
return (
(await this.store.redis.exists(this.keys.current, this.keys.next)) > 0
);
}
// add tracks to queue
public async add(
songs: Song | Array<Song>,
options: AddOptions = {}
): Promise<number> {
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<boolean> {
return await this.store.redis
.get(this.keys.systemPause)
.then(d => d === '1');
}
public async setSystemPaused(value: boolean): Promise<boolean> {
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<boolean> {
return await this.store.redis.get(this.keys.replay).then(d => d === '1');
}
public async setReplay(value: boolean): Promise<boolean> {
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<number> {
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<void> {
await this.player.seek(position);
}
// connect to a voice channel
public async connect(channelID: string): Promise<void> {
await this.player.connect(channelID, { deafened: true });
}
// leave the voice channel
public async leave(): Promise<void> {
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<TextChannel | null> {
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<string | null> {
return this.store.redis.get(this.keys.text);
}
public setTextChannelID(channelID: null): Promise<null>;
public async setTextChannelID(channelID: string): Promise<string>;
public async setTextChannelID(
channelID: string | null
): Promise<string | null> {
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<Song | null> {
const value = await this.store.redis.get(this.keys.current);
return value ? this.parseSongString(value) : null;
}
public async getAt(index: number): Promise<Song | undefined> {
const value = await this.store.redis.lindex(this.keys.next, -index - 1);
return value ? this.parseSongString(value) : undefined;
}
public async removeAt(position: number): Promise<void> {
await this.store.redis.lremat(this.keys.next, -position - 1);
await this.refresh();
}
public async next({ skipped = false } = {}): Promise<boolean> {
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<number> {
return this.store.redis.llen(this.keys.next);
}
public async moveTracks(from: number, to: number): Promise<void> {
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<void> {
await this.store.redis.lshuffle(this.keys.next, Date.now());
await this.refresh();
}
public async stop(): Promise<void> {
await this.player.stop();
}
public async clearTracks(): Promise<void> {
await this.store.redis.del(this.keys.next);
}
public async skipTo(position: number): Promise<void> {
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<number> {
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<NowPlaying | null> {
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<Song[]> {
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<void> {
await this.store.redis.set(this.keys.embed, id);
}
public async getEmbed(): Promise<string | null> {
return this.store.redis.get(this.keys.embed);
}
public async deleteEmbed(): Promise<void> {
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);
}
}

View File

@ -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)
);
}
}

View File

@ -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<string | null>;
}
export class QueueStore extends Collection<string, Queue> {
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<string[]> {
const guilds = new Set<string>();
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];
}
}

View File

@ -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;
}

View File

@ -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<EmbedBuilder> {
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**: <t:${Math.floor(
new Date(streamData.started_at).getTime() / 1000
)}:t>`
)
.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)
};
}
}

View File

@ -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];
}
}

20
apps/bot/src/lib/setup.ts Normal file
View File

@ -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 });

View File

@ -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;
}
}

View File

@ -0,0 +1,24 @@
import { ApplyOptions } from '@sapphire/decorators';
import {
type ChatInputCommandDeniedPayload,
Listener,
type ListenerOptions,
UserError
} from '@sapphire/framework';
@ApplyOptions<ListenerOptions>({
name: 'chatInputCommandDenied'
})
export class CommandDeniedListener extends Listener {
public override async run(
{ context, message: content }: UserError,
{ interaction }: ChatInputCommandDeniedPayload
): Promise<void> {
await interaction.reply({
ephemeral: true,
content: content
});
return;
}
}

View File

@ -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<ListenerOptions>({
name: 'guildCreate'
})
export class GuildCreateListener extends Listener {
public override async run(guild: Guild): Promise<void> {
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
});
}
}

View File

@ -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<ListenerOptions>({
name: 'guildDelete'
})
export class GuildDeleteListener extends Listener {
public override async run(guild: Guild): Promise<void> {
await trpcNode.guild.delete.mutate({
id: guild.id
});
}
}

View File

@ -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<ListenerOptions>({
name: 'guildMemberAdd'
})
export class GuildMemberListener extends Listener {
public override async run(member: GuildMember): Promise<void> {
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}` });
}
}
}

View File

@ -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<ListenerOptions>({
name: 'musicFinish'
})
export class MusicFinishListener extends Listener {
public override async run(
queue: Queue,
skipped: boolean = false
): Promise<void> {
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);
}
}

View File

@ -0,0 +1,12 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Listener, type ListenerOptions } from '@sapphire/framework';
import type { TextChannel } from 'discord.js';
@ApplyOptions<ListenerOptions>({
name: 'musicFinishNotify'
})
export class MusicFinishNotifyListener extends Listener {
public override async run(channel: TextChannel): Promise<void> {
await channel.send({ content: ':zzz: Leaving due to inactivity' });
}
}

View File

@ -0,0 +1,15 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Listener, type ListenerOptions } from '@sapphire/framework';
import type { ChatInputCommandInteraction } from 'discord.js';
@ApplyOptions<ListenerOptions>({
name: 'musicSongPause'
})
export class MusicSongPauseListener extends Listener {
public override async run(
interaction: ChatInputCommandInteraction
): Promise<void> {
await interaction.reply({ content: `Track paused.` });
return;
}
}

View File

@ -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<ListenerOptions>({
name: 'musicSongPlay'
})
export class MusicSongPlayListener extends Listener {
public override async run(queue: Queue, track: Song): Promise<void> {
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);
}
}
}

View File

@ -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<ListenerOptions>({
name: 'musicSongPlayMessage'
})
export class MusicSongPlayMessageListener extends Listener {
public override async run(channel: TextChannel, track: Song): Promise<void> {
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);
}
}
}

View File

@ -0,0 +1,14 @@
import { ApplyOptions } from '@sapphire/decorators';
import { Listener, type ListenerOptions } from '@sapphire/framework';
import type { ChatInputCommandInteraction } from 'discord.js';
@ApplyOptions<ListenerOptions>({
name: 'musicSongResume'
})
export class MusicSongResumeListener extends Listener {
public override async run(
interaction: ChatInputCommandInteraction
): Promise<void> {
await interaction.reply({ content: `Track resumed.` });
}
}

View File

@ -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<ListenerOptions>({
name: 'musicSongSkipNotify'
})
export class MusicSongSkipNotifyListener extends Listener {
public override async run(
interaction: ChatInputCommandInteraction,
track: Song
): Promise<void> {
if (!track) return;
await interaction.reply({ content: `${track.title} has been skipped.` });
}
}

View File

@ -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<ListenerOptions>({
name: 'voiceStateUpdate'
})
export class VoiceStateUpdateListener extends Listener {
public override async run(
oldState: VoiceState,
newState: VoiceState
): Promise<void> {
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
})
]);
}
}

View File

@ -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<PreconditionOptions>({
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;
}
}

View File

@ -0,0 +1,32 @@
import { ApplyOptions } from '@sapphire/decorators';
import {
Precondition,
PreconditionOptions,
PreconditionResult
} from '@sapphire/framework';
import type { ChatInputCommandInteraction, GuildMember } from 'discord.js';
@ApplyOptions<PreconditionOptions>({
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;
}
}

Some files were not shown because too many files have changed in this diff Show More