Skip to content

Commit 03e6fb7

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 03e6fb7

4 files changed

Lines changed: 406 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: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
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+
test('redacts sensitive credential fields from log lines', async () => {
159+
const sensitiveContent = [
160+
'2026-05-24 12:00:01 [INFO]: [CMD] admin#0001 used /setup: user_id=123456 user_hash=supersecret',
161+
'2026-05-24 12:00:02 [INFO]: [CMD] admin#0001 used /redeem: code=ABC-123',
162+
'2026-05-24 12:00:03 [INFO]: token=mytoken hash=myhash normal log line',
163+
].join('\n');
164+
165+
readSpy.mockReturnValue(sensitiveContent);
166+
const { interaction, editReplySpy } = makeInteraction({ lines: 10 });
167+
168+
await execute(interaction);
169+
170+
const desc = (editReplySpy.mock.calls[0]![0] as any).embeds[0].data.description;
171+
expect(desc).not.toContain('supersecret');
172+
expect(desc).not.toContain('mytoken');
173+
expect(desc).not.toContain('myhash');
174+
expect(desc).toContain('user_hash=[REDACTED]');
175+
expect(desc).toContain('user_id=[REDACTED]');
176+
expect(desc).toContain('token=[REDACTED]');
177+
expect(desc).toContain('hash=[REDACTED]');
178+
// Non-sensitive fields must not be redacted
179+
expect(desc).toContain('code=ABC-123');
180+
});
181+
182+
test('preserves blank lines within the log (only trailing newline is stripped)', async () => {
183+
const contentWithBlanks = 'line-a\n\nline-b\n\nline-c\n';
184+
readSpy.mockReturnValue(contentWithBlanks);
185+
const { interaction, editReplySpy } = makeInteraction({ lines: 10 });
186+
187+
await execute(interaction);
188+
189+
const desc = (editReplySpy.mock.calls[0]![0] as any).embeds[0].data.description;
190+
// All five lines (including the two blank ones) must be present
191+
expect(desc).toContain('line-a');
192+
expect(desc).toContain('line-b');
193+
expect(desc).toContain('line-c');
194+
// The title should report 5 lines (line-a, blank, line-b, blank, line-c)
195+
expect((editReplySpy.mock.calls[0]![0] as any).embeds[0].data.title).toContain('5 lines');
196+
});
197+
198+
test('escapes triple-backticks in log content to prevent breaking the code block', async () => {
199+
const contentWithBackticks = '2026-05-24 12:00:01 [INFO]: line with ```backticks``` inside';
200+
readSpy.mockReturnValue(contentWithBackticks);
201+
const { interaction, editReplySpy } = makeInteraction({ lines: 5 });
202+
203+
await execute(interaction);
204+
205+
const desc = (editReplySpy.mock.calls[0]![0] as any).embeds[0].data.description;
206+
// The injected triple-backticks must be replaced
207+
expect(desc).toContain("'''backticks'''");
208+
// Overall structure must still be a valid code block
209+
expect(desc).toMatch(/^```\n/);
210+
expect(desc).toMatch(/\n```$/);
211+
});
212+
});
213+
214+
// ---------------------------------------------------------------------------
215+
// Truncation
216+
// ---------------------------------------------------------------------------
217+
218+
describe('/logs – truncation', () => {
219+
let existsSpy: ReturnType<typeof spyOn>;
220+
let readSpy: ReturnType<typeof spyOn>;
221+
222+
// Build a log whose content far exceeds the 4096-char embed limit
223+
const bigLine = 'X'.repeat(80);
224+
const bigLines = Array.from({ length: 100 }, (_, i) => `${bigLine} line-${i}`);
225+
const bigContent = bigLines.join('\n');
226+
227+
beforeEach(() => {
228+
existsSpy = spyOn(fs, 'existsSync').mockReturnValue(true);
229+
readSpy = spyOn(fs, 'readFileSync').mockReturnValue(bigContent);
230+
});
231+
232+
afterEach(() => {
233+
existsSpy.mockRestore();
234+
readSpy.mockRestore();
235+
});
236+
237+
test('embed description stays within the 4096-char limit', async () => {
238+
const { interaction, editReplySpy } = makeInteraction({ lines: 100 });
239+
240+
await execute(interaction);
241+
242+
const embed = (editReplySpy.mock.calls[0]![0] as any).embeds[0].data;
243+
expect(embed.description.length).toBeLessThanOrEqual(4096);
244+
});
245+
246+
test('title shows displayed vs requested line count when truncated', async () => {
247+
const { interaction, editReplySpy } = makeInteraction({ lines: 100 });
248+
249+
await execute(interaction);
250+
251+
const embed = (editReplySpy.mock.calls[0]![0] as any).embeds[0].data;
252+
expect(embed.title).toContain('truncated');
253+
// e.g. "showing 45 of 100 lines — truncated"
254+
expect(embed.title).toMatch(/showing \d+ of 100 lines/);
255+
});
256+
});
257+
258+
// ---------------------------------------------------------------------------
259+
// Error handling
260+
// ---------------------------------------------------------------------------
261+
262+
describe('/logs – error handling', () => {
263+
let existsSpy: ReturnType<typeof spyOn>;
264+
let readSpy: ReturnType<typeof spyOn>;
265+
266+
beforeEach(() => {
267+
existsSpy = spyOn(fs, 'existsSync').mockReturnValue(true);
268+
readSpy = spyOn(fs, 'readFileSync').mockImplementation(() => {
269+
throw new Error('disk read error');
270+
});
271+
});
272+
273+
afterEach(() => {
274+
existsSpy.mockRestore();
275+
readSpy.mockRestore();
276+
});
277+
278+
test('replies with error embed when log file cannot be read', async () => {
279+
const { interaction, editReplySpy } = makeInteraction();
280+
281+
await execute(interaction);
282+
283+
expect(editReplySpy).toHaveBeenCalledTimes(1);
284+
const embed = (editReplySpy.mock.calls[0]![0] as any).embeds[0].data;
285+
expect(embed.title).toContain('Error');
286+
expect(embed.description).toContain('Failed to read log file');
287+
});
288+
});

0 commit comments

Comments
 (0)