Frostbite

A sharded Discord anti-scam bot with perceptual image matching and OCR.

kotlindiscordjda
On this page 4 sections
  1. What happens to the member who posted it
  2. Why the image filter isn’t just an exact-match check
  3. Reading the scams that hide inside an image
  4. Every member in their own language

Frostbite is a sharded Discord moderation bot written in Kotlin, built as a standalone Spring Boot app with JDA on the gateway and BotCommands handling commands.

The headline feature is a layered anti-spam/filter system:

  • a substring scam/phishing filter for known bad strings, in two tiers: a network-wide list the bot owner curates (applied across every server) and a per-guild list each server’s own staff manages. Every entry also carries a scope deciding where it’s allowed to match: chat text only, text pulled back out of images only (more on that below), or both. Scoping matters because a string that’s damning inside a scam graphic can be perfectly innocent typed in chat, and the narrow scope means it can never nuke a real message. Entries written before scopes existed load as “both”, so nothing quietly stopped matching when the feature landed.
  • a perceptual image filter (256-bit dHash + Hamming distance) that catches reposted scam pictures even when they’ve been recompressed or lightly edited, backed by OCR that reads text baked into an image so a screenshot of a scam is caught the same way the typed version would be.
  • a set of three cross-channel spam detectors built around the hacked-account playbook: the same image sprayed across many channels, one account dumping the same image back-to-back, and one account firing off different images across every server it shares with you at once. Any of them nukes every copy, DMs the posters, watchlists the image so later copies die on arrival, and forwards the hash to a shared review server so staff can ban it network-wide.
  • a honeypot trap channel that auto-bans (or just deletes + alerts) any non-staff member who posts in it, with a warning dropped in the channel so real members know to stay out.

When the string or image filter catches a scam, Frostbite doesn’t just delete it and alert staff. It also DMs the offender a compromised-account recovery guide, on the assumption that their account was hacked rather than that they’re a willing spammer, so a real member who got phished has a clear path back.

Everything is configured per-guild and editable at runtime through a single /filter command (toggles, exempt channels, log channel, banned strings, watchlists, honeypot, punishments), so no redeploys to tweak a server’s setup. Each of the five filters is switched on or off independently with /filter enable, and a server that just wants the sane defaults can skip all of it: /filter quicksetup takes a log channel and an optional staff role and turns on the scam filters in one go. And when one threat is sprayed across a dozen channels at once, the staff log doesn’t drown in it: the per-message removals collapse into a single alert that updates in place as each copy is cleaned up.

Two commands answer the question every admin actually has, which is “is this thing even working?” /filter status prints a wiring-health checklist rather than a config dump, so a missing log channel or an unset staff role shows up as a warning instead of a blank field. /filter inspect goes further and audits the bot’s permissions channel by channel, paginated, listing exactly where it’s missing View Channel, Read Message History, or Manage Messages. A filter that silently can’t see half your server is the worst possible failure mode, so it’s made loud.

Beyond the filters there’s a bit more:

  • Reporting: /report <message_link> (or right-click a message → Apps → Report) forwards its images and their hashes to the support server’s review channel, so staff can vet a scam and ban the hash network-wide. Owners can blacklist abusers via /reportblacklist.
  • Stats: /stats surfaces network-wide monitoring numbers (messages watched, guilds protected, lifetime totals), persisted across restarts. A snapshot is also published to a public repo every fifteen minutes, so the same numbers can be shown outside Discord without standing up a web server for it.
  • Onboarding: Frostbite posts a setup guide when it joins a guild, and /support hands out an invite to the support server. Joining that server auto-assigns a role.

What happens to the member who posted it

Deleting the message and telling staff is the floor, not the ceiling, and different servers want very different ceilings. So each of the five filters (string, image, cross-channel, honeypot, chat) carries its own punishment setting rather than one global one. A server can treat a honeypot post as an instant ban while leaving the string filter on delete-and-alert, because those two things mean different amounts of guilt.

The options are the obvious four: nothing beyond the delete and the alert, a timeout (any duration up to Discord’s 28-day ceiling), a kick, or a ban with a configurable reason. Two of the details are more interesting than the list:

  • A kick can carry an invite back. Turn on re-invite and Frostbite mints a single-use, one-day invite and DMs it to the member before removing them. That sounds contradictory until you remember the whole premise: if the account was compromised, kicking it stops the spray in progress, and the invite is how the actual human returns once they’ve got their account back. Punishing the malware without exiling the person.
  • The DM always goes out before the removal, and that ordering is a correctness constraint rather than a courtesy. Discord will only deliver a DM while you and the recipient still share a server, so a ban issued first makes the recovery guide, the appeal link, and the re-invite all undeliverable. Get the sequence backwards and the kindest-looking config silently sends nothing at all.

Servers can also attach an appeal link that rides along in every punishment DM, which matters most in exactly the case the bot is built around: the member did nothing wrong and has no idea why they were removed. And when a compromised account sprays fifty messages, it earns one punishment, not fifty, since the burst is throttled per person per guild.

Why the image filter isn’t just an exact-match check

The obvious way to block a known-bad image is to hash the file (say, SHA-256 of the bytes) and reject anything with a matching hash. The problem: that only catches byte-for-byte identical files. A spammer re-saves the picture, bumps the JPEG quality a notch, resizes it, crops a pixel, or slaps on a watermark, and the file bytes are now completely different, so a cryptographic hash is completely different too. To an exact-match filter every reposted scam looks brand new.

Frostbite hashes what the image looks like instead of what its bytes are. The dHash (difference hash) shrinks the image down to a tiny grayscale grid and records, for each pixel, whether it’s brighter or darker than its neighbor, a 256-bit fingerprint of the image’s structure. Recompressing, resizing, or lightly editing the picture barely moves that fingerprint. Two images count as “the same” when their fingerprints differ by at most a handful of bits (Hamming distance ≤ 16), so a recompressed or cropped repost still lands right on top of the original and gets caught.

To keep that fast, a cheap aspect-ratio prefilter runs first: if two images aren’t even close to the same shape, there’s no point doing the full bit-by-bit comparison, so they’re skipped before the expensive part. The same fingerprints feed the cross-channel trackers and the network-wide banned-hash list, so one report can shut a scam image down everywhere at once.

Reading the scams that hide inside an image

A text filter is easy to dodge: put the scam text in a picture instead of typing it. A screenshot of the message, a “free Nitro” graphic, a fake giveaway card, none of it has a single character for a substring filter to match, and if the picture isn’t a known-bad hash yet, the perceptual filter waves it through too.

So when an image clears the hash filter, Frostbite reads it. OCR (optical character recognition, the technology that turns a scanned document or a phone-camera photo of a street sign back into text you can actually search) pulls whatever text is in the image, and that recognized text runs back through the scam-string filter, but fuzzily, since OCR output is noisy, and against the image-only string list as well, a tier of bad strings that only ever applies to text lifted out of images so it can never nuke an innocent chat message. The engine doing the reading is Tesseract, bundled inside the jar through JavaCPP (which packages the native C++ Tesseract libraries as a Java dependency), so there’s nothing to install on the host, and if it ever fails to load (a platform its natives weren’t built for) OCR quietly switches itself off and the rest of the bot carries on.

Running that engine on Frostbite’s own machine, rather than calling out to a cloud vision API, was a deliberate trade. A hosted OCR service reads messy text a little better out of the box, but it bills per image and the bot inspects a great many of them, it would bolt a network round-trip and someone else’s outage onto the hot path of every single attachment, and it would mean handing a third party a copy of everything members post. Tesseract is free, mature, and self-contained, so none of that applies: no per-image cost, no external dependency to fail, and no user images ever leaving the server. Its one real weakness, reading stylized text less reliably than the big cloud models, is something Frostbite can chip away at itself.

Scam images tend to use stylized, noisy, deliberately-hard-to-read text, exactly what stock OCR struggles with, so every image the OCR path flags is archived into a growing corpus, and there’s an offline pipeline that fine-tunes the recognition model on those real examples. The filter gets a little better at reading the images it actually sees.

Every member in their own language

Frostbite speaks six languages (English, plus German, Spanish, French, Dutch, and Portuguese), and it’s built so that adding more takes no code at all. A member picks their own language with /language set (or the /lang alias), a server sets a default with /language server, and every reply resolves the same way: the user’s choice first, then the server default, then English. Any phrase a translation hasn’t covered yet just falls back to English, so a half-finished language is still perfectly safe to ship.

The interesting part is where the translations live. They aren’t baked into the bot; they sit in a separate, open repo, Frostbite-Lang. On startup Frostbite lists that repo through GitHub’s API and downloads every <code>.json file it finds, and the filename is the language code, so dropping an it.json into the repo makes Italian exist on the next restart, with zero changes to the bot. Anyone can open a pull request to fix a clumsy phrase or add a whole new language.

Because the non-English catalogs are machine-translated to start, the bot is honest about it: /language shows a short disclaimer and a link to contribute corrections. And since Discord shows every viewer the same shared message, public posts (the honeypot warning, the join setup guide) can only be written in one language at a time, so they carry a 🌐 button that quietly re-renders the same text in whoever clicked it.

Need help or want to report something? The support server is the place.

Under the hood, dependency injection is wired through Spring, caches are Caffeine, OCR is Tesseract (bundled via JavaCPP), and messages are built with JDA’s Components V2 (no dusty embeds here). The bot runs fully sharded on JDA’s leanest cache profile, letting Discord pick the shard count and holding almost nothing about members or guilds in memory, so it stays cheap to run even across large servers. Every piece of blocking work (downloading an attachment, hashing it, running OCR, talking to GitHub) is handed to virtual threads, which suits the shape of the workload exactly: hundreds of jobs that spend nearly all their time waiting, on a host that may only have a couple of cores to its name.

Built on Java 21 and Kotlin 2.3.