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,7 +1,6 @@
|
|
|
1
|
-
import { CATALOGS, placeholderNames } from '../common/translator.js';
|
|
1
|
+
import { isPluralShape, CATALOGS, placeholderNames } from '../common/translator.js';
|
|
2
2
|
import { MEOCORD_MESSAGES } from '../common/meocord-messages.js';
|
|
3
3
|
|
|
4
|
-
const isPlural = (value)=>typeof value === 'object' && value !== null && typeof value.other === 'string';
|
|
5
4
|
/** Every message key of a catalog, and whether it is a plural. */ function leaves(catalog, prefix = '') {
|
|
6
5
|
return Object.entries(catalog).flatMap(([name, value])=>{
|
|
7
6
|
const key = `${prefix}${name}`;
|
|
@@ -11,7 +10,7 @@ const isPlural = (value)=>typeof value === 'object' && value !== null && typeof
|
|
|
11
10
|
plural: false
|
|
12
11
|
}
|
|
13
12
|
];
|
|
14
|
-
if (
|
|
13
|
+
if (isPluralShape(value)) return [
|
|
15
14
|
{
|
|
16
15
|
key,
|
|
17
16
|
plural: true
|
|
@@ -20,8 +19,9 @@ const isPlural = (value)=>typeof value === 'object' && value !== null && typeof
|
|
|
20
19
|
return leaves(value, `${key}.`);
|
|
21
20
|
});
|
|
22
21
|
}
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
/** A translation's text, which an empty string isn't: the translator reads `''` as missing, as tools export one untranslated. */ const isText = (value)=>typeof value === 'string' && value !== '';
|
|
23
|
+
/** The value at a key's path, through own properties only, so `constructor` or `toString` is not read off a prototype. */ function at(catalog, key) {
|
|
24
|
+
return key.split('.').reduce((current, part)=>typeof current === 'object' && current !== null && Object.hasOwn(current, part) ? current[part] : undefined, catalog);
|
|
25
25
|
}
|
|
26
26
|
const MEOCORD_KEYS = new Set(leaves(MEOCORD_MESSAGES).map(({ key })=>key));
|
|
27
27
|
const isMeoCordKey = (key)=>key.startsWith('meocord.');
|
|
@@ -34,7 +34,7 @@ const isMeoCordKey = (key)=>key.startsWith('meocord.');
|
|
|
34
34
|
/** 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);
|
|
35
35
|
/** The `{params}` a translation uses that the message it translates doesn't take; a plural's `{count}` is taken. */ function strayParams(translation, original) {
|
|
36
36
|
const taken = placeholders(original);
|
|
37
|
-
if (
|
|
37
|
+
if (isPluralShape(original)) taken.add('count');
|
|
38
38
|
return [
|
|
39
39
|
...placeholders(translation)
|
|
40
40
|
].filter((name)=>!taken.has(name));
|
|
@@ -44,9 +44,11 @@ const isMeoCordKey = (key)=>key.startsWith('meocord.');
|
|
|
44
44
|
*
|
|
45
45
|
* Use it in a test for a team that wants no fallback in production. It reports the messages a locale lacks, the
|
|
46
46
|
* messages the default catalog doesn't have, the plural forms a language needs but a plural lacks, such as `few` for
|
|
47
|
-
* Russian,
|
|
48
|
-
*
|
|
49
|
-
*
|
|
47
|
+
* Russian, a translation of another shape than the default's, a text for a plural or a plural for a text, and each
|
|
48
|
+
* `{param}` a translation uses that the message it translates doesn't take. An empty translation, `''`, counts as
|
|
49
|
+
* lacking, as the translator reads it as missing. It reads the catalogs' own strings, so it checks the params of a
|
|
50
|
+
* catalog from a plain variable or a JSON file too, which the compiler can't. An object counts as a plural only when
|
|
51
|
+
* its every key is a plural category, so a group with an `other` key is a group.
|
|
50
52
|
*
|
|
51
53
|
* @remarks
|
|
52
54
|
* MeoCord's own texts, a catalog's `meocord` group, fall back to English by design, so only a key MeoCord lacks, and
|
|
@@ -93,12 +95,17 @@ const isMeoCordKey = (key)=>key.startsWith('meocord.');
|
|
|
93
95
|
const categories = new Intl.PluralRules(locale).resolvedOptions().pluralCategories;
|
|
94
96
|
for (const { key, plural } of reference){
|
|
95
97
|
const message = at(catalog, key);
|
|
96
|
-
if (message === undefined) {
|
|
98
|
+
if (message === undefined || message === '') {
|
|
97
99
|
problems.push(`missing ${key}`);
|
|
98
100
|
continue;
|
|
99
101
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
+
// A translation of another shape than the default's can't be read as it: a text for a plural, or the reverse
|
|
103
|
+
if (plural !== isPluralShape(message) || !plural && typeof message !== 'string') {
|
|
104
|
+
problems.push(`${key} should be a ${plural ? 'plural' : 'text'}, as the default is`);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (plural) {
|
|
108
|
+
const missing = categories.filter((category)=>!isText(message[category]));
|
|
102
109
|
if (missing.length > 0) problems.push(`${key} lacks ${missing.join(', ')}`);
|
|
103
110
|
}
|
|
104
111
|
const original = at(defaultCatalog, key);
|
|
@@ -106,7 +113,7 @@ const isMeoCordKey = (key)=>key.startsWith('meocord.');
|
|
|
106
113
|
for (const name of stray)problems.push(`${key} takes no {${name}}; the default is "${shown(original)}"`);
|
|
107
114
|
}
|
|
108
115
|
if (options.meocord && !locale.startsWith('en-')) {
|
|
109
|
-
for (const key of MEOCORD_KEYS)if (at(catalog, key) === undefined) problems.push(`missing ${key}`);
|
|
116
|
+
for (const key of MEOCORD_KEYS)if (at(catalog, key) === undefined || at(catalog, key) === '') problems.push(`missing ${key}`);
|
|
110
117
|
}
|
|
111
118
|
for (const { key } of leaves(catalog)){
|
|
112
119
|
if (isMeoCordKey(key)) {
|
|
@@ -12,8 +12,9 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
|
|
|
12
12
|
*
|
|
13
13
|
* @remarks
|
|
14
14
|
* The cases cover what a shared store most often gets wrong: a sliding window rather than fixed buckets,
|
|
15
|
-
* `retryAfterMs` from the oldest
|
|
16
|
-
* calls at the limit where exactly one may pass
|
|
15
|
+
* `retryAfterMs` until a use frees up, from the oldest of the newest `uses` calls in the window, calls in the same
|
|
16
|
+
* millisecond kept apart, concurrent calls at the limit where exactly one may pass, a batch refusal that names the
|
|
17
|
+
* longest wait, and a release that frees only the call it gave back. They use real time, with windows short enough that the suite takes
|
|
17
18
|
* a few seconds and margins wide enough that a correct store passes when timers fire late, as on a busy CI runner,
|
|
18
19
|
* and each case counts under keys of its own. Each window is a whole number of milliseconds, as
|
|
19
20
|
* `@Cooldown` gives a store: it rounds its `seconds` to the millisecond.
|
|
@@ -88,6 +89,30 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
|
|
|
88
89
|
expect(retryAfterMs).toBeLessThanOrEqual(limit.windowMs - (secondSent - firstDone) + CLOCK_SLACK_MS);
|
|
89
90
|
expect(retryAfterMs).toBeGreaterThanOrEqual(limit.windowMs - (thirdDone - firstSent) - CLOCK_SLACK_MS);
|
|
90
91
|
});
|
|
92
|
+
// @Cooldown keeps the calls counted so far when a limit is lowered, so a key can hold more calls than `uses`
|
|
93
|
+
test('counts retryAfterMs from the oldest of the newest `uses` calls when a lowered limit leaves more in the window', async (store)=>{
|
|
94
|
+
const windowMs = 2000;
|
|
95
|
+
for(let call = 0; call < 3; call += 1)await store.consume(key('lowered'), {
|
|
96
|
+
uses: 5,
|
|
97
|
+
windowMs
|
|
98
|
+
});
|
|
99
|
+
await sleep(1000);
|
|
100
|
+
const newerSent = Date.now();
|
|
101
|
+
for(let call = 0; call < 2; call += 1)await store.consume(key('lowered'), {
|
|
102
|
+
uses: 5,
|
|
103
|
+
windowMs
|
|
104
|
+
});
|
|
105
|
+
const { allowed, retryAfterMs } = await store.consume(key('lowered'), {
|
|
106
|
+
uses: 2,
|
|
107
|
+
windowMs
|
|
108
|
+
});
|
|
109
|
+
const refusedDone = Date.now();
|
|
110
|
+
expect(allowed).toBe(false);
|
|
111
|
+
// A use frees up when the older of the two newest calls leaves the window; counted from the oldest call, it would
|
|
112
|
+
// be a second sooner
|
|
113
|
+
expect(retryAfterMs).toBeGreaterThanOrEqual(windowMs - (refusedDone - newerSent) - CLOCK_SLACK_MS);
|
|
114
|
+
expect(retryAfterMs).toBeLessThanOrEqual(windowMs + CLOCK_SLACK_MS);
|
|
115
|
+
});
|
|
91
116
|
test('counts each key on its own', async (store)=>{
|
|
92
117
|
const limit = {
|
|
93
118
|
uses: 1,
|
|
@@ -149,6 +174,41 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
|
|
|
149
174
|
expect(refused.retryAfterMs).toBeGreaterThan(short.windowMs);
|
|
150
175
|
expect(refused.retryAfterMs).toBeLessThanOrEqual(long.windowMs);
|
|
151
176
|
});
|
|
177
|
+
// A refusal names the wait until every cooldown of the batch allows the call; the default consumeMany stops at the first
|
|
178
|
+
test('names the longest wait when several cooldowns refuse a batch, or with the default consumeMany, the first', async (store)=>{
|
|
179
|
+
const short = {
|
|
180
|
+
uses: 1,
|
|
181
|
+
windowMs: 1000
|
|
182
|
+
};
|
|
183
|
+
const long = {
|
|
184
|
+
uses: 1,
|
|
185
|
+
windowMs: 2000
|
|
186
|
+
};
|
|
187
|
+
const batch = [
|
|
188
|
+
{
|
|
189
|
+
key: key('both-short'),
|
|
190
|
+
limit: short
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
key: key('both-long'),
|
|
194
|
+
limit: long
|
|
195
|
+
}
|
|
196
|
+
];
|
|
197
|
+
await store.consume(batch[0].key, short);
|
|
198
|
+
await store.consume(batch[1].key, long);
|
|
199
|
+
const refused = await store.consumeMany(batch);
|
|
200
|
+
expect(refused.allowed).toBe(false);
|
|
201
|
+
const [blocked, limit] = overridesConsumeMany(store) ? [
|
|
202
|
+
1,
|
|
203
|
+
long
|
|
204
|
+
] : [
|
|
205
|
+
0,
|
|
206
|
+
short
|
|
207
|
+
];
|
|
208
|
+
expect(refused.blocked).toBe(blocked);
|
|
209
|
+
expect(refused.retryAfterMs).toBeGreaterThan(limit.windowMs - 1000);
|
|
210
|
+
expect(refused.retryAfterMs).toBeLessThanOrEqual(limit.windowMs);
|
|
211
|
+
});
|
|
152
212
|
// The default consumeMany counts in order and stops at the first refusal, so the entries before it count
|
|
153
213
|
test('records nothing when one cooldown of a batch refuses it, or with the default consumeMany, counts those before it', async (store)=>{
|
|
154
214
|
const limit = {
|
|
@@ -191,10 +251,11 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
|
|
|
191
251
|
}, ()=>store.consumeMany(batch)));
|
|
192
252
|
expect(verdicts.filter((verdict)=>verdict.allowed).length).toBe(1);
|
|
193
253
|
});
|
|
194
|
-
// A store without release keeps a counted call counted, as the default consumeMany does
|
|
195
|
-
|
|
254
|
+
// A store without release keeps a counted call counted, as the default consumeMany does. Two uses tell a release of
|
|
255
|
+
// the one call apart from one that frees the key's other calls too
|
|
256
|
+
test('releases only the call it counted when the verdict gives release, or keeps it counted without one', async (store)=>{
|
|
196
257
|
const limit = {
|
|
197
|
-
uses:
|
|
258
|
+
uses: 2,
|
|
198
259
|
windowMs: 2000
|
|
199
260
|
};
|
|
200
261
|
const entries = [
|
|
@@ -207,6 +268,7 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
|
|
|
207
268
|
limit
|
|
208
269
|
}
|
|
209
270
|
];
|
|
271
|
+
expect((await store.consumeMany(entries)).allowed).toBe(true);
|
|
210
272
|
const counted = await store.consumeMany(entries);
|
|
211
273
|
expect(counted.allowed).toBe(true);
|
|
212
274
|
if (!counted.release) {
|
|
@@ -1,10 +1,66 @@
|
|
|
1
|
-
import { Message, Guild, TextChannel, ThreadChannel,
|
|
2
|
-
import { createMockChannel, createMockGuild, memberRoles, createMockUser, isRawMember } from './mock-interaction.js';
|
|
1
|
+
import { GuildChannel, MessageReaction, MessageMentions, Message, GuildMember, BaseGuild, Guild, ModalSubmitInteraction, TextChannel, ThreadChannel, BaseGuildVoiceChannel, GuildPremiumTier, GuildVerificationLevel, GuildNSFWLevel, Locale, PresenceManager, ClientUser, UserFlagsBitField, User, GuildMemberFlagsBitField, GuildEmoji, Sticker, GuildScheduledEvent, AutoModerationRule, Invite, Role, Collection, MessageType, ReactionManager, ChatInputCommandInteraction, CommandInteraction, AutocompleteInteraction, ReactionUserManager, BaseInteraction, ChannelType, NewsChannel, VoiceChannel, ForumChannel, MediaChannel, DMChannel, BaseChannel, Client, PermissionsBitField, PermissionFlagsBits } from 'discord.js';
|
|
2
|
+
import { createMockChannel, createMockGuild, createMockMessage, ownedManager, memberRoles, createMockUser, createChatInputOptions, readPlaceAs, botMemberOf, heldChannelOf, isRawMember, isUnsetEveryone, NEW_SERVER_EVERYONE_PERMISSIONS } from './mock-interaction.js';
|
|
3
|
+
import { nextSnowflake } from './snowflake.js';
|
|
3
4
|
import { strictMocks } from './strict-mocks.js';
|
|
5
|
+
import { Logger } from '../common/logger.js';
|
|
6
|
+
import { warnOnce } from '../common/deprecation.js';
|
|
4
7
|
|
|
8
|
+
const mockLogger = new Logger('Mocks');
|
|
9
|
+
/**
|
|
10
|
+
* A member's permissions where an interaction was made, as Discord computes them: under strict mocks, every permission
|
|
11
|
+
* for an Administrator or the owner, else the channel's overwrites on top of `member.permissions`, so a test that sets
|
|
12
|
+
* those keeps the base; otherwise `member.permissions`, with a warning once where strict mocks would read otherwise.
|
|
13
|
+
*/ function inChannel(interaction, member) {
|
|
14
|
+
const base = member.permissions;
|
|
15
|
+
// Only a server the mock caches has the overwrites to apply
|
|
16
|
+
const { guild } = interaction;
|
|
17
|
+
if (!(guild instanceof Guild)) return base;
|
|
18
|
+
const computed = base.has(PermissionFlagsBits.Administrator) || member.id === guild.ownerId ? new PermissionsBitField(PermissionsBitField.All).freeze() : withOverwrites(base, overwritingChannel(interaction, guild), member);
|
|
19
|
+
if (strictMocks()) return computed;
|
|
20
|
+
if (!computed.equals(base)) {
|
|
21
|
+
const lost = new PermissionsBitField(base).remove(computed).toArray();
|
|
22
|
+
const gained = new PermissionsBitField(computed).remove(base).toArray();
|
|
23
|
+
const change = lost.length > 0 ? [
|
|
24
|
+
`without ${lost.join(', ')}`
|
|
25
|
+
] : [];
|
|
26
|
+
if (gained.length > 0) change.push(`with ${gained.join(', ')}`);
|
|
27
|
+
warnOnce(mockLogger, `${interaction.constructor.name}.memberPermissions reads the member's own permissions here, without the ` + "channel's overwrites or every permission an Administrator or the owner has; in the next major version (5.0) " + `it reads them as Discord computes them in the channel, ${change.join(' and ')}. Set memberPermissions on the ` + 'mock, or call useStrictMocks() to compute them now.');
|
|
28
|
+
}
|
|
29
|
+
// Unless the test set them, a member's permissions come from its roles, @everyone's included
|
|
30
|
+
if (!Object.prototype.hasOwnProperty.call(member, 'permissions')) warnOnUnsetEveryone(interaction, guild, 'memberPermissions', base);
|
|
31
|
+
return base;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Warns once, as `key`, where `permissions` read a server's @everyone role default mode made with none, and lack what
|
|
35
|
+
* strict mocks give @everyone in a new server.
|
|
36
|
+
*/ function warnOnUnsetEveryone(interaction, guild, key, permissions) {
|
|
37
|
+
const everyone = guild.roles.everyone;
|
|
38
|
+
if (!everyone || !isUnsetEveryone(everyone) || permissions.has(NEW_SERVER_EVERYONE_PERMISSIONS)) return;
|
|
39
|
+
warnOnce(mockLogger, `${interaction.constructor.name}.${key} reads the server's @everyone role here with no permissions; in the next major ` + 'version (5.0) it has the permissions Discord gives @everyone in a new server, as strict mocks read it. Set ' + 'guild.roles.everyone.permissions, or call useStrictMocks() to read it so now.');
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The channel whose overwrites apply where an interaction was made, read without making or caching one: a thread's
|
|
43
|
+
* parent, or undefined where the channel, or a thread's parent, is one the mock would make, which has none.
|
|
44
|
+
*/ function overwritingChannel(interaction, guild) {
|
|
45
|
+
const channel = heldChannelOf(interaction, guild);
|
|
46
|
+
// A thread's parent by the id the test gave it or one already read, so strict mocks' parent isn't made for this read
|
|
47
|
+
const parentId = (thread)=>Object.prototype.hasOwnProperty.call(thread, 'parentId') ? thread.parentId : madeParentIds.get(thread);
|
|
48
|
+
const at = channel instanceof ThreadChannel ? guild.channels.cache.get(parentId(channel) ?? '') : channel;
|
|
49
|
+
return at instanceof GuildChannel ? at : undefined;
|
|
50
|
+
}
|
|
51
|
+
// The parent id strict mocks made for each thread made without one, once read
|
|
52
|
+
const madeParentIds = new WeakMap();
|
|
53
|
+
// Private in discord.js's typings, though they are what its own permissionsFor computes with; called on the prototype
|
|
54
|
+
// so a test's stub on the channel stays for its caller
|
|
55
|
+
const channelPermissions = GuildChannel.prototype;
|
|
56
|
+
/** `base` with `channel`'s overwrites for `member` applied, in discord.js's order: @everyone's, the roles', the member's. */ function withOverwrites(base, channel, member) {
|
|
57
|
+
if (!channel) return base;
|
|
58
|
+
// Given the member and its roles as verified, as discord.js's memberPermissions does, so it resolves nothing again
|
|
59
|
+
const overwrites = channelPermissions.overwritesFor.call(channel, member, true, member.roles.cache);
|
|
60
|
+
return new PermissionsBitField(base).remove(overwrites.everyone?.deny ?? 0n).add(overwrites.everyone?.allow ?? 0n).remove(overwrites.roles.length > 0 ? overwrites.roles.map((role)=>role.deny) : 0n).add(overwrites.roles.length > 0 ? overwrites.roles.map((role)=>role.allow) : 0n).remove(overwrites.member?.deny ?? 0n).add(overwrites.member?.allow ?? 0n).freeze();
|
|
61
|
+
}
|
|
5
62
|
/** A permission set with none, frozen as discord.js freezes the ones it gives. */ const noPermissions = ()=>new PermissionsBitField().freeze();
|
|
6
63
|
/** A property discord.js computes from the others: its own getter runs, reading the mock's values. */ const REAL_GETTER = Symbol('real getter');
|
|
7
|
-
const snowflake = ()=>SnowflakeUtil.generate().toString();
|
|
8
64
|
/** A class and its defaults, typed together so a default that stops fitting its property fails to compile. */ const defaultsOf = (Class, defaults)=>[
|
|
9
65
|
Class.prototype,
|
|
10
66
|
defaults
|
|
@@ -17,7 +73,7 @@ const snowflake = ()=>SnowflakeUtil.generate().toString();
|
|
|
17
73
|
defaultsOf(Guild, {
|
|
18
74
|
name: ()=>'Guild',
|
|
19
75
|
preferredLocale: ()=>Locale.EnglishUS,
|
|
20
|
-
ownerId:
|
|
76
|
+
ownerId: nextSnowflake,
|
|
21
77
|
memberCount: ()=>1,
|
|
22
78
|
available: ()=>true,
|
|
23
79
|
large: ()=>false,
|
|
@@ -30,6 +86,9 @@ const snowflake = ()=>SnowflakeUtil.generate().toString();
|
|
|
30
86
|
shardId: ()=>0,
|
|
31
87
|
nsfwLevel: ()=>GuildNSFWLevel.Default,
|
|
32
88
|
verificationLevel: ()=>GuildVerificationLevel.None,
|
|
89
|
+
presences: (guild)=>ownedManager(PresenceManager.prototype, {
|
|
90
|
+
guild
|
|
91
|
+
}),
|
|
33
92
|
premiumTier: ()=>GuildPremiumTier.None,
|
|
34
93
|
createdTimestamp: REAL_GETTER,
|
|
35
94
|
createdAt: REAL_GETTER
|
|
@@ -44,6 +103,7 @@ const snowflake = ()=>SnowflakeUtil.generate().toString();
|
|
|
44
103
|
discriminator: ()=>'0',
|
|
45
104
|
avatar: ()=>null,
|
|
46
105
|
system: ()=>false,
|
|
106
|
+
flags: ()=>new UserFlagsBitField(),
|
|
47
107
|
tag: REAL_GETTER,
|
|
48
108
|
displayName: REAL_GETTER,
|
|
49
109
|
defaultAvatarURL: REAL_GETTER,
|
|
@@ -64,12 +124,32 @@ const snowflake = ()=>SnowflakeUtil.generate().toString();
|
|
|
64
124
|
premiumSinceTimestamp: ()=>null,
|
|
65
125
|
communicationDisabledUntilTimestamp: ()=>null,
|
|
66
126
|
pending: ()=>false,
|
|
127
|
+
flags: ()=>new GuildMemberFlagsBitField().freeze(),
|
|
67
128
|
displayName: REAL_GETTER,
|
|
68
129
|
roles: (member)=>memberRoles(member, []),
|
|
69
130
|
permissions: REAL_GETTER
|
|
70
131
|
}),
|
|
132
|
+
// A server's emoji, sticker, scheduled event and AutoMod rule are named, and what discord.js formats from them
|
|
133
|
+
// computes: an emoji's mention and identifier, an event's and an invite's link
|
|
134
|
+
defaultsOf(GuildEmoji, {
|
|
135
|
+
name: ()=>'emoji',
|
|
136
|
+
animated: ()=>false,
|
|
137
|
+
identifier: REAL_GETTER
|
|
138
|
+
}),
|
|
139
|
+
defaultsOf(Sticker, {
|
|
140
|
+
name: ()=>'sticker'
|
|
141
|
+
}),
|
|
142
|
+
defaultsOf(GuildScheduledEvent, {
|
|
143
|
+
name: ()=>'event',
|
|
144
|
+
url: REAL_GETTER
|
|
145
|
+
}),
|
|
146
|
+
defaultsOf(AutoModerationRule, {
|
|
147
|
+
name: ()=>'rule'
|
|
148
|
+
}),
|
|
149
|
+
defaultsOf(Invite, {
|
|
150
|
+
url: REAL_GETTER
|
|
151
|
+
}),
|
|
71
152
|
defaultsOf(Role, {
|
|
72
|
-
id: snowflake,
|
|
73
153
|
name: ()=>'role',
|
|
74
154
|
// Above @everyone, which a server's role manager makes at position 0
|
|
75
155
|
position: ()=>1,
|
|
@@ -107,37 +187,71 @@ const snowflake = ()=>SnowflakeUtil.generate().toString();
|
|
|
107
187
|
poll: ()=>null,
|
|
108
188
|
interactionMetadata: ()=>null,
|
|
109
189
|
stickers: ()=>new Collection(),
|
|
190
|
+
messageSnapshots: ()=>new Collection(),
|
|
191
|
+
reactions: (message)=>ownedManager(ReactionManager.prototype, {
|
|
192
|
+
message
|
|
193
|
+
}),
|
|
110
194
|
url: REAL_GETTER
|
|
111
195
|
}),
|
|
112
196
|
// A command's name and a component's customId say which handler a call is for, so they stay the test's to give
|
|
197
|
+
defaultsOf(ChatInputCommandInteraction, {
|
|
198
|
+
options: ()=>createChatInputOptions()
|
|
199
|
+
}),
|
|
113
200
|
defaultsOf(CommandInteraction, {
|
|
114
|
-
commandId:
|
|
201
|
+
commandId: nextSnowflake,
|
|
115
202
|
commandGuildId: ()=>null
|
|
116
203
|
}),
|
|
117
204
|
defaultsOf(AutocompleteInteraction, {
|
|
118
|
-
commandId:
|
|
119
|
-
commandGuildId: ()=>null
|
|
205
|
+
commandId: nextSnowflake,
|
|
206
|
+
commandGuildId: ()=>null,
|
|
207
|
+
options: ()=>createChatInputOptions()
|
|
208
|
+
}),
|
|
209
|
+
defaultsOf(MessageReaction, {
|
|
210
|
+
users: (reaction)=>ownedManager(ReactionUserManager.prototype, {
|
|
211
|
+
reaction
|
|
212
|
+
})
|
|
120
213
|
}),
|
|
121
214
|
defaultsOf(BaseInteraction, {
|
|
122
|
-
applicationId:
|
|
215
|
+
applicationId: nextSnowflake,
|
|
123
216
|
token: ()=>'mock-interaction-token',
|
|
124
217
|
version: ()=>1,
|
|
125
218
|
context: ()=>null,
|
|
219
|
+
entitlements: ()=>new Collection(),
|
|
126
220
|
// What Discord sends with an interaction in a server, the member's permissions there; typed for a cached one
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
221
|
+
// Each reads where the interaction is as its own, and warns by its own name where strict mocks place it otherwise
|
|
222
|
+
memberPermissions: (interaction)=>readPlaceAs(interaction, 'memberPermissions', ()=>{
|
|
223
|
+
const { guildId } = interaction;
|
|
224
|
+
const member = interaction.member;
|
|
225
|
+
if (!guildId) return null;
|
|
226
|
+
if (member instanceof GuildMember) return inChannel(interaction, member);
|
|
227
|
+
// A raw member's permissions are the string Discord sends, read as discord.js reads them
|
|
228
|
+
return isRawMember(member) && !interaction.guild ? new PermissionsBitField(BigInt(member.permissions)).freeze() : null;
|
|
229
|
+
}),
|
|
230
|
+
// The bot's permissions where the interaction was made: its member's in the channel, or in a thread its parent, as
|
|
231
|
+
// discord.js computes them; none outside a server
|
|
232
|
+
appPermissions: (interaction)=>readPlaceAs(interaction, 'appPermissions', ()=>{
|
|
233
|
+
const { guild } = interaction;
|
|
234
|
+
const me = guild instanceof Guild ? botMemberOf(guild) : null;
|
|
235
|
+
if (!(me instanceof GuildMember) || heldChannelOf(interaction, guild) === null) return noPermissions();
|
|
236
|
+
// discord.js's own computation, given its own overwritesFor over a test's stub on the channel; a channel the
|
|
237
|
+
// mock would make has no overwrites
|
|
238
|
+
const at = overwritingChannel(interaction, guild) ?? {
|
|
239
|
+
guild,
|
|
240
|
+
permissionOverwrites: {
|
|
241
|
+
cache: new Collection()
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
const channel = Object.create(at, {
|
|
245
|
+
overwritesFor: {
|
|
246
|
+
value: channelPermissions.overwritesFor
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
const permissions = channelPermissions.memberPermissions.call(channel, me, true);
|
|
250
|
+
// The bot's member has @everyone's too
|
|
251
|
+
warnOnUnsetEveryone(interaction, guild, 'appPermissions', permissions);
|
|
252
|
+
return permissions;
|
|
253
|
+
}, // A server the bot isn't in has no guild in either mode, so no permissions in both
|
|
254
|
+
(place)=>!place.raw)
|
|
141
255
|
}),
|
|
142
256
|
defaultsOf(TextChannel, {
|
|
143
257
|
type: ()=>ChannelType.GuildText,
|
|
@@ -173,7 +287,7 @@ const snowflake = ()=>SnowflakeUtil.generate().toString();
|
|
|
173
287
|
}),
|
|
174
288
|
defaultsOf(GuildChannel, {
|
|
175
289
|
name: ()=>'channel',
|
|
176
|
-
guildId: (channel)=>typeof channel.guild?.id === 'string' ? channel.guild.id :
|
|
290
|
+
guildId: (channel)=>typeof channel.guild?.id === 'string' ? channel.guild.id : nextSnowflake(),
|
|
177
291
|
position: ()=>0,
|
|
178
292
|
rawPosition: ()=>0,
|
|
179
293
|
parentId: ()=>null
|
|
@@ -197,11 +311,26 @@ const snowflake = ()=>SnowflakeUtil.generate().toString();
|
|
|
197
311
|
* thread made without one, a thread's parent text channel in it, and data that is otherwise a placeholder.
|
|
198
312
|
*/ const STRICT_DEFAULTS = new Map([
|
|
199
313
|
defaultsOf(Message, {
|
|
200
|
-
content: ()=>''
|
|
314
|
+
content: ()=>'',
|
|
315
|
+
editedTimestamp: ()=>null,
|
|
316
|
+
editedAt: REAL_GETTER
|
|
317
|
+
}),
|
|
318
|
+
defaultsOf(MessageMentions, {
|
|
319
|
+
repliedUser: ()=>null
|
|
201
320
|
}),
|
|
202
321
|
defaultsOf(Guild, {
|
|
203
322
|
rulesChannelId: ()=>null,
|
|
204
|
-
publicUpdatesChannelId: ()=>null
|
|
323
|
+
publicUpdatesChannelId: ()=>null,
|
|
324
|
+
systemChannel: REAL_GETTER
|
|
325
|
+
}),
|
|
326
|
+
defaultsOf(BaseGuild, {
|
|
327
|
+
verified: REAL_GETTER
|
|
328
|
+
}),
|
|
329
|
+
defaultsOf(GuildMember, {
|
|
330
|
+
presence: REAL_GETTER
|
|
331
|
+
}),
|
|
332
|
+
defaultsOf(ModalSubmitInteraction, {
|
|
333
|
+
message: ()=>null
|
|
205
334
|
}),
|
|
206
335
|
defaultsOf(ThreadChannel, {
|
|
207
336
|
guild: ()=>createMockGuild(),
|
|
@@ -211,25 +340,91 @@ const snowflake = ()=>SnowflakeUtil.generate().toString();
|
|
|
211
340
|
guild: thread.guild
|
|
212
341
|
});
|
|
213
342
|
thread.guild.channels.cache.set(parent.id, parent);
|
|
343
|
+
madeParentIds.set(thread, parent.id);
|
|
214
344
|
return parent.id;
|
|
215
345
|
},
|
|
216
346
|
parent: REAL_GETTER,
|
|
217
|
-
ownerId:
|
|
347
|
+
ownerId: nextSnowflake,
|
|
218
348
|
archived: ()=>false,
|
|
219
349
|
locked: ()=>false
|
|
220
350
|
}),
|
|
221
351
|
defaultsOf(GuildChannel, {
|
|
222
|
-
guild: ()=>createMockGuild()
|
|
352
|
+
guild: ()=>createMockGuild(),
|
|
353
|
+
parent: REAL_GETTER
|
|
223
354
|
}),
|
|
224
355
|
// Full once its members reach a user limit, which a voice channel has none of unless given one
|
|
225
356
|
defaultsOf(BaseGuildVoiceChannel, {
|
|
226
357
|
userLimit: ()=>0,
|
|
227
358
|
full: REAL_GETTER
|
|
228
359
|
}),
|
|
360
|
+
// A reaction's message, as discord.js always has one, is a whole one
|
|
229
361
|
defaultsOf(MessageReaction, {
|
|
230
|
-
count: ()=>1
|
|
362
|
+
count: ()=>1,
|
|
363
|
+
me: ()=>false,
|
|
364
|
+
message: ()=>createMockMessage()
|
|
231
365
|
})
|
|
232
366
|
]);
|
|
367
|
+
/**
|
|
368
|
+
* What a mock reads as a placeholder unless strict mocks read it as discord.js gives it, by the value a test sets for
|
|
369
|
+
* that, which the warning on reading one names.
|
|
370
|
+
*/ const PLACEHOLDER_VALUES = new Map([
|
|
371
|
+
[
|
|
372
|
+
MessageReaction.prototype,
|
|
373
|
+
{
|
|
374
|
+
me: 'false',
|
|
375
|
+
message: 'createMockMessage()'
|
|
376
|
+
}
|
|
377
|
+
],
|
|
378
|
+
[
|
|
379
|
+
MessageMentions.prototype,
|
|
380
|
+
{
|
|
381
|
+
repliedUser: 'null'
|
|
382
|
+
}
|
|
383
|
+
],
|
|
384
|
+
[
|
|
385
|
+
Message.prototype,
|
|
386
|
+
{
|
|
387
|
+
editedAt: 'null'
|
|
388
|
+
}
|
|
389
|
+
],
|
|
390
|
+
[
|
|
391
|
+
GuildMember.prototype,
|
|
392
|
+
{
|
|
393
|
+
presence: 'null'
|
|
394
|
+
}
|
|
395
|
+
],
|
|
396
|
+
[
|
|
397
|
+
BaseGuild.prototype,
|
|
398
|
+
{
|
|
399
|
+
verified: 'false'
|
|
400
|
+
}
|
|
401
|
+
],
|
|
402
|
+
[
|
|
403
|
+
Guild.prototype,
|
|
404
|
+
{
|
|
405
|
+
systemChannel: 'null'
|
|
406
|
+
}
|
|
407
|
+
],
|
|
408
|
+
[
|
|
409
|
+
GuildChannel.prototype,
|
|
410
|
+
{
|
|
411
|
+
parent: 'null'
|
|
412
|
+
}
|
|
413
|
+
],
|
|
414
|
+
[
|
|
415
|
+
ModalSubmitInteraction.prototype,
|
|
416
|
+
{
|
|
417
|
+
message: 'null'
|
|
418
|
+
}
|
|
419
|
+
]
|
|
420
|
+
]);
|
|
421
|
+
/** The value a test sets for `key` on a mock of `target`'s class, when a mock reads it as a placeholder. */ function placeholderValue(target, key) {
|
|
422
|
+
for(let proto = Object.getPrototypeOf(target); proto !== null; proto = Object.getPrototypeOf(proto)){
|
|
423
|
+
const value = PLACEHOLDER_VALUES.get(proto)?.[key];
|
|
424
|
+
if (value !== undefined) return value;
|
|
425
|
+
}
|
|
426
|
+
return undefined;
|
|
427
|
+
}
|
|
233
428
|
/** The default a mock of `target`'s class has for `key`, read from the nearest class that has one, strict ones first. */ function discordDefault(target, key) {
|
|
234
429
|
for (const defaults of strictMocks() ? [
|
|
235
430
|
STRICT_DEFAULTS,
|
|
@@ -245,4 +440,4 @@ const snowflake = ()=>SnowflakeUtil.generate().toString();
|
|
|
245
440
|
return undefined;
|
|
246
441
|
}
|
|
247
442
|
|
|
248
|
-
export { REAL_GETTER, discordDefault };
|
|
443
|
+
export { REAL_GETTER, discordDefault, placeholderValue };
|