diff options
-rw-r--r-- | src/gateway/opcodes/Identify.ts | 490 | ||||
-rw-r--r-- | src/util/dtos/ReadyGuildDTO.ts | 56 | ||||
-rw-r--r-- | src/util/entities/Channel.ts | 7 | ||||
-rw-r--r-- | src/util/entities/User.ts | 9 | ||||
-rw-r--r-- | src/util/interfaces/Event.ts | 31 |
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 { |