chapterjs 0.2.2 → 0.3.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/LICENSE +21 -0
- package/README.md +65 -0
- package/dist/cli/cli.js +41 -6
- package/dist/cli/cluster.d.ts +3 -0
- package/dist/cli/cluster.js +4 -1
- package/dist/cli/dev.js +2 -0
- package/dist/cli/project.d.ts +38 -0
- package/dist/cli/project.js +51 -10
- package/dist/cli/start.js +18 -0
- package/dist/cli/test.d.ts +29 -0
- package/dist/cli/test.js +444 -0
- package/dist/commands/autocomplete.d.ts +1 -1
- package/dist/commands/autocomplete.js +37 -41
- package/dist/commands/router.d.ts +4 -1
- package/dist/commands/router.js +16 -17
- package/dist/components/router.d.ts +7 -2
- package/dist/components/router.js +17 -16
- package/dist/discord/types/channel.d.ts +9 -1
- package/dist/discord/types/guild.d.ts +5 -5
- package/dist/discord/types/permissions.d.ts +6 -0
- package/dist/discord/types/poll.d.ts +7 -1
- package/dist/events/router.d.ts +12 -4
- package/dist/events/router.js +29 -12
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/interactions/dispatch.d.ts +7 -1
- package/dist/interactions/dispatch.js +7 -2
- package/dist/loader/hot.js +22 -2
- package/dist/loader/loader.d.ts +7 -0
- package/dist/loader/loader.js +22 -12
- package/dist/store/backend.d.ts +33 -0
- package/dist/store/backend.js +27 -0
- package/dist/store/declaration.d.ts +15 -0
- package/dist/store/declaration.js +53 -0
- package/dist/store/file-backend.d.ts +22 -0
- package/dist/store/file-backend.js +172 -0
- package/dist/store/ipc-backend.d.ts +53 -0
- package/dist/store/ipc-backend.js +87 -0
- package/dist/store/memory-backend.d.ts +15 -0
- package/dist/store/memory-backend.js +56 -0
- package/dist/store/scope.d.ts +8 -0
- package/dist/store/scope.js +20 -0
- package/dist/store/store.d.ts +115 -0
- package/dist/store/store.js +203 -0
- package/dist/tasks/scheduler.d.ts +6 -0
- package/dist/tasks/scheduler.js +10 -3
- package/dist/testing/failure.d.ts +9 -0
- package/dist/testing/failure.js +20 -0
- package/dist/testing/fake-discord.d.ts +59 -0
- package/dist/testing/fake-discord.js +120 -0
- package/dist/testing/fake-gateway.d.ts +59 -0
- package/dist/testing/fake-gateway.js +123 -0
- package/dist/testing/harness.d.ts +387 -0
- package/dist/testing/harness.js +1418 -0
- package/dist/testing/index.d.ts +4 -0
- package/dist/testing/index.js +5 -0
- package/dist/testing/scenario.d.ts +39 -0
- package/dist/testing/scenario.js +58 -0
- package/dist/testing/smoke.d.ts +20 -0
- package/dist/testing/smoke.js +659 -0
- package/dist/testing/websocket.d.ts +34 -0
- package/dist/testing/websocket.js +167 -0
- package/dist/testing/world.d.ts +145 -0
- package/dist/testing/world.js +1479 -0
- package/package.json +20 -3
|
@@ -0,0 +1,1418 @@
|
|
|
1
|
+
// What a test drives: the bot of the project, connected to the Discord in
|
|
2
|
+
// memory of `world.ts`, and the handles a scenario works with (a member, a
|
|
3
|
+
// channel, what the bot answered). Everything a person would do on Discord
|
|
4
|
+
// is turned into the payloads Discord would send, given to the real
|
|
5
|
+
// routers, and what the bot sends back is read from the world.
|
|
6
|
+
import { Permissions } from '../discord/permissions.js';
|
|
7
|
+
import { ApplicationCommandOptionType, ApplicationCommandType, } from '../discord/types/application-command.js';
|
|
8
|
+
import { ChannelType } from '../discord/types/channel.js';
|
|
9
|
+
import { ComponentType, } from '../discord/types/component.js';
|
|
10
|
+
import { InteractionType, } from '../discord/types/interaction.js';
|
|
11
|
+
import { MessageFlags, MessageType, } from '../discord/types/message.js';
|
|
12
|
+
import { PermissionFlags } from '../discord/types/permissions.js';
|
|
13
|
+
import { commandName } from '../commands/tree.js';
|
|
14
|
+
import { componentStateOf } from '../components/declared.js';
|
|
15
|
+
import { isPiece, renderedOf, resolve, } from '../components/instance.js';
|
|
16
|
+
import { translatorOf } from '../messages/translate.js';
|
|
17
|
+
import { toCamelCase } from '../util/case.js';
|
|
18
|
+
import { expectThat, shown, TestFailure } from './failure.js';
|
|
19
|
+
/** A `Log` that keeps what is written, for the runner to show per check. */
|
|
20
|
+
export function captureLog() {
|
|
21
|
+
let current = { errors: [], warnings: [], infos: [] };
|
|
22
|
+
return {
|
|
23
|
+
log: {
|
|
24
|
+
success: message => current.infos.push(message),
|
|
25
|
+
info: message => current.infos.push(message),
|
|
26
|
+
reload: message => current.infos.push(message),
|
|
27
|
+
warn: message => current.warnings.push(message),
|
|
28
|
+
error: message => current.errors.push(message),
|
|
29
|
+
},
|
|
30
|
+
take() {
|
|
31
|
+
const taken = current;
|
|
32
|
+
current = { errors: [], warnings: [], infos: [] };
|
|
33
|
+
return taken;
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/** Watches what the bot does with the files, to wait for it. */
|
|
38
|
+
export class Observer {
|
|
39
|
+
#pending = new Set();
|
|
40
|
+
#interactions = new Map();
|
|
41
|
+
#interactionWaiters = new Map();
|
|
42
|
+
#eventWaiters = [];
|
|
43
|
+
observe = {
|
|
44
|
+
interaction: (raw, done) => {
|
|
45
|
+
const settled = done.then(() => { }, () => { });
|
|
46
|
+
this.#track(settled);
|
|
47
|
+
this.#interactions.set(raw.id, settled);
|
|
48
|
+
const waiter = this.#interactionWaiters.get(raw.id);
|
|
49
|
+
if (waiter) {
|
|
50
|
+
this.#interactionWaiters.delete(raw.id);
|
|
51
|
+
waiter(settled);
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
event: (event, data, done) => {
|
|
55
|
+
const settled = done.then(() => { }, () => { });
|
|
56
|
+
this.#track(settled);
|
|
57
|
+
for (let index = this.#eventWaiters.length - 1; index >= 0; index--) {
|
|
58
|
+
const waiter = this.#eventWaiters[index];
|
|
59
|
+
if (waiter.match(event, data)) {
|
|
60
|
+
this.#eventWaiters.splice(index, 1);
|
|
61
|
+
waiter.resolve(settled);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
#track(settled) {
|
|
67
|
+
this.#pending.add(settled);
|
|
68
|
+
settled.finally(() => this.#pending.delete(settled));
|
|
69
|
+
}
|
|
70
|
+
/** Forgets the interactions seen so far: a new world starts. */
|
|
71
|
+
reset() {
|
|
72
|
+
this.#interactions.clear();
|
|
73
|
+
}
|
|
74
|
+
/** Resolves once the files are done with the interaction `id`. */
|
|
75
|
+
interaction(id, timeout) {
|
|
76
|
+
const known = this.#interactions.get(id);
|
|
77
|
+
if (known)
|
|
78
|
+
return known;
|
|
79
|
+
return new Promise((resolve, reject) => {
|
|
80
|
+
const timer = setTimeout(() => {
|
|
81
|
+
this.#interactionWaiters.delete(id);
|
|
82
|
+
reject(new TestFailure(`The bot did not receive the interaction within ${timeout / 1000} s.`));
|
|
83
|
+
}, timeout);
|
|
84
|
+
this.#interactionWaiters.set(id, done => {
|
|
85
|
+
clearTimeout(timer);
|
|
86
|
+
resolve(done);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
/** Resolves once the files are done with the next event that matches. */
|
|
91
|
+
event(match, timeout) {
|
|
92
|
+
return new Promise((resolve, reject) => {
|
|
93
|
+
const waiter = {
|
|
94
|
+
match,
|
|
95
|
+
resolve: (done) => {
|
|
96
|
+
clearTimeout(timer);
|
|
97
|
+
resolve(done);
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
const timer = setTimeout(() => {
|
|
101
|
+
const index = this.#eventWaiters.indexOf(waiter);
|
|
102
|
+
if (index !== -1)
|
|
103
|
+
this.#eventWaiters.splice(index, 1);
|
|
104
|
+
reject(new TestFailure(`The bot did not receive the event within ${timeout / 1000} s.`));
|
|
105
|
+
}, timeout);
|
|
106
|
+
this.#eventWaiters.push(waiter);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
/** Resolves once nothing the files started is still running. */
|
|
110
|
+
async settle() {
|
|
111
|
+
while (this.#pending.size > 0) {
|
|
112
|
+
await Promise.all([...this.#pending]);
|
|
113
|
+
}
|
|
114
|
+
// Answers sent at the very end have reached the world by now.
|
|
115
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// ── The handles ──────────────────────────────────────────────────────
|
|
119
|
+
const permissionNames = (bits) => Object.keys(PermissionFlags).filter(name => (bits & PermissionFlags[name]) !== 0n);
|
|
120
|
+
const BUTTON_STYLES = {
|
|
121
|
+
1: 'primary',
|
|
122
|
+
2: 'secondary',
|
|
123
|
+
3: 'success',
|
|
124
|
+
4: 'danger',
|
|
125
|
+
5: 'link',
|
|
126
|
+
6: 'premium',
|
|
127
|
+
};
|
|
128
|
+
const MENU_TYPES = {
|
|
129
|
+
[ComponentType.StringSelect]: 'string',
|
|
130
|
+
[ComponentType.UserSelect]: 'user',
|
|
131
|
+
[ComponentType.RoleSelect]: 'role',
|
|
132
|
+
[ComponentType.MentionableSelect]: 'mentionable',
|
|
133
|
+
[ComponentType.ChannelSelect]: 'channel',
|
|
134
|
+
};
|
|
135
|
+
const CHANNEL_TYPES = {
|
|
136
|
+
text: ChannelType.GuildText,
|
|
137
|
+
announcement: ChannelType.GuildAnnouncement,
|
|
138
|
+
voice: ChannelType.GuildVoice,
|
|
139
|
+
category: ChannelType.GuildCategory,
|
|
140
|
+
forum: ChannelType.GuildForum,
|
|
141
|
+
};
|
|
142
|
+
const channelTypeName = (type) => (Object.entries(CHANNEL_TYPES).find(([, value]) => value === type)?.[0] ??
|
|
143
|
+
(type === ChannelType.Dm
|
|
144
|
+
? 'dm'
|
|
145
|
+
: type === ChannelType.PublicThread ||
|
|
146
|
+
type === ChannelType.PrivateThread ||
|
|
147
|
+
type === ChannelType.AnnouncementThread
|
|
148
|
+
? 'thread'
|
|
149
|
+
: 'other'));
|
|
150
|
+
/** Every button and menu of a message, wherever they are nested. */
|
|
151
|
+
function collectComponents(components) {
|
|
152
|
+
const buttons = [];
|
|
153
|
+
const menus = [];
|
|
154
|
+
const walk = (list) => {
|
|
155
|
+
for (const raw of list ?? []) {
|
|
156
|
+
const component = raw;
|
|
157
|
+
if (component.type === ComponentType.Button) {
|
|
158
|
+
buttons.push(component);
|
|
159
|
+
}
|
|
160
|
+
else if (MENU_TYPES[component.type]) {
|
|
161
|
+
menus.push(component);
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
walk(component.components);
|
|
165
|
+
if (component.accessory)
|
|
166
|
+
walk([component.accessory]);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
walk(components);
|
|
171
|
+
return { buttons, menus };
|
|
172
|
+
}
|
|
173
|
+
const buttonHandle = (raw) => Object.freeze({
|
|
174
|
+
label: raw.label ?? null,
|
|
175
|
+
style: BUTTON_STYLES[raw.style] ?? 'secondary',
|
|
176
|
+
disabled: raw.disabled === true,
|
|
177
|
+
customId: raw.custom_id ?? null,
|
|
178
|
+
});
|
|
179
|
+
const menuHandle = (raw) => Object.freeze({
|
|
180
|
+
type: MENU_TYPES[raw.type] ?? 'string',
|
|
181
|
+
placeholder: raw.placeholder ?? null,
|
|
182
|
+
disabled: raw.disabled === true,
|
|
183
|
+
options: 'options' in raw
|
|
184
|
+
? raw.options.map(option => ({
|
|
185
|
+
label: option.label,
|
|
186
|
+
value: option.value,
|
|
187
|
+
}))
|
|
188
|
+
: [],
|
|
189
|
+
customId: raw.custom_id,
|
|
190
|
+
});
|
|
191
|
+
/** Checks a message or an answer; written once for both. */
|
|
192
|
+
function check(self, what, get) {
|
|
193
|
+
const present = () => {
|
|
194
|
+
const state = get();
|
|
195
|
+
expectThat(state.present, `${what} has no message to check.`);
|
|
196
|
+
return state;
|
|
197
|
+
};
|
|
198
|
+
return {
|
|
199
|
+
says(text) {
|
|
200
|
+
const { content } = present();
|
|
201
|
+
const matches = typeof text === 'string' ? content.includes(text) : text.test(content);
|
|
202
|
+
expectThat(matches, `${what} should say ${typeof text === 'string' ? shown(text) : text}, it says ${shown(content)}.`);
|
|
203
|
+
return self;
|
|
204
|
+
},
|
|
205
|
+
saysExactly(text) {
|
|
206
|
+
const { content } = present();
|
|
207
|
+
expectThat(content === text, `${what} should say exactly ${shown(text)}, it says ${shown(content)}.`);
|
|
208
|
+
return self;
|
|
209
|
+
},
|
|
210
|
+
isEphemeral() {
|
|
211
|
+
expectThat(present().ephemeral, `${what} should be ephemeral (seen by one person), it is seen by everyone.`);
|
|
212
|
+
return self;
|
|
213
|
+
},
|
|
214
|
+
isPublic() {
|
|
215
|
+
expectThat(!present().ephemeral, `${what} should be seen by everyone, it is ephemeral.`);
|
|
216
|
+
return self;
|
|
217
|
+
},
|
|
218
|
+
hasButton(label) {
|
|
219
|
+
const { buttons } = present();
|
|
220
|
+
expectThat(buttons.some(button => button.label === label), `${what} should have a button ${shown(label)}, its buttons are: ${buttons.map(button => shown(button.label ?? '')).join(', ') || 'none'}.`);
|
|
221
|
+
return self;
|
|
222
|
+
},
|
|
223
|
+
hasEmbed(title) {
|
|
224
|
+
const { embeds } = present();
|
|
225
|
+
expectThat(embeds.length > 0, `${what} should have an embed, it has none.`);
|
|
226
|
+
if (title !== undefined) {
|
|
227
|
+
expectThat(embeds.some(embed => embed.title === title), `${what} should have an embed titled ${shown(title)}, its embeds are titled: ${embeds.map(embed => shown(embed.title ?? '')).join(', ')}.`);
|
|
228
|
+
}
|
|
229
|
+
return self;
|
|
230
|
+
},
|
|
231
|
+
hasNoEmbed() {
|
|
232
|
+
const { embeds } = present();
|
|
233
|
+
expectThat(embeds.length === 0, `${what} should have no embed, it has ${embeds.length}.`);
|
|
234
|
+
return self;
|
|
235
|
+
},
|
|
236
|
+
hasNoComponent() {
|
|
237
|
+
const { buttons, menus } = present();
|
|
238
|
+
expectThat(buttons.length === 0 && menus.length === 0, `${what} should have no button or menu, it has ${buttons.length} button(s) and ${menus.length} menu(s).`);
|
|
239
|
+
return self;
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
/** The bot in its world, with the handles a test works with. */
|
|
244
|
+
export class Session {
|
|
245
|
+
world;
|
|
246
|
+
project;
|
|
247
|
+
bot;
|
|
248
|
+
#observer;
|
|
249
|
+
#timeout;
|
|
250
|
+
#members = new Map();
|
|
251
|
+
#roles = new Map();
|
|
252
|
+
#channels = new Map();
|
|
253
|
+
#messages = new Map();
|
|
254
|
+
#someone = null;
|
|
255
|
+
constructor(world, project, bot, observer, options = {}) {
|
|
256
|
+
this.world = world;
|
|
257
|
+
this.project = project;
|
|
258
|
+
this.bot = bot;
|
|
259
|
+
this.#observer = observer;
|
|
260
|
+
this.#timeout = options.timeout ?? 5000;
|
|
261
|
+
}
|
|
262
|
+
get guild() {
|
|
263
|
+
return { id: this.world.guildId, name: this.world.guildName };
|
|
264
|
+
}
|
|
265
|
+
get me() {
|
|
266
|
+
return this.#member(this.world.botId);
|
|
267
|
+
}
|
|
268
|
+
get owner() {
|
|
269
|
+
return this.#member(this.world.ownerId);
|
|
270
|
+
}
|
|
271
|
+
get someone() {
|
|
272
|
+
return (this.#someone ??= this.member('someone'));
|
|
273
|
+
}
|
|
274
|
+
get requests() {
|
|
275
|
+
return this.world.discord.requests.map(({ method, path, body }) => ({
|
|
276
|
+
method,
|
|
277
|
+
path,
|
|
278
|
+
body,
|
|
279
|
+
}));
|
|
280
|
+
}
|
|
281
|
+
// ── Handles ──
|
|
282
|
+
#member(id) {
|
|
283
|
+
let handle = this.#members.get(id);
|
|
284
|
+
if (handle)
|
|
285
|
+
return handle;
|
|
286
|
+
const { world } = this;
|
|
287
|
+
const user = () => world.users.get(id);
|
|
288
|
+
const member = () => world.members.get(id);
|
|
289
|
+
const self = this;
|
|
290
|
+
handle = Object.freeze({
|
|
291
|
+
id,
|
|
292
|
+
get name() {
|
|
293
|
+
return user().username;
|
|
294
|
+
},
|
|
295
|
+
get nickname() {
|
|
296
|
+
return member()?.nick ?? null;
|
|
297
|
+
},
|
|
298
|
+
get roles() {
|
|
299
|
+
return (member()?.roles ?? [])
|
|
300
|
+
.map(roleId => world.roles.get(roleId)?.name)
|
|
301
|
+
.filter((name) => name !== undefined);
|
|
302
|
+
},
|
|
303
|
+
get permissions() {
|
|
304
|
+
return permissionNames(world.permissionsOf(id));
|
|
305
|
+
},
|
|
306
|
+
get inServer() {
|
|
307
|
+
return member() !== undefined;
|
|
308
|
+
},
|
|
309
|
+
get banned() {
|
|
310
|
+
return world.bans.has(id);
|
|
311
|
+
},
|
|
312
|
+
get timedOut() {
|
|
313
|
+
const until = member()?.communication_disabled_until;
|
|
314
|
+
return typeof until === 'string' && Date.parse(until) > Date.now();
|
|
315
|
+
},
|
|
316
|
+
get privateMessages() {
|
|
317
|
+
return (world.messages
|
|
318
|
+
.get([...world.channels.values()].find(channel => channel.type === ChannelType.Dm &&
|
|
319
|
+
channel.recipients?.[0]?.id === id)?.id ?? '')
|
|
320
|
+
?.map(raw => self.#message(raw)) ?? []);
|
|
321
|
+
},
|
|
322
|
+
can: (permission) => new Permissions(world.permissionsOf(id)).has(permission),
|
|
323
|
+
hasRole: (role) => (member()?.roles ?? []).some(roleId => typeof role === 'string'
|
|
324
|
+
? world.roles.get(roleId)?.name === role
|
|
325
|
+
: roleId === role.id),
|
|
326
|
+
});
|
|
327
|
+
this.#members.set(id, handle);
|
|
328
|
+
return handle;
|
|
329
|
+
}
|
|
330
|
+
#role(id) {
|
|
331
|
+
let handle = this.#roles.get(id);
|
|
332
|
+
if (handle)
|
|
333
|
+
return handle;
|
|
334
|
+
const { world } = this;
|
|
335
|
+
handle = Object.freeze({
|
|
336
|
+
id,
|
|
337
|
+
get name() {
|
|
338
|
+
return world.roles.get(id)?.name ?? '';
|
|
339
|
+
},
|
|
340
|
+
get permissions() {
|
|
341
|
+
return permissionNames(BigInt(world.roles.get(id)?.permissions ?? '0'));
|
|
342
|
+
},
|
|
343
|
+
get exists() {
|
|
344
|
+
return world.roles.has(id);
|
|
345
|
+
},
|
|
346
|
+
});
|
|
347
|
+
this.#roles.set(id, handle);
|
|
348
|
+
return handle;
|
|
349
|
+
}
|
|
350
|
+
#channel(id) {
|
|
351
|
+
let handle = this.#channels.get(id);
|
|
352
|
+
if (handle)
|
|
353
|
+
return handle;
|
|
354
|
+
const { world } = this;
|
|
355
|
+
const self = this;
|
|
356
|
+
const raw = () => world.channels.get(id);
|
|
357
|
+
// What only one person saw (an ephemeral answer) is not in the channel.
|
|
358
|
+
const messages = () => (world.messages.get(id) ?? [])
|
|
359
|
+
.filter(message => ((message.flags ?? 0) & MessageFlags.Ephemeral) === 0)
|
|
360
|
+
.map(message => self.#message(message));
|
|
361
|
+
handle = Object.freeze({
|
|
362
|
+
id,
|
|
363
|
+
get name() {
|
|
364
|
+
return raw()?.name ?? '';
|
|
365
|
+
},
|
|
366
|
+
get type() {
|
|
367
|
+
return raw() ? channelTypeName(raw().type) : 'deleted';
|
|
368
|
+
},
|
|
369
|
+
get topic() {
|
|
370
|
+
return raw()?.topic ?? null;
|
|
371
|
+
},
|
|
372
|
+
get slowmode() {
|
|
373
|
+
return raw()?.rate_limit_per_user ?? 0;
|
|
374
|
+
},
|
|
375
|
+
get exists() {
|
|
376
|
+
return raw() !== undefined;
|
|
377
|
+
},
|
|
378
|
+
get messages() {
|
|
379
|
+
return messages();
|
|
380
|
+
},
|
|
381
|
+
get last() {
|
|
382
|
+
return messages().at(-1) ?? null;
|
|
383
|
+
},
|
|
384
|
+
get threads() {
|
|
385
|
+
return [...world.channels.values()]
|
|
386
|
+
.filter(channel => channel.parent_id === id && world.isThread(channel))
|
|
387
|
+
.map(thread => self.#channel(thread.id));
|
|
388
|
+
},
|
|
389
|
+
get members() {
|
|
390
|
+
return [...(world.threadMembers.get(id) ?? [])].map(userId => self.#member(userId));
|
|
391
|
+
},
|
|
392
|
+
get archived() {
|
|
393
|
+
return raw()?.thread_metadata?.archived === true;
|
|
394
|
+
},
|
|
395
|
+
get locked() {
|
|
396
|
+
return raw()?.thread_metadata?.locked === true;
|
|
397
|
+
},
|
|
398
|
+
allows: (who, permission) => {
|
|
399
|
+
const channel = raw();
|
|
400
|
+
if (!channel)
|
|
401
|
+
return false;
|
|
402
|
+
const isRole = !('inServer' in who);
|
|
403
|
+
const base = isRole
|
|
404
|
+
? BigInt(world.roles.get(who.id)?.permissions ?? '0')
|
|
405
|
+
: world.permissionsOf(who.id);
|
|
406
|
+
if ((base & PermissionFlags.Administrator) !== 0n)
|
|
407
|
+
return true;
|
|
408
|
+
let permissions = base;
|
|
409
|
+
const overwrites = channel.permission_overwrites ?? [];
|
|
410
|
+
const apply = (overwriteId) => {
|
|
411
|
+
const overwrite = overwrites.find(one => one.id === overwriteId);
|
|
412
|
+
if (!overwrite)
|
|
413
|
+
return;
|
|
414
|
+
permissions &= ~BigInt(overwrite.deny);
|
|
415
|
+
permissions |= BigInt(overwrite.allow);
|
|
416
|
+
};
|
|
417
|
+
apply(world.guildId);
|
|
418
|
+
if (!isRole) {
|
|
419
|
+
for (const roleId of world.members.get(who.id)?.roles ?? [])
|
|
420
|
+
apply(roleId);
|
|
421
|
+
}
|
|
422
|
+
else {
|
|
423
|
+
apply(who.id);
|
|
424
|
+
}
|
|
425
|
+
apply(who.id);
|
|
426
|
+
return new Permissions(permissions).has(permission);
|
|
427
|
+
},
|
|
428
|
+
});
|
|
429
|
+
this.#channels.set(id, handle);
|
|
430
|
+
return handle;
|
|
431
|
+
}
|
|
432
|
+
#message(raw) {
|
|
433
|
+
let handle = this.#messages.get(raw.id);
|
|
434
|
+
if (handle)
|
|
435
|
+
return handle;
|
|
436
|
+
const { world } = this;
|
|
437
|
+
const self = this;
|
|
438
|
+
const current = () => world.findMessage(raw.id) ?? raw;
|
|
439
|
+
const parts = () => {
|
|
440
|
+
const message = current();
|
|
441
|
+
const { buttons, menus } = collectComponents(message.components);
|
|
442
|
+
return {
|
|
443
|
+
content: message.content,
|
|
444
|
+
embeds: toCamelCase(message.embeds),
|
|
445
|
+
buttons: buttons.map(buttonHandle),
|
|
446
|
+
menus: menus.map(menuHandle),
|
|
447
|
+
ephemeral: ((message.flags ?? 0) & MessageFlags.Ephemeral) !== 0,
|
|
448
|
+
present: true,
|
|
449
|
+
};
|
|
450
|
+
};
|
|
451
|
+
const base = {
|
|
452
|
+
id: raw.id,
|
|
453
|
+
get content() {
|
|
454
|
+
return current().content;
|
|
455
|
+
},
|
|
456
|
+
get author() {
|
|
457
|
+
return world.users.has(raw.author.id)
|
|
458
|
+
? self.#member(raw.author.id)
|
|
459
|
+
: null;
|
|
460
|
+
},
|
|
461
|
+
fromBot: raw.author.id === world.botId,
|
|
462
|
+
get embeds() {
|
|
463
|
+
return parts().embeds;
|
|
464
|
+
},
|
|
465
|
+
get buttons() {
|
|
466
|
+
return parts().buttons;
|
|
467
|
+
},
|
|
468
|
+
get menus() {
|
|
469
|
+
return parts().menus;
|
|
470
|
+
},
|
|
471
|
+
get ephemeral() {
|
|
472
|
+
return parts().ephemeral;
|
|
473
|
+
},
|
|
474
|
+
get pinned() {
|
|
475
|
+
return current().pinned;
|
|
476
|
+
},
|
|
477
|
+
channel: this.#channel(raw.channel_id),
|
|
478
|
+
get deleted() {
|
|
479
|
+
return world.findMessage(raw.id) === undefined;
|
|
480
|
+
},
|
|
481
|
+
button(label) {
|
|
482
|
+
const found = parts().buttons.find(button => button.label === label);
|
|
483
|
+
expectThat(found !== undefined, `The message has no button ${shown(label)}: its buttons are ${parts()
|
|
484
|
+
.buttons.map(button => shown(button.label ?? ''))
|
|
485
|
+
.join(', ') || 'none'}.`);
|
|
486
|
+
return found;
|
|
487
|
+
},
|
|
488
|
+
menu(placeholder) {
|
|
489
|
+
const { menus } = parts();
|
|
490
|
+
const found = placeholder === undefined
|
|
491
|
+
? menus[0]
|
|
492
|
+
: menus.find(menu => menu.placeholder === placeholder);
|
|
493
|
+
expectThat(found !== undefined, placeholder === undefined
|
|
494
|
+
? 'The message has no menu.'
|
|
495
|
+
: `The message has no menu with the placeholder ${shown(placeholder)}.`);
|
|
496
|
+
return found;
|
|
497
|
+
},
|
|
498
|
+
};
|
|
499
|
+
handle = Object.freeze(Object.assign(base, check(base, 'The message', parts)));
|
|
500
|
+
this.#messages.set(raw.id, handle);
|
|
501
|
+
return handle;
|
|
502
|
+
}
|
|
503
|
+
#reply(record) {
|
|
504
|
+
const self = this;
|
|
505
|
+
const message = () => record.original ? self.#message(record.original) : null;
|
|
506
|
+
const parts = () => {
|
|
507
|
+
const found = message();
|
|
508
|
+
return {
|
|
509
|
+
content: found?.content ?? '',
|
|
510
|
+
embeds: found?.embeds ?? [],
|
|
511
|
+
buttons: found?.buttons ?? [],
|
|
512
|
+
menus: found?.menus ?? [],
|
|
513
|
+
ephemeral: found?.ephemeral ?? false,
|
|
514
|
+
present: found !== null,
|
|
515
|
+
};
|
|
516
|
+
};
|
|
517
|
+
const base = {
|
|
518
|
+
get message() {
|
|
519
|
+
return message();
|
|
520
|
+
},
|
|
521
|
+
get content() {
|
|
522
|
+
return parts().content;
|
|
523
|
+
},
|
|
524
|
+
get embeds() {
|
|
525
|
+
return parts().embeds;
|
|
526
|
+
},
|
|
527
|
+
get buttons() {
|
|
528
|
+
return parts().buttons;
|
|
529
|
+
},
|
|
530
|
+
get menus() {
|
|
531
|
+
return parts().menus;
|
|
532
|
+
},
|
|
533
|
+
get followUps() {
|
|
534
|
+
return record.followUps.map(raw => self.#message(raw));
|
|
535
|
+
},
|
|
536
|
+
get form() {
|
|
537
|
+
if (!record.modal)
|
|
538
|
+
return null;
|
|
539
|
+
return Object.freeze({
|
|
540
|
+
title: record.modal.title,
|
|
541
|
+
fields: record.modal.components
|
|
542
|
+
.map(component => {
|
|
543
|
+
const label = component;
|
|
544
|
+
return label.component?.custom_id;
|
|
545
|
+
})
|
|
546
|
+
.filter((name) => typeof name === 'string'),
|
|
547
|
+
customId: record.modal.custom_id,
|
|
548
|
+
});
|
|
549
|
+
},
|
|
550
|
+
deferred: record.deferred,
|
|
551
|
+
updated: record.updated,
|
|
552
|
+
get ephemeral() {
|
|
553
|
+
return parts().ephemeral;
|
|
554
|
+
},
|
|
555
|
+
isDeferred() {
|
|
556
|
+
expectThat(record.deferred, 'The bot should have said the answer was coming (interaction.defer()), it answered right away.');
|
|
557
|
+
return reply;
|
|
558
|
+
},
|
|
559
|
+
updatesMessage() {
|
|
560
|
+
expectThat(record.updated, 'The bot should have changed the message of the component (interaction.update()), it answered with a new message instead.');
|
|
561
|
+
return reply;
|
|
562
|
+
},
|
|
563
|
+
opensForm(form) {
|
|
564
|
+
expectThat(record.modal !== null, `The bot should have opened a form, it ${record.original ? `answered ${shown(record.original.content)}` : 'opened none'}.`);
|
|
565
|
+
if (form) {
|
|
566
|
+
const expected = 'customId' in form
|
|
567
|
+
? form.customId
|
|
568
|
+
: self.#customIdOf(form, 'modal');
|
|
569
|
+
const opened = record.modal.custom_id.split(':')[0];
|
|
570
|
+
expectThat(opened === expected.split(':')[0], `The bot should have opened the form ${expected.split(':')[0]}, it opened ${opened}.`);
|
|
571
|
+
}
|
|
572
|
+
return reply;
|
|
573
|
+
},
|
|
574
|
+
changesNothing() {
|
|
575
|
+
expectThat(record.deferred &&
|
|
576
|
+
record.updated &&
|
|
577
|
+
record.followUps.length === 0 &&
|
|
578
|
+
record.callbacks.length === 1 &&
|
|
579
|
+
(record.original === null ||
|
|
580
|
+
record.original.edited_timestamp === null), 'The bot should have changed nothing (interaction.deferUpdate() alone), it answered something.');
|
|
581
|
+
return reply;
|
|
582
|
+
},
|
|
583
|
+
button(label) {
|
|
584
|
+
const found = message();
|
|
585
|
+
expectThat(found !== null, 'The bot answered with no message: there is no button to find.');
|
|
586
|
+
return found.button(label);
|
|
587
|
+
},
|
|
588
|
+
menu(placeholder) {
|
|
589
|
+
const found = message();
|
|
590
|
+
expectThat(found !== null, 'The bot answered with no message: there is no menu to find.');
|
|
591
|
+
return found.menu(placeholder);
|
|
592
|
+
},
|
|
593
|
+
};
|
|
594
|
+
const reply = Object.freeze(Object.assign(base, check(base, 'The answer', parts)));
|
|
595
|
+
return reply;
|
|
596
|
+
}
|
|
597
|
+
// ── Making things ──
|
|
598
|
+
member(name, options = {}) {
|
|
599
|
+
const existing = this.world.findUser(name);
|
|
600
|
+
if (existing)
|
|
601
|
+
return this.#member(existing.id);
|
|
602
|
+
const user = this.world.addUser(name, {
|
|
603
|
+
...(options.bot ? { bot: true } : {}),
|
|
604
|
+
locale: options.locale ?? 'en-US',
|
|
605
|
+
});
|
|
606
|
+
const roles = (options.roles ?? []).map(role => typeof role === 'string' ? this.role(role).id : role.id);
|
|
607
|
+
if (options.permissions && options.permissions.length > 0) {
|
|
608
|
+
roles.push(this.role(`${name} permissions`, { permissions: options.permissions })
|
|
609
|
+
.id);
|
|
610
|
+
}
|
|
611
|
+
this.world.addMember(user.id, {
|
|
612
|
+
roles,
|
|
613
|
+
nick: options.nickname ?? null,
|
|
614
|
+
});
|
|
615
|
+
return this.#member(user.id);
|
|
616
|
+
}
|
|
617
|
+
role(name, options = {}) {
|
|
618
|
+
for (const role of this.world.roles.values()) {
|
|
619
|
+
if (role.name === name)
|
|
620
|
+
return this.#role(role.id);
|
|
621
|
+
}
|
|
622
|
+
const role = this.world.addRole(name, {
|
|
623
|
+
permissions: new Permissions([...(options.permissions ?? [])]).bits,
|
|
624
|
+
});
|
|
625
|
+
if (this.#connected()) {
|
|
626
|
+
this.world.dispatch('GUILD_ROLE_CREATE', {
|
|
627
|
+
guild_id: this.world.guildId,
|
|
628
|
+
role,
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
return this.#role(role.id);
|
|
632
|
+
}
|
|
633
|
+
channel(name, options = {}) {
|
|
634
|
+
const existing = this.world.findChannel(name);
|
|
635
|
+
if (existing)
|
|
636
|
+
return this.#channel(existing.id);
|
|
637
|
+
const channel = this.world.addChannel(name, {
|
|
638
|
+
type: CHANNEL_TYPES[options.type ?? 'text'],
|
|
639
|
+
parentId: options.parent?.id ?? null,
|
|
640
|
+
topic: options.topic ?? null,
|
|
641
|
+
});
|
|
642
|
+
if (this.#connected())
|
|
643
|
+
this.world.dispatch('CHANNEL_CREATE', channel);
|
|
644
|
+
return this.#channel(channel.id);
|
|
645
|
+
}
|
|
646
|
+
#connected() {
|
|
647
|
+
const connection = this.world.gateway.connections.at(-1);
|
|
648
|
+
return connection !== undefined && !connection.closed;
|
|
649
|
+
}
|
|
650
|
+
// ── Doing things ──
|
|
651
|
+
/** Who does something: the member given, or someone. */
|
|
652
|
+
#actor(where) {
|
|
653
|
+
const id = where?.by?.id ?? this.someone.id;
|
|
654
|
+
const user = this.world.users.get(id);
|
|
655
|
+
if (!user)
|
|
656
|
+
throw new TestFailure(`${where?.by?.name ?? 'This person'} is not in this world.`);
|
|
657
|
+
return user;
|
|
658
|
+
}
|
|
659
|
+
/** The channel something happens in. */
|
|
660
|
+
#place(where, actor, forDm) {
|
|
661
|
+
if (where?.in === 'dm' || (where?.in === undefined && forDm)) {
|
|
662
|
+
return this.world.dm(actor.id);
|
|
663
|
+
}
|
|
664
|
+
const id = where?.in?.id ?? this.world.findChannel('general').id;
|
|
665
|
+
const channel = this.world.channels.get(id);
|
|
666
|
+
if (!channel) {
|
|
667
|
+
throw new TestFailure(`The channel ${where?.in ? `#${where.in.name}` : '#general'} does not exist any more.`);
|
|
668
|
+
}
|
|
669
|
+
return channel;
|
|
670
|
+
}
|
|
671
|
+
#token() {
|
|
672
|
+
return `token-${this.world.snowflake()}`;
|
|
673
|
+
}
|
|
674
|
+
/** An interaction as Discord would send it. */
|
|
675
|
+
#rawInteraction(type, actor, channel, data, message) {
|
|
676
|
+
const inGuild = channel.guild_id !== undefined;
|
|
677
|
+
const member = inGuild ? this.world.members.get(actor.id) : undefined;
|
|
678
|
+
if (inGuild && !member) {
|
|
679
|
+
throw new TestFailure(`${actor.username} is not in the server: they can not use anything there.`);
|
|
680
|
+
}
|
|
681
|
+
const id = this.world.snowflake();
|
|
682
|
+
return {
|
|
683
|
+
id,
|
|
684
|
+
application_id: this.world.applicationId,
|
|
685
|
+
type,
|
|
686
|
+
data,
|
|
687
|
+
...(inGuild
|
|
688
|
+
? {
|
|
689
|
+
guild_id: channel.guild_id,
|
|
690
|
+
guild: {
|
|
691
|
+
id: channel.guild_id,
|
|
692
|
+
locale: 'en-US',
|
|
693
|
+
features: [],
|
|
694
|
+
},
|
|
695
|
+
member: {
|
|
696
|
+
...member,
|
|
697
|
+
permissions: this.world.permissionsOf(actor.id).toString(),
|
|
698
|
+
},
|
|
699
|
+
guild_locale: 'en-US',
|
|
700
|
+
app_permissions: this.world
|
|
701
|
+
.permissionsOf(this.world.botId)
|
|
702
|
+
.toString(),
|
|
703
|
+
context: 0,
|
|
704
|
+
authorizing_integration_owners: { '0': channel.guild_id },
|
|
705
|
+
}
|
|
706
|
+
: {
|
|
707
|
+
user: actor,
|
|
708
|
+
app_permissions: '0',
|
|
709
|
+
context: 1,
|
|
710
|
+
authorizing_integration_owners: { '1': actor.id },
|
|
711
|
+
}),
|
|
712
|
+
channel_id: channel.id,
|
|
713
|
+
channel: {
|
|
714
|
+
id: channel.id,
|
|
715
|
+
type: channel.type,
|
|
716
|
+
...(channel.name ? { name: channel.name } : {}),
|
|
717
|
+
},
|
|
718
|
+
token: this.#token(),
|
|
719
|
+
version: 1,
|
|
720
|
+
...(message ? { message } : {}),
|
|
721
|
+
locale: actor.locale ?? 'en-US',
|
|
722
|
+
entitlements: [],
|
|
723
|
+
attachment_size_limit: 10 * 1024 * 1024,
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
/** Makes the interaction happen and waits for the bot to be done with it. */
|
|
727
|
+
async #interact(raw) {
|
|
728
|
+
const waiting = this.#observer.interaction(raw.id, this.#timeout);
|
|
729
|
+
const record = this.world.interaction(raw);
|
|
730
|
+
await waiting;
|
|
731
|
+
await this.#observer.settle();
|
|
732
|
+
return record;
|
|
733
|
+
}
|
|
734
|
+
#commandEntry(declaration) {
|
|
735
|
+
const run = declaration?.config?.run;
|
|
736
|
+
const entry = [...this.project.commands.values()].find(one => one.command.run === run);
|
|
737
|
+
if (!entry) {
|
|
738
|
+
throw new TestFailure(`This command is not loaded: is it exported from a file of src/ (export const ping = command({ ... })), and did its file load without error?`);
|
|
739
|
+
}
|
|
740
|
+
return entry;
|
|
741
|
+
}
|
|
742
|
+
async use(command, ...args) {
|
|
743
|
+
const [given = {}, where] = args;
|
|
744
|
+
const entry = this.#commandEntry(command);
|
|
745
|
+
const actor = this.#actor(where);
|
|
746
|
+
const channel = this.#place(where, actor, entry.command.where === 'dm');
|
|
747
|
+
const { options, resolved } = this.#commandOptions(entry, given, channel);
|
|
748
|
+
const raw = this.#rawInteraction(InteractionType.ApplicationCommand, actor, channel, this.#commandData(entry, options, resolved));
|
|
749
|
+
return this.#reply(await this.#interact(raw));
|
|
750
|
+
}
|
|
751
|
+
async suggest(command, option, typed, where) {
|
|
752
|
+
const entry = this.#commandEntry(command);
|
|
753
|
+
const declared = entry.command.options.find(one => one.name === option);
|
|
754
|
+
if (!declared) {
|
|
755
|
+
throw new TestFailure(`${commandName(entry.command.path)} has no option "${option}": its options are ${entry.command.options.map(one => one.name).join(', ') || 'none'}.`);
|
|
756
|
+
}
|
|
757
|
+
const actor = this.#actor(where);
|
|
758
|
+
const channel = this.#place(where, actor, entry.command.where === 'dm');
|
|
759
|
+
const isNumber = declared.type === 'integer' || declared.type === 'number';
|
|
760
|
+
const value = isNumber && typed !== '' && Number.isFinite(Number(typed))
|
|
761
|
+
? Number(typed)
|
|
762
|
+
: typed;
|
|
763
|
+
const focused = {
|
|
764
|
+
name: option,
|
|
765
|
+
type: OPTION_TYPES[declared.type],
|
|
766
|
+
value,
|
|
767
|
+
focused: true,
|
|
768
|
+
};
|
|
769
|
+
const raw = this.#rawInteraction(InteractionType.ApplicationCommandAutocomplete, actor, channel, this.#commandData(entry, [focused], {}));
|
|
770
|
+
const record = await this.#interact(raw);
|
|
771
|
+
return (record.suggestions ?? []).map(choice => ({
|
|
772
|
+
name: choice.name,
|
|
773
|
+
value: choice.value,
|
|
774
|
+
}));
|
|
775
|
+
}
|
|
776
|
+
/** The `data` of a command interaction, subcommands nested as Discord does. */
|
|
777
|
+
#commandData(entry, options, resolved) {
|
|
778
|
+
const [name, ...rest] = entry.command.path;
|
|
779
|
+
let nested = options;
|
|
780
|
+
for (const part of [...rest].reverse()) {
|
|
781
|
+
nested = [
|
|
782
|
+
{
|
|
783
|
+
name: part,
|
|
784
|
+
type: rest.indexOf(part) === 0 && rest.length === 2
|
|
785
|
+
? ApplicationCommandOptionType.SubCommandGroup
|
|
786
|
+
: ApplicationCommandOptionType.SubCommand,
|
|
787
|
+
options: nested,
|
|
788
|
+
},
|
|
789
|
+
];
|
|
790
|
+
}
|
|
791
|
+
const registered = this.world.guildCommands.find(command => command.name === name);
|
|
792
|
+
return {
|
|
793
|
+
id: registered?.id ?? this.world.snowflake(),
|
|
794
|
+
name: name,
|
|
795
|
+
type: ApplicationCommandType.ChatInput,
|
|
796
|
+
options: nested,
|
|
797
|
+
...(Object.keys(resolved).length > 0 ? { resolved } : {}),
|
|
798
|
+
guild_id: this.world.guildId,
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
/** The options as Discord sends them, checked as its client would. */
|
|
802
|
+
#commandOptions(entry, given, channel) {
|
|
803
|
+
const name = commandName(entry.command.path);
|
|
804
|
+
const declared = entry.command.options;
|
|
805
|
+
for (const key of Object.keys(given)) {
|
|
806
|
+
if (!declared.some(option => option.name === key)) {
|
|
807
|
+
throw new TestFailure(`${name} has no option "${key}": its options are ${declared.map(option => option.name).join(', ') || 'none'}.`);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
const options = [];
|
|
811
|
+
const resolved = {};
|
|
812
|
+
for (const option of declared) {
|
|
813
|
+
const value = given[option.name];
|
|
814
|
+
if (value === undefined) {
|
|
815
|
+
if (option.required) {
|
|
816
|
+
throw new TestFailure(`The option "${option.name}" of ${name} is required: Discord would not let the command be sent without it.`);
|
|
817
|
+
}
|
|
818
|
+
continue;
|
|
819
|
+
}
|
|
820
|
+
const what = `The option "${option.name}" of ${name}`;
|
|
821
|
+
options.push({
|
|
822
|
+
name: option.name,
|
|
823
|
+
type: OPTION_TYPES[option.type],
|
|
824
|
+
value: this.#optionValue(what, option, value, resolved, channel),
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
return { options, resolved };
|
|
828
|
+
}
|
|
829
|
+
#optionValue(what, option, value, resolved, channel) {
|
|
830
|
+
const choices = 'choices' in option && option.choices
|
|
831
|
+
? Array.isArray(option.choices)
|
|
832
|
+
? [...option.choices]
|
|
833
|
+
: Object.values(option.choices)
|
|
834
|
+
: null;
|
|
835
|
+
if (choices && !choices.includes(value)) {
|
|
836
|
+
throw new TestFailure(`${what} must be one of its choices (${choices.map(shown).join(', ')}), got ${shown(value)}.`);
|
|
837
|
+
}
|
|
838
|
+
switch (option.type) {
|
|
839
|
+
case 'string': {
|
|
840
|
+
if (typeof value !== 'string') {
|
|
841
|
+
throw new TestFailure(`${what} is a text, got ${shown(value)}.`);
|
|
842
|
+
}
|
|
843
|
+
if (option.minLength !== undefined && value.length < option.minLength) {
|
|
844
|
+
throw new TestFailure(`${what} is ${option.minLength} characters at least, got ${value.length}.`);
|
|
845
|
+
}
|
|
846
|
+
if (option.maxLength !== undefined && value.length > option.maxLength) {
|
|
847
|
+
throw new TestFailure(`${what} is ${option.maxLength} characters at most, got ${value.length}.`);
|
|
848
|
+
}
|
|
849
|
+
return value;
|
|
850
|
+
}
|
|
851
|
+
case 'integer':
|
|
852
|
+
case 'number': {
|
|
853
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
854
|
+
throw new TestFailure(`${what} is a number, got ${shown(value)}.`);
|
|
855
|
+
}
|
|
856
|
+
if (option.type === 'integer' && !Number.isInteger(value)) {
|
|
857
|
+
throw new TestFailure(`${what} is a whole number, got ${value}.`);
|
|
858
|
+
}
|
|
859
|
+
if (option.min !== undefined && value < option.min) {
|
|
860
|
+
throw new TestFailure(`${what} is ${option.min} at least, got ${value}.`);
|
|
861
|
+
}
|
|
862
|
+
if (option.max !== undefined && value > option.max) {
|
|
863
|
+
throw new TestFailure(`${what} is ${option.max} at most, got ${value}.`);
|
|
864
|
+
}
|
|
865
|
+
return value;
|
|
866
|
+
}
|
|
867
|
+
case 'boolean':
|
|
868
|
+
if (typeof value !== 'boolean') {
|
|
869
|
+
throw new TestFailure(`${what} is true or false, got ${shown(value)}.`);
|
|
870
|
+
}
|
|
871
|
+
return value;
|
|
872
|
+
case 'user':
|
|
873
|
+
return this.#resolveMember(what, value, resolved, channel);
|
|
874
|
+
case 'role':
|
|
875
|
+
return this.#resolveRole(what, value, resolved);
|
|
876
|
+
case 'mentionable':
|
|
877
|
+
return this.#isMember(value)
|
|
878
|
+
? this.#resolveMember(what, value, resolved, channel)
|
|
879
|
+
: this.#resolveRole(what, value, resolved);
|
|
880
|
+
case 'channel':
|
|
881
|
+
return this.#resolveChannel(what, value, resolved);
|
|
882
|
+
case 'attachment':
|
|
883
|
+
return this.#resolveFile(what, value, resolved);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
#isMember(value) {
|
|
887
|
+
return typeof value === 'object' && value !== null && 'inServer' in value;
|
|
888
|
+
}
|
|
889
|
+
#resolveMember(what, value, resolved, channel) {
|
|
890
|
+
if (!this.#isMember(value)) {
|
|
891
|
+
throw new TestFailure(`${what} is a person (bot.member('alice')), got ${shown(value)}.`);
|
|
892
|
+
}
|
|
893
|
+
const user = this.world.users.get(value.id);
|
|
894
|
+
if (!user)
|
|
895
|
+
throw new TestFailure(`${what}: ${value.name} is not in this world.`);
|
|
896
|
+
(resolved.users ??= {})[user.id] = user;
|
|
897
|
+
const member = channel.guild_id
|
|
898
|
+
? this.world.members.get(user.id)
|
|
899
|
+
: undefined;
|
|
900
|
+
if (member) {
|
|
901
|
+
const { user: _user, ...rest } = member;
|
|
902
|
+
(resolved.members ??= {})[user.id] = {
|
|
903
|
+
...rest,
|
|
904
|
+
permissions: this.world.permissionsOf(user.id).toString(),
|
|
905
|
+
};
|
|
906
|
+
}
|
|
907
|
+
return user.id;
|
|
908
|
+
}
|
|
909
|
+
#resolveRole(what, value, resolved) {
|
|
910
|
+
if (typeof value !== 'object' ||
|
|
911
|
+
value === null ||
|
|
912
|
+
!('exists' in value) ||
|
|
913
|
+
'inServer' in value ||
|
|
914
|
+
'messages' in value) {
|
|
915
|
+
throw new TestFailure(`${what} is a role (bot.role('Moderator')), got ${shown(value)}.`);
|
|
916
|
+
}
|
|
917
|
+
const role = this.world.roles.get(value.id);
|
|
918
|
+
if (!role)
|
|
919
|
+
throw new TestFailure(`${what}: that role does not exist any more.`);
|
|
920
|
+
(resolved.roles ??= {})[role.id] = role;
|
|
921
|
+
return role.id;
|
|
922
|
+
}
|
|
923
|
+
#resolveChannel(what, value, resolved) {
|
|
924
|
+
if (typeof value !== 'object' || value === null || !('messages' in value)) {
|
|
925
|
+
throw new TestFailure(`${what} is a channel (bot.channel('general')), got ${shown(value)}.`);
|
|
926
|
+
}
|
|
927
|
+
const channel = this.world.channels.get(value.id);
|
|
928
|
+
if (!channel)
|
|
929
|
+
throw new TestFailure(`${what}: that channel does not exist any more.`);
|
|
930
|
+
(resolved.channels ??= {})[channel.id] = {
|
|
931
|
+
id: channel.id,
|
|
932
|
+
type: channel.type,
|
|
933
|
+
name: channel.name,
|
|
934
|
+
...(channel.guild_id ? { guild_id: channel.guild_id } : {}),
|
|
935
|
+
...(channel.parent_id !== undefined
|
|
936
|
+
? { parent_id: channel.parent_id }
|
|
937
|
+
: {}),
|
|
938
|
+
permissions: this.world.permissionsOf(this.world.botId).toString(),
|
|
939
|
+
...(channel.thread_metadata
|
|
940
|
+
? { thread_metadata: channel.thread_metadata }
|
|
941
|
+
: {}),
|
|
942
|
+
};
|
|
943
|
+
return channel.id;
|
|
944
|
+
}
|
|
945
|
+
#resolveFile(what, value, resolved) {
|
|
946
|
+
const file = value;
|
|
947
|
+
if (typeof file !== 'object' ||
|
|
948
|
+
file === null ||
|
|
949
|
+
typeof file.name !== 'string') {
|
|
950
|
+
throw new TestFailure(`${what} is a file ({ name: 'photo.png', content: '...' }), got ${shown(value)}.`);
|
|
951
|
+
}
|
|
952
|
+
const attachment = {
|
|
953
|
+
id: this.world.snowflake(),
|
|
954
|
+
filename: file.name,
|
|
955
|
+
size: Buffer.byteLength(file.content ?? ''),
|
|
956
|
+
url: `https://cdn.discordapp.test/${file.name}`,
|
|
957
|
+
proxy_url: `https://media.discordapp.test/${file.name}`,
|
|
958
|
+
...(file.type ? { content_type: file.type } : {}),
|
|
959
|
+
};
|
|
960
|
+
(resolved.attachments ??= {})[attachment.id] = attachment;
|
|
961
|
+
return attachment.id;
|
|
962
|
+
}
|
|
963
|
+
/** The id and kind of a component, from how the test named it. */
|
|
964
|
+
#customIdOf(target, expected) {
|
|
965
|
+
if (typeof target === 'object' && target !== null && 'customId' in target) {
|
|
966
|
+
const id = target.customId;
|
|
967
|
+
if (typeof id !== 'string') {
|
|
968
|
+
throw new TestFailure('This button has no id: it is a link or a premium button, which the bot never hears of.');
|
|
969
|
+
}
|
|
970
|
+
return id;
|
|
971
|
+
}
|
|
972
|
+
const state = componentStateOf(target);
|
|
973
|
+
if (state && !state.bound) {
|
|
974
|
+
throw new TestFailure(`This ${state.kind} is not loaded: is it exported from a file of src/, and did its file load without error?`);
|
|
975
|
+
}
|
|
976
|
+
if (!isPiece(target)) {
|
|
977
|
+
throw new TestFailure(`Give the ${expected === 'modal' ? 'form' : expected === 'select' ? 'menu' : 'button'} itself (its export, called with its data when it has some) or one found on an answer (reply.button('Confirm')), got ${shown(target)}.`);
|
|
978
|
+
}
|
|
979
|
+
const rendered = renderedOf(target, 'The component given to the test');
|
|
980
|
+
if (rendered.kind !== expected) {
|
|
981
|
+
throw new TestFailure(`Expected a ${expected === 'modal' ? 'form' : expected === 'select' ? 'menu' : 'button'}, got a ${rendered.kind}.`);
|
|
982
|
+
}
|
|
983
|
+
const raw = resolve(rendered, translatorOf(this.bot.ctx, { person: 'en-US', ephemeral: true })).raw;
|
|
984
|
+
if (typeof raw.custom_id !== 'string') {
|
|
985
|
+
throw new TestFailure('This component has no id the bot could hear of.');
|
|
986
|
+
}
|
|
987
|
+
return raw.custom_id;
|
|
988
|
+
}
|
|
989
|
+
#componentEntry(kind, customId) {
|
|
990
|
+
const name = customId.split(':')[0];
|
|
991
|
+
return [...this.project.components.values()].find(entry => entry.component.kind === kind && entry.component.name === name);
|
|
992
|
+
}
|
|
993
|
+
/** The message a component is on: the one given, or one of the bot. */
|
|
994
|
+
#messageFor(where, actor, channel, component) {
|
|
995
|
+
const given = where?.on;
|
|
996
|
+
if (given) {
|
|
997
|
+
const id = 'message' in given ? given.message?.id : given.id;
|
|
998
|
+
const found = id ? this.world.findMessage(id) : undefined;
|
|
999
|
+
if (!found) {
|
|
1000
|
+
throw new TestFailure('message' in given
|
|
1001
|
+
? 'The answer given in "on" has no message: the bot answered with none.'
|
|
1002
|
+
: 'The message given in "on" was deleted.');
|
|
1003
|
+
}
|
|
1004
|
+
return found;
|
|
1005
|
+
}
|
|
1006
|
+
// Not sent: a message of the bot the person is looking at.
|
|
1007
|
+
return {
|
|
1008
|
+
id: this.world.snowflake(),
|
|
1009
|
+
channel_id: channel.id,
|
|
1010
|
+
author: this.world.bot,
|
|
1011
|
+
content: '',
|
|
1012
|
+
timestamp: new Date().toISOString(),
|
|
1013
|
+
edited_timestamp: null,
|
|
1014
|
+
tts: false,
|
|
1015
|
+
mention_everyone: false,
|
|
1016
|
+
mentions: [],
|
|
1017
|
+
mention_roles: [],
|
|
1018
|
+
attachments: [],
|
|
1019
|
+
embeds: [],
|
|
1020
|
+
pinned: false,
|
|
1021
|
+
type: MessageType.Default,
|
|
1022
|
+
flags: 0,
|
|
1023
|
+
components: [
|
|
1024
|
+
{ type: ComponentType.ActionRow, components: [component] },
|
|
1025
|
+
],
|
|
1026
|
+
interaction_metadata: {
|
|
1027
|
+
id: this.world.snowflake(),
|
|
1028
|
+
type: InteractionType.ApplicationCommand,
|
|
1029
|
+
user: actor,
|
|
1030
|
+
authorizing_integration_owners: {},
|
|
1031
|
+
},
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
1034
|
+
async click(button, where) {
|
|
1035
|
+
const customId = this.#customIdOf(button, 'button');
|
|
1036
|
+
const entry = this.#componentEntry('button', customId);
|
|
1037
|
+
const actor = this.#actor(where);
|
|
1038
|
+
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
|
+
});
|
|
1044
|
+
const raw = this.#rawInteraction(InteractionType.MessageComponent, actor, channel, { custom_id: customId, component_type: ComponentType.Button }, message);
|
|
1045
|
+
return this.#reply(await this.#interact(raw));
|
|
1046
|
+
}
|
|
1047
|
+
async select(menu, values, where) {
|
|
1048
|
+
const customId = this.#customIdOf(menu, 'select');
|
|
1049
|
+
const entry = this.#componentEntry('select', customId);
|
|
1050
|
+
const actor = this.#actor(where);
|
|
1051
|
+
const channel = this.#place(where, actor, entry?.component.kind === 'select' && entry.component.where === 'dm');
|
|
1052
|
+
const type = typeof menu === 'object' && menu !== null && 'customId' in menu
|
|
1053
|
+
? menu.type
|
|
1054
|
+
: entry?.component.kind === 'select'
|
|
1055
|
+
? entry.component.type
|
|
1056
|
+
: 'string';
|
|
1057
|
+
const resolved = {};
|
|
1058
|
+
const what = `What is picked in ${customId.split(':')[0]}`;
|
|
1059
|
+
const picked = values.map(value => {
|
|
1060
|
+
if (type === 'string') {
|
|
1061
|
+
if (typeof value !== 'string') {
|
|
1062
|
+
throw new TestFailure(`${what} is a text (the value of an option), got ${shown(value)}.`);
|
|
1063
|
+
}
|
|
1064
|
+
return value;
|
|
1065
|
+
}
|
|
1066
|
+
if (type === 'user')
|
|
1067
|
+
return this.#resolveMember(what, value, resolved, channel);
|
|
1068
|
+
if (type === 'role')
|
|
1069
|
+
return this.#resolveRole(what, value, resolved);
|
|
1070
|
+
if (type === 'channel')
|
|
1071
|
+
return this.#resolveChannel(what, value, resolved);
|
|
1072
|
+
return this.#isMember(value)
|
|
1073
|
+
? this.#resolveMember(what, value, resolved, channel)
|
|
1074
|
+
: this.#resolveRole(what, value, resolved);
|
|
1075
|
+
});
|
|
1076
|
+
if (entry?.component.kind === 'select') {
|
|
1077
|
+
const { min, max, options, name } = entry.component;
|
|
1078
|
+
if (picked.length < min || picked.length > max) {
|
|
1079
|
+
throw new TestFailure(`${name} takes ${min === max ? min : `${min} to ${max}`} value(s): Discord would not let ${picked.length} be sent.`);
|
|
1080
|
+
}
|
|
1081
|
+
if (type === 'string' && options.length > 0) {
|
|
1082
|
+
const allowed = options.map(option => option.value);
|
|
1083
|
+
for (const value of picked) {
|
|
1084
|
+
if (!allowed.includes(value)) {
|
|
1085
|
+
throw new TestFailure(`${name} has no option ${shown(value)}: its options are ${allowed.map(shown).join(', ')}.`);
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
const componentType = SELECT_COMPONENT_TYPES[type] ?? ComponentType.StringSelect;
|
|
1091
|
+
const message = this.#messageFor(where, actor, channel, {
|
|
1092
|
+
type: componentType,
|
|
1093
|
+
custom_id: customId,
|
|
1094
|
+
...(componentType === ComponentType.StringSelect ? { options: [] } : {}),
|
|
1095
|
+
});
|
|
1096
|
+
const raw = this.#rawInteraction(InteractionType.MessageComponent, actor, channel, {
|
|
1097
|
+
custom_id: customId,
|
|
1098
|
+
component_type: componentType,
|
|
1099
|
+
values: picked,
|
|
1100
|
+
...(Object.keys(resolved).length > 0 ? { resolved } : {}),
|
|
1101
|
+
}, message);
|
|
1102
|
+
return this.#reply(await this.#interact(raw));
|
|
1103
|
+
}
|
|
1104
|
+
async submit(form, fields = {}, where) {
|
|
1105
|
+
const customId = this.#customIdOf(form, 'modal');
|
|
1106
|
+
const entry = this.#componentEntry('modal', customId);
|
|
1107
|
+
if (!entry || entry.component.kind !== 'modal') {
|
|
1108
|
+
throw new TestFailure(`The form ${customId.split(':')[0]} is not loaded: is it exported from a file of src/, and did its file load without error?`);
|
|
1109
|
+
}
|
|
1110
|
+
const modal = entry.component;
|
|
1111
|
+
const actor = this.#actor(where);
|
|
1112
|
+
const channel = this.#place(where, actor, modal.where === 'dm');
|
|
1113
|
+
const given = fields;
|
|
1114
|
+
for (const key of Object.keys(given)) {
|
|
1115
|
+
if (!modal.fields.some(field => field.name === key && field.field.type !== 'note')) {
|
|
1116
|
+
throw new TestFailure(`The form ${modal.name} has no field "${key}": its fields are ${modal.fields
|
|
1117
|
+
.filter(field => field.field.type !== 'note')
|
|
1118
|
+
.map(field => field.name)
|
|
1119
|
+
.join(', ')}.`);
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
const resolved = {};
|
|
1123
|
+
const components = [];
|
|
1124
|
+
let id = 1;
|
|
1125
|
+
for (const loaded of modal.fields) {
|
|
1126
|
+
const { name, field, required, min, max } = loaded;
|
|
1127
|
+
if (field.type === 'note')
|
|
1128
|
+
continue;
|
|
1129
|
+
const what = `The field "${name}" of ${modal.name}`;
|
|
1130
|
+
const value = given[name];
|
|
1131
|
+
const count = (list) => {
|
|
1132
|
+
if (required && list.length === 0) {
|
|
1133
|
+
throw new TestFailure(`${what} is required: Discord would not let the form be sent empty.`);
|
|
1134
|
+
}
|
|
1135
|
+
if (list.length > 0 && (list.length < min || list.length > max)) {
|
|
1136
|
+
throw new TestFailure(`${what} takes ${min === max ? min : `${min} to ${max}`} value(s), got ${list.length}.`);
|
|
1137
|
+
}
|
|
1138
|
+
};
|
|
1139
|
+
let component;
|
|
1140
|
+
switch (field.type) {
|
|
1141
|
+
case 'text': {
|
|
1142
|
+
const text = value === undefined ? '' : value;
|
|
1143
|
+
if (typeof text !== 'string')
|
|
1144
|
+
throw new TestFailure(`${what} is a text, got ${shown(value)}.`);
|
|
1145
|
+
if (required && text === '') {
|
|
1146
|
+
throw new TestFailure(`${what} is required: Discord would not let the form be sent empty.`);
|
|
1147
|
+
}
|
|
1148
|
+
if (text !== '' && (text.length < min || text.length > max)) {
|
|
1149
|
+
throw new TestFailure(`${what} is ${min} to ${max} characters, got ${text.length}.`);
|
|
1150
|
+
}
|
|
1151
|
+
component = {
|
|
1152
|
+
type: ComponentType.TextInput,
|
|
1153
|
+
id: id++,
|
|
1154
|
+
custom_id: name,
|
|
1155
|
+
value: text,
|
|
1156
|
+
};
|
|
1157
|
+
break;
|
|
1158
|
+
}
|
|
1159
|
+
case 'select':
|
|
1160
|
+
case 'checkboxes': {
|
|
1161
|
+
const list = value === undefined ? [] : value;
|
|
1162
|
+
if (!Array.isArray(list) ||
|
|
1163
|
+
list.some(one => typeof one !== 'string')) {
|
|
1164
|
+
throw new TestFailure(`${what} is a list of option values, got ${shown(value)}.`);
|
|
1165
|
+
}
|
|
1166
|
+
const allowed = loaded.options.map(option => option.value);
|
|
1167
|
+
for (const one of list) {
|
|
1168
|
+
if (!allowed.includes(one)) {
|
|
1169
|
+
throw new TestFailure(`${what} has no option ${shown(one)}: its options are ${allowed.map(shown).join(', ')}.`);
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
count(list);
|
|
1173
|
+
component = {
|
|
1174
|
+
type: field.type === 'select'
|
|
1175
|
+
? ComponentType.StringSelect
|
|
1176
|
+
: ComponentType.CheckboxGroup,
|
|
1177
|
+
id: id++,
|
|
1178
|
+
custom_id: name,
|
|
1179
|
+
values: list,
|
|
1180
|
+
};
|
|
1181
|
+
break;
|
|
1182
|
+
}
|
|
1183
|
+
case 'radio': {
|
|
1184
|
+
if (value === undefined) {
|
|
1185
|
+
if (required)
|
|
1186
|
+
throw new TestFailure(`${what} is required: Discord would not let the form be sent without a choice.`);
|
|
1187
|
+
component = {
|
|
1188
|
+
type: ComponentType.RadioGroup,
|
|
1189
|
+
id: id++,
|
|
1190
|
+
custom_id: name,
|
|
1191
|
+
value: null,
|
|
1192
|
+
};
|
|
1193
|
+
break;
|
|
1194
|
+
}
|
|
1195
|
+
const allowed = loaded.options.map(option => option.value);
|
|
1196
|
+
if (typeof value !== 'string' || !allowed.includes(value)) {
|
|
1197
|
+
throw new TestFailure(`${what} is one of ${allowed.map(shown).join(', ')}, got ${shown(value)}.`);
|
|
1198
|
+
}
|
|
1199
|
+
component = {
|
|
1200
|
+
type: ComponentType.RadioGroup,
|
|
1201
|
+
id: id++,
|
|
1202
|
+
custom_id: name,
|
|
1203
|
+
value,
|
|
1204
|
+
};
|
|
1205
|
+
break;
|
|
1206
|
+
}
|
|
1207
|
+
case 'checkbox':
|
|
1208
|
+
if (value !== undefined && typeof value !== 'boolean') {
|
|
1209
|
+
throw new TestFailure(`${what} is true or false, got ${shown(value)}.`);
|
|
1210
|
+
}
|
|
1211
|
+
component = {
|
|
1212
|
+
type: ComponentType.Checkbox,
|
|
1213
|
+
id: id++,
|
|
1214
|
+
custom_id: name,
|
|
1215
|
+
value: value === true,
|
|
1216
|
+
};
|
|
1217
|
+
break;
|
|
1218
|
+
case 'files': {
|
|
1219
|
+
const list = value === undefined ? [] : value;
|
|
1220
|
+
if (!Array.isArray(list))
|
|
1221
|
+
throw new TestFailure(`${what} is a list of files, got ${shown(value)}.`);
|
|
1222
|
+
count(list);
|
|
1223
|
+
component = {
|
|
1224
|
+
type: ComponentType.FileUpload,
|
|
1225
|
+
id: id++,
|
|
1226
|
+
custom_id: name,
|
|
1227
|
+
values: list.map(file => this.#resolveFile(what, file, resolved)),
|
|
1228
|
+
};
|
|
1229
|
+
break;
|
|
1230
|
+
}
|
|
1231
|
+
default: {
|
|
1232
|
+
const list = value === undefined ? [] : value;
|
|
1233
|
+
if (!Array.isArray(list))
|
|
1234
|
+
throw new TestFailure(`${what} is a list, got ${shown(value)}.`);
|
|
1235
|
+
count(list);
|
|
1236
|
+
const picked = list.map(one => field.type === 'user'
|
|
1237
|
+
? this.#resolveMember(what, one, resolved, channel)
|
|
1238
|
+
: field.type === 'role'
|
|
1239
|
+
? this.#resolveRole(what, one, resolved)
|
|
1240
|
+
: field.type === 'channel'
|
|
1241
|
+
? this.#resolveChannel(what, one, resolved)
|
|
1242
|
+
: this.#isMember(one)
|
|
1243
|
+
? this.#resolveMember(what, one, resolved, channel)
|
|
1244
|
+
: this.#resolveRole(what, one, resolved));
|
|
1245
|
+
component = {
|
|
1246
|
+
type: SELECT_COMPONENT_TYPES[field.type],
|
|
1247
|
+
id: id++,
|
|
1248
|
+
custom_id: name,
|
|
1249
|
+
values: picked,
|
|
1250
|
+
resolved,
|
|
1251
|
+
};
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
components.push({
|
|
1255
|
+
type: ComponentType.Label,
|
|
1256
|
+
id: id++,
|
|
1257
|
+
component,
|
|
1258
|
+
});
|
|
1259
|
+
}
|
|
1260
|
+
const opened = where?.on
|
|
1261
|
+
? this.#messageFor(where, actor, channel, {
|
|
1262
|
+
type: ComponentType.Button,
|
|
1263
|
+
style: 2,
|
|
1264
|
+
custom_id: 'opened',
|
|
1265
|
+
})
|
|
1266
|
+
: undefined;
|
|
1267
|
+
const raw = this.#rawInteraction(InteractionType.ModalSubmit, actor, channel, {
|
|
1268
|
+
custom_id: customId,
|
|
1269
|
+
components,
|
|
1270
|
+
...(Object.keys(resolved).length > 0 ? { resolved } : {}),
|
|
1271
|
+
}, opened);
|
|
1272
|
+
return this.#reply(await this.#interact(raw));
|
|
1273
|
+
}
|
|
1274
|
+
async say(content, where) {
|
|
1275
|
+
const actor = this.#actor(where);
|
|
1276
|
+
const channel = this.#place(where, actor, false);
|
|
1277
|
+
if (channel.guild_id && !this.world.members.has(actor.id)) {
|
|
1278
|
+
throw new TestFailure(`${actor.username} is not in the server: they can not write there.`);
|
|
1279
|
+
}
|
|
1280
|
+
const id = this.world.snowflake();
|
|
1281
|
+
const done = this.#observer.event((type, data) => type === 'MESSAGE_CREATE' && data.id === id, this.#timeout);
|
|
1282
|
+
const message = this.world.createMessage(channel.id, actor, { content }, {}, {
|
|
1283
|
+
id,
|
|
1284
|
+
});
|
|
1285
|
+
await done;
|
|
1286
|
+
await this.#observer.settle();
|
|
1287
|
+
return this.#message(message);
|
|
1288
|
+
}
|
|
1289
|
+
async react(message, emoji, where) {
|
|
1290
|
+
const actor = this.#actor(where);
|
|
1291
|
+
const raw = this.world.findMessage(message.id);
|
|
1292
|
+
if (!raw)
|
|
1293
|
+
throw new TestFailure('That message was deleted.');
|
|
1294
|
+
const channel = this.world.channels.get(raw.channel_id);
|
|
1295
|
+
const [name, id] = emoji.includes(':')
|
|
1296
|
+
? emoji.split(':')
|
|
1297
|
+
: [emoji, undefined];
|
|
1298
|
+
const done = this.#observer.event((type, data) => type === 'MESSAGE_REACTION_ADD' &&
|
|
1299
|
+
data.message_id === raw.id, this.#timeout);
|
|
1300
|
+
this.world.dispatch('MESSAGE_REACTION_ADD', {
|
|
1301
|
+
user_id: actor.id,
|
|
1302
|
+
channel_id: raw.channel_id,
|
|
1303
|
+
message_id: raw.id,
|
|
1304
|
+
...(channel.guild_id
|
|
1305
|
+
? {
|
|
1306
|
+
guild_id: channel.guild_id,
|
|
1307
|
+
member: this.world.members.get(actor.id),
|
|
1308
|
+
}
|
|
1309
|
+
: {}),
|
|
1310
|
+
emoji: { id: id ?? null, name },
|
|
1311
|
+
burst: false,
|
|
1312
|
+
type: 0,
|
|
1313
|
+
});
|
|
1314
|
+
await done;
|
|
1315
|
+
await this.#observer.settle();
|
|
1316
|
+
}
|
|
1317
|
+
async join(member, options) {
|
|
1318
|
+
let user;
|
|
1319
|
+
if (typeof member === 'string') {
|
|
1320
|
+
user =
|
|
1321
|
+
this.world.findUser(member) ??
|
|
1322
|
+
this.world.addUser(member, {
|
|
1323
|
+
...(options?.bot ? { bot: true } : {}),
|
|
1324
|
+
locale: options?.locale ?? 'en-US',
|
|
1325
|
+
});
|
|
1326
|
+
}
|
|
1327
|
+
else {
|
|
1328
|
+
user = this.world.users.get(member.id);
|
|
1329
|
+
}
|
|
1330
|
+
if (!user)
|
|
1331
|
+
throw new TestFailure('That person is not in this world.');
|
|
1332
|
+
if (this.world.members.has(user.id)) {
|
|
1333
|
+
throw new TestFailure(`${user.username} is already in the server.`);
|
|
1334
|
+
}
|
|
1335
|
+
if (this.world.bans.has(user.id)) {
|
|
1336
|
+
throw new TestFailure(`${user.username} is banned: Discord would not let them join.`);
|
|
1337
|
+
}
|
|
1338
|
+
const roles = (options?.roles ?? []).map(role => typeof role === 'string' ? this.role(role).id : role.id);
|
|
1339
|
+
if (options?.permissions && options.permissions.length > 0) {
|
|
1340
|
+
roles.push(this.role(`${user.username} permissions`, {
|
|
1341
|
+
permissions: options.permissions,
|
|
1342
|
+
}).id);
|
|
1343
|
+
}
|
|
1344
|
+
const raw = this.world.addMember(user.id, {
|
|
1345
|
+
roles,
|
|
1346
|
+
nick: options?.nickname ?? null,
|
|
1347
|
+
});
|
|
1348
|
+
const done = this.#observer.event((type, data) => type === 'GUILD_MEMBER_ADD' &&
|
|
1349
|
+
data.user?.id === user.id, this.#timeout);
|
|
1350
|
+
this.world.dispatch('GUILD_MEMBER_ADD', {
|
|
1351
|
+
...raw,
|
|
1352
|
+
guild_id: this.world.guildId,
|
|
1353
|
+
});
|
|
1354
|
+
await done;
|
|
1355
|
+
await this.#observer.settle();
|
|
1356
|
+
return this.#member(user.id);
|
|
1357
|
+
}
|
|
1358
|
+
async leave(member) {
|
|
1359
|
+
if (!this.world.members.has(member.id)) {
|
|
1360
|
+
throw new TestFailure(`${member.name} is not in the server.`);
|
|
1361
|
+
}
|
|
1362
|
+
const done = this.#observer.event((type, data) => type === 'GUILD_MEMBER_REMOVE' &&
|
|
1363
|
+
data.user.id === member.id, this.#timeout);
|
|
1364
|
+
this.world.removeMember(member.id);
|
|
1365
|
+
await done;
|
|
1366
|
+
await this.#observer.settle();
|
|
1367
|
+
}
|
|
1368
|
+
async run(task, at = new Date()) {
|
|
1369
|
+
const run = task?.config?.run;
|
|
1370
|
+
const entry = [...this.project.tasks.values()].find(one => one.task.run === run);
|
|
1371
|
+
if (!entry) {
|
|
1372
|
+
throw new TestFailure('This task is not loaded: is it exported from a file of src/ (export const weekly = task({ ... })), and did its file load without error?');
|
|
1373
|
+
}
|
|
1374
|
+
await this.project.runTask(entry, this.bot, at);
|
|
1375
|
+
await this.#observer.settle();
|
|
1376
|
+
}
|
|
1377
|
+
settle() {
|
|
1378
|
+
return this.#observer.settle();
|
|
1379
|
+
}
|
|
1380
|
+
/**
|
|
1381
|
+
* Sends a gateway event to the bot and waits for the files to be done
|
|
1382
|
+
* with it. For the checks of `chapterjs test`, not for scenarios.
|
|
1383
|
+
*/
|
|
1384
|
+
async emit(type, data) {
|
|
1385
|
+
const done = this.#observer.event(event => event === type, this.#timeout);
|
|
1386
|
+
this.world.dispatch(type, data);
|
|
1387
|
+
await done;
|
|
1388
|
+
await this.#observer.settle();
|
|
1389
|
+
}
|
|
1390
|
+
/**
|
|
1391
|
+
* Runs `act`, which makes the world send the event `type`, and waits for
|
|
1392
|
+
* the files to be done with it.
|
|
1393
|
+
*/
|
|
1394
|
+
async expectEvent(type, act) {
|
|
1395
|
+
const done = this.#observer.event(event => event === type, this.#timeout);
|
|
1396
|
+
act();
|
|
1397
|
+
await done;
|
|
1398
|
+
await this.#observer.settle();
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
const OPTION_TYPES = {
|
|
1402
|
+
string: ApplicationCommandOptionType.String,
|
|
1403
|
+
integer: ApplicationCommandOptionType.Integer,
|
|
1404
|
+
number: ApplicationCommandOptionType.Number,
|
|
1405
|
+
boolean: ApplicationCommandOptionType.Boolean,
|
|
1406
|
+
user: ApplicationCommandOptionType.User,
|
|
1407
|
+
role: ApplicationCommandOptionType.Role,
|
|
1408
|
+
channel: ApplicationCommandOptionType.Channel,
|
|
1409
|
+
mentionable: ApplicationCommandOptionType.Mentionable,
|
|
1410
|
+
attachment: ApplicationCommandOptionType.Attachment,
|
|
1411
|
+
};
|
|
1412
|
+
const SELECT_COMPONENT_TYPES = {
|
|
1413
|
+
string: ComponentType.StringSelect,
|
|
1414
|
+
user: ComponentType.UserSelect,
|
|
1415
|
+
role: ComponentType.RoleSelect,
|
|
1416
|
+
mentionable: ComponentType.MentionableSelect,
|
|
1417
|
+
channel: ComponentType.ChannelSelect,
|
|
1418
|
+
};
|