feat(automod): check last message age
vi v@vt3e.cat
Sat, 11 Apr 2026 17:51:13 +0100
4 files changed,
142 insertions(+),
12 deletions(-)
M
src/automod/index.ts
→
src/automod/index.ts
@@ -1,5 +1,16 @@
+import { rest } from "@/discord"; import { hasManagerPermissions } from "@/utils/discord/permissions"; -import type { Client, Message, Snowflake } from "discord.js"; +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<Snowflake>;@@ -14,21 +25,60 @@ return client.channels.fetch("1478940790112915486");
} type AutomodResult = { delete: true } | null; +type AutomodSignals = { + messageCount?: number; + lastMessage?: Message; +}; type AutomodFunction = ( client: Client, message: Message, ) => Promise<AutomodResult>; +async function fetchSignals( + client: Client, + message: Message, +): Promise<AutomodSignals> { + 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) return; + if (message.author.bot || !message.guildId || !message.member) return; const automodFunctions: AutomodFunction[] = [handleSpamMarker]; - if (message.author.bot || !message.guildId || !message.member) return null; 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;@@ -40,15 +90,62 @@ async function handleSpamMarker(
client: Client, message: Message, ): Promise<AutomodResult> { - const { content } = message; - const blacklisted = [ + 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/", - "check my bio" + "discord.com/invite/", ]; + const wordMarkers = ["check my bio"]; - if (!blacklisted.some((link) => content.includes(link))) return null; + 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 };
A
src/discord.ts
@@ -0,0 +1,30 @@
+import { Client, Events, GatewayIntentBits, REST } from "discord.js"; + +import registerEvents from "./handlers/events.ts"; +import registerInteractions from "./handlers/interactions.ts"; + +import config from "./config.ts"; +import { startTicketWatcher } from "./tasks/ticketWatcher.ts"; +import logger from "./utils/logging.ts"; + +const client = new Client({ + intents: [ + GatewayIntentBits.GuildMessages, + GatewayIntentBits.Guilds, + GatewayIntentBits.MessageContent, + ], +}); + +client.on(Events.ClientReady, async (client) => { + logger.info(`logged in as ${client.user?.tag}!`); + + await Promise.all([registerEvents(client), registerInteractions(client)]); + logger.info("events and interactions registered!"); + console.log(); + + startTicketWatcher(client); +}); + +export default client; + +export const rest = new REST({ version: "9" }).setToken(config.discord.token);
M
src/handlers/interactions.ts
→
src/handlers/interactions.ts
@@ -1,7 +1,7 @@
+import { rest } from "@/discord.ts"; import type { ApplicationCommandData, Client, Snowflake } from "discord.js"; -import { Collection, REST, Routes } from "discord.js"; +import { Collection, Routes } from "discord.js"; -import cfg from "@/config.ts"; import type { ICommand } from "@/types.ts"; import _logger from "@/utils/logging.ts"; import { crawlDirectory, getHandlerPath } from "./common.ts";@@ -57,7 +57,6 @@ return logger.error("client application is not available");
if (interactions.length === 0) return; logger.info("registering interactions..."); - const rest = new REST({ version: "9" }).setToken(cfg.discord.token); try { logger.info("registering commands...");
M
src/utils/logging.ts
→
src/utils/logging.ts
@@ -10,7 +10,10 @@ const logger = pino({
transport: { target: "pino-pretty", }, - + base: { + pid: false, + hostname: false, + }, level: logLevel.toLowerCase(), });@@ -18,6 +21,7 @@ export const loggers = {
events: logger.child({ name: "events" }), interactions: logger.child({ name: "interactions" }), config: logger.child({ name: "config" }), + automod: logger.child({ name: "automod" }), }; export default logger;