Initial implementation
This commit is contained in:
commit
f75446fe48
12
.editorconfig
Normal file
12
.editorconfig
Normal file
@ -0,0 +1,12 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
134
.gitignore
vendored
Normal file
134
.gitignore
vendored
Normal file
@ -0,0 +1,134 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
## Custom
|
||||
config.js
|
||||
yt-dlp
|
15
README.md
Normal file
15
README.md
Normal file
@ -0,0 +1,15 @@
|
||||
# SquadMusicbot
|
||||
|
||||
Spaghetti code music bot to replace the garbage that's SinusBot.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js v19
|
||||
- npm
|
||||
- TypeScript
|
||||
|
||||
## Setup
|
||||
|
||||
1. Clone the repository
|
||||
2. Run `npm install`
|
||||
3. Run `npm run start`
|
53
config.sample.js
Normal file
53
config.sample.js
Normal file
@ -0,0 +1,53 @@
|
||||
module.exports = {
|
||||
/**
|
||||
* Discord bot token
|
||||
* Requires all privileged intents
|
||||
*/
|
||||
token: '',
|
||||
|
||||
/**
|
||||
* Instead of just being able to define command prefixes, you get to define all the commands themselves.
|
||||
*/
|
||||
commands: {
|
||||
'!next': 'next',
|
||||
'!skip': 'next',
|
||||
|
||||
'!play': 'play',
|
||||
'!qyt': 'queueUrl',
|
||||
|
||||
'!y': 'search',
|
||||
'!search': 'search',
|
||||
|
||||
'!pause': 'pause',
|
||||
'!stop': 'stop',
|
||||
|
||||
'!volume': 'volume',
|
||||
'!vol': 'volume',
|
||||
},
|
||||
|
||||
/**
|
||||
* Default volume on startup
|
||||
* Volume will be reset to this value on every startup
|
||||
*/
|
||||
defaultVolume: 0.05,
|
||||
|
||||
/**
|
||||
* Directory to store downloaded files
|
||||
*/
|
||||
downloadDir: './downloads',
|
||||
|
||||
/**
|
||||
* Voice channel to join (Discord ID/Snowflake)
|
||||
*/
|
||||
voiceChannel: '',
|
||||
|
||||
/**
|
||||
* Path to yt-dlp
|
||||
*/
|
||||
ytDlpPath: './yt-dlp',
|
||||
|
||||
/**
|
||||
* YouTube API key for search
|
||||
*/
|
||||
youtubeApiKey: '',
|
||||
};
|
2
downloads/.gitignore
vendored
Normal file
2
downloads/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
414
index.ts
Normal file
414
index.ts
Normal file
@ -0,0 +1,414 @@
|
||||
import {
|
||||
Client, GatewayIntentBits, Message,
|
||||
inlineCode, hideLinkEmbed,
|
||||
VoiceChannel,
|
||||
Partials
|
||||
} from 'discord.js';
|
||||
import { joinVoiceChannel, VoiceConnection, createAudioPlayer, createAudioResource, AudioPlayerStatus } from '@discordjs/voice';
|
||||
import axios from 'axios';
|
||||
|
||||
import YTDlpWrap from 'yt-dlp-wrap';
|
||||
|
||||
import * as crypto from 'crypto';
|
||||
import * as fs from 'fs';
|
||||
|
||||
const config = require('./config');
|
||||
|
||||
import QueueEntry from './interfaces/QueueEntry';
|
||||
|
||||
if (!config.token) {
|
||||
console.error('No token provided in config');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(config.downloadDir)) {
|
||||
fs.mkdirSync(config.downloadDir);
|
||||
}
|
||||
|
||||
if (!fs.lstatSync(config.downloadDir).isDirectory()) {
|
||||
console.error(`${config.downloadDir} is not a directory`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = new Client({
|
||||
intents: [
|
||||
GatewayIntentBits.Guilds,
|
||||
GatewayIntentBits.GuildMessages,
|
||||
GatewayIntentBits.MessageContent,
|
||||
GatewayIntentBits.GuildMembers,
|
||||
GatewayIntentBits.GuildPresences,
|
||||
GatewayIntentBits.DirectMessages,
|
||||
GatewayIntentBits.GuildVoiceStates,
|
||||
],
|
||||
partials: [
|
||||
Partials.Channel,
|
||||
],
|
||||
});
|
||||
|
||||
let ytdlp: YTDlpWrap;
|
||||
|
||||
/**
|
||||
* Bot state
|
||||
*/
|
||||
const player = createAudioPlayer();
|
||||
let songQueue = [] as QueueEntry[];
|
||||
let currentSong: QueueEntry | null | undefined;
|
||||
let voiceConnection: VoiceConnection;
|
||||
let volume = config.defaultVolume;
|
||||
|
||||
function getSongText(entry: QueueEntry | undefined = currentSong)
|
||||
{
|
||||
if (!entry) {
|
||||
return 'No song currently playing';
|
||||
}
|
||||
|
||||
const info = entry.songMeta;
|
||||
const songInfo = `${info.title} - ${info.uploader} (${info.duration})`;
|
||||
return `${songInfo} - ${hideLinkEmbed(entry.url)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Skip the currently playing song (if any)
|
||||
* Play the next song in the queue (if any)
|
||||
*/
|
||||
async function playNext(message: Message | null = null, words: string[] = [])
|
||||
{
|
||||
const previous = getSongText();
|
||||
currentSong = songQueue.shift();
|
||||
if (!currentSong) {
|
||||
console.log('No more songs in queue, stopping player');
|
||||
player.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
player.play(currentSong.audioResource);
|
||||
if (message) {
|
||||
message.reply(`Skipped: ${previous}\nNow playing: ${getSongText()}`);
|
||||
}
|
||||
}
|
||||
|
||||
player.on(AudioPlayerStatus.Idle, () => {
|
||||
console.log('Player idle, playing next song');
|
||||
playNext();
|
||||
});
|
||||
|
||||
player.on('error', (error) => {
|
||||
console.error(`Error: ${error.message} with resource`, error.resource);
|
||||
});
|
||||
|
||||
/**
|
||||
* Attempt to download a song from a URL, if it doesn't already exist.
|
||||
* Add the song to the queue.
|
||||
*/
|
||||
async function queueUrl(message: Message, words: string[])
|
||||
{
|
||||
let url = words[1];
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure it's a valid URL
|
||||
* Normalize various YouTube URLs to the same format
|
||||
*/
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
|
||||
if (parsed.hostname === 'youtu.be') {
|
||||
url = `https://www.youtube.com/watch?v=${parsed.pathname.slice(1)}`;
|
||||
}
|
||||
|
||||
if (parsed.hostname === 'youtube.com') {
|
||||
url = `https://www.youtube.com/watch?v=${parsed.searchParams.get('v')}`;
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
message.reply(`Invalid URL: ${inlineCode(url)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash the URL
|
||||
* This could create duplicated files
|
||||
*/
|
||||
const hash = crypto.createHash('sha256');
|
||||
hash.update(url);
|
||||
|
||||
const filename = hash.digest('hex');
|
||||
const path = `${config.downloadDir}/${filename}`;
|
||||
|
||||
// check if file exists
|
||||
const reply = await message.reply(`Downloading ${hideLinkEmbed(url)}`);
|
||||
if (!fs.existsSync(path)) {
|
||||
try {
|
||||
// Download the file
|
||||
await ytdlp.execPromise([
|
||||
url,
|
||||
'--write-info-json',
|
||||
'-x',
|
||||
'--audio-format',
|
||||
'mp3',
|
||||
'-o',
|
||||
`${path}.%(ext)s`,
|
||||
]);
|
||||
}
|
||||
catch (err) {
|
||||
reply.edit(`Failed to download ${hideLinkEmbed(url)}`);
|
||||
console.error(err);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const audioResource = createAudioResource(`${path}.mp3`, {
|
||||
inlineVolume: true,
|
||||
});
|
||||
|
||||
audioResource.volume.setVolume(volume);
|
||||
|
||||
const videoInfo = JSON.parse(fs.readFileSync(`${path}.info.json`, 'utf8'));
|
||||
|
||||
songQueue.push({
|
||||
url: url,
|
||||
path: `${path}.mp3`,
|
||||
message: message,
|
||||
audioResource: audioResource,
|
||||
videoInfo: videoInfo,
|
||||
songMeta: {
|
||||
title: videoInfo.title,
|
||||
uploader: videoInfo.uploader,
|
||||
duration: videoInfo.duration,
|
||||
},
|
||||
});
|
||||
|
||||
if (!currentSong) {
|
||||
console.log('No song currently playing, triggering playNext()');
|
||||
playNext();
|
||||
} else {
|
||||
console.log('Song currently playing, only adding to queue');
|
||||
}
|
||||
|
||||
const songInfo = `${videoInfo.title} - ${videoInfo.uploader} (${videoInfo.duration})`;
|
||||
reply.edit(`Added song to queue: ${songInfo}\n${hideLinkEmbed(url)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for a song on YouTube and add it to the queue
|
||||
* TODO: Use YouTube's API, not DecAPI.
|
||||
*/
|
||||
async function search(message: Message, words: string[])
|
||||
{
|
||||
let input = words[1];
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure it's a valid URL
|
||||
* Normalize various YouTube URLs to the same format
|
||||
*/
|
||||
try {
|
||||
// If this test passed, it's a valid URL
|
||||
const parsed = new URL(input);
|
||||
|
||||
// Therefore we simply use queueUrl instead.
|
||||
queueUrl(message, words);
|
||||
return;
|
||||
}
|
||||
catch (err) {
|
||||
// Do nothing
|
||||
}
|
||||
|
||||
const apiUrl = new URL('https://decapi.me/youtube/videoid');
|
||||
apiUrl.searchParams.set('search', input);
|
||||
apiUrl.searchParams.set('show_url', '1');
|
||||
const response = await axios.get(apiUrl.toString(), {
|
||||
headers: {
|
||||
'User-Agent': 'SquadMusicBot/1.0.0',
|
||||
},
|
||||
responseType: 'text',
|
||||
});
|
||||
|
||||
const url = await response.data;
|
||||
|
||||
if (!url) {
|
||||
message.reply(`No results for ${inlineCode(input)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.includes('Invalid')) {
|
||||
message.reply(`Invalid search: ${inlineCode(input)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
words[1] = url;
|
||||
queueUrl(message, words);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the volume
|
||||
*/
|
||||
async function setVolume(message: Message, words: string[])
|
||||
{
|
||||
if (!words[1]) {
|
||||
const currentVolume = (volume * 100).toString();
|
||||
message.reply(`Current volume: ${inlineCode(currentVolume)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const newVolume = parseFloat(words[1]);
|
||||
if (isNaN(newVolume)) {
|
||||
message.reply(`Invalid volume: ${inlineCode(words[1])}`);
|
||||
return;
|
||||
}
|
||||
|
||||
volume = newVolume / 100;
|
||||
if (currentSong) {
|
||||
currentSong.audioResource.volume.setVolume(volume);
|
||||
}
|
||||
|
||||
message.reply(`Set volume to ${inlineCode(newVolume.toString())}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the player and clear the queue
|
||||
*/
|
||||
async function stopPlayer(message: Message, words: string[])
|
||||
{
|
||||
message.reply('Stopping player and clearing queue');
|
||||
console.log('Stopping player');
|
||||
player.stop();
|
||||
songQueue = [];
|
||||
currentSong = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause the current player so it can be resumed later
|
||||
*/
|
||||
async function pausePlayer(message: Message, words: string[])
|
||||
{
|
||||
message.reply('Pausing player');
|
||||
console.log('Pausing player');
|
||||
player.pause();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume the current player
|
||||
*/
|
||||
async function resumePlayer(message: Message, words: string[])
|
||||
{
|
||||
message.reply('Resuming player');
|
||||
console.log('Resuming player');
|
||||
player.unpause();
|
||||
}
|
||||
|
||||
/**
|
||||
* List the current queue
|
||||
*/
|
||||
async function listQueue(message: Message, words: string[])
|
||||
{
|
||||
if (songQueue.length === 0) {
|
||||
message.reply('Current queue: Empty');
|
||||
return;
|
||||
}
|
||||
|
||||
const queueList = songQueue.map((song) => {
|
||||
return getSongText(song);
|
||||
});
|
||||
|
||||
message.reply(`Current queue:\n- ${queueList.join('\n- ')}`);
|
||||
}
|
||||
|
||||
const handlers = {
|
||||
'list': listQueue,
|
||||
'next': playNext,
|
||||
'pause': pausePlayer,
|
||||
'play': resumePlayer,
|
||||
'queueUrl': queueUrl,
|
||||
'search': search,
|
||||
'stop': stopPlayer,
|
||||
'volume': setVolume,
|
||||
} as { [key: string]: Function };
|
||||
|
||||
/**
|
||||
* Handle commands
|
||||
*/
|
||||
client.on('messageCreate', async (message: Message) => {
|
||||
if (message.author.bot) {
|
||||
return;
|
||||
}
|
||||
|
||||
const text = message.content;
|
||||
console.log(`Received message: ${text}`)
|
||||
const words = text.split(' ');
|
||||
if (words.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const command = words[0].toLowerCase();
|
||||
console.log(`Received command: ${command}`)
|
||||
const method = config.commands[command];
|
||||
if (!method) {
|
||||
console.log(`No method for command ${command}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const handler = handlers[method];
|
||||
if (!handler) {
|
||||
console.error(`No handler for method ${method}`);
|
||||
message.reply(`Command not implemented (yet): ${inlineCode(command)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Calling handler for method ${method}`)
|
||||
handler(message, words);
|
||||
});
|
||||
|
||||
client.on('ready', async () => {
|
||||
console.log(`Connected to Discord as ${client?.user?.tag}`);
|
||||
|
||||
const channel = await client.channels.fetch(config.voiceChannel) as VoiceChannel;
|
||||
if (!channel) {
|
||||
console.error(`Failed to find channel ${config.voiceChannel}`);
|
||||
return;
|
||||
}
|
||||
|
||||
voiceConnection = joinVoiceChannel({
|
||||
channelId: channel.id,
|
||||
guildId: channel.guild.id,
|
||||
adapterCreator: channel.guild.voiceAdapterCreator,
|
||||
});
|
||||
|
||||
if (!voiceConnection) {
|
||||
console.error(`Failed to join channel ${config.voiceChannel}`);
|
||||
return;
|
||||
}
|
||||
|
||||
voiceConnection.subscribe(player);
|
||||
});
|
||||
|
||||
function shutdown()
|
||||
{
|
||||
console.log('Shutting down');
|
||||
|
||||
if (voiceConnection) {
|
||||
voiceConnection.destroy();
|
||||
}
|
||||
|
||||
client.destroy();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on('SIGINT', shutdown);
|
||||
|
||||
async function init()
|
||||
{
|
||||
if (!fs.existsSync(config.ytDlpPath)) {
|
||||
console.log(`yt-dlp not found at ${config.ytDlpPath} - Attempting to download...`);
|
||||
await YTDlpWrap.downloadFromGithub(config.ytDlpPath);
|
||||
}
|
||||
|
||||
ytdlp = new YTDlpWrap(config.ytDlpPath);
|
||||
client.login(config.token);
|
||||
}
|
||||
|
||||
init();
|
16
interfaces/QueueEntry.ts
Normal file
16
interfaces/QueueEntry.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { AudioResource } from '@discordjs/voice';
|
||||
import { Message } from 'discord.js';
|
||||
|
||||
export default interface QueueEntry {
|
||||
url: string,
|
||||
path: string,
|
||||
message: Message,
|
||||
audioResource: AudioResource,
|
||||
// --write-info-json dump from yt-dlp
|
||||
videoInfo: Object,
|
||||
songMeta: {
|
||||
title: string | null | undefined,
|
||||
uploader: string | null | undefined,
|
||||
duration: number | null | undefined,
|
||||
},
|
||||
}
|
1220
package-lock.json
generated
Normal file
1220
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
package.json
Normal file
27
package.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "squad-musicbot",
|
||||
"version": "1.0.0",
|
||||
"description": "New music bot for Squad",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"build": "tsc --build && cp config.js dist/config.js",
|
||||
"start": "npm run build && node dist/index.js"
|
||||
},
|
||||
"author": "Alex Thomassen",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@discordjs/opus": "^0.8.0",
|
||||
"@discordjs/voice": "^0.14.0",
|
||||
"axios": "^1.2.2",
|
||||
"bufferutil": "^4.0.7",
|
||||
"discord.js": "^14.7.1",
|
||||
"erlpack": "github:discord/erlpack",
|
||||
"ffmpeg-static": "^5.1.0",
|
||||
"libsodium-wrappers": "^0.7.10",
|
||||
"sodium": "^3.0.2",
|
||||
"utf-8-validate": "^5.0.10",
|
||||
"yt-dlp-wrap": "^2.3.11",
|
||||
"zlib-sync": "^0.1.7"
|
||||
}
|
||||
}
|
16
tsconfig.json
Normal file
16
tsconfig.json
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"moduleResolution": "node",
|
||||
"target": "ES2021",
|
||||
"outDir": "dist",
|
||||
"module": "CommonJS",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": [
|
||||
"*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"dist",
|
||||
"node_modules"
|
||||
]
|
||||
}
|
Loading…
Reference in New Issue
Block a user