diff --git a/backend/src/plugins/Automod/AutomodPlugin.ts b/backend/src/plugins/Automod/AutomodPlugin.ts index 6dbc8158..6c8a2605 100644 --- a/backend/src/plugins/Automod/AutomodPlugin.ts +++ b/backend/src/plugins/Automod/AutomodPlugin.ts @@ -167,6 +167,9 @@ const configPreprocessor: ConfigPreprocessorFn = (options) => if (rule["actions"]["log"] == null) { rule["actions"]["log"] = true; } + if (rule["actions"]["clean"] && rule["actions"]["start_thread"]) { + throw new StrictValidationError([`Cannot have both clean and start_thread at rule '${rule.name}'`]); + } } } } diff --git a/backend/src/plugins/Automod/actions/availableActions.ts b/backend/src/plugins/Automod/actions/availableActions.ts index 76b2a60e..c37f3a39 100644 --- a/backend/src/plugins/Automod/actions/availableActions.ts +++ b/backend/src/plugins/Automod/actions/availableActions.ts @@ -15,6 +15,7 @@ import { ReplyAction } from "./reply"; import { SetAntiraidLevelAction } from "./setAntiraidLevel"; import { SetCounterAction } from "./setCounter"; import { SetSlowmodeAction } from "./setSlowmode"; +import { StartThreadAction } from "./startThread"; import { WarnAction } from "./warn"; export const availableActions: Record> = { @@ -33,6 +34,7 @@ export const availableActions: Record> = { add_to_counter: AddToCounterAction, set_counter: SetCounterAction, set_slowmode: SetSlowmodeAction, + start_thread: StartThreadAction, archive_thread: ArchiveThreadAction, }; @@ -52,5 +54,6 @@ export const AvailableActions = t.type({ add_to_counter: AddToCounterAction.configType, set_counter: SetCounterAction.configType, set_slowmode: SetSlowmodeAction.configType, + start_thread: StartThreadAction.configType, archive_thread: ArchiveThreadAction.configType, }); diff --git a/backend/src/plugins/Automod/actions/startThread.ts b/backend/src/plugins/Automod/actions/startThread.ts new file mode 100644 index 00000000..03a2dda2 --- /dev/null +++ b/backend/src/plugins/Automod/actions/startThread.ts @@ -0,0 +1,94 @@ +import { GuildFeature, ThreadAutoArchiveDuration } from "discord-api-types"; +import { TextChannel } from "discord.js"; +import * as t from "io-ts"; +import { renderTemplate, TemplateSafeValueContainer } from "../../../templateFormatter"; +import { ChannelTypeStrings } from "../../../types"; +import { convertDelayStringToMS, MINUTES, tDelayString, tNullable } from "../../../utils"; +import { savedMessageToTemplateSafeSavedMessage, userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; +import { automodAction } from "../helpers"; + +export const StartThreadAction = automodAction({ + configType: t.type({ + name: tNullable(t.string), + auto_archive: tDelayString, + private: tNullable(t.boolean), + slowmode: tNullable(tDelayString), + limit_per_channel: tNullable(t.number), + }), + + defaultConfig: { + limit_per_channel: 5, + }, + + async apply({ pluginData, contexts, actionConfig, ruleName }) { + // check if the message still exists, we don't want to create threads for deleted messages + const threads = contexts.filter((c) => { + if (!c.message || !c.user) return false; + const channel = pluginData.guild.channels.cache.get(c.message.channel_id); + if (channel?.type !== ChannelTypeStrings.TEXT || !channel.isText()) return false; // for some reason the typing here for channel.type defaults to ThreadChannelTypes (?) + // check against max threads per channel + if (actionConfig.limit_per_channel && actionConfig.limit_per_channel > 0) { + const threadCount = channel.threads.cache.filter( + (tr) => + tr.ownerId === pluginData.client.user!.id && !tr.deleted && !tr.archived && tr.parentId === channel.id, + ).size; + if (threadCount >= actionConfig.limit_per_channel) return false; + } + return true; + }); + + const guild = pluginData.guild; + const archiveSet = actionConfig.auto_archive + ? Math.ceil(Math.max(convertDelayStringToMS(actionConfig.auto_archive) ?? 0, 0) / MINUTES) + : ThreadAutoArchiveDuration.OneDay; + let autoArchive: ThreadAutoArchiveDuration; + if (archiveSet === ThreadAutoArchiveDuration.OneDay) { + autoArchive = ThreadAutoArchiveDuration.OneDay; + } else if ( + archiveSet === ThreadAutoArchiveDuration.ThreeDays && + guild.features.includes(GuildFeature.ThreeDayThreadArchive) + ) { + autoArchive = ThreadAutoArchiveDuration.ThreeDays; + } else if ( + archiveSet === ThreadAutoArchiveDuration.OneWeek && + guild.features.includes(GuildFeature.SevenDayThreadArchive) + ) { + autoArchive = ThreadAutoArchiveDuration.OneWeek; + } else { + autoArchive = ThreadAutoArchiveDuration.OneHour; + } + + for (const threadContext of threads) { + const channel = pluginData.guild.channels.cache.get(threadContext.message!.channel_id) as TextChannel; + const renderThreadName = async (str: string) => + renderTemplate( + str, + new TemplateSafeValueContainer({ + user: userToTemplateSafeUser(threadContext.user!), + msg: savedMessageToTemplateSafeSavedMessage(threadContext.message!), + }), + ); + const threadName = await renderThreadName(actionConfig.name ?? "{user.tag}s thread"); + const thread = await channel.threads + .create({ + name: threadName, + autoArchiveDuration: autoArchive, + type: + actionConfig.private && guild.features.includes(GuildFeature.PrivateThreads) + ? ChannelTypeStrings.PRIVATE_THREAD + : ChannelTypeStrings.PUBLIC_THREAD, + startMessage: + !actionConfig.private && guild.features.includes(GuildFeature.PrivateThreads) + ? threadContext.message!.id + : undefined, + }) + .catch(noop); + if (actionConfig.slowmode && thread) { + const dur = Math.ceil(Math.max(convertDelayStringToMS(actionConfig.slowmode) ?? 0, 0) / 1000); + if (dur > 0) { + await thread.setRateLimitPerUser(dur).catch(noop); + } + } + } + }, +});