Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.

LevelHowWho it is forPages you useAvailable
Ultra-manualYou read what everything is and does and build the installation yourself. No steps.Experienced admins who want the building blocksHow it works and Reference of each component✅ PolarBot
ManualEvery step as exact commands with an explanation and the expected output. No installer.People who want to know and control everythingInstall (manual), Configure, Update, Back up and restore, Roll back, Uninstall, Troubleshooting✅ PolarBot
Planpolarinstall 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 automationPolarInstall🚧 coming
Guidedpolarinstall asks every choice, shows a sensible default and explains it.Most ownersPolarInstall🚧 coming
Expresspolarinstall --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
Unattendedpolarinstall --config answers.toml --yesScripts and reinstallsPolarInstall🚧 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 info summarises this page.

What is processed in memory

WhatWhyHow long
Message text, attachment names, mentions, the author’s account age and join dateScanningWhile 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 detectionEach 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 channelUntil pushed out or the bot restarts
Members, roles and channels of the servers the bot is inPermission and hierarchy checksUntil 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.

DataPersonal?Kept
Punishment history (mod_actions, warnings)Yes: user and moderator IDs, reason, time180 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 onlyUntil 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, encryptedUntil replaced, revoked, or the bot leaves the server
Scanner counters (scan_stats)No: two numbers per server and day30 days
Server settings, /perms roles, scanner rules, allow/block listsNoUntil 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

ToWhy
discord.com, gateway.discord.ggThe 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/tokenOnly when a server uses /perms authorize

For operators

  • Point your members to /privacy info and /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, password or api_key is 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.net and 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)

  1. Install minisign (Debian/Ubuntu: sudo apt install minisign; Fedora: sudo dnf install minisign).
  2. Download manifest.json, manifest.json.minisig and the files you want.
  3. Check the signature against the published public key: minisign -Vm manifest.json -P <public key>. Only continue on Signature and comment signature verified.
  4. Check each file’s hash against the manifest: sha256sum <file> must print exactly the sha256 the 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 info now also lists the one other piece of personal data PolarBot keeps: the user ID of the admin who ran /perms authorize for 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 analyze show “Adult site link” as its own reason, and /scanner status shows the setting.
  • /scanner analyze explains 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-channel chooses 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 view shows where the notices go.
  • Support and data-protection contact contact@polarsystems.net in /about, /help and /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.
  • /help texts updated for /scanner nsfw-links and /config announcements-channel.

[0.3.0] - 2026-10-21

Added

  • /help lists the commands you can run here (by your /perms level; 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: /about and 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 pasted text, a message link from this server (only channels you can read), and up to three attachments. 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 status is 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 /scanner in 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, @everyone attempts, 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.
  • Modlog cards with Scam / Not scam / Undo buttons; Scam blocks only the risky domains, Not scam lifts the punishment.
  • /scanner to 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 info and /privacy my-data; /about and /privacy work in DMs.
  • polarbot export-training for 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:8787 behind nginx.
  • install.sh backs 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, /about and /privacy also 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 systemLinux with systemd 250 or newer (for encrypted credentials): Debian 12+, Ubuntu 24.04+, Fedora 38+. Windows is not supported yet.
Hardware1 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.
NetworkOutbound HTTPS to Discord and to the domain lists. Inbound only if you use command visibility: HTTPS (443) and, for the certificate, HTTP (80).
DiscordAn application with a bot user, the three privileged intents enabled, and the bot invited with the right permissions (Install).
OptionalA domain name and nginx for the OAuth callback (command visibility).

Where to go next

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:

  1. reads the config file (unknown or secret-looking keys stop it here, exit status 1);
  2. reads the discord_token credential and opens the SQLite database, running any new migrations;
  3. asks Discord who it is (application and bot user), which also proves the token works;
  4. loads the cached threat lists and adult-site lists from the data directory (an empty list if there is no cache yet);
  5. loads the two OAuth credentials if both are present (otherwise command visibility and the web server stay off);
  6. 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:

PoolSizeGets
Interactions64Slash commands, message commands, card buttons, autocomplete
Handlers256New and edited messages (the scanner), server/channel/role deletes
Log handlers64Every 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, /about and /privacy run.
  • 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_DELETE that 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 /perms level, 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:

LevelWhoCan use
UserEveryone/help, /about, /privacy
ModeratorRoles given Moderator with /perms setModeration commands, /lookup, /scanner analyze, Analyze with PolarBot, card buttons. The scanner skips Moderators and above.
AdminRoles given AdminAlso /setup, /config, the /scanner settings, /perms list
OwnerThe server owner and every member with Discord’s Administrator permissionAlso /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: answers ok.

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

JobEveryDoes
List updater (one for threat lists, one for adult lists)scanner.refresh_hours (24 h) per list; a failed download is retried after 1 hDownloads 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 sweep1 minDrops expired behaviour-tracker and cache entries
Purge1 h (the first right at startup)Deletes punishment history older than 180 days and scanner counters older than 30 days
Card and log queuesPer server, while there is workPost cards and log batches (below)

Data flows

DataComes fromGoes toStored?
Message textGateway MESSAGE_CREATE / MESSAGE_UPDATEScanner (memory); the card, if flaggedNo
Flag decisionScannerDiscord (delete, timeout, kick, ban, with an audit-log reason); the card; countersPunishments in mod_actions (180 days); daily counters
Moderator verdict (card button)Discord interactionThe card is updated; risky domains go on the block list; an anonymized training examplescan_domains, training_examples
Moderation commandDiscord interactionDiscord API; log channelmod_actions / warnings (180 days)
Logged eventsGatewayLog channel, batchedNo
OAuth grantDiscord, through nginx, to the web serverEncrypted refresh tokenguild_oauth
Domain listsThe configured https URLsCache files and in-memory indexCache 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

WhatHow fresh
A server’s scanner settings and listsUpdated at once by /scanner; otherwise at most 60 s old
A server’s log level and log channelUpdated 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 listsEvery 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.

GroupSignalWeightNotes
LinksOn 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.5Edit 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.0xn--…
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.
ContentScam phrase+1.0 to +3.0 eachAlso 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.0Often the last step of such a funnel.
Discord invite+1.5Not with /scanner invites allow:True.
Mass mention+2.05 or more user and role mentions.
@everyone / @here attempt+1.5
New account (under 7 days)+1.0Only together with a link or a scam phrase.
Joined under an hour ago+1.0Only together with a link or a scam phrase.
Recent punishments+0.5 each, at most +1.5Bans, kicks, timeouts and warnings in this server in the last 180 days.
BehaviourHacked-account burst+5.0The same content with a risky link in 3 or more channels within 120 s. Sets a floor of 97.
Flood+1.58 or more messages within 10 s.
AdultLink to an adult site0A 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 nothingDelete 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 setDelete and punish: timeout (default 1 hour), kick or ban
Score at or above delete (default 90)Delete
OtherwiseCard 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”.

ButtonDoes
ScamConfirms 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 scamSaves 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).
UndoLifts 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

AssetWhy it mattersWhere it lives
The bot tokenWhoever has it controls the bot in every server it is indiscord_token credential
The OAuth client secret and oauth_keyTogether they could forge /perms authorize links or read stored refresh tokensdiscord_client_secret, oauth_key credentials
Stored refresh tokensLet the holder change command permissions in a serverguild_oauth, encrypted
Punishment historyPersonal dataThe database
The bot’s Discord permissionsBan, kick, timeout, delete messagesDiscord

Credentials

  • Secrets are never in the config file: a key named token, secret, password or api_key anywhere 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/*.cred with mode 0600, and decrypted by systemd only into the service’s private, in-memory $CREDENTIALS_DIRECTORY while it runs. Copying a .cred file to another machine does not reveal it.
  • install.sh reads them with hidden input and pipes them straight into systemd-creds; they are never written to disk in plain text. oauth_key is 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 SecretString and 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).

  1. PolarBot replies (privately) with a Discord authorization link. Its state parameter 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.
  2. Discord redirects the browser to <public_url>/oauth/callback?code=…&state=….
  3. 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).
  4. 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.
  5. 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:8787 only (it warns at startup if [web] bind is not a loopback address) and serves only GET /oauth/callback and GET /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) and Cache-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, GET only; 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:

SettingEffect
DynamicUser=yesA throw-away system user that exists only while the service runs; owns nothing else on the machine
StateDirectory=polarbot, StateDirectoryMode=0700, UMask=0077The only writable place is the data directory, readable only by the service
ProtectSystem=strict, ProtectHome=yes, PrivateTmp=yes, PrivateDevices=yesThe 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=pidOther users and processes are invisible
ProtectKernelTunables, ProtectKernelModules, ProtectKernelLogs, ProtectControlGroups, ProtectClock, ProtectHostnameNo changes to the kernel, clock or host name
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX, RestrictNamespaces, RestrictRealtime, RestrictSUIDSGID, LockPersonality, MemoryDenyWriteExecute, RemoveIPCOnly IP and local sockets; no namespaces, realtime scheduling, set-uid files or writable+executable memory
SystemCallArchitectures=native, SystemCallFilter=@system-service, SystemCallFilter=~@privileged @resourcesOnly the system calls an ordinary service needs
MemoryMax=1G, TasksMax=512, LimitNOFILE=8192A runaway bot cannot take the machine down
RestartPreventExitStatus=78, StartLimitBurst=5 in 600 sNo 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
PageWhat is in it
CommandsEvery slash command and message command: subcommands, options (type, required, choices, limits), required level, DM availability
Configuration fileEvery config key with its type, default and meaning
CredentialsThe systemd credentials, their files and environment variables
Files and directoriesEvery path an installation uses
DatabaseEvery table and column, with the personal data it holds and how long it is kept; the migrations
Log levelsWhat each log level 0–10 posts
Command line and exit codespolarbot 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

CommandRequired levelIn DMsWhat it does
/aboutUseryesShow the bot’s version, license and source code
/banModeratorno (servers only)Ban a user from the server
/configAdminno (servers only)View or change PolarBot’s settings
/helpUseryesShow the commands you can use, or explain one command or topic
/kickModeratorno (servers only)Kick a member from the server
/lookupModeratorno (servers only)Show a user’s punishment history, account age and current scanner activity
/modlogModeratorno (servers only)Show the latest moderation actions
/permsAdmin to Ownerno (servers only)Manage which roles have which permission level
/privacyUseryesWhat PolarBot processes and stores about you
/scannerModerator to Adminno (servers only)Configure the scam and spam scanner
/setupAdminno (servers only)Set up PolarBot: choose the modlog channel
/timeoutModeratorno (servers only)Time out a member so they cannot chat or talk
/unbanModeratorno (servers only)Lift a user’s ban
/untimeoutModeratorno (servers only)Remove a member’s timeout
/warnModeratorno (servers only)Warn a member
/warningsModeratorno (servers only)List a user’s warnings
Analyze with PolarBotModeratorno (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
OptionTypeRequiredLimits and choicesDescription
useruseryes–The user: pick a member or paste a user ID.
reasontextnoat most 500 charactersWhy, at most 500 characters. Shown in the audit log and the log channel.
delete_messagestextnoone 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.

OptionTypeRequiredLimits and choicesDescription
channelchannelnochannel types: GuildText, GuildAnnouncementThe 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.

OptionTypeRequiredLimits and choicesDescription
levelintegeryes0 to 10A 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.

OptionTypeRequiredLimits and choicesDescription
channelchannelnochannel types: GuildText, GuildAnnouncementThe 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
OptionTypeRequiredLimits and choicesDescription
commandtextnoat most 32 characters; autocompletesThe command to explain. Start typing and pick it from the list.
topictextnoone 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
OptionTypeRequiredLimits and choicesDescription
useruseryes–The member to kick.
reasontextnoat most 500 charactersWhy, 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
OptionTypeRequiredLimits and choicesDescription
useruseryes–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
OptionTypeRequiredLimits and choicesDescription
useruserno–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.

OptionTypeRequiredLimits and choicesDescription
roleroleyes–The role.
leveltextyesone 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.

OptionTypeRequiredLimits and choicesDescription
roleroleyes–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.

OptionTypeRequiredLimits and choicesDescription
enabledtrue/falseyes–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.

OptionTypeRequiredLimits and choicesDescription
logintegerno1 to 100Post a card for messages at or above this score.
deleteintegerno1 to 100Also delete messages at or above this score.
punishintegerno1 to 100Also 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.

OptionTypeRequiredLimits and choicesDescription
actiontextyesone of: No punishment (none), Timeout (timeout), Kick (kick), Ban (ban)No punishment, Timeout, Kick or Ban.
durationtextnoat most 32 charactersTimeout 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).

OptionTypeRequiredLimits and choicesDescription
actiontextyesone 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.

OptionTypeRequiredLimits and choicesDescription
roleroleno–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).

OptionTypeRequiredLimits and choicesDescription
allowtrue/falseyes–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.

OptionTypeRequiredLimits and choicesDescription
channelchannelyeschannel types: GuildText, GuildAnnouncement, GuildVoice, GuildStageVoice, GuildForumThe channel.
ruletextyesone 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.

OptionTypeRequiredLimits and choicesDescription
modetextyesone 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.

OptionTypeRequiredLimits and choicesDescription
roleroleyes–The role.
exempttrue/falseyes–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.

OptionTypeRequiredLimits and choicesDescription
domaintextyesat most 300 charactersThe 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.

OptionTypeRequiredLimits and choicesDescription
domaintextyesat most 300 charactersThe domain, e.g. example.com.

/scanner unlist

Required level: Admin

Removes a domain from the allow or block list.

OptionTypeRequiredLimits and choicesDescription
domaintextyesat most 300 charactersThe domain, e.g. example.com.

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.

OptionTypeRequiredLimits and choicesDescription
actiontextyesone 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).

OptionTypeRequiredLimits and choicesDescription
texttextnoat most 2000 charactersText to analyze, e.g. a message you were sent elsewhere.
messagetextnoat most 200 charactersA link to a message in this server (right-click → Copy Message Link). Only channels you can read.
attachmentattachmentno–A file to check. Today only its name and type are checked; image content analysis comes later.
attachment2attachmentno–A second file to check.
attachment3attachmentno–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
OptionTypeRequiredLimits and choicesDescription
log_channelchannelyeschannel types: GuildText, GuildAnnouncementA 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
OptionTypeRequiredLimits and choicesDescription
useruseryes–The member to time out.
durationtextyesat most 32 charactersHow long, e.g. 10m, 1h30m or 7d (units s, m, h, d, w), at most 28 days.
reasontextnoat most 500 charactersWhy, 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
OptionTypeRequiredLimits and choicesDescription
useruseryes–The banned user’s ID.
reasontextnoat most 500 charactersWhy, 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
OptionTypeRequiredLimits and choicesDescription
useruseryes–The member whose timeout to remove.
reasontextnoat most 500 charactersWhy, 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
OptionTypeRequiredLimits and choicesDescription
useruseryes–The member to warn.
reasontextyesat most 500 charactersWhy, 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
OptionTypeRequiredLimits and choicesDescription
useruseryes–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]

KeyTypeDefaultDescription
dev_guild_idinteger (server ID), optionalunsetRegister the slash commands in this one server only, where they appear at once (development). Unset: commands are registered globally. Must not be 0.

[database]

KeyTypeDefaultDescription
pathpath/var/lib/polarbot/polarbot.dbThe 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]

KeyTypeDefaultDescription
leveltext (tracing filter)infoWhat 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.)
jsontrue/falsefalseLog JSON lines (for log collectors) instead of human-readable text.

[scanner]

KeyTypeDefaultDescription
refresh_hoursinteger, 1 to 72024How 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_bytesinteger, 1 to 107374182467108864Largest accepted list download in bytes (default 64 MiB). A larger download is aborted and the cached list is kept.
threatlistsarray of { name, url } tablesdestroylist: https://raw.githubusercontent.com/phishdestroy/destroylist/main/list.txt
discord-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.
adultlistsarray of { name, url } tablesstevenblack-porn: https://raw.githubusercontent.com/StevenBlack/hosts/master/alternates/porn-only/hostsAdult-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]

KeyTypeDefaultDescription
bindaddress:port127.0.0.1:8787Where 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_urlhttps URL, no trailing slashhttps://bot.polarsystems.netPublic 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.

CredentialEncrypted fileEnvironment variableRequiredWhat it is
discord_token/etc/polarbot/discord_token.credPOLARBOT_DISCORD_TOKENyes: the bot does not start without itThe 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.credPOLARBOT_DISCORD_CLIENT_SECRETno: without it command visibility sync and the web server are offThe application’s OAuth2 client secret (Developer Portal → OAuth2), used to exchange /perms authorize codes and refresh tokens.
oauth_key/etc/polarbot/oauth_key.credPOLARBOT_OAUTH_KEYno: 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.

PathWhat it is
/opt/polarbot/polarbotThe installed binary (install.sh).
/opt/polarbot/polarbot.prevThe previous binary, kept by install.sh for a rollback.
/etc/polarbot/config.tomlThe config file (default --config). Installed from deploy/config.toml once; never overwritten by install.sh.
/etc/polarbot/discord_token.credThe discord_token credential, encrypted with systemd-creds (mode 0600). See Credentials.
/etc/polarbot/discord_client_secret.credThe discord_client_secret credential, encrypted with systemd-creds (mode 0600). See Credentials.
/etc/polarbot/oauth_key.credThe oauth_key credential, encrypted with systemd-creds (mode 0600). See Credentials.
/etc/systemd/system/polarbot.serviceThe 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.dbThe SQLite database, in WAL mode (so also polarbot.db-wal and polarbot.db-shm). See Database.
/var/lib/polarbot/threatlists/destroylist.txtCached threat list destroylist (one domain per line). Loaded at startup, replaced after every successful refresh.
/var/lib/polarbot/threatlists/discord-phishing-links.txtCached threat list discord-phishing-links (one domain per line). Loaded at startup, replaced after every successful refresh.
/var/lib/polarbot/adultlists/stevenblack-porn.txtCached adult-site list stevenblack-porn (one domain per line).
/var/backups/polarbot/polarbot-<UTC stamp>.dbDatabase 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

TableWhat it holdsPersonal dataKept for
guild_settingsPer-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_levelsThe /perms mapping: role → permission level.None (role IDs).Until the bot leaves the server (then deleted at once).
mod_actionsPunishment 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_settingsScanner settings: on/off, thresholds, actions, mod role, channel mode, NSFW-link action.None.Until the bot leaves the server (then deleted at once).
scan_channelsScanner channel rules (monitor or exempt).None.Until the bot leaves the server (then deleted at once).
scan_exempt_rolesRoles the scanner skips.None.Until the bot leaves the server (then deleted at once).
scan_domainsThe server’s allow and block lists of domains.None.Until the bot leaves the server (then deleted at once).
training_examplesAnonymized 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_oauthThe /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_metaProcess-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_migrationsWhich migrations have run (managed by sqlx). A binary refuses to start on a database migrated by a newer one.None.Kept.

Columns

guild_settings

ColumnTypeNullDefaultKey
guild_idINTEGERyes–primary
localeTEXTno'en'
log_channel_idINTEGERyes–
created_atINTEGERno–
log_levelINTEGERno2
announcements_channel_idINTEGERyes–

role_levels

ColumnTypeNullDefaultKey
guild_idINTEGERno–primary
role_idINTEGERno–primary
levelINTEGERno–

mod_actions

ColumnTypeNullDefaultKey
idINTEGERyes–primary
guild_idINTEGERno–
moderator_idINTEGERno–
target_idINTEGERno–
actionTEXTno–
reasonTEXTyes–
duration_secsINTEGERyes–
created_atINTEGERno–

warnings

ColumnTypeNullDefaultKey
idINTEGERyes–primary
guild_idINTEGERno–
user_idINTEGERno–
moderator_idINTEGERno–
reasonTEXTno–
created_atINTEGERno–

scan_settings

ColumnTypeNullDefaultKey
guild_idINTEGERyes–primary
enabledINTEGERno1
log_thresholdINTEGERno60
delete_thresholdINTEGERno90
punish_thresholdINTEGERno97
punish_actionTEXTno'timeout'
timeout_secsINTEGERno3600
hacked_actionTEXTno'delete_timeout'
mod_role_idINTEGERyes–
allow_invitesINTEGERno0
channel_modeTEXTno'all'
actions_enabledINTEGERno1
updated_atINTEGERno–
nsfw_actionTEXTno'delete'

scan_channels

ColumnTypeNullDefaultKey
guild_idINTEGERno–primary
channel_idINTEGERno–primary
ruleTEXTno–

scan_exempt_roles

ColumnTypeNullDefaultKey
guild_idINTEGERno–primary
role_idINTEGERno–primary

scan_domains

ColumnTypeNullDefaultKey
guild_idINTEGERno–primary
domainTEXTno–primary
listTEXTno–

training_examples

ColumnTypeNullDefaultKey
idINTEGERyes–primary
textTEXTno–
categoryTEXTno–
verdictTEXTno–
created_atINTEGERno–

guild_oauth

ColumnTypeNullDefaultKey
guild_idINTEGERyes–primary
encrypted_refresh_tokenBLOBno–
nonceBLOBno–
authorized_byINTEGERno–
scopeTEXTno–
updated_atINTEGERno–

bot_meta

ColumnTypeNullDefaultKey
keyTEXTyes–primary
valueTEXTno–

scan_stats

ColumnTypeNullDefaultKey
guild_idINTEGERno–primary
dayINTEGERno–primary
flaggedINTEGERno0
actedINTEGERno0

_sqlx_migrations

ColumnTypeNullDefaultKey
versionBIGINTyes–primary
descriptionTEXTno–
installed_onTIMESTAMPnoCURRENT_TIMESTAMP
successBOOLEANno–
checksumBLOBno–
execution_timeBIGINTno–

Migrations

Applied in order, once each, at startup (crates/polarbot/migrations).

FileChanges
0001_core.sqlcreates 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.sqlcreates 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.sqladds column guild_settings.log_level
0004_oauth.sqlcreates table guild_oauth
0005_bot_meta.sqlcreates table bot_meta
0006_scan_stats.sqlcreates table scan_stats
0007_nsfw_links.sqladds column scan_settings.nsfw_action, updates scan_settings
0008_announcements_channel.sqladds 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.

LevelAddsEvents
0Nothing is logged.–
1Adds scanner punishments: deletes, timeouts, kicks, bans and hacked accounts.ScannerAction
2Adds moderation done through PolarBot.BotModeration
3Adds every message the scanner flags.MessageFlagged
4Adds Discord’s own moderation: bans, unbans, timeouts, kicks and AutoMod actions.BanAdd, BanRemove, NativeTimeout, NativeKick, AutoModExecution
5Adds members joining and leaving, and invites being created, deleted and used.MemberJoin, MemberLeave, InviteCreate, InviteDelete, InviteUse
6Adds deleted and edited messages, with the text before and after when known.MessageDelete, MessageBulkDelete, MessageEdit
7Adds changes to roles, channels, the server, emojis, stickers and webhooks.GuildObjectChange
8Adds nickname, avatar, username and role changes, and voice activity.NicknameChange, AvatarChange, MemberRoles, UsernameChange, Voice
9Adds reactions, threads, pins and every scan score with its reasons.Reaction, ThreadChange, PinsUpdate, ScanScore
10Adds 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>]
FormWhat 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 --helpPrints the usage and exits with status 2.

Exit codes

StatusMeaningsystemd
0Stopped normally (SIGTERM or Ctrl-C).Not restarted.
1Fatal 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.
2Bad command line.Restarted like 1 (fix the unit’s ExecStart).
78Configuration 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.

IntentPrivileged
GUILDSno
GUILD_MEMBERSyes
GUILD_MODERATIONno
GUILD_EMOJIS_AND_STICKERSno
GUILD_INTEGRATIONSno
GUILD_WEBHOOKSno
GUILD_INVITESno
GUILD_VOICE_STATESno
GUILD_PRESENCESyes
GUILD_MESSAGESno
GUILD_MESSAGE_REACTIONSno
GUILD_MESSAGE_TYPINGno
MESSAGE_CONTENTyes
GUILD_SCHEDULED_EVENTSno
AUTO_MODERATION_EXECUTIONno

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

  1. Open https://discord.com/developers/applications and choose New Application. Give it a name (members will see it as the bot’s name).

  2. On General Information, copy the Application ID. You need it for the invite link.

  3. 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.
  4. 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=1099511721094
    

    The 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.

  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 /about and the first log line. They are optional; without them the bot reports unknown and channel dev.
  • --locked builds exactly the dependency versions in Cargo.lock.
  • The first build downloads the toolchain components named in rust-toolchain.toml and 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/polarbot is the program the service runs.
  • /etc/polarbot/config.toml is the config file. The one from deploy/ 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 yet is 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:

  1. /setup log_channel:#mod-log chooses the log channel for mod cards and logs. PolarBot checks it can post there.
  2. /perms set role:@Moderators level:Moderator (and Admin for your admins) gives your staff roles their levels. Members with Administrator and the server owner are always Owner.
  3. /perms authorize (Owner, after command visibility is set up) so each role sees only its commands.
  4. /scanner status shows 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_secret and oauth_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 fileEnable it
Debian/Ubuntu/etc/nginx/sites-available/bot.example.orgsudo 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 tee copies the site from the repository with your host name in it.
  • nginx -t prints syntax is ok and test 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;, and nginx -t reports unknown directive "http2". Delete that line and write the two listen lines as listen 443 ssl http2; and listen [::]: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:

  1. under Redirects, add https://bot.example.org/oauth/callback. It must equal <public_url>/oauth/callback exactly, with the public_url from your config file (Install, step 6);
  2. 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_key is 32 random bytes written as 64 hex characters, generated here and never shown. It signs the /perms authorize links and encrypts the stored refresh tokens. Generating a new one later makes every server run /perms authorize again.

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.
  • curl prints ok. (From the server itself, curl -sS http://127.0.0.1:8787/healthz checks 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 toSet
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

StepCommandWho
Choose the log channel for cards and logs/setup log_channel:#mod-logAdmin
Give staff roles their levels/perms set role:@Mods level:Moderator, /perms set role:@Admins level:AdminOwner
Let each role see only its commands/perms authorize (needs OAuth)Owner
Ping a role on scanner punishments/scanner mod-role role:@ModsAdmin
Check everything/config view, /scanner status, /perms listAdmin

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:

  1. /scanner actions enabled:False: log-only mode. The scanner posts cards but deletes and punishes nothing.
  2. 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.
  3. Adjust: /scanner thresholds log:60 delete:90 punish:97, /scanner punishment action:Timeout duration:1h, /scanner allow domain:example.com for domains your community uses, /scanner channel / /scanner exempt-role for places and roles to skip.
  4. /scanner actions enabled:True when you trust it.
SettingCommandDefault
Scanner on/off/scanner enable, /scanner disableon
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 unlistempty
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 for DynamicUser services; /var/lib/polarbot points there). The -wal and -shm files are SQLite’s write-ahead log; they exist only sometimes and are copied along when they do.
  • /opt/polarbot/polarbot.prev is 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

WhatPathNeeded 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.tomlYour 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 .cred files 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 new oauth_key only means servers run /perms authorize again.

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 -wal and -shm files belong to the database you are replacing and must go; the backup’s own -wal (only backups made by install.sh or the stop method may have one) goes with it.
  • chown --reference gives the files to the service’s user, which owns the directory. It runs in sh -c because 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

  1. 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.
  2. Restore the database as above, and your config file to /etc/polarbot/config.toml.
  3. The new machine has a new oauth_key, so the stored authorizations cannot be decrypted: each server’s Owner runs /perms authorize again. 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 cp puts the old binary back; polarbot.prev stays, 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; /about shows 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 saysFix
invalid config: line N: …A typo or unknown key in /etc/polarbot/config.toml; see Configuration file.
secret-looking key … found in configRemove the secret from the config file; secrets are credentials.
secret … discord_token … is missingThe 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 polarbotThe 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 0Remove 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 says command registration failed, the reason follows.
  • The bot was invited without the applications.commands scope: 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_id set, 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 view shows 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 (/setup checks this when you choose it; channel overrides changed later can break it).
  • If the log channel was deleted, PolarBot cleared the setting: run /setup again.

… the scanner does not delete or punish

  • /scanner status says whether log-only mode is on (/scanner actions enabled:True turns actions on).
  • Moderators and above, exempt roles and exempt or unmonitored channels are never scanned.
  • /scanner analyze shows 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)

SymptomCause and fix
/perms authorize says visibility sync is not set upThe 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 expiredLinks work once and for 10 minutes. Run /perms authorize again.
502 Bad Gateway from nginxThe 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 failsDNS 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 commandsThe 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 plan prints the exact commands it would run (the same as the manual pages) and changes nothing; polarinstall asks every choice with a default; --express asks only for the Discord bot token (and the Minecraft EULA, when a Minecraft server is installed); --config answers.toml --yes runs unattended.
  • install, update, status, rollback, doctor and uninstall (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):

  1. runs the reference test, so a stale reference is never published;
  2. builds the site into docs/site/book/ (not committed) and fails on any mdBook warning;
  3. moves mdBook’s inline scripts into files under inline-js/, so the site works under a strict Content-Security-Policy without 'unsafe-inline';
  4. 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 GET and HEAD, never hidden files, and keeps no access log;
  • sends HSTS, X-Content-Type-Options, X-Frame-Options, Referrer-Policy: no-referrer and 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.