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