diff --git a/assets/openapi.json b/assets/openapi.json index 2af0a2c7..68adb455 100644 Binary files a/assets/openapi.json and b/assets/openapi.json differ diff --git a/assets/schemas.json b/assets/schemas.json index 05a63902..a4da2538 100644 Binary files a/assets/schemas.json and b/assets/schemas.json differ diff --git a/src/gateway/opcodes/Identify.ts b/src/gateway/opcodes/Identify.ts index c535cd45..94320eee 100644 --- a/src/gateway/opcodes/Identify.ts +++ b/src/gateway/opcodes/Identify.ts @@ -82,6 +82,7 @@ export async function onIdentify(this: WebSocket, data: Payload) { const identify: IdentifySchema = data.d; this.capabilities = new Capabilities(identify.capabilities || 0); + this.large_threshold = identify.large_threshold || 250; const user = await tryGetUserFromToken(identify.token, { relations: ["relationships", "relationships.to", "settings"], diff --git a/src/gateway/opcodes/RequestGuildMembers.ts b/src/gateway/opcodes/RequestGuildMembers.ts index c84bf893..3381caed 100644 --- a/src/gateway/opcodes/RequestGuildMembers.ts +++ b/src/gateway/opcodes/RequestGuildMembers.ts @@ -17,6 +17,7 @@ */ import { + getDatabase, getPermission, GuildMembersChunkEvent, Member, @@ -29,51 +30,103 @@ import { check } from "./instanceOf"; import { FindManyOptions, In, Like } from "typeorm"; export async function onRequestGuildMembers(this: WebSocket, { d }: Payload) { - // TODO: check data + // Schema validation can only accept either string or array, so transforming it here to support both + if (!d.guild_id) throw new Error('"guild_id" is required'); + d.guild_id = Array.isArray(d.guild_id) ? d.guild_id[0] : d.guild_id; + + if (d.user_ids && !Array.isArray(d.user_ids)) d.user_ids = [d.user_ids]; + check.call(this, RequestGuildMembersSchema, d); - const { guild_id, query, presences, nonce } = - d as RequestGuildMembersSchema; - let { limit, user_ids } = d as RequestGuildMembersSchema; + const { query, presences, nonce } = d as RequestGuildMembersSchema; + let { limit, user_ids, guild_id } = d as RequestGuildMembersSchema; + + guild_id = guild_id as string; + user_ids = user_ids as string[] | undefined; if ("query" in d && (!limit || Number.isNaN(limit))) throw new Error('"query" requires "limit" to be set'); if ("query" in d && user_ids) throw new Error('"query" and "user_ids" are mutually exclusive'); - if (user_ids && !Array.isArray(user_ids)) user_ids = [user_ids]; - user_ids = user_ids as string[] | undefined; // TODO: Configurable limit? if ((query || (user_ids && user_ids.length > 0)) && (!limit || limit > 100)) limit = 100; - const permissions = await getPermission( - this.user_id, - Array.isArray(guild_id) ? guild_id[0] : guild_id, - ); + const permissions = await getPermission(this.user_id, guild_id); permissions.hasThrow("VIEW_CHANNEL"); - const whereQuery: FindManyOptions["where"] = {}; - if (query) { - whereQuery.user = { - username: Like(query + "%"), - }; - } else if (user_ids && user_ids.length > 0) { - whereQuery.id = In(user_ids); - } + const memberCount = await Member.count({ + where: { + guild_id, + }, + }); const memberFind: FindManyOptions = { where: { - ...whereQuery, - guild_id: Array.isArray(guild_id) ? guild_id[0] : guild_id, + guild_id, }, relations: ["user", "roles"], }; if (limit) memberFind.take = Math.abs(Number(limit || 100)); - const members = await Member.find(memberFind); + + let members: Member[] = []; + + if (memberCount > 75000) { + // since we dont have voice channels yet, just return the connecting users member object + members = await Member.find({ + ...memberFind, + where: { + ...memberFind.where, + user: { + id: this.user_id, + }, + }, + }); + } else if (memberCount > this.large_threshold) { + // find all members who are online, have a role, have a nickname, or are in a voice channel, as well as respecting the query and user_ids + const db = getDatabase(); + if (!db) throw new Error("Database not initialized"); + const repo = db.getRepository(Member); + const q = repo + .createQueryBuilder("member") + .where("member.guild_id = :guild_id", { guild_id }) + .leftJoinAndSelect("member.roles", "role") + .leftJoinAndSelect("member.user", "user") + .leftJoinAndSelect("user.sessions", "session") + .andWhere( + "',' || member.roles || ',' NOT LIKE :everyoneRoleIdList", + { everyoneRoleIdList: "%," + guild_id + ",%" }, + ) + .andWhere("session.status != 'offline'") + .addOrderBy("user.username", "ASC") + .limit(memberFind.take); + + if (query && query != "") { + q.andWhere(`user.username ILIKE :query`, { + query: `${query}%`, + }); + } else if (user_ids) { + q.andWhere(`user.id IN (:...user_ids)`, { user_ids }); + } + + members = await q.getMany(); + } else { + if (query) { + // @ts-expect-error memberFind.where is very much defined + memberFind.where.user = { + username: Like(query + "%"), + }; + } else if (user_ids && user_ids.length > 0) { + // @ts-expect-error memberFind.where is still very much defined + memberFind.where.id = In(user_ids); + } + + members = await Member.find(memberFind); + } const baseData = { - guild_id: Array.isArray(guild_id) ? guild_id[0] : guild_id, + guild_id, nonce, }; @@ -114,7 +167,17 @@ export async function onRequestGuildMembers(this: WebSocket, { d }: Payload) { }); } - if (notFound.length > 0) chunks[0].not_found = notFound; + if (notFound.length > 0) { + if (chunks.length == 0) + chunks.push({ + ...baseData, + members: [], + presences: presences ? [] : undefined, + chunk_index: 0, + chunk_count: 1, + }); + chunks[0].not_found = notFound; + } chunks.forEach((chunk) => { Send(this, { diff --git a/src/gateway/util/WebSocket.ts b/src/gateway/util/WebSocket.ts index 833756ff..8cfc5e08 100644 --- a/src/gateway/util/WebSocket.ts +++ b/src/gateway/util/WebSocket.ts @@ -1,17 +1,17 @@ /* 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 . */ @@ -43,4 +43,5 @@ export interface WebSocket extends WS { listen_options: ListenEventOpts; capabilities?: Capabilities; // client?: Client; + large_threshold: number; } diff --git a/src/util/schemas/RequestGuildMembersSchema.ts b/src/util/schemas/RequestGuildMembersSchema.ts index 6909ba85..9e60d26e 100644 --- a/src/util/schemas/RequestGuildMembersSchema.ts +++ b/src/util/schemas/RequestGuildMembersSchema.ts @@ -26,7 +26,7 @@ export interface RequestGuildMembersSchema { } export const RequestGuildMembersSchema = { - guild_id: [] as string | string[], + guild_id: "" as string | string[], $query: String, $limit: Number, $presences: Boolean,