@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,548 +1,548 @@
|
|
|
1
|
-
import path from 'node:path';
|
|
2
|
-
import Constants from '@/constants';
|
|
3
|
-
import { readConfigFile, writeConfigFile, ConfigStore, MigrationStep } from '@/config/manager';
|
|
4
|
-
|
|
5
|
-
// ---------------------------------------------------------------------------
|
|
6
|
-
// 功能模块独立配置文件注册表
|
|
7
|
-
//
|
|
8
|
-
// 历史背景:所有功能的群聊/操作人白名单原先挤在 bot-config.json 的
|
|
9
|
-
// `function*` 字段里。现已拆分为各功能自己的配置文件(configs/features/*.json),
|
|
10
|
-
// 每个文件带 version 字段,未来的结构变更通过 versions/migrations 升级。
|
|
11
|
-
//
|
|
12
|
-
// 本模块职责:
|
|
13
|
-
// 1. 声明每个功能的独立配置文件路径、默认值、版本与迁移步骤
|
|
14
|
-
// 2. 提供 getFeatureConfig / getFeatureGroups 访问生效配置(ConfigStore 热重载)
|
|
15
|
-
// 3. 提供 migrateLegacyFunctionFields() 把 bot-config.json 里的旧 function*
|
|
16
|
-
// 字段一次性拆进各功能配置文件,并从 bot-config.json 移除
|
|
17
|
-
// ---------------------------------------------------------------------------
|
|
18
|
-
|
|
19
|
-
export interface FeatureConfig {
|
|
20
|
-
version: number;
|
|
21
|
-
/** 生效群聊 */
|
|
22
|
-
groups: number[];
|
|
23
|
-
/** 操作人白名单(如管理功能的管理员 QQ) */
|
|
24
|
-
operators?: number[];
|
|
25
|
-
/**
|
|
26
|
-
* Discord 管理员白名单(Discord 用户 ID,可带 `dc:` 前缀)。
|
|
27
|
-
*
|
|
28
|
-
* 用途:桥接频道里执行管理员命令时,除「服务器拥有者/管理员权限」外,
|
|
29
|
-
* 额外允许这些用户。Discord 用户 ID 为 snowflake 字符串,不能塞进 operators(number[])。
|
|
30
|
-
*
|
|
31
|
-
* 示例(configs/features/management.json):
|
|
32
|
-
* ```jsonc
|
|
33
|
-
* {
|
|
34
|
-
* "version": 1,
|
|
35
|
-
* "groups": [703453948],
|
|
36
|
-
* "operators": [2308465862], // QQ 管理员
|
|
37
|
-
* "discordOperators": ["123456789012345678"] // Discord 管理员(可写 dc: 前缀)
|
|
38
|
-
* }
|
|
39
|
-
* ```
|
|
40
|
-
*/
|
|
41
|
-
discordOperators?: string[];
|
|
42
|
-
/** 其他自定义字段(如 Bili 关注列表) */
|
|
43
|
-
extra?: Record<string, unknown>;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export interface FeatureDefinition {
|
|
47
|
-
/** 功能 id,对应 configs/features/<id>.json */
|
|
48
|
-
id: string;
|
|
49
|
-
/** 默认群聊 */
|
|
50
|
-
defaultGroups: number[];
|
|
51
|
-
/** 默认操作人白名单 */
|
|
52
|
-
defaultOperators?: number[];
|
|
53
|
-
/** bot-config.json 中的旧字段名(迁移用) */
|
|
54
|
-
legacyGroupsFields: string[];
|
|
55
|
-
/** bot-config.json 中旧操作人字段名(迁移用) */
|
|
56
|
-
legacyOperatorsField?: string;
|
|
57
|
-
/** 其他旧字段名 → 迁移到 extra 的键 */
|
|
58
|
-
|
|
59
|
-
legacyExtraFields?: Record<string, string>;
|
|
60
|
-
/** 当前配置版本 */
|
|
61
|
-
version?: number;
|
|
62
|
-
/** 迁移步骤 */
|
|
63
|
-
migrations?: import('@/config/manager').MigrationStep[];
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* GitHub 功能配置(v2)。
|
|
68
|
-
*
|
|
69
|
-
* groups 从 v1 的 `number[]`(全仓库白名单群)升级为
|
|
70
|
-
* `Record<仓库 full_name, 订阅该仓库的群号[]>`——webhook 事件只推送到
|
|
71
|
-
* 对应仓库订阅的群,实现"仓库级订阅"。
|
|
72
|
-
*/
|
|
73
|
-
export interface GithubFeatureConfig {
|
|
74
|
-
version: number;
|
|
75
|
-
/** 仓库 full_name(如 Anvil-Dev/AnvilCraft)→ 订阅群号列表 */
|
|
76
|
-
groups: Record<string, number[]>;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* 新人欢迎配置(v1)。
|
|
81
|
-
*
|
|
82
|
-
* configs/features/welcome.json:
|
|
83
|
-
* ```json
|
|
84
|
-
* {
|
|
85
|
-
* "version": 1,
|
|
86
|
-
* "welcomes": [
|
|
87
|
-
* {
|
|
88
|
-
* "group": [123456, 234567],
|
|
89
|
-
* "msg": ["欢迎 ${at} 加入群聊", "请查看群公告"]
|
|
90
|
-
* }
|
|
91
|
-
* ]
|
|
92
|
-
* }
|
|
93
|
-
* ```
|
|
94
|
-
* `msg` 可以是字符串或字符串数组(数组每项一行,发送时按换行拼接)。
|
|
95
|
-
*/
|
|
96
|
-
export interface WelcomeFeatureConfig {
|
|
97
|
-
version: number;
|
|
98
|
-
welcomes: WelcomeEntry[];
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/** 单条欢迎配置:适用的群列表 + 欢迎语模板(${at} 替换为 @新成员;支持多行数组) */
|
|
102
|
-
export interface WelcomeEntry {
|
|
103
|
-
group: number[];
|
|
104
|
-
msg: string | string[];
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
export interface LegacyFunctionValues {
|
|
108
|
-
featureId: string;
|
|
109
|
-
groups?: number[];
|
|
110
|
-
operators?: number[];
|
|
111
|
-
extra?: Record<string, unknown>;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
const FEATURES_DIR = path.resolve(process.cwd(), 'configs', 'features');
|
|
115
|
-
|
|
116
|
-
// ---------------------------------------------------------------------------
|
|
117
|
-
// 功能定义表
|
|
118
|
-
// ---------------------------------------------------------------------------
|
|
119
|
-
|
|
120
|
-
export const FEATURE_DEFINITIONS: FeatureDefinition[] = [
|
|
121
|
-
{
|
|
122
|
-
id: 'github',
|
|
123
|
-
defaultGroups: [],
|
|
124
|
-
legacyGroupsFields: ['functionGithubGroup']
|
|
125
|
-
},
|
|
126
|
-
{
|
|
127
|
-
id: 'management',
|
|
128
|
-
defaultGroups: [],
|
|
129
|
-
defaultOperators: [],
|
|
130
|
-
legacyGroupsFields: ['functionManagementGroup'],
|
|
131
|
-
legacyOperatorsField: 'functionManagementOperator'
|
|
132
|
-
},
|
|
133
|
-
{
|
|
134
|
-
id: 'parentheses',
|
|
135
|
-
defaultGroups: [],
|
|
136
|
-
legacyGroupsFields: ['functionParenthesesGroup']
|
|
137
|
-
},
|
|
138
|
-
{
|
|
139
|
-
id: 'poke',
|
|
140
|
-
defaultGroups: [],
|
|
141
|
-
legacyGroupsFields: ['functionPokeGroup']
|
|
142
|
-
},
|
|
143
|
-
{
|
|
144
|
-
id: 'minecraft',
|
|
145
|
-
defaultGroups: [],
|
|
146
|
-
legacyGroupsFields: ['functionMinecraftGroup']
|
|
147
|
-
},
|
|
148
|
-
{
|
|
149
|
-
id: 'modrinth',
|
|
150
|
-
defaultGroups: [],
|
|
151
|
-
legacyGroupsFields: ['functionModrinthGroup']
|
|
152
|
-
},
|
|
153
|
-
{
|
|
154
|
-
id: 'bili',
|
|
155
|
-
defaultGroups: [],
|
|
156
|
-
legacyGroupsFields: ['functionBiliGroup'],
|
|
157
|
-
legacyExtraFields: { functionBiliFollow: 'follow' }
|
|
158
|
-
},
|
|
159
|
-
{
|
|
160
|
-
id: 'command',
|
|
161
|
-
defaultGroups: [],
|
|
162
|
-
legacyGroupsFields: ['functionCommandGroup']
|
|
163
|
-
}
|
|
164
|
-
];
|
|
165
|
-
|
|
166
|
-
// ---------------------------------------------------------------------------
|
|
167
|
-
// 配置仓库(ConfigStore:独立文件 + 热重载)
|
|
168
|
-
// ---------------------------------------------------------------------------
|
|
169
|
-
|
|
170
|
-
const stores = new Map<string, ConfigStore<FeatureConfig>>();
|
|
171
|
-
|
|
172
|
-
function isNumberArray(value: unknown): value is number[] {
|
|
173
|
-
return Array.isArray(value) && value.every(item => typeof item === 'number' && Number.isSafeInteger(item));
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
function isGithubGroups(
|
|
177
|
-
value: unknown
|
|
178
|
-
): value is Record<string, number[]> {
|
|
179
|
-
return (
|
|
180
|
-
!!value &&
|
|
181
|
-
typeof value === 'object' &&
|
|
182
|
-
!Array.isArray(value) &&
|
|
183
|
-
Object.entries(value as Record<string, unknown>).every(
|
|
184
|
-
([repo, groups]) =>
|
|
185
|
-
typeof repo === 'string' && repo.includes('/') && isNumberArray(groups)
|
|
186
|
-
)
|
|
187
|
-
);
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/** github.json 的默认工厂:v2 空订阅(无仓库订阅) */
|
|
191
|
-
function githubFactory(): GithubFeatureConfig {
|
|
192
|
-
return { version: 2, groups: {} };
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/** github.json 的 v1 → v2 迁移:旧 groups 数组(全仓库白名单群)拆给默认仓库 */
|
|
196
|
-
function migrateGithubV1ToV2(data: Record<string, unknown>): Record<string, unknown> {
|
|
197
|
-
const legacyGroups = isNumberArray(data.groups) ? data.groups : [];
|
|
198
|
-
return {
|
|
199
|
-
groups: legacyGroups.length > 0 ? { 'Anvil-Dev/AnvilCraft': legacyGroups } : {}
|
|
200
|
-
};
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/** github.json 规范化:接受 v2 Record;非法/损坏返回 null 触发备份重建 */
|
|
204
|
-
function normalizeGithubConfig(raw: unknown): GithubFeatureConfig | null {
|
|
205
|
-
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
206
|
-
const record = raw as Record<string, unknown>;
|
|
207
|
-
if (isGithubGroups(record.groups)) {
|
|
208
|
-
return { version: 2, groups: record.groups };
|
|
209
|
-
}
|
|
210
|
-
// v1 遗留(groups 为数组):转为 v2 结构(不落盘,读取时动态转换)
|
|
211
|
-
if (isNumberArray(record.groups)) {
|
|
212
|
-
const groups: Record<string, number[]> =
|
|
213
|
-
record.groups.length > 0 ? { 'Anvil-Dev/AnvilCraft': record.groups } : {};
|
|
214
|
-
return { version: 2, groups };
|
|
215
|
-
}
|
|
216
|
-
return { version: 2, groups: {} };
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
function normalizeFeatureConfig(def: FeatureDefinition, raw: unknown): FeatureConfig | null {
|
|
220
|
-
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
221
|
-
const record = raw as Record<string, unknown>;
|
|
222
|
-
const groups = record.groups !== undefined && isNumberArray(record.groups) ? record.groups : def.defaultGroups;
|
|
223
|
-
const operators =
|
|
224
|
-
record.operators !== undefined
|
|
225
|
-
? isNumberArray(record.operators)
|
|
226
|
-
? record.operators
|
|
227
|
-
: def.defaultOperators
|
|
228
|
-
: def.defaultOperators;
|
|
229
|
-
const extra =
|
|
230
|
-
record.extra !== undefined && record.extra && typeof record.extra === 'object' && !Array.isArray(record.extra)
|
|
231
|
-
? (record.extra as Record<string, unknown>)
|
|
232
|
-
: undefined;
|
|
233
|
-
// Discord 用户 ID:snowflake 字符串,允许带 dc: 前缀;非法项剔除
|
|
234
|
-
const discordOperators = Array.isArray(record.discordOperators)
|
|
235
|
-
? record.discordOperators
|
|
236
|
-
.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
|
|
237
|
-
.map(item => normalizeDiscordUserId(item))
|
|
238
|
-
: undefined;
|
|
239
|
-
const config: FeatureConfig = {
|
|
240
|
-
version: def.version ?? 1,
|
|
241
|
-
groups,
|
|
242
|
-
...(operators ? { operators } : {}),
|
|
243
|
-
...(discordOperators ? { discordOperators } : {}),
|
|
244
|
-
...(extra ? { extra } : {})
|
|
245
|
-
};
|
|
246
|
-
return config;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
/** 归一化 Discord 用户 ID:去掉 dc: 前缀与首尾空白,统一存裸 snowflake */
|
|
250
|
-
export function normalizeDiscordUserId(raw: string): string {
|
|
251
|
-
return raw.trim().replace(/^dc:/i, '');
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
function createStore(def: FeatureDefinition): ConfigStore<FeatureConfig> {
|
|
255
|
-
const filePath = path.join('configs', 'features', `${def.id}.json`);
|
|
256
|
-
return new ConfigStore<FeatureConfig>({
|
|
257
|
-
path: filePath,
|
|
258
|
-
factory: () => ({
|
|
259
|
-
version: def.version ?? 1,
|
|
260
|
-
groups: def.defaultGroups,
|
|
261
|
-
...(def.defaultOperators ? { operators: def.defaultOperators } : {}),
|
|
262
|
-
...(def.legacyExtraFields ? { extra: {} } : {})
|
|
263
|
-
}),
|
|
264
|
-
version: def.version ?? 1,
|
|
265
|
-
...(def.migrations ? { migrations: def.migrations } : {}),
|
|
266
|
-
normalize: raw => normalizeFeatureConfig(def, raw)
|
|
267
|
-
});
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
function getStore(def: FeatureDefinition): ConfigStore<FeatureConfig> {
|
|
271
|
-
let store = stores.get(def.id);
|
|
272
|
-
if (!store) {
|
|
273
|
-
store = createStore(def);
|
|
274
|
-
stores.set(def.id, store);
|
|
275
|
-
}
|
|
276
|
-
return store;
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
/** 读取功能的生效配置(自动创建默认文件 + 热重载) */
|
|
280
|
-
export function getFeatureConfig(defId: string): FeatureConfig {
|
|
281
|
-
const def = FEATURE_DEFINITIONS.find(item => item.id === defId);
|
|
282
|
-
if (!def) throw new Error(`未注册的功能配置: ${defId}`);
|
|
283
|
-
return getStore(def).get();
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
/** 读取功能的生效群聊白名单 */
|
|
287
|
-
export function getFeatureGroups(defId: string): number[] {
|
|
288
|
-
return getFeatureConfig(defId).groups;
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
/** 订阅功能配置文件变化(外部改文件自动热重载) */
|
|
292
|
-
export function watchFeatureConfig(defId: string, listener: (config: FeatureConfig, source: import('@/config/manager').ConfigChangeSource) => void): () => void {
|
|
293
|
-
const def = FEATURE_DEFINITIONS.find(item => item.id === defId);
|
|
294
|
-
if (!def) throw new Error(`未注册的功能配置: ${defId}`);
|
|
295
|
-
return getStore(def).onChange(listener);
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
// ---------------------------------------------------------------------------
|
|
299
|
-
// GitHub 配置(v2:仓库级订阅)
|
|
300
|
-
// 与通用 FeatureConfig 不同:groups 是 仓库 full_name → 订阅群号[]。
|
|
301
|
-
// 独立 store 管理(configs/features/github.json,v1 → v2 自动迁移)。
|
|
302
|
-
// ---------------------------------------------------------------------------
|
|
303
|
-
|
|
304
|
-
const githubStore = new ConfigStore<GithubFeatureConfig>({
|
|
305
|
-
path: 'configs/features/github.json',
|
|
306
|
-
version: 2,
|
|
307
|
-
migrations: [{ from: 1, to: 2, migrate: migrateGithubV1ToV2 }],
|
|
308
|
-
factory: githubFactory,
|
|
309
|
-
normalize: normalizeGithubConfig
|
|
310
|
-
});
|
|
311
|
-
|
|
312
|
-
/** 读取 github 生效配置(v2:仓库 → 订阅群映射) */
|
|
313
|
-
export function getGithubConfig(): GithubFeatureConfig {
|
|
314
|
-
return githubStore.get();
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
/** 读取某仓库的订阅群列表(无订阅返回空数组) */
|
|
318
|
-
export function getGithubSubscribers(repository: string): number[] {
|
|
319
|
-
return getGithubConfig().groups[repository] || [];
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
/** 判断某群是否订阅了任意仓库(用于 processMessage 的启用判断) */
|
|
323
|
-
export function isGithubEnabledGroup(groupId: number): boolean {
|
|
324
|
-
return Object.values(getGithubConfig().groups).some(list => list.includes(groupId));
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
/**
|
|
328
|
-
* 订阅:把群加入仓库的订阅列表(幂等;写入后热生效)。
|
|
329
|
-
* @returns 该仓库订阅后的完整群列表
|
|
330
|
-
*/
|
|
331
|
-
export function subscribeGithubRepository(repository: string, groupId: number): number[] {
|
|
332
|
-
const current = getGithubConfig();
|
|
333
|
-
const subscribers = Array.from(new Set([...(current.groups[repository] || []), groupId]));
|
|
334
|
-
const next: GithubFeatureConfig = {
|
|
335
|
-
version: 2,
|
|
336
|
-
groups: { ...current.groups, [repository]: subscribers }
|
|
337
|
-
};
|
|
338
|
-
githubStore.update(next);
|
|
339
|
-
return subscribers;
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
/**
|
|
343
|
-
* 取消订阅:把群从仓库的订阅列表移除。
|
|
344
|
-
* @returns 该仓库订阅后的完整群列表(群号不存在则不变)
|
|
345
|
-
*/
|
|
346
|
-
export function unsubscribeGithubRepository(repository: string, groupId: number): number[] {
|
|
347
|
-
const current = getGithubConfig();
|
|
348
|
-
const subscribers = (current.groups[repository] || []).filter(id => id !== groupId);
|
|
349
|
-
const nextGroups: Record<string, number[]> = { ...current.groups };
|
|
350
|
-
if (subscribers.length > 0) nextGroups[repository] = subscribers;
|
|
351
|
-
else delete nextGroups[repository];
|
|
352
|
-
const next: GithubFeatureConfig = { version: 2, groups: nextGroups };
|
|
353
|
-
githubStore.update(next);
|
|
354
|
-
return subscribers;
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
/** 仓库名格式校验(owner/name,均允许字母数字 . _ -) */
|
|
358
|
-
export function isValidRepositoryName(repository: string): boolean {
|
|
359
|
-
return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository);
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
/** GitHub owner 格式校验(字母数字 . _ -,不含斜杠) */
|
|
363
|
-
export function isValidRepositoryOwner(owner: string): boolean {
|
|
364
|
-
return /^[A-Za-z0-9_.-]+$/.test(owner);
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
/**
|
|
368
|
-
* 判断某 QQ 用户是否为管理员(操作人白名单)。
|
|
369
|
-
*
|
|
370
|
-
* 白名单复用 management 功能的 operators 配置,使「谁是管理员」在全项目只有一个定义,
|
|
371
|
-
* 与 /pardon 等管理命令保持一致。
|
|
372
|
-
*
|
|
373
|
-
* @param qqUserId 发送者 QQ 号
|
|
374
|
-
*/
|
|
375
|
-
export function isOperator(qqUserId: number): boolean {
|
|
376
|
-
return getFeatureConfig('management').operators?.includes(qqUserId) ?? false;
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
/**
|
|
380
|
-
* 判断某 Discord 用户是否为管理员(配置白名单)。
|
|
381
|
-
*
|
|
382
|
-
* 与「服务器拥有者/管理员权限」互补:Discord 侧命令源用 isDiscordAdmin() 判定服务器权限,
|
|
383
|
-
* 这里额外允许 management.json 的 `discordOperators` 名单成员(便于不加服务器权限也能管理)。
|
|
384
|
-
*
|
|
385
|
-
* @param discordUserId Discord 用户 ID(裸 snowflake 或带 `dc:` 前缀)
|
|
386
|
-
*/
|
|
387
|
-
export function isDiscordOperator(discordUserId: string): boolean {
|
|
388
|
-
const list = getFeatureConfig('management').discordOperators;
|
|
389
|
-
if (!list || list.length === 0) return false;
|
|
390
|
-
const target = normalizeDiscordUserId(discordUserId);
|
|
391
|
-
return list.some(item => normalizeDiscordUserId(item) === target);
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
// ---------------------------------------------------------------------------
|
|
395
|
-
// 新人欢迎配置(v1:welcomes 数组)
|
|
396
|
-
// ---------------------------------------------------------------------------
|
|
397
|
-
|
|
398
|
-
function welcomeFactory(): WelcomeFeatureConfig {
|
|
399
|
-
return { version: 1, welcomes: [] };
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
function normalizeWelcomeConfig(raw: unknown): WelcomeFeatureConfig | null {
|
|
403
|
-
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
404
|
-
const record = raw as Record<string, unknown>;
|
|
405
|
-
const welcomes = Array.isArray(record.welcomes) ? record.welcomes : [];
|
|
406
|
-
const normalized: WelcomeEntry[] = [];
|
|
407
|
-
for (const item of welcomes) {
|
|
408
|
-
if (!item || typeof item !== 'object' || Array.isArray(item)) continue;
|
|
409
|
-
const entry = item as Record<string, unknown>;
|
|
410
|
-
if (!Array.isArray(entry.group)) continue;
|
|
411
|
-
const msg = normalizeWelcomeMsg(entry.msg);
|
|
412
|
-
if (msg === null) continue;
|
|
413
|
-
const group = entry.group.filter(g => typeof g === 'number' && Number.isSafeInteger(g));
|
|
414
|
-
normalized.push({ group, msg });
|
|
415
|
-
}
|
|
416
|
-
return { version: 1, welcomes: normalized };
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
/** 规范化 msg 字段:字符串或字符串数组(数组元素须全为字符串),否则返回 null */
|
|
420
|
-
function normalizeWelcomeMsg(raw: unknown): string | string[] | null {
|
|
421
|
-
if (typeof raw === 'string') return raw;
|
|
422
|
-
if (Array.isArray(raw)) {
|
|
423
|
-
if (raw.every(item => typeof item === 'string')) return raw;
|
|
424
|
-
return null;
|
|
425
|
-
}
|
|
426
|
-
return null;
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
const welcomeStore = new ConfigStore<WelcomeFeatureConfig>({
|
|
430
|
-
path: 'configs/features/welcome.json',
|
|
431
|
-
version: 1,
|
|
432
|
-
factory: welcomeFactory,
|
|
433
|
-
normalize: normalizeWelcomeConfig
|
|
434
|
-
});
|
|
435
|
-
|
|
436
|
-
/** 读取新人欢迎生效配置(v1:welcomes 数组) */
|
|
437
|
-
export function getWelcomeConfig(): WelcomeFeatureConfig {
|
|
438
|
-
return welcomeStore.get();
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
// ---------------------------------------------------------------------------
|
|
442
|
-
// 旧字段迁移:bot-config.json 的 function* → 各功能配置文件
|
|
443
|
-
// ---------------------------------------------------------------------------
|
|
444
|
-
|
|
445
|
-
const BOT_CONFIG_PATH = path.resolve(process.cwd(), 'configs', 'bot-config.json');
|
|
446
|
-
|
|
447
|
-
function readLegacyValues(def: FeatureDefinition, legacyRaw: Record<string, unknown>): LegacyFunctionValues | undefined {
|
|
448
|
-
const groupsValue = def.legacyGroupsFields
|
|
449
|
-
.map(field => legacyRaw[field])
|
|
450
|
-
.find(value => value !== undefined);
|
|
451
|
-
const groups = groupsValue !== undefined && isNumberArray(groupsValue) ? groupsValue : undefined;
|
|
452
|
-
|
|
453
|
-
let operators: number[] | undefined = undefined;
|
|
454
|
-
if (def.legacyOperatorsField) {
|
|
455
|
-
const operatorValue = legacyRaw[def.legacyOperatorsField];
|
|
456
|
-
if (operatorValue !== undefined && isNumberArray(operatorValue)) operators = operatorValue;
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
let extra: Record<string, unknown> | undefined = undefined;
|
|
460
|
-
if (def.legacyExtraFields) {
|
|
461
|
-
const extras: Record<string, unknown> = {};
|
|
462
|
-
let extrasAny = false;
|
|
463
|
-
for (const [legacyField, extraKey] of Object.entries(def.legacyExtraFields)) {
|
|
464
|
-
const value = legacyRaw[legacyField];
|
|
465
|
-
if (value !== undefined) {
|
|
466
|
-
extras[extraKey] = value;
|
|
467
|
-
extrasAny = true;
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
if (extrasAny) extra = extras;
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
if (groups === undefined && operators === undefined && extra === undefined) return undefined;
|
|
474
|
-
return {
|
|
475
|
-
featureId: def.id,
|
|
476
|
-
...(groups !== undefined ? { groups } : {}),
|
|
477
|
-
...(operators !== undefined ? { operators } : {}),
|
|
478
|
-
...(extra !== undefined ? { extra } : {})
|
|
479
|
-
};
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
function applyLegacyToStore(def: FeatureDefinition, legacy: LegacyFunctionValues): void {
|
|
483
|
-
// github 使用专用 store(v2 仓库级订阅),旧 groups 数组拆给默认仓库
|
|
484
|
-
if (def.id === 'github') {
|
|
485
|
-
const legacyGroups = legacy.groups || [];
|
|
486
|
-
const current = githubStore.get();
|
|
487
|
-
const nextGroups: Record<string, number[]> = { ...current.groups };
|
|
488
|
-
if (legacyGroups.length > 0 && Object.keys(nextGroups).length === 0) {
|
|
489
|
-
nextGroups['Anvil-Dev/AnvilCraft'] = legacyGroups;
|
|
490
|
-
}
|
|
491
|
-
githubStore.update({ version: 2, groups: nextGroups });
|
|
492
|
-
return;
|
|
493
|
-
}
|
|
494
|
-
const store = getStore(def);
|
|
495
|
-
const current = store.get();
|
|
496
|
-
const next: FeatureConfig = {
|
|
497
|
-
version: def.version ?? 1,
|
|
498
|
-
groups: legacy.groups ?? current.groups,
|
|
499
|
-
...(legacy.operators || current.operators ? { operators: legacy.operators ?? current.operators } : {}),
|
|
500
|
-
...(legacy.extra || current.extra ? { extra: legacy.extra ?? current.extra } : {})
|
|
501
|
-
};
|
|
502
|
-
store.update(next);
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
/**
|
|
506
|
-
* 把 bot-config.json 里的旧 `function*` 字段一次性拆进各功能配置文件。
|
|
507
|
-
* 迁移完成后从 bot-config.json 删除这些字段。幂等:无旧字段时直接返回。
|
|
508
|
-
*/
|
|
509
|
-
export function migrateLegacyFunctionFields(): boolean {
|
|
510
|
-
const legacyRaw = readConfigFile<Record<string, unknown>>(BOT_CONFIG_PATH);
|
|
511
|
-
if (!legacyRaw) return false;
|
|
512
|
-
|
|
513
|
-
let anyMigrated = false;
|
|
514
|
-
for (const def of FEATURE_DEFINITIONS) {
|
|
515
|
-
const legacy = readLegacyValues(def, legacyRaw);
|
|
516
|
-
if (legacy) {
|
|
517
|
-
applyLegacyToStore(def, legacy);
|
|
518
|
-
anyMigrated = true;
|
|
519
|
-
}
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
if (!anyMigrated) return false;
|
|
523
|
-
|
|
524
|
-
// 从 bot-config.json 删除已迁移的 function* 字段并写回
|
|
525
|
-
const next = { ...legacyRaw };
|
|
526
|
-
for (const def of FEATURE_DEFINITIONS) {
|
|
527
|
-
for (const field of def.legacyGroupsFields) delete next[field];
|
|
528
|
-
if (def.legacyOperatorsField) delete next[def.legacyOperatorsField];
|
|
529
|
-
if (def.legacyExtraFields) {
|
|
530
|
-
for (const legacyField of Object.keys(def.legacyExtraFields)) delete next[legacyField];
|
|
531
|
-
}
|
|
532
|
-
}
|
|
533
|
-
writeConfigFile(BOT_CONFIG_PATH, next);
|
|
534
|
-
return true;
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
/** 供 ConfigStore 观察者使用的内部导出(测试用) */
|
|
538
|
-
export function __featureStores(): Map<string, ConfigStore<FeatureConfig>> {
|
|
539
|
-
return stores;
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
export type { MigrationStep };
|
|
543
|
-
export { FEATURES_DIR };
|
|
544
|
-
|
|
545
|
-
// 模块加载时立即执行一次旧字段迁移:
|
|
546
|
-
// 保证任何功能模块首次 getFeatureConfig() 之前,bot-config.json 的
|
|
547
|
-
// function* 旧字段已拆入各自的 features/*.json 配置文件。
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import Constants from '@/constants';
|
|
3
|
+
import { readConfigFile, writeConfigFile, ConfigStore, MigrationStep } from '@/config/manager';
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// 功能模块独立配置文件注册表
|
|
7
|
+
//
|
|
8
|
+
// 历史背景:所有功能的群聊/操作人白名单原先挤在 bot-config.json 的
|
|
9
|
+
// `function*` 字段里。现已拆分为各功能自己的配置文件(configs/features/*.json),
|
|
10
|
+
// 每个文件带 version 字段,未来的结构变更通过 versions/migrations 升级。
|
|
11
|
+
//
|
|
12
|
+
// 本模块职责:
|
|
13
|
+
// 1. 声明每个功能的独立配置文件路径、默认值、版本与迁移步骤
|
|
14
|
+
// 2. 提供 getFeatureConfig / getFeatureGroups 访问生效配置(ConfigStore 热重载)
|
|
15
|
+
// 3. 提供 migrateLegacyFunctionFields() 把 bot-config.json 里的旧 function*
|
|
16
|
+
// 字段一次性拆进各功能配置文件,并从 bot-config.json 移除
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
export interface FeatureConfig {
|
|
20
|
+
version: number;
|
|
21
|
+
/** 生效群聊 */
|
|
22
|
+
groups: number[];
|
|
23
|
+
/** 操作人白名单(如管理功能的管理员 QQ) */
|
|
24
|
+
operators?: number[];
|
|
25
|
+
/**
|
|
26
|
+
* Discord 管理员白名单(Discord 用户 ID,可带 `dc:` 前缀)。
|
|
27
|
+
*
|
|
28
|
+
* 用途:桥接频道里执行管理员命令时,除「服务器拥有者/管理员权限」外,
|
|
29
|
+
* 额外允许这些用户。Discord 用户 ID 为 snowflake 字符串,不能塞进 operators(number[])。
|
|
30
|
+
*
|
|
31
|
+
* 示例(configs/features/management.json):
|
|
32
|
+
* ```jsonc
|
|
33
|
+
* {
|
|
34
|
+
* "version": 1,
|
|
35
|
+
* "groups": [703453948],
|
|
36
|
+
* "operators": [2308465862], // QQ 管理员
|
|
37
|
+
* "discordOperators": ["123456789012345678"] // Discord 管理员(可写 dc: 前缀)
|
|
38
|
+
* }
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
discordOperators?: string[];
|
|
42
|
+
/** 其他自定义字段(如 Bili 关注列表) */
|
|
43
|
+
extra?: Record<string, unknown>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface FeatureDefinition {
|
|
47
|
+
/** 功能 id,对应 configs/features/<id>.json */
|
|
48
|
+
id: string;
|
|
49
|
+
/** 默认群聊 */
|
|
50
|
+
defaultGroups: number[];
|
|
51
|
+
/** 默认操作人白名单 */
|
|
52
|
+
defaultOperators?: number[];
|
|
53
|
+
/** bot-config.json 中的旧字段名(迁移用) */
|
|
54
|
+
legacyGroupsFields: string[];
|
|
55
|
+
/** bot-config.json 中旧操作人字段名(迁移用) */
|
|
56
|
+
legacyOperatorsField?: string;
|
|
57
|
+
/** 其他旧字段名 → 迁移到 extra 的键 */
|
|
58
|
+
|
|
59
|
+
legacyExtraFields?: Record<string, string>;
|
|
60
|
+
/** 当前配置版本 */
|
|
61
|
+
version?: number;
|
|
62
|
+
/** 迁移步骤 */
|
|
63
|
+
migrations?: import('@/config/manager').MigrationStep[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* GitHub 功能配置(v2)。
|
|
68
|
+
*
|
|
69
|
+
* groups 从 v1 的 `number[]`(全仓库白名单群)升级为
|
|
70
|
+
* `Record<仓库 full_name, 订阅该仓库的群号[]>`——webhook 事件只推送到
|
|
71
|
+
* 对应仓库订阅的群,实现"仓库级订阅"。
|
|
72
|
+
*/
|
|
73
|
+
export interface GithubFeatureConfig {
|
|
74
|
+
version: number;
|
|
75
|
+
/** 仓库 full_name(如 Anvil-Dev/AnvilCraft)→ 订阅群号列表 */
|
|
76
|
+
groups: Record<string, number[]>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 新人欢迎配置(v1)。
|
|
81
|
+
*
|
|
82
|
+
* configs/features/welcome.json:
|
|
83
|
+
* ```json
|
|
84
|
+
* {
|
|
85
|
+
* "version": 1,
|
|
86
|
+
* "welcomes": [
|
|
87
|
+
* {
|
|
88
|
+
* "group": [123456, 234567],
|
|
89
|
+
* "msg": ["欢迎 ${at} 加入群聊", "请查看群公告"]
|
|
90
|
+
* }
|
|
91
|
+
* ]
|
|
92
|
+
* }
|
|
93
|
+
* ```
|
|
94
|
+
* `msg` 可以是字符串或字符串数组(数组每项一行,发送时按换行拼接)。
|
|
95
|
+
*/
|
|
96
|
+
export interface WelcomeFeatureConfig {
|
|
97
|
+
version: number;
|
|
98
|
+
welcomes: WelcomeEntry[];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 单条欢迎配置:适用的群列表 + 欢迎语模板(${at} 替换为 @新成员;支持多行数组) */
|
|
102
|
+
export interface WelcomeEntry {
|
|
103
|
+
group: number[];
|
|
104
|
+
msg: string | string[];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface LegacyFunctionValues {
|
|
108
|
+
featureId: string;
|
|
109
|
+
groups?: number[];
|
|
110
|
+
operators?: number[];
|
|
111
|
+
extra?: Record<string, unknown>;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const FEATURES_DIR = path.resolve(process.cwd(), 'configs', 'features');
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// 功能定义表
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
export const FEATURE_DEFINITIONS: FeatureDefinition[] = [
|
|
121
|
+
{
|
|
122
|
+
id: 'github',
|
|
123
|
+
defaultGroups: [],
|
|
124
|
+
legacyGroupsFields: ['functionGithubGroup']
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
id: 'management',
|
|
128
|
+
defaultGroups: [],
|
|
129
|
+
defaultOperators: [],
|
|
130
|
+
legacyGroupsFields: ['functionManagementGroup'],
|
|
131
|
+
legacyOperatorsField: 'functionManagementOperator'
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
id: 'parentheses',
|
|
135
|
+
defaultGroups: [],
|
|
136
|
+
legacyGroupsFields: ['functionParenthesesGroup']
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
id: 'poke',
|
|
140
|
+
defaultGroups: [],
|
|
141
|
+
legacyGroupsFields: ['functionPokeGroup']
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
id: 'minecraft',
|
|
145
|
+
defaultGroups: [],
|
|
146
|
+
legacyGroupsFields: ['functionMinecraftGroup']
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
id: 'modrinth',
|
|
150
|
+
defaultGroups: [],
|
|
151
|
+
legacyGroupsFields: ['functionModrinthGroup']
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
id: 'bili',
|
|
155
|
+
defaultGroups: [],
|
|
156
|
+
legacyGroupsFields: ['functionBiliGroup'],
|
|
157
|
+
legacyExtraFields: { functionBiliFollow: 'follow' }
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
id: 'command',
|
|
161
|
+
defaultGroups: [],
|
|
162
|
+
legacyGroupsFields: ['functionCommandGroup']
|
|
163
|
+
}
|
|
164
|
+
];
|
|
165
|
+
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
// 配置仓库(ConfigStore:独立文件 + 热重载)
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
const stores = new Map<string, ConfigStore<FeatureConfig>>();
|
|
171
|
+
|
|
172
|
+
function isNumberArray(value: unknown): value is number[] {
|
|
173
|
+
return Array.isArray(value) && value.every(item => typeof item === 'number' && Number.isSafeInteger(item));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function isGithubGroups(
|
|
177
|
+
value: unknown
|
|
178
|
+
): value is Record<string, number[]> {
|
|
179
|
+
return (
|
|
180
|
+
!!value &&
|
|
181
|
+
typeof value === 'object' &&
|
|
182
|
+
!Array.isArray(value) &&
|
|
183
|
+
Object.entries(value as Record<string, unknown>).every(
|
|
184
|
+
([repo, groups]) =>
|
|
185
|
+
typeof repo === 'string' && repo.includes('/') && isNumberArray(groups)
|
|
186
|
+
)
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** github.json 的默认工厂:v2 空订阅(无仓库订阅) */
|
|
191
|
+
function githubFactory(): GithubFeatureConfig {
|
|
192
|
+
return { version: 2, groups: {} };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** github.json 的 v1 → v2 迁移:旧 groups 数组(全仓库白名单群)拆给默认仓库 */
|
|
196
|
+
function migrateGithubV1ToV2(data: Record<string, unknown>): Record<string, unknown> {
|
|
197
|
+
const legacyGroups = isNumberArray(data.groups) ? data.groups : [];
|
|
198
|
+
return {
|
|
199
|
+
groups: legacyGroups.length > 0 ? { 'Anvil-Dev/AnvilCraft': legacyGroups } : {}
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** github.json 规范化:接受 v2 Record;非法/损坏返回 null 触发备份重建 */
|
|
204
|
+
function normalizeGithubConfig(raw: unknown): GithubFeatureConfig | null {
|
|
205
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
206
|
+
const record = raw as Record<string, unknown>;
|
|
207
|
+
if (isGithubGroups(record.groups)) {
|
|
208
|
+
return { version: 2, groups: record.groups };
|
|
209
|
+
}
|
|
210
|
+
// v1 遗留(groups 为数组):转为 v2 结构(不落盘,读取时动态转换)
|
|
211
|
+
if (isNumberArray(record.groups)) {
|
|
212
|
+
const groups: Record<string, number[]> =
|
|
213
|
+
record.groups.length > 0 ? { 'Anvil-Dev/AnvilCraft': record.groups } : {};
|
|
214
|
+
return { version: 2, groups };
|
|
215
|
+
}
|
|
216
|
+
return { version: 2, groups: {} };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function normalizeFeatureConfig(def: FeatureDefinition, raw: unknown): FeatureConfig | null {
|
|
220
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
221
|
+
const record = raw as Record<string, unknown>;
|
|
222
|
+
const groups = record.groups !== undefined && isNumberArray(record.groups) ? record.groups : def.defaultGroups;
|
|
223
|
+
const operators =
|
|
224
|
+
record.operators !== undefined
|
|
225
|
+
? isNumberArray(record.operators)
|
|
226
|
+
? record.operators
|
|
227
|
+
: def.defaultOperators
|
|
228
|
+
: def.defaultOperators;
|
|
229
|
+
const extra =
|
|
230
|
+
record.extra !== undefined && record.extra && typeof record.extra === 'object' && !Array.isArray(record.extra)
|
|
231
|
+
? (record.extra as Record<string, unknown>)
|
|
232
|
+
: undefined;
|
|
233
|
+
// Discord 用户 ID:snowflake 字符串,允许带 dc: 前缀;非法项剔除
|
|
234
|
+
const discordOperators = Array.isArray(record.discordOperators)
|
|
235
|
+
? record.discordOperators
|
|
236
|
+
.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
|
|
237
|
+
.map(item => normalizeDiscordUserId(item))
|
|
238
|
+
: undefined;
|
|
239
|
+
const config: FeatureConfig = {
|
|
240
|
+
version: def.version ?? 1,
|
|
241
|
+
groups,
|
|
242
|
+
...(operators ? { operators } : {}),
|
|
243
|
+
...(discordOperators ? { discordOperators } : {}),
|
|
244
|
+
...(extra ? { extra } : {})
|
|
245
|
+
};
|
|
246
|
+
return config;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** 归一化 Discord 用户 ID:去掉 dc: 前缀与首尾空白,统一存裸 snowflake */
|
|
250
|
+
export function normalizeDiscordUserId(raw: string): string {
|
|
251
|
+
return raw.trim().replace(/^dc:/i, '');
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function createStore(def: FeatureDefinition): ConfigStore<FeatureConfig> {
|
|
255
|
+
const filePath = path.join('configs', 'features', `${def.id}.json`);
|
|
256
|
+
return new ConfigStore<FeatureConfig>({
|
|
257
|
+
path: filePath,
|
|
258
|
+
factory: () => ({
|
|
259
|
+
version: def.version ?? 1,
|
|
260
|
+
groups: def.defaultGroups,
|
|
261
|
+
...(def.defaultOperators ? { operators: def.defaultOperators } : {}),
|
|
262
|
+
...(def.legacyExtraFields ? { extra: {} } : {})
|
|
263
|
+
}),
|
|
264
|
+
version: def.version ?? 1,
|
|
265
|
+
...(def.migrations ? { migrations: def.migrations } : {}),
|
|
266
|
+
normalize: raw => normalizeFeatureConfig(def, raw)
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function getStore(def: FeatureDefinition): ConfigStore<FeatureConfig> {
|
|
271
|
+
let store = stores.get(def.id);
|
|
272
|
+
if (!store) {
|
|
273
|
+
store = createStore(def);
|
|
274
|
+
stores.set(def.id, store);
|
|
275
|
+
}
|
|
276
|
+
return store;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** 读取功能的生效配置(自动创建默认文件 + 热重载) */
|
|
280
|
+
export function getFeatureConfig(defId: string): FeatureConfig {
|
|
281
|
+
const def = FEATURE_DEFINITIONS.find(item => item.id === defId);
|
|
282
|
+
if (!def) throw new Error(`未注册的功能配置: ${defId}`);
|
|
283
|
+
return getStore(def).get();
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** 读取功能的生效群聊白名单 */
|
|
287
|
+
export function getFeatureGroups(defId: string): number[] {
|
|
288
|
+
return getFeatureConfig(defId).groups;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** 订阅功能配置文件变化(外部改文件自动热重载) */
|
|
292
|
+
export function watchFeatureConfig(defId: string, listener: (config: FeatureConfig, source: import('@/config/manager').ConfigChangeSource) => void): () => void {
|
|
293
|
+
const def = FEATURE_DEFINITIONS.find(item => item.id === defId);
|
|
294
|
+
if (!def) throw new Error(`未注册的功能配置: ${defId}`);
|
|
295
|
+
return getStore(def).onChange(listener);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
// GitHub 配置(v2:仓库级订阅)
|
|
300
|
+
// 与通用 FeatureConfig 不同:groups 是 仓库 full_name → 订阅群号[]。
|
|
301
|
+
// 独立 store 管理(configs/features/github.json,v1 → v2 自动迁移)。
|
|
302
|
+
// ---------------------------------------------------------------------------
|
|
303
|
+
|
|
304
|
+
const githubStore = new ConfigStore<GithubFeatureConfig>({
|
|
305
|
+
path: 'configs/features/github.json',
|
|
306
|
+
version: 2,
|
|
307
|
+
migrations: [{ from: 1, to: 2, migrate: migrateGithubV1ToV2 }],
|
|
308
|
+
factory: githubFactory,
|
|
309
|
+
normalize: normalizeGithubConfig
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
/** 读取 github 生效配置(v2:仓库 → 订阅群映射) */
|
|
313
|
+
export function getGithubConfig(): GithubFeatureConfig {
|
|
314
|
+
return githubStore.get();
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** 读取某仓库的订阅群列表(无订阅返回空数组) */
|
|
318
|
+
export function getGithubSubscribers(repository: string): number[] {
|
|
319
|
+
return getGithubConfig().groups[repository] || [];
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** 判断某群是否订阅了任意仓库(用于 processMessage 的启用判断) */
|
|
323
|
+
export function isGithubEnabledGroup(groupId: number): boolean {
|
|
324
|
+
return Object.values(getGithubConfig().groups).some(list => list.includes(groupId));
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* 订阅:把群加入仓库的订阅列表(幂等;写入后热生效)。
|
|
329
|
+
* @returns 该仓库订阅后的完整群列表
|
|
330
|
+
*/
|
|
331
|
+
export function subscribeGithubRepository(repository: string, groupId: number): number[] {
|
|
332
|
+
const current = getGithubConfig();
|
|
333
|
+
const subscribers = Array.from(new Set([...(current.groups[repository] || []), groupId]));
|
|
334
|
+
const next: GithubFeatureConfig = {
|
|
335
|
+
version: 2,
|
|
336
|
+
groups: { ...current.groups, [repository]: subscribers }
|
|
337
|
+
};
|
|
338
|
+
githubStore.update(next);
|
|
339
|
+
return subscribers;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* 取消订阅:把群从仓库的订阅列表移除。
|
|
344
|
+
* @returns 该仓库订阅后的完整群列表(群号不存在则不变)
|
|
345
|
+
*/
|
|
346
|
+
export function unsubscribeGithubRepository(repository: string, groupId: number): number[] {
|
|
347
|
+
const current = getGithubConfig();
|
|
348
|
+
const subscribers = (current.groups[repository] || []).filter(id => id !== groupId);
|
|
349
|
+
const nextGroups: Record<string, number[]> = { ...current.groups };
|
|
350
|
+
if (subscribers.length > 0) nextGroups[repository] = subscribers;
|
|
351
|
+
else delete nextGroups[repository];
|
|
352
|
+
const next: GithubFeatureConfig = { version: 2, groups: nextGroups };
|
|
353
|
+
githubStore.update(next);
|
|
354
|
+
return subscribers;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** 仓库名格式校验(owner/name,均允许字母数字 . _ -) */
|
|
358
|
+
export function isValidRepositoryName(repository: string): boolean {
|
|
359
|
+
return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** GitHub owner 格式校验(字母数字 . _ -,不含斜杠) */
|
|
363
|
+
export function isValidRepositoryOwner(owner: string): boolean {
|
|
364
|
+
return /^[A-Za-z0-9_.-]+$/.test(owner);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* 判断某 QQ 用户是否为管理员(操作人白名单)。
|
|
369
|
+
*
|
|
370
|
+
* 白名单复用 management 功能的 operators 配置,使「谁是管理员」在全项目只有一个定义,
|
|
371
|
+
* 与 /pardon 等管理命令保持一致。
|
|
372
|
+
*
|
|
373
|
+
* @param qqUserId 发送者 QQ 号
|
|
374
|
+
*/
|
|
375
|
+
export function isOperator(qqUserId: number): boolean {
|
|
376
|
+
return getFeatureConfig('management').operators?.includes(qqUserId) ?? false;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* 判断某 Discord 用户是否为管理员(配置白名单)。
|
|
381
|
+
*
|
|
382
|
+
* 与「服务器拥有者/管理员权限」互补:Discord 侧命令源用 isDiscordAdmin() 判定服务器权限,
|
|
383
|
+
* 这里额外允许 management.json 的 `discordOperators` 名单成员(便于不加服务器权限也能管理)。
|
|
384
|
+
*
|
|
385
|
+
* @param discordUserId Discord 用户 ID(裸 snowflake 或带 `dc:` 前缀)
|
|
386
|
+
*/
|
|
387
|
+
export function isDiscordOperator(discordUserId: string): boolean {
|
|
388
|
+
const list = getFeatureConfig('management').discordOperators;
|
|
389
|
+
if (!list || list.length === 0) return false;
|
|
390
|
+
const target = normalizeDiscordUserId(discordUserId);
|
|
391
|
+
return list.some(item => normalizeDiscordUserId(item) === target);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// ---------------------------------------------------------------------------
|
|
395
|
+
// 新人欢迎配置(v1:welcomes 数组)
|
|
396
|
+
// ---------------------------------------------------------------------------
|
|
397
|
+
|
|
398
|
+
function welcomeFactory(): WelcomeFeatureConfig {
|
|
399
|
+
return { version: 1, welcomes: [] };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function normalizeWelcomeConfig(raw: unknown): WelcomeFeatureConfig | null {
|
|
403
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
404
|
+
const record = raw as Record<string, unknown>;
|
|
405
|
+
const welcomes = Array.isArray(record.welcomes) ? record.welcomes : [];
|
|
406
|
+
const normalized: WelcomeEntry[] = [];
|
|
407
|
+
for (const item of welcomes) {
|
|
408
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) continue;
|
|
409
|
+
const entry = item as Record<string, unknown>;
|
|
410
|
+
if (!Array.isArray(entry.group)) continue;
|
|
411
|
+
const msg = normalizeWelcomeMsg(entry.msg);
|
|
412
|
+
if (msg === null) continue;
|
|
413
|
+
const group = entry.group.filter(g => typeof g === 'number' && Number.isSafeInteger(g));
|
|
414
|
+
normalized.push({ group, msg });
|
|
415
|
+
}
|
|
416
|
+
return { version: 1, welcomes: normalized };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** 规范化 msg 字段:字符串或字符串数组(数组元素须全为字符串),否则返回 null */
|
|
420
|
+
function normalizeWelcomeMsg(raw: unknown): string | string[] | null {
|
|
421
|
+
if (typeof raw === 'string') return raw;
|
|
422
|
+
if (Array.isArray(raw)) {
|
|
423
|
+
if (raw.every(item => typeof item === 'string')) return raw;
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
return null;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const welcomeStore = new ConfigStore<WelcomeFeatureConfig>({
|
|
430
|
+
path: 'configs/features/welcome.json',
|
|
431
|
+
version: 1,
|
|
432
|
+
factory: welcomeFactory,
|
|
433
|
+
normalize: normalizeWelcomeConfig
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
/** 读取新人欢迎生效配置(v1:welcomes 数组) */
|
|
437
|
+
export function getWelcomeConfig(): WelcomeFeatureConfig {
|
|
438
|
+
return welcomeStore.get();
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// ---------------------------------------------------------------------------
|
|
442
|
+
// 旧字段迁移:bot-config.json 的 function* → 各功能配置文件
|
|
443
|
+
// ---------------------------------------------------------------------------
|
|
444
|
+
|
|
445
|
+
const BOT_CONFIG_PATH = path.resolve(process.cwd(), 'configs', 'bot-config.json');
|
|
446
|
+
|
|
447
|
+
function readLegacyValues(def: FeatureDefinition, legacyRaw: Record<string, unknown>): LegacyFunctionValues | undefined {
|
|
448
|
+
const groupsValue = def.legacyGroupsFields
|
|
449
|
+
.map(field => legacyRaw[field])
|
|
450
|
+
.find(value => value !== undefined);
|
|
451
|
+
const groups = groupsValue !== undefined && isNumberArray(groupsValue) ? groupsValue : undefined;
|
|
452
|
+
|
|
453
|
+
let operators: number[] | undefined = undefined;
|
|
454
|
+
if (def.legacyOperatorsField) {
|
|
455
|
+
const operatorValue = legacyRaw[def.legacyOperatorsField];
|
|
456
|
+
if (operatorValue !== undefined && isNumberArray(operatorValue)) operators = operatorValue;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
let extra: Record<string, unknown> | undefined = undefined;
|
|
460
|
+
if (def.legacyExtraFields) {
|
|
461
|
+
const extras: Record<string, unknown> = {};
|
|
462
|
+
let extrasAny = false;
|
|
463
|
+
for (const [legacyField, extraKey] of Object.entries(def.legacyExtraFields)) {
|
|
464
|
+
const value = legacyRaw[legacyField];
|
|
465
|
+
if (value !== undefined) {
|
|
466
|
+
extras[extraKey] = value;
|
|
467
|
+
extrasAny = true;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
if (extrasAny) extra = extras;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (groups === undefined && operators === undefined && extra === undefined) return undefined;
|
|
474
|
+
return {
|
|
475
|
+
featureId: def.id,
|
|
476
|
+
...(groups !== undefined ? { groups } : {}),
|
|
477
|
+
...(operators !== undefined ? { operators } : {}),
|
|
478
|
+
...(extra !== undefined ? { extra } : {})
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function applyLegacyToStore(def: FeatureDefinition, legacy: LegacyFunctionValues): void {
|
|
483
|
+
// github 使用专用 store(v2 仓库级订阅),旧 groups 数组拆给默认仓库
|
|
484
|
+
if (def.id === 'github') {
|
|
485
|
+
const legacyGroups = legacy.groups || [];
|
|
486
|
+
const current = githubStore.get();
|
|
487
|
+
const nextGroups: Record<string, number[]> = { ...current.groups };
|
|
488
|
+
if (legacyGroups.length > 0 && Object.keys(nextGroups).length === 0) {
|
|
489
|
+
nextGroups['Anvil-Dev/AnvilCraft'] = legacyGroups;
|
|
490
|
+
}
|
|
491
|
+
githubStore.update({ version: 2, groups: nextGroups });
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
const store = getStore(def);
|
|
495
|
+
const current = store.get();
|
|
496
|
+
const next: FeatureConfig = {
|
|
497
|
+
version: def.version ?? 1,
|
|
498
|
+
groups: legacy.groups ?? current.groups,
|
|
499
|
+
...(legacy.operators || current.operators ? { operators: legacy.operators ?? current.operators } : {}),
|
|
500
|
+
...(legacy.extra || current.extra ? { extra: legacy.extra ?? current.extra } : {})
|
|
501
|
+
};
|
|
502
|
+
store.update(next);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* 把 bot-config.json 里的旧 `function*` 字段一次性拆进各功能配置文件。
|
|
507
|
+
* 迁移完成后从 bot-config.json 删除这些字段。幂等:无旧字段时直接返回。
|
|
508
|
+
*/
|
|
509
|
+
export function migrateLegacyFunctionFields(): boolean {
|
|
510
|
+
const legacyRaw = readConfigFile<Record<string, unknown>>(BOT_CONFIG_PATH);
|
|
511
|
+
if (!legacyRaw) return false;
|
|
512
|
+
|
|
513
|
+
let anyMigrated = false;
|
|
514
|
+
for (const def of FEATURE_DEFINITIONS) {
|
|
515
|
+
const legacy = readLegacyValues(def, legacyRaw);
|
|
516
|
+
if (legacy) {
|
|
517
|
+
applyLegacyToStore(def, legacy);
|
|
518
|
+
anyMigrated = true;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (!anyMigrated) return false;
|
|
523
|
+
|
|
524
|
+
// 从 bot-config.json 删除已迁移的 function* 字段并写回
|
|
525
|
+
const next = { ...legacyRaw };
|
|
526
|
+
for (const def of FEATURE_DEFINITIONS) {
|
|
527
|
+
for (const field of def.legacyGroupsFields) delete next[field];
|
|
528
|
+
if (def.legacyOperatorsField) delete next[def.legacyOperatorsField];
|
|
529
|
+
if (def.legacyExtraFields) {
|
|
530
|
+
for (const legacyField of Object.keys(def.legacyExtraFields)) delete next[legacyField];
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
writeConfigFile(BOT_CONFIG_PATH, next);
|
|
534
|
+
return true;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** 供 ConfigStore 观察者使用的内部导出(测试用) */
|
|
538
|
+
export function __featureStores(): Map<string, ConfigStore<FeatureConfig>> {
|
|
539
|
+
return stores;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
export type { MigrationStep };
|
|
543
|
+
export { FEATURES_DIR };
|
|
544
|
+
|
|
545
|
+
// 模块加载时立即执行一次旧字段迁移:
|
|
546
|
+
// 保证任何功能模块首次 getFeatureConfig() 之前,bot-config.json 的
|
|
547
|
+
// function* 旧字段已拆入各自的 features/*.json 配置文件。
|
|
548
548
|
migrateLegacyFunctionFields();
|