diff --git a/.env.example b/.env.example index 21fab01b4..ac2b55d6e 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,13 @@ # Discord bot token DISCORD_TOKEN=your_discord_bot_token +# Discord application client ID (for slash command registration) +CLIENT_ID=your_discord_client_id + +# Discord guild/server ID (optional - for faster command deployment during development) +# If not set, commands deploy globally (takes up to 1 hour to propagate) +GUILD_ID=your_discord_guild_id + # OpenClaw API (routes through your Claude subscription) OPENCLAW_URL=http://localhost:18789/v1/chat/completions OPENCLAW_TOKEN=your_openclaw_gateway_token diff --git a/.gitignore b/.gitignore index 44d1a9b22..a3f626a53 100644 --- a/.gitignore +++ b/.gitignore @@ -2,13 +2,14 @@ node_modules/ .env *.log -# Auto Claude data directory +# Auto Claude data directory and files .auto-claude/ - -# Auto Claude generated files -.auto-claude-security.json -.auto-claude-status +.auto-claude-* .claude_settings.json .worktrees/ .security-key logs/security/ + +# Verification scripts +verify-*.js +VERIFICATION_GUIDE.md diff --git a/package.json b/package.json index 2d22566c8..ac7a13fba 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "type": "module", "scripts": { "start": "node src/index.js", - "dev": "node --watch src/index.js" + "dev": "node --watch src/index.js", + "deploy": "node src/deploy-commands.js" }, "dependencies": { "discord.js": "^14.25.1", diff --git a/src/commands/status.js b/src/commands/status.js new file mode 100644 index 000000000..b50afc695 --- /dev/null +++ b/src/commands/status.js @@ -0,0 +1,118 @@ +/** + * Status Command - Display bot health metrics + * + * Shows uptime, memory usage, API status, and last AI request + * Admin mode (detailed: true) shows additional diagnostics + */ + +import { EmbedBuilder, PermissionFlagsBits } from 'discord.js'; +import { HealthMonitor } from '../utils/health.js'; + +/** + * Format timestamp as relative time + */ +function formatRelativeTime(timestamp) { + if (!timestamp) return 'Never'; + + const now = Date.now(); + const diff = now - timestamp; + const seconds = Math.floor(diff / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (diff < 1000) return 'Just now'; + if (seconds < 60) return `${seconds}s ago`; + if (minutes < 60) return `${minutes}m ago`; + if (hours < 24) return `${hours}h ago`; + return `${days}d ago`; +} + +/** + * Get status emoji based on API status + */ +function getStatusEmoji(status) { + switch (status) { + case 'ok': return '🟢'; + case 'error': return '🔴'; + case 'unknown': return '🟡'; + default: return '⚪'; + } +} + +/** + * Execute the status command + */ +export async function execute(interaction) { + try { + const detailed = interaction.options.getBoolean('detailed') || false; + const healthMonitor = HealthMonitor.getInstance(); + + if (detailed) { + // Check if user has admin permissions + if (!interaction.memberPermissions?.has(PermissionFlagsBits.Administrator)) { + await interaction.reply({ + content: '❌ Detailed diagnostics are only available to administrators.', + ephemeral: true + }); + return; + } + + // Detailed mode - admin diagnostics + const status = healthMonitor.getDetailedStatus(); + + const embed = new EmbedBuilder() + .setColor(0x5865F2) + .setTitle('🔍 Bot Status - Detailed Diagnostics') + .addFields( + { name: '⏱️ Uptime', value: status.uptimeFormatted, inline: true }, + { name: '🧠 Memory', value: status.memory.formatted, inline: true }, + { name: '🌐 API', value: `${getStatusEmoji(status.api.status)} ${status.api.status}`, inline: true }, + { name: '🤖 Last AI Request', value: formatRelativeTime(status.lastAIRequest), inline: true }, + { name: '📊 Process ID', value: `${status.process.pid}`, inline: true }, + { name: '🖥️ Platform', value: status.process.platform, inline: true }, + { name: '📦 Node Version', value: status.process.nodeVersion, inline: true }, + { name: '⚙️ Process Uptime', value: `${Math.floor(status.process.uptime)}s`, inline: true }, + { name: '🔢 Heap Used', value: `${status.memory.heapUsed}MB`, inline: true }, + { name: '💾 RSS', value: `${status.memory.rss}MB`, inline: true }, + { name: '📡 External', value: `${status.memory.external}MB`, inline: true }, + { name: '🔢 Array Buffers', value: `${status.memory.arrayBuffers}MB`, inline: true } + ) + .setTimestamp() + .setFooter({ text: 'Detailed diagnostics mode' }); + + await interaction.reply({ embeds: [embed], ephemeral: true }); + } else { + // Basic mode - user-friendly status + const status = healthMonitor.getStatus(); + + const embed = new EmbedBuilder() + .setColor(0x57F287) + .setTitle('📊 Bot Status') + .setDescription('Current health and performance metrics') + .addFields( + { name: '⏱️ Uptime', value: status.uptimeFormatted, inline: true }, + { name: '🧠 Memory', value: status.memory.formatted, inline: true }, + { name: '🌐 API Status', value: `${getStatusEmoji(status.api.status)} ${status.api.status.toUpperCase()}`, inline: true }, + { name: '🤖 Last AI Request', value: formatRelativeTime(status.lastAIRequest), inline: false } + ) + .setTimestamp() + .setFooter({ text: 'Use /status detailed:true for more info' }); + + await interaction.reply({ embeds: [embed] }); + } + } catch (err) { + console.error('Status command error:', err.message); + + const reply = { + content: 'Sorry, I couldn\'t retrieve the status. Try again in a moment!', + ephemeral: true + }; + + if (interaction.replied || interaction.deferred) { + await interaction.followUp(reply).catch(() => {}); + } else { + await interaction.reply(reply).catch(() => {}); + } + } +} diff --git a/src/deploy-commands.js b/src/deploy-commands.js new file mode 100644 index 000000000..b6fc0c5ba --- /dev/null +++ b/src/deploy-commands.js @@ -0,0 +1,79 @@ +/** + * Deploy Discord Slash Commands + * + * Registers bot commands with Discord API + * Run with: node src/deploy-commands.js + */ + +import { REST, Routes, ApplicationCommandOptionType } from 'discord.js'; +import { config as dotenvConfig } from 'dotenv'; + +dotenvConfig(); + +const DISCORD_TOKEN = process.env.DISCORD_TOKEN; +const CLIENT_ID = process.env.CLIENT_ID; +const GUILD_ID = process.env.GUILD_ID; // Optional: for faster guild-only deployment + +if (!DISCORD_TOKEN) { + console.error('❌ Missing DISCORD_TOKEN in .env'); + process.exit(1); +} + +if (!CLIENT_ID) { + console.error('❌ Missing CLIENT_ID in .env'); + process.exit(1); +} + +// Define commands +const commands = [ + { + name: 'status', + description: 'Check bot health and status', + options: [ + { + name: 'detailed', + description: 'Show detailed diagnostics (admin only)', + type: ApplicationCommandOptionType.Boolean, + required: false, + }, + ], + }, +]; + +const rest = new REST({ version: '10' }).setToken(DISCORD_TOKEN); + +/** + * Deploy commands to Discord + */ +async function deployCommands() { + try { + console.log('🔄 Registering slash commands...'); + + let route; + let scope; + + if (GUILD_ID) { + // Guild-specific deployment (faster for testing) + route = Routes.applicationGuildCommands(CLIENT_ID, GUILD_ID); + scope = `guild ${GUILD_ID}`; + } else { + // Global deployment (takes up to 1 hour to propagate) + route = Routes.applicationCommands(CLIENT_ID); + scope = 'globally'; + } + + const data = await rest.put(route, { body: commands }); + + console.log(`✅ Successfully registered ${data.length} command(s) ${scope}`); + console.log(` Commands: ${data.map(cmd => `/${cmd.name}`).join(', ')}`); + + if (!GUILD_ID) { + console.log('⏱️ Note: Global commands may take up to 1 hour to appear'); + } + } catch (error) { + console.error('❌ Failed to register commands:', error); + process.exit(1); + } +} + +deployCommands(); diff --git a/src/index.js b/src/index.js index 754e73dba..4d39cf2f6 100644 --- a/src/index.js +++ b/src/index.js @@ -12,6 +12,8 @@ import { config as dotenvConfig } from 'dotenv'; import { readFileSync, existsSync } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; +import { HealthMonitor } from './utils/health.js'; +import * as statusCommand from './commands/status.js'; dotenvConfig(); @@ -46,6 +48,9 @@ const client = new Client({ ], }); +// Initialize health monitor +const healthMonitor = HealthMonitor.getInstance(); + // Conversation history per channel (simple in-memory store) const conversationHistory = new Map(); const MAX_HISTORY = 20; @@ -126,19 +131,25 @@ You can use Discord markdown formatting.`; }); if (!response.ok) { + healthMonitor.setAPIStatus('error'); throw new Error(`API error: ${response.status} ${response.statusText}`); } const data = await response.json(); const reply = data.choices?.[0]?.message?.content || "I got nothing. Try again?"; - + + // Record successful AI request + healthMonitor.recordAIRequest(); + healthMonitor.setAPIStatus('ok'); + // Update history addToHistory(channelId, 'user', `${username}: ${userMessage}`); addToHistory(channelId, 'assistant', reply); - + return reply; } catch (err) { console.error('OpenClaw API error:', err.message); + healthMonitor.setAPIStatus('error'); return "Sorry, I'm having trouble thinking right now. Try again in a moment!"; } } @@ -175,7 +186,10 @@ async function sendSpamAlert(message) { client.once('ready', () => { console.log(`✅ ${client.user.tag} is online!`); console.log(`📡 Serving ${client.guilds.cache.size} server(s)`); - + + // Record bot start time + healthMonitor.recordStart(); + if (config.welcome?.enabled) { console.log(`👋 Welcome messages → #${config.welcome.channelId}`); } @@ -262,6 +276,41 @@ client.on('messageCreate', async (message) => { } }); +// Handle slash commands +client.on('interactionCreate', async (interaction) => { + if (!interaction.isChatInputCommand()) return; + + try { + console.log(`[INTERACTION] /${interaction.commandName} from ${interaction.user.tag}`); + + // Route commands + switch (interaction.commandName) { + case 'status': + await statusCommand.execute(interaction); + break; + default: + await interaction.reply({ + content: 'Unknown command!', + ephemeral: true + }); + } + } catch (err) { + console.error('Interaction error:', err.message); + + // Try to respond if we haven't already + const reply = { + content: 'Sorry, something went wrong with that command.', + ephemeral: true + }; + + if (interaction.replied || interaction.deferred) { + await interaction.followUp(reply).catch(() => {}); + } else { + await interaction.reply(reply).catch(() => {}); + } + } +}); + // Error handling client.on('error', (error) => { console.error('Discord error:', error); diff --git a/src/utils/health.js b/src/utils/health.js new file mode 100644 index 000000000..c6b243a82 --- /dev/null +++ b/src/utils/health.js @@ -0,0 +1,159 @@ +/** + * Health Monitor - Tracks bot health metrics + * + * Monitors: + * - Uptime (time since bot started) + * - Memory usage + * - Last AI request timestamp + * - OpenClaw API connectivity status + */ + +/** + * Singleton health monitor instance + */ +class HealthMonitor { + constructor() { + if (HealthMonitor.instance) { + throw new Error('Use HealthMonitor.getInstance() to obtain the singleton'); + } + + this.startTime = Date.now(); + this.lastAIRequest = null; + this.apiStatus = 'unknown'; + this.lastAPICheck = null; + + HealthMonitor.instance = this; + } + + /** + * Get singleton instance + */ + static getInstance() { + if (!HealthMonitor.instance) { + HealthMonitor.instance = new HealthMonitor(); + } + return HealthMonitor.instance; + } + + /** + * Record the start time (call when bot is ready) + */ + recordStart() { + this.startTime = Date.now(); + } + + /** + * Record AI request activity + */ + recordAIRequest() { + this.lastAIRequest = Date.now(); + } + + /** + * Update API status + * @param {string} status - 'ok', 'error', or 'unknown' + */ + setAPIStatus(status) { + this.apiStatus = status; + this.lastAPICheck = Date.now(); + } + + /** + * Get current uptime in milliseconds + */ + getUptime() { + return Date.now() - this.startTime; + } + + /** + * Get formatted uptime string + */ + getFormattedUptime() { + const uptime = this.getUptime(); + const seconds = Math.floor(uptime / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 0) { + return `${days}d ${hours % 24}h ${minutes % 60}m`; + } else if (hours > 0) { + return `${hours}h ${minutes % 60}m ${seconds % 60}s`; + } else if (minutes > 0) { + return `${minutes}m ${seconds % 60}s`; + } else { + return `${seconds}s`; + } + } + + /** + * Get memory usage stats + */ + getMemoryUsage() { + const usage = process.memoryUsage(); + return { + heapUsed: Math.round(usage.heapUsed / 1024 / 1024), // MB + heapTotal: Math.round(usage.heapTotal / 1024 / 1024), // MB + rss: Math.round(usage.rss / 1024 / 1024), // MB + external: Math.round(usage.external / 1024 / 1024), // MB + }; + } + + /** + * Get formatted memory usage string + */ + getFormattedMemory() { + const mem = this.getMemoryUsage(); + return `${mem.heapUsed}MB / ${mem.heapTotal}MB (RSS: ${mem.rss}MB)`; + } + + /** + * Get complete health status + */ + getStatus() { + const memory = this.getMemoryUsage(); + + return { + uptime: this.getUptime(), + uptimeFormatted: this.getFormattedUptime(), + memory: { + heapUsed: memory.heapUsed, + heapTotal: memory.heapTotal, + rss: memory.rss, + external: memory.external, + formatted: this.getFormattedMemory(), + }, + api: { + status: this.apiStatus, + lastCheck: this.lastAPICheck, + }, + lastAIRequest: this.lastAIRequest, + timestamp: Date.now(), + }; + } + + /** + * Get detailed diagnostics (for admin use) + */ + getDetailedStatus() { + const status = this.getStatus(); + const memory = process.memoryUsage(); + + return { + ...status, + process: { + pid: process.pid, + platform: process.platform, + nodeVersion: process.version, + uptime: process.uptime(), + }, + memory: { + ...status.memory, + arrayBuffers: Math.round(memory.arrayBuffers / 1024 / 1024), + }, + cpu: process.cpuUsage(), + }; + } +} + +export { HealthMonitor };