@zhin.js/plugin-reminder 1.0.5 → 1.0.7
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 +8 -2
- package/commands/remind/after/index.js +6 -5
- package/commands/remind/after/index.ts +6 -5
- package/commands/remind/at/index.js +8 -7
- package/commands/remind/at/index.ts +6 -5
- package/commands/remind/cancel/index.js +5 -4
- package/commands/remind/cancel/index.ts +5 -4
- package/commands/remind/create/index.js +3 -3
- package/commands/remind/create/index.ts +3 -3
- package/commands/remind/every/index.js +6 -5
- package/commands/remind/every/index.ts +6 -5
- package/commands/remind/list/index.js +5 -4
- package/commands/remind/list/index.ts +5 -4
- package/lib/admin-i18n.d.ts +155 -0
- package/lib/admin-i18n.d.ts.map +1 -0
- package/lib/admin-i18n.js +152 -0
- package/lib/admin-i18n.js.map +1 -0
- package/lib/admin-tasks.d.ts +35 -5
- package/lib/admin-tasks.d.ts.map +1 -1
- package/lib/admin-tasks.js +77 -57
- package/lib/admin-tasks.js.map +1 -1
- package/lib/caller.d.ts +2 -1
- package/lib/caller.d.ts.map +1 -1
- package/lib/caller.js +5 -3
- package/lib/caller.js.map +1 -1
- package/lib/config.js +6 -6
- package/lib/config.js.map +1 -1
- package/lib/create-reminder.d.ts.map +1 -1
- package/lib/create-reminder.js +31 -25
- package/lib/create-reminder.js.map +1 -1
- package/lib/duration.d.ts +2 -1
- package/lib/duration.d.ts.map +1 -1
- package/lib/duration.js +4 -3
- package/lib/duration.js.map +1 -1
- package/lib/i18n.d.ts +2 -0
- package/lib/i18n.d.ts.map +1 -0
- package/lib/i18n.js +336 -0
- package/lib/i18n.js.map +1 -0
- package/lib/natural-time.d.ts +5 -1
- package/lib/natural-time.d.ts.map +1 -1
- package/lib/natural-time.js +71 -57
- package/lib/natural-time.js.map +1 -1
- package/lib/service.d.ts +8 -2
- package/lib/service.d.ts.map +1 -1
- package/lib/service.js +47 -36
- package/lib/service.js.map +1 -1
- package/lib/storage.d.ts.map +1 -1
- package/lib/storage.js +7 -5
- package/lib/storage.js.map +1 -1
- package/lib/timing.d.ts +2 -1
- package/lib/timing.d.ts.map +1 -1
- package/lib/timing.js +5 -4
- package/lib/timing.js.map +1 -1
- package/lib/tool-actor.d.ts +2 -0
- package/lib/tool-actor.d.ts.map +1 -1
- package/lib/tool-actor.js +14 -4
- package/lib/tool-actor.js.map +1 -1
- package/package.json +9 -9
- package/plugin.js +8 -3
- package/schema.json +30 -0
- package/skills/reminder/tools/reminder_cancel/index.js +3 -2
- package/skills/reminder/tools/reminder_cancel/index.ts +3 -2
- package/skills/reminder/tools/reminder_create/index.js +7 -6
- package/skills/reminder/tools/reminder_create/index.ts +7 -6
- package/skills/reminder/tools/reminder_list/index.js +3 -2
- package/skills/reminder/tools/reminder_list/index.ts +3 -2
- package/src/admin-i18n.ts +154 -0
- package/src/admin-tasks.ts +79 -53
- package/src/caller.ts +5 -3
- package/src/config.ts +6 -6
- package/src/create-reminder.ts +33 -25
- package/src/duration.ts +5 -3
- package/src/i18n.ts +336 -0
- package/src/natural-time.ts +40 -30
- package/src/service.ts +51 -38
- package/src/storage.ts +7 -5
- package/src/timing.ts +6 -4
- package/src/tool-actor.ts +15 -4
package/src/admin-tasks.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { adminText, taskDetail, formatTaskDetail, isAdminTextKey, LocalizedTaskError, type AdminTextKey } from './admin-i18n.js';
|
|
2
|
+
import { resolveLocale, type Locale } from 'zhin.js/i18n';
|
|
3
|
+
import { reminderMessages as t } from './i18n.js';
|
|
1
4
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
5
|
import { createDeliveryTarget, type DeliveryTarget, type OutboundHost, type PluginStateStore } from 'zhin.js';
|
|
3
6
|
import { PluginTaskError, createGenerationAdmissionGate, generationAdmissionSource, type JsonValue, type PluginTask, type PluginTaskAction, type PluginTaskMutation,
|
|
@@ -9,9 +12,9 @@ import { MAX_DELAY_SECONDS, nextPeriodicTime, parseReminderInstant } from './tim
|
|
|
9
12
|
export const ADMIN_REMINDER_STATE_KEY = 'admin-reminders/v1';
|
|
10
13
|
type AdminTask = {
|
|
11
14
|
readonly id: string; readonly creation: string; readonly target: DeliveryTarget; readonly text: string;
|
|
12
|
-
readonly createdAt: number; readonly nextRunAt: number; readonly intervalMs: number | null;
|
|
15
|
+
readonly locale?: Locale; readonly createdAt: number; readonly nextRunAt: number; readonly intervalMs: number | null;
|
|
13
16
|
readonly state: Exclude<PluginTask['state'], 'running'>; readonly claimId: string | null;
|
|
14
|
-
readonly lastRunAt: number | null; readonly detail: string;
|
|
17
|
+
readonly lastRunAt: number | null; readonly detail: string; readonly detailKey?: AdminTextKey;
|
|
15
18
|
};
|
|
16
19
|
type MutationReceipt = { readonly id: string; readonly fingerprint: string; readonly taskId: string; readonly operation: 'create' | PluginTaskAction };
|
|
17
20
|
export type AdminReminderBook = { readonly version: 1; readonly revision: string;
|
|
@@ -22,60 +25,79 @@ const uuidPattern = /^[\da-f]{8}-(?:[\da-f]{4}-){3}[\da-f]{12}$/iu;
|
|
|
22
25
|
|
|
23
26
|
/** A separate administrator state pool; no sender identity and no access to chat reminders. */
|
|
24
27
|
export class AdministratorReminderTasks implements PluginTaskProvider {
|
|
28
|
+
readonly i18n = {
|
|
29
|
+
"en": {
|
|
30
|
+
"title": "Administrator reminders",
|
|
31
|
+
"description": "Send one-time or recurring reminders to an exact account, conversation and thread, independently of personal chat reminders.",
|
|
32
|
+
"effects": [
|
|
33
|
+
"Creating a task schedules delivery through this plugin instance. Pausing or cancelling cannot recall a delivery already in progress.",
|
|
34
|
+
"Unconfirmed delivery is never retried or resumed automatically. Check the target conversation before cancelling and creating another task."
|
|
35
|
+
]
|
|
36
|
+
}
|
|
37
|
+
} as const;
|
|
25
38
|
readonly title = '管理员提醒';
|
|
26
39
|
readonly description = '独立于聊天个人提醒,向指定账号、会话及线程发送一次性或固定周期提醒';
|
|
27
40
|
readonly effects = ['创建后将在指定时间通过当前插件实例向目标会话发送提醒;暂停或取消不能撤回已开始的投递', '未确认的投递不会自动重试或恢复,需核实目标会话后取消并另建任务'];
|
|
28
41
|
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: '周期秒数(可选)' },
|
|
42
|
+
text: { type: 'string', minLength: 1, maxLength: 500, 'x-i18n': {"en": {"title": "Reminder text"}}, title: '提醒内容' },
|
|
43
|
+
at: { type: 'string', format: 'date-time', 'x-i18n': {"en": {"title": "First run time", "description": "ISO time within the next year, with Z or an explicit ±hh:mm offset"}}, title: '首次执行时间', description: '未来一年内、显式包含 Z 或 ±hh:mm 的 ISO 时间' },
|
|
44
|
+
intervalSeconds: { type: 'integer', minimum: 60, maximum: MAX_DELAY_SECONDS, 'x-i18n': {"en": {"title": "Interval in seconds (optional)"}}, title: '周期秒数(可选)' },
|
|
32
45
|
} } as const;
|
|
33
46
|
#stopped = false;
|
|
34
47
|
readonly #admission = createGenerationAdmissionGate();
|
|
35
48
|
get [generationAdmissionSource]() { return [this.#admission]; }
|
|
36
|
-
|
|
49
|
+
private readonly state: PluginStateStore;
|
|
50
|
+
private readonly outbound: OutboundHost;
|
|
51
|
+
private readonly config: ReminderConfig;
|
|
52
|
+
private readonly projectLocale: Locale;
|
|
53
|
+
constructor(state: PluginStateStore, outbound: OutboundHost, config: ReminderConfig, projectLocale: Locale = 'zh-CN') {
|
|
54
|
+
this.state = state;
|
|
55
|
+
this.outbound = outbound;
|
|
56
|
+
this.config = config;
|
|
57
|
+
this.projectLocale = projectLocale;
|
|
58
|
+
}
|
|
37
59
|
stop(): void { this.#stopped = true; }
|
|
38
60
|
|
|
39
|
-
async read(): Promise<PluginTaskSnapshot> {
|
|
40
|
-
return this.#admit(async () => this.#snapshot(readBook(await this.state.get<AdminReminderBook>(ADMIN_REMINDER_STATE_KEY))));
|
|
61
|
+
async read(options?: { readonly locale?: Locale }): Promise<PluginTaskSnapshot> {
|
|
62
|
+
return this.#admit(async () => this.#snapshot(readBook(await this.state.get<AdminReminderBook>(ADMIN_REMINDER_STATE_KEY)), options?.locale), options?.locale);
|
|
41
63
|
}
|
|
42
64
|
|
|
43
65
|
async create(input: PluginTaskMutation & Readonly<{ target: DeliveryTarget; parameters: Readonly<Record<string, JsonValue>> }>): Promise<PluginTaskSnapshot> {
|
|
44
|
-
return this.#admit(() => this.#create(input));
|
|
66
|
+
return this.#admit(() => this.#create(input), input.locale);
|
|
45
67
|
}
|
|
46
68
|
async #create(input: PluginTaskMutation & Readonly<{ target: DeliveryTarget; parameters: Readonly<Record<string, JsonValue>> }>): Promise<PluginTaskSnapshot> {
|
|
47
69
|
this.#assertLive(); assertMutation(input);
|
|
48
70
|
let target: DeliveryTarget;
|
|
49
|
-
try { target = createDeliveryTarget({ conversation: input.target }); } catch { throw invalid('
|
|
71
|
+
try { target = createDeliveryTarget({ conversation: input.target }); } catch { throw invalid('invalidTarget'); }
|
|
50
72
|
const parameters = parseParameters(input.parameters);
|
|
51
73
|
const fingerprint = digest({ operation: 'create', target, parameters });
|
|
52
74
|
const revision = randomUUID(); const now = Date.now();
|
|
53
|
-
const task: AdminTask = { id: input.requestId, creation: fingerprint, target, text: parameters.text, createdAt: now,
|
|
75
|
+
const task: AdminTask = { id: input.requestId, creation: fingerprint, target, locale: this.projectLocale, text: parameters.text, createdAt: now,
|
|
54
76
|
nextRunAt: Date.parse(parameters.at), intervalMs: parameters.intervalSeconds === undefined ? null : parameters.intervalSeconds * 1000,
|
|
55
|
-
state: 'scheduled', claimId: null, lastRunAt: null,
|
|
77
|
+
state: 'scheduled', claimId: null, lastRunAt: null, ...taskDetail('waiting') };
|
|
56
78
|
const result = await this.state.update<AdminReminderBook>(ADMIN_REMINDER_STATE_KEY, current => {
|
|
57
79
|
this.#assertLive(); const book = readBook(current);
|
|
58
80
|
if (reconcile(book, input.requestId, fingerprint)) return book;
|
|
59
81
|
assertRevision(book, input.expectedRevision);
|
|
60
82
|
const existing = book.tasks.find(item => item.id === input.requestId);
|
|
61
|
-
if (existing) { if (existing.creation !== fingerprint) throw conflict('
|
|
83
|
+
if (existing) { if (existing.creation !== fingerprint) throw conflict('requestReused'); return book; }
|
|
62
84
|
assertFuture(task.nextRunAt, now);
|
|
63
85
|
const active = book.tasks.filter(item => activeStates.has(item.state));
|
|
64
|
-
if (active.length >= this.config.adminMaxTasks) throw invalid('
|
|
86
|
+
if (active.length >= this.config.adminMaxTasks) throw invalid('quota');
|
|
65
87
|
const historyLimit = Math.max(0, this.config.adminMaxTasks - active.length - 1);
|
|
66
88
|
const history = historyLimit ? book.tasks.filter(item => !activeStates.has(item.state)).slice(-historyLimit) : [];
|
|
67
89
|
return bounded({ ...book, revision, tasks: [...active, ...history, task], receipts: receipt(book, input.requestId, fingerprint, task.id, 'create') });
|
|
68
90
|
});
|
|
69
|
-
return this.#snapshot(readBook(result));
|
|
91
|
+
return this.#snapshot(readBook(result), input.locale);
|
|
70
92
|
}
|
|
71
93
|
|
|
72
94
|
async change(input: PluginTaskMutation & Readonly<{ id: string; action: PluginTaskAction; parameters?: Readonly<Record<string, JsonValue>> }>): Promise<PluginTaskSnapshot> {
|
|
73
|
-
return this.#admit(() => this.#change(input));
|
|
95
|
+
return this.#admit(() => this.#change(input), input.locale);
|
|
74
96
|
}
|
|
75
97
|
async #change(input: PluginTaskMutation & Readonly<{ id: string; action: PluginTaskAction; parameters?: Readonly<Record<string, JsonValue>> }>): Promise<PluginTaskSnapshot> {
|
|
76
98
|
this.#assertLive(); assertMutation(input);
|
|
77
|
-
if (typeof input.id !== 'string' || !uuidPattern.test(input.id) || !['pause', 'resume', 'cancel', 'update'].includes(input.action)) throw invalid('
|
|
78
|
-
if (input.action !== 'update' && input.parameters !== undefined) throw invalid('
|
|
99
|
+
if (typeof input.id !== 'string' || !uuidPattern.test(input.id) || !['pause', 'resume', 'cancel', 'update'].includes(input.action)) throw invalid('invalidAction');
|
|
100
|
+
if (input.action !== 'update' && input.parameters !== undefined) throw invalid('updateParameters');
|
|
79
101
|
const parameters = input.action === 'update' ? parseParameters(input.parameters) : undefined;
|
|
80
102
|
const fingerprint = digest({ operation: 'change', id: input.id, action: input.action, parameters: parameters ?? null });
|
|
81
103
|
const revision = randomUUID(); const now = Date.now();
|
|
@@ -84,30 +106,30 @@ export class AdministratorReminderTasks implements PluginTaskProvider {
|
|
|
84
106
|
if (reconcile(book, input.requestId, fingerprint)) return book;
|
|
85
107
|
assertRevision(book, input.expectedRevision);
|
|
86
108
|
const task = book.tasks.find(item => item.id === input.id);
|
|
87
|
-
if (!task) throw new
|
|
109
|
+
if (!task) throw new LocalizedTaskError('not_found', 'notFound');
|
|
88
110
|
let changed: AdminTask;
|
|
89
111
|
switch (input.action) {
|
|
90
112
|
case 'pause':
|
|
91
|
-
if (task.state !== 'scheduled') throw invalid('
|
|
92
|
-
changed = { ...task, state: 'paused',
|
|
113
|
+
if (task.state !== 'scheduled') throw invalid('pauseState');
|
|
114
|
+
changed = { ...task, state: 'paused', ...taskDetail('paused') }; break;
|
|
93
115
|
case 'resume': {
|
|
94
|
-
if (task.state !== 'paused') throw invalid('
|
|
95
|
-
if (task.intervalMs === null && task.nextRunAt <= now) throw invalid('
|
|
116
|
+
if (task.state !== 'paused') throw invalid('resumeState');
|
|
117
|
+
if (task.intervalMs === null && task.nextRunAt <= now) throw invalid('expired');
|
|
96
118
|
const nextRunAt = task.nextRunAt <= now && task.intervalMs !== null ? nextPeriodicTime(task.nextRunAt, task.intervalMs, now) : task.nextRunAt;
|
|
97
|
-
changed = { ...task, state: 'scheduled', nextRunAt,
|
|
119
|
+
changed = { ...task, state: 'scheduled', nextRunAt, ...taskDetail(nextRunAt !== task.nextRunAt ? 'resumedSkipping' : 'resumed') }; break;
|
|
98
120
|
}
|
|
99
121
|
case 'update':
|
|
100
|
-
if (task.state !== 'paused') throw invalid('
|
|
122
|
+
if (task.state !== 'paused') throw invalid('updateState');
|
|
101
123
|
assertFuture(Date.parse(parameters!.at), now);
|
|
102
124
|
changed = { ...task, text: parameters!.text, nextRunAt: Date.parse(parameters!.at),
|
|
103
|
-
intervalMs: parameters!.intervalSeconds === undefined ? null : parameters!.intervalSeconds * 1000,
|
|
125
|
+
intervalMs: parameters!.intervalSeconds === undefined ? null : parameters!.intervalSeconds * 1000, ...taskDetail('edited') }; break;
|
|
104
126
|
case 'cancel':
|
|
105
|
-
if (!activeStates.has(task.state)) throw invalid('
|
|
106
|
-
changed = { ...task, state: 'cancelled',
|
|
127
|
+
if (!activeStates.has(task.state)) throw invalid('finished');
|
|
128
|
+
changed = { ...task, state: 'cancelled', ...taskDetail('cancelled') }; break;
|
|
107
129
|
}
|
|
108
130
|
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
131
|
});
|
|
110
|
-
return this.#snapshot(readBook(result));
|
|
132
|
+
return this.#snapshot(readBook(result), input.locale);
|
|
111
133
|
}
|
|
112
134
|
|
|
113
135
|
/** Called only by the existing ReminderService worker, within its shared batch limit. */
|
|
@@ -118,11 +140,11 @@ export class AdministratorReminderTasks implements PluginTaskProvider {
|
|
|
118
140
|
const tasks = book.tasks.map(task => {
|
|
119
141
|
if (task.state !== 'scheduled' || task.nextRunAt > now) return task;
|
|
120
142
|
if (task.intervalMs !== null && now - task.nextRunAt > this.config.missedGraceSeconds * 1000) {
|
|
121
|
-
changed = true; return { ...task, nextRunAt: nextPeriodicTime(task.nextRunAt, task.intervalMs, now),
|
|
143
|
+
changed = true; return { ...task, nextRunAt: nextPeriodicTime(task.nextRunAt, task.intervalMs, now), ...taskDetail('skipped') };
|
|
122
144
|
}
|
|
123
145
|
if (claimed) return task;
|
|
124
146
|
changed = claimed = true;
|
|
125
|
-
return { ...task, state: 'unknown' as const, claimId, lastRunAt: now,
|
|
147
|
+
return { ...task, state: 'unknown' as const, claimId, lastRunAt: now, ...taskDetail('delivering') };
|
|
126
148
|
});
|
|
127
149
|
return changed ? bounded({ ...book, revision, tasks }) : current;
|
|
128
150
|
});
|
|
@@ -131,12 +153,12 @@ export class AdministratorReminderTasks implements PluginTaskProvider {
|
|
|
131
153
|
|
|
132
154
|
async deliver(task: AdminTask): Promise<void> {
|
|
133
155
|
if (this.#stopped) return;
|
|
134
|
-
let state: AdminTask['state'] = 'unknown'; let detail = '
|
|
156
|
+
let state: AdminTask['state'] = 'unknown'; let detail = taskDetail('unknown');
|
|
135
157
|
try {
|
|
136
|
-
const id = await this.outbound.sendTarget!(task.target,
|
|
137
|
-
if (id) { state = task.intervalMs === null ? 'succeeded' : 'scheduled'; detail = '
|
|
158
|
+
const id = await this.outbound.sendTarget!(task.target, t(resolveLocale(task.locale), 'notification', { text: task.text }), { locale: resolveLocale(task.locale) });
|
|
159
|
+
if (id) { state = task.intervalMs === null ? 'succeeded' : 'scheduled'; detail = taskDetail('delivered'); }
|
|
138
160
|
} catch (error) {
|
|
139
|
-
if (error instanceof EndpointDeliveryError && error.disposition !== 'unknown') { state = 'failed'; detail = '
|
|
161
|
+
if (error instanceof EndpointDeliveryError && error.disposition !== 'unknown') { state = 'failed'; detail = taskDetail('rejected'); }
|
|
140
162
|
}
|
|
141
163
|
const revision = randomUUID();
|
|
142
164
|
await this.state.update<AdminReminderBook>(ADMIN_REMINDER_STATE_KEY, current => {
|
|
@@ -144,24 +166,27 @@ export class AdministratorReminderTasks implements PluginTaskProvider {
|
|
|
144
166
|
const tasks = book.tasks.map(item => {
|
|
145
167
|
if (item.id !== task.id || item.claimId !== task.claimId || item.state !== 'unknown') return item;
|
|
146
168
|
changed = true;
|
|
147
|
-
return { ...item, state, detail, ...(state === 'scheduled' && task.intervalMs !== null
|
|
169
|
+
return { ...item, state, ...detail, ...(state === 'scheduled' && task.intervalMs !== null
|
|
148
170
|
? { nextRunAt: nextPeriodicTime(task.nextRunAt, task.intervalMs, Date.now()) } : {}) };
|
|
149
171
|
});
|
|
150
172
|
return changed ? bounded({ ...book, revision, tasks }) : current;
|
|
151
173
|
});
|
|
152
174
|
}
|
|
153
175
|
|
|
154
|
-
#assertLive(): void { if (this.#stopped) throw new
|
|
155
|
-
async #admit<T>(operation: () => Promise<T
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
176
|
+
#assertLive(): void { if (this.#stopped) throw new LocalizedTaskError('unavailable', 'stopped'); }
|
|
177
|
+
async #admit<T>(operation: () => Promise<T>, locale?: Locale): Promise<T> {
|
|
178
|
+
let release: (() => void) | undefined;
|
|
179
|
+
try {
|
|
180
|
+
this.#assertLive(); release = this.#admission.acquire();
|
|
181
|
+
if (!release) throw new LocalizedTaskError('unavailable', 'retired');
|
|
182
|
+
return await operation();
|
|
183
|
+
} catch (error) { throw error instanceof LocalizedTaskError ? error.localize(locale) : error; } finally { release?.(); }
|
|
159
184
|
}
|
|
160
|
-
#snapshot(book: AdminReminderBook): PluginTaskSnapshot {
|
|
185
|
+
#snapshot(book: AdminReminderBook, locale?: Locale): PluginTaskSnapshot {
|
|
161
186
|
return { revision: book.revision, receipts: book.receipts.map(item => ({ requestId: item.id, taskId: item.taskId, operation: item.operation })), tasks: book.tasks.map(task => ({
|
|
162
187
|
id: task.id, ownership: 'administrator', title: taskTitle(task.text), target: task.target, state: task.state, createdAt: task.createdAt,
|
|
163
188
|
...(task.state === 'scheduled' || task.state === 'paused' ? { nextRunAt: task.nextRunAt } : {}), ...(task.lastRunAt === null ? {} : { lastRunAt: task.lastRunAt }),
|
|
164
|
-
timezone: this.config.timezone, detail:
|
|
189
|
+
timezone: this.config.timezone, detail: adminText(locale, 'stateNote', { detail: formatTaskDetail(task, locale) }),
|
|
165
190
|
parameters: { text: task.text, at: new Date(task.nextRunAt).toISOString(), ...(task.intervalMs === null ? {} : { intervalSeconds: task.intervalMs / 1000 }) },
|
|
166
191
|
actions: task.state === 'scheduled' ? ['pause', 'cancel'] : task.state === 'paused' ? ['update', 'resume', 'cancel'] : task.state === 'unknown' ? ['cancel'] : [],
|
|
167
192
|
})) };
|
|
@@ -169,18 +194,18 @@ export class AdministratorReminderTasks implements PluginTaskProvider {
|
|
|
169
194
|
}
|
|
170
195
|
|
|
171
196
|
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('
|
|
197
|
+
if (typeof input.requestId !== 'string' || !uuidPattern.test(input.requestId) || typeof input.expectedRevision !== 'string' || !input.expectedRevision || input.expectedRevision.length > 128) throw invalid('invalidMutation');
|
|
173
198
|
}
|
|
174
199
|
function parseParameters(value: Readonly<Record<string, JsonValue>> | undefined): Parameters {
|
|
175
200
|
if (!value || Array.isArray(value) || Object.keys(value).some(key => !['text', 'at', 'intervalSeconds'].includes(key))
|
|
176
201
|
|| 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('
|
|
202
|
+
|| (value.intervalSeconds !== undefined && (typeof value.intervalSeconds !== 'number' || !Number.isSafeInteger(value.intervalSeconds) || value.intervalSeconds < 60 || value.intervalSeconds > MAX_DELAY_SECONDS))) throw invalid('invalidParameters');
|
|
178
203
|
let at: number;
|
|
179
|
-
try { at = parseReminderInstant(value.at); } catch { throw invalid('
|
|
204
|
+
try { at = parseReminderInstant(value.at); } catch { throw invalid('invalidInstant'); }
|
|
180
205
|
return { text: value.text, at: new Date(at).toISOString(), ...(value.intervalSeconds === undefined ? {} : { intervalSeconds: value.intervalSeconds as number }) };
|
|
181
206
|
}
|
|
182
207
|
function assertFuture(at: number, now: number): void {
|
|
183
|
-
if (!Number.isSafeInteger(at) || at <= now || at - now > MAX_DELAY_SECONDS * 1000) throw invalid('
|
|
208
|
+
if (!Number.isSafeInteger(at) || at <= now || at - now > MAX_DELAY_SECONDS * 1000) throw invalid('future');
|
|
184
209
|
}
|
|
185
210
|
function digest(value: unknown): string { return createHash('sha256').update(JSON.stringify(value)).digest('hex'); }
|
|
186
211
|
function taskTitle(text: string): string {
|
|
@@ -195,17 +220,17 @@ function taskTitle(text: string): string {
|
|
|
195
220
|
function reconcile(book: AdminReminderBook, id: string, fingerprint: string): boolean {
|
|
196
221
|
const previous = book.receipts.find(item => item.id === id);
|
|
197
222
|
if (!previous) return false;
|
|
198
|
-
if (previous.fingerprint !== fingerprint) throw conflict('
|
|
223
|
+
if (previous.fingerprint !== fingerprint) throw conflict('replayConflict');
|
|
199
224
|
return true;
|
|
200
225
|
}
|
|
201
|
-
function assertRevision(book: AdminReminderBook, revision: string): void { if (book.revision !== revision) throw conflict('
|
|
226
|
+
function assertRevision(book: AdminReminderBook, revision: string): void { if (book.revision !== revision) throw conflict('revision'); }
|
|
202
227
|
function receipt(book: AdminReminderBook, id: string, fingerprint: string, taskId: string, operation: MutationReceipt['operation']): readonly MutationReceipt[] {
|
|
203
228
|
return [...book.receipts, { id, fingerprint, taskId, operation }].slice(-128);
|
|
204
229
|
}
|
|
205
|
-
function invalid(message:
|
|
206
|
-
function conflict(message:
|
|
230
|
+
function invalid(message: AdminTextKey): PluginTaskError { return new LocalizedTaskError('invalid_input', message); }
|
|
231
|
+
function conflict(message: AdminTextKey): PluginTaskError { return new LocalizedTaskError('conflict', message); }
|
|
207
232
|
function bounded(book: AdminReminderBook): AdminReminderBook {
|
|
208
|
-
if (book.tasks.length > 32 || new TextEncoder().encode(JSON.stringify(book)).byteLength > 60 * 1024) throw invalid('
|
|
233
|
+
if (book.tasks.length > 32 || new TextEncoder().encode(JSON.stringify(book)).byteLength > 60 * 1024) throw invalid('capacity');
|
|
209
234
|
return book;
|
|
210
235
|
}
|
|
211
236
|
function readBook(value: AdminReminderBook | undefined): AdminReminderBook {
|
|
@@ -221,9 +246,10 @@ function readBook(value: AdminReminderBook | undefined): AdminReminderBook {
|
|
|
221
246
|
|| (task.intervalMs !== null && (!Number.isSafeInteger(task.intervalMs) || task.intervalMs < 60_000 || task.intervalMs > MAX_DELAY_SECONDS * 1000))
|
|
222
247
|
|| !['scheduled', 'paused', 'unknown', 'succeeded', 'failed', 'cancelled'].includes(task.state)
|
|
223
248
|
|| (task.claimId !== null && (typeof task.claimId !== 'string' || !task.claimId)) || (task.lastRunAt !== null && !Number.isSafeInteger(task.lastRunAt))
|
|
249
|
+
|| (task.locale !== undefined && task.locale !== 'zh-CN' && task.locale !== 'en') || (task.detailKey !== undefined && !isAdminTextKey(task.detailKey))
|
|
224
250
|
|| typeof task.detail !== 'string' || !Number.isFinite(new Date(task.nextRunAt).getTime())) throw new Error('invalid task');
|
|
225
251
|
createDeliveryTarget({ conversation: task.target }); ids.add(task.id);
|
|
226
252
|
}
|
|
227
253
|
return bounded(value);
|
|
228
|
-
} catch { throw new
|
|
254
|
+
} catch { throw new LocalizedTaskError('unavailable', 'corrupt'); }
|
|
229
255
|
}
|
package/src/caller.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
import { resolveLocale, type Locale } from 'zhin.js/i18n';
|
|
2
|
+
import { reminderMessages as t } from './i18n.js';
|
|
1
3
|
import { createDeliveryTarget } from 'zhin.js';
|
|
2
4
|
import type { CommandMessage } from '@zhin.js/command';
|
|
3
5
|
import { ReminderInputError, type ReminderActor } from './service.js';
|
|
4
6
|
|
|
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) };
|
|
7
|
+
export function reminderActor(input: CommandMessage | undefined, locale: Locale = resolveLocale(input?.locale)): ReminderActor {
|
|
8
|
+
if (!input?.sender?.id) throw new ReminderInputError(t(locale, 'actorRequired'));
|
|
9
|
+
return { senderId: input.sender.id, target: createDeliveryTarget(input), locale };
|
|
8
10
|
}
|
|
9
11
|
|
|
10
12
|
export async function replyReminder(operation: () => Promise<string>): Promise<string> {
|
package/src/config.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { Schema } from 'zhin.js';
|
|
2
2
|
|
|
3
3
|
export const reminderConfig = Schema.object({
|
|
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'),
|
|
7
|
-
maxTasksPerSender: Schema.number().min(1).max(10).step(1).default(10).description('同一发送者在同一会话的任务上限').effect('reload'),
|
|
8
|
-
batchSize: Schema.number().min(1).max(5).step(1).default(5).description('每次扫描最多投递的任务数').effect('reload'),
|
|
9
|
-
missedGraceSeconds: Schema.number().min(5).max(60).step(1).default(15).description('周期轮次超过此延迟后跳过;停机期间不补历史轮次').effect('reload'),
|
|
4
|
+
timezone: Schema.string().default('UTC').description('解释 create 自然时间并显示提醒的 IANA 时区;ISO 绝对时间仍须显式 offset').i18n({"en": {"description": "IANA timezone for natural reminder times and display. Absolute ISO times still require an explicit offset."}}).effect('reload'),
|
|
5
|
+
maxTasks: Schema.number().min(1).max(32).step(1).default(20).description('每实例聊天个人提醒池的活跃任务上限;与管理员池独立,降低上限不删除既有任务').i18n({"en": {"description": "Maximum active personal chat reminders per instance, separate from administrator tasks. Lowering the limit does not delete existing tasks."}}).effect('reload'),
|
|
6
|
+
adminMaxTasks: Schema.number().min(1).max(32).step(1).default(20).description('每实例管理员提醒池的活跃任务上限(待执行、暂停或未确认);与聊天池独立,两池总量可达上限之和').i18n({"en": {"description": "Maximum active administrator reminders (scheduled, paused or unconfirmed) per instance. Separate from chat tasks; the two quotas are additive."}}).effect('reload'),
|
|
7
|
+
maxTasksPerSender: Schema.number().min(1).max(10).step(1).default(10).description('同一发送者在同一会话的任务上限').i18n({"en": {"description": "Maximum tasks per sender in the same conversation."}}).effect('reload'),
|
|
8
|
+
batchSize: Schema.number().min(1).max(5).step(1).default(5).description('每次扫描最多投递的任务数').i18n({"en": {"description": "Maximum tasks delivered in each scan."}}).effect('reload'),
|
|
9
|
+
missedGraceSeconds: Schema.number().min(5).max(60).step(1).default(15).description('周期轮次超过此延迟后跳过;停机期间不补历史轮次').i18n({"en": {"description": "Skip recurring runs delayed beyond this many seconds. Missed runs during downtime are not backfilled."}}).effect('reload'),
|
|
10
10
|
});
|
|
11
11
|
|
|
12
12
|
export type ReminderConfig = Schema.Output<typeof reminderConfig>;
|
package/src/create-reminder.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { resolveLocale } from 'zhin.js/i18n';
|
|
2
|
+
import { reminderMessages as t } from './i18n.js';
|
|
1
3
|
import type { CommandContext } from 'zhin.js/command';
|
|
2
4
|
import type { ReminderConfig } from './config.js';
|
|
3
5
|
import { reminderActor } from './caller.js';
|
|
@@ -7,7 +9,6 @@ import { ReminderTimeInputError, parseNaturalReminderTime, formatReminderTime }
|
|
|
7
9
|
|
|
8
10
|
const STEP_TIMEOUT = 60_000;
|
|
9
11
|
const FLOW_TIMEOUT = 3 * STEP_TIMEOUT;
|
|
10
|
-
const TIMEOUT_TEXT = '提醒输入超时,未创建提醒;请重新执行 remind create。';
|
|
11
12
|
class ReminderCreationCancelled extends Error {}
|
|
12
13
|
class ReminderCreationDeadline extends Error {}
|
|
13
14
|
class ReminderPreviewDeliveryError extends Error {}
|
|
@@ -17,23 +18,28 @@ export async function createPersonalReminder(
|
|
|
17
18
|
context: CommandContext<ReminderConfig>,
|
|
18
19
|
parameters: Readonly<{ when?: string; text?: string }>,
|
|
19
20
|
): Promise<string | undefined> {
|
|
20
|
-
|
|
21
|
+
const locale = resolveLocale(context.locale);
|
|
22
|
+
const createCommand = context.formatCommand('remind create');
|
|
23
|
+
const listCommand = context.formatCommand('remind list');
|
|
24
|
+
const restart = createCommand ? t(locale, 'restartCommand', { command: createCommand }) : t(locale, 'restart');
|
|
25
|
+
const timeoutText = `${t(locale, 'flowTimeout')} ${restart}`;
|
|
26
|
+
const checkExisting = listCommand ? t(locale, 'checkCommand', { command: listCommand }) : t(locale, 'checkNoCommand');
|
|
27
|
+
if (!context.input?.sender?.id || !context.interaction || !context.input.$replyFrom && !context.input.$reply) return t(locale, 'needsChat');
|
|
21
28
|
const deadline = Date.now() + FLOW_TIMEOUT;
|
|
22
29
|
let creating = false;
|
|
23
30
|
try {
|
|
24
|
-
const actor = reminderActor(context.input);
|
|
31
|
+
const actor = reminderActor(context.input, locale);
|
|
25
32
|
const interaction = context.interaction;
|
|
26
33
|
const timezone = context.config.timezone;
|
|
27
34
|
const steps = new Map<string, number>();
|
|
28
|
-
const
|
|
29
|
-
const isCommand = (value: string) =>
|
|
30
|
-
|| !!invocationHead && value.startsWith(`${invocationHead} `);
|
|
35
|
+
const commandHeads = ['create', 'after', 'at', 'every', 'list', 'cancel'].map(name => context.formatCommand(`remind ${name}`)).filter((value): value is string => !!value);
|
|
36
|
+
const isCommand = (value: string) => commandHeads.some(head => value === head || value.startsWith(`${head} `));
|
|
31
37
|
const checkDeadline = () => {
|
|
32
38
|
context.signal.throwIfAborted();
|
|
33
39
|
if (Date.now() >= deadline) throw new ReminderCreationDeadline();
|
|
34
40
|
};
|
|
35
41
|
const checkCancel = (value: string) => {
|
|
36
|
-
if (
|
|
42
|
+
if (value.trim().toLowerCase() === (locale === 'en' ? 'cancel' : '取消')) throw new ReminderCreationCancelled();
|
|
37
43
|
};
|
|
38
44
|
const ask = async (stage: 'time' | 'body' | 'confirmation', title: string, description: string, maxLength: number): Promise<string> => {
|
|
39
45
|
checkDeadline();
|
|
@@ -42,8 +48,8 @@ export async function createPersonalReminder(
|
|
|
42
48
|
const remaining = Math.min(deadline, stepDeadline) - Date.now();
|
|
43
49
|
if (remaining <= 0) throw new ReminderCreationDeadline();
|
|
44
50
|
const answer = (await interaction.ask({ type: 'text', title, description,
|
|
45
|
-
tip:
|
|
46
|
-
timeout: remaining, timeoutText
|
|
51
|
+
tip: t(locale, 'answerTip'), maxLength,
|
|
52
|
+
timeout: remaining, timeoutText, signal: context.signal,
|
|
47
53
|
})).trim();
|
|
48
54
|
checkDeadline(); checkCancel(answer); return answer;
|
|
49
55
|
};
|
|
@@ -53,8 +59,10 @@ export async function createPersonalReminder(
|
|
|
53
59
|
let timeError = '';
|
|
54
60
|
while (!parsed) {
|
|
55
61
|
checkDeadline();
|
|
56
|
-
if (!expression) expression = await ask('time', '
|
|
57
|
-
try {
|
|
62
|
+
if (!expression) expression = await ask('time', t(locale, 'askWhen'), [timeError, t(locale, 'whenHelp', { timezone })].filter(Boolean).join('\n'), 200);
|
|
63
|
+
try {
|
|
64
|
+
parsed = parseNaturalReminderTime(expression, { now: Date.now(), timezone, locale });
|
|
65
|
+
}
|
|
58
66
|
catch (error) {
|
|
59
67
|
if (!(error instanceof ReminderTimeInputError)) throw error;
|
|
60
68
|
timeError = error.message; expression = undefined;
|
|
@@ -64,13 +72,13 @@ export async function createPersonalReminder(
|
|
|
64
72
|
if (text) checkCancel(text);
|
|
65
73
|
let textError = '';
|
|
66
74
|
while (!text || text.length > 500 || isCommand(text)) {
|
|
67
|
-
if (text) textError = isCommand(text) ? '
|
|
68
|
-
text = await ask('body', '
|
|
75
|
+
if (text) textError = isCommand(text) ? t(locale, 'bodyCommand') : t(locale, 'bodyLimit');
|
|
76
|
+
text = await ask('body', t(locale, 'askBody'), [textError, t(locale, 'bodyHelp')].filter(Boolean).join('\n'), 500);
|
|
69
77
|
}
|
|
70
78
|
const target = actor.target;
|
|
71
|
-
const scene = `${target.kind} ${target.id}${target.parent ?
|
|
79
|
+
const scene = `${target.kind} ${target.id}${target.parent ? `; ${t(locale, 'parent')} ${target.parent.kind} ${target.parent.id}` : ''}${target.threadId ? `; ${t(locale, 'thread')} ${target.threadId}` : ''}`;
|
|
72
80
|
const account = `${target.endpoint.adapter} / ${target.endpoint.id.replaceAll('\0', ' / ')}`;
|
|
73
|
-
const preview =
|
|
81
|
+
const preview = `${t(locale, 'preview', { time: formatReminderTime(parsed.at, parsed.timezone), scene, account, text })}\n${checkExisting}`;
|
|
74
82
|
checkDeadline();
|
|
75
83
|
// Canonical text avoids interpreting reminder content as Markdown/HTML in the prompt renderer.
|
|
76
84
|
const previewContent = { type: 'text', data: { text: preview } };
|
|
@@ -81,26 +89,26 @@ export async function createPersonalReminder(
|
|
|
81
89
|
if (!receipt || typeof receipt !== 'object' || !('status' in receipt) || receipt.status !== 'sent') throw new ReminderPreviewDeliveryError();
|
|
82
90
|
let confirmError = '';
|
|
83
91
|
while (true) {
|
|
84
|
-
const answer = await ask('confirmation', '
|
|
85
|
-
if (answer === '确认') break;
|
|
86
|
-
confirmError = '
|
|
92
|
+
const answer = await ask('confirmation', t(locale, 'askConfirm'), [confirmError, t(locale, 'confirmHelp')].filter(Boolean).join('\n'), 500);
|
|
93
|
+
if (answer.toLowerCase() === (locale === 'en' ? 'confirm' : '确认')) break;
|
|
94
|
+
confirmError = t(locale, 'confirmInvalid');
|
|
87
95
|
}
|
|
88
96
|
checkDeadline();
|
|
89
|
-
if (parsed.at <= Date.now()) return '
|
|
97
|
+
if (parsed.at <= Date.now()) return `${t(locale, 'expired')} ${restart}`;
|
|
90
98
|
const service = context.use(reminderServiceToken);
|
|
91
99
|
creating = true;
|
|
92
100
|
const task = await service.add(actor, text, parsed.at, null);
|
|
93
|
-
return
|
|
101
|
+
return `${t(locale, 'personalCreated')}\n${service.describe(task, locale)}`;
|
|
94
102
|
} catch (error) {
|
|
95
|
-
if (error instanceof ReminderCreationCancelled) return '
|
|
96
|
-
if (error instanceof ReminderInputError) return
|
|
97
|
-
if (creating) return
|
|
103
|
+
if (error instanceof ReminderCreationCancelled) return t(locale, 'flowCancelled');
|
|
104
|
+
if (error instanceof ReminderInputError) return t(locale, 'notCreated', { error: error.message });
|
|
105
|
+
if (creating) return checkExisting;
|
|
98
106
|
if (context.signal.aborted
|
|
99
107
|
|| error instanceof Error && ['UserInteractionTimeoutError', 'UserInteractionSupersededError', 'AbortError'].includes(error.name)) return undefined;
|
|
100
108
|
if (error instanceof Error && /(?:retired|inactive generation|scope has ended)/iu.test(error.message)) return undefined;
|
|
101
109
|
// Core reports ask timeouts itself; this deadline can instead expire during preview delivery.
|
|
102
|
-
if (error instanceof ReminderCreationDeadline) return
|
|
103
|
-
if (error instanceof ReminderPreviewDeliveryError || error instanceof Error && error.name === 'UserInteractionDeliveryError') return '
|
|
110
|
+
if (error instanceof ReminderCreationDeadline) return timeoutText;
|
|
111
|
+
if (error instanceof ReminderPreviewDeliveryError || error instanceof Error && error.name === 'UserInteractionDeliveryError') return t(locale, 'previewFailed');
|
|
104
112
|
throw error;
|
|
105
113
|
}
|
|
106
114
|
}
|
package/src/duration.ts
CHANGED
|
@@ -1,18 +1,20 @@
|
|
|
1
|
+
import type { Locale } from 'zhin.js/i18n';
|
|
2
|
+
import { reminderMessages as t } from './i18n.js';
|
|
1
3
|
/** Relative durations for Agent tools: `10m`, `2h`, `1d`, or plain seconds. */
|
|
2
4
|
import { ReminderInputError } from "./service.js";
|
|
3
5
|
|
|
4
|
-
export function parseRelativeDelayMs(value: string): number {
|
|
6
|
+
export function parseRelativeDelayMs(value: string, locale?: Locale): number {
|
|
5
7
|
const text = value.trim().toLowerCase();
|
|
6
8
|
if (/^\d+$/.test(text)) {
|
|
7
9
|
const seconds = Number(text);
|
|
8
10
|
if (!Number.isSafeInteger(seconds) || seconds < 1) {
|
|
9
|
-
throw new ReminderInputError(
|
|
11
|
+
throw new ReminderInputError(t(locale, 'durationInvalid'));
|
|
10
12
|
}
|
|
11
13
|
return seconds * 1000;
|
|
12
14
|
}
|
|
13
15
|
const match = /^(\d+)\s*(s|m|h|d|秒|分|分钟|小时|天)$/u.exec(text);
|
|
14
16
|
if (!match) {
|
|
15
|
-
throw new ReminderInputError(
|
|
17
|
+
throw new ReminderInputError(t(locale, 'durationFormat'));
|
|
16
18
|
}
|
|
17
19
|
const amount = Number(match[1]);
|
|
18
20
|
const unit = match[2]!;
|