meocord 4.1.2 → 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 (140) hide show
  1. package/CHANGELOG.md +362 -0
  2. package/THIRD_PARTY_NOTICES.md +14 -13
  3. package/dist/cjs/_shared/{controller.decorator-BywwkU9O.cjs → controller.decorator-XKMlcUZz.cjs} +669 -185
  4. package/dist/cjs/_shared/{providers-BMMLp2SN.cjs → providers-BZgJyfZS.cjs} +218 -75
  5. package/dist/cjs/_shared/{sharded-cooldown-store-B65iaiAy.cjs → sharded-cooldown-store-CsJJyCZd.cjs} +10 -7
  6. package/dist/cjs/_shared/{startup-roots-lFc69AxC.cjs → startup-roots-CgfPQrNl.cjs} +374 -132
  7. package/dist/cjs/_shared/{translate-error-BnM8FbSH.cjs → translate-error-BUAR1WYG.cjs} +21 -7
  8. package/dist/cjs/common/index.cjs +4 -4
  9. package/dist/cjs/core/index.cjs +365 -310
  10. package/dist/cjs/decorator/index.cjs +85 -61
  11. package/dist/cjs/testing/index.cjs +2236 -496
  12. package/dist/cli.json +1 -1
  13. package/dist/esm/bin/app-template/README.md.template +3 -1
  14. package/dist/esm/bin/app-template/src/controllers/button/sample.button.controller.spec.ts.template +8 -4
  15. package/dist/esm/bin/app-template/src/controllers/button/sample.button.controller.ts.template +2 -2
  16. package/dist/esm/bin/app-template/src/guards/owner.guard.spec.ts.template +8 -4
  17. package/dist/esm/bin/app-template/src/guards/owner.guard.ts.template +1 -1
  18. package/dist/esm/bin/app-template/vitest.config.ts.template +1 -1
  19. package/dist/esm/bin/app-template/vitest.setup.ts.template +7 -3
  20. package/dist/esm/bin/generator.js +4 -5
  21. package/dist/esm/bin/helper/filter-generator.helper.js +4 -5
  22. package/dist/esm/bin/helper/guard-generator.helper.js +4 -5
  23. package/dist/esm/bin/helper/interceptor-generator.helper.js +4 -5
  24. package/dist/esm/bin/helper/observer-generator.helper.js +4 -5
  25. package/dist/esm/bin/helper/pipe-generator.helper.js +4 -5
  26. package/dist/esm/bin/helper/service-generator.helper.js +4 -5
  27. package/dist/esm/bin/meocord.js +84 -25
  28. package/dist/esm/build/load-config.pre-entry.js +17 -4
  29. package/dist/esm/build/rsbuild-config.js +16 -12
  30. package/dist/esm/build/typed-externals.js +36 -0
  31. package/dist/esm/common/cooldown-store.js +10 -2
  32. package/dist/esm/common/decorator.js +6 -6
  33. package/dist/esm/common/deprecation.js +2 -2
  34. package/dist/esm/common/errors.js +27 -2
  35. package/dist/esm/common/execution-context.js +1 -1
  36. package/dist/esm/common/meocord-text.js +11 -8
  37. package/dist/esm/common/metadata.js +1 -1
  38. package/dist/esm/common/response/call-order.js +9 -4
  39. package/dist/esm/common/response/presenter.js +33 -6
  40. package/dist/esm/common/response/response-state.js +84 -10
  41. package/dist/esm/common/route.js +21 -5
  42. package/dist/esm/common/sharded-cooldown-store.js +1 -1
  43. package/dist/esm/common/translate-error.js +1 -1
  44. package/dist/esm/common/translator.js +37 -6
  45. package/dist/esm/core/command-conflicts.js +34 -34
  46. package/dist/esm/core/command-registration.js +93 -24
  47. package/dist/esm/core/command-routes.js +2 -2
  48. package/dist/esm/core/component-routes.js +74 -31
  49. package/dist/esm/core/cooldown-runner.js +29 -8
  50. package/dist/esm/core/dispatcher.js +51 -7
  51. package/dist/esm/core/fallback.js +20 -11
  52. package/dist/esm/core/filter-runner.js +2 -2
  53. package/dist/esm/core/guard-runner.js +16 -3
  54. package/dist/esm/core/handler-input.js +4 -3
  55. package/dist/esm/core/handler-pipeline.js +27 -14
  56. package/dist/esm/core/handler-registry.js +1 -1
  57. package/dist/esm/core/interceptor-runner.js +2 -2
  58. package/dist/esm/core/lifecycle-hooks.js +134 -47
  59. package/dist/esm/core/meocord-factory.js +241 -179
  60. package/dist/esm/core/meocord.app.js +116 -75
  61. package/dist/esm/core/message-routes.js +4 -3
  62. package/dist/esm/core/observer-runner.js +4 -3
  63. package/dist/esm/core/providers.js +45 -23
  64. package/dist/esm/core/route-specificity.js +4 -4
  65. package/dist/esm/core/running-calls.js +43 -0
  66. package/dist/esm/core/scalar-types.js +15 -3
  67. package/dist/esm/core/shard-context.js +6 -7
  68. package/dist/esm/core/shard-manager.js +16 -10
  69. package/dist/esm/core/startup-roots.js +23 -4
  70. package/dist/esm/core/theme-resolvers.js +4 -1
  71. package/dist/esm/core/theme-runtime.js +2 -2
  72. package/dist/esm/core/theme-scope.js +3 -1
  73. package/dist/esm/core/theme-validation.js +17 -10
  74. package/dist/esm/decorator/app.decorator.js +12 -7
  75. package/dist/esm/decorator/command-builder.decorator.js +4 -4
  76. package/dist/esm/decorator/controller-class.decorator.js +9 -4
  77. package/dist/esm/decorator/controller.decorator.js +162 -83
  78. package/dist/esm/decorator/cooldown.decorator.js +20 -3
  79. package/dist/esm/decorator/defer.decorator.js +4 -4
  80. package/dist/esm/decorator/event.decorator.js +5 -5
  81. package/dist/esm/decorator/filter.decorator.js +7 -7
  82. package/dist/esm/decorator/guard.decorator.js +11 -10
  83. package/dist/esm/decorator/inject.decorator.js +4 -4
  84. package/dist/esm/decorator/interceptor.decorator.js +7 -7
  85. package/dist/esm/decorator/observer.decorator.js +4 -4
  86. package/dist/esm/decorator/service.decorator.js +4 -4
  87. package/dist/esm/decorator/theme.decorator.js +3 -3
  88. package/dist/esm/decorator/validation.decorator.js +10 -10
  89. package/dist/esm/package.json.js +1 -1
  90. package/dist/esm/testing/catalog.js +27 -11
  91. package/dist/esm/testing/cooldown-store-suite.js +85 -15
  92. package/dist/esm/testing/discord-defaults.js +249 -21
  93. package/dist/esm/testing/index.js +4 -2
  94. package/dist/esm/testing/inspect-handler.js +20 -3
  95. package/dist/esm/testing/meocord-testing-module.js +352 -205
  96. package/dist/esm/testing/mock-fn.js +173 -22
  97. package/dist/esm/testing/mock-interaction.js +1214 -209
  98. package/dist/esm/testing/response.js +1 -1
  99. package/dist/esm/testing/routing.js +39 -14
  100. package/dist/esm/testing/snowflake.js +9 -2
  101. package/dist/esm/testing/startup-errors.js +26 -0
  102. package/dist/esm/testing/strict-mocks.js +65 -0
  103. package/dist/esm/testing/theme.js +1 -1
  104. package/dist/esm/util/bundle-entry.util.js +2 -1
  105. package/dist/esm/util/common.util.js +47 -19
  106. package/dist/esm/util/dev-runner.util.js +8 -8
  107. package/dist/esm/util/generator-cli.util.js +4 -5
  108. package/dist/esm/util/interaction.util.js +3 -3
  109. package/dist/esm/util/meocord-config-loader.util.js +10 -2
  110. package/dist/esm/util/meocord-config-validation.util.js +4 -5
  111. package/dist/esm/util/meocord-source-config.util.js +65 -20
  112. package/dist/esm/util/metadata-keys.js +1 -0
  113. package/dist/esm/util/method-wrapper.util.js +16 -0
  114. package/dist/esm/util/platform.util.js +10 -10
  115. package/dist/esm/util/refusal.util.js +148 -8
  116. package/dist/esm/util/shard-process.util.js +4 -1
  117. package/dist/esm/util/sharding-mode.util.js +5 -7
  118. package/dist/esm/util/shared-state.util.js +21 -0
  119. package/dist/esm/util/tsconfig-paths.util.js +29 -0
  120. package/dist/esm/util/tsconfig.util.js +10 -10
  121. package/dist/esm/util/value.util.js +12 -1
  122. package/dist/types/common/index.d.cts +8 -8
  123. package/dist/types/common/index.d.ts +8 -8
  124. package/dist/types/core/index.d.cts +10 -7
  125. package/dist/types/core/index.d.ts +10 -7
  126. package/dist/types/decorator/index.d.cts +31 -29
  127. package/dist/types/decorator/index.d.ts +31 -29
  128. package/dist/types/{index-D7n0FBMm.d.ts → index-DKJclIi-.d.ts} +107 -36
  129. package/dist/types/{index-DEP0NYrU.d.cts → index-Dlf0ZZtS.d.cts} +107 -36
  130. package/dist/types/interface/index.d.cts +1 -1
  131. package/dist/types/interface/index.d.ts +1 -1
  132. package/dist/types/{route-C-aMI7Sl.d.cts → route-Bh1U13yy.d.cts} +8 -4
  133. package/dist/types/{route-C-aMI7Sl.d.ts → route-Bh1U13yy.d.ts} +8 -4
  134. package/dist/types/testing/index.d.cts +406 -70
  135. package/dist/types/testing/index.d.ts +406 -70
  136. package/dist/types/{theme-resolvers-COHdX5g6.d.cts → theme-resolvers-CyEz9pnv.d.cts} +1 -1
  137. package/dist/types/{theme-resolvers-DedxyscT.d.ts → theme-resolvers-DA204O-q.d.ts} +1 -1
  138. package/dist/types/{translator-J6tIUfdp.d.ts → translator-Bg7hwxDF.d.cts} +21 -8
  139. package/dist/types/{translator-B4mc1lzJ.d.cts → translator-CeP4bCzp.d.ts} +21 -8
  140. package/package.json +4 -3
@@ -0,0 +1,36 @@
1
+ /**
2
+ * How a package kept out of the bundle is loaded, by how the bundle asks for it: an ESM `import` becomes a dynamic
3
+ * `import()` where its importer runs, rather than one hoisted above the pre-entry, so the package loads after the
4
+ * platform check and after the config has loaded `.env`; a CommonJS `require` stays a `require` at its call site, which
5
+ * returns the module rather than the promise a dynamic import would.
6
+ */ function externalType(request, dependencyType) {
7
+ return `${dependencyType === 'commonjs' ? 'node-commonjs' : 'import'} ${request}`;
8
+ }
9
+ /** Whether an external's result names its own type, as `node-commonjs zlib-sync` does. */ const typed = (result)=>/\s/.test(result);
10
+ /**
11
+ * The externals with every untyped entry given its type by {@link externalType}: a string or pattern, as `autoExternal`
12
+ * and `externals` give them, and a function's untyped result. An object's entries are left as they are.
13
+ */ function typeExternals(items) {
14
+ return items.map((item)=>{
15
+ if (typeof item === 'string' || item instanceof RegExp) {
16
+ const matches = (request)=>typeof item === 'string' ? request === item : item.test(request);
17
+ return (data, callback)=>{
18
+ const { request, dependencyType } = data;
19
+ if (request && matches(request)) return callback(null, externalType(request, dependencyType));
20
+ callback();
21
+ };
22
+ }
23
+ if (typeof item !== 'function') return item;
24
+ const original = item;
25
+ return (data, callback)=>{
26
+ const finish = (result)=>callback(null, typeof result === 'string' && !typed(result) ? externalType(result, data.dependencyType) : result);
27
+ // Callback style, as MeoCord's own native externals are, or a promise
28
+ if (original.length >= 2) {
29
+ return original(data, (error, result)=>error ? callback(error) : finish(result));
30
+ }
31
+ Promise.resolve(original(data)).then(finish, callback);
32
+ };
33
+ });
34
+ }
35
+
36
+ export { externalType, typeExternals };
@@ -254,9 +254,17 @@
254
254
  };
255
255
  });
256
256
  }
257
- /** Drops the keys whose calls have all left their window, once a minute from the store's first call. */ startSweeping() {
257
+ /**
258
+ * Drops the keys whose calls have all left their window, once a minute from a call, until a sweep leaves none: an
259
+ * idle store then holds no timer, so one nothing references is collected, and its next call starts the sweep again.
260
+ */ startSweeping() {
258
261
  if (this.sweeper) return;
259
- this.sweeper = setInterval(()=>sweep(this.calls, Date.now()), SWEEP_INTERVAL_MS);
262
+ this.sweeper = setInterval(()=>{
263
+ sweep(this.calls, Date.now());
264
+ if (this.calls.size > 0) return;
265
+ clearInterval(this.sweeper);
266
+ this.sweeper = undefined;
267
+ }, SWEEP_INTERVAL_MS);
260
268
  // A cooldown must never be what keeps a stopping bot alive.
261
269
  this.sweeper.unref?.();
262
270
  }
@@ -1,7 +1,7 @@
1
1
  import { warnDeprecated } from './deprecation.js';
2
2
  import { Logger } from './logger.js';
3
3
  import { MetadataKey } from '../enum/metadata-key.enum.js';
4
- import { refuse, decoratedName } from '../util/refusal.util.js';
4
+ import { declaring, refuse, decoratedName } from '../util/refusal.util.js';
5
5
 
6
6
  /**
7
7
  * Composes several class or method decorators into one.
@@ -30,9 +30,9 @@ import { refuse, decoratedName } from '../util/refusal.util.js';
30
30
  *
31
31
  * @group Utilities
32
32
  * @see {@link createMetadata}
33
- * @see {@link https://meocord.dev/docs/4.1/custom-decorators | Custom decorators}
33
+ * @see {@link https://meocord.dev/docs/4.2/custom-decorators | Custom decorators}
34
34
  */ function applyDecorators(...decorators) {
35
- return function(target, propertyKey, descriptor) {
35
+ return declaring(function(target, propertyKey, descriptor) {
36
36
  // Last first, as TypeScript applies decorators stacked, each given what the one before it returned
37
37
  const ordered = [
38
38
  ...decorators
@@ -45,7 +45,7 @@ import { refuse, decoratedName } from '../util/refusal.util.js';
45
45
  let current = target;
46
46
  for (const decorator of ordered)current = decorator(current) ?? current;
47
47
  return current;
48
- };
48
+ });
49
49
  }
50
50
  const logger = new Logger('SetMetadata');
51
51
  /** The prefix of every key MeoCord keeps its own metadata under. */ const MEOCORD_PREFIX = 'meocord:';
@@ -84,7 +84,7 @@ const logger = new Logger('SetMetadata');
84
84
  * @see {@link createMetadata}
85
85
  */ function SetMetadata(metadataKey, metadataValue) {
86
86
  warnDeprecated(logger, 'SetMetadata', 'createMetadata');
87
- return function(target, propertyKey) {
87
+ return declaring(function(target, propertyKey) {
88
88
  // Checked where it applies, so the refusal names the handler or controller
89
89
  const owner = metadataKey.startsWith(MEOCORD_PREFIX) ? `MeoCord keeps its own metadata under keys beginning "${MEOCORD_PREFIX}"` : INJECTION_KEYS.has(metadataKey) ? 'dependency injection reads how to make the class from it' : undefined;
90
90
  if (owner) {
@@ -95,7 +95,7 @@ const logger = new Logger('SetMetadata');
95
95
  } else {
96
96
  Reflect.defineMetadata(metadataKey, metadataValue, target);
97
97
  }
98
- };
98
+ });
99
99
  }
100
100
 
101
101
  export { SetMetadata, applyDecorators };
@@ -14,10 +14,10 @@ const warned = new Set();
14
14
  */ function warnPlaceholder(logger, value, outcome, how) {
15
15
  warnOnce(logger, `${value} reads a placeholder here; in the next major version (5.0) ${outcome}. ${how}`);
16
16
  }
17
- function warnOnce(logger, text) {
17
+ /** Logs `text` once per run, however often it is asked for. */ function warnOnce(logger, text) {
18
18
  if (warned.has(text)) return;
19
19
  warned.add(text);
20
20
  logger.warn(text);
21
21
  }
22
22
 
23
- export { warnDeprecated, warnDeprecatedBehaviour, warnPlaceholder };
23
+ export { warnDeprecated, warnDeprecatedBehaviour, warnOnce, warnPlaceholder };
@@ -1,4 +1,5 @@
1
1
  import { renderText } from './meocord-text.js';
2
+ import { sharedKey } from '../util/shared-state.util.js';
2
3
 
3
4
  /**
4
5
  * Thrown by a guard to deny a call and tell the user why.
@@ -90,7 +91,7 @@ import { renderText } from './meocord-text.js';
90
91
  * @group Responses
91
92
  * @category Errors
92
93
  * @see {@link Catch}
93
- * @see {@link https://meocord.dev/docs/4.1/exception-filters | Exception filters}
94
+ * @see {@link https://meocord.dev/docs/4.2/exception-filters | Exception filters}
94
95
  */ class CommandNotFoundError extends Error {
95
96
  /** @param message - What the error says, `No handler matched the interaction.` unless given. */ constructor(message = 'No handler matched the interaction.'){
96
97
  super(message);
@@ -163,7 +164,7 @@ import { renderText } from './meocord-text.js';
163
164
  * @category Errors
164
165
  * @see {@link MessageHandler}
165
166
  * @see {@link translateError}
166
- * @see {@link https://meocord.dev/docs/4.1/message-commands | Message commands}
167
+ * @see {@link https://meocord.dev/docs/4.2/message-commands | Message commands}
167
168
  */ class MessageUsageError extends Error {
168
169
  /**
169
170
  * @param usage - The command as the user should type it, such as `!ban <target> [reason…]`, or one line per
@@ -330,5 +331,29 @@ import { renderText } from './meocord-text.js';
330
331
  this.name = 'CooldownStoreError';
331
332
  }
332
333
  }
334
+ /**
335
+ * Makes `instanceof` hold for an error from either build of this meocord version, ES module or CommonJS: the class's
336
+ * prototype carries a brand both builds share, which `instanceof` on the class itself checks. A subclass, such as an
337
+ * app's own `UserError`, keeps the usual check, so it still matches only its own instances.
338
+ */ function brand(error, name) {
339
+ const key = sharedKey(`error:${name}`);
340
+ Object.defineProperty(error.prototype, key, {
341
+ value: true
342
+ });
343
+ Object.defineProperty(error, Symbol.hasInstance, {
344
+ value (value) {
345
+ if (this !== error) return Function.prototype[Symbol.hasInstance].call(this, value);
346
+ return (typeof value === 'object' || typeof value === 'function') && value !== null && key in value;
347
+ }
348
+ });
349
+ }
350
+ // Named as written, since a minifier may rename the classes themselves
351
+ brand(GuardDeniedError, 'GuardDeniedError');
352
+ brand(UserError, 'UserError');
353
+ brand(CommandNotFoundError, 'CommandNotFoundError');
354
+ brand(ValidationError, 'ValidationError');
355
+ brand(MessageUsageError, 'MessageUsageError');
356
+ brand(CooldownError, 'CooldownError');
357
+ brand(CooldownStoreError, 'CooldownStoreError');
333
358
 
334
359
  export { CommandNotFoundError, CooldownError, CooldownStoreError, GuardDeniedError, MessageUsageError, UserError, ValidationError, cooldownMessage, cooldownStoreMessage, cooldownText, usageHeading };
@@ -33,7 +33,7 @@ import { useTheme } from '../core/theme-scope.js';
33
33
  * @group Utilities
34
34
  * @see {@link createMetadata}
35
35
  * @see {@link StageParams}
36
- * @see {@link https://meocord.dev/docs/4.1/guards | Guards}
36
+ * @see {@link https://meocord.dev/docs/4.2/guards | Guards}
37
37
  */ class ExecutionContext {
38
38
  /**
39
39
  * The response state of the interaction being handled, the same one `respond(interaction)` returns,
@@ -1,5 +1,6 @@
1
1
  import { fillPlaceholders, pluralForm, lookup, languageOf, LOCALE_CHAIN, CATALOGS } from './translator.js';
2
2
  import { MEOCORD_MESSAGES } from './meocord-messages.js';
3
+ import { shared } from '../util/shared-state.util.js';
3
4
 
4
5
  const hasCatalogs = (translator)=>translator !== undefined && CATALOGS in translator && LOCALE_CHAIN in translator;
5
6
  const ENGLISH = 'en-US';
@@ -31,16 +32,17 @@ const ENGLISH = 'en-US';
31
32
  return english();
32
33
  }
33
34
  const lists = new Map();
34
- /** A list in a locale's words: "a, b, or c" in en-US, or, for a unit, "a, b, c". */ function listIn(locale, style, items) {
35
- let format = lists.get(`${locale} ${style}`);
36
- if (!format) lists.set(`${locale} ${style}`, format = new Intl.ListFormat(locale, {
37
- type: style === 'or' ? 'disjunction' : 'unit'
35
+ /** An `or` list in a locale's words, such as "a, b, or c" in en-US. */ function orListIn(locale, items) {
36
+ let format = lists.get(locale);
37
+ if (!format) lists.set(locale, format = new Intl.ListFormat(locale, {
38
+ type: 'disjunction'
38
39
  }));
39
40
  return format.format(items);
40
41
  }
41
42
  /**
42
- * A text in `locale`, as {@link findText} finds it. A list in MeoCord's English is joined with its `joiner`, a comma
43
- * unless given; in an app's catalog, in the words of the locale that has it.
43
+ * A text in `locale`, as {@link findText} finds it. An `or` list in an app's catalog reads in the words of the locale
44
+ * that has it; any other list is joined with its `joiner`, a comma unless given, as Intl's `unit` lists join with
45
+ * nothing in zh-CN and with a space in ja.
44
46
  */ function renderText(translator, locale, text) {
45
47
  const found = findText(translator, locale, text.key);
46
48
  if (!found) return text.fallback ?? text.key;
@@ -49,14 +51,15 @@ const lists = new Map();
49
51
  if (!Object.hasOwn(params, name)) return whole;
50
52
  const value = params[name];
51
53
  if (typeof value !== 'object') return String(value);
52
- if ('list' in value) return found.own ? listIn(found.locale, value.style, value.list) : value.list.join(value.joiner ?? ', ');
54
+ if ('list' in value) return found.own && value.style === 'or' ? orListIn(found.locale, value.list) : value.list.join(value.joiner ?? ', ');
53
55
  return renderText(translator, locale, value);
54
56
  });
55
57
  }
56
58
  /** Renders texts in one locale through one translator: a word or number as it is, a text in the locale's words. */ function textRenderer(translator, locale) {
57
59
  return (text)=>typeof text !== 'object' ? String(text) : 'list' in text ? text.list.join(text.joiner ?? ', ') : renderText(translator, locale, text);
58
60
  }
59
- const translators = new WeakMap();
61
+ // Shared by both builds of this version, so either answers in the app's language
62
+ const translators = shared('clientTranslators', ()=>new WeakMap());
60
63
  /** Records the translator of the app a client's interactions and messages come to. */ function registerClientTranslator(client, translator) {
61
64
  if (translator) translators.set(client, translator);
62
65
  else translators.delete(client);
@@ -31,7 +31,7 @@ import 'reflect-metadata';
31
31
  *
32
32
  * @group Utilities
33
33
  * @see {@link ExecutionContext}
34
- * @see {@link https://meocord.dev/docs/4.1/guards | Guards}
34
+ * @see {@link https://meocord.dev/docs/4.2/guards | Guards}
35
35
  */ function createMetadata(description) {
36
36
  const key = Symbol(description);
37
37
  const decorator = (value)=>function(target, propertyKey) {
@@ -1,10 +1,15 @@
1
- let last = 0;
2
- const order = new WeakMap();
1
+ import { shared } from '../../util/shared-state.util.js';
2
+
3
+ // One count for both builds of this version, so calls either makes merge in the order made
4
+ const calls = shared('callOrder', ()=>({
5
+ last: 0,
6
+ order: new WeakMap()
7
+ }));
3
8
  /** Marks a Discord call as made now, so calls recorded in different places can be merged in the order made. */ function stampCall(call) {
4
- order.set(call, ++last);
9
+ calls.order.set(call, ++calls.last);
5
10
  }
6
11
  /** When a call stamped by {@link stampCall} was made, relative to the others. */ function callOrder(call) {
7
- return order.get(call) ?? 0;
12
+ return calls.order.get(call) ?? 0;
8
13
  }
9
14
 
10
15
  export { callOrder, stampCall };
@@ -1,5 +1,6 @@
1
1
  import { EmbedBuilder, ContainerBuilder, resolveColor, TextDisplayBuilder, SectionBuilder, ThumbnailBuilder, MediaGalleryBuilder, FileBuilder, ComponentType, AttachmentBuilder } from 'discord.js';
2
2
  import { renderText, translatorOfClient } from '../meocord-text.js';
3
+ import { shared } from '../../util/shared-state.util.js';
3
4
 
4
5
  /** One of the presenter's texts, in the context's locale through the app's translator; English for a context without one. */ const text = ({ interaction, locale }, key)=>renderText(translatorOfClient(interaction?.client), locale, {
5
6
  key
@@ -21,16 +22,41 @@ import { renderText, translatorOfClient } from '../meocord-text.js';
21
22
  })
22
23
  };
23
24
  /** The id MeoCord gives the containers it renders, so a view left behind can be found again. */ const RENDERED_CONTAINER_ID = 0x4d43;
24
- const presenters = new WeakMap();
25
+ // Shared by both builds of this version, so either answers with the app's presenter
26
+ const presenters = shared('clientPresenters', ()=>new WeakMap());
25
27
  /** Sets the presenter for the interactions a client receives. */ function setPresenter(client, presenter) {
26
28
  presenters.set(client, presenter);
27
29
  }
28
30
  /** The presenter for an interaction's client, or the default one. */ function presenterFor(client) {
29
31
  return client && presenters.get(client) || defaultPresenter;
30
32
  }
31
- function textOf(view) {
32
- return view.emoji ? `${view.emoji} ${view.text}` : view.text;
33
+ /** Discord's limits on a message's text, counted by `String.length` as discord.js's builders count them. */ const TEXT_LIMITS = {
34
+ embed: 4096,
35
+ v2: 4000,
36
+ content: 2000
37
+ };
38
+ /** `text` cut to `limit` characters ending in `…`, on a code point boundary so no surrogate pair is split. */ function fitText(text, limit) {
39
+ if (text.length <= limit) return text;
40
+ let cut = text.slice(0, Math.max(0, limit - 1));
41
+ const last = cut.charCodeAt(cut.length - 1);
42
+ if (last >= 0xd800 && last <= 0xdbff) cut = cut.slice(0, -1);
43
+ return `${cut}…`;
44
+ }
45
+ /**
46
+ * An error's message as a presenter is given it: fitted to `limit`, and, when empty or only whitespace, which Discord
47
+ * refuses as a text, MeoCord's generic error text, so a presenter that writes it as its text always renders.
48
+ */ function presentedMessage(message, limit, generic) {
49
+ return fitText(message.trim() === '' ? generic() : message, limit);
50
+ }
51
+ /**
52
+ * The view's text after what MeoCord puts before it, its emoji and a Text Display's title line. When only those take
53
+ * the two past `limit`, the text is cut so they fit; a text past `limit` by itself is left, a view MeoCord can't render.
54
+ */ function textAfter(prefix, view, limit) {
55
+ const total = prefix.length + view.text.length;
56
+ if (total <= limit || view.text.length > limit || prefix.length >= limit) return prefix + view.text;
57
+ return prefix + fitText(view.text, limit - prefix.length);
33
58
  }
59
+ const emojiOf = (view)=>view.emoji ? `${view.emoji} ` : '';
34
60
  /** Discord's limit of attachments on one message. */ const ATTACHMENT_LIMIT = 10;
35
61
  /** The most items Discord shows in one media gallery. */ const MEDIA_GALLERY_LIMIT = 10;
36
62
  /**
@@ -114,7 +140,7 @@ const IMAGE = /\.(png|jpe?g|gif|webp)$/i;
114
140
  return (view.files ?? []).filter((file)=>!shown.has(`attachment://${nameOf(file)}`));
115
141
  }
116
142
  /** A view as an embed: its image, or else its first image file not its thumbnail, as the embed's image. */ function renderEmbed(view) {
117
- const embed = new EmbedBuilder().setDescription(textOf(view));
143
+ const embed = new EmbedBuilder().setDescription(textAfter(emojiOf(view), view, TEXT_LIMITS.embed));
118
144
  if (view.title) embed.setTitle(view.title);
119
145
  if (view.color !== undefined) embed.setColor(view.color);
120
146
  const image = view.image ?? unshownFiles(view).map(nameOf).find((name)=>IMAGE.test(name));
@@ -125,7 +151,8 @@ const IMAGE = /\.(png|jpe?g|gif|webp)$/i;
125
151
  /** A view as a Components V2 container, carrying MeoCord's id. */ function renderContainer(view) {
126
152
  const container = new ContainerBuilder().setId(RENDERED_CONTAINER_ID);
127
153
  if (view.color !== undefined) container.setAccentColor(resolveColor(view.color));
128
- const text = new TextDisplayBuilder().setContent(view.title ? `### ${view.title}\n${textOf(view)}` : textOf(view));
154
+ const titleLine = view.title ? `### ${view.title}\n` : '';
155
+ const text = new TextDisplayBuilder().setContent(textAfter(titleLine + emojiOf(view), view, TEXT_LIMITS.v2));
129
156
  if (view.thumbnail) {
130
157
  container.addSectionComponents(new SectionBuilder().addTextDisplayComponents(text).setThumbnailAccessory(new ThumbnailBuilder().setURL(urlOf(view, view.thumbnail))));
131
158
  } else container.addTextDisplayComponents(text);
@@ -158,4 +185,4 @@ const IMAGE = /\.(png|jpe?g|gif|webp)$/i;
158
185
  };
159
186
  }
160
187
 
161
- export { ATTACHMENT_LIMIT, DEFAULT_ATTACHMENT_SIZE_LIMIT, REFUSED_AS_TOO_LARGE, RENDERED_CONTAINER_ID, attachmentsOf, defaultPresenter, filesProblem, isTooLarge, presenterFor, renderContainer, renderEmbed, setPresenter, withSendableFiles, withoutFiles };
188
+ export { ATTACHMENT_LIMIT, DEFAULT_ATTACHMENT_SIZE_LIMIT, REFUSED_AS_TOO_LARGE, RENDERED_CONTAINER_ID, TEXT_LIMITS, attachmentsOf, defaultPresenter, filesProblem, fitText, isTooLarge, presentedMessage, presenterFor, renderContainer, renderEmbed, setPresenter, withSendableFiles, withoutFiles };
@@ -8,12 +8,13 @@ import { describeInteraction } from '../../util/interaction.util.js';
8
8
  import { getInstallContext } from './install-context.js';
9
9
  import { resolveFlags, flagNames, hasEphemeral, hasComponentsV2 } from './flags.js';
10
10
  import { rewriteAttachmentUrls, keptAttachmentNames } from './attachments.js';
11
- import { renderEmbed, isTooLarge, REFUSED_AS_TOO_LARGE, withoutFiles, renderContainer, attachmentsOf, withSendableFiles, presenterFor, defaultPresenter, DEFAULT_ATTACHMENT_SIZE_LIMIT } from './presenter.js';
11
+ import { renderEmbed, isTooLarge, REFUSED_AS_TOO_LARGE, withoutFiles, renderContainer, attachmentsOf, withSendableFiles, presenterFor, presentedMessage, TEXT_LIMITS, defaultPresenter, DEFAULT_ATTACHMENT_SIZE_LIMIT } from './presenter.js';
12
12
  import { sameEmbed, withoutRenderedViews, lockComponents, countComponents, V2_COMPONENT_LIMIT, EMBED_LIMIT } from './components.js';
13
13
  import { stampCall } from './call-order.js';
14
14
  import { isUserOutcome } from '../user-outcome.js';
15
15
  import { themeForInteraction } from '../../core/theme-scope.js';
16
16
  import { LOADING_DRAW_TIMEOUT_MS } from '../../core/theme-resolvers.js';
17
+ import { shared } from '../../util/shared-state.util.js';
17
18
 
18
19
  const ALREADY_ACKNOWLEDGED = 40060;
19
20
  /** Discord's code for an interaction it no longer knows, such as one past its three seconds unacknowledged. */ const UNKNOWN_INTERACTION = 10062;
@@ -55,7 +56,8 @@ function toBody(payload) {
55
56
  */ function editedSince(stamp, ours) {
56
57
  return typeof stamp === 'number' && ours !== undefined && stamp > ours;
57
58
  }
58
- const messageLocks = new Map();
59
+ // Both builds of this meocord version keep one, so a message one locks, the other sees locked
60
+ const messageLocks = shared('messageLocks', ()=>new Map());
59
61
  /** How long a locked message is remembered once no call holds it. */ const LOCK_MEMORY_MS = 60000;
60
62
  /**
61
63
  * A body with the theme's primary colour where the app left one unset: an embed with no `color`, and a
@@ -117,9 +119,12 @@ function toJson(value) {
117
119
  return this.phase;
118
120
  }
119
121
  get message() {
122
+ // A deleted answer, or a follow-up in its place, leaves the component's own message behind
123
+ if (this.answer.kind !== 'original') return this.lastMessage;
120
124
  return this.lastMessage ?? ('message' in this.interaction ? this.interaction.message ?? undefined : undefined);
121
125
  }
122
126
  get original() {
127
+ // Each component is a top-level component's API JSON, as `toJSON()` gave it; the lock reads them as plain JSON
123
128
  return this.snapshot;
124
129
  }
125
130
  /** The Discord calls made through this state, for tests. */ get history() {
@@ -451,9 +456,11 @@ function toJson(value) {
451
456
  this.sync();
452
457
  const body = await this.themed(payload, options);
453
458
  if (this.phase === 'unanswered') return this.reply(body);
454
- if (this.phase === 'deferred' && answersWithOwnMessage(this.interaction)) {
459
+ // Once the answer is deleted, the next message sent is the answer
460
+ if (this.answer.kind === 'deleted') return this.answerWith(body);
461
+ if (this.phase === 'deferred' && answersWithOwnMessage(this.interaction) && this.answer.kind === 'original') {
455
462
  // Discord makes a follow-up to a deferred, unsent reply that reply, ignoring its flags: a private one
456
- // would be shown to everyone on a public deferral, so that deferral is deleted first.
463
+ // would be shown to everyone on a public deferral, so that deferral is deleted, and the follow-up is the answer.
457
464
  const requested = Number(MessageFlagsBitField.resolve(body.flags ?? 0));
458
465
  if (!hasEphemeral(requested)) return this.editMessage(body);
459
466
  // A private deferral is already what the flag asks for, which an edit cannot take
@@ -463,7 +470,11 @@ function toJson(value) {
463
470
  });
464
471
  await this.call('deleteReply', undefined, ()=>this.interaction.deleteReply());
465
472
  this.phase = 'replied';
473
+ return this.answerWith(body);
466
474
  }
475
+ return (await this.sendFollowUp(body)).message;
476
+ }
477
+ /** Sends a follow-up, with the flags it was sent with. */ async sendFollowUp(body) {
467
478
  const flags = this.withSuppression(this.flagsFor('followUp', body.flags, false));
468
479
  const sent = forMode(body, hasComponentsV2(flags));
469
480
  const message = await this.call('followUp', {
@@ -473,6 +484,20 @@ function toJson(value) {
473
484
  ...sent,
474
485
  flags
475
486
  }));
487
+ return {
488
+ message: message,
489
+ flags
490
+ };
491
+ }
492
+ /** Sends a follow-up that becomes the answer, which later edits and `delete()` act on by its id. */ async answerWith(body) {
493
+ const { message, flags } = await this.sendFollowUp(body);
494
+ // Its privacy as sent, not as read back from the message
495
+ this.answer = {
496
+ kind: 'followUp',
497
+ id: message.id,
498
+ ephemeral: hasEphemeral(flags)
499
+ };
500
+ this.lastMessage = message;
476
501
  return message;
477
502
  }
478
503
  delete() {
@@ -480,7 +505,22 @@ function toJson(value) {
480
505
  this.sync();
481
506
  if (this.phase === 'unanswered') throw new Error('There is no answer to delete: the interaction has not been answered.');
482
507
  this.refuseAfterModal();
483
- await this.call('deleteReply', undefined, ()=>this.interaction.deleteReply());
508
+ const { answer } = this;
509
+ if (answer.kind === 'deleted') throw new Error('There is no answer to delete: it was deleted.');
510
+ // A follow-up is as private as it was sent, a command's own reply as the interaction's answer, a component's
511
+ // message as its flags
512
+ const shown = this.message?.flags?.has(MessageFlags.Ephemeral) ?? false;
513
+ const ephemeral = answer.kind === 'followUp' ? answer.ephemeral : answersWithOwnMessage(this.interaction) ? Boolean(this.interaction.ephemeral) || shown : shown;
514
+ if (answer.kind === 'followUp') await this.call('deleteReply', answer.id, ()=>this.interaction.deleteReply(answer.id));
515
+ else await this.call('deleteReply', undefined, ()=>this.interaction.deleteReply());
516
+ this.answer = {
517
+ kind: 'deleted',
518
+ ephemeral
519
+ };
520
+ this.lastMessage = undefined;
521
+ // The message is gone: nothing is left to put back as it was before a lock
522
+ this.settled = true;
523
+ this.leave();
484
524
  });
485
525
  }
486
526
  /** A modal is the interaction's whole answer: what the user enters arrives as a submit interaction of its own. */ refuseAfterModal() {
@@ -590,7 +630,20 @@ function toJson(value) {
590
630
  withAttachments(body) {
591
631
  return rewriteAttachmentUrls(body, keptAttachmentNames(body, this.message?.attachments?.values() ?? []));
592
632
  }
593
- /** Edits the answer through the interaction, and through the channel only once its token has expired. */ async editMessage(body, { restoring = false } = {}) {
633
+ /**
634
+ * Edits the answer through the interaction, and through the channel only once its token has expired. Once the answer
635
+ * is deleted, the edit is sent as a follow-up that becomes the answer, as private as the deleted one unless it sets
636
+ * flags of its own.
637
+ */ async editMessage(body, { restoring = false } = {}) {
638
+ const { answer } = this;
639
+ if (answer.kind === 'deleted') {
640
+ if (restoring) return undefined;
641
+ const { attachments: _attachments, ...rest } = body;
642
+ return this.answerWith({
643
+ ...rest,
644
+ flags: body.flags ?? (answer.ephemeral ? MessageFlags.Ephemeral : 0)
645
+ });
646
+ }
594
647
  if (!restoring) {
595
648
  this.settled = true;
596
649
  this.leave();
@@ -612,8 +665,13 @@ function toJson(value) {
612
665
  ...this.withAttachments(forMode(body, this.v2)),
613
666
  flags
614
667
  };
668
+ // A follow-up that is the answer is edited by its id
669
+ const edit = answer.kind === 'followUp' ? {
670
+ ...sent,
671
+ message: answer.id
672
+ } : sent;
615
673
  try {
616
- this.lastMessage = await this.call('editReply', sent, ()=>this.interaction.editReply(sent));
674
+ this.lastMessage = await this.call('editReply', edit, ()=>this.interaction.editReply(edit));
617
675
  } catch (error) {
618
676
  const message = this.message;
619
677
  const expired = Date.now() - this.interaction.createdTimestamp >= TOKEN_EXPIRED_AFTER_MS;
@@ -732,12 +790,16 @@ function toJson(value) {
732
790
  * place, and the failure is returned for the caller to report once the user is answered.
733
791
  */ async drawnView(error, message, theme) {
734
792
  const tone = isUserOutcome(error, this.interaction) ? 'warning' : 'danger';
793
+ const context = this.presenterContext(this.v2, theme);
794
+ // Fitted to the mode's limit first, so a presenter that writes the message as its text, MeoCord's own included, renders
795
+ const generic = ()=>textFor(this.interaction, {
796
+ key: 'meocord.fallback.error'
797
+ });
735
798
  const presented = {
736
- message,
799
+ message: presentedMessage(message, TEXT_LIMITS[context.mode], generic),
737
800
  error,
738
801
  tone
739
802
  };
740
- const context = this.presenterContext(this.v2, theme);
741
803
  const ready = (view)=>{
742
804
  const sendable = this.sendable(themedView(view, theme), this.keptBeside());
743
805
  // Rendered once here, so a view MeoCord cannot render fails before anything is sent
@@ -880,6 +942,11 @@ function toJson(value) {
880
942
  await this.followUpNow(this.privateError(view));
881
943
  return;
882
944
  }
945
+ // A deleted message has nothing to add the error to or put back: the error follows up, and is the answer
946
+ if (this.answer.kind === 'deleted') {
947
+ await this.followUpNow(this.privateError(view));
948
+ return;
949
+ }
883
950
  // The message the component is on: whether it is private does not change with edits.
884
951
  const current = 'message' in this.interaction ? this.interaction.message ?? undefined : this.message;
885
952
  if (current?.flags?.has(MessageFlags.Ephemeral)) {
@@ -925,6 +992,12 @@ function toJson(value) {
925
992
  /** The answer steps asked for, run one after another; `pending` counts those not yet settled. */ this.queue = Promise.resolve();
926
993
  this.pending = 0;
927
994
  /** Whether the answer was a modal, which has no message to edit or delete. */ this.modalShown = false;
995
+ /**
996
+ * Which message is the answer: the interaction's own reply, a follow-up that took its place, or none since `delete()`,
997
+ * with whether the deleted one was private. `sync()` leaves it alone, so every edit follows it.
998
+ */ this.answer = {
999
+ kind: 'original'
1000
+ };
928
1001
  this.calls = [];
929
1002
  /** Whether the interaction was acknowledged with a private deferred reply for a drawn error view, which that view replaces. */ this.privateDeferral = false;
930
1003
  /** Whether the locked message was answered, so nothing restores it again. */ this.settled = false;
@@ -936,7 +1009,8 @@ function toJson(value) {
936
1009
  this.v2 = Boolean(message?.flags?.has(MessageFlags.IsComponentsV2));
937
1010
  }
938
1011
  }
939
- const states = new WeakMap();
1012
+ // One state per interaction for both builds of this version, so either sees where the other left its answer
1013
+ const states = shared('responseStates', ()=>new WeakMap());
940
1014
  /** The response state of a repliable interaction, created on first use. */ function responseOf(interaction) {
941
1015
  let state = states.get(interaction);
942
1016
  if (!state) states.set(interaction, state = new InteractionResponse(interaction));
@@ -1,7 +1,9 @@
1
- import { createRegexFromPattern } from '../decorator/controller.decorator.js';
1
+ import { createRegexFromPattern, literalBraces, MAX_CUSTOM_ID_LENGTH } from '../decorator/controller.decorator.js';
2
2
  import { parseSegment } from '../core/scalar-types.js';
3
+ import { Logger } from './logger.js';
4
+ import { warnOnce } from './deprecation.js';
3
5
 
4
- /** The longest customId Discord accepts. */ const MAX_CUSTOM_ID_LENGTH = 100;
6
+ const logger = new Logger('Route');
5
7
  const PLACEHOLDER = /\{(\w+)(?::([^}/]*))?}/g;
6
8
  /** Encodes the characters that would end a param's segment, or read as an encoding, in a customId. */ const encodeSegment = (value)=>value.replace(/%/g, '%25').replace(/\//g, '%2F');
7
9
  /**
@@ -14,6 +16,10 @@ const PLACEHOLDER = /\{(\w+)(?::([^}/]*))?}/g;
14
16
  decodeSegment(value)
15
17
  ]));
16
18
  }
19
+ /** The segment types whose value is text, which `build` takes only as a string. */ const TEXT_TYPES = new Set([
20
+ 'snowflake',
21
+ 'uuid'
22
+ ]);
17
23
  /**
18
24
  * Makes a typed route from a customId pattern, so one declaration serves the handler and the ids that reach it.
19
25
  *
@@ -26,7 +32,8 @@ const PLACEHOLDER = /\{(\w+)(?::([^}/]*))?}/g;
26
32
  * fails to compile, and a typed param takes a value of its type.
27
33
  *
28
34
  * @param pattern - The customId pattern, where `{name}` captures one `/`-separated segment and `{name:int}` a
29
- * typed one.
35
+ * typed one. A param's name is ASCII letters, digits and _: a brace pair such as `{café}` is literal text, and is
36
+ * warned about.
30
37
  * @returns A route whose `build` takes a value for each param.
31
38
  * @throws Error when the pattern cannot be read, as `@Command` would throw for it.
32
39
  *
@@ -48,9 +55,12 @@ const PLACEHOLDER = /\{(\w+)(?::([^}/]*))?}/g;
48
55
  *
49
56
  * @group Utilities
50
57
  * @see {@link Route}
51
- * @see {@link https://meocord.dev/docs/4.1/components | Buttons, selects and modals}
58
+ * @see {@link https://meocord.dev/docs/4.2/components | Buttons, selects and modals}
52
59
  */ function route(pattern) {
53
60
  const { params, types } = createRegexFromPattern(pattern);
61
+ const braces = literalBraces(pattern);
62
+ // Once per pattern, as a route made inside a handler is made on every call
63
+ if (braces) warnOnce(logger, `Pattern "${pattern}": ${braces}`);
54
64
  const names = new Set(params);
55
65
  const build = (values = {})=>{
56
66
  const unknown = Object.keys(values).filter((name)=>!names.has(name));
@@ -60,6 +70,11 @@ const PLACEHOLDER = /\{(\w+)(?::([^}/]*))?}/g;
60
70
  const value = Object.hasOwn(values, name) ? values[name] : undefined;
61
71
  const type = Object.hasOwn(types, name) ? types[name] : undefined;
62
72
  if (value === undefined || value === null) throw new TypeError(`route('${pattern}').build() needs a value for ${placeholder}.`);
73
+ // A snowflake's or uuid's value is its text: a number for a snowflake may have lost digits before it got here
74
+ if (type && TEXT_TYPES.has(type) && typeof value !== 'string') {
75
+ const lost = type === 'snowflake' ? ' A number may have lost digits already: pass the ID as text, such as user.id.' : '';
76
+ throw new TypeError(`route('${pattern}').build() got the ${typeof value} ${String(value)} for ${placeholder}, which takes its value as a string.${lost}`);
77
+ }
63
78
  const text = String(value);
64
79
  if (text === '') throw new TypeError(`route('${pattern}').build() got an empty {${name}}, which no customId segment can hold.`);
65
80
  // A typed segment must read back as the value it was built from, or the route could never match it
@@ -69,6 +84,7 @@ const PLACEHOLDER = /\{(\w+)(?::([^}/]*))?}/g;
69
84
  }
70
85
  return encodeSegment(text);
71
86
  });
87
+ if (id === '') throw new RangeError(`route('${pattern}').build() made an empty customId; Discord's customIds are 1 to ${MAX_CUSTOM_ID_LENGTH} characters.`);
72
88
  if (id.length > MAX_CUSTOM_ID_LENGTH) {
73
89
  throw new RangeError(`route('${pattern}').build() made a customId of ${id.length} characters, over Discord's ${MAX_CUSTOM_ID_LENGTH}: "${id}".`);
74
90
  }
@@ -81,4 +97,4 @@ const PLACEHOLDER = /\{(\w+)(?::([^}/]*))?}/g;
81
97
  });
82
98
  }
83
99
 
84
- export { MAX_CUSTOM_ID_LENGTH, decodeRouteParams, route };
100
+ export { decodeRouteParams, route };
@@ -9,7 +9,7 @@ import { isShardProcess } from '../util/shard-process.util.js';
9
9
  * never held forever. MeoCord itself fails the call after `cooldownStoreTimeoutMs`, a second by default.
10
10
  */ const SHARDED_COOLDOWN_ABANDON_MS = 30000;
11
11
  /** A shard's channel to its manager, or none outside process sharding. */ function managerChannel() {
12
- return isShardProcess() && typeof process.send === 'function' ? channelOver(process) : undefined;
12
+ return isShardProcess(process.env) && typeof process.send === 'function' ? channelOver(process) : undefined;
13
13
  }
14
14
  /**
15
15
  * The channel over a process's IPC. A send on a closed channel does not throw: without a callback, Node raises an
@@ -58,7 +58,7 @@ import { interactionLocale, messageLocale, textOfIssue, renderText } from './meo
58
58
  * @group Utilities
59
59
  * @category Localisation
60
60
  * @see {@link createTranslator}
61
- * @see {@link https://meocord.dev/docs/4.1/localisation | Localisation}
61
+ * @see {@link https://meocord.dev/docs/4.2/localisation | Localisation}
62
62
  */ function translateError(error, t, target) {
63
63
  const locale = target instanceof BaseInteraction ? interactionLocale(target) : target instanceof Message ? messageLocale(target) : target;
64
64
  return errorText(error, t, locale);