User presence/status

This commit is contained in:
Flam3rboy 2021-10-17 00:39:54 +02:00
parent 116e17f6d0
commit 68053418f3
9 changed files with 193 additions and 50 deletions

View File

@ -1,13 +1,46 @@
import { WebSocket } from "@fosscord/gateway"; 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) { export async function Close(this: WebSocket, code: number, reason: string) {
console.log("[WebSocket] closed", code, reason); 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.heartbeatTimeout) clearTimeout(this.heartbeatTimeout);
if (this.readyTimeout) clearTimeout(this.readyTimeout); if (this.readyTimeout) clearTimeout(this.readyTimeout);
this.deflate?.close(); this.deflate?.close();
this.removeAllListeners(); 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);
}
} }

View File

@ -8,7 +8,6 @@ import { Close } from "./Close";
import { Message } from "./Message"; import { Message } from "./Message";
import { createDeflate } from "zlib"; import { createDeflate } from "zlib";
import { URL } from "url"; import { URL } from "url";
import { Session } from "@fosscord/util";
var erlpack: any; var erlpack: any;
try { try {
erlpack = require("@yukikaze-bot/erlpack"); erlpack = require("@yukikaze-bot/erlpack");
@ -57,6 +56,7 @@ export async function Connection(
} }
socket.events = {}; socket.events = {};
socket.member_events = {};
socket.permissions = {}; socket.permissions = {};
socket.sequence = 0; socket.sequence = 0;

View File

@ -1,46 +1,55 @@
import { import {
EVENTEnum,
EventOpts,
getPermission, getPermission,
listenEvent,
Member, Member,
PublicMemberProjection,
Role, Role,
} from "@fosscord/util"; } from "@fosscord/util";
import { LazyRequest } from "../schema/LazyRequest"; import { LazyRequest } from "../schema/LazyRequest";
import { Send } from "../util/Send"; import { Send } from "../util/Send";
import { OPCODES } from "../util/Constants"; import { OPCODES } from "../util/Constants";
import { WebSocket, Payload } from "@fosscord/gateway"; import { WebSocket, Payload, handlePresenceUpdate } from "@fosscord/gateway";
import { check } from "./instanceOf"; import { check } from "./instanceOf";
import "missing-native-js-functions"; 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: config: to list all members (even those who are offline) sorted by role, or just those who are online
// TODO: rewrite typeorm // TODO: rewrite typeorm
export async function onLazyRequest(this: WebSocket, { d }: Payload) { async function getMembers(guild_id: string, range: [number, number]) {
// TODO: check data if (!Array.isArray(range) || range.length !== 2) {
check.call(this, LazyRequest, d); throw new Error("range is not a valid array");
const { guild_id, typing, channels, activities } = d as LazyRequest; }
// TODO: wait for typeorm to implement ordering for .find queries https://github.com/typeorm/typeorm/issues/2620
const permissions = await getPermission(this.user_id, guild_id); let members = await getRepository(Member)
permissions.hasThrow("VIEW_CHANNEL"); .createQueryBuilder("member")
.where("member.guild_id = :guild_id", { guild_id })
var members = await Member.find({ .leftJoinAndSelect("member.roles", "role")
where: { guild_id: guild_id }, .leftJoinAndSelect("member.user", "user")
relations: ["roles", "user"], .leftJoinAndSelect("user.sessions", "session")
select: PublicMemberProjection, .addSelect(
}); "CASE WHEN session.status = 'offline' THEN 0 ELSE 1 END",
"_status"
const roles = await Role.find({ )
where: { guild_id: guild_id }, .orderBy("role.position", "DESC")
order: { .addOrderBy("_status", "DESC")
position: "DESC", .addOrderBy("user.username", "ASC")
}, .offset(Number(range[0]) || 0)
}); .limit(Number(range[1]) || 100)
.getMany();
const groups = [] as any[]; const groups = [] as any[];
var member_count = 0;
const items = []; 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 // @ts-ignore
const [role_members, other_members] = partition(members, (m: Member) => const [role_members, other_members] = partition(members, (m: Member) =>
m.roles.find((r) => r.id === role.id) m.roles.find((r) => r.id === role.id)
@ -54,35 +63,86 @@ export async function onLazyRequest(this: WebSocket, { d }: Payload) {
groups.push(group); groups.push(group);
for (const member of role_members) { 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({ items.push({
member: { member: {
...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; 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, { return Send(this, {
op: OPCODES.Dispatch, op: OPCODES.Dispatch,
s: this.sequence++, s: this.sequence++,
t: "GUILD_MEMBER_LIST_UPDATE", t: "GUILD_MEMBER_LIST_UPDATE",
d: { d: {
ops: [ ops: ops.map((x) => ({
{ items: x.items,
range: [0, 99],
op: "SYNC", op: "SYNC",
items, range: x.range,
}, })),
], online_count: member_count,
online_count: member_count, // TODO count online count
member_count, member_count,
id: "everyone", id: "everyone",
guild_id, guild_id,
groups, groups: ops
.map((x) => x.groups)
.flat()
.unique(),
}, },
}); });
} }

View File

@ -1,5 +1,25 @@
import { WebSocket, Payload } from "@fosscord/gateway"; 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) { export async function onPresenceUpdate(this: WebSocket, { d }: Payload) {
// return this.close(CLOSECODES.Unknown_error); 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);
} }

View File

@ -1,6 +1,6 @@
export interface LazyRequest { export interface LazyRequest {
guild_id: string; guild_id: string;
channels?: Record<string, [number, number]>; channels?: Record<string, [number, number][]>;
activities?: boolean; activities?: boolean;
threads?: boolean; threads?: boolean;
typing?: true; typing?: true;

View File

@ -17,4 +17,6 @@ export interface WebSocket extends WS {
sequence: number; sequence: number;
permissions: Record<string, Permissions>; permissions: Record<string, Permissions>;
events: Record<string, Function>; events: Record<string, Function>;
member_events: Record<string, Function>;
listen_options: any;
} }

View File

@ -27,7 +27,7 @@
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
/* Strict Type-Checking Options */ /* 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. */, "noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */,
"strictNullChecks": true /* Enable strict null checks. */, "strictNullChecks": true /* Enable strict null checks. */,
// "strictFunctionTypes": true, /* Enable strict checking of function types. */ // "strictFunctionTypes": true, /* Enable strict checking of function types. */

View File

@ -26,6 +26,22 @@ import { BaseClassWithoutId } from "./BaseClass";
import { Ban, PublicGuildRelations } from "."; import { Ban, PublicGuildRelations } from ".";
import { DiscordApiErrors } from "../util/Constants"; 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") @Entity("members")
@Index(["id", "guild_id"], { unique: true }) @Index(["id", "guild_id"], { unique: true })
export class Member extends BaseClassWithoutId { export class Member extends BaseClassWithoutId {
@ -81,7 +97,7 @@ export class Member extends BaseClassWithoutId {
@Column() @Column()
pending: boolean; pending: boolean;
@Column({ type: "simple-json" }) @Column({ type: "simple-json", select: false })
settings: UserGuildSettings; settings: UserGuildSettings;
@Column({ nullable: true }) @Column({ nullable: true })

View File

@ -1,6 +1,8 @@
import { User } from "./User"; import { User } from "./User";
import { BaseClass } from "./BaseClass"; import { BaseClass } from "./BaseClass";
import { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm"; 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 //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; user: User;
//TODO check, should be 32 char long hex string //TODO check, should be 32 char long hex string
@Column({ nullable: false }) @Column({ nullable: false, select: false })
session_id: string; session_id: string;
activities: []; //TODO @Column({ type: "simple-json", nullable: true })
activities: Activity[] = [];
// TODO client_status
@Column({ type: "simple-json", select: false }) @Column({ type: "simple-json", select: false })
client_info: { client_info: {
client: string; client: string;
@ -29,6 +33,14 @@ export class Session extends BaseClass {
version: number; version: number;
}; };
@Column({ nullable: false }) @Column({ nullable: false, type: "varchar" })
status: string; //TODO enum status: Status; //TODO enum
} }
export const PrivateSessionProjection: (keyof Session)[] = [
"user_id",
"session_id",
"activities",
"client_info",
"status",
];