Initial implementation

This commit is contained in:
Alex Thomassen 2023-01-07 00:23:48 +00:00
commit f75446fe48
Signed by: Alex
GPG Key ID: 10BD786B5F6FF5DE
10 changed files with 1909 additions and 0 deletions

12
.editorconfig Normal file
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,2 @@
*
!.gitignore

414
index.ts Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

27
package.json Normal file
View 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
View File

@ -0,0 +1,16 @@
{
"compilerOptions": {
"moduleResolution": "node",
"target": "ES2021",
"outDir": "dist",
"module": "CommonJS",
"allowSyntheticDefaultImports": true
},
"include": [
"*.ts"
],
"exclude": [
"dist",
"node_modules"
]
}