koishi-plugin-blacklist-online 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/lib/core.d.ts ADDED
@@ -0,0 +1,15 @@
1
+ import { Context, Session } from 'koishi';
2
+ import { PluginConfig } from './types';
3
+ export declare const sleep: (ms: number) => Promise<unknown>;
4
+ export declare function isUserAdmin(session: Session, config: PluginConfig, userId: string): Promise<boolean>;
5
+ export declare function syncBlacklist(ctx: Context, config: PluginConfig): Promise<void>;
6
+ export declare function queueRequest(ctx: Context, type: 'ADD' | 'REMOVE' | 'CANCEL', payload: any): Promise<any>;
7
+ export declare function processOfflineQueue(ctx: Context, config: PluginConfig): Promise<void>;
8
+ export declare function checkAndHandleUser(ctx: Context, config: PluginConfig, session: Session, userId: string): Promise<boolean>;
9
+ export declare function parseUserId(input: string): string;
10
+ export declare function scanGuild(ctx: Context, config: PluginConfig, bot: any, // 传入具体的 bot 实例
11
+ guildId: string): Promise<{
12
+ handled: number;
13
+ total: number;
14
+ error?: string;
15
+ }>;
package/lib/core.js ADDED
@@ -0,0 +1,281 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.sleep = void 0;
4
+ exports.isUserAdmin = isUserAdmin;
5
+ exports.syncBlacklist = syncBlacklist;
6
+ exports.queueRequest = queueRequest;
7
+ exports.processOfflineQueue = processOfflineQueue;
8
+ exports.checkAndHandleUser = checkAndHandleUser;
9
+ exports.parseUserId = parseUserId;
10
+ exports.scanGuild = scanGuild;
11
+ const koishi_1 = require("koishi");
12
+ const node_crypto_1 = require("node:crypto");
13
+ const logger = new koishi_1.Logger('blacklist-online');
14
+ const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
15
+ exports.sleep = sleep;
16
+ // 全局锁:防止队列处理并发重入
17
+ let isProcessingQueue = false;
18
+ // --- 1. 权限判断 ---
19
+ async function isUserAdmin(session, config, userId) {
20
+ if (!session.guildId)
21
+ return false;
22
+ try {
23
+ const member = await session.bot.getGuildMember(session.guildId, userId);
24
+ if (!member)
25
+ return false;
26
+ // 统一转小写比对
27
+ const allowedRoles = (config.adminRoles || ['owner', 'admin']).map(r => r.toLowerCase());
28
+ const userRoles = [...(member.roles || [])].map(r => r.toLowerCase());
29
+ return userRoles.some(role => allowedRoles.includes(role));
30
+ }
31
+ catch (error) {
32
+ return false; // 报错视为无权限
33
+ }
34
+ }
35
+ // --- 2. 同步 ---
36
+ async function syncBlacklist(ctx, config) {
37
+ try {
38
+ const meta = await ctx.database.get('blacklist_meta', { key: 'sync_revision' });
39
+ const localRevision = meta[0]?.value || '';
40
+ const instanceMeta = await ctx.database.get('blacklist_meta', { key: 'instance_uuid' });
41
+ const instanceId = instanceMeta[0]?.value;
42
+ logger.debug(`🔄 同步开始 (本地版本: ${localRevision || 'INIT'})`);
43
+ // 发起同步请求
44
+ // 强制使用 HTTPS 应该在 config.remoteApiUrl 配置中体现
45
+ const response = await ctx.http.post(`${config.remoteApiUrl}/sync`, {
46
+ revision: localRevision,
47
+ instanceId: instanceId
48
+ }, {
49
+ headers: { Authorization: `Bearer ${config.apiToken}` },
50
+ timeout: 10000
51
+ });
52
+ const { strategy, newRevision, data } = response;
53
+ if (strategy === 'up-to-date') {
54
+ logger.debug('✅ 黑名单已是最新');
55
+ return;
56
+ }
57
+ if (strategy === 'full_replace') {
58
+ logger.info(`📥 全量同步 -> ${newRevision} (条数: ${data.length})`);
59
+ await ctx.database.remove('blacklist_users', {});
60
+ // 分批写入防报错
61
+ const batchSize = 100;
62
+ for (let i = 0; i < data.length; i += batchSize) {
63
+ await ctx.database.upsert('blacklist_users', data.slice(i, i + batchSize));
64
+ }
65
+ }
66
+ else if (strategy === 'incremental') {
67
+ logger.info(`📥 增量同步 -> ${newRevision}`);
68
+ if (data.upserts?.length)
69
+ await ctx.database.upsert('blacklist_users', data.upserts);
70
+ if (data.deletes?.length)
71
+ await ctx.database.remove('blacklist_users', { userId: data.deletes });
72
+ }
73
+ await ctx.database.upsert('blacklist_meta', [{ key: 'sync_revision', value: newRevision }]);
74
+ logger.info('✅ 同步完成');
75
+ }
76
+ catch (error) {
77
+ logger.warn(`❌ 同步失败: ${error.message || error}`);
78
+ }
79
+ }
80
+ // --- 3. 队列入队 ---
81
+ async function queueRequest(ctx, type, payload) {
82
+ const requestId = payload.requestId || (0, node_crypto_1.randomUUID)();
83
+ payload.requestId = requestId;
84
+ await ctx.database.create('blacklist_request_queue', {
85
+ id: requestId,
86
+ type,
87
+ payload,
88
+ createdAt: new Date(),
89
+ retryCount: 0
90
+ });
91
+ return requestId;
92
+ }
93
+ // --- 4. 离线队列处理 ---
94
+ async function processOfflineQueue(ctx, config) {
95
+ if (isProcessingQueue)
96
+ return; // 锁:防止重入
97
+ isProcessingQueue = true;
98
+ try {
99
+ // 每次处理 10 条,避免堵塞过久
100
+ const queue = await ctx.database.get('blacklist_request_queue', {}, { limit: 10, sort: { createdAt: 'asc' } });
101
+ if (queue.length === 0)
102
+ return;
103
+ const instanceMeta = await ctx.database.get('blacklist_meta', { key: 'instance_uuid' });
104
+ const instanceId = instanceMeta[0]?.value;
105
+ logger.info(`📤 处理离线队列 (积压: ${queue.length})`);
106
+ for (const item of queue) {
107
+ // 死信检测:超过 5 次重试失败,移除并记录日志
108
+ if (item.retryCount > 5) {
109
+ logger.warn(`🚨 请求 ${item.id} (${item.type}) 成为死信 (Retry > 5),已丢弃。Payload: ${JSON.stringify(item.payload)}`);
110
+ await ctx.database.remove('blacklist_request_queue', { id: item.id });
111
+ continue;
112
+ }
113
+ try {
114
+ await ctx.http.post(`${config.remoteApiUrl}/applications`, {
115
+ ...item.payload,
116
+ instanceId,
117
+ isOfflineRetry: true
118
+ }, {
119
+ headers: { Authorization: `Bearer ${config.apiToken}` },
120
+ timeout: 5000
121
+ });
122
+ await ctx.database.remove('blacklist_request_queue', { id: item.id });
123
+ logger.info(`✅ 离线请求 ${item.id} 同步成功`);
124
+ }
125
+ catch (error) {
126
+ const isNetworkError = error.code === 'ECONNABORTED' || error.code === 'ENOTFOUND' || !error.response;
127
+ if (isNetworkError) {
128
+ // 网络问题:单纯保留,不增加死信计数(或者增加得慢一点),等待网络恢复
129
+ logger.debug(`离线请求 ${item.id} 网络失败,等待重连。`);
130
+ }
131
+ else {
132
+ // 业务错误 (400/500):增加重试计数
133
+ logger.warn(`离线请求 ${item.id} 业务报错: ${error.message}`);
134
+ await ctx.database.set('blacklist_request_queue', { id: item.id }, {
135
+ retryCount: item.retryCount + 1
136
+ });
137
+ }
138
+ }
139
+ }
140
+ }
141
+ catch (err) {
142
+ logger.error(`队列处理发生未知异常: ${err}`);
143
+ }
144
+ finally {
145
+ isProcessingQueue = false; // 释放锁
146
+ }
147
+ }
148
+ // --- 5. 用户检查核心 ---
149
+ async function checkAndHandleUser(ctx, config, session, userId) {
150
+ if (!session.guildId)
151
+ return false;
152
+ const guildSettings = await ctx.database.get('blacklist_guild_settings', { guildId: session.guildId });
153
+ const mode = guildSettings[0]?.mode || config.defaultGuildMode;
154
+ if (mode === 'off')
155
+ return false;
156
+ // 1. 本地白名单 (最高优先级)
157
+ const protectedSet = new Set(config.protectedUsers || []);
158
+ if (protectedSet.has(userId))
159
+ return false;
160
+ // 2. 查库
161
+ const entries = await ctx.database.get('blacklist_users', { userId, disabled: false });
162
+ if (entries.length === 0)
163
+ return false;
164
+ const entry = entries[0];
165
+ const reason = entry.reason || 'QQ号黑名单';
166
+ // 3. 查管理员
167
+ if (await isUserAdmin(session, config, userId)) {
168
+ logger.info(`🛡️ 跳过黑名单管理员 ${userId}`);
169
+ return false;
170
+ }
171
+ logger.info(`🎯 [群: ${session.guildId}] 发现黑名单用户: ${userId} - 原因: ${reason}`);
172
+ // 获取显示名
173
+ let displayName = userId;
174
+ try {
175
+ const member = await session.bot.getGuildMember(session.guildId, userId);
176
+ displayName = member.nick || member.user?.name || userId;
177
+ }
178
+ catch {
179
+ }
180
+ // 执行通知
181
+ if (mode === 'notify' || mode === 'both') {
182
+ const tpl = mode === 'notify' ? config.adminNotifyMessage : config.kickNotifyMessage;
183
+ const msg = tpl
184
+ .replace('{user}', displayName)
185
+ .replace('{userId}', userId)
186
+ .replace('{reason}', reason)
187
+ .replace('{guild}', session.guildId);
188
+ // 引用回复
189
+ await session.send(session.messageId ? (0, koishi_1.h)('quote', { id: session.messageId }) + msg : msg);
190
+ }
191
+ // 执行踢出
192
+ let kicked = false;
193
+ if (mode === 'kick' || mode === 'both') {
194
+ for (let i = 0; i < config.retryAttempts; i++) {
195
+ try {
196
+ await session.bot.kickGuildMember(session.guildId, userId);
197
+ kicked = true;
198
+ logger.info(`✅ [群: ${session.guildId}] 成功踢出: ${userId}`);
199
+ break;
200
+ }
201
+ catch (e) {
202
+ if (i < config.retryAttempts - 1)
203
+ await (0, exports.sleep)(config.retryDelay);
204
+ else {
205
+ const failMsg = config.kickFailMessage.replace('{user}', displayName).replace('{reason}', String(e));
206
+ await session.send(failMsg);
207
+ }
208
+ }
209
+ }
210
+ // 踢出后验证 (可选)
211
+ if (kicked && config.verifyKickResult) {
212
+ await (0, exports.sleep)(2000);
213
+ try {
214
+ await session.bot.getGuildMember(session.guildId, userId);
215
+ // 如果还能获取到,说明没踢掉
216
+ logger.warn(`⚠️ [群: ${session.guildId}] 踢出验证失败,用户仍在群内: ${userId}`);
217
+ }
218
+ catch {
219
+ // 获取不到说明踢出成功
220
+ }
221
+ }
222
+ }
223
+ return kicked;
224
+ }
225
+ function parseUserId(input) {
226
+ if (!input)
227
+ return "";
228
+ const atMatch = input.match(/<at id="([^"]+)"\/>/);
229
+ if (atMatch)
230
+ input = atMatch[1];
231
+ if (input.includes(':'))
232
+ return input.split(':')[1];
233
+ return input;
234
+ }
235
+ // 通用的群组扫描函数
236
+ async function scanGuild(ctx, config, bot, // 传入具体的 bot 实例
237
+ guildId) {
238
+ try {
239
+ // 1. 获取群成员
240
+ const members = await bot.getGuildMemberList(guildId);
241
+ // 2. 获取本地黑名单缓存
242
+ const blacklist = await ctx.database.get('blacklist_users', { disabled: false });
243
+ const blacklistSet = new Set(blacklist.map(b => b.userId));
244
+ // 3. 筛选目标 (内存操作,极快)
245
+ const targets = members.data.filter((m) => {
246
+ if (!m.user?.id)
247
+ return false;
248
+ if (config.skipBotMembers && m.user.isBot)
249
+ return false;
250
+ return blacklistSet.has(m.user.id);
251
+ });
252
+ if (targets.length === 0)
253
+ return { handled: 0, total: 0 };
254
+ // 4. 构造伪造的 Session 用于复用 checkAndHandleUser 逻辑
255
+ // 注意:checkAndHandleUser 内部依赖 session.send 发送通知
256
+ // 全局扫描时可能不需要每踢一个人都发消息,或者需要构造一个静默的 session
257
+ const fakeSession = bot.session({
258
+ type: 'message',
259
+ guildId,
260
+ user: { id: bot.selfId }, // 模拟机器人自己
261
+ });
262
+ let handled = 0;
263
+ const BATCH_SIZE = 5;
264
+ // 5. 分批执行
265
+ for (let i = 0; i < targets.length; i += BATCH_SIZE) {
266
+ const batch = targets.slice(i, i + BATCH_SIZE);
267
+ await Promise.all(batch.map(async (m) => {
268
+ if (m.user?.id) {
269
+ // 调用核心处理逻辑
270
+ const result = await checkAndHandleUser(ctx, config, fakeSession, m.user.id);
271
+ if (result)
272
+ handled++;
273
+ }
274
+ }));
275
+ }
276
+ return { handled, total: targets.length };
277
+ }
278
+ catch (error) {
279
+ return { handled: 0, total: 0, error: String(error) };
280
+ }
281
+ }
package/lib/index.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ import { Context, Schema } from 'koishi';
2
+ import { PluginConfig } from './types';
3
+ export declare const name = "blacklist-online";
4
+ export declare const inject: string[];
5
+ export declare const usage = "\n## \u529F\u80FD\u8BF4\u660E\n\u4E00\u4E2A\u5F3A\u5927\u7684\u3001\u57FA\u4E8E\u6570\u636E\u5E93\u7684\u7FA4\u7EC4\u9ED1\u540D\u5355\u7BA1\u7406\u63D2\u4EF6\u3002\n- **\u6570\u636E\u5E93\u9A71\u52A8**: QQ\u53F7\u9ED1\u540D\u5355\u5B58\u50A8\u5728\u6570\u636E\u5E93\u4E2D\uFF0C\u53EF\u901A\u8FC7\u6307\u4EE4\u52A8\u6001\u589E\u5220\uFF0C\u652F\u6301\u64CD\u4F5C\u8FFD\u6EAF\u3002\n- **\u53D7\u4FDD\u62A4\u540D\u5355**: \u914D\u7F6E\u4E2D\u7684\u7528\u6237\u65E0\u6CD5\u88AB\u62C9\u9ED1\u3002\n- **\u7533\u8BF7/\u5BA1\u6279\u6D41\u7A0B**: \u666E\u901A\u7FA4\u7BA1\u7406\u53EF\u63D0\u4EA4\u62C9\u9ED1\u7533\u8BF7\uFF0C\u7531\u673A\u5668\u4EBA\u7BA1\u7406\u5458\u5BA1\u6279\uFF0C\u6240\u6709\u6D41\u7A0B\u81EA\u52A8\u5316\u901A\u77E5\u3002\n- **\u5206\u7FA4\u7BA1\u7406**: \u53EF\u4E3A\u6BCF\u4E2A\u7FA4\u72EC\u7ACB\u8BBE\u7F6E\u9ED1\u540D\u5355\u5904\u7406\u6A21\u5F0F\u3002\n- **\u6635\u79F0\u9ED1\u540D\u5355**: \u652F\u6301\u914D\u7F6E\u9759\u6001\u7684\u6635\u79F0\u5173\u952E\u8BCD\u9ED1\u540D\u5355\uFF0C\u5E76\u6709\u603B\u5F00\u5173\u63A7\u5236\u3002\n- **\u5165\u7FA4\u626B\u63CF**: \u53EF\u914D\u7F6E\u673A\u5668\u4EBA\u5728\u52A0\u5165\u65B0\u7FA4\u7EC4\u65F6\uFF0C\u81EA\u52A8\u626B\u63CF\u7FA4\u5185\u73B0\u6709\u6210\u5458\u3002\n- **\u5EF6\u8FDF\u68C0\u67E5**: \u65B0\u6210\u5458\u52A0\u5165\u540E\uFF0C\u53EF\u914D\u7F6E\u5EF6\u8FDF\u53CA\u5468\u671F\u6027\u6635\u79F0\u68C0\u67E5\uFF0C\u9632\u6B62\u5176\u4FEE\u6539\u6635\u79F0\u7ED5\u8FC7\u68C0\u6D4B\u3002\n- **\u81EA\u52A8\u62D2\u7EDD**: \u81EA\u52A8\u62D2\u7EDD\u6570\u636E\u5E93\u9ED1\u540D\u5355\u7528\u6237\u7684\u52A0\u7FA4\u7533\u8BF7\u3002\n- **\u624B\u52A8\u626B\u63CF**: \u63D0\u4F9B\u6307\u4EE4\u624B\u52A8\u626B\u63CF\u5F53\u524D\u6216\u5168\u90E8\u7FA4\u7EC4\u3002\n- **\u6743\u9650\u63A7\u5236**: \u6240\u6709\u6307\u4EE4\u5747\u6709\u6743\u9650\u7B49\u7EA7\u63A7\u5236\u3002\n";
6
+ export declare const Config: Schema<PluginConfig>;
7
+ export declare function apply(ctx: Context, config: PluginConfig): void;
package/lib/index.js ADDED
@@ -0,0 +1,257 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Config = exports.usage = exports.inject = exports.name = void 0;
4
+ exports.apply = apply;
5
+ const koishi_1 = require("koishi");
6
+ const node_crypto_1 = require("node:crypto"); // 【修复】使用原生 crypto
7
+ const core_1 = require("./core");
8
+ exports.name = 'blacklist-online';
9
+ exports.inject = ['database', 'http'];
10
+ exports.usage = `
11
+ ## 功能说明
12
+ 一个强大的、基于数据库的群组黑名单管理插件。
13
+ - **数据库驱动**: QQ号黑名单存储在数据库中,可通过指令动态增删,支持操作追溯。
14
+ - **受保护名单**: 配置中的用户无法被拉黑。
15
+ - **申请/审批流程**: 普通群管理可提交拉黑申请,由机器人管理员审批,所有流程自动化通知。
16
+ - **分群管理**: 可为每个群独立设置黑名单处理模式。
17
+ - **昵称黑名单**: 支持配置静态的昵称关键词黑名单,并有总开关控制。
18
+ - **入群扫描**: 可配置机器人在加入新群组时,自动扫描群内现有成员。
19
+ - **延迟检查**: 新成员加入后,可配置延迟及周期性昵称检查,防止其修改昵称绕过检测。
20
+ - **自动拒绝**: 自动拒绝数据库黑名单用户的加群申请。
21
+ - **手动扫描**: 提供指令手动扫描当前或全部群组。
22
+ - **权限控制**: 所有指令均有权限等级控制。
23
+ `;
24
+ // --- Schema 定义 ---
25
+ exports.Config = koishi_1.Schema.intersect([
26
+ koishi_1.Schema.object({
27
+ remoteApiUrl: koishi_1.Schema.string().required().description('远程黑名单中心 API 地址 (建议 HTTPS)'),
28
+ apiToken: koishi_1.Schema.string().role('secret').required().description('API 访问令牌'),
29
+ adminRoles: koishi_1.Schema.array(String).default(['owner', 'admin']).description('管理员角色名 (不区分大小写)'),
30
+ protectedUsers: koishi_1.Schema.array(String).role('table').description('本地受保护用户 (白名单)'),
31
+ defaultGuildMode: koishi_1.Schema.union([
32
+ koishi_1.Schema.const('off').description('关闭'),
33
+ koishi_1.Schema.const('notify').description('仅通知'),
34
+ koishi_1.Schema.const('kick').description('仅踢出'),
35
+ koishi_1.Schema.const('both').description('通知并踢出'),
36
+ ]).default('off').description('新群组默认模式'),
37
+ }).description('核心设置'),
38
+ koishi_1.Schema.object({
39
+ enableAutoReject: koishi_1.Schema.boolean().default(true).description("自动拒绝加群申请"),
40
+ skipBotMembers: koishi_1.Schema.boolean().default(true).description("跳过其他机器人"),
41
+ retryAttempts: koishi_1.Schema.number().default(3).description("踢人重试次数"),
42
+ retryDelay: koishi_1.Schema.number().default(2000).description("重试间隔(ms)"),
43
+ verifyKickResult: koishi_1.Schema.boolean().default(true).description("验证踢出结果"),
44
+ }).description('高级行为'),
45
+ koishi_1.Schema.object({
46
+ rejectionMessage: koishi_1.Schema.string().default('您的账号存在安全风险。').description("拒绝申请理由"),
47
+ adminNotifyMessage: koishi_1.Schema.string().role('textarea').default('检测到黑名单用户 {user} ({userId})。\n原因: {reason}').description('通知模式模板'),
48
+ kickNotifyMessage: koishi_1.Schema.string().role('textarea').default('正在移除黑名单用户 {user} ({userId})...\n原因: {reason}').description('踢出模式模板'),
49
+ kickFailMessage: koishi_1.Schema.string().role('textarea').default('⚠️ 无法踢出用户 {user}。\n错误: {reason}').description('失败通知'),
50
+ autoRejectNotifyMessage: koishi_1.Schema.string().role('textarea').default('🚫 已自动拒绝黑名单用户 {user} ({userId})。').description('自动拒绝通知'),
51
+ }).description('消息模板'),
52
+ ]);
53
+ function apply(ctx, config) {
54
+ const logger = ctx.logger('blacklist-online');
55
+ // 1. 扩展数据库
56
+ ctx.model.extend('blacklist_users', {
57
+ userId: 'string', reason: 'string', disabled: { type: 'boolean', initial: false }, updatedAt: 'timestamp'
58
+ }, { primary: 'userId' });
59
+ ctx.model.extend('blacklist_request_queue', {
60
+ id: 'string', type: 'string', payload: 'json', createdAt: 'timestamp', retryCount: 'unsigned'
61
+ }, { primary: 'id' });
62
+ ctx.model.extend('blacklist_meta', {
63
+ key: 'string', value: 'string'
64
+ }, { primary: 'key' });
65
+ ctx.model.extend('blacklist_guild_settings', {
66
+ guildId: 'string', mode: 'string'
67
+ }, { primary: 'guildId' });
68
+ // 2. 初始化
69
+ ctx.on('ready', async () => {
70
+ // 生成/读取 InstanceUUID
71
+ const entries = await ctx.database.get('blacklist_meta', { key: 'instance_uuid' });
72
+ if (entries.length === 0) {
73
+ const uuid = (0, node_crypto_1.randomUUID)();
74
+ await ctx.database.create('blacklist_meta', { key: 'instance_uuid', value: uuid });
75
+ logger.info(`✨ 初始化实例 UUID: ${uuid}`);
76
+ }
77
+ else {
78
+ logger.info(`📱 当前实例 UUID: ${entries[0].value}`);
79
+ }
80
+ // 启动时立即同步一次
81
+ (0, core_1.syncBlacklist)(ctx, config);
82
+ // 启动时处理积压队列
83
+ (0, core_1.processOfflineQueue)(ctx, config);
84
+ });
85
+ // 3. 定时任务
86
+ ctx.setInterval(() => (0, core_1.syncBlacklist)(ctx, config), 5 * koishi_1.Time.minute); // 每5分同步
87
+ ctx.setInterval(() => (0, core_1.processOfflineQueue)(ctx, config), koishi_1.Time.minute); // 每1分处理队列
88
+ // 4. 事件监听
89
+ // 监听加群申请 (自动拒绝)
90
+ ctx.on('guild-member-request', async (session) => {
91
+ if (!config.enableAutoReject || !session.userId)
92
+ return;
93
+ // 先查本地白名单
94
+ if (config.protectedUsers.includes(session.userId))
95
+ return;
96
+ // 查库
97
+ const entries = await ctx.database.get('blacklist_users', { userId: session.userId, disabled: false });
98
+ if (entries.length > 0) {
99
+ try {
100
+ await session.bot.handleGuildRequest(session.messageId, false, config.rejectionMessage);
101
+ logger.info(`🚫 自动拒绝: ${session.userId}`);
102
+ const msg = config.autoRejectNotifyMessage.replace('{user}', session.userId).replace('{userId}', session.userId);
103
+ await session.send(msg);
104
+ }
105
+ catch (e) {
106
+ logger.warn(`拒绝申请失败: ${e}`);
107
+ }
108
+ }
109
+ });
110
+ // 监听新成员加入
111
+ ctx.on('guild-member-added', async (session) => {
112
+ if (!session.userId || !session.guildId)
113
+ return;
114
+ if (config.skipBotMembers && session.author?.isBot)
115
+ return;
116
+ await (0, core_1.checkAndHandleUser)(ctx, config, session, session.userId);
117
+ });
118
+ // 5. 指令集
119
+ const cmd = ctx.command('blacklist', '黑名单管理');
120
+ // 子指令: 申请拉黑
121
+ cmd.subcommand('.request <user:string> <reason:text>', '申请拉黑', { authority: 2 })
122
+ .action(async ({ session }, user, reason) => {
123
+ if (!session?.guildId)
124
+ return '请在群组中使用。';
125
+ if (!reason)
126
+ return '请填写理由。';
127
+ const userId = (0, core_1.parseUserId)(user);
128
+ // 本地白名单前置拦截
129
+ if (config.protectedUsers.includes(userId))
130
+ return '❌ 该用户在本地白名单中,无法拉黑。';
131
+ const requestId = (0, node_crypto_1.randomUUID)();
132
+ const payload = {
133
+ requestId: requestId,
134
+ type: 'ADD',
135
+ applicantId: session.userId,
136
+ targetUserId: userId,
137
+ reason,
138
+ guildId: session.guildId,
139
+ timestamp: Date.now()
140
+ };
141
+ try {
142
+ await ctx.http.post(`${config.remoteApiUrl}/applications`, payload, {
143
+ headers: { Authorization: `Bearer ${config.apiToken}` }
144
+ });
145
+ return `✅ 申请已提交至云端,请等待审批。\n🆔 申请ID: ${requestId}\n(可使用 blacklist.cancel 指令撤回)`;
146
+ }
147
+ catch (e) {
148
+ // 失败入队
149
+ await (0, core_1.queueRequest)(ctx, 'ADD', payload);
150
+ return `⚠️ 无法连接服务器,申请已加入离线队列。\n🆔 申请ID: ${requestId}\n将在网络恢复后自动提交。(可使用 blacklist.cancel 指令撤回)`;
151
+ }
152
+ });
153
+ // 子指令: 申请删除
154
+ cmd.subcommand('.delete <user:string> <reason:text>', '申请移除', { authority: 2 })
155
+ .action(async ({ session }, user, reason) => {
156
+ if (!session)
157
+ return '无有效session。';
158
+ if (!reason)
159
+ return '请填写理由。';
160
+ const userId = (0, core_1.parseUserId)(user);
161
+ const requestId = (0, node_crypto_1.randomUUID)();
162
+ const payload = {
163
+ requestId: requestId,
164
+ type: 'REMOVE',
165
+ applicantId: session.userId,
166
+ targetUserId: userId,
167
+ reason,
168
+ timestamp: Date.now()
169
+ };
170
+ try {
171
+ await ctx.http.post(`${config.remoteApiUrl}/applications`, payload, {
172
+ headers: { Authorization: `Bearer ${config.apiToken}` }
173
+ });
174
+ return `✅ 移除申请已提交。\n🆔 申请ID: ${requestId}`;
175
+ }
176
+ catch (e) {
177
+ await (0, core_1.queueRequest)(ctx, 'REMOVE', payload);
178
+ return `⚠️ 网络故障,移除申请已加入离线队列。\n🆔 申请ID: ${requestId}`;
179
+ }
180
+ });
181
+ // 子指令: 撤回申请
182
+ cmd.subcommand('.cancel <uuid:string>', '撤回申请', { authority: 2 })
183
+ .action(async ({ session }, uuid) => {
184
+ if (!uuid)
185
+ return '请输入要撤回的申请 UUID。';
186
+ const payload = {
187
+ requestId: (0, node_crypto_1.randomUUID)(),
188
+ targetRequestId: uuid,
189
+ applicantId: session?.userId,
190
+ timestamp: Date.now()
191
+ };
192
+ try {
193
+ await ctx.http.post(`${config.remoteApiUrl}/applications/cancel`, payload, {
194
+ headers: { Authorization: `Bearer ${config.apiToken}` }
195
+ });
196
+ return `✅ 针对申请 ${uuid} 的撤回指令已发送。`;
197
+ }
198
+ catch (e) {
199
+ await (0, core_1.queueRequest)(ctx, 'CANCEL', payload);
200
+ return `⚠️ 网络故障,针对 ${uuid} 的撤回指令已加入离线队列。`;
201
+ }
202
+ });
203
+ // 子指令: 设置模式
204
+ cmd.subcommand('.mode <mode:string>', '设置当前群处理模式', { authority: 3 })
205
+ .action(async ({ session }, mode) => {
206
+ if (!session?.guildId)
207
+ return;
208
+ const valid = ['off', 'notify', 'kick', 'both'];
209
+ if (!valid.includes(mode))
210
+ return `无效模式。可用: ${valid.join(', ')}`;
211
+ await ctx.database.upsert('blacklist_guild_settings', [{
212
+ guildId: session.guildId,
213
+ mode: mode
214
+ }]);
215
+ return `当前群模式已设置为: ${mode}`;
216
+ });
217
+ const scanCmd = cmd.subcommand('.scan', '黑名单扫描', { authority: 3 });
218
+ // 扫描当前群
219
+ scanCmd.action(async ({ session }) => {
220
+ if (!session?.guildId)
221
+ return '请在群组中使用。';
222
+ session.send('🔍 开始扫描本群...');
223
+ const result = await (0, core_1.scanGuild)(ctx, config, session.bot, session.guildId);
224
+ if (result.error)
225
+ return `⚠️ 扫描出错: ${result.error}`;
226
+ return `✅ 扫描结束。发现目标 ${result.total} 人,成功处理 ${result.handled} 人。`;
227
+ });
228
+ // 扫描所有群
229
+ scanCmd.subcommand('.all', '扫描所有群组 (高负载)', { authority: 4 })
230
+ .action(async ({ session }) => {
231
+ session?.send('🚀 开始全局扫描,这可能需要一些时间...');
232
+ const logger = ctx.logger('blacklist-online');
233
+ let totalGuilds = 0;
234
+ let totalHandled = 0;
235
+ let processedGuilds = 0;
236
+ // 遍历所有机器人实例
237
+ for (const bot of ctx.bots) {
238
+ try {
239
+ const guilds = await bot.getGuildList();
240
+ totalGuilds += guilds.data.length;
241
+ for (const guild of guilds.data) {
242
+ // 逐个群扫描
243
+ const result = await (0, core_1.scanGuild)(ctx, config, bot, guild.id);
244
+ if (result.handled > 0) {
245
+ logger.info(`[全局扫描] 群 ${guild.id}: 处理 ${result.handled}/${result.total}`);
246
+ totalHandled += result.handled;
247
+ }
248
+ processedGuilds++;
249
+ }
250
+ }
251
+ catch (e) {
252
+ logger.warn(`Bot ${bot.selfId} 获取群列表失败: ${e}`);
253
+ }
254
+ }
255
+ return `✅ 全局扫描完成!\n共扫描群组: ${processedGuilds}/${totalGuilds}\n共处理黑名单用户: ${totalHandled} 人`;
256
+ });
257
+ }
package/lib/types.d.ts ADDED
@@ -0,0 +1,47 @@
1
+ export interface PluginConfig {
2
+ remoteApiUrl: string;
3
+ apiToken: string;
4
+ adminRoles: string[];
5
+ protectedUsers: string[];
6
+ defaultGuildMode: 'off' | 'notify' | 'kick' | 'both';
7
+ enableAutoReject: boolean;
8
+ skipBotMembers: boolean;
9
+ retryAttempts: number;
10
+ retryDelay: number;
11
+ verifyKickResult: boolean;
12
+ rejectionMessage: string;
13
+ adminNotifyMessage: string;
14
+ kickNotifyMessage: string;
15
+ kickFailMessage: string;
16
+ autoRejectNotifyMessage: string;
17
+ }
18
+ export interface BlacklistEntry {
19
+ userId: string;
20
+ reason: string;
21
+ operatorId?: string;
22
+ disabled: boolean;
23
+ updatedAt: Date;
24
+ }
25
+ export interface OfflineRequest {
26
+ id: string;
27
+ type: 'ADD' | 'REMOVE' | 'CANCEL';
28
+ payload: any;
29
+ createdAt: Date;
30
+ retryCount: number;
31
+ }
32
+ export interface MetaEntry {
33
+ key: string;
34
+ value: string;
35
+ }
36
+ export interface GuildSettings {
37
+ guildId: string;
38
+ mode: 'off' | 'notify' | 'kick' | 'both';
39
+ }
40
+ declare module 'koishi' {
41
+ interface Tables {
42
+ blacklist_users: BlacklistEntry;
43
+ blacklist_request_queue: OfflineRequest;
44
+ blacklist_meta: MetaEntry;
45
+ blacklist_guild_settings: GuildSettings;
46
+ }
47
+ }
package/lib/types.js ADDED
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "koishi-plugin-blacklist-online",
3
+ "description": "自用插件",
4
+ "version": "0.1.0",
5
+ "main": "lib/index.js",
6
+ "typings": "lib/index.d.ts",
7
+ "files": [
8
+ "lib",
9
+ "dist"
10
+ ],
11
+ "license": "MIT",
12
+ "keywords": [
13
+ "chatbot",
14
+ "koishi",
15
+ "plugin",
16
+ "auto-kick",
17
+ "blacklist",
18
+ "group-management",
19
+ "moderation",
20
+ "security"
21
+ ],
22
+ "peerDependencies": {
23
+ "koishi": "4.18.8"
24
+ }
25
+ }
package/readme.md ADDED
@@ -0,0 +1,5 @@
1
+ # koishi-plugin-blacklist-online
2
+
3
+ [![npm](https://img.shields.io/npm/v/koishi-plugin-blacklist-online?style=flat-square)](https://www.npmjs.com/package/koishi-plugin-blacklist-online)
4
+
5
+