@starhikari/koishi-plugin-group-whitelist 1.0.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/lib/index.d.ts +66 -0
- package/lib/index.js +773 -0
- package/package.json +41 -0
- package/readme.md +190 -0
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Context, Schema } from 'koishi';
|
|
2
|
+
export declare const name = "group-whitelist";
|
|
3
|
+
/**
|
|
4
|
+
* 数据库为可选依赖:
|
|
5
|
+
* - 注册了数据库(如 database-sqlite)时,白名单存放在数据表 groupWhitelist 中,由指令动态维护;
|
|
6
|
+
* - 未注册数据库时,插件自动降级为读取配置中的白名单数组(fallbackGroups),依然可正常拦截。
|
|
7
|
+
*/
|
|
8
|
+
export declare const inject: {
|
|
9
|
+
required: any[];
|
|
10
|
+
optional: string[];
|
|
11
|
+
};
|
|
12
|
+
/** 私聊放行策略 */
|
|
13
|
+
export type PrivateChatMode = 'forbidden' | 'all' | 'whitelist';
|
|
14
|
+
export interface Config {
|
|
15
|
+
/** 未命中白名单时的可选提示文本,留空表示完全静默 */
|
|
16
|
+
ignoreMessage: string;
|
|
17
|
+
/** 启动后等待多少秒再初始化白名单,期间拦截全部群聊请求 */
|
|
18
|
+
startupDelay: number;
|
|
19
|
+
/** 是否记录被拦截的群聊消息,用于排查问题 */
|
|
20
|
+
enableLog: boolean;
|
|
21
|
+
/** 是否启用内置 authority 权限检查 */
|
|
22
|
+
authorityCheck: boolean;
|
|
23
|
+
/** authority 阈值 */
|
|
24
|
+
authorityLevel: number;
|
|
25
|
+
/** 是否启用管理员数组 */
|
|
26
|
+
adminCheck: boolean;
|
|
27
|
+
/** 管理员用户 ID 列表 */
|
|
28
|
+
adminList: string[];
|
|
29
|
+
/** 无数据库模式下的白名单,支持 '平台:群号' 或仅群号(匹配任意平台) */
|
|
30
|
+
fallbackGroups: string[];
|
|
31
|
+
/** 私聊策略 */
|
|
32
|
+
privateChat: PrivateChatMode;
|
|
33
|
+
/** 私聊白名单用户 ID */
|
|
34
|
+
privateWhitelist: string[];
|
|
35
|
+
/** 是否在机器人入群时发送提示 */
|
|
36
|
+
joinNoticeEnabled: boolean;
|
|
37
|
+
/** 入群提示文本(静态文本) */
|
|
38
|
+
joinNoticeText: string;
|
|
39
|
+
/** 群名自动刷新间隔(天),0 表示不自动刷新 */
|
|
40
|
+
groupNameRefreshDays: number;
|
|
41
|
+
/** 群名刷新时每次并发查询的群数量 */
|
|
42
|
+
groupNameBatch: number;
|
|
43
|
+
}
|
|
44
|
+
export declare const Config: Schema<Config>;
|
|
45
|
+
declare module 'koishi' {
|
|
46
|
+
interface Tables {
|
|
47
|
+
groupWhitelist: GroupWhitelistEntry;
|
|
48
|
+
}
|
|
49
|
+
interface Events {
|
|
50
|
+
/** 手动刷新白名单缓存(可选传入平台名,只刷新该平台) */
|
|
51
|
+
'group-whitelist/refresh'(platform?: string): void;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export interface GroupWhitelistEntry {
|
|
55
|
+
/** 平台名,如 onebot / qq */
|
|
56
|
+
platform: string;
|
|
57
|
+
/** 群号(guildId) */
|
|
58
|
+
groupId: string;
|
|
59
|
+
/** 群名称,仅用于展示 */
|
|
60
|
+
name: string;
|
|
61
|
+
/** 由谁添加 */
|
|
62
|
+
creator: string;
|
|
63
|
+
/** 添加时间 */
|
|
64
|
+
createdAt: Date;
|
|
65
|
+
}
|
|
66
|
+
export declare function apply(ctx: Context, config: Config): void;
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,773 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
5
|
+
var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name2 in all)
|
|
8
|
+
__defProp(target, name2, { get: all[name2], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var src_exports = {};
|
|
22
|
+
__export(src_exports, {
|
|
23
|
+
Config: () => Config,
|
|
24
|
+
apply: () => apply,
|
|
25
|
+
inject: () => inject,
|
|
26
|
+
name: () => name
|
|
27
|
+
});
|
|
28
|
+
module.exports = __toCommonJS(src_exports);
|
|
29
|
+
var import_koishi = require("koishi");
|
|
30
|
+
var name = "group-whitelist";
|
|
31
|
+
var inject = {
|
|
32
|
+
required: [],
|
|
33
|
+
optional: ["database"]
|
|
34
|
+
};
|
|
35
|
+
var Config = import_koishi.Schema.intersect([
|
|
36
|
+
// ==================== 群聊白名单设置 ====================
|
|
37
|
+
import_koishi.Schema.object({
|
|
38
|
+
ignoreMessage: import_koishi.Schema.string().default("").description("未命中白名单时的提示文本,留空表示完全静默(不推荐填写,容易在陌生群刷屏)"),
|
|
39
|
+
startupDelay: import_koishi.Schema.number().min(0).max(60).default(3).description("启动后等待多少秒再初始化白名单。这段时间内会拦截全部群聊请求,用于等数据库等服务就绪(数据库可能比插件启动晚约 1 秒)"),
|
|
40
|
+
enableLog: import_koishi.Schema.boolean().default(false).description("是否在控制台记录被拦截的群聊消息(logger 名称为 group-whitelist,级别 debug)")
|
|
41
|
+
}).description("群聊白名单设置"),
|
|
42
|
+
// ==================== 管理指令权限设置 ====================
|
|
43
|
+
import_koishi.Schema.intersect([
|
|
44
|
+
import_koishi.Schema.object({
|
|
45
|
+
authorityCheck: import_koishi.Schema.boolean().default(true).description("启用内置 authority 权限检查(Koishi 用户自带的权限等级)"),
|
|
46
|
+
adminCheck: import_koishi.Schema.boolean().default(true).description("启用管理员数组检查(与内置 permission 无关,独立生效)")
|
|
47
|
+
}).description("管理指令权限设置"),
|
|
48
|
+
// 仅在启用内置权限检查时展示阈值
|
|
49
|
+
import_koishi.Schema.union([
|
|
50
|
+
import_koishi.Schema.object({
|
|
51
|
+
authorityCheck: import_koishi.Schema.const(true).required(),
|
|
52
|
+
authorityLevel: import_koishi.Schema.number().min(0).max(5).default(3).description("使用管理指令所需的最低 authority 等级。Koishi 默认用户等级为 1,所以默认只有手动提权过的人能用")
|
|
53
|
+
}),
|
|
54
|
+
import_koishi.Schema.object({})
|
|
55
|
+
]),
|
|
56
|
+
// 仅在启用管理员数组时展示名单
|
|
57
|
+
import_koishi.Schema.union([
|
|
58
|
+
import_koishi.Schema.object({
|
|
59
|
+
adminCheck: import_koishi.Schema.const(true).required(),
|
|
60
|
+
adminList: import_koishi.Schema.array(import_koishi.Schema.string()).default([]).description("管理员用户 ID 列表,可跨平台,与 authority 满足其一即可使用管理指令")
|
|
61
|
+
}),
|
|
62
|
+
import_koishi.Schema.object({})
|
|
63
|
+
])
|
|
64
|
+
]),
|
|
65
|
+
// ==================== 无数据库模式白名单 ====================
|
|
66
|
+
import_koishi.Schema.object({
|
|
67
|
+
fallbackGroups: import_koishi.Schema.array(import_koishi.Schema.string()).default([]).description("未注册数据库时使用的白名单。格式为「平台:群号」或仅「群号」(仅写群号则对所有平台生效)")
|
|
68
|
+
}).description("无数据库模式白名单(仅在未注册数据库时生效)"),
|
|
69
|
+
// ==================== 私聊设置 ====================
|
|
70
|
+
import_koishi.Schema.intersect([
|
|
71
|
+
import_koishi.Schema.object({
|
|
72
|
+
privateChat: import_koishi.Schema.union([
|
|
73
|
+
import_koishi.Schema.const("all").description("放行所有私聊"),
|
|
74
|
+
import_koishi.Schema.const("whitelist").description("仅放行私聊白名单中的用户"),
|
|
75
|
+
import_koishi.Schema.const("forbidden").description("禁止一切私聊(不推荐,管理员将无法在私聊中使用管理指令)")
|
|
76
|
+
]).role("radio").default("all").description("私聊消息的处理策略")
|
|
77
|
+
}).description("私聊设置"),
|
|
78
|
+
import_koishi.Schema.union([
|
|
79
|
+
import_koishi.Schema.object({
|
|
80
|
+
privateChat: import_koishi.Schema.const("whitelist").required(),
|
|
81
|
+
privateWhitelist: import_koishi.Schema.array(import_koishi.Schema.string()).default([]).description("允许私聊的用户 ID 列表(跨平台匹配)")
|
|
82
|
+
}),
|
|
83
|
+
import_koishi.Schema.object({})
|
|
84
|
+
])
|
|
85
|
+
]),
|
|
86
|
+
// ==================== 入群提示设置 ====================
|
|
87
|
+
import_koishi.Schema.intersect([
|
|
88
|
+
import_koishi.Schema.object({
|
|
89
|
+
joinNoticeEnabled: import_koishi.Schema.boolean().default(true).description("机器人被拉入新群且该群不在白名单时,在群里发送一次提示")
|
|
90
|
+
}).description("入群提示设置"),
|
|
91
|
+
import_koishi.Schema.union([
|
|
92
|
+
import_koishi.Schema.object({
|
|
93
|
+
joinNoticeEnabled: import_koishi.Schema.const(true).required(),
|
|
94
|
+
joinNoticeText: import_koishi.Schema.string().role("textarea").default("本群尚未启用机器人服务,如需启用请联系管理员。").description("入群提示文本(静态文本,不支持占位符)")
|
|
95
|
+
}),
|
|
96
|
+
import_koishi.Schema.object({})
|
|
97
|
+
])
|
|
98
|
+
]),
|
|
99
|
+
// ==================== 群名刷新设置 ====================
|
|
100
|
+
import_koishi.Schema.intersect([
|
|
101
|
+
import_koishi.Schema.object({
|
|
102
|
+
groupNameRefreshDays: import_koishi.Schema.number().min(0).max(365).default(7).description("每隔多少天自动刷新一次白名单群的群名称,0 表示不自动刷新(群名只用于展示,不影响拦截判定)")
|
|
103
|
+
}).description("群名刷新设置"),
|
|
104
|
+
import_koishi.Schema.union([
|
|
105
|
+
import_koishi.Schema.object({
|
|
106
|
+
groupNameRefreshDays: import_koishi.Schema.const(0)
|
|
107
|
+
}),
|
|
108
|
+
import_koishi.Schema.object({
|
|
109
|
+
groupNameBatch: import_koishi.Schema.number().min(1).max(20).default(5).description("每次并发查询的群数量。NapCat 的获取群信息接口有频率限制,数值过大可能被风控")
|
|
110
|
+
})
|
|
111
|
+
])
|
|
112
|
+
])
|
|
113
|
+
]);
|
|
114
|
+
function formatTime(date) {
|
|
115
|
+
const time = new Date(date);
|
|
116
|
+
if (Number.isNaN(time.getTime())) return "未知时间";
|
|
117
|
+
const pad = /* @__PURE__ */ __name((value) => String(value).padStart(2, "0"), "pad");
|
|
118
|
+
return `${time.getFullYear()}-${pad(time.getMonth() + 1)}-${pad(time.getDate())} ${pad(time.getHours())}:${pad(time.getMinutes())}`;
|
|
119
|
+
}
|
|
120
|
+
__name(formatTime, "formatTime");
|
|
121
|
+
function parseGroupIds(digits) {
|
|
122
|
+
const invalid = [];
|
|
123
|
+
const valid = [];
|
|
124
|
+
for (const item of digits) {
|
|
125
|
+
for (const raw of String(item).split(/[,\uFF0C\u3001;;\s]+/)) {
|
|
126
|
+
const id = raw.trim();
|
|
127
|
+
if (!id) continue;
|
|
128
|
+
if (!/^\d{1,20}$/.test(id)) {
|
|
129
|
+
if (!invalid.includes(id)) invalid.push(id);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (!valid.includes(id)) valid.push(id);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return { valid, invalid };
|
|
136
|
+
}
|
|
137
|
+
__name(parseGroupIds, "parseGroupIds");
|
|
138
|
+
function parseFallbackItem(source) {
|
|
139
|
+
const item = source.trim();
|
|
140
|
+
if (!item) return null;
|
|
141
|
+
const colon = item.indexOf(":");
|
|
142
|
+
if (colon >= 0) {
|
|
143
|
+
const platform = item.slice(0, colon).trim();
|
|
144
|
+
const groupId = item.slice(colon + 1).trim();
|
|
145
|
+
if (platform && /^\d{1,20}$/.test(groupId)) return { platform, groupId };
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
return /^\d{1,20}$/.test(item) ? { groupId: item } : null;
|
|
149
|
+
}
|
|
150
|
+
__name(parseFallbackItem, "parseFallbackItem");
|
|
151
|
+
function apply(ctx, config) {
|
|
152
|
+
const logger = ctx.logger("group-whitelist");
|
|
153
|
+
const TABLE = "groupWhitelist";
|
|
154
|
+
let db = null;
|
|
155
|
+
async function isGroupAllowed(platform, groupId) {
|
|
156
|
+
try {
|
|
157
|
+
await ensurePlatformLoaded(platform);
|
|
158
|
+
} catch (error) {
|
|
159
|
+
logger.warn("查询白名单失败,本条消息按放行处理:%s", error instanceof Error ? error.message : error);
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
return isGroupAllowedSync(platform, groupId);
|
|
163
|
+
}
|
|
164
|
+
__name(isGroupAllowed, "isGroupAllowed");
|
|
165
|
+
async function getAuthority(session) {
|
|
166
|
+
if (!db) return 0;
|
|
167
|
+
try {
|
|
168
|
+
const user = await session.observeUser(["authority"]);
|
|
169
|
+
return user?.authority ?? 0;
|
|
170
|
+
} catch (error) {
|
|
171
|
+
logger.warn("读取用户权限失败,按默认等级处理:%s", error instanceof Error ? error.message : error);
|
|
172
|
+
return ctx.root.config?.autoAuthorize ?? 1;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
__name(getAuthority, "getAuthority");
|
|
176
|
+
async function isAdmin(session) {
|
|
177
|
+
if (config.authorityCheck) {
|
|
178
|
+
if (await getAuthority(session) >= config.authorityLevel) return true;
|
|
179
|
+
}
|
|
180
|
+
if (config.adminCheck && session.userId) {
|
|
181
|
+
if (config.adminList.includes(session.userId)) return true;
|
|
182
|
+
}
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
__name(isAdmin, "isAdmin");
|
|
186
|
+
async function ensureAdmin(session) {
|
|
187
|
+
if (await isAdmin(session)) return true;
|
|
188
|
+
const hints = [
|
|
189
|
+
config.authorityCheck ? `authority ≥ ${config.authorityLevel}` : "",
|
|
190
|
+
config.adminCheck ? "管理员数组" : ""
|
|
191
|
+
].filter(Boolean).join(" 或 ");
|
|
192
|
+
if (!hints) {
|
|
193
|
+
logger.warn("authority 与管理员数组均已关闭,管理指令当前对所有人开放,建议至少开启其中一项");
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
if (session.isDirect) {
|
|
197
|
+
await session.send(`你没有权限管理白名单(需要满足:${hints})。`);
|
|
198
|
+
}
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
__name(ensureAdmin, "ensureAdmin");
|
|
202
|
+
function resolvePlatform(session, given) {
|
|
203
|
+
if (given) return given;
|
|
204
|
+
if (session.platform) return session.platform;
|
|
205
|
+
const platforms = [...new Set(ctx.bots.map((bot) => bot.platform))];
|
|
206
|
+
return platforms.length === 1 ? platforms[0] : null;
|
|
207
|
+
}
|
|
208
|
+
__name(resolvePlatform, "resolvePlatform");
|
|
209
|
+
function getEventGuildName(session, groupId) {
|
|
210
|
+
if (session.guildId !== groupId) return "";
|
|
211
|
+
return session.event?.guild?.name ?? "";
|
|
212
|
+
}
|
|
213
|
+
__name(getEventGuildName, "getEventGuildName");
|
|
214
|
+
function pickBot(platform, selfId) {
|
|
215
|
+
const candidates = ctx.bots.filter((bot) => bot.platform === platform);
|
|
216
|
+
if (selfId) return candidates.find((bot) => bot.selfId === selfId) ?? null;
|
|
217
|
+
return candidates[0] ?? null;
|
|
218
|
+
}
|
|
219
|
+
__name(pickBot, "pickBot");
|
|
220
|
+
async function fetchGroupName(platform, groupId, selfId) {
|
|
221
|
+
const bot = pickBot(platform, selfId);
|
|
222
|
+
if (!bot) return "";
|
|
223
|
+
try {
|
|
224
|
+
const guild = await bot.getGuild?.(groupId);
|
|
225
|
+
return guild?.name ?? "";
|
|
226
|
+
} catch (error) {
|
|
227
|
+
logger.debug("获取群 %s/%s 的名称失败:%s", platform, groupId, error instanceof Error ? error.message : error);
|
|
228
|
+
return "";
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
__name(fetchGroupName, "fetchGroupName");
|
|
232
|
+
async function inBatches(items, size, worker) {
|
|
233
|
+
const results = [];
|
|
234
|
+
for (let index = 0; index < items.length; index += size) {
|
|
235
|
+
const slice = items.slice(index, index + size);
|
|
236
|
+
results.push(...await Promise.all(slice.map(worker)));
|
|
237
|
+
}
|
|
238
|
+
return results;
|
|
239
|
+
}
|
|
240
|
+
__name(inBatches, "inBatches");
|
|
241
|
+
async function refreshGroupNames(targets) {
|
|
242
|
+
const database = db;
|
|
243
|
+
if (!database) {
|
|
244
|
+
return { total: 0, updated: 0, failed: 0, skipped: true };
|
|
245
|
+
}
|
|
246
|
+
let rows;
|
|
247
|
+
if (targets?.length) {
|
|
248
|
+
rows = [];
|
|
249
|
+
for (const target of targets) {
|
|
250
|
+
try {
|
|
251
|
+
const found = await database.get(TABLE, target, ["name"]);
|
|
252
|
+
if (found[0]) rows.push({ ...target, name: found[0].name });
|
|
253
|
+
} catch (error) {
|
|
254
|
+
logger.warn("读取群 %s/%s 失败:%s", target.platform, target.groupId, error instanceof Error ? error.message : error);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
} else {
|
|
258
|
+
try {
|
|
259
|
+
rows = await database.get(TABLE, {}, ["platform", "groupId", "name"]);
|
|
260
|
+
} catch (error) {
|
|
261
|
+
logger.warn("读取白名单失败:%s", error instanceof Error ? error.message : error);
|
|
262
|
+
return { total: 0, updated: 0, failed: 0, skipped: false };
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (!rows.length) return { total: 0, updated: 0, failed: 0, skipped: false };
|
|
266
|
+
const size = Math.max(1, Math.min(20, config.groupNameBatch ?? 5));
|
|
267
|
+
const names = await inBatches(rows, size, (row) => fetchGroupName(row.platform, row.groupId));
|
|
268
|
+
let updated = 0;
|
|
269
|
+
let failed = 0;
|
|
270
|
+
for (let index = 0; index < rows.length; ++index) {
|
|
271
|
+
const row = rows[index];
|
|
272
|
+
const name2 = names[index];
|
|
273
|
+
if (!name2) {
|
|
274
|
+
failed++;
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
if (name2 === row.name) continue;
|
|
278
|
+
try {
|
|
279
|
+
await database.set(TABLE, { platform: row.platform, groupId: row.groupId }, { name: name2 });
|
|
280
|
+
updated++;
|
|
281
|
+
} catch (error) {
|
|
282
|
+
logger.warn("更新群 %s/%s 的名称失败:%s", row.platform, row.groupId, error instanceof Error ? error.message : error);
|
|
283
|
+
failed++;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
logger.info("群名刷新完成:共 %d 个群,更新 %d 个,失败/不支持 %d 个", rows.length, updated, failed);
|
|
287
|
+
return { total: rows.length, updated, failed, skipped: false };
|
|
288
|
+
}
|
|
289
|
+
__name(refreshGroupNames, "refreshGroupNames");
|
|
290
|
+
let refreshTimer = null;
|
|
291
|
+
function stopNameScheduler() {
|
|
292
|
+
refreshTimer?.();
|
|
293
|
+
refreshTimer = null;
|
|
294
|
+
}
|
|
295
|
+
__name(stopNameScheduler, "stopNameScheduler");
|
|
296
|
+
function startNameScheduler() {
|
|
297
|
+
stopNameScheduler();
|
|
298
|
+
const days = config.groupNameRefreshDays ?? 0;
|
|
299
|
+
if (!days || days <= 0) return;
|
|
300
|
+
const interval = days * 24 * 60 * 60 * 1e3;
|
|
301
|
+
refreshTimer = ctx.setTimeout(async () => {
|
|
302
|
+
try {
|
|
303
|
+
await refreshGroupNames();
|
|
304
|
+
} catch (error) {
|
|
305
|
+
logger.warn("自动刷新群名失败:%s", error instanceof Error ? error.message : error);
|
|
306
|
+
}
|
|
307
|
+
startNameScheduler();
|
|
308
|
+
}, interval);
|
|
309
|
+
logger.info("群名自动刷新已启用:每 %d 天一次", days);
|
|
310
|
+
}
|
|
311
|
+
__name(startNameScheduler, "startNameScheduler");
|
|
312
|
+
async function addGroups(platform, ids, session) {
|
|
313
|
+
const added = [];
|
|
314
|
+
const duplicated = [];
|
|
315
|
+
const failed = [];
|
|
316
|
+
const database = db;
|
|
317
|
+
if (!database) {
|
|
318
|
+
return { added, duplicated, failed: ids, readonly: true };
|
|
319
|
+
}
|
|
320
|
+
const existIds = /* @__PURE__ */ new Set();
|
|
321
|
+
try {
|
|
322
|
+
const exist = await database.get(TABLE, { platform, groupId: { $in: ids } }, ["groupId"]);
|
|
323
|
+
for (const row of exist) existIds.add(row.groupId);
|
|
324
|
+
} catch (error) {
|
|
325
|
+
logger.warn("读取白名单失败:%s", error instanceof Error ? error.message : error);
|
|
326
|
+
return { added, duplicated, failed: ids, readonly: false };
|
|
327
|
+
}
|
|
328
|
+
for (const id of ids) {
|
|
329
|
+
if (existIds.has(id)) duplicated.push(id);
|
|
330
|
+
}
|
|
331
|
+
const pending = ids.filter((id) => !existIds.has(id));
|
|
332
|
+
if (pending.length) {
|
|
333
|
+
const names = await inBatches(pending, Math.max(1, Math.min(20, config.groupNameBatch ?? 5)), async (groupId) => {
|
|
334
|
+
return getEventGuildName(session, groupId) || await fetchGroupName(platform, groupId, session.selfId);
|
|
335
|
+
});
|
|
336
|
+
for (let index = 0; index < pending.length; ++index) {
|
|
337
|
+
const groupId = pending[index];
|
|
338
|
+
try {
|
|
339
|
+
await database.create(TABLE, {
|
|
340
|
+
platform,
|
|
341
|
+
groupId,
|
|
342
|
+
name: names[index] ?? "",
|
|
343
|
+
creator: session.userId,
|
|
344
|
+
createdAt: /* @__PURE__ */ new Date()
|
|
345
|
+
});
|
|
346
|
+
added.push(groupId);
|
|
347
|
+
markAllowed(platform, groupId);
|
|
348
|
+
} catch (error) {
|
|
349
|
+
logger.warn("写入白名单失败(%s):%s", groupId, error instanceof Error ? error.message : error);
|
|
350
|
+
failed.push(groupId);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return { added, duplicated, failed, readonly: false };
|
|
355
|
+
}
|
|
356
|
+
__name(addGroups, "addGroups");
|
|
357
|
+
async function removeGroups(platform, ids) {
|
|
358
|
+
const removed = [];
|
|
359
|
+
const missing = [];
|
|
360
|
+
const database = db;
|
|
361
|
+
if (!database) {
|
|
362
|
+
return { removed, missing: ids, readonly: true };
|
|
363
|
+
}
|
|
364
|
+
const existIds = /* @__PURE__ */ new Set();
|
|
365
|
+
try {
|
|
366
|
+
const exist = await database.get(TABLE, { platform, groupId: { $in: ids } }, ["groupId"]);
|
|
367
|
+
for (const row of exist) existIds.add(row.groupId);
|
|
368
|
+
} catch (error) {
|
|
369
|
+
logger.warn("读取白名单失败:%s", error instanceof Error ? error.message : error);
|
|
370
|
+
return { removed, missing: ids, readonly: false };
|
|
371
|
+
}
|
|
372
|
+
for (const id of ids) {
|
|
373
|
+
if (!existIds.has(id)) missing.push(id);
|
|
374
|
+
}
|
|
375
|
+
if (existIds.size) {
|
|
376
|
+
const targets = [...existIds];
|
|
377
|
+
try {
|
|
378
|
+
await database.remove(TABLE, { platform, groupId: { $in: targets } });
|
|
379
|
+
for (const id of targets) {
|
|
380
|
+
removed.push(id);
|
|
381
|
+
allowedGroups.delete(cacheKey(platform, id));
|
|
382
|
+
}
|
|
383
|
+
} catch (error) {
|
|
384
|
+
logger.warn("移除白名单失败:%s", error instanceof Error ? error.message : error);
|
|
385
|
+
removed.length = 0;
|
|
386
|
+
missing.push(...targets);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
return { removed, missing, readonly: false };
|
|
390
|
+
}
|
|
391
|
+
__name(removeGroups, "removeGroups");
|
|
392
|
+
const blockedMap = /* @__PURE__ */ new WeakMap();
|
|
393
|
+
const allowedGroups = /* @__PURE__ */ new Set();
|
|
394
|
+
const cacheKey = /* @__PURE__ */ __name((platform, groupId) => `${platform}:${groupId}`, "cacheKey");
|
|
395
|
+
const platformLoaded = /* @__PURE__ */ new Set();
|
|
396
|
+
const emptyWarned = /* @__PURE__ */ new Set();
|
|
397
|
+
const configNameCache = /* @__PURE__ */ new Map();
|
|
398
|
+
let dbEverSeen = false;
|
|
399
|
+
let configSettled = false;
|
|
400
|
+
function markAllowed(platform, groupId) {
|
|
401
|
+
allowedGroups.add(cacheKey(platform, groupId));
|
|
402
|
+
}
|
|
403
|
+
__name(markAllowed, "markAllowed");
|
|
404
|
+
function clearDecisions() {
|
|
405
|
+
configNameCache.clear();
|
|
406
|
+
allowedGroups.clear();
|
|
407
|
+
platformLoaded.clear();
|
|
408
|
+
emptyWarned.clear();
|
|
409
|
+
}
|
|
410
|
+
__name(clearDecisions, "clearDecisions");
|
|
411
|
+
function resetDecisions() {
|
|
412
|
+
clearDecisions();
|
|
413
|
+
if (!initDone) return;
|
|
414
|
+
logger.debug("数据源已切换,重新载入白名单判定表");
|
|
415
|
+
initialize().catch((error) => {
|
|
416
|
+
logger.warn("按新数据源重新载入白名单失败:%s", error instanceof Error ? error.message : error);
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
__name(resetDecisions, "resetDecisions");
|
|
420
|
+
function decisionsSettled() {
|
|
421
|
+
return !!db || dbEverSeen || configSettled;
|
|
422
|
+
}
|
|
423
|
+
__name(decisionsSettled, "decisionsSettled");
|
|
424
|
+
async function ensurePlatformLoaded(platform) {
|
|
425
|
+
if (platformLoaded.has(platform)) return;
|
|
426
|
+
const database = db;
|
|
427
|
+
if (!database) {
|
|
428
|
+
if (!decisionsSettled()) return;
|
|
429
|
+
let count = 0;
|
|
430
|
+
for (const item of config.fallbackGroups) {
|
|
431
|
+
const parsed = parseFallbackItem(item);
|
|
432
|
+
if (!parsed) continue;
|
|
433
|
+
if (parsed.platform && parsed.platform !== platform) continue;
|
|
434
|
+
markAllowed(platform, parsed.groupId);
|
|
435
|
+
count++;
|
|
436
|
+
}
|
|
437
|
+
platformLoaded.add(platform);
|
|
438
|
+
if (!count) markEmpty(platform);
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
const rows = await database.get(TABLE, { platform }, ["groupId"]);
|
|
442
|
+
for (const row of rows) markAllowed(platform, row.groupId);
|
|
443
|
+
platformLoaded.add(platform);
|
|
444
|
+
if (!rows.length) markEmpty(platform);
|
|
445
|
+
logger.debug("已载入 %s 平台白名单,共 %d 条", platform, rows.length);
|
|
446
|
+
}
|
|
447
|
+
__name(ensurePlatformLoaded, "ensurePlatformLoaded");
|
|
448
|
+
function markEmpty(platform) {
|
|
449
|
+
if (emptyWarned.has(platform)) return;
|
|
450
|
+
emptyWarned.add(platform);
|
|
451
|
+
logger.info("%s 平台白名单为空,该平台所有群聊都会被忽略;可私聊机器人执行「白名单 添加 <群号> -p %s」启用", platform, platform);
|
|
452
|
+
}
|
|
453
|
+
__name(markEmpty, "markEmpty");
|
|
454
|
+
function isGroupAllowedSync(platform, groupId) {
|
|
455
|
+
if (!platformLoaded.has(platform)) return false;
|
|
456
|
+
return allowedGroups.has(cacheKey(platform, groupId));
|
|
457
|
+
}
|
|
458
|
+
__name(isGroupAllowedSync, "isGroupAllowedSync");
|
|
459
|
+
function resolveBlockSync(session) {
|
|
460
|
+
const groupId = session.guildId;
|
|
461
|
+
if (groupId) {
|
|
462
|
+
if (isGroupAllowedSync(session.platform, groupId)) return null;
|
|
463
|
+
if (config.enableLog) {
|
|
464
|
+
logger.debug("已拦截非白名单群聊消息:%s / %s / %s", session.platform, groupId, session.userId);
|
|
465
|
+
}
|
|
466
|
+
return config.ignoreMessage;
|
|
467
|
+
}
|
|
468
|
+
if (config.privateChat === "all") return null;
|
|
469
|
+
if (config.privateChat === "whitelist") {
|
|
470
|
+
if (session.userId && config.privateWhitelist.includes(session.userId)) return null;
|
|
471
|
+
if (config.enableLog) {
|
|
472
|
+
logger.debug("已拦截非白名单私聊消息:%s / %s", session.platform, session.userId);
|
|
473
|
+
}
|
|
474
|
+
return "";
|
|
475
|
+
}
|
|
476
|
+
return "";
|
|
477
|
+
}
|
|
478
|
+
__name(resolveBlockSync, "resolveBlockSync");
|
|
479
|
+
ctx.on("ready", async () => {
|
|
480
|
+
for (const platform of new Set(ctx.bots.map((bot) => bot.platform))) {
|
|
481
|
+
await ensurePlatformLoaded(platform);
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
ctx.before("attach", (session) => {
|
|
485
|
+
if (!session.userId || !session.event) return;
|
|
486
|
+
if (!session.guildId) return;
|
|
487
|
+
ensurePlatformLoaded(session.platform).catch((error) => {
|
|
488
|
+
logger.warn("载入白名单失败:%s", error instanceof Error ? error.message : error);
|
|
489
|
+
});
|
|
490
|
+
});
|
|
491
|
+
ctx.on("message", (session) => {
|
|
492
|
+
if (!session.userId || !session.event) return;
|
|
493
|
+
const blocked = resolveBlockSync(session);
|
|
494
|
+
if (blocked === null) return;
|
|
495
|
+
blockedMap.set(session, blocked);
|
|
496
|
+
session.response = async () => blockedMap.get(session) ?? "";
|
|
497
|
+
}, { prepend: true });
|
|
498
|
+
const sample = ctx.bots[0]?.session({ type: "message" });
|
|
499
|
+
const prototype = sample ? Object.getPrototypeOf(sample) : null;
|
|
500
|
+
if (prototype && typeof prototype.execute === "function") {
|
|
501
|
+
const original = prototype.execute;
|
|
502
|
+
prototype.execute = function(...args) {
|
|
503
|
+
if (blockedMap.has(this)) return Promise.resolve(blockedMap.get(this));
|
|
504
|
+
return original.apply(this, args);
|
|
505
|
+
};
|
|
506
|
+
ctx.on("dispose", () => {
|
|
507
|
+
prototype.execute = original;
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
ctx.on("guild-added", async (session) => {
|
|
511
|
+
if (!config.joinNoticeEnabled || !config.joinNoticeText) return;
|
|
512
|
+
const platform = session.platform;
|
|
513
|
+
const groupId = session.guildId;
|
|
514
|
+
if (!groupId) return;
|
|
515
|
+
try {
|
|
516
|
+
if (await isGroupAllowed(platform, groupId)) return;
|
|
517
|
+
await session.send(config.joinNoticeText);
|
|
518
|
+
} catch (error) {
|
|
519
|
+
logger.warn("发送入群提示失败:%s", error instanceof Error ? error.message : error);
|
|
520
|
+
}
|
|
521
|
+
});
|
|
522
|
+
const base = { showWarning: false, authority: 0 };
|
|
523
|
+
const platformOption = { type: "string", authority: 0 };
|
|
524
|
+
const command = ctx.command("whitelist", "群聊白名单管理", base).alias("白名单").usage([
|
|
525
|
+
"查看与维护群聊白名单。名单按「平台 + 群号」保存,未命中白名单的群消息将被整只机器人忽略。",
|
|
526
|
+
"",
|
|
527
|
+
"子指令:",
|
|
528
|
+
" 白名单.列表 查看白名单(别名 白名单.list / 白名单.ls)",
|
|
529
|
+
" 白名单.添加 添加群聊(别名 白名单.add)",
|
|
530
|
+
" 白名单.移除 移除群聊(别名 白名单.remove / 白名单.rm)",
|
|
531
|
+
" 白名单.刷新 立刻重新拉取群名称(别名 白名单.refresh / 白名单.同步群名)",
|
|
532
|
+
"",
|
|
533
|
+
"指令选项:",
|
|
534
|
+
" -p, --platform <name> 指定平台(默认取当前会话所在平台)",
|
|
535
|
+
"",
|
|
536
|
+
"示例:",
|
|
537
|
+
" 白名单.列表",
|
|
538
|
+
" 白名单.添加 123456 654321",
|
|
539
|
+
" 白名单.添加 -p qq 123456,654321",
|
|
540
|
+
" 白名单.移除 123456",
|
|
541
|
+
" 白名单.刷新"
|
|
542
|
+
].join("\n"));
|
|
543
|
+
const list = command.subcommand(".列表", "查看白名单", base).alias(".list", ".ls", ".查看").option("platform", "-p <name> 指定平台", platformOption).example("白名单.列表").example("白名单.列表 -p qq");
|
|
544
|
+
const add = command.subcommand(".添加 <...digits:string>", "添加群聊到白名单(支持批量)", base).alias(".add").option("platform", "-p <name> 指定平台", platformOption).example("白名单.添加 123456 654321").example("白名单.添加 -p qq 123456,654321");
|
|
545
|
+
const remove = command.subcommand(".移除 <...digits:string>", "从白名单移除群聊(支持批量)", base).alias(".remove", ".rm", ".delete", ".删除").option("platform", "-p <name> 指定平台", platformOption).example("白名单.移除 123456");
|
|
546
|
+
const refreshNames = command.subcommand(".刷新", "立刻重新拉取白名单群的名称", base).alias(".refresh", ".sync", ".同步群名").option("platform", "-p <name> 指定平台", platformOption).example("白名单.刷新").example("白名单.刷新 -p qq");
|
|
547
|
+
const commands = [command, list, add, remove, refreshNames];
|
|
548
|
+
if (config.authorityCheck) {
|
|
549
|
+
for (const item of commands) item.userFields(["authority"]);
|
|
550
|
+
}
|
|
551
|
+
list.action(async ({ session, options }) => {
|
|
552
|
+
if (!session) return;
|
|
553
|
+
if (!await ensureAdmin(session)) return;
|
|
554
|
+
const platform = resolvePlatform(session, options.platform);
|
|
555
|
+
if (!platform) return "无法确定要查询的平台,请使用 -p <平台名> 指定。";
|
|
556
|
+
let rows = [];
|
|
557
|
+
let fromConfig = false;
|
|
558
|
+
const database = db;
|
|
559
|
+
if (database) {
|
|
560
|
+
try {
|
|
561
|
+
rows = await database.get(TABLE, { platform }, { sort: { createdAt: "asc" }, limit: 201 });
|
|
562
|
+
} catch (error) {
|
|
563
|
+
logger.warn("查询白名单失败:%s", error instanceof Error ? error.message : error);
|
|
564
|
+
return "查询白名单失败,请查看控制台日志。";
|
|
565
|
+
}
|
|
566
|
+
} else {
|
|
567
|
+
fromConfig = true;
|
|
568
|
+
for (const item of config.fallbackGroups) {
|
|
569
|
+
const parsed = parseFallbackItem(item);
|
|
570
|
+
if (!parsed) continue;
|
|
571
|
+
if (parsed.platform && parsed.platform !== platform) continue;
|
|
572
|
+
rows.push({ platform, groupId: parsed.groupId, name: "", creator: "", createdAt: /* @__PURE__ */ new Date(0) });
|
|
573
|
+
}
|
|
574
|
+
rows.sort((a, b) => a.groupId.localeCompare(b.groupId));
|
|
575
|
+
}
|
|
576
|
+
const lines = [`${platform} 平台白名单(共 ${rows.length} 条${fromConfig ? ",来自配置项" : ""})`];
|
|
577
|
+
if (!rows.length) {
|
|
578
|
+
lines.push("(名单为空,当前该平台下所有群聊消息都会被忽略)");
|
|
579
|
+
} else {
|
|
580
|
+
if (fromConfig) {
|
|
581
|
+
await inBatches(rows, Math.max(1, Math.min(20, config.groupNameBatch ?? 5)), async (row) => {
|
|
582
|
+
const key = cacheKey(platform, row.groupId);
|
|
583
|
+
if (!configNameCache.has(key)) {
|
|
584
|
+
configNameCache.set(key, await fetchGroupName(platform, row.groupId, session.selfId));
|
|
585
|
+
}
|
|
586
|
+
row.name = configNameCache.get(key) ?? "";
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
const limit = 20;
|
|
590
|
+
for (const row of rows.slice(0, limit)) {
|
|
591
|
+
const isSelf = !!session.guildId && row.groupId === session.guildId;
|
|
592
|
+
const since = row.createdAt && row.createdAt.getTime() > 0 ? `|${formatTime(row.createdAt)}` : "";
|
|
593
|
+
lines.push(`${isSelf ? "▶" : "·"} ${row.groupId}${row.name ? `(${row.name})` : ""}${since}`);
|
|
594
|
+
}
|
|
595
|
+
if (rows.length > limit) lines.push(`…… 其余 ${rows.length - limit} 条已省略`);
|
|
596
|
+
}
|
|
597
|
+
if (fromConfig) {
|
|
598
|
+
lines.push("提示:当前未注册数据库,白名单来自配置项 fallbackGroups,指令增删不会生效。");
|
|
599
|
+
}
|
|
600
|
+
return lines.join("\n");
|
|
601
|
+
});
|
|
602
|
+
add.action(async ({ session, options, args }) => {
|
|
603
|
+
if (!session) return;
|
|
604
|
+
if (!await ensureAdmin(session)) return;
|
|
605
|
+
const platform = resolvePlatform(session, options.platform);
|
|
606
|
+
if (!platform) return "无法确定要操作的平台,请使用 -p <平台名> 指定。";
|
|
607
|
+
const { valid, invalid } = parseGroupIds(args);
|
|
608
|
+
if (!valid.length) {
|
|
609
|
+
return invalid.length ? `群号格式不正确:${invalid.join("、")}。群号应为纯数字,可一次填写多个(空格或逗号分隔)。` : "请提供至少一个群号,例如:白名单 添加 123456";
|
|
610
|
+
}
|
|
611
|
+
const result = await addGroups(platform, valid, session);
|
|
612
|
+
if (result.readonly) {
|
|
613
|
+
return "当前未注册数据库,白名单来自配置项 fallbackGroups,无法通过指令添加。请先在配置中维护,或安装 database-sqlite 等数据库插件。";
|
|
614
|
+
}
|
|
615
|
+
const lines = [];
|
|
616
|
+
if (result.added.length) {
|
|
617
|
+
lines.push(`已向 ${platform} 平台添加 ${result.added.length} 个群:${result.added.join("、")}`);
|
|
618
|
+
}
|
|
619
|
+
if (result.duplicated.length) {
|
|
620
|
+
const preview = result.duplicated.slice(0, 5).join("、");
|
|
621
|
+
lines.push(`${result.duplicated.length} 个群已在白名单中,已跳过:${preview}${result.duplicated.length > 5 ? " 等" : ""}`);
|
|
622
|
+
}
|
|
623
|
+
if (result.failed.length) {
|
|
624
|
+
const preview = result.failed.slice(0, 5).join("、");
|
|
625
|
+
lines.push(`${result.failed.length} 个群写入失败:${preview}${result.failed.length > 5 ? " 等" : ""}`);
|
|
626
|
+
}
|
|
627
|
+
if (!lines.length) return "没有需要添加的群。";
|
|
628
|
+
if (invalid.length) lines.push(`另有格式不正确的项已忽略:${invalid.join("、")}`);
|
|
629
|
+
return lines.join("\n");
|
|
630
|
+
});
|
|
631
|
+
remove.action(async ({ session, options, args }) => {
|
|
632
|
+
if (!session) return;
|
|
633
|
+
if (!await ensureAdmin(session)) return;
|
|
634
|
+
const platform = resolvePlatform(session, options.platform);
|
|
635
|
+
if (!platform) return "无法确定要操作的平台,请使用 -p <平台名> 指定。";
|
|
636
|
+
const { valid, invalid } = parseGroupIds(args);
|
|
637
|
+
if (!valid.length) {
|
|
638
|
+
return invalid.length ? `群号格式不正确:${invalid.join("、")}。群号应为纯数字,可一次填写多个(空格或逗号分隔)。` : "请提供至少一个群号,例如:白名单 移除 123456";
|
|
639
|
+
}
|
|
640
|
+
const result = await removeGroups(platform, valid);
|
|
641
|
+
if (result.readonly) {
|
|
642
|
+
return "当前未注册数据库,白名单来自配置项 fallbackGroups,无法通过指令移除。请直接在配置中维护。";
|
|
643
|
+
}
|
|
644
|
+
const lines = [];
|
|
645
|
+
if (result.removed.length) {
|
|
646
|
+
lines.push(`已从 ${platform} 平台移除 ${result.removed.length} 个群:${result.removed.join("、")}`);
|
|
647
|
+
}
|
|
648
|
+
if (result.missing.length) {
|
|
649
|
+
const preview = result.missing.slice(0, 5).join("、");
|
|
650
|
+
lines.push(`${result.missing.length} 个群不在白名单中,已跳过:${preview}${result.missing.length > 5 ? " 等" : ""}`);
|
|
651
|
+
}
|
|
652
|
+
if (!lines.length) return "没有需要移除的群。";
|
|
653
|
+
if (invalid.length) lines.push(`另有格式不正确的项已忽略:${invalid.join("、")}`);
|
|
654
|
+
if (result.removed.some((id) => id === session.guildId)) {
|
|
655
|
+
lines.push("注意:本群已被移出白名单,接下来本群消息将不再被处理。");
|
|
656
|
+
}
|
|
657
|
+
return lines.join("\n");
|
|
658
|
+
});
|
|
659
|
+
refreshNames.action(async ({ session, options }) => {
|
|
660
|
+
if (!session) return;
|
|
661
|
+
if (!await ensureAdmin(session)) return;
|
|
662
|
+
if (!db) {
|
|
663
|
+
return "当前未注册数据库,白名单来自配置项 fallbackGroups,没有可刷新的群名记录。";
|
|
664
|
+
}
|
|
665
|
+
const platform = options.platform ?? void 0;
|
|
666
|
+
let targets;
|
|
667
|
+
if (platform) {
|
|
668
|
+
try {
|
|
669
|
+
const rows = await db.get(TABLE, { platform }, ["groupId"]);
|
|
670
|
+
targets = rows.map((row) => ({ platform, groupId: row.groupId }));
|
|
671
|
+
} catch (error) {
|
|
672
|
+
logger.warn("读取白名单失败:%s", error instanceof Error ? error.message : error);
|
|
673
|
+
return "读取白名单失败,请查看控制台日志。";
|
|
674
|
+
}
|
|
675
|
+
if (!targets.length) return `${platform} 平台白名单为空,没有需要刷新的群。`;
|
|
676
|
+
}
|
|
677
|
+
await session.send("正在拉取群名称,请稍候…");
|
|
678
|
+
const result = await refreshGroupNames(targets);
|
|
679
|
+
const scope = platform ? `${platform} 平台` : "全部平台";
|
|
680
|
+
const lines = [`群名刷新完成(${scope}):共 ${result.total} 个群。`];
|
|
681
|
+
if (result.updated) lines.push(`已更新 ${result.updated} 个群的名称。`);
|
|
682
|
+
if (result.failed) lines.push(`${result.failed} 个群未能取到名称,已保留原值(多为平台不支持或接口异常)。`);
|
|
683
|
+
if (!result.updated && !result.failed) lines.push("所有群名都是最新的,无需更新。");
|
|
684
|
+
return lines.join("\n");
|
|
685
|
+
});
|
|
686
|
+
ctx.inject(["database"], (ctx2) => {
|
|
687
|
+
db = ctx2.database;
|
|
688
|
+
dbEverSeen = true;
|
|
689
|
+
ctx2.model.extend("groupWhitelist", {
|
|
690
|
+
platform: "string",
|
|
691
|
+
groupId: "string",
|
|
692
|
+
name: { type: "string", initial: "" },
|
|
693
|
+
creator: { type: "string", initial: "" },
|
|
694
|
+
// 注意:minato 的 initial 只接受字面值、不接受工厂函数,时间戳在插入时显式赋值
|
|
695
|
+
createdAt: "timestamp"
|
|
696
|
+
}, {
|
|
697
|
+
primary: ["platform", "groupId"],
|
|
698
|
+
indexes: ["platform"]
|
|
699
|
+
});
|
|
700
|
+
resetDecisions();
|
|
701
|
+
logger.info("白名单存储:数据库(表 %s)", TABLE);
|
|
702
|
+
ctx2.on("dispose", () => {
|
|
703
|
+
db = null;
|
|
704
|
+
resetDecisions();
|
|
705
|
+
logger.info("数据库服务已卸载,白名单回落到配置模式(fallbackGroups)");
|
|
706
|
+
});
|
|
707
|
+
});
|
|
708
|
+
async function refresh(platform) {
|
|
709
|
+
if (platform) {
|
|
710
|
+
platformLoaded.delete(platform);
|
|
711
|
+
emptyWarned.delete(platform);
|
|
712
|
+
for (const key of [...allowedGroups]) {
|
|
713
|
+
if (key.startsWith(platform + ":")) allowedGroups.delete(key);
|
|
714
|
+
}
|
|
715
|
+
return ensurePlatformLoaded(platform);
|
|
716
|
+
}
|
|
717
|
+
clearDecisions();
|
|
718
|
+
for (const item of new Set(ctx.bots.map((bot) => bot.platform))) {
|
|
719
|
+
await ensurePlatformLoaded(item);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
__name(refresh, "refresh");
|
|
723
|
+
ctx.on("group-whitelist/refresh", (platform) => {
|
|
724
|
+
refresh(platform).catch((error) => {
|
|
725
|
+
logger.warn("刷新白名单判定表失败:%s", error instanceof Error ? error.message : error);
|
|
726
|
+
});
|
|
727
|
+
});
|
|
728
|
+
ctx.on("dispose", () => {
|
|
729
|
+
clearDecisions();
|
|
730
|
+
initDone = false;
|
|
731
|
+
stopNameScheduler();
|
|
732
|
+
});
|
|
733
|
+
let initDone = false;
|
|
734
|
+
async function initialize() {
|
|
735
|
+
if (initDone) return;
|
|
736
|
+
configSettled = !db;
|
|
737
|
+
for (const platform of new Set(ctx.bots.map((bot) => bot.platform))) {
|
|
738
|
+
try {
|
|
739
|
+
await ensurePlatformLoaded(platform);
|
|
740
|
+
} catch (error) {
|
|
741
|
+
logger.warn("载入 %s 平台白名单失败:%s", platform, error instanceof Error ? error.message : error);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
initDone = true;
|
|
745
|
+
logger.info(
|
|
746
|
+
"白名单已就绪:%s,%d 个平台、%d 个群",
|
|
747
|
+
db ? `数据库(表 ${TABLE})` : `配置文件(fallbackGroups,${config.fallbackGroups.length} 项)`,
|
|
748
|
+
platformLoaded.size,
|
|
749
|
+
allowedGroups.size
|
|
750
|
+
);
|
|
751
|
+
startNameScheduler();
|
|
752
|
+
}
|
|
753
|
+
__name(initialize, "initialize");
|
|
754
|
+
ctx.on("ready", () => {
|
|
755
|
+
const delay = Math.max(0, config.startupDelay ?? 3) * 1e3;
|
|
756
|
+
if (!delay) {
|
|
757
|
+
initialize();
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
logger.debug("启动闸门已开启:%d 秒内拦截全部群聊请求,随后初始化白名单", delay / 1e3);
|
|
761
|
+
ctx.setTimeout(() => {
|
|
762
|
+
initialize();
|
|
763
|
+
}, delay);
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
__name(apply, "apply");
|
|
767
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
768
|
+
0 && (module.exports = {
|
|
769
|
+
Config,
|
|
770
|
+
apply,
|
|
771
|
+
inject,
|
|
772
|
+
name
|
|
773
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@starhikari/koishi-plugin-group-whitelist",
|
|
3
|
+
"description": "实现koishi全局白名单,群聊不在白名单内时bot将不再处理该群的任何消息与指令",
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"main": "lib/index.js",
|
|
6
|
+
"typings": "lib/index.d.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"lib",
|
|
9
|
+
"dist"
|
|
10
|
+
],
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"homepage": "https://github.com/Star-Hikari/group-whitelist",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "https://github.com/Star-Hikari/group-whitelist.git"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"chatbot",
|
|
19
|
+
"koishi",
|
|
20
|
+
"plugin",
|
|
21
|
+
"whitelist",
|
|
22
|
+
"group-whitelist",
|
|
23
|
+
"群聊白名单",
|
|
24
|
+
"群白名单"
|
|
25
|
+
],
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"koishi": "^4.18.7"
|
|
28
|
+
},
|
|
29
|
+
"koishi": {
|
|
30
|
+
"description": {
|
|
31
|
+
"zh": "群聊白名单系统:按「平台 + 群号」维护白名单,未命中的群消息会被整只机器人静默忽略。名单可用指令批量增删,也可在没有数据库时改用配置项维护。",
|
|
32
|
+
"en": "Group chat whitelist: keep a per-platform group whitelist; messages from groups not on it are silently ignored by the whole bot. Manage it with commands, or fall back to a config list when no database is available."
|
|
33
|
+
},
|
|
34
|
+
"service": {
|
|
35
|
+
"required": [],
|
|
36
|
+
"optional": [
|
|
37
|
+
"database"
|
|
38
|
+
]
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
package/readme.md
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# @starhikari/koishi-plugin-group-whitelist
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@starhikari/koishi-plugin-group-whitelist)
|
|
4
|
+
|
|
5
|
+
群聊白名单系统:按「平台 + 群号」维护白名单,**未命中白名单的群消息会被整只机器人静默忽略** —— 所有指令、其他插件的监听器、模糊匹配都不会触发,等同于机器人不在这个群里。
|
|
6
|
+
|
|
7
|
+
## 特性
|
|
8
|
+
|
|
9
|
+
- **全局总闸**:拦截发生在消息进入 Koishi 消息流水线的第一刻,不是逐个插件过滤
|
|
10
|
+
- **按平台隔离**:名单主键为「平台 + 群号」,onebot(NapCat)与 qq(官方 bot)各自独立
|
|
11
|
+
- **指令维护**:`白名单 添加/移除/列表`,增删均支持批量群号,数据库自动建表
|
|
12
|
+
- **自动降级**:未注册数据库时改用配置项 `fallbackGroups`,无需手动切换
|
|
13
|
+
- **私聊可控**:私聊默认放行(方便管理员在私聊里救急),可切换为禁止或私聊白名单
|
|
14
|
+
- **入群提示**:机器人被拉入未白名单的群时发送一次可自定义的提示文本
|
|
15
|
+
- **同步判定**:运行期不逐条消息查库,启动时整表载入内存判定表
|
|
16
|
+
|
|
17
|
+
## 安装
|
|
18
|
+
|
|
19
|
+
```text
|
|
20
|
+
# 在插件市场搜索「group-whitelist」并安装,或在终端执行
|
|
21
|
+
npm install @starhikari/koishi-plugin-group-whitelist
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
然后在 `koishi.yml` 中启用(行首的 `~` 表示禁用,去掉即可):
|
|
25
|
+
|
|
26
|
+
```yaml
|
|
27
|
+
plugins:
|
|
28
|
+
'@starhikari/group-whitelist': {}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
插件依赖 `database` 服务(可选)。项目里默认已有 `database-sqlite`,无需额外安装。
|
|
32
|
+
|
|
33
|
+
## 快速上手
|
|
34
|
+
|
|
35
|
+
首次启动时白名单为空,**该平台下所有群聊都会被忽略**(这正是白名单的语义)。启用某个群有两种方式:
|
|
36
|
+
|
|
37
|
+
1. **私聊机器人**(推荐,私聊默认放行):
|
|
38
|
+
|
|
39
|
+
```text
|
|
40
|
+
白名单.添加 123456 # 自动使用当前会话平台
|
|
41
|
+
白名单.添加 123456 -p qq # 跨平台管理时显式指定平台
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
2. **改配置文件**:在 `fallbackGroups` 里写上群号后重载插件(仅在未注册数据库时生效)。
|
|
45
|
+
|
|
46
|
+
## 配置项
|
|
47
|
+
|
|
48
|
+
配置在 Koishi 控制台中分组展示,不满足条件的项会自动折叠。
|
|
49
|
+
|
|
50
|
+
### 群聊白名单设置
|
|
51
|
+
|
|
52
|
+
| 配置项 | 类型 | 默认值 | 说明 |
|
|
53
|
+
|--------|------|--------|------|
|
|
54
|
+
| `ignoreMessage` | `string` | `''` | 未命中白名单时的提示文本,留空表示完全静默 |
|
|
55
|
+
| `startupDelay` | `number` | `3` | 启动后等待多少秒再初始化白名单,期间拦截全部群聊请求 |
|
|
56
|
+
| `enableLog` | `boolean` | `false` | 是否在控制台以 `debug` 级别记录被拦截的群聊消息 |
|
|
57
|
+
|
|
58
|
+
> **为什么要等 3 秒**:数据库服务(如 `database-sqlite`)比插件启动晚约 1 秒才就绪,
|
|
59
|
+
> 插件若在那一刻就去读库,会读不到任何数据而误判「白名单为空」,甚至把整只机器人锁死。
|
|
60
|
+
> 因此启动闸门期内**一律拦截群聊请求**(fail-closed),等数据源就位后再初始化判定表。
|
|
61
|
+
> 私聊不受闸门影响,这段时间仍可私聊机器人执行管理指令。设为 `0` 可关闭等待。
|
|
62
|
+
|
|
63
|
+
### 管理指令权限设置
|
|
64
|
+
|
|
65
|
+
`authority`(内置权限等级)与管理员数组**两个开关独立生效,满足其一即可**使用管理指令。
|
|
66
|
+
|
|
67
|
+
| 配置项 | 类型 | 默认值 | 说明 |
|
|
68
|
+
|--------|------|--------|------|
|
|
69
|
+
| `authorityCheck` | `boolean` | `true` | 启用内置 authority 检查 |
|
|
70
|
+
| `authorityLevel` | `number` | `3` | 最低 authority 等级。Koishi 默认用户等级为 1,因此默认只有手动提权过的人能用 |
|
|
71
|
+
| `adminCheck` | `boolean` | `true` | 启用管理员数组检查 |
|
|
72
|
+
| `adminList` | `string[]` | `[]` | 管理员用户 ID 列表(跨平台匹配) |
|
|
73
|
+
|
|
74
|
+
### 无数据库模式白名单
|
|
75
|
+
|
|
76
|
+
仅在未注册 `database` 服务时生效,支持两种写法:
|
|
77
|
+
|
|
78
|
+
| 配置项 | 类型 | 默认值 | 说明 |
|
|
79
|
+
|--------|------|--------|------|
|
|
80
|
+
| `fallbackGroups` | `string[]` | `[]` | `'onebot:123456'` 限定平台;`'123456'` 对所有平台生效 |
|
|
81
|
+
|
|
82
|
+
> 该模式下指令增删不会生效,管理指令会明确提示原因。
|
|
83
|
+
|
|
84
|
+
### 私聊设置
|
|
85
|
+
|
|
86
|
+
| 配置项 | 类型 | 默认值 | 说明 |
|
|
87
|
+
|--------|------|--------|------|
|
|
88
|
+
| `privateChat` | `'all' \| 'whitelist' \| 'forbidden'` | `'all'` | 私聊策略 |
|
|
89
|
+
| `privateWhitelist` | `string[]` | `[]` | 仅 `privateChat = 'whitelist'` 时展示 |
|
|
90
|
+
|
|
91
|
+
> 不推荐使用 `forbidden`:那样管理员就没法在私聊里添加群了。
|
|
92
|
+
|
|
93
|
+
### 入群提示设置
|
|
94
|
+
|
|
95
|
+
| 配置项 | 类型 | 默认值 | 说明 |
|
|
96
|
+
|--------|------|--------|------|
|
|
97
|
+
| `joinNoticeEnabled` | `boolean` | `true` | 被拉入未白名单的群时发送一次提示 |
|
|
98
|
+
| `joinNoticeText` | `string` | `本群尚未启用机器人服务,如需启用请联系管理员。` | 提示文本(静态文本,不支持占位符) |
|
|
99
|
+
|
|
100
|
+
### 群名刷新设置
|
|
101
|
+
|
|
102
|
+
| 配置项 | 类型 | 默认值 | 说明 |
|
|
103
|
+
|--------|------|--------|------|
|
|
104
|
+
| `groupNameRefreshDays` | `number` | `7` | 每隔多少天自动刷新一次群名称,`0` 表示不自动刷新 |
|
|
105
|
+
| `groupNameBatch` | `number` | `5` | 每次并发查询的群数量;NapCat 的群信息接口有频率限制,数值过大会被风控 |
|
|
106
|
+
|
|
107
|
+
> 群名只用于 `白名单.列表` 展示,**不参与拦截判定**,因此刷新失败不会影响白名单功能。
|
|
108
|
+
|
|
109
|
+
## 指令
|
|
110
|
+
|
|
111
|
+
主指令 `白名单`(英文 `whitelist`)。`authority` 与管理员数组任一满足即可使用。
|
|
112
|
+
|
|
113
|
+
> **子指令必须用点号调用**:Koishi 的指令解析按 `.` 逐段匹配(`Commander._resolve` 里 `key.split('.')`),
|
|
114
|
+
> 所以写法是 `白名单.添加 123456`,而不是 `白名单 添加 123456`。
|
|
115
|
+
> 中文名本身就是子指令名,因此下列三种写法等价:`白名单.添加` / `whitelist.添加` / `whitelist.add`。
|
|
116
|
+
|
|
117
|
+
| 子指令 | 别名 | 说明 |
|
|
118
|
+
|--------|------|------|
|
|
119
|
+
| `白名单.列表` | `白名单.list` / `白名单.ls` / `白名单.查看` | 列出当前平台白名单,`▶` 标记当前群 |
|
|
120
|
+
| `白名单.添加 <群号...>` | `白名单.add` | 批量添加,空格或逗号分隔 |
|
|
121
|
+
| `白名单.移除 <群号...>` | `白名单.remove` / `白名单.rm` / `白名单.delete` / `白名单.删除` | 批量移除 |
|
|
122
|
+
| `白名单.刷新` | `白名单.refresh` / `白名单.sync` / `白名单.同步群名` | 立刻重新拉取群名称 |
|
|
123
|
+
|
|
124
|
+
公共选项:
|
|
125
|
+
|
|
126
|
+
- `-p, --platform <name>`:指定平台,默认取当前会话所在平台(私聊里会尝试按唯一 bot 自动推断)
|
|
127
|
+
|
|
128
|
+
使用示例:
|
|
129
|
+
|
|
130
|
+
```text
|
|
131
|
+
白名单.列表
|
|
132
|
+
白名单.列表 -p qq
|
|
133
|
+
白名单.添加 123456 654321
|
|
134
|
+
白名单.添加 123456,654321
|
|
135
|
+
白名单.移除 123456
|
|
136
|
+
白名单.刷新
|
|
137
|
+
白名单.刷新 -p qq
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
批量操作会逐项回报结果:已添加 / 已存在跳过 / 写入失败 / 格式不正确的项分别统计。
|
|
141
|
+
|
|
142
|
+
## 群名称
|
|
143
|
+
|
|
144
|
+
OneBot 的群消息事件里**没有群名**(事件只带 `group_id`),群名必须额外调 `get_group_info`
|
|
145
|
+
才能拿到 —— 适配器把它接到了 `bot.getGuild()`/`bot.getChannel()` 上,这也是 `白名单.列表`
|
|
146
|
+
里群名显示的实现来源。官方 qq 适配器对此接口支持受限,取不到时会留空。
|
|
147
|
+
|
|
148
|
+
群名获取的三个时机:
|
|
149
|
+
|
|
150
|
+
1. **添加群时**:`白名单.添加` 会顺带抓一次群名写入数据库,列表直接读表,零额外开销;
|
|
151
|
+
2. **定时刷新**:按 `groupNameRefreshDays` 周期遍历所有平台的已入库群名并更新;
|
|
152
|
+
3. **手动刷新**:`白名单.刷新` 立刻执行一次,可用 `-p` 只刷新某个平台。
|
|
153
|
+
|
|
154
|
+
取不到名称时**保留原有值**,不会把已有数据清空;名称相同也不会产生多余的写库操作。
|
|
155
|
+
未注册数据库(配置模式)时没有可持久化的位置,群名改为列表展示时按需查询并缓存在内存中。
|
|
156
|
+
|
|
157
|
+
## 权限模型
|
|
158
|
+
|
|
159
|
+
管理指令的裁决**完全由插件自己完成**(`ensureAdmin`),Koishi 内置的 `authority` 权限被刻意设为 0
|
|
160
|
+
(永不拦截)—— 因为内置的 `authority:N` 会先挡掉「等级不足但在管理员数组里」的用户,
|
|
161
|
+
双通道设计就失效了。因此:
|
|
162
|
+
|
|
163
|
+
- 开启 `authorityCheck` 时,`authority ≥ authorityLevel` 即通过;
|
|
164
|
+
- 开启 `adminCheck` 时,用户 ID 在 `adminList` 中即通过;
|
|
165
|
+
- 两者满足其一即放行;两者都关闭时管理指令对所有人开放(会在控制台警告)。
|
|
166
|
+
|
|
167
|
+
## 外部同步
|
|
168
|
+
|
|
169
|
+
判定结果常驻内存,指向由本插件维护的四个时机更新:启动载入、指令增删、事件刷新、数据库接入。
|
|
170
|
+
|
|
171
|
+
如果通过控制台、数据库工具或其他插件**绕过本插件直接改库**,触发下面的事件即可让判定表立刻同步:
|
|
172
|
+
|
|
173
|
+
```ts
|
|
174
|
+
ctx.emit('group-whitelist/refresh') // 刷新所有平台
|
|
175
|
+
ctx.emit('group-whitelist/refresh', 'qq') // 只刷新某个平台
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
## 行为细节
|
|
179
|
+
|
|
180
|
+
- **拦截范围**:仅消息事件。入群等通知类事件不受白名单影响(否则就没法发入群提示了)。
|
|
181
|
+
- **静默语义**:未命中的群不仅不回复指令,连其他插件的 `ctx.on('message')` 监听器也不会触发。
|
|
182
|
+
- **判定时机**:拦截在 `Processor.attach` 收尾处完成,早于任何中间件与指令解析。
|
|
183
|
+
- **异常兜底**:判定过程或数据库查询出错时按**放行**处理,避免白名单故障导致整只机器人失声。
|
|
184
|
+
- **兜底防线**:`Session.prototype.execute` 会被包一层,拦截会话即便被其他插件主动调用也不会执行指令;插件卸载时自动还原。
|
|
185
|
+
|
|
186
|
+
## 已知限制
|
|
187
|
+
|
|
188
|
+
- 名单按平台隔离,跨平台批量管理必须用 `-p` 显式指定平台。
|
|
189
|
+
- 启动瞬间(数据库模式下的极短冷启动窗口)若白名单尚未载入,该条消息会被拦截而不是放行 —— 这是刻意选择的「宁可静默也不放行」。
|
|
190
|
+
- 白名单为空时该平台所有群聊都会被忽略。首次启用请用私聊管理指令添加群,或改配置后重载。
|