Skip to content
7CavPublic

About

electric boogaloo. Cavbot 1 made obsolete by discord so rewriting it from the ground up

Resources

Stars

1 star

Watchers

1 watching

Forks

Repository files navigation

Build Test Deploy

CavBot2 Readme

A Discord bot built for the 7th Cavalry Gaming Regiment using Go and DiscordGo, enabling various functions for 7Cav members.

Prerequisites

  • Go 1.21 or later. The golang tag of the Dockerfile's builder stage names the Go that CI, the gate and the image run on, and the gate has Go download it on first use. A plain go build or go test runs on the Go you have, or on 1.26.0 if yours is older.
  • golangci-lint at the version CI pins in GOLANGCI_LINT_VERSION (.github/workflows/build_test.yml), built with a Go at least as new as the one the Dockerfile's golang tag names.
  • A C compiler (gcc/clang) if you want to run the tests with -race
  • ffmpeg on PATH, built with libopus, to build a recording's mix. The Docker image installs it. Without it the mixer's contract test skips, except in CI, and a recording's mix fails while its tracks stay downloadable.
  • Docker, only if you want the container path in step 4

Commands

Command Purpose
/milpac Return a user's milpac
/zulu Current Zulu time, or a given Zulu time shown in each viewer's own local time
/gamertag_search Search for a user by gamertag
/awol AWOL troopers for a position
/loa Active, upcoming and needs-review LOAs for a position
/afsm Users eligible for the AFSM in a department
/s3aar Disabled due to disuse
/s6-it-check S6 IT members eligible for full status
/foxhole Add, remove, bulk-add or purge the Foxhole roles
/foxhole-bulkadd-internal Add a validated unit's roster to the Internal Foxhole role
/warden, /warden-bulkadd-internal The old names of the two commands above, kept until a cleanup release on or after 6 November 2026. They run the same code, and each run under them ends with a note naming the new command and that date
/helpline Crisis and mental health support resources, optionally addressed to a member
/enlist The enlistment process, with a link to the application
/voice-rename Rename the spawned voice channel you are in, optionally making it a knock channel (🚦)
/voice-lock Lock the spawned voice channel you are in to the people inside it and the hub's moderators, and post a lock notice in its chat with Unlock and Let someone in buttons
/voice-unlock Unlock the spawned voice channel you are in
/record start, /record stop Start recording the voice or stage channel you are in, with an optional title of up to 100 characters, and stop it. A recorder joins the channel at the start and leaves at the stop, and the recording notice then links to the recording on the panel's Recordings page, and the starter gets the link by DM as well in a spawned channel, or instead when the notice's channel is gone. Needs a recording role and a rank role, both set on the panel's Settings page

The registered set lives in commands/registry.go — update this table when it changes.

/foxhole and the panel's Foxhole page match names differently. /foxhole add, remove and bulkadd send a name to Discord's member search, which matches the start of a name, and refuse a name with more than one hit. The page's paste box reads the bot's own member list and needs a whole username, server nickname or global name, ignoring case. When a line matches two to five members it asks the manager to pick one. So the same name can find a member on one path and miss on the other, and an ID or mention is the one form both read the same way. bulkadd takes at most 50 entries, and the paste box has no cap.

The two paths never change the roles at the same time. While an action started on the Foxhole page runs, /foxhole and /foxhole-bulkadd-internal refuse and change nothing. The reply names the action, who started it and how far it has got. While one of those commands runs, the page names it and refuses to start an action. Two commands still run together.

Setup

1. Discord application

At https://discord.com/developers/applications:

  1. 'New Application', then open the 'Bot' tab.
  2. 'Reset Token' and copy it. This is DISCORD_TOKEN. It is only shown once!
  3. On the same tab, enable the 'Server Members Intent' under Privileged Gateway Intents. The bot requests IntentsGuildMembers and will not start without it. A guild is a Discord server.
  4. Create (if you don't have one already) a Discord server that will serve as your test environment for the bot.
  5. Under 'OAuth2 -> URL Generator', select the bot and applications.commands scopes, then the permissions below, then open the generated URL to invite the bot to your test environment server.
Permission Needed for
View Channels, Send Messages, Embed Links, Attach Files Every command's response
Manage Roles /foxhole adds, removes and creates roles; /foxhole-bulkadd-internal adds them
Manage Channels /foxhole sets per-channel permission overwrites

applications.commands is what allows slash commands to register. Discord will not let the bot grant a role positioned above its own, so drag the bot's role high in the server's role list before testing /foxhole.

Recorder applications

A recorder is the account that joins a voice channel to record it (GLOSSARY.md), and each one is a Discord application of its own, separate from the bot's. Recording stays off until at least one recorder token is set in RECORDER_TOKENS, and removing every token and restarting turns it off again. Skip this on a host that doesn't record. For now a recorder joins the channel at /record start and leaves at /record stop. The bot writes one track per speaker and an info file under RECORDINGS_DIR, then builds the recording's mix with ffmpeg (#381). Temporary voice channels ignore it.

  1. 'New Application', named for what it is, such as CavBot Recorder, so members can tell it from the bot.
  2. On the 'Bot' tab, 'Reset Token' and copy it into RECORDER_TOKENS. Leave every Privileged Gateway Intent off. A recorder asks only for the guilds and voice states intents (ADR 0014).
  3. Under 'OAuth2 -> URL Generator', select the bot scope and the Administrator permission, then open the generated URL to invite the recorder to the same server as the bot. It holds Administrator like the bot, since its token sits in the same environment (ADR 0014).

Another recorder is another application, its token added to RECORDER_TOKENS after a comma, with no code change. Two recorders record two channels at once, never one channel twice.

2. IDs

Enable 'Developer Mode' in the Discord client ('User Settings' -> 'Advanced'), then right-click a server or channel and choose Copy ID.

Commands register per guild, so GUILD_ID must be the server you are testing in. Guild commands appear immediately.

3. Environment

Copy the template — keep .env.example in place, it is tracked:

cp .env.example .env

Startup panics without these three:

Variable Source
DISCORD_TOKEN Developer Portal -> Bot -> Reset Token
GUILD_ID Right-click the server -> Copy Server ID
BM_TOKEN BattleMetrics -> Account -> Developers. Only /s3aar uses it; any non-empty placeholder works otherwise.

Not checked at startup, but each one silently disables something:

Variable Effect if unset
BEARER API token for api.7cav.us. Every milpac lookup fails with no startup error. Check this first if /milpac, /awol or /afsm come back empty. The startup rank ladder check also needs it and logs Rank ladder check skipped, ranks fetch failed once without it. /awol and /loa also need the token to hold the read:loas scope. Without it /awol reports raw inactivity with a warning that no LOA days were subtracted, and /loa says LOAs couldn't be read.
CAV_API_BASE_URL Lookups go to the public address, https://api.7cav.us. Set it to the 7Cav API's origin, meaning its scheme, host and optional port, to send them somewhere else. Production's stack sets http://7cav-api:11000, the API's own container on the production host, so lookups never leave the host for Cloudflare. Run names the networks that carry them. The bot adds /api/v1 itself and drops a trailing slash. A value with a path, user info, a query or a fragment, a scheme other than http or https, no host, or one that doesn't parse leaves lookups on the public address. The bot then logs CAV_API_BASE_URL rejected with the value, sends one Sentry event, and keeps running. Every start logs the address in use as 7Cav API address, which shows whether a value in .env reached the container.
FOXHOLE_ROLE_BASE_NAME Defaults to Verified Foxhole, what the roles are named in Discord now. Every /foxhole subcommand composes its role names from this and matches Discord exactly, so a value that doesn't reproduce the role name character for character fails all of them with role not found and changes nothing. The resolved value is logged at startup as Foxhole role base name resolved. Until the cleanup release that drops the old command names, the bot also reads the old variable, WARDEN_ROLE_BASE_NAME, when this one is unset or empty, and logs a warning naming it at startup.
LOG_LEVEL Defaults to INFO. Accepts DEBUG, INFO, WARN, ERROR — uppercase only, anything else silently means INFO (including the default that .env.example ships).
DISCORDGO_LOG_LEVEL Defaults to ERROR, so discordgo reports only its own failures. Accepts ERROR, WARN, INFO, DEBUG; anything else means ERROR. WARN adds the frame the gateway sent when startup fails with Discord session unavailable. DEBUG also needs LOG_LEVEL=DEBUG, and logs every gateway event discordgo does not recognise with its full payload.
SENTRY_DSN Sentry stays off; the bot logs Sentry disabled (SENTRY_DSN not set).
BOT_DB_DSN The bot's own Postgres store (hubs, spawned channels and the change log for temporary voice channels, the Foxhole page's notes, approvals and change log, and the pages of each long /loa or /awol list for 30 days) stays off; the bot logs BOT_DB_DSN not set, bot store disabled once, every other command works as before, and /voice-rename, /voice-lock, /voice-unlock, /record and /winged-aviator refuse every invocation. /loa and /awol show only the first page of a long list, with its arrows greyed out and a line saying the other pages couldn't be kept. .env.example leaves it empty on purpose. Set it and the bot pings the database with a short retry, runs its migrations, loads the hub rows for temporary voice channels (Starting temp voice channels), ends any Foxhole action the last run left running as stopped by a restart (Foxhole action stopped by a restart), and only then opens the Discord session; a database it cannot reach or migrate stops the bot with Bot store unavailable. With no hub rows the feature does nothing; rows arrive through the panel. Under compose the value is postgres://cavbot:<POSTGRES_PASSWORD>@postgres:5432/cavbot?sslmode=disable.
PANEL_ADDR The panel, the bot's web UI, does not listen; the bot logs PANEL_ADDR not set, panel disabled once. Production's stack sets it to :8080. Once it's set, every other PANEL_* variable but PANEL_GROUP_IDS is required; a missing one stops the bot with Panel misconfigured before the Discord session opens. The panel also needs BOT_DB_DSN, because the hub page reads the store on every load; without it the bot logs BOT_DB_DSN not set, panel disabled and runs with no panel. The listener starts after READY and logs Panel listening. .env.example documents each PANEL_* variable.
FOXHOLE_GROUP_ID Defaults to 323, the forum's Foxhole group. A forum user in it, as primary or secondary group, is a Foxhole manager: sign-in takes them to the panel's Foxhole page, and the hub page and its saves refuse them. Panel admins open the Foxhole page whatever their groups. The group check reads it on every request, so adding or removing a manager on the forum needs no deploy. With PANEL_ADDR set, a value that isn't a number stops the bot with Panel misconfigured.
RECORDER_TOKENS Recording stays off, and nobody can record; the bot logs RECORDER_TOKENS not set, recording off once, and /record start replies that recording is off. /record stays in the command list either way, so it keeps its Server Settings roles. Set it to a recorder token, or several separated by commas (Recorder applications), and each recorder connects at startup, logs Recorder connected with its user ID, and shows online. Removing every token and restarting turns recording off again. A recorder whose first login fails logs Recorder login failed, trying again and keeps logging in while the bot runs. One whose token Discord refuses goes to Sentry as Recorder failed to connect, and the bot and the other recorders keep running.
RECORDINGS_DIR Where recordings go: a directory per recording, named by its ID, holding one Ogg Opus track per speaker, named by the speaker's Discord ID, the info file info.txt, and the mix mix.opus once ffmpeg has built it. A mix ffmpeg can't build goes to Sentry as Mix failed, and the tracks stay downloadable. Defaults to recordings under the working directory. docker-compose.yml sets it to /recordings, the mount point of the cavbot2_recordings volume, and doesn't read it from .env, so the tracks always land on the volume and survive a redeploy. A directory the bot can't write to goes to Sentry as Track not written at a speaker's first words, and the recording keeps running without that track. Each recording's directory is deleted with its row 30 days after the recording stopped, by the retention sweep, which runs at startup and every hour, whether or not a recorder token is set.
WINGED_AVIATOR_ARMA_ROLE_ID, WINGED_AVIATOR_DCS_ROLE_ID That game's winged aviators stay off: the bot logs WINGED_AVIATOR_ARMA_ROLE_ID not set, Arma winged aviators off or WINGED_AVIATOR_DCS_ROLE_ID not set, DCS winged aviators off once and tracks nobody for it, and /winged-aviator replies that they aren't set up. /winged-aviator stays in the command list either way, so it keeps its Server Settings roles. Set each to its Winged Aviator role's ID (right-click the role, Copy Role ID), and the role follows each of its winged aviators' milpacs: the bot takes it on a discharge, gives it back on a re-enlistment or a rejoin, and checks every winged aviator at startup and once an hour. The first start with one set makes every holder of that role a winged aviator. Each also needs BOT_DB_DSN, which keeps the lists, and the bot's highest role above the Winged Aviator roles.
POSTGRES_PASSWORD Read by the postgres service in docker-compose.yml, not by the bot. .env.example ships change-me; a blank value makes the image refuse to start and the bot wait on its healthcheck forever. Only the first boot of an empty volume reads it.
APP_ENV Only tags Sentry events with an environment. No effect unless SENTRY_DSN is also set.

When adding a new variable, add it to both .env.example and the environment: block in docker-compose.yml. Compose does not pass through variables that are not listed, so skipping the second step means the value never reaches the container.

4. Run

Locally. The bot reads .env from the working directory at startup, so having filled it in is enough:

go run .

Or build and run the binary:

go build -o cavbot2 . && ./cavbot2

Anything already exported in your shell wins over the file, which is how the container gets its configuration. If you export DISCORD_TOKEN and then wonder why editing .env changes nothing, that is why.

In Docker. The repo's docker-compose.yml is for running the bot locally. Compose reads .env itself and injects the values. .dockerignore keeps .env out of the image, so the image carries no secrets. The service names an image: and has no build:, so Compose won't build the image. Build it first:

docker build -t cavbot2:latest .
docker compose up

The file declares no external network, so there's no network to create first. The bot and its Postgres share cavbot2_internal, which docker compose up creates and docker compose down removes.

Production doesn't run this file. Its stack lives on the production host, and the maintainer keeps it there. A deploy sends that host only a version tag, and the host pulls 7cav/cavbot2:<tag> into its own stack. There the bot joins two networks this file leaves out:

Network What it carries for the bot
edge Nginx Proxy Manager's requests to the panel on port 8080. Production's stack doesn't publish the port, so the proxy is the only way in. Also a route to the 7Cav API.
xenforo_internal A route to the 7Cav API. It also carried the forum database connection until the bot stopped reading that database.

The 7Cav API's container, 7cav-api, is on both networks, so CAV_API_BASE_URL=http://7cav-api:11000 reaches the API over either one without leaving the host. Dropping xenforo_internal from production's stack leaves lookups working over edge.

Compose also starts a postgres:18-alpine service for the bot's own store, with its data in the cavbot2_pgdata volume. The bot waits for its healthcheck before it starts. A Postgres minor update is a manual docker compose pull postgres && docker compose up -d postgres.

A healthy startup logs, in order: Logger initialized, Sentry disabled (SENTRY_DSN not set), CavBot2 starting, either BOT_DB_DSN not set, bot store disabled or Bot store configured followed by Bot database migrated, then RECORDER_TOKENS not set, recording off or a Recorder connected line per recorder, then Starting temp voice channels when the store is configured, Panel listening when the panel is configured, Registering commands, Commands registered, Starting Star Citizen joiner report scheduler, and finally Bot is now running. Press CTRL-C to exit. That last line is the success signal — anything that stops earlier is a failed start.

Registering the commands takes under a second, since the registry goes to Discord in one request. Discord rate-limits that request, so after restarts seconds apart it can hold one for up to about a minute. A silent console on Registering commands is then normal, not a hang. The panel already listens by then, and a stop still shuts the bot down cleanly.

In production the image comes from a GitHub Release, which pushes 7cav/cavbot2:<tag>, then deploys that tag to the host over SSH. GitHub records the result as a deployment in the production environment. A prerelease pushes and stops. To redeploy or roll back, run the workflow by hand with a tag Docker Hub already holds. Before it touches the host, a deploy waits while a recording is running, polling the panel's /recording-status, which says only whether one is. It fails after five and a half hours of waiting. To deploy past a recording, cancel the waiting run and run the workflow by hand with the same tag and force checked. After a deploy succeeds, the workflow records it in Sentry as the release cavbot2@<tag> with the commits since the previous release, so a Fixes CAVBOT2-N line in a commit resolves that Sentry issue once its release deploys. The bot sends the same name with its events; only Sentry sees the prefix. That step reads an organization auth token from the SENTRY_AUTH_TOKEN repository secret, and without it the run ends red after a good deploy. The reverse proxy in front of the panel has a 90-second read timeout. A hub or Foxhole page load takes about 20 seconds at the slowest: the 10-second group check, then the page's 10-second time budget. The budget covers the page's store reads and any wait for Discord to send the bot the guild's data, or on the Foxhole page the member list. A roster preview's fetch of the unit's roster from the 7Cav API counts against the same budget, and its Confirm fetches the roster again under a budget of its own. A save can take longer. It waits at most the same 10 seconds for that data, and its create or rename goes to Discord with up to 20 seconds per attempt. discordgo retries a call Discord answers with a 502, up to three times. A Foxhole action, such as a purge, runs in the background and its request redirects at once, so the proxy never cuts one off however long it runs. Keep the proxy's read timeout above the slowest request. If the proxy drops a page before the panel gives up on it, the page looks like an abandoned page load and reaches no one.

One thing that is not a command

main.go starts a weekly Star Citizen joiner report unconditionally, with no env var to disable it. It fires Sundays at 04:20 UTC and DMs a hardcoded production user ID (commands/star_citizen_joiners.go). Against your own test guild the DM simply fails, since the bot shares no server with that person. If you point GUILD_ID at the live 7Cav server and leave the bot running over a Sunday, a real person gets your test output. Prefer a test guild.

Troubleshooting

Symptom Likely cause
Panic naming DISCORD_TOKEN, GUILD_ID or BM_TOKEN The variable is blank in .env, or you are running from a directory that has no .env
Found .env but could not load it The file exists but is malformed — usually an unquoted value containing #, or a stray line with no =
Error opening connection: websocket: close 4004 Discord rejected the token. Re-copy DISCORD_TOKEN — a truncated paste or a token reset since you last copied it both land here
Error opening connection: websocket: close 4014 Disallowed intent. Enable the Server Members Intent in the Developer Portal
Bot database not answering, retrying nine times, then a panic Bot store unavailable BOT_DB_DSN names a Postgres that is not there. The compose host postgres resolves only inside compose; for go run . blank the variable or point it at a local server
Panic Bot store unavailable: open bot database: the DSN does not parse BOT_DB_DSN is malformed. The value is not echoed because it carries a password; compare it against the form in .env.example
A panic line ending [recovered, repanicked], its stack trace starting in StartWatch.ReportFailure Expected for a failed start with SENTRY_DSN set. The bot caught the panic to send it to Sentry, then let the same panic go on. The frames below panic(...) name the step that failed
Failed start already sent to Sentry just before the panic The bot failed to start with the same message less than an hour ago, and that failure went to Sentry at the logged sent_at. While a restart loop keeps failing with one message, that message goes to Sentry at most once an hour, so the loop can't use up the org's Sentry quota. A start that reaches running clears the record, and recreating the container clears it too
Recorder failed to connect at startup, with recorder=N, and a Sentry event when SENTRY_DSN is set Discord refused the Nth token in RECORDER_TOKENS, usually one reset since you copied it. Reset it on that recorder application's 'Bot' tab and paste the new one. The bot and the other recorders keep running without it
Gateway session offline and can't reconnect goes to Sentry with session=main, or with session=recorder, recorder=N and, once that recorder has logged in since startup, user_id That session's gateway connection has been down for a minute, or that recorder has been offline that long, since its logout after a cut-short recording or since its first login at startup. If the log has Discord refused the gateway session's token for session=main, or Discord refused the recorder's token, recorder out until a restart with the same recorder=N, a row below applies. Otherwise discordgo or the recorder is still retrying, so check Discord's status. One outage sends one event however long it lasts, and Gateway session back online in the log marks the reconnect
Discord refused the gateway session's token in the log, with session=main Discord closed the bot's running session with 4004 because its token no longer works, as after a reset in the Developer Portal. The bot stops logging that session in until it restarts, and sends Gateway session offline and can't reconnect a minute later. If Discord refuses the token at startup instead, the bot fails to start with Discord session unavailable. A recorder whose token Discord refuses logs Discord refused the recorder's token, recorder out until a restart, or goes to Sentry as Recorder failed to connect at startup. Put the new token in .env as DISCORD_TOKEN, and restart
Recorder login failed, trying again in the log, a WARN with recorder=N, user_id and retry_in Recorder N couldn't log in to Discord's gateway, at startup or as it logged back in after a cut-short recording. It tries again after retry_in, which doubles after each failed try up to 10 minutes. Check Discord's status. Recorder connected with the same recorder=N marks the login. Until then a start that takes it waits for it
Recorder logout timed out, logging in again without it in the log, a WARN with recorder=N and user_id Recorder N's logout after a cut-short recording took longer than 5 seconds. Its next login went ahead without waiting for it, so the recorder still comes back
Discord refused the recorder's token, recorder out until a restart in the log, a WARN with recorder=N and user_id Discord closed recorder N's session with 4004 because its token no longer works, as after a reset in the Developer Portal. That happens while the session runs, or as the recorder logs back in after a cut-short recording or a failed first login at startup. It stops logging in, and starts skip it until the bot restarts. A recording it's already in goes on until its stop or a cut short. Sentry hears Gateway session offline and can't reconnect for it a minute after it went offline. Reset the token on that recorder application's 'Bot' tab, put it in recorder N's place in RECORDER_TOKENS, and restart
/record start replies that the recorder is coming back online The start took a recorder that was logging back in after a cut-short recording, and it wasn't back within 30 seconds. Sentry hears nothing. Try again in a minute. If it keeps happening, look for Recorder login failed, trying again in the log
/record start replies that something went wrong with the recorder, and Recorder failed to join goes to Sentry with recorder and channel_id Discord didn't let the recorder into the channel within 30 seconds. Check the recorder is in the server and holds Administrator (Recorder applications). The recorder is free again for the next start
Voice connection lost mid-recording goes to Sentry with recorder and the close that started it disgo couldn't keep the recorder's voice connection: the voice gateway closed and a fresh connection failed, Discord removed the recorder from the channel (close 4014: moved, kicked, the channel deleted, or its gateway session dropped), or Discord ended the call because someone deleted the channel (close 4022). Audio stops reaching the tracks. A brief voice reconnect that succeeds sends nothing. Neither does a region change, which moves the call to another voice server. The recorder rejoins the channel there, and the recording goes on
DAVE decrypt failures goes to Sentry with recorder, user_id and frames At least half a second of that speaker's audio in a row failed end-to-end decryption while other speakers' audio came through, so their track is missing it. The event goes out once for each such run, when another speaker's audio decrypts. DAVE decrypt recovered in the log, with frames_lost, marks when it came back
Voice packets unreadable in the log, a WARN with recorder Half a second of the channel's packets in a row failed transport decryption, so no speaker's audio reached the tracks. When 175 packets or frames in a row fail and none decrypts, the recorder opens a fresh voice connection. If the recording still decrypts nothing 10 seconds after the failures started, counting from the last break of 200 ms or more between them, the bot cuts it short
DAVE setup stalled in the log, a WARN with recorder and channel_id The recorder's end-to-end encryption setup held every frame for 3 seconds, so it heard nobody. It starts the setup over on a fresh voice connection, and DAVE setup recovered, with unheard, marks when it heard the channel again. A stall that lasts 10 seconds cuts the recording short
Voice media watch never armed in the log, a WARN with recorder, channel_id and voice_server The media watch has the recorder rejoin the channel when nothing comes from the voice server. It arms at the connection's first receiver report. A voice server sends one every second, but none came in the 5 seconds after the recorder connected with DAVE ready. Until one comes, the recorder records silence instead of rejoining if the voice server sends nothing more. Each voice connection logs this at most once, and the recording goes on. One line needs nothing. If every connection logs it, Discord has stopped sending receiver reports, so the watch never arms. File an issue, since the watch needs another signal to arm on
Recording gap ended in the log, an INFO with recording_id, channel_id, recorder, gap_cause and lasted The recorder couldn't hear the channel for lasted. The gap ended when the recorder heard the channel again or someone stopped the recording. Every track is silent for that stretch. gap_cause takes the values the Recording cut short row lists. Nothing needs doing. Over many lines, lasted for each gap_cause shows which cause comes closest to cutting recordings short. A gap that ends as it reaches 10 seconds can still log here with lasted at 10s or a little over, and the bot doesn't cut that recording short. Any other gap that reaches 10 seconds logs no such line and goes to Sentry as Recording cut short, the recorder couldn't hear the channel instead
Recording cut short, the recorder couldn't hear the channel goes to Sentry at error level, or warning for server_deafened, and to the log as an ERROR. It carries recording_id, channel_id, recorder, gap_cause, voice_server, gap_started_at, recorded_for, gateway_session, the cause's own fields and attempts. The event adds close_code when a close or a failed read started the gap, and close_error when the cause is voice_gateway_closed. It adds earlier_attempts when attempts left older steps out, and the log line adds error The recorder couldn't hear the channel for 10 seconds. The bot ended the recording at the gap's start and kept everything before it. It pinged the starter in the channel's chat, and had the recorder leave the channel, log out and log back in. Each gap_cause gets a Sentry issue of its own, whose error text opens with voice: recording cut short: and says what started the gap. voice_gateway_closed means the voice gateway closed or a read of it failed, and closed_server names the server it was on. heartbeat_overdue means the voice server stopped answering heartbeats. heartbeat_interval comes with it, and last_answer says how long before the gap the server last answered. call_moved means the call moved to another voice server. moved_from names the server it left, and moved_to names the one it moved to. moved_to is empty when Discord had named no other server by the 10-second mark. dave_stalled means the DAVE setup stalled, and held_for says how long it held frames. voice_server_silent means nothing came from the voice server for 3.5 seconds, and last_datagram says how long before the 10-second mark the last packet came. nothing_decrypted means frames and packets kept failing to decrypt with none getting through, and speakers_failing, frames_failed and unreadable_datagrams count them. server_deafened means someone server-deafened the recorder. attempts lists the recovery steps the recorder took after the gap started. Each entry gives the step's kind, such as rejoin, dial or fresh_connection, how long after the gap's start it began as at, and how it ended as outcome. The last entry shows where recovery got stuck. For a server_deafened gap the event goes at warning level, so the high-priority issue alert skips it. A person chose the deafen, and no recovery fixes it. The next start lifts a deafen left on the recorder. One cut-short recording needs nothing. The recorder comes back by itself, and the starter can run /record start again. When cut-short recordings keep landing in one cause's issue, read attempts for the step that keeps failing. gateway_session=offline means the recorder's gateway session was down at the 10-second mark, so it couldn't send a rejoin. Check Discord's status
Track not written goes to Sentry with recording_id and user_id The bot couldn't create or write that speaker's track under RECORDINGS_DIR, usually a full disk or a directory it can't write to. The track keeps what reached the disk before, and takes nothing more. The recording lists the speaker with "(track lost)" after their name in its info file and on the Recordings page
Panel milpac lookup failed at sign-in goes to Sentry with username The 7Cav API didn't answer the milpac lookup that tells the panel a forum user's Discord ID. They're signed in, but their Recordings page doesn't show their recordings until they sign in again with the API up. Check BEARER and api.7cav.us
A starter's Recordings page says "No recordings yet." though they started a recording The panel found no Discord ID for them at sign-in: Panel sign-in found no milpac in the log means their forum username has no milpac, and a Panel sign-in line with an empty discord_id means their milpac names no Discord ID
Panel zip download failed goes to Sentry with recording_id The zip couldn't be written to the browser, or a track or the info file that is under RECORDINGS_DIR couldn't be read, usually a file the bot can't read. The browser got a cut-off file. A file that isn't there doesn't fail the zip
Track not on disk in the log, a WARN with recording_id and user_id That speaker's track isn't under RECORDINGS_DIR, though the recording doesn't list it as lost, usually because someone removed the file by hand. The zip and the mix leave it out and carry every other track. A track the recording lists as lost is left out with no line
Recording files not deleted after retention goes to Sentry with recording_id The retention sweep couldn't remove that recording's directory under RECORDINGS_DIR, usually a directory the bot can't write to. Its row stays, so the Recordings page still lists it, and the next hourly pass tries again
Recording row not deleted after retention goes to Sentry with recording_id The retention sweep removed that recording's files, but the bot store didn't delete its row. The next hourly pass tries again
Recordings unreadable for retention goes to Sentry The bot store didn't answer the retention sweep's list of recordings, so that pass deleted nothing. The next hourly pass tries again
/foxhole replies that a Foxhole action is running on the Foxhole page A manager started an action on the panel's Foxhole page, and the role-changing commands refuse until it ends. Try again then, or press Stop on the page's progress block
/foxhole fails with a permissions error Bot invited without Manage Roles / Manage Channels, or its own role sits below the role it is editing
Commands never appear Bot invited without applications.commands, or GUILD_ID is not the server you are in
Every milpac lookup fails BEARER missing or expired
Rank ladder check skipped, ranks fetch failed once at startup BEARER missing or expired, or the 7Cav API unreachable. The 7Cav API address line at startup names the address the bot tried. The check runs once after READY and does not retry
Bot member lacks Administrator at startup, and a Sentry event when SENTRY_DSN is set The bot's role on the guild lacks Administrator. Grant it; the bot keeps running but spawning, handover and /foxhole fail at the next call
Rank ladder drift at startup, and a Sentry event when SENTRY_DSN is set The abbreviations or order of tempVCRankRoles in commands/temp_vc.go differ from the milpacs ranks endpoint. The event lists the positions that differ
Compose says pull access denied for cavbot2:latest The image was never built locally — run docker build -t cavbot2:latest .
golangci-lint stops with used to build golangci-lint is lower than the targeted Go version Your golangci-lint was built with a Go older than the go line in go.mod, which the message calls the targeted Go version. Install a build made with a Go at least as new as the one the Dockerfile's golang tag names, since the gate runs golangci-lint on that Go

Testing

Run the tests:

go test ./...

Run the CI gate, the same script CI's Build job runs. It needs Docker and golangci-lint:

.github/scripts/gate.sh

It lints, checks that go.mod and go.sum are tidy, runs the suite with -race and coverage, checks the coverage floors and builds. The store package's tests need a real Postgres, so the gate starts a throwaway one of its own and removes it on exit. Several checkouts can run the gate at once. It runs on the Go the Dockerfile's golang tag names, whatever Go you have installed, because coverage differs between Go versions. CI and the image's builder run on that Go too, so the Go the tests ran on builds the image production runs. To move to a new Go, change that tag, which is all a Dependabot golang update does. The gate fails when the tag isn't one exact release such as golang:1.27.2, or when go.mod has a toolchain line, which would name a second Go.

CI enforces per-package coverage floors, listed in .github/coverage-floors.tsv. A floor stays within 3 points of its package's coverage. When coverage climbs past that, the floor check prints a RAISE line with the new floor, and the floor goes up in the same PR. A package with tests needs a floor. Without one, the check fails with a NO FLOOR line naming the floor to set. ADR 0005 has the reasoning.

To run the store tests on their own, start a throwaway database. The script prints its DSN, and the container's name on stderr:

dsn=$(.github/scripts/test-db.sh)
TEST_BOT_DB_DSN=$dsn go test ./store/...

Without TEST_BOT_DB_DSN the Postgres tests skip and only the in-memory Fake runs. The tests drop and recreate the public schema before every case, so give them only a database test-db.sh started.

Smoke test

CI never reaches the Discord gateway or a browser, so a change members can see also runs on a test guild before the release that ships it. go run ./tools/smoke up starts this checkout's bot there, with the panel at http://localhost:8080 and a fake forum to sign in through. docs/smoke-test.md covers the rest.

Contributing

Contributions are welcome through issues and pull requests on our GitHub repository.

License

Licensed under the MIT License.

About

electric boogaloo. Cavbot 1 made obsolete by discord so rewriting it from the ground up

Resources

Stars

1 star

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages