Add Discord.js bot base with prefix commands and Winston logging

Co-authored-by: yhqe <139268606+yhqe@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]andyhqe committed 2026-03-12 23:32:21 +00:00
1 parent 8bd000a57a
commit e8049008eb
12 files changed
+1076 -2

No files matched your search

+45
View File
@@ -0,0 +1,45 @@
'use strict';
module.exports = {
name: 'help',
description: 'Lists all available commands or shows info about a specific one.',
aliases: ['h', 'commands'],
usage: 'help [command]',
execute(client, message, args) {
const prefix = process.env.PREFIX || '!';
// Show info for a specific command
if (args.length > 0) {
const name = args[0].toLowerCase();
const command = client.commands.get(name);
if (!command) {
return message.reply(`No command found for \`${name}\`.`);
}
const lines = [`**${prefix}${command.name}**`];
if (command.description) lines.push(`> ${command.description}`);
if (command.aliases?.length)
lines.push(`> **Aliases:** ${command.aliases.map((a) => `\`${prefix}${a}\``).join(', ')}`);
if (command.usage)
lines.push(`> **Usage:** \`${prefix}${command.usage}\``);
return message.reply(lines.join('\n'));
}
// Build a deduplicated command list (ignore aliases pointing to the same command)
const seen = new Set();
const commandList = [];
for (const command of client.commands.values()) {
if (seen.has(command.name)) continue;
seen.add(command.name);
commandList.push(`\`${prefix}${command.name}\` — ${command.description || 'No description.'}`);
}
return message.reply(
`**Available commands:**\n${commandList.join('\n')}\n\nUse \`${prefix}help <command>\` for more details.`,
);
},
};
+18
View File
@@ -0,0 +1,18 @@
'use strict';
module.exports = {
name: 'ping',
description: 'Replies with Pong! and shows the bot latency.',
aliases: ['pong'],
usage: 'ping',
async execute(client, message) {
const sent = await message.reply('Pinging…');
const latency = sent.createdTimestamp - message.createdTimestamp;
const apiLatency = Math.round(client.ws.ping);
await sent.edit(
`🏓 Pong!\n> Bot latency: **${latency}ms** | API latency: **${apiLatency}ms**`,
);
},
};
+37
View File
@@ -0,0 +1,37 @@
'use strict';
const { Events } = require('discord.js');
const logger = require('../utils/logger');
module.exports = {
name: Events.MessageCreate,
async execute(client, message) {
// Ignore bots and messages without the prefix
if (message.author.bot) return;
const prefix = process.env.PREFIX || '!';
if (!message.content.startsWith(prefix)) return;
// Parse command name and arguments
const args = message.content.slice(prefix.length).trim().split(/\s+/);
const commandName = args.shift().toLowerCase();
const command = client.commands.get(commandName);
if (!command) return;
logger.info(
`[Command] "${commandName}" used by ${message.author.tag} (${message.author.id})` +
` in ${message.guild ? `#${message.channel.name} (${message.guild.name})` : 'DMs'}`,
);
try {
await command.execute(client, message, args);
} catch (err) {
logger.error(`[Command] Error executing "${commandName}":`, err);
await message
.reply('An error occurred while running that command.')
.catch(() => null);
}
},
};
+14
View File
@@ -0,0 +1,14 @@
'use strict';
const { Events } = require('discord.js');
const logger = require('../utils/logger');
module.exports = {
name: Events.ClientReady,
once: true,
execute(client) {
logger.info(`[Ready] Logged in as ${client.user.tag} (${client.user.id})`);
logger.info(`[Ready] Serving ${client.guilds.cache.size} guild(s).`);
},
};
+51
View File
@@ -0,0 +1,51 @@
'use strict';
const fs = require('fs');
const path = require('path');
const logger = require('../utils/logger');
/**
* Loads all command files from src/commands and registers them on the client.
* Each command file must export: { name, description, aliases?, usage?, execute }
*
* @param {import('discord.js').Client} client
*/
function loadCommands(client) {
const commandsPath = path.join(__dirname, '..', 'commands');
const commandFiles = fs
.readdirSync(commandsPath)
.filter((file) => file.endsWith('.js'));
let loaded = 0;
for (const file of commandFiles) {
try {
const command = require(path.join(commandsPath, file));
if (!command.name || typeof command.execute !== 'function') {
logger.warn(
`[CommandHandler] Skipping ${file}: missing "name" or "execute".`,
);
continue;
}
client.commands.set(command.name.toLowerCase(), command);
// Register aliases if provided
if (Array.isArray(command.aliases)) {
for (const alias of command.aliases) {
client.commands.set(alias.toLowerCase(), command);
}
}
logger.debug(`[CommandHandler] Loaded command: ${command.name}`);
loaded++;
} catch (err) {
logger.error(`[CommandHandler] Failed to load ${file}:`, err);
}
}
logger.info(`[CommandHandler] ${loaded} command(s) loaded.`);
}
module.exports = { loadCommands };
+48
View File
@@ -0,0 +1,48 @@
'use strict';
const fs = require('fs');
const path = require('path');
const logger = require('../utils/logger');
/**
* Loads all event files from src/events and registers them on the client.
* Each event file must export: { name, once?, execute }
*
* @param {import('discord.js').Client} client
*/
function loadEvents(client) {
const eventsPath = path.join(__dirname, '..', 'events');
const eventFiles = fs
.readdirSync(eventsPath)
.filter((file) => file.endsWith('.js'));
let loaded = 0;
for (const file of eventFiles) {
try {
const event = require(path.join(eventsPath, file));
if (!event.name || typeof event.execute !== 'function') {
logger.warn(
`[EventHandler] Skipping ${file}: missing "name" or "execute".`,
);
continue;
}
if (event.once) {
client.once(event.name, (...args) => event.execute(client, ...args));
} else {
client.on(event.name, (...args) => event.execute(client, ...args));
}
logger.debug(`[EventHandler] Registered event: ${event.name}`);
loaded++;
} catch (err) {
logger.error(`[EventHandler] Failed to load ${file}:`, err);
}
}
logger.info(`[EventHandler] ${loaded} event(s) registered.`);
}
module.exports = { loadEvents };
+49
View File
@@ -0,0 +1,49 @@
'use strict';
require('dotenv').config();
const { Client, Collection, GatewayIntentBits } = require('discord.js');
const logger = require('./utils/logger');
const { loadCommands } = require('./handlers/commandHandler');
const { loadEvents } = require('./handlers/eventHandler');
// Validate required environment variables
if (!process.env.BOT_TOKEN) {
logger.error(
'BOT_TOKEN is not set. Copy .env.example to .env and fill in your token.',
);
process.exit(1);
}
// Create the Discord client with the intents needed for prefix commands
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent, // Required to read message content for prefix commands
GatewayIntentBits.DirectMessages,
],
});
// Attach a Collection to store commands
client.commands = new Collection();
// Load commands and events
loadCommands(client);
loadEvents(client);
// Global error handling
process.on('unhandledRejection', (err) => {
logger.error('[Process] Unhandled rejection:', err);
});
process.on('uncaughtException', (err) => {
logger.error('[Process] Uncaught exception:', err);
process.exit(1);
});
// Connect to Discord
client.login(process.env.BOT_TOKEN).catch((err) => {
logger.error('[Login] Failed to log in:', err);
process.exit(1);
});
+47
View File
@@ -0,0 +1,47 @@
'use strict';
const { createLogger, format, transports } = require('winston');
const { combine, timestamp, printf, colorize, errors } = format;
const path = require('path');
const fs = require('fs');
// Ensure logs directory exists
const logsDir = path.join(__dirname, '..', '..', 'logs');
if (!fs.existsSync(logsDir)) {
fs.mkdirSync(logsDir, { recursive: true });
}
const logFormat = printf(({ level, message, timestamp: ts, stack }) => {
return `[${ts}] [${level}]: ${stack || message}`;
});
const logger = createLogger({
level: process.env.LOG_LEVEL || 'info',
format: combine(
timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
errors({ stack: true }),
logFormat,
),
transports: [
// Human-readable coloured output in the console
new transports.Console({
format: combine(
colorize({ all: true }),
timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
errors({ stack: true }),
logFormat,
),
}),
// All logs at info level and above written to combined.log
new transports.File({
filename: path.join(logsDir, 'combined.log'),
}),
// Error logs written to a dedicated file
new transports.File({
filename: path.join(logsDir, 'error.log'),
level: 'error',
}),
],
});
module.exports = logger;