Skip to content
GitHub

4.1.0-beta.5 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

  • #190 6cc22f8 Thanks @l7aromeo! - CooldownStore.peekMany(entries) checks a call against its cooldowns without recording it, returning the verdict consumeMany would. MemoryCooldownStore, ShardedCooldownStore (one message to the shard manager) and RedisCooldownStore (one read-only script; on Redis Cluster, one per slot unless hashTag: 'handler' keeps a handler's keys in one) answer it from their counts. A store of your own needs nothing: the default allows every call and leaves the refusal to consumeMany. Override it to let a cooldown refuse a call before the work ahead of its handler. testCooldownStore checks an override: a peek records nothing and refuses with the wait consume gives.

  • Breaking

    #157 3ff8175 Thanks @l7aromeo! - Cooldowns survive a failing store, and count a handler's stacked cooldowns in one step.

    • When the store fails. @MeoCord({ cooldownStoreFailure, cooldownStoreTimeoutMs }) decides what a call gets when the cooldown store throws, rejects or does not answer within cooldownStoreTimeoutMs (1000 by default).
      • 'deny', the default, refuses it with the new CooldownStoreError from meocord/common, which the fallback answers privately: "Cooldowns can't be checked right now: try again shortly." A filter can catch it to answer otherwise, and observers see outcome: 'error'.
      • 'allow' runs it uncounted.
      • Either way the failure is logged once per outage, with its cause, and again when the store answers. MeoCord never counts a call itself or asks twice, so an answer after the timeout records the call once, in the store.
    • Stacked cooldowns, one step. CooldownStore gains consumeMany(entries), which @Cooldown calls once per call with every stacked cooldown. The default calls consume for each in order, so a store of your own keeps working; override it to check every entry and record the call against all of them only if all allow it. The built-in stores do:
      • MemoryCooldownStore checks them together.
      • ShardedCooldownStore sends one message to the manager.
      • RedisCooldownStore runs one script, so a call costs one round trip however many cooldowns it has: with 3 stacked and 5 ms to Redis, about 5 ms instead of 16. On Redis Cluster, where a handler's keys sit in different slots, it counts them a script each, in order, unless you pass { hashTag: 'handler' } to keep each handler's keys in one slot.
      • With these stores, a call one cooldown refuses no longer spends the others, and waits the longest wait among those that refuse it.
    • ShardedCooldownStore treats a manager that does not answer as a store failure, handled by cooldownStoreFailure, rather than counting in the shard.
    • testCooldownStore checks the batch path too: a batch is counted at once and names the longest wait, and for a store that overrides consumeMany, a refused batch records nothing and concurrent batches at the limit let exactly one through.

    See Smaller changes in the upgrade guide.

  • #164 8b524a0 Thanks @l7aromeo! - route() in meocord/common builds customIds from a component pattern: const ticket = route('ticket/{id}') goes to @Command(ticket, CommandType.BUTTON) in place of the string, and ticket.build({ id }) gives ticket/42. A missing or unknown param fails to compile. So does a button's or select menu's handler whose params require a key its route does not capture, other than the menu's choices; a modal's fields, and a command's options, are not checked. A / or % in a value is encoded as %2F or %25, an empty value or an id over Discord's 100 characters throws, and routes are ranked and checked for duplicates as their pattern strings are. Handlers now receive %2F and %25 in a captured param decoded, for string patterns too.

  • Breaking

    #154 2ba4e96 Thanks @l7aromeo! - Two component handlers of one type whose customId patterns match exactly the same ids, such as profile/{uid} and profile/{id}, stop the bot at startup with an error naming both, as two message handlers with the same pattern do. The bot used to start with a warning and send every click to one of them, chosen by the order the controllers were listed. One handler declared under both spellings is one route, the same pattern on different component types is still allowed, and patterns that only overlap, such as a/{x}/c and a/b/{y}, are still a warning. findRouteConflicts and resolveRoute throw the same error. Controllers generated by 4.0 share their default customIds, such as button-click; see Two component handlers with the same customId pattern stop the bot.

  • #197 9fd7ad3 Thanks @l7aromeo! - A typed message param's member, user, role or channel is fetched from Discord only once the handler's guards let the call through, so a caller they refuse, or one still on a cooldown that has no by, costs no request, however many IDs the message names. The guards see each such param as an EntityRef: its id, the entity as cached when discord.js already has it, and resolve() to fetch it; ParamRefsOf<'pattern'>, from meocord/interface, types the params that way. The handler, @Validate, pipes and @Cooldown({ by }) get the entities, as before. Each ID is fetched once however many messages and guards ask for it at the same time. An app's own param type can return an EntityRef from parse to fetch after the guards too.

  • Breaking

    #156 64d9caa Thanks @l7aromeo! - Class-level @UseGuard, @UseInterceptor, @UseFilter and @Cooldown on a controller also apply to the handlers a subclass declares itself, as they do in NestJS: a guard on an abstract StaffController now guards every command of a class that extends it, where the subclass's own handlers ran without it. Every handler gets the chain inherited handlers had: the subclass's class stages first, then each base's, then the method's, with filters tried and cooldowns counted from the base out. @Controller({ inheritStages: false }) keeps a subclass's own handlers to its own class and method stages; the handlers it inherits keep their base's. inspectHandler lists the resolved chain, and a direct call to a guarded handler runs the same guards in the same order as dispatch.

    Each handler's stages are resolved once, not on every call, so dispatch pays nothing for the chain. See A base controller's class stages cover its subclasses in the upgrade guide.

  • #175 e22c401 Thanks @l7aromeo! - Logger prints from a level up, set by logLevel in meocord.config.ts ('debug', 'log', 'warn', 'error' or 'silent') or, for one run, by the MEOCORD_LOG_LEVEL environment variable, which wins. By default [DEBUG] lines show in development, where NODE_ENV is development as under meocord start --dev, and are hidden elsewhere, so a production log no longer carries the raw error and stack behind an explained startup failure such as a refused token. To see debug lines in production again, start with MEOCORD_LOG_LEVEL=debug or set logLevel: 'debug'. An unknown MEOCORD_LOG_LEVEL is reported once and ignored. The level is resolved once, on the first line logged, and a suppressed line costs no formatting.

  • #166 0d9b466 Thanks @l7aromeo! - @MessageHandler takes aliases, description and scope. aliases: ['m'] lets !m @ana run mute {target:member}, each alias standing in place of the words the pattern begins with. scope: 'guild' or 'dm' answers a message sent elsewhere that the command works in a server only, or in direct messages only, and the handler does not run; MessageUsageError gains dmOnly for the second. HandlerRegistry's message entries list each command once, with its command words, aliases, description, scope, usage(prefix), the text a usage error shows, and matches(words), for a help command of the app's own; the README has one to start from.

  • #173 6af9e7a Thanks @l7aromeo! - Message patterns take flags and typed lists. {--bots} is true when a message gives --bots anywhere after the command word, and false when it does not; {--from:user?} takes --from=@ana, resolved as a typed param is, and is required without the ?. {options:string...} gives the rest of the message as a list, one item per word or "quoted words", and {targets:member...} a list of members, fetched with the message's other members in one request. A flag the command does not have, a typed flag missing or given no value, and a list item of the wrong type each get the usage reply. ParamsOf types flags as boolean or their value, and typed lists as arrays. Only a message naming a command with flags is read for them, so a pattern without flags reads --bots as an ordinary word, as before, and {name...} without a type stays the rest of the message as text.

  • #159 4b7ef50 Thanks @l7aromeo! - A @MessageHandler pattern's params can name a type, {name:type}: int, number, bool, duration (10m, 2h30m, in milliseconds), member, user, role, channel (by mention, ID or, for a role, its name), words to choose from such as {mode:on|off}, or a type the app adds in @MeoCord({ messages: { types } }) and declares in MessageParamTypes. Several optional params may end a pattern, as in ban {target:member} {duration:duration?} {reason...?}: each one that another follows takes a word only if it fits its type, so !ban @ana spamming gives a reason and no duration. Each word becomes its value before the guards run, so guards, @Validate, pipes, @Cooldown({ by }) and the handler receive members and numbers. A mentioned member, a cached member, user, role or channel costs no request, and the members a message names that are not cached are fetched in one request. The params a handler declares are checked against its pattern at compile time, and ParamsOf<'pattern'> gives their type.

    A message that names a command after a prefix or mention but does not fit its pattern, with a word of the wrong type, a param missing, or a member, role or channel param sent in a DM, gets the command's usage in a reply, deleted after @MeoCord({ messages: { deleteUsageRepliesAfter } }) seconds (10, or 0 to keep it). It is a MessageUsageError from meocord/common, which the handler's exception filters see first. A message with no prefix or mention gets no reply. In meocord/testing, createMockGuild({ members, roles, channels }) fills the guild's caches, createMockMessage({ guild }) sends a message in that guild or, with null, in a DM, and invoke resolves typed params as dispatch does, and answers a prefixed message that names the command but leaves out a param with the same MessageUsageError, through the handler's filters.

  • #162 d0e6bf7 Thanks @l7aromeo! - @ReactionHandler matches a custom emoji by its id, as well as by name. Pass the id, @ReactionHandler('1234567890123456789'), or the <:party:1234567890123456789> Discord shows when you send \:party: in a message. The handler then runs for that one emoji, rather than for every custom emoji called party across the bot's servers. A name, and a standard emoji's character, match as before.

  • #161 05bcea6 Thanks @l7aromeo! - A select menu's choices arrive in its handler's params, as a modal's fields do. values holds the chosen options' values or ids, beside the customId params. discord.js's resolved objects come too: users and members from a user select, roles from a role select, channels from a channel select, and users, members and roles from a mentionable one.

    @Validate, pipes, @Cooldown({ by }) and getHandlerParams() see them, so a poll can limit each option on its own with by: (_context, { values }: { values: string[] }) => values[0]. A customId param of the same name keeps winning, and development warns about the clash. invoke builds them from a mock's values, users, members, roles and channels. Handlers that read interaction.values keep working.

  • #183 3af939e Thanks @l7aromeo! - module.dispatch(input) in meocord/testing sends an interaction, a message or a reaction through the bot's own dispatch: routed over the module's controllers and its app's message options exactly as the bot routes it, then run through the full pipeline of each handler it reaches. It resolves to { ran, handlers, error? }, where handlers lists each handler reached, in the order it ran, with its own ran and error. What the user is sent reaches the mocks as the bot sends it, including a usage reply and the built-in fallback's answer to an error no filter handles. An error the fallback answers as the user's own outcome resolves in error: a usage reply, an unknown command, or a guard's, a cooldown's, a validation's or a UserError's refusal. Any other error no filter handles rejects the call once the fallback has answered. A reaction is dispatched with the user who reacted, module.dispatch(reaction, { user, action }), added unless an action is given, so @ReactionHandler can be tested through routing, which emit never reaches. invoke still tests one handler you name.

  • #172 c1fd9e5 Thanks @l7aromeo! - A testing module runs the lifecycle hooks. await module.init({ ready: true }) runs every onReady once, in the order the bot runs them: each class after the classes and providers it injects, the observers last. await module.close() runs, in reverse, the onShutdown hooks of everything the module has constructed, whether or not it was readied, so a test can close a provider it opened, such as a connection pool a factory made in init(), instead of leaking it; nothing is constructed just to be shut down. onReady receives a client from createMockClient and { primary: true }, or those passed as init({ ready: { client, primary } }). Every hook runs even when one throws; init or close then rejects with that error, or an AggregateError naming each hook that threw. init() without ready runs no hook, as before.

  • Breaking

    #200 c6b4af0 Thanks @l7aromeo! - Add themes: design tokens by role, which respond() and MeoCord's own views take their colours, emojis and button styles from, set once for the app and changed per controller, handler, server or user. See Theming.

    What an existing bot sees without changing anything

    • Answers sent through respond() with no colour, an embed without color or a Components V2 container without accent_color, now show the theme's primary, #7680F4 unless the app sets another. A colour that is set is kept, 0 and a null accent included, and nothing sent around respond() is touched. To send one message as written, pass { fill: false } (ResponseSendOptions) as the second argument to send(), edit() or followUp().
    • MeoCord's error view is coloured by the error's tone: warning when it is the user's own outcome, such as a denied guard, a cooldown, invalid input or a UserError, and danger for a fault in the bot. Its loading view uses the theme's loading emoji.
    • Theme from meocord/common is deprecated, and goes in MeoCord 5. Its colours still work: each reads the matching role of the call's theme, so code written against Theme.primaryColor follows @MeoCord({ theme }) and @UseTheme with no change, and errorColor is the danger role. Their values are now the new defaults, tuned for at least 3:1 contrast against every Discord surface: 4.0's were primaryColor #5865F2, successColor #28A745, infoColor #17A2B8, errorColor #DC3545 and warningColor #FFC107, which @MeoCord({ theme }) sets again if you want them. Assigning one still recolours MeoCord's views, beneath every theme the app sets, and logs a warning once per colour. See Theme is deprecated, and its colours changed.
    • A presenter from an earlier 4.1 beta gets context.theme and the error's tone. A spec that builds a ResponseContext or a PresentedError by hand adds theme: createMockTheme() and tone.

    What's new

    • Tokens: ThemeColors, ThemeEmojis and ThemeButtons in meocord/interface, grouped in MeoCordTheme, each role with a default. An app adds tokens of its own by augmenting them; a bad token stops the bot before it logs in, naming where it was set.
    • Setting and reading: @MeoCord({ theme }) for the app, @UseTheme for a controller or handler, and useTheme() from meocord/common to read the call's theme anywhere the call runs, context.getTheme() in a stage. @MeoCord({ themeFor: { guild, user } }) looks a theme up per server and per user, cached, with ThemeCache to clear a result when it changes; see Themes per server and per user.
    • Presenters: ResponseContext.theme and PresentedError.tone, so context.theme.colors[tone] styles an error by kind.
    • Replies to messages: @MeoCord({ messages: { replyEmoji: true } }) starts MeoCord's text replies to message commands with the theme's emoji. It is off by default.
    • Testing: calls in a testing module run in its theme as in the bot; overrideTheme, overrideThemeFor, createMockTheme and withTheme from meocord/testing set or check one.
    • New apps: meocord create writes a presenter styled from context.theme and tone, src/types/theme.d.ts for the app's own tokens beside src/types/assets.d.ts, and an eslint.config.ts that warns on deprecated APIs in app code.
  • #167 0edd2cf Thanks @l7aromeo! - A guard, interceptor, filter or pipe can declare the params it takes, declare readonly params?: { channelIds: string[] }, and every { provide, params } for it is checked against that type: in @UseGuard, @UseInterceptor, @UseFilter, @UsePipe and @MeoCord({ guards, interceptors, filters }). A misspelt param, such as channelId, or one of the wrong type, fails to compile, where it failed at the first call. A class that declares none takes any params, as before.

    A guard also has its params whole as this.params, besides each as a property of its own. An interceptor, filter or pipe, shared across calls, reads them typed with context.getParams<StageParams<typeof X>>(); StageParams is exported from meocord/interface.

  • #169 433a449 Thanks @l7aromeo! - factoryProvider in meocord/common types a factory provider: each useFactory parameter is what the token in the same place of inject provides (a class's instance, a createToken token's type, or unknown for a string or plain symbol), and the factory must return what provide stands for. A parameter inject does not supply, one of the wrong type, or a wrong return fails to compile. It returns the provider unchanged, for @MeoCord({ providers }) and the testing module.

    TypeScript
    factoryProvider({
      provide: DATABASE,
      inject: [Config, PORT],
      useFactory: (config, port) => new Pool(config.url, port),
    })

    A plain { provide, useFactory, inject } object works as before. TypeScript cannot type its factory from inject inside a list, which is why this is a function.

  • #168 645e951 Thanks @l7aromeo! - In development, MeoCord warns once per handler that finishes without answering its interaction, which leaves the user with "The application did not respond", or that defers it and never follows up, which leaves them watching it think until Discord gives up. The warning names the handler and what to call. A call a guard denied, or one that failed, is answered by the fallback and never warned about. It is on while NODE_ENV is development, as under meocord start --dev, and off in production; @MeoCord({ warnUnanswered }) turns it on or off regardless.

  • #165 ab924dc Thanks @l7aromeo! - UserError in meocord/common is for a mistake the user can fix, such as too few coins or an account that does not exist, rather than a fault in the bot. Throw it from a handler, a pipe, a service or a guard: the built-in fallback shows its message privately for an interaction, even after @Defer, and as a reply to a message, without pinging its author, and logs it only at debug level.

    TypeScript
    throw new UserError(`You need ${missing} more coins.`, { code: 'shop.poor', context: { missing } })
    • code and context let an exception filter or a presenter phrase it otherwise, such as in the user's language; a presenter's error() receives the error with the interaction.
    • respond(interaction).error(userError) shows its message privately by default.
    • Observers see the new outcome 'refused', with handled set, apart from 'error', so metrics tell the user's mistakes from the bot's faults. An observer that switches over every DispatchOutcome gains a case to handle.

Patch Changes

  • #215 f7f64a4 Thanks @l7aromeo! - @Defer({ mode: 'auto' }) acknowledges an interaction without a creation time after its delay, 1.5 s unless after says otherwise, instead of at once. The 2.5 s cap counts from createdTimestamp, and without one the deadline was not a number, so the timer fired immediately. A real interaction always has one, but a test's mock did not, so a test of an auto-deferred handler saw an acknowledgement the bot would not send.

    createMockInteraction and createMockMessage now give createdTimestamp and createdAt: the time an id the test gives encodes, as discord.js reads it, or, with the generated id, the time the mock was made. A createdTimestamp the test sets wins. Generated ids are unchanged.

  • #176 e1810cc Thanks @l7aromeo! - A @MessageHandler whose own prefix is '', no prefix, no longer stops every message command in the bot: each message threw Cannot read properties of undefined (reading 'toLowerCase') before any handler ran. The handler now matches messages without a prefix, as its JSDoc says, beside the handlers that use the app's prefix or their own.

  • #195 3cd75b7 Thanks @l7aromeo! - A UserError thrown from an @On handler of an event that carries a message, such as messageCreate, now answers that message as a message handler's does: a reply with its message, without pinging, the edited message for messageUpdate. It was logged as an error and answered nothing. From any other event it is logged at debug level, not as an error, since it is the user's outcome rather than a fault.

  • #170 e8fffd2 Thanks @l7aromeo! - CooldownStoreFailure, the type of @MeoCord({ cooldownStoreFailure }), is exported from meocord/interface. @MeoCord's declarations referred to it without any entry exporting it, so a consumer declaring a value of that type, or emitting declarations for an app that wraps @MeoCord's options, had no name to import and could hit TS2742.

  • #199 f91aee5 Thanks @l7aromeo! - A flag before a command's first word, as in !--bots purge 5, is never read, and the message names no command. Before, such a message ran purge whenever some other handler's pattern with flags began with a param, such as {target} {--ping}, so whether it matched depended on unrelated handlers. A pattern that begins with a param still takes its flags anywhere.

  • Breaking

    #152 e03539d Thanks @l7aromeo! - meocord generate writes components that fit a 4.1 app as they are.

    • A button, modal or select menu takes its customId from its name, and a message handler its pattern: meocord g co button ticket routes ticket and ticket/{id}, and meocord g co message ping matches ping. Generated components no longer share the fixed ids button-click or select-menu, or the baka pattern of the sample message controller. With that one listed, the bot stopped at startup: "match the same messages, so only one of them could ever run".
    • A nested name gives its whole path to the class, as it already did to the command: admin/ban makes AdminBanButtonController, so it never shares a class name with ban's BanButtonController. Two classes of one name are refused under process sharding and share cooldown keys.
    • Controllers answer with respond(), and their methods are named after the class: handleTicket. The filter template answers with context.response?.error().
    • After writing, generate names the next step, such as Next: add TicketButtonController to @MeoCord({ controllers }) in src/app.ts. It still never edits src/app.ts.

    New apps from meocord create get src/types/assets.d.ts, so import logo from './logo.png' and the template's Markdown imports pass the app's own tsc. Its coverage settings leave declaration files out. The sample app.ts no longer sets an empty custom activity. To add the declarations to an existing app, see Smaller changes.

  • #184 29fdbcb Thanks @l7aromeo! - invoke in meocord/testing runs a message handler only for a message dispatch would give it. With roll {sides} in one controller and roll 20 in another, invoking the first with !roll 20 rejects saying dispatch runs the second, rather than running a handler the bot never would.

  • #179 3ec6c2d Thanks @l7aromeo! - invoke in meocord/testing answers a message that names a command without fitting its pattern with that command's usage only where dispatch would. With config {key} beside config set {key} {value...}, invoking the first with !config set prefix ? rejects saying dispatch runs the second, rather than with a usage error the user would never see.

  • #182 920da89 Thanks @l7aromeo! - logLevel in meocord.config.ts applies to the built bot only. The CLI and tests read it from whatever dist/meocord.config.mjs a previous build left, so meocord build printed its progress on the first build and nothing on the next, and a test's log lines depended on whether the app had been built. Both now print by MEOCORD_LOG_LEVEL and the default; set MEOCORD_LOG_LEVEL to quiet them.

  • #182 aaaf328 Thanks @l7aromeo! - MEOCORD_LOG_LEVEL is read in any case, so MEOCORD_LOG_LEVEL=DEBUG shows debug lines rather than being rejected. A value that names no level is reported even when the configured logLevel is error or silent, which hid the warning, so a bot that prints nothing tells you why your override did not apply.

  • #185 ba57fcc Thanks @l7aromeo! - Logger reads appName from the config only in the built bot. Elsewhere it read dist/meocord.config.mjs left by the last build, so the CLI prefixed its lines with a previous build's name, and a test that logged loaded .env through that file's import 'dotenv/config', but only once the app had been built. Tests now never load .env on their own; see Running tests to load it in vitest.setup.ts.

  • #157 6827411 Thanks @l7aromeo! - MemoryCooldownStore, the default, spends the same time on a call however many calls its key holds. It filtered every call time of a key on each call, so a busy cooldown with a large uses, such as a 'global' one, slowed dispatch as calls built up: at 20,000 calls a second with uses: 1_000_000, about 45 µs a call. Each key's times are now trimmed from the front as they leave the window, which costs about 0.12 µs a call there, and decisions are unchanged.

  • #191 399e4e4 Thanks @l7aromeo! - A message command that a guard denies with a GuardDeniedError, or that @Validate refuses, now gets a reply with the reason, without pinging, deleted after @MeoCord({ messages: { deleteUsageRepliesAfter } }) seconds as a usage reply is, and is logged at debug level. It was answered with nothing and logged as an error, though an interaction gets the same reason and neither is a fault. A guard denying a listener, an unpatterned @MessageHandler() or an @On handler, still gets no reply, since it only filters what the listener takes, and is now logged at debug level rather than as an error.

  • #198 a9ae5e4 Thanks @l7aromeo! - A message after a prefix whose first word names no command is no longer split into words, so an unknown command costs dispatch about 40% less. A message naming a command with flags is split once instead of twice.

  • #184 8e5f76a Thanks @l7aromeo! - A message pattern's flag must start with a letter: {--2fa} or {--_x} stops the bot at startup with a message saying so, since a message's --2fa is read as a word and the flag could never be given. A rest param with flags taken out keeps its own spacing and line breaks: say {text...} {--loud} with "one\n--loud\ntwo" gives "one\ntwo", where the flag's surroundings were joined by a single space.

  • #158 d724f12 Thanks @l7aromeo! - Matching a message against @MessageHandler patterns costs the same however many patterns an app has. The patterns are compiled once into an index of their words, a message's words are read once rather than once per pattern, and a message whose first character no prefix or mention begins with is turned away before it is read at all. At 1000 patterns a matching message costs about 0.4 µs where it cost about 0.4 ms, and ordinary chat about 20 ns where it cost 25–50 µs. Which handler a message reaches, and the params it receives, are unchanged.

  • #184 e728d8c Thanks @l7aromeo! - A message naming more than 100 uncached members, as a member list can, has them fetched 100 at a time. Discord's gateway request for members takes at most 100 IDs, and a larger one was sent whole.

  • #194 7508511 Thanks @l7aromeo! - A mention of the bot starts a message command in an app whose handlers all have their own prefixes, when mention is on. Such an app took no mention, since it read no starts at all, and did not prefer a handler whose scope fits where the message was sent. Its prefix function is still never called, since no handler uses the app's prefixes.

  • #184 770ca8f Thanks @l7aromeo! - A message command's scope now decides which handler runs, not only whether it may. A handler whose scope fits where the message was sent runs before one of another scope, so a DM-only config {key} no longer answers "direct messages only" in a server where an unscoped config {words...} fits, and one command may have a server handler and a DM handler with the same pattern, which startup refused. A message that only an out-of-scope handler matches still gets the reply saying where the command works.

  • #184 6664a27 Thanks @l7aromeo! - A message full of unclosed quotes no longer costs time growing with the square of its length. Each unclosed quote searched to the end of the message for its close, about 14 ms for a 4000-character message of them, work any user could make the bot do; reading a message is now one pass whatever its quotes.

  • #177 d43f382 Thanks @l7aromeo! - createMockMessage caches what its content mentions, as the gateway delivers a message's mentions with it: <@id> a user in message.client.users.cache and, in a guild, a member in guild.members.cache; <@&id> a role; <#id> a channel; each also in message.mentions. A typed user param in a test, through invoke or dispatch, no longer throws message.client.users.cache.get is not a function. createMockClient has real users and channels caches, and every mock client is the same bot, createMockClient().user.id, so a message starting with a mention of the bot reaches its handler through invoke. createMockMessage takes client and users to set the client it arrived on and more cached users.

  • #163 c1c9ff0 Thanks @l7aromeo! - A bot token Discord refuses, or an empty one, is now explained in one line: what is wrong, and where to get a token (Developer Portal → your application → Bot → Reset Token, into DISCORD_TOKEN in .env for a generated app). app.start() still rejects and sets the exit code, and the error is recognised by isExplainedError, so the generated main.ts no longer logs discord.js's error and stack a second time. meocord register explains a refused token the same way instead of printing the raw DiscordAPIError 401. With process sharding, the manager explains it and exits before spawning any shard.

    commands.guilds whose ids are all blank, as [process.env.GUILD_ID] leaves it with the variable empty or unset, no longer registers globally. The commands without guilds of their own are registered nowhere, with a warning that names them, leftovers are not cleared even with clearOther, and meocord register exits 1. guilds accepts undefined ids, so [process.env.GUILD_ID] needs no !.

    With bundleDependencies, a build that finds supports-color missing, which debug probes for, prints one line naming the dependency and the optionalExternals entry that silences it, instead of the bundler's "Module not found" warning with a code frame.

    meocord show without a flag says to run meocord show --license or meocord show --warranty instead of reprinting its options. A config number out of range shows the value and the range, as in sharding.shards must be 'auto' or a whole number of shards, 1 or more (got 0). A new application's README lists the observer generator.

  • Breaking

    #153 0b3f3b4 Thanks @l7aromeo! - @ReactionHandler skips reactions from bots, the bot's own included, as @MessageHandler skips messages from bots. Every handler ran for them: a bot's reaction handlers ran for the reactions it added itself, a poll counted the reactions the bot seeded, and the generated sample answered its own reaction twice. A handler that should still run for bot reactions sets bots: true: @ReactionHandler('📌', { bots: true }), or @ReactionHandler({ bots: true }) for every emoji. A partial user is fetched to tell whether it is a bot. See Reactions from bots reach no handler in the upgrade guide.

    New applications' sample reaction controller answers 😋 once, and its handler for every emoji only logs.

  • Breaking

    #160 b452256 Thanks @l7aromeo! - Testing mocks behave more like discord.js, and a few messages and types say more:

    • An interaction mock has an id, a channelId and a user with an id, and a message mock an id, author.id, channelId and guildId, each a snowflake string no other mock in the test run has; createMockUser, createMockGuild and createMockChannel get ids too. They were mock objects that all read as [object Object], so two default users were one user, and shared a per-user cooldown.
    • An interaction mock made without a guildId has guildId, guild and member null, as a direct message does, where they were truthy while inGuild() said otherwise. Giving it a guildId gives it a member.
    • The autocomplete mock's respond() rejects more than 25 choices, as Discord does.
    • invoke takes the interaction for a handler declared with no parameters, which failed to compile.
    • respond().modal() after @Defer acknowledged the interaction says so, and how to fix it.
    • A class listed alone in providers is refused with what to write instead: in the testing module it needs no listing, and in @MeoCord it goes in services.
    • The ExceptionFilter and @Catch examples answer through context.response?.error(), which suits a deferred interaction too.
    • The README gives the key a cooldown is counted under, with an example.

    Tests that relied on the old mock defaults may need a change; see Smaller changes.

  • #193 3cc9898 Thanks @l7aromeo! - A shared guard that calls another shared guard, such as one injected into it, no longer makes the inner guard read the outer guard's params. Each guard reads only the params its own { provide, params } entry gives; called directly by another guard, it reads its own values.

    A shared guard whose class takes a param through a setter, such as set limit(value), gets it again: the value was dropped, and the guard read its own. Such a param has nowhere to be kept per call, so it is set on the shared instance, as it was before shared guards read each call's own params, and the bot warns once that overlapping calls can read each other's. Reading the param as a plain property, or not binding the guard, avoids that.

    A shared guard whose instance is sealed (Object.seal(this)) cannot read each call's own params: its properties cannot be changed to do so. It takes them on the one instance, as before, so overlapping calls can read each other's params and a call without params reads the last ones given; the bot now warns about such a guard once. Leave the instance unsealed, or stop binding the guard, to keep calls apart.

  • #187 721ec61 Thanks @l7aromeo! - A guard shared as one instance now reads each call's own params. A guard bound once, by listing it in @MeoCord({ services }) or providers or by injecting it into a service, is a single instance for every call. When two handlers gave it different params, such as { role: 'admin' } and { role: 'mod' }, and their calls overlapped, one call's guard could read the other's params and allow or deny the wrong call. Each call now sees its own, while the instance, its state and its private fields stay shared. Guards made for each call, the default, are unchanged.

    No action is needed. The startup warning about a guard listed in services is gone, since such a guard is now safe.

    A sealed guard that declares the properties its params set, and no params property, takes its params again instead of failing the call. A frozen guard given params fails the call with an error that names the guard and says why.

  • #149 b9be088 Thanks @l7aromeo! - Stack traces name your source files, lines and columns on Node and Bun, in development and production. meocord start runs node with --enable-source-maps, and its shard processes inherit it. A bundle started any other way, such as node dist/main.js in a Docker CMD or under Bun, which applies no source map to a bundle, maps its stacks from dist/main.js.map through Error.prepareStackTrace.

    • The map is read the first time a stack needs it. Each frame keeps the runtime's format, at fn (/abs/path/src/file.ts:line:col).
    • A hook already set on Error.prepareStackTrace receives the mapped call sites.
    • On minified Bun builds, a frame for a call can land one line above it.

    Under Bun, development traces had pointed into dist/main.js since 4.1.0-beta.4 dropped the eval devtool, and production traces always did without the Node flag.

    Set sourceMappedStacks: false in meocord.config.ts for an error tracker that applies uploaded source maps to the bundle's positions. See Stack traces.

  • #151 8faddd0 Thanks @l7aromeo! - Source-mapped stacks leave alone what a bot's dependencies do with Error.captureStackTrace. Some packages give it an object built by a function rather than an Error: follow-redirects, which axios loads, and node-fetch 2 both do. Bun's own stack hook refuses such an object, so MeoCord no longer hands it one, and that stack reads as it does with no hook set. A stack hook set before MeoCord's that throws no longer fails the code reading the stack; MeoCord writes the stack itself.

  • Breaking

    #155 e064407 Thanks @l7aromeo! - A new app's test:coverage reads files no spec imports. Coverage counts them as untested, and gets them as file.ts?cache=…&vitest-uncovered-coverage=true. The template's SWC plugin matched files by extension only, so it skipped those, and istanbul stopped with a syntax error on the first type annotation or decorator in one. The template now passes SWC an include that allows that query. For an existing app, see Smaller changes.

  • #218 9022251 Thanks @l7aromeo! - A click a discord.js collector takes is no longer answered "Command not found!". A button, select menu or modal submission no @Command route matches was answered at once, before a collector's collect callback or awaitModalSubmit could answer it, so the user saw "Command not found!" and the collector's own answer failed as already sent. While anything besides MeoCord listens for the client's interactions, such an interaction is now left to it for 1.5 seconds, and "Command not found!" and its warning come only if nothing has answered it by then. A bot with no other listener, and a command no handler takes, are answered at once as before. In a testing module, dispatch() does the same for the client of the interaction it is given. Observers are told of such an interaction only when nothing answered it, as 'not-found'; a click a collector answered is the collector's, and is not reported.