meocord 4.2.0 → 4.2.2

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 (103) hide show
  1. package/CHANGELOG.md +322 -0
  2. package/THIRD_PARTY_NOTICES.md +14 -13
  3. package/dist/cjs/_shared/{controller.decorator-CRn9kWob.cjs → controller.decorator-U2NBWcoU.cjs} +416 -108
  4. package/dist/cjs/_shared/{providers-ChvMqnLd.cjs → providers-B4MOFBrW.cjs} +202 -50
  5. package/dist/cjs/_shared/{sharded-cooldown-store-BdAUTdYs.cjs → sharded-cooldown-store-HTUb3Ii_.cjs} +10 -7
  6. package/dist/cjs/_shared/{startup-roots-CQo8mSP8.cjs → startup-roots-lA5xkBFq.cjs} +228 -71
  7. package/dist/cjs/_shared/{translate-error-DhDVbT5M.cjs → translate-error-CbBHUu5P.cjs} +10 -5
  8. package/dist/cjs/common/index.cjs +3 -3
  9. package/dist/cjs/core/index.cjs +188 -169
  10. package/dist/cjs/decorator/index.cjs +52 -7
  11. package/dist/cjs/testing/index.cjs +1854 -311
  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 +89 -15
  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 +52 -11
  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/dispatcher.js +9 -8
  37. package/dist/esm/core/fallback.js +20 -11
  38. package/dist/esm/core/filter-runner.js +2 -2
  39. package/dist/esm/core/guard-runner.js +16 -3
  40. package/dist/esm/core/handler-input.js +4 -3
  41. package/dist/esm/core/handler-pipeline.js +10 -4
  42. package/dist/esm/core/lifecycle-hooks.js +134 -47
  43. package/dist/esm/core/meocord-factory.js +59 -34
  44. package/dist/esm/core/meocord.app.js +116 -75
  45. package/dist/esm/core/message-help.js +32 -6
  46. package/dist/esm/core/observer-runner.js +2 -1
  47. package/dist/esm/core/providers.js +19 -4
  48. package/dist/esm/core/running-calls.js +43 -0
  49. package/dist/esm/core/shard-context.js +5 -6
  50. package/dist/esm/core/shard-manager.js +16 -10
  51. package/dist/esm/core/stage-scope.js +2 -1
  52. package/dist/esm/core/startup-roots.js +21 -1
  53. package/dist/esm/core/theme-resolvers.js +4 -1
  54. package/dist/esm/core/theme-scope.js +7 -2
  55. package/dist/esm/core/theme-validation.js +17 -10
  56. package/dist/esm/decorator/app.decorator.js +31 -0
  57. package/dist/esm/decorator/controller.decorator.js +56 -20
  58. package/dist/esm/decorator/cooldown.decorator.js +17 -0
  59. package/dist/esm/decorator/filter.decorator.js +2 -2
  60. package/dist/esm/decorator/guard.decorator.js +3 -2
  61. package/dist/esm/package.json.js +1 -1
  62. package/dist/esm/testing/catalog.js +20 -13
  63. package/dist/esm/testing/cooldown-store-suite.js +67 -5
  64. package/dist/esm/testing/discord-defaults.js +225 -30
  65. package/dist/esm/testing/meocord-testing-module.js +158 -44
  66. package/dist/esm/testing/mock-fn.js +107 -26
  67. package/dist/esm/testing/mock-interaction.js +1250 -182
  68. package/dist/esm/testing/snowflake.js +9 -2
  69. package/dist/esm/testing/strict-mocks.js +40 -17
  70. package/dist/esm/util/common.util.js +47 -19
  71. package/dist/esm/util/dev-runner.util.js +8 -8
  72. package/dist/esm/util/generator-cli.util.js +4 -5
  73. package/dist/esm/util/interaction.util.js +2 -2
  74. package/dist/esm/util/meocord-config-loader.util.js +10 -2
  75. package/dist/esm/util/meocord-config-validation.util.js +3 -5
  76. package/dist/esm/util/meocord-source-config.util.js +65 -20
  77. package/dist/esm/util/method-wrapper.util.js +16 -0
  78. package/dist/esm/util/platform.util.js +10 -10
  79. package/dist/esm/util/shard-process.util.js +4 -1
  80. package/dist/esm/util/sharding-mode.util.js +5 -7
  81. package/dist/esm/util/shared-state.util.js +21 -0
  82. package/dist/esm/util/tsconfig-paths.util.js +29 -0
  83. package/dist/esm/util/tsconfig.util.js +10 -10
  84. package/dist/esm/util/value.util.js +30 -2
  85. package/dist/types/common/index.d.cts +10 -7
  86. package/dist/types/common/index.d.ts +10 -7
  87. package/dist/types/core/index.d.cts +3 -2
  88. package/dist/types/core/index.d.ts +3 -2
  89. package/dist/types/decorator/index.d.cts +1 -0
  90. package/dist/types/decorator/index.d.ts +1 -0
  91. package/dist/types/{index-CdXsFgjE.d.ts → index-DKJclIi-.d.ts} +45 -21
  92. package/dist/types/{index-nn5p7OoX.d.cts → index-Dlf0ZZtS.d.cts} +45 -21
  93. package/dist/types/interface/index.d.cts +1 -1
  94. package/dist/types/interface/index.d.ts +1 -1
  95. package/dist/types/{route-BZgFoNeB.d.cts → route-Bh1U13yy.d.cts} +3 -2
  96. package/dist/types/{route-BZgFoNeB.d.ts → route-Bh1U13yy.d.ts} +3 -2
  97. package/dist/types/testing/index.d.cts +198 -62
  98. package/dist/types/testing/index.d.ts +198 -62
  99. package/dist/types/{theme-resolvers-UKr3-6Ys.d.cts → theme-resolvers-CyEz9pnv.d.cts} +1 -1
  100. package/dist/types/{theme-resolvers-BcOJVzR9.d.ts → theme-resolvers-DA204O-q.d.ts} +1 -1
  101. package/dist/types/{translator-DHMkkCpg.d.cts → translator-Bg7hwxDF.d.cts} +21 -8
  102. package/dist/types/{translator-BViWKtLJ.d.ts → translator-CeP4bCzp.d.ts} +21 -8
  103. package/package.json +2 -2
@@ -4,24 +4,22 @@ import './theme-scope.js';
4
4
  import './theme-validation.js';
5
5
  import '../common/decorator.js';
6
6
  import 'reflect-metadata';
7
- import '../common/translator.js';
8
- import '../common/response/send-failure.js';
9
- import '../util/interaction.util.js';
10
- import '../common/response/flags.js';
11
- import 'path';
12
- import '../common/response/components.js';
7
+ import '../common/response/response-state.js';
8
+ import '../common/errors.js';
13
9
  import { MemoryCooldownStore } from '../common/cooldown-store.js';
14
10
  import 'node:crypto';
15
11
  import '../decorator/controller.decorator.js';
16
12
  import './scalar-types.js';
17
13
  import { answerCooldown } from '../common/sharded-cooldown-store.js';
14
+ import '../common/translator.js';
15
+ import '../common/meocord-text.js';
18
16
  import { registerCommands } from './command-registration.js';
19
17
  import { SHUTDOWN_MARGIN_MS, shutdownTimeoutOf } from '../util/shutdown-timeout.util.js';
20
18
  import { bundleEntry } from '../util/bundle-entry.util.js';
21
19
  import { FORCE_REGISTER_ENV } from '../util/registration-mode.util.js';
22
20
  import { isShardMessage, REFUSED_CODE } from './shard-messages.js';
23
21
  import { tokenMessage, isRefusedToken } from './login-failure.js';
24
- import { onDevRunnerStop, underDevRunner, tellDevRunner } from '../util/dev-runner.util.js';
22
+ import { onDevRunnerStop, tellDevRunner, underDevRunner } from '../util/dev-runner.util.js';
25
23
  import { stopRequests } from '../util/stop-request.util.js';
26
24
 
27
25
  /** How long the manager waits for a shard to become ready before spawning the next. */ const SHARD_READY_TIMEOUT_MS = 30000;
@@ -60,7 +58,7 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
60
58
  process.on('SIGINT', ()=>void this.stopAndExit());
61
59
  process.on('SIGTERM', ()=>void this.stopAndExit());
62
60
  // How `meocord start --dev` stops the bot to restart it; no stop request of the user's, whose first Ctrl+C joins it
63
- onDevRunnerStop(()=>void this.exitAfterShutdown());
61
+ onDevRunnerStop(()=>void this.exitAfterShutdown(), false);
64
62
  let total;
65
63
  try {
66
64
  const shards = config.sharding?.shards ?? 'auto';
@@ -77,6 +75,10 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
77
75
  const manager = (this.options.createManager ?? ((path, opts)=>new ShardingManager(path, opts)))(file, {
78
76
  token,
79
77
  totalShards: total,
78
+ // discord.js refuses a call to every shard unless shardList counts the shards; createShard leaves it 'auto'
79
+ shardList: [
80
+ ...Array(total).keys()
81
+ ],
80
82
  mode: 'process',
81
83
  execArgv: process.execArgv,
82
84
  respawn: false
@@ -173,12 +175,12 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
173
175
  void this.exitForLogin();
174
176
  }
175
177
  /** Exits 1 for a bot that could not log in, telling `meocord start --dev` first, which then waits for a change. */ exitForLogin() {
176
- if (!underDevRunner()) return this.exit(1);
178
+ if (!this.devRunner) return this.exit(1);
177
179
  // Set first, so the process exits 1 even if it ends while the message is still on its way
178
180
  process.exitCode = 1;
179
181
  return tellDevRunner({
180
182
  meocord: 'login-failed'
181
- }).then(()=>this.exit(1));
183
+ }, false).then(()=>this.exit(1));
182
184
  }
183
185
  /**
184
186
  * Asks every shard to shut down through its own hooks, waits for them up to the shutdown timeout plus a margin, and
@@ -255,6 +257,10 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
255
257
  /** Every shard's cooldown calls, for an app whose store is ShardedCooldownStore. */ this.cooldowns = new MemoryCooldownStore();
256
258
  this.stopping = false;
257
259
  this.fatal = false;
260
+ /**
261
+ * Whether `meocord start --dev` runs the manager, decided before discord.js's ShardingManager, which writes the
262
+ * variable that marks a shard into this process too.
263
+ */ this.devRunner = underDevRunner(false);
258
264
  // The manager holds the credential for its shards and logs their failures, without an app of its own to register it
259
265
  hideInLogs(options.token);
260
266
  this.exit = options.exit ?? ((code)=>process.exit(code));
@@ -46,7 +46,8 @@ import { META } from '../util/metadata-keys.js';
46
46
  }
47
47
  }
48
48
  /** The class of a stage entry. */ function stageClass(entry) {
49
- return isConstructor(entry) ? entry : entry.provide;
49
+ // Entries were checked as they were declared, so a function is the class itself
50
+ return typeof entry === 'function' ? entry : entry.provide;
50
51
  }
51
52
  /** The context types a stage declared, or `undefined` when it runs for every type. */ function stageTypes(entry) {
52
53
  return Reflect.getMetadata(META.stageTypes, stageClass(entry));
@@ -3,6 +3,8 @@ import { injectedTokens, isGuardWithParams } from './guard-runner.js';
3
3
  import { classDecorators, handlerStageClasses } from './handler-pipeline.js';
4
4
  import { reachableClasses, assertTypedParameters } from './providers.js';
5
5
  import { themeResolverClass } from './theme-resolvers.js';
6
+ import { isAppClassToken } from './lifecycle-order.js';
7
+ import { ExecutionContext } from '../common/execution-context.js';
6
8
  import { META } from '../util/metadata-keys.js';
7
9
  import { startupError } from '../util/refusal.util.js';
8
10
 
@@ -57,6 +59,24 @@ import { startupError } from '../util/refusal.util.js';
57
59
  for (const [stage, decorator] of found)decorators.set(stage, decorator);
58
60
  }
59
61
  }
62
+ /** Whether making `cls` reaches the per-call `ExecutionContext` through the classes it injects, which each call makes. */ function reachesContext(cls, providers, seen = new Set()) {
63
+ seen.add(cls);
64
+ return injectedTokens(cls).some((token)=>token === ExecutionContext || isAppClassToken(token) && !providers.has(token) && !seen.has(token) && reachesContext(token, providers, seen));
65
+ }
66
+ /**
67
+ * The classes the app's stages and presenter inject, which they would make only at their first call. Made with the
68
+ * app's own classes, a service among them gets its lifecycle hooks. One that reaches the call's `ExecutionContext` is
69
+ * made for each call, so it is left out, and a stage a test replaces is not among the stages, so what it would inject
70
+ * is not made.
71
+ */ function stageDependencies({ decorators }, providers, presenter) {
72
+ const stages = [
73
+ ...decorators
74
+ ].filter(([, decorator])=>decorator !== '@Controller()').map(([stage])=>stage);
75
+ if (typeof presenter === 'function') stages.push(presenter);
76
+ return [
77
+ ...new Set(stages.flatMap((stage)=>injectedTokens(stage)))
78
+ ].filter(isAppClassToken).filter((cls)=>!reachesContext(cls, providers));
79
+ }
60
80
  /** How a container checks a guard first met at a call: the guard and what it injects, as the startup checks would. */ function lateGuardCheck(providers, translator) {
61
81
  return (guard)=>assertStartupClasses({
62
82
  classes: reachableClasses([
@@ -78,4 +98,4 @@ import { startupError } from '../util/refusal.util.js';
78
98
  for (const needsOne of classes.filter((cls)=>injectedTokens(cls).includes(Translator)))startupError(missingTranslatorError(needsOne));
79
99
  }
80
100
 
81
- export { assertStartupClasses, lateGuardCheck, startupClasses };
101
+ export { assertStartupClasses, lateGuardCheck, stageDependencies, startupClasses };
@@ -118,7 +118,10 @@ const logger = new Logger('Theme');
118
118
  */ accept(id, result, report) {
119
119
  if (result === undefined || result === null) return undefined;
120
120
  const layer = copyLayer(result);
121
- const problems = themeProblems(layer, `themeFor.${this.kind} for ${this.kind} ${id}`);
121
+ // A value from the app's data, such as a user's saved choice, shown as a log line shows outside text
122
+ const problems = themeProblems(layer, `themeFor.${this.kind} for ${this.kind} ${id}`, {
123
+ forLog: true
124
+ });
122
125
  if (problems.length === 0) {
123
126
  if (report) this.warned.delete(id);
124
127
  return layer;
@@ -1,7 +1,9 @@
1
1
  import { AsyncLocalStorage } from 'node:async_hooks';
2
2
  import { DEFAULT_THEME } from './theme-defaults.js';
3
+ import { shared } from '../util/shared-state.util.js';
3
4
 
4
- const scope = new AsyncLocalStorage();
5
+ // One per process for this meocord version, so a call's theme reads the same from its ES module and CommonJS builds
6
+ const scope = shared('themeScope', ()=>new AsyncLocalStorage());
5
7
  const isPlainObject = (value)=>{
6
8
  if (value === null || typeof value !== 'object') return false;
7
9
  const prototype = Object.getPrototypeOf(value);
@@ -170,6 +172,9 @@ let themeOutsideCalls;
170
172
  * Such a listener runs in its emitter's context, so without it a `useTheme()` inside reads the theme outside any call.
171
173
  * Timers and promises the handler starts keep its theme without it.
172
174
  *
175
+ * The theme is the binding call's whole theme, its server's and user's themes included, so every later call, whoever
176
+ * it is for, is answered in the starting member's theme.
177
+ *
173
178
  * @param fn - The function to run in the call's theme; `this` and its arguments are passed through.
174
179
  * @returns A function that runs `fn` in the theme `useTheme()` returns where `bindTheme` is called.
175
180
  *
@@ -179,7 +184,7 @@ let themeOutsideCalls;
179
184
  * async poll(message: Message) {
180
185
  * const collector = message.createMessageComponentCollector({ componentType: ComponentType.Button, time: 60_000 })
181
186
  * collector.on('collect', bindTheme(async (click: ButtonInteraction) => {
182
- * const { emojis } = useTheme() // the handler's theme, @UseTheme included
187
+ * const { emojis } = useTheme() // the starting member's theme, @UseTheme included
183
188
  * await click.reply(`${emojis.success} Picked`)
184
189
  * }))
185
190
  * }
@@ -1,5 +1,6 @@
1
1
  import { ButtonStyle, Colors } from 'discord.js';
2
2
  import { refuse } from '../util/refusal.util.js';
3
+ import { escapeForLog, quoteForLog } from '../util/user-text.util.js';
3
4
 
4
5
  /**
5
6
  * The role names MeoCord keeps for roles it may add to `colors`, `emojis` and `buttons`: the runtime copy of
@@ -25,9 +26,10 @@ const EMOJI = 'a unicode emoji, or a custom one written <:name:id> or <a:name:id
25
26
  const BUTTON = 'ButtonStyle.Primary, Secondary, Success or Danger';
26
27
  /** A custom emoji as Discord writes one in text: its name, 2 to 32 word characters, and its id. */ const CUSTOM_EMOJI = /^<a?:\w{2,32}:\d{17,20}>$/;
27
28
  /**
28
- * One unicode emoji: a flag of two regional indicators, a keycap, or a pictograph with an optional presentation
29
- * selector or skin tone, joined to others by zero-width joiners into one sequence.
30
- */ const UNICODE_EMOJI = /^(?:\p{Regional_Indicator}{2}|[#*0-9]️?⃣|\p{Extended_Pictographic}(?:️|\p{Emoji_Modifier})?(?:‍\p{Extended_Pictographic}(?:️|\p{Emoji_Modifier})?)*)$/u;
29
+ * One unicode emoji: a flag of two regional indicators, a subdivision flag (a black flag, tag characters and a cancel
30
+ * tag, as England's), a keycap, or a pictograph with an optional presentation selector or skin tone, joined to others by
31
+ * zero-width joiners into one sequence.
32
+ */ 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;
31
33
  const byte = (value)=>Number.isInteger(value) && value >= 0 && value <= 255;
32
34
  /** Whether discord.js resolves the value to a colour Discord takes. Stricter on tuples, whose channels it only sums. */ function isColour(value) {
33
35
  if (typeof value === 'string') return /^#?[\da-f]{6}$/i.test(value) || value === 'Random' || value === 'Default' || Object.hasOwn(Colors, value);
@@ -42,8 +44,9 @@ const THEMED_BUTTON_STYLES = [
42
44
  ButtonStyle.Danger
43
45
  ];
44
46
  const isButtonStyle = (value)=>THEMED_BUTTON_STYLES.includes(value);
45
- 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);
46
- const describeGroup = (value)=>Array.isArray(value) ? 'an array' : describe(value);
47
+ const asWritten = (text)=>`'${text}'`;
48
+ 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);
49
+ const describeGroup = (value, quote = asWritten)=>Array.isArray(value) ? 'an array' : describe(value, quote);
47
50
  /** 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)=>{
48
51
  const prototype = Object.getPrototypeOf(value);
49
52
  return prototype === Object.prototype || prototype === null;
@@ -75,11 +78,15 @@ const PLAIN = "give a plain object, such as { ...value }, or a row's toObject(),
75
78
  * roles an app added, a colour discord.js cannot resolve, an emoji Discord would refuse, a button style other than
76
79
  * Discord's four coloured ones, or a reserved role name. A group of the app's own is its to check.
77
80
  * @param where - Where it was set, such as `themeFor.guild for guild 123`, to begin each line with.
78
- */ function themeProblems(theme, where) {
81
+ * @param options.forLog - Shows values and role names as a log line shows text from outside the code, for a theme a
82
+ * `themeFor` resolver returned: escaped, and a value quoted and cut short.
83
+ */ function themeProblems(theme, where, { forLog = false } = {}) {
79
84
  const at = where ? `${where}: ` : '';
85
+ const quote = forLog ? quoteForLog : asWritten;
86
+ const name = forLog ? escapeForLog : (role)=>role;
80
87
  if (typeof theme !== 'object' || theme === null || Array.isArray(theme)) {
81
88
  return [
82
- `${at}theme must be an object of groups (got ${describeGroup(theme)})`
89
+ `${at}theme must be an object of groups (got ${describeGroup(theme, quote)})`
83
90
  ];
84
91
  }
85
92
  if (!isPlainObject(theme)) return [
@@ -91,7 +98,7 @@ const PLAIN = "give a plain object, such as { ...value }, or a row's toObject(),
91
98
  const roles = theme[group];
92
99
  if (roles === undefined) continue;
93
100
  if (typeof roles !== 'object' || roles === null || Array.isArray(roles)) {
94
- problems.push(`${at}theme.${group} must be an object of roles (got ${describeGroup(roles)})`);
101
+ problems.push(`${at}theme.${group} must be an object of roles (got ${describeGroup(roles, quote)})`);
95
102
  continue;
96
103
  }
97
104
  if (!isPlainObject(roles)) {
@@ -99,8 +106,8 @@ const PLAIN = "give a plain object, such as { ...value }, or a row's toObject(),
99
106
  continue;
100
107
  }
101
108
  for (const [role, value] of Object.entries(roles)){
102
- if (reserved.has(role)) problems.push(`${at}theme.${group}.${role}: MeoCord reserves the role name ${role} for a role it may add; rename yours`);
103
- else if (value !== undefined && !check(value)) problems.push(`${at}theme.${group}.${role}: ${describe(value)} is not ${what}: give ${instead}`);
109
+ 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`);
110
+ else if (value !== undefined && !check(value)) problems.push(`${at}theme.${group}.${name(role)}: ${describe(value, quote)} is not ${what}: give ${instead}`);
104
111
  }
105
112
  }
106
113
  return problems;
@@ -9,6 +9,7 @@ import { assertValidTheme, themeForProblem } from '../core/theme-validation.js';
9
9
  import { copyLayer } from '../core/theme-scope.js';
10
10
  import { declaring, deprecatedOnMethod, refuse } from '../util/refusal.util.js';
11
11
  import { timeoutProblem } from '../util/timer-limit.util.js';
12
+ import { isConstructor, describeValue } from '../util/value.util.js';
12
13
  import { META } from '../util/metadata-keys.js';
13
14
 
14
15
  /** Refuses a `messages` option of the wrong type where the app is declared, rather than at the first message. */ function assertMessageOptions(appName, messages, i18n) {
@@ -52,6 +53,35 @@ import { META } from '../util/metadata-keys.js';
52
53
  if (type.labelKey !== undefined) assertLabelKey(appName, name, type.labelKey, i18n);
53
54
  }
54
55
  }
56
+ /**
57
+ * Refuses an option that takes a class given anything else, such as an object or an arrow function, where the app is
58
+ * declared: the startup checks read each class's prototype, and would otherwise fail on it without naming the option.
59
+ */ function assertClassOptions(appName, { presenter, cooldownStore, controllers, services }) {
60
+ for (const [name, value, implementing] of [
61
+ [
62
+ 'presenter',
63
+ presenter,
64
+ 'ResponsePresenter'
65
+ ],
66
+ [
67
+ 'cooldownStore',
68
+ cooldownStore,
69
+ 'CooldownStore'
70
+ ]
71
+ ]){
72
+ if (value !== undefined && !isConstructor(value)) {
73
+ throw refuse(new TypeError(`${appName}: @MeoCord({ ${name} }) takes a class implementing ${implementing}, not ${describeValue(value)}.`));
74
+ }
75
+ }
76
+ for (const controller of controllers ?? []){
77
+ if (!isConstructor(controller)) throw refuse(new TypeError(`${appName}: @MeoCord({ controllers }) takes classes, not ${describeValue(controller)}.`));
78
+ }
79
+ // A string or symbol is a token, which a provider has to provide; that is checked as the app is created
80
+ for (const service of services ?? []){
81
+ if (isConstructor(service) || typeof service === 'string' || typeof service === 'symbol') continue;
82
+ throw refuse(new TypeError(`${appName}: @MeoCord({ services }) takes classes, or tokens that providers provide, not ${describeValue(service)}.`));
83
+ }
84
+ }
55
85
  /** Refuses a type's `labelKey` that no message of the app's default catalog has, as it would be shown as the key. */ function assertLabelKey(appName, name, labelKey, i18n) {
56
86
  const where = `${appName}: @MeoCord({ messages: { types } }): "${name}" has labelKey`;
57
87
  if (typeof labelKey !== 'string') throw refuse(new TypeError(`${where} ${String(labelKey)}; it takes a message key, such as 'types.${name}'.`));
@@ -96,6 +126,7 @@ import { META } from '../util/metadata-keys.js';
96
126
  */ function MeoCord(options) {
97
127
  return declaring((target, propertyKey)=>{
98
128
  if (deprecatedOnMethod('@MeoCord', target, propertyKey)) return;
129
+ assertClassOptions(target.name, options);
99
130
  assertStageEntries('@MeoCord({ guards })', 'guard', target.name, options.guards ?? []);
100
131
  assertStageEntries('@MeoCord({ interceptors })', 'interceptor', target.name, options.interceptors ?? []);
101
132
  assertStageEntries('@MeoCord({ filters })', 'filter', target.name, options.filters ?? []);
@@ -2,7 +2,8 @@ import 'reflect-metadata';
2
2
  import { UserContextMenuCommandInteraction, ApplicationCommandType, MessageContextMenuCommandInteraction } from 'discord.js';
3
3
  import { CommandType } from '../enum/controller.enum.js';
4
4
  import { MetadataKey } from '../enum/metadata-key.enum.js';
5
- import { matchesCommandType, interactionClassName, isCustomIdRouted } from '../util/interaction.util.js';
5
+ import { isCustomIdRouted, matchesCommandType, interactionClassName } from '../util/interaction.util.js';
6
+ import { wrapsMethod } from '../util/method-wrapper.util.js';
6
7
  import { warnDeprecatedBehaviour } from '../common/deprecation.js';
7
8
  import { Logger } from '../common/logger.js';
8
9
  import { routeSpecificity } from '../core/route-specificity.js';
@@ -79,9 +80,9 @@ const autocompleteRoute = (method, commandPath, optionName)=>({
79
80
  // An entry the class takes from its base, for a method it re-declares
80
81
  const inheritedFor = (inherited, method)=>(entry)=>inherited.includes(entry) && redeclared.has(method(entry));
81
82
  const commands = ownCommandMap(prototype);
82
- const baseCommands = Reflect.getMetadata(META.commands, base) ?? {};
83
+ const baseCommands = Reflect.getMetadata(META.commands, base) ?? Object.create(null);
83
84
  for (const [name, metas] of Object.entries(commands)){
84
- const kept = metas.filter((meta)=>!inheritedFor(baseCommands[name] ?? [], (entry)=>entry.methodName)(meta));
85
+ const kept = metas.filter((meta)=>!inheritedFor(commandsNamed(baseCommands, name), (entry)=>entry.methodName)(meta));
85
86
  if (kept.length > 0) commands[name] = kept;
86
87
  else delete commands[name];
87
88
  }
@@ -105,16 +106,21 @@ const autocompleteRoute = (method, commandPath, optionName)=>({
105
106
  Reflect.defineMetadata(key, ownHandlerList(key, prototype).filter((entry)=>!inheritedFor(inherited, method)(entry)), prototype);
106
107
  }
107
108
  }
108
- /** The class's own command map, started from a copy of the inherited one, for the same reason. */ function ownCommandMap(target) {
109
+ /**
110
+ * The class's own command map, started from a copy of the inherited one, for the same reason. Keyed by command name
111
+ * without a prototype, so a command named like an inherited key, such as `constructor`, is a key like any other.
112
+ */ function ownCommandMap(target) {
109
113
  const own = Reflect.getOwnMetadata(META.commands, target);
110
114
  if (own) return own;
111
115
  const inherited = Reflect.getMetadata(META.commands, target) ?? {};
112
- return Object.fromEntries(Object.entries(inherited).map(([name, metas])=>[
113
- name,
114
- [
115
- ...metas
116
- ]
117
- ]));
116
+ const copy = Object.create(null);
117
+ for (const [name, metas] of Object.entries(inherited))copy[name] = [
118
+ ...metas
119
+ ];
120
+ return copy;
121
+ }
122
+ /** The handlers a command map holds for `name`: its own entry only, never one `Object.prototype` holds. */ function commandsNamed(map, name) {
123
+ return map && Object.hasOwn(map, name) ? map[name] : [];
118
124
  }
119
125
  function MessageHandler(pattern, options = {}) {
120
126
  return declaring(function(target, propertyKey) {
@@ -179,12 +185,24 @@ function ReactionHandler(emojiOrSettings, settings = {}) {
179
185
  }
180
186
  // `{name}`, or `{name:type}` with the type read as far as the brace, so a type no segment can hold is named
181
187
  const PLACEHOLDER_PATTERN = /\{(\w+)(?::([^}/]*))?}/g;
188
+ // The whole of a brace pair that is a param, as PLACEHOLDER_PATTERN reads one
189
+ const WHOLE_PLACEHOLDER = new RegExp(`^${PLACEHOLDER_PATTERN.source}$`);
190
+ /**
191
+ * Why a pattern's brace pairs that aren't params are matched as literal text, or undefined when it has none: a
192
+ * param's name is ASCII letters, digits and _, so `{café}` is text.
193
+ */ function literalBraces(pattern) {
194
+ const braces = (pattern.match(/\{[^{}]*}/g) ?? []).filter((brace)=>!WHOLE_PLACEHOLDER.test(brace));
195
+ if (braces.length === 0) return undefined;
196
+ const one = braces.length === 1;
197
+ 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.`;
198
+ }
182
199
  /** The character a parameter will not cross, so one pattern segment maps to one value. */ const PARAM_SEPARATOR = '/';
200
+ /** The longest customId Discord accepts; the shortest is one character. */ const MAX_CUSTOM_ID_LENGTH = 100;
183
201
  /** Escapes a literal stretch of a pattern so only placeholders stay meaningful. */ const escapeLiteral = (literal)=>literal.replace(/[/\\^$*+?.()|[\]{}]/g, '\\$&');
184
202
  /**
185
- * Compiles a pattern into a regex, its parameter names, their types and its 4.1 specificity. A `{name}` takes a
186
- * whole `/`-separated segment, so a uuid is captured whole and `profile/{uuid}` never overlaps
187
- * `profile/{uuid}/{id}`; a param that shares a segment with literal text is refused.
203
+ * Compiles a pattern into a regex, its parameter names, their types, its 4.1 specificity and the length of the
204
+ * shortest customId it matches. A `{name}` takes a whole `/`-separated segment, so a uuid is captured whole and
205
+ * `profile/{uuid}` never overlaps `profile/{uuid}/{id}`; a param that shares a segment with literal text is refused.
188
206
  */ function createRegexFromPattern(pattern) {
189
207
  const params = [];
190
208
  // By the param's own name, so one named like an inherited key, such as `__proto__`, keeps its type
@@ -205,6 +223,13 @@ const PLACEHOLDER_PATTERN = /\{(\w+)(?::([^}/]*))?}/g;
205
223
  if (literal !== '' && !literal.endsWith(PARAM_SEPARATOR) || after !== undefined && after !== PARAM_SEPARATOR) {
206
224
  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}}".`));
207
225
  }
226
+ // A regex group's name, which the engine would refuse with the compiled regex in place of the pattern
227
+ if (params.includes(param)) {
228
+ throw refuse(new Error(`Invalid pattern "${pattern}": {${param}} appears twice; give each param its own name.`));
229
+ }
230
+ if (/^\d/.test(param)) {
231
+ throw refuse(new Error(`Invalid pattern "${pattern}": {${param}} starts with a digit; start a param's name with a letter or _.`));
232
+ }
208
233
  if (type !== undefined) {
209
234
  if (!isSegmentType(type)) throw refuse(new Error(`Invalid pattern "${pattern}": ${segmentTypeProblem(param, type)}`));
210
235
  if (choicesOf(type)?.includes('')) {
@@ -228,11 +253,13 @@ const PLACEHOLDER_PATTERN = /\{(\w+)(?::([^}/]*))?}/g;
228
253
  params: params.length,
229
254
  typed: Object.values(types).reduce((sum, type)=>sum + (choicesOf(type) ? 4 : type === 'bool' ? 3 : type === 'int' ? 2 : 1), 0)
230
255
  });
256
+ // Each param takes at least one character
231
257
  return {
232
258
  regex,
233
259
  params,
234
260
  types: lookupTable(types),
235
- specificity
261
+ specificity,
262
+ shortest: literalLength + params.length
236
263
  };
237
264
  }
238
265
  /** Why a `{name:type}` cannot type a customId segment, which holds text the bot wrote, with no message to read. */ function segmentTypeProblem(param, type) {
@@ -302,7 +329,6 @@ const PLACEHOLDER_PATTERN = /\{(\w+)(?::([^}/]*))?}/g;
302
329
  */ function Command(name, builderOrType) {
303
330
  const commandName = typeof name === 'string' ? name : name.pattern;
304
331
  return declaring(function(target, propertyKey, _descriptor) {
305
- var _commands, _commandName;
306
332
  refuseOnClass('@Command', target, propertyKey);
307
333
  const originalMethod = _descriptor.value;
308
334
  if (!originalMethod) {
@@ -310,7 +336,7 @@ const PLACEHOLDER_PATTERN = /\{(\w+)(?::([^}/]*))?}/g;
310
336
  }
311
337
  // Wrap original method for interaction type validation
312
338
  const declaredAs = typeof builderOrType === 'function' ? builderOrType.name : `CommandType.${builderOrType}`;
313
- _descriptor.value = function(interaction, params) {
339
+ const checked = function(interaction, params) {
314
340
  if (!matchesCommandType(commandType, interaction)) {
315
341
  throw new Error(`${target.constructor.name}.${propertyKey}: @Command('${commandName}', ${declaredAs}) takes ` + `${interactionClassName(commandType)}${givenInstead(interaction)}.`);
316
342
  }
@@ -319,6 +345,7 @@ const PLACEHOLDER_PATTERN = /\{(\w+)(?::([^}/]*))?}/g;
319
345
  params
320
346
  ]);
321
347
  };
348
+ _descriptor.value = wrapsMethod(checked, originalMethod);
322
349
  // This class's own map, inherited routes included
323
350
  const commands = ownCommandMap(target);
324
351
  let builderInstance;
@@ -354,7 +381,7 @@ const PLACEHOLDER_PATTERN = /\{(\w+)(?::([^}/]*))?}/g;
354
381
  }));
355
382
  }
356
383
  // discord.js builders validate as they are set, and their errors name neither the command nor the field.
357
- 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.`, {
384
+ 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.', {
358
385
  cause: error
359
386
  }));
360
387
  }
@@ -374,6 +401,9 @@ const PLACEHOLDER_PATTERN = /\{(\w+)(?::([^}/]*))?}/g;
374
401
  commandType = builderOrType;
375
402
  }
376
403
  if (isCustomIdRouted(commandType)) {
404
+ // A route warned as it was made
405
+ const braces = typeof name === 'string' ? literalBraces(commandName) : undefined;
406
+ if (braces) logger.warn(`${target.constructor.name}.${propertyKey}: in the pattern "${commandName}", ${braces}`);
377
407
  let pattern;
378
408
  try {
379
409
  pattern = createRegexFromPattern(commandName);
@@ -385,6 +415,11 @@ const PLACEHOLDER_PATTERN = /\{(\w+)(?::([^}/]*))?}/g;
385
415
  regex = pattern.regex;
386
416
  dynamicParams = pattern.params;
387
417
  specificity = pattern.specificity;
418
+ // Warned rather than refused, so a bot that carries such a handler still starts
419
+ if (pattern.shortest === 0 || pattern.shortest > MAX_CUSTOM_ID_LENGTH) {
420
+ const length = pattern.shortest === 0 ? 'the pattern is empty' : `its shortest is ${pattern.shortest} characters`;
421
+ 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.`);
422
+ }
388
423
  }
389
424
  const declared = {
390
425
  methodName: propertyKey,
@@ -401,8 +436,9 @@ const PLACEHOLDER_PATTERN = /\{(\w+)(?::([^}/]*))?}/g;
401
436
  }
402
437
  };
403
438
  // One the class inherits for this method and route is replaced where it stands, so this one's options apply
404
- const metas = (_commands = commands)[_commandName = commandName] ?? (_commands[_commandName] = []);
405
- const inherited = getCommandMap(Object.getPrototypeOf(target))?.[commandName] ?? [];
439
+ if (!Object.hasOwn(commands, commandName)) commands[commandName] = [];
440
+ const metas = commands[commandName];
441
+ const inherited = commandsNamed(getCommandMap(Object.getPrototypeOf(target)), commandName);
406
442
  const replaced = metas.findIndex((meta)=>inherited.includes(meta) && meta.methodName === propertyKey && meta.type === commandType);
407
443
  if (replaced === -1) metas.push(declared);
408
444
  else metas[replaced] = declared;
@@ -572,4 +608,4 @@ const narrowness = (type)=>choicesOf(type) ? 6 : NARROWNESS[type];
572
608
  return values.every((value)=>value !== undefined) ? values.join(PARAM_SEPARATOR) : undefined;
573
609
  }
574
610
 
575
- export { Autocomplete, Command, MessageHandler, PARAM_SEPARATOR, ReactionHandler, createRegexFromPattern, dropInheritedRoutes, getAutocompleteHandlers, getCommandMap, getDeclaredRoutes, getHandlerRoutes, getMessageHandlers, getReactionHandlers, matchesEmoji, ownHandlerList, paramNarrowness, patternShape, sharedCustomId };
611
+ export { Autocomplete, Command, MAX_CUSTOM_ID_LENGTH, MessageHandler, PARAM_SEPARATOR, ReactionHandler, commandsNamed, createRegexFromPattern, dropInheritedRoutes, getAutocompleteHandlers, getCommandMap, getDeclaredRoutes, getHandlerRoutes, getMessageHandlers, getReactionHandlers, literalBraces, matchesEmoji, ownHandlerList, paramNarrowness, patternShape, sharedCustomId };
@@ -1,5 +1,7 @@
1
1
  import 'reflect-metadata';
2
+ import { Logger } from '../common/logger.js';
2
3
  import { declaring, refuse, decoratedName } from '../util/refusal.util.js';
4
+ import { describeValue } from '../util/value.util.js';
3
5
  import { META } from '../util/metadata-keys.js';
4
6
 
5
7
  function Cooldown(options) {
@@ -14,6 +16,7 @@ function Cooldown(options) {
14
16
  // Checked where it applies, so the refusal names the handler or controller
15
17
  const problem = cooldownProblem(seconds, uses, per, by);
16
18
  if (problem) throw refuse(new Error(`${decoratedName(target, propertyKey)}: ${problem}`));
19
+ for (const warning of cooldownWarnings(options))logger.warn(`${decoratedName(target, propertyKey)}: ${warning}`);
17
20
  // Decorators apply bottom-up; prepending keeps them in the order they read.
18
21
  if (propertyKey === undefined) {
19
22
  const existing = Reflect.getOwnMetadata(META.classCooldowns, target) ?? [];
@@ -34,6 +37,20 @@ function Cooldown(options) {
34
37
  * The longest window, in seconds: half the furthest time a `Date` holds, 8.64e15 ms, so the moment a wait ends, now
35
38
  * plus the window, is a real date for a refusal's `retryAt` and the time it shows.
36
39
  */ const MAX_SECONDS = 4320000000000;
40
+ const logger = new Logger('Cooldown');
41
+ /**
42
+ * What a cooldown's options get wrong that the types refuse, yet still counts: a `bypass` that is not a function, which
43
+ * fails every call, and a `seconds` that is not a number, read as one. A falsy `bypass` means none.
44
+ */ function cooldownWarnings({ seconds, bypass }) {
45
+ const warnings = [];
46
+ if (bypass && typeof bypass !== 'function') {
47
+ warnings.push(`@Cooldown takes bypass as a function of the call, returning whether the call skips the cooldown, not ${describeValue(bypass)}; ` + 'every call it counts fails with it. In the next major version (5.0) it is refused.');
48
+ }
49
+ if (typeof seconds !== 'number') {
50
+ warnings.push(`@Cooldown takes seconds as a number, not ${describeValue(seconds)}, which it counts as a window of ` + `${Math.round(seconds * 1000)} ms. In the next major version (5.0) it is refused.`);
51
+ }
52
+ return warnings;
53
+ }
37
54
  /** Why a cooldown's options cannot be counted, or undefined when they can. */ function cooldownProblem(seconds, uses, per, by) {
38
55
  if (!(seconds >= 0.001 && seconds <= MAX_SECONDS)) return `@Cooldown needs a number of seconds from 0.001 to ${MAX_SECONDS}, not ${seconds}.`;
39
56
  if (!Number.isInteger(uses) || uses < 1) return `@Cooldown needs a whole number of uses of at least 1, not ${uses}.`;
@@ -3,7 +3,7 @@ import { makeInjectable } from '../util/injectable.util.js';
3
3
  import { assertStageEntries } from '../core/stage-scope.js';
4
4
  import { Logger } from '../common/logger.js';
5
5
  import { warnDeprecatedBehaviour } from '../common/deprecation.js';
6
- import { isConstructor, describeValue } from '../util/value.util.js';
6
+ import { isConstructible, describeValue } from '../util/value.util.js';
7
7
  import { META } from '../util/metadata-keys.js';
8
8
  import { declaring, refuseOnMethod } from '../util/refusal.util.js';
9
9
 
@@ -40,7 +40,7 @@ import { declaring, refuseOnMethod } from '../util/refusal.util.js';
40
40
  refuseOnMethod('@Catch', target, propertyKey);
41
41
  makeInjectable(target);
42
42
  errorTypes.forEach((type, index)=>{
43
- if (isConstructor(type)) return;
43
+ if (isConstructible(type)) return;
44
44
  const entry = ORDINALS[index] ? `${ORDINALS[index]} entry` : `entry ${index + 1}`;
45
45
  warnDeprecatedBehaviour(logger, `${target.name}: @Catch's ${entry}, ${describeValue(type)}, which matches no error,`, 'is refused', 'an error class, such as @Catch(CooldownError),');
46
46
  });
@@ -1,6 +1,7 @@
1
1
  import 'reflect-metadata';
2
2
  import { handlerMethods, classLevelGuards, consumeDispatchMark, runDirectCall, declaringPrototype, classChain } from '../core/guard-runner.js';
3
3
  import { makeInjectable } from '../util/injectable.util.js';
4
+ import { wrapsMethod } from '../util/method-wrapper.util.js';
4
5
  import { defineStageTypes, assertStageEntries } from '../core/stage-scope.js';
5
6
  import { META } from '../util/metadata-keys.js';
6
7
  import { declaring, deprecatedOnMethod } from '../util/refusal.util.js';
@@ -34,10 +35,10 @@ import { declaring, deprecatedOnMethod } from '../util/refusal.util.js';
34
35
  * which has already run the guards, passes through every wrapper.
35
36
  */ function applyGuards(descriptor, prototype, propertyKey) {
36
37
  const originalMethod = descriptor.value;
37
- descriptor.value = async function(...args) {
38
+ descriptor.value = wrapsMethod(async function(...args) {
38
39
  if (consumeDispatchMark(args[0], this, propertyKey)) return originalMethod.apply(this, args);
39
40
  return runDirectCall(this, propertyKey, args, ()=>originalMethod.apply(this, args));
40
- };
41
+ }, originalMethod);
41
42
  const wrappers = Reflect.getOwnMetadata(META.guardWrappers, prototype, propertyKey) ?? 0;
42
43
  Reflect.defineMetadata(META.guardWrappers, wrappers + 1, prototype, propertyKey);
43
44
  }
@@ -1,4 +1,4 @@
1
- var version = "4.2.0";
1
+ var version = "4.2.2";
2
2
  var engines = {
3
3
  node: ">=22.13"
4
4
  };