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,5 +1,5 @@
|
|
|
1
1
|
import { createHash } from 'crypto';
|
|
2
|
-
import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'fs';
|
|
2
|
+
import { existsSync, readFileSync, mkdirSync, writeFileSync, rmSync } from 'fs';
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import { ApplicationCommandType, Routes } from 'discord.js';
|
|
5
5
|
import CliTable3 from 'cli-table3';
|
|
@@ -103,19 +103,35 @@ const scopeKey = (scope)=>scope === 'global' ? 'global' : `guild-${scope.guild}`
|
|
|
103
103
|
const guilds = options.config?.guilds ?? [];
|
|
104
104
|
return !everythingTarget(options) && guilds.length > 0 && ids(guilds).length === 0;
|
|
105
105
|
}
|
|
106
|
+
/** Whether a command is an activity's primary entry point, which Discord takes only globally. */ const isEntryPoint = (command)=>command.body.type === ApplicationCommandType.PrimaryEntryPoint;
|
|
107
|
+
/** What a blank guild to register every command to says, as an unset variable gives one. */ const BLANK_GUILD = 'No commands were registered: the guild to register to is blank. Give a guild id, or leave it out to register where commands.* says.';
|
|
106
108
|
/**
|
|
107
109
|
* Where each command is sent: one bulk update per scope. A scope's update replaces everything the application has
|
|
108
110
|
* there, so the default scope is included even when it ends up empty, unless `commands.guilds` lists no guild id.
|
|
111
|
+
*
|
|
112
|
+
* A primary entry point command goes in the global update, as Discord refuses a guild update that holds one. A run
|
|
113
|
+
* that sends everything to one guild sends nothing global, so it leaves the entry point out.
|
|
109
114
|
*/ function planTargets(commands, options, logger) {
|
|
115
|
+
// Registering to the default scopes instead could publish everywhere
|
|
116
|
+
if (options.onlyGuild !== undefined && !options.onlyGuild.trim()) {
|
|
117
|
+
logger.error(BLANK_GUILD);
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
110
120
|
const everythingTo = everythingTarget(options);
|
|
111
|
-
if (everythingTo)
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
},
|
|
116
|
-
commands
|
|
121
|
+
if (everythingTo) {
|
|
122
|
+
// A global update from here would hold only what this run sends, deleting the application's other global commands
|
|
123
|
+
for (const command of commands.filter(isEntryPoint)){
|
|
124
|
+
logger.warn(`Entry point command "${command.name}" is registered globally only; a production run or \`meocord register\` registers it.`);
|
|
117
125
|
}
|
|
118
|
-
|
|
126
|
+
return [
|
|
127
|
+
{
|
|
128
|
+
scope: {
|
|
129
|
+
guild: everythingTo
|
|
130
|
+
},
|
|
131
|
+
commands: commands.filter((command)=>!isEntryPoint(command))
|
|
132
|
+
}
|
|
133
|
+
];
|
|
134
|
+
}
|
|
119
135
|
const defaultGuilds = ids(options.config?.guilds);
|
|
120
136
|
const nowhere = defaultGuildsEmpty(options);
|
|
121
137
|
const targets = new Map();
|
|
@@ -133,7 +149,12 @@ const scopeKey = (scope)=>scope === 'global' ? 'global' : `guild-${scope.guild}`
|
|
|
133
149
|
});
|
|
134
150
|
const unregistered = [];
|
|
135
151
|
for (const command of commands){
|
|
136
|
-
if (command
|
|
152
|
+
if (isEntryPoint(command)) {
|
|
153
|
+
if (command.guilds) {
|
|
154
|
+
logger.warn(`Entry point command "${command.name}" can only be registered globally, so the guilds its builder lists are ignored.`);
|
|
155
|
+
}
|
|
156
|
+
add('global', command);
|
|
157
|
+
} else if (command.guilds) {
|
|
137
158
|
const own = ids(command.guilds);
|
|
138
159
|
// Falling back to the default scope could publish a staff-only command everywhere.
|
|
139
160
|
if (own.length === 0) logger.warn(`Command "${command.name}" lists no guild ids in its builder, so it is not registered.`);
|
|
@@ -151,7 +172,7 @@ const scopeKey = (scope)=>scope === 'global' ? 'global' : `guild-${scope.guild}`
|
|
|
151
172
|
}
|
|
152
173
|
}
|
|
153
174
|
if (nowhere) {
|
|
154
|
-
const outcome = unregistered.length === 0 ? 'nothing is registered globally' : `${unregistered.join(', ')} ${unregistered.length === 1 ? 'is' : 'are'} not registered, rather than registered globally`;
|
|
175
|
+
const outcome = unregistered.length === 0 ? targets.has('global') ? 'only the entry point command is registered globally' : 'nothing is registered globally' : `${unregistered.join(', ')} ${unregistered.length === 1 ? 'is' : 'are'} not registered, rather than registered globally`;
|
|
155
176
|
logger.warn(`commands.guilds lists no guild id, as an unset environment variable leaves it, so ${outcome}. ` + `Set the guild ids, or remove commands.guilds to register globally.`);
|
|
156
177
|
}
|
|
157
178
|
return [
|
|
@@ -180,28 +201,57 @@ const describeScope = (scope)=>scope === 'global' ? 'globally' : `to guild ${sco
|
|
|
180
201
|
return path.join(nodeModules, '.cache', 'meocord', `commands-${applicationId}-${scopeKey(scope)}.json`);
|
|
181
202
|
}
|
|
182
203
|
const hashOf = (commands)=>createHash('sha256').update(JSON.stringify(commands.map(({ body })=>body))).digest('hex');
|
|
183
|
-
function
|
|
204
|
+
function readRecord(file, logger) {
|
|
184
205
|
if (!file || !existsSync(file)) return undefined;
|
|
185
206
|
try {
|
|
186
|
-
return JSON.parse(readFileSync(file, 'utf8'))
|
|
207
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
187
208
|
} catch (error) {
|
|
188
209
|
logger.debug(`Could not read the registered commands, so they are sent again: ${String(error)}`);
|
|
189
210
|
return undefined;
|
|
190
211
|
}
|
|
191
212
|
}
|
|
192
|
-
function
|
|
213
|
+
function writeRecord(file, record, logger) {
|
|
193
214
|
if (!file) return;
|
|
194
215
|
try {
|
|
195
216
|
mkdirSync(path.dirname(file), {
|
|
196
217
|
recursive: true
|
|
197
218
|
});
|
|
198
|
-
writeFileSync(file, `${JSON.stringify(
|
|
199
|
-
hash
|
|
200
|
-
})}\n`);
|
|
219
|
+
writeFileSync(file, `${JSON.stringify(record)}\n`);
|
|
201
220
|
} catch (error) {
|
|
202
221
|
logger.debug(`Could not record the registered commands: ${String(error)}`);
|
|
203
222
|
}
|
|
204
223
|
}
|
|
224
|
+
/** Forgets what development last sent to a scope, once that scope is written some other way. */ function forgetRecord(file, logger) {
|
|
225
|
+
if (!file || !existsSync(file)) return;
|
|
226
|
+
try {
|
|
227
|
+
rmSync(file);
|
|
228
|
+
} catch (error) {
|
|
229
|
+
logger.debug(`Could not forget the registered commands: ${String(error)}`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Each command's `id:version`, sorted, from what Discord lists or answers a bulk update with; `undefined` for anything
|
|
234
|
+
* else. Discord gives a command a new version whenever it is updated, so the set changes with any edit, removal or
|
|
235
|
+
* re-registration, from this checkout or any other.
|
|
236
|
+
*/ function versionsOf(commands) {
|
|
237
|
+
if (!Array.isArray(commands)) return undefined;
|
|
238
|
+
const versions = commands.map((command)=>{
|
|
239
|
+
const { id, version } = command ?? {};
|
|
240
|
+
return typeof id === 'string' && typeof version === 'string' ? `${id}:${version}` : undefined;
|
|
241
|
+
});
|
|
242
|
+
return versions.every((version)=>version !== undefined) ? versions.sort() : undefined;
|
|
243
|
+
}
|
|
244
|
+
/** Whether Discord still holds a scope as development last sent it, by each command's version; a failed listing doesn't. */ async function stillAsSent(rest, route, record, scope, logger) {
|
|
245
|
+
if (!record.versions) return false;
|
|
246
|
+
let registered;
|
|
247
|
+
try {
|
|
248
|
+
registered = await rest.get(route);
|
|
249
|
+
} catch (error) {
|
|
250
|
+
logger.debug(`Could not list the commands registered ${describeScope(scope)}, so they are sent again: ${String(error)}`);
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
return versionsOf(registered)?.join('\n') === record.versions.join('\n');
|
|
254
|
+
}
|
|
205
255
|
function logRegistered(target, logger) {
|
|
206
256
|
const table = new CliTable3({
|
|
207
257
|
head: [
|
|
@@ -224,22 +274,29 @@ function logRegistered(target, logger) {
|
|
|
224
274
|
};
|
|
225
275
|
for (const { name, body } of target.commands){
|
|
226
276
|
const options = Array.isArray(body.options) ? body.options : [];
|
|
277
|
+
// A subcommand or a subcommand group, not a plain option
|
|
278
|
+
const subcommands = options.filter((option)=>option.type === 1 || option.type === 2);
|
|
227
279
|
table.push([
|
|
228
280
|
name,
|
|
229
281
|
typeNames[typeof body.type === 'number' ? body.type : 1] ?? 'Command',
|
|
230
|
-
|
|
282
|
+
subcommands.map((option)=>option.name).join(', ')
|
|
231
283
|
]);
|
|
232
284
|
}
|
|
233
285
|
logger.log(`Registered ${target.commands.length} bot commands ${describeScope(target.scope)}:\n${table.toString()}`);
|
|
234
286
|
}
|
|
235
287
|
/**
|
|
236
|
-
* Registers the commands, one bulk update per scope; development skips a scope unchanged since its last send
|
|
237
|
-
* `force`. Once one is sent, and not to `onlyGuild`, the other scopes the configuration names are checked for
|
|
288
|
+
* Registers the commands, one bulk update per scope; development skips a scope unchanged since its last send and still
|
|
289
|
+
* registered as sent, which one listing per skipped scope checks by each command's version, unless `force`. A blank `onlyGuild` registers nothing. Once one is sent, and not to `onlyGuild`, the other scopes the configuration names are checked for
|
|
238
290
|
* leftovers, which `clearOther` removes; a development-guild run or a `commands.guilds` with no id only warns. Never
|
|
239
291
|
* throws.
|
|
240
292
|
* @returns Whether every scope was sent or unchanged, and `commands.guilds`, if set, lists a guild id.
|
|
241
293
|
*/ async function registerCommands(options) {
|
|
242
294
|
const { rest, applicationId, controllerClasses, logger, config, development, onlyGuild, force = false } = options;
|
|
295
|
+
// An unset variable passed as the guild; registering to the default scopes instead could publish everywhere
|
|
296
|
+
if (onlyGuild !== undefined && !onlyGuild.trim()) {
|
|
297
|
+
logger.error(BLANK_GUILD);
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
243
300
|
const commands = collectCommands(controllerClasses, logger);
|
|
244
301
|
if (!commands) return false;
|
|
245
302
|
const targets = planTargets(commands, {
|
|
@@ -256,18 +313,26 @@ function logRegistered(target, logger) {
|
|
|
256
313
|
let succeeded = !guildsEmpty;
|
|
257
314
|
let sent = false;
|
|
258
315
|
for (const target of targets){
|
|
259
|
-
const file =
|
|
316
|
+
const file = cacheFile(applicationId, target.scope);
|
|
260
317
|
const hash = hashOf(target.commands);
|
|
261
|
-
|
|
318
|
+
const route = routeFor(applicationId, target.scope);
|
|
319
|
+
// Unchanged since development last sent it, and still what Discord holds there
|
|
320
|
+
const record = development && !force ? readRecord(file, logger) : undefined;
|
|
321
|
+
if (record?.hash === hash && await stillAsSent(rest, route, record, target.scope, logger)) {
|
|
262
322
|
logger.log(`Commands ${describeScope(target.scope)} are unchanged; not registering them again.`);
|
|
263
323
|
continue;
|
|
264
324
|
}
|
|
265
325
|
try {
|
|
266
|
-
await rest.put(
|
|
326
|
+
const answered = await rest.put(route, {
|
|
267
327
|
body: target.commands.map(({ body })=>body)
|
|
268
328
|
});
|
|
269
329
|
sent = true;
|
|
270
|
-
|
|
330
|
+
// Outside development the record would be stale, so the next development start sends again
|
|
331
|
+
if (development) writeRecord(file, {
|
|
332
|
+
hash,
|
|
333
|
+
versions: versionsOf(answered)
|
|
334
|
+
}, logger);
|
|
335
|
+
else forgetRecord(file, logger);
|
|
271
336
|
logRegistered(target, logger);
|
|
272
337
|
} catch (error) {
|
|
273
338
|
succeeded = false;
|
|
@@ -296,13 +361,17 @@ function logRegistered(target, logger) {
|
|
|
296
361
|
if (!Array.isArray(existing) || existing.length === 0) continue;
|
|
297
362
|
const names = existing.map(({ name })=>name).join(', ');
|
|
298
363
|
if (!config?.clearOther || keptBecause) {
|
|
299
|
-
|
|
364
|
+
// Advising clearOther when it would not apply sends the reader to a setting that changes nothing
|
|
365
|
+
const advice = !keptBecause ? 'Set commands.clearOther to remove them.' : config?.clearOther ? `clearOther is on, but they are not removed ${keptBecause}.` : `They are not removed ${keptBecause}, even with commands.clearOther.`;
|
|
366
|
+
logger.warn(`${existing.length} command(s) are still registered ${describeScope(scope)} (${names}), which this ` + `configuration does not register to, so Discord keeps showing them there. ${advice}`);
|
|
300
367
|
continue;
|
|
301
368
|
}
|
|
302
369
|
try {
|
|
303
370
|
await rest.put(routeFor(applicationId, scope), {
|
|
304
371
|
body: []
|
|
305
372
|
});
|
|
373
|
+
// What development last sent there is gone, so its next start sends again
|
|
374
|
+
forgetRecord(cacheFile(applicationId, scope), logger);
|
|
306
375
|
logger.log(`Removed ${existing.length} command(s) left registered ${describeScope(scope)}: ${names}`);
|
|
307
376
|
} catch (error) {
|
|
308
377
|
logger.error(`Error removing the commands left registered ${describeScope(scope)}:`, error);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getCommandMap } from '../decorator/controller.decorator.js';
|
|
1
|
+
import { commandsNamed, getCommandMap } from '../decorator/controller.decorator.js';
|
|
2
2
|
import { resolveCommandPaths, matchesHandler } from '../util/interaction.util.js';
|
|
3
3
|
|
|
4
4
|
/** The keys a command is routed by, most specific first: a chat command's full path, then its name; none for anything else. */ function commandRouteKeys(interaction) {
|
|
@@ -15,7 +15,7 @@ import { resolveCommandPaths, matchesHandler } from '../util/interaction.util.js
|
|
|
15
15
|
*/ function matchCommandRoute(controllers, interaction) {
|
|
16
16
|
for (const key of commandRouteKeys(interaction)){
|
|
17
17
|
for (const controllerClass of controllers){
|
|
18
|
-
const meta = getCommandMap(controllerClass.prototype)
|
|
18
|
+
const meta = commandsNamed(getCommandMap(controllerClass.prototype), key).find((each)=>matchesHandler(each, interaction));
|
|
19
19
|
if (meta) return {
|
|
20
20
|
controllerClass,
|
|
21
21
|
meta
|
|
@@ -222,19 +222,37 @@ const logger = new Logger('Cooldown');
|
|
|
222
222
|
const value = by ? await by(context, params) : undefined;
|
|
223
223
|
// Encoded, so a value holding a colon cannot count under another value's key. Without a value, `:by` alone keeps
|
|
224
224
|
// the call apart from a cooldown with no `by` over the same window.
|
|
225
|
-
const suffix = !by ? '' : value === undefined ? ':by' : `:by:${
|
|
225
|
+
const suffix = !by ? '' : value === undefined ? ':by' : `:by:${encodeKeyPart(String(value))}`;
|
|
226
|
+
const scope = scopeId(per, first);
|
|
227
|
+
// The caller, for a notice of a refusal under a scope that counts more than one caller
|
|
228
|
+
const user = scope.startsWith('user:') ? undefined : caller(first).user;
|
|
226
229
|
counted.push({
|
|
227
|
-
key: `${controller.name}.${methodName}#${id}:${per}:${
|
|
230
|
+
key: `${controller.name}.${methodName}#${id}:${per}:${scope}${suffix}`,
|
|
228
231
|
windowMs,
|
|
229
232
|
uses,
|
|
230
|
-
per
|
|
233
|
+
per,
|
|
234
|
+
user
|
|
231
235
|
});
|
|
232
236
|
}
|
|
233
237
|
return counted;
|
|
234
238
|
}
|
|
239
|
+
// A surrogate with no partner beside it, which encodeURIComponent refuses
|
|
240
|
+
const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
|
|
235
241
|
/**
|
|
236
|
-
*
|
|
237
|
-
*
|
|
242
|
+
* A value as part of a store key: each well-formed run as `encodeURIComponent` writes it, and each lone surrogate as `%u`
|
|
243
|
+
* and its four hex digits, which no other value encodes to, since `encodeURIComponent` writes `%` only before two.
|
|
244
|
+
*/ function encodeKeyPart(value) {
|
|
245
|
+
let encoded = '';
|
|
246
|
+
let from = 0;
|
|
247
|
+
for (const { 0: surrogate, index } of value.matchAll(LONE_SURROGATE)){
|
|
248
|
+
encoded += `${encodeURIComponent(value.slice(from, index))}%u${surrogate.charCodeAt(0).toString(16).toUpperCase()}`;
|
|
249
|
+
from = index + 1;
|
|
250
|
+
}
|
|
251
|
+
return encoded + encodeURIComponent(value.slice(from));
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* The store key and window of the cooldown each refusal came from, when its wait ends by the store's clock if the
|
|
255
|
+
* store said, and the refused caller when the key counts more than one, for {@link claimCooldownNotice}.
|
|
238
256
|
*/ const refusalKeys = new WeakMap();
|
|
239
257
|
/** Asks the store about the call under the app's policy, and throws what the answer means for it. */ async function ask(container, counted, peek, call) {
|
|
240
258
|
const store = cooldownStoreOf(container);
|
|
@@ -267,7 +285,8 @@ const logger = new Logger('Cooldown');
|
|
|
267
285
|
refusalKeys.set(refusal, {
|
|
268
286
|
key: blocking.key,
|
|
269
287
|
windowMs: blocking.windowMs,
|
|
270
|
-
endsAt: verdict.retryTimestamp
|
|
288
|
+
endsAt: verdict.retryTimestamp,
|
|
289
|
+
user: blocking.user
|
|
271
290
|
});
|
|
272
291
|
throw refusal;
|
|
273
292
|
}
|
|
@@ -290,16 +309,18 @@ const logger = new Logger('Cooldown');
|
|
|
290
309
|
// retry landing a moment late is refused on the earlier, which its wait holds, before taking the later, which a wait
|
|
291
310
|
// ending then needs for its own notice.
|
|
292
311
|
const bucket = Math.floor(refusal.retryAt.getTime() / NOTICE_BUCKET_MS);
|
|
312
|
+
// A key counting a server, a channel or everyone is shared by every caller it refuses, each told once
|
|
313
|
+
const key = refused.user === undefined ? refused.key : `${refused.key}:user:${refused.user}`;
|
|
293
314
|
const notices = refused.endsAt !== undefined ? [
|
|
294
315
|
{
|
|
295
|
-
key: `${
|
|
316
|
+
key: `${key}:notice:at:${refused.endsAt}`,
|
|
296
317
|
limit
|
|
297
318
|
}
|
|
298
319
|
] : [
|
|
299
320
|
bucket - 1,
|
|
300
321
|
bucket
|
|
301
322
|
].map((at)=>({
|
|
302
|
-
key: `${
|
|
323
|
+
key: `${key}:notice:${at}`,
|
|
303
324
|
limit
|
|
304
325
|
}));
|
|
305
326
|
try {
|
|
@@ -241,10 +241,7 @@ import { existingResponse } from '../common/response/response-state.js';
|
|
|
241
241
|
* says whether the handler ran. Its guard wrappers let this call through, so each guard runs once.
|
|
242
242
|
*/ async invokeHandler(instance, methodName, args, call, hooks = {}) {
|
|
243
243
|
const handler = `${instance.constructor.name}.${methodName}`;
|
|
244
|
-
const onUnanswered = this.options.warnUnanswered ? (
|
|
245
|
-
name: returnedBy.interceptor.name,
|
|
246
|
-
handlerStarted: returnedBy.handlerStarted
|
|
247
|
-
}) : undefined;
|
|
244
|
+
const onUnanswered = this.options.warnUnanswered ? (unanswered)=>this.warnUnansweredOnce(handler, unanswered) : undefined;
|
|
248
245
|
const outcome = await runHandler(this.container, instance, methodName, args, {
|
|
249
246
|
...this.runOptions(call),
|
|
250
247
|
onUnanswered,
|
|
@@ -254,13 +251,17 @@ import { existingResponse } from '../common/response/response-state.js';
|
|
|
254
251
|
return outcome.ran;
|
|
255
252
|
}
|
|
256
253
|
/**
|
|
257
|
-
* Warns, once per handler, that it left its interaction unanswered or deferred without a follow-up,
|
|
258
|
-
*
|
|
259
|
-
*/ warnUnansweredOnce(handler, phase,
|
|
254
|
+
* Warns, once per handler, that it left its interaction or autocomplete unanswered or deferred without a follow-up,
|
|
255
|
+
* or that an interceptor returned before the handler ran or finished, and the call ended so.
|
|
256
|
+
*/ warnUnansweredOnce(handler, { type, phase, returnedBy }) {
|
|
260
257
|
if (this.warnedUnanswered.has(handler)) return;
|
|
261
258
|
this.warnedUnanswered.add(handler);
|
|
259
|
+
const interceptor = returnedBy && {
|
|
260
|
+
name: returnedBy.interceptor.name,
|
|
261
|
+
handlerStarted: returnedBy.handlerStarted
|
|
262
|
+
};
|
|
262
263
|
const returned = interceptor && `${handler}: its interceptor ${interceptor.name} returned before the handler ${interceptor.handlerStarted ? 'finished' : 'ran'}`;
|
|
263
|
-
const what = interceptor ? phase === 'unanswered' ? `${returned}, without answering the interaction, so the user saw "The application did not respond". Answer it ` + `in ${interceptor.name}, or await next.handle().` : interceptor.handlerStarted ? `${returned}, and the deferred interaction had no follow-up when the call ended, so @Defer's lock was ` + `released while the user still saw it thinking. Follow up in ${interceptor.name} with respond(interaction).send(), ` + 'or await next.handle().' : `${returned}, and the deferred interaction was never followed up, so the user saw it thinking until Discord ` + `gave up. Follow up in ${interceptor.name} with respond(interaction).send(), or await next.handle().` : phase === 'unanswered' ? `${handler} finished without answering its interaction, so the user saw "The application did not respond". ` + 'Answer it with respond(interaction).send(), or acknowledge it first with @Defer().' : `${handler} deferred its interaction and never followed up, so the user saw it thinking until Discord gave up. ` + 'Follow up with respond(interaction).send().';
|
|
264
|
+
const what = type === 'autocomplete' ? `${handler} finished without answering its autocomplete, so the user saw its options fail to load. Answer it ` + 'with interaction.respond(choices) within 3 seconds.' : interceptor ? phase === 'unanswered' ? `${returned}, without answering the interaction, so the user saw "The application did not respond". Answer it ` + `in ${interceptor.name}, or await next.handle().` : interceptor.handlerStarted ? `${returned}, and the deferred interaction had no follow-up when the call ended, so @Defer's lock was ` + `released while the user still saw it thinking. Follow up in ${interceptor.name} with respond(interaction).send(), ` + 'or await next.handle().' : `${returned}, and the deferred interaction was never followed up, so the user saw it thinking until Discord ` + `gave up. Follow up in ${interceptor.name} with respond(interaction).send(), or await next.handle().` : phase === 'unanswered' ? `${handler} finished without answering its interaction, so the user saw "The application did not respond". ` + 'Answer it with respond(interaction).send(), or acknowledge it first with @Defer().' : `${handler} deferred its interaction and never followed up, so the user saw it thinking until Discord gave up. ` + 'Follow up with respond(interaction).send().';
|
|
264
265
|
this.logger.warn(`${what} Shown once per handler; @MeoCord({ warnUnanswered: false }) turns it off.`);
|
|
265
266
|
}
|
|
266
267
|
/**
|
|
@@ -7,7 +7,7 @@ import { themeForInteraction, useTheme } from './theme-scope.js';
|
|
|
7
7
|
import { errorText } from '../common/translate-error.js';
|
|
8
8
|
import { interactionLocale, translatorOfClient, messageLocale, renderText } from '../common/meocord-text.js';
|
|
9
9
|
import { logFailedSend } from '../common/response/send-failure.js';
|
|
10
|
-
import { presenterFor, withSendableFiles, DEFAULT_ATTACHMENT_SIZE_LIMIT, attachmentsOf, renderEmbed, withoutFiles, isTooLarge, REFUSED_AS_TOO_LARGE } from '../common/response/presenter.js';
|
|
10
|
+
import { presenterFor, fitText, TEXT_LIMITS, withSendableFiles, DEFAULT_ATTACHMENT_SIZE_LIMIT, attachmentsOf, renderEmbed, withoutFiles, isTooLarge, REFUSED_AS_TOO_LARGE } from '../common/response/presenter.js';
|
|
11
11
|
import { escapeForLog, quoteForLog } from '../util/user-text.util.js';
|
|
12
12
|
|
|
13
13
|
const UNKNOWN_INTERACTION = 10062;
|
|
@@ -79,7 +79,10 @@ function errorCode(error) {
|
|
|
79
79
|
return send(reply.withoutFiles);
|
|
80
80
|
}
|
|
81
81
|
}
|
|
82
|
-
/** The text of a reply to a message, after the call's `emojis.warning` when `withEmoji
|
|
82
|
+
/** The text of a reply to a message, after the call's `emojis.warning` when `withEmoji`, cut to fit a message's content. */ function replyText(text, withEmoji) {
|
|
83
|
+
const emoji = withEmoji ? `${useTheme().emojis.warning} ` : '';
|
|
84
|
+
return emoji + fitText(text, TEXT_LIMITS.content - emoji.length);
|
|
85
|
+
}
|
|
83
86
|
/**
|
|
84
87
|
* `text`, answering `error` for `message`: plain, after the call's `emojis.warning` when `withEmoji`, unless the
|
|
85
88
|
* presenter's `messageError` draws it as an embed, coloured as an interaction's view is, with that emoji when it has
|
|
@@ -87,28 +90,33 @@ function errorCode(error) {
|
|
|
87
90
|
* the failure comes back with it, for the caller to report once sent.
|
|
88
91
|
*/ async function presentedReply(message, error, text, withEmoji, logger) {
|
|
89
92
|
const presenter = presenterFor(message.client);
|
|
93
|
+
const translator = translatorOfClient(message.client);
|
|
94
|
+
const locale = messageLocale(message) ?? translator?.defaultLocale ?? 'en-US';
|
|
95
|
+
// An empty or whitespace-only message, which Discord refuses, reads as MeoCord's generic error text, so neither the plain reply nor a presenter is left without one
|
|
96
|
+
const shown = text.trim() === '' ? renderText(translator, locale, {
|
|
97
|
+
key: 'meocord.fallback.error'
|
|
98
|
+
}) : text;
|
|
90
99
|
const plain = {
|
|
91
100
|
body: {
|
|
92
|
-
content: replyText(
|
|
101
|
+
content: replyText(shown, withEmoji)
|
|
93
102
|
}
|
|
94
103
|
};
|
|
95
104
|
const { messageError } = presenter;
|
|
96
105
|
if (!messageError) return plain;
|
|
97
106
|
try {
|
|
98
107
|
const theme = await themeForInteraction(message);
|
|
99
|
-
const translator = translatorOfClient(message.client);
|
|
100
|
-
const locale = messageLocale(message) ?? translator?.defaultLocale ?? 'en-US';
|
|
101
108
|
const tone = isUserOutcome(error, message) ? 'warning' : 'danger';
|
|
109
|
+
const presented = {
|
|
110
|
+
message: fitText(shown, TEXT_LIMITS.embed),
|
|
111
|
+
error,
|
|
112
|
+
tone
|
|
113
|
+
};
|
|
102
114
|
const drawn = await messageError.call(presenter, {
|
|
103
115
|
message,
|
|
104
116
|
locale,
|
|
105
117
|
mode: 'embed',
|
|
106
118
|
theme
|
|
107
|
-
},
|
|
108
|
-
message: text,
|
|
109
|
-
error,
|
|
110
|
-
tone
|
|
111
|
-
});
|
|
119
|
+
}, presented);
|
|
112
120
|
let view = drawn.color === undefined ? {
|
|
113
121
|
...drawn,
|
|
114
122
|
color: theme.colors.primary
|
|
@@ -165,7 +173,8 @@ async function replyToMessage(message, error, logger, withEmoji, answering) {
|
|
|
165
173
|
await sendReply(reply, (body)=>message.reply({
|
|
166
174
|
...body,
|
|
167
175
|
allowedMentions: {
|
|
168
|
-
repliedUser: false
|
|
176
|
+
repliedUser: false,
|
|
177
|
+
parse: []
|
|
169
178
|
}
|
|
170
179
|
}));
|
|
171
180
|
} catch (replyError) {
|
|
@@ -2,7 +2,7 @@ import 'reflect-metadata';
|
|
|
2
2
|
import { perHandler, sourcePrototype, stageClasses } from './guard-runner.js';
|
|
3
3
|
import { bindShared } from './interceptor-runner.js';
|
|
4
4
|
import { startupError } from '../util/refusal.util.js';
|
|
5
|
-
import {
|
|
5
|
+
import { isConstructible } from '../util/value.util.js';
|
|
6
6
|
import { META } from '../util/metadata-keys.js';
|
|
7
7
|
|
|
8
8
|
/** The filter class an entry names. */ function filterClass(entry) {
|
|
@@ -51,7 +51,7 @@ import { META } from '../util/metadata-keys.js';
|
|
|
51
51
|
// Every filter reaching here was checked for @Catch at startup, so its types are always recorded
|
|
52
52
|
const types = Reflect.getOwnMetadata(META.catchTypes, filterClass(entry));
|
|
53
53
|
// Only classes match: anything else given to @Catch, warned of as it applied, matches no error
|
|
54
|
-
if (types.length === 0 || types.some((type)=>
|
|
54
|
+
if (types.length === 0 || types.some((type)=>isConstructible(type) && error instanceof type)) return entry;
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
57
|
return undefined;
|
|
@@ -338,14 +338,27 @@ const wrapperCount = perHandler((prototype, methodName)=>{
|
|
|
338
338
|
else marks.delete(methodName);
|
|
339
339
|
return true;
|
|
340
340
|
}
|
|
341
|
+
/** The container that made each instance, so a direct call runs in its own app or testing module. */ const instanceContainers = new WeakMap();
|
|
342
|
+
/**
|
|
343
|
+
* Records each instance `container` makes of `classes` as its own. Two testing modules made from one class each run a
|
|
344
|
+
* direct call on their own instance in themselves, where the class's metadata names only the last.
|
|
345
|
+
*/ function recordInstanceContainers(container, classes) {
|
|
346
|
+
for (const cls of classes){
|
|
347
|
+
container.onActivation(cls, (_context, instance)=>{
|
|
348
|
+
if (typeof instance === 'object' && instance !== null) instanceContainers.set(instance, container);
|
|
349
|
+
return instance;
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
}
|
|
341
353
|
/**
|
|
342
354
|
* Runs a direct call to a guarded handler: the handler's guards, as dispatch resolves them, then the
|
|
343
355
|
* call with a pass for each wrapper inside the one entered, so no guard runs twice.
|
|
344
356
|
*/ async function runDirectCall(instance, methodName, args, call) {
|
|
345
357
|
const prototype = Object.getPrototypeOf(instance);
|
|
346
358
|
const controller = instance.constructor;
|
|
347
|
-
// The app that made the class
|
|
348
|
-
|
|
359
|
+
// The container that made the instance, or else the app that made the class or a class it extends: a subclass the
|
|
360
|
+
// app's code makes runs as its base does
|
|
361
|
+
const container = instanceContainers.get(instance) ?? Reflect.getMetadata(META.container, controller);
|
|
349
362
|
if (!container) {
|
|
350
363
|
throw new Error(`${controller.name}.${methodName}: @UseGuard runs its guards through the app that made ${controller.name}, and this ` + `instance was made outside one. Inject ${controller.name}, or call it on an instance the app made.`);
|
|
351
364
|
}
|
|
@@ -384,4 +397,4 @@ const wrapperCount = perHandler((prototype, methodName)=>{
|
|
|
384
397
|
}
|
|
385
398
|
}
|
|
386
399
|
|
|
387
|
-
export { callGuardedHandler, classChain, classLevelGuards, consumeDispatchMark, declaringPrototype, handlerGuards, handlerMethods, injectedTokens, isGuardWithParams, perHandler, runDirectCall, runGuards, singletonContextError, sourcePrototype, stageClasses, undecoratedConstructor, untypedParameter };
|
|
400
|
+
export { callGuardedHandler, classChain, classLevelGuards, consumeDispatchMark, declaringPrototype, handlerGuards, handlerMethods, injectedTokens, isGuardWithParams, perHandler, recordInstanceContainers, runDirectCall, runGuards, singletonContextError, sourcePrototype, stageClasses, undecoratedConstructor, untypedParameter };
|
|
@@ -2,6 +2,7 @@ import { ModalSubmitInteraction, ComponentType, StringSelectMenuInteraction, Use
|
|
|
2
2
|
import { getCommandMap } from '../decorator/controller.decorator.js';
|
|
3
3
|
import { matchComponentRoute } from './component-routes.js';
|
|
4
4
|
import { hasCustomId, isCustomIdRouted, matchesHandler, resolveOptionParams, matchesCommandType } from '../util/interaction.util.js';
|
|
5
|
+
import { setOwn } from '../util/value.util.js';
|
|
5
6
|
import { commandRouteKeys, matchCommandRoute } from './command-routes.js';
|
|
6
7
|
|
|
7
8
|
/** The entries of a discord.js Collection, or none for anything else, such as a test double's stub. */ const entriesOf = (collection)=>collection instanceof Map ? [
|
|
@@ -18,8 +19,8 @@ import { commandRouteKeys, matchCommandRoute } from './command-routes.js';
|
|
|
18
19
|
for (const [customId, field] of fields){
|
|
19
20
|
const data = field;
|
|
20
21
|
// An upload's `values` are the attachments' ids; discord.js resolves the attachments themselves alongside
|
|
21
|
-
if (data.type === ComponentType.FileUpload) values
|
|
22
|
-
else values
|
|
22
|
+
if (data.type === ComponentType.FileUpload) setOwn(values, customId, entriesOf(interaction.fields.getUploadedFiles(customId)));
|
|
23
|
+
else setOwn(values, customId, 'value' in data ? data.value : data.values);
|
|
23
24
|
}
|
|
24
25
|
return values;
|
|
25
26
|
}
|
|
@@ -78,7 +79,7 @@ import { commandRouteKeys, matchCommandRoute } from './command-routes.js';
|
|
|
78
79
|
collisions: []
|
|
79
80
|
};
|
|
80
81
|
const fields = interaction instanceof ModalSubmitInteraction ? modalFields(interaction) : selectChoices(interaction);
|
|
81
|
-
const collisions = Object.keys(routeParams).filter((name)=>name
|
|
82
|
+
const collisions = Object.keys(routeParams).filter((name)=>Object.hasOwn(fields, name));
|
|
82
83
|
return {
|
|
83
84
|
params: {
|
|
84
85
|
...fields,
|
|
@@ -343,7 +343,9 @@ const logger = new Logger('ExceptionFilter');
|
|
|
343
343
|
denial
|
|
344
344
|
})) {
|
|
345
345
|
outcome = 'denied';
|
|
346
|
-
|
|
346
|
+
// Without @Defer too, so a denied component is acknowledged rather than left to fail
|
|
347
|
+
const denied = response ?? (type === 'interaction' && first?.isRepliable?.() ? responseOf(first) : undefined);
|
|
348
|
+
await denied?.abandon();
|
|
347
349
|
return {
|
|
348
350
|
ran: false
|
|
349
351
|
};
|
|
@@ -399,14 +401,18 @@ const logger = new Logger('ExceptionFilter');
|
|
|
399
401
|
};
|
|
400
402
|
} finally{
|
|
401
403
|
await response?.release();
|
|
402
|
-
if (options.onUnanswered && (ran || returnedEarly) && outcome === 'ran' && type === 'interaction') {
|
|
404
|
+
if (options.onUnanswered && (ran || returnedEarly) && outcome === 'ran' && (type === 'interaction' || type === 'autocomplete')) {
|
|
403
405
|
const phase = responsePhaseOf(contextOf());
|
|
404
406
|
// The handler is to blame only once it has finished; until then, the interceptor that ended the call is
|
|
405
|
-
const
|
|
407
|
+
const returnedBy = !finished && returnedEarly ? {
|
|
406
408
|
interceptor: returnedEarly,
|
|
407
409
|
handlerStarted: ran
|
|
408
410
|
} : undefined;
|
|
409
|
-
if (phase === 'unanswered' || phase === 'deferred') options.onUnanswered(
|
|
411
|
+
if (phase === 'unanswered' || phase === 'deferred') options.onUnanswered({
|
|
412
|
+
type,
|
|
413
|
+
phase,
|
|
414
|
+
returnedBy
|
|
415
|
+
});
|
|
410
416
|
}
|
|
411
417
|
if (options.awaitObservers) await starting;
|
|
412
418
|
// After the answer and the release, so the duration covers the whole call
|