885 lines
22 KiB
TypeScript
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();
|