Creating Plugins

Runtime Registration

register(api) is synchronous. It declares what the plugin contributes to Seyfert.

import { createPlugin } from 'seyfert';
import HealthCommand from './commands/health';
import RefreshButton from './components/refresh-button';
import ProfileModal from './modals/profile-modal';
import { auditService } from './audit-service';
import { auditMiddleware } from './middlewares/audit';

export const auditPlugin = createPlugin({
    name: 'audit-plugin',
    client: {
        // expose the service as client.audit
        audit: () => auditService,
    },
    register(api) {
        // contribute a command, component, and modal
        api.commands.add(HealthCommand);
        api.components.add(RefreshButton);
        api.modals.add(ProfileModal);
        // register a named middleware and make it global
        api.middlewares.add('audit', auditMiddleware, { global: true });

        // listen for an event the framework emits
        api.events.on('commandsLoaded', (metadata) => {
            auditService.recordCommandsLoaded(metadata);
        });

        // contribute a partial client options fragment
        api.options.set({
            allowedMentions: { parse: [] },
        });
    },
});

Do not return a promise from register(api). Open connections in setup, not in register.

Commands, Components, and Modals

Plugin commands load after disk commands. Plugin components and modals load after disk components.

register(api) {
    // add a command, a component, and a modal
    api.commands.add(HealthCommand);
    api.components.add(RefreshButton);
    api.modals.add(ProfileModal);
}

Conflicts are attributed errors:

  • duplicate command names fail
  • duplicate static component or modal custom IDs fail
  • subcommands are not top-level plugin commands

Events

Plugins can listen to client, gateway, and custom events.

register(api) {
    // listen every time the event fires
    api.events.on('botReady', (user, client) => {
        client.logger.info(`${user.username} is online`);
    });

    // listen only the first time it fires
    api.events.once('commandsLoaded', (metadata) => {
        auditService.recordCommandSources(metadata.plugin.sources);
    });

    // observe every event by name
    api.events.onAny((name) => {
        auditService.recordEvent(name);
    });
}

Plugin listeners are additive. Multiple plugins can observe the same event, and a listener failure is wrapped with the plugin name and phase without skipping the other listeners.

To emit a custom event, use the client (client.events.emit(...)) once the event runner exists — from setup, a plugin listener, or a runtime callback. The registration api does not expose emit, because register(api) runs before events are bound. Gateway event names cannot be emitted from plugins.

Loaded Metadata

commandsLoaded and componentsLoaded receive metadata, not a raw array.

api.events.on('commandsLoaded', (metadata) => {
    metadata.kind; // "commands"
    metadata.total; // all loaded commands
    metadata.items; // loaded command objects
    metadata.plugin.total; // plugin-contributed commands
    metadata.plugin.sources; // count by plugin name
});

The same shape is used for components with kind: "components".

Middlewares

api.middlewares.add(...) registers a named middleware. { global: true } also contributes the middleware name to globalMiddlewares.

register(api) {
    // global: true also adds it to globalMiddlewares
    api.middlewares.add('audit', auditMiddleware, { global: true });
}

Duplicate middleware names fail.

If a command references a middleware name that is not registered, Seyfert warns and continues with the registered middlewares that are available.

For public packages, type middleware contributions on the plugin so app code can use Middlewares([...]) without extra casts.

import { createPlugin, type CommandContext, type MiddlewareContext, type SeyfertPlugin } from 'seyfert';

type AuthMiddleware = MiddlewareContext<{ userId: string }, CommandContext>;

const auth: AuthMiddleware = ({ next }) => next({ userId: '1' });

// type the middleware contribution so app code stays cast-free
export const authPlugin: SeyfertPlugin<{}, {}, readonly [], { auth: AuthMiddleware }> = createPlugin({
    name: 'auth',
    register(api) {
        api.middlewares.add('auth', auth);
    },
});

When the app includes that plugin in the tuple registered via SeyfertRegistry, Middlewares(['auth']) and ctx.metadata.auth are typed.

Option Fragments

api.options.set(...) contributes partial client options.

register(api) {
    // contribute default command hooks; they compose with the app's
    api.options.set({
        commands: {
            defaults: {
                onRunError(ctx, error) {
                    ctx.client.logger.error(error);
                },
            },
        },
    });
}

Plugin option fragments are composed before final user options. Plain option values can still be overridden by app configuration through normal option merging. Command, component, and modal default hooks are composed, so plugin hooks and app hooks both run instead of one replacing the other.

Use options(current) when the fragment depends on options already composed before your plugin:

export const plugin = createPlugin({
    name: 'mentions',
    // read options already composed before this plugin
    options(current) {
        return {
            // only set a default when the app didn't provide one
            allowedMentions: current.allowedMentions ?? { parse: [] },
        };
    },
});