summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--src/gateway/opcodes/Identify.ts490
-rw-r--r--src/util/dtos/ReadyGuildDTO.ts56
-rw-r--r--src/util/entities/Channel.ts7
-rw-r--r--src/util/entities/User.ts9
-rw-r--r--src/util/interfaces/Event.ts31
5 files changed, 382 insertions, 211 deletions
diff --git a/src/gateway/opcodes/Identify.ts b/src/gateway/opcodes/Identify.ts
index 1a632b84..51a6e2e4 100644
--- a/src/gateway/opcodes/Identify.ts
+++ b/src/gateway/opcodes/Identify.ts
@@ -16,7 +16,7 @@
 	along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */
 
-import { WebSocket, Payload } from "@fosscord/gateway";
+import { WebSocket, Payload, setupListener } from "@fosscord/gateway";
 import {
 	checkToken,
 	Intents,
@@ -26,7 +26,6 @@ import {
 	Session,
 	EVENTEnum,
 	Config,
-	PublicMember,
 	PublicUser,
 	PrivateUserProjection,
 	ReadState,
@@ -36,19 +35,19 @@ import {
 	PrivateSessionProjection,
 	MemberPrivateProjection,
 	PresenceUpdateEvent,
-	UserSettings,
 	IdentifySchema,
 	DefaultUserGuildSettings,
-	UserGuildSettings,
 	ReadyGuildDTO,
 	Guild,
-	UserTokenData,
+	PublicUserProjection,
+	ReadyUserGuildSettingsEntries,
+	UserSettings,
+	Permissions,
+	DMChannel,
+	GuildOrUnavailable,
 } from "@fosscord/util";
 import { Send } from "../util/Send";
 import { CLOSECODES, OPCODES } from "../util/Constants";
-import { setupListener } from "../listener/listener";
-// import experiments from "./experiments.json";
-const experiments: unknown[] = [];
 import { check } from "./instanceOf";
 import { Recipient } from "@fosscord/util";
 
@@ -56,49 +55,132 @@ import { Recipient } from "@fosscord/util";
 // TODO: check privileged intents, if defined in the config
 // TODO: check if already identified
 
-// TODO: Refactor identify ( and lazyrequest, tbh )
+const getUserFromToken = async (token: string): Promise<string | null> => {
+	try {
+		const { jwtSecret } = Config.get().security;
+		const { decoded } = await checkToken(token, jwtSecret);
+		return decoded.id;
+	} catch (e) {
+		console.error(`[Gateway] Invalid token`, e);
+		return null;
+	}
+};
 
 export async function onIdentify(this: WebSocket, data: Payload) {
 	clearTimeout(this.readyTimeout);
-	// TODO: is this needed now that we use `json-bigint`?
-	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);
 
+	// Check payload matches schema
+	check.call(this, IdentifySchema, data.d);
 	const identify: IdentifySchema = data.d;
 
-	let decoded: UserTokenData["decoded"];
-	try {
-		const { jwtSecret } = Config.get().security;
-		decoded = (await checkToken(identify.token, jwtSecret)).decoded; // will throw an error if invalid
-	} catch (error) {
-		console.error("invalid token", error);
-		return this.close(CLOSECODES.Authentication_failed);
+	// Check auth
+	// TODO: the checkToken call will fetch user, and then we have to refetch with different select
+	// checkToken should be able to select what we want
+	const user_id = await getUserFromToken(identify.token);
+	if (!user_id) return this.close(CLOSECODES.Authentication_failed);
+	this.user_id = user_id;
+
+	// Check intents
+	if (!identify.intents) identify.intents = 30064771071n; // TODO: what is this number?
+	this.intents = new Intents(identify.intents);
+
+	// TODO: actually do intent things.
+
+	// Validate sharding
+	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
+		) {
+			// TODO: why do we even care about this?
+			console.log(
+				`[Gateway] Invalid sharding from ${user_id}: ${identify.shard}`,
+			);
+			return this.close(CLOSECODES.Invalid_shard);
+		}
 	}
-	this.user_id = decoded.id;
-	const session_id = this.session_id;
 
-	const [user, read_states, members, recipients, session, application] =
+	// Generate a new gateway session ( id is already made, just save it in db )
+	const session = Session.create({
+		user_id: this.user_id,
+		session_id: this.session_id,
+		status: identify.presence?.status || "online",
+		client_info: {
+			client: identify.properties?.$device,
+			os: identify.properties?.os,
+			version: 0,
+		},
+		activities: identify.presence?.activities, // TODO: validation
+	});
+
+	// Get from database:
+	// * the current user,
+	// * the users read states
+	// * guild members for this user
+	// * recipients ( dm channels )
+	// * the bot application, if it exists
+	const [, user, application, read_states, members, recipients] =
 		await Promise.all([
+			session.save(),
+
+			// TODO: Refactor checkToken to allow us to skip this additional query
 			User.findOneOrFail({
 				where: { id: this.user_id },
 				relations: ["relationships", "relationships.to", "settings"],
 				select: [...PrivateUserProjection, "relationships"],
 			}),
-			ReadState.find({ where: { user_id: this.user_id } }),
+
+			Application.findOne({
+				where: { id: this.user_id },
+				select: ["id", "flags"],
+			}),
+
+			ReadState.find({
+				where: { user_id: this.user_id },
+				select: [
+					"id",
+					"channel_id",
+					"last_message_id",
+					"last_pin_timestamp",
+					"mention_count",
+				],
+			}),
+
 			Member.find({
 				where: { id: this.user_id },
-				select: MemberPrivateProjection,
+				select: {
+					// We only want some member props
+					...Object.fromEntries(
+						MemberPrivateProjection.map((x) => [x, true]),
+					),
+					settings: true, // guild settings
+					roles: { id: true }, // the full role is fetched from the `guild` relation
+
+					// TODO: we don't really need every property of
+					// guild channels, emoji, roles, stickers
+					// but we do want almost everything from guild.
+					// How do you do that without just enumerating the guild props?
+					guild: true,
+				},
 				relations: [
 					"guild",
 					"guild.channels",
 					"guild.emojis",
 					"guild.roles",
 					"guild.stickers",
-					"user",
 					"roles",
+
+					// For these entities, `user` is always just the logged in user we fetched above
+					// "user",
 				],
 			}),
+
 			Recipient.find({
 				where: { user_id: this.user_id, closed: false },
 				relations: [
@@ -106,220 +188,240 @@ export async function onIdentify(this: WebSocket, data: Payload) {
 					"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,
+				select: {
+					channel: {
+						id: true,
+						flags: true,
+						// is_spam: true,	// TODO
+						last_message_id: true,
+						last_pin_timestamp: true,
+						type: true,
+						icon: true,
+						name: true,
+						owner_id: true,
+						recipients: {
+							// we don't actually need this ID or any other information about the recipient info,
+							// but typeorm does not select anything from the users relation of recipients unless we select
+							// at least one column.
+							id: true,
+							// We only want public user data for each dm channel
+							user: Object.fromEntries(
+								PublicUserProjection.map((x) => [x, true]),
+							),
+						},
+					},
 				},
-				activities: [],
-			}).save(),
-			Application.findOne({ where: { id: this.user_id } }),
+			}),
 		]);
 
-	if (!user) return this.close(CLOSECODES.Authentication_failed);
+	// We forgot to migrate user settings from the JSON column of `users`
+	// to the `user_settings` table theyre in now,
+	// so for instances that migrated, users may not have a `user_settings` row.
 	if (!user.settings) {
 		user.settings = new UserSettings();
 		await user.settings.save();
 	}
 
-	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);
-		}
-	}
-	let users: PublicUser[] = [];
-
-	const merged_members = members.map((x: Member) => {
+	// Generate merged_members
+	const merged_members = members.map((x) => {
 		return [
 			{
 				...x,
 				roles: x.roles.map((x) => x.id),
+
+				// add back user, which we don't fetch from db
+				// TODO: For guild profiles, this may need to be changed.
+				// TODO: The only field required in the user prop is `id`,
+				// but our types are annoying so I didn't bother.
+				user: user.toPublicUser(),
+
+				guild: {
+					id: x.guild.id,
+				},
 				settings: undefined,
-				guild: undefined,
 			},
 		];
-	}) as PublicMember[][];
-	// TODO: This type is bad.
-	let guilds: Partial<Guild>[] = members.map((x) => ({
-		...x.guild,
-		joined_at: x.joined_at,
-	}));
+	});
 
-	const pending_guilds: typeof guilds = [];
-	if (user.bot)
-		guilds = guilds.map((guild) => {
-			pending_guilds.push(guild);
-			return { id: guild.id, unavailable: true };
+	// Populated with guilds 'unavailable' currently
+	// Just for bots
+	const pending_guilds: Guild[] = [];
+
+	// Generate guilds list ( make them unavailable if user is bot )
+	const guilds: GuildOrUnavailable[] = members.map((member) => {
+		// Some Discord libraries do `'blah' in object` instead of
+		// checking if the type is correct
+		member.guild.roles.forEach((role) => {
+			for (const key in role) {
+				// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+				//@ts-ignore
+				if (!role[key]) role[key] = undefined;
+			}
 		});
 
-	// TODO: Rewrite this. Perhaps a DTO?
-	const user_guild_settings_entries = members.map((x) => ({
-		...DefaultUserGuildSettings,
-		...x.settings,
-		guild_id: x.guild.id,
-		channel_overrides: Object.entries(
-			x.settings.channel_overrides ?? {},
-		).map((y) => ({
-			...y[1],
-			channel_id: y[0],
-		})),
-	})) as unknown as UserGuildSettings[];
-
-	const channels = recipients.map((x) => {
-		// eslint-disable-next-line @typescript-eslint/ban-ts-comment
-		//@ts-ignore
-		x.channel.recipients = x.channel.recipients.map((x) =>
-			x.user.toPublicUser(),
-		);
-		//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,
-			);
+		// filter guild channels we don't have permission to view
+		// TODO: check if this causes issues when the user is granted other roles?
+		member.guild.channels = member.guild.channels.filter((channel) => {
+			const perms = Permissions.finalPermission({
+				user: { id: member.id, roles: member.roles.map((x) => x.id) },
+				guild: member.guild,
+				channel,
+			});
+
+			return perms.has("VIEW_CHANNEL");
+		});
+
+		if (user.bot) {
+			pending_guilds.push(member.guild);
+			return { id: member.guild.id, unavailable: true };
 		}
-		return x.channel;
-	});
 
-	for (const 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,
-			premium_type: user.premium_type,
-			accent_color: related_user.accent_color,
+		return {
+			...member.guild.toJSON(),
+			joined_at: member.joined_at,
 		};
-		users.push(public_related_user);
-	}
+	});
+
+	// Generate user_guild_settings
+	const user_guild_settings_entries: ReadyUserGuildSettingsEntries[] =
+		members.map((x) => ({
+			...DefaultUserGuildSettings,
+			...x.settings,
+			guild_id: x.guild_id,
+			channel_overrides: Object.entries(
+				x.settings.channel_overrides ?? {},
+			).map((y) => ({
+				...y[1],
+				channel_id: y[0],
+			})),
+		}));
 
-	setImmediate(async () => {
-		// run in seperate "promise context" because ready payload is not dependent on those events
+	// Popultaed with users from private channels, relationships.
+	// Uses a set to dedupe for us.
+	const users: Set<PublicUser> = new Set();
+
+	// Generate dm channels from recipients list. Append recipients to `users` list
+	const channels = recipients
+		.filter(({ channel }) => channel.isDm())
+		.map((r) => {
+			// TODO: fix the types of Recipient
+			// Their channels are only ever private (I think) and thus are always DM channels
+			const channel = r.channel as DMChannel;
+
+			// Remove ourself from the list of other users in dm channel
+			channel.recipients = channel.recipients.filter(
+				(recipient) => recipient.user.id !== this.user_id,
+			);
+
+			const channelUsers = channel.recipients?.map((recipient) =>
+				recipient.user.toPublicUser(),
+			);
+
+			if (channelUsers && channelUsers.length > 0)
+				channelUsers.forEach((user) => users.add(user));
+
+			return {
+				id: channel.id,
+				flags: channel.flags,
+				last_message_id: channel.last_message_id,
+				type: channel.type,
+				recipients: channelUsers || [],
+				is_spam: false, // TODO
+			};
+		});
+
+	// From user relationships ( friends ), also append to `users` list
+	user.relationships.forEach((x) => users.add(x.to.toPublicUser()));
+
+	// Send SESSIONS_REPLACE and PRESENCE_UPDATE
+	const allSessions = (
+		await Session.find({
+			where: { user_id: this.user_id },
+			select: PrivateSessionProjection,
+		})
+	).map((x) => ({
+		// TODO how is active determined?
+		// in our lazy request impl, we just pick the 'most relevant' session
+		active: x.session_id == session.session_id,
+		activities: x.activities,
+		client_info: x.client_info,
+		// TODO: what does all mean?
+		session_id: x.session_id == session.session_id ? "all" : x.session_id,
+		status: x.status,
+	}));
+
+	Promise.all([
 		emitEvent({
 			event: "SESSIONS_REPLACE",
 			user_id: this.user_id,
-			data: await Session.find({
-				where: { user_id: this.user_id },
-				select: PrivateSessionProjection,
-			}),
-		} as SessionsReplace);
+			data: allSessions,
+		} as SessionsReplace),
 		emitEvent({
 			event: "PRESENCE_UPDATE",
 			user_id: this.user_id,
 			data: {
-				user: await User.getPublicUser(this.user_id),
+				user: user.toPublicUser(),
 				activities: session.activities,
-				client_status: session?.client_info,
+				client_status: session.client_info,
 				status: session.status,
 			},
-		} as PresenceUpdateEvent);
-	});
+		} as PresenceUpdateEvent),
+	]);
 
-	read_states.forEach((s: Partial<ReadState>) => {
-		s.id = s.channel_id;
-		delete s.user_id;
-		delete s.channel_id;
-	});
+	// Build READY
 
-	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,
-	};
+	read_states.forEach((x) => {
+		x.id = x.channel_id;
+	});
 
 	const d: ReadyEventData = {
 		v: 9,
-		application: {
-			id: application?.id ?? "",
-			flags: application?.flags ?? 0,
-		}, //TODO: check this code!
-		user: privateUser,
+		application: application
+			? { id: application.id, flags: application.flags }
+			: undefined,
+		user: user.toPrivateUser(),
 		user_settings: user.settings,
-		// eslint-disable-next-line @typescript-eslint/ban-ts-comment
-		// @ts-ignore
-		guilds: guilds.map((x: Guild & { joined_at: Date }) => {
-			return {
-				...new ReadyGuildDTO(x).toJSON(),
-				guild_hashes: {},
-				joined_at: x.joined_at,
-			};
-		}),
-		guild_experiments: [], // TODO
-		geo_ordered_rtc_regions: [], // TODO
+		guilds: guilds.map((x) => new ReadyGuildDTO(x).toJSON()),
 		relationships: user.relationships.map((x) => x.toPublicRelationship()),
 		read_state: {
 			entries: read_states,
 			partial: false,
+			// TODO: what is this magic number?
+			// Isn't `version` referring to the number of changes since this obj was created?
+			// Why do we send this specific version?
 			version: 304128,
 		},
 		user_guild_settings: {
 			entries: user_guild_settings_entries,
-			partial: false, // TODO partial
-			version: 642,
+			partial: false,
+			version: 642, // TODO: see above
 		},
 		private_channels: channels,
-		session_id: session_id,
-		analytics_token: "", // TODO
-		connected_accounts: [], // TODO
+		session_id: this.session_id,
+		country_code: user.settings.locale, // TODO: do ip analysis instead
+		users: Array.from(users),
+		merged_members: merged_members,
+		sessions: allSessions,
+
 		consents: {
 			personalization: {
 				consented: false, // TODO
 			},
 		},
-		country_code: user.settings.locale,
-		friend_suggestion_count: 0, // TODO
-		// eslint-disable-next-line @typescript-eslint/ban-ts-comment
-		// @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:
+		experiments: [],
+		guild_join_requests: [],
+		connected_accounts: [],
+		guild_experiments: [],
+		geo_ordered_rtc_regions: [],
+		api_code_version: 1,
+		friend_suggestion_count: 0,
+		analytics_token: "",
+		tutorial: null,
+		resume_gateway_url:
+			Config.get().gateway.endpointClient ||
+			Config.get().gateway.endpointPublic ||
+			"ws://127.0.0.1:3001",
+		session_type: "normal", // TODO
 
 		// lol hack whatever
 		required_action:
@@ -328,7 +430,7 @@ export async function onIdentify(this: WebSocket, data: Payload) {
 				: undefined,
 	};
 
-	// TODO: send real proper data structure
+	// Send READY
 	await Send(this, {
 		op: OPCODES.Dispatch,
 		t: EVENTEnum.Ready,
@@ -336,23 +438,23 @@ export async function onIdentify(this: WebSocket, data: Payload) {
 		d,
 	});
 
+	// If we're a bot user, send GUILD_CREATE for each unavailable guild
 	await Promise.all(
-		pending_guilds.map((guild) =>
+		pending_guilds.map((x) =>
 			Send(this, {
 				op: OPCODES.Dispatch,
 				t: EVENTEnum.GuildCreate,
 				s: this.sequence++,
-				d: guild,
-			})?.catch(console.error),
+				d: x,
+			})?.catch((e) =>
+				console.error(`[Gateway] error when sending bot guilds`, e),
+			),
 		),
 	);
 
 	//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/util/dtos/ReadyGuildDTO.ts b/src/util/dtos/ReadyGuildDTO.ts
index 97e6931f..e91248d2 100644
--- a/src/util/dtos/ReadyGuildDTO.ts
+++ b/src/util/dtos/ReadyGuildDTO.ts
@@ -16,7 +16,46 @@
 	along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */
 
-import { Channel, Emoji, Guild, Member, Role, Sticker } from "../entities";
+import {
+	Channel,
+	ChannelOverride,
+	ChannelType,
+	Emoji,
+	Guild,
+	Member,
+	PublicUser,
+	Role,
+	Sticker,
+	UserGuildSettings,
+} from "../entities";
+
+// TODO: this is not the best place for this type
+export type ReadyUserGuildSettingsEntries = Omit<
+	UserGuildSettings,
+	"channel_overrides"
+> & {
+	channel_overrides: (ChannelOverride & { channel_id: string })[];
+};
+
+// TODO: probably should move somewhere else
+export interface ReadyPrivateChannel {
+	id: string;
+	flags: number;
+	is_spam: boolean;
+	last_message_id?: string;
+	recipients: PublicUser[];
+	type: ChannelType.DM | ChannelType.GROUP_DM;
+}
+
+export type GuildOrUnavailable =
+	| { id: string; unavailable: boolean }
+	| (Guild & { joined_at?: Date; unavailable: boolean });
+
+const guildIsAvailable = (
+	guild: GuildOrUnavailable,
+): guild is Guild & { joined_at: Date; unavailable: false } => {
+	return guild.unavailable == false;
+};
 
 export interface IReadyGuildDTO {
 	application_command_counts?: { 1: number; 2: number; 3: number }; // ????????????
@@ -64,6 +103,8 @@ export interface IReadyGuildDTO {
 	stickers: Sticker[];
 	threads: unknown[];
 	version: string;
+	guild_hashes: unknown;
+	unavailable: boolean;
 }
 
 export class ReadyGuildDTO implements IReadyGuildDTO {
@@ -112,8 +153,17 @@ export class ReadyGuildDTO implements IReadyGuildDTO {
 	stickers: Sticker[];
 	threads: unknown[];
 	version: string;
+	guild_hashes: unknown;
+	unavailable: boolean;
+	joined_at: Date;
+
+	constructor(guild: GuildOrUnavailable) {
+		if (!guildIsAvailable(guild)) {
+			this.id = guild.id;
+			this.unavailable = true;
+			return;
+		}
 
-	constructor(guild: Guild) {
 		this.application_command_counts = {
 			1: 5,
 			2: 2,
@@ -163,6 +213,8 @@ export class ReadyGuildDTO implements IReadyGuildDTO {
 		this.stickers = guild.stickers;
 		this.threads = [];
 		this.version = "1"; // ??????
+		this.guild_hashes = {};
+		this.joined_at = guild.joined_at;
 	}
 
 	toJSON() {
diff --git a/src/util/entities/Channel.ts b/src/util/entities/Channel.ts
index 1f128713..7c0828eb 100644
--- a/src/util/entities/Channel.ts
+++ b/src/util/entities/Channel.ts
@@ -482,3 +482,10 @@ export enum ChannelPermissionOverwriteType {
 	member = 1,
 	group = 2,
 }
+
+export interface DMChannel extends Omit<Channel, "type" | "recipients"> {
+	type: ChannelType.DM | ChannelType.GROUP_DM;
+	recipients: Recipient[];
+
+	// TODO: probably more props
+}
diff --git a/src/util/entities/User.ts b/src/util/entities/User.ts
index f99a85e7..0ed88c15 100644
--- a/src/util/entities/User.ts
+++ b/src/util/entities/User.ts
@@ -280,6 +280,15 @@ export class User extends BaseClass {
 		return user as PublicUser;
 	}
 
+	toPrivateUser() {
+		// eslint-disable-next-line @typescript-eslint/no-explicit-any
+		const user: any = {};
+		PrivateUserProjection.forEach((x) => {
+			user[x] = this[x];
+		});
+		return user as UserPrivate;
+	}
+
 	static async getPublicUser(user_id: string, opts?: FindOneOptions<User>) {
 		return await User.findOneOrFail({
 			where: { id: user_id },
diff --git a/src/util/interfaces/Event.ts b/src/util/interfaces/Event.ts
index c3bfbf9b..492821f1 100644
--- a/src/util/interfaces/Event.ts
+++ b/src/util/interfaces/Event.ts
@@ -40,6 +40,9 @@ import {
 	UserSettings,
 	IReadyGuildDTO,
 	ReadState,
+	UserPrivate,
+	ReadyUserGuildSettingsEntries,
+	ReadyPrivateChannel,
 } from "@fosscord/util";
 
 export interface Event {
@@ -68,20 +71,8 @@ export interface PublicRelationship {
 
 export interface ReadyEventData {
 	v: number;
-	user: PublicUser & {
-		mobile: boolean;
-		desktop: boolean;
-		email: string | undefined;
-		flags: string;
-		mfa_enabled: boolean;
-		nsfw_allowed: boolean;
-		phone: string | undefined;
-		premium: boolean;
-		premium_type: number;
-		verified: boolean;
-		bot: boolean;
-	};
-	private_channels: Channel[]; // this will be empty for bots
+	user: UserPrivate;
+	private_channels: ReadyPrivateChannel[]; // this will be empty for bots
 	session_id: string; // resuming
 	guilds: IReadyGuildDTO[];
 	analytics_token?: string;
@@ -115,7 +106,7 @@ export interface ReadyEventData {
 		version: number;
 	};
 	user_guild_settings?: {
-		entries: UserGuildSettings[];
+		entries: ReadyUserGuildSettingsEntries[];
 		version: number;
 		partial: boolean;
 	};
@@ -127,6 +118,16 @@ export interface ReadyEventData {
 	// probably all users who the user is in contact with
 	users?: PublicUser[];
 	sessions: unknown[];
+	api_code_version: number;
+	tutorial: number | null;
+	resume_gateway_url: string;
+	session_type: string;
+	required_action?:
+		| "REQUIRE_VERIFIED_EMAIL"
+		| "REQUIRE_VERIFIED_PHONE"
+		| "REQUIRE_CAPTCHA" // TODO: allow these to be triggered
+		| "TOS_UPDATE_ACKNOWLEDGMENT"
+		| "AGREEMENTS";
 }
 
 export interface ReadyEvent extends Event {