Custom IDs
Declare what a component's custom id carries with CustomId, so a click arrives with typed values. Covers minting the string, the field kinds, reading an id back by hand, and clicks the router should skip.
Discord hands a component's custom_id back when someone clicks a button, picks from a select menu, or submits a modal. It's always a plain string, and whatever your handler reads later has to be inside it.
Written by hand, that's a string like ticket:184580573574955008:close:true that you build in one file and split apart in another. Every value comes back as text, so turning 'true' back into a boolean is your job too. The two sides agree on the order only because you kept them matching by hand. Add a field to one side and the other reads the wrong piece, with no error until someone clicks.
CustomId declares what the string carries, once. The component that encodes it and the handler that decodes it both read that one declaration, so a field you add reaches both sides. The AppealId on the button from building components is one of these.
import { class CustomId<
Prefix extends string,
Shape extends CustomIdShape = {}
>
CustomId } from '@seedcord/gateway';
export const const TicketAction: CustomId<
"ticket",
Record<"ownerId", CustomIdField<string>> &
Record<"action", CustomIdField<"close" | "reopen">> &
Record<"notify", CustomIdField<boolean>>
>
TicketAction = new new CustomId<"ticket", {}>(prefix: "ticket", shape?: {} | undefined): CustomId<"ticket", {}>CustomId('ticket')
.CustomId<"ticket", {}>.snowflake<"ownerId", false>(name: "ownerId", opts?: FieldOptions<false> | undefined): CustomId<"ticket", Record<"ownerId", CustomIdField<string>>>snowflake('ownerId')
.CustomId<"ticket", Record<"ownerId", CustomIdField<string>>>.oneOf<"action", readonly ["close", "reopen"], false>(name: "action", choices: readonly ["close", "reopen"], opts?: FieldOptions<false> | undefined): CustomId<"ticket", Record<"ownerId", CustomIdField<string>> & Record<"action", CustomIdField<"close" | "reopen">>>oneOf('action', ['close', 'reopen'])
.CustomId<"ticket", Record<"ownerId", CustomIdField<string>> & Record<"action", CustomIdField<"close" | "reopen">>>.bool<"notify", false>(name: "notify", opts?: FieldOptions<false> | undefined): CustomId<"ticket", Record<"ownerId", CustomIdField<string>> & Record<"action", CustomIdField<"close" | "reopen">> & Record<"notify", CustomIdField<boolean>>>bool('notify');'ticket' is the prefix, which seedcord routes a click by. Each call after it adds one field. Together those fields type the values encode takes and decode returns.
{ ownerId: '184580573574955008', action: 'close', notify: true }
│
encode │
▼
ticketuFh:o_DDcBfgAB
└──┬─┘└┬┘ └────┬───┘
│ │ └ the three values
│ └ shape hash
└ prefix
│
decode │
▼
{ ownerId: '184580573574955008', action: 'close', notify: true }Warning
Each method returns a new CustomId and leaves the original untouched. A call whose result you don't keep adds nothing, which is why every declaration on this page is one chain.
const Broken = new CustomId('ticket');
// the result goes nowhere and Broken keeps zero fields
Broken.snowflake('ownerId');Minting the string
encode takes one value per field and returns the wire string.
export class TicketActions extends RowComponent<'button'> {
constructor(ownerId: string) {
super('button');
const close = TicketAction.encode({
ownerId,
action: 'close',
notify: true
});
this.instance.addComponents(
new ButtonBuilder()
.setCustomId(close)
.setLabel('Close')
.setStyle(ButtonStyle.Danger)
);
}
}The chain types every value, so your editor offers the two choices oneOf declared for action.
TicketAction.encode({ ownerId: '123', action: '- close
- reopen
'archive' below isn't one of the choices, so the build stops, and leaving out a field like notify stops it too.
TicketAction.encode({ ownerId: '123', action: 'archive' });The type can't check a value's range or format. 900 compiles against int('page', 1, 50), and so does 'abc' for a snowflake. encode throws on both when it runs.
The field kinds
The kind you pick sets the type your handler gets back and how many characters the value takes. Pick the narrowest kind that fits.
| method | decodes to | on the wire |
|---|---|---|
snowflake(name) | string | packs |
uuid(name) | string | packs |
int(name, min, max) | number | packs |
int(name) | number | its own token |
bool(name) | boolean | packs |
oneOf(name, choices) | the literal union | packs |
someOf(name, choices) | an array of the literal union | packs |
str(name) | string | its own token |
oneOf reads its choices as literals with no as const, so choice decodes as that union.
const Vote = new CustomId('vote')
.uuid('pollId')
.oneOf('choice', ['yes', 'no', 'abstain'])
.int('weight', 1, 10);
const { choice } = Vote.decode('...');someOf carries any subset of its list, which suits a select menu that allows several picks.
const Assign = new CustomId('assign').someOf('roles', [
'reader',
'artist',
'vip'
]);
const wire = Assign.encode({ roles: ['vip', 'reader', 'vip'] });
const { roles } = Assign.decode(wire); // ['reader', 'vip']The 'vip' passed twice comes back once. decode returns the picks in the order the list declares them, which puts 'reader' first.
Fields that can be absent
A /leaderboard [role] command takes a filter the caller can leave out, and every page button has to carry that filter either way. Every kind takes { nullable: true } for a value like that, which decodes the field as T | null.
const Board = new CustomId('board')
.int('page', 0, 999)
.snowflake('roleId', { nullable: true });
const { roleId } = Board.decode('...');When the caller leaves the role out, pass roleId: null to encode. Each button then decodes roleId as null, which your handler reads as no filter.
Warning
Marking a field nullable changes the shape, which changes the hash in the route key. Every button already on a message refuses with StaleCustomId on the next click. The user gets a card saying so, and that card is yours to replace.
What a field costs
Discord caps a custom_id at 100 characters, and that covers the prefix, the shape hash, and every value.
A packing field has a known count of possible values. seedcord multiplies those counts into one integer and writes it in base64. A nullable field adds one to its own count, for the absent case.
ownerId 184580573574955008 2^64 possible values
action 'close' 2 possible values
notify true 2 possible values
multiplied into one integer, written in base64
│
▼
o_DDcBfgAB 10 charactersA token field sits after that number behind a separator. str writes its text as given, which makes it the expensive kind. Avoid it where another kind fits.
Once a wire runs past 100, encode throws CustomIdWireTooLong. You call encode while you build the component, so you get that throw before anyone can click.
Tip
Bound an integer whenever you know its range. int('page') writes its own token. int('page', 0, 500) packs into the shared number with everything else and makes a shorter wire. oneOf, someOf, and bool pack in the same way.
Reading one back yourself
A handler decodes the id for you, as Buttons shows. Code outside a handler, like a discord.js collector, gets only the raw string, so you decode it yourself.
declare const wire: string;
if (TicketAction.owns(wire)) {
const { ownerId, action } = TicketAction.decode(wire);}
const Poll = new CustomId('poll').uuid('pollId');
const { prefix, params } = decodeFor([TicketAction, Poll], wire);owns compares the prefix and ignores everything after it. decode throws on a matching prefix with an old hash, which means the id went stale. It also throws on a corrupt string, or one minted by another CustomId.
decodeFor tries each id in the array against the string. It returns the prefix that matched with that id's own params, and checking prefix narrows params to match.
Danger
A custom id you write by hand compiles. setCustomId('ticket:123') skips the shape hash encode adds, so seedcord reads that prefix as tic. No handler matches the click. Mint every id through encode.
Danger
CustomId ships in @seedcord/custom-id, and your transport package re-exports it. Import it from @seedcord/gateway or @seedcord/http. Adding @seedcord/custom-id to your own dependencies can resolve a second copy, and a decode failure from that copy skips the rendered notices seedcord provides.
Clicks the router should skip
A discord.js collector you opened yourself answers the clicks it waits for. seedcord's router gets those same clicks too. With no handler registered for them, it answers with its unhandled reply. Discord then rejects whichever answer comes second. List the id under ignoreCustomIds to make the router return before it answers.
export const seedcord = new Seedcord({
bot: {
clientOptions: { intents: [GatewayIntentBits.Guilds] },
interactions: {
path: resolve(import.meta.dirname, './handlers'),
ignoreCustomIds: [Poll]
},
commands: { path: null },
events: { path: null }
},
subscribers: { path: null }
});A click whose prefix Poll owns never reaches a handler. The list takes CustomId values. A raw string fails to compile, since seedcord matches each entry by calling owns.
Gateway only
An http bot does not read ignoreCustomIds. Discord posts every
interaction to one endpoint, and the router dispatches all of
them.