@zhin.js/plugin-reminder 1.0.1 → 1.0.3
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 +32 -8
- package/commands/remind/create/index.js +12 -0
- package/commands/remind/create/index.ts +14 -0
- package/lib/admin-tasks.d.ts +82 -0
- package/lib/admin-tasks.d.ts.map +1 -0
- package/lib/admin-tasks.js +294 -0
- package/lib/admin-tasks.js.map +1 -0
- package/lib/config.d.ts +2 -0
- package/lib/config.d.ts.map +1 -1
- package/lib/config.js +5 -2
- package/lib/config.js.map +1 -1
- package/lib/create-reminder.d.ts +8 -0
- package/lib/create-reminder.d.ts.map +1 -0
- package/lib/create-reminder.js +128 -0
- package/lib/create-reminder.js.map +1 -0
- package/lib/natural-time.d.ts +15 -0
- package/lib/natural-time.d.ts.map +1 -0
- package/lib/natural-time.js +219 -0
- package/lib/natural-time.js.map +1 -0
- package/lib/service.d.ts +3 -3
- package/lib/service.d.ts.map +1 -1
- package/lib/service.js +29 -20
- package/lib/service.js.map +1 -1
- package/lib/timing.d.ts +4 -0
- package/lib/timing.d.ts.map +1 -0
- package/lib/timing.js +19 -0
- package/lib/timing.js.map +1 -0
- package/package.json +12 -12
- package/plugin.js +4 -3
- package/schema.json +69 -0
- package/src/admin-tasks.ts +229 -0
- package/src/config.ts +7 -3
- package/src/create-reminder.ts +106 -0
- package/src/natural-time.ts +197 -0
- package/src/service.ts +23 -18
- package/src/timing.ts +17 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhin.js/plugin-reminder",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"description": "Persistent personal reminders for Zhin Plugin Runtime",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -8,7 +8,6 @@
|
|
|
8
8
|
"exports": {
|
|
9
9
|
".": {
|
|
10
10
|
"types": "./lib/index.d.ts",
|
|
11
|
-
"development": "./src/index.ts",
|
|
12
11
|
"import": "./lib/index.js"
|
|
13
12
|
}
|
|
14
13
|
},
|
|
@@ -19,18 +18,19 @@
|
|
|
19
18
|
"plugin.js",
|
|
20
19
|
"schedules",
|
|
21
20
|
"skills",
|
|
22
|
-
"src"
|
|
21
|
+
"src",
|
|
22
|
+
"schema.json"
|
|
23
23
|
],
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@zhin.js/im-contract": "1.1.
|
|
26
|
-
"@zhin.js/skill": "1.1.
|
|
27
|
-
"@zhin.js/tool": "1.1.
|
|
25
|
+
"@zhin.js/im-contract": "1.1.4",
|
|
26
|
+
"@zhin.js/skill": "1.1.7",
|
|
27
|
+
"@zhin.js/tool": "1.1.7"
|
|
28
28
|
},
|
|
29
29
|
"peerDependencies": {
|
|
30
30
|
"zod": "^4.0.0",
|
|
31
|
-
"zhin.js": "^1.1.
|
|
32
|
-
"@zhin.js/command": "^1.1.
|
|
33
|
-
"@zhin.js/schedule-feature": "^1.1.
|
|
31
|
+
"zhin.js": "^1.1.11",
|
|
32
|
+
"@zhin.js/command": "^1.1.8",
|
|
33
|
+
"@zhin.js/schedule-feature": "^1.1.6"
|
|
34
34
|
},
|
|
35
35
|
"peerDependenciesMeta": {
|
|
36
36
|
"@zhin.js/command": {
|
|
@@ -47,11 +47,11 @@
|
|
|
47
47
|
"typescript": "^6.0.3",
|
|
48
48
|
"vitest": "^4.1.10",
|
|
49
49
|
"zod": "^4.4.3",
|
|
50
|
-
"zhin.js": "1.1.
|
|
51
|
-
"
|
|
50
|
+
"@zhin.js/schedule-feature": "1.1.6",
|
|
51
|
+
"zhin.js": "1.1.11"
|
|
52
52
|
},
|
|
53
53
|
"engines": {
|
|
54
|
-
"node": "
|
|
54
|
+
"node": ">=22.19.0"
|
|
55
55
|
},
|
|
56
56
|
"license": "MIT",
|
|
57
57
|
"repository": {
|
package/plugin.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
// Generated by build-plugin-runtime-entries.mjs. Do not edit.
|
|
2
2
|
import { databaseHostToken, definePlugin, outboundHostToken } from 'zhin.js';
|
|
3
|
-
import { pluginVerificationToken } from 'zhin.js/plugin-runtime';
|
|
3
|
+
import { pluginVerificationToken, pluginTaskProviderToken } from 'zhin.js/plugin-runtime';
|
|
4
4
|
import { randomUUID } from 'node:crypto';
|
|
5
|
-
import { assertReminderTimezone
|
|
5
|
+
import { assertReminderTimezone } from "./lib/config.js";
|
|
6
6
|
import { ReminderService } from "./lib/service.js";
|
|
7
7
|
import { reminderServiceToken } from "./lib/runtime.js";
|
|
8
8
|
export default definePlugin({
|
|
9
|
-
name: 'reminder',
|
|
9
|
+
name: 'reminder',
|
|
10
10
|
metadata: { displayName: '持久提醒' },
|
|
11
11
|
requires: [databaseHostToken, outboundHostToken],
|
|
12
12
|
setup(context) {
|
|
@@ -18,6 +18,7 @@ export default definePlugin({
|
|
|
18
18
|
throw new Error('提醒需要支持精确 DeliveryTarget 的 Outbound Host');
|
|
19
19
|
const service = new ReminderService(state, outbound, config);
|
|
20
20
|
context.resources.provide(reminderServiceToken, service);
|
|
21
|
+
context.resources.provide(pluginTaskProviderToken, service.administratorTasks);
|
|
21
22
|
context.resources.provide(pluginVerificationToken, {
|
|
22
23
|
checks: () => [{ id: 'local-state', title: '持久状态读写检查', scope: 'local', effects: ['写入、读取并删除一个独立探针键;不创建提醒、不发送消息'] }],
|
|
23
24
|
async run(checkId) {
|
package/schema.json
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
{
|
|
2
|
+
"type": "object",
|
|
3
|
+
"additionalProperties": false,
|
|
4
|
+
"properties": {
|
|
5
|
+
"timezone": {
|
|
6
|
+
"type": "string",
|
|
7
|
+
"description": "解释 create 自然时间并显示提醒的 IANA 时区;ISO 绝对时间仍须显式 offset",
|
|
8
|
+
"default": "UTC",
|
|
9
|
+
"x-zhin-config": {
|
|
10
|
+
"effect": "reload"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"maxTasks": {
|
|
14
|
+
"type": "number",
|
|
15
|
+
"description": "每实例聊天个人提醒池的活跃任务上限;与管理员池独立,降低上限不删除既有任务",
|
|
16
|
+
"default": 20,
|
|
17
|
+
"minimum": 1,
|
|
18
|
+
"maximum": 32,
|
|
19
|
+
"multipleOf": 1,
|
|
20
|
+
"x-zhin-config": {
|
|
21
|
+
"effect": "reload"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"adminMaxTasks": {
|
|
25
|
+
"type": "number",
|
|
26
|
+
"description": "每实例管理员提醒池的活跃任务上限(待执行、暂停或未确认);与聊天池独立,两池总量可达上限之和",
|
|
27
|
+
"default": 20,
|
|
28
|
+
"minimum": 1,
|
|
29
|
+
"maximum": 32,
|
|
30
|
+
"multipleOf": 1,
|
|
31
|
+
"x-zhin-config": {
|
|
32
|
+
"effect": "reload"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"maxTasksPerSender": {
|
|
36
|
+
"type": "number",
|
|
37
|
+
"description": "同一发送者在同一会话的任务上限",
|
|
38
|
+
"default": 10,
|
|
39
|
+
"minimum": 1,
|
|
40
|
+
"maximum": 10,
|
|
41
|
+
"multipleOf": 1,
|
|
42
|
+
"x-zhin-config": {
|
|
43
|
+
"effect": "reload"
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
"batchSize": {
|
|
47
|
+
"type": "number",
|
|
48
|
+
"description": "每次扫描最多投递的任务数",
|
|
49
|
+
"default": 5,
|
|
50
|
+
"minimum": 1,
|
|
51
|
+
"maximum": 5,
|
|
52
|
+
"multipleOf": 1,
|
|
53
|
+
"x-zhin-config": {
|
|
54
|
+
"effect": "reload"
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
"missedGraceSeconds": {
|
|
58
|
+
"type": "number",
|
|
59
|
+
"description": "周期轮次超过此延迟后跳过;停机期间不补历史轮次",
|
|
60
|
+
"default": 15,
|
|
61
|
+
"minimum": 5,
|
|
62
|
+
"maximum": 60,
|
|
63
|
+
"multipleOf": 1,
|
|
64
|
+
"x-zhin-config": {
|
|
65
|
+
"effect": "reload"
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { createDeliveryTarget, type DeliveryTarget, type OutboundHost, type PluginStateStore } from 'zhin.js';
|
|
3
|
+
import { PluginTaskError, createGenerationAdmissionGate, generationAdmissionSource, type JsonValue, type PluginTask, type PluginTaskAction, type PluginTaskMutation,
|
|
4
|
+
type PluginTaskProvider, type PluginTaskSnapshot } from 'zhin.js/plugin-runtime';
|
|
5
|
+
import { EndpointDeliveryError } from '@zhin.js/im-contract';
|
|
6
|
+
import type { ReminderConfig } from './config.js';
|
|
7
|
+
import { MAX_DELAY_SECONDS, nextPeriodicTime, parseReminderInstant } from './timing.js';
|
|
8
|
+
|
|
9
|
+
export const ADMIN_REMINDER_STATE_KEY = 'admin-reminders/v1';
|
|
10
|
+
type AdminTask = {
|
|
11
|
+
readonly id: string; readonly creation: string; readonly target: DeliveryTarget; readonly text: string;
|
|
12
|
+
readonly createdAt: number; readonly nextRunAt: number; readonly intervalMs: number | null;
|
|
13
|
+
readonly state: Exclude<PluginTask['state'], 'running'>; readonly claimId: string | null;
|
|
14
|
+
readonly lastRunAt: number | null; readonly detail: string;
|
|
15
|
+
};
|
|
16
|
+
type MutationReceipt = { readonly id: string; readonly fingerprint: string; readonly taskId: string; readonly operation: 'create' | PluginTaskAction };
|
|
17
|
+
export type AdminReminderBook = { readonly version: 1; readonly revision: string;
|
|
18
|
+
readonly tasks: readonly AdminTask[]; readonly receipts: readonly MutationReceipt[] };
|
|
19
|
+
type Parameters = { readonly text: string; readonly at: string; readonly intervalSeconds?: number };
|
|
20
|
+
const activeStates = new Set<AdminTask['state']>(['scheduled', 'paused', 'unknown']);
|
|
21
|
+
const uuidPattern = /^[\da-f]{8}-(?:[\da-f]{4}-){3}[\da-f]{12}$/iu;
|
|
22
|
+
|
|
23
|
+
/** A separate administrator state pool; no sender identity and no access to chat reminders. */
|
|
24
|
+
export class AdministratorReminderTasks implements PluginTaskProvider {
|
|
25
|
+
readonly title = '管理员提醒';
|
|
26
|
+
readonly description = '独立于聊天个人提醒,向指定账号、会话及线程发送一次性或固定周期提醒';
|
|
27
|
+
readonly effects = ['创建后将在指定时间通过当前插件实例向目标会话发送提醒;暂停或取消不能撤回已开始的投递', '未确认的投递不会自动重试或恢复,需核实目标会话后取消并另建任务'];
|
|
28
|
+
readonly schema = { type: 'object', additionalProperties: false, required: ['text', 'at'], properties: {
|
|
29
|
+
text: { type: 'string', minLength: 1, maxLength: 500, title: '提醒内容' },
|
|
30
|
+
at: { type: 'string', format: 'date-time', title: '首次执行时间', description: '未来一年内、显式包含 Z 或 ±hh:mm 的 ISO 时间' },
|
|
31
|
+
intervalSeconds: { type: 'integer', minimum: 60, maximum: MAX_DELAY_SECONDS, title: '周期秒数(可选)' },
|
|
32
|
+
} } as const;
|
|
33
|
+
#stopped = false;
|
|
34
|
+
readonly #admission = createGenerationAdmissionGate();
|
|
35
|
+
get [generationAdmissionSource]() { return [this.#admission]; }
|
|
36
|
+
constructor(private readonly state: PluginStateStore, private readonly outbound: OutboundHost, private readonly config: ReminderConfig) {}
|
|
37
|
+
stop(): void { this.#stopped = true; }
|
|
38
|
+
|
|
39
|
+
async read(): Promise<PluginTaskSnapshot> {
|
|
40
|
+
return this.#admit(async () => this.#snapshot(readBook(await this.state.get<AdminReminderBook>(ADMIN_REMINDER_STATE_KEY))));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async create(input: PluginTaskMutation & Readonly<{ target: DeliveryTarget; parameters: Readonly<Record<string, JsonValue>> }>): Promise<PluginTaskSnapshot> {
|
|
44
|
+
return this.#admit(() => this.#create(input));
|
|
45
|
+
}
|
|
46
|
+
async #create(input: PluginTaskMutation & Readonly<{ target: DeliveryTarget; parameters: Readonly<Record<string, JsonValue>> }>): Promise<PluginTaskSnapshot> {
|
|
47
|
+
this.#assertLive(); assertMutation(input);
|
|
48
|
+
let target: DeliveryTarget;
|
|
49
|
+
try { target = createDeliveryTarget({ conversation: input.target }); } catch { throw invalid('请提供完整有效的目标账号、会话、父容器和线程'); }
|
|
50
|
+
const parameters = parseParameters(input.parameters);
|
|
51
|
+
const fingerprint = digest({ operation: 'create', target, parameters });
|
|
52
|
+
const revision = randomUUID(); const now = Date.now();
|
|
53
|
+
const task: AdminTask = { id: input.requestId, creation: fingerprint, target, text: parameters.text, createdAt: now,
|
|
54
|
+
nextRunAt: Date.parse(parameters.at), intervalMs: parameters.intervalSeconds === undefined ? null : parameters.intervalSeconds * 1000,
|
|
55
|
+
state: 'scheduled', claimId: null, lastRunAt: null, detail: '等待执行' };
|
|
56
|
+
const result = await this.state.update<AdminReminderBook>(ADMIN_REMINDER_STATE_KEY, current => {
|
|
57
|
+
this.#assertLive(); const book = readBook(current);
|
|
58
|
+
if (reconcile(book, input.requestId, fingerprint)) return book;
|
|
59
|
+
assertRevision(book, input.expectedRevision);
|
|
60
|
+
const existing = book.tasks.find(item => item.id === input.requestId);
|
|
61
|
+
if (existing) { if (existing.creation !== fingerprint) throw conflict('同一请求 ID 已用于其他内容'); return book; }
|
|
62
|
+
assertFuture(task.nextRunAt, now);
|
|
63
|
+
const active = book.tasks.filter(item => activeStates.has(item.state));
|
|
64
|
+
if (active.length >= this.config.adminMaxTasks) throw invalid('管理员提醒池已达上限,请先取消任务;聊天个人提醒使用独立配额');
|
|
65
|
+
const historyLimit = Math.max(0, this.config.adminMaxTasks - active.length - 1);
|
|
66
|
+
const history = historyLimit ? book.tasks.filter(item => !activeStates.has(item.state)).slice(-historyLimit) : [];
|
|
67
|
+
return bounded({ ...book, revision, tasks: [...active, ...history, task], receipts: receipt(book, input.requestId, fingerprint, task.id, 'create') });
|
|
68
|
+
});
|
|
69
|
+
return this.#snapshot(readBook(result));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async change(input: PluginTaskMutation & Readonly<{ id: string; action: PluginTaskAction; parameters?: Readonly<Record<string, JsonValue>> }>): Promise<PluginTaskSnapshot> {
|
|
73
|
+
return this.#admit(() => this.#change(input));
|
|
74
|
+
}
|
|
75
|
+
async #change(input: PluginTaskMutation & Readonly<{ id: string; action: PluginTaskAction; parameters?: Readonly<Record<string, JsonValue>> }>): Promise<PluginTaskSnapshot> {
|
|
76
|
+
this.#assertLive(); assertMutation(input);
|
|
77
|
+
if (typeof input.id !== 'string' || !uuidPattern.test(input.id) || !['pause', 'resume', 'cancel', 'update'].includes(input.action)) throw invalid('任务 ID 或操作无效');
|
|
78
|
+
if (input.action !== 'update' && input.parameters !== undefined) throw invalid('仅编辑操作接受参数');
|
|
79
|
+
const parameters = input.action === 'update' ? parseParameters(input.parameters) : undefined;
|
|
80
|
+
const fingerprint = digest({ operation: 'change', id: input.id, action: input.action, parameters: parameters ?? null });
|
|
81
|
+
const revision = randomUUID(); const now = Date.now();
|
|
82
|
+
const result = await this.state.update<AdminReminderBook>(ADMIN_REMINDER_STATE_KEY, current => {
|
|
83
|
+
this.#assertLive(); const book = readBook(current);
|
|
84
|
+
if (reconcile(book, input.requestId, fingerprint)) return book;
|
|
85
|
+
assertRevision(book, input.expectedRevision);
|
|
86
|
+
const task = book.tasks.find(item => item.id === input.id);
|
|
87
|
+
if (!task) throw new PluginTaskError('not_found', '未找到当前实例的管理员任务;聊天任务不在此入口管理');
|
|
88
|
+
let changed: AdminTask;
|
|
89
|
+
switch (input.action) {
|
|
90
|
+
case 'pause':
|
|
91
|
+
if (task.state !== 'scheduled') throw invalid('仅待执行任务可以暂停;未确认投递不能暂停或恢复');
|
|
92
|
+
changed = { ...task, state: 'paused', detail: '管理员暂停' }; break;
|
|
93
|
+
case 'resume': {
|
|
94
|
+
if (task.state !== 'paused') throw invalid('仅已暂停任务可以恢复;未确认投递不能恢复或重试');
|
|
95
|
+
if (task.intervalMs === null && task.nextRunAt <= now) throw invalid('一次性任务时间已过,请先编辑为未来时间再恢复');
|
|
96
|
+
const nextRunAt = task.nextRunAt <= now && task.intervalMs !== null ? nextPeriodicTime(task.nextRunAt, task.intervalMs, now) : task.nextRunAt;
|
|
97
|
+
changed = { ...task, state: 'scheduled', nextRunAt, detail: nextRunAt !== task.nextRunAt ? '已恢复,并跳过错过的周期轮次' : '管理员恢复' }; break;
|
|
98
|
+
}
|
|
99
|
+
case 'update':
|
|
100
|
+
if (task.state !== 'paused') throw invalid('编辑前请先暂停任务;未确认投递不能编辑');
|
|
101
|
+
assertFuture(Date.parse(parameters!.at), now);
|
|
102
|
+
changed = { ...task, text: parameters!.text, nextRunAt: Date.parse(parameters!.at),
|
|
103
|
+
intervalMs: parameters!.intervalSeconds === undefined ? null : parameters!.intervalSeconds * 1000, detail: '已编辑,保持暂停;请确认后恢复' }; break;
|
|
104
|
+
case 'cancel':
|
|
105
|
+
if (!activeStates.has(task.state)) throw invalid('任务已结束,不能再次取消');
|
|
106
|
+
changed = { ...task, state: 'cancelled', detail: '管理员取消;已经开始的投递无法撤回' }; break;
|
|
107
|
+
}
|
|
108
|
+
return bounded({ ...book, revision, tasks: book.tasks.map(item => item.id === task.id ? changed : item), receipts: receipt(book, input.requestId, fingerprint, task.id, input.action) });
|
|
109
|
+
});
|
|
110
|
+
return this.#snapshot(readBook(result));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Called only by the existing ReminderService worker, within its shared batch limit. */
|
|
114
|
+
async claim(now: number, claimId: string): Promise<AdminTask | undefined> {
|
|
115
|
+
this.#assertLive(); const revision = randomUUID();
|
|
116
|
+
const book = await this.state.update<AdminReminderBook>(ADMIN_REMINDER_STATE_KEY, current => {
|
|
117
|
+
const book = readBook(current); let claimed = false; let changed = false;
|
|
118
|
+
const tasks = book.tasks.map(task => {
|
|
119
|
+
if (task.state !== 'scheduled' || task.nextRunAt > now) return task;
|
|
120
|
+
if (task.intervalMs !== null && now - task.nextRunAt > this.config.missedGraceSeconds * 1000) {
|
|
121
|
+
changed = true; return { ...task, nextRunAt: nextPeriodicTime(task.nextRunAt, task.intervalMs, now), detail: '已跳过错过的周期轮次' };
|
|
122
|
+
}
|
|
123
|
+
if (claimed) return task;
|
|
124
|
+
changed = claimed = true;
|
|
125
|
+
return { ...task, state: 'unknown' as const, claimId, lastRunAt: now, detail: '投递中或未确认;不会自动重试' };
|
|
126
|
+
});
|
|
127
|
+
return changed ? bounded({ ...book, revision, tasks }) : current;
|
|
128
|
+
});
|
|
129
|
+
return book?.tasks.find(task => task.claimId === claimId);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async deliver(task: AdminTask): Promise<void> {
|
|
133
|
+
if (this.#stopped) return;
|
|
134
|
+
let state: AdminTask['state'] = 'unknown'; let detail = '平台未确认投递,请核实目标会话后取消并另建任务;不会自动重试';
|
|
135
|
+
try {
|
|
136
|
+
const id = await this.outbound.sendTarget!(task.target, `⏰ 提醒:${task.text}`);
|
|
137
|
+
if (id) { state = task.intervalMs === null ? 'succeeded' : 'scheduled'; detail = '已确认投递'; }
|
|
138
|
+
} catch (error) {
|
|
139
|
+
if (error instanceof EndpointDeliveryError && error.disposition !== 'unknown') { state = 'failed'; detail = '平台明确未投递或拒绝;任务已停止'; }
|
|
140
|
+
}
|
|
141
|
+
const revision = randomUUID();
|
|
142
|
+
await this.state.update<AdminReminderBook>(ADMIN_REMINDER_STATE_KEY, current => {
|
|
143
|
+
const book = readBook(current); let changed = false;
|
|
144
|
+
const tasks = book.tasks.map(item => {
|
|
145
|
+
if (item.id !== task.id || item.claimId !== task.claimId || item.state !== 'unknown') return item;
|
|
146
|
+
changed = true;
|
|
147
|
+
return { ...item, state, detail, ...(state === 'scheduled' && task.intervalMs !== null
|
|
148
|
+
? { nextRunAt: nextPeriodicTime(task.nextRunAt, task.intervalMs, Date.now()) } : {}) };
|
|
149
|
+
});
|
|
150
|
+
return changed ? bounded({ ...book, revision, tasks }) : current;
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
#assertLive(): void { if (this.#stopped) throw new PluginTaskError('unavailable', '管理员提醒服务属于已停止的插件实例'); }
|
|
155
|
+
async #admit<T>(operation: () => Promise<T>): Promise<T> {
|
|
156
|
+
this.#assertLive(); const release = this.#admission.acquire();
|
|
157
|
+
if (!release) throw new PluginTaskError('unavailable', '管理员提醒服务属于未发布或已退役的 generation');
|
|
158
|
+
try { return await operation(); } finally { release(); }
|
|
159
|
+
}
|
|
160
|
+
#snapshot(book: AdminReminderBook): PluginTaskSnapshot {
|
|
161
|
+
return { revision: book.revision, receipts: book.receipts.map(item => ({ requestId: item.id, taskId: item.taskId, operation: item.operation })), tasks: book.tasks.map(task => ({
|
|
162
|
+
id: task.id, ownership: 'administrator', title: taskTitle(task.text), target: task.target, state: task.state, createdAt: task.createdAt,
|
|
163
|
+
...(task.state === 'scheduled' || task.state === 'paused' ? { nextRunAt: task.nextRunAt } : {}), ...(task.lastRunAt === null ? {} : { lastRunAt: task.lastRunAt }),
|
|
164
|
+
timezone: this.config.timezone, detail: `${task.detail};状态仅表示调度事实,不代表平台连接或投递健康`,
|
|
165
|
+
parameters: { text: task.text, at: new Date(task.nextRunAt).toISOString(), ...(task.intervalMs === null ? {} : { intervalSeconds: task.intervalMs / 1000 }) },
|
|
166
|
+
actions: task.state === 'scheduled' ? ['pause', 'cancel'] : task.state === 'paused' ? ['update', 'resume', 'cancel'] : task.state === 'unknown' ? ['cancel'] : [],
|
|
167
|
+
})) };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function assertMutation(input: PluginTaskMutation): void {
|
|
172
|
+
if (typeof input.requestId !== 'string' || !uuidPattern.test(input.requestId) || typeof input.expectedRevision !== 'string' || !input.expectedRevision || input.expectedRevision.length > 128) throw invalid('操作须包含 UUID requestId 和读取到的 expectedRevision');
|
|
173
|
+
}
|
|
174
|
+
function parseParameters(value: Readonly<Record<string, JsonValue>> | undefined): Parameters {
|
|
175
|
+
if (!value || Array.isArray(value) || Object.keys(value).some(key => !['text', 'at', 'intervalSeconds'].includes(key))
|
|
176
|
+
|| typeof value.text !== 'string' || !value.text.trim() || value.text.length > 500 || typeof value.at !== 'string'
|
|
177
|
+
|| (value.intervalSeconds !== undefined && (typeof value.intervalSeconds !== 'number' || !Number.isSafeInteger(value.intervalSeconds) || value.intervalSeconds < 60 || value.intervalSeconds > MAX_DELAY_SECONDS))) throw invalid('参数须包含 1–500 字提醒内容、带 offset 的 ISO 时间及可选的 60 秒至一年整数周期');
|
|
178
|
+
let at: number;
|
|
179
|
+
try { at = parseReminderInstant(value.at); } catch { throw invalid('提醒时间须为有效且带 Z 或 ±hh:mm 的 ISO 时间'); }
|
|
180
|
+
return { text: value.text, at: new Date(at).toISOString(), ...(value.intervalSeconds === undefined ? {} : { intervalSeconds: value.intervalSeconds as number }) };
|
|
181
|
+
}
|
|
182
|
+
function assertFuture(at: number, now: number): void {
|
|
183
|
+
if (!Number.isSafeInteger(at) || at <= now || at - now > MAX_DELAY_SECONDS * 1000) throw invalid('提醒时间须在未来一年内');
|
|
184
|
+
}
|
|
185
|
+
function digest(value: unknown): string { return createHash('sha256').update(JSON.stringify(value)).digest('hex'); }
|
|
186
|
+
function taskTitle(text: string): string {
|
|
187
|
+
if (text.length <= 200) return text;
|
|
188
|
+
let title = '';
|
|
189
|
+
for (const character of text) {
|
|
190
|
+
if (title.length + character.length > 199) break;
|
|
191
|
+
title += character;
|
|
192
|
+
}
|
|
193
|
+
return `${title}…`;
|
|
194
|
+
}
|
|
195
|
+
function reconcile(book: AdminReminderBook, id: string, fingerprint: string): boolean {
|
|
196
|
+
const previous = book.receipts.find(item => item.id === id);
|
|
197
|
+
if (!previous) return false;
|
|
198
|
+
if (previous.fingerprint !== fingerprint) throw conflict('同一操作请求 ID 已用于其他内容,禁止重放');
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
function assertRevision(book: AdminReminderBook, revision: string): void { if (book.revision !== revision) throw conflict('任务列表已变化,请刷新后重新确认操作'); }
|
|
202
|
+
function receipt(book: AdminReminderBook, id: string, fingerprint: string, taskId: string, operation: MutationReceipt['operation']): readonly MutationReceipt[] {
|
|
203
|
+
return [...book.receipts, { id, fingerprint, taskId, operation }].slice(-128);
|
|
204
|
+
}
|
|
205
|
+
function invalid(message: string): PluginTaskError { return new PluginTaskError('invalid_input', message); }
|
|
206
|
+
function conflict(message: string): PluginTaskError { return new PluginTaskError('conflict', message); }
|
|
207
|
+
function bounded(book: AdminReminderBook): AdminReminderBook {
|
|
208
|
+
if (book.tasks.length > 32 || new TextEncoder().encode(JSON.stringify(book)).byteLength > 60 * 1024) throw invalid('管理员提醒池状态达到 32 条或 60 KiB 上限,请先取消并整理任务');
|
|
209
|
+
return book;
|
|
210
|
+
}
|
|
211
|
+
function readBook(value: AdminReminderBook | undefined): AdminReminderBook {
|
|
212
|
+
if (value === undefined) return { version: 1, revision: '0', tasks: [], receipts: [] };
|
|
213
|
+
try {
|
|
214
|
+
if (value.version !== 1 || typeof value.revision !== 'string' || !value.revision || !Array.isArray(value.tasks) || !Array.isArray(value.receipts) || value.receipts.length > 128) throw new Error('invalid book');
|
|
215
|
+
for (const item of value.receipts) if (!item || !uuidPattern.test(item.id) || !uuidPattern.test(item.taskId)
|
|
216
|
+
|| !['create', 'pause', 'resume', 'cancel', 'update'].includes(item.operation) || !/^[\da-f]{64}$/u.test(item.fingerprint)) throw new Error('invalid receipt');
|
|
217
|
+
const ids = new Set<string>();
|
|
218
|
+
for (const task of value.tasks) {
|
|
219
|
+
if (!task || !uuidPattern.test(task.id) || ids.has(task.id) || typeof task.creation !== 'string' || !/^[\da-f]{64}$/u.test(task.creation)
|
|
220
|
+
|| typeof task.text !== 'string' || !task.text.trim() || task.text.length > 500 || !Number.isSafeInteger(task.createdAt) || !Number.isSafeInteger(task.nextRunAt)
|
|
221
|
+
|| (task.intervalMs !== null && (!Number.isSafeInteger(task.intervalMs) || task.intervalMs < 60_000 || task.intervalMs > MAX_DELAY_SECONDS * 1000))
|
|
222
|
+
|| !['scheduled', 'paused', 'unknown', 'succeeded', 'failed', 'cancelled'].includes(task.state)
|
|
223
|
+
|| (task.claimId !== null && (typeof task.claimId !== 'string' || !task.claimId)) || (task.lastRunAt !== null && !Number.isSafeInteger(task.lastRunAt))
|
|
224
|
+
|| typeof task.detail !== 'string' || !Number.isFinite(new Date(task.nextRunAt).getTime())) throw new Error('invalid task');
|
|
225
|
+
createDeliveryTarget({ conversation: task.target }); ids.add(task.id);
|
|
226
|
+
}
|
|
227
|
+
return bounded(value);
|
|
228
|
+
} catch { throw new PluginTaskError('unavailable', '管理员提醒持久状态格式无效,已停止投递'); }
|
|
229
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { Schema } from 'zhin.js';
|
|
2
2
|
|
|
3
3
|
export const reminderConfig = Schema.object({
|
|
4
|
-
timezone: Schema.string().default('UTC').description('
|
|
5
|
-
maxTasks: Schema.number().min(1).max(32).step(1).default(20).description('
|
|
4
|
+
timezone: Schema.string().default('UTC').description('解释 create 自然时间并显示提醒的 IANA 时区;ISO 绝对时间仍须显式 offset').effect('reload'),
|
|
5
|
+
maxTasks: Schema.number().min(1).max(32).step(1).default(20).description('每实例聊天个人提醒池的活跃任务上限;与管理员池独立,降低上限不删除既有任务').effect('reload'),
|
|
6
|
+
adminMaxTasks: Schema.number().min(1).max(32).step(1).default(20).description('每实例管理员提醒池的活跃任务上限(待执行、暂停或未确认);与聊天池独立,两池总量可达上限之和').effect('reload'),
|
|
6
7
|
maxTasksPerSender: Schema.number().min(1).max(10).step(1).default(10).description('同一发送者在同一会话的任务上限').effect('reload'),
|
|
7
8
|
batchSize: Schema.number().min(1).max(5).step(1).default(5).description('每次扫描最多投递的任务数').effect('reload'),
|
|
8
9
|
missedGraceSeconds: Schema.number().min(5).max(60).step(1).default(15).description('周期轮次超过此延迟后跳过;停机期间不补历史轮次').effect('reload'),
|
|
@@ -11,6 +12,9 @@ export const reminderConfig = Schema.object({
|
|
|
11
12
|
export type ReminderConfig = Schema.Output<typeof reminderConfig>;
|
|
12
13
|
|
|
13
14
|
export function assertReminderTimezone(timezone: string): void {
|
|
14
|
-
try {
|
|
15
|
+
try {
|
|
16
|
+
if (!timezone || timezone.length > 100 || timezone !== timezone.trim() || /^[+-]/u.test(timezone)) throw new RangeError('Invalid IANA timezone');
|
|
17
|
+
new Intl.DateTimeFormat('zh-CN', { timeZone: timezone }).format(0);
|
|
18
|
+
}
|
|
15
19
|
catch { throw new TypeError(`提醒 timezone 必须是有效的 IANA 时区:${timezone}`); }
|
|
16
20
|
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { CommandContext } from 'zhin.js/command';
|
|
2
|
+
import type { ReminderConfig } from './config.js';
|
|
3
|
+
import { reminderActor } from './caller.js';
|
|
4
|
+
import { reminderServiceToken } from './runtime.js';
|
|
5
|
+
import { ReminderInputError } from './service.js';
|
|
6
|
+
import { ReminderTimeInputError, parseNaturalReminderTime, formatReminderTime } from './natural-time.js';
|
|
7
|
+
|
|
8
|
+
const STEP_TIMEOUT = 60_000;
|
|
9
|
+
const FLOW_TIMEOUT = 3 * STEP_TIMEOUT;
|
|
10
|
+
const TIMEOUT_TEXT = '提醒输入超时,未创建提醒;请重新执行 remind create。';
|
|
11
|
+
class ReminderCreationCancelled extends Error {}
|
|
12
|
+
class ReminderCreationDeadline extends Error {}
|
|
13
|
+
class ReminderPreviewDeliveryError extends Error {}
|
|
14
|
+
|
|
15
|
+
/** Personal reminder entry: no persistence is touched before explicit confirmation. */
|
|
16
|
+
export async function createPersonalReminder(
|
|
17
|
+
context: CommandContext<ReminderConfig>,
|
|
18
|
+
parameters: Readonly<{ when?: string; text?: string }>,
|
|
19
|
+
): Promise<string | undefined> {
|
|
20
|
+
if (!context.input?.sender?.id || !context.interaction || !context.input.$replyFrom && !context.input.$reply) return '请在真实聊天会话中使用此指令;需要发送者身份和交互能力,未创建提醒。';
|
|
21
|
+
const deadline = Date.now() + FLOW_TIMEOUT;
|
|
22
|
+
let creating = false;
|
|
23
|
+
try {
|
|
24
|
+
const actor = reminderActor(context.input);
|
|
25
|
+
const interaction = context.interaction;
|
|
26
|
+
const timezone = context.config.timezone;
|
|
27
|
+
const steps = new Map<string, number>();
|
|
28
|
+
const invocationHead = /^(.*?remind)\s+create(?:\s|$)/iu.exec(context.input.content.trim())?.[1];
|
|
29
|
+
const isCommand = (value: string) => /^[!/#.]?remind\s+(?:create|after|at|every|list|cancel)(?:\s|$)/iu.test(value)
|
|
30
|
+
|| !!invocationHead && value.startsWith(`${invocationHead} `);
|
|
31
|
+
const checkDeadline = () => {
|
|
32
|
+
context.signal.throwIfAborted();
|
|
33
|
+
if (Date.now() >= deadline) throw new ReminderCreationDeadline();
|
|
34
|
+
};
|
|
35
|
+
const checkCancel = (value: string) => {
|
|
36
|
+
if (/^(?:取消|cancel)$/iu.test(value.trim())) throw new ReminderCreationCancelled();
|
|
37
|
+
};
|
|
38
|
+
const ask = async (stage: 'time' | 'body' | 'confirmation', title: string, description: string, maxLength: number): Promise<string> => {
|
|
39
|
+
checkDeadline();
|
|
40
|
+
const stepDeadline = steps.get(stage) ?? Date.now() + STEP_TIMEOUT;
|
|
41
|
+
steps.set(stage, stepDeadline);
|
|
42
|
+
const remaining = Math.min(deadline, stepDeadline) - Date.now();
|
|
43
|
+
if (remaining <= 0) throw new ReminderCreationDeadline();
|
|
44
|
+
const answer = (await interaction.ask({ type: 'text', title, description,
|
|
45
|
+
tip: '直接回复内容,不加命令前缀;输入“取消”或 cancel 可退出。', maxLength,
|
|
46
|
+
timeout: remaining, timeoutText: TIMEOUT_TEXT, signal: context.signal,
|
|
47
|
+
})).trim();
|
|
48
|
+
checkDeadline(); checkCancel(answer); return answer;
|
|
49
|
+
};
|
|
50
|
+
let expression = parameters.when?.trim();
|
|
51
|
+
if (expression) checkCancel(expression);
|
|
52
|
+
let parsed: ReturnType<typeof parseNaturalReminderTime> | undefined;
|
|
53
|
+
let timeError = '';
|
|
54
|
+
while (!parsed) {
|
|
55
|
+
checkDeadline();
|
|
56
|
+
if (!expression) expression = await ask('time', '什么时候提醒你?', [timeError, `按 ${timezone} 解析,例如“10分钟后”或“明天上午九点”;也可使用带 offset 的 ISO 时间。`].filter(Boolean).join('\n'), 200);
|
|
57
|
+
try { parsed = parseNaturalReminderTime(expression, { now: Date.now(), timezone }); }
|
|
58
|
+
catch (error) {
|
|
59
|
+
if (!(error instanceof ReminderTimeInputError)) throw error;
|
|
60
|
+
timeError = error.message; expression = undefined;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
let text = parameters.text?.trim();
|
|
64
|
+
if (text) checkCancel(text);
|
|
65
|
+
let textError = '';
|
|
66
|
+
while (!text || text.length > 500 || isCommand(text)) {
|
|
67
|
+
if (text) textError = isCommand(text) ? '当前正在补全提醒;请直接回复正文,或先取消后重新发指令。' : '提醒正文须为 1–500 个字符。';
|
|
68
|
+
text = await ask('body', '提醒你做什么?', [textError, '请输入纯文字提醒正文(1–500 字)。'].filter(Boolean).join('\n'), 500);
|
|
69
|
+
}
|
|
70
|
+
const target = actor.target;
|
|
71
|
+
const scene = `${target.kind} ${target.id}${target.parent ? `;父容器 ${target.parent.kind} ${target.parent.id}` : ''}${target.threadId ? `;话题 ${target.threadId}` : ''}`;
|
|
72
|
+
const account = `${target.endpoint.adapter} / ${target.endpoint.id.replaceAll('\0', ' / ')}`;
|
|
73
|
+
const preview = `时间:${formatReminderTime(parsed.at, parsed.timezone)}\n原会话:${scene}\n账号:${account}\n正文(原样显示):\n${text}\n\n请等待下一条确认提示,再输入“确认”创建;不会更换目标会话。\n若确认后未收到创建结果,请先在原会话用 remind list 核对,不要直接重复创建。`;
|
|
74
|
+
checkDeadline();
|
|
75
|
+
// Canonical text avoids interpreting reminder content as Markdown/HTML in the prompt renderer.
|
|
76
|
+
const previewContent = { type: 'text', data: { text: preview } };
|
|
77
|
+
const receipt = context.input.$reply
|
|
78
|
+
? await context.input.$reply(previewContent)
|
|
79
|
+
: await context.input.$replyFrom!(context.owner.id, previewContent);
|
|
80
|
+
checkDeadline();
|
|
81
|
+
if (!receipt || typeof receipt !== 'object' || !('status' in receipt) || receipt.status !== 'sent') throw new ReminderPreviewDeliveryError();
|
|
82
|
+
let confirmError = '';
|
|
83
|
+
while (true) {
|
|
84
|
+
const answer = await ask('confirmation', '确认创建这条提醒?', [confirmError, '请核对上一条预览的时间、时区、正文和原会话;仅输入“确认”才创建。'].filter(Boolean).join('\n'), 500);
|
|
85
|
+
if (answer === '确认') break;
|
|
86
|
+
confirmError = '未创建提醒。请明确输入“确认”,或输入“取消”退出。';
|
|
87
|
+
}
|
|
88
|
+
checkDeadline();
|
|
89
|
+
if (parsed.at <= Date.now()) return '提醒时间已过,未创建提醒。请重新执行 remind create 并选择未来时间;不会自动顺延。';
|
|
90
|
+
const service = context.use(reminderServiceToken);
|
|
91
|
+
creating = true;
|
|
92
|
+
const task = await service.add(actor, text, parsed.at, null);
|
|
93
|
+
return `已创建个人提醒\n${service.describe(task)}`;
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (error instanceof ReminderCreationCancelled) return '已取消,未创建提醒。';
|
|
96
|
+
if (error instanceof ReminderInputError) return `未创建提醒:${error.message}`;
|
|
97
|
+
if (creating) return '提醒创建结果未能确认,请先使用 remind list 核对;不要直接重复创建。';
|
|
98
|
+
if (context.signal.aborted
|
|
99
|
+
|| error instanceof Error && ['UserInteractionTimeoutError', 'UserInteractionSupersededError', 'AbortError'].includes(error.name)) return undefined;
|
|
100
|
+
if (error instanceof Error && /(?:retired|inactive generation|scope has ended)/iu.test(error.message)) return undefined;
|
|
101
|
+
// Core reports ask timeouts itself; this deadline can instead expire during preview delivery.
|
|
102
|
+
if (error instanceof ReminderCreationDeadline) return TIMEOUT_TEXT;
|
|
103
|
+
if (error instanceof ReminderPreviewDeliveryError || error instanceof Error && error.name === 'UserInteractionDeliveryError') return '提示消息未能确认送达,未创建提醒;请核对会话后重新执行,不会自动重发。';
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
}
|