chapterjs 0.2.3 → 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/README.md +65 -0
- package/dist/cli/cli.js +41 -6
- package/dist/cli/project.d.ts +19 -0
- package/dist/cli/project.js +18 -8
- 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 +15 -17
- package/dist/components/router.d.ts +7 -2
- package/dist/components/router.js +16 -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 +9 -4
- package/dist/events/router.js +24 -11
- package/dist/interactions/dispatch.d.ts +5 -1
- package/dist/interactions/dispatch.js +6 -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/memory-backend.d.ts +15 -0
- package/dist/store/memory-backend.js +56 -0
- package/dist/store/store.js +4 -1
- package/dist/tasks/scheduler.d.ts +6 -0
- package/dist/tasks/scheduler.js +9 -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,1479 @@
|
|
|
1
|
+
// A Discord in memory, for `chapterjs test`: one server with its roles,
|
|
2
|
+
// channels, members and messages, kept as the raw payloads Discord sends,
|
|
3
|
+
// behind the fake REST API and the fake gateway. The real framework runs
|
|
4
|
+
// against it, so what a test sees is what production would do.
|
|
5
|
+
//
|
|
6
|
+
// The routes the framework and the files of a project use answer with
|
|
7
|
+
// meaning: creating a message keeps it in its channel and sends it back,
|
|
8
|
+
// answering an interaction records the answer, banning someone bans them.
|
|
9
|
+
// A change the world does not model succeeds and is recorded; asking for
|
|
10
|
+
// something it does not have answers 404, as Discord would; a route the
|
|
11
|
+
// documentation does not have answers 404 too. Nothing is made up.
|
|
12
|
+
import { Limits } from '../discord/api.js';
|
|
13
|
+
import { pathParamNames, } from '../discord/endpoint.js';
|
|
14
|
+
import * as E from '../discord/endpoints.js';
|
|
15
|
+
import { ALL_PERMISSIONS, computeBasePermissions, } from '../discord/permissions.js';
|
|
16
|
+
import { ChannelType, } from '../discord/types/channel.js';
|
|
17
|
+
import { InteractionCallbackType, InteractionType, } from '../discord/types/interaction.js';
|
|
18
|
+
import { MessageFlags, MessageType, } from '../discord/types/message.js';
|
|
19
|
+
import { PermissionFlags } from '../discord/types/permissions.js';
|
|
20
|
+
import { fakeDiscord, NOT_FOUND, } from './fake-discord.js';
|
|
21
|
+
import { fakeGateway } from './fake-gateway.js';
|
|
22
|
+
/** Application flags of a bot with every privileged intent enabled. */
|
|
23
|
+
const ALL_PRIVILEGED = (1 << 13) | (1 << 15) | (1 << 19);
|
|
24
|
+
/** What everyone can do in the server, unless a role says more. */
|
|
25
|
+
const EVERYONE_PERMISSIONS = [
|
|
26
|
+
PermissionFlags.ViewChannel,
|
|
27
|
+
PermissionFlags.SendMessages,
|
|
28
|
+
PermissionFlags.SendMessagesInThreads,
|
|
29
|
+
PermissionFlags.CreatePublicThreads,
|
|
30
|
+
PermissionFlags.ReadMessageHistory,
|
|
31
|
+
PermissionFlags.AddReactions,
|
|
32
|
+
PermissionFlags.EmbedLinks,
|
|
33
|
+
PermissionFlags.AttachFiles,
|
|
34
|
+
PermissionFlags.UseExternalEmojis,
|
|
35
|
+
PermissionFlags.ChangeNickname,
|
|
36
|
+
PermissionFlags.Connect,
|
|
37
|
+
PermissionFlags.Speak,
|
|
38
|
+
PermissionFlags.UseVad,
|
|
39
|
+
PermissionFlags.UseApplicationCommands,
|
|
40
|
+
].reduce((all, flag) => all | flag, 0n);
|
|
41
|
+
/** The Discord epoch, for snowflakes. */
|
|
42
|
+
const EPOCH = 1_420_070_400_000;
|
|
43
|
+
/**
|
|
44
|
+
* Ids are dated from when the process started, one millisecond apart: what
|
|
45
|
+
* they are about looks recent, as Discord needs it to (a message older than
|
|
46
|
+
* two weeks can not be deleted in bulk).
|
|
47
|
+
*/
|
|
48
|
+
const FIRST_ID_AT = Date.now();
|
|
49
|
+
/**
|
|
50
|
+
* Ids given so far by every world of the process: two worlds never give
|
|
51
|
+
* the same id, so what waits for an interaction of one never takes one of
|
|
52
|
+
* the other for it.
|
|
53
|
+
*/
|
|
54
|
+
let counter = 0;
|
|
55
|
+
const now = () => new Date().toISOString();
|
|
56
|
+
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
57
|
+
/** Discord's error for something that does not exist. */
|
|
58
|
+
const unknown = (what, code) => ({
|
|
59
|
+
status: 404,
|
|
60
|
+
body: { message: `Unknown ${what}`, code },
|
|
61
|
+
});
|
|
62
|
+
/** The regular expression matching a documented path, placeholders filled. */
|
|
63
|
+
function patternOf(path) {
|
|
64
|
+
const source = path
|
|
65
|
+
.split(/\{[^}]+\}/)
|
|
66
|
+
.map(part => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
|
67
|
+
.join('([^/]+)');
|
|
68
|
+
return new RegExp(`^${source}$`);
|
|
69
|
+
}
|
|
70
|
+
/** How specific a path is: its literal characters, placeholders left out. */
|
|
71
|
+
const weightOf = (path) => path.replace(/\{[^}]+\}/g, '').length;
|
|
72
|
+
/** Every documented endpoint, with how to recognise its path. */
|
|
73
|
+
const DOCUMENTED = Object.values(E)
|
|
74
|
+
.filter((value) => typeof value === 'object' &&
|
|
75
|
+
value !== null &&
|
|
76
|
+
'method' in value &&
|
|
77
|
+
'path' in value)
|
|
78
|
+
.map(endpoint => ({
|
|
79
|
+
endpoint,
|
|
80
|
+
pattern: patternOf(endpoint.path),
|
|
81
|
+
weight: weightOf(endpoint.path),
|
|
82
|
+
}));
|
|
83
|
+
/**
|
|
84
|
+
* The Discord of a test: one server, the bot in it, people who come and go,
|
|
85
|
+
* and everything the bot does kept where a test can read it.
|
|
86
|
+
*/
|
|
87
|
+
export class World {
|
|
88
|
+
botId;
|
|
89
|
+
applicationId;
|
|
90
|
+
guildId;
|
|
91
|
+
ownerId;
|
|
92
|
+
guildName;
|
|
93
|
+
users = new Map();
|
|
94
|
+
/** The members of the server, by user id. */
|
|
95
|
+
members = new Map();
|
|
96
|
+
roles = new Map();
|
|
97
|
+
/** The channels of the server, its threads and the private channels. */
|
|
98
|
+
channels = new Map();
|
|
99
|
+
/** The messages of each channel, oldest first. */
|
|
100
|
+
messages = new Map();
|
|
101
|
+
bans = new Map();
|
|
102
|
+
/** Who is in each thread. */
|
|
103
|
+
threadMembers = new Map();
|
|
104
|
+
/** Every interaction made, by id and by token. */
|
|
105
|
+
interactions = new Map();
|
|
106
|
+
/** The commands registered, for the server and for everyone. */
|
|
107
|
+
guildCommands = [];
|
|
108
|
+
globalCommands = [];
|
|
109
|
+
/** Every gateway event the world sent to the bot, in order. */
|
|
110
|
+
sent = [];
|
|
111
|
+
discord;
|
|
112
|
+
gateway;
|
|
113
|
+
#options;
|
|
114
|
+
#routes = [];
|
|
115
|
+
#byMethod = new Map();
|
|
116
|
+
constructor(options = {}) {
|
|
117
|
+
this.#options = options;
|
|
118
|
+
this.botId = this.snowflake();
|
|
119
|
+
this.applicationId = this.botId;
|
|
120
|
+
this.guildId = this.snowflake();
|
|
121
|
+
this.guildName = options.guildName ?? 'Test Server';
|
|
122
|
+
this.users.set(this.botId, {
|
|
123
|
+
id: this.botId,
|
|
124
|
+
username: options.botName ?? 'Test Bot',
|
|
125
|
+
discriminator: '0',
|
|
126
|
+
global_name: options.botName ?? 'Test Bot',
|
|
127
|
+
avatar: null,
|
|
128
|
+
bot: true,
|
|
129
|
+
});
|
|
130
|
+
// @everyone has the id of the server.
|
|
131
|
+
this.roles.set(this.guildId, {
|
|
132
|
+
id: this.guildId,
|
|
133
|
+
name: '@everyone',
|
|
134
|
+
color: 0,
|
|
135
|
+
colors: { primary_color: 0, secondary_color: null, tertiary_color: null },
|
|
136
|
+
hoist: false,
|
|
137
|
+
position: 0,
|
|
138
|
+
permissions: EVERYONE_PERMISSIONS.toString(),
|
|
139
|
+
managed: false,
|
|
140
|
+
mentionable: false,
|
|
141
|
+
flags: 0,
|
|
142
|
+
});
|
|
143
|
+
const botRole = this.addRole(options.botName ?? 'Test Bot', {
|
|
144
|
+
permissions: PermissionFlags.Administrator,
|
|
145
|
+
position: 1,
|
|
146
|
+
});
|
|
147
|
+
botRole.managed = true;
|
|
148
|
+
botRole.tags = { bot_id: this.botId };
|
|
149
|
+
this.addMember(this.botId, { roles: [botRole.id] });
|
|
150
|
+
const owner = this.addUser('owner');
|
|
151
|
+
this.ownerId = owner.id;
|
|
152
|
+
this.addMember(owner.id);
|
|
153
|
+
this.addChannel('general');
|
|
154
|
+
this.#defineRoutes();
|
|
155
|
+
}
|
|
156
|
+
/** The environment that points the CLI at this Discord. */
|
|
157
|
+
get env() {
|
|
158
|
+
return { CHAPTERJS_API_URL: this.discord.url };
|
|
159
|
+
}
|
|
160
|
+
/** The user of the bot. */
|
|
161
|
+
get bot() {
|
|
162
|
+
return this.users.get(this.botId);
|
|
163
|
+
}
|
|
164
|
+
/** Starts the fake REST API and gateway. */
|
|
165
|
+
async start() {
|
|
166
|
+
this.discord = await fakeDiscord({
|
|
167
|
+
fallback: request => this.#answer(request),
|
|
168
|
+
});
|
|
169
|
+
this.gateway = await fakeGateway();
|
|
170
|
+
this.gateway.behavior.onIdentify = connection => {
|
|
171
|
+
connection.dispatch('READY', {
|
|
172
|
+
v: 10,
|
|
173
|
+
user: this.bot,
|
|
174
|
+
guilds: [{ id: this.guildId, unavailable: true }],
|
|
175
|
+
session_id: `session-${this.gateway.connections.length}`,
|
|
176
|
+
resume_gateway_url: this.gateway.url,
|
|
177
|
+
application: { id: this.applicationId, flags: ALL_PRIVILEGED },
|
|
178
|
+
});
|
|
179
|
+
connection.dispatch('GUILD_CREATE', this.guildPayload());
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
async close() {
|
|
183
|
+
await Promise.all([this.gateway?.close(), this.discord?.close()]);
|
|
184
|
+
}
|
|
185
|
+
/** A new id, later than every id given before, in this world or another. */
|
|
186
|
+
snowflake() {
|
|
187
|
+
counter++;
|
|
188
|
+
return ((BigInt(FIRST_ID_AT - EPOCH + counter) << 22n) |
|
|
189
|
+
BigInt(counter % 4096)).toString();
|
|
190
|
+
}
|
|
191
|
+
// ── State ──────────────────────────────────────────────────────────
|
|
192
|
+
addUser(name, options = {}) {
|
|
193
|
+
const user = {
|
|
194
|
+
id: this.snowflake(),
|
|
195
|
+
username: name,
|
|
196
|
+
discriminator: '0',
|
|
197
|
+
global_name: name.charAt(0).toUpperCase() + name.slice(1),
|
|
198
|
+
avatar: null,
|
|
199
|
+
...(options.bot ? { bot: true } : {}),
|
|
200
|
+
...(options.locale ? { locale: options.locale } : {}),
|
|
201
|
+
};
|
|
202
|
+
this.users.set(user.id, user);
|
|
203
|
+
return user;
|
|
204
|
+
}
|
|
205
|
+
/** The user named so, if any. */
|
|
206
|
+
findUser(name) {
|
|
207
|
+
for (const user of this.users.values()) {
|
|
208
|
+
if (user.username === name)
|
|
209
|
+
return user;
|
|
210
|
+
}
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
213
|
+
addMember(userId, options = {}) {
|
|
214
|
+
const user = this.users.get(userId);
|
|
215
|
+
if (!user)
|
|
216
|
+
throw new Error(`No user ${userId} in this world.`);
|
|
217
|
+
const member = {
|
|
218
|
+
user,
|
|
219
|
+
nick: options.nick ?? null,
|
|
220
|
+
avatar: null,
|
|
221
|
+
roles: [...(options.roles ?? [])],
|
|
222
|
+
joined_at: now(),
|
|
223
|
+
deaf: false,
|
|
224
|
+
mute: false,
|
|
225
|
+
flags: 0,
|
|
226
|
+
};
|
|
227
|
+
this.members.set(userId, member);
|
|
228
|
+
return member;
|
|
229
|
+
}
|
|
230
|
+
addRole(name, options = {}) {
|
|
231
|
+
const role = {
|
|
232
|
+
id: this.snowflake(),
|
|
233
|
+
name,
|
|
234
|
+
color: options.color ?? 0,
|
|
235
|
+
colors: {
|
|
236
|
+
primary_color: options.color ?? 0,
|
|
237
|
+
secondary_color: null,
|
|
238
|
+
tertiary_color: null,
|
|
239
|
+
},
|
|
240
|
+
hoist: false,
|
|
241
|
+
position: options.position ?? this.roles.size,
|
|
242
|
+
permissions: (options.permissions ?? 0n).toString(),
|
|
243
|
+
managed: false,
|
|
244
|
+
mentionable: false,
|
|
245
|
+
flags: 0,
|
|
246
|
+
};
|
|
247
|
+
this.roles.set(role.id, role);
|
|
248
|
+
return role;
|
|
249
|
+
}
|
|
250
|
+
addChannel(name, options = {}) {
|
|
251
|
+
const channel = {
|
|
252
|
+
id: this.snowflake(),
|
|
253
|
+
type: options.type ?? ChannelType.GuildText,
|
|
254
|
+
guild_id: this.guildId,
|
|
255
|
+
name,
|
|
256
|
+
position: [...this.channels.values()].filter(one => one.guild_id).length,
|
|
257
|
+
permission_overwrites: [],
|
|
258
|
+
parent_id: options.parentId ?? null,
|
|
259
|
+
topic: options.topic ?? null,
|
|
260
|
+
nsfw: false,
|
|
261
|
+
last_message_id: null,
|
|
262
|
+
rate_limit_per_user: 0,
|
|
263
|
+
};
|
|
264
|
+
this.channels.set(channel.id, channel);
|
|
265
|
+
this.messages.set(channel.id, []);
|
|
266
|
+
return channel;
|
|
267
|
+
}
|
|
268
|
+
/** The channel of the server named so, if any. */
|
|
269
|
+
findChannel(name) {
|
|
270
|
+
for (const channel of this.channels.values()) {
|
|
271
|
+
if (channel.guild_id && channel.name === name)
|
|
272
|
+
return channel;
|
|
273
|
+
}
|
|
274
|
+
return undefined;
|
|
275
|
+
}
|
|
276
|
+
/** The private channel with a user, made the first time it is asked. */
|
|
277
|
+
dm(userId) {
|
|
278
|
+
for (const channel of this.channels.values()) {
|
|
279
|
+
if (channel.type === ChannelType.Dm &&
|
|
280
|
+
channel.recipients?.some(user => user.id === userId)) {
|
|
281
|
+
return channel;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
const channel = {
|
|
285
|
+
id: this.snowflake(),
|
|
286
|
+
type: ChannelType.Dm,
|
|
287
|
+
recipients: [this.users.get(userId)],
|
|
288
|
+
last_message_id: null,
|
|
289
|
+
};
|
|
290
|
+
this.channels.set(channel.id, channel);
|
|
291
|
+
this.messages.set(channel.id, []);
|
|
292
|
+
return channel;
|
|
293
|
+
}
|
|
294
|
+
/** The permissions of a member in the server (channel overwrites left aside). */
|
|
295
|
+
permissionsOf(userId) {
|
|
296
|
+
const member = this.members.get(userId);
|
|
297
|
+
if (!member)
|
|
298
|
+
return 0n;
|
|
299
|
+
return computeBasePermissions({
|
|
300
|
+
guildId: this.guildId,
|
|
301
|
+
ownerId: this.ownerId,
|
|
302
|
+
userId,
|
|
303
|
+
memberRoleIds: member.roles,
|
|
304
|
+
roles: [...this.roles.values()].map(role => ({
|
|
305
|
+
id: role.id,
|
|
306
|
+
permissions: BigInt(role.permissions),
|
|
307
|
+
})),
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
/** The server as Discord sends it in Guild Create. */
|
|
311
|
+
guildPayload() {
|
|
312
|
+
const guildChannels = [...this.channels.values()].filter(channel => channel.guild_id === this.guildId);
|
|
313
|
+
return {
|
|
314
|
+
...this.guildFields(),
|
|
315
|
+
joined_at: '2024-01-01T00:00:00Z',
|
|
316
|
+
large: false,
|
|
317
|
+
unavailable: false,
|
|
318
|
+
member_count: this.members.size,
|
|
319
|
+
members: [...this.members.values()],
|
|
320
|
+
channels: guildChannels.filter(channel => !this.isThread(channel)),
|
|
321
|
+
threads: guildChannels.filter(channel => this.isThread(channel)),
|
|
322
|
+
voice_states: [],
|
|
323
|
+
presences: [],
|
|
324
|
+
stage_instances: [],
|
|
325
|
+
guild_scheduled_events: [],
|
|
326
|
+
soundboard_sounds: [],
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
/** The server as Get Guild returns it. */
|
|
330
|
+
guildFields() {
|
|
331
|
+
return {
|
|
332
|
+
id: this.guildId,
|
|
333
|
+
name: this.guildName,
|
|
334
|
+
icon: null,
|
|
335
|
+
splash: null,
|
|
336
|
+
discovery_splash: null,
|
|
337
|
+
owner_id: this.ownerId,
|
|
338
|
+
afk_channel_id: null,
|
|
339
|
+
afk_timeout: 300,
|
|
340
|
+
verification_level: 0,
|
|
341
|
+
default_message_notifications: 0,
|
|
342
|
+
explicit_content_filter: 0,
|
|
343
|
+
roles: [...this.roles.values()],
|
|
344
|
+
emojis: [],
|
|
345
|
+
features: [],
|
|
346
|
+
mfa_level: 0,
|
|
347
|
+
application_id: null,
|
|
348
|
+
system_channel_id: this.findChannel('general')?.id ?? null,
|
|
349
|
+
system_channel_flags: 0,
|
|
350
|
+
rules_channel_id: null,
|
|
351
|
+
vanity_url_code: null,
|
|
352
|
+
description: null,
|
|
353
|
+
banner: null,
|
|
354
|
+
premium_tier: 0,
|
|
355
|
+
preferred_locale: 'en-US',
|
|
356
|
+
public_updates_channel_id: null,
|
|
357
|
+
nsfw_level: 0,
|
|
358
|
+
premium_progress_bar_enabled: false,
|
|
359
|
+
safety_alerts_channel_id: null,
|
|
360
|
+
incidents_data: null,
|
|
361
|
+
approximate_member_count: this.members.size,
|
|
362
|
+
approximate_presence_count: this.members.size,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
isThread(channel) {
|
|
366
|
+
return (channel.type === ChannelType.PublicThread ||
|
|
367
|
+
channel.type === ChannelType.PrivateThread ||
|
|
368
|
+
channel.type === ChannelType.AnnouncementThread);
|
|
369
|
+
}
|
|
370
|
+
// ── Gateway ────────────────────────────────────────────────────────
|
|
371
|
+
/** The connection of the bot, once it identified. */
|
|
372
|
+
get connection() {
|
|
373
|
+
const connection = this.gateway.connections.at(-1);
|
|
374
|
+
if (!connection || connection.closed) {
|
|
375
|
+
throw new Error('The bot is not connected to the simulated Discord.');
|
|
376
|
+
}
|
|
377
|
+
return connection;
|
|
378
|
+
}
|
|
379
|
+
/** Sends a gateway event to the bot. */
|
|
380
|
+
dispatch(type, data) {
|
|
381
|
+
this.sent.push({ type, data });
|
|
382
|
+
this.connection.dispatch(type, data);
|
|
383
|
+
}
|
|
384
|
+
/** Makes an interaction happen: records it and sends it to the bot. */
|
|
385
|
+
interaction(raw) {
|
|
386
|
+
const record = {
|
|
387
|
+
id: raw.id,
|
|
388
|
+
token: raw.token,
|
|
389
|
+
raw,
|
|
390
|
+
// The message of the world itself: what an update changes.
|
|
391
|
+
message: raw.message
|
|
392
|
+
? (this.findMessage(raw.message.id) ?? raw.message)
|
|
393
|
+
: null,
|
|
394
|
+
callbacks: [],
|
|
395
|
+
original: null,
|
|
396
|
+
followUps: [],
|
|
397
|
+
modal: null,
|
|
398
|
+
suggestions: null,
|
|
399
|
+
deferred: false,
|
|
400
|
+
updated: false,
|
|
401
|
+
};
|
|
402
|
+
this.interactions.set(raw.id, record);
|
|
403
|
+
this.interactions.set(raw.token, record);
|
|
404
|
+
this.dispatch('INTERACTION_CREATE', raw);
|
|
405
|
+
return record;
|
|
406
|
+
}
|
|
407
|
+
// ── Messages ───────────────────────────────────────────────────────
|
|
408
|
+
/** Puts a message in its channel and tells the bot, as Discord does. */
|
|
409
|
+
createMessage(channelId, author, data, files = {}, extra = {}) {
|
|
410
|
+
const channel = this.channels.get(channelId);
|
|
411
|
+
const list = this.messages.get(channelId);
|
|
412
|
+
if (!channel || !list)
|
|
413
|
+
throw new Error(`No channel ${channelId}.`);
|
|
414
|
+
const attachments = Object.values(files).map(file => ({
|
|
415
|
+
id: this.snowflake(),
|
|
416
|
+
filename: file.name,
|
|
417
|
+
content_type: file.type,
|
|
418
|
+
size: Buffer.byteLength(file.text),
|
|
419
|
+
url: `https://cdn.discordapp.test/${file.name}`,
|
|
420
|
+
proxy_url: `https://media.discordapp.test/${file.name}`,
|
|
421
|
+
}));
|
|
422
|
+
const reference = isRecord(data.message_reference)
|
|
423
|
+
? data.message_reference
|
|
424
|
+
: undefined;
|
|
425
|
+
const referenced = reference?.message_id
|
|
426
|
+
? (list.find(message => message.id === reference.message_id) ?? null)
|
|
427
|
+
: undefined;
|
|
428
|
+
const message = {
|
|
429
|
+
id: this.snowflake(),
|
|
430
|
+
channel_id: channelId,
|
|
431
|
+
...(channel.guild_id ? { guild_id: channel.guild_id } : {}),
|
|
432
|
+
author,
|
|
433
|
+
...(channel.guild_id && this.members.get(author.id)
|
|
434
|
+
? { member: this.members.get(author.id) }
|
|
435
|
+
: {}),
|
|
436
|
+
content: typeof data.content === 'string' ? data.content : '',
|
|
437
|
+
timestamp: now(),
|
|
438
|
+
edited_timestamp: null,
|
|
439
|
+
tts: data.tts === true,
|
|
440
|
+
mention_everyone: false,
|
|
441
|
+
mentions: [],
|
|
442
|
+
mention_roles: [],
|
|
443
|
+
attachments,
|
|
444
|
+
embeds: Array.isArray(data.embeds) ? data.embeds : [],
|
|
445
|
+
pinned: false,
|
|
446
|
+
type: referenced ? MessageType.Reply : MessageType.Default,
|
|
447
|
+
flags: typeof data.flags === 'number' ? data.flags : 0,
|
|
448
|
+
...(Array.isArray(data.components)
|
|
449
|
+
? { components: data.components }
|
|
450
|
+
: {}),
|
|
451
|
+
...(isRecord(data.poll) ? { poll: data.poll } : {}),
|
|
452
|
+
...(reference ? { message_reference: reference } : {}),
|
|
453
|
+
...(referenced !== undefined ? { referenced_message: referenced } : {}),
|
|
454
|
+
...extra,
|
|
455
|
+
};
|
|
456
|
+
list.push(message);
|
|
457
|
+
channel.last_message_id = message.id;
|
|
458
|
+
this.#dispatchIfConnected('MESSAGE_CREATE', message);
|
|
459
|
+
return message;
|
|
460
|
+
}
|
|
461
|
+
/** The message with this id, wherever it is. */
|
|
462
|
+
findMessage(id) {
|
|
463
|
+
for (const list of this.messages.values()) {
|
|
464
|
+
const found = list.find(message => message.id === id);
|
|
465
|
+
if (found)
|
|
466
|
+
return found;
|
|
467
|
+
}
|
|
468
|
+
return undefined;
|
|
469
|
+
}
|
|
470
|
+
editMessage(message, data) {
|
|
471
|
+
if (typeof data.content === 'string' || data.content === null) {
|
|
472
|
+
message.content = data.content ?? '';
|
|
473
|
+
}
|
|
474
|
+
if (Array.isArray(data.embeds))
|
|
475
|
+
message.embeds = data.embeds;
|
|
476
|
+
if (Array.isArray(data.components)) {
|
|
477
|
+
message.components = data.components;
|
|
478
|
+
}
|
|
479
|
+
if (typeof data.flags === 'number') {
|
|
480
|
+
// Editing never changes the flags Discord set itself.
|
|
481
|
+
const kept = (message.flags ?? 0) & MessageFlags.Ephemeral;
|
|
482
|
+
message.flags = (data.flags & ~MessageFlags.Loading) | kept;
|
|
483
|
+
}
|
|
484
|
+
else {
|
|
485
|
+
message.flags = (message.flags ?? 0) & ~MessageFlags.Loading;
|
|
486
|
+
}
|
|
487
|
+
message.edited_timestamp = now();
|
|
488
|
+
this.#dispatchIfConnected('MESSAGE_UPDATE', message);
|
|
489
|
+
return message;
|
|
490
|
+
}
|
|
491
|
+
deleteMessage(message) {
|
|
492
|
+
const list = this.messages.get(message.channel_id);
|
|
493
|
+
if (list)
|
|
494
|
+
list.splice(list.indexOf(message), 1);
|
|
495
|
+
const guildId = this.channels.get(message.channel_id)?.guild_id;
|
|
496
|
+
this.#dispatchIfConnected('MESSAGE_DELETE', {
|
|
497
|
+
id: message.id,
|
|
498
|
+
channel_id: message.channel_id,
|
|
499
|
+
...(guildId ? { guild_id: guildId } : {}),
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
#dispatchIfConnected(type, data) {
|
|
503
|
+
const connection = this.gateway?.connections.at(-1);
|
|
504
|
+
if (connection && !connection.closed)
|
|
505
|
+
this.dispatch(type, data);
|
|
506
|
+
}
|
|
507
|
+
// ── Members ────────────────────────────────────────────────────────
|
|
508
|
+
removeMember(userId) {
|
|
509
|
+
const member = this.members.get(userId);
|
|
510
|
+
if (!member)
|
|
511
|
+
return;
|
|
512
|
+
this.members.delete(userId);
|
|
513
|
+
for (const inside of this.threadMembers.values())
|
|
514
|
+
inside.delete(userId);
|
|
515
|
+
this.#dispatchIfConnected('GUILD_MEMBER_REMOVE', {
|
|
516
|
+
guild_id: this.guildId,
|
|
517
|
+
user: member.user,
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
memberUpdated(member) {
|
|
521
|
+
this.#dispatchIfConnected('GUILD_MEMBER_UPDATE', {
|
|
522
|
+
guild_id: this.guildId,
|
|
523
|
+
roles: member.roles,
|
|
524
|
+
user: member.user,
|
|
525
|
+
nick: member.nick,
|
|
526
|
+
avatar: member.avatar ?? null,
|
|
527
|
+
banner: member.banner ?? null,
|
|
528
|
+
joined_at: member.joined_at,
|
|
529
|
+
deaf: member.deaf,
|
|
530
|
+
mute: member.mute,
|
|
531
|
+
flags: member.flags,
|
|
532
|
+
communication_disabled_until: member.communication_disabled_until ?? null,
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
// ── REST ───────────────────────────────────────────────────────────
|
|
536
|
+
#route(endpoint, handler) {
|
|
537
|
+
const route = {
|
|
538
|
+
endpoint,
|
|
539
|
+
pattern: patternOf(endpoint.path),
|
|
540
|
+
weight: weightOf(endpoint.path),
|
|
541
|
+
handler,
|
|
542
|
+
};
|
|
543
|
+
this.#routes.push(route);
|
|
544
|
+
const list = this.#byMethod.get(endpoint.method) ?? [];
|
|
545
|
+
list.push(route);
|
|
546
|
+
list.sort((a, b) => b.weight - a.weight);
|
|
547
|
+
this.#byMethod.set(endpoint.method, list);
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* Answers a request: the route of the world, else what Discord would say.
|
|
551
|
+
* The most specific documented path wins (`/messages/pins` is not the
|
|
552
|
+
* message "pins"), whether the world models it or not.
|
|
553
|
+
*/
|
|
554
|
+
#answer(request) {
|
|
555
|
+
const method = request.method;
|
|
556
|
+
let best = null;
|
|
557
|
+
for (const route of this.#byMethod.get(method) ?? []) {
|
|
558
|
+
if (route.pattern.test(request.path) &&
|
|
559
|
+
(best?.weight ?? -1) < route.weight) {
|
|
560
|
+
best = { weight: route.weight, route, endpoint: route.endpoint };
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
for (const { endpoint, pattern, weight } of DOCUMENTED) {
|
|
564
|
+
if (endpoint.method === method &&
|
|
565
|
+
pattern.test(request.path) &&
|
|
566
|
+
(best?.weight ?? -1) < weight) {
|
|
567
|
+
best = { weight, endpoint };
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
if (!best)
|
|
571
|
+
return NOT_FOUND;
|
|
572
|
+
if (best.route) {
|
|
573
|
+
const match = best.route.pattern.exec(request.path);
|
|
574
|
+
return best.route.handler(match.slice(1).map(decodeURIComponent), request);
|
|
575
|
+
}
|
|
576
|
+
return this.#generic(best.endpoint, request);
|
|
577
|
+
}
|
|
578
|
+
/** What a documented route the world does not model answers. */
|
|
579
|
+
#generic(endpoint, request) {
|
|
580
|
+
switch (endpoint.method) {
|
|
581
|
+
case 'GET':
|
|
582
|
+
return {
|
|
583
|
+
status: 404,
|
|
584
|
+
body: {
|
|
585
|
+
message: `The simulated Discord of chapterjs test has nothing for ${endpoint.method} ${endpoint.path} yet.`,
|
|
586
|
+
code: 0,
|
|
587
|
+
},
|
|
588
|
+
};
|
|
589
|
+
case 'POST':
|
|
590
|
+
return {
|
|
591
|
+
body: {
|
|
592
|
+
id: this.snowflake(),
|
|
593
|
+
...(isRecord(request.body) ? request.body : {}),
|
|
594
|
+
},
|
|
595
|
+
};
|
|
596
|
+
case 'PATCH': {
|
|
597
|
+
const names = pathParamNames(endpoint.path);
|
|
598
|
+
const values = patternOf(endpoint.path).exec(request.path).slice(1);
|
|
599
|
+
const id = values[names.length - 1];
|
|
600
|
+
return {
|
|
601
|
+
body: {
|
|
602
|
+
...(id ? { id } : {}),
|
|
603
|
+
...(isRecord(request.body) ? request.body : {}),
|
|
604
|
+
},
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
default:
|
|
608
|
+
return {};
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
#defineRoutes() {
|
|
612
|
+
const body = (request) => isRecord(request.body) ? request.body : {};
|
|
613
|
+
// The application and the gateway.
|
|
614
|
+
this.#route(E.GetCurrentApplication, () => ({
|
|
615
|
+
body: {
|
|
616
|
+
id: this.applicationId,
|
|
617
|
+
name: this.bot.username,
|
|
618
|
+
icon: null,
|
|
619
|
+
description: '',
|
|
620
|
+
bot_public: true,
|
|
621
|
+
bot_require_code_grant: false,
|
|
622
|
+
bot: this.bot,
|
|
623
|
+
verify_key: '',
|
|
624
|
+
team: null,
|
|
625
|
+
flags: ALL_PRIVILEGED,
|
|
626
|
+
},
|
|
627
|
+
}));
|
|
628
|
+
this.#route(E.GetCurrentUser, () => ({ body: this.bot }));
|
|
629
|
+
this.#route(E.GetGatewayBot, () => ({
|
|
630
|
+
body: {
|
|
631
|
+
url: this.gateway.url,
|
|
632
|
+
shards: 1,
|
|
633
|
+
session_start_limit: {
|
|
634
|
+
total: 1000,
|
|
635
|
+
remaining: 999,
|
|
636
|
+
reset_after: 0,
|
|
637
|
+
max_concurrency: 1,
|
|
638
|
+
},
|
|
639
|
+
},
|
|
640
|
+
}));
|
|
641
|
+
// Commands.
|
|
642
|
+
const registered = (commands, guildId) => (Array.isArray(commands) ? commands : []).map((command) => ({
|
|
643
|
+
id: this.snowflake(),
|
|
644
|
+
application_id: this.applicationId,
|
|
645
|
+
...(guildId ? { guild_id: guildId } : {}),
|
|
646
|
+
version: this.snowflake(),
|
|
647
|
+
type: 1,
|
|
648
|
+
default_member_permissions: null,
|
|
649
|
+
...command,
|
|
650
|
+
}));
|
|
651
|
+
this.#route(E.GetGlobalApplicationCommands, () => ({
|
|
652
|
+
body: this.globalCommands,
|
|
653
|
+
}));
|
|
654
|
+
this.#route(E.BulkOverwriteGlobalApplicationCommands, (_, request) => {
|
|
655
|
+
this.globalCommands = registered(request.body);
|
|
656
|
+
return { body: this.globalCommands };
|
|
657
|
+
});
|
|
658
|
+
this.#route(E.GetGuildApplicationCommands, () => ({
|
|
659
|
+
body: this.guildCommands,
|
|
660
|
+
}));
|
|
661
|
+
this.#route(E.BulkOverwriteGuildApplicationCommands, ([, guildId], request) => {
|
|
662
|
+
this.guildCommands = registered(request.body, guildId);
|
|
663
|
+
return { body: this.guildCommands };
|
|
664
|
+
});
|
|
665
|
+
// The server.
|
|
666
|
+
const guildOr404 = (id) => id === this.guildId ? null : unknown('Guild', 10004);
|
|
667
|
+
this.#route(E.GetGuild, ([id]) => guildOr404(id) ?? { body: this.guildFields() });
|
|
668
|
+
this.#route(E.ModifyGuild, ([id], request) => {
|
|
669
|
+
const refused = guildOr404(id);
|
|
670
|
+
if (refused)
|
|
671
|
+
return refused;
|
|
672
|
+
const changes = body(request);
|
|
673
|
+
if (typeof changes.name === 'string')
|
|
674
|
+
this.guildName = changes.name;
|
|
675
|
+
this.#dispatchIfConnected('GUILD_UPDATE', this.guildFields());
|
|
676
|
+
return { body: this.guildFields() };
|
|
677
|
+
});
|
|
678
|
+
this.#route(E.GetGuildChannels, ([id]) => guildOr404(id) ?? {
|
|
679
|
+
body: [...this.channels.values()].filter(channel => channel.guild_id === this.guildId && !this.isThread(channel)),
|
|
680
|
+
});
|
|
681
|
+
this.#route(E.CreateGuildChannel, ([id], request) => {
|
|
682
|
+
const refused = guildOr404(id);
|
|
683
|
+
if (refused)
|
|
684
|
+
return refused;
|
|
685
|
+
const given = body(request);
|
|
686
|
+
const channel = this.addChannel(String(given.name ?? 'channel'), {
|
|
687
|
+
type: typeof given.type === 'number'
|
|
688
|
+
? given.type
|
|
689
|
+
: undefined,
|
|
690
|
+
parentId: typeof given.parent_id === 'string' ? given.parent_id : null,
|
|
691
|
+
topic: typeof given.topic === 'string' ? given.topic : null,
|
|
692
|
+
});
|
|
693
|
+
if (Array.isArray(given.permission_overwrites)) {
|
|
694
|
+
channel.permission_overwrites =
|
|
695
|
+
given.permission_overwrites;
|
|
696
|
+
}
|
|
697
|
+
this.#dispatchIfConnected('CHANNEL_CREATE', channel);
|
|
698
|
+
return { body: channel };
|
|
699
|
+
});
|
|
700
|
+
this.#route(E.GetGuildAuditLog, ([id]) => guildOr404(id) ?? {
|
|
701
|
+
body: {
|
|
702
|
+
application_commands: [],
|
|
703
|
+
audit_log_entries: [],
|
|
704
|
+
auto_moderation_rules: [],
|
|
705
|
+
guild_scheduled_events: [],
|
|
706
|
+
integrations: [],
|
|
707
|
+
threads: [],
|
|
708
|
+
users: [],
|
|
709
|
+
webhooks: [],
|
|
710
|
+
},
|
|
711
|
+
});
|
|
712
|
+
// Members.
|
|
713
|
+
const memberOr404 = (guildId, userId) => {
|
|
714
|
+
const refused = guildOr404(guildId);
|
|
715
|
+
if (refused)
|
|
716
|
+
return { refused };
|
|
717
|
+
const member = this.members.get(userId);
|
|
718
|
+
return member ? { member } : { refused: unknown('Member', 10007) };
|
|
719
|
+
};
|
|
720
|
+
this.#route(E.GetGuildMember, ([guildId, userId]) => {
|
|
721
|
+
const found = memberOr404(guildId, userId);
|
|
722
|
+
return 'refused' in found ? found.refused : { body: found.member };
|
|
723
|
+
});
|
|
724
|
+
this.#route(E.ListGuildMembers, ([guildId], request) => {
|
|
725
|
+
const refused = guildOr404(guildId);
|
|
726
|
+
if (refused)
|
|
727
|
+
return refused;
|
|
728
|
+
const limit = Number(request.query.limit?.[0] ?? 1);
|
|
729
|
+
const after = request.query.after?.[0];
|
|
730
|
+
const members = [...this.members.values()]
|
|
731
|
+
.filter(member => !after || BigInt(member.user.id) > BigInt(after))
|
|
732
|
+
.sort((a, b) => (BigInt(a.user.id) < BigInt(b.user.id) ? -1 : 1));
|
|
733
|
+
return {
|
|
734
|
+
body: members.slice(0, Math.min(limit, Limits.ListGuildMembers)),
|
|
735
|
+
};
|
|
736
|
+
});
|
|
737
|
+
this.#route(E.SearchGuildMembers, ([guildId], request) => {
|
|
738
|
+
const refused = guildOr404(guildId);
|
|
739
|
+
if (refused)
|
|
740
|
+
return refused;
|
|
741
|
+
const query = (request.query.query?.[0] ?? '').toLowerCase();
|
|
742
|
+
return {
|
|
743
|
+
body: [...this.members.values()].filter(member => member.user.username.toLowerCase().startsWith(query) ||
|
|
744
|
+
member.nick?.toLowerCase().startsWith(query)),
|
|
745
|
+
};
|
|
746
|
+
});
|
|
747
|
+
this.#route(E.ModifyGuildMember, ([guildId, userId], request) => {
|
|
748
|
+
const found = memberOr404(guildId, userId);
|
|
749
|
+
if ('refused' in found)
|
|
750
|
+
return found.refused;
|
|
751
|
+
const { member } = found;
|
|
752
|
+
const changes = body(request);
|
|
753
|
+
if ('nick' in changes)
|
|
754
|
+
member.nick = changes.nick ?? null;
|
|
755
|
+
if (Array.isArray(changes.roles))
|
|
756
|
+
member.roles = changes.roles;
|
|
757
|
+
if (typeof changes.mute === 'boolean')
|
|
758
|
+
member.mute = changes.mute;
|
|
759
|
+
if (typeof changes.deaf === 'boolean')
|
|
760
|
+
member.deaf = changes.deaf;
|
|
761
|
+
if ('communication_disabled_until' in changes) {
|
|
762
|
+
member.communication_disabled_until =
|
|
763
|
+
changes.communication_disabled_until ?? null;
|
|
764
|
+
}
|
|
765
|
+
if (typeof changes.flags === 'number')
|
|
766
|
+
member.flags = changes.flags;
|
|
767
|
+
this.memberUpdated(member);
|
|
768
|
+
return { body: member };
|
|
769
|
+
});
|
|
770
|
+
this.#route(E.ModifyCurrentMember, ([guildId], request) => {
|
|
771
|
+
const found = memberOr404(guildId, this.botId);
|
|
772
|
+
if ('refused' in found)
|
|
773
|
+
return found.refused;
|
|
774
|
+
const changes = body(request);
|
|
775
|
+
if ('nick' in changes)
|
|
776
|
+
found.member.nick = changes.nick ?? null;
|
|
777
|
+
this.memberUpdated(found.member);
|
|
778
|
+
return { body: found.member };
|
|
779
|
+
});
|
|
780
|
+
this.#route(E.AddGuildMemberRole, ([guildId, userId, roleId]) => {
|
|
781
|
+
const found = memberOr404(guildId, userId);
|
|
782
|
+
if ('refused' in found)
|
|
783
|
+
return found.refused;
|
|
784
|
+
if (!this.roles.has(roleId))
|
|
785
|
+
return unknown('Role', 10011);
|
|
786
|
+
if (!found.member.roles.includes(roleId))
|
|
787
|
+
found.member.roles.push(roleId);
|
|
788
|
+
this.memberUpdated(found.member);
|
|
789
|
+
return {};
|
|
790
|
+
});
|
|
791
|
+
this.#route(E.RemoveGuildMemberRole, ([guildId, userId, roleId]) => {
|
|
792
|
+
const found = memberOr404(guildId, userId);
|
|
793
|
+
if ('refused' in found)
|
|
794
|
+
return found.refused;
|
|
795
|
+
if (!this.roles.has(roleId))
|
|
796
|
+
return unknown('Role', 10011);
|
|
797
|
+
found.member.roles = found.member.roles.filter(id => id !== roleId);
|
|
798
|
+
this.memberUpdated(found.member);
|
|
799
|
+
return {};
|
|
800
|
+
});
|
|
801
|
+
this.#route(E.RemoveGuildMember, ([guildId, userId]) => {
|
|
802
|
+
const found = memberOr404(guildId, userId);
|
|
803
|
+
if ('refused' in found)
|
|
804
|
+
return found.refused;
|
|
805
|
+
this.removeMember(userId);
|
|
806
|
+
return {};
|
|
807
|
+
});
|
|
808
|
+
// Bans.
|
|
809
|
+
this.#route(E.GetGuildBans, ([guildId]) => guildOr404(guildId) ?? {
|
|
810
|
+
body: [...this.bans.values()].map(ban => ({
|
|
811
|
+
reason: ban.reason,
|
|
812
|
+
user: ban.user,
|
|
813
|
+
})),
|
|
814
|
+
});
|
|
815
|
+
this.#route(E.GetGuildBan, ([guildId, userId]) => {
|
|
816
|
+
const refused = guildOr404(guildId);
|
|
817
|
+
if (refused)
|
|
818
|
+
return refused;
|
|
819
|
+
const ban = this.bans.get(userId);
|
|
820
|
+
return ban
|
|
821
|
+
? { body: { reason: ban.reason, user: ban.user } }
|
|
822
|
+
: unknown('Ban', 10026);
|
|
823
|
+
});
|
|
824
|
+
this.#route(E.CreateGuildBan, ([guildId, userId], request) => {
|
|
825
|
+
const refused = guildOr404(guildId);
|
|
826
|
+
if (refused)
|
|
827
|
+
return refused;
|
|
828
|
+
const user = this.users.get(userId);
|
|
829
|
+
if (!user)
|
|
830
|
+
return unknown('User', 10013);
|
|
831
|
+
const reason = request.headers['x-audit-log-reason'];
|
|
832
|
+
this.bans.set(userId, {
|
|
833
|
+
user,
|
|
834
|
+
reason: typeof reason === 'string' ? decodeURIComponent(reason) : null,
|
|
835
|
+
});
|
|
836
|
+
this.removeMember(userId);
|
|
837
|
+
this.#dispatchIfConnected('GUILD_BAN_ADD', {
|
|
838
|
+
guild_id: this.guildId,
|
|
839
|
+
user,
|
|
840
|
+
});
|
|
841
|
+
return {};
|
|
842
|
+
});
|
|
843
|
+
this.#route(E.RemoveGuildBan, ([guildId, userId]) => {
|
|
844
|
+
const refused = guildOr404(guildId);
|
|
845
|
+
if (refused)
|
|
846
|
+
return refused;
|
|
847
|
+
const ban = this.bans.get(userId);
|
|
848
|
+
if (!ban)
|
|
849
|
+
return unknown('Ban', 10026);
|
|
850
|
+
this.bans.delete(userId);
|
|
851
|
+
this.#dispatchIfConnected('GUILD_BAN_REMOVE', {
|
|
852
|
+
guild_id: this.guildId,
|
|
853
|
+
user: ban.user,
|
|
854
|
+
});
|
|
855
|
+
return {};
|
|
856
|
+
});
|
|
857
|
+
this.#route(E.BulkGuildBan, ([guildId], request) => {
|
|
858
|
+
const refused = guildOr404(guildId);
|
|
859
|
+
if (refused)
|
|
860
|
+
return refused;
|
|
861
|
+
const ids = body(request).user_ids ?? [];
|
|
862
|
+
const banned = [];
|
|
863
|
+
const failed = [];
|
|
864
|
+
for (const id of ids) {
|
|
865
|
+
const user = this.users.get(id);
|
|
866
|
+
if (!user || this.bans.has(id)) {
|
|
867
|
+
failed.push(id);
|
|
868
|
+
continue;
|
|
869
|
+
}
|
|
870
|
+
this.bans.set(id, { user, reason: null });
|
|
871
|
+
this.removeMember(id);
|
|
872
|
+
this.#dispatchIfConnected('GUILD_BAN_ADD', {
|
|
873
|
+
guild_id: this.guildId,
|
|
874
|
+
user,
|
|
875
|
+
});
|
|
876
|
+
banned.push(id);
|
|
877
|
+
}
|
|
878
|
+
return { body: { banned_users: banned, failed_users: failed } };
|
|
879
|
+
});
|
|
880
|
+
// Roles.
|
|
881
|
+
this.#route(E.GetGuildRoles, ([guildId]) => guildOr404(guildId) ?? { body: [...this.roles.values()] });
|
|
882
|
+
this.#route(E.GetGuildRole, ([guildId, roleId]) => {
|
|
883
|
+
const refused = guildOr404(guildId);
|
|
884
|
+
if (refused)
|
|
885
|
+
return refused;
|
|
886
|
+
const role = this.roles.get(roleId);
|
|
887
|
+
return role ? { body: role } : unknown('Role', 10011);
|
|
888
|
+
});
|
|
889
|
+
this.#route(E.CreateGuildRole, ([guildId], request) => {
|
|
890
|
+
const refused = guildOr404(guildId);
|
|
891
|
+
if (refused)
|
|
892
|
+
return refused;
|
|
893
|
+
const given = body(request);
|
|
894
|
+
const role = this.addRole(String(given.name ?? 'new role'), {
|
|
895
|
+
permissions: typeof given.permissions === 'string'
|
|
896
|
+
? BigInt(given.permissions)
|
|
897
|
+
: 0n,
|
|
898
|
+
color: typeof given.color === 'number' ? given.color : 0,
|
|
899
|
+
});
|
|
900
|
+
if (typeof given.hoist === 'boolean')
|
|
901
|
+
role.hoist = given.hoist;
|
|
902
|
+
if (typeof given.mentionable === 'boolean')
|
|
903
|
+
role.mentionable = given.mentionable;
|
|
904
|
+
this.#dispatchIfConnected('GUILD_ROLE_CREATE', {
|
|
905
|
+
guild_id: this.guildId,
|
|
906
|
+
role,
|
|
907
|
+
});
|
|
908
|
+
return { body: role };
|
|
909
|
+
});
|
|
910
|
+
this.#route(E.ModifyGuildRole, ([guildId, roleId], request) => {
|
|
911
|
+
const refused = guildOr404(guildId);
|
|
912
|
+
if (refused)
|
|
913
|
+
return refused;
|
|
914
|
+
const role = this.roles.get(roleId);
|
|
915
|
+
if (!role)
|
|
916
|
+
return unknown('Role', 10011);
|
|
917
|
+
const given = body(request);
|
|
918
|
+
if (typeof given.name === 'string')
|
|
919
|
+
role.name = given.name;
|
|
920
|
+
if (typeof given.permissions === 'string')
|
|
921
|
+
role.permissions = given.permissions;
|
|
922
|
+
if (typeof given.color === 'number')
|
|
923
|
+
role.color = given.color;
|
|
924
|
+
if (typeof given.hoist === 'boolean')
|
|
925
|
+
role.hoist = given.hoist;
|
|
926
|
+
if (typeof given.mentionable === 'boolean')
|
|
927
|
+
role.mentionable = given.mentionable;
|
|
928
|
+
this.#dispatchIfConnected('GUILD_ROLE_UPDATE', {
|
|
929
|
+
guild_id: this.guildId,
|
|
930
|
+
role,
|
|
931
|
+
});
|
|
932
|
+
return { body: role };
|
|
933
|
+
});
|
|
934
|
+
this.#route(E.DeleteGuildRole, ([guildId, roleId]) => {
|
|
935
|
+
const refused = guildOr404(guildId);
|
|
936
|
+
if (refused)
|
|
937
|
+
return refused;
|
|
938
|
+
if (!this.roles.has(roleId))
|
|
939
|
+
return unknown('Role', 10011);
|
|
940
|
+
this.roles.delete(roleId);
|
|
941
|
+
for (const member of this.members.values()) {
|
|
942
|
+
member.roles = member.roles.filter(id => id !== roleId);
|
|
943
|
+
}
|
|
944
|
+
this.#dispatchIfConnected('GUILD_ROLE_DELETE', {
|
|
945
|
+
guild_id: this.guildId,
|
|
946
|
+
role_id: roleId,
|
|
947
|
+
});
|
|
948
|
+
return {};
|
|
949
|
+
});
|
|
950
|
+
// Channels and threads.
|
|
951
|
+
const channelOr404 = (id) => {
|
|
952
|
+
const channel = this.channels.get(id);
|
|
953
|
+
return channel ? { channel } : { refused: unknown('Channel', 10003) };
|
|
954
|
+
};
|
|
955
|
+
this.#route(E.GetChannel, ([id]) => {
|
|
956
|
+
const found = channelOr404(id);
|
|
957
|
+
return 'refused' in found ? found.refused : { body: found.channel };
|
|
958
|
+
});
|
|
959
|
+
this.#route(E.ModifyChannel, ([id], request) => {
|
|
960
|
+
const found = channelOr404(id);
|
|
961
|
+
if ('refused' in found)
|
|
962
|
+
return found.refused;
|
|
963
|
+
const { channel } = found;
|
|
964
|
+
const given = body(request);
|
|
965
|
+
if (typeof given.name === 'string')
|
|
966
|
+
channel.name = given.name;
|
|
967
|
+
if ('topic' in given)
|
|
968
|
+
channel.topic = given.topic ?? null;
|
|
969
|
+
if (typeof given.nsfw === 'boolean')
|
|
970
|
+
channel.nsfw = given.nsfw;
|
|
971
|
+
if (typeof given.rate_limit_per_user === 'number') {
|
|
972
|
+
channel.rate_limit_per_user = given.rate_limit_per_user;
|
|
973
|
+
}
|
|
974
|
+
if ('parent_id' in given)
|
|
975
|
+
channel.parent_id = given.parent_id ?? null;
|
|
976
|
+
if (Array.isArray(given.permission_overwrites)) {
|
|
977
|
+
channel.permission_overwrites =
|
|
978
|
+
given.permission_overwrites;
|
|
979
|
+
}
|
|
980
|
+
if (this.isThread(channel) && channel.thread_metadata) {
|
|
981
|
+
if (typeof given.archived === 'boolean') {
|
|
982
|
+
channel.thread_metadata.archived = given.archived;
|
|
983
|
+
channel.thread_metadata.archive_timestamp = now();
|
|
984
|
+
}
|
|
985
|
+
if (typeof given.locked === 'boolean')
|
|
986
|
+
channel.thread_metadata.locked = given.locked;
|
|
987
|
+
if (typeof given.auto_archive_duration === 'number') {
|
|
988
|
+
channel.thread_metadata.auto_archive_duration =
|
|
989
|
+
given.auto_archive_duration;
|
|
990
|
+
}
|
|
991
|
+
if (typeof given.invitable === 'boolean') {
|
|
992
|
+
channel.thread_metadata.invitable = given.invitable;
|
|
993
|
+
}
|
|
994
|
+
this.#dispatchIfConnected('THREAD_UPDATE', channel);
|
|
995
|
+
}
|
|
996
|
+
else {
|
|
997
|
+
this.#dispatchIfConnected('CHANNEL_UPDATE', channel);
|
|
998
|
+
}
|
|
999
|
+
return { body: channel };
|
|
1000
|
+
});
|
|
1001
|
+
this.#route(E.DeleteCloseChannel, ([id]) => {
|
|
1002
|
+
const found = channelOr404(id);
|
|
1003
|
+
if ('refused' in found)
|
|
1004
|
+
return found.refused;
|
|
1005
|
+
const { channel } = found;
|
|
1006
|
+
this.channels.delete(channel.id);
|
|
1007
|
+
this.messages.delete(channel.id);
|
|
1008
|
+
if (this.isThread(channel)) {
|
|
1009
|
+
this.threadMembers.delete(channel.id);
|
|
1010
|
+
this.#dispatchIfConnected('THREAD_DELETE', {
|
|
1011
|
+
id: channel.id,
|
|
1012
|
+
guild_id: channel.guild_id,
|
|
1013
|
+
parent_id: channel.parent_id,
|
|
1014
|
+
type: channel.type,
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
else if (channel.guild_id) {
|
|
1018
|
+
this.#dispatchIfConnected('CHANNEL_DELETE', channel);
|
|
1019
|
+
}
|
|
1020
|
+
return { body: channel };
|
|
1021
|
+
});
|
|
1022
|
+
this.#route(E.EditChannelPermissions, ([id, overwriteId], request) => {
|
|
1023
|
+
const found = channelOr404(id);
|
|
1024
|
+
if ('refused' in found)
|
|
1025
|
+
return found.refused;
|
|
1026
|
+
const given = body(request);
|
|
1027
|
+
const overwrites = (found.channel.permission_overwrites ??= []).filter(one => one.id !== overwriteId);
|
|
1028
|
+
overwrites.push({
|
|
1029
|
+
id: overwriteId,
|
|
1030
|
+
type: given.type ?? 0,
|
|
1031
|
+
allow: typeof given.allow === 'string' ? given.allow : '0',
|
|
1032
|
+
deny: typeof given.deny === 'string' ? given.deny : '0',
|
|
1033
|
+
});
|
|
1034
|
+
found.channel.permission_overwrites = overwrites;
|
|
1035
|
+
this.#dispatchIfConnected('CHANNEL_UPDATE', found.channel);
|
|
1036
|
+
return {};
|
|
1037
|
+
});
|
|
1038
|
+
this.#route(E.DeleteChannelPermission, ([id, overwriteId]) => {
|
|
1039
|
+
const found = channelOr404(id);
|
|
1040
|
+
if ('refused' in found)
|
|
1041
|
+
return found.refused;
|
|
1042
|
+
found.channel.permission_overwrites = (found.channel.permission_overwrites ?? []).filter(one => one.id !== overwriteId);
|
|
1043
|
+
this.#dispatchIfConnected('CHANNEL_UPDATE', found.channel);
|
|
1044
|
+
return {};
|
|
1045
|
+
});
|
|
1046
|
+
this.#route(E.TriggerTypingIndicator, ([id]) => {
|
|
1047
|
+
const found = channelOr404(id);
|
|
1048
|
+
return 'refused' in found ? found.refused : {};
|
|
1049
|
+
});
|
|
1050
|
+
this.#route(E.CreateDm, (_, request) => {
|
|
1051
|
+
const recipient = body(request).recipient_id;
|
|
1052
|
+
const user = typeof recipient === 'string' ? this.users.get(recipient) : undefined;
|
|
1053
|
+
if (!user)
|
|
1054
|
+
return unknown('User', 10013);
|
|
1055
|
+
return { body: this.dm(user.id) };
|
|
1056
|
+
});
|
|
1057
|
+
this.#route(E.GetUser, ([id]) => {
|
|
1058
|
+
const user = this.users.get(id);
|
|
1059
|
+
return user ? { body: user } : unknown('User', 10013);
|
|
1060
|
+
});
|
|
1061
|
+
const startThread = (parent, given, type, message) => {
|
|
1062
|
+
const thread = {
|
|
1063
|
+
id: message?.id ?? this.snowflake(),
|
|
1064
|
+
type,
|
|
1065
|
+
guild_id: parent.guild_id,
|
|
1066
|
+
name: String(given.name ?? 'thread'),
|
|
1067
|
+
parent_id: parent.id,
|
|
1068
|
+
owner_id: this.botId,
|
|
1069
|
+
last_message_id: null,
|
|
1070
|
+
message_count: 0,
|
|
1071
|
+
member_count: 1,
|
|
1072
|
+
rate_limit_per_user: 0,
|
|
1073
|
+
thread_metadata: {
|
|
1074
|
+
archived: false,
|
|
1075
|
+
auto_archive_duration: typeof given.auto_archive_duration === 'number'
|
|
1076
|
+
? given.auto_archive_duration
|
|
1077
|
+
: 4320,
|
|
1078
|
+
archive_timestamp: now(),
|
|
1079
|
+
locked: false,
|
|
1080
|
+
invitable: typeof given.invitable === 'boolean' ? given.invitable : true,
|
|
1081
|
+
create_timestamp: now(),
|
|
1082
|
+
},
|
|
1083
|
+
total_message_sent: 0,
|
|
1084
|
+
flags: 0,
|
|
1085
|
+
};
|
|
1086
|
+
this.channels.set(thread.id, thread);
|
|
1087
|
+
this.messages.set(thread.id, []);
|
|
1088
|
+
this.threadMembers.set(thread.id, new Set([this.botId]));
|
|
1089
|
+
if (message)
|
|
1090
|
+
message.flags = (message.flags ?? 0) | MessageFlags.HasThread;
|
|
1091
|
+
this.#dispatchIfConnected('THREAD_CREATE', {
|
|
1092
|
+
...thread,
|
|
1093
|
+
newly_created: true,
|
|
1094
|
+
});
|
|
1095
|
+
return thread;
|
|
1096
|
+
};
|
|
1097
|
+
this.#route(E.StartThreadFromMessage, ([channelId, messageId], request) => {
|
|
1098
|
+
const found = channelOr404(channelId);
|
|
1099
|
+
if ('refused' in found)
|
|
1100
|
+
return found.refused;
|
|
1101
|
+
const message = this.messages
|
|
1102
|
+
.get(channelId)
|
|
1103
|
+
?.find(one => one.id === messageId);
|
|
1104
|
+
if (!message)
|
|
1105
|
+
return unknown('Message', 10008);
|
|
1106
|
+
const type = found.channel.type === ChannelType.GuildAnnouncement
|
|
1107
|
+
? ChannelType.AnnouncementThread
|
|
1108
|
+
: ChannelType.PublicThread;
|
|
1109
|
+
return { body: startThread(found.channel, body(request), type, message) };
|
|
1110
|
+
});
|
|
1111
|
+
this.#route(E.StartThreadWithoutMessage, ([channelId], request) => {
|
|
1112
|
+
const found = channelOr404(channelId);
|
|
1113
|
+
if ('refused' in found)
|
|
1114
|
+
return found.refused;
|
|
1115
|
+
const given = body(request);
|
|
1116
|
+
const type = typeof given.type === 'number'
|
|
1117
|
+
? given.type
|
|
1118
|
+
: ChannelType.PrivateThread;
|
|
1119
|
+
return { body: startThread(found.channel, given, type) };
|
|
1120
|
+
});
|
|
1121
|
+
this.#route(E.StartThreadInForumOrMediaChannel, ([channelId], request) => {
|
|
1122
|
+
const found = channelOr404(channelId);
|
|
1123
|
+
if ('refused' in found)
|
|
1124
|
+
return found.refused;
|
|
1125
|
+
const given = body(request);
|
|
1126
|
+
const thread = startThread(found.channel, given, ChannelType.PublicThread);
|
|
1127
|
+
const message = this.createMessage(thread.id, this.bot, isRecord(given.message) ? given.message : {}, request.files);
|
|
1128
|
+
return { body: { ...thread, message } };
|
|
1129
|
+
});
|
|
1130
|
+
const threadMembersUpdate = (thread, added, removed) => {
|
|
1131
|
+
const inside = this.threadMembers.get(thread.id) ?? new Set();
|
|
1132
|
+
for (const id of added)
|
|
1133
|
+
inside.add(id);
|
|
1134
|
+
for (const id of removed)
|
|
1135
|
+
inside.delete(id);
|
|
1136
|
+
this.threadMembers.set(thread.id, inside);
|
|
1137
|
+
thread.member_count = inside.size;
|
|
1138
|
+
const members = added.map(id => ({
|
|
1139
|
+
id: thread.id,
|
|
1140
|
+
user_id: id,
|
|
1141
|
+
join_timestamp: now(),
|
|
1142
|
+
flags: 0,
|
|
1143
|
+
...(this.members.get(id) ? { member: this.members.get(id) } : {}),
|
|
1144
|
+
}));
|
|
1145
|
+
this.#dispatchIfConnected('THREAD_MEMBERS_UPDATE', {
|
|
1146
|
+
id: thread.id,
|
|
1147
|
+
guild_id: thread.guild_id,
|
|
1148
|
+
member_count: inside.size,
|
|
1149
|
+
...(members.length > 0 ? { added_members: members } : {}),
|
|
1150
|
+
...(removed.length > 0 ? { removed_member_ids: removed } : {}),
|
|
1151
|
+
});
|
|
1152
|
+
};
|
|
1153
|
+
const threadOr404 = (id) => {
|
|
1154
|
+
const found = channelOr404(id);
|
|
1155
|
+
if ('refused' in found)
|
|
1156
|
+
return found;
|
|
1157
|
+
return this.isThread(found.channel)
|
|
1158
|
+
? { thread: found.channel }
|
|
1159
|
+
: { refused: unknown('Channel', 10003) };
|
|
1160
|
+
};
|
|
1161
|
+
this.#route(E.JoinThread, ([id]) => {
|
|
1162
|
+
const found = threadOr404(id);
|
|
1163
|
+
if ('refused' in found)
|
|
1164
|
+
return found.refused;
|
|
1165
|
+
threadMembersUpdate(found.thread, [this.botId], []);
|
|
1166
|
+
return {};
|
|
1167
|
+
});
|
|
1168
|
+
this.#route(E.LeaveThread, ([id]) => {
|
|
1169
|
+
const found = threadOr404(id);
|
|
1170
|
+
if ('refused' in found)
|
|
1171
|
+
return found.refused;
|
|
1172
|
+
threadMembersUpdate(found.thread, [], [this.botId]);
|
|
1173
|
+
return {};
|
|
1174
|
+
});
|
|
1175
|
+
this.#route(E.AddThreadMember, ([id, userId]) => {
|
|
1176
|
+
const found = threadOr404(id);
|
|
1177
|
+
if ('refused' in found)
|
|
1178
|
+
return found.refused;
|
|
1179
|
+
if (!this.members.has(userId))
|
|
1180
|
+
return unknown('Member', 10007);
|
|
1181
|
+
threadMembersUpdate(found.thread, [userId], []);
|
|
1182
|
+
return {};
|
|
1183
|
+
});
|
|
1184
|
+
this.#route(E.RemoveThreadMember, ([id, userId]) => {
|
|
1185
|
+
const found = threadOr404(id);
|
|
1186
|
+
if ('refused' in found)
|
|
1187
|
+
return found.refused;
|
|
1188
|
+
threadMembersUpdate(found.thread, [], [userId]);
|
|
1189
|
+
return {};
|
|
1190
|
+
});
|
|
1191
|
+
this.#route(E.ListThreadMembers, ([id]) => {
|
|
1192
|
+
const found = threadOr404(id);
|
|
1193
|
+
if ('refused' in found)
|
|
1194
|
+
return found.refused;
|
|
1195
|
+
return {
|
|
1196
|
+
body: [...(this.threadMembers.get(id) ?? [])].map(userId => ({
|
|
1197
|
+
id,
|
|
1198
|
+
user_id: userId,
|
|
1199
|
+
join_timestamp: now(),
|
|
1200
|
+
flags: 0,
|
|
1201
|
+
...(this.members.get(userId)
|
|
1202
|
+
? { member: this.members.get(userId) }
|
|
1203
|
+
: {}),
|
|
1204
|
+
})),
|
|
1205
|
+
};
|
|
1206
|
+
});
|
|
1207
|
+
// Messages.
|
|
1208
|
+
const messageOr404 = (channelId, messageId) => {
|
|
1209
|
+
const found = channelOr404(channelId);
|
|
1210
|
+
if ('refused' in found)
|
|
1211
|
+
return found;
|
|
1212
|
+
const message = this.messages
|
|
1213
|
+
.get(channelId)
|
|
1214
|
+
?.find(one => one.id === messageId);
|
|
1215
|
+
return message
|
|
1216
|
+
? { channel: found.channel, message }
|
|
1217
|
+
: { refused: unknown('Message', 10008) };
|
|
1218
|
+
};
|
|
1219
|
+
this.#route(E.GetChannelMessages, ([channelId], request) => {
|
|
1220
|
+
const found = channelOr404(channelId);
|
|
1221
|
+
if ('refused' in found)
|
|
1222
|
+
return found.refused;
|
|
1223
|
+
const limit = Math.min(Number(request.query.limit?.[0] ?? 50), Limits.GetChannelMessages);
|
|
1224
|
+
// What only one person saw is not in the history of the channel.
|
|
1225
|
+
let list = (this.messages.get(channelId) ?? []).filter(message => ((message.flags ?? 0) & MessageFlags.Ephemeral) === 0);
|
|
1226
|
+
const before = request.query.before?.[0];
|
|
1227
|
+
const after = request.query.after?.[0];
|
|
1228
|
+
if (before)
|
|
1229
|
+
list = list.filter(message => BigInt(message.id) < BigInt(before));
|
|
1230
|
+
if (after)
|
|
1231
|
+
list = list.filter(message => BigInt(message.id) > BigInt(after));
|
|
1232
|
+
// Newest first, as Discord returns them.
|
|
1233
|
+
return { body: list.reverse().slice(0, limit) };
|
|
1234
|
+
});
|
|
1235
|
+
this.#route(E.GetChannelMessage, ([channelId, messageId]) => {
|
|
1236
|
+
const found = messageOr404(channelId, messageId);
|
|
1237
|
+
return 'refused' in found ? found.refused : { body: found.message };
|
|
1238
|
+
});
|
|
1239
|
+
this.#route(E.CreateMessage, ([channelId], request) => {
|
|
1240
|
+
const found = channelOr404(channelId);
|
|
1241
|
+
if ('refused' in found)
|
|
1242
|
+
return found.refused;
|
|
1243
|
+
return {
|
|
1244
|
+
body: this.createMessage(channelId, this.bot, body(request), request.files),
|
|
1245
|
+
};
|
|
1246
|
+
});
|
|
1247
|
+
this.#route(E.EditMessage, ([channelId, messageId], request) => {
|
|
1248
|
+
const found = messageOr404(channelId, messageId);
|
|
1249
|
+
if ('refused' in found)
|
|
1250
|
+
return found.refused;
|
|
1251
|
+
return { body: this.editMessage(found.message, body(request)) };
|
|
1252
|
+
});
|
|
1253
|
+
this.#route(E.DeleteMessage, ([channelId, messageId]) => {
|
|
1254
|
+
const found = messageOr404(channelId, messageId);
|
|
1255
|
+
if ('refused' in found)
|
|
1256
|
+
return found.refused;
|
|
1257
|
+
this.deleteMessage(found.message);
|
|
1258
|
+
return {};
|
|
1259
|
+
});
|
|
1260
|
+
this.#route(E.BulkDeleteMessages, ([channelId], request) => {
|
|
1261
|
+
const found = channelOr404(channelId);
|
|
1262
|
+
if ('refused' in found)
|
|
1263
|
+
return found.refused;
|
|
1264
|
+
const ids = body(request).messages ?? [];
|
|
1265
|
+
const list = this.messages.get(channelId);
|
|
1266
|
+
const deleted = ids.filter(id => list.some(message => message.id === id));
|
|
1267
|
+
this.messages.set(channelId, list.filter(message => !deleted.includes(message.id)));
|
|
1268
|
+
this.#dispatchIfConnected('MESSAGE_DELETE_BULK', {
|
|
1269
|
+
ids: deleted,
|
|
1270
|
+
channel_id: channelId,
|
|
1271
|
+
...(found.channel.guild_id ? { guild_id: found.channel.guild_id } : {}),
|
|
1272
|
+
});
|
|
1273
|
+
return {};
|
|
1274
|
+
});
|
|
1275
|
+
this.#route(E.PinMessage, ([channelId, messageId]) => {
|
|
1276
|
+
const found = messageOr404(channelId, messageId);
|
|
1277
|
+
if ('refused' in found)
|
|
1278
|
+
return found.refused;
|
|
1279
|
+
found.message.pinned = true;
|
|
1280
|
+
return {};
|
|
1281
|
+
});
|
|
1282
|
+
this.#route(E.UnpinMessage, ([channelId, messageId]) => {
|
|
1283
|
+
const found = messageOr404(channelId, messageId);
|
|
1284
|
+
if ('refused' in found)
|
|
1285
|
+
return found.refused;
|
|
1286
|
+
found.message.pinned = false;
|
|
1287
|
+
return {};
|
|
1288
|
+
});
|
|
1289
|
+
this.#route(E.CreateReaction, ([channelId, messageId, emoji]) => {
|
|
1290
|
+
const found = messageOr404(channelId, messageId);
|
|
1291
|
+
if ('refused' in found)
|
|
1292
|
+
return found.refused;
|
|
1293
|
+
const [name, id] = emoji.split(':');
|
|
1294
|
+
this.#dispatchIfConnected('MESSAGE_REACTION_ADD', {
|
|
1295
|
+
user_id: this.botId,
|
|
1296
|
+
channel_id: channelId,
|
|
1297
|
+
message_id: messageId,
|
|
1298
|
+
...(found.channel.guild_id
|
|
1299
|
+
? {
|
|
1300
|
+
guild_id: found.channel.guild_id,
|
|
1301
|
+
member: this.members.get(this.botId),
|
|
1302
|
+
}
|
|
1303
|
+
: {}),
|
|
1304
|
+
emoji: { id: id ?? null, name },
|
|
1305
|
+
burst: false,
|
|
1306
|
+
type: 0,
|
|
1307
|
+
});
|
|
1308
|
+
return {};
|
|
1309
|
+
});
|
|
1310
|
+
for (const endpoint of [
|
|
1311
|
+
E.DeleteOwnReaction,
|
|
1312
|
+
E.DeleteUserReaction,
|
|
1313
|
+
E.DeleteAllReactions,
|
|
1314
|
+
E.DeleteAllReactionsForEmoji,
|
|
1315
|
+
]) {
|
|
1316
|
+
this.#route(endpoint, ([channelId, messageId]) => {
|
|
1317
|
+
const found = messageOr404(channelId, messageId);
|
|
1318
|
+
return 'refused' in found ? found.refused : {};
|
|
1319
|
+
});
|
|
1320
|
+
}
|
|
1321
|
+
this.#route(E.GetReactions, ([channelId, messageId]) => {
|
|
1322
|
+
const found = messageOr404(channelId, messageId);
|
|
1323
|
+
return 'refused' in found ? found.refused : { body: [] };
|
|
1324
|
+
});
|
|
1325
|
+
// Interactions: the answers of the bot.
|
|
1326
|
+
const recordOr404 = (key) => {
|
|
1327
|
+
const record = this.interactions.get(key);
|
|
1328
|
+
return record ? { record } : { refused: unknown('Interaction', 10062) };
|
|
1329
|
+
};
|
|
1330
|
+
this.#route(E.CreateInteractionResponse, ([id, token], request) => {
|
|
1331
|
+
const found = recordOr404(id);
|
|
1332
|
+
if ('refused' in found || found.record.token !== token) {
|
|
1333
|
+
return unknown('Interaction', 10062);
|
|
1334
|
+
}
|
|
1335
|
+
const { record } = found;
|
|
1336
|
+
if (record.callbacks.length > 0) {
|
|
1337
|
+
return {
|
|
1338
|
+
status: 400,
|
|
1339
|
+
body: {
|
|
1340
|
+
message: 'Interaction has already been acknowledged.',
|
|
1341
|
+
code: 40060,
|
|
1342
|
+
},
|
|
1343
|
+
};
|
|
1344
|
+
}
|
|
1345
|
+
const response = body(request);
|
|
1346
|
+
record.callbacks.push(response);
|
|
1347
|
+
const channelId = record.raw.channel_id;
|
|
1348
|
+
const withResponse = request.query.with_response?.[0] === 'true';
|
|
1349
|
+
const metadata = {
|
|
1350
|
+
id: record.id,
|
|
1351
|
+
type: record.raw.type,
|
|
1352
|
+
user: record.raw.member?.user ?? record.raw.user,
|
|
1353
|
+
authorizing_integration_owners: {},
|
|
1354
|
+
};
|
|
1355
|
+
const data = (response.data ?? {});
|
|
1356
|
+
switch (response.type) {
|
|
1357
|
+
case InteractionCallbackType.ChannelMessageWithSource:
|
|
1358
|
+
record.original = this.createMessage(channelId, this.bot, data, request.files, { interaction_metadata: metadata });
|
|
1359
|
+
break;
|
|
1360
|
+
case InteractionCallbackType.DeferredChannelMessageWithSource:
|
|
1361
|
+
record.deferred = true;
|
|
1362
|
+
record.original = this.createMessage(channelId, this.bot, { flags: (data.flags ?? 0) | MessageFlags.Loading }, {}, { interaction_metadata: metadata });
|
|
1363
|
+
break;
|
|
1364
|
+
case InteractionCallbackType.DeferredUpdateMessage:
|
|
1365
|
+
record.deferred = true;
|
|
1366
|
+
record.updated = true;
|
|
1367
|
+
record.original = record.message;
|
|
1368
|
+
break;
|
|
1369
|
+
case InteractionCallbackType.UpdateMessage:
|
|
1370
|
+
record.updated = true;
|
|
1371
|
+
if (record.message) {
|
|
1372
|
+
record.original = this.editMessage(record.message, data);
|
|
1373
|
+
}
|
|
1374
|
+
break;
|
|
1375
|
+
case InteractionCallbackType.ApplicationCommandAutocompleteResult:
|
|
1376
|
+
record.suggestions =
|
|
1377
|
+
response.data.choices ??
|
|
1378
|
+
[];
|
|
1379
|
+
return {};
|
|
1380
|
+
case InteractionCallbackType.Modal:
|
|
1381
|
+
record.modal = response.data;
|
|
1382
|
+
return {};
|
|
1383
|
+
default:
|
|
1384
|
+
return {
|
|
1385
|
+
status: 400,
|
|
1386
|
+
body: {
|
|
1387
|
+
message: 'Invalid interaction response type.',
|
|
1388
|
+
code: 50035,
|
|
1389
|
+
},
|
|
1390
|
+
};
|
|
1391
|
+
}
|
|
1392
|
+
if (!withResponse)
|
|
1393
|
+
return {};
|
|
1394
|
+
return {
|
|
1395
|
+
body: {
|
|
1396
|
+
interaction: {
|
|
1397
|
+
id: record.id,
|
|
1398
|
+
type: record.raw.type,
|
|
1399
|
+
...(record.original
|
|
1400
|
+
? { response_message_id: record.original.id }
|
|
1401
|
+
: {}),
|
|
1402
|
+
response_message_loading: record.deferred,
|
|
1403
|
+
response_message_ephemeral: ((record.original?.flags ?? 0) & MessageFlags.Ephemeral) !== 0,
|
|
1404
|
+
},
|
|
1405
|
+
...(record.original
|
|
1406
|
+
? { resource: { type: response.type, message: record.original } }
|
|
1407
|
+
: {}),
|
|
1408
|
+
},
|
|
1409
|
+
};
|
|
1410
|
+
});
|
|
1411
|
+
this.#route(E.GetOriginalInteractionResponse, ([, token]) => {
|
|
1412
|
+
const found = recordOr404(token);
|
|
1413
|
+
if ('refused' in found)
|
|
1414
|
+
return found.refused;
|
|
1415
|
+
return found.record.original
|
|
1416
|
+
? { body: found.record.original }
|
|
1417
|
+
: unknown('Message', 10008);
|
|
1418
|
+
});
|
|
1419
|
+
this.#route(E.EditOriginalInteractionResponse, ([, token], request) => {
|
|
1420
|
+
const found = recordOr404(token);
|
|
1421
|
+
if ('refused' in found)
|
|
1422
|
+
return found.refused;
|
|
1423
|
+
const { record } = found;
|
|
1424
|
+
if (!record.original)
|
|
1425
|
+
return unknown('Message', 10008);
|
|
1426
|
+
return { body: this.editMessage(record.original, body(request)) };
|
|
1427
|
+
});
|
|
1428
|
+
this.#route(E.DeleteOriginalInteractionResponse, ([, token]) => {
|
|
1429
|
+
const found = recordOr404(token);
|
|
1430
|
+
if ('refused' in found)
|
|
1431
|
+
return found.refused;
|
|
1432
|
+
if (!found.record.original)
|
|
1433
|
+
return unknown('Message', 10008);
|
|
1434
|
+
this.deleteMessage(found.record.original);
|
|
1435
|
+
found.record.original = null;
|
|
1436
|
+
return {};
|
|
1437
|
+
});
|
|
1438
|
+
this.#route(E.CreateFollowupMessage, ([, token], request) => {
|
|
1439
|
+
const found = recordOr404(token);
|
|
1440
|
+
if ('refused' in found)
|
|
1441
|
+
return found.refused;
|
|
1442
|
+
const { record } = found;
|
|
1443
|
+
const message = this.createMessage(record.raw.channel_id, this.bot, body(request), request.files);
|
|
1444
|
+
record.followUps.push(message);
|
|
1445
|
+
return { body: message };
|
|
1446
|
+
});
|
|
1447
|
+
const followUpOr404 = (token, messageId) => {
|
|
1448
|
+
const found = recordOr404(token);
|
|
1449
|
+
if ('refused' in found)
|
|
1450
|
+
return found;
|
|
1451
|
+
const message = found.record.followUps.find(one => one.id === messageId);
|
|
1452
|
+
return message
|
|
1453
|
+
? { record: found.record, message }
|
|
1454
|
+
: { refused: unknown('Message', 10008) };
|
|
1455
|
+
};
|
|
1456
|
+
this.#route(E.GetFollowupMessage, ([, token, messageId]) => {
|
|
1457
|
+
const found = followUpOr404(token, messageId);
|
|
1458
|
+
return 'refused' in found ? found.refused : { body: found.message };
|
|
1459
|
+
});
|
|
1460
|
+
this.#route(E.EditFollowupMessage, ([, token, messageId], request) => {
|
|
1461
|
+
const found = followUpOr404(token, messageId);
|
|
1462
|
+
if ('refused' in found)
|
|
1463
|
+
return found.refused;
|
|
1464
|
+
return { body: this.editMessage(found.message, body(request)) };
|
|
1465
|
+
});
|
|
1466
|
+
this.#route(E.DeleteFollowupMessage, ([, token, messageId]) => {
|
|
1467
|
+
const found = followUpOr404(token, messageId);
|
|
1468
|
+
if ('refused' in found)
|
|
1469
|
+
return found.refused;
|
|
1470
|
+
this.deleteMessage(found.message);
|
|
1471
|
+
found.record.followUps.splice(found.record.followUps.indexOf(found.message), 1);
|
|
1472
|
+
return {};
|
|
1473
|
+
});
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
/** The raw interaction type of a kind of thing someone does. */
|
|
1477
|
+
export const INTERACTION_TYPES = InteractionType;
|
|
1478
|
+
/** Every permission, for the roles of tests. */
|
|
1479
|
+
export const EVERY_PERMISSION = ALL_PERMISSIONS;
|