Compare commits
6 Commits
ee2fda83e5
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1eace32aa1 | ||
|
|
32c614975e | ||
|
|
7dd9052c9b | ||
|
|
9d1d4aeaea | ||
|
|
bd59f01a41 | ||
|
|
4639fecf45 |
@@ -19,7 +19,7 @@ export const balance = createCommand({
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setAuthor({ name: targetUser.username, iconURL: targetUser.displayAvatarURL() })
|
||||
.setDescription(`**Balance**: ${user.balance || 0n} 🪙`)
|
||||
.setDescription(`**Balance**: ${user.balance || 0n} AU`)
|
||||
.setColor("Yellow");
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
|
||||
@@ -13,9 +13,9 @@ export const daily = createCommand({
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle("💰 Daily Reward Claimed!")
|
||||
.setDescription(`You claimed **${result.amount}** coins!`)
|
||||
.setDescription(`You claimed **${result.amount}** Astral Units!`)
|
||||
.addFields(
|
||||
{ name: "Current Streak", value: `🔥 ${result.streak} days`, inline: true },
|
||||
{ name: "Streak", value: `🔥 ${result.streak} days`, inline: true },
|
||||
{ name: "Next Reward", value: `<t:${Math.floor(result.nextReadyAt.getTime() / 1000)}:R>`, inline: true }
|
||||
)
|
||||
.setColor("Gold")
|
||||
|
||||
@@ -8,7 +8,7 @@ import { createErrorEmbed, createWarningEmbed } from "@lib/embeds";
|
||||
export const pay = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("pay")
|
||||
.setDescription("Transfer coins to another user")
|
||||
.setDescription("Transfer Astral Units to another user")
|
||||
.addUserOption(option =>
|
||||
option.setName("user")
|
||||
.setDescription("The user to pay")
|
||||
@@ -41,7 +41,7 @@ export const pay = createCommand({
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle("💸 Transfer Successful")
|
||||
.setDescription(`Successfully sent **${amount}** coins to <@${targetUser.id}>.`)
|
||||
.setDescription(`Successfully sent **${amount}** Astral Units to <@${targetUser.id}>.`)
|
||||
.setColor("Green")
|
||||
.setTimestamp();
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
ComponentType,
|
||||
type BaseGuildTextChannel,
|
||||
type ButtonInteraction,
|
||||
PermissionFlagsBits
|
||||
PermissionFlagsBits,
|
||||
MessageFlags
|
||||
} from "discord.js";
|
||||
import { userService } from "@/modules/user/user.service";
|
||||
import { inventoryService } from "@/modules/inventory/inventory.service";
|
||||
@@ -31,7 +32,7 @@ export const sell = createCommand({
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
const itemId = interaction.options.getNumber("itemid", true);
|
||||
const targetChannel = (interaction.options.getChannel("channel") as BaseGuildTextChannel) || interaction.channel as BaseGuildTextChannel;
|
||||
@@ -90,7 +91,7 @@ export const sell = createCommand({
|
||||
|
||||
async function handleBuyInteraction(interaction: ButtonInteraction, item: typeof items.$inferSelect) {
|
||||
try {
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
const userId = interaction.user.id;
|
||||
const user = await userService.getUserById(userId);
|
||||
@@ -118,7 +119,7 @@ async function handleBuyInteraction(interaction: ButtonInteraction, item: typeof
|
||||
if (interaction.deferred || interaction.replied) {
|
||||
await interaction.editReply({ content: "", embeds: [createErrorEmbed("An error occurred while processing your purchase.")] });
|
||||
} else {
|
||||
await interaction.reply({ embeds: [createErrorEmbed("An error occurred while processing your purchase.")], ephemeral: true });
|
||||
await interaction.reply({ embeds: [createErrorEmbed("An error occurred while processing your purchase.")], flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
75
src/commands/system/webhook.ts
Normal file
75
src/commands/system/webhook.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { createCommand } from "@/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, TextChannel, NewsChannel, VoiceChannel } from "discord.js";
|
||||
import { createErrorEmbed } from "@/lib/embeds";
|
||||
|
||||
export const webhook = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("webhook")
|
||||
.setDescription("Send a message via webhook using a JSON payload")
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.ManageWebhooks)
|
||||
.addStringOption(option =>
|
||||
option.setName("payload")
|
||||
.setDescription("The JSON payload for the webhook message")
|
||||
.setRequired(true)
|
||||
),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
|
||||
const payloadString = interaction.options.getString("payload", true);
|
||||
let payload;
|
||||
|
||||
try {
|
||||
payload = JSON.parse(payloadString);
|
||||
} catch (error) {
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed("The provided payload is not valid JSON.", "Invalid JSON")]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const channel = interaction.channel;
|
||||
|
||||
if (!channel || !('createWebhook' in channel)) {
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed("This channel does not support webhooks.", "Unsupported Channel")]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let webhook;
|
||||
try {
|
||||
webhook = await channel.createWebhook({
|
||||
name: `${interaction.client.user.username} - Proxy`,
|
||||
avatar: interaction.client.user.displayAvatarURL(),
|
||||
reason: `Proxy message requested by ${interaction.user.tag}`
|
||||
});
|
||||
|
||||
|
||||
// Support snake_case keys for raw API compatibility
|
||||
if (payload.avatar_url && !payload.avatarURL) {
|
||||
payload.avatarURL = payload.avatar_url;
|
||||
delete payload.avatar_url;
|
||||
}
|
||||
|
||||
await webhook.send(payload);
|
||||
|
||||
await webhook.delete("Proxy message sent");
|
||||
|
||||
await interaction.editReply({ content: "Message sent successfully!" });
|
||||
} catch (error) {
|
||||
console.error("Webhook error:", error);
|
||||
// Attempt cleanup if webhook was created but sending failed
|
||||
if (webhook) {
|
||||
try {
|
||||
await webhook.delete("Cleanup after failure");
|
||||
} catch (cleanupError) {
|
||||
console.error("Failed to delete webhook during cleanup:", cleanupError);
|
||||
}
|
||||
}
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed("Failed to send message via webhook. Ensure the bot has 'Manage Webhooks' permission and the payload is valid.", "Delivery Failed")]
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
13
src/events/guildMemberAdd.ts
Normal file
13
src/events/guildMemberAdd.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Events } from "discord.js";
|
||||
import type { Event } from "@lib/types";
|
||||
|
||||
const event: Event<Events.GuildMemberAdd> = {
|
||||
name: Events.GuildMemberAdd,
|
||||
execute: async (member) => {
|
||||
const role = member.guild.roles.cache.find(role => role.name === "Visitor");
|
||||
if (!role) return;
|
||||
await member.roles.add(role);
|
||||
},
|
||||
};
|
||||
|
||||
export default event;
|
||||
53
src/events/interactionCreate.ts
Normal file
53
src/events/interactionCreate.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Events, MessageFlags } from "discord.js";
|
||||
import { KyokoClient } from "@lib/KyokoClient";
|
||||
import { userService } from "@/modules/user/user.service";
|
||||
import { createErrorEmbed } from "@lib/embeds";
|
||||
import type { Event } from "@lib/types";
|
||||
|
||||
const event: Event<Events.InteractionCreate> = {
|
||||
name: Events.InteractionCreate,
|
||||
execute: async (interaction) => {
|
||||
// Handle Trade Interactions
|
||||
if (interaction.isButton() || interaction.isStringSelectMenu() || interaction.isModalSubmit()) {
|
||||
if (interaction.customId.startsWith("trade_") || interaction.customId === "amount") {
|
||||
await import("@/modules/trade/trade.interaction").then(m => m.handleTradeInteraction(interaction));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!interaction.isChatInputCommand()) return;
|
||||
|
||||
const command = KyokoClient.commands.get(interaction.commandName);
|
||||
|
||||
if (!command) {
|
||||
console.error(`No command matching ${interaction.commandName} was found.`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Ensure user exists in database
|
||||
try {
|
||||
const user = await userService.getUserById(interaction.user.id);
|
||||
if (!user) {
|
||||
console.log(`🆕 Creating new user entry for ${interaction.user.tag}`);
|
||||
await userService.createUser(interaction.user.id, interaction.user.username);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to check/create user:", error);
|
||||
}
|
||||
|
||||
try {
|
||||
await command.execute(interaction);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
const errorEmbed = createErrorEmbed('There was an error while executing this command!');
|
||||
if (interaction.replied || interaction.deferred) {
|
||||
await interaction.followUp({ embeds: [errorEmbed], flags: MessageFlags.Ephemeral });
|
||||
} else {
|
||||
await interaction.reply({ embeds: [errorEmbed], flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default event;
|
||||
19
src/events/messageCreate.ts
Normal file
19
src/events/messageCreate.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Events } from "discord.js";
|
||||
import { userService } from "@/modules/user/user.service";
|
||||
import { levelingService } from "@/modules/leveling/leveling.service";
|
||||
import type { Event } from "@lib/types";
|
||||
|
||||
const event: Event<Events.MessageCreate> = {
|
||||
name: Events.MessageCreate,
|
||||
execute: async (message) => {
|
||||
if (message.author.bot) return;
|
||||
if (!message.guild) return;
|
||||
|
||||
const user = await userService.getUserById(message.author.id);
|
||||
if (!user) return;
|
||||
|
||||
levelingService.processChatXp(message.author.id);
|
||||
},
|
||||
};
|
||||
|
||||
export default event;
|
||||
14
src/events/ready.ts
Normal file
14
src/events/ready.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Events } from "discord.js";
|
||||
import { schedulerService } from "@/modules/system/scheduler";
|
||||
import type { Event } from "@lib/types";
|
||||
|
||||
const event: Event<Events.ClientReady> = {
|
||||
name: Events.ClientReady,
|
||||
once: true,
|
||||
execute: async (c) => {
|
||||
console.log(`Ready! Logged in as ${c.user.tag}`);
|
||||
schedulerService.start();
|
||||
},
|
||||
};
|
||||
|
||||
export default event;
|
||||
69
src/index.ts
69
src/index.ts
@@ -1,76 +1,11 @@
|
||||
import { Events } from "discord.js";
|
||||
import { KyokoClient } from "@lib/KyokoClient";
|
||||
import { env } from "@lib/env";
|
||||
import { userService } from "@/modules/user/user.service";
|
||||
import { levelingService } from "@/modules/leveling/leveling.service";
|
||||
import { createErrorEmbed } from "@lib/embeds";
|
||||
|
||||
// Load commands
|
||||
// Load commands & events
|
||||
await KyokoClient.loadCommands();
|
||||
await KyokoClient.loadEvents();
|
||||
await KyokoClient.deployCommands();
|
||||
|
||||
import { schedulerService } from "@/modules/system/scheduler";
|
||||
|
||||
KyokoClient.once(Events.ClientReady, async c => {
|
||||
console.log(`Ready! Logged in as ${c.user.tag}`);
|
||||
schedulerService.start();
|
||||
});
|
||||
|
||||
// process xp on message
|
||||
KyokoClient.on(Events.MessageCreate, async message => {
|
||||
if (message.author.bot) return;
|
||||
if (!message.guild) return;
|
||||
|
||||
const user = await userService.getUserById(message.author.id);
|
||||
if (!user) return;
|
||||
|
||||
levelingService.processChatXp(message.author.id);
|
||||
});
|
||||
|
||||
// handle commands
|
||||
KyokoClient.on(Events.InteractionCreate, async interaction => {
|
||||
// Handle Trade Interactions
|
||||
if (interaction.isButton() || interaction.isStringSelectMenu() || interaction.isModalSubmit()) {
|
||||
if (interaction.customId.startsWith("trade_") || interaction.customId === "amount") {
|
||||
await import("@/modules/trade/trade.interaction").then(m => m.handleTradeInteraction(interaction));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!interaction.isChatInputCommand()) return;
|
||||
|
||||
const command = KyokoClient.commands.get(interaction.commandName);
|
||||
|
||||
if (!command) {
|
||||
console.error(`No command matching ${interaction.commandName} was found.`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Ensure user exists in database
|
||||
try {
|
||||
const user = await userService.getUserById(interaction.user.id);
|
||||
if (!user) {
|
||||
console.log(`🆕 Creating new user entry for ${interaction.user.tag}`);
|
||||
await userService.createUser(interaction.user.id, interaction.user.username);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to check/create user:", error);
|
||||
}
|
||||
|
||||
try {
|
||||
await command.execute(interaction);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
const errorEmbed = createErrorEmbed('There was an error while executing this command!');
|
||||
if (interaction.replied || interaction.deferred) {
|
||||
await interaction.followUp({ embeds: [errorEmbed], ephemeral: true });
|
||||
} else {
|
||||
await interaction.reply({ embeds: [errorEmbed], ephemeral: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// login with the token from .env
|
||||
if (!env.DISCORD_BOT_TOKEN) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 type { Command, Event } from "@lib/types";
|
||||
import { env } from "@lib/env";
|
||||
|
||||
class Client extends DiscordClient {
|
||||
@@ -23,6 +23,16 @@ class Client extends DiscordClient {
|
||||
await this.readCommandsRecursively(commandsPath, reload);
|
||||
}
|
||||
|
||||
async loadEvents(reload: boolean = false) {
|
||||
if (reload) {
|
||||
this.removeAllListeners();
|
||||
console.log("♻️ Reloading events...");
|
||||
}
|
||||
|
||||
const eventsPath = join(import.meta.dir, '../events');
|
||||
await this.readEventsRecursively(eventsPath, reload);
|
||||
}
|
||||
|
||||
private async readCommandsRecursively(dir: string, reload: boolean = false) {
|
||||
try {
|
||||
const files = await readdir(dir, { withFileTypes: true });
|
||||
@@ -63,10 +73,52 @@ class Client extends DiscordClient {
|
||||
}
|
||||
}
|
||||
|
||||
private async readEventsRecursively(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.readEventsRecursively(filePath, reload);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!file.name.endsWith('.ts') && !file.name.endsWith('.js')) continue;
|
||||
|
||||
try {
|
||||
const importPath = reload ? `${filePath}?t=${Date.now()}` : filePath;
|
||||
const eventModule = await import(importPath);
|
||||
const event = eventModule.default;
|
||||
|
||||
if (this.isValidEvent(event)) {
|
||||
if (event.once) {
|
||||
this.once(event.name, (...args) => event.execute(...args));
|
||||
} else {
|
||||
this.on(event.name, (...args) => event.execute(...args));
|
||||
}
|
||||
console.log(`✅ Loaded event: ${event.name}`);
|
||||
} else {
|
||||
console.warn(`⚠️ Skipping invalid event in ${file.name}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to load event 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;
|
||||
}
|
||||
|
||||
private isValidEvent(event: any): event is Event<any> {
|
||||
return event && typeof event === 'object' && 'name' in event && 'execute' in event;
|
||||
}
|
||||
|
||||
async deployCommands() {
|
||||
// We use env.DISCORD_BOT_TOKEN directly so this can run without client.login()
|
||||
const token = env.DISCORD_BOT_TOKEN;
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import type { ChatInputCommandInteraction, SlashCommandBuilder, SlashCommandOptionsOnlyBuilder, SlashCommandSubcommandsOnlyBuilder } from "discord.js";
|
||||
import type { ChatInputCommandInteraction, ClientEvents, SlashCommandBuilder, SlashCommandOptionsOnlyBuilder, SlashCommandSubcommandsOnlyBuilder } from "discord.js";
|
||||
|
||||
export interface Command {
|
||||
data: SlashCommandBuilder | SlashCommandOptionsOnlyBuilder | SlashCommandSubcommandsOnlyBuilder;
|
||||
execute: (interaction: ChatInputCommandInteraction) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export interface Event<K extends keyof ClientEvents> {
|
||||
name: K;
|
||||
once?: boolean;
|
||||
execute: (...args: ClientEvents[K]) => Promise<void> | void;
|
||||
}
|
||||
|
||||
@@ -100,11 +100,11 @@ export const economyService = {
|
||||
|
||||
let streak = (user.dailyStreak || 0) + 1;
|
||||
|
||||
// If previous cooldown exists and expired more than 24h ago (meaning >48h since last claim), reset streak
|
||||
// If previous cooldown exists and expired more than 24h ago (meaning >48h since last claim), reduce streak by one for each day passed minimum 1
|
||||
if (cooldown) {
|
||||
const timeSinceReady = now.getTime() - cooldown.expiresAt.getTime();
|
||||
if (timeSinceReady > 24 * 60 * 60 * 1000) {
|
||||
streak = 1;
|
||||
streak = Math.max(1, streak - Math.floor(timeSinceReady / (24 * 60 * 60 * 1000)));
|
||||
}
|
||||
} else {
|
||||
streak = 1;
|
||||
@@ -113,9 +113,6 @@ export const economyService = {
|
||||
const bonus = (BigInt(streak) - 1n) * GameConfig.economy.daily.streakBonus;
|
||||
|
||||
const totalReward = GameConfig.economy.daily.amount + bonus;
|
||||
|
||||
// Update User w/ Economy Service (reuse modifyUserBalance if we split it out, but here manual is fine for atomic combined streak update)
|
||||
// Actually, we can just update directly here as we are already refining specific fields like streak.
|
||||
await txFn.update(users)
|
||||
.set({
|
||||
balance: sql`${users.balance} + ${totalReward}`,
|
||||
|
||||
Reference in New Issue
Block a user