meocord 4.2.0 → 4.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/CHANGELOG.md +264 -0
  2. package/THIRD_PARTY_NOTICES.md +14 -13
  3. package/dist/cjs/_shared/{controller.decorator-CRn9kWob.cjs → controller.decorator-XKMlcUZz.cjs} +376 -100
  4. package/dist/cjs/_shared/{providers-ChvMqnLd.cjs → providers-BZgJyfZS.cjs} +145 -36
  5. package/dist/cjs/_shared/{sharded-cooldown-store-BdAUTdYs.cjs → sharded-cooldown-store-CsJJyCZd.cjs} +10 -7
  6. package/dist/cjs/_shared/{startup-roots-CQo8mSP8.cjs → startup-roots-CgfPQrNl.cjs} +219 -63
  7. package/dist/cjs/_shared/{translate-error-DhDVbT5M.cjs → translate-error-BUAR1WYG.cjs} +10 -5
  8. package/dist/cjs/common/index.cjs +3 -3
  9. package/dist/cjs/core/index.cjs +185 -168
  10. package/dist/cjs/decorator/index.cjs +21 -6
  11. package/dist/cjs/testing/index.cjs +1594 -286
  12. package/dist/cli.json +1 -1
  13. package/dist/esm/bin/generator.js +4 -5
  14. package/dist/esm/bin/helper/filter-generator.helper.js +4 -5
  15. package/dist/esm/bin/helper/guard-generator.helper.js +4 -5
  16. package/dist/esm/bin/helper/interceptor-generator.helper.js +4 -5
  17. package/dist/esm/bin/helper/observer-generator.helper.js +4 -5
  18. package/dist/esm/bin/helper/pipe-generator.helper.js +4 -5
  19. package/dist/esm/bin/helper/service-generator.helper.js +4 -5
  20. package/dist/esm/bin/meocord.js +84 -25
  21. package/dist/esm/build/load-config.pre-entry.js +13 -3
  22. package/dist/esm/build/rsbuild-config.js +16 -12
  23. package/dist/esm/build/typed-externals.js +36 -0
  24. package/dist/esm/common/cooldown-store.js +10 -2
  25. package/dist/esm/common/errors.js +25 -0
  26. package/dist/esm/common/meocord-text.js +11 -8
  27. package/dist/esm/common/response/call-order.js +9 -4
  28. package/dist/esm/common/response/presenter.js +33 -6
  29. package/dist/esm/common/response/response-state.js +84 -10
  30. package/dist/esm/common/route.js +11 -4
  31. package/dist/esm/common/sharded-cooldown-store.js +1 -1
  32. package/dist/esm/common/translator.js +37 -6
  33. package/dist/esm/core/command-registration.js +93 -24
  34. package/dist/esm/core/command-routes.js +2 -2
  35. package/dist/esm/core/cooldown-runner.js +29 -8
  36. package/dist/esm/core/fallback.js +20 -11
  37. package/dist/esm/core/guard-runner.js +16 -3
  38. package/dist/esm/core/handler-input.js +4 -3
  39. package/dist/esm/core/lifecycle-hooks.js +134 -47
  40. package/dist/esm/core/meocord-factory.js +55 -32
  41. package/dist/esm/core/meocord.app.js +116 -75
  42. package/dist/esm/core/providers.js +7 -2
  43. package/dist/esm/core/running-calls.js +43 -0
  44. package/dist/esm/core/shard-context.js +5 -6
  45. package/dist/esm/core/shard-manager.js +16 -10
  46. package/dist/esm/core/startup-roots.js +21 -1
  47. package/dist/esm/core/theme-resolvers.js +4 -1
  48. package/dist/esm/core/theme-scope.js +3 -1
  49. package/dist/esm/core/theme-validation.js +17 -10
  50. package/dist/esm/decorator/controller.decorator.js +56 -20
  51. package/dist/esm/decorator/cooldown.decorator.js +17 -0
  52. package/dist/esm/decorator/guard.decorator.js +3 -2
  53. package/dist/esm/package.json.js +1 -1
  54. package/dist/esm/testing/catalog.js +26 -10
  55. package/dist/esm/testing/cooldown-store-suite.js +67 -5
  56. package/dist/esm/testing/discord-defaults.js +205 -30
  57. package/dist/esm/testing/meocord-testing-module.js +134 -28
  58. package/dist/esm/testing/mock-fn.js +107 -26
  59. package/dist/esm/testing/mock-interaction.js +1026 -172
  60. package/dist/esm/testing/snowflake.js +9 -2
  61. package/dist/esm/testing/strict-mocks.js +37 -17
  62. package/dist/esm/util/common.util.js +47 -19
  63. package/dist/esm/util/dev-runner.util.js +8 -8
  64. package/dist/esm/util/generator-cli.util.js +4 -5
  65. package/dist/esm/util/interaction.util.js +2 -2
  66. package/dist/esm/util/meocord-config-loader.util.js +10 -2
  67. package/dist/esm/util/meocord-config-validation.util.js +3 -5
  68. package/dist/esm/util/meocord-source-config.util.js +65 -20
  69. package/dist/esm/util/method-wrapper.util.js +16 -0
  70. package/dist/esm/util/platform.util.js +10 -10
  71. package/dist/esm/util/shard-process.util.js +4 -1
  72. package/dist/esm/util/sharding-mode.util.js +5 -7
  73. package/dist/esm/util/shared-state.util.js +21 -0
  74. package/dist/esm/util/tsconfig-paths.util.js +29 -0
  75. package/dist/esm/util/tsconfig.util.js +10 -10
  76. package/dist/esm/util/value.util.js +12 -1
  77. package/dist/types/common/index.d.cts +6 -6
  78. package/dist/types/common/index.d.ts +6 -6
  79. package/dist/types/core/index.d.cts +3 -2
  80. package/dist/types/core/index.d.ts +3 -2
  81. package/dist/types/decorator/index.d.cts +1 -0
  82. package/dist/types/decorator/index.d.ts +1 -0
  83. package/dist/types/{index-CdXsFgjE.d.ts → index-DKJclIi-.d.ts} +45 -21
  84. package/dist/types/{index-nn5p7OoX.d.cts → index-Dlf0ZZtS.d.cts} +45 -21
  85. package/dist/types/interface/index.d.cts +1 -1
  86. package/dist/types/interface/index.d.ts +1 -1
  87. package/dist/types/{route-BZgFoNeB.d.cts → route-Bh1U13yy.d.cts} +3 -2
  88. package/dist/types/{route-BZgFoNeB.d.ts → route-Bh1U13yy.d.ts} +3 -2
  89. package/dist/types/testing/index.d.cts +175 -53
  90. package/dist/types/testing/index.d.ts +175 -53
  91. package/dist/types/{theme-resolvers-UKr3-6Ys.d.cts → theme-resolvers-CyEz9pnv.d.cts} +1 -1
  92. package/dist/types/{theme-resolvers-BcOJVzR9.d.ts → theme-resolvers-DA204O-q.d.ts} +1 -1
  93. package/dist/types/{translator-DHMkkCpg.d.cts → translator-Bg7hwxDF.d.cts} +21 -8
  94. package/dist/types/{translator-BViWKtLJ.d.ts → translator-CeP4bCzp.d.ts} +21 -8
  95. package/package.json +2 -2
@@ -1,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
  /**
@@ -30,7 +32,8 @@ const PLACEHOLDER = /\{(\w+)(?::([^}/]*))?}/g;
30
32
  * fails to compile, and a typed param takes a value of its type.
31
33
  *
32
34
  * @param pattern - The customId pattern, where `{name}` captures one `/`-separated segment and `{name:int}` a
33
- * 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.
34
37
  * @returns A route whose `build` takes a value for each param.
35
38
  * @throws Error when the pattern cannot be read, as `@Command` would throw for it.
36
39
  *
@@ -55,6 +58,9 @@ const PLACEHOLDER = /\{(\w+)(?::([^}/]*))?}/g;
55
58
  * @see {@link https://meocord.dev/docs/4.2/components | Buttons, selects and modals}
56
59
  */ function route(pattern) {
57
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}`);
58
64
  const names = new Set(params);
59
65
  const build = (values = {})=>{
60
66
  const unknown = Object.keys(values).filter((name)=>!names.has(name));
@@ -78,6 +84,7 @@ const PLACEHOLDER = /\{(\w+)(?::([^}/]*))?}/g;
78
84
  }
79
85
  return encodeSegment(text);
80
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.`);
81
88
  if (id.length > MAX_CUSTOM_ID_LENGTH) {
82
89
  throw new RangeError(`route('${pattern}').build() made a customId of ${id.length} characters, over Discord's ${MAX_CUSTOM_ID_LENGTH}: "${id}".`);
83
90
  }
@@ -90,4 +97,4 @@ const PLACEHOLDER = /\{(\w+)(?::([^}/]*))?}/g;
90
97
  });
91
98
  }
92
99
 
93
- 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
@@ -1,6 +1,7 @@
1
1
  import { Locale } from 'discord.js';
2
2
  import { refuse } from '../util/refusal.util.js';
3
3
  import { Logger } from './logger.js';
4
+ import { sharedName } from '../util/shared-state.util.js';
4
5
 
5
6
  /**
6
7
  * Declares a message catalog, keeping each message's text as its type so the params it takes can be checked.
@@ -61,8 +62,8 @@ import { Logger } from './logger.js';
61
62
  /**
62
63
  * Where a translator made by `createTranslator` keeps its catalogs, for MeoCord's texts, `@MeoCord` and
63
64
  * `expectCompleteCatalog`.
64
- */ const CATALOGS = Symbol('catalogs');
65
- /** How MeoCord's own texts read a translator made by `createTranslator`: the catalogs that serve a locale, in order. */ const LOCALE_CHAIN = Symbol('localeChain');
65
+ */ const CATALOGS = Symbol.for(sharedName('translator:catalogs'));
66
+ /** How MeoCord's own texts read a translator made by `createTranslator`: the catalogs that serve a locale, in order. */ const LOCALE_CHAIN = Symbol.for(sharedName('translator:localeChain'));
66
67
  /** For a class that injects `Translator` in an app that configured none. */ function missingTranslatorError(cls) {
67
68
  return new Error(`${cls.name}: it injects Translator, but @MeoCord has no i18n. Pass @MeoCord({ i18n: t }), where t comes from createTranslator.`);
68
69
  }
@@ -109,6 +110,22 @@ function interpolate(message, params) {
109
110
  }
110
111
  return typeof current === 'object' && current !== null;
111
112
  }
113
+ /** The locale Discord shows a locale's users when that locale has no localization of its own, by its locale fallbacks. */ const FALLBACK_PARTNERS = {
114
+ [Locale.EnglishUS]: Locale.EnglishGB,
115
+ [Locale.EnglishGB]: Locale.EnglishUS,
116
+ [Locale.SpanishLATAM]: Locale.SpanishES
117
+ };
118
+ /** Each key with a `.` in it, which `lookup` splits as a path and so never reaches, with the group it sits in. */ function dottedKeys(catalog, group = '') {
119
+ return Object.entries(catalog).flatMap(([name, value])=>[
120
+ ...name.includes('.') ? [
121
+ {
122
+ name,
123
+ group
124
+ }
125
+ ] : [],
126
+ ...typeof value === 'object' && value !== null ? dottedKeys(value, group ? `${group}.${name}` : name) : []
127
+ ]);
128
+ }
112
129
  class CatalogTranslator extends Translator {
113
130
  /** The catalogs that serve a locale, most specific first, ending with the default. */ [LOCALE_CHAIN](requested) {
114
131
  const chain = [];
@@ -127,10 +144,13 @@ class CatalogTranslator extends Translator {
127
144
  translate(requested, key, params = {}) {
128
145
  for (const locale of this[LOCALE_CHAIN](requested)){
129
146
  const message = lookup(this.catalogs[locale], key);
130
- if (message !== undefined) return interpolate(pluralForm({
147
+ if (message === undefined) continue;
148
+ // An empty translation, as tools export an untranslated string, counts as missing: Discord refuses an empty reply
149
+ const text = pluralForm({
131
150
  message,
132
151
  locale
133
- }, params.count), params);
152
+ }, params.count);
153
+ if (text !== '') return interpolate(text, params);
134
154
  }
135
155
  this.warnUnknown(key);
136
156
  return key;
@@ -160,11 +180,16 @@ class CatalogTranslator extends Translator {
160
180
  for (const locale of this.locales){
161
181
  if (locale === this.defaultLocale) continue;
162
182
  const message = lookup(this.catalogs[locale], key);
163
- // A translation with a {param} is left out: Discord would show it as written
164
- if (typeof message === 'string' && placeholderNames(message).length === 0) {
183
+ // A translation with a {param} is left out, as Discord would show it as written, and so is an empty one
184
+ if (typeof message === 'string' && message !== '' && placeholderNames(message).length === 0) {
165
185
  localized[locale] = fillPlaceholders(message, (_name, whole)=>whole);
166
186
  }
167
187
  }
188
+ // Discord gives a locale it falls back from its partner's value, so the default keeps its own wording beside one
189
+ const partner = FALLBACK_PARTNERS[this.defaultLocale];
190
+ if (partner && localized[partner] !== undefined && typeof original === 'string' && original !== '') {
191
+ localized[this.defaultLocale] = fillPlaceholders(original, (_name, whole)=>whole);
192
+ }
168
193
  return localized;
169
194
  }
170
195
  for(interaction, options = {}) {
@@ -243,6 +268,12 @@ class CatalogTranslator extends Translator {
243
268
  }
244
269
  }
245
270
  if (!locales[defaultLocale]) throw refuse(new Error(`The default locale "${defaultLocale}" has no catalog in locales.`));
271
+ const logger = new Logger('Translator');
272
+ for (const [locale, catalog] of Object.entries(locales)){
273
+ for (const { name, group } of dottedKeys(catalog)){
274
+ logger.warn(`The ${locale} catalog's key "${name}"${group ? ` in ${group}` : ''} has a ".", which a lookup reads as a path, so its ` + 'message is never found. Nest it as a group instead.');
275
+ }
276
+ }
246
277
  // Typed by the default catalog; at runtime every catalog is read the same way.
247
278
  return new CatalogTranslator(defaultLocale, locales);
248
279
  }