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. If the constructor takes only the host, attach it with two arguments.
Mongoose reads its dir relative to root in seedcord.config.ts, the same as the path keys. With root: './src', the './services' above means src/services. It reads the folder when the bot starts, and again during seedcord codegen. That read throws if the folder breaks one of these rules:
| if the folder | it throws |
|---|---|
is outside root | CorePathOutsideRoot |
is inside a dot-folder, node_modules, or your outDir | CoreDirectorySkipped |
is relative in a test or tsx script, which has no root | CoreDirectoryRootUnknown. Pass an absolute path there |
Keep bot.ts exporting the bot as its default export, the way the sample does. seedcord dev, build, and codegen load your bot from it and stop with CliInstanceInvalid without one.
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 the lines that seedcord codegen writes into src/seedcord-gen.ts in your project. With them, services.users is typed as the Users service above, so findByName type-checks.
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.ts, so your tc script reports this error in that file. this.core.db compiles as any in your handlers until you fix the export. 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 with its attach key's name. 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'On Cloudflare Workers, attach plugins the same way as on 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.