fix(cron): support Telegram thread IDs in cron add/edit

- Add `--thread-id` support to cron add/edit Telegram delivery.
- Reject non-positive thread IDs and guard cron edit lookup pagination against non-progress/max-page loops.
- Preserve existing delivery mode on thread-only cron edit patches.

Carries forward #51581, #60373, and #60890.

Co-authored-by: ChunHao Chen <crazycjh@gmail.com>
This commit is contained in:
Vincent Koc
2026-04-28 01:50:44 -07:00
committed by GitHub
parent 02908db62b
commit 76cd97289b
6 changed files with 334 additions and 12 deletions

View File

@@ -19,6 +19,7 @@ import {
printCronList,
warnIfCronSchedulerDisabled,
} from "./shared.js";
import { normalizeCronSessionTargetOption, parseCronThreadIdOption } from "./thread-id-shared.js";
export function registerCronStatusCommand(cron: Command) {
addGatewayClientOptions(
@@ -105,6 +106,7 @@ export function registerCronAddCommand(cron: Command) {
"--to <dest>",
"Delivery destination (E.164, Telegram chatId, or Discord channel/user)",
)
.option("--thread-id <id>", "Telegram forum topic thread id")
.option("--account <id>", "Channel account id for delivery (multi-account setups)")
.option("--best-effort-deliver", "Do not fail the job if delivery fails", false)
.option("--json", "Output JSON", false)
@@ -165,7 +167,9 @@ export function registerCronAddCommand(cron: Command) {
const sessionTargetRaw = normalizeOptionalString(opts.session) ?? "";
const inferredSessionTarget = payload.kind === "agentTurn" ? "isolated" : "main";
const sessionTarget =
sessionSource === "cli" ? sessionTargetRaw || "" : inferredSessionTarget;
sessionSource === "cli"
? normalizeCronSessionTargetOption(sessionTargetRaw) || ""
: inferredSessionTarget;
const isCustomSessionTarget =
normalizeLowercaseStringOrEmpty(sessionTarget).startsWith("session:") &&
Boolean(normalizeOptionalString(sessionTarget.slice(8)));
@@ -193,9 +197,16 @@ export function registerCronAddCommand(cron: Command) {
}
const accountId = normalizeOptionalString(opts.account);
const threadId = parseCronThreadIdOption(opts.threadId);
const hasThreadId = typeof threadId === "number";
if (accountId && (!isIsolatedLikeSessionTarget || payload.kind !== "agentTurn")) {
throw new Error("--account requires a non-main agentTurn job with delivery.");
if (
(accountId || hasThreadId) &&
(!isIsolatedLikeSessionTarget || payload.kind !== "agentTurn")
) {
throw new Error(
"--account and --thread-id require a non-main agentTurn job with delivery.",
);
}
const deliveryMode =
@@ -232,6 +243,7 @@ export function registerCronAddCommand(cron: Command) {
mode: deliveryMode,
channel: normalizeOptionalString(opts.channel),
to: normalizeOptionalString(opts.to),
threadId,
accountId,
bestEffort: opts.bestEffortDeliver ? true : undefined,
}

View File

@@ -18,6 +18,10 @@ import {
parseDurationMs,
warnIfCronSchedulerDisabled,
} from "./shared.js";
import { normalizeCronSessionTargetOption, parseCronThreadIdOption } from "./thread-id-shared.js";
const CRON_EDIT_LOOKUP_PAGE_SIZE = 200;
const CRON_EDIT_LOOKUP_MAX_PAGES = 50;
const assignIf = (
target: Record<string, unknown>,
@@ -30,6 +34,32 @@ const assignIf = (
}
};
async function loadCronJobForEditSchedulePatch(
opts: Record<string, unknown>,
id: string,
): Promise<CronJob | undefined> {
let offset = 0;
for (let page = 0; page < CRON_EDIT_LOOKUP_MAX_PAGES; page += 1) {
const listed = (await callGatewayFromCli("cron.list", opts, {
includeDisabled: true,
limit: CRON_EDIT_LOOKUP_PAGE_SIZE,
offset,
})) as { jobs?: CronJob[]; hasMore?: boolean; nextOffset?: number | null } | null;
const existing = (listed?.jobs ?? []).find((job) => job.id === id);
if (existing) {
return existing;
}
if (!listed?.hasMore || typeof listed.nextOffset !== "number") {
return undefined;
}
if (listed.nextOffset <= offset) {
throw new Error("cron.list pagination did not advance while looking up cron job");
}
offset = listed.nextOffset;
}
throw new Error("cron.list pagination exceeded maximum pages while looking up cron job");
}
export function registerCronEditCommand(cron: Command) {
addGatewayClientOptions(
cron
@@ -74,6 +104,7 @@ export function registerCronEditCommand(cron: Command) {
"--to <dest>",
"Delivery destination (E.164, Telegram chatId, or Discord channel/user)",
)
.option("--thread-id <id>", "Telegram forum topic thread id")
.option("--account <id>", "Channel account id for delivery (multi-account setups)")
.option("--best-effort-deliver", "Do not fail job if delivery fails")
.option("--no-best-effort-deliver", "Fail job when delivery fails")
@@ -95,12 +126,24 @@ export function registerCronEditCommand(cron: Command) {
)
.action(async (id, opts) => {
try {
if (opts.session === "main" && opts.message) {
const sessionTarget =
typeof opts.session === "string"
? normalizeCronSessionTargetOption(opts.session)
: undefined;
if (typeof opts.session === "string" && !sessionTarget) {
throw new Error("--session must be main, isolated, current, or session:<id>");
}
if (sessionTarget === "main" && opts.message) {
throw new Error(
"Main jobs cannot use --message; use --system-event or --session isolated.",
);
}
if (opts.session === "isolated" && opts.systemEvent) {
if (
(sessionTarget === "isolated" ||
sessionTarget === "current" ||
sessionTarget?.startsWith("session:")) &&
opts.systemEvent
) {
throw new Error(
"Isolated jobs cannot use --system-event; use --message or --session main.",
);
@@ -134,7 +177,7 @@ export function registerCronEditCommand(cron: Command) {
patch.deleteAfterRun = false;
}
if (typeof opts.session === "string") {
patch.sessionTarget = opts.session;
patch.sessionTarget = sessionTarget;
}
if (typeof opts.wake === "string") {
patch.wakeMode = opts.wake;
@@ -169,10 +212,7 @@ export function registerCronEditCommand(cron: Command) {
if (scheduleRequest.kind === "direct") {
patch.schedule = scheduleRequest.schedule;
} else if (scheduleRequest.kind === "patch-existing-cron") {
const listed = (await callGatewayFromCli("cron.list", opts, {
includeDisabled: true,
})) as { jobs?: CronJob[] } | null;
const existing = (listed?.jobs ?? []).find((job) => job.id === id);
const existing = await loadCronJobForEditSchedulePatch(opts, String(id));
if (!existing) {
throw new Error(`unknown cron job id: ${id}`);
}
@@ -188,7 +228,10 @@ export function registerCronEditCommand(cron: Command) {
: undefined;
const hasTimeoutSeconds = Boolean(timeoutSeconds && Number.isFinite(timeoutSeconds));
const hasDeliveryModeFlag = opts.announce || typeof opts.deliver === "boolean";
const hasDeliveryTarget = typeof opts.channel === "string" || typeof opts.to === "string";
const threadId = parseCronThreadIdOption(opts.threadId);
const hasDeliveryThreadId = typeof threadId === "number";
const hasDeliveryTarget =
typeof opts.channel === "string" || typeof opts.to === "string" || hasDeliveryThreadId;
const hasDeliveryAccount = typeof opts.account === "string";
const hasBestEffort = typeof opts.bestEffortDeliver === "boolean";
const hasAgentTurnPatch =
@@ -248,6 +291,9 @@ export function registerCronEditCommand(cron: Command) {
const to = opts.to.trim();
delivery.to = to ? to : undefined;
}
if (hasDeliveryThreadId) {
delivery.threadId = threadId;
}
if (typeof opts.account === "string") {
const account = opts.account.trim();
delivery.accountId = account ? account : undefined;

View File

@@ -0,0 +1,35 @@
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "../../shared/string-coerce.js";
export function parseCronThreadIdOption(value: unknown): number | undefined {
const raw = normalizeOptionalString(value);
if (!raw) {
return undefined;
}
if (!/^\d+$/.test(raw)) {
throw new Error("--thread-id must be a positive integer Telegram topic thread id");
}
const parsed = Number.parseInt(raw, 10);
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
throw new Error("--thread-id must be a safe positive integer Telegram topic thread id");
}
return parsed;
}
export function normalizeCronSessionTargetOption(value: unknown): string | undefined {
const raw = normalizeOptionalString(value);
if (!raw) {
return undefined;
}
const lower = normalizeLowercaseStringOrEmpty(raw);
if (lower === "main" || lower === "isolated" || lower === "current") {
return lower;
}
if (lower.startsWith("session:")) {
const id = normalizeOptionalString(raw.slice(8));
return id ? `session:${id}` : undefined;
}
return undefined;
}