# Phase 1 — Implementation > **Goal:** Implement the entire Discord music bot. Every file, every function, every test. TDD flow: write the test first, then the implementation. **No packages may be installed in this phase — everything was installed in Phase 0.** --- ## Architecture Overview ``` User sends "/play bohemian rhapsody" or "!play bohemian rhapsody" │ ▼ ┌──────────────────────────────────────────────────────┐ │ src/index.ts — Bot entrypoint │ │ Creates Discord.js Client, initializes Player, │ │ loads event handlers │ └──────────┬───────────────────────────────────────────┘ │ ┌─────┴──────┐ ▼ ▼ ┌─────────┐ ┌─────────────┐ │ Events │ │ Commands │ │ Layer │ │ Layer │ └────┬────┘ └──────┬──────┘ │ │ │ ┌─────────┴──────────┐ │ ▼ ▼ │ ┌──────────┐ ┌────────────┐ │ │ Slash │ │ Prefix │ │ │ Commands │ │ Commands │ │ └─────┬────┘ └─────┬──────┘ │ └───────┬───────┘ │ ▼ │ ┌──────────────────────┐ │ │ Command Handlers │ │ │ (shared logic) │ │ │ play, skip, queue, │ │ │ clear, nowplaying │ │ └──────────┬───────────┘ │ │ ▼ ▼ ┌──────────────────────────────────┐ │ src/player/ │ │ playerSetup.ts — init Player │ │ queueManager.ts — queue logic │ └──────────┬───────────────────────┘ │ ▼ ┌──────────────────────────────────┐ │ discord-player + youtubei │ │ (search, stream, queue, voice) │ └──────────────────────────────────┘ ``` ### File Manifest Every file that will be created in this phase, in implementation order: ``` src/ ├── utils/ │ ├── config.ts # Step 1.1 — env var loading + validation │ ├── config.test.ts # Step 1.1 — tests for config │ ├── formatters.ts # Step 1.2 — time formatting, embed builders │ └── formatters.test.ts # Step 1.2 — tests for formatters ├── player/ │ ├── playerSetup.ts # Step 1.3 — Player initialization + extractor registration │ ├── playerSetup.test.ts # Step 1.3 — tests for player setup │ ├── playerEvents.ts # Step 1.4 — player event handlers (playerStart, error, empty queue, etc.) │ └── playerEvents.test.ts # Step 1.4 — tests for player events ├── commands/ │ ├── types.ts # Step 1.5 — shared command types (Command interface) │ ├── play.ts # Step 1.6 — /play and !play handler │ ├── play.test.ts # Step 1.6 — tests for play │ ├── skip.ts # Step 1.7 — /skip and !skip handler │ ├── skip.test.ts # Step 1.7 — tests for skip │ ├── queue.ts # Step 1.8 — /queue and !queue handler │ ├── queue.test.ts # Step 1.8 — tests for queue │ ├── clear.ts # Step 1.9 — /clear and !clear handler │ ├── clear.test.ts # Step 1.9 — tests for clear │ ├── nowplaying.ts # Step 1.10 — /nowplaying and !nowplaying handler │ ├── nowplaying.test.ts # Step 1.10 — tests for nowplaying │ └── index.ts # Step 1.11 — command registry (collects all commands into a Map) ├── events/ │ ├── ready.ts # Step 1.12 — ClientReady event handler │ ├── interactionCreate.ts # Step 1.13 — slash command dispatcher │ ├── interactionCreate.test.ts # Step 1.13 — tests for slash command dispatch │ ├── messageCreate.ts # Step 1.14 — prefix command dispatcher │ └── messageCreate.test.ts # Step 1.14 — tests for prefix command dispatch ├── deploy-commands.ts # Step 1.15 — one-shot script to register slash commands with Discord API └── index.ts # Step 1.16 — main entrypoint (wires everything together) ``` --- ## Testing Strategy ### What We Test (Unit Tests) All business logic is tested with mocked Discord.js and discord-player objects. We never connect to Discord or YouTube in tests. | Module | What's Tested | |---|---| | `config.ts` | Validates env vars are loaded; throws on missing required vars | | `formatters.ts` | Time formatting (seconds → `mm:ss`/`hh:mm:ss`), queue embed building | | `playerSetup.ts` | Player is created with correct options; extractors are registered | | `playerEvents.ts` | Event handlers send correct messages on playerStart, error, emptyQueue, etc. | | `play.ts` | Validates user is in voice channel; calls `player.play()`; handles errors | | `skip.ts` | Validates queue exists; calls `queue.node.skip()`; handles empty queue | | `queue.ts` | Formats queue list; paginates if needed; handles empty queue | | `clear.ts` | Calls `queue.delete()`; handles no active queue | | `nowplaying.ts` | Shows current track info; handles no active track | | `interactionCreate.ts` | Routes slash commands to correct handler; ignores non-command interactions | | `messageCreate.ts` | Parses prefix; routes to correct handler; ignores bot messages; ignores wrong prefix | ### What We Don't Test - `index.ts` (entrypoint — thin wiring, no logic) - `deploy-commands.ts` (one-shot script — calls Discord REST API) - `ready.ts` (one-liner log statement) - Actual Discord API connectivity - Actual YouTube streaming ### Mock Strategy Every test file creates lightweight mock objects that satisfy the TypeScript interfaces needed by the function under test. We do **not** use a mocking library — plain objects with `vi.fn()` spies are sufficient. Common mocks needed: ```typescript // Mock interaction (slash commands) const mockInteraction = { isChatInputCommand: () => true, commandName: 'play', options: { getString: vi.fn().mockReturnValue('bohemian rhapsody') }, member: { voice: { channel: { id: '123', guild: { id: '456' } } } }, guild: { id: '456' }, reply: vi.fn(), followUp: vi.fn(), deferReply: vi.fn(), }; // Mock message (prefix commands) const mockMessage = { content: '!play bohemian rhapsody', author: { bot: false, id: '789' }, member: { voice: { channel: { id: '123', guild: { id: '456' } } } }, guild: { id: '456' }, channel: { send: vi.fn() }, reply: vi.fn(), }; // Mock GuildQueue (discord-player) const mockQueue = { currentTrack: { title: 'Bohemian Rhapsody', duration: '5:55', url: 'https://...' }, tracks: { toArray: () => [...], size: 3 }, node: { skip: vi.fn(), stop: vi.fn() }, delete: vi.fn(), channel: { send: vi.fn() }, metadata: { channel: { send: vi.fn() } }, }; ``` --- ## Implementation Steps (TDD Order) Each step follows: **write test → run test (fails) → write implementation → run test (passes)**. --- ### Step 1.1 — Config Module **Purpose:** Load and validate environment variables. Fail fast if required vars are missing. #### `src/utils/config.test.ts` Tests to write: 1. Returns a valid config object when all required env vars are set 2. Throws an error when `DISCORD_TOKEN` is missing 3. Throws an error when `CLIENT_ID` is missing 4. Uses default value `"!"` for `PREFIX` when not set 5. `GUILD_ID` is optional — returns `undefined` when not set #### `src/utils/config.ts` Exports: ```typescript interface BotConfig { discordToken: string; clientId: string; guildId: string | undefined; prefix: string; } function loadConfig(): BotConfig ``` **Logic:** - Read from `process.env` - Validate `DISCORD_TOKEN` and `CLIENT_ID` are non-empty strings — throw `Error` if missing - Default `PREFIX` to `"!"` - `GUILD_ID` is optional --- ### Step 1.2 — Formatters Module **Purpose:** Pure utility functions for formatting durations and building display strings. #### `src/utils/formatters.test.ts` Tests to write: 1. `formatDuration(0)` → `"0:00"` 2. `formatDuration(65)` → `"1:05"` 3. `formatDuration(3661)` → `"1:01:01"` 4. `formatDuration(-1)` → `"0:00"` (clamp negatives) 5. `formatQueueEntry(track, index)` → `"1. **Song Title** - \`3:45\`"` (formats a single queue line) 6. `formatNowPlaying(track, currentTime)` → returns a string with title, URL, elapsed/total time, and a text progress bar 7. `formatQueueList(tracks, currentTrack)` → formats full queue display: now playing + numbered upcoming list 8. `formatQueueList` with empty upcoming tracks → shows only now playing, says "No more songs in queue" 9. `formatQueueList` with no current track and no upcoming → returns "Queue is empty" #### `src/utils/formatters.ts` Exports: ```typescript function formatDuration(seconds: number): string function formatQueueEntry(track: TrackInfo, index: number): string function formatNowPlaying(track: TrackInfo, elapsedMs: number): string function formatQueueList(tracks: readonly TrackInfo[], currentTrack: TrackInfo | null): string ``` Where `TrackInfo` is a minimal interface: ```typescript interface TrackInfo { title: string; duration: string; // human-readable from discord-player (e.g. "3:45") durationMs: number; // milliseconds url: string; } ``` > **Key design decision:** These functions accept plain data objects (`TrackInfo`), not discord-player `Track` instances. This makes them trivially testable without mocking discord-player internals. The command handlers will map `Track` → `TrackInfo` before calling formatters. --- ### Step 1.3 — Player Setup Module **Purpose:** Create and configure the discord-player `Player` instance and register extractors. #### `src/player/playerSetup.test.ts` Tests to write: 1. `createPlayer(client)` returns an object with `extractors` property 2. `initializePlayer(player)` calls `player.extractors.loadMulti` with `DefaultExtractors` 3. `initializePlayer(player)` calls `player.extractors.register` with `YoutubeiExtractor` 4. If extractor registration throws, `initializePlayer` propagates the error (does not swallow it) > **Note:** These tests mock the `Player` constructor and its methods. We don't actually create a real Player. #### `src/player/playerSetup.ts` Exports: ```typescript function createPlayer(client: Client): Player async function initializePlayer(player: Player): Promise ``` **Logic:** - `createPlayer`: instantiates `new Player(client)` and returns it - `initializePlayer`: loads default extractors, then registers the YouTubei extractor --- ### Step 1.4 — Player Events Module **Purpose:** Attach event listeners to the discord-player `Player` that send messages to the text channel when things happen (track starts, queue empties, errors). #### `src/player/playerEvents.test.ts` Tests to write: 1. `onPlayerStart` handler sends a "Now playing: **Title**" message to the metadata channel 2. `onPlayerError` handler sends an error message to the metadata channel 3. `onEmptyQueue` handler sends "Queue finished" message to the metadata channel 4. `onEmptyChannel` handler sends "Leaving — voice channel is empty" message 5. All handlers gracefully handle missing metadata/channel (no crash) #### `src/player/playerEvents.ts` Exports: ```typescript function registerPlayerEvents(player: Player): void ``` **Logic:** - Listens to `player.events.on('playerStart', ...)` — sends now-playing message - Listens to `player.events.on('playerError', ...)` — sends error message - Listens to `player.events.on('emptyQueue', ...)` — sends queue-finished message - Listens to `player.events.on('emptyChannel', ...)` — sends leaving message - Each handler extracts the text channel from `queue.metadata` (which we set to `{ channel }` when calling `player.play()`) **Metadata convention** (used everywhere): ```typescript // When calling player.play(), metadata is set to: interface QueueMetadata { channel: TextBasedChannel; // the channel to send status messages to } ``` --- ### Step 1.5 — Command Types **Purpose:** Define the shared `Command` interface that all command modules implement. #### `src/commands/types.ts` No tests needed — this is a pure type file. ```typescript import type { ChatInputCommandInteraction, Message, SlashCommandBuilder } from 'discord.js'; import type { Player } from 'discord-player'; interface CommandContext { player: Player; } interface Command { /** Slash command definition for registration with Discord API */ data: SlashCommandBuilder | Omit; /** Handle a slash command interaction */ executeSlash(interaction: ChatInputCommandInteraction, context: CommandContext): Promise; /** Handle a prefix message command */ executePrefix(message: Message, args: string[], context: CommandContext): Promise; } ``` > **Why a shared `Command` interface?** Both slash and prefix commands need the same underlying logic. The `Command` interface forces each command to implement both entry points, but the actual logic is shared inside the module. `CommandContext` passes the `Player` instance so commands can access queue state. --- ### Step 1.6 — Play Command **Purpose:** Search for a song/URL and add it to the queue. If the bot isn't in a voice channel, join the user's voice channel and start playing. #### `src/commands/play.test.ts` Tests to write: **Slash command (`executeSlash`):** 1. When user is not in a voice channel → replies with error "You must be in a voice channel" 2. When no query provided → replies with error "Please provide a song name or URL" 3. When user is in voice channel and query is valid → calls `player.play()` with the voice channel, query, and correct metadata; replies with "Queued: **Title**" 4. When `player.play()` throws → replies with an error message containing the error details **Prefix command (`executePrefix`):** 5. When user is not in a voice channel → replies with error 6. When no args provided (`!play` with nothing after) → replies with error 7. When valid → calls `player.play()` with args joined as query; sends confirmation to channel #### `src/commands/play.ts` Exports: `Command` object with `data`, `executeSlash`, `executePrefix`. **Slash command definition:** ``` /play ``` **Shared logic (called by both executeSlash and executePrefix):** 1. Check that the invoking user is in a voice channel → error if not 2. Extract the query string (from interaction option or message args) 3. Validate query is non-empty 4. Call `player.play(voiceChannel, query, { nodeOptions: { metadata: { channel } } })` 5. The `player.play()` return value contains `{ track }` — use `track.title` in the success message 6. Wrap in try/catch — on error, send a user-friendly error message --- ### Step 1.7 — Skip Command **Purpose:** Skip the currently playing track. #### `src/commands/skip.test.ts` Tests to write: 1. When no queue exists for the guild → replies "Nothing is playing" 2. When queue exists → calls `queue.node.skip()`, replies "Skipped: **Title**" 3. When `queue.node.skip()` returns false (nothing to skip) → replies "Nothing to skip" #### `src/commands/skip.ts` Exports: `Command` object. **Slash command definition:** ``` /skip (no options) ``` **Shared logic:** 1. Get the guild's queue via `player.nodes.get(guildId)` 2. If no queue or no current track → error "Nothing is playing" 3. Store current track title for the response message 4. Call `queue.node.skip()` 5. Reply with "Skipped: **Title**" --- ### Step 1.8 — Queue Command **Purpose:** Display the current queue — now playing + upcoming tracks with position numbers. #### `src/commands/queue.test.ts` Tests to write: 1. When no queue exists → replies "Nothing is playing" 2. When queue has current track but no upcoming → shows now playing + "No more songs in queue" 3. When queue has current track + 3 upcoming tracks → shows now playing + numbered list with durations 4. When queue has many tracks (>10) → truncates with "...and N more" #### `src/commands/queue.ts` Exports: `Command` object. **Slash command definition:** ``` /queue (no options) ``` **Shared logic:** 1. Get the guild's queue via `player.nodes.get(guildId)` 2. If no queue or no current track → error "Nothing is playing" 3. Map `queue.currentTrack` and `queue.tracks.toArray()` to `TrackInfo` objects 4. Call `formatQueueList()` from the formatters module 5. Reply with the formatted string --- ### Step 1.9 — Clear Command **Purpose:** Clear the entire queue and stop playback. #### `src/commands/clear.test.ts` Tests to write: 1. When no queue exists → replies "Nothing is playing" 2. When queue exists → calls `queue.delete()`, replies "Cleared the queue and stopped playback" #### `src/commands/clear.ts` Exports: `Command` object. **Slash command definition:** ``` /clear (no options) ``` **Shared logic:** 1. Get the guild's queue via `player.nodes.get(guildId)` 2. If no queue → error "Nothing is playing" 3. Call `queue.delete()` 4. Reply with "Cleared the queue and stopped playback" --- ### Step 1.10 — Now Playing Command **Purpose:** Show detailed info about the currently playing track — title, URL, elapsed time, total duration, and a text progress bar. #### `src/commands/nowplaying.test.ts` Tests to write: 1. When no queue exists → replies "Nothing is playing" 2. When no current track → replies "Nothing is playing" 3. When track is playing → replies with formatted now-playing info including progress bar 4. Progress bar visually represents elapsed time relative to total duration #### `src/commands/nowplaying.ts` Exports: `Command` object. **Slash command definition:** ``` /nowplaying (no options) ``` **Shared logic:** 1. Get the guild's queue via `player.nodes.get(guildId)` 2. If no queue or no current track → error "Nothing is playing" 3. Get elapsed time via `queue.node.getTimestamp()` — returns `{ current: { value: ms }, total: { value: ms } }` 4. Map current track to `TrackInfo` 5. Call `formatNowPlaying()` from formatters 6. Reply with the formatted string --- ### Step 1.11 — Command Registry **Purpose:** Collect all command modules into a single `Map` for lookup by name. #### `src/commands/index.ts` No tests needed — pure wiring. ```typescript import type { Command } from './types'; import { playCommand } from './play'; import { skipCommand } from './skip'; import { queueCommand } from './queue'; import { clearCommand } from './clear'; import { nowPlayingCommand } from './nowplaying'; const commands = new Map(); commands.set(playCommand.data.name, playCommand); commands.set(skipCommand.data.name, skipCommand); commands.set(queueCommand.data.name, queueCommand); commands.set(clearCommand.data.name, clearCommand); commands.set(nowPlayingCommand.data.name, nowPlayingCommand); export { commands }; ``` --- ### Step 1.12 — Ready Event Handler **Purpose:** Log a startup message when the bot connects to Discord. #### `src/events/ready.ts` No tests — single `console.log` statement. ```typescript // Listens to client.once('ready', ...) // Logs: "✅ Logged in as !" ``` --- ### Step 1.13 — Interaction Create Event (Slash Command Dispatcher) **Purpose:** When Discord sends an interaction event, route slash commands to the correct handler. #### `src/events/interactionCreate.test.ts` Tests to write: 1. Ignores interactions that are not chat input commands (`isChatInputCommand()` returns false) 2. When `commandName` matches a registered command → calls `command.executeSlash(interaction, context)` 3. When `commandName` does not match any command → replies with "Unknown command" 4. When the command handler throws → replies with a generic error message (does not crash the bot) #### `src/events/interactionCreate.ts` Exports: ```typescript function handleInteractionCreate( interaction: Interaction, commands: Map, context: CommandContext, ): Promise ``` **Logic:** 1. If `!interaction.isChatInputCommand()` → return (ignore buttons, modals, autocomplete, etc.) 2. Look up `commands.get(interaction.commandName)` 3. If not found → reply with "Unknown command" 4. Call `command.executeSlash(interaction, context)` inside try/catch 5. On error → reply with "An error occurred while executing this command" (if not already replied) --- ### Step 1.14 — Message Create Event (Prefix Command Dispatcher) **Purpose:** When a message is sent, check if it starts with the configured prefix and route to the correct command handler. #### `src/events/messageCreate.test.ts` Tests to write: 1. Ignores messages from bots (`message.author.bot === true`) 2. Ignores messages that don't start with the prefix 3. Parses `"!play bohemian rhapsody"` → command name `"play"`, args `["bohemian", "rhapsody"]` 4. When command name matches a registered command → calls `command.executePrefix(message, args, context)` 5. When command name does not match → does nothing (no reply, silent ignore for prefix commands) 6. When the command handler throws → sends error message to channel (does not crash) 7. Works with custom prefix (e.g., `"$"` → `"$play something"`) #### `src/events/messageCreate.ts` Exports: ```typescript function handleMessageCreate( message: Message, commands: Map, context: CommandContext, prefix: string, ): Promise ``` **Logic:** 1. If `message.author.bot` → return 2. If `!message.content.startsWith(prefix)` → return 3. Split `message.content.slice(prefix.length).trim().split(/\s+/)` → first element is command name, rest is args 4. Look up `commands.get(commandName)` 5. If not found → return (silent ignore for unknown prefix commands) 6. Call `command.executePrefix(message, args, context)` inside try/catch 7. On error → `message.reply("An error occurred")` (does not crash) --- ### Step 1.15 — Deploy Commands Script **Purpose:** One-shot script to register slash commands with the Discord API. Run once (or when commands change). #### `src/deploy-commands.ts` No tests — this is a CLI script that talks to Discord's REST API. **Logic:** 1. Load config (`loadConfig()`) 2. Import all commands from `./commands/index` 3. Build the command JSON array: `commands.map(cmd => cmd.data.toJSON())` 4. Use `@discordjs/rest` + `Routes` to PUT the commands: - If `GUILD_ID` is set → register as guild commands (instant, good for dev) - If `GUILD_ID` is not set → register as global commands (takes up to 1 hour to propagate) 5. Log success/failure ```bash # Usage: npm run deploy-commands ``` --- ### Step 1.16 — Main Entrypoint **Purpose:** Wire everything together and start the bot. #### `src/index.ts` No tests — thin wiring layer. **Logic:** 1. Call `dotenv.config()` to load `.env` 2. Call `loadConfig()` to get validated config 3. Create the Discord.js `Client` with required intents: - `GatewayIntentBits.Guilds` - `GatewayIntentBits.GuildVoiceStates` - `GatewayIntentBits.GuildMessages` - `GatewayIntentBits.MessageContent` 4. Create the `Player` via `createPlayer(client)` 5. Initialize the player via `await initializePlayer(player)` 6. Register player events via `registerPlayerEvents(player)` 7. Import the `commands` map from `./commands/index` 8. Create the `CommandContext`: `{ player }` 9. Register Discord events: - `client.once('ready', ...)` → call ready handler - `client.on('interactionCreate', ...)` → call `handleInteractionCreate(interaction, commands, context)` - `client.on('messageCreate', ...)` → call `handleMessageCreate(message, commands, context, config.prefix)` 10. Call `client.login(config.discordToken)` --- ## Implementation Order Summary This is the exact order to implement, following TDD (test first, then code): | Step | Test File | Implementation File | What | |------|-----------|-------------------|------| | 1.1 | `src/utils/config.test.ts` | `src/utils/config.ts` | Env var loading + validation | | 1.2 | `src/utils/formatters.test.ts` | `src/utils/formatters.ts` | Duration formatting, queue display, progress bar | | 1.3 | `src/player/playerSetup.test.ts` | `src/player/playerSetup.ts` | Player creation + extractor registration | | 1.4 | `src/player/playerEvents.test.ts` | `src/player/playerEvents.ts` | Player event handlers (now playing, error, empty) | | 1.5 | — | `src/commands/types.ts` | Command interface + CommandContext type | | 1.6 | `src/commands/play.test.ts` | `src/commands/play.ts` | Play command (join voice, search, enqueue) | | 1.7 | `src/commands/skip.test.ts` | `src/commands/skip.ts` | Skip command | | 1.8 | `src/commands/queue.test.ts` | `src/commands/queue.ts` | Queue display command | | 1.9 | `src/commands/clear.test.ts` | `src/commands/clear.ts` | Clear queue + stop command | | 1.10 | `src/commands/nowplaying.test.ts` | `src/commands/nowplaying.ts` | Now playing with progress bar | | 1.11 | — | `src/commands/index.ts` | Command registry (Map) | | 1.12 | — | `src/events/ready.ts` | Ready event (log) | | 1.13 | `src/events/interactionCreate.test.ts` | `src/events/interactionCreate.ts` | Slash command dispatcher | | 1.14 | `src/events/messageCreate.test.ts` | `src/events/messageCreate.ts` | Prefix command dispatcher | | 1.15 | — | `src/deploy-commands.ts` | Slash command registration script | | 1.16 | — | `src/index.ts` | Main entrypoint | **Total: 22 files (12 implementation + 10 test files)** --- ## TDD Workflow Per Step For each step above, follow this exact cycle: ``` 1. Create the test file with all test cases (they will all fail) 2. Run: npm test -- 3. Confirm all tests fail (red) 4. Create the implementation file 5. Run: npm test -- 6. Confirm all tests pass (green) 7. Run: npm test (full suite — make sure nothing else broke) 8. Run: npm run lint (type-check — make sure no TS errors) 9. Commit: git commit -m "feat: " ``` --- ## Commit Strategy One commit per step: ``` feat: add config module with env validation feat: add formatters (duration, queue display, progress bar) feat: add player setup and extractor registration feat: add player event handlers feat: add command types feat: add play command feat: add skip command feat: add queue command feat: add clear command feat: add nowplaying command feat: add command registry feat: add ready event handler feat: add interaction create dispatcher feat: add message create dispatcher feat: add deploy-commands script feat: add main entrypoint — bot is functional ``` After the final commit, the bot is fully functional and deployable. --- ## Key Design Decisions ### 1. Shared logic between slash and prefix commands Each command file contains a private helper function with the actual logic. Both `executeSlash` and `executePrefix` call this helper, just adapting how they extract input and how they respond: ```typescript // Inside play.ts (pseudocode) async function playLogic(params: { voiceChannel: VoiceBasedChannel; query: string; textChannel: TextBasedChannel; player: Player; }): Promise { // shared logic — returns a success message string } // executeSlash calls playLogic, replies via interaction.reply() // executePrefix calls playLogic, replies via message.reply() ``` This avoids duplicating logic and makes testing straightforward — test the logic once, then test the thin adapter layers. ### 2. Metadata convention Every `player.play()` call sets metadata to `{ channel: TextBasedChannel }`. This lets player event handlers (playerStart, error, emptyQueue) know where to send status messages: ```typescript await player.play(voiceChannel, query, { nodeOptions: { metadata: { channel: textChannel }, }, }); ``` ### 3. TrackInfo interface for testability Commands don't pass raw `Track` objects to formatters. They map to a plain `TrackInfo` object first: ```typescript function toTrackInfo(track: Track): TrackInfo { return { title: track.title, duration: track.duration, durationMs: track.durationMS, url: track.url, }; } ``` This decouples formatters from discord-player internals, making them trivially testable with plain objects. ### 4. No database, no persistence Queue state lives entirely in discord-player's in-memory data structures. If the bot restarts, all queues are lost. This is fine for a personal bot. ### 5. Prefix commands fail silently for unknown commands If someone types `!hello` and there's no `hello` command, the bot ignores it. This prevents spam in active servers. Slash commands do reply with "Unknown command" because Discord shows a failed interaction indicator if no reply is sent. --- ## Error Handling Strategy 1. **Config errors** → crash on startup with a clear message (fail fast) 2. **Player/extractor initialization errors** → crash on startup (can't function without them) 3. **Command execution errors** → catch, send user-friendly error to channel, log the full error to console, do NOT crash 4. **Player event errors** → catch, log, do NOT crash 5. **Voice connection errors** → discord-player handles reconnection internally --- ## Commands Reference (User-Facing) | Command | Slash | Prefix | Description | |---|---|---|---| | Play | `/play ` | `!play ` | Search and play a song, or add it to the queue | | Skip | `/skip` | `!skip` | Skip the current song | | Queue | `/queue` | `!queue` | Show the current queue | | Clear | `/clear` | `!clear` | Clear the queue and stop playback | | Now Playing | `/nowplaying` | `!nowplaying` | Show current song with progress bar | --- ## Phase 1 Checklist - [ ] 1.1 — Config module (test + impl) - [ ] 1.2 — Formatters module (test + impl) - [ ] 1.3 — Player setup module (test + impl) - [ ] 1.4 — Player events module (test + impl) - [ ] 1.5 — Command types - [ ] 1.6 — Play command (test + impl) - [ ] 1.7 — Skip command (test + impl) - [ ] 1.8 — Queue command (test + impl) - [ ] 1.9 — Clear command (test + impl) - [ ] 1.10 — Now playing command (test + impl) - [ ] 1.11 — Command registry - [ ] 1.12 — Ready event handler - [ ] 1.13 — Interaction create dispatcher (test + impl) - [ ] 1.14 — Message create dispatcher (test + impl) - [ ] 1.15 — Deploy commands script - [ ] 1.16 — Main entrypoint - [ ] Full test suite passes: `npm test` - [ ] Type-check passes: `npm run lint` - [ ] Build succeeds: `npm run build` - [ ] Docker build succeeds: `docker build -t music-bot .`