@zhin.js/plugin-reminder 0.0.0-stage → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +87 -2
- package/commands/remind/after/index.js +16 -0
- package/commands/remind/after/index.ts +16 -0
- package/commands/remind/at/index.js +22 -0
- package/commands/remind/at/index.ts +16 -0
- package/commands/remind/cancel/index.js +11 -0
- package/commands/remind/cancel/index.ts +11 -0
- package/commands/remind/every/index.js +16 -0
- package/commands/remind/every/index.ts +16 -0
- package/commands/remind/list/index.js +14 -0
- package/commands/remind/list/index.ts +14 -0
- package/lib/caller.d.ts +5 -0
- package/lib/caller.d.ts.map +1 -0
- package/lib/caller.js +18 -0
- package/lib/caller.js.map +1 -0
- package/lib/config.d.ts +17 -0
- package/lib/config.d.ts.map +1 -0
- package/lib/config.js +17 -0
- package/lib/config.js.map +1 -0
- package/lib/duration.d.ts +2 -0
- package/lib/duration.d.ts.map +1 -0
- package/lib/duration.js +31 -0
- package/lib/duration.js.map +1 -0
- package/lib/index.d.ts +5 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +5 -0
- package/lib/index.js.map +1 -0
- package/lib/runtime.d.ts +3 -0
- package/lib/runtime.d.ts.map +1 -0
- package/lib/runtime.js +3 -0
- package/lib/runtime.js.map +1 -0
- package/lib/service.d.ts +42 -0
- package/lib/service.d.ts.map +1 -0
- package/lib/service.js +180 -0
- package/lib/service.js.map +1 -0
- package/lib/tool-actor.d.ts +20 -0
- package/lib/tool-actor.d.ts.map +1 -0
- package/lib/tool-actor.js +38 -0
- package/lib/tool-actor.js.map +1 -0
- package/package.json +94 -3
- package/plugin.js +41 -0
- package/schedules/poll/index.js +8 -0
- package/schedules/poll/index.ts +8 -0
- package/skills/reminder/SKILL.md +45 -0
- package/skills/reminder/tools/reminder_cancel/index.js +24 -0
- package/skills/reminder/tools/reminder_cancel/index.ts +25 -0
- package/skills/reminder/tools/reminder_create/index.js +57 -0
- package/skills/reminder/tools/reminder_create/index.ts +77 -0
- package/skills/reminder/tools/reminder_list/index.js +22 -0
- package/skills/reminder/tools/reminder_list/index.ts +22 -0
- package/src/caller.ts +13 -0
- package/src/config.ts +16 -0
- package/src/duration.ts +31 -0
- package/src/index.ts +4 -0
- package/src/runtime.ts +4 -0
- package/src/service.ts +171 -0
- package/src/tool-actor.ts +52 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { defineAgentTool } from "@zhin.js/tool";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { replyReminder } from "../../../../src/caller.js";
|
|
4
|
+
import { parseRelativeDelayMs } from "../../../../src/duration.js";
|
|
5
|
+
import { reminderServiceToken } from "../../../../src/runtime.js";
|
|
6
|
+
import {
|
|
7
|
+
ReminderInputError,
|
|
8
|
+
parseReminderInstant,
|
|
9
|
+
} from "../../../../src/service.js";
|
|
10
|
+
import { reminderActorFromTool } from "../../../../src/tool-actor.js";
|
|
11
|
+
|
|
12
|
+
export default defineAgentTool({
|
|
13
|
+
description:
|
|
14
|
+
"Create a personal reminder that delivers a message back to the current chat. " +
|
|
15
|
+
"Use after (relative) or at (ISO with offset) for one-off; every_seconds for repeating. " +
|
|
16
|
+
"Do NOT use this for agent re-invocation jobs — use schedule_add instead.",
|
|
17
|
+
requiresApproval: "always",
|
|
18
|
+
tags: ["reminder"],
|
|
19
|
+
keywords: ["提醒", "reminder", "定时", "闹钟"],
|
|
20
|
+
inputSchema: z.object({
|
|
21
|
+
text: z.string().min(1).max(500).describe("Reminder message body"),
|
|
22
|
+
after: z.string().optional().describe("Relative delay: 600, 10m, 2h, 1d"),
|
|
23
|
+
at: z
|
|
24
|
+
.string()
|
|
25
|
+
.optional()
|
|
26
|
+
.describe(
|
|
27
|
+
"Absolute ISO time with Z or ±hh:mm, e.g. 2026-10-14T09:00:00+08:00"
|
|
28
|
+
),
|
|
29
|
+
every_seconds: z
|
|
30
|
+
.number()
|
|
31
|
+
.int()
|
|
32
|
+
.min(60)
|
|
33
|
+
.max(365 * 24 * 60 * 60)
|
|
34
|
+
.optional()
|
|
35
|
+
.describe(
|
|
36
|
+
"Repeat interval in seconds (min 60). First fire is now+interval."
|
|
37
|
+
),
|
|
38
|
+
}),
|
|
39
|
+
async execute({ text, after, at, every_seconds }, context) {
|
|
40
|
+
return replyReminder(async () => {
|
|
41
|
+
const modes = [
|
|
42
|
+
after,
|
|
43
|
+
at,
|
|
44
|
+
every_seconds !== undefined ? "every" : undefined,
|
|
45
|
+
].filter(Boolean);
|
|
46
|
+
if (modes.length !== 1) {
|
|
47
|
+
throw new ReminderInputError(
|
|
48
|
+
"请在 after / at / every_seconds 中只选一种时间表达"
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
const service = context.use(reminderServiceToken);
|
|
52
|
+
const actor = reminderActorFromTool(context);
|
|
53
|
+
if (every_seconds !== undefined) {
|
|
54
|
+
const intervalMs = every_seconds * 1000;
|
|
55
|
+
const task = await service.add(
|
|
56
|
+
actor,
|
|
57
|
+
text,
|
|
58
|
+
Date.now() + intervalMs,
|
|
59
|
+
intervalMs
|
|
60
|
+
);
|
|
61
|
+
return `已创建周期提醒\n${service.describe(task)}`;
|
|
62
|
+
}
|
|
63
|
+
if (after) {
|
|
64
|
+
const delayMs = parseRelativeDelayMs(after);
|
|
65
|
+
const task = await service.add(actor, text, Date.now() + delayMs, null);
|
|
66
|
+
return `已创建提醒\n${service.describe(task)}`;
|
|
67
|
+
}
|
|
68
|
+
const task = await service.add(
|
|
69
|
+
actor,
|
|
70
|
+
text,
|
|
71
|
+
parseReminderInstant(at!),
|
|
72
|
+
null
|
|
73
|
+
);
|
|
74
|
+
return `已创建提醒\n${service.describe(task)}`;
|
|
75
|
+
});
|
|
76
|
+
},
|
|
77
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Generated by build-plugin-runtime-entries.mjs. Do not edit.
|
|
2
|
+
import { defineAgentTool } from "@zhin.js/tool";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { replyReminder } from "../../../../lib/caller.js";
|
|
5
|
+
import { reminderServiceToken } from "../../../../lib/runtime.js";
|
|
6
|
+
import { reminderActorFromTool } from "../../../../lib/tool-actor.js";
|
|
7
|
+
export default defineAgentTool({
|
|
8
|
+
description: "List pending personal reminders for the current sender in the current chat.",
|
|
9
|
+
requiresApproval: "never",
|
|
10
|
+
tags: ["reminder"],
|
|
11
|
+
keywords: ["提醒列表", "list reminders"],
|
|
12
|
+
inputSchema: z.object({}),
|
|
13
|
+
async execute(_input, context) {
|
|
14
|
+
return replyReminder(async () => {
|
|
15
|
+
const service = context.use(reminderServiceToken);
|
|
16
|
+
const tasks = await service.list(reminderActorFromTool(context));
|
|
17
|
+
if (tasks.length === 0)
|
|
18
|
+
return "当前会话没有提醒";
|
|
19
|
+
return tasks.map((task) => service.describe(task)).join("\n---\n");
|
|
20
|
+
});
|
|
21
|
+
},
|
|
22
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { defineAgentTool } from "@zhin.js/tool";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { replyReminder } from "../../../../src/caller.js";
|
|
4
|
+
import { reminderServiceToken } from "../../../../src/runtime.js";
|
|
5
|
+
import { reminderActorFromTool } from "../../../../src/tool-actor.js";
|
|
6
|
+
|
|
7
|
+
export default defineAgentTool({
|
|
8
|
+
description:
|
|
9
|
+
"List pending personal reminders for the current sender in the current chat.",
|
|
10
|
+
requiresApproval: "never",
|
|
11
|
+
tags: ["reminder"],
|
|
12
|
+
keywords: ["提醒列表", "list reminders"],
|
|
13
|
+
inputSchema: z.object({}),
|
|
14
|
+
async execute(_input, context) {
|
|
15
|
+
return replyReminder(async () => {
|
|
16
|
+
const service = context.use(reminderServiceToken);
|
|
17
|
+
const tasks = await service.list(reminderActorFromTool(context));
|
|
18
|
+
if (tasks.length === 0) return "当前会话没有提醒";
|
|
19
|
+
return tasks.map((task) => service.describe(task)).join("\n---\n");
|
|
20
|
+
});
|
|
21
|
+
},
|
|
22
|
+
});
|
package/src/caller.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { createDeliveryTarget } from 'zhin.js';
|
|
2
|
+
import type { CommandMessage } from '@zhin.js/command';
|
|
3
|
+
import { ReminderInputError, type ReminderActor } from './service.js';
|
|
4
|
+
|
|
5
|
+
export function reminderActor(input: CommandMessage | undefined): ReminderActor {
|
|
6
|
+
if (!input?.sender?.id) throw new ReminderInputError('提醒指令需要真实会话和发送者');
|
|
7
|
+
return { senderId: input.sender.id, target: createDeliveryTarget(input) };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function replyReminder(operation: () => Promise<string>): Promise<string> {
|
|
11
|
+
try { return await operation(); }
|
|
12
|
+
catch (error) { if (error instanceof ReminderInputError) return error.message; throw error; }
|
|
13
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Schema } from 'zhin.js';
|
|
2
|
+
|
|
3
|
+
export const reminderConfig = Schema.object({
|
|
4
|
+
timezone: Schema.string().default('UTC').description('显示提醒时间的 IANA 时区;绝对时间仍必须提供 ISO offset').effect('reload'),
|
|
5
|
+
maxTasks: Schema.number().min(1).max(32).step(1).default(20).description('每个插件实例新建时允许的活跃任务上限(待触发或未确认);降低上限不删除既有任务').effect('reload'),
|
|
6
|
+
maxTasksPerSender: Schema.number().min(1).max(10).step(1).default(10).description('同一发送者在同一会话的任务上限').effect('reload'),
|
|
7
|
+
batchSize: Schema.number().min(1).max(5).step(1).default(5).description('每次扫描最多投递的任务数').effect('reload'),
|
|
8
|
+
missedGraceSeconds: Schema.number().min(5).max(60).step(1).default(15).description('周期轮次超过此延迟后跳过;停机期间不补历史轮次').effect('reload'),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
export type ReminderConfig = Schema.Output<typeof reminderConfig>;
|
|
12
|
+
|
|
13
|
+
export function assertReminderTimezone(timezone: string): void {
|
|
14
|
+
try { new Intl.DateTimeFormat('zh-CN', { timeZone: timezone }).format(0); }
|
|
15
|
+
catch { throw new TypeError(`提醒 timezone 必须是有效的 IANA 时区:${timezone}`); }
|
|
16
|
+
}
|
package/src/duration.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Relative durations for Agent tools: `10m`, `2h`, `1d`, or plain seconds. */
|
|
2
|
+
import { ReminderInputError } from "./service.js";
|
|
3
|
+
|
|
4
|
+
export function parseRelativeDelayMs(value: string): number {
|
|
5
|
+
const text = value.trim().toLowerCase();
|
|
6
|
+
if (/^\d+$/.test(text)) {
|
|
7
|
+
const seconds = Number(text);
|
|
8
|
+
if (!Number.isSafeInteger(seconds) || seconds < 1) {
|
|
9
|
+
throw new ReminderInputError("延迟秒数无效");
|
|
10
|
+
}
|
|
11
|
+
return seconds * 1000;
|
|
12
|
+
}
|
|
13
|
+
const match = /^(\d+)\s*(s|m|h|d|秒|分|分钟|小时|天)$/u.exec(text);
|
|
14
|
+
if (!match) {
|
|
15
|
+
throw new ReminderInputError("时长格式无效(示例:600、10m、2h、1d)");
|
|
16
|
+
}
|
|
17
|
+
const amount = Number(match[1]);
|
|
18
|
+
const unit = match[2]!;
|
|
19
|
+
const factor = {
|
|
20
|
+
s: 1000,
|
|
21
|
+
秒: 1000,
|
|
22
|
+
m: 60_000,
|
|
23
|
+
分: 60_000,
|
|
24
|
+
分钟: 60_000,
|
|
25
|
+
h: 3_600_000,
|
|
26
|
+
小时: 3_600_000,
|
|
27
|
+
d: 86_400_000,
|
|
28
|
+
天: 86_400_000,
|
|
29
|
+
}[unit]!;
|
|
30
|
+
return amount * factor;
|
|
31
|
+
}
|
package/src/index.ts
ADDED
package/src/runtime.ts
ADDED
package/src/service.ts
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { createDeliveryTarget, type DeliveryTarget, type OutboundHost, type PluginStateStore } from 'zhin.js';
|
|
3
|
+
import { EndpointDeliveryError } from '@zhin.js/im-contract';
|
|
4
|
+
import type { ReminderConfig } from './config.js';
|
|
5
|
+
|
|
6
|
+
export const REMINDER_STATE_KEY = 'reminders/v1';
|
|
7
|
+
export const MAX_DELAY_SECONDS = 365 * 24 * 60 * 60;
|
|
8
|
+
export class ReminderInputError extends Error {}
|
|
9
|
+
|
|
10
|
+
type TaskStatus = 'pending' | 'unknown' | 'delivered' | 'failed' | 'cancelled';
|
|
11
|
+
export type ReminderActor = { readonly senderId: string; readonly target: DeliveryTarget };
|
|
12
|
+
export type ReminderTask = {
|
|
13
|
+
readonly id: string; readonly senderId: string; readonly target: DeliveryTarget;
|
|
14
|
+
readonly text: string; readonly createdAt: number; readonly nextRunAt: number;
|
|
15
|
+
readonly intervalMs: number | null; readonly status: TaskStatus;
|
|
16
|
+
readonly claimId: string | null; readonly lastRunAt: number | null; readonly detail: string;
|
|
17
|
+
};
|
|
18
|
+
export type ReminderBook = { readonly version: 1; readonly tasks: readonly ReminderTask[] };
|
|
19
|
+
|
|
20
|
+
/** Generation-owned worker; saved state contains plain identities, never live objects. */
|
|
21
|
+
export class ReminderService {
|
|
22
|
+
readonly #state: PluginStateStore;
|
|
23
|
+
readonly #outbound: OutboundHost;
|
|
24
|
+
readonly #config: ReminderConfig;
|
|
25
|
+
#working = false;
|
|
26
|
+
#stopped = false;
|
|
27
|
+
|
|
28
|
+
constructor(state: PluginStateStore, outbound: OutboundHost, config: ReminderConfig) {
|
|
29
|
+
this.#state = state;
|
|
30
|
+
this.#outbound = outbound;
|
|
31
|
+
this.#config = config;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
stop(): void { this.#stopped = true; }
|
|
35
|
+
|
|
36
|
+
async add(actor: ReminderActor, text: string, at: number, intervalMs: number | null): Promise<ReminderTask> {
|
|
37
|
+
this.#assertLive();
|
|
38
|
+
assertActor(actor);
|
|
39
|
+
const now = Date.now();
|
|
40
|
+
if (!text.trim() || text.length > 500) throw new ReminderInputError('提醒内容须为 1–500 个字符');
|
|
41
|
+
if (!Number.isSafeInteger(at) || at <= now || at - now > MAX_DELAY_SECONDS * 1000) throw new ReminderInputError('提醒时间须在未来一年内');
|
|
42
|
+
if (intervalMs !== null && (!Number.isSafeInteger(intervalMs) || intervalMs < 60_000 || intervalMs > MAX_DELAY_SECONDS * 1000)) throw new ReminderInputError('周期须为 60 秒至一年');
|
|
43
|
+
const task: ReminderTask = { id: randomUUID(), senderId: actor.senderId, target: createDeliveryTarget({ conversation: actor.target }),
|
|
44
|
+
text, createdAt: now, nextRunAt: at, intervalMs, status: 'pending', claimId: null, lastRunAt: null, detail: '' };
|
|
45
|
+
await this.#state.update<ReminderBook>(REMINDER_STATE_KEY, current => {
|
|
46
|
+
const book = readBook(current);
|
|
47
|
+
// Completed history is disposable; unconfirmed tasks require an explicit
|
|
48
|
+
// user decision and continue to count against the storage quota.
|
|
49
|
+
const active = book.tasks.filter(item => item.status === 'pending' || item.status === 'unknown');
|
|
50
|
+
if (active.length >= this.#config.maxTasks) throw new ReminderInputError('此插件实例的提醒数量已达上限,请先取消任务');
|
|
51
|
+
if (active.filter(item => sameActor(item, actor)).length >= this.#config.maxTasksPerSender) throw new ReminderInputError('你在此会话的提醒数量已达上限,请先取消任务');
|
|
52
|
+
const historyLimit = Math.max(0, this.#config.maxTasks - active.length - 1);
|
|
53
|
+
const history = historyLimit > 0 ? book.tasks.filter(item => item.status !== 'pending' && item.status !== 'unknown').slice(-historyLimit) : [];
|
|
54
|
+
return boundedBook([...active, ...history, task]);
|
|
55
|
+
});
|
|
56
|
+
return task;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async list(actor: ReminderActor): Promise<readonly ReminderTask[]> {
|
|
60
|
+
this.#assertLive();
|
|
61
|
+
assertActor(actor);
|
|
62
|
+
return readBook(await this.#state.get<ReminderBook>(REMINDER_STATE_KEY)).tasks.filter(item => sameActor(item, actor));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async cancel(actor: ReminderActor, id: string): Promise<boolean> {
|
|
66
|
+
this.#assertLive();
|
|
67
|
+
assertActor(actor);
|
|
68
|
+
const book = await this.#state.update<ReminderBook>(REMINDER_STATE_KEY, current => ({ version: 1,
|
|
69
|
+
tasks: readBook(current).tasks.map(task => task.id === id && sameActor(task, actor)
|
|
70
|
+
? { ...task, status: 'cancelled', detail: '用户取消;已经开始的投递无法撤销' } : task),
|
|
71
|
+
}));
|
|
72
|
+
return !!book?.tasks.some(task => task.id === id && sameActor(task, actor) && task.status === 'cancelled');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async tick(): Promise<void> {
|
|
76
|
+
if (this.#working || this.#stopped) return;
|
|
77
|
+
this.#working = true;
|
|
78
|
+
try {
|
|
79
|
+
for (let count = 0; count < this.#config.batchSize && !this.#stopped; count++) {
|
|
80
|
+
const now = Date.now();
|
|
81
|
+
const claimId = randomUUID();
|
|
82
|
+
const book = await this.#state.update<ReminderBook>(REMINDER_STATE_KEY, current => {
|
|
83
|
+
let claimed = false;
|
|
84
|
+
return { version: 1, tasks: readBook(current).tasks.map(task => {
|
|
85
|
+
if (task.status !== 'pending' || task.nextRunAt > now) return task;
|
|
86
|
+
if (task.intervalMs !== null && now - task.nextRunAt > this.#config.missedGraceSeconds * 1000) {
|
|
87
|
+
return { ...task, nextRunAt: nextPeriodicTime(task.nextRunAt, task.intervalMs, now), detail: '已跳过错过的周期轮次' };
|
|
88
|
+
}
|
|
89
|
+
if (claimed) return task;
|
|
90
|
+
claimed = true;
|
|
91
|
+
// Persist the conservative disposition before touching the external
|
|
92
|
+
// platform. A crash here cannot cause an automatic duplicate send.
|
|
93
|
+
return { ...task, status: 'unknown', claimId, lastRunAt: now, detail: '投递中或未确认;不会自动重试' };
|
|
94
|
+
}) };
|
|
95
|
+
});
|
|
96
|
+
const task = book?.tasks.find(item => item.claimId === claimId);
|
|
97
|
+
if (!task || this.#stopped) break;
|
|
98
|
+
let status: TaskStatus = 'unknown';
|
|
99
|
+
let detail = '平台未返回确认的消息 ID;不会自动重试';
|
|
100
|
+
try {
|
|
101
|
+
const id = await this.#outbound.sendTarget!(task.target, `⏰ 提醒:${task.text}`);
|
|
102
|
+
if (id) { status = task.intervalMs === null ? 'delivered' : 'pending'; detail = '已确认投递'; }
|
|
103
|
+
} catch (error) {
|
|
104
|
+
if (error instanceof EndpointDeliveryError && error.disposition !== 'unknown') {
|
|
105
|
+
status = 'failed'; detail = `投递失败:${error.code};任务已停止`;
|
|
106
|
+
} else detail = '投递结果未确认;请核对目标会话后取消或重新创建';
|
|
107
|
+
}
|
|
108
|
+
await this.#state.update<ReminderBook>(REMINDER_STATE_KEY, current => ({ version: 1,
|
|
109
|
+
tasks: readBook(current).tasks.map(item => item.id === task.id && item.claimId === claimId && item.status === 'unknown'
|
|
110
|
+
? { ...item, status, detail, ...(status === 'pending' && task.intervalMs !== null
|
|
111
|
+
? { nextRunAt: nextPeriodicTime(task.nextRunAt, task.intervalMs, Date.now()) } : {}) } : item),
|
|
112
|
+
}));
|
|
113
|
+
}
|
|
114
|
+
} finally { this.#working = false; }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
describe(task: ReminderTask): string {
|
|
118
|
+
const date = new Intl.DateTimeFormat('zh-CN', { timeZone: this.#config.timezone, dateStyle: 'short', timeStyle: 'medium' }).format(task.nextRunAt);
|
|
119
|
+
const state = { pending: '待触发', unknown: '投递中或未确认', delivered: '已完成', failed: '投递失败', cancelled: '已取消' }[task.status];
|
|
120
|
+
return `${task.id} | ${state} | ${date} (${this.#config.timezone})${task.intervalMs === null ? '' : ` | 每 ${task.intervalMs / 1000} 秒`}`
|
|
121
|
+
+ `\n${task.text}${task.detail ? `\n${task.detail}` : ''}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
#assertLive(): void { if (this.#stopped) throw new Error('提醒服务属于已停止的插件实例'); }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function parseReminderInstant(value: string): number {
|
|
128
|
+
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,3})?)?(?:Z|[+-]\d{2}:\d{2})$/u.test(value)) throw new TypeError('绝对时间须为带 Z 或 ±hh:mm 的 ISO 时间,例如 2026-10-14T09:00:00+08:00');
|
|
129
|
+
const [year = 0, month = 0, day = 0, hour = 0, minute = 0, second = 0] = value.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?/u)!.slice(1).map(item => Number(item ?? 0));
|
|
130
|
+
const calendar = new Date(0);
|
|
131
|
+
calendar.setUTCFullYear(year, month, 0);
|
|
132
|
+
const daysInMonth = calendar.getUTCDate();
|
|
133
|
+
if (month < 1 || month > 12 || day < 1 || day > daysInMonth || hour > 23 || minute > 59 || second > 59) throw new TypeError('提醒时间无效');
|
|
134
|
+
const time = Date.parse(value);
|
|
135
|
+
if (!Number.isSafeInteger(time)) throw new TypeError('提醒时间无效');
|
|
136
|
+
return time;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function nextPeriodicTime(from: number, interval: number, now: number): number {
|
|
140
|
+
return from + (Math.floor((now - from) / interval) + 1) * interval;
|
|
141
|
+
}
|
|
142
|
+
function sameActor(task: ReminderTask, actor: ReminderActor): boolean {
|
|
143
|
+
return task.senderId === actor.senderId && JSON.stringify(task.target) === JSON.stringify(createDeliveryTarget({ conversation: actor.target }));
|
|
144
|
+
}
|
|
145
|
+
function readBook(value: ReminderBook | undefined): ReminderBook {
|
|
146
|
+
if (value === undefined) return { version: 1, tasks: [] };
|
|
147
|
+
try {
|
|
148
|
+
if (value.version !== 1 || !Array.isArray(value.tasks) || value.tasks.length > 32) throw new Error('Invalid book');
|
|
149
|
+
for (const task of value.tasks) {
|
|
150
|
+
if (!task || typeof task.id !== 'string' || !task.id || typeof task.senderId !== 'string' || !task.senderId
|
|
151
|
+
|| typeof task.text !== 'string' || !task.text.trim() || task.text.length > 500
|
|
152
|
+
|| !Number.isSafeInteger(task.createdAt) || !Number.isSafeInteger(task.nextRunAt)
|
|
153
|
+
|| (task.intervalMs !== null && (!Number.isSafeInteger(task.intervalMs) || task.intervalMs < 60_000 || task.intervalMs > MAX_DELAY_SECONDS * 1000))
|
|
154
|
+
|| !['pending', 'unknown', 'delivered', 'failed', 'cancelled'].includes(task.status)
|
|
155
|
+
|| (task.claimId !== null && typeof task.claimId !== 'string')
|
|
156
|
+
|| (task.lastRunAt !== null && !Number.isSafeInteger(task.lastRunAt)) || typeof task.detail !== 'string') throw new Error('Invalid task');
|
|
157
|
+
createDeliveryTarget({ conversation: task.target });
|
|
158
|
+
}
|
|
159
|
+
return boundedBook(value.tasks);
|
|
160
|
+
} catch { throw new Error('提醒持久状态格式无效,已停止投递'); }
|
|
161
|
+
}
|
|
162
|
+
function boundedBook(tasks: readonly ReminderTask[]): ReminderBook {
|
|
163
|
+
const book: ReminderBook = { version: 1, tasks };
|
|
164
|
+
if (new TextEncoder().encode(JSON.stringify(book)).byteLength > 60 * 1024) throw new ReminderInputError('提醒存储达到大小上限,请先取消旧任务');
|
|
165
|
+
return book;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function assertActor(actor: ReminderActor): void {
|
|
169
|
+
if (!actor.senderId) throw new ReminderInputError('提醒需要真实发送者');
|
|
170
|
+
createDeliveryTarget({ conversation: actor.target });
|
|
171
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve a ReminderActor from an Agent Tool invocation (IM origin only).
|
|
3
|
+
*/
|
|
4
|
+
import { createDeliveryTarget, type DeliveryTarget } from "zhin.js";
|
|
5
|
+
import { adapterFeatureId, type AdapterIndex } from "zhin.js/adapter";
|
|
6
|
+
import type { ToolInvocationOrigin } from "@zhin.js/tool";
|
|
7
|
+
import { ReminderInputError, type ReminderActor } from "./service.js";
|
|
8
|
+
|
|
9
|
+
export interface ToolActorContext {
|
|
10
|
+
readonly origin: ToolInvocationOrigin;
|
|
11
|
+
readonly principal: Readonly<{ subjectId: string; roles: readonly string[] }>;
|
|
12
|
+
project<T>(feature: typeof adapterFeatureId): T;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Build the exact conversation DeliveryTarget for the current tool turn. */
|
|
16
|
+
export function toolDeliveryTarget(context: ToolActorContext): DeliveryTarget {
|
|
17
|
+
const origin = context.origin;
|
|
18
|
+
if (origin.kind !== "im") {
|
|
19
|
+
throw new ReminderInputError(
|
|
20
|
+
"提醒工具只能在聊天会话中使用(不能用于 http/schedule 等来源)"
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
const index = context.project<AdapterIndex>(adapterFeatureId);
|
|
24
|
+
const matches = index
|
|
25
|
+
.describe()
|
|
26
|
+
.filter(
|
|
27
|
+
(endpoint) =>
|
|
28
|
+
(String(endpoint.id) === origin.endpoint ||
|
|
29
|
+
endpoint.name === origin.endpoint) &&
|
|
30
|
+
index.clientAdapter(endpoint.id) === origin.platform
|
|
31
|
+
);
|
|
32
|
+
if (matches.length !== 1) {
|
|
33
|
+
throw new ReminderInputError("无法确定当前会话所属的机器人账号");
|
|
34
|
+
}
|
|
35
|
+
return createDeliveryTarget({
|
|
36
|
+
conversation: {
|
|
37
|
+
endpoint: { id: String(matches[0]!.id), adapter: origin.platform },
|
|
38
|
+
kind: origin.scope,
|
|
39
|
+
id: origin.sceneId,
|
|
40
|
+
...(origin.threadId !== undefined ? { threadId: origin.threadId } : {}),
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Actor bound to the invoking principal and the current IM conversation. */
|
|
46
|
+
export function reminderActorFromTool(
|
|
47
|
+
context: ToolActorContext
|
|
48
|
+
): ReminderActor {
|
|
49
|
+
const senderId = context.principal.subjectId?.trim();
|
|
50
|
+
if (!senderId) throw new ReminderInputError("提醒工具需要真实发送者");
|
|
51
|
+
return { senderId, target: toolDeliveryTarget(context) };
|
|
52
|
+
}
|