diff --git a/backend/src/plugins/Automod/AutomodPlugin.ts b/backend/src/plugins/Automod/AutomodPlugin.ts index 16339309..6dbc8158 100644 --- a/backend/src/plugins/Automod/AutomodPlugin.ts +++ b/backend/src/plugins/Automod/AutomodPlugin.ts @@ -24,6 +24,7 @@ import { RunAutomodOnJoinEvt, RunAutomodOnLeaveEvt } from "./events/RunAutomodOn import { RunAutomodOnMemberUpdate } from "./events/RunAutomodOnMemberUpdate"; import { runAutomodOnMessage } from "./events/runAutomodOnMessage"; import { runAutomodOnModAction } from "./events/runAutomodOnModAction"; +import { RunAutomodOnThreadCreate, RunAutomodOnThreadDelete } from "./events/runAutomodOnThreadEvents"; import { clearOldRecentNicknameChanges } from "./functions/clearOldNicknameChanges"; import { clearOldRecentActions } from "./functions/clearOldRecentActions"; import { clearOldRecentSpam } from "./functions/clearOldRecentSpam"; @@ -202,6 +203,8 @@ export const AutomodPlugin = zeppelinGuildPlugin()({ RunAutomodOnJoinEvt, RunAutomodOnMemberUpdate, RunAutomodOnLeaveEvt, + RunAutomodOnThreadCreate, + RunAutomodOnThreadDelete, // Messages use message events from SavedMessages, see onLoad below ], diff --git a/backend/src/plugins/Automod/events/runAutomodOnThreadEvents.ts b/backend/src/plugins/Automod/events/runAutomodOnThreadEvents.ts new file mode 100644 index 00000000..f1f47a48 --- /dev/null +++ b/backend/src/plugins/Automod/events/runAutomodOnThreadEvents.ts @@ -0,0 +1,47 @@ +import { typedGuildEventListener } from "knub"; +import { runAutomod } from "../functions/runAutomod"; +import { AutomodContext, AutomodPluginType } from "../types"; + +export const RunAutomodOnThreadCreate = typedGuildEventListener()({ + event: "threadCreate", + async listener({ pluginData, args: { thread } }) { + const user = thread.ownerId + ? await pluginData.client.users.fetch(thread.ownerId).catch(() => undefined) + : undefined; + + const context: AutomodContext = { + timestamp: Date.now(), + threadChange: { + created: thread, + }, + user, + channel: thread, + }; + + pluginData.state.queue.add(() => { + runAutomod(pluginData, context); + }); + }, +}); + +export const RunAutomodOnThreadDelete = typedGuildEventListener()({ + event: "threadDelete", + async listener({ pluginData, args: { thread } }) { + const user = thread.ownerId + ? await pluginData.client.users.fetch(thread.ownerId).catch(() => undefined) + : undefined; + + const context: AutomodContext = { + timestamp: Date.now(), + threadChange: { + deleted: thread, + }, + user, + channel: thread, + }; + + pluginData.state.queue.add(() => { + runAutomod(pluginData, context); + }); + }, +}); diff --git a/backend/src/plugins/Automod/functions/runAutomod.ts b/backend/src/plugins/Automod/functions/runAutomod.ts index 7f298eb7..d21818ff 100644 --- a/backend/src/plugins/Automod/functions/runAutomod.ts +++ b/backend/src/plugins/Automod/functions/runAutomod.ts @@ -15,9 +15,11 @@ export async function runAutomod(pluginData: GuildPluginData, const member = context.member || (userId && pluginData.guild.members.cache.get(userId as Snowflake)) || null; const channelIdOrThreadId = context.message?.channel_id; - const channelOrThread = channelIdOrThreadId - ? (pluginData.guild.channels.cache.get(channelIdOrThreadId as Snowflake) as TextChannel | ThreadChannel) - : null; + const channelOrThread = + context.channel ?? + (channelIdOrThreadId + ? (pluginData.guild.channels.cache.get(channelIdOrThreadId as Snowflake) as TextChannel | ThreadChannel) + : null); const channelId = channelOrThread?.isThread() ? channelOrThread.parent?.id : channelIdOrThreadId; const threadId = channelOrThread?.isThread() ? channelOrThread.id : null; const channel = channelOrThread?.isThread() ? channelOrThread.parent : channelOrThread; @@ -33,7 +35,15 @@ export async function runAutomod(pluginData: GuildPluginData, for (const [ruleName, rule] of Object.entries(config.rules)) { if (rule.enabled === false) continue; - if (!rule.affects_bots && (!user || user.bot) && !context.counterTrigger && !context.antiraid) continue; + if ( + !rule.affects_bots && + (!user || user.bot) && + !context.counterTrigger && + !context.antiraid && + !context.threadChange?.deleted + ) { + continue; + } if (!rule.affects_self && userId && userId === pluginData.client.user?.id) continue; if (rule.cooldown && checkAndUpdateCooldown(pluginData, rule, context)) { diff --git a/backend/src/plugins/Automod/triggers/availableTriggers.ts b/backend/src/plugins/Automod/triggers/availableTriggers.ts index 22c63cd9..0bd3b949 100644 --- a/backend/src/plugins/Automod/triggers/availableTriggers.ts +++ b/backend/src/plugins/Automod/triggers/availableTriggers.ts @@ -26,6 +26,8 @@ import { NoteTrigger } from "./note"; import { RoleAddedTrigger } from "./roleAdded"; import { RoleRemovedTrigger } from "./roleRemoved"; import { StickerSpamTrigger } from "./stickerSpam"; +import { ThreadCreateTrigger } from "./threadCreate"; +import { ThreadDeleteTrigger } from "./threadDelete"; import { UnbanTrigger } from "./unban"; import { UnmuteTrigger } from "./unmute"; import { WarnTrigger } from "./warn"; @@ -64,6 +66,9 @@ export const availableTriggers: Record unban: UnbanTrigger, antiraid_level: AntiraidLevelTrigger, + + thread_create: ThreadCreateTrigger, + thread_delete: ThreadDeleteTrigger, }; export const AvailableTriggers = t.type({ @@ -101,4 +106,7 @@ export const AvailableTriggers = t.type({ unban: UnbanTrigger.configType, antiraid_level: AntiraidLevelTrigger.configType, + + thread_create: ThreadCreateTrigger.configType, + thread_delete: ThreadDeleteTrigger.configType, }); diff --git a/backend/src/plugins/Automod/triggers/threadCreate.ts b/backend/src/plugins/Automod/triggers/threadCreate.ts new file mode 100644 index 00000000..f4bc7e7a --- /dev/null +++ b/backend/src/plugins/Automod/triggers/threadCreate.ts @@ -0,0 +1,48 @@ +import { Snowflake } from "discord-api-types"; +import { User, Util } from "discord.js"; +import * as t from "io-ts"; +import { automodTrigger } from "../helpers"; + +interface ThreadCreateResult { + matchedThreadId: Snowflake; + matchedThreadName: string; + matchedThreadParentId: Snowflake; + matchedThreadParentName: string; + matchedThreadOwner: User | undefined; +} + +export const ThreadCreateTrigger = automodTrigger()({ + configType: t.type({}), + defaultConfig: {}, + + async match({ context }) { + if (!context.threadChange?.created) { + return; + } + + const thread = context.threadChange.created; + + return { + extra: { + matchedThreadId: thread.id, + matchedThreadName: thread.name, + matchedThreadParentId: thread.parentId ?? "Unknown", + matchedThreadParentName: thread.parent?.name ?? "Unknown", + matchedThreadOwner: context.user, + }, + }; + }, + + async renderMatchInformation({ matchResult }) { + const threadId = matchResult.extra.matchedThreadId; + const threadName = matchResult.extra.matchedThreadName; + const threadOwner = matchResult.extra.matchedThreadOwner; + const parentId = matchResult.extra.matchedThreadParentId; + const parentName = matchResult.extra.matchedThreadParentName; + const base = `Thread **#${threadName}** (\`${threadId}\`) has been created in the **#${parentName}** (\`${parentId}\`) channel`; + if (threadOwner) { + return `${base} by **${Util.escapeBold(threadOwner.tag)}** (\`${threadOwner.id}\`)`; + } + return base; + }, +}); diff --git a/backend/src/plugins/Automod/triggers/threadDelete.ts b/backend/src/plugins/Automod/triggers/threadDelete.ts new file mode 100644 index 00000000..988ee752 --- /dev/null +++ b/backend/src/plugins/Automod/triggers/threadDelete.ts @@ -0,0 +1,51 @@ +import { Snowflake } from "discord-api-types"; +import { User, Util } from "discord.js"; +import * as t from "io-ts"; +import { automodTrigger } from "../helpers"; + +interface ThreadDeleteResult { + matchedThreadId: Snowflake; + matchedThreadName: string; + matchedThreadParentId: Snowflake; + matchedThreadParentName: string; + matchedThreadOwner: User | undefined; +} + +export const ThreadDeleteTrigger = automodTrigger()({ + configType: t.type({}), + defaultConfig: {}, + + async match({ context }) { + if (!context.threadChange?.deleted) { + return; + } + + const thread = context.threadChange.deleted; + + return { + extra: { + matchedThreadId: thread.id, + matchedThreadName: thread.name, + matchedThreadParentId: thread.parentId ?? "Unknown", + matchedThreadParentName: thread.parent?.name ?? "Unknown", + matchedThreadOwner: context.user, + }, + }; + }, + + renderMatchInformation({ matchResult }) { + const threadId = matchResult.extra.matchedThreadId; + const threadOwner = matchResult.extra.matchedThreadOwner; + const threadName = matchResult.extra.matchedThreadName; + const parentId = matchResult.extra.matchedThreadParentId; + const parentName = matchResult.extra.matchedThreadParentName; + if (threadOwner) { + return `Thread **#${threadName ?? "Unknown"}** (\`${threadId}\`) created by **${Util.escapeBold( + threadOwner.tag, + )}** (\`${threadOwner.id}\`) in the **#${parentName}** (\`${parentId}\`) channel has been deleted`; + } + return `Thread **#${ + threadName ?? "Unknown" + }** (\`${threadId}\`) from the **#${parentName}** (\`${parentId}\`) channel has been deleted`; + }, +}); diff --git a/backend/src/plugins/Automod/types.ts b/backend/src/plugins/Automod/types.ts index 8fee6e7d..7ae40f30 100644 --- a/backend/src/plugins/Automod/types.ts +++ b/backend/src/plugins/Automod/types.ts @@ -1,4 +1,4 @@ -import { GuildMember, PartialGuildMember, User } from "discord.js"; +import { GuildMember, PartialGuildMember, TextChannel, ThreadChannel, User } from "discord.js"; import * as t from "io-ts"; import { BasePluginType, CooldownManager } from "knub"; import { SavedMessage } from "../../data/entities/SavedMessage"; @@ -131,6 +131,11 @@ export interface AutomodContext { antiraid?: { level: string | null; }; + threadChange?: { + created?: ThreadChannel; + deleted?: ThreadChannel; + }; + channel?: TextChannel | ThreadChannel; } export interface RecentAction {