diff --git a/CHANGELOG.md b/CHANGELOG.md index d388ea86153..1f6505718f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,10 @@ Docs: https://docs.openclaw.ai ### Fixes +- QQ Bot: make `qqbot_remind` schedule, list, and remove Gateway cron jobs + directly for owner-authorized senders instead of returning `cronParams` and + relying on a follow-up generic `cron` tool call. Fixes #70865. (#70937) + Thanks @GaosCode. - Agents/subagents: keep queued subagent announces session-only when the requester has no external channel target, avoiding ambiguous multi-channel delivery failures. Fixes #59201. Thanks @larrylhollan. diff --git a/extensions/qqbot/skills/qqbot-remind/SKILL.md b/extensions/qqbot/skills/qqbot-remind/SKILL.md index 751208ee56e..6c010b7d1c2 100644 --- a/extensions/qqbot/skills/qqbot-remind/SKILL.md +++ b/extensions/qqbot/skills/qqbot-remind/SKILL.md @@ -14,7 +14,7 @@ metadata: { "openclaw": { "emoji": "⏰", "requires": { "config": ["channels.qqb --- -## 推荐流程(优先使用 `qqbot_remind` 工具) +## 推荐流程(使用 `qqbot_remind` 工具) **第一步**:调用 `qqbot_remind` 工具,传入简单参数: @@ -26,23 +26,20 @@ metadata: { "openclaw": { "emoji": "⏰", "requires": { "config": ["channels.qqb | `time` | 时间(相对时间或 cron 表达式) | `"5m"` / `"1h30m"` / `"0 8 * * *"` | | `jobId` | 任务 ID(仅 remove) | `"xxx"` | -**第二步**:`qqbot_remind` 会返回 `cronParams`,你必须**立即**将 `cronParams` 的值作为参数调用 `cron` 工具。 - -**第三步**:根据 `cron` 工具的返回结果,回复用户。 +**第二步**:根据 `qqbot_remind` 的返回结果,回复用户。`qqbot_remind` 会直接创建、查询或取消 Gateway cron 任务;成功后不要再调用 `cron` 工具。 ### 示例 用户说:"5分钟后提醒我喝水" 1. 调用 `qqbot_remind`:`{ "action": "add", "content": "喝水", "time": "5m" }` -2. 收到返回的 `cronParams` → 立即调用 `cron` 工具,参数为该 `cronParams` -3. 回复用户:`⏰ 好的,5分钟后提醒你喝水~` +2. 工具返回成功后,回复用户:`⏰ 好的,5分钟后提醒你喝水~` --- ## 备用方案(直接使用 `cron` 工具) -> 仅当 `qqbot_remind` 工具不可用时使用以下方式。 +> 仅当 `qqbot_remind` 工具不可用但 `cron` 工具可用时使用以下方式。 ### 核心规则 @@ -113,7 +110,6 @@ metadata: { "openclaw": { "emoji": "⏰", "requires": { "config": ["channels.qqb ``` > 周期任务**不加** `deleteAfterRun`。群聊 `delivery.to` 格式为 `"qqbot:group:{group_openid}"`。 -> 若通过 `qqbot_remind` 工具生成 cronParams,**必须**原样传给 `cron` 工具,不要修改或省略任何字段,特别是 `delivery.accountId`。 --- diff --git a/extensions/qqbot/src/bridge/tools/remind.test.ts b/extensions/qqbot/src/bridge/tools/remind.test.ts new file mode 100644 index 00000000000..9945cb87b50 --- /dev/null +++ b/extensions/qqbot/src/bridge/tools/remind.test.ts @@ -0,0 +1,124 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { callGatewayToolMock } = vi.hoisted(() => ({ + callGatewayToolMock: vi.fn(), +})); + +vi.mock("openclaw/plugin-sdk/agent-harness-runtime", () => ({ + callGatewayTool: callGatewayToolMock, +})); + +import { createRemindTool } from "./remind.js"; + +describe("bridge/tools/remind", () => { + beforeEach(() => { + callGatewayToolMock.mockReset(); + callGatewayToolMock.mockResolvedValue({ ok: true }); + }); + + it("marks qqbot_remind as owner-only", () => { + const tool = createRemindTool(); + expect(tool.ownerOnly).toBe(true); + }); + + it("schedules reminders directly through Gateway cron with ambient QQ delivery context", async () => { + callGatewayToolMock.mockResolvedValue({ id: "job-1" }); + const tool = createRemindTool({ + senderIsOwner: true, + deliveryContext: { to: "qqbot:c2c:user-openid", accountId: "bot2" }, + }); + + const result = await tool.execute("tool-call-1", { + action: "add", + content: "drink water", + time: "5m", + }); + + expect(callGatewayToolMock).toHaveBeenCalledWith( + "cron.add", + { timeoutMs: 60_000 }, + { + job: expect.objectContaining({ + sessionTarget: "isolated", + payload: { kind: "agentTurn", message: expect.stringContaining("drink water") }, + delivery: { + mode: "announce", + channel: "qqbot", + to: "qqbot:c2c:user-openid", + accountId: "bot2", + }, + }), + }, + ); + expect(result.details).toEqual({ + ok: true, + action: "add", + summary: '⏰ Reminder in 5m: "drink water"', + cronResult: { id: "job-1" }, + }); + }); + + it("routes list and remove through Gateway cron without exposing generic cron to the model", async () => { + const tool = createRemindTool({ senderIsOwner: true }); + + await tool.execute("tool-call-1", { action: "list" }); + await tool.execute("tool-call-2", { action: "remove", jobId: "job-1" }); + + expect(callGatewayToolMock).toHaveBeenNthCalledWith(1, "cron.list", { timeoutMs: 60_000 }, {}); + expect(callGatewayToolMock).toHaveBeenNthCalledWith( + 2, + "cron.remove", + { timeoutMs: 60_000 }, + { jobId: "job-1" }, + ); + }); + + it("supports injected cron scheduler dependencies for engine-level tests", async () => { + const callCron = vi.fn(async () => ({ id: "job-1" })); + const tool = createRemindTool( + { + senderIsOwner: true, + deliveryContext: { to: "qqbot:c2c:user-openid", accountId: "bot2" }, + }, + { callCron }, + ); + + await tool.execute("tool-call-1", { + action: "add", + content: "drink water", + time: "5m", + }); + + expect(callCron).toHaveBeenCalledWith( + expect.objectContaining({ + action: "add", + job: expect.objectContaining({ + delivery: expect.objectContaining({ to: "qqbot:c2c:user-openid", accountId: "bot2" }), + }), + }), + ); + expect(callGatewayToolMock).not.toHaveBeenCalled(); + }); + + it("does not schedule when sender ownership is missing", async () => { + const callCron = vi.fn(async () => ({ id: "job-1" })); + const tool = createRemindTool( + { + deliveryContext: { to: "qqbot:c2c:user-openid", accountId: "bot2" }, + }, + { callCron }, + ); + + const result = await tool.execute("tool-call-1", { + action: "add", + content: "drink water", + time: "5m", + }); + + expect(callCron).not.toHaveBeenCalled(); + expect(callGatewayToolMock).not.toHaveBeenCalled(); + expect(result.details).toEqual({ + error: "QQ reminders require an owner-authorized sender.", + }); + }); +}); diff --git a/extensions/qqbot/src/bridge/tools/remind.ts b/extensions/qqbot/src/bridge/tools/remind.ts index 518046248c2..fafaaade76a 100644 --- a/extensions/qqbot/src/bridge/tools/remind.ts +++ b/extensions/qqbot/src/bridge/tools/remind.ts @@ -1,30 +1,91 @@ -import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -import { RemindSchema, executeRemind } from "../../engine/tools/remind-logic.js"; -import type { RemindParams } from "../../engine/tools/remind-logic.js"; +import { callGatewayTool } from "openclaw/plugin-sdk/agent-harness-runtime"; +import type { + AnyAgentTool, + OpenClawPluginApi, + OpenClawPluginToolContext, +} from "openclaw/plugin-sdk/core"; +import { RemindSchema, executeScheduledRemind } from "../../engine/tools/remind-logic.js"; +import type { RemindCronAction, RemindParams } from "../../engine/tools/remind-logic.js"; import { getRequestContext } from "../../engine/utils/request-context.js"; -export function registerRemindTool(api: OpenClawPluginApi): void { - api.registerTool( - { - name: "qqbot_remind", - label: "QQBot Reminder", - description: - "Create, list, and remove QQ reminders. " + - "Use simple parameters without manually building cron JSON.\n" + - "Create: action=add, content=message, time=schedule (to is optional, " + - "resolved automatically from the current conversation)\n" + - "List: action=list\n" + - "Remove: action=remove, jobId=job id from list\n" + - 'Time examples: "5m", "1h", "0 8 * * *"', - parameters: RemindSchema, - async execute(_toolCallId, params) { - const ctx = getRequestContext(); - return executeRemind(params as RemindParams, { - fallbackTo: ctx?.target, - fallbackAccountId: ctx?.accountId, - }); - }, - }, - { name: "qqbot_remind" }, - ); +type CronGatewayCaller = (params: RemindCronAction) => Promise; + +type RemindToolDeps = { + callCron: CronGatewayCaller; +}; + +const DEFAULT_GATEWAY_TIMEOUT_MS = 60_000; + +function unexpectedCronParams(params: never): never { + throw new Error(`Unsupported reminder cron action: ${JSON.stringify(params)}`); +} + +const defaultDeps: RemindToolDeps = { + callCron: async (params) => { + switch (params.action) { + case "list": + return await callGatewayTool("cron.list", { timeoutMs: DEFAULT_GATEWAY_TIMEOUT_MS }, {}); + case "remove": + return await callGatewayTool( + "cron.remove", + { timeoutMs: DEFAULT_GATEWAY_TIMEOUT_MS }, + { jobId: params.jobId }, + ); + case "add": + return await callGatewayTool( + "cron.add", + { timeoutMs: DEFAULT_GATEWAY_TIMEOUT_MS }, + { job: params.job }, + ); + } + return unexpectedCronParams(params); + }, +}; + +export function createRemindTool( + toolContext: OpenClawPluginToolContext = {}, + deps: RemindToolDeps = defaultDeps, +): AnyAgentTool { + return { + name: "qqbot_remind", + label: "QQBot Reminder", + ownerOnly: true, + description: + "Create, list, and remove QQ reminders. " + + "This tool schedules Gateway cron jobs directly; do not call the cron tool after it succeeds.\n" + + "Create: action=add, content=message, time=schedule (to is optional, " + + "resolved automatically from the current conversation)\n" + + "List: action=list\n" + + "Remove: action=remove, jobId=job id from list\n" + + 'Time examples: "5m", "1h", "0 8 * * *"', + parameters: RemindSchema, + async execute(_toolCallId, params) { + if (toolContext.senderIsOwner !== true) { + return { + content: [ + { + type: "text" as const, + text: JSON.stringify({ + error: "QQ reminders require an owner-authorized sender.", + }), + }, + ], + details: { error: "QQ reminders require an owner-authorized sender." }, + }; + } + const ctx = getRequestContext(); + return await executeScheduledRemind( + params as RemindParams, + { + fallbackTo: ctx?.target ?? toolContext.deliveryContext?.to, + fallbackAccountId: ctx?.accountId ?? toolContext.deliveryContext?.accountId, + }, + deps.callCron, + ); + }, + }; +} + +export function registerRemindTool(api: OpenClawPluginApi): void { + api.registerTool((ctx) => createRemindTool(ctx), { name: "qqbot_remind" }); } diff --git a/extensions/qqbot/src/engine/tools/remind-logic.test.ts b/extensions/qqbot/src/engine/tools/remind-logic.test.ts index 6323e3d754d..da92ab0da5e 100644 --- a/extensions/qqbot/src/engine/tools/remind-logic.test.ts +++ b/extensions/qqbot/src/engine/tools/remind-logic.test.ts @@ -6,6 +6,8 @@ import { generateJobName, buildReminderPrompt, executeRemind, + executeScheduledRemind, + prepareRemindCronAction, } from "./remind-logic.js"; describe("engine/tools/remind-logic", () => { @@ -100,53 +102,57 @@ describe("engine/tools/remind-logic", () => { }); describe("executeRemind", () => { - it("returns list instruction", () => { + it("renders internal scheduling output without exposing cronParams", () => { const result = executeRemind({ action: "list" }); expect(result.details).toEqual({ - _instruction: expect.any(String), - cronParams: { action: "list" }, + _instruction: "Gateway cron action prepared for internal QQ reminder scheduling.", + action: "list", + summary: undefined, + }); + expect((result.details as { _instruction: string })._instruction).not.toContain( + "Use the cron tool", + ); + expect(result.details).not.toHaveProperty("cronParams"); + }); + }); + + describe("prepareRemindCronAction", () => { + it("returns error when removing without jobId", () => { + const result = prepareRemindCronAction({ action: "remove" }); + expect(result).toEqual({ + ok: false, + error: "jobId is required when action=remove. Use action=list first.", }); }); - it("returns error when removing without jobId", () => { - const result = executeRemind({ action: "remove" }); - expect((result.details as { error: string }).error).toContain("jobId"); - }); - it("returns error when content is missing for add", () => { - const result = executeRemind({ action: "add", to: "qqbot:c2c:123", time: "5m" }); - expect((result.details as { error: string }).error).toContain("content"); + const result = prepareRemindCronAction({ action: "add", to: "qqbot:c2c:123", time: "5m" }); + expect(result).toEqual({ ok: false, error: "content is required when action=add" }); }); it("returns error when delay is too short", () => { - const result = executeRemind({ + const result = prepareRemindCronAction({ action: "add", content: "test", to: "qqbot:c2c:123", time: "10s", }); - expect((result.details as { error: string }).error).toContain("30 seconds"); + expect(result).toEqual({ ok: false, error: "Reminder delay must be at least 30 seconds" }); }); it("builds once job with delivery envelope for relative time", () => { - const result = executeRemind({ + const result = prepareRemindCronAction({ action: "add", content: "test reminder", to: "qqbot:c2c:123", time: "5m", }); - const details = result.details as { - cronParams: { - job: { - schedule: { kind: string }; - payload: { kind: string; message: string }; - delivery: { mode: string; channel: string; to: string; accountId: string }; - }; - }; - }; - expect(details.cronParams.job.schedule.kind).toBe("at"); - expect(details.cronParams.job.payload.kind).toBe("agentTurn"); - expect(details.cronParams.job.delivery).toEqual({ + expect(result.ok).toBe(true); + expect(result.ok ? result.cronAction.action : undefined).toBe("add"); + const job = result.ok && result.cronAction.action === "add" ? result.cronAction.job : null; + expect(job?.schedule.kind).toBe("at"); + expect(job?.payload.kind).toBe("agentTurn"); + expect(job?.delivery).toEqual({ mode: "announce", channel: "qqbot", to: "qqbot:c2c:123", @@ -155,51 +161,120 @@ describe("engine/tools/remind-logic", () => { }); it("builds cron job with delivery envelope for cron expression", () => { - const result = executeRemind({ + const result = prepareRemindCronAction({ action: "add", content: "test reminder", to: "qqbot:c2c:123", time: "0 8 * * *", }); - const details = result.details as { - cronParams: { - job: { - schedule: { kind: string }; - delivery: { channel: string; to: string; accountId: string }; - }; - }; - }; - expect(details.cronParams.job.schedule.kind).toBe("cron"); - expect(details.cronParams.job.delivery.to).toBe("qqbot:c2c:123"); + expect(result.ok).toBe(true); + const job = result.ok && result.cronAction.action === "add" ? result.cronAction.job : null; + expect(job?.schedule.kind).toBe("cron"); + expect(job?.delivery.to).toBe("qqbot:c2c:123"); }); it("falls back to ctx.fallbackTo when to is omitted", () => { - const result = executeRemind( + const result = prepareRemindCronAction( { action: "add", content: "test", time: "5m" }, { fallbackTo: "qqbot:c2c:ctx-target", fallbackAccountId: "alt" }, ); - const details = result.details as { - cronParams: { job: { delivery: { to: string; accountId: string } } }; - }; - expect(details.cronParams.job.delivery.to).toBe("qqbot:c2c:ctx-target"); - expect(details.cronParams.job.delivery.accountId).toBe("alt"); + expect(result.ok).toBe(true); + const job = result.ok && result.cronAction.action === "add" ? result.cronAction.job : null; + expect(job?.delivery.to).toBe("qqbot:c2c:ctx-target"); + expect(job?.delivery.accountId).toBe("alt"); }); it("prefers AI-supplied to over ctx fallback", () => { - const result = executeRemind( + const result = prepareRemindCronAction( { action: "add", content: "test", time: "5m", to: "qqbot:group:ai-chosen" }, { fallbackTo: "qqbot:c2c:ctx-target", fallbackAccountId: "alt" }, ); - const details = result.details as { - cronParams: { job: { delivery: { to: string; accountId: string } } }; - }; - expect(details.cronParams.job.delivery.to).toBe("qqbot:group:ai-chosen"); - expect(details.cronParams.job.delivery.accountId).toBe("alt"); + expect(result.ok).toBe(true); + const job = result.ok && result.cronAction.action === "add" ? result.cronAction.job : null; + expect(job?.delivery.to).toBe("qqbot:group:ai-chosen"); + expect(job?.delivery.accountId).toBe("alt"); }); it("returns error when neither AI nor ctx provides a target", () => { - const result = executeRemind({ action: "add", content: "test", time: "5m" }); - expect((result.details as { error: string }).error).toMatch(/delivery target/i); + const result = prepareRemindCronAction({ action: "add", content: "test", time: "5m" }); + expect(result).toEqual({ + ok: false, + error: + "Unable to determine delivery target for action=add. " + + "The reminder can only be scheduled from within an active conversation.", + }); + }); + }); + + describe("executeScheduledRemind", () => { + it("runs cron.add directly for relative reminders", async () => { + const calls: unknown[] = []; + const result = await executeScheduledRemind( + { action: "add", content: "test reminder", to: "qqbot:c2c:123", time: "5m" }, + {}, + async (params) => { + calls.push(params); + return { id: "job-1" }; + }, + ); + + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ + action: "add", + job: { + sessionTarget: "isolated", + payload: { kind: "agentTurn" }, + delivery: { + mode: "announce", + channel: "qqbot", + to: "qqbot:c2c:123", + accountId: "default", + }, + }, + }); + expect(result.details).toEqual({ + ok: true, + action: "add", + summary: '⏰ Reminder in 5m: "test reminder"', + cronResult: { id: "job-1" }, + }); + }); + + it("runs cron list and remove through the scheduler", async () => { + const calls: unknown[] = []; + await executeScheduledRemind({ action: "list" }, {}, async (params) => { + calls.push(params); + return { jobs: [] }; + }); + await executeScheduledRemind({ action: "remove", jobId: "job-1" }, {}, async (params) => { + calls.push(params); + return { ok: true }; + }); + + expect(calls).toEqual([{ action: "list" }, { action: "remove", jobId: "job-1" }]); + }); + + it("does not call scheduler when validation fails", async () => { + const result = await executeScheduledRemind({ action: "add", time: "5m" }, {}, async () => { + throw new Error("should not run"); + }); + + expect((result.details as { error: string }).error).toContain("content"); + }); + + it("returns a clear error when Gateway cron fails", async () => { + const result = await executeScheduledRemind( + { action: "remove", jobId: "job-1" }, + {}, + async () => { + throw new Error("gateway unavailable"); + }, + ); + + expect(result.details).toEqual({ + error: "Failed to run Gateway cron action: gateway unavailable", + action: "remove", + }); }); }); }); diff --git a/extensions/qqbot/src/engine/tools/remind-logic.ts b/extensions/qqbot/src/engine/tools/remind-logic.ts index 6b57c509dd0..45fde9b2c13 100644 --- a/extensions/qqbot/src/engine/tools/remind-logic.ts +++ b/extensions/qqbot/src/engine/tools/remind-logic.ts @@ -33,6 +33,31 @@ export interface RemindExecuteContext { fallbackAccountId?: string; } +export type RemindCronAction = + | { action: "list" } + | { action: "remove"; jobId: string } + | { + action: "add"; + job: ReturnType["job"] | ReturnType["job"]; + }; + +export type RemindCronScheduler = (params: RemindCronAction) => Promise; + +export type RemindCronPlan = + | { + ok: true; + action: RemindParams["action"]; + cronAction: RemindCronAction; + summary?: string; + } + | { + ok: false; + error: string; + }; + +const PREPARED_CRON_PARAMS_INSTRUCTION = + "Gateway cron action prepared for internal QQ reminder scheduling."; + /** * JSON Schema for AI tool parameters (used by framework registration). * AI Tool 参数的 JSON Schema 定义(供框架注册使用)。 @@ -161,20 +186,20 @@ export function buildOnceJob(params: RemindParams, delayMs: number, to: string, const content = params.content!; const name = params.name || generateJobName(content); return { - action: "add", + action: "add" as const, job: { name, - schedule: { kind: "at", atMs }, - sessionTarget: "isolated", - wakeMode: "now", + schedule: { kind: "at" as const, atMs }, + sessionTarget: "isolated" as const, + wakeMode: "now" as const, deleteAfterRun: true, payload: { - kind: "agentTurn", + kind: "agentTurn" as const, message: buildReminderPrompt(content), }, delivery: { - mode: "announce", - channel: "qqbot", + mode: "announce" as const, + channel: "qqbot" as const, to, accountId, }, @@ -188,19 +213,19 @@ export function buildCronJob(params: RemindParams, to: string, accountId: string const name = params.name || generateJobName(content); const tz = params.timezone || "Asia/Shanghai"; return { - action: "add", + action: "add" as const, job: { name, - schedule: { kind: "cron", expr: params.time!.trim(), tz }, - sessionTarget: "isolated", - wakeMode: "now", + schedule: { kind: "cron" as const, expr: params.time!.trim(), tz }, + sessionTarget: "isolated" as const, + wakeMode: "now" as const, payload: { - kind: "agentTurn", + kind: "agentTurn" as const, message: buildReminderPrompt(content), }, delivery: { - mode: "announce", - channel: "qqbot", + mode: "announce" as const, + channel: "qqbot" as const, to, accountId, }, @@ -233,6 +258,74 @@ function json(data: unknown) { }; } +function formatSchedulerError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export function prepareRemindCronAction( + params: RemindParams, + ctx: RemindExecuteContext = {}, +): RemindCronPlan { + if (params.action === "list") { + return { ok: true, action: "list", cronAction: { action: "list" } }; + } + + if (params.action === "remove") { + if (!params.jobId) { + return { ok: false, error: "jobId is required when action=remove. Use action=list first." }; + } + return { + ok: true, + action: "remove", + cronAction: { action: "remove", jobId: params.jobId }, + }; + } + + if (!params.content) { + return { ok: false, error: "content is required when action=add" }; + } + const resolvedTo = params.to || ctx.fallbackTo; + if (!resolvedTo) { + return { + ok: false, + error: + "Unable to determine delivery target for action=add. " + + "The reminder can only be scheduled from within an active conversation.", + }; + } + if (!params.time) { + return { ok: false, error: "time is required when action=add" }; + } + const resolvedAccountId = ctx.fallbackAccountId || "default"; + + if (isCronExpression(params.time)) { + return { + ok: true, + action: "add", + cronAction: buildCronJob(params, resolvedTo, resolvedAccountId), + summary: `⏰ Recurring reminder: "${params.content}" (${params.time}, tz=${params.timezone || "Asia/Shanghai"})`, + }; + } + + const delayMs = parseRelativeTime(params.time); + if (delayMs == null) { + return { + ok: false, + error: `Could not parse time format: ${params.time}. Use values like 5m, 1h, 1h30m, or a cron expression.`, + }; + } + if (delayMs < 30_000) { + return { ok: false, error: "Reminder delay must be at least 30 seconds" }; + } + + return { + ok: true, + action: "add", + cronAction: buildOnceJob(params, delayMs, resolvedTo, resolvedAccountId), + summary: `⏰ Reminder in ${formatDelay(delayMs)}: "${params.content}"`, + }; +} + /** * Execute the reminder tool logic. * 执行提醒工具逻辑。 @@ -246,66 +339,39 @@ function json(data: unknown) { * the request-scoped AsyncLocalStorage) to fill them in. */ export function executeRemind(params: RemindParams, ctx: RemindExecuteContext = {}) { - if (params.action === "list") { - return json({ - _instruction: "Use the cron tool immediately with the following parameters.", - cronParams: { action: "list" }, - }); + const plan = prepareRemindCronAction(params, ctx); + if (!plan.ok) { + return json({ error: plan.error }); } - - if (params.action === "remove") { - if (!params.jobId) { - return json({ - error: "jobId is required when action=remove. Use action=list first.", - }); - } - return json({ - _instruction: "Use the cron tool immediately with the following parameters.", - cronParams: { action: "remove", jobId: params.jobId }, - }); - } - - if (!params.content) { - return json({ error: "content is required when action=add" }); - } - const resolvedTo = params.to || ctx.fallbackTo; - if (!resolvedTo) { - return json({ - error: - "Unable to determine delivery target for action=add. " + - "The reminder can only be scheduled from within an active conversation.", - }); - } - if (!params.time) { - return json({ error: "time is required when action=add" }); - } - const resolvedAccountId = ctx.fallbackAccountId || "default"; - - if (isCronExpression(params.time)) { - return json({ - _instruction: - "Use the cron tool immediately with the following parameters. " + - "Pass cronParams verbatim — do not modify or omit any field, especially delivery.accountId — then tell the user the reminder has been scheduled.", - cronParams: buildCronJob(params, resolvedTo, resolvedAccountId), - summary: `⏰ Recurring reminder: "${params.content}" (${params.time}, tz=${params.timezone || "Asia/Shanghai"})`, - }); - } - - const delayMs = parseRelativeTime(params.time); - if (delayMs == null) { - return json({ - error: `Could not parse time format: ${params.time}. Use values like 5m, 1h, 1h30m, or a cron expression.`, - }); - } - if (delayMs < 30_000) { - return json({ error: "Reminder delay must be at least 30 seconds" }); - } - return json({ - _instruction: - "Use the cron tool immediately with the following parameters. " + - "Pass cronParams verbatim — do not modify or omit any field, especially delivery.accountId — then tell the user the reminder has been scheduled.", - cronParams: buildOnceJob(params, delayMs, resolvedTo, resolvedAccountId), - summary: `⏰ Reminder in ${formatDelay(delayMs)}: "${params.content}"`, + _instruction: PREPARED_CRON_PARAMS_INSTRUCTION, + action: plan.action, + summary: plan.summary, }); } + +export async function executeScheduledRemind( + params: RemindParams, + ctx: RemindExecuteContext, + scheduler: RemindCronScheduler, +) { + const plan = prepareRemindCronAction(params, ctx); + if (!plan.ok) { + return json({ error: plan.error }); + } + + try { + const cronResult = await scheduler(plan.cronAction); + return json({ + ok: true, + action: plan.action, + summary: plan.summary, + cronResult, + }); + } catch (error) { + return json({ + error: `Failed to run Gateway cron action: ${formatSchedulerError(error)}`, + action: plan.action, + }); + } +}