Interaction middleware
Run code before your interaction handlers with middleware. Covers which interactions it runs for, the order, stopping a dispatch, replying, and cleaning up in after().
A middleware runs before your handler. Write one when several handlers all need the same thing done first, like loading the caller's record, or when you want to refuse an interaction before it reaches your code.
A middleware runs for every handler of the kinds it lists, without being attached to any of them. A gate runs only on the handlers you put it on. Put work that every dispatch needs in a middleware. Put a check that only some commands need in a gate on those commands.
A middleware's execute() runs after seedcord builds your handler and before any gate.
The smallest middleware is a class with the decorator and an execute(). Trace logs the route every dispatch matched.
import {
InteractionMiddleware,
RegisterInteractionMiddleware
} from '@seedcord/gateway';
@RegisterInteractionMiddleware()
export class Trace extends InteractionMiddleware {
public async execute(): Promise<void> {
this.logger.debug(`reached ${this.dispatch.routeId}`);
await Promise.resolve();
}
}You can omit the options object on @RegisterInteractionMiddleware entirely, the way Trace does. Both of its keys are optional on their own too. If you omit both, the middleware runs on every kind that carries a reply target.
Which interactions it runs for
You write the kinds twice, once in { kinds } and once in the generic on InteractionMiddleware. The two have to agree. If they differ, the decorator line fails to compile. The error says which side is missing a kind.
@RegisterInteractionMiddleware({ kinds: [InteractionKind.Button] })
export class ClickLog extends InteractionMiddleware<InteractionKind.Button> {
public async execute(): Promise<void> {
const clicked = this.event; this.logger.debug(`clicked ${clicked.customId}`);
await Promise.resolve();
}
}this.event follows the generic, which is why customId is there to read. Naming several kinds gives you a union of those payloads.
@RegisterInteractionMiddleware({
kinds: [InteractionKind.Button, InteractionKind.Modal]
})
export class ComponentTrace extends InteractionMiddleware<
InteractionKind.Button | InteractionKind.Modal
> {
public async execute(): Promise<void> {
this.logger.debug(`component on ${this.dispatch.routeId}`);
await Promise.resolve();
}
}ComponentTrace runs for buttons and modals, and its this.event is a union of those two payloads. Every kind seedcord routes is a member of InteractionKind.
InteractionKind.- Autocomplete
- Button
- ChannelMenu
- MentionableMenu
- MessageContextMenu
- Modal
- RoleMenu
- Slash
- StringMenu
- UserContextMenu
- UserMenu
Autocomplete is the one a middleware can't list, since Discord accepts only a list of choices in reply to one.
Gateway and http differ
this.event is a discord.js interaction on gateway and the payload Discord posted on http. A button's id reads this.event.customId on the first and this.event.data.custom_id on the second. A union narrows through the discord.js guards on gateway, and through type and data.component_type on http.
Order
priority takes any finite number. The middleware with the lower number runs first. If you leave priority out, the middleware gets 0.
If two middleware share a number, they run in the order seedcord loaded them. That order can change when you add, rename, or move a middleware file, so give anything order-dependent a distinct priority.
A negative priority puts a middleware ahead of every one that took the default.
Stopping the dispatch
If you throw from execute(), the dispatch ends before the handler or its gates run.
What you throw decides what the caller sees. A Notice renders its card. A Silence drops the interaction without replying, so the caller sees Discord's own failed-interaction message. Anything else reports as a fault, the same as a throw from a handler.
@RegisterInteractionMiddleware({ priority: -10 })
export class Maintenance extends InteractionMiddleware {
public async execute(): Promise<void> {
if (await underMaintenance()) throw new Closed();
}
}Closed is a Notice you write yourself, with its own render().
Replying from a middleware
A middleware uses the same reply methods as its handler. reply, defer, followUp, edit, send, and delete all share one record of whether the interaction has been answered yet.
If a middleware acks, its handler gets an interaction that's already acknowledged. Keep track of what the middleware called, so the handler uses a method that fits. After a middleware calls defer(), the handler can edit() or followUp(). If the handler calls reply() there, it throws ReplyIllegalAckState. If you'd rather not track that, call send(). It checks what already happened, then replies, edits, or follows up to match.
Warning
seedcord builds your handler first, before any middleware or gate runs. If your constructor queries your database, that query runs even when a middleware or a gate refuses the interaction afterwards. Only set fields in the constructor. Put database calls and other work in execute(), which runs only after every middleware and gate has passed.
After the handler runs
A middleware can also have an after() method. seedcord calls it once the whole dispatch is over, after your handler's execute() has finished or something stopped the dispatch early.
A middleware gets its after() call as long as its own execute() started, even if that execute() threw. Say middleware A runs before middleware B.
- If both ran,
B.after()runs first, thenA.after(). - If
Athrew, onlyA.after()runs.Bnever started, so it doesn't get one.
Those calls always run. They run after a gate refuses, after a handler throws, and even after the render() of a refusal card throws. Use after() to release whatever execute() held open, a lock or a tracing span.
import {
class InteractionMiddleware<
in out Kind extends MiddlewareKind = MiddlewareKind
>
InteractionMiddleware,
function RegisterInteractionMiddleware<
const Kinds extends NonEmptyTuple<MiddlewareKind> =
NonEmptyTuple<MiddlewareKind>
>(
options?: InteractionMiddlewareOptions<Kinds>
): <TCtor extends AnyMiddlewareCtor>(
ctor: AssertMiddlewareKinds<Kinds[number], TCtor>
) => void
RegisterInteractionMiddleware
} from '@seedcord/gateway';
import type { type DispatchResult =
| {
readonly outcome: "handled";
}
| {
readonly outcome: "refused" | "failed";
readonly caught: unknown;
}
DispatchResult } from '@seedcord/gateway';
@RegisterInteractionMiddleware<NonEmptyTuple<MiddlewareKind>>(options?: InteractionMiddlewareOptions<NonEmptyTuple<MiddlewareKind>> | undefined): <TCtor>(ctor: AssertMiddlewareKinds<MiddlewareKind, TCtor>) => voidRegisterInteractionMiddleware()
export class class SpanSpan extends class InteractionMiddleware<
in out Kind extends MiddlewareKind = MiddlewareKind
>
InteractionMiddleware {
private Span.span?: {
end(status: string): void;
}
span?: type ReturnType<T extends (...args: any) => any> = T extends (
...args: any
) => infer R
? R
: any
ReturnType<(typeof const tracer: {
open(route: string): {
end(status: string): void;
};
}
tracer)['open']>;
public async Span.execute(): Promise<void>execute(): interface Promise<T>Promise<void> {
this.Span.span?: {
end(status: string): void;
}
span = const tracer: {
open(route: string): {
end(status: string): void;
};
}
tracer.function open(route: string): {
end(status: string): void;
}
open(this.BaseHandler<InteractionOf<MiddlewareKind>, Core>.dispatch: DispatchContextdispatch.DispatchContext.routeId: stringrouteId);
await var Promise: PromiseConstructorPromise.PromiseConstructor.resolve(): Promise<void> (+2 overloads)resolve();
}
public override async Span.after(result: DispatchResult): Promise<void>after(
result: DispatchResultresult: type DispatchResult =
| {
readonly outcome: "handled";
}
| {
readonly outcome: "refused" | "failed";
readonly caught: unknown;
}
DispatchResult
): interface Promise<T>Promise<void> {
this.Span.span?: {
end(status: string): void;
} | undefined
span?.function end(status: string): voidend(result: DispatchResultresult.outcome: "handled" | "refused" | "failed"outcome);
await var Promise: PromiseConstructorPromise.PromiseConstructor.resolve(): Promise<void> (+2 overloads)resolve();
}
}The base class declares after(), so your version overrides it. Mark it override, since leaving that off is a compile error under TypeScript's noImplicitOverride.
after() receives a DispatchResult that says how the dispatch ended. The Span sample ends its tracing span with result.outcome.
outcome | when | caught |
|---|---|---|
'handled' | the handler finished without throwing | not present |
'refused' | something threw a Silence, or a Notice with report false | the thrown value |
'failed' | something threw a reported Notice or any other error | the thrown value |
An after() that throws doesn't stop the other after() calls. If B.after() throws, seedcord logs the error and still calls A.after().
Where to put middleware
seedcord loads middleware from bot.interactions.middlewares. The key is optional.
export const seedcord = new Seedcord({
bot: {
...rest,
clientOptions: { intents: [GatewayIntentBits.Guilds] },
interactions: {
path: resolve(import.meta.dirname, './handlers'),
middlewares: resolve(
import.meta.dirname,
'./handlers/middlewares'
)
}
},
subscribers: { path: null }
});If a class in that directory doesn't have the decorator, seedcord loads it and never runs it. A handler missing its route decorator behaves the same way. seedcord's middleware-missing-register-decorator lint rule flags that class while you write it.
If two middleware classes share a name, seedcord throws DuplicateMiddleware when it loads the second one. Give every class a unique name.
A middleware passes values to the handler and its gates through the dispatch context.