diff --git a/src/gateway/server-methods/cron.ts b/src/gateway/server-methods/cron.ts index 7eccb895534..e854b416e65 100644 --- a/src/gateway/server-methods/cron.ts +++ b/src/gateway/server-methods/cron.ts @@ -1,3 +1,4 @@ +import { loadConfig } from "../../config/config.js"; import { normalizeCronJobCreate, normalizeCronJobPatch } from "../../cron/normalize.js"; import { readCronRunLogEntriesPage, @@ -6,6 +7,7 @@ import { } from "../../cron/run-log.js"; import type { CronJobCreate, CronJobPatch } from "../../cron/types.js"; import { validateScheduleTimestamp } from "../../cron/validate-timestamp.js"; +import { listConfiguredMessageChannels } from "../../infra/outbound/channel-selection.js"; import { ErrorCodes, errorShape, @@ -21,6 +23,33 @@ import { } from "../protocol/index.js"; import type { GatewayRequestHandlers } from "./types.js"; +/** + * Validates that announce-mode delivery has an explicit channel when multiple + * channels are configured. Without this, delivery silently fails at runtime + * because the "last" channel fallback cannot resolve an unambiguous target. + * See: https://github.com/openclaw/openclaw/issues/47524 + */ +async function validateDeliveryChannelForAnnounce( + delivery: Record | undefined, +): Promise { + if (!delivery || typeof delivery !== "object") { + return null; + } + const mode = typeof delivery.mode === "string" ? delivery.mode.trim().toLowerCase() : undefined; + if (mode !== "announce") { + return null; + } + if (typeof delivery.channel === "string" && delivery.channel.trim()) { + return null; + } + const cfg = loadConfig(); + const configured = await listConfiguredMessageChannels(cfg); + if (configured.length > 1) { + return `delivery.channel is required when mode is "announce" and multiple channels are configured (${configured.join(", ")}). Set --channel explicitly.`; + } + return null; +} + export const cronHandlers: GatewayRequestHandlers = { wake: ({ params, respond, context }) => { if (!validateWakeParams(params)) { @@ -118,6 +147,13 @@ export const cronHandlers: GatewayRequestHandlers = { ); return; } + const deliveryChannelError = await validateDeliveryChannelForAnnounce( + (jobCreate as { delivery?: Record }).delivery, + ); + if (deliveryChannelError) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, deliveryChannelError)); + return; + } const job = await context.cron.add(jobCreate); context.logGateway.info("cron: job created", { jobId: job.id, schedule: jobCreate.schedule }); respond(true, job, undefined); @@ -165,6 +201,15 @@ export const cronHandlers: GatewayRequestHandlers = { return; } } + if (patch.delivery) { + const deliveryChannelError = await validateDeliveryChannelForAnnounce( + patch.delivery as Record, + ); + if (deliveryChannelError) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, deliveryChannelError)); + return; + } + } const job = await context.cron.update(jobId, patch); context.logGateway.info("cron: job updated", { jobId }); respond(true, job, undefined);