lilybird
Version:
A bun-first discord api wrapper written in TS
215 lines (214 loc) • 6.86 kB
JavaScript
import { setTimeout } from "node:timers/promises";
export class WebSocketManager {
#debug;
#options;
#dispatch;
#sequenceNumber = null;
#isResuming = false;
#ws;
#gatewayInfo;
#timer;
#gotACK = true;
resumeInfo = {};
constructor(options, dispatch, debug) {
if (typeof options.intents !== "number" && Number.isNaN(options.intents))
throw new Error("Invalid intents");
this.#dispatch = dispatch;
this.#debug = debug;
this.#options = options;
}
init(token, dispatch) {
this.#options.token = token;
this.#dispatch = dispatch;
}
close() {
this.#ws.close(3000);
}
async connect(url) {
if (typeof this.#dispatch === "undefined")
throw new Error("You need to pass the dispatch function via 'init' before connecting.");
if (typeof this.#gatewayInfo === "undefined") {
const response = await fetch("https://discord.com/api/v10/gateway/bot", {
headers: {
Authorization: `Bot ${this.#options.token}`
}
});
if (!response.ok)
throw new Error("An invalid Token was provided");
const data = await response.json();
data.url = `${data.url}/?v=10&encoding=json`;
this.#gatewayInfo = data;
}
this.#ws = new WebSocket(url ?? this.#gatewayInfo.url);
this.#ws.addEventListener("error", (err) => {
this.#debug?.(8, err);
});
this.#ws.addEventListener("close", async ({ code }) => {
this.#debug?.(12, code);
this.#clearTimer();
if (code === 3000)
return;
if (code === 4010)
throw new Error("Invalid Shard");
if (code === 4011)
throw new Error("Sharding Required");
if (code === 4012)
throw new Error("Invalid API Version");
if (code === 4013)
throw new Error("Invalid intent(s)");
if (code === 4014)
throw new Error("Disallowed intent(s)");
if (typeof code === "undefined" || code === 1001 || closeCodeAllowsReconnection(code)) {
await this.#attemptResume();
return;
}
this.#isResuming = false;
await this.connect();
});
this.#ws.addEventListener("message", (event) => {
this.#debug?.(0, event.data);
const payload = JSON.parse(event.data.toString());
if (typeof payload.s === "number")
this.#sequenceNumber = payload.s;
switch (payload.op) {
case 0: {
this.#dispatch(payload);
break;
}
case 10: {
const interval = this.#getInterval(payload.d.heartbeat_interval);
this.#startTimer(interval);
if (!this.#isResuming)
this.#identify();
else
this.#resume();
break;
}
case 1: {
this.#debug?.(3);
this.#sendHeartbeatPayload();
break;
}
case 7: {
this.#debug?.(7);
this.#ws.close(1001);
break;
}
case 9: {
this.#debug?.(6);
if (payload.d)
this.#ws.close(1001);
else
this.#ws.close(1000);
break;
}
case 11: {
this.#gotACK = true;
this.#debug?.(2);
break;
}
default:
break;
}
return;
});
}
#getInterval(interval) {
let res = 0;
let i = 0;
do {
res = Math.round((interval * Math.random()) + i);
i++;
} while (res < interval / 2);
return res;
}
#sendHeartbeatPayload() {
this.#gotACK = false;
this.#ws.send(`{ "op": 1, "d": ${this.#sequenceNumber}, "s": null, "t": null }`);
}
#identify() {
if (typeof this.#options.token === "undefined")
throw new Error("No token was found");
const payload = {
op: 2,
d: {
token: this.#options.token,
intents: this.#options.intents,
properties: {
os: process.platform,
browser: "Lilybird",
device: "Lilybird"
},
presence: this.#options.presence
},
s: null,
t: null
};
this.#debug?.(4);
this.#ws.send(JSON.stringify(payload));
}
#resume() {
const payload = {
op: 6,
d: {
token: this.#options.token,
session_id: this.resumeInfo.id,
seq: this.#sequenceNumber ?? 0
},
s: null,
t: null
};
this.#debug?.(5);
this.#ws.send(JSON.stringify(payload));
}
#startTimer(interval) {
this.#gotACK = true;
this.#timer = setInterval(async () => {
if (!this.#gotACK) {
this.#debug?.(9);
await setTimeout(500);
if (!this.#gotACK) {
this.#debug?.(10);
this.#ws.close(1001);
return;
}
}
this.#debug?.(1);
this.#sendHeartbeatPayload();
}, interval);
}
#clearTimer() {
if (typeof this.#timer === "undefined")
return;
clearInterval(this.#timer);
}
async #attemptResume() {
this.#debug?.(11);
this.#isResuming = true;
await this.connect(`${this.resumeInfo.url}/?v=10&encoding=json`);
}
async ping() {
return new Promise((res) => {
this.#ws.addEventListener("pong", () => {
res(performance.now() - start);
}, { once: true });
const start = performance.now();
this.#ws.ping();
});
}
updatePresence(presence) {
const options = {
op: 3,
d: presence,
s: null,
t: null
};
this.#ws.send(JSON.stringify(options));
}
get options() {
return this.#options;
}
}
function closeCodeAllowsReconnection(code) {
return code >= 4000 && code !== 4004 && code < 4010;
}