mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:40:44 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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`。
|
||||
|
||||
---
|
||||
|
||||
|
||||
124
extensions/qqbot/src/bridge/tools/remind.test.ts
Normal file
124
extensions/qqbot/src/bridge/tools/remind.test.ts
Normal 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.",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user