- Add Rock Paper Scissors game with PvP and bot modes - Fixed syntax errors and improved game mechanics - PvP moves now require PM for secrecy - Add Word Scramble game with difficulty levels - Multiple word categories and persistent scoring - Enhance duck hunt with better statistics tracking - Separate points vs duck count tracking - Fixed migration logic issues - Add core rate limiting system (5 commands/30s) - Admin whitelist for megasconed - Automatic cleanup and unblocking - Improve reload functionality for hot-reloading plugins - Add channel-specific commands (\!stopducks/\!startducks) 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
589 lines
No EOL
21 KiB
JavaScript
589 lines
No EOL
21 KiB
JavaScript
// bot.js - Main IRC Bot with Plugin System (Debug Version)
|
||
|
||
// ================================
|
||
// BOT CONFIGURATION - EDIT HERE
|
||
// ================================
|
||
const config = {
|
||
server: 'irc.libera.chat', // IRC server address
|
||
port: 6667, // IRC server port (6667 for plain, 6697 for SSL)
|
||
nick: 'cancerbot', // Bot's nickname
|
||
channels: ['#bakedbeans'], // Channels to join (add more like: ['#channel1', '#channel2'])
|
||
commandPrefix: '!', // Command prefix (e.g., !help, !ping)
|
||
pluginsDir: './plugins' // Directory containing plugin files
|
||
};
|
||
|
||
const net = require('net');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
|
||
class IRCBot {
|
||
constructor(config) {
|
||
this.config = {
|
||
server: config.server || 'irc.libera.chat',
|
||
port: config.port || 6667,
|
||
nick: config.nick || 'MyBot',
|
||
channels: config.channels || ['#test'],
|
||
commandPrefix: config.commandPrefix || '!',
|
||
pluginsDir: config.pluginsDir || './plugins'
|
||
};
|
||
|
||
this.socket = null;
|
||
this.plugins = new Map();
|
||
this.commands = new Map();
|
||
this.connected = false;
|
||
|
||
// Rate limiting system
|
||
this.rateLimits = new Map(); // nick -> { commands: [], blocked: false, blockExpiry: null }
|
||
this.rateLimitConfig = {
|
||
maxCommands: 5, // Max commands per window
|
||
windowMs: 30000, // 30 second window
|
||
blockDurationMs: 60000, // 1 minute block
|
||
cleanupInterval: 300000, // 5 minutes cleanup
|
||
adminWhitelist: ['megasconed'] // Users exempt from rate limiting
|
||
};
|
||
|
||
console.log('🔧 Bot configuration:', this.config);
|
||
console.log('🚦 Rate limiting enabled:', this.rateLimitConfig);
|
||
this.loadPlugins();
|
||
this.startRateLimitCleanup();
|
||
}
|
||
|
||
connect() {
|
||
console.log(`Connecting to ${this.config.server}:${this.config.port}`);
|
||
|
||
this.socket = net.createConnection(this.config.port, this.config.server);
|
||
|
||
this.socket.on('connect', () => {
|
||
console.log('Connected to IRC server');
|
||
this.send(`NICK ${this.config.nick}`);
|
||
this.send(`USER ${this.config.nick} 0 * :${this.config.nick}`);
|
||
});
|
||
|
||
this.socket.on('data', (data) => {
|
||
const lines = data.toString().split('\r\n');
|
||
lines.forEach(line => {
|
||
if (line.trim()) {
|
||
this.handleMessage(line);
|
||
}
|
||
});
|
||
});
|
||
|
||
this.socket.on('error', (err) => {
|
||
console.error('Socket error:', err);
|
||
});
|
||
|
||
this.socket.on('close', () => {
|
||
console.log('Connection closed');
|
||
this.connected = false;
|
||
// Reconnect after 5 seconds
|
||
setTimeout(() => this.connect(), 5000);
|
||
});
|
||
}
|
||
|
||
send(message) {
|
||
if (this.socket && this.socket.writable) {
|
||
console.log('→', message);
|
||
this.socket.write(message + '\r\n');
|
||
}
|
||
}
|
||
|
||
handleMessage(line) {
|
||
console.log('←', line);
|
||
|
||
// Handle PING
|
||
if (line.startsWith('PING')) {
|
||
this.send('PONG ' + line.substring(5));
|
||
return;
|
||
}
|
||
|
||
// Handle successful connection
|
||
if (line.includes('001')) {
|
||
this.connected = true;
|
||
console.log('Successfully connected and registered');
|
||
// Join channels
|
||
this.config.channels.forEach(channel => {
|
||
this.send(`JOIN ${channel}`);
|
||
});
|
||
return;
|
||
}
|
||
|
||
// Parse IRC message
|
||
const parsed = this.parseMessage(line);
|
||
if (parsed) {
|
||
this.handleParsedMessage(parsed);
|
||
}
|
||
}
|
||
|
||
parseMessage(line) {
|
||
// Basic IRC message parsing
|
||
const match = line.match(/^:([^!]+)!([^@]+)@([^\s]+)\s+(\w+)\s+([^:]+):(.*)$/);
|
||
if (match) {
|
||
return {
|
||
nick: match[1],
|
||
user: match[2],
|
||
host: match[3],
|
||
command: match[4],
|
||
target: match[5].trim(),
|
||
message: match[6]
|
||
};
|
||
}
|
||
return null;
|
||
}
|
||
|
||
handleParsedMessage(msg) {
|
||
// Handle PRIVMSG (channel messages and private messages)
|
||
if (msg.command === 'PRIVMSG') {
|
||
const isChannel = msg.target.startsWith('#');
|
||
const replyTo = isChannel ? msg.target : msg.nick;
|
||
|
||
// Check if it's a command
|
||
if (msg.message.startsWith(this.config.commandPrefix)) {
|
||
const commandLine = msg.message.substring(this.config.commandPrefix.length);
|
||
const [commandName, ...args] = commandLine.split(' ');
|
||
|
||
// Handle built-in admin commands first (keeping legacy support)
|
||
if (commandName === 'reloadplugins' && msg.nick === 'megasconed') {
|
||
this.say(replyTo, '🔄 Reloading all plugins... (use !reload command next time)');
|
||
const pluginCount = this.plugins.size;
|
||
try {
|
||
this.reloadAllPlugins();
|
||
this.say(replyTo, `✅ Reloaded ${pluginCount} plugins. New commands: ${Array.from(this.commands.keys()).join(', ')}`);
|
||
} catch (error) {
|
||
console.error('❌ Error during plugin reload:', error);
|
||
this.say(replyTo, '❌ Error during plugin reload - check console for details.');
|
||
}
|
||
return;
|
||
}
|
||
|
||
this.executeCommand(commandName, {
|
||
nick: msg.nick,
|
||
user: msg.user,
|
||
host: msg.host,
|
||
channel: isChannel ? msg.target : null,
|
||
replyTo: replyTo,
|
||
args: args,
|
||
fullMessage: msg.message
|
||
});
|
||
}
|
||
|
||
// Emit message event for plugins
|
||
this.emit('message', {
|
||
nick: msg.nick,
|
||
user: msg.user,
|
||
host: msg.host,
|
||
target: msg.target,
|
||
message: msg.message,
|
||
isChannel: isChannel,
|
||
replyTo: replyTo
|
||
});
|
||
}
|
||
}
|
||
|
||
executeCommand(commandName, context) {
|
||
// Check rate limit BEFORE executing command
|
||
if (this.isRateLimited(context.nick, context.replyTo)) {
|
||
return; // User is rate limited, ignore command
|
||
}
|
||
|
||
// Track this command
|
||
this.trackCommand(context.nick);
|
||
|
||
const command = this.commands.get(commandName);
|
||
if (command) {
|
||
try {
|
||
command.execute(context, this);
|
||
} catch (error) {
|
||
console.error(`Error executing command ${commandName}:`, error);
|
||
this.say(context.replyTo, `Error executing command: ${error.message}`);
|
||
}
|
||
} else {
|
||
console.log(`Command not found: ${commandName}`);
|
||
}
|
||
}
|
||
|
||
loadPlugins() {
|
||
console.log(`🔍 Looking for plugins in: ${this.config.pluginsDir}`);
|
||
console.log(`🔍 Full path: ${path.resolve(this.config.pluginsDir)}`);
|
||
|
||
if (!fs.existsSync(this.config.pluginsDir)) {
|
||
console.log(`📁 Creating plugins directory: ${this.config.pluginsDir}`);
|
||
fs.mkdirSync(this.config.pluginsDir, { recursive: true });
|
||
|
||
// Create a basic example plugin
|
||
this.createExamplePlugin();
|
||
return;
|
||
}
|
||
|
||
console.log(`📁 Plugins directory exists, scanning for .js files...`);
|
||
|
||
let pluginFiles;
|
||
try {
|
||
pluginFiles = fs.readdirSync(this.config.pluginsDir)
|
||
.filter(file => file.endsWith('.js'));
|
||
console.log(`📋 Found files:`, pluginFiles);
|
||
} catch (error) {
|
||
console.error(`❌ Error reading plugins directory:`, error);
|
||
return;
|
||
}
|
||
|
||
if (pluginFiles.length === 0) {
|
||
console.log(`⚠️ No .js files found in plugins directory`);
|
||
console.log(`💡 Creating example plugin...`);
|
||
this.createExamplePlugin();
|
||
return;
|
||
}
|
||
|
||
pluginFiles.forEach(file => {
|
||
console.log(`🔄 Loading plugin: ${file}`);
|
||
this.loadPlugin(file);
|
||
});
|
||
|
||
console.log(`✅ Loaded ${pluginFiles.length} plugins`);
|
||
console.log(`🎮 Available commands:`, Array.from(this.commands.keys()));
|
||
}
|
||
|
||
createExamplePlugin() {
|
||
const examplePlugin = `// plugins/basic.js - Basic commands plugin
|
||
module.exports = {
|
||
init(bot) {
|
||
console.log('Basic plugin initialized');
|
||
},
|
||
|
||
cleanup(bot) {
|
||
console.log('Basic plugin cleaned up');
|
||
},
|
||
|
||
commands: [
|
||
{
|
||
name: 'ping',
|
||
description: 'Responds with pong',
|
||
execute(context, bot) {
|
||
bot.say(context.replyTo, \`\${context.nick}: pong!\`);
|
||
}
|
||
},
|
||
|
||
{
|
||
name: 'help',
|
||
description: 'Shows available commands',
|
||
execute(context, bot) {
|
||
const commands = Array.from(bot.commands.keys());
|
||
bot.say(context.replyTo, \`Available commands: \${commands.join(', ')}\`);
|
||
}
|
||
},
|
||
|
||
{
|
||
name: 'time',
|
||
description: 'Shows current time',
|
||
execute(context, bot) {
|
||
const now = new Date().toLocaleString();
|
||
bot.say(context.replyTo, \`Current time: \${now}\`);
|
||
}
|
||
}
|
||
]
|
||
};`;
|
||
|
||
const pluginPath = path.join(this.config.pluginsDir, 'basic.js');
|
||
try {
|
||
fs.writeFileSync(pluginPath, examplePlugin);
|
||
console.log(`✅ Created example plugin: ${pluginPath}`);
|
||
|
||
// Give filesystem a moment to settle
|
||
setTimeout(() => {
|
||
console.log(`🔄 Loading newly created plugin...`);
|
||
this.loadPlugin('basic.js');
|
||
}, 100);
|
||
|
||
} catch (writeError) {
|
||
console.error(`❌ Error creating example plugin:`, writeError);
|
||
}
|
||
}
|
||
|
||
loadPlugin(filename) {
|
||
const filePath = path.join(this.config.pluginsDir, filename);
|
||
console.log(`🔄 Attempting to load: ${filePath}`);
|
||
|
||
try {
|
||
// Check if file exists
|
||
if (!fs.existsSync(filePath)) {
|
||
console.error(`❌ Plugin file not found: ${filePath}`);
|
||
return;
|
||
}
|
||
|
||
// Get absolute path for require.resolve
|
||
const absolutePath = path.resolve(filePath);
|
||
console.log(`📍 Absolute path: ${absolutePath}`);
|
||
|
||
// Clear require cache to allow reloading (only if already cached)
|
||
try {
|
||
const fullPath = require.resolve(absolutePath);
|
||
console.log(`🗑️ Clearing cache for: ${fullPath}`);
|
||
delete require.cache[fullPath];
|
||
} catch (resolveError) {
|
||
console.log(`ℹ️ File not in cache yet: ${absolutePath}`);
|
||
}
|
||
|
||
// Require the plugin
|
||
console.log(`📥 Requiring plugin: ${absolutePath}`);
|
||
const plugin = require(absolutePath);
|
||
console.log(`📋 Plugin object:`, Object.keys(plugin));
|
||
|
||
// Initialize plugin
|
||
if (typeof plugin.init === 'function') {
|
||
console.log(`🚀 Initializing plugin: ${filename}`);
|
||
plugin.init(this);
|
||
} else {
|
||
console.log(`⚠️ Plugin ${filename} has no init function`);
|
||
}
|
||
|
||
// Register commands
|
||
if (plugin.commands && Array.isArray(plugin.commands)) {
|
||
console.log(`📝 Registering ${plugin.commands.length} commands from ${filename}`);
|
||
plugin.commands.forEach(command => {
|
||
if (command.name && typeof command.execute === 'function') {
|
||
this.commands.set(command.name, command);
|
||
console.log(`✅ Registered command: ${command.name}`);
|
||
} else {
|
||
console.error(`❌ Invalid command in ${filename}:`, command);
|
||
}
|
||
});
|
||
} else {
|
||
console.log(`⚠️ Plugin ${filename} has no commands array`);
|
||
}
|
||
|
||
this.plugins.set(filename, plugin);
|
||
console.log(`✅ Successfully loaded plugin: ${filename}`);
|
||
|
||
} catch (error) {
|
||
console.error(`❌ Error loading plugin ${filename}:`, error.message);
|
||
console.error(`📍 Stack trace:`, error.stack);
|
||
}
|
||
}
|
||
|
||
reloadPlugin(filename) {
|
||
console.log(`🔄 Reloading plugin: ${filename}`);
|
||
|
||
const plugin = this.plugins.get(filename);
|
||
if (plugin) {
|
||
// Unregister old commands
|
||
if (plugin.commands) {
|
||
plugin.commands.forEach(command => {
|
||
this.commands.delete(command.name);
|
||
console.log(`🗑️ Unregistered command: ${command.name}`);
|
||
});
|
||
}
|
||
|
||
// Call cleanup if available
|
||
if (typeof plugin.cleanup === 'function') {
|
||
try {
|
||
plugin.cleanup(this);
|
||
console.log(`🧹 Cleaned up plugin: ${filename}`);
|
||
} catch (error) {
|
||
console.error(`❌ Error cleaning up plugin ${filename}:`, error);
|
||
}
|
||
}
|
||
|
||
this.plugins.delete(filename);
|
||
}
|
||
|
||
// Clear require cache for this specific plugin
|
||
const pluginPath = path.resolve(this.config.pluginsDir, filename);
|
||
if (require.cache[pluginPath]) {
|
||
delete require.cache[pluginPath];
|
||
console.log(`🗑️ Cleared cache for: ${filename}`);
|
||
}
|
||
|
||
// Load the plugin again
|
||
this.loadPlugin(filename);
|
||
}
|
||
|
||
reloadAllPlugins() {
|
||
console.log('🔄 Starting plugin reload process...');
|
||
|
||
// Clear all plugins and commands
|
||
this.plugins.forEach((plugin, filename) => {
|
||
if (typeof plugin.cleanup === 'function') {
|
||
try {
|
||
plugin.cleanup(this);
|
||
console.log(`🧹 Cleaned up plugin: ${filename}`);
|
||
} catch (error) {
|
||
console.error(`❌ Error cleaning up plugin ${filename}:`, error);
|
||
}
|
||
}
|
||
});
|
||
|
||
// Clear require cache for all plugin files
|
||
console.log('🗑️ Clearing require cache...');
|
||
const pluginDir = path.resolve(this.config.pluginsDir);
|
||
Object.keys(require.cache).forEach(filepath => {
|
||
if (filepath.startsWith(pluginDir)) {
|
||
delete require.cache[filepath];
|
||
console.log(`🗑️ Cleared cache for: ${path.basename(filepath)}`);
|
||
}
|
||
});
|
||
|
||
this.plugins.clear();
|
||
this.commands.clear();
|
||
|
||
// Reload all plugins
|
||
console.log('🔄 Reloading all plugins...');
|
||
this.loadPlugins();
|
||
console.log('✅ Plugin reload complete');
|
||
}
|
||
|
||
// Helper methods for plugins
|
||
say(target, message) {
|
||
this.send(`PRIVMSG ${target} :${message}`);
|
||
}
|
||
|
||
action(target, message) {
|
||
this.send(`PRIVMSG ${target} :\x01ACTION ${message}\x01`);
|
||
}
|
||
|
||
notice(target, message) {
|
||
this.send(`NOTICE ${target} :${message}`);
|
||
}
|
||
|
||
join(channel) {
|
||
this.send(`JOIN ${channel}`);
|
||
}
|
||
|
||
part(channel, reason = '') {
|
||
this.send(`PART ${channel} :${reason}`);
|
||
}
|
||
|
||
// Simple event system for plugins
|
||
emit(event, data) {
|
||
this.plugins.forEach(plugin => {
|
||
if (typeof plugin[`on${event.charAt(0).toUpperCase() + event.slice(1)}`] === 'function') {
|
||
try {
|
||
plugin[`on${event.charAt(0).toUpperCase() + event.slice(1)}`](data, this);
|
||
} catch (error) {
|
||
console.error(`Error in plugin event handler:`, error);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// Rate limiting methods
|
||
isRateLimited(nick, replyTo) {
|
||
// Check if user is whitelisted (admin)
|
||
if (this.rateLimitConfig.adminWhitelist.includes(nick)) {
|
||
return false;
|
||
}
|
||
|
||
const now = Date.now();
|
||
const userLimits = this.rateLimits.get(nick);
|
||
|
||
if (!userLimits) {
|
||
// First time user, no limits
|
||
return false;
|
||
}
|
||
|
||
// Check if user is currently blocked
|
||
if (userLimits.blocked && now < userLimits.blockExpiry) {
|
||
const remainingMs = userLimits.blockExpiry - now;
|
||
const remainingSeconds = Math.ceil(remainingMs / 1000);
|
||
console.log(`🚫 Rate limited user ${nick} tried command, ${remainingSeconds}s remaining`);
|
||
return true;
|
||
}
|
||
|
||
// If block expired, unblock user
|
||
if (userLimits.blocked && now >= userLimits.blockExpiry) {
|
||
userLimits.blocked = false;
|
||
userLimits.blockExpiry = null;
|
||
userLimits.commands = [];
|
||
console.log(`✅ Rate limit expired for ${nick}`);
|
||
return false;
|
||
}
|
||
|
||
// Count recent commands in sliding window
|
||
const windowStart = now - this.rateLimitConfig.windowMs;
|
||
const recentCommands = userLimits.commands.filter(timestamp => timestamp > windowStart);
|
||
|
||
// If user exceeds limit, block them
|
||
if (recentCommands.length >= this.rateLimitConfig.maxCommands) {
|
||
userLimits.blocked = true;
|
||
userLimits.blockExpiry = now + this.rateLimitConfig.blockDurationMs;
|
||
|
||
const blockMinutes = Math.ceil(this.rateLimitConfig.blockDurationMs / 60000);
|
||
this.say(replyTo, `⚠️ ${nick}: Rate limit exceeded! Please wait ${blockMinutes} minute(s) before using commands again.`);
|
||
console.log(`🚫 Rate limited ${nick} for ${blockMinutes} minute(s)`);
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
trackCommand(nick) {
|
||
// Don't track admin commands
|
||
if (this.rateLimitConfig.adminWhitelist.includes(nick)) {
|
||
return;
|
||
}
|
||
|
||
const now = Date.now();
|
||
|
||
if (!this.rateLimits.has(nick)) {
|
||
this.rateLimits.set(nick, {
|
||
commands: [],
|
||
blocked: false,
|
||
blockExpiry: null
|
||
});
|
||
}
|
||
|
||
const userLimits = this.rateLimits.get(nick);
|
||
userLimits.commands.push(now);
|
||
|
||
// Clean up old command timestamps (sliding window)
|
||
const windowStart = now - this.rateLimitConfig.windowMs;
|
||
userLimits.commands = userLimits.commands.filter(timestamp => timestamp > windowStart);
|
||
}
|
||
|
||
startRateLimitCleanup() {
|
||
// Clean up old rate limit entries every 5 minutes
|
||
setInterval(() => {
|
||
const now = Date.now();
|
||
const cutoffTime = now - (this.rateLimitConfig.windowMs * 2); // Keep data for 2x window time
|
||
|
||
let cleanedUsers = 0;
|
||
for (const [nick, limits] of this.rateLimits.entries()) {
|
||
// Remove users who haven't used commands recently and aren't blocked
|
||
if (!limits.blocked && limits.commands.length === 0) {
|
||
this.rateLimits.delete(nick);
|
||
cleanedUsers++;
|
||
} else if (!limits.blocked) {
|
||
// Clean up old command timestamps
|
||
const oldLength = limits.commands.length;
|
||
limits.commands = limits.commands.filter(timestamp => timestamp > cutoffTime);
|
||
|
||
// If no recent commands, remove user
|
||
if (limits.commands.length === 0) {
|
||
this.rateLimits.delete(nick);
|
||
cleanedUsers++;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (cleanedUsers > 0) {
|
||
console.log(`🧹 Cleaned up ${cleanedUsers} old rate limit entries`);
|
||
}
|
||
}, this.rateLimitConfig.cleanupInterval);
|
||
|
||
console.log(`🧹 Rate limit cleanup scheduled every ${this.rateLimitConfig.cleanupInterval / 1000}s`);
|
||
}
|
||
}
|
||
|
||
// Create and start bot
|
||
const bot = new IRCBot(config);
|
||
bot.connect();
|
||
|
||
// Handle graceful shutdown
|
||
process.on('SIGINT', () => {
|
||
console.log('\nShutting down bot...');
|
||
if (bot.socket) {
|
||
bot.send('QUIT :Bot shutting down');
|
||
bot.socket.end();
|
||
}
|
||
process.exit(0);
|
||
});
|
||
|
||
module.exports = IRCBot; |