Introduction
PolarSystems is a set of open-source tools for running game communities: PolarBot, a Discord bot that catches scams and helps moderators; PolarManager, which runs and connects game servers; and the in-game parts for Minecraft (PolarCraft) and Stormworks (PolarScript). Everything is AGPL-3.0-or-later and self-hosted: you run it on your own machine, and no message, player or server data is sent anywhere else.
These docs are for anyone who runs it, not only the PolarSystems community. Commands and
host names such as bot.example.org are examples: use your own.
Linux first. Every guide here is written and tested for Linux with systemd (Debian, Ubuntu and Fedora; where commands differ, both are shown). The code builds on Windows, but Windows is not supported yet and not documented.
Pick your level
You choose how much you want to do yourself. Every level installs the same thing in the same way; they differ only in who types the commands.
| Level | How | Who it is for | Pages you use | Available |
|---|---|---|---|---|
| Ultra-manual | You read what everything is and does and build the installation yourself. No steps. | Experienced admins who want the building blocks | How it works and Reference of each component | ✅ PolarBot |
| Manual | Every step as exact commands with an explanation and the expected output. No installer. | People who want to know and control everything | Install (manual), Configure, Update, Back up and restore, Roll back, Uninstall, Troubleshooting | ✅ PolarBot |
| Plan | polarinstall plan asks its questions, then prints the exact commands it would run (the same as the manual pages) and changes nothing. | Reviewing before trusting a tool; copying into your own automation | PolarInstall | 🚧 coming |
| Guided | polarinstall asks every choice, shows a sensible default and explains it. | Most owners | PolarInstall | 🚧 coming |
| Express | polarinstall --express asks only what nobody can choose for you: the Discord bot token, and “Do you accept the Minecraft EULA?” if you install a Minecraft server. | “Just make it work” | PolarInstall | 🚧 coming |
| Unattended | polarinstall --config answers.toml --yes | Scripts and reinstalls | PolarInstall | 🚧 coming |
The levels build on each other: the Reference pages are generated from the source code (a test fails when they and the code differ), the manual pages explain each step against them, and the installer will run exactly the manual steps. Whatever level you start at, you can always look one level down to see what is really happening.
How the pages are organised
Every component has the same set of pages:
- Overview: what it is and what it needs.
- How it works: architecture, what talks to what and why, data flows, the security model.
- Reference: every command, option, config key, credential, file, database table and exit code. Generated from the code.
- Install (manual), Configure, Update, Back up and restore, Roll back, Uninstall and Troubleshooting.
Pages that apply to everything: Privacy and data, Security and the Changelog.
Getting help
Open an issue at https://github.com/sergeantpolarbear/PolarSystems/issues, or write to contact@polarsystems.net (also for data-protection questions). Report security problems privately as described in Security.
Privacy and data
PolarSystems is self-hosted. Whoever runs a component (the operator) is responsible for the data it handles; nothing is sent to the PolarSystems project. This page says exactly what PolarBot processes, what it stores, where, and for how long, so operators can write their own privacy notice and answer their members’ questions. Today it covers PolarBot; the other components will be added as they ship.
PolarBot in short
- Messages are analysed in memory and not stored. A flagged message is shown on a mod card in the server’s log channel; that card, in Discord, is the only record.
- The only personal data kept in the database is punishment history: bans, unbans, kicks, timeouts and warnings, with the user’s and the moderator’s Discord IDs, the reason and the time. Each entry is deleted after 180 days.
- No user URL is ever fetched. Links are judged by their text. PolarBot talks only to Discord and downloads the public domain lists you configure.
- Members can see everything stored about them with
/privacy my-data(also in a DM with the bot);/privacy infosummarises this page.
What is processed in memory
| What | Why | How long |
|---|---|---|
| Message text, attachment names, mentions, the author’s account age and join date | Scanning | While the message is scanned (milliseconds) |
| Per-member fingerprints of recent messages (a 64-bit hash, channel and message ID, time; at most 32 per member and server) | Hacked-account and flood detection | Each entry 2 minutes (swept every minute) |
| The last 200 messages per channel (Discord’s own data, in the bot’s cache) | “Before/after” text of edits and deletes in the log channel | Until pushed out or the bot restarts |
| Members, roles and channels of the servers the bot is in | Permission and hierarchy checks | Until the bot restarts |
What is stored, and for how long
All of it is in one SQLite database on the operator’s machine. The complete list of tables and columns is generated from the code in Database.
| Data | Personal? | Kept |
|---|---|---|
Punishment history (mod_actions, warnings) | Yes: user and moderator IDs, reason, time | 180 days, purged every hour |
Training examples (training_examples) | No: anonymized (mentions, IDs, names, invite codes, e-mail addresses and link paths replaced); no server or user ID; the day only | Until the operator exports them (polarbot export-training), which deletes them |
/perms authorize grant (guild_oauth) | The ID of the admin who authorized; the OAuth refresh token, encrypted | Until replaced, revoked, or the bot leaves the server |
Scanner counters (scan_stats) | No: two numbers per server and day | 30 days |
Server settings, /perms roles, scanner rules, allow/block lists | No | Until the bot leaves the server |
When the bot is removed from a server, every row of that server is deleted at once, including its punishment history.
What goes to Discord
- Log channel posts. Depending on the server’s log level, PolarBot posts events (from scanner punishments at level 1 up to every message at level 10) in the server’s own log channel. They are stored by Discord like any message, under the server’s control. PolarBot does not keep them.
- Mod cards contain the flagged message’s text and its domains, so moderators can decide.
- Audit log reasons for actions the scanner takes (
PolarBot scanner: score 97).
What the operator’s system logs
The service logs to the system journal (journalctl -u polarbot). Log lines contain server,
channel and user IDs (for example “scanner flagged a message” with the author’s ID and the
score) but never message text, tokens or secrets. How long the journal keeps them is the
operator’s choice (/etc/systemd/journald.conf, MaxRetentionSec=). nginx keeps no access log
for the OAuth callback.
Outbound connections
| To | Why |
|---|---|
discord.com, gateway.discord.gg | The Discord API and gateway |
The configured threat lists and adult-site lists (by default raw.githubusercontent.com) | Downloading the domain lists, every 24 hours |
discord.com/api/oauth2/token | Only when a server uses /perms authorize |
For operators
- Point your members to
/privacy infoand/privacy my-data, and name a contact for data questions. Requests to delete an entry before 180 days go to the server’s staff, who can remove it by hand (see Database); a bot that leaves a server deletes everything of that server. - If you change retention or add logging, update your own privacy notice.
Security
Reporting a vulnerability
Report vulnerabilities privately through GitHub Security Advisories (“Report a vulnerability” on the repository’s Security tab), or by email to contact@polarsystems.net. Please do not open public issues for security problems.
Supported: the latest stable release.
Principles
These hold for every component and are checked in review:
- No secrets in config files or on command lines. Secrets are systemd credentials,
encrypted at rest with the host key and only decrypted into the service’s private
credentials directory. A config file that contains a key called
token,secret,passwordorapi_keyis refused. - No user URL is ever fetched. PolarBot analyses links by their text only. The only outbound connections are to Discord and to the public domain lists you configure.
- Least privilege. Services run as a throw-away system user (
DynamicUser=yes) under a strict systemd sandbox: read-only system, no home directories, no new privileges, no capabilities, a system-call filter. See PolarBot’s security model. - Minimal exposure. The only network listener is PolarBot’s OAuth callback, on
127.0.0.1, behind nginx with TLS; nginx forwards exactly two paths and keeps no access log. - Fail closed. Unknown commands need the highest level, unknown config keys stop the start, a list download that looks wrong keeps the previous list, an expired or reused OAuth link is refused.
- Privacy by design. Flagged messages are not stored. See Privacy and data.
Verifying what you run
Today you build PolarBot from source, so what you run is what you checked out. Check that you are on the commit you expect before you build:
git fetch origin
git status # "On branch stable" and "up to date with 'origin/stable'"
git log -1 --oneline # the commit you are about to build
The running bot shows the commit it was built from in /about and in its first log line
(starting polarbot version=… rev=…), so you can confirm the server runs that commit.
Signed releases (prebuilt binaries with a signed manifest) are coming with PolarInstall; the steps to check a signature by hand are in Verify a release signature by hand.
Verify a release signature by hand
🚧 Coming with signed releases (PolarInstall, phase I2). There are no signed release files yet: today PolarBot is installed by building it from source (Install (manual)). This page describes the planned format so you know what to expect; the exact commands will be filled in when the first signed release is published.
What a release will look like
- A release is a manifest (
manifest.json) listing every file with its version, platform, size and SHA-256 hash, plus a signature of that manifest (manifest.json.minisig). - The signature is made with an ed25519 key in minisign format. The private key is kept offline on the maintainer’s desktop: never on the download server and never in CI.
- The public key will be published on this page, in the repository and compiled into PolarInstall, so you can compare all three.
- Files are offered from
downloads.polarsystems.netand from GitHub Releases. Both carry the same signed manifest, so it does not matter which one you use: a download that does not match the signed hash is not the release. - Third-party downloads the installer needs (Paper, Java, SteamCMD, the Stormworks server) are pinned by version and SHA-256 in the same manifest, never “latest”.
Checking by hand (outline)
- Install
minisign(Debian/Ubuntu:sudo apt install minisign; Fedora:sudo dnf install minisign). - Download
manifest.json,manifest.json.minisigand the files you want. - Check the signature against the published public key:
minisign -Vm manifest.json -P <public key>. Only continue onSignature and comment signature verified. - Check each file’s hash against the manifest:
sha256sum <file>must print exactly thesha256the manifest lists for it.
Anything that fails a check must not be run. The installer will do exactly these checks before it executes anything.
Changelog
All notable changes to PolarBot. The format follows Keep a Changelog and versions follow Semantic Versioning (before 1.0, a minor bump may change behaviour).
Every release gets an entry here before it is deployed. The bot shows its version, commit and build date in /about and in the startup log, and announces a new version in each server’s log channel.
[Unreleased]
[0.3.2] - 2026-10-22
Fixed
/privacy infonow also lists the one other piece of personal data PolarBot keeps: the user ID of the admin who ran/perms authorizefor a server, kept until the authorization is revoked or PolarBot leaves.
Security
- Updated the async runtime (tokio 1.54.0 → 1.54.1): 1.54.0 was withdrawn because of a race condition in its timers (RUSTSEC-2026-0384).
Documentation
- docs.polarsystems.net: complete PolarBot documentation — how it works, a reference generated from the code (commands, configuration, credentials, files, database tables with their personal data and retention, log levels, exit codes), and manual guides for install, update, backup, rollback, uninstall and troubleshooting.
[0.3.1] - 2026-10-21
Added
- Critical scam phrases: a few phrases that almost only ever appear in scams (“I accidentally reported you”, “your account will be disabled … verify”, “add my friend, he’s Discord staff, he can unban you”, “verify your account to avoid a ban”, “Steam support … you were reported”, “I’m quitting CS, giving away my skins”) now score at least 90 even without a link. When a message is clearly about a scam (a warning, a “PSA”, “someone sent me…”, or the phrase in quotes) they count as an ordinary scam phrase instead.
- “Move off Discord” pattern: asking to continue on Snapchat/Telegram/WhatsApp (“it’s in my bio”), “texting from my alt because I’m shy”, “text me on my main account”, “saw you were online” and links to a Discord user profile. One of these alone stays low (friends say “add me on snap”); two or more together reach the log threshold.
- Adult-site links as their own category, separate from the scam score:
/scanner nsfw-links action:Ignore, Log, Delete, or Delete and time out. Never applies in channels Discord marks age-restricted (threads follow their parent), and only logs in log-only mode. New servers: Delete; servers that already had PolarBot: Log. The domains come from a public adult-domain list (StevenBlack/hosts, porn extension), refreshed daily like the threat lists. Cards and/scanner analyzeshow “Adult site link” as its own reason, and/scanner statusshows the setting. /scanner analyzeexplains itself: a “Why” section says in plain words what the strongest reasons mean (e.g. a lookalike of discord.com and the real address, or why moving you to Snapchat is a red flag; for a scam phrase without a link, what the same text would score with a suspicious link), and from the Elevated band a “What to do” line./config announcements-channelchooses the staff channel for PolarBot’s “updated to vX.Y.Z” notices. Without one, PolarBot uses a channel named like “staff-announcements”, “staff-news”, “mod-announcements”, “staff-updates” or “bot-updates” where it can post, otherwise the log channel./config viewshows where the notices go.- Support and data-protection contact contact@polarsystems.net in
/about,/helpand/privacy info.
Changed
- Update notices go to the staff announcements channel (see above) instead of always the log channel; still one notice per server and version.
/helptexts updated for/scanner nsfw-linksand/config announcements-channel.
[0.3.0] - 2026-10-21
Added
/helplists the commands you can run here (by your/permslevel; in DMs the DM commands), grouped with a one-line description and an example./help command:<name>explains a command, its required level, every subcommand and option, with examples./help topic:explains how the scanner works, the permission levels and privacy. Works in DMs.- Version info:
/aboutand the startup log show the version, commit, build date and release channel. - Update notice: after an update, each server’s log channel gets one “PolarBot updated to vX.Y.Z” message with that version’s changelog (not for servers with log level 0; never twice for the same version).
/lookup user:<member>(Mod+): punishment history in this server for the last 180 days (counts per kind and the 5 most recent with date, moderator and reason), account age, join date, role count and highest role, the scanner’s live activity for the user (messages tracked, flood, same link in several channels), and a risk band (Low / Elevated / High) computed on the spot with the reasons that produced it. Works for users who left (history only). Stores nothing./scanner analyze(Mod+): a dry run of the scanner with this server’s lists and settings on pastedtext, amessagelink from this server (only channels you can read), and up to threeattachments. Shows a 0–100 rating with a band, what the scanner would do under the current settings (log-only mode included), the reasons with their weights, the domains (risky ones marked) and one line per attachment. Nothing is stored, deleted or posted, and the flood/burst tracker is not fed.- “Analyze with PolarBot” message command (right-click a message → Apps, Mod+): the same dry run on that message, including forwarded text and attachments.
- Attachment checks in the dry run, from the file name and type only (nothing is downloaded): programs and installers (
.exe,.scr,.msi,.apk,.bat, …), scripts (.js,.vbs,.ps1,.hta, …), double extensions (photo.png.exe) and a content type that contradicts the extension. Image content analysis (text in images, known-bad hashes, NSFW) comes in a later release. - Scanner counters in
/scanner status: messages flagged and acted on today (UTC) and over the last 7 days. Only per-server daily totals are stored (no users, no content), kept for 30 days and deleted when the bot leaves the server.
Changed
/scanner statusis easier to read: punishment and hacked-account settings say (inactive: log-only mode) while actions are off; only the channel list that matters for the channel mode is shown; empty lists collapse into one “Exemptions and domain rules” field; without a mod role it explains that cards ping no one and how to set one.- Mods now see
/scannerin their command list (for/scanner analyze); every settings subcommand still needs Admin.
[0.2.0] - 2026-10-21
Deployed from commit e6ee176. The binary still reported 0.1.0; version numbers in the binary start with 0.3.0.
Added
- Scam/spam scanner for every message in monitored channels, scored 0–100:
- links: lookalike and Unicode-homoglyph domains (
dіscord.com,steamcommunlty.com), punycode, IP links, shorteners, suspicious TLDs, deceptive masked links; - content: scam phrases (leetspeak-aware), invites, mass mentions,
@everyoneattempts, new or just-joined accounts (only together with a link or scam phrase); - behaviour: hacked-account bursts (the same link in 3+ channels within 120 s) and floods;
- two public threat lists (about 250 000 phishing domains), refreshed every 24 h;
- prior punishments.
- links: lookalike and Unicode-homoglyph domains (
- Modlog cards with Scam / Not scam / Undo buttons; Scam blocks only the risky domains, Not scam lifts the punishment.
/scannerto configure thresholds, actions, channels, exempt roles and domain lists. Existing servers start in log-only mode; enable actions with/scanner actions enabled:True.- Log levels 0–10 (
/config log-level): from nothing to every gateway event, batched into the log channel. - Command visibility: after
/perms authorize, each role sees exactly the commands its level allows. /privacy infoand/privacy my-data;/aboutand/privacywork in DMs.polarbot export-trainingfor anonymized training examples.
Changed
- English only (Swedish locale removed).
- Punishment history (bans, kicks, timeouts, warnings) is kept for 180 days, then deleted automatically.
Security
- OAuth state signed with HMAC-SHA256, refresh tokens encrypted with AES-256-GCM; the OAuth callback is served only on
127.0.0.1:8787behind nginx. install.shbacks up the database and keeps the previous binary; config errors (e.g. missing intents) exit with status 78 and stop systemd restart loops.
[0.1.0] - 2026-10-20
First Rust release, replacing the Python bot.
Added
/setup,/config,/perms(role → permission level).- Moderation:
/ban,/unban,/kick,/timeout,/untimeout,/warn,/warnings,/modlog, with hierarchy checks and modlog posts. /about.- Hardened systemd unit, encrypted credentials, deploy and install scripts.
PolarBot
PolarBot is a Discord moderation bot with a live scam and spam scanner. One process serves any number of Discord servers; each server configures it with slash commands.
What it does
- Scam and spam scanner. Every new and edited message in the scanned channels gets a score from 0 to 100, computed locally in about a millisecond, from its links (about 250 000 known phishing domains, lookalike and Unicode-homoglyph domains, punycode, shorteners, masked links), its text (scam phrases, also in leetspeak; “move to Snapchat”-style funnels) and the author’s behaviour (the same link in many channels, floods). Links to adult sites are a separate rule that never applies in age-restricted channels.
- Mod cards in the log channel with Scam, Not scam and Undo buttons. Moderators decide; confirmed scam domains go on the server’s block list.
- Moderation commands:
/ban,/unban,/kick,/timeout,/untimeout,/warn,/warnings,/modlog,/lookup, with PolarBot’s own permission levels (/perms) and hierarchy checks. - Logging from level 0 (nothing) to 10 (every event), batched so busy servers stay within Discord’s rate limits.
- Command visibility: after
/perms authorize, each role sees only the commands its level allows. /help,/aboutand/privacyalso work in a DM with the bot.
New servers start with the scanner on and actions enabled at the default thresholds (log 60,
delete 90, punish 97 with a 1 hour timeout). Servers that had PolarBot before the scanner
existed start in log-only mode. Try the scanner safely with /scanner analyze.
What you need
| Operating system | Linux with systemd 250 or newer (for encrypted credentials): Debian 12+, Ubuntu 24.04+, Fedora 38+. Windows is not supported yet. |
| Hardware | 1 CPU core and 512 MB RAM are plenty for a few large servers (the unit caps memory at 1 GB). Building from source needs about 2 GB RAM and 3 GB disk. |
| Network | Outbound HTTPS to Discord and to the domain lists. Inbound only if you use command visibility: HTTPS (443) and, for the certificate, HTTP (80). |
| Discord | An application with a bot user, the three privileged intents enabled, and the bot invited with the right permissions (Install). |
| Optional | A domain name and nginx for the OAuth callback (command visibility). |
Where to go next
- Want the building blocks? How it works and the Reference.
- Want step-by-step commands? Install (manual).
- Already running? Configure, Update, Back up and restore, Troubleshooting.
How PolarBot works
This page explains PolarBot’s architecture: what runs, what talks to what, where data goes, and how load is kept under Discord’s limits. The scanner and the security model have their own pages: The scanner and Security model. Exact names, values and files are in the Reference.
The process
PolarBot is one Rust binary (polarbot) running as one systemd service. At startup it:
- reads the config file (unknown or secret-looking keys stop it here, exit status 1);
- reads the
discord_tokencredential and opens the SQLite database, running any new migrations; - asks Discord who it is (application and bot user), which also proves the token works;
- loads the cached threat lists and adult-site lists from the data directory (an empty list if there is no cache yet);
- loads the two OAuth credentials if both are present (otherwise command visibility and the web server stay off);
- connects to the Discord gateway with the shard count Discord recommends, and starts the background jobs.
After the first Ready from the gateway (Discord accepted the token and the intents) it
registers the slash commands (globally, or in discord.dev_guild_id only), queues a command
visibility sync for every authorized server, and posts the one-time “PolarBot updated to
vX.Y.Z” notice if this version has not been announced yet. Doing this only after Ready means
a start that Discord refuses never touches the registered commands.
On SIGTERM (systemctl stop) or Ctrl-C it closes the gateway connections and gives running
handlers and jobs at most 10 seconds to finish.
Architecture
Discord gateway (one websocket per shard)
| events
v
+---------------------------- shard loop -----------------------------+
| 1. gateway_log::prepare (cheap, never waits: is this event logged?) |
| 2. cache.update (guilds, channels, roles, members, the last |
| 200 messages per channel) |
| 3. start a handler task if a slot of its pool is free |
+--------+-----------------------+--------------------------+----------+
| interactions | messages, deletes | other logged events
| (64 slots) | (256 slots) | (64 slots)
v v v
commands::dispatch scanner pipeline gateway_log --> log dispatcher
card buttons server/role/channel (per-server queue,
/help autocomplete deletes batched posts)
| |
v v
Discord HTTP API scanner -> actions -> card queue --> log channel
| |
+------------> SQLite database <------------ background jobs
(list updater, sweeps, purges)
OAuth callback web server (127.0.0.1:8787, behind nginx) --> database, visibility sync
Gateway and shards
PolarBot asks Discord for the recommended number of shards and runs one websocket connection for each. Each shard loop only receives events, updates the in-memory cache and starts handler tasks: it never waits on a handler, because it also drives the gateway heartbeat. When all handler slots of a kind are busy, the event is dropped (with a log line) instead of waiting. There are three separate pools, so one kind of load can never starve another:
| Pool | Size | Gets |
|---|---|---|
| Interactions | 64 | Slash commands, message commands, card buttons, autocomplete |
| Handlers | 256 | New and edited messages (the scanner), server/channel/role deletes |
| Log handlers | 64 | Every other event a server logs (presence, typing, member updates, …) |
If Discord closes a connection with a code that allows reconnecting, the shard reconnects by itself. A fatal close code (for example 4014: privileged intents not enabled, or 4004: bad token) stops the bot with exit status 78, which the systemd unit does not restart (see exit codes).
The cache
The in-memory cache holds the servers, channels, roles, members and users the bot can see, and the last 200 messages of each channel. It is used for permission and hierarchy checks and for the before/after of edits and deletes in the log. Presences are not cached. Memory grows with the size of the servers, not with time.
Handlers
- Interactions go to
commands::dispatch. In a server it first defers the reply (Discord drops interactions not answered within 3 seconds), loads the server’s settings, runs the matching command and fills in the deferred reply. Replies are ephemeral (only the caller sees them) and never ping anyone. In a DM only/help,/aboutand/privacyrun. - Messages (new and edited) go to the scanner. An update that did not change the text (a link preview, a pin) is ignored.
- Bot removed from a server (a
GUILD_DELETEthat is not an outage): every row of that server is deleted from the database and its caches are dropped. - Log channel deleted: the server’s log channel setting is cleared.
- Role deleted: if it had a
/permslevel, the mapping is removed and command visibility is synced again.
Permission levels
PolarBot has its own four levels, checked by the bot on every command and button press, whatever Discord shows:
| Level | Who | Can use |
|---|---|---|
| User | Everyone | /help, /about, /privacy |
| Moderator | Roles given Moderator with /perms set | Moderation commands, /lookup, /scanner analyze, Analyze with PolarBot, card buttons. The scanner skips Moderators and above. |
| Admin | Roles given Admin | Also /setup, /config, the /scanner settings, /perms list |
| Owner | The server owner and every member with Discord’s Administrator permission | Also /perms set, /perms remove, /perms authorize |
A member gets the highest level of their roles. The exact level of every command and subcommand is in Commands. Moderators can only act on members below them both in Discord’s role order and in PolarBot’s levels (the server owner may act on anyone), and PolarBot’s own role must be above the target’s.
Command visibility
Every command except /about, /help and /privacy is registered with
default_member_permissions = 0, so Discord shows it only to Administrators. After an Owner
runs /perms authorize and approves the OAuth link, PolarBot stores an encrypted refresh token
for that server and keeps Discord’s per-command permission overrides in sync with /perms:
each role sees exactly the commands its level allows. A sync runs after every /perms change
and role deletion and at startup, at most once per 10 seconds per server and two servers at a
time, retried three times (after 10, 20 and 40 s) on failure. If the authorization is revoked,
PolarBot deletes it and the next /perms reply asks for /perms authorize again; Discord keeps
the last overrides, and the bot’s own level check applies either way. See
the security model for the OAuth details.
The web server
Only when both OAuth credentials are present, PolarBot serves exactly two routes on
[web] bind (default 127.0.0.1:8787):
GET /oauth/callback?code&state: the end of/perms authorize;GET /healthz: answersok.
Everything else is a 404. Every response has a 10 s deadline and strict security headers. nginx terminates TLS in front of it (Install: OAuth and nginx). If the web server cannot bind, the bot keeps running without it.
Background jobs
| Job | Every | Does |
|---|---|---|
| List updater (one for threat lists, one for adult lists) | scanner.refresh_hours (24 h) per list; a failed download is retried after 1 h | Downloads each list (https only, size cap, 60 s timeout), keeps it only if it parses to at least one domain, writes the cache file atomically and swaps the in-memory index without blocking the scanner |
| Scanner sweep | 1 min | Drops expired behaviour-tracker and cache entries |
| Purge | 1 h (the first right at startup) | Deletes punishment history older than 180 days and scanner counters older than 30 days |
| Card and log queues | Per server, while there is work | Post cards and log batches (below) |
Data flows
| Data | Comes from | Goes to | Stored? |
|---|---|---|---|
| Message text | Gateway MESSAGE_CREATE / MESSAGE_UPDATE | Scanner (memory); the card, if flagged | No |
| Flag decision | Scanner | Discord (delete, timeout, kick, ban, with an audit-log reason); the card; counters | Punishments in mod_actions (180 days); daily counters |
| Moderator verdict (card button) | Discord interaction | The card is updated; risky domains go on the block list; an anonymized training example | scan_domains, training_examples |
| Moderation command | Discord interaction | Discord API; log channel | mod_actions / warnings (180 days) |
| Logged events | Gateway | Log channel, batched | No |
| OAuth grant | Discord, through nginx, to the web server | Encrypted refresh token | guild_oauth |
| Domain lists | The configured https URLs | Cache files and in-memory index | Cache files in the data directory |
Everything that is stored, and for how long, is in Privacy and data and Database.
Rate limiting and batching
PolarBot never posts to Discord from inside an event handler, where a burst could pile up:
- Log events go into a per-server queue of at most 500 events (the oldest are dropped when it is full, and the next post says how many). One worker per active server posts every 2 seconds, or as soon as 10 embeds are waiting, packing them into as few messages as fit Discord’s limits, at most one post per 1.5 seconds. Events in the log channel itself and the bot’s own actions are dropped. An idle worker exits after 5 minutes.
- Mod cards go into a per-server queue of at most 50 cards, posted at most once per second. A delete-only card waits 3 seconds; further delete-only flags of the same author within 10 seconds are merged into it (“and N more messages”), so a spam wave becomes one card. Punishment cards go out at once.
- Repeat offenders: within 2 minutes of a punishment, further flags of the same member only delete the message: one ping per incident.
- Update notices go out one server at a time, 1.5 seconds apart, starting 30 seconds after
the first
Ready. - On top of that, every Discord request goes through twilight’s rate limiter, which waits out Discord’s 429 responses.
Caches and freshness
| What | How fresh |
|---|---|
| A server’s scanner settings and lists | Updated at once by /scanner; otherwise at most 60 s old |
| A server’s log level and log channel | Updated at once by /config and /setup; otherwise at most 60 s old |
| A member’s punishment count (a scanner signal) | At most 5 minutes old; updated at once after a scanner punishment |
| Domain lists | Every refresh_hours |
The scanner
The scanner gives every new and edited message in the scanned channels a score from 0 to 100,
decides what to do from the server’s thresholds, does it, and posts a mod card. It runs in
memory, needs no network (no link is ever opened), and takes about a millisecond per message.
The rules live in the polar-scan crate, which has no Discord dependency so it can later
scan game chat and e-mail too.
message ──► skip rules ──► signals (links, threat lists, content, behaviour, adult links)
│
▼
fusion: score 0–100
│
thresholds + log-only mode + NSFW-link setting ──► action
│
delete / timeout / kick / ban (Discord) ──► punishment history, counters
│
▼
card queue ──► log channel ──► buttons (Scam / Not scam / Undo)
1. Which messages are scanned
A message is skipped when any of these holds:
- it is not in a server (DMs), comes from a bot, a system user or a webhook, or is not an ordinary message or reply (joins, pins, boosts, …);
- it has no text and no attachments; an edit is scanned only when its text changed;
- the scanner is off in this server (
/scanner disable); - the channel is exempt, or, in Only monitored channels mode, not monitored
(
/scanner channel,/scanner channel-mode); a thread follows its parent channel’s rule; - the author has an exempt role (
/scanner exempt-role); - the author is a Moderator or higher (the server owner and members with Administrator are Owner).
For a forwarded message (empty text), the text of the forwarded messages is scanned, and forwarded attachments count too.
2. Signals
Every rule that matches adds a signal: a reason the card can show and a weight. Weights are log-odds: positive weights make a scam more likely, and several weak signals add up.
| Group | Signal | Weight | Notes |
|---|---|---|---|
| Links | On a threat list | +7.0 | ≈250 000 phishing domains from the configured lists. Sets a floor of 90. |
| On the server’s block list | +6.0 | /scanner block. Sets a floor of 90. | |
| Lookalike of a known brand | +4.5 | Edit distance and Unicode homoglyphs: dіscord.com (Cyrillic і), steamcommunlty.com. | |
| Masked link that hides where it goes | +3.5 | [discord.com](https://evil.example) | |
| Suspicious top-level domain | +2.5 | ||
| Punycode domain | +2.0 | xn--… | |
| IP address instead of a domain | +1.5 | ||
| Link shortener | +1.5 | ||
| On the server’s allow list | −3.0 | /scanner allow. Turns off the link-shape checks for that domain, never the threat lists or the block list. | |
| Content | Scam phrase | +1.0 to +3.0 each | Also in leetspeak; at most 3 phrases count. “free nitro” is +3.0, “promo code” +1.0. |
| Critical phrase | +3.0 | “I accidentally reported you”, “verify your account to avoid a ban”, … Sets a floor of 90, unless the message is clearly about a scam (a warning, a “PSA”, “someone sent me…”, the phrase in quotes); then it counts as an ordinary phrase. | |
| “Move off Discord” step | +1.5 each, +2.0 for two or more | “add me on snap, it’s in my bio”, “texting from my alt because I’m shy”, “text me on my main account”, “saw you were online” | |
| Link to a Discord user profile | +1.0 | Often the last step of such a funnel. | |
| Discord invite | +1.5 | Not with /scanner invites allow:True. | |
| Mass mention | +2.0 | 5 or more user and role mentions. | |
@everyone / @here attempt | +1.5 | ||
| New account (under 7 days) | +1.0 | Only together with a link or a scam phrase. | |
| Joined under an hour ago | +1.0 | Only together with a link or a scam phrase. | |
| Recent punishments | +0.5 each, at most +1.5 | Bans, kicks, timeouts and warnings in this server in the last 180 days. | |
| Behaviour | Hacked-account burst | +5.0 | The same content with a risky link in 3 or more channels within 120 s. Sets a floor of 97. |
| Flood | +1.5 | 8 or more messages within 10 s. | |
| Adult | Link to an adult site | 0 | A separate category (below); never part of the score. |
Official domains (discord.com, discord.gg, steamcommunity.com, github.com,
youtube.com, …) are never flagged by the threat lists or the lookalike check, even if a list
contains them. At most 20 links per message are looked at.
The behaviour tracker keeps, per member and server, a fingerprint (a 64-bit simhash) of their last messages for 2 minutes; two messages match when their fingerprints differ in at most 3 bits. Only links that could be malicious count towards a burst: an official or allow-listed link shared in several channels is not one, unless it is on a threat list.
3. Fusion
score = round(100 × 1 / (1 + e^-(−4 + sum of weights)))
score = max(score, floors) floors: listed domain or critical phrase 90, burst 97
Without signals a message scores 2. One lookalike domain (+4.5) gives 62: a card. A lookalike plus “free nitro” (+3.0) gives 97. A domain on a threat list scores at least 90 whatever else the message says.
4. What happens
The server’s settings decide (/scanner thresholds, /scanner punishment, /scanner hacked, /scanner actions):
| Condition (checked in this order) | Action |
|---|---|
| Score below log (default 60) | Nothing (at log level 9 the score is logged) |
Log-only mode (/scanner actions enabled:False) | Card only |
Hacked-account burst and /scanner hacked is not Do nothing | Delete every message of the burst; with Delete the messages and time out, also time the author out |
| Score at or above punish (default 97) and a punishment is set | Delete and punish: timeout (default 1 hour), kick or ban |
| Score at or above delete (default 90) | Delete |
| Otherwise | Card only |
Adult-site links (/scanner nsfw-links): Ignore, Log, Delete or Delete and time
out. Never applied in a channel Discord marks age-restricted (a thread follows its parent; a
channel whose flag cannot be read counts as age-restricted), and only Log in log-only mode.
When both the scam score and the adult-link setting call for an action, the stronger one is
taken.
Repeat offenders: within 2 minutes after the scanner punished a member, further flags of that member only delete the message, so a spam wave gives one punishment and one ping.
Executing an action:
- every Discord request carries the audit-log reason
PolarBot scanner: score N; - a message that is already gone counts as deleted;
- before a timeout, kick or ban, PolarBot checks that the member is not the server owner or the bot and that its own role is above the member’s top role; otherwise the card says why it could not act;
- a timeout, kick or ban is recorded in the punishment history with the bot as moderator (a plain delete is not a punishment and is not recorded);
- the server’s daily counters (flagged, acted on) are increased for
/scanner status.
5. Cards
When the server has a log channel and a log level of at least 1, a flag becomes a card in
the log channel. Without a log channel, or at log level 0, there is no card and no log entry
(the action still happens). A card shows the
author, the score, the action taken (or why it failed), the message text, up to 8 reasons
(strongest first, an adult link always first), up to 20 domains (risky ones marked ⚠), a jump
link while the message still exists, and how many further messages were merged into it. The
mod role (/scanner mod-role) is pinged only for a punishment or a hacked account. User text
is escaped and never pings anyone.
The card is the only record of a flag: everything the buttons need is on the card itself (the
button state in its custom_id, the text and domains in the embed). Nothing about the message
is stored in the database.
Buttons
Only Moderators and above can press them; the level is read fresh from the database. A card’s buttons work once: the first press disables them all, and a second press, even a simultaneous one, gets “already handled”.
| Button | Does |
|---|---|
| Scam | Confirms the flag: puts the card’s risky domains (those with a domain signal of their own, never official ones) on the server’s block list and saves an anonymized training example marked violation. On a log-only card it then offers the moderator (privately) Delete & time out, which checks the moderator’s rights against the member exactly like /timeout before acting. |
| Not scam | Saves an anonymized training example marked ok, lifts the scanner’s timeout or ban, and deletes the scanner’s punishment-history row for it, so a false positive does not count against the member. Nothing is allow-listed (use /scanner allow). |
| Undo | Lifts the scanner’s timeout or ban (shown only when there is one to lift). |
Training examples are anonymized before they are stored: mentions, IDs, the member’s names,
invite codes, e-mail addresses and link paths are replaced with placeholders such as [user]
and [path], no server or user ID is kept, and only the day is recorded. The operator exports
(and thereby deletes) them with polarbot export-training.
6. Dry runs: /scanner analyze
/scanner analyze and the Analyze with PolarBot message command run the same link, content
and threat-list rules with the server’s lists and settings on pasted text, a message link or a
right-clicked message, and say what the scanner would do. They do not feed or read the
behaviour tracker (no flood or burst signals), store nothing, delete nothing and post nothing.
They also check up to three attachments by name and type only (nothing is downloaded):
programs and installers (+4.0), scripts (+2.5), double extensions such as photo.png.exe
(+5.0) and a content type that contradicts the extension (+2.5). The live scanner does not use
the attachment rules yet.
7. Domain lists
The threat lists and the adult-site lists are plain domain lists or hosts files downloaded over https from the URLs in the config file. Each list is cached in the data directory and loaded at startup, so the scanner works right away after a restart, even offline. A new download replaces the cache only when it parses to at least one domain: an empty download, an HTML error page or garbage keeps the previous list. The in-memory index is swapped atomically, so the scanner never waits for an update.
Security model
What PolarBot protects, against what, and how. For the policy and how to report a problem see Security.
What is worth protecting
| Asset | Why it matters | Where it lives |
|---|---|---|
| The bot token | Whoever has it controls the bot in every server it is in | discord_token credential |
The OAuth client secret and oauth_key | Together they could forge /perms authorize links or read stored refresh tokens | discord_client_secret, oauth_key credentials |
| Stored refresh tokens | Let the holder change command permissions in a server | guild_oauth, encrypted |
| Punishment history | Personal data | The database |
| The bot’s Discord permissions | Ban, kick, timeout, delete messages | Discord |
Credentials
- Secrets are never in the config file: a key named
token,secret,passwordorapi_keyanywhere in it stops the start, and config error messages never quote the file’s contents (so a misplaced secret does not end up in the journal). - On Linux they are systemd credentials: encrypted at rest with the machine’s credential
key (
systemd-creds encrypt, using the TPM2 chip when there is one), stored as/etc/polarbot/*.credwith mode 0600, and decrypted by systemd only into the service’s private, in-memory$CREDENTIALS_DIRECTORYwhile it runs. Copying a.credfile to another machine does not reveal it. install.shreads them with hidden input and pipes them straight intosystemd-creds; they are never written to disk in plain text.oauth_keyis generated from/dev/urandom.- Credential names are restricted to
[a-z0-9_], so a name can never point outside the credentials directory. - Secrets are held in memory as
SecretStringand are never logged; errors never contain tokens, codes or keys.
OAuth (command visibility)
/perms authorize lets an Owner grant PolarBot the right to change the server’s command
permissions (scope applications.commands.permissions.update).
- PolarBot replies (privately) with a Discord authorization link. Its
stateparameter is<server>.<user>.<expiry>.<MAC>: an HMAC-SHA256 over those three values, valid for 10 minutes. The server is preselected and locked on Discord’s page. - Discord redirects the browser to
<public_url>/oauth/callback?code=…&state=…. - The callback verifies the MAC (constant time) and the expiry, and uses the state up: each state works once (used states are remembered until they expire; at most 10 000, beyond that new callbacks are refused rather than letting memory grow).
- It exchanges the code at Discord’s token endpoint (8 s timeout, the response capped at 64 KiB), checks that the required scope was granted, and stores the refresh token encrypted with AES-256-GCM: a fresh random 96-bit nonce for every encryption and the server ID as associated data, so a token only decrypts for the server it was issued for.
- Each sync refreshes the token (Discord returns a new one, which replaces the old) and sets the permission overrides. A token that Discord rejects (HTTP 401, or “Missing Access” / “Missing Permissions” because the authorizing admin lost their rights) is deleted; nothing new is exposed after that.
The HMAC key and the encryption key are two different subkeys derived from oauth_key with
HKDF-SHA256, so neither primitive ever sees the other’s key. A new oauth_key makes every
stored authorization unreadable: servers then run /perms authorize again.
Overrides only ever allow roles whose /perms level meets a command’s level; @everyone is
never added, and unknown commands get no override (hidden from all but Administrators).
Discord’s visibility is a convenience: the bot’s own level check decides on every use.
The web server and nginx
- PolarBot listens on
127.0.0.1:8787only (it warns at startup if[web] bindis not a loopback address) and serves onlyGET /oauth/callbackandGET /healthz. It runs only when both OAuth credentials are configured. - Every response has a 10 s deadline and the headers
Content-Security-Policy: default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none',X-Content-Type-Options: nosniff,X-Frame-Options: DENY,Referrer-Policy: no-referrer(the callback URL carries the one-time code) andCache-Control: no-store. No CORS headers. - Pages show only fixed texts; nothing from the request is reflected. Codes, states and tokens are never logged.
- nginx terminates TLS (TLS 1.2 and 1.3, HSTS) and forwards exactly those two paths,
GETonly; everything else is a 404 from nginx and never reaches the bot. Request bodies are limited to 1 KiB and slow clients are cut off after 10 s. The access log is off and the error log keeps only critical errors, because the callback URL carries the one-time code.
The systemd sandbox
deploy/polarbot.service runs the bot with as little as possible:
| Setting | Effect |
|---|---|
DynamicUser=yes | A throw-away system user that exists only while the service runs; owns nothing else on the machine |
StateDirectory=polarbot, StateDirectoryMode=0700, UMask=0077 | The only writable place is the data directory, readable only by the service |
ProtectSystem=strict, ProtectHome=yes, PrivateTmp=yes, PrivateDevices=yes | The rest of the file system is read-only; no home directories, no shared /tmp, no devices |
NoNewPrivileges=yes, CapabilityBoundingSet= (empty), AmbientCapabilities= (empty) | No way to gain privileges or capabilities |
PrivateUsers=yes, ProtectProc=invisible, ProcSubset=pid | Other users and processes are invisible |
ProtectKernelTunables, ProtectKernelModules, ProtectKernelLogs, ProtectControlGroups, ProtectClock, ProtectHostname | No changes to the kernel, clock or host name |
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX, RestrictNamespaces, RestrictRealtime, RestrictSUIDSGID, LockPersonality, MemoryDenyWriteExecute, RemoveIPC | Only IP and local sockets; no namespaces, realtime scheduling, set-uid files or writable+executable memory |
SystemCallArchitectures=native, SystemCallFilter=@system-service, SystemCallFilter=~@privileged @resources | Only the system calls an ordinary service needs |
MemoryMax=1G, TasksMax=512, LimitNOFILE=8192 | A runaway bot cannot take the machine down |
RestartPreventExitStatus=78, StartLimitBurst=5 in 600 s | No restart loop on a configuration problem |
You can check the result with systemd-analyze security polarbot.
Untrusted input
- No user URL is ever fetched. Links are judged by their text. The only outbound connections are Discord, Discord’s OAuth token endpoint and the configured domain lists.
- Domain lists are downloaded over https only (redirects too, at most 5), with a size cap
(
max_list_bytes) and a 60 s timeout. A download that does not parse to at least one domain is ignored. List names are restricted to[a-z0-9_-]because they become file names. - Messages and names shown on cards and in logs are escaped (backticks and
@are defused) and every reply is sent with mentions disabled; only the configured mod role may be pinged, and only for punishments. - Card buttons are checked on every press: the card must be the bot’s own message in the same server, the presser must be a Moderator (read fresh from the database), and a card is handled once.
- Moderation checks Discord’s role order and PolarBot’s levels for the moderator, the target and the bot itself; when role data is stale or unknown, it refuses instead of guessing. Unknown commands and subcommands need the highest level.
The database and backups
The SQLite database sits in the service’s private data directory (mode 0700). install.sh
backs it up to /var/backups/polarbot (directory 0700, files 0600) before every install.
Refresh tokens in it are encrypted; everything else is plain, so protect backups like the
server itself.
PolarBot reference
Everything PolarBot has, with no steps: the building blocks for the ultra-manual level.
These pages are generated from the source code by a test in crates/polarbot
(docs_reference). The test renders them from the real definitions (the registered commands
and their level checks, the config structs, the systemd unit, the database after every
migration, the log-level table) and fails when a checked-in page differs, so a change to the
code cannot ship without its reference page. To regenerate after a change:
POLAR_UPDATE_DOCS=1 cargo test -p polarbot docs_reference
| Page | What is in it |
|---|---|
| Commands | Every slash command and message command: subcommands, options (type, required, choices, limits), required level, DM availability |
| Configuration file | Every config key with its type, default and meaning |
| Credentials | The systemd credentials, their files and environment variables |
| Files and directories | Every path an installation uses |
| Database | Every table and column, with the personal data it holds and how long it is kept; the migrations |
| Log levels | What each log level 0–10 posts |
| Command line and exit codes | polarbot usage, exit statuses (including 78), gateway intents |
The units and scripts themselves are in the repository:
deploy/polarbot.service,
deploy/install.sh,
deploy/config.toml
and crates/polarbot/config.example.toml.
Commands
Every slash command and message command PolarBot registers, with its subcommands, options, the permission level it needs and whether it works in a DM with the bot. Levels come from /perms (see Permission levels); the bot checks the level itself on every use, whatever Discord shows.
Before a server runs /perms authorize, Discord shows every command except /about, /help and /privacy to Administrators only.
Overview
| Command | Required level | In DMs | What it does |
|---|---|---|---|
/about | User | yes | Show the bot’s version, license and source code |
/ban | Moderator | no (servers only) | Ban a user from the server |
/config | Admin | no (servers only) | View or change PolarBot’s settings |
/help | User | yes | Show the commands you can use, or explain one command or topic |
/kick | Moderator | no (servers only) | Kick a member from the server |
/lookup | Moderator | no (servers only) | Show a user’s punishment history, account age and current scanner activity |
/modlog | Moderator | no (servers only) | Show the latest moderation actions |
/perms | Admin to Owner | no (servers only) | Manage which roles have which permission level |
/privacy | User | yes | What PolarBot processes and stores about you |
/scanner | Moderator to Admin | no (servers only) | Configure the scam and spam scanner |
/setup | Admin | no (servers only) | Set up PolarBot: choose the modlog channel |
/timeout | Moderator | no (servers only) | Time out a member so they cannot chat or talk |
/unban | Moderator | no (servers only) | Lift a user’s ban |
/untimeout | Moderator | no (servers only) | Remove a member’s timeout |
/warn | Moderator | no (servers only) | Warn a member |
/warnings | Moderator | no (servers only) | List a user’s warnings |
Analyze with PolarBot | Moderator | no (servers only) | Message command: right-click a message → Apps. |
/about
Shows the running version of PolarBot, the commit and date it was built from, its release channel, its license (AGPL-3.0-or-later) and a link to its source code. Anyone can use it, also in a DM with PolarBot.
- Required level: User
- In DMs: yes
- Shown before
/perms authorize: to everyone - Example:
/about
No options.
/ban
Bans the user from the server. Works for users who are not members too (paste their user ID). You can’t ban yourself, the server owner, PolarBot, or a member whose highest role or PolarBot level is equal to or above yours, and PolarBot’s role must be above theirs. The ban is posted in the log channel and kept in the punishment history for 180 days.
- Required level: Moderator
- In DMs: no (servers only)
- Shown before
/perms authorize: to Administrators only - Example:
/ban user:@Spammer reason:Phishing links delete_messages:Previous 24 hours
| Option | Type | Required | Limits and choices | Description |
|---|---|---|---|---|
user | user | yes | – | The user: pick a member or paste a user ID. |
reason | text | no | at most 500 characters | Why, at most 500 characters. Shown in the audit log and the log channel. |
delete_messages | text | no | one of: Don’t delete any (none), Previous hour (1h), Previous 24 hours (1d), Previous 7 days (7d) | How much of their message history to delete: none (the default), the previous hour, 24 hours or 7 days. |
/config
Shows and changes the settings from /setup. The log level decides how much PolarBot posts in the log channel, from 0 (nothing) to 10 (every event); each level adds to the ones below it. New servers start at level 2.
- Required level: Admin
- In DMs: no (servers only)
- Shown before
/perms authorize: to Administrators only - Example:
/config log-level level:4
/config log-channel
Required level: Admin
Changes the log channel, or turns logging off when you leave channel empty. PolarBot checks its permissions in the channel like /setup does.
| Option | Type | Required | Limits and choices | Description |
|---|---|---|---|---|
channel | channel | no | channel types: GuildText, GuildAnnouncement | The new log channel. Leave it empty to turn logging off. |
/config log-level
Required level: Admin
Sets how much is posted. 0: nothing. 1: scanner punishments. 2 (default): + moderation through PolarBot. 3: + every flagged message. 4: + Discord’s own bans, kicks, timeouts and AutoMod. 5: + joins, leaves and invites. 6: + deleted and edited messages. 7: + role, channel, server, emoji, sticker and webhook changes. 8: + nickname, avatar, username, member role and voice changes. 9: + reactions, threads, pins and every scan score. 10: + every message, presence, typing and other event.
| Option | Type | Required | Limits and choices | Description |
|---|---|---|---|---|
level | integer | yes | 0 to 10 | A number from 0 to 10; each level includes everything below it. |
/config announcements-channel
Required level: Admin
Sets the staff channel where PolarBot posts its “updated to vX.Y.Z” notices. Leave channel empty to clear it: PolarBot then uses a channel named like “staff-announcements”, “staff-news”, “mod-announcements”, “staff-updates” or “bot-updates” (emoji and separators don’t matter) where it can post, otherwise the log channel.
| Option | Type | Required | Limits and choices | Description |
|---|---|---|---|---|
channel | channel | no | channel types: GuildText, GuildAnnouncement | The staff announcements channel. Leave it empty to let PolarBot find one by name. |
/config view
Required level: Admin
Shows the log channel, the log level, where update notices go and which roles have which level.
No options.
/help
Without options, lists the commands your permission level allows in this server (in a DM: the commands that work in DMs), each with a short description and an example. With command, explains one command: what it does, the level it needs, every subcommand and option, and examples. With topic, explains how the scanner works, the permission levels or privacy. Only you see the answer.
- Required level: User
- In DMs: yes
- Shown before
/perms authorize: to everyone - Example:
/help command:scanner
| Option | Type | Required | Limits and choices | Description |
|---|---|---|---|---|
command | text | no | at most 32 characters; autocompletes | The command to explain. Start typing and pick it from the list. |
topic | text | no | one of: How the scanner works (scanner), Permission levels (levels), Privacy (privacy) | A topic: how the scanner works, permission levels or privacy. Ignored when you also give a command. |
/kick
Removes the member from the server; they can come back with a new invite. The same rank rules as /ban apply. The kick is posted in the log channel and kept in the punishment history for 180 days.
- Required level: Moderator
- In DMs: no (servers only)
- Shown before
/perms authorize: to Administrators only - Example:
/kick user:@Troll reason:Ignored two warnings
| Option | Type | Required | Limits and choices | Description |
|---|---|---|---|---|
user | user | yes | – | The member to kick. |
reason | text | no | at most 500 characters | Why, at most 500 characters. Shown in the audit log and the log channel. |
/lookup
Shows the member’s bans, kicks, timeouts and warnings in this server from the last 180 days (counts and the 5 latest), account age, join date, roles, the scanner’s live view of their recent activity, and a risk band (Low, Elevated or High) with the reasons behind it. It is computed on the spot from data PolarBot already keeps; nothing new is stored. Only you see the answer.
- Required level: Moderator
- In DMs: no (servers only)
- Shown before
/perms authorize: to Administrators only - Example:
/lookup user:@Member
| Option | Type | Required | Limits and choices | Description |
|---|---|---|---|---|
user | user | yes | – | The member (or a user who left; then only the history is shown). |
/modlog
Shows the 10 latest bans, unbans, kicks, timeouts and warnings in this server, made with PolarBot’s commands or by the scanner, with moderator, reason and duration. Entries are deleted after 180 days.
- Required level: Moderator
- In DMs: no (servers only)
- Shown before
/perms authorize: to Administrators only - Example:
/modlog user:@Member
| Option | Type | Required | Limits and choices | Description |
|---|---|---|---|---|
user | user | no | – | Only show actions against this user. |
/perms
PolarBot has its own permission levels: User, Moderator, Admin and Owner. A member gets the highest level of their roles; members with the Administrator permission and the server owner are always Owner; everyone else is User. See /help topic: Permission levels for what each level can do.
- Required level: Admin to Owner
- In DMs: no (servers only)
- Shown before
/perms authorize: to Administrators only - Example:
/perms set role:@Moderators level:Moderator
/perms set
Required level: Owner
Gives a role a level, replacing the one it had. @everyone can’t get a level.
| Option | Type | Required | Limits and choices | Description |
|---|---|---|---|---|
role | role | yes | – | The role. |
level | text | yes | one of: User (user), Moderator (mod), Admin (admin), Owner (owner) | User, Moderator, Admin or Owner. |
/perms remove
Required level: Owner
Removes a role’s level; its members keep the levels of their other roles.
| Option | Type | Required | Limits and choices | Description |
|---|---|---|---|---|
role | role | yes | – | The role. |
/perms list
Required level: Admin
Lists the roles that have a level.
No options.
/perms authorize
Required level: Owner
Gives you a private link, valid for 10 minutes, to let PolarBot manage command permissions. After you approve it, each role sees only the commands its level allows, and PolarBot keeps that up to date when levels change. Until then the staff commands are hidden from everyone but administrators.
No options.
/privacy
Explains what PolarBot processes and stores, and shows the punishment history it has about you in every server. Anyone can use it, also in a DM with PolarBot. See also /help topic: Privacy.
- Required level: User
- In DMs: yes
- Shown before
/perms authorize: to everyone - Example:
/privacy my-data
/privacy info
Required level: User
Shows what PolarBot processes and stores, how long it keeps it, the privacy policy and the source code.
No options.
/privacy my-data
Required level: User
Shows the punishment history stored about you (bans, kicks, timeouts and warnings, each server), only to you.
No options.
/scanner
The scanner scores every message from 0 to 100 and logs, deletes or punishes from the thresholds you set. Changes apply at once. See /help topic: How the scanner works for what it looks at.
- Required level: Moderator to Admin
- In DMs: no (servers only)
- Shown before
/perms authorize: to Administrators only - Example:
/scanner status
/scanner status
Required level: Admin
Shows every scanner setting: on or off, actions or log-only mode, thresholds, punishment, channels, exempt roles, domain lists and the size of the threat lists.
No options.
/scanner enable
Required level: Admin
Turns the scanner on (the default).
No options.
/scanner disable
Required level: Admin
Turns the scanner off; no message is scanned.
No options.
/scanner actions
Required level: Admin
Switches between acting (deleting and punishing at the thresholds) and log-only mode, which only posts cards.
| Option | Type | Required | Limits and choices | Description |
|---|---|---|---|---|
enabled | true/false | yes | – | True to delete and punish, False for log-only mode. |
/scanner thresholds
Required level: Admin
Sets the scores (1 to 100) where the scanner logs, deletes and punishes. Give one or more; they must stay in order log ≤ delete ≤ punish. Defaults: 60, 90 and 97.
| Option | Type | Required | Limits and choices | Description |
|---|---|---|---|---|
log | integer | no | 1 to 100 | Post a card for messages at or above this score. |
delete | integer | no | 1 to 100 | Also delete messages at or above this score. |
punish | integer | no | 1 to 100 | Also punish the author at or above this score. |
/scanner punishment
Required level: Admin
Chooses what happens to the author at the punish threshold: nothing, a timeout (default 1 hour), a kick or a ban.
| Option | Type | Required | Limits and choices | Description |
|---|---|---|---|---|
action | text | yes | one of: No punishment (none), Timeout (timeout), Kick (kick), Ban (ban) | No punishment, Timeout, Kick or Ban. |
duration | text | no | at most 32 characters | Timeout length, e.g. 1h or 2d, from 1 minute to 28 days. Only for Timeout. |
/scanner hacked
Required level: Admin
Chooses what happens when an account posts the same link in 3 or more channels within 2 minutes: nothing, delete those messages, or delete them and time out the author (the default).
| Option | Type | Required | Limits and choices | Description |
|---|---|---|---|---|
action | text | yes | one of: Do nothing (none), Delete the messages (delete), Delete the messages and time out (delete_timeout) | Do nothing, Delete the messages, or Delete the messages and time out. |
/scanner mod-role
Required level: Admin
Sets the role PolarBot pings in the log channel on punishments and hacked accounts.
| Option | Type | Required | Limits and choices | Description |
|---|---|---|---|---|
role | role | no | – | The role to ping. Leave it empty to stop pinging. |
/scanner invites
Required level: Admin
Chooses whether Discord invite links are allowed or count towards the score (flagged is the default).
| Option | Type | Required | Limits and choices | Description |
|---|---|---|---|---|
allow | true/false | yes | – | True to allow invites, False to flag them. |
/scanner channel
Required level: Admin
Gives a channel a rule: Monitor (scanned in “only monitored channels” mode), Exempt (never scanned) or Clear the rule. Threads follow their parent channel.
| Option | Type | Required | Limits and choices | Description |
|---|---|---|---|---|
channel | channel | yes | channel types: GuildText, GuildAnnouncement, GuildVoice, GuildStageVoice, GuildForum | The channel. |
rule | text | yes | one of: Monitor (monitor), Exempt (exempt), Clear the rule (clear) | Monitor, Exempt or Clear the rule. |
/scanner channel-mode
Required level: Admin
Chooses which channels are scanned: all except exempt ones (the default), or only monitored ones.
| Option | Type | Required | Limits and choices | Description |
|---|---|---|---|---|
mode | text | yes | one of: All channels except exempt ones (all), Only monitored channels (selected) | All channels except exempt ones, or Only monitored channels. |
/scanner exempt-role
Required level: Admin
Stops scanning members with a role, or starts again. Members with the Moderator level or higher are never scanned anyway.
| Option | Type | Required | Limits and choices | Description |
|---|---|---|---|---|
role | role | yes | – | The role. |
exempt | true/false | yes | – | True to exempt the role, False to scan it again. |
/scanner allow
Required level: Admin
Puts a domain on the allow list: links to it and its subdomains skip the link checks. A domain on a threat list or the block list is still flagged.
| Option | Type | Required | Limits and choices | Description |
|---|---|---|---|---|
domain | text | yes | at most 300 characters | The domain, e.g. example.com. |
/scanner block
Required level: Admin
Puts a domain on the block list: links to it and its subdomains score at least 90. Official domains such as discord.com can’t be blocked.
| Option | Type | Required | Limits and choices | Description |
|---|---|---|---|---|
domain | text | yes | at most 300 characters | The domain, e.g. example.com. |
/scanner unlist
Required level: Admin
Removes a domain from the allow or block list.
| Option | Type | Required | Limits and choices | Description |
|---|---|---|---|---|
domain | text | yes | at most 300 characters | The domain, e.g. example.com. |
/scanner nsfw-links
Required level: Admin
Chooses what happens to links to adult sites (from public adult-domain lists): Ignore, Log (a card), Delete, or Delete and time out. This is separate from the scam score and never applies in channels Discord marks age-restricted (threads follow their parent). New servers: Delete; servers that had PolarBot before 0.3.1: Log. In log-only mode they are only logged.
| Option | Type | Required | Limits and choices | Description |
|---|---|---|---|---|
action | text | yes | one of: Ignore (ignore), Log (log), Delete (delete), Delete and time out (delete_timeout) | Ignore, Log, Delete, or Delete and time out (the timeout length of /scanner punishment). |
/scanner analyze
Required level: Moderator
A dry run: shows the score 0 to 100, what the scanner would do under the current settings, every reason with its weight, a plain-English “Why” for the strongest reasons, what to do when the rating is Elevated or higher, the domains and a line per attachment. Nothing is stored, deleted or posted. Also available by right-clicking a message → Apps → Analyze with PolarBot (Moderator).
| Option | Type | Required | Limits and choices | Description |
|---|---|---|---|---|
text | text | no | at most 2000 characters | Text to analyze, e.g. a message you were sent elsewhere. |
message | text | no | at most 200 characters | A link to a message in this server (right-click → Copy Message Link). Only channels you can read. |
attachment | attachment | no | – | A file to check. Today only its name and type are checked; image content analysis comes later. |
attachment2 | attachment | no | – | A second file to check. |
attachment3 | attachment | no | – | A third file to check. |
/setup
Sets the log channel: PolarBot posts moderation actions, scanner cards and, depending on the log level, other server events there. PolarBot checks that it can view the channel, send messages and embed links in it and refuses the channel otherwise. Change it later with /config log-channel.
Next steps: give your staff roles a level with /perms set, run /perms authorize once, and check the scanner with /scanner status.
- Required level: Admin
- In DMs: no (servers only)
- Shown before
/perms authorize: to Administrators only - Example:
/setup log_channel:#mod-log
| Option | Type | Required | Limits and choices | Description |
|---|---|---|---|---|
log_channel | channel | yes | channel types: GuildText, GuildAnnouncement | A text or announcement channel in this server where PolarBot can view, send messages and embed links. |
/timeout
Puts the member in a Discord timeout: they can still read, but can’t send messages, react or speak until it ends. The same rank rules as /ban apply. End it early with /untimeout.
- Required level: Moderator
- In DMs: no (servers only)
- Shown before
/perms authorize: to Administrators only - Example:
/timeout user:@Spammer duration:1h reason:Spam
| Option | Type | Required | Limits and choices | Description |
|---|---|---|---|---|
user | user | yes | – | The member to time out. |
duration | text | yes | at most 32 characters | How long, e.g. 10m, 1h30m or 7d (units s, m, h, d, w), at most 28 days. |
reason | text | no | at most 500 characters | Why, at most 500 characters. Shown in the audit log and the log channel. |
/unban
Lifts a ban. Banned users aren’t in the server, so paste their user ID (from the server’s ban list or the log channel). Tells you when the user isn’t banned.
- Required level: Moderator
- In DMs: no (servers only)
- Shown before
/perms authorize: to Administrators only - Example:
/unban user:123456789012345678 reason:Appeal accepted
| Option | Type | Required | Limits and choices | Description |
|---|---|---|---|---|
user | user | yes | – | The banned user’s ID. |
reason | text | no | at most 500 characters | Why, at most 500 characters. Shown in the audit log and the log channel. |
/untimeout
Ends the member’s timeout now. The same rank rules as /ban apply.
- Required level: Moderator
- In DMs: no (servers only)
- Shown before
/perms authorize: to Administrators only - Example:
/untimeout user:@Member reason:Calmed down
| Option | Type | Required | Limits and choices | Description |
|---|---|---|---|---|
user | user | yes | – | The member whose timeout to remove. |
reason | text | no | at most 500 characters | Why, at most 500 characters. Shown in the audit log and the log channel. |
/warn
Records a warning with a reason and posts it in the log channel. PolarBot doesn’t message the member, so tell them yourself if they should know. The same rank rules as /ban apply. Warnings are deleted after 180 days; list them with /warnings.
- Required level: Moderator
- In DMs: no (servers only)
- Shown before
/perms authorize: to Administrators only - Example:
/warn user:@Member reason:No advertising in #general
| Option | Type | Required | Limits and choices | Description |
|---|---|---|---|---|
user | user | yes | – | The member to warn. |
reason | text | yes | at most 500 characters | Why, required, at most 500 characters. |
/warnings
Shows how many warnings the user has in this server and the latest 10, with reason, moderator and date.
- Required level: Moderator
- In DMs: no (servers only)
- Shown before
/perms authorize: to Administrators only - Example:
/warnings user:@Member
| Option | Type | Required | Limits and choices | Description |
|---|---|---|---|---|
user | user | yes | – | The user whose warnings to show. |
Message command: Analyze with PolarBot
A message command (right-click a message → Apps → Analyze with PolarBot). It has no options: it runs on the message you clicked.
- Required level: Moderator
- In DMs: no (servers only)
- Shown before
/perms authorize: to Administrators only
Configuration file
PolarBot reads one TOML file: --config <path>, or /etc/polarbot/config.toml by default. Every section and every key is optional; the defaults below apply. Unknown keys are rejected at startup so typos are caught. An annotated example is crates/polarbot/config.example.toml.
Secrets never go in this file. A key named token, secret, password or api_key (any case, at any depth) makes the bot refuse to start. Secrets are systemd credentials.
Defaults shown are the Linux ones.
[discord]
| Key | Type | Default | Description |
|---|---|---|---|
dev_guild_id | integer (server ID), optional | unset | Register the slash commands in this one server only, where they appear at once (development). Unset: commands are registered globally. Must not be 0. |
[database]
| Key | Type | Default | Description |
|---|---|---|---|
path | path | /var/lib/polarbot/polarbot.db | The SQLite database file. Its directory is the data directory: the threat-list and adult-list caches live next to it. The directory is created if missing and must be writable. |
[log]
| Key | Type | Default | Description |
|---|---|---|---|
level | text (tracing filter) | info | What the process logs to stderr/journald: error, warn, info, debug, trace, or a filter such as polarbot=debug,info. An invalid value falls back to info. (Not the per-server Discord log level.) |
json | true/false | false | Log JSON lines (for log collectors) instead of human-readable text. |
[scanner]
| Key | Type | Default | Description |
|---|---|---|---|
refresh_hours | integer, 1 to 720 | 24 | How often each threat list and adult list is downloaded again, in hours. A failed download is retried after an hour; the cached list stays in use. |
max_list_bytes | integer, 1 to 1073741824 | 67108864 | Largest accepted list download in bytes (default 64 MiB). A larger download is aborted and the cached list is kept. |
threatlists | array of { name, url } tables | destroylist: https://raw.githubusercontent.com/phishdestroy/destroylist/main/list.txtdiscord-phishing-links: https://raw.githubusercontent.com/nikolaischunk/discord-phishing-links/main/txt/domain-list.txt | Phishing-domain lists (plain domain lists or hosts files). Setting it replaces the defaults; [] turns threat lists off. name must match [a-z0-9_-]+ (at most 64 characters) and be unique: it names the cache file <data dir>/threatlists/<name>.txt. url must be https without credentials. A download that is empty, an HTML page or has no valid entries keeps the previous list. |
adultlists | array of { name, url } tables | stevenblack-porn: https://raw.githubusercontent.com/StevenBlack/hosts/master/alternates/porn-only/hosts | Adult-site domain lists for the separate NSFW-link category (/scanner nsfw-links); they never raise the scam score. Same rules as threatlists, cached as <data dir>/adultlists/<name>.txt; [] turns the category off. |
[web]
| Key | Type | Default | Description |
|---|---|---|---|
bind | address:port | 127.0.0.1:8787 | Where the OAuth callback server listens. Keep it on localhost and let a reverse proxy (nginx) terminate TLS. The server only runs when both OAuth credentials are present. |
public_url | https URL, no trailing slash | https://bot.polarsystems.net | Public base URL of the callback server. The OAuth redirect URI is <public_url>/oauth/callback and must be registered exactly like that in the Discord Developer Portal. Change it to your own domain. |
Example
A production file usually needs only the public URL of the OAuth callback:
[log]
level = "info"
[web]
public_url = "https://bot.example.org"
Credentials
Secrets are never in the config file. On Linux they are systemd credentials, encrypted at rest with the host key (systemd-creds encrypt) and decrypted only into the service’s private $CREDENTIALS_DIRECTORY while it runs. Without systemd (for development) each one can come from an environment variable instead. A credential file wins over the environment variable; a trailing newline is stripped; an empty value counts as missing.
| Credential | Encrypted file | Environment variable | Required | What it is |
|---|---|---|---|---|
discord_token | /etc/polarbot/discord_token.cred | POLARBOT_DISCORD_TOKEN | yes: the bot does not start without it | The bot token from the Discord Developer Portal (Bot → Reset Token). install.sh asks for it with hidden input. |
discord_client_secret | /etc/polarbot/discord_client_secret.cred | POLARBOT_DISCORD_CLIENT_SECRET | no: without it command visibility sync and the web server are off | The application’s OAuth2 client secret (Developer Portal → OAuth2), used to exchange /perms authorize codes and refresh tokens. |
oauth_key | /etc/polarbot/oauth_key.cred | POLARBOT_OAUTH_KEY | no: same as discord_client_secret (both are needed) | 32 random bytes as 64 hex characters. Subkeys derived from it (HKDF-SHA256) sign the OAuth state (HMAC-SHA256) and encrypt stored refresh tokens (AES-256-GCM). install.sh generates it. A new key invalidates every stored authorization. |
The unit loads them with:
LoadCredentialEncrypted=discord_token:/etc/polarbot/discord_token.cred
LoadCredentialEncrypted=discord_client_secret:/etc/polarbot/discord_client_secret.cred
LoadCredentialEncrypted=oauth_key:/etc/polarbot/oauth_key.cred
Files and directories
Where a Linux installation made with deploy/install.sh and deploy/polarbot.service keeps everything. Nothing else is written: logs go to the journal, and flagged messages are never stored.
| Path | What it is |
|---|---|
/opt/polarbot/polarbot | The installed binary (install.sh). |
/opt/polarbot/polarbot.prev | The previous binary, kept by install.sh for a rollback. |
/etc/polarbot/config.toml | The config file (default --config). Installed from deploy/config.toml once; never overwritten by install.sh. |
/etc/polarbot/discord_token.cred | The discord_token credential, encrypted with systemd-creds (mode 0600). See Credentials. |
/etc/polarbot/discord_client_secret.cred | The discord_client_secret credential, encrypted with systemd-creds (mode 0600). See Credentials. |
/etc/polarbot/oauth_key.cred | The oauth_key credential, encrypted with systemd-creds (mode 0600). See Credentials. |
/etc/systemd/system/polarbot.service | The systemd unit (from deploy/polarbot.service). |
/var/lib/polarbot/ | The data directory (StateDirectory=polarbot, mode 0700). With DynamicUser=yes systemd keeps it in /var/lib/private/polarbot and links /var/lib/polarbot to it. |
/var/lib/polarbot/polarbot.db | The SQLite database, in WAL mode (so also polarbot.db-wal and polarbot.db-shm). See Database. |
/var/lib/polarbot/threatlists/destroylist.txt | Cached threat list destroylist (one domain per line). Loaded at startup, replaced after every successful refresh. |
/var/lib/polarbot/threatlists/discord-phishing-links.txt | Cached threat list discord-phishing-links (one domain per line). Loaded at startup, replaced after every successful refresh. |
/var/lib/polarbot/adultlists/stevenblack-porn.txt | Cached adult-site list stevenblack-porn (one domain per line). |
/var/backups/polarbot/polarbot-<UTC stamp>.db | Database backups made by install.sh before every install (with -wal/-shm when present, mode 0600). The 10 newest are kept. |
Database
PolarBot keeps everything in one SQLite file (see Files), opened in WAL mode with foreign keys on and a 5 s busy timeout. Migrations run automatically at startup. Nothing about a flagged message is stored: the mod card in Discord is the only record. Every row of a server is deleted when the bot leaves that server.
Tables and personal data
| Table | What it holds | Personal data | Kept for |
|---|---|---|---|
guild_settings | Per-server settings: log channel, log level, staff announcements channel. | None (server and channel IDs). | Until the bot leaves the server (then deleted at once). |
role_levels | The /perms mapping: role → permission level. | None (role IDs). | Until the bot leaves the server (then deleted at once). |
mod_actions | Punishment history: bans, unbans, kicks, timeouts, untimeouts and warns, from commands and from the scanner. | Yes: target and moderator user IDs, the reason text, the time. | 180 days (purged hourly), or until the bot leaves the server. |
warnings | /warn warnings. | Yes: user and moderator IDs, the reason text, the time. | 180 days (purged hourly), or until the bot leaves the server. |
scan_settings | Scanner settings: on/off, thresholds, actions, mod role, channel mode, NSFW-link action. | None. | Until the bot leaves the server (then deleted at once). |
scan_channels | Scanner channel rules (monitor or exempt). | None. | Until the bot leaves the server (then deleted at once). |
scan_exempt_roles | Roles the scanner skips. | None. | Until the bot leaves the server (then deleted at once). |
scan_domains | The server’s allow and block lists of domains. | None. | Until the bot leaves the server (then deleted at once). |
training_examples | Anonymized texts of decided scanner cards (Scam / Not scam) for a future classifier. | No: mentions, IDs, names, invite codes and URL paths are replaced before storing; no server or user column; the day only. | Until exported with polarbot export-training (the export deletes them). |
guild_oauth | The /perms authorize grant for command visibility: the refresh token, encrypted (AES-256-GCM, server ID as associated data). | The user ID of the admin who authorized, and the encrypted token. | Until replaced by a new authorization, found revoked, or the bot leaves the server. |
bot_meta | Process-wide state, e.g. announced_version (the last version whose update notice was posted). | None. | Kept. |
scan_stats | /scanner status counters: messages flagged and acted on, per server per UTC day. | None (two numbers per server and day). | 30 days (purged hourly), or until the bot leaves the server. |
_sqlx_migrations | Which migrations have run (managed by sqlx). A binary refuses to start on a database migrated by a newer one. | None. | Kept. |
Columns
guild_settings
| Column | Type | Null | Default | Key |
|---|---|---|---|---|
guild_id | INTEGER | yes | – | primary |
locale | TEXT | no | 'en' | |
log_channel_id | INTEGER | yes | – | |
created_at | INTEGER | no | – | |
log_level | INTEGER | no | 2 | |
announcements_channel_id | INTEGER | yes | – |
role_levels
| Column | Type | Null | Default | Key |
|---|---|---|---|---|
guild_id | INTEGER | no | – | primary |
role_id | INTEGER | no | – | primary |
level | INTEGER | no | – |
mod_actions
| Column | Type | Null | Default | Key |
|---|---|---|---|---|
id | INTEGER | yes | – | primary |
guild_id | INTEGER | no | – | |
moderator_id | INTEGER | no | – | |
target_id | INTEGER | no | – | |
action | TEXT | no | – | |
reason | TEXT | yes | – | |
duration_secs | INTEGER | yes | – | |
created_at | INTEGER | no | – |
warnings
| Column | Type | Null | Default | Key |
|---|---|---|---|---|
id | INTEGER | yes | – | primary |
guild_id | INTEGER | no | – | |
user_id | INTEGER | no | – | |
moderator_id | INTEGER | no | – | |
reason | TEXT | no | – | |
created_at | INTEGER | no | – |
scan_settings
| Column | Type | Null | Default | Key |
|---|---|---|---|---|
guild_id | INTEGER | yes | – | primary |
enabled | INTEGER | no | 1 | |
log_threshold | INTEGER | no | 60 | |
delete_threshold | INTEGER | no | 90 | |
punish_threshold | INTEGER | no | 97 | |
punish_action | TEXT | no | 'timeout' | |
timeout_secs | INTEGER | no | 3600 | |
hacked_action | TEXT | no | 'delete_timeout' | |
mod_role_id | INTEGER | yes | – | |
allow_invites | INTEGER | no | 0 | |
channel_mode | TEXT | no | 'all' | |
actions_enabled | INTEGER | no | 1 | |
updated_at | INTEGER | no | – | |
nsfw_action | TEXT | no | 'delete' |
scan_channels
| Column | Type | Null | Default | Key |
|---|---|---|---|---|
guild_id | INTEGER | no | – | primary |
channel_id | INTEGER | no | – | primary |
rule | TEXT | no | – |
scan_exempt_roles
| Column | Type | Null | Default | Key |
|---|---|---|---|---|
guild_id | INTEGER | no | – | primary |
role_id | INTEGER | no | – | primary |
scan_domains
| Column | Type | Null | Default | Key |
|---|---|---|---|---|
guild_id | INTEGER | no | – | primary |
domain | TEXT | no | – | primary |
list | TEXT | no | – |
training_examples
| Column | Type | Null | Default | Key |
|---|---|---|---|---|
id | INTEGER | yes | – | primary |
text | TEXT | no | – | |
category | TEXT | no | – | |
verdict | TEXT | no | – | |
created_at | INTEGER | no | – |
guild_oauth
| Column | Type | Null | Default | Key |
|---|---|---|---|---|
guild_id | INTEGER | yes | – | primary |
encrypted_refresh_token | BLOB | no | – | |
nonce | BLOB | no | – | |
authorized_by | INTEGER | no | – | |
scope | TEXT | no | – | |
updated_at | INTEGER | no | – |
bot_meta
| Column | Type | Null | Default | Key |
|---|---|---|---|---|
key | TEXT | yes | – | primary |
value | TEXT | no | – |
scan_stats
| Column | Type | Null | Default | Key |
|---|---|---|---|---|
guild_id | INTEGER | no | – | primary |
day | INTEGER | no | – | primary |
flagged | INTEGER | no | 0 | |
acted | INTEGER | no | 0 |
_sqlx_migrations
| Column | Type | Null | Default | Key |
|---|---|---|---|---|
version | BIGINT | yes | – | primary |
description | TEXT | no | – | |
installed_on | TIMESTAMP | no | CURRENT_TIMESTAMP | |
success | BOOLEAN | no | – | |
checksum | BLOB | no | – | |
execution_time | BIGINT | no | – |
Migrations
Applied in order, once each, at startup (crates/polarbot/migrations).
| File | Changes |
|---|---|
0001_core.sql | creates table guild_settings, creates table role_levels, creates table mod_actions, creates index mod_actions_guild_target, creates table warnings, creates index warnings_guild_user |
0002_scanner.sql | creates table scan_settings, creates table scan_channels, creates table scan_exempt_roles, creates table scan_domains, creates table training_examples, creates index mod_actions_created_at, creates index warnings_created_at, fills scan_settings |
0003_logging.sql | adds column guild_settings.log_level |
0004_oauth.sql | creates table guild_oauth |
0005_bot_meta.sql | creates table bot_meta |
0006_scan_stats.sql | creates table scan_stats |
0007_nsfw_links.sql | adds column scan_settings.nsfw_action, updates scan_settings |
0008_announcements_channel.sql | adds column guild_settings.announcements_channel_id |
Log levels
Each server chooses with /config log-level how much PolarBot posts in its log channel, from 0 (nothing) to 10 (every gateway event). Each level includes everything below it. The default is 2. Nothing that is logged is stored: events are batched (up to 10 embeds about every 2 seconds per server) and posted to the log channel only. Events in the log channel itself and the bot’s own actions are never logged. Scanner flags appear as mod cards from level 1 when a log channel is set.
| Level | Adds | Events |
|---|---|---|
| 0 | Nothing is logged. | – |
| 1 | Adds scanner punishments: deletes, timeouts, kicks, bans and hacked accounts. | ScannerAction |
| 2 | Adds moderation done through PolarBot. | BotModeration |
| 3 | Adds every message the scanner flags. | MessageFlagged |
| 4 | Adds Discord’s own moderation: bans, unbans, timeouts, kicks and AutoMod actions. | BanAdd, BanRemove, NativeTimeout, NativeKick, AutoModExecution |
| 5 | Adds members joining and leaving, and invites being created, deleted and used. | MemberJoin, MemberLeave, InviteCreate, InviteDelete, InviteUse |
| 6 | Adds deleted and edited messages, with the text before and after when known. | MessageDelete, MessageBulkDelete, MessageEdit |
| 7 | Adds changes to roles, channels, the server, emojis, stickers and webhooks. | GuildObjectChange |
| 8 | Adds nickname, avatar, username and role changes, and voice activity. | NicknameChange, AvatarChange, MemberRoles, UsernameChange, Voice |
| 9 | Adds reactions, threads, pins and every scan score with its reasons. | Reaction, ThreadChange, PinsUpdate, ScanScore |
| 10 | Adds every message, presence, typing, command and other Discord event. | MessageCreate, Presence, Typing, Interaction, Gateway |
Command line and exit codes
The polarbot binary runs the bot, or one offline subcommand. It never takes secrets as arguments.
Usage
usage: polarbot [--config <path>]
polarbot export-training --out <file.jsonl> [--config <path>]
| Form | What it does |
|---|---|
polarbot [--config <path>] | Runs the bot. Without --config it reads /etc/polarbot/config.toml. Stops cleanly on SIGTERM or Ctrl-C (at most 10 s for open work). |
polarbot export-training --out <file.jsonl> [--config <path>] | Writes every stored training example as one JSON line (text, category, verdict, created_at) to a new file with mode 0600, then deletes the exported rows. Refuses an existing file or a missing database. Run it while the bot is stopped or as the service user. |
polarbot --help | Prints the usage and exits with status 2. |
Exit codes
| Status | Meaning | systemd |
|---|---|---|
| 0 | Stopped normally (SIGTERM or Ctrl-C). | Not restarted. |
| 1 | Fatal error: unreadable or invalid config file, missing discord_token, database error, Discord rejected the login, or every gateway connection ended. | Restarted after 5 s (Restart=on-failure), at most 5 times in 10 minutes. |
| 2 | Bad command line. | Restarted like 1 (fix the unit’s ExecStart). |
| 78 | Configuration problem a restart cannot fix: Discord closed the gateway with a fatal close code, e.g. 4014 (privileged intents not enabled) or 4004 (bad token). The log says which. | Not restarted (RestartPreventExitStatus=78). Fix the cause, then systemctl reset-failed polarbot and start it. |
Discord gateway intents
PolarBot connects with these intents. The three privileged ones must be enabled under Bot → Privileged Gateway Intents in the Developer Portal, or Discord closes the connection with 4014 and the bot exits with status 78.
| Intent | Privileged |
|---|---|
GUILDS | no |
GUILD_MEMBERS | yes |
GUILD_MODERATION | no |
GUILD_EMOJIS_AND_STICKERS | no |
GUILD_INTEGRATIONS | no |
GUILD_WEBHOOKS | no |
GUILD_INVITES | no |
GUILD_VOICE_STATES | no |
GUILD_PRESENCES | yes |
GUILD_MESSAGES | no |
GUILD_MESSAGE_REACTIONS | no |
GUILD_MESSAGE_TYPING | no |
MESSAGE_CONTENT | yes |
GUILD_SCHEDULED_EVENTS | no |
AUTO_MODERATION_EXECUTION | no |
Install PolarBot (manual)
This page installs PolarBot from source on one Linux server, step by step: every command, what
it does and what you should see. It takes about an hour, most of it waiting for the build.
The result is exactly what deploy/install.sh sets up; the Reference
lists every file and setting by name.
Commands are for Debian/Ubuntu and Fedora; where they differ, both are shown. Lines
starting with sudo change the system; the others run as your normal user.
Before you start: you need a Linux server with systemd 250 or newer (Debian 12+, Ubuntu 24.04+, Fedora 38+), about 2 GB of free RAM and 3 GB of disk for the build, and a Discord account that can add bots to your server.
1. Create the Discord application
-
Open https://discord.com/developers/applications and choose New Application. Give it a name (members will see it as the bot’s name).
-
On General Information, copy the Application ID. You need it for the invite link.
-
On Bot:
- choose Reset Token, copy the token and keep it somewhere safe for step 7. It is shown only once, and anyone with it controls your bot;
- under Privileged Gateway Intents, enable Presence Intent, Server Members Intent and Message Content Intent. Without them Discord refuses the connection and the bot stops with exit status 78.
-
Invite the bot to your server: open this link in a browser, with your application ID in it, and pick the server.
https://discord.com/oauth2/authorize?client_id=<APPLICATION_ID>&scope=bot%20applications.commands&permissions=1099511721094The number grants exactly: Kick Members, Ban Members, View Audit Log, View Channels, Send Messages, Manage Messages, Embed Links, Read Message History and Timeout Members. Optionally add Manage Server to the bot’s role later so that joins can be attributed to invites at log level 5.
-
In your server’s settings, drag PolarBot’s role above the roles of members it should be able to moderate. Discord does not let a bot act on members whose top role is above its own.
2. Check the server
systemd --version | head -n1
systemd-creds --version | head -n1
Both print systemd 250 or a higher number (for example systemd 252 (252.33-1~deb12u1)).
If systemd-creds is missing or older than 250, this guide does not work on that system:
the bot’s secrets are stored with it.
3. Install the build tools
PolarBot is written in Rust. The build also compiles some C code (SQLite and the crypto library), so it needs a C compiler.
Debian/Ubuntu:
sudo apt update
sudo apt install build-essential git curl ca-certificates
Fedora:
sudo dnf install gcc git curl
Then install Rust with rustup, the official installer (PolarBot needs Rust 1.90 or newer, which most distributions do not ship yet). Download the installer, look at it if you like, then run it:
curl --proto '=https' --tlsv1.2 -sSf -o rustup-init.sh https://sh.rustup.rs
sh rustup-init.sh -y --profile minimal
. "$HOME/.cargo/env"
cargo --version
cargo --version prints something like cargo 1.90.0 (…) or newer. (On Fedora you can
instead run sudo dnf install rustup and then rustup-init -y --profile minimal.)
4. Get the source
git clone https://github.com/sergeantpolarbear/PolarSystems.git
cd PolarSystems
git checkout stable
git log -1 --oneline
stable is the branch with the released versions. The last command shows the commit you are
about to build; the running bot will show the same commit in /about.
5. Build
export POLARBOT_CHANNEL=stable
export POLARBOT_GIT_REV="$(git rev-parse --short HEAD)"
export POLARBOT_BUILD_DATE="$(date -u +%Y-%m-%d)"
cargo build --release --locked -p polarbot
- The three variables are baked into the binary and shown by
/aboutand the first log line. They are optional; without them the bot reportsunknownand channeldev. --lockedbuilds exactly the dependency versions inCargo.lock.- The first build downloads the toolchain components named in
rust-toolchain.tomland all dependencies, and takes several minutes.
It ends with a line like:
Finished `release` profile [optimized] target(s) in 4m 31s
Check the binary:
./target/release/polarbot --help
prints the usage (and exits with status 2, which is expected for --help):
usage: polarbot [--config <path>]
polarbot export-training --out <file.jsonl> [--config <path>]
6. Install the binary and the config file
sudo install -d -m 0755 /opt/polarbot
sudo install -m 0755 target/release/polarbot /opt/polarbot/polarbot
sudo install -d -m 0755 /etc/polarbot
sudo install -m 0644 deploy/config.toml /etc/polarbot/config.toml
/opt/polarbot/polarbotis the program the service runs./etc/polarbot/config.tomlis the config file. The one fromdeploy/only sets the log format; every other setting has a sensible default. It must never contain secrets (the bot refuses to start if it does).
If you will set up command visibility (next page), set your public URL now:
sudoedit /etc/polarbot/config.toml
and add:
[web]
public_url = "https://bot.example.org"
with your own host name instead of bot.example.org.
7. Store the secrets
The bot token is stored encrypted with systemd-creds: only systemd can decrypt it, and
only for the running service. read -s reads it without showing it and without putting it in
your shell history.
sudo -v
read -rsp "Bot token: " TOKEN; echo
printf '%s' "$TOKEN" | sudo systemd-creds encrypt --name=discord_token - /etc/polarbot/discord_token.cred
unset TOKEN
sudo chmod 0600 /etc/polarbot/discord_token.cred
Paste the token from step 1 at the prompt and press Enter. sudo -v asks for your password
first, so the password prompt does not get in the way of the pipe. systemd-creds prints
nothing, or on its very first use a note that it created the machine’s credential key.
The unit also loads the two OAuth credentials for command visibility. Set them up now with Command visibility: OAuth and nginx (recommended), or, to run without command visibility for now, remove those two lines from the unit in the next step.
8. Install and start the service
sudo install -m 0644 deploy/polarbot.service /etc/systemd/system/polarbot.service
Only if you skip command visibility for now:
sudo sed -i '/^LoadCredentialEncrypted=\(discord_client_secret\|oauth_key\):/d' /etc/systemd/system/polarbot.service
Then:
sudo systemctl daemon-reload
sudo systemctl enable --now polarbot
systemctl status polarbot --no-pager
status shows Active: active (running). The unit runs the bot as a throw-away system user
in a strict sandbox and creates the data directory /var/lib/polarbot on first start (see
the security model).
9. Check the log
sudo journalctl -u polarbot -n 30 --no-pager
A good start looks like this (shortened):
INFO polarbot: starting polarbot version="0.3.1" rev="1a2b3c4" built="2026-10-22" channel="stable" config=/etc/polarbot/config.toml
INFO polarbot::app: authenticated application=1234… bot=1234…
INFO polarbot::threatlists: no cached list yet kind="threat" source=destroylist
INFO polarbot::app: shards started shards=1
INFO shard{id=[0, 1]}: polarbot::app: connected guilds=1
INFO polarbot::app: registered global commands count=17
INFO polarbot::threatlists: list updated kind="threat" source=destroylist bytes=…
no cached list yetis normal on the first start; the lists are downloaded within a minute (list updated) and cached for the next start.- Without OAuth credentials you also see
OAuth credentials not configured; command visibility sync and the web server are disabled. - Global commands can take a few minutes to appear in Discord the first time.
If the service is not running, see Troubleshooting.
10. Set it up in Discord
In your server:
/setup log_channel:#mod-logchooses the log channel for mod cards and logs. PolarBot checks it can post there./perms set role:@Moderators level:Moderator(andAdminfor your admins) gives your staff roles their levels. Members with Administrator and the server owner are always Owner./perms authorize(Owner, after command visibility is set up) so each role sees only its commands./scanner statusshows the scanner settings. New servers start with the scanner on and actions enabled at the defaults (card from 60, delete from 90, 1 hour timeout from 97). Try it with/scanner analyze text:….
Configure explains the settings in detail.
Shortcut: the install script
Steps 6–8 (and the credentials of the next page) are what deploy/install.sh does in one go.
Run it from a directory that contains the built binary as ./polarbot and the deploy/
folder:
cp target/release/polarbot ./polarbot
sudo ./deploy/install.sh
It asks for every missing secret with hidden input, never overwrites an existing config file,
backs up the database and keeps the previous binary before it replaces anything. The
maintainers use deploy/deploy.sh <ssh-host> from a workstation to copy the source to the
server and build it there before running install.sh.
Command visibility: OAuth and nginx
Optional but recommended. Without it, every staff command is visible only to members with the
Administrator permission. With it, after an Owner runs /perms authorize once, each role
sees exactly the commands its /perms level allows. (The bot checks the level itself either
way; this is about what Discord shows.) How it works
explains the mechanism, the security model the OAuth
details.
What you set up here:
- a host name, e.g.
bot.example.org, pointing at your server; - nginx with a Let’s Encrypt certificate, forwarding exactly two paths to the bot on
127.0.0.1:8787; - the redirect URL and the client secret in the Discord Developer Portal;
- two more encrypted credentials:
discord_client_secretandoauth_key.
Replace bot.example.org with your own host name everywhere below.
1. DNS
Create an A (and, if your server has IPv6, AAAA) record for bot.example.org pointing at
your server, or a CNAME to a name that already does. If your DNS provider can proxy traffic
(e.g. Cloudflare’s orange cloud), keep it DNS only until the first certificate exists, so
Let’s Encrypt can reach the server over plain HTTP.
Check from any machine:
getent hosts bot.example.org
prints your server’s address.
2. nginx, certbot and the firewall
Debian/Ubuntu:
sudo apt install nginx certbot
Fedora:
sudo dnf install nginx certbot
sudo systemctl enable --now nginx certbot-renew.timer
sudo firewall-cmd --permanent --add-service=http --add-service=https
sudo firewall-cmd --reload
sudo setsebool -P httpd_can_network_connect 1
On Fedora, SELinux by default forbids nginx to open network connections; the last command
allows it to reach the bot on 127.0.0.1:8787. Debian and Ubuntu start nginx and certbot’s
renewal timer on installation. If you run a firewall there, allow ports 80 and 443.
Where site files go differs:
| Site file | Enable it | |
|---|---|---|
| Debian/Ubuntu | /etc/nginx/sites-available/bot.example.org | sudo ln -sf /etc/nginx/sites-available/bot.example.org /etc/nginx/sites-enabled/bot.example.org |
| Fedora | /etc/nginx/conf.d/bot.example.org.conf | (every *.conf there is loaded) |
The commands below use the Debian path in a variable; on Fedora set
SITE=/etc/nginx/conf.d/bot.example.org.conf instead and skip the ln lines.
3. The first certificate
The full site refers to the certificate files, so nginx -t would fail before they exist.
Start with the HTTP-only bootstrap site, which serves only Let’s Encrypt’s challenge files:
SITE=/etc/nginx/sites-available/bot.example.org
sudo mkdir -p /var/www/letsencrypt
sed 's/bot\.polarsystems\.net/bot.example.org/g' deploy/nginx-bot.polarsystems.net.bootstrap.conf | sudo tee "$SITE" >/dev/null
sudo ln -sf "$SITE" /etc/nginx/sites-enabled/bot.example.org
sudo nginx -t && sudo systemctl reload nginx
sudo certbot certonly --webroot -w /var/www/letsencrypt -d bot.example.org --deploy-hook "systemctl reload nginx"
sed … | sudo teecopies the site from the repository with your host name in it.nginx -tprintssyntax is okandtest is successful.- certbot asks for an e-mail address and the terms on its first run, then prints
Successfully received certificate.and the paths under/etc/letsencrypt/live/bot.example.org/. Its timer renews the certificate, and the deploy hook reloads nginx after each renewal.
4. The full site
sed 's/bot\.polarsystems\.net/bot.example.org/g' deploy/nginx-bot.polarsystems.net.conf | sudo tee "$SITE" >/dev/null
sudo nginx -t && sudo systemctl reload nginx
The site redirects HTTP to HTTPS, serves TLS 1.2/1.3 with HSTS and strict headers, forwards
only GET /oauth/callback and GET /healthz to 127.0.0.1:8787, answers everything else with
404 and keeps no access log (the callback URL carries a one-time code).
nginx older than 1.25.1 (Debian 12, Ubuntu 24.04) does not know
http2 on;, andnginx -treportsunknown directive "http2". Delete that line and write the twolistenlines aslisten 443 ssl http2;andlisten [::]:443 ssl http2;instead.
If your DNS is proxied (Cloudflare), you can turn the proxy on now; use SSL mode Full (strict).
5. The Developer Portal
In https://discord.com/developers/applications, your application, OAuth2:
- under Redirects, add
https://bot.example.org/oauth/callback. It must equal<public_url>/oauth/callbackexactly, with thepublic_urlfrom your config file (Install, step 6); - choose Reset Secret and copy the client secret.
6. The two credentials
sudo -v
read -rsp "Client secret: " SECRET; echo
printf '%s' "$SECRET" | sudo systemd-creds encrypt --name=discord_client_secret - /etc/polarbot/discord_client_secret.cred
unset SECRET
od -An -N32 -tx1 /dev/urandom | tr -d ' \n' | sudo systemd-creds encrypt --name=oauth_key - /etc/polarbot/oauth_key.cred
sudo chmod 0600 /etc/polarbot/discord_client_secret.cred /etc/polarbot/oauth_key.cred
- The client secret is read with hidden input, like the token.
oauth_keyis 32 random bytes written as 64 hex characters, generated here and never shown. It signs the/perms authorizelinks and encrypts the stored refresh tokens. Generating a new one later makes every server run/perms authorizeagain.
If you removed the two LoadCredentialEncrypted= lines from the unit earlier, install the unit
again: sudo install -m 0644 deploy/polarbot.service /etc/systemd/system/polarbot.service and
sudo systemctl daemon-reload.
7. Restart and check
sudo systemctl restart polarbot
sudo journalctl -u polarbot -n 20 --no-pager | grep -i "web server"
curl -sS https://bot.example.org/healthz; echo
- The log shows
web server listening addr=127.0.0.1:8787. curlprintsok. (From the server itself,curl -sS http://127.0.0.1:8787/healthzchecks the bot without nginx.)
Then, in your Discord server, an Owner runs /perms authorize, opens the private link,
approves, and sees “PolarBot is authorized”. Within seconds
each role sees only the commands its level allows. /perms list shows the roles and levels.
Configure PolarBot
PolarBot is configured in two places:
- on the server, by the operator: the config file and the credentials. They apply to the whole bot. Changes need a restart.
- in Discord, per server, by its staff: slash commands. Changes apply at once and are stored in the database.
On the server
The config file
sudoedit /etc/polarbot/config.toml
sudo systemctl restart polarbot
sudo journalctl -u polarbot -n 20 --no-pager
The file is TOML; every key is optional and documented in Configuration file. Common changes:
| You want to | Set |
|---|---|
| Use your own host name for the OAuth callback | [web] public_url = "https://bot.example.org" |
| More detail in the journal while debugging | [log] level = "polarbot=debug,info" (and back to "info" afterwards) |
| Logs for a log collector | [log] json = true |
| Your own or extra threat lists | [[scanner.threatlists]] entries (they replace the defaults, so list the defaults too if you want to keep them) |
| No adult-site category | [scanner] adultlists = [] |
| Test commands in one server instantly | [discord] dev_guild_id = <server ID> (development only) |
A typo or an unknown key stops the bot at startup with a message such as
polarbot: invalid config: line 3: unknown field …; systemctl status polarbot shows it.
Credentials
To replace a credential (for example after resetting the bot token in the Developer Portal), encrypt the new value over the old file and restart:
sudo -v
read -rsp "Bot token: " TOKEN; echo
printf '%s' "$TOKEN" | sudo systemd-creds encrypt --name=discord_token - /etc/polarbot/discord_token.cred
unset TOKEN
sudo systemctl restart polarbot
The same works for discord_client_secret. sudo ./deploy/install.sh --token and
sudo ./deploy/install.sh --secrets do the same from the repository (--secrets also
generates a new oauth_key, which makes every server run /perms authorize again).
In Discord
Every command, option and required level is listed in Commands;
/help command:<name> explains one inside Discord.
First steps in a new server
| Step | Command | Who |
|---|---|---|
| Choose the log channel for cards and logs | /setup log_channel:#mod-log | Admin |
| Give staff roles their levels | /perms set role:@Mods level:Moderator, /perms set role:@Admins level:Admin | Owner |
| Let each role see only its commands | /perms authorize (needs OAuth) | Owner |
| Ping a role on scanner punishments | /scanner mod-role role:@Mods | Admin |
| Check everything | /config view, /scanner status, /perms list | Admin |
Logging
/config log-level level:<0–10> chooses how much is posted in the log channel; each level adds
to the ones below it. The default is 2 (scanner punishments and moderation through
PolarBot). See Log levels for exactly what each level adds. Level 1
or higher is needed for scanner cards.
/config announcements-channel chooses where “PolarBot updated to vX.Y.Z” notices go. Without
one, PolarBot uses a channel named like staff-announcements, staff-news,
mod-announcements, staff-updates or bot-updates that it can post in, and otherwise the
log channel.
The scanner
A cautious way to start in an existing community:
/scanner actions enabled:False: log-only mode. The scanner posts cards but deletes and punishes nothing.- Watch the cards for a few days. Press Scam or Not scam on each: this teaches the block list, lifts wrong punishments and collects anonymized training examples.
- Adjust:
/scanner thresholds log:60 delete:90 punish:97,/scanner punishment action:Timeout duration:1h,/scanner allow domain:example.comfor domains your community uses,/scanner channel//scanner exempt-rolefor places and roles to skip. /scanner actions enabled:Truewhen you trust it.
| Setting | Command | Default |
|---|---|---|
| Scanner on/off | /scanner enable, /scanner disable | on |
| Log-only mode | /scanner actions enabled: | actions on (servers that had PolarBot before the scanner: off) |
| Thresholds | /scanner thresholds log: delete: punish: (1–100, log ≤ delete ≤ punish) | 60 / 90 / 97 |
| Punishment at the punish threshold | /scanner punishment action: duration: | Timeout, 1 hour (1 minute to 28 days) |
| Hacked accounts (same link in 3+ channels in 2 min) | /scanner hacked action: | Delete the messages and time out |
| Discord invites | /scanner invites allow: | flagged |
| Which channels | /scanner channel-mode mode: and /scanner channel channel: rule: | all channels |
| Skipped roles | /scanner exempt-role role: exempt: | none (Moderators and above are always skipped) |
| Allow and block lists | /scanner allow, /scanner block, /scanner unlist | empty |
| Adult-site links | /scanner nsfw-links action: | Delete (servers that had PolarBot before 0.3.1: Log) |
/scanner analyze (and right-click a message → Apps → Analyze with PolarBot) shows what the
scanner would do with a text or message under the current settings, without doing anything.
The scanner explains the rules and the score.
Update PolarBot
An update is: get the new source, build it, back up the database, keep the old binary, install the new one, start it. The database backup and the old binary are what make a rollback possible: a new version may add database migrations, and an older binary refuses to start on a database a newer one has migrated.
Read the Changelog first: it says what changed and whether anything needs your attention.
1. Get and build the new version
In the clone you installed from (Install, step 4):
cd ~/PolarSystems
OLD="$(git rev-parse HEAD)"
git fetch origin
git checkout stable
git pull --ff-only
git log -1 --oneline
export POLARBOT_CHANNEL=stable
export POLARBOT_GIT_REV="$(git rev-parse --short HEAD)"
export POLARBOT_BUILD_DATE="$(date -u +%Y-%m-%d)"
cargo build --release --locked -p polarbot
OLD remembers the commit you run now (the clone is still at the commit you last installed).
git pull --ff-only refuses to merge if your clone has local changes, so you never build
something you did not mean to. The build ends with a Finished line.
To see whether this update changes the database:
git diff --stat "$OLD" HEAD -- crates/polarbot/migrations
Any output means new migrations: after the update, rolling back needs the database backup from the next step.
2. Back up, keep the old binary, install
sudo systemctl stop polarbot
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
sudo install -d -m 0700 /var/backups/polarbot
sudo cp -a /var/lib/private/polarbot/polarbot.db "/var/backups/polarbot/polarbot-$STAMP.db"
sudo cp -a /var/lib/private/polarbot/polarbot.db-wal "/var/backups/polarbot/polarbot-$STAMP.db-wal" 2>/dev/null || true
sudo cp -a /var/lib/private/polarbot/polarbot.db-shm "/var/backups/polarbot/polarbot-$STAMP.db-shm" 2>/dev/null || true
sudo cp -a /opt/polarbot/polarbot /opt/polarbot/polarbot.prev
sudo install -m 0755 target/release/polarbot /opt/polarbot/polarbot
- The bot is stopped first, so the database is not written while it is copied.
- The database lives in
/var/lib/private/polarbot(systemd’s private directory forDynamicUserservices;/var/lib/polarbotpoints there). The-waland-shmfiles are SQLite’s write-ahead log; they exist only sometimes and are copied along when they do. /opt/polarbot/polarbot.previs the binary you are replacing.
If the unit file changed in this release (git diff "$OLD" HEAD -- deploy/polarbot.service
prints something), install it too:
sudo install -m 0644 deploy/polarbot.service /etc/systemd/system/polarbot.service
sudo systemctl daemon-reload
(If you removed the OAuth credential lines from your unit, remove them again.)
3. Start and check
sudo systemctl start polarbot
sudo journalctl -u polarbot -n 30 --no-pager
The first line shows the new version and commit (starting polarbot version="…" rev="…"),
then connected and registered global commands. /about in Discord shows the same version.
About 30 seconds after the start, each server gets one “PolarBot updated to vX.Y.Z” notice
with that version’s changelog, in its staff announcements channel (once per version, never
again after a restart).
Old backups are not deleted automatically by these steps; install.sh (below) keeps the 10
newest. To do the same by hand:
ls -1t /var/backups/polarbot/polarbot-*.db | tail -n +11
lists the ones beyond the 10 newest; delete them (with their -wal/-shm files) with
sudo rm.
Shortcut: the install script
Steps 2 and 3 are exactly what deploy/install.sh does (and it keeps the 10 newest backups):
cp target/release/polarbot ./polarbot
sudo ./deploy/install.sh
It never touches an existing /etc/polarbot/config.toml and does not ask for credentials that
already exist. It always installs the unit from deploy/, so if you edited your unit, put your
changes in a drop-in instead (sudo systemctl edit polarbot).
The maintainers update from a workstation with deploy/deploy.sh <ssh-host>, which copies the
committed source to ~/deploy/polarbot/src on the server and builds it there; then
cd ~/deploy/polarbot/src && sudo ./deploy/install.sh on the server.
Back up and restore PolarBot
What to back up
| What | Path | Needed for |
|---|---|---|
| The database | /var/lib/private/polarbot/polarbot.db (and -wal, -shm when present) | Everything the servers configured: settings, /perms roles, scanner rules and lists, punishment history, authorizations |
| The config file | /etc/polarbot/config.toml | Your settings (easy to recreate) |
| The unit, if you changed it | /etc/systemd/system/polarbot.service and /etc/systemd/system/polarbot.service.d/ | Your changes |
Not needed:
- The list caches (
threatlists/,adultlists/in the data directory) are downloaded again within a minute of a start. - The
.credfiles are encrypted with this machine’s credential key (and its TPM, if it has one), so they cannot be decrypted on another machine. Keep the secrets themselves somewhere safe instead (a password manager): the bot token and client secret can always be reset in the Developer Portal, and a newoauth_keyonly means servers run/perms authorizeagain.
The database holds personal data (punishment history). Keep backups as private as the server:
install.sh uses a root-only directory (0700) and root-only files (0600). Backups also keep
punishment history beyond its 180 days for as long as you keep them; delete old backups.
Back up
With a short stop (simplest, consistent)
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
sudo install -d -m 0700 /var/backups/polarbot
sudo systemctl stop polarbot
sudo cp -a /var/lib/private/polarbot/polarbot.db "/var/backups/polarbot/polarbot-$STAMP.db"
sudo cp -a /var/lib/private/polarbot/polarbot.db-wal "/var/backups/polarbot/polarbot-$STAMP.db-wal" 2>/dev/null || true
sudo systemctl start polarbot
sudo cp -a /etc/polarbot/config.toml "/var/backups/polarbot/config-$STAMP.toml"
The bot is down for a second or two. Copy /var/backups/polarbot to another machine with your
usual backup tool.
While it runs
SQLite can write a consistent copy of a live database. This needs the sqlite3 program
(sudo apt install sqlite3 or sudo dnf install sqlite):
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
sudo install -d -m 0700 /var/backups/polarbot
sudo sqlite3 /var/lib/private/polarbot/polarbot.db "VACUUM INTO '/var/backups/polarbot/polarbot-$STAMP.db'"
sudo chmod 0600 "/var/backups/polarbot/polarbot-$STAMP.db"
VACUUM INTO writes a single, compact file that already contains everything from the
write-ahead log. It is safe to run from a daily timer or cron job.
Check a backup
sudo sqlite3 "/var/backups/polarbot/polarbot-$STAMP.db" "PRAGMA integrity_check;"
prints ok.
Restore
Restoring replaces the current database: everything since the backup (new settings, punishments, authorizations) is lost.
sudo systemctl stop polarbot
sudo cp /var/backups/polarbot/polarbot-<STAMP>.db /var/lib/private/polarbot/polarbot.db
sudo rm -f /var/lib/private/polarbot/polarbot.db-wal /var/lib/private/polarbot/polarbot.db-shm
sudo cp /var/backups/polarbot/polarbot-<STAMP>.db-wal /var/lib/private/polarbot/polarbot.db-wal 2>/dev/null || true
sudo sh -c 'chown --reference=/var/lib/private/polarbot /var/lib/private/polarbot/polarbot.db* && chmod 0600 /var/lib/private/polarbot/polarbot.db*'
sudo systemctl start polarbot
sudo journalctl -u polarbot -n 20 --no-pager
- Replace
<STAMP>with the backup’s time stamp (ls /var/backups/polarbot). - The old
-waland-shmfiles belong to the database you are replacing and must go; the backup’s own-wal(only backups made byinstall.shor the stop method may have one) goes with it. chown --referencegives the files to the service’s user, which owns the directory. It runs insh -cbecause only root can list/var/lib/private, so your own shell could not expand the*.
The binary must be the same version or newer than the one that wrote the backup. An older binary refuses to start on a database migrated by a newer one (see Roll back).
On a new machine
- Install PolarBot (steps 1–8; enter the secrets again, they do not move with the backup), and start it once so systemd creates the data directory.
- Restore the database as above, and your config file to
/etc/polarbot/config.toml. - The new machine has a new
oauth_key, so the stored authorizations cannot be decrypted: each server’s Owner runs/perms authorizeagain. Point the callback host name’s DNS at the new machine and set up nginx there.
Roll back PolarBot
Go back to the version you ran before an update, for example when the new version misbehaves.
Why the database matters
A release may add database migrations. The bot runs them at startup, and an older binary refuses to start on a database that a newer one has migrated (the log then says that a migration was applied that it does not know). So a rollback restores two things together:
- the previous binary,
/opt/polarbot/polarbot.prev(kept by every update), and - the database backup made just before that update,
/var/backups/polarbot/polarbot-<STAMP>.db.
Everything written to the database since that backup (new settings, punishments recorded,
authorizations) is lost. If the update added no migrations (the changelog and
git diff --stat <old commit> <new commit> -- crates/polarbot/migrations tell you), you can
skip the database steps and keep that data.
Steps
Find the backup taken by the update you are undoing (the newest one, usually):
sudo ls -1t /var/backups/polarbot/
Then, with its time stamp in place of <STAMP>:
sudo systemctl stop polarbot
sudo cp -a /opt/polarbot/polarbot.prev /opt/polarbot/polarbot
sudo cp -a /var/backups/polarbot/polarbot-<STAMP>.db /var/lib/private/polarbot/polarbot.db
sudo rm -f /var/lib/private/polarbot/polarbot.db-wal /var/lib/private/polarbot/polarbot.db-shm
sudo cp -a /var/backups/polarbot/polarbot-<STAMP>.db-wal /var/lib/private/polarbot/polarbot.db-wal 2>/dev/null || true
sudo systemctl start polarbot
sudo journalctl -u polarbot -n 30 --no-pager
- The first
cpputs the old binary back;polarbot.prevstays, so you can roll forward again by reinstalling the new build. - The database copy, the removal of the newer write-ahead files and the copy of the backup’s own write-ahead file (if it has one) restore the database exactly as it was before the update.
- The log shows the old version in its first line;
/aboutshows it in Discord.
A rollback posts no update notice. If you restored the database, it no longer remembers that the newer version was announced, so updating to it again posts its notice once more.
Rolling back further
polarbot.prev is only the version before the last update. For anything older, check out that
version in your clone, build it (Update, step 1
with git checkout <commit or tag> instead of stable), install the binary, and restore a
database backup from that time.
If you also changed the unit
If the update installed a new deploy/polarbot.service, install the old one from your clone
(git show <old commit>:deploy/polarbot.service | sudo tee /etc/systemd/system/polarbot.service)
and run sudo systemctl daemon-reload before starting.
Uninstall PolarBot
Remove it from one Discord server only
Kick PolarBot from the server (or let the server’s owner do it). When the bot sees that it was removed, it deletes every row of that server from its database at once: settings, roles, scanner rules and lists, punishment history, the authorization. Server outages do not count as a removal.
Remove it from the machine
1. Stop it and remove the service
sudo systemctl disable --now polarbot
sudo rm /etc/systemd/system/polarbot.service
sudo rm -rf /etc/systemd/system/polarbot.service.d
sudo systemctl daemon-reload
sudo systemctl reset-failed polarbot 2>/dev/null || true
After this systemctl status polarbot says Unit polarbot.service could not be found.
2. Remove the program and its configuration
sudo rm -rf /opt/polarbot
sudo rm -rf /etc/polarbot
This removes the binary, the previous binary, the config file and the encrypted credentials.
3. Remove the data (optional)
Keep this step for last and only do it if you are sure: it deletes the database, including every server’s settings and punishment history, and cannot be undone. Make a backup first if you might want it back.
sudo rm -rf /var/lib/private/polarbot
sudo rm -f /var/lib/polarbot
sudo rm -rf /var/backups/polarbot
/var/lib/polarbot is only a link to the private directory; /var/backups/polarbot holds the
backups made by updates.
4. Remove the OAuth callback site (if you set it up)
Debian/Ubuntu:
sudo rm /etc/nginx/sites-enabled/bot.example.org /etc/nginx/sites-available/bot.example.org
Fedora:
sudo rm /etc/nginx/conf.d/bot.example.org.conf
sudo setsebool -P httpd_can_network_connect 0
Then, on both:
sudo nginx -t && sudo systemctl reload nginx
sudo certbot delete --cert-name bot.example.org
Only turn the SELinux setting off again if nothing else on the machine needs nginx to connect to other services. Remove the DNS record for the host name too.
5. The build tools and the source (optional)
rm -rf ~/PolarSystems
rustup self uninstall
rustup self uninstall removes Rust and everything in ~/.cargo and ~/.rustup. The
packages from Install, step 3 can stay or be removed
with your package manager.
6. Discord
In the Developer Portal you can delete the application, which also removes the bot from every server it is in and invalidates its token and client secret. Do this after step 1, or the running bot would stop with an error first.
Troubleshooting PolarBot
Start with these two commands; almost every problem shows up in them:
systemctl status polarbot --no-pager
sudo journalctl -u polarbot -n 50 --no-pager
For more detail, set [log] level = "polarbot=debug,info" in /etc/polarbot/config.toml,
restart, reproduce the problem, and set it back to "info".
The service does not run
status=78/CONFIG and it is not restarted
Exit status 78 means Discord refused the connection for a reason a restart cannot fix, so
systemd stops restarting it (RestartPreventExitStatus=78). The log’s last error says which:
Discord refused the gateway intents (close code 4014): the privileged intents are not enabled. In the Developer Portal, your application, Bot → Privileged Gateway Intents: enable Presence Intent, Server Members Intent and Message Content Intent, and save.fatal close code 4004: the token is wrong or was reset. Store the current one again (Configure → Credentials).- Another fatal close code: see Discord’s list of gateway close codes.
Then:
sudo systemctl reset-failed polarbot
sudo systemctl start polarbot
status=1/FAILURE
The bot started and stopped with an error; the log’s last lines say why:
| Log says | Fix |
|---|---|
invalid config: line N: … | A typo or unknown key in /etc/polarbot/config.toml; see Configuration file. |
secret-looking key … found in config | Remove the secret from the config file; secrets are credentials. |
secret … discord_token … is missing | The credential is not loaded: check that /etc/polarbot/discord_token.cred exists and the unit has its LoadCredentialEncrypted= line. |
An HTTP 401 (Unauthorized) error right after starting polarbot | The token is wrong or was reset; store it again. |
migration … was previously applied but is missing (or similar) | The binary is older than the database; see Roll back. |
discord.dev_guild_id must not be 0 | Remove or fix dev_guild_id. |
After 5 failed starts within 10 minutes systemd gives up (start-limit-hit); after fixing the
cause run sudo systemctl reset-failed polarbot and start it.
status=243/CREDENTIALS
systemd could not decrypt a credential before starting the bot: a .cred file named in the
unit is missing, or it was made on another machine (credentials are tied to the machine’s
key). Create it again (Install, step 7 and
OAuth, step 6), or remove the
LoadCredentialEncrypted= lines for the OAuth credentials if you do not use command
visibility.
The bot is online but …
… the slash commands do not appear
- Global commands can take a few minutes to show up the first time. Restart Discord (Ctrl+R).
- The log must contain
registered global commands. If it sayscommand registration failed, the reason follows. - The bot was invited without the
applications.commandsscope: invite it again with the link from Install, step 1. - Staff commands are only shown to Administrators until an Owner runs
/perms authorize. - With
dev_guild_idset, commands exist only in that one server.
… a command says I may not use it
PolarBot checks its own levels, not Discord’s. /perms list shows which roles have which level; members with Administrator and the server owner are
always Owner. Commands lists the level of every command.
… moderation fails with “missing permissions” or a hierarchy message
- The bot needs the permissions from the invite link (Kick, Ban, Timeout Members, Manage Messages, …). Check the bot’s role in Server Settings → Roles, and channel overrides that deny it.
- Discord never lets a bot act on the server owner, or on members whose highest role is at or above the bot’s highest role: move PolarBot’s role higher.
- You can only act on members below you, in Discord’s role order and in PolarBot’s levels.
… nothing appears in the log channel
/config viewshows the log channel and level. Level 0 posts nothing; scanner cards need level 1 or higher.- The bot needs View Channel, Send Messages and Embed Links in the log channel (
/setupchecks this when you choose it; channel overrides changed later can break it). - If the log channel was deleted, PolarBot cleared the setting: run
/setupagain.
… the scanner does not delete or punish
/scanner statussays whether log-only mode is on (/scanner actions enabled:Trueturns actions on).- Moderators and above, exempt roles and exempt or unmonitored channels are never scanned.
/scanner analyzeshows the score and what the scanner would do with a given text or message.- A card that says the action failed shows why (usually permissions or role order, above).
… the threat lists stay empty
The log says list download failed; keeping the previous list with the reason. The server needs
outbound HTTPS to the list URLs (by default raw.githubusercontent.com). Until the first
download succeeds, threat-list matches cannot happen (all other rules work).
Command visibility (OAuth)
| Symptom | Cause and fix |
|---|---|
/perms authorize says visibility sync is not set up | The OAuth credentials are not loaded. Set them up (OAuth and nginx); the log says OAuth credentials not configured or OAuth credentials invalid (oauth_key must be 64 hex characters). |
| Discord shows “Invalid OAuth2 redirect_uri” | The redirect in the Developer Portal must be exactly <public_url>/oauth/callback, with the public_url from the config file (no trailing slash, https). |
| The page says the link is invalid or expired | Links work once and for 10 minutes. Run /perms authorize again. |
502 Bad Gateway from nginx | The bot’s web server is not running: check the log for web server listening (it only starts with both OAuth credentials) and curl http://127.0.0.1:8787/healthz on the server. On Fedora, also sudo setsebool -P httpd_can_network_connect 1. |
| The certificate request fails | DNS must point at the server, port 80 must be reachable, and a proxying DNS provider must be set to DNS only for the first request. |
| Roles still see the wrong commands | The sync runs a few seconds after each /perms change; the log says command visibility synced or why it failed. If the authorizing admin lost their rights or revoked the app, /perms asks for /perms authorize again. |
Still stuck?
Open an issue at https://github.com/sergeantpolarbear/PolarSystems/issues with the output of the two commands at the top of this page (check it for anything private first; it never contains tokens), or write to contact@polarsystems.net.
PolarManager
🚧 Coming. PolarManager is being built. Its pages (How it works, Reference, Install, Configure, Update, Back up and restore, Roll back, Uninstall, Troubleshooting) will appear here as it ships, generated and checked against the code like PolarBot’s.
PolarManager is the hub on the game server machine. It will:
- run the game servers as their own systemd units, start, stop and restart them, read their logs and restart crashed ones with a rate limit;
- relay chat between each game server and a Discord channel through PolarBot, with every relayed message checked by the same scanner;
- link a member’s Discord, Minecraft and Steam identities, so a ban, kick, warning or mute given anywhere is enforced everywhere (with the same 180-day history retention as PolarBot);
- talk to PolarBot and PolarCraft over a local WebSocket, and to PolarScript over local HTTP.
Like PolarBot it will be one Rust binary under a hardened systemd unit, with SQLite, and Linux first.
PolarCraft
🚧 Coming. PolarCraft’s pages (How it works, Reference, Install, Configure, Update, Back up and restore, Roll back, Uninstall, Troubleshooting) will appear here as it ships.
PolarCraft is a plugin for Minecraft Java servers running Paper. It connects the server to
PolarManager: account linking (/link in Minecraft, confirmed in Discord), chat relay to and
from Discord, moderation that follows a person across Discord and the game, and ranks.
PolarScript
🚧 Coming. PolarScript’s pages (How it works, Reference, Install, Configure, Update, Back up and restore, Roll back, Uninstall, Troubleshooting) will appear here as it is connected to PolarManager.
PolarScript is a Stormworks dedicated-server addon (Lua): vehicle limits, anti-lag, an economy
and moderation. It already exists in the repository (polarscript/, AGPL-3.0-or-later; see
its README there). It is being connected to PolarManager for chat relay, account linking
(?link in game) and shared moderation.
Game servers
🚧 Coming. Manual guides for installing and maintaining the game servers themselves will appear here, and PolarInstall will be able to set them up for you.
Planned:
- Minecraft Java (Paper) with Java, as a systemd unit managed by PolarManager. Installing it means accepting the Minecraft EULA, which only you can do.
- Stormworks dedicated server on Linux with Wine and SteamCMD, as a systemd unit managed by PolarManager.
Third-party downloads (Paper, Java, SteamCMD, the Stormworks server) will be pinned by version and SHA-256, never “latest”.
PolarInstall
🚧 Coming. PolarInstall is the installer for the plan, guided, express and unattended levels (see the Introduction). Until it ships, use the manual guides, starting with Install PolarBot.
PolarInstall will be one static binary with a terminal interface:
polarinstall planprints the exact commands it would run (the same as the manual pages) and changes nothing;polarinstallasks every choice with a default;--expressasks only for the Discord bot token (and the Minecraft EULA, when a Minecraft server is installed);--config answers.toml --yesruns unattended.install,update,status,rollback,doctoranduninstall(which keeps your data unless you ask otherwise).- It only runs what it has verified: it downloads a signed release manifest, checks the signature and every file’s SHA-256 (Verify a release signature by hand) and never pipes a remote script into a shell.
- Every step has a check, an action and an undo; a failed step undoes the steps of that run.
Build and host the docs
These docs are Markdown in docs/site/src/, built into a static site with
mdBook (a Rust build tool, MPL-2.0; it is not a
dependency of any PolarSystems program). Anyone can build and host a copy, for example on an
internal network.
What is generated
The PolarBot Reference pages are generated from the source code
by a test in crates/polarbot and checked in. The test fails when a page and the code
differ, so after changing commands, config keys, migrations, credentials, the unit file or the
log levels, regenerate them and commit the result:
POLAR_UPDATE_DOCS=1 cargo test -p polarbot docs_reference
Everything else is written by hand. The Changelog is the repository’s
CHANGELOG.md, included when the site is built.
Build
cargo install mdbook --locked
deploy/build-docs.sh
cargo install puts mdbook into ~/.cargo/bin. The script (it also needs python3):
- runs the reference test, so a stale reference is never published;
- builds the site into
docs/site/book/(not committed) and fails on any mdBook warning; - moves mdBook’s inline scripts into files under
inline-js/, so the site works under a strict Content-Security-Policy without'unsafe-inline'; - fails if any page would load something from another host, or carries an inline style the CSP does not allow.
The site loads nothing from other hosts: no web fonts, no CDN, no analytics. MathJax and the
Rust playground of mdBook are switched off in docs/site/book.toml. Search runs in the
browser from a local index.
To preview while writing: mdbook serve docs/site and open http://localhost:3000.
Host it with nginx
The steps mirror the OAuth callback site, with
deploy/nginx-docs.polarsystems.net.bootstrap.conf and deploy/nginx-docs.polarsystems.net.conf.
Replace docs.example.org with your host name (Fedora: use
/etc/nginx/conf.d/docs.example.org.conf as SITE and skip the ln lines).
SITE=/etc/nginx/sites-available/docs.example.org
sudo mkdir -p /var/www/letsencrypt /var/www/docs.example.org
sed 's/docs\.polarsystems\.net/docs.example.org/g' deploy/nginx-docs.polarsystems.net.bootstrap.conf | sudo tee "$SITE" >/dev/null
sudo ln -sf "$SITE" /etc/nginx/sites-enabled/docs.example.org
sudo nginx -t && sudo systemctl reload nginx
sudo certbot certonly --webroot -w /var/www/letsencrypt -d docs.example.org --deploy-hook "systemctl reload nginx"
sed 's/docs\.polarsystems\.net/docs.example.org/g' deploy/nginx-docs.polarsystems.net.conf | sudo tee "$SITE" >/dev/null
sudo nginx -t && sudo systemctl reload nginx
Then publish a build (again after every change):
sudo rsync -a --delete docs/site/book/ /var/www/docs.example.org/
or from a workstation, rsync -a --delete docs/site/book/ <server>:/var/www/docs.example.org/
with a user that may write there.
The site:
- serves only
GETandHEAD, never hidden files, and keeps no access log; - sends HSTS,
X-Content-Type-Options,X-Frame-Options,Referrer-Policy: no-referrerand a Content-Security-Policy that allows scripts, styles, fonts and images from the site itself only (plus the three inline styles mdBook uses, by hash); - lets browsers cache scripts, styles and fonts for a week (their file names change with their content) and revalidate pages on every visit.
If you change mdBook’s theme or version and build-docs.sh reports an inline style that is not
allowed, add its hash to the CSP (printf '%s' '<style value>' | openssl dgst -sha256 -binary | base64) in the nginx site and in the script’s ALLOWED_STYLES.