@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.
Files changed (78) hide show
  1. package/README.md +8 -2
  2. package/commands/remind/after/index.js +6 -5
  3. package/commands/remind/after/index.ts +6 -5
  4. package/commands/remind/at/index.js +8 -7
  5. package/commands/remind/at/index.ts +6 -5
  6. package/commands/remind/cancel/index.js +5 -4
  7. package/commands/remind/cancel/index.ts +5 -4
  8. package/commands/remind/create/index.js +3 -3
  9. package/commands/remind/create/index.ts +3 -3
  10. package/commands/remind/every/index.js +6 -5
  11. package/commands/remind/every/index.ts +6 -5
  12. package/commands/remind/list/index.js +5 -4
  13. package/commands/remind/list/index.ts +5 -4
  14. package/lib/admin-i18n.d.ts +155 -0
  15. package/lib/admin-i18n.d.ts.map +1 -0
  16. package/lib/admin-i18n.js +152 -0
  17. package/lib/admin-i18n.js.map +1 -0
  18. package/lib/admin-tasks.d.ts +35 -5
  19. package/lib/admin-tasks.d.ts.map +1 -1
  20. package/lib/admin-tasks.js +77 -57
  21. package/lib/admin-tasks.js.map +1 -1
  22. package/lib/caller.d.ts +2 -1
  23. package/lib/caller.d.ts.map +1 -1
  24. package/lib/caller.js +5 -3
  25. package/lib/caller.js.map +1 -1
  26. package/lib/config.js +6 -6
  27. package/lib/config.js.map +1 -1
  28. package/lib/create-reminder.d.ts.map +1 -1
  29. package/lib/create-reminder.js +31 -25
  30. package/lib/create-reminder.js.map +1 -1
  31. package/lib/duration.d.ts +2 -1
  32. package/lib/duration.d.ts.map +1 -1
  33. package/lib/duration.js +4 -3
  34. package/lib/duration.js.map +1 -1
  35. package/lib/i18n.d.ts +2 -0
  36. package/lib/i18n.d.ts.map +1 -0
  37. package/lib/i18n.js +336 -0
  38. package/lib/i18n.js.map +1 -0
  39. package/lib/natural-time.d.ts +5 -1
  40. package/lib/natural-time.d.ts.map +1 -1
  41. package/lib/natural-time.js +71 -57
  42. package/lib/natural-time.js.map +1 -1
  43. package/lib/service.d.ts +8 -2
  44. package/lib/service.d.ts.map +1 -1
  45. package/lib/service.js +47 -36
  46. package/lib/service.js.map +1 -1
  47. package/lib/storage.d.ts.map +1 -1
  48. package/lib/storage.js +7 -5
  49. package/lib/storage.js.map +1 -1
  50. package/lib/timing.d.ts +2 -1
  51. package/lib/timing.d.ts.map +1 -1
  52. package/lib/timing.js +5 -4
  53. package/lib/timing.js.map +1 -1
  54. package/lib/tool-actor.d.ts +2 -0
  55. package/lib/tool-actor.d.ts.map +1 -1
  56. package/lib/tool-actor.js +14 -4
  57. package/lib/tool-actor.js.map +1 -1
  58. package/package.json +9 -9
  59. package/plugin.js +8 -3
  60. package/schema.json +30 -0
  61. package/skills/reminder/tools/reminder_cancel/index.js +3 -2
  62. package/skills/reminder/tools/reminder_cancel/index.ts +3 -2
  63. package/skills/reminder/tools/reminder_create/index.js +7 -6
  64. package/skills/reminder/tools/reminder_create/index.ts +7 -6
  65. package/skills/reminder/tools/reminder_list/index.js +3 -2
  66. package/skills/reminder/tools/reminder_list/index.ts +3 -2
  67. package/src/admin-i18n.ts +154 -0
  68. package/src/admin-tasks.ts +79 -53
  69. package/src/caller.ts +5 -3
  70. package/src/config.ts +6 -6
  71. package/src/create-reminder.ts +33 -25
  72. package/src/duration.ts +5 -3
  73. package/src/i18n.ts +336 -0
  74. package/src/natural-time.ts +40 -30
  75. package/src/service.ts +51 -38
  76. package/src/storage.ts +7 -5
  77. package/src/timing.ts +6 -4
  78. package/src/tool-actor.ts +15 -4
@@ -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
- constructor(private readonly state: PluginStateStore, private readonly outbound: OutboundHost, private readonly config: ReminderConfig) {}
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, detail: '等待执行' };
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('同一请求 ID 已用于其他内容'); return book; }
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('任务 ID 或操作无效');
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 PluginTaskError('not_found', '未找到当前实例的管理员任务;聊天任务不在此入口管理');
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', detail: '管理员暂停' }; break;
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, detail: nextRunAt !== task.nextRunAt ? '已恢复,并跳过错过的周期轮次' : '管理员恢复' }; break;
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, detail: '已编辑,保持暂停;请确认后恢复' }; break;
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', detail: '管理员取消;已经开始的投递无法撤回' }; break;
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), detail: '已跳过错过的周期轮次' };
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, detail: '投递中或未确认;不会自动重试' };
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, `⏰ 提醒:${task.text}`);
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 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(); }
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: `${task.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('操作须包含 UUID requestId 和读取到的 expectedRevision');
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('参数须包含 1–500 字提醒内容、带 offset 的 ISO 时间及可选的 60 秒至一年整数周期');
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('提醒时间须为有效且带 Z 或 ±hh:mm 的 ISO 时间'); }
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('同一操作请求 ID 已用于其他内容,禁止重放');
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: string): PluginTaskError { return new PluginTaskError('invalid_input', message); }
206
- function conflict(message: string): PluginTaskError { return new PluginTaskError('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('管理员提醒池状态达到 32 条或 60 KiB 上限,请先取消并整理任务');
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 PluginTaskError('unavailable', '管理员提醒持久状态格式无效,已停止投递'); }
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>;
@@ -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
- if (!context.input?.sender?.id || !context.interaction || !context.input.$replyFrom && !context.input.$reply) return '请在真实聊天会话中使用此指令;需要发送者身份和交互能力,未创建提醒。';
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 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} `);
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 (/^(?:取消|cancel)$/iu.test(value.trim())) throw new ReminderCreationCancelled();
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: '直接回复内容,不加命令前缀;输入“取消”或 cancel 可退出。', maxLength,
46
- timeout: remaining, timeoutText: TIMEOUT_TEXT, signal: context.signal,
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', '什么时候提醒你?', [timeError, `按 ${timezone} 解析,例如“10分钟后”或“明天上午九点”;也可使用带 offset 的 ISO 时间。`].filter(Boolean).join('\n'), 200);
57
- try { parsed = parseNaturalReminderTime(expression, { now: Date.now(), timezone }); }
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) ? '当前正在补全提醒;请直接回复正文,或先取消后重新发指令。' : '提醒正文须为 1–500 个字符。';
68
- text = await ask('body', '提醒你做什么?', [textError, '请输入纯文字提醒正文(1–500 字)。'].filter(Boolean).join('\n'), 500);
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 ? `;父容器 ${target.parent.kind} ${target.parent.id}` : ''}${target.threadId ? `;话题 ${target.threadId}` : ''}`;
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 = `时间:${formatReminderTime(parsed.at, parsed.timezone)}\n原会话:${scene}\n账号:${account}\n正文(原样显示):\n${text}\n\n请等待下一条确认提示,再输入“确认”创建;不会更换目标会话。\n若确认后未收到创建结果,请先在原会话用 remind list 核对,不要直接重复创建。`;
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', '确认创建这条提醒?', [confirmError, '请核对上一条预览的时间、时区、正文和原会话;仅输入“确认”才创建。'].filter(Boolean).join('\n'), 500);
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 '提醒时间已过,未创建提醒。请重新执行 remind create 并选择未来时间;不会自动顺延。';
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 `已创建个人提醒\n${service.describe(task)}`;
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 `未创建提醒:${error.message}`;
97
- if (creating) return '提醒创建结果未能确认,请先使用 remind list 核对;不要直接重复创建。';
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 TIMEOUT_TEXT;
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("时长格式无效(示例:600、10m、2h、1d)");
17
+ throw new ReminderInputError(t(locale, 'durationFormat'));
16
18
  }
17
19
  const amount = Number(match[1]);
18
20
  const unit = match[2]!;