mirror of
https://github.com/tiramisulabs/seyfert.git
synced 2025-07-01 20:46:08 +00:00
fix(ws): gateway resume now it works correctly
feat(ws): more usesfull methods in sharder fix(helpers): data arrays fixed ci: change rome to biome Co-authored-by: MARCROCK22 <MARCROCK22@users.noreply.github.com>
This commit is contained in:
parent
e2202772cf
commit
02b8bd0d28
@ -10,15 +10,12 @@
|
||||
"suspicious": {
|
||||
"noExplicitAny": "off",
|
||||
"noAssignInExpressions": "off"
|
||||
},
|
||||
"nursery": {
|
||||
"useCamelCase": "off"
|
||||
}
|
||||
}
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentSize": 2,
|
||||
"indentWidth": 2,
|
||||
"indentStyle": "space",
|
||||
"lineWidth": 140,
|
||||
"formatWithErrors": true
|
136
package.json
136
package.json
@ -1,70 +1,70 @@
|
||||
{
|
||||
"name": "@biscuitland/core",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "turbo run build",
|
||||
"clean": "turbo run clean",
|
||||
"check": "rome check ./packages/",
|
||||
"check:apply": "rome check --apply --max-diagnostics 200 --quote-style single --trailing-comma none ./packages/",
|
||||
"lint": "rome format --write --quote-style single --trailing-comma none ./packages/",
|
||||
"dev": "turbo run dev --parallel"
|
||||
},
|
||||
"engines": {
|
||||
"npm": ">=7.0.0",
|
||||
"node": ">=16.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@changesets/cli": "^2.24.1",
|
||||
"@types/node": "^18.6.3",
|
||||
"rome": "^12.0.0",
|
||||
"turbo": "^1.4.2",
|
||||
"typescript": "^5.0.4"
|
||||
},
|
||||
"packageManager": "npm@8.19.4",
|
||||
"bugs": {
|
||||
"url": "https://github.com/oasisjs/biscuit"
|
||||
},
|
||||
"keywords": [
|
||||
"api",
|
||||
"discord",
|
||||
"bots",
|
||||
"typescript",
|
||||
"botdev"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"author": "Yuzuru <yuzuru@programmer.net>",
|
||||
"contributors": [
|
||||
{
|
||||
"name": "Yuzuru",
|
||||
"url": "https://github.com/yuzudev",
|
||||
"author": true
|
||||
},
|
||||
{
|
||||
"name": "miia",
|
||||
"url": "https://github.com/dragurimu"
|
||||
},
|
||||
{
|
||||
"name": "n128",
|
||||
"url": "https://github.com/nicolito128"
|
||||
},
|
||||
{
|
||||
"name": "socram03",
|
||||
"url": "https://github.com/socram03"
|
||||
},
|
||||
{
|
||||
"name": "Drylozu",
|
||||
"url": "https://github.com/Drylozu"
|
||||
},
|
||||
{
|
||||
"name": "FreeAoi",
|
||||
"url": "https://github.com/FreeAoi"
|
||||
}
|
||||
],
|
||||
"homepage": "https://biscuitjs.com",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/oasisjs/biscuit.git"
|
||||
}
|
||||
"name": "@biscuitland/core",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "turbo run build",
|
||||
"clean": "turbo run clean",
|
||||
"check": "biome check ./packages/",
|
||||
"check:apply": "biome check --apply --max-diagnostics 200 --quote-style single --trailing-comma none ./packages/",
|
||||
"lint": "biome format --write --quote-style single --trailing-comma none ./packages/",
|
||||
"dev": "turbo run dev --parallel"
|
||||
},
|
||||
"engines": {
|
||||
"npm": ">=7.0.0",
|
||||
"node": ">=16.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.3.3",
|
||||
"@changesets/cli": "^2.24.1",
|
||||
"@types/node": "^18.6.3",
|
||||
"turbo": "^1.4.2",
|
||||
"typescript": "^5.0.4"
|
||||
},
|
||||
"packageManager": "npm@8.19.4",
|
||||
"bugs": {
|
||||
"url": "https://github.com/oasisjs/biscuit"
|
||||
},
|
||||
"keywords": [
|
||||
"api",
|
||||
"discord",
|
||||
"bots",
|
||||
"typescript",
|
||||
"botdev"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"author": "Yuzuru <yuzuru@programmer.net>",
|
||||
"contributors": [
|
||||
{
|
||||
"name": "Yuzuru",
|
||||
"url": "https://github.com/yuzudev",
|
||||
"author": true
|
||||
},
|
||||
{
|
||||
"name": "miia",
|
||||
"url": "https://github.com/dragurimu"
|
||||
},
|
||||
{
|
||||
"name": "n128",
|
||||
"url": "https://github.com/nicolito128"
|
||||
},
|
||||
{
|
||||
"name": "socram03",
|
||||
"url": "https://github.com/socram03"
|
||||
},
|
||||
{
|
||||
"name": "Drylozu",
|
||||
"url": "https://github.com/Drylozu"
|
||||
},
|
||||
{
|
||||
"name": "FreeAoi",
|
||||
"url": "https://github.com/FreeAoi"
|
||||
}
|
||||
],
|
||||
"homepage": "https://biscuitjs.com",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/oasisjs/biscuit.git"
|
||||
}
|
||||
}
|
||||
|
@ -6,7 +6,7 @@ export class Collection<K, V> extends Map<K, V> {
|
||||
*/
|
||||
maxSize: number | undefined;
|
||||
/** Handler to remove items from the collection every so often. */
|
||||
sweeper: (CollectionSweeper<K, V> & { intervalId?: NodeJS.Timer }) | undefined;
|
||||
sweeper: (CollectionSweeper<K, V> & { intervalId?: NodeJS.Timeout }) | undefined;
|
||||
|
||||
constructor(entries?: (ReadonlyArray<readonly [K, V]> | null) | Map<K, V>, options?: CollectionOptions<K, V>) {
|
||||
super(entries ?? []);
|
||||
@ -23,6 +23,7 @@ export class Collection<K, V> extends Map<K, V> {
|
||||
|
||||
this.sweeper = options;
|
||||
this.sweeper.intervalId = setInterval(() => {
|
||||
// biome-ignore lint/complexity/noForEach: <explanation>
|
||||
this.forEach((value, key) => {
|
||||
if (!this.sweeper?.filter(value, key)) return;
|
||||
|
||||
@ -98,6 +99,7 @@ export class Collection<K, V> extends Map<K, V> {
|
||||
/** Find all elements in this collection that match the given pattern. */
|
||||
filter(callback: (value: V, key: K) => boolean): Collection<K, V> {
|
||||
const relevant = new Collection<K, V>();
|
||||
// biome-ignore lint/complexity/noForEach: <explanation>
|
||||
this.forEach((value, key) => {
|
||||
if (callback(value, key)) relevant.set(key, value);
|
||||
});
|
||||
|
@ -1,51 +1,27 @@
|
||||
import { setTimeout } from "node:timers/promises";
|
||||
import { ObjectToLower, ObjectToSnake } from "./Types";
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
import { ObjectToLower, ObjectToSnake } from './Types';
|
||||
|
||||
const isPlainObject = (value: any) => {
|
||||
return (
|
||||
(value !== null &&
|
||||
typeof value === "object" &&
|
||||
typeof value.constructor === "function" &&
|
||||
// rome-ignore lint/suspicious/noPrototypeBuiltins: js tricks
|
||||
(value.constructor.prototype.hasOwnProperty("isPrototypeOf") || Object.getPrototypeOf(value.constructor.prototype) === null)) ||
|
||||
(value !== undefined && Object.getPrototypeOf(value) === null)
|
||||
export function isObject(o: any) {
|
||||
return o && typeof o === 'object' && !Array.isArray(o);
|
||||
}
|
||||
|
||||
export function Options<T>(defaults: any, ...options: any[]): T {
|
||||
const option = options.shift();
|
||||
if (!option) return defaults;
|
||||
|
||||
return Options(
|
||||
{
|
||||
...option,
|
||||
...Object.fromEntries(
|
||||
Object.entries(defaults).map(([key, value]) => [
|
||||
key,
|
||||
isObject(value) ? Options(value, option?.[key] || {}) : option?.[key] || value
|
||||
])
|
||||
)
|
||||
},
|
||||
...options
|
||||
);
|
||||
};
|
||||
|
||||
const isObject = (o: any) => {
|
||||
return !!o && typeof o === "object" && !Array.isArray(o);
|
||||
};
|
||||
|
||||
export const Options = <T = any>(defaults: any, ...options: any[]): T => {
|
||||
if (!options.length) {
|
||||
return defaults;
|
||||
}
|
||||
|
||||
const source = options.shift();
|
||||
|
||||
// This prevents default options from being intercepted by `Object.assign`
|
||||
const $ = { ...defaults };
|
||||
|
||||
if (isObject($) && isPlainObject(source)) {
|
||||
Object.entries(source).forEach(([key, value]) => {
|
||||
if (typeof value === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPlainObject(value)) {
|
||||
if (!(key in $)) {
|
||||
Object.assign($, { [key]: {} });
|
||||
}
|
||||
|
||||
Options($[key], value);
|
||||
} else {
|
||||
Object.assign($, { [key]: value });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return Options($, ...options);
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Convert a camelCase object to snake_case.
|
||||
* @param target The object to convert.
|
||||
@ -55,18 +31,18 @@ export function toSnakeCase<Obj extends Record<string, any>>(target: Obj): Objec
|
||||
const result = {};
|
||||
for (const [key, value] of Object.entries(target)) {
|
||||
switch (typeof value) {
|
||||
case "string":
|
||||
case "bigint":
|
||||
case "boolean":
|
||||
case "function":
|
||||
case "number":
|
||||
case "symbol":
|
||||
case "undefined":
|
||||
case 'string':
|
||||
case 'bigint':
|
||||
case 'boolean':
|
||||
case 'function':
|
||||
case 'number':
|
||||
case 'symbol':
|
||||
case 'undefined':
|
||||
result[ReplaceRegex.snake(key)] = value;
|
||||
break;
|
||||
case "object":
|
||||
case 'object':
|
||||
if (Array.isArray(value)) {
|
||||
result[ReplaceRegex.snake(key)] = value.map((prop) => (typeof prop === "object" && prop ? toSnakeCase(prop) : prop));
|
||||
result[ReplaceRegex.snake(key)] = value.map((prop) => (typeof prop === 'object' && prop ? toSnakeCase(prop) : prop));
|
||||
break;
|
||||
}
|
||||
if (isObject(value)) {
|
||||
@ -93,18 +69,18 @@ export function toCamelCase<Obj extends Record<string, any>>(target: Obj): Objec
|
||||
const result = {};
|
||||
for (const [key, value] of Object.entries(target)) {
|
||||
switch (typeof value) {
|
||||
case "string":
|
||||
case "bigint":
|
||||
case "boolean":
|
||||
case "function":
|
||||
case "symbol":
|
||||
case "number":
|
||||
case "undefined":
|
||||
case 'string':
|
||||
case 'bigint':
|
||||
case 'boolean':
|
||||
case 'function':
|
||||
case 'symbol':
|
||||
case 'number':
|
||||
case 'undefined':
|
||||
result[ReplaceRegex.camel(key)] = value;
|
||||
break;
|
||||
case "object":
|
||||
case 'object':
|
||||
if (Array.isArray(value)) {
|
||||
result[ReplaceRegex.camel(key)] = value.map((prop) => (typeof prop === "object" && prop ? toCamelCase(prop) : prop));
|
||||
result[ReplaceRegex.camel(key)] = value.map((prop) => (typeof prop === 'object' && prop ? toCamelCase(prop) : prop));
|
||||
break;
|
||||
}
|
||||
if (isObject(value)) {
|
||||
@ -128,7 +104,7 @@ export const ReplaceRegex = {
|
||||
},
|
||||
snake: (s: string) => {
|
||||
return s.replace(/[A-Z]/g, (a) => `_${a.toLowerCase()}`);
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
// https://github.com/discordeno/discordeno/blob/main/packages/utils/src/colors.ts
|
||||
@ -168,9 +144,9 @@ export function getColorEnabled(): boolean {
|
||||
*/
|
||||
function code(open: number[], close: number): Code {
|
||||
return {
|
||||
open: `\x1b[${open.join(";")}m`,
|
||||
open: `\x1b[${open.join(';')}m`,
|
||||
close: `\x1b[${close}m`,
|
||||
regexp: new RegExp(`\\x1b\\[${close}m`, "g"),
|
||||
regexp: new RegExp(`\\x1b\\[${close}m`, 'g')
|
||||
};
|
||||
}
|
||||
|
||||
@ -559,7 +535,7 @@ export function bgRgb8(str: string, color: number): string {
|
||||
* @param color code
|
||||
*/
|
||||
export function rgb24(str: string, color: number | Rgb): string {
|
||||
if (typeof color === "number") {
|
||||
if (typeof color === 'number') {
|
||||
return run(str, code([38, 2, (color >> 16) & 0xff, (color >> 8) & 0xff, color & 0xff], 39));
|
||||
}
|
||||
return run(str, code([38, 2, clampAndTruncate(color.r), clampAndTruncate(color.g), clampAndTruncate(color.b)], 39));
|
||||
@ -581,7 +557,7 @@ export function rgb24(str: string, color: number | Rgb): string {
|
||||
* @param color code
|
||||
*/
|
||||
export function bgRgb24(str: string, color: number | Rgb): string {
|
||||
if (typeof color === "number") {
|
||||
if (typeof color === 'number') {
|
||||
return run(str, code([48, 2, (color >> 16) & 0xff, (color >> 8) & 0xff, color & 0xff], 49));
|
||||
}
|
||||
return run(str, code([48, 2, clampAndTruncate(color.r), clampAndTruncate(color.g), clampAndTruncate(color.b)], 49));
|
||||
@ -590,10 +566,10 @@ export function bgRgb24(str: string, color: number | Rgb): string {
|
||||
// https://github.com/chalk/ansi-regex/blob/02fa893d619d3da85411acc8fd4e2eea0e95a9d9/index.js
|
||||
const ANSI_PATTERN = new RegExp(
|
||||
[
|
||||
"[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)",
|
||||
"(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))",
|
||||
].join("|"),
|
||||
"g",
|
||||
'[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)',
|
||||
'(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))'
|
||||
].join('|'),
|
||||
'g'
|
||||
);
|
||||
|
||||
/**
|
||||
@ -601,9 +577,9 @@ const ANSI_PATTERN = new RegExp(
|
||||
* @param string to remove ANSI escape codes from
|
||||
*/
|
||||
export function stripColor(string: string): string {
|
||||
return string.replace(ANSI_PATTERN, "");
|
||||
return string.replace(ANSI_PATTERN, '');
|
||||
}
|
||||
|
||||
export function delay<T>(time: number, result?: T) {
|
||||
export function delay<T>(time: number, result?: T): Promise<T> {
|
||||
return setTimeout(time, result);
|
||||
}
|
||||
|
@ -1,11 +1,11 @@
|
||||
import type { GatewayEvents } from "@biscuitland/ws";
|
||||
import type { Session } from "../index";
|
||||
import type { GatewayEvents } from '@biscuitland/ws';
|
||||
import type { Session } from '../index';
|
||||
|
||||
export function actionHandler([session, payload, shardId]: Parameters<ActionHandler>) {
|
||||
// @ts-expect-error At this point, typescript sucks
|
||||
session.emit(payload.t, payload.d, shardId);
|
||||
// @ts-expect-error At this point, typescript sucks
|
||||
session.emit("RAW", payload.d, shardId);
|
||||
session.emit('RAW', payload.d, shardId);
|
||||
}
|
||||
|
||||
export type ActionHandler<G extends keyof GatewayEvents = keyof GatewayEvents,> = (
|
||||
|
@ -1,23 +1,23 @@
|
||||
import type {
|
||||
APIChannel,
|
||||
RESTPostAPIChannelMessageJSONBody,
|
||||
RESTPatchAPIChannelJSONBody,
|
||||
RESTGetAPIChannelThreadsArchivedQuery,
|
||||
RESTGetAPIChannelMessageReactionUsersQuery,
|
||||
RESTGetAPIChannelThreadMemberQuery,
|
||||
RESTGetAPIChannelThreadMembersQuery,
|
||||
RESTGetAPIChannelThreadsArchivedQuery,
|
||||
RESTPatchAPIChannelJSONBody,
|
||||
RESTPatchAPIChannelMessageJSONBody,
|
||||
RESTPostAPIChannelMessagesBulkDeleteJSONBody,
|
||||
RESTPutAPIChannelPermissionJSONBody,
|
||||
RESTPostAPIChannelInviteJSONBody,
|
||||
RESTPatchAPIStageInstanceJSONBody,
|
||||
RESTPostAPIChannelFollowersJSONBody,
|
||||
RESTPutAPIChannelRecipientJSONBody,
|
||||
RESTPostAPIChannelInviteJSONBody,
|
||||
RESTPostAPIChannelMessageJSONBody,
|
||||
RESTPostAPIChannelMessagesBulkDeleteJSONBody,
|
||||
RESTPostAPIChannelMessagesThreadsJSONBody,
|
||||
RESTPostAPIChannelThreadsJSONBody,
|
||||
RESTPostAPIChannelThreadsResult,
|
||||
RESTPostAPIGuildForumThreadsJSONBody,
|
||||
RESTGetAPIChannelThreadMembersQuery,
|
||||
RESTGetAPIChannelThreadMemberQuery,
|
||||
RESTPostAPIChannelWebhookJSONBody,
|
||||
RESTPatchAPIStageInstanceJSONBody
|
||||
RESTPostAPIGuildForumThreadsJSONBody,
|
||||
RESTPutAPIChannelPermissionJSONBody,
|
||||
RESTPutAPIChannelRecipientJSONBody
|
||||
} from '@biscuitland/common';
|
||||
import type { RawFile } from '@biscuitland/rest';
|
||||
|
||||
|
@ -1,45 +1,45 @@
|
||||
import type { Session } from '../session';
|
||||
import type {
|
||||
GuildMFALevel,
|
||||
APIGuildChannel,
|
||||
GuildChannelType,
|
||||
RESTPostAPIGuildPruneJSONBody,
|
||||
RESTPostAPIGuildsJSONBody,
|
||||
RESTPatchAPIGuildJSONBody,
|
||||
RESTPostAPIGuildChannelJSONBody,
|
||||
RESTPatchAPIGuildChannelPositionsJSONBody,
|
||||
RESTGetAPIGuildBansQuery,
|
||||
RESTPutAPIGuildBanJSONBody,
|
||||
RESTPostAPIGuildRoleJSONBody,
|
||||
RESTPatchAPIGuildRolePositionsJSONBody,
|
||||
RESTPatchAPIGuildRoleJSONBody,
|
||||
RESTPatchAPIGuildWidgetSettingsJSONBody,
|
||||
RESTPatchAPIGuildWelcomeScreenJSONBody,
|
||||
RESTGetAPIGuildPruneCountQuery,
|
||||
GuildMFALevel,
|
||||
RESTGetAPIAuditLogQuery,
|
||||
RESTPostAPIAutoModerationRuleJSONBody,
|
||||
RESTPatchAPIAutoModerationRuleJSONBody,
|
||||
RESTPostAPITemplateCreateGuildJSONBody,
|
||||
RESTGetAPIGuildBansQuery,
|
||||
RESTGetAPIGuildMembersQuery,
|
||||
RESTGetAPIGuildMembersSearchQuery,
|
||||
RESTPatchAPICurrentGuildMemberJSONBody,
|
||||
RESTPutAPIGuildMemberJSONBody,
|
||||
RESTPatchAPIGuildMemberJSONBody,
|
||||
RESTGetAPIGuildWidgetImageQuery,
|
||||
RESTPatchAPIGuildEmojiJSONBody,
|
||||
RESTPostAPIGuildEmojiJSONBody,
|
||||
RESTPatchAPIGuildVoiceStateUserJSONBody,
|
||||
RESTPatchAPIGuildVoiceStateCurrentMemberJSONBody,
|
||||
RESTPatchAPIGuildStickerJSONBody,
|
||||
RESTPostAPIGuildStickerFormDataBody,
|
||||
RESTGetAPIGuildScheduledEventsQuery,
|
||||
RESTPatchAPIGuildScheduledEventJSONBody,
|
||||
RESTPostAPIGuildScheduledEventJSONBody,
|
||||
RESTGetAPIGuildPruneCountQuery,
|
||||
RESTGetAPIGuildScheduledEventQuery,
|
||||
RESTGetAPIGuildScheduledEventUsersQuery,
|
||||
RESTGetAPIGuildScheduledEventsQuery,
|
||||
RESTGetAPIGuildWidgetImageQuery,
|
||||
RESTPatchAPIAutoModerationRuleJSONBody,
|
||||
RESTPatchAPICurrentGuildMemberJSONBody,
|
||||
RESTPatchAPIGuildChannelPositionsJSONBody,
|
||||
RESTPatchAPIGuildEmojiJSONBody,
|
||||
RESTPatchAPIGuildJSONBody,
|
||||
RESTPatchAPIGuildMemberJSONBody,
|
||||
RESTPatchAPIGuildRoleJSONBody,
|
||||
RESTPatchAPIGuildRolePositionsJSONBody,
|
||||
RESTPatchAPIGuildScheduledEventJSONBody,
|
||||
RESTPatchAPIGuildStickerJSONBody,
|
||||
RESTPatchAPIGuildTemplateJSONBody,
|
||||
RESTPostAPIGuildTemplatesJSONBody
|
||||
RESTPatchAPIGuildVoiceStateCurrentMemberJSONBody,
|
||||
RESTPatchAPIGuildVoiceStateUserJSONBody,
|
||||
RESTPatchAPIGuildWelcomeScreenJSONBody,
|
||||
RESTPatchAPIGuildWidgetSettingsJSONBody,
|
||||
RESTPostAPIAutoModerationRuleJSONBody,
|
||||
RESTPostAPIGuildChannelJSONBody,
|
||||
RESTPostAPIGuildEmojiJSONBody,
|
||||
RESTPostAPIGuildPruneJSONBody,
|
||||
RESTPostAPIGuildRoleJSONBody,
|
||||
RESTPostAPIGuildScheduledEventJSONBody,
|
||||
RESTPostAPIGuildStickerFormDataBody,
|
||||
RESTPostAPIGuildTemplatesJSONBody,
|
||||
RESTPostAPIGuildsJSONBody,
|
||||
RESTPostAPITemplateCreateGuildJSONBody,
|
||||
RESTPutAPIGuildBanJSONBody,
|
||||
RESTPutAPIGuildMemberJSONBody
|
||||
} from '@biscuitland/common';
|
||||
import type { Session } from '../session';
|
||||
|
||||
export class GuildManager {
|
||||
readonly session!: Session;
|
||||
|
@ -3,8 +3,8 @@ import type {
|
||||
RESTPostAPIInteractionCallbackJSONBody,
|
||||
RESTPostAPIInteractionFollowupJSONBody
|
||||
} from '@biscuitland/common';
|
||||
import type { Session } from '..';
|
||||
import type { RawFile } from '@biscuitland/rest';
|
||||
import type { Session } from '..';
|
||||
|
||||
export class InteractionManager {
|
||||
readonly session!: Session;
|
||||
|
@ -1,6 +1,6 @@
|
||||
import type { APIGuildMember, MakeRequired } from "@biscuitland/common";
|
||||
import type { ImageOptions, Session } from "../index";
|
||||
import { formatImageURL } from "../index";
|
||||
import type { APIGuildMember, MakeRequired } from '@biscuitland/common';
|
||||
import type { ImageOptions, Session } from '../index';
|
||||
import { formatImageURL } from '../index';
|
||||
|
||||
export class MemberManager {
|
||||
constructor(private readonly session: Session) {}
|
||||
@ -14,6 +14,6 @@ export class MemberManager {
|
||||
}
|
||||
}
|
||||
|
||||
export type DynamicMember = MakeRequired<APIGuildMember, "user"> & {
|
||||
export type DynamicMember = MakeRequired<APIGuildMember, 'user'> & {
|
||||
guild_id: string;
|
||||
};
|
||||
|
@ -2,21 +2,21 @@ import type {
|
||||
APIUser,
|
||||
RESTGetAPICurrentUserGuildsQuery,
|
||||
RESTPatchAPICurrentUserJSONBody,
|
||||
RESTPutAPICurrentUserApplicationRoleConnectionJSONBody,
|
||||
} from "@biscuitland/common";
|
||||
import type { ImageOptions, Session } from "../index";
|
||||
import { formatImageURL } from "../index";
|
||||
RESTPutAPICurrentUserApplicationRoleConnectionJSONBody
|
||||
} from '@biscuitland/common';
|
||||
import type { ImageOptions, Session } from '../index';
|
||||
import { formatImageURL } from '../index';
|
||||
|
||||
export class UserManager {
|
||||
readonly session!: Session;
|
||||
constructor(session: Session) {
|
||||
Object.defineProperty(this, "session", {
|
||||
Object.defineProperty(this, 'session', {
|
||||
value: session,
|
||||
writable: false,
|
||||
writable: false
|
||||
});
|
||||
}
|
||||
|
||||
get(userId = "@me") {
|
||||
get(userId = '@me') {
|
||||
return this.session.api.users(userId).get();
|
||||
}
|
||||
|
||||
@ -29,36 +29,36 @@ export class UserManager {
|
||||
}
|
||||
|
||||
createDM(userId: string) {
|
||||
return this.session.api.users("@me").channels.post({ body: { recipient_id: userId } });
|
||||
return this.session.api.users('@me').channels.post({ body: { recipient_id: userId } });
|
||||
}
|
||||
|
||||
editCurrent(body: RESTPatchAPICurrentUserJSONBody) {
|
||||
return this.session.api.users("@me").patch({
|
||||
body,
|
||||
return this.session.api.users('@me').patch({
|
||||
body
|
||||
});
|
||||
}
|
||||
|
||||
getGuilds(query?: RESTGetAPICurrentUserGuildsQuery) {
|
||||
return this.session.api.users("@me").guilds.get({ query });
|
||||
return this.session.api.users('@me').guilds.get({ query });
|
||||
}
|
||||
|
||||
getGuildMember(guildId: string) {
|
||||
return this.session.api.users("@me").guilds(guildId).member.get();
|
||||
return this.session.api.users('@me').guilds(guildId).member.get();
|
||||
}
|
||||
|
||||
leaveGuild(guildId: string) {
|
||||
return this.session.api.users("@me").guilds(guildId).delete();
|
||||
return this.session.api.users('@me').guilds(guildId).delete();
|
||||
}
|
||||
|
||||
getConnections() {
|
||||
return this.session.api.users("@me").connections.get();
|
||||
return this.session.api.users('@me').connections.get();
|
||||
}
|
||||
|
||||
getRoleConnections(applicationId: string) {
|
||||
return this.session.api.users("@me").applications(applicationId)["role-connection"].get();
|
||||
return this.session.api.users('@me').applications(applicationId)['role-connection'].get();
|
||||
}
|
||||
|
||||
updateRoleConnection(applicationId: string, body: RESTPutAPICurrentUserApplicationRoleConnectionJSONBody) {
|
||||
return this.session.api.users("@me").applications(applicationId)["role-connection"].put({ body });
|
||||
return this.session.api.users('@me').applications(applicationId)['role-connection'].put({ body });
|
||||
}
|
||||
}
|
||||
|
@ -1,10 +1,10 @@
|
||||
import { GatewayIntentBits, Identify, When } from "@biscuitland/common";
|
||||
import type { BiscuitRESTOptions, CDNRoutes, Routes } from "@biscuitland/rest";
|
||||
import { BiscuitREST, CDN, Router } from "@biscuitland/rest";
|
||||
import { GatewayEvents, GatewayManager, GatewayManagerOptions } from "@biscuitland/ws";
|
||||
import EventEmitter2 from "eventemitter2";
|
||||
import { MainManager, getBotIdFromToken } from ".";
|
||||
import { Handler, actionHandler } from "./events/handler";
|
||||
import { GatewayIntentBits, Identify, When } from '@biscuitland/common';
|
||||
import type { BiscuitRESTOptions, CDNRoutes, Routes } from '@biscuitland/rest';
|
||||
import { BiscuitREST, CDN, Router } from '@biscuitland/rest';
|
||||
import { GatewayEvents, ShardManager, ShardManagerOptions } from '@biscuitland/ws';
|
||||
import EventEmitter2 from 'eventemitter2';
|
||||
import { MainManager, getBotIdFromToken } from '.';
|
||||
import { Handler, actionHandler } from './events/handler';
|
||||
|
||||
export class Session<On extends boolean = boolean> extends EventEmitter2 {
|
||||
constructor(public options: BiscuitOptions) {
|
||||
@ -18,7 +18,7 @@ export class Session<On extends boolean = boolean> extends EventEmitter2 {
|
||||
api: Routes;
|
||||
cdn: CDNRoutes;
|
||||
managers: MainManager;
|
||||
gateway!: When<On, GatewayManager>;
|
||||
gateway!: When<On, ShardManager>;
|
||||
private _applicationId?: string;
|
||||
private _botId?: string;
|
||||
|
||||
@ -67,7 +67,7 @@ export class Session<On extends boolean = boolean> extends EventEmitter2 {
|
||||
if (!rest) {
|
||||
return new BiscuitREST({
|
||||
token: this.options.token,
|
||||
...this.options.defaultRestOptions,
|
||||
...this.options.defaultRestOptions
|
||||
});
|
||||
}
|
||||
|
||||
@ -75,14 +75,14 @@ export class Session<On extends boolean = boolean> extends EventEmitter2 {
|
||||
return rest;
|
||||
}
|
||||
|
||||
throw new Error("[CORE] REST not found");
|
||||
throw new Error('[CORE] REST not found');
|
||||
}
|
||||
|
||||
async start() {
|
||||
// alias fixed `this` on handlePayload
|
||||
const ctx = this as Session<true>;
|
||||
|
||||
ctx.gateway = new GatewayManager({
|
||||
ctx.gateway = new ShardManager({
|
||||
token: this.options.token,
|
||||
intents: this.options.intents ?? 0,
|
||||
info: this.options.defaultGatewayOptions?.info ?? (await this.api.gateway.bot.get()),
|
||||
@ -92,10 +92,10 @@ export class Session<On extends boolean = boolean> extends EventEmitter2 {
|
||||
// @ts-expect-error
|
||||
actionHandler([ctx, { t, d }, shard]);
|
||||
},
|
||||
...this.options.defaultGatewayOptions,
|
||||
...this.options.defaultGatewayOptions
|
||||
});
|
||||
|
||||
ctx.once("READY", (payload) => {
|
||||
ctx.once('READY', (payload) => {
|
||||
const { user, application } = payload;
|
||||
this.botId = user.id;
|
||||
this.applicationId = application.id;
|
||||
@ -110,12 +110,12 @@ export class Session<On extends boolean = boolean> extends EventEmitter2 {
|
||||
}
|
||||
}
|
||||
|
||||
export type HandlePayload = Pick<GatewayManagerOptions, "handlePayload">["handlePayload"];
|
||||
export type HandlePayload = Pick<ShardManagerOptions, 'handlePayload'>['handlePayload'];
|
||||
|
||||
export interface BiscuitOptions {
|
||||
token: string;
|
||||
intents: number | GatewayIntentBits;
|
||||
rest?: BiscuitREST;
|
||||
defaultRestOptions?: Partial<BiscuitRESTOptions>;
|
||||
defaultGatewayOptions?: Identify<Partial<Omit<GatewayManagerOptions, "token" | "intents">>>;
|
||||
defaultGatewayOptions?: Identify<Partial<Omit<ShardManagerOptions, 'token' | 'intents'>>>;
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { MakeRequired, Options } from "@biscuitland/common";
|
||||
import { Handler, type Session } from "@biscuitland/core";
|
||||
import { GatewayEvents } from "@biscuitland/ws";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { MakeRequired, Options } from '@biscuitland/common';
|
||||
import { Handler, type Session } from '@biscuitland/core';
|
||||
import { GatewayEvents } from '@biscuitland/ws';
|
||||
|
||||
interface CollectorOptions<E extends keyof GatewayEvents> {
|
||||
event: `${E}`;
|
||||
@ -13,19 +13,19 @@ interface CollectorOptions<E extends keyof GatewayEvents> {
|
||||
|
||||
export const DEFAULT_OPTIONS = {
|
||||
filter: () => true,
|
||||
max: -1,
|
||||
max: -1
|
||||
};
|
||||
|
||||
export enum CollectorStatus {
|
||||
Idle = 0,
|
||||
Started = 1,
|
||||
Ended = 2,
|
||||
Ended = 2
|
||||
}
|
||||
|
||||
export class EventCollector<E extends keyof GatewayEvents> extends EventEmitter {
|
||||
collected = new Set<Parameters<Handler[E]>[0]>();
|
||||
status: CollectorStatus = CollectorStatus.Idle;
|
||||
options: MakeRequired<CollectorOptions<E>, "filter" | "max">;
|
||||
options: MakeRequired<CollectorOptions<E>, 'filter' | 'max'>;
|
||||
private timeout: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(readonly session: Session, rawOptions: CollectorOptions<E>) {
|
||||
@ -36,24 +36,24 @@ export class EventCollector<E extends keyof GatewayEvents> extends EventEmitter
|
||||
start() {
|
||||
this.session.setMaxListeners(this.session.getMaxListeners() + 1);
|
||||
this.session.on(this.options.event, (...args: unknown[]) => this.collect(...(args as Parameters<Handler[E]>)));
|
||||
this.timeout = setTimeout(() => this.stop("time"), this.options.idle ?? this.options.time);
|
||||
this.timeout = setTimeout(() => this.stop('time'), this.options.idle ?? this.options.time);
|
||||
}
|
||||
|
||||
private collect(...args: Parameters<Handler[E]>) {
|
||||
if (this.options.filter?.(...args)) {
|
||||
this.collected.add(args[0]);
|
||||
this.emit("collect", ...args);
|
||||
this.emit('collect', ...args);
|
||||
}
|
||||
|
||||
if (this.options.idle) {
|
||||
if (this.timeout) clearTimeout(this.timeout);
|
||||
this.timeout = setTimeout(() => this.stop("time"), this.options.idle);
|
||||
this.timeout = setTimeout(() => this.stop('time'), this.options.idle);
|
||||
}
|
||||
|
||||
if (this.collected.size >= this.options.max!) this.stop("max");
|
||||
if (this.collected.size >= this.options.max!) this.stop('max');
|
||||
}
|
||||
|
||||
stop(reason = "User stopped") {
|
||||
stop(reason = 'User stopped') {
|
||||
if (this.status === CollectorStatus.Ended) return;
|
||||
|
||||
if (this.timeout) clearTimeout(this.timeout);
|
||||
@ -62,17 +62,17 @@ export class EventCollector<E extends keyof GatewayEvents> extends EventEmitter
|
||||
this.session.setMaxListeners(this.session.getMaxListeners() - 1);
|
||||
|
||||
this.status = CollectorStatus.Ended;
|
||||
this.emit("end", reason, this.collected);
|
||||
this.emit('end', reason, this.collected);
|
||||
}
|
||||
|
||||
on(event: "collect", listener: (...args: Parameters<Handler[E]>) => unknown): this;
|
||||
on(event: "end", listener: (reason: string | null | undefined, collected: Set<Parameters<Handler[E]>[0]>) => void): this;
|
||||
on(event: 'collect', listener: (...args: Parameters<Handler[E]>) => unknown): this;
|
||||
on(event: 'end', listener: (reason: string | null | undefined, collected: Set<Parameters<Handler[E]>[0]>) => void): this;
|
||||
on(event: string, listener: unknown): this {
|
||||
return super.on(event, listener as () => unknown);
|
||||
}
|
||||
|
||||
once(event: "collect", listener: (...args: Parameters<Handler[E]>) => unknown): this;
|
||||
once(event: "end", listener: (reason: string | null | undefined, collected: Set<Parameters<Handler[E]>[0]>) => void): this;
|
||||
once(event: 'collect', listener: (...args: Parameters<Handler[E]>) => unknown): this;
|
||||
once(event: 'end', listener: (reason: string | null | undefined, collected: Set<Parameters<Handler[E]>[0]>) => void): this;
|
||||
once(event: string, listener: unknown): this {
|
||||
return super.once(event, listener as () => unknown);
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { APIEmbed, APIEmbedAuthor, APIEmbedField, APIEmbedFooter, ObjectToLower, TypeArray, toSnakeCase } from "@biscuitland/common";
|
||||
import { APIEmbed, APIEmbedAuthor, APIEmbedField, APIEmbedFooter, ObjectToLower, TypeArray, toSnakeCase } from '@biscuitland/common';
|
||||
|
||||
export class MessageEmbed {
|
||||
constructor(public data: Partial<APIEmbed> = {}) {
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { PermissionFlagsBits } from "@biscuitland/common";
|
||||
import { PermissionFlagsBits } from '@biscuitland/common';
|
||||
|
||||
export type PermissionsStrings = keyof typeof PermissionFlagsBits;
|
||||
export type PermissionResolvable = bigint | PermissionsStrings | PermissionsStrings[] | PermissionsStrings | PermissionsStrings[];
|
||||
@ -89,17 +89,17 @@ export class Permissions {
|
||||
|
||||
static resolve(bit: PermissionResolvable): bigint {
|
||||
switch (typeof bit) {
|
||||
case "bigint":
|
||||
case 'bigint':
|
||||
return bit;
|
||||
case "number":
|
||||
case 'number':
|
||||
return BigInt(bit);
|
||||
case "string":
|
||||
case 'string':
|
||||
return BigInt(Permissions.Flags[bit]);
|
||||
case "object":
|
||||
case 'object':
|
||||
return Permissions.resolve(
|
||||
bit
|
||||
.map((p) => (typeof p === "string" ? BigInt(Permissions.Flags[p]) : BigInt(p)))
|
||||
.reduce((acc, cur) => acc | cur, Permissions.None),
|
||||
.map((p) => (typeof p === 'string' ? BigInt(Permissions.Flags[p]) : BigInt(p)))
|
||||
.reduce((acc, cur) => acc | cur, Permissions.None)
|
||||
);
|
||||
default:
|
||||
throw new TypeError(`Cannot resolve permission: ${bit}`);
|
||||
@ -125,7 +125,7 @@ export class Permissions {
|
||||
}
|
||||
|
||||
toJSON(): { fields: string[] } {
|
||||
const fields = Object.keys(Permissions.Flags).filter((bit) => typeof bit === "number" && this.has(bit));
|
||||
const fields = Object.keys(Permissions.Flags).filter((bit) => typeof bit === 'number' && this.has(bit));
|
||||
|
||||
return { fields };
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { APIMessageActionRowComponent, APIModalActionRowComponent, ComponentType } from "@biscuitland/common";
|
||||
import { APIMessageActionRowComponent, APIModalActionRowComponent, ComponentType } from '@biscuitland/common';
|
||||
import {
|
||||
ChannelSelectMenu,
|
||||
MentionableSelectMenu,
|
||||
@ -6,9 +6,9 @@ import {
|
||||
ModalTextInput,
|
||||
RoleSelectMenu,
|
||||
StringSelectMenu,
|
||||
UserSelectMenu,
|
||||
} from "./components";
|
||||
import { BaseComponent } from "./components/BaseComponent";
|
||||
UserSelectMenu
|
||||
} from './components';
|
||||
import { BaseComponent } from './components/BaseComponent';
|
||||
|
||||
export function createComponent(data: APIMessageActionRowComponent): HelperComponents;
|
||||
export function createComponent(data: APIModalActionRowComponent): HelperComponents;
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { ApplicationCommandType, LocalizationMap, RESTPostAPIContextMenuApplicationCommandsJSONBody } from "@biscuitland/common";
|
||||
import { PermissionResolvable, Permissions } from "../../Permissions";
|
||||
import { ApplicationCommandType, LocalizationMap, RESTPostAPIContextMenuApplicationCommandsJSONBody } from '@biscuitland/common';
|
||||
import { PermissionResolvable, Permissions } from '../../Permissions';
|
||||
|
||||
export type ContextCommandType = ApplicationCommandType.Message | ApplicationCommandType.User;
|
||||
|
||||
|
@ -1,3 +1,3 @@
|
||||
export * from "./contextMenu/ContextCommand";
|
||||
export * from "./slash/SlashCommand";
|
||||
export * from "./slash/SlashCommandOption";
|
||||
export * from './contextMenu/ContextCommand';
|
||||
export * from './slash/SlashCommand';
|
||||
export * from './slash/SlashCommandOption';
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { ApplicationCommandType, RESTPostAPIChatInputApplicationCommandsJSONBody } from "@biscuitland/common";
|
||||
import { Mixin } from "ts-mixer";
|
||||
import { PermissionResolvable, Permissions } from "../../Permissions";
|
||||
import { AllSlashOptions, SlashSubcommandGroupOption, SlashSubcommandOption } from "./SlashCommandOption";
|
||||
import { ApplicationCommandType, RESTPostAPIChatInputApplicationCommandsJSONBody } from '@biscuitland/common';
|
||||
import { Mixin } from 'ts-mixer';
|
||||
import { PermissionResolvable, Permissions } from '../../Permissions';
|
||||
import { AllSlashOptions, SlashSubcommandGroupOption, SlashSubcommandOption } from './SlashCommandOption';
|
||||
|
||||
class SlashCommandB {
|
||||
constructor(public data: Partial<RESTPostAPIChatInputApplicationCommandsJSONBody> = {}) {}
|
||||
@ -33,7 +33,7 @@ class SlashCommandB {
|
||||
return this;
|
||||
}
|
||||
|
||||
addRawOption(option: ReturnType<AllSlashOptions["toJSON"]>) {
|
||||
addRawOption(option: ReturnType<AllSlashOptions['toJSON']>) {
|
||||
this.data.options ??= [];
|
||||
// @ts-expect-error discord-api-types bad typing, again
|
||||
this.data.options.push(option);
|
||||
@ -42,7 +42,7 @@ class SlashCommandB {
|
||||
toJSON(): RESTPostAPIChatInputApplicationCommandsJSONBody {
|
||||
return {
|
||||
...this.data,
|
||||
type: ApplicationCommandType.ChatInput,
|
||||
type: ApplicationCommandType.ChatInput
|
||||
} as RESTPostAPIChatInputApplicationCommandsJSONBody & {
|
||||
type: ApplicationCommandType.ChatInput;
|
||||
};
|
||||
|
@ -1,26 +1,26 @@
|
||||
import {
|
||||
APIApplicationCommandIntegerOption as AACIO,
|
||||
APIApplicationCommandNumberOption as AACNO,
|
||||
APIApplicationCommandSubcommandOption as AACSCO,
|
||||
APIApplicationCommandSubcommandGroupOption as AACSGO,
|
||||
APIApplicationCommandStringOption as AACSO,
|
||||
APIApplicationCommandAttachmentOption,
|
||||
APIApplicationCommandBooleanOption,
|
||||
APIApplicationCommandChannelOption,
|
||||
APIApplicationCommandIntegerOption as AACIO,
|
||||
APIApplicationCommandMentionableOption,
|
||||
APIApplicationCommandNumberOption as AACNO,
|
||||
APIApplicationCommandOption,
|
||||
APIApplicationCommandOptionBase,
|
||||
APIApplicationCommandOptionChoice,
|
||||
APIApplicationCommandRoleOption,
|
||||
APIApplicationCommandStringOption as AACSO,
|
||||
APIApplicationCommandSubcommandGroupOption as AACSGO,
|
||||
APIApplicationCommandSubcommandOption as AACSCO,
|
||||
APIApplicationCommandUserOption,
|
||||
ApplicationCommandOptionType,
|
||||
ChannelType,
|
||||
LocalizationMap,
|
||||
RestToKeys,
|
||||
TypeArray,
|
||||
When,
|
||||
} from "@biscuitland/common";
|
||||
import { OptionValuesLength } from "../../";
|
||||
When
|
||||
} from '@biscuitland/common';
|
||||
import { OptionValuesLength } from '../../';
|
||||
|
||||
export type SlashBaseOptionTypes =
|
||||
| Exclude<APIApplicationCommandOption, AACSO | AACNO | AACIO | AACSCO>
|
||||
@ -53,7 +53,7 @@ export abstract class SlashBaseOption<DataType extends SlashBaseOptionTypes> {
|
||||
return this;
|
||||
}
|
||||
|
||||
addLocalizations(locals: RestToKeys<[LocalizationMap, "name", "description"]>): this {
|
||||
addLocalizations(locals: RestToKeys<[LocalizationMap, 'name', 'description']>): this {
|
||||
this.data.name_localizations = locals.name;
|
||||
this.data.description_localizations = locals.description;
|
||||
return this;
|
||||
@ -102,7 +102,7 @@ export class SlashStringOption<AC extends boolean = boolean> extends SlashRequir
|
||||
addChoices(choices: TypeArray<APIApplicationCommandOptionChoice<string>>): SlashStringOption<true> {
|
||||
const ctx = this as SlashStringOption<true>;
|
||||
ctx.data.choices ??= [];
|
||||
ctx.data.choices.concat(choices);
|
||||
ctx.data.choices = ctx.data.choices!.concat(choices);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
@ -148,7 +148,7 @@ export class SlashNumberOption<AC extends boolean = boolean> extends SlashRequir
|
||||
addChoices(choices: TypeArray<APIApplicationCommandOptionChoice<number>>): SlashNumberOption<true> {
|
||||
const ctx = this as SlashNumberOption<true>;
|
||||
ctx.data.choices ??= [];
|
||||
ctx.data.choices.concat(choices);
|
||||
ctx.data.choices = ctx.data.choices.concat(choices);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
@ -333,7 +333,7 @@ export class SlashSubcommandGroupOption extends SlashBaseOption<APIApplicationCo
|
||||
return this;
|
||||
}
|
||||
|
||||
addRawOption(option: ReturnType<SlashSubcommandOption["toJSON"]>) {
|
||||
addRawOption(option: ReturnType<SlashSubcommandOption['toJSON']>) {
|
||||
this.data.options ??= [];
|
||||
this.data.options.push(option);
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { APIActionRowComponent, APIMessageActionRowComponent, ComponentType, TypeArray } from "@biscuitland/common";
|
||||
import { MessageComponents, createComponent } from "../Utils";
|
||||
import { BaseComponent } from "./BaseComponent";
|
||||
import { APIActionRowComponent, APIMessageActionRowComponent, ComponentType, TypeArray } from '@biscuitland/common';
|
||||
import { MessageComponents, createComponent } from '../Utils';
|
||||
import { BaseComponent } from './BaseComponent';
|
||||
|
||||
export class MessageActionRow<T extends MessageComponents> extends BaseComponent<APIActionRowComponent<APIMessageActionRowComponent>> {
|
||||
constructor({ components, ...data }: Partial<APIActionRowComponent<APIMessageActionRowComponent>> = {}) {
|
||||
@ -10,7 +10,7 @@ export class MessageActionRow<T extends MessageComponents> extends BaseComponent
|
||||
components: T[];
|
||||
|
||||
addComponents(component: TypeArray<T>): this {
|
||||
this.components.concat(component);
|
||||
this.components = this.components.concat(component);
|
||||
return this;
|
||||
}
|
||||
|
||||
@ -22,7 +22,7 @@ export class MessageActionRow<T extends MessageComponents> extends BaseComponent
|
||||
toJSON(): APIActionRowComponent<APIMessageActionRowComponent> {
|
||||
return {
|
||||
...this.data,
|
||||
components: this.components.map((c) => c.toJSON()),
|
||||
} as APIActionRowComponent<ReturnType<T["toJSON"]>>;
|
||||
components: this.components.map((c) => c.toJSON())
|
||||
} as APIActionRowComponent<ReturnType<T['toJSON']>>;
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { APIBaseComponent, ComponentType } from "@biscuitland/common";
|
||||
import { APIBaseComponent, ComponentType } from '@biscuitland/common';
|
||||
|
||||
export abstract class BaseComponent<TYPE extends Partial<APIBaseComponent<ComponentType>> = APIBaseComponent<ComponentType>,> {
|
||||
constructor(public data: Partial<TYPE>) {}
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { APIButtonComponentBase, APIMessageComponentEmoji, ButtonStyle, ComponentType, When } from "@biscuitland/common";
|
||||
import { BaseComponent } from "./BaseComponent";
|
||||
import { APIButtonComponentBase, APIMessageComponentEmoji, ButtonStyle, ComponentType, When } from '@biscuitland/common';
|
||||
import { BaseComponent } from './BaseComponent';
|
||||
|
||||
export type ButtonStylesForID = Exclude<ButtonStyle, ButtonStyle.Link>;
|
||||
|
||||
|
@ -73,7 +73,7 @@ export class StringSelectMenu extends SelectMenu<APIStringSelectComponent> {
|
||||
|
||||
addOption(option: TypeArray<APISelectMenuOption>): this {
|
||||
this.data.options ??= [];
|
||||
this.data.options.concat(option);
|
||||
this.data.options = this.data.options.concat(option);
|
||||
return this;
|
||||
}
|
||||
|
||||
@ -84,6 +84,7 @@ export class StringSelectMenu extends SelectMenu<APIStringSelectComponent> {
|
||||
}
|
||||
|
||||
export class StringSelectOption {
|
||||
// biome-ignore lint/nursery/noEmptyBlockStatements: <explanation>
|
||||
constructor(public data: Partial<APISelectMenuOption> = {}) {}
|
||||
|
||||
setLabel(label: string): this {
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { APITextInputComponent, ComponentType, TextInputStyle } from "@biscuitland/common";
|
||||
import { OptionValuesLength } from "..";
|
||||
import { BaseComponent } from "./BaseComponent";
|
||||
import { APITextInputComponent, ComponentType, TextInputStyle } from '@biscuitland/common';
|
||||
import { OptionValuesLength } from '..';
|
||||
import { BaseComponent } from './BaseComponent';
|
||||
|
||||
export class ModalTextInput extends BaseComponent<APITextInputComponent> {
|
||||
constructor(data: Partial<APITextInputComponent> = {}) {
|
||||
|
@ -1,5 +1,5 @@
|
||||
export * from "./ActionRow";
|
||||
export * from "./BaseComponent";
|
||||
export * from "./MessageButton";
|
||||
export * from "./SelectMenu";
|
||||
export * from "./TextInput";
|
||||
export * from './ActionRow';
|
||||
export * from './BaseComponent';
|
||||
export * from './MessageButton';
|
||||
export * from './SelectMenu';
|
||||
export * from './TextInput';
|
||||
|
@ -1,5 +1,5 @@
|
||||
export * from "./MessageEmbed";
|
||||
export * from "./Permissions";
|
||||
export * from "./Utils";
|
||||
export * from "./commands";
|
||||
export * from "./components";
|
||||
export * from './MessageEmbed';
|
||||
export * from './Permissions';
|
||||
export * from './Utils';
|
||||
export * from './commands';
|
||||
export * from './components';
|
||||
|
@ -1,8 +1,8 @@
|
||||
import type { Identify } from '@biscuitland/common';
|
||||
import type { RawFile, RequestData } from '@discordjs/rest';
|
||||
import { REST } from '@discordjs/rest';
|
||||
import type { Identify } from '@biscuitland/common';
|
||||
import type { RequestMethod } from './Router';
|
||||
import { Routes } from './Routes';
|
||||
|
||||
export class BiscuitREST {
|
||||
api: REST;
|
||||
constructor(public options: BiscuitRESTOptions) {
|
||||
@ -72,12 +72,12 @@ export type RequestObject<M extends RequestMethod, B = Record<string, any>, Q =
|
||||
(M extends `${RequestMethod.Get}`
|
||||
? unknown
|
||||
: {
|
||||
body?: B;
|
||||
files?: RawFile[];
|
||||
});
|
||||
body?: B;
|
||||
files?: RawFile[];
|
||||
});
|
||||
|
||||
export type RestArguments<M extends RequestMethod, B = any, Q extends never | Record<string, any> = any> = M extends RequestMethod.Get
|
||||
? Q extends never
|
||||
? RequestObject<M, never, B>
|
||||
: never
|
||||
? RequestObject<M, never, B>
|
||||
: never
|
||||
: RequestObject<M, B, Q>;
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { RESTGetAPIGatewayResult, RESTGetAPIGatewayBotResult } from '@biscuitland/common';
|
||||
import { RESTGetAPIGatewayBotResult, RESTGetAPIGatewayResult } from '@biscuitland/common';
|
||||
import { RestArguments } from '../REST';
|
||||
import { RequestMethod } from '../Router';
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
export * from './Routes/';
|
||||
export * from './Router';
|
||||
export * from './REST';
|
||||
export { REST, type RawFile } from '@discordjs/rest';
|
||||
export * from './CDN';
|
||||
export { type RawFile, REST } from '@discordjs/rest';
|
||||
export * from './REST';
|
||||
export * from './Router';
|
||||
export * from './Routes';
|
||||
|
@ -23,7 +23,7 @@ yarn add @biscuitland/ws
|
||||
## Example
|
||||
|
||||
```ts
|
||||
import { GatewayManager } from "@biscuitland/ws";
|
||||
import { ShardManager } from "@biscuitland/ws";
|
||||
import { BiscuitREST, Router } from "@biscuitland/rest";
|
||||
import { GatewayIntentBits } from "@biscuitland/common";
|
||||
|
||||
@ -36,7 +36,7 @@ const api = new Router(rest).createProxy();
|
||||
const connection = await api.gateway.bot.get();
|
||||
|
||||
// gateway bot code ↓
|
||||
const ws = new GatewayManager({
|
||||
const ws = new ShardManager({
|
||||
token,
|
||||
intents,
|
||||
connection,
|
||||
|
@ -11,7 +11,6 @@ import type {
|
||||
GatewayAutoModerationActionExecutionDispatchData,
|
||||
GatewayChannelPinsUpdateDispatchData,
|
||||
GatewayChannelUpdateDispatchData,
|
||||
GatewayDispatchEvents,
|
||||
GatewayGuildBanAddDispatchData,
|
||||
GatewayGuildBanRemoveDispatchData,
|
||||
GatewayGuildCreateDispatchData,
|
||||
@ -56,13 +55,15 @@ import type {
|
||||
GatewayVoiceStateUpdateData,
|
||||
GatewayWebhooksUpdateDispatchData,
|
||||
PresenceUpdateStatus,
|
||||
RestToKeys,
|
||||
} from "@biscuitland/common";
|
||||
RestToKeys
|
||||
} from '@biscuitland/common';
|
||||
|
||||
import { GatewayDispatchEvents } from '@biscuitland/common';
|
||||
|
||||
/** https://discord.com/developers/docs/topics/gateway-events#update-presence */
|
||||
export interface StatusUpdate {
|
||||
/** The user's activities */
|
||||
activities?: Omit<GatewayActivity, "created_at" | "id">[];
|
||||
activities?: Omit<GatewayActivity, 'created_at' | 'id'>[];
|
||||
/** The user's new status */
|
||||
status: PresenceUpdateStatus;
|
||||
}
|
||||
@ -79,7 +80,7 @@ export interface UpdateVoiceState {
|
||||
self_deaf: boolean;
|
||||
}
|
||||
|
||||
export type ShardStatusUpdate = Pick<GatewayPresenceUpdateData, "activities" | "status">;
|
||||
export type ShardStatusUpdate = Pick<GatewayPresenceUpdateData, 'activities' | 'status'>;
|
||||
|
||||
export interface RequestGuildMembersOptions extends GatewayRequestGuildMembersDataWithQuery, GatewayRequestGuildMembersDataWithUserIds {}
|
||||
|
||||
@ -96,7 +97,7 @@ export type AtLeastOne<
|
||||
T,
|
||||
U = {
|
||||
[K in keyof T]: Pick<T, K>;
|
||||
},
|
||||
}
|
||||
> = Partial<T> & U[keyof U];
|
||||
|
||||
export type ClientUser = { bot: true } & APIUser;
|
||||
@ -155,7 +156,7 @@ export type StageSameEvents = RestToKeys<
|
||||
APIStageInstance,
|
||||
GatewayDispatchEvents.StageInstanceCreate,
|
||||
GatewayDispatchEvents.StageInstanceUpdate,
|
||||
GatewayDispatchEvents.StageInstanceDelete,
|
||||
GatewayDispatchEvents.StageInstanceDelete
|
||||
]
|
||||
>;
|
||||
|
||||
@ -167,7 +168,7 @@ export type GuildScheduledUserSameEvents = RestToKeys<
|
||||
[
|
||||
GatewayGuildScheduledEventUserRemoveDispatchData,
|
||||
GatewayDispatchEvents.GuildScheduledEventUserRemove,
|
||||
GatewayDispatchEvents.GuildScheduledEventUserAdd,
|
||||
GatewayDispatchEvents.GuildScheduledEventUserAdd
|
||||
]
|
||||
>;
|
||||
|
||||
@ -176,7 +177,7 @@ export type GuildScheduledSameEvents = RestToKeys<
|
||||
APIGuildScheduledEvent,
|
||||
GatewayDispatchEvents.GuildScheduledEventCreate,
|
||||
GatewayDispatchEvents.GuildScheduledEventDelete,
|
||||
GatewayDispatchEvents.GuildScheduledEventUpdate,
|
||||
GatewayDispatchEvents.GuildScheduledEventUpdate
|
||||
]
|
||||
>;
|
||||
|
||||
@ -189,7 +190,7 @@ export type AutoModetaractionRuleEvents = RestToKeys<
|
||||
APIAutoModerationRule,
|
||||
GatewayDispatchEvents.AutoModerationRuleCreate,
|
||||
GatewayDispatchEvents.AutoModerationRuleDelete,
|
||||
GatewayDispatchEvents.AutoModerationRuleUpdate,
|
||||
GatewayDispatchEvents.AutoModerationRuleUpdate
|
||||
]
|
||||
>;
|
||||
|
||||
|
@ -1,24 +1,24 @@
|
||||
import type { GatewayDispatchPayload } from "@biscuitland/common";
|
||||
import type { GatewayManagerOptions } from "../gateway";
|
||||
import type { GatewayDispatchPayload } from '@biscuitland/common';
|
||||
import { ShardManagerOptions } from '../discord';
|
||||
|
||||
const COMPRESS = false;
|
||||
|
||||
const properties = {
|
||||
os: process.platform,
|
||||
browser: "Biscuit",
|
||||
device: "Biscuit",
|
||||
browser: 'Biscuit',
|
||||
device: 'Biscuit'
|
||||
};
|
||||
|
||||
const GatewayManagerDefaults: Partial<GatewayManagerOptions> = {
|
||||
const ShardManagerDefaults: Partial<ShardManagerOptions> = {
|
||||
totalShards: 1,
|
||||
spawnShardDelay: 5300,
|
||||
debug: false,
|
||||
intents: 0,
|
||||
properties: properties,
|
||||
version: 10,
|
||||
handlePayload: function (shardId: number, packet: GatewayDispatchPayload): void {
|
||||
handlePayload: (shardId: number, packet: GatewayDispatchPayload): void => {
|
||||
console.info(`Packet ${packet.t} on shard ${shardId}`);
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
export interface IdentifyProperties {
|
||||
@ -51,41 +51,7 @@ enum ShardState {
|
||||
/** Shard is trying to resume a session with the gateway. */
|
||||
Resuming = 5,
|
||||
/** Shard got shut down studied or due to a not (self) fixable error and may not attempt to reconnect on its own. */
|
||||
Offline = 6,
|
||||
Offline = 6
|
||||
}
|
||||
|
||||
function isObject(o: any) {
|
||||
return o && typeof o === "object" && !Array.isArray(o);
|
||||
}
|
||||
|
||||
function Options<T>(defaults: any, ...options: any[]): T {
|
||||
const option = options.shift();
|
||||
if (!option) return defaults;
|
||||
|
||||
return Options(
|
||||
{
|
||||
...option,
|
||||
...Object.fromEntries(
|
||||
Object.entries(defaults).map(([key, value]) => [
|
||||
key,
|
||||
isObject(value) ? Options(value, option?.[key] || {}) : option?.[key] || value,
|
||||
]),
|
||||
),
|
||||
},
|
||||
...options,
|
||||
);
|
||||
}
|
||||
|
||||
// nutella truco
|
||||
function OptionsD<O extends Record<any, any>>(o: O) {
|
||||
return function (target: { new (...args: any[]): any }) {
|
||||
return class extends target {
|
||||
constructor(...args: any[]) {
|
||||
super();
|
||||
this.options = Options(o, ...args.filter(isObject));
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export { COMPRESS, GatewayManagerDefaults, Options, OptionsD, ShardState, isObject, properties };
|
||||
export { COMPRESS, ShardManagerDefaults, ShardState, properties };
|
||||
|
122
packages/ws/src/discord/heartbeater.ts
Normal file
122
packages/ws/src/discord/heartbeater.ts
Normal file
@ -0,0 +1,122 @@
|
||||
import { GatewayHeartbeatRequest, GatewayHello, GatewayOpcodes, GatewayReceivePayload } from '@biscuitland/common';
|
||||
import { Shard } from './shard.js';
|
||||
import { ShardSocketCloseCodes } from './shared.js';
|
||||
|
||||
export interface ShardHeart {
|
||||
/** Whether or not the heartbeat was acknowledged by Discord in time. */
|
||||
ack: boolean;
|
||||
/** Interval between heartbeats requested by Discord. */
|
||||
interval: number;
|
||||
/** Id of the interval, which is used for sending the heartbeats. */
|
||||
intervalId?: NodeJS.Timeout;
|
||||
/** Unix (in milliseconds) timestamp when the last heartbeat ACK was received from Discord. */
|
||||
lastAck?: number;
|
||||
/** Unix timestamp (in milliseconds) when the last heartbeat was sent. */
|
||||
lastBeat?: number;
|
||||
/** Round trip time (in milliseconds) from Shard to Discord and back.
|
||||
* Calculated using the heartbeat system.
|
||||
* Note: this value is undefined until the first heartbeat to Discord has happened.
|
||||
*/
|
||||
rtt?: number;
|
||||
/** Id of the timeout which is used for sending the first heartbeat to Discord since it's "special". */
|
||||
timeoutId?: NodeJS.Timeout;
|
||||
/** internal value */
|
||||
toString(): string;
|
||||
}
|
||||
|
||||
export class ShardHeartBeater {
|
||||
heart: ShardHeart = {
|
||||
ack: false,
|
||||
interval: 30_000
|
||||
};
|
||||
// biome-ignore lint/nursery/noEmptyBlockStatements: <explanation>
|
||||
constructor(public shard: Shard) {}
|
||||
|
||||
acknowledge(ack = true) {
|
||||
this.heart.ack = ack;
|
||||
}
|
||||
|
||||
handleHeartbeat(_packet: Extract<GatewayReceivePayload, GatewayHeartbeatRequest>) {
|
||||
this.shard.logger.debug(`[Shard #${this.shard.id}] received hearbeat event`);
|
||||
this.heartbeat(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* sends a heartbeat whenever its needed
|
||||
* fails if heart.interval is null
|
||||
*/
|
||||
heartbeat(acknowledgeAck: boolean) {
|
||||
if (acknowledgeAck) {
|
||||
if (!this.heart.lastAck) {
|
||||
this.shard.logger.debug(`[Shard #${this.shard.id}] Heartbeat not acknowledged.`);
|
||||
this.shard.close(ShardSocketCloseCodes.ZombiedConnection, 'Zombied connection, did not receive an heartbeat ACK in time.');
|
||||
this.shard.identify(true);
|
||||
}
|
||||
this.heart.lastAck = undefined;
|
||||
}
|
||||
|
||||
this.heart.lastBeat = Date.now();
|
||||
|
||||
// avoid creating a bucket here
|
||||
this.shard.websocket?.send(
|
||||
JSON.stringify({
|
||||
op: GatewayOpcodes.Heartbeat,
|
||||
d: this.shard.data.resumeSeq
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
stopHeartbeating() {
|
||||
clearInterval(this.heart.intervalId);
|
||||
clearTimeout(this.heart.timeoutId);
|
||||
}
|
||||
|
||||
startHeartBeating() {
|
||||
this.shard.logger.debug(`[Shard #${this.shard.id}] scheduling heartbeat!`);
|
||||
|
||||
if (!this.shard.isOpen()) return;
|
||||
|
||||
// The first heartbeat needs to be send with a random delay between `0` and `interval`
|
||||
// Using a `setTimeout(_, jitter)` here to accomplish that.
|
||||
// `Math.random()` can be `0` so we use `0.5` if this happens
|
||||
// Reference: https://discord.com/developers/docs/topics/gateway#heartbeating
|
||||
const jitter = Math.ceil(this.heart.interval * (Math.random() || 0.5));
|
||||
|
||||
this.heart.timeoutId = setTimeout(() => {
|
||||
// send a heartbeat
|
||||
this.heartbeat(false);
|
||||
this.heart.intervalId = setInterval(() => {
|
||||
this.acknowledge(false);
|
||||
this.heartbeat(false);
|
||||
}, this.heart.interval);
|
||||
}, jitter);
|
||||
}
|
||||
|
||||
handleHello(packet: GatewayHello) {
|
||||
if (packet.d.heartbeat_interval > 0) {
|
||||
if (this.heart.interval != null) {
|
||||
this.stopHeartbeating();
|
||||
}
|
||||
|
||||
this.heart.interval = packet.d.heartbeat_interval;
|
||||
this.heart.intervalId = setInterval(() => {
|
||||
this.acknowledge(false);
|
||||
this.heartbeat(false);
|
||||
}, this.heart.interval);
|
||||
}
|
||||
|
||||
this.startHeartBeating();
|
||||
}
|
||||
|
||||
onpacket(packet: GatewayReceivePayload) {
|
||||
switch (packet.op) {
|
||||
case GatewayOpcodes.Heartbeat:
|
||||
return this.handleHeartbeat(packet);
|
||||
case GatewayOpcodes.Hello:
|
||||
return this.handleHello(packet);
|
||||
case GatewayOpcodes.HeartbeatAck:
|
||||
this.acknowledge();
|
||||
return (this.heart.lastAck = Date.now());
|
||||
}
|
||||
}
|
||||
}
|
3
packages/ws/src/discord/index.ts
Normal file
3
packages/ws/src/discord/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './shard';
|
||||
export * from './sharder';
|
||||
export * from './shared';
|
315
packages/ws/src/discord/shard.ts
Normal file
315
packages/ws/src/discord/shard.ts
Normal file
@ -0,0 +1,315 @@
|
||||
import {
|
||||
GATEWAY_BASE_URL,
|
||||
GatewayCloseCodes,
|
||||
GatewayDispatchEvents,
|
||||
GatewayDispatchPayload,
|
||||
GatewayOpcodes,
|
||||
GatewayReadyDispatchData,
|
||||
GatewayReceivePayload,
|
||||
GatewaySendPayload,
|
||||
type Logger,
|
||||
} from "@biscuitland/common";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { inflateSync } from "node:zlib";
|
||||
import WS, { WebSocket, type CloseEvent } from "ws";
|
||||
import { ShardState, properties } from "../constants";
|
||||
import { DynamicBucket, PriorityQueue } from "../structures";
|
||||
import { ShardHeartBeater } from "./heartbeater.js";
|
||||
import { ShardData, ShardOptions, ShardSocketCloseCodes } from "./shared.js";
|
||||
|
||||
export class Shard {
|
||||
logger: Logger;
|
||||
data: Partial<ShardData> | ShardData;
|
||||
websocket: WebSocket | null = null;
|
||||
heartbeater: ShardHeartBeater;
|
||||
bucket: DynamicBucket;
|
||||
offlineSendQueue = new PriorityQueue<(_?: unknown) => void>();
|
||||
constructor(public id: number, protected options: ShardOptions) {
|
||||
this.options.ratelimitOptions ??= {
|
||||
rateLimitResetInterval: 60_000,
|
||||
maxRequestsPerRateLimitTick: 120,
|
||||
};
|
||||
this.logger = options.logger;
|
||||
this.data = {
|
||||
resumeSeq: null,
|
||||
resume_gateway_url: GATEWAY_BASE_URL,
|
||||
};
|
||||
|
||||
this.heartbeater = new ShardHeartBeater(this);
|
||||
|
||||
const safe = this.calculateSafeRequests();
|
||||
this.bucket = new DynamicBucket({
|
||||
limit: safe,
|
||||
refillAmount: safe,
|
||||
refillInterval: 6e4,
|
||||
logger: this.logger,
|
||||
});
|
||||
}
|
||||
|
||||
isOpen() {
|
||||
this.logger.fatal(`[Shard #${this.id}]`, "isOpen", this.websocket?.readyState === WebSocket.OPEN);
|
||||
return this.websocket?.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
/**
|
||||
* the state of the current shard
|
||||
*/
|
||||
get state() {
|
||||
return this.data.shardState ?? ShardState.Offline;
|
||||
}
|
||||
|
||||
set state(st: ShardState) {
|
||||
this.data.shardState = st;
|
||||
}
|
||||
|
||||
get gatewayURL() {
|
||||
return this.data.resume_gateway_url ?? this.options.info.url;
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.logger.fatal(`[Shard #${this.id}]`, "Connect", this.state);
|
||||
if (![ShardState.Resuming, ShardState.Identifying].includes(this.state)) {
|
||||
this.state = ShardState.Connecting;
|
||||
}
|
||||
|
||||
this.websocket = new WebSocket(this.gatewayURL);
|
||||
|
||||
this.websocket!.onmessage = (event) => this.handleMessage(event);
|
||||
|
||||
this.websocket!.onclose = (event) => this.handleClosed(event);
|
||||
|
||||
this.websocket!.onerror = (event) => this.logger.error(event);
|
||||
|
||||
return new Promise<Shard>((resolve, reject) => {
|
||||
const timer = setTimeout(reject, 30_000);
|
||||
this.websocket!.onopen = () => {
|
||||
if (![ShardState.Resuming, ShardState.Identifying].includes(this.state)) {
|
||||
this.state = ShardState.Unidentified;
|
||||
}
|
||||
|
||||
clearTimeout(timer);
|
||||
resolve(this);
|
||||
};
|
||||
|
||||
this.heartbeater = new ShardHeartBeater(this);
|
||||
});
|
||||
}
|
||||
|
||||
checkOffline(priority: number) {
|
||||
// biome-ignore lint/style/noArguments: <explanation>
|
||||
// biome-ignore lint/correctness/noUndeclaredVariables: <explanation>
|
||||
this.logger.fatal(`[Shard #${this.id}]`, "checkOffline", ...arguments);
|
||||
if (!this.isOpen()) {
|
||||
return new Promise((resolve) => this.offlineSendQueue.push(resolve, priority));
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
async identify(justTry = false) {
|
||||
this.logger.debug(`[Shard #${this.id}] ${justTry ? "Trying " : ""}on identify ${this.isOpen()}`);
|
||||
|
||||
if (this.isOpen()) {
|
||||
if (justTry) return;
|
||||
this.logger.debug(`[Shard #${this.id}] CLOSING EXISTING SHARD`);
|
||||
this.close(ShardSocketCloseCodes.ReIdentifying, "Re-identifying closure of old connection.");
|
||||
}
|
||||
|
||||
this.state = ShardState.Identifying;
|
||||
|
||||
if (!this.isOpen()) {
|
||||
await this.connect();
|
||||
}
|
||||
|
||||
this.send(0, {
|
||||
op: GatewayOpcodes.Identify,
|
||||
d: {
|
||||
token: `Bot ${this.options.token}`,
|
||||
compress: this.options.compress,
|
||||
properties,
|
||||
shard: [this.id, this.options.info.shards],
|
||||
intents: this.options.intents,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
resume() {
|
||||
this.logger.fatal(`[Shard #${this.id}]`, "Resuming");
|
||||
this.state = ShardState.Resuming;
|
||||
const data = {
|
||||
seq: this.data.resumeSeq!,
|
||||
session_id: this.data.session_id!,
|
||||
token: `Bot ${this.options.token}`,
|
||||
};
|
||||
console.log({ data });
|
||||
return this.send(0, { d: data, op: GatewayOpcodes.Resume });
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to Discord Gateway.
|
||||
* sets up the buckets aswell for every path
|
||||
* these buckets are dynamic memory however a good practice is to use 'WebSocket.send' directly
|
||||
* in simpler terms, do not use where we don't want buckets
|
||||
*/
|
||||
async send<T extends GatewaySendPayload = GatewaySendPayload>(priority: number, message: T) {
|
||||
// biome-ignore lint/style/noArguments: <explanation>
|
||||
// biome-ignore lint/correctness/noUndeclaredVariables: <explanation>
|
||||
this.logger.fatal(`[Shard #${this.id}]`, "Send", ...arguments);
|
||||
// Before acquiring a token from the bucket, check whether the shard is currently offline or not.
|
||||
// Else bucket and token wait time just get wasted.
|
||||
await this.checkOffline(priority);
|
||||
|
||||
// pause the function execution for the bucket to be acquired
|
||||
await this.bucket.acquire(priority);
|
||||
|
||||
// It's possible, that the shard went offline after a token has been acquired from the bucket.
|
||||
await this.checkOffline(priority);
|
||||
|
||||
// send the payload at last
|
||||
this.websocket?.send(JSON.stringify(message));
|
||||
}
|
||||
|
||||
protected handleMessage({ data }: WS.MessageEvent) {
|
||||
if (data instanceof Buffer) {
|
||||
data = inflateSync(data);
|
||||
}
|
||||
/**
|
||||
* Idk why, but Bun sends this event when websocket connects.
|
||||
* MessageEvent {
|
||||
* type: "message",
|
||||
* data: "Already authenticated."
|
||||
* }
|
||||
*/
|
||||
if ((data as string).startsWith("{")) data = JSON.parse(data as string);
|
||||
|
||||
const packet = data as unknown as GatewayReceivePayload;
|
||||
|
||||
// emit other events
|
||||
this.onpacket(packet);
|
||||
}
|
||||
|
||||
async onpacket(packet: GatewayReceivePayload | GatewayDispatchPayload) {
|
||||
if (packet.s !== null) {
|
||||
this.data.resumeSeq = packet.s;
|
||||
}
|
||||
|
||||
this.logger.debug(`[Shard #${this.id}]`, packet.t, packet.op);
|
||||
|
||||
this.heartbeater.onpacket(packet);
|
||||
|
||||
switch (packet.op) {
|
||||
case GatewayOpcodes.Hello:
|
||||
if (this.data.session_id) {
|
||||
await this.resume();
|
||||
} else {
|
||||
// await this.identify(true);
|
||||
}
|
||||
break;
|
||||
case GatewayOpcodes.Reconnect:
|
||||
this.disconnect();
|
||||
await this.connect();
|
||||
// await this.resume();
|
||||
break;
|
||||
case GatewayOpcodes.InvalidSession: {
|
||||
const resumable = packet.d as boolean;
|
||||
// We need to wait for a random amount of time between 1 and 5
|
||||
// Reference: https://discord.com/developers/docs/topics/gateway#resuming
|
||||
// el delay es el tipico timoeut promise, hazmelo pls
|
||||
//yo con un import { setTimeout as delay } from 'node:timers/promises'; en la mochila
|
||||
await delay(Math.floor((Math.random() * 4 + 1) * 1000));
|
||||
|
||||
if (!resumable) {
|
||||
this.data.resumeSeq = 0;
|
||||
this.data.session_id = undefined;
|
||||
await this.identify(true);
|
||||
break;
|
||||
}
|
||||
await this.resume();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
switch (packet.t) {
|
||||
case GatewayDispatchEvents.Resumed:
|
||||
this.state = ShardState.Connected;
|
||||
this.offlineSendQueue.toArray().map((resolve: () => any) => resolve());
|
||||
break;
|
||||
case GatewayDispatchEvents.Ready: {
|
||||
const payload = packet.d as GatewayReadyDispatchData;
|
||||
this.data.resume_gateway_url = payload.resume_gateway_url;
|
||||
this.data.session_id = payload.session_id;
|
||||
this.state = ShardState.Connected;
|
||||
this.offlineSendQueue.toArray().map((resolve: () => any) => resolve());
|
||||
this.options.handlePayload(this.id, packet);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
this.options.handlePayload(this.id, packet as GatewayDispatchPayload);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
close(code: number, reason: string) {
|
||||
if (this.websocket?.readyState !== WebSocket.OPEN) return;
|
||||
this.websocket?.close(code, reason);
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
// biome-ignore lint/style/noArguments: <explanation>
|
||||
// biome-ignore lint/correctness/noUndeclaredVariables: <explanation>
|
||||
this.logger.info(`[Shard #${this.id}]`, "Disconnect", ...arguments);
|
||||
this.close(ShardSocketCloseCodes.Shutdown, "Shard down request");
|
||||
this.state = ShardState.Offline;
|
||||
}
|
||||
|
||||
protected async handleClosed(close: CloseEvent) {
|
||||
this.heartbeater.stopHeartbeating();
|
||||
|
||||
switch (close.code) {
|
||||
case ShardSocketCloseCodes.Shutdown:
|
||||
case ShardSocketCloseCodes.ReIdentifying:
|
||||
case ShardSocketCloseCodes.Resharded:
|
||||
case ShardSocketCloseCodes.ResumeClosingOldConnection:
|
||||
case ShardSocketCloseCodes.ZombiedConnection:
|
||||
this.state = ShardState.Disconnected;
|
||||
return;
|
||||
|
||||
case GatewayCloseCodes.UnknownOpcode:
|
||||
case GatewayCloseCodes.NotAuthenticated:
|
||||
case GatewayCloseCodes.InvalidSeq:
|
||||
case GatewayCloseCodes.RateLimited:
|
||||
case GatewayCloseCodes.SessionTimedOut:
|
||||
this.logger.debug(`[Shard #${this.id}] Gateway connection closing requiring re-identify. Code: ${close.code}`);
|
||||
this.state = ShardState.Identifying;
|
||||
|
||||
return this.identify();
|
||||
case GatewayCloseCodes.AuthenticationFailed:
|
||||
case GatewayCloseCodes.InvalidShard:
|
||||
case GatewayCloseCodes.ShardingRequired:
|
||||
case GatewayCloseCodes.InvalidAPIVersion:
|
||||
case GatewayCloseCodes.InvalidIntents:
|
||||
case GatewayCloseCodes.DisallowedIntents:
|
||||
this.state = ShardState.Offline;
|
||||
|
||||
throw new Error(close.reason || "Discord gave no reason! GG! You broke Discord!");
|
||||
// Gateway connection closes on which a resume is allowed.
|
||||
default:
|
||||
console.log(close.code);
|
||||
this.logger.info(`[Shard #${this.id}] closed shard #${this.id}. Resuming...`);
|
||||
this.state = ShardState.Resuming;
|
||||
|
||||
this.disconnect();
|
||||
await this.connect();
|
||||
}
|
||||
}
|
||||
|
||||
/** Calculate the amount of requests which can safely be made per rate limit interval, before the gateway gets disconnected due to an exceeded rate limit. */
|
||||
calculateSafeRequests(): number {
|
||||
// * 2 adds extra safety layer for discords OP 1 requests that we need to respond to
|
||||
const safeRequests =
|
||||
this.options.ratelimitOptions!.maxRequestsPerRateLimitTick -
|
||||
Math.ceil(this.options.ratelimitOptions!.rateLimitResetInterval / this.heartbeater!.heart.interval) * 2;
|
||||
|
||||
if (safeRequests < 0) return 0;
|
||||
return safeRequests;
|
||||
}
|
||||
}
|
160
packages/ws/src/discord/sharder.ts
Normal file
160
packages/ws/src/discord/sharder.ts
Normal file
@ -0,0 +1,160 @@
|
||||
import {
|
||||
APIGatewayBotInfo,
|
||||
Collection,
|
||||
GatewayOpcodes,
|
||||
GatewayUpdatePresence,
|
||||
GatewayVoiceStateUpdate,
|
||||
LogLevels,
|
||||
Logger,
|
||||
ObjectToLower,
|
||||
Options,
|
||||
toSnakeCase,
|
||||
} from "@biscuitland/common";
|
||||
import { ShardManagerDefaults } from "../constants";
|
||||
import { SequentialBucket } from "../structures";
|
||||
import { Shard } from "./shard.js";
|
||||
import { ShardManagerOptions } from "./shared";
|
||||
|
||||
export class ShardManager extends Collection<number, Shard> {
|
||||
connectQueue: SequentialBucket;
|
||||
options: Required<ShardManagerOptions>;
|
||||
logger: Logger;
|
||||
|
||||
constructor(options: ShardManagerOptions) {
|
||||
super();
|
||||
this.options = Options<Required<ShardManagerOptions>>(ShardManagerDefaults, options, { info: { shards: options.totalShards } });
|
||||
|
||||
this.connectQueue = new SequentialBucket(this.concurrency);
|
||||
|
||||
this.logger = new Logger({
|
||||
active: this.options.debug,
|
||||
name: "[ShardManager]",
|
||||
logLevel: LogLevels.Debug,
|
||||
});
|
||||
}
|
||||
|
||||
get remaining(): number {
|
||||
return this.options.info.session_start_limit.remaining;
|
||||
}
|
||||
|
||||
get concurrency(): number {
|
||||
return this.options.info.session_start_limit.max_concurrency;
|
||||
}
|
||||
|
||||
calculeShardId(guildId: string) {
|
||||
return Number((BigInt(guildId) >> 22n) % BigInt(this.options.totalShards));
|
||||
}
|
||||
|
||||
spawn(shardId: number) {
|
||||
this.logger.info(`Spawn shard ${shardId}`);
|
||||
let shard = this.get(shardId);
|
||||
|
||||
shard ??= new Shard(shardId, {
|
||||
token: this.options.token,
|
||||
intents: this.options.intents,
|
||||
info: Options<APIGatewayBotInfo>(this.options.info, { shards: this.options.totalShards }),
|
||||
handlePayload: this.options.handlePayload,
|
||||
properties: this.options.properties,
|
||||
logger: this.logger,
|
||||
compress: false,
|
||||
});
|
||||
|
||||
this.set(shardId, shard);
|
||||
|
||||
return shard;
|
||||
}
|
||||
|
||||
async spawnShards(): Promise<void> {
|
||||
const buckets = this.spawnBuckets();
|
||||
|
||||
this.logger.info("Spawn shards");
|
||||
for (const bucket of buckets) {
|
||||
for (const shard of bucket) {
|
||||
if (!shard) break;
|
||||
this.logger.info(`${shard.id} add to connect queue`);
|
||||
await this.connectQueue.push(shard.identify.bind(shard, false));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* spawns buckets in order
|
||||
* https://discord.com/developers/docs/topics/gateway#sharding-max-concurrency
|
||||
*/
|
||||
spawnBuckets(): Shard[][] {
|
||||
this.logger.info("Preparing buckets");
|
||||
const chunks = SequentialBucket.chunk(new Array(this.options.totalShards), this.concurrency);
|
||||
|
||||
// biome-ignore lint/complexity/noForEach: i mean is the same thing, but we need the index;
|
||||
chunks.forEach((arr: any[], index: number) => {
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
const id = i + (index > 0 ? index * this.concurrency : 0);
|
||||
chunks[index][i] = this.spawn(id);
|
||||
}
|
||||
});
|
||||
this.logger.info(`${chunks.length} buckets created`);
|
||||
return chunks;
|
||||
}
|
||||
|
||||
forceIdentify(shardId: number) {
|
||||
this.logger.info(`Shard #${shardId} force identify`);
|
||||
return this.spawn(shardId).identify();
|
||||
}
|
||||
|
||||
disconnect(shardId: number) {
|
||||
this.logger.info(`Force disconnect shard ${shardId}`);
|
||||
return this.get(shardId)?.disconnect();
|
||||
}
|
||||
|
||||
disconnectAll() {
|
||||
this.logger.info("Disconnect all shards");
|
||||
// biome-ignore lint/complexity/noForEach: In maps, for each and for of have same performance
|
||||
return this.forEach((shard) => shard.disconnect());
|
||||
}
|
||||
|
||||
setShardPresence(shardId: number, payload: GatewayUpdatePresence["d"]) {
|
||||
this.logger.info(`Shard #${shardId} update presence`);
|
||||
return this.get(shardId)?.send<GatewayUpdatePresence>(1, {
|
||||
op: GatewayOpcodes.PresenceUpdate,
|
||||
d: payload,
|
||||
});
|
||||
}
|
||||
setPresence(payload: GatewayUpdatePresence["d"]): Promise<void> | undefined {
|
||||
return new Promise((resolve) => {
|
||||
// biome-ignore lint/complexity/noForEach: In maps, for each and for of have same performance
|
||||
this.forEach((shard) => {
|
||||
this.setShardPresence(shard.id, payload);
|
||||
}, this);
|
||||
resolve();
|
||||
});
|
||||
}
|
||||
|
||||
joinVoice(guild_id: string, channel_id: string, options: ObjectToLower<Pick<GatewayVoiceStateUpdate["d"], "self_deaf" | "self_mute">>) {
|
||||
const shardId = this.calculeShardId(guild_id);
|
||||
this.logger.info(`Shard #${shardId} join voice ${channel_id} in ${guild_id}`);
|
||||
|
||||
return this.get(shardId)?.send<GatewayVoiceStateUpdate>(1, {
|
||||
op: GatewayOpcodes.VoiceStateUpdate,
|
||||
d: {
|
||||
guild_id,
|
||||
channel_id,
|
||||
...toSnakeCase(options),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
leaveVoice(guild_id: string) {
|
||||
const shardId = this.calculeShardId(guild_id);
|
||||
this.logger.info(`Shard #${shardId} leave voice in ${guild_id}`);
|
||||
|
||||
return this.get(shardId)?.send<GatewayVoiceStateUpdate>(1, {
|
||||
op: GatewayOpcodes.VoiceStateUpdate,
|
||||
d: {
|
||||
guild_id,
|
||||
channel_id: null,
|
||||
self_mute: false,
|
||||
self_deaf: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
@ -1,5 +1,48 @@
|
||||
import type { APIGatewayBotInfo, GatewayDispatchPayload, GatewayIntentBits } from "@biscuitland/common";
|
||||
import type { IdentifyProperties, ShardState } from "../constants/index";
|
||||
import { APIGatewayBotInfo, GatewayDispatchPayload, GatewayIntentBits, Logger } from '@biscuitland/common';
|
||||
import { IdentifyProperties, ShardState } from '../constants';
|
||||
|
||||
export interface ShardManagerOptions extends ShardDetails {
|
||||
/** Important data which is used by the manager to connect shards to the gateway. */
|
||||
info: APIGatewayBotInfo;
|
||||
/**
|
||||
* Delay in milliseconds to wait before spawning next shard. OPTIMAL IS ABOVE 5100. YOU DON'T WANT TO HIT THE RATE LIMIT!!!
|
||||
* @default 5300
|
||||
*/
|
||||
spawnShardDelay?: number;
|
||||
/**
|
||||
* Total amount of shards your bot uses. Useful for zero-downtime updates or resharding.
|
||||
* @default 1
|
||||
*/
|
||||
totalShards?: number;
|
||||
/**
|
||||
* The payload handlers for messages on the shard.
|
||||
*/
|
||||
handlePayload(shardId: number, packet: GatewayDispatchPayload): unknown;
|
||||
/**
|
||||
* wheter to send debug information to the console
|
||||
*/
|
||||
debug?: boolean;
|
||||
}
|
||||
|
||||
export interface ShardData {
|
||||
/** state */
|
||||
shardState: ShardState;
|
||||
|
||||
/** resume seq to resume connections */
|
||||
resumeSeq: number | null;
|
||||
|
||||
/**
|
||||
* resume_gateway_url is the url to resume the connection
|
||||
* @link https://discord.com/developers/docs/topics/gateway#ready-event
|
||||
*/
|
||||
resume_gateway_url?: string;
|
||||
|
||||
/**
|
||||
* session_id is the unique session id of the gateway
|
||||
* do not mistake with the biscuit client which is named Session
|
||||
*/
|
||||
session_id?: string;
|
||||
}
|
||||
|
||||
export interface ShardDetails {
|
||||
/** Bot token which is used to connect to Discord */
|
||||
@ -24,6 +67,17 @@ export interface ShardDetails {
|
||||
properties?: IdentifyProperties;
|
||||
}
|
||||
|
||||
export interface ShardOptions extends ShardDetails {
|
||||
info: APIGatewayBotInfo;
|
||||
handlePayload(shardId: number, packet: GatewayDispatchPayload): unknown;
|
||||
ratelimitOptions?: {
|
||||
maxRequestsPerRateLimitTick: number;
|
||||
rateLimitResetInterval: number;
|
||||
};
|
||||
logger: Logger;
|
||||
compress: boolean;
|
||||
}
|
||||
|
||||
export enum ShardSocketCloseCodes {
|
||||
/** A regular Shard shutdown. */
|
||||
Shutdown = 3000,
|
||||
@ -38,61 +92,5 @@ export enum ShardSocketCloseCodes {
|
||||
/** Special close code reserved for Discordeno's zero-downtime resharding system. */
|
||||
Resharded = 3065,
|
||||
/** Shard is re-identifying therefore the old connection needs to be closed. */
|
||||
ReIdentifying = 3066,
|
||||
}
|
||||
|
||||
export interface ShardOptions extends ShardDetails {
|
||||
info: APIGatewayBotInfo;
|
||||
handlePayload(shardId: number, packet: GatewayDispatchPayload): unknown;
|
||||
debug?: boolean;
|
||||
ratelimitOptions?: {
|
||||
maxRequestsPerRateLimitTick: number;
|
||||
rateLimitResetInterval: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ShardData {
|
||||
/** the id of the current shard */
|
||||
id: number;
|
||||
|
||||
/** state */
|
||||
shardState: ShardState;
|
||||
|
||||
/** resume seq to resume connections */
|
||||
resumeSeq: number | null;
|
||||
|
||||
/**
|
||||
* resume_gateway_url is the url to resume the connection
|
||||
* @link https://discord.com/developers/docs/topics/gateway#ready-event
|
||||
*/
|
||||
resume_gateway_url?: string;
|
||||
|
||||
/**
|
||||
* session_id is the unique session id of the gateway
|
||||
* do not mistake with the biscuit client which is named Session
|
||||
*/
|
||||
session_id?: string;
|
||||
}
|
||||
|
||||
export interface GatewayManagerOptions extends ShardDetails {
|
||||
/** Important data which is used by the manager to connect shards to the gateway. */
|
||||
info: APIGatewayBotInfo;
|
||||
/**
|
||||
* Delay in milliseconds to wait before spawning next shard. OPTIMAL IS ABOVE 5100. YOU DON'T WANT TO HIT THE RATE LIMIT!!!
|
||||
* @default 5300
|
||||
*/
|
||||
spawnShardDelay?: number;
|
||||
/**
|
||||
* Total amount of shards your bot uses. Useful for zero-downtime updates or resharding.
|
||||
* @default 1
|
||||
*/
|
||||
totalShards?: number;
|
||||
/**
|
||||
* The payload handlers for messages on the shard.
|
||||
*/
|
||||
handlePayload(shardId: number, packet: GatewayDispatchPayload): unknown;
|
||||
/**
|
||||
* wheter to send debug information to the console
|
||||
*/
|
||||
debug?: boolean;
|
||||
ReIdentifying = 3066
|
||||
}
|
@ -1,3 +0,0 @@
|
||||
export * from "./shard";
|
||||
export * from "./sharder";
|
||||
export * from "./shared";
|
@ -1,477 +0,0 @@
|
||||
import type {
|
||||
GatewayDispatchPayload,
|
||||
GatewayHeartbeatRequest,
|
||||
GatewayHello,
|
||||
GatewayReceivePayload,
|
||||
GatewaySendPayload,
|
||||
When,
|
||||
} from "@biscuitland/common";
|
||||
|
||||
import {
|
||||
GATEWAY_BASE_URL,
|
||||
GatewayVersion as GATEWAY_VERSION,
|
||||
GatewayCloseCodes,
|
||||
GatewayDispatchEvents,
|
||||
GatewayOpcodes,
|
||||
Logger,
|
||||
} from "@biscuitland/common";
|
||||
import { inflateSync } from "node:zlib";
|
||||
import { WebSocket, type MessageEvent } from "ws";
|
||||
import { COMPRESS, ShardState, properties } from "../constants/index";
|
||||
import { DynamicBucket, PriorityQueue } from "../structures/index";
|
||||
import type { ShardData, ShardOptions } from "./shared";
|
||||
|
||||
export interface ShardHeart {
|
||||
/** Whether or not the heartbeat was acknowledged by Discord in time. */
|
||||
ack: boolean;
|
||||
/** Interval between heartbeats requested by Discord. */
|
||||
interval: number;
|
||||
/** Id of the interval, which is used for sending the heartbeats. */
|
||||
intervalId?: NodeJS.Timer;
|
||||
/** Unix (in milliseconds) timestamp when the last heartbeat ACK was received from Discord. */
|
||||
lastAck?: number;
|
||||
/** Unix timestamp (in milliseconds) when the last heartbeat was sent. */
|
||||
lastBeat?: number;
|
||||
/** Round trip time (in milliseconds) from Shard to Discord and back.
|
||||
* Calculated using the heartbeat system.
|
||||
* Note: this value is undefined until the first heartbeat to Discord has happened.
|
||||
*/
|
||||
rtt?: number;
|
||||
/** Id of the timeout which is used for sending the first heartbeat to Discord since it's "special". */
|
||||
timeoutId?: NodeJS.Timeout;
|
||||
/** internal value */
|
||||
toString(): string;
|
||||
}
|
||||
|
||||
export class ShardHeartbeater {
|
||||
shard: Shard<true>;
|
||||
heart: ShardHeart;
|
||||
|
||||
constructor(shard: Shard<true>) {
|
||||
this.shard = shard;
|
||||
this.heart = {
|
||||
ack: false,
|
||||
interval: 30_000,
|
||||
};
|
||||
}
|
||||
|
||||
acknowledge(ack = true) {
|
||||
this.heart.ack = ack;
|
||||
}
|
||||
|
||||
async handleHeartbeat(_packet: Extract<GatewayReceivePayload, GatewayHeartbeatRequest>) {
|
||||
this.shard.logger.debug("received hearbeat event");
|
||||
this.heartbeat(false);
|
||||
}
|
||||
|
||||
async handleHello(packet: GatewayHello) {
|
||||
if (packet.d.heartbeat_interval > 0) {
|
||||
if (this.heart.interval != null) {
|
||||
this.stopHeartbeating();
|
||||
}
|
||||
|
||||
this.heart.interval = packet.d.heartbeat_interval;
|
||||
this.heart.intervalId = setInterval(() => {
|
||||
this.acknowledge(false);
|
||||
this.heartbeat(false);
|
||||
}, this.heart.interval);
|
||||
}
|
||||
|
||||
const interval: number = packet.d.heartbeat_interval;
|
||||
return this.startHeartbeating(interval);
|
||||
}
|
||||
|
||||
async onpacket(packet: GatewayReceivePayload) {
|
||||
switch (packet.op) {
|
||||
case GatewayOpcodes.Hello: {
|
||||
return this.handleHello(packet);
|
||||
}
|
||||
case GatewayOpcodes.Heartbeat: {
|
||||
return this.handleHeartbeat(packet);
|
||||
}
|
||||
case GatewayOpcodes.HeartbeatAck: {
|
||||
this.acknowledge();
|
||||
return (this.heart.lastAck = Date.now());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* sends a heartbeat whenever its needed
|
||||
* fails if heart.interval is null
|
||||
*/
|
||||
heartbeat(acknowledgeAck: boolean) {
|
||||
if (acknowledgeAck) {
|
||||
if (!this.heart.lastAck) this.shard.disconnect({ reconnect: () => this.shard.resume() });
|
||||
this.heart.lastAck = undefined;
|
||||
}
|
||||
|
||||
this.heart.lastBeat = Date.now();
|
||||
|
||||
// avoid creating a bucket here
|
||||
this.shard.websocket.send(
|
||||
JSON.stringify({
|
||||
op: GatewayOpcodes.Heartbeat,
|
||||
d: this.shard.data.resumeSeq,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* wait the first interval upon receiving the hello event, then start heartbiting
|
||||
* interval * jitter
|
||||
* @param interval the packet interval
|
||||
*/
|
||||
async startHeartbeating(interval: number) {
|
||||
this.heart.interval = interval;
|
||||
this.shard.logger.debug("scheduling heartbeat!");
|
||||
|
||||
// The first heartbeat needs to be send with a random delay between `0` and `interval`
|
||||
// Using a `setTimeout(_, jitter)` here to accomplish that.
|
||||
// `Math.random()` can be `0` so we use `0.5` if this happens
|
||||
// Reference: https://discord.com/developers/docs/topics/gateway#heartbeating
|
||||
const jitter = Math.ceil(this.heart.interval * (Math.random() || 0.5));
|
||||
|
||||
return (this.heart.timeoutId = setTimeout(() => {
|
||||
// send a heartbeat
|
||||
this.heartbeat(false);
|
||||
this.heart.intervalId = setInterval(() => {
|
||||
this.acknowledge(false);
|
||||
this.heartbeat(false);
|
||||
}, this.heart.interval);
|
||||
}, jitter));
|
||||
}
|
||||
|
||||
stopHeartbeating() {
|
||||
clearTimeout(this.heart.intervalId);
|
||||
return clearTimeout(this.heart.timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
export class Shard<Connected extends boolean = true> {
|
||||
constructor(id: number, protected options: ShardOptions) {
|
||||
this.options.ratelimitOptions ??= {
|
||||
rateLimitResetInterval: 60_000,
|
||||
maxRequestsPerRateLimitTick: 120,
|
||||
};
|
||||
this.data = {
|
||||
id,
|
||||
resume_gateway_url: GATEWAY_BASE_URL,
|
||||
resumeSeq: null,
|
||||
} as never;
|
||||
this.websocket = null as never;
|
||||
this.ready = false;
|
||||
this.handlePayload = (packet) => {
|
||||
// do not ->
|
||||
// decorate this function
|
||||
if (packet.t === "READY") {
|
||||
this.logger.debug("received ready event");
|
||||
this.data.resume_gateway_url = `${packet.d.resume_gateway_url}?v=${GATEWAY_VERSION}&encoding=json`;
|
||||
this.logger.debug("switch resume url");
|
||||
this.data.session_id = packet.d.session_id;
|
||||
}
|
||||
|
||||
this.options.handlePayload(this.data.id ?? id, packet);
|
||||
};
|
||||
|
||||
// we create an empty heartbeater just to get the interval variable
|
||||
this.heartbeater = new ShardHeartbeater(this as Shard<true>) as never;
|
||||
|
||||
const safe = this.calculateSafeRequests();
|
||||
|
||||
this.bucket = new DynamicBucket({
|
||||
limit: safe,
|
||||
refillAmount: safe,
|
||||
refillInterval: 6e4,
|
||||
debug: this.options.debug,
|
||||
});
|
||||
this.logger = new Logger({
|
||||
name: "[Shard]",
|
||||
active: this.options.debug,
|
||||
logLevel: 0,
|
||||
});
|
||||
}
|
||||
|
||||
websocket: When<Connected, WebSocket, null>;
|
||||
heartbeater: When<Connected, ShardHeartbeater, null>;
|
||||
data: When<Connected, ShardData, Partial<ShardData>>;
|
||||
handlePayload: (packet: GatewayDispatchPayload) => unknown;
|
||||
offlineSendQueue: PriorityQueue<(_?: unknown) => void> = new PriorityQueue();
|
||||
bucket: DynamicBucket;
|
||||
logger: Logger;
|
||||
resolves: Map<"READY" | "RESUMED" | "INVALID_SESSION", (payload: GatewayReceivePayload) => void> = new Map();
|
||||
|
||||
/** wheter the shard was already identified */
|
||||
ready: boolean;
|
||||
|
||||
/**
|
||||
* a guard of wheter is connected or not
|
||||
*/
|
||||
isConnected(): this is Shard<true> {
|
||||
{
|
||||
return this.websocket?.readyState === WebSocket.OPEN;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* the state of the current shard
|
||||
*/
|
||||
get state() {
|
||||
return this.data.shardState ?? ShardState.Offline;
|
||||
}
|
||||
|
||||
set state(st: ShardState) {
|
||||
this.data.shardState = st;
|
||||
}
|
||||
|
||||
/**
|
||||
* pushes dead requests for the bot to resolve later on send payload
|
||||
* this is a remanecent of the old gateway but I (Yuzu) decided to let it untouched
|
||||
*/
|
||||
async checkOffline(priority: number) {
|
||||
if (!this.isConnected()) {
|
||||
return new Promise((resolve) => this.offlineSendQueue.push(resolve, priority));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to Discord Gateway.
|
||||
* sets up the buckets aswell for every path
|
||||
* these buckets are dynamic memory however a good practice is to use 'WebSocket.send' directly
|
||||
* in simpler terms, do not use where we don't want buckets
|
||||
*/
|
||||
async send(this: Shard<true>, priority: number, message: GatewaySendPayload) {
|
||||
// Before acquiring a token from the bucket, check whether the shard is currently offline or not.
|
||||
// Else bucket and token wait time just get wasted.
|
||||
await this.checkOffline(priority);
|
||||
|
||||
// pause the function execution for the bucket to be acquired
|
||||
await this.bucket.acquire(priority);
|
||||
|
||||
// It's possible, that the shard went offline after a token has been acquired from the bucket.
|
||||
await this.checkOffline(priority);
|
||||
|
||||
// send the payload at last
|
||||
this.websocket.send(JSON.stringify(message));
|
||||
}
|
||||
|
||||
/** starts the ws connection */
|
||||
async connect(): Promise<Shard<true>> {
|
||||
if (this.state === ShardState.Resuming) {
|
||||
this.state = ShardState.Connecting;
|
||||
}
|
||||
if (this.state === ShardState.Identifying) {
|
||||
this.state = ShardState.Connecting;
|
||||
}
|
||||
|
||||
// set client
|
||||
const websocket = new WebSocket(this.data.session_id ? this.gatewayURL : this.options.info.url);
|
||||
this.websocket = websocket as When<Connected, WebSocket>;
|
||||
|
||||
this.websocket.onmessage = async (event) => {
|
||||
return await (this as Shard<true>).handleMessage(event);
|
||||
};
|
||||
|
||||
this.websocket.onerror = (event) => {
|
||||
return this.logger.error({ error: event, shardId: this.data.id });
|
||||
};
|
||||
|
||||
this.websocket.onclose = async (event) => {
|
||||
return await (this as Shard<true>).handleClose(event.code, event.reason);
|
||||
};
|
||||
|
||||
return new Promise<Shard<true>>((resolve, _reject) => {
|
||||
this.websocket!.onopen = () => {
|
||||
if (this.state === ShardState.Resuming) {
|
||||
this.state = ShardState.Unidentified;
|
||||
}
|
||||
if (this.state === ShardState.Identifying) {
|
||||
this.state = ShardState.Unidentified;
|
||||
}
|
||||
resolve(this as Shard<true>);
|
||||
};
|
||||
|
||||
// set hearbeater
|
||||
const heartbeater = new ShardHeartbeater(this as Shard<true>);
|
||||
this.heartbeater = heartbeater as When<Connected, ShardHeartbeater, null>;
|
||||
});
|
||||
}
|
||||
|
||||
/** Handle an incoming gateway message. */
|
||||
async handleMessage(this: Shard<true>, event: MessageEvent) {
|
||||
let preProcessMessage = event.data;
|
||||
|
||||
if (preProcessMessage instanceof Buffer) {
|
||||
preProcessMessage = inflateSync(preProcessMessage);
|
||||
}
|
||||
|
||||
if (typeof preProcessMessage === "string") {
|
||||
const packet = JSON.parse(preProcessMessage);
|
||||
|
||||
// emit heartbeat events
|
||||
this.heartbeater.onpacket(packet);
|
||||
|
||||
// try to identify
|
||||
if (packet.op === GatewayOpcodes.Hello) {
|
||||
this.identify();
|
||||
}
|
||||
|
||||
// emit other events
|
||||
this.onpacket(packet);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
async handleClose(this: Shard<true>, code: number, reason: string) {
|
||||
this.heartbeater.stopHeartbeating();
|
||||
|
||||
switch (code) {
|
||||
case 1000: {
|
||||
this.logger.info("Uknown error code trying to resume");
|
||||
return this.resume();
|
||||
}
|
||||
case GatewayCloseCodes.UnknownOpcode:
|
||||
case GatewayCloseCodes.NotAuthenticated:
|
||||
case GatewayCloseCodes.InvalidSeq:
|
||||
case GatewayCloseCodes.RateLimited:
|
||||
case GatewayCloseCodes.SessionTimedOut:
|
||||
this.logger.info(`Gateway connection closing requiring re-identify. Code: ${code}`);
|
||||
this.state = ShardState.Identifying;
|
||||
return this.identify();
|
||||
case GatewayCloseCodes.AuthenticationFailed:
|
||||
case GatewayCloseCodes.InvalidShard:
|
||||
case GatewayCloseCodes.ShardingRequired:
|
||||
case GatewayCloseCodes.InvalidAPIVersion:
|
||||
case GatewayCloseCodes.InvalidIntents:
|
||||
case GatewayCloseCodes.DisallowedIntents:
|
||||
this.state = ShardState.Offline;
|
||||
// disconnected event
|
||||
throw new Error(reason || "Discord gave no reason! GG! You broke Discord!");
|
||||
default:
|
||||
return this.disconnect({ reconnect: () => this.resume() });
|
||||
}
|
||||
}
|
||||
|
||||
async resume(this: Shard<true>) {
|
||||
this.state = ShardState.Resuming;
|
||||
|
||||
if (!this.ready) return;
|
||||
|
||||
return await this.send(0, {
|
||||
op: GatewayOpcodes.Resume,
|
||||
d: {
|
||||
token: this.options.token,
|
||||
session_id: this.data.session_id!,
|
||||
seq: this.data.resumeSeq!,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
resetState() {
|
||||
this.ready = false;
|
||||
}
|
||||
|
||||
disconnect(options?: { reconnect?: () => unknown }) {
|
||||
this.logger.error("closing connection");
|
||||
|
||||
if (!this.websocket) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!options?.reconnect) {
|
||||
this.websocket.close(1012, "BiscuitWS: close");
|
||||
} else {
|
||||
this.logger.error("trying to reconnect");
|
||||
options.reconnect();
|
||||
}
|
||||
|
||||
this.websocket.terminate();
|
||||
|
||||
this.resetState();
|
||||
}
|
||||
|
||||
async onpacket(this: Shard<true>, packet: GatewayDispatchPayload | GatewayReceivePayload) {
|
||||
if (packet.s) this.data.resumeSeq = packet.s;
|
||||
|
||||
switch (packet.op) {
|
||||
case GatewayOpcodes.InvalidSession:
|
||||
this.logger.debug("got invalid session, trying to identify back");
|
||||
this.data.resumeSeq = null;
|
||||
this.data.session_id = undefined;
|
||||
this.data.resume_gateway_url = undefined;
|
||||
this.resetState();
|
||||
await this.identify();
|
||||
break;
|
||||
case GatewayOpcodes.Reconnect:
|
||||
this.disconnect({
|
||||
reconnect: () => {
|
||||
this.resume();
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
switch (packet.t) {
|
||||
case GatewayDispatchEvents.Resumed:
|
||||
this.heartbeater.heartbeat(false);
|
||||
break;
|
||||
default:
|
||||
this.handlePayload(packet as any);
|
||||
}
|
||||
}
|
||||
|
||||
/** do the handshake with discord */
|
||||
async identify() {
|
||||
if (!this.isConnected()) {
|
||||
await this.connect().then((shardThis: Shard<true>) => {
|
||||
this.identify.call(shardThis);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.ready) {
|
||||
this.logger.debug(`identifying shard ${this.data.id} with a total of ${this.options.info.shards}`);
|
||||
this.data.shardState = ShardState.Identifying;
|
||||
await this.send(0, {
|
||||
op: GatewayOpcodes.Identify,
|
||||
d: {
|
||||
token: this.options.token,
|
||||
intents: this.options.intents,
|
||||
properties: properties,
|
||||
shard: [this.data.id, this.options.info.shards],
|
||||
compress: COMPRESS,
|
||||
},
|
||||
}).then(() => {
|
||||
this.logger.debug("finished identifying");
|
||||
});
|
||||
// ^if we don't get this message start preocupating
|
||||
|
||||
this.ready = true;
|
||||
}
|
||||
// ^we make sure we can no longer identify unless invalid session
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
return this.shardIsReady?.().then(() => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
get gatewayURL() {
|
||||
return this.data.resume_gateway_url ?? this.options.info.url;
|
||||
}
|
||||
|
||||
/** Calculate the amount of requests which can safely be made per rate limit interval, before the gateway gets disconnected due to an exceeded rate limit. */
|
||||
calculateSafeRequests(): number {
|
||||
// * 2 adds extra safety layer for discords OP 1 requests that we need to respond to
|
||||
const safeRequests =
|
||||
this.options.ratelimitOptions!.maxRequestsPerRateLimitTick -
|
||||
Math.ceil(this.options.ratelimitOptions!.rateLimitResetInterval / this.heartbeater!.heart.interval) * 2;
|
||||
|
||||
if (safeRequests < 0) return 0;
|
||||
else return safeRequests;
|
||||
}
|
||||
|
||||
shardIsReady?: () => Promise<void>;
|
||||
}
|
@ -1,86 +0,0 @@
|
||||
import type { APIGatewayBotInfo } from "@biscuitland/common";
|
||||
import { Collection, Logger } from "@biscuitland/common";
|
||||
import { GatewayManagerDefaults, Options } from "../constants/index";
|
||||
import { SequentialBucket } from "../structures/index";
|
||||
import { Shard } from "./shard";
|
||||
import type { GatewayManagerOptions } from "./shared";
|
||||
|
||||
export class GatewayManager extends Collection<number, Shard> {
|
||||
connectQueue: SequentialBucket;
|
||||
options: Required<GatewayManagerOptions>;
|
||||
logger: Logger;
|
||||
|
||||
constructor(_options: GatewayManagerOptions) {
|
||||
super();
|
||||
this.options = Options<Required<GatewayManagerOptions>>(GatewayManagerDefaults, { info: { shards: _options.totalShards } }, _options);
|
||||
this.connectQueue = new SequentialBucket(this.concurrency);
|
||||
this.logger = new Logger({
|
||||
name: "[GatewayManager]",
|
||||
active: this.options.debug,
|
||||
logLevel: 0,
|
||||
});
|
||||
}
|
||||
|
||||
get concurrency(): number {
|
||||
return this.options.info.session_start_limit.max_concurrency;
|
||||
}
|
||||
|
||||
get remaining(): number {
|
||||
return this.options.info.session_start_limit.remaining;
|
||||
}
|
||||
|
||||
/*
|
||||
* spawns buckets in order
|
||||
* https://discord.com/developers/docs/topics/gateway#sharding-max-concurrency
|
||||
*/
|
||||
spawnBuckets(): Shard[][] {
|
||||
this.logger.info("Preparing buckets");
|
||||
const chunks = SequentialBucket.chunk(new Array(this.options.totalShards), this.concurrency);
|
||||
|
||||
chunks.forEach((arr: any[], index: number) => {
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
const id = i + (index > 0 ? index * this.concurrency : 0);
|
||||
chunks[index][i] = this.spawn(id);
|
||||
}
|
||||
});
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
async spawnShards(): Promise<void> {
|
||||
const buckets = this.spawnBuckets();
|
||||
|
||||
this.logger.info("Spawn shards");
|
||||
for (const bucket of buckets) {
|
||||
for (const shard of bucket) {
|
||||
if (!shard) break;
|
||||
await this.connectQueue.push(shard.identify.bind(shard));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
spawn(shardId: number) {
|
||||
let shard = this.get(shardId);
|
||||
|
||||
shard ??= new Shard<true>(shardId, {
|
||||
token: this.options.token,
|
||||
intents: this.options.intents,
|
||||
info: Options<APIGatewayBotInfo>(this.options.info, { shards: this.options.totalShards }),
|
||||
handlePayload: this.options.handlePayload,
|
||||
properties: this.options.properties,
|
||||
debug: this.options.debug,
|
||||
});
|
||||
|
||||
this.set(shardId, shard);
|
||||
|
||||
return shard;
|
||||
}
|
||||
|
||||
async forceIdentify(shardId: number) {
|
||||
await this.spawn(shardId).identify();
|
||||
}
|
||||
|
||||
explode() {
|
||||
return this.forEach(($) => $.disconnect());
|
||||
}
|
||||
}
|
@ -1,3 +1,3 @@
|
||||
export * from "./SharedTypes";
|
||||
export * from "./constants";
|
||||
export * from "./gateway";
|
||||
export * from './SharedTypes';
|
||||
export * from './constants';
|
||||
export * from './discord';
|
||||
|
@ -1,138 +0,0 @@
|
||||
import { Logger, delay } from "@biscuitland/common";
|
||||
import { PriorityQueue } from "./lists";
|
||||
|
||||
/**
|
||||
* just any kind of request to queue and resolve later
|
||||
*/
|
||||
export type QueuedRequest = (value: void | Promise<void>) => Promise<unknown> | any;
|
||||
|
||||
/**
|
||||
* options of the dynamic bucket
|
||||
*/
|
||||
export interface DynamicBucketOptions {
|
||||
limit: number;
|
||||
refillInterval: number;
|
||||
refillAmount: number;
|
||||
debug?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* generally useless for interaction based bots
|
||||
* ideally this would only be triggered on certain paths
|
||||
* example: a huge amount of messages being spammed
|
||||
*
|
||||
* a dynamic bucket is just a priority queue implemented using linked lists
|
||||
* we create an empty bucket for every path
|
||||
* dynamically allocating memory improves the final memory footprint
|
||||
*/
|
||||
export class DynamicBucket {
|
||||
limit: number;
|
||||
refillInterval: number;
|
||||
refillAmount: number;
|
||||
|
||||
/** The queue of requests to acquire an available request. Mapped by <shardId, resolve()> */
|
||||
queue: PriorityQueue<QueuedRequest> = new PriorityQueue();
|
||||
|
||||
/** The amount of requests that have been used up already. */
|
||||
used = 0;
|
||||
|
||||
/** Whether or not the queue is already processing. */
|
||||
processing = false;
|
||||
|
||||
/** The timeout id for the timer to reduce the used amount by the refill amount. */
|
||||
timeoutId?: NodeJS.Timeout;
|
||||
|
||||
/** The timestamp in milliseconds when the next refill is scheduled. */
|
||||
refillsAt?: number;
|
||||
|
||||
logger: Logger;
|
||||
|
||||
constructor(options: DynamicBucketOptions) {
|
||||
this.limit = options.limit;
|
||||
this.refillInterval = options.refillInterval;
|
||||
this.refillAmount = options.refillAmount;
|
||||
this.logger = new Logger({
|
||||
name: "[Gateway]",
|
||||
active: options.debug,
|
||||
logLevel: 0,
|
||||
});
|
||||
}
|
||||
|
||||
get remaining(): number {
|
||||
if (this.limit < this.used) return 0;
|
||||
else return this.limit - this.used;
|
||||
}
|
||||
|
||||
refill(): void {
|
||||
// Lower the used amount by the refill amount
|
||||
this.used = this.refillAmount > this.used ? 0 : this.used - this.refillAmount;
|
||||
|
||||
// Reset the refillsAt timestamp since it just got refilled
|
||||
this.refillsAt = undefined;
|
||||
|
||||
if (this.used > 0) {
|
||||
if (this.timeoutId) {
|
||||
clearTimeout(this.timeoutId);
|
||||
}
|
||||
this.timeoutId = setTimeout(() => {
|
||||
this.refill();
|
||||
}, this.refillInterval);
|
||||
this.refillsAt = Date.now() + this.refillInterval;
|
||||
}
|
||||
}
|
||||
|
||||
/** Begin processing the queue. */
|
||||
async processQueue(): Promise<void> {
|
||||
// There is already a queue that is processing
|
||||
if (this.processing) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Begin going through the queue.
|
||||
while (!this.queue.isEmpty()) {
|
||||
if (this.remaining) {
|
||||
this.logger.debug(`Processing queue. Remaining: ${this.remaining} Length: ${this.queue.size()}`);
|
||||
// Resolves the promise allowing the paused execution of this request to resolve and continue.
|
||||
this.queue.peek()();
|
||||
this.queue.pop();
|
||||
|
||||
// A request can be made
|
||||
this.used++;
|
||||
|
||||
// Create a new timeout for this request if none exists.
|
||||
if (!this.timeoutId) {
|
||||
this.timeoutId = setTimeout(() => {
|
||||
this.refill();
|
||||
}, this.refillInterval);
|
||||
|
||||
// Set the time for when this refill will occur.
|
||||
this.refillsAt = Date.now() + this.refillInterval;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if a refill is scheduled, since we have used up all available requests
|
||||
else if (this.refillsAt) {
|
||||
const now = Date.now();
|
||||
// If there is time left until next refill, just delay execution.
|
||||
if (this.refillsAt > now) {
|
||||
await delay(this.refillsAt - now);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Loop has ended mark false so it can restart later when needed
|
||||
this.processing = false;
|
||||
}
|
||||
|
||||
/** Pauses the execution until the request is available to be made. */
|
||||
async acquire(priority: number): Promise<void> {
|
||||
return await new Promise((resolve) => {
|
||||
this.queue.push(resolve, priority);
|
||||
void this.processQueue();
|
||||
});
|
||||
}
|
||||
|
||||
toString() {
|
||||
return [...this.queue].toString();
|
||||
}
|
||||
}
|
@ -1,3 +1,337 @@
|
||||
export * from "./dynamic_bucket";
|
||||
export * from "./lists";
|
||||
export * from "./sequential_bucket";
|
||||
import { Logger, delay } from '@biscuitland/common';
|
||||
|
||||
/**
|
||||
* just any kind of request to queue and resolve later
|
||||
*/
|
||||
export type QueuedRequest = (value: void | Promise<void>) => Promise<unknown> | any;
|
||||
|
||||
/**
|
||||
* options of the dynamic bucket
|
||||
*/
|
||||
export interface DynamicBucketOptions {
|
||||
limit: number;
|
||||
refillInterval: number;
|
||||
refillAmount: number;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* generally useless for interaction based bots
|
||||
* ideally this would only be triggered on certain paths
|
||||
* example: a huge amount of messages being spammed
|
||||
*
|
||||
* a dynamic bucket is just a priority queue implemented using linked lists
|
||||
* we create an empty bucket for every path
|
||||
* dynamically allocating memory improves the final memory footprint
|
||||
*/
|
||||
export class DynamicBucket {
|
||||
limit: number;
|
||||
refillInterval: number;
|
||||
refillAmount: number;
|
||||
|
||||
/** The queue of requests to acquire an available request. Mapped by <shardId, resolve()> */
|
||||
queue = new PriorityQueue<QueuedRequest>();
|
||||
|
||||
/** The amount of requests that have been used up already. */
|
||||
used = 0;
|
||||
|
||||
/** Whether or not the queue is already processing. */
|
||||
processing = false;
|
||||
|
||||
/** The timeout id for the timer to reduce the used amount by the refill amount. */
|
||||
timeoutId?: NodeJS.Timeout;
|
||||
|
||||
/** The timestamp in milliseconds when the next refill is scheduled. */
|
||||
refillsAt?: number;
|
||||
|
||||
logger: Logger;
|
||||
|
||||
constructor(options: DynamicBucketOptions) {
|
||||
this.limit = options.limit;
|
||||
this.refillInterval = options.refillInterval;
|
||||
this.refillAmount = options.refillAmount;
|
||||
this.logger = options.logger;
|
||||
}
|
||||
|
||||
get remaining(): number {
|
||||
if (this.limit < this.used) return 0;
|
||||
return this.limit - this.used;
|
||||
}
|
||||
|
||||
refill(): void {
|
||||
// Lower the used amount by the refill amount
|
||||
this.used = this.refillAmount > this.used ? 0 : this.used - this.refillAmount;
|
||||
|
||||
// Reset the refillsAt timestamp since it just got refilled
|
||||
this.refillsAt = undefined;
|
||||
|
||||
if (this.used > 0) {
|
||||
if (this.timeoutId) {
|
||||
clearTimeout(this.timeoutId);
|
||||
}
|
||||
this.timeoutId = setTimeout(() => {
|
||||
this.refill();
|
||||
}, this.refillInterval);
|
||||
this.refillsAt = Date.now() + this.refillInterval;
|
||||
}
|
||||
}
|
||||
|
||||
/** Begin processing the queue. */
|
||||
async processQueue(): Promise<void> {
|
||||
// There is already a queue that is processing
|
||||
if (this.processing) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Begin going through the queue.
|
||||
while (!this.queue.isEmpty()) {
|
||||
if (this.remaining) {
|
||||
this.logger.debug(`Processing queue. Remaining: ${this.remaining} Length: ${this.queue.size()}`);
|
||||
// Resolves the promise allowing the paused execution of this request to resolve and continue.
|
||||
this.queue.peek()();
|
||||
this.queue.pop();
|
||||
|
||||
// A request can be made
|
||||
this.used++;
|
||||
|
||||
// Create a new timeout for this request if none exists.
|
||||
if (!this.timeoutId) {
|
||||
this.timeoutId = setTimeout(() => {
|
||||
this.refill();
|
||||
}, this.refillInterval);
|
||||
|
||||
// Set the time for when this refill will occur.
|
||||
this.refillsAt = Date.now() + this.refillInterval;
|
||||
}
|
||||
// Check if a refill is scheduled, since we have used up all available requests
|
||||
} else if (this.refillsAt) {
|
||||
const now = Date.now();
|
||||
// If there is time left until next refill, just delay execution.
|
||||
if (this.refillsAt > now) {
|
||||
await delay(this.refillsAt - now);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Loop has ended mark false so it can restart later when needed
|
||||
this.processing = false;
|
||||
}
|
||||
|
||||
/** Pauses the execution until the request is available to be made. */
|
||||
async acquire(priority: number): Promise<void> {
|
||||
return await new Promise((resolve) => {
|
||||
this.queue.push(resolve, priority);
|
||||
// biome-ignore lint/complexity/noVoid: <explanation>
|
||||
void this.processQueue();
|
||||
});
|
||||
}
|
||||
|
||||
toString() {
|
||||
return [...this.queue].toString();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* abstract node lol
|
||||
*/
|
||||
export interface AbstractNode<T> {
|
||||
data: T;
|
||||
next: this | null;
|
||||
}
|
||||
|
||||
export interface QueuePusher<T> {
|
||||
push(data: T): NonNullable<TNode<T>>;
|
||||
}
|
||||
|
||||
export interface QueuePusherWithPriority<T> {
|
||||
push(data: T, priority: number): NonNullable<PNode<T>>;
|
||||
}
|
||||
|
||||
export class TNode<T> implements AbstractNode<T> {
|
||||
data: T;
|
||||
next: this | null;
|
||||
|
||||
constructor(data: T) {
|
||||
this.data = data;
|
||||
this.next = null;
|
||||
}
|
||||
|
||||
static null<T>(list: AbstractNode<T> | null): list is null {
|
||||
return !list;
|
||||
}
|
||||
}
|
||||
|
||||
export class PNode<T> extends TNode<T> {
|
||||
priority: number;
|
||||
|
||||
constructor(data: T, priority: number) {
|
||||
super(data);
|
||||
this.priority = priority;
|
||||
}
|
||||
}
|
||||
|
||||
export abstract class Queue<T> {
|
||||
protected abstract head: AbstractNode<T> | null;
|
||||
|
||||
/**
|
||||
* O(1)
|
||||
*/
|
||||
public pop() {
|
||||
if (TNode.null(this.head)) {
|
||||
throw new Error('cannot pop a list without elements');
|
||||
}
|
||||
|
||||
return (this.head = this.head.next);
|
||||
}
|
||||
|
||||
/**
|
||||
* O(1)
|
||||
*/
|
||||
public peek(): T {
|
||||
if (TNode.null(this.head)) {
|
||||
throw new Error('cannot peek an empty list');
|
||||
}
|
||||
|
||||
return this.head.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* O(n)
|
||||
*/
|
||||
public size(): number {
|
||||
let aux = this.head;
|
||||
|
||||
if (TNode.null(aux)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let count = 1;
|
||||
|
||||
while (aux.next !== null) {
|
||||
count++;
|
||||
aux = aux.next;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* O(1)
|
||||
*/
|
||||
public isEmpty() {
|
||||
return TNode.null(this.head);
|
||||
}
|
||||
|
||||
*[Symbol.iterator](): IterableIterator<T> {
|
||||
let temp = this.head;
|
||||
while (temp !== null) {
|
||||
yield temp.data;
|
||||
temp = temp.next;
|
||||
}
|
||||
}
|
||||
|
||||
public toArray(): T[] {
|
||||
return Array.from(this);
|
||||
}
|
||||
public toString() {
|
||||
return this.head?.toString() || '';
|
||||
}
|
||||
}
|
||||
|
||||
export class LinkedList<T> extends Queue<T> implements QueuePusher<T> {
|
||||
protected head: TNode<T> | null = null;
|
||||
|
||||
/**
|
||||
* O(1)
|
||||
*/
|
||||
public push(data: T): NonNullable<TNode<T>> {
|
||||
const temp = new TNode<T>(data);
|
||||
temp.next = this.head;
|
||||
this.head = temp;
|
||||
|
||||
return this.head;
|
||||
}
|
||||
}
|
||||
|
||||
export class PriorityQueue<T> extends Queue<T> implements QueuePusherWithPriority<T> {
|
||||
protected head: PNode<T> | null = null;
|
||||
|
||||
/**
|
||||
* O(#priorities)
|
||||
*/
|
||||
public push(data: T, priority: number): NonNullable<PNode<T>> {
|
||||
let start = this.head;
|
||||
const temp = new PNode(data, priority);
|
||||
|
||||
if (TNode.null(this.head) || TNode.null(start)) {
|
||||
this.head = temp;
|
||||
return this.head;
|
||||
}
|
||||
|
||||
if (this.head.priority > priority) {
|
||||
temp.next = this.head;
|
||||
this.head = temp;
|
||||
return this.head;
|
||||
}
|
||||
|
||||
while (start.next !== null && start.next.priority < priority) {
|
||||
start = start.next;
|
||||
}
|
||||
|
||||
temp.next = start.next as PNode<T>;
|
||||
start.next = temp;
|
||||
|
||||
return this.head;
|
||||
}
|
||||
}
|
||||
|
||||
export class SequentialBucket {
|
||||
private connections: LinkedList<QueuedRequest>;
|
||||
private capacity: number; // max_concurrency
|
||||
private spawnTimeout: number;
|
||||
|
||||
constructor(maxCapacity: number) {
|
||||
this.connections = new LinkedList();
|
||||
this.capacity = maxCapacity;
|
||||
this.spawnTimeout = 5000;
|
||||
}
|
||||
|
||||
public async destroy() {
|
||||
this.connections = new LinkedList();
|
||||
}
|
||||
|
||||
public async push(promise: QueuedRequest) {
|
||||
this.connections.push(promise);
|
||||
|
||||
if (this.capacity <= this.connections.size()) {
|
||||
await this.acquire();
|
||||
await delay(this.spawnTimeout);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
public async acquire(promises = this.connections) {
|
||||
while (!promises.isEmpty()) {
|
||||
const item = promises.peek();
|
||||
item().catch((...args: any[]) => {
|
||||
Promise.reject(...args);
|
||||
});
|
||||
promises.pop();
|
||||
}
|
||||
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
public static chunk<T>(array: T[], chunks: number): T[][] {
|
||||
let index = 0;
|
||||
let resIndex = 0;
|
||||
const result = Array(Math.ceil(array.length / chunks));
|
||||
|
||||
while (index < array.length) {
|
||||
result[resIndex] = array.slice(index, (index += chunks));
|
||||
resIndex++;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
@ -1,153 +0,0 @@
|
||||
/**
|
||||
* abstract node lol
|
||||
*/
|
||||
export interface AbstractNode<T> {
|
||||
data: T;
|
||||
next: this | null;
|
||||
}
|
||||
|
||||
export interface QueuePusher<T> {
|
||||
push(data: T): NonNullable<TNode<T>>;
|
||||
}
|
||||
|
||||
export interface QueuePusherWithPriority<T> {
|
||||
push(data: T, priority: number): NonNullable<PNode<T>>;
|
||||
}
|
||||
|
||||
export class TNode<T> implements AbstractNode<T> {
|
||||
data: T;
|
||||
next: this | null;
|
||||
|
||||
constructor(data: T) {
|
||||
this.data = data;
|
||||
this.next = null;
|
||||
}
|
||||
|
||||
static null<T>(list: AbstractNode<T> | null): list is null {
|
||||
return !list;
|
||||
}
|
||||
}
|
||||
|
||||
export class PNode<T> extends TNode<T> {
|
||||
priority: number;
|
||||
|
||||
constructor(data: T, priority: number) {
|
||||
super(data);
|
||||
this.priority = priority;
|
||||
}
|
||||
}
|
||||
|
||||
export abstract class Queue<T> {
|
||||
protected abstract head: AbstractNode<T> | null;
|
||||
|
||||
/**
|
||||
* O(1)
|
||||
*/
|
||||
public pop() {
|
||||
if (TNode.null(this.head)) {
|
||||
throw new Error("cannot pop a list without elements");
|
||||
}
|
||||
|
||||
return (this.head = this.head.next);
|
||||
}
|
||||
|
||||
/**
|
||||
* O(1)
|
||||
*/
|
||||
public peek(): T {
|
||||
if (TNode.null(this.head)) {
|
||||
throw new Error("cannot peek an empty list");
|
||||
}
|
||||
|
||||
return this.head.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* O(n)
|
||||
*/
|
||||
public size(): number {
|
||||
let aux = this.head;
|
||||
|
||||
if (TNode.null(aux)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let count = 1;
|
||||
|
||||
while (aux.next !== null) {
|
||||
count++;
|
||||
aux = aux.next;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* O(1)
|
||||
*/
|
||||
public isEmpty() {
|
||||
return TNode.null(this.head);
|
||||
}
|
||||
|
||||
*[Symbol.iterator](): IterableIterator<T> {
|
||||
let temp = this.head;
|
||||
while (temp !== null) {
|
||||
yield temp.data;
|
||||
temp = temp.next;
|
||||
}
|
||||
}
|
||||
|
||||
public toArray(): T[] {
|
||||
return Array.from(this);
|
||||
}
|
||||
public toString() {
|
||||
return this.head?.toString() || "";
|
||||
}
|
||||
}
|
||||
|
||||
export class LinkedList<T> extends Queue<T> implements QueuePusher<T> {
|
||||
protected head: TNode<T> | null = null;
|
||||
|
||||
/**
|
||||
* O(1)
|
||||
*/
|
||||
public push(data: T): NonNullable<TNode<T>> {
|
||||
const temp = new TNode<T>(data);
|
||||
temp.next = this.head;
|
||||
this.head = temp;
|
||||
|
||||
return this.head;
|
||||
}
|
||||
}
|
||||
|
||||
export class PriorityQueue<T> extends Queue<T> implements QueuePusherWithPriority<T> {
|
||||
protected head: PNode<T> | null = null;
|
||||
|
||||
/**
|
||||
* O(#priorities)
|
||||
*/
|
||||
public push(data: T, priority: number): NonNullable<PNode<T>> {
|
||||
let start = this.head;
|
||||
const temp = new PNode(data, priority);
|
||||
|
||||
if (TNode.null(this.head) || TNode.null(start)) {
|
||||
this.head = temp;
|
||||
return this.head;
|
||||
}
|
||||
|
||||
if (this.head.priority > priority) {
|
||||
temp.next = this.head;
|
||||
this.head = temp;
|
||||
return this.head;
|
||||
}
|
||||
|
||||
while (start.next !== null && start.next.priority < priority) {
|
||||
start = start.next;
|
||||
}
|
||||
|
||||
temp.next = start.next as PNode<T>;
|
||||
start.next = temp;
|
||||
|
||||
return this.head;
|
||||
}
|
||||
}
|
@ -1,54 +0,0 @@
|
||||
import { delay } from "@biscuitland/common";
|
||||
import type { QueuedRequest } from "./dynamic_bucket";
|
||||
import { LinkedList } from "./lists";
|
||||
|
||||
export class SequentialBucket {
|
||||
private connections: LinkedList<QueuedRequest>;
|
||||
private capacity: number; // max_concurrency
|
||||
private spawnTimeout: number;
|
||||
|
||||
constructor(maxCapacity: number) {
|
||||
this.connections = new LinkedList();
|
||||
this.capacity = maxCapacity;
|
||||
this.spawnTimeout = 5000;
|
||||
}
|
||||
|
||||
public async destroy() {
|
||||
this.connections = new LinkedList();
|
||||
}
|
||||
|
||||
public async push(promise: QueuedRequest) {
|
||||
this.connections.push(promise);
|
||||
|
||||
if (this.capacity <= this.connections.size()) {
|
||||
await this.acquire();
|
||||
await delay(this.spawnTimeout);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
public async acquire(promises = this.connections) {
|
||||
while (!promises.isEmpty()) {
|
||||
const item = promises.peek();
|
||||
item().catch((...args: any[]) => {
|
||||
Promise.reject(...args);
|
||||
});
|
||||
promises.pop();
|
||||
}
|
||||
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
public static chunk<T>(array: T[], chunks: number): T[][] {
|
||||
let index = 0;
|
||||
let resIndex = 0;
|
||||
const result = Array(Math.ceil(array.length / chunks));
|
||||
|
||||
while (index < array.length) {
|
||||
result[resIndex] = array.slice(index, (index += chunks));
|
||||
resIndex++;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user