From 0ea7d5f35cba312545d56bee8abc878fe34383a0 Mon Sep 17 00:00:00 2001 From: Flam3rboy <34555296+Flam3rboy@users.noreply.github.com> Date: Sun, 17 Oct 2021 00:39:54 +0200 Subject: :sparkles: User presence/status --- gateway/src/events/Close.ts | 41 +++++++++-- gateway/src/events/Connection.ts | 2 +- gateway/src/opcodes/LazyRequest.ts | 132 ++++++++++++++++++++++++---------- gateway/src/opcodes/PresenceUpdate.ts | 24 ++++++- gateway/src/schema/LazyRequest.ts | 2 +- gateway/src/util/WebSocket.ts | 2 + gateway/tsconfig.json | 2 +- util/src/entities/Member.ts | 18 ++++- util/src/entities/Session.ts | 20 ++++-- 9 files changed, 193 insertions(+), 50 deletions(-) diff --git a/gateway/src/events/Close.ts b/gateway/src/events/Close.ts index 5c1bd292..5b7c512c 100644 --- a/gateway/src/events/Close.ts +++ b/gateway/src/events/Close.ts @@ -1,13 +1,46 @@ import { WebSocket } from "@fosscord/gateway"; -import { Session } from "@fosscord/util"; +import { + emitEvent, + PresenceUpdateEvent, + PrivateSessionProjection, + Session, + SessionsReplace, + User, +} from "@fosscord/util"; export async function Close(this: WebSocket, code: number, reason: string) { console.log("[WebSocket] closed", code, reason); - if (this.session_id) await Session.delete({ session_id: this.session_id }); if (this.heartbeatTimeout) clearTimeout(this.heartbeatTimeout); if (this.readyTimeout) clearTimeout(this.readyTimeout); - this.deflate?.close(); - this.removeAllListeners(); + + if (this.session_id) { + await Session.delete({ session_id: this.session_id }); + const sessions = await Session.find({ + where: { user_id: this.user_id }, + select: PrivateSessionProjection, + }); + await emitEvent({ + event: "SESSIONS_REPLACE", + user_id: this.user_id, + data: sessions, + } as SessionsReplace); + const session = sessions.first() || { + activities: [], + client_info: {}, + status: "offline", + }; + + await emitEvent({ + event: "PRESENCE_UPDATE", + user_id: this.user_id, + data: { + user: await User.getPublicUser(this.user_id), + activities: session.activities, + client_status: session?.client_info, + status: session.status, + }, + } as PresenceUpdateEvent); + } } diff --git a/gateway/src/events/Connection.ts b/gateway/src/events/Connection.ts index 9bb034f0..4954cd08 100644 --- a/gateway/src/events/Connection.ts +++ b/gateway/src/events/Connection.ts @@ -8,7 +8,6 @@ import { Close } from "./Close"; import { Message } from "./Message"; import { createDeflate } from "zlib"; import { URL } from "url"; -import { Session } from "@fosscord/util"; var erlpack: any; try { erlpack = require("@yukikaze-bot/erlpack"); @@ -57,6 +56,7 @@ export async function Connection( } socket.events = {}; + socket.member_events = {}; socket.permissions = {}; socket.sequence = 0; diff --git a/gateway/src/opcodes/LazyRequest.ts b/gateway/src/opcodes/LazyRequest.ts index f5fd561a..c304dfe7 100644 --- a/gateway/src/opcodes/LazyRequest.ts +++ b/gateway/src/opcodes/LazyRequest.ts @@ -1,46 +1,55 @@ import { + EVENTEnum, + EventOpts, getPermission, + listenEvent, Member, - PublicMemberProjection, Role, } from "@fosscord/util"; import { LazyRequest } from "../schema/LazyRequest"; import { Send } from "../util/Send"; import { OPCODES } from "../util/Constants"; -import { WebSocket, Payload } from "@fosscord/gateway"; +import { WebSocket, Payload, handlePresenceUpdate } from "@fosscord/gateway"; import { check } from "./instanceOf"; import "missing-native-js-functions"; +import { getRepository } from "typeorm"; +import "missing-native-js-functions"; -// TODO: check permission and only show roles/members that have access to this channel +// 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 -export async function onLazyRequest(this: WebSocket, { d }: Payload) { - // TODO: check data - check.call(this, LazyRequest, d); - const { guild_id, typing, channels, activities } = d as LazyRequest; - - const permissions = await getPermission(this.user_id, guild_id); - permissions.hasThrow("VIEW_CHANNEL"); - - var members = await Member.find({ - where: { guild_id: guild_id }, - relations: ["roles", "user"], - select: PublicMemberProjection, - }); +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"); + } + // TODO: wait for typeorm to implement ordering for .find queries https://github.com/typeorm/typeorm/issues/2620 - const roles = await Role.find({ - where: { guild_id: guild_id }, - order: { - position: "DESC", - }, - }); + let members = await 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( + "CASE WHEN session.status = 'offline' THEN 0 ELSE 1 END", + "_status" + ) + .orderBy("role.position", "DESC") + .addOrderBy("_status", "DESC") + .addOrderBy("user.username", "ASC") + .offset(Number(range[0]) || 0) + .limit(Number(range[1]) || 100) + .getMany(); const groups = [] as any[]; - var member_count = 0; const items = []; + const member_roles = members + .map((m) => m.roles) + .flat() + .unique((r) => r.id); - for (const role of roles) { + for (const role of member_roles) { // @ts-ignore const [role_members, other_members] = partition(members, (m: Member) => m.roles.find((r) => r.id === role.id) @@ -54,35 +63,86 @@ export async function onLazyRequest(this: WebSocket, { d }: Payload) { groups.push(group); for (const member of role_members) { - member.roles = member.roles.filter((x: Role) => x.id !== guild_id); + const roles = member.roles + .filter((x: Role) => x.id !== guild_id) + .map((x: Role) => x.id); + + const session = member.user.sessions.first(); + + // TODO: properly mock/hide offline/invisible status items.push({ member: { ...member, - roles: member.roles.map((x: Role) => x.id), + roles, + user: { ...member.user, sessions: undefined }, + presence: { + ...session, + activities: session?.activities || [], + user: { id: member.user.id }, + }, }, }); } members = other_members; - member_count += role_members.length; } + return { + items, + groups, + range, + members: items.map((x) => x.member).filter((x) => x), + }; +} + +export async function onLazyRequest(this: WebSocket, { d }: Payload) { + // TODO: check data + check.call(this, LazyRequest, d); + const { guild_id, typing, channels, activities } = d as LazyRequest; + + const channel_id = Object.keys(channels || {}).first(); + 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({ guild_id }); + const ops = await Promise.all(ranges.map((x) => getMembers(guild_id, x))); + + // TODO: unsubscribe member_events that are not in op.members + + ops.forEach((op) => { + op.members.forEach(async (member) => { + if (this.events[member.user.id]) return; // already subscribed as friend + if (this.member_events[member.user.id]) return; // already subscribed in member list + this.member_events[member.user.id] = await listenEvent( + member.user.id, + handlePresenceUpdate.bind(this), + this.listen_options + ); + }); + }); + return Send(this, { op: OPCODES.Dispatch, s: this.sequence++, t: "GUILD_MEMBER_LIST_UPDATE", d: { - ops: [ - { - range: [0, 99], - op: "SYNC", - items, - }, - ], - online_count: member_count, // TODO count online count + ops: ops.map((x) => ({ + items: x.items, + op: "SYNC", + range: x.range, + })), + online_count: member_count, member_count, id: "everyone", guild_id, - groups, + groups: ops + .map((x) => x.groups) + .flat() + .unique(), }, }); } diff --git a/gateway/src/opcodes/PresenceUpdate.ts b/gateway/src/opcodes/PresenceUpdate.ts index 53d7b9d2..415df6ee 100644 --- a/gateway/src/opcodes/PresenceUpdate.ts +++ b/gateway/src/opcodes/PresenceUpdate.ts @@ -1,5 +1,25 @@ import { WebSocket, Payload } from "@fosscord/gateway"; +import { emitEvent, PresenceUpdateEvent, Session, User } from "@fosscord/util"; +import { ActivitySchema } from "../schema/Activity"; +import { check } from "./instanceOf"; -export function onPresenceUpdate(this: WebSocket, data: Payload) { - // return this.close(CLOSECODES.Unknown_error); +export async function onPresenceUpdate(this: WebSocket, { d }: Payload) { + check.call(this, ActivitySchema, d); + const presence = d as ActivitySchema; + + await Session.update( + { session_id: this.session_id }, + { status: presence.status, activities: presence.activities } + ); + + await emitEvent({ + event: "PRESENCE_UPDATE", + user_id: this.user_id, + data: { + user: await User.getPublicUser(this.user_id), + activities: presence.activities, + client_status: {}, // TODO: + status: presence.status, + }, + } as PresenceUpdateEvent); } diff --git a/gateway/src/schema/LazyRequest.ts b/gateway/src/schema/LazyRequest.ts index 7c828ac6..1fe658bb 100644 --- a/gateway/src/schema/LazyRequest.ts +++ b/gateway/src/schema/LazyRequest.ts @@ -1,6 +1,6 @@ export interface LazyRequest { guild_id: string; - channels?: Record; + channels?: Record; activities?: boolean; threads?: boolean; typing?: true; diff --git a/gateway/src/util/WebSocket.ts b/gateway/src/util/WebSocket.ts index 49626b2a..e3313f40 100644 --- a/gateway/src/util/WebSocket.ts +++ b/gateway/src/util/WebSocket.ts @@ -17,4 +17,6 @@ export interface WebSocket extends WS { sequence: number; permissions: Record; events: Record; + member_events: Record; + listen_options: any; } diff --git a/gateway/tsconfig.json b/gateway/tsconfig.json index 2ad38f93..b6ae9455 100644 --- a/gateway/tsconfig.json +++ b/gateway/tsconfig.json @@ -27,7 +27,7 @@ // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ /* Strict Type-Checking Options */ - "strict": false /* Enable all strict type-checking options. */, + "strict": true /* Enable all strict type-checking options. */, "noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */, "strictNullChecks": true /* Enable strict null checks. */, // "strictFunctionTypes": true, /* Enable strict checking of function types. */ diff --git a/util/src/entities/Member.ts b/util/src/entities/Member.ts index 12b0b49a..0f7be2a7 100644 --- a/util/src/entities/Member.ts +++ b/util/src/entities/Member.ts @@ -26,6 +26,22 @@ import { BaseClassWithoutId } from "./BaseClass"; import { Ban, PublicGuildRelations } from "."; import { DiscordApiErrors } from "../util/Constants"; +export const MemberPrivateProjection: (keyof Member)[] = [ + "id", + "guild", + "guild_id", + "deaf", + "joined_at", + "last_message_id", + "mute", + "nick", + "pending", + "premium_since", + "roles", + "settings", + "user", +]; + @Entity("members") @Index(["id", "guild_id"], { unique: true }) export class Member extends BaseClassWithoutId { @@ -81,7 +97,7 @@ export class Member extends BaseClassWithoutId { @Column() pending: boolean; - @Column({ type: "simple-json" }) + @Column({ type: "simple-json", select: false }) settings: UserGuildSettings; @Column({ nullable: true }) diff --git a/util/src/entities/Session.ts b/util/src/entities/Session.ts index 7cc325f5..ac5313f1 100644 --- a/util/src/entities/Session.ts +++ b/util/src/entities/Session.ts @@ -1,6 +1,8 @@ import { User } from "./User"; import { BaseClass } from "./BaseClass"; import { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm"; +import { Status } from "../interfaces/Status"; +import { Activity } from "../interfaces/Activity"; //TODO we need to remove all sessions on server start because if the server crashes without closing websockets it won't delete them @@ -17,11 +19,13 @@ export class Session extends BaseClass { user: User; //TODO check, should be 32 char long hex string - @Column({ nullable: false }) + @Column({ nullable: false, select: false }) session_id: string; - activities: []; //TODO + @Column({ type: "simple-json", nullable: true }) + activities: Activity[] = []; + // TODO client_status @Column({ type: "simple-json", select: false }) client_info: { client: string; @@ -29,6 +33,14 @@ export class Session extends BaseClass { version: number; }; - @Column({ nullable: false }) - status: string; //TODO enum + @Column({ nullable: false, type: "varchar" }) + status: Status; //TODO enum } + +export const PrivateSessionProjection: (keyof Session)[] = [ + "user_id", + "session_id", + "activities", + "client_info", + "status", +]; -- cgit 1.4.1