meocord 4.2.0 → 4.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/CHANGELOG.md +264 -0
  2. package/THIRD_PARTY_NOTICES.md +14 -13
  3. package/dist/cjs/_shared/{controller.decorator-CRn9kWob.cjs → controller.decorator-XKMlcUZz.cjs} +376 -100
  4. package/dist/cjs/_shared/{providers-ChvMqnLd.cjs → providers-BZgJyfZS.cjs} +145 -36
  5. package/dist/cjs/_shared/{sharded-cooldown-store-BdAUTdYs.cjs → sharded-cooldown-store-CsJJyCZd.cjs} +10 -7
  6. package/dist/cjs/_shared/{startup-roots-CQo8mSP8.cjs → startup-roots-CgfPQrNl.cjs} +219 -63
  7. package/dist/cjs/_shared/{translate-error-DhDVbT5M.cjs → translate-error-BUAR1WYG.cjs} +10 -5
  8. package/dist/cjs/common/index.cjs +3 -3
  9. package/dist/cjs/core/index.cjs +185 -168
  10. package/dist/cjs/decorator/index.cjs +21 -6
  11. package/dist/cjs/testing/index.cjs +1594 -286
  12. package/dist/cli.json +1 -1
  13. package/dist/esm/bin/generator.js +4 -5
  14. package/dist/esm/bin/helper/filter-generator.helper.js +4 -5
  15. package/dist/esm/bin/helper/guard-generator.helper.js +4 -5
  16. package/dist/esm/bin/helper/interceptor-generator.helper.js +4 -5
  17. package/dist/esm/bin/helper/observer-generator.helper.js +4 -5
  18. package/dist/esm/bin/helper/pipe-generator.helper.js +4 -5
  19. package/dist/esm/bin/helper/service-generator.helper.js +4 -5
  20. package/dist/esm/bin/meocord.js +84 -25
  21. package/dist/esm/build/load-config.pre-entry.js +13 -3
  22. package/dist/esm/build/rsbuild-config.js +16 -12
  23. package/dist/esm/build/typed-externals.js +36 -0
  24. package/dist/esm/common/cooldown-store.js +10 -2
  25. package/dist/esm/common/errors.js +25 -0
  26. package/dist/esm/common/meocord-text.js +11 -8
  27. package/dist/esm/common/response/call-order.js +9 -4
  28. package/dist/esm/common/response/presenter.js +33 -6
  29. package/dist/esm/common/response/response-state.js +84 -10
  30. package/dist/esm/common/route.js +11 -4
  31. package/dist/esm/common/sharded-cooldown-store.js +1 -1
  32. package/dist/esm/common/translator.js +37 -6
  33. package/dist/esm/core/command-registration.js +93 -24
  34. package/dist/esm/core/command-routes.js +2 -2
  35. package/dist/esm/core/cooldown-runner.js +29 -8
  36. package/dist/esm/core/fallback.js +20 -11
  37. package/dist/esm/core/guard-runner.js +16 -3
  38. package/dist/esm/core/handler-input.js +4 -3
  39. package/dist/esm/core/lifecycle-hooks.js +134 -47
  40. package/dist/esm/core/meocord-factory.js +55 -32
  41. package/dist/esm/core/meocord.app.js +116 -75
  42. package/dist/esm/core/providers.js +7 -2
  43. package/dist/esm/core/running-calls.js +43 -0
  44. package/dist/esm/core/shard-context.js +5 -6
  45. package/dist/esm/core/shard-manager.js +16 -10
  46. package/dist/esm/core/startup-roots.js +21 -1
  47. package/dist/esm/core/theme-resolvers.js +4 -1
  48. package/dist/esm/core/theme-scope.js +3 -1
  49. package/dist/esm/core/theme-validation.js +17 -10
  50. package/dist/esm/decorator/controller.decorator.js +56 -20
  51. package/dist/esm/decorator/cooldown.decorator.js +17 -0
  52. package/dist/esm/decorator/guard.decorator.js +3 -2
  53. package/dist/esm/package.json.js +1 -1
  54. package/dist/esm/testing/catalog.js +26 -10
  55. package/dist/esm/testing/cooldown-store-suite.js +67 -5
  56. package/dist/esm/testing/discord-defaults.js +205 -30
  57. package/dist/esm/testing/meocord-testing-module.js +134 -28
  58. package/dist/esm/testing/mock-fn.js +107 -26
  59. package/dist/esm/testing/mock-interaction.js +1026 -172
  60. package/dist/esm/testing/snowflake.js +9 -2
  61. package/dist/esm/testing/strict-mocks.js +37 -17
  62. package/dist/esm/util/common.util.js +47 -19
  63. package/dist/esm/util/dev-runner.util.js +8 -8
  64. package/dist/esm/util/generator-cli.util.js +4 -5
  65. package/dist/esm/util/interaction.util.js +2 -2
  66. package/dist/esm/util/meocord-config-loader.util.js +10 -2
  67. package/dist/esm/util/meocord-config-validation.util.js +3 -5
  68. package/dist/esm/util/meocord-source-config.util.js +65 -20
  69. package/dist/esm/util/method-wrapper.util.js +16 -0
  70. package/dist/esm/util/platform.util.js +10 -10
  71. package/dist/esm/util/shard-process.util.js +4 -1
  72. package/dist/esm/util/sharding-mode.util.js +5 -7
  73. package/dist/esm/util/shared-state.util.js +21 -0
  74. package/dist/esm/util/tsconfig-paths.util.js +29 -0
  75. package/dist/esm/util/tsconfig.util.js +10 -10
  76. package/dist/esm/util/value.util.js +12 -1
  77. package/dist/types/common/index.d.cts +6 -6
  78. package/dist/types/common/index.d.ts +6 -6
  79. package/dist/types/core/index.d.cts +3 -2
  80. package/dist/types/core/index.d.ts +3 -2
  81. package/dist/types/decorator/index.d.cts +1 -0
  82. package/dist/types/decorator/index.d.ts +1 -0
  83. package/dist/types/{index-CdXsFgjE.d.ts → index-DKJclIi-.d.ts} +45 -21
  84. package/dist/types/{index-nn5p7OoX.d.cts → index-Dlf0ZZtS.d.cts} +45 -21
  85. package/dist/types/interface/index.d.cts +1 -1
  86. package/dist/types/interface/index.d.ts +1 -1
  87. package/dist/types/{route-BZgFoNeB.d.cts → route-Bh1U13yy.d.cts} +3 -2
  88. package/dist/types/{route-BZgFoNeB.d.ts → route-Bh1U13yy.d.ts} +3 -2
  89. package/dist/types/testing/index.d.cts +175 -53
  90. package/dist/types/testing/index.d.ts +175 -53
  91. package/dist/types/{theme-resolvers-UKr3-6Ys.d.cts → theme-resolvers-CyEz9pnv.d.cts} +1 -1
  92. package/dist/types/{theme-resolvers-BcOJVzR9.d.ts → theme-resolvers-DA204O-q.d.ts} +1 -1
  93. package/dist/types/{translator-DHMkkCpg.d.cts → translator-Bg7hwxDF.d.cts} +21 -8
  94. package/dist/types/{translator-BViWKtLJ.d.ts → translator-CeP4bCzp.d.ts} +21 -8
  95. package/package.json +2 -2
@@ -80,12 +80,20 @@ let problem;
80
80
  const reason = (problem.error instanceof Error ? problem.error.message : String(problem.error)).replace(/\.$/, '');
81
81
  const missing = missingPackage(problem.error);
82
82
  const neededBy = missing?.importer ? `, which ${missing.importer} imports,` : '';
83
- const fix = missing ? `Install ${missing.name}${neededBy} in the project` : 'Fix meocord.config.ts';
84
- return `MeoCord config at ${problem.path} failed to load: ${reason}. ${fix}, then run \`meocord build\`.`;
83
+ const fix = missing ? `Install ${missing.name}${neededBy} in the project, then run \`meocord build\`.` : fixedInConfig(problem.error) ? 'Fix meocord.config.ts, then run `meocord build`.' : 'It threw as it loaded: check the environment it reads, such as its .env files.';
84
+ return `MeoCord config at ${problem.path} failed to load: ${reason}. ${fix}`;
85
85
  }
86
86
  const where = problem?.path ?? 'meocord.config.mjs';
87
87
  return `MeoCord config not found at ${where} (working directory ${process.cwd()}). Run \`meocord build\`, and start the bot from the dist it writes.`;
88
88
  }
89
+ /**
90
+ * Whether the config's file or imports are at fault, which a rebuild after fixing it mends: it could not be read as a
91
+ * module, or imports a file that isn't there. Otherwise its code threw as it ran.
92
+ */ function fixedInConfig(error) {
93
+ const code = error?.code;
94
+ if (error instanceof SyntaxError || code === 'ERR_MODULE_NOT_FOUND' || code === 'MODULE_NOT_FOUND') return true;
95
+ return typeof code === 'string' && code.startsWith('ERR_REQUIRE_');
96
+ }
89
97
  /**
90
98
  * The package a module-not-found error names, when it is a package rather than a file: `dotenv` for `dotenv/config`,
91
99
  * `@scope/name` for `@scope/name/sub`, with the installed package that imports it, if one does. Undefined for any other
@@ -120,7 +128,7 @@ let problem;
120
128
  try {
121
129
  // Called through a variable so a bundler does not try to resolve the path while building the
122
130
  // application; the file is only there once the build is done.
123
- const load = module$1.createRequire((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('_shared/controller.decorator-CRn9kWob.cjs', document.baseURI).href)));
131
+ const load = module$1.createRequire((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('_shared/controller.decorator-XKMlcUZz.cjs', document.baseURI).href)));
124
132
  const loaded = load(compiledPath);
125
133
  return loaded.default ?? loaded;
126
134
  } catch (error) {
@@ -395,7 +403,30 @@ const warned = new Set();
395
403
  }
396
404
  });
397
405
 
398
- const scope = new node_async_hooks.AsyncLocalStorage();
406
+ var version = "4.2.1";
407
+ var packageJson = {
408
+ version: version};
409
+
410
+ /**
411
+ * The name of a key the ES module and CommonJS builds of this meocord version share, and no other version has, for
412
+ * `Symbol.for`: a process that loads both builds of one install keeps one state under it, and two installed versions
413
+ * keep theirs apart. `Symbol.for(sharedName(...))` written out types as a `unique symbol`.
414
+ */ const sharedName = (name)=>`meocord@${packageJson.version}:${name}`;
415
+ /** The key named {@link sharedName}. */ function sharedKey(name) {
416
+ return Symbol.for(sharedName(name));
417
+ }
418
+ /** The state both builds of this version keep on `globalThis` under `name`, made by whichever asks first. */ function shared(name, make) {
419
+ const key = sharedKey(name);
420
+ const store = globalThis;
421
+ if (!Object.hasOwn(store, key)) Object.defineProperty(store, key, {
422
+ value: make(),
423
+ configurable: true
424
+ });
425
+ return store[key];
426
+ }
427
+
428
+ // One per process for this meocord version, so a call's theme reads the same from its ES module and CommonJS builds
429
+ const scope = shared('themeScope', ()=>new node_async_hooks.AsyncLocalStorage());
399
430
  const isPlainObject$1 = (value)=>{
400
431
  if (value === null || typeof value !== 'object') return false;
401
432
  const prototype = Object.getPrototypeOf(value);
@@ -877,6 +908,38 @@ function keepOrThrow(error, target, propertyKey) {
877
908
  process.on('unhandledRejection', onRejection);
878
909
  }
879
910
 
911
+ /** Characters a log line shows escaped: C0 and C1 controls, DEL, line and paragraph separators, and bidi controls. */ const UNSAFE_IN_LOG = /[\u0000-\u001f\u007f-\u009f\u2028\u2029\u202a-\u202e\u2066-\u2069]/g;
912
+ /** The same, with the quote and the backslash, for text shown between quotes. */ const UNSAFE_IN_QUOTES = /[\u0000-\u001f\u007f-\u009f\u2028\u2029\u202a-\u202e\u2066-\u2069"\\]/g;
913
+ const SHORT_ESCAPES = {
914
+ '\n': '\\n',
915
+ '\r': '\\r',
916
+ '\t': '\\t',
917
+ '"': '\\"',
918
+ '\\': '\\\\'
919
+ };
920
+ const escapeChar = (char)=>SHORT_ESCAPES[char] ?? `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`;
921
+ /** Text for a log line, on one line: its control characters escaped, so it can't start a line of its own. */ function escapeForLog(text) {
922
+ return text.replace(UNSAFE_IN_LOG, escapeChar);
923
+ }
924
+ /** How many characters of a user's text a log line quotes before cutting it short. */ const LOGGED_CHARACTERS = 200;
925
+ /**
926
+ * A user's text as a log line quotes it: escaped as {@link escapeForLog} does, with its quotes and backslashes too,
927
+ * and cut short after 200 characters, with its length.
928
+ */ function quoteForLog(text) {
929
+ const characters = [
930
+ ...text
931
+ ];
932
+ const quoted = (shown)=>`"${shown.replace(UNSAFE_IN_QUOTES, escapeChar)}"`;
933
+ if (characters.length <= LOGGED_CHARACTERS) return quoted(text);
934
+ return `${quoted(`${characters.slice(0, LOGGED_CHARACTERS).join('')}…`)} (${characters.length} characters)`;
935
+ }
936
+ /** Words a user wrote, as a reply quotes them: on one line, with their markdown, masked links included, shown as typed. */ function userWords(text) {
937
+ return discord_js.escapeMarkdown(text.replace(/\s+/g, ' ').trim(), {
938
+ maskedLink: true
939
+ });
940
+ }
941
+ /** Items as a sentence lists them: `a`, `a and b`, `a, b and c`. */ const listed = (items)=>items.length < 2 ? items.join('') : `${items.slice(0, -1).join(', ')} and ${items[items.length - 1]}`;
942
+
880
943
  /**
881
944
  * The role names MeoCord keeps for roles it may add to `colors`, `emojis` and `buttons`: the runtime copy of
882
945
  * `ReservedThemeRole`, which refuses them in TypeScript at the root theme, so that a JavaScript app is told too. A type
@@ -901,9 +964,10 @@ const EMOJI = 'a unicode emoji, or a custom one written <:name:id> or <a:name:id
901
964
  const BUTTON = 'ButtonStyle.Primary, Secondary, Success or Danger';
902
965
  /** A custom emoji as Discord writes one in text: its name, 2 to 32 word characters, and its id. */ const CUSTOM_EMOJI$1 = /^<a?:\w{2,32}:\d{17,20}>$/;
903
966
  /**
904
- * One unicode emoji: a flag of two regional indicators, a keycap, or a pictograph with an optional presentation
905
- * selector or skin tone, joined to others by zero-width joiners into one sequence.
906
- */ const UNICODE_EMOJI = /^(?:\p{Regional_Indicator}{2}|[#*0-9]️?⃣|\p{Extended_Pictographic}(?:️|\p{Emoji_Modifier})?(?:‍\p{Extended_Pictographic}(?:️|\p{Emoji_Modifier})?)*)$/u;
967
+ * One unicode emoji: a flag of two regional indicators, a subdivision flag (a black flag, tag characters and a cancel
968
+ * tag, as England's), a keycap, or a pictograph with an optional presentation selector or skin tone, joined to others by
969
+ * zero-width joiners into one sequence.
970
+ */ const UNICODE_EMOJI = /^(?:\p{Regional_Indicator}{2}|\u{1F3F4}[\u{E0020}-\u{E007E}]+\u{E007F}|[#*0-9]️?⃣|\p{Extended_Pictographic}(?:️|\p{Emoji_Modifier})?(?:‍\p{Extended_Pictographic}(?:️|\p{Emoji_Modifier})?)*)$/u;
907
971
  const byte = (value)=>Number.isInteger(value) && value >= 0 && value <= 255;
908
972
  /** Whether discord.js resolves the value to a colour Discord takes. Stricter on tuples, whose channels it only sums. */ function isColour(value) {
909
973
  if (typeof value === 'string') return /^#?[\da-f]{6}$/i.test(value) || value === 'Random' || value === 'Default' || Object.hasOwn(discord_js.Colors, value);
@@ -918,8 +982,9 @@ const THEMED_BUTTON_STYLES = [
918
982
  discord_js.ButtonStyle.Danger
919
983
  ];
920
984
  const isButtonStyle = (value)=>THEMED_BUTTON_STYLES.includes(value);
921
- const describe = (value)=>typeof value === 'string' ? `'${value}'` : Array.isArray(value) ? `[${value.map((item)=>typeof item === 'string' ? `'${item}'` : String(item)).join(', ')}]` : value === null ? 'null' : typeof value === 'object' ? 'an object' : String(value);
922
- const describeGroup = (value)=>Array.isArray(value) ? 'an array' : describe(value);
985
+ const asWritten = (text)=>`'${text}'`;
986
+ const describe = (value, quote = asWritten)=>typeof value === 'string' ? quote(value) : Array.isArray(value) ? `[${value.map((item)=>typeof item === 'string' ? quote(item) : String(item)).join(', ')}]` : value === null ? 'null' : typeof value === 'object' ? 'an object' : String(value);
987
+ const describeGroup = (value, quote = asWritten)=>Array.isArray(value) ? 'an array' : describe(value, quote);
923
988
  /** Whether a value is a plain object, as an object literal or `JSON.parse` makes: one a theme merges key by key. */ const isPlainObject = (value)=>{
924
989
  const prototype = Object.getPrototypeOf(value);
925
990
  return prototype === Object.prototype || prototype === null;
@@ -951,11 +1016,15 @@ const PLAIN = "give a plain object, such as { ...value }, or a row's toObject(),
951
1016
  * roles an app added, a colour discord.js cannot resolve, an emoji Discord would refuse, a button style other than
952
1017
  * Discord's four coloured ones, or a reserved role name. A group of the app's own is its to check.
953
1018
  * @param where - Where it was set, such as `themeFor.guild for guild 123`, to begin each line with.
954
- */ function themeProblems(theme, where) {
1019
+ * @param options.forLog - Shows values and role names as a log line shows text from outside the code, for a theme a
1020
+ * `themeFor` resolver returned: escaped, and a value quoted and cut short.
1021
+ */ function themeProblems(theme, where, { forLog = false } = {}) {
955
1022
  const at = where ? `${where}: ` : '';
1023
+ const quote = forLog ? quoteForLog : asWritten;
1024
+ const name = forLog ? escapeForLog : (role)=>role;
956
1025
  if (typeof theme !== 'object' || theme === null || Array.isArray(theme)) {
957
1026
  return [
958
- `${at}theme must be an object of groups (got ${describeGroup(theme)})`
1027
+ `${at}theme must be an object of groups (got ${describeGroup(theme, quote)})`
959
1028
  ];
960
1029
  }
961
1030
  if (!isPlainObject(theme)) return [
@@ -967,7 +1036,7 @@ const PLAIN = "give a plain object, such as { ...value }, or a row's toObject(),
967
1036
  const roles = theme[group];
968
1037
  if (roles === undefined) continue;
969
1038
  if (typeof roles !== 'object' || roles === null || Array.isArray(roles)) {
970
- problems.push(`${at}theme.${group} must be an object of roles (got ${describeGroup(roles)})`);
1039
+ problems.push(`${at}theme.${group} must be an object of roles (got ${describeGroup(roles, quote)})`);
971
1040
  continue;
972
1041
  }
973
1042
  if (!isPlainObject(roles)) {
@@ -975,8 +1044,8 @@ const PLAIN = "give a plain object, such as { ...value }, or a row's toObject(),
975
1044
  continue;
976
1045
  }
977
1046
  for (const [role, value] of Object.entries(roles)){
978
- if (reserved.has(role)) problems.push(`${at}theme.${group}.${role}: MeoCord reserves the role name ${role} for a role it may add; rename yours`);
979
- else if (value !== undefined && !check(value)) problems.push(`${at}theme.${group}.${role}: ${describe(value)} is not ${what}: give ${instead}`);
1047
+ if (reserved.has(role)) problems.push(`${at}theme.${group}.${name(role)}: MeoCord reserves the role name ${name(role)} for a role it may add; rename yours`);
1048
+ else if (value !== undefined && !check(value)) problems.push(`${at}theme.${group}.${name(role)}: ${describe(value, quote)} is not ${what}: give ${instead}`);
980
1049
  }
981
1050
  }
982
1051
  return problems;
@@ -1170,8 +1239,8 @@ const logger$5 = new Logger('SetMetadata');
1170
1239
  /**
1171
1240
  * Where a translator made by `createTranslator` keeps its catalogs, for MeoCord's texts, `@MeoCord` and
1172
1241
  * `expectCompleteCatalog`.
1173
- */ const CATALOGS = Symbol('catalogs');
1174
- /** How MeoCord's own texts read a translator made by `createTranslator`: the catalogs that serve a locale, in order. */ const LOCALE_CHAIN = Symbol('localeChain');
1242
+ */ const CATALOGS = Symbol.for(sharedName('translator:catalogs'));
1243
+ /** How MeoCord's own texts read a translator made by `createTranslator`: the catalogs that serve a locale, in order. */ const LOCALE_CHAIN = Symbol.for(sharedName('translator:localeChain'));
1175
1244
  /** For a class that injects `Translator` in an app that configured none. */ function missingTranslatorError(cls) {
1176
1245
  return new Error(`${cls.name}: it injects Translator, but @MeoCord has no i18n. Pass @MeoCord({ i18n: t }), where t comes from createTranslator.`);
1177
1246
  }
@@ -1218,6 +1287,22 @@ function interpolate(message, params) {
1218
1287
  }
1219
1288
  return typeof current === 'object' && current !== null;
1220
1289
  }
1290
+ /** The locale Discord shows a locale's users when that locale has no localization of its own, by its locale fallbacks. */ const FALLBACK_PARTNERS = {
1291
+ [discord_js.Locale.EnglishUS]: discord_js.Locale.EnglishGB,
1292
+ [discord_js.Locale.EnglishGB]: discord_js.Locale.EnglishUS,
1293
+ [discord_js.Locale.SpanishLATAM]: discord_js.Locale.SpanishES
1294
+ };
1295
+ /** Each key with a `.` in it, which `lookup` splits as a path and so never reaches, with the group it sits in. */ function dottedKeys(catalog, group = '') {
1296
+ return Object.entries(catalog).flatMap(([name, value])=>[
1297
+ ...name.includes('.') ? [
1298
+ {
1299
+ name,
1300
+ group
1301
+ }
1302
+ ] : [],
1303
+ ...typeof value === 'object' && value !== null ? dottedKeys(value, group ? `${group}.${name}` : name) : []
1304
+ ]);
1305
+ }
1221
1306
  class CatalogTranslator extends Translator {
1222
1307
  /** The catalogs that serve a locale, most specific first, ending with the default. */ [LOCALE_CHAIN](requested) {
1223
1308
  const chain = [];
@@ -1236,10 +1321,13 @@ class CatalogTranslator extends Translator {
1236
1321
  translate(requested, key, params = {}) {
1237
1322
  for (const locale of this[LOCALE_CHAIN](requested)){
1238
1323
  const message = lookup(this.catalogs[locale], key);
1239
- if (message !== undefined) return interpolate(pluralForm({
1324
+ if (message === undefined) continue;
1325
+ // An empty translation, as tools export an untranslated string, counts as missing: Discord refuses an empty reply
1326
+ const text = pluralForm({
1240
1327
  message,
1241
1328
  locale
1242
- }, params.count), params);
1329
+ }, params.count);
1330
+ if (text !== '') return interpolate(text, params);
1243
1331
  }
1244
1332
  this.warnUnknown(key);
1245
1333
  return key;
@@ -1269,11 +1357,16 @@ class CatalogTranslator extends Translator {
1269
1357
  for (const locale of this.locales){
1270
1358
  if (locale === this.defaultLocale) continue;
1271
1359
  const message = lookup(this.catalogs[locale], key);
1272
- // A translation with a {param} is left out: Discord would show it as written
1273
- if (typeof message === 'string' && placeholderNames(message).length === 0) {
1360
+ // A translation with a {param} is left out, as Discord would show it as written, and so is an empty one
1361
+ if (typeof message === 'string' && message !== '' && placeholderNames(message).length === 0) {
1274
1362
  localized[locale] = fillPlaceholders(message, (_name, whole)=>whole);
1275
1363
  }
1276
1364
  }
1365
+ // Discord gives a locale it falls back from its partner's value, so the default keeps its own wording beside one
1366
+ const partner = FALLBACK_PARTNERS[this.defaultLocale];
1367
+ if (partner && localized[partner] !== undefined && typeof original === 'string' && original !== '') {
1368
+ localized[this.defaultLocale] = fillPlaceholders(original, (_name, whole)=>whole);
1369
+ }
1277
1370
  return localized;
1278
1371
  }
1279
1372
  for(interaction, options = {}) {
@@ -1352,6 +1445,12 @@ class CatalogTranslator extends Translator {
1352
1445
  }
1353
1446
  }
1354
1447
  if (!locales[defaultLocale]) throw refuse(new Error(`The default locale "${defaultLocale}" has no catalog in locales.`));
1448
+ const logger = new Logger('Translator');
1449
+ for (const [locale, catalog] of Object.entries(locales)){
1450
+ for (const { name, group } of dottedKeys(catalog)){
1451
+ logger.warn(`The ${locale} catalog's key "${name}"${group ? ` in ${group}` : ''} has a ".", which a lookup reads as a path, so its ` + 'message is never found. Nest it as a group instead.');
1452
+ }
1453
+ }
1355
1454
  // Typed by the default catalog; at runtime every catalog is read the same way.
1356
1455
  return new CatalogTranslator(defaultLocale, locales);
1357
1456
  }
@@ -1456,16 +1555,17 @@ const ENGLISH = 'en-US';
1456
1555
  return english();
1457
1556
  }
1458
1557
  const lists = new Map();
1459
- /** A list in a locale's words: "a, b, or c" in en-US, or, for a unit, "a, b, c". */ function listIn(locale, style, items) {
1460
- let format = lists.get(`${locale} ${style}`);
1461
- if (!format) lists.set(`${locale} ${style}`, format = new Intl.ListFormat(locale, {
1462
- type: style === 'or' ? 'disjunction' : 'unit'
1558
+ /** An `or` list in a locale's words, such as "a, b, or c" in en-US. */ function orListIn(locale, items) {
1559
+ let format = lists.get(locale);
1560
+ if (!format) lists.set(locale, format = new Intl.ListFormat(locale, {
1561
+ type: 'disjunction'
1463
1562
  }));
1464
1563
  return format.format(items);
1465
1564
  }
1466
1565
  /**
1467
- * A text in `locale`, as {@link findText} finds it. A list in MeoCord's English is joined with its `joiner`, a comma
1468
- * unless given; in an app's catalog, in the words of the locale that has it.
1566
+ * A text in `locale`, as {@link findText} finds it. An `or` list in an app's catalog reads in the words of the locale
1567
+ * that has it; any other list is joined with its `joiner`, a comma unless given, as Intl's `unit` lists join with
1568
+ * nothing in zh-CN and with a space in ja.
1469
1569
  */ function renderText(translator, locale, text) {
1470
1570
  const found = findText(translator, locale, text.key);
1471
1571
  if (!found) return text.fallback ?? text.key;
@@ -1474,14 +1574,15 @@ const lists = new Map();
1474
1574
  if (!Object.hasOwn(params, name)) return whole;
1475
1575
  const value = params[name];
1476
1576
  if (typeof value !== 'object') return String(value);
1477
- if ('list' in value) return found.own ? listIn(found.locale, value.style, value.list) : value.list.join(value.joiner ?? ', ');
1577
+ if ('list' in value) return found.own && value.style === 'or' ? orListIn(found.locale, value.list) : value.list.join(value.joiner ?? ', ');
1478
1578
  return renderText(translator, locale, value);
1479
1579
  });
1480
1580
  }
1481
1581
  /** Renders texts in one locale through one translator: a word or number as it is, a text in the locale's words. */ function textRenderer(translator, locale) {
1482
1582
  return (text)=>typeof text !== 'object' ? String(text) : 'list' in text ? text.list.join(text.joiner ?? ', ') : renderText(translator, locale, text);
1483
1583
  }
1484
- const translators = new WeakMap();
1584
+ // Shared by both builds of this version, so either answers in the app's language
1585
+ const translators = shared('clientTranslators', ()=>new WeakMap());
1485
1586
  /** Records the translator of the app a client's interactions and messages come to. */ function registerClientTranslator(client, translator) {
1486
1587
  if (translator) translators.set(client, translator);
1487
1588
  else translators.delete(client);
@@ -1837,6 +1938,30 @@ const issueTexts = new WeakMap();
1837
1938
  this.name = 'CooldownStoreError';
1838
1939
  }
1839
1940
  }
1941
+ /**
1942
+ * Makes `instanceof` hold for an error from either build of this meocord version, ES module or CommonJS: the class's
1943
+ * prototype carries a brand both builds share, which `instanceof` on the class itself checks. A subclass, such as an
1944
+ * app's own `UserError`, keeps the usual check, so it still matches only its own instances.
1945
+ */ function brand(error, name) {
1946
+ const key = sharedKey(`error:${name}`);
1947
+ Object.defineProperty(error.prototype, key, {
1948
+ value: true
1949
+ });
1950
+ Object.defineProperty(error, Symbol.hasInstance, {
1951
+ value (value) {
1952
+ if (this !== error) return Function.prototype[Symbol.hasInstance].call(this, value);
1953
+ return (typeof value === 'object' || typeof value === 'function') && value !== null && key in value;
1954
+ }
1955
+ });
1956
+ }
1957
+ // Named as written, since a minifier may rename the classes themselves
1958
+ brand(GuardDeniedError, 'GuardDeniedError');
1959
+ brand(UserError, 'UserError');
1960
+ brand(CommandNotFoundError, 'CommandNotFoundError');
1961
+ brand(ValidationError, 'ValidationError');
1962
+ brand(MessageUsageError, 'MessageUsageError');
1963
+ brand(CooldownError, 'CooldownError');
1964
+ brand(CooldownStoreError, 'CooldownStoreError');
1840
1965
 
1841
1966
  /** Discord's codes for a body it could not read: invalid form body or JSON, or an empty message. */ const MALFORMED_BODY = new Set([
1842
1967
  discord_js.RESTJSONErrorCodes.InvalidFormBodyOrContentType,
@@ -1853,38 +1978,6 @@ const issueTexts = new WeakMap();
1853
1978
  else logger.error(`Could not ${what}:`, error);
1854
1979
  }
1855
1980
 
1856
- /** Characters a log line shows escaped: C0 and C1 controls, DEL, line and paragraph separators, and bidi controls. */ const UNSAFE_IN_LOG = /[\u0000-\u001f\u007f-\u009f\u2028\u2029\u202a-\u202e\u2066-\u2069]/g;
1857
- /** The same, with the quote and the backslash, for text shown between quotes. */ const UNSAFE_IN_QUOTES = /[\u0000-\u001f\u007f-\u009f\u2028\u2029\u202a-\u202e\u2066-\u2069"\\]/g;
1858
- const SHORT_ESCAPES = {
1859
- '\n': '\\n',
1860
- '\r': '\\r',
1861
- '\t': '\\t',
1862
- '"': '\\"',
1863
- '\\': '\\\\'
1864
- };
1865
- const escapeChar = (char)=>SHORT_ESCAPES[char] ?? `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`;
1866
- /** Text for a log line, on one line: its control characters escaped, so it can't start a line of its own. */ function escapeForLog(text) {
1867
- return text.replace(UNSAFE_IN_LOG, escapeChar);
1868
- }
1869
- /** How many characters of a user's text a log line quotes before cutting it short. */ const LOGGED_CHARACTERS = 200;
1870
- /**
1871
- * A user's text as a log line quotes it: escaped as {@link escapeForLog} does, with its quotes and backslashes too,
1872
- * and cut short after 200 characters, with its length.
1873
- */ function quoteForLog(text) {
1874
- const characters = [
1875
- ...text
1876
- ];
1877
- const quoted = (shown)=>`"${shown.replace(UNSAFE_IN_QUOTES, escapeChar)}"`;
1878
- if (characters.length <= LOGGED_CHARACTERS) return quoted(text);
1879
- return `${quoted(`${characters.slice(0, LOGGED_CHARACTERS).join('')}…`)} (${characters.length} characters)`;
1880
- }
1881
- /** Words a user wrote, as a reply quotes them: on one line, with their markdown, masked links included, shown as typed. */ function userWords(text) {
1882
- return discord_js.escapeMarkdown(text.replace(/\s+/g, ' ').trim(), {
1883
- maskedLink: true
1884
- });
1885
- }
1886
- /** Items as a sentence lists them: `a`, `a and b`, `a, b and c`. */ const listed = (items)=>items.length < 2 ? items.join('') : `${items.slice(0, -1).join(', ')} and ${items[items.length - 1]}`;
1887
-
1888
1981
  /** Whether a value is a class, which a stage entry or a `@Catch` type must be: what `new` and `instanceof` take. */ const isConstructor = (value)=>typeof value === 'function';
1889
1982
  /** A value as a refusal or a warning names it: a string quoted, `null` and `undefined` as written, else its kind. */ function describeValue(value) {
1890
1983
  if (value === null || value === undefined) return String(value);
@@ -1893,6 +1986,17 @@ const escapeChar = (char)=>SHORT_ESCAPES[char] ?? `\\u${char.charCodeAt(0).toStr
1893
1986
  return typeof value === 'object' ? 'an object' : `a ${typeof value}`;
1894
1987
  }
1895
1988
  /** A class's name after its article, such as `an AutocompleteInteraction`; a `U` name reads as "you", so takes `a`. */ const withArticle = (name)=>`${/^[AEIO]/i.test(name) ? 'an' : 'a'} ${name}`;
1989
+ /**
1990
+ * Sets `key` on `target` as an own, enumerable data property: unlike `target[key] = value`, a key named `__proto__`
1991
+ * is a key like any other rather than the object's prototype.
1992
+ */ function setOwn(target, key, value) {
1993
+ Object.defineProperty(target, key, {
1994
+ value,
1995
+ enumerable: true,
1996
+ writable: true,
1997
+ configurable: true
1998
+ });
1999
+ }
1896
2000
 
1897
2001
  /**
1898
2002
  * The discord.js interaction class each command type handles, shared by `@Command`, the dispatcher and the testing
@@ -1990,7 +2094,7 @@ const escapeChar = (char)=>SHORT_ESCAPES[char] ?? `\\u${char.charCodeAt(0).toStr
1990
2094
  walk(option.options ?? []);
1991
2095
  continue;
1992
2096
  }
1993
- params[option.name] = resolveOptionValue(option);
2097
+ setOwn(params, option.name, resolveOptionValue(option));
1994
2098
  }
1995
2099
  };
1996
2100
  walk(data);
@@ -2268,16 +2372,41 @@ function rewriteComponent(component, kept) {
2268
2372
  })
2269
2373
  };
2270
2374
  /** The id MeoCord gives the containers it renders, so a view left behind can be found again. */ const RENDERED_CONTAINER_ID = 0x4d43;
2271
- const presenters = new WeakMap();
2375
+ // Shared by both builds of this version, so either answers with the app's presenter
2376
+ const presenters = shared('clientPresenters', ()=>new WeakMap());
2272
2377
  /** Sets the presenter for the interactions a client receives. */ function setPresenter(client, presenter) {
2273
2378
  presenters.set(client, presenter);
2274
2379
  }
2275
2380
  /** The presenter for an interaction's client, or the default one. */ function presenterFor(client) {
2276
2381
  return client && presenters.get(client) || defaultPresenter;
2277
2382
  }
2278
- function textOf(view) {
2279
- return view.emoji ? `${view.emoji} ${view.text}` : view.text;
2383
+ /** Discord's limits on a message's text, counted by `String.length` as discord.js's builders count them. */ const TEXT_LIMITS = {
2384
+ embed: 4096,
2385
+ v2: 4000,
2386
+ content: 2000
2387
+ };
2388
+ /** `text` cut to `limit` characters ending in `…`, on a code point boundary so no surrogate pair is split. */ function fitText(text, limit) {
2389
+ if (text.length <= limit) return text;
2390
+ let cut = text.slice(0, Math.max(0, limit - 1));
2391
+ const last = cut.charCodeAt(cut.length - 1);
2392
+ if (last >= 0xd800 && last <= 0xdbff) cut = cut.slice(0, -1);
2393
+ return `${cut}…`;
2280
2394
  }
2395
+ /**
2396
+ * An error's message as a presenter is given it: fitted to `limit`, and, when empty or only whitespace, which Discord
2397
+ * refuses as a text, MeoCord's generic error text, so a presenter that writes it as its text always renders.
2398
+ */ function presentedMessage(message, limit, generic) {
2399
+ return fitText(message.trim() === '' ? generic() : message, limit);
2400
+ }
2401
+ /**
2402
+ * The view's text after what MeoCord puts before it, its emoji and a Text Display's title line. When only those take
2403
+ * the two past `limit`, the text is cut so they fit; a text past `limit` by itself is left, a view MeoCord can't render.
2404
+ */ function textAfter(prefix, view, limit) {
2405
+ const total = prefix.length + view.text.length;
2406
+ if (total <= limit || view.text.length > limit || prefix.length >= limit) return prefix + view.text;
2407
+ return prefix + fitText(view.text, limit - prefix.length);
2408
+ }
2409
+ const emojiOf = (view)=>view.emoji ? `${view.emoji} ` : '';
2281
2410
  /** Discord's limit of attachments on one message. */ const ATTACHMENT_LIMIT = 10;
2282
2411
  /** The most items Discord shows in one media gallery. */ const MEDIA_GALLERY_LIMIT = 10;
2283
2412
  /**
@@ -2361,7 +2490,7 @@ const IMAGE = /\.(png|jpe?g|gif|webp)$/i;
2361
2490
  return (view.files ?? []).filter((file)=>!shown.has(`attachment://${nameOf(file)}`));
2362
2491
  }
2363
2492
  /** A view as an embed: its image, or else its first image file not its thumbnail, as the embed's image. */ function renderEmbed(view) {
2364
- const embed = new discord_js.EmbedBuilder().setDescription(textOf(view));
2493
+ const embed = new discord_js.EmbedBuilder().setDescription(textAfter(emojiOf(view), view, TEXT_LIMITS.embed));
2365
2494
  if (view.title) embed.setTitle(view.title);
2366
2495
  if (view.color !== undefined) embed.setColor(view.color);
2367
2496
  const image = view.image ?? unshownFiles(view).map(nameOf).find((name)=>IMAGE.test(name));
@@ -2372,7 +2501,8 @@ const IMAGE = /\.(png|jpe?g|gif|webp)$/i;
2372
2501
  /** A view as a Components V2 container, carrying MeoCord's id. */ function renderContainer(view) {
2373
2502
  const container = new discord_js.ContainerBuilder().setId(RENDERED_CONTAINER_ID);
2374
2503
  if (view.color !== undefined) container.setAccentColor(discord_js.resolveColor(view.color));
2375
- const text = new discord_js.TextDisplayBuilder().setContent(view.title ? `### ${view.title}\n${textOf(view)}` : textOf(view));
2504
+ const titleLine = view.title ? `### ${view.title}\n` : '';
2505
+ const text = new discord_js.TextDisplayBuilder().setContent(textAfter(titleLine + emojiOf(view), view, TEXT_LIMITS.v2));
2376
2506
  if (view.thumbnail) {
2377
2507
  container.addSectionComponents(new discord_js.SectionBuilder().addTextDisplayComponents(text).setThumbnailAccessory(new discord_js.ThumbnailBuilder().setURL(urlOf(view, view.thumbnail))));
2378
2508
  } else container.addTextDisplayComponents(text);
@@ -2503,13 +2633,16 @@ const EMBED_CONTENT = [
2503
2633
  return sameJson(content(a), content(b));
2504
2634
  }
2505
2635
 
2506
- let last = 0;
2507
- const order = new WeakMap();
2636
+ // One count for both builds of this version, so calls either makes merge in the order made
2637
+ const calls = shared('callOrder', ()=>({
2638
+ last: 0,
2639
+ order: new WeakMap()
2640
+ }));
2508
2641
  /** Marks a Discord call as made now, so calls recorded in different places can be merged in the order made. */ function stampCall(call) {
2509
- order.set(call, ++last);
2642
+ calls.order.set(call, ++calls.last);
2510
2643
  }
2511
2644
  /** When a call stamped by {@link stampCall} was made, relative to the others. */ function callOrder(call) {
2512
- return order.get(call) ?? 0;
2645
+ return calls.order.get(call) ?? 0;
2513
2646
  }
2514
2647
 
2515
2648
  /**
@@ -2642,7 +2775,10 @@ const logger$4 = new Logger('Theme');
2642
2775
  */ accept(id, result, report) {
2643
2776
  if (result === undefined || result === null) return undefined;
2644
2777
  const layer = copyLayer(result);
2645
- const problems = themeProblems(layer, `themeFor.${this.kind} for ${this.kind} ${id}`);
2778
+ // A value from the app's data, such as a user's saved choice, shown as a log line shows outside text
2779
+ const problems = themeProblems(layer, `themeFor.${this.kind} for ${this.kind} ${id}`, {
2780
+ forLog: true
2781
+ });
2646
2782
  if (problems.length === 0) {
2647
2783
  if (report) this.warned.delete(id);
2648
2784
  return layer;
@@ -2889,7 +3025,8 @@ function toBody(payload) {
2889
3025
  */ function editedSince(stamp, ours) {
2890
3026
  return typeof stamp === 'number' && ours !== undefined && stamp > ours;
2891
3027
  }
2892
- const messageLocks = new Map();
3028
+ // Both builds of this meocord version keep one, so a message one locks, the other sees locked
3029
+ const messageLocks = shared('messageLocks', ()=>new Map());
2893
3030
  /** How long a locked message is remembered once no call holds it. */ const LOCK_MEMORY_MS = 60000;
2894
3031
  /**
2895
3032
  * A body with the theme's primary colour where the app left one unset: an embed with no `color`, and a
@@ -2951,9 +3088,12 @@ function toJson(value) {
2951
3088
  return this.phase;
2952
3089
  }
2953
3090
  get message() {
3091
+ // A deleted answer, or a follow-up in its place, leaves the component's own message behind
3092
+ if (this.answer.kind !== 'original') return this.lastMessage;
2954
3093
  return this.lastMessage ?? ('message' in this.interaction ? this.interaction.message ?? undefined : undefined);
2955
3094
  }
2956
3095
  get original() {
3096
+ // Each component is a top-level component's API JSON, as `toJSON()` gave it; the lock reads them as plain JSON
2957
3097
  return this.snapshot;
2958
3098
  }
2959
3099
  /** The Discord calls made through this state, for tests. */ get history() {
@@ -3285,9 +3425,11 @@ function toJson(value) {
3285
3425
  this.sync();
3286
3426
  const body = await this.themed(payload, options);
3287
3427
  if (this.phase === 'unanswered') return this.reply(body);
3288
- if (this.phase === 'deferred' && answersWithOwnMessage(this.interaction)) {
3428
+ // Once the answer is deleted, the next message sent is the answer
3429
+ if (this.answer.kind === 'deleted') return this.answerWith(body);
3430
+ if (this.phase === 'deferred' && answersWithOwnMessage(this.interaction) && this.answer.kind === 'original') {
3289
3431
  // Discord makes a follow-up to a deferred, unsent reply that reply, ignoring its flags: a private one
3290
- // would be shown to everyone on a public deferral, so that deferral is deleted first.
3432
+ // would be shown to everyone on a public deferral, so that deferral is deleted, and the follow-up is the answer.
3291
3433
  const requested = Number(discord_js.MessageFlagsBitField.resolve(body.flags ?? 0));
3292
3434
  if (!hasEphemeral(requested)) return this.editMessage(body);
3293
3435
  // A private deferral is already what the flag asks for, which an edit cannot take
@@ -3297,7 +3439,11 @@ function toJson(value) {
3297
3439
  });
3298
3440
  await this.call('deleteReply', undefined, ()=>this.interaction.deleteReply());
3299
3441
  this.phase = 'replied';
3442
+ return this.answerWith(body);
3300
3443
  }
3444
+ return (await this.sendFollowUp(body)).message;
3445
+ }
3446
+ /** Sends a follow-up, with the flags it was sent with. */ async sendFollowUp(body) {
3301
3447
  const flags = this.withSuppression(this.flagsFor('followUp', body.flags, false));
3302
3448
  const sent = forMode(body, hasComponentsV2(flags));
3303
3449
  const message = await this.call('followUp', {
@@ -3307,6 +3453,20 @@ function toJson(value) {
3307
3453
  ...sent,
3308
3454
  flags
3309
3455
  }));
3456
+ return {
3457
+ message: message,
3458
+ flags
3459
+ };
3460
+ }
3461
+ /** Sends a follow-up that becomes the answer, which later edits and `delete()` act on by its id. */ async answerWith(body) {
3462
+ const { message, flags } = await this.sendFollowUp(body);
3463
+ // Its privacy as sent, not as read back from the message
3464
+ this.answer = {
3465
+ kind: 'followUp',
3466
+ id: message.id,
3467
+ ephemeral: hasEphemeral(flags)
3468
+ };
3469
+ this.lastMessage = message;
3310
3470
  return message;
3311
3471
  }
3312
3472
  delete() {
@@ -3314,7 +3474,22 @@ function toJson(value) {
3314
3474
  this.sync();
3315
3475
  if (this.phase === 'unanswered') throw new Error('There is no answer to delete: the interaction has not been answered.');
3316
3476
  this.refuseAfterModal();
3317
- await this.call('deleteReply', undefined, ()=>this.interaction.deleteReply());
3477
+ const { answer } = this;
3478
+ if (answer.kind === 'deleted') throw new Error('There is no answer to delete: it was deleted.');
3479
+ // A follow-up is as private as it was sent, a command's own reply as the interaction's answer, a component's
3480
+ // message as its flags
3481
+ const shown = this.message?.flags?.has(discord_js.MessageFlags.Ephemeral) ?? false;
3482
+ const ephemeral = answer.kind === 'followUp' ? answer.ephemeral : answersWithOwnMessage(this.interaction) ? Boolean(this.interaction.ephemeral) || shown : shown;
3483
+ if (answer.kind === 'followUp') await this.call('deleteReply', answer.id, ()=>this.interaction.deleteReply(answer.id));
3484
+ else await this.call('deleteReply', undefined, ()=>this.interaction.deleteReply());
3485
+ this.answer = {
3486
+ kind: 'deleted',
3487
+ ephemeral
3488
+ };
3489
+ this.lastMessage = undefined;
3490
+ // The message is gone: nothing is left to put back as it was before a lock
3491
+ this.settled = true;
3492
+ this.leave();
3318
3493
  });
3319
3494
  }
3320
3495
  /** A modal is the interaction's whole answer: what the user enters arrives as a submit interaction of its own. */ refuseAfterModal() {
@@ -3424,7 +3599,20 @@ function toJson(value) {
3424
3599
  withAttachments(body) {
3425
3600
  return rewriteAttachmentUrls(body, keptAttachmentNames(body, this.message?.attachments?.values() ?? []));
3426
3601
  }
3427
- /** Edits the answer through the interaction, and through the channel only once its token has expired. */ async editMessage(body, { restoring = false } = {}) {
3602
+ /**
3603
+ * Edits the answer through the interaction, and through the channel only once its token has expired. Once the answer
3604
+ * is deleted, the edit is sent as a follow-up that becomes the answer, as private as the deleted one unless it sets
3605
+ * flags of its own.
3606
+ */ async editMessage(body, { restoring = false } = {}) {
3607
+ const { answer } = this;
3608
+ if (answer.kind === 'deleted') {
3609
+ if (restoring) return undefined;
3610
+ const { attachments: _attachments, ...rest } = body;
3611
+ return this.answerWith({
3612
+ ...rest,
3613
+ flags: body.flags ?? (answer.ephemeral ? discord_js.MessageFlags.Ephemeral : 0)
3614
+ });
3615
+ }
3428
3616
  if (!restoring) {
3429
3617
  this.settled = true;
3430
3618
  this.leave();
@@ -3446,8 +3634,13 @@ function toJson(value) {
3446
3634
  ...this.withAttachments(forMode(body, this.v2)),
3447
3635
  flags
3448
3636
  };
3637
+ // A follow-up that is the answer is edited by its id
3638
+ const edit = answer.kind === 'followUp' ? {
3639
+ ...sent,
3640
+ message: answer.id
3641
+ } : sent;
3449
3642
  try {
3450
- this.lastMessage = await this.call('editReply', sent, ()=>this.interaction.editReply(sent));
3643
+ this.lastMessage = await this.call('editReply', edit, ()=>this.interaction.editReply(edit));
3451
3644
  } catch (error) {
3452
3645
  const message = this.message;
3453
3646
  const expired = Date.now() - this.interaction.createdTimestamp >= TOKEN_EXPIRED_AFTER_MS;
@@ -3566,12 +3759,16 @@ function toJson(value) {
3566
3759
  * place, and the failure is returned for the caller to report once the user is answered.
3567
3760
  */ async drawnView(error, message, theme) {
3568
3761
  const tone = isUserOutcome(error, this.interaction) ? 'warning' : 'danger';
3762
+ const context = this.presenterContext(this.v2, theme);
3763
+ // Fitted to the mode's limit first, so a presenter that writes the message as its text, MeoCord's own included, renders
3764
+ const generic = ()=>textFor(this.interaction, {
3765
+ key: 'meocord.fallback.error'
3766
+ });
3569
3767
  const presented = {
3570
- message,
3768
+ message: presentedMessage(message, TEXT_LIMITS[context.mode], generic),
3571
3769
  error,
3572
3770
  tone
3573
3771
  };
3574
- const context = this.presenterContext(this.v2, theme);
3575
3772
  const ready = (view)=>{
3576
3773
  const sendable = this.sendable(themedView(view, theme), this.keptBeside());
3577
3774
  // Rendered once here, so a view MeoCord cannot render fails before anything is sent
@@ -3714,6 +3911,11 @@ function toJson(value) {
3714
3911
  await this.followUpNow(this.privateError(view));
3715
3912
  return;
3716
3913
  }
3914
+ // A deleted message has nothing to add the error to or put back: the error follows up, and is the answer
3915
+ if (this.answer.kind === 'deleted') {
3916
+ await this.followUpNow(this.privateError(view));
3917
+ return;
3918
+ }
3717
3919
  // The message the component is on: whether it is private does not change with edits.
3718
3920
  const current = 'message' in this.interaction ? this.interaction.message ?? undefined : this.message;
3719
3921
  if (current?.flags?.has(discord_js.MessageFlags.Ephemeral)) {
@@ -3759,6 +3961,12 @@ function toJson(value) {
3759
3961
  /** The answer steps asked for, run one after another; `pending` counts those not yet settled. */ this.queue = Promise.resolve();
3760
3962
  this.pending = 0;
3761
3963
  /** Whether the answer was a modal, which has no message to edit or delete. */ this.modalShown = false;
3964
+ /**
3965
+ * Which message is the answer: the interaction's own reply, a follow-up that took its place, or none since `delete()`,
3966
+ * with whether the deleted one was private. `sync()` leaves it alone, so every edit follows it.
3967
+ */ this.answer = {
3968
+ kind: 'original'
3969
+ };
3762
3970
  this.calls = [];
3763
3971
  /** Whether the interaction was acknowledged with a private deferred reply for a drawn error view, which that view replaces. */ this.privateDeferral = false;
3764
3972
  /** Whether the locked message was answered, so nothing restores it again. */ this.settled = false;
@@ -3770,7 +3978,8 @@ function toJson(value) {
3770
3978
  this.v2 = Boolean(message?.flags?.has(discord_js.MessageFlags.IsComponentsV2));
3771
3979
  }
3772
3980
  }
3773
- const states = new WeakMap();
3981
+ // One state per interaction for both builds of this version, so either sees where the other left its answer
3982
+ const states = shared('responseStates', ()=>new WeakMap());
3774
3983
  /** The response state of a repliable interaction, created on first use. */ function responseOf(interaction) {
3775
3984
  let state = states.get(interaction);
3776
3985
  if (!state) states.set(interaction, state = new InteractionResponse(interaction));
@@ -4240,9 +4449,17 @@ const logger$2 = new Logger('ExecutionContext');
4240
4449
  };
4241
4450
  });
4242
4451
  }
4243
- /** Drops the keys whose calls have all left their window, once a minute from the store's first call. */ startSweeping() {
4452
+ /**
4453
+ * Drops the keys whose calls have all left their window, once a minute from a call, until a sweep leaves none: an
4454
+ * idle store then holds no timer, so one nothing references is collected, and its next call starts the sweep again.
4455
+ */ startSweeping() {
4244
4456
  if (this.sweeper) return;
4245
- this.sweeper = setInterval(()=>sweep(this.calls, Date.now()), SWEEP_INTERVAL_MS);
4457
+ this.sweeper = setInterval(()=>{
4458
+ sweep(this.calls, Date.now());
4459
+ if (this.calls.size > 0) return;
4460
+ clearInterval(this.sweeper);
4461
+ this.sweeper = undefined;
4462
+ }, SWEEP_INTERVAL_MS);
4246
4463
  // A cooldown must never be what keeps a stopping bot alive.
4247
4464
  this.sweeper.unref?.();
4248
4465
  }
@@ -4539,6 +4756,21 @@ const messageOf = (error)=>String(error?.message ?? error);
4539
4756
  };
4540
4757
  }
4541
4758
 
4759
+ /**
4760
+ * Gives a decorator's `wrapper` the `length` and `name` of the method it replaces, so whatever reads the handler, such
4761
+ * as `invoke` counting the arguments it takes, sees the method as it was declared.
4762
+ */ function wrapsMethod(wrapper, original) {
4763
+ Object.defineProperty(wrapper, 'length', {
4764
+ value: original.length,
4765
+ configurable: true
4766
+ });
4767
+ Object.defineProperty(wrapper, 'name', {
4768
+ value: original.name,
4769
+ configurable: true
4770
+ });
4771
+ return wrapper;
4772
+ }
4773
+
4542
4774
  /**
4543
4775
  * How specific a route pattern is, for ranking the message patterns that can match one message: more literal
4544
4776
  * words first, then a fixed length before one taking the rest, then no optional param before one, then
@@ -4719,9 +4951,9 @@ const autocompleteRoute = (method, commandPath, optionName)=>({
4719
4951
  // An entry the class takes from its base, for a method it re-declares
4720
4952
  const inheritedFor = (inherited, method)=>(entry)=>inherited.includes(entry) && redeclared.has(method(entry));
4721
4953
  const commands = ownCommandMap(prototype);
4722
- const baseCommands = Reflect.getMetadata(META.commands, base) ?? {};
4954
+ const baseCommands = Reflect.getMetadata(META.commands, base) ?? Object.create(null);
4723
4955
  for (const [name, metas] of Object.entries(commands)){
4724
- const kept = metas.filter((meta)=>!inheritedFor(baseCommands[name] ?? [], (entry)=>entry.methodName)(meta));
4956
+ const kept = metas.filter((meta)=>!inheritedFor(commandsNamed(baseCommands, name), (entry)=>entry.methodName)(meta));
4725
4957
  if (kept.length > 0) commands[name] = kept;
4726
4958
  else delete commands[name];
4727
4959
  }
@@ -4745,16 +4977,21 @@ const autocompleteRoute = (method, commandPath, optionName)=>({
4745
4977
  Reflect.defineMetadata(key, ownHandlerList(key, prototype).filter((entry)=>!inheritedFor(inherited, method)(entry)), prototype);
4746
4978
  }
4747
4979
  }
4748
- /** The class's own command map, started from a copy of the inherited one, for the same reason. */ function ownCommandMap(target) {
4980
+ /**
4981
+ * The class's own command map, started from a copy of the inherited one, for the same reason. Keyed by command name
4982
+ * without a prototype, so a command named like an inherited key, such as `constructor`, is a key like any other.
4983
+ */ function ownCommandMap(target) {
4749
4984
  const own = Reflect.getOwnMetadata(META.commands, target);
4750
4985
  if (own) return own;
4751
4986
  const inherited = Reflect.getMetadata(META.commands, target) ?? {};
4752
- return Object.fromEntries(Object.entries(inherited).map(([name, metas])=>[
4753
- name,
4754
- [
4755
- ...metas
4756
- ]
4757
- ]));
4987
+ const copy = Object.create(null);
4988
+ for (const [name, metas] of Object.entries(inherited))copy[name] = [
4989
+ ...metas
4990
+ ];
4991
+ return copy;
4992
+ }
4993
+ /** The handlers a command map holds for `name`: its own entry only, never one `Object.prototype` holds. */ function commandsNamed(map, name) {
4994
+ return map && Object.hasOwn(map, name) ? map[name] : [];
4758
4995
  }
4759
4996
  function MessageHandler(pattern, options = {}) {
4760
4997
  return declaring(function(target, propertyKey) {
@@ -4819,12 +5056,24 @@ function ReactionHandler(emojiOrSettings, settings = {}) {
4819
5056
  }
4820
5057
  // `{name}`, or `{name:type}` with the type read as far as the brace, so a type no segment can hold is named
4821
5058
  const PLACEHOLDER_PATTERN = /\{(\w+)(?::([^}/]*))?}/g;
5059
+ // The whole of a brace pair that is a param, as PLACEHOLDER_PATTERN reads one
5060
+ const WHOLE_PLACEHOLDER = new RegExp(`^${PLACEHOLDER_PATTERN.source}$`);
5061
+ /**
5062
+ * Why a pattern's brace pairs that aren't params are matched as literal text, or undefined when it has none: a
5063
+ * param's name is ASCII letters, digits and _, so `{café}` is text.
5064
+ */ function literalBraces(pattern) {
5065
+ const braces = (pattern.match(/\{[^{}]*}/g) ?? []).filter((brace)=>!WHOLE_PLACEHOLDER.test(brace));
5066
+ if (braces.length === 0) return undefined;
5067
+ const one = braces.length === 1;
5068
+ return `${braces.join(', ')} ${one ? "isn't a param" : "aren't params"}, since a param's name is ASCII letters, digits and _; ` + `${one ? 'it is' : 'they are'} matched as literal text.`;
5069
+ }
4822
5070
  /** The character a parameter will not cross, so one pattern segment maps to one value. */ const PARAM_SEPARATOR = '/';
5071
+ /** The longest customId Discord accepts; the shortest is one character. */ const MAX_CUSTOM_ID_LENGTH = 100;
4823
5072
  /** Escapes a literal stretch of a pattern so only placeholders stay meaningful. */ const escapeLiteral = (literal)=>literal.replace(/[/\\^$*+?.()|[\]{}]/g, '\\$&');
4824
5073
  /**
4825
- * Compiles a pattern into a regex, its parameter names, their types and its 4.1 specificity. A `{name}` takes a
4826
- * whole `/`-separated segment, so a uuid is captured whole and `profile/{uuid}` never overlaps
4827
- * `profile/{uuid}/{id}`; a param that shares a segment with literal text is refused.
5074
+ * Compiles a pattern into a regex, its parameter names, their types, its 4.1 specificity and the length of the
5075
+ * shortest customId it matches. A `{name}` takes a whole `/`-separated segment, so a uuid is captured whole and
5076
+ * `profile/{uuid}` never overlaps `profile/{uuid}/{id}`; a param that shares a segment with literal text is refused.
4828
5077
  */ function createRegexFromPattern(pattern) {
4829
5078
  const params = [];
4830
5079
  // By the param's own name, so one named like an inherited key, such as `__proto__`, keeps its type
@@ -4845,6 +5094,13 @@ const PLACEHOLDER_PATTERN = /\{(\w+)(?::([^}/]*))?}/g;
4845
5094
  if (literal !== '' && !literal.endsWith(PARAM_SEPARATOR) || after !== undefined && after !== PARAM_SEPARATOR) {
4846
5095
  throw refuse(new Error(`Invalid pattern "${pattern}": {${param}} must occupy a whole segment, so it has to be ` + `preceded and followed by "${PARAM_SEPARATOR}" or by the ends of the pattern. ` + `Write "a${PARAM_SEPARATOR}{${param}}" rather than "a-{${param}}".`));
4847
5096
  }
5097
+ // A regex group's name, which the engine would refuse with the compiled regex in place of the pattern
5098
+ if (params.includes(param)) {
5099
+ throw refuse(new Error(`Invalid pattern "${pattern}": {${param}} appears twice; give each param its own name.`));
5100
+ }
5101
+ if (/^\d/.test(param)) {
5102
+ throw refuse(new Error(`Invalid pattern "${pattern}": {${param}} starts with a digit; start a param's name with a letter or _.`));
5103
+ }
4848
5104
  if (type !== undefined) {
4849
5105
  if (!isSegmentType(type)) throw refuse(new Error(`Invalid pattern "${pattern}": ${segmentTypeProblem(param, type)}`));
4850
5106
  if (choicesOf(type)?.includes('')) {
@@ -4868,11 +5124,13 @@ const PLACEHOLDER_PATTERN = /\{(\w+)(?::([^}/]*))?}/g;
4868
5124
  params: params.length,
4869
5125
  typed: Object.values(types).reduce((sum, type)=>sum + (choicesOf(type) ? 4 : type === 'bool' ? 3 : type === 'int' ? 2 : 1), 0)
4870
5126
  });
5127
+ // Each param takes at least one character
4871
5128
  return {
4872
5129
  regex,
4873
5130
  params,
4874
5131
  types: lookupTable(types),
4875
- specificity
5132
+ specificity,
5133
+ shortest: literalLength + params.length
4876
5134
  };
4877
5135
  }
4878
5136
  /** Why a `{name:type}` cannot type a customId segment, which holds text the bot wrote, with no message to read. */ function segmentTypeProblem(param, type) {
@@ -4942,7 +5200,6 @@ const PLACEHOLDER_PATTERN = /\{(\w+)(?::([^}/]*))?}/g;
4942
5200
  */ function Command(name, builderOrType) {
4943
5201
  const commandName = typeof name === 'string' ? name : name.pattern;
4944
5202
  return declaring(function(target, propertyKey, _descriptor) {
4945
- var _commands, _commandName;
4946
5203
  refuseOnClass('@Command', target, propertyKey);
4947
5204
  const originalMethod = _descriptor.value;
4948
5205
  if (!originalMethod) {
@@ -4950,7 +5207,7 @@ const PLACEHOLDER_PATTERN = /\{(\w+)(?::([^}/]*))?}/g;
4950
5207
  }
4951
5208
  // Wrap original method for interaction type validation
4952
5209
  const declaredAs = typeof builderOrType === 'function' ? builderOrType.name : `CommandType.${builderOrType}`;
4953
- _descriptor.value = function(interaction, params) {
5210
+ const checked = function(interaction, params) {
4954
5211
  if (!matchesCommandType(commandType, interaction)) {
4955
5212
  throw new Error(`${target.constructor.name}.${propertyKey}: @Command('${commandName}', ${declaredAs}) takes ` + `${interactionClassName(commandType)}${givenInstead(interaction)}.`);
4956
5213
  }
@@ -4959,6 +5216,7 @@ const PLACEHOLDER_PATTERN = /\{(\w+)(?::([^}/]*))?}/g;
4959
5216
  params
4960
5217
  ]);
4961
5218
  };
5219
+ _descriptor.value = wrapsMethod(checked, originalMethod);
4962
5220
  // This class's own map, inherited routes included
4963
5221
  const commands = ownCommandMap(target);
4964
5222
  let builderInstance;
@@ -4994,7 +5252,7 @@ const PLACEHOLDER_PATTERN = /\{(\w+)(?::([^}/]*))?}/g;
4994
5252
  }));
4995
5253
  }
4996
5254
  // discord.js builders validate as they are set, and their errors name neither the command nor the field.
4997
- throw refuse(new Error(`${where}: ${builderOrType.name} could not build "${commandName}": ${detail}. Check its names, descriptions and ` + `localizations, which Discord limits to 32 and 100 characters.`, {
5255
+ throw refuse(new Error(`${where}: ${builderOrType.name} could not build "${commandName}": ${detail}. Check its names, descriptions and ` + 'localizations, which Discord limits to 32 characters for a name and 100 for a description. A name is letters, ' + 'numbers, `-` and `_`, lowercase where the script has case, and without spaces, in every locale.', {
4998
5256
  cause: error
4999
5257
  }));
5000
5258
  }
@@ -5014,6 +5272,9 @@ const PLACEHOLDER_PATTERN = /\{(\w+)(?::([^}/]*))?}/g;
5014
5272
  commandType = builderOrType;
5015
5273
  }
5016
5274
  if (isCustomIdRouted(commandType)) {
5275
+ // A route warned as it was made
5276
+ const braces = typeof name === 'string' ? literalBraces(commandName) : undefined;
5277
+ if (braces) logger.warn(`${target.constructor.name}.${propertyKey}: in the pattern "${commandName}", ${braces}`);
5017
5278
  let pattern;
5018
5279
  try {
5019
5280
  pattern = createRegexFromPattern(commandName);
@@ -5025,6 +5286,11 @@ const PLACEHOLDER_PATTERN = /\{(\w+)(?::([^}/]*))?}/g;
5025
5286
  regex = pattern.regex;
5026
5287
  dynamicParams = pattern.params;
5027
5288
  specificity = pattern.specificity;
5289
+ // Warned rather than refused, so a bot that carries such a handler still starts
5290
+ if (pattern.shortest === 0 || pattern.shortest > MAX_CUSTOM_ID_LENGTH) {
5291
+ const length = pattern.shortest === 0 ? 'the pattern is empty' : `its shortest is ${pattern.shortest} characters`;
5292
+ logger.warn(`${target.constructor.name}.${propertyKey}: the pattern "${commandName}" matches no customId: Discord's customIds ` + `are 1 to ${MAX_CUSTOM_ID_LENGTH} characters, and ${length}, so the handler never runs.`);
5293
+ }
5028
5294
  }
5029
5295
  const declared = {
5030
5296
  methodName: propertyKey,
@@ -5041,8 +5307,9 @@ const PLACEHOLDER_PATTERN = /\{(\w+)(?::([^}/]*))?}/g;
5041
5307
  }
5042
5308
  };
5043
5309
  // One the class inherits for this method and route is replaced where it stands, so this one's options apply
5044
- const metas = (_commands = commands)[_commandName = commandName] ?? (_commands[_commandName] = []);
5045
- const inherited = getCommandMap(Object.getPrototypeOf(target))?.[commandName] ?? [];
5310
+ if (!Object.hasOwn(commands, commandName)) commands[commandName] = [];
5311
+ const metas = commands[commandName];
5312
+ const inherited = commandsNamed(getCommandMap(Object.getPrototypeOf(target)), commandName);
5046
5313
  const replaced = metas.findIndex((meta)=>inherited.includes(meta) && meta.methodName === propertyKey && meta.type === commandType);
5047
5314
  if (replaced === -1) metas.push(declared);
5048
5315
  else metas[replaced] = declared;
@@ -5224,6 +5491,7 @@ exports.ExecutionContext = ExecutionContext;
5224
5491
  exports.GuardDeniedError = GuardDeniedError;
5225
5492
  exports.HandlerExecutionContext = HandlerExecutionContext;
5226
5493
  exports.Logger = Logger;
5494
+ exports.MAX_CUSTOM_ID_LENGTH = MAX_CUSTOM_ID_LENGTH;
5227
5495
  exports.MEOCORD_MESSAGES = MEOCORD_MESSAGES;
5228
5496
  exports.META = META;
5229
5497
  exports.MemoryCooldownStore = MemoryCooldownStore;
@@ -5233,6 +5501,7 @@ exports.REFUSED_AS_TOO_LARGE = REFUSED_AS_TOO_LARGE;
5233
5501
  exports.ReactionHandler = ReactionHandler;
5234
5502
  exports.RedisCooldownStore = RedisCooldownStore;
5235
5503
  exports.SetMetadata = SetMetadata;
5504
+ exports.TEXT_LIMITS = TEXT_LIMITS;
5236
5505
  exports.ThemeCache = ThemeCache;
5237
5506
  exports.Translator = Translator;
5238
5507
  exports.UnroutedExecutionContext = UnroutedExecutionContext;
@@ -5252,6 +5521,7 @@ exports.callOrder = callOrder;
5252
5521
  exports.choicesOf = choicesOf;
5253
5522
  exports.claimAmbientTheme = claimAmbientTheme;
5254
5523
  exports.collectStartupErrors = collectStartupErrors;
5524
+ exports.commandsNamed = commandsNamed;
5255
5525
  exports.compiledConfigMessage = compiledConfigMessage;
5256
5526
  exports.cooldownMessage = cooldownMessage;
5257
5527
  exports.cooldownStoreMessage = cooldownStoreMessage;
@@ -5272,6 +5542,7 @@ exports.dropInheritedRoutes = dropInheritedRoutes;
5272
5542
  exports.duration = duration;
5273
5543
  exports.escapeForLog = escapeForLog;
5274
5544
  exports.existingResponse = existingResponse;
5545
+ exports.fitText = fitText;
5275
5546
  exports.focusedOptionName = focusedOptionName;
5276
5547
  exports.forgetRecorded = forgetRecorded;
5277
5548
  exports.getAutocompleteHandlers = getAutocompleteHandlers;
@@ -5295,6 +5566,7 @@ exports.isRefusal = isRefusal;
5295
5566
  exports.isTooLarge = isTooLarge;
5296
5567
  exports.isUserOutcome = isUserOutcome;
5297
5568
  exports.listed = listed;
5569
+ exports.literalBraces = literalBraces;
5298
5570
  exports.loadMeoCordConfig = loadMeoCordConfig;
5299
5571
  exports.logFailedSend = logFailedSend;
5300
5572
  exports.logStartupErrors = logStartupErrors;
@@ -5334,10 +5606,13 @@ exports.routeSpecificity = routeSpecificity;
5334
5606
  exports.runInThemeScope = runInThemeScope;
5335
5607
  exports.runWithTheme = runWithTheme;
5336
5608
  exports.setLegacyThemeLayer = setLegacyThemeLayer;
5609
+ exports.setOwn = setOwn;
5337
5610
  exports.setPresenter = setPresenter;
5338
5611
  exports.setStartupErrorsMode = setStartupErrorsMode;
5339
5612
  exports.setThemeOutsideCalls = setThemeOutsideCalls;
5613
+ exports.shared = shared;
5340
5614
  exports.sharedCustomId = sharedCustomId;
5615
+ exports.sharedName = sharedName;
5341
5616
  exports.stampCall = stampCall;
5342
5617
  exports.startupError = startupError;
5343
5618
  exports.stopOnStartupErrors = stopOnStartupErrors;
@@ -5360,3 +5635,4 @@ exports.warnPlaceholder = warnPlaceholder;
5360
5635
  exports.withRelease = withRelease;
5361
5636
  exports.withSendableFiles = withSendableFiles;
5362
5637
  exports.withoutFiles = withoutFiles;
5638
+ exports.wrapsMethod = wrapsMethod;