lunibee 0.1.6 → 0.1.7
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 +3 -3
- package/dist/builders/commands.d.ts +37 -0
- package/dist/builders/components.d.ts +19 -12
- package/dist/builders/embed.d.ts +3 -1
- package/dist/builders/index.d.ts +1 -1
- package/dist/builders/index.js +146 -19
- package/dist/builders/index.js.map +8 -7
- package/dist/collection/index.d.ts +30 -0
- package/dist/collection/index.js +78 -5
- package/dist/collection/index.js.map +4 -4
- package/dist/core/events.d.ts +2 -0
- package/dist/core/index.d.ts +38 -0
- package/dist/core/index.js +754 -175
- package/dist/core/index.js.map +24 -22
- package/dist/core/permissions.d.ts +112 -53
- package/dist/formatters/index.d.ts +43 -3
- package/dist/formatters/index.js +64 -1
- package/dist/formatters/index.js.map +3 -3
- package/dist/handlers/index.js.map +2 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1762 -291
- package/dist/index.js.map +43 -38
- package/dist/managers/emoji.d.ts +38 -0
- package/dist/managers/guild.d.ts +112 -1
- package/dist/managers/index.d.ts +31 -1
- package/dist/managers/index.js +803 -77
- package/dist/managers/index.js.map +21 -20
- package/dist/rest/index.d.ts +101 -11
- package/dist/rest/index.js +301 -45
- package/dist/rest/index.js.map +8 -6
- package/dist/rest/redis.d.ts +37 -0
- package/dist/rest/routes.d.ts +22 -0
- package/dist/rest/store.d.ts +38 -0
- package/dist/rest/webhook.d.ts +2 -2
- package/dist/sharding/cluster.d.ts +61 -0
- package/dist/sharding/index.d.ts +8 -0
- package/dist/sharding/index.js +286 -18
- package/dist/sharding/index.js.map +8 -7
- package/dist/structures/audit-log.d.ts +26 -0
- package/dist/structures/base.d.ts +32 -0
- package/dist/structures/index.d.ts +29 -1
- package/dist/structures/index.js +1022 -86
- package/dist/structures/index.js.map +14 -10
- package/dist/structures/resources.d.ts +192 -4
- package/dist/types/index.d.ts +76 -0
- package/dist/types/index.js.map +2 -2
- package/dist/utils/index.js.map +2 -2
- package/dist/voice/index.d.ts +88 -0
- package/dist/voice/index.js +204 -5
- package/dist/voice/index.js.map +3 -3
- package/dist/ws/index.d.ts +40 -1
- package/dist/ws/index.js +102 -18
- package/dist/ws/index.js.map +4 -4
- package/package.json +16 -16
- package/dist/structures/permissions.d.ts +0 -73
package/dist/rest/index.d.ts
CHANGED
|
@@ -12,15 +12,86 @@ export declare class RESTError extends Error {
|
|
|
12
12
|
cause?: unknown;
|
|
13
13
|
});
|
|
14
14
|
}
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
/** Abort signal used to cancel the request and any queued wait. */ signal?: AbortSignal;
|
|
18
|
-
}
|
|
15
|
+
import { type RateLimitStore } from "./store.js";
|
|
16
|
+
/** A file attachment sent as part of a multipart REST request. */
|
|
19
17
|
export interface RESTFileAttachment {
|
|
20
18
|
name: string;
|
|
21
19
|
data: Blob | Uint8Array | ArrayBuffer;
|
|
22
20
|
contentType?: string;
|
|
23
21
|
}
|
|
22
|
+
/** Query-string value accepted by {@link RESTRequestOptions.query}. */
|
|
23
|
+
export type RESTQuery = URLSearchParams | Record<string, string | number | boolean | null | undefined> | string;
|
|
24
|
+
/**
|
|
25
|
+
* Options controlling an individual REST request.
|
|
26
|
+
*
|
|
27
|
+
* Lunibee's canonical call shape is positional (`post(path, body)`); these options
|
|
28
|
+
* add the Discord.js-`@discordjs/rest`-familiar per-request knobs (`query`, `headers`,
|
|
29
|
+
* `reason`, `auth`) additively without changing that positional default.
|
|
30
|
+
*/
|
|
31
|
+
export interface RESTRequestOptions {
|
|
32
|
+
/** Abort signal used to cancel the request and any queued wait. */ signal?: AbortSignal;
|
|
33
|
+
/** Query-string appended to the path (`URLSearchParams`, a record, or a raw string). */ query?: RESTQuery;
|
|
34
|
+
/** Extra request headers merged in (library headers such as `Authorization` win). */ headers?: Record<string, string>;
|
|
35
|
+
/** Audit-log reason, sent as the `X-Audit-Log-Reason` header. */ reason?: string;
|
|
36
|
+
/** Set `false` to omit the `Authorization` header for this request. Defaults to `true`. */ auth?: boolean;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Discord.js-familiar request payload. Superset of {@link RESTRequestOptions} that also
|
|
40
|
+
* carries the `body` and `files`, matching `@discordjs/rest`'s `RequestData`. It powers the
|
|
41
|
+
* additive `method(path, { body })` overloads without breaking the positional signature.
|
|
42
|
+
*/
|
|
43
|
+
export interface RequestData extends RESTRequestOptions {
|
|
44
|
+
/** Request body. Encoded as JSON unless `files` are present (then multipart). */ body?: unknown;
|
|
45
|
+
/** File attachments; presence upgrades the request to `multipart/form-data`. */ files?: RESTFileAttachment[];
|
|
46
|
+
}
|
|
47
|
+
/** Context passed to `RESTHooks.onRequest`. */
|
|
48
|
+
export interface RESTRequestContext {
|
|
49
|
+
method: string;
|
|
50
|
+
path: string;
|
|
51
|
+
attempt: number;
|
|
52
|
+
}
|
|
53
|
+
/** Context passed to `RESTHooks.onResponse`. */
|
|
54
|
+
export interface RESTResponseContext {
|
|
55
|
+
method: string;
|
|
56
|
+
path: string;
|
|
57
|
+
status: number;
|
|
58
|
+
durationMs: number;
|
|
59
|
+
}
|
|
60
|
+
/** Context passed to `RESTHooks.onRateLimit`. */
|
|
61
|
+
export interface RESTRateLimitContext {
|
|
62
|
+
method: string;
|
|
63
|
+
path: string;
|
|
64
|
+
retryAfterMs: number;
|
|
65
|
+
global: boolean;
|
|
66
|
+
bucket?: string;
|
|
67
|
+
}
|
|
68
|
+
/** Context passed to `RESTHooks.onRetry`. */
|
|
69
|
+
export interface RESTRetryContext {
|
|
70
|
+
method: string;
|
|
71
|
+
path: string;
|
|
72
|
+
attempt: number;
|
|
73
|
+
status: number;
|
|
74
|
+
delayMs: number;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Lightweight callback hooks for REST request lifecycle events.
|
|
78
|
+
*
|
|
79
|
+
* All callbacks are optional. They are dispatched in isolation from the request
|
|
80
|
+
* path (deferred to a microtask, and any thrown error is swallowed), so a hook
|
|
81
|
+
* cannot break or reject a request. Even so, hooks **must** be fast and
|
|
82
|
+
* non-blocking: heavy synchronous work in a hook still steals event-loop time
|
|
83
|
+
* from in-flight requests. Offload anything expensive to your own queue.
|
|
84
|
+
*/
|
|
85
|
+
export interface RESTHooks {
|
|
86
|
+
/** Called just before every HTTP attempt. */
|
|
87
|
+
onRequest?(ctx: RESTRequestContext): void;
|
|
88
|
+
/** Called after a successful or failed response is received. */
|
|
89
|
+
onResponse?(ctx: RESTResponseContext): void;
|
|
90
|
+
/** Called when a rate limit is encountered and the client is about to wait. */
|
|
91
|
+
onRateLimit?(ctx: RESTRateLimitContext): void;
|
|
92
|
+
/** Called when a failed request is about to be retried. */
|
|
93
|
+
onRetry?(ctx: RESTRetryContext): void;
|
|
94
|
+
}
|
|
24
95
|
/** Configures which REST failures may be retried. */
|
|
25
96
|
export interface RetryPolicy {
|
|
26
97
|
/** Maximum retry attempts. */ maxRetries: number;
|
|
@@ -39,7 +110,11 @@ export declare class REST {
|
|
|
39
110
|
retries?: number;
|
|
40
111
|
retryPolicy?: RetryPolicy;
|
|
41
112
|
baseURL?: string;
|
|
113
|
+
hooks?: RESTHooks;
|
|
114
|
+
store?: RateLimitStore;
|
|
42
115
|
});
|
|
116
|
+
/** Replaces the current hook set. Pass an empty object to clear all hooks. */
|
|
117
|
+
setHooks(hooks: RESTHooks): this;
|
|
43
118
|
/** Sets the authentication token. @param token Discord bot token. @returns Nothing. @throws {TypeError} If token is empty. */
|
|
44
119
|
setToken(token: string): void;
|
|
45
120
|
/** Executes an HTTP request with Discord bucket/global rate limits, retry handling, and AbortSignal support.
|
|
@@ -47,16 +122,23 @@ export declare class REST {
|
|
|
47
122
|
* @param method HTTP method.
|
|
48
123
|
* @param path API path.
|
|
49
124
|
* @param body Optional JSON body, FormData for file uploads, or undefined.
|
|
50
|
-
* @param options
|
|
125
|
+
* @param options Request options: cancellation `signal` plus the Discord.js-familiar
|
|
126
|
+
* `query`, `headers`, `reason`, `auth`, and `files` (which upgrade to multipart).
|
|
51
127
|
* @returns Decoded response body.
|
|
52
128
|
* @throws {RESTError} If Discord or transport rejects the request.
|
|
53
129
|
*/
|
|
54
|
-
request<T>(method: string, path: string, body?: unknown, options?:
|
|
55
|
-
/** Sends a GET request. @param path API path. @param options
|
|
56
|
-
/** Sends a POST request. @param path API path. @param body Optional JSON body. @param options
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
/** Sends a
|
|
130
|
+
request<T>(method: string, path: string, body?: unknown, options?: RequestData): Promise<T>;
|
|
131
|
+
/** Sends a GET request. @param path API path. @param options Request options (`query`, `headers`, `signal`, …). @returns Decoded response. @throws {RESTError} When the request fails. */ get<T>(path: string, options?: RequestData): Promise<T>;
|
|
132
|
+
/** Sends a POST request. Accepts a raw body (`post(path, body)`) or a Discord.js-familiar `post(path, { body })`. @param path API path. @param body Optional JSON body, or a `RequestData` wrapper. @param options Request options. @returns Decoded response. @throws {RESTError} When the request fails. */
|
|
133
|
+
post<T>(path: string, options: RequestData): Promise<T>;
|
|
134
|
+
post<T>(path: string, body?: unknown, options?: RequestData): Promise<T>;
|
|
135
|
+
/** Sends a PATCH request. Accepts a raw body (`patch(path, body)`) or a Discord.js-familiar `patch(path, { body })`. @param path API path. @param body Optional JSON body, or a `RequestData` wrapper. @param options Request options. @returns Decoded response. @throws {RESTError} When the request fails. */
|
|
136
|
+
patch<T>(path: string, options: RequestData): Promise<T>;
|
|
137
|
+
patch<T>(path: string, body?: unknown, options?: RequestData): Promise<T>;
|
|
138
|
+
/** Sends a PUT request. Accepts a raw body (`put(path, body)`) or a Discord.js-familiar `put(path, { body })`. @param path API path. @param body Optional JSON body, or a `RequestData` wrapper. @param options Request options. @returns Decoded response. @throws {RESTError} When the request fails. */
|
|
139
|
+
put<T>(path: string, options: RequestData): Promise<T>;
|
|
140
|
+
put<T>(path: string, body?: unknown, options?: RequestData): Promise<T>;
|
|
141
|
+
/** Sends a DELETE request. @param path API path. @param options Request options (`reason`, `headers`, `signal`, …). @returns Decoded response. @throws {RESTError} When the request fails. */ delete<T>(path: string, options?: RequestData): Promise<T>;
|
|
60
142
|
/**
|
|
61
143
|
* Sends a multipart/form-data POST request for file uploads.
|
|
62
144
|
* Attaches a JSON payload as `payload_json` and files as additional fields.
|
|
@@ -80,4 +162,12 @@ export declare class REST {
|
|
|
80
162
|
patchWithFiles<T>(path: string, payload: unknown, files: RESTFileAttachment[], options?: RESTRequestOptions): Promise<T>;
|
|
81
163
|
}
|
|
82
164
|
export { Routes } from "./routes.js";
|
|
165
|
+
export { type RateLimitStore, type BucketState, MemoryRateLimitStore, } from "./store.js";
|
|
166
|
+
export { RedisRateLimitStore, type RedisRateLimitStoreOptions, type MinimalRedisClient, } from "./redis.js";
|
|
83
167
|
export { WebhookClient, type WebhookClientOptions, type WebhookMessageOptions, } from "./webhook.js";
|
|
168
|
+
/**
|
|
169
|
+
* Discord.js-familiar alias for {@link RESTError}. `@discordjs/rest` throws `DiscordAPIError`
|
|
170
|
+
* on a Discord-rejected request; Lunibee keeps `RESTError` as the canonical class and exports
|
|
171
|
+
* this alias so Discord.js-shaped `catch (err) { if (err instanceof DiscordAPIError) … }` works.
|
|
172
|
+
*/
|
|
173
|
+
export { RESTError as DiscordAPIError };
|
package/dist/rest/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// packages/rest/package.json
|
|
3
3
|
var package_default = {
|
|
4
4
|
name: "@lunibee/rest",
|
|
5
|
-
version: "0.1.
|
|
5
|
+
version: "0.1.7",
|
|
6
6
|
type: "module",
|
|
7
7
|
module: "./src/index.ts",
|
|
8
8
|
types: "./src/index.ts",
|
|
@@ -11,6 +11,45 @@ var package_default = {
|
|
|
11
11
|
}
|
|
12
12
|
};
|
|
13
13
|
|
|
14
|
+
// packages/rest/src/store.ts
|
|
15
|
+
class MemoryRateLimitStore {
|
|
16
|
+
#buckets = new Map;
|
|
17
|
+
#routeBuckets = new Map;
|
|
18
|
+
#globalResetAt = 0;
|
|
19
|
+
static #PRUNE_INTERVAL = 60000;
|
|
20
|
+
#lastPruneAt = 0;
|
|
21
|
+
getBucketHash(route) {
|
|
22
|
+
return this.#routeBuckets.get(route);
|
|
23
|
+
}
|
|
24
|
+
setBucketHash(route, hash) {
|
|
25
|
+
this.#routeBuckets.set(route, hash);
|
|
26
|
+
}
|
|
27
|
+
getBucket(key) {
|
|
28
|
+
return this.#buckets.get(key);
|
|
29
|
+
}
|
|
30
|
+
updateBucket(key, state) {
|
|
31
|
+
this.#buckets.set(key, state);
|
|
32
|
+
const now = Date.now();
|
|
33
|
+
if (now - this.#lastPruneAt >= MemoryRateLimitStore.#PRUNE_INTERVAL) {
|
|
34
|
+
this.#lastPruneAt = now;
|
|
35
|
+
this.prune();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
prune(maxAge = 0) {
|
|
39
|
+
const cutoff = Date.now() - Math.max(0, maxAge);
|
|
40
|
+
for (const [key, state] of this.#buckets) {
|
|
41
|
+
if (state.resetAt <= cutoff)
|
|
42
|
+
this.#buckets.delete(key);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
getGlobalReset() {
|
|
46
|
+
return this.#globalResetAt;
|
|
47
|
+
}
|
|
48
|
+
setGlobalReset(resetAt) {
|
|
49
|
+
this.#globalResetAt = resetAt;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
14
53
|
// packages/rest/src/routes.ts
|
|
15
54
|
var Routes = {
|
|
16
55
|
user: () => "/users/@me",
|
|
@@ -56,13 +95,122 @@ var Routes = {
|
|
|
56
95
|
guildAutoModerationRule: (guildId, ruleId) => `/guilds/${snowflake(guildId, "Guild ID")}/auto-moderation/rules/${snowflake(ruleId, "Rule ID")}`,
|
|
57
96
|
voiceRegions: () => "/voice/regions",
|
|
58
97
|
gateway: () => "/gateway",
|
|
59
|
-
gatewayBot: () => "/gateway/bot"
|
|
98
|
+
gatewayBot: () => "/gateway/bot",
|
|
99
|
+
guildAuditLog: (guildId) => `/guilds/${snowflake(guildId, "Guild ID")}/audit-logs`,
|
|
100
|
+
guildMembers: (guildId) => `/guilds/${snowflake(guildId, "Guild ID")}/members`,
|
|
101
|
+
guildWelcomeScreen: (guildId) => `/guilds/${snowflake(guildId, "Guild ID")}/welcome-screen`,
|
|
102
|
+
guildOnboarding: (guildId) => `/guilds/${snowflake(guildId, "Guild ID")}/onboarding`,
|
|
103
|
+
guildPrune: (guildId) => `/guilds/${snowflake(guildId, "Guild ID")}/prune`,
|
|
104
|
+
stageInstances: () => "/stage-instances",
|
|
105
|
+
stageInstanceByChannel: (channelId) => `/stage-instances/${snowflake(channelId, "Channel ID")}`,
|
|
106
|
+
invite: (code) => `/invites/${encodeURIComponent(code)}`,
|
|
107
|
+
guildVanity: (guildId) => `/guilds/${snowflake(guildId, "Guild ID")}/vanity-url`,
|
|
108
|
+
guildEmoji: (guildId, emojiId) => emojiId ? `/guilds/${snowflake(guildId, "Guild ID")}/emojis/${snowflake(emojiId, "Emoji ID")}` : `/guilds/${snowflake(guildId, "Guild ID")}/emojis`,
|
|
109
|
+
applicationEmoji: (applicationId, emojiId) => emojiId ? `/applications/${snowflake(applicationId, "Application ID")}/emojis/${snowflake(emojiId, "Emoji ID")}` : `/applications/${snowflake(applicationId, "Application ID")}/emojis`
|
|
60
110
|
};
|
|
61
111
|
function snowflake(value, field) {
|
|
62
112
|
if (!/^\d{1,20}$/.test(value))
|
|
63
113
|
throw new TypeError(`${field} must be a valid Discord snowflake.`);
|
|
64
114
|
return value;
|
|
65
115
|
}
|
|
116
|
+
// packages/rest/src/redis.ts
|
|
117
|
+
class RedisRateLimitStore {
|
|
118
|
+
#client;
|
|
119
|
+
#prefix;
|
|
120
|
+
#onErrorCallback;
|
|
121
|
+
#lastError;
|
|
122
|
+
constructor(options) {
|
|
123
|
+
this.#client = options.client;
|
|
124
|
+
this.#prefix = options.prefix ?? "lunibee:rest:";
|
|
125
|
+
this.#onErrorCallback = options.onError;
|
|
126
|
+
}
|
|
127
|
+
get lastError() {
|
|
128
|
+
return this.#lastError;
|
|
129
|
+
}
|
|
130
|
+
isHealthy() {
|
|
131
|
+
return this.#lastError === undefined;
|
|
132
|
+
}
|
|
133
|
+
#onError(operation, error) {
|
|
134
|
+
this.#lastError = error;
|
|
135
|
+
console.warn(`[lunibee/rest] Redis rate-limit store ${operation} failed; ` + `falling back to local limiting for this call.`, error);
|
|
136
|
+
try {
|
|
137
|
+
this.#onErrorCallback?.(operation, error);
|
|
138
|
+
} catch {}
|
|
139
|
+
}
|
|
140
|
+
#markHealthy() {
|
|
141
|
+
this.#lastError = undefined;
|
|
142
|
+
}
|
|
143
|
+
async getBucketHash(route) {
|
|
144
|
+
try {
|
|
145
|
+
const hash = await this.#client.get(`${this.#prefix}route:${route}`);
|
|
146
|
+
this.#markHealthy();
|
|
147
|
+
return hash ?? undefined;
|
|
148
|
+
} catch (error) {
|
|
149
|
+
this.#onError("getBucketHash", error);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
async setBucketHash(route, hash) {
|
|
154
|
+
try {
|
|
155
|
+
await this.#client.set(`${this.#prefix}route:${route}`, hash, "EX", 604800);
|
|
156
|
+
this.#markHealthy();
|
|
157
|
+
} catch (error) {
|
|
158
|
+
this.#onError("setBucketHash", error);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
async getBucket(key) {
|
|
162
|
+
try {
|
|
163
|
+
const data = await this.#client.get(`${this.#prefix}bucket:${key}`);
|
|
164
|
+
this.#markHealthy();
|
|
165
|
+
if (!data)
|
|
166
|
+
return;
|
|
167
|
+
return JSON.parse(data);
|
|
168
|
+
} catch (error) {
|
|
169
|
+
this.#onError("getBucket", error);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
async updateBucket(key, state) {
|
|
174
|
+
const bucketKey = `${this.#prefix}bucket:${key}`;
|
|
175
|
+
const ttl = Math.ceil((state.resetAt - Date.now()) / 1000);
|
|
176
|
+
try {
|
|
177
|
+
if (ttl <= 0) {
|
|
178
|
+
await this.#client.del(bucketKey);
|
|
179
|
+
this.#markHealthy();
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
await this.#client.set(bucketKey, JSON.stringify(state), "EX", ttl);
|
|
183
|
+
this.#markHealthy();
|
|
184
|
+
} catch (error) {
|
|
185
|
+
this.#onError("updateBucket", error);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
async getGlobalReset() {
|
|
189
|
+
try {
|
|
190
|
+
const resetAt = await this.#client.get(`${this.#prefix}global`);
|
|
191
|
+
this.#markHealthy();
|
|
192
|
+
return resetAt ? Number(resetAt) : 0;
|
|
193
|
+
} catch (error) {
|
|
194
|
+
this.#onError("getGlobalReset", error);
|
|
195
|
+
return 0;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
async setGlobalReset(resetAt) {
|
|
199
|
+
const globalKey = `${this.#prefix}global`;
|
|
200
|
+
const ttl = Math.ceil((resetAt - Date.now()) / 1000);
|
|
201
|
+
try {
|
|
202
|
+
if (ttl <= 0) {
|
|
203
|
+
await this.#client.del(globalKey);
|
|
204
|
+
this.#markHealthy();
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
await this.#client.set(globalKey, String(resetAt), "EX", ttl);
|
|
208
|
+
this.#markHealthy();
|
|
209
|
+
} catch (error) {
|
|
210
|
+
this.#onError("setGlobalReset", error);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
66
214
|
// packages/rest/src/webhook.ts
|
|
67
215
|
class WebhookClient {
|
|
68
216
|
id;
|
|
@@ -87,7 +235,7 @@ class WebhookClient {
|
|
|
87
235
|
const payload = typeof options === "string" ? { content: options } : options;
|
|
88
236
|
const { thread_id, ...body } = payload;
|
|
89
237
|
const query = thread_id ? `?thread_id=${thread_id}&wait=true` : `?wait=true`;
|
|
90
|
-
const formattedEmbeds = body.embeds?.map((e) =>
|
|
238
|
+
const formattedEmbeds = body.embeds?.map((e) => ("toJSON" in e) ? e.toJSON() : e);
|
|
91
239
|
return this.#rest.post(`${Routes.webhook(this.id, this.token)}${query}`, {
|
|
92
240
|
...body,
|
|
93
241
|
embeds: formattedEmbeds
|
|
@@ -122,6 +270,49 @@ class RESTError extends Error {
|
|
|
122
270
|
var LIBRARY_VERSION = package_default.version;
|
|
123
271
|
var LIBRARY_URL = "https://github.com/Ekretos/lunibee";
|
|
124
272
|
var USER_AGENT = `DiscordBot (${LIBRARY_URL}, ${package_default.version})`;
|
|
273
|
+
var REQUEST_DATA_KEYS = new Set([
|
|
274
|
+
"body",
|
|
275
|
+
"files",
|
|
276
|
+
"query",
|
|
277
|
+
"headers",
|
|
278
|
+
"reason",
|
|
279
|
+
"auth",
|
|
280
|
+
"signal"
|
|
281
|
+
]);
|
|
282
|
+
function isRequestData(value) {
|
|
283
|
+
if (value === null || typeof value !== "object")
|
|
284
|
+
return false;
|
|
285
|
+
if (Array.isArray(value))
|
|
286
|
+
return false;
|
|
287
|
+
if (typeof FormData !== "undefined" && value instanceof FormData)
|
|
288
|
+
return false;
|
|
289
|
+
if (value instanceof Blob || value instanceof Uint8Array || value instanceof ArrayBuffer)
|
|
290
|
+
return false;
|
|
291
|
+
const keys = Object.keys(value);
|
|
292
|
+
if (keys.length === 0)
|
|
293
|
+
return false;
|
|
294
|
+
return keys.every((key) => REQUEST_DATA_KEYS.has(key));
|
|
295
|
+
}
|
|
296
|
+
function serializeQuery(query) {
|
|
297
|
+
if (query === undefined)
|
|
298
|
+
return "";
|
|
299
|
+
if (typeof query === "string")
|
|
300
|
+
return query.length === 0 || query.startsWith("?") ? query : `?${query}`;
|
|
301
|
+
const params = query instanceof URLSearchParams ? query : new URLSearchParams(Object.entries(query).flatMap(([key, value]) => value === undefined || value === null ? [] : [[key, String(value)]]));
|
|
302
|
+
const serialized = params.toString();
|
|
303
|
+
return serialized.length === 0 ? "" : `?${serialized}`;
|
|
304
|
+
}
|
|
305
|
+
function majorParameter(path) {
|
|
306
|
+
const match = /^\/(channels|guilds|webhooks)\/(\d+)(?:\/([^/?]+))?/.exec(path);
|
|
307
|
+
if (!match)
|
|
308
|
+
return "@none";
|
|
309
|
+
if (match[1] === "webhooks" && match[3] !== undefined)
|
|
310
|
+
return `${match[2]}:${match[3]}`;
|
|
311
|
+
return match[2];
|
|
312
|
+
}
|
|
313
|
+
function scopeBucket(hashOrRoute, major) {
|
|
314
|
+
return `${hashOrRoute}|${major}`;
|
|
315
|
+
}
|
|
125
316
|
function createRetryPolicy(maxRetries = 2) {
|
|
126
317
|
if (!Number.isFinite(maxRetries) || maxRetries < 0)
|
|
127
318
|
throw new TypeError("maxRetries must be a non-negative finite number.");
|
|
@@ -144,14 +335,21 @@ class REST {
|
|
|
144
335
|
#timeout;
|
|
145
336
|
#retryPolicy;
|
|
146
337
|
#token;
|
|
147
|
-
#
|
|
148
|
-
#
|
|
149
|
-
#
|
|
338
|
+
#store;
|
|
339
|
+
#localQueues = new Map;
|
|
340
|
+
#hooks = {};
|
|
150
341
|
constructor(options = {}) {
|
|
151
342
|
this.#token = options.token;
|
|
152
343
|
this.#timeout = Math.max(1, options.timeout ?? 15000);
|
|
153
344
|
this.#retryPolicy = options.retryPolicy ?? createRetryPolicy(options.retries ?? 2);
|
|
154
345
|
this.#baseURL = (options.baseURL ?? "https://discord.com/api/v10").replace(/\/$/, "");
|
|
346
|
+
this.#store = options.store ?? new MemoryRateLimitStore;
|
|
347
|
+
if (options.hooks)
|
|
348
|
+
this.#hooks = options.hooks;
|
|
349
|
+
}
|
|
350
|
+
setHooks(hooks) {
|
|
351
|
+
this.#hooks = hooks;
|
|
352
|
+
return this;
|
|
155
353
|
}
|
|
156
354
|
setToken(token) {
|
|
157
355
|
if (!token.trim())
|
|
@@ -164,56 +362,91 @@ class REST {
|
|
|
164
362
|
throw new TypeError("REST method is required.");
|
|
165
363
|
if (!path.startsWith("/"))
|
|
166
364
|
throw new TypeError("REST paths must start with '/'.");
|
|
365
|
+
const files = options.files;
|
|
366
|
+
if (files && files.length > 0 && !(body instanceof FormData))
|
|
367
|
+
body = this.#fileForm(body, files);
|
|
368
|
+
const query = serializeQuery(options.query);
|
|
369
|
+
const finalPath = query ? `${path}${query}` : path;
|
|
167
370
|
const route = `${normalizedMethod}:${this.#normalizeRoute(path)}`;
|
|
168
|
-
|
|
169
|
-
let
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
queue: Promise.resolve()
|
|
173
|
-
};
|
|
174
|
-
this.#buckets.set(bucketKey, bucket);
|
|
175
|
-
const previous = bucket.queue;
|
|
371
|
+
const major = majorParameter(path);
|
|
372
|
+
let bucketKey = scopeBucket(await this.#store.getBucketHash(route) ?? route, major);
|
|
373
|
+
const queueKey = bucketKey;
|
|
374
|
+
const previous = this.#localQueues.get(queueKey) ?? Promise.resolve();
|
|
176
375
|
let release;
|
|
177
|
-
|
|
376
|
+
const gate = new Promise((resolve) => {
|
|
178
377
|
release = resolve;
|
|
179
378
|
});
|
|
379
|
+
this.#localQueues.set(queueKey, gate);
|
|
180
380
|
await this.#abortable(previous, options.signal, path);
|
|
181
381
|
try {
|
|
182
382
|
for (let attempt = 0;attempt <= this.#retryPolicy.maxRetries; attempt++) {
|
|
183
|
-
await this.#wait(
|
|
383
|
+
await this.#wait(bucketKey, options.signal, path);
|
|
184
384
|
await this.#waitGlobal(options.signal, path);
|
|
185
385
|
const controller = new AbortController;
|
|
186
386
|
const onAbort = () => controller.abort(options.signal?.reason);
|
|
187
387
|
if (options.signal?.aborted)
|
|
188
388
|
throw this.#abortError(path, options.signal.reason);
|
|
189
|
-
options.signal?.addEventListener("abort", onAbort, {
|
|
389
|
+
options.signal?.addEventListener("abort", onAbort, {
|
|
390
|
+
once: true
|
|
391
|
+
});
|
|
190
392
|
const timer = setTimeout(() => controller.abort(new DOMException("REST request timeout", "TimeoutError")), this.#timeout);
|
|
191
393
|
const isFormData = typeof FormData !== "undefined" && body instanceof FormData;
|
|
192
|
-
const headers = {
|
|
193
|
-
|
|
194
|
-
"
|
|
195
|
-
|
|
394
|
+
const headers = { ...options.headers };
|
|
395
|
+
if (options.auth !== false && this.#token)
|
|
396
|
+
headers["Authorization"] = `Bot ${this.#token}`;
|
|
397
|
+
headers["User-Agent"] = USER_AGENT;
|
|
398
|
+
if (options.reason !== undefined)
|
|
399
|
+
headers["X-Audit-Log-Reason"] = encodeURIComponent(options.reason);
|
|
196
400
|
if (!isFormData)
|
|
197
401
|
headers["Content-Type"] = "application/json";
|
|
198
402
|
const fetchBody = isFormData ? body : body === undefined ? undefined : JSON.stringify(body);
|
|
199
403
|
try {
|
|
200
|
-
|
|
404
|
+
this.#emit(this.#hooks.onRequest, {
|
|
405
|
+
method: normalizedMethod,
|
|
406
|
+
path,
|
|
407
|
+
attempt
|
|
408
|
+
});
|
|
409
|
+
const requestStart = Date.now();
|
|
410
|
+
const response = await fetch(`${this.#baseURL}${finalPath}`, {
|
|
201
411
|
method: normalizedMethod,
|
|
202
412
|
headers,
|
|
203
413
|
body: fetchBody,
|
|
204
414
|
signal: controller.signal
|
|
205
415
|
});
|
|
206
|
-
|
|
207
|
-
|
|
416
|
+
this.#emit(this.#hooks.onResponse, {
|
|
417
|
+
method: normalizedMethod,
|
|
418
|
+
path,
|
|
419
|
+
status: response.status,
|
|
420
|
+
durationMs: Date.now() - requestStart
|
|
421
|
+
});
|
|
422
|
+
bucketKey = await this.#update(response, bucketKey, route, major);
|
|
208
423
|
const payload = await this.#readPayload(response);
|
|
209
424
|
if (response.ok)
|
|
210
425
|
return response.status === 204 ? undefined : payload;
|
|
211
426
|
const data = this.#errorData(payload);
|
|
212
427
|
const retryAfter = response.status === 429 ? this.#retryAfter(response, data) : undefined;
|
|
213
|
-
if (data.global && retryAfter !== undefined)
|
|
214
|
-
|
|
428
|
+
if (data.global && retryAfter !== undefined) {
|
|
429
|
+
const currentGlobal = await this.#store.getGlobalReset();
|
|
430
|
+
await this.#store.setGlobalReset(Math.max(currentGlobal, Date.now() + retryAfter * 1000));
|
|
431
|
+
}
|
|
432
|
+
if (response.status === 429)
|
|
433
|
+
this.#emit(this.#hooks.onRateLimit, {
|
|
434
|
+
method: normalizedMethod,
|
|
435
|
+
path,
|
|
436
|
+
retryAfterMs: (retryAfter ?? 1) * 1000,
|
|
437
|
+
global: data.global ?? false,
|
|
438
|
+
bucket: await this.#store.getBucketHash(route)
|
|
439
|
+
});
|
|
215
440
|
if (attempt < this.#retryPolicy.maxRetries && this.#retryPolicy.shouldRetry(normalizedMethod, response.status)) {
|
|
216
|
-
|
|
441
|
+
const delay = this.#retryPolicy.getDelay(attempt, retryAfter);
|
|
442
|
+
this.#emit(this.#hooks.onRetry, {
|
|
443
|
+
method: normalizedMethod,
|
|
444
|
+
path,
|
|
445
|
+
attempt,
|
|
446
|
+
status: response.status,
|
|
447
|
+
delayMs: delay
|
|
448
|
+
});
|
|
449
|
+
await this.#sleep(delay, options.signal, path);
|
|
217
450
|
continue;
|
|
218
451
|
}
|
|
219
452
|
throw new RESTError(data.message ?? response.statusText ?? `Discord REST request failed with status ${response.status}`, response.status, data.code, payload, { method: normalizedMethod, path });
|
|
@@ -236,22 +469,29 @@ class REST {
|
|
|
236
469
|
throw new RESTError("Discord REST request exhausted its retry attempts", 0, undefined, undefined, { method: normalizedMethod, path });
|
|
237
470
|
} finally {
|
|
238
471
|
release();
|
|
472
|
+
if (this.#localQueues.get(queueKey) === gate)
|
|
473
|
+
this.#localQueues.delete(queueKey);
|
|
239
474
|
}
|
|
240
475
|
}
|
|
476
|
+
#dispatch(method, path, bodyOrOptions, options) {
|
|
477
|
+
if (options === undefined && isRequestData(bodyOrOptions))
|
|
478
|
+
return this.request(method, path, bodyOrOptions.body, bodyOrOptions);
|
|
479
|
+
return this.request(method, path, bodyOrOptions, options);
|
|
480
|
+
}
|
|
241
481
|
get(path, options) {
|
|
242
|
-
return this.request("GET", path,
|
|
482
|
+
return this.request("GET", path, options?.body, options);
|
|
243
483
|
}
|
|
244
484
|
post(path, body, options) {
|
|
245
|
-
return this
|
|
485
|
+
return this.#dispatch("POST", path, body, options);
|
|
246
486
|
}
|
|
247
487
|
patch(path, body, options) {
|
|
248
|
-
return this
|
|
488
|
+
return this.#dispatch("PATCH", path, body, options);
|
|
249
489
|
}
|
|
250
490
|
put(path, body, options) {
|
|
251
|
-
return this
|
|
491
|
+
return this.#dispatch("PUT", path, body, options);
|
|
252
492
|
}
|
|
253
493
|
delete(path, options) {
|
|
254
|
-
return this.request("DELETE", path,
|
|
494
|
+
return this.request("DELETE", path, options?.body, options);
|
|
255
495
|
}
|
|
256
496
|
#fileForm(payload, files) {
|
|
257
497
|
const form = new FormData;
|
|
@@ -271,13 +511,22 @@ class REST {
|
|
|
271
511
|
patchWithFiles(path, payload, files, options) {
|
|
272
512
|
return this.request("PATCH", path, this.#fileForm(payload, files), options);
|
|
273
513
|
}
|
|
274
|
-
|
|
514
|
+
#emit(hook, ctx) {
|
|
515
|
+
if (!hook)
|
|
516
|
+
return;
|
|
517
|
+
Promise.resolve().then(() => hook(ctx)).catch(() => {});
|
|
518
|
+
}
|
|
519
|
+
async#wait(bucketKey, signal, path) {
|
|
520
|
+
const bucket = await this.#store.getBucket(bucketKey);
|
|
521
|
+
if (!bucket)
|
|
522
|
+
return;
|
|
275
523
|
const delay = bucket.resetAt - Date.now();
|
|
276
524
|
if (bucket.remaining <= 0 && delay > 0)
|
|
277
525
|
await this.#sleep(delay, signal, path);
|
|
278
526
|
}
|
|
279
527
|
async#waitGlobal(signal, path) {
|
|
280
|
-
const
|
|
528
|
+
const globalResetAt = await this.#store.getGlobalReset();
|
|
529
|
+
const delay = globalResetAt - Date.now();
|
|
281
530
|
if (delay > 0)
|
|
282
531
|
await this.#sleep(delay, signal, path);
|
|
283
532
|
}
|
|
@@ -341,16 +590,17 @@ class REST {
|
|
|
341
590
|
const routePath = queryIndex === -1 ? path : path.slice(0, queryIndex);
|
|
342
591
|
return routePath.replace(/\/\d+(?=\/|$)/g, "/:id");
|
|
343
592
|
}
|
|
344
|
-
#update(response,
|
|
593
|
+
async#update(response, bucketKey, route, major) {
|
|
594
|
+
let currentKey = bucketKey;
|
|
345
595
|
const serverBucket = response.headers.get("X-RateLimit-Bucket");
|
|
346
596
|
if (serverBucket) {
|
|
347
|
-
this.#
|
|
348
|
-
|
|
349
|
-
const shared = this.#buckets.get(serverBucket) ?? bucket;
|
|
350
|
-
this.#buckets.set(serverBucket, shared);
|
|
351
|
-
bucket = shared;
|
|
352
|
-
}
|
|
597
|
+
await this.#store.setBucketHash(route, serverBucket);
|
|
598
|
+
currentKey = scopeBucket(serverBucket, major);
|
|
353
599
|
}
|
|
600
|
+
let bucket = await this.#store.getBucket(currentKey) ?? {
|
|
601
|
+
remaining: 1,
|
|
602
|
+
resetAt: 0
|
|
603
|
+
};
|
|
354
604
|
const remaining = Number(response.headers.get("X-RateLimit-Remaining"));
|
|
355
605
|
const resetAfter = Number(response.headers.get("X-RateLimit-Reset-After"));
|
|
356
606
|
const reset = Number(response.headers.get("X-RateLimit-Reset"));
|
|
@@ -365,20 +615,26 @@ class REST {
|
|
|
365
615
|
if (Number.isFinite(retryAfter))
|
|
366
616
|
bucket.resetAt = Math.max(bucket.resetAt, Date.now() + retryAfter * 1000);
|
|
367
617
|
}
|
|
618
|
+
await this.#store.updateBucket(currentKey, bucket);
|
|
368
619
|
if (response.headers.get("X-RateLimit-Global") === "true") {
|
|
369
620
|
const retryAfter = Number(response.headers.get("Retry-After"));
|
|
370
|
-
if (Number.isFinite(retryAfter))
|
|
371
|
-
|
|
621
|
+
if (Number.isFinite(retryAfter)) {
|
|
622
|
+
const currentGlobal = await this.#store.getGlobalReset();
|
|
623
|
+
await this.#store.setGlobalReset(Math.max(currentGlobal, Date.now() + retryAfter * 1000));
|
|
624
|
+
}
|
|
372
625
|
}
|
|
373
|
-
return
|
|
626
|
+
return currentKey;
|
|
374
627
|
}
|
|
375
628
|
}
|
|
376
629
|
export {
|
|
630
|
+
RESTError as DiscordAPIError,
|
|
631
|
+
MemoryRateLimitStore,
|
|
377
632
|
REST,
|
|
378
633
|
RESTError,
|
|
634
|
+
RedisRateLimitStore,
|
|
379
635
|
Routes,
|
|
380
636
|
WebhookClient,
|
|
381
637
|
createRetryPolicy
|
|
382
638
|
};
|
|
383
639
|
|
|
384
|
-
//# debugId=
|
|
640
|
+
//# debugId=7273A79583E1900164756E2164756E21
|