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
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- var controller_decorator = require('./controller.decorator-CRn9kWob.cjs');
3
+ var controller_decorator = require('./controller.decorator-U2NBWcoU.cjs');
4
4
  require('reflect-metadata');
5
5
  var node_async_hooks = require('node:async_hooks');
6
6
  var inversify = require('inversify');
@@ -90,7 +90,8 @@ var Table__default = /*#__PURE__*/_interopDefaultCompat(Table);
90
90
  }
91
91
  }
92
92
  /** The class of a stage entry. */ function stageClass(entry) {
93
- return controller_decorator.isConstructor(entry) ? entry : entry.provide;
93
+ // Entries were checked as they were declared, so a function is the class itself
94
+ return typeof entry === 'function' ? entry : entry.provide;
94
95
  }
95
96
  /** The context types a stage declared, or `undefined` when it runs for every type. */ function stageTypes(entry) {
96
97
  return Reflect.getMetadata(controller_decorator.META.stageTypes, stageClass(entry));
@@ -505,14 +506,27 @@ const wrapperCount = perHandler((prototype, methodName)=>{
505
506
  else marks.delete(methodName);
506
507
  return true;
507
508
  }
509
+ /** The container that made each instance, so a direct call runs in its own app or testing module. */ const instanceContainers = new WeakMap();
510
+ /**
511
+ * Records each instance `container` makes of `classes` as its own. Two testing modules made from one class each run a
512
+ * direct call on their own instance in themselves, where the class's metadata names only the last.
513
+ */ function recordInstanceContainers(container, classes) {
514
+ for (const cls of classes){
515
+ container.onActivation(cls, (_context, instance)=>{
516
+ if (typeof instance === 'object' && instance !== null) instanceContainers.set(instance, container);
517
+ return instance;
518
+ });
519
+ }
520
+ }
508
521
  /**
509
522
  * Runs a direct call to a guarded handler: the handler's guards, as dispatch resolves them, then the
510
523
  * call with a pass for each wrapper inside the one entered, so no guard runs twice.
511
524
  */ async function runDirectCall(instance, methodName, args, call) {
512
525
  const prototype = Object.getPrototypeOf(instance);
513
526
  const controller = instance.constructor;
514
- // The app that made the class, or a class it extends: a subclass the app's code makes runs as its base does
515
- const container = Reflect.getMetadata(controller_decorator.META.container, controller);
527
+ // The container that made the instance, or else the app that made the class or a class it extends: a subclass the
528
+ // app's code makes runs as its base does
529
+ const container = instanceContainers.get(instance) ?? Reflect.getMetadata(controller_decorator.META.container, controller);
516
530
  if (!container) {
517
531
  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.`);
518
532
  }
@@ -767,19 +781,37 @@ const logger$3 = new controller_decorator.Logger('Cooldown');
767
781
  const value = by ? await by(context, params) : undefined;
768
782
  // Encoded, so a value holding a colon cannot count under another value's key. Without a value, `:by` alone keeps
769
783
  // the call apart from a cooldown with no `by` over the same window.
770
- const suffix = !by ? '' : value === undefined ? ':by' : `:by:${encodeURIComponent(String(value))}`;
784
+ const suffix = !by ? '' : value === undefined ? ':by' : `:by:${encodeKeyPart(String(value))}`;
785
+ const scope = scopeId(per, first);
786
+ // The caller, for a notice of a refusal under a scope that counts more than one caller
787
+ const user = scope.startsWith('user:') ? undefined : caller(first).user;
771
788
  counted.push({
772
- key: `${controller.name}.${methodName}#${id}:${per}:${scopeId(per, first)}${suffix}`,
789
+ key: `${controller.name}.${methodName}#${id}:${per}:${scope}${suffix}`,
773
790
  windowMs,
774
791
  uses,
775
- per
792
+ per,
793
+ user
776
794
  });
777
795
  }
778
796
  return counted;
779
797
  }
798
+ // A surrogate with no partner beside it, which encodeURIComponent refuses
799
+ const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
780
800
  /**
781
- * The store key and window of the cooldown each refusal came from, and when its wait ends by the store's clock if
782
- * the store said, for {@link claimCooldownNotice}.
801
+ * A value as part of a store key: each well-formed run as `encodeURIComponent` writes it, and each lone surrogate as `%u`
802
+ * and its four hex digits, which no other value encodes to, since `encodeURIComponent` writes `%` only before two.
803
+ */ function encodeKeyPart(value) {
804
+ let encoded = '';
805
+ let from = 0;
806
+ for (const { 0: surrogate, index } of value.matchAll(LONE_SURROGATE)){
807
+ encoded += `${encodeURIComponent(value.slice(from, index))}%u${surrogate.charCodeAt(0).toString(16).toUpperCase()}`;
808
+ from = index + 1;
809
+ }
810
+ return encoded + encodeURIComponent(value.slice(from));
811
+ }
812
+ /**
813
+ * The store key and window of the cooldown each refusal came from, when its wait ends by the store's clock if the
814
+ * store said, and the refused caller when the key counts more than one, for {@link claimCooldownNotice}.
783
815
  */ const refusalKeys = new WeakMap();
784
816
  /** 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) {
785
817
  const store = cooldownStoreOf(container);
@@ -812,7 +844,8 @@ const logger$3 = new controller_decorator.Logger('Cooldown');
812
844
  refusalKeys.set(refusal, {
813
845
  key: blocking.key,
814
846
  windowMs: blocking.windowMs,
815
- endsAt: verdict.retryTimestamp
847
+ endsAt: verdict.retryTimestamp,
848
+ user: blocking.user
816
849
  });
817
850
  throw refusal;
818
851
  }
@@ -835,16 +868,18 @@ const logger$3 = new controller_decorator.Logger('Cooldown');
835
868
  // retry landing a moment late is refused on the earlier, which its wait holds, before taking the later, which a wait
836
869
  // ending then needs for its own notice.
837
870
  const bucket = Math.floor(refusal.retryAt.getTime() / NOTICE_BUCKET_MS);
871
+ // A key counting a server, a channel or everyone is shared by every caller it refuses, each told once
872
+ const key = refused.user === undefined ? refused.key : `${refused.key}:user:${refused.user}`;
838
873
  const notices = refused.endsAt !== undefined ? [
839
874
  {
840
- key: `${refused.key}:notice:at:${refused.endsAt}`,
875
+ key: `${key}:notice:at:${refused.endsAt}`,
841
876
  limit
842
877
  }
843
878
  ] : [
844
879
  bucket - 1,
845
880
  bucket
846
881
  ].map((at)=>({
847
- key: `${refused.key}:notice:${at}`,
882
+ key: `${key}:notice:${at}`,
848
883
  limit
849
884
  }));
850
885
  try {
@@ -1066,19 +1101,35 @@ const scopeKey = (scope)=>scope === 'global' ? 'global' : `guild-${scope.guild}`
1066
1101
  const guilds = options.config?.guilds ?? [];
1067
1102
  return !everythingTarget(options) && guilds.length > 0 && ids(guilds).length === 0;
1068
1103
  }
1104
+ /** Whether a command is an activity's primary entry point, which Discord takes only globally. */ const isEntryPoint = (command)=>command.body.type === discord_js.ApplicationCommandType.PrimaryEntryPoint;
1105
+ /** 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.';
1069
1106
  /**
1070
1107
  * Where each command is sent: one bulk update per scope. A scope's update replaces everything the application has
1071
1108
  * there, so the default scope is included even when it ends up empty, unless `commands.guilds` lists no guild id.
1109
+ *
1110
+ * A primary entry point command goes in the global update, as Discord refuses a guild update that holds one. A run
1111
+ * that sends everything to one guild sends nothing global, so it leaves the entry point out.
1072
1112
  */ function planTargets(commands, options, logger) {
1113
+ // Registering to the default scopes instead could publish everywhere
1114
+ if (options.onlyGuild !== undefined && !options.onlyGuild.trim()) {
1115
+ logger.error(BLANK_GUILD);
1116
+ return [];
1117
+ }
1073
1118
  const everythingTo = everythingTarget(options);
1074
- if (everythingTo) return [
1075
- {
1076
- scope: {
1077
- guild: everythingTo
1078
- },
1079
- commands
1119
+ if (everythingTo) {
1120
+ // A global update from here would hold only what this run sends, deleting the application's other global commands
1121
+ for (const command of commands.filter(isEntryPoint)){
1122
+ logger.warn(`Entry point command "${command.name}" is registered globally only; a production run or \`meocord register\` registers it.`);
1080
1123
  }
1081
- ];
1124
+ return [
1125
+ {
1126
+ scope: {
1127
+ guild: everythingTo
1128
+ },
1129
+ commands: commands.filter((command)=>!isEntryPoint(command))
1130
+ }
1131
+ ];
1132
+ }
1082
1133
  const defaultGuilds = ids(options.config?.guilds);
1083
1134
  const nowhere = defaultGuildsEmpty(options);
1084
1135
  const targets = new Map();
@@ -1096,7 +1147,12 @@ const scopeKey = (scope)=>scope === 'global' ? 'global' : `guild-${scope.guild}`
1096
1147
  });
1097
1148
  const unregistered = [];
1098
1149
  for (const command of commands){
1099
- if (command.guilds) {
1150
+ if (isEntryPoint(command)) {
1151
+ if (command.guilds) {
1152
+ logger.warn(`Entry point command "${command.name}" can only be registered globally, so the guilds its builder lists are ignored.`);
1153
+ }
1154
+ add('global', command);
1155
+ } else if (command.guilds) {
1100
1156
  const own = ids(command.guilds);
1101
1157
  // Falling back to the default scope could publish a staff-only command everywhere.
1102
1158
  if (own.length === 0) logger.warn(`Command "${command.name}" lists no guild ids in its builder, so it is not registered.`);
@@ -1114,7 +1170,7 @@ const scopeKey = (scope)=>scope === 'global' ? 'global' : `guild-${scope.guild}`
1114
1170
  }
1115
1171
  }
1116
1172
  if (nowhere) {
1117
- const outcome = unregistered.length === 0 ? 'nothing is registered globally' : `${unregistered.join(', ')} ${unregistered.length === 1 ? 'is' : 'are'} not registered, rather than registered globally`;
1173
+ 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`;
1118
1174
  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.`);
1119
1175
  }
1120
1176
  return [
@@ -1143,28 +1199,57 @@ const describeScope = (scope)=>scope === 'global' ? 'globally' : `to guild ${sco
1143
1199
  return path__default.default.join(nodeModules, '.cache', 'meocord', `commands-${applicationId}-${scopeKey(scope)}.json`);
1144
1200
  }
1145
1201
  const hashOf = (commands)=>crypto.createHash('sha256').update(JSON.stringify(commands.map(({ body })=>body))).digest('hex');
1146
- function readHash(file, logger) {
1202
+ function readRecord(file, logger) {
1147
1203
  if (!file || !fs.existsSync(file)) return undefined;
1148
1204
  try {
1149
- return JSON.parse(fs.readFileSync(file, 'utf8')).hash;
1205
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
1150
1206
  } catch (error) {
1151
1207
  logger.debug(`Could not read the registered commands, so they are sent again: ${String(error)}`);
1152
1208
  return undefined;
1153
1209
  }
1154
1210
  }
1155
- function writeHash(file, hash, logger) {
1211
+ function writeRecord(file, record, logger) {
1156
1212
  if (!file) return;
1157
1213
  try {
1158
1214
  fs.mkdirSync(path__default.default.dirname(file), {
1159
1215
  recursive: true
1160
1216
  });
1161
- fs.writeFileSync(file, `${JSON.stringify({
1162
- hash
1163
- })}\n`);
1217
+ fs.writeFileSync(file, `${JSON.stringify(record)}\n`);
1164
1218
  } catch (error) {
1165
1219
  logger.debug(`Could not record the registered commands: ${String(error)}`);
1166
1220
  }
1167
1221
  }
1222
+ /** Forgets what development last sent to a scope, once that scope is written some other way. */ function forgetRecord(file, logger) {
1223
+ if (!file || !fs.existsSync(file)) return;
1224
+ try {
1225
+ fs.rmSync(file);
1226
+ } catch (error) {
1227
+ logger.debug(`Could not forget the registered commands: ${String(error)}`);
1228
+ }
1229
+ }
1230
+ /**
1231
+ * Each command's `id:version`, sorted, from what Discord lists or answers a bulk update with; `undefined` for anything
1232
+ * else. Discord gives a command a new version whenever it is updated, so the set changes with any edit, removal or
1233
+ * re-registration, from this checkout or any other.
1234
+ */ function versionsOf(commands) {
1235
+ if (!Array.isArray(commands)) return undefined;
1236
+ const versions = commands.map((command)=>{
1237
+ const { id, version } = command ?? {};
1238
+ return typeof id === 'string' && typeof version === 'string' ? `${id}:${version}` : undefined;
1239
+ });
1240
+ return versions.every((version)=>version !== undefined) ? versions.sort() : undefined;
1241
+ }
1242
+ /** 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) {
1243
+ if (!record.versions) return false;
1244
+ let registered;
1245
+ try {
1246
+ registered = await rest.get(route);
1247
+ } catch (error) {
1248
+ logger.debug(`Could not list the commands registered ${describeScope(scope)}, so they are sent again: ${String(error)}`);
1249
+ return false;
1250
+ }
1251
+ return versionsOf(registered)?.join('\n') === record.versions.join('\n');
1252
+ }
1168
1253
  function logRegistered(target, logger) {
1169
1254
  const table = new Table__default.default({
1170
1255
  head: [
@@ -1187,22 +1272,29 @@ function logRegistered(target, logger) {
1187
1272
  };
1188
1273
  for (const { name, body } of target.commands){
1189
1274
  const options = Array.isArray(body.options) ? body.options : [];
1275
+ // A subcommand or a subcommand group, not a plain option
1276
+ const subcommands = options.filter((option)=>option.type === 1 || option.type === 2);
1190
1277
  table.push([
1191
1278
  name,
1192
1279
  typeNames[typeof body.type === 'number' ? body.type : 1] ?? 'Command',
1193
- options.map((option)=>option.name).join(', ')
1280
+ subcommands.map((option)=>option.name).join(', ')
1194
1281
  ]);
1195
1282
  }
1196
1283
  logger.log(`Registered ${target.commands.length} bot commands ${describeScope(target.scope)}:\n${table.toString()}`);
1197
1284
  }
1198
1285
  /**
1199
- * Registers the commands, one bulk update per scope; development skips a scope unchanged since its last send, unless
1200
- * `force`. Once one is sent, and not to `onlyGuild`, the other scopes the configuration names are checked for
1286
+ * Registers the commands, one bulk update per scope; development skips a scope unchanged since its last send and still
1287
+ * 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
1201
1288
  * leftovers, which `clearOther` removes; a development-guild run or a `commands.guilds` with no id only warns. Never
1202
1289
  * throws.
1203
1290
  * @returns Whether every scope was sent or unchanged, and `commands.guilds`, if set, lists a guild id.
1204
1291
  */ async function registerCommands(options) {
1205
1292
  const { rest, applicationId, controllerClasses, logger, config, development, onlyGuild, force = false } = options;
1293
+ // An unset variable passed as the guild; registering to the default scopes instead could publish everywhere
1294
+ if (onlyGuild !== undefined && !onlyGuild.trim()) {
1295
+ logger.error(BLANK_GUILD);
1296
+ return false;
1297
+ }
1206
1298
  const commands = collectCommands(controllerClasses, logger);
1207
1299
  if (!commands) return false;
1208
1300
  const targets = planTargets(commands, {
@@ -1219,18 +1311,26 @@ function logRegistered(target, logger) {
1219
1311
  let succeeded = !guildsEmpty;
1220
1312
  let sent = false;
1221
1313
  for (const target of targets){
1222
- const file = development ? cacheFile(applicationId, target.scope) : undefined;
1314
+ const file = cacheFile(applicationId, target.scope);
1223
1315
  const hash = hashOf(target.commands);
1224
- if (development && !force && readHash(file, logger) === hash) {
1316
+ const route = routeFor(applicationId, target.scope);
1317
+ // Unchanged since development last sent it, and still what Discord holds there
1318
+ const record = development && !force ? readRecord(file, logger) : undefined;
1319
+ if (record?.hash === hash && await stillAsSent(rest, route, record, target.scope, logger)) {
1225
1320
  logger.log(`Commands ${describeScope(target.scope)} are unchanged; not registering them again.`);
1226
1321
  continue;
1227
1322
  }
1228
1323
  try {
1229
- await rest.put(routeFor(applicationId, target.scope), {
1324
+ const answered = await rest.put(route, {
1230
1325
  body: target.commands.map(({ body })=>body)
1231
1326
  });
1232
1327
  sent = true;
1233
- if (development) writeHash(file, hash, logger);
1328
+ // Outside development the record would be stale, so the next development start sends again
1329
+ if (development) writeRecord(file, {
1330
+ hash,
1331
+ versions: versionsOf(answered)
1332
+ }, logger);
1333
+ else forgetRecord(file, logger);
1234
1334
  logRegistered(target, logger);
1235
1335
  } catch (error) {
1236
1336
  succeeded = false;
@@ -1259,13 +1359,17 @@ function logRegistered(target, logger) {
1259
1359
  if (!Array.isArray(existing) || existing.length === 0) continue;
1260
1360
  const names = existing.map(({ name })=>name).join(', ');
1261
1361
  if (!config?.clearOther || keptBecause) {
1262
- 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.'));
1362
+ // Advising clearOther when it would not apply sends the reader to a setting that changes nothing
1363
+ 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.`;
1364
+ 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}`);
1263
1365
  continue;
1264
1366
  }
1265
1367
  try {
1266
1368
  await rest.put(routeFor(applicationId, scope), {
1267
1369
  body: []
1268
1370
  });
1371
+ // What development last sent there is gone, so its next start sends again
1372
+ forgetRecord(cacheFile(applicationId, scope), logger);
1269
1373
  logger.log(`Removed ${existing.length} command(s) left registered ${describeScope(scope)}: ${names}`);
1270
1374
  } catch (error) {
1271
1375
  logger.error(`Error removing the commands left registered ${describeScope(scope)}:`, error);
@@ -1581,7 +1685,7 @@ function isInterceptorWithParams(entry) {
1581
1685
  // Every filter reaching here was checked for @Catch at startup, so its types are always recorded
1582
1686
  const types = Reflect.getOwnMetadata(controller_decorator.META.catchTypes, filterClass(entry));
1583
1687
  // Only classes match: anything else given to @Catch, warned of as it applied, matches no error
1584
- if (types.length === 0 || types.some((type)=>controller_decorator.isConstructor(type) && error instanceof type)) return entry;
1688
+ if (types.length === 0 || types.some((type)=>controller_decorator.isConstructible(type) && error instanceof type)) return entry;
1585
1689
  }
1586
1690
  }
1587
1691
  return undefined;
@@ -1686,7 +1790,7 @@ const logger$1 = new controller_decorator.Logger('Observer');
1686
1790
  /** Reports each entry that is not an `@Observer` class, naming where it was listed. */ function assertObservers(where, observers) {
1687
1791
  for (const observer of observers){
1688
1792
  if (typeof observer !== 'function' || !Reflect.getMetadata(controller_decorator.META.observerClass, observer)) {
1689
- const name = typeof observer === 'function' ? observer.name : String(observer);
1793
+ const name = controller_decorator.isConstructor(observer) ? observer.name || 'an anonymous class' : typeof observer === 'function' ? 'a function' : String(observer);
1690
1794
  controller_decorator.startupError(new Error(`${where} takes classes decorated with @Observer(), not ${name}.`));
1691
1795
  continue;
1692
1796
  }
@@ -2229,7 +2333,9 @@ const logger = new controller_decorator.Logger('ExceptionFilter');
2229
2333
  denial
2230
2334
  })) {
2231
2335
  outcome = 'denied';
2232
- await response?.abandon();
2336
+ // Without @Defer too, so a denied component is acknowledged rather than left to fail
2337
+ const denied = response ?? (type === 'interaction' && first?.isRepliable?.() ? controller_decorator.responseOf(first) : undefined);
2338
+ await denied?.abandon();
2233
2339
  return {
2234
2340
  ran: false
2235
2341
  };
@@ -2285,14 +2391,18 @@ const logger = new controller_decorator.Logger('ExceptionFilter');
2285
2391
  };
2286
2392
  } finally{
2287
2393
  await response?.release();
2288
- if (options.onUnanswered && (ran || returnedEarly) && outcome === 'ran' && type === 'interaction') {
2394
+ if (options.onUnanswered && (ran || returnedEarly) && outcome === 'ran' && (type === 'interaction' || type === 'autocomplete')) {
2289
2395
  const phase = responsePhaseOf(contextOf());
2290
2396
  // The handler is to blame only once it has finished; until then, the interceptor that ended the call is
2291
- const blamed = !finished && returnedEarly ? {
2397
+ const returnedBy = !finished && returnedEarly ? {
2292
2398
  interceptor: returnedEarly,
2293
2399
  handlerStarted: ran
2294
2400
  } : undefined;
2295
- if (phase === 'unanswered' || phase === 'deferred') options.onUnanswered(phase, blamed);
2401
+ if (phase === 'unanswered' || phase === 'deferred') options.onUnanswered({
2402
+ type,
2403
+ phase,
2404
+ returnedBy
2405
+ });
2296
2406
  }
2297
2407
  if (options.awaitObservers) await starting;
2298
2408
  // After the answer and the release, so the duration covers the whole call
@@ -4087,22 +4197,48 @@ const sortedEntries = (entries)=>entries.sort((a, b)=>a.usage < b.usage ? -1 : a
4087
4197
  scope
4088
4198
  ].filter((line)=>line !== undefined).join('\n');
4089
4199
  }
4090
- /** Splits a reply into Discord-sized messages at line breaks, a line longer than one message cut where it must be. */ function splitReply(text) {
4200
+ // Built on first use, for a line longer than a message
4201
+ let graphemes;
4202
+ /**
4203
+ * Where a line longer than one message is cut: after the last character a reader sees as one, such as an emoji, a flag
4204
+ * or a letter with its accent, that still fits. A single such character longer than a message is cut between code points.
4205
+ */ function fittingCut(piece) {
4206
+ let cut = 0;
4207
+ graphemes ?? (graphemes = new Intl.Segmenter(undefined, {
4208
+ granularity: 'grapheme'
4209
+ }));
4210
+ for (const { index, segment } of graphemes.segment(piece)){
4211
+ if (index + segment.length > MAX_REPLY_LENGTH) break;
4212
+ cut = index + segment.length;
4213
+ }
4214
+ if (cut > 0) return cut;
4215
+ const high = piece.charCodeAt(MAX_REPLY_LENGTH - 1);
4216
+ return high >= 0xd800 && high <= 0xdbff ? MAX_REPLY_LENGTH - 1 : MAX_REPLY_LENGTH;
4217
+ }
4218
+ /**
4219
+ * Splits a reply into Discord-sized messages at line breaks, a line longer than one message cut where it must be. A
4220
+ * message of only whitespace, which Discord refuses as empty, is left out.
4221
+ */ function splitReply(text) {
4091
4222
  const chunks = [];
4223
+ const push = (chunk)=>{
4224
+ if (chunk.trim() !== '') chunks.push(chunk);
4225
+ };
4092
4226
  let current = '';
4093
4227
  for (const line of text.split('\n')){
4094
- for(let piece = line;; piece = piece.slice(MAX_REPLY_LENGTH)){
4095
- const part = piece.slice(0, MAX_REPLY_LENGTH);
4228
+ for(let piece = line;;){
4229
+ const cut = piece.length <= MAX_REPLY_LENGTH ? piece.length : fittingCut(piece);
4230
+ const part = piece.slice(0, cut);
4096
4231
  const joined = current ? `${current}\n${part}` : part;
4097
4232
  if (joined.length <= MAX_REPLY_LENGTH) current = joined;
4098
4233
  else {
4099
- chunks.push(current);
4234
+ push(current);
4100
4235
  current = part;
4101
4236
  }
4102
- if (piece.length <= MAX_REPLY_LENGTH) break;
4237
+ if (cut === piece.length) break;
4238
+ piece = piece.slice(cut);
4103
4239
  }
4104
4240
  }
4105
- if (current) chunks.push(current);
4241
+ push(current);
4106
4242
  return chunks;
4107
4243
  }
4108
4244
 
@@ -4644,7 +4780,7 @@ const isToken = (value)=>typeof value === 'function' || typeof value === 'string
4644
4780
  controller_decorator.startupError(new Error(`${where}: the provider for ${name} needs exactly one of useValue, useClass and useFactory.`));
4645
4781
  continue;
4646
4782
  }
4647
- if ('useClass' in entry && typeof entry.useClass !== 'function') {
4783
+ if ('useClass' in entry && !controller_decorator.isConstructor(entry.useClass)) {
4648
4784
  controller_decorator.startupError(new Error(`${where}: the provider for ${name} has a useClass that is not a class.`));
4649
4785
  continue;
4650
4786
  }
@@ -4721,6 +4857,15 @@ const isToken = (value)=>typeof value === 'function' || typeof value === 'string
4721
4857
  });
4722
4858
  for (const dependency of inject)if (isAppClassToken(dependency)) bindClass(dependency);
4723
4859
  }
4860
+ /**
4861
+ * Reports each service listed by a string or symbol token that no provider provides, which would otherwise be bound
4862
+ * as if it were a class.
4863
+ */ function assertProvidedServices(appName, services, providers, where) {
4864
+ for (const service of services){
4865
+ if (typeof service === 'function' || providers.has(service)) continue;
4866
+ controller_decorator.startupError(new Error(`${appName}: @MeoCord({ services }) lists ${tokenName(service)}, which nothing provides: add a provider for it to ${where}.`));
4867
+ }
4868
+ }
4724
4869
  /**
4725
4870
  * Reports a class or a factory that asks for a string or symbol token nothing binds, naming both, rather than failing
4726
4871
  * when the class is first resolved. A class token is bound on demand, so it is never missing.
@@ -4854,15 +4999,20 @@ const isToken = (value)=>typeof value === 'function' || typeof value === 'string
4854
4999
  }
4855
5000
  /**
4856
5001
  * Runs every factory in `order`, awaiting those that return a promise, so each value is made before
4857
- * anything that injects it is resolved. Rejects with {@link providerFailure} for the first that fails.
4858
- */ async function resolveProviders(container, providers, order) {
5002
+ * anything that injects it is resolved. Rejects with {@link providerFailure} for the first that fails. With `control`,
5003
+ * it makes no further value once the app is stopping.
5004
+ */ async function resolveProviders(container, providers, order, control) {
4859
5005
  for (const token of order){
4860
5006
  const provider = providers.get(token);
4861
5007
  if (!provider || !isFactoryProvider(provider)) continue;
5008
+ if (control?.stopped()) return;
5009
+ control?.pending(tokenName(token));
4862
5010
  try {
4863
5011
  await container.getAsync(token);
4864
5012
  } catch (error) {
4865
5013
  throw providerFailure(token, error);
5014
+ } finally{
5015
+ control?.pending(undefined);
4866
5016
  }
4867
5017
  }
4868
5018
  }
@@ -4883,6 +5033,7 @@ exports.appPresenterOf = appPresenterOf;
4883
5033
  exports.appStages = appStages;
4884
5034
  exports.assertObservers = assertObservers;
4885
5035
  exports.assertProvided = assertProvided;
5036
+ exports.assertProvidedServices = assertProvidedServices;
4886
5037
  exports.assertStageEntries = assertStageEntries;
4887
5038
  exports.assertStartupChecked = assertStartupChecked;
4888
5039
  exports.assertTypedParameters = assertTypedParameters;
@@ -4941,6 +5092,7 @@ exports.prepareHandlerStages = prepareHandlerStages;
4941
5092
  exports.prepareThemes = prepareThemes;
4942
5093
  exports.providerMap = providerMap;
4943
5094
  exports.reachableClasses = reachableClasses;
5095
+ exports.recordInstanceContainers = recordInstanceContainers;
4944
5096
  exports.registerClientTheme = registerClientTheme;
4945
5097
  exports.registerCommands = registerCommands;
4946
5098
  exports.registrationKey = registrationKey;
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  var node_crypto = require('node:crypto');
4
- var controller_decorator = require('./controller.decorator-CRn9kWob.cjs');
4
+ var controller_decorator = require('./controller.decorator-U2NBWcoU.cjs');
5
5
 
6
6
  /** The `fatal` code of a shard whose app MeoCord refused, which every shard would refuse alike. */ const REFUSED_CODE = 'Refused';
7
7
  /** Whether an IPC message is one of MeoCord's own, rather than one of discord.js's. */ function isShardMessage(message) {
@@ -9,7 +9,10 @@ var controller_decorator = require('./controller.decorator-CRn9kWob.cjs');
9
9
  }
10
10
 
11
11
  /** Set by discord.js in every process its ShardingManager spawns. */ const SHARDING_MANAGER_ENV = 'SHARDING_MANAGER';
12
- /** Whether this process was spawned as a shard by a sharding manager. */ function isShardProcess(env = process.env) {
12
+ /**
13
+ * Whether a process with `env` was spawned as a shard by a sharding manager. Read before anything builds a
14
+ * `ShardingManager`, whose constructor writes the variable into its own process too.
15
+ */ function isShardProcess(env) {
13
16
  return env[SHARDING_MANAGER_ENV] !== undefined;
14
17
  }
15
18
 
@@ -21,10 +24,10 @@ var controller_decorator = require('./controller.decorator-CRn9kWob.cjs');
21
24
  return env.NODE_ENV !== 'development' || config.sharding.development === true;
22
25
  }
23
26
  /**
24
- * The role of this process: a shard when a manager spawned it, the manager when process sharding is on,
25
- * and otherwise a single process running every shard it has.
26
- */ function shardingRole(config, env = process.env) {
27
- if (isShardProcess(env)) return 'shard';
27
+ * The role of this process: a shard when a manager spawned it, as `shard` says, the manager when process sharding is
28
+ * on, and otherwise a single process running every shard it has.
29
+ */ function shardingRole(config, shard, env = process.env) {
30
+ if (shard) return 'shard';
28
31
  return processShardingEnabled(config, env) ? 'manager' : 'single';
29
32
  }
30
33
  /**
@@ -68,7 +71,7 @@ var controller_decorator = require('./controller.decorator-CRn9kWob.cjs');
68
71
  * never held forever. MeoCord itself fails the call after `cooldownStoreTimeoutMs`, a second by default.
69
72
  */ const SHARDED_COOLDOWN_ABANDON_MS = 30000;
70
73
  /** A shard's channel to its manager, or none outside process sharding. */ function managerChannel() {
71
- return isShardProcess() && typeof process.send === 'function' ? channelOver(process) : undefined;
74
+ return isShardProcess(process.env) && typeof process.send === 'function' ? channelOver(process) : undefined;
72
75
  }
73
76
  /**
74
77
  * The channel over a process's IPC. A send on a closed channel does not throw: without a callback, Node raises an