Underground 2 Revived
Chrome과 Firefox에서 Need for Speed Underground 2를 WebGPU로 두고 팬 프로젝트입니다
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기메이커가 쓴 소개는 위의 한 줄이 전부입니다. 더 자세한 내용은 저장소 README에서 읽을 수 있습니다. 저장소 README ↗
README 전체를 불러오는 중입니다.
README 전문 보기 ↗A Discord bot for managing Arma 3 operation slot requests. Members request slots via a two-step squad → slot picker or the 📋 Request a Slot button on the ORBAT embed; admins and Unit Leaders approve or deny requests with a button click.
The roster an operation runs on lives either in the bot's own database — built and edited in the browser under ORBATs — or in a Google Sheet, the way it always did. You pick one per operation; everything else behaves the same.
It also manages self-assignable game roles — permission-free tag roles for games like Minecraft or DCS that members opt into themselves, so you can @mention everyone who plays a given game. See Game Roles.
And it runs standalone events with sign-ups — trainings, movie nights, anything — where members answer Accepted / Tentative / Declined on a button, or whatever options the organiser defined, and get reminded before the start. No Google Sheet involved. See Events.
Looking for how to do one particular thing? How-tos has a short recipe for each — and the bot serves the same text at /help, behind the ? in the site header.
Much of this can also be done from a browser instead of slash commands — events, the slot roster, slot approvals, game roles, embeds and the logs: an optional web interface with Discord login, running inside the same bot process. See Web UI.
Three independent feature areas plus maintenance commands. ORBAT & Slots is the Arma operation system, running on an ORBAT you build in the browser or on a Google Sheet; Events and Game Roles work on their own and never involve either.
In the Who column, Unit Leader+ means Unit Leaders and Admins, and Unit Leaders are scoped to their own unit. See Role-Based Access for the full matrix.
Operation slot requests, approvals and the live ORBAT board.
| Command | Who | What |
|---|---|---|
/request-slot | Everyone | Open the squad → slot picker for the current operation |
/cancel-request | Everyone | Cancel your pending slot request |
/change-slot | Everyone | Forfeit your current slot and pick a new one |
/leave-operation | Everyone | Remove yourself from the operation entirely (pending or approved) |
/assign-slot <member> | Unit Leader+ | Assign a member to a slot directly, bypassing approval |
/clear-slot | Unit Leader+ | Remove a member from a slot; on a sheet-backed operation the cell is restored and the unit tag stripped |
/setup-slots | Admin | Start an operation from an ORBAT or a Google Sheet; optional event time and reminder; auto-posts the ORBAT to #orbat |
/post-orbat [channel] | Admin | Post or re-post the live ORBAT board |
/set-event-time <time> | Admin | Update the operation's start time and reminder |
/post-event [channel] [mission_name] [event_time] | Admin | Post an announcement embed for the operation, pointing at #orbat for sign-ups |
/clear-requests | Admin | Cancel all pending requests for the current operation |
/current-operation | Admin | Show which operation is active, and which ORBAT or sheet it runs on |
/post-eventonly announces the active operation — sign-up still happens through ORBAT slots. For a standalone event with its own attendee list, use/event-createinstead.
How it behaves
/request-slot, /change-slot, /assign-slot and the ORBAT button alike#slot-approvals with Approve / Deny buttons and a denial modal for an optional reason —
or on the web interface, which does exactly the same thing#slot-approvals and are archived as a compact embed in #approval-archive#orbat before the startnocount option; on a sheet it is a squad called ReservistsStandalone events with their own sign-ups — trainings, movie nights, campaign sessions. No Google Sheet, no operation required. Full detail in Events.
| Command | Who | What |
|---|---|---|
/event-list | Everyone | Upcoming events with sign-up counts and jump links |
/event-create <title> <start_time> | Unit Leader+ | Create an event; optional description, duration, location, channel, ping role, reminder, banner image, repeat interval and custom sign-up options |
/event-edit <event> | Organiser or Admin | Change any field including the sign-up options; moving the start time re-arms the reminder. repeat:none stops a series |
/event-cancel <event> [reason] [stop_series] | Organiser or Admin | Cancel it and DM everyone who signed up; the message stays as a record |
/event-delete <event> | Organiser or Admin | Delete it and its message for good, after a confirmation |
How it behaves
🚁 Pilot | 🔫 Infantry | -❌ Can'tevent parameters autocomplete over upcoming events, so nobody types IDsPermission-free tag roles for games (Minecraft, DCS, …) that members opt into themselves. Full detail in Game Roles.
| Command | Who | What |
|---|---|---|
/game-roles | Everyone | Pick your own game roles, with the ones you have pre-ticked |
/game-role-list | Everyone | List every game role on the server |
/game-role-add <name> [emoji] [description] | Admin | Create a permission-free game role and make it self-assignable |
/game-role-remove <role> [delete_role] | Admin | Stop a role being self-assignable, optionally deleting it |
/game-role-panel [channel] | Admin | Post the self-assign panel; it updates itself when roles change |
How it behaves
Unit Leader can never become game roles, so nobody self-assigns approval rightsDraw the plan on the terrain and share it with a link — unit symbols, movement lines, objectives and boundaries, in the browser. No slash command: the whole thing lives in the web interface.
(+) / (-) beside them — plus Arma's own mil_* task markers for objectives, rally points and the restWatch a Reddit user or a subreddit and announce every new post in a Discord channel, in your own words, pinging the roles and people you choose. Set up entirely in the web interface — there is no slash command, and no Reddit API registration either. Full detail in Reddit announcements.
u/TaskForcePhalanx) or a subreddit's new posts (r/arma){title}, {url}, {author} and {subreddit} filled inold.reddit.com, then waits it out instead of hammeringPer-player kills, deaths, kill distance, shots and more for every Arma mission, counted straight from the unit's OCAP server — public, no Discord login. There are no commands: the bot checks the OCAP server every 10 minutes and counts any finished recording it hasn't seen. See Kill Tracker.
| Command | Who | What |
|---|---|---|
/set-timezone <tz> | Admin | Server timezone used when reading any time you type — operations and events alike (default UTC) |
/sync | Admin | Force-sync slash commands with Discord and refresh the ORBAT embed |
/restart | Admin | Restart the bot (the container relaunches itself — see /restart) |
/debug-slots [squad] | Admin | Show the raw slot data the bot reads, whichever roster is in use, for diagnosing missing slots |
/archive-old-approvals | Admin | One-time migration of pre-existing approved messages into #approval-archive |
/purge [amount] [since] | Manage Messages | Delete messages in the channel it is run in — the last N, everything since a date or an age, or both |
/sync is only for when something looks missing/help, with a ? in the header and another beside each page's tabs that opens that page's own instructionsShort how-tos for everything the bot does, one per thing you might want to do. The bot serves the same text at /help — the ? in the site header, and next to each page’s heading.
Slots and operations — Request a slot · Change, cancel or leave · Approve or deny a request · See just your own unit, and clear the easy ones · Put somebody on a slot directly · Take somebody off a slot · Start an operation · Set the start time and reminder · Post or re-post the ORBAT board · Announce the operation · Empty the approval queue · Check what the bot is reading
ORBATs and rosters — Build an ORBAT · Edit an ORBAT without unseating anybody · Rename, copy or delete an ORBAT · Export an ORBAT to a Google Sheet · Run the operation on an ORBAT · Run the operation on a Google Sheet
Events — Create an event · Sign up for an event · Repeat an event · Custom sign-up options · Ping roles on an event · Edit, cancel or delete an event
Game roles — Pick your game roles · Add and remove game roles
Tactical maps — Draw the plan · Use layers · Put a real terrain behind the plan · Put the town names on the map · Share, copy or post a map · Get the plan into Arma 3 · Bring markers back from Arma 3
Campaign planner — Set up the campaign planner · Plan a campaign · Write a mission in the wizard · Use and edit the objectives library · Give a mission its ORBAT · Download the briefing packs · Plan on the campaign and AO maps · Link the OCAP recording and use its numbers · Approve and post the drops
Reddit announcements — Watch a Reddit user or subreddit · Catch a post up, or skip a backlog · Follow someone whose profile hides their posts
Kill Tracker — Rename a mission · Manage campaigns · See a player’s missions and map them to Discord · Link a player’s names · Set a player’s unit · Check the longest kills · Check the biggest multikills · Take over the old sheet and JSON files
Server and members — Read Launcher presets from a channel · Roll back or upload a modlist · Promote Cadets to Members after enough missions · Log joins, leaves, kicks and bans · Welcome new members · Label your invite links · Track time in voice · Build a rich message · Delete messages in bulk · Choose the channels and the timezone · Sync, restart and the one-time migration
Asking for a slot, deciding who gets it, and running the evening.
Who: Everyone · Where: Discord — the ORBAT board, or /request-slot
Ask for one of the slots on tonight’s board and wait for a Unit Leader to decide.
/request-slot anywhere.Somebody else may ask for the same slot. Both requests stand, the approver picks one, and the other person is told — so a 🟡 slot is worth asking for rather than avoiding.
You can only hold one request at a time. Use
/change-slotto move rather than requesting twice.
See also: Change, cancel or leave · Approve or deny a request
Who: Everyone · Where: Discord — /change-slot, /cancel-request, /leave-operation
Move to a different slot, withdraw a request you have not had answered yet, or drop out of the operation.
/change-slot. It gives up what you hold and opens the picker again./cancel-request./leave-operation, then confirm.
/change-slotfrees your old slot the moment you run it, so somebody else can take it while you are choosing. If you only want a different squad, that is the trade.All three release the slot on the board straight away, so nobody is planning around a seat you have left.
See also: Request a slot
Who: Unit Leader or Admin · Where: Discord — #slot-approvals, or the Slot Approvals tab
Decide who gets the slot, in the channel or in the browser — both do exactly the same thing.
Approving a contested slot denies the other requests for it automatically and tells those members why.
A Unit Leader may only decide requests from their own unit. The web page still lists the rest — it says CNTO only instead of the buttons, so the queue never lies about how many people are waiting.
Old requests stay clickable after a new operation starts, and are always decided against the operation they were made for.
See also: Take somebody off a slot · Put somebody on a slot directly
Who: Unit Leader or Admin · Where: Discord — /my-approvals
A private list of the open requests you can actually decide, with one press for the ones nobody is competing over.
/my-approvals. The reply is only visible to you and lists the open requests from your own unit — admins see all of them.It shows less than #slot-approvals does; it does not let you do anything you could not already do. The same unit check runs on every decision, wherever you make it.
The list is a snapshot. If somebody asks for one of those slots between the list appearing and you pressing the button, that slot is skipped rather than decided for you, and the reply says so.
Approving in bulk is not all-or-nothing. If one fails — a sheet that will not write, say — the rest still go through and the reply names the ones that did not.
Denying still happens one at a time, in #slot-approvals, because a denial takes a reason.
See also: Approve or deny a request · Take somebody off a slot
Who: Unit Leader or Admin · Where: Discord — /assign-slot, or the Slot Approvals tab
Book a member onto a slot without them asking and without an approval step.
/assign-slot @member, then pick the squad and the slot.This is stricter than approving: you need a unit of your own and the member must share it. Choosing who goes on the roster is not the same act as answering somebody who asked.
Members are searched rather than listed, because the bot does not read your member list. More than one match is reported with IDs rather than guessed at.
It is refused if the member already holds a slot — clear the old one first.
See also: Take somebody off a slot
Who: Unit Leader or Admin · Where: Discord — /clear-slot, or Release / Withdraw on the web
Give a booked slot back, or take an undecided request out of the queue.
/clear-slot and pick from the dropdown of who is currently on the operation.Neither can be undone, which is why both ask first.
Clearing a pending request greys out its approval message, so nobody finds it later and presses Approve on a request that is gone.
The archive record of an earlier approval is left alone. The archive says what was decided; this is a later decision, not a correction of that one.
Who: Admin · Where: Discord — /setup-slots, or the Operation tab
Load a roster, set the start time, and put the board up.
/setup-slots orbat:<name> or /setup-slots sheet_url:<link> — one or the other, never both.event_time and reminder_minutes if you know them; both can be set later.Starting an operation archives the previous one. Its pending requests are left alone and stay decidable.
Times are read in the server timezone — set that once with
/set-timezone.If the bot cannot post in the ORBAT channel the operation is still created and you are told. Post the board yourself afterwards.
See also: Set the start time and reminder · Post or re-post the ORBAT board · Build an ORBAT
Who: Admin · Where: Discord — /set-event-time, or the Operation tab
Move tonight’s start, and choose how long before it everybody is reminded.
/set-event-time time:25/06/2025 19:00 reminder_minutes:30.The reminder DMs every approved member and pings #orbat. It fires once — moving the time arms it again.
The time renders as a Discord timestamp, so everyone sees it in their own local time whatever the server timezone is.
See also: Choose the channels and the timezone
Who: Admin · Where: Discord — /post-orbat, or the Operation tab
Put a fresh board up when the old message has scrolled away or been deleted.
/post-orbat posts it in the channel you run it in; /post-orbat channel:#orbat sends it elsewhere.There is only ever one live board. Posting a new one means the old message stops updating — delete it, or it will mislead somebody.
The board refreshes itself on every approval, denial and release, so you should rarely need this.
Who: Admin · Where: Discord — /post-event, or the Operation tab
Post an announcement embed pointing people at the board to sign up.
/post-event channel:#announcements, optionally overriding the mission name and time.This only announces. Sign-up still happens through the board — for an event with its own attendee list, use
/event-createinstead.
See also: Create an event
Who: Admin · Where: Discord — /clear-requests, or the Operation tab
Cancel every pending request for the operation at once.
/clear-requests, or press it on the Operation page.Nobody is DMed and nothing is archived. This empties a queue that was never going to be answered — it is not the same as turning people down, which is what Deny is for.
Approved bookings are untouched.
Who: Admin · Where: Discord — /current-operation, /debug-slots
Find out which operation is live and what the bot sees on its roster, when a slot is missing.
/current-operation names the operation and says whether it runs on an ORBAT or a sheet./debug-slots prints the raw slots as the bot reads them; /debug-slots squad:<name> narrows it.Each slot is keyed
db:412on an ORBAT orsheet:r12c4on a sheet. A slot missing here is missing in the roster, not in the board.On a sheet, a slot the bot cannot see usually means the cell does not start with
1.or1-, or its<Insert Name>marker is gone.
Where the slots themselves come from — built here, or in a sheet.
Who: Admin · Where: Web — 🎖️ Operations → ORBATs
Write the roster as indented text and let the bot turn it into squads and slots.
|, separated by commas: left or right for the column, unit:TFP, radio:343 CHN:3, and nocount for a bench that should not count against the numbers.Platoon Net | 152 CHN : 1.A line starting with
#is a comment. A leading1.or2)on a slot is stripped, so lines pasted out of a sheet land clean.Leave the column out entirely and the squads are split down the middle for you. One explicit
leftorrightturns that guessing off for the whole ORBAT.Discord will not render an unlimited board. The editor warns you before you save when the ORBAT outgrows 25 fields or the character limits — eight squads plus a net list is the practical ceiling.
See also: Edit an ORBAT without unseating anybody · Run the operation on an ORBAT
Who: Admin · Where: Web — 🎖️ Operations → ORBATs → the ORBAT
Change the roster while it is in use, and see who an edit would affect before it happens.
Reordering lines is free — slots are matched by name first, so moving a squad changes nothing.
Renaming keeps the booking. The person stays on the slot under its new name, which is why a rename asks: right for a typo, wrong if you meant to replace the role.
Cutting three
Riflemanlines to two keeps the first two, so a booked one is not the casualty.A red banner at the top means this ORBAT is backing the operation running right now, and an edit changes tonight’s board.
See also: Build an ORBAT
Who: Admin · Where: Web — 🎖️ Operations → ORBATs
Keep a library of rosters: give one a clearer name, copy it as the basis for the next one, or throw it away.
Duplicating is the way to build next week’s roster from this week’s without touching the one an operation is running on.
An ORBAT backing the active operation cannot be deleted — the delete would take tonight’s whole board with it. Finish or replace the operation first.
Deleting an ORBAT that ran an old operation is allowed, and releases those bookings in the same move rather than leaving records pointing at slots that no longer exist.
Who: Admin · Where: Web — 🎖️ Operations → ORBATs → the ORBAT
Write the roster into a spreadsheet, for people who want it there as well.
It never touches an existing tab. A title collision gets a
(2)suffix rather than overwriting anything, so an export can never damage the sheet another operation is running on.It is one-way. Nothing is recorded about the export, and the bot reads only the first tab — so an exported tab is not picked up on its own.
Needs
GOOGLE_CREDENTIALSconfigured, and the service account invited to the spreadsheet as an editor.
See also: Run the operation on a Google Sheet
Who: Admin · Where: Discord — /setup-slots orbat:<name>
Use a roster held here instead of a spreadsheet — the default choice, and the simpler one.
/setup-slots orbat:<name> — the name autocompletes — or pick it from the dropdown on the Operation page.No Google account, no credentials and no network call is involved, so nothing about the evening depends on a third party being up.
The approved request is the booking. There is no second copy to fall out of step with the board.
The board additionally shows each squad’s unit and radio channel and the shared nets, which a sheet has nowhere to put.
See also: Run the operation on a Google Sheet · Start an operation
Who: Admin · Where: Discord — /setup-slots sheet_url:<link>
Use an ORBAT-style spreadsheet as the roster, the way it worked before ORBATs existed.
GOOGLE_CREDENTIALS as an Editor.1. or 1-, each with [] <Insert Name> beside it./setup-slots sheet_url:<link>.[] <Insert Name>.Only the first tab is read, and the operation is named after the spreadsheet unless you pass
name:.A sheet with no
<Insert Name>markers has no free slots as far as the bot is concerned, whatever its columns are called.Inserting a row moves every cell below it. Run
/syncafterwards to repair pending requests that now point at the wrong row.If the sheet write fails on approval the request is rolled back, so the sheet and the board never disagree.
See also: Run the operation on an ORBAT · Export an ORBAT to a Google Sheet
Standalone sign-ups: trainings, movie nights, campaign sessions.
Who: Unit Leader or Admin · Where: Discord — /event-create, or the 📅 Events tab
Post a training, a movie night or anything else with its own sign-up buttons and attendee list.
/event-create title:<name> start_time:25/06/2025 19:00.description, duration, location, channel, mention, reminder, image_url.A start time in the past is refused.
durationis what lets the event close itself out afterwards — without it the message keeps its buttons indefinitely.Events are entirely separate from operations and ORBAT slots. Nothing here touches a roster.
See also: Sign up for an event · Repeat an event · Custom sign-up options
Who: Everyone · Where: Discord — the event message, or the 📅 Events tab
Answer on the buttons; the attendee list updates for everyone immediately.
/event-list shows what is coming up with counts and jump links.Withdrawing is not the same as declining. Declining says you are not coming; withdrawing takes you off the list altogether.
Start times render as Discord timestamps, so you see them in your own local time.
Who: Unit Leader or Admin · Where: Discord — repeat: on /event-create or /event-edit
Make it a series: the next occurrence posts itself when this one finishes.
repeat: when creating, or add it later with /event-edit event:<name> repeat:weekly.repeat_until: if the series should stop on a date.repeat_delay: to hold the next post back a number of hours after this one ends, instead of posting it the moment it closes./event-edit event:<name> repeat:none.The weekday patterns take both the weekday and the position from the first occurrence. A series created on Saturday the 13th means 2nd Saturday under monthly by weekday.
Only one occurrence is live at a time — there is no pre-generated calendar, and sign-ups deliberately do not carry over.
Dates are measured from the first occurrence, so a series on the 31st gives 28 Feb → 31 Mar rather than drifting to the 28th for good.
A bot that was offline for ten weeks posts one occurrence in the future, not one per missed week.
See also: Edit, cancel or delete an event
Who: Unit Leader or Admin · Where: Discord — responses: on /event-create or /event-edit
Replace Accepted / Tentative / Declined with your own buttons.
responses: with the options separated by |, for example 🚁 Pilot | 🔫 Infantry | -❌ Can’t.- marks an option as not coming: those people are left out of reminders and cancellation DMs.Between 2 and 10 options, labels under 40 characters, and at least one that is not a decline.
Changing the options on a live event drops the sign-ups whose answer no longer exists, and tells you how many. Everyone else keeps theirs.
A repeating event copies its options to each new occurrence.
Who: Unit Leader or Admin · Where: Discord — mention: on /event-create or /event-edit
Notify one or more roles when the event goes up and again on the reminder.
mention:@Infantry @Armour — type the @ and let Discord turn it into a role token./event-edit event:<name> mention:none.Up to ten roles. Anything that cannot be resolved is reported rather than dropped quietly.
A role that is not mentionable needs the bot to have Mention All Roles, or the ping notifies nobody. You are warned when that is the case.
Who: Organiser or Admin · Where: Discord — /event-edit, /event-cancel, /event-delete
Change the details, call it off while keeping the record, or remove it for good.
/event-edit event:<name> and pass only the fields that change. The rest keep their values./event-cancel event:<name> reason:<why>. The message turns red and loses its buttons, and everyone attending is DMed./event-delete event:<name>, then confirm. The event and its message are gone.Moving the start time re-arms the reminder, so it fires again for the new time.
Cancel tells people; delete does not. If anybody has signed up, cancel is almost always what you want — the confirmation page says so.
On a repeating event, cancelling one occurrence still posts the next one.
stop_series:Trueends the series instead.Moving an event to a different channel is not possible — the sign-up history belongs to the message. Cancel and recreate.
Tag roles members give themselves, so you can ping everyone who plays a game.
Who: Everyone · Where: Discord — the panel button or /game-roles, or the 🎮 Game roles tab
Give yourself the tag for the games you play, so people can ping everyone who plays them.
/game-roles.These roles grant no permissions at all. They exist to be mentioned.
/game-role-listshows every game role on the server without changing yours.Unticking everything is a valid answer and removes all of them.
Who: Admin · Where: Discord — /game-role-add, /game-role-remove, /game-role-panel
Decide which games are on the list, and put the self-assign panel in a channel.
/game-role-add name:Minecraft emoji:⛏️ description:Survival server creates a permission-free role and lists it./game-role-remove role:<role> takes it off the list. Add delete_role:True to delete the Discord role as well./game-role-panel channel:#roles posts the panel with the self-assign button.Up to 25 roles — as many as a Discord menu can show.
A pre-existing role is refused if it grants any permission, is
@everyone, is managed by an integration, or sits above the bot in the role list.Unit roles and
Unit Leadercan never be made self-assignable, so nobody can tick their way into approval rights.There is one panel per server and it updates itself whenever the list changes.
Drawing the plan on the terrain, sharing it, and getting it into the mission.
Who: Unit Leader or Admin · Where: Web — 🎖️ Operations → Maps
Put unit symbols, movement lines, objectives and boxes on the terrain.
Units and markers are drawn with Arma’s own pictures, as the game draws them: a unit is the game’s NATO marker for its side (BLUFOR, OPFOR, Independent, civilian) with Arma’s size marker stacked on it, and every marker is the game’s, grouped as in the game. Each exports as exactly that marker.
The pictures are Bohemia’s, so an admin uploads them once, as a zip of PNGs: Maps → 🎖️ Marker pictures (the bottom of the Terrains page), which also lists which files are still missing. The editor only offers the units and markers with a picture; anything else on a map shows as a ? placeholder. The pictures stay private to the server’s members and share-link holders.
Lines get an arrow head only when you ask for one — most lines on a plan are boundaries and phase lines, which point nowhere.
Lines, areas, shapes and markers are black until you colour them, and take whatever you pick from the swatches. Units do not: they take their side’s colour, as in Arma.
Whatever colour you pick is the colour it comes out in Arma, on the nearest marker colour the game ships. Cyan and purple are the two Arma has no match for.
If somebody else saved the map after you opened it, your save is stopped and you choose: reload theirs, or save yours over it.
On a campaign map, an AO’s layer is the planner’s: its name follows the AO, and deleting it deletes the AO when you save. A phase’s layer on an AO map cannot be deleted here.
See also: Use layers · Put a real terrain behind the plan · Share, copy or post a map
Who: Unit Leader or Admin · Where: Web — the layer panel on a map
Keep phase 1, phase 2 and the enemy picture apart, and show them one at a time.
Hiding a layer is not deleting it. The items stay in the document and in every save.
The switches work for whoever is looking, including somebody who only has a share link — toggling changes their view and nothing else.
Deleting a layer moves its items to the first layer rather than taking them with it.
The ↑ ↓ beside a layer change the drawing order — lower in the list is drawn on top. 🔒 locks a layer: it is still drawn, but nothing on it can be picked up or moved while you draw over it.
A hidden layer is left out of the Arma export: markers you have switched off are not part of the plan being handed over.
Who: Admin · Where: Web — Terrains, or the map editor’s Terrain and OCAP menus
Draw on the actual terrain instead of a blank sheet.
0/ 1/ 2/ … in it.Prefer the upload for anything shared outside the unit. A share link is for people who do not sign in here, and expecting them to reach your OCAP instance as well is how a link ends up showing an empty sheet.
If the archive has no
map.json, you are asked for the world size in metres. Without it the tiles would draw but the Arma export would be nonsense.Levels deeper than a planning sheet needs are dropped, and the reply says how many — the terrain is meant to be smaller than the file.
A terrain cannot be deleted while a map is drawn on it, and the refusal names the maps.
See also: Draw the plan
Who: Admin · Where: Web — the map’s OCAP menu, or Terrains
A terrain arrives with roads and contours and no labels. This is how it learns what its towns are called.
locations files are read on upload, if it has them.The names are in the OCAP data, just not in
map.json. OCAP builds a terrain from a grad_meh export, which writes them beside the tiles asgeojson/locations/<type>.geojson.gz— one file per Arma location type, and the file name is the type.Whether they are there is decided per terrain, not per server. A terrain built the older way — Arma’s map export through gdal2tiles — carries no vector data at all, so there is nothing to find for it however hard the import looks, while the terrain next to it on the same OCAP imports in one press. A failure names every address it tried, so you can tell that apart from a broken feature.
The tiles genuinely have no labels in them: they come out of Arma’s own map export as pure topography, and the game draws the names over that from its config afterwards.
Place names are never exported to Arma — the game already draws its own.
See also: Put a real terrain behind the plan · Draw the plan
Who: Unit Leader or Admin · Where: Web — the map’s Share and Post menus
Give people a link that needs no login, copy a plan to start the next one, or announce it in a channel.
/m/… link that works without signing in.The link is the credential. Anybody who has it can open the map, and with edit can draw on it. New link is the only way to un-share a map that has travelled further than intended.
Changing view↔edit keeps the same token, so tightening the rights does not break every copy of the link already out there.
A map posted in Discord keeps the link it was posted with. Post it again if the site’s address changes.
It posts a link rather than a picture, which is what keeps it current while the plan is still being drawn.
Who: Unit Leader or Admin · Where: Web — the map’s To Arma menu
Paste the plan into a running mission as markers, with no mod on either side.
createMarker is global, so one machine running it draws the plan for everybody.Two scripts, side by side. The first draws markers that stay put. The second draws markers players can drag and delete in game — handy, and the same click that fixes a misplaced objective also deletes it, with nothing asking first.
Both are LOCAL EXEC. GLOBAL EXEC is wasted on the first and actively wrong on the second, where the marker names carry the client that made them.
Running it again replaces the markers from the last run rather than laying a second set over them. Two maps can never delete each other’s.
A unit’s size arrives as Arma’s own size marker, stacked on the unit as a mission maker would place it.
Rectangles and ellipses arrive as Arma’s own area markers, filled and turned the way they are drawn. Arma shows no text on an area marker, so their labels stay on the website’s map.
AOs come across as their borders. The grey mask around them does not: Arma cannot draw a cover with holes in it from a pasted script.
Arma has no dashed line and draws every line at one width, so a dashed or dotted line arrives as its dashes — one short line marker each. Arrow heads arrive as Arma arrow markers at whichever ends have one.
A line’s name arrives as text alone at the same place — the middle, or both ends of a phase line — on an invisible marker, because a line marker shows no text in Arma.
Above the scripts, the panel says how many markers the plan makes and what will not survive the trip.
The debug console has to be enabled in the mission’s
description.ext.
Who: Unit Leader or Admin · Where: Web — the map’s From Arma menu
Copy the markers a mission’s players drew onto the map, so a plan finished in game can be kept and briefed.
They land on a new layer, From Arma, to switch off or delete in one go.
Lines, areas, rectangles, ellipses, the standard military markers and NATO unit markers come back as themselves. Any other marker comes back as a dot named after its type.
Markers outside this map are left out, and the message says how many.
Planning a three-to-five mission campaign and its weekly drops.
Who: Admin · Where: Web — 🗂️ Campaigns → Settings
Choose who may plan, where the weekly drops are posted, and when they go out.
With no unit role set, only server admins can plan. That is on purpose: the planner never opens wider than you chose.
Drop times are read in the server's time zone, and posts show every time as a Discord timestamp, so readers see their own time.
Make the drop channel an Announcement channel and partner servers can Follow it to get each drop in their own server: I publish every drop, which is what sends it on to them, and edits follow by themselves. Give me Attach Files there too, for drops with images.
The survey link may contain
{op}, which becomes the mission name with spaces as%20.Who can edit mission ORBATs decides who may open the ORBATs campaign missions use in the ORBAT editor: server admins only, or admins and planners. Also allow these roles adds anybody else — they find those ORBATs under Operations → ORBATs. Creating, copying and deleting ORBATs always stays with admins.
Who: Admin, or Unit Leader with the unit role · Where: Web — 🗂️ Campaigns → Campaigns
Set up a campaign of three to five missions, its AOs, and the missions in the order they will be played.
A new mission copies the details that stay the same week to week (time zone, orders time and H-Hour, AO, unit, support, radio plan, loadout and so on) from the mission before it. The story, intel, orders and AAR start empty.
The next mission's date is suggested four weeks after the last one, the length of one cycle of drops.
Creating a real campaign also sets one up in the Kill Tracker under the same name, or links the one that already exists, so stats land in one place.
Set the campaign to Archived to fold it away on the list without deleting anything.
Tick Test campaign to try things out. A test campaign is not added to the Kill Tracker and its drops never post publicly. Delete test campaign removes it with one press, and Delete all test campaigns on the list clears every one — with its maps, its posts and the ORBATs the planner made for it. An ORBAT you only linked stays. Make it real keeps a test you want after all.
Deleting a real campaign asks you to type its name. Its maps go with it. Its Kill Tracker campaign goes too if the planner created it and no recordings have been added; one holding stats, or set up in the Kill Tracker by hand, is kept. Its missions' ORBATs and its posted drops stay.
A campaign closes itself: once every planned mission has been played, its AAR day has passed and its OCAP recording is linked (or a week has gone by), it is marked Complete and the mission team channel is told. Set it back to Active to keep it open; it stays open until another mission is added. Switch this off in Campaigns → Settings.
See also: Write a mission in the wizard · Plan on the campaign and AO maps · Set up the campaign planner
Who: Admin, or Unit Leader with the unit role · Where: Web — 🗂️ Campaigns → a campaign → a mission
Six steps from concept to after action, saved as you type, with each drop previewed exactly as it will post.
Everything saves a moment after you stop typing — the top right says when. Leave the page while a save is pending and the browser asks first.
Paste a screenshot straight into an image field, drop a file on it, or use Upload. PNG, JPEG, GIF or WebP, up to 8 MB.
Drag rows and phases by their ⠿ handle, or use ↑ and ↓.
Held back for Zeus in the Intelligence step is never posted or exported.
A phase reads best with one primary objective, one secondary and one complication; Checks says when one is missing. With Success looks like left empty, the OPORD uses the primary objectives' success conditions.
Two people editing the same field at once: the last save wins. Different fields are fine.
The wizard needs JavaScript. The Schedule page shows the mission, its drops and checks without it.
See also: Plan a campaign · Approve and post the drops · Use and edit the objectives library
Who: Everyone with planner access; editing is Admin only · Where: Web — 🗂️ Campaigns → Objectives
The list of objectives and complications the wizard's picker draws from.
Entries that pair well with what the phase already has are lifted to the top of the picker and outlined. That is a suggestion only.
Picking an objective copies its wording into the mission, where it can be edited freely. Changing the library later does not change missions that already picked from it, and nor does removing an entry.
The library starts as the TFP Mission Objectives Library: 57 objectives in eight categories, plus 12 complications.
See also: Write a mission in the wizard
Who: Admin, or Unit Leader with the unit role · Where: Web — a mission's wizard or Schedule page → ORBAT
Link the roster a mission is signed up on: last mission's copied, one reused as it is, or a new blank one.
A copy takes the structure — squads, slots, radios, nets — and none of the bookings.
Who may edit mission ORBATs is up to the planner's settings: admins only, or admins and planners (the default), plus any extra roles ticked there. It covers only ORBATs a campaign mission uses, and copying and deleting ORBATs stay with admins.
An ORBAT can be edited at any time, even while sign-ups are open: the editor asks before a change would unseat anybody.
Unlink only removes the link; the ORBAT itself stays.
See also: Write a mission in the wizard · Download the briefing packs
Who: Admin, or Unit Leader with the unit role · Where: Web — a mission's wizard or Schedule page → Briefing packs
The mission as a PDF or Word file, in three versions for three audiences.
Player briefing — warning order, situation, intelligence and the OPORD; safe for everyone signed up. Leaders' pack — that plus the FragO, timings, tasks, the ORBAT and the ops traces. Full op file — everything, plus the complications, the back-schedule and the after action.
Each phase drawn on its AO's map goes into the leaders' pack and the full op file as an ops trace, before any traces you uploaded — see Plan on the campaign and AO maps.
Complications appear only in the full op file, and Held back for Zeus is never in any pack.
Times are in the op's time zone, with the server's beside them in the FragO timings when the two differ.
The cover carries the site's name and logo. Word files open straight into Google Docs.
Each download is made from what is saved at that moment, so download again after an edit.
See also: Give a mission its ORBAT · Write a mission in the wizard
Who: Admin, or Unit Leader with the unit role · Where: Web — a campaign page → Maps and areas of operations, or a mission's Maps card
Draw the AOs on a greyed-out campaign map, then plan each one on a detailed map of its own with a layer per phase, drawn into the leaders' pack.
The map and the planner keep each other up to date. Rename an AO on the map and it is renamed in the planner; delete its outline and the AO goes, unless phases are fought in it or its own map has drawings, when it is kept and its outline put back. An AO added on the campaign page appears on the map as a clear box to move into place.
Overlapping AOs merge into one clear area, and stay easy to tell apart: each has its own border colour and its name in its own corner. The campaign page shows a key of every AO and its colour beside the map. Change an AO's colour with the swatches when it is selected. Its Arma marker uses that colour too, and its own map takes it when the map is made. Map settings switches the grey off or changes its strength.
The layers keep up with the planner: a new AO gets an outline, a renamed phase or mission renames its layer, and a phase moved to another AO or deleted loses its layer — unless something is drawn on it, which is kept.
A trace shows its own phase plus every layer that belongs to no phase, such as Plan or an enemy layer you add, so draw shared things there.
A map holds 12 layers, so an AO with more phases than fit says so; delete a layer you no longer need in the editor.
If you have the map open in the editor while you change phases in the wizard, your next save there puts the old layer names back. Opening the map from the planner again fixes them.
Removing an AO removes its map. Deleting a campaign deletes its maps.
See also: Plan a campaign · Download the briefing packs · Write a mission in the wizard
Who: Admin, or Unit Leader with the unit role · Where: Web — a mission's wizard or Schedule page → OCAP recording
Which OCAP recording is the mission, so its stats land in the campaign's Kill Tracker campaign and its numbers in the AAR.
The bot only links a recording by itself in the week after the op, and never one somebody changed or said None of these to. Let the bot choose hands the choice back.
Unlinking, linking another, or deleting the mission takes the recording back out of the Kill Tracker campaign — but only if the planner put it there. One an admin added by hand stays.
A test campaign links recordings so you can try the numbers, but adds nothing to the Kill Tracker. Make it real adds them then.
Zeus is not counted as a player, as on the dashboard.
See also: Write a mission in the wizard · Plan a campaign
Who: Admin, or Unit Leader with the unit role · Where: Web — a mission's Schedule page → Drops, or the drop preview in the wizard
Each drop posts by itself at its time once somebody has approved it.
An approval is for exactly the words and images you read, at that time. Edit the drop afterwards, or move the op date, and it says Needs re-approval and will not post until somebody approves it again.
A drop whose time comes without an approval is Held: the mission team channel is pinged once, and it posts the minute it is approved.
Approving a change to a drop that already posted edits the posted message in place, Part 1/2 and images included.
When the drop that opens the ORBAT posts, sign-ups start by themselves on the mission's ORBAT, with H-Hour as the start time. They never replace an operation that is still to be played: then the team channel is told, and Start sign-ups now on the Schedule page does it once that one is over.
A test campaign's drops go to the mission team channel only, never start sign-ups, and are deleted from Discord with the campaign. A real campaign's posted drops stay in Discord when it is deleted.
Set the drop channel and the mission team channel in Campaigns → Settings — nothing posts without them.
See also: Write a mission in the wizard · Set up the campaign planner · Give a mission its ORBAT
Watching a user or a subreddit and announcing new posts.
Who: Admin · Where: Web — 📣 Reddit
Announce every new post in a channel, in your own words.
u/ or r/.
{title},{url},{author}and{subreddit}are filled in. Everything else is literal.Checked every five minutes. Check now runs that check early and does announce.
No Reddit account, API registration or secret is needed — it reads the public feed.
The bot will not ask anybody to upvote anything, and there is no wording for it. Organised voting is what gets accounts banned, and the post score is deliberately never read or shown.
Pointing a watch at a different source resets what it has seen, so the new feed is seeded rather than announced from scratch.
See also: Catch a post up, or skip a backlog
Who: Admin · Where: Web — 📣 Reddit → the watch → Recent posts
Post something the watch missed, or clear a queue you do not want announced.
Marking posts nothing. It just takes them out of the queue so the next check does not work through them three at a time.
This is the way past a flood — an account that un-hid its posts, or a subreddit that suddenly woke up.
Marking needs no working connection to Reddit, which matters: a backlog most needs clearing exactly when Reddit is refusing us.
Who: Admin · Where: Web — Only these authors on a subreddit watch
Watch the subreddit instead, and keep only what that account wrote.
The filter is applied before anything else looks at the posts, so the other authors’ posts never take up the watch’s memory.
A very busy subreddit can push a post out of its own feed between two checks. Nothing can be done about that from here.
Widening the filter later makes old posts look new. Use Recent posts → Mark all afterwards.
The names and groupings the public statistics are shown under.
Who: Admin · Where: Web — 📊 Kill Tracker → Missions
Show a mission under a clean name when it was not named cleanly in game.
The recording’s own date is added after the name, so type just the name; if you type the date as well it is taken off again.
Only the shown name changes. The statistics are not touched, so the name survives every re-count.
The public numbers pick the new name up within about a minute.
Missions are in a campaign by recording, not by name, so a renamed mission stays in its campaigns.
See also: Take over the old sheet and JSON files
Who: Admin · Where: Web — 📊 Kill Tracker → Campaigns
Group missions into a campaign, which the Kill Tracker shows as a page of its own.
A mission can be in several campaigns.
Names that match no recording appear only after an import from the old campaigns.json; keep them ticked and they are included as they were.
See also: Take over the old sheet and JSON files
Who: Admin · Where: Web — 📊 Kill Tracker → Players
See how many missions each player has played and which Discord member they are.
The mapping stays on this page: the public numbers carry no Discord identity.
Players who played under several names are joined under Link names.
See also: Link a player’s names
Who: Admin · Where: Web — 📊 Kill Tracker → Link names
Count a player who played under several names as one, and pick the name shown.
The Kill Tracker also joins obvious variants of a name by itself. This is for the ones it cannot know, such as a changed callsign.
Linking pulls in everything already linked to any of the names, so there are never chains: they all end up under the name you picked.
See also: Set a player’s unit · Take over the old sheet and JSON files
Who: Admin · Where: Web — 📊 Kill Tracker → Units
Correct the unit the Kill Tracker guessed for a player.
Automatic leaves it to the Kill Tracker’s own guess, which uses the squads the player plays in and a tag in the name such as
[2nd USC].Unknown stops the guess and shows no unit.
Players are listed under the name that is shown, after linking.
See also: Link a player’s names
Who: Admin · Where: Web — 📊 Kill Tracker → Longest kills
Look through the farthest kills of all missions and strike the ones that cannot be true.
A struck kill still counts as a kill; only its distance leaves the longest and average kill distance.
Kills from a vehicle are not listed: a vehicle’s weapons reach that far. Each player’s own longest kill is always on offer, so the number on the dashboard can be checked.
Mortars and other indirect fire, pistols beyond 300 m, the crew of a destroyed vehicle and on-foot kills beyond 1500 m are left out automatically and are not listed.
The decision is kept with the recording, so a later re-count does not undo it.
See also: Set a player’s unit
Who: Admin · Where: Web — 📊 Kill Tracker → Multikills
Look through every player’s best multikill and strike the ones that cannot be true.
The kills of a struck multikill still count as kills; only that stretch of time leaves the multikill statistics.
Kills from a vehicle and the crew of a destroyed vehicle are not counted as multikills to begin with.
The decision is kept with the recording, so a later re-count does not undo it.
See also: Check the longest kills
Who: Admin · Where: Web — 📊 Kill Tracker → Import
Bring the mission names, campaigns, linked names and units over from the sheet and the dashboard’s JSON files.
Nothing is deleted. A mission, campaign, link or unit that is already set here is left alone — except mission names, where Replace names that were already set overrides that.
A recording the bot has not counted yet is not in the list; import again once it is.
A mission name in a campaign that matches no recording stays on the campaign and is shown on its page, so it can be dropped by hand.
The log, the welcome, voice time, embeds and the housekeeping.
Who: Admin · Where: Web — 🛠️ Server → Modlists
Post an Arma 3 Launcher preset in a channel and the bot keeps it as that list.
.html the Launcher exports in that channel. The bot reacts ✅ and says what changed.A post that fits more than one list, or none, gets ❓ and buttons to pick the list. Nothing is guessed.
Reading attachments needs the bot’s
MESSAGE_CONTENT=1setting; without it nothing is read.
See also: Roll back or upload a modlist
Who: Admin · Where: Web — 🛠️ Server → Modlists
Put an earlier version back, or add a preset without posting it.
.html and press Upload.Versions are never edited. Only a version that is not current can be deleted.
Uploading is the way to change Core and Optional without a channel post.
Who: Admin · Where: Web — 🛠️ Server → Promotion
Pick two roles and a number of missions; the bot tells the admins who is due, or promotes them itself.
Every recording counts as a mission. Linked names are one player.
You can change the text of the message.
{member},{player},{missions},{needed}and{role}are filled in; empty means the default. It applies to the message with the buttons, not to the note the bot posts when it promotes automatically.Promotions are off until you turn them on. Promote automatically makes the bot swap the roles itself after each count; turning it on promotes everyone who already qualifies at the next count.
The bot needs Manage Roles and its role must sit above the role it hands out.
Pass over the ticked removes a person from the list for good; they are not announced again.
See also: Link a player’s names
Who: Admin · Where: Web — 📋 Member log
Announce who came and went in a channel your staff watches.
Joins and leaves need Discord’s privileged Server Members Intent. Tick it in the Developer Portal first, then set
MEMBER_EVENTS=1and redeploy — asking for the intent before it is granted stops the bot from starting at all.Bans and unbans need no intent and work without that.
Without View Audit Log every kick reads as a plain leave and bans have no moderator. Without Manage Server the invite list cannot be read. The page tells you which are missing.
See also: Welcome new members · Label your invite links
Who: Admin · Where: Web — 📋 Member log → Welcome message
Greet somebody who joins, in a public channel, by DM, or both.
Four placeholders:
{member}pings them,{name}is their name as plain text,{server}is the server name and{member_count}is how many members there now are. Anything else is literal, so a stray{is harmless.This is separate from the join log. It has its own channel, it does not need the log to be switched on, and it does not care about the Announce joins tick — the log is written for staff, the welcome for the member.
It does use the same join event, so it needs the same Server Members Intent as the join log.
A member with DMs closed simply does not get the DM. Nothing fails, and the channel message still goes out.
See also: Log joins, leaves, kicks and bans
Who: Admin · Where: Web — 📋 Member log → Invite links
Say where each link was published, so a join names the source instead of a code.
rnPAfscGbE · Steam rather than the code alone.Needs Manage Server, or the invite list cannot be read at all.
Labels for links that have since expired are kept and stay editable, because old join messages still refer to them.
A join through a single-use link can still be attributed: Discord deletes the link immediately, and the bot works out which one went missing.
Two people joining in the same second cannot be told apart, and a member added by another bot has no invite at all. Those say so rather than guessing.
See also: Log joins, leaves, kicks and bans
Who: Admin · Where: Web — 🔊 Voice time
Record how long members spend in voice, and show a leaderboard.
By default time only counts while at least two people share a channel, and the AFK channel is skipped — so what you measure is time spent together, not time connected. Both rules can be switched off, and channels can be excluded.
The daily board edits the same message once a day, so you can pin it. It never posts twice in a day, and catches up if the bot was down at the chosen hour.
Members are named, not mentioned. A leaderboard that pings ten people every day would be worse than useless.
A normal restart loses nothing; a hard crash costs at most five minutes, and time is never rounded up.
No privileged intent and no extra permission — it works the moment you switch it on.
Who: Admin · Where: Web — 📝 Embeds
Compose the server-info and rules posts you would otherwise write by hand, and edit them in place afterwards.
Discord’s limits are checked when you save, not when you send — an over-long title is refused on the form rather than becoming an embed that can never be posted.
Sending again posts a new message and stops tracking the old one. That is how you move an embed to another channel.
If the message was deleted in Discord, the embed quietly becomes a draft again rather than failing forever.
Image and icon fields must be full
https://URLs.
Who: Manage Messages · Where: Discord — /purge
Clear the last N messages in a channel, everything since a point in time, or both.
/purge amount:50 — the newest 50 messages./purge since:2h — everything from the last two hours. 30m, 7d and 1w work too, as does a date like 25/06/2025./purge amount:100 since:7d — at most 100, and nothing older than a week.It deletes in the channel you run it in, and only for people with Manage Messages there.
The confirmation holds the exact messages it counted, so anything posted while you read it is left alone.
Discord will not bulk-delete anything older than 14 days. Those go one at a time, roughly one a second, capped at 200 per run — the reply says how many were left, and running it again continues where it stopped.
Pinned messages are deleted like any other, but the confirmation says how many are in the set.
Who: Admin · Where: Web — 🎖️ Operations → Settings, or /set-timezone
Point the bot at the channels you actually use, and tell it what time you mean.
#orbat, #slot-approvals and #approval-archive, creating them if they are missing.Europe/Berlin, or run /set-timezone.The timezone is how every time you type is read — operations, events and
/purgealike. Times the bot shows are Discord timestamps and localise themselves per viewer.The empty option is called Default rather than left blank on purpose: nothing chosen and "chosen, and it happens to be #orbat" behave differently the day somebody renames a channel.
A chosen channel that has since been deleted is called out rather than quietly falling back.
Who: Admin · Where: Discord — /sync, /restart, /archive-old-approvals
The three commands that are about the bot rather than about an operation.
/sync re-registers the slash commands with Discord and refreshes the board. Use it when a command looks missing./restart restarts the bot. Nothing is lost — everything lives in the database and every button is re-registered on start./archive-old-approvals is a one-time migration that moves approval messages decided before the archive existed into #approval-archive.Commands sync automatically on start and when the bot joins a server, so
/syncis for when something looks wrong rather than routine.On a sheet-backed operation
/syncalso repairs pending requests whose rows moved because somebody inserted a row. On an ORBAT there is nothing to repair.
/archive-old-approvalsscans up to 500 messages and is not meant to be run twice.
Grouped by the same feature areas as Features above, so the two sections line up.
| Command | Members | Unit Leaders | Admins |
|---|---|---|---|
/request-slot, /cancel-request, /change-slot, /leave-operation | ✅ | ✅ | ✅ |
/assign-slot, /clear-slot — or Assign / Release on the web | ❌ | ✅ (own unit only) | ✅ |
Approve / Deny — in #slot-approvals or on the web | ❌ | ✅ (own unit only) | ✅ |
/setup-slots, /post-orbat, /set-event-time, /post-event, /clear-requests, /current-operation | ❌ | ❌ | ✅ |
| Command | Members | Unit Leaders | Admins |
|---|---|---|---|
/event-list, signing up to an event | ✅ | ✅ | ✅ |
/event-create | ❌ | ✅ | ✅ |
/event-edit, /event-cancel, /event-delete | ❌ | ✅ (own events only) | ✅ |
Editing and cancelling go by who created the event, not by rank — one Unit Leader cannot change another's event. Admins can change any.
| Command | Members | Unit Leaders | Admins |
|---|---|---|---|
/game-roles, /game-role-list | ✅ | ✅ | ✅ |
/game-role-add, /game-role-remove, /game-role-panel | ❌ | ❌ | ✅ |
| Action | Members | Unit Leaders | Admins |
|---|---|---|---|
| Open and read a map — web only | ✅ | ✅ | ✅ |
| Draw on one, create, copy or delete one — web only | ❌ | ✅ | ✅ |
| Upload a terrain, or delete one — web only | ❌ | ❌ | ✅ |
| Share it with a link, or post it in a channel — web only | ❌ | ✅ | ✅ |
| Open a map through a share link | anyone holding the link |
| Action | Members | Unit Leaders | Admins |
|---|---|---|---|
| Add, edit or remove a watch — web only | ❌ | ❌ | ✅ |
| Command | Members | Unit Leaders | Admins |
|---|---|---|---|
/set-timezone, /sync, /restart, /debug-slots, /archive-old-approvals | ❌ | ❌ | ✅ |
/purge | ❌ | ❌ | ✅ (anyone with Manage Messages in the channel) |
Unit roles: 2nd USC, CNTO, PXG, TFP, SKUA
A Unit Leader is any member with the Unit Leader Discord role. They can approve/deny requests, assign slots, and manage slots for members who share their unit role. Admins (Manage Server permission) have unrestricted access.
The unit roles and Unit Leader can never be turned into game roles — the bot refuses, so members can't self-assign their way into approval rights.
Only relevant for sheet-backed operations. If your roster is an ORBAT, skip this section.
The bot reads ORBAT-style sheets, where each slot is a cell rather than a row under column headers. It reads the first tab only, and the operation name is the spreadsheet's title.
| A | B | |
|---|---|---|
| 1 | 1-1 Alpha | |
| 2 | 1. Squad Leader | [] <Insert Name> |
| 3 | 2. Rifleman (AR) | [TFP] Panzer |
| 4 | 3. Medic - [] <Insert Name> |
1-1 Alpha or
Command. Every slot below it in the same column belongs to it, until the next
header. Radio-frequency cells (152 CHN : 1), headings ending in :, and
sentences are skipped, so they don't become squads by accident.. or -, e.g. 1. Squad Leader.
(1-1 Alpha is not a slot — a digit after the hyphen means it's a squad id.)<Insert Name> — that exact text is what the bot
looks for. It can be in the same cell as the role, or in a cell up to four
columns to its right.[TAG] Name, [] Name, Role — Name, or just a name in
the cell to the right.[UNIT] MemberName in bold; on clearing it
restores [] <Insert Name> and removes the unit tag and the bold.There is no header-row/column layout — a sheet of
Squad | Role | Statuscolumns is not read. If/setup-slotssays "No available slots found", the sheet has no<Insert Name>cells; that exact text is what marks a slot as open./debug-slotsshows every slot the bot found, with its cell reference.Share the sheet with your service account email before running
/setup-slots.
None of this applies if you use an ORBAT. Building the roster in the browser under ORBATs needs no spreadsheet, no service account and no particular cell format — see Running an operation on an ORBAT.
bot, applications.commandsSend Messages, Embed Links, Read Message History, Manage Channels, Use Slash Commands, Manage Roles
Manage Rolesis only needed for the game roles feature. Without it, everything else works fine and the game role commands will tell you what's missing. If you add the permission later, you don't have to re-invite the bot — grant it to the bot's role in Server Settings → Roles.
Important — command visibility: After the bot joins, go to Server Settings → Integrations → ORBATBot → Manage. Make sure
@everyoneis set to ✅ (allow). If it is set to ❌, all commands will be hidden from regular members regardless of what the bot configures. Admin-only commands are restricted automatically by the bot — you do not need to configure those manually.
Only needed if you want to run operations from a Google Sheet. If you build your rosters as ORBATs in the browser instead, skip this step entirely — the bot starts and runs without any Google credentials, and only complains if you actually pass a sheet_url to /setup-slots.
client_email) — give it Editor accessCopy .env.example to .env and fill in the two required values:
DISCORD_TOKEN=your_bot_token
DB_PASSWORD=choose_a_secure_password
GOOGLE_CREDENTIALS is optional and only needed for sheet-backed operations:
GOOGLE_CREDENTIALS={...paste entire JSON key file contents here...}
DATABASE_URLis constructed automatically by docker-compose fromDB_PASSWORD. On Railway it is injected automatically — you do not set it manually in either case. The only time you fill it in yourself is running the bot outside Docker, which is why.env.examplecarries a commented example of it.
The optional web interface adds DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET, WEB_SECRET_KEY and WEB_BASE_URL. Leave them empty and the bot behaves exactly as before — no web server is started at all.
REDDIT_USER_AGENT is optional and only matters if you use Reddit announcements: it is how the bot identifies itself when it reads a feed, and Reddit throttles clients that don't. Something like orbatbot/1.0 (by /u/YourRedditName) is ideal. Note that it does not on its own guarantee Reddit will serve the feed to a cloud-hosted bot — see Reddit announcements for what happens when it doesn't.
The Task Force Phalanx bot moved off Railway to a Portainer stack on 2026-11-01 — see 4c. These steps still work for a fresh Railway setup.
DISCORD_TOKEN and GOOGLE_CREDENTIALS
DATABASE_URL is injected automatically from the Postgres service — no manual entry neededRAILWAY_API_TOKEN — an account or team token from Account Settings → Tokens; lets /restart trigger a clean restart via the Railway API. Without it, /restart still works by exiting the process so Railway's restart policy relaunches it. Project tokens do not work — the bot authenticates with a Bearer header.Procfile tells it to run python bot.pyThe database lives in PostgreSQL and persists across all restarts and redeployments. No volume configuration needed.
This is the recommended self-hosted option. You need a Linux VPS with SSH access (Ubuntu 22.04 or similar).
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER
newgrp docker
Verify:
docker --version
docker compose version
git clone https://github.com/loltorres9/orbatbot.git
cd orbatbot
cp .env.example .env
nano .env
Fill in DISCORD_TOKEN, GOOGLE_CREDENTIALS, and DB_PASSWORD. Save and exit (Ctrl+X → Y → Enter).
docker compose up -d
This builds the bot image, starts a PostgreSQL 16 container, and launches the bot. Both containers restart automatically if the VPS reboots.
# View live logs
docker compose logs -f bot
# Stop the bot
docker compose down
# Update to the latest version
git pull
docker compose up -d --build
# Restart the bot only
docker compose restart bot
Bot data is stored in a named Docker volume (
postgres_data) and survives container restarts, rebuilds, and updates.
This is how the bot runs in production. It needs a Docker host that already runs Portainer and a jwilder/nginx-proxy + acme-companion stack, which terminates TLS and fetches Let's Encrypt certificates.
A record for the bot's name (bot.example.com) pointing at the host. The certificate is only issued once it resolves; until then Let's Encrypt validation reaches whatever the name pointed at before and fails.main, compose path docker-compose.portainer.yml.NAME=value per line, no quotes): DISCORD_TOKEN, DB_PASSWORD (letters and digits only), DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET, WEB_SECRET_KEY, WEB_BASE_URL=https://bot.example.com, PROXY_HOST=bot.example.com, LETSENCRYPT_EMAIL, and PROXY_NETWORK set to the network the nginx-proxy container is on (Portainer → Networks). Optional: GOOGLE_CREDENTIALS as one line, MEMBER_EVENTS=1 and MESSAGE_CONTENT=1 once those intents are ticked in the Developer Portal. Do not set DATABASE_URL — the compose file builds it.Logged in and Web UI listening; https://bot.example.com/healthz answers bot ok · db ok. Add https://bot.example.com/auth/callback as a redirect in the Developer Portal (OAuth2).PORTAINER_WEBHOOK to the stack's webhook so every push to main builds the image to ghcr.io/<owner>/orbatbot and redeploys it (then set BOT_IMAGE on the stack to that image).pg_dump to another disk./restart works without any token here: the bot exits and restart: unless-stopped starts it again. To move an existing database in, stop the old bot, pg_dump --format=custom it and pg_restore --clean --if-exists --no-owner --role=orbatuser into the db container, and compare a few row counts before starting the new bot — never run both at once.
Available to all server members.
/request-slot
Opens a squad picker — select your squad first, then choose your slot. You can also click the 📋 Request a Slot button directly on the ORBAT embed for the same flow. You can only hold one slot per operation.
/cancel-request
Cancels your pending slot request and frees it for others.
/change-slot
Forfeits your current slot (pending or approved) and lets you pick a new one via the squad → slot picker. An approved slot is released either way; on a sheet-backed operation the cell is cleared too.
/leave-operation
Removes you from the operation entirely. Works for both pending and approved slots, and on a sheet-backed operation the cell is cleared as well. Shows a confirmation prompt before acting.
/game-roles
Pick which game roles you want (Minecraft, DCS, …). Unrelated to operation slots — see Game Roles.
/event-list
Show upcoming events and sign up to them with the buttons on each event. Unrelated to operation slots — see Events.
Available to members with the Unit Leader Discord role. Scoped to their own unit only.
/assign-slot @member
Directly assigns a member of your unit to a slot — no approval message, no waiting. Uses the same squad → slot picker. On a sheet-backed operation the sheet is updated immediately; the member gets a DM either way. Blocked if the member already holds a slot; use /clear-slot first to reassign. Also on the Slot Approvals page.
/clear-slot
Presents a dropdown of active slots. Select one to remove the member and free the slot. On a sheet-backed operation the cell is restored to [] <Insert Name> and the unit tag removed. The member receives a DM.
Unit Leaders only see slots belonging to members of their own unit.
The same thing is on the 📋 Slot Approvals page of the web interface, as Release next to a booked slot and Withdraw next to a request nobody has decided.
Unit Leaders can also Approve / Deny requests for members of their own unit — in #slot-approvals, or on that same page.
Available to members with the Manage Server permission. Full access with no unit restrictions.
/assign-slot @member
Directly assigns any member to any slot — no approval message, no waiting. Uses the same squad → slot picker. On a sheet-backed operation the sheet is updated immediately; the member gets a DM either way. Blocked if the member already holds a slot; use /clear-slot first to reassign.
/setup-slots orbat:Platoon ORBAT
/setup-slots sheet_url:https://docs.google.com/spreadsheets/d/.../edit
Run this once per operation. Give either an orbat — one you built under the 🗺️ ORBATs tab on the website, autocompleted as you type — or a sheet_url, never both. The previous operation is archived automatically, and a live ORBAT embed is posted to the ORBAT channel — #orbat unless you have moved it, created if it doesn't exist. The whole thing is also a form on the Operation page. Optional parameters:
event_time — operation start time in DD/MM/YYYY HH:MM or YYYY-MM-DD HH:MM format (uses the server's configured timezone)reminder_minutes — how many minutes before the event to send reminders (default: 30)name — overrides the operation name, which otherwise comes from the ORBAT or the spreadsheet title/set-timezone Europe/Berlin
Sets the server's local timezone so event times you type are interpreted correctly. Only needs to be set once. Default is UTC.
/set-event-time 25/06/2025 20:00
Updates the event time for the current operation without re-running /setup-slots. The ORBAT embed and reminder are updated immediately.
/post-orbat [#channel]
Manually post or re-post the live ORBAT board. Defaults to the current channel.
/clear-requests
Cancels all pending requests for the current operation (e.g. to reset before an op).
/post-event [#channel] [mission_name] [event_time]
Posts a formatted event announcement embed with the mission name, start time (as a Discord timestamp with countdown), and a pointer to #orbat for sign-ups. All parameters are optional: the channel defaults to the current one, and the mission name and event time default to the active operation's values.
/archive-old-approvals
One-time migration command. Scans #slot-approvals for old bot-posted approved messages (green embeds with an Approved field) and moves them to #approval-archive. Creates the archive channel if it doesn't exist. Use this once after upgrading from a version that edited approval messages in place.
/debug-slots [squad]
Shows the raw slot data the bot reads from the current roster, each slot with the identifier it is booked against — db:412 for an ORBAT slot, sheet:r12c4 for a spreadsheet cell. Useful for diagnosing why a slot isn't appearing in the picker. Optionally filter by squad name.
/current-operation
Shows the active operation and where its roster comes from — the ORBAT's name, or a link to the sheet.
/sync
Force-syncs slash commands with Discord and refreshes the live ORBAT embed. Only needed if commands appear missing after a deployment.
/purge amount: 50
/purge since: 2h
/purge since: 25/06/2025
/purge amount: 100 since: 7d
Deletes messages in the channel the command is run in. Give it a count, a point in time, or both — with both, the count is a cap on the window.
amount — the newest 1–1000 messagessince — an age (30m, 2h, 7d, 1w), a date (25/06/2025), or a date and time (25/06/2025 19:00), read in the server's configured timezoneNothing is deleted until you confirm: the bot first shows how many messages match, the time span they cover, and whether any of them are pinned. Deleted messages cannot be recovered.
Discord only allows bulk deletion of messages younger than 14 days. Anything
older has to be removed one at a time, which is slow, so at most 200 of those go
per run — the reply says how many were left over, and running /purge again
continues where it stopped.
Needs Manage Messages in that channel, both for you and for the bot. Every run is logged with the requesting user, and the bulk deletions carry your name in the server's audit log.
/restart
Restarts the bot — useful if the bot hangs or misbehaves. Two modes:
restart: unless-stopped on Docker/Portainer, Railway's ON_FAILURE policy on Railway. This is what runs on Portainer.RAILWAY_API_TOKEN is set in the service variables, the bot triggers a clean deployment restart via the Railway APIEither way the bot is back online in ~30–60 seconds. Slots, buttons, and data survive the restart (PostgreSQL + persistent views). The confirmation is ephemeral and every restart is logged with the requesting user.
#slot-approvals (created automatically if it doesn't exist)#slot-approvals#approval-archive (created automatically if it doesn't exist)Unit role gating: deciding a request needs the Unit Leader role, and Unit Leaders can only approve or deny requests from members of their own unit. Admins can action any request, with or without a unit role. Having a unit role alone is not enough — the buttons sit in a channel everybody can read, and without that rule any member of a unit could approve their own request.
Every approved slot request is logged to #approval-archive as a compact embed showing the operation, unit, member, slot, and approver. The channel is created automatically the first time an approval goes through. To migrate old approved messages that were posted before this feature existed, run /archive-old-approvals.
When an event time is set, the bot automatically:
#orbat tagging all approved membersReminders fire at the configured window before the event (default 30 minutes). The reminder fires once and will not repeat.
Standalone events with sign-ups — weekly trainings, movie nights, campaign sessions. Completely separate from the ORBAT slot system: no Google Sheet, no operation required, and signing up to an event does not touch anyone's slot.
An event is a message with three buttons. Members press one and the attendee list updates live for everyone.
Built in stages toward full Apollo-style functionality. Already in: sign-ups, reminders, editing, cancelling, automatic close-out, repeating events and custom sign-up options. Coming next: sign-up roles with per-role limits, then waitlist, templates and a calendar view.
/event-create title:Weekly Training start_time:25/06/2025 19:00
Start times use the same format as the rest of the bot — DD/MM/YYYY HH:MM or YYYY-MM-DD HH:MM — and are read in the server timezone you set with /set-timezone. Times in the past are rejected.
Everything else is optional:
| Option | What it does |
|---|---|
description | What the event is about |
duration | Length in minutes; shows an end time and decides when the event counts as finished |
location | Free text — a voice channel, a server name, a map |
channel | Where to post it (defaults to the current channel) |
mention | Role(s) to ping — type @ and pick as many as you need |
reminder | 15 / 30 / 60 min, 2 h, 24 h before, or no reminder at all |
image_url | A banner image shown on the event |
repeat | Daily, weekly, fortnightly, or monthly by date or weekday — see Repeating events |
repeat_until | Stop repeating after this date |
responses | Your own sign-up buttons — see Custom sign-up options |
/event-edit event:#3 start_time:26/06/2025 20:00
Changes only what you pass — everything else keeps its value. Moving the start time re-arms the reminder, so it fires again for the new time. Only the organiser or an admin can edit.
/event-cancel event:#3 reason:Server maintenance
Marks the event cancelled, greys out the message, removes the buttons and DMs everyone who signed up as coming. The event stays visible as a record rather than vanishing. With custom sign-up options that means every option except the ones marked not coming.
On a repeating event this cancels only that one occurrence and posts the next one — "this week is off, next week isn't" is the usual case. Add stop_series: True to end the whole series instead.
/event-delete event:#3
Removes the event and its message for good, together with every sign-up on it. There's a confirmation step first, because it can't be undone.
Cancel or delete?
/event-cancel | /event-delete | |
|---|---|---|
| The message | Stays, greyed out as a record | Removed from the channel |
| People who signed up | Get a DM | Are not told |
| Sign-ups | Kept | Deleted with the event |
| Good for | An event that was real but isn't happening | Test events, typos, clutter |
If anyone is signed up to a scheduled event, the confirmation says so and points you at /event-cancel instead. Its autocomplete also lists cancelled and finished events — those are usually the ones you want to tidy away.
All these commands autocomplete: start typing and pick the event from the list instead of remembering its number.
/event-create title:Weekly Training start_time:25/06/2025 19:00 repeat:Weekly
Seven patterns to choose from. Optionally add repeat_until:31/12/2025 23:59 to end the series on a date.
repeat | Meaning | Example series |
|---|---|---|
| Daily | Every day | |
| Weekly | Same weekday every week | |
| Every 2 weeks | Same weekday, fortnightly | |
| Monthly — same date | Same day number each month | 15 Jun → 15 Jul → 15 Aug |
| Monthly — last weekday | Last of each month | 27 Jun → 25 Jul → 29 Aug → 26 Sep |
| Monthly — same weekday | Same weekday position each month | 2nd Sat: 13 Jun → 11 Jul → 8 Aug |
| Weekly — except the last one of the month | Every week, but skipping the last <weekday> | 6 Jun → 13 Jun → 20 Jun → (skips 27) → 4 Jul |
The weekday and the position both come from the start time you give it. So for a monthly op on the last Saturday:
/event-create title:Monthly Op start_time:27/06/2026 19:00 repeat:Monthly — last weekday
27 June 2026 is a Saturday, so the series runs on the last Saturday of every month from then on — 25 Jul, 29 Aug, 26 Sep, 31 Oct, and so on, always at 19:00. If the date you pick is not itself the last Saturday of its month, the bot says so when creating the event, so the shift doesn't surprise you later.
Weekly — except the last one of the month is the counterpart: every Saturday apart from the last one. Pair the two and you get a monthly op on the last Saturday with weekly ops on all the others, with no double-booking:
/event-create title:Monthly Op start_time:27/06/2026 19:00 repeat:Monthly — last weekday
/event-create title:Weekly Op start_time:06/06/2026 19:00 repeat:Weekly — except the last one of the month
The weekly series runs 6, 13, 20 June, skips the 27th, then 4, 11, 18 July, skips the 25th, and so on. It adapts to the month: in a month with four Saturdays it runs three times, in a month with five it runs four times — always every Saturday but the last.
Note this is not the same as picking the 1st through 4th Saturdays separately. In a four-Saturday month the 4th Saturday is the last one, so that approach would double-book your monthly op.
Only ever one occurrence exists at a time. When the current one finishes, the bot posts the next automatically in the same channel, with the same description, duration, location, ping role and reminder. Sign-ups start fresh each time — nobody is carried over, so an "Accepted" always means someone answered for that date.
To change or stop a series, use /event-edit:
/event-edit event:#3 repeat:Monthly # change the interval
/event-edit event:#3 repeat:Don't repeat # stop after this occurrence
Three details worth knowing:
Press one of the buttons on the event. Unless the organiser set custom options, they are:
Pressing a different button changes your answer. Pressing the button you already chose withdraws you and takes you off the list entirely — which is not the same as declining. The footer on every event says so.
/event-list shows all upcoming events with sign-up counts and a jump link to each one.
mention takes as many roles as you need, not just one:
/event-create title:Joint Op start_time:25/06/2026 19:00 mention:@2nd USC @CNTO @TFP
Type @ in the field and pick them from the list — Discord turns each into a proper mention. All of them get pinged when the event is posted and again when the reminder fires. Up to 10 roles.
If you'd rather type them, comma-separated names work too: mention:2nd USC, CNTO. Anything the bot can't find is reported back instead of being quietly ignored.
To change or remove them later:
/event-edit event:#3 mention:@2nd USC @PXG # replace the list
/event-edit event:#3 mention:none # stop pinging anyone
A role only actually notifies people if it's mentionable, or if the bot has Mention All Roles. The bot warns you when you pick a role that isn't.
The three default buttons don't suit every event. Give responses your own list, separated by |:
/event-create title:Air Assault start_time:25/06/2026 19:00 responses:🚁 Pilot | 🔫 Infantry | ❓ Maybe | -❌ Can't make it
That event gets four buttons instead of three, and the attendee list on the message is grouped by exactly those options.
Pilot | Infantry works too.- marks "not coming". Those people are left out of reminders and cancellation DMs, the way Declined always has been. At least one option has to mean coming.responses out and you keep the usual ✅ Accepted / ❓ Tentative / ❌ Declined.Everything else behaves the same: pressing the button you already picked withdraws you, and a repeating event carries its options to every future occurrence.
To change the options later:
/event-edit event:#3 responses:✅ In | -❌ Out
If anyone had already signed up with an option you removed, their answer is cleared and the bot tells you how many — they need to answer again. Options you keep are unaffected.
When the reminder window is reached, everyone who signed up as coming gets a DM, and the event's channel gets a ping — including the mention roles if any were set. People who declined are left alone, and so is anyone who picked a custom option marked not coming with a leading -. The reminder fires once.
Times always display as Discord timestamps, so everyone sees the start in their own local time without configuring anything.
Once an event's start time — plus its duration, if set — has passed, the bot marks it finished, greys out the message and removes the buttons, so old events can't collect stray sign-ups. If the event repeats, that is also the moment the next one goes up.
Self-assignable tag roles for games — Minecraft, DCS, Squad, whatever your members play. They are completely separate from the slot and ORBAT system, and they exist for one purpose: so anyone can @mention everyone who plays a given game.
These roles never grant permissions. Roles the bot creates are created with no permissions at all and are mentionable by everyone. If you point the bot at a role that already exists, it checks it first and refuses when the role:
@everyone, or is managed by an integration (bot roles, the Nitro booster role)Unit Leader — otherwise a member could self-assign their way into slot-approval rights/game-role-add name:Minecraft emoji:⛏️ description:Vanilla and modded
Creates a permission-free, mentionable role called Minecraft and makes it self-assignable. emoji and description are optional and only affect how the role looks in the picker.
If a role with that exact name already exists, it is reused rather than duplicated — so you can make your existing game roles self-assignable without recreating them. Running the command again for the same name just updates the emoji and description. You can have up to 25 game roles, which is as many as a Discord menu can show.
/game-role-panel #game-roles
Posts the panel: an embed listing every game role plus a 🎮 Choose your game roles button. This is the normal way members opt in — no command to remember. There is one panel per server, and it updates itself whenever you add or remove a game role. The button keeps working after a bot restart.
/game-role-remove role:Minecraft
Stops the role being self-assignable. By default the Discord role itself stays and members who have it keep it — pass delete_role: True to delete it outright, which removes it from everyone. If you delete a game role in Discord directly, the bot drops it from its own list the next time it reads them.
Click 🎮 Choose your game roles on the panel, or run:
/game-roles
Both open the same private menu listing every game role, with the ones you already have already ticked. Tick the games you play, untick the ones you don't, and submit — your game roles are set to exactly what you left selected. Deselecting everything is valid and removes all of them.
To drop a role, either untick it in that menu, or press ➖ Remove a role for a shorter list containing only the roles you currently have — pick one or several and they are removed. The button only appears when you actually have a game role to give up.
Only the roles that actually changed are touched, and you get a short summary of what was added and removed. /game-role-list shows the available roles without changing anything.
The bot needs the Manage Roles permission, and its own role must sit above the game roles in Server Settings → Roles — Discord does not let a bot hand out roles ranked at or above its own. If either is missing, the bot says so with the specific fix instead of failing silently.
No privileged intents are needed for this feature.
Watch a Reddit account or a subreddit, and every time something new is posted, the bot announces it in a Discord channel — with your wording and your pings. It is set up in the web interface under 📣 Reddit, admin only.
No Reddit account, API key or app registration is needed. Every user and every subreddit has a public RSS feed, and that is all the bot reads. Do set REDDIT_USER_AGENT, so Reddit can see who is asking — clients that don't say get throttled.
That may not be enough on its own, and it is worth knowing why before you go looking for a setting to change: Reddit also refuses requests based on where they come from. A hosting provider's address — Railway's included — gets turned away from the public feeds with 429 Too Many Requests however politely it identifies itself, and however rarely it asks. One watch checked every five minutes is 288 requests a day, which is nothing at all; being refused is not about that number and cannot be fixed by lowering it.
So when a check is refused, the bot retries the same feed on old.reddit.com, which is the older renderer and is much less fussy about who is asking. If both turn it down, the watch stands down for half an hour (or for whatever Retry-After Reddit asked for) rather than asking again every five minutes, since hammering a refusal is what makes a passing throttle into a permanent one. The list page says when the next attempt is due, and Check now ignores the wait, so you can always try immediately by hand.
The bot announces posts. It will not ask anyone to upvote them, and there is deliberately no wording for that anywhere in it.
Asking a Discord server to go and upvote a post is vote manipulation under Reddit's content policy, and it is one of the few things Reddit enforces hard — not only against the account that posted, but against the accounts that keep answering the call. It is easy to spot: the same handful of accounts voting within minutes of the same author posting is exactly what shows up in the voting timeline, however the Discord message is worded.
Asking people to read and comment is fine, and comments and real discussion weigh more in Reddit's own ranking than a few early votes do. That is what the suggested templates say.
Open the web UI, pick your server, go to 📣 Reddit and press + Watch a feed.
Choose Reddit user or Subreddit and give the name. The bare name (TaskForcePhalanx), the u/ or r/ form, or the page URL pasted straight from the address bar all work.
A Reddit user watch covers everything that account submits, in any subreddit — plus anything it posts to its own profile page. It is not limited to one community. A Subreddit watch is the other way round: every new post in that subreddit, whoever writes it.
Pick the channel to announce in.
Optionally, fill in Only these accounts to announce just those authors' posts. See following someone whose profile hides their posts.
Write the message. Four placeholders are filled in for you:
| Placeholder | Becomes |
|---|---|
{title} | The post's title |
{url} | A link to the post |
{author} | The Reddit account that posted it |
{subreddit} | The subreddit it was posted in — for a post made on the author's own profile, that is u_TheirName, which is where such posts actually live |
Leave it empty and the default is used. Discord unfurls the link on its own, so the title and thumbnail appear underneath the message without you doing anything.
Tick the roles to ping, and list any people by user ID (right-click a member → Copy User ID, with Developer Mode on — there is no dropdown, because the bot cannot list your members without the privileged members intent).
Save.
Reddit lets an account hide its posts from its own profile page (Settings → Curate your profile → Content and activities). A Reddit user watch reads exactly that profile listing, so when the setting is on it comes back nearly empty — the bot sees what a logged-out visitor sees.
The posts themselves are not hidden: the subreddit they were written in still lists them. So watch the subreddit instead, and put the account's name in Only these accounts. Every new post in that subreddit is read, and only that author's are announced. Several names are allowed, separated by spaces.
Two limits worth knowing:
The first check after that notes down what is already on the feed and announces nothing — otherwise switching a watch on would post the author's last 25 submissions at once. From then on, every new post is announced, within about five minutes of going up.
If a post never made it into the channel — the bot was down when it went up, Discord refused that one message, or you only set the watch up afterwards — open Recent posts and press Announce on it. It goes out exactly as the scheduled check would have posted it, pings included, and is then marked as announced so the next check doesn't repeat it. Each row can be expanded to see the message first.
The one limit is that only what the feed still lists can be caught up: Reddit's feed carries roughly the last 25 submissions, and anything older than that is out of reach.
Tick the posts you don't want announced and press Mark ticked as announced — or Mark all for the whole list. Nothing is posted; the posts simply leave the queue.
That is what to do when a feed suddenly fills up — an account that un-hid its posts, a subreddit that was quiet for a month. Without it the bot works through the backlog three posts at a time, every five minutes, until all of it has been announced. Mark them, and only what comes after that point reaches the channel.
Marking needs no connection to Reddit: the page has already read the feed, so ticking and marking works even while Reddit is refusing the server — which is exactly when a backlog is waiting.
The page also names the exact address it read and how many entries came back, with a link to open it yourself. That is the answer to "why hasn't it announced X": if a post is missing from that page, it is missing from the feed, not being skipped by the bot. Each post says where it lives too — r/arma, or the author's own profile for one posted to u/TheirName.
@everyone cannot ping your server: the announcement quotes Reddit, so only the roles and people on the watch are allowed to be mentioned.429s are a hosting problem, not a settings problem — see above. If a watch is refused from both hosts every single time, the public feed is not usable from that server's address, and the way through is Reddit's OAuth API with a registered script app rather than any change to how often it checks.The unit's public scoreboard. The bot reads the OCAP server's recording list every 10 minutes, downloads each finished recording it hasn't counted yet, and stores the per-player rows. Nothing is uploaded by hand.
| Address | What |
|---|---|
/kt/api/stats.csv | Every counted mission as CSV — the 35 columns the Google Sheet had plus two for the weapon of the longest kill, so the existing dashboard reads it unchanged |
/kt/api/missions | JSON: which recordings are counted, under which rules, and how our kill count compares with the OCAP server's own |
| /kt/api/campaigns.json, /kt/api/player_aliases.json, /kt/api/unit_overrides.json | The campaigns, the names that belong together and the units set by hand, in the shape of the dashboard's own JSON files |
The page that reads them is in dashboard/ and is published to GitHub Pages (https://armatfp.github.io/ORBATBot/) by .github/workflows/dashboard.yml. It reads only this bot — nothing reads a Google Sheet any more.
All of them are open to everyone and need no login.
📊 Kill Tracker in the tab bar, for server admins only:
campaigns.json, player_aliases.json and unit_overrides.json, once.How-tos for each are under How-tos → Kill Tracker.
Set OCAP_BASE_URL if the OCAP server is not https://ocap.taskforcephalanx.com, and KT_SYNC=0 to stop the polling. A rule change is a code change: bump RULES_VERSION in utils/killtracker.py and the bot counts every mission again by itself. How it counts, and why, is in docs/killtracker.md.
The mission plan, drawn on the terrain and shared with a link. It lives entirely in the web interface — there is no slash command, because dragging a symbol onto a hillside is not something a Discord modal can do.
Open Operations → Maps. Every member of the server can read the maps; creating one and drawing on it needs the Unit Leader role or Manage Server.
| Tool | What it places | Key |
|---|---|---|
| Select | Pick something up and move it; drag the background to pan | V |
| Unit | An APP-6 symbol — infantry, armour, mortars, air, medical, supply… — with its size mark | U |
| Marker | One of Arma's task markers — objective, destroy, flag, warning, pick-up… with optional text | M |
| Line | A route, a boundary or a phase line | L |
| Area | A shaded boundary or a suspected position | A |
| Text | A free label — phase names, timings | T |
Pick the side (friendly, hostile, neutral, civilian, unknown), the layer, the symbol, the size mark, whether it is in the air and whether it is there yet or only planned, in the toolbar before you place something; everything can be changed afterwards in the panel on the right, which also holds the mobility indicator and the parent unit.
Drawing a line is like the game: press the left button and drag. With the Line or Area tool that is all there is to it; from any other tool — including Select — hold Ctrl while you drag. Letting go finishes the line, Esc throws the stroke away, and the Area tool closes the shape into an area.
A plain drag is freehand — as straight as your hand is, which a boundary or a phase line is not. Hold Shift while you drag for a straight segment: it runs from wherever you were when you pressed Shift to wherever the pointer is now. Let go and freehand carries on from that point, so one stroke can be a straight leg, a curve round a hill and a straight leg again — and tapping Shift again chains another one.
Lines, areas and markers are black until you colour them — they belong to the plan, not to a side, which is why they no longer take the colour of whichever side happens to be picked. They can be red, orange, yellow, green, blue, cyan, pink, purple, white or black, from the swatches in the toolbar before you draw or in the panel afterwards — or any other colour from the colour field beside them. A unit symbol keeps its side's colour, because that is what says whose it is. A line gets no arrowhead unless you tick one: most lines on a plan are boundaries and phase lines, not directions of attack.
The symbols follow APP-6, the way the planning tools and pocket cards do: the frame's shape says whose a unit is, so the plan still reads when it is printed, projected, or looked at by somebody who is colour-blind.
Del removes what is selected, Ctrl+Z undoes, Ctrl+S savesA map holds up to twelve named layers, each with its own switch: Phase 1, Phase 2, Feindlage. Add one in the Layers panel, pick which layer you are drawing on in the toolbar, and move anything between layers from its properties.
Best: upload the terrain. The tile archives you already prepare for OCAP work as they are — a zip or 7z holding the numbered folders 0/ 1/ 2/ … and, if it came from OCAP, a map.json. Go to Terrains (linked from the Maps page), upload it once, and from then on every map can be put on it from the editor's 🗻 Put it on a terrain panel.
Uploading is worth the storage for one reason: the bot then serves the tiles itself, so nothing outside has to be reachable — not for your members, and not for whoever you send a share link to.
map.json, so usually you only pick the file. Without one, type the world size (Altis 30720, Tanoa 15360, Stratis 8192).Alternative: point at a running OCAP server. Open 🛰️ Load a terrain from OCAP — the directory field starts filled in, so for most people it is just List the terrains — or give it the folder OCAP keeps its terrains in — e.g. https://ocap.your-unit.net/images/maps — and press List the terrains: everything in it comes back as a dropdown, so you never have to remember that Cham is tem_cham. The address is saved for the server, so it is typed once. Same terrain, same automatic Arma coordinates, nothing stored here; the catch is that everyone opening the map loads the tiles from that server, so it has to be reachable for them too. If your OCAP does not list its directory, Paste an address instead takes one map folder's URL as before.
Or paste the URL of any image the browser can load — a terrain screenshot, a map export — under Map settings. It is stretched across the sheet, so pick the sheet shape (square, landscape, portrait) that matches it, and set the Arma corners yourself if you want the export.
However the background got there, Detail under Map settings picks how deep into the tile pyramid to draw, an optional grid can be laid over the top, and the map works with no background at all: a plain dark sheet with a grid is enough for a schematic.
A terrain arrives with no labels on it — the tiles come out of Arma's own map
export as pure topography, and the game draws the names over that afterwards.
Where they can be read from depends on how the terrain was built. OCAP's
current maptool builds one from a grad_meh
export, which writes the locations beside the tiles as
geojson/locations/<type>.geojson.gz, one file per Arma location type — so on
such a server this is a read, not a trip into the game. Its older route is
raster tiles only and carries none.
On an OCAP terrain: open the map → Terrain and background → Place names from OCAP → Import place names. It reads the server the map points at.
On a terrain uploaded here: the archive's own locations files are read on
upload if it has them.
It is decided per terrain, not per server. The .emf → gdal2tiles route in
OCAP's own tile guide produces raster tiles and no vector data at all, so no
amount of probing will turn up locations for a terrain built that way — while
the terrain beside it on the same OCAP, imported from a grad_meh export, gives
up its names in one press. That is a property of how each terrain was built, not
of the server and not of the import. A failure therefore names the terrain and
every address it tried, so you can tell it apart from a broken feature.
When there is nothing to import, the same panel carries a script and a paste box. Run the script in a mission on that terrain — debug console, LOCAL EXEC — and paste what lands on your clipboard into the box. That is once for the terrain: not once per map, not once per operation, and every plan drawn on it afterwards has them.
The box is on the map rather than only on the Terrains page because a map backed by an OCAP server has no terrain row there to paste into — which for a while made the one remaining path unreachable for exactly the people who needed it.
Names belong to the terrain, not to one map, so this is once per terrain and every plan drawn on it gains them — including maps on an OCAP server, which have no terrain record here but are matched by the folder's world name.
They are drawn under the plan, so a symbol you place is never hidden behind a
village name, and they scale by rank the way a paper map does — a capital larger
and letter-spaced, a rock small. Nameless entries are dropped: Arma's terrains
are full of FlatArea helpers that exist to position things and have nothing to
say.
Because you may well want all of them on one terrain and almost none on a briefing sheet, the editor's background panel carries a switch per group — towns and villages, hills and landmarks, water and coast, places of interest, everything else — plus a size slider. That setting belongs to the map; the names themselves belong to the terrain.
Place names are never exported to Arma: the game draws its own.
A map is private to people who can sign in here until you give it a link. Under Share link, choose:
The link looks like https://your-domain/m/xxxxxxxx, works without a Discord login, and Replace the link issues a new one — the old link stops opening the map immediately, for everyone who has it. That is how you take a plan back once it has been forwarded further than you meant.
If the site answers on more than one name, the link always carries the first one in WEB_BASE_URL — even if you made it while browsing the other. That is deliberate: a share link is copied to somebody who was never on the site, so it has to be the name you want people to see. The same goes for the link the bot posts into a channel. A map already announced in Discord keeps the link its message was posted with; post it again to refresh that.
Copying a plan. Duplicate gives you the whole plan again under a new name — every symbol, line and layer, on the same terrain. The share link deliberately does not come with it, so a copy made to try something out is not reachable by everyone who already has the original's link. Rename is the Name and description form on the same page.
Post in a channel sends an embed with the map's name, what is on it and a link. It posts a link rather than a picture on purpose: the plan usually keeps changing after the briefing is announced, and a link is always current where an image is not. If the map has a share link, that is what gets posted; otherwise the link only opens for people who can sign in here.
The map can be dropped into a running mission as ordinary Arma markers, with no mod on the server or the client. Arma cannot fetch anything from outside without one, so the way in is the debug console:
.sqf:
DEL. For planning in game, at the map table. The same click that fixes a misplaced objective deletes it, and nothing asks first. This one also lets you pick which map channel the markers sit in, so a plan meant for one side need not be in Global.#login <password>), open the debug console, paste it and press LOCAL EXEC — for both scripts, once, on one machine.LOCAL EXEC, not GLOBAL, even though every player sees the markers: createMarker is global by itself, so one machine running the script draws them for everybody. GLOBAL EXEC runs the script on every machine instead, which is wasted on the first script and actively wrong on the second, where each marker's name carries the id of whoever ran it — you would get one plan per player.
Pasting a script again after changing the plan replaces that map's markers instead of adding a second copy — each map owns its own marker names.
The mission has to allow the debug console: enableDebugConsole = 1; (admins) or 2 (everyone) in its description.ext. Lines and areas need Arma 2.00 or newer, which is anything current.
What comes across:
| On the map | In Arma |
|---|---|
| Unit symbols | The matching NATO marker, in the side's colour — b_inf, o_armor, n_med … |
| Anything in the air | That side's air marker, whatever branch the symbol was |
| Headquarters | The hq marker of that side |
| Size, strength, mobility, parent unit, planned | Written into the marker's name: 1-1 Alpha / A Coy (planned Plt twd) |
| Markers (OBJ, TGT, RP …) | mil_objective, mil_destroy, mil_dot and friends, with the text |
| Lines and areas | Polyline markers; an area closes itself, a line's arrow becomes a mil_arrow |
| The colour you picked | The nearest marker colour Arma ships — a red line arrives red. Cyan and purple are the two it has no match for |
| Text labels | An empty marker carrying the text |
Anti-tank, snipers and a few others have no marker in vanilla Arma, so they come across as the nearest one that exists. Dashed lines arrive solid, and so does a planned symbol's dashed frame — Arma has no dashed marker, which is why "planned" is written into the name instead.
An optional browser interface for most of what the bot does — events, the slot roster and its approvals, tactical maps, game roles, embeds and the logs — without touching a slash command. You sign in with your Discord account, and everything you do goes through the same code as the slash commands, so it produces the same messages, in the same channels, with the same buttons.
It is off until you configure it. With DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET and WEB_SECRET_KEY unset, the bot starts exactly as it always did and opens no HTTP port. The startup log says which of the three is missing.
| Page | What you get |
|---|---|
| Sign-in | Continue with Discord — OAuth2, identify scope only |
| Help | Every how-to, searchable — reachable signed out, at /help |
| Server picker | Every server you and the bot are both in (skipped when there is only one) |
| Events | Upcoming events with live sign-up counts, plus recently finished and cancelled ones |
| Event | Full details, the attendee list per response, and RSVP buttons for yourself |
| New / Edit | Title, start, duration, description, location, channel, ping roles, reminder, repeat pattern, custom sign-up buttons, banner image |
| Cancel | Reason field, DMs everyone attending, optionally stops the whole series |
| Delete | Confirmation page stating the sign-up count, then removes the event and its message |
| Operations | One group holding the three pages that are always about the same evening: |
| · Operation | Start an operation, set its start time, post the board and the announcement, empty the queue, read the raw roster |
| · Slot Approvals | Approve, deny or withdraw the pending requests, release a booked slot, or put somebody on one outright |
| · ORBATs | Build and edit the slot roster |
| · Maps | Draw the tactical plan, share it with a link, post it in a channel |
| · Terrains | Upload the tile archives the maps are drawn on, and see what each one costs (linked from Maps) |
| · Settings | Which channels the bot posts into, and the server timezone |
| Game roles | Tick the games you play; admins add and remove roles and post the self-assign panel |
| Embeds | Build rich messages, post them, and edit the posted message in place |
| Member log | Announce joins, leaves, kicks, bans and unbans in a channel, and welcome new members in a channel or by DM |
| Watch a Reddit user or subreddit and announce new posts, with your own text and pings | |
| Voice time | Leaderboard of time spent in voice channels; admins configure what counts |
Who may do what is read live from your Discord roles, not from the login:
That is the same rule set the slash commands use; it is literally the same code. Roles are cached for a minute, so if you have just been given a role, the “Changed your roles on Discord? Re-read them” link at the bottom of the event list picks it up immediately.
Times are entered and displayed in the server timezone (/set-timezone), the same as every time you type into a slash command.
1. Create the OAuth2 credentials. In the Discord Developer Portal → your application → OAuth2:
https://your-domain/auth/callback — it must match WEB_BASE_URL exactly, including https:// and with no trailing slash. Serving the site on two names? Put both in WEB_BASE_URL, separated by a space, and add a redirect URI here for each — the site then keeps whoever opened one of them on that oneAlready have a Discord OAuth2 app? Reuse it. Take its existing client ID and secret, and just add
https://your-domain/auth/callbackas an additional redirect URI — Discord allows several per application and the existing ones keep working. Do not regenerate the client secret if that app is used elsewhere; that would break the other integration. It doesn't even have to be the bot's own application: membership and roles are read through the bot's connection, not through the user's token, so any application works — only the name on the consent screen changes. Using the bot's application is still the tidiest.
2. Generate a session key.
python -c "import secrets; print(secrets.token_urlsafe(48))"
Changing this key later signs everybody out; nothing else is lost.
3. Set the variables on Railway (service → Variables) or in .env for Docker:
DISCORD_CLIENT_ID=your_application_id
DISCORD_CLIENT_SECRET=your_client_secret
WEB_SECRET_KEY=the_random_string_from_step_2
WEB_BASE_URL=https://orbat.example.com
4. Expose it.
PORT and the app listens on it; use that domain as WEB_BASE_URL. A domain of your own goes in the same place (Custom Domain, then the CNAME it gives you at your DNS provider); put both names in WEB_BASE_URL if the old one should keep working — your own domain first, because the first name is the one share links carry. Each name also needs its own redirect URI in the Developer Portal.8080 is published by docker-compose.yml; put it behind a reverse proxy that terminates TLS (Caddy or nginx), and point WEB_BASE_URL at the public name.docker-compose.portainer.yml publishes nothing; the bot joins an existing nginx-proxy's network and is announced by VIRTUAL_HOST / LETSENCRYPT_HOST, which also gets the certificate. See 4c.Restart the bot. The log line ✅ Web UI listening on … means it is up; /healthz answers ok once the bot is connected to Discord.
The site is called TFP BOT. Set WEB_BRAND to rename it — that string is the header, the browser tab title and the footer.
For the logo, commit an image to web/static/logo.png (.webp, .svg, .jpg also work). It is picked up on the next start and appears next to the name in the header, large on the sign-in page, as the browser-tab icon, and — when WEB_BASE_URL is set — as the preview image when the site's link is pasted into Discord or Slack. Square images look best; anything else is fitted rather than squashed. With no such file, the name shows on its own and the tab falls back to a 🛡️ emoji — nothing breaks.
Every how-to in How-tos is also a page on the site, at /help. There are two ways in:
Both work signed out, which is the point of putting them outside the server pages: somebody who cannot get past the sign-in screen is exactly the person who needs to read one.
There is only one copy of this text. The topics live in utils/help.py, the site renders them, and python scripts/gen_help.py writes the same thing into this README between its help:start and help:end markers — so the two cannot drift apart. tests/test_help.py goes further and reads the command names straight out of cogs/: adding a slash command without writing a how-to for it makes the test suite fail. Run python scripts/gen_help.py --check to find out whether the README has fallen behind.
The site runs inside the bot process, on the same event loop. That is why a page can post a message, register a persistent button and read a member's roles directly — there is no second service, no polling and no queue table, and one Railway service still runs everything.
The session is a signed cookie, so there is no session table and a redeploy doesn't sign anyone out. It holds nothing but your user id, display name, avatar hash and a CSRF token; the Discord access token is used once to read your profile and then discarded. Every permission decision is made fresh from the bot's live view of the guild.
The 🎮 Game roles tab does everything the slash commands do. Members get one page with every game role, the ones they already have ticked; saving sets their roles to exactly what is ticked, and unticking removes. Admins additionally get an add form (name, emoji, description — an existing role with that exact name is reused, never duplicated), a remove button per role with an optional delete the Discord role too, and a channel picker for the self-assign panel.
Every rule the commands enforce applies unchanged: roles with permissions, @everyone, integration-managed roles, the unit roles and Unit Leader are all refused, the cap is 25, and the panel message refreshes itself after every change. Roles submitted that aren't registered game roles are ignored, so the form can't be used to hand yourself something else. If the bot is missing Manage Roles, the page says so and the controls are disabled rather than failing on submit.
📝 Embeds is a builder for the rich messages you'd otherwise write by hand — server info, rules, announcements. Title, description, colour, up to ten fields (optionally side by side), author line, thumbnail, image, footer, timestamp, and plain text above the embed for pings. A preview shows roughly what Discord will make of it.
Each embed is saved with a name and starts as a draft. Posting it records which message it became, and saving an edit afterwards updates that message in place — no delete-and-repost, so pinned server info stays pinned. Posting again sends a new message and stops tracking the old one, which is how you move an embed to another channel. Deleting offers to remove the Discord message with it.
Discord's limits are checked when you save, not when you post: an over-long title or a 6000-character total is refused on the form rather than becoming a saved embed that can never be sent. Image and icon fields must be full https:// URLs.
📋 Member log posts an announcement when someone joins, leaves, is kicked, banned or unbanned. Pick a channel, tick which events you want, save.
Three prerequisites, and the page tells you which are missing:
MEMBER_EVENTS=1 in the bot's variables and redeploy. In that order — asking for the intent before it is granted stops the bot from starting at all, which is why it is off by default. Bans and unbans work without it.Label your invite links at the bottom of the same page: the invite list is shown with its use counts, and next to each one a free-text field — Steam, Website, Reddit, whatever you use it for. A join through that link then reads rnPAfscGbE · Steam instead of just the code, so nobody has to look it up in a spreadsheet. Labels for links that have since expired are kept and stay editable, because old joins still refer to them.
Invite attribution works by comparing each invite's use counter before and after a join. Two people joining in the same second can't be told apart that way, and a member added by another bot has no invite at all — those simply show no link.
The same page also greets the member, which is a different job from the log above and is configured separately. The log is written for staff in whatever channel they watch; the welcome is written for the person who just arrived.
Four placeholders are filled in: {member} pings them, {name} is their name as plain text, {server} is the server name, and {member_count} is how many members there now are. Everything else is literal, so a stray { in your wording is harmless.
Neither destination depends on the join log being switched on, or on Announce joins being ticked — but the welcome does use the same join event, so it needs the same Server Members Intent as the join log. A member with DMs closed simply doesn't get the DM; nothing fails, and the channel message still goes out.
🔊 Voice time records how long members spend in voice channels and shows a leaderboard for the last 24 hours, 7, 30 or 90 days, or all time, plus which channels see the most use. Every member can see it; only admins can change the settings.
It is off until an admin switches it on. Nothing is recorded before that.
What counts is deliberately narrow by default: time is only counted while at least two people share a channel, and the AFK channel is skipped. Counting pauses the moment somebody is left alone and resumes when somebody joins them, so what you measure is time actually spent together rather than time connected. Both rules can be switched off, and individual voice channels can be excluded.
Optionally, a finished visit is announced in a channel of your choice — with a minimum length, so quick drop-ins don't fill it up. Leave the channel unset to keep the statistics without any messages.
A daily board. Switch on Keep a daily top-10 message up to date, pick a channel, a period and an hour: the bot posts one message and edits that same message once a day, so you can pin it and it always shows current standings. It never posts twice in a day, and if the bot was down at the chosen hour it catches up as soon as it is back rather than skipping. Moving it to another channel posts a fresh message there.
Posting once. Separately, Post the top 10 once sends the leaderboard as a one-off message for a period you choose. Either way members are named rather than mentioned, so nobody gets pinged.
Two details worth knowing about the numbers:
Voice state updates need no privileged intent and no extra permission, so unlike the member log this works the moment you switch it on.
📋 Slot Approvals shows the pending requests for the operation that is running right now, with ✅ Approve and ❌ Deny next to each one and a field for an optional denial reason. Below them, who already holds a slot.
Deciding here does exactly what the buttons in #slot-approvals do — it is the same code: the member is DMed, the request leaves #slot-approvals and is archived in #approval-archive, the live ORBAT board refreshes, and anyone else who wanted that slot is denied and told. You can work through a queue in Discord and finish it in the browser without anything noticing.
Two things to know:
contested. That is the case where approving is a choice rather than a formality, and it is not otherwise visible: in Discord it is simply two separate messages.Taking somebody off a slot is on this page too. A booked slot has a 🧹 Release button and a request nobody has decided yet has 🧹 Withdraw — the same two halves /clear-slot offers in one dropdown, and the same code again. The slot goes back on the board immediately, the member is DMed and told who removed them, and on a sheet-backed operation the cell is restored to [] <Insert Name>. Neither can be undone, so both ask first.
Withdrawing is not a denial: nothing is written to #approval-archive and the member is not told a reason. Use ❌ Deny when the answer is no, and 🧹 Withdraw when the request should simply stop being in the queue.
Putting somebody on a slot outright is the box at the top — the same as /assign-slot. Type who (a name, an @mention, or a Discord ID), pick a free slot, and they are on the roster with no request and no approval. There is no member dropdown because the bot deliberately does not hold a copy of your member list; a name is searched for when you submit, and if it matches more than one person you are told who, with their IDs.
A Unit Leader can only assign members of their own unit, and needs a unit role themselves — slightly stricter than deciding a request, because choosing who goes on the roster is not the same as answering somebody who asked.
Requesting a slot is still Discord-side — this page is for deciding requests, not making them.
🎖️ Operations is a group of four pages, with a second row of tabs under the main one. Operation is the admin half of the slot system, and everything on it has a slash command behind it doing exactly the same thing.
The page opens on what matters most weeks — which operation is running, how full it is, and when it starts. Everything else is a row you click to unfold, so the page stays short:
| On the page | Same as | What it does |
|---|---|---|
| Start an operation | /setup-slots | Archives the operation running now, loads an ORBAT or a Google Sheet as the new one, and posts the live board |
| Start time | /set-event-time | Moves the start and re-arms the reminder, so it fires again for the new time |
| Post the live board | /post-orbat | A fresh board in the channel you pick. It becomes the one that updates; the previous one stops |
| Post an announcement | /post-event | The "we play at 19:00, sign up here" message, linking to the ORBAT channel |
| Empty the queue | /clear-requests | Cancels every request still waiting. Nobody is DMed and nothing is archived — this resets a queue, it does not turn people down |
| The roster as the bot reads it | /debug-slots | Every slot with the key it is booked against — for when one is missing from the board |
The header is /current-operation: which operation is live, whether it runs on an ORBAT or a sheet, and how many slots are open, pending and filled.
The last tab in the group, for the two things you set once rather than every week.
Timezone — /set-timezone. What a time typed anywhere on this site or into a command means. The Discord messages localise themselves for each reader either way, so this is about input only.
Where the bot posts is now yours to choose. Three channels, each with a Default option that is selected until you change it:
| Channel | Default | Used for |
|---|---|---|
| ORBAT board | #orbat | The live board, and the reminder ping before an operation starts |
| Slot approvals | #slot-approvals | Where a new request goes to be decided |
| Approval archive | #approval-archive | The record of every request that was decided |
Leaving one on Default means exactly what the bot did before this setting existed: it uses the channel of that name and creates it if it is missing. Nothing changes for a server that never opens this form. If you pick a channel and later delete it, the bot falls back to the default name rather than posting nowhere — and the form says so.
/sync, /restart and /archive-old-approvals stayed slash commands: they are bot maintenance and a one-off migration rather than parts of running an operation.
The slot roster, written out on a page instead of kept in a Google Sheet. Manage Server only, under the 🗺️ ORBATs tab.
An ORBAT is a template: the same one is meant to back as many operation nights as you like, which is what duplicating the sheet used to be for. Copy takes the structure and none of the bookings.
Which ORBAT is live is marked in the list with a red 🔴 chip naming the operation running on it — only one can carry it, because a server has one active operation at a time. Opening that ORBAT in the editor says the same thing at the top, since an edit there changes tonight's board, and the Start an operation dropdown on the Operation tab marks it too.
Deleting one is refused while an operation is running on it — say so with /setup-slots for the next operation first. Once it is no longer the live one, deleting takes its squads and slots with it and everybody booked into it comes off the roster, the same as deleting their slot in the editor would.
Renaming and copying. The Name and description form under the editor renames an ORBAT; the description is only ever shown in the list, to tell two similar rosters apart. Duplicate gives you a copy of the squads, slots and nets — and none of the bookings, which is the point: it is how you build next week's roster from this week's without touching the one tonight's operation is running on.
You write the roster as indented text — squad at the left margin, its slots indented under it:
1-0 Platoon HQ | left
Platoon Leader
Platoon Sergeant
1-1 Alpha | left, unit:TFP, radio:343 CHN:3
Squad Leader
Team Leader
Automatic Rifleman
Rifleman
Reservists | right, nocount
Reserve
Everything after the pipe belongs to the squad, and several options are separated by commas — | left, unit:TFP, radio:343 CHN:3:
left / right put it in that column of the boardunit:TFP marks the whole squad as one unit's — the tag is the unit's role name, and you get a warning if it matches none of themradio:343 CHN:3 is the channel that squad talks on internally, shown under its name on the boardLeaving a comma out still works — | left unit:TFP is read as both — but the comma is the form to rely on.
nocount leaves it out of the open/filled counts (what Reservists gets today)A line starting with # is a comment, and a leading number — 1. Rifleman — is removed, so lines pasted straight out of a sheet work.
Under the roster there is a second, smaller box for the radio nets everyone shares — the platoon net, logistics, air, high command. One per line:
Platoon Net | 152 CHN : 1
Logi | 152 CHN : 2
-Air Net | 152 CHN : 3
High Com Net | 152 CHN : 4
A line starting with - is a net that exists in the plan but is not in use this time; it shows struck through, the way you would cross it out on paper.
Preview shows the board exactly as Discord would render it, without saving. It also warns you before you hit a limit Discord enforces silently: more than 8 rows of squads, a squad too long for one field, or an embed over 6000 characters. Save writes it.
Editing an ORBAT never quietly drops anyone. Renaming a slot keeps whoever is booked into it; reordering lines changes nothing at all. Anything that would take someone off the roster — or move them onto a differently named role — stops at a confirmation page that names them and which operation they are in, and you have to click Save anyway.
At the bottom of the editor there is an Export to a Google Sheet form. Paste the sheet's URL and the roster is written into a new tab, named after the ORBAT and the time. An existing tab is never touched, so you cannot overwrite a sheet another operation is running on; a second export with the same name gets a (2) suffix instead of replacing the first.
Tick Include who is booked in to export the current operation's board rather than the empty roster. That only works while the active operation actually runs on this ORBAT.
The layout is the one the bot itself understands — squad header, 1. Squad Leader beside [] <Insert Name> — so the tab looks like the sheets you already keep by hand, and if you ever move it to first position the bot can read it as a sheet-backed operation again.
It is strictly one-way: nothing is read back, no link is stored, and there is no sync. The sheet has to be shared with the service account as an editor, which means this is the one ORBAT feature that does need GOOGLE_CREDENTIALS.
Once the roster is written, start an operation on it from Discord:
/setup-slots orbat:Platoon ORBAT event_time:14/09/2026 19:00
orbat autocompletes over the ORBATs on your server. From there everything works as it always did — /request-slot, the 📋 Request a Slot button, the approval buttons in #slot-approvals, /assign-slot, /clear-slot — except that no Google Sheet is involved anywhere. The board in #orbat shows each squad's unit and radio channel and the shared net list underneath.
The old way still works: give sheet_url instead of orbat and the operation is sheet-backed exactly as before. You give one or the other, never both.
Two things get better on the ORBAT side. Approving is instant, because there is no spreadsheet to write to and therefore nothing that can fail halfway and need re-approving. And the board no longer re-reads a Google Sheet on every refresh.
Still Discord-side: requesting and approving slots. Clicking a slot on the web page is not built yet, and neither is entering somebody who is not on your Discord.
#slot-approvalspython -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt
cp .env.example .env # fill in your values
python bot.py
You will need a PostgreSQL instance running locally and DATABASE_URL set in your .env — .env.example has an entry for it, since this is the one setup where neither Railway nor docker-compose provides it. No manual command sync is needed — the bot syncs slash commands to all guilds automatically on startup.
To work on the web UI locally, add DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET and WEB_SECRET_KEY to your .env, leave WEB_BASE_URL empty so the callback URL is taken from the request, and register http://localhost:8080/auth/callback as a redirect URI in the Developer Portal. WEB_PORT changes the port. The bot has to be in a server with you for anything to show up.