Skip to content

Writing your own

Write your own plugin by extending Plugin, with typed options, logging, and dev reloads, then publish it on npm.

Let's assume 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 core under the key you attach it with.

To write one, extend Plugin and write init(). Everything else on the base is optional.

src/plugins/Uptime.ts
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;
    }
}

Uptime records the time in init() and reports it from elapsed(). Attach it with two arguments, since its constructor takes only the host.

export default seedcord.attach('uptime', Uptime);

Calling it from a handler

A handler calls your plugin's methods on this.core.uptime, since uptime is the key you attached it under. Nothing gets exported from bot.ts or imported into the handler.

src/handlers/UptimeCommand.tshover for typestap for types, arrow keys walk the tokens
import { 
class SlashHandler<
    Route extends keyof SlashRegistry,
    Cache extends CacheType = CacheFor<Route>
>
SlashHandler
,
function SlashRoute<const Route extends keyof SlashRegistry>(
    ...routes: Route[]
): <TCtor extends AnyHandlerCtor>(
    constructor: AssertSlashRoute<Route, TCtor>
) => void
SlashRoute
} from '@seedcord/gateway';
@SlashRoute<"uptime">(...routes: "uptime"[]): <TCtor>(constructor: AssertSlashRoute<"uptime", TCtor>) => voidSlashRoute('uptime') export class class UptimeCommandUptimeCommand extends
class SlashHandler<
    Route extends keyof SlashRegistry,
    Cache extends CacheType = CacheFor<Route>
>
SlashHandler
<'uptime'> {
public async UptimeCommand.execute(): Promise<void>execute(): interface Promise<T>Promise<void> { const const ms: numberms = this.BaseHandler<ChatInputCommandInteraction<"cached">, Core>.core: Corecore.Core.uptime: Uptimeuptime.Uptime.elapsed(): numberelapsed(); await this.
RepliableHandler<ChatInputCommandInteraction<"cached">, Core, SentMessage, BufferResolvable | Stream | JSONEncodable<...> | Attachment | AttachmentBuilder | AttachmentPayload, ReplySender>.reply(
    response:
        | string
        | ReplyResponse<
              | BufferResolvable
              | Stream
              | JSONEncodable<APIAttachment>
              | Attachment
              | AttachmentBuilder
              | AttachmentPayload
          >,
    opts?: SendOpts
): Promise<SentMessage>
reply
(
`Up for ${var Math: MathMath.Math.floor(x: number): numberfloor(const ms: numberms / 1000)} seconds.` ); } }

this.core.uptime has the type Uptime, so your editor offers elapsed() and types its result as a number.

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.

With the default phases, handlers start receiving interactions after every init() has finished. ready() may still be running at that point, so if a method reads something that ready() sets, check that it's set first. The lifecycle has the full order.

Which base to import

Each transport exports its own Plugin class. The one you extend sets the type of this.core. Extend a transport's Plugin when your plugin reads something only that transport has, like this.core.bot on gateway. Extend the Plugin from @seedcord/core/plugin when your plugin only uses what both transports share, so a bot on either transport can attach it.

import fromthis.core carriesextend it when your plugin
@seedcord/gatewaythe gateway Core, including botreads gateway members, like bot
@seedcord/httpthe http Corereads http members
@seedcord/core/pluginCoreBase, the members both transports shareonly uses what both transports share

CoreBase carries config, rest, applicationId, rateLimiter, and bus. Anything beyond those needs a transport base.

Gateway and http differ

config narrows with the base you extend. A transport base types it as that transport's own config. CoreBase types it as the shared Config.

Declaring where it runs

The gateway base declares transport: 'gateway' and runtime: 'server'. The http base declares transport: 'http'.

The base from @seedcord/core/plugin leaves both at 'any'. When a shared plugin only works in one setup, like one that opens a voice connection, pass the PluginOptions generic to narrow them. If someone attaches it to the wrong kind of bot, their attach call fails to compile.

import { Plugin } from '@seedcord/core/plugin';

export class Voice extends Plugin<{
    transport: 'gateway';
    runtime: 'server';
}> {
    public async init(): Promise<void> {}
}

You can set either field without the other. Plugin<{ runtime: 'server' }> keeps a plugin off the edge runtime and takes either transport.

seedcord.attach('voice', Voice);
Argument of type 'typeof Voice' is not assignable to parameter of type 'AttachAsserts<typeof Voice, "http", "server" | "edge">'. Property '"this plugin declares transport 'gateway' but this bot runs 'http'"' is missing in type 'typeof Voice' but required in type 'TransportMismatch<"gateway", "http">'.

seedcord here is an http bot, so passing Voice to its attach fails to compile. The error prints the transport Voice declares beside the one the bot runs.

What the constructor takes

Most plugins need settings, like a connection string or a label. The constructor's first parameter is the bot itself, which seedcord passes in when you call attach. Type it as CoreBase. Every parameter after it is yours. You pass their values to attach after the plugin class, where TypeScript checks their types.

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 attach it with attach('uptime', Uptime, label).

If you type that first parameter as the gateway Core, your attach call fails to compile.

class Uptime extends Plugin {
    public constructor(host: Core) {
        super(host);
    }

    public async init(): Promise<void> {}
}

seedcord.attach('uptime', Uptime);
Argument of type 'typeof Uptime' is not assignable to parameter of type 'CoreParamTooNarrow'. Property '"this plugin constructor must take CoreBase as its first parameter and read the transport Core off this.core"' is missing in type 'typeof Uptime' but required in type 'CoreParamTooNarrow'.

attach rejects Uptime because its constructor types host as Core. seedcord codegen adds every plugin you attach to that same Core type, so a plugin whose constructor takes Core ends up depending on itself, which TypeScript can't resolve. Keep the parameter as CoreBase, then read the transport's Core from this.core, which your base class already types for you.

A plugin with a type parameter

A plugin that keeps values for the bot, like a key-value cache, doesn't know their type when you write it. A type parameter leaves that to the bot that attaches it.

interface Session {
    userId: string;
}

export class KeyValue<TValue> extends Plugin {
    private readonly entries = new Map<string, TValue>();

    public async init(): Promise<void> {}

    public get(key: string): TValue | undefined {
        return this.entries.get(key);
    }
}

const bot = seedcord.attach('sessions', KeyValue<Session>);
const session = bot.sessions.get('abc');
const session: Session | undefined

KeyValue<Session> in the attach call sets TValue, so session comes back as Session | undefined. A handler reading this.core.sessions gets the same type, because seedcord codegen copies the attached type onto Core.

If you pass KeyValue alone, TypeScript sets TValue to its constraint, unknown here. It does that even when TValue has a default. A value you pass to the constructor doesn't set it either, so write the type argument in the attach call whenever you want a narrower type.

Rejecting bad options

A bad option, like an empty label, is cheapest to catch while the bot starts. Call rejectOptions from the constructor to stop the bot at the attach call, before anything connects.

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 PluginOptionsRejected. Your class name comes first in the message, then whatever reason you passed.

Logging

this.logger exists from the constructor onward. It prints under your class name, on the channel your attach key sets, so if someone attaches your plugin as metrics, they can filter its lines by that name.

Reacting to a dev reload

seedcord dev swaps a changed file into the running bot. If your plugin loads files itself, the way the mongoose plugin loads its service classes, it keeps the old copies. Override onHmr to reload them. Reloading only helps with files your plugin reads again, though. A schema that a connection read at startup stays as it was, since swapping the file doesn't repeat that read. Pass glob patterns for files like that to registerCriticalFiles. When one of them changes, the dev terminal shows a Restart required card telling you to press r, which restarts the whole bot. Register only the files your plugin reads. seedcord already requests a restart on its own startup files, listed in Hot reload.

hover for typestap for types, arrow keys walk the tokens
export class class UptimeUptime extends 
class Plugin<
    Opts extends GatewayPluginOptions = {
        transport: "gateway";
        runtime: "server";
    }
>
Plugin
{
public async Uptime.init(): Promise<void>init(): interface Promise<T>Promise<void> { this.Plugin<{ transport: "gateway"; runtime: "server"; }, Core>.registerCriticalFiles(patterns: string[]): voidregisterCriticalFiles(['src/schema/**']); } public override async Uptime.onHmr(event: HmrUpdateEvent): Promise<void>onHmr( event: HmrUpdateEventevent: interface HmrUpdateEventHmrUpdateEvent ): interface Promise<T>Promise<void> { this.Plugin<{ transport: "gateway"; runtime: "server"; }, Core>.logger: Loggerlogger.Logger.debug(msg: string, ...args: unknown[]): voiddebug(`${event: HmrUpdateEventevent.HmrUpdateEvent.file: stringfile} changed`); } }

Uptime logs each changed file and marks everything under src/schema/ as needing a restart. Both calls do nothing outside seedcord dev.

seedcord's own plugins extend this same base, starting with Mongoose.

Publishing to npm

Someone who wants a plugin searches npm for seedcord, and that search reads package names and keywords.

To help with discoverability, and to follow the convention, name your package seedcord-plugin-<thing>, like seedcord-plugin-uptime, and add seedcord and plugin to your keywords. Official plugins will always be published under the @seedcord/plugin- scope, which only seedcord can publish to.

Whichever package you import Plugin from goes in peerDependencies, with the versions you tested against. Mongoose extends the base from @seedcord/core/plugin, so it declares @seedcord/core at >=0.2.0 <1.0.0. seedcord is below 1.0, so a breaking change can ship in a minor, and a plugin written for 0.2 can break on 0.3. The range tells people which versions work before they install.