@dsh-plugin/dsh-synapse 0.1.0-dev.41044228975 → 0.1.0

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 (74) hide show
  1. package/LICENSE +661 -661
  2. package/README.md +0 -14
  3. package/package.json +1 -1
  4. package/vendor/gugle-chat-bot/LICENSE +661 -661
  5. package/vendor/gugle-chat-bot/package.json +57 -57
  6. package/vendor/gugle-chat-bot/src/allow-rule.yml +9345 -9345
  7. package/vendor/gugle-chat-bot/src/api.json +4284 -4284
  8. package/vendor/gugle-chat-bot/src/command/index.ts +141 -141
  9. package/vendor/gugle-chat-bot/src/config/features.ts +547 -547
  10. package/vendor/gugle-chat-bot/src/config/index.ts +184 -184
  11. package/vendor/gugle-chat-bot/src/config/manager.ts +356 -356
  12. package/vendor/gugle-chat-bot/src/constants/index.ts +13 -13
  13. package/vendor/gugle-chat-bot/src/custom.ts +548 -548
  14. package/vendor/gugle-chat-bot/src/event/index.ts +118 -118
  15. package/vendor/gugle-chat-bot/src/features/admin/index.ts +414 -414
  16. package/vendor/gugle-chat-bot/src/features/bili/image/impl/video.ts +39 -39
  17. package/vendor/gugle-chat-bot/src/features/bili/image/index.ts +37 -37
  18. package/vendor/gugle-chat-bot/src/features/bili/index.ts +65 -65
  19. package/vendor/gugle-chat-bot/src/features/bili/template/style/video.css +97 -97
  20. package/vendor/gugle-chat-bot/src/features/bili/template/video.html +56 -56
  21. package/vendor/gugle-chat-bot/src/features/discord-bridge/index.ts +1128 -1128
  22. package/vendor/gugle-chat-bot/src/features/discord-bridge/markdown.ts +105 -105
  23. package/vendor/gugle-chat-bot/src/features/discord-bridge/slash.ts +355 -355
  24. package/vendor/gugle-chat-bot/src/features/discord-bridge/source.ts +96 -96
  25. package/vendor/gugle-chat-bot/src/features/discord-bridge/template/message.html +14 -14
  26. package/vendor/gugle-chat-bot/src/features/discord-bridge/template/style/message.css +144 -144
  27. package/vendor/gugle-chat-bot/src/features/github/api.ts +93 -93
  28. package/vendor/gugle-chat-bot/src/features/github/binding.ts +161 -161
  29. package/vendor/gugle-chat-bot/src/features/github/image/impl/issue.ts +132 -132
  30. package/vendor/gugle-chat-bot/src/features/github/image/impl/pr.ts +144 -144
  31. package/vendor/gugle-chat-bot/src/features/github/image/impl/release.ts +160 -160
  32. package/vendor/gugle-chat-bot/src/features/github/image/index.ts +310 -310
  33. package/vendor/gugle-chat-bot/src/features/github/index.ts +302 -302
  34. package/vendor/gugle-chat-bot/src/features/github/template/issue.html +118 -118
  35. package/vendor/gugle-chat-bot/src/features/github/template/pull_request.html +125 -125
  36. package/vendor/gugle-chat-bot/src/features/github/template/release.html +53 -53
  37. package/vendor/gugle-chat-bot/src/features/github/template/style/issue.css +468 -468
  38. package/vendor/gugle-chat-bot/src/features/github/template/style/pull_request.css +468 -468
  39. package/vendor/gugle-chat-bot/src/features/github/template/style/release.css +412 -412
  40. package/vendor/gugle-chat-bot/src/features/hermes/client.ts +184 -184
  41. package/vendor/gugle-chat-bot/src/features/hermes/config.ts +210 -210
  42. package/vendor/gugle-chat-bot/src/features/hermes/context.ts +67 -67
  43. package/vendor/gugle-chat-bot/src/features/hermes/history.ts +86 -86
  44. package/vendor/gugle-chat-bot/src/features/hermes/index.ts +1533 -1533
  45. package/vendor/gugle-chat-bot/src/features/hermes/message.ts +319 -319
  46. package/vendor/gugle-chat-bot/src/features/hermes/reaction.ts +181 -181
  47. package/vendor/gugle-chat-bot/src/features/hermes/render.ts +118 -118
  48. package/vendor/gugle-chat-bot/src/features/hermes/reply-units.ts +331 -331
  49. package/vendor/gugle-chat-bot/src/features/hermes/skills.ts +406 -406
  50. package/vendor/gugle-chat-bot/src/features/hermes/template/approval.html +30 -30
  51. package/vendor/gugle-chat-bot/src/features/hermes/template/progress.html +21 -21
  52. package/vendor/gugle-chat-bot/src/features/hermes/template/style/approval.css +81 -81
  53. package/vendor/gugle-chat-bot/src/features/hermes/template/style/progress.css +120 -120
  54. package/vendor/gugle-chat-bot/src/features/hermes/trigger.ts +86 -86
  55. package/vendor/gugle-chat-bot/src/features/hermes/types.ts +327 -327
  56. package/vendor/gugle-chat-bot/src/features/management/index.ts +156 -156
  57. package/vendor/gugle-chat-bot/src/features/minecraft/index.ts +127 -127
  58. package/vendor/gugle-chat-bot/src/features/modrinth/index.ts +71 -71
  59. package/vendor/gugle-chat-bot/src/features/parentheses/index.ts +117 -117
  60. package/vendor/gugle-chat-bot/src/features/peak-valley-timer/index.ts +451 -451
  61. package/vendor/gugle-chat-bot/src/features/poke/index.ts +24 -24
  62. package/vendor/gugle-chat-bot/src/features/version-tracker/index.ts +77 -77
  63. package/vendor/gugle-chat-bot/src/features/welcome/index.ts +57 -57
  64. package/vendor/gugle-chat-bot/src/github-ip.json +5814 -5814
  65. package/vendor/gugle-chat-bot/src/image/index.ts +278 -278
  66. package/vendor/gugle-chat-bot/src/index.ts +695 -695
  67. package/vendor/gugle-chat-bot/src/logger/index.ts +54 -54
  68. package/vendor/gugle-chat-bot/src/result.json +359 -359
  69. package/vendor/gugle-chat-bot/src/type/bili.d.ts +825 -825
  70. package/vendor/gugle-chat-bot/src/type/event.d.ts +146 -146
  71. package/vendor/gugle-chat-bot/src/type/github.d.ts +446 -446
  72. package/vendor/gugle-chat-bot/src/type/index.d.ts +254 -254
  73. package/vendor/gugle-chat-bot/tsconfig.json +46 -46
  74. package/scripts/dump-npm-latest.mjs +0 -30
@@ -1,452 +1,452 @@
1
- import dayjs from 'dayjs';
2
- import { QQBot } from '@/index';
3
- import { getFeatureGroups } from '@/config/features';
4
- import { readOrCreate } from '@/config/manager';
5
- import { Message } from '@/type';
6
-
7
- export type PeakValleyMode = 'peak' | 'valley';
8
-
9
- export type DayToken = 'Mon' | 'Tue' | 'Wed' | 'Thu' | 'Fri' | 'Sat' | 'Sun';
10
-
11
- export interface PeakValleyTimerTimeRange {
12
- start: string;
13
- end: string;
14
- days: DayToken[];
15
- mode?: PeakValleyMode;
16
- msg?: string;
17
- }
18
-
19
- export interface PeakValleyTimerConfig {
20
- groups?: number[];
21
- mode?: PeakValleyMode;
22
- time: Array<string | PeakValleyTimerTimeRange>;
23
- peak_msg?: string;
24
- valley_msg?: string;
25
- cmd_peak_msg?: string;
26
- cmd_valley_msg?: string;
27
- }
28
-
29
- interface NormalizedTimeRange {
30
- startMinutes: number;
31
- endMinutes: number;
32
- days: number[];
33
- msg: string;
34
- mode?: PeakValleyMode;
35
- key: string;
36
- }
37
-
38
- const DAY_MAP: Record<DayToken, number> = {
39
- Mon: 1,
40
- Tue: 2,
41
- Wed: 3,
42
- Thu: 4,
43
- Fri: 5,
44
- Sat: 6,
45
- Sun: 0
46
- };
47
-
48
- const DAY_ORDER: DayToken[] = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
49
-
50
- const TIME_PATTERN: RegExp = /^([01]?\d|2[0-3]):([0-5]\d)$/;
51
-
52
- const CHECK_INTERVAL_MS = 30 * 1000;
53
- const CATCH_UP_WINDOW_SECONDS = 90;
54
-
55
- const defaultConfig: PeakValleyTimerConfig = {
56
- groups: getFeatureGroups('command'),
57
- mode: 'peak',
58
- time: ['9:00-12:00 Mon-Fri', '14:00-18:00 Mon-Fri'],
59
- peak_msg: '梁文峰时间到!\n当前时间是${time}',
60
- valley_msg: '梁文谷时间到!\n当前时间是${time}',
61
- cmd_peak_msg: '当前时间是${time},梁文峰时间!',
62
- cmd_valley_msg: '当前时间是${time},梁文谷时间!'
63
- };
64
-
65
- function isPeakValleyMode(value: unknown): value is PeakValleyMode {
66
- return value === 'peak' || value === 'valley';
67
- }
68
-
69
- function isNumberArray(value: unknown): value is number[] {
70
- return Array.isArray(value) && value.every(item => typeof item === 'number' && Number.isSafeInteger(item));
71
- }
72
-
73
- function isNonEmptyString(value: unknown): value is string {
74
- return typeof value === 'string' && value.trim().length > 0;
75
- }
76
-
77
- function parseTimeToMinutes(time: string): number | null {
78
- if (!TIME_PATTERN.test(time)) return null;
79
- const [hour, minute] = time.split(':');
80
- const hourValue = Number.parseInt(hour || '0', 10);
81
- const minuteValue = Number.parseInt(minute || '0', 10);
82
- if (Number.isNaN(hourValue) || Number.isNaN(minuteValue)) return null;
83
- return hourValue * 60 + minuteValue;
84
- }
85
-
86
- function expandDayToken(token: string): DayToken[] | null {
87
- const trimmed = token.trim();
88
- if (!trimmed) return null;
89
- const hyphenIndex = trimmed.indexOf('-');
90
- if (hyphenIndex === -1) {
91
- if (!(trimmed in DAY_MAP)) return null;
92
- return [trimmed as DayToken];
93
- }
94
- const from = trimmed.slice(0, hyphenIndex) as DayToken;
95
- const to = trimmed.slice(hyphenIndex + 1) as DayToken;
96
- if (!(from in DAY_MAP) || !(to in DAY_MAP)) return null;
97
- const fromIndex = DAY_ORDER.indexOf(from);
98
- const toIndex = DAY_ORDER.indexOf(to);
99
- const days: DayToken[] = [];
100
- let index = fromIndex;
101
- while (true) {
102
- const current = DAY_ORDER[index % DAY_ORDER.length];
103
- if (!current) break;
104
- days.push(current);
105
- if (current === to) break;
106
- index = (index + 1) % DAY_ORDER.length;
107
- }
108
- return days;
109
- }
110
-
111
- function parseDaysToken(daysToken: string): DayToken[] | null {
112
- if (!daysToken.trim()) return [];
113
- const tokens = daysToken.split(',').map(token => token.trim()).filter(token => token.length > 0);
114
- const result: DayToken[] = [];
115
- for (const token of tokens) {
116
- const expanded = expandDayToken(token);
117
- if (!expanded) return null;
118
- result.push(...expanded);
119
- }
120
- return Array.from(new Set(result));
121
- }
122
-
123
- function parseTimeRangeToken(token: string): PeakValleyTimerTimeRange | null {
124
- const parts = token.trim().split(/\s+/);
125
- const timePart = parts[0];
126
- if (!timePart) return null;
127
- const dashIndex = timePart.indexOf('-');
128
- if (dashIndex === -1) return null;
129
- const start = timePart.slice(0, dashIndex).trim();
130
- const end = timePart.slice(dashIndex + 1).trim();
131
- if (parseTimeToMinutes(start) === null || parseTimeToMinutes(end) === null) return null;
132
- const daysToken = parts.slice(1).join(' ');
133
- const days = parseDaysToken(daysToken);
134
- if (days === null) return null;
135
- return { start, end, days };
136
- }
137
-
138
- function normalizeTimeRange(time: unknown): NormalizedTimeRange | null {
139
- let resolved: PeakValleyTimerTimeRange | null = null;
140
- if (typeof time === 'string') {
141
- resolved = parseTimeRangeToken(time);
142
- } else if (time && typeof time === 'object' && !Array.isArray(time)) {
143
- const candidate = time as Record<string, unknown>;
144
- if (isNonEmptyString(candidate.start) && isNonEmptyString(candidate.end)) {
145
- const daysValue = candidate.days;
146
- let days: DayToken[] = [];
147
- if (daysValue !== undefined) {
148
- if (!Array.isArray(daysValue) || !daysValue.every(isNonEmptyString)) return null;
149
- const parsedDays = parseDaysToken(daysValue.join(','));
150
- if (parsedDays === null) return null;
151
- days = parsedDays;
152
- }
153
- const parsedMode = isPeakValleyMode(candidate.mode) ? candidate.mode : undefined;
154
- const parsedMsg = isNonEmptyString(candidate.msg) ? candidate.msg : undefined;
155
- if (parsedMode || parsedMsg) {
156
- resolved = {
157
- start: candidate.start,
158
- end: candidate.end,
159
- days,
160
- ...(parsedMode ? { mode: parsedMode } : {}),
161
- ...(parsedMsg ? { msg: parsedMsg } : {})
162
- };
163
- } else {
164
- resolved = {
165
- start: candidate.start,
166
- end: candidate.end,
167
- days
168
- };
169
- }
170
- }
171
- }
172
- if (!resolved) return null;
173
-
174
- const startMinutes = parseTimeToMinutes(resolved.start);
175
- const endMinutes = parseTimeToMinutes(resolved.end);
176
- if (startMinutes === null || endMinutes === null) return null;
177
-
178
- const mode = resolved.mode;
179
- const msg = resolved.msg;
180
- const key = `${resolved.start}-${resolved.end}-${resolved.days.join(',')}-${mode || ''}-${msg || ''}`;
181
- return {
182
- startMinutes: startMinutes,
183
- endMinutes: endMinutes,
184
- days: resolved.days.map(day => DAY_MAP[day]),
185
- msg: msg || '',
186
- ...(mode ? { mode: mode } : {}),
187
- key
188
- };
189
- }
190
-
191
- function normalizeConfig(raw: PeakValleyTimerConfig | null): {
192
- groups: number[];
193
- mode: PeakValleyMode;
194
- time: NormalizedTimeRange[];
195
- peak_msg: string;
196
- valley_msg: string;
197
- cmd_peak_msg: string;
198
- cmd_valley_msg: string;
199
- } {
200
- const groups =
201
- raw && raw.groups !== undefined ? (isNumberArray(raw.groups) ? raw.groups : defaultConfig.groups!) : defaultConfig.groups!;
202
- const mode = raw && raw.mode !== undefined ? (isPeakValleyMode(raw.mode) ? raw.mode : 'peak') : 'peak';
203
-
204
- let time: NormalizedTimeRange[] = [];
205
- if (raw && Array.isArray(raw.time)) {
206
- time = raw.time.map(normalizeTimeRange).filter((range): range is NormalizedTimeRange => range !== null);
207
- }
208
- if (time.length === 0) {
209
- time = defaultConfig.time.map(normalizeTimeRange).filter((range): range is NormalizedTimeRange => range !== null);
210
- }
211
-
212
- const peak_msg =
213
- raw && raw.peak_msg !== undefined
214
- ? isNonEmptyString(raw.peak_msg)
215
- ? raw.peak_msg
216
- : defaultConfig.peak_msg!
217
- : defaultConfig.peak_msg!;
218
- const valley_msg =
219
- raw && raw.valley_msg !== undefined
220
- ? isNonEmptyString(raw.valley_msg)
221
- ? raw.valley_msg
222
- : defaultConfig.valley_msg!
223
- : defaultConfig.valley_msg!;
224
- const cmd_peak_msg =
225
- raw && raw.cmd_peak_msg !== undefined
226
- ? isNonEmptyString(raw.cmd_peak_msg)
227
- ? raw.cmd_peak_msg
228
- : defaultConfig.cmd_peak_msg!
229
- : defaultConfig.cmd_peak_msg!;
230
- const cmd_valley_msg =
231
- raw && raw.cmd_valley_msg !== undefined
232
- ? isNonEmptyString(raw.cmd_valley_msg)
233
- ? raw.cmd_valley_msg
234
- : defaultConfig.cmd_valley_msg!
235
- : defaultConfig.cmd_valley_msg!;
236
- return { groups, mode, time, peak_msg, valley_msg, cmd_peak_msg, cmd_valley_msg };
237
- }
238
-
239
- function normalizeRawConfig(raw: unknown): PeakValleyTimerConfig | null {
240
- if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
241
- const record = raw as Record<string, unknown>;
242
- const time = record.time;
243
- if (time !== undefined && !Array.isArray(time)) return null;
244
- return raw as PeakValleyTimerConfig;
245
- }
246
-
247
- function loadConfig(): ReturnType<typeof normalizeConfig> {
248
- const { data } = readOrCreate<PeakValleyTimerConfig>({
249
- path: 'configs/peak-valley-timer.json',
250
- factory: () => defaultConfig,
251
- version: 1,
252
- normalize: normalizeRawConfig
253
- });
254
- return normalizeConfig(data);
255
- }
256
-
257
- export class PeakValleyTimer {
258
- private static instance?: PeakValleyTimer = undefined;
259
-
260
- public static getInstance(): PeakValleyTimer {
261
- if (!PeakValleyTimer.instance) {
262
- PeakValleyTimer.instance = new PeakValleyTimer();
263
- }
264
- return PeakValleyTimer.instance;
265
- }
266
-
267
- private groups: number[];
268
- private mode: PeakValleyMode;
269
- private time: NormalizedTimeRange[];
270
- private peakMsg: string;
271
- private valleyMsg: string;
272
- private cmdPeakMsg: string;
273
- private cmdValleyMsg: string;
274
- private readonly reportedToday: Set<string> = new Set();
275
- private timer: NodeJS.Timeout | undefined = undefined;
276
-
277
- public constructor() {
278
- const config = loadConfig();
279
- this.groups = config.groups;
280
- this.mode = config.mode;
281
- this.time = config.time;
282
- this.peakMsg = config.peak_msg;
283
- this.valleyMsg = config.valley_msg;
284
- this.cmdPeakMsg = config.cmd_peak_msg;
285
- this.cmdValleyMsg = config.cmd_valley_msg;
286
- }
287
-
288
- /**
289
- * 从磁盘重新读取配置(/reload 用)。
290
- * 本类在构造时把配置读进字段(非 ConfigStore 热重载),故需显式重载入口。
291
- */
292
- public reloadConfig(): void {
293
- const config = loadConfig();
294
- this.groups = config.groups;
295
- this.mode = config.mode;
296
- this.time = config.time;
297
- this.peakMsg = config.peak_msg;
298
- this.valleyMsg = config.valley_msg;
299
- this.cmdPeakMsg = config.cmd_peak_msg;
300
- this.cmdValleyMsg = config.cmd_valley_msg;
301
- }
302
-
303
- public start(bot: QQBot): void {
304
- bot.logger?.info(
305
- `PeakValleyTimer started: ${this.time.length} time range(s), ${this.groups.length} group(s), mode=${this.mode}`
306
- );
307
- this.timer = setInterval(() => this.check(bot), CHECK_INTERVAL_MS);
308
- setTimeout(() => this.check(bot), 1000);
309
- }
310
-
311
- public stop(): void {
312
- if (this.timer) {
313
- clearInterval(this.timer);
314
- this.timer = undefined;
315
- }
316
- }
317
-
318
- /**
319
- * /pvtime 命令处理:根据当前时间是否处于某个 peak/valley 区间返回对应消息。
320
- * 命中区间 → 该区间类型(自身 mode 优先,否则全局 mode)的 cmd 消息;
321
- * 未命中任何区间 → 全局 mode 相反类型的 cmd 消息。
322
- */
323
- public getCommandMessage(): string {
324
- const now = dayjs();
325
- const weekday = now.day();
326
- const nowMinutes = now.hour() * 60 + now.minute();
327
- const currentTime = now.format('HH:mm');
328
-
329
- for (const range of this.time) {
330
- if (this.isInRange(range, weekday, nowMinutes)) {
331
- const rangeMode: PeakValleyMode = range.mode ?? this.mode;
332
- return this.renderMessage(rangeMode === 'peak' ? this.cmdPeakMsg : this.cmdValleyMsg, currentTime);
333
- }
334
- }
335
- const opposite: PeakValleyMode = this.mode === 'peak' ? 'valley' : 'peak';
336
- return this.renderMessage(opposite === 'peak' ? this.cmdPeakMsg : this.cmdValleyMsg, currentTime);
337
- }
338
-
339
- /**
340
- * 判断某时刻是否处于区间内。区间归属日:不跨天区间当天在 days 中;
341
- * 跨天区间(end <= start)[start, 24:00) 当天在 days,[0, end) 前一天在 days。
342
- */
343
- private isInRange(range: NormalizedTimeRange, weekday: number, nowMinutes: number): boolean {
344
- if (range.endMinutes > range.startMinutes) {
345
- return range.days.includes(weekday) && nowMinutes >= range.startMinutes && nowMinutes < range.endMinutes;
346
- }
347
- if (nowMinutes >= range.startMinutes && range.days.includes(weekday)) return true;
348
- if (nowMinutes < range.endMinutes && range.days.includes((weekday + 6) % 7)) return true;
349
- return false;
350
- }
351
-
352
- private renderMessage(template: string, currentTime: string): string {
353
- return template.replace(/\$\{time\}/g, currentTime);
354
- }
355
-
356
- private check(bot: QQBot): void {
357
- const now = dayjs();
358
- const today = now.format('YYYY-MM-DD');
359
- const weekday = now.day();
360
- const secondsIntoDay = now.hour() * 3600 + now.minute() * 60 + now.second();
361
- const nowMinutes = now.hour() * 60 + now.minute();
362
-
363
- for (const range of this.time) {
364
- this.checkStart(bot, range, today, weekday, secondsIntoDay, now, nowMinutes);
365
- this.checkEnd(bot, range, today, weekday, secondsIntoDay, now, nowMinutes);
366
- }
367
- }
368
-
369
- /**
370
- * 区间开始触发:当天在 days 中且到达 start 时刻 → 报区间类型"到"。
371
- */
372
- private checkStart(
373
- bot: QQBot,
374
- range: NormalizedTimeRange,
375
- today: string,
376
- weekday: number,
377
- secondsIntoDay: number,
378
- now: dayjs.Dayjs,
379
- nowMinutes: number
380
- ): void {
381
- if (!range.days.includes(weekday)) return;
382
- const elapsed = secondsIntoDay - range.startMinutes * 60;
383
- if (elapsed < 0 || elapsed >= CATCH_UP_WINDOW_SECONDS) return;
384
- const reportKey = `${today}:${range.key}:start`;
385
- if (this.reportedToday.has(reportKey)) return;
386
- this.reportedToday.add(reportKey);
387
- bot.logger?.info(`PeakValleyTimer start triggered: ${range.key} at ${nowMinutes}`);
388
- const rangeMode: PeakValleyMode = range.mode ?? this.mode;
389
- const message = this.buildMessage(rangeMode, range.msg, now);
390
- this.broadcast(bot, message);
391
- }
392
-
393
- /**
394
- * 区间结束触发:到达 end 时刻 → 报相反类型"到"(离开该类型时段)。
395
- * 不跨天:end 触发日在 days 中当天;跨天(end <= start):start 在
396
- * 开始日 D 的 startMinutes,end 在 D+1 的 endMinutes,因此要求
397
- * 前一天在 days 中,且此刻尚未到今天的 start。
398
- */
399
- private checkEnd(
400
- bot: QQBot,
401
- range: NormalizedTimeRange,
402
- today: string,
403
- weekday: number,
404
- secondsIntoDay: number,
405
- now: dayjs.Dayjs,
406
- nowMinutes: number
407
- ): void {
408
- if (range.endMinutes > range.startMinutes) {
409
- if (!range.days.includes(weekday)) return;
410
- } else {
411
- // 跨天区间:end 在开始日的次日凌晨
412
- const prevWeekday = (weekday + 6) % 7;
413
- if (!range.days.includes(prevWeekday)) return;
414
- if (nowMinutes >= range.startMinutes) return; // 今天已到 start,属于新一轮区间
415
- }
416
- const elapsed = secondsIntoDay - range.endMinutes * 60;
417
- if (elapsed < 0 || elapsed >= CATCH_UP_WINDOW_SECONDS) return;
418
- const reportKey = `${today}:${range.key}:end`;
419
- if (this.reportedToday.has(reportKey)) return;
420
- this.reportedToday.add(reportKey);
421
- bot.logger?.info(`PeakValleyTimer end triggered: ${range.key} at ${nowMinutes}`);
422
- const rangeMode: PeakValleyMode = range.mode ?? this.mode;
423
- const opposite: PeakValleyMode = rangeMode === 'peak' ? 'valley' : 'peak';
424
- const message = this.buildMessage(opposite, range.msg, now);
425
- this.broadcast(bot, message);
426
- }
427
-
428
- private broadcast(bot: QQBot, message: string): void {
429
- for (const groupId of this.groups) {
430
- const sentMessage: Message[] = [
431
- {
432
- type: 'text',
433
- data: {
434
- text: message
435
- }
436
- }
437
- ];
438
- bot.sendGroupMsg(groupId, sentMessage);
439
- }
440
- }
441
-
442
- private buildMessage(rangeMode: PeakValleyMode, customMsg: string | undefined, now: dayjs.Dayjs): string {
443
- const currentTime = now.format('HH:mm');
444
- let template: string;
445
- if (customMsg) {
446
- template = customMsg;
447
- } else {
448
- template = rangeMode === 'valley' ? this.valleyMsg : this.peakMsg;
449
- }
450
- return template.replace(/\$\{time\}/g, currentTime);
451
- }
1
+ import dayjs from 'dayjs';
2
+ import { QQBot } from '@/index';
3
+ import { getFeatureGroups } from '@/config/features';
4
+ import { readOrCreate } from '@/config/manager';
5
+ import { Message } from '@/type';
6
+
7
+ export type PeakValleyMode = 'peak' | 'valley';
8
+
9
+ export type DayToken = 'Mon' | 'Tue' | 'Wed' | 'Thu' | 'Fri' | 'Sat' | 'Sun';
10
+
11
+ export interface PeakValleyTimerTimeRange {
12
+ start: string;
13
+ end: string;
14
+ days: DayToken[];
15
+ mode?: PeakValleyMode;
16
+ msg?: string;
17
+ }
18
+
19
+ export interface PeakValleyTimerConfig {
20
+ groups?: number[];
21
+ mode?: PeakValleyMode;
22
+ time: Array<string | PeakValleyTimerTimeRange>;
23
+ peak_msg?: string;
24
+ valley_msg?: string;
25
+ cmd_peak_msg?: string;
26
+ cmd_valley_msg?: string;
27
+ }
28
+
29
+ interface NormalizedTimeRange {
30
+ startMinutes: number;
31
+ endMinutes: number;
32
+ days: number[];
33
+ msg: string;
34
+ mode?: PeakValleyMode;
35
+ key: string;
36
+ }
37
+
38
+ const DAY_MAP: Record<DayToken, number> = {
39
+ Mon: 1,
40
+ Tue: 2,
41
+ Wed: 3,
42
+ Thu: 4,
43
+ Fri: 5,
44
+ Sat: 6,
45
+ Sun: 0
46
+ };
47
+
48
+ const DAY_ORDER: DayToken[] = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
49
+
50
+ const TIME_PATTERN: RegExp = /^([01]?\d|2[0-3]):([0-5]\d)$/;
51
+
52
+ const CHECK_INTERVAL_MS = 30 * 1000;
53
+ const CATCH_UP_WINDOW_SECONDS = 90;
54
+
55
+ const defaultConfig: PeakValleyTimerConfig = {
56
+ groups: getFeatureGroups('command'),
57
+ mode: 'peak',
58
+ time: ['9:00-12:00 Mon-Fri', '14:00-18:00 Mon-Fri'],
59
+ peak_msg: '梁文峰时间到!\n当前时间是${time}',
60
+ valley_msg: '梁文谷时间到!\n当前时间是${time}',
61
+ cmd_peak_msg: '当前时间是${time},梁文峰时间!',
62
+ cmd_valley_msg: '当前时间是${time},梁文谷时间!'
63
+ };
64
+
65
+ function isPeakValleyMode(value: unknown): value is PeakValleyMode {
66
+ return value === 'peak' || value === 'valley';
67
+ }
68
+
69
+ function isNumberArray(value: unknown): value is number[] {
70
+ return Array.isArray(value) && value.every(item => typeof item === 'number' && Number.isSafeInteger(item));
71
+ }
72
+
73
+ function isNonEmptyString(value: unknown): value is string {
74
+ return typeof value === 'string' && value.trim().length > 0;
75
+ }
76
+
77
+ function parseTimeToMinutes(time: string): number | null {
78
+ if (!TIME_PATTERN.test(time)) return null;
79
+ const [hour, minute] = time.split(':');
80
+ const hourValue = Number.parseInt(hour || '0', 10);
81
+ const minuteValue = Number.parseInt(minute || '0', 10);
82
+ if (Number.isNaN(hourValue) || Number.isNaN(minuteValue)) return null;
83
+ return hourValue * 60 + minuteValue;
84
+ }
85
+
86
+ function expandDayToken(token: string): DayToken[] | null {
87
+ const trimmed = token.trim();
88
+ if (!trimmed) return null;
89
+ const hyphenIndex = trimmed.indexOf('-');
90
+ if (hyphenIndex === -1) {
91
+ if (!(trimmed in DAY_MAP)) return null;
92
+ return [trimmed as DayToken];
93
+ }
94
+ const from = trimmed.slice(0, hyphenIndex) as DayToken;
95
+ const to = trimmed.slice(hyphenIndex + 1) as DayToken;
96
+ if (!(from in DAY_MAP) || !(to in DAY_MAP)) return null;
97
+ const fromIndex = DAY_ORDER.indexOf(from);
98
+ const toIndex = DAY_ORDER.indexOf(to);
99
+ const days: DayToken[] = [];
100
+ let index = fromIndex;
101
+ while (true) {
102
+ const current = DAY_ORDER[index % DAY_ORDER.length];
103
+ if (!current) break;
104
+ days.push(current);
105
+ if (current === to) break;
106
+ index = (index + 1) % DAY_ORDER.length;
107
+ }
108
+ return days;
109
+ }
110
+
111
+ function parseDaysToken(daysToken: string): DayToken[] | null {
112
+ if (!daysToken.trim()) return [];
113
+ const tokens = daysToken.split(',').map(token => token.trim()).filter(token => token.length > 0);
114
+ const result: DayToken[] = [];
115
+ for (const token of tokens) {
116
+ const expanded = expandDayToken(token);
117
+ if (!expanded) return null;
118
+ result.push(...expanded);
119
+ }
120
+ return Array.from(new Set(result));
121
+ }
122
+
123
+ function parseTimeRangeToken(token: string): PeakValleyTimerTimeRange | null {
124
+ const parts = token.trim().split(/\s+/);
125
+ const timePart = parts[0];
126
+ if (!timePart) return null;
127
+ const dashIndex = timePart.indexOf('-');
128
+ if (dashIndex === -1) return null;
129
+ const start = timePart.slice(0, dashIndex).trim();
130
+ const end = timePart.slice(dashIndex + 1).trim();
131
+ if (parseTimeToMinutes(start) === null || parseTimeToMinutes(end) === null) return null;
132
+ const daysToken = parts.slice(1).join(' ');
133
+ const days = parseDaysToken(daysToken);
134
+ if (days === null) return null;
135
+ return { start, end, days };
136
+ }
137
+
138
+ function normalizeTimeRange(time: unknown): NormalizedTimeRange | null {
139
+ let resolved: PeakValleyTimerTimeRange | null = null;
140
+ if (typeof time === 'string') {
141
+ resolved = parseTimeRangeToken(time);
142
+ } else if (time && typeof time === 'object' && !Array.isArray(time)) {
143
+ const candidate = time as Record<string, unknown>;
144
+ if (isNonEmptyString(candidate.start) && isNonEmptyString(candidate.end)) {
145
+ const daysValue = candidate.days;
146
+ let days: DayToken[] = [];
147
+ if (daysValue !== undefined) {
148
+ if (!Array.isArray(daysValue) || !daysValue.every(isNonEmptyString)) return null;
149
+ const parsedDays = parseDaysToken(daysValue.join(','));
150
+ if (parsedDays === null) return null;
151
+ days = parsedDays;
152
+ }
153
+ const parsedMode = isPeakValleyMode(candidate.mode) ? candidate.mode : undefined;
154
+ const parsedMsg = isNonEmptyString(candidate.msg) ? candidate.msg : undefined;
155
+ if (parsedMode || parsedMsg) {
156
+ resolved = {
157
+ start: candidate.start,
158
+ end: candidate.end,
159
+ days,
160
+ ...(parsedMode ? { mode: parsedMode } : {}),
161
+ ...(parsedMsg ? { msg: parsedMsg } : {})
162
+ };
163
+ } else {
164
+ resolved = {
165
+ start: candidate.start,
166
+ end: candidate.end,
167
+ days
168
+ };
169
+ }
170
+ }
171
+ }
172
+ if (!resolved) return null;
173
+
174
+ const startMinutes = parseTimeToMinutes(resolved.start);
175
+ const endMinutes = parseTimeToMinutes(resolved.end);
176
+ if (startMinutes === null || endMinutes === null) return null;
177
+
178
+ const mode = resolved.mode;
179
+ const msg = resolved.msg;
180
+ const key = `${resolved.start}-${resolved.end}-${resolved.days.join(',')}-${mode || ''}-${msg || ''}`;
181
+ return {
182
+ startMinutes: startMinutes,
183
+ endMinutes: endMinutes,
184
+ days: resolved.days.map(day => DAY_MAP[day]),
185
+ msg: msg || '',
186
+ ...(mode ? { mode: mode } : {}),
187
+ key
188
+ };
189
+ }
190
+
191
+ function normalizeConfig(raw: PeakValleyTimerConfig | null): {
192
+ groups: number[];
193
+ mode: PeakValleyMode;
194
+ time: NormalizedTimeRange[];
195
+ peak_msg: string;
196
+ valley_msg: string;
197
+ cmd_peak_msg: string;
198
+ cmd_valley_msg: string;
199
+ } {
200
+ const groups =
201
+ raw && raw.groups !== undefined ? (isNumberArray(raw.groups) ? raw.groups : defaultConfig.groups!) : defaultConfig.groups!;
202
+ const mode = raw && raw.mode !== undefined ? (isPeakValleyMode(raw.mode) ? raw.mode : 'peak') : 'peak';
203
+
204
+ let time: NormalizedTimeRange[] = [];
205
+ if (raw && Array.isArray(raw.time)) {
206
+ time = raw.time.map(normalizeTimeRange).filter((range): range is NormalizedTimeRange => range !== null);
207
+ }
208
+ if (time.length === 0) {
209
+ time = defaultConfig.time.map(normalizeTimeRange).filter((range): range is NormalizedTimeRange => range !== null);
210
+ }
211
+
212
+ const peak_msg =
213
+ raw && raw.peak_msg !== undefined
214
+ ? isNonEmptyString(raw.peak_msg)
215
+ ? raw.peak_msg
216
+ : defaultConfig.peak_msg!
217
+ : defaultConfig.peak_msg!;
218
+ const valley_msg =
219
+ raw && raw.valley_msg !== undefined
220
+ ? isNonEmptyString(raw.valley_msg)
221
+ ? raw.valley_msg
222
+ : defaultConfig.valley_msg!
223
+ : defaultConfig.valley_msg!;
224
+ const cmd_peak_msg =
225
+ raw && raw.cmd_peak_msg !== undefined
226
+ ? isNonEmptyString(raw.cmd_peak_msg)
227
+ ? raw.cmd_peak_msg
228
+ : defaultConfig.cmd_peak_msg!
229
+ : defaultConfig.cmd_peak_msg!;
230
+ const cmd_valley_msg =
231
+ raw && raw.cmd_valley_msg !== undefined
232
+ ? isNonEmptyString(raw.cmd_valley_msg)
233
+ ? raw.cmd_valley_msg
234
+ : defaultConfig.cmd_valley_msg!
235
+ : defaultConfig.cmd_valley_msg!;
236
+ return { groups, mode, time, peak_msg, valley_msg, cmd_peak_msg, cmd_valley_msg };
237
+ }
238
+
239
+ function normalizeRawConfig(raw: unknown): PeakValleyTimerConfig | null {
240
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
241
+ const record = raw as Record<string, unknown>;
242
+ const time = record.time;
243
+ if (time !== undefined && !Array.isArray(time)) return null;
244
+ return raw as PeakValleyTimerConfig;
245
+ }
246
+
247
+ function loadConfig(): ReturnType<typeof normalizeConfig> {
248
+ const { data } = readOrCreate<PeakValleyTimerConfig>({
249
+ path: 'configs/peak-valley-timer.json',
250
+ factory: () => defaultConfig,
251
+ version: 1,
252
+ normalize: normalizeRawConfig
253
+ });
254
+ return normalizeConfig(data);
255
+ }
256
+
257
+ export class PeakValleyTimer {
258
+ private static instance?: PeakValleyTimer = undefined;
259
+
260
+ public static getInstance(): PeakValleyTimer {
261
+ if (!PeakValleyTimer.instance) {
262
+ PeakValleyTimer.instance = new PeakValleyTimer();
263
+ }
264
+ return PeakValleyTimer.instance;
265
+ }
266
+
267
+ private groups: number[];
268
+ private mode: PeakValleyMode;
269
+ private time: NormalizedTimeRange[];
270
+ private peakMsg: string;
271
+ private valleyMsg: string;
272
+ private cmdPeakMsg: string;
273
+ private cmdValleyMsg: string;
274
+ private readonly reportedToday: Set<string> = new Set();
275
+ private timer: NodeJS.Timeout | undefined = undefined;
276
+
277
+ public constructor() {
278
+ const config = loadConfig();
279
+ this.groups = config.groups;
280
+ this.mode = config.mode;
281
+ this.time = config.time;
282
+ this.peakMsg = config.peak_msg;
283
+ this.valleyMsg = config.valley_msg;
284
+ this.cmdPeakMsg = config.cmd_peak_msg;
285
+ this.cmdValleyMsg = config.cmd_valley_msg;
286
+ }
287
+
288
+ /**
289
+ * 从磁盘重新读取配置(/reload 用)。
290
+ * 本类在构造时把配置读进字段(非 ConfigStore 热重载),故需显式重载入口。
291
+ */
292
+ public reloadConfig(): void {
293
+ const config = loadConfig();
294
+ this.groups = config.groups;
295
+ this.mode = config.mode;
296
+ this.time = config.time;
297
+ this.peakMsg = config.peak_msg;
298
+ this.valleyMsg = config.valley_msg;
299
+ this.cmdPeakMsg = config.cmd_peak_msg;
300
+ this.cmdValleyMsg = config.cmd_valley_msg;
301
+ }
302
+
303
+ public start(bot: QQBot): void {
304
+ bot.logger?.info(
305
+ `PeakValleyTimer started: ${this.time.length} time range(s), ${this.groups.length} group(s), mode=${this.mode}`
306
+ );
307
+ this.timer = setInterval(() => this.check(bot), CHECK_INTERVAL_MS);
308
+ setTimeout(() => this.check(bot), 1000);
309
+ }
310
+
311
+ public stop(): void {
312
+ if (this.timer) {
313
+ clearInterval(this.timer);
314
+ this.timer = undefined;
315
+ }
316
+ }
317
+
318
+ /**
319
+ * /pvtime 命令处理:根据当前时间是否处于某个 peak/valley 区间返回对应消息。
320
+ * 命中区间 → 该区间类型(自身 mode 优先,否则全局 mode)的 cmd 消息;
321
+ * 未命中任何区间 → 全局 mode 相反类型的 cmd 消息。
322
+ */
323
+ public getCommandMessage(): string {
324
+ const now = dayjs();
325
+ const weekday = now.day();
326
+ const nowMinutes = now.hour() * 60 + now.minute();
327
+ const currentTime = now.format('HH:mm');
328
+
329
+ for (const range of this.time) {
330
+ if (this.isInRange(range, weekday, nowMinutes)) {
331
+ const rangeMode: PeakValleyMode = range.mode ?? this.mode;
332
+ return this.renderMessage(rangeMode === 'peak' ? this.cmdPeakMsg : this.cmdValleyMsg, currentTime);
333
+ }
334
+ }
335
+ const opposite: PeakValleyMode = this.mode === 'peak' ? 'valley' : 'peak';
336
+ return this.renderMessage(opposite === 'peak' ? this.cmdPeakMsg : this.cmdValleyMsg, currentTime);
337
+ }
338
+
339
+ /**
340
+ * 判断某时刻是否处于区间内。区间归属日:不跨天区间当天在 days 中;
341
+ * 跨天区间(end <= start)[start, 24:00) 当天在 days,[0, end) 前一天在 days。
342
+ */
343
+ private isInRange(range: NormalizedTimeRange, weekday: number, nowMinutes: number): boolean {
344
+ if (range.endMinutes > range.startMinutes) {
345
+ return range.days.includes(weekday) && nowMinutes >= range.startMinutes && nowMinutes < range.endMinutes;
346
+ }
347
+ if (nowMinutes >= range.startMinutes && range.days.includes(weekday)) return true;
348
+ if (nowMinutes < range.endMinutes && range.days.includes((weekday + 6) % 7)) return true;
349
+ return false;
350
+ }
351
+
352
+ private renderMessage(template: string, currentTime: string): string {
353
+ return template.replace(/\$\{time\}/g, currentTime);
354
+ }
355
+
356
+ private check(bot: QQBot): void {
357
+ const now = dayjs();
358
+ const today = now.format('YYYY-MM-DD');
359
+ const weekday = now.day();
360
+ const secondsIntoDay = now.hour() * 3600 + now.minute() * 60 + now.second();
361
+ const nowMinutes = now.hour() * 60 + now.minute();
362
+
363
+ for (const range of this.time) {
364
+ this.checkStart(bot, range, today, weekday, secondsIntoDay, now, nowMinutes);
365
+ this.checkEnd(bot, range, today, weekday, secondsIntoDay, now, nowMinutes);
366
+ }
367
+ }
368
+
369
+ /**
370
+ * 区间开始触发:当天在 days 中且到达 start 时刻 → 报区间类型"到"。
371
+ */
372
+ private checkStart(
373
+ bot: QQBot,
374
+ range: NormalizedTimeRange,
375
+ today: string,
376
+ weekday: number,
377
+ secondsIntoDay: number,
378
+ now: dayjs.Dayjs,
379
+ nowMinutes: number
380
+ ): void {
381
+ if (!range.days.includes(weekday)) return;
382
+ const elapsed = secondsIntoDay - range.startMinutes * 60;
383
+ if (elapsed < 0 || elapsed >= CATCH_UP_WINDOW_SECONDS) return;
384
+ const reportKey = `${today}:${range.key}:start`;
385
+ if (this.reportedToday.has(reportKey)) return;
386
+ this.reportedToday.add(reportKey);
387
+ bot.logger?.info(`PeakValleyTimer start triggered: ${range.key} at ${nowMinutes}`);
388
+ const rangeMode: PeakValleyMode = range.mode ?? this.mode;
389
+ const message = this.buildMessage(rangeMode, range.msg, now);
390
+ this.broadcast(bot, message);
391
+ }
392
+
393
+ /**
394
+ * 区间结束触发:到达 end 时刻 → 报相反类型"到"(离开该类型时段)。
395
+ * 不跨天:end 触发日在 days 中当天;跨天(end <= start):start 在
396
+ * 开始日 D 的 startMinutes,end 在 D+1 的 endMinutes,因此要求
397
+ * 前一天在 days 中,且此刻尚未到今天的 start。
398
+ */
399
+ private checkEnd(
400
+ bot: QQBot,
401
+ range: NormalizedTimeRange,
402
+ today: string,
403
+ weekday: number,
404
+ secondsIntoDay: number,
405
+ now: dayjs.Dayjs,
406
+ nowMinutes: number
407
+ ): void {
408
+ if (range.endMinutes > range.startMinutes) {
409
+ if (!range.days.includes(weekday)) return;
410
+ } else {
411
+ // 跨天区间:end 在开始日的次日凌晨
412
+ const prevWeekday = (weekday + 6) % 7;
413
+ if (!range.days.includes(prevWeekday)) return;
414
+ if (nowMinutes >= range.startMinutes) return; // 今天已到 start,属于新一轮区间
415
+ }
416
+ const elapsed = secondsIntoDay - range.endMinutes * 60;
417
+ if (elapsed < 0 || elapsed >= CATCH_UP_WINDOW_SECONDS) return;
418
+ const reportKey = `${today}:${range.key}:end`;
419
+ if (this.reportedToday.has(reportKey)) return;
420
+ this.reportedToday.add(reportKey);
421
+ bot.logger?.info(`PeakValleyTimer end triggered: ${range.key} at ${nowMinutes}`);
422
+ const rangeMode: PeakValleyMode = range.mode ?? this.mode;
423
+ const opposite: PeakValleyMode = rangeMode === 'peak' ? 'valley' : 'peak';
424
+ const message = this.buildMessage(opposite, range.msg, now);
425
+ this.broadcast(bot, message);
426
+ }
427
+
428
+ private broadcast(bot: QQBot, message: string): void {
429
+ for (const groupId of this.groups) {
430
+ const sentMessage: Message[] = [
431
+ {
432
+ type: 'text',
433
+ data: {
434
+ text: message
435
+ }
436
+ }
437
+ ];
438
+ bot.sendGroupMsg(groupId, sentMessage);
439
+ }
440
+ }
441
+
442
+ private buildMessage(rangeMode: PeakValleyMode, customMsg: string | undefined, now: dayjs.Dayjs): string {
443
+ const currentTime = now.format('HH:mm');
444
+ let template: string;
445
+ if (customMsg) {
446
+ template = customMsg;
447
+ } else {
448
+ template = rangeMode === 'valley' ? this.valleyMsg : this.peakMsg;
449
+ }
450
+ return template.replace(/\$\{time\}/g, currentTime);
451
+ }
452
452
  }