diff --git a/src/bot/cli-runner.ts b/src/bot/cli-runner.ts index 95d43ab..5509e1a 100644 --- a/src/bot/cli-runner.ts +++ b/src/bot/cli-runner.ts @@ -719,21 +719,16 @@ export async function runSummaryCommand( */ export async function runStatsCommand( options: RunnerOptions = {} -): Promise { +): 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; @@ -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") }; } /** diff --git a/src/discord/client.ts b/src/discord/client.ts index 2f51eaa..906d1e1 100644 --- a/src/discord/client.ts +++ b/src/discord/client.ts @@ -39,15 +39,23 @@ export class DiscordBot { return this.client; } - async start(): Promise { - 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 { + this.client.once("clientReady", (client) => { + this.handleReady(client); }); this.client.on("messageCreate", async (message) => { diff --git a/src/handlers/StatsCommandHandler.ts b/src/handlers/StatsCommandHandler.ts index c114988..270685f 100644 --- a/src/handlers/StatsCommandHandler.ts +++ b/src/handlers/StatsCommandHandler.ts @@ -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."; @@ -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 { @@ -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) { @@ -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"); - } } diff --git a/tests/discord/interaction.test.ts b/tests/discord/interaction.test.ts index 9ea213a..718cfe7 100644 --- a/tests/discord/interaction.test.ts +++ b/tests/discord/interaction.test.ts @@ -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 () => { @@ -153,6 +158,7 @@ describe("slash interaction handlers", () => { authorId: "user-1", }); expect(edits).toContain( + "```markdown\n" + [ "📊 OpenBrain statistics", "", @@ -161,7 +167,8 @@ describe("slash interaction handlers", () => { "- Processed: 80 (80.0%)", "- Pending: 15", "- Failed: 5 (5.0%)", - ].join("\n") + ].join("\n") + + "\n```" ); }); diff --git a/tests/unit/StatsCommandHandler.spec.ts b/tests/unit/StatsCommandHandler.spec.ts index 12c75a3..f990a40 100644 --- a/tests/unit/StatsCommandHandler.spec.ts +++ b/tests/unit/StatsCommandHandler.spec.ts @@ -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({ @@ -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```" ); }); @@ -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({