Skip to content

Commit

Permalink
feat(extensions): elegant extension configuration support
Browse files Browse the repository at this point in the history
  • Loading branch information
virtual-designer committed Feb 16, 2024
1 parent 75630b0 commit d0344a2
Show file tree
Hide file tree
Showing 6 changed files with 250 additions and 65 deletions.
4 changes: 2 additions & 2 deletions src/core/Client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,8 @@ class Client<R extends boolean = boolean> extends DiscordJSClient<R> {

public readonly services = [
"@services/StartupManager",
"@services/ConfigManager",
"@services/ConfigManager" /* This service is manually booted by the Extension Service. */,
"@services/ExtensionService",
"@services/CommandManager",
"@services/InfractionManager",
"@services/LoggerService",
Expand All @@ -111,7 +112,6 @@ class Client<R extends boolean = boolean> extends DiscordJSClient<R> {
"@services/InviteTrackerService",
"@services/BallotManager",
"@services/TriggerService",
"@services/ExtensionService",
"@services/BumpReminderService",
"@services/LogServer",
"@services/CooldownService",
Expand Down
26 changes: 23 additions & 3 deletions src/core/Extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,22 +17,42 @@
* along with SudoBot. If not, see <https://www.gnu.org/licenses/>.
*/

import { Awaitable } from "discord.js";
import { ZodSchema } from "zod";
import Client from "./Client";

export abstract class Extension {
public readonly name?: string;

constructor(protected readonly client: Client) {}

async commands(): Promise<string[] | null> {
commands(): Awaitable<string[] | null> {
return null;
}

async events(): Promise<string[] | null> {
events(): Awaitable<string[] | null> {
return null;
}

async services(): Promise<string[] | null> {
services(): Awaitable<string[] | null> {
return null;
}

guildConfig(): Awaitable<
| {
[K in PropertyKey]: ZodSchema<unknown>;
}
| null
> {
return null;
}

systemConfig(): Awaitable<
| {
[K in PropertyKey]: ZodSchema<unknown>;
}
| null
> {
return null;
}
}
7 changes: 3 additions & 4 deletions src/polyfills/FileSystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export default class FileSystem {

/**
* Writes the contents to a file.
*
*
* @param path - The path of the file to write to.
* @param contents - The contents to write to the file.
* @returns A promise that resolves when the file is written.
Expand All @@ -62,11 +62,10 @@ export default class FileSystem {
static async writeFileContents(path: string, contents: Stringable, json: boolean = false): Promise<void> {
if (process.versions.bun) {
await Bun.write(path, json ? JSON.stringify(contents) : contents.toString());
}
else {
} else {
await writeFile(path, json ? JSON.stringify(contents) : contents.toString(), { encoding: "utf-8" });
}
}
}

type Stringable = { toString: () => string } | string;
type Stringable = { toString: () => string } | string;
127 changes: 113 additions & 14 deletions src/services/ConfigManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,31 +17,62 @@
* along with SudoBot. If not, see <https://www.gnu.org/licenses/>.
*/

import { Snowflake } from "discord.js";
import fs, { writeFile } from "fs/promises";
import { z } from "zod";
import path from "path";
import { AnyZodObject, z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";
import type { Extension } from "../core/Extension";
import Service from "../core/Service";
import { GuildConfigSchema } from "../types/GuildConfigSchema";
import FileSystem from "../polyfills/FileSystem";
import { GuildConfig, GuildConfigSchema } from "../types/GuildConfigSchema";
import { SystemConfig, SystemConfigSchema } from "../types/SystemConfigSchema";
import { log, logInfo } from "../utils/logger";
import { log, logDebug, logInfo } from "../utils/logger";
import { sudoPrefix } from "../utils/utils";

export * from "../types/GuildConfigSchema";

export const name = "configManager";

export const GuildConfigContainerSchema = z.record(z.string(), GuildConfigSchema.optional().or(z.undefined()));
export type GuildConfigContainer = z.infer<typeof GuildConfigContainerSchema>;
export type GuildConfigContainer = {
[key: string]: GuildConfig | undefined;
};

export default class ConfigManager extends Service {
public readonly configPath = sudoPrefix("config/config.json");
public readonly systemConfigPath = sudoPrefix("config/system.json");
protected configSchemaPath = "";
public readonly schemaDirectory = sudoPrefix("config/schema", true);
public readonly configSchemaPath = path.join(this.schemaDirectory, "config.json");
public readonly systemConfigSchemaPath = path.join(this.schemaDirectory, "system.json");

protected configSchemaInfo = "";
protected systemConfigSchemaInfo = "";
protected loaded = false;
protected guildConfigSchema = GuildConfigSchema;
protected systemConfigSchema = SystemConfigSchema;
protected guildConfigContainerSchema = this.guildConfigContainer();

config: GuildConfigContainer = {} as GuildConfigContainer;
systemConfig: SystemConfig = {} as SystemConfig;

async boot() {
await this.load();
/**
* This service is manually booted by the Extension Service.
*/
async manualBoot() {
await this.loadOnce();
}

private guildConfigContainer() {
return z.record(z.string(), this.guildConfigSchema.optional().or(z.undefined()));
}

loadOnce() {
if (this.loaded) {
return;
}

this.loaded = true;
return this.load();
}

async load() {
Expand All @@ -52,14 +83,20 @@ export default class ConfigManager extends Service {
const configFileContents = await fs.readFile(this.configPath, { encoding: "utf-8" });

const configJSON = JSON.parse(configFileContents);
const systemConfigJSON = JSON.parse(systemConfigFileContents);

if ("$schema" in configJSON) {
this.configSchemaPath = configJSON.$schema;
this.configSchemaInfo = configJSON.$schema;
delete configJSON.$schema;
}

this.config = GuildConfigContainerSchema.parse(configJSON);
this.systemConfig = SystemConfigSchema.parse(JSON.parse(systemConfigFileContents));
if ("$schema" in systemConfigJSON) {
this.systemConfigSchemaInfo = systemConfigJSON.$schema;
delete systemConfigJSON.$schema;
}

this.config = this.guildConfigContainerSchema.parse(configJSON);
this.systemConfig = this.systemConfigSchema.parse(systemConfigJSON);
logInfo("Successfully loaded the configuration files");
}

Expand All @@ -73,9 +110,13 @@ export default class ConfigManager extends Service {
}

logInfo(`Auto configuring default settings for guild: ${id}`);
this.config[id] = GuildConfigSchema.parse({});
this.config[id] = this.guildConfigSchema.parse({});
}
}

if (!process.env.NO_GENERATE_CONFIG_SCHEMA) {
this.generateSchema();
}
}

async write({ guild = true, system = false } = {}) {
Expand All @@ -84,7 +125,7 @@ export default class ConfigManager extends Service {

const json = JSON.stringify(
{
$schema: this.configSchemaPath,
$schema: this.configSchemaInfo,
...this.config
},
null,
Expand All @@ -97,10 +138,68 @@ export default class ConfigManager extends Service {
if (system) {
log(`Writing system configuration to file: ${this.systemConfigPath}`);

const json = JSON.stringify(this.systemConfig, null, 4);
const json = JSON.stringify(
{
$schema: this.systemConfigSchemaInfo,
...this.systemConfig
},
null,
4
);

await writeFile(this.systemConfigPath, json, { encoding: "utf-8" });
}

logInfo("Successfully wrote the configuration files");
}

get<T extends GuildConfig = GuildConfig>(guildId: Snowflake): T | undefined {
return this.config[guildId] as T | undefined;
}

set(guildId: Snowflake, value: GuildConfig) {
this.config[guildId] = value;
}

async registerExtensionConfig(extensions: Extension[]) {
if (extensions.length === 0) {
return;
}

logDebug("Registering extension configuration schemas");

let finalGuildConfigSchema: AnyZodObject = this.guildConfigSchema;
let finalSystemConfigSchema: AnyZodObject = this.systemConfigSchema;

for (const extension of extensions) {
const guildConfigSchema = await extension.guildConfig();
const systemConfigSchema = await extension.systemConfig();

if (guildConfigSchema) {
finalGuildConfigSchema = finalGuildConfigSchema.extend(guildConfigSchema);
}

if (systemConfigSchema) {
finalSystemConfigSchema = finalSystemConfigSchema.extend(systemConfigSchema);
}
}

this.systemConfigSchema = finalSystemConfigSchema as typeof this.systemConfigSchema;
this.guildConfigSchema = finalGuildConfigSchema as typeof this.guildConfigSchema;
this.guildConfigContainerSchema = this.guildConfigContainer();
}

async generateSchema() {
if (!FileSystem.exists(this.configSchemaPath)) {
const configSchema = JSON.stringify(zodToJsonSchema(this.guildConfigContainerSchema), null, 4);
await writeFile(this.configSchemaPath, configSchema, { encoding: "utf-8" });
logInfo("Successfully generated the guild configuration schema file");
}

if (!FileSystem.exists(this.systemConfigSchemaPath)) {
const systemConfigSchema = JSON.stringify(zodToJsonSchema(this.systemConfigSchema), null, 4);
await writeFile(this.systemConfigSchemaPath, systemConfigSchema, { encoding: "utf-8" });
logInfo("Successfully generated the system configuration schema file");
}
}
}
Loading

0 comments on commit d0344a2

Please sign in to comment.