Skip to content

Your own gates

Write a gate of your own with defineGate() when the shipped ones don't fit your rule. Covers what a check reads, narrowing it to certain handlers, the two guild permission fields, async checks, and what to throw.

Some checks belong to your bot alone, like keeping a command to one channel or requiring a linked account. The shipped gates cover owners, roles, permissions, and cooldowns, so a check like that would otherwise go back to the top of every handler that needs it. defineGate turns that check into a gate you attach with the rest.

It takes a name and a check. The check reads the context, refuses by throwing, and passes by returning. Here's an example that keeps a command to one channel.

src/gates/InChannel.tshover for typestap for types, arrow keys walk the tokens
import {
    class BuilderComponent<BuilderKey extends BuilderType>BuilderComponent,
    
function defineGate<
    const Name extends string,
    Ctx extends GateContextBase = GateContextBase
>(name: Name, fn: (ctx: Ctx) => void | Promise<void>): Gate<Ctx, Name>
defineGate
,
class NoticeNotice } from '@seedcord/gateway'; import type {
interface Gate<
    Ctx extends GateContextBase = GateContextBase,
    Name extends string = string
>
Gate
,
interface GateContextBaseGateContextBase, interface ReplyResponse<TNative = never>ReplyResponse } from '@seedcord/gateway'; class class WrongChannelCardWrongChannelCard extends class BuilderComponent<BuilderKey extends BuilderType>BuilderComponent<'container'> { public constructor(channelId: stringchannelId: string) { super('container'); this.BaseComponent<ContainerBuilder>.instance: ContainerBuilderinstance.ContainerBuilder.addTextDisplayComponents(...components: RestOrArray<APITextDisplayComponent | TextDisplayBuilder | ((builder: TextDisplayBuilder) => TextDisplayBuilder)>): ContainerBuilderaddTextDisplayComponents((text: TextDisplayBuildertext) => text: TextDisplayBuildertext.TextDisplayBuilder.setContent(content: string): TextDisplayBuildersetContent( `### Wrong channel\nTry <#${channelId: stringchannelId}>.` ) ); } } class class WrongChannelWrongChannel extends class NoticeNotice { public constructor(private readonly WrongChannel.channelId: stringchannelId: string) { super(`caller was outside ${channelId: stringchannelId}`); } public WrongChannel.render(): ReplyResponserender(): interface ReplyResponse<TNative = never>ReplyResponse { return { ReplyResponse<never>.components: V2Component[]components: [ new constructor WrongChannelCard(channelId: string): WrongChannelCardWrongChannelCard(this.WrongChannel.channelId: stringchannelId).BuilderComponent<"container">.component: ContainerBuildercomponent ] }; } } export function
function InChannel(
    channelId: string
): Gate<GateContextBase, "InChannel">
InChannel
(
channelId: stringchannelId: string ):
interface Gate<
    Ctx extends GateContextBase = GateContextBase,
    Name extends string = string
>
Gate
<interface GateContextBaseGateContextBase, 'InChannel'> {
return defineGate<"InChannel", GateContextBase>(name: "InChannel", fn: (ctx: GateContextBase) => void | Promise<void>): Gate<GateContextBase, "InChannel">defineGate('InChannel', (ctx: GateContextBasectx) => { if (ctx: GateContextBasectx.GateContextBase.channelId: string | nullchannelId !== channelId: stringchannelId) throw new constructor WrongChannel(channelId: string): WrongChannelWrongChannel(channelId: stringchannelId); }); }

InChannel is a function that returns a gate, which is how most of the catalog is built. The closure keeps the argument, so one factory covers any channel.

@Gated(InChannel('221439658686939136'))
@SlashRoute('leaderboard')
export class Leaderboard extends SlashHandler<'leaderboard'> {
    public async execute(): Promise<void> {
        await this.reply('Top ten.');
    }
}

The 'InChannel' string you passed to defineGate appears in the compile error when you attach the gate to a handler it doesn't fit. and and or join the names of their arms into one.

What a check reads

Leaving the ctx parameter unannotated makes it a GateContextBase, which every handler on both transports provides. These are its fields.

fieldtypeholds
coreCoreBasethe running framework, for core.config and core.rateLimiter
userIdstring | nullthe acting user's id, null on an event that carries none
guildIdstring | nullthe server id, null in a direct message
channelIdstring | nullthe channel id, null when the source carries none
memberRoleIdsreadonly string[]the caller's role ids without the everyone role, empty outside a server
memberPermissionsbigint | nullthe caller's permission bits, channel-scoped on an interaction and role-derived on a gateway event, null outside a server
appPermissionsbigint | nullyour bot's permission bits in the invoked channel, null on gateway events
declaredRoutestring | nullthe route this dispatch matched, such as slash:daily, null off a route
dispatchDispatchContextthe dispatch context, holding what a middleware wrote before the first gate ran

A gate that reads only these fields fits any handler you attach it to. InChannel above reads one of them, so it works on a slash command, a button, and a message event.

Narrowing what the gate accepts

Annotating ctx with a narrower context changes two things. The check reads the extra fields, and @Gated rejects the gate on any handler that lacks them.

src/gates/NoAttachments.tshover for typestap for types, arrow keys walk the tokens
export const 
const NoAttachments: Gate<
    EventGateContext<Events.MessageCreate>,
    "NoAttachments"
>
NoAttachments
= defineGate<"NoAttachments", EventGateContext<Events.MessageCreate>>(name: "NoAttachments", fn: (ctx: EventGateContext<Events.MessageCreate>) => void | Promise<void>): Gate<EventGateContext<Events.MessageCreate>, "NoAttachments">defineGate(
'NoAttachments', (ctx: EventGateContext<Events.MessageCreate>ctx:
interface EventGateContext<
    Names extends ValidNonInteractionKeys = ValidNonInteractionKeys
>
EventGateContext
<enum EventsEvents.function (enum member) Events.MessageCreate = "messageCreate"MessageCreate>) => {
const [const message: OmitPartialGroupDMChannel<Message<boolean>>message] = ctx: EventGateContext<Events.MessageCreate>ctx.EventGateContext<Events.MessageCreate>.payload: [message: OmitPartialGroupDMChannel<Message<boolean>>]payload; if (const message: OmitPartialGroupDMChannel<Message<boolean>>message.Message<boolean>.attachments: Collection<string, Attachment>attachments.Map<string, Attachment>.size: numbersize > 0) { throw new new Silence(reason?: string | undefined): SilenceSilence('message carried an attachment'); } } );

ctx.payload is typed to that one event's argument tuple, so message is a discord.js Message. Attaching this gate to any other handler fails to compile.

Tip

NoAttachments doesn't take an argument. Export the gate itself and attach it without calling, the way IgnoreBots does. InChannel above stays a function because it closes over a channel id.

The two gateway arms carry different fields. Narrow on ctx.kind before reading either set.

annotationfitsadds
GateContextBaseevery handler, both transportsthe nine fields above
InteractionGateContextany interaction handlerinteraction, user, guild, member
InteractionGateContext<ButtonInteraction>button handlersthe same, with interaction typed to a button
EventGateContextany event handlereventName, payload, user, guild, member
EventGateContext<Events.MessageCreate>that one eventthe same, with payload typed to its tuple
GateContexteither armthe union of the two rows above

Gateway and http differ

Both transports export a type called InteractionGateContext. Each transport's version has its own fields. Gateway's holds live discord.js objects: a User that's always present, plus a Guild and a GuildMember that can each be null, outside a server or when the cache lacks them. Http's holds an APIUser and an APIInteractionGuildMember, and it doesn't carry guild at all. A gate reading ctx.guild compiles on gateway. The same gate fails to compile on http.

The gateway cache builds that discord.js Guild. An http bot doesn't run one. Discord sends it a partial guild carrying id, features, and locale, which ctx.interaction.guild reaches.

The two guild permission fields

On an interaction, memberPermissions and appPermissions hold the set for the channel the command ran in, after overwrites. A gate about the whole server, like one guarding a server-wide setting, needs what the roles grant instead. GuildPermissionsContext adds memberGuildPermissions and appGuildPermissions, the permission sets computed from roles alone.

Gateway only

Only the gateway cache carries role-derived permissions. Annotating ctx with GuildPermissionsContext gives you a gate that the http transport rejects at the decorator line. RequirePermissions applies the same rule under in: 'guild'.

An async check

A check can return a promise, and seedcord awaits it before the handler runs. Read a database, call an API, or wait on anything else you need.

export const NotBanned = defineGate('NotBanned', async (ctx) => {
    if (ctx.userId === null) return;
    if (await bans.has(ctx.userId)) throw new Banned();
});

Gates run one after another, so the await bans.has above delays every gate behind it until the lookup returns. Discord gives you three seconds to answer an interaction, and the checks use part of it. seedcord logs a warning naming each gate's share when one handler's checks total more than 750ms. That warning is off in production.

What to throw

If you throw a Notice, seedcord replies with the card its render() builds. If you throw a Silence, seedcord drops the request without replying, which suits an event handler. Throwing has both in full, plus what a check throwing anything else produces.

If you plan to pass a gate to an or, set summary in your Notice's constructor, as in this.summary = 'run it in #bots'. When every arm refuses, the or lists each arm's summary. If one arm doesn't set a summary, the or shows a generic refusal.