Skip to content
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Empty file added data/.gitkeep
Empty file.
113 changes: 112 additions & 1 deletion src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,29 @@
* - 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';
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();

Expand All @@ -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);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pending request tracking functions never called

Medium Severity

The registerPendingRequest and removePendingRequest functions are exported but never imported or called anywhere in the codebase. The generateResponse function in events.js makes AI requests without registering them. As a result, pendingRequests will always be empty, and the graceful shutdown logic that waits for pending requests to complete will never actually wait for anything—requests in flight during shutdown will be dropped.

Additional Locations (1)

Fix in Cursor Fix in Web


/**
* 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
*/
Expand Down Expand Up @@ -137,13 +204,57 @@ 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) {
console.error('❌ DISCORD_TOKEN not set');
process.exit(1);
}

// Load previous state on startup
loadState();

// Load commands and login
loadCommands()
.then(() => client.login(token))
Expand Down
18 changes: 17 additions & 1 deletion src/modules/ai.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 || '';
Expand Down