diff --git a/src/bot/bot.ts b/src/bot/bot.ts index 50da912..bb91951 100644 --- a/src/bot/bot.ts +++ b/src/bot/bot.ts @@ -18,6 +18,7 @@ import { buildCodesPage } from './commands/codes'; import * as autoredeemCommand from './commands/autoredeem'; import * as helpCommand from './commands/help'; import * as inventoryCommand from './commands/inventory'; +import * as logsCommand from './commands/logs'; import * as makepublicCommand from './commands/makepublic'; import * as notificationsCommand from './commands/notifications'; import * as openCommand from './commands/open'; @@ -61,6 +62,7 @@ const commands = [ deleteaccountCommand, helpCommand, inventoryCommand, + logsCommand, makepublicCommand, notificationsCommand, openCommand, diff --git a/src/bot/commands/help.ts b/src/bot/commands/help.ts index ed79479..6526187 100644 --- a/src/bot/commands/help.ts +++ b/src/bot/commands/help.ts @@ -76,6 +76,11 @@ export async function execute(interaction: ChatInputCommandInteraction) { value: '`/backfill [channel:]`\nRecover missed codes from message history (admin only).', inline: false, }, + { + name: '📋 Logs', + value: '`/logs [lines:]`\nShow the last N lines of the bot log (admin only, default 20, max 100).', + inline: false, + }, { name: '🗑️ Delete Account', value: diff --git a/src/bot/commands/logs.test.ts b/src/bot/commands/logs.test.ts new file mode 100644 index 0000000..18a4e1e --- /dev/null +++ b/src/bot/commands/logs.test.ts @@ -0,0 +1,288 @@ +import { describe, test, expect, beforeEach, afterEach, spyOn } from 'bun:test'; +import { MessageFlags } from 'discord.js'; +import fs from 'fs'; +import { execute } from './logs'; + +// --------------------------------------------------------------------------- +// Interaction mock helpers +// --------------------------------------------------------------------------- + +function makeInteraction(opts: { hasPermission?: boolean; lines?: number | null } = {}) { + const { hasPermission = true, lines = null } = opts; + + const editReplySpy = spyOn({ editReply: async (_: unknown) => {} }, 'editReply'); + const replySpy = spyOn({ reply: async (_: unknown) => {} }, 'reply'); + + const interaction = { + user: { id: 'admin-1', tag: 'admin#0001' }, + deferred: false, + replied: false, + memberPermissions: hasPermission ? { has: () => true } : null, + deferReply: async () => { + (interaction as any).deferred = true; + }, + editReply: editReplySpy, + reply: replySpy, + options: { + getInteger: (name: string) => (name === 'lines' ? lines : null), + }, + } as any; + + return { interaction, editReplySpy, replySpy }; +} + +// Sample log lines used across multiple tests +const SAMPLE_LINES = Array.from( + { length: 50 }, + (_, i) => `2026-05-24 12:00:${String(i).padStart(2, '0')} [INFO]: Log message ${i}` +); +const SAMPLE_CONTENT = SAMPLE_LINES.join('\n'); + +// --------------------------------------------------------------------------- +// Permission denied +// --------------------------------------------------------------------------- + +describe('/logs – permission denied', () => { + test('replies ephemerally with "Permission Denied" when user lacks ManageMessages', async () => { + const { interaction, replySpy } = makeInteraction({ hasPermission: false }); + + await execute(interaction); + + expect(replySpy).toHaveBeenCalledTimes(1); + const reply = replySpy.mock.calls[0]![0] as any; + expect(reply.embeds[0].data.title).toContain('Permission Denied'); + expect(reply.flags).toBe(MessageFlags.Ephemeral); + expect(interaction.deferred).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Log file missing +// --------------------------------------------------------------------------- + +describe('/logs – log file missing', () => { + let existsSpy: ReturnType; + + beforeEach(() => { + existsSpy = spyOn(fs, 'existsSync').mockReturnValue(false); + }); + + afterEach(() => { + existsSpy.mockRestore(); + }); + + test('defers reply and shows "No log entries found" when combined.log does not exist', async () => { + const { interaction, editReplySpy } = makeInteraction(); + + await execute(interaction); + + expect(interaction.deferred).toBe(true); + expect(editReplySpy).toHaveBeenCalledTimes(1); + const embed = (editReplySpy.mock.calls[0]![0] as any).embeds[0].data; + expect(embed.description).toContain('No log entries found'); + }); +}); + +// --------------------------------------------------------------------------- +// Reading log lines +// --------------------------------------------------------------------------- + +describe('/logs – reading log lines', () => { + let existsSpy: ReturnType; + let readSpy: ReturnType; + + beforeEach(() => { + existsSpy = spyOn(fs, 'existsSync').mockReturnValue(true); + readSpy = spyOn(fs, 'readFileSync').mockReturnValue(SAMPLE_CONTENT); + }); + + afterEach(() => { + existsSpy.mockRestore(); + readSpy.mockRestore(); + }); + + test('defers reply', async () => { + const { interaction } = makeInteraction({ lines: 5 }); + + await execute(interaction); + + expect(interaction.deferred).toBe(true); + }); + + test('defaults to 20 lines when no lines param is provided', async () => { + const { interaction, editReplySpy } = makeInteraction({ lines: null }); + + await execute(interaction); + + const embed = (editReplySpy.mock.calls[0]![0] as any).embeds[0].data; + expect(embed.title).toContain('last 20 lines'); + const lastTwenty = SAMPLE_LINES.slice(-20); + expect(embed.description).toContain(lastTwenty[0]); + expect(embed.description).toContain(lastTwenty[19]); + }); + + test('respects custom lines parameter', async () => { + const { interaction, editReplySpy } = makeInteraction({ lines: 5 }); + + await execute(interaction); + + const embed = (editReplySpy.mock.calls[0]![0] as any).embeds[0].data; + expect(embed.title).toContain('last 5 lines'); + const lastFive = SAMPLE_LINES.slice(-5); + for (const line of lastFive) { + expect(embed.description).toContain(line); + } + // Lines outside the requested range must not appear + expect(embed.description).not.toContain(SAMPLE_LINES[0]); + }); + + test('wraps output in a code block', async () => { + const { interaction, editReplySpy } = makeInteraction({ lines: 3 }); + + await execute(interaction); + + const embed = (editReplySpy.mock.calls[0]![0] as any).embeds[0].data; + expect(embed.description).toMatch(/^```\n/); + expect(embed.description).toMatch(/\n```$/); + }); + + test('title includes the actual number of lines returned', async () => { + const { interaction, editReplySpy } = makeInteraction({ lines: 10 }); + + await execute(interaction); + + const embed = (editReplySpy.mock.calls[0]![0] as any).embeds[0].data; + expect(embed.title).toContain('10 lines'); + }); + + test('redacts sensitive credential fields from log lines', async () => { + const sensitiveContent = [ + '2026-05-24 12:00:01 [INFO]: [CMD] admin#0001 used /setup: user_id=123456 user_hash=supersecret', + '2026-05-24 12:00:02 [INFO]: [CMD] admin#0001 used /redeem: code=ABC-123', + '2026-05-24 12:00:03 [INFO]: token=mytoken hash=myhash normal log line', + ].join('\n'); + + readSpy.mockReturnValue(sensitiveContent); + const { interaction, editReplySpy } = makeInteraction({ lines: 10 }); + + await execute(interaction); + + const desc = (editReplySpy.mock.calls[0]![0] as any).embeds[0].data.description; + expect(desc).not.toContain('supersecret'); + expect(desc).not.toContain('mytoken'); + expect(desc).not.toContain('myhash'); + expect(desc).toContain('user_hash=[REDACTED]'); + expect(desc).toContain('user_id=[REDACTED]'); + expect(desc).toContain('token=[REDACTED]'); + expect(desc).toContain('hash=[REDACTED]'); + // Non-sensitive fields must not be redacted + expect(desc).toContain('code=ABC-123'); + }); + + test('preserves blank lines within the log (only trailing newline is stripped)', async () => { + const contentWithBlanks = 'line-a\n\nline-b\n\nline-c\n'; + readSpy.mockReturnValue(contentWithBlanks); + const { interaction, editReplySpy } = makeInteraction({ lines: 10 }); + + await execute(interaction); + + const desc = (editReplySpy.mock.calls[0]![0] as any).embeds[0].data.description; + // All five lines (including the two blank ones) must be present + expect(desc).toContain('line-a'); + expect(desc).toContain('line-b'); + expect(desc).toContain('line-c'); + // The title should report 5 lines (line-a, blank, line-b, blank, line-c) + expect((editReplySpy.mock.calls[0]![0] as any).embeds[0].data.title).toContain('5 lines'); + }); + + test('escapes triple-backticks in log content to prevent breaking the code block', async () => { + const contentWithBackticks = '2026-05-24 12:00:01 [INFO]: line with ```backticks``` inside'; + readSpy.mockReturnValue(contentWithBackticks); + const { interaction, editReplySpy } = makeInteraction({ lines: 5 }); + + await execute(interaction); + + const desc = (editReplySpy.mock.calls[0]![0] as any).embeds[0].data.description; + // The injected triple-backticks must be replaced + expect(desc).toContain("'''backticks'''"); + // Overall structure must still be a valid code block + expect(desc).toMatch(/^```\n/); + expect(desc).toMatch(/\n```$/); + }); +}); + +// --------------------------------------------------------------------------- +// Truncation +// --------------------------------------------------------------------------- + +describe('/logs – truncation', () => { + let existsSpy: ReturnType; + let readSpy: ReturnType; + + // Build a log whose content far exceeds the 4096-char embed limit + const bigLine = 'X'.repeat(80); + const bigLines = Array.from({ length: 100 }, (_, i) => `${bigLine} line-${i}`); + const bigContent = bigLines.join('\n'); + + beforeEach(() => { + existsSpy = spyOn(fs, 'existsSync').mockReturnValue(true); + readSpy = spyOn(fs, 'readFileSync').mockReturnValue(bigContent); + }); + + afterEach(() => { + existsSpy.mockRestore(); + readSpy.mockRestore(); + }); + + test('embed description stays within the 4096-char limit', async () => { + const { interaction, editReplySpy } = makeInteraction({ lines: 100 }); + + await execute(interaction); + + const embed = (editReplySpy.mock.calls[0]![0] as any).embeds[0].data; + expect(embed.description.length).toBeLessThanOrEqual(4096); + }); + + test('title shows displayed vs requested line count when truncated', async () => { + const { interaction, editReplySpy } = makeInteraction({ lines: 100 }); + + await execute(interaction); + + const embed = (editReplySpy.mock.calls[0]![0] as any).embeds[0].data; + expect(embed.title).toContain('truncated'); + // e.g. "showing 45 of 100 lines — truncated" + expect(embed.title).toMatch(/showing \d+ of 100 lines/); + }); +}); + +// --------------------------------------------------------------------------- +// Error handling +// --------------------------------------------------------------------------- + +describe('/logs – error handling', () => { + let existsSpy: ReturnType; + let readSpy: ReturnType; + + beforeEach(() => { + existsSpy = spyOn(fs, 'existsSync').mockReturnValue(true); + readSpy = spyOn(fs, 'readFileSync').mockImplementation(() => { + throw new Error('disk read error'); + }); + }); + + afterEach(() => { + existsSpy.mockRestore(); + readSpy.mockRestore(); + }); + + test('replies with error embed when log file cannot be read', async () => { + const { interaction, editReplySpy } = makeInteraction(); + + await execute(interaction); + + expect(editReplySpy).toHaveBeenCalledTimes(1); + const embed = (editReplySpy.mock.calls[0]![0] as any).embeds[0].data; + expect(embed.title).toContain('Error'); + expect(embed.description).toContain('Failed to read log file'); + }); +}); diff --git a/src/bot/commands/logs.ts b/src/bot/commands/logs.ts new file mode 100644 index 0000000..64dc1bc --- /dev/null +++ b/src/bot/commands/logs.ts @@ -0,0 +1,111 @@ +import { + SlashCommandBuilder, + ChatInputCommandInteraction, + EmbedBuilder, + MessageFlags, + PermissionFlagsBits, +} from 'discord.js'; +import fs from 'fs'; +import path from 'path'; +import logger from '../utils/logger'; + +const LOG_FILE = path.join(process.cwd(), 'logs', 'combined.log'); +const MAX_LINES = 100; +const EMBED_DESCRIPTION_LIMIT = 4096; + +// Patterns that must never be shown in Discord (credentials logged by bot.ts command handler) +const SENSITIVE_PATTERN = /\b(user_hash|user_id|hash|token)=\S+/gi; + +function redactSensitive(line: string): string { + return line.replace(SENSITIVE_PATTERN, (_, key: string) => `${key}=[REDACTED]`); +} + +function readLastLines(filePath: string, n: number): string[] { + if (!fs.existsSync(filePath)) return []; + + const content = fs.readFileSync(filePath, 'utf-8'); + // trimEnd removes the trailing newline that log files end with; blank lines + // within the log are preserved so the tail window is not shifted. + const lines = content.trimEnd().split('\n'); + return lines.slice(-n).map(redactSensitive); +} + +export const data = new SlashCommandBuilder() + .setName('logs') + .setDescription('Show the last N lines of the bot log (admin only)') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages) + .addIntegerOption((option) => + option + .setName('lines') + .setDescription(`Number of log lines to show (1-${MAX_LINES}, default 20)`) + .setRequired(false) + .setMinValue(1) + .setMaxValue(MAX_LINES) + ); + +export async function execute(interaction: ChatInputCommandInteraction) { + try { + if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageMessages)) { + const embed = new EmbedBuilder() + .setColor(0xff0000) + .setTitle('❌ Permission Denied') + .setDescription('You need the "Manage Messages" permission to view logs.'); + await interaction.reply({ embeds: [embed], flags: MessageFlags.Ephemeral }); + return; + } + + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + const n = interaction.options.getInteger('lines') ?? 20; + logger.debug(`[LOGS CMD] ${interaction.user.tag} requested last ${n} log lines`); + + const lines = readLastLines(LOG_FILE, n); + + if (lines.length === 0) { + const embed = new EmbedBuilder() + .setColor(0xffaa00) + .setTitle('📋 Bot Logs') + .setDescription('No log entries found.'); + await interaction.editReply({ embeds: [embed] }); + return; + } + + // Escape triple-backticks in log content to prevent breaking the code block + const safeLines = lines.map((l) => l.replaceAll('```', "'''")); + + // Build the code block and truncate from the start if it exceeds Discord's limit + const codeBlockOverhead = '```\n'.length + '\n```'.length; + const maxContent = EMBED_DESCRIPTION_LIMIT - codeBlockOverhead; + + let content = safeLines.join('\n'); + let truncated = false; + if (content.length > maxContent) { + content = content.slice(content.length - maxContent); + // Trim to a clean line boundary + const firstNewline = content.indexOf('\n'); + if (firstNewline !== -1) content = content.slice(firstNewline + 1); + truncated = true; + } + + const displayedLineCount = content.split('\n').length; + const description = `\`\`\`\n${content}\n\`\`\``; + const title = truncated + ? `📋 Bot Logs (showing ${displayedLineCount} of ${lines.length} lines — truncated)` + : `📋 Bot Logs (last ${lines.length} lines)`; + + const embed = new EmbedBuilder().setColor(0x0099ff).setTitle(title).setDescription(description); + + await interaction.editReply({ embeds: [embed] }); + } catch (error) { + logger.error('[LOGS CMD] Error:', error); + const embed = new EmbedBuilder() + .setColor(0xff0000) + .setTitle('❌ Error') + .setDescription('Failed to read log file.'); + if (interaction.deferred) { + await interaction.editReply({ embeds: [embed] }); + } else { + await interaction.reply({ embeds: [embed], flags: MessageFlags.Ephemeral }); + } + } +}