diff --git a/.env.example b/.env.example index 7d9aef6..ebef6ea 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,8 @@ DB_NAME=kyoko DB_PORT=5432 DB_HOST=db DISCORD_BOT_TOKEN=your-discord-bot-token +DISCORD_CLIENT_ID=your-discord-client-id +DISCORD_GUILD_ID=your-discord-guild-id DB_DATA_DIR=./db-data DB_LOG_DIR=./db-logs -DATABASE_URL=postgres://kyoko:kyoko@db:5432/kyoko \ No newline at end of file +DATABASE_URL=postgres://kyoko:kyoko@db:5432/kyoko diff --git a/app/README.md b/app/README.md index aca6abd..b4ac57b 100644 --- a/app/README.md +++ b/app/README.md @@ -1,15 +1,65 @@ -# app +# Kyoko - Discord Rpg -To install dependencies: +A Discord bot built with [Bun](https://bun.sh), [Discord.js](https://discord.js.org/), and [Drizzle ORM](https://orm.drizzle.team/). +## Architecture + +This project uses a modular architecture: + +- **`src/index.ts`**: Entry point. initializes the client. +- **`src/lib/KyokoClient.ts`**: Custom Discord Client wrapper handling command loading and events. +- **`src/lib/env.ts`**: **Centralized Environment Configuration**. Validates environment variables using `zod` at startup. +- **`src/lib/DrizzleClient.ts`**: Database client instance. +- **`src/commands/`**: Command files. +- **`src/db/`**: Database schema and migrations. + +## Setup + +1. **Install Dependencies**: + ```bash + bun install + ``` + +2. **Environment Variables**: + Copy `.env.example` to `.env` (create one if it doesn't exist) and fill in the required values: + ```env + DISCORD_BOT_TOKEN=your_token_here + DISCORD_CLIENT_ID=your_client_id + DISCORD_GUILD_ID=your_guild_id_optional + DATABASE_URL=postgres://user:pass@localhost:5432/db_name + ``` + *Note: The app will fail to start if `DISCORD_BOT_TOKEN` or `DATABASE_URL` are missing or invalid.* + +3. **Run Development**: + ```bash + bun run dev + ``` + +4. **Database Migrations**: + ```bash + bun run db:push # Apply schema changes + bun run generate # Generate migrations + ``` + +## Deployment + +### Manual Command Registration +Since command registration is decoupled from startup, you must run this manually when you add or change commands. + +**Option 1: Using Docker (Recommended)** +Uses the credentials configured in `docker-compose.yml`. ```bash -bun install +docker compose run --rm app bun run deploy ``` -To run: - +**Option 2: Running Locally** +Requires valid `.env` file with `DISCORD_CLIENT_ID`. ```bash -bun run index.ts +bun run deploy ``` -This project was created using `bun init` in bun v1.3.2. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime. +## Development Features + +- **Type Safety**: Full TypeScript support. +- **Env Validation**: `zod` ensures all required env vars are present. +- **Hot Reloading**: `bun --watch` for fast development. diff --git a/app/bun.lock b/app/bun.lock index a0d1eb6..e6a9b6c 100644 --- a/app/bun.lock +++ b/app/bun.lock @@ -6,7 +6,9 @@ "name": "app", "dependencies": { "discord.js": "^14.25.1", + "dotenv": "^17.2.3", "drizzle-orm": "^0.44.7", + "zod": "^4.1.13", }, "devDependencies": { "@types/bun": "latest", @@ -113,6 +115,8 @@ "discord.js": ["discord.js@14.25.1", "", { "dependencies": { "@discordjs/builders": "^1.13.0", "@discordjs/collection": "1.5.3", "@discordjs/formatters": "^0.6.2", "@discordjs/rest": "^2.6.0", "@discordjs/util": "^1.2.0", "@discordjs/ws": "^1.2.3", "@sapphire/snowflake": "3.5.3", "discord-api-types": "^0.38.33", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", "magic-bytes.js": "^1.10.0", "tslib": "^2.6.3", "undici": "6.21.3" } }, "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g=="], + "dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="], + "drizzle-kit": ["drizzle-kit@0.31.7", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-hOzRGSdyKIU4FcTSFYGKdXEjFsncVwHZ43gY3WU5Bz9j5Iadp6Rh6hxLSQ1IWXpKLBKt/d5y1cpSPcV+FcoQ1A=="], "drizzle-orm": ["drizzle-orm@0.44.7", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-quIpnYznjU9lHshEOAYLoZ9s3jweleHlZIAWR/jX9gAWNg/JhQ1wj0KGRf7/Zm+obRrYd9GjPVJg790QY9N5AQ=="], @@ -153,6 +157,8 @@ "ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], + "zod": ["zod@4.1.13", "", {}, "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig=="], + "@discordjs/rest/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="], "@discordjs/ws/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="], diff --git a/app/drizzle.config.ts b/app/drizzle.config.ts index 0b98651..bac0cfd 100644 --- a/app/drizzle.config.ts +++ b/app/drizzle.config.ts @@ -1,10 +1,11 @@ import { defineConfig } from "drizzle-kit"; +import { env } from "./src/lib/env"; export default defineConfig({ - schema: "./schema.ts", + schema: "./src/db/schema.ts", out: "./drizzle", dialect: "postgresql", dbCredentials: { - url: process.env.DATABASE_URL || "postgres://kyoko:kyoko@localhost:5432/kyoko", + url: env.DATABASE_URL, }, }); diff --git a/app/drizzle/0000_big_obadiah_stane.sql b/app/drizzle/0000_big_obadiah_stane.sql new file mode 100644 index 0000000..afbab87 --- /dev/null +++ b/app/drizzle/0000_big_obadiah_stane.sql @@ -0,0 +1,22 @@ +CREATE TABLE "transactions" ( + "transaction_id" serial PRIMARY KEY NOT NULL, + "from_user_id" text, + "to_user_id" text, + "amount" integer NOT NULL, + "occured_at" timestamp DEFAULT now(), + "type" text NOT NULL, + "description" text, + CONSTRAINT "transactions_transaction_id_unique" UNIQUE("transaction_id") +); +--> statement-breakpoint +CREATE TABLE "users" ( + "user_id" text PRIMARY KEY NOT NULL, + "balance" integer DEFAULT 0 NOT NULL, + "last_daily" timestamp, + "daily_streak" integer DEFAULT 0 NOT NULL, + "created_at" timestamp DEFAULT now(), + CONSTRAINT "users_user_id_unique" UNIQUE("user_id") +); +--> statement-breakpoint +ALTER TABLE "transactions" ADD CONSTRAINT "transactions_from_user_id_users_user_id_fk" FOREIGN KEY ("from_user_id") REFERENCES "public"."users"("user_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "transactions" ADD CONSTRAINT "transactions_to_user_id_users_user_id_fk" FOREIGN KEY ("to_user_id") REFERENCES "public"."users"("user_id") ON DELETE no action ON UPDATE no action; \ No newline at end of file diff --git a/app/drizzle/meta/0000_snapshot.json b/app/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..afe421b --- /dev/null +++ b/app/drizzle/meta/0000_snapshot.json @@ -0,0 +1,164 @@ +{ + "id": "e5884b86-8257-466d-86f8-5fef47cfcd50", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.transactions": { + "name": "transactions", + "schema": "", + "columns": { + "transaction_id": { + "name": "transaction_id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "from_user_id": { + "name": "from_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "to_user_id": { + "name": "to_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "occured_at": { + "name": "occured_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "transactions_from_user_id_users_user_id_fk": { + "name": "transactions_from_user_id_users_user_id_fk", + "tableFrom": "transactions", + "tableTo": "users", + "columnsFrom": [ + "from_user_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "transactions_to_user_id_users_user_id_fk": { + "name": "transactions_to_user_id_users_user_id_fk", + "tableFrom": "transactions", + "tableTo": "users", + "columnsFrom": [ + "to_user_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "transactions_transaction_id_unique": { + "name": "transactions_transaction_id_unique", + "nullsNotDistinct": false, + "columns": [ + "transaction_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "balance": { + "name": "balance", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_daily": { + "name": "last_daily", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "daily_streak": { + "name": "daily_streak", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_user_id_unique": { + "name": "users_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/app/drizzle/meta/_journal.json b/app/drizzle/meta/_journal.json new file mode 100644 index 0000000..ed31459 --- /dev/null +++ b/app/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1764930369542, + "tag": "0000_big_obadiah_stane", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/app/package.json b/app/package.json index 5b281f7..2af40a0 100644 --- a/app/package.json +++ b/app/package.json @@ -15,11 +15,13 @@ "generate": "drizzle-kit generate", "migrate": "drizzle-kit migrate", "db:push": "drizzle-kit push", - "deploy": "bun src/scripts/deploy.ts", + "deploy": "docker compose run --rm app bun src/scripts/deploy.ts", "dev": "bun --watch src/index.ts" }, "dependencies": { "discord.js": "^14.25.1", - "drizzle-orm": "^0.44.7" + "dotenv": "^17.2.3", + "drizzle-orm": "^0.44.7", + "zod": "^4.1.13" } } \ No newline at end of file diff --git a/app/src/commands/economy/balance.ts b/app/src/commands/economy/balance.ts index 2882895..2cd9ad1 100644 --- a/app/src/commands/economy/balance.ts +++ b/app/src/commands/economy/balance.ts @@ -1,13 +1,13 @@ -import type { Command } from "@lib/types"; +import { createCommand } from "@lib/utils"; import { getUserBalance } from "@/modules/economy/economy.service"; import { SlashCommandBuilder, EmbedBuilder } from "discord.js"; -export const balance: Command = { +export const balance = createCommand({ data: new SlashCommandBuilder().setName("balance").setDescription("Check your balance"), execute: async (interaction) => { const balance = await getUserBalance(interaction.user.id) || 0; const embed = new EmbedBuilder().setDescription(`Your balance is ${balance}`); await interaction.reply({ embeds: [embed] }); } -}; +}); diff --git a/app/src/index.ts b/app/src/index.ts index 19195a1..2a3fb90 100644 --- a/app/src/index.ts +++ b/app/src/index.ts @@ -1,13 +1,12 @@ import { Events } from "discord.js"; import { KyokoClient } from "@lib/KyokoClient"; +import { env } from "@lib/env"; // Load commands await KyokoClient.loadCommands(); KyokoClient.once(Events.ClientReady, async c => { console.log(`Ready! Logged in as ${c.user.tag}`); - console.log("Deploying commands..."); - KyokoClient.deployCommands(); }); KyokoClient.on(Events.InteractionCreate, async interaction => { @@ -33,4 +32,7 @@ KyokoClient.on(Events.InteractionCreate, async interaction => { }); // login with the token from .env -KyokoClient.login(process.env.DISCORD_BOT_TOKEN); \ No newline at end of file +if (!env.DISCORD_BOT_TOKEN) { + throw new Error("❌ DISCORD_BOT_TOKEN is not set in environment variables."); +} +KyokoClient.login(env.DISCORD_BOT_TOKEN); \ No newline at end of file diff --git a/app/src/lib/DrizzleClient.ts b/app/src/lib/DrizzleClient.ts index fce47cd..0f86be7 100644 --- a/app/src/lib/DrizzleClient.ts +++ b/app/src/lib/DrizzleClient.ts @@ -1,8 +1,9 @@ import { drizzle } from "drizzle-orm/bun-sql"; import { SQL } from "bun"; import * as schema from "@db/schema"; +import { env } from "@lib/env"; -const connectionString = process.env.DATABASE_URL || "postgres://kyoko:kyoko@localhost:5432/kyoko"; +const connectionString = env.DATABASE_URL; const postgres = new SQL(connectionString); export const DrizzleClient = drizzle(postgres, { schema }); \ No newline at end of file diff --git a/app/src/lib/KyokoClient.ts b/app/src/lib/KyokoClient.ts index a6474ef..7813b89 100644 --- a/app/src/lib/KyokoClient.ts +++ b/app/src/lib/KyokoClient.ts @@ -1,7 +1,8 @@ -import { Client as DiscordClient, Collection, GatewayIntentBits, REST, Routes, SlashCommandBuilder } from "discord.js"; +import { Client as DiscordClient, Collection, GatewayIntentBits, REST, Routes } from "discord.js"; import { readdir } from "node:fs/promises"; import { join } from "node:path"; import type { Command } from "@lib/types"; +import { env } from "@lib/env"; class Client extends DiscordClient { @@ -34,15 +35,21 @@ class Client extends DiscordClient { try { const commandModule = await import(filePath); const commands = Object.values(commandModule); + if (commands.length === 0) { + console.warn(`⚠️ No commands found in ${file.name}`); + continue; + } for (const command of commands) { if (this.isValidCommand(command)) { this.commands.set(command.data.name, command); - console.log(`Loaded command: ${command.data.name}`); + console.log(`✅ Loaded command: ${command.data.name}`); + } else { + console.warn(`⚠️ Skipping invalid command in ${file.name}`); } } } catch (error) { - console.error(`Failed to load command from ${filePath}:`, error); + console.error(`❌ Failed to load command from ${filePath}:`, error); } } } catch (error) { @@ -55,9 +62,22 @@ class Client extends DiscordClient { } async deployCommands() { - const rest = new REST().setToken(this.token!); + // We use env.DISCORD_BOT_TOKEN directly so this can run without client.login() + const token = env.DISCORD_BOT_TOKEN; + if (!token) { + console.error("❌ DISCORD_BOT_TOKEN is not set."); + return; + } + + const rest = new REST().setToken(token); const commandsData = this.commands.map(c => c.data.toJSON()); - const guildId = process.env.DISCORD_GUILD_ID; + const guildId = env.DISCORD_GUILD_ID; + const clientId = env.DISCORD_CLIENT_ID; + + if (!clientId) { + console.error("❌ DISCORD_CLIENT_ID is not set."); + return; + } try { console.log(`Started refreshing ${commandsData.length} application (/) commands.`); @@ -66,22 +86,27 @@ class Client extends DiscordClient { if (guildId) { console.log(`Registering commands to guild: ${guildId}`); data = await rest.put( - Routes.applicationGuildCommands(this.user!.id, guildId), + Routes.applicationGuildCommands(clientId, guildId), { body: commandsData }, ); // Clear global commands to avoid duplicates - await rest.put(Routes.applicationCommands(this.user!.id), { body: [] }); + await rest.put(Routes.applicationCommands(clientId), { body: [] }); } else { console.log('Registering commands globally'); data = await rest.put( - Routes.applicationCommands(this.user!.id), + Routes.applicationCommands(clientId), { body: commandsData }, ); } - console.log(`Successfully reloaded ${(data as any).length} application (/) commands.`); - } catch (error) { - console.error(error); + console.log(`✅ Successfully reloaded ${(data as any).length} application (/) commands.`); + } catch (error: any) { + if (error.code === 50001) { + console.warn("⚠️ Missing Access: The bot is not in the guild or lacks 'applications.commands' scope."); + console.warn(" If you are testing locally, make sure you invited the bot with scope 'bot applications.commands'."); + } else { + console.error(error); + } } } } diff --git a/app/src/lib/env.ts b/app/src/lib/env.ts new file mode 100644 index 0000000..c57ced7 --- /dev/null +++ b/app/src/lib/env.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; +import "dotenv/config"; + +const envSchema = z.object({ + DISCORD_BOT_TOKEN: z.string().optional(), + DISCORD_CLIENT_ID: z.string().optional(), + DISCORD_GUILD_ID: z.string().optional(), + DATABASE_URL: z.string().min(1, "Database URL is required").default("postgres://kyoko:kyoko@localhost:5432/kyoko"), +}); + +const parsedEnv = envSchema.safeParse(process.env); + +if (!parsedEnv.success) { + console.error("❌ Invalid environment variables:", parsedEnv.error.flatten().fieldErrors); + throw new Error("Invalid environment variables"); +} + +export const env = parsedEnv.data; diff --git a/app/src/lib/utils.ts b/app/src/lib/utils.ts new file mode 100644 index 0000000..98476e4 --- /dev/null +++ b/app/src/lib/utils.ts @@ -0,0 +1,11 @@ +import type { Command } from "./types"; + +/** + * Type-safe helper to create a command definition. + * + * @param command The command definition + * @returns The command object + */ +export function createCommand(command: Command): Command { + return command; +} diff --git a/app/src/scripts/deploy.ts b/app/src/scripts/deploy.ts new file mode 100644 index 0000000..2976fe2 --- /dev/null +++ b/app/src/scripts/deploy.ts @@ -0,0 +1,14 @@ +import { KyokoClient } from "@lib/KyokoClient"; + +console.log("🚀 Starting deployment script..."); + +// Load all commands first +await KyokoClient.loadCommands(); + +console.log(`📦 Loaded ${KyokoClient.commands.size} commands.`); + +// Deploy +await KyokoClient.deployCommands(); + +console.log("👋 Deployment script finished."); +process.exit(0); diff --git a/docker-compose.yml b/docker-compose.yml index 305854c..6a64327 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,6 +32,7 @@ services: - DB_HOST=db - DISCORD_BOT_TOKEN=${DISCORD_BOT_TOKEN} - DISCORD_GUILD_ID=${DISCORD_GUILD_ID} + - DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID} - DATABASE_URL=${DATABASE_URL} depends_on: - db