first attempt at code mostly working
This commit is contained in:
344
bot/src/index.js
Normal file
344
bot/src/index.js
Normal file
@@ -0,0 +1,344 @@
|
||||
import { Client, GatewayIntentBits } from 'discord.js';
|
||||
import { joinVoiceChannel, createAudioPlayer, createAudioResource, AudioPlayerStatus, VoiceConnectionStatus } from '@discordjs/voice';
|
||||
import express from 'express';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { createLogger, format, transports } from 'winston';
|
||||
|
||||
const logger = createLogger({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
format: format.combine(
|
||||
format.timestamp(),
|
||||
format.printf(({ timestamp, level, message }) => `${timestamp} [${level}]: ${message}`)
|
||||
),
|
||||
transports: [new transports.Console()]
|
||||
});
|
||||
|
||||
const DISCORD_BOT_TOKEN = process.env.DISCORD_BOT_TOKEN;
|
||||
const DISCORD_GUILD_ID = process.env.DISCORD_GUILD_ID;
|
||||
const DISCORD_CHANNEL_ID = process.env.DISCORD_CHANNEL_ID;
|
||||
const MUSIC_DIRECTORY = process.env.MUSIC_DIRECTORY || '/music';
|
||||
const INTERNAL_PORT = 8080;
|
||||
|
||||
if (!DISCORD_BOT_TOKEN || !DISCORD_GUILD_ID || !DISCORD_CHANNEL_ID) {
|
||||
logger.error('Missing required environment variables');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// State
|
||||
let currentState = {
|
||||
currentTrack: null,
|
||||
state: 'stopped',
|
||||
position: 0,
|
||||
volume: 100,
|
||||
queue: [],
|
||||
shuffled: false
|
||||
};
|
||||
|
||||
let connection = null;
|
||||
let player = null;
|
||||
let startTime = 0;
|
||||
let currentResource = null;
|
||||
|
||||
// Discord client
|
||||
const client = new Client({
|
||||
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildVoiceStates]
|
||||
});
|
||||
|
||||
// Load all MP3 files from music directory
|
||||
function loadMusicLibrary() {
|
||||
if (!fs.existsSync(MUSIC_DIRECTORY)) {
|
||||
logger.warn(`Music directory not found: ${MUSIC_DIRECTORY}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(MUSIC_DIRECTORY);
|
||||
return files
|
||||
.filter(file => file.toLowerCase().endsWith('.mp3'))
|
||||
.map(file => ({
|
||||
id: file,
|
||||
filename: file,
|
||||
filepath: path.join(MUSIC_DIRECTORY, file),
|
||||
title: file.replace('.mp3', ''),
|
||||
artist: 'Unknown',
|
||||
album: '',
|
||||
duration: 0,
|
||||
hasArt: false
|
||||
}));
|
||||
}
|
||||
|
||||
// Initialize queue with all tracks
|
||||
function initializeQueue() {
|
||||
const library = loadMusicLibrary();
|
||||
currentState.queue = [...library];
|
||||
logger.info(`Loaded ${library.length} tracks into queue`);
|
||||
}
|
||||
|
||||
// Play next track in queue
|
||||
function playNext() {
|
||||
if (currentState.queue.length === 0) {
|
||||
logger.info('Queue empty, reloading library');
|
||||
initializeQueue();
|
||||
}
|
||||
|
||||
if (currentState.queue.length === 0) {
|
||||
logger.warn('No tracks available to play');
|
||||
currentState.state = 'stopped';
|
||||
currentState.currentTrack = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const track = currentState.queue.shift();
|
||||
currentState.currentTrack = track;
|
||||
currentState.position = 0;
|
||||
currentState.state = 'playing';
|
||||
startTime = Date.now();
|
||||
|
||||
logger.info(`Now playing: ${track.title}`);
|
||||
|
||||
try {
|
||||
currentResource = createAudioResource(track.filepath, {
|
||||
inlineVolume: true
|
||||
});
|
||||
currentResource.volume.setVolume(currentState.volume / 100);
|
||||
player.play(currentResource);
|
||||
} catch (error) {
|
||||
logger.error(`Error playing track: ${error.message}`);
|
||||
currentResource = null;
|
||||
playNext();
|
||||
}
|
||||
}
|
||||
|
||||
// Connect to voice channel
|
||||
async function connectToVoice() {
|
||||
try {
|
||||
const guild = await client.guilds.fetch(DISCORD_GUILD_ID);
|
||||
const channel = await guild.channels.fetch(DISCORD_CHANNEL_ID);
|
||||
|
||||
if (!channel || channel.type !== 2) {
|
||||
throw new Error('Invalid voice channel');
|
||||
}
|
||||
|
||||
connection = joinVoiceChannel({
|
||||
channelId: channel.id,
|
||||
guildId: guild.id,
|
||||
adapterCreator: guild.voiceAdapterCreator,
|
||||
});
|
||||
|
||||
player = createAudioPlayer();
|
||||
|
||||
player.on(AudioPlayerStatus.Idle, () => {
|
||||
logger.info('Track finished, playing next');
|
||||
playNext();
|
||||
});
|
||||
|
||||
player.on('error', error => {
|
||||
logger.error(`Player error: ${error.message}`);
|
||||
playNext();
|
||||
});
|
||||
|
||||
connection.subscribe(player);
|
||||
|
||||
connection.on(VoiceConnectionStatus.Ready, () => {
|
||||
logger.info('Voice connection ready');
|
||||
playNext();
|
||||
});
|
||||
|
||||
connection.on(VoiceConnectionStatus.Disconnected, async () => {
|
||||
logger.warn('Disconnected from voice channel');
|
||||
try {
|
||||
await Promise.race([
|
||||
connection.destroy(),
|
||||
new Promise(resolve => setTimeout(resolve, 5000))
|
||||
]);
|
||||
} catch (error) {
|
||||
logger.error('Error during reconnection');
|
||||
}
|
||||
});
|
||||
|
||||
connection.on('error', error => {
|
||||
logger.error(`Voice connection error: ${error.message}`);
|
||||
});
|
||||
|
||||
logger.info(`Connected to voice channel: ${channel.name}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to connect to voice: ${error.message}`);
|
||||
setTimeout(connectToVoice, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
// Internal API for control
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok', connected: connection !== null });
|
||||
});
|
||||
|
||||
app.get('/state', (req, res) => {
|
||||
const elapsed = currentState.state === 'playing' ? Math.floor((Date.now() - startTime) / 1000) : 0;
|
||||
res.json({
|
||||
...currentState,
|
||||
position: currentState.position + elapsed,
|
||||
queueLength: currentState.queue.length
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/play', (req, res) => {
|
||||
if (currentState.state === 'paused' && player) {
|
||||
player.unpause();
|
||||
currentState.state = 'playing';
|
||||
startTime = Date.now() - (currentState.position * 1000);
|
||||
logger.info('Resumed playback');
|
||||
} else if (currentState.state === 'stopped') {
|
||||
playNext();
|
||||
}
|
||||
res.json({ success: true, state: currentState.state });
|
||||
});
|
||||
|
||||
app.post('/pause', (req, res) => {
|
||||
if (player && currentState.state === 'playing') {
|
||||
player.pause();
|
||||
currentState.position += Math.floor((Date.now() - startTime) / 1000);
|
||||
currentState.state = 'paused';
|
||||
logger.info('Paused playback');
|
||||
}
|
||||
res.json({ success: true, state: currentState.state });
|
||||
});
|
||||
|
||||
app.post('/skip', (req, res) => {
|
||||
logger.info('Skipping to next track');
|
||||
playNext();
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
app.post('/volume', (req, res) => {
|
||||
const { volume } = req.body;
|
||||
if (volume >= 0 && volume <= 100) {
|
||||
currentState.volume = volume;
|
||||
// Update volume on currently playing resource
|
||||
if (currentResource && currentResource.volume) {
|
||||
currentResource.volume.setVolume(volume / 100);
|
||||
}
|
||||
logger.info(`Volume set to ${volume}%`);
|
||||
}
|
||||
res.json({ success: true, volume: currentState.volume });
|
||||
});
|
||||
|
||||
app.post('/reload', (req, res) => {
|
||||
logger.info('Reloading music library');
|
||||
initializeQueue();
|
||||
res.json({ success: true, tracks: currentState.queue.length });
|
||||
});
|
||||
|
||||
app.post('/queue/shuffle', (req, res) => {
|
||||
currentState.queue.sort(() => Math.random() - 0.5);
|
||||
currentState.shuffled = !currentState.shuffled;
|
||||
logger.info('Queue shuffled');
|
||||
res.json({ success: true, shuffled: currentState.shuffled });
|
||||
});
|
||||
|
||||
app.get('/channels', async (req, res) => {
|
||||
try {
|
||||
const guild = await client.guilds.fetch(DISCORD_GUILD_ID);
|
||||
const channels = await guild.channels.fetch();
|
||||
const voiceChannels = channels
|
||||
.filter(channel => channel.type === 2) // Type 2 is voice channel
|
||||
.map(channel => ({
|
||||
id: channel.id,
|
||||
name: channel.name,
|
||||
userCount: channel.members?.size || 0,
|
||||
current: connection?.joinConfig?.channelId === channel.id
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
res.json({ channels: voiceChannels });
|
||||
} catch (error) {
|
||||
logger.error(`Failed to fetch channels: ${error.message}`);
|
||||
res.status(500).json({ error: 'Failed to fetch channels' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/join', async (req, res) => {
|
||||
const { channelId } = req.body;
|
||||
|
||||
if (!channelId) {
|
||||
return res.status(400).json({ error: 'Channel ID required' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Destroy existing connection
|
||||
if (connection) {
|
||||
connection.destroy();
|
||||
connection = null;
|
||||
}
|
||||
|
||||
const guild = await client.guilds.fetch(DISCORD_GUILD_ID);
|
||||
const channel = await guild.channels.fetch(channelId);
|
||||
|
||||
if (!channel || channel.type !== 2) {
|
||||
return res.status(400).json({ error: 'Invalid voice channel' });
|
||||
}
|
||||
|
||||
connection = joinVoiceChannel({
|
||||
channelId: channel.id,
|
||||
guildId: guild.id,
|
||||
adapterCreator: guild.voiceAdapterCreator,
|
||||
});
|
||||
|
||||
if (!player) {
|
||||
player = createAudioPlayer();
|
||||
|
||||
player.on(AudioPlayerStatus.Idle, () => {
|
||||
logger.info('Track finished, playing next');
|
||||
playNext();
|
||||
});
|
||||
|
||||
player.on('error', error => {
|
||||
logger.error(`Player error: ${error.message}`);
|
||||
playNext();
|
||||
});
|
||||
}
|
||||
|
||||
connection.subscribe(player);
|
||||
|
||||
connection.on(VoiceConnectionStatus.Ready, () => {
|
||||
logger.info('Voice connection ready');
|
||||
});
|
||||
|
||||
connection.on(VoiceConnectionStatus.Disconnected, async () => {
|
||||
logger.warn('Disconnected from voice channel');
|
||||
try {
|
||||
await Promise.race([
|
||||
connection.destroy(),
|
||||
new Promise(resolve => setTimeout(resolve, 5000))
|
||||
]);
|
||||
} catch (error) {
|
||||
logger.error('Error during disconnection');
|
||||
}
|
||||
});
|
||||
|
||||
connection.on('error', error => {
|
||||
logger.error(`Voice connection error: ${error.message}`);
|
||||
});
|
||||
|
||||
logger.info(`Joined voice channel: ${channel.name}`);
|
||||
res.json({ success: true, channelName: channel.name });
|
||||
} catch (error) {
|
||||
logger.error(`Failed to join channel: ${error.message}`);
|
||||
res.status(500).json({ error: 'Failed to join channel' });
|
||||
}
|
||||
});
|
||||
|
||||
app.listen(INTERNAL_PORT, () => {
|
||||
logger.info(`Internal API listening on port ${INTERNAL_PORT}`);
|
||||
});
|
||||
|
||||
// Discord client ready
|
||||
client.once('clientReady', () => {
|
||||
logger.info(`Logged in as ${client.user.tag}`);
|
||||
initializeQueue();
|
||||
connectToVoice();
|
||||
});
|
||||
|
||||
client.login(DISCORD_BOT_TOKEN);
|
||||
Reference in New Issue
Block a user