@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,357 +1,357 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
|
|
4
|
-
export interface MigrationStep {
|
|
5
|
-
/** 源版本号(迁移前) */
|
|
6
|
-
from: number;
|
|
7
|
-
/** 目标版本号(迁移后) */
|
|
8
|
-
to: number;
|
|
9
|
-
/** 迁移函数:接收旧数据,返回新数据 */
|
|
10
|
-
migrate: (data: Record<string, unknown>) => Record<string, unknown>;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export interface ConfigFileOptions<T> {
|
|
14
|
-
/** 配置文件路径(相对 process.cwd() 或绝对路径) */
|
|
15
|
-
path: string;
|
|
16
|
-
/** 默认值:文件不存在时写入的内容;也可用 factory 动态生成 */
|
|
17
|
-
default?: T;
|
|
18
|
-
/** 动态默认值工厂(优先于 default),如需要基于运行时状态生成默认配置 */
|
|
19
|
-
factory?: () => T;
|
|
20
|
-
/** 解析后的校验/规范化钩子:返回 false 表示内容非法(触发备份重建),返回规范化后的值 */
|
|
21
|
-
normalize?: (raw: unknown) => T | null;
|
|
22
|
-
/** 期望的配置文件版本号(默认 1)。文件版本低于此值时按 migrations 逐级升级 */
|
|
23
|
-
version?: number;
|
|
24
|
-
/** 版本迁移步骤:按 from 升序执行,把旧版本数据升级到新版本 */
|
|
25
|
-
migrations?: MigrationStep[];
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export interface ReadOrCreateResult<T> {
|
|
29
|
-
/** 最终生效的数据(可能是默认值或规范化后的值) */
|
|
30
|
-
data: T;
|
|
31
|
-
/** 是否发生了"新建 / 备份重建 / 规范化后写回"等写盘动作 */
|
|
32
|
-
wrote: boolean;
|
|
33
|
-
/** 文件原本的内容损坏被备份时的备份路径;未发生则为 undefined */
|
|
34
|
-
backupPath?: string;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const EMPTY_OBJECT = {};
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* 读取 JSON 配置文件;文件不存在时用默认值创建,内容损坏时备份并重建。
|
|
41
|
-
*
|
|
42
|
-
* 统一了各区模块各自为政的"读取/创建/备份"逻辑:
|
|
43
|
-
* - 目录不存在 → 自动创建
|
|
44
|
-
* - 文件不存在 → 写入 default / factory() 生成的内容
|
|
45
|
-
* - JSON 解析失败 → 备份为 `<name>.bak.<timestamp>.json` 后重建为默认内容
|
|
46
|
-
* - normalize 返回 null(内容非法)→ 同样备份后重建
|
|
47
|
-
* - normalize 返回合法值 → 若与原文件不同则写回(规范化落盘)
|
|
48
|
-
*
|
|
49
|
-
* @example
|
|
50
|
-
* const { data } = readOrCreate<BotConfig>({
|
|
51
|
-
* path: 'configs/bot-config.json',
|
|
52
|
-
* factory: () => defaultBotConfig,
|
|
53
|
-
* normalize: raw => normalizeConfig(raw)
|
|
54
|
-
* });
|
|
55
|
-
*/
|
|
56
|
-
export function readOrCreate<T>(options: ConfigFileOptions<T>): ReadOrCreateResult<T> {
|
|
57
|
-
const configPath = path.resolve(process.cwd(), options.path);
|
|
58
|
-
const dir = path.dirname(configPath);
|
|
59
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
60
|
-
|
|
61
|
-
const factory = options.factory || (() => options.default as T);
|
|
62
|
-
let wrote = false;
|
|
63
|
-
let backupPath: string | undefined = undefined;
|
|
64
|
-
|
|
65
|
-
// 文件不存在:直接创建默认内容
|
|
66
|
-
if (!fs.existsSync(configPath)) {
|
|
67
|
-
const created = factory();
|
|
68
|
-
writeConfigFile(configPath, created);
|
|
69
|
-
return { data: created, wrote: true };
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
const contents = fs.readFileSync(configPath, 'utf8');
|
|
73
|
-
let raw: unknown;
|
|
74
|
-
let parsed: unknown;
|
|
75
|
-
try {
|
|
76
|
-
parsed = JSON.parse(contents);
|
|
77
|
-
} catch (_) {
|
|
78
|
-
// JSON 损坏:备份原文件后重建默认
|
|
79
|
-
backupPath = createBackupConfigFile(configPath, contents);
|
|
80
|
-
const rebuilt = factory();
|
|
81
|
-
writeConfigFile(configPath, rebuilt);
|
|
82
|
-
return { data: rebuilt, wrote: true, backupPath };
|
|
83
|
-
}
|
|
84
|
-
// 版本迁移:文件版本低于期望版本时逐级升级
|
|
85
|
-
const targetVersion = options.version ?? 1;
|
|
86
|
-
let migrated = parsed;
|
|
87
|
-
let migratedAny = false;
|
|
88
|
-
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
89
|
-
const currentVersion =
|
|
90
|
-
typeof (parsed as Record<string, unknown>).version === 'number'
|
|
91
|
-
? ((parsed as Record<string, unknown>).version as number)
|
|
92
|
-
: 1;
|
|
93
|
-
if (currentVersion < targetVersion && options.migrations) {
|
|
94
|
-
const ordered = [...options.migrations].sort((a, b) => a.from - b.from);
|
|
95
|
-
let working = parsed as Record<string, unknown>;
|
|
96
|
-
for (const step of ordered) {
|
|
97
|
-
if (step.from >= currentVersion && step.to <= targetVersion) {
|
|
98
|
-
working = step.migrate(working);
|
|
99
|
-
migratedAny = true;
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
if (migratedAny) {
|
|
103
|
-
working = { ...working, version: targetVersion };
|
|
104
|
-
writeConfigFile(configPath, working);
|
|
105
|
-
return { data: working as T, wrote: true };
|
|
106
|
-
}
|
|
107
|
-
migrated = working;
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
raw = migrated;
|
|
111
|
-
|
|
112
|
-
if (options.normalize) {
|
|
113
|
-
const normalized = options.normalize(raw);
|
|
114
|
-
if (normalized === null) {
|
|
115
|
-
// 内容非法:备份后重建默认
|
|
116
|
-
backupPath = createBackupConfigFile(configPath, contents);
|
|
117
|
-
const rebuilt = factory();
|
|
118
|
-
writeConfigFile(configPath, rebuilt);
|
|
119
|
-
return { data: rebuilt, wrote: true, backupPath };
|
|
120
|
-
}
|
|
121
|
-
// 规范化结果与文件不一致时写回(比如补了缺失的默认字段)
|
|
122
|
-
const serialized = JSON.stringify(normalized, null, 2);
|
|
123
|
-
if (serialized !== contents.trim()) {
|
|
124
|
-
writeConfigFile(configPath, normalized);
|
|
125
|
-
wrote = true;
|
|
126
|
-
}
|
|
127
|
-
return { data: normalized, wrote };
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
return { data: raw as T, wrote };
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* 读取 JSON 配置文件;文件不存在返回 null,JSON 损坏抛出错误(由调用方决定如何处理)。
|
|
135
|
-
*/
|
|
136
|
-
export function readConfigFile<T>(configPath: string): T | null {
|
|
137
|
-
const resolved = path.resolve(process.cwd(), configPath);
|
|
138
|
-
if (!fs.existsSync(resolved)) return null;
|
|
139
|
-
const contents = fs.readFileSync(resolved, 'utf8');
|
|
140
|
-
try {
|
|
141
|
-
return JSON.parse(contents) as T;
|
|
142
|
-
} catch (error) {
|
|
143
|
-
throw new Error(
|
|
144
|
-
`无法解析配置文件 ${resolved}: ${error instanceof Error ? error.message : String(error)}`
|
|
145
|
-
);
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
/**
|
|
150
|
-
* 写入 JSON 配置文件(原子写:先写临时文件再 rename,避免写入中断损坏文件)。
|
|
151
|
-
*/
|
|
152
|
-
export function writeConfigFile<T>(configPath: string, data: T, options: { pretty?: boolean } = {}): void {
|
|
153
|
-
const resolved = path.resolve(process.cwd(), configPath);
|
|
154
|
-
fs.mkdirSync(path.dirname(resolved), { recursive: true });
|
|
155
|
-
const serialized = options.pretty === false ? JSON.stringify(data) : `${JSON.stringify(data, null, 2)}\n`;
|
|
156
|
-
const tempPath = `${resolved}.tmp-${process.pid}`;
|
|
157
|
-
fs.writeFileSync(tempPath, serialized, 'utf8');
|
|
158
|
-
fs.renameSync(tempPath, resolved);
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* 备份文件为 `<name>.bak.<timestamp>.<n>.json`,返回备份路径。
|
|
163
|
-
*/
|
|
164
|
-
export function createBackupConfigFile(configPath: string, contents: string): string {
|
|
165
|
-
const resolved = path.resolve(process.cwd(), configPath);
|
|
166
|
-
const { dir, ext, name } = path.parse(resolved);
|
|
167
|
-
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
168
|
-
let backupPath = path.join(dir, `${name}.bak.${timestamp}${ext}`);
|
|
169
|
-
let index = 1;
|
|
170
|
-
while (fs.existsSync(backupPath)) {
|
|
171
|
-
backupPath = path.join(dir, `${name}.bak.${timestamp}.${index}${ext}`);
|
|
172
|
-
index++;
|
|
173
|
-
}
|
|
174
|
-
fs.writeFileSync(backupPath, contents, 'utf8');
|
|
175
|
-
return backupPath;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
export { EMPTY_OBJECT };
|
|
179
|
-
|
|
180
|
-
// ---------------------------------------------------------------------------
|
|
181
|
-
// ConfigStore:带热重载的配置仓库
|
|
182
|
-
// ---------------------------------------------------------------------------
|
|
183
|
-
|
|
184
|
-
export type ConfigChangeSource = 'self' | 'file' | 'reload';
|
|
185
|
-
|
|
186
|
-
/**
|
|
187
|
-
* 全局 ConfigStore 注册表:供 /reload 命令统一从磁盘重载全部配置。
|
|
188
|
-
*
|
|
189
|
-
* 各功能模块的 store 在模块加载时创建(部分为懒加载),没有统一入口;
|
|
190
|
-
* 这里由 ConfigStore 构造函数自动登记,避免每个模块各自暴露 reload 函数、
|
|
191
|
-
* 也避免新增配置时漏掉重载。
|
|
192
|
-
*/
|
|
193
|
-
const configStoreRegistry = new Set<ConfigStore<unknown>>();
|
|
194
|
-
|
|
195
|
-
/** 从磁盘重载全部已登记的配置仓库,返回成功重载的数量 */
|
|
196
|
-
export function reloadAllConfigStores(): number {
|
|
197
|
-
let reloaded = 0;
|
|
198
|
-
for (const store of configStoreRegistry) {
|
|
199
|
-
try {
|
|
200
|
-
store.reload('reload');
|
|
201
|
-
reloaded++;
|
|
202
|
-
} catch {
|
|
203
|
-
// 单个配置文件损坏时不影响其余配置重载
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
return reloaded;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
/** 已登记的配置仓库数量(诊断/测试用) */
|
|
210
|
-
export function configStoreCount(): number {
|
|
211
|
-
return configStoreRegistry.size;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
export interface ConfigStoreOptions<T> extends ConfigFileOptions<T> {
|
|
215
|
-
/** 是否监听文件变化自动热重载(默认 true) */
|
|
216
|
-
watch?: boolean;
|
|
217
|
-
/** 文件变化触发重载的防抖毫秒数(默认 300),应对编辑器多次写入 */
|
|
218
|
-
debounceMs?: number;
|
|
219
|
-
/** 通过 onChange 通知时是否传入(旧值, 新值, 来源) */
|
|
220
|
-
logger?: (message: string) => void;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
export class ConfigStore<T> {
|
|
224
|
-
private readonly options: ConfigStoreOptions<T>;
|
|
225
|
-
private data: T;
|
|
226
|
-
private readonly listeners: Array<(data: T, source: ConfigChangeSource) => void> = [];
|
|
227
|
-
private watcher: fs.FSWatcher | undefined = undefined;
|
|
228
|
-
private debounceTimer: NodeJS.Timeout | undefined = undefined;
|
|
229
|
-
private selfWrite = false;
|
|
230
|
-
|
|
231
|
-
public constructor(options: ConfigStoreOptions<T>) {
|
|
232
|
-
this.options = options;
|
|
233
|
-
const result = readOrCreate<T>(options);
|
|
234
|
-
this.data = result.data;
|
|
235
|
-
if (options.watch !== false) this.watch();
|
|
236
|
-
// 登记到全局注册表,供 /reload 统一重载
|
|
237
|
-
configStoreRegistry.add(this as unknown as ConfigStore<unknown>);
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
/** 当前生效配置(内存缓存) */
|
|
241
|
-
public get(): T {
|
|
242
|
-
return this.data;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
/**
|
|
246
|
-
* 更新配置:写入文件(原子写)→ 更新缓存 → 通知订阅者(来源 self)。
|
|
247
|
-
* 通过 ConfigStore 更新的无需依赖文件 watch 事件。
|
|
248
|
-
*/
|
|
249
|
-
public update(next: T): T {
|
|
250
|
-
const resolved = path.resolve(process.cwd(), this.options.path);
|
|
251
|
-
this.selfWrite = true;
|
|
252
|
-
try {
|
|
253
|
-
writeConfigFile(resolved, next);
|
|
254
|
-
} finally {
|
|
255
|
-
this.selfWrite = false;
|
|
256
|
-
}
|
|
257
|
-
this.data = next;
|
|
258
|
-
for (const listener of this.listeners) {
|
|
259
|
-
try {
|
|
260
|
-
listener(next, 'self');
|
|
261
|
-
} catch (error) {
|
|
262
|
-
this.options.logger?.(`[config-manager] onChange(self) 回调异常: ${String(error)}`);
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
return this.data;
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
/** 从磁盘重新读取配置(丢弃缓存)。文件变化时通知订阅者(来源 file / reload)。 */
|
|
269
|
-
public reload(source: ConfigChangeSource = 'reload'): T {
|
|
270
|
-
const result = readOrCreate<T>(this.options);
|
|
271
|
-
if (!isDeepEqual(result.data, this.data)) {
|
|
272
|
-
this.data = result.data;
|
|
273
|
-
for (const listener of this.listeners) {
|
|
274
|
-
try {
|
|
275
|
-
listener(result.data, source);
|
|
276
|
-
} catch (error) {
|
|
277
|
-
this.options.logger?.(`[config-manager] onChange(${source}) 回调异常: ${String(error)}`);
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
return this.data;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
/** 订阅配置变更(数据变化时触发)。返回取消订阅函数。 */
|
|
285
|
-
public onChange(listener: (data: T, source: ConfigChangeSource) => void): () => void {
|
|
286
|
-
this.listeners.push(listener);
|
|
287
|
-
return () => {
|
|
288
|
-
const index = this.listeners.indexOf(listener);
|
|
289
|
-
if (index >= 0) this.listeners.splice(index, 1);
|
|
290
|
-
};
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
/**
|
|
294
|
-
* 监听文件变化自动热重载。
|
|
295
|
-
* - 防抖:编辑器/批量工具连续写入时只在停顿后重载一次
|
|
296
|
-
* - 自写抑制:update() 自己写入时标记 selfWrite,忽略随后的 change 事件
|
|
297
|
-
*/
|
|
298
|
-
public watch(): void {
|
|
299
|
-
if (this.watcher) return;
|
|
300
|
-
const resolved = path.resolve(process.cwd(), this.options.path);
|
|
301
|
-
const dir = path.dirname(resolved);
|
|
302
|
-
const filename = path.basename(resolved);
|
|
303
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
304
|
-
|
|
305
|
-
let lastReported: string | undefined = undefined;
|
|
306
|
-
this.watcher = fs.watch(dir, (_event, changedFile) => {
|
|
307
|
-
if (changedFile !== filename) return;
|
|
308
|
-
if (this.selfWrite) return;
|
|
309
|
-
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
|
310
|
-
this.debounceTimer = setTimeout(() => {
|
|
311
|
-
try {
|
|
312
|
-
const before = JSON.stringify(this.data);
|
|
313
|
-
this.reload('file');
|
|
314
|
-
const after = JSON.stringify(this.data);
|
|
315
|
-
if (before !== after) {
|
|
316
|
-
const reported = `${new Date().toISOString()} ${this.options.path} -> 已热重载`;
|
|
317
|
-
if (reported !== lastReported) {
|
|
318
|
-
lastReported = reported;
|
|
319
|
-
this.options.logger?.(reported);
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
} catch (error) {
|
|
323
|
-
this.options.logger?.(`[config-manager] 热重载失败: ${String(error)}`);
|
|
324
|
-
}
|
|
325
|
-
}, this.options.debounceMs ?? 300);
|
|
326
|
-
});
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
/** 停止文件监听 */
|
|
330
|
-
public dispose(): void {
|
|
331
|
-
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
|
332
|
-
if (this.watcher) {
|
|
333
|
-
this.watcher.close();
|
|
334
|
-
this.watcher = undefined;
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
/** 深度比较两个 JSON 值是否相等(用于判断变更是否需要通知)。 */
|
|
340
|
-
function isDeepEqual(a: unknown, b: unknown): boolean {
|
|
341
|
-
if (a === b) return true;
|
|
342
|
-
if (typeof a !== typeof b) return false;
|
|
343
|
-
if (a === null || b === null) return a === b;
|
|
344
|
-
if (Array.isArray(a) && Array.isArray(b)) {
|
|
345
|
-
if (a.length !== b.length) return false;
|
|
346
|
-
return a.every((item, index) => isDeepEqual(item, b[index]));
|
|
347
|
-
}
|
|
348
|
-
if (typeof a === 'object' && typeof b === 'object') {
|
|
349
|
-
const aObj = a as Record<string, unknown>;
|
|
350
|
-
const bObj = b as Record<string, unknown>;
|
|
351
|
-
const aKeys = Object.keys(aObj);
|
|
352
|
-
const bKeys = Object.keys(bObj);
|
|
353
|
-
if (aKeys.length !== bKeys.length) return false;
|
|
354
|
-
return aKeys.every(key => isDeepEqual(aObj[key], bObj[key]));
|
|
355
|
-
}
|
|
356
|
-
return false;
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export interface MigrationStep {
|
|
5
|
+
/** 源版本号(迁移前) */
|
|
6
|
+
from: number;
|
|
7
|
+
/** 目标版本号(迁移后) */
|
|
8
|
+
to: number;
|
|
9
|
+
/** 迁移函数:接收旧数据,返回新数据 */
|
|
10
|
+
migrate: (data: Record<string, unknown>) => Record<string, unknown>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ConfigFileOptions<T> {
|
|
14
|
+
/** 配置文件路径(相对 process.cwd() 或绝对路径) */
|
|
15
|
+
path: string;
|
|
16
|
+
/** 默认值:文件不存在时写入的内容;也可用 factory 动态生成 */
|
|
17
|
+
default?: T;
|
|
18
|
+
/** 动态默认值工厂(优先于 default),如需要基于运行时状态生成默认配置 */
|
|
19
|
+
factory?: () => T;
|
|
20
|
+
/** 解析后的校验/规范化钩子:返回 false 表示内容非法(触发备份重建),返回规范化后的值 */
|
|
21
|
+
normalize?: (raw: unknown) => T | null;
|
|
22
|
+
/** 期望的配置文件版本号(默认 1)。文件版本低于此值时按 migrations 逐级升级 */
|
|
23
|
+
version?: number;
|
|
24
|
+
/** 版本迁移步骤:按 from 升序执行,把旧版本数据升级到新版本 */
|
|
25
|
+
migrations?: MigrationStep[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ReadOrCreateResult<T> {
|
|
29
|
+
/** 最终生效的数据(可能是默认值或规范化后的值) */
|
|
30
|
+
data: T;
|
|
31
|
+
/** 是否发生了"新建 / 备份重建 / 规范化后写回"等写盘动作 */
|
|
32
|
+
wrote: boolean;
|
|
33
|
+
/** 文件原本的内容损坏被备份时的备份路径;未发生则为 undefined */
|
|
34
|
+
backupPath?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const EMPTY_OBJECT = {};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 读取 JSON 配置文件;文件不存在时用默认值创建,内容损坏时备份并重建。
|
|
41
|
+
*
|
|
42
|
+
* 统一了各区模块各自为政的"读取/创建/备份"逻辑:
|
|
43
|
+
* - 目录不存在 → 自动创建
|
|
44
|
+
* - 文件不存在 → 写入 default / factory() 生成的内容
|
|
45
|
+
* - JSON 解析失败 → 备份为 `<name>.bak.<timestamp>.json` 后重建为默认内容
|
|
46
|
+
* - normalize 返回 null(内容非法)→ 同样备份后重建
|
|
47
|
+
* - normalize 返回合法值 → 若与原文件不同则写回(规范化落盘)
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* const { data } = readOrCreate<BotConfig>({
|
|
51
|
+
* path: 'configs/bot-config.json',
|
|
52
|
+
* factory: () => defaultBotConfig,
|
|
53
|
+
* normalize: raw => normalizeConfig(raw)
|
|
54
|
+
* });
|
|
55
|
+
*/
|
|
56
|
+
export function readOrCreate<T>(options: ConfigFileOptions<T>): ReadOrCreateResult<T> {
|
|
57
|
+
const configPath = path.resolve(process.cwd(), options.path);
|
|
58
|
+
const dir = path.dirname(configPath);
|
|
59
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
60
|
+
|
|
61
|
+
const factory = options.factory || (() => options.default as T);
|
|
62
|
+
let wrote = false;
|
|
63
|
+
let backupPath: string | undefined = undefined;
|
|
64
|
+
|
|
65
|
+
// 文件不存在:直接创建默认内容
|
|
66
|
+
if (!fs.existsSync(configPath)) {
|
|
67
|
+
const created = factory();
|
|
68
|
+
writeConfigFile(configPath, created);
|
|
69
|
+
return { data: created, wrote: true };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const contents = fs.readFileSync(configPath, 'utf8');
|
|
73
|
+
let raw: unknown;
|
|
74
|
+
let parsed: unknown;
|
|
75
|
+
try {
|
|
76
|
+
parsed = JSON.parse(contents);
|
|
77
|
+
} catch (_) {
|
|
78
|
+
// JSON 损坏:备份原文件后重建默认
|
|
79
|
+
backupPath = createBackupConfigFile(configPath, contents);
|
|
80
|
+
const rebuilt = factory();
|
|
81
|
+
writeConfigFile(configPath, rebuilt);
|
|
82
|
+
return { data: rebuilt, wrote: true, backupPath };
|
|
83
|
+
}
|
|
84
|
+
// 版本迁移:文件版本低于期望版本时逐级升级
|
|
85
|
+
const targetVersion = options.version ?? 1;
|
|
86
|
+
let migrated = parsed;
|
|
87
|
+
let migratedAny = false;
|
|
88
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
89
|
+
const currentVersion =
|
|
90
|
+
typeof (parsed as Record<string, unknown>).version === 'number'
|
|
91
|
+
? ((parsed as Record<string, unknown>).version as number)
|
|
92
|
+
: 1;
|
|
93
|
+
if (currentVersion < targetVersion && options.migrations) {
|
|
94
|
+
const ordered = [...options.migrations].sort((a, b) => a.from - b.from);
|
|
95
|
+
let working = parsed as Record<string, unknown>;
|
|
96
|
+
for (const step of ordered) {
|
|
97
|
+
if (step.from >= currentVersion && step.to <= targetVersion) {
|
|
98
|
+
working = step.migrate(working);
|
|
99
|
+
migratedAny = true;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (migratedAny) {
|
|
103
|
+
working = { ...working, version: targetVersion };
|
|
104
|
+
writeConfigFile(configPath, working);
|
|
105
|
+
return { data: working as T, wrote: true };
|
|
106
|
+
}
|
|
107
|
+
migrated = working;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
raw = migrated;
|
|
111
|
+
|
|
112
|
+
if (options.normalize) {
|
|
113
|
+
const normalized = options.normalize(raw);
|
|
114
|
+
if (normalized === null) {
|
|
115
|
+
// 内容非法:备份后重建默认
|
|
116
|
+
backupPath = createBackupConfigFile(configPath, contents);
|
|
117
|
+
const rebuilt = factory();
|
|
118
|
+
writeConfigFile(configPath, rebuilt);
|
|
119
|
+
return { data: rebuilt, wrote: true, backupPath };
|
|
120
|
+
}
|
|
121
|
+
// 规范化结果与文件不一致时写回(比如补了缺失的默认字段)
|
|
122
|
+
const serialized = JSON.stringify(normalized, null, 2);
|
|
123
|
+
if (serialized !== contents.trim()) {
|
|
124
|
+
writeConfigFile(configPath, normalized);
|
|
125
|
+
wrote = true;
|
|
126
|
+
}
|
|
127
|
+
return { data: normalized, wrote };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return { data: raw as T, wrote };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* 读取 JSON 配置文件;文件不存在返回 null,JSON 损坏抛出错误(由调用方决定如何处理)。
|
|
135
|
+
*/
|
|
136
|
+
export function readConfigFile<T>(configPath: string): T | null {
|
|
137
|
+
const resolved = path.resolve(process.cwd(), configPath);
|
|
138
|
+
if (!fs.existsSync(resolved)) return null;
|
|
139
|
+
const contents = fs.readFileSync(resolved, 'utf8');
|
|
140
|
+
try {
|
|
141
|
+
return JSON.parse(contents) as T;
|
|
142
|
+
} catch (error) {
|
|
143
|
+
throw new Error(
|
|
144
|
+
`无法解析配置文件 ${resolved}: ${error instanceof Error ? error.message : String(error)}`
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* 写入 JSON 配置文件(原子写:先写临时文件再 rename,避免写入中断损坏文件)。
|
|
151
|
+
*/
|
|
152
|
+
export function writeConfigFile<T>(configPath: string, data: T, options: { pretty?: boolean } = {}): void {
|
|
153
|
+
const resolved = path.resolve(process.cwd(), configPath);
|
|
154
|
+
fs.mkdirSync(path.dirname(resolved), { recursive: true });
|
|
155
|
+
const serialized = options.pretty === false ? JSON.stringify(data) : `${JSON.stringify(data, null, 2)}\n`;
|
|
156
|
+
const tempPath = `${resolved}.tmp-${process.pid}`;
|
|
157
|
+
fs.writeFileSync(tempPath, serialized, 'utf8');
|
|
158
|
+
fs.renameSync(tempPath, resolved);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* 备份文件为 `<name>.bak.<timestamp>.<n>.json`,返回备份路径。
|
|
163
|
+
*/
|
|
164
|
+
export function createBackupConfigFile(configPath: string, contents: string): string {
|
|
165
|
+
const resolved = path.resolve(process.cwd(), configPath);
|
|
166
|
+
const { dir, ext, name } = path.parse(resolved);
|
|
167
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
168
|
+
let backupPath = path.join(dir, `${name}.bak.${timestamp}${ext}`);
|
|
169
|
+
let index = 1;
|
|
170
|
+
while (fs.existsSync(backupPath)) {
|
|
171
|
+
backupPath = path.join(dir, `${name}.bak.${timestamp}.${index}${ext}`);
|
|
172
|
+
index++;
|
|
173
|
+
}
|
|
174
|
+
fs.writeFileSync(backupPath, contents, 'utf8');
|
|
175
|
+
return backupPath;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export { EMPTY_OBJECT };
|
|
179
|
+
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
// ConfigStore:带热重载的配置仓库
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
export type ConfigChangeSource = 'self' | 'file' | 'reload';
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* 全局 ConfigStore 注册表:供 /reload 命令统一从磁盘重载全部配置。
|
|
188
|
+
*
|
|
189
|
+
* 各功能模块的 store 在模块加载时创建(部分为懒加载),没有统一入口;
|
|
190
|
+
* 这里由 ConfigStore 构造函数自动登记,避免每个模块各自暴露 reload 函数、
|
|
191
|
+
* 也避免新增配置时漏掉重载。
|
|
192
|
+
*/
|
|
193
|
+
const configStoreRegistry = new Set<ConfigStore<unknown>>();
|
|
194
|
+
|
|
195
|
+
/** 从磁盘重载全部已登记的配置仓库,返回成功重载的数量 */
|
|
196
|
+
export function reloadAllConfigStores(): number {
|
|
197
|
+
let reloaded = 0;
|
|
198
|
+
for (const store of configStoreRegistry) {
|
|
199
|
+
try {
|
|
200
|
+
store.reload('reload');
|
|
201
|
+
reloaded++;
|
|
202
|
+
} catch {
|
|
203
|
+
// 单个配置文件损坏时不影响其余配置重载
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return reloaded;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** 已登记的配置仓库数量(诊断/测试用) */
|
|
210
|
+
export function configStoreCount(): number {
|
|
211
|
+
return configStoreRegistry.size;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export interface ConfigStoreOptions<T> extends ConfigFileOptions<T> {
|
|
215
|
+
/** 是否监听文件变化自动热重载(默认 true) */
|
|
216
|
+
watch?: boolean;
|
|
217
|
+
/** 文件变化触发重载的防抖毫秒数(默认 300),应对编辑器多次写入 */
|
|
218
|
+
debounceMs?: number;
|
|
219
|
+
/** 通过 onChange 通知时是否传入(旧值, 新值, 来源) */
|
|
220
|
+
logger?: (message: string) => void;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export class ConfigStore<T> {
|
|
224
|
+
private readonly options: ConfigStoreOptions<T>;
|
|
225
|
+
private data: T;
|
|
226
|
+
private readonly listeners: Array<(data: T, source: ConfigChangeSource) => void> = [];
|
|
227
|
+
private watcher: fs.FSWatcher | undefined = undefined;
|
|
228
|
+
private debounceTimer: NodeJS.Timeout | undefined = undefined;
|
|
229
|
+
private selfWrite = false;
|
|
230
|
+
|
|
231
|
+
public constructor(options: ConfigStoreOptions<T>) {
|
|
232
|
+
this.options = options;
|
|
233
|
+
const result = readOrCreate<T>(options);
|
|
234
|
+
this.data = result.data;
|
|
235
|
+
if (options.watch !== false) this.watch();
|
|
236
|
+
// 登记到全局注册表,供 /reload 统一重载
|
|
237
|
+
configStoreRegistry.add(this as unknown as ConfigStore<unknown>);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** 当前生效配置(内存缓存) */
|
|
241
|
+
public get(): T {
|
|
242
|
+
return this.data;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* 更新配置:写入文件(原子写)→ 更新缓存 → 通知订阅者(来源 self)。
|
|
247
|
+
* 通过 ConfigStore 更新的无需依赖文件 watch 事件。
|
|
248
|
+
*/
|
|
249
|
+
public update(next: T): T {
|
|
250
|
+
const resolved = path.resolve(process.cwd(), this.options.path);
|
|
251
|
+
this.selfWrite = true;
|
|
252
|
+
try {
|
|
253
|
+
writeConfigFile(resolved, next);
|
|
254
|
+
} finally {
|
|
255
|
+
this.selfWrite = false;
|
|
256
|
+
}
|
|
257
|
+
this.data = next;
|
|
258
|
+
for (const listener of this.listeners) {
|
|
259
|
+
try {
|
|
260
|
+
listener(next, 'self');
|
|
261
|
+
} catch (error) {
|
|
262
|
+
this.options.logger?.(`[config-manager] onChange(self) 回调异常: ${String(error)}`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return this.data;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** 从磁盘重新读取配置(丢弃缓存)。文件变化时通知订阅者(来源 file / reload)。 */
|
|
269
|
+
public reload(source: ConfigChangeSource = 'reload'): T {
|
|
270
|
+
const result = readOrCreate<T>(this.options);
|
|
271
|
+
if (!isDeepEqual(result.data, this.data)) {
|
|
272
|
+
this.data = result.data;
|
|
273
|
+
for (const listener of this.listeners) {
|
|
274
|
+
try {
|
|
275
|
+
listener(result.data, source);
|
|
276
|
+
} catch (error) {
|
|
277
|
+
this.options.logger?.(`[config-manager] onChange(${source}) 回调异常: ${String(error)}`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return this.data;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** 订阅配置变更(数据变化时触发)。返回取消订阅函数。 */
|
|
285
|
+
public onChange(listener: (data: T, source: ConfigChangeSource) => void): () => void {
|
|
286
|
+
this.listeners.push(listener);
|
|
287
|
+
return () => {
|
|
288
|
+
const index = this.listeners.indexOf(listener);
|
|
289
|
+
if (index >= 0) this.listeners.splice(index, 1);
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* 监听文件变化自动热重载。
|
|
295
|
+
* - 防抖:编辑器/批量工具连续写入时只在停顿后重载一次
|
|
296
|
+
* - 自写抑制:update() 自己写入时标记 selfWrite,忽略随后的 change 事件
|
|
297
|
+
*/
|
|
298
|
+
public watch(): void {
|
|
299
|
+
if (this.watcher) return;
|
|
300
|
+
const resolved = path.resolve(process.cwd(), this.options.path);
|
|
301
|
+
const dir = path.dirname(resolved);
|
|
302
|
+
const filename = path.basename(resolved);
|
|
303
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
304
|
+
|
|
305
|
+
let lastReported: string | undefined = undefined;
|
|
306
|
+
this.watcher = fs.watch(dir, (_event, changedFile) => {
|
|
307
|
+
if (changedFile !== filename) return;
|
|
308
|
+
if (this.selfWrite) return;
|
|
309
|
+
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
|
310
|
+
this.debounceTimer = setTimeout(() => {
|
|
311
|
+
try {
|
|
312
|
+
const before = JSON.stringify(this.data);
|
|
313
|
+
this.reload('file');
|
|
314
|
+
const after = JSON.stringify(this.data);
|
|
315
|
+
if (before !== after) {
|
|
316
|
+
const reported = `${new Date().toISOString()} ${this.options.path} -> 已热重载`;
|
|
317
|
+
if (reported !== lastReported) {
|
|
318
|
+
lastReported = reported;
|
|
319
|
+
this.options.logger?.(reported);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
} catch (error) {
|
|
323
|
+
this.options.logger?.(`[config-manager] 热重载失败: ${String(error)}`);
|
|
324
|
+
}
|
|
325
|
+
}, this.options.debounceMs ?? 300);
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** 停止文件监听 */
|
|
330
|
+
public dispose(): void {
|
|
331
|
+
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
|
332
|
+
if (this.watcher) {
|
|
333
|
+
this.watcher.close();
|
|
334
|
+
this.watcher = undefined;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** 深度比较两个 JSON 值是否相等(用于判断变更是否需要通知)。 */
|
|
340
|
+
function isDeepEqual(a: unknown, b: unknown): boolean {
|
|
341
|
+
if (a === b) return true;
|
|
342
|
+
if (typeof a !== typeof b) return false;
|
|
343
|
+
if (a === null || b === null) return a === b;
|
|
344
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
345
|
+
if (a.length !== b.length) return false;
|
|
346
|
+
return a.every((item, index) => isDeepEqual(item, b[index]));
|
|
347
|
+
}
|
|
348
|
+
if (typeof a === 'object' && typeof b === 'object') {
|
|
349
|
+
const aObj = a as Record<string, unknown>;
|
|
350
|
+
const bObj = b as Record<string, unknown>;
|
|
351
|
+
const aKeys = Object.keys(aObj);
|
|
352
|
+
const bKeys = Object.keys(bObj);
|
|
353
|
+
if (aKeys.length !== bKeys.length) return false;
|
|
354
|
+
return aKeys.every(key => isDeepEqual(aObj[key], bObj[key]));
|
|
355
|
+
}
|
|
356
|
+
return false;
|
|
357
357
|
}
|