Skip to content

Commit d390f81

Browse files
author
Sorra
committed
SB-0MNP2ITRZ003GCCC: Add /recent slash command handler and register command; add tests
1 parent 3a6a457 commit d390f81

File tree

4 files changed

+278
-0
lines changed

4 files changed

+278
-0
lines changed

src/discord/client.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,22 @@ export class DiscordBot {
115115
],
116116
});
117117

118+
// Register the recent command
119+
await guild.commands.create({
120+
name: "recent",
121+
description: "List recently modified OpenBrain items",
122+
options: [
123+
{
124+
name: "limit",
125+
description: "Maximum number of recent items to return (1-100)",
126+
type: 4, // INTEGER
127+
required: false,
128+
minValue: 1,
129+
maxValue: 100,
130+
},
131+
],
132+
});
133+
118134
this.options.logger.info("Slash commands registered successfully");
119135
} catch (error) {
120136
this.options.logger.error("Failed to register slash commands", {
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import type { CommandInteraction } from "discord.js";
2+
import { runCliCommand, CliRunnerError } from "../bot/cli-runner.js";
3+
import type { SlashCommandHandler } from "../interfaces/command-handler.js";
4+
5+
const DEFAULT_ERROR_MESSAGE = "❌ Failed to retrieve recent OpenBrain items. Please try again.";
6+
7+
export interface RecentCommandHandlerDependencies {
8+
runCli?: typeof runCliCommand;
9+
errorMessage?: string;
10+
}
11+
12+
export class RecentCommandHandler implements SlashCommandHandler {
13+
private readonly runCli: typeof runCliCommand;
14+
private readonly errorMessage: string;
15+
16+
constructor(dependencies: RecentCommandHandlerDependencies = {}) {
17+
this.runCli = dependencies.runCli ?? runCliCommand;
18+
this.errorMessage = dependencies.errorMessage ?? DEFAULT_ERROR_MESSAGE;
19+
}
20+
21+
async handleCommand(command: CommandInteraction): Promise<boolean> {
22+
if (command.commandName !== "recent") return false;
23+
24+
await command.deferReply();
25+
26+
try {
27+
const opt = command.options.getInteger("limit");
28+
const limit = opt ?? 5;
29+
30+
if (opt !== null && (limit < 1 || limit > 100)) {
31+
await command.editReply("⚠️ Recent parameter `limit` must be between 1 and 100.");
32+
return true;
33+
}
34+
35+
const args = ["--json", "--limit", String(limit)];
36+
37+
const result = await this.runCli("recent", args, {
38+
channelId: command.channelId ?? undefined,
39+
messageId: undefined,
40+
authorId: command.user?.id,
41+
});
42+
43+
if (result.exitCode !== 0) {
44+
await command.editReply("❌ Recent failed: CLI returned an error");
45+
return true;
46+
}
47+
48+
if (!result.stdout || result.stdout.length === 0) {
49+
await command.editReply("No recent items found.");
50+
return true;
51+
}
52+
53+
const stdoutText = result.stdout.join("\n").trim();
54+
let items: any[] = [];
55+
56+
try {
57+
const parsed = JSON.parse(stdoutText);
58+
if (Array.isArray(parsed)) {
59+
items = parsed.slice(0, limit);
60+
} else if (parsed && typeof parsed === "object") {
61+
if (Array.isArray((parsed as any).items)) items = (parsed as any).items.slice(0, limit);
62+
else if (Array.isArray((parsed as any).results)) items = (parsed as any).results.slice(0, limit);
63+
else if (Array.isArray((parsed as any).rows)) items = (parsed as any).rows.slice(0, limit);
64+
else {
65+
const arrProp = Object.keys(parsed).find((k) => Array.isArray((parsed as any)[k]));
66+
if (arrProp) items = (parsed as any)[arrProp].slice(0, limit);
67+
else items = [parsed];
68+
}
69+
}
70+
} catch {
71+
// Fallback: try parsing each stdout line as JSON (NDJSON style)
72+
for (const line of result.stdout) {
73+
try {
74+
const obj = JSON.parse(line);
75+
if (obj) items.push(obj);
76+
} catch {
77+
// ignore non-json lines
78+
}
79+
}
80+
items = items.slice(0, limit);
81+
}
82+
83+
// Ensure we have an array of entries
84+
items = items.filter(Boolean);
85+
86+
if (items.length === 0) {
87+
await command.editReply("No recent items found.");
88+
return true;
89+
}
90+
91+
// Helper to escape stray closing bracket to avoid accidental markdown
92+
const escape = (s: unknown) => {
93+
if (s === undefined || s === null) return "";
94+
return String(s).replace(/\]/g, "\\]").replace(/\[/g, "\\[");
95+
};
96+
97+
const lines: string[] = [];
98+
lines.push("🕘 Recent OpenBrain items");
99+
lines.push("");
100+
101+
for (const it of items) {
102+
const id = it.id ?? it.item_id ?? it.itemId ?? it._id ?? "";
103+
const title = it.title ?? it.name ?? it.heading ?? it.text ?? "(untitled)";
104+
const modified = it.modified ?? it.updated_at ?? it.updated ?? it.timestamp ?? it.mtime ?? "";
105+
const summary = it.summary ?? it.brief ?? it.description ?? it.text ?? "";
106+
107+
const idPart = id !== "" ? `\`${String(id)}\`` : "";
108+
const titlePart = escape(title);
109+
const modifiedPart = modified ? ` — ${String(modified)}` : "";
110+
111+
lines.push(`- ${idPart} ${titlePart}${modifiedPart}`.trim());
112+
113+
if (summary && typeof summary === "string") {
114+
const one = summary.replace(/\s+/g, " ").trim();
115+
const short = one.length > 200 ? one.slice(0, 197).trim() + "..." : one;
116+
if (short) lines.push(` ${short}`);
117+
}
118+
119+
lines.push("");
120+
}
121+
122+
const message = lines.join("\n").trim();
123+
const DISCORD_CONTENT_LIMIT = 1900;
124+
125+
if (message.length <= DISCORD_CONTENT_LIMIT) {
126+
await command.editReply(message);
127+
} else {
128+
// Attach full content as a markdown file and post a short TOC
129+
const filename = `recent-${Date.now()}.md`;
130+
const summary = lines.slice(0, 20).join("\n");
131+
const file = { attachment: Buffer.from(lines.join("\n"), "utf8"), name: filename } as any;
132+
await command.editReply({ content: `${"🕘 Recent OpenBrain items"}\n\n${summary}\n\n*(Full content attached as ${filename})*`, files: [file] } as any);
133+
}
134+
} catch (err) {
135+
try {
136+
// eslint-disable-next-line no-console
137+
console.error("RecentCommandHandler: error while retrieving recent items:", err);
138+
} catch {
139+
// ignore logging issues
140+
}
141+
142+
if (err instanceof CliRunnerError) {
143+
const CLI_UNAVAILABLE_MESSAGE =
144+
"⚠️ OpenBrain CLI is not available. Please ensure the CLI is installed and accessible on PATH.";
145+
await command.editReply(CLI_UNAVAILABLE_MESSAGE);
146+
} else {
147+
if (process.env.NODE_ENV === "test") {
148+
await command.editReply(this.errorMessage);
149+
} else {
150+
const msg = String((err && (err as any).message) || String(err || ""));
151+
const snippet = msg.length > 500 ? msg.slice(0, 500) + "...(truncated)" : msg;
152+
await command.editReply(`${this.errorMessage}\n\nError: ${snippet}\n(See bot logs for details)`);
153+
}
154+
}
155+
}
156+
157+
return true;
158+
}
159+
}

src/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { formatProgressMessage } from "./formatters/progress.js";
88
import { postCliErrorReport } from "./discord/cli-error-report.js";
99
import { CrawlCommandHandler } from "./handlers/CrawlCommandHandler.js";
1010
import { StatsCommandHandler } from "./handlers/StatsCommandHandler.js";
11+
import { RecentCommandHandler } from "./handlers/RecentCommandHandler.js";
1112
import { startBot } from "./lifecycle/startup.js";
1213
import { createShutdownController } from "./lifecycle/shutdown.js";
1314
import {
@@ -32,6 +33,7 @@ import { pathToFileURL } from "url";
3233
const logger = new Logger(config.LOG_LEVEL as any);
3334
const crawlCommandHandler = new CrawlCommandHandler();
3435
const statsCommandHandler = new StatsCommandHandler();
36+
const recentCommandHandler = new RecentCommandHandler();
3537

3638
export { formatProgressMessage };
3739
export { postCliErrorReport };
@@ -1455,6 +1457,11 @@ const bot = new DiscordBot({
14551457
return;
14561458
}
14571459

1460+
// Handle /recent
1461+
if (await recentCommandHandler.handleCommand(cmd)) {
1462+
return;
1463+
}
1464+
14581465
// Handle /search
14591466
if (commandName === "search") {
14601467
try {

tests/discord/interaction.test.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,102 @@ describe("slash interaction handlers", () => {
275275
);
276276
});
277277

278+
it("parses JSON array recent output into id/title/modified lines", async () => {
279+
const handler = await loadInteractionHandler(async () => {
280+
await vi.doMock("../../src/bot/cli-runner.js", () => {
281+
return {
282+
runCliCommand: vi.fn(async (cmd: string) => {
283+
if (cmd === "recent") {
284+
return {
285+
exitCode: 0,
286+
stdout: [
287+
JSON.stringify([
288+
{ id: 1, title: "Alpha", modified: "2026-04-07T12:00:00Z", summary: "One-liner alpha" },
289+
{ id: 2, title: "Beta", modified: "2026-04-07T13:00:00Z", summary: "One-liner beta" },
290+
]),
291+
],
292+
};
293+
}
294+
return { exitCode: 0, stdout: [] };
295+
}),
296+
runAddCommand: vi.fn(),
297+
runQueueCommand: vi.fn(),
298+
runSummaryCommand: vi.fn(),
299+
runStatsCommand: vi.fn(async () => ({
300+
totalLinks: 0,
301+
processedCount: 0,
302+
pendingCount: 0,
303+
failedCount: 0,
304+
})),
305+
isCliAvailable: vi.fn(async () => true),
306+
CliRunnerError: class MockCliRunnerError extends Error {},
307+
};
308+
});
309+
});
310+
311+
const edits: string[] = [];
312+
const fakeInteraction: any = {
313+
isCommand: () => true,
314+
commandName: "recent",
315+
options: { getInteger: (_: string) => null },
316+
user: { id: "user-1" },
317+
channelId: "chan-1",
318+
deferReply: vi.fn(async () => {}),
319+
editReply: vi.fn(async (content: string) => edits.push(String(content))),
320+
fetchReply: vi.fn(async () => ({ id: "posted-1" })),
321+
reply: vi.fn(async () => {}),
322+
};
323+
324+
await handler(fakeInteraction);
325+
326+
expect(edits.length).toBeGreaterThan(0);
327+
const body = edits[0];
328+
expect(body).toContain("Alpha");
329+
expect(body).toContain("Beta");
330+
expect(body).toContain("2026-04-07T12:00:00");
331+
});
332+
333+
it("rejects out-of-range recent limit before calling CLI", async () => {
334+
const runCliCommandMock = vi.fn(async () => ({ exitCode: 0, stdout: ["[]"] }));
335+
336+
const handler = await loadInteractionHandler(async () => {
337+
await vi.doMock("../../src/bot/cli-runner.js", () => {
338+
return {
339+
runCliCommand: runCliCommandMock,
340+
runAddCommand: vi.fn(),
341+
runQueueCommand: vi.fn(),
342+
runSummaryCommand: vi.fn(),
343+
runStatsCommand: vi.fn(async () => ({
344+
totalLinks: 0,
345+
processedCount: 0,
346+
pendingCount: 0,
347+
failedCount: 0,
348+
})),
349+
isCliAvailable: vi.fn(async () => true),
350+
CliRunnerError: class MockCliRunnerError extends Error {},
351+
};
352+
});
353+
});
354+
355+
const edits: string[] = [];
356+
const fakeInteraction: any = {
357+
isCommand: () => true,
358+
commandName: "recent",
359+
options: { getInteger: (_: string) => 999 },
360+
user: { id: "user-1" },
361+
channelId: "chan-1",
362+
deferReply: vi.fn(async () => {}),
363+
editReply: vi.fn(async (content: string) => edits.push(String(content))),
364+
fetchReply: vi.fn(async () => ({ id: "posted-1" })),
365+
reply: vi.fn(async () => {}),
366+
};
367+
368+
await handler(fakeInteraction);
369+
370+
expect(runCliCommandMock).not.toHaveBeenCalled();
371+
expect(edits).toContain("⚠️ Recent parameter `limit` must be between 1 and 100.");
372+
});
373+
278374
it("passes optional briefing k argument through to CLI when provided", async () => {
279375
const runCliCommandMock = vi.fn(async (cmd: string) => {
280376
if (cmd === "briefing") {

0 commit comments

Comments
 (0)