@zhin.js/plugin-reminder 1.0.1 → 1.0.3

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.
@@ -0,0 +1,197 @@
1
+ import { MAX_DELAY_SECONDS, parseReminderInstant } from './timing.js';
2
+
3
+ export class ReminderTimeInputError extends Error {
4
+ constructor(message: string) {
5
+ super(message);
6
+ this.name = 'ReminderTimeInputError';
7
+ }
8
+ }
9
+
10
+ interface CalendarTime {
11
+ year: number;
12
+ month: number;
13
+ day: number;
14
+ hour: number;
15
+ minute: number;
16
+ second: number;
17
+ }
18
+
19
+ const DAY = 86_400_000;
20
+ const MAX_DELAY = MAX_DELAY_SECONDS * 1000;
21
+ const NUMBER = '[0-9零〇一二两三四五六七八九十百千万]+';
22
+ const RELATIVE = new RegExp(`^(${NUMBER})\\s*(秒钟?|分钟?|小时|天)\\s*后$`, 'u');
23
+ const LOCAL = /^(今天|明天|后天)\s*(凌晨|上午|中午|下午|晚上)?\s*(.+)$/u;
24
+ const POINT = new RegExp(`^(${NUMBER})\\s*点(?:\\s*(半)|\\s*(${NUMBER})\\s*分(?:\\s*(${NUMBER})\\s*秒)?)?$`, 'u');
25
+ const DIGITS = '零一二三四五六七八九';
26
+ const HELP = '暂不支持这种时间表达,请使用“十分钟后”“明天九点半”或带时区 offset 的 ISO 时间;不支持每天、每周等日历周期';
27
+
28
+ /** Parse a bounded, explicit grammar; this never reads the machine's default timezone. */
29
+ export function parseNaturalReminderTime(
30
+ expression: string,
31
+ options: { now: number; timezone: string },
32
+ ): { at: number; timezone: string; expression: string } {
33
+ if (typeof expression !== 'string' || expression.length > 200) fail('提醒时间须为不超过 200 个字符的文字');
34
+ const text = expression.trim();
35
+ if (!text || /\p{Cc}/u.test(expression)) fail('请在一行内输入提醒时间');
36
+ assertInstant(options.now);
37
+ const formatter = createFormatter(options.timezone);
38
+ let at: number;
39
+ if (/^\d{4}-/u.test(text)) {
40
+ try { at = parseReminderInstant(text); }
41
+ catch { fail('绝对时间无效,请使用带 Z 或 ±hh:mm 的 ISO 时间,例如 2026-10-20T09:00:00+08:00'); }
42
+ } else {
43
+ const relative = RELATIVE.exec(text);
44
+ if (relative) {
45
+ const amount = parseInteger(relative[1]!);
46
+ const unit = relative[2]!;
47
+ const multiplier = unit.startsWith('秒') ? 1000 : unit.startsWith('分') ? 60_000 : unit === '小时' ? 3_600_000 : DAY;
48
+ at = options.now + amount * multiplier;
49
+ } else {
50
+ const local = LOCAL.exec(text);
51
+ if (!local) fail(HELP);
52
+ const current = partsAt(options.now, formatter);
53
+ const date = new Date(utc({ ...current, hour: 0, minute: 0, second: 0 }));
54
+ date.setUTCDate(date.getUTCDate() + (local[1] === '今天' ? 0 : local[1] === '明天' ? 1 : 2));
55
+ const clock = parseClock(local[3]!, local[2]);
56
+ const desired: CalendarTime = { year: date.getUTCFullYear(), month: date.getUTCMonth() + 1,
57
+ day: date.getUTCDate(), ...clock };
58
+ at = localInstant(desired, formatter);
59
+ }
60
+ }
61
+ const delay = at - options.now;
62
+ if (delay > MAX_DELAY) fail('提醒时间不能超过当前时间的 365 天后');
63
+ if (!Number.isSafeInteger(at) || !Number.isFinite(new Date(at).getTime()) || delay < 1000) {
64
+ fail('提醒时间须在当前时间至少 1 秒后;今天已过的时间不会自动改到明天,请重新指定日期');
65
+ }
66
+ return { at, timezone: options.timezone, expression: text };
67
+ }
68
+
69
+ /** Always show the resolved date, configured zone and its actual offset. */
70
+ export function formatReminderTime(at: number, timezone: string): string {
71
+ assertInstant(at);
72
+ const formatter = createFormatter(timezone);
73
+ const local = partsAt(at, formatter);
74
+ const offset = offsetAt(at, formatter);
75
+ const seconds = Math.abs(offset) / 1000;
76
+ const offsetText = `${offset < 0 ? '-' : '+'}${pad(Math.floor(seconds / 3600))}:${pad(Math.floor(seconds / 60) % 60)}${seconds % 60 ? `:${pad(seconds % 60)}` : ''}`;
77
+ return `${String(local.year).padStart(4, '0')}-${pad(local.month)}-${pad(local.day)} ${pad(local.hour)}:${pad(local.minute)}:${pad(local.second)} UTC${offsetText} (${timezone})`;
78
+ }
79
+
80
+ function fail(message: string): never { throw new ReminderTimeInputError(message); }
81
+ function pad(value: number): string { return String(value).padStart(2, '0'); }
82
+ function assertInstant(value: number): void {
83
+ if (!Number.isSafeInteger(value) || !Number.isFinite(new Date(value).getTime())) fail('当前时间或提醒时间无效');
84
+ }
85
+ function createFormatter(timezone: string): Intl.DateTimeFormat {
86
+ if (typeof timezone !== 'string' || !timezone || timezone.length > 100 || timezone !== timezone.trim() || /^[+-]/u.test(timezone)) {
87
+ fail('提醒时区无效,请配置有效的 IANA 时区,例如 Asia/Shanghai');
88
+ }
89
+ try {
90
+ return new Intl.DateTimeFormat('en-GB', { timeZone: timezone, calendar: 'iso8601', numberingSystem: 'latn',
91
+ year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit',
92
+ hourCycle: 'h23', era: 'short' });
93
+ } catch { fail('提醒时区无效,请配置有效的 IANA 时区,例如 Asia/Shanghai'); }
94
+ }
95
+ function partsAt(at: number, formatter: Intl.DateTimeFormat): CalendarTime {
96
+ const parts = Object.fromEntries(formatter.formatToParts(at).map(part => [part.type, part.value]));
97
+ return { year: parts.era === 'BC' ? 1 - Number(parts.year) : Number(parts.year), month: Number(parts.month),
98
+ day: Number(parts.day), hour: Number(parts.hour), minute: Number(parts.minute), second: Number(parts.second) };
99
+ }
100
+ function utc(value: CalendarTime): number {
101
+ const date = new Date(0);
102
+ date.setUTCFullYear(value.year, value.month - 1, value.day);
103
+ date.setUTCHours(value.hour, value.minute, value.second, 0);
104
+ return date.getTime();
105
+ }
106
+ function offsetAt(at: number, formatter: Intl.DateTimeFormat): number {
107
+ const local = utc(partsAt(at, formatter));
108
+ if (!Number.isFinite(local)) fail('提醒日期超出支持范围');
109
+ return local - Math.floor(at / 1000) * 1000;
110
+ }
111
+ function localInstant(desired: CalendarTime, formatter: Intl.DateTimeFormat): number {
112
+ const naive = utc(desired);
113
+ if (!Number.isFinite(naive)) fail('提醒日期超出支持范围');
114
+ const offsets = new Set<number>();
115
+ // A fixed 97 probes cover both sides of a DST or date-line transition.
116
+ // Candidate instants must round-trip exactly; never pick one side of a fold.
117
+ for (let hour = -48; hour <= 48; hour++) {
118
+ const probe = naive + hour * 3_600_000;
119
+ if (Number.isFinite(new Date(probe).getTime())) offsets.add(offsetAt(probe, formatter));
120
+ }
121
+ const candidates = [...offsets].map(offset => naive - offset).filter(at => {
122
+ if (!Number.isFinite(new Date(at).getTime())) return false;
123
+ const actual = partsAt(at, formatter);
124
+ return (Object.keys(desired) as (keyof CalendarTime)[]).every(key => actual[key] === desired[key]);
125
+ });
126
+ if (candidates.length === 0) fail('这个本地时间因时区或夏令时切换而不存在,请改用明确日期并带 offset 的 ISO 时间');
127
+ if (candidates.length !== 1) fail('这个本地时间因夏令时切换出现两次,请使用带明确 offset 的 ISO 时间选择其中一次');
128
+ return candidates[0]!;
129
+ }
130
+ function parseClock(text: string, period: string | undefined): Pick<CalendarTime, 'hour' | 'minute' | 'second'> {
131
+ const colon = /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/u.exec(text);
132
+ const point = POINT.exec(text);
133
+ if (!colon && !point) fail('时间须写为 09:30、九点、九点半或九点三十分;请同时指定今天、明天或后天');
134
+ let hour = colon ? Number(colon[1]) : parseInteger(point![1]!);
135
+ const minute = colon ? Number(colon[2]) : point![2] ? 30 : point![3] ? parseInteger(point![3]!) : 0;
136
+ const second = colon ? Number(colon[3] ?? 0) : point![4] ? parseInteger(point![4]!) : 0;
137
+ if (minute > 59 || second > 59 || hour > 23) fail('小时须为 0–23,分钟和秒须为 0–59');
138
+ if (period === '下午' || period === '晚上') {
139
+ if (hour < 1 || hour > 12 || period === '晚上' && hour === 12) fail('时段与钟点不明确,请使用 24 小时制,例如 00:00 或 18:00');
140
+ hour = hour === 12 ? 12 : hour + 12;
141
+ } else if (period === '上午' || period === '凌晨') {
142
+ if (hour > 11) fail('时段与钟点不明确,请使用 24 小时制,例如 00:00 或 12:00');
143
+ } else if (period === '中午') {
144
+ if (hour === 1 || hour === 2) hour += 12;
145
+ else if (hour !== 11 && hour !== 12) fail('中午的钟点不明确,请使用 24 小时制');
146
+ }
147
+ return { hour, minute, second };
148
+ }
149
+
150
+ function parseInteger(text: string): number {
151
+ if (/^\d+$/u.test(text)) {
152
+ const number = Number(text);
153
+ if (!Number.isSafeInteger(number)) fail('时间数值过大,请使用一年以内的时间');
154
+ return number;
155
+ }
156
+ if (/两(?![百千万]|$)/u.test(text)) fail('中文数字不明确,请改用阿拉伯数字');
157
+ const normalized = text.replace(/〇/gu, '零').replace(/两/gu, '二');
158
+ let total = 0;
159
+ let section = 0;
160
+ let digit = 0;
161
+ for (const character of normalized) {
162
+ const index = DIGITS.indexOf(character);
163
+ if (index !== -1) digit = index;
164
+ else if (character === '万') { total += (section + digit) * 10_000; section = 0; digit = 0; }
165
+ else {
166
+ const unit = character === '十' ? 10 : character === '百' ? 100 : character === '千' ? 1000 : NaN;
167
+ section += (digit || 1) * unit;
168
+ digit = 0;
169
+ }
170
+ }
171
+ const number = total + section + digit;
172
+ if (!Number.isSafeInteger(number) || number < 0 || number > 99_999_999
173
+ || chineseInteger(number) !== normalized && !(normalized.startsWith('一十') && chineseInteger(number) === normalized.slice(1))) {
174
+ fail('中文数字不明确,请写完整中文整数或改用阿拉伯数字');
175
+ }
176
+ return number;
177
+ }
178
+ function chineseInteger(value: number): string {
179
+ if (value === 0) return '零';
180
+ const section = (number: number): string => {
181
+ let result = '';
182
+ let zero = false;
183
+ for (const [unit, label] of [[1000, '千'], [100, '百'], [10, '十'], [1, '']] as const) {
184
+ const digit = Math.floor(number / unit) % 10;
185
+ if (digit) {
186
+ if (zero) result += '零';
187
+ result += `${DIGITS[digit]}${label}`;
188
+ zero = false;
189
+ } else if (result && number % unit) zero = true;
190
+ }
191
+ return result;
192
+ };
193
+ const high = Math.floor(value / 10_000);
194
+ const low = value % 10_000;
195
+ const result = high ? `${section(high)}万${low ? `${low < 1000 ? '零' : ''}${section(low)}` : ''}` : section(low);
196
+ return result.startsWith('一十') ? result.slice(1) : result;
197
+ }
package/src/service.ts CHANGED
@@ -2,9 +2,11 @@ import { randomUUID } from 'node:crypto';
2
2
  import { createDeliveryTarget, type DeliveryTarget, type OutboundHost, type PluginStateStore } from 'zhin.js';
3
3
  import { EndpointDeliveryError } from '@zhin.js/im-contract';
4
4
  import type { ReminderConfig } from './config.js';
5
+ import { AdministratorReminderTasks } from './admin-tasks.js';
6
+ import { MAX_DELAY_SECONDS, nextPeriodicTime } from './timing.js';
7
+ export { MAX_DELAY_SECONDS, parseReminderInstant } from './timing.js';
5
8
 
6
9
  export const REMINDER_STATE_KEY = 'reminders/v1';
7
- export const MAX_DELAY_SECONDS = 365 * 24 * 60 * 60;
8
10
  export class ReminderInputError extends Error {}
9
11
 
10
12
  type TaskStatus = 'pending' | 'unknown' | 'delivered' | 'failed' | 'cancelled';
@@ -19,19 +21,22 @@ export type ReminderBook = { readonly version: 1; readonly tasks: readonly Remin
19
21
 
20
22
  /** Generation-owned worker; saved state contains plain identities, never live objects. */
21
23
  export class ReminderService {
24
+ readonly administratorTasks: AdministratorReminderTasks;
22
25
  readonly #state: PluginStateStore;
23
26
  readonly #outbound: OutboundHost;
24
27
  readonly #config: ReminderConfig;
25
28
  #working = false;
26
29
  #stopped = false;
30
+ #administratorFirst = false;
27
31
 
28
32
  constructor(state: PluginStateStore, outbound: OutboundHost, config: ReminderConfig) {
29
33
  this.#state = state;
30
34
  this.#outbound = outbound;
31
35
  this.#config = config;
36
+ this.administratorTasks = new AdministratorReminderTasks(state, outbound, config);
32
37
  }
33
38
 
34
- stop(): void { this.#stopped = true; }
39
+ stop(): void { this.#stopped = true; this.administratorTasks.stop(); }
35
40
 
36
41
  async add(actor: ReminderActor, text: string, at: number, intervalMs: number | null): Promise<ReminderTask> {
37
42
  this.#assertLive();
@@ -79,6 +84,14 @@ export class ReminderService {
79
84
  for (let count = 0; count < this.#config.batchSize && !this.#stopped; count++) {
80
85
  const now = Date.now();
81
86
  const claimId = randomUUID();
87
+ const administratorFirst = this.#administratorFirst;
88
+ // Persist priority across ticks too: batchSize=1 must not starve either pool.
89
+ this.#administratorFirst = !this.#administratorFirst;
90
+ if (administratorFirst) {
91
+ const administratorTask = await this.administratorTasks.claim(now, claimId);
92
+ if (this.#stopped) break;
93
+ if (administratorTask) { await this.administratorTasks.deliver(administratorTask); continue; }
94
+ }
82
95
  const book = await this.#state.update<ReminderBook>(REMINDER_STATE_KEY, current => {
83
96
  let claimed = false;
84
97
  return { version: 1, tasks: readBook(current).tasks.map(task => {
@@ -94,7 +107,14 @@ export class ReminderService {
94
107
  }) };
95
108
  });
96
109
  const task = book?.tasks.find(item => item.claimId === claimId);
97
- if (!task || this.#stopped) break;
110
+ if (this.#stopped) break;
111
+ if (!task) {
112
+ if (administratorFirst) break;
113
+ const administratorTask = await this.administratorTasks.claim(now, claimId);
114
+ if (!administratorTask || this.#stopped) break;
115
+ await this.administratorTasks.deliver(administratorTask);
116
+ continue;
117
+ }
98
118
  let status: TaskStatus = 'unknown';
99
119
  let detail = '平台未返回确认的消息 ID;不会自动重试';
100
120
  try {
@@ -124,21 +144,6 @@ export class ReminderService {
124
144
  #assertLive(): void { if (this.#stopped) throw new Error('提醒服务属于已停止的插件实例'); }
125
145
  }
126
146
 
127
- export function parseReminderInstant(value: string): number {
128
- 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');
129
- 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));
130
- const calendar = new Date(0);
131
- calendar.setUTCFullYear(year, month, 0);
132
- const daysInMonth = calendar.getUTCDate();
133
- if (month < 1 || month > 12 || day < 1 || day > daysInMonth || hour > 23 || minute > 59 || second > 59) throw new TypeError('提醒时间无效');
134
- const time = Date.parse(value);
135
- if (!Number.isSafeInteger(time)) throw new TypeError('提醒时间无效');
136
- return time;
137
- }
138
-
139
- function nextPeriodicTime(from: number, interval: number, now: number): number {
140
- return from + (Math.floor((now - from) / interval) + 1) * interval;
141
- }
142
147
  function sameActor(task: ReminderTask, actor: ReminderActor): boolean {
143
148
  return task.senderId === actor.senderId && JSON.stringify(task.target) === JSON.stringify(createDeliveryTarget({ conversation: actor.target }));
144
149
  }
package/src/timing.ts ADDED
@@ -0,0 +1,17 @@
1
+ export const MAX_DELAY_SECONDS = 365 * 24 * 60 * 60;
2
+
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
+ 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
+ const calendar = new Date(0);
7
+ calendar.setUTCFullYear(year, month, 0);
8
+ const daysInMonth = calendar.getUTCDate();
9
+ if (month < 1 || month > 12 || day < 1 || day > daysInMonth || hour > 23 || minute > 59 || second > 59) throw new TypeError('提醒时间无效');
10
+ const time = Date.parse(value);
11
+ if (!Number.isSafeInteger(time)) throw new TypeError('提醒时间无效');
12
+ return time;
13
+ }
14
+
15
+ export function nextPeriodicTime(from: number, interval: number, now: number): number {
16
+ return from + (Math.floor((now - from) / interval) + 1) * interval;
17
+ }