@lilsnibbi/discord 1.0.1
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 +5 -0
- package/package.json +45 -0
- package/src/DiscordClient.ts +598 -0
- package/src/DiscordCommand.ts +93 -0
- package/src/DiscordEvent.ts +126 -0
- package/src/DiscordModuleLoader.ts +49 -0
- package/src/DiscordPagination.ts +626 -0
- package/src/DiscordShutdown.ts +110 -0
- package/src/container.ts +65 -0
- package/src/index.ts +18 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 lilsnibbi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lilsnibbi/discord",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "Client, command, event and pagination structures for discord.js bots on the Bun runtime.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"private": false,
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "./src/index.ts",
|
|
9
|
+
"types": "./src/index.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./src/index.ts",
|
|
13
|
+
"import": "./src/index.ts"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"src",
|
|
18
|
+
"LICENSE"
|
|
19
|
+
],
|
|
20
|
+
"engines": {
|
|
21
|
+
"bun": "^1.3.13"
|
|
22
|
+
},
|
|
23
|
+
"engineStrict": true,
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/lilsnibbi/packages.git",
|
|
27
|
+
"directory": "discord"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@lilsnibbi/logger": "^1.2.0"
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"discord.js": "^14.27.0"
|
|
34
|
+
},
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"discord.js": "14.27.0"
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"postpack": "bun ../scripts/license.ts --clean",
|
|
43
|
+
"prepack": "bun ../scripts/license.ts"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Client,
|
|
3
|
+
type ClientOptions,
|
|
4
|
+
type Guild,
|
|
5
|
+
Routes,
|
|
6
|
+
type APIApplicationCommand,
|
|
7
|
+
} from "discord.js";
|
|
8
|
+
import { Logger, type LoggerOptions } from "@lilsnibbi/logger";
|
|
9
|
+
import { DiscordCommand } from "./DiscordCommand";
|
|
10
|
+
import { type AnyDiscordEvent, DiscordEvent } from "./DiscordEvent";
|
|
11
|
+
import {
|
|
12
|
+
discoverDiscordModules,
|
|
13
|
+
importDiscordModule,
|
|
14
|
+
type DiscordModuleOptions,
|
|
15
|
+
} from "./DiscordModuleLoader";
|
|
16
|
+
import {
|
|
17
|
+
runDiscordShutdown,
|
|
18
|
+
validateShutdownTimeout,
|
|
19
|
+
type DiscordShutdownPhase,
|
|
20
|
+
} from "./DiscordShutdown";
|
|
21
|
+
import { container } from "./container";
|
|
22
|
+
|
|
23
|
+
/** The bot-specific half of {@link DiscordClientOptions}. */
|
|
24
|
+
export interface DiscordClientCustomOptions {
|
|
25
|
+
/** Configuration for the client's logger. */
|
|
26
|
+
logger: LoggerOptions;
|
|
27
|
+
/** Environment variables checked before setup, imports or login. */
|
|
28
|
+
requiredEnvs: string[];
|
|
29
|
+
/** Project root used to resolve module directories. */
|
|
30
|
+
root: string;
|
|
31
|
+
/** Default token for `login` and `init`. */
|
|
32
|
+
botToken: string;
|
|
33
|
+
/** Default guild for command registration and `mainGuild`. */
|
|
34
|
+
operatingGuildId?: string;
|
|
35
|
+
/** Application ID override, useful for registration before gateway login. */
|
|
36
|
+
applicationId?: string;
|
|
37
|
+
/** Module discovery configuration. Omit to use manual imports only. */
|
|
38
|
+
modules?: DiscordModuleOptions;
|
|
39
|
+
/** Duplicate command-name policy. Defaults to `replace` for compatibility. */
|
|
40
|
+
duplicateCommands?: "replace" | "skip" | "error";
|
|
41
|
+
/** Total `kill` deadline. Defaults to 7,000ms. */
|
|
42
|
+
shutdownTimeoutMs?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** discord.js options plus the client's own settings. */
|
|
46
|
+
export interface DiscordClientOptions extends ClientOptions {
|
|
47
|
+
/** Settings specific to the Discord package. */
|
|
48
|
+
custom: DiscordClientCustomOptions;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The pieces registered to a client, preserving its subclass type. */
|
|
52
|
+
export interface DiscordClientComponents<C extends Client = DiscordClient> {
|
|
53
|
+
/** Events awaiting attachment; emptied when listeners are bound. */
|
|
54
|
+
events: Set<AnyDiscordEvent<C>>;
|
|
55
|
+
/** Commands by `data.name`; names must be unique across command types. */
|
|
56
|
+
commands: Map<string, DiscordCommand<C>>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Options for explicitly replacing an application's command set. */
|
|
60
|
+
export interface DiscordCommandRegistrationOptions {
|
|
61
|
+
/** Guild ID, or `null` to explicitly target global commands. */
|
|
62
|
+
guildId?: string | null;
|
|
63
|
+
/** Overrides the configured or logged-in application ID. */
|
|
64
|
+
applicationId?: string;
|
|
65
|
+
/** Permit an empty registry to delete all commands in this scope. */
|
|
66
|
+
allowEmpty?: boolean;
|
|
67
|
+
/** Re-send an unchanged set after a previous successful registration. */
|
|
68
|
+
force?: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Where an asynchronous client failure originated. */
|
|
72
|
+
export type DiscordClientErrorContext =
|
|
73
|
+
| { type: "event"; source: AnyDiscordEvent["type"]; name: string }
|
|
74
|
+
| { type: "shutdown"; name: string };
|
|
75
|
+
|
|
76
|
+
/** Placement and deadline for an application-owned cleanup hook. */
|
|
77
|
+
export interface DiscordShutdownHookOptions {
|
|
78
|
+
/** Stop intake, drain work, or clean up after Discord disconnects. Default: cleanup. */
|
|
79
|
+
stage?: "stop" | "drain" | "cleanup";
|
|
80
|
+
/** Deadline for this hook. Defaults to 1,500ms. */
|
|
81
|
+
timeoutMs?: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A point-in-time snapshot; memory sizes are bytes and caches are entry counts. */
|
|
85
|
+
export interface DiscordClientStats {
|
|
86
|
+
ready: boolean;
|
|
87
|
+
uptime: number | null;
|
|
88
|
+
ping: number;
|
|
89
|
+
memory: ReturnType<typeof process.memoryUsage>;
|
|
90
|
+
cache: {
|
|
91
|
+
guilds: number;
|
|
92
|
+
users: number;
|
|
93
|
+
channels: number;
|
|
94
|
+
members: number;
|
|
95
|
+
presences: number;
|
|
96
|
+
messages: number;
|
|
97
|
+
};
|
|
98
|
+
commands: number;
|
|
99
|
+
events: number;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* A Discord client with module discovery, typed pieces, command registration
|
|
104
|
+
* and application-owned lifecycle hooks. `login` attaches pieces; `init` also
|
|
105
|
+
* awaits `setup`. Neither dispatches interactions or registers commands remotely.
|
|
106
|
+
*/
|
|
107
|
+
export class DiscordClient extends Client {
|
|
108
|
+
/** Logger constructed from `custom.logger`. */
|
|
109
|
+
public readonly log: Logger;
|
|
110
|
+
/** Settings supplied to the constructor. */
|
|
111
|
+
public readonly custom: DiscordClientCustomOptions;
|
|
112
|
+
/** Registry with handlers typed to this client. */
|
|
113
|
+
public readonly components: DiscordClientComponents<this>;
|
|
114
|
+
private readonly boundEvents = new Map<AnyDiscordEvent<this>, () => void>();
|
|
115
|
+
private readonly registeredEvents = new Set<AnyDiscordEvent<this>>();
|
|
116
|
+
private readonly loadedPaths = new Set<string>();
|
|
117
|
+
private readonly stopController = new AbortController();
|
|
118
|
+
private readonly shutdownHooks: {
|
|
119
|
+
phase: DiscordShutdownPhase;
|
|
120
|
+
stage: "stop" | "drain" | "cleanup";
|
|
121
|
+
}[] = [];
|
|
122
|
+
private moduleQueue: Promise<void> = Promise.resolve();
|
|
123
|
+
private setupPromise?: Promise<void>;
|
|
124
|
+
private initPromise?: Promise<void>;
|
|
125
|
+
private loginPromise?: Promise<string>;
|
|
126
|
+
private killPromise?: Promise<void>;
|
|
127
|
+
private destroyPromise?: Promise<void>;
|
|
128
|
+
private eventsActive = false;
|
|
129
|
+
private readonly commandQueues = new Map<
|
|
130
|
+
string,
|
|
131
|
+
Promise<APIApplicationCommand[]>
|
|
132
|
+
>();
|
|
133
|
+
private readonly commandRegistrations = new Map<
|
|
134
|
+
string,
|
|
135
|
+
{ body: string; result: APIApplicationCommand[] }
|
|
136
|
+
>();
|
|
137
|
+
|
|
138
|
+
constructor(ops: DiscordClientOptions) {
|
|
139
|
+
validateShutdownTimeout(ops.custom.shutdownTimeoutMs ?? 7_000);
|
|
140
|
+
super(ops);
|
|
141
|
+
this.custom = ops.custom;
|
|
142
|
+
this.log = new Logger({ ...this.custom.logger });
|
|
143
|
+
this.components = { events: new Set(), commands: new Map() };
|
|
144
|
+
container.client = this;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Alias for `components.commands`, suitable for existing Lumi dispatchers. */
|
|
148
|
+
public get commands(): Map<string, DiscordCommand<this>> {
|
|
149
|
+
return this.components.commands;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Reads this client's cache on every access; never shares a guild between clients. */
|
|
153
|
+
public get mainGuild(): Guild {
|
|
154
|
+
const id = this.custom.operatingGuildId;
|
|
155
|
+
if (!id) throw new Error("No operatingGuildId configured");
|
|
156
|
+
const guild = this.guilds.cache.get(id);
|
|
157
|
+
if (!guild) throw new Error(`Operating guild ${id} is not cached`);
|
|
158
|
+
return guild;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Fetches the configured guild when cache-only `mainGuild` is insufficient. */
|
|
162
|
+
public async fetchMainGuild(): Promise<Guild> {
|
|
163
|
+
const id = this.custom.operatingGuildId;
|
|
164
|
+
if (!id) throw new Error("No operatingGuildId configured");
|
|
165
|
+
return await this.guilds.fetch(id);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Aborted immediately when `kill` or `destroy` stops new work. */
|
|
169
|
+
public get shutdownSignal(): AbortSignal {
|
|
170
|
+
return this.stopController.signal;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Whether shutdown has started; no new pieces or startup work are accepted. */
|
|
174
|
+
public get isShuttingDown(): boolean {
|
|
175
|
+
return this.shutdownSignal.aborted;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Throws with missing variable names, never their values. */
|
|
179
|
+
public validateEnv(): void {
|
|
180
|
+
const missing = this.custom.requiredEnvs.filter(
|
|
181
|
+
(key) => !Bun.env[key]?.trim(),
|
|
182
|
+
);
|
|
183
|
+
if (missing.length)
|
|
184
|
+
throw new Error(`Missing env vars: ${missing.join(", ")}`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private assertActive(): void {
|
|
188
|
+
if (this.isShuttingDown)
|
|
189
|
+
throw new Error("DiscordClient is shutting down or destroyed");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Override to initialize application services before module imports and login. */
|
|
193
|
+
protected async setup(): Promise<void> {}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Runs setup once, then logs in. Concurrent calls share startup; failed setup
|
|
197
|
+
* can be retried. Setup implementations must clean up partial failures themselves.
|
|
198
|
+
* A gateway failure may destroy the underlying discord.js client; construct a
|
|
199
|
+
* new instance in that case. Does not await asynchronous ready handlers.
|
|
200
|
+
*/
|
|
201
|
+
public init(token = this.custom.botToken): Promise<void> {
|
|
202
|
+
if (this.isShuttingDown)
|
|
203
|
+
return Promise.reject(
|
|
204
|
+
new Error("DiscordClient is shutting down or destroyed"),
|
|
205
|
+
);
|
|
206
|
+
if (this.initPromise) return this.initPromise;
|
|
207
|
+
this.initPromise = Promise.resolve()
|
|
208
|
+
.then(async () => {
|
|
209
|
+
this.assertActive();
|
|
210
|
+
this.validateEnv();
|
|
211
|
+
this.setupPromise ??= Promise.resolve()
|
|
212
|
+
.then(() => this.setup())
|
|
213
|
+
.catch((error) => {
|
|
214
|
+
this.setupPromise = undefined;
|
|
215
|
+
throw error;
|
|
216
|
+
});
|
|
217
|
+
await this.setupPromise;
|
|
218
|
+
this.assertActive();
|
|
219
|
+
await this.login(token);
|
|
220
|
+
})
|
|
221
|
+
.catch((error) => {
|
|
222
|
+
this.initPromise = undefined;
|
|
223
|
+
throw error;
|
|
224
|
+
});
|
|
225
|
+
return this.initPromise;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Validates the environment, loads configured modules, and connects once. */
|
|
229
|
+
public override login(token = this.custom.botToken): Promise<string> {
|
|
230
|
+
if (this.isShuttingDown)
|
|
231
|
+
return Promise.reject(
|
|
232
|
+
new Error("DiscordClient is shutting down or destroyed"),
|
|
233
|
+
);
|
|
234
|
+
if (this.loginPromise) return this.loginPromise;
|
|
235
|
+
this.loginPromise = Promise.resolve()
|
|
236
|
+
.then(async () => {
|
|
237
|
+
this.assertActive();
|
|
238
|
+
this.validateEnv();
|
|
239
|
+
await this.loadModules();
|
|
240
|
+
this.assertActive();
|
|
241
|
+
const result = await super.login(token);
|
|
242
|
+
if (this.isShuttingDown) {
|
|
243
|
+
await super.destroy();
|
|
244
|
+
this.assertActive();
|
|
245
|
+
}
|
|
246
|
+
return result;
|
|
247
|
+
})
|
|
248
|
+
.catch((error) => {
|
|
249
|
+
this.loginPromise = undefined;
|
|
250
|
+
throw error;
|
|
251
|
+
});
|
|
252
|
+
return this.loginPromise;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Imports sorted default-exported pieces or factories `(client) => piece`.
|
|
257
|
+
* Factories can return arrays. Successful paths are loaded once per client;
|
|
258
|
+
* failures reject startup and remain retryable. Side-effect imports are supported.
|
|
259
|
+
*/
|
|
260
|
+
public loadModules(
|
|
261
|
+
options: DiscordModuleOptions = this.custom.modules ?? {},
|
|
262
|
+
): Promise<void> {
|
|
263
|
+
const load = this.moduleQueue
|
|
264
|
+
.catch(() => {})
|
|
265
|
+
.then(async () => {
|
|
266
|
+
this.assertActive();
|
|
267
|
+
this.validateEnv();
|
|
268
|
+
for (const path of await discoverDiscordModules(
|
|
269
|
+
this.custom.root,
|
|
270
|
+
options,
|
|
271
|
+
)) {
|
|
272
|
+
this.assertActive();
|
|
273
|
+
if (this.loadedPaths.has(path)) continue;
|
|
274
|
+
try {
|
|
275
|
+
let value = await importDiscordModule(path);
|
|
276
|
+
this.assertActive();
|
|
277
|
+
if (typeof value === "function") value = await value(this);
|
|
278
|
+
this.assertActive();
|
|
279
|
+
const pieces = (Array.isArray(value) ? value : [value]).filter(
|
|
280
|
+
(piece) => piece !== undefined,
|
|
281
|
+
);
|
|
282
|
+
const names = new Map(this.commands);
|
|
283
|
+
// Validate the whole export before changing the registry.
|
|
284
|
+
for (const piece of pieces) {
|
|
285
|
+
if (
|
|
286
|
+
!(piece instanceof DiscordEvent) &&
|
|
287
|
+
!(piece instanceof DiscordCommand)
|
|
288
|
+
) {
|
|
289
|
+
throw new TypeError(
|
|
290
|
+
"Expected a DiscordCommand or DiscordEvent default export",
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
if (piece instanceof DiscordCommand) {
|
|
294
|
+
const previous = names.get(piece.data.name);
|
|
295
|
+
if (
|
|
296
|
+
previous &&
|
|
297
|
+
previous !== piece &&
|
|
298
|
+
this.custom.duplicateCommands === "error"
|
|
299
|
+
) {
|
|
300
|
+
throw new Error(`Duplicate command: ${piece.data.name}`);
|
|
301
|
+
}
|
|
302
|
+
names.set(piece.data.name, piece);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
for (const piece of pieces) this.addPiece(piece);
|
|
306
|
+
this.loadedPaths.add(path);
|
|
307
|
+
} catch (cause) {
|
|
308
|
+
throw new Error(`Failed to load Discord module: ${path}`, {
|
|
309
|
+
cause,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
this.assertActive();
|
|
314
|
+
this.eventsActive = true;
|
|
315
|
+
for (const event of this.components.events) this.bindEvent(event);
|
|
316
|
+
});
|
|
317
|
+
this.moduleQueue = load;
|
|
318
|
+
return load;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Register a piece, retaining contextual event-source and subclass inference. */
|
|
322
|
+
// biome-ignore lint/suspicious/noExplicitAny: event-name widening preserves source inference
|
|
323
|
+
public registerPiece(piece: DiscordEvent<"client", any, this>): void;
|
|
324
|
+
// biome-ignore lint/suspicious/noExplicitAny: event-name widening preserves source inference
|
|
325
|
+
public registerPiece(piece: DiscordEvent<"rest", any, this>): void;
|
|
326
|
+
// biome-ignore lint/suspicious/noExplicitAny: event-name widening preserves source inference
|
|
327
|
+
public registerPiece(piece: DiscordEvent<"custom", any, this>): void;
|
|
328
|
+
public registerPiece(piece: DiscordCommand<this>): void;
|
|
329
|
+
public registerPiece(
|
|
330
|
+
piece: AnyDiscordEvent<this> | DiscordCommand<this>,
|
|
331
|
+
): void {
|
|
332
|
+
this.addPiece(piece);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
private addPiece(piece: AnyDiscordEvent<this> | DiscordCommand<this>): void {
|
|
336
|
+
this.assertActive();
|
|
337
|
+
if (piece instanceof DiscordEvent) {
|
|
338
|
+
if (this.registeredEvents.has(piece)) return;
|
|
339
|
+
this.registeredEvents.add(piece);
|
|
340
|
+
this.components.events.add(piece);
|
|
341
|
+
if (this.eventsActive) this.bindEvent(piece);
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
if (piece instanceof DiscordCommand) {
|
|
345
|
+
const previous = this.commands.get(piece.data.name);
|
|
346
|
+
if (previous && previous !== piece) {
|
|
347
|
+
if (this.custom.duplicateCommands === "error")
|
|
348
|
+
throw new Error(`Duplicate command: ${piece.data.name}`);
|
|
349
|
+
if (this.custom.duplicateCommands === "skip") return;
|
|
350
|
+
}
|
|
351
|
+
this.commands.set(piece.data.name, piece);
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
const label = (piece as object)?.constructor?.name ?? String(piece);
|
|
355
|
+
this.log.alert(`Unsupported piece loaded. Piece: ${label}`, {
|
|
356
|
+
box: { topRight: "PieceLoader" },
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Detaches only this piece; a replaced command cannot remove its replacement. */
|
|
361
|
+
public unregisterPiece(
|
|
362
|
+
piece: AnyDiscordEvent<this> | DiscordCommand<this>,
|
|
363
|
+
): boolean {
|
|
364
|
+
if (piece instanceof DiscordCommand) {
|
|
365
|
+
return (
|
|
366
|
+
this.commands.get(piece.data.name) === piece &&
|
|
367
|
+
this.commands.delete(piece.data.name)
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
this.boundEvents.get(piece)?.();
|
|
371
|
+
this.boundEvents.delete(piece);
|
|
372
|
+
this.components.events.delete(piece);
|
|
373
|
+
return this.registeredEvents.delete(piece);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
private bindEvent(event: AnyDiscordEvent<this>): void {
|
|
377
|
+
if (this.boundEvents.has(event)) return;
|
|
378
|
+
const listener = (...args: unknown[]) => {
|
|
379
|
+
if (event.once) this.boundEvents.delete(event);
|
|
380
|
+
if (this.isShuttingDown) return;
|
|
381
|
+
try {
|
|
382
|
+
return Promise.resolve(event.method(this, ...args)).catch((error) =>
|
|
383
|
+
this.reportError(error, {
|
|
384
|
+
type: "event",
|
|
385
|
+
source: event.type,
|
|
386
|
+
name: String(event.name),
|
|
387
|
+
}),
|
|
388
|
+
);
|
|
389
|
+
} catch (error) {
|
|
390
|
+
return this.reportError(error, {
|
|
391
|
+
type: "event",
|
|
392
|
+
source: event.type,
|
|
393
|
+
name: String(event.name),
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
};
|
|
397
|
+
if (event.type === "rest") {
|
|
398
|
+
this.rest[event.once ? "once" : "on"](event.name, listener);
|
|
399
|
+
this.boundEvents.set(event, () => this.rest.off(event.name, listener));
|
|
400
|
+
} else {
|
|
401
|
+
this[event.once ? "once" : "on"](event.name, listener);
|
|
402
|
+
this.boundEvents.set(event, () => this.off(event.name, listener));
|
|
403
|
+
}
|
|
404
|
+
this.components.events.delete(event);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** Override to integrate an application's error reporter. */
|
|
408
|
+
protected onError(
|
|
409
|
+
error: unknown,
|
|
410
|
+
context: DiscordClientErrorContext,
|
|
411
|
+
): void | Promise<void> {
|
|
412
|
+
this.log.error(
|
|
413
|
+
new Error(`[${context.type}:${context.name}] ${String(error)}`, {
|
|
414
|
+
cause: error,
|
|
415
|
+
}),
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
private async reportError(
|
|
420
|
+
error: unknown,
|
|
421
|
+
context: DiscordClientErrorContext,
|
|
422
|
+
): Promise<void> {
|
|
423
|
+
try {
|
|
424
|
+
await this.onError(error, context);
|
|
425
|
+
} catch (reportError) {
|
|
426
|
+
// Reporter failures must not become unhandled event rejections.
|
|
427
|
+
try {
|
|
428
|
+
this.log.error(
|
|
429
|
+
new AggregateError(
|
|
430
|
+
[error, reportError],
|
|
431
|
+
"Client error reporter failed",
|
|
432
|
+
),
|
|
433
|
+
);
|
|
434
|
+
} catch {}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Bulk-replaces commands in one explicit scope. No REST calls happen during login.
|
|
440
|
+
* Identical successful bodies are skipped; failures are retryable and requests
|
|
441
|
+
* for the same scope are serialized. Empty sets require `allowEmpty: true`.
|
|
442
|
+
*/
|
|
443
|
+
public async registerCommands(
|
|
444
|
+
options: DiscordCommandRegistrationOptions = {},
|
|
445
|
+
): Promise<APIApplicationCommand[]> {
|
|
446
|
+
this.assertActive();
|
|
447
|
+
const applicationId =
|
|
448
|
+
options.applicationId ??
|
|
449
|
+
this.custom.applicationId ??
|
|
450
|
+
this.application?.id ??
|
|
451
|
+
this.user?.id;
|
|
452
|
+
if (!applicationId)
|
|
453
|
+
throw new Error(
|
|
454
|
+
"An application ID or logged-in client is required to register commands",
|
|
455
|
+
);
|
|
456
|
+
const guildId =
|
|
457
|
+
options.guildId === undefined
|
|
458
|
+
? this.custom.operatingGuildId
|
|
459
|
+
: options.guildId;
|
|
460
|
+
if (guildId === undefined || guildId === "")
|
|
461
|
+
throw new Error(
|
|
462
|
+
"Set operatingGuildId or pass guildId (null for global commands)",
|
|
463
|
+
);
|
|
464
|
+
const body = [...this.commands.values()].map(({ data }) => data.toJSON());
|
|
465
|
+
if (!body.length && !options.allowEmpty)
|
|
466
|
+
throw new Error("Refusing to clear commands without allowEmpty: true");
|
|
467
|
+
const route =
|
|
468
|
+
guildId === null
|
|
469
|
+
? Routes.applicationCommands(applicationId)
|
|
470
|
+
: Routes.applicationGuildCommands(applicationId, guildId);
|
|
471
|
+
const serialized = JSON.stringify(body);
|
|
472
|
+
const request = (this.commandQueues.get(route) ?? Promise.resolve())
|
|
473
|
+
.catch(() => {})
|
|
474
|
+
.then(async () => {
|
|
475
|
+
this.assertActive();
|
|
476
|
+
const previous = this.commandRegistrations.get(route);
|
|
477
|
+
if (!options.force && previous?.body === serialized)
|
|
478
|
+
return previous.result;
|
|
479
|
+
const result = (await this.rest.put(route, {
|
|
480
|
+
body,
|
|
481
|
+
})) as APIApplicationCommand[];
|
|
482
|
+
this.commandRegistrations.set(route, { body: serialized, result });
|
|
483
|
+
return result;
|
|
484
|
+
});
|
|
485
|
+
this.commandQueues.set(route, request);
|
|
486
|
+
try {
|
|
487
|
+
return await request;
|
|
488
|
+
} finally {
|
|
489
|
+
if (this.commandQueues.get(route) === request)
|
|
490
|
+
this.commandQueues.delete(route);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** Registers an ordered cleanup hook; returns a function that removes it. */
|
|
495
|
+
public addShutdownHook(
|
|
496
|
+
label: string,
|
|
497
|
+
task: (signal: AbortSignal) => unknown | Promise<unknown>,
|
|
498
|
+
options: DiscordShutdownHookOptions = {},
|
|
499
|
+
): () => void {
|
|
500
|
+
this.assertActive();
|
|
501
|
+
validateShutdownTimeout(options.timeoutMs ?? 1_500);
|
|
502
|
+
const hook = {
|
|
503
|
+
phase: { label, tasks: [task], timeoutMs: options.timeoutMs },
|
|
504
|
+
stage: options.stage ?? "cleanup",
|
|
505
|
+
};
|
|
506
|
+
this.shutdownHooks.push(hook);
|
|
507
|
+
return () => {
|
|
508
|
+
const index = this.shutdownHooks.indexOf(hook);
|
|
509
|
+
if (index !== -1) this.shutdownHooks.splice(index, 1);
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Stops registered event intake immediately, then runs stop/drain hooks,
|
|
515
|
+
* disconnects Discord, and runs cleanup hooks. Repeated calls share one promise.
|
|
516
|
+
* Failures are reported and cleanup continues within the total deadline.
|
|
517
|
+
*/
|
|
518
|
+
public kill(): Promise<void> {
|
|
519
|
+
if (this.killPromise) return this.killPromise;
|
|
520
|
+
const phases = (stage: "stop" | "drain" | "cleanup") =>
|
|
521
|
+
this.shutdownHooks
|
|
522
|
+
.filter((hook) => hook.stage === stage)
|
|
523
|
+
.map((hook) => hook.phase);
|
|
524
|
+
this.killPromise = Promise.resolve().then(async () => {
|
|
525
|
+
try {
|
|
526
|
+
await runDiscordShutdown(
|
|
527
|
+
[
|
|
528
|
+
...phases("stop"),
|
|
529
|
+
...phases("drain"),
|
|
530
|
+
{ label: "Discord", tasks: [() => this.destroy()] },
|
|
531
|
+
...phases("cleanup"),
|
|
532
|
+
],
|
|
533
|
+
{
|
|
534
|
+
budgetMs: this.custom.shutdownTimeoutMs,
|
|
535
|
+
onError: (name, error) =>
|
|
536
|
+
this.reportError(error, { type: "shutdown", name }),
|
|
537
|
+
},
|
|
538
|
+
);
|
|
539
|
+
} finally {
|
|
540
|
+
// Even an exhausted hook budget must initiate gateway teardown.
|
|
541
|
+
void this.destroy().catch((error) =>
|
|
542
|
+
this.reportError(error, { type: "shutdown", name: "Discord" }),
|
|
543
|
+
);
|
|
544
|
+
this.shutdownHooks.length = 0;
|
|
545
|
+
}
|
|
546
|
+
});
|
|
547
|
+
this.stopController.abort();
|
|
548
|
+
this.detachEvents();
|
|
549
|
+
return this.killPromise;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
private detachEvents(): void {
|
|
553
|
+
for (const detach of this.boundEvents.values()) detach();
|
|
554
|
+
this.boundEvents.clear();
|
|
555
|
+
this.components.events.clear();
|
|
556
|
+
this.registeredEvents.clear();
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** Immediate Discord teardown. Use `kill` to also run application cleanup hooks. */
|
|
560
|
+
public override destroy(): Promise<void> {
|
|
561
|
+
if (this.destroyPromise) return this.destroyPromise;
|
|
562
|
+
this.destroyPromise = Promise.resolve().then(() => super.destroy());
|
|
563
|
+
this.stopController.abort();
|
|
564
|
+
this.detachEvents();
|
|
565
|
+
if (container.client === this) Reflect.deleteProperty(container, "client");
|
|
566
|
+
return this.destroyPromise;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** Cache and process memory statistics, without forcing GC or starting a timer. */
|
|
570
|
+
public getStats(): DiscordClientStats {
|
|
571
|
+
return {
|
|
572
|
+
ready: this.isReady(),
|
|
573
|
+
uptime: this.uptime,
|
|
574
|
+
ping: this.ws.ping,
|
|
575
|
+
memory: process.memoryUsage(),
|
|
576
|
+
cache: {
|
|
577
|
+
guilds: this.guilds.cache.size,
|
|
578
|
+
users: this.users.cache.size,
|
|
579
|
+
channels: this.channels.cache.size,
|
|
580
|
+
members: this.guilds.cache.reduce(
|
|
581
|
+
(total, guild) => total + guild.members.cache.size,
|
|
582
|
+
0,
|
|
583
|
+
),
|
|
584
|
+
presences: this.guilds.cache.reduce(
|
|
585
|
+
(total, guild) => total + guild.presences.cache.size,
|
|
586
|
+
0,
|
|
587
|
+
),
|
|
588
|
+
messages: this.channels.cache.reduce(
|
|
589
|
+
(total, channel) =>
|
|
590
|
+
total + ("messages" in channel ? channel.messages.cache.size : 0),
|
|
591
|
+
0,
|
|
592
|
+
),
|
|
593
|
+
},
|
|
594
|
+
commands: this.commands.size,
|
|
595
|
+
events: this.boundEvents.size,
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
}
|