diff --git a/src/Client.ts b/src/Client.ts index c921274d..e9c18c83 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -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; + user?: User; + constructor(options: Partial = {}) { 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. diff --git a/src/api.old/account.ts b/src/api.old/account.ts deleted file mode 100644 index 340355bb..00000000 --- a/src/api.old/account.ts +++ /dev/null @@ -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, - } -} diff --git a/src/api.old/channels.ts b/src/api.old/channels.ts deleted file mode 100644 index 9eaca30b..00000000 --- a/src/api.old/channels.ts +++ /dev/null @@ -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 { } -} diff --git a/src/api.old/guild.ts b/src/api.old/guild.ts deleted file mode 100644 index 02dccb89..00000000 --- a/src/api.old/guild.ts +++ /dev/null @@ -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 { } -} \ No newline at end of file diff --git a/src/api.old/index.ts b/src/api.old/index.ts deleted file mode 100644 index 15bc748e..00000000 --- a/src/api.old/index.ts +++ /dev/null @@ -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 - } -} diff --git a/src/api.old/users.ts b/src/api.old/users.ts deleted file mode 100644 index 0ce9aa42..00000000 --- a/src/api.old/users.ts +++ /dev/null @@ -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, - } -} diff --git a/src/api/users.ts b/src/api/users.ts index ccaba019..8ab18518 100644 --- a/src/api/users.ts +++ b/src/api/users.ts @@ -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 + } +} diff --git a/src/index.ts b/src/index.ts index 5961d5b5..53f4719e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 }; diff --git a/src/objects/User.ts b/src/objects/User.ts new file mode 100644 index 00000000..2933fed3 --- /dev/null +++ b/src/objects/User.ts @@ -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 { + 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; + } +} diff --git a/src/tester.ts b/src/tester.ts index 7b85ecaf..74fb5003 100644 --- a/src/tester.ts +++ b/src/tester.ts @@ -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()); diff --git a/src/websocket/client.ts b/src/websocket/client.ts new file mode 100644 index 00000000..ef6bc767 --- /dev/null +++ b/src/websocket/client.ts @@ -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 { + 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'); + }; + }); + } +} diff --git a/src/websocket/notifications.ts b/src/websocket/notifications.ts new file mode 100644 index 00000000..a58d55ad --- /dev/null +++ b/src/websocket/notifications.ts @@ -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 } +)