mirror of
https://github.com/ZeppelinBot/Zeppelin.git
synced 2025-05-12 21:05:02 +00:00
Automod work
This commit is contained in:
parent
140ba84544
commit
f657b169df
32 changed files with 1099 additions and 5 deletions
|
@ -0,0 +1,130 @@
|
|||
import moment from "moment-timezone";
|
||||
import { AutomodContext, AutomodPluginType } from "../types";
|
||||
import { PluginData } from "knub";
|
||||
import { RECENT_ACTION_EXPIRY_TIME, RecentActionType } from "../constants";
|
||||
import { getEmojiInString, getRoleMentions, getUrlsInString, getUserMentions } from "../../../utils";
|
||||
|
||||
export function addRecentActionsFromMessage(pluginData: PluginData<AutomodPluginType>, context: AutomodContext) {
|
||||
const globalIdentifier = context.message.user_id;
|
||||
const perChannelIdentifier = `${context.message.channel_id}-${context.message.user_id}`;
|
||||
const expiresAt = Date.now() + RECENT_ACTION_EXPIRY_TIME;
|
||||
|
||||
pluginData.state.recentActions.push({
|
||||
context,
|
||||
type: RecentActionType.Message,
|
||||
identifier: globalIdentifier,
|
||||
count: 1,
|
||||
});
|
||||
|
||||
pluginData.state.recentActions.push({
|
||||
context,
|
||||
type: RecentActionType.Message,
|
||||
identifier: perChannelIdentifier,
|
||||
count: 1,
|
||||
});
|
||||
|
||||
const mentionCount =
|
||||
getUserMentions(context.message.data.content || "").length +
|
||||
getRoleMentions(context.message.data.content || "").length;
|
||||
if (mentionCount) {
|
||||
pluginData.state.recentActions.push({
|
||||
context,
|
||||
type: RecentActionType.Mention,
|
||||
identifier: globalIdentifier,
|
||||
count: mentionCount,
|
||||
});
|
||||
|
||||
pluginData.state.recentActions.push({
|
||||
context,
|
||||
type: RecentActionType.Mention,
|
||||
identifier: perChannelIdentifier,
|
||||
count: mentionCount,
|
||||
});
|
||||
}
|
||||
|
||||
const linkCount = getUrlsInString(context.message.data.content || "").length;
|
||||
if (linkCount) {
|
||||
pluginData.state.recentActions.push({
|
||||
context,
|
||||
type: RecentActionType.Link,
|
||||
identifier: globalIdentifier,
|
||||
count: linkCount,
|
||||
});
|
||||
|
||||
pluginData.state.recentActions.push({
|
||||
context,
|
||||
type: RecentActionType.Link,
|
||||
identifier: perChannelIdentifier,
|
||||
count: linkCount,
|
||||
});
|
||||
}
|
||||
|
||||
const attachmentCount = context.message.data.attachments && context.message.data.attachments.length;
|
||||
if (attachmentCount) {
|
||||
pluginData.state.recentActions.push({
|
||||
context,
|
||||
type: RecentActionType.Attachment,
|
||||
identifier: globalIdentifier,
|
||||
count: attachmentCount,
|
||||
});
|
||||
|
||||
pluginData.state.recentActions.push({
|
||||
context,
|
||||
type: RecentActionType.Attachment,
|
||||
identifier: perChannelIdentifier,
|
||||
count: attachmentCount,
|
||||
});
|
||||
}
|
||||
|
||||
const emojiCount = getEmojiInString(context.message.data.content || "").length;
|
||||
if (emojiCount) {
|
||||
pluginData.state.recentActions.push({
|
||||
context,
|
||||
type: RecentActionType.Emoji,
|
||||
identifier: globalIdentifier,
|
||||
count: emojiCount,
|
||||
});
|
||||
|
||||
pluginData.state.recentActions.push({
|
||||
context,
|
||||
type: RecentActionType.Emoji,
|
||||
identifier: perChannelIdentifier,
|
||||
count: emojiCount,
|
||||
});
|
||||
}
|
||||
|
||||
// + 1 is for the first line of the message (which doesn't have a line break)
|
||||
const lineCount = context.message.data.content ? (context.message.data.content.match(/\n/g) || []).length + 1 : 0;
|
||||
if (lineCount) {
|
||||
pluginData.state.recentActions.push({
|
||||
context,
|
||||
type: RecentActionType.Line,
|
||||
identifier: globalIdentifier,
|
||||
count: lineCount,
|
||||
});
|
||||
|
||||
pluginData.state.recentActions.push({
|
||||
context,
|
||||
type: RecentActionType.Line,
|
||||
identifier: perChannelIdentifier,
|
||||
count: lineCount,
|
||||
});
|
||||
}
|
||||
|
||||
const characterCount = [...(context.message.data.content || "")].length;
|
||||
if (characterCount) {
|
||||
pluginData.state.recentActions.push({
|
||||
context,
|
||||
type: RecentActionType.Character,
|
||||
identifier: globalIdentifier,
|
||||
count: characterCount,
|
||||
});
|
||||
|
||||
pluginData.state.recentActions.push({
|
||||
context,
|
||||
type: RecentActionType.Character,
|
||||
identifier: perChannelIdentifier,
|
||||
count: characterCount,
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
import { PluginData } from "knub";
|
||||
import { AutomodPluginType } from "../types";
|
||||
import { RECENT_ACTION_EXPIRY_TIME } from "../constants";
|
||||
|
||||
export function clearOldRecentActions(pluginData: PluginData<AutomodPluginType>) {
|
||||
const now = Date.now();
|
||||
pluginData.state.recentActions = pluginData.state.recentActions.filter(info => {
|
||||
return info.context.timestamp + RECENT_ACTION_EXPIRY_TIME > now;
|
||||
});
|
||||
}
|
10
backend/src/plugins/Automod/functions/clearOldRecentSpam.ts
Normal file
10
backend/src/plugins/Automod/functions/clearOldRecentSpam.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
import { PluginData } from "knub";
|
||||
import { AutomodPluginType } from "../types";
|
||||
import { RECENT_SPAM_EXPIRY_TIME } from "../constants";
|
||||
|
||||
export function clearOldRecentSpam(pluginData: PluginData<AutomodPluginType>) {
|
||||
const now = Date.now();
|
||||
pluginData.state.recentSpam = pluginData.state.recentSpam.filter(spam => {
|
||||
return spam.timestamp + RECENT_SPAM_EXPIRY_TIME > now;
|
||||
});
|
||||
}
|
|
@ -0,0 +1,84 @@
|
|||
import { RecentActionType } from "../constants";
|
||||
import { automodTrigger } from "../helpers";
|
||||
import { getBaseUrl } from "../../../pluginUtils";
|
||||
import { convertDelayStringToMS, tDelayString, tNullable } from "../../../utils";
|
||||
import { humanizeDurationShort } from "../../../humanizeDurationShort";
|
||||
import { findRecentSpam } from "./findRecentSpam";
|
||||
import { getMatchingMessageRecentActions } from "./getMatchingMessageRecentActions";
|
||||
import * as t from "io-ts";
|
||||
|
||||
const MessageSpamTriggerConfig = t.type({
|
||||
amount: t.number,
|
||||
within: tDelayString,
|
||||
per_channel: tNullable(t.boolean),
|
||||
});
|
||||
type TMessageSpamTriggerConfig = t.TypeOf<typeof MessageSpamTriggerConfig>;
|
||||
|
||||
const MessageSpamMatchResultType = t.type({
|
||||
archiveId: t.string,
|
||||
});
|
||||
type TMessageSpamMatchResultType = t.TypeOf<typeof MessageSpamMatchResultType>;
|
||||
|
||||
export function createMessageSpamTrigger(spamType: RecentActionType, prettyName: string) {
|
||||
return automodTrigger({
|
||||
configType: MessageSpamTriggerConfig,
|
||||
defaultConfig: {},
|
||||
|
||||
matchResultType: MessageSpamMatchResultType,
|
||||
|
||||
async match({ pluginData, context, triggerConfig }) {
|
||||
if (!context.message) {
|
||||
return;
|
||||
}
|
||||
|
||||
const recentSpam = findRecentSpam(pluginData, spamType, context.message.user_id);
|
||||
if (recentSpam) {
|
||||
// TODO: Combine with old archive
|
||||
return {
|
||||
silentClean: true,
|
||||
};
|
||||
}
|
||||
|
||||
const within = convertDelayStringToMS(triggerConfig.within);
|
||||
const matchedSpam = getMatchingMessageRecentActions(
|
||||
pluginData,
|
||||
context.message,
|
||||
spamType,
|
||||
triggerConfig.amount,
|
||||
within,
|
||||
triggerConfig.per_channel,
|
||||
);
|
||||
|
||||
if (matchedSpam) {
|
||||
// TODO: Generate archive link
|
||||
const archiveId = "TODO";
|
||||
|
||||
pluginData.state.recentSpam.push({
|
||||
type: spamType,
|
||||
userId: context.message.user_id,
|
||||
archiveId,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
return {
|
||||
extraContexts: matchedSpam.recentActions
|
||||
.map(action => action.context)
|
||||
.filter(_context => _context !== context),
|
||||
|
||||
extra: {
|
||||
archiveId,
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
renderMatchInformation({ pluginData, matchResult, triggerConfig }) {
|
||||
const baseUrl = getBaseUrl(pluginData);
|
||||
const archiveUrl = pluginData.state.archives.getUrl(baseUrl, matchResult.extra.archiveId);
|
||||
const withinMs = convertDelayStringToMS(triggerConfig.within);
|
||||
const withinStr = humanizeDurationShort(withinMs);
|
||||
|
||||
return `Matched ${prettyName} spam (${triggerConfig.amount} in ${withinStr}): ${archiveUrl}`;
|
||||
},
|
||||
});
|
||||
}
|
9
backend/src/plugins/Automod/functions/findRecentSpam.ts
Normal file
9
backend/src/plugins/Automod/functions/findRecentSpam.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
import { PluginData } from "knub";
|
||||
import { AutomodPluginType } from "../types";
|
||||
import { RecentActionType } from "../constants";
|
||||
|
||||
export function findRecentSpam(pluginData: PluginData<AutomodPluginType>, type: RecentActionType, userId: string) {
|
||||
return pluginData.state.recentSpam.find(spam => {
|
||||
return spam.type === type && spam.userId === userId;
|
||||
});
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
import { PluginData } from "knub";
|
||||
import { AutomodPluginType } from "../types";
|
||||
import { SavedMessage } from "../../../data/entities/SavedMessage";
|
||||
import moment from "moment-timezone";
|
||||
import { getMatchingRecentActions } from "./getMatchingRecentActions";
|
||||
import { RecentActionType } from "../constants";
|
||||
|
||||
export function getMatchingMessageRecentActions(
|
||||
pluginData: PluginData<AutomodPluginType>,
|
||||
message: SavedMessage,
|
||||
type: RecentActionType,
|
||||
count: number,
|
||||
within: number,
|
||||
perChannel: boolean,
|
||||
) {
|
||||
const since = moment.utc(message.posted_at).valueOf() - within;
|
||||
const to = moment.utc(message.posted_at).valueOf();
|
||||
const identifier = perChannel ? `${message.channel_id}-${message.user_id}` : message.user_id;
|
||||
const recentActions = getMatchingRecentActions(pluginData, type, identifier, since, to);
|
||||
const totalCount = recentActions.reduce((total, action) => total + action.count, 0);
|
||||
|
||||
if (totalCount >= count) {
|
||||
return {
|
||||
identifier,
|
||||
recentActions,
|
||||
};
|
||||
}
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
import { PluginData } from "knub";
|
||||
import { AutomodPluginType } from "../types";
|
||||
import { RecentActionType } from "../constants";
|
||||
|
||||
export function getMatchingRecentActions(
|
||||
pluginData: PluginData<AutomodPluginType>,
|
||||
type: RecentActionType,
|
||||
identifier: string | null,
|
||||
since: number,
|
||||
to: number,
|
||||
) {
|
||||
return pluginData.state.recentActions.filter(action => {
|
||||
return (
|
||||
action.type === type &&
|
||||
(!identifier || action.identifier === identifier) &&
|
||||
action.context.timestamp >= since &&
|
||||
action.context.timestamp <= to
|
||||
);
|
||||
});
|
||||
}
|
|
@ -0,0 +1,68 @@
|
|||
import * as t from "io-ts";
|
||||
import { SavedMessage } from "../../../data/entities/SavedMessage";
|
||||
import { resolveMember } from "../../../utils";
|
||||
import { PluginData } from "knub";
|
||||
import { AutomodPluginType } from "../types";
|
||||
|
||||
type TextTriggerWithMultipleMatchTypes = {
|
||||
match_messages: boolean;
|
||||
match_embeds: boolean;
|
||||
match_visible_names: boolean;
|
||||
match_usernames: boolean;
|
||||
match_nicknames: boolean;
|
||||
match_custom_status: boolean;
|
||||
};
|
||||
|
||||
export const MatchableTextType = t.union([
|
||||
t.literal("message"),
|
||||
t.literal("embed"),
|
||||
t.literal("visiblename"),
|
||||
t.literal("username"),
|
||||
t.literal("nickname"),
|
||||
t.literal("customstatus"),
|
||||
]);
|
||||
|
||||
export type TMatchableTextType = t.TypeOf<typeof MatchableTextType>;
|
||||
|
||||
type YieldedContent = [TMatchableTextType, string];
|
||||
|
||||
/**
|
||||
* Generator function that allows iterating through matchable pieces of text of a SavedMessage
|
||||
*/
|
||||
export async function* matchMultipleTextTypesOnMessage(
|
||||
pluginData: PluginData<AutomodPluginType>,
|
||||
trigger: TextTriggerWithMultipleMatchTypes,
|
||||
msg: SavedMessage,
|
||||
): AsyncIterableIterator<YieldedContent> {
|
||||
const member = await resolveMember(pluginData.client, pluginData.guild, msg.user_id);
|
||||
if (!member) return;
|
||||
|
||||
if (trigger.match_messages && msg.data.content) {
|
||||
yield ["message", msg.data.content];
|
||||
}
|
||||
|
||||
if (trigger.match_embeds && msg.data.embeds && msg.data.embeds.length) {
|
||||
const copiedEmbed = JSON.parse(JSON.stringify(msg.data.embeds[0]));
|
||||
if (copiedEmbed.type === "video") {
|
||||
copiedEmbed.description = ""; // The description is not rendered, hence it doesn't need to be matched
|
||||
}
|
||||
yield ["embed", JSON.stringify(copiedEmbed)];
|
||||
}
|
||||
|
||||
if (trigger.match_visible_names) {
|
||||
yield ["visiblename", member.nick || msg.data.author.username];
|
||||
}
|
||||
|
||||
if (trigger.match_usernames) {
|
||||
yield ["username", `${msg.data.author.username}#${msg.data.author.discriminator}`];
|
||||
}
|
||||
|
||||
if (trigger.match_nicknames && member.nick) {
|
||||
yield ["nickname", member.nick];
|
||||
}
|
||||
|
||||
// type 4 = custom status
|
||||
if (trigger.match_custom_status && member.game?.type === 4 && member.game?.state) {
|
||||
yield ["customstatus", member.game.state];
|
||||
}
|
||||
}
|
83
backend/src/plugins/Automod/functions/runAutomod.ts
Normal file
83
backend/src/plugins/Automod/functions/runAutomod.ts
Normal file
|
@ -0,0 +1,83 @@
|
|||
import { PluginData } from "knub";
|
||||
import { AutomodContext, AutomodPluginType, TRule } from "../types";
|
||||
import { availableTriggers } from "../triggers/availableTriggers";
|
||||
import { availableActions } from "../actions/availableActions";
|
||||
import { AutomodTriggerMatchResult } from "../helpers";
|
||||
import { CleanAction } from "../actions/clean";
|
||||
|
||||
export async function runAutomod(pluginData: PluginData<AutomodPluginType>, context: AutomodContext) {
|
||||
const userId = context.user?.id || context.message?.user_id;
|
||||
const member = userId && pluginData.guild.members.get(userId);
|
||||
const channelId = context.message?.channel_id;
|
||||
const channel = channelId && pluginData.guild.channels.get(channelId);
|
||||
const categoryId = channel?.parentID;
|
||||
|
||||
const config = pluginData.config.getMatchingConfig({
|
||||
channelId,
|
||||
categoryId,
|
||||
userId,
|
||||
member,
|
||||
});
|
||||
|
||||
for (const [ruleName, rule] of Object.entries(config.rules)) {
|
||||
if (rule.enabled === false) continue;
|
||||
|
||||
let matchResult: AutomodTriggerMatchResult<any>;
|
||||
let matchSummary: string;
|
||||
let contexts: AutomodContext[];
|
||||
|
||||
triggerLoop: for (const triggerItem of rule.triggers) {
|
||||
for (const [triggerName, triggerConfig] of Object.entries(triggerItem)) {
|
||||
const trigger = availableTriggers[triggerName];
|
||||
matchResult = await trigger.match({
|
||||
ruleName,
|
||||
pluginData,
|
||||
context,
|
||||
triggerConfig,
|
||||
});
|
||||
|
||||
if (matchResult) {
|
||||
contexts = [context, ...(matchResult.extraContexts || [])];
|
||||
|
||||
for (const _context of contexts) {
|
||||
_context.actioned = true;
|
||||
}
|
||||
|
||||
if (matchResult.silentClean) {
|
||||
await CleanAction.apply({
|
||||
ruleName,
|
||||
pluginData,
|
||||
contexts,
|
||||
actionConfig: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
matchSummary = await trigger.renderMatchInformation({
|
||||
ruleName,
|
||||
pluginData,
|
||||
contexts,
|
||||
matchResult,
|
||||
triggerConfig,
|
||||
});
|
||||
|
||||
break triggerLoop;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (matchResult) {
|
||||
for (const [actionName, actionConfig] of Object.entries(rule.actions)) {
|
||||
const action = availableActions[actionName];
|
||||
action.apply({
|
||||
ruleName,
|
||||
pluginData,
|
||||
contexts,
|
||||
actionConfig,
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue