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
@@ -1,5 +1,5 @@
1
1
  import { createHash } from 'crypto';
2
- import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'fs';
2
+ import { existsSync, readFileSync, mkdirSync, writeFileSync, rmSync } from 'fs';
3
3
  import path from 'path';
4
4
  import { ApplicationCommandType, Routes } from 'discord.js';
5
5
  import CliTable3 from 'cli-table3';
@@ -103,19 +103,35 @@ const scopeKey = (scope)=>scope === 'global' ? 'global' : `guild-${scope.guild}`
103
103
  const guilds = options.config?.guilds ?? [];
104
104
  return !everythingTarget(options) && guilds.length > 0 && ids(guilds).length === 0;
105
105
  }
106
+ /** Whether a command is an activity's primary entry point, which Discord takes only globally. */ const isEntryPoint = (command)=>command.body.type === ApplicationCommandType.PrimaryEntryPoint;
107
+ /** What a blank guild to register every command to says, as an unset variable gives one. */ const BLANK_GUILD = 'No commands were registered: the guild to register to is blank. Give a guild id, or leave it out to register where commands.* says.';
106
108
  /**
107
109
  * Where each command is sent: one bulk update per scope. A scope's update replaces everything the application has
108
110
  * there, so the default scope is included even when it ends up empty, unless `commands.guilds` lists no guild id.
111
+ *
112
+ * A primary entry point command goes in the global update, as Discord refuses a guild update that holds one. A run
113
+ * that sends everything to one guild sends nothing global, so it leaves the entry point out.
109
114
  */ function planTargets(commands, options, logger) {
115
+ // Registering to the default scopes instead could publish everywhere
116
+ if (options.onlyGuild !== undefined && !options.onlyGuild.trim()) {
117
+ logger.error(BLANK_GUILD);
118
+ return [];
119
+ }
110
120
  const everythingTo = everythingTarget(options);
111
- if (everythingTo) return [
112
- {
113
- scope: {
114
- guild: everythingTo
115
- },
116
- commands
121
+ if (everythingTo) {
122
+ // A global update from here would hold only what this run sends, deleting the application's other global commands
123
+ for (const command of commands.filter(isEntryPoint)){
124
+ logger.warn(`Entry point command "${command.name}" is registered globally only; a production run or \`meocord register\` registers it.`);
117
125
  }
118
- ];
126
+ return [
127
+ {
128
+ scope: {
129
+ guild: everythingTo
130
+ },
131
+ commands: commands.filter((command)=>!isEntryPoint(command))
132
+ }
133
+ ];
134
+ }
119
135
  const defaultGuilds = ids(options.config?.guilds);
120
136
  const nowhere = defaultGuildsEmpty(options);
121
137
  const targets = new Map();
@@ -133,7 +149,12 @@ const scopeKey = (scope)=>scope === 'global' ? 'global' : `guild-${scope.guild}`
133
149
  });
134
150
  const unregistered = [];
135
151
  for (const command of commands){
136
- if (command.guilds) {
152
+ if (isEntryPoint(command)) {
153
+ if (command.guilds) {
154
+ logger.warn(`Entry point command "${command.name}" can only be registered globally, so the guilds its builder lists are ignored.`);
155
+ }
156
+ add('global', command);
157
+ } else if (command.guilds) {
137
158
  const own = ids(command.guilds);
138
159
  // Falling back to the default scope could publish a staff-only command everywhere.
139
160
  if (own.length === 0) logger.warn(`Command "${command.name}" lists no guild ids in its builder, so it is not registered.`);
@@ -151,7 +172,7 @@ const scopeKey = (scope)=>scope === 'global' ? 'global' : `guild-${scope.guild}`
151
172
  }
152
173
  }
153
174
  if (nowhere) {
154
- const outcome = unregistered.length === 0 ? 'nothing is registered globally' : `${unregistered.join(', ')} ${unregistered.length === 1 ? 'is' : 'are'} not registered, rather than registered globally`;
175
+ const outcome = unregistered.length === 0 ? targets.has('global') ? 'only the entry point command is registered globally' : 'nothing is registered globally' : `${unregistered.join(', ')} ${unregistered.length === 1 ? 'is' : 'are'} not registered, rather than registered globally`;
155
176
  logger.warn(`commands.guilds lists no guild id, as an unset environment variable leaves it, so ${outcome}. ` + `Set the guild ids, or remove commands.guilds to register globally.`);
156
177
  }
157
178
  return [
@@ -180,28 +201,57 @@ const describeScope = (scope)=>scope === 'global' ? 'globally' : `to guild ${sco
180
201
  return path.join(nodeModules, '.cache', 'meocord', `commands-${applicationId}-${scopeKey(scope)}.json`);
181
202
  }
182
203
  const hashOf = (commands)=>createHash('sha256').update(JSON.stringify(commands.map(({ body })=>body))).digest('hex');
183
- function readHash(file, logger) {
204
+ function readRecord(file, logger) {
184
205
  if (!file || !existsSync(file)) return undefined;
185
206
  try {
186
- return JSON.parse(readFileSync(file, 'utf8')).hash;
207
+ return JSON.parse(readFileSync(file, 'utf8'));
187
208
  } catch (error) {
188
209
  logger.debug(`Could not read the registered commands, so they are sent again: ${String(error)}`);
189
210
  return undefined;
190
211
  }
191
212
  }
192
- function writeHash(file, hash, logger) {
213
+ function writeRecord(file, record, logger) {
193
214
  if (!file) return;
194
215
  try {
195
216
  mkdirSync(path.dirname(file), {
196
217
  recursive: true
197
218
  });
198
- writeFileSync(file, `${JSON.stringify({
199
- hash
200
- })}\n`);
219
+ writeFileSync(file, `${JSON.stringify(record)}\n`);
201
220
  } catch (error) {
202
221
  logger.debug(`Could not record the registered commands: ${String(error)}`);
203
222
  }
204
223
  }
224
+ /** Forgets what development last sent to a scope, once that scope is written some other way. */ function forgetRecord(file, logger) {
225
+ if (!file || !existsSync(file)) return;
226
+ try {
227
+ rmSync(file);
228
+ } catch (error) {
229
+ logger.debug(`Could not forget the registered commands: ${String(error)}`);
230
+ }
231
+ }
232
+ /**
233
+ * Each command's `id:version`, sorted, from what Discord lists or answers a bulk update with; `undefined` for anything
234
+ * else. Discord gives a command a new version whenever it is updated, so the set changes with any edit, removal or
235
+ * re-registration, from this checkout or any other.
236
+ */ function versionsOf(commands) {
237
+ if (!Array.isArray(commands)) return undefined;
238
+ const versions = commands.map((command)=>{
239
+ const { id, version } = command ?? {};
240
+ return typeof id === 'string' && typeof version === 'string' ? `${id}:${version}` : undefined;
241
+ });
242
+ return versions.every((version)=>version !== undefined) ? versions.sort() : undefined;
243
+ }
244
+ /** Whether Discord still holds a scope as development last sent it, by each command's version; a failed listing doesn't. */ async function stillAsSent(rest, route, record, scope, logger) {
245
+ if (!record.versions) return false;
246
+ let registered;
247
+ try {
248
+ registered = await rest.get(route);
249
+ } catch (error) {
250
+ logger.debug(`Could not list the commands registered ${describeScope(scope)}, so they are sent again: ${String(error)}`);
251
+ return false;
252
+ }
253
+ return versionsOf(registered)?.join('\n') === record.versions.join('\n');
254
+ }
205
255
  function logRegistered(target, logger) {
206
256
  const table = new CliTable3({
207
257
  head: [
@@ -224,22 +274,29 @@ function logRegistered(target, logger) {
224
274
  };
225
275
  for (const { name, body } of target.commands){
226
276
  const options = Array.isArray(body.options) ? body.options : [];
277
+ // A subcommand or a subcommand group, not a plain option
278
+ const subcommands = options.filter((option)=>option.type === 1 || option.type === 2);
227
279
  table.push([
228
280
  name,
229
281
  typeNames[typeof body.type === 'number' ? body.type : 1] ?? 'Command',
230
- options.map((option)=>option.name).join(', ')
282
+ subcommands.map((option)=>option.name).join(', ')
231
283
  ]);
232
284
  }
233
285
  logger.log(`Registered ${target.commands.length} bot commands ${describeScope(target.scope)}:\n${table.toString()}`);
234
286
  }
235
287
  /**
236
- * Registers the commands, one bulk update per scope; development skips a scope unchanged since its last send, unless
237
- * `force`. Once one is sent, and not to `onlyGuild`, the other scopes the configuration names are checked for
288
+ * Registers the commands, one bulk update per scope; development skips a scope unchanged since its last send and still
289
+ * registered as sent, which one listing per skipped scope checks by each command's version, unless `force`. A blank `onlyGuild` registers nothing. Once one is sent, and not to `onlyGuild`, the other scopes the configuration names are checked for
238
290
  * leftovers, which `clearOther` removes; a development-guild run or a `commands.guilds` with no id only warns. Never
239
291
  * throws.
240
292
  * @returns Whether every scope was sent or unchanged, and `commands.guilds`, if set, lists a guild id.
241
293
  */ async function registerCommands(options) {
242
294
  const { rest, applicationId, controllerClasses, logger, config, development, onlyGuild, force = false } = options;
295
+ // An unset variable passed as the guild; registering to the default scopes instead could publish everywhere
296
+ if (onlyGuild !== undefined && !onlyGuild.trim()) {
297
+ logger.error(BLANK_GUILD);
298
+ return false;
299
+ }
243
300
  const commands = collectCommands(controllerClasses, logger);
244
301
  if (!commands) return false;
245
302
  const targets = planTargets(commands, {
@@ -256,18 +313,26 @@ function logRegistered(target, logger) {
256
313
  let succeeded = !guildsEmpty;
257
314
  let sent = false;
258
315
  for (const target of targets){
259
- const file = development ? cacheFile(applicationId, target.scope) : undefined;
316
+ const file = cacheFile(applicationId, target.scope);
260
317
  const hash = hashOf(target.commands);
261
- if (development && !force && readHash(file, logger) === hash) {
318
+ const route = routeFor(applicationId, target.scope);
319
+ // Unchanged since development last sent it, and still what Discord holds there
320
+ const record = development && !force ? readRecord(file, logger) : undefined;
321
+ if (record?.hash === hash && await stillAsSent(rest, route, record, target.scope, logger)) {
262
322
  logger.log(`Commands ${describeScope(target.scope)} are unchanged; not registering them again.`);
263
323
  continue;
264
324
  }
265
325
  try {
266
- await rest.put(routeFor(applicationId, target.scope), {
326
+ const answered = await rest.put(route, {
267
327
  body: target.commands.map(({ body })=>body)
268
328
  });
269
329
  sent = true;
270
- if (development) writeHash(file, hash, logger);
330
+ // Outside development the record would be stale, so the next development start sends again
331
+ if (development) writeRecord(file, {
332
+ hash,
333
+ versions: versionsOf(answered)
334
+ }, logger);
335
+ else forgetRecord(file, logger);
271
336
  logRegistered(target, logger);
272
337
  } catch (error) {
273
338
  succeeded = false;
@@ -296,13 +361,17 @@ function logRegistered(target, logger) {
296
361
  if (!Array.isArray(existing) || existing.length === 0) continue;
297
362
  const names = existing.map(({ name })=>name).join(', ');
298
363
  if (!config?.clearOther || keptBecause) {
299
- logger.warn(`${existing.length} command(s) are still registered ${describeScope(scope)} (${names}), which this ` + `configuration does not register to, so Discord keeps showing them there. ` + (config?.clearOther ? `clearOther is on, but they are not removed ${keptBecause}.` : 'Set commands.clearOther to remove them.'));
364
+ // Advising clearOther when it would not apply sends the reader to a setting that changes nothing
365
+ const advice = !keptBecause ? 'Set commands.clearOther to remove them.' : config?.clearOther ? `clearOther is on, but they are not removed ${keptBecause}.` : `They are not removed ${keptBecause}, even with commands.clearOther.`;
366
+ logger.warn(`${existing.length} command(s) are still registered ${describeScope(scope)} (${names}), which this ` + `configuration does not register to, so Discord keeps showing them there. ${advice}`);
300
367
  continue;
301
368
  }
302
369
  try {
303
370
  await rest.put(routeFor(applicationId, scope), {
304
371
  body: []
305
372
  });
373
+ // What development last sent there is gone, so its next start sends again
374
+ forgetRecord(cacheFile(applicationId, scope), logger);
306
375
  logger.log(`Removed ${existing.length} command(s) left registered ${describeScope(scope)}: ${names}`);
307
376
  } catch (error) {
308
377
  logger.error(`Error removing the commands left registered ${describeScope(scope)}:`, error);
@@ -1,4 +1,4 @@
1
- import { getCommandMap } from '../decorator/controller.decorator.js';
1
+ import { commandsNamed, getCommandMap } from '../decorator/controller.decorator.js';
2
2
  import { resolveCommandPaths, matchesHandler } from '../util/interaction.util.js';
3
3
 
4
4
  /** The keys a command is routed by, most specific first: a chat command's full path, then its name; none for anything else. */ function commandRouteKeys(interaction) {
@@ -15,7 +15,7 @@ import { resolveCommandPaths, matchesHandler } from '../util/interaction.util.js
15
15
  */ function matchCommandRoute(controllers, interaction) {
16
16
  for (const key of commandRouteKeys(interaction)){
17
17
  for (const controllerClass of controllers){
18
- const meta = getCommandMap(controllerClass.prototype)?.[key]?.find((each)=>matchesHandler(each, interaction));
18
+ const meta = commandsNamed(getCommandMap(controllerClass.prototype), key).find((each)=>matchesHandler(each, interaction));
19
19
  if (meta) return {
20
20
  controllerClass,
21
21
  meta
@@ -222,19 +222,37 @@ const logger = new Logger('Cooldown');
222
222
  const value = by ? await by(context, params) : undefined;
223
223
  // Encoded, so a value holding a colon cannot count under another value's key. Without a value, `:by` alone keeps
224
224
  // the call apart from a cooldown with no `by` over the same window.
225
- const suffix = !by ? '' : value === undefined ? ':by' : `:by:${encodeURIComponent(String(value))}`;
225
+ const suffix = !by ? '' : value === undefined ? ':by' : `:by:${encodeKeyPart(String(value))}`;
226
+ const scope = scopeId(per, first);
227
+ // The caller, for a notice of a refusal under a scope that counts more than one caller
228
+ const user = scope.startsWith('user:') ? undefined : caller(first).user;
226
229
  counted.push({
227
- key: `${controller.name}.${methodName}#${id}:${per}:${scopeId(per, first)}${suffix}`,
230
+ key: `${controller.name}.${methodName}#${id}:${per}:${scope}${suffix}`,
228
231
  windowMs,
229
232
  uses,
230
- per
233
+ per,
234
+ user
231
235
  });
232
236
  }
233
237
  return counted;
234
238
  }
239
+ // A surrogate with no partner beside it, which encodeURIComponent refuses
240
+ const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
235
241
  /**
236
- * The store key and window of the cooldown each refusal came from, and when its wait ends by the store's clock if
237
- * the store said, for {@link claimCooldownNotice}.
242
+ * A value as part of a store key: each well-formed run as `encodeURIComponent` writes it, and each lone surrogate as `%u`
243
+ * and its four hex digits, which no other value encodes to, since `encodeURIComponent` writes `%` only before two.
244
+ */ function encodeKeyPart(value) {
245
+ let encoded = '';
246
+ let from = 0;
247
+ for (const { 0: surrogate, index } of value.matchAll(LONE_SURROGATE)){
248
+ encoded += `${encodeURIComponent(value.slice(from, index))}%u${surrogate.charCodeAt(0).toString(16).toUpperCase()}`;
249
+ from = index + 1;
250
+ }
251
+ return encoded + encodeURIComponent(value.slice(from));
252
+ }
253
+ /**
254
+ * The store key and window of the cooldown each refusal came from, when its wait ends by the store's clock if the
255
+ * store said, and the refused caller when the key counts more than one, for {@link claimCooldownNotice}.
238
256
  */ const refusalKeys = new WeakMap();
239
257
  /** Asks the store about the call under the app's policy, and throws what the answer means for it. */ async function ask(container, counted, peek, call) {
240
258
  const store = cooldownStoreOf(container);
@@ -267,7 +285,8 @@ const logger = new Logger('Cooldown');
267
285
  refusalKeys.set(refusal, {
268
286
  key: blocking.key,
269
287
  windowMs: blocking.windowMs,
270
- endsAt: verdict.retryTimestamp
288
+ endsAt: verdict.retryTimestamp,
289
+ user: blocking.user
271
290
  });
272
291
  throw refusal;
273
292
  }
@@ -290,16 +309,18 @@ const logger = new Logger('Cooldown');
290
309
  // retry landing a moment late is refused on the earlier, which its wait holds, before taking the later, which a wait
291
310
  // ending then needs for its own notice.
292
311
  const bucket = Math.floor(refusal.retryAt.getTime() / NOTICE_BUCKET_MS);
312
+ // A key counting a server, a channel or everyone is shared by every caller it refuses, each told once
313
+ const key = refused.user === undefined ? refused.key : `${refused.key}:user:${refused.user}`;
293
314
  const notices = refused.endsAt !== undefined ? [
294
315
  {
295
- key: `${refused.key}:notice:at:${refused.endsAt}`,
316
+ key: `${key}:notice:at:${refused.endsAt}`,
296
317
  limit
297
318
  }
298
319
  ] : [
299
320
  bucket - 1,
300
321
  bucket
301
322
  ].map((at)=>({
302
- key: `${refused.key}:notice:${at}`,
323
+ key: `${key}:notice:${at}`,
303
324
  limit
304
325
  }));
305
326
  try {
@@ -7,7 +7,7 @@ import { themeForInteraction, useTheme } from './theme-scope.js';
7
7
  import { errorText } from '../common/translate-error.js';
8
8
  import { interactionLocale, translatorOfClient, messageLocale, renderText } from '../common/meocord-text.js';
9
9
  import { logFailedSend } from '../common/response/send-failure.js';
10
- import { presenterFor, withSendableFiles, DEFAULT_ATTACHMENT_SIZE_LIMIT, attachmentsOf, renderEmbed, withoutFiles, isTooLarge, REFUSED_AS_TOO_LARGE } from '../common/response/presenter.js';
10
+ import { presenterFor, fitText, TEXT_LIMITS, withSendableFiles, DEFAULT_ATTACHMENT_SIZE_LIMIT, attachmentsOf, renderEmbed, withoutFiles, isTooLarge, REFUSED_AS_TOO_LARGE } from '../common/response/presenter.js';
11
11
  import { escapeForLog, quoteForLog } from '../util/user-text.util.js';
12
12
 
13
13
  const UNKNOWN_INTERACTION = 10062;
@@ -79,7 +79,10 @@ function errorCode(error) {
79
79
  return send(reply.withoutFiles);
80
80
  }
81
81
  }
82
- /** The text of a reply to a message, after the call's `emojis.warning` when `withEmoji`. */ const replyText = (text, withEmoji)=>withEmoji ? `${useTheme().emojis.warning} ${text}` : text;
82
+ /** The text of a reply to a message, after the call's `emojis.warning` when `withEmoji`, cut to fit a message's content. */ function replyText(text, withEmoji) {
83
+ const emoji = withEmoji ? `${useTheme().emojis.warning} ` : '';
84
+ return emoji + fitText(text, TEXT_LIMITS.content - emoji.length);
85
+ }
83
86
  /**
84
87
  * `text`, answering `error` for `message`: plain, after the call's `emojis.warning` when `withEmoji`, unless the
85
88
  * presenter's `messageError` draws it as an embed, coloured as an interaction's view is, with that emoji when it has
@@ -87,28 +90,33 @@ function errorCode(error) {
87
90
  * the failure comes back with it, for the caller to report once sent.
88
91
  */ async function presentedReply(message, error, text, withEmoji, logger) {
89
92
  const presenter = presenterFor(message.client);
93
+ const translator = translatorOfClient(message.client);
94
+ const locale = messageLocale(message) ?? translator?.defaultLocale ?? 'en-US';
95
+ // An empty or whitespace-only message, which Discord refuses, reads as MeoCord's generic error text, so neither the plain reply nor a presenter is left without one
96
+ const shown = text.trim() === '' ? renderText(translator, locale, {
97
+ key: 'meocord.fallback.error'
98
+ }) : text;
90
99
  const plain = {
91
100
  body: {
92
- content: replyText(text, withEmoji)
101
+ content: replyText(shown, withEmoji)
93
102
  }
94
103
  };
95
104
  const { messageError } = presenter;
96
105
  if (!messageError) return plain;
97
106
  try {
98
107
  const theme = await themeForInteraction(message);
99
- const translator = translatorOfClient(message.client);
100
- const locale = messageLocale(message) ?? translator?.defaultLocale ?? 'en-US';
101
108
  const tone = isUserOutcome(error, message) ? 'warning' : 'danger';
109
+ const presented = {
110
+ message: fitText(shown, TEXT_LIMITS.embed),
111
+ error,
112
+ tone
113
+ };
102
114
  const drawn = await messageError.call(presenter, {
103
115
  message,
104
116
  locale,
105
117
  mode: 'embed',
106
118
  theme
107
- }, {
108
- message: text,
109
- error,
110
- tone
111
- });
119
+ }, presented);
112
120
  let view = drawn.color === undefined ? {
113
121
  ...drawn,
114
122
  color: theme.colors.primary
@@ -165,7 +173,8 @@ async function replyToMessage(message, error, logger, withEmoji, answering) {
165
173
  await sendReply(reply, (body)=>message.reply({
166
174
  ...body,
167
175
  allowedMentions: {
168
- repliedUser: false
176
+ repliedUser: false,
177
+ parse: []
169
178
  }
170
179
  }));
171
180
  } catch (replyError) {
@@ -338,14 +338,27 @@ const wrapperCount = perHandler((prototype, methodName)=>{
338
338
  else marks.delete(methodName);
339
339
  return true;
340
340
  }
341
+ /** The container that made each instance, so a direct call runs in its own app or testing module. */ const instanceContainers = new WeakMap();
342
+ /**
343
+ * Records each instance `container` makes of `classes` as its own. Two testing modules made from one class each run a
344
+ * direct call on their own instance in themselves, where the class's metadata names only the last.
345
+ */ function recordInstanceContainers(container, classes) {
346
+ for (const cls of classes){
347
+ container.onActivation(cls, (_context, instance)=>{
348
+ if (typeof instance === 'object' && instance !== null) instanceContainers.set(instance, container);
349
+ return instance;
350
+ });
351
+ }
352
+ }
341
353
  /**
342
354
  * Runs a direct call to a guarded handler: the handler's guards, as dispatch resolves them, then the
343
355
  * call with a pass for each wrapper inside the one entered, so no guard runs twice.
344
356
  */ async function runDirectCall(instance, methodName, args, call) {
345
357
  const prototype = Object.getPrototypeOf(instance);
346
358
  const controller = instance.constructor;
347
- // The app that made the class, or a class it extends: a subclass the app's code makes runs as its base does
348
- const container = Reflect.getMetadata(META.container, controller);
359
+ // The container that made the instance, or else the app that made the class or a class it extends: a subclass the
360
+ // app's code makes runs as its base does
361
+ const container = instanceContainers.get(instance) ?? Reflect.getMetadata(META.container, controller);
349
362
  if (!container) {
350
363
  throw new Error(`${controller.name}.${methodName}: @UseGuard runs its guards through the app that made ${controller.name}, and this ` + `instance was made outside one. Inject ${controller.name}, or call it on an instance the app made.`);
351
364
  }
@@ -384,4 +397,4 @@ const wrapperCount = perHandler((prototype, methodName)=>{
384
397
  }
385
398
  }
386
399
 
387
- export { callGuardedHandler, classChain, classLevelGuards, consumeDispatchMark, declaringPrototype, handlerGuards, handlerMethods, injectedTokens, isGuardWithParams, perHandler, runDirectCall, runGuards, singletonContextError, sourcePrototype, stageClasses, undecoratedConstructor, untypedParameter };
400
+ export { callGuardedHandler, classChain, classLevelGuards, consumeDispatchMark, declaringPrototype, handlerGuards, handlerMethods, injectedTokens, isGuardWithParams, perHandler, recordInstanceContainers, runDirectCall, runGuards, singletonContextError, sourcePrototype, stageClasses, undecoratedConstructor, untypedParameter };
@@ -2,6 +2,7 @@ import { ModalSubmitInteraction, ComponentType, StringSelectMenuInteraction, Use
2
2
  import { getCommandMap } from '../decorator/controller.decorator.js';
3
3
  import { matchComponentRoute } from './component-routes.js';
4
4
  import { hasCustomId, isCustomIdRouted, matchesHandler, resolveOptionParams, matchesCommandType } from '../util/interaction.util.js';
5
+ import { setOwn } from '../util/value.util.js';
5
6
  import { commandRouteKeys, matchCommandRoute } from './command-routes.js';
6
7
 
7
8
  /** The entries of a discord.js Collection, or none for anything else, such as a test double's stub. */ const entriesOf = (collection)=>collection instanceof Map ? [
@@ -18,8 +19,8 @@ import { commandRouteKeys, matchCommandRoute } from './command-routes.js';
18
19
  for (const [customId, field] of fields){
19
20
  const data = field;
20
21
  // An upload's `values` are the attachments' ids; discord.js resolves the attachments themselves alongside
21
- if (data.type === ComponentType.FileUpload) values[customId] = entriesOf(interaction.fields.getUploadedFiles(customId));
22
- else values[customId] = 'value' in data ? data.value : data.values;
22
+ if (data.type === ComponentType.FileUpload) setOwn(values, customId, entriesOf(interaction.fields.getUploadedFiles(customId)));
23
+ else setOwn(values, customId, 'value' in data ? data.value : data.values);
23
24
  }
24
25
  return values;
25
26
  }
@@ -78,7 +79,7 @@ import { commandRouteKeys, matchCommandRoute } from './command-routes.js';
78
79
  collisions: []
79
80
  };
80
81
  const fields = interaction instanceof ModalSubmitInteraction ? modalFields(interaction) : selectChoices(interaction);
81
- const collisions = Object.keys(routeParams).filter((name)=>name in fields);
82
+ const collisions = Object.keys(routeParams).filter((name)=>Object.hasOwn(fields, name));
82
83
  return {
83
84
  params: {
84
85
  ...fields,