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.
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.
| field | type | holds |
|---|---|---|
core | CoreBase | the running framework, for core.config and core.rateLimiter |
userId | string | null | the acting user's id, null on an event that carries none |
guildId | string | null | the server id, null in a direct message |
channelId | string | null | the channel id, null when the source carries none |
memberRoleIds | readonly string[] | the caller's role ids without the everyone role, empty outside a server |
memberPermissions | bigint | null | the caller's permission bits, channel-scoped on an interaction and role-derived on a gateway event, null outside a server |
appPermissions | bigint | null | your bot's permission bits in the invoked channel, null on gateway events |
declaredRoute | string | null | the route this dispatch matched, such as slash:daily, null off a route |
dispatch | DispatchContext | the 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.
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.
| annotation | fits | adds |
|---|---|---|
GateContextBase | every handler, both transports | the nine fields above |
InteractionGateContext | any interaction handler | interaction, user, guild, member |
InteractionGateContext<ButtonInteraction> | button handlers | the same, with interaction typed to a button |
EventGateContext | any event handler | eventName, payload, user, guild, member |
EventGateContext<Events.MessageCreate> | that one event | the same, with payload typed to its tuple |
GateContext | either arm | the 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.