@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.
- package/LICENSE +661 -661
- package/README.md +0 -14
- package/package.json +1 -1
- package/vendor/gugle-chat-bot/LICENSE +661 -661
- package/vendor/gugle-chat-bot/package.json +57 -57
- package/vendor/gugle-chat-bot/src/allow-rule.yml +9345 -9345
- package/vendor/gugle-chat-bot/src/api.json +4284 -4284
- package/vendor/gugle-chat-bot/src/command/index.ts +141 -141
- package/vendor/gugle-chat-bot/src/config/features.ts +547 -547
- package/vendor/gugle-chat-bot/src/config/index.ts +184 -184
- package/vendor/gugle-chat-bot/src/config/manager.ts +356 -356
- package/vendor/gugle-chat-bot/src/constants/index.ts +13 -13
- package/vendor/gugle-chat-bot/src/custom.ts +548 -548
- package/vendor/gugle-chat-bot/src/event/index.ts +118 -118
- package/vendor/gugle-chat-bot/src/features/admin/index.ts +414 -414
- package/vendor/gugle-chat-bot/src/features/bili/image/impl/video.ts +39 -39
- package/vendor/gugle-chat-bot/src/features/bili/image/index.ts +37 -37
- package/vendor/gugle-chat-bot/src/features/bili/index.ts +65 -65
- package/vendor/gugle-chat-bot/src/features/bili/template/style/video.css +97 -97
- package/vendor/gugle-chat-bot/src/features/bili/template/video.html +56 -56
- package/vendor/gugle-chat-bot/src/features/discord-bridge/index.ts +1128 -1128
- package/vendor/gugle-chat-bot/src/features/discord-bridge/markdown.ts +105 -105
- package/vendor/gugle-chat-bot/src/features/discord-bridge/slash.ts +355 -355
- package/vendor/gugle-chat-bot/src/features/discord-bridge/source.ts +96 -96
- package/vendor/gugle-chat-bot/src/features/discord-bridge/template/message.html +14 -14
- package/vendor/gugle-chat-bot/src/features/discord-bridge/template/style/message.css +144 -144
- package/vendor/gugle-chat-bot/src/features/github/api.ts +93 -93
- package/vendor/gugle-chat-bot/src/features/github/binding.ts +161 -161
- package/vendor/gugle-chat-bot/src/features/github/image/impl/issue.ts +132 -132
- package/vendor/gugle-chat-bot/src/features/github/image/impl/pr.ts +144 -144
- package/vendor/gugle-chat-bot/src/features/github/image/impl/release.ts +160 -160
- package/vendor/gugle-chat-bot/src/features/github/image/index.ts +310 -310
- package/vendor/gugle-chat-bot/src/features/github/index.ts +302 -302
- package/vendor/gugle-chat-bot/src/features/github/template/issue.html +118 -118
- package/vendor/gugle-chat-bot/src/features/github/template/pull_request.html +125 -125
- package/vendor/gugle-chat-bot/src/features/github/template/release.html +53 -53
- package/vendor/gugle-chat-bot/src/features/github/template/style/issue.css +468 -468
- package/vendor/gugle-chat-bot/src/features/github/template/style/pull_request.css +468 -468
- package/vendor/gugle-chat-bot/src/features/github/template/style/release.css +412 -412
- package/vendor/gugle-chat-bot/src/features/hermes/client.ts +184 -184
- package/vendor/gugle-chat-bot/src/features/hermes/config.ts +210 -210
- package/vendor/gugle-chat-bot/src/features/hermes/context.ts +67 -67
- package/vendor/gugle-chat-bot/src/features/hermes/history.ts +86 -86
- package/vendor/gugle-chat-bot/src/features/hermes/index.ts +1533 -1533
- package/vendor/gugle-chat-bot/src/features/hermes/message.ts +319 -319
- package/vendor/gugle-chat-bot/src/features/hermes/reaction.ts +181 -181
- package/vendor/gugle-chat-bot/src/features/hermes/render.ts +118 -118
- package/vendor/gugle-chat-bot/src/features/hermes/reply-units.ts +331 -331
- package/vendor/gugle-chat-bot/src/features/hermes/skills.ts +406 -406
- package/vendor/gugle-chat-bot/src/features/hermes/template/approval.html +30 -30
- package/vendor/gugle-chat-bot/src/features/hermes/template/progress.html +21 -21
- package/vendor/gugle-chat-bot/src/features/hermes/template/style/approval.css +81 -81
- package/vendor/gugle-chat-bot/src/features/hermes/template/style/progress.css +120 -120
- package/vendor/gugle-chat-bot/src/features/hermes/trigger.ts +86 -86
- package/vendor/gugle-chat-bot/src/features/hermes/types.ts +327 -327
- package/vendor/gugle-chat-bot/src/features/management/index.ts +156 -156
- package/vendor/gugle-chat-bot/src/features/minecraft/index.ts +127 -127
- package/vendor/gugle-chat-bot/src/features/modrinth/index.ts +71 -71
- package/vendor/gugle-chat-bot/src/features/parentheses/index.ts +117 -117
- package/vendor/gugle-chat-bot/src/features/peak-valley-timer/index.ts +451 -451
- package/vendor/gugle-chat-bot/src/features/poke/index.ts +24 -24
- package/vendor/gugle-chat-bot/src/features/version-tracker/index.ts +77 -77
- package/vendor/gugle-chat-bot/src/features/welcome/index.ts +57 -57
- package/vendor/gugle-chat-bot/src/github-ip.json +5814 -5814
- package/vendor/gugle-chat-bot/src/image/index.ts +278 -278
- package/vendor/gugle-chat-bot/src/index.ts +695 -695
- package/vendor/gugle-chat-bot/src/logger/index.ts +54 -54
- package/vendor/gugle-chat-bot/src/result.json +359 -359
- package/vendor/gugle-chat-bot/src/type/bili.d.ts +825 -825
- package/vendor/gugle-chat-bot/src/type/event.d.ts +146 -146
- package/vendor/gugle-chat-bot/src/type/github.d.ts +446 -446
- package/vendor/gugle-chat-bot/src/type/index.d.ts +254 -254
- package/vendor/gugle-chat-bot/tsconfig.json +46 -46
- 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
|
}
|