Skip to content

Commit

Permalink
feat: Add plugins. (#507)
Browse files Browse the repository at this point in the history
  • Loading branch information
vxern authored Jan 2, 2025
2 parents 2c4fcee + d14874a commit 395cf28
Show file tree
Hide file tree
Showing 11 changed files with 166 additions and 16 deletions.
8 changes: 7 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
.idea/
!.idea/modules.xml
!.idea/vcs.xml
logs/

node_modules/
dist/

logs/*
!logs/.keep
plugins/*
!plugins/.keep

.cert.pfx
.env
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## 4.51.0

- Added the ability to load 'plugins' at start-up to extend Logos by custom code.

## 4.50.0

- Added new commands:
Expand Down
Empty file added logs/.keep
Empty file.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"name": "logos",
"description": "A multi-purpose community bot built to cater to language-learning communities on Discord.",
"license": "Apache-2.0",
"version": "4.50.0",
"version": "4.51.0",
"type": "module",
"keywords": [
"discord",
Expand Down
Empty file added plugins/.keep
Empty file.
1 change: 1 addition & 0 deletions source/constants/directories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const directories = Object.freeze({
},
logs: "./logs",
migrations: "./migrations",
plugins: "./plugins",
} as const);

export default directories;
5 changes: 5 additions & 0 deletions source/library/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { LocalisationStore, type RawLocalisations } from "logos/stores/localisat
import { ServiceStore } from "logos/stores/services";
import { VolatileStore } from "logos/stores/volatile";
import type pino from "pino";
import { PluginStore } from "logos/stores/plugins";

class Client {
readonly log: pino.Logger;
Expand All @@ -32,6 +33,7 @@ class Client {
readonly #journalling: JournallingStore;
readonly #guilds: GuildStore;
readonly adapters: AdapterStore;
readonly #plugins: PluginStore;
readonly #connection: DiscordConnection;

readonly #channelDeletes: Collector<"channelDelete">;
Expand Down Expand Up @@ -208,6 +210,7 @@ class Client {
this.#journalling = new JournallingStore(this);
this.#guilds = new GuildStore(this, { services: this.services, commands: this.#commands });
this.adapters = new AdapterStore(this);
this.#plugins = new PluginStore(this);
this.#connection = new DiscordConnection({
log,
environment,
Expand Down Expand Up @@ -255,6 +258,7 @@ class Client {
await this.interactions.setup();
await this.#setupCollectors();
await this.#connection.open();
await this.#plugins.setup();

this.log.info("Client started.");
}
Expand All @@ -281,6 +285,7 @@ class Client {
await this.#guilds.teardown();
await this.interactions.teardown();
this.#teardownCollectors();
await this.#plugins.teardown();
await this.#connection.close();

this.#isStopping = false;
Expand Down
2 changes: 2 additions & 0 deletions source/library/services/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import type { Guild } from "logos/models/guild";
import type pino from "pino";

abstract class Service {
readonly identifier: string;
readonly log: pino.Logger;
readonly client: Client;

constructor(client: Client, { identifier }: { identifier: string }) {
this.identifier = identifier;
this.log = client.log.child({ name: identifier });
this.client = client;
}
Expand Down
81 changes: 81 additions & 0 deletions source/library/stores/plugins.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import type { Client } from "logos/client";
import type pino from "pino";

interface Plugin {
readonly filename: string;
readonly load: (client: Client) => Promise<void>;
readonly unload?: (client: Client) => Promise<void>;
}

class PluginStore {
readonly log: pino.Logger;

readonly #client: Client;
readonly #plugins: Plugin[];

constructor(client: Client) {
this.log = client.log.child({ name: "PluginStore" });

this.#client = client;
this.#plugins = [];
}

async setup(): Promise<void> {
this.log.info("Looking for plugins to load...");

const filenames = await PluginStore.#getPluginFilenames();
if (filenames.length === 0) {
this.log.info("There were no plugins to load.");
return;
}

this.log.info(`Found ${filenames.length} plugins. Loading...`);

await Promise.all(filenames.map((filename) => this.#loadPlugin(filename)));

this.log.info(`Loaded ${filenames.length} plugin(s).`);
}

async teardown(): Promise<void> {
this.log.info("Tearing down plugin store...");

if (this.#plugins.length > 0) {
this.log.info(`There are ${this.#plugins.length} plugin(s) to unload. Unloading...`);

await Promise.all(this.#plugins.map((plugin) => this.#unloadPlugin(plugin)));

this.log.info(`Unloaded ${this.#plugins.length} plugin(s).`);
} else {
this.log.info("No plugins to unload.");
}

this.log.info("Plugin store torn down.");
}

static async #getPluginFilenames(): Promise<string[]> {
return Array.fromAsync(new Bun.Glob("*.ts").scan(constants.directories.plugins));
}

async #loadPlugin(filename: string): Promise<void> {
const module = await import(`../../../${constants.directories.plugins}/${filename}`);
const plugin = { filename, ...module };

this.#plugins.push(plugin);

this.log.info(`Loading plugin ${plugin.filename}...`);

await plugin.load(this.#client);

this.log.info(`Loaded plugin ${plugin.filename}.`);
}

async #unloadPlugin(plugin: Plugin): Promise<void> {
this.log.info(`Unloading plugin ${plugin.filename}...`);

await plugin.unload?.(this.#client);

this.log.info(`Unloaded plugin ${plugin.filename}.`);
}
}

export { PluginStore };
77 changes: 64 additions & 13 deletions source/library/stores/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ interface LocalServices {
readonly dynamicVoiceChannels: DynamicVoiceChannelService;
readonly entry: EntryService;
readonly music: MusicService;
readonly informationNoticess: InformationNoticeService;
readonly informationNotices: InformationNoticeService;
readonly resourceNotices: ResourceNoticeService;
readonly roleNotices: RoleNoticeService;
readonly welcomeNotices: WelcomeNoticeService;
Expand All @@ -48,19 +48,27 @@ interface LocalServices {
readonly wordSigils: WordSigilService;
}

interface CustomServices {
readonly global: GlobalService[];
readonly local: Map<bigint, Set<LocalService>>;
}

class ServiceStore {
readonly log: pino.Logger;

readonly #client: Client;
readonly #global: GlobalServices;
readonly #local: { [K in keyof LocalServices]: Map<bigint, LocalServices[K]> };
readonly #custom: CustomServices;

get #globalServices(): GlobalService[] {
return Object.values(this.#global).filter(isDefined);
return [...Object.values(this.#global).filter(isDefined), ...this.#custom.global];
}

get #localServices(): LocalService[] {
return Object.values(this.#local).flatMap((services) => [...services.values()]);
return [...Object.values(this.#local), ...Object.values(this.#custom.local)].flatMap((services) => [
...services.values(),
]);
}

constructor(client: Client) {
Expand All @@ -85,7 +93,7 @@ class ServiceStore {
dynamicVoiceChannels: new Map(),
entry: new Map(),
music: new Map(),
informationNoticess: new Map(),
informationNotices: new Map(),
resourceNotices: new Map(),
roleNotices: new Map(),
welcomeNotices: new Map(),
Expand All @@ -97,12 +105,19 @@ class ServiceStore {
roleIndicators: new Map(),
wordSigils: new Map(),
};
this.#custom = {
global: [],
local: new Map(),
};
}

#localServicesFor({ guildId }: { guildId: bigint }): LocalService[] {
return Object.values(this.#local)
.map((services) => services.get(guildId))
.filter(isDefined);
return [
...Object.values(this.#local)
.map((services) => services.get(guildId))
.filter(isDefined),
...(this.#custom.local.get(guildId)?.values() ?? []),
];
}

async setup(): Promise<void> {
Expand All @@ -128,11 +143,7 @@ class ServiceStore {

this.log.info(`Starting global services... (${services.length} services to start)`);

const promises: Promise<void>[] = [];
for (const service of services) {
promises.push(service.start());
}
await Promise.all(promises);
await this.#startServices(services);

this.log.info("Global services started.");
}
Expand All @@ -154,7 +165,7 @@ class ServiceStore {
const service = new InformationNoticeService(this.#client, { guildId });
services.push(service);

this.#local.informationNoticess.set(guildId, service);
this.#local.informationNotices.set(guildId, service);
}

if (guildDocument.hasEnabled("resourceNotices")) {
Expand Down Expand Up @@ -318,6 +329,46 @@ class ServiceStore {
await Promise.all(services.map((service) => service.stop()));
}

/**
* Registers and starts a service at runtime.
*
* @remarks
* This should only be used for loading services inside of plugins.
*/
async loadLocalService(service: LocalService): Promise<void> {
this.log.info(`Loading local service ${service.identifier}...`);

if (this.#custom.local.has(service.guildId)) {
this.#custom.local.get(service.guildId)!.add(service);
} else {
this.#custom.local.set(service.guildId, new Set([service]));
}

await service.start();

this.log.info(`Local service ${service.identifier} has been loaded.`);
}

/**
* Unregisters and stops a service at runtime.
*
* @remarks
* This should only be used for unloading services inside of plugins.
*/
async unloadLocalService(service: LocalService, { guildId }: { guildId: bigint }): Promise<void> {
this.log.info(`Unloading custom local service ${service.identifier}...`);

const isRemoved = this.#custom.local.get(guildId)?.delete(service) ?? false;
if (isRemoved === undefined) {
this.log.warn(`Could not unload local service ${service.identifier}: It wasn't loaded previously.`);
return;
}

await service.stop();

this.log.info(`Local service ${service.identifier} has been unloaded.`);
}

hasGlobalService<K extends keyof GlobalServices>(service: K): boolean {
return this.#global[service] !== undefined;
}
Expand Down
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,5 +43,5 @@
},
"compileOnSave": true,
"exclude": ["node_modules/"],
"include": ["migrations/", "scripts/", "source/", "test/"]
"include": ["migrations/", "plugins/", "scripts/", "source/", "test/"]
}

0 comments on commit 395cf28

Please sign in to comment.