meocord 4.2.0 → 4.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +322 -0
- package/THIRD_PARTY_NOTICES.md +14 -13
- package/dist/cjs/_shared/{controller.decorator-CRn9kWob.cjs → controller.decorator-U2NBWcoU.cjs} +416 -108
- package/dist/cjs/_shared/{providers-ChvMqnLd.cjs → providers-B4MOFBrW.cjs} +202 -50
- package/dist/cjs/_shared/{sharded-cooldown-store-BdAUTdYs.cjs → sharded-cooldown-store-HTUb3Ii_.cjs} +10 -7
- package/dist/cjs/_shared/{startup-roots-CQo8mSP8.cjs → startup-roots-lA5xkBFq.cjs} +228 -71
- package/dist/cjs/_shared/{translate-error-DhDVbT5M.cjs → translate-error-CbBHUu5P.cjs} +10 -5
- package/dist/cjs/common/index.cjs +3 -3
- package/dist/cjs/core/index.cjs +188 -169
- package/dist/cjs/decorator/index.cjs +52 -7
- package/dist/cjs/testing/index.cjs +1854 -311
- package/dist/cli.json +1 -1
- package/dist/esm/bin/generator.js +4 -5
- package/dist/esm/bin/helper/filter-generator.helper.js +4 -5
- package/dist/esm/bin/helper/guard-generator.helper.js +4 -5
- package/dist/esm/bin/helper/interceptor-generator.helper.js +4 -5
- package/dist/esm/bin/helper/observer-generator.helper.js +4 -5
- package/dist/esm/bin/helper/pipe-generator.helper.js +4 -5
- package/dist/esm/bin/helper/service-generator.helper.js +4 -5
- package/dist/esm/bin/meocord.js +84 -25
- package/dist/esm/build/load-config.pre-entry.js +13 -3
- package/dist/esm/build/rsbuild-config.js +16 -12
- package/dist/esm/build/typed-externals.js +36 -0
- package/dist/esm/common/cooldown-store.js +10 -2
- package/dist/esm/common/errors.js +25 -0
- package/dist/esm/common/meocord-text.js +11 -8
- package/dist/esm/common/response/call-order.js +9 -4
- package/dist/esm/common/response/presenter.js +33 -6
- package/dist/esm/common/response/response-state.js +89 -15
- package/dist/esm/common/route.js +11 -4
- package/dist/esm/common/sharded-cooldown-store.js +1 -1
- package/dist/esm/common/translator.js +52 -11
- package/dist/esm/core/command-registration.js +93 -24
- package/dist/esm/core/command-routes.js +2 -2
- package/dist/esm/core/cooldown-runner.js +29 -8
- package/dist/esm/core/dispatcher.js +9 -8
- package/dist/esm/core/fallback.js +20 -11
- package/dist/esm/core/filter-runner.js +2 -2
- package/dist/esm/core/guard-runner.js +16 -3
- package/dist/esm/core/handler-input.js +4 -3
- package/dist/esm/core/handler-pipeline.js +10 -4
- package/dist/esm/core/lifecycle-hooks.js +134 -47
- package/dist/esm/core/meocord-factory.js +59 -34
- package/dist/esm/core/meocord.app.js +116 -75
- package/dist/esm/core/message-help.js +32 -6
- package/dist/esm/core/observer-runner.js +2 -1
- package/dist/esm/core/providers.js +19 -4
- package/dist/esm/core/running-calls.js +43 -0
- package/dist/esm/core/shard-context.js +5 -6
- package/dist/esm/core/shard-manager.js +16 -10
- package/dist/esm/core/stage-scope.js +2 -1
- package/dist/esm/core/startup-roots.js +21 -1
- package/dist/esm/core/theme-resolvers.js +4 -1
- package/dist/esm/core/theme-scope.js +7 -2
- package/dist/esm/core/theme-validation.js +17 -10
- package/dist/esm/decorator/app.decorator.js +31 -0
- package/dist/esm/decorator/controller.decorator.js +56 -20
- package/dist/esm/decorator/cooldown.decorator.js +17 -0
- package/dist/esm/decorator/filter.decorator.js +2 -2
- package/dist/esm/decorator/guard.decorator.js +3 -2
- package/dist/esm/package.json.js +1 -1
- package/dist/esm/testing/catalog.js +20 -13
- package/dist/esm/testing/cooldown-store-suite.js +67 -5
- package/dist/esm/testing/discord-defaults.js +225 -30
- package/dist/esm/testing/meocord-testing-module.js +158 -44
- package/dist/esm/testing/mock-fn.js +107 -26
- package/dist/esm/testing/mock-interaction.js +1250 -182
- package/dist/esm/testing/snowflake.js +9 -2
- package/dist/esm/testing/strict-mocks.js +40 -17
- package/dist/esm/util/common.util.js +47 -19
- package/dist/esm/util/dev-runner.util.js +8 -8
- package/dist/esm/util/generator-cli.util.js +4 -5
- package/dist/esm/util/interaction.util.js +2 -2
- package/dist/esm/util/meocord-config-loader.util.js +10 -2
- package/dist/esm/util/meocord-config-validation.util.js +3 -5
- package/dist/esm/util/meocord-source-config.util.js +65 -20
- package/dist/esm/util/method-wrapper.util.js +16 -0
- package/dist/esm/util/platform.util.js +10 -10
- package/dist/esm/util/shard-process.util.js +4 -1
- package/dist/esm/util/sharding-mode.util.js +5 -7
- package/dist/esm/util/shared-state.util.js +21 -0
- package/dist/esm/util/tsconfig-paths.util.js +29 -0
- package/dist/esm/util/tsconfig.util.js +10 -10
- package/dist/esm/util/value.util.js +30 -2
- package/dist/types/common/index.d.cts +10 -7
- package/dist/types/common/index.d.ts +10 -7
- package/dist/types/core/index.d.cts +3 -2
- package/dist/types/core/index.d.ts +3 -2
- package/dist/types/decorator/index.d.cts +1 -0
- package/dist/types/decorator/index.d.ts +1 -0
- package/dist/types/{index-CdXsFgjE.d.ts → index-DKJclIi-.d.ts} +45 -21
- package/dist/types/{index-nn5p7OoX.d.cts → index-Dlf0ZZtS.d.cts} +45 -21
- package/dist/types/interface/index.d.cts +1 -1
- package/dist/types/interface/index.d.ts +1 -1
- package/dist/types/{route-BZgFoNeB.d.cts → route-Bh1U13yy.d.cts} +3 -2
- package/dist/types/{route-BZgFoNeB.d.ts → route-Bh1U13yy.d.ts} +3 -2
- package/dist/types/testing/index.d.cts +198 -62
- package/dist/types/testing/index.d.ts +198 -62
- package/dist/types/{theme-resolvers-UKr3-6Ys.d.cts → theme-resolvers-CyEz9pnv.d.cts} +1 -1
- package/dist/types/{theme-resolvers-BcOJVzR9.d.ts → theme-resolvers-DA204O-q.d.ts} +1 -1
- package/dist/types/{translator-DHMkkCpg.d.cts → translator-Bg7hwxDF.d.cts} +21 -8
- package/dist/types/{translator-BViWKtLJ.d.ts → translator-CeP4bCzp.d.ts} +21 -8
- package/package.json +2 -2
|
@@ -1,10 +1,15 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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() {
|
|
@@ -409,15 +414,15 @@ function toJson(value) {
|
|
|
409
414
|
});
|
|
410
415
|
}
|
|
411
416
|
/**
|
|
412
|
-
*
|
|
413
|
-
*
|
|
414
|
-
* 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.
|
|
415
420
|
*/ abandon() {
|
|
416
421
|
const waiting = this.timer !== undefined;
|
|
417
422
|
this.cancelScheduled();
|
|
418
423
|
return this.queued(async ()=>{
|
|
419
|
-
//
|
|
420
|
-
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({
|
|
421
426
|
ephemeral: true
|
|
422
427
|
});
|
|
423
428
|
this.sync();
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
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',
|
|
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
|
-
|
|
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));
|
package/dist/esm/common/route.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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,23 +62,33 @@ 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
|
}
|
|
69
70
|
const DISCORD_LOCALES = new Set(Object.values(Locale));
|
|
70
71
|
const languageOf = (locale)=>locale.split('-')[0];
|
|
71
|
-
|
|
72
|
+
const PLURAL_CATEGORIES = new Set([
|
|
73
|
+
'zero',
|
|
74
|
+
'one',
|
|
75
|
+
'two',
|
|
76
|
+
'few',
|
|
77
|
+
'many',
|
|
78
|
+
'other'
|
|
79
|
+
]);
|
|
80
|
+
/** A plural's shape: a non-empty object whose every key is a plural category, as the types read one; any other is a group. */ const isPluralShape = (value)=>typeof value === 'object' && value !== null && Object.keys(value).length > 0 && Object.keys(value).every((key)=>PLURAL_CATEGORIES.has(key));
|
|
81
|
+
/**
|
|
82
|
+
* A message by its dotted key, or undefined when the catalog lacks it or the key names a group. A plural without an
|
|
83
|
+
* `other` form reads as missing, so a translation that left it out falls back to the next catalog.
|
|
84
|
+
*/ function lookup(catalog, key) {
|
|
72
85
|
let current = catalog;
|
|
73
86
|
for (const part of key.split('.')){
|
|
74
87
|
if (typeof current !== 'object' || current === null) return undefined;
|
|
75
88
|
current = current[part];
|
|
76
89
|
}
|
|
77
90
|
if (typeof current === 'string') return current;
|
|
78
|
-
if (
|
|
79
|
-
return current;
|
|
80
|
-
}
|
|
91
|
+
if (isPluralShape(current) && typeof current.other === 'string') return current;
|
|
81
92
|
return undefined;
|
|
82
93
|
}
|
|
83
94
|
const plurals = new Map();
|
|
@@ -109,6 +120,22 @@ function interpolate(message, params) {
|
|
|
109
120
|
}
|
|
110
121
|
return typeof current === 'object' && current !== null;
|
|
111
122
|
}
|
|
123
|
+
/** The locale Discord shows a locale's users when that locale has no localization of its own, by its locale fallbacks. */ const FALLBACK_PARTNERS = {
|
|
124
|
+
[Locale.EnglishUS]: Locale.EnglishGB,
|
|
125
|
+
[Locale.EnglishGB]: Locale.EnglishUS,
|
|
126
|
+
[Locale.SpanishLATAM]: Locale.SpanishES
|
|
127
|
+
};
|
|
128
|
+
/** 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 = '') {
|
|
129
|
+
return Object.entries(catalog).flatMap(([name, value])=>[
|
|
130
|
+
...name.includes('.') ? [
|
|
131
|
+
{
|
|
132
|
+
name,
|
|
133
|
+
group
|
|
134
|
+
}
|
|
135
|
+
] : [],
|
|
136
|
+
...typeof value === 'object' && value !== null ? dottedKeys(value, group ? `${group}.${name}` : name) : []
|
|
137
|
+
]);
|
|
138
|
+
}
|
|
112
139
|
class CatalogTranslator extends Translator {
|
|
113
140
|
/** The catalogs that serve a locale, most specific first, ending with the default. */ [LOCALE_CHAIN](requested) {
|
|
114
141
|
const chain = [];
|
|
@@ -127,10 +154,13 @@ class CatalogTranslator extends Translator {
|
|
|
127
154
|
translate(requested, key, params = {}) {
|
|
128
155
|
for (const locale of this[LOCALE_CHAIN](requested)){
|
|
129
156
|
const message = lookup(this.catalogs[locale], key);
|
|
130
|
-
if (message
|
|
157
|
+
if (message === undefined) continue;
|
|
158
|
+
// An empty translation, as tools export an untranslated string, counts as missing: Discord refuses an empty reply
|
|
159
|
+
const text = pluralForm({
|
|
131
160
|
message,
|
|
132
161
|
locale
|
|
133
|
-
}, params.count)
|
|
162
|
+
}, params.count);
|
|
163
|
+
if (text !== '') return interpolate(text, params);
|
|
134
164
|
}
|
|
135
165
|
this.warnUnknown(key);
|
|
136
166
|
return key;
|
|
@@ -160,11 +190,16 @@ class CatalogTranslator extends Translator {
|
|
|
160
190
|
for (const locale of this.locales){
|
|
161
191
|
if (locale === this.defaultLocale) continue;
|
|
162
192
|
const message = lookup(this.catalogs[locale], key);
|
|
163
|
-
// A translation with a {param} is left out
|
|
164
|
-
if (typeof message === 'string' && placeholderNames(message).length === 0) {
|
|
193
|
+
// A translation with a {param} is left out, as Discord would show it as written, and so is an empty one
|
|
194
|
+
if (typeof message === 'string' && message !== '' && placeholderNames(message).length === 0) {
|
|
165
195
|
localized[locale] = fillPlaceholders(message, (_name, whole)=>whole);
|
|
166
196
|
}
|
|
167
197
|
}
|
|
198
|
+
// Discord gives a locale it falls back from its partner's value, so the default keeps its own wording beside one
|
|
199
|
+
const partner = FALLBACK_PARTNERS[this.defaultLocale];
|
|
200
|
+
if (partner && localized[partner] !== undefined && typeof original === 'string' && original !== '') {
|
|
201
|
+
localized[this.defaultLocale] = fillPlaceholders(original, (_name, whole)=>whole);
|
|
202
|
+
}
|
|
168
203
|
return localized;
|
|
169
204
|
}
|
|
170
205
|
for(interaction, options = {}) {
|
|
@@ -243,8 +278,14 @@ class CatalogTranslator extends Translator {
|
|
|
243
278
|
}
|
|
244
279
|
}
|
|
245
280
|
if (!locales[defaultLocale]) throw refuse(new Error(`The default locale "${defaultLocale}" has no catalog in locales.`));
|
|
281
|
+
const logger = new Logger('Translator');
|
|
282
|
+
for (const [locale, catalog] of Object.entries(locales)){
|
|
283
|
+
for (const { name, group } of dottedKeys(catalog)){
|
|
284
|
+
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.');
|
|
285
|
+
}
|
|
286
|
+
}
|
|
246
287
|
// Typed by the default catalog; at runtime every catalog is read the same way.
|
|
247
288
|
return new CatalogTranslator(defaultLocale, locales);
|
|
248
289
|
}
|
|
249
290
|
|
|
250
|
-
export { CATALOGS, LOCALE_CHAIN, Translator, createTranslator, defineCatalog, fillPlaceholders, languageOf, lookup, missingTranslatorError, placeholderNames, pluralForm };
|
|
291
|
+
export { CATALOGS, LOCALE_CHAIN, Translator, createTranslator, defineCatalog, fillPlaceholders, isPluralShape, languageOf, lookup, missingTranslatorError, placeholderNames, pluralForm };
|