No description
Find a file
2026-07-30 01:32:01 -07:00
data initial - game rotation stuff and non-functional voice/music code 2026-03-10 01:32:47 -07:00
patches Stabilize Discord audio playback 2026-07-28 18:01:49 -07:00
scripts Add durable music statistics collection 2026-07-30 01:31:51 -07:00
src Add durable music statistics collection 2026-07-30 01:31:51 -07:00
tests Add durable music statistics collection 2026-07-30 01:31:51 -07:00
.env.example Add durable music statistics collection 2026-07-30 01:31:51 -07:00
.gitignore Add durable music statistics collection 2026-07-30 01:31:51 -07:00
bun.lock Stabilize Discord audio playback 2026-07-28 18:01:49 -07:00
deploy_commands.js initial - game rotation stuff and non-functional voice/music code 2026-03-10 01:32:47 -07:00
package.json Add durable music statistics collection 2026-07-30 01:31:51 -07:00
README.md Add durable music statistics collection 2026-07-30 01:31:51 -07:00

bbois_bot

A Discord bot built with Bun and discord.js. Its audio queue supports direct audio sources and individual public YouTube videos.

Requirements

  • Bun for installing and running the bot
  • A Discord bot token with the Guild Voice States and Message Content intents
  • yt-dlp for YouTube playback
  • Deno 2.3 or newer for yt-dlp's YouTube challenge solver

@discordjs/voice, ffmpeg-static, opusscript, and the DAVE voice library are installed with the project. FFmpeg is therefore not a separate system requirement. The portable opusscript encoder runs in a worker thread rather than on the bot's main event loop.

The supported application runtime is Bun. Music statistics use Bun's built-in bun:sqlite module, so launching the bot under Node is not supported.

Install and run

bun install
bun dev

Required environment:

DISCORD_BOT_TOKEN=your-token
DISCORD_APPLICATION_ID=your-application-id
BOT_COMMAND_PREFIX=!

Optional environment:

DATA_DIRECTORY=./data
MUSIC_STATS_DATABASE=./data/music-stats.sqlite
YTDLP_PATH=/absolute/path/to/yt-dlp
YT_DLP_PATH=/absolute/path/to/yt-dlp
DENO_PATH=/absolute/path/to/deno
DENO_INSTALL=/path/to/deno/install
YOUTUBE_TOOLCHAIN_DIRECTORY=./data/toolchains/youtube
MUSIC_DIRECTORY=/absolute/path/to/music
MUSIC_TIMING_DIAGNOSTICS=1
VOICE_DIAGNOSTICS=1
VOICE_EVENT_LOOP_WARN_MS=75

YTDLP_PATH takes precedence over the compatibility alias YT_DLP_PATH. Voice event-loop delay warnings are always enabled while audio is active. VOICE_DIAGNOSTICS=1 additionally prints the resolved Discord voice dependency report at startup. VOICE_EVENT_LOOP_WARN_MS changes the warning threshold and accepts values from 20 through 5000 milliseconds. MUSIC_TIMING_DIAGNOSTICS=1 emits response timing for every play command. Requests that reach the audio manager additionally emit one playback lifecycle event when they start, fail, or are cancelled; a request rejected during search or metadata validation has no playback event. Events contain a random request ID, guild ID, stage durations, provider, transport, status, and stable error code. They never contain the user's input, media URL, title, or request headers.

Register slash commands after installing the bot, and again whenever their schema changes:

bun run register:commands

Set DISCORD_GUILD_ID while developing to register in one server, where changes appear immediately:

DISCORD_GUILD_ID=your-test-guild-id bun run register:commands

The registration script replaces the application's command set in the selected scope. Without DISCORD_GUILD_ID, that scope is global. Guild registration is recommended for quick testing because guild commands update immediately.

Audio commands

With BOT_COMMAND_PREFIX=!:

!play <YouTube URL, search terms, enabled audio URL, or enabled file path>
!queue (alias: !q)
!nowplaying (alias: !np)
!pause
!resume
!remove <queue position>
!move <from> <to>
!shuffle
!loop off|track|queue
!clear
!skip
!stop
!volume <1-10>
!musichelp

The queue is isolated per guild. Up to four yt-dlp preparations run across the entire bot process, with no more than four from one guild. A preparation is either one pasted-link probe or a bounded search followed by candidate validation. Queue insertion remains serialized in message-arrival order, so a slow preparation does not prevent other preparations from doing useful work and completion order cannot reorder tracks. Admission is capped at eight unprepared requests per guild and two per requester; prepared tracks remain subject to the configurable queue limit. queue displays the current track, playback progress, and up to ten upcoming tracks; it reports any additional tracks and accepted requests awaiting queue insertion. Queue positions are one-based and refer only to upcoming tracks. An accepted play command immediately sends Preparing playback…, then edits that message with the final now-playing, queue, or error result.

pause and resume retain the current source process. remove removes one prepared upcoming track. move and shuffle reorder prepared upcoming tracks without affecting the current track; requests that are still being prepared remain in arrival order. clear preserves the current track while removing prepared upcoming tracks and cancelling later metadata requests. stop clears everything and leaves the voice channel.

Track loop repeats natural completions, while skip bypasses the track loop. Queue loop cycles completed tracks and keeps a manually skipped track in the cycle. Every replay resolves a fresh source using the toolchain generation active at that time; it never retains or rewinds an old stream. Failed sources are not reinserted into the loop. Switching queue loop off removes generated replay entries without removing tracks that had not yet played.

By default, anyone may use the music commands. Commands that change playback require the member to be in the bot's current voice channel. Server managers can restrict mutating commands to a DJ role and restrict all music commands to one text channel with /music-settings. The first accepted request atomically binds preparation to its voice channel; concurrent requests from another voice channel are rejected. AudioManager checks the same invariant again before queue insertion.

Server music settings

/music-settings is a guild-only command. Discord shows it to members with Manage Server permission by default, and the bot rechecks that permission when executing it. Views, changes, validation errors, and exports are ephemeral.

Subcommand Default Effect
view Show all effective settings and the last change metadata.
volume level:<1-10> 10 Set the volume used when a new playback session is created.
idle-timeout minutes:<0-60> 15 Set how long the bot remains connected after playback becomes idle. Zero leaves immediately.
queue-limit tracks:<1-100> 100 Limit current, queued, and accepted preparing requests together.
duration-limit minutes:<1-720> 720 Set a per-server YouTube VOD limit, capped at 12 hours.
access mode:everyone Everyone Allow any member in the active voice channel to change playback.
access mode:dj role:<role> Require the selected role for playback changes. Manage Server bypasses this restriction.
command-channel channel:<channel> Any Restrict all recognized prefix music commands to one text or announcement channel.
voice-channel channel:<channel> Any Restrict new playback sessions to one voice channel.
source source:direct-urls enabled:<boolean> Disabled Allow or reject non-YouTube HTTP(S) audio URLs.
source source:local-files enabled:<boolean> Disabled Allow files contained by MUSIC_DIRECTORY.
announcements mode:current Request channel Send queued-track starts and failures to the channel where each request was made.
announcements mode:fixed channel:<channel> Send queued-track updates to one text or announcement channel.
announcements mode:off Disable queued-track start and failure messages. Command replies still appear.
reset setting:<choice> confirm:true Restore one category or every setting to defaults.
export Download the effective settings as JSON.

Settings are stored per guild at ${DATA_DIRECTORY:-./data}/<guild-id>/music-settings.json. Writes are serialized and atomically replace the prior file. Changes are logged with the guild, actor, and changed categories. Every recognized prefix music command rereads and validates the current on-disk revision before applying channel or DJ-role policy. Mutations validate it again inside the guild update lock before saving. Only a missing file creates defaults. An unreadable, malformed, or schema-invalid file disables music commands for that guild instead of bypassing restrictions or overwriting the damaged file. Repair the file, or move it aside to deliberately regenerate defaults on the next load. Other legacy data files, such as games.json, retain their permissive loading behavior.

The idle timer starts only when there is no current track, no prepared queue, and no accepted request still probing metadata. Pausing a track does not start the timer. A new request cancels an active idle timer and reuses the existing voice connection. stop always clears the session and leaves immediately, regardless of the configured timeout. Changing the idle timeout while a guild is already idle applies the new value immediately.

Direct URLs and local files are disabled by default; YouTube URLs and plain text searches remain enabled. Local playback cannot be enabled until the operator sets MUSIC_DIRECTORY. Requested files and symlinks are resolved to their real path and must remain inside that directory. Direct URL playback allows HTTP(S) locations reachable from the bot host, including redirects. The bot validates the returned media content type, including application/ogg, waits up to 20 seconds for the first byte, and streams the response into FFmpeg without buffering the complete file. Missing content types remain eligible for FFmpeg format detection. Direct URL playback should only be enabled when playback users are trusted.

Music statistics

Music statistics collection is always enabled and remains strictly per guild. It measures music a user requested; it does not inspect voice-channel presence or attempt to determine who listened.

A playback record is created only after audio starts. It stores the guild, requester ID, account-username snapshot, provider, exact provider asset, best-effort video title, track title, artist, album, uploader, channel, and release-year metadata, catalog duration, discovery method, start and end times, active playback time, and terminal outcome. Active time excludes preparation, connection time, idle time, and pauses, and is measured with a monotonic clock so wall-clock adjustments do not distort it. Partial playback before a skip, stop, playback error, or voice disconnect still counts. Tracks removed before starting do not. Original tracks count once; loop-generated replays and loop-mode changes are excluded.

Successful pause, remove, shuffle, skip, and stop actions are stored separately with the acting user. Skip and stop count only when they end an already-started original track. Action records deliberately do not identify the affected track.

YouTube records keep the exact video ID and public canonical URL. This is an asset identity, not a claim that differently uploaded videos are the same song. Direct URLs and local files store only a display title and SHA-256 source fingerprint; raw URLs, query strings, credentials, and filesystem paths are never persisted. Search text is never stored. Metadata enrichment and Wrapped reports are intentionally left for later, so future grouping can be improved without rewriting the original history.

Users manage their own privacy through guild-only ephemeral commands:

/music-stats opt-out
/music-stats opt-in
/music-stats erase confirm:true

An opted-out user contributes neither requested plays nor control actions, including guild totals. Opting back in affects future playback only. Erasing deletes that user's playback and action history from the current guild; it does not change their opt-in state.

The collector uses one SQLite database at ${DATA_DIRECTORY:-./data}/music-stats.sqlite; MUSIC_STATS_DATABASE overrides that location. Writes are immediate prepared statements in WAL mode; there is no collector heartbeat, timer, or in-memory batch. Lock contention fails quickly so statistics cannot stall the audio event loop. Database failures are logged with stable [music-stats] codes and never interrupt playback. An unfinished row left by an abrupt process exit is conservatively discarded at the next startup.

Create a consistent live backup at a new path:

bun run stats:backup --output ./backups/music-stats-2026-07-30.sqlite

The backup command refuses to overwrite an existing file. To permanently purge one guild's playback and action history, repeat its ID as explicit confirmation:

bun run stats:purge --guild 123456789012345678 \
  --confirm 123456789012345678

Guild purges preserve privacy opt-outs and collection resumes immediately. Statistics are otherwise retained until explicitly erased or purged.

YouTube support

!play accepts plain text search terms or an individual public VOD link:

  • youtube.com/watch?v=...
  • Watch links containing a playlist parameter; only the selected video plays
  • youtu.be/...
  • youtube.com/shorts/...
  • youtube.com/embed/...
  • Equivalent www, m, music, and youtube-nocookie hosts

For plain text, yt-dlp requests the top five YouTube results. The bot checks up to three candidates in rank order through the same full metadata and server policy validation used for pasted links, skipping candidates that are live, account-gated, too long, unavailable, or lack playable audio. Search terms must be one line and no more than 200 characters. URL-looking input remains a direct source, and explicit or audio-extension file paths remain local sources, so search does not silently change those existing playback modes.

The maximum duration is 12 hours. Finished livestream archives are accepted when they expose a finite duration. Active livestreams, upcoming premieres, unknown-duration videos, playlist-only links, and channel links are rejected.

Playback and search validation are logged out. Private, members-only, and other account-gated content is not supported. No PO-token provider is bundled. The hotfix mechanism below can activate a managed provider plugin or extractor arguments later without restarting the bot.

The metadata probe asks yt-dlp to select the same best-audio format used for playback. When yt-dlp returns a usable media URL and required HTTP headers, the bot keeps them only in memory for at most five minutes and opens that selected stream directly. This avoids starting a redundant second yt-dlp/Deno process. The single-use lease lives in a private weak map rather than on the durable track, is deleted when opening begins, and is deleted by an expiry timer if the track remains queued. It is bound to the exact immutable toolchain snapshot that created it. A hotfix, rollback, expiry, invalid content type, rejected response, or slow first byte falls back to the normal yt-dlp stdout path. If a lease stream fails after playback starts, the bot makes one recovery attempt by restarting that track through yt-dlp. Manifest extraArguments also select the fallback path so native fetching cannot bypass operator-configured downloader, proxy, TLS, or routing behavior. Media URLs and headers are neither persisted nor included in queue snapshots or timing logs. The bot never downloads media to disk.

On POSIX, each fallback runtime yt-dlp process leads an isolated process group so Deno and other descendants receive termination signals with it. On Windows, cleanup uses process-tree termination. Stop, skip, timeout, and shutdown wait for the child to close after graceful and forced termination attempts.

Voice playback stability

FFmpeg decodes incoming media to PCM in its own process. Volume scaling and Opus encoding then run in a worker thread, keeping the bot's main event loop available for Discord's 20-millisecond packet cadence. Runtime volume changes are forwarded to that worker and apply to subsequent frames.

The maintained baseline uses opusscript, but it no longer performs encoding on the main event loop. Prism can still select another compatible Opus encoder that an operator deliberately installs and audits.

The pinned @discordjs/voice package has a Bun-managed patch under patches/. It prevents overdue frames from being emitted in a compressed burst after an event-loop stall. Treat a voice-package upgrade as an explicit maintenance operation: review upstream scheduling behavior, regenerate or remove the patch, and run the voice cadence test before deploying.

Executable discovery

yt-dlp

src/utils/ytDlpPath.js checks:

  1. YTDLP_PATH
  2. YT_DLP_PATH
  3. The process PATH
  4. Common local-user, Homebrew, MacPorts, WinGet, Scoop, and Chocolatey paths

Only successful discovery is cached. System discovery and compatibility checks begin in the background at startup so the first request normally pays no tool-validation cost. Installing yt-dlp while the bot is already running still works without a restart when no previous path was found.

Deno

src/utils/denoRuntime.js checks:

  1. DENO_PATH
  2. DENO_INSTALL/bin
  3. The process PATH
  4. The default ~/.deno/bin installation
  5. Common package-manager paths

Deno must report version 2.3.0 or newer. Missing tools do not prevent the bot from starting; an actionable error is sent when YouTube playback first needs them.

Hot-swappable YouTube toolchains

The bot polls a small activation manifest every five seconds. Polls are serialized, and the pointer digest is checked again after validation before a generation can activate. A slow validation result therefore cannot overwrite a newer hotfix or rollback. Polling is cross-platform and avoids relying on inconsistent filesystem watcher behavior.

Default layout:

${DATA_DIRECTORY:-./data}/toolchains/youtube/
├── active.json
└── generations/
    ├── 2026-07-28-a/
    │   ├── toolchain.json
    │   ├── bin/
    │   └── plugins/
    └── 2026-07-29-b/
        ├── toolchain.json
        ├── bin/
        └── plugins/

YOUTUBE_TOOLCHAIN_DIRECTORY overrides the root directory.

Activation pointer

active.json:

{
  "schemaVersion": 1,
  "generation": "2026-07-28-a"
}

A generation name may contain letters, digits, dots, underscores, and hyphens. It cannot contain a path separator.

Generation manifest

generations/2026-07-28-a/toolchain.json:

{
  "schemaVersion": 1,
  "ytDlpPath": "bin/yt-dlp",
  "denoPath": "bin/deno",
  "pluginDirectories": ["plugins"],
  "remoteComponents": [],
  "extraArguments": []
}

On Windows, generation-relative paths may name .exe files:

{
  "schemaVersion": 1,
  "ytDlpPath": "bin\\yt-dlp.exe",
  "denoPath": "bin\\deno.exe",
  "pluginDirectories": ["plugins"],
  "remoteComponents": ["ejs:github"],
  "extraArguments": ["--force-ipv4"]
}

Executable paths and plugin directories may be absolute or relative to the generation directory. Omitting an executable path uses normal system discovery.

remoteComponents is translated to repeated yt-dlp --remote-components arguments. Useful current values include ejs:github and ejs:npm. Official yt-dlp executables generally bundle matching EJS scripts, so the default is empty.

extraArguments is a trusted argument array with an explicit allowlist. The supported switches without values are --abort-on-unavailable-fragments, --force-ipv4, --force-ipv6, --geo-bypass, --no-abort-on-unavailable-fragments, --no-check-certificates, --no-geo-bypass, and --prefer-insecure. The supported value-taking options are --add-headers, --concurrent-fragments, --extractor-args, --extractor-retries, --fragment-retries, --geo-bypass-country, --geo-bypass-ip-block, --geo-verification-proxy, --http-chunk-size, --impersonate, --limit-rate, --max-sleep-interval, --proxy, --referer, --retries, --retry-sleep, --sleep-interval, --sleep-requests, --socket-timeout, --source-address, --throttled-rate, --user-agent, and --xff. Values may use either the next array item or --option=value.

Short options, abbreviations, unknown options, external downloaders, and pipeline controls such as output, config, plugin/runtime selection, execution hooks, batch input, format selection, or print/dump modes are rejected.

The toolchain directory is an administrator-only code-execution trust boundary. Do not allow Discord users or an untrusted process to write manifests, executables, plugins, or argument arrays there.

Activation and rollback

  1. Create a new immutable generation directory.
  2. Stage yt-dlp, Deno, plugins, and toolchain.json.
  3. Write the new active.json contents to a temporary file in the same directory.
  4. Atomically rename the temporary file over active.json.
  5. Check the bot log for Activated YouTube toolchain generation ....

POSIX example:

mv data/toolchains/youtube/active.json.tmp \
  data/toolchains/youtube/active.json

PowerShell example:

Move-Item -Force `
  .\data\toolchains\youtube\active.json.tmp `
  .\data\toolchains\youtube\active.json

Activation performs local program checks only:

  • Strict manifest/schema validation
  • Executable and plugin-directory checks
  • yt-dlp --version
  • Required yt-dlp --help capabilities
  • deno --version and the 2.3 minimum

It never contacts YouTube. Invalid content is logged once per distinct pointer revision and the previous generation remains active. Executable validation first requests normal termination on timeout, then force-terminates the process group after a short grace period when the platform supports it.

Each source open captures an immutable generation snapshot. A currently playing track continues with its existing source; a queued track whose probe lease was created by an older snapshot safely falls back through the newly active toolchain. Rollback is the same operation: atomically point active.json at an earlier retained generation. Deleting active.json deliberately returns to system discovery.

If Discord voice reconnection fails after the five-second grace period, the bot sends a failure announcement for every queued request whose announcement mode is enabled before clearing the disconnected session.

The bot never installs, downloads, or upgrades tooling automatically.

Testing

Run the fully offline unit suite:

bun test

Run the deterministic Discord packet-cadence regression:

bun run test:voice

It deliberately stalls the event loop and verifies that playback resumes at normal cadence rather than sending accumulated frames in a speed-up burst. It does not connect to Discord or require network access.

Run an explicit real-network smoke test:

bun run test:youtube

That command permits the durable yt-dlp fallback. To specifically verify that the current default toolchain produces and opens a media lease, run:

bun run test:youtube:lease

The smoke test validates discovery, preparation, actual audio bytes, and process cleanup without connecting to Discord. Override its input with either a public video URL or plain search terms:

YOUTUBE_TEST_INPUT="daft punk get lucky" \
  bun run test:youtube

On PowerShell:

$env:YOUTUBE_TEST_INPUT = "daft punk get lucky"
bun run test:youtube

To verify a direct HTTP(S) audio resource through the production fetch, FFmpeg, and worker Opus pipeline without connecting to Discord, pass its URL to:

bun run test:direct -- "https://example.com/audio.ogg"

The command follows redirects, validates the response media type, decodes the entire response, and fails unless it produces Opus packets.

The legacy YOUTUBE_TEST_URL override remains supported. The network smoke test is intentionally excluded from bun test.

For a manual Discord acceptance pass:

  1. Play a normal public VOD, then play a song using plain search terms.
  2. Queue another VOD, change volume, and skip.
  3. Set a short idle timeout, finish the queue, and confirm the bot leaves only after the grace period. Queue a track during the grace period and confirm the existing connection is reused.
  4. Activate a new toolchain generation during playback.
  5. Confirm the current song continues and the next song uses the new generation.
  6. Point active.json back to the prior generation and test another track.
  7. Stop playback and confirm the bot leaves the voice channel immediately.