Skip to content

Throwing

Refuse from a handler by throwing a Notice, so the user gets a reason rather than silence. Covers what each kind of throw produces, the fields on a Notice, and what render receives.

A handler refuses by throwing. seedcord catches the throw at one boundary and sends the reply through the handler, so the class you throw sets what the reply says. A gate throws into the same boundary, and its refusal reaches the user the same way.

Say your ban handler checks for an open case in a helper. Returning a flag means execute() has to check it, and every caller of every helper that can refuse has to check one too. Throw from the helper and the handler stops at the line that found the problem.

You write a refusal as a Notice subclass.

src/handlers/Ban.ts
import {
    BuilderComponent,
    Notice,
    SlashHandler,
    SlashRoute
} from '@seedcord/gateway';

import type { ReplyResponse } from '@seedcord/gateway';

class OpenCaseCard extends BuilderComponent<'container'> {
    constructor() {
        super('container');

        this.instance.addTextDisplayComponents((text) =>
            text.setContent(
                '### Cannot ban\nThat user already has an open case.'
            )
        );
    }
}

class OpenCaseExists extends Notice {
    constructor(id: string) {
        super(`user ${id} has an open case`);
    }

    public render(): ReplyResponse {
        return { components: [new OpenCaseCard().component] };
    }
}

@SlashRoute('ban')
export class Ban extends SlashHandler<'ban'> {
    public async execute(): Promise<void> {
        const target = this.options.getUser('target');
        await this.assertNoOpenCase(target.id);

        await this.reply(`Banned ${target.username}.`);
    }

    private async assertNoOpenCase(id: string): Promise<void> {
        const open = await cases.countOpen(id);
        if (open > 0) throw new OpenCaseExists(id);
    }
}

Your logs and your fault reports read the string you pass to super(). The user never sees it, so the id goes there. render() builds the reply and returns a ReplyResponse, the same { components } shape that this.reply() takes.

assertNoOpenCase refuses on its own. execute() calls it and continues, since a refusal never comes back as a return value.

super() takes a second argument too, for the error you caught.

class ProfileUnavailable extends Notice {
    constructor(cause: unknown) {
        super('the profile lookup failed', { cause });
        this.report = true;
    }
}

{ cause } keeps that error on the throw. With report set to true there, the fault report prints its stack.

What each throw produces

you throwthe user seesreported
a Notice subclasswhatever your render() returnswhen you set report
a Faulta generic card carrying a tracking uuidyes, by default
a Silencenothingno
any other errorthat same generic cardyes

Reported means seedcord logs the throw and puts it on the bus, where a webhook reporter can send it to a Discord channel. Faults explains the bottom three rows.

The fields on a Notice

constructor(id: string) {
    super(`user ${id} has an open case`);

    this.report = true;
    this.ephemeral = false;
    this.summary = 'the target already has an open case';
}
fielddefaultwhat it changes
reportfalsetrue logs the throw with a uuid and publishes it to the handledException bus key
ephemeraltruefalse shows the reply to everyone in the channel
summaryunseta one-line reason the or gate lists when every gate inside it refuses

Only the or gate reads summary. Combining gates shows where it appears.

Warning

Throwing a custom Notice for a real bug shows its card and produces nothing else, because report starts false. Throw a Fault when you need the error in your logs or set report = true on the Notice.

What render receives

render() takes a RenderContext. Leave the parameter off when you don't read any of its fields, as the first sample does.

fieldholds
ctx.uuidthe id seedcord logs and reports for this throw
ctx.developerUsernamenotifications.developerUsername from your bot config, set on Faults. undefined when you have not set it
ctx.dispatchthe dispatch context the handler and its gates read
public render(ctx: RenderContext): ReplyResponse {
    const contact = ctx.developerUsername ?? 'the developer';

    return {
        components: [new TraceCard(ctx.uuid, contact).component]
    };
}

LookupFailed builds its TraceCard inside render(). seedcord calls render() each time it shows the refusal, so every reply gets new builders with your bot color read at that moment.