summary refs log tree commit diff
path: root/src/gateway
diff options
context:
space:
mode:
authorMadeline <46743919+MaddyUnderStars@users.noreply.github.com>2022-09-25 18:24:21 +1000
committerMadeline <46743919+MaddyUnderStars@users.noreply.github.com>2022-09-25 23:35:18 +1000
commitf44f5d7ac2d24ff836c2e1d4b2fa58da04b13052 (patch)
treea6655c41bb3db79c30fd876b06ee60fe9cf70c9b /src/gateway
parentAllow edited_timestamp to passthrough in handleMessage (diff)
downloadserver-f44f5d7ac2d24ff836c2e1d4b2fa58da04b13052.tar.xz
Refactor to mono-repo + upgrade packages
Diffstat (limited to 'src/gateway')
-rw-r--r--src/gateway/Server.ts63
-rw-r--r--src/gateway/events/Close.ts47
-rw-r--r--src/gateway/events/Connection.ts84
-rw-r--r--src/gateway/events/Message.ts74
-rw-r--r--src/gateway/index.ts4
-rw-r--r--src/gateway/listener/listener.ts252
-rw-r--r--src/gateway/opcodes/Heartbeat.ts11
-rw-r--r--src/gateway/opcodes/Identify.ts312
-rw-r--r--src/gateway/opcodes/LazyRequest.ts205
-rw-r--r--src/gateway/opcodes/PresenceUpdate.ts25
-rw-r--r--src/gateway/opcodes/RequestGuildMembers.ts5
-rw-r--r--src/gateway/opcodes/Resume.ts12
-rw-r--r--src/gateway/opcodes/VoiceStateUpdate.ts113
-rw-r--r--src/gateway/opcodes/experiments.json76
-rw-r--r--src/gateway/opcodes/index.ts25
-rw-r--r--src/gateway/opcodes/instanceOf.ts18
-rw-r--r--src/gateway/schema/Activity.ts60
-rw-r--r--src/gateway/schema/Identify.ts106
-rw-r--r--src/gateway/schema/LazyRequest.ts19
-rw-r--r--src/gateway/schema/VoiceStateUpdateSchema.ts17
-rw-r--r--src/gateway/start.ts15
-rw-r--r--src/gateway/util/Constants.ts52
-rw-r--r--src/gateway/util/Heartbeat.ts11
-rw-r--r--src/gateway/util/Send.ts32
-rw-r--r--src/gateway/util/SessionUtils.ts13
-rw-r--r--src/gateway/util/WebSocket.ts26
-rw-r--r--src/gateway/util/index.ts5
27 files changed, 1682 insertions, 0 deletions
diff --git a/src/gateway/Server.ts b/src/gateway/Server.ts
new file mode 100644
index 00000000..7e1489be
--- /dev/null
+++ b/src/gateway/Server.ts
@@ -0,0 +1,63 @@
+import "missing-native-js-functions";
+import dotenv from "dotenv";
+dotenv.config();
+import { closeDatabase, Config, initDatabase, initEvent } from "@fosscord/util";
+import ws from "ws";
+import { Connection } from "./events/Connection";
+import http from "http";
+
+export class Server {
+	public ws: ws.Server;
+	public port: number;
+	public server: http.Server;
+	public production: boolean;
+
+	constructor({
+		port,
+		server,
+		production,
+	}: {
+		port: number;
+		server?: http.Server;
+		production?: boolean;
+	}) {
+		this.port = port;
+		this.production = production || false;
+
+		if (server) this.server = server;
+		else {
+			this.server = http.createServer(function (req, res) {
+				res.writeHead(200).end("Online");
+			});
+		}
+
+		this.server.on("upgrade", (request, socket, head) => {
+			// @ts-ignore
+			this.ws.handleUpgrade(request, socket, head, (socket) => {
+				this.ws.emit("connection", socket, request);
+			});
+		});
+
+		this.ws = new ws.Server({
+			maxPayload: 4096,
+			noServer: true,
+		});
+		this.ws.on("connection", Connection);
+		this.ws.on("error", console.error);
+	}
+
+	async start(): Promise<void> {
+		await initDatabase();
+		await Config.init();
+		await initEvent();
+		if (!this.server.listening) {
+			this.server.listen(this.port);
+			console.log(`[Gateway] online on 0.0.0.0:${this.port}`);
+		}
+	}
+
+	async stop() {
+		closeDatabase();
+		this.server.close();
+	}
+}
diff --git a/src/gateway/events/Close.ts b/src/gateway/events/Close.ts
new file mode 100644
index 00000000..40d9a6f7
--- /dev/null
+++ b/src/gateway/events/Close.ts
@@ -0,0 +1,47 @@
+import { WebSocket } from "@fosscord/gateway";
+import {
+	emitEvent,
+	PresenceUpdateEvent,
+	PrivateSessionProjection,
+	Session,
+	SessionsReplace,
+	User,
+} from "@fosscord/util";
+
+export async function Close(this: WebSocket, code: number, reason: string) {
+	console.log("[WebSocket] closed", code, reason);
+	if (this.heartbeatTimeout) clearTimeout(this.heartbeatTimeout);
+	if (this.readyTimeout) clearTimeout(this.readyTimeout);
+	this.deflate?.close();
+	this.inflate?.close();
+	this.removeAllListeners();
+
+	if (this.session_id) {
+		await Session.delete({ session_id: this.session_id });
+		const sessions = await Session.find({
+			where: { user_id: this.user_id },
+			select: PrivateSessionProjection,
+		});
+		await emitEvent({
+			event: "SESSIONS_REPLACE",
+			user_id: this.user_id,
+			data: sessions,
+		} as SessionsReplace);
+		const session = sessions.first() || {
+			activities: [],
+			client_info: {},
+			status: "offline",
+		};
+
+		await emitEvent({
+			event: "PRESENCE_UPDATE",
+			user_id: this.user_id,
+			data: {
+				user: await User.getPublicUser(this.user_id),
+				activities: session.activities,
+				client_status: session?.client_info,
+				status: session.status,
+			},
+		} as PresenceUpdateEvent);
+	}
+}
diff --git a/src/gateway/events/Connection.ts b/src/gateway/events/Connection.ts
new file mode 100644
index 00000000..bed3cf44
--- /dev/null
+++ b/src/gateway/events/Connection.ts
@@ -0,0 +1,84 @@
+import WS from "ws";
+import { WebSocket } from "@fosscord/gateway";
+import { Send } from "../util/Send";
+import { CLOSECODES, OPCODES } from "../util/Constants";
+import { setHeartbeat } from "../util/Heartbeat";
+import { IncomingMessage } from "http";
+import { Close } from "./Close";
+import { Message } from "./Message";
+import { Deflate, Inflate } from "fast-zlib";
+import { URL } from "url";
+import { Config } from "@fosscord/util";
+var erlpack: any;
+try {
+	erlpack = require("@yukikaze-bot/erlpack");
+} catch (error) {}
+
+// TODO: check rate limit
+// TODO: specify rate limit in config
+// TODO: check msg max size
+
+export async function Connection(
+	this: WS.Server,
+	socket: WebSocket,
+	request: IncomingMessage
+) {
+	const forwardedFor = Config.get().security.forwadedFor;
+	const ipAddress = forwardedFor ? request.headers[forwardedFor] as string : request.socket.remoteAddress;
+
+	socket.ipAddress = ipAddress;
+
+	try {
+		// @ts-ignore
+		socket.on("close", Close);
+		// @ts-ignore
+		socket.on("message", Message);
+		console.log(`[Gateway] New connection from ${socket.ipAddress}, total ${this.clients.size}`);
+
+		const { searchParams } = new URL(`http://localhost${request.url}`);
+		// @ts-ignore
+		socket.encoding = searchParams.get("encoding") || "json";
+		if (!["json", "etf"].includes(socket.encoding)) {
+			if (socket.encoding === "etf" && erlpack) {
+				throw new Error(
+					"Erlpack is not installed: 'npm i @yukikaze-bot/erlpack'"
+				);
+			}
+			return socket.close(CLOSECODES.Decode_error);
+		}
+
+		socket.version = Number(searchParams.get("version")) || 8;
+		if (socket.version != 8)
+			return socket.close(CLOSECODES.Invalid_API_version);
+
+		// @ts-ignore
+		socket.compress = searchParams.get("compress") || "";
+		if (socket.compress) {
+			if (socket.compress !== "zlib-stream")
+				return socket.close(CLOSECODES.Decode_error);
+			socket.deflate = new Deflate();
+			socket.inflate = new Inflate();
+		}
+
+		socket.events = {};
+		socket.member_events = {};
+		socket.permissions = {};
+		socket.sequence = 0;
+
+		setHeartbeat(socket);
+
+		await Send(socket, {
+			op: OPCODES.Hello,
+			d: {
+				heartbeat_interval: 1000 * 30,
+			},
+		});
+
+		socket.readyTimeout = setTimeout(() => {
+			return socket.close(CLOSECODES.Session_timed_out);
+		}, 1000 * 30);
+	} catch (error) {
+		console.error(error);
+		return socket.close(CLOSECODES.Unknown_error);
+	}
+}
diff --git a/src/gateway/events/Message.ts b/src/gateway/events/Message.ts
new file mode 100644
index 00000000..db7dbad2
--- /dev/null
+++ b/src/gateway/events/Message.ts
@@ -0,0 +1,74 @@
+import { CLOSECODES, OPCODES } from "../util/Constants";
+import { WebSocket, Payload } from "@fosscord/gateway";
+var erlpack: any;
+try {
+	erlpack = require("@yukikaze-bot/erlpack");
+} catch (error) { }
+import OPCodeHandlers from "../opcodes";
+import { Tuple } from "lambert-server";
+import { check } from "../opcodes/instanceOf";
+import WS from "ws";
+import BigIntJson from "json-bigint";
+import * as Sentry from "@sentry/node";
+const bigIntJson = BigIntJson({ storeAsString: true });
+
+const PayloadSchema = {
+	op: Number,
+	$d: new Tuple(Object, Number), // or number for heartbeat sequence
+	$s: Number,
+	$t: String,
+};
+
+export async function Message(this: WebSocket, buffer: WS.Data) {
+	// TODO: compression
+	var data: Payload;
+
+	if (this.encoding === "etf" && buffer instanceof Buffer)
+		data = erlpack.unpack(buffer);
+	else if (this.encoding === "json" && buffer instanceof Buffer) {
+		if (this.inflate) {
+			try {
+				buffer = this.inflate.process(buffer) as any;
+			} catch {
+				buffer = buffer.toString() as any;
+			}
+		}
+		data = bigIntJson.parse(buffer as string);
+	}
+	else if (typeof buffer == "string") {
+		data = bigIntJson.parse(buffer as string)
+	}
+	else return;
+
+	check.call(this, PayloadSchema, data);
+
+	// @ts-ignore
+	const OPCodeHandler = OPCodeHandlers[data.op];
+	if (!OPCodeHandler) {
+		console.error("[Gateway] Unkown opcode " + data.op);
+		// TODO: if all opcodes are implemented comment this out:
+		// this.close(CLOSECODES.Unknown_opcode);
+		return;
+	}
+
+	// const transaction = Sentry.startTransaction({
+	// 	op: OPCODES[data.op],
+	// 	name: `GATEWAY ${OPCODES[data.op]}`,
+	// 	data: {
+	// 		...data.d,
+	// 		token: data?.d?.token ? "[Redacted]" : undefined,
+	// 	},
+	// });
+
+	try {
+		var ret = await OPCodeHandler.call(this, data);
+		// transaction.finish();
+		return ret;
+	} catch (error) {
+		Sentry.captureException(error);
+		// transaction.finish();
+		console.error(`Error: Op ${data.op}`, error);
+		// if (!this.CLOSED && this.CLOSING)
+		return this.close(CLOSECODES.Unknown_error);
+	}
+}
diff --git a/src/gateway/index.ts b/src/gateway/index.ts
new file mode 100644
index 00000000..d77ce931
--- /dev/null
+++ b/src/gateway/index.ts
@@ -0,0 +1,4 @@
+export * from "./Server";
+export * from "./util/";
+export * from "./opcodes/";
+export * from "./listener/listener";
diff --git a/src/gateway/listener/listener.ts b/src/gateway/listener/listener.ts
new file mode 100644
index 00000000..72dd9d5b
--- /dev/null
+++ b/src/gateway/listener/listener.ts
@@ -0,0 +1,252 @@
+import {
+	getPermission,
+	Permissions,
+	RabbitMQ,
+	listenEvent,
+	EventOpts,
+	ListenEventOpts,
+	Member,
+	EVENTEnum,
+	Relationship,
+	RelationshipType,
+} from "@fosscord/util";
+import { OPCODES } from "../util/Constants";
+import { Send } from "../util/Send";
+import { WebSocket } from "@fosscord/gateway";
+import "missing-native-js-functions";
+import { Channel as AMQChannel } from "amqplib";
+import { Recipient } from "@fosscord/util";
+
+// TODO: close connection on Invalidated Token
+// TODO: check intent
+// TODO: Guild Member Update is sent for current-user updates regardless of whether the GUILD_MEMBERS intent is set.
+
+// Sharding: calculate if the current shard id matches the formula: shard_id = (guild_id >> 22) % num_shards
+// https://discord.com/developers/docs/topics/gateway#sharding
+
+export function handlePresenceUpdate(
+	this: WebSocket,
+	{ event, acknowledge, data }: EventOpts
+) {
+	acknowledge?.();
+	if (event === EVENTEnum.PresenceUpdate) {
+		return Send(this, {
+			op: OPCODES.Dispatch,
+			t: event,
+			d: data,
+			s: this.sequence++,
+		});
+	}
+}
+
+// TODO: use already queried guilds/channels of Identify and don't fetch them again
+export async function setupListener(this: WebSocket) {
+	const [members, recipients, relationships] = await Promise.all([
+		Member.find({
+			where: { id: this.user_id },
+			relations: ["guild", "guild.channels"],
+		}),
+		Recipient.find({
+			where: { user_id: this.user_id, closed: false },
+			relations: ["channel"],
+		}),
+		Relationship.find({
+			where: {
+				from_id: this.user_id,
+				type: RelationshipType.friends,
+			}
+		}),
+	]);
+
+	const guilds = members.map((x) => x.guild);
+	const dm_channels = recipients.map((x) => x.channel);
+
+	const opts: { acknowledge: boolean; channel?: AMQChannel; } = {
+		acknowledge: true,
+	};
+	this.listen_options = opts;
+	const consumer = consume.bind(this);
+
+	if (RabbitMQ.connection) {
+		opts.channel = await RabbitMQ.connection.createChannel();
+		// @ts-ignore
+		opts.channel.queues = {};
+	}
+
+	this.events[this.user_id] = await listenEvent(this.user_id, consumer, opts);
+
+	relationships.forEach(async (relationship) => {
+		this.events[relationship.to_id] = await listenEvent(
+			relationship.to_id,
+			handlePresenceUpdate.bind(this),
+			opts
+		);
+	});
+
+	dm_channels.forEach(async (channel) => {
+		this.events[channel.id] = await listenEvent(channel.id, consumer, opts);
+	});
+
+	guilds.forEach(async (guild) => {
+		const permission = await getPermission(this.user_id, guild.id);
+		this.permissions[guild.id] = permission;
+		this.events[guild.id] = await listenEvent(guild.id, consumer, opts);
+
+		guild.channels.forEach(async (channel) => {
+			if (
+				permission
+					.overwriteChannel(channel.permission_overwrites!)
+					.has("VIEW_CHANNEL")
+			) {
+				this.events[channel.id] = await listenEvent(
+					channel.id,
+					consumer,
+					opts
+				);
+			}
+		});
+	});
+
+	this.once("close", () => {
+		if (opts.channel) opts.channel.close();
+		else {
+			Object.values(this.events).forEach((x) => x());
+			Object.values(this.member_events).forEach((x) => x());
+		}
+	});
+}
+
+// TODO: only subscribe for events that are in the connection intents
+async function consume(this: WebSocket, opts: EventOpts) {
+	const { data, event } = opts;
+	let id = data.id as string;
+	const permission = this.permissions[id] || new Permissions("ADMINISTRATOR"); // default permission for dm
+
+	const consumer = consume.bind(this);
+	const listenOpts = opts as ListenEventOpts;
+	opts.acknowledge?.();
+	// console.log("event", event);
+
+	// subscription managment
+	switch (event) {
+		case "GUILD_MEMBER_REMOVE":
+			this.member_events[data.user.id]?.();
+			delete this.member_events[data.user.id];
+		case "GUILD_MEMBER_ADD":
+			if (this.member_events[data.user.id]) break; // already subscribed
+			this.member_events[data.user.id] = await listenEvent(
+				data.user.id,
+				handlePresenceUpdate.bind(this),
+				this.listen_options
+			);
+			break;
+		case "GUILD_MEMBER_REMOVE":
+			if (!this.member_events[data.user.id]) break;
+			this.member_events[data.user.id]();
+			break;
+		case "RELATIONSHIP_REMOVE":
+		case "CHANNEL_DELETE":
+		case "GUILD_DELETE":
+			delete this.events[id];
+			opts.cancel();
+			break;
+		case "CHANNEL_CREATE":
+			if (
+				!permission
+					.overwriteChannel(data.permission_overwrites)
+					.has("VIEW_CHANNEL")
+			) {
+				return;
+			}
+			this.events[id] = await listenEvent(id, consumer, listenOpts);
+			break;
+		case "RELATIONSHIP_ADD":
+			this.events[data.user.id] = await listenEvent(
+				data.user.id,
+				handlePresenceUpdate.bind(this),
+				this.listen_options
+			);
+			break;
+		case "GUILD_CREATE":
+			this.events[id] = await listenEvent(id, consumer, listenOpts);
+			break;
+		case "CHANNEL_UPDATE":
+			const exists = this.events[id];
+			// @ts-ignore
+			if (
+				permission
+					.overwriteChannel(data.permission_overwrites)
+					.has("VIEW_CHANNEL")
+			) {
+				if (exists) break;
+				this.events[id] = await listenEvent(id, consumer, listenOpts);
+			} else {
+				if (!exists) return; // return -> do not send channel update events for hidden channels
+				opts.cancel(id);
+				delete this.events[id];
+			}
+			break;
+	}
+
+	// permission checking
+	switch (event) {
+		case "INVITE_CREATE":
+		case "INVITE_DELETE":
+		case "GUILD_INTEGRATIONS_UPDATE":
+			if (!permission.has("MANAGE_GUILD")) return;
+			break;
+		case "WEBHOOKS_UPDATE":
+			if (!permission.has("MANAGE_WEBHOOKS")) return;
+			break;
+		case "GUILD_MEMBER_ADD":
+		case "GUILD_MEMBER_REMOVE":
+		case "GUILD_MEMBER_UPDATE":
+		// only send them, if the user subscribed for this part of the member list, or is a bot
+		case "PRESENCE_UPDATE": // exception if user is friend
+			break;
+		case "GUILD_BAN_ADD":
+		case "GUILD_BAN_REMOVE":
+			if (!permission.has("BAN_MEMBERS")) return;
+			break;
+		case "VOICE_STATE_UPDATE":
+		case "MESSAGE_CREATE":
+		case "MESSAGE_DELETE":
+		case "MESSAGE_DELETE_BULK":
+		case "MESSAGE_UPDATE":
+		case "CHANNEL_PINS_UPDATE":
+		case "MESSAGE_REACTION_ADD":
+		case "MESSAGE_REACTION_REMOVE":
+		case "MESSAGE_REACTION_REMOVE_ALL":
+		case "MESSAGE_REACTION_REMOVE_EMOJI":
+		case "TYPING_START":
+			// only gets send if the user is alowed to view the current channel
+			if (!permission.has("VIEW_CHANNEL")) return;
+			break;
+		case "GUILD_CREATE":
+		case "GUILD_DELETE":
+		case "GUILD_UPDATE":
+		case "GUILD_ROLE_CREATE":
+		case "GUILD_ROLE_UPDATE":
+		case "GUILD_ROLE_DELETE":
+		case "CHANNEL_CREATE":
+		case "CHANNEL_DELETE":
+		case "CHANNEL_UPDATE":
+		case "GUILD_EMOJIS_UPDATE":
+		case "READY": // will be sent by the gateway
+		case "USER_UPDATE":
+		case "APPLICATION_COMMAND_CREATE":
+		case "APPLICATION_COMMAND_DELETE":
+		case "APPLICATION_COMMAND_UPDATE":
+		default:
+			// always gets sent
+			// Any events not defined in an intent are considered "passthrough" and will always be sent
+			break;
+	}
+
+	await Send(this, {
+		op: OPCODES.Dispatch,
+		t: event,
+		d: data,
+		s: this.sequence++,
+	});
+}
diff --git a/src/gateway/opcodes/Heartbeat.ts b/src/gateway/opcodes/Heartbeat.ts
new file mode 100644
index 00000000..50394130
--- /dev/null
+++ b/src/gateway/opcodes/Heartbeat.ts
@@ -0,0 +1,11 @@
+import { Payload, WebSocket } from "@fosscord/gateway";
+import { setHeartbeat } from "../util/Heartbeat";
+import { Send } from "../util/Send";
+
+export async function onHeartbeat(this: WebSocket, data: Payload) {
+	// TODO: validate payload
+
+	setHeartbeat(this);
+
+	await Send(this, { op: 11 });
+}
diff --git a/src/gateway/opcodes/Identify.ts b/src/gateway/opcodes/Identify.ts
new file mode 100644
index 00000000..3c40962c
--- /dev/null
+++ b/src/gateway/opcodes/Identify.ts
@@ -0,0 +1,312 @@
+import { WebSocket, Payload } from "@fosscord/gateway";
+import {
+	checkToken,
+	Intents,
+	Member,
+	ReadyEventData,
+	User,
+	Session,
+	EVENTEnum,
+	Config,
+	PublicMember,
+	PublicUser,
+	PrivateUserProjection,
+	ReadState,
+	Application,
+	emitEvent,
+	SessionsReplace,
+	PrivateSessionProjection,
+	MemberPrivateProjection,
+	PresenceUpdateEvent,
+	DefaultUserGuildSettings,
+	UserGuildSettings,
+} from "@fosscord/util";
+import { Send } from "../util/Send";
+import { CLOSECODES, OPCODES } from "../util/Constants";
+import { genSessionId } from "../util/SessionUtils";
+import { setupListener } from "../listener/listener";
+import { IdentifySchema } from "../schema/Identify";
+// import experiments from "./experiments.json";
+const experiments: any = [];
+import { check } from "./instanceOf";
+import { Recipient } from "@fosscord/util";
+
+// TODO: user sharding
+// TODO: check privileged intents, if defined in the config
+// TODO: check if already identified
+
+export async function onIdentify(this: WebSocket, data: Payload) {
+	clearTimeout(this.readyTimeout);
+	if (typeof data.d?.client_state?.highest_last_message_id === "number")
+		data.d.client_state.highest_last_message_id += "";
+	check.call(this, IdentifySchema, data.d);
+
+	const identify: IdentifySchema = data.d;
+
+	try {
+		const { jwtSecret } = Config.get().security;
+		var { decoded } = await checkToken(identify.token, jwtSecret); // will throw an error if invalid
+	} catch (error) {
+		console.error("invalid token", error);
+		return this.close(CLOSECODES.Authentication_failed);
+	}
+	this.user_id = decoded.id;
+
+	const session_id = genSessionId();
+	this.session_id = session_id; //Set the session of the WebSocket object
+
+	const [user, read_states, members, recipients, session, application] =
+		await Promise.all([
+			User.findOneOrFail({
+				where: { id: this.user_id },
+				relations: ["relationships", "relationships.to"],
+				select: [...PrivateUserProjection, "relationships"],
+			}),
+			ReadState.find({ where: { user_id: this.user_id } }),
+			Member.find({
+				where: { id: this.user_id },
+				select: MemberPrivateProjection,
+				relations: [
+					"guild",
+					"guild.channels",
+					"guild.emojis",
+					"guild.emojis.user",
+					"guild.roles",
+					"guild.stickers",
+					"user",
+					"roles",
+				],
+			}),
+			Recipient.find({
+				where: { user_id: this.user_id, closed: false },
+				relations: [
+					"channel",
+					"channel.recipients",
+					"channel.recipients.user",
+				],
+				// TODO: public user selection
+			}),
+			// save the session and delete it when the websocket is closed
+			Session.create({
+				user_id: this.user_id,
+				session_id: session_id,
+				// TODO: check if status is only one of: online, dnd, offline, idle
+				status: identify.presence?.status || "offline", //does the session always start as online?
+				client_info: {
+					//TODO read from identity
+					client: "desktop",
+					os: identify.properties?.os,
+					version: 0,
+				},
+				activities: [],
+			}).save(),
+			Application.findOne({ where: { id: this.user_id } }),
+		]);
+
+	if (!user) return this.close(CLOSECODES.Authentication_failed);
+
+	if (!identify.intents) identify.intents = BigInt("0x6ffffffff");
+	this.intents = new Intents(identify.intents);
+	if (identify.shard) {
+		this.shard_id = identify.shard[0];
+		this.shard_count = identify.shard[1];
+		if (
+			this.shard_count == null ||
+			this.shard_id == null ||
+			this.shard_id >= this.shard_count ||
+			this.shard_id < 0 ||
+			this.shard_count <= 0
+		) {
+			console.log(identify.shard);
+			return this.close(CLOSECODES.Invalid_shard);
+		}
+	}
+	var users: PublicUser[] = [];
+
+	const merged_members = members.map((x: Member) => {
+		return [
+			{
+				...x,
+				roles: x.roles.map((x) => x.id),
+				settings: undefined,
+				guild: undefined,
+			},
+		];
+	}) as PublicMember[][];
+	let guilds = members.map((x) => ({ ...x.guild, joined_at: x.joined_at }));
+
+	// @ts-ignore
+	guilds = guilds.map((guild) => {
+		if (user.bot) {
+			setTimeout(() => {
+				var promise = Send(this, {
+					op: OPCODES.Dispatch,
+					t: EVENTEnum.GuildCreate,
+					s: this.sequence++,
+					d: guild,
+				});
+				if (promise) promise.catch(console.error);
+			}, 500);
+			return { id: guild.id, unavailable: true };
+		}
+
+		return guild;
+	});
+
+	const user_guild_settings_entries = members.map((x) => ({
+		...DefaultUserGuildSettings,
+		...x.settings,
+		guild_id: x.guild.id,
+		// disgusting
+		channel_overrides: Object.entries(x.settings.channel_overrides ?? {}).map(y => ({
+			...y[1],
+			channel_id: y[0],
+		}))
+	})) as any as UserGuildSettings[];	// VERY disgusting. don't care.
+
+	const channels = recipients.map((x) => {
+		// @ts-ignore
+		x.channel.recipients = x.channel.recipients?.map((x) => x.user);
+		//TODO is this needed? check if users in group dm that are not friends are sent in the READY event
+		users = users.concat(x.channel.recipients as unknown as User[]);
+		if (x.channel.isDm()) {
+			x.channel.recipients = x.channel.recipients!.filter(
+				(x) => x.id !== this.user_id
+			);
+		}
+		return x.channel;
+	});
+
+	for (let relation of user.relationships) {
+		const related_user = relation.to;
+		const public_related_user = {
+			username: related_user.username,
+			discriminator: related_user.discriminator,
+			id: related_user.id,
+			public_flags: related_user.public_flags,
+			avatar: related_user.avatar,
+			bot: related_user.bot,
+			bio: related_user.bio,
+			premium_since: user.premium_since,
+			accent_color: related_user.accent_color,
+		};
+		users.push(public_related_user);
+	}
+
+	setImmediate(async () => {
+		// run in seperate "promise context" because ready payload is not dependent on those events
+		emitEvent({
+			event: "SESSIONS_REPLACE",
+			user_id: this.user_id,
+			data: await Session.find({
+				where: { user_id: this.user_id },
+				select: PrivateSessionProjection,
+			}),
+		} as SessionsReplace);
+		emitEvent({
+			event: "PRESENCE_UPDATE",
+			user_id: this.user_id,
+			data: {
+				user: await User.getPublicUser(this.user_id),
+				activities: session.activities,
+				client_status: session?.client_info,
+				status: session.status,
+			},
+		} as PresenceUpdateEvent);
+	});
+
+	read_states.forEach((s: any) => {
+		s.id = s.channel_id;
+		delete s.user_id;
+		delete s.channel_id;
+	});
+
+	const privateUser = {
+		avatar: user.avatar,
+		mobile: user.mobile,
+		desktop: user.desktop,
+		discriminator: user.discriminator,
+		email: user.email,
+		flags: user.flags,
+		id: user.id,
+		mfa_enabled: user.mfa_enabled,
+		nsfw_allowed: user.nsfw_allowed,
+		phone: user.phone,
+		premium: user.premium,
+		premium_type: user.premium_type,
+		public_flags: user.public_flags,
+		premium_usage_flags: user.premium_usage_flags,
+		purchased_flags: user.purchased_flags,
+		username: user.username,
+		verified: user.verified,
+		bot: user.bot,
+		accent_color: user.accent_color,
+		banner: user.banner,
+		bio: user.bio,
+		premium_since: user.premium_since
+	};
+
+	const d: ReadyEventData = {
+		v: 8,
+		application: application ?? undefined,
+		user: privateUser,
+		user_settings: user.settings,
+		// @ts-ignore
+		guilds: guilds.map((x) => {
+			// @ts-ignore
+			x.guild_hashes = {}; // @ts-ignore
+			x.guild_scheduled_events = []; // @ts-ignore
+			x.threads = [];
+			return x;
+		}),
+		guild_experiments: [], // TODO
+		geo_ordered_rtc_regions: [], // TODO
+		relationships: user.relationships.map((x) => x.toPublicRelationship()),
+		read_state: {
+			entries: read_states,
+			partial: false,
+			version: 304128,
+		},
+		user_guild_settings: {
+			entries: user_guild_settings_entries,
+			partial: false, // TODO partial
+			version: 642,
+		},
+		private_channels: channels,
+		session_id: session_id,
+		analytics_token: "", // TODO
+		connected_accounts: [], // TODO
+		consents: {
+			personalization: {
+				consented: false, // TODO
+			},
+		},
+		country_code: user.settings.locale,
+		friend_suggestion_count: 0, // TODO
+		// @ts-ignore
+		experiments: experiments, // TODO
+		guild_join_requests: [], // TODO what is this?
+		users: users.filter((x) => x).unique(),
+		merged_members: merged_members,
+		// shard // TODO: only for user sharding
+		sessions: [], // TODO:
+		presences: [], // TODO:
+	};
+
+	// TODO: send real proper data structure
+	await Send(this, {
+		op: OPCODES.Dispatch,
+		t: EVENTEnum.Ready,
+		s: this.sequence++,
+		d,
+	});
+
+	//TODO send READY_SUPPLEMENTAL
+	//TODO send GUILD_MEMBER_LIST_UPDATE
+	//TODO send SESSIONS_REPLACE
+	//TODO send VOICE_STATE_UPDATE to let the client know if another device is already connected to a voice channel
+
+	await setupListener.call(this);
+
+	console.log(`${this.ipAddress} identified as ${d.user.id}`);
+}
diff --git a/src/gateway/opcodes/LazyRequest.ts b/src/gateway/opcodes/LazyRequest.ts
new file mode 100644
index 00000000..82342224
--- /dev/null
+++ b/src/gateway/opcodes/LazyRequest.ts
@@ -0,0 +1,205 @@
+import { getDatabase, getPermission, listenEvent, Member, Role, Session } from "@fosscord/util";
+import { WebSocket, Payload, handlePresenceUpdate, OPCODES, Send } from "@fosscord/gateway";
+import { LazyRequest } from "../schema/LazyRequest";
+import { check } from "./instanceOf";
+
+// TODO: only show roles/members that have access to this channel
+// TODO: config: to list all members (even those who are offline) sorted by role, or just those who are online
+// TODO: rewrite typeorm
+
+async function getMembers(guild_id: string, range: [number, number]) {
+	if (!Array.isArray(range) || range.length !== 2) {
+		throw new Error("range is not a valid array");
+	}
+	// TODO: wait for typeorm to implement ordering for .find queries https://github.com/typeorm/typeorm/issues/2620
+
+	let members: Member[] = [];
+	try {
+		members = await getDatabase()!.getRepository(Member)
+			.createQueryBuilder("member")
+			.where("member.guild_id = :guild_id", { guild_id })
+			.leftJoinAndSelect("member.roles", "role")
+			.leftJoinAndSelect("member.user", "user")
+			.leftJoinAndSelect("user.sessions", "session")
+			.addSelect("user.settings")
+			.addSelect(
+				"CASE WHEN session.status = 'offline' THEN 0 ELSE 1 END",
+				"_status"
+			)
+			.orderBy("role.position", "DESC")
+			.addOrderBy("_status", "DESC")
+			.addOrderBy("user.username", "ASC")
+			.offset(Number(range[0]) || 0)
+			.limit(Number(range[1]) || 100)
+			.getMany();
+	}
+	catch (e) {
+		console.error(`LazyRequest`, e);
+	}
+
+	if (!members) {
+		return {
+			items: [],
+			groups: [],
+			range: [],
+			members: [],
+		};
+	}
+
+	const groups = [] as any[];
+	const items = [];
+	const member_roles = members
+		.map((m) => m.roles)
+		.flat()
+		.unique((r: Role) => r.id);
+	member_roles.push(member_roles.splice(member_roles.findIndex(x => x.id === x.guild_id), 1)[0]);
+
+	const offlineItems = [];
+
+	for (const role of member_roles) {
+		// @ts-ignore
+		const [role_members, other_members]: Member[][] = partition(members, (m: Member) =>
+			m.roles.find((r) => r.id === role.id)
+		);
+		const group = {
+			count: role_members.length,
+			id: role.id === guild_id ? "online" : role.id,
+		};
+
+		items.push({ group });
+		groups.push(group);
+
+		for (const member of role_members) {
+			const roles = member.roles
+				.filter((x: Role) => x.id !== guild_id)
+				.map((x: Role) => x.id);
+
+			const statusMap = {
+				"online": 0,
+				"idle": 1,
+				"dnd": 2,
+				"invisible": 3,
+				"offline": 4,
+			};
+			// sort sessions by relevance
+			const sessions = member.user.sessions.sort((a, b) => {
+				return (statusMap[a.status] - statusMap[b.status]) + ((a.activities.length - b.activities.length) * 2);
+			});
+			var session: Session | undefined = sessions.first();
+
+			if (session?.status == "offline") {
+				session.status = member.user.settings.status || "online";
+			}
+
+			const item = {
+				member: {
+					...member,
+					roles,
+					user: { ...member.user, sessions: undefined },
+					presence: {
+						...session,
+						activities: session?.activities || [],
+						user: { id: member.user.id },
+					},
+				},
+			};
+
+			if (!session || session.status == "invisible" || session.status == "offline") {
+				item.member.presence.status = "offline";
+				offlineItems.push(item);
+				group.count--;
+				continue;
+			}
+
+			items.push(item);
+		}
+		members = other_members;
+	}
+
+	if (offlineItems.length) {
+		const group = {
+			count: offlineItems.length,
+			id: "offline",
+		};
+		items.push({ group });
+		groups.push(group);
+
+		items.push(...offlineItems);
+	}
+
+	return {
+		items,
+		groups,
+		range,
+		members: items.map((x) => 'member' in x ? x.member : undefined).filter(x => !!x),
+	};
+}
+
+export async function onLazyRequest(this: WebSocket, { d }: Payload) {
+	// TODO: check data
+	check.call(this, LazyRequest, d);
+	const { guild_id, typing, channels, activities } = d as LazyRequest;
+
+	const channel_id = Object.keys(channels || {}).first();
+	if (!channel_id) return;
+
+	const permissions = await getPermission(this.user_id, guild_id, channel_id);
+	permissions.hasThrow("VIEW_CHANNEL");
+
+	const ranges = channels![channel_id];
+	if (!Array.isArray(ranges)) throw new Error("Not a valid Array");
+
+	const member_count = await Member.count({ where: { guild_id } });
+	const ops = await Promise.all(ranges.map((x) => getMembers(guild_id, x)));
+
+	// TODO: unsubscribe member_events that are not in op.members
+
+	ops.forEach((op) => {
+		op.members.forEach(async (member) => {
+			if (!member) return;
+			if (this.events[member.user.id]) return; // already subscribed as friend
+			if (this.member_events[member.user.id]) return; // already subscribed in member list
+			this.member_events[member.user.id] = await listenEvent(
+				member.user.id,
+				handlePresenceUpdate.bind(this),
+				this.listen_options
+			);
+		});
+	});
+
+	const groups = ops
+		.map((x) => x.groups)
+		.flat()
+		.unique();
+
+	return await Send(this, {
+		op: OPCODES.Dispatch,
+		s: this.sequence++,
+		t: "GUILD_MEMBER_LIST_UPDATE",
+		d: {
+			ops: ops.map((x) => ({
+				items: x.items,
+				op: "SYNC",
+				range: x.range,
+			})),
+			online_count: member_count - (groups.find(x => x.id == "offline")?.count ?? 0),
+			member_count,
+			id: "everyone",
+			guild_id,
+			groups,
+		},
+	});
+}
+
+function partition<T>(array: T[], isValid: Function) {
+	// @ts-ignore
+	return array.reduce(
+		// @ts-ignore
+		([pass, fail], elem) => {
+			return isValid(elem)
+				? [[...pass, elem], fail]
+				: [pass, [...fail, elem]];
+		},
+		[[], []]
+	);
+}
diff --git a/src/gateway/opcodes/PresenceUpdate.ts b/src/gateway/opcodes/PresenceUpdate.ts
new file mode 100644
index 00000000..415df6ee
--- /dev/null
+++ b/src/gateway/opcodes/PresenceUpdate.ts
@@ -0,0 +1,25 @@
+import { WebSocket, Payload } from "@fosscord/gateway";
+import { emitEvent, PresenceUpdateEvent, Session, User } from "@fosscord/util";
+import { ActivitySchema } from "../schema/Activity";
+import { check } from "./instanceOf";
+
+export async function onPresenceUpdate(this: WebSocket, { d }: Payload) {
+	check.call(this, ActivitySchema, d);
+	const presence = d as ActivitySchema;
+
+	await Session.update(
+		{ session_id: this.session_id },
+		{ status: presence.status, activities: presence.activities }
+	);
+
+	await emitEvent({
+		event: "PRESENCE_UPDATE",
+		user_id: this.user_id,
+		data: {
+			user: await User.getPublicUser(this.user_id),
+			activities: presence.activities,
+			client_status: {}, // TODO:
+			status: presence.status,
+		},
+	} as PresenceUpdateEvent);
+}
diff --git a/src/gateway/opcodes/RequestGuildMembers.ts b/src/gateway/opcodes/RequestGuildMembers.ts
new file mode 100644
index 00000000..b80721dc
--- /dev/null
+++ b/src/gateway/opcodes/RequestGuildMembers.ts
@@ -0,0 +1,5 @@
+import { Payload, WebSocket } from "@fosscord/gateway";
+
+export function onRequestGuildMembers(this: WebSocket, data: Payload) {
+	// return this.close(CLOSECODES.Unknown_error);
+}
diff --git a/src/gateway/opcodes/Resume.ts b/src/gateway/opcodes/Resume.ts
new file mode 100644
index 00000000..42dc586d
--- /dev/null
+++ b/src/gateway/opcodes/Resume.ts
@@ -0,0 +1,12 @@
+import { WebSocket, Payload } from "@fosscord/gateway";
+import { Send } from "../util/Send";
+
+export async function onResume(this: WebSocket, data: Payload) {
+	console.log("Got Resume -> cancel not implemented");
+	await Send(this, {
+		op: 9,
+		d: false,
+	});
+
+	// return this.close(CLOSECODES.Invalid_session);
+}
diff --git a/src/gateway/opcodes/VoiceStateUpdate.ts b/src/gateway/opcodes/VoiceStateUpdate.ts
new file mode 100644
index 00000000..fa63f7fc
--- /dev/null
+++ b/src/gateway/opcodes/VoiceStateUpdate.ts
@@ -0,0 +1,113 @@
+import { VoiceStateUpdateSchema } from "../schema/VoiceStateUpdateSchema";
+import { Payload, WebSocket } from "@fosscord/gateway";
+import { genVoiceToken } from "../util/SessionUtils";
+import { check } from "./instanceOf";
+import {
+	Config,
+	emitEvent,
+	Guild,
+	Member,
+	Region,
+	VoiceServerUpdateEvent,
+	VoiceState,
+	VoiceStateUpdateEvent,
+} from "@fosscord/util";
+// TODO: check if a voice server is setup
+// Notice: Bot users respect the voice channel's user limit, if set. When the voice channel is full, you will not receive the Voice State Update or Voice Server Update events in response to your own Voice State Update. Having MANAGE_CHANNELS permission bypasses this limit and allows you to join regardless of the channel being full or not.
+
+export async function onVoiceStateUpdate(this: WebSocket, data: Payload) {
+	check.call(this, VoiceStateUpdateSchema, data.d);
+	const body = data.d as VoiceStateUpdateSchema;
+
+	let voiceState: VoiceState;
+	try {
+		voiceState = await VoiceState.findOneOrFail({
+			where: { user_id: this.user_id },
+		});
+		if (
+			voiceState.session_id !== this.session_id &&
+			body.channel_id === null
+		) {
+			//Should we also check guild_id === null?
+			//changing deaf or mute on a client that's not the one with the same session of the voicestate in the database should be ignored
+			return;
+		}
+
+		//If a user change voice channel between guild we should send a left event first
+		if (
+			voiceState.guild_id !== body.guild_id &&
+			voiceState.session_id === this.session_id
+		) {
+			await emitEvent({
+				event: "VOICE_STATE_UPDATE",
+				data: { ...voiceState, channel_id: null },
+				guild_id: voiceState.guild_id,
+			});
+		}
+
+		//The event send by Discord's client on channel leave has both guild_id and channel_id as null
+		if (body.guild_id === null) body.guild_id = voiceState.guild_id;
+		voiceState.assign(body);
+	} catch (error) {
+		voiceState = VoiceState.create({
+			...body,
+			user_id: this.user_id,
+			deaf: false,
+			mute: false,
+			suppress: false,
+		});
+	}
+
+	// 'Fix' for this one voice state error
+	if (!voiceState.guild_id) return;
+
+	//TODO the member should only have these properties: hoisted_role, deaf, joined_at, mute, roles, user
+	//TODO the member.user should only have these properties: avatar, discriminator, id, username
+	//TODO this may fail
+	voiceState.member = await Member.findOneOrFail({
+		where: { id: voiceState.user_id, guild_id: voiceState.guild_id },
+		relations: ["user", "roles"],
+	});
+
+	//If the session changed we generate a new token
+	if (voiceState.session_id !== this.session_id)
+		voiceState.token = genVoiceToken();
+	voiceState.session_id = this.session_id;
+
+	const { id, ...newObj } = voiceState;
+
+	await Promise.all([
+		voiceState.save(),
+		emitEvent({
+			event: "VOICE_STATE_UPDATE",
+			data: newObj,
+			guild_id: voiceState.guild_id,
+		} as VoiceStateUpdateEvent),
+	]);
+
+	//If it's null it means that we are leaving the channel and this event is not needed
+	if (voiceState.channel_id !== null) {
+		const guild = await Guild.findOne({ where: { id: voiceState.guild_id } });
+		const regions = Config.get().regions;
+		let guildRegion: Region;
+		if (guild && guild.region) {
+			guildRegion = regions.available.filter(
+				(r) => r.id === guild.region
+			)[0];
+		} else {
+			guildRegion = regions.available.filter(
+				(r) => r.id === regions.default
+			)[0];
+		}
+
+		await emitEvent({
+			event: "VOICE_SERVER_UPDATE",
+			data: {
+				token: voiceState.token,
+				guild_id: voiceState.guild_id,
+				endpoint: guildRegion.endpoint,
+			},
+			guild_id: voiceState.guild_id,
+		} as VoiceServerUpdateEvent);
+	}
+}
diff --git a/src/gateway/opcodes/experiments.json b/src/gateway/opcodes/experiments.json
new file mode 100644
index 00000000..0370b5da
--- /dev/null
+++ b/src/gateway/opcodes/experiments.json
@@ -0,0 +1,76 @@
+[
+	[4047587481, 0, 0, -1, 0],
+	[1509401575, 0, 1, -1, 0],
+	[1865079242, 0, 1, -1, 0],
+	[1962538549, 1, 0, -1, 0],
+	[3816091942, 3, 2, -1, 0],
+	[4130837190, 0, 10, -1, 0],
+	[1861568052, 0, 1, -1, 0],
+	[2290910058, 6, 2, -1, 0],
+	[1578940118, 1, 1, -1, 0],
+	[1571676964, 0, 1, -1, 2],
+	[3640172371, 0, 2, -1, 2],
+	[1658164312, 2, 1, -1, 0],
+	[98883956, 1, 1, -1, 0],
+	[3114091169, 0, 1, -1, 0],
+	[2570684145, 4, 1, -1, 2],
+	[4007615411, 0, 1, -1, 0],
+	[3665310159, 2, 1, -1, 1],
+	[852550504, 3, 1, -1, 0],
+	[2333572067, 0, 1, -1, 0],
+	[935994771, 1, 1, -1, 0],
+	[1127795596, 1, 1, -1, 0],
+	[4168223991, 0, 1, -1, 0],
+	[18585280, 0, 1, -1, 1],
+	[327482016, 0, 1, -1, 2],
+	[3458098201, 7, 1, -1, 0],
+	[478613943, 2, 1, -1, 1],
+	[2792197902, 0, 1, -1, 2],
+	[284670956, 0, 1, -1, 0],
+	[2099185390, 0, 1, -1, 0],
+	[1202202685, 0, 1, -1, 0],
+	[2122174751, 0, 1, -1, 0],
+	[3633864632, 0, 1, -1, 0],
+	[3103053065, 0, 1, -1, 0],
+	[820624960, 0, 1, -1, 0],
+	[1134479292, 0, 1, -1, 0],
+	[2511257455, 3, 1, -1, 3],
+	[2599708267, 0, 1, -1, 0],
+	[613180822, 1, 1, -1, 0],
+	[2885186814, 0, 1, -1, 0],
+	[221503477, 0, 1, -1, 0],
+	[1054317075, 0, 1, -1, 3],
+	[683872522, 0, 1, -1, 1],
+	[1739278764, 0, 2, -1, 0],
+	[2855249023, 0, 1, -1, 0],
+	[3721841948, 0, 1, -1, 0],
+	[1285203515, 0, 1, -1, 0],
+	[1365487849, 6, 1, -1, 0],
+	[955229746, 0, 1, -1, 0],
+	[3128009767, 0, 10, -1, 0],
+	[441885003, 0, 1, -1, 0],
+	[3433971238, 0, 1, -1, 2],
+	[1038765354, 3, 1, -1, 0],
+	[1174347196, 0, 1, -1, 0],
+	[3649806352, 1, 1, -1, 0],
+	[2973729510, 2, 1, -1, 0],
+	[2571931329, 1, 6, -1, 0],
+	[3884442008, 0, 1, -1, 0],
+	[978673395, 1, 1, -1, 0],
+	[4050927174, 0, 1, -1, 0],
+	[1260103069, 0, 1, -1, 0],
+	[4168894280, 0, 1, -1, 0],
+	[4045587091, 0, 1, -1, 0],
+	[2003494159, 1, 1, -1, 0],
+	[51193042, 0, 1, -1, 0],
+	[2634540382, 3, 1, -1, 0],
+	[886364171, 0, 1, -1, 0],
+	[3898604944, 0, 1, -1, 0],
+	[3388129398, 0, 1, -1, 0],
+	[3964382884, 2, 1, -1, 1],
+	[3305874255, 0, 1, -1, 0],
+	[156590431, 0, 1, -1, 0],
+	[3106485751, 0, 0, -1, 0],
+	[3035674767, 0, 1, -1, 0],
+	[851697110, 0, 1, -1, 0]
+]
diff --git a/src/gateway/opcodes/index.ts b/src/gateway/opcodes/index.ts
new file mode 100644
index 00000000..027739db
--- /dev/null
+++ b/src/gateway/opcodes/index.ts
@@ -0,0 +1,25 @@
+import { WebSocket, Payload } from "@fosscord/gateway";
+import { onHeartbeat } from "./Heartbeat";
+import { onIdentify } from "./Identify";
+import { onLazyRequest } from "./LazyRequest";
+import { onPresenceUpdate } from "./PresenceUpdate";
+import { onRequestGuildMembers } from "./RequestGuildMembers";
+import { onResume } from "./Resume";
+import { onVoiceStateUpdate } from "./VoiceStateUpdate";
+
+export type OPCodeHandler = (this: WebSocket, data: Payload) => any;
+
+export default {
+	1: onHeartbeat,
+	2: onIdentify,
+	3: onPresenceUpdate,
+	4: onVoiceStateUpdate,
+	// 5: Voice Server Ping
+	6: onResume,
+	// 7: Reconnect: You should attempt to reconnect and resume immediately.
+	8: onRequestGuildMembers,
+	// 9: Invalid Session
+	// 10: Hello
+	// 13: Dm_update
+	14: onLazyRequest,
+};
diff --git a/src/gateway/opcodes/instanceOf.ts b/src/gateway/opcodes/instanceOf.ts
new file mode 100644
index 00000000..6fd50852
--- /dev/null
+++ b/src/gateway/opcodes/instanceOf.ts
@@ -0,0 +1,18 @@
+import { instanceOf } from "lambert-server";
+import { WebSocket } from "@fosscord/gateway";
+import { CLOSECODES } from "../util/Constants";
+
+export function check(this: WebSocket, schema: any, data: any) {
+	try {
+		const error = instanceOf(schema, data, { path: "body" });
+		if (error !== true) {
+			throw error;
+		}
+		return true;
+	} catch (error) {
+		console.error(error);
+		// invalid payload
+		this.close(CLOSECODES.Decode_error);
+		throw error;
+	}
+}
diff --git a/src/gateway/schema/Activity.ts b/src/gateway/schema/Activity.ts
new file mode 100644
index 00000000..f58b0fa9
--- /dev/null
+++ b/src/gateway/schema/Activity.ts
@@ -0,0 +1,60 @@
+import { Activity, Status } from "@fosscord/util";
+
+export const ActivitySchema = {
+	afk: Boolean,
+	status: String,
+	$activities: [
+		{
+			name: String,
+			type: Number,
+			$url: String,
+			$created_at: Date,
+			$timestamps: {
+				$start: Number,
+				$end: Number,
+			},
+			$application_id: String,
+			$details: String,
+			$state: String,
+			$emoji: {
+				$name: String,
+				$id: String,
+				$animated: Boolean,
+			},
+			$party: {
+				$id: String,
+				$size: [Number, Number],
+			},
+			$assets: {
+				$large_image: String,
+				$large_text: String,
+				$small_image: String,
+				$small_text: String,
+			},
+			$secrets: {
+				$join: String,
+				$spectate: String,
+				$match: String,
+			},
+			$instance: Boolean,
+			$flags: String,
+
+			$id: String,
+			$sync_id: String,
+			$metadata: { // spotify
+				$context_uri: String,
+				album_id: String,
+				artist_ids: [String],
+			},
+			$session_id: String,
+		},
+	],
+	$since: Number, // unix time (in milliseconds) of when the client went idle, or null if the client is not idle
+};
+
+export interface ActivitySchema {
+	afk: boolean;
+	status: Status;
+	activities?: Activity[];
+	since?: number; // unix time (in milliseconds) of when the client went idle, or null if the client is not idle
+}
diff --git a/src/gateway/schema/Identify.ts b/src/gateway/schema/Identify.ts
new file mode 100644
index 00000000..6f68b515
--- /dev/null
+++ b/src/gateway/schema/Identify.ts
@@ -0,0 +1,106 @@
+import { ActivitySchema } from "./Activity";
+
+export const IdentifySchema = {
+	token: String,
+	$intents: BigInt, // discord uses a Integer for bitfields we use bigints tho. | instanceOf will automatically convert the Number to a BigInt
+	$properties: Object,
+	// {
+	// 	// discord uses $ in the property key for bots, so we need to double prefix it, because instanceOf treats $ (prefix) as a optional key
+	// 	$os: String,
+	// 	$os_arch: String,
+	// 	$browser: String,
+	// 	$device: String,
+	// 	$$os: String,
+	// 	$$browser: String,
+	// 	$$device: String,
+	// 	$browser_user_agent: String,
+	// 	$browser_version: String,
+	// 	$os_version: String,
+	// 	$referrer: String,
+	// 	$$referrer: String,
+	// 	$referring_domain: String,
+	// 	$$referring_domain: String,
+	// 	$referrer_current: String,
+	// 	$referring_domain_current: String,
+	// 	$release_channel: String,
+	// 	$client_build_number: Number,
+	// 	$client_event_source: String,
+	// 	$client_version: String,
+	// 	$system_locale: String,
+	// 	$window_manager: String,
+	// 	$distro: String,
+	// },
+	$presence: ActivitySchema,
+	$compress: Boolean,
+	$large_threshold: Number,
+	$shard: [BigInt, BigInt],
+	$guild_subscriptions: Boolean,
+	$capabilities: Number,
+	$client_state: {
+		$guild_hashes: Object,
+		$highest_last_message_id: String || Number,
+		$read_state_version: Number,
+		$user_guild_settings_version: Number,
+		$user_settings_version: undefined,
+		$useruser_guild_settings_version: undefined,
+	},
+	$clientState: {
+		$guildHashes: Object,
+		$highestLastMessageId: String || Number,
+		$readStateVersion: Number,
+		$useruserGuildSettingsVersion: undefined,
+		$userGuildSettingsVersion: undefined,
+	},
+	$v: Number,
+	$version: Number,
+};
+
+export interface IdentifySchema {
+	token: string;
+	properties: {
+		// bruh discord really uses $ in the property key, so we need to double prefix it, because instanceOf treats $ (prefix) as a optional key
+		os?: string;
+		os_atch?: string;
+		browser?: string;
+		device?: string;
+		$os?: string;
+		$browser?: string;
+		$device?: string;
+		browser_user_agent?: string;
+		browser_version?: string;
+		os_version?: string;
+		referrer?: string;
+		referring_domain?: string;
+		referrer_current?: string;
+		referring_domain_current?: string;
+		release_channel?: "stable" | "dev" | "ptb" | "canary";
+		client_build_number?: number;
+		client_event_source?: any;
+		client_version?: string;
+		system_locale?: string;
+	};
+	intents?: bigint; // discord uses a Integer for bitfields we use bigints tho. | instanceOf will automatically convert the Number to a BigInt
+	presence?: ActivitySchema;
+	compress?: boolean;
+	large_threshold?: number;
+	largeThreshold?: number;
+	shard?: [bigint, bigint];
+	guild_subscriptions?: boolean;
+	capabilities?: number;
+	client_state?: {
+		guild_hashes?: any;
+		highest_last_message_id?: string | number;
+		read_state_version?: number;
+		user_guild_settings_version?: number;
+		user_settings_version?: number;
+		useruser_guild_settings_version?: number;
+	};
+	clientState?: {
+		guildHashes?: any;
+		highestLastMessageId?: string | number;
+		readStateVersion?: number;
+		userGuildSettingsVersion?: number;
+		useruserGuildSettingsVersion?: number;
+	};
+	v?: number;
+}
diff --git a/src/gateway/schema/LazyRequest.ts b/src/gateway/schema/LazyRequest.ts
new file mode 100644
index 00000000..1fe658bb
--- /dev/null
+++ b/src/gateway/schema/LazyRequest.ts
@@ -0,0 +1,19 @@
+export interface LazyRequest {
+	guild_id: string;
+	channels?: Record<string, [number, number][]>;
+	activities?: boolean;
+	threads?: boolean;
+	typing?: true;
+	members?: any[];
+	thread_member_lists?: any[];
+}
+
+export const LazyRequest = {
+	guild_id: String,
+	$activities: Boolean,
+	$channels: Object,
+	$typing: Boolean,
+	$threads: Boolean,
+	$members: [] as any[],
+	$thread_member_lists: [] as any[],
+};
diff --git a/src/gateway/schema/VoiceStateUpdateSchema.ts b/src/gateway/schema/VoiceStateUpdateSchema.ts
new file mode 100644
index 00000000..f6480414
--- /dev/null
+++ b/src/gateway/schema/VoiceStateUpdateSchema.ts
@@ -0,0 +1,17 @@
+export const VoiceStateUpdateSchema = {
+	$guild_id: String,
+	$channel_id: String,
+	self_mute: Boolean,
+	self_deaf: Boolean,
+	$self_video: Boolean,	//required in docs but bots don't always send it
+	$preferred_region: String,
+};
+
+export interface VoiceStateUpdateSchema {
+	guild_id?: string;
+	channel_id?: string;
+	self_mute: boolean;
+	self_deaf: boolean;
+	self_video?: boolean;
+	preferred_region?: string;
+}
\ No newline at end of file
diff --git a/src/gateway/start.ts b/src/gateway/start.ts
new file mode 100644
index 00000000..90d7f34e
--- /dev/null
+++ b/src/gateway/start.ts
@@ -0,0 +1,15 @@
+require('module-alias/register');
+process.on("uncaughtException", console.error);
+process.on("unhandledRejection", console.error);
+
+import { Server } from "./Server";
+import { config } from "dotenv";
+config();
+
+var port = Number(process.env.PORT);
+if (isNaN(port)) port = 3002;
+
+const server = new Server({
+	port,
+});
+server.start();
diff --git a/src/gateway/util/Constants.ts b/src/gateway/util/Constants.ts
new file mode 100644
index 00000000..ff9b5525
--- /dev/null
+++ b/src/gateway/util/Constants.ts
@@ -0,0 +1,52 @@
+// import { VoiceOPCodes } from "@fosscord/webrtc";
+
+export enum OPCODES {
+	Dispatch = 0,
+	Heartbeat = 1,
+	Identify = 2,
+	Presence_Update = 3,
+	Voice_State_Update = 4,
+	Voice_Server_Ping = 5, // ? What is opcode 5?
+	Resume = 6,
+	Reconnect = 7,
+	Request_Guild_Members = 8,
+	Invalid_Session = 9,
+	Hello = 10,
+	Heartbeat_ACK = 11,
+	Guild_Sync = 12,
+	DM_Update = 13,
+	Lazy_Request = 14,
+	Lobby_Connect = 15,
+	Lobby_Disconnect = 16,
+	Lobby_Voice_States_Update = 17,
+	Stream_Create = 18,
+	Stream_Delete = 19,
+	Stream_Watch = 20,
+	Stream_Ping = 21,
+	Stream_Set_Paused = 22,
+	Request_Application_Commands = 24,
+}
+export enum CLOSECODES {
+	Unknown_error = 4000,
+	Unknown_opcode,
+	Decode_error,
+	Not_authenticated,
+	Authentication_failed,
+	Already_authenticated,
+	Invalid_session,
+	Invalid_seq,
+	Rate_limited,
+	Session_timed_out,
+	Invalid_shard,
+	Sharding_required,
+	Invalid_API_version,
+	Invalid_intent,
+	Disallowed_intent,
+}
+
+export interface Payload {
+	op: OPCODES /*  | VoiceOPCodes */;
+	d?: any;
+	s?: number;
+	t?: string;
+}
diff --git a/src/gateway/util/Heartbeat.ts b/src/gateway/util/Heartbeat.ts
new file mode 100644
index 00000000..f6871cfe
--- /dev/null
+++ b/src/gateway/util/Heartbeat.ts
@@ -0,0 +1,11 @@
+import { CLOSECODES } from "./Constants";
+import { WebSocket } from "./WebSocket";
+
+// TODO: make heartbeat timeout configurable
+export function setHeartbeat(socket: WebSocket) {
+	if (socket.heartbeatTimeout) clearTimeout(socket.heartbeatTimeout);
+
+	socket.heartbeatTimeout = setTimeout(() => {
+		return socket.close(CLOSECODES.Session_timed_out);
+	}, 1000 * 45);
+}
diff --git a/src/gateway/util/Send.ts b/src/gateway/util/Send.ts
new file mode 100644
index 00000000..e1460846
--- /dev/null
+++ b/src/gateway/util/Send.ts
@@ -0,0 +1,32 @@
+var erlpack: any;
+try {
+	erlpack = require("@yukikaze-bot/erlpack");
+} catch (error) {
+	console.log("Missing @yukikaze-bot/erlpack, electron-based desktop clients designed for discord.com will not be able to connect!");
+}
+import { Payload, WebSocket } from "@fosscord/gateway";
+
+export function Send(socket: WebSocket, data: Payload) {
+	let buffer: Buffer | string;
+	if (socket.encoding === "etf") buffer = erlpack.pack(data);
+	// TODO: encode circular object
+	else if (socket.encoding === "json") buffer = JSON.stringify(data);
+	else return;
+	// TODO: compression
+	if (socket.deflate) {
+		buffer = socket.deflate.process(buffer) as Buffer;
+	}
+
+	return new Promise((res, rej) => {
+		if (socket.readyState !== 1) {
+			// return rej("socket not open");
+			socket.close();
+			return;
+		}
+
+		socket.send(buffer, (err: any) => {
+			if (err) return rej(err);
+			return res(null);
+		});
+	});
+}
diff --git a/src/gateway/util/SessionUtils.ts b/src/gateway/util/SessionUtils.ts
new file mode 100644
index 00000000..bf854042
--- /dev/null
+++ b/src/gateway/util/SessionUtils.ts
@@ -0,0 +1,13 @@
+export function genSessionId() {
+	return genRanHex(32);
+}
+
+export function genVoiceToken() {
+	return genRanHex(16);
+}
+
+function genRanHex(size: number) {
+	return [...Array(size)]
+		.map(() => Math.floor(Math.random() * 16).toString(16))
+		.join("");
+}
diff --git a/src/gateway/util/WebSocket.ts b/src/gateway/util/WebSocket.ts
new file mode 100644
index 00000000..930fa78a
--- /dev/null
+++ b/src/gateway/util/WebSocket.ts
@@ -0,0 +1,26 @@
+import { Intents, Permissions } from "@fosscord/util";
+import WS from "ws";
+import { Deflate, Inflate } from "fast-zlib";
+// import { Client } from "@fosscord/webrtc";
+
+export interface WebSocket extends WS {
+	version: number;
+	user_id: string;
+	session_id: string;
+	encoding: "etf" | "json";
+	compress?: "zlib-stream";
+	ipAddress?: string;
+	shard_count?: bigint;
+	shard_id?: bigint;
+	deflate?: Deflate;
+	inflate?: Inflate;
+	heartbeatTimeout: NodeJS.Timeout;
+	readyTimeout: NodeJS.Timeout;
+	intents: Intents;
+	sequence: number;
+	permissions: Record<string, Permissions>;
+	events: Record<string, Function>;
+	member_events: Record<string, Function>;
+	listen_options: any;
+	// client?: Client;
+}
diff --git a/src/gateway/util/index.ts b/src/gateway/util/index.ts
new file mode 100644
index 00000000..0be5ecee
--- /dev/null
+++ b/src/gateway/util/index.ts
@@ -0,0 +1,5 @@
+export * from "./Constants";
+export * from "./Send";
+export * from "./SessionUtils";
+export * from "./Heartbeat";
+export * from "./WebSocket";