Newer
Older
percord / src / gateway / opcodes / LazyRequest.ts
@MathMan05 MathMan05 on 29 Nov 8 KB don't show invis
/*
	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 <https://www.gnu.org/licenses/>.
*/

import { getDatabase, getPermission, listenEvent, Member, Role, Session, User, Presence, Channel, Permissions, arrayPartition } from "@spacebar/util";
import { WebSocket, Payload, handlePresenceUpdate, OPCODES, Send } from "@spacebar/gateway";
import murmur from "murmurhash-js/murmurhash3_gc";
import { check } from "./instanceOf";
import { LazyRequestSchema } from "@spacebar/schemas";

// 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

const getMostRelevantSession = (sessions: Session[]) => {
	const statusMap = {
		online: 0,
		idle: 1,
		dnd: 2,
		invisible: 3,
		offline: 4,
	};
	// sort sessions by relevance
	sessions = sessions.sort((a, b) => {
		return statusMap[a.status] - statusMap[b.status] + ((a.activities?.length ?? 0) - (b.activities?.length ?? 0)) * 2;
	});

	return sessions[0];
};

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");
	}

	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 IS NULL OR session.status = 'offline' OR session.status = 'invisible' THEN 0 ELSE 1 END", "_status")
				.orderBy("_status", "DESC")
				.addOrderBy("role.position", "DESC")
				.addOrderBy("user.username", "ASC")
				.offset(Number(range[0]) || 0)
				.limit(Number(range[1]) || 100)
				.getMany()) ?? [];
	} catch (e) {
		console.error(`LazyRequest`, e);
	}

	if (!members || !members.length) {
		return {
			items: [],
			groups: [],
			range: [],
			members: [],
		};
	}

	const groups = [];
	const items = [];
	const member_roles = [
		...new Map(
			members
				.map((m) => m.roles)
				.flat()
				.map((role) => [role.id, role] as [string, Role]),
		).values(),
	];
	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) {
		const [role_members, other_members] = arrayPartition(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 session: Session | undefined = getMostRelevantSession(member.user.sessions);

			// eslint-disable-next-line @typescript-eslint/ban-ts-comment
			// @ts-ignore
			if (session?.status == "unknown") {
				session.status = member?.user?.settings?.status || "online";
			}

			const item = {
				member: {
					...member,
					roles,
					user: member.user.toPublicUser(),
					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, settings: undefined } : undefined)).filter((x) => !!x),
	};
}

async function subscribeToMemberEvents(this: WebSocket, user_id: string) {
	if (this.events[user_id]) return false; // already subscribed as friend
	if (this.member_events[user_id]) return false; // already subscribed in member list
	this.member_events[user_id] = await listenEvent(user_id, handlePresenceUpdate.bind(this), this.listen_options);
	return true;
}

export async function onLazyRequest(this: WebSocket, { d }: Payload) {
	const startTime = Date.now();
	// TODO: check data
	check.call(this, LazyRequestSchema, d);
	const { guild_id, typing, channels, activities, members } = d as LazyRequestSchema;

	if (members) {
		// Client has requested a PRESENCE_UPDATE for specific member

		await Promise.all([
			members.map(async (x) => {
				if (!x) return;
				const didSubscribe = await subscribeToMemberEvents.call(this, x);
				if (!didSubscribe) return;

				// if we didn't subscribe just now, this is a new subscription
				// and we should send a PRESENCE_UPDATE immediately

				const sessions = await Session.find({ where: { user_id: x } });
				const session = getMostRelevantSession(sessions);
				// eslint-disable-next-line @typescript-eslint/ban-ts-comment
				// @ts-ignore
				if (session?.status == "unknown") session.status = "online";
				const user = (await User.getPublicUser(x)).toPublicUser(); // why is this needed?

				return Send(this, {
					op: OPCODES.Dispatch,
					s: this.sequence++,
					t: "PRESENCE_UPDATE",
					d: {
						user: user,
						activities: session?.activities || [],
						client_status: session?.client_status,
						status: session?.getPublicStatus() || "offline",
					} as Presence,
				});
			}),
		]);

		if (!channels) return;
	}

	if (!channels) throw new Error("Must provide channel ranges");

	const channel_id = Object.keys(channels || {})[0];
	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 as [number, number])));

	let list_id = "everyone";

	const channel = await Channel.findOneOrFail({
		where: { id: channel_id },
	});
	if (channel.permission_overwrites) {
		const perms: string[] = [];

		channel.permission_overwrites.forEach((overwrite) => {
			const { id, allow, deny } = overwrite;

			if (BigInt(allow) & Permissions.FLAGS.VIEW_CHANNEL) perms.push(`allow:${id}`);
			else if (BigInt(deny) & Permissions.FLAGS.VIEW_CHANNEL) perms.push(`deny:${id}`);
		});

		if (perms.length > 0) {
			list_id = murmur(perms.sort().join(",")).toString();
		}
	}

	// TODO: unsubscribe member_events that are not in op.members

	ops.forEach((op) => {
		op.members.forEach(async (member) => {
			if (!member?.user.id) return;
			return subscribeToMemberEvents.call(this, member.user.id);
		});
	});

	const groups = [...new Set(ops.map((x) => x.groups).flat())];

	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: list_id,
			guild_id,
			groups,
		},
	});

	console.log(`[Gateway] LAZY_REQUEST ${guild_id} ${channel_id} took ${Date.now() - startTime}ms`);
}