diff --git a/README.md b/README.md index 9ac14d3..20bc175 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,359 @@ -# Amplify +# Amplify — A Music Discovery Discord Bot 🎵 -A music discovery discord bot written in Typescript. +Amplify is a TypeScript + ESM **Discord bot** focused on **music discovery**. It helps you find new tracks, explore genres, and build playlists collaboratively with your server. Core to the experience is `/vibe` — describe your mood (“feeling some blues”, “late-night study”, “summer road trip”), and Amplify will queue tracks that fit. + +> Built with **Node 20+**, **TypeScript**, **discord.js v14+**, and a modular command system with auto-loading, ESLint/Prettier, Husky, and Jest (`ts-jest`). + +--- + +## Features + +- **/vibe** — Enter a mood/genre/artist and get a dynamic queue tailored to the vibe. +- **/play** — Play a specific track/URL. +- **/search** — Search tracks by keywords and preview before queueing. +- **/skip /back /pause /resume /stop** — Essential playback controls. +- **/queue /nowplaying** — Inspect and manage the current queue. +- **/like /dislike** — Simple feedback to improve future recommendations. +- **/surprise** — Toss something unexpected into the queue (“wildcard”). + +### Planned / Nice-to-haves + +- **/blend** — Merge two users’ tastes for a shared queue. +- **/daily** — A daily 3–5 track discovery drop based on server taste. +- **/room** — Temporary “listening rooms” with collaborative votes. +- **/ban** — Ban an artist/track for the session (or permanently). +- **Audio previews** in chat (short sample previews). +- **Provider integrations**: Spotify, Apple Music, YouTube Music (OAuth). +- **LLM-powered prompts**: richer `/vibe` intent parsing and explanations. +- **Server charts**: top tracks/artists per week. +- **Taste map**: summarize a user’s long-term preferences. + +> You can start simple (local JSON + YouTube links) and progressively integrate provider APIs and recommendation services as you go. + +--- + +## Tech Stack + +- **Node.js 20+**, **TypeScript** (ESM, `moduleResolution: NodeNext`) +- **discord.js v14+** +- **Jest** with **ts-jest** (ESM preset) for tests +- **ESLint** + **Prettier** + **Husky** + **lint-staged** +- Optional: OAuth + Web API clients (Spotify, Apple, YouTube) + +--- + +## Quick Start + +### 1) Prerequisites + +- Node **20+** +- A Discord application & bot token +- (Optional) Spotify/Apple/YT credentials for deeper features + +### 2) Install + +```bash +npm i +# or: pnpm i / yarn +``` + +### 3) Configure `.env` + +```env +DISCORD_TOKEN=your-bot-token +DISCORD_CLIENT_ID=your-app-client-id +DISCORD_GUILD_ID=your-dev-guild-id # optional: for per-guild dev registration + +# Optional provider creds +# SPOTIFY_CLIENT_ID=... +# SPOTIFY_CLIENT_SECRET=... +# LLM_PROVIDER=openai|anthropic +# LLM_API_KEY=... +``` + +### 4) Run the bot (dev) + +```bash +npm run dev +``` + +Typical scripts (adjust to your project): + +```jsonc +{ + "scripts": { + "dev": "node --watch --enable-source-maps --loader ts-node/esm src/index.ts", + "build": "tsc -p tsconfig.json", + "start": "node dist/index.js", + "lint": "eslint . --ext .ts", + "lint:fix": "eslint . --ext .ts --fix", + "test": "jest", + }, +} +``` + +--- + +## Slash Commands + +### `/vibe` + +- **Description**: Queue tracks that fit a mood/genre/intent. +- **Options**: `text` (string) — e.g., “feeling some blues”, “dark synthwave”, “rainy focus” +- **Behavior**: Resolve intent → search provider(s) → seed/expand queue → play. + +### Other Commands + +- `/play query_or_url` +- `/search query` (returns a list with buttons to queue) +- `/skip`, `/back`, `/pause`, `/resume`, `/stop` +- `/queue`, `/nowplaying` +- `/like`, `/dislike` +- `/surprise` + +> “Planned” commands (`/blend`, `/daily`, `/ban`, `/room`) can be scaffolded with stubs and toggled via feature flags. + +--- + +## Project Structure (example) + +``` +src/ + commands/ + core/ + ping.ts + vibe.ts + play.ts + queue.ts + admin/ + ban.ts + helpers/ + logger.ts + audio/ + player.ts + queue.ts + sources.ts # adapters: spotify/youtube/etc + types/ + commands.ts # ChatCommand / ButtonCommand / SelectCommand / ModalCommand + discord-augment.d.ts # module augmentation for client.commands + index.ts +``` + +- **Auto-loading commands**: Files in `src/commands/**` export `command` objects. Your loader dynamically imports them and populates `client.commands`. +- **Module augmentation** (`.d.ts`) adds a typed `commands` collection to `discord.js`’s `Client`. + +--- + +## Example Command (Slash: `/vibe`) + +```ts +// src/commands/core/vibe.ts +import { + SlashCommandBuilder, + type ChatInputCommandInteraction, +} from "discord.js"; +import type { ChatCommand } from "../../types/commands.js"; + +async function execute(interaction: ChatInputCommandInteraction) { + const vibe = interaction.options.getString("vibe", true); + await interaction.deferReply({ ephemeral: true }); + + // TODO: resolve vibe -> tracks + // const tracks = await recommendFromVibe(vibe); + + await interaction.editReply(`Building a queue for **${vibe}**…`); +} + +export const command: ChatCommand = { + kind: "chat", + data: new SlashCommandBuilder() + .setName("vibe") + .setDescription("Queue music based on a mood or vibe") + .addStringOption((o) => + o + .setName("vibe") + .setDescription("e.g., 'feeling some blues'") + .setRequired(true), + ), + execute, +}; +``` + +--- + +## Typing & Command Registry + +Use a discriminated union so each command’s `execute` is fully typed: + +```ts +// src/types/commands.ts +import type { + SlashCommandBuilder, + ChatInputCommandInteraction, + ButtonInteraction, + StringSelectMenuInteraction, + ModalSubmitInteraction, +} from "discord.js"; + +type Handler = (i: I) => Promise; +type CustomIdMatcher = string | RegExp | ((id: string) => boolean); + +export type ChatCommand = { + kind: "chat"; + data: SlashCommandBuilder; + execute: Handler; +}; + +export type ButtonCommand = { + kind: "button"; + data: { customId: CustomIdMatcher }; + execute: Handler; +}; + +export type SelectCommand = { + kind: "select"; + data: { customId: CustomIdMatcher }; + execute: Handler; +}; + +export type ModalCommand = { + kind: "modal"; + data: { customId: CustomIdMatcher }; + execute: Handler; +}; + +export type AnyCommand = + | ChatCommand + | ButtonCommand + | SelectCommand + | ModalCommand; +``` + +**Module augmentation** gives you `client.commands` everywhere: + +```ts +// src/types/discord-augment.d.ts +import "discord.js"; +import type { Collection } from "discord.js"; +import type { AnyCommand } from "./commands.js"; + +declare module "discord.js" { + interface Client { + commands: Collection; + } +} +``` + +--- + +## Interaction Routing (example) + +```ts +// src/index.ts (excerpt) +client.on(Events.InteractionCreate, async (interaction) => { + try { + if (interaction.isChatInputCommand()) { + const cmd = interaction.client.commands.get(interaction.commandName); + if (cmd?.kind === "chat") return void cmd.execute(interaction); + return; + } + + if (interaction.isButton()) { + const cmd = [...interaction.client.commands.values()].find( + (c): c is ButtonCommand => + c.kind === "button" && + (typeof c.data.customId === "string" + ? c.data.customId === interaction.customId + : c.data.customId instanceof RegExp + ? c.data.customId.test(interaction.customId) + : c.data.customId(interaction.customId)), + ); + if (cmd) return void cmd.execute(interaction); + } + + // Selects / Modals similar… + } catch (err) { + // Safe error reply + if (interaction.isRepliable()) { + const msg = "There was an error while executing this command."; + if (interaction.deferred || interaction.replied) { + await interaction.followUp({ content: msg, ephemeral: true }); + } else { + await interaction.reply({ content: msg, ephemeral: true }); + } + } + } +}); +``` + +--- + +## Development + +### Linting & Formatting + +- ESLint + Prettier with **Husky** pre-commit and **lint-staged**: + +```jsonc +// package.json +{ + "lint-staged": { + "*.{ts,tsx,js,jsx,json,md,yml,yaml}": ["prettier --write", "eslint --fix"], + }, +} +``` + +`.husky/pre-commit`: + +```sh +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +npm test --silent +npx lint-staged +``` + +### Testing (Jest + ts-jest, ESM) + +- Use a **separate** `tsconfig.jest.json` with `"isolatedModules": true`. +- ESM preset + `extensionsToTreatAsEsm: ['.ts']`. +- Clear Jest cache when you change config. + +--- + +## Deployment + +- Recommended: **compile to JS** (`npm run build`) and run `node dist/index.js`. +- For slash command registration: + - Use **guild-scoped** registration during development (`DISCORD_GUILD_ID`). + - Promote to **global** registration for production (propagation can take up to an hour). + +--- + +## Roadmap + +- [ ] `/vibe` v1: seed recommendations from mood text +- [ ] Provider adapters: YouTube → Spotify → Apple +- [ ] Audio previews in chat +- [ ] `/blend` shared-queue sessions +- [ ] `/daily` discovery drops +- [ ] Server charts & taste map +- [ ] Persistence: Postgres/SQLite for feedback + history +- [ ] Caching: Redis for recommendations/queues +- [ ] Web dashboard for auth & settings + +--- + +## Contributing + +PRs welcome! If you’re adding commands, please: + +- Export `command` with a `kind` and typed `execute` as shown above. +- Add tests where reasonable. +- Run `npm run lint` and `npm test` before committing. + +--- + +## License + +MIT © Amplify contributors diff --git a/package.json b/package.json index 6517966..27fe03b 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "format": "prettier --write \"src/**/*.{ts,js,json,md}\"", "start": "node lib/index.js", "dev": "tsx src/index.ts", + "deploy-commands": "tsx src/scripts/deploy-commands.ts", "test": "jest", "prepare": "husky", "lint": "eslint . --ext .ts,.tsx", diff --git a/src/@types/commands.ts b/src/@types/commands.ts index fbba0e0..b4f6471 100644 --- a/src/@types/commands.ts +++ b/src/@types/commands.ts @@ -1,14 +1,14 @@ import type { SlashCommandBuilder, - ButtonInteraction, - StringSelectMenuInteraction, - ModalSubmitInteraction, + // ButtonInteraction, + // StringSelectMenuInteraction, + // ModalSubmitInteraction, ChatInputCommandInteraction, } from "discord.js"; type Handler = (interaction: I) => Promise; type Kind = "chat" | "button" | "select" | "modal"; -type CustomIdMatcher = string | RegExp | ((id: string) => boolean); +// type CustomIdMatcher = string | RegExp | ((id: string) => boolean); export type Command = { kind: K; @@ -22,26 +22,25 @@ export type ChatCommand = Command< "chat" >; -export type ButtonCommand = Command< - ButtonInteraction, - { customId: CustomIdMatcher }, - "button" ->; +// export type ButtonCommand = Command< +// ButtonInteraction, +// { customId: CustomIdMatcher, name: 'button-command' }, +// "button" +// >; -export type SelectCommand = Command< - StringSelectMenuInteraction, - { customId: CustomIdMatcher }, - "select" ->; +// export type SelectCommand = Command< +// StringSelectMenuInteraction, +// { customId: CustomIdMatcher, name: 'select-command' }, +// "select" +// >; -export type ModalCommand = Command< - ModalSubmitInteraction, - { customId: CustomIdMatcher }, - "modal" ->; +// export type ModalCommand = Command< +// ModalSubmitInteraction, +// { customId: CustomIdMatcher, name: 'modal-command' }, +// "modal" +// >; -export type DiscordCommand = - | ChatCommand - | ButtonCommand - | SelectCommand - | ModalCommand; +export type DiscordCommand = ChatCommand; +// | ButtonCommand +// | SelectCommand +// | ModalCommand; diff --git a/src/commands/chat-commands/ping.ts b/src/commands/chat-commands/ping.ts index e7bdd15..e3189f6 100644 --- a/src/commands/chat-commands/ping.ts +++ b/src/commands/chat-commands/ping.ts @@ -4,7 +4,7 @@ import type { ChatCommand } from "../../@types/commands.js"; async function execute( interaction: ChatInputCommandInteraction, ): Promise { - await interaction.reply("Pong!"); + await interaction.reply("Hello World"); } const command: ChatCommand = { diff --git a/src/helpers/commands.ts b/src/helpers/commands.ts index 941c9af..2c654b9 100644 --- a/src/helpers/commands.ts +++ b/src/helpers/commands.ts @@ -4,6 +4,7 @@ import { dirname, join } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { logger } from "./logger.js"; import type { Dirent } from "node:fs"; +import { DiscordCommand } from "../@types/commands.js"; async function attachCommands(client: Client): Promise { const __filename: string = fileURLToPath(import.meta.url); @@ -20,18 +21,21 @@ async function attachCommands(client: Client): Promise { for (const folder of commandFolders) { if (!folder.isDirectory()) continue; - const commandsPath = join(foldersPath, folder.name); - const files = await readdir(commandsPath); - const commandFiles = files.filter( - (f: string) => f.endsWith(".js") || f.endsWith(".ts"), - ); + const commandsPath: string = join(foldersPath, folder.name); + const files: string[] = await readdir(commandsPath); + const commandFiles: string[] = files.filter((f: string) => { + if (!/\.(ts|js)$/i.test(f)) return false; + if (f.endsWith(".d.ts")) return false; + if (/\.(test|spec)\.(ts|js)$/i.test(f)) return false; + return true; + }); for (const file of commandFiles) { - const filePath = join(commandsPath, file); - const fileUrl = pathToFileURL(filePath).href; + const filePath: string = join(commandsPath, file); + const fileUrl: string = pathToFileURL(filePath).href; const mod = await import(fileUrl); - const command = mod.command; + const command: DiscordCommand = mod.command; if (command && "data" in command && "execute" in command) { client.commands.set(command.data.name, command); } else { diff --git a/src/index.ts b/src/index.ts index 065e3d0..1389eb9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,7 +17,7 @@ try { attachCommands(client); client.once(Events.ClientReady, (readyClient: Client) => { - logger.info(`Amplify bot started. Logged in as ${readyClient.user.tag}`); + logger.info(`Amplify started. Logged in as ${readyClient.user.tag}`); }); client.on(Events.InteractionCreate, async (interaction) => { diff --git a/src/scripts/deploy-commands.ts b/src/scripts/deploy-commands.ts new file mode 100644 index 0000000..d085835 --- /dev/null +++ b/src/scripts/deploy-commands.ts @@ -0,0 +1,90 @@ +import { + REST, + RESTPostAPIChatInputApplicationCommandsJSONBody, + Routes, +} from "discord.js"; +import { readdir } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { logger } from "../helpers/logger.js"; +import type { Dirent } from "node:fs"; +import { DiscordCommand } from "../@types/commands.js"; +import dotenv from "dotenv"; + +dotenv.config({ path: ".env" }); + +if ( + !process.env.DISCORD_TOKEN || + !process.env.GUILD_ID || + !process.env.CLIENT_ID +) { + logger.error( + "(DISCORD_TOKEN, CLIENT_ID, GUILD_ID) environment variable not set. This is required to register Amplify commands", + ); + process.exit(0); +} + +const slashCommands: RESTPostAPIChatInputApplicationCommandsJSONBody[] = []; + +const __filename: string = fileURLToPath(import.meta.url); +const __dirname: string = dirname(__filename); + +const foldersPath: string = join(__dirname, "..", "commands"); +const commandFolders: Dirent[] = await readdir(foldersPath, { + withFileTypes: true, +}); + +for (const folder of commandFolders) { + if (!folder.isDirectory()) continue; + + const commandsPath: string = join(foldersPath, folder.name); + const files: string[] = await readdir(commandsPath); + const commandFiles: string[] = files.filter((f: string) => { + if (!/\.(ts|js)$/i.test(f)) return false; + if (f.endsWith(".d.ts")) return false; + if (/\.(test|spec)\.(ts|js)$/i.test(f)) return false; + return true; + }); + + for (const file of commandFiles) { + const filePath: string = join(commandsPath, file); + const fileUrl: string = pathToFileURL(filePath).href; + + const mod = await import(fileUrl); + const command: DiscordCommand = mod.command; + if (command && "data" in command && "execute" in command) { + if (command.kind === "chat") slashCommands.push(command.data.toJSON()); + } else { + logger.warn( + `[WARNING] The command at ${filePath} is missing a required "data" or "execute" property.`, + ); + } + } +} + +logger.info("Commands gathered, sending to discord now..."); +const rest = new REST({ version: "10" }).setToken(process.env.DISCORD_TOKEN!); + +try { + if (process.env.DEPLOY_GLOBAL === "true") { + await rest.put(Routes.applicationCommands(process.env.CLIENT_ID!), { + body: slashCommands, + }); + logger.info( + `Successfully registered ${slashCommands.length} commands with discord globally!`, + ); + } else { + await rest.put( + Routes.applicationGuildCommands( + process.env.CLIENT_ID!, + process.env.GUILD_ID!, + ), + { body: slashCommands }, + ); + logger.info( + `Successfully registered ${slashCommands.length} commands on server with server id: ${process.env.GUILD_ID}!`, + ); + } +} catch (err) { + logger.error(`Error registering commands with discord: ${err}`); +} diff --git a/tsconfig.tsbuildinfo b/tsconfig.tsbuildinfo index 791686a..099d899 100644 --- a/tsconfig.tsbuildinfo +++ b/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/index.ts","./src/@types/commands.ts","./src/@types/discord-augment.d.ts","./src/commands/chat-commands/ping.ts","./src/helpers/commands.ts","./src/helpers/logger.ts"],"version":"5.9.2"} \ No newline at end of file +{"root":["./src/index.ts","./src/@types/commands.ts","./src/@types/discord-augment.d.ts","./src/commands/chat-commands/ping.ts","./src/helpers/commands.ts","./src/helpers/logger.ts","./src/scripts/deploy-commands.ts"],"version":"5.9.2"} \ No newline at end of file