diff --git a/CHANGELOG.md b/CHANGELOG.md index 37fb877eb84..31d34fb8a6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai - Providers/Cloudflare AI Gateway: strip assistant prefill turns from Anthropic Messages payloads when thinking is enabled, so Claude requests through Cloudflare AI Gateway no longer fail Anthropic conversation-ending validation. Fixes #72905; carries forward #73005. Thanks @AaronFaby and @sahilsatralkar. - Channels/sessions: prevent guarded inbound session recording from creating route-only phantom sessions while still allowing last-route updates for sessions that already exist. Carries forward #73009. Thanks @jzakirov. +- Cron: accept `delivery.threadId` in Gateway cron add/update schemas so scheduled announce delivery can target Telegram forum topics and other threaded channel destinations through the documented delivery path. Fixes #73017. Thanks @coachsootz. - Plugins/runtime deps: stage bundled plugin dependencies imported by mirrored root dist chunks, so packaged memory and status commands do not miss `chokidar` or similar root-chunk dependencies after update. Fixes #72882 and #72970; carries forward #72992. Thanks @shrimpy8, @colin-chang, and @Schnup03. - Agents/runtime context: deliver hidden runtime context through prompt-local system context while keeping the transcript-only custom entry out of provider user turns, and strip stale copied runtime-context prefaces from user-facing replies. Fixes #72386; carries forward #72969. Thanks @jhsmith409. - Channels/Telegram: skip the optional webhook-info API call during polling-mode status checks and startup bot-label probes so long-polling setups avoid an unnecessary Telegram round trip. Carries forward #72990. Thanks @danielgruneberg. diff --git a/docs/automation/cron-jobs.md b/docs/automation/cron-jobs.md index 8cf035b86ad..ca3f931cde9 100644 --- a/docs/automation/cron-jobs.md +++ b/docs/automation/cron-jobs.md @@ -150,7 +150,7 @@ If an isolated run hits a live model-switch handoff, cron retries with the switc | `webhook` | POST finished event payload to a URL | | `none` | No runner fallback delivery | -Use `--announce --channel telegram --to "-1001234567890"` for channel delivery. For Telegram forum topics, use `-1001234567890:topic:123`. Slack/Discord/Mattermost targets should use explicit prefixes (`channel:`, `user:`). Matrix room IDs are case-sensitive; use the exact room ID or `room:!room:server` form from Matrix. +Use `--announce --channel telegram --to "-1001234567890"` for channel delivery. For Telegram forum topics, use `-1001234567890:topic:123`; direct RPC/config callers may also pass `delivery.threadId` as a string or number. Slack/Discord/Mattermost targets should use explicit prefixes (`channel:`, `user:`). Matrix room IDs are case-sensitive; use the exact room ID or `room:!room:server` form from Matrix. For isolated jobs, chat delivery is shared. If a chat route is available, the agent can use the `message` tool even when the job uses `--no-deliver`. If the agent sends to the configured/current target, OpenClaw skips the fallback announce. Otherwise `announce`, `webhook`, and `none` only control what the runner does with the final reply after the agent turn. diff --git a/src/agents/tools/cron-tool.test.ts b/src/agents/tools/cron-tool.test.ts index 38194e00c37..9b615dcd2ea 100644 --- a/src/agents/tools/cron-tool.test.ts +++ b/src/agents/tools/cron-tool.test.ts @@ -15,6 +15,12 @@ vi.mock("../agent-scope.js", async () => { import { createCronTool } from "./cron-tool.js"; describe("cron tool", () => { + type SchemaLike = { + anyOf?: Array<{ type?: string }>; + description?: string; + properties?: Record; + }; + type TestDelivery = { mode?: string; channel?: string; @@ -145,6 +151,18 @@ describe("cron tool", () => { ); }); + it("advertises delivery threadId in the tool schema", () => { + const tool = createTestCronTool(); + const parameters = tool.parameters as SchemaLike; + const jobThreadId = parameters.properties?.job?.properties?.delivery?.properties?.threadId; + const patchThreadId = parameters.properties?.patch?.properties?.delivery?.properties?.threadId; + + for (const threadId of [jobThreadId, patchThreadId]) { + expect(threadId?.description).toContain("Thread/topic id"); + expect(threadId?.anyOf?.map((entry) => entry.type)).toEqual(["string", "number"]); + } + }); + it.each([ [ "update", diff --git a/src/agents/tools/cron-tool.ts b/src/agents/tools/cron-tool.ts index 325a9990506..ef67bb49658 100644 --- a/src/agents/tools/cron-tool.ts +++ b/src/agents/tools/cron-tool.ts @@ -183,6 +183,11 @@ const CronDeliverySchema = Type.Optional( mode: optionalStringEnum(CRON_DELIVERY_MODES, { description: "Delivery mode" }), channel: Type.Optional(Type.String({ description: "Delivery channel" })), to: Type.Optional(Type.String({ description: "Delivery target" })), + threadId: Type.Optional( + Type.Union([Type.String(), Type.Number()], { + description: "Thread/topic id for channels that support threaded delivery", + }), + ), bestEffort: Type.Optional(Type.Boolean()), accountId: Type.Optional(Type.String({ description: "Account target for delivery" })), failureDestination: Type.Optional( @@ -576,9 +581,10 @@ PAYLOAD TYPES (payload.kind): { "kind": "agentTurn", "message": "", "model": "", "thinking": "", "timeoutSeconds": } DELIVERY (top-level): - { "mode": "none|announce|webhook", "channel": "", "to": "", "bestEffort": } + { "mode": "none|announce|webhook", "channel": "", "to": "", "threadId": "", "bestEffort": } - Default for isolated agentTurn jobs (when delivery omitted): "announce" - announce: send to chat channel (optional channel/to target) + - threadId: chat thread/topic id for channels that support threaded delivery - webhook: send finished-run event as HTTP POST to delivery.to (URL required) - If the task needs to send to a specific chat/recipient, set announce delivery.channel/to; do not call messaging tools inside the run. diff --git a/src/gateway/protocol/schema/cron.ts b/src/gateway/protocol/schema/cron.ts index 8b7cb3dc5f8..9411f61f1ba 100644 --- a/src/gateway/protocol/schema/cron.ts +++ b/src/gateway/protocol/schema/cron.ts @@ -183,6 +183,7 @@ export const CronFailureDestinationSchema = Type.Object( const CronDeliverySharedProperties = { channel: Type.Optional(Type.Union([Type.Literal("last"), NonEmptyString])), + threadId: Type.Optional(Type.Union([Type.String(), Type.Number()])), accountId: Type.Optional(NonEmptyString), bestEffort: Type.Optional(Type.Boolean()), failureDestination: Type.Optional(CronFailureDestinationSchema), diff --git a/src/gateway/server-methods/cron.validation.test.ts b/src/gateway/server-methods/cron.validation.test.ts index ea379fefd84..9a0922a7abc 100644 --- a/src/gateway/server-methods/cron.validation.test.ts +++ b/src/gateway/server-methods/cron.validation.test.ts @@ -82,6 +82,93 @@ describe("cron method validation", () => { getRuntimeConfig.mockReset().mockReturnValue({} as OpenClawConfig); }); + it("accepts threadId on announce delivery add params", async () => { + getRuntimeConfig.mockReturnValue({ + channels: { + telegram: { + botToken: "telegram-token", + }, + }, + plugins: { + entries: { + telegram: { enabled: true }, + }, + }, + } as OpenClawConfig); + + const { context, respond } = await invokeCronAdd({ + name: "topic announce add", + enabled: true, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "isolated", + wakeMode: "next-heartbeat", + payload: { kind: "agentTurn", message: "hello" }, + delivery: { + mode: "announce", + channel: "telegram", + to: "-1001234567890", + threadId: 123, + }, + }); + + expect(context.cron.add).toHaveBeenCalledWith( + expect.objectContaining({ + delivery: expect.objectContaining({ + mode: "announce", + channel: "telegram", + to: "-1001234567890", + threadId: 123, + }), + }), + ); + expect(respond).toHaveBeenCalledWith(true, { id: "cron-1" }, undefined); + }); + + it("accepts threadId on announce delivery update params", async () => { + getRuntimeConfig.mockReturnValue({ + channels: { + telegram: { + botToken: "telegram-token", + }, + }, + plugins: { + entries: { + telegram: { enabled: true }, + }, + }, + } as OpenClawConfig); + + const { context, respond } = await invokeCronUpdate( + { + id: "cron-1", + patch: { + delivery: { + mode: "announce", + channel: "telegram", + to: "-1001234567890", + threadId: "456", + }, + }, + }, + createCronJob({ + delivery: { mode: "announce", channel: "telegram", to: "-1001234567890" }, + }), + ); + + expect(context.cron.update).toHaveBeenCalledWith( + "cron-1", + expect.objectContaining({ + delivery: expect.objectContaining({ + mode: "announce", + channel: "telegram", + to: "-1001234567890", + threadId: "456", + }), + }), + ); + expect(respond).toHaveBeenCalledWith(true, { id: "cron-1" }, undefined); + }); + it("rejects ambiguous announce delivery on add when multiple channels are configured", async () => { getRuntimeConfig.mockReturnValue({ session: {