Skip to content

Commit 48b47b8

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 48b47b8

4 files changed

Lines changed: 388 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: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
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('escapes triple-backticks in log content to prevent breaking the code block', async () => {
183+
const contentWithBackticks = '2026-05-24 12:00:01 [INFO]: line with ```backticks``` inside';
184+
readSpy.mockReturnValue(contentWithBackticks);
185+
const { interaction, editReplySpy } = makeInteraction({ lines: 5 });
186+
187+
await execute(interaction);
188+
189+
const desc = (editReplySpy.mock.calls[0]![0] as any).embeds[0].data.description;
190+
// The injected triple-backticks must be replaced
191+
expect(desc).toContain("'''backticks'''");
192+
// Overall structure must still be a valid code block
193+
expect(desc).toMatch(/^```\n/);
194+
expect(desc).toMatch(/\n```$/);
195+
});
196+
});
197+
198+
// ---------------------------------------------------------------------------
199+
// Truncation
200+
// ---------------------------------------------------------------------------
201+
202+
describe('/logs – truncation', () => {
203+
let existsSpy: ReturnType<typeof spyOn>;
204+
let readSpy: ReturnType<typeof spyOn>;
205+
206+
// Build a log whose content far exceeds the 4096-char embed limit
207+
const bigLine = 'X'.repeat(80);
208+
const bigLines = Array.from({ length: 100 }, (_, i) => `${bigLine} line-${i}`);
209+
const bigContent = bigLines.join('\n');
210+
211+
beforeEach(() => {
212+
existsSpy = spyOn(fs, 'existsSync').mockReturnValue(true);
213+
readSpy = spyOn(fs, 'readFileSync').mockReturnValue(bigContent);
214+
});
215+
216+
afterEach(() => {
217+
existsSpy.mockRestore();
218+
readSpy.mockRestore();
219+
});
220+
221+
test('embed description stays within the 4096-char limit', async () => {
222+
const { interaction, editReplySpy } = makeInteraction({ lines: 100 });
223+
224+
await execute(interaction);
225+
226+
const embed = (editReplySpy.mock.calls[0]![0] as any).embeds[0].data;
227+
expect(embed.description.length).toBeLessThanOrEqual(4096);
228+
});
229+
230+
test('title shows displayed vs requested line count when truncated', async () => {
231+
const { interaction, editReplySpy } = makeInteraction({ lines: 100 });
232+
233+
await execute(interaction);
234+
235+
const embed = (editReplySpy.mock.calls[0]![0] as any).embeds[0].data;
236+
expect(embed.title).toContain('truncated');
237+
// e.g. "showing 45 of 100 lines — truncated"
238+
expect(embed.title).toMatch(/showing \d+ of 100 lines/);
239+
});
240+
});
241+
242+
// ---------------------------------------------------------------------------
243+
// Error handling
244+
// ---------------------------------------------------------------------------
245+
246+
describe('/logs – error handling', () => {
247+
let existsSpy: ReturnType<typeof spyOn>;
248+
let readSpy: ReturnType<typeof spyOn>;
249+
250+
beforeEach(() => {
251+
existsSpy = spyOn(fs, 'existsSync').mockReturnValue(true);
252+
readSpy = spyOn(fs, 'readFileSync').mockImplementation(() => {
253+
throw new Error('disk read error');
254+
});
255+
});
256+
257+
afterEach(() => {
258+
existsSpy.mockRestore();
259+
readSpy.mockRestore();
260+
});
261+
262+
test('replies with error embed when log file cannot be read', async () => {
263+
const { interaction, editReplySpy } = makeInteraction();
264+
265+
await execute(interaction);
266+
267+
expect(editReplySpy).toHaveBeenCalledTimes(1);
268+
const embed = (editReplySpy.mock.calls[0]![0] as any).embeds[0].data;
269+
expect(embed.title).toContain('Error');
270+
expect(embed.description).toContain('Failed to read log file');
271+
});
272+
});

0 commit comments

Comments
 (0)