Skip to content

Commit 6da1ccf

Browse files
committed
feat: add admin /logs command to view bot log lines
Signed-off-by: Michael Cramer <michael@bigmichi1.de>
1 parent c463b35 commit 6da1ccf

4 files changed

Lines changed: 336 additions & 0 deletions

File tree

src/bot/bot.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { buildCodesPage } from './commands/codes';
1818
import * as autoredeemCommand from './commands/autoredeem';
1919
import * as helpCommand from './commands/help';
2020
import * as inventoryCommand from './commands/inventory';
21+
import * as logsCommand from './commands/logs';
2122
import * as makepublicCommand from './commands/makepublic';
2223
import * as notificationsCommand from './commands/notifications';
2324
import * as openCommand from './commands/open';
@@ -61,6 +62,7 @@ const commands = [
6162
deleteaccountCommand,
6263
helpCommand,
6364
inventoryCommand,
65+
logsCommand,
6466
makepublicCommand,
6567
notificationsCommand,
6668
openCommand,

src/bot/commands/help.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,11 @@ export async function execute(interaction: ChatInputCommandInteraction) {
7676
value: '`/backfill [channel:<channel>]`\nRecover missed codes from message history (admin only).',
7777
inline: false,
7878
},
79+
{
80+
name: '📋 Logs',
81+
value: '`/logs [lines:<n>]`\nShow the last N lines of the bot log (admin only, default 20, max 100).',
82+
inline: false,
83+
},
7984
{
8085
name: '🗑️ Delete Account',
8186
value:

src/bot/commands/logs.test.ts

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
import { describe, test, expect, beforeEach, afterEach, spyOn } from 'bun:test';
2+
import { MessageFlags } from 'discord.js';
3+
import fs from 'fs';
4+
import { execute } from './logs';
5+
6+
// ---------------------------------------------------------------------------
7+
// Interaction mock helpers
8+
// ---------------------------------------------------------------------------
9+
10+
function makeInteraction(opts: { hasPermission?: boolean; lines?: number | null } = {}) {
11+
const { hasPermission = true, lines = null } = opts;
12+
13+
const editReplySpy = spyOn({ editReply: async (_: unknown) => {} }, 'editReply');
14+
const replySpy = spyOn({ reply: async (_: unknown) => {} }, 'reply');
15+
16+
const interaction = {
17+
user: { id: 'admin-1', tag: 'admin#0001' },
18+
deferred: false,
19+
replied: false,
20+
memberPermissions: hasPermission ? { has: () => true } : null,
21+
deferReply: async () => {
22+
(interaction as any).deferred = true;
23+
},
24+
editReply: editReplySpy,
25+
reply: replySpy,
26+
options: {
27+
getInteger: (name: string) => (name === 'lines' ? lines : null),
28+
},
29+
} as any;
30+
31+
return { interaction, editReplySpy, replySpy };
32+
}
33+
34+
// Sample log lines used across multiple tests
35+
const SAMPLE_LINES = Array.from(
36+
{ length: 50 },
37+
(_, i) => `2026-05-24 12:00:${String(i).padStart(2, '0')} [INFO]: Log message ${i}`
38+
);
39+
const SAMPLE_CONTENT = SAMPLE_LINES.join('\n');
40+
41+
// ---------------------------------------------------------------------------
42+
// Permission denied
43+
// ---------------------------------------------------------------------------
44+
45+
describe('/logs – permission denied', () => {
46+
test('replies ephemerally with "Permission Denied" when user lacks ManageMessages', async () => {
47+
const { interaction, replySpy } = makeInteraction({ hasPermission: false });
48+
49+
await execute(interaction);
50+
51+
expect(replySpy).toHaveBeenCalledTimes(1);
52+
const reply = replySpy.mock.calls[0]![0] as any;
53+
expect(reply.embeds[0].data.title).toContain('Permission Denied');
54+
expect(reply.flags).toBe(MessageFlags.Ephemeral);
55+
expect(interaction.deferred).toBe(false);
56+
});
57+
});
58+
59+
// ---------------------------------------------------------------------------
60+
// Log file missing
61+
// ---------------------------------------------------------------------------
62+
63+
describe('/logs – log file missing', () => {
64+
let existsSpy: ReturnType<typeof spyOn>;
65+
66+
beforeEach(() => {
67+
existsSpy = spyOn(fs, 'existsSync').mockReturnValue(false);
68+
});
69+
70+
afterEach(() => {
71+
existsSpy.mockRestore();
72+
});
73+
74+
test('defers reply and shows "No log entries found" when combined.log does not exist', async () => {
75+
const { interaction, editReplySpy } = makeInteraction();
76+
77+
await execute(interaction);
78+
79+
expect(interaction.deferred).toBe(true);
80+
expect(editReplySpy).toHaveBeenCalledTimes(1);
81+
const embed = (editReplySpy.mock.calls[0]![0] as any).embeds[0].data;
82+
expect(embed.description).toContain('No log entries found');
83+
});
84+
});
85+
86+
// ---------------------------------------------------------------------------
87+
// Reading log lines
88+
// ---------------------------------------------------------------------------
89+
90+
describe('/logs – reading log lines', () => {
91+
let existsSpy: ReturnType<typeof spyOn>;
92+
let readSpy: ReturnType<typeof spyOn>;
93+
94+
beforeEach(() => {
95+
existsSpy = spyOn(fs, 'existsSync').mockReturnValue(true);
96+
readSpy = spyOn(fs, 'readFileSync').mockReturnValue(SAMPLE_CONTENT);
97+
});
98+
99+
afterEach(() => {
100+
existsSpy.mockRestore();
101+
readSpy.mockRestore();
102+
});
103+
104+
test('defers reply', async () => {
105+
const { interaction } = makeInteraction({ lines: 5 });
106+
107+
await execute(interaction);
108+
109+
expect(interaction.deferred).toBe(true);
110+
});
111+
112+
test('defaults to 20 lines when no lines param is provided', async () => {
113+
const { interaction, editReplySpy } = makeInteraction({ lines: null });
114+
115+
await execute(interaction);
116+
117+
const embed = (editReplySpy.mock.calls[0]![0] as any).embeds[0].data;
118+
expect(embed.title).toContain('last 20 lines');
119+
const lastTwenty = SAMPLE_LINES.slice(-20);
120+
expect(embed.description).toContain(lastTwenty[0]);
121+
expect(embed.description).toContain(lastTwenty[19]);
122+
});
123+
124+
test('respects custom lines parameter', async () => {
125+
const { interaction, editReplySpy } = makeInteraction({ lines: 5 });
126+
127+
await execute(interaction);
128+
129+
const embed = (editReplySpy.mock.calls[0]![0] as any).embeds[0].data;
130+
expect(embed.title).toContain('last 5 lines');
131+
const lastFive = SAMPLE_LINES.slice(-5);
132+
for (const line of lastFive) {
133+
expect(embed.description).toContain(line);
134+
}
135+
// Lines outside the requested range must not appear
136+
expect(embed.description).not.toContain(SAMPLE_LINES[0]);
137+
});
138+
139+
test('wraps output in a code block', async () => {
140+
const { interaction, editReplySpy } = makeInteraction({ lines: 3 });
141+
142+
await execute(interaction);
143+
144+
const embed = (editReplySpy.mock.calls[0]![0] as any).embeds[0].data;
145+
expect(embed.description).toMatch(/^```\n/);
146+
expect(embed.description).toMatch(/\n```$/);
147+
});
148+
149+
test('title includes the actual number of lines returned', async () => {
150+
const { interaction, editReplySpy } = makeInteraction({ lines: 10 });
151+
152+
await execute(interaction);
153+
154+
const embed = (editReplySpy.mock.calls[0]![0] as any).embeds[0].data;
155+
expect(embed.title).toContain('10 lines');
156+
});
157+
});
158+
159+
// ---------------------------------------------------------------------------
160+
// Truncation
161+
// ---------------------------------------------------------------------------
162+
163+
describe('/logs – truncation', () => {
164+
let existsSpy: ReturnType<typeof spyOn>;
165+
let readSpy: ReturnType<typeof spyOn>;
166+
167+
// Build a log whose content far exceeds the 4096-char embed limit
168+
const bigLine = 'X'.repeat(80);
169+
const bigLines = Array.from({ length: 100 }, (_, i) => `${bigLine} line-${i}`);
170+
const bigContent = bigLines.join('\n');
171+
172+
beforeEach(() => {
173+
existsSpy = spyOn(fs, 'existsSync').mockReturnValue(true);
174+
readSpy = spyOn(fs, 'readFileSync').mockReturnValue(bigContent);
175+
});
176+
177+
afterEach(() => {
178+
existsSpy.mockRestore();
179+
readSpy.mockRestore();
180+
});
181+
182+
test('embed description stays within the 4096-char limit', async () => {
183+
const { interaction, editReplySpy } = makeInteraction({ lines: 100 });
184+
185+
await execute(interaction);
186+
187+
const embed = (editReplySpy.mock.calls[0]![0] as any).embeds[0].data;
188+
expect(embed.description.length).toBeLessThanOrEqual(4096);
189+
});
190+
191+
test('title indicates truncation when content was trimmed', async () => {
192+
const { interaction, editReplySpy } = makeInteraction({ lines: 100 });
193+
194+
await execute(interaction);
195+
196+
const embed = (editReplySpy.mock.calls[0]![0] as any).embeds[0].data;
197+
expect(embed.title).toContain('truncated');
198+
});
199+
});
200+
201+
// ---------------------------------------------------------------------------
202+
// Error handling
203+
// ---------------------------------------------------------------------------
204+
205+
describe('/logs – error handling', () => {
206+
let existsSpy: ReturnType<typeof spyOn>;
207+
let readSpy: ReturnType<typeof spyOn>;
208+
209+
beforeEach(() => {
210+
existsSpy = spyOn(fs, 'existsSync').mockReturnValue(true);
211+
readSpy = spyOn(fs, 'readFileSync').mockImplementation(() => {
212+
throw new Error('disk read error');
213+
});
214+
});
215+
216+
afterEach(() => {
217+
existsSpy.mockRestore();
218+
readSpy.mockRestore();
219+
});
220+
221+
test('replies with error embed when log file cannot be read', async () => {
222+
const { interaction, editReplySpy } = makeInteraction();
223+
224+
await execute(interaction);
225+
226+
expect(editReplySpy).toHaveBeenCalledTimes(1);
227+
const embed = (editReplySpy.mock.calls[0]![0] as any).embeds[0].data;
228+
expect(embed.title).toContain('Error');
229+
expect(embed.description).toContain('Failed to read log file');
230+
});
231+
});

src/bot/commands/logs.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import {
2+
SlashCommandBuilder,
3+
ChatInputCommandInteraction,
4+
EmbedBuilder,
5+
MessageFlags,
6+
PermissionFlagsBits,
7+
} from 'discord.js';
8+
import fs from 'fs';
9+
import path from 'path';
10+
import logger from '../utils/logger';
11+
12+
const LOG_FILE = path.join(process.cwd(), 'logs', 'combined.log');
13+
const MAX_LINES = 100;
14+
const EMBED_DESCRIPTION_LIMIT = 4096;
15+
16+
function readLastLines(filePath: string, n: number): string[] {
17+
if (!fs.existsSync(filePath)) return [];
18+
19+
const content = fs.readFileSync(filePath, 'utf-8');
20+
const lines = content.split('\n').filter((l) => l.trim() !== '');
21+
return lines.slice(-n);
22+
}
23+
24+
export const data = new SlashCommandBuilder()
25+
.setName('logs')
26+
.setDescription('Show the last N lines of the bot log (admin only)')
27+
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages)
28+
.addIntegerOption((option) =>
29+
option
30+
.setName('lines')
31+
.setDescription(`Number of log lines to show (1-${MAX_LINES}, default 20)`)
32+
.setRequired(false)
33+
.setMinValue(1)
34+
.setMaxValue(MAX_LINES)
35+
);
36+
37+
export async function execute(interaction: ChatInputCommandInteraction) {
38+
try {
39+
if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageMessages)) {
40+
const embed = new EmbedBuilder()
41+
.setColor(0xff0000)
42+
.setTitle('❌ Permission Denied')
43+
.setDescription('You need the "Manage Messages" permission to view logs.');
44+
await interaction.reply({ embeds: [embed], flags: MessageFlags.Ephemeral });
45+
return;
46+
}
47+
48+
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
49+
50+
const n = interaction.options.getInteger('lines') ?? 20;
51+
logger.info(`[LOGS CMD] ${interaction.user.tag} requested last ${n} log lines`);
52+
53+
const lines = readLastLines(LOG_FILE, n);
54+
55+
if (lines.length === 0) {
56+
const embed = new EmbedBuilder()
57+
.setColor(0xffaa00)
58+
.setTitle('📋 Bot Logs')
59+
.setDescription('No log entries found.');
60+
await interaction.editReply({ embeds: [embed] });
61+
return;
62+
}
63+
64+
// Build the code block and truncate from the start if it exceeds Discord's limit
65+
const codeBlockOverhead = '```\n'.length + '\n```'.length;
66+
const maxContent = EMBED_DESCRIPTION_LIMIT - codeBlockOverhead;
67+
68+
let content = lines.join('\n');
69+
let truncated = false;
70+
if (content.length > maxContent) {
71+
content = content.slice(content.length - maxContent);
72+
// Trim to a clean line boundary
73+
const firstNewline = content.indexOf('\n');
74+
if (firstNewline !== -1) content = content.slice(firstNewline + 1);
75+
truncated = true;
76+
}
77+
78+
const description = `\`\`\`\n${content}\n\`\`\``;
79+
const title = truncated
80+
? `📋 Bot Logs (last ${lines.length} lines — truncated)`
81+
: `📋 Bot Logs (last ${lines.length} lines)`;
82+
83+
const embed = new EmbedBuilder().setColor(0x0099ff).setTitle(title).setDescription(description);
84+
85+
await interaction.editReply({ embeds: [embed] });
86+
} catch (error) {
87+
logger.error('[LOGS CMD] Error:', error);
88+
const embed = new EmbedBuilder()
89+
.setColor(0xff0000)
90+
.setTitle('❌ Error')
91+
.setDescription('Failed to read log file.');
92+
if (interaction.deferred) {
93+
await interaction.editReply({ embeds: [embed] });
94+
} else {
95+
await interaction.reply({ embeds: [embed], flags: MessageFlags.Ephemeral });
96+
}
97+
}
98+
}

0 commit comments

Comments
 (0)