Larger refactoring and added avoidlist

This commit is contained in:
Myx
2023-10-21 05:09:38 +02:00
parent ccf7a218fd
commit 8fd3eeae03
30 changed files with 753 additions and 213 deletions

3
avoid-list.json Normal file
View File

@@ -0,0 +1,3 @@
{
"avoidUsers": []
}

96
bot.ts
View File

@@ -14,11 +14,17 @@ import {
VoiceConnectionStatus,
VoiceConnection
} from '@discordjs/voice';
import { ChannelType, Client, GatewayIntentBits, GuildBasedChannel } from 'discord.js';
import { ChannelType, Collection, GuildBasedChannel, Snowflake, VoiceChannel, VoiceState } from 'discord.js';
import * as dotenv from 'dotenv';
import * as fileSystem from 'fs';
import { startServer as startWebServer } from './client/webserver';
import * as schedule from 'node-schedule';
import { loadAvoidList } from './helpers/load-avoid-list';
import { LoggerColors } from './helpers/logger-colors';
import { getRandomSoundFilePath } from './helpers/get-random-sound-file-path';
import { logger } from './helpers/logger';
import { SetupDiscordCLient } from './helpers/setup-discord-client';
import { convertHoursToMinutes, dateToString } from './helpers/converters';
import { AvoidList } from './models/avoid-list';
dotenv.config();
@@ -26,28 +32,7 @@ export var nextPlayBackTime: string = ''; // Export so it can be used in the web
const minTimeInterval = parseInt(process.env.INTERVALMIN_MINUTES!, 10); // Minimum interval in minutes.
const maxTimeInterval = convertHoursToMinutes(parseFloat(process.env.INTERVALMAX_HOURS!)); // Maximum interval in minutes.
const voiceChannelRetries = parseInt(process.env.VOICECHANNELRETRIES!, 10); // Number of retries to find a voice channel with members in it.
const discordApplicationToken = process.env.TOKEN; // Discord bot token from .env file (required) More info: https://discordgsm.com/guide/how-to-get-a-discord-bot-token
const soundsDirectory = './sounds/';
const discordClient = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
GatewayIntentBits.GuildVoiceStates,
GatewayIntentBits.GuildIntegrations,
],
});
export class LoggerColors {
public static readonly Green: string = "\x1b[32m%s\x1b[0m";
public static readonly Yellow: string = "\x1b[33m%s\x1b[0m";
public static readonly Cyan: string = "\x1b[36m%s\x1b[0m";
public static readonly Red: string = "\x1b[31m%s\x1b[0m";
public static readonly Teal: string = "\x1b[35m%s\x1b[0m";
}
discordClient.login(discordApplicationToken);
const discordClient = SetupDiscordCLient();
discordClient.on('ready', async () => {
console.log(LoggerColors.Green, `Add to server by: https://discord.com/oauth2/authorize?client_id=${discordClient.application?.id}&permissions=70379584&scope=bot`);
@@ -89,9 +74,10 @@ export async function joinRandomChannel(retries = 12) {
}
const randomlyPickedVoiceChannel = accessableVoiceChannels.random();
try {
// Join the voice channel
if(isUserFromAvoidListNotInVoiceChannel(randomlyPickedVoiceChannel!)) {
const voiceChannelConnection = joinVoiceChannel({
channelId: randomlyPickedVoiceChannel!.id,
guildId: randomlyPickedVoiceChannel!.guild.id,
@@ -111,6 +97,7 @@ export async function joinRandomChannel(retries = 12) {
soundFilePath,
randomlyPickedVoiceChannel,
voiceChannelConnection);
}
} catch (error) {
console.error(error);
@@ -143,15 +130,6 @@ async function playSoundFile(
voiceChannelConnection.destroy();
}
/**
* Returns a random sound file from the sounds directory.
* @returns string - The path to a random sound file.
*/
function getRandomSoundFilePath(): string {
const allSoundFilesAsArray = fileSystem.readdirSync(soundsDirectory).filter(file => file.endsWith('.mp3'));
return soundsDirectory + allSoundFilesAsArray[Math.floor(Math.random() * allSoundFilesAsArray.length)];
}
/**
* Schedules the next join to a random channel. Using a random interval between minTime and maxTime.
* It clears the previous schedule before scheduling the next join, to avoid multiple schedules.
@@ -172,36 +150,34 @@ function scheduleNextJoin(): void {
let nextPlaybackDate = schedule.scheduledJobs[jobName].nextInvocation();
nextPlayBackTime = dateToString(nextPlaybackDate) ?? '';
log(nextPlaybackDate, hours, minutes);
logger(nextPlaybackDate, hours, minutes);
});
}
function convertHoursToMinutes(hours: number): number {
return hours * 60;
}
/**
* Logs the wait time, current time, next join time, and cron schedule in the console.
* @param waitTime - The time to wait until the next join.
* @param hour - The hour of the cron schedule.
* @param minute - The minute of the cron schedule.
*/
function log(
waitTime: Date,
hour: number,
minute: number
){
const currentTime = new Date();
function isUserFromAvoidListNotInVoiceChannel(channel: GuildBasedChannel): boolean {
const avoidList: AvoidList = loadAvoidList();
const voiceChannel = channel as VoiceChannel;
const voiceStates: Collection<Snowflake, VoiceState> = voiceChannel.guild.voiceStates.cache;
const membersInVoiceChannel = voiceStates.filter(voiceState => voiceState.channelId === voiceChannel.id);
console.log(
LoggerColors.Cyan, `
Wait time: ${(waitTime.getTime() - currentTime.getTime()) / 60000} minutes,
Current time: ${dateToString(currentTime)},
Next join time: ${dateToString(waitTime)},
Cron: ${Math.floor(minute)} ${Math.floor(hour) == 0 ? '*' : Math.floor(hour) } * * *`
);
}
if(avoidList.avoidUsers.length === 0)
return true;
function dateToString(date: Date): string {
return date.toLocaleString('sv-SE', { timeZone: 'Europe/Stockholm' });
if (channel.type !== ChannelType.GuildVoice)
return true;
// Check if any member from the avoid list is in the voice channel
for (const voiceState of membersInVoiceChannel.values()) {
if(!voiceState.member)
continue;
if (avoidList.avoidUsers.includes(voiceState.member.user.username)) {
console.log(LoggerColors.Yellow, `${voiceState.member.user.username} is in the avoid list, skipping...`);
return false;
}
}
// No member from the avoid list is in the voice channel
return true;
}

View File

@@ -0,0 +1,20 @@
import * as fileSystem from 'fs';
import express from 'express';
import { loadAvoidList } from '../../helpers/load-avoid-list';
/**
* Adds a user to the avoid list.
* @param user - The user to add to the avoid list.
* @returns void
*/
export function AddUserToAvoidList(response: express.Response, request: express.Request) {
const avoidList = loadAvoidList();
if (avoidList.avoidUsers.includes(request.body.user)) {
response.send('User already in avoid list.');
} else {
avoidList.avoidUsers.push(request.body.user);
fileSystem.writeFileSync('avoid-list.json', JSON.stringify(avoidList, null, "\t"));
response.send('User added to avoid list.');
}
}

View File

@@ -0,0 +1,19 @@
import express from 'express';
import path from 'path';
import * as fileSystem from 'fs';
import { LoggerColors } from '../../helpers/logger-colors';
/**
* Deletes a file from the sounds folder by filename
*/
export function DeleteSoundFile(response: express.Response, request: express.Request) {
const directoryPath = path.join(__dirname, '../../sounds');
const filePath = directoryPath + '/' + request.params.filename;
fileSystem.unlink(filePath, (err: any) => {
if (err) {
return console.log(LoggerColors.Red, 'Unable to delete file: ' + err);
}
response.send('File deleted successfully.');
});
}

View File

@@ -0,0 +1,20 @@
import express from 'express';
import fs from 'fs';
import { loadAvoidList } from '../../helpers/load-avoid-list';
/**
* Removes a user from the avoid list.
* @param user - The user to remove from the avoid list.
* @returns void
*/
export function DeleteUserFromAvoidList(response: express.Response, request: express.Request) {
const avoidList = loadAvoidList();
if (avoidList.avoidUsers.includes(request.params.user)) {
avoidList.avoidUsers = avoidList.avoidUsers.filter(user => user !== request.params.user);
fs.writeFileSync('avoid-list.json', JSON.stringify(avoidList, null, "\t"));
response.send('User removed from avoid list.');
} else {
response.send('User not in avoid list.');
}
}

View File

@@ -0,0 +1,18 @@
import express from 'express';
import path from 'path';
import * as fileSystem from 'fs';
import { LoggerColors } from '../../helpers/logger-colors';
/**
* Returns a list of all sound files in the sounds directory.
* @returns string[] - An array of all sound files in the sounds directory.
*/
export function GetSoundFiles(response: express.Response) {
const directoryPath = path.join(__dirname, '../../sounds');
fileSystem.readdir(directoryPath, function (error: any, files: any[]) {
if (error) {
return console.log(LoggerColors.Red, 'Unable to scan directory: ' + error);
}
response.send(files);
});
}

15
client/handlers/index.ts Normal file
View File

@@ -0,0 +1,15 @@
import { AddUserToAvoidList } from './addUserToAvoidList';
import { DeleteSoundFile } from './deleteSoundFile';
import { GetSoundFiles } from './getSoundFiles';
import { UploadYouTubeFile } from './uploadYouTubeFile';
import { DeleteUserFromAvoidList } from './deleteUserFromAvoidList';
import { JoinChannel } from './joinChannel';
export class Handlers {
public static AddUserToAvoidList = AddUserToAvoidList;
public static DeleteSoundFile = DeleteSoundFile;
public static GetSoundFiles = GetSoundFiles;
public static UploadYouTubeFile = UploadYouTubeFile;
public static DeleteUserFromAvoidList = DeleteUserFromAvoidList;
public static JoinChannel = JoinChannel;
}

View File

@@ -0,0 +1,11 @@
import { joinRandomChannel } from "../../bot";
import express from 'express';
/**
* Joins a random channel and plays a random sound file.
* @returns void
*/
export function JoinChannel(response: express.Response) {
joinRandomChannel();
response.send('Joining random channel.');
}

View File

@@ -0,0 +1,60 @@
import express from 'express';
import path from 'path';
import ytdl from 'ytdl-core';
import * as fileSystem from 'fs';
import ffmpeg from 'fluent-ffmpeg';
import { generateFileName } from '../../helpers/generate-file-name';
/**
* Uploads a YouTube video as an mp3 file to the sounds folder.
* The video must be shorter than 10 seconds.
* @Body url - The YouTube video url.
*/
export async function UploadYouTubeFile(response: express.Response, request: express.Request) {
const url = request.body.url;
if (ytdl.validateURL(url)) {
const info = await ytdl.getInfo(url);
// remove special characters from the title and white spaces
const title = info.videoDetails.title.replace(/[^a-zA-Z ]/g, "").replace(/\s+/g, '-').toLowerCase();
// Create a temporary directory to store the uploaded file so validation can be done
const tempDir = fileSystem.mkdtempSync('temp');
const outputFilePath = path.resolve(tempDir, generateFileName(title));
const videoReadableStream = ytdl(url, { filter: 'audioonly' });
const fileWritableStream = fileSystem.createWriteStream(outputFilePath);
videoReadableStream.pipe(fileWritableStream);
fileWritableStream.on('finish', () => {
ffmpeg.ffprobe(outputFilePath, function (err, metadata) {
if (err) {
fileSystem.rmSync(tempDir, { recursive: true, force: true });
return response.status(500).send('Error occurred during processing.');
}
const duration = metadata.format.duration;
if (duration == undefined) {
fileSystem.rmSync(tempDir, { recursive: true, force: true });
return response.status(400).send('Something went wrong.');
}
if (duration > 10) {
fileSystem.rmSync(tempDir, { recursive: true, force: true });
return response.status(400).send('File is longer than 10 seconds.');
} else {
// Move the file from the temporary directory to its final destination
const finalFilePath = path.resolve(__dirname, '../../sounds/', generateFileName(title));
fileSystem.renameSync(outputFilePath, finalFilePath);
response.send('File uploaded successfully.');
}
// Remove the temporary directory and its contents once done
fileSystem.rmSync(tempDir, { recursive: true, force: true });
});
});
} else {
response.status(400).send('Invalid url provided.');
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -106,6 +106,7 @@ function loadFiles() {
// Call loadFiles when the script is loaded
loadFiles();
loadNextPlaybackTime();
updateAvoidList();
document.getElementById('uploadForm').addEventListener('submit', function(event) {
event.preventDefault();
@@ -185,3 +186,79 @@ document.getElementById('uploadForm').addEventListener('submit', function(event)
alert('Please select a file or paste a YouTube link.');
}
});
document.getElementById('avoidForm').addEventListener('submit', function(event) {
event.preventDefault();
const user = document.getElementById('avoidUser').value;
fetch('/avoidlist', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ user }),
})
.then(response => response.text())
.then((data) => {
updateAvoidList();
document.getElementById('avoidUser').value = '';
})
.catch((error) => {
console.error('Error:', error);
});
});
document.getElementById('removeUser').addEventListener('click', function() {
const user = document.getElementById('avoidUser').value;
fetch(`/avoidlist/${user}`, {
method: 'DELETE',
})
.then(_ => {
updateAvoidList();
})
.catch((error) => {
console.error('Error:', error);
});
});
function updateAvoidList() {
fetch('/avoidlist')
.then(response => response.json())
.then(data => {
const avoidListElement = document.getElementById('avoidList');
// Clear the avoid list.
avoidListElement.innerHTML = '';
// Add each user in the avoid list to the UI.
data.avoidUsers.forEach(user => {
const listItem = document.createElement('li');
listItem.textContent = user;
listItem.className = 'list-group-item list-group-item-action d-flex justify-content-between align-items-center';
// Add a button to remove the user from the avoid list.
const removeButton = document.createElement('button');
removeButton.textContent = 'Remove';
removeButton.className = 'btn btn-success';
removeButton.addEventListener('click', function() {
fetch(`/avoidlist/${user}`, {
method: 'DELETE',
})
.then(_ => {
updateAvoidList();
})
.catch((error) => {
console.error('Error:', error);
});
});
listItem.appendChild(removeButton);
avoidListElement.appendChild(listItem);
});
})
.catch((error) => {
console.error('Error:', error);
});
}

View File

@@ -1,16 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RandomMemer upload sounds</title>
<link href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/css/bootstrap.min.css" rel="stylesheet">
</head>
<body class="bg-dark text-white">
<link rel="icon" href="assets/favicon.ico" type="image/x-icon"/>
</head>
<body class="bg-dark text-white">
<div class="container py-5">
<div class="row justify-content-center">
<div class="col-md-6">
<img src="logo.svg" alt="Logo" class="img-fluid mx-auto d-block mb-5">
<img src="assets/logo.svg" alt="Logo" class="img-fluid mx-auto d-block mb-5">
<form id="uploadForm" enctype="multipart/form-data" class="mb-4 dropzone dz-clickable">
<h3>Upload a sound</h3>
<div class="form-group">
@@ -25,12 +26,26 @@
<button type="submit" class="btn btn-primary btn-block">Upload</button>
</form>
<button id="joinButton" class="btn btn-info btn-block">Join</button>
<button id="joinButton" class="btn btn-info btn-block">Trigger a join</button>
<div id="nextPlaybackTime" class="mb-4">
Endpoint not loading!?
</div>
<div>
<h3>Avoid list</h3>
<p>A list of users the bot should never be around</p>
<form id="avoidForm" class="mb-4">
<div class="form-group">
<input type="text" id="avoidUser" class="form-control" placeholder="Enter user to avoid">
</div>
<button type="submit" class="btn btn-info btn-block">Add</button>
</form>
<ul id="avoidList" class="list-group"></ul>
</div>
<hr class="my-4">
<div>
<h3>Uploaded Files</h3>
<ul id="fileList" class="list-group"></ul>
@@ -41,6 +56,11 @@
<!-- Include Dropzone JS -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/dropzone/5.7.0/dropzone.js"></script>
<script src="client.js"></script>
</body>
<script src="scripts/avoid-list.js" type="module"></script>
<script src="scripts/avoid-form.js" type="module"></script>
<script src="scripts/upload.js" type="module"></script>
<script src="scripts/file-list.js" type="module"></script>
<script src="scripts/index.js" type="module"></script>
<script src="scripts/join.js" type="module"></script>
</body>
</html>

View File

@@ -0,0 +1,48 @@
import { updateAvoidList } from './avoid-list.js';
const avoidForm = document.getElementById('avoidForm');
if (avoidForm) {
avoidForm.addEventListener('submit', function (event) {
event.preventDefault();
const user = document.getElementById('avoidUser').value;
if(!user)
return;
fetch('/avoidlist', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
user
}),
})
.then(response => response.text())
.then((data) => {
updateAvoidList();
document.getElementById('avoidUser').value = '';
})
.catch((error) => {
console.error('Error:', error);
});
});
const removeUser = document.getElementById('removeUser');
if(removeUser){
document.getElementById('removeUser').addEventListener('click', function () {
const user = document.getElementById('avoidUser').value;
fetch(`/avoidlist/${user}`, {
method: 'DELETE',
})
.then(_ => {
updateAvoidList();
})
.catch((error) => {
console.error('Error:', error);
});
})
}
};

View File

@@ -0,0 +1,39 @@
export function updateAvoidList() {
fetch('/avoidlist')
.then(response => response.json())
.then(data => {
const avoidListElement = document.getElementById('avoidList');
// Clear the avoid list.
avoidListElement.innerHTML = '';
// Add each user in the avoid list to the UI.
data.avoidUsers.forEach(user => {
const listItem = document.createElement('li');
listItem.textContent = user;
listItem.className = 'list-group-item list-group-item-action d-flex justify-content-between align-items-center';
// Add a button to remove the user from the avoid list.
const removeButton = document.createElement('button');
removeButton.textContent = 'Remove';
removeButton.className = 'btn btn-success';
removeButton.addEventListener('click', function () {
fetch(`/avoidlist/${user}`, {
method: 'DELETE',
})
.then(_ => {
updateAvoidList();
})
.catch((error) => {
console.error('Error:', error);
});
});
listItem.appendChild(removeButton);
avoidListElement.appendChild(listItem);
});
})
.catch((error) => {
console.error('Error:', error);
});
}

View File

@@ -0,0 +1,67 @@
export function loadFiles() {
// Fetch the JSON data from the /sounds endpoint
fetch('/sounds')
.then(response => response.json())
.then(data => {
// Get the fileList element
const fileList = document.getElementById('fileList');
// Clear the current list
fileList.innerHTML = '';
// Add each file to the list
data.forEach(file => {
// Create a new list item
const li = document.createElement('li');
li.className = 'list-group-item list-group-item-action d-flex justify-content-between align-items-center'
// Create a div for the file name and add it to the list item
const fileNameDiv = document.createElement('div');
fileNameDiv.textContent = file;
li.appendChild(fileNameDiv);
// Create a div for the icons and add it to the list item
const iconDiv = document.createElement('div');
li.appendChild(iconDiv);
// Create a div for the play icon and add it to the icon div
const playIconDiv = document.createElement('span');
playIconDiv.style.cursor = 'pointer';
playIconDiv.textContent = '▶️';
iconDiv.appendChild(playIconDiv);
// Attach a click event listener to the play icon div
playIconDiv.addEventListener('click', () => {
// Create a new audio object and play the file
let audio = new Audio('/sounds/' + file);
audio.play();
});
// Create a div for the trash icon and add it to the icon div
const trashIconDiv = document.createElement('span');
trashIconDiv.style.cursor = 'pointer';
trashIconDiv.textContent = '🗑️';
iconDiv.appendChild(trashIconDiv);
// Attach a click event listener to the trash icon div
trashIconDiv.addEventListener('click', () => {
// Send a DELETE request to the server to remove the file
fetch('/sounds/' + file, {
method: 'DELETE'
})
.then(response => response.text())
.then(message => {
console.log(message);
// Remove the list item from the fileList element
fileList.removeChild(li);
})
.catch(error => console.error('Error:', error));
});
// Add the list item to the fileList element
fileList.appendChild(li);
});
})
.catch(error => console.error('Error:', error));
}

View File

@@ -0,0 +1,8 @@
import { loadFiles } from './file-list.js';
import { updateAvoidList } from './avoid-list.js';
import { loadNextPlaybackTime } from './upload.js';
// Call loadFiles when the script is loaded
loadFiles();
loadNextPlaybackTime();
updateAvoidList();

View File

@@ -0,0 +1,18 @@
document.getElementById('joinButton').addEventListener('click', function() {
this.disabled = true;
fetch('/join')
.then(response => response.text())
.then(data => {
console.log(data);
setTimeout(() => {
this.disabled = false;
}, 40000);
})
.catch((error) => {
console.error('Error:', error);
setTimeout(() => {
this.disabled = false;
}, 1000);
});
});

View File

@@ -0,0 +1,100 @@
import { loadFiles } from "./file-list.js";
document.addEventListener('DOMContentLoaded', (event) => {
document.querySelector('#myFile').addEventListener('change', function(e) {
var fileName = e.target.files[0].name;
var nextSibling = e.target.nextElementSibling
nextSibling.innerText = fileName
})
});
export function loadNextPlaybackTime() {
fetch('/nextplaybacktime')
.then(response => response.text())
.then(data => {
const nextPlaybackTime = document.getElementById('nextPlaybackTime');
nextPlaybackTime.textContent = `Playing next time: ${data}`;
})
.catch(error => console.error('Error:', error));
}
document.getElementById('uploadForm').addEventListener('submit', function (event) {
event.preventDefault();
var fileInput = document.getElementById('myFile');
var file = fileInput.files[0];
var youtubeLink = document.getElementById('youtubeLink').value;
if (file) {
var objectURL = URL.createObjectURL(file);
var audio = new Audio(objectURL);
audio.addEventListener('loadedmetadata', function () {
var duration = audio.duration;
console.log(duration);
if (duration > 10) {
alert('File is longer than 10 seconds.');
return;
}
if (file.size > 1024 * 1024) {
alert('File is larger than 1MB.');
return;
}
if (file.name.split('.').pop().toLowerCase() !== 'mp3') {
alert('Only .mp3 files are allowed.');
return;
}
var formData = new FormData();
formData.append('myFile', file);
fetch('/upload', {
method: 'POST',
body: formData
})
.then(response => response.text())
.then(data => {
console.log(data);
})
.catch(error => {
console.error(error);
alert('An error occurred while uploading the file.');
}).finally(() => {
fileInput.value = '';
loadFiles();
alert('File uploaded successfully.');
});
});
} else if (youtubeLink) {
console.log(youtubeLink);
fetch('/upload-youtube', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
url: youtubeLink
}),
})
.then(response => response.text())
.then(data => {
console.log(data);
// if response is not ok, alert
if (data !== 'ok') {
alert(data);
return;
}
alert('File uploaded successfully.');
loadFiles();
})
.catch(error => {
console.error(error);
alert('An error occurred while uploading the file.');
});
} else {
alert('Please select a file or paste a YouTube link.');
}
});

View File

@@ -1,15 +1,17 @@
import { joinRandomChannel, nextPlayBackTime } from './../bot';
import { nextPlayBackTime } from './../bot';
import express from 'express';
import multer, { diskStorage } from 'multer';
import path from 'path';
import { LoggerColors } from '../bot';
import ytdl from 'ytdl-core';
import fs from 'fs';
import bodyParser from 'body-parser';
import ffmpeg from 'fluent-ffmpeg';
import https from 'https';
import ip from 'ip';
import { Handlers } from "./handlers/index"
import { loadAvoidList } from '../helpers/load-avoid-list';
import { LoggerColors } from '../helpers/logger-colors';
import { generateFileName } from '../helpers/generate-file-name';
const app = express();
const storage = diskStorage({
destination: 'sounds/',
@@ -48,58 +50,8 @@ app.post('/upload', upload.single('myFile'), async (req, res) => {
res.send('File uploaded successfully.');
});
/**
* Uploads a YouTube video as an mp3 file to the sounds folder.
* The video must be shorter than 10 seconds.
* @Body url - The YouTube video url.
*/
app.post('/upload-youtube', async (req, res) => {
const url = req.body.url;
if (ytdl.validateURL(url)) {
const info = await ytdl.getInfo(url);
// remove special characters from the title and white spaces
const title = info.videoDetails.title.replace(/[^a-zA-Z ]/g, "").replace(/\s+/g, '-').toLowerCase();
// Create a temporary directory to store the uploaded file so validation can be done
const tempDir = fs.mkdtempSync('temp');
const outputFilePath = path.resolve(tempDir, generateFileName(title));
const videoReadableStream = ytdl(url, { filter: 'audioonly' });
const fileWritableStream = fs.createWriteStream(outputFilePath);
videoReadableStream.pipe(fileWritableStream);
fileWritableStream.on('finish', () => {
ffmpeg.ffprobe(outputFilePath, function(err, metadata) {
if (err) {
fs.rmSync(tempDir, { recursive: true, force: true });
return res.status(500).send('Error occurred during processing.');
}
const duration = metadata.format.duration;
if (duration == undefined) {
fs.rmSync(tempDir, { recursive: true, force: true });
return res.status(400).send('Something went wrong.');
}
if (duration > 10) {
fs.rmSync(tempDir, { recursive: true, force: true });
return res.status(400).send('File is longer than 10 seconds.');
} else {
// Move the file from the temporary directory to its final destination
const finalFilePath = path.resolve(__dirname, '../sounds/', generateFileName(title));
fs.renameSync(outputFilePath, finalFilePath);
res.send('File uploaded successfully.');
}
// Remove the temporary directory and its contents once done
fs.rmSync(tempDir, { recursive: true, force: true });
});
});
} else {
res.status(400).send('Invalid url provided.');
}
await Handlers.UploadYouTubeFile(res, req);
});
/**
@@ -109,19 +61,8 @@ app.post('/upload-youtube', async (req, res) => {
*/
app.use('/sounds', express.static(path.join(__dirname, '../sounds')));
/**
* Returns a list of all sound files in the sounds directory.
* @returns string[] - An array of all sound files in the sounds directory.
*/
app.get('/sounds', (_req, res) => {
const fs = require('fs');
const directoryPath = path.join(__dirname, '../sounds');
fs.readdir(directoryPath, function (err: any, files: any[]) {
if (err) {
return console.log(LoggerColors.Red, 'Unable to scan directory: ' + err);
}
res.send(files);
});
app.get('/sounds', (_req, res: express.Response) => {
return Handlers.GetSoundFiles(res);
});
/**
@@ -132,26 +73,26 @@ app.get('/nextplaybacktime', (_req, res) => {
res.send(nextPlayBackTime);
});
/**
* Deletes a file from the sounds folder by filename
*/
app.delete('/sounds/:filename', (req, res) => {
const fs = require('fs');
const directoryPath = path.join(__dirname, '../sounds');
const filePath = directoryPath + '/' + req.params.filename;
fs.unlink(filePath, (err: any) => {
if (err) {
return console.log(LoggerColors.Red, 'Unable to delete file: ' + err);
}
res.send('File deleted successfully.');
});
app.delete('/sounds/:filename', (_req, res) => {
Handlers.DeleteSoundFile(res, _req);
});
app.use(express.static(path.join(__dirname, "web")));
app.get('/join', (_req, res) => {
joinRandomChannel();
res.send('Joining random channel.');
Handlers.JoinChannel(res);
});
app.post('/avoidlist', (req, res) => {
Handlers.AddUserToAvoidList(res, req);
});
app.delete('/avoidlist/:user', (req, res) => {
Handlers.DeleteUserFromAvoidList(res, req);
});
app.get('/avoidlist', (_req, res) => {
res.send(loadAvoidList());
});
/**
@@ -183,21 +124,3 @@ export function startServer() {
console.log(`Server started at ${ssl}://${ip.address()}:${port}`);
});
}
/**
* Generates a random file name based on the provided name.
* @param name - The name to generate a file name for.
* @returns string - The generated file name.
*/
function generateFileName(name: string) {
const randomHex = [...Array(3)].map(() => Math.floor(Math.random() * 16).toString(16)).join('');
const formattedName = name
.replace(/\(.*?\)|\[.*?\]/g, '')
.split(' ')
.filter(word => /^[a-zA-Z0-9]/.test(word))
.join(' ')
.replace(/\s+/g, ' ');
return `${formattedName}-${randomHex}.mp3`;
}

7
helpers/converters.ts Normal file
View File

@@ -0,0 +1,7 @@
export function dateToString(date: Date): string {
return date.toLocaleString('sv-SE', { timeZone: 'Europe/Stockholm' });
}
export function convertHoursToMinutes(hours: number): number {
return hours * 60;
}

View File

@@ -0,0 +1,18 @@
/**
* Formats a name into a file name with random generated value at the end.
* @param name - The name to generate a file name for.
* @returns string - The generated file name.
*/
export function generateFileName(name: string): string {
const randomHex = [...Array(3)].map(() => Math.floor(Math.random() * 16).toString(16)).join('');
const formattedName = name
.replace(/\(.*?\)|\[.*?\]/g, '')
.split(' ')
.filter(word => /^[a-zA-Z0-9]/.test(word))
.join(' ')
.replace(/\s+/g, ' ')
.replace('.mp3', '');
return `${formattedName}-${randomHex}.mp3`;
}

View File

@@ -0,0 +1,11 @@
import * as fileSystem from 'fs';
export const soundsDirectory = './sounds/';
/**
* Returns a random sound file from the sounds directory.
* @returns string - The path to a random sound file.
*/
export function getRandomSoundFilePath(): string {
const allSoundFilesAsArray = fileSystem.readdirSync(soundsDirectory).filter(file => file.endsWith('.mp3'));
return soundsDirectory + allSoundFilesAsArray[Math.floor(Math.random() * allSoundFilesAsArray.length)];
}

View File

@@ -0,0 +1,10 @@
import * as fileSystem from 'fs';
import { AvoidList } from '../models/avoid-list';
/**
* Returns a list of users that the bot should avoid being in same channel as.
* @returns avoidList
*/
export function loadAvoidList(): AvoidList {
return JSON.parse(fileSystem.readFileSync("avoid-list.json", 'utf-8'));
}

7
helpers/logger-colors.ts Normal file
View File

@@ -0,0 +1,7 @@
export class LoggerColors {
public static readonly Green: string = "\x1b[32m%s\x1b[0m";
public static readonly Yellow: string = "\x1b[33m%s\x1b[0m";
public static readonly Cyan: string = "\x1b[36m%s\x1b[0m";
public static readonly Red: string = "\x1b[31m%s\x1b[0m";
public static readonly Teal: string = "\x1b[35m%s\x1b[0m";
}

24
helpers/logger.ts Normal file
View File

@@ -0,0 +1,24 @@
import { dateToString } from "./converters";
import { LoggerColors } from "./logger-colors";
/**
* Logs the wait time, current time, next join time, and cron schedule in the console.
* @param waitTime - The time to wait until the next join.
* @param hour - The hour of the cron schedule.
* @param minute - The minute of the cron schedule.
*/
export function logger(
waitTime: Date,
hour: number,
minute: number
){
const currentTime = new Date();
console.log(
LoggerColors.Cyan, `
Wait time: ${(waitTime.getTime() - currentTime.getTime()) / 60000} minutes,
Current time: ${dateToString(currentTime)},
Next join time: ${dateToString(waitTime)},
Cron: ${Math.floor(minute)} ${Math.floor(hour) == 0 ? '*' : Math.floor(hour) } * * *`
);
}

View File

@@ -0,0 +1,18 @@
import { Client, GatewayIntentBits } from "discord.js";
export function SetupDiscordCLient(): Client{
const discordClient = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
GatewayIntentBits.GuildVoiceStates,
GatewayIntentBits.GuildIntegrations,
],
});
const discordApplicationToken = process.env.TOKEN; // Discord bot token from .env file (required) More info: https://discordgsm.com/guide/how-to-get-a-discord-bot-token
discordClient.login(discordApplicationToken);
return discordClient;
}

3
models/avoid-list.ts Normal file
View File

@@ -0,0 +1,3 @@
export interface AvoidList {
avoidUsers: string[];
}

View File

@@ -1,4 +1,4 @@
![randommemer discord bot logotype](/client/web/logo.svg)
![randommemer discord bot logotype](/client/web/assets/logo.svg)
# What is this!?
this is a discord bot that joins a random voice channel in a random guild and plays a random sound file (mp3). it uses the `@discordjs/voice` library for voice connections and the `node-schedule` library for scheduling the next join. the bot also starts a web server using the `startserver` function from the `webserver` module.