diff --git a/src/cli/program/message/helpers.test.ts b/src/cli/program/message/helpers.test.ts index e8f74637ffd..a4c929da45e 100644 --- a/src/cli/program/message/helpers.test.ts +++ b/src/cli/program/message/helpers.test.ts @@ -306,6 +306,116 @@ describe("runMessageAction", () => { expect(exitMock).not.toHaveBeenCalledWith(0); }); + it.each([ + [ + "poll duration hours", + "poll", + { + channel: "discord", + target: "123", + pollQuestion: "ship?", + pollOption: ["yes", "no"], + pollDurationHours: "1.5", + }, + "--poll-duration-hours", + ], + [ + "poll duration seconds", + "poll", + { + channel: "telegram", + target: "123", + pollQuestion: "ship?", + pollOption: ["yes", "no"], + pollDurationSeconds: "60s", + }, + "--poll-duration-seconds", + ], + [ + "timeout duration", + "timeout", + { guildId: "g", userId: "u", durationMin: "5m" }, + "--duration-min", + ], + ["ban delete days", "ban", { guildId: "g", userId: "u", deleteDays: "7d" }, "--delete-days"], + ["read limit", "read", { channel: "discord", target: "123", limit: "10x" }, "--limit"], + ["search limit", "search", { guildId: "g", query: "hello", limit: "10x" }, "--limit"], + ["pins limit", "list-pins", { channel: "discord", target: "123", limit: "10x" }, "--limit"], + [ + "reactions limit", + "reactions", + { channel: "discord", target: "123", messageId: "m", limit: "10x" }, + "--limit", + ], + [ + "thread auto archive minutes", + "thread-create", + { + channel: "discord", + target: "123", + threadName: "ops", + autoArchiveMin: "60m", + }, + "--auto-archive-min", + ], + ["thread list limit", "thread-list", { guildId: "g", limit: "10x" }, "--limit"], + ])("rejects malformed numeric CLI option for %s", async (_name, action, opts, flag) => { + const runMessageAction = createRunMessageAction(); + + await expect(runMessageAction(action, opts)).rejects.toThrow("exit"); + + const kind = flag === "--delete-days" ? "non-negative" : "positive"; + expect(errorMock).toHaveBeenCalledWith(`Error: ${flag} must be a ${kind} integer.`); + expect(ensurePluginRegistryLoaded).not.toHaveBeenCalled(); + expect(messageCommandMock).not.toHaveBeenCalled(); + expect(exitMock).toHaveBeenCalledWith(1); + expect(exitMock).not.toHaveBeenCalledWith(0); + }); + + it.each([ + ["pollDurationHours", "0", "--poll-duration-hours"], + ["pollDurationSeconds", "-1", "--poll-duration-seconds"], + ["durationMin", "", "--duration-min"], + ["deleteDays", Number.NaN, "--delete-days"], + ["limit", 1.2, "--limit"], + ["autoArchiveMin", null, "--auto-archive-min"], + ])("rejects non-positive or non-integer %s values", async (key, value, flag) => { + const runMessageAction = createRunMessageAction(); + + await expect( + runMessageAction("send", { + ...baseSendOptions, + [key]: value, + }), + ).rejects.toThrow("exit"); + + const kind = flag === "--delete-days" ? "non-negative" : "positive"; + expect(errorMock).toHaveBeenCalledWith(`Error: ${flag} must be a ${kind} integer.`); + expect(messageCommandMock).not.toHaveBeenCalled(); + expect(exitMock).toHaveBeenCalledWith(1); + }); + + it("allows zero delete-days for no-history Discord bans", async () => { + const runMessageAction = createRunMessageAction(); + + await expect( + runMessageAction("ban", { + guildId: "g", + userId: "u", + deleteDays: "0", + }), + ).rejects.toThrow("exit"); + + expect(errorMock).not.toHaveBeenCalled(); + expectMessageCommandOptions({ + action: "ban", + guildId: "g", + userId: "u", + deleteDays: "0", + }); + expect(exitMock).toHaveBeenCalledWith(0); + }); + it("runs gateway_stop hooks before exit when registered", async () => { hasHooksMock.mockReturnValueOnce(true); await runSendAction(); diff --git a/src/cli/program/message/helpers.ts b/src/cli/program/message/helpers.ts index def381c726f..739945a0eb0 100644 --- a/src/cli/program/message/helpers.ts +++ b/src/cli/program/message/helpers.ts @@ -8,6 +8,10 @@ import { resolveMessageSecretScope } from "../../../cli/message-secret-scope.js" import { messageCommand } from "../../../commands/message.js"; import { danger, setVerbose } from "../../../globals.js"; import { CHANNEL_TARGET_DESCRIPTION } from "../../../infra/outbound/channel-target.js"; +import { + parseStrictNonNegativeInteger, + parseStrictPositiveInteger, +} from "../../../infra/parse-finite-number.js"; import { runGlobalGatewayStopSafely } from "../../../plugins/hook-runner-global.js"; import { defaultRuntime } from "../../../runtime.js"; import { runCommandWithRuntime } from "../../cli-utils.js"; @@ -25,6 +29,14 @@ const GATEWAY_STOP_TIMEOUT_MS = 2500; const ACTIONS_WITHOUT_STOP_HOOKS = new Set(["read"]); const ACTIONS_REQUIRING_CONFIGURED_CHANNEL_PRELOAD = new Set(["broadcast"]); const CHANNEL_MESSAGE_ACTION_NAME_SET = new Set(CHANNEL_MESSAGE_ACTION_NAMES); +const STRICT_POSITIVE_INTEGER_OPTIONS = new Map([ + ["pollDurationHours", "--poll-duration-hours"], + ["pollDurationSeconds", "--poll-duration-seconds"], + ["durationMin", "--duration-min"], + ["limit", "--limit"], + ["autoArchiveMin", "--auto-archive-min"], +]); +const STRICT_NON_NEGATIVE_INTEGER_OPTIONS = new Map([["deleteDays", "--delete-days"]]); type MessagePluginLoadOptions = { scope: PluginRegistryScope; onlyChannelIds?: string[] }; type MessagePluginPreloadPlan = @@ -39,6 +51,25 @@ function normalizeMessageOptions(opts: Record): Record): void { + for (const [key, flag] of STRICT_POSITIVE_INTEGER_OPTIONS) { + if (opts[key] === undefined) { + continue; + } + if (parseStrictPositiveInteger(opts[key]) === undefined) { + throw new Error(`${flag} must be a positive integer.`); + } + } + for (const [key, flag] of STRICT_NON_NEGATIVE_INTEGER_OPTIONS) { + if (opts[key] === undefined) { + continue; + } + if (parseStrictNonNegativeInteger(opts[key]) === undefined) { + throw new Error(`${flag} must be a non-negative integer.`); + } + } +} + async function runPluginStopHooks(): Promise { let timeout: NodeJS.Timeout | null = null; const hookRun = runGlobalGatewayStopSafely({ @@ -128,6 +159,7 @@ export function createMessageCliHelpers( await runCommandWithRuntime( defaultRuntime, async () => { + validateMessageNumericOptions(opts); const preloadPlan = resolveMessagePluginPreloadPlan(action, opts); if (preloadPlan.preload) { ensurePluginRegistryLoaded(preloadPlan.loadOptions);