2025-06-01 13:52:06 +07:00
|
|
|
import { type StorageOptions, getStorage } from '@revenge-mod/storage'
|
2025-05-19 02:22:12 +07:00
|
|
|
import { allSettled, sleepReject } from '@revenge-mod/utils/promises'
|
2025-05-08 20:25:11 +07:00
|
|
|
|
2025-05-09 21:53:48 +07:00
|
|
|
import { _uapi } from './apis'
|
2025-05-18 22:24:48 +07:00
|
|
|
|
2025-05-22 02:45:38 +07:00
|
|
|
import { PluginFlags as Flag, PluginsStorageDirectory, PluginStatus as Status } from './constants'
|
2025-04-29 21:38:14 +07:00
|
|
|
|
2025-05-22 02:45:38 +07:00
|
|
|
import type { AnyObject } from '@revenge-mod/utils/types'
|
2025-05-08 18:52:57 +07:00
|
|
|
import type {
|
|
|
|
|
InitPluginApi,
|
|
|
|
|
Plugin,
|
|
|
|
|
PluginApi,
|
|
|
|
|
PluginCleanup,
|
|
|
|
|
PluginManifest,
|
2025-05-22 02:45:38 +07:00
|
|
|
PluginOptions,
|
2025-05-18 22:24:48 +07:00
|
|
|
PreInitPluginApi,
|
2025-05-09 21:53:48 +07:00
|
|
|
} from './types'
|
2025-04-29 21:38:14 +07:00
|
|
|
|
2025-05-18 22:24:48 +07:00
|
|
|
export const _plugins = new Map<PluginManifest['id'], InternalPlugin>()
|
|
|
|
|
export const _metas = new Map<
|
2025-05-09 21:53:48 +07:00
|
|
|
PluginManifest['id'],
|
2025-05-18 22:24:48 +07:00
|
|
|
[
|
|
|
|
|
api: PreInitPluginApi | InitPluginApi | PluginApi | undefined,
|
|
|
|
|
promises: Promise<void>[],
|
|
|
|
|
iflags: number,
|
|
|
|
|
apiLevel: number,
|
|
|
|
|
]
|
2025-05-09 21:53:48 +07:00
|
|
|
>()
|
2025-04-29 21:38:14 +07:00
|
|
|
|
2025-05-22 02:45:38 +07:00
|
|
|
export function registerPlugin<S extends AnyObject = AnyObject>(
|
|
|
|
|
manifest: PluginManifest,
|
|
|
|
|
options: PluginOptions<S>,
|
|
|
|
|
flags: number,
|
|
|
|
|
iflags: number,
|
|
|
|
|
) {
|
2025-04-29 21:38:14 +07:00
|
|
|
// TODO(plugins): verify plugin manifest
|
|
|
|
|
if (_plugins.has(manifest.id)) throw new Error(`Plugin with ID "${manifest.id}" already registered`)
|
|
|
|
|
|
|
|
|
|
const plugin: InternalPlugin = {
|
2025-05-22 02:45:38 +07:00
|
|
|
_c: [],
|
|
|
|
|
_s: options.storage,
|
2025-04-29 21:38:14 +07:00
|
|
|
errors: [],
|
|
|
|
|
manifest,
|
2025-05-22 02:45:38 +07:00
|
|
|
lifecycles: {
|
|
|
|
|
preInit: options.preInit,
|
|
|
|
|
init: options.init,
|
|
|
|
|
start: options.start,
|
|
|
|
|
stop: options.stop,
|
|
|
|
|
},
|
2025-05-01 01:18:36 +07:00
|
|
|
status: 0,
|
2025-04-29 21:38:14 +07:00
|
|
|
flags,
|
2025-05-18 22:24:48 +07:00
|
|
|
disable: () => disablePlugin(plugin),
|
|
|
|
|
stop: () => stopPlugin(plugin),
|
2025-05-09 21:53:48 +07:00
|
|
|
}
|
|
|
|
|
|
2025-05-18 22:24:48 +07:00
|
|
|
_metas.set(manifest.id, [undefined, [], iflags, PluginApiLevel.None])
|
2025-05-09 21:53:48 +07:00
|
|
|
_plugins.set(manifest.id, plugin)
|
|
|
|
|
}
|
2025-05-01 01:18:36 +07:00
|
|
|
|
2025-05-09 21:53:48 +07:00
|
|
|
function handlePluginError(e: unknown, plugin: InternalPlugin) {
|
|
|
|
|
plugin.errors.push(e)
|
|
|
|
|
plugin.flags |= Flag.Errored
|
2025-05-18 22:24:48 +07:00
|
|
|
const [api, , iflags] = _metas.get(plugin.manifest.id)!
|
|
|
|
|
|
|
|
|
|
const log = (api as InitPluginApi).logger ?? console
|
|
|
|
|
log.error('Plugin encountered an error', e)
|
|
|
|
|
|
|
|
|
|
if (!(iflags & InternalPluginFlags.Essential)) return plugin.disable()
|
2025-05-09 21:53:48 +07:00
|
|
|
}
|
2025-04-29 21:38:14 +07:00
|
|
|
|
2025-05-18 22:24:48 +07:00
|
|
|
function preparePluginPreInit(id: PluginManifest['id']) {
|
|
|
|
|
const plugin = _plugins.get(id)!
|
|
|
|
|
const meta = _metas.get(id)!
|
|
|
|
|
|
|
|
|
|
// Clear errors from previous runs
|
|
|
|
|
plugin.errors = []
|
|
|
|
|
plugin.status &= ~Flag.Errored
|
|
|
|
|
|
|
|
|
|
meta[0] = {
|
|
|
|
|
cleanup: (...items) => {
|
2025-05-22 02:45:38 +07:00
|
|
|
plugin._c.push(...items)
|
2025-05-18 22:24:48 +07:00
|
|
|
},
|
|
|
|
|
plugin,
|
|
|
|
|
unscoped: _uapi,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
meta[3] = PluginApiLevel.PreInit
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-22 02:45:38 +07:00
|
|
|
function preparePluginInit(id: PluginManifest['id'], storageOptions?: StorageOptions<AnyObject>) {
|
2025-05-18 22:24:48 +07:00
|
|
|
const meta = _metas.get(id)!
|
|
|
|
|
const api = meta[0] as InitPluginApi
|
|
|
|
|
|
2025-05-22 02:45:38 +07:00
|
|
|
Object.defineProperties(api, {
|
|
|
|
|
logger: {
|
|
|
|
|
configurable: true,
|
|
|
|
|
get() {
|
|
|
|
|
// @ts-expect-error
|
|
|
|
|
// biome-ignore lint/performance/noDelete: <explanation>
|
|
|
|
|
delete api.logger
|
|
|
|
|
return (api.logger = new api.unscoped.discord.common.Logger(`Revenge > Plugins (${id})`))
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
storage: {
|
|
|
|
|
configurable: true,
|
|
|
|
|
get() {
|
|
|
|
|
// @ts-expect-error
|
|
|
|
|
// biome-ignore lint/performance/noDelete: <explanation>
|
|
|
|
|
delete api.storage
|
|
|
|
|
return (api.storage = getStorage(`${PluginsStorageDirectory}/${id}.json`, {
|
|
|
|
|
...storageOptions,
|
|
|
|
|
directory: 'documents',
|
|
|
|
|
}))
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
2025-05-18 22:24:48 +07:00
|
|
|
|
|
|
|
|
meta[3] = PluginApiLevel.Init
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function preparePluginStart(id: PluginManifest['id']) {
|
|
|
|
|
const meta = _metas.get(id)!
|
|
|
|
|
|
|
|
|
|
// const api = meta[0] as PluginApi
|
|
|
|
|
// api.settings = ...
|
|
|
|
|
|
|
|
|
|
meta[3] = PluginApiLevel.Start
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function disablePlugin(plugin: InternalPlugin) {
|
2025-05-19 02:21:28 +07:00
|
|
|
const iflags = _metas.get(plugin.manifest.id)![2] ?? 0
|
2025-05-18 22:24:48 +07:00
|
|
|
if (iflags & InternalPluginFlags.Essential)
|
|
|
|
|
throw new Error(`Plugin "${plugin.manifest.id}" is essential and cannot be disabled`)
|
|
|
|
|
|
|
|
|
|
// If plugin is not stopped, and is also not stopping, we need to stop it
|
|
|
|
|
if (plugin.status && !(plugin.status & Status.Stopping)) await stopPlugin(plugin)
|
2025-04-29 21:38:14 +07:00
|
|
|
|
2025-05-18 22:24:48 +07:00
|
|
|
// TODO(plugins): write to storage
|
|
|
|
|
plugin.flags &= ~Flag.Enabled
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function enablePlugin(plugin: InternalPlugin, late: boolean) {
|
2025-05-09 21:53:48 +07:00
|
|
|
// TODO(plugins): write to storage
|
|
|
|
|
plugin.flags |= Flag.Enabled
|
2025-05-18 22:24:48 +07:00
|
|
|
if (late) plugin.flags |= Flag.EnabledLate
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function preInitPlugin(plugin: InternalPlugin) {
|
|
|
|
|
const { manifest, lifecycles } = plugin
|
2025-05-19 02:21:28 +07:00
|
|
|
if (!lifecycles.preInit) return
|
|
|
|
|
|
2025-05-18 22:24:48 +07:00
|
|
|
const meta = _metas.get(manifest.id)!
|
|
|
|
|
const [, promises] = meta
|
|
|
|
|
|
|
|
|
|
preparePluginPreInit(manifest.id)
|
|
|
|
|
|
|
|
|
|
if (!(plugin.flags & Flag.Enabled)) throw new Error(`Plugin "${manifest.id}" is not enabled`)
|
|
|
|
|
if (plugin.status & (Status.PreIniting | Status.PreInited))
|
|
|
|
|
throw new Error(`Plugin preInit lifecycle for "${manifest.id}" is already running`)
|
|
|
|
|
|
|
|
|
|
plugin.status |= Status.PreIniting
|
|
|
|
|
|
|
|
|
|
try {
|
2025-05-19 02:21:28 +07:00
|
|
|
const prom = lifecycles.preInit(meta[0] as PreInitPluginApi)
|
2025-05-18 22:24:48 +07:00
|
|
|
promises.push(prom)
|
|
|
|
|
await prom
|
|
|
|
|
|
|
|
|
|
// plugin.disable() already handles, so it's in the try block
|
|
|
|
|
plugin.status |= Status.PreInited
|
|
|
|
|
plugin.status &= ~Status.PreIniting
|
|
|
|
|
} catch (e) {
|
|
|
|
|
await handlePluginError(e, plugin)
|
|
|
|
|
}
|
2025-05-09 21:53:48 +07:00
|
|
|
}
|
2025-05-01 01:18:36 +07:00
|
|
|
|
2025-05-09 21:53:48 +07:00
|
|
|
export async function initPlugin(plugin: InternalPlugin) {
|
|
|
|
|
const { manifest, lifecycles } = plugin
|
2025-05-19 02:21:28 +07:00
|
|
|
if (!lifecycles.init) return
|
|
|
|
|
|
2025-05-18 22:24:48 +07:00
|
|
|
const meta = _metas.get(manifest.id)!
|
|
|
|
|
const [, promises, , apiLevel] = meta
|
2025-05-01 01:18:36 +07:00
|
|
|
|
2025-05-09 21:53:48 +07:00
|
|
|
if (!(plugin.flags & Flag.Enabled)) throw new Error(`Plugin "${manifest.id}" is not enabled`)
|
|
|
|
|
if (plugin.status & (Status.Initing | Status.Inited))
|
2025-05-18 22:24:48 +07:00
|
|
|
throw new Error(`Plugin init lifecycle for "${manifest.id}" is already running`)
|
2025-05-09 21:53:48 +07:00
|
|
|
|
2025-05-18 22:24:48 +07:00
|
|
|
if (apiLevel < PluginApiLevel.PreInit) preparePluginPreInit(manifest.id)
|
|
|
|
|
if (apiLevel < PluginApiLevel.Init) preparePluginInit(manifest.id)
|
2025-05-09 21:53:48 +07:00
|
|
|
|
|
|
|
|
try {
|
2025-05-19 02:21:28 +07:00
|
|
|
const prom = lifecycles.init(meta[0] as InitPluginApi)
|
2025-05-09 21:53:48 +07:00
|
|
|
promises.push(prom)
|
|
|
|
|
await prom
|
|
|
|
|
|
2025-05-18 22:24:48 +07:00
|
|
|
// plugin.disable() already handles, so it's in the try block
|
2025-05-09 21:53:48 +07:00
|
|
|
plugin.status |= Status.Inited
|
|
|
|
|
plugin.status &= ~Status.Initing
|
|
|
|
|
} catch (e) {
|
|
|
|
|
await handlePluginError(e, plugin)
|
2025-04-29 21:38:14 +07:00
|
|
|
}
|
2025-05-09 21:53:48 +07:00
|
|
|
}
|
2025-04-29 21:38:14 +07:00
|
|
|
|
2025-05-09 21:53:48 +07:00
|
|
|
export async function startPlugin(plugin: InternalPlugin) {
|
|
|
|
|
const { manifest, lifecycles } = plugin
|
2025-05-19 02:21:28 +07:00
|
|
|
if (!lifecycles.start) return
|
|
|
|
|
|
2025-05-18 22:24:48 +07:00
|
|
|
const meta = _metas.get(manifest.id)!
|
|
|
|
|
const [, promises, , apiLevel] = meta
|
2025-05-08 18:52:57 +07:00
|
|
|
|
2025-05-09 21:53:48 +07:00
|
|
|
if (!(plugin.flags & Flag.Enabled)) throw new Error(`Plugin "${manifest.id}" is not enabled`)
|
|
|
|
|
if (plugin.status & (Status.Starting | Status.Started))
|
2025-05-18 22:24:48 +07:00
|
|
|
throw new Error(`Plugin start lifecycle for "${manifest.id}" is already running`)
|
2025-05-09 21:53:48 +07:00
|
|
|
|
|
|
|
|
plugin.status |= Status.Starting
|
|
|
|
|
|
2025-05-18 22:24:48 +07:00
|
|
|
if (apiLevel < PluginApiLevel.PreInit) preparePluginPreInit(manifest.id)
|
|
|
|
|
if (apiLevel < PluginApiLevel.Init) preparePluginInit(manifest.id)
|
|
|
|
|
if (apiLevel < PluginApiLevel.Start) preparePluginStart(manifest.id)
|
2025-05-09 21:53:48 +07:00
|
|
|
|
|
|
|
|
try {
|
2025-05-19 02:21:28 +07:00
|
|
|
const prom = lifecycles.start(meta[0] as PluginApi)
|
2025-05-09 21:53:48 +07:00
|
|
|
promises.push(prom)
|
|
|
|
|
await prom
|
|
|
|
|
|
|
|
|
|
// disablePlugin() already handles cleaning up statuses, so it's in the try block
|
|
|
|
|
plugin.status |= Status.Started
|
|
|
|
|
plugin.status &= ~Status.Starting
|
|
|
|
|
} catch (e) {
|
|
|
|
|
await handlePluginError(e, plugin)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-19 02:22:12 +07:00
|
|
|
const MaxWaitTime = 5000
|
2025-05-18 22:24:48 +07:00
|
|
|
|
|
|
|
|
export async function stopPlugin(plugin: InternalPlugin) {
|
2025-05-09 21:53:48 +07:00
|
|
|
const { manifest, lifecycles } = plugin
|
|
|
|
|
const meta = _metas.get(manifest.id)!
|
2025-05-18 22:24:48 +07:00
|
|
|
const [, promises, iflags, apiLevel] = meta
|
|
|
|
|
|
|
|
|
|
if (iflags & InternalPluginFlags.Essential)
|
|
|
|
|
throw new Error(`Plugin "${manifest.id}" is essential and cannot be stopped`)
|
2025-05-09 21:53:48 +07:00
|
|
|
|
|
|
|
|
if (!(plugin.flags & Flag.Enabled)) throw new Error(`Plugin "${manifest.id}" is not enabled`)
|
|
|
|
|
if (plugin.status & Status.Stopping) throw new Error(`Plugin "${manifest.id}" is stopping`)
|
|
|
|
|
|
2025-05-18 22:24:48 +07:00
|
|
|
// If the plugin is running its lifecycles, we need to wait for it to finish, then we'll stop it
|
|
|
|
|
// We want to wait at max 5 seconds for the lifecycles to finish
|
|
|
|
|
if (plugin.status & (Status.PreIniting | Status.Initing | Status.Starting))
|
2025-05-19 02:22:12 +07:00
|
|
|
await Promise.race([
|
|
|
|
|
Promise.all(promises),
|
|
|
|
|
sleepReject(MaxWaitTime, 'Plugin lifecycles timed out, force stopping'),
|
|
|
|
|
])
|
|
|
|
|
.then(finished => {
|
|
|
|
|
// If the lifecycles don't finish in 5 seconds, a reload is probably required to unapply the changes
|
|
|
|
|
if (!finished) plugin.flags |= Flag.ReloadRequired
|
|
|
|
|
})
|
|
|
|
|
.catch(e => handlePluginError(e, plugin))
|
2025-05-18 22:24:48 +07:00
|
|
|
else if (!(plugin.status & (Status.PreInited | Status.Inited | Status.Started)))
|
|
|
|
|
throw new Error(`Plugin "${manifest.id}" is not running`)
|
2025-05-09 21:53:48 +07:00
|
|
|
|
2025-05-18 22:24:48 +07:00
|
|
|
if (apiLevel < PluginApiLevel.PreInit) preparePluginPreInit(manifest.id)
|
2025-05-22 02:45:38 +07:00
|
|
|
if (apiLevel < PluginApiLevel.Init) preparePluginInit(manifest.id, plugin._s)
|
2025-05-18 22:24:48 +07:00
|
|
|
if (apiLevel < PluginApiLevel.Start) preparePluginStart(manifest.id)
|
2025-05-09 21:53:48 +07:00
|
|
|
|
|
|
|
|
plugin.status |= Status.Stopping
|
|
|
|
|
|
|
|
|
|
try {
|
2025-05-19 02:22:12 +07:00
|
|
|
if (lifecycles.stop)
|
|
|
|
|
await Promise.race([
|
|
|
|
|
lifecycles.stop(meta[0] as PluginApi),
|
|
|
|
|
sleepReject(MaxWaitTime, 'Plugin stop lifecycle timed out, force stopping'),
|
|
|
|
|
])
|
2025-05-09 21:53:48 +07:00
|
|
|
} catch (e) {
|
|
|
|
|
await handlePluginError(e, plugin)
|
|
|
|
|
} finally {
|
|
|
|
|
// Run cleanups
|
2025-05-22 02:45:38 +07:00
|
|
|
const results = await allSettled(plugin._c.map(cleanup => cleanup()))
|
2025-05-09 21:53:48 +07:00
|
|
|
for (const result of results)
|
|
|
|
|
if (result.status === 'rejected') {
|
|
|
|
|
await handlePluginError(result.reason, plugin)
|
|
|
|
|
// Some cleanup was unsuccessful, so we need to reload the app
|
|
|
|
|
plugin.flags |= Flag.ReloadRequired
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-18 22:24:48 +07:00
|
|
|
// Reset APIs
|
|
|
|
|
meta[0] = undefined
|
|
|
|
|
meta[3] = PluginApiLevel.None
|
|
|
|
|
|
|
|
|
|
// Clear temp data
|
2025-05-09 21:53:48 +07:00
|
|
|
meta[1] = []
|
2025-05-22 02:45:38 +07:00
|
|
|
plugin._c = []
|
2025-05-18 22:24:48 +07:00
|
|
|
|
|
|
|
|
// Reset status
|
2025-05-09 21:53:48 +07:00
|
|
|
plugin.status = 0
|
|
|
|
|
}
|
2025-04-29 21:38:14 +07:00
|
|
|
}
|
|
|
|
|
|
2025-05-08 18:52:57 +07:00
|
|
|
export interface InternalPlugin extends Plugin {
|
2025-05-22 02:45:38 +07:00
|
|
|
_s?: StorageOptions<AnyObject>
|
|
|
|
|
_c: PluginCleanup[]
|
2025-04-29 21:38:14 +07:00
|
|
|
}
|
2025-05-01 01:18:36 +07:00
|
|
|
|
|
|
|
|
export const InternalPluginFlags = {
|
|
|
|
|
/**
|
|
|
|
|
* Marks the plugin as internal.
|
|
|
|
|
*/
|
|
|
|
|
Internal: 1 << 0,
|
|
|
|
|
/**
|
|
|
|
|
* Marks the plugin as essential. This means it should not be removed, disabled, or stopped by normal means.
|
|
|
|
|
*/
|
|
|
|
|
Essential: 1 << 1,
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-18 22:24:48 +07:00
|
|
|
const PluginApiLevel = {
|
|
|
|
|
None: 0,
|
|
|
|
|
PreInit: 1,
|
|
|
|
|
Init: 2,
|
|
|
|
|
Start: 3,
|
|
|
|
|
} as const
|