feat: implement store backed common classes

This commit is contained in:
Paul Makles
2023-04-07 23:16:49 +01:00
parent acb0521a29
commit 71b9dc5523
21 changed files with 1502 additions and 14 deletions
+1
View File
@@ -2,6 +2,7 @@
node_modules
.env
docs
todo
lib
# yarn
+3
View File
@@ -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": {
+46
View File
@@ -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:
+105
View File
@@ -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<T extends (...args: any) => any> = InstanceType<ReturnType<T>>;
export type Emoji = Obj<typeof emojiClassFactory>;
export type Channel = Obj<typeof channelClassFactory>;
export type Server = Obj<typeof serverClassFactory>;
export type ServerMember = Obj<typeof serverMemberClassFactory>;
export type User = Obj<typeof userClassFactory>;
/**
* 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!);
}
}
+158
View File
@@ -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<HydratedChannel>();
static #objects: Record<string, Channel> = {};
/**
* 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<Channel | undefined> {
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
};
+91
View File
@@ -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<HydratedEmoji>();
static #objects: Record<string, Emoji> = {};
/**
* 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<Emoji | undefined> {
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;
}
};
+153
View File
@@ -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<HydratedMessage>();
static #objects: Record<string, Message> = {};
/**
* 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<Message | undefined> {
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;
}
};
+235
View File
@@ -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<HydratedServer>();
static #objects: Record<string, Server> = {};
/**
* 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<Server | undefined> {
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<Category, "channels"> & {
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))
: [];
}
};
+117
View File
@@ -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<HydratedServerMember>();
static #objects: Record<string, ServerMember> = {};
/**
* 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<ServerMember | undefined> {
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;
}
};
+119
View File
@@ -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<HydratedUser>();
static #objects: Record<string, User> = {};
/**
* 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<User | undefined> {
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;
}
};
+12 -3
View File
@@ -28,7 +28,7 @@ type Events<T extends AvailableProtocols, P extends EventProtocol<T>> = {
/**
* Simple wrapper around the Revolt websocket service.
*/
class EventClient<
class Client<
T extends AvailableProtocols,
P extends EventProtocol<T>
> extends EventEmitter {
@@ -187,6 +187,15 @@ class EventClient<
}
}
/**
* Event client
*/
export type EventClient<T extends AvailableProtocols> = Omit<
Client<T, EventProtocol<T>>,
"on" | "once"
> &
TypedEmitter<Events<T, EventProtocol<T>>>;
/**
* Create a new event client.
* @param protocolVersion Target protocol version
@@ -203,10 +212,10 @@ export function createEventClient<
heartbeatInterval?: number,
pongTimeout?: number
) {
return new EventClient<T, P>(
return new Client<T, P>(
protocolVersion,
transportFormat,
heartbeatInterval,
pongTimeout
) as EventClient<T, P> & TypedEmitter<Events<T, P>>;
) as EventClient<T>;
}
+1 -1
View File
@@ -169,7 +169,7 @@ type ReadyData = {
servers: Server[];
channels: Channel[];
members: Member[];
emojis?: Emoji[];
emojis: Emoji[];
};
/**
+59
View File
@@ -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<string, OverrideField>;
nsfw: boolean;
lastMessageId?: string;
};
export const channelHydration: Hydrate<Merge<ApiChannel>, 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!,
},
};
+29
View File
@@ -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<Merge<ApiEmoji>, 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,
},
};
+86
View File
@@ -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<Input, Output, Key extends keyof Output> = Record<
Key,
(value: Input) => Output[Key]
>;
/**
* Key mapping information
*/
export type KeyMapping<Input, Output> = Record<keyof Input, keyof Output>;
/**
* Hydration information
*/
export type Hydrate<Input, Output> = {
keyMapping: Partial<KeyMapping<Input, Output>>;
functions: MappingFns<Input, Output, keyof Output>;
};
/**
* Hydrate some data
* @param hydration Hydration data
* @param input Input data
* @returns Output data
*/
function hydrateInternal<Input extends object, Output>(
hydration: Hydrate<Input, Output>,
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> = T extends Hydrate<infer I, any> ? I : never;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type ExtractOutput<T> = T extends Hydrate<any, infer O> ? O : never;
/**
* Hydrate some input with a given type
* @param type Type
* @param input Input Object
* @returns Hydrated Object
*/
export function hydrate<T extends keyof Hydrators>(
type: T,
input: ExtractInput<Hydrators[T]>
) {
return hydrateInternal(hydrators[type] as never, input) as ExtractOutput<
Hydrators[T]
>;
}
+67
View File
@@ -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<string, ReactiveSet<string>>;
interactions?: Interactions;
masquerade?: Masquerade;
};
export const messageHydration: Hydrate<Merge<ApiMessage>, 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<string, ReactiveSet<string>>();
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!,
},
};
+72
View File
@@ -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<string>;
categories?: Category[];
systemMessages?: SystemMessageChannels;
roles?: ReactiveMap<string, Role>;
defaultPermissions: number;
flags: ServerFlags;
analytics: boolean;
discoverable: boolean;
nsfw: boolean;
};
export const serverHydration: Hydrate<ApiServer, HydratedServer> = {
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,
}
+32
View File
@@ -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<ApiMember>,
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!),
},
};
+72
View File
@@ -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<ApiUser, HydratedUser> = {
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,
}
+3 -10
View File
@@ -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();
+41
View File
@@ -0,0 +1,41 @@
import { SetStoreFunction, createStore } from "solid-js/store";
import { Hydrators, hydrate } from "../hydration";
/**
* Wrapper around Solid.js store
*/
export class ObjectStorage<T> {
private store: Record<string, T>;
readonly set: SetStoreFunction<Record<string, T>>;
/**
* 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);
}
}
}