summaryrefslogtreecommitdiffhomepage
path: root/docs/phase-1-implementation.md
blob: 10b1288dbd97599a2eb2017a9c88ab76ab446848 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
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 .`