@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.
- package/README.md +12 -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 +4 -0
- package/lib/storage.d.ts.map +1 -0
- package/lib/storage.js +51 -0
- package/lib/storage.js.map +1 -0
- 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 +12 -4
- 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 +54 -0
- package/src/timing.ts +6 -4
- 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
|
-
|
|
46
|
-
if (!
|
|
47
|
-
if (
|
|
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',
|
|
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),
|
|
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,
|
|
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 =
|
|
128
|
+
let detail = taskDetail(task.locale, 'unconfirmed');
|
|
120
129
|
try {
|
|
121
|
-
const id = await this.#outbound.sendTarget!(task.target,
|
|
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 =
|
|
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(
|
|
139
|
-
const state = { pending: '
|
|
140
|
-
|
|
141
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
}
|