summaryrefslogtreecommitdiffhomepage
path: root/docs/phase-1-implementation.md
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-04-02 02:22:36 +0900
committerAdam Malczewski <[email protected]>2026-04-02 02:22:36 +0900
commit738732db20d827c4a305434a82c6464f3ebaf65d (patch)
treebaa0c9eddc72660342d3d3266ed5262f100030a3 /docs/phase-1-implementation.md
downloadmusic-bot-738732db20d827c4a305434a82c6464f3ebaf65d.tar.gz
music-bot-738732db20d827c4a305434a82c6464f3ebaf65d.zip
project setupHEADmain
Diffstat (limited to 'docs/phase-1-implementation.md')
-rw-r--r--docs/phase-1-implementation.md844
1 files changed, 844 insertions, 0 deletions
diff --git a/docs/phase-1-implementation.md b/docs/phase-1-implementation.md
new file mode 100644
index 0000000..10b1288
--- /dev/null
+++ b/docs/phase-1-implementation.md
@@ -0,0 +1,844 @@
+# 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<void>
+```
+
+**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<SlashCommandBuilder, 'addSubcommand' | 'addSubcommandGroup'>;
+
+ /** Handle a slash command interaction */
+ executeSlash(interaction: ChatInputCommandInteraction, context: CommandContext): Promise<void>;
+
+ /** Handle a prefix message command */
+ executePrefix(message: Message, args: string[], context: CommandContext): Promise<void>;
+}
+```
+
+> **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 <query: string, required, description: "Song name or URL">
+```
+
+**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<string, Command>` 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<string, Command>();
+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 <bot username>!"
+```
+
+---
+
+### 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<string, Command>,
+ context: CommandContext,
+): Promise<void>
+```
+
+**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<string, Command>,
+ context: CommandContext,
+ prefix: string,
+): Promise<void>
+```
+
+**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 -- <test-file-path>
+3. Confirm all tests fail (red)
+4. Create the implementation file
+5. Run: npm test -- <test-file-path>
+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: <description>"
+```
+
+---
+
+## 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<string> {
+ // 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 <query>` | `!play <query>` | 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 .`