SquadMusicBot/index.ts

885 lines
22 KiB
TypeScript

import {
Client, GatewayIntentBits, Message,
inlineCode, hideLinkEmbed,
VoiceChannel,
Partials,
ActivityType,
codeBlock,
MessageReaction,
ChannelType,
TextChannel
} from 'discord.js';
import { joinVoiceChannel, VoiceConnection, createAudioPlayer, createAudioResource, AudioPlayerStatus, PlayerSubscription, StreamType } from '@discordjs/voice';
import * as Duration from 'tinyduration';
import axios from 'axios';
// @ts-ignore - Regardless of what TS says, we cannot import yargs-parser as a default import
import * as parser from 'yargs-parser';
import YTDlpWrap from 'yt-dlp-wrap';
import * as crypto from 'crypto';
import * as fs from 'fs';
import * as fsp from 'fs/promises';
import { spawn } from 'child_process';
const config = require('./config');
import QueueEntry from './interfaces/QueueEntry';
import YouTube from './services/YouTube';
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;
const player = createAudioPlayer();
const yt = new YouTube(config.youtubeApiKey);
const httpClient = axios.create();
let ytProxy = null as null | string;
if (config.ytdlpProxy) {
ytProxy = config.ytdlpProxy;
}
/**
* Bot state
*/
let songQueue = [] as QueueEntry[];
let currentSong: QueueEntry | null | undefined;
let voiceConnection: VoiceConnection;
let playerSubscription: PlayerSubscription;
let volume = config.defaultVolume;
let ffmpegProcess = null;
function logError(message: string | Object)
{
if (typeof message === 'string') {
console.error(`[${new Date().toISOString()}] ${message}`);
return;
}
console.error(`[${new Date().toISOString()}]`, message);
}
function log(message: string | Object)
{
if (typeof message === 'string') {
console.log(`[${new Date().toISOString()}] ${message}`);
return;
}
console.log(`[${new Date().toISOString()}]`, message);
}
/**
* Formats the given duration in ISO 8601 format into a simpler format.
*
* @param duration Duration in ISO 8601 format
* @returns Duration in HH:MM:SS format
*/
function formatDuration(duration: Duration.Duration): string
{
const valueOrder = ['days', 'hours', 'minutes', 'seconds'];
let result = '';
for (const durationType of valueOrder) {
const value = duration[durationType] as number | undefined;
if (value) {
const valueString = value.toString();
result += `${valueString.padStart(2, '0')}:`;
}
}
return result.replace(/:$/, '');
}
function formatSongText(songMeta: QueueEntry['songMeta'], withUrl: boolean = true)
{
const songInfo = `${songMeta.title} - ${songMeta.uploader} (${songMeta.duration})`;
if (!withUrl) {
return songInfo;
}
return `${songInfo} - ${hideLinkEmbed(songMeta.url)}`;
}
function getSongText(entry: QueueEntry | undefined = currentSong, withUrl: boolean = true)
{
if (!entry) {
return 'No song currently playing';
}
const info = entry.songMeta;
return formatSongText(info, withUrl);
}
/**
* Kill the FFmpeg process, if it exists
*/
function killFfmpeg()
{
if (ffmpegProcess) {
ffmpegProcess.kill('SIGKILL');
ffmpegProcess = null;
log('Killed FFmpeg process');
}
}
/**
* Initialize a new voice connection
* If a connection already exists, return that instead.
*
* @param {boolean} destroy - Destroy the current connection, if there is one. Will always create a new connection if this is set to true. Default: false
* @returns Promise<VoiceConnection | null>
*/
async function newVoiceConnection(destroy: boolean = false) : Promise<VoiceConnection | null>
{
if (voiceConnection) {
if (!destroy) {
return voiceConnection;
}
voiceConnection.destroy();
voiceConnection = null;
}
const channel = await client.channels.fetch(config.voiceChannel) as VoiceChannel;
if (!channel) {
logError(`Failed to find channel ${config.voiceChannel}`);
return null;
}
voiceConnection = joinVoiceChannel({
channelId: channel.id,
guildId: channel.guild.id,
adapterCreator: channel.guild.voiceAdapterCreator,
});
if (!voiceConnection) {
logError(`Failed to join channel ${config.voiceChannel}`);
return null;
}
return voiceConnection;
}
/**
* 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[] = [])
{
if (currentSong && currentSong.isStream) {
killFfmpeg();
player.pause();
}
const previous = getSongText();
currentSong = songQueue.shift();
if (!currentSong) {
log('No more songs in queue, stopping player');
client.user.setPresence({
activities: [],
});
player.stop();
return;
}
currentSong.audioResource.volume.setVolume(volume);
player.play(currentSong.audioResource);
const songText = getSongText(currentSong, false);
client.user.setPresence({
activities: [{
name: songText,
url: currentSong.songMeta.url,
type: ActivityType.Listening,
}],
});
const channel = message?.channel as TextChannel;
if (message) {
channel.send(`Skipped: ${previous}\nNow playing: ${songText}`);
}
}
player.on('stateChange', (oldState, newState) => {
if (newState.status === AudioPlayerStatus.Playing) {
log(`Started playing ${getSongText()}`);
}
else if (newState.status === AudioPlayerStatus.Paused) {
log(`Paused ${getSongText()}`);
}
else if (newState.status === AudioPlayerStatus.AutoPaused) {
log(`Auto paused ${getSongText()}`);
}
else if (newState.status === AudioPlayerStatus.Idle) {
log(`Stopped playing ${getSongText()}`);
}
});
player.on(AudioPlayerStatus.Idle, () => {
killFfmpeg();
log('Player idle, playing next song');
playNext();
});
player.on('error', (error) => {
logError(`Error: ${error.message} with resource`);
logError(error.resource);
});
/**
* Stream a URL to the voice channel
*/
async function stream(message: Message, words: string[], options: Object | undefined = null)
{
if (!options) {
options = parser(words.slice(1).join(' '));
}
const channel = message.channel as TextChannel;
if (options['_'].length === 0) {
channel.send('No URL provided');
return false;
}
let url = options['_'][0] ?? null;
if (!url) {
channel.send('No URL provided');
return false;
}
let parsed: URL;
try {
parsed = new URL(url);
}
catch (err) {
channel.send(`Invalid URL provided: ${inlineCode(url)}`);
return false;
}
const response = await httpClient.head(url);
const headers = response.headers;
const contentType = (headers['content-type'] || '').trim().toLowerCase();
console.log('Content type:', contentType);
if (contentType === 'audio/x-mpegurl') {
const getRequest = await httpClient.get(url);
const newUrl = getRequest.data.trim();
options['_'] = [newUrl];
log('Calling stream() with new URL: ' + newUrl);
return await stream(message, words, options);
}
if (!contentType.includes('audio')) {
channel.send(`Invalid content type: ${headers['content-type']}`);
return false;
}
url = parsed.href;
const songMeta = {
title: parsed.hostname,
uploader: parsed.hostname,
duration: 'Stream',
url: url,
} as QueueEntry['songMeta'];
ffmpegProcess = spawn('ffmpeg', [
'-reconnect', '1',
'-reconnect_streamed', '1',
'-reconnect_delay_max', '5',
'-i', url,
'-f', 's16le',
'-ar', '48000',
'-ac', '2',
'-loglevel', 'error',
'pipe:1'
]);
ffmpegProcess.stderr.on('data', (data) => {
logError('FFmpeg error: ' + data.toString());
});
ffmpegProcess.on('close', (code) => {
console.log(`FFmpeg process closed with code ${code}`);
});
const audioResource = createAudioResource(ffmpegProcess.stdout, {
inlineVolume: true,
inputType: StreamType.Raw,
});
audioResource.volume.setVolume(volume);
songQueue.push({
url: url,
path: url,
message: message,
audioResource: audioResource,
isStream: true,
videoInfo: null,
songMeta: songMeta,
botMeta: {
message,
words,
options,
}
});
if (!currentSong) {
log('Nothing currently playing, triggering playNext()');
playNext();
} else {
log('Song currently playing, only adding to queue');
}
await channel.send(`Added stream to queue: ${hideLinkEmbed(url)}`);
}
/**
* Attempt to download a song from a URL, if it doesn't already exist.
* Add the song to the queue.
*
* TODO: Refactor YouTube handling, so that we can also queue SoundCloud and other services
*/
const validYouTubeUrls = ['youtu.be', 'youtube.com', 'www.youtube.com'];
async function queueUrl(message: Message, words: string[], options: Object | undefined = null)
{
let url = words[1];
if (!url) {
return;
}
if (!options) {
options = parser(words.slice(1).join(' '));
}
const channel = message.channel as TextChannel;
/**
* Make sure it's a valid URL
* Normalize various YouTube URLs to the same format
*/
let parsed: URL;
try {
parsed = new URL(url);
if (!validYouTubeUrls.includes(parsed.hostname)) {
channel.send(`Invalid URL: ${inlineCode(url)}`);
return;
}
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) {
channel.send(`Invalid URL: ${inlineCode(url)}`);
return;
}
let reply = await channel.send(`Retrieving video info for URL: ${hideLinkEmbed(url)}`);
parsed = new URL(url);
const videoId = parsed.searchParams.get('v');
const videoInfo = await yt.getVideoInfo(videoId);
if (!videoInfo) {
channel.send(`Failed to get video info for: ${hideLinkEmbed(url)}`);
return;
}
const apiDuration = videoInfo.contentDetails.duration;
const length = Duration.parse(apiDuration);
const textDuration = formatDuration(length);
const title = videoInfo.snippet.title;
const uploader = videoInfo.snippet.channelTitle;
if (length.hours > 0 || length.minutes > 20) {
channel.send(`Video too long: ${textDuration}`);
return;
}
const songMeta = {
title,
uploader,
duration: textDuration,
url: url,
} as QueueEntry['songMeta'];
/**
* Hash the URL
* This could create duplicated files
*/
const hash = crypto.createHash('sha256');
hash.update(url);
const filename = hash.digest('hex');
const fullVideo = options['full'];
let path = `${config.downloadDir}/${filename}`;
if (fullVideo) {
path += '_full';
}
await fsp.writeFile(`${path}.info.json`, JSON.stringify(videoInfo));
const videoText = formatSongText(songMeta);
await reply.edit(`Downloading: ${videoText}`);
// Only download if the file doesn't already exist
if (!fs.existsSync(path)) {
let ytdlpParams = [
url,
'-x',
'--audio-format',
'mp3',
'-o',
`${path}.%(ext)s`,
];
if (ytProxy) {
ytdlpParams = ytdlpParams.concat([
'--proxy',
ytProxy,
]);
}
if (!fullVideo) {
ytdlpParams = ytdlpParams.concat([
'--sponsorblock-remove',
'music_offtopic',
]);
}
try {
// Download the file
await ytdlp.execPromise(ytdlpParams);
}
catch (err) {
await reply.edit(`Failed to download ${videoText}`);
logError(err);
return;
}
}
const audioResource = createAudioResource(`${path}.mp3`, {
inlineVolume: true,
});
audioResource.volume.setVolume(volume);
songQueue.push({
url: url,
path: `${path}.mp3`,
message: message,
isStream: false,
audioResource: audioResource,
videoInfo,
songMeta: songMeta,
botMeta: {
message,
words,
options,
}
});
if (!currentSong) {
log('No song currently playing, triggering playNext()');
playNext();
} else {
log('Song currently playing, only adding to queue');
}
await reply.edit(`Added song to queue: ${videoText}`);
}
/**
* Reconnect to the voice channel
*
* @param {Message} message
* @param {string[]} words
*/
async function triggerReconnect(message: Message, words: string[])
{
const channel = message.channel as TextChannel;
await channel.send('Reconnecting to voice channel');
await newVoiceConnection(true);
}
/**
* Add a reaction to a message
* Silently fails if it can't add the reaction.
*
* @param {Message} message
* @param {string} reaction
* @returns Promise<MessageReaction | null>
*/
async function addReaction(message: Message, reaction: string) : Promise<MessageReaction | null>
{
try {
return await message.react(reaction);
}
catch (err) { /* Reactions honestly aren't that important, even if they don't get added, so whatever */ }
return null;
}
/**
* Attempts to remove a reaction.
* It silently fails if it can't remove the reaction, as we don't really care.
*
* @param {MessageReaction} reaction
*/
async function removeReaction(reaction: MessageReaction)
{
try {
await reaction.remove();
}
catch (err) { /* Reactions honestly aren't that important, even if they don't get removed, so whatever */ }
}
/**
* Search for a song on YouTube and add it to the queue
* Will attempt to use the 'Topic' version of the song if it exists
*/
async function search(message: Message, words: string[]) : Promise<boolean>
{
let input = words[1];
if (!input) {
return;
}
const options = parser(words.slice(1));
/**
* 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
}
// Add a "searching" reaction
const searching = await addReaction(message, '🔍');
const search = options['_'].join(' ');
const results = await yt.searchVideos(search, 10);
const escapedSearch = search.includes('\n') ? codeBlock(search) : inlineCode(search);
const channel = message.channel as TextChannel;
if (results.length === 0) {
channel.send(`No results for: ${escapedSearch}`);
if (searching) {
removeReaction(searching);
}
return false;
}
let result = results[0];
if (options['topic']) {
const topicResult = results.find(video => video.snippet.channelTitle.includes('- Topic'));
if (topicResult) {
result = topicResult;
console.log('"Topic" found, using that instead of the first result', result);
}
}
if (searching) {
removeReaction(searching);
}
const url = `https://www.youtube.com/watch?v=${result.id.videoId}`;
if (!url) {
channel.send(`No results for: ${escapedSearch}`);
return false;
}
if (url.includes('Invalid')) {
channel.send(`Invalid search: ${escapedSearch}`);
return false;
}
// Add the URL to the queue
words[1] = url;
queueUrl(message, words, options);
return true;
}
/**
* Update the volume
*/
async function setVolume(message: Message, words: string[]) : Promise<boolean>
{
const channel = message.channel as TextChannel;
if (!words[1]) {
const currentVolume = (volume * 100).toString();
await channel.send(`Current volume is ${currentVolume}%`);
return false;
}
const newVolume = parseFloat(words[1]);
if (isNaN(newVolume)) {
await channel.send(`Invalid volume [0-100]: ${inlineCode(words[1])}`);
return false;
}
if (newVolume < 0 || newVolume > 100) {
await channel.send(`Invalid volume [0-100]: ${inlineCode(words[1])}`);
return false;
}
volume = newVolume / 100;
if (currentSong) {
currentSong.audioResource.volume.setVolume(volume);
}
await channel.send(`Set volume to ${newVolume.toString()}%`);
}
/**
* Stop the player and clear the queue
*/
async function stopPlayer(message: Message, words: string[])
{
const channel = message.channel as TextChannel;
await channel.send('Stopping player and clearing queue');
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[])
{
const channel = message.channel as TextChannel;
await channel.send('Pausing player, queue will be preserved');
log('Pausing player');
player.pause();
}
/**
* Resume the current player
*/
async function resumePlayer(message: Message, words: string[])
{
const channel = message.channel as TextChannel;
await channel.send('Resuming player');
log('Resuming player');
player.unpause();
}
/**
* List the current queue
*/
async function listQueue(message: Message, words: string[])
{
const channel = message.channel as TextChannel;
if (songQueue.length === 0) {
channel.send('Current queue: Empty');
return;
}
const queueList = songQueue.map((song) => {
return getSongText(song);
});
await channel.send(`Current queue [${songQueue.length}]:\n- ${queueList.join('\n- ')}`);
}
/**
* Get the current song
*/
async function getCurrentSong(message: Message, words: string[])
{
const channel = message.channel as TextChannel;
if (!currentSong) {
channel.send('No song currently playing');
return;
}
const songText = getSongText(currentSong);
await channel.send(`Current song: ${songText}`);
}
const handlers = {
'currentSong': getCurrentSong,
'list': listQueue,
'next': playNext,
'pause': pausePlayer,
'play': resumePlayer,
'queueUrl': queueUrl,
'reconnect': triggerReconnect,
'search': search,
'stop': stopPlayer,
'stream': stream,
'volume': setVolume,
} as { [key: string]: Function };
/**
* Handle commands
*/
const ignoreBots = config.ignoreBots || false;
client.on('messageCreate', async (message: Message) => {
// Ignore messages from the bot itself.
if (message.author.id === client.user.id) {
return;
}
if (ignoreBots && message.author.bot) {
return;
}
const channel = message.channel;
if (channel.type === ChannelType.GuildStageVoice) {
return;
}
const text = message.content;
const words = text.split(' ');
if (words.length === 0) {
return;
}
const command = words[0].toLowerCase();
const method = config.commands[command];
if (!method) {
return;
}
const handler = handlers[method];
if (!handler) {
logError(`No handler for method ${method}`);
return;
}
log(`Command used: ${command} [handler: ${method}] by ${message.author.tag}`);
let result: any;
try {
result = await handler(message, words);
}
catch (err) {
logError(err);
await addReaction(message, '❌');
return;
}
if (result === false) {
await addReaction(message, '❌');
return;
}
if (result === 'noreact') {
return;
}
await addReaction(message, '✅');
});
client.on('ready', async () => {
log(`Connected to Discord as ${client?.user?.tag}`);
await newVoiceConnection();
/**
* Thanks to: https://github.com/discordjs/discord.js/issues/9185#issuecomment-1452514375
* Could maybe be removed in the future?
*/
voiceConnection.on('stateChange', (oldState, newState) => {
const oldNetworking = Reflect.get(oldState, 'networking');
const newNetworking = Reflect.get(newState, 'networking');
const networkStateChangeHandler = (oldNetworkState: any, newNetworkState: any) => {
const newUdp = Reflect.get(newNetworkState, 'udp');
clearInterval(newUdp?.keepAliveInterval);
}
oldNetworking?.off('stateChange', networkStateChangeHandler);
newNetworking?.on('stateChange', networkStateChangeHandler);
});
playerSubscription = voiceConnection.subscribe(player);
});
client.on('error', (error) => {
logError(error);
});
client.on('warn', (warning) => {
logError(warning);
});
async function shutdown()
{
log('Shutting down');
killFfmpeg();
if (voiceConnection) {
voiceConnection.destroy();
}
await client.destroy();
process.exit(0);
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
async function init()
{
if (!fs.existsSync(config.ytDlpPath)) {
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();