Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 3 additions & 67 deletions src/bot/cli-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -719,21 +719,16 @@ export async function runSummaryCommand(
*/
export async function runStatsCommand(
options: RunnerOptions = {}
): Promise<any> {
): Promise<{ raw: string }> {
const { stdoutIterator, exitPromise } = runCliSubprocess(
"stats",
["--json"],
[],
options
);

let jsonOutput = "";
const stdoutLines: string[] = [];

// Collect stdout lines (preserve them in case parsing fails so we can
// include the raw output in errors or fallbacks)
for await (const line of stdoutIterator) {
stdoutLines.push(line);
jsonOutput += line;
}

const { exitCode, stderr } = await exitPromise;
Expand All @@ -746,66 +741,7 @@ export async function runStatsCommand(
);
}

try {
// Helper: try parsing JSON from a candidate string. This is liberal
// because some CLI versions may emit the JSON on stderr or include
// additional logging around the JSON. We try the full string first,
// then attempt to extract a JSON object/array by finding matching
// braces/brackets.
const tryParseJson = (input: string): any | undefined => {
if (!input) return undefined;
const trimmed = input.trim();
if (!trimmed) return undefined;
try {
return JSON.parse(trimmed);
} catch {
// Try to find a JSON object substring
const firstBrace = trimmed.indexOf("{");
const lastBrace = trimmed.lastIndexOf("}");
if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) {
const candidate = trimmed.slice(firstBrace, lastBrace + 1);
try {
return JSON.parse(candidate);
} catch {
// continue
}
}

// Try JSON array
const firstBracket = trimmed.indexOf("[");
const lastBracket = trimmed.lastIndexOf("]");
if (firstBracket !== -1 && lastBracket !== -1 && lastBracket > firstBracket) {
const candidate = trimmed.slice(firstBracket, lastBracket + 1);
try {
return JSON.parse(candidate);
} catch {
// continue
}
}

return undefined;
}
};

// Prefer stdout, but fall back to parsing stderr if the CLI wrote JSON there.
const parsedFromStdout = tryParseJson(jsonOutput);
if (parsedFromStdout !== undefined) return parsedFromStdout;

const parsedFromStderr = tryParseJson(stderr);
if (parsedFromStderr !== undefined) return parsedFromStderr;

// Nothing parseable
} catch (parseErr) {
// If parsing fails, throw a distinct error so callers (the Discord
// handlers) can provide a more helpful message to users rather than
// assuming the CLI binary itself is missing/unavailable.
const raw = stdoutLines.join("\n");
throw new StatsParseError(
"Failed to parse stats JSON output",
exitCode,
`${stderr}\n${raw}`
);
}
return { raw: stdoutLines.join("\n") };
}

/**
Expand Down
24 changes: 16 additions & 8 deletions src/discord/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,15 +39,23 @@ export class DiscordBot {
return this.client;
}

async start(): Promise<void> {
this.client.once("ready", async (client) => {
this.options.logger.info("Discord bot connected", {
userTag: client.user.tag,
monitoredChannelId: this.options.monitoredChannelId
});
private startupDone = false;

private handleReady(client: any): void {
if (this.startupDone) return;
this.startupDone = true;

this.options.logger.info("Discord bot connected", {
userTag: client.user.tag,
monitoredChannelId: this.options.monitoredChannelId
});

this.registerSlashCommands();
}

// Register slash commands
await this.registerSlashCommands();
async start(): Promise<void> {
this.client.once("clientReady", (client) => {
this.handleReady(client);
});

this.client.on("messageCreate", async (message) => {
Expand Down
109 changes: 3 additions & 106 deletions src/handlers/StatsCommandHandler.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ChatInputCommandInteraction } from "discord.js";
import { runStatsCommand, type StatsResult, CliRunnerError, StatsParseError } from "../bot/cli-runner.js";
import { runStatsCommand, CliRunnerError } from "../bot/cli-runner.js";
import type { SlashCommandHandler } from "../interfaces/command-handler.js";

const DEFAULT_ERROR_MESSAGE = "❌ Failed to retrieve OpenBrain statistics. Please try again.";
Expand All @@ -26,43 +26,13 @@ export class StatsCommandHandler implements SlashCommandHandler {
await command.deferReply();

try {
const stats = await this.runStats({
const { raw } = await this.runStats({
channelId: command.channelId ?? undefined,
messageId: command.id,
authorId: command.user?.id,
});

// The CLI may return different JSON shapes across versions. Handle
// both the legacy shape (totalContents/withEmbeddings/...) and the
// newer structured StatsResult shape. Fall back to a best-effort
// formatting when fields don't match expectations.
if (stats && typeof stats === "object") {
// New shape expected by the bot
if (
typeof (stats as any).totalLinks === "number" ||
typeof (stats as any).processedCount === "number"
) {
await command.editReply(this.formatStatsMessage(stats as StatsResult));
} else if (typeof (stats as any).totalContents === "number") {
// Legacy/OpenBrain older schema - map fields
const mapped: StatsResult & { timeBased?: any } = {
totalLinks: (stats as any).totalContents,
processedCount: (stats as any).withEmbeddings ?? 0,
pendingCount: ((stats as any).totalContents || 0) - ((stats as any).withEmbeddings || 0),
failedCount: 0,
timeBased: (stats as any).timeBased ?? (stats as any).time_based,
};
await command.editReply(this.formatStatsMessage(mapped));
} else {
// Unknown shape: present raw JSON in a readable form
const pretty = JSON.stringify(stats, null, 2);
const header = "📊 OpenBrain statistics (raw output)";
// Use a fenced code block for readability
await command.editReply(`${header}\n\n\`\`\`json\n${pretty}\n\`\`\``);
}
} else {
await command.editReply(this.errorMessage);
}
await command.editReply(`\`\`\`markdown\n${raw}\n\`\`\`` || "No statistics available.");
} catch (err) {
// Always log the error server-side for diagnostics
try {
Expand All @@ -72,24 +42,6 @@ export class StatsCommandHandler implements SlashCommandHandler {
// ignore logging failures
}

// Handle parse-specific errors specially so users see a meaningful
// message when the CLI returned human-readable output instead of JSON.
if (err instanceof StatsParseError) {
const raw = String(err.stderr || "");
// Truncate to a reasonable size for Discord messages
const maxLen = 1500;
const snippet = raw.length > maxLen ? raw.slice(0, maxLen) + "\n...(truncated)" : raw;
const msg = [
"⚠️ OpenBrain returned unexpected non-JSON output. The bot expected structured JSON from `ob stats --json`.",
"This may indicate an incompatible CLI version or that the CLI was invoked with unsupported flags.",
"",
"Raw CLI output (truncated):",
"```\n" + snippet + "\n```",
].join("\n");
await command.editReply(msg);
return true;
}

// If the failure was due to spawn/ENOENT/EACCES etc., surface the
// availability message. Other errors fall back to the generic one.
if (err instanceof CliRunnerError) {
Expand All @@ -112,59 +64,4 @@ export class StatsCommandHandler implements SlashCommandHandler {

return true;
}

private formatStatsMessage(stats: StatsResult): string {
const totalLinks = stats.totalLinks;
const processedCount = stats.processedCount;
const pendingCount = stats.pendingCount;
const failedCount = stats.failedCount;
const successRate = totalLinks > 0 ? ((processedCount / totalLinks) * 100) : 0;
const failureRate = totalLinks > 0 ? ((failedCount / totalLinks) * 100) : 0;

const lines: string[] = [];
lines.push("📊 OpenBrain statistics");
lines.push("");
lines.push(`**Totals**`);
lines.push(`- Total links: ${totalLinks.toLocaleString()}`);
lines.push(`- Processed: ${processedCount.toLocaleString()} (${successRate.toFixed(1)}%)`);
lines.push(`- Pending: ${pendingCount.toLocaleString()}`);
lines.push(`- Failed: ${failedCount.toLocaleString()} (${failureRate.toFixed(1)}%)`);

// Attempt to render time-based breakdown if available. Support multiple
// possible field namings (timeBased, time_based, timebased).
const tb = (stats as any).timeBased ?? (stats as any).time_based ?? (stats as any).timebased ?? (stats as any).time ?? null;
const asNumber = (v: unknown): number | undefined => {
if (typeof v === "number" && Number.isFinite(v)) return v;
if (typeof v === "string" && v.trim() !== "") {
const n = Number(v);
if (Number.isFinite(n)) return n;
}
return undefined;
};

if (tb && typeof tb === "object") {
const last24 = asNumber(tb.last24Hours ?? tb.last_24_hours ?? tb.last24 ?? tb.last_24h ?? tb.last_24) ?? asNumber(tb["24h"]) ?? undefined;
const last7 = asNumber(tb.last7Days ?? tb.last_7_days ?? tb.last7 ?? tb.last_7d ?? tb.last_7) ?? undefined;
const last30 = asNumber(tb.last30Days ?? tb.last_30_days ?? tb.last30 ?? tb.last_30d ?? tb.last_30) ?? undefined;

if (last24 !== undefined || last7 !== undefined || last30 !== undefined) {
lines.push("");
lines.push(`**By time**`);
if (last24 !== undefined) {
const pct = totalLinks > 0 ? ((last24 / totalLinks) * 100).toFixed(1) : "0.0";
lines.push(`- Last 24 hours: ${last24.toLocaleString()} (${pct}%)`);
}
if (last7 !== undefined) {
const pct = totalLinks > 0 ? ((last7 / totalLinks) * 100).toFixed(1) : "0.0";
lines.push(`- Last 7 days: ${last7.toLocaleString()} (${pct}%)`);
}
if (last30 !== undefined) {
const pct = totalLinks > 0 ? ((last30 / totalLinks) * 100).toFixed(1) : "0.0";
lines.push(`- Last 30 days: ${last30.toLocaleString()} (${pct}%)`);
}
}
}

return lines.join("\n");
}
}
17 changes: 12 additions & 5 deletions tests/discord/interaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,10 +108,15 @@ describe("slash interaction handlers", () => {

it("routes /stats through StatsCommandHandler", async () => {
const runStatsCommandMock = vi.fn(async () => ({
totalLinks: 100,
processedCount: 80,
pendingCount: 15,
failedCount: 5,
raw: [
"📊 OpenBrain statistics",
"",
"**Totals**",
"- Total links: 100",
"- Processed: 80 (80.0%)",
"- Pending: 15",
"- Failed: 5 (5.0%)",
].join("\n"),
}));

const handler = await loadInteractionHandler(async () => {
Expand Down Expand Up @@ -153,6 +158,7 @@ describe("slash interaction handlers", () => {
authorId: "user-1",
});
expect(edits).toContain(
"```markdown\n" +
[
"📊 OpenBrain statistics",
"",
Expand All @@ -161,7 +167,8 @@ describe("slash interaction handlers", () => {
"- Processed: 80 (80.0%)",
"- Pending: 15",
"- Failed: 5 (5.0%)",
].join("\n")
].join("\n") +
"\n```"
);
});

Expand Down
20 changes: 3 additions & 17 deletions tests/unit/StatsCommandHandler.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,7 @@ import { StatsCommandHandler } from "../../src/handlers/StatsCommandHandler.js";
describe("StatsCommandHandler", () => {
it("handles /stats by querying stats and editing the deferred reply", async () => {
const runStatsMock = vi.fn(async () => ({
totalLinks: 100,
processedCount: 80,
pendingCount: 15,
failedCount: 5,
raw: "Total links: 100\nProcessed: 80 (80.0%)\nPending: 15\nFailed: 5 (5.0%)",
}));

const handler = new StatsCommandHandler({
Expand All @@ -33,15 +30,7 @@ describe("StatsCommandHandler", () => {
authorId: "user-1",
});
expect(interaction.editReply).toHaveBeenCalledWith(
[
"📊 OpenBrain statistics",
"",
"**Totals**",
"- Total links: 100",
"- Processed: 80 (80.0%)",
"- Pending: 15",
"- Failed: 5 (5.0%)",
].join("\n")
"```markdown\nTotal links: 100\nProcessed: 80 (80.0%)\nPending: 15\nFailed: 5 (5.0%)\n```"
);
});

Expand Down Expand Up @@ -74,10 +63,7 @@ describe("StatsCommandHandler", () => {

it("returns false for non-stats command", async () => {
const runStatsMock = vi.fn(async () => ({
totalLinks: 1,
processedCount: 1,
pendingCount: 0,
failedCount: 0,
raw: "Total links: 1\nProcessed: 1 (100.0%)\nPending: 0\nFailed: 0",
}));

const handler = new StatsCommandHandler({
Expand Down
Loading