Writing a plugin
Write a plugin that your bot starts and stops, give it typed options, and call it from your handlers through this.core.
Your bot talks to an outside service, like a metrics API or a cache. You created its client in bot.ts and exported it. Nothing closes that client when the bot shuts down, and a handler can import it before it has connected. As a plugin, the client starts and stops with your bot, and handlers read it from this.core under the key you attach it with. Using a plugin shows how a bot attaches one.
To write a plugin, extend Plugin and write init(). Everything else on the base is optional.
import { Plugin } from '@seedcord/gateway';
export class Uptime extends Plugin {
private startedAt = 0;
public async init(): Promise<void> {
this.startedAt = Date.now();
}
public elapsed(): number {
return Date.now() - this.startedAt;
}
}A new project has a plugin like this in src/plugins/Uptime.ts. Its constructor takes only the bot, so you attach it with a key and the class.
export default seedcord.attach('uptime', Uptime);Calling it from a handler
A handler calls your plugin's methods on this.core.uptime, the key you attached it under. It needs no import from bot.ts.
import { SlashHandler, SlashRoute } from '@seedcord/gateway';
@SlashRoute('uptime')
export class UptimeCommand extends SlashHandler<'uptime'> {
public async execute(): Promise<void> {
const ms = this.core.uptime.elapsed(); await this.reply(
`Up for ${Math.floor(ms / 1000)} seconds.`
);
}
}ms is a number because elapsed() returns a number. Typing what you ship shows how the type gets from your class to this.core.
A handler can call every public member of your class. startedAt is private, so a handler reads the time through elapsed() and can't overwrite it. Keep connections and state private, and make public only the methods you want handlers to call.
Handlers can call those methods only after startup. Startup runs in three phases: Configuration, Login and Ready. init() runs in Configuration by default, and a plugin can also define ready() for the Ready phase. Handlers start receiving interactions once every init() has finished, so a handler can call your method while ready() is still running. If a method reads something ready() sets, return early or throw while it's unset. The lifecycle has the full order.
Closing what init() opened
A timer that init() starts keeps firing after the bot begins shutting down, and an open connection keeps the process from exiting. dispose() runs at shutdown. Close in it whatever init() started.
export class Heartbeat extends Plugin {
private timer: NodeJS.Timeout | undefined;
public async init(): Promise<void> {
this.timer = setInterval(() => {
this.logger.debug('still running');
}, 30_000);
}
public override async dispose(): Promise<void> {
clearInterval(this.timer);
}
}If init() throws, seedcord skips dispose(). A plugin that opens two things in init() has to close the first itself when the second fails. When init fails shows what seedcord cleans up.
On a bot running on Cloudflare Workers, dispose() runs only when startup fails, and then only for plugins whose init() had finished. So write state as soon as it changes. On Cloudflare Workers has the rest of the differences.
Which base to import
Each transport exports its own Plugin class. A transport is how the bot talks to Discord, either the gateway websocket or http interactions. The base you extend sets the type of this.core.
| import from | this.core has | extend it when your plugin |
|---|---|---|
@seedcord/gateway | the gateway Core, which adds bot, startup and shutdown | reads bot, startup or shutdown |
@seedcord/http | the http Core, which adds startup and shutdown | reads startup or shutdown on an http bot (on Workers, shutdown throws) |
@seedcord/core/plugin | CoreBase, the members both transports share | only uses what both transports share |
CoreBase has members both transports share, such as config, rest, and bus. A plugin on the @seedcord/core/plugin base attaches to a bot on either transport. Each base also sets where a bot may attach the plugin. Where it runs has that.
Gateway and http differ
config follows the base you extend. The gateway base types it as GatewayConfig, the http base as HttpConfig, and CoreBase as the shared Config.
When you publish a plugin, its base decides which bots can attach it. One on the gateway base attaches only to gateway bots, and one on the http base only to http bots. Extend @seedcord/core/plugin unless your plugin needs bot, startup, or shutdown. The Mongoose and Kysely plugins both extend it, so a bot on either transport can attach them. A plugin inside your own bot only ever runs on that bot's transport, so extend that transport's base.
What the constructor takes
Most plugins need settings, like a connection string or a label. The constructor's first parameter is the bot, which seedcord passes in when you call attach. The samples call it host. Type it as CoreBase and pass it to super. Every parameter after it is yours, and you pass their values to attach after the plugin class, where TypeScript checks their types. If you type the first parameter as a transport's Core, the attach call fails to compile. Typing what you ship explains why.
import { Plugin } from '@seedcord/gateway';
import type { CoreBase } from '@seedcord/gateway';
export class Uptime extends Plugin {
public constructor(
host: CoreBase,
private readonly label: string
) {
super(host);
}
public async init(): Promise<void> {
this.logger.info(`tracking ${this.label}`);
}
}Uptime takes a label after the bot, so you write seedcord.attach('uptime', Uptime, label).
Import CoreBase from the package you took Plugin from. On the @seedcord/core/plugin base, that's @seedcord/core, which a new bot doesn't install. Add it to your bot, and list it as a peer dependency when you publish the plugin. Publishing shows the entry.
After the host, super() takes an optional PluginLifecycleSpec to move init() to another phase or set a timeout in milliseconds, as in super(host, { init: { timeout: 30_000 } }). The lifecycle page shows the phases.
Keep the rest of the constructor to storing its arguments, because it runs whenever something loads bot.ts. seedcord codegen and seedcord build both load it. A connection opened there opens on every codegen run and every build, even though no bot is running. Open connections and read files in init().
Rejecting bad options
Catch a bad option, like an empty label, while the bot starts, before anything connects. Call rejectOptions from the constructor to stop the bot at the attach call.
export class Uptime extends Plugin {
public constructor(host: CoreBase, label: string) {
super(host);
if (label.length === 0)
this.rejectOptions('label is empty');
}
public async init(): Promise<void> {}
}Calling rejectOptions throws an error with the code PluginOptionsRejected, which stops the bot with this message.
Uptime rejected its options. Label is empty.Logging
this.logger exists from the constructor onward and prints under your class name. Each log line goes to a channel, a named stream that a bot can filter or silence. A line logged in the constructor goes to the default channel. From init() on, attach has set the channel to the key, so if someone attaches your plugin as metrics, its lines go to the metrics channel. The log channel section shows how a bot filters them.