all repos — stealth-developers @ 25498cdef38d67d4866e16e276bd2f96f6c9580a

feat(automod): ocr images for crypto scam
vi v@vt3e.cat
Sun, 12 Apr 2026 23:51:23 +0100
commit

25498cdef38d67d4866e16e276bd2f96f6c9580a

parent

9aac44c48a95c933580c3cd29f769740e6505332

4 files changed, 231 insertions(+), 46 deletions(-)

jump to
M .gitignore.gitignore

@@ -2,6 +2,7 @@ # Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore

.config.*json !.config.example.json data +eng.traineddata # Logs
M bun.lockbun.lock

@@ -7,15 +7,17 @@ "dependencies": {

"@libsql/client": "^0.17.2", "discord.js": "^14.26.2", "drizzle-orm": "^1.0.0-beta.9-e89174b", + "fastest-levenshtein": "^1.0.16", "nanoid": "^5.1.7", "pino": "^10.3.1", "pino-pretty": "^13.1.3", + "tesseract.js": "^7.0.0", "uwuifier": "^4.2.2", "zod": "^4.3.6", }, "devDependencies": { "@biomejs/biome": "^1.9.4", - "@types/bun": "^1.3.11", + "@types/bun": "^1.3.12", "drizzle-kit": "^1.0.0-beta.9-e89174b", }, "peerDependencies": {

@@ -208,6 +210,8 @@ "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],

"bl": ["bl@6.1.6", "", { "dependencies": { "@types/readable-stream": "^4.0.0", "buffer": "^6.0.3", "inherits": "^2.0.4", "readable-stream": "^4.2.0" } }, "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg=="], + "bmp-js": ["bmp-js@0.1.0", "", {}, "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw=="], + "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],

@@ -260,6 +264,8 @@ "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],

"fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="], + "fastest-levenshtein": ["fastest-levenshtein@1.0.16", "", {}, "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg=="], + "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="],

@@ -276,6 +282,8 @@ "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],

"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + "idb-keyval": ["idb-keyval@6.2.2", "", {}, "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg=="], + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],

@@ -283,6 +291,8 @@

"is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], + + "is-url": ["is-url@1.2.4", "", {}, "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww=="], "is-wsl": ["is-wsl@3.1.0", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw=="],

@@ -334,7 +344,7 @@ "native-duplexpair": ["native-duplexpair@1.0.0", "", {}, "sha512-E7QQoM+3jvNtlmyfqRZ0/U75VFgCls+fSkbml2MpgWkWyz3ox8Y58gNhfuziuQYGNNQAbFZJQck55LHCnCK6CA=="],

"node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], - "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], "on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="],

@@ -342,6 +352,8 @@ "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],

"open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], + "opencollective-postinstall": ["opencollective-postinstall@2.0.3", "", { "bin": { "opencollective-postinstall": "index.js" } }, "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q=="], + "pino": ["pino@10.3.1", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^3.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^4.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg=="], "pino-abstract-transport": ["pino-abstract-transport@3.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg=="],

@@ -363,6 +375,8 @@

"readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], "real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="], + + "regenerator-runtime": ["regenerator-runtime@0.13.11", "", {}, "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="], "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],

@@ -394,6 +408,10 @@ "tarn": ["tarn@3.0.2", "", {}, "sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ=="],

"tedious": ["tedious@18.6.2", "", { "dependencies": { "@azure/core-auth": "^1.7.2", "@azure/identity": "^4.2.1", "@azure/keyvault-keys": "^4.4.0", "@js-joda/core": "^5.6.1", "@types/node": ">=18", "bl": "^6.0.11", "iconv-lite": "^0.6.3", "js-md4": "^0.3.2", "native-duplexpair": "^1.0.0", "sprintf-js": "^1.1.3" } }, "sha512-g7jC56o3MzLkE3lHkaFe2ZdOVFBahq5bsB60/M4NYUbocw/MCrS89IOEQUFr+ba6pb8ZHczZ/VqCyYeYq0xBAg=="], + "tesseract.js": ["tesseract.js@7.0.0", "", { "dependencies": { "bmp-js": "^0.1.0", "idb-keyval": "^6.2.0", "is-url": "^1.2.4", "node-fetch": "^2.6.9", "opencollective-postinstall": "^2.0.3", "regenerator-runtime": "^0.13.3", "tesseract.js-core": "^7.0.0", "wasm-feature-detect": "^1.8.0", "zlibjs": "^0.3.1" } }, "sha512-exPBkd+z+wM1BuMkx/Bjv43OeLBxhL5kKWsz/9JY+DXcXdiBjiAch0V49QR3oAJqCaL5qURE0vx9Eo+G5YE7mA=="], + + "tesseract.js-core": ["tesseract.js-core@7.0.0", "", {}, "sha512-WnNH518NzmbSq9zgTPeoF8c+xmilS8rFIl1YKbk/ptuuc7p6cLNELNuPAzcmsYw450ca6bLa8j3t0VAtq435Vw=="], + "thread-stream": ["thread-stream@4.0.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA=="], "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],

@@ -414,6 +432,8 @@ "uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],

"uwuifier": ["uwuifier@4.2.2", "", {}, "sha512-Fhtj3Yg3rrDTEs+L1fqkgM3VYGJrP4U9GY+4fPdw08T5kzOB8NSosZnZfa5Zhv1Hh7NCpH5G6t1DIZyM1wPK1w=="], + "wasm-feature-detect": ["wasm-feature-detect@1.8.0", "", {}, "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ=="], + "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],

@@ -426,6 +446,8 @@ "ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],

"wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], + "zlibjs": ["zlibjs@0.3.1", "", {}, "sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w=="], + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], "@discordjs/rest/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],

@@ -440,7 +462,7 @@ "@discordjs/ws/@discordjs/util": ["@discordjs/util@1.1.1", "", {}, "sha512-eddz6UnOBEB1oITPinyrB2Pttej49M9FZQY8NxgEvc3tq6ZICZ19m70RsmzRdDHk80O9NoYN/25AqJl8vPVf/g=="],

"@discordjs/ws/discord-api-types": ["discord-api-types@0.38.31", "", {}, "sha512-kC94ANsk8ackj8ENTuO8joTNEL0KtymVhHy9dyEC/s4QAZ7GCx40dYEzQaadyo8w+oP0X8QydE/nzAWRylTGtQ=="], - "cross-fetch/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + "@libsql/hrana-client/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], "tsx/esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="],
M package.jsonpackage.json

@@ -27,9 +27,11 @@ "dependencies": {

"@libsql/client": "^0.17.2", "discord.js": "^14.26.2", "drizzle-orm": "^1.0.0-beta.9-e89174b", + "fastest-levenshtein": "^1.0.16", "nanoid": "^5.1.7", "pino": "^10.3.1", "pino-pretty": "^13.1.3", + "tesseract.js": "^7.0.0", "uwuifier": "^4.2.2", "zod": "^4.3.6" }
M src/automod/index.tssrc/automod/index.ts

@@ -1,5 +1,4 @@

import { rest } from "@/discord"; -import { hasManagerPermissions } from "@/utils/discord/permissions"; import { type Client, type Message,

@@ -8,12 +7,18 @@ type RESTGetAPIGuildMessagesSearchResult,

Routes, type Snowflake, } from "discord.js"; +import { distance } from "fastest-levenshtein"; +import Tesseract from "tesseract.js"; +import { hasManagerPermissions } from "@/utils/discord/permissions"; import { loggers } from "@/utils/logging"; const logger = loggers.automod; + +const IS_PROD = Bun.env.NODE_ENV === "PROD"; type Pending = { channels: Set<Snowflake>; + reasons: Set<string>; timer: NodeJS.Timeout; };

@@ -21,7 +26,9 @@ const pendingReports = new Map<Snowflake, Pending>();

const cooldowns = new Map<Snowflake, NodeJS.Timeout>(); async function getLogChannel(client: Client) { - return client.channels.fetch("1478940790112915486"); + return client.channels.fetch( + IS_PROD ? "1493015326554587146" : "1493015912234881205", + ); } type AutomodResult = { delete: true } | null;

@@ -71,12 +78,20 @@ }

export async function handleMessage(client: Client, message: Message) { if (message.author.bot || !message.guildId || !message.member) return; - const automodFunctions: AutomodFunction[] = [handleSpamMarker]; + const automodFunctions: AutomodFunction[] = [ + handleSpamMarker, + handleCryptoScamOCR, + ]; - if (await hasManagerPermissions(message.member)) return null; + if ((await hasManagerPermissions(message.member)) && IS_PROD) return null; if (message.channel.isThread()) return null; + if (message.content.includes("!bypass")) return null; for (const func of automodFunctions) { + logger.info( + { messageId: message.id, authorId: message.author.id }, + "checking message with automod function", + ); const result = await func(client, message); if (result?.delete) {

@@ -86,6 +101,183 @@ }

} } +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<AutomodResult> { + if (message.attachments.size === 0) return null; + + 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 }, + "Running OCR on image attachment", + ); + + const { + data: { text }, + } = await Tesseract.recognize(attachment.url, "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 - + distance(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", + ); + } + + 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,

@@ -147,44 +339,11 @@ );

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, + 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 };

@@ -202,9 +361,10 @@ 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 message in channels: ${channelMentions}`, + `<@${authorId}> posted flagged messages in channels: ${channelMentions}\n> ${reasons}`, ); const cooldownTimer = setTimeout(