import { ChannelType, type Client, type Message, type Snowflake } from "discord.js"; import { distance as levelsteinDistance } from "fastest-levenshtein"; import Tesseract from "tesseract.js"; import phash from "sharp-phash"; import phashDistance from "sharp-phash/distance.js"; import { hasManagerPermissions } from "@/utils/discord/permissions"; import { loggers } from "@/utils/logging"; import { db } from "@/database"; import { automodHashes } from "@/database/schema/automod"; import { isProd } from "@/config"; const logger = loggers.automod; type Pending = { channels: Set; reasons: Set; timer: NodeJS.Timeout; }; const pendingReports = new Map(); const cooldowns = new Map(); async function getLogChannel(client: Client) { return client.channels.fetch(isProd ? "1493015326554587146" : "1493015912234881205"); } type AutomodResult = { delete: true } | null; type AutomodFunction = (client: Client, message: Message) => Promise; export async function handleMessage(client: Client, message: Message) { if (message.author.bot || !message.guildId || !message.member) return; const automodFunctions: AutomodFunction[] = [handleSpamMarker, handleCryptoScamOCR]; if (message.content.includes("!clear")) { cooldowns.delete(message.author.id); pendingReports.delete(message.author.id); return; } if (message.channel.type == ChannelType.GuildText && message.channel.name.includes("ticket")) return; if (message.channel.isThread()) return null; if (message.content.includes("!bypass")) return null; if ( isProd && !message.content.includes("!test") && (await hasManagerPermissions(message.member)) ) { return null; } for (const func of automodFunctions) { const result = await func(client, message); if (result?.delete) { await message.delete().catch(() => null); break; } } } async function reportToAutomod( client: Client, message: Message, reason: string, userReply: string, ) { if (cooldowns.has(message.author.id)) return; const existing = pendingReports.get(message.author.id); if (!existing) { if (userReply) await message.reply(userReply).catch(() => null); const logChannel = await getLogChannel(client); if (logChannel?.isSendable()) { await message.forward(logChannel).catch(() => null); logChannel .send( `Deleted a message from <@${message.author.id}> in <#${message.channelId}>.\n> ${reason}`, ) .catch(() => null); } const timer = setTimeout(() => flushReport(client, message.author.id), 30_000); pendingReports.set(message.author.id, { channels: new Set([message.channelId]), reasons: new Set([reason]), timer, }); } else { existing.channels.add(message.channelId); existing.reasons.add(reason); clearTimeout(existing.timer); existing.timer = setTimeout(() => flushReport(client, message.author.id), 30_000); } } async function handleCryptoScamOCR(client: Client, message: Message): Promise { if (message.attachments.size === 0) return null; if (pendingReports.has(message.author.id) || cooldowns.has(message.author.id)) { logger.info( { messageId: message.id, authorId: message.author.id }, "Skipping OCR as user has already been recently flagged", ); await reportToAutomod( client, message, "User repeatedly sent images after being flagged", "You sent an image indicative of your account being compromised or a phishing attempt, so we removed it.", ); return { delete: true }; } const scamPatterns = [ "excited announce launch very own crypto casino", "launching my own crypto casino", "check out my crypto casino", "claim your reward", "special promo code", ]; for (const attachment of message.attachments.values()) { if (!attachment.contentType?.startsWith("image/")) continue; try { logger.debug( { messageId: message.id, attachmentUrl: attachment.url }, "Fetching image for hash/OCR", ); const imageResponse = await fetch(attachment.url); if (!imageResponse.ok) continue; const arrayBuffer = await imageResponse.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); const pHash = await phash(buffer); const existingHashes = await db.select().from(automodHashes); let matchedHash: string | undefined; for (const existing of existingHashes) { const dist = phashDistance(pHash, existing.hash); if (dist <= 8) { matchedHash = existing.hash; break; } } if (matchedHash) { logger.info( { messageId: message.id, authorId: message.author.id, imageHash: pHash, }, "Image matches known scam hash in DB, skipping OCR", ); await reportToAutomod( client, message, `Image matched known scam hash (in:${pHash};matched:${matchedHash})`, [ "You sent an image indicative of your account being compromised or a phishing attempt, so we removed it.", "You will not be able to send images for 10 minutes.", ].join("\n"), ); return { delete: true }; } logger.debug({ messageId: message.id, imageHash: pHash }, "Running OCR on image attachment"); const { data: { text }, } = await Tesseract.recognize(buffer, "eng", { errorHandler: (err) => logger.warn({ err }, "OCR error"), }); const normalizedText = text .toLowerCase() .replace(/[^\w\s]/g, " ") .replace(/\s+/g, " ") .trim(); logger.debug({ messageId: message.id, extractedText: normalizedText }, "OCR completed"); let isScam = scamPatterns.some((pattern) => normalizedText.includes(pattern)); if (!isScam) { const words = normalizedText.split(" "); for (const pattern of scamPatterns) { const patternWords = pattern.split(" "); const windowSize = patternWords.length; for (let i = 0; i <= words.length - windowSize; i++) { const window = words.slice(i, i + windowSize).join(" "); const similarity = 1 - levelsteinDistance(pattern, window) / Math.max(pattern.length, window.length); if (similarity > 0.7) { logger.info( { messageId: message.id, pattern, window, similarity, }, "fuzzy match found", ); isScam = true; break; } } if (isScam) break; } } const suspiciousKeywords = [ "crypto", "luxgamb", "promo code", "claim reward", "bonus will be deleted", "deleted in an hour", ]; const hasSuspiciousKeyword = suspiciousKeywords.some((keyword) => normalizedText.includes(keyword), ); if (isScam || hasSuspiciousKeyword) { logger.info( { messageId: message.id, authorId: message.author.id, isScam, hasSuspiciousKeyword, }, "detected probable crypto scam", ); try { await db .insert(automodHashes) .values({ hash: pHash, addedAt: Date.now(), }) .onConflictDoNothing(); logger.debug({ messageId: message.id, imageHash: pHash }, "saved scam image hash to DB"); } catch (err) { logger.error({ err, imageHash: pHash }, "failed to save scam image hash to DB"); } } if (isScam) { await reportToAutomod( client, message, "OCR detected probable crypto scam", "You sent an image indicative of your account being compromised or a phishing attempt, so we removed it.", ); return { delete: true }; } if (hasSuspiciousKeyword) { await reportToAutomod( client, message, "OCR detected suspicious keywords", "You sent an image that contained suspicious keywords indicative of your account being compromised or a phishing attempt, so we removed it.", ); return { delete: true }; } } catch (err) { logger.error({ err, messageId: message.id }, "failed to process image for ocr"); } } return null; } 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); // 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; // } await reportToAutomod( client, message, "potential phishing marker", `<@${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.`, ); 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(", "); const reasons = Array.from(pending.reasons).join(" | "); logChannel.send( `<@${authorId}> posted flagged messages in channels: ${channelMentions}\n> ${reasons}`, ); const MINUTES = 1; const cooldownTimer = setTimeout(() => cooldowns.delete(authorId), MINUTES * 60 * 1000); cooldowns.set(authorId, cooldownTimer); }