UNPKG

koishi-plugin-message-monitor

Version:

监控并自动撤回指定用户发送的图片/视频/文件/可执行文件/合并转发/链接/关键词

422 lines (360 loc) 20.7 kB
const { Context, Schema } = require('koishi'); module.exports.name = 'message-monitor'; module.exports.using = ['database']; module.exports.Config = Schema.intersect([ Schema.object({ // 监测设置 }).description('监测设置'), Schema.object({ monitorGuilds: Schema.array(String).default([]).description('需要监测的群组ID列表 (留空则监测所有群组)'), monitorUsers: Schema.array(String).default([]).description('需要监测图片消息的用户ID列表 (输入0则监测所有用户)'), monitorFileUsers: Schema.array(String).default([]).description('需要监测文件消息的用户ID列表 (输入0则监测所有用户)'), monitorVideoUsers: Schema.array(String).default([]).description('需要监测视频消息的用户ID列表 (输入0则监测所有用户)'), monitorForwardUsers: Schema.array(String).default([]).description('需要监测转发消息的用户ID列表 (输入0则监测所有用户)'), monitorExecutableUsers: Schema.array(String).default([]).description('需要监测可执行文件的用户ID列表 (输入0则监测所有用户)'), monitorLinkUsers: Schema.array(String).default([]).description('需要监测链接消息的用户ID列表 (输入0则监测所有用户)'), illegalFileExtensions: Schema.array(String).default(['.exe', '.dll', '.bat', '.sh']).description('需要撤回的文件后缀名列表'), log_info: Schema.boolean().default(false).description('是否开启日志记录'), ignoreStickers: Schema.boolean().default(true).description('是否忽略表情图片'), enableMute: Schema.boolean().default(false).description('是否开启自动禁言'), muteThreshold: Schema.number().default(3).description('触发禁言的撤回次数'), muteTimeWindow: Schema.number().default(60).description('撤回次数统计时间窗口(秒)'), muteDuration: Schema.number().default(10).description('基础禁言时长 (分钟)'), enableProgressiveMute: Schema.boolean().default(false).description('是否开启阶梯式禁言'), progressiveMuteTimeWindow: Schema.number().default(24).description('阶梯式禁言的计算周期 (小时)'), }), Schema.object({ // 违禁词撤回 recallGuilds: Schema.array(String).default([]).description('需要进行违禁词撤回的群组ID列表 (留空则监测所有群组)'), enableKeywordRecall: Schema.boolean().default(false).description('是否开启违禁词撤回功能'), recallKeywords: Schema.array(String).default([]).description('需要撤回的违禁词列表'), enableKeywordMute: Schema.boolean().default(false).description('是否开启违禁词撤回后自动禁言'), keywordMuteThreshold: Schema.number().default(3).description('触发禁言的违禁词撤回次数'), keywordMuteTimeWindow: Schema.number().default(60).description('违禁词撤回次数统计时间窗口(秒)'), keywordMuteDuration: Schema.number().default(10).description('基础禁言时长 (分钟)'), enableKeywordProgressiveMute: Schema.boolean().default(false).description('是否开启阶梯式禁言'), keywordProgressiveMuteTimeWindow: Schema.number().default(24).description('阶梯式禁言的计算周期 (小时)'), }).description('违禁词撤回'), ]); module.exports.apply = (ctx, config) => { const logger = ctx.logger('message-monitor'); ctx.model.extend('recall_keyword', { id: 'unsigned', keyword: 'string', guildId: 'string', }, { primary: 'id', autoInc: true, }); ctx.model.extend('monitored_user', { id: 'unsigned', userId: 'string', guildId: 'string', monitorTypes: { type: 'json', initial: [] }, // ['image', 'file', 'forward', 'all'] }, { primary: 'id', autoInc: true, }); const userRecallHistory = new Map(); const userMuteHistory = new Map(); async function handleUserMessage(session) { const { userId, guildId, elements, channelId, messageId } = session; const dbUsers = await ctx.database.get('monitored_user', { userId, guildId }); const isGuildMonitored = config.monitorGuilds.length === 0 || config.monitorGuilds.includes(guildId); let monitoredByConfig = { image: (config.monitorUsers.includes('0') && isGuildMonitored) || config.monitorUsers.includes(userId), file: (config.monitorFileUsers.includes('0') && isGuildMonitored) || config.monitorFileUsers.includes(userId), video: (config.monitorVideoUsers.includes('0') && isGuildMonitored) || config.monitorVideoUsers.includes(userId), forward: (config.monitorForwardUsers.includes('0') && isGuildMonitored) || config.monitorForwardUsers.includes(userId), executable: (config.monitorExecutableUsers.includes('0') && isGuildMonitored) || config.monitorExecutableUsers.includes(userId), link: (config.monitorLinkUsers.includes('0') && isGuildMonitored) || config.monitorLinkUsers.includes(userId), }; let monitoredByDb = { image: false, file: false, video: false, forward: false, link: false }; if (dbUsers.length > 0) { const allDbTypes = dbUsers.reduce((acc, userRule) => { const types = userRule.monitorTypes || []; return [...acc, ...types]; }, []); const uniqueDbTypes = [...new Set(allDbTypes)]; if (uniqueDbTypes.includes('all')) { monitoredByDb.image = true; monitoredByDb.file = true; monitoredByDb.video = true; monitoredByDb.forward = true; monitoredByDb.link = true; } else { if (uniqueDbTypes.includes('image')) monitoredByDb.image = true; if (uniqueDbTypes.includes('file')) monitoredByDb.file = true; if (uniqueDbTypes.includes('video')) monitoredByDb.video = true; if (uniqueDbTypes.includes('forward')) monitoredByDb.forward = true; if (uniqueDbTypes.includes('link')) monitoredByDb.link = true; } } const finalMonitoringRules = { image: monitoredByConfig.image || monitoredByDb.image, file: monitoredByConfig.file || monitoredByDb.file, video: monitoredByConfig.video || monitoredByDb.video, forward: monitoredByConfig.forward || monitoredByDb.forward, executable: monitoredByConfig.executable || monitoredByDb.executable, link: monitoredByConfig.link || monitoredByDb.link, }; for (const element of elements) { let shouldRecall = false; let recallType = ''; if (finalMonitoringRules.image && ['img', 'image'].includes(element.type)) { if (config.ignoreStickers && element.attrs.summary) continue; shouldRecall = true; recallType = '图片'; } if (finalMonitoringRules.file && element.type === 'file') { shouldRecall = true; recallType = '文件'; } if (finalMonitoringRules.video && element.type === 'video') { shouldRecall = true; recallType = '视频'; } if (finalMonitoringRules.forward && element.type === 'forward') { shouldRecall = true; recallType = '转发'; } if (finalMonitoringRules.link && element.type === 'text') { // 检查文本中是否包含链接 const urlRegex = /(https?:\/\/[^\s]+)|(www\.[^\s]+\.[^\s]+)|([a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]*\.[a-zA-Z]{2,}[^\s]*)/g; if (urlRegex.test(element.attrs.content)) { shouldRecall = true; recallType = '链接'; } } if (finalMonitoringRules.executable && element.type === 'file') { const fileName = element.attrs.file; const fileExtension = fileName.substring(fileName.lastIndexOf('.')); if (config.illegalFileExtensions.includes(fileExtension)) { shouldRecall = true; recallType = `可执行文件(${fileExtension})`; } } const isRecallGuild = config.recallGuilds.length === 0 || config.recallGuilds.includes(guildId); if (config.enableKeywordRecall && isRecallGuild) { const keywords = config.recallKeywords.concat((await ctx.database.get('recall_keyword', { guildId })).map(k => k.keyword)); for (const keyword of keywords) { if (session.content.includes(keyword)) { try { await session.bot.deleteMessage(channelId, messageId); logger.info(`已撤回用户 ${userId} 包含违禁词“${keyword}”的消息。`); if (config.enableKeywordMute) { const now = Date.now(); const history = userRecallHistory.get(userId) || []; const recentRecalls = history.filter(time => now - time < config.keywordMuteTimeWindow * 1000); recentRecalls.push(now); if (recentRecalls.length >= config.keywordMuteThreshold) { let finalMuteDurationInMinutes = config.keywordMuteDuration; const muteNow = Date.now(); if (config.enableKeywordProgressiveMute) { const muteRecord = userMuteHistory.get(userId); let currentMuteCount = 1; if (muteRecord && (muteNow - muteRecord.timestamp) < config.keywordProgressiveMuteTimeWindow * 60 * 60 * 1000) { currentMuteCount = muteRecord.count + 1; } finalMuteDurationInMinutes = config.keywordMuteDuration * Math.pow(2, currentMuteCount - 1); userMuteHistory.set(userId, { count: currentMuteCount, timestamp: muteNow }); } try { await session.bot.muteGuildMember(guildId, userId, finalMuteDurationInMinutes * 60000); logger.info(`用户 ${userId} 因触发违禁词已被禁言 ${finalMuteDurationInMinutes} 分钟。`); userRecallHistory.set(userId, []); } catch (muteError) { logger.error(`违禁词禁言用户 ${userId} 失败: ${muteError.message}`); } } else { userRecallHistory.set(userId, recentRecalls); } } return; } catch (error) { logger.error(`违禁词撤回失败: ${error.message}`); } } } } if (shouldRecall) { try { await session.bot.deleteMessage(channelId, messageId); logger.info(`已撤回用户 ${userId} 发送的${recallType}消息。`); if (config.enableMute) { const now = Date.now(); const history = userRecallHistory.get(userId) || []; const recentRecalls = history.filter(time => now - time < config.muteTimeWindow * 1000); recentRecalls.push(now); if (recentRecalls.length >= config.muteThreshold) { let finalMuteDurationInMinutes = config.muteDuration; const muteNow = Date.now(); if (config.enableProgressiveMute) { const muteRecord = userMuteHistory.get(userId); let currentMuteCount = 1; if (muteRecord && (muteNow - muteRecord.timestamp) < config.progressiveMuteTimeWindow * 60 * 60 * 1000) { currentMuteCount = muteRecord.count + 1; } finalMuteDurationInMinutes = config.muteDuration * Math.pow(2, currentMuteCount - 1); userMuteHistory.set(userId, { count: currentMuteCount, timestamp: muteNow }); } try { await session.bot.muteGuildMember(guildId, userId, finalMuteDurationInMinutes * 60000); logger.info(`用户 ${userId} 因频繁撤回消息已被禁言 ${finalMuteDurationInMinutes} 分钟。`); userRecallHistory.set(userId, []); } catch (muteError) { logger.error(`禁言用户 ${userId} 失败: ${muteError.message}`); } } else { userRecallHistory.set(userId, recentRecalls); } } return; } catch (error) { logger.error(`撤回用户 ${userId}${recallType}消息失败: ${error.message}`); } } } } ctx.on('message', async (session) => { if (config.log_info) { logger.info(`收到用户 ${session.userId} 的消息: ${session.content}`); } // 忽略机器人自身发送的消息,避免如 recall.list 输出被误撤回 if (session.userId === session.selfId) return; if (config.monitorGuilds.length > 0 && !config.monitorGuilds.includes(session.guildId)) { return; } await handleUserMessage(session); }); const monitorCmd = ctx.command('monitor', '消息监控指令'); monitorCmd .subcommand('.add <user> [...types]', '添加或更新监控用户。可指定多种类型(图片、文件、视频、转发),不指定则默认为所有类型。', { authority: 2 }) .option('guild', '-g <guildId:string>', { fallback: '' }) .action(async ({ session, options }, user, ...types) => { if (!user) return '用法: monitor.add @用户 [图片/文件/转发]。'; const userId = user.replace(/<at id="(.*?)"\/>/, '$1'); const guildId = options.guild || session.guildId; const typeMap = { '图片': 'image', '文件': 'file', '视频': 'video', '转发': 'forward', '链接': 'link' }; let newTypes = types.map(t => typeMap[t]).filter(Boolean); if (newTypes.length === 0) newTypes = ['all']; const existingUser = await ctx.database.get('monitored_user', { userId, guildId }); if (existingUser.length > 0) { let oldTypes = existingUser[0].monitorTypes || []; // 如果新类型含 'all',或旧类型含 'all',则直接设置为 'all' if (newTypes.includes('all') || oldTypes.includes('all')) { await ctx.database.set('monitored_user', { id: existingUser[0].id }, { monitorTypes: ['all'] }); return `已更新用户 ${userId} 的监控类型为: 所有。`; } const mergedTypes = [...new Set([...oldTypes, ...newTypes])]; await ctx.database.set('monitored_user', { id: existingUser[0].id }, { monitorTypes: mergedTypes }); return `已为用户 ${userId} 添加新的监控类型。当前监控类型: ${mergedTypes.join(', ')}。`; } else { await ctx.database.create('monitored_user', { userId, guildId, monitorTypes: newTypes }); let response = `已将用户 ${userId} 添加到监控列表,监控类型: ${newTypes.join(', ')}。`; if (config.monitorGuilds && !config.monitorGuilds.includes(guildId)) { config.monitorGuilds.push(guildId); response += `\n已自动将当前群组 ${guildId} 加入监控列表。`; } return response; } }); monitorCmd .subcommand('.remove <user> [...types]', '移除监控用户的指定监控类型,不指定类型则移除整个用户。', { authority: 2 }) .option('guild', '-g <guildId:string>', { fallback: '' }) .action(async ({ session, options }, user, ...types) => { if (!user) return '用法: monitor.remove @用户 [图片/文件/转发]。'; const userId = user.replace(/<at id="(.*?)"\/>/, '$1'); const guildId = options.guild || session.guildId; const existingUser = await ctx.database.get('monitored_user', { userId, guildId }); if (existingUser.length === 0) return '数据库中未找到该监控用户。'; if (types.length === 0) { await ctx.database.remove('monitored_user', { id: existingUser[0].id }); return `已将用户 ${userId} 从数据库监控列表移除。`; } const typeMap = { '图片': 'image', '文件': 'file', '视频': 'video', '转发': 'forward', '链接': 'link' }; const typesToRemove = types.map(t => typeMap[t]).filter(Boolean); const oldTypes = existingUser[0].monitorTypes || []; const newTypes = oldTypes.filter(t => !typesToRemove.includes(t) && t !== 'all'); // 同时移除all if (newTypes.length === 0) { await ctx.database.remove('monitored_user', { id: existingUser[0].id }); return `用户 ${userId} 的所有监控类型已被移除,已从数据库删除该用户。`; } await ctx.database.set('monitored_user', { id: existingUser[0].id }, { monitorTypes: newTypes }); return `已为用户 ${userId} 移除指定的监控类型。剩余监控类型: ${newTypes.join(', ')}。`; }); monitorCmd .subcommand('.list', '查看本群监控列表', { authority: 1 }) .action(async ({ session }) => { const guildId = session.guildId; const guildMembers = await session.bot.getGuildMemberList(guildId); const guildMemberIds = new Set(guildMembers.data.map(m => m.user.id)); const dbUsers = await ctx.database.get('monitored_user', { guildId }); const fromConfig = [...new Set([...config.monitorUsers, ...config.monitorFileUsers, ...config.monitorVideoUsers, ...config.monitorForwardUsers])]; const fromDb = dbUsers.map(u => u.userId); const allMonitoredUsers = [...new Set([...fromConfig, ...fromDb])]; const allUsersInGuild = allMonitoredUsers.filter(userId => guildMemberIds.has(userId)); if (allUsersInGuild.length === 0) { return '本群当前没有用户在监控列表中。'; } const userListPromises = allUsersInGuild.map(async (userId) => { try { const user = await session.bot.getUser(userId); const userName = user.name || user.nick || userId; const sources = []; let typeParts = []; if (config.monitorUsers.includes(userId)) typeParts.push('图片(配置)'); if (config.monitorFileUsers.includes(userId)) typeParts.push('文件(配置)'); if (config.monitorVideoUsers.includes(userId)) typeParts.push('视频(配置)'); if (config.monitorForwardUsers.includes(userId)) typeParts.push('转发(配置)'); if (config.monitorLinkUsers.includes(userId)) typeParts.push('链接(配置)'); const dbUser = dbUsers.find(u => u.userId === userId); if (dbUser) { const dbTypes = dbUser.monitorTypes || []; const typeMap = { 'all': '所有', 'image': '图片', 'file': '文件', 'video': '视频', 'forward': '转发', 'link': '链接' }; const dbTypeNames = dbTypes.map(t => typeMap[t] || t).join(', '); typeParts.push(`${dbTypeNames}(指令)`); } return `用户: ${userName} (类型: ${typeParts.join('; ')})`; } catch (error) { logger.warn(`获取用户 ${userId} 信息失败: ${error.message}`); return `用户ID: ${userId} (信息获取失败)`; } }); const userList = await Promise.all(userListPromises); return `本群监控列表:\n${userList.join('\n')}`; }); const recallCmd = ctx.command('recall', '违禁词撤回指令'); recallCmd .subcommand('.add <keyword:string>', '添加撤回违禁词', { authority: 2 }) .action(async ({ session }, keyword) => { if (!keyword) return '用法: 添加违禁词 {违禁词}'; const guildId = session.guildId; const existing = await ctx.database.get('recall_keyword', { keyword, guildId }); if (existing.length > 0) return '该违禁词已存在。'; await ctx.database.create('recall_keyword', { keyword, guildId }); return `已添加违禁词: ${keyword}`; }); recallCmd .subcommand('.remove <keyword:string>', '移除撤回违禁词', { authority: 2 }) .action(async ({ session }, keyword) => { if (!keyword) return '用法: 删除违禁词 {违禁词}'; const guildId = session.guildId; const result = await ctx.database.remove('recall_keyword', { keyword, guildId }); if (result.removed > 0) { return `已移除违禁词: ${keyword}`; } else { return '未找到该违禁词。'; } }); recallCmd .subcommand('.list', '查看违禁词列表', { authority: 1 }) .action(async ({ session }) => { const guildId = session.guildId; const keywords = await ctx.database.get('recall_keyword', { guildId }); const configKeywords = config.recallKeywords; const allKeywords = [...new Set([...configKeywords, ...keywords.map(k => k.keyword)])]; if (allKeywords.length === 0) return '当前没有设置任何违禁词。'; return `当前违禁词列表:\n${allKeywords.join('\n')}`; }); };