Plugins
Add your own services to a bot with plugins, which seedcord starts and stops with it, and read them from any handler.
Most bots need something beside Discord, like a database, a cache, or a client for another API. Wiring one up by hand means starting it before the bot logs in, handing it to every handler through a global or an import, and stopping it on shutdown before the process exits.
A plugin packages that work. It starts and stops at defined points in your bot's lifecycle, takes its own options, and logs on its own channel. You attach it under a key you pick, then read it from this.core anywhere core is available, like a handler.
import { class SeedcordSeedcord } from '@seedcord/gateway';
import { class MongooseMongoose } from '@seedcord/plugin-mongoose';
import { enum GatewayIntentBitsGatewayIntentBits } from 'discord.js';
export const const seedcord: Seedcord & Record<"db", Mongoose>seedcord = new new Seedcord(config: GatewayConfig): SeedcordSeedcord({
GatewayConfig.bot: GatewayBotConfigbot: {
GatewayBotConfig.clientOptions: ClientOptionsclientOptions: { ClientOptions.intents: BitFieldResolvable<
| "Guilds"
| "GuildMembers"
| "GuildModeration"
| "GuildBans"
| "GuildExpressions"
| "GuildEmojisAndStickers"
| "GuildIntegrations"
| "GuildWebhooks"
| "GuildInvites"
| "GuildVoiceStates"
| "GuildPresences"
| "GuildMessages"
| "GuildMessageReactions"
| "GuildMessageTyping"
| "DirectMessages"
| "DirectMessageReactions"
| "DirectMessageTyping"
| "MessageContent"
| "GuildScheduledEvents"
| "AutoModerationConfiguration"
| "AutoModerationExecution"
| "GuildMessagePolls"
| "DirectMessagePolls",
number
>
intents: [enum GatewayIntentBitsGatewayIntentBits.function (enum member) GatewayIntentBits.Guilds = 1Guilds] },
BotConfig.interactions: InteractionsConfiginteractions: { path: stringpath: './handlers' },
BotConfig.commands: CommandsConfigcommands: { path: stringpath: './commands' },
GatewayBotConfig.events: EventsConfigevents: { path: nullpath: null }
},
Config.subscribers: SubscribersConfigsubscribers: { path: nullpath: null }
}).PluginHost<"gateway", "server">.attach<"db", typeof Mongoose>(this: Seedcord, key: "db", Plugin: typeof Mongoose, options: MongooseOptions): Seedcord & Record<"db", Mongoose>attach('db', class MongooseMongoose, {
MongooseOptions.dir: stringdir: './services',
MongooseOptions.uri: stringuri: 'mongodb://localhost:27017/',
MongooseOptions.name: stringname: 'seedcord'
});
export default const seedcord: Seedcord & Record<"db", Mongoose>seedcord;attach takes the key, the plugin class, and whatever that class's constructor takes after the host. Here that's 'db', Mongoose, and its options object. If a plugin's constructor takes only the host, attach it with two arguments.
The dir option is a folder of your bot, so a relative one is relative to root in seedcord.config.ts, like the path keys. With root: './src', './services' means src/services. The folder has to sit inside root, outside dot-folders, node_modules, and your outDir. In seedcord dev and when a built bot starts, a dir outside root throws CorePathOutsideRoot, and one inside a skipped folder throws CoreDirectorySkipped.
A test or a tsx script has no root, because it doesn't start through seedcord dev or a built bot. A relative dir throws CoreDirectoryRootUnknown there, so pass an absolute path in those runs.
However many plugins you attach, keep bot.ts exporting the bot as its default export, the way the sample does. seedcord dev, build, and codegen load your bot from that default export, and without one they stop with CliInstanceInvalid. seedcord codegen also reads it to type this.core.db.
Reading it from a handler
this.core carries every attached plugin under its key.
@SlashRoute('history')
export class History extends SlashHandler<'history'> {
public async execute(): Promise<void> {
const found =
await this.core.db.services.users.findByName('ada');
await this.reply(
found ? `Found ${found.username}.` : 'No record.'
);
}
}History reaches the mongoose service through this.core.db.
The sample hides a Core declaration above the handler so it compiles on this page. In your project, seedcord codegen writes that declaration for you.
Attaching more than one
A bot can attach more than one plugin, each under its own key. Chain another attach for each one. Inside one startup phase the plugins start one after another, in the order you attached them. If your plugin's init() reads another plugin, attach that one first. Otherwise your init() runs before the other plugin has started.
import { Seedcord } from '@seedcord/gateway';
import { KyselyPostgres } from '@seedcord/plugin-kysely-postgres';
import { Mongoose } from '@seedcord/plugin-mongoose';
import { GatewayIntentBits } from 'discord.js';
export const seedcord = new Seedcord({
bot: {
clientOptions: { intents: [GatewayIntentBits.Guilds] },
interactions: { path: './handlers' },
commands: { path: './commands' },
events: { path: null }
},
subscribers: { path: null }
})
.attach('db', Mongoose, {
dir: './mongo',
uri: 'mongodb://localhost:27017/',
name: 'seedcord'
})
.attach('sql', KyselyPostgres, {
dir: './postgres',
connectionString: 'postgres://localhost:5432/seedcord',
migrations: { dir: './migrations' }
});Grouping plugins under one name
A class you write as a Plugin takes its own key on core too. Ten of them means ten keys, core.usersService, core.ticketsService, core.economyService, and seven more, in one list with your database.
Put a dot in the key to move a plugin out of that list and into a group.
import { Plugin, Seedcord } from '@seedcord/gateway';
export default seedcord
.attach('app.users', Users)
.attach('app.tickets', Tickets);app here is a plain object on core. Each plugin takes the key after the dot, so a handler reads one as this.core.app.users. seedcord still starts both in attach order and stops them during shutdown.
A key takes one dot. seedcord codegen writes one row for the group, which types every plugin inside it.
A name holds one plugin or one group. If app.users exists, attaching a plugin at app fails to compile. Nesting under db, which already holds your database, fails the same way.
seedcord.attach('app.users', Users).attach('app', Tickets);No plugin types in your handler?
this.core.db has the type Mongoose in History because the first sample exports what attach returns. If you call attach as a statement and then export seedcord itself, the plugin still starts. TypeScript loses its type, since nothing keeps the type that call returns.
const const seedcord: Seedcordseedcord = new new Seedcord(config: GatewayConfig): SeedcordSeedcord(const config: GatewayConfigconfig);
const seedcord: Seedcordseedcord.PluginHost<"gateway", "server">.attach<"db", typeof Mongoose>(this: Seedcord, key: "db", Plugin: typeof Mongoose, options: MongooseOptions): Seedcord & Record<"db", Mongoose>attach('db', class MongooseMongoose, const options: MongooseOptionsoptions);
type type Db = anyDb = (typeof const seedcord: Seedcordseedcord)['db'];The last line is the lookup codegen writes into src/seedcord-gen.d.ts. You won't see this error there, because the scaffold's tsconfig turns on skipLibCheck, which skips type errors in .d.ts files. this.core.db compiles as any instead. Your editor stops offering services, and a typo like this.core.db.servics still compiles.
The key is also a log channel
Every mongoose log line prints on the db channel, since a plugin logs on the channel named after its attach key. You can then filter or silence one plugin's logs by the key you gave it. A grouped plugin keeps its whole key, dot included, as its channel. seedcord already logs on reserved channels of its own, so those names can't be keys.
seedcord.attach('commands', Mongoose, { dir: './services',
uri: 'mongodb://localhost:27017/',
name: 'seedcord'
});Warning
commands is one of those reserved channels, so that attach fails to compile. If the key is only known at run time, like one read from an environment variable, attach throws CorePluginReservedChannel when it runs. Logging lists the reserved channels with what each one carries.
attach also throws in these cases when it runs:
- If startup has begun, like when a startup task calls
attach, it throwsCorePluginAfterInit. - If another plugin or seedcord itself already uses the key on
core, likebus, it throwsCorePluginKeyExists. - Nesting under a name the bot already uses, like
db, throwsCorePluginGroupTaken. - Attaching a plugin at a name that holds a group throws
CorePluginKeyHoldsGroup. - If the key has an empty part or a second dot, it throws
CorePluginKeyMalformed. - If the plugin extends
Pluginfrom a second copy of@seedcord/core, it throwsCorePluginFromOtherCore. This usually happens when the plugin and your@seedcordpackages need different versions of@seedcord/core.
A plugin declares where it runs
Some plugins only work in one setup, like one that reads discord.js objects, which only a gateway bot has. A plugin declares the transport it supports, 'gateway' or 'http', and the runtime it supports. A gateway bot and an http bot on Node or Bun run on a 'server'. An http bot on Cloudflare Workers runs on the 'edge'. The base class a plugin extends sets what it starts with, and a plugin can narrow it:
| base class | transport | runtime |
|---|---|---|
Plugin from @seedcord/gateway | 'gateway' | 'server' |
Plugin from @seedcord/http | 'http' | 'any' |
Plugin from @seedcord/core/plugin | 'any' | 'any' |
'any' attaches to any bot. The Users and Tickets plugins above extend the gateway Plugin, so they attach to a gateway bot only. Extend the @seedcord/core/plugin one for a plugin that works on both transports.
If you attach a plugin to a bot that doesn't match, TypeScript reports the mismatch in your editor before the bot runs.
this plugin declares transport 'gateway' but this bot runs 'http'
this plugin declares runtime 'server' but this bot runs 'edge'A bot on Cloudflare Workers attaches plugins the same way as any other bot, with attach in bot.ts. The two plugins in this tab declare the 'server' runtime, so a Workers bot gets the second message above for Mongoose and Kysely. A plugin from the @seedcord/http or @seedcord/core/plugin base attaches there unless it limits runtime.
A cast, or a plain JavaScript bot, gets past the types. attach then throws CorePluginScopeMismatch when it runs, with the plugin's name and the transport or runtime that differs. Workers covers setting that bot up.
Typing a plugin explains the codegen step behind this.core.db. The lifecycle says when a plugin starts and when it stops, and what differs on Workers. To write a plugin of your own, start at Writing plugins.