Add websocket.

This commit is contained in:
Paul Makles
2020-12-31 11:51:49 +00:00
parent ba0b922d36
commit 715c532634
12 changed files with 164 additions and 388 deletions
+22 -7
View File
@@ -2,15 +2,20 @@ import Axios, { AxiosInstance } from 'axios';
import { defaultsDeep } from 'lodash';
import { EventEmitter } from 'events';
import { defaultConfig } from '.';
import { Core } from './api/core';
import { Auth } from './api/auth';
import { Onboarding } from './api/onboarding';
import { defaultConfig } from '.';
import { runInThisContext } from 'vm';
import { WebSocketClient } from './websocket/client';
import User from './objects/User';
import { Users } from './api/users';
export interface ClientOptions {
apiURL: string,
wsURL: string
wsURL: string,
autoReconnect: boolean
}
export declare interface Client {
@@ -23,13 +28,21 @@ export declare interface Client {
export class Client extends EventEmitter {
Axios: AxiosInstance;
options: ClientOptions;
websocket: WebSocketClient;
configuration?: Core.RevoltNodeConfiguration;
session?: Auth.Session;
users: Map<string, User>;
user?: User;
constructor(options: Partial<ClientOptions> = {}) {
super();
this.options = defaultsDeep(options, defaultConfig);
this.Axios = Axios.create({ baseURL: this.options.apiURL });
this.websocket = new WebSocketClient(this);
this.users = new Map();
}
// Stage 1: Connect to Revolt.
@@ -37,12 +50,12 @@ export class Client extends EventEmitter {
this.configuration = (await this.Axios.get('/')).data;
}
$checkConfiguration() {
private $checkConfiguration() {
if (typeof this.configuration === 'undefined')
throw new Error("No configuration synced from Revolt node yet. Use client.connect();");
}
$generateHeaders(session: Auth.Session | undefined = this.session) {
private $generateHeaders(session: Auth.Session | undefined = this.session) {
return {
'x-user-id': session?.user_id,
'x-session-token': session?.session_token
@@ -51,26 +64,28 @@ export class Client extends EventEmitter {
// Login to Revolt.
async login(details: Auth.LoginRequest) {
this.$checkConfiguration();
this.session = (await this.Axios.post('/auth/login', details)).data;
return await this.$connect();
}
// Use an existing session to log into Revolt.
async useExistingSession(session: Auth.Session) {
this.$checkConfiguration();
await this.Axios.get('/auth/check', { headers: this.$generateHeaders(session) });
this.session = session;
return await this.$connect();
}
// Check onboarding status and connect to notifications service.
async $connect() {
private async $connect() {
this.Axios.defaults.headers = this.$generateHeaders();
let { onboarding } = (await this.Axios.get('/onboard/hello')).data;
if (onboarding) {
return (username: string) => this.completeOnboarding({ username });
}
console.log((await this.Axios.get('/users/' + this.session?.user_id)).data);
await this.websocket.connect();
}
// Complete onboarding if required.
-42
View File
@@ -1,42 +0,0 @@
export namespace Account {
// POST /create
export interface CreateRequest {
username: string,
password: string,
email: string,
captcha?: string,
}
export interface CreateResponse {
email_sent?: boolean,
}
// POST /login
export interface LoginRequest {
email: string,
password: string,
captcha?: string,
}
export interface LoginResponse {
access_token: string,
id: string,
}
// POST /resend
export interface ResendRequest {
email: string,
captcha?: string,
}
export interface ResendResponse { }
// POST /token
export interface TokenRequest {
token: string,
}
export interface TokenResponse {
id: string,
}
}
-94
View File
@@ -1,94 +0,0 @@
export enum ChannelType {
DM = 0,
GROUPDM = 1,
GUILDCHANNEL = 2,
}
export interface LastMessage {
id: string,
user_id: string,
short_content: string
}
export type RawChannel = {
id: string
} & (
({
type: ChannelType.DM | ChannelType.GROUPDM,
recipients: string[],
last_message: LastMessage
} & (
{ type: ChannelType.DM } |
{
type: ChannelType.GROUPDM,
recipients: string[],
owner: string,
name: string,
description: string,
}
))
|
{
type: ChannelType.GUILDCHANNEL,
guild: string,
name: string,
description: string
}
);
export type RawMessage = {
id: string,
nonce?: string,
author: string,
content: string,
edited: number | null,
};
export namespace Channels {
// GET /:id
export type ChannelResponse = RawChannel;
// DELETE /:id
export interface DeleteChannelResponse { };
// GET /:id/messages
export type MessagesResponse = RawMessage[];
// POST /:id/messages
export interface SendMessageRequest {
content: string,
nonce: string,
}
export interface SendMessageResponse {
id: string,
}
// GET /:id/messages/:mid
export type MessageResponse = RawMessage;
// PATCH /:id/messages/:mid
export interface EditMessageRequest {
content: string,
}
export interface EditMessageResponse { }
// DELETE /:id/messages/:mid
export interface DeleteMessageResponse { }
// POST /create
export interface CreateGroupRequest {
name: string,
nonce: string,
users: string[]
}
export interface CreateGroupResponse { }
// PUT /:id/recipients/:uid
export interface AddToGroupResponse { }
// DELETE /:id/recipients/:uid
export interface RemoveFromGroupResponse { }
}
-95
View File
@@ -1,95 +0,0 @@
import { RawChannel } from "./channels";
export interface CoreGuild {
id: string,
name: string,
description: string,
owner: string,
channels: RawChannel[]
}
export namespace Guild {
// GET /@me
export type GuildsResponse = CoreGuild[];
// GET /:id
export type GuildResponse = CoreGuild;
// DELETE /:id
export interface GuildDeleteResponse { }
// POST /create
export interface CreateGuildRequest {
name: string,
description?: string,
nonce: string
}
export interface CreateGuildResponse {
id: string
}
// POST /:id/channels
export interface CreateChannelRequest {
name: string,
description?: string,
nonce: string
}
export interface CreateChannelResponse {
id: string
}
// GET /:id/members
export type MembersResponse = MemberResponse[];
// GET /:id/members/:uid
export interface MemberResponse {
id: string,
nickname?: string
}
// DELETE /:id/members/:uid
export interface KickMemberResponse { }
// PUT /:id/members/:uid/ban
export interface BanMemberResponse { }
// DELETE /:id/members/:uid/ban
export interface UnbanMemberResponse { }
// POST /:id/channels/:cid/invite
export interface CreateInviteRequest { }
export interface CreateInviteResponse {
code: string
}
// GET /join/:code
export interface InvitePreviewResponse {
guild: {
id: string,
name: string
},
channel: {
id: string,
name: string
}
}
// POST /join/:code
export interface AcceptInviteResponse {
guild: string,
channel: string
}
// GET /:id/invites
export type InvitesResponse = {
code: string,
creator: string,
channel: string
}[]
// DELETE /:id/invites/:code
export interface DeleteInviteResponse { }
}
-78
View File
@@ -1,78 +0,0 @@
import { Relationship, Users } from './users';
import { RawChannel } from './channels';
import { CoreGuild } from './guild';
export * from './account';
export * from './users';
export * from './channels';
export * from './guild';
export namespace WebsocketPackets {
export interface ready {
channels: RawChannel[],
guilds: CoreGuild[],
user: Users.UserResponse,
users: Users.UserResponse[]
}
export interface message_create {
id: string,
nonce?: string,
channel: string,
author: string,
content: string
}
export interface message_edit {
id: string,
channel: string,
author: string,
content: string
}
export interface message_delete {
id: string
}
export interface group_user_join {
id: string,
user: string
}
export interface group_user_leave {
id: string,
user: string
}
export interface guild_user_join {
id: string,
user: string
}
export interface guild_user_leave {
id: string,
user: string
}
export interface guild_channel_create {
id: string,
channel: string,
name: string,
description: string
}
export interface guild_channel_delete {
id: string,
channel: string
}
export interface guild_delete {
id: string
}
export interface user_friend_status {
id: string,
user: string,
status: Relationship
}
}
-67
View File
@@ -1,67 +0,0 @@
import { RawChannel } from "./channels";
export enum Relationship {
FRIEND = 0,
OUTGOING = 1,
INCOMING = 2,
BLOCKED = 3,
BLOCKED_OTHER = 4,
NONE = 5,
SELF = 6,
}
export namespace Users {
// GET /@me or /:id
export interface UserResponse {
id: string,
username: string,
display_name: string,
email?: string,
verified?: boolean,
relationship?: Relationship
}
// POST /query
export interface QueryRequest {
username: string,
}
export type QueryResponse = UserResponse;
// GET /@me/dms
export type DMsResponse = RawChannel[];
// GET /:id/dm
export interface OpenDMResponse {
id: string
}
// GET /@me/friend
export type FriendsResponse = FriendResponse[];
// GET /:id/friend
export interface FriendResponse {
id: string,
status: Relationship,
}
// PUT /:id/friend
export interface AddFriendResponse {
status: Relationship,
}
// DELETE /:id/friend
export interface RemoveFriendResponse {
status: Relationship,
}
// PUT /:id/block
export interface BlockUserResponse {
status: Relationship,
}
// DELETE /:id/block
export interface UnblockUserResponse {
status: Relationship,
}
}
+11
View File
@@ -7,3 +7,14 @@ export enum Relationship {
Blocked = "Blocked",
BlockedOther = "BlockedOther",
}
export type Relationships = { _id: string, status: Relationship }[];
export namespace Users {
// GET /:id
export interface User {
_id: string,
username: string,
relations?: Relationships
}
}
+3 -1
View File
@@ -16,5 +16,7 @@ export const LIBRARY_VERSION = {
export const defaultConfig = {
apiURL: 'https://api.revolt.chat',
wsURL: 'wss://api.revolt.chat/ws'
wsURL: 'wss://ws.revolt.chat',
autoReconnect: true
};
+30
View File
@@ -0,0 +1,30 @@
import { Client } from '..';
import { Users, Relationships } from '../api/users';
export default class User {
client: Client;
id: string;
username: string;
_relations?: Relationships;
constructor(client: Client, data: Users.User) {
this.client = client;
this.id = data._id;
this.username = data.username;
this._relations = data.relations;
}
static async fetch(client: Client, id: string, data?: Users.User): Promise<User> {
let existing;
if (existing = client.users.get(id)) {
return existing;
}
let user = new User(client, data ?? (await client.Axios.get(`/users/${id}`)).data);
client.users.set(id, user);
return user;
}
}
+12 -4
View File
@@ -4,13 +4,21 @@ config();
import { Client } from "./Client";
let client = new Client();
client.on('ready', () => {
console.log(`Logged in as @${client.user?.username}`);
});
(async () => {
console.log('Start:', new Date());
await client.connect();
let onboarding = await client.login({ email: 'mink3@insrt.uk', password: 'password', device_name: 'aaa' });
if (onboarding) {
await onboarding("poggers");
try {
await client.connect();
let onboarding = await client.login({ email: 'mink@insrt.uk', password: 'password', device_name: 'aaa' });
if (onboarding) {
await onboarding("username");
}
} catch (err) {
console.error(err);
}
console.log('End: ', new Date());
+69
View File
@@ -0,0 +1,69 @@
import WebSocket from 'isomorphic-ws';
import { Client } from '..';
import { Auth } from '../api/auth';
import { ServerboundNotification, ClientboundNotification } from './notifications';
import User from '../objects/User';
export class WebSocketClient {
client: Client;
ws?: WebSocket;
constructor(client: Client) {
this.client = client;
}
connect(): Promise<void> {
return new Promise((resolve, $reject) => {
let thrown = false;
const reject = (err: any) => {
if (!thrown) {
thrown = true;
$reject(err);
}
};
if (typeof this.ws !== 'undefined' && this.ws.readyState === WebSocket.OPEN) {
this.ws.close();
}
if (typeof this.client.session === 'undefined') {
throw new Error("Attempted to open WebSocket without valid session.");
}
let ws = new WebSocket(this.client.options.wsURL);
const send = (notification: ServerboundNotification) => ws.send(JSON.stringify(notification));
ws.onopen = () => {
send({ type: 'Authenticate', ...this.client.session as Auth.Session });
};
ws.onmessage = async (msg) => {
let data = msg.data;
if (typeof data !== 'string') return;
let packet = JSON.parse(data) as ClientboundNotification;
switch (packet.type) {
case 'Error': reject(packet.error); break;
case 'Authenticated': this.client.emit('connected'); break;
case 'Ready': {
this.client.user = await User.fetch(this.client, packet.user._id, packet.user);
this.client.emit('ready');
resolve();
break;
}
}
};
ws.onerror = (err) => {
reject(err);
}
ws.onclose = () => {
this.client.emit('dropped');
};
});
}
}
+17
View File
@@ -0,0 +1,17 @@
import { Auth } from '../api/auth';
import { Relationship, Users } from '../api/users';
type WebSocketError = {
error: 'InternalError' | 'InvalidSession' | 'OnboardingNotFinished' | 'AlreadyAuthenticated'
};
export type ServerboundNotification = (
({ type: 'Authenticate' } & Auth.Session)
);
export type ClientboundNotification = (
({ type: 'Error' } & WebSocketError) |
{ type: 'Authenticated' } |
{ type: 'Ready', user: Users.User } |
{ type: 'UserRelationship', user: string, status: Relationship }
)