Documentation

How the SAPD bot works

The bot and this website are one program. It reads the CIT forum, keeps a database of every member in step with it, answers commands in Discord, and posts reports and requests back to the forum on a member's behalf. This page walks through each of those in the order they actually happen.

Architecture

One program, not two

The most common assumption about this setup is wrong. There is no separate bot server. The website and the Discord bot run in the same Node process, on the same machine, against the same database connection. When the site starts it connects to MongoDB first, and only once that connection succeeds does it start the Discord client.

That ordering matters. A bot that answered commands before the database was ready would tell members their account does not exist. So the bot is held back until it has something to read from.

Discord
Commands, buttons, forms, role changes, audit events
In-game chat relay
Two channels that mirror CIT game chat into Discord
One Node process
Express website and discord.js bot, sharing every service and model
MongoDB
Members, actions, reports, requests, applications, logs
cit.gg forum
Read for the roster and changelog, written to for reports and requests

Because they share a process, the website can call the bot directly. An admin page that needs a Discord channel list does not make an HTTP request to a bot somewhere else. It asks the client object already sitting in memory. The same works in reverse: a command typed in Discord runs the identical database code the website uses, so the two can never disagree about what a member's rank is.

If the database goes down
The web server keeps running and keeps serving pages. It retries the database in the background with a growing delay, up to a minute between attempts. Pages that need data will error until it reconnects, but the site does not go dark, and the bot starts as soon as the connection comes back.
Reading from CIT

The sync loop

Every ten minutes the bot re-reads the SAPD forum topic and brings the database back in line with it. The forum is the authority. Nothing here edits the forum roster, it only mirrors it.

Read the changelog first
The topic carries a running log of every promotion, demotion, join, leave, kick, warning, vacation, name change and PUN assignment. Each line is matched against a set of patterns and turned into a typed event with a date and the person who performed it.
This runs before the roster on purpose. A name change has to land first, otherwise the roster step tries to match a member by a name the database has not learned about yet, and creates a duplicate.
Apply the events
Each parsed event is applied to the member it names. Promotions move the rank and record who signed it off, vacations set the status, kicks and leaves mark the member as no longer serving. Events are fingerprinted before they are written so a line that is read again on the next cycle is not applied twice.
Read the roster and the forum profiles
The roster table is parsed rank by rank, and each member's forum profile is read for their account name and playtime. These two run at the same time as each other, and each is allowed to fail on its own. If profiles time out, the roster still lands.
Stamp the clock
Every stage writes its own timestamp. That is where the Last updated line in the site footer comes from, and it is per stage rather than global, so a failed roster read does not make the whole site look fresh when it is not.

Two smaller loops run alongside it. Every five minutes the bot reads who is currently online in game and matches those players to members, falling back through account name, then member name, then Discord tag when the first match misses. Also every five minutes it reads the six live ranking boards on cit.gg, two seconds apart so the requests stay inside the forum's burst limit.

The data

What a member record holds

Everything else on this page reads or writes one of two things. The member record is the current state of a person. The action record is one thing that happened to them.

Member
Name, rank and rank order, PUN, country, status, CIT account name, playtime, join date and last seen. Plus the Discord link when the member has verified: their Discord id, tag, avatar and when the link was made. Cached counts of patrols and trainings sit here too, so the roster page does not have to count them on every load.
Member action
One row per event: promotions, demotions, warnings, vacations, name changes, joins and leaves. Each carries the date, who performed it, the ranks before and after where relevant, and where the record came from. Actions live in their own collection rather than inside the member document, so a member with four years of history does not become a document too large to work with.

Actions are what the promotion maths, the profile timeline and the changelog page all read from. Because each one is fingerprinted on the way in, the same forum line read on twenty consecutive sync cycles still produces exactly one action.

Identity

Linking Discord to a CIT account

Most of what follows needs to know which member is typing. A Discord user id on its own means nothing to the roster, so the two have to be tied together once.

The member runs the command or presses the button
!verify opens a form asking for one thing, the member's CIT account name.
The name is looked up exactly
The lookup is case sensitive and matches the account name, not the character name. If nothing matches, the member is told so and given the specific things to check rather than a generic failure.
One account, one Discord user
If that CIT account is already tied to a different Discord user, the link is refused and the attempt is logged. This is the check that stops someone claiming another member's record, so it fails closed and needs staff to unpick.
The link is written and announced
The member record stores the Discord id, tag and timestamp. The member gets their rank, status, country and playtime back as confirmation, and a line goes to the audit channel.

After that, !whois resolves a mention, a name or an id to a full profile, and staff can link or unlink an account by hand with !member link and !member unlink.

Writing back

Reports and requests reach the forum on their own

Training reports, patrol reports, vacation requests, promotion requests, test requests, badge requests and PUN requests all end up as a post on the CIT forum. The member never has to paste BBCode. They fill in a form, on the site or in Discord, and the bot posts it for them.

The form is saved first
The report or request is written to the database before anything is sent anywhere. Whatever happens to the forum afterwards, the work is not lost.
BBCode is built from the record
Participant names are turned into links to their profile on this site, the report number links back to the full report, and marks and comments are laid out in the format the topic expects. Non-SAPD participants are listed separately and are not linked, because they have no profile here.
The reply form is loaded, then posted
The forum stamps hidden per-session tokens into its reply form and rejects anything that does not send them back. So the form has to be fetched immediately before posting. The tokens cannot be saved and reused.
The outcome is recorded either way
Success or failure, a record is written holding the exact text that was sent, the topic, the attempt count and the direct link to the new post. A copy of that goes to a Discord log channel as a single entry per post, which is edited in place rather than duplicated when a retry happens.
On failure, and only then, the BBCode is shown
The member gets a Retry button on the same page. If the forum stays down they can copy the BBCode and post it by hand. When the post worked there is nothing to copy, so nothing is shown but the link to it.
Why one attempt and not three
The member is sitting there waiting for the page to come back. Three attempts with a backoff between them would leave them watching a spinner for the better part of fifteen seconds before being told it failed. A single attempt fails fast and they press Retry.

Requests go to one topic and reports to another. Which topic a post belongs in is decided by its kind, not by the form that produced it, so a vacation request raised in Discord lands in exactly the same place as one raised on the site.

Promotions

How eligibility is worked out

Every rank up to Inspector has a minimum time in rank and a set of activity requirements. The bot recalculates progress against them every five minutes and the numbers appear on the promotion dashboard.

Rank Promotes to Min days Activity required
TrialRecruit72 patrols
RecruitConstable142 patrols, 1 training
ConstableOfficer143 patrols, 1 training
OfficerStation Officer145 patrols, 3 trainings
Station OfficerSergeant3010 patrols, 1 training hosted, 1 patrol hosted
SergeantStaff Sergeant4012 patrols, 2 trainings hosted, 2 patrols hosted
Staff SergeantInspector505 trainings hosted, 5 patrols hosted
InspectorChief Inspector608 trainings hosted, 8 patrols hosted
Chief Inspector
and above
By appointment Not scored Given out by leadership, not earned by a count

Scoring stops at Inspector deliberately. Chief Inspector and everything above it is staff and leadership, and those seats are decided by the Chief. Running the requirement maths on them produced a percentage nobody was ever going to act on, and put a Superintendent in a queue behind a Constable.

Below that line, a member's progress is a weighted blend of three things: time in rank, activity against the requirements above, and evaluation marks from the reports they appear in. The weighting shifts as the ranks climb. At Trial it leans on activity and time. By Inspector, evaluation marks carry roughly a third of it.

Getting from eligible to promoted
The daily check announces who qualified
Once a day the bot posts the members who have become eligible since the last run. A record is kept of who has been announced at which rank, so nobody is pinged twice for the same promotion.
Keying that record on the rank rather than the member is what makes it clear itself. Once a member is promoted they are at a rank with no announcement against it, so the next time they qualify they are announced again.
A request is raised
Either the member presses the promotion request button, or staff file it for them. The evaluation at that moment is attached to the application.
Leadership approve, deny or hold
The notification carries three buttons. Each opens a form for a reason, which is recorded against the application. Once a decision is made the buttons are replaced by a status showing what was decided and by whom, so the same application cannot be actioned twice.
The forum post follows
An approved promotion is posted to the requests topic through the same pipeline as everything else. When the promotion is later written into the forum changelog, the sync loop reads it back and moves the member's rank. The database is never the thing that decides a rank, the forum is.
Recruitment

Applications

Joining runs through the same system whether the applicant starts on the website or in Discord. This used to be a separate bot with its own database. It was folded in so there is one record of an application rather than two that drift apart.

Filling it in
Three steps, one form at a time, so no single form is long enough to lose. The answers are held against the application between steps. A leader can also file one on someone else's behalf, and the record notes that is what happened.
Voting
The completed application is posted to the recruitment channel with Approve and Deny buttons, visible to the roles that hold forum rights. A decision asks for a reason, which is stored with the application and sent to the applicant.
The outcome
Status changes are appended to the application's own history rather than overwriting it, so the record shows every step it went through, not just where it ended up. Accepted applications are posted to the forum.
Everything else

The rest of what runs

In-game commands
Game chat is mirrored into two Discord channels. When a mirrored line starts with a command, the bot re-runs it as though it had been typed in Discord, so players can use the bot without leaving the game. The stand-in it builds for an in-game player answers no to every permission check, so staff commands can never be reached this way.
Field coordination
!backup pings the backup role with the current criminal count and wanted list pulled live. !training and !patrol ping the login role that one is about to be hosted. !wanted posts the wanted list on its own with no role ping.
Ranking alerts
When SAPD moves on the cop group board, the bot says so and names the group that overtook it. Only the cop board is watched, because that is the one SAPD's standing means anything on. The last position is kept on disk, so a change that happened while the bot was offline is still reported when it comes back.
Audit log
Messages, joins, leaves, bans, timeouts, role and channel changes, voice moves and server settings. Each entry shows who did it, resolved from Discord's own audit log within a recency window so a stale entry never gets blamed on the wrong person, and what it was before and after.
Roles
A role hub where members pick up self-serve roles directly, and request the ones that need approval. Requests and decisions are both logged with the requester and the approver named in plain text as well as mentioned, so the log stays readable when a mention no longer resolves.
Forum backup
Every hour the raw BBCode of the department's forum posts is pulled, written to disk and shipped to a Discord channel, with a fuller run once a day. If a post is wiped or the forum goes down, the exact text needed to rebuild it is already somewhere else.
Avatars
Discord avatars are cached against member records and refreshed on a schedule, so the roster and profile pages show a face without asking Discord on every page load. Failures are counted per member, and one that keeps failing is backed off rather than retried forever.
Music
A voice player with a button panel, queue, loop and seek. It reads from several sources and falls back to another when one refuses to stream.
Birthdays and events
Members set a birthday once and it is announced on the day. Events created through the bot get a countdown page on this site and an embed in Discord that links to it.
Site accounts
Staff website logins are managed from Discord with !admin. Permissions are per route and per action, held in the database rather than in code, so what a role can view, edit or delete can change without a deploy. Accounts left unused are locked automatically.
Logging
Console output is mirrored into Discord, with errors and warnings split into their own channel. Security relevant events, failed verifications, suspicious commands and blocked visitors, are recorded separately with the detail needed to act on them.
Visitor tracking
Requests to the site are logged with threat detection in front of them. The bot carries the ban and unban buttons for an address, so acting on an alert happens where the alert arrives.
Timing

What runs, and when

Job Runs What it does
Forum sync Every 10 min Changelog, then roster and forum profiles
Online players Every 5 min Who is in game, matched to member records
Live rankings Every 5 min Six ranking boards, and alerts when SAPD moves
Promotion recalculation Every 5 min Time in rank and last promotion, per member
Report metadata Every 30 min Cached patrol and training counts
Forum backup Hourly Raw BBCode of the department's posts, to disk and Discord
Eligibility announcement Daily, 18:00 Posts members who newly qualify for promotion
Account auto-lock Daily, 02:00 Locks unused staff website accounts
Full forum backup Daily, 03:30 Offset from the hourly run so the two never collide
Reliability

What happens when something breaks

Most of the awkward parts of this system come from one fact: the forum rate limits hard, and it does not always say so. A good deal of the design exists to avoid making a bad situation worse.

One login, shared
Anything that needs the forum waits on a single login rather than starting its own. Without that, the rankings refresh alone would fire six logins in the same millisecond and collect six rejections, then retry them all in lockstep.
The session is kept
A working session is saved and reused for up to twelve hours. The forum limits logins far more tightly than ordinary reads, so the cheapest login is the one that never happens. If the saved session turns out to be dead, the next request notices and logs in once.
Failure has a cooldown
After three failed attempts the bot stops trying for two minutes. Retrying immediately re-arms the forum's own window, which means the retries become the reason the block never lifts. Once the attempts are spent, the useful move is to stop asking.
Reads are spaced, never parallel
Forum pages are fetched one at a time with a gap between them. It makes a full rankings refresh take about ten seconds, which is the correct trade against being throttled for the next ten minutes.
A slow sync is skipped, not stacked
If a sync is still running when the next one is due, the new one is skipped. Two overlapping syncs would fight over the same records and double the forum traffic at the exact moment it is already struggling.
Discord waits are capped
A Discord call that will not go through takes a full minute to give up on. Long enough to hold a member's page open until the server in front gives them a timeout, for a request that already succeeded. So logging and notifications are sent without the page waiting on them.

The pattern underneath all of it is the same. Save the member's work first, tell them the truth about what happened to it, and never let a system that is outside our control turn a completed action into a lost one.