first attempt at code mostly working

This commit is contained in:
2025-12-12 21:30:26 -08:00
parent 7bc01a3828
commit a840e9385d
32 changed files with 2160 additions and 0 deletions

15
bot/Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM node:20-slim
# Install ffmpeg for audio processing
RUN apt-get update && \
apt-get install -y ffmpeg python3 make g++ && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
CMD ["npm", "start"]

19
bot/package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "discord-music-bot",
"version": "1.0.0",
"description": "Discord bot for playing local MP3 files",
"main": "src/index.js",
"type": "module",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js"
},
"dependencies": {
"@discordjs/opus": "^0.9.0",
"@discordjs/voice": "^0.18.0",
"discord.js": "^14.16.3",
"express": "^4.21.2",
"sodium-native": "^4.3.1",
"winston": "^3.17.0"
}
}

344
bot/src/index.js Normal file
View 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);