feat: handle all events from current protocol

This commit is contained in:
Paul Makles
2023-04-08 20:27:00 +01:00
parent 35b564e350
commit 932e73ab9e
18 changed files with 1029 additions and 176 deletions
+121 -72
View File
@@ -1,9 +1,10 @@
import { Accessor, Setter, createSignal } from "solid-js";
import { API, Metadata } from "revolt-api";
import EventEmitter from "eventemitter3";
import { API, Metadata, RelationshipStatus, Role } from "revolt-api";
import type { DataLogin, RevoltConfig } from "revolt-api";
import { User } from "./classes";
import { Channel, Emoji, Message, Server, ServerMember, User } from "./classes";
import {
ChannelCollection,
EmojiCollection,
@@ -12,14 +13,89 @@ import {
ServerMemberCollection,
UserCollection,
} from "./collections";
import { EventClient, createEventClient } from "./events/client";
import { ConnectionState, EventClient } from "./events/client";
import { handleEvent } from "./events/v1";
import {
HydratedChannel,
HydratedEmoji,
HydratedMessage,
HydratedServer,
HydratedServerMember,
HydratedUser,
} from "./hydration";
export type Session = { token: string; user_id: string } | string;
export type Session = { _id: string; token: string; user_id: string } | string;
/**
* Revolt.js Client
* Events provided by the client
*/
export class Client {
type Events = {
error(error: Error): void;
connected(): void;
connecting(): void;
disconnected(): void;
ready(): void;
logout(): void;
messageCreate(message: Message): void;
messageUpdate(message: Message, previousMessage: HydratedMessage): void;
messageDelete(message: HydratedMessage): void;
messageDeleteBulk(messages: HydratedMessage[], channel?: Channel): void;
messageReactionAdd(message: Message, userId: string, emoji: string): void;
messageReactionRemove(message: Message, userId: string, emoji: string): void;
messageReactionRemoveEmoji(message: Message, emoji: string): void;
channelCreate(channel: Channel): void;
channelUpdate(channel: Channel, previousChannel: HydratedChannel): void;
channelDelete(channel: HydratedChannel): void;
channelGroupJoin(channel: Channel, user: User): void;
channelGroupLeave(channel: Channel, user?: User): void;
channelStartTyping(channel: Channel, user?: User): void;
channelStopTyping(channel: Channel, user?: User): void;
channelAcknowledged(channel: Channel, messageId: string): void;
serverCreate(server: Server): void;
serverUpdate(server: Server, previousServer: HydratedServer): void;
serverDelete(server: HydratedServer): void;
serverRoleUpdate(server: Server, roleId: string, previousRole: Role): void;
serverRoleDelete(server: Server, roleId: string, role: Role): void;
serverMemberUpdate(
member: ServerMember,
previousMember: HydratedServerMember
): void;
serverMemberJoin(member: ServerMember): void;
serverMemberLeave(member: HydratedServerMember): void;
userUpdate(user: User, previousUser: HydratedUser): void;
// ^ userRelationshipChanged(user: User, previousRelationship: RelationshipStatus): void;
// ^ userPresenceChanged(user: User, previousPresence: boolean): void;
userSettingsUpdate(
id: string,
update: Record<string, [number, string]>
): void;
emojiCreate(emoji: Emoji): void;
emojiDelete(emoji: HydratedEmoji): void;
};
/**
* Client options object
*/
export interface ClientOptions {
baseURL: string;
/**
* Whether to allow partial objects to emit from events.
*/
partials: boolean;
}
/**
* Revolt.js Clients
*/
export class Client extends EventEmitter<Events> {
readonly channels;
readonly emojis;
readonly messages;
@@ -28,7 +104,7 @@ export class Client {
readonly serverMembers;
readonly api: API;
readonly baseURL: string;
readonly options: ClientOptions;
readonly events: EventClient<1>;
configuration: RevoltConfig | undefined;
@@ -41,13 +117,18 @@ export class Client {
/**
* Create Revolt.js Client
*/
constructor(baseURL?: string) {
this.baseURL = baseURL ?? "https://api.revolt.chat";
this.api = new API({
baseURL,
});
constructor(options?: Partial<ClientOptions>) {
super();
this.events = createEventClient(1);
this.options = {
baseURL: "https://api.revolt.chat",
partials: false,
...options,
};
this.api = new API({
baseURL: this.options.baseURL,
});
const [ready, setReady] = createSignal(false);
this.ready = ready;
@@ -59,67 +140,35 @@ export class Client {
this.users = new UserCollection(this);
this.servers = new ServerCollection(this);
this.serverMembers = new ServerMemberCollection(this);
}
/**
* 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) {
const u = this.users.getOrCreate(user._id, user);
if (u.relationship === "User") {
this.user = u;
}
}
console.timeEnd("load users");
console.time("load servers");
for (const server of event.servers) {
this.servers.getOrCreate(server._id, server);
}
console.timeEnd("load servers");
console.time("load memberships");
for (const member of event.members) {
this.serverMembers.getOrCreate(member._id, member);
}
console.timeEnd("load memberships");
console.time("load channels");
for (const channel of event.channels) {
this.channels.getOrCreate(channel._id, channel);
}
console.timeEnd("load channels");
console.time("load emojis");
for (const emoji of event.emojis) {
this.emojis.getOrCreate(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.#setReady(true);
this.events = new EventClient(1);
this.events.on("error", (error) => this.emit("error", error));
this.events.on("state", (state) => {
console.info("[state]", state);
switch (state) {
case ConnectionState.Connected:
this.emit("connected");
break;
case ConnectionState.Connecting:
this.emit("connecting");
break;
case ConnectionState.Disconnected:
this.emit("disconnected");
break;
}
});
this.events.on("state", (state) => console.info("STATE =", state));
this.events.on("error", (error) => console.error("ERROR =", error));
this.events.on("event", (event) =>
handleEvent(this, event, this.#setReady)
);
}
/**
* Connect to Revolt
*/
connect() {
this.events.disconnect();
this.#setReady(false);
this.events.connect(
"wss://ws.revolt.chat",
typeof this.session === "string" ? this.session : this.session!.token
@@ -140,7 +189,7 @@ export class Client {
*/
#updateHeaders() {
(this.api as API) = new API({
baseURL: this.baseURL,
baseURL: this.options.baseURL,
authentication: {
revolt: this.session,
},
@@ -187,14 +236,14 @@ export class Client {
}
/**
* Generates a URL to a given file with given options.
* Creates a URL to a given file with given options.
* @param attachment Partial of attachment object
* @param options Optional query parameters to modify object
* @param allowAnimation Returns GIF if applicable, no operations occur on image
* @param fallback Fallback URL
* @returns Generated URL or nothing
*/
generateFileURL(
createFileURL(
attachment?: {
tag: string;
_id: string;
+40 -25
View File
@@ -1,16 +1,17 @@
import type {
Channel as ApiChannel,
Member as ApiMember,
Message as ApiMessage,
User as ApiUser,
DataEditChannel,
DataMessageSend,
Member,
Message,
OptionsMessageSearch,
Override,
User,
} from "revolt-api";
import { APIRoutes } from "revolt-api/dist/routes";
import { decodeTime, ulid } from "ulid";
import { Message } from "..";
import { ChannelCollection } from "../collections";
import { bitwiseAndEq, calculatePermission } from "../permissions/calculator";
import { Permission } from "../permissions/definitions";
@@ -93,7 +94,23 @@ export class Channel {
}
/**
* User Ids of recipients of the group
* User ids of people currently typing in channel
*/
get typingIds() {
return this.collection.getUnderlyingObject(this.id).recipientIds;
}
/**
* Users currently trying in channel
*/
get typing() {
return [
...this.collection.getUnderlyingObject(this.id).typingIds.values(),
].map((id) => this.collection.client.users.get(id)!);
}
/**
* User ids of recipients of the group
*/
get recipientIds() {
return this.collection.getUnderlyingObject(this.id).recipientIds;
@@ -103,9 +120,9 @@ export class Channel {
* Recipients of the group
*/
get recipients() {
return this.collection
.getUnderlyingObject(this.id)
.recipientIds.map((id) => this.collection.client.users.get(id)!);
return [
...this.collection.getUnderlyingObject(this.id).recipientIds.values(),
].map((id) => this.collection.client.users.get(id)!);
}
/**
@@ -232,7 +249,7 @@ export class Channel {
* URL to the channel icon
*/
get iconURL() {
return this.collection.client.generateFileURL(
return this.collection.client.createFileURL(
this.icon ?? this.recipient?.avatar,
{
max_side: 256,
@@ -244,7 +261,7 @@ export class Channel {
* URL to a small variant of the channel icon
*/
get smallIconURL() {
return this.collection.client.generateFileURL(
return this.collection.client.createFileURL(
this.icon ?? this.recipient?.avatar,
{
max_side: 64,
@@ -256,7 +273,7 @@ export class Channel {
* URL to the animated channel icon
*/
get animatedIconURL() {
return this.collection.client.generateFileURL(
return this.collection.client.createFileURL(
this.icon ?? this.recipient?.avatar,
{ max_side: 256 },
true
@@ -322,13 +339,6 @@ export class Channel {
return;
}
if (this.type === "TextChannel" || this.type === "VoiceChannel") {
const server = this.server;
if (server) {
server.channelIds.delete(this.id);
}
}
this.collection.client.channels.delete(this.id);
}
@@ -373,7 +383,11 @@ export class Channel {
}
);
return this.collection.client.messages.getOrCreate(message._id, message);
return this.collection.client.messages.getOrCreate(
message._id,
message,
true
);
}
/**
@@ -406,7 +420,7 @@ export class Channel {
const messages = (await this.collection.client.api.get(
`/channels/${this.id as ""}/messages`,
{ ...params }
)) as Message[];
)) as ApiMessage[];
return messages.map((message) =>
this.collection.client.messages.getOrCreate(message._id, message)
@@ -430,7 +444,7 @@ export class Channel {
const data = (await this.collection.client.api.get(
`/channels/${this.id as ""}/messages`,
{ ...params, include_users: true }
)) as { messages: Message[]; users: User[]; members?: Member[] };
)) as { messages: ApiMessage[]; users: ApiUser[]; members?: ApiMember[] };
return {
messages: data.messages.map((message) =>
@@ -454,7 +468,7 @@ export class Channel {
const messages = (await this.collection.client.api.post(
`/channels/${this.id as ""}/search`,
params
)) as Message[];
)) as ApiMessage[];
return messages.map((message) =>
this.collection.client.messages.getOrCreate(message._id, message)
@@ -473,7 +487,7 @@ export class Channel {
...params,
include_users: true,
}
)) as { messages: Message[]; users: User[]; members?: Member[] };
)) as { messages: ApiMessage[]; users: ApiUser[]; members?: ApiMember[] };
return {
messages: data.messages.map((message) =>
@@ -517,16 +531,17 @@ export class Channel {
*/
async ack(message?: Message | string, skipRateLimiter?: boolean) {
const id =
(typeof message === "string" ? message : message?._id) ??
(typeof message === "string" ? message : message?.id) ??
this.lastMessageId ??
ulid();
const performAck = () => {
this.#ackLimit = undefined;
this.collection.client.api.put(`/channels/${this.id}/ack/${id as ""}`);
};
/* TODO: if (!this.collection.client.options.ackRateLimiter || skipRateLimiter)
return performAck();*/
// TODO: !this.collection.client.options.ackRateLimiter
if (skipRateLimiter) return performAck();
clearTimeout(this.#ackTimeout);
if (this.#ackLimit && +new Date() > this.#ackLimit) {
+79
View File
@@ -1,3 +1,4 @@
import { DataEditMessage, DataMessageSend } from "revolt-api";
import { decodeTime } from "ulid";
import { MessageCollection } from "../collections";
@@ -247,4 +248,82 @@ export class Message {
return { type, from: get(system.from), to: get(system.to) };
}
}
/**
* Edit a message
* @param data Message edit route data
*/
async edit(data: DataEditMessage) {
return await this.collection.client.api.patch(
`/channels/${this.channelId as ""}/messages/${this.id as ""}`,
data
);
}
/**
* Delete a message
*/
async delete() {
return await this.collection.client.api.delete(
`/channels/${this.channelId as ""}/messages/${this.id as ""}`
);
}
/**
* Acknowledge this message as read
*/
ack() {
this.channel?.ack(this);
}
/**
* Reply to Message
*/
reply(
data:
| string
| (Omit<DataMessageSend, "nonce"> & {
nonce?: string;
}),
mention = true
) {
const obj = typeof data === "string" ? { content: data } : data;
return this.channel?.sendMessage({
...obj,
replies: [{ id: this.id, mention }],
});
}
/**
* Clear all reactions from this message
*/
async clearReactions() {
return await this.collection.client.api.delete(
`/channels/${this.channelId as ""}/messages/${this.id as ""}/reactions`
);
}
/**
* React to a message
* @param emoji Unicode or emoji ID
*/
async react(emoji: string) {
return await this.collection.client.api.put(
`/channels/${this.channelId as ""}/messages/${this.id as ""}/reactions/${
emoji as ""
}`
);
}
/**
* Unreact from a message
* @param emoji Unicode or emoji ID
*/
async unreact(emoji: string) {
return await this.collection.client.api.delete(
`/channels/${this.channelId as ""}/messages/${this.id as ""}/reactions/${
emoji as ""
}`
);
}
}
+4 -4
View File
@@ -1,4 +1,4 @@
import type { Server as ApiServer, Category } from "revolt-api";
import type { Category } from "revolt-api";
import { decodeTime } from "ulid";
import { ServerCollection } from "../collections";
@@ -237,14 +237,14 @@ export class Server {
* URL to the server's icon
*/
get iconURL() {
return this.collection.client.generateFileURL(this.icon, { max_side: 256 });
return this.collection.client.createFileURL(this.icon, { max_side: 256 });
}
/**
* URL to the server's animated icon
*/
get animatedIconURL() {
return this.collection.client.generateFileURL(
return this.collection.client.createFileURL(
this.icon,
{ max_side: 256 },
true
@@ -255,7 +255,7 @@ export class Server {
* URL to the server's banner
*/
get bannerURL() {
return this.collection.client.generateFileURL(this.banner, {
return this.collection.client.createFileURL(this.banner, {
max_side: 256,
});
}
+2 -2
View File
@@ -179,7 +179,7 @@ export class ServerMember {
*/
get avatarURL() {
return (
this.collection.client.generateFileURL(this.avatar, { max_side: 256 }) ??
this.collection.client.createFileURL(this.avatar, { max_side: 256 }) ??
this.user?.avatarURL
);
}
@@ -189,7 +189,7 @@ export class ServerMember {
*/
get animatedAvatarURL() {
return (
this.collection.client.generateFileURL(
this.collection.client.createFileURL(
this.avatar,
{ max_side: 256 },
true
+2 -2
View File
@@ -102,7 +102,7 @@ export class User {
*/
get avatarURL() {
return (
this.collection.client.generateFileURL(this.avatar, { max_side: 256 }) ??
this.collection.client.createFileURL(this.avatar, { max_side: 256 }) ??
this.defaultAvatarURL
);
}
@@ -112,7 +112,7 @@ export class User {
*/
get animatedAvatarURL() {
return (
this.collection.client.generateFileURL(
this.collection.client.createFileURL(
this.avatar,
{ max_side: 256 },
true
+10 -1
View File
@@ -2,7 +2,7 @@ import { SetStoreFunction } from "solid-js/store";
import { ReactiveMap } from "@solid-primitives/map";
import { Hydrators, hydrate } from "../hydration";
import { Hydrators } from "../hydration";
import { ObjectStorage } from "../storage/ObjectStorage";
/**
@@ -127,6 +127,15 @@ export abstract class StoreCollection<T, V> extends Collection<T> {
this.#objects.set(id, instance);
}
/**
* Check whether an object is partially defined
* @param id Id
* @returns Whether it is a partial
*/
isPartial(id: string): boolean {
return !!(this.getUnderlyingObject(id) as { partial: boolean }).partial;
}
/**
* Number of stored objects
* @returns Size
+131 -17
View File
@@ -33,12 +33,22 @@ export class ChannelCollection extends ClassCollection<
Channel,
HydratedChannel
> {
/**
* Delete an object
* @param id Id
*/
override delete(id: string): void {
let channel = this.get(id);
channel?.server?.channelIds.delete(id);
super.delete(id);
}
/**
* Fetch channel by ID
* @param id Id
* @returns Channel
*/
async fetch(id: string): Promise<Channel | undefined> {
async fetch(id: string): Promise<Channel> {
const channel = this.get(id);
if (channel) return channel;
const data = await this.client.api.get(`/channels/${id as ""}`);
@@ -49,13 +59,32 @@ export class ChannelCollection extends ClassCollection<
* Get or create
* @param id Id
* @param data Data
* @param isNew Whether this object is new
*/
getOrCreate(id: string, data: API.Channel) {
getOrCreate(id: string, data: API.Channel, isNew = false) {
if (this.has(id)) {
return this.get(id)!;
} else {
const instance = new Channel(this, id);
this.create(id, "channel", instance, data);
isNew && this.client.emit("channelCreate", instance);
return instance;
}
}
/**
* Get or return partial
* @param id Id
*/
getOrPartial(id: string) {
if (this.has(id)) {
return this.get(id)!;
} else if (this.client.options.partials) {
const instance = new Channel(this, id);
this.create(id, "channel", instance, {
id,
partial: true,
});
return instance;
}
}
@@ -67,7 +96,7 @@ export class EmojiCollection extends ClassCollection<Emoji, HydratedEmoji> {
* @param id Id
* @returns Emoji
*/
async fetch(id: string): Promise<Emoji | undefined> {
async fetch(id: string): Promise<Emoji> {
const emoji = this.get(id);
if (emoji) return emoji;
const data = await this.client.api.get(`/custom/emoji/${id as ""}`);
@@ -78,13 +107,31 @@ export class EmojiCollection extends ClassCollection<Emoji, HydratedEmoji> {
* Get or create
* @param id Id
* @param data Data
* @param isNew Whether this object is new
*/
getOrCreate(id: string, data: API.Emoji) {
getOrCreate(id: string, data: API.Emoji, isNew = false) {
if (this.has(id)) {
return this.get(id)!;
} else {
const instance = new Emoji(this, id);
this.create(id, "emoji", instance, data);
isNew && this.client.emit("emojiCreate", instance);
return instance;
}
}
/**
* Get or return partial
* @param id Id
*/
getOrPartial(id: string) {
if (this.has(id)) {
return this.get(id)!;
} else if (this.client.options.partials) {
const instance = new Emoji(this, id);
this.create(id, "emoji", instance, {
id,
});
return instance;
}
}
@@ -100,10 +147,7 @@ export class MessageCollection extends ClassCollection<
* @param messageId Message Id
* @returns Message
*/
async fetch(
channelId: string,
messageId: string
): Promise<Message | undefined> {
async fetch(channelId: string, messageId: string): Promise<Message> {
const message = this.get(messageId);
if (message) return message;
@@ -111,20 +155,39 @@ export class MessageCollection extends ClassCollection<
`/channels/${channelId as ""}/messages/${messageId as ""}`
);
return this.getOrCreate(data._id, data);
return this.getOrCreate(data._id, data, false);
}
/**
* Get or create
* @param id Id
* @param data Data
* @param isNew Whether this object is new
*/
getOrCreate(id: string, data: API.Message) {
getOrCreate(id: string, data: API.Message, isNew = false) {
if (this.has(id)) {
return this.get(id)!;
} else {
const instance = new Message(this, id);
this.create(id, "message", instance, data);
isNew && this.client.emit("messageCreate", instance);
return instance;
}
}
/**
* Get or return partial
* @param id Id
*/
getOrPartial(id: string) {
if (this.has(id)) {
return this.get(id)!;
} else if (this.client.options.partials) {
const instance = new Message(this, id);
this.create(id, "message", instance, {
id,
partial: true,
});
return instance;
}
}
@@ -136,7 +199,7 @@ export class ServerCollection extends ClassCollection<Server, HydratedServer> {
* @param id Id
* @returns Server
*/
async fetch(id: string): Promise<Server | undefined> {
async fetch(id: string): Promise<Server> {
const server = this.get(id);
if (server) return server;
const data = await this.client.api.get(`/servers/${id as ""}`);
@@ -147,13 +210,32 @@ export class ServerCollection extends ClassCollection<Server, HydratedServer> {
* Get or create
* @param id Id
* @param data Data
* @param isNew Whether this object is new
*/
getOrCreate(id: string, data: API.Server) {
getOrCreate(id: string, data: API.Server, isNew = false) {
if (this.has(id)) {
return this.get(id)!;
} else {
const instance = new Server(this, id);
this.create(id, "server", instance, data);
isNew && this.client.emit("serverCreate", instance);
return instance;
}
}
/**
* Get or return partial
* @param id Id
*/
getOrPartial(id: string) {
if (this.has(id)) {
return this.get(id)!;
} else if (this.client.options.partials) {
const instance = new Server(this, id);
this.create(id, "server", instance, {
id,
partial: true,
});
return instance;
}
}
@@ -165,7 +247,7 @@ export class UserCollection extends ClassCollection<User, HydratedUser> {
* @param id Id
* @returns User
*/
async fetch(id: string): Promise<User | undefined> {
async fetch(id: string): Promise<User> {
const user = this.get(id);
if (user) return user;
const data = await this.client.api.get(`/users/${id as ""}`);
@@ -176,6 +258,7 @@ export class UserCollection extends ClassCollection<User, HydratedUser> {
* Get or create
* @param id Id
* @param data Data
* @param isNew Whether this object is new
*/
getOrCreate(id: string, data: API.User) {
if (this.has(id)) {
@@ -186,6 +269,23 @@ export class UserCollection extends ClassCollection<User, HydratedUser> {
return instance;
}
}
/**
* Get or return partial
* @param id Id
*/
getOrPartial(id: string) {
if (this.has(id)) {
return this.get(id)!;
} else if (this.client.options.partials) {
const instance = new User(this, id);
this.create(id, "user", instance, {
id,
partial: true,
});
return instance;
}
}
}
export class ServerMemberCollection extends ClassCollection<
@@ -216,10 +316,7 @@ export class ServerMemberCollection extends ClassCollection<
* @param userId User Id
* @returns Message
*/
async fetch(
serverId: string,
userId: string
): Promise<ServerMember | undefined> {
async fetch(serverId: string, userId: string): Promise<ServerMember> {
const member = this.get(userId);
if (member) return member;
@@ -244,4 +341,21 @@ export class ServerMemberCollection extends ClassCollection<
return instance;
}
}
/**
* Get or return partial
* @param id Id
*/
getOrPartial(id: API.MemberCompositeKey) {
if (this.hasByKey(id)) {
return this.getByKey(id)!;
} else if (this.client.options.partials) {
const instance = new ServerMember(this, id);
this.create(id.server + id.user, "serverMember", instance, {
id,
partial: true,
});
return instance;
}
}
}
+9 -43
View File
@@ -2,7 +2,6 @@ import { Accessor, Setter, createSignal } from "solid-js";
import EventEmitter from "eventemitter3";
import WebSocket from "isomorphic-ws";
import type TypedEmitter from "typed-emitter";
import type { AvailableProtocols, EventProtocol } from ".";
@@ -28,10 +27,9 @@ type Events<T extends AvailableProtocols, P extends EventProtocol<T>> = {
/**
* Simple wrapper around the Revolt websocket service.
*/
class Client<
T extends AvailableProtocols,
P extends EventProtocol<T>
> extends EventEmitter {
export class EventClient<T extends AvailableProtocols> extends EventEmitter<
Events<T, EventProtocol<T>>
> {
#protocolVersion: T;
#transportFormat: "json" | "msgpack";
#heartbeatInterval: number;
@@ -78,7 +76,6 @@ class Client<
private setState(state: ConnectionState) {
this.#setStateSetter(state);
this.emit("state", state);
// TODO: debug weird state changes
}
/**
@@ -107,7 +104,7 @@ class Client<
};
this.#socket.onerror = (error) => {
this.emit("error", error);
this.emit("error", error as never);
};
this.#socket.onmessage = (event) => {
@@ -129,14 +126,16 @@ class Client<
*/
disconnect() {
if (!this.#socket) return;
this.setState(ConnectionState.Disconnected);
this.#socket.close();
this.#socket = undefined;
}
/**
* Send an event to the server.
* @param event Event
*/
send(event: P["client"]) {
send(event: EventProtocol<T>["client"]) {
console.info(event);
}
@@ -144,7 +143,7 @@ class Client<
* Handle events intended for client before passing them along.
* @param event Event
*/
handle(event: P["server"]) {
handle(event: EventProtocol<T>["server"]) {
switch (event.type) {
case "Ping":
this.send({
@@ -156,7 +155,7 @@ class Client<
clearTimeout(this.#pongTimeoutReference);
return;
case "Error":
this.emit("error", event);
this.emit("error", event as never);
this.disconnect();
return;
}
@@ -186,36 +185,3 @@ class Client<
}
}
}
/**
* 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
* @param transportFormat Communication format
* @param heartbeatInterval Interval in seconds to send ping
* @param pongTimeout Time in seconds until heartbeat times out
*/
export function createEventClient<
T extends AvailableProtocols,
P extends EventProtocol<T>
>(
protocolVersion: T,
transportFormat?: "json",
heartbeatInterval?: number,
pongTimeout?: number
) {
return new Client<T, P>(
protocolVersion,
transportFormat,
heartbeatInterval,
pongTimeout
) as never as EventClient<T>;
}
+596
View File
@@ -1,5 +1,9 @@
import { Setter } from "solid-js";
import { ReactiveSet } from "@solid-primitives/set";
import type {
Channel,
Embed,
Emoji,
FieldsChannel,
FieldsMember,
@@ -14,6 +18,9 @@ import type {
User,
} from "revolt-api";
import { Client } from "..";
import { Merge } from "../lib/merge";
/**
* Version 1 of the events protocol
*/
@@ -182,3 +189,592 @@ type WebSocketError = {
| "OnboardingNotFinished"
| "AlreadyAuthenticated";
};
/**
* Handle an event for the Client
* @param client Client
* @param event Event
* @param setReady Signal state change
*/
export async function handleEvent(
client: Client,
event: ServerMessage,
setReady: Setter<boolean>
) {
console.info("[EVENT]", JSON.stringify(event).substring(0, 64));
switch (event.type) {
case "Bulk": {
for (const item of event.v) {
handleEvent(client, item, setReady);
}
break;
}
case "Ready": {
for (const user of event.users) {
const u = client.users.getOrCreate(user._id, user);
if (u.relationship === "User") {
client.user = u;
}
}
for (const server of event.servers) {
client.servers.getOrCreate(server._id, server);
}
for (const member of event.members) {
client.serverMembers.getOrCreate(member._id, member);
}
for (const channel of event.channels) {
client.channels.getOrCreate(channel._id, channel);
}
for (const emoji of event.emojis) {
client.emojis.getOrCreate(emoji._id, emoji);
}
setReady(true);
client.emit("ready");
break;
}
case "Message": {
if (!client.messages.has(event._id)) {
client.messages.getOrCreate(event._id, event, true);
}
break;
}
case "MessageUpdate": {
const message = client.messages.getOrPartial(event.id);
if (message) {
const previousMessage = {
...client.messages.getUnderlyingObject(event.id),
};
client.messages.updateUnderlyingObject(event.id, {
...(event.data as {}),
editedAt: new Date(),
});
client.emit("messageUpdate", message, previousMessage);
}
break;
}
case "MessageAppend": {
const message = client.messages.getOrPartial(event.id);
if (message) {
const previousMessage = {
...client.messages.getUnderlyingObject(event.id),
};
client.messages.updateUnderlyingObject(
event.id,
"embeds",
(embeds) => [...(embeds ?? []), event.append.embeds ?? []] as Embed[]
);
client.emit("messageUpdate", message, previousMessage);
}
break;
}
case "MessageDelete": {
if (client.messages.getOrPartial(event.id)) {
const message = client.messages.getUnderlyingObject(event.id);
client.emit("messageDelete", message);
client.messages.delete(event.id);
}
break;
}
case "BulkMessageDelete": {
client.emit(
"messageDeleteBulk",
event.ids
.map((id) => {
if (client.messages.has(id)) {
const message = client.messages.getUnderlyingObject(id);
client.messages.delete(id);
return message!;
}
return undefined!;
})
.filter((x) => x),
client.channels.get(event.channel)
);
break;
}
case "MessageReact": {
const message = client.messages.getOrPartial(event.id);
if (message) {
const reactions = message.reactions;
const set = reactions.get(event.emoji_id)!;
if (set) {
if (set.has(event.user_id)) return;
set.add(event.user_id);
} else {
reactions.set(event.emoji_id, new ReactiveSet([event.user_id]));
}
client.emit(
"messageReactionAdd",
message,
event.user_id,
event.emoji_id
);
}
break;
}
case "MessageUnreact": {
const message = client.messages.getOrPartial(event.id);
if (message) {
const set = message.reactions.get(event.emoji_id);
if (set?.has(event.user_id)) {
set.delete(event.user_id);
} else if (!client.messages.isPartial(event.id)) {
return;
}
client.emit(
"messageReactionRemove",
message,
event.user_id,
event.emoji_id
);
}
break;
}
case "MessageRemoveReaction": {
const message = client.messages.getOrPartial(event.id);
if (message) {
const reactions = message.reactions;
if (reactions.has(event.emoji_id)) {
reactions.delete(event.emoji_id);
} else if (!client.messages.isPartial(event.id)) {
return;
}
client.emit("messageReactionRemoveEmoji", message, event.emoji_id);
}
break;
}
case "ChannelCreate": {
if (!client.channels.has(event._id)) {
client.channels.getOrCreate(event._id, event, true);
}
break;
}
case "ChannelUpdate": {
const channel = client.channels.getOrPartial(event.id);
if (channel) {
const previousChannel = {
...client.channels.getUnderlyingObject(event.id),
};
const changes = {
...(event.data as Merge<Channel>),
};
if (event.clear) {
for (const remove of event.clear) {
switch (remove) {
case "Description":
changes["description"] = undefined;
break;
case "DefaultPermissions":
changes["default_permissions"] = undefined;
break;
case "Icon":
changes["icon"] = undefined;
break;
}
}
}
client.channels.updateUnderlyingObject(event.id, changes as never);
client.emit("channelUpdate", channel, previousChannel);
}
break;
}
case "ChannelDelete": {
if (client.channels.getOrPartial(event.id)) {
const channel = client.channels.getUnderlyingObject(event.id);
client.emit("channelDelete", channel);
client.channels.delete(event.id);
}
break;
}
case "ChannelGroupJoin": {
const channel = client.channels.getOrPartial(event.id);
if (channel) {
if (!channel.recipientIds.has(event.user)) {
channel.recipientIds.add(event.user);
} else if (!client.channels.isPartial(event.id)) {
return;
}
client.emit(
"channelGroupJoin",
channel,
await client.users.fetch(event.user)
);
}
break;
}
case "ChannelGroupLeave": {
const channel = client.channels.getOrPartial(event.id);
if (channel) {
if (channel.recipientIds.has(event.user)) {
channel.recipientIds.delete(event.user);
} else if (!client.channels.isPartial(event.id)) {
return;
}
client.emit(
"channelGroupLeave",
channel,
client.users.getOrPartial(event.user)!
);
}
break;
}
case "ChannelStartTyping": {
const channel = client.channels.getOrPartial(event.id);
if (channel) {
if (!channel.typingIds.has(event.user)) {
channel.typingIds.add(event.user);
} else if (!client.channels.isPartial(event.id)) {
return;
}
client.emit(
"channelStartTyping",
channel,
client.users.getOrPartial(event.user)!
);
}
break;
}
case "ChannelStopTyping": {
const channel = client.channels.getOrPartial(event.id);
if (channel) {
if (channel.typingIds.has(event.user)) {
channel.typingIds.delete(event.user);
} else if (!client.channels.isPartial(event.id)) {
return;
}
client.emit(
"channelStopTyping",
channel,
client.users.getOrPartial(event.user)!
);
}
break;
}
case "ChannelAck": {
const channel = client.channels.getOrPartial(event.id);
if (channel) {
client.emit("channelAcknowledged", channel, event.message_id);
}
break;
}
case "ServerCreate": {
if (!client.servers.has(event.server._id)) {
for (const channel of event.channels) {
client.channels.getOrCreate(channel._id, channel);
}
client.servers.getOrCreate(event.server._id, event.server, true);
}
break;
}
case "ServerUpdate": {
const server = client.servers.getOrPartial(event.id);
if (server) {
const previousServer = {
...client.servers.getUnderlyingObject(event.id),
};
const changes = {
...event.data,
};
if (event.clear) {
for (const remove of event.clear) {
switch (remove) {
case "Banner":
changes["banner"] = undefined;
break;
case "Categories":
changes["categories"] = undefined;
break;
case "SystemMessages":
changes["system_messages"] = undefined;
break;
case "Description":
changes["description"] = undefined;
break;
case "Icon":
changes["icon"] = undefined;
break;
}
}
}
client.servers.updateUnderlyingObject(event.id, changes as never);
client.emit("serverUpdate", server, previousServer);
}
break;
}
case "ServerDelete": {
if (client.servers.getOrPartial(event.id)) {
const server = client.servers.getUnderlyingObject(event.id);
client.emit("serverDelete", server);
client.servers.delete(event.id);
for (const channel of server.channelIds) {
client.channels.delete(channel);
}
}
break;
}
case "ServerRoleUpdate": {
const server = client.servers.getOrPartial(event.id);
if (server) {
const role = server.roles.get(event.role_id) ?? {};
server.roles.set(event.role_id, {
...role,
...event.data,
} as Role);
client.emit("serverRoleUpdate", server, event.role_id, role as never);
}
break;
}
case "ServerRoleDelete": {
const server = client.servers.getOrPartial(event.id);
if (server) {
let role = {};
const roles = server.roles;
if (roles.has(event.role_id)) {
role = roles.get(event.role_id) as Role;
roles.delete(event.role_id);
} else if (!client.servers.isPartial(event.id)) {
return;
}
client.emit("serverRoleDelete", server, event.role_id, role as never);
}
break;
}
case "ServerMemberJoin": {
const id = {
server: event.id,
user: event.user,
};
if (!client.serverMembers.hasByKey(id)) {
client.emit(
"serverMemberJoin",
client.serverMembers.getOrCreate(id, {
_id: id,
joined_at: new Date().toUTCString(),
})
);
}
break;
}
case "ServerMemberUpdate": {
const member = client.serverMembers.getOrPartial(event.id);
if (member) {
const previousMember = {
...client.serverMembers.getUnderlyingObject(
event.id.server + event.id.user
),
};
const changes = {
...event.data,
};
if (event.clear) {
for (const remove of event.clear) {
switch (remove) {
case "Nickname":
changes["nickname"] = undefined;
break;
case "Avatar":
changes["avatar"] = undefined;
break;
case "Roles":
changes["roles"] = undefined;
break;
case "Timeout":
changes["timeout"] = undefined;
break;
}
}
}
client.serverMembers.updateUnderlyingObject(
event.id.server + event.id.user,
changes as never
);
client.emit("serverMemberUpdate", member, previousMember);
}
break;
}
case "ServerMemberLeave": {
const id = {
server: event.id,
user: event.user,
};
if (client.serverMembers.getOrPartial(id)) {
const member = client.serverMembers.getUnderlyingObject(
id.server + id.user
);
client.emit("serverMemberLeave", member);
client.serverMembers.delete(id.server + id.user);
}
break;
}
case "UserUpdate": {
const user = client.users.getOrPartial(event.id);
if (user) {
const previousUser = {
...client.users.getUnderlyingObject(event.id),
};
const changes = {
...event.data,
};
if (event.clear) {
for (const remove of event.clear) {
switch (remove) {
case "Avatar":
changes["avatar"] = undefined;
break;
case "StatusPresence":
changes["status"] = {
...(previousUser.status ?? {}),
...(changes["status"] ?? {}),
presence: undefined,
};
break;
case "StatusText":
changes["status"] = {
...(previousUser.status ?? {}),
...(changes["status"] ?? {}),
text: undefined,
};
break;
}
}
}
client.users.updateUnderlyingObject(event.id, changes as never);
client.emit("userUpdate", user, previousUser);
}
break;
}
case "UserRelationship": {
handleEvent(
client,
{
type: "UserUpdate",
id: event.user._id,
data: {
relationship: event.user.relationship!,
},
},
setReady
);
break;
}
case "UserPresence": {
handleEvent(
client,
{
type: "UserUpdate",
id: event.id,
data: {
online: event.online,
},
},
setReady
);
break;
}
case "UserSettingsUpdate": {
client.emit("userSettingsUpdate", event.id, event.update);
break;
}
case "UserPlatformWipe": {
handleEvent(
client,
{
type: "BulkMessageDelete",
channel: "0",
ids: client.messages
.toList()
.filter((message) => message.authorId === event.user_id)
.map((message) => message.id),
},
setReady
);
handleEvent(
client,
{
type: "UserUpdate",
id: event.user_id,
data: {
username: `Deleted User`,
online: false,
flags: event.flags,
badges: 0,
relationship: "None",
},
clear: ["Avatar", "StatusPresence", "StatusText"],
},
setReady
);
break;
}
case "EmojiCreate": {
if (!client.emojis.has(event._id)) {
client.emojis.getOrCreate(event._id, event, true);
}
break;
}
case "EmojiDelete": {
if (client.emojis.getOrPartial(event.id)) {
const emoji = client.emojis.getUnderlyingObject(event.id);
client.emit("emojiDelete", emoji);
client.emojis.delete(event.id);
}
break;
}
case "Auth": {
if (typeof client.session === "string") throw "Unreachable code";
switch (event.event_type) {
case "DeleteSession":
if (event.session_id === client.session!._id) {
// TODO: logout
}
break;
case "DeleteAllSessions":
if (event.exclude_session_id !== client.session!._id) {
// TODO: logout
}
break;
}
break;
}
}
}
+9 -2
View File
@@ -1,3 +1,4 @@
import { ReactiveSet } from "@solid-primitives/set";
import { Channel as ApiChannel, File, OverrideField } from "revolt-api";
import type { Merge } from "../lib/merge";
@@ -13,7 +14,8 @@ export type HydratedChannel = {
icon?: File;
active: boolean;
recipientIds: string[];
typingIds: ReactiveSet<string>;
recipientIds: ReactiveSet<string>;
userId?: string;
ownerId?: string;
@@ -46,7 +48,8 @@ export const channelHydration: Hydrate<Merge<ApiChannel>, HydratedChannel> = {
description: (channel) => channel.description!,
icon: (channel) => channel.icon!,
active: (channel) => channel.active || false,
recipientIds: (channel) => channel.recipients ?? [],
typingIds: () => new ReactiveSet(),
recipientIds: (channel) => new ReactiveSet(channel.recipients),
userId: (channel) => channel.user,
ownerId: (channel) => channel.owner,
serverId: (channel) => channel.server,
@@ -56,4 +59,8 @@ export const channelHydration: Hydrate<Merge<ApiChannel>, HydratedChannel> = {
nsfw: (channel) => channel.nsfw || false,
lastMessageId: (channel) => channel.last_message_id!,
},
initialHydration: () => ({
typingIds: new ReactiveSet(),
recipientIds: new ReactiveSet(),
}),
};
+1
View File
@@ -26,4 +26,5 @@ export const emojiHydration: Hydrate<Merge<ApiEmoji>, HydratedEmoji> = {
animated: (emoji) => emoji.animated || false,
nsfw: (emoji) => emoji.nsfw || false,
},
initialHydration: () => ({}),
};
+8 -4
View File
@@ -31,6 +31,7 @@ export type KeyMapping<Input, Output> = Record<keyof Input, keyof Output>;
export type Hydrate<Input, Output> = {
keyMapping: Partial<KeyMapping<Input, Output>>;
functions: MappingFns<Input, Output, keyof Output>;
initialHydration: () => Partial<Output>;
};
/**
@@ -81,13 +82,16 @@ 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
* @param initial Whether this is the initial hydration
* @returns Hydrated Object
*/
export function hydrate<T extends keyof Hydrators>(
type: T,
input: Partial<ExtractInput<Hydrators[T]>>
input: Partial<ExtractInput<Hydrators[T]>>,
initial?: boolean
) {
return hydrateInternal(hydrators[type] as never, input) as ExtractOutput<
Hydrators[T]
>;
return hydrateInternal(
hydrators[type] as never,
initial ? { ...hydrators[type].initialHydration(), ...input } : input
) as ExtractOutput<Hydrators[T]>;
}
+4 -1
View File
@@ -25,7 +25,7 @@ export type HydratedMessage = {
embeds?: Embed[];
mentionIds?: string[];
replyIds?: string[];
reactions?: ReactiveMap<string, ReactiveSet<string>>;
reactions: ReactiveMap<string, ReactiveSet<string>>;
interactions?: Interactions;
masquerade?: Masquerade;
};
@@ -64,4 +64,7 @@ export const messageHydration: Hydrate<Merge<ApiMessage>, HydratedMessage> = {
interactions: (message) => message.interactions,
masquerade: (message) => message.masquerade!,
},
initialHydration: () => ({
reactions: new ReactiveMap(),
}),
};
+5 -1
View File
@@ -24,7 +24,7 @@ export type HydratedServer = {
categories?: Category[];
systemMessages?: SystemMessageChannels;
roles?: ReactiveMap<string, Role>;
roles: ReactiveMap<string, Role>;
defaultPermissions: number;
flags: ServerFlags;
@@ -61,6 +61,10 @@ export const serverHydration: Hydrate<ApiServer, HydratedServer> = {
discoverable: (server) => server.discoverable || false,
nsfw: (server) => server.nsfw || false,
},
initialHydration: () => ({
channelIds: new ReactiveSet(),
roles: new ReactiveMap(),
}),
};
/**
+4 -1
View File
@@ -9,7 +9,7 @@ export type HydratedServerMember = {
joinedAt: Date;
nickname?: string;
avatar?: File;
roles?: string[];
roles: string[];
timeout?: Date;
};
@@ -29,4 +29,7 @@ export const serverMemberHydration: Hydrate<
roles: (member) => member.roles,
timeout: (member) => new Date(member.timeout!),
},
initialHydration: () => ({
roles: [],
}),
};
+3
View File
@@ -43,6 +43,9 @@ export const userHydration: Hydrate<ApiUser, HydratedUser> = {
status: (user) => user.status! ?? undefined,
bot: (user) => user.bot! ?? undefined,
},
initialHydration: () => ({
relationship: "None",
}),
};
/**
+1 -1
View File
@@ -36,7 +36,7 @@ export class ObjectStorage<T> {
*/
hydrate(id: string, type: keyof Hydrators, data?: unknown) {
if (data) {
this.set(id, hydrate(type, data as never) as T);
this.set(id, hydrate(type, data as never, true) as T);
}
}
}