diff options
| author | Adam Malczewski <[email protected]> | 2026-04-02 02:22:36 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-04-02 02:22:36 +0900 |
| commit | 738732db20d827c4a305434a82c6464f3ebaf65d (patch) | |
| tree | baa0c9eddc72660342d3d3266ed5262f100030a3 /docs/phase-1-implementation.md | |
| download | music-bot-738732db20d827c4a305434a82c6464f3ebaf65d.tar.gz music-bot-738732db20d827c4a305434a82c6464f3ebaf65d.zip | |
Diffstat (limited to 'docs/phase-1-implementation.md')
| -rw-r--r-- | docs/phase-1-implementation.md | 844 |
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 .` |
