feat: Initialize database and restructure application source code.

This commit is contained in:
syntaxbullet
2025-12-05 18:52:20 +01:00
parent 1fb59a26cc
commit fdfb2508ae
24 changed files with 55 additions and 255 deletions

View File

@@ -0,0 +1,13 @@
import { createCommand } from "@lib/utils";
import { getUserBalance } from "@/modules/economy/economy.service";
import { SlashCommandBuilder, EmbedBuilder } from "discord.js";
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

@@ -0,0 +1,34 @@
import { createCommand } from "@lib/utils";
import { KyokoClient } from "@lib/KyokoClient";
import { SlashCommandBuilder, EmbedBuilder, PermissionFlagsBits } from "discord.js";
export const reload = createCommand({
data: new SlashCommandBuilder()
.setName("reload")
.setDescription("Reloads all commands")
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction) => {
await interaction.deferReply({ ephemeral: true });
try {
await KyokoClient.loadCommands(true);
const embed = new EmbedBuilder()
.setTitle("✅ System Reloaded")
.setDescription(`Successfully reloaded ${KyokoClient.commands.size} commands.`)
.setColor("Green");
// Deploy commands
await KyokoClient.deployCommands();
await interaction.editReply({ embeds: [embed] });
} catch (error) {
console.error(error);
const embed = new EmbedBuilder()
.setTitle("❌ Reload Failed")
.setDescription("An error occurred while reloading commands. Check console for details.")
.setColor("Red");
await interaction.editReply({ embeds: [embed] });
}
}
});

19
src/db/schema.ts Normal file
View File

@@ -0,0 +1,19 @@
import { pgTable, integer, text, timestamp, serial } from "drizzle-orm/pg-core";
export const users = pgTable("users", {
userId: text("user_id").primaryKey().notNull(),
balance: integer("balance").notNull().default(0),
lastDaily: timestamp("last_daily"),
dailyStreak: integer("daily_streak").notNull().default(0),
createdAt: timestamp("created_at").defaultNow(),
});
export const transactions = pgTable("transactions", {
transactionId: serial("transaction_id").primaryKey().notNull(),
fromUserId: text("from_user_id").references(() => users.userId),
toUserId: text("to_user_id").references(() => users.userId),
amount: integer("amount").notNull(),
occuredAt: timestamp("occured_at").defaultNow(),
type: text("type").notNull(),
description: text("description"),
});

38
src/index.ts Normal file
View File

@@ -0,0 +1,38 @@
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}`);
});
KyokoClient.on(Events.InteractionCreate, async interaction => {
if (!interaction.isChatInputCommand()) return;
const command = KyokoClient.commands.get(interaction.commandName);
if (!command) {
console.error(`No command matching ${interaction.commandName} was found.`);
return;
}
try {
await command.execute(interaction);
} catch (error) {
console.error(error);
if (interaction.replied || interaction.deferred) {
await interaction.followUp({ content: 'There was an error while executing this command!', ephemeral: true });
} else {
await interaction.reply({ content: 'There was an error while executing this command!', ephemeral: true });
}
}
});
// login with the token from .env
if (!env.DISCORD_BOT_TOKEN) {
throw new Error("❌ DISCORD_BOT_TOKEN is not set in environment variables.");
}
KyokoClient.login(env.DISCORD_BOT_TOKEN);

9
src/lib/DrizzleClient.ts Normal file
View File

@@ -0,0 +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 = env.DATABASE_URL;
const postgres = new SQL(connectionString);
export const DrizzleClient = drizzle(postgres, { schema });

120
src/lib/KyokoClient.ts Normal file
View File

@@ -0,0 +1,120 @@
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 {
commands: Collection<string, Command>;
constructor({ intents }: { intents: number[] }) {
super({ intents });
this.commands = new Collection<string, Command>();
}
async loadCommands(reload: boolean = false) {
if (reload) {
this.commands.clear();
console.log("♻️ Reloading commands...");
}
const commandsPath = join(import.meta.dir, '../commands');
await this.readCommandsRecursively(commandsPath, reload);
}
private async readCommandsRecursively(dir: string, reload: boolean = false) {
try {
const files = await readdir(dir, { withFileTypes: true });
for (const file of files) {
const filePath = join(dir, file.name);
if (file.isDirectory()) {
await this.readCommandsRecursively(filePath, reload);
continue;
}
if (!file.name.endsWith('.ts') && !file.name.endsWith('.js')) continue;
try {
const importPath = reload ? `${filePath}?t=${Date.now()}` : filePath;
const commandModule = await import(importPath);
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}`);
} else {
console.warn(`⚠️ Skipping invalid command in ${file.name}`);
}
}
} catch (error) {
console.error(`❌ Failed to load command from ${filePath}:`, error);
}
}
} catch (error) {
console.error(`Error reading directory ${dir}:`, error);
}
}
private isValidCommand(command: any): command is Command {
return command && typeof command === 'object' && 'data' in command && 'execute' in command;
}
async deployCommands() {
// 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 = 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.`);
let data;
if (guildId) {
console.log(`Registering commands to guild: ${guildId}`);
data = await rest.put(
Routes.applicationGuildCommands(clientId, guildId),
{ body: commandsData },
);
// Clear global commands to avoid duplicates
await rest.put(Routes.applicationCommands(clientId), { body: [] });
} else {
console.log('Registering commands globally');
data = await rest.put(
Routes.applicationCommands(clientId),
{ body: commandsData },
);
}
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);
}
}
}
}
export const KyokoClient = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.MessageContent, GatewayIntentBits.GuildMessages] });

17
src/lib/env.ts Normal file
View File

@@ -0,0 +1,17 @@
import { z } from "zod";
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"),
});
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;

6
src/lib/types.ts Normal file
View File

@@ -0,0 +1,6 @@
import type { ChatInputCommandInteraction, SlashCommandBuilder, SlashCommandOptionsOnlyBuilder, SlashCommandSubcommandsOnlyBuilder } from "discord.js";
export interface Command {
data: SlashCommandBuilder | SlashCommandOptionsOnlyBuilder | SlashCommandSubcommandsOnlyBuilder;
execute: (interaction: ChatInputCommandInteraction) => Promise<void> | void;
}

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

View File

@@ -0,0 +1,12 @@
import { DrizzleClient } from "@lib/DrizzleClient";
import { users } from "@/db/schema";
import { eq } from "drizzle-orm";
export async function getUserBalance(userId: string) {
const user = await DrizzleClient.query.users.findFirst({ where: eq(users.userId, userId) });
return user?.balance ?? 0;
}
export async function setUserBalance(userId: string, balance: number) {
await DrizzleClient.update(users).set({ balance }).where(eq(users.userId, userId));
}

View File

@@ -0,0 +1,11 @@
import { DrizzleClient } from "@lib/DrizzleClient";
import { users } from "@/db/schema";
import { eq } from "drizzle-orm";
export async function getUserById(userId: string) {
return await DrizzleClient.query.users.findFirst({ where: eq(users.userId, userId) });
}
export async function createUser(userId: string) {
return (await DrizzleClient.insert(users).values({ userId }).returning())[0]!;
}