@zhin.js/plugin-reminder 1.0.4 → 1.0.6

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 (79) hide show
  1. package/README.md +12 -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 +4 -0
  48. package/lib/storage.d.ts.map +1 -0
  49. package/lib/storage.js +51 -0
  50. package/lib/storage.js.map +1 -0
  51. package/lib/timing.d.ts +2 -1
  52. package/lib/timing.d.ts.map +1 -1
  53. package/lib/timing.js +5 -4
  54. package/lib/timing.js.map +1 -1
  55. package/lib/tool-actor.d.ts +2 -0
  56. package/lib/tool-actor.d.ts.map +1 -1
  57. package/lib/tool-actor.js +14 -4
  58. package/lib/tool-actor.js.map +1 -1
  59. package/package.json +9 -9
  60. package/plugin.js +12 -4
  61. package/schema.json +30 -0
  62. package/skills/reminder/tools/reminder_cancel/index.js +3 -2
  63. package/skills/reminder/tools/reminder_cancel/index.ts +3 -2
  64. package/skills/reminder/tools/reminder_create/index.js +7 -6
  65. package/skills/reminder/tools/reminder_create/index.ts +7 -6
  66. package/skills/reminder/tools/reminder_list/index.js +3 -2
  67. package/skills/reminder/tools/reminder_list/index.ts +3 -2
  68. package/src/admin-i18n.ts +154 -0
  69. package/src/admin-tasks.ts +79 -53
  70. package/src/caller.ts +5 -3
  71. package/src/config.ts +6 -6
  72. package/src/create-reminder.ts +33 -25
  73. package/src/duration.ts +5 -3
  74. package/src/i18n.ts +336 -0
  75. package/src/natural-time.ts +40 -30
  76. package/src/service.ts +51 -38
  77. package/src/storage.ts +54 -0
  78. package/src/timing.ts +6 -4
  79. package/src/tool-actor.ts +15 -4
package/src/service.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { resolveLocale, type Locale } from 'zhin.js/i18n';
2
+ import { reminderMessages as t } from './i18n.js';
1
3
  import { randomUUID } from 'node:crypto';
2
4
  import { createDeliveryTarget, type DeliveryTarget, type OutboundHost, type PluginStateStore } from 'zhin.js';
3
5
  import { EndpointDeliveryError } from '@zhin.js/im-contract';
@@ -9,13 +11,19 @@ export { MAX_DELAY_SECONDS, parseReminderInstant } from './timing.js';
9
11
  export const REMINDER_STATE_KEY = 'reminders/v1';
10
12
  export class ReminderInputError extends Error {}
11
13
 
14
+ type ReminderDetailKey = 'userCancelled' | 'skipped' | 'unknown' | 'unconfirmed' | 'delivered' | 'deliveryFailed' | 'checkUnconfirmed';
15
+ const detailKeys: readonly ReminderDetailKey[] = ['userCancelled', 'skipped', 'unknown', 'unconfirmed', 'delivered', 'deliveryFailed', 'checkUnconfirmed'];
16
+ function taskDetail(locale: Locale | undefined, detailKey: ReminderDetailKey, failureCode?: string) {
17
+ return { detailKey, detail: t(locale, detailKey, { code: failureCode ?? '' }), failureCode: failureCode ?? null };
18
+ }
19
+
12
20
  type TaskStatus = 'pending' | 'unknown' | 'delivered' | 'failed' | 'cancelled';
13
- export type ReminderActor = { readonly senderId: string; readonly target: DeliveryTarget };
21
+ export type ReminderActor = { readonly senderId: string; readonly target: DeliveryTarget; readonly locale?: Locale };
14
22
  export type ReminderTask = {
15
23
  readonly id: string; readonly senderId: string; readonly target: DeliveryTarget;
16
- readonly text: string; readonly createdAt: number; readonly nextRunAt: number;
24
+ readonly locale?: Locale; readonly text: string; readonly createdAt: number; readonly nextRunAt: number;
17
25
  readonly intervalMs: number | null; readonly status: TaskStatus;
18
- readonly claimId: string | null; readonly lastRunAt: number | null; readonly detail: string;
26
+ readonly claimId: string | null; readonly lastRunAt: number | null; readonly detail: string; readonly detailKey?: ReminderDetailKey; readonly failureCode?: string | null;
19
27
  };
20
28
  export type ReminderBook = { readonly version: 1; readonly tasks: readonly ReminderTask[] };
21
29
 
@@ -29,50 +37,51 @@ export class ReminderService {
29
37
  #stopped = false;
30
38
  #administratorFirst = false;
31
39
 
32
- constructor(state: PluginStateStore, outbound: OutboundHost, config: ReminderConfig) {
40
+ constructor(state: PluginStateStore, outbound: OutboundHost, config: ReminderConfig, projectLocale: Locale = 'zh-CN') {
33
41
  this.#state = state;
34
42
  this.#outbound = outbound;
35
43
  this.#config = config;
36
- this.administratorTasks = new AdministratorReminderTasks(state, outbound, config);
44
+ this.administratorTasks = new AdministratorReminderTasks(state, outbound, config, projectLocale);
37
45
  }
38
46
 
39
47
  stop(): void { this.#stopped = true; this.administratorTasks.stop(); }
40
48
 
41
49
  async add(actor: ReminderActor, text: string, at: number, intervalMs: number | null): Promise<ReminderTask> {
42
- this.#assertLive();
50
+ this.#assertLive(actor.locale);
43
51
  assertActor(actor);
44
52
  const now = Date.now();
45
- if (!text.trim() || text.length > 500) throw new ReminderInputError('提醒内容须为 1–500 个字符');
46
- if (!Number.isSafeInteger(at) || at <= now || at - now > MAX_DELAY_SECONDS * 1000) throw new ReminderInputError('提醒时间须在未来一年内');
47
- if (intervalMs !== null && (!Number.isSafeInteger(intervalMs) || intervalMs < 60_000 || intervalMs > MAX_DELAY_SECONDS * 1000)) throw new ReminderInputError('周期须为 60 秒至一年');
53
+ const locale = resolveLocale(actor.locale);
54
+ if (!text.trim() || text.length > 500) throw new ReminderInputError(t(locale, 'contentInvalid'));
55
+ if (!Number.isSafeInteger(at) || at <= now || at - now > MAX_DELAY_SECONDS * 1000) throw new ReminderInputError(t(locale, 'futureRequired'));
56
+ if (intervalMs !== null && (!Number.isSafeInteger(intervalMs) || intervalMs < 60_000 || intervalMs > MAX_DELAY_SECONDS * 1000)) throw new ReminderInputError(t(locale, 'intervalInvalid'));
48
57
  const task: ReminderTask = { id: randomUUID(), senderId: actor.senderId, target: createDeliveryTarget({ conversation: actor.target }),
49
- text, createdAt: now, nextRunAt: at, intervalMs, status: 'pending', claimId: null, lastRunAt: null, detail: '' };
58
+ locale, text, createdAt: now, nextRunAt: at, intervalMs, status: 'pending', claimId: null, lastRunAt: null, detail: '' };
50
59
  await this.#state.update<ReminderBook>(REMINDER_STATE_KEY, current => {
51
- const book = readBook(current);
60
+ const book = readBook(current, locale);
52
61
  // Completed history is disposable; unconfirmed tasks require an explicit
53
62
  // user decision and continue to count against the storage quota.
54
63
  const active = book.tasks.filter(item => item.status === 'pending' || item.status === 'unknown');
55
- if (active.length >= this.#config.maxTasks) throw new ReminderInputError('此插件实例的提醒数量已达上限,请先取消任务');
56
- if (active.filter(item => sameActor(item, actor)).length >= this.#config.maxTasksPerSender) throw new ReminderInputError('你在此会话的提醒数量已达上限,请先取消任务');
64
+ if (active.length >= this.#config.maxTasks) throw new ReminderInputError(t(locale, 'instanceLimit'));
65
+ if (active.filter(item => sameActor(item, actor)).length >= this.#config.maxTasksPerSender) throw new ReminderInputError(t(locale, 'actorLimit'));
57
66
  const historyLimit = Math.max(0, this.#config.maxTasks - active.length - 1);
58
67
  const history = historyLimit > 0 ? book.tasks.filter(item => item.status !== 'pending' && item.status !== 'unknown').slice(-historyLimit) : [];
59
- return boundedBook([...active, ...history, task]);
68
+ return boundedBook([...active, ...history, task], locale);
60
69
  });
61
70
  return task;
62
71
  }
63
72
 
64
73
  async list(actor: ReminderActor): Promise<readonly ReminderTask[]> {
65
- this.#assertLive();
74
+ this.#assertLive(actor.locale);
66
75
  assertActor(actor);
67
- return readBook(await this.#state.get<ReminderBook>(REMINDER_STATE_KEY)).tasks.filter(item => sameActor(item, actor));
76
+ return readBook(await this.#state.get<ReminderBook>(REMINDER_STATE_KEY), actor.locale).tasks.filter(item => sameActor(item, actor));
68
77
  }
69
78
 
70
79
  async cancel(actor: ReminderActor, id: string): Promise<boolean> {
71
- this.#assertLive();
80
+ this.#assertLive(actor.locale);
72
81
  assertActor(actor);
73
82
  const book = await this.#state.update<ReminderBook>(REMINDER_STATE_KEY, current => ({ version: 1,
74
- tasks: readBook(current).tasks.map(task => task.id === id && sameActor(task, actor)
75
- ? { ...task, status: 'cancelled', detail: '用户取消;已经开始的投递无法撤销' } : task),
83
+ tasks: readBook(current, actor.locale).tasks.map(task => task.id === id && sameActor(task, actor)
84
+ ? { ...task, status: 'cancelled', ...taskDetail(task.locale, 'userCancelled') } : task),
76
85
  }));
77
86
  return !!book?.tasks.some(task => task.id === id && sameActor(task, actor) && task.status === 'cancelled');
78
87
  }
@@ -97,13 +106,13 @@ export class ReminderService {
97
106
  return { version: 1, tasks: readBook(current).tasks.map(task => {
98
107
  if (task.status !== 'pending' || task.nextRunAt > now) return task;
99
108
  if (task.intervalMs !== null && now - task.nextRunAt > this.#config.missedGraceSeconds * 1000) {
100
- return { ...task, nextRunAt: nextPeriodicTime(task.nextRunAt, task.intervalMs, now), detail: '已跳过错过的周期轮次' };
109
+ return { ...task, nextRunAt: nextPeriodicTime(task.nextRunAt, task.intervalMs, now), ...taskDetail(task.locale, 'skipped') };
101
110
  }
102
111
  if (claimed) return task;
103
112
  claimed = true;
104
113
  // Persist the conservative disposition before touching the external
105
114
  // platform. A crash here cannot cause an automatic duplicate send.
106
- return { ...task, status: 'unknown', claimId, lastRunAt: now, detail: '投递中或未确认;不会自动重试' };
115
+ return { ...task, status: 'unknown', claimId, lastRunAt: now, ...taskDetail(task.locale, 'unknown') };
107
116
  }) };
108
117
  });
109
118
  const task = book?.tasks.find(item => item.claimId === claimId);
@@ -116,38 +125,39 @@ export class ReminderService {
116
125
  continue;
117
126
  }
118
127
  let status: TaskStatus = 'unknown';
119
- let detail = '平台未返回确认的消息 ID;不会自动重试';
128
+ let detail = taskDetail(task.locale, 'unconfirmed');
120
129
  try {
121
- const id = await this.#outbound.sendTarget!(task.target, `⏰ 提醒:${task.text}`);
122
- if (id) { status = task.intervalMs === null ? 'delivered' : 'pending'; detail = '已确认投递'; }
130
+ const id = await this.#outbound.sendTarget!(task.target, t(resolveLocale(task.locale), 'notification', { text: task.text }), { locale: resolveLocale(task.locale) });
131
+ if (id) { status = task.intervalMs === null ? 'delivered' : 'pending'; detail = taskDetail(task.locale, 'delivered'); }
123
132
  } catch (error) {
124
133
  if (error instanceof EndpointDeliveryError && error.disposition !== 'unknown') {
125
- status = 'failed'; detail = `投递失败:${error.code};任务已停止`;
126
- } else detail = '投递结果未确认;请核对目标会话后取消或重新创建';
134
+ status = 'failed'; detail = taskDetail(task.locale, 'deliveryFailed', error.code);
135
+ } else detail = taskDetail(task.locale, 'checkUnconfirmed');
127
136
  }
128
137
  await this.#state.update<ReminderBook>(REMINDER_STATE_KEY, current => ({ version: 1,
129
138
  tasks: readBook(current).tasks.map(item => item.id === task.id && item.claimId === claimId && item.status === 'unknown'
130
- ? { ...item, status, detail, ...(status === 'pending' && task.intervalMs !== null
139
+ ? { ...item, status, ...detail, ...(status === 'pending' && task.intervalMs !== null
131
140
  ? { nextRunAt: nextPeriodicTime(task.nextRunAt, task.intervalMs, Date.now()) } : {}) } : item),
132
141
  }));
133
142
  }
134
143
  } finally { this.#working = false; }
135
144
  }
136
145
 
137
- describe(task: ReminderTask): string {
138
- const date = new Intl.DateTimeFormat('zh-CN', { timeZone: this.#config.timezone, dateStyle: 'short', timeStyle: 'medium' }).format(task.nextRunAt);
139
- const state = { pending: '待触发', unknown: '投递中或未确认', delivered: '已完成', failed: '投递失败', cancelled: '已取消' }[task.status];
140
- return `${task.id} | ${state} | ${date} (${this.#config.timezone})${task.intervalMs === null ? '' : ` | 每 ${task.intervalMs / 1000} 秒`}`
141
- + `\n${task.text}${task.detail ? `\n${task.detail}` : ''}`;
146
+ describe(task: ReminderTask, locale: Locale = resolveLocale(task.locale)): string {
147
+ const date = new Intl.DateTimeFormat(locale, { timeZone: this.#config.timezone, dateStyle: 'short', timeStyle: 'medium' }).format(task.nextRunAt);
148
+ const state = t(locale, { pending: 'pending', unknown: 'unknownStatus', delivered: 'deliveredStatus', failed: 'failedStatus', cancelled: 'cancelledStatus' }[task.status] as 'pending' | 'unknownStatus' | 'deliveredStatus' | 'failedStatus' | 'cancelledStatus');
149
+ const detail = task.detailKey ? t(locale, task.detailKey, { code: task.failureCode ?? '' }) : task.detail;
150
+ return `${task.id} | ${state} | ${date} (${this.#config.timezone})${task.intervalMs === null ? '' : ` | ${t(locale, 'every', { seconds: task.intervalMs / 1000 })}`}`
151
+ + `\n${task.text}${detail ? `\n${detail}` : ''}`;
142
152
  }
143
153
 
144
- #assertLive(): void { if (this.#stopped) throw new Error('提醒服务属于已停止的插件实例'); }
154
+ #assertLive(locale?: Locale): void { if (this.#stopped) throw new Error(t(locale, 'serviceStopped')); }
145
155
  }
146
156
 
147
157
  function sameActor(task: ReminderTask, actor: ReminderActor): boolean {
148
158
  return task.senderId === actor.senderId && JSON.stringify(task.target) === JSON.stringify(createDeliveryTarget({ conversation: actor.target }));
149
159
  }
150
- function readBook(value: ReminderBook | undefined): ReminderBook {
160
+ function readBook(value: ReminderBook | undefined, locale?: Locale): ReminderBook {
151
161
  if (value === undefined) return { version: 1, tasks: [] };
152
162
  try {
153
163
  if (value.version !== 1 || !Array.isArray(value.tasks) || value.tasks.length > 32) throw new Error('Invalid book');
@@ -156,21 +166,24 @@ function readBook(value: ReminderBook | undefined): ReminderBook {
156
166
  || typeof task.text !== 'string' || !task.text.trim() || task.text.length > 500
157
167
  || !Number.isSafeInteger(task.createdAt) || !Number.isSafeInteger(task.nextRunAt)
158
168
  || (task.intervalMs !== null && (!Number.isSafeInteger(task.intervalMs) || task.intervalMs < 60_000 || task.intervalMs > MAX_DELAY_SECONDS * 1000))
169
+ || (task.locale !== undefined && task.locale !== 'zh-CN' && task.locale !== 'en')
170
+ || (task.detailKey !== undefined && !detailKeys.includes(task.detailKey))
171
+ || (task.failureCode != null && typeof task.failureCode !== 'string')
159
172
  || !['pending', 'unknown', 'delivered', 'failed', 'cancelled'].includes(task.status)
160
173
  || (task.claimId !== null && typeof task.claimId !== 'string')
161
174
  || (task.lastRunAt !== null && !Number.isSafeInteger(task.lastRunAt)) || typeof task.detail !== 'string') throw new Error('Invalid task');
162
175
  createDeliveryTarget({ conversation: task.target });
163
176
  }
164
177
  return boundedBook(value.tasks);
165
- } catch { throw new Error('提醒持久状态格式无效,已停止投递'); }
178
+ } catch { throw new Error(t(locale, 'invalidState')); }
166
179
  }
167
- function boundedBook(tasks: readonly ReminderTask[]): ReminderBook {
180
+ function boundedBook(tasks: readonly ReminderTask[], locale?: Locale): ReminderBook {
168
181
  const book: ReminderBook = { version: 1, tasks };
169
- if (new TextEncoder().encode(JSON.stringify(book)).byteLength > 60 * 1024) throw new ReminderInputError('提醒存储达到大小上限,请先取消旧任务');
182
+ if (new TextEncoder().encode(JSON.stringify(book)).byteLength > 60 * 1024) throw new ReminderInputError(t(locale, 'storageLimit'));
170
183
  return book;
171
184
  }
172
185
 
173
186
  function assertActor(actor: ReminderActor): void {
174
- if (!actor.senderId) throw new ReminderInputError('提醒需要真实发送者');
187
+ if (!actor.senderId) throw new ReminderInputError(t(actor.locale, 'toolSender'));
175
188
  createDeliveryTarget({ conversation: actor.target });
176
189
  }
package/src/storage.ts ADDED
@@ -0,0 +1,54 @@
1
+ import { createRelationalStateStore, TASK_TARGET_COLUMNS, TASK_RECEIPT_COLUMNS, encodeTaskTarget, decodeTaskTarget,
2
+ encodeTaskReceipts, decodeTaskReceipts, encodeStorageIdentity, decodeStorageIdentity,
3
+ type PluginDatabaseHost, type RelationalStateCodec } from 'zhin.js/plugin-runtime';
4
+ import { REMINDER_STATE_KEY, type ReminderBook } from './service.js';
5
+ import { ADMIN_REMINDER_STATE_KEY, type AdminReminderBook } from './admin-tasks.js';
6
+
7
+ const taskColumns = {
8
+ id: { type: 'text', length: 36, primary: true }, position: { type: 'integer', nullable: false },
9
+ ...TASK_TARGET_COLUMNS, sender_id: { type: 'text', nullable: true }, creation: { type: 'text', nullable: true },
10
+ locale: { type: 'text', length: 16, nullable: true },
11
+ content: { type: 'text', nullable: false }, state: { type: 'text', length: 24, nullable: false },
12
+ created_at: { type: 'bigint', nullable: false }, next_run_at: { type: 'bigint', nullable: false },
13
+ interval_ms: { type: 'bigint', nullable: true }, claim_id: { type: 'text', nullable: true },
14
+ last_run_at: { type: 'bigint', nullable: true }, detail: { type: 'text', nullable: false }, detail_key: { type: 'text', length: 40, nullable: true }, failure_code: { type: 'text', length: 64, nullable: true },
15
+ };
16
+
17
+ function codec(admin: boolean): RelationalStateCodec {
18
+ const table = admin ? 'admin_reminder_tasks' : 'reminder_tasks';
19
+ const receipts = 'admin_reminder_receipts';
20
+ return {
21
+ key: admin ? ADMIN_REMINDER_STATE_KEY : REMINDER_STATE_KEY,
22
+ header: { table: `${table}_book`, definition: {
23
+ version: { type: 'integer', nullable: true }, revision: { type: 'text', nullable: true },
24
+ } },
25
+ addColumns: { [table]: ['locale', 'detail_key', 'failure_code'] },
26
+ tables: { [table]: taskColumns, ...(admin ? { [receipts]: TASK_RECEIPT_COLUMNS } : {}) },
27
+ encode(value) {
28
+ const book = value as ReminderBook | AdminReminderBook;
29
+ if (book.version !== 1 || !Array.isArray(book.tasks)) throw new Error('Unsupported reminder book; original state was preserved');
30
+ return { header: { version: book.version, revision: 'revision' in book ? book.revision : null }, rows: {
31
+ [table]: book.tasks.map((task, position) => ({ id: task.id, position, ...encodeTaskTarget(task.target),
32
+ sender_id: 'senderId' in task ? encodeStorageIdentity(task.senderId) : null,
33
+ locale: task.locale ?? 'zh-CN', creation: 'creation' in task ? task.creation : null, content: encodeStorageIdentity(task.text),
34
+ state: 'status' in task ? task.status : task.state, created_at: task.createdAt, next_run_at: task.nextRunAt,
35
+ interval_ms: task.intervalMs, claim_id: task.claimId, last_run_at: task.lastRunAt, detail: encodeStorageIdentity(task.detail), detail_key: task.detailKey ?? null, failure_code: 'failureCode' in task ? task.failureCode ?? null : null })),
36
+ ...(admin ? { [receipts]: encodeTaskReceipts((book as AdminReminderBook).receipts) } : {}),
37
+ } };
38
+ },
39
+ decode(header, rows) {
40
+ return { version: Number(header.version), ...(admin ? { revision: String(header.revision) } : {}),
41
+ tasks: rows[table]!.map(row => ({ id: String(row.id), target: decodeTaskTarget(row), text: decodeStorageIdentity(row.content), locale: row.locale == null ? 'zh-CN' : String(row.locale),
42
+ createdAt: Number(row.created_at), nextRunAt: Number(row.next_run_at), intervalMs: row.interval_ms == null ? null : Number(row.interval_ms),
43
+ claimId: row.claim_id == null ? null : String(row.claim_id), lastRunAt: row.last_run_at == null ? null : Number(row.last_run_at), detail: decodeStorageIdentity(row.detail), ...(row.detail_key == null ? {} : { detailKey: String(row.detail_key) }), ...(row.failure_code == null ? {} : { failureCode: String(row.failure_code) }),
44
+ ...(admin ? { creation: String(row.creation), state: String(row.state) } : { senderId: decodeStorageIdentity(row.sender_id), status: String(row.state) }),
45
+ })), ...(admin ? { receipts: decodeTaskReceipts(rows[receipts]!) } : {}),
46
+ };
47
+ },
48
+ };
49
+ }
50
+
51
+ /** Both pools keep their existing CAS API but persist independent rows atomically. */
52
+ export function createReminderState(database: PluginDatabaseHost) {
53
+ return createRelationalStateStore(database, [codec(false), codec(true)]);
54
+ }
package/src/timing.ts CHANGED
@@ -1,14 +1,16 @@
1
+ import type { Locale } from 'zhin.js/i18n';
2
+ import { reminderMessages as t } from './i18n.js';
1
3
  export const MAX_DELAY_SECONDS = 365 * 24 * 60 * 60;
2
4
 
3
- export function parseReminderInstant(value: string): number {
4
- if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,3})?)?(?:Z|[+-]\d{2}:\d{2})$/u.test(value)) throw new TypeError('绝对时间须为带 Z 或 ±hh:mm 的 ISO 时间,例如 2026-10-14T09:00:00+08:00');
5
+ export function parseReminderInstant(value: string, locale?: Locale): number {
6
+ if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,3})?)?(?:Z|[+-]\d{2}:\d{2})$/u.test(value)) throw new TypeError(t(locale, 'absoluteFormat'));
5
7
  const [year = 0, month = 0, day = 0, hour = 0, minute = 0, second = 0] = value.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?/u)!.slice(1).map(item => Number(item ?? 0));
6
8
  const calendar = new Date(0);
7
9
  calendar.setUTCFullYear(year, month, 0);
8
10
  const daysInMonth = calendar.getUTCDate();
9
- if (month < 1 || month > 12 || day < 1 || day > daysInMonth || hour > 23 || minute > 59 || second > 59) throw new TypeError('提醒时间无效');
11
+ if (month < 1 || month > 12 || day < 1 || day > daysInMonth || hour > 23 || minute > 59 || second > 59) throw new TypeError(t(locale, 'instantInvalid'));
10
12
  const time = Date.parse(value);
11
- if (!Number.isSafeInteger(time)) throw new TypeError('提醒时间无效');
13
+ if (!Number.isSafeInteger(time)) throw new TypeError(t(locale, 'instantInvalid'));
12
14
  return time;
13
15
  }
14
16
 
package/src/tool-actor.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { resolveLocale, type Locale } from 'zhin.js/i18n';
2
+ import { reminderMessages as t } from './i18n.js';
1
3
  /**
2
4
  * Resolve a ReminderActor from an Agent Tool invocation (IM origin only).
3
5
  */
@@ -8,6 +10,7 @@ import { ReminderInputError, type ReminderActor } from "./service.js";
8
10
 
9
11
  export interface ToolActorContext {
10
12
  readonly origin: ToolInvocationOrigin;
13
+ readonly locale?: Locale;
11
14
  readonly principal: Readonly<{ subjectId: string; roles: readonly string[] }>;
12
15
  project<T>(feature: typeof adapterFeatureId): T;
13
16
  }
@@ -17,7 +20,7 @@ export function toolDeliveryTarget(context: ToolActorContext): DeliveryTarget {
17
20
  const origin = context.origin;
18
21
  if (origin.kind !== "im") {
19
22
  throw new ReminderInputError(
20
- "提醒工具只能在聊天会话中使用(不能用于 http/schedule 等来源)"
23
+ t(context.locale, 'toolOrigin')
21
24
  );
22
25
  }
23
26
  const index = context.project<AdapterIndex>(adapterFeatureId);
@@ -30,7 +33,15 @@ export function toolDeliveryTarget(context: ToolActorContext): DeliveryTarget {
30
33
  index.clientAdapter(endpoint.id) === origin.platform
31
34
  );
32
35
  if (matches.length !== 1) {
33
- throw new ReminderInputError("无法确定当前会话所属的机器人账号");
36
+ throw new ReminderInputError(t(context.locale, 'toolEndpoint'));
37
+ }
38
+ if (origin.conversation) {
39
+ const canonical = createDeliveryTarget({ conversation: origin.conversation });
40
+ if (canonical.endpoint.id !== String(matches[0]!.id) || canonical.endpoint.adapter !== origin.platform
41
+ || canonical.kind !== origin.scope || canonical.id !== origin.sceneId || canonical.threadId !== origin.threadId) {
42
+ throw new ReminderInputError(t(context.locale, 'toolEndpoint'));
43
+ }
44
+ return canonical;
34
45
  }
35
46
  return createDeliveryTarget({
36
47
  conversation: {
@@ -47,6 +58,6 @@ export function reminderActorFromTool(
47
58
  context: ToolActorContext
48
59
  ): ReminderActor {
49
60
  const senderId = context.principal.subjectId?.trim();
50
- if (!senderId) throw new ReminderInputError("提醒工具需要真实发送者");
51
- return { senderId, target: toolDeliveryTarget(context) };
61
+ if (!senderId) throw new ReminderInputError(t(context.locale, 'toolSender'));
62
+ return { senderId, target: toolDeliveryTarget(context), locale: resolveLocale(context.locale) };
52
63
  }