diff --git a/src/commands/settings/ConfigCommand.ts b/src/commands/settings/ConfigCommand.ts
new file mode 100644
index 000000000..8192e3093
--- /dev/null
+++ b/src/commands/settings/ConfigCommand.ts
@@ -0,0 +1,410 @@
+/**
+ * This file is part of SudoBot.
+ *
+ * Copyright (C) 2021-2024 OSN Developers.
+ *
+ * SudoBot is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * SudoBot is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with SudoBot. If not, see .
+ */
+
+import {
+ ChatInputCommandInteraction,
+ Colors,
+ EmbedBuilder,
+ Interaction,
+ Message,
+ PermissionsBitField,
+ SlashCommandBuilder,
+ codeBlock,
+ escapeInlineCode,
+ inlineCode
+} from "discord.js";
+import Command, { ArgumentType, BasicCommandContext, CommandMessage, CommandReturn, ValidationRule } from "../../core/Command";
+import { get, has, set, toDotted } from "../../utils/objects";
+import JSON5 from "json5";
+import { HasEventListeners } from "../../types/HasEventListeners";
+import { GatewayEventListener } from "../../decorators/GatewayEventListener";
+import Client from "../../core/Client";
+
+export default class ConfigCommand extends Command implements HasEventListeners {
+ public readonly name = "config";
+ public readonly subcommands = ["get", "set", "save", "restore"];
+ public readonly validationRules: ValidationRule[] = [
+ {
+ types: [ArgumentType.String],
+ name: "subcommand",
+ optional: false,
+ errors: {
+ required: `You must provide a subcommand. The valid subcommands are \`${this.subcommands.join("`, `")}\`.`
+ }
+ }
+ ];
+ public readonly permissions = [PermissionsBitField.Flags.ManageGuild];
+ public readonly aliases = ["setting", "settings"];
+ public readonly description = "View or change a configuration setting.";
+ public readonly argumentSyntaxes = [" [value]"];
+ public readonly slashCommandBuilder = new SlashCommandBuilder()
+ .addSubcommand(subcommand =>
+ subcommand
+ .setName("get")
+ .setDescription("Get the value of a configuration key")
+ .addStringOption(option =>
+ option
+ .setName("key")
+ .setDescription("The configuration key to view or change.")
+ .setAutocomplete(true)
+ .setRequired(true)
+ )
+ .addStringOption(option =>
+ option.setName("config_type").setDescription("The configuration type").setChoices(
+ {
+ name: "Guild",
+ value: "guild"
+ },
+ {
+ name: "System",
+ value: "system"
+ }
+ )
+ )
+ )
+ .addSubcommand(subcommand =>
+ subcommand
+ .setName("set")
+ .setDescription("Set the value of a configuration key")
+ .addStringOption(option =>
+ option
+ .setName("key")
+ .setDescription("The configuration key to view or change.")
+ .setAutocomplete(true)
+ .setRequired(true)
+ )
+ .addStringOption(option =>
+ option.setName("value").setDescription("The new value to set the configuration key to.").setRequired(true)
+ )
+ .addStringOption(option =>
+ option.setName("cast").setDescription("The type to cast the value to.").setChoices(
+ {
+ name: "String",
+ value: "string"
+ },
+ {
+ name: "Number",
+ value: "number"
+ },
+ {
+ name: "Boolean",
+ value: "boolean"
+ },
+ {
+ name: "JSON",
+ value: "json"
+ }
+ )
+ )
+ .addBooleanOption(option => option.setName("save").setDescription("Save the current configuration immediately."))
+ .addBooleanOption(option =>
+ option.setName("no_create").setDescription("Do not create the key if it does not exist.")
+ )
+ .addStringOption(option =>
+ option.setName("config_type").setDescription("The configuration type").setChoices(
+ {
+ name: "Guild",
+ value: "guild"
+ },
+ {
+ name: "System",
+ value: "system"
+ }
+ )
+ )
+ )
+ .addSubcommand(subcommand => subcommand.setName("save").setDescription("Save the current configuration."))
+ .addSubcommand(subcommand => subcommand.setName("restore").setDescription("Restore the previously saved configuration."));
+ protected readonly dottedConfig = {
+ guild: {} as Record,
+ system: [] as string[]
+ };
+
+ constructor(client: Client) {
+ super(client);
+ this.reloadDottedConfig();
+ }
+
+ reloadDottedConfig(configType: "guild" | "system" | null = null) {
+ if (!configType || configType === "guild") {
+ const guildConfig: Record = {};
+
+ for (const key in this.client.configManager.config) {
+ guildConfig[key] = Object.keys(toDotted(this.client.configManager.config[key]!));
+ }
+
+ this.dottedConfig.guild = guildConfig;
+ }
+
+ if (!configType || configType === "system") {
+ this.dottedConfig.system = Object.keys(toDotted(this.client.configManager.systemConfig));
+ }
+ }
+
+ @GatewayEventListener("interactionCreate")
+ async onInteractionCreate(interaction: Interaction) {
+ if (!interaction.isAutocomplete() || interaction.commandName !== this.name) {
+ return;
+ }
+
+ const query = interaction.options.getFocused();
+ const configType = (interaction.options.getString("config_type") ?? "guild") as "guild" | "system";
+ const config = configType === "guild" ? this.dottedConfig.guild[interaction.guildId!] : this.dottedConfig.system;
+ const keys = [];
+
+ for (const key of config) {
+ if (keys.length >= 25) {
+ break;
+ }
+
+ if (key.includes(query)) {
+ keys.push({ name: key, value: key });
+ }
+ }
+
+ await interaction.respond(keys);
+ }
+
+ async execute(message: CommandMessage, context: BasicCommandContext): Promise {
+ await this.deferIfInteraction(message);
+
+ const subcommand = context.isLegacy ? context.parsedNamedArgs.subcommand : context.options.getSubcommand(true);
+
+ switch (subcommand) {
+ case "get":
+ return this.get(message, context);
+ case "set":
+ return this.set(message, context);
+ case "save":
+ return this.save(message);
+ case "restore":
+ return this.restore(message);
+ default:
+ await this.error(
+ message,
+ `The subcommand \`${escapeInlineCode(
+ subcommand
+ )}\` does not exist. Please use one of the following subcommands: \`${this.subcommands.join("`, `")}\`.`
+ );
+ return;
+ }
+ }
+
+ private async get(message: CommandMessage, context: BasicCommandContext): Promise {
+ const key = context.isLegacy ? context.args[1] : context.options.getString("key", true);
+
+ if (!key) {
+ await this.error(message, "You must provide a configuration key to view.");
+ return;
+ }
+
+ const configType = (context.isLegacy ? "guild" : context.options.getString("config_type") ?? "guild") as
+ | "guild"
+ | "system";
+ const config = configType === "guild" ? context.config : this.client.configManager.systemConfig;
+
+ if (!has(config, key)) {
+ await this.error(message, `The configuration key \`${escapeInlineCode(key)}\` does not exist.`);
+ return;
+ }
+
+ const configValue = get(config, key);
+ const embed = new EmbedBuilder()
+ .setTitle("Configuration Value")
+ .setDescription(
+ `### ${inlineCode(key)}\n\n${codeBlock(
+ "json",
+ JSON5.stringify(configValue, {
+ space: 2,
+ replacer: null,
+ quote: '"'
+ })
+ )}`
+ )
+ .setColor(Colors.Green)
+ .setTimestamp();
+
+ await this.deferredReply(message, { embeds: [embed] });
+ }
+
+ private async set(message: CommandMessage, context: BasicCommandContext): Promise {
+ if (context.isLegacy) {
+ if (!context.args[1]) {
+ await this.error(message, "You must provide a configuration key to set.");
+ return;
+ }
+
+ if (!context.args[2]) {
+ await this.error(message, "You must provide a value to set the configuration key to.");
+ return;
+ }
+ }
+
+ const key = context.isLegacy ? context.args[1] : context.options.getString("key", true);
+ const value =
+ message instanceof Message && context.isLegacy
+ ? message.content
+ .slice(context.prefix.length)
+ .trimStart()
+ .slice(context.argv[0].length)
+ .trimStart()
+ .slice(context.argv[1].length)
+ .trimStart()
+ .slice(context.argv[2].length)
+ .trim() // FIXME: Extract this into a method
+ : (message as ChatInputCommandInteraction).options.getString("value", true);
+ const cast = (context.isLegacy ? "json" : context.options.getString("cast") ?? "string") as CastType;
+ const save = context.isLegacy ? false : context.options.getBoolean("save");
+ const noCreate = context.isLegacy ? false : context.options.getBoolean("no_create");
+ const configType = (context.isLegacy ? "guild" : context.options.getString("config_type") ?? "guild") as
+ | "guild"
+ | "system";
+ const config = configType === "guild" ? context.config : this.client.configManager.systemConfig;
+
+ if (!key) {
+ await this.error(message, "You must provide a configuration key to set.");
+ return;
+ }
+
+ if (noCreate && !has(config, key)) {
+ await this.error(message, `The configuration key \`${escapeInlineCode(key)}\` does not exist.`);
+ return;
+ }
+
+ let finalValue;
+
+ switch (cast) {
+ case "string":
+ finalValue = value;
+ break;
+ case "number":
+ finalValue = parseFloat(value);
+
+ if (isNaN(finalValue)) {
+ await this.error(message, `The value \`${escapeInlineCode(value)}\` is not a valid number.`);
+ return;
+ }
+
+ break;
+ case "boolean":
+ const lowerCased = value.toLowerCase();
+
+ if (lowerCased !== "true" && lowerCased !== "false") {
+ await this.error(message, `The value \`${escapeInlineCode(value)}\` is not a valid boolean.`);
+ return;
+ }
+
+ finalValue = lowerCased === "true";
+ break;
+ case "json":
+ try {
+ finalValue = JSON5.parse(value);
+ } catch (e) {
+ const error = codeBlock(e instanceof Object && "message" in e ? `${e.message}` : `${e}`);
+ await this.deferredReply(message, {
+ embeds: [
+ {
+ description: `### ${this.emoji("error")} Failed to parse the value as JSON\n\n${error.slice(
+ 0,
+ 1800
+ )}${error.length > 1800 ? "\n... The error message is loo long." : ""}`,
+ color: Colors.Red,
+ footer: {
+ text: "No changes were made to the configuration"
+ },
+ timestamp: new Date().toISOString()
+ }
+ ]
+ });
+
+ return;
+ }
+
+ break;
+ }
+
+ set(config, key, finalValue);
+
+ const embed = new EmbedBuilder();
+ const error = this.client.configManager.testConfig();
+ const errorString = error
+ ? JSON5.stringify(error.error.format(), {
+ space: 2,
+ replacer: null,
+ quote: '"'
+ })
+ : null;
+
+ if (errorString && error) {
+ await this.client.configManager.load();
+
+ embed
+ .setDescription(
+ `### ${this.emoji("error")} The configuration is invalid (${inlineCode(
+ error.type
+ )})\n\nThe changes were not saved.\n\n${errorString.slice(0, 1800)}${
+ errorString.length > 1800 ? "\n... The error description is loo long." : ""
+ }`
+ )
+ .setColor(Colors.Red)
+ .setFooter({ text: "The configuration was not saved." });
+
+ await this.deferredReply(message, { embeds: [embed] });
+ return;
+ }
+
+ embed
+ .setTitle("Configuration Value Changed")
+ .setDescription(
+ `### ${inlineCode(key)}\n\n${codeBlock(
+ "json",
+ JSON5.stringify(finalValue, {
+ space: 2,
+ replacer: null,
+ quote: '"'
+ })
+ )}`
+ )
+ .setColor(Colors.Green)
+ .setTimestamp()
+ .setFooter({ text: `The configuration was ${save ? "saved" : "applied"}.` });
+
+ if (save) {
+ await this.client.configManager.write({
+ guild: configType === "guild",
+ system: configType === "system"
+ });
+ }
+
+ await this.deferredReply(message, { embeds: [embed] });
+ this.reloadDottedConfig(configType);
+ }
+
+ private async save(message: CommandMessage): Promise {
+ await this.client.configManager.write();
+ await this.success(message, "The configuration was saved.");
+ }
+
+ private async restore(message: CommandMessage): Promise {
+ await this.client.configManager.load();
+ await this.success(message, "The configuration was restored.");
+ }
+}
+
+type CastType = "string" | "number" | "boolean" | "json";
diff --git a/src/services/ConfigManager.ts b/src/services/ConfigManager.ts
index e6d96d793..5e3d5f845 100644
--- a/src/services/ConfigManager.ts
+++ b/src/services/ConfigManager.ts
@@ -45,8 +45,8 @@ export default class ConfigManager extends Service {
public readonly configSchemaPath = path.join(this.schemaDirectory, "config.json");
public readonly systemConfigSchemaPath = path.join(this.schemaDirectory, "system.json");
- protected configSchemaInfo = "";
- protected systemConfigSchemaInfo = "";
+ protected configSchemaInfo = "https://raw.githubusercontent.com/onesoft-sudo/sudobot/main/config/schema/config.json";
+ protected systemConfigSchemaInfo = "https://raw.githubusercontent.com/onesoft-sudo/sudobot/main/config/schema/system.json";
protected loaded = false;
protected guildConfigSchema = GuildConfigSchema;
protected systemConfigSchema = SystemConfigSchema;
@@ -119,6 +119,22 @@ export default class ConfigManager extends Service {
}
}
+ testConfig() {
+ const guildResult = this.guildConfigContainerSchema.safeParse(this.config);
+
+ if (!guildResult.success) {
+ return { error: guildResult.error, type: "guild" as const };
+ }
+
+ const systemResult = this.systemConfigSchema.safeParse(this.systemConfig);
+
+ if (!systemResult.success) {
+ return { error: systemResult.error, type: "system" as const };
+ }
+
+ return null;
+ }
+
async write({ guild = true, system = false } = {}) {
if (guild) {
log(`Writing guild configuration to file: ${this.configPath}`);
diff --git a/src/utils/objects.ts b/src/utils/objects.ts
new file mode 100644
index 000000000..558886c64
--- /dev/null
+++ b/src/utils/objects.ts
@@ -0,0 +1,94 @@
+type AccessOptions = {
+ noCreate?: boolean;
+ noModify?: boolean;
+ noArrayAccess?: boolean;
+ returnExists?: boolean;
+};
+
+const access = (object: object | unknown[], accessor: string, setter?: (value: unknown) => unknown, options?: AccessOptions) => {
+ const accessors = accessor.split(".");
+ let current: unknown = object;
+ let prevAccessor: string | undefined;
+
+ if (accessors.length === 0 || !accessor) {
+ return object;
+ }
+
+ for (const access of accessors) {
+ const last = access === accessors[accessors.length - 1];
+
+ if (current instanceof Object) {
+ if (!options?.noArrayAccess && /\[\d+\]$/.test(access)) {
+ const array = current[access.slice(0, access.indexOf("[")) as keyof typeof current] as unknown as Array;
+
+ if (!Array.isArray(array)) {
+ throw new Error(`Cannot access index ${access} of non-array value (${prevAccessor ?? "root"})`);
+ }
+
+ const index = parseInt(access.match(/\d+/)![0]);
+
+ if (Number.isNaN(index)) {
+ throw new Error(`Invalid index ${index} (${prevAccessor ?? "root"})`);
+ }
+
+ current = array[index];
+
+ if (options?.returnExists && last) {
+ return index in array;
+ }
+
+ if (setter && last && (!options?.noModify || !(index in array)) && (!options?.noCreate || index < array.length)) {
+ array[index] = setter(current);
+ }
+ } else {
+ if (Array.isArray(current)) {
+ return options?.returnExists ? false : undefined;
+ }
+
+ const value = current[access as keyof typeof current];
+
+ if (options?.returnExists && last) {
+ return access in current;
+ }
+
+ if (setter && last && (!options?.noModify || !(access in current)) && (!options?.noCreate || access in current)) {
+ current[access as keyof typeof current] = setter(current) as any;
+ }
+
+ current = value;
+ }
+ } else {
+ if (last) {
+ return options?.returnExists ? false : undefined;
+ }
+
+ return current;
+ }
+ }
+
+ return options?.returnExists ? true : current;
+};
+
+export const get = (object: object | unknown[], accessor: string, options?: AccessOptions) =>
+ access(object, accessor, undefined, options) as V;
+export const has = (object: object | unknown[], accessor: string, options?: AccessOptions) =>
+ access(object, accessor, undefined, { ...options, returnExists: true });
+export const set = (object: object | unknown[], accessor: string, value: unknown, options?: AccessOptions) =>
+ access(object, accessor, () => value, options);
+
+export const toDotted = (object: Record, arrayAccess = false) => {
+ const result: Record = {};
+
+ function recurse(current: Record, path: string[] = []) {
+ for (const key in current) {
+ if (current[key] instanceof Object && (arrayAccess || !Array.isArray(current[key]))) {
+ recurse(current[key] as Record, path.concat(key));
+ } else {
+ result[path.concat(key).join(".")] = current[key];
+ }
+ }
+ }
+
+ recurse(object);
+ return result;
+};