fix(qqbot): schedule reminders through cron gateway (#70937)

* fix(qqbot): schedule reminders through cron gateway

* fix(qqbot): update reminder cron instruction

* fix(qqbot): schedule reminders directly (#70937) (thanks @GaosCode)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
MrBrain
2026-04-26 07:15:28 +08:00
committed by GitHub
parent 73cacebac3
commit 28497515fe
6 changed files with 484 additions and 158 deletions

View File

@@ -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.

View File

@@ -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`。
---

View File

@@ -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.",
});
});
});

View File

@@ -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<unknown>;
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" });
}

View File

@@ -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",
});
});
});
});

View File

@@ -33,6 +33,31 @@ export interface RemindExecuteContext {
fallbackAccountId?: string;
}
export type RemindCronAction =
| { action: "list" }
| { action: "remove"; jobId: string }
| {
action: "add";
job: ReturnType<typeof buildOnceJob>["job"] | ReturnType<typeof buildCronJob>["job"];
};
export type RemindCronScheduler = (params: RemindCronAction) => Promise<unknown>;
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,
});
}
}