all repos — stealth-developers @ faabc8d87687fbd6bf23fc56f3cf614409b526d9

feat(automod): hash images
vi did:web:vt3e.cat
Fri, 08 May 2026 23:52:56 +0100
commit

faabc8d87687fbd6bf23fc56f3cf614409b526d9

parent

7c56a6b32a7cd20904cba12b5dae1cbe15dbbb4b

3 files changed, 57 insertions(+), 5 deletions(-)

jump to
M src/automod/index.tssrc/automod/index.ts

@@ -1,19 +1,21 @@

import { rest } from "@/discord"; import { - CategoryChannel, ChannelType, type Client, type Message, type RESTGetAPIGuildMessagesSearchQuery, type RESTGetAPIGuildMessagesSearchResult, - Routes, type Snowflake, } from "discord.js"; import { distance } from "fastest-levenshtein"; import Tesseract from "tesseract.js"; +import { createHash } from "node:crypto"; import { hasManagerPermissions } from "@/utils/discord/permissions"; import { loggers } from "@/utils/logging"; +import { db } from "@/database"; +import { automodHashes } from "@/database/schema/automod"; +import { eq } from "drizzle-orm"; const logger = loggers.automod; const IS_PROD = Bun.env.NODE_ENV === "PROD";

@@ -38,6 +40,7 @@ lastMessage?: Message;

}; type AutomodFunction = (client: Client, message: Message) => Promise<AutomodResult>; +// oxlint-disable-next-line no-unused-vars async function fetchSignals(client: Client, message: Message): Promise<AutomodSignals> { const signals: AutomodSignals = { messageCount: undefined,

@@ -155,12 +158,42 @@

try { logger.debug( { messageId: message.id, attachmentUrl: attachment.url }, - "Running OCR on image attachment", + "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 imageHash = createHash("sha256").update(buffer).digest("hex"); + + const existingHash = await db + .select() + .from(automodHashes) + .where(eq(automodHashes.hash, imageHash)); + + if (existingHash) { + logger.info( + { messageId: message.id, authorId: message.author.id, imageHash }, + "Image matches known scam hash in DB, skipping OCR", + ); + + await reportToAutomod( + client, + message, + "Image matched known scam hash", + "You sent an image indicative of your account being compromised or a phishing attempt, so we removed it.", + ); + + return { delete: true }; + } + + logger.debug({ messageId: message.id, imageHash }, "Running OCR on image attachment"); + const { data: { text }, - } = await Tesseract.recognize(attachment.url, "eng", { + } = await Tesseract.recognize(buffer, "eng", { errorHandler: (err) => logger.warn({ err }, "OCR error"), });

@@ -228,6 +261,18 @@ hasSuspiciousKeyword,

}, "detected probable crypto scam", ); + + try { + await db + .insert(automodHashes) + .values({ + hash: imageHash, + addedAt: Date.now(), + }) + .onConflictDoNothing(); + } catch (err) { + logger.error({ err, imageHash }, "failed to save scam image hash to DB"); + } } if (isScam) {

@@ -277,7 +322,7 @@ 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 signals = await fetchSignals(client, message); // if ( // hasWordMarker &&
A src/database/schema/automod.ts

@@ -0,0 +1,6 @@

+import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"; + +export const automodHashes = sqliteTable("automod_hashes", { + hash: text("hash").primaryKey(), + addedAt: integer("added_at").notNull(), +});
M src/database/schema/index.tssrc/database/schema/index.ts

@@ -5,3 +5,4 @@ export * from "./bugs";

export * from "./moderators"; export * from "./userPreferences"; export * from "./sessions"; +export * from "./automod";