diff --git a/.env.example b/.env.example index ac2b55d6..6501e8f4 100644 --- a/.env.example +++ b/.env.example @@ -11,3 +11,6 @@ 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 + +# Logging level (options: debug, info, warn, error) +LOG_LEVEL=info diff --git a/.gitignore b/.gitignore index d5e80c60..9f6ba53a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ node_modules/ .env *.log +logs/ # Auto Claude data directory and files .auto-claude/ diff --git a/config.json b/config.json index fa311500..88617138 100644 --- a/config.json +++ b/config.json @@ -16,6 +16,10 @@ "alertChannelId": "1438665401243275284", "autoDelete": false }, + "logging": { + "level": "info", + "fileOutput": true + }, "permissions": { "enabled": true, "adminRoleId": null, diff --git a/package.json b/package.json index ac7a13fb..7b94b0db 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,9 @@ }, "dependencies": { "discord.js": "^14.25.1", - "dotenv": "^17.2.3" + "dotenv": "^17.2.3", + "winston": "^3.19.0", + "winston-daily-rotate-file": "^5.0.0" }, "pnpm": { "overrides": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 431521fa..32536af5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,9 +17,22 @@ importers: dotenv: specifier: ^17.2.3 version: 17.2.3 + winston: + specifier: ^3.19.0 + version: 3.19.0 + winston-daily-rotate-file: + specifier: ^5.0.0 + version: 5.0.0(winston@3.19.0) packages: + '@colors/colors@1.6.0': + resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==} + engines: {node: '>=0.1.90'} + + '@dabh/diagnostics@2.0.8': + resolution: {integrity: sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==} + '@discordjs/builders@1.13.1': resolution: {integrity: sha512-cOU0UDHc3lp/5nKByDxkmRiNZBpdp0kx55aarbiAfakfKJHlxv/yFW1zmIqCAmwH5CRlrH9iMFKJMpvW4DPB+w==} engines: {node: '>=16.11.0'} @@ -60,9 +73,15 @@ packages: resolution: {integrity: sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==} engines: {node: '>=v14.0.0', npm: '>=7.0.0'} + '@so-ric/colorspace@1.1.6': + resolution: {integrity: sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==} + '@types/node@25.2.0': resolution: {integrity: sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w==} + '@types/triple-beam@1.3.5': + resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==} + '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} @@ -70,6 +89,25 @@ packages: resolution: {integrity: sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==} engines: {node: '>=v14.0.0', npm: '>=7.0.0'} + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + + color-convert@3.1.3: + resolution: {integrity: sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==} + engines: {node: '>=14.6'} + + color-name@2.1.0: + resolution: {integrity: sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==} + engines: {node: '>=12.20'} + + color-string@2.1.4: + resolution: {integrity: sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==} + engines: {node: '>=18'} + + color@5.0.3: + resolution: {integrity: sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==} + engines: {node: '>=18'} + discord-api-types@0.38.38: resolution: {integrity: sha512-7qcM5IeZrfb+LXW07HvoI5L+j4PQeMZXEkSm1htHAHh4Y9JSMXBWjy/r7zmUCOj4F7zNjMcm7IMWr131MT2h0Q==} @@ -81,18 +119,81 @@ packages: resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} engines: {node: '>=12'} + enabled@2.0.0: + resolution: {integrity: sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fecha@4.2.3: + resolution: {integrity: sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==} + + file-stream-rotator@0.6.1: + resolution: {integrity: sha512-u+dBid4PvZw17PmDeRcNOtCP9CCK/9lRN2w+r1xIS7yOL9JFrIBKTvrYsxT4P0pGtThYTn++QS5ChHaUov3+zQ==} + + fn.name@1.1.0: + resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + kuler@2.0.0: + resolution: {integrity: sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==} + lodash.snakecase@4.1.1: resolution: {integrity: sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==} lodash@4.17.23: resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} + logform@2.7.0: + resolution: {integrity: sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==} + engines: {node: '>= 12.0.0'} + magic-bytes.js@1.13.0: resolution: {integrity: sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==} + moment@2.30.1: + resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + + one-time@1.0.0: + resolution: {integrity: sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + stack-trace@0.0.10: + resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + text-hex@1.0.0: + resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==} + + triple-beam@1.4.1: + resolution: {integrity: sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==} + engines: {node: '>= 14.0.0'} + ts-mixer@6.0.4: resolution: {integrity: sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==} @@ -106,6 +207,23 @@ packages: resolution: {integrity: sha512-MJZrkjyd7DeC+uPZh+5/YaMDxFiiEEaDgbUSVMXayofAkDWF1088CDo+2RPg7B1BuS1qf1vgNE7xqwPxE0DuSQ==} engines: {node: '>=20.18.1'} + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + winston-daily-rotate-file@5.0.0: + resolution: {integrity: sha512-JDjiXXkM5qvwY06733vf09I2wnMXpZEhxEVOSPenZMii+g7pcDcTBt2MRugnoi8BwVSuCT2jfRXBUy+n1Zz/Yw==} + engines: {node: '>=8'} + peerDependencies: + winston: ^3 + + winston-transport@4.9.0: + resolution: {integrity: sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==} + engines: {node: '>= 12.0.0'} + + winston@3.19.0: + resolution: {integrity: sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==} + engines: {node: '>= 12.0.0'} + ws@8.19.0: resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} engines: {node: '>=10.0.0'} @@ -120,6 +238,14 @@ packages: snapshots: + '@colors/colors@1.6.0': {} + + '@dabh/diagnostics@2.0.8': + dependencies: + '@so-ric/colorspace': 1.1.6 + enabled: 2.0.0 + kuler: 2.0.0 + '@discordjs/builders@1.13.1': dependencies: '@discordjs/formatters': 0.6.2 @@ -178,16 +304,40 @@ snapshots: '@sapphire/snowflake@3.5.3': {} + '@so-ric/colorspace@1.1.6': + dependencies: + color: 5.0.3 + text-hex: 1.0.0 + '@types/node@25.2.0': dependencies: undici-types: 7.16.0 + '@types/triple-beam@1.3.5': {} + '@types/ws@8.18.1': dependencies: '@types/node': 25.2.0 '@vladfrangu/async_event_emitter@2.4.7': {} + async@3.2.6: {} + + color-convert@3.1.3: + dependencies: + color-name: 2.1.0 + + color-name@2.1.0: {} + + color-string@2.1.4: + dependencies: + color-name: 2.1.0 + + color@5.0.3: + dependencies: + color-convert: 3.1.3 + color-string: 2.1.4 + discord-api-types@0.38.38: {} discord.js@14.25.1: @@ -211,14 +361,69 @@ snapshots: dotenv@17.2.3: {} + enabled@2.0.0: {} + fast-deep-equal@3.1.3: {} + fecha@4.2.3: {} + + file-stream-rotator@0.6.1: + dependencies: + moment: 2.30.1 + + fn.name@1.1.0: {} + + inherits@2.0.4: {} + + is-stream@2.0.1: {} + + kuler@2.0.0: {} + lodash.snakecase@4.1.1: {} lodash@4.17.23: {} + logform@2.7.0: + dependencies: + '@colors/colors': 1.6.0 + '@types/triple-beam': 1.3.5 + fecha: 4.2.3 + ms: 2.1.3 + safe-stable-stringify: 2.5.0 + triple-beam: 1.4.1 + magic-bytes.js@1.13.0: {} + moment@2.30.1: {} + + ms@2.1.3: {} + + object-hash@3.0.0: {} + + one-time@1.0.0: + dependencies: + fn.name: 1.1.0 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + safe-buffer@5.2.1: {} + + safe-stable-stringify@2.5.0: {} + + stack-trace@0.0.10: {} + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + text-hex@1.0.0: {} + + triple-beam@1.4.1: {} + ts-mixer@6.0.4: {} tslib@2.8.1: {} @@ -227,4 +432,34 @@ snapshots: undici@7.20.0: {} + util-deprecate@1.0.2: {} + + winston-daily-rotate-file@5.0.0(winston@3.19.0): + dependencies: + file-stream-rotator: 0.6.1 + object-hash: 3.0.0 + triple-beam: 1.4.1 + winston: 3.19.0 + winston-transport: 4.9.0 + + winston-transport@4.9.0: + dependencies: + logform: 2.7.0 + readable-stream: 3.6.2 + triple-beam: 1.4.1 + + winston@3.19.0: + dependencies: + '@colors/colors': 1.6.0 + '@dabh/diagnostics': 2.0.8 + async: 3.2.6 + is-stream: 2.0.1 + logform: 2.7.0 + one-time: 1.0.0 + readable-stream: 3.6.2 + safe-stable-stringify: 2.5.0 + stack-trace: 0.0.10 + triple-beam: 1.4.1 + winston-transport: 4.9.0 + ws@8.19.0: {} diff --git a/src/index.js b/src/index.js index 55c421ea..2731e761 100644 --- a/src/index.js +++ b/src/index.js @@ -8,6 +8,7 @@ * - Spam/scam detection and moderation * - Health monitoring and status command * - Graceful shutdown handling + * - Structured logging */ import { Client, GatewayIntentBits, Collection } from 'discord.js'; @@ -15,6 +16,7 @@ import { config as dotenvConfig } from 'dotenv'; import { readdirSync, writeFileSync, readFileSync, existsSync, mkdirSync } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; +import { info, warn, error } from './logger.js'; import { loadConfig } from './modules/config.js'; import { registerEventHandlers } from './modules/events.js'; import { HealthMonitor } from './utils/health.js'; @@ -89,9 +91,9 @@ function saveState() { timestamp: new Date().toISOString(), }; writeFileSync(statePath, JSON.stringify(stateData, null, 2), 'utf-8'); - console.log('๐Ÿ’พ State saved successfully'); + info('State saved successfully'); } catch (err) { - console.error('โŒ Failed to save state:', err.message); + error('Failed to save state', { error: err.message }); } } @@ -106,10 +108,10 @@ function loadState() { const stateData = JSON.parse(readFileSync(statePath, 'utf-8')); if (stateData.conversationHistory) { setConversationHistory(new Map(stateData.conversationHistory)); - console.log('๐Ÿ“‚ State loaded successfully'); + info('State loaded successfully'); } } catch (err) { - console.error('โŒ Failed to load state:', err.message); + error('Failed to load state', { error: err.message }); } } @@ -126,12 +128,12 @@ async function loadCommands() { const command = await import(filePath); if (command.data && command.execute) { client.commands.set(command.data.name, command); - console.log(`โœ… Loaded command: ${command.data.name}`); + info('Loaded command', { command: command.data.name }); } else { - console.warn(`โš ๏ธ Command ${file} missing data or execute export`); + warn('Command missing data or execute export', { file }); } } catch (err) { - console.error(`โŒ Failed to load command ${file}:`, err.message); + error('Failed to load command', { file, error: err.message }); } } } @@ -153,7 +155,7 @@ client.once('ready', async () => { guildId ); } catch (err) { - console.error('Command registration failed:', err.message); + error('Command registration failed', { error: err.message }); } }); @@ -164,7 +166,7 @@ client.on('interactionCreate', async (interaction) => { const { commandName, member } = interaction; try { - console.log(`[INTERACTION] /${commandName} from ${interaction.user.tag}`); + info('Slash command received', { command: commandName, user: interaction.user.tag }); // Permission check if (!hasPermission(member, commandName, config)) { @@ -172,7 +174,7 @@ client.on('interactionCreate', async (interaction) => { content: getPermissionError(commandName), ephemeral: true }); - console.log(`[DENIED] ${interaction.user.tag} attempted /${commandName}`); + warn('Permission denied', { user: interaction.user.tag, command: commandName }); return; } @@ -187,9 +189,9 @@ client.on('interactionCreate', async (interaction) => { } await command.execute(interaction); - console.log(`[CMD] ${interaction.user.tag} used /${commandName}`); + info('Command executed', { command: commandName, user: interaction.user.tag }); } catch (err) { - console.error(`Command error (/${commandName}):`, err.message); + error('Command error', { command: commandName, error: err.message, stack: err.stack }); const errorMessage = { content: 'โŒ An error occurred while executing this command.', @@ -209,12 +211,12 @@ client.on('interactionCreate', async (interaction) => { * @param {string} signal - Signal that triggered shutdown */ async function gracefulShutdown(signal) { - console.log(`\n๐Ÿ›‘ Received ${signal}, shutting down gracefully...`); + info('Shutdown initiated', { signal }); // 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)...`); + info('Waiting for pending requests', { count: pendingRequests.size }); const startTime = Date.now(); while (pendingRequests.size > 0 && (Date.now() - startTime) < SHUTDOWN_TIMEOUT) { @@ -222,22 +224,22 @@ async function gracefulShutdown(signal) { } if (pendingRequests.size > 0) { - console.log(`โš ๏ธ Timeout: ${pendingRequests.size} request(s) still pending`); + warn('Shutdown timeout, requests still pending', { count: pendingRequests.size }); } else { - console.log('โœ… All requests completed'); + info('All requests completed'); } } // 2. Save state after pending requests complete - console.log('๐Ÿ’พ Saving conversation state...'); + info('Saving conversation state'); saveState(); // 3. Destroy Discord client - console.log('๐Ÿ”Œ Disconnecting from Discord...'); + info('Disconnecting from Discord'); client.destroy(); // 4. Log clean exit - console.log('โœ… Shutdown complete'); + info('Shutdown complete'); process.exit(0); } @@ -245,10 +247,27 @@ async function gracefulShutdown(signal) { process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); process.on('SIGINT', () => gracefulShutdown('SIGINT')); +// Error handling +client.on('error', (err) => { + error('Discord client error', { + error: err.message, + stack: err.stack, + code: err.code + }); +}); + +process.on('unhandledRejection', (err) => { + error('Unhandled promise rejection', { + error: err?.message || String(err), + stack: err?.stack, + type: typeof err + }); +}); + // Start bot const token = process.env.DISCORD_TOKEN; if (!token) { - console.error('โŒ DISCORD_TOKEN not set'); + error('DISCORD_TOKEN not set'); process.exit(1); } @@ -259,6 +278,6 @@ loadState(); loadCommands() .then(() => client.login(token)) .catch((err) => { - console.error('โŒ Startup failed:', err.message); + error('Startup failed', { error: err.message }); process.exit(1); }); diff --git a/src/logger.js b/src/logger.js new file mode 100644 index 00000000..7c092cff --- /dev/null +++ b/src/logger.js @@ -0,0 +1,243 @@ +/** + * Structured Logger Module + * + * Provides centralized logging with: + * - Multiple log levels (debug, info, warn, error) + * - Timestamp formatting + * - Structured output + * - Console transport (file transport added in phase 3) + */ + +import winston from 'winston'; +import DailyRotateFile from 'winston-daily-rotate-file'; +import { readFileSync, existsSync, mkdirSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const configPath = join(__dirname, '..', 'config.json'); +const logsDir = join(__dirname, '..', 'logs'); + +// Load config to get log level and file output setting +let logLevel = 'info'; +let fileOutputEnabled = false; + +try { + if (existsSync(configPath)) { + const config = JSON.parse(readFileSync(configPath, 'utf-8')); + logLevel = process.env.LOG_LEVEL || config.logging?.level || 'info'; + fileOutputEnabled = config.logging?.fileOutput || false; + } +} catch (err) { + // Fallback to default if config can't be loaded + logLevel = process.env.LOG_LEVEL || 'info'; +} + +// Create logs directory if file output is enabled +if (fileOutputEnabled) { + try { + if (!existsSync(logsDir)) { + mkdirSync(logsDir, { recursive: true }); + } + } catch (err) { + // Log directory creation failed, but continue without file logging + fileOutputEnabled = false; + } +} + +/** + * Sensitive field names that should be redacted from logs + */ +const SENSITIVE_FIELDS = [ + 'DISCORD_TOKEN', + 'OPENCLAW_TOKEN', + 'token', + 'password', + 'apiKey', + 'authorization' +]; + +/** + * Recursively filter sensitive data from objects + */ +function filterSensitiveData(obj) { + if (obj === null || obj === undefined) { + return obj; + } + + if (typeof obj !== 'object') { + return obj; + } + + if (Array.isArray(obj)) { + return obj.map(item => filterSensitiveData(item)); + } + + const filtered = {}; + for (const [key, value] of Object.entries(obj)) { + // Check if key matches any sensitive field (case-insensitive) + const isSensitive = SENSITIVE_FIELDS.some( + field => key.toLowerCase() === field.toLowerCase() + ); + + if (isSensitive) { + filtered[key] = '[REDACTED]'; + } else if (typeof value === 'object' && value !== null) { + filtered[key] = filterSensitiveData(value); + } else { + filtered[key] = value; + } + } + + return filtered; +} + +/** + * Winston format that redacts sensitive data + */ +const redactSensitiveData = winston.format((info) => { + // Reserved winston properties that should not be filtered + const reserved = ['level', 'message', 'timestamp', 'stack']; + + // Filter each property in the info object + for (const key in info) { + if (Object.prototype.hasOwnProperty.call(info, key) && !reserved.includes(key)) { + // Check if this key is sensitive (case-insensitive) + const isSensitive = SENSITIVE_FIELDS.some( + field => key.toLowerCase() === field.toLowerCase() + ); + + if (isSensitive) { + info[key] = '[REDACTED]'; + } else if (typeof info[key] === 'object' && info[key] !== null) { + // Recursively filter nested objects + info[key] = filterSensitiveData(info[key]); + } + } + } + + return info; +})(); + +/** + * Emoji mapping for log levels + */ +const EMOJI_MAP = { + error: 'โŒ', + warn: 'โš ๏ธ', + info: 'โœ…', + debug: '๐Ÿ”' +}; + +/** + * Format that stores the original level before colorization + */ +const preserveOriginalLevel = winston.format((info) => { + info.originalLevel = info.level; + return info; +})(); + +/** + * Custom format for console output with emoji prefixes + */ +const consoleFormat = winston.format.printf(({ level, message, timestamp, originalLevel, ...meta }) => { + // Use originalLevel for emoji lookup since 'level' may contain ANSI color codes + const prefix = EMOJI_MAP[originalLevel] || '๐Ÿ“'; + const metaStr = Object.keys(meta).length > 0 ? ` ${JSON.stringify(meta)}` : ''; + + return `${prefix} [${timestamp}] ${level.toUpperCase()}: ${message}${metaStr}`; +}); + +/** + * Create winston logger instance + */ +const transports = [ + new winston.transports.Console({ + format: winston.format.combine( + redactSensitiveData, + preserveOriginalLevel, + winston.format.colorize(), + winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), + consoleFormat + ) + }) +]; + +// Add file transport if enabled in config +if (fileOutputEnabled) { + transports.push( + new DailyRotateFile({ + filename: join(logsDir, 'combined-%DATE%.log'), + datePattern: 'YYYY-MM-DD', + maxSize: '20m', + maxFiles: '14d', + format: winston.format.combine( + redactSensitiveData, + winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), + winston.format.json() + ) + }) + ); + + // Separate transport for error-level logs only + transports.push( + new DailyRotateFile({ + level: 'error', + filename: join(logsDir, 'error-%DATE%.log'), + datePattern: 'YYYY-MM-DD', + maxSize: '20m', + maxFiles: '14d', + format: winston.format.combine( + redactSensitiveData, + winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), + winston.format.json() + ) + }) + ); +} + +const logger = winston.createLogger({ + level: logLevel, + format: winston.format.combine( + winston.format.errors({ stack: true }), + winston.format.splat() + ), + transports +}); + +/** + * Log at debug level + */ +export function debug(message, meta = {}) { + logger.debug(message, meta); +} + +/** + * Log at info level + */ +export function info(message, meta = {}) { + logger.info(message, meta); +} + +/** + * Log at warn level + */ +export function warn(message, meta = {}) { + logger.warn(message, meta); +} + +/** + * Log at error level + */ +export function error(message, meta = {}) { + logger.error(message, meta); +} + +// Default export for convenience +export default { + debug, + info, + warn, + error, + logger // Export winston logger instance for advanced usage +}; diff --git a/test-log-levels.js b/test-log-levels.js new file mode 100644 index 00000000..ad73c9d4 --- /dev/null +++ b/test-log-levels.js @@ -0,0 +1,64 @@ +/** + * Log Level Verification Test + * + * This script tests that all log levels work correctly and filtering behaves as expected. + * + * Expected behavior: + * - debug level: shows debug, info, warn, error + * - info level: shows info, warn, error (no debug) + * - warn level: shows warn, error (no debug, info) + * - error level: shows only error + */ + +import { debug, info, warn, error } from './src/logger.js'; + +console.log('\n=== Log Level Verification Test ===\n'); +console.log(`Current LOG_LEVEL: ${process.env.LOG_LEVEL || 'info (default)'}`); +console.log('Testing all log levels...\n'); + +// Test all log levels with different types of messages +debug('DEBUG: This is a debug message', { test: 'debug-data', value: 1 }); +info('INFO: This is an info message', { test: 'info-data', value: 2 }); +warn('WARN: This is a warning message', { test: 'warn-data', value: 3 }); +error('ERROR: This is an error message', { test: 'error-data', value: 4 }); + +// Test with nested metadata +debug('DEBUG: Testing nested metadata', { + user: 'testUser', + context: { + channel: 'test-channel', + guild: 'test-guild' + } +}); + +info('INFO: Testing nested metadata', { + user: 'testUser', + context: { + channel: 'test-channel', + guild: 'test-guild' + } +}); + +warn('WARN: Testing nested metadata', { + user: 'testUser', + context: { + channel: 'test-channel', + guild: 'test-guild' + } +}); + +error('ERROR: Testing nested metadata', { + user: 'testUser', + context: { + channel: 'test-channel', + guild: 'test-guild' + } +}); + +console.log('\n=== Test Complete ==='); +console.log('\nExpected output based on LOG_LEVEL:'); +console.log('- debug: All 8 log messages (4 simple + 4 with nested metadata)'); +console.log('- info: 6 messages (info, warn, error ร— 2)'); +console.log('- warn: 4 messages (warn, error ร— 2)'); +console.log('- error: 2 messages (error ร— 2)'); +console.log('\n'); diff --git a/verify-contextual-logging.js b/verify-contextual-logging.js new file mode 100644 index 00000000..05c5cf66 --- /dev/null +++ b/verify-contextual-logging.js @@ -0,0 +1,321 @@ +/** + * Verification Script: Contextual Logging for Discord Events + * + * This script verifies that Discord events include proper context + * in their log output (channel, user, guild) and that the format + * is consistent and parseable. + */ + +import { readFileSync, existsSync, readdirSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const logsDir = join(__dirname, 'logs'); + +console.log('='.repeat(70)); +console.log('CONTEXTUAL LOGGING VERIFICATION'); +console.log('='.repeat(70)); +console.log(); + +// Expected context fields for each event type +const expectedContextFields = { + 'Welcome message': ['user', 'userId', 'guild', 'guildId', 'channel', 'channelId'], + 'Spam detected': ['user', 'userId', 'channel', 'channelId', 'guild', 'guildId', 'contentPreview'], + 'AI chat': ['channelId', 'username'] // AI chat context is minimal but present in error logs +}; + +let passed = 0; +let failed = 0; +let warnings = 0; + +function pass(message) { + console.log(`โœ… PASS: ${message}`); + passed++; +} + +function fail(message) { + console.log(`โŒ FAIL: ${message}`); + failed++; +} + +function warn(message) { + console.log(`โš ๏ธ WARN: ${message}`); + warnings++; +} + +// 1. Check if logs directory exists +console.log('1. Checking logs directory...'); +if (!existsSync(logsDir)) { + fail('Logs directory does not exist. Run the bot with fileOutput enabled first.'); + console.log('\nSKIPPING remaining tests - no log files to analyze\n'); + process.exit(1); +} else { + pass('Logs directory exists'); +} +console.log(); + +// 2. Find and read log files +console.log('2. Reading log files...'); +const logFiles = readdirSync(logsDir).filter(f => f.startsWith('combined-') && f.endsWith('.log')); + +if (logFiles.length === 0) { + fail('No combined log files found. Run the bot with fileOutput enabled first.'); + console.log('\nSKIPPING remaining tests - no log files to analyze\n'); + process.exit(1); +} + +console.log(` Found ${logFiles.length} log file(s):`); +logFiles.forEach(f => console.log(` - ${f}`)); +pass('Log files found'); +console.log(); + +// 3. Parse and analyze log entries +console.log('3. Analyzing log entries for contextual data...'); +const allLogEntries = []; +let parseErrors = 0; + +for (const file of logFiles) { + const content = readFileSync(join(logsDir, file), 'utf-8'); + const lines = content.trim().split('\n').filter(l => l.trim()); + + for (const line of lines) { + try { + const entry = JSON.parse(line); + allLogEntries.push(entry); + } catch (err) { + parseErrors++; + fail(`Failed to parse log line: ${line.slice(0, 50)}...`); + } + } +} + +if (parseErrors === 0) { + pass(`All ${allLogEntries.length} log entries are valid JSON`); +} else { + fail(`${parseErrors} log entries failed to parse`); +} +console.log(); + +// 4. Verify timestamp presence +console.log('4. Verifying timestamps...'); +const entriesWithTimestamp = allLogEntries.filter(e => e.timestamp); +if (entriesWithTimestamp.length === allLogEntries.length) { + pass('All log entries include timestamps'); +} else { + fail(`${allLogEntries.length - entriesWithTimestamp.length} entries missing timestamps`); +} +console.log(); + +// 5. Check for welcome message context +console.log('5. Checking Welcome Message context...'); +const welcomeLogs = allLogEntries.filter(e => + e.message && e.message.includes('Welcome message') +); + +if (welcomeLogs.length === 0) { + warn('No welcome message logs found. Trigger a user join to test this.'); +} else { + console.log(` Found ${welcomeLogs.length} welcome message log(s)`); + + let contextComplete = true; + for (const log of welcomeLogs) { + const missing = expectedContextFields['Welcome message'].filter( + field => !log[field] && log[field] !== 0 + ); + + if (missing.length > 0) { + fail(`Welcome message log missing context: ${missing.join(', ')}`); + contextComplete = false; + } + } + + if (contextComplete) { + pass('Welcome message logs include all expected context fields'); + console.log(' Context fields:', expectedContextFields['Welcome message'].join(', ')); + } +} +console.log(); + +// 6. Check for spam detection context +console.log('6. Checking Spam Detection context...'); +const spamLogs = allLogEntries.filter(e => + e.message && e.message.includes('Spam detected') +); + +if (spamLogs.length === 0) { + warn('No spam detection logs found. Post a spam message to test this.'); +} else { + console.log(` Found ${spamLogs.length} spam detection log(s)`); + + let contextComplete = true; + for (const log of spamLogs) { + const missing = expectedContextFields['Spam detected'].filter( + field => !log[field] && log[field] !== 0 + ); + + if (missing.length > 0) { + fail(`Spam detection log missing context: ${missing.join(', ')}`); + contextComplete = false; + } + } + + if (contextComplete) { + pass('Spam detection logs include all expected context fields'); + console.log(' Context fields:', expectedContextFields['Spam detected'].join(', ')); + } +} +console.log(); + +// 7. Check for AI chat context (in error logs) +console.log('7. Checking AI Chat context...'); +const aiLogs = allLogEntries.filter(e => + e.message && (e.message.includes('OpenClaw API') || e.message.includes('AI')) +); + +if (aiLogs.length === 0) { + warn('No AI chat logs found. Mention the bot to trigger AI chat.'); +} else { + console.log(` Found ${aiLogs.length} AI-related log(s)`); + + // AI chat logs should include channelId and username in error cases + const aiErrorLogs = aiLogs.filter(e => e.level === 'error'); + if (aiErrorLogs.length > 0) { + let contextComplete = true; + for (const log of aiErrorLogs) { + if (!log.channelId || !log.username) { + fail('AI error log missing context (channelId or username)'); + contextComplete = false; + } + } + + if (contextComplete) { + pass('AI error logs include channelId and username context'); + } + } else { + warn('No AI error logs found (this is good - no errors occurred)'); + } +} +console.log(); + +// 8. Verify log format consistency +console.log('8. Verifying log format consistency...'); +const requiredFields = ['level', 'message', 'timestamp']; +let formatConsistent = true; + +for (const entry of allLogEntries) { + const missing = requiredFields.filter(field => !entry[field]); + if (missing.length > 0) { + fail(`Log entry missing required fields: ${missing.join(', ')}`); + formatConsistent = false; + break; + } +} + +if (formatConsistent) { + pass('All log entries have consistent format (level, message, timestamp)'); +} +console.log(); + +// 9. Check log levels +console.log('9. Verifying log levels...'); +const levels = new Set(allLogEntries.map(e => e.level)); +console.log(` Found log levels: ${Array.from(levels).join(', ')}`); + +const validLevels = ['debug', 'info', 'warn', 'error']; +const invalidLevels = Array.from(levels).filter(l => !validLevels.includes(l)); + +if (invalidLevels.length === 0) { + pass('All log entries use valid log levels'); +} else { + fail(`Invalid log levels found: ${invalidLevels.join(', ')}`); +} +console.log(); + +// 10. Verify Discord event context patterns +console.log('10. Verifying Discord event context patterns...'); + +// Events that should include guild context +const guildEvents = allLogEntries.filter(e => + e.message && ( + e.message.includes('Welcome message') || + e.message.includes('Spam detected') + ) +); + +if (guildEvents.length > 0) { + const withGuildContext = guildEvents.filter(e => e.guild && e.guildId); + if (withGuildContext.length === guildEvents.length) { + pass('All guild events include guild and guildId context'); + } else { + fail(`${guildEvents.length - withGuildContext.length} guild events missing guild context`); + } +} + +// Events that should include channel context +const channelEvents = allLogEntries.filter(e => + e.message && ( + e.message.includes('Welcome message') || + e.message.includes('Spam detected') || + e.message.includes('enabled') && e.channelId + ) +); + +if (channelEvents.length > 0) { + const withChannelContext = channelEvents.filter(e => e.channelId); + if (withChannelContext.length === channelEvents.length) { + pass('All channel events include channelId context'); + } else { + fail(`${channelEvents.length - withChannelContext.length} channel events missing channelId`); + } +} + +// Events that should include user context +const userEvents = allLogEntries.filter(e => + e.message && ( + e.message.includes('Welcome message') || + e.message.includes('Spam detected') + ) +); + +if (userEvents.length > 0) { + const withUserContext = userEvents.filter(e => e.user && e.userId); + if (withUserContext.length === userEvents.length) { + pass('All user events include user and userId context'); + } else { + fail(`${userEvents.length - withUserContext.length} user events missing user context`); + } +} +console.log(); + +// Summary +console.log('='.repeat(70)); +console.log('VERIFICATION SUMMARY'); +console.log('='.repeat(70)); +console.log(`Total log entries analyzed: ${allLogEntries.length}`); +console.log(`โœ… Passed: ${passed}`); +console.log(`โŒ Failed: ${failed}`); +console.log(`โš ๏ธ Warnings: ${warnings}`); +console.log(); + +if (failed === 0 && warnings <= 3) { + console.log('โœ… VERIFICATION PASSED - Contextual logging is working correctly!'); + console.log(); + console.log('Notes:'); + console.log('- All log entries are properly formatted with timestamps'); + console.log('- Discord events include appropriate context (channel, user, guild)'); + console.log('- Log format is consistent and parseable as JSON'); + console.log('- Warnings are expected if not all event types were triggered'); + process.exit(0); +} else if (failed === 0) { + console.log('โš ๏ธ VERIFICATION PASSED WITH WARNINGS'); + console.log(); + console.log('To fully verify, trigger the following events:'); + if (welcomeLogs.length === 0) console.log('- User join (welcome message)'); + if (spamLogs.length === 0) console.log('- Spam message (spam detection)'); + if (aiLogs.length === 0) console.log('- Mention bot (AI chat)'); + process.exit(0); +} else { + console.log('โŒ VERIFICATION FAILED - Issues found with contextual logging'); + process.exit(1); +} diff --git a/verify-file-output.js b/verify-file-output.js new file mode 100644 index 00000000..31b615de --- /dev/null +++ b/verify-file-output.js @@ -0,0 +1,180 @@ +/** + * Verification script for file output and rotation configuration + * Tests that logger creates log files with proper JSON format + */ + +import { debug, info, warn, error } from './src/logger.js'; +import { existsSync, readFileSync, readdirSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const logsDir = join(__dirname, 'logs'); + +console.log('\n๐Ÿงช Starting file output verification...\n'); + +// Generate test logs at different levels +info('File output verification started'); +debug('This is a debug message for testing', { testId: 1, service: 'verification' }); +info('This is an info message for testing', { testId: 2, channel: 'test-channel' }); +warn('This is a warning message for testing', { testId: 3, user: 'test-user' }); +error('This is an error message for testing', { testId: 4, code: 'TEST_ERROR' }); + +// Log with sensitive data to verify redaction +info('Testing sensitive data redaction', { + DISCORD_TOKEN: 'this-should-be-redacted', + username: 'safe-to-log', + password: 'this-should-also-be-redacted' +}); + +console.log('โœ… Test logs generated\n'); + +// Wait a moment for file writes to complete +setTimeout(() => { + console.log('๐Ÿ” Verifying log files...\n'); + + // Check 1: Logs directory exists + if (!existsSync(logsDir)) { + console.error('โŒ FAIL: logs directory was not created'); + process.exit(1); + } + console.log('โœ… PASS: logs directory exists'); + + // Check 2: List files in logs directory + const logFiles = readdirSync(logsDir); + console.log(`\n๐Ÿ“ Files in logs directory: ${logFiles.join(', ')}`); + + // Check 3: Combined log file exists + const combinedLog = logFiles.find(f => f.startsWith('combined-')); + if (!combinedLog) { + console.error('โŒ FAIL: combined log file not found'); + process.exit(1); + } + console.log(`โœ… PASS: combined log file exists (${combinedLog})`); + + // Check 4: Error log file exists + const errorLog = logFiles.find(f => f.startsWith('error-')); + if (!errorLog) { + console.error('โŒ FAIL: error log file not found'); + process.exit(1); + } + console.log(`โœ… PASS: error log file exists (${errorLog})`); + + // Check 5: Combined log contains valid JSON + console.log('\n๐Ÿ“„ Verifying combined log format...'); + const combinedPath = join(logsDir, combinedLog); + const combinedContent = readFileSync(combinedPath, 'utf-8'); + const combinedLines = combinedContent.trim().split('\n').filter(line => line.trim()); + + console.log(`\nCombined log entries: ${combinedLines.length}`); + + let validJsonCount = 0; + let hasInfoLevel = false; + let hasWarnLevel = false; + let hasErrorLevel = false; + let sensitiveDataRedacted = false; + + for (const line of combinedLines) { + try { + const entry = JSON.parse(line); + validJsonCount++; + + // Verify required fields + if (!entry.timestamp || !entry.level || !entry.message) { + console.error(`โŒ FAIL: Log entry missing required fields: ${line}`); + process.exit(1); + } + + // Track log levels + if (entry.level === 'info') hasInfoLevel = true; + if (entry.level === 'warn') hasWarnLevel = true; + if (entry.level === 'error') hasErrorLevel = true; + + // Check for sensitive data redaction + if (entry.message.includes('sensitive data')) { + if (entry.DISCORD_TOKEN === '[REDACTED]' && entry.password === '[REDACTED]') { + sensitiveDataRedacted = true; + } else { + console.error('โŒ FAIL: Sensitive data was not redacted properly'); + console.error('Entry:', JSON.stringify(entry, null, 2)); + process.exit(1); + } + } + + // Display sample entry + if (validJsonCount === 1) { + console.log('\nSample log entry:'); + console.log(JSON.stringify(entry, null, 2)); + } + } catch (err) { + console.error(`โŒ FAIL: Invalid JSON in combined log: ${line}`); + console.error('Parse error:', err.message); + process.exit(1); + } + } + + console.log(`\nโœ… PASS: All ${validJsonCount} entries are valid JSON`); + console.log(`โœ… PASS: Timestamps present in all entries`); + console.log(`โœ… PASS: Log levels present - info: ${hasInfoLevel}, warn: ${hasWarnLevel}, error: ${hasErrorLevel}`); + console.log(`โœ… PASS: Sensitive data redacted: ${sensitiveDataRedacted}`); + + // Check 6: Error log contains only error-level entries + console.log('\n๐Ÿ“„ Verifying error log format...'); + const errorPath = join(logsDir, errorLog); + const errorContent = readFileSync(errorPath, 'utf-8'); + const errorLines = errorContent.trim().split('\n').filter(line => line.trim()); + + console.log(`\nError log entries: ${errorLines.length}`); + + for (const line of errorLines) { + try { + const entry = JSON.parse(line); + + if (entry.level !== 'error') { + console.error(`โŒ FAIL: Non-error level found in error log: ${entry.level}`); + process.exit(1); + } + + // Display sample error entry + if (errorLines.indexOf(line) === 0) { + console.log('\nSample error entry:'); + console.log(JSON.stringify(entry, null, 2)); + } + } catch (err) { + console.error(`โŒ FAIL: Invalid JSON in error log: ${line}`); + console.error('Parse error:', err.message); + process.exit(1); + } + } + + console.log(`\nโœ… PASS: All error log entries are error-level only`); + console.log(`โœ… PASS: Error log format is valid JSON`); + + // Check 7: Verify rotation configuration + console.log('\n๐Ÿ”„ Verifying rotation configuration...'); + console.log('Expected: Daily rotation with YYYY-MM-DD pattern'); + console.log('Expected: Max size 20MB, max files 14 days'); + + const datePattern = /\d{4}-\d{2}-\d{2}/; + if (datePattern.test(combinedLog) && datePattern.test(errorLog)) { + console.log('โœ… PASS: Log files use correct date pattern (YYYY-MM-DD)'); + } else { + console.error('โŒ FAIL: Log files do not use expected date pattern'); + process.exit(1); + } + + console.log('\nโœ… ALL CHECKS PASSED!'); + console.log('\n๐Ÿ“‹ Summary:'); + console.log(' - Logs directory created: โœ…'); + console.log(' - Combined log file created: โœ…'); + console.log(' - Error log file created: โœ…'); + console.log(' - JSON format valid: โœ…'); + console.log(' - Timestamps present: โœ…'); + console.log(' - Log levels working: โœ…'); + console.log(' - Error log filtering: โœ…'); + console.log(' - Sensitive data redaction: โœ…'); + console.log(' - Date-based rotation pattern: โœ…'); + console.log('\nโœจ File output and rotation verification complete!\n'); + + process.exit(0); +}, 1000); // Wait 1 second for file writes diff --git a/verify-sensitive-data-redaction.js b/verify-sensitive-data-redaction.js new file mode 100644 index 00000000..489c8eb1 --- /dev/null +++ b/verify-sensitive-data-redaction.js @@ -0,0 +1,179 @@ +/** + * Verification Script: Sensitive Data Redaction + * + * Comprehensive test to ensure all sensitive data is properly redacted + * in both console and file output. + */ + +import { info, warn, error } from './src/logger.js'; +import { readFileSync, existsSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const logsDir = join(__dirname, 'logs'); + +console.log('='.repeat(70)); +console.log('SENSITIVE DATA REDACTION VERIFICATION'); +console.log('='.repeat(70)); +console.log(); + +// Test 1: Direct sensitive field logging +console.log('Test 1: Direct sensitive fields...'); +info('Testing direct sensitive fields', { + DISCORD_TOKEN: 'MTk4OTg2MjQ3ODk4NjI0MDAwMA.GXxxXX.xxxxxxxxxxxxxxxxxxxxxxxx', + OPENCLAW_TOKEN: 'sk-test-1234567890abcdefghijklmnop', + username: 'test-user' +}); +console.log('โœ“ Logged with DISCORD_TOKEN and OPENCLAW_TOKEN\n'); + +// Test 2: Various sensitive field names (case variations) +console.log('Test 2: Case-insensitive sensitive fields...'); +warn('Testing case variations', { + discord_token: 'should-be-redacted', + Token: 'should-be-redacted', + PASSWORD: 'should-be-redacted', + apikey: 'should-be-redacted', + Authorization: 'Bearer should-be-redacted' +}); +console.log('โœ“ Logged with various case variations\n'); + +// Test 3: Nested objects +console.log('Test 3: Nested objects with sensitive data...'); +info('Testing nested sensitive data', { + config: { + database: { + host: 'localhost', + password: 'db-password-123' + }, + api: { + endpoint: 'https://api.example.com', + DISCORD_TOKEN: 'nested-token-value', + apiKey: 'nested-api-key' + } + } +}); +console.log('โœ“ Logged with nested sensitive data\n'); + +// Test 4: Arrays with sensitive data +console.log('Test 4: Arrays containing sensitive data...'); +info('Testing arrays with sensitive data', { + tokens: [ + { name: 'discord', token: 'token-1' }, + { name: 'openclaw', OPENCLAW_TOKEN: 'token-2' } + ] +}); +console.log('โœ“ Logged with arrays containing sensitive data\n'); + +// Test 5: Mixed safe and sensitive data +console.log('Test 5: Mixed safe and sensitive data...'); +error('Testing mixed data', { + user: 'john_doe', + channel: 'general', + guild: 'My Server', + DISCORD_TOKEN: 'should-be-redacted', + timestamp: new Date().toISOString(), + password: 'user-password', + metadata: { + version: '1.0.0', + authorization: 'Bearer secret-token' + } +}); +console.log('โœ“ Logged with mixed safe and sensitive data\n'); + +// Wait a moment for file writes to complete +await new Promise(resolve => setTimeout(resolve, 1000)); + +console.log('='.repeat(70)); +console.log('VERIFYING LOG FILES'); +console.log('='.repeat(70)); +console.log(); + +if (!existsSync(logsDir)) { + console.log('โš ๏ธ No logs directory found. File output may be disabled.'); + console.log(' This is OK if fileOutput is set to false in config.json\n'); +} else { + // Find the most recent combined log file + const fs = await import('fs'); + const files = fs.readdirSync(logsDir) + .filter(f => f.startsWith('combined-') && f.endsWith('.log')) + .sort() + .reverse(); + + if (files.length === 0) { + console.log('โš ๏ธ No combined log files found\n'); + } else { + const logFile = join(logsDir, files[0]); + console.log(`Reading log file: ${files[0]}\n`); + + const logContent = readFileSync(logFile, 'utf-8'); + const lines = logContent.trim().split('\n'); + + // Check for any exposed tokens + const sensitivePatterns = [ + /MTk4OTg2MjQ3ODk4NjI0MDAwMA/, // Example Discord token + /sk-test-\d+/, // Example OpenClaw token + /"password":"(?!\[REDACTED\])/, // Password not redacted + /"token":"(?!\[REDACTED\])/, // Token not redacted + /"apiKey":"(?!\[REDACTED\])/, // API key not redacted + /Bearer secret-token/, // Authorization header + /db-password-123/, // Database password + /nested-token-value/, // Nested token + /nested-api-key/, // Nested API key + /token-1/, // Array token + /token-2/, // Array OPENCLAW_TOKEN + /user-password/ // User password + ]; + + let exposedCount = 0; + const exposedPatterns = []; + + for (const pattern of sensitivePatterns) { + if (pattern.test(logContent)) { + exposedCount++; + exposedPatterns.push(pattern.toString()); + } + } + + if (exposedCount > 0) { + console.log('โŒ FAILED: Found exposed sensitive data!'); + console.log(` ${exposedCount} pattern(s) were not properly redacted:`); + exposedPatterns.forEach(p => console.log(` - ${p}`)); + console.log(); + process.exit(1); + } + + // Count redacted occurrences + const redactedCount = (logContent.match(/\[REDACTED\]/g) || []).length; + console.log(`โœ“ All sensitive data properly redacted`); + console.log(` Found ${redactedCount} [REDACTED] markers in log file\n`); + + // Verify specific fields are redacted + const checks = [ + { field: 'DISCORD_TOKEN', expected: '[REDACTED]' }, + { field: 'OPENCLAW_TOKEN', expected: '[REDACTED]' }, + { field: 'password', expected: '[REDACTED]' }, + { field: 'token', expected: '[REDACTED]' }, + { field: 'apiKey', expected: '[REDACTED]' }, + { field: 'authorization', expected: '[REDACTED]' } + ]; + + console.log('Field-specific verification:'); + for (const check of checks) { + const regex = new RegExp(`"${check.field}":"\\[REDACTED\\]"`, 'i'); + if (regex.test(logContent)) { + console.log(` โœ“ ${check.field}: properly redacted`); + } + } + } +} + +console.log(); +console.log('='.repeat(70)); +console.log('VERIFICATION COMPLETE'); +console.log('='.repeat(70)); +console.log('โœ“ All sensitive data is properly redacted'); +console.log('โœ“ No tokens or credentials exposed in logs'); +console.log('โœ“ Redaction works for nested objects and arrays'); +console.log('โœ“ Case-insensitive field matching works correctly'); +console.log();