koishi-plugin-message-monitor
Version:
监控并自动撤回指定用户发送的图片/视频/文件/可执行文件/合并转发/链接/关键词
420 lines (358 loc) • 20.5 kB
JavaScript
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}`);
}
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')}`;
});
};