diff --git a/.gitignore b/.gitignore index a3f626a5..d5e80c60 100644 --- a/.gitignore +++ b/.gitignore @@ -5,11 +5,17 @@ node_modules/ # Auto Claude data directory and files .auto-claude/ .auto-claude-* +.auto-claude-security.json +.auto-claude-status .claude_settings.json .worktrees/ .security-key logs/security/ +# State persistence data (keep structure, ignore content) +data/* +!data/.gitkeep + # Verification scripts verify-*.js VERIFICATION_GUIDE.md diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/index.js b/src/index.js index b17a13f0..55c421ea 100644 --- a/src/index.js +++ b/src/index.js @@ -7,11 +7,12 @@ * - Welcome messages for new members * - Spam/scam detection and moderation * - Health monitoring and status command + * - Graceful shutdown handling */ import { Client, GatewayIntentBits, Collection } from 'discord.js'; import { config as dotenvConfig } from 'dotenv'; -import { readdirSync } from 'fs'; +import { readdirSync, writeFileSync, readFileSync, existsSync, mkdirSync } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; import { loadConfig } from './modules/config.js'; @@ -19,11 +20,16 @@ import { registerEventHandlers } from './modules/events.js'; import { HealthMonitor } from './utils/health.js'; import { registerCommands } from './utils/registerCommands.js'; import { hasPermission, getPermissionError } from './utils/permissions.js'; +import { getConversationHistory, setConversationHistory } from './modules/ai.js'; // ES module dirname equivalent const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); +// State persistence path +const dataDir = join(__dirname, '..', 'data'); +const statePath = join(dataDir, 'state.json'); + // Load environment variables dotenvConfig(); @@ -46,6 +52,67 @@ client.commands = new Collection(); // Initialize health monitor const healthMonitor = HealthMonitor.getInstance(); +// Track pending AI requests for graceful shutdown +const pendingRequests = new Set(); + +/** + * Register a pending request for tracking + * @returns {Symbol} Request ID to use for cleanup + */ +export function registerPendingRequest() { + const requestId = Symbol('request'); + pendingRequests.add(requestId); + return requestId; +} + +/** + * Remove a pending request from tracking + * @param {Symbol} requestId - Request ID to remove + */ +export function removePendingRequest(requestId) { + pendingRequests.delete(requestId); +} + +/** + * Save conversation history to disk + */ +function saveState() { + try { + // Ensure data directory exists + if (!existsSync(dataDir)) { + mkdirSync(dataDir, { recursive: true }); + } + + const conversationHistory = getConversationHistory(); + const stateData = { + conversationHistory: Array.from(conversationHistory.entries()), + timestamp: new Date().toISOString(), + }; + writeFileSync(statePath, JSON.stringify(stateData, null, 2), 'utf-8'); + console.log('šŸ’¾ State saved successfully'); + } catch (err) { + console.error('āŒ Failed to save state:', err.message); + } +} + +/** + * Load conversation history from disk + */ +function loadState() { + try { + if (!existsSync(statePath)) { + return; + } + const stateData = JSON.parse(readFileSync(statePath, 'utf-8')); + if (stateData.conversationHistory) { + setConversationHistory(new Map(stateData.conversationHistory)); + console.log('šŸ“‚ State loaded successfully'); + } + } catch (err) { + console.error('āŒ Failed to load state:', err.message); + } +} + /** * Load all commands from the commands directory */ @@ -137,6 +204,47 @@ client.on('interactionCreate', async (interaction) => { } }); +/** + * Graceful shutdown handler + * @param {string} signal - Signal that triggered shutdown + */ +async function gracefulShutdown(signal) { + console.log(`\nšŸ›‘ Received ${signal}, shutting down gracefully...`); + + // 1. Wait for pending requests with timeout + const SHUTDOWN_TIMEOUT = 10000; // 10 seconds + if (pendingRequests.size > 0) { + console.log(`ā³ Waiting for ${pendingRequests.size} pending request(s)...`); + const startTime = Date.now(); + + while (pendingRequests.size > 0 && (Date.now() - startTime) < SHUTDOWN_TIMEOUT) { + await new Promise(resolve => setTimeout(resolve, 100)); + } + + if (pendingRequests.size > 0) { + console.log(`āš ļø Timeout: ${pendingRequests.size} request(s) still pending`); + } else { + console.log('āœ… All requests completed'); + } + } + + // 2. Save state after pending requests complete + console.log('šŸ’¾ Saving conversation state...'); + saveState(); + + // 3. Destroy Discord client + console.log('šŸ”Œ Disconnecting from Discord...'); + client.destroy(); + + // 4. Log clean exit + console.log('āœ… Shutdown complete'); + process.exit(0); +} + +// Handle shutdown signals +process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); +process.on('SIGINT', () => gracefulShutdown('SIGINT')); + // Start bot const token = process.env.DISCORD_TOKEN; if (!token) { @@ -144,6 +252,9 @@ if (!token) { process.exit(1); } +// Load previous state on startup +loadState(); + // Load commands and login loadCommands() .then(() => client.login(token)) diff --git a/src/modules/ai.js b/src/modules/ai.js index 26adfedb..5ab922ab 100644 --- a/src/modules/ai.js +++ b/src/modules/ai.js @@ -4,9 +4,25 @@ */ // Conversation history per channel (simple in-memory store) -const conversationHistory = new Map(); +let conversationHistory = new Map(); const MAX_HISTORY = 20; +/** + * Get the full conversation history map (for state persistence) + * @returns {Map} Conversation history map + */ +export function getConversationHistory() { + return conversationHistory; +} + +/** + * Set the conversation history map (for state restoration) + * @param {Map} history - Conversation history map to restore + */ +export function setConversationHistory(history) { + conversationHistory = history; +} + // OpenClaw API endpoint const OPENCLAW_URL = process.env.OPENCLAW_URL || 'http://localhost:18789/v1/chat/completions'; const OPENCLAW_TOKEN = process.env.OPENCLAW_TOKEN || '';