When a custom id goes stale
Someone clicks a component your bot minted under an older custom id shape. Covers the edits that change the shape hash, what your user sees, a wire from a different id, and replacing both cards with your own.
A message with a button or a select menu on it stays in the channel until someone deletes it. Your shape changes on the next deploy. Decoded against the new shape, an old button's values would fill the wrong fields, and your handler would use them.
seedcord stops that with a three-character shape hash in every id it mints. An old button's hash doesn't match the new shape, so seedcord refuses its values before your handler reads them.
import { CustomId } from '@seedcord/gateway';
export const TicketAction = new CustomId('ticket')
.snowflake('ownerId')
.oneOf('action', ['close', 'reopen']);TicketAction.routeKey reads ticketVrl, the prefix ticket followed by the hash Vrl. Every id encode mints starts with it.
What changes the hash
The hash covers each field's name, its position in the chain, its kind, whether it's nullable, the choices on a oneOf or a someOf, and an int's bounds. Change any one of those and every id already in Discord belongs to the old shape.
| the edit | new routeKey |
|---|---|
| the shape above | ticketVrl |
rename ownerId to userId | ticketbdx |
add 'escalate' to the choices | ticketY1t |
| swap the two fields around | ticketpXF |
append .bool('notify') | ticketuFh |
Bounds count the same way. int('n', 0, 100) and int('n', 0, 200) produce different hashes, so widening a page range invalidates the buttons you already sent.
Weigh an edit against how long its messages stay in use. A reply someone clicks once and scrolls past can change shape on any deploy. A role menu pinned in a rules channel stops working after the same edit, so plan to send that message again when you ship it.
What your user sees
Routing reads the prefix alone, so an interaction from an old message still reaches your handler. Reading this.params decodes the wire, compares its route key against the current one, and refuses when they differ.
That refusal is a Notice, so the boundary catches it and replies with a card.
### Outdated
This button or menu is from an older version. Please run the command again.The card is ephemeral, and nothing reaches your logs or the handledException bus. seedcord doesn't report a stale click, because it's the normal result of editing a shape.
A modal and a select menu route by prefix the same way, so both go stale on the same rule.
A wire from a different id
decode separates two failures. A matching prefix with a different hash is the stale case above. A wire minted by some other CustomId refuses with a message naming both route keys.
Invalid customId. routeKey "pollM4x" is not "ticketVrl"That one does report, since a working bot never produces it. seedcord logs it and publishes it to the handledException bus, where the report carries the name InvalidCustomId. The user sees the generic card.
### Cannot Proceed
Something went wrong. Please try again.decode also throws on a corrupt string, or on one you wrote by hand.
Replacing both cards
Both cards above use seedcord's wording. Replace them when your bot needs its own, like a card naming the command that sends a fresh menu.
The defaults are Notice subclasses seedcord registers when it loads. setCustomIdErrors replaces them with yours.
class OutdatedCard extends BuilderComponent<'container'> {
public constructor(message: string) {
super('container');
this.instance.addTextDisplayComponents(
new TextDisplayBuilder().setContent(message)
);
}
}
class Outdated extends Notice {
public constructor(message: string) {
super(message);
}
public render(): ReplyResponse {
return {
components: [
new OutdatedCard(
'### Try again\nThat button is from an older deploy.'
).component
]
};
}
}
setCustomIdErrors({
stale: (prefix) => new Outdated(`stale customId for ${prefix}`),
invalid: (detail) => new Outdated(detail)
});Call it once, before your bot starts. A click that arrives before the call still gets the built-in card. One registration covers every CustomId in the process, and a later call replaces the earlier one.
The stale arm receives the route prefix. The invalid arm receives a detail string naming what failed. Neither arm receives the interaction. The Notice you return still gets a RenderContext when seedcord calls its render(), carrying the fault's uuid and the dispatch context your gates and middleware filled, so read per-user details there. Return a Notice from both arms. A plain Error sends the generic fault card.