From 71b9dc55235904c65f3dfae11e61bca1e36fc41d Mon Sep 17 00:00:00 2001 From: Paul Makles Date: Fri, 7 Apr 2023 23:16:49 +0100 Subject: [PATCH] feat: implement store backed common classes --- .gitignore | 1 + package.json | 3 + pnpm-lock.yaml | 46 +++++++ src/Client.ts | 105 +++++++++++++++ src/classes/Channel.ts | 158 +++++++++++++++++++++++ src/classes/Emoji.ts | 91 +++++++++++++ src/classes/Message.ts | 153 ++++++++++++++++++++++ src/classes/Server.ts | 235 ++++++++++++++++++++++++++++++++++ src/classes/ServerMember.ts | 117 +++++++++++++++++ src/classes/User.ts | 119 +++++++++++++++++ src/events/client.ts | 15 ++- src/events/v1.ts | 2 +- src/hydration/channel.ts | 59 +++++++++ src/hydration/emoji.ts | 29 +++++ src/hydration/index.ts | 86 +++++++++++++ src/hydration/message.ts | 67 ++++++++++ src/hydration/server.ts | 72 +++++++++++ src/hydration/serverMember.ts | 32 +++++ src/hydration/user.ts | 72 +++++++++++ src/index.ts | 13 +- src/storage/ObjectStorage.ts | 41 ++++++ 21 files changed, 1502 insertions(+), 14 deletions(-) create mode 100644 src/Client.ts create mode 100644 src/classes/Channel.ts create mode 100644 src/classes/Emoji.ts create mode 100644 src/classes/Message.ts create mode 100644 src/classes/Server.ts create mode 100644 src/classes/ServerMember.ts create mode 100644 src/classes/User.ts create mode 100644 src/hydration/channel.ts create mode 100644 src/hydration/emoji.ts create mode 100644 src/hydration/index.ts create mode 100644 src/hydration/message.ts create mode 100644 src/hydration/server.ts create mode 100644 src/hydration/serverMember.ts create mode 100644 src/hydration/user.ts create mode 100644 src/storage/ObjectStorage.ts diff --git a/.gitignore b/.gitignore index db6d2e54..0089b785 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ node_modules .env docs +todo lib # yarn diff --git a/package.json b/package.json index f780fb24..18100a60 100644 --- a/package.json +++ b/package.json @@ -18,9 +18,12 @@ "description": "Library for interacting with the Revolt API.", "packageManager": "pnpm@7.14.2", "dependencies": { + "@solid-primitives/map": "^0.4.3", + "@solid-primitives/set": "^0.4.3", "isomorphic-ws": "^5.0.0", "revolt-api": "^0.5.17", "solid-js": "^1.7.2", + "ulid": "^2.3.0", "ws": "^8.13.0" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0f4bb449..f79d5c8e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,6 +1,8 @@ lockfileVersion: 5.4 specifiers: + '@solid-primitives/map': ^0.4.3 + '@solid-primitives/set': ^0.4.3 '@trivago/prettier-plugin-sort-imports': ^4.1.1 '@types/node': ^18.15.11 '@types/ws': ^8.5.4 @@ -18,12 +20,16 @@ specifiers: tsc-watch: ^6.0.0 typed-emitter: ^2.1.0 typescript: ^5.0.3 + ulid: ^2.3.0 ws: ^8.13.0 dependencies: + '@solid-primitives/map': 0.4.3_solid-js@1.7.2 + '@solid-primitives/set': 0.4.3_solid-js@1.7.2 isomorphic-ws: 5.0.0_ws@8.13.0 revolt-api: 0.5.17 solid-js: 1.7.2 + ulid: 2.3.0 ws: 8.13.0 devDependencies: @@ -248,6 +254,41 @@ packages: fastq: 1.15.0 dev: true + /@solid-primitives/map/0.4.3_solid-js@1.7.2: + resolution: {integrity: sha512-9cpa8MjhLNg+PTVQaIhDUuc192kXoyIAbSCGNTxjzqq7u4OwakhvZlt4C3rIkvLX6yThNlC4IBhOnOSpos5DQA==} + peerDependencies: + solid-js: ^1.6.12 + dependencies: + '@solid-primitives/trigger': 1.0.5_solid-js@1.7.2 + solid-js: 1.7.2 + dev: false + + /@solid-primitives/set/0.4.3_solid-js@1.7.2: + resolution: {integrity: sha512-n/owKQy/76IWPLZkvLSWZ+0Ji4fndvQ6vj4idXEm5vS8hj/xvGHP2Fw6uBf2XrijGr3BVuSuq9zvnaGjIAB12w==} + peerDependencies: + solid-js: ^1.6.12 + dependencies: + '@solid-primitives/trigger': 1.0.5_solid-js@1.7.2 + solid-js: 1.7.2 + dev: false + + /@solid-primitives/trigger/1.0.5_solid-js@1.7.2: + resolution: {integrity: sha512-NAnLYmUFPvJoH05qBf90IEcSTROM+woFdqcGgGmlcYcewewLNLIuJDB69CvPvM+O9yHvRk7UXcpapV25iXcoDQ==} + peerDependencies: + solid-js: ^1.6.12 + dependencies: + '@solid-primitives/utils': 6.0.0_solid-js@1.7.2 + solid-js: 1.7.2 + dev: false + + /@solid-primitives/utils/6.0.0_solid-js@1.7.2: + resolution: {integrity: sha512-SSu12+vvU6J8SVWW9eGV+dqiMsc0WG1Oy8YYY9AWzRzssmzNXg/uexaW+0eqTuj8JcRAaGplMDjhraB01HOidA==} + peerDependencies: + solid-js: ^1.6.12 + dependencies: + solid-js: 1.7.2 + dev: false + /@trivago/prettier-plugin-sort-imports/4.1.1_prettier@2.8.7: resolution: {integrity: sha512-dQ2r2uzNr1x6pJsuh/8x0IRA3CBUB+pWEW3J/7N98axqt7SQSm+2fy0FLNXvXGg77xEDC7KHxJlHfLYyi7PDcw==} peerDependencies: @@ -1834,6 +1875,11 @@ packages: hasBin: true dev: true + /ulid/2.3.0: + resolution: {integrity: sha512-keqHubrlpvT6G2wH0OEfSW4mquYRcbe/J8NMmveoQOjUqmo+hXtO+ORCpWhdbZ7k72UtY61BL7haGxW6enBnjw==} + hasBin: true + dev: false + /unbox-primitive/1.0.2: resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==} dependencies: diff --git a/src/Client.ts b/src/Client.ts new file mode 100644 index 00000000..0d6a17e5 --- /dev/null +++ b/src/Client.ts @@ -0,0 +1,105 @@ +import { API } from "revolt-api"; + +import channelClassFactory from "./classes/Channel"; +import emojiClassFactory from "./classes/Emoji"; +import serverClassFactory from "./classes/Server"; +import serverMemberClassFactory from "./classes/ServerMember"; +import userClassFactory from "./classes/User"; +import { EventClient, createEventClient } from "./events/client"; + +// eslint-disable-next-line +type Obj any> = InstanceType>; + +export type Emoji = Obj; +export type Channel = Obj; +export type Server = Obj; +export type ServerMember = Obj; +export type User = Obj; + +/** + * Revolt.js Client + */ +export class Client { + #Emoji = emojiClassFactory(this); + #Channel = channelClassFactory(this); + #User = userClassFactory(this); + #Server = serverClassFactory(this); + #ServerMember = serverMemberClassFactory(this); + + readonly emojis = this.#Emoji; + readonly channels = this.#Channel; + readonly users = this.#User; + readonly servers = this.#Server; + readonly serverMembers = this.#ServerMember; + + readonly api: API; + readonly events: EventClient<1>; + + /** + * Create Revolt.js Client + */ + constructor() { + this.api = new API({ + authentication: { + rauth: process.env.TOKEN!, + }, + }); + this.events = createEventClient(1); + } + + /** + * Connect + */ + connect() { + this.events.on("event", (event) => { + console.info("[EVENT]", JSON.stringify(event).substring(0, 32)); + if (event.type === "Ready") { + console.time("load users"); + for (const user of event.users) { + new this.#User(user._id, user); + } + console.timeEnd("load users"); + console.time("load servers"); + for (const server of event.servers) { + new this.#Server(server._id, server); + } + console.timeEnd("load servers"); + console.time("load memberships"); + for (const member of event.members) { + new this.#ServerMember(member._id, member); + } + console.timeEnd("load memberships"); + console.time("load channels"); + for (const channel of event.channels) { + new this.#Channel(channel._id, channel); + } + console.timeEnd("load channels"); + console.time("load emojis"); + for (const emoji of event.emojis) { + new this.#Emoji(emoji._id, emoji); + } + console.timeEnd("load emojis"); + + const lounge = this.servers.get("01F7ZSBSFHQ8TA81725KQCSDDP")!; + console.info( + `The owner of ${lounge.name} is ${lounge.owner!.username}!` + ); + console.log(lounge.owner); + console.log( + "It has the channels:", + lounge.channels.map((channel) => channel.name) + ); + console.log( + "They joined at:", + this.serverMembers.get({ server: lounge.id, user: lounge.owner!.id }) + ?.joinedAt + ); + } + }); + + this.events.on("state", (state) => console.info("STATE =", state)); + this.events.on("error", (error) => console.error("ERROR =", error)); + + this.events.connect("wss://ws.revolt.chat", process.env.TOKEN!); + } +} diff --git a/src/classes/Channel.ts b/src/classes/Channel.ts new file mode 100644 index 00000000..e11e6be9 --- /dev/null +++ b/src/classes/Channel.ts @@ -0,0 +1,158 @@ +import type { Channel as ApiChannel } from "revolt-api"; +import { decodeTime } from "ulid"; + +import { Client } from "../Client"; +import { HydratedChannel } from "../hydration/channel"; +import { ObjectStorage } from "../storage/ObjectStorage"; + +export default (client: Client) => + /** + * Channel Class + */ + class Channel { + static #storage = new ObjectStorage(); + static #objects: Record = {}; + + /** + * Get an existing Channel + * @param id Channel ID + * @returns Channel + */ + static get(id: string): Channel | undefined { + return Channel.#objects[id]; + } + + /** + * Fetch channel by ID + * @param id ID + * @returns Channel + */ + static async fetch(id: string): Promise { + const channel = Channel.get(id); + if (channel) return channel; + + const data = await client.api.get(`/channels/${id as ""}`); + return new Channel(id, data); + } + + readonly id: string; + + /** + * Construct Channel + * @param id Channel Id + */ + constructor(id: string, data?: ApiChannel) { + Channel.#storage.hydrate(id, "channel", data); + Channel.#objects[id] = this; + this.id = id; + } + + /** + * Time when this server was created + */ + get createdAt() { + return new Date(decodeTime(this.id)); + } + + /** + * Channel type + */ + get type() { + return Channel.#storage.get(this.id).channelType; + } + + /** + * Channel name + */ + get name() { + return Channel.#storage.get(this.id).name; + } + + /** + * Channel description + */ + get description() { + return Channel.#storage.get(this.id).description; + } + + /** + * Channel icon + */ + get icon() { + return Channel.#storage.get(this.id).icon; + } + + /** + * Whether the conversation is active + */ + get active() { + return Channel.#storage.get(this.id).active; + } + + /** + * Recipients of the group + */ + get recipients() { + return Channel.#storage + .get(this.id) + .recipientIds.map((id) => client.users.get(id)!); + } + + /** + * User this channel belongs to + */ + get user() { + return client.users.get(Channel.#storage.get(this.id).userId!); + } + + /** + * Owner of the group + */ + get owner() { + return client.users.get(Channel.#storage.get(this.id).ownerId!); + } + + /** + * Server this channel is in + */ + get server() { + return client.users.get(Channel.#storage.get(this.id).serverId!); + } + + /** + * Permissions allowed for users in this group + */ + get permissions() { + return Channel.#storage.get(this.id).permissions; + } + + /** + * Default permissions for this server channel + */ + get defaultPermissions() { + return Channel.#storage.get(this.id).defaultPermissions; + } + + /** + * Role permissions for this server channel + */ + get rolePermissions() { + return Channel.#storage.get(this.id).rolePermissions; + } + + /** + * Whether this channel is marked as mature + */ + get mature() { + return Channel.#storage.get(this.id).nsfw; + } + + /** + * ID of the last message sent in this channel + */ + get lastMessageId() { + return Channel.#storage.get(this.id).lastMessageId; + } + + // TODO: lastMessage + }; diff --git a/src/classes/Emoji.ts b/src/classes/Emoji.ts new file mode 100644 index 00000000..1eb10df2 --- /dev/null +++ b/src/classes/Emoji.ts @@ -0,0 +1,91 @@ +import type { Emoji as ApiEmoji } from "revolt-api"; +import { decodeTime } from "ulid"; + +import { Client } from "../Client"; +import { HydratedEmoji } from "../hydration/emoji"; +import { ObjectStorage } from "../storage/ObjectStorage"; + +export default (client: Client) => + /** + * Emoji Class + */ + class Emoji { + static #storage = new ObjectStorage(); + static #objects: Record = {}; + + /** + * Get an existing Emoji + * @param id Emoji ID + * @returns Emoji + */ + static get(id: string): Emoji | undefined { + return Emoji.#objects[id]; + } + + /** + * Fetch emoji by ID + * @param id ID + * @returns Emoji + */ + static async fetch(id: string): Promise { + const emoji = Emoji.get(id); + if (emoji) return emoji; + + const data = await client.api.get(`/custom/emoji/${id as ""}`); + return new Emoji(id, data); + } + + readonly id: string; + + /** + * Construct Emoji + * @param id Emoji Id + */ + constructor(id: string, data?: ApiEmoji) { + Emoji.#storage.hydrate(id, "emoji", data); + Emoji.#objects[id] = this; + this.id = id; + } + + /** + * Time when this emoji was created + */ + get createdAt() { + return new Date(decodeTime(this.id)); + } + + /** + * Information about the parent of this emoji + */ + get parent() { + return Emoji.#storage.get(this.id).parent; + } + + /** + * Creator of the emoji + */ + get creator() { + return client.users.get(Emoji.#storage.get(this.id).creatorId); + } + + /** + * Name + */ + get name() { + return Emoji.#storage.get(this.id).name; + } + + /** + * Whether the emoji is animated + */ + get animated() { + return Emoji.#storage.get(this.id).animated; + } + + /** + * Whether the emoji is marked as mature + */ + get mature() { + return Emoji.#storage.get(this.id).nsfw; + } + }; diff --git a/src/classes/Message.ts b/src/classes/Message.ts new file mode 100644 index 00000000..7dfd0b61 --- /dev/null +++ b/src/classes/Message.ts @@ -0,0 +1,153 @@ +import type { Message as ApiMessage } from "revolt-api"; +import { decodeTime } from "ulid"; + +import { Client } from "../Client"; +import { HydratedMessage } from "../hydration/message"; +import { ObjectStorage } from "../storage/ObjectStorage"; + +export default (client: Client) => + /** + * Message Class + */ + class Message { + static #storage = new ObjectStorage(); + static #objects: Record = {}; + + /** + * Get an existing Message + * @param id Message ID + * @returns Message + */ + static get(id: string): Message | undefined { + return Message.#objects[id]; + } + + /** + * Fetch message by ID + * @param channelId Channel ID + * @param messageId Message ID + * @returns Message + */ + static async fetch( + channelId: string, + messageId: string + ): Promise { + const message = Message.get(messageId); + if (message) return message; + + const data = await client.api.get( + `/channels/${channelId as ""}/messages/${messageId as ""}` + ); + return new Message(messageId, data); + } + + readonly id: string; + + /** + * Construct Message + * @param id Message Id + */ + constructor(id: string, data?: ApiMessage) { + Message.#storage.hydrate(id, "message", data); + Message.#objects[id] = this; + this.id = id; + } + + /** + * Time when this message was posted + */ + get createdAt() { + return new Date(decodeTime(this.id)); + } + + /** + * Nonce value + */ + get nonce() { + return Message.#storage.get(this.id).nonce; + } + + /** + * Channel this message was sent in + */ + get channel() { + return client.channels.get(Message.#storage.get(this.id).channelId); + } + + /** + * User this message was sent by + */ + get author() { + return client.users.get(Message.#storage.get(this.id).authorId!); + } + + /** + * Content + */ + get content() { + return Message.#storage.get(this.id).content; + } + + /** + * System message content + */ + get systemMessage() { + return Message.#storage.get(this.id).systemMessage; + } + + /** + * Attachments + */ + get attachments() { + return Message.#storage.get(this.id).attachments; + } + + /** + * Time at which this message was edited + */ + get editedAt() { + return Message.#storage.get(this.id).editedAt; + } + + /** + * Embeds + */ + get embeds() { + return Message.#storage.get(this.id).embeds; + } + + /** + * IDs of users this message mentions + */ + get mentionIds() { + return Message.#storage.get(this.id).mentionIds; + } + + /** + * IDs of messages this message replies to + */ + get replyIds() { + return Message.#storage.get(this.id).replyIds; + } + + /** + * Reactions + */ + get reactions() { + return Message.#storage.get(this.id).reactions; + } + + /** + * Interactions + */ + get interactions() { + return Message.#storage.get(this.id).interactions; + } + + /** + * Masquerade + */ + get masquerade() { + return Message.#storage.get(this.id).masquerade; + } + }; diff --git a/src/classes/Server.ts b/src/classes/Server.ts new file mode 100644 index 00000000..929b3a13 --- /dev/null +++ b/src/classes/Server.ts @@ -0,0 +1,235 @@ +import type { Server as ApiServer, Category } from "revolt-api"; +import { decodeTime } from "ulid"; + +import { Channel, Client } from "../Client"; +import { HydratedServer } from "../hydration/server"; +import { ObjectStorage } from "../storage/ObjectStorage"; + +export default (client: Client) => + /** + * Server Class + */ + class Server { + static #storage = new ObjectStorage(); + static #objects: Record = {}; + + /** + * Get an existing Server + * @param id Server ID + * @returns Server + */ + static get(id: string): Server | undefined { + return Server.#objects[id]; + } + + /** + * Fetch server by ID + * @param id ID + * @returns Server + */ + static async fetch(id: string): Promise { + const server = Server.get(id); + if (server) return server; + + const data = await client.api.get(`/servers/${id as ""}`); + return new Server(id, data); + } + + readonly id: string; + + /** + * Construct Server + * @param id Server Id + */ + constructor(id: string, data?: ApiServer) { + Server.#storage.hydrate(id, "server", data); + Server.#objects[id] = this; + this.id = id; + } + + /** + * Time when this server was created + */ + get createdAt() { + return new Date(decodeTime(this.id)); + } + + /** + * Owner + */ + get owner() { + return client.users.get(Server.#storage.get(this.id).ownerId); + } + + /** + * Name + */ + get name() { + return Server.#storage.get(this.id).name; + } + + /** + * Description + */ + get description() { + return Server.#storage.get(this.id).description; + } + + /** + * Icon + */ + get icon() { + return Server.#storage.get(this.id).icon; + } + + /** + * Banner + */ + get banner() { + return Server.#storage.get(this.id).banner; + } + + /** + * Channels + */ + get channels() { + return [...Server.#storage.get(this.id).channelIds.values()] + .map((id) => client.channels.get(id)!) + .filter((x) => x); + } + + /** + * Categories + */ + get categories() { + return Server.#storage.get(this.id).categories; + } + + /** + * System message channels + */ + get systemMessages() { + return Server.#storage.get(this.id).systemMessages; + } + + /** + * Roles + */ + get roles() { + return Server.#storage.get(this.id).roles; + } + + /** + * Default permissions + */ + get defaultPermissions() { + return Server.#storage.get(this.id).defaultPermissions; + } + + /** + * Server flags + */ + get flags() { + return Server.#storage.get(this.id).flags; + } + + /** + * Whether analytics are enabled for this server + */ + get analytics() { + return Server.#storage.get(this.id).analytics; + } + + /** + * Whether this server is publicly discoverable + */ + get discoverable() { + return Server.#storage.get(this.id).discoverable; + } + + /** + * Whether this server is marked as mature + */ + get mature() { + return Server.#storage.get(this.id).nsfw; + } + + /** + * Get an array of ordered categories with their respective channels. + * Uncategorised channels are returned in `id="default"` category. + */ + get orderedChannels(): (Omit & { + channels: Channel[]; + })[] { + const uncategorised = new Set(this.channels.map((channel) => channel.id)); + + const elements = []; + let defaultCategory; + + const categories = this.categories; + if (categories) { + for (const category of categories) { + const channels = []; + for (const key of category.channels) { + if (uncategorised.delete(key)) { + channels.push(client.channels.get(key)!); + } + } + + const cat = { + ...category, + channels, + }; + + if (cat.id === "default") { + if (channels.length === 0) continue; + + defaultCategory = cat; + } + + elements.push(cat); + } + } + + if (uncategorised.size > 0) { + const channels = [...uncategorised].map( + (key) => client.channels.get(key)! + ); + + if (defaultCategory) { + defaultCategory.channels = [...defaultCategory.channels, ...channels]; + } else { + elements.unshift({ + id: "default", + title: "Default", + channels, + }); + } + } + + return elements; + } + + /** + * Default channel for this server + */ + get defaultChannel(): Channel | undefined { + return this.orderedChannels.find((cat) => cat.channels.length) + ?.channels[0]; + } + + /** + * Get an ordered array of roles with their IDs attached. + * The highest ranking roles will be first followed by lower + * ranking roles. This is dictated by the "rank" property + * which is smaller for higher priority roles. + */ + get orderedRoles() { + const roles = this.roles; + return roles + ? [...roles.entries()] + .map(([id, role]) => ({ id, ...role })) + .sort((a, b) => (a.rank || 0) - (b.rank || 0)) + : []; + } + }; diff --git a/src/classes/ServerMember.ts b/src/classes/ServerMember.ts new file mode 100644 index 00000000..a12c48ed --- /dev/null +++ b/src/classes/ServerMember.ts @@ -0,0 +1,117 @@ +import type { Member as ApiMember, MemberCompositeKey } from "revolt-api"; + +import { Client } from "../Client"; +import { HydratedServerMember } from "../hydration/serverMember"; +import { ObjectStorage } from "../storage/ObjectStorage"; + +/** + * Deterministic conversion of member composite key to string ID + * @param key Key + * @returns String key + */ +function key(key: MemberCompositeKey) { + return key.server + key.user; +} + +export default (client: Client) => + /** + * Server Member Class + */ + class ServerMember { + static #storage = new ObjectStorage(); + static #objects: Record = {}; + + /** + * Deterministic conversion of member composite key to string ID + * @param key Key + * @returns String key + */ + static keyToString = key; + + /** + * Get an existing Member + * @param id Member ID + * @returns Member + */ + static get(id: MemberCompositeKey): ServerMember | undefined { + return ServerMember.#objects[key(id)]; + } + + /** + * Fetch member by ID + * @param id Member ID + * @returns Member + */ + static async fetch( + id: MemberCompositeKey + ): Promise { + const channel = ServerMember.get(id); + if (channel) return channel; + + const data = await client.api.get( + `/servers/${id.server as ""}/members/${id.user as ""}` + ); + return new ServerMember(id, data); + } + + readonly id: MemberCompositeKey; + + /** + * Construct Member + * @param id Member ID + */ + constructor(id: MemberCompositeKey, data?: ApiMember) { + ServerMember.#storage.hydrate(key(id), "serverMember", data); + ServerMember.#objects[key(id)] = this; + this.id = id; + } + + /** + * Server this member belongs to + */ + get server() { + return client.servers.get(this.id.server); + } + + /** + * User corresponding to this member + */ + get user() { + return client.users.get(this.id.user); + } + + /** + * When this user joined the server + */ + get joinedAt() { + return ServerMember.#storage.get(key(this.id)).joinedAt; + } + + /** + * Nickname + */ + get nickname() { + return ServerMember.#storage.get(key(this.id)).nickname; + } + + /** + * Avatar + */ + get avatar() { + return ServerMember.#storage.get(key(this.id)).avatar; + } + + /** + * List of role IDs + */ + get roles() { + return ServerMember.#storage.get(key(this.id)).roles; + } + + /** + * Time at which timeout expires + */ + get timeout() { + return ServerMember.#storage.get(key(this.id)).timeout; + } + }; diff --git a/src/classes/User.ts b/src/classes/User.ts new file mode 100644 index 00000000..5adb184d --- /dev/null +++ b/src/classes/User.ts @@ -0,0 +1,119 @@ +import type { User as ApiUser } from "revolt-api"; +import { decodeTime } from "ulid"; + +import { Client } from "../Client"; +import { HydratedUser } from "../hydration/user"; +import { ObjectStorage } from "../storage/ObjectStorage"; + +export default (client: Client) => + /** + * User Class + */ + class User { + static #storage = new ObjectStorage(); + static #objects: Record = {}; + + /** + * Get an existing User + * @param id User ID + * @returns User + */ + static get(id: string): User | undefined { + return User.#objects[id]; + } + + /** + * Fetch user by ID + * @param id ID + * @returns User + */ + static async fetch(id: string): Promise { + const user = User.get(id); + if (user) return user; + + const data = await client.api.get(`/users/${id as ""}`); + return new User(id, data); + } + + readonly id: string; + + /** + * Construct User + * @param id User Id + */ + constructor(id: string, data?: ApiUser) { + User.#storage.hydrate(id, "user", data); + User.#objects[id] = this; + this.id = id; + } + + /** + * Time when this user created their account + */ + get createdAt() { + return new Date(decodeTime(this.id)); + } + + /** + * Username + */ + get username() { + return User.#storage.get(this.id).username; + } + + /** + * Avatar + */ + get avatar() { + return User.#storage.get(this.id).avatar; + } + + /** + * Badges + */ + get badges() { + return User.#storage.get(this.id).badges; + } + + /** + * User Status + */ + get status() { + return User.#storage.get(this.id).status; + } + + /** + * Relationship with user + */ + get relationship() { + return User.#storage.get(this.id).relationship; + } + + /** + * Whether the user is online + */ + get online() { + return User.#storage.get(this.id).online; + } + + /** + * Whether the user is privileged + */ + get privileged() { + return User.#storage.get(this.id).privileged; + } + + /** + * Flags + */ + get flags() { + return User.#storage.get(this.id).flags; + } + + /** + * Bot information + */ + get bot() { + return User.#storage.get(this.id).bot; + } + }; diff --git a/src/events/client.ts b/src/events/client.ts index 57179ddb..5be34fb9 100644 --- a/src/events/client.ts +++ b/src/events/client.ts @@ -28,7 +28,7 @@ type Events> = { /** * Simple wrapper around the Revolt websocket service. */ -class EventClient< +class Client< T extends AvailableProtocols, P extends EventProtocol > extends EventEmitter { @@ -187,6 +187,15 @@ class EventClient< } } +/** + * Event client + */ +export type EventClient = Omit< + Client>, + "on" | "once" +> & + TypedEmitter>>; + /** * Create a new event client. * @param protocolVersion Target protocol version @@ -203,10 +212,10 @@ export function createEventClient< heartbeatInterval?: number, pongTimeout?: number ) { - return new EventClient( + return new Client( protocolVersion, transportFormat, heartbeatInterval, pongTimeout - ) as EventClient & TypedEmitter>; + ) as EventClient; } diff --git a/src/events/v1.ts b/src/events/v1.ts index 8954295a..7a199142 100644 --- a/src/events/v1.ts +++ b/src/events/v1.ts @@ -169,7 +169,7 @@ type ReadyData = { servers: Server[]; channels: Channel[]; members: Member[]; - emojis?: Emoji[]; + emojis: Emoji[]; }; /** diff --git a/src/hydration/channel.ts b/src/hydration/channel.ts new file mode 100644 index 00000000..ebc3516c --- /dev/null +++ b/src/hydration/channel.ts @@ -0,0 +1,59 @@ +import { Channel as ApiChannel, File, OverrideField } from "revolt-api"; + +import { Merge } from "../lib/merge"; + +import { Hydrate } from "."; + +export type HydratedChannel = { + id: string; + channelType: ApiChannel["channel_type"]; + + name: string; + description?: string; + icon?: File; + + active: boolean; + recipientIds: string[]; + + userId?: string; + ownerId?: string; + serverId?: string; + + permissions?: number; + defaultPermissions?: OverrideField; + rolePermissions?: Record; + nsfw: boolean; + + lastMessageId?: string; +}; + +export const channelHydration: Hydrate, HydratedChannel> = { + keyMapping: { + _id: "id", + channel_type: "channelType", + recipients: "recipientIds", + user: "userId", + owner: "ownerId", + server: "serverId", + default_permissions: "defaultPermissions", + role_permissions: "rolePermissions", + last_message_id: "lastMessageId", + }, + functions: { + id: (channel) => channel._id, + channelType: (channel) => channel.channel_type, + name: (channel) => channel.name, + description: (channel) => channel.description!, + icon: (channel) => channel.icon!, + active: (channel) => channel.active || false, + recipientIds: (channel) => channel.recipients ?? [], + userId: (channel) => channel.user, + ownerId: (channel) => channel.owner, + serverId: (channel) => channel.server, + permissions: (channel) => channel.permissions!, + defaultPermissions: (channel) => channel.default_permissions!, + rolePermissions: (channel) => channel.role_permissions ?? {}, + nsfw: (channel) => channel.nsfw || false, + lastMessageId: (channel) => channel.last_message_id!, + }, +}; diff --git a/src/hydration/emoji.ts b/src/hydration/emoji.ts new file mode 100644 index 00000000..df12d927 --- /dev/null +++ b/src/hydration/emoji.ts @@ -0,0 +1,29 @@ +import { Emoji as ApiEmoji, EmojiParent } from "revolt-api"; + +import { Merge } from "../lib/merge"; + +import { Hydrate } from "."; + +export type HydratedEmoji = { + id: string; + parent: EmojiParent; + creatorId: string; + name: string; + animated: boolean; + nsfw: boolean; +}; + +export const emojiHydration: Hydrate, HydratedEmoji> = { + keyMapping: { + _id: "id", + creator_id: "creatorId", + }, + functions: { + id: (emoji) => emoji._id, + parent: (emoji) => emoji.parent, + creatorId: (emoji) => emoji.creator_id, + name: (emoji) => emoji.name, + animated: (emoji) => emoji.animated || false, + nsfw: (emoji) => emoji.nsfw || false, + }, +}; diff --git a/src/hydration/index.ts b/src/hydration/index.ts new file mode 100644 index 00000000..66ed59be --- /dev/null +++ b/src/hydration/index.ts @@ -0,0 +1,86 @@ +import { channelHydration } from "./channel"; +import { emojiHydration } from "./emoji"; +import { messageHydration } from "./message"; +import { serverHydration } from "./server"; +import { serverMemberHydration } from "./serverMember"; +import { userHydration } from "./user"; + +/** + * Functions to map from one object to another + */ +export type MappingFns = Record< + Key, + (value: Input) => Output[Key] +>; + +/** + * Key mapping information + */ +export type KeyMapping = Record; + +/** + * Hydration information + */ +export type Hydrate = { + keyMapping: Partial>; + functions: MappingFns; +}; + +/** + * Hydrate some data + * @param hydration Hydration data + * @param input Input data + * @returns Output data + */ +function hydrateInternal( + hydration: Hydrate, + input: Input +): Output { + return (Object.keys(input) as (keyof Input)[]).reduce((acc, key) => { + let targetKey, value; + try { + targetKey = hydration.keyMapping[key] ?? key; + value = hydration.functions[targetKey as keyof Output](input); + } catch (err) { + console.debug(`Skipping key ${String(key)} during hydration!`); + return acc; + } + + return { + ...acc, + [targetKey]: value, + }; + }, {} as Output); +} + +const hydrators = { + channel: channelHydration, + emoji: emojiHydration, + message: messageHydration, + server: serverHydration, + serverMember: serverMemberHydration, + user: userHydration, +}; + +export type Hydrators = typeof hydrators; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type ExtractInput = T extends Hydrate ? I : never; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type ExtractOutput = T extends Hydrate ? O : never; + +/** + * Hydrate some input with a given type + * @param type Type + * @param input Input Object + * @returns Hydrated Object + */ +export function hydrate( + type: T, + input: ExtractInput +) { + return hydrateInternal(hydrators[type] as never, input) as ExtractOutput< + Hydrators[T] + >; +} diff --git a/src/hydration/message.ts b/src/hydration/message.ts new file mode 100644 index 00000000..1180aaa8 --- /dev/null +++ b/src/hydration/message.ts @@ -0,0 +1,67 @@ +import { ReactiveMap } from "@solid-primitives/map"; +import { ReactiveSet } from "@solid-primitives/set"; +import { + Message as ApiMessage, + Embed, + File, + Interactions, + Masquerade, + SystemMessage, +} from "revolt-api"; + +import { Merge } from "../lib/merge"; + +import { Hydrate } from "."; + +export type HydratedMessage = { + id: string; + nonce?: string; + channelId: string; + authorId?: string; + content?: string; + systemMessage?: SystemMessage; + attachments?: File[]; + editedAt?: Date; + embeds?: Embed[]; + mentionIds?: string[]; + replyIds?: string[]; + reactions?: ReactiveMap>; + interactions?: Interactions; + masquerade?: Masquerade; +}; + +export const messageHydration: Hydrate, HydratedMessage> = { + keyMapping: { + _id: "id", + channel: "channelId", + author: "authorId", + system: "systemMessage", + edited: "editedAt", + mentions: "mentionIds", + replies: "replyIds", + }, + functions: { + id: (message) => message._id, + nonce: (message) => message.nonce!, + channelId: (message) => message.channel, + authorId: (message) => message.author, + content: (message) => message.content!, + systemMessage: (message) => message.system!, + attachments: (message) => message.attachments!, + editedAt: (message) => new Date(message.edited!), + embeds: (message) => message.embeds!, + mentionIds: (message) => message.mentions!, + replyIds: (message) => message.replies!, + reactions: (message) => { + const map = new ReactiveMap>(); + if (message.reactions) { + for (const reaction of Object.keys(message.reactions)) { + map.set(reaction, new ReactiveSet(message.reactions![reaction])); + } + } + return map; + }, + interactions: (message) => message.interactions, + masquerade: (message) => message.masquerade!, + }, +}; diff --git a/src/hydration/server.ts b/src/hydration/server.ts new file mode 100644 index 00000000..143ec5d5 --- /dev/null +++ b/src/hydration/server.ts @@ -0,0 +1,72 @@ +import { ReactiveMap } from "@solid-primitives/map"; +import { ReactiveSet } from "@solid-primitives/set"; +import { + Server as ApiServer, + Category, + File, + Role, + SystemMessageChannels, +} from "revolt-api"; + +import { Hydrate } from "."; + +export type HydratedServer = { + id: string; + ownerId: string; + + name: string; + description?: string; + + icon?: File; + banner?: File; + + channelIds: ReactiveSet; + categories?: Category[]; + + systemMessages?: SystemMessageChannels; + roles?: ReactiveMap; + defaultPermissions: number; + + flags: ServerFlags; + analytics: boolean; + discoverable: boolean; + nsfw: boolean; +}; + +export const serverHydration: Hydrate = { + keyMapping: { + _id: "id", + owner: "ownerId", + channels: "channelIds", + system_messages: "systemMessages", + default_permissions: "defaultPermissions", + }, + functions: { + id: (server) => server._id, + ownerId: (server) => server.owner, + name: (server) => server.name, + description: (server) => server.description!, + channelIds: (server) => new ReactiveSet(server.channels), + categories: (server) => server.categories ?? [], + systemMessages: (server) => server.system_messages ?? {}, + roles: (server) => + new ReactiveMap( + Object.keys(server.roles!).map((id) => [id, server.roles![id]]) + ), + defaultPermissions: (server) => server.default_permissions, + icon: (server) => server.icon!, + banner: (server) => server.banner!, + flags: (server) => server.flags!, + analytics: (server) => server.analytics || false, + discoverable: (server) => server.discoverable || false, + nsfw: (server) => server.nsfw || false, + }, +}; + +/** + * Flags attributed to servers + */ +export enum ServerFlags { + Official = 1, + Verified = 2, +} diff --git a/src/hydration/serverMember.ts b/src/hydration/serverMember.ts new file mode 100644 index 00000000..1d6fbe45 --- /dev/null +++ b/src/hydration/serverMember.ts @@ -0,0 +1,32 @@ +import { Member as ApiMember, File, MemberCompositeKey } from "revolt-api"; + +import { Merge } from "../lib/merge"; + +import { Hydrate } from "."; + +export type HydratedServerMember = { + id: MemberCompositeKey; + joinedAt: Date; + nickname?: string; + avatar?: File; + roles?: string[]; + timeout?: Date; +}; + +export const serverMemberHydration: Hydrate< + Merge, + HydratedServerMember +> = { + keyMapping: { + _id: "id", + joined_at: "joinedAt", + }, + functions: { + id: (member) => member._id, + joinedAt: (member) => new Date(member.joined_at), + nickname: (member) => member.nickname!, + avatar: (member) => member.avatar!, + roles: (member) => member.roles, + timeout: (member) => new Date(member.timeout!), + }, +}; diff --git a/src/hydration/user.ts b/src/hydration/user.ts new file mode 100644 index 00000000..af1f67d2 --- /dev/null +++ b/src/hydration/user.ts @@ -0,0 +1,72 @@ +import { + User as ApiUser, + BotInformation, + File, + RelationshipStatus, + UserStatus, +} from "revolt-api"; + +import { Hydrate } from "."; + +export type HydratedUser = { + id: string; + username: string; + relationship: RelationshipStatus; + + online: boolean; + privileged: boolean; + + badges: UserBadges; + flags: UserFlags; + + avatar?: File; + status?: UserStatus; + bot?: BotInformation; +}; + +export const userHydration: Hydrate = { + keyMapping: { + _id: "id", + }, + functions: { + id: (user) => user._id, + username: (user) => user.username, + relationship: (user) => user.relationship ?? "None", + + online: (user) => user.online || false, + privileged: (user) => user.privileged || false, + + badges: (user) => user.badges ?? 0, + flags: (user) => user.flags ?? 0, + + avatar: (user) => user.avatar! ?? undefined, + status: (user) => user.status! ?? undefined, + bot: (user) => user.bot! ?? undefined, + }, +}; + +/** + * Badges available to users + */ +export enum UserBadges { + Developer = 1, + Translator = 2, + Supporter = 4, + ResponsibleDisclosure = 8, + Founder = 16, + PlatformModeration = 32, + ActiveSupporter = 64, + Paw = 128, + EarlyAdopter = 256, + ReservedRelevantJokeBadge1 = 512, + ReservedRelevantJokeBadge2 = 1024, +} + +/** + * Flags attributed to users + */ +export enum UserFlags { + Suspended = 1, + Deleted = 2, + Banned = 4, +} diff --git a/src/index.ts b/src/index.ts index 850804a3..730be9a2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,15 +1,8 @@ import { config } from "dotenv"; -import { createEventClient } from "./events/client"; +import { Client } from "./Client"; config(); -const ws = createEventClient(1); - -ws.on("event", (event) => - console.info("[EVENT]", JSON.stringify(event).substring(0, 32)) -); -ws.on("state", (state) => console.info("STATE =", state)); -ws.on("error", (error) => console.error("ERROR =", error)); - -ws.connect("wss://ws.revolt.chat", process.env.TOKEN!); +const client = new Client(); +client.connect(); diff --git a/src/storage/ObjectStorage.ts b/src/storage/ObjectStorage.ts new file mode 100644 index 00000000..fee6b373 --- /dev/null +++ b/src/storage/ObjectStorage.ts @@ -0,0 +1,41 @@ +import { SetStoreFunction, createStore } from "solid-js/store"; + +import { Hydrators, hydrate } from "../hydration"; + +/** + * Wrapper around Solid.js store + */ +export class ObjectStorage { + private store: Record; + readonly set: SetStoreFunction>; + + /** + * Create new object storage + */ + constructor() { + const [store, setStore] = createStore({}); + this.store = store as never; + this.set = setStore; + } + + /** + * Get object by ID + * @param id ID + * @returns Object + */ + get(id: string) { + return this.store[id]; + } + + /** + * Hydrate some data into storage + * @param id ID + * @param type Hydration type + * @param data Input Data + */ + hydrate(id: string, type: keyof Hydrators, data?: unknown) { + if (data) { + this.set(id, hydrate(type, data as never) as T); + } + } +}