chapterjs 0.3.0 → 0.5.0
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/README.md +5 -3
- package/dist/assets/public.js +4 -4
- package/dist/cli/build.d.ts +6 -2
- package/dist/cli/build.js +17 -4
- package/dist/cli/dev.js +37 -6
- package/dist/cli/env.d.ts +5 -0
- package/dist/cli/env.js +39 -3
- package/dist/cli/project.d.ts +61 -9
- package/dist/cli/project.js +250 -44
- package/dist/cli/redis.d.ts +23 -0
- package/dist/cli/redis.js +69 -0
- package/dist/cli/start.js +54 -18
- package/dist/cli/test.js +112 -18
- package/dist/commands/command.d.ts +140 -5
- package/dist/commands/command.js +17 -2
- package/dist/commands/declaration.d.ts +17 -4
- package/dist/commands/declaration.js +128 -2
- package/dist/commands/router.js +78 -18
- package/dist/commands/tree.d.ts +25 -4
- package/dist/commands/tree.js +95 -26
- package/dist/commands/twins.d.ts +8 -0
- package/dist/commands/twins.js +24 -6
- package/dist/components/button.d.ts +8 -3
- package/dist/components/declaration.d.ts +2 -2
- package/dist/components/declaration.js +16 -12
- package/dist/components/render.js +5 -0
- package/dist/components/router.js +41 -0
- package/dist/components/select.d.ts +12 -5
- package/dist/core/bot.d.ts +7 -0
- package/dist/core/bot.js +44 -0
- package/dist/discord/api.d.ts +2 -0
- package/dist/discord/api.js +2 -0
- package/dist/discord/permissions.d.ts +4 -4
- package/dist/discord/permissions.js +13 -0
- package/dist/events/registry.d.ts +265 -243
- package/dist/events/registry.js +131 -100
- package/dist/index.d.ts +7 -4
- package/dist/index.js +2 -1
- package/dist/interactions/dispatch.js +4 -2
- package/dist/loader/loader.d.ts +16 -0
- package/dist/loader/loader.js +52 -7
- package/dist/loader/usage.d.ts +15 -0
- package/dist/loader/usage.js +40 -0
- package/dist/loader/watch.d.ts +5 -3
- package/dist/loader/watch.js +88 -17
- package/dist/messages/commands.js +3 -0
- package/dist/messages/messages.d.ts +1 -0
- package/dist/messages/translate.js +5 -1
- package/dist/presence/declaration.d.ts +49 -2
- package/dist/presence/declaration.js +122 -39
- package/dist/presence/presence.d.ts +65 -6
- package/dist/presence/presence.js +11 -0
- package/dist/presence/rotation.d.ts +32 -0
- package/dist/presence/rotation.js +167 -0
- package/dist/ranking/declaration.d.ts +12 -0
- package/dist/ranking/declaration.js +48 -0
- package/dist/ranking/ranking.d.ts +102 -0
- package/dist/ranking/ranking.js +129 -0
- package/dist/redis/client.d.ts +36 -0
- package/dist/redis/client.js +252 -0
- package/dist/redis/protocol.d.ts +22 -0
- package/dist/redis/protocol.js +99 -0
- package/dist/redis/url.d.ts +24 -0
- package/dist/redis/url.js +50 -0
- package/dist/rest/errors.d.ts +0 -1
- package/dist/rest/errors.js +15 -1
- package/dist/rest/rest.js +10 -1
- package/dist/store/backend.d.ts +49 -4
- package/dist/store/backend.js +66 -11
- package/dist/store/declaration.d.ts +3 -0
- package/dist/store/declaration.js +8 -3
- package/dist/store/file-backend.d.ts +3 -2
- package/dist/store/file-backend.js +47 -1
- package/dist/store/ipc-backend.d.ts +24 -1
- package/dist/store/ipc-backend.js +49 -0
- package/dist/store/memory-backend.d.ts +4 -2
- package/dist/store/memory-backend.js +27 -1
- package/dist/store/ranking-backend.d.ts +45 -0
- package/dist/store/ranking-backend.js +82 -0
- package/dist/store/redis-backend.d.ts +38 -0
- package/dist/store/redis-backend.js +245 -0
- package/dist/store/redis-ranking.d.ts +15 -0
- package/dist/store/redis-ranking.js +69 -0
- package/dist/store/scope.d.ts +17 -1
- package/dist/store/scope.js +32 -3
- package/dist/store/store.d.ts +39 -5
- package/dist/store/store.js +79 -32
- package/dist/structures/channel.d.ts +13 -0
- package/dist/structures/channel.js +24 -0
- package/dist/structures/context.d.ts +3 -0
- package/dist/structures/entities.js +1 -0
- package/dist/structures/interaction.d.ts +6 -0
- package/dist/structures/interaction.js +20 -0
- package/dist/structures/invite.d.ts +6 -0
- package/dist/structures/invite.js +11 -0
- package/dist/structures/message.d.ts +17 -0
- package/dist/structures/message.js +33 -0
- package/dist/structures/wait.d.ts +105 -0
- package/dist/structures/wait.js +85 -0
- package/dist/testing/harness.d.ts +48 -8
- package/dist/testing/harness.js +156 -36
- package/dist/testing/index.d.ts +1 -1
- package/dist/testing/smoke.js +112 -1
- package/dist/testing/world.js +51 -2
- package/dist/util/call-site.d.ts +4 -0
- package/dist/util/call-site.js +22 -0
- package/dist/voice/connection.d.ts +2 -0
- package/dist/voice/connection.js +15 -1
- package/dist/voice/usage.d.ts +3 -2
- package/dist/voice/usage.js +7 -19
- package/dist/waiting/usage.d.ts +5 -0
- package/dist/waiting/usage.js +19 -0
- package/dist/waiting/waiters.d.ts +48 -0
- package/dist/waiting/waiters.js +107 -0
- package/package.json +1 -1
package/dist/testing/harness.js
CHANGED
|
@@ -10,12 +10,17 @@ import { ComponentType, } from '../discord/types/component.js';
|
|
|
10
10
|
import { InteractionType, } from '../discord/types/interaction.js';
|
|
11
11
|
import { MessageFlags, MessageType, } from '../discord/types/message.js';
|
|
12
12
|
import { PermissionFlags } from '../discord/types/permissions.js';
|
|
13
|
-
import { commandName } from '../commands/tree.js';
|
|
13
|
+
import { capitalize, commandName, describeCommand, } from '../commands/tree.js';
|
|
14
14
|
import { componentStateOf } from '../components/declared.js';
|
|
15
15
|
import { isPiece, renderedOf, resolve, } from '../components/instance.js';
|
|
16
16
|
import { translatorOf } from '../messages/translate.js';
|
|
17
17
|
import { toCamelCase } from '../util/case.js';
|
|
18
18
|
import { expectThat, shown, TestFailure } from './failure.js';
|
|
19
|
+
/** The type Discord gives each kind of menu command. */
|
|
20
|
+
const MENU_COMMAND_TYPES = {
|
|
21
|
+
user: ApplicationCommandType.User,
|
|
22
|
+
message: ApplicationCommandType.Message,
|
|
23
|
+
};
|
|
19
24
|
/** A `Log` that keeps what is written, for the runner to show per check. */
|
|
20
25
|
export function captureLog() {
|
|
21
26
|
let current = { errors: [], warnings: [], infos: [] };
|
|
@@ -37,10 +42,27 @@ export function captureLog() {
|
|
|
37
42
|
/** Watches what the bot does with the files, to wait for it. */
|
|
38
43
|
export class Observer {
|
|
39
44
|
#pending = new Set();
|
|
45
|
+
/**
|
|
46
|
+
* The runs of the files waiting for what comes next: each keeps one
|
|
47
|
+
* promise pending that only the test can end (`bot.say()`...).
|
|
48
|
+
*/
|
|
49
|
+
#parked = new Set();
|
|
50
|
+
/** Resolves the next time a run starts or stops waiting. */
|
|
51
|
+
#parkChanged = () => { };
|
|
40
52
|
#interactions = new Map();
|
|
41
53
|
#interactionWaiters = new Map();
|
|
42
54
|
#eventWaiters = [];
|
|
43
55
|
observe = {
|
|
56
|
+
waits: {
|
|
57
|
+
parked: run => {
|
|
58
|
+
this.#parked.add(run);
|
|
59
|
+
this.#parkChanged();
|
|
60
|
+
},
|
|
61
|
+
resumed: run => {
|
|
62
|
+
this.#parked.delete(run);
|
|
63
|
+
this.#parkChanged();
|
|
64
|
+
},
|
|
65
|
+
},
|
|
44
66
|
interaction: (raw, done) => {
|
|
45
67
|
const settled = done.then(() => { }, () => { });
|
|
46
68
|
this.#track(settled);
|
|
@@ -67,34 +89,47 @@ export class Observer {
|
|
|
67
89
|
this.#pending.add(settled);
|
|
68
90
|
settled.finally(() => this.#pending.delete(settled));
|
|
69
91
|
}
|
|
70
|
-
/**
|
|
92
|
+
/**
|
|
93
|
+
* Forgets everything of the world before: a new world starts. What the
|
|
94
|
+
* files of the last one still run (a case given up on) is never waited
|
|
95
|
+
* for again.
|
|
96
|
+
*/
|
|
71
97
|
reset() {
|
|
72
98
|
this.#interactions.clear();
|
|
99
|
+
this.#pending.clear();
|
|
100
|
+
this.#parked.clear();
|
|
101
|
+
this.#interactionWaiters.clear();
|
|
102
|
+
this.#eventWaiters.length = 0;
|
|
73
103
|
}
|
|
74
|
-
/**
|
|
104
|
+
/**
|
|
105
|
+
* Resolves once the files were given the interaction `id`: `settle()`
|
|
106
|
+
* then waits for them to be done with it.
|
|
107
|
+
*/
|
|
75
108
|
interaction(id, timeout) {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
return known;
|
|
109
|
+
if (this.#interactions.has(id))
|
|
110
|
+
return Promise.resolve();
|
|
79
111
|
return new Promise((resolve, reject) => {
|
|
80
112
|
const timer = setTimeout(() => {
|
|
81
113
|
this.#interactionWaiters.delete(id);
|
|
82
114
|
reject(new TestFailure(`The bot did not receive the interaction within ${timeout / 1000} s.`));
|
|
83
115
|
}, timeout);
|
|
84
|
-
this.#interactionWaiters.set(id,
|
|
116
|
+
this.#interactionWaiters.set(id, () => {
|
|
85
117
|
clearTimeout(timer);
|
|
86
|
-
resolve(
|
|
118
|
+
resolve();
|
|
87
119
|
});
|
|
88
120
|
});
|
|
89
121
|
}
|
|
90
|
-
/**
|
|
122
|
+
/**
|
|
123
|
+
* Resolves once the files were given the next event that matches:
|
|
124
|
+
* `settle()` then waits for them to be done with it.
|
|
125
|
+
*/
|
|
91
126
|
event(match, timeout) {
|
|
92
127
|
return new Promise((resolve, reject) => {
|
|
93
128
|
const waiter = {
|
|
94
129
|
match,
|
|
95
|
-
resolve: (
|
|
130
|
+
resolve: () => {
|
|
96
131
|
clearTimeout(timer);
|
|
97
|
-
resolve(
|
|
132
|
+
resolve();
|
|
98
133
|
},
|
|
99
134
|
};
|
|
100
135
|
const timer = setTimeout(() => {
|
|
@@ -106,13 +141,22 @@ export class Observer {
|
|
|
106
141
|
this.#eventWaiters.push(waiter);
|
|
107
142
|
});
|
|
108
143
|
}
|
|
109
|
-
/**
|
|
144
|
+
/**
|
|
145
|
+
* Resolves once nothing the files started is still running, but what
|
|
146
|
+
* waits for the test to act (a run waiting for a message, a click...).
|
|
147
|
+
*/
|
|
110
148
|
async settle() {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
149
|
+
do {
|
|
150
|
+
while (this.#pending.size > this.#parked.size) {
|
|
151
|
+
const changed = new Promise(resolve => (this.#parkChanged = resolve));
|
|
152
|
+
// Looked at again as soon as one is done: the one left may be a
|
|
153
|
+
// run that waits, which only the test ends.
|
|
154
|
+
await Promise.race([...this.#pending, changed]);
|
|
155
|
+
}
|
|
156
|
+
// Answers sent at the very end have reached the world by now, and a
|
|
157
|
+
// wait given what just happened has started running again.
|
|
158
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
159
|
+
} while (this.#pending.size > this.#parked.size);
|
|
116
160
|
}
|
|
117
161
|
}
|
|
118
162
|
// ── The handles ──────────────────────────────────────────────────────
|
|
@@ -170,13 +214,24 @@ function collectComponents(components) {
|
|
|
170
214
|
walk(components);
|
|
171
215
|
return { buttons, menus };
|
|
172
216
|
}
|
|
173
|
-
|
|
217
|
+
/**
|
|
218
|
+
* The message a button or a menu was found on, kept out of sight: clicking
|
|
219
|
+
* it clicks that message, as a person would.
|
|
220
|
+
*/
|
|
221
|
+
const FOUND_ON = Symbol('found on');
|
|
222
|
+
/** The id of the message a handle was found on, if it was found on one. */
|
|
223
|
+
const foundOn = (target) => typeof target === 'object' && target !== null
|
|
224
|
+
? target[FOUND_ON]
|
|
225
|
+
: undefined;
|
|
226
|
+
/** Keeps the message a handle was found on, without showing it. */
|
|
227
|
+
const onMessage = (handle, messageId) => Object.freeze(Object.defineProperty(handle, FOUND_ON, { value: messageId }));
|
|
228
|
+
const buttonHandle = (raw, messageId) => onMessage({
|
|
174
229
|
label: raw.label ?? null,
|
|
175
230
|
style: BUTTON_STYLES[raw.style] ?? 'secondary',
|
|
176
231
|
disabled: raw.disabled === true,
|
|
177
232
|
customId: raw.custom_id ?? null,
|
|
178
|
-
});
|
|
179
|
-
const menuHandle = (raw) =>
|
|
233
|
+
}, messageId);
|
|
234
|
+
const menuHandle = (raw, messageId) => onMessage({
|
|
180
235
|
type: MENU_TYPES[raw.type] ?? 'string',
|
|
181
236
|
placeholder: raw.placeholder ?? null,
|
|
182
237
|
disabled: raw.disabled === true,
|
|
@@ -187,7 +242,7 @@ const menuHandle = (raw) => Object.freeze({
|
|
|
187
242
|
}))
|
|
188
243
|
: [],
|
|
189
244
|
customId: raw.custom_id,
|
|
190
|
-
});
|
|
245
|
+
}, messageId);
|
|
191
246
|
/** Checks a message or an answer; written once for both. */
|
|
192
247
|
function check(self, what, get) {
|
|
193
248
|
const present = () => {
|
|
@@ -442,8 +497,8 @@ export class Session {
|
|
|
442
497
|
return {
|
|
443
498
|
content: message.content,
|
|
444
499
|
embeds: toCamelCase(message.embeds),
|
|
445
|
-
buttons: buttons.map(buttonHandle),
|
|
446
|
-
menus: menus.map(menuHandle),
|
|
500
|
+
buttons: buttons.map(button => buttonHandle(button, raw.id)),
|
|
501
|
+
menus: menus.map(menu => menuHandle(menu, raw.id)),
|
|
447
502
|
ephemeral: ((message.flags ?? 0) & MessageFlags.Ephemeral) !== 0,
|
|
448
503
|
present: true,
|
|
449
504
|
};
|
|
@@ -563,9 +618,14 @@ export class Session {
|
|
|
563
618
|
opensForm(form) {
|
|
564
619
|
expectThat(record.modal !== null, `The bot should have opened a form, it ${record.original ? `answered ${shown(record.original.content)}` : 'opened none'}.`);
|
|
565
620
|
if (form) {
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
621
|
+
// Only the name is compared: the form itself names it, with no
|
|
622
|
+
// need for the data it was opened with.
|
|
623
|
+
const state = componentStateOf(form);
|
|
624
|
+
const expected = state?.kind === 'modal' && state.bound
|
|
625
|
+
? state.bound.name
|
|
626
|
+
: typeof form === 'object' && 'customId' in form
|
|
627
|
+
? form.customId
|
|
628
|
+
: self.#customIdOf(form, 'modal');
|
|
569
629
|
const opened = record.modal.custom_id.split(':')[0];
|
|
570
630
|
expectThat(opened === expected.split(':')[0], `The bot should have opened the form ${expected.split(':')[0]}, it opened ${opened}.`);
|
|
571
631
|
}
|
|
@@ -740,8 +800,11 @@ export class Session {
|
|
|
740
800
|
return entry;
|
|
741
801
|
}
|
|
742
802
|
async use(command, ...args) {
|
|
743
|
-
const [given = {}, where] = args;
|
|
744
803
|
const entry = this.#commandEntry(command);
|
|
804
|
+
if (entry.command.type !== 'slash') {
|
|
805
|
+
return this.#useMenu(entry, args[0], args[1]);
|
|
806
|
+
}
|
|
807
|
+
const [given = {}, where] = args;
|
|
745
808
|
const actor = this.#actor(where);
|
|
746
809
|
const channel = this.#place(where, actor, entry.command.where === 'dm');
|
|
747
810
|
const { options, resolved } = this.#commandOptions(entry, given, channel);
|
|
@@ -773,6 +836,54 @@ export class Session {
|
|
|
773
836
|
value: choice.value,
|
|
774
837
|
}));
|
|
775
838
|
}
|
|
839
|
+
/**
|
|
840
|
+
* Uses a menu command on a person or a message, as Discord's client
|
|
841
|
+
* does: on a message, in the channel of that message; on a person, where
|
|
842
|
+
* `where` says (`#general`, or the private conversation of a
|
|
843
|
+
* `where: 'dm'` command, by default).
|
|
844
|
+
*/
|
|
845
|
+
async #useMenu(entry, target, where) {
|
|
846
|
+
const { type, path, where: place } = entry.command;
|
|
847
|
+
const kind = MENU_COMMAND_TYPES[type];
|
|
848
|
+
const name = describeCommand(entry.command);
|
|
849
|
+
const on = target?.on;
|
|
850
|
+
const actor = this.#actor(where);
|
|
851
|
+
const resolved = {};
|
|
852
|
+
let channel;
|
|
853
|
+
let targetId;
|
|
854
|
+
if (type === 'message') {
|
|
855
|
+
const message = on && !this.#isMember(on) ? this.world.findMessage(on.id) : undefined;
|
|
856
|
+
if (!message) {
|
|
857
|
+
throw new TestFailure(on && !this.#isMember(on)
|
|
858
|
+
? `The message ${on.id} does not exist any more: ${name} can not be used on it.`
|
|
859
|
+
: `${capitalize(name)} is used on a message: bot.use(command, { on: message }), with a message of the test (await bot.say('...'), or one the bot sent).`);
|
|
860
|
+
}
|
|
861
|
+
const found = this.world.channels.get(message.channel_id);
|
|
862
|
+
if (!found) {
|
|
863
|
+
throw new TestFailure(`The channel of the message ${message.id} does not exist any more.`);
|
|
864
|
+
}
|
|
865
|
+
channel = found;
|
|
866
|
+
targetId = message.id;
|
|
867
|
+
(resolved.messages ??= {})[message.id] = message;
|
|
868
|
+
}
|
|
869
|
+
else {
|
|
870
|
+
if (!this.#isMember(on)) {
|
|
871
|
+
throw new TestFailure(`${capitalize(name)} is used on a person: bot.use(command, { on: bot.member('alice') }).`);
|
|
872
|
+
}
|
|
873
|
+
channel = this.#place(where, actor, place === 'dm');
|
|
874
|
+
targetId = this.#resolveMember(`${capitalize(name)}`, on, resolved, channel);
|
|
875
|
+
}
|
|
876
|
+
const registered = this.world.guildCommands.find(command => command.name === path[0] && command.type === kind);
|
|
877
|
+
const raw = this.#rawInteraction(InteractionType.ApplicationCommand, actor, channel, {
|
|
878
|
+
id: registered?.id ?? this.world.snowflake(),
|
|
879
|
+
name: path[0],
|
|
880
|
+
type: kind,
|
|
881
|
+
target_id: targetId,
|
|
882
|
+
resolved,
|
|
883
|
+
...(channel.guild_id ? { guild_id: channel.guild_id } : {}),
|
|
884
|
+
});
|
|
885
|
+
return this.#reply(await this.#interact(raw));
|
|
886
|
+
}
|
|
776
887
|
/** The `data` of a command interaction, subcommands nested as Discord does. */
|
|
777
888
|
#commandData(entry, options, resolved) {
|
|
778
889
|
const [name, ...rest] = entry.command.path;
|
|
@@ -788,7 +899,9 @@ export class Session {
|
|
|
788
899
|
},
|
|
789
900
|
];
|
|
790
901
|
}
|
|
791
|
-
const registered = this.world.guildCommands.find(command => command.name === name
|
|
902
|
+
const registered = this.world.guildCommands.find(command => command.name === name &&
|
|
903
|
+
(command.type ?? ApplicationCommandType.ChatInput) ===
|
|
904
|
+
ApplicationCommandType.ChatInput);
|
|
792
905
|
return {
|
|
793
906
|
id: registered?.id ?? this.world.snowflake(),
|
|
794
907
|
name: name,
|
|
@@ -991,8 +1104,13 @@ export class Session {
|
|
|
991
1104
|
return [...this.project.components.values()].find(entry => entry.component.kind === kind && entry.component.name === name);
|
|
992
1105
|
}
|
|
993
1106
|
/** The message a component is on: the one given, or one of the bot. */
|
|
994
|
-
#messageFor(where, actor, channel, component) {
|
|
1107
|
+
#messageFor(where, actor, channel, component, target) {
|
|
995
1108
|
const given = where?.on;
|
|
1109
|
+
// Found on a message: that message, as long as it is there.
|
|
1110
|
+
const found = given ? undefined : foundOn(target);
|
|
1111
|
+
const sent = found ? this.world.findMessage(found) : undefined;
|
|
1112
|
+
if (sent)
|
|
1113
|
+
return sent;
|
|
996
1114
|
if (given) {
|
|
997
1115
|
const id = 'message' in given ? given.message?.id : given.id;
|
|
998
1116
|
const found = id ? this.world.findMessage(id) : undefined;
|
|
@@ -1036,11 +1154,7 @@ export class Session {
|
|
|
1036
1154
|
const entry = this.#componentEntry('button', customId);
|
|
1037
1155
|
const actor = this.#actor(where);
|
|
1038
1156
|
const channel = this.#place(where, actor, entry?.component.kind === 'button' && entry.component.where === 'dm');
|
|
1039
|
-
const message = this.#messageFor(where, actor, channel, {
|
|
1040
|
-
type: ComponentType.Button,
|
|
1041
|
-
style: 2,
|
|
1042
|
-
custom_id: customId,
|
|
1043
|
-
});
|
|
1157
|
+
const message = this.#messageFor(where, actor, channel, { type: ComponentType.Button, style: 2, custom_id: customId }, button);
|
|
1044
1158
|
const raw = this.#rawInteraction(InteractionType.MessageComponent, actor, channel, { custom_id: customId, component_type: ComponentType.Button }, message);
|
|
1045
1159
|
return this.#reply(await this.#interact(raw));
|
|
1046
1160
|
}
|
|
@@ -1091,8 +1205,10 @@ export class Session {
|
|
|
1091
1205
|
const message = this.#messageFor(where, actor, channel, {
|
|
1092
1206
|
type: componentType,
|
|
1093
1207
|
custom_id: customId,
|
|
1094
|
-
...(componentType === ComponentType.StringSelect
|
|
1095
|
-
|
|
1208
|
+
...(componentType === ComponentType.StringSelect
|
|
1209
|
+
? { options: [] }
|
|
1210
|
+
: {}),
|
|
1211
|
+
}, menu);
|
|
1096
1212
|
const raw = this.#rawInteraction(InteractionType.MessageComponent, actor, channel, {
|
|
1097
1213
|
custom_id: customId,
|
|
1098
1214
|
component_type: componentType,
|
|
@@ -1377,6 +1493,10 @@ export class Session {
|
|
|
1377
1493
|
settle() {
|
|
1378
1494
|
return this.#observer.settle();
|
|
1379
1495
|
}
|
|
1496
|
+
async endWaits() {
|
|
1497
|
+
this.bot.ctx.waiters?.close();
|
|
1498
|
+
await this.#observer.settle();
|
|
1499
|
+
}
|
|
1380
1500
|
/**
|
|
1381
1501
|
* Sends a gateway event to the bot and waits for the files to be done
|
|
1382
1502
|
* with it. For the checks of `chapterjs test`, not for scenarios.
|
package/dist/testing/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { scenario } from './scenario.js';
|
|
2
2
|
export type { ScenarioDeclaration } from './scenario.js';
|
|
3
3
|
export { TestFailure } from './failure.js';
|
|
4
|
-
export type { ButtonTarget, FieldInputsOf, FormTarget, OptionInputsOf, SelectInput, SelectTarget, TestAssertions, TestBot, TestButton, TestChannel, TestChannelOptions, TestChannelType, TestFile, TestForm, TestMember, TestMemberOptions, TestMenu, TestMessage, TestReply, TestRole, TestRoleOptions, TestSuggestion, TestWhere, } from './harness.js';
|
|
4
|
+
export type { ButtonTarget, FieldInputsOf, FormTarget, OptionInputsOf, SelectInput, SelectTarget, TestAssertions, TestBot, TestButton, TestChannel, TestChannelOptions, TestChannelType, TestFile, TestForm, TestMember, TestMemberOptions, TestMenu, TestMenuTarget, TestMessage, TestReply, TestRole, TestRoleOptions, TestSuggestion, TestWhere, } from './harness.js';
|
package/dist/testing/smoke.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// function threw: the content is the scenarios' business.
|
|
6
6
|
import { Permissions } from '../discord/permissions.js';
|
|
7
7
|
import { ChannelType } from '../discord/types/channel.js';
|
|
8
|
-
import { commandName } from '../commands/tree.js';
|
|
8
|
+
import { commandName, describeCommand, } from '../commands/tree.js';
|
|
9
9
|
import { EVENTS } from '../events/registry.js';
|
|
10
10
|
const UNICODE = '🙂 héllo wörld — ça va ?';
|
|
11
11
|
const FILE = {
|
|
@@ -152,9 +152,90 @@ function hard(option) {
|
|
|
152
152
|
];
|
|
153
153
|
}
|
|
154
154
|
}
|
|
155
|
+
/** The longest message Discord accepts. */
|
|
156
|
+
const MAX_MESSAGE = 2000;
|
|
157
|
+
/**
|
|
158
|
+
* The checks of a menu command: used on an ordinary message or person, and
|
|
159
|
+
* on the hard ones (a message at its longest, the bot, the owner, someone
|
|
160
|
+
* who left...).
|
|
161
|
+
*/
|
|
162
|
+
function menuCheck({ command, file }) {
|
|
163
|
+
const declaration = { config: { run: command.run } };
|
|
164
|
+
const subject = describeCommand(command);
|
|
165
|
+
const dm = command.where === 'dm';
|
|
166
|
+
const cases = [];
|
|
167
|
+
const use = (session, on, where = {}) => {
|
|
168
|
+
const by = userFor(session, command);
|
|
169
|
+
return Promise.resolve(on(session, by))
|
|
170
|
+
.then(target => session.use(declaration, { on: target }, {
|
|
171
|
+
by,
|
|
172
|
+
...where,
|
|
173
|
+
}))
|
|
174
|
+
.then(() => { });
|
|
175
|
+
};
|
|
176
|
+
if (command.type === 'message') {
|
|
177
|
+
// The message is written where the menu is used.
|
|
178
|
+
const say = (content, author) => (session, by) => session.say(content, {
|
|
179
|
+
by: author?.(session) ?? session.someone,
|
|
180
|
+
...(dm ? { in: 'dm', by } : {}),
|
|
181
|
+
});
|
|
182
|
+
cases.push({ label: 'on a message', run: session => use(session, say('Hello!')) }, {
|
|
183
|
+
label: 'on a message at its longest, with unicode',
|
|
184
|
+
run: session => use(session, say(`${UNICODE} ${textOf(MAX_MESSAGE)}`.slice(0, MAX_MESSAGE))),
|
|
185
|
+
});
|
|
186
|
+
if (!dm) {
|
|
187
|
+
cases.push({
|
|
188
|
+
label: 'on a message of the owner',
|
|
189
|
+
run: session => use(session, say('Hello!', current => current.owner)),
|
|
190
|
+
}, {
|
|
191
|
+
label: 'on a message of someone who left',
|
|
192
|
+
run: session => use(session, async (current, by) => {
|
|
193
|
+
// Someone of its own, who writes and then leaves.
|
|
194
|
+
const writer = current.member('gone-writer');
|
|
195
|
+
const message = await say('Hello!', () => writer)(current, by);
|
|
196
|
+
current.world.members.delete(writer.id);
|
|
197
|
+
return message;
|
|
198
|
+
}),
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
if (command.where === 'both') {
|
|
202
|
+
cases.push({
|
|
203
|
+
label: 'in a private message',
|
|
204
|
+
run: session => use(session, (current, by) => current.say('Hello!', { by, in: 'dm' })),
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
return { subject, file, cases };
|
|
208
|
+
}
|
|
209
|
+
cases.push({
|
|
210
|
+
label: 'on someone',
|
|
211
|
+
run: session => use(session, current => current.member('alice')),
|
|
212
|
+
}, { label: 'on who uses it', run: session => use(session, (_, by) => by) }, { label: 'on the bot', run: session => use(session, current => current.me) });
|
|
213
|
+
if (!dm) {
|
|
214
|
+
cases.push({
|
|
215
|
+
label: 'on the owner',
|
|
216
|
+
run: session => use(session, current => current.owner),
|
|
217
|
+
}, {
|
|
218
|
+
label: 'on someone who left',
|
|
219
|
+
run: session => use(session, current => {
|
|
220
|
+
const gone = current.member('gone');
|
|
221
|
+
current.world.members.delete(gone.id);
|
|
222
|
+
return gone;
|
|
223
|
+
}),
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
if (command.where === 'both') {
|
|
227
|
+
cases.push({
|
|
228
|
+
label: 'in a private message',
|
|
229
|
+
run: session => use(session, (_, by) => by, { in: 'dm' }),
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
return { subject, file, cases };
|
|
233
|
+
}
|
|
155
234
|
/** The checks of the commands. */
|
|
156
235
|
function commandChecks(project) {
|
|
157
236
|
return [...project.commands.values()].map((entry) => {
|
|
237
|
+
if (entry.command.type !== 'slash')
|
|
238
|
+
return menuCheck(entry);
|
|
158
239
|
const { command, file } = entry;
|
|
159
240
|
const declaration = { config: { run: command.run } };
|
|
160
241
|
const subject = `command ${commandName(command.path)}`;
|
|
@@ -274,6 +355,9 @@ function componentChecks(project) {
|
|
|
274
355
|
const { component, file } = entry;
|
|
275
356
|
if (component.kind === 'embed')
|
|
276
357
|
continue;
|
|
358
|
+
// Only waited for (`message.waitForClick()`): nothing of its own runs.
|
|
359
|
+
if (component.kind !== 'modal' && !component.run)
|
|
360
|
+
continue;
|
|
277
361
|
const { name, data } = component;
|
|
278
362
|
const target = (values) => ({
|
|
279
363
|
customId: [
|
|
@@ -644,6 +728,32 @@ function taskChecks(project) {
|
|
|
644
728
|
],
|
|
645
729
|
}));
|
|
646
730
|
}
|
|
731
|
+
/**
|
|
732
|
+
* The check of the presence, when it has functions: each runs once, as
|
|
733
|
+
* the rotation would run it.
|
|
734
|
+
*/
|
|
735
|
+
function presenceChecks(project) {
|
|
736
|
+
const file = project.presenceFile;
|
|
737
|
+
const presence = project.presence;
|
|
738
|
+
if (!file || !presence)
|
|
739
|
+
return [];
|
|
740
|
+
const hasFunctions = typeof presence.activities === 'function' ||
|
|
741
|
+
presence.activities.some(one => typeof one.name === 'function');
|
|
742
|
+
if (!hasFunctions)
|
|
743
|
+
return [];
|
|
744
|
+
return [
|
|
745
|
+
{
|
|
746
|
+
subject: 'the presence',
|
|
747
|
+
file,
|
|
748
|
+
cases: [
|
|
749
|
+
{
|
|
750
|
+
label: 'once',
|
|
751
|
+
run: session => session.project.runPresence(session.bot),
|
|
752
|
+
},
|
|
753
|
+
],
|
|
754
|
+
},
|
|
755
|
+
];
|
|
756
|
+
}
|
|
647
757
|
/** Every check of the project, in the order of the report. */
|
|
648
758
|
export function smokeChecks(project) {
|
|
649
759
|
const events = eventChecks(project);
|
|
@@ -653,6 +763,7 @@ export function smokeChecks(project) {
|
|
|
653
763
|
...componentChecks(project),
|
|
654
764
|
...events.checks,
|
|
655
765
|
...taskChecks(project),
|
|
766
|
+
...presenceChecks(project),
|
|
656
767
|
],
|
|
657
768
|
notSimulated: events.notSimulated,
|
|
658
769
|
};
|
package/dist/testing/world.js
CHANGED
|
@@ -59,6 +59,11 @@ const unknown = (what, code) => ({
|
|
|
59
59
|
status: 404,
|
|
60
60
|
body: { message: `Unknown ${what}`, code },
|
|
61
61
|
});
|
|
62
|
+
/** What Discord answers an action the bot is not high enough for. */
|
|
63
|
+
const MISSING_PERMISSIONS = {
|
|
64
|
+
status: 403,
|
|
65
|
+
body: { message: 'Missing Permissions', code: 50013 },
|
|
66
|
+
};
|
|
62
67
|
/** The regular expression matching a documented path, placeholders filled. */
|
|
63
68
|
function patternOf(path) {
|
|
64
69
|
const source = path
|
|
@@ -238,15 +243,49 @@ export class World {
|
|
|
238
243
|
tertiary_color: null,
|
|
239
244
|
},
|
|
240
245
|
hoist: false,
|
|
241
|
-
position: options.position ??
|
|
246
|
+
position: options.position ?? 1,
|
|
242
247
|
permissions: (options.permissions ?? 0n).toString(),
|
|
243
248
|
managed: false,
|
|
244
249
|
mentionable: false,
|
|
245
250
|
flags: 0,
|
|
246
251
|
};
|
|
252
|
+
// A new role goes at the bottom, just above @everyone, as Discord
|
|
253
|
+
// places it: the ones from there up move up one.
|
|
254
|
+
for (const other of this.roles.values()) {
|
|
255
|
+
if (other.position >= role.position && other.id !== this.guildId)
|
|
256
|
+
other.position += 1;
|
|
257
|
+
}
|
|
247
258
|
this.roles.set(role.id, role);
|
|
248
259
|
return role;
|
|
249
260
|
}
|
|
261
|
+
/**
|
|
262
|
+
* Whether the bot may act on someone: kick, ban, change their nickname,
|
|
263
|
+
* roles or timeout. Never on the owner of the server, and only on members
|
|
264
|
+
* whose highest role is lower than the bot's.
|
|
265
|
+
* @see https://docs.discord.com/developers/topics/permissions#permission-hierarchy
|
|
266
|
+
*/
|
|
267
|
+
#outranks(userId) {
|
|
268
|
+
if (userId === this.ownerId)
|
|
269
|
+
return false;
|
|
270
|
+
if (userId === this.botId)
|
|
271
|
+
return true;
|
|
272
|
+
const member = this.members.get(userId);
|
|
273
|
+
return !member || this.#highest(this.botId) > this.#highest(userId);
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Whether the bot may give or take this role from someone: a role lower
|
|
277
|
+
* than its highest, on someone it outranks (itself included).
|
|
278
|
+
*/
|
|
279
|
+
#givable(userId, roleId) {
|
|
280
|
+
const role = this.roles.get(roleId);
|
|
281
|
+
return (this.#outranks(userId) &&
|
|
282
|
+
(role?.position ?? 0) < this.#highest(this.botId));
|
|
283
|
+
}
|
|
284
|
+
/** The position of someone's highest role (0: @everyone only). */
|
|
285
|
+
#highest(userId) {
|
|
286
|
+
const member = this.members.get(userId);
|
|
287
|
+
return Math.max(0, ...(member?.roles ?? []).map(id => this.roles.get(id)?.position ?? 0));
|
|
288
|
+
}
|
|
250
289
|
addChannel(name, options = {}) {
|
|
251
290
|
const channel = {
|
|
252
291
|
id: this.snowflake(),
|
|
@@ -750,6 +789,8 @@ export class World {
|
|
|
750
789
|
return found.refused;
|
|
751
790
|
const { member } = found;
|
|
752
791
|
const changes = body(request);
|
|
792
|
+
if (!this.#outranks(userId))
|
|
793
|
+
return MISSING_PERMISSIONS;
|
|
753
794
|
if ('nick' in changes)
|
|
754
795
|
member.nick = changes.nick ?? null;
|
|
755
796
|
if (Array.isArray(changes.roles))
|
|
@@ -783,6 +824,8 @@ export class World {
|
|
|
783
824
|
return found.refused;
|
|
784
825
|
if (!this.roles.has(roleId))
|
|
785
826
|
return unknown('Role', 10011);
|
|
827
|
+
if (!this.#givable(userId, roleId))
|
|
828
|
+
return MISSING_PERMISSIONS;
|
|
786
829
|
if (!found.member.roles.includes(roleId))
|
|
787
830
|
found.member.roles.push(roleId);
|
|
788
831
|
this.memberUpdated(found.member);
|
|
@@ -794,6 +837,8 @@ export class World {
|
|
|
794
837
|
return found.refused;
|
|
795
838
|
if (!this.roles.has(roleId))
|
|
796
839
|
return unknown('Role', 10011);
|
|
840
|
+
if (!this.#givable(userId, roleId))
|
|
841
|
+
return MISSING_PERMISSIONS;
|
|
797
842
|
found.member.roles = found.member.roles.filter(id => id !== roleId);
|
|
798
843
|
this.memberUpdated(found.member);
|
|
799
844
|
return {};
|
|
@@ -802,6 +847,8 @@ export class World {
|
|
|
802
847
|
const found = memberOr404(guildId, userId);
|
|
803
848
|
if ('refused' in found)
|
|
804
849
|
return found.refused;
|
|
850
|
+
if (!this.#outranks(userId))
|
|
851
|
+
return MISSING_PERMISSIONS;
|
|
805
852
|
this.removeMember(userId);
|
|
806
853
|
return {};
|
|
807
854
|
});
|
|
@@ -828,6 +875,8 @@ export class World {
|
|
|
828
875
|
const user = this.users.get(userId);
|
|
829
876
|
if (!user)
|
|
830
877
|
return unknown('User', 10013);
|
|
878
|
+
if (!this.#outranks(userId))
|
|
879
|
+
return MISSING_PERMISSIONS;
|
|
831
880
|
const reason = request.headers['x-audit-log-reason'];
|
|
832
881
|
this.bans.set(userId, {
|
|
833
882
|
user,
|
|
@@ -863,7 +912,7 @@ export class World {
|
|
|
863
912
|
const failed = [];
|
|
864
913
|
for (const id of ids) {
|
|
865
914
|
const user = this.users.get(id);
|
|
866
|
-
if (!user || this.bans.has(id)) {
|
|
915
|
+
if (!user || this.bans.has(id) || !this.#outranks(id)) {
|
|
867
916
|
failed.push(id);
|
|
868
917
|
continue;
|
|
869
918
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Where a call of the files was made, for an error that comes later. An
|
|
2
|
+
// error made after an `await` only has the lines of the files in its stack
|
|
3
|
+
// when they awaited the call: `member.ban()` written without `await`, and
|
|
4
|
+
// refused by Discord, would point nowhere in the project. `callSite()` is
|
|
5
|
+
// taken when the call is made, before anything is awaited, and
|
|
6
|
+
// `fromSite()` adds its lines to the error, so `locate()` finds the line
|
|
7
|
+
// that asked whether the call was awaited or not.
|
|
8
|
+
/** The stack of the moment a call is made. */
|
|
9
|
+
export function callSite() {
|
|
10
|
+
return new Error('Called here');
|
|
11
|
+
}
|
|
12
|
+
/** Adds where the call was made to an error that came later. */
|
|
13
|
+
export function fromSite(error, site) {
|
|
14
|
+
if (!(error instanceof Error) || typeof error.stack !== 'string') {
|
|
15
|
+
return error;
|
|
16
|
+
}
|
|
17
|
+
const frames = (site.stack ?? '').split('\n').slice(1).join('\n');
|
|
18
|
+
if (frames && !error.stack.includes(frames)) {
|
|
19
|
+
error.stack = `${error.stack}\n${frames}`;
|
|
20
|
+
}
|
|
21
|
+
return error;
|
|
22
|
+
}
|
|
@@ -69,6 +69,8 @@ export declare class VoiceManager {
|
|
|
69
69
|
secure?: boolean;
|
|
70
70
|
joinTimeout?: number;
|
|
71
71
|
endedGrace?: number;
|
|
72
|
+
/** Where nothing can play: what `join()` says at once. */
|
|
73
|
+
unavailable?: string;
|
|
72
74
|
});
|
|
73
75
|
/** The connection of the bot in a server, if it is in voice there. */
|
|
74
76
|
connectionOf(guildId: Snowflake): VoiceConnection | null;
|