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
42 changes: 41 additions & 1 deletion src/handlers/StatsCommandHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
51 changes: 50 additions & 1 deletion tests/unit/StatsCommandHandler.spec.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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",
Expand Down
Loading