meocord 4.2.1 → 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 (36) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/dist/cjs/_shared/{controller.decorator-XKMlcUZz.cjs → controller.decorator-U2NBWcoU.cjs} +45 -13
  3. package/dist/cjs/_shared/{providers-BZgJyfZS.cjs → providers-B4MOFBrW.cjs} +58 -15
  4. package/dist/cjs/_shared/{sharded-cooldown-store-CsJJyCZd.cjs → sharded-cooldown-store-HTUb3Ii_.cjs} +1 -1
  5. package/dist/cjs/_shared/{startup-roots-CgfPQrNl.cjs → startup-roots-lA5xkBFq.cjs} +12 -11
  6. package/dist/cjs/_shared/{translate-error-BUAR1WYG.cjs → translate-error-CbBHUu5P.cjs} +1 -1
  7. package/dist/cjs/common/index.cjs +3 -3
  8. package/dist/cjs/core/index.cjs +8 -6
  9. package/dist/cjs/decorator/index.cjs +33 -3
  10. package/dist/cjs/testing/index.cjs +295 -60
  11. package/dist/cli.json +1 -1
  12. package/dist/esm/common/response/response-state.js +5 -5
  13. package/dist/esm/common/translator.js +15 -5
  14. package/dist/esm/core/dispatcher.js +9 -8
  15. package/dist/esm/core/filter-runner.js +2 -2
  16. package/dist/esm/core/handler-pipeline.js +10 -4
  17. package/dist/esm/core/meocord-factory.js +4 -2
  18. package/dist/esm/core/message-help.js +32 -6
  19. package/dist/esm/core/observer-runner.js +2 -1
  20. package/dist/esm/core/providers.js +12 -2
  21. package/dist/esm/core/stage-scope.js +2 -1
  22. package/dist/esm/core/theme-scope.js +4 -1
  23. package/dist/esm/decorator/app.decorator.js +31 -0
  24. package/dist/esm/decorator/filter.decorator.js +2 -2
  25. package/dist/esm/package.json.js +1 -1
  26. package/dist/esm/testing/catalog.js +4 -13
  27. package/dist/esm/testing/discord-defaults.js +21 -1
  28. package/dist/esm/testing/meocord-testing-module.js +24 -16
  29. package/dist/esm/testing/mock-interaction.js +242 -28
  30. package/dist/esm/testing/strict-mocks.js +7 -4
  31. package/dist/esm/util/value.util.js +19 -2
  32. package/dist/types/common/index.d.cts +4 -1
  33. package/dist/types/common/index.d.ts +4 -1
  34. package/dist/types/testing/index.d.cts +28 -14
  35. package/dist/types/testing/index.d.ts +28 -14
  36. package/package.json +1 -1
@@ -2,11 +2,11 @@
2
2
 
3
3
  require('reflect-metadata');
4
4
  var inversify = require('inversify');
5
- var providers = require('../_shared/providers-BZgJyfZS.cjs');
5
+ var providers = require('../_shared/providers-B4MOFBrW.cjs');
6
6
  var discord_js = require('discord.js');
7
7
  var enum_index = require('../enum/index.cjs');
8
- var controller_decorator = require('../_shared/controller.decorator-XKMlcUZz.cjs');
9
- var startupRoots = require('../_shared/startup-roots-CgfPQrNl.cjs');
8
+ var controller_decorator = require('../_shared/controller.decorator-U2NBWcoU.cjs');
9
+ var startupRoots = require('../_shared/startup-roots-lA5xkBFq.cjs');
10
10
  var node_crypto = require('node:crypto');
11
11
  require('node:async_hooks');
12
12
  require('crypto');
@@ -21,7 +21,7 @@ require('dayjs/plugin/utc.js');
21
21
  require('dayjs/plugin/timezone.js');
22
22
  require('module');
23
23
  require('chalk');
24
- require('../_shared/translate-error-BUAR1WYG.cjs');
24
+ require('../_shared/translate-error-CbBHUu5P.cjs');
25
25
 
26
26
  // Framework-agnostic mock function, so `meocord/testing` depends on neither jest nor vitest.
27
27
  // Both detect it through `_isMockFunction` and read `fn.mock.calls` in their assertions.
@@ -517,8 +517,9 @@ const settings = controller_decorator.shared('strictMocks', ()=>({
517
517
  * and `message.thread` is `null` when the channel caches no thread under the message's id. So do a message's
518
518
  * `editedAt`, a member's `presence`, a server's `verified` and `systemChannel` and a channel's `parent`, each `null` or
519
519
  * `false` until the test gives what it is computed from. A reaction's `me` is `false`, a message's
520
- * `mentions.repliedUser` and a modal's `message` are `null`, and a reaction has a whole message of its own, so
521
- * MeoCord's dispatcher fetches neither. No placeholder warning is logged.
520
+ * `mentions.repliedUser` and a modal's `message` are `null`, an interaction's `ephemeral` is `null` until `reply()` or
521
+ * `deferReply()` decides it, and a reaction has a whole message of its own, so MeoCord's dispatcher fetches neither. No
522
+ * placeholder warning is logged.
522
523
  *
523
524
  * @remarks
524
525
  * Those getters read what a server holds, so strict mocks also have it as Discord sends it: the bot's member is in the
@@ -531,8 +532,10 @@ const settings = controller_decorator.shared('strictMocks', ()=>({
531
532
  * A message the bot sends is its `client.user`'s, and one it sends in a DM is in that DM. Give the bot's member a role
532
533
  * to let it do more, such as kick a member its role ranks above. A value the test sets on a mock wins over the computed
533
534
  * one. An interaction's answer that discord.js would refuse, such as a second `reply()` after one a test set with
534
- * `mockResolvedValue`, is refused too, where without it the mock runs it and warns. The next major version (5.0)
535
- * computes these values, and refuses those answers, without the call.
535
+ * `mockResolvedValue`, is refused too, where without it the mock runs it and warns. An answer marks the interaction
536
+ * `replied`, `deferred` or `responded` once it resolves, as discord.js does, so code that doesn't await one fails as it
537
+ * would on a bot, within one run of the test's code. The next major version (5.0) computes these values, and refuses
538
+ * those answers, without the call.
536
539
  *
537
540
  * `invoke` also refuses a call that gives a handler fewer arguments than it declares where it builds none, such as a
538
541
  * reaction handler given its reaction without its `ReactionEvent`; without strict mocks, it warns once and calls it.
@@ -684,6 +687,26 @@ const channelPermissions = discord_js.GuildChannel.prototype;
684
687
  roles: (member)=>memberRoles(member, []),
685
688
  permissions: REAL_GETTER
686
689
  }),
690
+ // A server's emoji, sticker, scheduled event and AutoMod rule are named, and what discord.js formats from them
691
+ // computes: an emoji's mention and identifier, an event's and an invite's link
692
+ defaultsOf(discord_js.GuildEmoji, {
693
+ name: ()=>'emoji',
694
+ animated: ()=>false,
695
+ identifier: REAL_GETTER
696
+ }),
697
+ defaultsOf(discord_js.Sticker, {
698
+ name: ()=>'sticker'
699
+ }),
700
+ defaultsOf(discord_js.GuildScheduledEvent, {
701
+ name: ()=>'event',
702
+ url: REAL_GETTER
703
+ }),
704
+ defaultsOf(discord_js.AutoModerationRule, {
705
+ name: ()=>'rule'
706
+ }),
707
+ defaultsOf(discord_js.Invite, {
708
+ url: REAL_GETTER
709
+ }),
687
710
  defaultsOf(discord_js.Role, {
688
711
  name: ()=>'role',
689
712
  // Above @everyone, which a server's role manager makes at position 0
@@ -1584,8 +1607,49 @@ const MANAGER_ITEMS = [
1584
1607
  guild: guildOf(manager)
1585
1608
  } : {}
1586
1609
  })
1610
+ ],
1611
+ [
1612
+ discord_js.GuildEmojiManager,
1613
+ (id, manager)=>itemInGuild(discord_js.GuildEmoji, {
1614
+ id: id ?? nextSnowflake()
1615
+ }, manager)
1616
+ ],
1617
+ [
1618
+ discord_js.GuildStickerManager,
1619
+ (id, manager)=>itemInGuild(discord_js.Sticker, {
1620
+ id: id ?? nextSnowflake()
1621
+ }, manager)
1622
+ ],
1623
+ [
1624
+ discord_js.GuildScheduledEventManager,
1625
+ (id, manager)=>itemInGuild(discord_js.GuildScheduledEvent, {
1626
+ id: id ?? nextSnowflake()
1627
+ }, manager)
1628
+ ],
1629
+ [
1630
+ discord_js.AutoModerationRuleManager,
1631
+ (id, manager)=>itemInGuild(discord_js.AutoModerationRule, {
1632
+ id: id ?? nextSnowflake()
1633
+ }, manager)
1634
+ ],
1635
+ // An invite is keyed by its code, as discord.js caches and fetches one
1636
+ [
1637
+ discord_js.GuildInviteManager,
1638
+ (code, manager)=>itemInGuild(discord_js.Invite, {
1639
+ code: code ?? BigInt(nextSnowflake()).toString(36)
1640
+ }, manager)
1587
1641
  ]
1588
1642
  ];
1643
+ /** A mock of `Class` with `props`, in the manager's guild, by the guild and its id, when the manager has one. */ function itemInGuild(Class, props, manager) {
1644
+ const guild = guildOf(manager);
1645
+ return createMockInteraction(Class, {
1646
+ ...props,
1647
+ ...guild ? {
1648
+ guild,
1649
+ guildId: guild.id
1650
+ } : {}
1651
+ });
1652
+ }
1589
1653
  const returnsPromise = (key, method)=>method.constructor.name === 'AsyncFunction' || PROMISE_METHODS.has(key) || /^set[A-Z]/.test(key) && !SYNC_SETTERS.has(key);
1590
1654
  // The ids a fetch asks for: an id, a discord.js object, or options naming one or several, such as `{ user: id }` or
1591
1655
  // `{ user: [ids] }`; undefined for a fetch of a list
@@ -1659,6 +1723,18 @@ function fetchedIds(args) {
1659
1723
  return typeof user === 'string' ? user : user?.id;
1660
1724
  });
1661
1725
  }
1726
+ // An invite is made for the channel given, in its guild, and cached nowhere, as discord.js's create does
1727
+ if (item && key === 'create' && target instanceof discord_js.GuildInviteManager) {
1728
+ return createMockFn(async (channel)=>{
1729
+ const invite = item(undefined, receiver());
1730
+ const channelId = typeof channel === 'string' ? channel : channel?.id;
1731
+ const found = channel instanceof discord_js.BaseChannel ? channel : channelId ? cacheOf(guildOf(receiver())?.channels)?.get(channelId) ?? null : null;
1732
+ return Object.assign(invite, {
1733
+ channel: found,
1734
+ channelId: channelId ?? null
1735
+ });
1736
+ });
1737
+ }
1662
1738
  // Every other manager here caches what its create() makes, as discord.js's does; check a manager's create when
1663
1739
  // adding it to MANAGER_ITEMS
1664
1740
  if (item && key === 'create') {
@@ -1875,24 +1951,36 @@ const isSnowflake = (id)=>typeof id === 'string' && /^\d{1,20}$/.test(id);
1875
1951
  };
1876
1952
  controller_decorator.stampCall(call);
1877
1953
  log.push(call);
1878
- let undo;
1954
+ let gated;
1879
1955
  try {
1880
- // Before the behaviour, as discord.js changes the state at once: a second answer made without awaiting is refused
1881
- if (gate) undo = gate(args, byTest);
1956
+ // Before the behaviour, as discord.js checks the state before it sends an answer
1957
+ if (gate) gated = gate(args, byTest);
1882
1958
  } catch (error) {
1883
1959
  call.error = error;
1884
1960
  return Promise.reject(error);
1885
1961
  }
1886
1962
  const failed = (error)=>{
1887
- undo?.();
1963
+ gated?.settle?.();
1964
+ gated?.undo?.();
1888
1965
  call.error = error;
1889
1966
  throw error;
1890
1967
  };
1968
+ // Once the behaviour resolves, as discord.js changes the state once Discord has taken the answer
1969
+ const landed = (value)=>{
1970
+ gated?.settle?.();
1971
+ try {
1972
+ gated?.land?.();
1973
+ } catch (error) {
1974
+ call.error = error;
1975
+ throw error;
1976
+ }
1977
+ return value;
1978
+ };
1891
1979
  try {
1892
1980
  const result = Reflect.apply(behaviour, this, args);
1893
1981
  // Recorded and passed on, so a rejection nobody awaits is unhandled in the test, as it is on a bot
1894
- if (result instanceof Promise) return result.then(undefined, failed);
1895
- return result;
1982
+ if (result instanceof Promise) return result.then(landed, failed);
1983
+ return landed(result);
1896
1984
  } catch (error) {
1897
1985
  return failed(error);
1898
1986
  }
@@ -2006,7 +2094,31 @@ function createMockInteraction(Class, props) {
2006
2094
  if (repliable) {
2007
2095
  instance.replied = false;
2008
2096
  instance.deferred = false;
2009
- instance.ephemeral = false;
2097
+ // Null until reply() or deferReply() decides it, as discord.js has it; default mode reads false until then, and a
2098
+ // test reading it there is told once. A value the test sets is read as set
2099
+ const strict = strictMocks();
2100
+ let ephemeral = strict ? null : false;
2101
+ let ephemeralDecided = false;
2102
+ // Whether one of the mock's own first answers ran; flags a test sets without one leave ephemeral as discord.js would
2103
+ let answeredByMock = false;
2104
+ const answeredOutside = ()=>Boolean(instance.replied || instance.deferred) && !answeredByMock;
2105
+ const decideEphemeral = (value, decided)=>{
2106
+ ephemeral = value;
2107
+ ephemeralDecided = decided;
2108
+ };
2109
+ Object.defineProperty(instance, 'ephemeral', {
2110
+ get: ()=>{
2111
+ // Quiet once a test answered the interaction itself: MeoCord then reads ephemeral only for truthiness, where false
2112
+ // and null act alike
2113
+ if (!ephemeralDecided && !strict && !answeredOutside()) {
2114
+ controller_decorator.warnPlaceholder(mockLogger, `${Object.getPrototypeOf(instance).constructor.name}.ephemeral`, 'it is null until reply() or deferReply() decides it, as discord.js has it', 'Reply or defer first, or call useStrictMocks() to read null now.');
2115
+ }
2116
+ return ephemeral;
2117
+ },
2118
+ set: (value)=>decideEphemeral(value, true),
2119
+ enumerable: true,
2120
+ configurable: true
2121
+ });
2010
2122
  // discord.js's errors, with their codes; a call before any answer keeps the message the mock has always given
2011
2123
  const alreadyReplied = ()=>discordjsError(discord_js.DiscordjsErrorCodes.InteractionAlreadyReplied);
2012
2124
  const notYetReplied = (method)=>discordjsError(discord_js.DiscordjsErrorCodes.InteractionNotReplied, `Cannot call ${method}() before replying or deferring.`);
@@ -2032,7 +2144,9 @@ function createMockInteraction(Class, props) {
2032
2144
  const saved = {
2033
2145
  replied: instance.replied,
2034
2146
  deferred: instance.deferred,
2035
- ephemeral: instance.ephemeral,
2147
+ ephemeral,
2148
+ ephemeralDecided,
2149
+ answeredByMock,
2036
2150
  original,
2037
2151
  originalGone,
2038
2152
  followUps: new Map(followUps),
@@ -2042,10 +2156,10 @@ function createMockInteraction(Class, props) {
2042
2156
  return ()=>{
2043
2157
  Object.assign(instance, {
2044
2158
  replied: saved.replied,
2045
- deferred: saved.deferred,
2046
- ephemeral: saved.ephemeral
2159
+ deferred: saved.deferred
2047
2160
  });
2048
- ({ original, originalGone, answeredByTest, modalAnswered } = saved);
2161
+ decideEphemeral(saved.ephemeral, saved.ephemeralDecided);
2162
+ ({ original, originalGone, answeredByTest, answeredByMock, modalAnswered } = saved);
2049
2163
  followUps.clear();
2050
2164
  for (const [id, held] of saved.followUps)followUps.set(id, held);
2051
2165
  };
@@ -2070,24 +2184,76 @@ function createMockInteraction(Class, props) {
2070
2184
  return build;
2071
2185
  }
2072
2186
  };
2073
- /** A first answer: refused once the interaction has one, else it replies or defers. */ const first = (method, kind, then)=>(args, byTest)=>{
2074
- if (answered()) refuse(method, alreadyReplied(), byTest);
2075
- // Read first: flags discord.js refuses throw before anything changes
2076
- const ephemeral = method === 'reply' || method === 'deferReply' ? hasEphemeralFlag(args[0]) : undefined;
2187
+ // The changes answers make to the reply state, in order. One whose answer fails is undone once every change after
2188
+ // it is undone too, so a later answer's change stands while it does, as on a bot
2189
+ const changes = [];
2190
+ const undoneInTurn = (undo)=>{
2191
+ const change = {
2192
+ undo,
2193
+ failed: false
2194
+ };
2195
+ changes.push(change);
2196
+ return ()=>{
2197
+ change.failed = true;
2198
+ while(changes.at(-1)?.failed)changes.pop().undo();
2199
+ };
2200
+ };
2201
+ // The first answers called and not yet settled, oldest first, for default mode to warn where a bot would differ
2202
+ const unsettled = [];
2203
+ // Default mode warns once per interaction where code that doesn't await an answer would fail on a bot
2204
+ let inFlightWarned = false;
2205
+ const warnInFlight = (text)=>{
2206
+ if (inFlightWarned) return;
2207
+ inFlightWarned = true;
2208
+ mockLogger.warn(text);
2209
+ };
2210
+ /**
2211
+ * A first answer: refused once the interaction has one, else it replies or defers. Under strict mocks the change
2212
+ * waits for the answer to land, as discord.js makes it once the request resolves, so one sent while another is in
2213
+ * flight passes discord.js's check and Discord refuses it with 40060 if the other landed first. Default mode makes
2214
+ * the change at once.
2215
+ */ const first = (method, kind, then)=>(args, byTest)=>{
2216
+ if (answered()) {
2217
+ if (unsettled.length > 0) {
2218
+ warnInFlight(`${method}() was called while ${unsettled[0]}() had not resolved; on a bot discord.js sends it, and Discord ` + `refuses it with 40060 ("Interaction has already been acknowledged") once the first lands. Await ` + `${unsettled[0]}() before answering again, or call useStrictMocks() to have the mock answer as Discord does.`);
2219
+ }
2220
+ refuse(method, alreadyReplied(), byTest);
2221
+ }
2222
+ // Read first: flags discord.js refuses throw before anything changes; reply() and deferReply() decide
2223
+ // ephemeral, as discord.js's do, and update() and deferUpdate() leave it
2224
+ const flagged = method === 'reply' || method === 'deferReply' ? hasEphemeralFlag(args[0]) : undefined;
2077
2225
  const undo = snapshot();
2078
2226
  instance[kind] = true;
2079
- if (ephemeral) instance.ephemeral = true;
2227
+ if (flagged !== undefined) decideEphemeral(flagged, true);
2080
2228
  answeredByTest = byTest;
2229
+ answeredByMock = true;
2081
2230
  try {
2082
2231
  then(args);
2083
2232
  } catch (error) {
2084
2233
  undo();
2085
2234
  throw error;
2086
2235
  }
2087
- return undo;
2236
+ if (strict) {
2237
+ const land = snapshot();
2238
+ undo();
2239
+ return {
2240
+ land: ()=>{
2241
+ if (answered()) throw createDiscordError(40060, 'Interaction has already been acknowledged.');
2242
+ land();
2243
+ }
2244
+ };
2245
+ }
2246
+ unsettled.push(method);
2247
+ return {
2248
+ undo: undoneInTurn(undo),
2249
+ settle: ()=>void unsettled.splice(unsettled.indexOf(method), 1)
2250
+ };
2088
2251
  };
2089
2252
  /** A later answer: refused before the interaction has one. */ const later = (method, then)=>(args, byTest)=>{
2090
2253
  if (!answered()) refuse(method, notYetReplied(method), byTest);
2254
+ if (unsettled.length > 0) {
2255
+ warnInFlight(`${unsettled[0]}() had not resolved when ${method}() was called; on a bot this throws InteractionNotReplied. ` + `Await ${unsettled[0]}() first, or call useStrictMocks() to have the mock refuse it now.`);
2256
+ }
2091
2257
  const undo = snapshot();
2092
2258
  try {
2093
2259
  then(args);
@@ -2095,7 +2261,9 @@ function createMockInteraction(Class, props) {
2095
2261
  undo();
2096
2262
  throw error;
2097
2263
  }
2098
- return undo;
2264
+ return {
2265
+ undo: undoneInTurn(undo)
2266
+ };
2099
2267
  };
2100
2268
  /** The id an edit, fetch or delete names, `undefined` for the original response. */ const targetId = (message)=>{
2101
2269
  if (message === undefined || message === '@original') return undefined;
@@ -2213,12 +2381,31 @@ function createMockInteraction(Class, props) {
2213
2381
  // it before answering would decide the window was already closed.
2214
2382
  if (instance.type === discord_js.InteractionType.ApplicationCommandAutocomplete) {
2215
2383
  instance.responded = false;
2384
+ // Under strict mocks responded is set once respond() lands, as discord.js sets it once the request resolves, and a
2385
+ // second one sent while the first is in flight is refused by Discord with 40060; default mode sets it at once
2386
+ let respondsInFlight = 0;
2387
+ let respondWarned = false;
2216
2388
  stubs.set('respond', createMockFn(async (choices)=>{
2217
- if (instance.responded) throw discordjsError(discord_js.DiscordjsErrorCodes.InteractionAlreadyReplied);
2389
+ if (instance.responded) {
2390
+ if (respondsInFlight > 0 && !respondWarned) {
2391
+ respondWarned = true;
2392
+ mockLogger.warn('respond() was called while respond() had not resolved; on a bot discord.js sends it, and Discord refuses ' + 'it with 40060 ("Interaction has already been acknowledged") once the first lands. Await respond() ' + 'before responding again, or call useStrictMocks() to have the mock respond as Discord does.');
2393
+ }
2394
+ throw discordjsError(discord_js.DiscordjsErrorCodes.InteractionAlreadyReplied);
2395
+ }
2218
2396
  // Discord refuses a list longer than its limit, and the menu shows nothing
2219
2397
  if (Array.isArray(choices) && choices.length > MAX_AUTOCOMPLETE_CHOICES) {
2220
2398
  throw createDiscordError(50035, `Invalid Form Body\ndata.choices[BASE_TYPE_MAX_LENGTH]: Must be ${MAX_AUTOCOMPLETE_CHOICES} or fewer in length.`);
2221
2399
  }
2400
+ respondsInFlight++;
2401
+ if (!strictMocks()) instance.responded = true;
2402
+ try {
2403
+ await undefined;
2404
+ } finally{
2405
+ respondsInFlight--;
2406
+ }
2407
+ if (!strictMocks()) return;
2408
+ if (instance.responded) throw createDiscordError(40060, 'Interaction has already been acknowledged.');
2222
2409
  instance.responded = true;
2223
2410
  }));
2224
2411
  }
@@ -2796,6 +2983,26 @@ const HOLDS = [
2796
2983
  [
2797
2984
  discord_js.PresenceManager.prototype,
2798
2985
  discord_js.Presence
2986
+ ],
2987
+ [
2988
+ discord_js.GuildEmojiManager.prototype,
2989
+ discord_js.GuildEmoji
2990
+ ],
2991
+ [
2992
+ discord_js.GuildStickerManager.prototype,
2993
+ discord_js.Sticker
2994
+ ],
2995
+ [
2996
+ discord_js.GuildScheduledEventManager.prototype,
2997
+ discord_js.GuildScheduledEvent
2998
+ ],
2999
+ [
3000
+ discord_js.AutoModerationRuleManager.prototype,
3001
+ discord_js.AutoModerationRule
3002
+ ],
3003
+ [
3004
+ discord_js.GuildInviteManager.prototype,
3005
+ discord_js.Invite
2799
3006
  ]
2800
3007
  ];
2801
3008
  /**
@@ -2843,7 +3050,8 @@ const HOLDS = [
2843
3050
  return manager;
2844
3051
  }
2845
3052
  /**
2846
- * Creates a mock {@link Guild}, with the `members`, `channels`, `roles` and `bans` managers ready to stub.
3053
+ * Creates a mock {@link Guild}, with its `members`, `channels`, `roles`, `bans`, `emojis`, `stickers`, `scheduledEvents`,
3054
+ * `autoModerationRules` and `invites` managers ready to stub.
2847
3055
  *
2848
3056
  * Use it for the server a message or an interaction came from, with the members, roles and channels a handler looks
2849
3057
  * up in it.
@@ -2853,9 +3061,11 @@ const HOLDS = [
2853
3061
  *
2854
3062
  * @remarks
2855
3063
  * A manager's `fetch(id)` resolves to its cached item with that id, as discord.js looks there first, or to a new one it
2856
- * caches under that id: a member or channel in this guild, a role with that id, or a ban. `members.fetch({ user: ids })`
2857
- * resolves to a collection of each of those members, found or made the same way; `create()` and `edit()` resolve to a
2858
- * mock of its item, and a list fetch to an empty collection. Members, roles and channels given are put in
3064
+ * caches under that id: a member, channel, emoji, sticker, scheduled event or AutoMod rule in this guild, a role with
3065
+ * that id, a ban, or an invite with that code. `members.fetch({ user: ids })` resolves to a collection of each of those
3066
+ * members, found or made the same way; `create()` and `edit()` resolve to a mock of its item, which `create()` caches
3067
+ * as discord.js does, but for an invite, made for the channel given and cached nowhere; a list fetch resolves to an
3068
+ * empty collection. Members, roles and channels given are put in
2859
3069
  * their managers' caches, where dispatch looks first when it resolves a message's typed params; a member
2860
3070
  * {@link createMockMember} made without a server is in this one. Its `roles.everyone` is the role given with the
2861
3071
  * guild's id, or else an @everyone role of its own at position 0 with no permissions, in `roles.cache` as Discord has
@@ -2886,7 +3096,7 @@ const HOLDS = [
2886
3096
  instance.members = managerWith(discord_js.GuildMemberManager.prototype, overrides.members);
2887
3097
  instance.channels = managerWith(discord_js.GuildChannelManager.prototype, overrides.channels);
2888
3098
  instance.roles = guildRoleManager(instance.id, overrides.roles);
2889
- instance.bans = managerWith(discord_js.GuildBanManager.prototype, undefined);
3099
+ for (const [key, Manager] of GUILD_MANAGERS)instance[key] = managerWith(Manager.prototype, undefined);
2890
3100
  const guild = stubDeep(instance);
2891
3101
  settleGuild(instance, guild);
2892
3102
  // A member made without a server of its own is in this one
@@ -3510,7 +3720,7 @@ let placeReadsSeen;
3510
3720
  guild.members = managerWith(discord_js.GuildMemberManager.prototype, undefined);
3511
3721
  guild.channels = managerWith(discord_js.GuildChannelManager.prototype, undefined);
3512
3722
  guild.roles = guildRoleManager(guild.id, undefined);
3513
- guild.bans = managerWith(discord_js.GuildBanManager.prototype, undefined);
3723
+ for (const [key, Manager] of GUILD_MANAGERS)guild[key] = managerWith(Manager.prototype, undefined);
3514
3724
  const proxy = stubDeep(guild);
3515
3725
  settleGuild(guild, proxy);
3516
3726
  return proxy;
@@ -3532,12 +3742,39 @@ const botMembers = new WeakMap();
3532
3742
  return guildId ? cacheOf(guild.channels)?.get(channelId) : null;
3533
3743
  });
3534
3744
  }
3745
+ // A guild's managers beside members, channels and roles, each empty, of its discord.js class
3746
+ const GUILD_MANAGERS = [
3747
+ [
3748
+ 'bans',
3749
+ discord_js.GuildBanManager
3750
+ ],
3751
+ [
3752
+ 'emojis',
3753
+ discord_js.GuildEmojiManager
3754
+ ],
3755
+ [
3756
+ 'stickers',
3757
+ discord_js.GuildStickerManager
3758
+ ],
3759
+ [
3760
+ 'scheduledEvents',
3761
+ discord_js.GuildScheduledEventManager
3762
+ ],
3763
+ [
3764
+ 'autoModerationRules',
3765
+ discord_js.AutoModerationRuleManager
3766
+ ],
3767
+ [
3768
+ 'invites',
3769
+ discord_js.GuildInviteManager
3770
+ ]
3771
+ ];
3535
3772
  /** Gives a guild's managers the guild, as discord.js's do, so what they fetch or make is in it. */ function homeManagers(instance, guild) {
3536
3773
  for (const key of [
3537
3774
  'members',
3538
3775
  'channels',
3539
3776
  'roles',
3540
- 'bans'
3777
+ ...GUILD_MANAGERS.map(([key])=>key)
3541
3778
  ])instance[key].guild = guild;
3542
3779
  }
3543
3780
  /**
@@ -5041,7 +5278,17 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
5041
5278
  }
5042
5279
  // Each pass of checks ends before the next step that is no check, so a module with one error stops where it always did
5043
5280
  controller_decorator.stopOnStartupErrors();
5281
+ // What the walk below takes as classes, reported with what the walk finds; an entry refused here is left out of it
5044
5282
  const services = this.wiring?.services ?? [];
5283
+ if (this.options.app) providers.assertProvidedServices(this.options.app.name, services, providers$1, "@MeoCord({ providers }) or the testing module's providers");
5284
+ for (const controller of this.options.controllers ?? []){
5285
+ if (!controller_decorator.isConstructor(controller)) controller_decorator.startupError(new Error(`the testing module's controllers takes classes, not ${controller_decorator.describeValue(controller)}.`));
5286
+ }
5287
+ const observers = [
5288
+ ...this.options.app ? providers.appObservers(this.options.app) : [],
5289
+ ...this.options.observers ?? []
5290
+ ];
5291
+ providers.assertObservers("the testing module's observers", observers);
5045
5292
  const appOptions = this.options.app ? Reflect.getMetadata(controller_decorator.META.appOptions, this.options.app) : undefined;
5046
5293
  // The app's own store, as the bot binds it, whether the module is given the app or built from it; none when the
5047
5294
  // test provides the CooldownStore, so nothing builds the app's or asks for what it injects
@@ -5052,17 +5299,14 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
5052
5299
  const i18n = this.options.app && Reflect.getMetadata(controller_decorator.META.appOptions, this.options.app)?.i18n;
5053
5300
  const appPresenterClass = this.options.app && Reflect.getMetadata(controller_decorator.META.appOptions, this.options.app)?.presenter;
5054
5301
  const startup = startupRoots.startupClasses({
5055
- controllers: this.options.controllers ?? [],
5302
+ controllers: (this.options.controllers ?? []).filter(controller_decorator.isConstructor),
5056
5303
  services,
5057
5304
  providers: providers$1,
5058
5305
  stages,
5059
5306
  presenter: appPresenterClass,
5060
5307
  cooldownStore: store,
5061
5308
  themeFor: stages?.themeFor?.resolvers,
5062
- observers: [
5063
- ...this.options.app ? providers.appObservers(this.options.app) : [],
5064
- ...this.options.observers ?? []
5065
- ],
5309
+ observers,
5066
5310
  stubs: [
5067
5311
  ...this.guardOverrides.keys(),
5068
5312
  ...this.interceptorOverrides.keys(),
@@ -5152,13 +5396,6 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
5152
5396
  // As the app does when it is created, so a test sees the refusal the bot would give
5153
5397
  startupRoots.assertDistinctCommands(this.options.controllers ?? []);
5154
5398
  if (this.options.app) providers.prepareAppPresenter(container, this.options.app);
5155
- const observers = [
5156
- ...this.options.app ? providers.appObservers(this.options.app) : [],
5157
- ...this.options.observers ?? []
5158
- ];
5159
- providers.assertObservers("the testing module's observers", observers);
5160
- // Only @Observer classes are bound as observers
5161
- controller_decorator.stopOnStartupErrors();
5162
5399
  providers.bindObservers(container, observers);
5163
5400
  // The store calls ask: the test's own when it provides CooldownStore, else the app's
5164
5401
  const boundStore = store ?? (providers$1.has(controller_decorator.CooldownStore) ? controller_decorator.CooldownStore : undefined);
@@ -5298,7 +5535,8 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
5298
5535
  * @param app - The class `@MeoCord` decorates.
5299
5536
  * @param options - Providers that replace the app's by token, extra controllers, and observers.
5300
5537
  * @returns The builder, whose `override*` methods still apply, for `compile()`.
5301
- * @throws TypeError when `app` is not a `@MeoCord` class.
5538
+ * @throws TypeError when `app` is not a `@MeoCord` class. After {@link reportAllStartupErrors}, an app whose
5539
+ * `@MeoCord` was refused throws the error it was refused for instead.
5302
5540
  *
5303
5541
  * @example
5304
5542
  * ```ts
@@ -5339,7 +5577,13 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
5339
5577
  * ```
5340
5578
  */ static fromApp(app, options = {}) {
5341
5579
  const appOptions = Reflect.getMetadata(controller_decorator.META.appOptions, app);
5342
- if (!appOptions) throw new TypeError(`${app?.name ?? String(app)} is not a @MeoCord app: fromApp takes the class @MeoCord decorates.`);
5580
+ if (!appOptions) {
5581
+ // A refused @MeoCord stores no options, so its own errors, kept with startupErrors: 'all', say why
5582
+ controller_decorator.collectStartupErrors(()=>controller_decorator.declaredErrorsOf([
5583
+ app
5584
+ ]).forEach(controller_decorator.startupError));
5585
+ throw new TypeError(`${app?.name ?? String(app)} is not a @MeoCord app: fromApp takes the class @MeoCord decorates.`);
5586
+ }
5343
5587
  const controllers = [
5344
5588
  ...new Set([
5345
5589
  ...appOptions.controllers,
@@ -5587,15 +5831,6 @@ providers.adviseInPlaceOfInjecting(TestingModule, 'make it with MeoCordTestingMo
5587
5831
  return new Fields(components);
5588
5832
  }
5589
5833
 
5590
- const PLURAL_CATEGORIES = new Set([
5591
- 'zero',
5592
- 'one',
5593
- 'two',
5594
- 'few',
5595
- 'many',
5596
- 'other'
5597
- ]);
5598
- /** A plural: an object whose every key is a plural category, as the translator's types read one; any other is a group. */ const isPlural = (value)=>typeof value === 'object' && value !== null && Object.keys(value).length > 0 && Object.keys(value).every((key)=>PLURAL_CATEGORIES.has(key));
5599
5834
  /** Every message key of a catalog, and whether it is a plural. */ function leaves(catalog, prefix = '') {
5600
5835
  return Object.entries(catalog).flatMap(([name, value])=>{
5601
5836
  const key = `${prefix}${name}`;
@@ -5605,7 +5840,7 @@ const PLURAL_CATEGORIES = new Set([
5605
5840
  plural: false
5606
5841
  }
5607
5842
  ];
5608
- if (isPlural(value)) return [
5843
+ if (controller_decorator.isPluralShape(value)) return [
5609
5844
  {
5610
5845
  key,
5611
5846
  plural: true
@@ -5629,7 +5864,7 @@ const isMeoCordKey = (key)=>key.startsWith('meocord.');
5629
5864
  /** A message as a report shows it: the text, or a plural's `other` form. */ const shown = (message)=>typeof message === 'string' ? message : String(message.other);
5630
5865
  /** The `{params}` a translation uses that the message it translates doesn't take; a plural's `{count}` is taken. */ function strayParams(translation, original) {
5631
5866
  const taken = placeholders(original);
5632
- if (isPlural(original)) taken.add('count');
5867
+ if (controller_decorator.isPluralShape(original)) taken.add('count');
5633
5868
  return [
5634
5869
  ...placeholders(translation)
5635
5870
  ].filter((name)=>!taken.has(name));
@@ -5695,7 +5930,7 @@ const isMeoCordKey = (key)=>key.startsWith('meocord.');
5695
5930
  continue;
5696
5931
  }
5697
5932
  // A translation of another shape than the default's can't be read as it: a text for a plural, or the reverse
5698
- if (plural !== isPlural(message) || !plural && typeof message !== 'string') {
5933
+ if (plural !== controller_decorator.isPluralShape(message) || !plural && typeof message !== 'string') {
5699
5934
  problems.push(`${key} should be a ${plural ? 'plural' : 'text'}, as the default is`);
5700
5935
  continue;
5701
5936
  }
package/dist/cli.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "meocordVersion": "4.2.1",
3
+ "meocordVersion": "4.2.2",
4
4
  "name": "meocord",
5
5
  "description": "CLI for managing the MeoCord application",
6
6
  "usage": "[options] [command]",
@@ -414,15 +414,15 @@ function toJson(value) {
414
414
  });
415
415
  }
416
416
  /**
417
- * Undoes `@Defer`'s acknowledgement after a guard denied the call silently: a command's deferred
418
- * reply is deleted while nothing was sent into it; a component's invisible acknowledgement needs
419
- * nothing. Never throws.
417
+ * Ends a call a guard denied silently. A component still unanswered is acknowledged invisibly, with or without
418
+ * `@Defer`, since Discord tells the user an unanswered call failed; so is a command `@Defer`'s 'auto' timer was
419
+ * waiting on, whose deferred reply is then deleted, as `@Defer`'s is while nothing was sent into it. Never throws.
420
420
  */ abandon() {
421
421
  const waiting = this.timer !== undefined;
422
422
  this.cancelScheduled();
423
423
  return this.queued(async ()=>{
424
- // Denied before the 'auto' timer: acknowledged here, since Discord tells the user an unanswered call failed
425
- if (waiting) await this.acknowledgeUnanswered({
424
+ // A command's acknowledgement shows "thinking…", so one is made only for the timer that would have made it
425
+ if (waiting || !answersWithOwnMessage(this.interaction)) await this.acknowledgeUnanswered({
426
426
  ephemeral: true
427
427
  });
428
428
  this.sync();