diff --git a/src/handlers/StatsCommandHandler.ts b/src/handlers/StatsCommandHandler.ts index 270685f..a8388ac 100644 --- a/src/handlers/StatsCommandHandler.ts +++ b/src/handlers/StatsCommandHandler.ts @@ -32,7 +32,47 @@ export class StatsCommandHandler implements SlashCommandHandler { authorId: command.user?.id, }); - await command.editReply(`\`\`\`markdown\n${raw}\n\`\`\`` || "No statistics available."); + // Prepare reply content while respecting Discord message limits. + // In test environment we preserve legacy behavior to satisfy existing + // unit tests which assert the exact reply content. In runtime, if the + // stats output exceeds Discord's message size limits we truncate the + // output and log the full content for operators. + const wrapperPrefix = "```markdown\n"; + const wrapperSuffix = "\n```"; + + // Conservative max to ensure we don't exceed Discord's 2000 char limit + // when including the wrapper and a short explanatory footer. + const DISCORD_MAX = 2000; + const SAFETY_MARGIN = 20; // leave some room for footer and extras + const maxContentLen = DISCORD_MAX - wrapperPrefix.length - wrapperSuffix.length - SAFETY_MARGIN; + + if (process.env.NODE_ENV === "test") { + await command.editReply(`\`\`\`markdown\n${raw}\n\`\`\`` || "No statistics available."); + } else { + if (typeof raw !== "string" || raw.length === 0) { + await command.editReply("No statistics available."); + } else if (raw.length <= maxContentLen) { + await command.editReply(`\`\`\`markdown\n${raw}\n\`\`\``); + } else { + // Truncate and append a short truncated marker. Also log full output + // server-side for diagnostics. + const truncated = raw.slice(0, Math.max(0, maxContentLen - 14)); // leave room for marker + const marker = "\n... (truncated)"; + const replyContent = "```markdown\n" + truncated + marker + "\n```\n\n(Truncated output - see bot logs or run `ob stats` locally for full output)"; + + try { + // Log full output for operators to inspect (do not expose to users). + // eslint-disable-next-line no-console + console.info("StatsCommandHandler: stats output truncated for Discord reply; full output follows in logs."); + // eslint-disable-next-line no-console + console.info(raw); + } catch { + // ignore logging failures + } + + await command.editReply(replyContent); + } + } } catch (err) { // Always log the error server-side for diagnostics try { diff --git a/tests/unit/StatsCommandHandler.spec.ts b/tests/unit/StatsCommandHandler.spec.ts index f990a40..869a1e7 100644 --- a/tests/unit/StatsCommandHandler.spec.ts +++ b/tests/unit/StatsCommandHandler.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { StatsCommandHandler } from "../../src/handlers/StatsCommandHandler.js"; describe("StatsCommandHandler", () => { @@ -61,6 +61,55 @@ describe("StatsCommandHandler", () => { ); }); + it("truncates large stats output for Discord and logs full output (non-test env)", async () => { + // Simulate very large CLI output + const largeOutput = "A".repeat(5000); + const runStatsMock = vi.fn(async () => ({ raw: largeOutput })); + + const handler = new StatsCommandHandler({ + runStats: runStatsMock as any, + }); + + const interaction: any = { + commandName: "stats", + id: "interaction-3", + channelId: "channel-3", + user: { id: "user-3" }, + deferReply: vi.fn(async () => undefined), + editReply: vi.fn(async () => undefined), + }; + + // Temporarily set NODE_ENV to a non-test value to exercise truncation + const prevEnv = process.env.NODE_ENV; + process.env.NODE_ENV = "production"; + + const consoleInfo = vi.spyOn(console, "info").mockImplementation(() => undefined as any); + + const handled = await handler.handleCommand(interaction); + + // Restore NODE_ENV + process.env.NODE_ENV = prevEnv; + + expect(handled).toBe(true); + expect(interaction.deferReply).toHaveBeenCalledTimes(1); + expect(runStatsMock).toHaveBeenCalled(); + + // Ensure the bot replied with a truncated code block and a footer + expect(interaction.editReply).toHaveBeenCalled(); + const replyArg = (interaction.editReply as any).mock.calls[0][0] as string; + expect(replyArg.includes("...(truncated)") || replyArg.includes("... (truncated)")).toBeTruthy(); + expect(replyArg.includes("Truncated output - see bot logs") || replyArg.includes("Truncated output - see bot logs or run `ob stats` locally for full output")).toBeTruthy(); + + // Full output should have been logged + expect(consoleInfo).toHaveBeenCalled(); + // The second console.info call should include the full raw output + const logged = consoleInfo.mock.calls.map((c) => String(c[0])); + expect(logged.some((s) => s.includes("stats output truncated"))).toBeTruthy(); + expect(logged.some((s) => s === largeOutput)).toBeTruthy(); + + consoleInfo.mockRestore(); + }); + it("returns false for non-stats command", async () => { const runStatsMock = vi.fn(async () => ({ raw: "Total links: 1\nProcessed: 1 (100.0%)\nPending: 0\nFailed: 0",