Refactor architecture: improve env loading and command workflow

This commit is contained in:
syntaxbullet
2025-12-05 12:06:29 +01:00
parent f4b72b93e4
commit 48995204a5
16 changed files with 362 additions and 30 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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=="],

View File

@@ -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,
},
});

View File

@@ -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;

View File

@@ -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": {}
}
}

View File

@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1764930369542,
"tag": "0000_big_obadiah_stane",
"breakpoints": true
}
]
}

View File

@@ -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"
}
}

View File

@@ -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] });
}
};
});

View File

@@ -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);
if (!env.DISCORD_BOT_TOKEN) {
throw new Error("❌ DISCORD_BOT_TOKEN is not set in environment variables.");
}
KyokoClient.login(env.DISCORD_BOT_TOKEN);

View File

@@ -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 });

View File

@@ -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);
}
}
}
}

18
app/src/lib/env.ts Normal file
View File

@@ -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;

11
app/src/lib/utils.ts Normal file
View File

@@ -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;
}

14
app/src/scripts/deploy.ts Normal file
View File

@@ -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);

View File

@@ -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