forked from syntaxbullet/aurorabot
refactor: consolidate config types and remove file-based config
Tickets: #2, #3 - Remove duplicate type definitions from shared/lib/config.ts - Import types from schema files (game-settings.ts, guild-settings.ts) - Add GuildConfig interface to guild-settings.ts schema - Rename ModerationConfig to ModerationCaseConfig in moderation.service.ts - Delete shared/config/config.json and shared/scripts/migrate-config-to-db.ts - Update settings API to use gameSettingsService exclusively - Return DB format (strings) from API instead of runtime BigInts - Fix moderation service tests to pass config as parameter Breaking Changes: - Removes legacy file-based configuration system - API now returns database format with string values for BigInt fields
This commit is contained in:
@@ -10,6 +10,26 @@ import { relations, type InferSelectModel, type InferInsertModel } from 'drizzle
|
||||
export type GuildSettings = InferSelectModel<typeof guildSettings>;
|
||||
export type GuildSettingsInsert = InferInsertModel<typeof guildSettings>;
|
||||
|
||||
export interface GuildConfig {
|
||||
studentRole?: string;
|
||||
visitorRole?: string;
|
||||
colorRoles: string[];
|
||||
welcomeChannelId?: string;
|
||||
welcomeMessage?: string;
|
||||
feedbackChannelId?: string;
|
||||
terminal?: {
|
||||
channelId: string;
|
||||
messageId: string;
|
||||
};
|
||||
moderation: {
|
||||
cases: {
|
||||
dmOnWarn: boolean;
|
||||
logChannelId?: string;
|
||||
autoTimeoutThreshold?: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export const guildSettings = pgTable('guild_settings', {
|
||||
guildId: bigint('guild_id', { mode: 'bigint' }).primaryKey(),
|
||||
studentRoleId: bigint('student_role_id', { mode: 'bigint' }),
|
||||
|
||||
@@ -1,29 +1,12 @@
|
||||
import { jsonReplacer } from './utils';
|
||||
import { readFileSync, existsSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { z } from 'zod';
|
||||
|
||||
const configPath = join(import.meta.dir, '..', 'config', 'config.json');
|
||||
|
||||
export interface GuildConfig {
|
||||
studentRole?: string;
|
||||
visitorRole?: string;
|
||||
colorRoles: string[];
|
||||
welcomeChannelId?: string;
|
||||
welcomeMessage?: string;
|
||||
feedbackChannelId?: string;
|
||||
terminal?: {
|
||||
channelId: string;
|
||||
messageId: string;
|
||||
};
|
||||
moderation: {
|
||||
cases: {
|
||||
dmOnWarn: boolean;
|
||||
logChannelId?: string;
|
||||
autoTimeoutThreshold?: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
import type {
|
||||
LevelingConfig,
|
||||
EconomyConfig as EconomyConfigDB,
|
||||
InventoryConfig as InventoryConfigDB,
|
||||
LootdropConfig,
|
||||
TriviaConfig as TriviaConfigDB,
|
||||
ModerationConfig
|
||||
} from "@db/schema/game-settings";
|
||||
import type { GuildConfig } from "@db/schema/guild-settings";
|
||||
|
||||
const guildConfigCache = new Map<string, { config: GuildConfig; timestamp: number }>();
|
||||
const CACHE_TTL_MS = 60000;
|
||||
@@ -82,16 +65,10 @@ export function invalidateGuildConfigCache(guildId: string) {
|
||||
guildConfigCache.delete(guildId);
|
||||
}
|
||||
|
||||
export interface LevelingConfig {
|
||||
base: number;
|
||||
exponent: number;
|
||||
chat: {
|
||||
cooldownMs: number;
|
||||
minXp: number;
|
||||
maxXp: number;
|
||||
};
|
||||
}
|
||||
// Re-export DB types
|
||||
export type { LevelingConfig, LootdropConfig, ModerationConfig };
|
||||
|
||||
// Runtime config types with BigInt for numeric fields
|
||||
export interface EconomyConfig {
|
||||
daily: {
|
||||
amount: bigint;
|
||||
@@ -114,18 +91,6 @@ export interface InventoryConfig {
|
||||
maxSlots: number;
|
||||
}
|
||||
|
||||
export interface LootdropConfig {
|
||||
activityWindowMs: number;
|
||||
minMessages: number;
|
||||
spawnChance: number;
|
||||
cooldownMs: number;
|
||||
reward: {
|
||||
min: number;
|
||||
max: number;
|
||||
currency: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TriviaConfig {
|
||||
entryFee: bigint;
|
||||
rewardMultiplier: number;
|
||||
@@ -135,20 +100,6 @@ export interface TriviaConfig {
|
||||
difficulty: 'easy' | 'medium' | 'hard' | 'random';
|
||||
}
|
||||
|
||||
export interface ModerationConfig {
|
||||
prune: {
|
||||
maxAmount: number;
|
||||
confirmThreshold: number;
|
||||
batchSize: number;
|
||||
batchDelayMs: number;
|
||||
};
|
||||
cases: {
|
||||
dmOnWarn: boolean;
|
||||
logChannelId?: string;
|
||||
autoTimeoutThreshold?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GameConfigType {
|
||||
leveling: LevelingConfig;
|
||||
economy: EconomyConfig;
|
||||
@@ -158,160 +109,11 @@ export interface GameConfigType {
|
||||
trivia: TriviaConfig;
|
||||
moderation: ModerationConfig;
|
||||
system: Record<string, unknown>;
|
||||
studentRole: string;
|
||||
visitorRole: string;
|
||||
colorRoles: string[];
|
||||
welcomeChannelId?: string;
|
||||
welcomeMessage?: string;
|
||||
feedbackChannelId?: string;
|
||||
terminal?: {
|
||||
channelId: string;
|
||||
messageId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const config: GameConfigType = {} as GameConfigType;
|
||||
|
||||
const bigIntSchema = z.union([z.string(), z.number(), z.bigint()])
|
||||
.refine((val) => {
|
||||
try {
|
||||
BigInt(val);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, { message: "Must be a valid integer" })
|
||||
.transform((val) => BigInt(val));
|
||||
|
||||
const fileConfigSchema = z.object({
|
||||
leveling: z.object({
|
||||
base: z.number(),
|
||||
exponent: z.number(),
|
||||
chat: z.object({
|
||||
cooldownMs: z.number(),
|
||||
minXp: z.number(),
|
||||
maxXp: z.number(),
|
||||
})
|
||||
}),
|
||||
economy: z.object({
|
||||
daily: z.object({
|
||||
amount: bigIntSchema,
|
||||
streakBonus: bigIntSchema,
|
||||
weeklyBonus: bigIntSchema.default(50n),
|
||||
cooldownMs: z.number(),
|
||||
}),
|
||||
transfers: z.object({
|
||||
allowSelfTransfer: z.boolean(),
|
||||
minAmount: bigIntSchema,
|
||||
}),
|
||||
exam: z.object({
|
||||
multMin: z.number(),
|
||||
multMax: z.number(),
|
||||
})
|
||||
}),
|
||||
inventory: z.object({
|
||||
maxStackSize: bigIntSchema,
|
||||
maxSlots: z.number(),
|
||||
}),
|
||||
commands: z.record(z.string(), z.boolean()),
|
||||
lootdrop: z.object({
|
||||
activityWindowMs: z.number(),
|
||||
minMessages: z.number(),
|
||||
spawnChance: z.number(),
|
||||
cooldownMs: z.number(),
|
||||
reward: z.object({
|
||||
min: z.number(),
|
||||
max: z.number(),
|
||||
currency: z.string(),
|
||||
})
|
||||
}),
|
||||
studentRole: z.string(),
|
||||
visitorRole: z.string(),
|
||||
colorRoles: z.array(z.string()).default([]),
|
||||
welcomeChannelId: z.string().optional(),
|
||||
welcomeMessage: z.string().optional(),
|
||||
feedbackChannelId: z.string().optional(),
|
||||
terminal: z.object({
|
||||
channelId: z.string(),
|
||||
messageId: z.string()
|
||||
}).optional(),
|
||||
moderation: z.object({
|
||||
prune: z.object({
|
||||
maxAmount: z.number().default(100),
|
||||
confirmThreshold: z.number().default(50),
|
||||
batchSize: z.number().default(100),
|
||||
batchDelayMs: z.number().default(1000)
|
||||
}),
|
||||
cases: z.object({
|
||||
dmOnWarn: z.boolean().default(true),
|
||||
logChannelId: z.string().optional(),
|
||||
autoTimeoutThreshold: z.number().optional()
|
||||
})
|
||||
}).default({
|
||||
prune: {
|
||||
maxAmount: 100,
|
||||
confirmThreshold: 50,
|
||||
batchSize: 100,
|
||||
batchDelayMs: 1000
|
||||
},
|
||||
cases: {
|
||||
dmOnWarn: true
|
||||
}
|
||||
}),
|
||||
trivia: z.object({
|
||||
entryFee: bigIntSchema,
|
||||
rewardMultiplier: z.number().min(0).max(10),
|
||||
timeoutSeconds: z.number().min(5).max(300),
|
||||
cooldownMs: z.number().min(0),
|
||||
categories: z.array(z.number()).default([]),
|
||||
difficulty: z.enum(['easy', 'medium', 'hard', 'random']).default('random'),
|
||||
}).default({
|
||||
entryFee: 50n,
|
||||
rewardMultiplier: 1.8,
|
||||
timeoutSeconds: 30,
|
||||
cooldownMs: 60000,
|
||||
categories: [],
|
||||
difficulty: 'random'
|
||||
}),
|
||||
system: z.record(z.string(), z.any()).default({}),
|
||||
});
|
||||
|
||||
type FileConfig = z.infer<typeof fileConfigSchema>;
|
||||
|
||||
function loadFromFile(): FileConfig | null {
|
||||
if (!existsSync(configPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = readFileSync(configPath, 'utf-8');
|
||||
const rawConfig = JSON.parse(raw);
|
||||
return fileConfigSchema.parse(rawConfig);
|
||||
} catch (error) {
|
||||
console.error("Failed to load config from file:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function applyFileConfig(fileConfig: FileConfig) {
|
||||
Object.assign(config, {
|
||||
leveling: fileConfig.leveling,
|
||||
economy: fileConfig.economy,
|
||||
inventory: fileConfig.inventory,
|
||||
commands: fileConfig.commands,
|
||||
lootdrop: fileConfig.lootdrop,
|
||||
trivia: fileConfig.trivia,
|
||||
moderation: fileConfig.moderation,
|
||||
system: fileConfig.system,
|
||||
studentRole: fileConfig.studentRole,
|
||||
visitorRole: fileConfig.visitorRole,
|
||||
colorRoles: fileConfig.colorRoles,
|
||||
welcomeChannelId: fileConfig.welcomeChannelId,
|
||||
welcomeMessage: fileConfig.welcomeMessage,
|
||||
feedbackChannelId: fileConfig.feedbackChannelId,
|
||||
terminal: fileConfig.terminal,
|
||||
});
|
||||
}
|
||||
export const GameConfig = config;
|
||||
|
||||
async function loadFromDatabase(): Promise<boolean> {
|
||||
try {
|
||||
@@ -358,16 +160,8 @@ async function loadFromDatabase(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function reloadConfig(): Promise<void> {
|
||||
const dbLoaded = await loadFromDatabase();
|
||||
|
||||
if (!dbLoaded) {
|
||||
const fileConfig = loadFromFile();
|
||||
if (fileConfig) {
|
||||
applyFileConfig(fileConfig);
|
||||
console.log("📄 Game config loaded from file (database not available).");
|
||||
} else {
|
||||
console.warn("⚠️ No game config found in database or file. Using defaults.");
|
||||
async function loadDefaults(): Promise<void> {
|
||||
console.warn("⚠️ No game config found in database. Using defaults.");
|
||||
const { gameSettingsService } = await import('@shared/modules/game-settings/game-settings.service');
|
||||
const defaults = gameSettingsService.getDefaults();
|
||||
Object.assign(config, {
|
||||
@@ -399,47 +193,15 @@ export async function reloadConfig(): Promise<void> {
|
||||
system: defaults.system,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function loadFileSync(): void {
|
||||
const fileConfig = loadFromFile();
|
||||
if (fileConfig) {
|
||||
applyFileConfig(fileConfig);
|
||||
console.log("📄 Game config loaded from file (sync).");
|
||||
}
|
||||
}
|
||||
export async function reloadConfig(): Promise<void> {
|
||||
const dbLoaded = await loadFromDatabase();
|
||||
|
||||
export const GameConfig = config;
|
||||
|
||||
export function saveConfig(newConfig: unknown) {
|
||||
const validatedConfig = fileConfigSchema.parse(newConfig);
|
||||
const jsonString = JSON.stringify(validatedConfig, jsonReplacer, 4);
|
||||
writeFileSync(configPath, jsonString, 'utf-8');
|
||||
applyFileConfig(validatedConfig);
|
||||
console.log("🔄 Config saved to file.");
|
||||
if (!dbLoaded) {
|
||||
await loadDefaults();
|
||||
}
|
||||
|
||||
export function toggleCommand(commandName: string, enabled: boolean) {
|
||||
const fileConfig = loadFromFile();
|
||||
if (!fileConfig) {
|
||||
console.error("Cannot toggle command: no file config available");
|
||||
return;
|
||||
}
|
||||
|
||||
const newConfig = {
|
||||
...fileConfig,
|
||||
commands: {
|
||||
...fileConfig.commands,
|
||||
[commandName]: enabled
|
||||
}
|
||||
};
|
||||
saveConfig(newConfig);
|
||||
}
|
||||
|
||||
export async function initializeConfig(): Promise<void> {
|
||||
loadFileSync();
|
||||
await reloadConfig();
|
||||
}
|
||||
|
||||
loadFileSync();
|
||||
|
||||
@@ -14,19 +14,7 @@ const mockReturning = mock();
|
||||
const mockSet = mock();
|
||||
const mockWhere = mock();
|
||||
|
||||
// Mock Config
|
||||
const mockConfig = {
|
||||
moderation: {
|
||||
cases: {
|
||||
dmOnWarn: true,
|
||||
autoTimeoutThreshold: 3
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
mock.module("@shared/lib/config", () => ({
|
||||
config: mockConfig
|
||||
}));
|
||||
|
||||
// Mock View
|
||||
const mockGetUserWarningEmbed = mock(() => ({}));
|
||||
@@ -66,9 +54,6 @@ describe("ModerationService", () => {
|
||||
mockSet.mockClear();
|
||||
mockWhere.mockClear();
|
||||
mockGetUserWarningEmbed.mockClear();
|
||||
// Reset config to defaults
|
||||
mockConfig.moderation.cases.dmOnWarn = true;
|
||||
mockConfig.moderation.cases.autoTimeoutThreshold = 3;
|
||||
});
|
||||
|
||||
describe("issueWarning", () => {
|
||||
@@ -100,7 +85,6 @@ describe("ModerationService", () => {
|
||||
});
|
||||
|
||||
it("should not DM if dmOnWarn is false", async () => {
|
||||
mockConfig.moderation.cases.dmOnWarn = false;
|
||||
mockFindFirst.mockResolvedValue({ caseId: "CASE-0001" });
|
||||
mockReturning.mockResolvedValue([{ caseId: "CASE-0002" }]);
|
||||
mockFindMany.mockResolvedValue([]);
|
||||
@@ -109,7 +93,8 @@ describe("ModerationService", () => {
|
||||
|
||||
await ModerationService.issueWarning({
|
||||
...defaultOptions,
|
||||
dmTarget: mockDmTarget
|
||||
dmTarget: mockDmTarget,
|
||||
config: { dmOnWarn: false }
|
||||
});
|
||||
|
||||
expect(mockDmTarget.send).not.toHaveBeenCalled();
|
||||
@@ -125,7 +110,8 @@ describe("ModerationService", () => {
|
||||
|
||||
const result = await ModerationService.issueWarning({
|
||||
...defaultOptions,
|
||||
timeoutTarget: mockTimeoutTarget
|
||||
timeoutTarget: mockTimeoutTarget,
|
||||
config: { autoTimeoutThreshold: 3 }
|
||||
});
|
||||
|
||||
expect(result.autoTimeoutIssued).toBe(true);
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { CreateCaseOptions, ClearCaseOptions, SearchCasesFilter } from "@/m
|
||||
import { getUserWarningEmbed } from "@/modules/moderation/moderation.view";
|
||||
import { CaseType } from "@shared/lib/constants";
|
||||
|
||||
export interface ModerationConfig {
|
||||
export interface ModerationCaseConfig {
|
||||
dmOnWarn?: boolean;
|
||||
autoTimeoutThreshold?: number;
|
||||
}
|
||||
@@ -66,7 +66,7 @@ export class ModerationService {
|
||||
guildName?: string;
|
||||
dmTarget?: { send: (options: any) => Promise<any> };
|
||||
timeoutTarget?: { timeout: (duration: number, reason: string) => Promise<any> };
|
||||
config?: ModerationConfig;
|
||||
config?: ModerationCaseConfig;
|
||||
}) {
|
||||
const moderationCase = await this.createCase({
|
||||
type: CaseType.WARN,
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import { guildSettingsService } from "@shared/modules/guild-settings/guild-settings.service";
|
||||
import { config } from "@shared/lib/config";
|
||||
import { env } from "@shared/lib/env";
|
||||
|
||||
async function migrateConfigToDatabase() {
|
||||
const guildId = env.DISCORD_GUILD_ID;
|
||||
|
||||
if (!guildId) {
|
||||
console.error("DISCORD_GUILD_ID not set. Cannot migrate config.");
|
||||
console.log("Set DISCORD_GUILD_ID in your environment to migrate config.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Migrating config for guild ${guildId}...`);
|
||||
|
||||
const existing = await guildSettingsService.getSettings(guildId);
|
||||
if (existing) {
|
||||
console.log("Guild settings already exist in database:");
|
||||
console.log(JSON.stringify(existing, null, 2));
|
||||
console.log("\nSkipping migration. Delete existing settings first if you want to re-migrate.");
|
||||
return;
|
||||
}
|
||||
|
||||
await guildSettingsService.upsertSettings({
|
||||
guildId,
|
||||
studentRoleId: config.studentRole ?? undefined,
|
||||
visitorRoleId: config.visitorRole ?? undefined,
|
||||
colorRoleIds: config.colorRoles ?? [],
|
||||
welcomeChannelId: config.welcomeChannelId ?? undefined,
|
||||
welcomeMessage: config.welcomeMessage ?? undefined,
|
||||
feedbackChannelId: config.feedbackChannelId ?? undefined,
|
||||
terminalChannelId: config.terminal?.channelId ?? undefined,
|
||||
terminalMessageId: config.terminal?.messageId ?? undefined,
|
||||
moderationLogChannelId: config.moderation?.cases?.logChannelId ?? undefined,
|
||||
moderationDmOnWarn: config.moderation?.cases?.dmOnWarn ?? true,
|
||||
moderationAutoTimeoutThreshold: config.moderation?.cases?.autoTimeoutThreshold ?? undefined,
|
||||
});
|
||||
|
||||
console.log("✅ Migration complete!");
|
||||
console.log("\nGuild settings are now stored in the database.");
|
||||
console.log("You can manage them via:");
|
||||
console.log(" - /settings command in Discord");
|
||||
console.log(" - API endpoints at /api/guilds/:guildId/settings");
|
||||
}
|
||||
|
||||
migrateConfigToDatabase()
|
||||
.then(() => process.exit(0))
|
||||
.catch((error) => {
|
||||
console.error("Migration failed:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -7,13 +7,6 @@
|
||||
import type { RouteContext, RouteModule } from "./types";
|
||||
import { jsonResponse, errorResponse, withErrorHandling } from "./utils";
|
||||
|
||||
/**
|
||||
* JSON replacer for BigInt serialization.
|
||||
*/
|
||||
function jsonReplacer(_key: string, value: unknown): unknown {
|
||||
return typeof value === "bigint" ? value.toString() : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings routes handler.
|
||||
*
|
||||
@@ -32,26 +25,31 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
|
||||
/**
|
||||
* @route GET /api/settings
|
||||
* @description Returns the current bot configuration.
|
||||
* @description Returns the current bot configuration from database.
|
||||
* Configuration includes economy settings, leveling settings,
|
||||
* command toggles, and other system settings.
|
||||
* @response 200 - Full configuration object
|
||||
* @response 200 - Full configuration object (DB format with strings for BigInts)
|
||||
* @response 500 - Error fetching settings
|
||||
*
|
||||
* @example
|
||||
* // Response
|
||||
* {
|
||||
* "economy": { "dailyReward": 100, "streakBonus": 10 },
|
||||
* "leveling": { "xpPerMessage": 15, "levelUpChannel": "123456789" },
|
||||
* "economy": { "daily": { "amount": "100", "streakBonus": "10" } },
|
||||
* "leveling": { "base": 100, "exponent": 1.5 },
|
||||
* "commands": { "disabled": [], "channelLocks": {} }
|
||||
* }
|
||||
*/
|
||||
if (pathname === "/api/settings" && method === "GET") {
|
||||
return withErrorHandling(async () => {
|
||||
const { config } = await import("@shared/lib/config");
|
||||
return new Response(JSON.stringify(config, jsonReplacer), {
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
const { gameSettingsService } = await import("@shared/modules/game-settings/game-settings.service");
|
||||
const settings = await gameSettingsService.getSettings();
|
||||
|
||||
if (!settings) {
|
||||
// Return defaults if no settings in DB yet
|
||||
return jsonResponse(gameSettingsService.getDefaults());
|
||||
}
|
||||
|
||||
return jsonResponse(settings);
|
||||
}, "fetch settings");
|
||||
}
|
||||
|
||||
@@ -61,7 +59,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
* Only the provided fields will be updated; other settings remain unchanged.
|
||||
* After updating, commands are automatically reloaded.
|
||||
*
|
||||
* @body Partial configuration object
|
||||
* @body Partial configuration object (DB format with strings for BigInts)
|
||||
* @response 200 - `{ success: true }`
|
||||
* @response 400 - Validation error
|
||||
* @response 500 - Error saving settings
|
||||
@@ -69,19 +67,15 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
* @example
|
||||
* // Request - Only update economy daily reward
|
||||
* POST /api/settings
|
||||
* { "economy": { "dailyReward": 150 } }
|
||||
* { "economy": { "daily": { "amount": "150" } } }
|
||||
*/
|
||||
if (pathname === "/api/settings" && method === "POST") {
|
||||
try {
|
||||
const partialConfig = await req.json();
|
||||
const { saveConfig, config: currentConfig } = await import("@shared/lib/config");
|
||||
const { deepMerge } = await import("@shared/lib/utils");
|
||||
const partialConfig = await req.json() as Record<string, unknown>;
|
||||
const { gameSettingsService } = await import("@shared/modules/game-settings/game-settings.service");
|
||||
|
||||
// Merge partial update into current config
|
||||
const mergedConfig = deepMerge(currentConfig, partialConfig);
|
||||
|
||||
// saveConfig throws if validation fails
|
||||
saveConfig(mergedConfig);
|
||||
// Use upsertSettings to merge partial update
|
||||
await gameSettingsService.upsertSettings(partialConfig as Record<string, unknown>);
|
||||
|
||||
const { systemEvents, EVENTS } = await import("@shared/lib/events");
|
||||
systemEvents.emit(EVENTS.ACTIONS.RELOAD_COMMANDS);
|
||||
|
||||
Reference in New Issue
Block a user