import { rest } from "@/discord"; import { hasManagerPermissions } from "@/utils/discord/permissions"; import { type Client, type Message, type RESTGetAPIGuildMessagesSearchQuery, type RESTGetAPIGuildMessagesSearchResult, Routes, type Snowflake, } from "discord.js"; import { loggers } from "@/utils/logging"; const logger = loggers.automod; type Pending = { channels: Set; timer: NodeJS.Timeout; }; const pendingReports = new Map(); const cooldowns = new Map(); async function getLogChannel(client: Client) { return client.channels.fetch("1478940790112915486"); } type AutomodResult = { delete: true } | null; type AutomodSignals = { messageCount?: number; lastMessage?: Message; }; type AutomodFunction = ( client: Client, message: Message, ) => Promise; async function fetchSignals( client: Client, message: Message, ): Promise { const signals: AutomodSignals = { messageCount: undefined, }; const messages = (await rest.get( `/guilds/${message.guildId}/messages/search`, { body: { author_id: [message.author.id], } as RESTGetAPIGuildMessagesSearchQuery, }, )) as RESTGetAPIGuildMessagesSearchResult; if ("total_results" in messages) { signals.messageCount = messages.total_results; const [lastApiMessage] = messages.messages[1]; const channel = await client.channels.fetch(lastApiMessage.channel_id); if (channel?.isTextBased()) { const lastMessage = await channel.messages .fetch(lastApiMessage.id) .catch(() => null); if (lastMessage) { signals.lastMessage = lastMessage; } } } return signals; } export async function handleMessage(client: Client, message: Message) { if (message.author.bot || !message.guildId || !message.member) return; const automodFunctions: AutomodFunction[] = [handleSpamMarker]; if (await hasManagerPermissions(message.member)) return null; if (message.channel.isThread()) return null; for (const func of automodFunctions) { const result = await func(client, message); if (result?.delete) { await message.delete().catch(() => null); break; } } } async function handleSpamMarker( client: Client, message: Message, ): Promise { const { content } = message; /* a message will contain either no markers, a link marker or a word maker, likely not two at once. if a messsage contains a link marker, then we delete it. if a message contains a word marker, we first check if the last message is older than an hour, if it is, we delete the message. */ const linkMarkers = [ "discord.gg/", "discordapp.com/invite/", "discord.com/invite/", ]; const wordMarkers = ["check my bio"]; const hasLinkMarker = linkMarkers.some((marker) => content.toLowerCase().includes(marker), ); const hasWordMarker = wordMarkers.some((marker) => content.toLowerCase().includes(marker), ); if (!hasLinkMarker && !hasWordMarker) return null; const signals = await fetchSignals(client, message); const ONE_HOUR = 60 * 60 * 1000; logger.debug( { signals, message, markers: { hasLinkMarker, hasWordMarker, }, }, "evaluating message for potential phishing markers", ); if ( hasWordMarker && signals.lastMessage && Date.now() - signals.lastMessage.createdTimestamp < ONE_HOUR ) { logger.debug( { messageId: message.id, authorId: message.author.id, lastMessageId: signals.lastMessage.id, lastMessageAge: Date.now() - signals.lastMessage.createdTimestamp, }, "skipping deletion due to recent message", ); return null; } if (cooldowns.has(message.author.id)) { return { delete: true }; } const existing = pendingReports.get(message.author.id); if (!existing) { const content = [ `<@${message.author.id}>, your message contained something indicative of a compromised account or phishing attempt, so we removed it.`, "Keep in mind, we don't allow advertising other servers here.", ]; await message.reply(content.join(" ")); const logChannel = await getLogChannel(client); if (logChannel?.isSendable()) { await message.forward(logChannel); logChannel.send( `Deleted a message from <@${message.author.id}> in <#${message.channelId}> containing a potential phishing marker.`, ); } const timer = setTimeout( () => flushReport(client, message.author.id), 30_000, ); pendingReports.set(message.author.id, { channels: new Set([message.channelId]), timer, }); return { delete: true }; } existing.channels.add(message.channelId); clearTimeout(existing.timer); existing.timer = setTimeout( () => flushReport(client, message.author.id), 30_000, ); return { delete: true }; } async function flushReport(client: Client, authorId: Snowflake) { const pending = pendingReports.get(authorId); if (!pending) return; pendingReports.delete(authorId); clearTimeout(pending.timer); const logChannel = await getLogChannel(client); if (!logChannel?.isSendable()) return; const channels = Array.from(pending.channels); const channelMentions = channels.map((c) => `<#${c}>`).join(", "); logChannel.send( `<@${authorId}> posted message in channels: ${channelMentions}`, ); const cooldownTimer = setTimeout( () => cooldowns.delete(authorId), 5 * 60 * 1000, ); cooldowns.set(authorId, cooldownTimer); }