| data | ||
| patches | ||
| scripts | ||
| src | ||
| tests | ||
| .env.example | ||
| .gitignore | ||
| bun.lock | ||
| deploy_commands.js | ||
| package.json | ||
| README.md | ||
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-dlpfor 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, andyoutube-nocookiehosts
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:
YTDLP_PATHYT_DLP_PATH- The process
PATH - 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:
DENO_PATHDENO_INSTALL/bin- The process
PATH - The default
~/.deno/bininstallation - 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
- Create a new immutable generation directory.
- Stage yt-dlp, Deno, plugins, and
toolchain.json. - Write the new
active.jsoncontents to a temporary file in the same directory. - Atomically rename the temporary file over
active.json. - 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 --helpcapabilities deno --versionand 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:
- Play a normal public VOD, then play a song using plain search terms.
- Queue another VOD, change volume, and skip.
- 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.
- Activate a new toolchain generation during playback.
- Confirm the current song continues and the next song uses the new generation.
- Point
active.jsonback to the prior generation and test another track. - Stop playback and confirm the bot leaves the voice channel immediately.