Skip to content
GitHub

4.1.0-beta.10 changelog

Published

Entries marked Breaking change a working bot; the migration guide says what to do about them. Every release of the line is in the changelog.

Minor Changes

  • Breaking

    #295 a304494 Thanks @l7aromeo! - Stop a bot from code with app.stop(). It runs the onShutdown hooks under your shutdownTimeout and closes the client, so an owner-only shutdown command, a graceful restart or an integration test needs no signal. A bot in one process keeps its process running. With process sharding, the manager's stop() asks every shard to shut down and waits for it, and keeps the manager running. Called in a shard, stop() asks the manager to stop every shard, so it stops the bot whichever process calls it; that shard's process ends with the others. A stop while the bot logs in ends that login, so its start() rejects. Calls after the first wait for it, and a stopped app does not start again: use MeoCordFactory.create to make a new one. A client that fails to close, or a shard the manager has to kill, sets process.exitCode to 1, unless another code is set, so the process exits as a signal's shutdown would. A signal, or a shard manager's request, that comes while stop() runs waits for its hooks to finish rather than exiting at once.

    TypeScript
    const app = MeoCordFactory.create(App)
    await app.start()
    // later
    await app.stop()

    A SIGINT or SIGTERM after a failed login now exits with the code the failed login set, 1, where 4.0 exited 0, so a process supervisor no longer reads a bot that never came online as a clean stop. See the upgrade guide.

  • Breaking

    #310 79015cf Thanks @l7aromeo! - applyDecorators(A, B) now applies its decorators as @A @B does, stacked in the order written: B first, then A. It applied them the other way round, so guards listed in it ran in the reverse of the order written, and moving stacked decorators into applyDecorators changed what ran first. A method or class a decorator returns in place of the one it was given, as a wrapping decorator does, now reaches the next decorator and TypeScript; it was dropped.

    applyDecorators(UseGuard(A), UseGuard(B)) now runs A before B. To keep 4.0's order, write applyDecorators(UseGuard(B), UseGuard(A)). The same holds for interceptors, pipes and filters composed this way. See the upgrade guide.

  • #326 a3c2a2e Thanks @l7aromeo! - A catalog message shows a brace as text when it is written twice, and a translator says when a key has no message.

    • {{ and }} are one brace each, when the code compiles and when translating, so 'Buttons use ticket/{{id}}' shows ticket/{id} and takes no id param. A 4.1 beta catalog that writes {{name}} for a param inside braces now shows {name} as written; write {{{name}}} instead.
    • localizations(key) takes only a message without {params}, as Discord shows a name or description as written. Another key fails to compile, or, in a catalog the compiler can't read, such as a JSON file, throws as the app loads. A translation with a {param} is left out, so Discord shows the default for that locale, and expectCompleteCatalog reports it.
    • In development, a key with no message, or one naming a group of messages, logs a warning once, since the key is shown in its place.

    See Localisation.

  • #399 24f33e2 Thanks @l7aromeo! - Fixes in meocord/common:

    • A send Discord could not read is logged as an error. When a reply, an edit, a direct message or an acknowledgement fails, MeoCord still logs a refusal for something Discord reports at debug level, such as a missing permission, an interaction already answered or a message already gone. A body Discord could not read is logged as an error with its cause: an invalid form body (50035), invalid JSON (50109) or an empty message (50006). Only the code that built the request can fix those, and they were logged at debug, where they went unseen.
    • RedisCooldownStore on Redis Cluster counts a call against all of a handler's keys or none. When a handler's cooldowns sit in different slots, each key is counted by a script of its own. If a later key's script fails, the uses already counted are now given back before the failure is reported, so the call does not leave earlier keys counted for a call that was refused.
    • RedisCooldownStore's error for a reply it cannot read describes the reply it expects, including the wait's end on a refusal.
    • meocord/common exports ResponseLockOptions, the options respond(interaction).lock() takes, so a helper that passes them on can type them.
    • MemoryCooldownStore keeps its key count and its sweep to itself. It dropped expired keys once a minute through a public sweep(), beside a size getter, both of them for MeoCord's tests. Neither is part of the store's API, and code that called them uses the store as any other CooldownStore.
    • A catalog's meocord group with a text where MeoCord has a group is refused with one message naming it, such as "meocord.usage is a group, not a text", where it named every property of a string.
  • #349 380a2a8 Thanks @l7aromeo! - A call refused because the cooldown store answered too late no longer costs its caller a use. Under cooldownStoreFailure: 'deny', the default, a store slower than cooldownStoreTimeoutMs refused the call with "try again shortly", yet its late answer still recorded it, so the retry was told to wait the whole window although the handler never ran: a /daily was lost. Now @Cooldown gives that use back.

    CooldownStore.consumeMany may return a verdict with release(), which undoes the call it recorded. MemoryCooldownStore, RedisCooldownStore and ShardedCooldownStore give it, and @Cooldown calls it for any call it has already refused when the late answer arrives. A store of your own can add it to the verdict its consumeMany returns; one without it keeps such a call counted, as before, and testCooldownStore checks either. Under 'allow', the call ran uncounted, so the late count is its own and stays.

    RedisCooldownStore now stores each call under its nonce alone, which release removes; calls recorded before the upgrade leave their windows as usual. On Redis Cluster, where a handler's keys sit in different slots without hashTag: 'handler', each key is counted by a script of its own: a call counted that way is given back on every key, and a cooldown that refuses it gives back the keys counted before it, so the call counts against all of them or none, as on a single node.

  • #372 efe4518 Thanks @l7aromeo! - A cooldown store's refusal can say when the next call is allowed: CooldownVerdict.retryTimestamp, a Unix timestamp in milliseconds on the store's own clock. Every refusal in one wait gives the same one. MemoryCooldownStore, RedisCooldownStore and ShardedCooldownStore give it, and messages.dmOnCooldown tells one wait from the next by it. A store of your own can add it to its refusals; without it, waits are told apart by retryAfterMs and the bot's clock, as before. testCooldownStore checks a store that gives it.

  • #352 5667989 Thanks @l7aromeo! - A cooldown's wait is shown as a Discord timestamp, so a daily cooldown no longer says "try again in 1440m". The built-in answer to a blocked call, in replies, DMs and the presenter, is the new meocord.cooldown.until text, "Slow down: try again {when}.", where {when} is <t:…:R>: Discord words it in the reader's language and counts it down. The time is rounded up to the second, so it never reads as now while the call is still refused.

    For a bot upgrading from an earlier 4.1 beta: meocord.cooldown.seconds, meocord.cooldown.minutes and meocord.cooldown.wholeMinutes are gone. Translate meocord.cooldown.until instead, keeping {when}.

    CooldownError gains retryAt, the Date the next call is allowed, and limit, the uses and windowMs of the cooldown that blocked the call, so a filter or presenter needs no arithmetic: time(error.retryAt, 'R') from discord.js gives the same timestamp. Its message, which reaches logs and tests, stays plain text, now in the two biggest units that fit, and cooldownMessage() gives the same. For a wait of an hour or more that changes what it says: "Slow down: try again in 1439m." now reads "Slow down: try again in 23h 59m.", so a test that matches it changes too.

  • #376 5a58dc1 Thanks @l7aromeo! - @MeoCord's options and @Validate's pipes have names you can import:

    • MeoCordOptions, from meocord/decorator, is what @MeoCord takes, each option documented where your editor shows it as you write the object. Name a base two app classes share with it; its guard, interceptor and filter lists are still checked against the classes they hold.
    • ValidatePipes<S>, from meocord/interface, is the pipes @Validate takes for schema S, so a decorator of your own that wraps @Validate checks the pipes it passes on against the schema, as @Validate does.

    A misused decorator's message names it more exactly: @Command called with the wrong interaction names the builder it was declared with, as @Command('stats', StatsBuilder), and puts the right article before the class, an AutocompleteInteraction; a stage entry that is a string is quoted, "Allow" is not a class, and one that is a list, as guards: [[StaffGuard]] writes, is named as one, an array is not a class, rather than { provide } does not name a class.

  • Breaking

    #296 90d859c Thanks @l7aromeo! - ReactionEvent names the second argument a @ReactionHandler method receives, { user, action }. ReactionHandlerOptions stays as a deprecated alias of it: every other …Options type is something you pass in, to a decorator, a function or a constructor.

    These are deprecated, and removed in the next major version (5.0). Each still works in 4.x, and its JSDoc names what to use instead:

    • ReactionHandlerOptions: use ReactionEvent. See the upgrade guide.
    • SetMetadata: use createMetadata. It logs a warning once.
    • ExecutionContext.get(key) and getAll(key) with a string or symbol key, and the same on a HandlerRegistry entry: pass a decorator made by createMetadata. They log a warning once. See the upgrade guide.
    • respond()'s ephemeral option: use flags: MessageFlags.Ephemeral. It logs a warning once.
    • Theme and its colours: read useTheme().colors, and set the colours in @MeoCord({ theme }). Reading a Theme colour now logs a warning once too, as assigning one already did. See the upgrade guide.
    • MetadataKey, CommandMetadata and AutocompleteMetadata: internal names with nothing to use instead. In the next major version (5.0) they are no longer exported. See the upgrade guide.

    Run ESLint with @typescript-eslint/no-deprecated, as a new app's config does, to find every use in your code.

  • #367 a497b6e Thanks @l7aromeo! - meocord start --dev fixes:

    • A save that doesn't compile leaves the bot running. A build with errors emitted a bundle that throws them, and the bot restarted onto it. Now a build with errors emits nothing and restarts nothing, and the bot keeps running its last good build until the code compiles again.
    • Ctrl+C while watch mode restarts the bot joins that shutdown, so the bot's onShutdown hooks finish and the session exits 0. A second Ctrl+C still stops it at once.

    A new app reads the same .env files on every runtime and however it is started, meocord start or node dist/main.js under pm2, systemd or Docker: .env.<mode>.local, .env.local (not under test), .env.<mode> and .env, a more specific file winning and the shell over all of them, as Bun reads them. An app made before this keeps import 'dotenv/config', which reads .env alone under node, as 4.0 did. To read them all, replace that import in meocord.config.ts with:

    TypeScript
    import { config } from 'dotenv'
    
    const mode = process.env.NODE_ENV || 'development'
    config({
      path: [`.env.${mode}.local`, ...(mode === 'test' ? [] : ['.env.local']), `.env.${mode}`, '.env'],
      quiet: true,
    })

    On Bun, set NODE_ENV=production where you start a production bot yourself, as with bun dist/main.js under pm2, systemd or Docker. With NODE_ENV unset, Bun loads .env.development and .env.development.local before any code runs, and dotenv keeps what is already set, so their values win over .env.production. The bot now warns when that happens, naming the variables that hold a development value: "Bun loaded .env.development because NODE_ENV is unset, and this is a production build, so API_URL has its development value; set NODE_ENV=production, or start with bun --no-env-file." meocord start --prod sets NODE_ENV=production already.

    meocord build --prod compiles meocord.config.ts in production mode, as it builds the bot, so process.env.NODE_ENV in the config reads production in a production build however the bot is started. It read development, the mode the config was always compiled in. So a config that branches on NODE_ENV, such as to register commands to a development guild, now takes its production branch in a production build: check what that branch does before you deploy, and rebuild to pick this up.

  • #301 a2991bf Thanks @l7aromeo! - meocord/eslint turns on @typescript-eslint/no-floating-promises. A promise nothing awaits, such as respond(interaction).send() or a database write left without await, rejects outside every handler MeoCord runs, so its error reaches no exception filter and can end the bot. An interceptor's next.handle() left that way runs the code after it before the handler starts, so that code never sees the handler's result or error.

    After upgrading, bun run lint may report calls like these in your code. Each one is a promise that runs on its own:

    • await it, or return it, where the code after it should wait, as an interceptor's next.handle() always should;
    • or write void before it where it is meant to run on its own, and handle its failure with .catch().

    To keep the previous behaviour, set the rule to 'off' in your eslint.config.ts.

  • #320 404ba3c Thanks @l7aromeo! - getResponse(interaction) reports every answer a mock interaction got, whether the handler made it through respond() or with discord.js directly, such as interaction.reply() or interaction.followUp(). Before, it reported only what respond() made, so a handler written with discord.js's own methods showed sent: true with no calls, and a test had to read the mock's methods instead. Each call appears once, in the order made, with what it sent, without withResponse, and the error of one Discord refused. A test asserting the old calls of a handler that mixes the two now sees the direct calls too.

  • #373 e01776a Thanks @l7aromeo! - meocord/common exports LocalizationKey<C>, the keys Translator.localizations() takes: a single string with no {params}. The method's parameter used that type without a name you could import, so a helper that forwards a key to localizations() could not type it. StringMessageKey<C>'s documentation no longer says it is the type command names and descriptions need, and TranslatorOptions and CatalogDefinition are listed with the other localisation types.

  • #312 32ede56 Thanks @l7aromeo! - meocord/testing's mock channels, and the managers that fetch, answer as discord.js does, and createMockMessage takes the channel it was sent in.

    • An interaction's channel is a text channel of its server, the one the server caches under channelId, or the user's DM channel in a DM. It was a stub, whose send() and isTextBased() threw. A channel given still wins.
    • A message's channel is a text channel of its server, cached there, or the author's DM channel for a DM. It was a bare text channel with no managers. Pass createMockMessage({ channel }) to send it in another.
    • A mock channel's type guards, such as isTextBased(), isDMBased(), isThread() and isSendable(), run discord.js's own logic. They returned undefined, so if (!channel.isTextBased()) return returned early. A voice channel carries its text chat, and a forum its tags, as discord.js tells them apart by.
    • A manager's fetch(id) resolves to its cached item with that id, such as a member given to createMockGuild({ members }), or to a new one with that id, which it caches. It returned an item with another id. A member fetched from a guild's members is in that guild.

    A test that relied on one of the old answers changes with it; nothing changes in a bot.

  • #291 836e026 Thanks @l7aromeo! - meocord/testing adds createMockMember({ user, guild, roles, nickname }), and its mock users and members answer as discord.js does.

    • createMockMember() makes a member with the roles given. roles.cache holds the server's @everyone role, then those roles; roles.add(), remove() and set() change them and resolve to the member; roles.highest is the role that ranks highest, by position, then the lower id. permissions are its roles' permissions combined, @everyone's included, or every permission for the server's owner. Pass the member to createMockGuild({ members }), and an interaction or a message from its user in that server has it as its member. See Mocks.
    • Every mock member has a roles manager and permissions, with only @everyone unless given roles (for an interaction with a guildId but no guild, an @everyone with that id), instead of stubs that threw on roles.cache.has() or permissions.has(). A mock role has an id, position 1, above @everyone, and no permissions unless given.
    • A mock guild has an @everyone role, roles.everyone, in roles.cache: the role given to createMockGuild({ roles }) with the guild's id, or one at position 0 with no permissions. A guild's roles.cache therefore holds one more role than the roles given, unless one of them has the guild's id.
    • An interaction in a server has the member's permissions as memberPermissions, and null in a DM, instead of a stub whose has() threw.
    • Every mock user is a person, bot: false, however it is made. createMockInteraction(User) gave a truthy bot, so a message from that user reached no handler.
    • A DM sent to a member goes through its user's send(), and a user's through its one DM channel, so user.send, member.send and (await user.createDM()).send each see it. createDM() resolves to that channel; it returned undefined.
    • In createChatInputOptions, a user option's getMember() is the user's member in the interaction's server, and null in a DM, instead of the user; a member given resolves getUser() to its user. A whole number is an Integer option and a fraction a Number one: getInteger() returned 1.5 for a fraction, which only a Number option holds, and now returns null.

    A test that relied on one of the old answers changes with it; nothing changes in a bot.

  • #324 3b41afd Thanks @l7aromeo! - meocord/interface names the options of the stage decorators, GuardOptions, InterceptorOptions, ObserverOptions and ValidateOptions, so a decorator of your own that wraps one can type what it passes on, instead of Parameters<typeof Guard>[0]. It also exports PrimaryEntryPointCommandData, the body an entry point command's builder returns, which could otherwise be written only as CommandBuildResult<CommandType.PRIMARY_ENTRY_POINT>.

  • #354 1eb4fa0 Thanks @l7aromeo! - A presenter can draw its views, such as with a canvas library, and attach what it draws:

    • ResponseView.files carries files, an AttachmentBuilder or { name, data }, which MeoCord sends with the view and shows for you. In an embed, the first image is the embed's image. In a Components V2 container, images go in galleries of up to 10 below the text, and other files as file components. A file the view's own components show by attachment://<name> is not shown again.
    • ResponseView.image and thumbnail name one of the files, or a URL, to use as the embed's image or thumbnail, or the container's leading image and the text's thumbnail.
    • loading() and error() may return a promise. A slow drawing never misses Discord's three seconds. The loading view is drawn after @Defer acknowledges the click. An interaction not yet acknowledged is acknowledged privately before a drawn error view, which then replaces the acknowledgement; should that drawing fail, MeoCord's own view answers instead. Discord refusing the acknowledgement, as for an interaction past its three seconds, is logged once as the refused send, never as the presenter failing, and an interaction Discord no longer knows is not answered again.
    • A view past Discord's limits is sent without its files, with a warning: more than 10 attachments on the message, counting those it keeps, or a file over the interaction's attachment size limit, 20 MiB without one. A send Discord refuses as too large, as one with a file whose size could not be checked first, is sent again without its files. The user still gets the answer.
    • A presenter's optional messageError() draws a message command's error replies: the usage reply, a guard's or validation's reason, a UserError's message, and the DMs dmOnError and dmOnCooldown send, as embeds with its files. It receives a MessageResponseContext, with the message in place of an interaction. A presenter without it, such as the one a new app is generated with, leaves those replies plain text, exactly as before. Should messageError() throw, reject or return a view an embed cannot hold, the reply or DM is sent as that plain text, and the failure is logged as the call's fault, so a testing module's dispatch rejects with it.

    Edits that add a view's files keep the message's own attachments, and a drawn loading view's files leave when the lock does.

  • #377 f543e78 Thanks @l7aromeo! - ShardContext.call types each result as it arrives: through JSON, as every mode passes it. meocord/core exports Jsonified<T>, the type a value has after that trip: a Date is a string, a Map or a Set is {}, a class with toJSON() is what it returns, and a function or undefined property is left out. A method returning Promise<Date> now gives ShardCallResult<string>[], where it said Date and gave a string. Code that read the value as the method's own type changes to the JSON form.

    The arguments take the same trip, so call() refuses a method whose params JSON would change, such as one taking a Date: the compile error says the argument arrives as JSON and names the type to declare (string for a Date). An undefined argument now arrives as undefined, where it arrived as null. ShardContext is new in 4.1, so only beta users see these changes.

  • #350 1f39632 Thanks @l7aromeo! - MeoCordTestingModule.create() and fromApp() take shutdownTimeout, which mirrors the option of that name in meocord.config.ts: from 0 to 2147478647 ms, refused otherwise, with the message meocord.config.ts gets for it. close() runs the bot's own shutdown sequence, so before the cooldown store shuts down, the calls invoke, dispatch and emit have under way finish. It waits up to shutdownTimeout, 10 seconds unless set, for the calls, the store's operations and the onShutdown hooks. Then it stops waiting and logs that it did, as the bot does, and still rejects with any hook that failed before then. A test whose fake store never answers, or whose onShutdown never settles, sets it short:

    TypeScript
    const module = MeoCordTestingModule.create({
      app: App,
      providers: [{ provide: CooldownStore, useValue: silent }],
      shutdownTimeout: 50,
    }).compile()
  • #325 c5ca2b9 Thanks @l7aromeo! - createTranslator and defineCatalog read as createTranslator(options: TranslatorOptions<Locales, Default>) and defineCatalog(catalog: CatalogDefinition<T>) in your editor and on the API pages, instead of spelling out the compiler checks they make. TranslatorOptions and CatalogDefinition are exported from meocord/common, each documented with what it refuses. The checks are unchanged. Nothing to change in your code.

Patch Changes

  • Breaking

    #297 94b573c Thanks @l7aromeo! - A bot without activities keeps the presence it sets. 4.0 rotated through activities every 10 seconds whether or not any were set, and with none it cleared the bot's activity each time, so a status set in onReady, in clientOptions.presence or by a command disappeared 10 seconds after ready, and an empty presence update was sent every 10 seconds. Now MeoCord touches the presence only when activities lists some, and shows one as soon as the bot is ready rather than 10 seconds later. With activities set, they cycle in order, as documented: the first once the bot is ready, then the next every 10 seconds, starting again after the last. 4.0 picked one at random each time. A timer that set the status again to work around it can go. See the upgrade guide.

  • #329 94c8b2c Thanks @l7aromeo! - An asset import resolves beside the bundle, wherever dist is run from. import logo from './logo.png' gave the absolute path of the folder the build ran in. So a dist built in CI, on a laptop and then copied to a server, or in an image stage with another WORKDIR, read its assets from a path that wasn't there, and failed with ENOENT at the first attachment. The path is now set when the bot starts, from the bundle's own location. It's still an absolute path on disk, now the right one. Rebuild to pick this up.

  • #314 4c0a815 Thanks @l7aromeo! - bundleDependencies packs every package a bot needs into dist/node_modules, each with the dependency versions it was installed with, from a pnpm project as from npm, yarn and bun.

    • A package listed in externals or optionalExternals was packed without the packages it depends on, in a pnpm project. pnpm keeps those beside the package in its store, not in your project's node_modules, so they were left out, and the bot failed at startup with "Cannot find module". They're packed now.
    • A native package that ships its binary as a per-platform package, as napi-rs packages do, is recognised as native in a pnpm project and packed with that binary, whether you list it in externals or the bot imports it. Before, a listed one was packed without its binary, and an imported one stopped the build, asking for it in externals.
    • Each packed package loads the version of each dependency it was installed with. When packages need different versions of one package, the first stays at the top of dist/node_modules, and each other version is nested where Node, resolving from the package that needs it, finds it first. Before, every package got the first.
    • A package npm nested under another, because another version of it holds the top of node_modules, is packed with the packages it needs from the top. Before, they were left out, and the bot failed at startup with "Cannot find module".

    Rebuild to pick these up.

  • #316 d2284d9 Thanks @l7aromeo! - respond() could see a locked message's content as changed when it wasn't. It compares the message with what MeoCord last wrote by sorting keys with the runtime's collation, which ranks two different keys equal when they differ only in Unicode normalization, such as café written with é and with e and a combining accent. Their order then followed the input, so equal content could compare unequal. Keys are now sorted in code-unit order, which needs no locale. This also no longer loads the runtime's ICU data on the first lock or restore. Nothing to change in your code.

  • #395 d5d07cb Thanks @l7aromeo! - CLI and build fixes:

    • Every imported file lands in dist/assets under its own name. A pdf, txt, webmanifest or wasm import was written to dist/static/assets with a content hash, unlike images, fonts and media. Its import gives that path as before, so nothing in your code changes; rebuild to pick it up. Two imported files of one name in different folders stop the build with Rspack's conflict error, naming the file.
    • meocord start --dev runs the bot on the development .env files whatever NODE_ENV your shell holds, as its development build's config reads them: it watches them, and starts the bot with NODE_ENV=development. With NODE_ENV=production in the shell, it watched the production files, and a bot on Bun read the production values.
    • A production bot on Bun warns about another mode's .env values for any NODE_ENV but production. Bun reads the development files for every NODE_ENV except production and test, so a bot started with NODE_ENV=staging ran on development values without the warning an unset NODE_ENV gets.
    • meocord create warns on Node 22.0 to 22.12, below the >=22.13 the package requires. It compared the major version alone.
    • The CLI finds an installed package in the filesystem root's node_modules, as with a project directly under / in a container.
  • #319 218b6c5 Thanks @l7aromeo! - The CLI clears the screen only where it helps: as meocord start --dev begins, in a terminal. meocord build and meocord start --prod no longer clear it, so the output of the commands before them, such as a failing test run, stays on screen. No command writes escape codes into piped output any more, such as CI logs, docker logs, pm2 or systemd. When start --dev clears, it keeps your scrollback.

    A build's output no longer lists the temporary folder the config is compiled in. The line after it still says where the config went.

  • #391 f8dcc1a Thanks @l7aromeo! - Corrections to comments that ship with the package and in a new app; no behaviour changes.

    • The meocord/eslint example ignores generated code, in place of coverage, which the config already ignores.
    • In a new app, the comments in vitest.config.ts give the reasons that apply to the versions it installs, src/types/theme.d.ts explains the theme augmentation in three lines, and the sample message controller's TODO reads correctly. The sample slash, modal and guard spec titles say only what their tests check.
    • meocord generate: the guard template lists 'autocomplete' among the handler kinds it can limit itself to, the interceptor template says interceptors skip autocomplete, and the filter template says response is undefined for autocomplete too.
  • #364 23514e7 Thanks @l7aromeo! - The CLI reads the config the bot it runs reads. meocord start --dev uses the shutdownTimeout and sourceMappedStacks of the config it last compiled, so an edit to meocord.config.ts applies from the next restart. Before, it kept the ones from whatever dist held when the session began, and waited a shorter shutdownTimeout than the bot's own, killing it before its onShutdown hooks finished. The token check follows the same rule. start and register check the source config when they build first. Otherwise they check the compiled config the bundle runs, so meocord register without --build now stops at a compiled config that fails to load, as start --prod does.

  • #394 bfb3e07 Thanks @l7aromeo! - The editor documentation of meocord/common and meocord/interface matches what the code does:

    • respond()'s state. message is the last reply, update or edit, never a follow-up. lock() with disable: 'none' leaves the message alone. error() puts a locked message back as it was before it follows up.
    • Errors. GuardDeniedError, ValidationError, CooldownError and CooldownStoreError say how a message command is answered. A message command gets a reply in the channel for a denial or invalid input. For a cooldown or a store outage it gets nothing, or a direct message under messages.dmOnCooldown or messages.dmOnError. CooldownStoreError also says that the recovery is logged once the store has answered for 30 seconds without failing. The constructors and helpers have their @param and @returns.
    • ExecutionContext. getController() is the class the handler runs on, the subclass for an inherited one. getParams() covers pipes too.
    • Cooldown stores. MemoryCooldownStore, RedisCooldownStore.using and the Redis constructor document all their parameters, hashTag included. On Redis Cluster, a refused call counts against no key unless giving a use back fails.
    • route().build() also throws a TypeError for a value that is not of its param's type.
    • Localisation.
      • createTranslator documents options and what it returns.
      • defineCatalog says which catalogs need it.
      • LocaleCatalog says where a locale's {params} are checked.
      • A plural needs its other form to be read as one.
    • createToken names what its type checks: TestingModule.get and a provider's useValue or useFactory.
    • Logger. A string prints in its tag's colour. Each method documents args.
    • App options.
      • caseSensitive covers choice words and flag names.
      • deleteUsageRepliesAfter covers a guard's or validation's reason.
      • replyEmoji notes that help begins with the info emoji.
      • help and MessageHelp say that !help leaves out hidden and guarded commands.
      • scope: 'dm' with a server-only param is refused.
    • OnShutdown runs on app.stop() too.
    • Examples. The examples for a param type's and a theme's declare module import what they use.
  • #302 e5724e8 Thanks @l7aromeo! - A component handler's params are checked against what a call gets, when the code compiles:

    • A select menu's choices have their real types: values: string[], users: User[], members, roles and channels as discord.js resolves them. A declaration no choice can have, such as values: number or users: string, fails to compile, where it used to compile and then fail on the first selection. A narrower type that a choice can hold, such as members: GuildMember[] or readonly Role[], still compiles.
    • A plain-string customId pattern's keys are checked as a route()'s are. With @Command('stats/{id}', CommandType.BUTTON), a handler declaring { uid } fails to compile, naming uid, since that param was always undefined. Fix the name to one the pattern captures.
    • A key a pipe produces is declared Piped<T>, as with @Validate, and the check leaves it to the pipe. That covers a choice @UsePipe turns into something else, a typed customId param such as {id:int} piped into an object, and a @MessageHandler pattern's param piped the same way, which no declaration could satisfy before. The check reads only the handler's own type, so it cannot see the pipe. A 4.1 beta handler that writes @UsePipe('values', ToQuantities) with { values: number[] } now fails to compile, with a message ending "a key a pipe produces is marked Piped"; write { values: Piped<number[]> } instead.
  • #317 eb6698e Thanks @l7aromeo! - A built bot finds its config beside its bundle, wherever it's started from. Before, it looked in dist under the working directory, so a bot started from elsewhere failed with "MeoCord config not found … Run meocord build", even with a fresh build. That covered pm2 without cwd, a systemd unit without WorkingDirectory, and cd dist && node main.js.

    When the config really is missing, the message names the file it looked for and the working directory. When the file is there but fails to load, the message says so, gives the reason and says what to do: install the package it names when one is missing, naming the installed package that imports it if the config does not import it itself, or fix meocord.config.ts, then run meocord build.

    .env is still read from the working directory, through dotenv in your meocord.config.ts. If you start the bot from elsewhere, set its environment there or point dotenv at the file.

    The check that refuses a build made for another platform also reads its record beside the bundle. It now applies when a process manager's wrapper starts the bot.

    A development build (meocord build --dev) does the same, also when a process manager such as pm2 starts it through its own wrapper: its config, its asset imports and the script its shards start from are all found beside its bundle.

    Rebuild to pick these up.

  • #363 b38234c Thanks @l7aromeo! - A compiled config that fails to load is reported once, with its reason and what to do: "MeoCord config at … failed to load: . Fix meocord.config.ts, then run meocord build.", or, when the config needs a package that isn't installed, one that says to install it. A built bot printed a separate "[MeoCord] Failed to load …" line before that one, and meocord start --prod printed it a third time. meocord start --prod without --build now stops with that message, rather than check meocord.config.ts in its place, which could stop on a missing token without naming the broken config the bot would run.

  • Breaking

    #313 0060a3c Thanks @l7aromeo! - Three mistakes with @Command and @MessageHandler are now named where they happen:

    • A command builder whose constructor throws, such as one reading a translator or the environment in a field, is refused as the class loads, like a builder whose build() throws: Stats.stats: StatsBuilder could not be made for "stats": missing translator. It used to surface as the bare error at import, naming neither the builder nor the command.
    • A @Command handler called with another kind of interaction, as a direct call in a test can be, throws Cards.card: @Command('card/{id}', CommandType.BUTTON) takes a ButtonInteraction, not a ChatInputCommandInteraction., or …; it was given undefined. for something that is no interaction at all, instead of Invalid interaction type passed to @Command for method: card.
    • @MessageHandler('') still runs for every message, as @MessageHandler() does, and now logs a warning naming the handler: @MessageHandler('') on Chat.every is deprecated; in the next major version (5.0) it is refused. Use @MessageHandler() instead. See the upgrade guide.

    A builder error that ends in a full stop no longer gets a second one in its refusal.

  • #344 7b68f81 Thanks @l7aromeo! - @Cooldown's seconds is now counted in whole milliseconds, and a window no store can count is refused where the decorator applies.

    • A seconds value whose milliseconds weren't a whole number, such as 16.1, made RedisCooldownStore fail every call to that handler with "ERR value is not an integer or out of range", while the memory store used in development and tests counted it fine. seconds is now rounded to the millisecond, once, so every store gets a whole windowMs of at least 1.
    • seconds must be from 0.001 to 4320000000000. Infinity and other values beyond that were accepted, and each store did something different with them: the memory store refused with "try again in Infinitym NaNs", Redis failed every call, and process sharding never limited anything.
    • @MeoCord({ cooldownStoreTimeoutMs }) must be at most 2147483647, the longest delay a timer keeps. A longer one fired at once, so every call with a cooldown timed out.
    • testCooldownStore checks a store that keeps the default consumeMany or peekMany against what those defaults do, where its batch and peek cases passed without checking anything. A store whose consume lets two concurrent calls take the last use now fails the concurrent batch case as well.
    • The cooldownStoreTimeoutMs refusal shows Infinity and NaN as they are, rather than as null.
  • #348 63f9514 Thanks @l7aromeo! - A cooldown's count is kept under a key named for its window, not its position among the handler's cooldowns. A release that added, removed or reordered a @Cooldown moved the counts a persistent store such as Redis kept to other cooldowns: adding a short cooldown above a daily one reset the daily for everyone, and a short one could inherit a long one's history. Now a deploy that adds, removes or reorders cooldowns leaves the others' counts where they are. Changing a cooldown's uses keeps the calls counted so far, held to the new number; changing its seconds starts its count again. A handler's cooldowns with the same seconds, per, by and bypass count the same calls, so they share one count, held to the smallest uses. The exception is two cooldowns over the same seconds and per, both with by or both without, whose by or bypass functions differ (two inline functions differ even when written alike): uses tells them apart, so for those, changing uses, or adding or removing another such cooldown, starts their counts again, and reordering two with the same uses swaps their counts. A call that by returns undefined for is counted apart from a cooldown with no by over the same window. For a bot upgrading from an earlier 4.1 beta, counts kept under the old keys are not carried over, so every cooldown's count starts afresh once.

  • #345 a3358e4 Thanks @l7aromeo! - messages.dmOnCooldown now DMs an author once per wait, as documented, however often they retry within it. The notice was counted over the wait left at each refusal, which shrinks with every retry, so it expired halfway through: an author retrying every second during a 60-second wait got six DMs (at 1, 31, 46, 53, 57 and 59 seconds), and about twelve in an hour-long one. Each wait now has a notice of its own, kept for the cooldown's full window. A wait is told apart by when it ends on the store's own clock, so a retry whose answer comes back late, or from another shard sharing the store, finds the notice already taken.

  • #346 3f9d6f7 Thanks @l7aromeo! - A cooldown store that fails some calls and answers others, as a Redis Cluster with one node down does, is now logged as one outage. Any answer ended the outage and the next failure began a new one, so each failing call logged an error with its stack and then a recovery line. An outage now ends when the store answers 30 seconds or more after its last failure, and the recovery line counts every call that failed in it.

  • #350 b36cba3 Thanks @l7aromeo! - A class you bind with @MeoCord({ cooldownStore }) now gets its onReady and onShutdown hooks, as a service does. Before, neither ran, so a store that opens a connection in onReady and closes it in onShutdown never connected and leaked its connection on every shutdown.

    The order suits a store that connects:

    • Its onReady runs before the services'. A call that comes while it runs waits for it, within cooldownStoreTimeoutMs. One that would wait longer meets your cooldownStoreFailure policy, as a store that doesn't answer does.
    • Its onShutdown runs after the services'. The bot first stops taking new calls and lets the ones under way finish, along with every store operation they started, even an answer that came after its call stopped waiting. So the store closes after the last write it is asked for.
    • What the store injects, such as the queries it runs, is ready before it and shuts down after it, so it is there for the store's last write.
    • A store with no onShutdown, and nothing it injects with one, doesn't hold shutdown up: the bot waits for the calls under way only when a hook needs the store.

    MeoCordTestingModule runs the store's hooks in the same order, in init({ ready: true }) and close(), for the app's store or the CooldownStore a test provides in its place.

  • #393 1403136 Thanks @l7aromeo! - Corrected the editor documentation of several public APIs to match what they do:

    • HandlerRegistry: list() gives the handlers in the order the app makes its classes, not the order they were bound. messageHelp() lists by where the message was sent, not by its author, and an entry's hidden leaves a command out of help's lists while !help <command> still shows it.
    • useTheme(): outside a call it reads the app's theme from when its start begins until it has shut down. The chain of @UseTheme stops where inheritStages: false does, and only the theme's plain objects and arrays are frozen.
    • MeoCordFactory.create: with process sharding, the manager logs an error a shard throws.
    • ShardContext: runHere takes a service's class in a bot of one process, and its name with process sharding.
  • #311 bcda23d Thanks @l7aromeo! - meocord create writes any app name into meocord.config.ts as a valid string. A name with an apostrophe, such as "Bob's Bot", or one ending in a backslash made a config that failed to parse, so the new app never built. A backslash elsewhere changed the name silently: Back\slash was read as Backslash. The name is now quoted and escaped the way the app's own Prettier config writes it, so the file also passes the app's lint unchanged. An app created with such a name before this needs its appName fixed by hand.

  • #306 d04a276 Thanks @l7aromeo! - meocord create keeps the app when git can't make its first commit. Before, a git failure deleted the app it had just written and exited with an error. That happened on a machine where git has no user.email (many fresh Linux machines, containers and CI runners) and on one without git. Now create finishes, says what happened, and tells you how to finish the commit. Inside an existing Git repository it makes no new one and leaves the files to that repository.

    The first commit now comes after the install, so your lockfile (bun.lock, package-lock.json, yarn.lock or pnpm-lock.yaml) is part of it, rather than showing as an untracked file in a new app's first git status.

  • #389 6ac2e26 Thanks @l7aromeo! - meocord create --use-npm makes an app that npm 11.16 and later install without the allow-scripts warning. Those versions list every dependency install script your package.json neither allows nor denies, and a new app listed @swc/core, unrs-resolver and, on macOS, fsevents. An app created for npm now denies all three under allowScripts:

    • @swc/core and unrs-resolver load the native binding npm installs for your platform. Their scripts check that binding and, only where it fails to load, fetch a fallback: @swc/core's installs @swc/wasm, which @swc/core itself does not load, and unrs-resolver's downloads the binding npm installs anyway.
    • fsevents ships its binary prebuilt. Its script rebuilds it from source, which fails because the package has no build files, so npm left the optional fsevents out. Denied, it is installed with its prebuilt binary.

    npm before 11.16 ignores the field. An app created with pnpm, yarn or bun gets no allowScripts.

    An app created for npm before this gets the same by adding to its package.json:

    JSON
    "allowScripts": {
      "@swc/core": false,
      "fsevents": false,
      "unrs-resolver": false
    }
  • #375 8c212ae Thanks @l7aromeo! - meocord create --use-pnpm makes an app that installs and passes its own checks on pnpm, 10 and later.

    • pnpm 11 and later refuse to install while a dependency's build script is neither allowed nor denied, so create stopped at "Failed to install dependencies" with ERR_PNPM_IGNORED_BUILDS for @swc/core and unrs-resolver. An app created for pnpm now has a pnpm-workspace.yaml that leaves both scripts off under allowBuilds: each only checks the native binding pnpm installs for your platform, and fetches a fallback without it. The same file lets pnpm install the meocord that created the app, which pnpm 11 and later would otherwise hold back for a day after its release (minimumReleaseAgeExclude). An app created with npm, yarn or bun gets no such file.
    • The app declares reflect-metadata and @types/node, which its test setup imports and its tsconfig names. npm and bun hoist them from other packages, but pnpm links only what package.json declares, so on pnpm 10 the app's lint and test failed.

    An app created for pnpm before this gets the same fix by adding both packages to its devDependencies, and on pnpm 11 or later this pnpm-workspace.yaml:

    YAML
    allowBuilds:
      '@swc/core': false
      unrs-resolver: false
    minimumReleaseAgeExclude:
      - meocord
  • #397 e1168b9 Thanks @l7aromeo! - @Defer() written below @MessageHandler, @ReactionHandler, @On or @Autocomplete is refused in one line at create(), as it is when written above one, rather than printed with a stack trace.

  • #343 87dbef1 Thanks @l7aromeo! - meocord start --dev restarts the bot through the bot's own stop, the one SIGINT and SIGTERM run, on every platform. On Windows, a restart used to end the bot outright, so its onShutdown hooks never ran on a save, and whatever they release or flush was left as it was. The dev runner now asks the bot to stop over the channel it already gives it, and the bot shuts down as it does on Ctrl+C, before the new build starts. A bot that doesn't exit within its shutdownTimeout and a short grace period is still killed, as before. Rebuild to pick this up.

    When watch mode can't start, it stops a bot it had already started the same way, and exits once that bot has. Before, a bot that held on to its stop signal could outlive the CLI.

    One save no longer restarts the bot twice. An editor's save can produce two builds of the same output, and a bot already running the latest output is now left alone. A change to meocord.config.ts, tsconfig.json or .env still always restarts it.

  • #337 8504f45 Thanks @l7aromeo! - meocord start --dev keeps watching when the bot cannot log in, such as with a wrong token or an intent Discord refuses. It says so, and starts the bot again on the next change, whether to your code or to .env.

    start --dev also watches .env, .env.local, .env.development and .env.development.local: saving one restarts the bot with the values the files now hold, without a rebuild. The bot reads the files itself as it starts, and inherits only what your shell set.

    When watch mode cannot start, for example because an rsbuild hook in meocord.config.ts throws, start --dev exits with code 1, as meocord build does, so a script or process manager around it sees the failure.

    A rebuild that can't start partway through a session, such as when you save a meocord.config.ts whose rsbuild hook or plugin throws, no longer ends start --dev with the bot still running in the background. It says the rebuild failed and why, keeps the bot and its last build running, and tries again when you save the file again.

  • #350 b36cba3 Thanks @l7aromeo! - The startup check now names a @MessageHandler with scope: 'dm' that can never receive a DM. A DM reaches the bot only with the DirectMessages intent, and only with discord.js's Partials.Channel, since no DM channel is cached after the bot starts. The generated app's client options have neither, so a DM-only command added to it never ran, and nothing said why. The warning names the handler and what is missing. A command for servers, or for both, gets no new warning.

  • #372 270d315 Thanks @l7aromeo! - The direct message messages.dmOnError sends for a command in a server now says what went wrong. meocord.dm.error reads {command} in {channel} on {server}: {reason}, where {reason} is what the fallback answers the error with: "An error occurred while executing the command." for a fault, and "Cooldowns can't be checked right now: try again shortly." when the cooldown store is down. It said "Something went wrong running {command} in {channel} on {server}. Try again later." for both, so a store outage read as a broken command. The text has the same shape as meocord.dm.cooldown beside it. A catalog that translates meocord.dm.error adds {reason} to it.

  • #355 d642b0c Thanks @l7aromeo! - A reaction in a DM the bot has not cached since it started reaches its handlers again. From discord.js 14.26.2, discord.js makes a channel it has not cached from a gateway event only when the event says the channel is a DM, and a reaction's event names its channel by id alone, so such reactions were dropped before any listener saw them, even with Partials.Channel. With the DirectMessageReactions intent, MeoCord now fetches that DM channel once, on its first reaction, and hands the reaction back to discord.js, which delivers it to @ReactionHandler and to your own messageReactionAdd and messageReactionRemove listeners alike. Later reactions in that DM need no channel fetch, though the first reaction on a message the bot doesn't hold still fetches that message once, and a reaction discord.js delivers itself is left alone. One window remains: a DM reaction that arrives before the gateway is ready, in the seconds while discord.js waits for the bot's servers, is replayed by discord.js later without a raw event, so it is still dropped.

  • #332 bb22d53 Thanks @l7aromeo! - On Windows, meocord generate in a project installed with npm, yarn or pnpm reported "Failed to create" and exited 1 for every file, though the file was written. Its formatting step spawned node_modules/.bin/eslint.cmd, which Node refuses to start without a shell. The project's ESLint now runs through the same runtime as the CLI. A formatting failure never marks a written file as failed.

  • #357 cfb94c8 Thanks @l7aromeo! - meocord generate formats the files it writes with your project's ESLint in one run, and says so. Before, it started a separate ESLint for each file, all at once. Each built your project's type information, and the command sat silent until the slowest finished: about 2.3 times the CPU for a controller with its spec and builder, enough to stall generate on a busy machine. Now it prints "Formatting with your project's ESLint..." after the files are created and waits for that one run. If ESLint can't run, or reports problems it can't fix, generate says so, and the files stay as written either way.

  • #332 c95beab Thanks @l7aromeo! - meocord generate controller writes a spec that tests the handler. It invokes the handler with a mock of the interaction, message or reaction it handles, and checks what it answers: how it answers, such as a reply or an update of the message, and the text, such as "Hello from /ping!" or, for a mentionable select menu given a user and a role, "Selected 1 user(s) and 1 role(s)." Before, every spec only checked that the controller existed, and passed whatever the handler did. A generated slash, context menu or entry point builder now takes the command's name from @Command (build(commandName)), so the two can't drift apart. Files you've already generated are unchanged.

    The sample specs meocord create writes check the same way. The slash, context menu, button and modal samples check the text they answer. The message and reaction samples send a message or a reaction through module.dispatch(), as the bot routes it, and check the reply, where before they only checked that the controller existed.

  • Breaking

    #400 dcc5087 Thanks @l7aromeo! - MeoCordFactory.create() and the testing module's compile() now warn about each @MessageHandler, @ReactionHandler, @Command or @Autocomplete on a class that is not one of the app's @MeoCord({ controllers }), such as a service or a class one injects. MeoCord dispatches only to controllers, so these handlers never run, and nothing said so. Move them to a controller: the next major version (5.0) refuses to start with them, as the upgrade guide describes. A sharded bot warns once, from its manager. The warnings about missing intents and partials no longer name these handlers, since no intent would make them run. @On and @Once handlers run on any bound class, as before.

  • #300 a4afcf9 Thanks @l7aromeo! - A handler may return a value. @Command, @MessageHandler, @ReactionHandler and @Autocomplete accepted only a method returning nothing, so return interaction.reply(…) or return message.reply(…), as discord.js code often ends a handler, failed to compile with "Unable to resolve signature of method decorator". As with @On, any return type compiles: MeoCord answers nothing with it, and an interceptor receives it from next.handle(). A parameter of the wrong type is still refused.

  • #397 622010c Thanks @l7aromeo! - The built-in help, and the list of subcommands a parent command with no handler of its own answers, now run the app's @MeoCord({ guards }) first, as a command does. A guard that returns false leaves the message unanswered, and one that throws GuardDeniedError gets its reason as the reply, as a denied command does. They answered whatever the app's guards decide, so a bot its guards close in a channel, or to a user, still answered help there.

  • Breaking

    #334 4de3194 Thanks @l7aromeo! - A handler that a subclass re-declares follows one rule for @Command, @MessageHandler, @ReactionHandler and @Autocomplete.

    • On the route it inherits, the subclass's declaration takes that route's place, so the subclass's options apply: a re-declared @Command('ping', LoudPingBuilder), @MessageHandler('roll', { description }) or @ReactionHandler('👍', { bots: true }) uses its own builder, description or settings. In 4.0 the base's applied, since the base's declaration came first. To keep the base's builder or options, don't re-declare that route on the subclass, or declare it with the base's builder. The routes the subclass answers are unchanged.
    • On another route, the subclass still answers the route it inherits too, as in 4.0. For example, a subclass overrides page(), which its base declares as @Command('page/{n}', …), with @Command('shop/page/{n}', …), and answers both. The bot now names each such handler in one warning as it starts, with the routes it inherits and its own. In the next major version (5.0), a handler's own routes replace the ones it inherits. To keep an inherited route, declare it on the subclass's method as well.

    A class between them that declares nothing changes neither. A subclass that declares every route itself, or none, gets no warning. See the upgrade guide.

  • #341 e09926b Thanks @l7aromeo! - An interceptor that calls next.handle() without returning or awaiting it no longer crashes the bot when the handler throws. The handler's error was an unhandled rejection, which ends the process, and the filters, the fallback and the user never saw it, while observers reported the call as 'ran' and @Defer() released the message before the handler finished. Now, when an interceptor returns and leaves what next.handle() returns, or a then or finally chain from it, without a rejection handler, as next.handle().then(log) does, the call ends when the handler does and fails with what it throws: its filters and the fallback answer it as they would any handler error. An interceptor that awaits, returns or catches the promise, or races it against a timeout, behaves as before. A promise handed to something else, such as Promise.all, is still that one's to handle.

    A handler that an interceptor took on, such as by racing it against a timeout, and that throws after the interceptor has returned is now logged as a warning naming the interceptor, the handler and the error, unless a handler of the interceptor's own, in a chain from next.handle(), disposes of it; one that rethrows it, or wraps it in an error of the app's, still leaves it to the warning. The call has ended by then, so nothing else reports it.

  • #328 dffb24d Thanks @l7aromeo! - The editor documentation of three APIs says more about what they do:

    • TestingModule.invoke says that a guard's GuardDeniedError, a UserError and a CooldownError reject it, where dispatch resolves { ran, error }, and its example shows both.
    • useTheme names themeFor's layers: the server's theme, then the user's, over the handler's @UseTheme.
    • createMock says that a property its type declares as data is a mock function, so truthy, and shows passing the values the code reads.
  • Breaking

    #282 1f8fbf4 Thanks @l7aromeo! - Logger prints as console.log does. It redacts the bot's credentials from everything it prints, as 4.0.1 does.

    • Colour follows each line's stream. Warnings and errors go to stderr and the other levels to stdout, but colour followed stdout alone, so node dist/main.js 2>>errors.log from a terminal wrote colour codes into the file. Each line now takes colour only where its own stream is a terminal, or FORCE_COLOR asks for it.
    • An object is coloured only where the rest of its line is. An object or other non-string argument was always printed with colour codes, even into a file or a log collector, where they appear as raw escape sequences. It now prints in colour on a terminal, and plain where the output is not one. Set FORCE_COLOR=1 to keep colour where your log viewer shows it.
    • Objects print as console.log prints them. A small object such as { id, name } takes one line, where 4.0 printed one property per line. Objects print four levels deep, and without their non-enumerable properties, as 4.0.1 does, where 4.0.0 and the earlier 4.1 betas printed every level and those properties: nested data a bot logs, such as a payload or its settings, still shows in full, and a discord.js structure prints a few hundred lines instead of everything it reaches. An error prints its stack, its own properties such as code, its cause, and an AggregateError's errors, with its message and stack printed once, as in 4.0.1.
    • A value of any type prints, and none makes Logger throw. A Symbol threw Cannot convert a Symbol value to a string, so a handler or an observer that threw a Symbol made MeoCord's own log of it throw too, and from an observer that became an unhandled rejection. Anything other than a string now prints as console.log prints it: Symbol(boom), 10n, [Function: handler]. A number or a boolean takes console.log's colour rather than the level's.
    • logger.info() and logger.verbose() lines are tagged [INFO] and [VERBOSE]. 4.0 tagged them [LOG]. They still print at the log level, so a filter or parser that matches [LOG] needs to match [INFO] and [VERBOSE] too to keep catching them. See the upgrade guide.
  • #322 1ceb803 Thanks @l7aromeo! - A @MessageHandler(pattern) handler whose params don't fit its pattern is now explained in three lines rather than twenty-four. The decorator had one form for each way a handler can be declared, so TypeScript explained the mismatch against every form, and the reason, such as a param the pattern lacks, came last. It now has one form, and the second line names the params that don't fit and what the pattern gives each: "The handler's params do not fit the pattern": { side: { readonly 'not a param of the pattern': "side" } }. What compiles is unchanged.

  • #304 31f0f02 Thanks @l7aromeo! - Two message handlers that can take the same message stop the bot at startup, wherever their starts are known as it starts. One with its own prefix: '!' and one using the app's '!', own prefixes that share one ('!' and ['!', '?']), prefix: false beside an app with no prefix, or two a mention starts in a server were all taken as different starts, so the order of your controllers decided which ran. The refusal names both handlers and their patterns; give one another prefix or pattern. An app whose prefix is a function gives its prefixes only as each message arrives, so a handler using it is refused only beside another that uses it too. Beside one with its own prefix or prefix: false, which the function can also give, the handler with its own start runs, whatever the order of your controllers.

    A prefix function that finds no prefix for a message, returning an empty list, undefined or null, now lets no prefix start a command for it; a mention still does when mention is on. It took the message as it is, so in a server with no prefix of its own, plain chat starting with a command's word ran that command. Return '' to take a message as it is. The function's type takes undefined and null too, so a lookup such as return prefixes.get(id) needs no cast.

  • #347 928aa57 Thanks @l7aromeo! - With messages.dmOnError on, a message command refused because the cooldown store is down now DMs its author, once per outage, the meocord.dm.error message, or meocord.cooldown.storeDown for a command sent in a DM. Such a command got no answer at all, so while the store was down every !command with a cooldown looked like a dead bot. Without dmOnError it is still skipped silently, as before.

  • #370 0ff8eba Thanks @l7aromeo! - meocord/testing's mocks take where they were made, and what a user option carries, as discord.js reads them:

    • A channel given to createMockInteraction or createMockMessage sets the channelId, guildId and guild the test leaves out. A DM channel is no server, so inGuild() is false, and a server's channel puts the mock in its server. An interaction given a channel kept a channelId of its own, so a per-channel cooldown counted each one apart, and a message given a DM channel said it was in a server. A server's channel that names no server is put in the mock's, and a channel in another server than the one the test gives, or a DM's where it gives a server, is refused with both named. The channel is cached on the mock's client, as the gateway caches it.
    • A mock channel's managers have it as their channel, and a thread's members as their thread, as discord.js's do.
    • A user option from createChatInputOptions carries its user, and in a server its member, as the gateway sends them. A member given carried only member, as in 4.0, so a handler's param typed User got the GuildMember.
    • A select menu given its users, members, roles or channels has their ids as values, as Discord sends them. They stayed empty unless given as well.
  • #333 60e0290 Thanks @l7aromeo! - A mock select menu from createMockInteraction has picked nothing unless the test gives its choices, as discord.js builds one: values is an empty array, and users and members, roles or channels are empty Collections, the ones its kind picks. They were stubs, so a handler's interaction.users.map(...) or interaction.values.length threw or read a function on a default mock. Values and collections a test gives are kept.

  • #374 78d444e Thanks @l7aromeo! - A modal Discord refuses as already acknowledged (40060) now leaves the interaction answered, as a refused reply or update does, so the next respond().send() edits the answer that was made elsewhere instead of failing the same way. modal() still rejects with the refusal.

  • #397 13d8521 Thanks @l7aromeo! - A modal's file upload field reaches the handler's params as an array of the uploaded Attachments, the ones interaction.fields.getUploadedFiles() gives. It gave the attachments' ids. For a bot upgrading from an earlier 4.1 beta, a handler that read ids from the field takes them from the attachments: files.map(file => file.id).

    createModalFields takes an array of Attachments for a file upload field, so a test can submit one: createModalFields({ screenshot: [attachment] }).

  • #358 8773cf8 Thanks @l7aromeo! - A presenter that fails to draw a view no longer leaves the user without an answer:

    • An error view whose error() throws or rejects, or returns a view MeoCord cannot render, such as a colour that is no colour or an empty text, is answered with MeoCord's own error view. The failure is still logged as the call's fault, and a testing module's dispatch still rejects with it. An asynchronous error() was the only one answered this way before.
    • A loading view whose loading() fails the same way, or takes longer than a second to draw, is replaced by MeoCord's own loading view, with a warning naming the presenter, so the click is still locked and the handler still runs. A drawing that comes later is left unused.
  • #342 d9a03d0 Thanks @l7aromeo! - Three fixes to how @MeoCord({ providers }) and a testing module wire classes and providers:

    • A provider that would receive ExecutionContext is refused as the app is created: a factory provider whose inject lists it, or a useClass provider whose class injects it. The message names where it is declared and its token, such as App: @MeoCord({ providers }): the provider for 'audit' uses Audit, which injects ExecutionContext, …. Such a provider is made once, so the context it received was an empty one, bound for the whole app, and every later call shared it.
    • A class whose own source contains the text [native code], such as one that inspects functions, is injected like any other class of the app. It was taken for a built-in constructor and never bound, so the app stopped at startup with No bindings found for service.
    • Providers or classes that inject each other in a cycle are refused as the app is created, naming the cycle, such as 'a' → 'b' → 'a'. The app failed when the first of them was made, with Circular dependency found: (No dependency trace).
  • #353 bbe1ef2 Thanks @l7aromeo! - A reaction no longer re-fetches its message from Discord's API when the bot already holds it whole. Every reaction add and remove cost one request, queued behind Discord's rate limits, so a reaction-role or poll message delayed every handler under a burst of reactions. Now reaction.message is the copy the gateway keeps current, fetched first only when the bot holds the message by its id alone, and a reaction that arrives without its count (with Partials.Reaction) is fetched once, so reaction.count is no longer null.

    This changes 4.0's behaviour, which fetched the message for every reaction. The cached copy differs from a fresh fetch only rarely: after a reconnect that could not resume and so missed an edit, or for a poll's counts when the bot lacks the GuildMessagePolls intent. A handler that needs the message straight from Discord calls await reaction.message.fetch() itself.

  • #397 fe021d7 Thanks @l7aromeo! - HandlerRegistry.messageHelp() reads the message commands from the table dispatch routes with, so a help command of the app's own lists exactly what the built-in help lists. It also read @MessageHandlers on services, which no message reaches, and listed or described them as commands.

  • #288 6e62061 Thanks @l7aromeo! - respond() makes the answers of one interaction one after another, and keeps its state right when Discord refuses one.

    • Answers asked for together, such as two send() calls at once or the fallback's error while a send() is in flight, run in the order they were made: the first replies and the next edits or follows up, instead of both replying.
    • An acknowledgement that fails leaves the interaction unanswered, so the next send() replies instead of throwing that failure again.
    • A reply or an update Discord refuses as already acknowledged still throws, and the next send() edits. The error answer MeoCord follows up with after such a refusal now reaches Discord.
    • send(), edit() and delete() after modal() throw an error saying to answer the modal's submit, since a modal has no message. modal() also throws while another answer is in flight.
    • In meocord/testing, a mock command that showed a modal rejects editReply(), fetchReply() and deleteReply() with Unknown Message (10008).
  • Breaking

    #335 e524937 Thanks @l7aromeo! - Routing fixes for handlers that compete for the same interaction:

    • Two @Autocomplete handlers for one option are named at startup. Two handlers that complete the same option of a command, or every option of one path, used to start silently, and the first controller listed always won. The warning about command handlers that never run now names the one that never does, and the one that runs instead. Handlers of an option Discord never asks to complete are named for that alone. The bot still starts. In the next major version (5.0), it refuses to start. See the upgrade guide.
    • The warning about overlapping component patterns names the handler that runs. For each pair of patterns that can match the same customId, such as a/{x}/c and a/b/{y}, it now says which handler runs for the ids both match, and why: the more specific pattern, or between equally specific ones, the one whose controller is listed (or handler declared) first, as in 4.0. Where the next major version (5.0) runs the other one instead, preferring the pattern that spells out the first segment where the two differ, the warning says so, and what to do so the bot does the same before and after: list that handler's controller (or declare that handler) first, or make the patterns distinct. See the upgrade guide.
    • The overlap warning is given once, as the bot starts. It is a startup check like the others: meocord start, meocord register and the shard manager give it once, where each process-sharded shard gave it again, and MeoCordTestingModule.compile() gives it without anything being dispatched. Two handlers whose patterns match exactly the same customIds are refused there too: by the shard manager before it spawns a shard, and by MeoCordTestingModule.compile(). A test that expected invoke() to reject such a module now sees compile() throw the same error.
    • @Command's documentation now says that only patterns matching exactly the same ids stop the bot, that overlapping ones are warned about, and how the next major version (5.0) breaks a tie. @Autocomplete's names the two startup warnings about its handlers.
  • #396 5ae9cc1 Thanks @l7aromeo! - Fixes at the edges of shutdown, sharding, themes and the handler registry:

    • With sharding.development, a Ctrl+C while meocord start --dev restarts the bot joins that stop, as it does in one process; it no longer kills every shard and exits 1.
    • The shard manager's stop() sets process.exitCode to 1 when it has to kill a shard, unless another code is set, as a bot in one process does when its client fails to close.
    • A shard's stop(), and its report of a failed start, wait at most a second for a manager that is gone, where Bun would otherwise wait for ever.
    • An onShutdown hook does not run for a class whose onReady was still running when shutdown began, even when it finishes while the calls under way are waited for, as the OnShutdown docs say.
    • useTheme() outside a call reads the app's theme until the app has shut down, so onShutdown hooks and the calls shutdown waits for read it too.
    • A themeFor lookup that ThemeCache forgot while it was in flight logs nothing when it fails.
    • HandlerRegistry gives an entry point command, whose builder returns a REST body, its command and description. A handler without a builder of its own gets the JSON of its own kind of command, by type and name as Discord tells commands apart, and a context menu's by its whole name. A message command's scope is narrowed to 'guild' by a member, role or channel param or flag, as help shows it.
    • A context menu whose name has a space and whose builder cannot be serialised is no longer reported as one no builder registers.
    • A localization under a key that is not a Discord locale is reported once, as an unknown locale, and its value is not checked.
  • #284 204f93f Thanks @l7aromeo! - Message command params, flags and customId segments look up param types and booleans in tables with no inherited keys, so a customId param named like an inherited key, such as {__proto__:int}, keeps its type. A word such as constructor or toString is now an invalid bool value, answered with the usage like any other wrong word. A {name:type} whose type is such a name is refused at startup as naming no type, whether or not the app adds its own messages.types. An app type is matched only by a name the app gave it.

    route().build() reads a param's value and type by the param's own name, so a param named constructor or toString builds from the value given, and asks for one when none is.

  • #327 47069bc Thanks @l7aromeo! - ShardContext.call fixes:

    • Identical concurrent calls run once each. With process sharding, two identical calls made at once, such as two users triggering the same announcement, used to run once in each shard and share one answer, and a later identical call could get an earlier call's answer. Each call now runs in every shard and gets its own answer.
    • One process and tests now pass values as JSON, as process sharding does. In one process, which is how meocord start --dev and the testing module run, the arguments and the result were passed as live objects, while process sharding sends them as JSON. A Date arrived as a Date in development and tests, then as a string in production, and a returned Map arrived as {}. They're now passed through JSON in every mode, so a test sees what production gets.
    • A class a provider stands in for can be called. call(Payments, 'charge') with providers: [{ provide: Payments, useClass: StripePayments }] answered "Payments is not a controller or service of this app." It now runs StripePayments.charge. With process sharding, a call from another shard names the class, so the bot refuses to start when a provided class shares a name with a controller, a service or another provided class, as it already did for two controllers or services.
  • #380 e0631ab Thanks @l7aromeo! - The process-sharding manager checks what it can before it does anything:

    • A build for another platform stops before the manager registers commands or spawns a shard. The check ran only in each shard, so the manager registered the commands and spawned shard 0 before the build was refused.
    • A missing bundle is found before anything is registered. A manager started without a bundle to spawn shards from registered the commands and asked Discord for the shard count before saying it could not find one.
    • start() starts the manager once. A second call, at once or later, registered the commands and spawned every shard again; it now waits for the first.
  • #321 22e3c3e Thanks @l7aromeo! - Process sharding now handles a shard whose start fails:

    • A failed start ends the shard, so its manager restarts it. The failure might be a network error at login, a provider factory that rejects, or Discord being briefly unavailable. Before, the shard set exit code 1 and kept running without logging in, so the manager never restarted it and its servers stayed offline until the whole bot was restarted. The shard now exits 1 once main.ts has handled the rejection, and the manager restarts it with the usual backoff.
    • An app MeoCord refuses stops the bot. Examples are two services with one name, a provider of the wrong shape, or native addons built for another platform. Every shard would refuse it alike, so the shard tells its manager, which logs "Shard N cannot start; stopping every shard." with the reason, and where it is, on lines of its own, stops every shard and exits 1. Under meocord start --dev, the session then reports that the application exited with code 1 and waits for a change, rather than that the bot could not log in. Before, the manager restarted the shard about once a minute, forever, while a process supervisor saw a healthy process.
    • A shard listens to its manager from the start. A stop request, or the manager going away, while the shard's providers are still being made now stops the shard before it logs in. Before, the shard went on to log in with no manager, and a restarted manager then ran a second copy of it.

    Outside process sharding, a failed start() still leaves the process to main.ts.

  • #351 2ce5380 Thanks @l7aromeo! - A shard whose manager is gone no longer crashes on a call with a cooldown. When the manager died, each shard began its graceful shutdown, but ShardedCooldownStore still sent to the closed IPC channel. On Node, that send raised an unhandled 'error' event, so the shard exited 1 at once and skipped its shutdown hooks. On Bun, the message was dropped, and the call waited out cooldownStoreTimeoutMs. Now the store checks the channel before sending and reports a failed delivery to the call, so the call fails with CooldownStoreError at once on both runtimes, and the shutdown carries on. A call sent just before the manager died fails as soon as the channel closes, too, rather than waiting out cooldownStoreTimeoutMs.

  • #369 951d5ac Thanks @l7aromeo! - shutdownTimeout in meocord.config.ts is at most 2147478647 ms. Node fires a timer longer than 2147483647 ms at once, and the shard manager and meocord start --dev wait up to 5 seconds past shutdownTimeout, so a larger value made the bot or those two give up on shutdown at once. The config now refuses such a value as it loads, as it refuses a negative one. A bot started without the CLI, whose config is not checked, waits the default 10 seconds instead and warns, naming the value it was given.

    @MeoCord({ themeForTimeoutMs }) names Infinity or NaN in its refusal, where it said null, and words it as cooldownStoreTimeoutMs does.

  • Breaking

    #315 f12857d Thanks @l7aromeo! - A @Catch given something that is not an error class, such as an undefined from an import cycle, is named in a warning as the filter loads, and now matches no error: Broken: @Catch's first entry, undefined, which matches no error, is deprecated; in the next major version (5.0) it is refused. Use an error class, such as @Catch(CooldownError), instead. It used to throw "Right-hand side of 'instanceof' is not an object" at the first error to reach the filter, which hid the handler's own error and handled the call a second time. The bot still starts; in the next major version (5.0) it is refused. See the upgrade guide.

  • #336 1e442d1 Thanks @l7aromeo! - A base controller's class stages now wrap the classes that extend it, as global stages wrap controllers. Guards and interceptors run the top base's first, then each subclass's, then the method's. Filters are tried the other way: the method's, then the subclass's, then each base's, then the global ones. In the 4.1 betas the subclass's guards ran before the base's, so a guard a subclass added ran even for callers the base's auth guard would refuse. For a handler the subclass declared itself, the base's filters were also tried first, so a catch-all on the base hid the subclass's own, more specific filter. Class cooldowns keep their order, and inspectHandler and the MetadataKey.Guards metadata list the stages in the order they now run.

  • Breaking

    #287 dea37a4 Thanks @l7aromeo! - start() sets the bot up once. Calling it again after a failed login used to attach every event handler a second time, so each command, message and reaction ran twice, onReady ran twice, and an app with a presenter could no longer answer the built-in help. Now a retry logs in again with the handlers it already has. Two calls at once share one start, and a call once the bot is online does nothing.

    After a retry logs in, the client works as a fresh one would: isReady() reports it, shutdown closes the gateway, and configured cache sweepers run again. A failed login makes discord.js destroy its client, so MeoCord undoes that before trying again.

    A retry that logs in now clears the exit code the failed login set to 0, which Bun keeps, rather than to undefined, which Bun ignores. It also gives code that runs outside a handler, such as a scheduled job calling useTheme(), the app's theme again, which the failed login had dropped.

    Retrying start() after a failed login is deprecated; in the next major version (5.0) it rejects. Use MeoCordFactory.create to make a new app instead. It logs that warning once. A retry after a provider's factory failed stays supported, with no warning. See the upgrade guide.

  • #379 d5a2e03 Thanks @l7aromeo! - A class that injects CooldownStore gets the app's store, and nothing else is made of it.

    • The store's hooks run once. A service that injected CooldownStore beside @MeoCord({ cooldownStore }) made the token count as a class of its own, so the store's onReady and onShutdown each ran twice, and the first onShutdown came before the calls under way had finished. The token now stands for the app's store, so the service depends on the store, and the store's hooks run once, after the last call.
    • MeoCordTestingModule binds the app's store first, as the bot does. A module made with fromApp(), or with app, whose app has a cooldownStore, failed to compile with "Ambiguous bindings found for service: CooldownStore" when one of its classes injected the token.
    • A cycle through the token is named. A store that injects a class which injects CooldownStore is refused where it is declared, naming the classes, as any other cycle is, rather than failing with inversify's "Circular dependency" as the bot starts.
  • #330 c62618f Thanks @l7aromeo! - MeoCordTestingModule.create({ app }) counts cooldowns in the app's cooldownStore, as fromApp and the bot do. It counted them in a store of its own in memory, so a test of a cooldown never reached the app's store. A CooldownStore in the module's providers still takes the app's place, and the app's own store is then never built, so what it injects needs no provider. A store of the app's that injects something the test doesn't list needs it in providers, or a CooldownStore provider in its place.

  • #392 421081b Thanks @l7aromeo! - The documentation your editor shows for meocord/testing and meocord/decorator now matches what the code does. Nothing to change in your code.

    • Mocks: createMockClient says only a mock message gets a client of its own; give an interaction its client when the code under test reaches it. createMockGuild says what a manager's fetch(id) makes: a member or channel in the guild, a role with that id, or a ban. createMockUser is for a user, and MockProps names only a property the mock does not let you assign. createMock, createMockGuild and createChatInputOptions document their parameter.
    • Testing module: the invoke, themeCache and overrideThemeFor examples compile, and expectCompleteCatalog's example passes. observers and init() name dispatch beside invoke and emit. inspectHandler's app says the app's filters are tried after the handler's own. resolveRoute's dm says when a handler outside its scope is returned. A testCooldownStore case title claims only what it checks.
    • Decorators: @Cooldown, @Validate and cooldownStoreFailure say how a message command over its limit, with invalid input or with the store down is answered. @Defer describes mode: 'auto' and when a misplaced @Defer is refused. @Service says when to list a class in services. @Controller says which handlers its class stages reach. @Command lists everything it refuses as it applies, and @Autocomplete says a handler's own guards run. @Guard, @Interceptor, @Observer and @Validate document their options.
  • #398 14f6d3c Thanks @l7aromeo! - meocord/testing mocks and resolveRoute behave as the bot does in four more places:

    • A mock interaction has a client. createMockInteraction gives an interaction made without a client one from createMockClient, as a mock message has, with the interaction's user in client.users.cache and its channel in client.channels.cache once read. Code that reaches interaction.client, such as interaction.client.users.cache or interaction.client.user.id, reads real caches and the mock bot's id rather than stubs.
    • getAttachment() returns an attachment option. createChatInputOptions({ file }) with an Attachment makes options.getAttachment('file') return it, and null for an option not given.
    • Resolved media never takes the bot's id. A thumbnail, image or media gallery item an edit resolves gets an id no other mock has, the mock bot's included.
    • resolveRoute(app, { content, dm: true }) returns nothing for a handler that works only in a server, by its scope or a member, role or channel param in its pattern, as dispatch answers such a message with its usage and never runs the handler.
  • #289 1659e78 Thanks @l7aromeo! - A build resolves your paths from compilerOptions.baseUrl when your tsconfig.json sets it, as TypeScript does. Before, the build read every paths target from the project root, so an alias such as "@lib/*": ["lib/*"] with "baseUrl": "./src" typechecked but failed to resolve in meocord build and meocord start --dev. A baseUrl your tsconfig.json only inherits through extends isn't applied to the paths it sets itself, so declare those paths relative to your project's own tsconfig.json. TypeScript 6, which a new app pins, deprecates baseUrl, and tsc stops with TS5101 where it is set. Without baseUrl, TypeScript and the build both read paths from the folder tsconfig.json is in, as a new app's "@src/*": ["./src/*"] does, so write each target from there, or keep baseUrl and set "ignoreDeprecations": "6.0".

    meocord start --dev rebuilds when you save meocord.config.ts or tsconfig.json, and each rebuild writes a copy of your tsconfig.json. Those copies now share one temporary directory and one exit listener for the session, instead of one each. On Node, a session with several such saves no longer prints MaxListenersExceededWarning for exit.

    A build or start --dev that stops before it can clean up, such as one killed, crashed or closed with its terminal, left its directory in your system's temp directory for good. The next build or start --dev on the same machine now removes it. Directories left by earlier 4.1 betas, named meocord-tsconfig- and six characters, can't be told apart from a build that is still running, so they stay: delete them yourself while no MeoCord build or start --dev is running.

  • #338 b6a34af Thanks @l7aromeo! - warnUnanswered now also warns when an interceptor returns without calling next.handle() and leaves the interaction unanswered, or deferred by @Defer() with no follow-up, as an interceptor that answers from a cache can. The warning names the interceptor: Shop.buy: its interceptor Cached returned before the handler ran, without answering the interaction, …. It warned only when the handler itself ran, so this case passed silently while the user saw "The application did not respond".

    An interceptor that returns before the handler finishes, as one racing next.handle() against a timeout does, is named the same way, the outermost when several do, … returned before the handler finished, …, rather than the warning blaming the handler for an answer it was still about to send.

  • Breaking

    #323 ef58d97 Thanks @l7aromeo! - The startup warning about command handlers that Discord never sends now also covers @Autocomplete handlers. One is named when:

    • no builder registers its command;
    • its path isn't a subcommand that its command's builder registers;
    • it names an option the builder doesn't register, or one built without setAutocomplete(true);
    • it completes every option of a subcommand that has no option with autocomplete on.

    A handler of the whole command is checked against every option of the command, its subcommands' included, since MeoCord falls back to it for all of them. The bot still starts. In the next major version (5.0), these refuse to start. See the upgrade guide.

    MeoCordTestingModule.compile() names the same cases, apart from a command no builder registers, as it already does for @Command handlers.

    meocord generate controller autocomplete <name> now ends by saying what to add to /<name>'s builder, the query option with setAutocomplete(true), since the generated handler completes that option and Discord never asks it to until the command declares it.

  • Breaking

    #307 db19771 Thanks @l7aromeo! - A command handler that Discord never sends an interaction to now gets a warning when the app is created, so meocord start, meocord register and the shard manager report it. Before, such a handler was dead or misrouted with no sign at startup. One warning names every such handler, what is wrong and what to do:

    • a subcommand path that the command's builder doesn't register: @Command('settings notfy', CommandType.SLASH) beside a settings builder with view and notify. Before, /settings notify silently ran the settings handler;
    • a customId pattern, such as a route(), given to a slash, context menu or entry point handler, which is matched by its command name;
    • a builder that registers another name than its @Command's, such as setName('ping') under @Command('pong', PingBuilder);
    • a slash, context menu or entry point command that no builder registers at all.

    The bot still starts. In the next major version (5.0), these refuse to start. See the upgrade guide.

    MeoCordTestingModule.compile() gives the same warning, apart from the last case: a handler with a CommandType and no builder is how a test fixture is written.

  • #339 d2fb9cf Thanks @l7aromeo! - How MeoCord logs and echoes what users send:

    • Autocomplete and reactions log their users' outcomes at debug level, as commands do. An autocomplete call that a guard denies, or whose handler throws a UserError or a ValidationError, still closes its menu, and is no longer logged as an error with its stack. The same holds for a guard that denies a reaction. TestingModule.dispatch resolves for such an autocomplete call or reaction, where it rejected; read what stopped it from the result's error.
    • Log lines quote what a user sent on one line. A message's text, a reaction's emoji and a component's customId are quoted with line breaks, control characters and quotes escaped, and a message's text is cut short after 200 characters, with its length. So are the messages of the debug lines for a refused, denied or invalid call.
    • Usage and help replies show the user's words as typed. A param's value, a flag's name or value, and the command a help query names are quoted with their markdown escaped and on one line, so **up** or [text](https://example.com) reads as written instead of rendering as bold text or a link.