/* Spacebar: A FOSS re-implementation and extension of the Discord.com backend. Copyright (C) 2023 Spacebar and Spacebar Contributors This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . */ import { CLOSECODES, Capabilities, OPCODES, Payload, Send, WebSocket, setupListener, } from "@spacebar/gateway"; import { Application, Config, DMChannel, DefaultUserGuildSettings, EVENTEnum, Guild, GuildOrUnavailable, IdentifySchema, Intents, Member, MemberPrivateProjection, OPCodes, Permissions, PresenceUpdateEvent, PrivateSessionProjection, PrivateUserProjection, PublicUser, PublicUserProjection, ReadState, ReadyEventData, ReadyGuildDTO, ReadyUserGuildSettingsEntries, Recipient, Session, SessionsReplace, UserSettings, checkToken, emitEvent, getDatabase, } from "@spacebar/util"; import { check } from "./instanceOf"; // TODO: user sharding // TODO: check privileged intents, if defined in the config const tryGetUserFromToken = async (...args: Parameters) => { try { return (await checkToken(...args)).user; } catch (e) { return null; } }; export async function onIdentify(this: WebSocket, data: Payload) { if (this.user_id) { // we've already identified return this.close(CLOSECODES.Already_authenticated); } clearTimeout(this.readyTimeout); // Check payload matches schema check.call(this, IdentifySchema, data.d); const identify: IdentifySchema = data.d; this.capabilities = new Capabilities(identify.capabilities || 0); const user = await tryGetUserFromToken(identify.token, { relations: ["relationships", "relationships.to", "settings"], select: [...PrivateUserProjection, "relationships"], }); if (!user) 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 right now? console.log( `[Gateway] Invalid sharding from ${user.id}: ${identify.shard}`, ); return this.close(CLOSECODES.Invalid_shard); } } // 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 users read states // * guild members for this user // * recipients ( dm channels ) // * the bot application, if it exists const [, application, read_states, members, recipients] = await Promise.all( [ session.save(), 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: { // 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: Object.fromEntries( // eslint-disable-next-line @typescript-eslint/no-non-null-assertion getDatabase()! .getMetadata(Guild) .columns.map((x) => [x.propertyName, true]), ), }, relations: [ "guild", "guild.channels", "guild.emojis", "guild.roles", "guild.stickers", "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: [ "channel", "channel.recipients", "channel.recipients.user", ], 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]), ), }, }, }, }), ], ); // 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(); } // 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, }, ]; }); // 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) => { // 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"); }) .map((channel) => { channel.position = member.guild.channel_ordering.indexOf( channel.id, ); return channel; }) .sort((a, b) => a.position - b.position); if (user.bot) { pending_guilds.push(member.guild); return { id: member.guild.id, unavailable: true }; } return { ...member.guild.toJSON(), joined_at: member.joined_at, threads: [], }; }); // 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], })), })); // Popultaed with users from private channels, relationships. // Uses a set to dedupe for us. const users: Set = 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, session_id: x.session_id, // TODO: discord.com sends 'all', what is that??? status: x.status, })); Promise.all([ emitEvent({ event: "SESSIONS_REPLACE", user_id: this.user_id, data: allSessions, } as SessionsReplace), emitEvent({ event: "PRESENCE_UPDATE", user_id: this.user_id, data: { user: user.toPublicUser(), activities: session.activities, client_status: session.client_info, status: session.status, }, } as PresenceUpdateEvent), ]); // Build READY read_states.forEach((x) => { x.id = x.channel_id; }); const d: ReadyEventData = { v: 9, application: application ? { id: application.id, flags: application.flags } : undefined, user: user.toPrivateUser(), user_settings: user.settings, guilds: this.capabilities.has(Capabilities.FLAGS.CLIENT_STATE_V2) ? guilds.map((x) => new ReadyGuildDTO(x).toJSON()) : guilds, relationships: user.relationships.map((x) => x.toPublicRelationship()), read_state: { entries: read_states, partial: false, version: 0, // TODO }, user_guild_settings: { entries: user_guild_settings_entries, partial: false, version: 0, // TODO }, private_channels: channels, 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, resume_gateway_url: Config.get().gateway.endpointClient || Config.get().gateway.endpointPublic || "ws://127.0.0.1:3001", // lol hack whatever required_action: Config.get().login.requireVerification && !user.verified ? "REQUIRE_VERIFIED_EMAIL" : undefined, consents: { personalization: { consented: false, // 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, session_type: "normal", // TODO auth_session_id_hash: "", // TODO }; // Send READY await Send(this, { op: OPCODES.Dispatch, t: EVENTEnum.Ready, s: this.sequence++, d, }); // If we're a bot user, send GUILD_CREATE for each unavailable guild await Promise.all( pending_guilds.map((x) => Send(this, { op: OPCODES.Dispatch, t: EVENTEnum.GuildCreate, s: this.sequence++, d: x, })?.catch((e) => console.error(`[Gateway] error when sending bot guilds`, e), ), ), ); // TODO: ready supplemental await Send(this, { op: OPCodes.DISPATCH, t: EVENTEnum.ReadySupplemental, s: this.sequence++, d: { merged_presences: { guilds: [], friends: [], }, // these merged members seem to be all users currently in vc in your guilds merged_members: [], lazy_private_channels: [], guilds: [], // { voice_states: [], id: string, embedded_activities: [] } // embedded_activities are users currently in an activity? disclose: [], // Config.get().general.uniqueUsernames ? ["pomelo"] : [] }, }); //TODO send GUILD_MEMBER_LIST_UPDATE //TODO send VOICE_STATE_UPDATE to let the client know if another device is already connected to a voice channel await setupListener.call(this); }