feat: Introduce dynamic JSON-based configuration for game settings and command toggling via a new admin command.
This commit is contained in:
96
src/commands/admin/features.ts
Normal file
96
src/commands/admin/features.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { createCommand } from "@/lib/utils";
|
||||||
|
import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder, MessageFlags } from "discord.js";
|
||||||
|
import { configManager } from "@/lib/configManager";
|
||||||
|
import { config, reloadConfig } from "@/lib/config";
|
||||||
|
import { KyokoClient } from "@/lib/KyokoClient"; // Import directly from lib, avoiding circular dep with index
|
||||||
|
|
||||||
|
export const features = createCommand({
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName("features")
|
||||||
|
.setDescription("Manage bot features and commands")
|
||||||
|
.addSubcommand(sub =>
|
||||||
|
sub.setName("list")
|
||||||
|
.setDescription("List all commands and their status")
|
||||||
|
)
|
||||||
|
.addSubcommand(sub =>
|
||||||
|
sub.setName("toggle")
|
||||||
|
.setDescription("Enable or disable a command")
|
||||||
|
.addStringOption(option =>
|
||||||
|
option.setName("command")
|
||||||
|
.setDescription("The name of the command")
|
||||||
|
.setRequired(true)
|
||||||
|
)
|
||||||
|
.addBooleanOption(option =>
|
||||||
|
option.setName("enabled")
|
||||||
|
.setDescription("Whether the command should be enabled")
|
||||||
|
.setRequired(true)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||||
|
execute: async (interaction) => {
|
||||||
|
const subcommand = interaction.options.getSubcommand();
|
||||||
|
|
||||||
|
if (subcommand === "list") {
|
||||||
|
const activeCommands = KyokoClient.commands;
|
||||||
|
const categories = new Map<string, string[]>();
|
||||||
|
|
||||||
|
// Group active commands
|
||||||
|
activeCommands.forEach(cmd => {
|
||||||
|
const cat = cmd.category || 'Uncategorized';
|
||||||
|
if (!categories.has(cat)) categories.set(cat, []);
|
||||||
|
categories.get(cat)!.push(cmd.data.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Config overrides
|
||||||
|
const overrides = Object.entries(config.commands)
|
||||||
|
.map(([name, enabled]) => `• **${name}**: ${enabled ? "✅ Enabled (Override)" : "❌ Disabled"}`);
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle("Command Features")
|
||||||
|
.setColor("Blue");
|
||||||
|
|
||||||
|
// Add fields for each category
|
||||||
|
const sortedCategories = [...categories.keys()].sort();
|
||||||
|
for (const cat of sortedCategories) {
|
||||||
|
const cmds = categories.get(cat)!.sort();
|
||||||
|
const cmdList = cmds.map(name => {
|
||||||
|
const isOverride = config.commands[name] !== undefined;
|
||||||
|
return isOverride ? `**${name}** (See Overrides)` : `**${name}**`;
|
||||||
|
}).join(", ");
|
||||||
|
|
||||||
|
embed.addFields({ name: `📂 ${cat.toUpperCase()}`, value: cmdList || "None" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overrides.length > 0) {
|
||||||
|
embed.addFields({ name: "⚙️ Configuration Overrides", value: overrides.join("\n") });
|
||||||
|
} else {
|
||||||
|
embed.addFields({ name: "⚙️ Configuration Overrides", value: "No overrides set." });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check permissions manually as a fallback (though defaultMemberPermissions handles it at the API level)
|
||||||
|
if (!interaction.memberPermissions?.has(PermissionFlagsBits.Administrator)) {
|
||||||
|
await interaction.reply({ content: "❌ You need Administrator permissions to use this command.", flags: MessageFlags.Ephemeral });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.reply({ embeds: [embed], flags: MessageFlags.Ephemeral });
|
||||||
|
} else if (subcommand === "toggle") {
|
||||||
|
const commandName = interaction.options.getString("command", true);
|
||||||
|
const enabled = interaction.options.getBoolean("enabled", true);
|
||||||
|
|
||||||
|
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||||
|
|
||||||
|
configManager.toggleCommand(commandName, enabled);
|
||||||
|
|
||||||
|
await interaction.editReply({ content: `✅ Command **${commandName}** has been ${enabled ? "enabled" : "disabled"}. Reloading configuration...` });
|
||||||
|
|
||||||
|
// Reload config from disk (which was updated by configManager)
|
||||||
|
reloadConfig();
|
||||||
|
|
||||||
|
await KyokoClient.loadCommands(true);
|
||||||
|
await KyokoClient.deployCommands();
|
||||||
|
|
||||||
|
await interaction.editReply({ content: `✅ Command **${commandName}** has been ${enabled ? "enabled" : "disabled"}. Commands reloaded!` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -2,7 +2,7 @@ import { createCommand } from "@/lib/utils";
|
|||||||
import { SlashCommandBuilder, EmbedBuilder } from "discord.js";
|
import { SlashCommandBuilder, EmbedBuilder } from "discord.js";
|
||||||
import { economyService } from "@/modules/economy/economy.service";
|
import { economyService } from "@/modules/economy/economy.service";
|
||||||
import { userService } from "@/modules/user/user.service";
|
import { userService } from "@/modules/user/user.service";
|
||||||
import { GameConfig } from "@/config/game";
|
import { config } from "@/lib/config";
|
||||||
import { createErrorEmbed, createWarningEmbed } from "@lib/embeds";
|
import { createErrorEmbed, createWarningEmbed } from "@lib/embeds";
|
||||||
|
|
||||||
export const pay = createCommand({
|
export const pay = createCommand({
|
||||||
@@ -26,8 +26,8 @@ export const pay = createCommand({
|
|||||||
const senderId = interaction.user.id;
|
const senderId = interaction.user.id;
|
||||||
const receiverId = targetUser.id;
|
const receiverId = targetUser.id;
|
||||||
|
|
||||||
if (amount < GameConfig.economy.transfers.minAmount) {
|
if (amount < config.economy.transfers.minAmount) {
|
||||||
await interaction.reply({ embeds: [createWarningEmbed(`Amount must be at least ${GameConfig.economy.transfers.minAmount}.`)], ephemeral: true });
|
await interaction.reply({ embeds: [createWarningEmbed(`Amount must be at least ${config.economy.transfers.minAmount}.`)], ephemeral: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
33
src/config/game.json
Normal file
33
src/config/game.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"leveling": {
|
||||||
|
"base": 100,
|
||||||
|
"exponent": 2.5,
|
||||||
|
"chat": {
|
||||||
|
"cooldownMs": 60000,
|
||||||
|
"minXp": 15,
|
||||||
|
"maxXp": 25
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"economy": {
|
||||||
|
"daily": {
|
||||||
|
"amount": "100",
|
||||||
|
"streakBonus": "10",
|
||||||
|
"cooldownMs": 86400000
|
||||||
|
},
|
||||||
|
"transfers": {
|
||||||
|
"allowSelfTransfer": false,
|
||||||
|
"minAmount": "1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"inventory": {
|
||||||
|
"maxStackSize": "999",
|
||||||
|
"maxSlots": 50
|
||||||
|
},
|
||||||
|
"commands": {
|
||||||
|
"daily": true,
|
||||||
|
"quests": false,
|
||||||
|
"inventory": false,
|
||||||
|
"trade": false,
|
||||||
|
"balance": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
export const GameConfig = {
|
|
||||||
leveling: {
|
|
||||||
// Curve: Base * (Level ^ Exponent)
|
|
||||||
base: 100,
|
|
||||||
exponent: 2.5,
|
|
||||||
|
|
||||||
chat: {
|
|
||||||
cooldownMs: 60000, // 1 minute
|
|
||||||
minXp: 15,
|
|
||||||
maxXp: 25,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
economy: {
|
|
||||||
daily: {
|
|
||||||
amount: 100n,
|
|
||||||
streakBonus: 10n,
|
|
||||||
cooldownMs: 24 * 60 * 60 * 1000, // 24 hours
|
|
||||||
},
|
|
||||||
transfers: {
|
|
||||||
// Future use
|
|
||||||
allowSelfTransfer: false,
|
|
||||||
minAmount: 1n,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
inventory: {
|
|
||||||
maxStackSize: 999n,
|
|
||||||
maxSlots: 50,
|
|
||||||
}
|
|
||||||
} as const;
|
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Events } from "discord.js";
|
import { Events } from "discord.js";
|
||||||
import type { Event } from "@lib/types";
|
import type { Event } from "@lib/types";
|
||||||
|
|
||||||
|
// Visitor role
|
||||||
const event: Event<Events.GuildMemberAdd> = {
|
const event: Event<Events.GuildMemberAdd> = {
|
||||||
name: Events.GuildMemberAdd,
|
name: Events.GuildMemberAdd,
|
||||||
execute: async (member) => {
|
execute: async (member) => {
|
||||||
const role = member.guild.roles.cache.find(role => role.name === "Visitor");
|
const role = member.guild.roles.cache.find(role => role.id === "1449859380269940947");
|
||||||
if (!role) return;
|
if (!role) return;
|
||||||
await member.roles.add(role);
|
await member.roles.add(role);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { readdir } from "node:fs/promises";
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import type { Command, Event } from "@lib/types";
|
import type { Command, Event } from "@lib/types";
|
||||||
import { env } from "@lib/env";
|
import { env } from "@lib/env";
|
||||||
|
import { config } from "@lib/config";
|
||||||
|
|
||||||
class Client extends DiscordClient {
|
class Client extends DiscordClient {
|
||||||
|
|
||||||
@@ -56,8 +57,23 @@ class Client extends DiscordClient {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract category from parent directory name
|
||||||
|
// filePath is like /path/to/commands/admin/features.ts
|
||||||
|
// we want "admin"
|
||||||
|
const pathParts = filePath.split('/');
|
||||||
|
const category = pathParts[pathParts.length - 2];
|
||||||
|
|
||||||
for (const command of commands) {
|
for (const command of commands) {
|
||||||
if (this.isValidCommand(command)) {
|
if (this.isValidCommand(command)) {
|
||||||
|
command.category = category; // Inject category
|
||||||
|
|
||||||
|
const isEnabled = config.commands[command.data.name] !== false; // Default true if undefined
|
||||||
|
|
||||||
|
if (!isEnabled) {
|
||||||
|
console.log(`🚫 Skipping disabled command: ${command.data.name}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
this.commands.set(command.data.name, command);
|
this.commands.set(command.data.name, command);
|
||||||
console.log(`✅ Loaded command: ${command.data.name}`);
|
console.log(`✅ Loaded command: ${command.data.name}`);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
71
src/lib/config.ts
Normal file
71
src/lib/config.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { readFileSync, existsSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
const configPath = join(process.cwd(), 'src', 'config', 'game.json');
|
||||||
|
|
||||||
|
export interface GameConfigType {
|
||||||
|
leveling: {
|
||||||
|
base: number;
|
||||||
|
exponent: number;
|
||||||
|
chat: {
|
||||||
|
cooldownMs: number;
|
||||||
|
minXp: number;
|
||||||
|
maxXp: number;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
economy: {
|
||||||
|
daily: {
|
||||||
|
amount: bigint;
|
||||||
|
streakBonus: bigint;
|
||||||
|
cooldownMs: number;
|
||||||
|
},
|
||||||
|
transfers: {
|
||||||
|
allowSelfTransfer: boolean;
|
||||||
|
minAmount: bigint;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
inventory: {
|
||||||
|
maxStackSize: bigint;
|
||||||
|
maxSlots: number;
|
||||||
|
},
|
||||||
|
commands: Record<string, boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial default config state
|
||||||
|
export const config: GameConfigType = {} as GameConfigType;
|
||||||
|
|
||||||
|
export function reloadConfig() {
|
||||||
|
if (!existsSync(configPath)) {
|
||||||
|
throw new Error(`Config file not found at ${configPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = readFileSync(configPath, 'utf-8');
|
||||||
|
const rawConfig = JSON.parse(raw);
|
||||||
|
|
||||||
|
// Update config object in place
|
||||||
|
config.leveling = rawConfig.leveling;
|
||||||
|
config.economy = {
|
||||||
|
daily: {
|
||||||
|
...rawConfig.economy.daily,
|
||||||
|
amount: BigInt(rawConfig.economy.daily.amount),
|
||||||
|
streakBonus: BigInt(rawConfig.economy.daily.streakBonus),
|
||||||
|
},
|
||||||
|
transfers: {
|
||||||
|
...rawConfig.economy.transfers,
|
||||||
|
minAmount: BigInt(rawConfig.economy.transfers.minAmount),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
config.inventory = {
|
||||||
|
...rawConfig.inventory,
|
||||||
|
maxStackSize: BigInt(rawConfig.inventory.maxStackSize),
|
||||||
|
};
|
||||||
|
config.commands = rawConfig.commands || {};
|
||||||
|
|
||||||
|
console.log("🔄 Config reloaded from disk.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
reloadConfig();
|
||||||
|
|
||||||
|
// Backwards compatibility alias
|
||||||
|
export const GameConfig = config;
|
||||||
20
src/lib/configManager.ts
Normal file
20
src/lib/configManager.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { readFileSync, writeFileSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { KyokoClient } from '@/lib/KyokoClient';
|
||||||
|
|
||||||
|
const configPath = join(process.cwd(), 'src', 'config', 'game.json');
|
||||||
|
|
||||||
|
export const configManager = {
|
||||||
|
toggleCommand: (commandName: string, enabled: boolean) => {
|
||||||
|
const raw = readFileSync(configPath, 'utf-8');
|
||||||
|
const data = JSON.parse(raw);
|
||||||
|
|
||||||
|
if (!data.commands) {
|
||||||
|
data.commands = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
data.commands[commandName] = enabled;
|
||||||
|
|
||||||
|
writeFileSync(configPath, JSON.stringify(data, null, 4));
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -3,6 +3,7 @@ import type { ChatInputCommandInteraction, ClientEvents, SlashCommandBuilder, Sl
|
|||||||
export interface Command {
|
export interface Command {
|
||||||
data: SlashCommandBuilder | SlashCommandOptionsOnlyBuilder | SlashCommandSubcommandsOnlyBuilder;
|
data: SlashCommandBuilder | SlashCommandOptionsOnlyBuilder | SlashCommandSubcommandsOnlyBuilder;
|
||||||
execute: (interaction: ChatInputCommandInteraction) => Promise<void> | void;
|
execute: (interaction: ChatInputCommandInteraction) => Promise<void> | void;
|
||||||
|
category?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Event<K extends keyof ClientEvents> {
|
export interface Event<K extends keyof ClientEvents> {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { users, transactions, userTimers } from "@/db/schema";
|
import { users, transactions, userTimers } from "@/db/schema";
|
||||||
import { eq, sql, and } from "drizzle-orm";
|
import { eq, sql, and } from "drizzle-orm";
|
||||||
import { DrizzleClient } from "@/lib/DrizzleClient";
|
import { DrizzleClient } from "@/lib/DrizzleClient";
|
||||||
import { GameConfig } from "@/config/game";
|
import { config } from "@/lib/config";
|
||||||
|
|
||||||
export const economyService = {
|
export const economyService = {
|
||||||
transfer: async (fromUserId: string, toUserId: string, amount: bigint, tx?: any) => {
|
transfer: async (fromUserId: string, toUserId: string, amount: bigint, tx?: any) => {
|
||||||
@@ -110,9 +110,9 @@ export const economyService = {
|
|||||||
streak = 1;
|
streak = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bonus = (BigInt(streak) - 1n) * GameConfig.economy.daily.streakBonus;
|
const bonus = (BigInt(streak) - 1n) * config.economy.daily.streakBonus;
|
||||||
|
|
||||||
const totalReward = GameConfig.economy.daily.amount + bonus;
|
const totalReward = config.economy.daily.amount + bonus;
|
||||||
await txFn.update(users)
|
await txFn.update(users)
|
||||||
.set({
|
.set({
|
||||||
balance: sql`${users.balance} + ${totalReward}`,
|
balance: sql`${users.balance} + ${totalReward}`,
|
||||||
@@ -122,7 +122,7 @@ export const economyService = {
|
|||||||
.where(eq(users.id, BigInt(userId)));
|
.where(eq(users.id, BigInt(userId)));
|
||||||
|
|
||||||
// Set new cooldown (now + 24h)
|
// Set new cooldown (now + 24h)
|
||||||
const nextReadyAt = new Date(now.getTime() + GameConfig.economy.daily.cooldownMs);
|
const nextReadyAt = new Date(now.getTime() + config.economy.daily.cooldownMs);
|
||||||
|
|
||||||
await txFn.insert(userTimers)
|
await txFn.insert(userTimers)
|
||||||
.values({
|
.values({
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { inventory, items, users } from "@/db/schema";
|
|||||||
import { eq, and, sql, count } from "drizzle-orm";
|
import { eq, and, sql, count } from "drizzle-orm";
|
||||||
import { DrizzleClient } from "@/lib/DrizzleClient";
|
import { DrizzleClient } from "@/lib/DrizzleClient";
|
||||||
import { economyService } from "@/modules/economy/economy.service";
|
import { economyService } from "@/modules/economy/economy.service";
|
||||||
import { GameConfig } from "@/config/game";
|
import { config } from "@/lib/config";
|
||||||
|
|
||||||
export const inventoryService = {
|
export const inventoryService = {
|
||||||
addItem: async (userId: string, itemId: number, quantity: bigint = 1n, tx?: any) => {
|
addItem: async (userId: string, itemId: number, quantity: bigint = 1n, tx?: any) => {
|
||||||
@@ -18,8 +18,8 @@ export const inventoryService = {
|
|||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
const newQuantity = (existing.quantity ?? 0n) + quantity;
|
const newQuantity = (existing.quantity ?? 0n) + quantity;
|
||||||
if (newQuantity > GameConfig.inventory.maxStackSize) {
|
if (newQuantity > config.inventory.maxStackSize) {
|
||||||
throw new Error(`Cannot exceed max stack size of ${GameConfig.inventory.maxStackSize}`);
|
throw new Error(`Cannot exceed max stack size of ${config.inventory.maxStackSize}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [entry] = await txFn.update(inventory)
|
const [entry] = await txFn.update(inventory)
|
||||||
@@ -39,12 +39,12 @@ export const inventoryService = {
|
|||||||
.from(inventory)
|
.from(inventory)
|
||||||
.where(eq(inventory.userId, BigInt(userId)));
|
.where(eq(inventory.userId, BigInt(userId)));
|
||||||
|
|
||||||
if (inventoryCount.count >= GameConfig.inventory.maxSlots) {
|
if (inventoryCount.count >= config.inventory.maxSlots) {
|
||||||
throw new Error(`Inventory full (Max ${GameConfig.inventory.maxSlots} slots)`);
|
throw new Error(`Inventory full (Max ${config.inventory.maxSlots} slots)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (quantity > GameConfig.inventory.maxStackSize) {
|
if (quantity > config.inventory.maxStackSize) {
|
||||||
throw new Error(`Cannot exceed max stack size of ${GameConfig.inventory.maxStackSize}`);
|
throw new Error(`Cannot exceed max stack size of ${config.inventory.maxStackSize}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [entry] = await txFn.insert(inventory)
|
const [entry] = await txFn.insert(inventory)
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { users, userTimers } from "@/db/schema";
|
import { users, userTimers } from "@/db/schema";
|
||||||
import { eq, sql, and } from "drizzle-orm";
|
import { eq, sql, and } from "drizzle-orm";
|
||||||
import { DrizzleClient } from "@/lib/DrizzleClient";
|
import { DrizzleClient } from "@/lib/DrizzleClient";
|
||||||
import { GameConfig } from "@/config/game";
|
import { config } from "@/lib/config";
|
||||||
|
|
||||||
export const levelingService = {
|
export const levelingService = {
|
||||||
// Calculate XP required for a specific level
|
// Calculate XP required for a specific level
|
||||||
getXpForLevel: (level: number) => {
|
getXpForLevel: (level: number) => {
|
||||||
return Math.floor(GameConfig.leveling.base * Math.pow(level, GameConfig.leveling.exponent));
|
return Math.floor(config.leveling.base * Math.pow(level, config.leveling.exponent));
|
||||||
},
|
},
|
||||||
|
|
||||||
// Pure XP addition - No cooldown checks
|
// Pure XP addition - No cooldown checks
|
||||||
@@ -72,13 +72,13 @@ export const levelingService = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Calculate random XP
|
// Calculate random XP
|
||||||
const amount = BigInt(Math.floor(Math.random() * (GameConfig.leveling.chat.maxXp - GameConfig.leveling.chat.minXp + 1)) + GameConfig.leveling.chat.minXp);
|
const amount = BigInt(Math.floor(Math.random() * (config.leveling.chat.maxXp - config.leveling.chat.minXp + 1)) + config.leveling.chat.minXp);
|
||||||
|
|
||||||
// Add XP
|
// Add XP
|
||||||
const result = await levelingService.addXp(id, amount, txFn);
|
const result = await levelingService.addXp(id, amount, txFn);
|
||||||
|
|
||||||
// Update/Set Cooldown
|
// Update/Set Cooldown
|
||||||
const nextReadyAt = new Date(now.getTime() + GameConfig.leveling.chat.cooldownMs);
|
const nextReadyAt = new Date(now.getTime() + config.leveling.chat.cooldownMs);
|
||||||
|
|
||||||
await txFn.insert(userTimers)
|
await txFn.insert(userTimers)
|
||||||
.values({
|
.values({
|
||||||
|
|||||||
Reference in New Issue
Block a user