Presenters
Decide how MeoCord's loading view and error answers look, in your bot's style and language.
You'll learn
- Write a presenter for the loading and error views
- Style an error by whether the user caused it
- Draw a view as an image and attach it
- Register it on the app and test it without a module
Before this
MeoCord answers for you in a few places: the loading view @Defer adds while a handler runs, the
error answer the built-in fallback sends when a call fails, a message command's error replies, and the reply of the
built-in !help. A presenter decides how those look, as text or as an image it draws. What they say is decided
elsewhere, by exception filters and the fallback.
When to use it
Write a presenter when MeoCord's own answers should match yours: your bot's title for an error, a loading text in the user's language, or an error that looks different when it's the user's mistake rather than the bot's.
To change only the colours or the loading emoji, you don't need one: set them in the theme, and the default presenter uses them. To change what an error says, write an exception filter instead.
Example
import { Service } from 'meocord/decorator'
import { type PresentedError, type ResponseContext, type ResponsePresenter } from 'meocord/interface'
@Service()
export class BrandPresenter implements ResponsePresenter {
loading({ theme }: ResponseContext) {
return { text: 'Hang on…', emoji: theme.emojis.loading, color: theme.colors.primary }
}
// `tone` is 'warning' when the user can fix it, such as a refused or invalid call, and 'danger' for a fault
error({ theme }: ResponseContext, { message, tone }: PresentedError) {
return {
title: tone === 'warning' ? 'Not quite' : 'Something went wrong',
text: message,
color: theme.colors[tone],
}
}
}Register it on the app. It's resolved once, from the container, so it can inject services, a Translator to answer
in the user's language, for instance:
@MeoCord({
controllers: [CardButtonController],
clientOptions: { intents: [GatewayIntentBits.Guilds] },
presenter: BrandPresenter,
})
export default class App {}The loading view reads "Hang on…" with the theme's loading emoji. An error the user can fix, such as a refused or invalid call, is titled "Not quite" in the warning colour, and a fault in the bot "Something went wrong" in the danger colour.
How it works
loading() and error() each return a view, { text, title?, color?, emoji?, components?, files?, image?,
thumbnail? }, or a promise of one. MeoCord renders it as an embed, or as a Components V2 container on a message that
uses Components V2. A view with no color takes the theme's primary colour.
Each method gets a ResponseContext:
| Field | What it is |
|---|---|
interaction | The interaction being answered. |
locale | The user's locale, for text in their language. |
mode | 'embed' or 'v2', the form the view is rendered in. |
theme | The call's resolved theme, @UseTheme and per-server themes included. |
error() also gets a PresentedError: the message to show, the error itself, and
its tone. tone is 'warning' for an answer that is no fault of the bot's code: the user's own doing, such as a
denied guard, a cooldown, invalid input or a UserError, and also a command nothing handles and a cooldown store
that is down. It's 'danger' for anything else thrown. So theme.colors[tone] colours an error by kind.
Without a presenter, the loading view is "Working on it…" with the theme's loading emoji in its primary colour, and errors are titled "Oops!" in the colour of their tone, both in the user's language where the app translates MeoCord's texts.
Drawing a view
A view can carry files, such as an image the presenter drew, and MeoCord attaches and shows them. This presenter
draws each error as a card with meo-canvas, in the colour of its tone:
import { Column, Root, Text } from 'meo-canvas'
import { Service } from 'meocord/decorator'
/** Draws a card, a title over a message, as a PNG. */
@Service()
export class CardRenderer {
async draw(title: string, message: string, accent: string): Promise<Uint8Array> {
const canvas = await Root({
width: 480,
padding: 24,
backgroundColor: '#1E1F22',
children: Column({
gap: 8,
children: [
Text(title, { fontSize: 22, fontWeight: 'bold', color: accent }),
Text(message, { fontSize: 16, color: '#DBDEE1' }),
],
}),
})
try {
return await canvas.png
} finally {
canvas.release()
}
}
}import { resolveColor } from 'discord.js'
import { Service } from 'meocord/decorator'
import {
type MessageResponseContext,
type PresentedError,
type ResponseContext,
type ResponsePresenter,
type ResponseView,
} from 'meocord/interface'
import { CardRenderer } from '@src/presenters/card.renderer'
/** Draws the bot's errors as image cards, for interactions and message commands alike. */
@Service()
export class CardPresenter implements ResponsePresenter {
constructor(private readonly cards: CardRenderer) {}
loading({ theme }: ResponseContext) {
return { text: 'Hang on…', emoji: theme.emojis.loading }
}
error(context: ResponseContext, error: PresentedError) {
return this.card(context, error)
}
// A message command's usage reply, refusals and errors
messageError(context: MessageResponseContext, error: PresentedError) {
return this.card(context, error)
}
private async card({ theme }: ResponseContext | MessageResponseContext, { message, tone }: PresentedError) {
const color = theme.colors[tone]
const accent = `#${resolveColor(color).toString(16).padStart(6, '0')}`
const data = await this.cards.draw(tone === 'warning' ? 'Not quite' : 'Something went wrong', message, accent)
return { text: message, color, files: [{ name: 'error.png', data }], image: 'error.png' } satisfies ResponseView
}
}A file is an AttachmentBuilder, or { name, data } with the bytes as a Buffer or Uint8Array. In an embed, the
first image is the embed's image. In a Components V2 container, images go in a gallery below the text, and other
files below it as file components. image and thumbnail name one of the files, or a URL: the embed's image and
thumbnail, or the container's leading image and the text's thumbnail. A file the view's own components show by
attachment://<name> isn't shown again.
Each method may draw asynchronously, and a slow drawing never misses Discord's three seconds:
- The loading view is drawn after
@Deferacknowledges the call. Its files leave the message when the lock does. The handler waits for it, so a drawing that takes more than a second is given up on, with a warning, and MeoCord's loading view is shown instead. - For an error on an interaction not yet acknowledged, MeoCord acknowledges it privately first, and the drawn view replaces the acknowledgement.
- A view added to a message by an edit keeps the message's own attachments.
A presenter that fails never leaves the user without an answer. When error() or messageError() throws, rejects, or
returns a view MeoCord can't render, such as a colour that is no colour or an empty text, MeoCord's own answer goes out
instead: its error view, or the plain text a message command gets without messageError. When MeoCord's fallback is
answering, the failure is then logged as the call's fault, and a testing module's dispatch rejects with it; a
respond().error() of your own logs it. A loading() that fails or is late the same way is replaced by MeoCord's
loading view, with a warning naming the presenter, and the handler still runs.
Discord takes at most 10 attachments on a message, counting the ones a message the view is added to keeps, and each file within the interaction's attachment size limit, or 20 MiB without one. A view past either is sent without its files, and a warning says why. A send Discord refuses as too large, such as one with a file given as a path or a stream, whose size can't be checked first, is sent again without its files. Either way, the image and thumbnail that named a dropped file go with it, and the user still gets the answer.
Message command errors
A message command can't be answered privately, so its errors are replies and direct messages: the usage reply, a
guard's or validation's reason, a UserError's message, and the direct messages
dmOnError and dmOnCooldown send. They're plain text,
unless the presenter has a third, optional method, messageError(context, error), which draws them as views. The
card presenter above has one, so a message command's errors get the same card.
Its MessageResponseContext has the message in place of an interaction:
| Field | What it is |
|---|---|
message | The message being answered. |
locale | The server's preferred locale, or the translator's default locale in a DM. |
mode | Always 'embed': a reply is a new message, drawn as an embed. |
theme | The call's resolved theme, as ResponseContext has it. |
The error it gets is a PresentedError, as error() gets. The view is sent as an embed with its files, under the
same limits. When it fails, the author gets the plain text instead, and the failure is the call's fault.
The help reply
The built-in !help writes plain text. A presenter with another optional method,
messageHelp(help, message), writes it instead: help is the MessageHelp the built-in
found, a list of commands, one command, a parent's subcommands, or that nothing matched, and the method returns the
text or the options message.reply takes, such as an embed. Message commands shows one.
A help command of your own reads the same model from HandlerRegistry.
Testing a presenter
A presenter is plain code, so its test needs no module. Give it a context with a theme from
createMockTheme():
import { ChatInputCommandInteraction } from 'discord.js'
import { createMockInteraction, createMockTheme } from 'meocord/testing'
import { describe, expect, it } from 'vitest'
import { BrandPresenter } from '@src/presenters/brand.presenter'
describe('BrandPresenter', () => {
const presenter = new BrandPresenter()
const theme = createMockTheme({ colors: { warning: '#B08400', danger: '#E3606D' } })
const context = {
interaction: createMockInteraction(ChatInputCommandInteraction),
locale: 'en-US',
mode: 'embed' as const,
theme,
}
const error = new Error('x')
it('titles a fault, in the danger colour', () => {
expect(presenter.error(context, { message: 'Try again later.', error, tone: 'danger' })).toMatchObject({
title: 'Something went wrong',
text: 'Try again later.',
color: '#E3606D',
})
})
it("titles the user's own mistake apart, in the warning colour", () => {
expect(presenter.error(context, { message: 'Pick a smaller number.', error, tone: 'warning' })).toMatchObject({
title: 'Not quite',
color: '#B08400',
})
})
it("shows its loading text with the theme's loading emoji", () => {
expect(presenter.loading(context)).toMatchObject({ text: 'Hang on…', emoji: theme.emojis.loading })
})
})A drawn view's test checks the file it carries. The card presenter's draws a real PNG:
import { ChatInputCommandInteraction } from 'discord.js'
import { createMockInteraction, createMockMessage, createMockTheme } from 'meocord/testing'
import { describe, expect, it } from 'vitest'
import { CardPresenter } from '@src/presenters/card.presenter'
import { CardRenderer } from '@src/presenters/card.renderer'
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
describe('CardPresenter', () => {
const presenter = new CardPresenter(new CardRenderer())
const theme = createMockTheme()
const error = { message: 'Pick a smaller number.', error: new Error('x'), tone: 'warning' as const }
it("draws an error as a PNG and shows it as the view's image", async () => {
const context = {
interaction: createMockInteraction(ChatInputCommandInteraction),
locale: 'en-US',
mode: 'embed' as const,
theme,
}
const view = await presenter.error(context, error)
expect(view).toMatchObject({ text: 'Pick a smaller number.', color: theme.colors.warning, image: 'error.png' })
expect(view.files.map(file => file.name)).toEqual(['error.png'])
expect([...view.files[0]!.data.subarray(0, 8)]).toEqual(PNG_SIGNATURE)
})
it("draws a message command's error reply the same way", async () => {
const context = { message: createMockMessage(), locale: 'en-US', mode: 'embed' as const, theme }
const view = await presenter.messageError(context, error)
expect(view).toMatchObject({ image: 'error.png', files: [{ name: 'error.png' }] })
})
})Gotchas
- A presenter styles, it doesn't word. The
messageit gets is what a filter or the fallback chose; change the words there, not here. - A file over Discord's limits is dropped, not the answer. When a drawn image doesn't show, look for the warning, which names the file and the limit.
- A context built by hand needs
theme, and aPresentedErrorneedstone. UsecreateMockTheme()for the theme in a test.
Build it
The feedback bot's loading view and its error answers use MeoCord's defaults. Give them the bot's own words, coloured by the theme:
// How the bot looks while it works and when something fails, in the theme's colours
@Service()
export class FeedbackPresenter implements ResponsePresenter {
loading({ theme }: ResponseContext): ResponseView {
return { text: 'Working on it…', emoji: theme.emojis.loading, color: theme.colors.primary }
}
// A user's own mistake is a warning, a fault in the bot is danger
error({ theme }: ResponseContext, { message, tone }: PresentedError): ResponseView {
return { title: 'Something went wrong', text: message, color: theme.colors[tone] }
}
}Register it on the app:
import { FeedbackPresenter } from '@src/tutorial/feedback.presenter'
// …
presenter: FeedbackPresenter,Click Approve on a report: while the review is saved, the post shows the bot's loading text, with the theme's loading emoji, in the theme's primary colour.
Next steps
- Theming: set the colours and emojis a presenter reads from
theme. - Exception filters: change what an error says, and throw a
UserErrorfor the user's own mistakes. - Localisation: answer in the user's language, in a presenter and everywhere else.