Add spam plugin. Add clean commands. Update Knub to 9.6.0.
This commit is contained in:
parent
ad6afdfac1
commit
7ded84b924
11 changed files with 539 additions and 85 deletions
11
package-lock.json
generated
11
package-lock.json
generated
|
@ -781,6 +781,11 @@
|
||||||
"integrity": "sha1-2wQ1IcldfjA/2PNFvtwzSc+wcp4=",
|
"integrity": "sha1-2wQ1IcldfjA/2PNFvtwzSc+wcp4=",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"emoji-regex": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-lnvttkzAlYW8WpFPiStPWyd/YdS02cFsYwXwWqnbKY43fMgUeUx+vzW1Zaozu34n4Fm7sxygi8+SEL6dcks/hQ=="
|
||||||
|
},
|
||||||
"eris": {
|
"eris": {
|
||||||
"version": "0.8.6",
|
"version": "0.8.6",
|
||||||
"resolved": "https://registry.npmjs.org/eris/-/eris-0.8.6.tgz",
|
"resolved": "https://registry.npmjs.org/eris/-/eris-0.8.6.tgz",
|
||||||
|
@ -2176,9 +2181,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"knub": {
|
"knub": {
|
||||||
"version": "9.4.13",
|
"version": "9.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/knub/-/knub-9.4.13.tgz",
|
"resolved": "https://registry.npmjs.org/knub/-/knub-9.6.0.tgz",
|
||||||
"integrity": "sha512-4m5IMbctg1xAe6DoYSkk1jdQNWpUb6ZkjKxJPxHEmbXtIZm11qt/AmIcASgG5pvZOM7Q/PnsbLfRyzlUTbvOLA==",
|
"integrity": "sha512-+a/woh8WnSxBkflNjCjvfGASadz80o/0Mot81K9sr8BvcITzeDtoOBaxzeiwCb5NWNtYz/Qp9M7ZZ6Jr5U45bg==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"escape-string-regexp": "^1.0.5",
|
"escape-string-regexp": "^1.0.5",
|
||||||
"js-yaml": "^3.9.1",
|
"js-yaml": "^3.9.1",
|
||||||
|
|
|
@ -28,11 +28,12 @@
|
||||||
"@types/moment-timezone": "^0.5.6",
|
"@types/moment-timezone": "^0.5.6",
|
||||||
"@types/node": "^8.0.50",
|
"@types/node": "^8.0.50",
|
||||||
"dotenv": "^4.0.0",
|
"dotenv": "^4.0.0",
|
||||||
|
"emoji-regex": "^7.0.0",
|
||||||
"eris": "^0.8.6",
|
"eris": "^0.8.6",
|
||||||
"escape-string-regexp": "^1.0.5",
|
"escape-string-regexp": "^1.0.5",
|
||||||
"humanize-duration": "^3.15.0",
|
"humanize-duration": "^3.15.0",
|
||||||
"knex": "^0.14.6",
|
"knex": "^0.14.6",
|
||||||
"knub": "^9.4.13",
|
"knub": "^9.6.0",
|
||||||
"lodash.at": "^4.6.0",
|
"lodash.at": "^4.6.0",
|
||||||
"lodash.difference": "^4.5.0",
|
"lodash.difference": "^4.5.0",
|
||||||
"lodash.intersection": "^4.4.0",
|
"lodash.intersection": "^4.4.0",
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
{
|
{
|
||||||
"MEMBER_WARN": "⚠️ **{member.user.username}#{member.user.discriminator}** (`{member.id}`) was warned by {mod.username}#{mod.discriminator}",
|
"MEMBER_WARN": "⚠️ **{member.user.username}#{member.user.discriminator}** (`{member.id}`) was warned by {mod.username}#{mod.discriminator}",
|
||||||
"MEMBER_MUTE": "🔇 **{member.user.username}#{member.user.discriminator}** (`{member.id}`) was muted by {mod.username}#{mod.discriminator}",
|
"MEMBER_MUTE": "🔇 **{member.user.username}#{member.user.discriminator}** (`{member.id}`) was muted by {mod.username}#{mod.discriminator}",
|
||||||
|
"MEMBER_MUTE_SPAM": "🔇 **{member.user.username}#{member.user.discriminator}** (`{member.id}`) was muted for spam in **#{channel.name}**: {description} (more than {limit} in {interval}s)",
|
||||||
"MEMBER_UNMUTE": "🔉 **{member.user.username}#{member.user.discriminator}** (`{member.id}`) was unmuted by {mod.username}#{mod.discriminator}",
|
"MEMBER_UNMUTE": "🔉 **{member.user.username}#{member.user.discriminator}** (`{member.id}`) was unmuted by {mod.username}#{mod.discriminator}",
|
||||||
"MEMBER_MUTE_EXPIRED": "🔉 **{member.user.username}#{member.user.discriminator}**'s mute expired",
|
"MEMBER_MUTE_EXPIRED": "🔉 **{member.user.username}#{member.user.discriminator}**'s mute expired",
|
||||||
"MEMBER_KICK": "👢 **{user.username}#{user.discriminator}** (`{user.id}`) was kicked by {mod.username}#{mod.discriminator}",
|
"MEMBER_KICK": "👢 **{user.username}#{user.discriminator}** (`{user.id}`) was kicked by {mod.username}#{mod.discriminator}",
|
||||||
|
@ -23,10 +24,10 @@
|
||||||
"ROLE_DELETE": "🖊 Role **{role.name}** was deleted",
|
"ROLE_DELETE": "🖊 Role **{role.name}** was deleted",
|
||||||
"ROLE_EDIT": "🖊 Role **{role.name}** was edited",
|
"ROLE_EDIT": "🖊 Role **{role.name}** was edited",
|
||||||
|
|
||||||
"MESSAGE_EDIT": "✏ **{member.user.username}#{member.user.discriminator}** (`{member.id}`) message edited in **{channel.name}**:\n`B:` {before}\n`A:` {after}",
|
"MESSAGE_EDIT": "✏ **{member.user.username}#{member.user.discriminator}** (`{member.id}`) message edited in **#{channel.name}**:\n`B:` {before}\n`A:` {after}",
|
||||||
"MESSAGE_DELETE": "🗑 **{member.user.username}#{member.user.discriminator}** (`{member.id}`) message deleted in **{channel.name}**:\n```{messageText}```",
|
"MESSAGE_DELETE": "🗑 **{member.user.username}#{member.user.discriminator}** (`{member.id}`) message deleted in **#{channel.name}**:\n```{messageText}```",
|
||||||
"MESSAGE_DELETE_BULK": "🗑 **{count}** messages deleted in **{channel.name}**",
|
"MESSAGE_DELETE_BULK": "🗑 **{count}** messages deleted in **#{channel.name}**",
|
||||||
"MESSAGE_DELETE_BARE": "🗑 message (id `{messageId}`) deleted in **{channel.name}** (no more info available due to restart)",
|
"MESSAGE_DELETE_BARE": "🗑 Message (`{messageId}`) deleted in **#{channel.name}** (no more info available due to bot restart)",
|
||||||
|
|
||||||
"VOICE_CHANNEL_JOIN": "🔸 **{member.user.username}#{member.user.discriminator}** (`{member.id}`) joined **{channel.name}**",
|
"VOICE_CHANNEL_JOIN": "🔸 **{member.user.username}#{member.user.discriminator}** (`{member.id}`) joined **{channel.name}**",
|
||||||
"VOICE_CHANNEL_MOVE": "🔹 **{member.user.username}#{member.user.discriminator}** (`{member.id}`) moved **{oldChannel.name}** ➞ **{newChannel.name}**",
|
"VOICE_CHANNEL_MOVE": "🔹 **{member.user.username}#{member.user.discriminator}** (`{member.id}`) moved **{oldChannel.name}** ➞ **{newChannel.name}**",
|
||||||
|
@ -34,8 +35,9 @@
|
||||||
|
|
||||||
"COMMAND": "🤖 **{member.user.username}#{member.user.discriminator}** (`{member.id}`) used command in **#{channel.name}**:\n`{command}`",
|
"COMMAND": "🤖 **{member.user.username}#{member.user.discriminator}** (`{member.id}`) used command in **#{channel.name}**:\n`{command}`",
|
||||||
|
|
||||||
"SPAM_DELETE": "🛑 **{member.user.username}#{member.user.discriminator}** (`{member.id}`) triggered spam filter: **{filterName}**",
|
"SPAM_DELETE": "🛑 **{member.user.username}#{member.user.discriminator}** (`{member.id}`) spam deleted in **#{channel.name}**: {description} (more than {limit} in {interval}s)",
|
||||||
"CENSOR": "🛑 **{member.user.username}#{member.user.discriminator}** (`{member.id}`) censored message in **#{channel.name}** (`{channel.id}`) {reason}:\n```{messageText}```",
|
"CENSOR": "🛑 **{member.user.username}#{member.user.discriminator}** (`{member.id}`) censored message in **#{channel.name}** (`{channel.id}`) {reason}:\n```{messageText}```",
|
||||||
|
"CLEAN": "🚿 **{mod.username}#{mod.discriminator}** (`{mod.id}`) cleaned **{count}** message(s) in **#{channel.name}**",
|
||||||
|
|
||||||
"CASE_CREATE": "✏ **{member.user.username}#{member.user.discriminator}** (`{member.id}`) manually created new **{caseType}** case (#{caseNum})"
|
"CASE_CREATE": "✏ **{member.user.username}#{member.user.discriminator}** (`{member.id}`) manually created new **{caseType}** case (#{caseNum})"
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
export enum LogType {
|
export enum LogType {
|
||||||
MEMBER_WARN = 1,
|
MEMBER_WARN = 1,
|
||||||
MEMBER_MUTE,
|
MEMBER_MUTE,
|
||||||
|
MEMBER_MUTE_SPAM,
|
||||||
MEMBER_UNMUTE,
|
MEMBER_UNMUTE,
|
||||||
MEMBER_MUTE_EXPIRED,
|
MEMBER_MUTE_EXPIRED,
|
||||||
MEMBER_KICK,
|
MEMBER_KICK,
|
||||||
|
@ -34,6 +35,7 @@ export enum LogType {
|
||||||
|
|
||||||
SPAM_DELETE,
|
SPAM_DELETE,
|
||||||
CENSOR,
|
CENSOR,
|
||||||
|
CLEAN,
|
||||||
|
|
||||||
CASE_CREATE
|
CASE_CREATE
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,12 +21,14 @@ import { PostPlugin } from "./plugins/Post";
|
||||||
import { ReactionRolesPlugin } from "./plugins/ReactionRoles";
|
import { ReactionRolesPlugin } from "./plugins/ReactionRoles";
|
||||||
import { CensorPlugin } from "./plugins/Censor";
|
import { CensorPlugin } from "./plugins/Censor";
|
||||||
import { PersistPlugin } from "./plugins/Persist";
|
import { PersistPlugin } from "./plugins/Persist";
|
||||||
|
import { SpamPlugin } from "./plugins/Spam";
|
||||||
import knex from "./knex";
|
import knex from "./knex";
|
||||||
|
|
||||||
// Run latest database migrations
|
// Run latest database migrations
|
||||||
logger.info("Running database migrations");
|
logger.info("Running database migrations");
|
||||||
knex.migrate.latest().then(() => {
|
knex.migrate.latest().then(() => {
|
||||||
const client = new Client(process.env.TOKEN);
|
const client = new Client(process.env.TOKEN);
|
||||||
|
client.setMaxListeners(100);
|
||||||
|
|
||||||
const bot = new Knub(client, {
|
const bot = new Knub(client, {
|
||||||
plugins: {
|
plugins: {
|
||||||
|
@ -36,7 +38,8 @@ knex.migrate.latest().then(() => {
|
||||||
post: PostPlugin,
|
post: PostPlugin,
|
||||||
reaction_roles: ReactionRolesPlugin,
|
reaction_roles: ReactionRolesPlugin,
|
||||||
censor: CensorPlugin,
|
censor: CensorPlugin,
|
||||||
persist: PersistPlugin
|
persist: PersistPlugin,
|
||||||
|
spam: SpamPlugin
|
||||||
},
|
},
|
||||||
globalPlugins: {
|
globalPlugins: {
|
||||||
bot_control: BotControlPlugin
|
bot_control: BotControlPlugin
|
||||||
|
|
|
@ -1,51 +1,9 @@
|
||||||
import { Plugin, decorators as d } from "knub";
|
import { decorators as d, Plugin } from "knub";
|
||||||
import { Invite, Message } from "eris";
|
import { Invite, Message } from "eris";
|
||||||
import url from "url";
|
|
||||||
import tlds from "tlds";
|
|
||||||
import escapeStringRegexp from "escape-string-regexp";
|
import escapeStringRegexp from "escape-string-regexp";
|
||||||
import { GuildLogs } from "../data/GuildLogs";
|
import { GuildLogs } from "../data/GuildLogs";
|
||||||
import { LogType } from "../data/LogType";
|
import { LogType } from "../data/LogType";
|
||||||
import { stripObjectToScalars } from "../utils";
|
import { getInviteCodesInString, getUrlsInString, stripObjectToScalars } from "../utils";
|
||||||
|
|
||||||
const urlRegex = /(\S+\.\S+)/g;
|
|
||||||
const protocolRegex = /^[a-z]+:\/\//;
|
|
||||||
|
|
||||||
const getInviteCodesInString = (str: string): string[] => {
|
|
||||||
const inviteCodeRegex = /(?:discord.gg|discordapp.com\/invite)\/([a-z0-9]+)/gi;
|
|
||||||
const inviteCodes = [];
|
|
||||||
let match;
|
|
||||||
|
|
||||||
// tslint:disable-next-line
|
|
||||||
while ((match = inviteCodeRegex.exec(str)) !== null) {
|
|
||||||
inviteCodes.push(match[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return inviteCodes;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getUrlsInString = (str: string): url.URL[] => {
|
|
||||||
const matches = str.match(urlRegex) || [];
|
|
||||||
return matches.reduce((urls, match) => {
|
|
||||||
if (!protocolRegex.test(match)) {
|
|
||||||
match = `https://${match}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
let matchUrl: url.URL;
|
|
||||||
try {
|
|
||||||
matchUrl = new url.URL(match);
|
|
||||||
} catch (e) {
|
|
||||||
return urls;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hostnameParts = matchUrl.hostname.split(".");
|
|
||||||
const tld = hostnameParts[hostnameParts.length - 1];
|
|
||||||
if (tlds.includes(tld)) {
|
|
||||||
urls.push(matchUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
return urls;
|
|
||||||
}, []);
|
|
||||||
};
|
|
||||||
|
|
||||||
export class CensorPlugin extends Plugin {
|
export class CensorPlugin extends Plugin {
|
||||||
protected serverLogs: GuildLogs;
|
protected serverLogs: GuildLogs;
|
||||||
|
|
|
@ -158,17 +158,25 @@ export class LogsPlugin extends Plugin {
|
||||||
const mod = relevantAuditLogEntry ? relevantAuditLogEntry.user : unknownUser;
|
const mod = relevantAuditLogEntry ? relevantAuditLogEntry.user : unknownUser;
|
||||||
|
|
||||||
if (addedRoles.length) {
|
if (addedRoles.length) {
|
||||||
this.serverLogs.log(LogType.MEMBER_ROLE_ADD, {
|
this.serverLogs.log(
|
||||||
member,
|
LogType.MEMBER_ROLE_ADD,
|
||||||
role: this.guild.roles.get(addedRoles[0]),
|
{
|
||||||
mod: stripObjectToScalars(mod)
|
member,
|
||||||
});
|
role: this.guild.roles.get(addedRoles[0]),
|
||||||
|
mod: stripObjectToScalars(mod)
|
||||||
|
},
|
||||||
|
member.id
|
||||||
|
);
|
||||||
} else if (removedRoles.length) {
|
} else if (removedRoles.length) {
|
||||||
this.serverLogs.log(LogType.MEMBER_ROLE_REMOVE, {
|
this.serverLogs.log(
|
||||||
member,
|
LogType.MEMBER_ROLE_REMOVE,
|
||||||
role: this.guild.roles.get(removedRoles[0]),
|
{
|
||||||
mod: stripObjectToScalars(mod)
|
member,
|
||||||
});
|
role: this.guild.roles.get(removedRoles[0]),
|
||||||
|
mod: stripObjectToScalars(mod)
|
||||||
|
},
|
||||||
|
member.id
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -230,7 +238,7 @@ export class LogsPlugin extends Plugin {
|
||||||
|
|
||||||
@d.event("messageDelete")
|
@d.event("messageDelete")
|
||||||
onMessageDelete(msg: Message) {
|
onMessageDelete(msg: Message) {
|
||||||
if (msg.type !== 0) return;
|
if (msg.type != null && msg.type !== 0) return;
|
||||||
|
|
||||||
if (msg.member) {
|
if (msg.member) {
|
||||||
this.serverLogs.log(
|
this.serverLogs.log(
|
||||||
|
@ -256,10 +264,14 @@ export class LogsPlugin extends Plugin {
|
||||||
|
|
||||||
@d.event("messageDeleteBulk")
|
@d.event("messageDeleteBulk")
|
||||||
onMessageDeleteBulk(messages: Message[]) {
|
onMessageDeleteBulk(messages: Message[]) {
|
||||||
this.serverLogs.log(LogType.MESSAGE_DELETE_BULK, {
|
this.serverLogs.log(
|
||||||
count: messages.length,
|
LogType.MESSAGE_DELETE_BULK,
|
||||||
channel: messages[0] ? messages[0].channel : null
|
{
|
||||||
});
|
count: messages.length,
|
||||||
|
channel: messages[0] ? messages[0].channel : null
|
||||||
|
},
|
||||||
|
messages[0] && messages[0].id
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@d.event("voiceChannelJoin")
|
@d.event("voiceChannelJoin")
|
||||||
|
|
|
@ -258,6 +258,11 @@ export class ModActionsPlugin extends Plugin {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async muteMember(member: Member, muteTime: number = null, reason: string = null) {
|
||||||
|
await member.addRole(this.configValue("mute_role"), reason);
|
||||||
|
await this.mutes.addOrUpdateMute(member.id, muteTime);
|
||||||
|
}
|
||||||
|
|
||||||
@d.command("mute", "<member:Member> [time:string] [reason:string$]")
|
@d.command("mute", "<member:Member> [time:string] [reason:string$]")
|
||||||
@d.permission("mute")
|
@d.permission("mute")
|
||||||
async muteCmd(msg: Message, args: any) {
|
async muteCmd(msg: Message, args: any) {
|
||||||
|
@ -283,8 +288,7 @@ export class ModActionsPlugin extends Plugin {
|
||||||
|
|
||||||
// Apply "muted" role
|
// Apply "muted" role
|
||||||
this.serverLogs.ignoreLog(LogType.MEMBER_ROLE_ADD, args.member.id);
|
this.serverLogs.ignoreLog(LogType.MEMBER_ROLE_ADD, args.member.id);
|
||||||
await args.member.addRole(this.configValue("mute_role"), args.reason);
|
this.muteMember(args.member, muteTime, args.reason);
|
||||||
await this.mutes.addOrUpdateMute(args.member.id, muteTime);
|
|
||||||
|
|
||||||
// Create a case
|
// Create a case
|
||||||
await this.createCase(args.member.id, msg.author.id, CaseType.Mute, null, args.reason);
|
await this.createCase(args.member.id, msg.author.id, CaseType.Mute, null, args.reason);
|
||||||
|
@ -713,7 +717,7 @@ export class ModActionsPlugin extends Plugin {
|
||||||
return this.displayCase(caseOrCaseId, caseLogChannelId);
|
return this.displayCase(caseOrCaseId, caseLogChannelId);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async createCase(
|
public async createCase(
|
||||||
userId: string,
|
userId: string,
|
||||||
modId: string,
|
modId: string,
|
||||||
caseType: CaseType,
|
caseType: CaseType,
|
||||||
|
@ -770,6 +774,7 @@ export class ModActionsPlugin extends Plugin {
|
||||||
if (!member) continue;
|
if (!member) continue;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
this.serverLogs.ignoreLog(LogType.MEMBER_ROLE_REMOVE, member.id);
|
||||||
await member.removeRole(this.configValue("mute_role"));
|
await member.removeRole(this.configValue("mute_role"));
|
||||||
} catch (e) {} // tslint:disable-line
|
} catch (e) {} // tslint:disable-line
|
||||||
|
|
||||||
|
|
226
src/plugins/Spam.ts
Normal file
226
src/plugins/Spam.ts
Normal file
|
@ -0,0 +1,226 @@
|
||||||
|
import { decorators as d, Plugin } from "knub";
|
||||||
|
import { Message, TextChannel } from "eris";
|
||||||
|
import {
|
||||||
|
cleanMessagesInChannel,
|
||||||
|
getEmojiInString,
|
||||||
|
getUrlsInString,
|
||||||
|
stripObjectToScalars
|
||||||
|
} from "../utils";
|
||||||
|
import { LogType } from "../data/LogType";
|
||||||
|
import { GuildLogs } from "../data/GuildLogs";
|
||||||
|
import { ModActionsPlugin } from "./ModActions";
|
||||||
|
import { CaseType } from "../data/CaseType";
|
||||||
|
|
||||||
|
enum RecentActionType {
|
||||||
|
Message = 1,
|
||||||
|
Mention,
|
||||||
|
Link,
|
||||||
|
Attachment,
|
||||||
|
Emoji,
|
||||||
|
Newline
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IRecentAction {
|
||||||
|
type: RecentActionType;
|
||||||
|
userId: string;
|
||||||
|
channelId: string;
|
||||||
|
timestamp: number;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SpamPlugin extends Plugin {
|
||||||
|
protected logs: GuildLogs;
|
||||||
|
|
||||||
|
protected recentActions: IRecentAction[];
|
||||||
|
|
||||||
|
private expiryInterval;
|
||||||
|
|
||||||
|
getDefaultOptions() {
|
||||||
|
return {
|
||||||
|
config: {
|
||||||
|
max_messages: null,
|
||||||
|
max_mentions: null,
|
||||||
|
max_links: null,
|
||||||
|
max_attachments: null,
|
||||||
|
max_emojis: null,
|
||||||
|
max_newlines: null,
|
||||||
|
max_duplicates: null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
onLoad() {
|
||||||
|
this.logs = new GuildLogs(this.guildId);
|
||||||
|
this.expiryInterval = setInterval(() => this.clearOldRecentActions(), 1000 * 60);
|
||||||
|
this.recentActions = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnload() {
|
||||||
|
clearInterval(this.expiryInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
addRecentAction(
|
||||||
|
type: RecentActionType,
|
||||||
|
userId: string,
|
||||||
|
channelId: string,
|
||||||
|
timestamp: number,
|
||||||
|
count = 1
|
||||||
|
) {
|
||||||
|
this.recentActions.push({
|
||||||
|
type,
|
||||||
|
userId,
|
||||||
|
channelId,
|
||||||
|
timestamp,
|
||||||
|
count
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getRecentActionCount(type: RecentActionType, userId: string, channelId: string, since: number) {
|
||||||
|
return this.recentActions.reduce((count, action) => {
|
||||||
|
if (action.timestamp < since) return count;
|
||||||
|
if (action.type !== type) return count;
|
||||||
|
if (action.channelId !== channelId) return count;
|
||||||
|
return count + action.count;
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearRecentUserActions(type: RecentActionType, userId: string, channelId: string) {
|
||||||
|
this.recentActions = this.recentActions.filter(action => {
|
||||||
|
return action.type !== type || action.userId !== userId || action.channelId !== channelId;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
clearOldRecentActions() {
|
||||||
|
// TODO: Figure out expiry time from longest interval in the config?
|
||||||
|
const expiryTimestamp = Date.now() - 1000 * 60 * 5;
|
||||||
|
this.recentActions = this.recentActions.filter(action => action.timestamp >= expiryTimestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
async detectSpam(
|
||||||
|
msg: Message,
|
||||||
|
type: RecentActionType,
|
||||||
|
spamConfig: any,
|
||||||
|
actionCount: number,
|
||||||
|
description: string
|
||||||
|
) {
|
||||||
|
if (actionCount === 0) return;
|
||||||
|
|
||||||
|
this.addRecentAction(type, msg.author.id, msg.channel.id, msg.timestamp, actionCount);
|
||||||
|
const recentMessagesCount = this.getRecentActionCount(
|
||||||
|
type,
|
||||||
|
msg.author.id,
|
||||||
|
msg.channel.id,
|
||||||
|
msg.timestamp - 1000 * spamConfig.interval
|
||||||
|
);
|
||||||
|
|
||||||
|
if (recentMessagesCount > spamConfig.count) {
|
||||||
|
if (spamConfig.clean !== false) {
|
||||||
|
const cleanCount =
|
||||||
|
type === RecentActionType.Message ? spamConfig.count : spamConfig.cleanCount || 20;
|
||||||
|
|
||||||
|
await cleanMessagesInChannel(
|
||||||
|
this.bot,
|
||||||
|
msg.channel as TextChannel,
|
||||||
|
cleanCount,
|
||||||
|
msg.author.id,
|
||||||
|
"Spam detected"
|
||||||
|
);
|
||||||
|
this.logs.log(LogType.SPAM_DELETE, {
|
||||||
|
member: stripObjectToScalars(msg.member, ["user"]),
|
||||||
|
channel: stripObjectToScalars(msg.channel),
|
||||||
|
description,
|
||||||
|
limit: spamConfig.count,
|
||||||
|
interval: spamConfig.interval
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (spamConfig.mute) {
|
||||||
|
const guildData = this.knub.getGuildData(this.guildId);
|
||||||
|
const modActionsPlugin = guildData.loadedPlugins.get("mod_actions") as ModActionsPlugin;
|
||||||
|
if (!modActionsPlugin) return;
|
||||||
|
|
||||||
|
this.logs.ignoreLog(LogType.MEMBER_ROLE_ADD, msg.member.id);
|
||||||
|
await modActionsPlugin.muteMember(
|
||||||
|
msg.member,
|
||||||
|
spamConfig.muteTime ? spamConfig.muteTime * 1000 : 120 * 1000,
|
||||||
|
"Automatic spam detection"
|
||||||
|
);
|
||||||
|
await modActionsPlugin.createCase(
|
||||||
|
msg.member.id,
|
||||||
|
this.bot.user.id,
|
||||||
|
CaseType.Mute,
|
||||||
|
null,
|
||||||
|
"Automatic spam detection",
|
||||||
|
true
|
||||||
|
);
|
||||||
|
this.logs.log(LogType.MEMBER_MUTE_SPAM, {
|
||||||
|
member: stripObjectToScalars(msg.member, ["user"]),
|
||||||
|
channel: stripObjectToScalars(msg.channel),
|
||||||
|
description,
|
||||||
|
limit: spamConfig.count,
|
||||||
|
interval: spamConfig.interval
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@d.event("messageCreate")
|
||||||
|
onMessageCreate(msg: Message) {
|
||||||
|
if (msg.author.bot) return;
|
||||||
|
|
||||||
|
const maxMessages = this.configValueForMsg(msg, "max_messages");
|
||||||
|
if (maxMessages) {
|
||||||
|
this.detectSpam(msg, RecentActionType.Message, maxMessages, 1, "too many messages");
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxMentions = this.configValueForMsg(msg, "max_mentions");
|
||||||
|
if (maxMentions && (msg.mentions.length || msg.roleMentions.length)) {
|
||||||
|
this.detectSpam(
|
||||||
|
msg,
|
||||||
|
RecentActionType.Mention,
|
||||||
|
maxMentions,
|
||||||
|
msg.mentions.length + msg.roleMentions.length,
|
||||||
|
"too many mentions"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxLinks = this.configValueForMsg(msg, "max_links");
|
||||||
|
if (maxLinks && msg.content) {
|
||||||
|
const links = getUrlsInString(msg.content);
|
||||||
|
this.detectSpam(msg, RecentActionType.Link, maxLinks, links.length, "too many links");
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxAttachments = this.configValueForMsg(msg, "max_attachments");
|
||||||
|
if (maxAttachments && msg.attachments.length) {
|
||||||
|
this.detectSpam(
|
||||||
|
msg,
|
||||||
|
RecentActionType.Attachment,
|
||||||
|
maxAttachments,
|
||||||
|
msg.attachments.length,
|
||||||
|
"too many attachments"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxEmoji = this.configValueForMsg(msg, "max_emoji");
|
||||||
|
if (maxEmoji && msg.content) {
|
||||||
|
const emojiCount = getEmojiInString(msg.content).length;
|
||||||
|
this.detectSpam(msg, RecentActionType.Emoji, maxEmoji, emojiCount, "too many emoji");
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxNewlines = this.configValueForMsg(msg, "max_newlines");
|
||||||
|
if (maxNewlines && msg.content) {
|
||||||
|
const newlineCount = (msg.content.match(/\n/g) || []).length;
|
||||||
|
this.detectSpam(
|
||||||
|
msg,
|
||||||
|
RecentActionType.Newline,
|
||||||
|
maxNewlines,
|
||||||
|
newlineCount,
|
||||||
|
"too many newlines"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Max duplicates
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,31 +1,43 @@
|
||||||
import { Plugin, decorators as d } from "knub";
|
import { Plugin, decorators as d, reply } from "knub";
|
||||||
import { Message, TextChannel } from "eris";
|
import { Channel, Message, TextChannel, User } from "eris";
|
||||||
import { errorMessage } from "../utils";
|
import { errorMessage, getMessages, stripObjectToScalars, successMessage } from "../utils";
|
||||||
|
import { GuildLogs } from "../data/GuildLogs";
|
||||||
|
import { LogType } from "../data/LogType";
|
||||||
|
|
||||||
|
const MAX_SEARCH_RESULTS = 15;
|
||||||
|
const MAX_CLEAN_COUNT = 50;
|
||||||
|
|
||||||
export class UtilityPlugin extends Plugin {
|
export class UtilityPlugin extends Plugin {
|
||||||
|
protected logs: GuildLogs;
|
||||||
|
|
||||||
getDefaultOptions() {
|
getDefaultOptions() {
|
||||||
return {
|
return {
|
||||||
permissions: {
|
permissions: {
|
||||||
roles: false,
|
roles: false,
|
||||||
level: false
|
level: false,
|
||||||
|
search: false,
|
||||||
|
clean: false,
|
||||||
|
info: false
|
||||||
},
|
},
|
||||||
overrides: [
|
overrides: [
|
||||||
{
|
|
||||||
level: ">0",
|
|
||||||
permissions: {
|
|
||||||
level: true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
level: ">=50",
|
level: ">=50",
|
||||||
permissions: {
|
permissions: {
|
||||||
roles: true
|
roles: true,
|
||||||
|
level: true,
|
||||||
|
search: true,
|
||||||
|
clean: true,
|
||||||
|
info: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onLoad() {
|
||||||
|
this.logs = new GuildLogs(this.guildId);
|
||||||
|
}
|
||||||
|
|
||||||
@d.command("roles")
|
@d.command("roles")
|
||||||
@d.permission("roles")
|
@d.permission("roles")
|
||||||
async rolesCmd(msg: Message) {
|
async rolesCmd(msg: Message) {
|
||||||
|
@ -48,4 +60,125 @@ export class UtilityPlugin extends Plugin {
|
||||||
`The permission level of ${member.username}#${member.discriminator} is **${level}**`
|
`The permission level of ${member.username}#${member.discriminator} is **${level}**`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@d.command("search", "<query:string$>")
|
||||||
|
@d.permission("search")
|
||||||
|
async searchCmd(msg: Message, args: { query: string }) {
|
||||||
|
const query = args.query.toLowerCase();
|
||||||
|
const matchingMembers = this.guild.members.filter(member => {
|
||||||
|
const fullUsername = `${member.user.username}#${member.user.discriminator}`;
|
||||||
|
if (member.nick && member.nick.toLowerCase().indexOf(query) !== -1) return true;
|
||||||
|
if (fullUsername.toLowerCase().indexOf(query) !== -1) return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (matchingMembers.length > 0) {
|
||||||
|
let header;
|
||||||
|
const resultText = matchingMembers.length === 1 ? "result" : "results";
|
||||||
|
|
||||||
|
if (matchingMembers.length > MAX_SEARCH_RESULTS) {
|
||||||
|
header = `Found ${matchingMembers.length} ${resultText} (showing ${MAX_SEARCH_RESULTS})`;
|
||||||
|
} else {
|
||||||
|
header = `Found ${matchingMembers.length} ${resultText}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = matchingMembers.slice(0, MAX_SEARCH_RESULTS).map(member => {
|
||||||
|
return `${member.user.username}#${member.user.discriminator} (${member.id})`;
|
||||||
|
});
|
||||||
|
lines.sort((a, b) => {
|
||||||
|
return a.toLowerCase() < b.toLowerCase() ? -1 : 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
msg.channel.createMessage(`${header}\n\`\`\`${lines.join("\n")}\`\`\``);
|
||||||
|
} else {
|
||||||
|
msg.channel.createMessage(errorMessage("No results found"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async cleanMessages(channel: Channel, messageIds: string[], mod: User) {
|
||||||
|
this.logs.ignoreLog(LogType.MESSAGE_DELETE, messageIds[0]);
|
||||||
|
this.logs.ignoreLog(LogType.MESSAGE_DELETE_BULK, messageIds[0]);
|
||||||
|
await this.bot.deleteMessages(channel.id, messageIds);
|
||||||
|
this.logs.log(LogType.CLEAN, {
|
||||||
|
mod: stripObjectToScalars(mod),
|
||||||
|
channel: stripObjectToScalars(channel),
|
||||||
|
count: messageIds.length
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@d.command("clean", "<count:number>")
|
||||||
|
@d.command("clean all", "<count:number>")
|
||||||
|
@d.permission("clean")
|
||||||
|
async cleanAllCmd(msg: Message, args: { count: number }) {
|
||||||
|
if (args.count > MAX_CLEAN_COUNT || args.count <= 0) {
|
||||||
|
msg.channel.createMessage(
|
||||||
|
errorMessage(`Clean count must be between 1 and ${MAX_CLEAN_COUNT}`)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messagesToClean = await getMessages(
|
||||||
|
msg.channel as TextChannel,
|
||||||
|
m => m.id !== msg.id,
|
||||||
|
args.count
|
||||||
|
);
|
||||||
|
if (messagesToClean.length > 0)
|
||||||
|
await this.cleanMessages(msg.channel, messagesToClean.map(m => m.id), msg.author);
|
||||||
|
|
||||||
|
msg.channel.createMessage(
|
||||||
|
successMessage(
|
||||||
|
`Cleaned ${messagesToClean.length} ${messagesToClean.length === 1 ? "message" : "messages"}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@d.command("clean user", "<userId:string> <count:number>")
|
||||||
|
@d.permission("clean")
|
||||||
|
async cleanUserCmd(msg: Message, args: { userId: string; count: number }) {
|
||||||
|
if (args.count > MAX_CLEAN_COUNT || args.count <= 0) {
|
||||||
|
msg.channel.createMessage(
|
||||||
|
errorMessage(`Clean count must be between 1 and ${MAX_CLEAN_COUNT}`)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messagesToClean = await getMessages(
|
||||||
|
msg.channel as TextChannel,
|
||||||
|
m => m.id !== msg.id && m.author.id === args.userId,
|
||||||
|
args.count
|
||||||
|
);
|
||||||
|
if (messagesToClean.length > 0)
|
||||||
|
await this.cleanMessages(msg.channel, messagesToClean.map(m => m.id), msg.author);
|
||||||
|
|
||||||
|
msg.channel.createMessage(
|
||||||
|
successMessage(
|
||||||
|
`Cleaned ${messagesToClean.length} ${messagesToClean.length === 1 ? "message" : "messages"}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@d.command("clean bot", "<count:number>")
|
||||||
|
@d.permission("clean")
|
||||||
|
async cleanBotCmd(msg: Message, args: { count: number }) {
|
||||||
|
if (args.count > MAX_CLEAN_COUNT || args.count <= 0) {
|
||||||
|
msg.channel.createMessage(
|
||||||
|
errorMessage(`Clean count must be between 1 and ${MAX_CLEAN_COUNT}`)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messagesToClean = await getMessages(
|
||||||
|
msg.channel as TextChannel,
|
||||||
|
m => m.id !== msg.id && m.author.bot,
|
||||||
|
args.count
|
||||||
|
);
|
||||||
|
if (messagesToClean.length > 0)
|
||||||
|
await this.cleanMessages(msg.channel, messagesToClean.map(m => m.id), msg.author);
|
||||||
|
|
||||||
|
msg.channel.createMessage(
|
||||||
|
successMessage(
|
||||||
|
`Cleaned ${messagesToClean.length} ${messagesToClean.length === 1 ? "message" : "messages"}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
111
src/utils.ts
111
src/utils.ts
|
@ -1,5 +1,8 @@
|
||||||
import at = require("lodash.at");
|
import at = require("lodash.at");
|
||||||
import { Guild, GuildAuditLogEntry } from "eris";
|
import { Client, Guild, GuildAuditLogEntry, Message, TextChannel } from "eris";
|
||||||
|
import url from "url";
|
||||||
|
import tlds from "tlds";
|
||||||
|
import emojiRegex from "emoji-regex";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Turns a "delay string" such as "1h30m" to milliseconds
|
* Turns a "delay string" such as "1h30m" to milliseconds
|
||||||
|
@ -72,7 +75,8 @@ export function stripObjectToScalars(obj, includedNested: string[] = []) {
|
||||||
const stringFormatRegex = /{([^{}]+?)}/g;
|
const stringFormatRegex = /{([^{}]+?)}/g;
|
||||||
export function formatTemplateString(str: string, values) {
|
export function formatTemplateString(str: string, values) {
|
||||||
return str.replace(stringFormatRegex, (match, val) => {
|
return str.replace(stringFormatRegex, (match, val) => {
|
||||||
return (at(values, val)[0] as string) || "";
|
const value = at(values, val)[0];
|
||||||
|
return typeof value === "string" || typeof value === "number" ? String(value) : "";
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -119,3 +123,106 @@ export async function findRelevantAuditLogEntry(
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const urlRegex = /(\S+\.\S+)/g;
|
||||||
|
const protocolRegex = /^[a-z]+:\/\//;
|
||||||
|
|
||||||
|
export function getUrlsInString(str: string): url.URL[] {
|
||||||
|
const matches = str.match(urlRegex) || [];
|
||||||
|
return matches.reduce((urls, match) => {
|
||||||
|
if (!protocolRegex.test(match)) {
|
||||||
|
match = `https://${match}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let matchUrl: url.URL;
|
||||||
|
try {
|
||||||
|
matchUrl = new url.URL(match);
|
||||||
|
} catch (e) {
|
||||||
|
return urls;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostnameParts = matchUrl.hostname.split(".");
|
||||||
|
const tld = hostnameParts[hostnameParts.length - 1];
|
||||||
|
if (tlds.includes(tld)) {
|
||||||
|
urls.push(matchUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return urls;
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInviteCodesInString(str: string): string[] {
|
||||||
|
const inviteCodeRegex = /(?:discord.gg|discordapp.com\/invite)\/([a-z0-9]+)/gi;
|
||||||
|
const inviteCodes = [];
|
||||||
|
let match;
|
||||||
|
|
||||||
|
// tslint:disable-next-line
|
||||||
|
while ((match = inviteCodeRegex.exec(str)) !== null) {
|
||||||
|
inviteCodes.push(match[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return inviteCodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const unicodeEmojiRegex = emojiRegex();
|
||||||
|
export const customEmojiRegex = /<:(?:.*?):(\d+)>/g;
|
||||||
|
export const anyEmojiRegex = new RegExp(
|
||||||
|
`(?:(?:${unicodeEmojiRegex.source})|(?:${customEmojiRegex.source}))`,
|
||||||
|
"g"
|
||||||
|
);
|
||||||
|
|
||||||
|
export function getEmojiInString(str: string): string[] {
|
||||||
|
return str.match(anyEmojiRegex) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MessageFilterFn = (msg: Message) => boolean;
|
||||||
|
export type StopFn = (msg: Message) => boolean;
|
||||||
|
|
||||||
|
export async function getMessages(
|
||||||
|
channel: TextChannel,
|
||||||
|
filter: MessageFilterFn = null,
|
||||||
|
maxCount: number = 50,
|
||||||
|
stopFn: StopFn = null
|
||||||
|
): Promise<Message[]> {
|
||||||
|
let messages: Message[] = [];
|
||||||
|
let before;
|
||||||
|
|
||||||
|
if (!filter) {
|
||||||
|
filter = () => true;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const newMessages = await channel.getMessages(50, before);
|
||||||
|
if (newMessages.length === 0) break;
|
||||||
|
|
||||||
|
before = newMessages[newMessages.length - 1].id;
|
||||||
|
|
||||||
|
const filtered = newMessages.filter(filter);
|
||||||
|
messages.push(...filtered);
|
||||||
|
|
||||||
|
if (messages.length >= maxCount) {
|
||||||
|
messages = messages.slice(0, maxCount);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stopFn && newMessages.some(stopFn)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cleanMessagesInChannel(
|
||||||
|
bot: Client,
|
||||||
|
channel: TextChannel,
|
||||||
|
count: number,
|
||||||
|
userId: string = null,
|
||||||
|
reason: string = null
|
||||||
|
) {
|
||||||
|
const messages = await getMessages(channel, msg => !userId || msg.author.id === userId, count);
|
||||||
|
const ids = messages.map(m => m.id);
|
||||||
|
if (ids) {
|
||||||
|
await bot.deleteMessages(channel.id, ids, reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue