merge: pull request #70 from revoltchat/insert/feat/store-rewrite

This commit is contained in:
Paul Makles
2023-04-14 20:36:04 +01:00
committed by GitHub
79 changed files with 8506 additions and 7429 deletions
-4
View File
@@ -1,4 +0,0 @@
API_URL=https://api.revolt.chat
EMAIL=1@example.com
PASSWORD=uNiquePassword!2
BOT_TOKEN=abc
+49
View File
@@ -0,0 +1,49 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": ["solid", "spellcheck"],
"extends": [
"eslint:recommended",
"plugin:solid/typescript",
"plugin:@typescript-eslint/recommended",
"prettier"
],
"rules": {
"@typescript-eslint/no-non-null-assertion": "off",
"spellcheck/spell-checker": [
"warn",
{
"lang": "en_GB",
"strings": false,
"identifiers": false,
"templates": false,
"skipWords": ["uri", "webhook", "unreads"],
"minLength": 3
}
],
"@typescript-eslint/no-unused-vars": [
"warn",
{
"varsIgnorePattern": "^_"
}
],
"no-unused-vars": [
"warn",
{
"varsIgnorePattern": "^_"
}
],
"require-jsdoc": [
"warn",
{
"require": {
"FunctionDeclaration": true,
"MethodDefinition": true,
"ClassDeclaration": true,
"ArrowFunctionExpression": true,
"FunctionExpression": true
}
}
]
}
}
+22 -17
View File
@@ -2,27 +2,32 @@ name: Publish Documentation
on:
push:
branches: [ master ]
branches: [master]
jobs:
docs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Checkout repository and submodules
uses: actions/checkout@v3
with:
submodules: recursive
- uses: pnpm/action-setup@v2.2.4
with:
version: 7
- name: Use Node.js 18
uses: actions/setup-node@v3
with:
node-version: 18
cache: "pnpm"
- name: Install packages
run: pnpm install
- name: Setup Node
uses: actions/setup-node@v2
with:
node-version: 16
- name: Build
run: pnpm run docs
- name: Install dependencies
run: yarn install
- name: Build
run: yarn run docs
- name: Deploy
uses: JamesIves/github-pages-deploy-action@4.1.5
with:
branch: docs
folder: docs
- name: Deploy
uses: JamesIves/github-pages-deploy-action@4.1.5
with:
branch: docs
folder: docs
+37
View File
@@ -0,0 +1,37 @@
name: Lint
on:
push:
branches: [master]
pull_request:
branches: [master]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [16.x, 18.x]
steps:
- name: Checkout repository and submodules
uses: actions/checkout@v3
with:
submodules: recursive
- uses: pnpm/action-setup@v2.2.4
with:
version: 7
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
cache: "pnpm"
- name: Install packages
run: pnpm install
- name: Typecheck
run: pnpm typecheck
- name: Code Formatting
run: pnpm fmt:check
- name: Lint
run: pnpm lint
-24
View File
@@ -1,24 +0,0 @@
name: Typecheck
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [12.x, 14.x, 16.x, 18.x]
steps:
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node-version }}
cache: 'yarn'
- run: yarn install
- run: yarn run typecheck
+2 -2
View File
@@ -1,9 +1,9 @@
# build
node_modules
.env
dist
docs
esm
todo
/lib
# yarn
.pnp.*
+13
View File
@@ -0,0 +1,13 @@
{
"tabWidth": 2,
"useTabs": false,
"plugins": ["@trivago/prettier-plugin-sort-imports"],
"importOrder": [
"^solid",
"<THIRD_PARTY_MODULES>",
"^\\.\\.",
"^[./]"
],
"importOrderSeparation": true,
"importOrderSortSpecifiers": true
}
-5
View File
@@ -1,5 +0,0 @@
module.exports = {
tabWidth: 4,
trailingComma: "all",
jsxBracketSameLine: true,
};
-783
View File
File diff suppressed because one or more lines are too long
-2
View File
@@ -1,2 +0,0 @@
yarnPath: .yarn/releases/yarn-3.2.3.cjs
nodeLinker: node-modules
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2021 Paul Makles
Copyright (c) 2021-2023 Paul Makles
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+28 -62
View File
@@ -1,95 +1,61 @@
# revolt.js
> **Warning**
> revolt.js is currently being rewritten, it's pretty much ready for use for most applications but is still not entirely feature complete.
>
> You can find the [version 6 README here](https://github.com/revoltchat/revolt.js/tree/v6).
![revolt.js](https://img.shields.io/npm/v/revolt.js) ![revolt-api](https://img.shields.io/npm/v/revolt-api?label=Revolt%20API)
**revolt.js** is a direct implementation of the entire Revolt API and provides a way to authenticate and start communicating with Revolt servers.
**revolt.js** is a JavaScript library for interacting with the entire Revolt API.
## Example Usage (Javascript / ES6)
## Example Usage
```javascript
// esm / typescript
import { Client } from "revolt.js";
let client = new Client();
client.on("ready", async () =>
console.info(`Logged in as ${client.user.username}!`),
);
client.on("message", async (message) => {
if (message.content === "hello") {
message.channel.sendMessage("world");
}
});
client.loginBot("..");
```
If you are using Node, you must specify `--experimental-specifier-resolution=node`.
For example, `node --experimental-specifier-resolution=node index.js`.
## Example Usage (CommonJS)
```javascript
// ...or commonjs
const { Client } = require("revolt.js");
let client = new Client();
client.on("ready", async () =>
console.info(`Logged in as ${client.user.username}!`),
console.info(`Logged in as ${client.user.username}!`)
);
client.on("message", async (message) => {
if (message.content === "hello") {
message.channel.sendMessage("world");
}
client.on("messageCreate", async (message) => {
if (message.content === "hello") {
message.channel.sendMessage("world");
}
});
client.loginBot("..");
```
## Example Usage (Typescript)
## Reactivity with Signals & Solid.js Primitives
```typescript
import { Client } from "revolt.js";
All objects have reactivity built-in and can be dropped straight into any Solid.js project.
let client = new Client();
```tsx
const client = new Client();
// initialise the client
client.on("ready", async () =>
console.info(`Logged in as ${client.user!.username}!`),
);
client.on("message", async (message) => {
if (message.content === "hello") {
message.channel!.sendMessage("world");
}
});
client.loginBot("..");
```
## MobX
MobX is used behind the scenes so you can subscribe to any change as you normally would, e.g. with `mobx-react(-lite)` or mobx's utility functions.
```typescript
import { autorun } from 'mobx';
[..]
client.once('ready', () => {
autorun(() => {
console.log(`Current username is ${client.user!.username}!`);
});
});
function MyApp() {
return (
<h1>Your username is: {client.user?.username ?? "[logging in...]"}</h1>
);
}
```
## Revolt API Types
All `revolt-api` types are re-exported from this library under `API`.
> **Warning**
> It is advised you do not use this unless necessary, if you find somewhere that isn't covered by the library, please open an issue as this library aims to transform all objects.
```typescript
import { API } from 'revolt.js';
import { API } from "revolt.js";
// API.Channel;
// API.[..];
+53 -92
View File
@@ -1,94 +1,55 @@
{
"name": "revolt.js",
"version": "6.0.20",
"main": "dist/index.js",
"typings": "dist/index.d.ts",
"module": "esm/index.js",
"repository": "https://github.com/revoltchat/revolt.js",
"author": "Paul Makles <insrt.uk>",
"license": "MIT",
"dependencies": {
"@insertish/exponential-backoff": "3.1.0-patch.2",
"@insertish/isomorphic-ws": "^4.0.1",
"axios": "^0.21.4",
"eventemitter3": "^4.0.7",
"lodash.defaultsdeep": "^4.6.1",
"lodash.flatten": "^4.4.0",
"lodash.isequal": "^4.5.0",
"long": "^5.2.0",
"mobx": "^6.3.2",
"revolt-api": "0.5.17",
"ulid": "^2.3.0",
"ws": "^8.2.2"
},
"devDependencies": {
"@types/events": "^3.0.0",
"@types/lodash": "^4.14.168",
"@types/lodash.defaultsdeep": "^4.6.6",
"@types/lodash.flatten": "^4.4.6",
"@types/lodash.isequal": "^4.5.5",
"@types/node": "^14.14.31",
"@types/ws": "^7.2.1",
"@typescript-eslint/eslint-plugin": "^5.3.1",
"@typescript-eslint/parser": "^5.3.1",
"dotenv": "^8.2.0",
"eslint": "^8.2.0",
"in-publish": "^2.0.1",
"jsdoc-babel": "^0.5.0",
"node-fetch": "^2.6.1",
"prettier": "^2.4.1",
"rimraf": "^3.0.2",
"tsc-watch": "^4.1.0",
"typedoc": "^0.21.4",
"typescript": "^4.4.4"
},
"eslintConfig": {
"parser": "@typescript-eslint/parser",
"plugins": [
"mobx"
],
"extends": [
"plugin:@typescript-eslint/recommended",
"plugin:mobx/recommended"
],
"ignorePatterns": [
"build/"
],
"rules": {
"radix": "off",
"no-spaced-func": "off",
"react/no-danger": "off",
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/no-unused-vars": [
"warn",
{
"varsIgnorePattern": "^_"
}
],
"no-unused-vars": [
"warn",
{
"varsIgnorePattern": "^_"
}
]
}
},
"scripts": {
"dev": "tsc-watch --onSuccess \"node --experimental-specifier-resolution=node dist/tester\"",
"prepublish": "in-publish && tsc || echo Skipping build.",
"build": "rimraf dist esm && tsc && tsc -p tsconfig.cjs.json",
"build:watch": "tsc-watch",
"typecheck": "tsc --noEmit",
"docs": "typedoc --readme README.md src/",
"lint": "eslint 'src/**/*.{js,jsx,ts,tsx}'",
"fmt": "prettier --write 'src/**/*.{js,jsx,ts,tsx}'"
},
"files": [
"README.md",
"dist",
"esm"
],
"description": "Library for interacting with the Revolt API.",
"packageManager": "yarn@3.2.3"
"name": "revolt.js",
"version": "7.0.0-beta.1",
"main": "lib/cjs/index.js",
"module": "src/index.ts",
"types": "src/index.ts",
"repository": "https://github.com/revoltchat/revolt.js",
"author": "Paul Makles <insrt.uk>",
"license": "MIT",
"scripts": {
"build": "tsc",
"build:watch": "tsc-watch --onSuccess \"node .\"",
"lint": "eslint --ext .ts,.tsx src/",
"lint:fix": "eslint --fix --ext .ts,.tsx src/",
"typecheck": "tsc --noEmit",
"docs": "typedoc --plugin @mxssfd/typedoc-theme --theme my-theme --readme README.md src/",
"fmt": "prettier --write 'src/**/*.{js,jsx,ts,tsx}'",
"fmt:check": "prettier --check 'src/**/*.{js,jsx,ts,tsx}'"
},
"files": [
"README.md",
"lib"
],
"description": "Library for interacting with the Revolt API.",
"packageManager": "pnpm@7.14.2",
"dependencies": {
"@mxssfd/typedoc-theme": "^1.1.1",
"@solid-primitives/map": "^0.4.3",
"@solid-primitives/set": "^0.4.3",
"eventemitter3": "^5.0.0",
"isomorphic-ws": "^5.0.0",
"long": "^5.2.1",
"revolt-api": "^0.5.17",
"solid-js": "^1.7.2",
"typedoc": "^0.24.1",
"ulid": "^2.3.0",
"ws": "^8.13.0"
},
"devDependencies": {
"@trivago/prettier-plugin-sort-imports": "^4.1.1",
"@types/node": "^18.15.11",
"@types/ws": "^8.5.4",
"@typescript-eslint/eslint-plugin": "^5.57.1",
"@typescript-eslint/parser": "^5.57.1",
"dotenv": "^16.0.3",
"eslint": "^8.37.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-solid": "^0.12.0",
"eslint-plugin-spellcheck": "^0.0.20",
"prettier": "^2.8.7",
"tsc-watch": "^6.0.0",
"typed-emitter": "^2.1.0",
"typescript": "^5.0.3"
}
}
+2056
View File
File diff suppressed because it is too large Load Diff
+337 -507
View File
@@ -1,544 +1,374 @@
import { Accessor, Setter, createSignal } from "solid-js";
import EventEmitter from "eventemitter3";
import defaultsDeep from "lodash.defaultsdeep";
import { action, makeObservable, observable } from "mobx";
import type {
DataCreateAccount,
DataLogin,
DataOnboard,
InviteResponse,
} from "revolt-api";
import type { RevoltConfig, Metadata } from "revolt-api";
import { API, MemberCompositeKey, Role } from "revolt-api";
import { API, DataCreateAccount, Role } from "revolt-api";
import type { DataLogin, RevoltConfig } from "revolt-api";
import Bots from "./maps/Bots";
import Channels, { Channel } from "./maps/Channels";
import Members, { Member } from "./maps/Members";
import Messages, { Message } from "./maps/Messages";
import Servers, { Server } from "./maps/Servers";
import Users, { User } from "./maps/Users";
import Emojis, { Emoji } from "./maps/Emojis";
import { Channel, Emoji, Message, Server, ServerMember, User } from "./classes";
import {
BotCollection,
ChannelCollection,
ChannelUnreadCollection,
EmojiCollection,
MessageCollection,
ServerCollection,
ServerMemberCollection,
UserCollection,
} from "./collections";
import {
ConnectionState,
EventClient,
EventClientOptions,
handleEventV1,
} from "./events";
import {
HydratedChannel,
HydratedEmoji,
HydratedMessage,
HydratedServer,
HydratedServerMember,
HydratedUser,
} from "./hydration";
import { RE_CHANNELS, RE_MENTIONS, RE_SPOILER } from "./lib/regex";
import { WebSocketClient } from "./websocket/client";
import { ClientboundNotification } from "./websocket/notifications";
export type Session = { _id: string; token: string; user_id: string } | string;
import { defaultConfig } from "./config";
import { Nullable } from "./util/null";
import Unreads from "./util/Unreads";
/**
* Events provided by the 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 {
apiURL: string;
debug: boolean;
export type ClientOptions = Partial<EventClientOptions> & {
/**
* Base URL of the API server
*/
baseURL: string;
cache: boolean;
unreads: boolean;
/**
* Whether to allow partial objects to emit from events
* @default false
*/
partials: boolean;
heartbeat: number;
autoReconnect: boolean;
/**
* Whether to eagerly fetch users and members for incoming events
* @default true
* @deprecated
*/
eagerFetching: boolean;
/**
* Automatically reconnect the client if no
* `pong` is received after X seconds of sending a `ping`.
* This is a temporary fix for an issue where the client
* would randomly stop receiving websocket messages.
*/
pongTimeout?: number;
/**
* Whether to automatically sync unreads information
* @default false
*/
syncUnreads: boolean;
/**
* If `pongTimeout` is set, this decides what to do when
* the timeout is triggered. Default is `RECONNECT`.
*/
onPongTimeout?: "EXIT" | "RECONNECT";
/**
* Whether to reconnect when disconnected
* @default true
*/
autoReconnect: boolean;
ackRateLimiter: boolean;
}
/**
* Retry delay function
* @param retryCount Count
* @returns Delay in seconds
* @default (2^x-1) ±20%
*/
retryDelayFunction(retryCount: number): number;
export declare interface Client {
on(event: "connected", listener: () => void): this;
on(event: "connecting", listener: () => void): this;
on(event: "dropped", listener: () => void): this;
on(event: "ready", listener: () => void): this;
on(event: "logout", listener: () => void): this;
on(
event: "packet",
listener: (packet: ClientboundNotification) => void,
): this;
on(event: "message", listener: (message: Message) => void): this;
on(event: "message/update", listener: (message: Message) => void): this;
on(
event: "message/delete",
listener: (id: string, message?: Message) => void,
): this;
// General purpose event
on(
event: "message/updated",
listener: (message: Message, packet: ClientboundNotification) => void,
): this;
on(event: "channel/create", listener: (channel: Channel) => void): this;
on(event: "channel/update", listener: (channel: Channel) => void): this;
on(
event: "channel/delete",
listener: (id: string, channel?: Channel) => void,
): this;
on(event: "server/update", listener: (server: Server) => void): this;
on(
event: "server/delete",
listener: (id: string, server?: Server) => void,
): this;
on(
event: "role/update",
listener: (roleId: string, role: Role, serverId: string) => void,
): this;
on(
event: "role/delete",
listener: (id: string, serverId: string) => void,
): this;
on(event: "member/join", listener: (member: Member) => void): this;
on(event: "member/update", listener: (member: Member) => void): this;
on(event: "member/leave", listener: (id: MemberCompositeKey) => void): this;
on(event: "user/relationship", listener: (user: User) => void): this;
on(event: "emoji/create", listener: (emoji: Emoji) => void): this;
on(
event: "emoji/delete",
listener: (id: string, emoji?: Emoji) => void,
): this;
}
/**
* Check whether a channel is muted
* @param channel Channel
* @return Whether it is muted
* @default false
*/
channelIsMuted(channel: Channel): boolean;
};
/**
* Regular expression for mentions.
* Revolt.js Clients
*/
export const RE_MENTIONS = /<@([A-z0-9]{26})>/g;
export class Client extends EventEmitter<Events> {
readonly bots;
readonly channels;
readonly channelUnreads;
readonly emojis;
readonly messages;
readonly servers;
readonly serverMembers;
readonly users;
/**
* Regular expression for spoilers.
*/
export const RE_SPOILER = /!!.+!!/g;
readonly api: API;
readonly options: ClientOptions;
readonly events: EventClient<1>;
export type FileArgs = [
options?: {
max_side?: number;
size?: number;
width?: number;
height?: number;
},
allowAnimation?: boolean,
fallback?: string,
];
configuration: RevoltConfig | undefined;
#session: Session | undefined;
user: User | undefined;
export type Session = { token: string };
readonly ready: Accessor<boolean>;
#setReady: Setter<boolean>;
export class Client extends EventEmitter {
heartbeat: number;
readonly connectionFailureCount: Accessor<number>;
#setConnectionFailureCount: Setter<number>;
#reconnectTimeout: number | undefined;
api: API;
session?: Session | string;
user: Nullable<User> = null;
/**
* Create Revolt.js Client
*/
constructor(options?: Partial<ClientOptions>) {
super();
options: ClientOptions;
websocket: WebSocketClient;
configuration?: RevoltConfig;
this.options = {
baseURL: "https://api.revolt.chat",
partials: false,
eagerFetching: true,
syncUnreads: false,
autoReconnect: true,
/**
* Retry delay function
* @param retryCount Count
* @returns Delay in seconds
*/
retryDelayFunction(retryCount) {
return (Math.pow(2, retryCount) - 1) * (0.8 + Math.random() * 0.4);
},
/**
* Check whether a channel is muted
* @param channel Channel
* @return Whether it is muted
*/
channelIsMuted() {
return false;
},
...options,
};
users: Users;
channels: Channels;
servers: Servers;
members: Members;
messages: Messages;
bots: Bots;
emojis: Emojis;
this.api = new API({
baseURL: this.options.baseURL,
});
unreads?: Unreads;
const [ready, setReady] = createSignal(false);
this.ready = ready;
this.#setReady = setReady;
constructor(options: Partial<ClientOptions> = {}) {
super();
const [connectionFailureCount, setConnectionFailureCount] = createSignal(0);
this.connectionFailureCount = connectionFailureCount;
this.#setConnectionFailureCount = setConnectionFailureCount;
this.users = new Users(this);
this.channels = new Channels(this);
this.servers = new Servers(this);
this.members = new Members(this);
this.messages = new Messages(this);
this.bots = new Bots(this);
this.emojis = new Emojis(this);
this.bots = new BotCollection(this);
this.channels = new ChannelCollection(this);
this.channelUnreads = new ChannelUnreadCollection(this);
this.emojis = new EmojiCollection(this);
this.messages = new MessageCollection(this);
this.users = new UserCollection(this);
this.servers = new ServerCollection(this);
this.serverMembers = new ServerMemberCollection(this);
makeObservable(
this,
{
user: observable,
users: observable,
channels: observable,
servers: observable,
members: observable,
messages: observable,
emojis: observable,
reset: action,
},
{
proxy: false,
},
);
this.events = new EventClient(1, "json", this.options);
this.events.on("error", (error) => this.emit("error", error));
this.events.on("state", (state) => {
switch (state) {
case ConnectionState.Connected:
this.#setConnectionFailureCount(0);
this.emit("connected");
break;
case ConnectionState.Connecting:
this.emit("connecting");
break;
case ConnectionState.Disconnected:
this.emit("disconnected");
if (this.options.autoReconnect) {
this.#reconnectTimeout = setTimeout(
() => this.connect(),
this.options.retryDelayFunction(this.connectionFailureCount()) *
1e3
) as never;
this.options = defaultsDeep(options, defaultConfig);
if (this.options.cache) throw "Cache is not supported yet.";
this.#setConnectionFailureCount((count) => count + 1);
}
break;
}
});
if (this.options.unreads) {
this.unreads = new Unreads(this);
this.events.on("event", (event) =>
handleEventV1(this, event, this.#setReady)
);
}
/**
* Connect to Revolt
*/
connect() {
clearTimeout(this.#reconnectTimeout);
this.events.disconnect();
this.#setReady(false);
this.events.connect(
"wss://ws.revolt.chat",
typeof this.#session === "string" ? this.#session : this.#session!.token
);
}
/**
* Fetches the configuration of the server if it has not been already fetched.
*/
async #fetchConfiguration() {
if (!this.configuration) {
this.configuration = await this.api.get("/");
}
}
/**
* Update API object to use authentication.
*/
#updateHeaders() {
(this.api as API) = new API({
baseURL: this.options.baseURL,
authentication: {
revolt: this.#session,
},
});
}
/**
* Log in with auth data, creating a new session in the process.
* @param details Login data object
* @returns An on-boarding function if on-boarding is required, undefined otherwise
*/
async login(details: DataLogin) {
await this.#fetchConfiguration();
const data = await this.api.post("/auth/session/login", details);
if (data.result === "Success") {
this.#session = data;
// TODO: return await this.connect();
} else {
throw "MFA not implemented!";
}
}
/**
* Use an existing session to log into Revolt
* @param session Session data object
* @returns An on-boarding function if on-boarding is required, undefined otherwise
*/
async useExistingSession(session: Session) {
await this.#fetchConfiguration();
this.#session = session;
this.#updateHeaders();
this.connect();
}
/**
* Log in as a bot
* @param token Bot token
*/
async loginBot(token: string) {
await this.#fetchConfiguration();
this.#session = token;
this.#updateHeaders();
this.connect();
}
/**
* Register for a new account
* @param data Registration data object
* @returns A promise containing a registration response object
*/
register(data: DataCreateAccount) {
return this.api.post("/auth/account/create", data);
}
/**
* Prepare a markdown-based message to be displayed to the user as plain text.
* @param source Source markdown text
* @returns Modified plain text
*/
markdownToText(source: string) {
return source
.replace(RE_MENTIONS, (sub: string, id: string) => {
const user = this.users.get(id as string);
if (user) {
return `@${user.username}`;
}
this.api = new API({ baseURL: this.apiURL });
this.websocket = new WebSocketClient(this);
this.heartbeat = this.options.heartbeat;
return sub;
})
.replace(RE_CHANNELS, (sub: string, id: string) => {
const channel = this.channels.get(id as string);
this.proxyFile = this.proxyFile.bind(this);
}
/**
* ? Configuration.
*/
/**
* Get the current API base URL.
*/
get apiURL() {
return this.options.apiURL;
}
/**
* Whether debug mode is turned on.
*/
get debug() {
return this.options.debug;
}
/**
* Whether revolt.js should auto-reconnect
*/
get autoReconnect() {
return this.options.autoReconnect;
}
/**
* ? Authentication and connection.
*/
/**
* Fetches the configuration of the server.
*
* @remarks
* Unlike `fetchConfiguration`, this function also fetches the
* configuration if it has already been fetched before.
*/
async connect() {
this.configuration = await this.api.get("/");
}
/**
* Fetches the configuration of the server if it has not been already fetched.
*/
async fetchConfiguration() {
if (!this.configuration) await this.connect();
}
/**
* Update API object to use authentication.
*/
private $updateHeaders() {
this.api = new API({
baseURL: this.apiURL,
authentication: {
revolt: this.session,
},
});
}
/**
* Log in with auth data, creating a new session in the process.
* @param details Login data object
* @returns An onboarding function if onboarding is required, undefined otherwise
*/
async login(details: DataLogin) {
await this.fetchConfiguration();
const data = await this.api.post("/auth/session/login", details);
if (data.result === "Success") {
this.session = data;
return await this.$connect();
} else {
throw "MFA not implemented!";
}
}
/**
* Use an existing session to log into Revolt.
* @param session Session data object
* @returns An onboarding function if onboarding is required, undefined otherwise
*/
async useExistingSession(session: Session) {
await this.fetchConfiguration();
this.session = session;
return await this.$connect();
}
/**
* Log in as a bot.
* @param token Bot token
*/
async loginBot(token: string) {
await this.fetchConfiguration();
this.session = token;
this.$updateHeaders();
return await this.websocket.connect();
}
/**
* Check onboarding status and connect to notifications service.
* @returns
*/
private async $connect() {
this.$updateHeaders();
const { onboarding } = await this.api.get("/onboard/hello");
if (onboarding) {
return (username: string, loginAfterSuccess?: boolean) =>
this.completeOnboarding({ username }, loginAfterSuccess);
if (channel) {
return `#${channel.displayName}`;
}
await this.websocket.connect();
}
/**
* Finish onboarding for a user, for example by providing a username.
* @param data Onboarding data object
* @param loginAfterSuccess Defines whether to automatically log in and connect after onboarding finishes
*/
async completeOnboarding(data: DataOnboard, loginAfterSuccess?: boolean) {
await this.api.post("/onboard/complete", data);
if (loginAfterSuccess) {
await this.websocket.connect();
}
}
/**
* ? Miscellaneous API routes.
*/
/**
* Fetch information about a given invite code.
* @param code The invite code.
* @returns Invite information.
*/
async fetchInvite(code: string) {
return await this.api.get(`/invites/${code as ""}`);
}
async joinInvite(code: string): Promise<Server>;
async joinInvite(invite: InviteResponse): Promise<Server>;
/**
* Use an invite.
* @param invite Invite
* @returns Data provided by invite.
*/
async joinInvite(invite: string | InviteResponse) {
const code = typeof invite === "string" ? invite : invite.code;
if (typeof invite === "object") {
switch (invite.type) {
case "Group": {
const group = this.channels.get(invite.channel_id);
if (group) return group;
break;
}
case "Server": {
const server = this.servers.get(invite.server_id);
if (server) return server;
}
}
}
const response = await this.api.post(`/invites/${code}`);
if (response.type === "Server") {
return await this.servers.fetch(
response.server._id,
response.server,
response.channels,
);
} else {
throw "Unsupported invite type.";
}
}
/**
* Delete an invite.
* @param code The invite code.
*/
async deleteInvite(code: string) {
await this.api.delete(`/invites/${code as ""}`);
}
/**
* Fetch user settings for current user.
* @param keys Settings keys to fetch, leave blank to fetch full object.
* @returns Key-value object of settings.
*/
async syncFetchSettings(keys: string[]) {
return await this.api.post("/sync/settings/fetch", { keys });
}
/**
* Set user settings for current user.
* @param data Data to set as an object. Any non-string values will be automatically serialised.
* @param timestamp Timestamp to use for the current revision.
*/
async syncSetSettings(
data: { [key: string]: object | string },
timestamp?: number,
) {
const requestData: { [key: string]: string } = {};
for (const key of Object.keys(data)) {
const value = data[key];
requestData[key] =
typeof value === "string" ? value : JSON.stringify(value);
}
await this.api.post(`/sync/settings/set`, {
...requestData,
timestamp,
});
}
/**
* Fetch user unreads for current user.
* @returns Array of channel unreads.
*/
async syncFetchUnreads() {
return await this.api.get("/sync/unreads");
}
/**
* ? Utility functions.
*/
/**
* Log out of Revolt. Disconnect the WebSocket, request a session invalidation and reset the client.
*/
async logout(avoidRequest?: boolean) {
this.user = null;
this.emit("logout");
!avoidRequest && (await this.api.post("/auth/session/logout"));
this.reset();
}
/**
* Reset the client by setting properties to their original value or deleting them entirely.
* Disconnects the current WebSocket.
*/
reset() {
this.user = null;
this.websocket.disconnect();
delete this.session;
this.users = new Users(this);
this.channels = new Channels(this);
this.servers = new Servers(this);
this.members = new Members(this);
this.messages = new Messages(this);
this.emojis = new Emojis(this);
}
/**
* Register for a new account.
* @param data Registration data object
* @returns A promise containing a registration response object
*/
register(data: DataCreateAccount) {
return this.api.post("/auth/account/create", data);
}
/**
* Prepare a markdown-based message to be displayed to the user as plain text.
* @param source Source markdown text
* @returns Modified plain text
*/
markdownToText(source: string) {
return source
.replace(RE_MENTIONS, (sub: string, id: string) => {
const user = this.users.get(id as string);
if (user) {
return `@${user.username}`;
}
return sub;
})
.replace(RE_SPOILER, "<spoiler>");
}
/**
* Proxy a file through January.
* @param url URL to proxy
* @returns Proxied media URL
*/
proxyFile(url: string): string | undefined {
if (this.configuration?.features.january.enabled) {
return `${
this.configuration.features.january.url
}/proxy?url=${encodeURIComponent(url)}`;
}
}
/**
* Generates 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(
attachment?: {
tag: string;
_id: string;
content_type?: string;
metadata?: Metadata;
},
...args: FileArgs
) {
const [options, allowAnimation, fallback] = args;
const autumn = this.configuration?.features.autumn;
if (!autumn?.enabled) return fallback;
if (!attachment) return fallback;
const { tag, _id, content_type, metadata } = attachment;
// ! FIXME: These limits should be done on Autumn.
if (metadata?.type === "Image") {
if (
Math.min(metadata.width, metadata.height) <= 0 ||
(content_type === "image/gif" &&
Math.max(metadata.width, metadata.height) >= 1024)
)
return fallback;
}
let query = "";
if (options) {
if (!allowAnimation || content_type !== "image/gif") {
query =
"?" +
Object.keys(options)
.map((k) => `${k}=${options[k as keyof FileArgs[0]]}`)
.join("&");
}
}
return `${autumn.url}/${tag}/${_id}${query}`;
return sub;
})
.replace(RE_SPOILER, "<spoiler>");
}
/**
* Proxy a file through January.
* @param url URL to proxy
* @returns Proxied media URL
*/
proxyFile(url: string): string | undefined {
if (this.configuration?.features.january.enabled) {
return `${
this.configuration.features.january.url
}/proxy?url=${encodeURIComponent(url)}`;
}
}
}
+130
View File
@@ -0,0 +1,130 @@
import { DataEditBot } from "revolt-api";
import { decodeTime } from "ulid";
import { BotCollection } from "../collections";
/**
* Bot Class
*/
export class Bot {
readonly #collection: BotCollection;
readonly id: string;
/**
* Construct Bot
* @param collection Collection
* @param id Id
*/
constructor(collection: BotCollection, id: string) {
this.#collection = collection;
this.id = id;
}
/**
* Convert to string
* @returns String
*/
toString() {
return `<@${this.id}>`;
}
/**
* Time when this user created their account
*/
get createdAt() {
return new Date(decodeTime(this.id));
}
/**
* Corresponding user
*/
get user() {
return this.#collection.client.users.get(this.id);
}
/**
* Owner's Id
*/
get ownerId() {
return this.#collection.getUnderlyingObject(this.id).ownerId;
}
/**
* Owner
*/
get owner() {
return this.#collection.client.users.get(this.ownerId);
}
/**
* Bot Token
*/
get token() {
return this.#collection.getUnderlyingObject(this.id).token;
}
/**
* Whether this bot can be invited by anyone
*/
get public() {
return this.#collection.getUnderlyingObject(this.id).public;
}
/**
* Whether this bot has analytics enabled
*/
get analytics() {
return this.#collection.getUnderlyingObject(this.id).analytics;
}
/**
* Whether this bot shows up on Discover
*/
get discoverable() {
return this.#collection.getUnderlyingObject(this.id).discoverable;
}
/**
* Interactions URL
*/
get interactionsUrl() {
return this.#collection.getUnderlyingObject(this.id).interactionsUrl;
}
/**
* Link to terms of service
*/
get termsOfServiceUrl() {
return this.#collection.getUnderlyingObject(this.id).termsOfServiceUrl;
}
/**
* Link to privacy policy
*/
get privacyPolicyUrl() {
return this.#collection.getUnderlyingObject(this.id).privacyPolicyUrl;
}
/**
* Bot Flags
*/
get flags() {
return this.#collection.getUnderlyingObject(this.id).flags;
}
/**
* Edit a bot
* @param data Changes
*/
async edit(data: DataEditBot) {
await this.#collection.client.api.patch(`/bots/${this.id as ""}`, data);
}
/**
* Delete a bot
*/
async delete() {
await this.#collection.client.api.delete(`/bots/${this.id as ""}`);
this.#collection.delete(this.id);
}
}
+660
View File
@@ -0,0 +1,660 @@
import type {
Member as ApiMember,
Message as ApiMessage,
User as ApiUser,
DataEditChannel,
DataMessageSend,
OptionsMessageSearch,
Override,
} 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";
/**
* Channel Class
*/
export class Channel {
readonly #collection: ChannelCollection;
readonly id: string;
/**
* Construct Channel
* @param collection Collection
* @param id Channel Id
*/
constructor(collection: ChannelCollection, id: string) {
this.#collection = collection;
this.id = id;
}
/**
* Write to string as a channel mention
* @returns Formatted String
*/
toString() {
return `<#${this.id}>`;
}
/**
* Time when this server was created
*/
get createdAt() {
return new Date(decodeTime(this.id));
}
/**
* Channel type
*/
get type() {
return this.#collection.getUnderlyingObject(this.id).channelType;
}
/**
* Absolute pathname to this channel in the client
*/
get path() {
if (this.serverId) {
return `/server/${this.serverId}/channel/${this.id}`;
} else {
return `/channel/${this.id}`;
}
}
/**
* URL to this channel
*/
get url() {
return this.#collection.client.configuration?.app + this.path;
}
/**
* Channel name
*/
get name() {
return this.#collection.getUnderlyingObject(this.id).name;
}
/**
* Display name
*/
get displayName() {
return this.type === "SavedMessages"
? this.user?.username
: this.type === "DirectMessage"
? this.recipient?.username
: this.name;
}
/**
* Channel description
*/
get description() {
return this.#collection.getUnderlyingObject(this.id).description;
}
/**
* Channel icon
*/
get icon() {
return this.#collection.getUnderlyingObject(this.id).icon;
}
/**
* Whether the conversation is active
*/
get active() {
return this.#collection.getUnderlyingObject(this.id).active;
}
/**
* User ids of people currently typing in channel
*/
get typingIds() {
return this.#collection.getUnderlyingObject(this.id).typingIds;
}
/**
* Users currently trying in channel
*/
get typing() {
return [...this.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;
}
/**
* Recipients of the group
*/
get recipients() {
return [
...this.#collection.getUnderlyingObject(this.id).recipientIds.values(),
].map((id) => this.#collection.client.users.get(id)!);
}
/**
* Find recipient of this DM
*/
get recipient() {
return this.type === "DirectMessage"
? this.recipients.find(
(user) => user.id !== this.#collection.client.user!.id
)
: undefined;
}
/**
* User ID
*/
get userId() {
return this.#collection.getUnderlyingObject(this.id).userId!;
}
/**
* User this channel belongs to
*/
get user() {
return this.#collection.client.users.get(
this.#collection.getUnderlyingObject(this.id).userId!
);
}
/**
* Owner ID
*/
get ownerId() {
return this.#collection.getUnderlyingObject(this.id).ownerId!;
}
/**
* Owner of the group
*/
get owner() {
return this.#collection.client.users.get(
this.#collection.getUnderlyingObject(this.id).ownerId!
);
}
/**
* Server ID
*/
get serverId() {
return this.#collection.getUnderlyingObject(this.id).serverId!;
}
/**
* Server this channel is in
*/
get server() {
return this.#collection.client.servers.get(
this.#collection.getUnderlyingObject(this.id).serverId!
);
}
/**
* Permissions allowed for users in this group
*/
get permissions() {
return this.#collection.getUnderlyingObject(this.id).permissions;
}
/**
* Default permissions for this server channel
*/
get defaultPermissions() {
return this.#collection.getUnderlyingObject(this.id).defaultPermissions;
}
/**
* Role permissions for this server channel
*/
get rolePermissions() {
return this.#collection.getUnderlyingObject(this.id).rolePermissions;
}
/**
* Whether this channel is marked as mature
*/
get mature() {
return this.#collection.getUnderlyingObject(this.id).nsfw;
}
/**
* ID of the last message sent in this channel
*/
get lastMessageId() {
return this.#collection.getUnderlyingObject(this.id).lastMessageId;
}
/**
* Last message sent in this channel
*/
get lastMessage() {
return this.#collection.client.messages.get(this.lastMessageId!);
}
/**
* Time when the last message was sent
*/
get lastMessageAt() {
return this.lastMessageId
? new Date(decodeTime(this.lastMessageId))
: undefined;
}
/**
* Time when the channel was last updated (either created or a message was sent)
*/
get updatedAt() {
return this.lastMessageAt ?? this.createdAt;
}
/**
* Get whether this channel is unread.
*/
get unread() {
if (
!this.lastMessageId ||
this.type === "SavedMessages" ||
this.type === "VoiceChannel" ||
this.#collection.client.options.channelIsMuted(this)
)
return false;
return (
(
this.#collection.client.channelUnreads.get(this.id)?.lastMessageId ??
"0"
).localeCompare(this.lastMessageId) === -1
);
}
/**
* Get mentions in this channel for user.
*/
get mentions() {
if (this.type === "SavedMessages" || this.type === "VoiceChannel")
return undefined;
return this.#collection.client.channelUnreads.get(this.id)
?.messageMentionIds;
}
/**
* URL to the channel icon
*/
get iconURL() {
return (
this.icon?.createFileURL({ max_side: 256 }) ?? this.recipient?.avatarURL
);
}
/**
* URL to a small variant of the channel icon
*/
get smallIconURL() {
return this.icon?.createFileURL({ max_side: 64 });
}
/**
* URL to the animated channel icon
*/
get animatedIconURL() {
return (
this.icon?.createFileURL({ max_side: 256 }, true) ??
this.recipient?.animatedAvatarURL
);
}
/**
* Permission the currently authenticated user has against this channel
*/
get permission() {
return calculatePermission(this.#collection.client, this);
}
/**
* Check whether we have a given permission in a channel
* @param permission Permission Names
* @returns Whether we have this permission
*/
havePermission(...permission: (keyof typeof Permission)[]) {
return bitwiseAndEq(
this.permission,
...permission.map((x) => Permission[x])
);
}
/**
* Fetch a channel's members.
* @requires `Group`
* @returns An array of the channel's members.
*/
async fetchMembers() {
const members = await this.#collection.client.api.get(
`/channels/${this.id as ""}/members`
);
return members.map((user) =>
this.#collection.client.users.getOrCreate(user._id, user)
);
}
/**
* Edit a channel
* @param data Changes
*/
async edit(data: DataEditChannel) {
await this.#collection.client.api.patch(`/channels/${this.id as ""}`, data);
}
/**
* Delete or leave a channel
* @param leaveSilently Whether to not send a message on leave
* @requires `DirectMessage`, `Group`, `TextChannel`, `VoiceChannel`
*/
async delete(leaveSilently?: boolean) {
await this.#collection.client.api.delete(`/channels/${this.id as ""}`, {
leave_silently: leaveSilently,
});
if (this.type === "DirectMessage") {
this.#collection.updateUnderlyingObject(this.id, "active", false);
return;
}
this.#collection.delete(this.id);
}
/**
* Add a user to a group
* @param user_id ID of the target user
* @requires `Group`
*/
async addMember(user_id: string) {
return await this.#collection.client.api.put(
`/channels/${this.id as ""}/recipients/${user_id as ""}`
);
}
/**
* Remove a user from a group
* @param user_id ID of the target user
* @requires `Group`
*/
async removeMember(user_id: string) {
return await this.#collection.client.api.delete(
`/channels/${this.id as ""}/recipients/${user_id as ""}`
);
}
/**
* Send a message
* @param data Either the message as a string or message sending route data
* @requires `SavedMessages`, `DirectMessage`, `Group`, `TextChannel`
* @returns Sent message
*/
async sendMessage(
data: string | DataMessageSend,
idempotencyKey: string = ulid()
) {
const msg: DataMessageSend =
typeof data === "string" ? { content: data } : data;
const message = await this.#collection.client.api.post(
`/channels/${this.id as ""}/messages`,
msg,
{
headers: {
"Idempotency-Key": idempotencyKey,
},
}
);
return this.#collection.client.messages.getOrCreate(
message._id,
message,
true
);
}
/**
* Fetch a message by its ID
* @param messageId ID of the target message
* @requires `SavedMessages`, `DirectMessage`, `Group`, `TextChannel`
* @returns Message
*/
async fetchMessage(messageId: string) {
const message = await this.#collection.client.api.get(
`/channels/${this.id as ""}/messages/${messageId as ""}`
);
return this.#collection.client.messages.getOrCreate(message._id, message);
}
/**
* Fetch multiple messages from a channel
* @param params Message fetching route data
* @requires `SavedMessages`, `DirectMessage`, `Group`, `TextChannel`
* @returns Messages
*/
async fetchMessages(
params?: Omit<
(APIRoutes & {
method: "get";
path: "/channels/{target}/messages";
})["params"],
"include_users"
>
) {
const messages = (await this.#collection.client.api.get(
`/channels/${this.id as ""}/messages`,
{ ...params }
)) as ApiMessage[];
return messages.map((message) =>
this.#collection.client.messages.getOrCreate(message._id, message)
);
}
/**
* Fetch multiple messages from a channel including the users that sent them
* @param params Message fetching route data
* @requires `SavedMessages`, `DirectMessage`, `Group`, `TextChannel`
* @returns Object including messages and users
*/
async fetchMessagesWithUsers(
params?: Omit<
(APIRoutes & {
method: "get";
path: "/channels/{target}/messages";
})["params"],
"include_users"
>
) {
const data = (await this.#collection.client.api.get(
`/channels/${this.id as ""}/messages`,
{ ...params, include_users: true }
)) as { messages: ApiMessage[]; users: ApiUser[]; members?: ApiMember[] };
return {
messages: data.messages.map((message) =>
this.#collection.client.messages.getOrCreate(message._id, message)
),
users: data.users.map((user) =>
this.#collection.client.users.getOrCreate(user._id, user)
),
members: data.members?.map((member) =>
this.#collection.client.serverMembers.getOrCreate(member._id, member)
),
};
}
/**
* Search for messages
* @param params Message searching route data
* @requires `SavedMessages`, `DirectMessage`, `Group`, `TextChannel`
* @returns Messages
*/
async search(params: Omit<OptionsMessageSearch, "include_users">) {
const messages = (await this.#collection.client.api.post(
`/channels/${this.id as ""}/search`,
params
)) as ApiMessage[];
return messages.map((message) =>
this.#collection.client.messages.getOrCreate(message._id, message)
);
}
/**
* Search for messages including the users that sent them
* @param params Message searching route data
* @requires `SavedMessages`, `DirectMessage`, `Group`, `TextChannel`
* @returns Object including messages and users
*/
async searchWithUsers(params: Omit<OptionsMessageSearch, "include_users">) {
const data = (await this.#collection.client.api.post(
`/channels/${this.id as ""}/search`,
{
...params,
include_users: true,
}
)) as { messages: ApiMessage[]; users: ApiUser[]; members?: ApiMember[] };
return {
messages: data.messages.map((message) =>
this.#collection.client.messages.getOrCreate(message._id, message)
),
users: data.users.map((user) =>
this.#collection.client.users.getOrCreate(user._id, user)
),
members: data.members?.map((member) =>
this.#collection.client.serverMembers.getOrCreate(member._id, member)
),
};
}
/**
* Delete many messages by their IDs
* @param ids List of message IDs
* @requires `SavedMessages`, `DirectMessage`, `Group`, `TextChannel`
*/
async deleteMessages(ids: string[]) {
await this.#collection.client.api.delete(
`/channels/${this.id as ""}/messages/bulk`,
{
ids,
}
);
}
/**
* Create an invite to the channel
* @requires `TextChannel`, `VoiceChannel`
* @returns Newly created invite code
*/
async createInvite() {
return await this.#collection.client.api.post(
`/channels/${this.id as ""}/invites`
);
}
#ackTimeout?: number;
#ackLimit?: number;
/**
* Mark a channel as read
* @param message Last read message or its ID
* @param skipRateLimiter Whether to skip the internal rate limiter
* @requires `SavedMessages`, `DirectMessage`, `Group`, `TextChannel`
*/
async ack(message?: Message | string, skipRateLimiter?: boolean) {
const lastMessageId =
(typeof message === "string" ? message : message?.id) ??
this.lastMessageId ??
ulid();
const unreads = this.#collection.client.channelUnreads;
const channelUnread = unreads.get(this.id);
if (channelUnread) {
unreads.updateUnderlyingObject(this.id, {
lastMessageId,
});
if (channelUnread.messageMentionIds.size) {
channelUnread.messageMentionIds.clear();
}
}
/**
* Send the actual acknowledgement request
*/
const performAck = () => {
this.#ackLimit = undefined;
this.#collection.client.api.put(
`/channels/${this.id}/ack/${lastMessageId as ""}`
);
};
if (skipRateLimiter) return performAck();
clearTimeout(this.#ackTimeout);
if (this.#ackLimit && +new Date() > this.#ackLimit) {
performAck();
}
// We need to use setTimeout here for both Node.js and browser.
this.#ackTimeout = setTimeout(performAck, 5000) as unknown as number;
if (!this.#ackLimit) {
this.#ackLimit = +new Date() + 15e3;
}
}
/**
* Set role permissions
* @param role_id Role Id, set to 'default' to affect all users
* @param permissions Permission value
* @requires `Group`, `TextChannel`, `VoiceChannel`
*/
async setPermissions(role_id = "default", permissions: Override) {
return await this.#collection.client.api.put(
`/channels/${this.id as ""}/permissions/${role_id as ""}`,
{ permissions }
);
}
/**
* Start typing in this channel
* @requires `DirectMessage`, `Group`, `TextChannel`
*/
startTyping() {
this.#collection.client.events.send({
type: "BeginTyping",
channel: this.id,
});
}
/**
* Stop typing in this channel
* @requires `DirectMessage`, `Group`, `TextChannel`
*/
stopTyping() {
this.#collection.client.events.send({
type: "EndTyping",
channel: this.id,
});
}
}
+33
View File
@@ -0,0 +1,33 @@
import { ChannelUnreadCollection } from "../collections";
/**
* Channel Unread Class
*/
export class ChannelUnread {
readonly #collection: ChannelUnreadCollection;
readonly id: string;
/**
* Construct Channel
* @param collection Collection
* @param id Channel Id
*/
constructor(collection: ChannelUnreadCollection, id: string) {
this.#collection = collection;
this.id = id;
}
/**
* Last read message id
*/
get lastMessageId() {
return this.#collection.getUnderlyingObject(this.id).lastMessageId;
}
/**
* List of message IDs that we were mentioned in
*/
get messageMentionIds() {
return this.#collection.getUnderlyingObject(this.id).messageMentionIds;
}
}
+86
View File
@@ -0,0 +1,86 @@
import { decodeTime } from "ulid";
import { EmojiCollection } from "../collections";
/**
* Emoji Class
*/
export class Emoji {
readonly #collection: EmojiCollection;
readonly id: string;
/**
* Construct Emoji
* @param collection Collection
* @param id Emoji Id
*/
constructor(collection: EmojiCollection, id: string) {
this.#collection = collection;
this.id = id;
}
/**
* Convert to string
* @returns String
*/
toString() {
return `:${this.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 this.#collection.getUnderlyingObject(this.id).parent;
}
/**
* Creator of the emoji
*/
get creator() {
return this.#collection.client.users.get(
this.#collection.getUnderlyingObject(this.id).creatorId
);
}
/**
* Name
*/
get name() {
return this.#collection.getUnderlyingObject(this.id).name;
}
/**
* Whether the emoji is animated
*/
get animated() {
return this.#collection.getUnderlyingObject(this.id).animated;
}
/**
* Whether the emoji is marked as mature
*/
get mature() {
return this.#collection.getUnderlyingObject(this.id).nsfw;
}
/**
* Delete Emoji
*/
async delete() {
await this.#collection.client.api.delete(`/custom/emoji/${this.id}`);
const emoji = this.#collection.getUnderlyingObject(this.id);
if (emoji) {
this.#collection.client.emit("emojiDelete", emoji);
this.#collection.delete(this.id);
}
}
}
+135
View File
@@ -0,0 +1,135 @@
import { Metadata } from "revolt-api";
import { API, Client } from "..";
/**
* Uploaded File
*/
export class File {
#client: Client;
/**
* File Id
*/
readonly id: string;
/**
* File bucket
*/
readonly tag: string;
/**
* Original filename
*/
readonly filename: string;
/**
* Parsed metadata of the file
*/
readonly metadata: Metadata;
/**
* Raw content type of this file
*/
readonly contentType: string;
/**
* Size of the file (in bytes)
*/
readonly size: number;
/**
* Construct File
* @param client Client
* @param file File
*/
constructor(client: Client, file: API.File) {
this.#client = client;
this.id = file._id;
this.tag = file.tag;
this.filename = file.filename;
this.metadata = file.metadata;
this.contentType = file.content_type;
this.size = file.size;
}
/**
* Direct URL to the file
*/
get url() {
return `${this.#client.configuration?.features.autumn.url}/${this.tag}/${
this.id
}/${this.filename}`;
}
/**
* Download URL for the file
*/
get downloadURL() {
return `${this.#client.configuration?.features.autumn.url}/${
this.tag
}/download/${this.id}/${this.filename}`;
}
/**
* Human readable file size
*/
get humanReadableSize() {
if (this.size > 1e6) {
return `${(this.size / 1e6).toFixed(2)} MB`;
} else if (this.size > 1e3) {
return `${(this.size / 1e3).toFixed(2)} KB`;
}
return `${this.size} B`;
}
/**
* Whether this file should have a spoiler
*/
get isSpoiler() {
return this.filename.toLowerCase().startsWith("spoiler_");
}
/**
* Creates a URL to a given file with given options.
* @param options Optional query parameters to modify object
* @param allowAnimation Returns GIF if applicable, no operations occur on image
* @returns Generated URL or nothing
*/
createFileURL(
options?: {
max_side?: number;
size?: number;
width?: number;
height?: number;
},
allowAnimation?: boolean
) {
const autumn = this.#client.configuration?.features.autumn;
if (!autumn?.enabled) return;
// TODO: server-side
if (this.metadata.type === "Image") {
if (
Math.min(this.metadata.width, this.metadata.height) <= 0 ||
(this.contentType === "image/gif" &&
Math.max(this.metadata.width, this.metadata.height) >= 4096)
)
return;
}
let query = "";
if (options) {
if (!allowAnimation || this.contentType !== "image/gif") {
query =
"?" +
Object.keys(options)
.map((k) => `${k}=${options[k as keyof typeof options]}`)
.join("&");
}
}
return `${autumn.url}/${this.tag}/${this.id}${query}`;
}
}
+91
View File
@@ -0,0 +1,91 @@
import { API, Client } from "..";
/**
* Channel Invite
*/
export abstract class ChannelInvite {
protected client?: Client;
readonly type: API.Invite["type"] | "None";
/**
* Construct Channel Invite
* @param client Client
* @param type Type
*/
constructor(client?: Client, type: API.Invite["type"] | "None" = "None") {
this.client = client;
this.type = type;
}
/**
* Create an Invite from an API Invite
* @param client Client
* @param invite Data
* @returns Invite
*/
static from(client: Client, invite: API.Invite): ChannelInvite {
switch (invite.type) {
case "Server":
return new ServerInvite(client, invite);
default:
return new UnknownInvite(client);
}
}
}
/**
* Invite of unknown type
*/
export class UnknownInvite extends ChannelInvite {}
/**
* Server Invite
*/
export class ServerInvite extends ChannelInvite {
readonly id: string;
readonly creatorId: string;
readonly serverId: string;
readonly channelId: string;
/**
* Construct Server Invite
* @param client Client
* @param invite Invite
*/
constructor(client: Client, invite: API.Invite & { type: "Server" }) {
super(client, "Server");
this.id = invite._id;
this.creatorId = invite.creator;
this.serverId = invite.server;
this.channelId = invite.server;
}
/**
* Creator of the invite
*/
get creator() {
return this.client!.users.get(this.creatorId);
}
/**
* Server this invite points to
*/
get server() {
return this.client!.servers.get(this.serverId);
}
/**
* Channel this invite points to
*/
get channel() {
return this.client!.channels.get(this.channelId);
}
/**
* Delete the invite
*/
async delete() {
await this.client!.api.delete(`/invites/${this.id}`);
}
}
+294
View File
@@ -0,0 +1,294 @@
import { DataEditMessage, DataMessageSend } from "revolt-api";
import { decodeTime } from "ulid";
import { MessageCollection } from "../collections";
/**
* Message Class
*/
export class Message {
readonly #collection: MessageCollection;
readonly id: string;
/**
* Construct Message
* @param collection Collection
* @param id Message Id
*/
constructor(collection: MessageCollection, id: string) {
this.#collection = collection;
this.id = id;
}
/**
* Time when this message was posted
*/
get createdAt() {
return new Date(decodeTime(this.id));
}
/**
* Absolute pathname to this message in the client
*/
get path() {
return `${this.channel!.path}/${this.id}`;
}
/**
* URL to this message
*/
get url() {
return this.#collection.client.configuration?.app + this.path;
}
/**
* Nonce value
*/
get nonce() {
return this.#collection.getUnderlyingObject(this.id).nonce;
}
/**
* Id of channel this message was sent in
*/
get channelId() {
return this.#collection.getUnderlyingObject(this.id).channelId;
}
/**
* Channel this message was sent in
*/
get channel() {
return this.#collection.client.channels.get(
this.#collection.getUnderlyingObject(this.id).channelId
);
}
/**
* Server this message was sent in
*/
get server() {
return this.channel!.server;
}
/**
* Member this message was sent by
*/
get member() {
return this.#collection.client.serverMembers.getByKey({
server: this.channel!.serverId,
user: this.authorId!,
});
}
/**
* Id of user this message was sent by
*/
get authorId() {
return this.#collection.getUnderlyingObject(this.id).authorId;
}
/**
* User this message was sent by
*/
get author() {
return this.#collection.client.users.get(
this.#collection.getUnderlyingObject(this.id).authorId!
);
}
/**
* Content
*/
get content() {
return this.#collection.getUnderlyingObject(this.id).content;
}
/**
* System message content
*/
get systemMessage() {
return this.#collection.getUnderlyingObject(this.id).systemMessage;
}
/**
* Attachments
*/
get attachments() {
return this.#collection.getUnderlyingObject(this.id).attachments;
}
/**
* Time at which this message was edited
*/
get editedAt() {
return this.#collection.getUnderlyingObject(this.id).editedAt;
}
/**
* Embeds
*/
get embeds() {
return this.#collection.getUnderlyingObject(this.id).embeds;
}
/**
* IDs of users this message mentions
*/
get mentionIds() {
return this.#collection.getUnderlyingObject(this.id).mentionIds;
}
/**
* IDs of messages this message replies to
*/
get replyIds() {
return this.#collection.getUnderlyingObject(this.id).replyIds;
}
/**
* Reactions
*/
get reactions() {
return this.#collection.getUnderlyingObject(this.id).reactions;
}
/**
* Interactions
*/
get interactions() {
return this.#collection.getUnderlyingObject(this.id).interactions;
}
/**
* Masquerade
*/
get masquerade() {
return this.#collection.getUnderlyingObject(this.id).masquerade;
}
/**
* Get the username for this message
*/
get username() {
return (
this.masquerade?.name ?? this.member?.nickname ?? this.author?.username
);
}
/**
* Get the role colour for this message
*/
get roleColour() {
return this.masquerade?.colour ?? this.member?.roleColour;
}
/**
* Get the avatar URL for this message
*/
get avatarURL() {
return (
this.masqueradeAvatarURL ??
this.member?.avatarURL ??
this.author?.avatarURL
);
}
/**
* Get the animated avatar URL for this message
*/
get animatedAvatarURL() {
return (
this.masqueradeAvatarURL ??
(this.member
? this.member?.animatedAvatarURL
: this.author?.animatedAvatarURL)
);
}
/**
* Avatar URL from the masquerade
*/
get masqueradeAvatarURL() {
const avatar = this.masquerade?.avatar;
return avatar ? this.#collection.client.proxyFile(avatar) : undefined;
}
/**
* 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 ""
}`
);
}
/**
* Un-react 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 ""
}`
);
}
}
+232
View File
@@ -0,0 +1,232 @@
import { API, Client, File } from "..";
/**
* Message Embed
*/
export abstract class MessageEmbed {
protected client?: Client;
readonly type: API.Embed["type"];
/**
* Construct Embed
* @param client Client
* @param type Type
*/
constructor(client?: Client, type: API.Embed["type"] = "None") {
this.client = client;
this.type = type;
}
/**
* Create an Embed from an API Embed
* @param client Client
* @param embed Data
* @returns Embed
*/
static from(client: Client, embed: API.Embed): MessageEmbed {
switch (embed.type) {
case "Image":
return new ImageEmbed(client, embed);
case "Video":
return new VideoEmbed(client, embed);
case "Website":
return new WebsiteEmbed(client, embed);
case "Text":
return new TextEmbed(client, embed);
default:
return new UnknownEmbed(client);
}
}
}
/**
* Embed of unknown type
*/
export class UnknownEmbed extends MessageEmbed {}
/**
* Image Embed
*/
export class ImageEmbed extends MessageEmbed {
readonly url: string;
readonly width: number;
readonly height: number;
readonly size: API.ImageSize;
/**
* Construct Image Embed
* @param client Client
* @param embed Embed
*/
constructor(
client: Client,
embed: Omit<API.Embed & { type: "Image" }, "type">
) {
super(client, "Image");
this.url = embed.url;
this.width = embed.width;
this.height = embed.height;
this.size = embed.size;
}
/**
* Proxied image URL
*/
get proxiedURL() {
return this.client?.proxyFile(this.url);
}
}
/**
* Video Embed
*/
export class VideoEmbed extends MessageEmbed {
readonly url: string;
readonly width: number;
readonly height: number;
/**
* Construct Video Embed
* @param client Client
* @param embed Embed
*/
constructor(
client: Client,
embed: Omit<API.Embed & { type: "Video" }, "type">
) {
super(client, "Video");
this.url = embed.url;
this.width = embed.width;
this.height = embed.height;
}
/**
* Proxied video URL
*/
get proxiedURL() {
return this.client?.proxyFile(this.url);
}
}
/**
* Website Embed
*/
export class WebsiteEmbed extends MessageEmbed {
readonly url?: string;
readonly originalUrl?: string;
readonly specialContent?: API.Special;
readonly title?: string;
readonly description?: string;
readonly image?: ImageEmbed;
readonly video?: VideoEmbed;
readonly siteName?: string;
readonly iconUrl?: string;
readonly colour?: string;
/**
* Construct Video Embed
* @param client Client
* @param embed Embed
*/
constructor(
client: Client,
embed: Omit<API.Embed & { type: "Website" }, "type">
) {
super(client, "Website");
this.url = embed.url!;
this.originalUrl = embed.original_url!;
this.specialContent = embed.special!;
this.title = embed.title!;
this.description = embed.description!;
this.image = embed.image ? new ImageEmbed(client, embed.image) : undefined;
this.video = embed.video ? new VideoEmbed(client, embed.video) : undefined;
this.siteName = embed.site_name!;
this.iconUrl = embed.icon_url!;
this.colour = embed.colour!;
}
/**
* Proxied icon URL
*/
get proxiedIconURL() {
return this.iconUrl ? this.client?.proxyFile(this.iconUrl) : undefined;
}
/**
* If special content is present, generate the embed URL
*/
get embedURL() {
switch (this.specialContent?.type) {
case "YouTube": {
let timestamp = "";
if (this.specialContent.timestamp) {
timestamp = `&start=${this.specialContent.timestamp}`;
}
return `https://www.youtube-nocookie.com/embed/${this.specialContent.id}?modestbranding=1${timestamp}`;
}
case "Twitch":
return `https://player.twitch.tv/?${this.specialContent.content_type.toLowerCase()}=${
this.specialContent.id
}&parent=${(window ?? {})?.location?.hostname}&autoplay=false`;
case "Lightspeed":
return `https://new.lightspeed.tv/embed/${this.specialContent.id}/stream`;
case "Spotify":
return `https://open.spotify.com/embed/${this.specialContent.content_type}/${this.specialContent.id}`;
case "Soundcloud":
return `https://w.soundcloud.com/player/?url=${encodeURIComponent(
this.url!
)}&color=%23FF7F50&auto_play=false&hide_related=false&show_comments=true&show_user=true&show_reposts=false&show_teaser=true&visual=true`;
case "Bandcamp": {
return `https://bandcamp.com/EmbeddedPlayer/${this.specialContent.content_type.toLowerCase()}=${
this.specialContent.id
}/size=large/bgcol=181a1b/linkcol=056cc4/tracklist=false/transparent=true/`;
}
case "Streamable": {
return `https://streamable.com/e/${this.specialContent.id}?loop=0`;
}
}
}
}
/**
* Text Embed
*/
export class TextEmbed extends MessageEmbed {
readonly iconUrl?: string;
readonly url?: string;
readonly title?: string;
readonly description?: string;
readonly media?: File;
readonly colour?: string;
/**
* Construct Video Embed
* @param client Client
* @param embed Embed
*/
constructor(
client: Client,
embed: Omit<API.Embed & { type: "Text" }, "type">
) {
super(client, "Text");
this.iconUrl = embed.icon_url!;
this.url = embed.url!;
this.title = embed.title!;
this.description = embed.description!;
this.media = embed.media ? new File(client, embed.media) : undefined;
this.colour = embed.colour!;
}
/**
* Proxied icon URL
*/
get proxiedIconURL() {
return this.iconUrl ? this.client?.proxyFile(this.iconUrl) : undefined;
}
}
+47
View File
@@ -0,0 +1,47 @@
import { API, Channel, Client, File, Server } from "..";
/**
* Public Bot Class
*/
export class PublicBot {
#client: Client;
readonly id: string;
readonly username: string;
readonly avatar?: File;
readonly description?: string;
/**
* Construct Public Bot
* @param client Client
* @param data Data
*/
constructor(client: Client, data: API.PublicBot) {
this.#client = client;
this.id = data._id;
this.username = data.username;
this.avatar = data.avatar ? new File(client, data.avatar) : undefined;
this.description = data.description!;
}
/**
* Add the bot to a server
* @param server Server
*/
addToServer(server: Server | string) {
this.#client.api.post(`/bots/${this.id as ""}/invite`, {
server: server instanceof Server ? server.id : server,
});
}
/**
* Add the bot to a group
* @param group Group
*/
addToGroup(group: Channel | string) {
// TODO: should use GroupChannel once that is added
this.#client.api.post(`/bots/${this.id as ""}/invite`, {
group: group instanceof Channel ? group.id : group,
});
}
}
+108
View File
@@ -0,0 +1,108 @@
import { API, Client, File } from "..";
import { ServerFlags } from "../hydration/server";
/**
* Public Channel Invite
*/
export abstract class PublicChannelInvite {
protected client?: Client;
readonly type: API.Invite["type"] | "None";
/**
* Construct Channel Invite
* @param client Client
* @param type Type
*/
constructor(client?: Client, type: API.Invite["type"] | "None" = "None") {
this.client = client;
this.type = type;
}
/**
* Create an Invite from an API Invite Response
* @param client Client
* @param invite Data
* @returns Invite
*/
static from(client: Client, invite: API.InviteResponse): PublicChannelInvite {
switch (invite.type) {
case "Server":
return new ServerPublicInvite(client, invite);
default:
return new UnknownPublicInvite(client);
}
}
}
/**
* Public invite of unknown type
*/
export class UnknownPublicInvite extends PublicChannelInvite {}
/**
* Public Server Invite
*/
export class ServerPublicInvite extends PublicChannelInvite {
readonly code: string;
readonly userName: string;
readonly userAvatar?: File;
readonly serverId: string;
readonly serverName: string;
readonly serverIcon?: File;
readonly serverBanner?: File;
readonly serverFlags: ServerFlags;
readonly memberCount: number;
readonly channelId: string;
readonly channelName: string;
readonly channelDescription?: string;
/**
* Construct Server Invite
* @param client Client
* @param invite Invite
*/
constructor(client: Client, invite: API.InviteResponse & { type: "Server" }) {
super(client, "Server");
this.code = invite.code;
this.userName = invite.user_name;
this.userAvatar = invite.user_avatar
? new File(client, invite.user_avatar)
: undefined;
this.serverId = invite.server_id;
this.serverName = invite.server_name;
this.serverIcon = invite.server_icon
? new File(client, invite.server_icon)
: undefined;
this.serverBanner = invite.server_banner
? new File(client, invite.server_banner)
: undefined;
this.serverFlags = invite.server_flags ?? 0;
this.memberCount = invite.member_count;
this.channelId = invite.channel_id;
this.channelName = invite.channel_name;
this.channelDescription = invite.channel_description!;
}
/**
* Join the server
*/
async join() {
const existingServer = this.client!.servers.get(this.serverId);
if (existingServer) return existingServer;
const { server, channels } = await this.client!.api.post(
`/invites/${this.code as ""}`
);
for (const channel of channels) {
this.client!.channels.getOrCreate(channel._id, channel);
}
return this.client!.servers.getOrCreate(server._id, server, true);
}
}
+603
View File
@@ -0,0 +1,603 @@
import type {
Category,
DataBanCreate,
DataCreateChannel,
DataCreateEmoji,
DataEditRole,
DataEditServer,
Override,
} from "revolt-api";
import { decodeTime } from "ulid";
import { ServerMember, User } from "..";
import { ServerCollection } from "../collections";
import { bitwiseAndEq, calculatePermission } from "../permissions/calculator";
import { Permission } from "../permissions/definitions";
import { Channel } from "./Channel";
import { ChannelInvite } from "./Invite";
import { ServerBan } from "./ServerBan";
/**
* Server Class
*/
export class Server {
readonly #collection: ServerCollection;
readonly id: string;
/**
* Construct Server
* @param collection Collection
* @param id Id
*/
constructor(collection: ServerCollection, id: string) {
this.#collection = collection;
this.id = id;
}
/**
* Convert to string
* @returns String
*/
toString() {
return `<%${this.id}>`;
}
/**
* Time when this server was created
*/
get createdAt() {
return new Date(decodeTime(this.id));
}
/**
* Owner's user ID
*/
get ownerId() {
return this.#collection.getUnderlyingObject(this.id).ownerId;
}
/**
* Owner
*/
get owner() {
return this.#collection.client.users.get(
this.#collection.getUnderlyingObject(this.id).ownerId
);
}
/**
* Name
*/
get name() {
return this.#collection.getUnderlyingObject(this.id).name;
}
/**
* Description
*/
get description() {
return this.#collection.getUnderlyingObject(this.id).description;
}
/**
* Icon
*/
get icon() {
return this.#collection.getUnderlyingObject(this.id).icon;
}
/**
* Banner
*/
get banner() {
return this.#collection.getUnderlyingObject(this.id).banner;
}
/**
* Channel IDs
*/
get channelIds() {
return this.#collection.getUnderlyingObject(this.id).channelIds;
}
/**
* Channels
*/
get channels() {
return [
...this.#collection.getUnderlyingObject(this.id).channelIds.values(),
]
.map((id) => this.#collection.client.channels.get(id)!)
.filter((x) => x);
}
/**
* Categories
*/
get categories() {
return this.#collection.getUnderlyingObject(this.id).categories;
}
/**
* System message channels
*/
get systemMessages() {
return this.#collection.getUnderlyingObject(this.id).systemMessages;
}
/**
* Roles
*/
get roles() {
return this.#collection.getUnderlyingObject(this.id).roles;
}
/**
* Default permissions
*/
get defaultPermissions() {
return this.#collection.getUnderlyingObject(this.id).defaultPermissions;
}
/**
* Server flags
*/
get flags() {
return this.#collection.getUnderlyingObject(this.id).flags;
}
/**
* Whether analytics are enabled for this server
*/
get analytics() {
return this.#collection.getUnderlyingObject(this.id).analytics;
}
/**
* Whether this server is publicly discoverable
*/
get discoverable() {
return this.#collection.getUnderlyingObject(this.id).discoverable;
}
/**
* Whether this server is marked as mature
*/
get mature() {
return this.#collection.getUnderlyingObject(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(this.#collection.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) => this.#collection.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))
: [];
}
/**
* Check whether the server is currently unread
* @returns Whether the server is unread
*/
get unread() {
return this.channels.find((channel) => channel.unread);
}
/**
* Find all message IDs of unread messages
* @returns Array of message IDs which are unread
*/
get mentions() {
const arr = this.channels.map((channel) =>
Array.from(channel.mentions?.values() ?? [])
);
return ([] as string[]).concat(...arr);
}
/**
* URL to the server's icon
*/
get iconURL() {
return this.icon?.createFileURL({ max_side: 256 });
}
/**
* URL to the server's animated icon
*/
get animatedIconURL() {
return this.icon?.createFileURL({ max_side: 256 }, true);
}
/**
* URL to the server's banner
*/
get bannerURL() {
return this.banner?.createFileURL({ max_side: 256 }, true);
}
/**
* Own member object for this server
*/
get member() {
return this.#collection.client.serverMembers.getByKey({
server: this.id,
user: this.#collection.client.user!.id,
});
}
/**
* Permission the currently authenticated user has against this server
*/
get permission() {
return calculatePermission(this.#collection.client, this);
}
/**
* Check whether we have a given permission in a server
* @param permission Permission Names
* @returns Whether we have this permission
*/
havePermission(...permission: (keyof typeof Permission)[]) {
return bitwiseAndEq(
this.permission,
...permission.map((x) => Permission[x])
);
}
/**
* Create a channel
* @param data Channel create route data
* @returns The newly-created channel
*/
async createChannel(data: DataCreateChannel) {
const channel = await this.#collection.client.api.post(
`/servers/${this.id as ""}/channels`,
data
);
return this.#collection.client.channels.getOrCreate(channel._id, channel);
}
/**
* Edit a server
* @param data Changes
*/
async edit(data: DataEditServer) {
await this.#collection.client.api.patch(`/servers/${this.id as ""}`, data);
}
/**
* Delete or leave a server
* @param leaveSilently Whether to not send a message on leave
*/
async delete(leaveSilently?: boolean) {
await this.#collection.client.api.delete(`/servers/${this.id as ""}`, {
leave_silently: leaveSilently,
});
this.#collection.delete(this.id);
}
/**
* Mark a server as read
*/
async ack() {
await this.#collection.client.api.put(`/servers/${this.id}/ack`);
}
/**
* Ban user from this server
* @param user User
* @param options Ban options
*/
async banUser(
user: string | User | ServerMember,
options: DataBanCreate = {}
) {
const userId =
user instanceof User
? user.id
: user instanceof ServerMember
? user.id.user
: user;
const ban = await this.#collection.client.api.put(
`/servers/${this.id as ""}/bans/${userId as ""}`,
options
);
return new ServerBan(this.#collection.client, ban);
}
/**
* Kick user from this server
* @param user User
*/
async kickUser(user: string | User | ServerMember) {
const userId =
user instanceof User
? user.id
: user instanceof ServerMember
? user.id.user
: user;
return await this.#collection.client.api.delete(
`/servers/${this.id as ""}/members/${userId}`
);
}
/**
* Pardon user's ban
* @param user User
*/
async unbanUser(user: string | User) {
const userId = user instanceof User ? user.id : user;
return await this.#collection.client.api.delete(
`/servers/${this.id as ""}/bans/${userId}`
);
}
/**
* Fetch a server's invites
* @returns An array of the server's invites
*/
async fetchInvites() {
const invites = await this.#collection.client.api.get(
`/servers/${this.id as ""}/invites`
);
return invites.map((invite) =>
ChannelInvite.from(this.#collection.client, invite)
);
}
/**
* Fetch a server's bans
* @returns An array of the server's bans.
*/
async fetchBans() {
const { users, bans } = await this.#collection.client.api.get(
`/servers/${this.id as ""}/bans`
);
users.forEach((user) =>
this.#collection.client.users.getOrCreate(user._id, user)
);
return bans.map((ban) => new ServerBan(this.#collection.client, ban));
}
/**
* Set role permissions
* @param roleId Role Id, set to 'default' to affect all users
* @param permissions Permission value
*/
async setPermissions(roleId = "default", permissions: Override | number) {
return await this.#collection.client.api.put(
`/servers/${this.id as ""}/permissions/${roleId as ""}`,
{ permissions: permissions as Override }
);
}
/**
* Create role
* @param name Role name
*/
async createRole(name: string) {
return await this.#collection.client.api.post(
`/servers/${this.id as ""}/roles`,
{
name,
}
);
}
/**
* Edit a role
* @param roleId Role ID
* @param data Role editing route data
*/
async editRole(roleId: string, data: DataEditRole) {
return await this.#collection.client.api.patch(
`/servers/${this.id as ""}/roles/${roleId as ""}`,
data
);
}
/**
* Delete role
* @param roleId Role ID
*/
async deleteRole(roleId: string) {
return await this.#collection.client.api.delete(
`/servers/${this.id as ""}/roles/${roleId as ""}`
);
}
/**
* Fetch a server member
* @param user User
* @returns Server member object
*/
async fetchMember(user: User | string) {
const userId = typeof user === "string" ? user : user.id;
const existing = this.#collection.client.serverMembers.getByKey({
server: this.id,
user: userId,
});
if (existing) return existing;
const member = await this.#collection.client.api.get(
`/servers/${this.id as ""}/members/${userId as ""}`
);
return this.#collection.client.serverMembers.getOrCreate(
member._id,
member
);
}
/**
* Optimised member fetch route
* @param excludeOffline
*/
async syncMembers(excludeOffline?: boolean) {
const data = await this.#collection.client.api.get(
`/servers/${this.id as ""}/members`,
{ exclude_offline: excludeOffline }
);
if (excludeOffline) {
for (let i = 0; i < data.users.length; i++) {
const user = data.users[i];
if (user.online) {
this.#collection.client.users.getOrCreate(user._id, user);
this.#collection.client.serverMembers.getOrCreate(
data.members[i]._id,
data.members[i]
);
}
}
} else {
for (let i = 0; i < data.users.length; i++) {
this.#collection.client.users.getOrCreate(
data.users[i]._id,
data.users[i]
);
this.#collection.client.serverMembers.getOrCreate(
data.members[i]._id,
data.members[i]
);
}
}
}
/**
* Fetch a server's members
* @returns List of the server's members and their user objects
*/
async fetchMembers() {
const data = await this.#collection.client.api.get(
`/servers/${this.id as ""}/members`
);
return {
members: data.members.map((member) =>
this.#collection.client.serverMembers.getOrCreate(member._id, member)
),
users: data.users.map((user) =>
this.#collection.client.users.getOrCreate(user._id, user)
),
};
}
/**
* Create an emoji on the server
* @param autumnId Autumn Id
* @param options Options
*/
async createEmoji(
autumnId: string,
options: Omit<DataCreateEmoji, "parent">
) {
const emoji = await this.#collection.client.api.put(
`/custom/emoji/${autumnId as ""}`,
{
parent: {
type: "Server",
id: this.id,
},
...options,
}
);
return this.#collection.client.emojis.getOrCreate(emoji._id, emoji, true);
}
/**
* Fetch a server's emoji
* @returns List of server emoji
*/
async fetchEmojis() {
const emojis = await this.#collection.client.api.get(
`/servers/${this.id as ""}/emojis`
);
return emojis.map((emoji) =>
this.#collection.client.emojis.getOrCreate(emoji._id, emoji)
);
}
}
+46
View File
@@ -0,0 +1,46 @@
import { MemberCompositeKey } from "revolt-api";
import { API, Client } from "..";
/**
* Server Ban
*/
export class ServerBan {
protected client: Client;
readonly id: MemberCompositeKey;
readonly reason?: string;
/**
* Construct Server Ban
* @param client Client
* @param data Data
*/
constructor(client: Client, data: API.ServerBan) {
this.client = client;
this.id = data._id;
this.reason = data.reason!;
}
/**
* User
*/
get user() {
return this.client.users.get(this.id.user);
}
/**
* Server
*/
get server() {
return this.client.servers.get(this.id.server);
}
/**
* Remove this server ban
*/
async pardon() {
await this.client.api.delete(
`/servers/${this.id.server as ""}/bans/${this.id.user as ""}`
);
}
}
+233
View File
@@ -0,0 +1,233 @@
import type {
DataBanCreate,
DataMemberEdit,
MemberCompositeKey,
} from "revolt-api";
import { ServerMemberCollection } from "../collections";
import { bitwiseAndEq, calculatePermission } from "../permissions/calculator";
import { Permission } from "../permissions/definitions";
import { Channel } from "./Channel";
import { Server } from "./Server";
/**
* Deterministic conversion of member composite key to string ID
* @param key Key
* @returns String key
*/
function key(key: MemberCompositeKey) {
return key.server + key.user;
}
/**
* Server Member Class
*/
export class ServerMember {
readonly #collection: ServerMemberCollection;
readonly id: MemberCompositeKey;
/**
* Construct Server Member
* @param collection Collection
* @param id Id
*/
constructor(collection: ServerMemberCollection, id: MemberCompositeKey) {
this.#collection = collection;
this.id = id;
}
/**
* Convert to string
* @returns String
*/
toString() {
return `<@${this.id.user}>`;
}
/**
* Server this member belongs to
*/
get server() {
return this.#collection.client.servers.get(this.id.server);
}
/**
* User corresponding to this member
*/
get user() {
return this.#collection.client.users.get(this.id.user);
}
/**
* When this user joined the server
*/
get joinedAt() {
return this.#collection.getUnderlyingObject(key(this.id)).joinedAt;
}
/**
* Nickname
*/
get nickname() {
return this.#collection.getUnderlyingObject(key(this.id)).nickname;
}
/**
* Avatar
*/
get avatar() {
return this.#collection.getUnderlyingObject(key(this.id)).avatar;
}
/**
* List of role IDs
*/
get roles() {
return this.#collection.getUnderlyingObject(key(this.id)).roles;
}
/**
* Time at which timeout expires
*/
get timeout() {
return this.#collection.getUnderlyingObject(key(this.id)).timeout;
}
/**
* Ordered list of roles for this member, from lowest to highest priority.
*/
get orderedRoles() {
const server = this.server!;
return (
this.roles
?.map((id) => ({
id,
...server.roles?.get(id),
}))
.sort((a, b) => b.rank! - a.rank!) ?? []
);
}
/**
* Member's currently hoisted role.
*/
get hoistedRole() {
const roles = this.orderedRoles.filter((x) => x.hoist);
if (roles.length > 0) {
return roles[roles.length - 1];
} else {
return null;
}
}
/**
* Member's current role colour.
*/
get roleColour() {
const roles = this.orderedRoles.filter((x) => x.colour);
if (roles.length > 0) {
return roles[roles.length - 1].colour;
} else {
return null;
}
}
/**
* Member's ranking
* Smaller values are ranked as higher priority
*/
get ranking() {
if (this.id.user === this.server?.ownerId) {
return -Infinity;
}
const roles = this.orderedRoles;
if (roles.length > 0) {
return roles[roles.length - 1].rank!;
} else {
return Infinity;
}
}
/**
* Get the permissions that this member has against a certain object
* @param target Target object to check permissions against
* @returns Permissions that this member has
*/
getPermissions(target: Server | Channel) {
return calculatePermission(this.#collection.client, target, {
member: this,
});
}
/**
* Check whether a member has a certain permission against a certain object
* @param target Target object to check permissions against
* @param permission Permission names to check for
* @returns Whether the member has this permission
*/
hasPermission(
target: Server | Channel,
...permission: (keyof typeof Permission)[]
) {
return bitwiseAndEq(
this.getPermissions(target),
...permission.map((x) => Permission[x])
);
}
/**
* Checks whether the target member has a higher rank than this member.
* @param target The member to compare against
* @returns Whether this member is inferior to the target
*/
inferiorTo(target: ServerMember) {
return target.ranking < this.ranking;
}
/**
* URL to the member's avatar
*/
get avatarURL() {
return (
this.avatar?.createFileURL({ max_side: 256 }) ?? this.user?.avatarURL
);
}
/**
* URL to the member's animated avatar
*/
get animatedAvatarURL() {
return (
this.avatar?.createFileURL({ max_side: 256 }, true) ??
this.user?.animatedAvatarURL
);
}
/**
* Edit a member
* @param data Changes
*/
async edit(data: DataMemberEdit) {
await this.#collection.client.api.patch(
`/servers/${this.id.server as ""}/members/${this.id.user as ""}`,
data
);
}
/**
* Ban this member from the server
* @param options Ban options
*/
async ban(options: DataBanCreate) {
this.server?.banUser(this, options);
}
/**
* Kick this member from the server
*/
async kick() {
this.server?.kickUser(this);
}
}
+229
View File
@@ -0,0 +1,229 @@
import { API, Client } from "..";
/**
* System Message
*/
export abstract class SystemMessage {
protected client?: Client;
readonly type: API.SystemMessage["type"];
/**
* Construct System Message
* @param client Client
* @param type Type
*/
constructor(client: Client, type: API.SystemMessage["type"]) {
this.client = client;
this.type = type;
}
/**
* Create an System Message from an API System Message
* @param client Client
* @param embed Data
* @returns System Message
*/
static from(client: Client, message: API.SystemMessage): SystemMessage {
switch (message.type) {
case "text":
return new TextSystemMessage(client, message);
case "user_added":
case "user_remove":
return new UserModeratedSystemMessage(client, message);
case "user_joined":
case "user_left":
case "user_kicked":
case "user_banned":
return new UserSystemMessage(client, message);
case "channel_renamed":
return new ChannelRenamedSystemMessage(client, message);
case "channel_description_changed":
case "channel_icon_changed":
return new ChannelEditSystemMessage(client, message);
case "channel_ownership_changed":
return new ChannelOwnershipChangeSystemMessage(client, message);
default:
return new TextSystemMessage(client, {
type: "text",
content: `${(message as { type: string }).type} is not supported.`,
});
}
}
}
/**
* Text System Message
*/
export class TextSystemMessage extends SystemMessage {
readonly content: string;
/**
* Construct System Message
* @param client Client
* @param systemMessage System Message
*/
constructor(
client: Client,
systemMessage: API.SystemMessage & { type: "text" }
) {
super(client, systemMessage.type);
this.content = systemMessage.content;
}
}
/**
* User System Message
*/
export class UserSystemMessage extends SystemMessage {
readonly userId: string;
/**
* Construct System Message
* @param client Client
* @param systemMessage System Message
*/
constructor(
client: Client,
systemMessage: API.SystemMessage & {
type:
| "user_added"
| "user_remove"
| "user_joined"
| "user_left"
| "user_kicked"
| "user_banned";
}
) {
super(client, systemMessage.type);
this.userId = systemMessage.id;
}
/**
* User this message concerns
*/
get user() {
return this.client!.users.get(this.userId);
}
}
/**
* User Moderated System Message
*/
export class UserModeratedSystemMessage extends UserSystemMessage {
readonly byId: string;
/**
* Construct System Message
* @param client Client
* @param systemMessage System Message
*/
constructor(
client: Client,
systemMessage: API.SystemMessage & {
type: "user_added" | "user_remove";
}
) {
super(client, systemMessage);
this.byId = systemMessage.by;
}
/**
* User this action was performed by
*/
get by() {
console.info("deez!", this.byId);
return this.client!.users.get(this.byId);
}
}
/**
* Channel Edit System Message
*/
export class ChannelEditSystemMessage extends SystemMessage {
readonly byId: string;
/**
* Construct System Message
* @param client Client
* @param systemMessage System Message
*/
constructor(
client: Client,
systemMessage: API.SystemMessage & {
type:
| "channel_renamed"
| "channel_description_changed"
| "channel_icon_changed";
}
) {
super(client, systemMessage.type);
this.byId = systemMessage.by;
}
/**
* User this action was performed by
*/
get by() {
return this.client!.users.get(this.byId);
}
}
/**
* Channel Renamed System Message
*/
export class ChannelRenamedSystemMessage extends ChannelEditSystemMessage {
readonly name: string;
/**
* Construct System Message
* @param client Client
* @param systemMessage System Message
*/
constructor(
client: Client,
systemMessage: API.SystemMessage & {
type: "channel_renamed";
}
) {
super(client, systemMessage);
this.name = systemMessage.name;
}
}
/**
* Channel Ownership Change System Message
*/
export class ChannelOwnershipChangeSystemMessage extends SystemMessage {
readonly fromId: string;
readonly toId: string;
/**
* Construct System Message
* @param client Client
* @param systemMessage System Message
*/
constructor(
client: Client,
systemMessage: API.SystemMessage & {
type: "channel_ownership_changed";
}
) {
super(client, systemMessage.type);
this.fromId = systemMessage.from;
this.toId = systemMessage.to;
}
/**
* User giving away channel ownership
*/
get from() {
return this.client!.users.get(this.fromId);
}
/**
* User receiving channel ownership
*/
get to() {
return this.client!.users.get(this.toId);
}
}
+271
View File
@@ -0,0 +1,271 @@
import { DataEditUser } from "revolt-api";
import { decodeTime } from "ulid";
import { UserCollection } from "../collections";
import { U32_MAX, UserPermission } from "../permissions/definitions";
/**
* User Class
*/
export class User {
readonly #collection: UserCollection;
readonly id: string;
/**
* Construct User
* @param collection Collection
* @param id Id
*/
constructor(collection: UserCollection, id: string) {
this.#collection = collection;
this.id = id;
}
/**
* Write to string as a user mention
* @returns Formatted String
*/
toString() {
return `<@${this.id}>`;
}
/**
* Time when this user created their account
*/
get createdAt() {
return new Date(decodeTime(this.id));
}
/**
* Username
*/
get username() {
return this.#collection.getUnderlyingObject(this.id).username;
}
/**
* Avatar
*/
get avatar() {
return this.#collection.getUnderlyingObject(this.id).avatar;
}
/**
* Badges
*/
get badges() {
return this.#collection.getUnderlyingObject(this.id).badges;
}
/**
* User Status
*/
get status() {
return this.#collection.getUnderlyingObject(this.id).status;
}
/**
* Relationship with user
*/
get relationship() {
return this.#collection.getUnderlyingObject(this.id).relationship;
}
/**
* Whether the user is online
*/
get online() {
return this.#collection.getUnderlyingObject(this.id).online;
}
/**
* Whether the user is privileged
*/
get privileged() {
return this.#collection.getUnderlyingObject(this.id).privileged;
}
/**
* Flags
*/
get flags() {
return this.#collection.getUnderlyingObject(this.id).flags;
}
/**
* Bot information
*/
get bot() {
return this.#collection.getUnderlyingObject(this.id).bot;
}
/**
* URL to the user's default avatar
*/
get defaultAvatarURL() {
return `${this.#collection.client.options.baseURL}/users/${
this.id
}/default_avatar`;
}
/**
* URL to the user's avatar
*/
get avatarURL() {
return (
this.avatar?.createFileURL({ max_side: 256 }) ?? this.defaultAvatarURL
);
}
/**
* URL to the user's animated avatar
*/
get animatedAvatarURL() {
return (
this.avatar?.createFileURL({ max_side: 256 }, true) ??
this.defaultAvatarURL
);
}
/**
* Permissions against this user
*/
get permission() {
let permissions = 0;
switch (this.relationship) {
case "Friend":
case "User":
return U32_MAX;
case "Blocked":
case "BlockedOther":
return UserPermission.Access;
case "Incoming":
case "Outgoing":
permissions = UserPermission.Access;
}
if (
this.#collection.client.channels.find(
(channel) =>
(channel.type === "Group" || channel.type === "DirectMessage") &&
channel.recipientIds.has(this.id)
) ||
this.#collection.client.serverMembers.find(
(member) => member.id.user === this.id
)
) {
if (this.#collection.client.user?.bot || this.bot) {
permissions |= UserPermission.SendMessage;
}
permissions |= UserPermission.Access | UserPermission.ViewProfile;
}
return permissions;
}
/**
* Edit the user
* @param data Changes
*/
async edit(data: DataEditUser) {
await this.#collection.client.api.patch(
`/users/${
this.id === this.#collection.client.user?.id ? "@me" : this.id
}`,
data
);
}
/**
* Change the username of the current user
* @param username New username
* @param password Current password
*/
async changeUsername(username: string, password: string) {
return await this.#collection.client.api.patch("/users/@me/username", {
username,
password,
});
}
/**
* Open a DM with a user
* @returns DM Channel
*/
async openDM() {
let dm = [...this.#collection.client.channels.values()].find(
(x) => x.type === "DirectMessage" && x.recipient == this
);
if (dm) {
if (!dm.active) {
this.#collection.client.channels.updateUnderlyingObject(
dm.id,
"active",
true
);
}
} else {
const data = await this.#collection.client.api.get(
`/users/${this.id as ""}/dm`
);
dm = this.#collection.client.channels.getOrCreate(data._id, data)!;
}
return dm;
}
/**
* Send a friend request to a user
*/
async addFriend() {
const user = await this.#collection.client.api.post(`/users/friend`, {
username: this.username,
});
return this.#collection.getOrCreate(user._id, user);
}
/**
* Remove a user from the friend list
*/
async removeFriend() {
await this.#collection.client.api.delete(`/users/${this.id as ""}/friend`);
}
/**
* Block a user
*/
async blockUser() {
await this.#collection.client.api.put(`/users/${this.id as ""}/block`);
}
/**
* Unblock a user
*/
async unblockUser() {
await this.#collection.client.api.delete(`/users/${this.id as ""}/block`);
}
/**
* Fetch the profile of a user
* @returns The profile of the user
*/
async fetchProfile() {
return await this.#collection.client.api.get(
`/users/${this.id as ""}/profile`
);
}
/**
* Fetch the mutual connections of the current user and a target user
* @returns The mutual connections of the current user and a target user
*/
async fetchMutual() {
return await this.#collection.client.api.get(
`/users/${this.id as ""}/mutual`
);
}
}
+15
View File
@@ -0,0 +1,15 @@
export * from "./Bot";
export * from "./Channel";
export * from "./ChannelUnread";
export * from "./Emoji";
export * from "./File";
export * from "./Invite";
export * from "./Message";
export * from "./MessageEmbed";
export * from "./PublicBot";
export * from "./PublicInvite";
export * from "./Server";
export * from "./ServerBan";
export * from "./ServerMember";
export * from "./SystemMessage";
export * from "./User";
+73
View File
@@ -0,0 +1,73 @@
import { OwnedBotsResponse } from "revolt-api";
import { API, Bot, PublicBot } from "..";
import { HydratedBot } from "../hydration/bot";
import { ClassCollection } from ".";
/**
* Collection of Bots
*/
export class BotCollection extends ClassCollection<Bot, HydratedBot> {
/**
* Fetch bot by ID
* @param id Id
* @returns Bot
*/
async fetch(id: string): Promise<Bot> {
const bot = this.get(id);
if (bot) return bot;
const data = await this.client.api.get(`/bots/${id as ""}`);
this.client.users.getOrCreate(data.user._id, data.user);
return this.getOrCreate(data.bot._id, data.bot);
}
/**
* Fetch owned bots
* @returns List of bots
*/
async fetchOwned(): Promise<Bot[]> {
const data = (await this.client.api.get("/bots/@me")) as OwnedBotsResponse;
data.users.forEach((user) => this.client.users.getOrCreate(user._id, user));
return data.bots.map((bot) => this.getOrCreate(bot._id, bot));
}
/**
* Fetch public bot by ID
* @param id Id
* @returns Public Bot
*/
async fetchPublic(id: string): Promise<PublicBot> {
const data = await this.client.api.get(`/bots/${id as ""}/invite`);
return new PublicBot(this.client, data);
}
/**
* Get or create
* @param id Id
* @param data Data
* @returns Bot
*/
getOrCreate(id: string, data: API.Bot) {
if (this.has(id)) {
return this.get(id)!;
} else {
const instance = new Bot(this, id);
this.create(id, "bot", instance, this.client, data);
return instance;
}
}
/**
* Create a bot
* @param name Bot name
* @returns The newly-created bot
*/
async createBot(name: string) {
const bot = await this.client.api.post(`/bots/create`, {
name,
});
return this.getOrCreate(bot._id, bot);
}
}
+83
View File
@@ -0,0 +1,83 @@
import { API, Channel, User } from "..";
import { HydratedChannel } from "../hydration";
import { ClassCollection } from ".";
/**
* Collection of Channels
*/
export class ChannelCollection extends ClassCollection<
Channel,
HydratedChannel
> {
/**
* Delete an object
* @param id Id
*/
override delete(id: string): void {
const 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> {
const channel = this.get(id);
if (channel) return channel;
const data = await this.client.api.get(`/channels/${id as ""}`);
return this.getOrCreate(data._id, data);
}
/**
* Get or create
* @param id Id
* @param data Data
* @param isNew Whether this object is new
*/
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, this.client, 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, this.client, {
id,
partial: true,
});
return instance;
}
}
/**
* Create a group
* @param name Group name
* @param users Users to add
* @returns The newly-created group
*/
async createGroup(name: string, users: (User | string)[]) {
const group = await this.client.api.post(`/channels/create`, {
name,
users: users.map((user) => (user instanceof User ? user.id : user)),
});
return this.getOrCreate(group._id, group, true);
}
}
@@ -0,0 +1,46 @@
import { API } from "..";
import { ChannelUnread } from "../classes/ChannelUnread";
import { HydratedChannelUnread } from "../hydration";
import { ClassCollection } from ".";
/**
* Collection of Channel Unreads
*/
export class ChannelUnreadCollection extends ClassCollection<
ChannelUnread,
HydratedChannelUnread
> {
/**
* Load unread information from server
*/
async sync() {
const unreads = await this.client.api.get("/sync/unreads");
this.reset();
for (const unread of unreads) {
this.getOrCreate(unread._id.channel, unread);
}
}
/**
* Clear all unread data
*/
reset() {
this.updateUnderlyingObject({});
}
/**
* Get or create
* @param id Id
* @param data Data
*/
getOrCreate(id: string, data: API.ChannelUnread) {
if (this.has(id)) {
return this.get(id)!;
} else {
const instance = new ChannelUnread(this, id);
this.create(id, "channelUnread", instance, this.client, data);
return instance;
}
}
}
+243
View File
@@ -0,0 +1,243 @@
import { SetStoreFunction } from "solid-js/store";
import { ReactiveMap } from "@solid-primitives/map";
import { Client } from "..";
import { Hydrators } from "../hydration";
import { ObjectStorage } from "../storage/ObjectStorage";
/**
* Abstract Collection type
*/
export abstract class Collection<T> {
/**
* Get an existing object
* @param id Id
* @returns Object
*/
abstract get(id: string): T | undefined;
/**
* Check whether an id exists in the Collection
* @param id Id
* @returns Whether it exists
*/
abstract has(id: string): boolean;
/**
* Delete an object
* @param id Id
*/
abstract delete(id: string): void;
/**
* Number of stored objects
* @returns Size
*/
abstract size(): number;
/**
* Iterable of keys in the map
* @returns Iterable
*/
abstract keys(): IterableIterator<string>;
/**
* Iterable of values in the map
* @returns Iterable
*/
abstract values(): IterableIterator<T>;
/**
* Iterable of key, value pairs in the map
* @returns Iterable
*/
abstract entries(): IterableIterator<[string, T]>;
/**
* Execute a provided function over each key, value pair in the map
* @param cb Callback for each pair
*/
abstract forEach(
cb: (value: T, key: string, map: ReactiveMap<string, T>) => void
): void;
/**
* List of values in the map
* @returns List
*/
toList() {
return [...this.values()];
}
/**
* Filter the collection by a given predicate
* @param predicate Predicate to satisfy
*/
filter(predicate: (value: T, key: string) => boolean): T[] {
const list: T[] = [];
for (const [key, value] of this.entries()) {
if (predicate(value, key)) {
list.push(value);
}
}
return list;
}
/**
* Map the collection using a given callback
* @param cb Callback
*/
map<O>(cb: (value: T, key: string) => O): O[] {
const list: O[] = [];
for (const [key, value] of this.entries()) {
list.push(cb(value, key));
}
return list;
}
/**
* Find some value based on a predicate
* @param predicate Predicate to satisfy
*/
find(predicate: (value: T, key: string) => boolean): T | undefined {
for (const [key, value] of this.entries()) {
if (predicate(value, key)) {
return value;
}
}
}
}
/**
* Collection backed by a Solid.js Store
*/
export abstract class StoreCollection<T, V> extends Collection<T> {
#storage = new ObjectStorage<V>();
#objects = new ReactiveMap<string, T>();
readonly getUnderlyingObject: (id: string) => V;
readonly updateUnderlyingObject: SetStoreFunction<Record<string, V>>;
/**
* Construct store backed collection
*/
constructor() {
super();
this.getUnderlyingObject = this.#storage.get;
this.updateUnderlyingObject = this.#storage.set;
}
/**
* Get an existing object
* @param id Id
* @returns Object
*/
get(id: string): T | undefined {
return this.#objects.get(id);
}
/**
* Check whether an id exists in the Collection
* @param id Id
* @returns Whether it exists
*/
has(id: string) {
return this.#objects.has(id);
}
/**
* Delete an object
* @param id Id
*/
delete(id: string): void {
this.#objects.delete(id);
this.updateUnderlyingObject(id, undefined as never);
}
/**
* Create a new instance of an object
* @param id Id
* @param type Type
* @param instance Instance
* @param context Context
* @param data Data
*/
create(
id: string,
type: keyof Hydrators,
instance: T,
context: unknown,
data?: unknown
) {
this.#storage.hydrate(id, type, context, data);
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
*/
size() {
return this.#objects.size;
}
/**
* Iterable of keys in the map
* @returns Iterable
*/
keys() {
return this.#objects.keys();
}
/**
* Iterable of values in the map
* @returns Iterable
*/
values() {
return this.#objects.values();
}
/**
* Iterable of key, value pairs in the map
* @returns Iterable
*/
entries() {
return this.#objects.entries();
}
/**
* Execute a provided function over each key, value pair in the map
* @param cb Callback for each pair
* @returns Iterable
*/
forEach(cb: (value: T, key: string, map: ReactiveMap<string, T>) => void) {
return this.#objects.forEach(cb);
}
}
/**
* Generic class collection backed by store
*/
export class ClassCollection<T, V> extends StoreCollection<T, V> {
readonly client: Client;
/**
* Create generic class collection
* @param client Client
*/
constructor(client: Client) {
super();
this.client = client;
}
}
+54
View File
@@ -0,0 +1,54 @@
import { API, Emoji } from "..";
import { HydratedEmoji } from "../hydration";
import { ClassCollection } from ".";
/**
* Collection of Emoji
*/
export class EmojiCollection extends ClassCollection<Emoji, HydratedEmoji> {
/**
* Fetch emoji by ID
* @param id Id
* @returns Emoji
*/
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 ""}`);
return this.getOrCreate(data._id, data);
}
/**
* Get or create
* @param id Id
* @param data Data
* @param isNew Whether this object is new
*/
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, this.client, 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, this.client, {
id,
});
return instance;
}
}
}
+72
View File
@@ -0,0 +1,72 @@
import { API, Message } from "..";
import { HydratedMessage } from "../hydration";
import { ClassCollection } from ".";
/**
* Collection of Messages
*/
export class MessageCollection extends ClassCollection<
Message,
HydratedMessage
> {
/**
* Fetch message by Id
* @param channelId Channel Id
* @param messageId Message Id
* @returns Message
*/
async fetch(channelId: string, messageId: string): Promise<Message> {
const message = this.get(messageId);
if (message) return message;
const data = await this.client.api.get(
`/channels/${channelId as ""}/messages/${messageId as ""}`
);
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, isNew = false) {
if (this.has(id)) {
return this.get(id)!;
} else {
const instance = new Message(this, id);
this.create(id, "message", instance, this.client, 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, this.client, {
id,
partial: true,
});
return instance;
}
}
/**
* Globally fetch messages
* @requires Admin
* @param query Message query
*/
async queryMessages(query: API.MessageQuery) {
return this.client.api.post("/admin/messages", query);
}
}
+77
View File
@@ -0,0 +1,77 @@
import { DataCreateServer } from "revolt-api";
import { API, Server } from "..";
import { HydratedServer } from "../hydration";
import { ClassCollection } from ".";
/**
* Collection of Servers
*/
export class ServerCollection extends ClassCollection<Server, HydratedServer> {
/**
* Fetch server by ID
*
* This will not fetch channels!
* @param id Id
* @returns Server
*/
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 ""}`);
return this.getOrCreate(data._id, data);
}
/**
* Get or create
* @param id Id
* @param data Data
* @param isNew Whether this object is new
*/
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, this.client, 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, this.client, {
id,
partial: true,
});
return instance;
}
}
/**
* Create a server
* @param data Server options
* @returns The newly-created server
*/
async createServer(data: DataCreateServer) {
const { server, channels } = await this.client.api.post(
`/servers/create`,
data
);
for (const channel of channels) {
this.client.channels.getOrCreate(channel._id, channel);
}
return this.getOrCreate(server._id, server, true);
}
}
+85
View File
@@ -0,0 +1,85 @@
import { API, ServerMember } from "..";
import { HydratedServerMember } from "../hydration";
import { ClassCollection } from ".";
/**
* Collection of Server Members
*/
export class ServerMemberCollection extends ClassCollection<
ServerMember,
HydratedServerMember
> {
/**
* Check if member exists by composite key
* @param id Id
* @returns Whether it exists
*/
hasByKey(id: API.MemberCompositeKey) {
return super.has(id.server + id.user);
}
/**
* Get member by composite key
* @param id Id
* @returns Member
*/
getByKey(id: API.MemberCompositeKey) {
return super.get(id.server + id.user);
}
/**
* Fetch server member by Id
* @param serverId Server Id
* @param userId User Id
* @returns Message
*/
async fetch(serverId: string, userId: string): Promise<ServerMember> {
const member = this.get(serverId + userId);
if (member) return member;
const data = await this.client.api.get(
`/servers/${serverId as ""}/members/${userId as ""}`
);
return this.getOrCreate(data._id, data);
}
/**
* Get or create
* @param id Id
* @param data Data
*/
getOrCreate(id: API.MemberCompositeKey, data: API.Member) {
if (this.hasByKey(id)) {
return this.getByKey(id)!;
} else {
const instance = new ServerMember(this, id);
this.create(
id.server + id.user,
"serverMember",
instance,
this.client,
data
);
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, this.client, {
id,
partial: true,
});
return instance;
}
}
}
+54
View File
@@ -0,0 +1,54 @@
import { API, User } from "..";
import { HydratedUser } from "../hydration";
import { ClassCollection } from ".";
/**
* Collection of Users
*/
export class UserCollection extends ClassCollection<User, HydratedUser> {
/**
* Fetch user by ID
* @param id Id
* @returns User
*/
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 ""}`);
return this.getOrCreate(data._id, data);
}
/**
* 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)) {
return this.get(id)!;
} else {
const instance = new User(this, id);
this.create(id, "user", instance, this.client, data);
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, this.client, {
id,
partial: true,
});
return instance;
}
}
}
+10
View File
@@ -0,0 +1,10 @@
export * from "./Collection";
export { BotCollection } from "./BotCollection";
export { ChannelCollection } from "./ChannelCollection";
export { ChannelUnreadCollection } from "./ChannelUnreadCollection";
export { EmojiCollection } from "./EmojiCollection";
export { MessageCollection } from "./MessageCollection";
export { ServerCollection } from "./ServerCollection";
export { ServerMemberCollection } from "./ServerMemberCollection";
export { UserCollection } from "./UserCollection";
-13
View File
@@ -1,13 +0,0 @@
export const LIBRARY_VERSION = "6.0.17";
export const defaultConfig = {
apiURL: "https://api.revolt.chat",
autoReconnect: true,
heartbeat: 30,
debug: false,
cache: false,
unreads: false,
ackRateLimiter: true,
};
+229
View File
@@ -0,0 +1,229 @@
import { Accessor, Setter, createSignal } from "solid-js";
import EventEmitter from "eventemitter3";
import WebSocket from "isomorphic-ws";
import type { AvailableProtocols, EventProtocol } from ".";
/**
* All possible event client states.
*/
export enum ConnectionState {
Idle,
Connecting,
Connected,
Disconnected,
}
/**
* Event client options object
*/
export interface EventClientOptions {
/**
* Whether to log events
* @default false
*/
debug: boolean;
/**
* Time in seconds between Ping packets sent to the server
* @default 30
*/
heartbeatInterval: number;
/**
* Maximum time in seconds between Ping and corresponding Pong
* @default 10
*/
pongTimeout: number;
}
/**
* Events provided by the client.
*/
type Events<T extends AvailableProtocols, P extends EventProtocol<T>> = {
error: (error: Error) => void;
event: (event: P["server"]) => void;
state: (state: ConnectionState) => void;
};
/**
* Simple wrapper around the Revolt websocket service.
*/
export class EventClient<T extends AvailableProtocols> extends EventEmitter<
Events<T, EventProtocol<T>>
> {
readonly options: EventClientOptions;
#protocolVersion: T;
#transportFormat: "json" | "msgpack";
readonly ping: Accessor<number>;
#setPing: Setter<number>;
readonly state: Accessor<ConnectionState>;
#setStateSetter: Setter<ConnectionState>;
#socket: WebSocket | undefined;
#heartbeatIntervalReference: number | undefined;
#pongTimeoutReference: number | undefined;
/**
* Create a new event client.
* @param protocolVersion Target protocol version
* @param transportFormat Communication format
* @param options Configuration options
*/
constructor(
protocolVersion: T,
transportFormat: "json" = "json",
options?: Partial<EventClientOptions>
) {
super();
this.#protocolVersion = protocolVersion;
this.#transportFormat = transportFormat;
this.options = {
heartbeatInterval: 30,
pongTimeout: 10,
debug: false,
...options,
};
const [state, setState] = createSignal(ConnectionState.Idle);
this.state = state;
this.#setStateSetter = setState;
const [ping, setPing] = createSignal(-1);
this.ping = ping;
this.#setPing = setPing;
this.disconnect = this.disconnect.bind(this);
}
/**
* Set the current state
* @param state state
*/
private setState(state: ConnectionState) {
this.#setStateSetter(state);
this.emit("state", state);
}
/**
* Connect to the websocket service.
* @param uri WebSocket URI
* @param token Authentication token
*/
connect(uri: string, token: string) {
this.disconnect();
this.setState(ConnectionState.Connecting);
this.#socket = new WebSocket(
`${uri}?version=${this.#protocolVersion}&format=${
this.#transportFormat
}&token=${token}`
);
this.#socket.onopen = () => {
this.#heartbeatIntervalReference = setInterval(() => {
this.send({ type: "Ping", data: +new Date() });
this.#pongTimeoutReference = setTimeout(
() => this.disconnect(),
this.options.pongTimeout * 1e3
) as never;
}, this.options.heartbeatInterval * 1e3) as never;
};
this.#socket.onerror = (error) => {
this.emit("error", error as never);
};
this.#socket.onmessage = (event) => {
if (this.#transportFormat === "json") {
if (typeof event.data === "string") {
this.handle(JSON.parse(event.data));
}
}
};
let closed = false;
this.#socket.onclose = () => {
if (closed) return;
closed = true;
this.disconnect();
};
}
/**
* Disconnect the websocket client.
*/
disconnect() {
if (!this.#socket) return;
clearInterval(this.#heartbeatIntervalReference);
const socket = this.#socket;
this.#socket = undefined;
socket.close();
this.setState(ConnectionState.Disconnected);
}
/**
* Send an event to the server.
* @param event Event
*/
send(event: EventProtocol<T>["client"]) {
this.options.debug && console.debug("[C->S]", event);
if (!this.#socket) throw "Socket closed, trying to send.";
this.#socket.send(JSON.stringify(event));
}
/**
* Handle events intended for client before passing them along.
* @param event Event
*/
handle(event: EventProtocol<T>["server"]) {
this.options.debug && console.debug("[S->C]", event);
switch (event.type) {
case "Ping":
this.send({
type: "Pong",
data: event.data,
});
return;
case "Pong":
clearTimeout(this.#pongTimeoutReference);
this.#setPing(+new Date() - event.data);
this.options.debug && console.debug(`[ping] ${this.ping()}ms`);
return;
case "Error":
this.emit("error", event as never);
this.disconnect();
return;
}
switch (this.state()) {
case ConnectionState.Connecting:
if (event.type === "Authenticated") {
// no-op
} else if (event.type === "Ready") {
this.emit("event", event);
this.setState(ConnectionState.Connected);
} else {
throw `Unreachable code. Received ${event.type} in Connecting state.`;
}
break;
case ConnectionState.Connected:
if (event.type === "Authenticated" || event.type === "Ready") {
throw `Unreachable code. Received ${event.type} in Connected state.`;
} else {
this.emit("event", event);
}
break;
default:
throw `Unreachable code. Received ${
event.type
} in state ${this.state()}.`;
}
}
}
+22
View File
@@ -0,0 +1,22 @@
import type { ProtocolV1 } from "./v1";
export { handleEvent as handleEventV1 } from "./v1";
export * from "./EventClient";
/**
* Available protocols to connect with
*/
export type AvailableProtocols = 1;
/**
* Protocol mapping
*/
type Protocols = {
1: ProtocolV1;
};
/**
* Select a protocol by its key
*/
export type EventProtocol<T extends AvailableProtocols> = Protocols[T];
+779
View File
@@ -0,0 +1,779 @@
import { Setter } from "solid-js";
import { ReactiveSet } from "@solid-primitives/set";
import type {
Channel,
Embed,
Emoji,
FieldsChannel,
FieldsMember,
FieldsServer,
FieldsUser,
Member,
MemberCompositeKey,
Message,
RelationshipStatus,
Role,
Server,
User,
} from "revolt-api";
import { Client } from "..";
import { Merge } from "../lib/merge";
/**
* Version 1 of the events protocol
*/
export type ProtocolV1 = {
client: ClientMessage;
server: ServerMessage;
};
/**
* Messages sent to the server
*/
type ClientMessage =
| { type: "Authenticate"; token: string }
| {
type: "BeginTyping";
channel: string;
}
| {
type: "EndTyping";
channel: string;
}
| {
type: "Ping";
data: number;
}
| {
type: "Pong";
data: number;
};
/**
* Messages sent from the server
*/
type ServerMessage =
| ({ type: "Error" } & WebSocketError)
| { type: "Bulk"; v: ServerMessage[] }
| { type: "Authenticated" }
| ({ type: "Ready" } & ReadyData)
| { type: "Ping"; data: number }
| { type: "Pong"; data: number }
| ({ type: "Message" } & Message)
| {
type: "MessageUpdate";
id: string;
channel: string;
data: Partial<Message>;
}
| {
type: "MessageAppend";
id: string;
channel: string;
append: Pick<Partial<Message>, "embeds">;
}
| { type: "MessageDelete"; id: string; channel: string }
| {
type: "MessageReact";
id: string;
channel_id: string;
user_id: string;
emoji_id: string;
}
| {
type: "MessageUnreact";
id: string;
channel_id: string;
user_id: string;
emoji_id: string;
}
| {
type: "MessageRemoveReaction";
id: string;
channel_id: string;
emoji_id: string;
}
| { type: "BulkMessageDelete"; channel: string; ids: string[] }
| ({ type: "ChannelCreate" } & Channel)
| {
type: "ChannelUpdate";
id: string;
data: Partial<Channel>;
clear?: FieldsChannel[];
}
| { type: "ChannelDelete"; id: string }
| { type: "ChannelGroupJoin"; id: string; user: string }
| { type: "ChannelGroupLeave"; id: string; user: string }
| { type: "ChannelStartTyping"; id: string; user: string }
| { type: "ChannelStopTyping"; id: string; user: string }
| { type: "ChannelAck"; id: string; user: string; message_id: string }
| {
type: "ServerCreate";
id: string;
server: Server;
channels: Channel[];
}
| {
type: "ServerUpdate";
id: string;
data: Partial<Server>;
clear?: FieldsServer[];
}
| { type: "ServerDelete"; id: string }
| {
type: "ServerMemberUpdate";
id: MemberCompositeKey;
data: Partial<Member>;
clear?: FieldsMember[];
}
| { type: "ServerMemberJoin"; id: string; user: string }
| { type: "ServerMemberLeave"; id: string; user: string }
| {
type: "ServerRoleUpdate";
id: string;
role_id: string;
data: Partial<Role>;
}
| { type: "ServerRoleDelete"; id: string; role_id: string }
| {
type: "UserUpdate";
id: string;
data: Partial<User>;
clear?: FieldsUser[];
}
| { type: "UserRelationship"; user: User; status: RelationshipStatus }
| { type: "UserPresence"; id: string; online: boolean }
| {
type: "UserSettingsUpdate";
id: string;
update: { [key: string]: [number, string] };
}
| { type: "UserPlatformWipe"; user_id: string; flags: number }
| ({ type: "EmojiCreate" } & Emoji)
| { type: "EmojiDelete"; id: string }
| ({
type: "Auth";
} & (
| {
event_type: "DeleteSession";
user_id: string;
session_id: string;
}
| {
event_type: "DeleteAllSessions";
user_id: string;
exclude_session_id: string;
}
));
/**
* Initial synchronisation packet
*/
type ReadyData = {
users: User[];
servers: Server[];
channels: Channel[];
members: Member[];
emojis: Emoji[];
};
/**
* Websocket error packet
*/
type WebSocketError = {
error:
| "InternalError"
| "InvalidSession"
| "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>
) {
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);
}
if (client.options.syncUnreads) {
await client.channelUnreads.sync();
}
setReady(true);
client.emit("ready");
break;
}
case "Message": {
if (!client.messages.has(event._id)) {
// TODO: this should not be necessary in future protocols:
if (event.author && client.options.eagerFetching) {
await client.users.fetch(event.author);
const serverId = client.channels.get(event.channel)?.serverId;
if (serverId)
await client.serverMembers.fetch(serverId, event.author);
}
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 object),
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": {
// TODO: implement DeleteSession and DeleteAllSessions
break;
}
}
}
-1
View File
@@ -1 +0,0 @@
type Tail<T extends unknown[]> = T extends [infer _A, ...infer R] ? R : never;
+44
View File
@@ -0,0 +1,44 @@
import { Bot as ApiBot } from "revolt-api";
import { Hydrate } from ".";
export type HydratedBot = {
id: string;
ownerId: string;
token: string;
public: boolean;
analytics: boolean;
discoverable: boolean;
interactionsUrl?: string;
termsOfServiceUrl?: string;
privacyPolicyUrl?: string;
flags: BotFlags;
};
export const botHydration: Hydrate<ApiBot, HydratedBot> = {
keyMapping: {
_id: "id",
owner: "ownerId",
interactions_url: "interactionsUrl",
terms_of_service_url: "termsOfServiceUrl",
privacy_policy_url: "privacyPolicyUrl",
},
functions: {
id: (bot) => bot._id,
ownerId: (bot) => bot.owner,
token: (bot) => bot.token,
public: (bot) => bot.public,
analytics: (bot) => bot.analytics!,
discoverable: (bot) => bot.discoverable!,
interactionsUrl: (bot) => bot.interactions_url!,
termsOfServiceUrl: (bot) => bot.terms_of_service_url!,
privacyPolicyUrl: (bot) => bot.privacy_policy_url!,
flags: (bot) => bot.flags!,
},
initialHydration: () => ({}),
};
/**
* Flags attributed to users
*/
export enum BotFlags {}
+67
View File
@@ -0,0 +1,67 @@
import { ReactiveSet } from "@solid-primitives/set";
import { Channel as ApiChannel, OverrideField } from "revolt-api";
import { Client, File } from "..";
import type { Merge } from "../lib/merge";
import { Hydrate } from ".";
export type HydratedChannel = {
id: string;
channelType: ApiChannel["channel_type"];
name: string;
description?: string;
icon?: File;
active: boolean;
typingIds: ReactiveSet<string>;
recipientIds: ReactiveSet<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, ctx) => new File(ctx as Client, channel.icon!),
active: (channel) => channel.active || false,
typingIds: () => new ReactiveSet(),
recipientIds: (channel) => new ReactiveSet(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!,
},
initialHydration: () => ({
typingIds: new ReactiveSet(),
recipientIds: new ReactiveSet(),
}),
};
+31
View File
@@ -0,0 +1,31 @@
import { ReactiveSet } from "@solid-primitives/set";
import { API } from "..";
import type { Merge } from "../lib/merge";
import { Hydrate } from ".";
export type HydratedChannelUnread = {
id: string;
lastMessageId?: string;
messageMentionIds: ReactiveSet<string>;
};
export const channelUnreadHydration: Hydrate<
Merge<API.ChannelUnread>,
HydratedChannelUnread
> = {
keyMapping: {
_id: "id",
last_id: "lastMessageId",
mentions: "messageMentionIds",
},
functions: {
id: (unread) => unread._id.channel,
lastMessageId: (unread) => unread.last_id!,
messageMentionIds: (unread) => new ReactiveSet(unread.mentions!),
},
initialHydration: () => ({
messageMentionIds: new ReactiveSet(),
}),
};
+30
View File
@@ -0,0 +1,30 @@
import { Emoji as ApiEmoji, EmojiParent } from "revolt-api";
import type { 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,
},
initialHydration: () => ({}),
};
+111
View File
@@ -0,0 +1,111 @@
import { botHydration } from "./bot";
import { channelHydration } from "./channel";
import { channelUnreadHydration } from "./channelUnread";
import { emojiHydration } from "./emoji";
import { messageHydration } from "./message";
import { serverHydration } from "./server";
import { serverMemberHydration } from "./serverMember";
import { userHydration } from "./user";
export { BotFlags } from "./bot";
export { ServerFlags } from "./server";
export { UserBadges, UserFlags } from "./user";
export type { HydratedBot } from "./bot";
export type { HydratedChannel } from "./channel";
export type { HydratedChannelUnread } from "./channelUnread";
export type { HydratedEmoji } from "./emoji";
export type { HydratedMessage } from "./message";
export type { HydratedServer } from "./server";
export type { HydratedServerMember } from "./serverMember";
export type { HydratedUser } from "./user";
/**
* Functions to map from one object to another
*/
export type MappingFns<Input, Output, Key extends keyof Output> = Record<
Key,
(value: Input, context: unknown) => 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>;
initialHydration: () => Partial<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,
context: unknown
): 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, context);
} catch (err) {
if (key === "type") return acc;
console.debug(`Skipping key ${String(key)} during hydration!`);
return acc;
}
return {
...acc,
[targetKey]: value,
};
}, {} as Output);
}
const hydrators = {
bot: botHydration,
channel: channelHydration,
channelUnread: channelUnreadHydration,
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
* @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]>>,
context: unknown,
initial?: boolean
) {
return hydrateInternal(
hydrators[type] as never,
initial ? { ...hydrators[type].initialHydration(), ...input } : input,
context
) as ExtractOutput<Hydrators[T]>;
}
+66
View File
@@ -0,0 +1,66 @@
import { ReactiveMap } from "@solid-primitives/map";
import { ReactiveSet } from "@solid-primitives/set";
import { API, Client, File, MessageEmbed, SystemMessage } from "..";
import type { 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?: MessageEmbed[];
mentionIds?: string[];
replyIds?: string[];
reactions: ReactiveMap<string, ReactiveSet<string>>;
interactions?: API.Interactions;
masquerade?: API.Masquerade;
};
export const messageHydration: Hydrate<Merge<API.Message>, 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, ctx) =>
SystemMessage.from(ctx as Client, message.system!),
attachments: (message, ctx) =>
message.attachments!.map((file) => new File(ctx as Client, file)),
editedAt: (message) => new Date(message.edited!),
embeds: (message, ctx) =>
message.embeds!.map((embed) => MessageEmbed.from(ctx as Client, embed)),
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!,
},
initialHydration: () => ({
reactions: new ReactiveMap(),
}),
};
+77
View File
@@ -0,0 +1,77 @@
import { ReactiveMap } from "@solid-primitives/map";
import { ReactiveSet } from "@solid-primitives/set";
import {
Server as ApiServer,
Category,
Role,
SystemMessageChannels,
} from "revolt-api";
import { Client, File } from "..";
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, ctx) => new File(ctx as Client, server.icon!),
banner: (server, ctx) => new File(ctx as Client, server.banner!),
flags: (server) => server.flags!,
analytics: (server) => server.analytics || false,
discoverable: (server) => server.discoverable || false,
nsfw: (server) => server.nsfw || false,
},
initialHydration: () => ({
channelIds: new ReactiveSet(),
roles: new ReactiveMap(),
}),
};
/**
* Flags attributed to servers
*/
export enum ServerFlags {
Official = 1,
Verified = 2,
}
+36
View File
@@ -0,0 +1,36 @@
import { Member as ApiMember, MemberCompositeKey } from "revolt-api";
import { Client, File } from "..";
import type { 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, ctx) => new File(ctx as Client, member.avatar!),
roles: (member) => member.roles,
timeout: (member) => new Date(member.timeout!),
},
initialHydration: () => ({
roles: [],
}),
};
+76
View File
@@ -0,0 +1,76 @@
import {
User as ApiUser,
BotInformation,
RelationshipStatus,
UserStatus,
} from "revolt-api";
import { Client, File } from "..";
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!,
online: (user) => user.online!,
privileged: (user) => user.privileged,
badges: (user) => user.badges!,
flags: (user) => user.flags!,
avatar: (user, ctx) => new File(ctx as Client, user.avatar!),
status: (user) => user.status!,
bot: (user) => user.bot!,
},
initialHydration: () => ({
relationship: "None",
}),
};
/**
* 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,
}
+7 -29
View File
@@ -1,30 +1,8 @@
export { Channel } from "./maps/Channels";
export { Member } from "./maps/Members";
export { Message } from "./maps/Messages";
export { Server } from "./maps/Servers";
export { User } from "./maps/Users";
export { Emoji } from "./maps/Emojis";
export * from "./Client";
export * from "./config";
export {
UserPermission,
Permission,
DEFAULT_PERMISSION,
DEFAULT_PERMISSION_DIRECT_MESSAGE,
DEFAULT_PERMISSION_SAVED_MESSAGES,
DEFAULT_PERMISSION_SERVER,
DEFAULT_PERMISSION_VIEW_ONLY,
U32_MAX,
} from "./permissions/definitions";
export { calculatePermission } from "./permissions/calculator";
export { Nullable, toNullable, toNullableDate } from "./util/null";
export {
ReadyPacket,
ClientboundNotification,
ServerboundNotification,
} from "./websocket/notifications";
export * from "./classes";
export * from "./lib/regex";
export * from "./collections";
export * as API from "revolt-api";
export type { Session } from "./Client";
export { Client, ClientOptions } from "./Client";
export { EventClient, ConnectionState } from "./events";
export { ServerFlags, UserBadges, UserFlags, BotFlags } from "./hydration";
+22
View File
@@ -0,0 +1,22 @@
// Merge type provided by https://dev.to/lucianbc/union-type-merging-in-typescript-9al
export type Merge<T extends object> = {
[k in CommonKeys<T>]: PickTypeOf<T, k>;
} & {
[k in NonCommonKeys<T>]?: PickTypeOf<T, k>;
};
type PickTypeOf<T, K extends string | number | symbol> = K extends AllKeys<T>
? PickType<T, K>
: never;
// eslint-disable-next-line
type PickType<T, K extends AllKeys<T>> = T extends { [k in K]?: any }
? T[K]
: undefined;
type Subtract<A, C> = A extends C ? never : A;
type NonCommonKeys<T extends object> = Subtract<AllKeys<T>, CommonKeys<T>>;
// eslint-disable-next-line
type AllKeys<T> = T extends any ? keyof T : never;
type CommonKeys<T extends object> = keyof T;
+14
View File
@@ -0,0 +1,14 @@
/**
* Regular expression for mentions.
*/
export const RE_MENTIONS = /<@([0-9ABCDEFGHJKMNPQRSTVWXYZ]{26})>/g;
/**
* Regular expression for channels.
*/
export const RE_CHANNELS = /<#([0-9ABCDEFGHJKMNPQRSTVWXYZ]{26})>/g;
/**
* Regular expression for spoilers.
*/
export const RE_SPOILER = /!!.+!!/g;
-119
View File
@@ -1,119 +0,0 @@
import { runInAction } from "mobx";
import { Client } from "../Client";
import { InviteBotDestination, DataEditBot, DataCreateBot, OwnedBotsResponse } from 'revolt-api';
export default class Bots {
client: Client;
constructor(client: Client) {
this.client = client;
}
/**
* Fetch a bot
* @param id Bot ID
* @returns Bot and User object
*/
async fetch(id: string) {
const { bot, user } = await this.client.api.get(
`/bots/${id as ''}`,
);
return {
bot,
user: await this.client.users.fetch(user._id, user),
};
}
/**
* Delete a bot
* @param id Bot ID
*/
async delete(id: string) {
await this.client.api.delete(`/bots/${id as ''}`);
}
/**
* Fetch a public bot
* @param id Bot ID
* @returns Public Bot object
*/
async fetchPublic(id: string) {
return await this.client.api.get(
`/bots/${id as ''}/invite`,
);
}
/**
* Invite a public bot
* @param id Bot ID
* @param destination The group or server to add to
*/
async invite(
id: string,
destination: InviteBotDestination,
) {
return await this.client.api.post(
`/bots/${id as ''}/invite`,
destination,
);
}
/**
* Fetch a bot
* @param id Bot ID
* @returns Bot and User objects
*/
async fetchOwned() {
const { bots, users: userObjects } = await this.client.api.get(
`/bots/@me`,
) as OwnedBotsResponse;
const users = [];
for (const obj of userObjects) {
users.push(await this.client.users.fetch(obj._id, obj));
}
return { bots, users };
}
/**
* Edit a bot
* @param id Bot ID
* @param data Bot edit data object
*/
async edit(id: string, data: DataEditBot) {
await this.client.api.patch(`/bots/${id as ''}`, data);
if (data.name) {
const user = this.client.users.get(id);
if (user) {
runInAction(() => {
user!.username = data.name!;
});
}
}
}
/**
* Create a bot
* @param data Bot creation data
*/
async create(data: DataCreateBot) {
const bot = await this.client.api.post("/bots/create", data);
const user = await this.client.users.fetch(bot._id, {
_id: bot._id,
username: data.name,
bot: {
owner: this.client.user!._id,
},
});
return {
bot,
user,
};
}
}
-774
View File
@@ -1,774 +0,0 @@
import type {
Channel as ChannelI,
DataCreateGroup,
DataEditChannel,
DataMessageSend,
FieldsChannel,
Message as MessageI,
OptionsMessageSearch,
} from "revolt-api";
import type { File } from "revolt-api";
import type { Member } from "revolt-api";
import type { User } from "revolt-api";
import { action, computed, makeAutoObservable, runInAction } from "mobx";
import isEqual from "lodash.isequal";
import { decodeTime, ulid } from "ulid";
import { Nullable, toNullable } from "../util/null";
import Collection from "./Collection";
import { Message } from "./Messages";
import { Client, FileArgs } from "..";
import { Permission } from "../permissions/definitions";
import { INotificationChecker } from "../util/Unreads";
import { Override, OverrideField } from "revolt-api";
import type { APIRoutes } from "revolt-api/dist/routes";
import { bitwiseAndEq, calculatePermission } from "../permissions/calculator";
export class Channel {
client: Client;
_id: string;
channel_type: ChannelI["channel_type"];
/**
* Whether this DM is active.
* @requires `DirectMessage`
*/
active: Nullable<boolean> = null;
/**
* The ID of the group owner.
* @requires `Group`
*/
owner_id: Nullable<string> = null;
/**
* The ID of the server this channel is in.
* @requires `TextChannel`, `VoiceChannel`
*/
server_id: Nullable<string> = null;
/**
* Permissions for group members.
* @requires `Group`
*/
permissions: Nullable<number> = null;
/**
* Default server channel permissions.
* @requires `TextChannel`, `VoiceChannel`
*/
default_permissions: Nullable<OverrideField> = null;
/**
* Channel permissions for each role.
* @requires `TextChannel`, `VoiceChannel`
*/
role_permissions: Nullable<{ [key: string]: OverrideField }> = null;
/**
* Channel name.
* @requires `Group`, `TextChannel`, `VoiceChannel`
*/
name: Nullable<string> = null;
/**
* Channel icon.
* @requires `Group`, `TextChannel`, `VoiceChannel`
*/
icon: Nullable<File> = null;
/**
* Channel description.
* @requires `Group`, `TextChannel`, `VoiceChannel`
*/
description: Nullable<string> = null;
/**
* Group / DM members.
* @requires `Group`, `DM`
*/
recipient_ids: Nullable<string[]> = null;
/**
* Id of last message in channel.
* @requires `Group`, `DM`, `TextChannel`, `VoiceChannel`
*/
last_message_id: Nullable<string> = null;
/**
* Users typing in channel.
*/
typing_ids: Set<string> = new Set();
/**
* Channel is not safe for work.
* @requires `Group`, `TextChannel`, `VoiceChannel`
*/
nsfw: Nullable<boolean> = null;
/**
* The group owner.
* @requires `Group`
*/
get owner() {
if (this.owner_id === null) return;
return this.client.users.get(this.owner_id);
}
/**
* Server this channel belongs to.
* @requires `Server`
*/
get server() {
if (this.server_id === null) return;
return this.client.servers.get(this.server_id);
}
/**
* The DM recipient.
* @requires `DM`
*/
get recipient() {
const user_id = this.recipient_ids?.find(
(x) => this.client.user!._id !== x,
);
if (!user_id) return;
return this.client.users.get(user_id);
}
/**
* Last message sent in this channel.
* @requires `Group`, `DM`, `TextChannel`, `VoiceChannel`
*/
get last_message() {
const id = this.last_message_id;
if (!id) return;
return this.client.messages.get(id);
}
/**
* Get the last message ID if it is present or the origin timestamp.
* TODO: deprecate
*/
get last_message_id_or_past() {
return this.last_message_id ?? "0";
}
/**
* Group recipients.
* @requires `Group`
*/
get recipients() {
return this.recipient_ids?.map((id) => this.client.users.get(id));
}
/**
* Users typing.
*/
get typing() {
return Array.from(this.typing_ids).map((id) =>
this.client.users.get(id),
);
}
/**
* Get timestamp when this channel was created.
*/
get createdAt() {
return decodeTime(this._id);
}
/**
* Get timestamp when this channel last had a message sent or when it was created
*/
get updatedAt() {
return this.last_message_id
? decodeTime(this.last_message_id)
: this.createdAt;
}
/**
* Absolute pathname to this channel in the client.
*/
get path() {
if (this.server_id) {
return `/server/${this.server_id}/channel/${this._id}`;
} else {
return `/channel/${this._id}`;
}
}
/**
* Get URL to this channel.
*/
get url() {
return this.client.configuration?.app + this.path;
}
/**
* Check whether the channel is currently unread
* @param permit Callback function to determine whether a channel has certain properties
* @returns Whether the channel is unread
*/
@computed isUnread(permit: INotificationChecker) {
if (permit.isMuted(this)) return false;
return this.unread;
}
/**
* Find all message IDs of unread messages
* @param permit Callback function to determine whether a channel has certain properties
* @returns Array of message IDs which are unread
*/
@computed getMentions(permit: INotificationChecker) {
if (permit.isMuted(this)) return [];
return this.mentions;
}
/**
* Get whether this channel is unread.
*/
get unread() {
if (
!this.last_message_id ||
this.channel_type === "SavedMessages" ||
this.channel_type === "VoiceChannel"
)
return false;
return (
(
this.client.unreads?.getUnread(this._id)?.last_id ?? "0"
).localeCompare(this.last_message_id) === -1
);
}
/**
* Get mentions in this channel for user.
*/
get mentions() {
if (
this.channel_type === "SavedMessages" ||
this.channel_type === "VoiceChannel"
)
return [];
return this.client.unreads?.getUnread(this._id)?.mentions ?? [];
}
constructor(client: Client, data: ChannelI) {
this.client = client;
this._id = data._id;
this.channel_type = data.channel_type;
switch (data.channel_type) {
case "DirectMessage": {
this.active = toNullable(data.active);
this.recipient_ids = toNullable(data.recipients);
this.last_message_id = toNullable(data.last_message_id);
break;
}
case "Group": {
this.recipient_ids = toNullable(data.recipients);
this.name = toNullable(data.name);
this.owner_id = toNullable(data.owner);
this.description = toNullable(data.description);
this.last_message_id = toNullable(data.last_message_id);
this.icon = toNullable(data.icon);
this.permissions = toNullable(data.permissions);
this.nsfw = toNullable(data.nsfw);
break;
}
case "TextChannel":
case "VoiceChannel": {
this.server_id = toNullable(data.server);
this.name = toNullable(data.name);
this.description = toNullable(data.description);
this.icon = toNullable(data.icon);
this.default_permissions = toNullable(data.default_permissions);
this.role_permissions = toNullable(data.role_permissions);
if (data.channel_type === "TextChannel") {
this.last_message_id = toNullable(data.last_message_id);
this.nsfw = toNullable(data.nsfw);
}
break;
}
}
makeAutoObservable(this, {
_id: false,
client: false,
});
}
@action update(data: Partial<ChannelI>, clear: FieldsChannel[] = []) {
const apply = (key: string, target?: string) => {
if (
// @ts-expect-error TODO: clean up types here
typeof data[key] !== "undefined" &&
// @ts-expect-error TODO: clean up types here
!isEqual(this[target ?? key], data[key])
) {
// @ts-expect-error TODO: clean up types here
this[target ?? key] = data[key];
}
};
for (const entry of clear) {
switch (entry) {
case "Description":
this.description = null;
break;
case "Icon":
this.icon = null;
break;
}
}
apply("active");
apply("owner", "owner_id");
apply("permissions");
apply("default_permissions");
apply("role_permissions");
apply("name");
apply("icon");
apply("description");
apply("recipients", "recipient_ids");
apply("last_message_id");
apply("nsfw");
}
@action updateGroupJoin(user: string) {
this.recipient_ids?.push(user);
}
@action updateGroupLeave(user: string) {
this.recipient_ids = toNullable(
this.recipient_ids?.filter((x) => x !== user),
);
}
@action updateStartTyping(id: string) {
this.typing_ids.add(id);
}
@action updateStopTyping(id: string) {
this.typing_ids.delete(id);
}
/**
* Fetch a channel's members.
* @requires `Group`
* @returns An array of the channel's members.
*/
async fetchMembers() {
const members = await this.client.api.get(
`/channels/${this._id as ""}/members`,
);
return members.map(this.client.users.createObj);
}
/**
* Edit a channel
* @param data Edit data
*/
async edit(data: DataEditChannel) {
this.update(
await this.client.api.patch(`/channels/${this._id as ""}`, data),
);
}
/**
* Delete a channel
* @requires `DM`, `Group`, `TextChannel`, `VoiceChannel`
*/
async delete(leave_silently?: boolean, avoidReq?: boolean) {
if (!avoidReq)
await this.client.api.delete(`/channels/${this._id as ""}`, {
leave_silently,
});
runInAction(() => {
if (this.channel_type === "DirectMessage") {
this.active = false;
return;
}
if (
this.channel_type === "TextChannel" ||
this.channel_type === "VoiceChannel"
) {
const server = this.server;
if (server) {
server.channel_ids = server.channel_ids.filter(
(x) => x !== this._id,
);
}
}
this.client.channels.delete(this._id);
});
}
/**
* Add a user to a group
* @param user_id ID of the target user
*/
async addMember(user_id: string) {
return await this.client.api.put(
`/channels/${this._id as ""}/recipients/${user_id as ""}`,
);
}
/**
* Remove a user from a group
* @param user_id ID of the target user
*/
async removeMember(user_id: string) {
return await this.client.api.delete(
`/channels/${this._id as ""}/recipients/${user_id as ""}`,
);
}
/**
* Send a message
* @param data Either the message as a string or message sending route data
* @returns The message
*/
async sendMessage(
data: string | DataMessageSend,
idempotencyKey: string = ulid(),
) {
const msg: DataMessageSend =
typeof data === "string" ? { content: data } : data;
const message = await this.client.api.post(
`/channels/${this._id as ""}/messages`,
msg,
{
headers: {
"Idempotency-Key": idempotencyKey,
},
},
);
return this.client.messages.createObj(message, true);
}
/**
* Fetch a message by its ID
* @param message_id ID of the target message
* @returns The message
*/
async fetchMessage(message_id: string) {
const message = await this.client.api.get(
`/channels/${this._id as ""}/messages/${message_id as ""}`,
);
return this.client.messages.createObj(message);
}
/**
* Fetch multiple messages from a channel
* @param params Message fetching route data
* @returns The messages
*/
async fetchMessages(
params?: Omit<
(APIRoutes & {
method: "get";
path: "/channels/{target}/messages";
})["params"],
"include_users"
>,
) {
const messages = (await this.client.api.get(
`/channels/${this._id as ""}/messages`,
{ ...params },
)) as MessageI[];
return runInAction(() => messages.map(this.client.messages.createObj));
}
/**
* Fetch multiple messages from a channel including the users that sent them
* @param params Message fetching route data
* @returns Object including messages and users
*/
async fetchMessagesWithUsers(
params?: Omit<
(APIRoutes & {
method: "get";
path: "/channels/{target}/messages";
})["params"],
"include_users"
>,
) {
const data = (await this.client.api.get(
`/channels/${this._id as ""}/messages`,
{ ...params, include_users: true },
)) as { messages: MessageI[]; users: User[]; members?: Member[] };
return runInAction(() => {
return {
messages: data.messages.map(this.client.messages.createObj),
users: data.users.map(this.client.users.createObj),
members: data.members?.map(this.client.members.createObj),
};
});
}
/**
* Search for messages
* @param params Message searching route data
* @returns The messages
*/
async search(params: Omit<OptionsMessageSearch, "include_users">) {
const messages = (await this.client.api.post(
`/channels/${this._id as ""}/search`,
params,
)) as MessageI[];
return runInAction(() => messages.map(this.client.messages.createObj));
}
/**
* Search for messages including the users that sent them
* @param params Message searching route data
* @returns The messages
*/
async searchWithUsers(params: Omit<OptionsMessageSearch, "include_users">) {
const data = (await this.client.api.post(
`/channels/${this._id as ""}/search`,
{ ...params, include_users: true },
)) as { messages: MessageI[]; users: User[]; members?: Member[] };
return runInAction(() => {
return {
messages: data.messages.map(this.client.messages.createObj),
users: data.users.map(this.client.users.createObj),
members: data.members?.map(this.client.members.createObj),
};
});
}
/**
* Fetch stale messages
* @param ids IDs of the target messages
* @returns The stale messages
*/
async fetchStale(ids: string[]) {
/*const data = await this.client.api.post(
`/channels/${this._id as ''}/messages/stale`,
{ ids },
);
runInAction(() => {
data.deleted.forEach((id) => this.client.messages.delete(id));
data.updated.forEach((data) =>
this.client.messages.get(data._id)?.update(data),
);
});
return data;*/
return { deprecated: ids };
}
async deleteMessages(ids: string[]) {
await this.client.api.delete(
`/channels/${this._id as ""}/messages/bulk`,
{ data: { ids } },
);
}
/**
* Create an invite to the channel
* @returns Newly created invite code
*/
async createInvite() {
return await this.client.api.post(
`/channels/${this._id as ""}/invites`,
);
}
/**
* Join a call in a channel
* @returns Join call response data
*/
async joinCall() {
return await this.client.api.post(
`/channels/${this._id as ""}/join_call`,
);
}
private ackTimeout?: number;
private ackLimit?: number;
/**
* Mark a channel as read
* @param message Last read message or its ID
* @param skipRateLimiter Whether to skip the internal rate limiter
*/
async ack(message?: Message | string, skipRateLimiter?: boolean) {
const id =
(typeof message === "string" ? message : message?._id) ??
this.last_message_id ??
ulid();
const performAck = () => {
delete this.ackLimit;
this.client.api.put(`/channels/${this._id}/ack/${id as ""}`);
};
if (!this.client.options.ackRateLimiter || skipRateLimiter)
return performAck();
clearTimeout(this.ackTimeout);
if (this.ackLimit && +new Date() > this.ackLimit) {
performAck();
}
// We need to use setTimeout here for both Node.js and browser.
this.ackTimeout = setTimeout(performAck, 5000) as unknown as number;
if (!this.ackLimit) {
this.ackLimit = +new Date() + 15e3;
}
}
/**
* Set role permissions
* @param role_id Role Id, set to 'default' to affect all users
* @param permissions Permission value
*/
async setPermissions(role_id = "default", permissions: Override) {
return await this.client.api.put(
`/channels/${this._id as ""}/permissions/${role_id as ""}`,
{ permissions },
);
}
/**
* Start typing in this channel
*/
startTyping() {
this.client.websocket.send({ type: "BeginTyping", channel: this._id });
}
/**
* Stop typing in this channel
*/
stopTyping() {
this.client.websocket.send({ type: "EndTyping", channel: this._id });
}
/**
* Generate URL to icon for this channel
* @param args File parameters
* @returns File URL
*/
generateIconURL(...args: FileArgs) {
if (this.channel_type === "DirectMessage") {
return this.client.generateFileURL(
this.recipient?.avatar ?? undefined,
...args,
);
}
return this.client.generateFileURL(this.icon ?? undefined, ...args);
}
/**
* Permission the currently authenticated user has against this channel
*/
@computed get permission() {
return calculatePermission(this);
}
/**
* Check whether we have a given permission in a channel
* @param permission Permission Names
* @returns Whether we have this permission
*/
@computed havePermission(...permission: (keyof typeof Permission)[]) {
return bitwiseAndEq(
this.permission,
...permission.map((x) => Permission[x]),
);
}
}
export default class Channels extends Collection<string, Channel> {
constructor(client: Client) {
super(client);
this.createObj = this.createObj.bind(this);
}
@action $get(id: string, data?: ChannelI) {
const channel = this.get(id)!;
if (data) channel.update(data);
return channel;
}
/**
* Check whether a channel should currently exist
* @param id Channel ID
* @returns Whether it should current exist
*/
exists(id: string) {
const channel = this.get(id);
if (channel) {
switch (channel.channel_type) {
case "DirectMessage":
return channel.active;
default:
return true;
}
} else {
return false;
}
}
/**
* Fetch a channel
* @param id Channel ID
* @returns The channel
*/
async fetch(id: string, data?: ChannelI) {
if (this.has(id)) return this.$get(id);
const res =
data ?? (await this.client.api.get(`/channels/${id as ""}`));
return this.createObj(res);
}
/**
* Create a channel object.
* This is meant for internal use only.
* @param data: Channel Data
* @param emit Whether to emit creation event
* @returns Channel
*/
createObj(data: ChannelI, emit?: boolean | number) {
if (this.has(data._id)) return this.$get(data._id);
const channel = new Channel(this.client, data);
runInAction(() => {
this.set(data._id, channel);
});
if (emit === true) this.client.emit("channel/create", channel);
return channel;
}
/**
* Create a group
* @param data Group create route data
* @returns The newly-created group
*/
async createGroup(data: DataCreateGroup) {
const group = await this.client.api.post(`/channels/create`, data);
return (await this.fetch(group._id, group))!;
}
}
-12
View File
@@ -1,12 +0,0 @@
import { ObservableMap } from "mobx";
import { Client } from "../Client";
export default class Collection<K, V> extends ObservableMap<K, V> {
client: Client;
constructor(client: Client) {
super();
this.client = client;
}
}
-90
View File
@@ -1,90 +0,0 @@
import type { Emoji as EmojiI, EmojiParent } from "revolt-api";
import { makeAutoObservable, runInAction } from "mobx";
import Collection from "./Collection";
import { Client } from "..";
import { decodeTime } from "ulid";
export class Emoji {
client: Client;
_id: string;
name: string;
creator_id: string;
parent: EmojiParent;
animated: boolean;
nsfw: boolean;
/**
* Get timestamp when this message was created.
*/
get createdAt() {
return decodeTime(this._id);
}
/**
* Get creator of this emoji.
*/
get creator() {
return this.client.users.get(this.creator_id);
}
constructor(client: Client, data: EmojiI) {
this.client = client;
this._id = data._id;
this.name = data.name;
this.creator_id = data.creator_id;
this.parent = data.parent;
this.animated = data.animated ?? false;
this.nsfw = data.nsfw ?? false;
makeAutoObservable(this, {
_id: false,
client: false,
});
}
/**
* Delete a message
*/
async delete() {
return await this.client.api.delete(`/custom/emoji/${this._id as ""}`);
}
/**
* Generate emoji URL
*/
get imageURL() {
return `${this.client.configuration?.features.autumn.url}/emojis/${
this._id
}${this.animated ? "" : "?max_side=128"}`;
}
}
export default class Emojis extends Collection<string, Emoji> {
constructor(client: Client) {
super(client);
this.createObj = this.createObj.bind(this);
}
/**
* Create an emoji object.
* This is meant for internal use only.
* @param data Emoji Data
* @param emit Whether to emit creation event
* @returns Emoji
*/
createObj(data: EmojiI, emit?: boolean | number) {
if (this.has(data._id)) return this.get(data._id)!;
const emoji = new Emoji(this.client, data);
runInAction(() => {
this.set(data._id, emoji);
});
if (emit === true) this.client.emit("emoji/create", emoji);
return emoji;
}
}
-342
View File
@@ -1,342 +0,0 @@
import type {
DataMemberEdit,
FieldsMember,
Member as MemberI,
MemberCompositeKey,
Role,
} from "revolt-api";
import type { File } from "revolt-api";
import { makeAutoObservable, runInAction, action, computed } from "mobx";
import isEqual from "lodash.isequal";
import { Nullable, toNullable, toNullableDate } from "../util/null";
import Collection from "./Collection";
import { Channel, Client, FileArgs, Server } from "..";
import { bitwiseAndEq, calculatePermission } from "../permissions/calculator";
import { Permission } from "../permissions/definitions";
export class Member {
client: Client;
_id: MemberCompositeKey;
joined_at: Date;
nickname: Nullable<string> = null;
avatar: Nullable<File> = null;
roles: Nullable<string[]> = null;
timeout: Nullable<Date> = null;
private _timeout: number | undefined;
/**
* Associated user.
*/
get user() {
return this.client.users.get(this._id.user);
}
/**
* Associated server.
*/
get server() {
return this.client.servers.get(this._id.server);
}
/**
* Whether the client has a higher rank than this member.
*/
get inferior() {
return (this.server?.member?.ranking ?? Infinity) < this.ranking;
}
/**
* Whether the client can kick this user.
*/
get kickable() {
return this.server?.havePermission("KickMembers") && this.inferior;
}
/**
* Whether the client can ban this user.
*/
get bannable() {
return this.server?.havePermission("BanMembers") && this.inferior;
}
constructor(client: Client, data: MemberI) {
this.client = client;
this._id = data._id;
this.joined_at = new Date(data.joined_at);
this.nickname = toNullable(data.nickname);
this.avatar = toNullable(data.avatar);
this.roles = toNullable(data.roles);
this.timeout = toNullableDate(data.timeout);
this.scheduleTimeout();
makeAutoObservable(this, {
_id: false,
client: false,
});
}
@action update(data: Partial<MemberI>, clear: FieldsMember[] = []) {
const apply = (key: string) => {
// This code has been tested.
if (
// @ts-expect-error TODO: clean up types here
typeof data[key] !== "undefined" &&
// @ts-expect-error TODO: clean up types here
!isEqual(this[key], data[key])
) {
// @ts-expect-error TODO: clean up types here
this[key] =
// @ts-expect-error TODO: clean up types here
key === "timeout" ? toNullableDate(data[key]) : data[key];
}
};
for (const field of clear) {
switch (field) {
case "Nickname":
this.nickname = null;
break;
case "Avatar":
this.avatar = null;
break;
case "Roles":
this.roles = [];
break;
case "Timeout":
this.timeout = null;
break;
}
}
apply("nickname");
apply("avatar");
apply("roles");
apply("timeout");
this.scheduleTimeout();
}
/**
* Schedule timeout revocation
*/
private scheduleTimeout() {
delete this._timeout;
clearTimeout(this._timeout);
if (this.timeout) {
const offset = +this.timeout - +new Date();
if (offset > 0) {
this._timeout = setTimeout(() => {
runInAction(() => {
this.timeout = null;
delete this._timeout;
});
}, offset) as unknown as number;
} else {
runInAction(() => {
this.timeout = null;
});
}
}
}
/**
* Edit a server member
* @param data Member editing route data
* @returns Server member object
*/
async edit(data: DataMemberEdit) {
return await this.client.api.patch(
`/servers/${this._id.server as ""}/members/${this._id.user as ""}`,
data,
);
}
/**
* Kick server member
* @param user_id User ID
*/
async kick() {
return await this.client.api.delete(
`/servers/${this._id.server as ""}/members/${this._id.user as ""}`,
);
}
/**
* Get an ordered list of roles for this member, from lowest to highest priority.
*/
@computed get orderedRoles() {
const member_roles = new Set(this.roles);
const server = this.server!;
return Object.keys(server.roles ?? {})
.filter((x) => member_roles.has(x))
.map(
(role_id) =>
[role_id, server.roles![role_id]] as [string, Role],
)
.sort(([, a], [, b]) => b.rank! - a.rank!);
}
/**
* Get this member's currently hoisted role.
*/
@computed get hoistedRole() {
const roles = this.orderedRoles.filter((x) => x[1].hoist);
if (roles.length > 0) {
const [id, role] = roles[roles.length - 1];
return {
id,
...role
}
} else {
return null;
}
}
/**
* Get this member's current role colour.
*/
@computed get roleColour() {
const roles = this.orderedRoles.filter((x) => x[1].colour);
if (roles.length > 0) {
return roles[roles.length - 1][1].colour;
} else {
return null;
}
}
/**
* Get this member's ranking.
* Smaller values are ranked as higher priotity.
*/
@computed get ranking() {
if (this._id.user === this.server?.owner) {
return -Infinity;
}
const roles = this.orderedRoles;
if (roles.length > 0) {
return roles[roles.length - 1][1].rank!;
} else {
return Infinity;
}
}
/**
* Get a pre-configured avatar URL of a member
*/
get avatarURL() {
return this.generateAvatarURL({ max_side: 256 }) ?? this.user?.avatarURL;
}
/**
* Get a pre-configured animated avatar URL of a member
*/
get animatedAvatarURL() {
return this.generateAvatarURL({ max_side: 256 }, true) ?? this.user?.animatedAvatarURL;
}
/**
* Generate URL to this member's avatar
* @param args File parameters
* @returns File URL
*/
@computed generateAvatarURL(...args: FileArgs) {
return this.client.generateFileURL(this.avatar ?? undefined, ...args);
}
/**
* Get the permissions that this member has against a certain object
* @param target Target object to check permissions against
* @returns Permissions that this member has
*/
@computed getPermissions(target: Server | Channel) {
return calculatePermission(target, { member: this });
}
/**
* Check whether a member has a certain permission against a certain object
* @param target Target object to check permissions against
* @param permission Permission names to check for
* @returns Whether the member has this permission
*/
@computed hasPermission(
target: Server | Channel,
...permission: (keyof typeof Permission)[]
) {
return bitwiseAndEq(
this.getPermissions(target),
...permission.map((x) => Permission[x]),
);
}
/**
* Checks whether the target member has a higher rank than this member.
* @param target The member to compare against
* @returns Whether this member is inferior to the target
*/
@computed inferiorTo(target: Member) {
return target.ranking < this.ranking;
}
}
export default class Members extends Collection<string, Member> {
constructor(client: Client) {
super(client);
this.createObj = this.createObj.bind(this);
}
static toKey(id: MemberCompositeKey) {
return JSON.stringify(id, Object.keys(id).sort());
}
hasKey(id: MemberCompositeKey) {
return super.has(Members.toKey(id));
}
getKey(id: MemberCompositeKey) {
return super.get(Members.toKey(id));
}
setKey(id: MemberCompositeKey, member: Member) {
return super.set(Members.toKey(id), member);
}
deleteKey(id: MemberCompositeKey) {
return super.delete(Members.toKey(id));
}
@action $get(id: MemberCompositeKey, data?: MemberI) {
const member = this.getKey(id)!;
if (data) member.update(data);
return member;
}
/**
* Create a member object.
* This is meant for internal use only.
* @param data: Member Data
* @param emit Whether to emit creation event
* @returns Member
*/
createObj(data: MemberI, emit?: boolean | number) {
if (this.hasKey(data._id)) return this.$get(data._id, data);
const member = new Member(this.client, data);
runInAction(() => {
this.setKey(data._id, member);
});
if (emit === true) this.client.emit("member/join", member);
return member;
}
}
-341
View File
@@ -1,341 +0,0 @@
import type {
DataEditMessage,
DataMessageSend,
Embed,
Interactions,
Masquerade,
Message as MessageI,
SystemMessage,
} from "revolt-api";
import type { File } from "revolt-api";
import {
makeAutoObservable,
runInAction,
action,
computed,
ObservableMap,
ObservableSet,
} from "mobx";
import isEqual from "lodash.isequal";
import { Nullable, toNullable, toNullableDate } from "../util/null";
import Collection from "./Collection";
import { Client } from "..";
import { decodeTime } from "ulid";
export class Message {
client: Client;
_id: string;
nonce?: string;
channel_id: string;
author_id: string;
content: Nullable<string>;
system: Nullable<SystemMessage>;
attachments: Nullable<File[]>;
edited: Nullable<Date>;
embeds: Nullable<Embed[]>;
mention_ids: Nullable<string[]>;
reply_ids: Nullable<string[]>;
masquerade: Nullable<Masquerade>;
reactions: ObservableMap<string, ObservableSet<string>>;
interactions: Nullable<Interactions>;
get channel() {
return this.client.channels.get(this.channel_id);
}
get author() {
return this.client.users.get(this.author_id);
}
get member() {
const channel = this.channel;
if (channel?.channel_type === "TextChannel") {
return this.client.members.getKey({
server: channel.server_id!,
user: this.author_id,
});
}
}
get mentions() {
return this.mention_ids?.map((id) => this.client.users.get(id));
}
/**
* Get timestamp when this message was created.
*/
get createdAt() {
return decodeTime(this._id);
}
/**
* Absolute pathname to this message in the client.
*/
get path() {
return this.channel?.path + "/" + this._id;
}
/**
* Get URL to this message.
*/
get url() {
return this.client.configuration?.app + this.path;
}
/**
* Get the username for this message.
*/
get username() {
return this.masquerade?.name ?? this.member?.nickname ?? this.author?.username;
}
/**
* Get the role colour for this message.
*/
get roleColour() {
return this.masquerade?.colour ?? this.member?.roleColour;
}
/**
* Get the avatar URL for this message.
*/
get avatarURL() {
return this.generateMasqAvatarURL()
?? (this.member
? this.member?.avatarURL
: this.author?.avatarURL);
}
/**
* Get the animated avatar URL for this message.
*/
get animatedAvatarURL() {
return this.generateMasqAvatarURL()
?? (this.member
? this.member?.animatedAvatarURL
: this.author?.animatedAvatarURL);
}
@computed generateMasqAvatarURL() {
const avatar = this.masquerade?.avatar;
return avatar ? this.client.proxyFile(avatar) : undefined;
}
@computed
get asSystemMessage() {
const system = this.system;
if (!system) return { type: "none" };
const { type } = system;
const get = (id: string) => this.client.users.get(id);
switch (system.type) {
case "text":
return system;
case "user_added":
return { type, user: get(system.id), by: get(system.by) };
case "user_remove":
return { type, user: get(system.id), by: get(system.by) };
case "user_joined":
return { type, user: get(system.id) };
case "user_left":
return { type, user: get(system.id) };
case "user_kicked":
return { type, user: get(system.id) };
case "user_banned":
return { type, user: get(system.id) };
case "channel_renamed":
return { type, name: system.name, by: get(system.by) };
case "channel_description_changed":
return { type, by: get(system.by) };
case "channel_icon_changed":
return { type, by: get(system.by) };
case "channel_ownership_changed":
return { type, from: get(system.from), to: get(system.to) };
}
}
constructor(client: Client, data: MessageI) {
this.client = client;
this._id = data._id;
this.nonce = data.nonce ?? undefined;
this.channel_id = data.channel;
this.author_id = data.author;
this.content = toNullable(data.content);
this.system = toNullable(data.system);
this.attachments = toNullable(data.attachments);
this.edited = toNullableDate(data.edited);
this.embeds = toNullable(data.embeds);
this.mention_ids = toNullable(data.mentions);
this.reply_ids = toNullable(data.replies);
this.masquerade = toNullable(data.masquerade);
this.interactions = toNullable(data.interactions);
this.reactions = new ObservableMap();
for (const reaction of Object.keys(data.reactions ?? {})) {
this.reactions.set(
reaction,
new ObservableSet(data.reactions![reaction]),
);
}
makeAutoObservable(this, {
_id: false,
client: false,
});
}
@action update(data: Partial<MessageI>) {
const apply = (
key: string,
target?: string,
transform?: (obj: unknown) => unknown,
) => {
// This code has been tested.
if (
// @ts-expect-error TODO: clean up types here
typeof data[key] !== "undefined" &&
// @ts-expect-error TODO: clean up types here
!isEqual(this[target ?? key], data[key])
) {
// @ts-expect-error TODO: clean up types here
this[target ?? key] = transform
? // @ts-expect-error TODO: clean up types here
transform(data[key])
: // @ts-expect-error TODO: clean up types here
data[key];
}
};
apply("content");
apply("attachments");
apply("edited", undefined, toNullableDate as (obj: unknown) => unknown);
apply("embeds");
apply("mentions", "mention_ids");
apply("masquerade");
apply("reactions");
apply("interactions");
}
@action append({ embeds }: Pick<Partial<MessageI>, "embeds">) {
if (embeds) {
this.embeds = [...(this.embeds ?? []), ...embeds];
}
}
/**
* Edit a message
* @param data Message edit route data
*/
async edit(data: DataEditMessage) {
return await this.client.api.patch(
`/channels/${this.channel_id as ""}/messages/${this._id as ""}`,
data,
);
}
/**
* Delete a message
*/
async delete() {
return await this.client.api.delete(
`/channels/${this.channel_id 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.client.api.delete(
`/channels/${this.channel_id as ""}/messages/${
this._id as ""
}/reactions`,
);
}
/**
* React to a message
* @param emoji Unicode or emoji ID
*/
async react(emoji: string) {
return await this.client.api.put(
`/channels/${this.channel_id 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.client.api.delete(
`/channels/${this.channel_id as ""}/messages/${
this._id as ""
}/reactions/${emoji as ""}`,
);
}
}
export default class Messages extends Collection<string, Message> {
constructor(client: Client) {
super(client);
this.createObj = this.createObj.bind(this);
}
@action $get(id: string, data?: MessageI) {
const msg = this.get(id)!;
if (data) msg.update(data);
return msg;
}
/**
* Create a message object.
* This is meant for internal use only.
* @param data Message Data
* @param emit Whether to emit creation event
* @returns Message
*/
createObj(data: MessageI, emit?: boolean | number) {
if (this.has(data._id)) return this.$get(data._id);
const message = new Message(this.client, data);
runInAction(() => {
this.set(data._id, message);
});
if (emit === true) this.client.emit("message", message);
return message;
}
}
-563
View File
@@ -1,563 +0,0 @@
import type {
Category,
Channel as ChannelI,
DataBanCreate,
DataCreateChannel,
DataCreateServer,
DataEditRole,
DataEditServer,
FieldsServer,
Role,
Server as ServerI,
SystemMessageChannels,
} from "revolt-api";
import type { File } from "revolt-api";
import { makeAutoObservable, action, runInAction, computed } from "mobx";
import isEqual from "lodash.isequal";
import { Nullable, toNullable } from "../util/null";
import { Permission } from "../permissions/definitions";
import Collection from "./Collection";
import { User } from "./Users";
import { Channel, Client, FileArgs } from "..";
import { decodeTime } from "ulid";
import { INotificationChecker } from "../util/Unreads";
import { Override } from "revolt-api";
import { bitwiseAndEq, calculatePermission } from "../permissions/calculator";
export class Server {
client: Client;
_id: string;
owner: string;
name: string;
description: Nullable<string> = null;
channel_ids: string[] = [];
categories: Nullable<Category[]> = null;
system_messages: Nullable<SystemMessageChannels> = null;
roles: Nullable<{ [key: string]: Role }> = null;
default_permissions: number;
icon: Nullable<File> = null;
banner: Nullable<File> = null;
nsfw: Nullable<boolean> = null;
flags: Nullable<number> = null;
get channels() {
return this.channel_ids
.map((x) => this.client.channels.get(x))
.filter((x) => x);
}
/**
* Get timestamp when this server was created.
*/
get createdAt() {
return decodeTime(this._id);
}
/**
* Absolute pathname to this server in the client.
*/
get path() {
return `/server/${this._id}`;
}
/**
* Get URL to this server.
*/
get url() {
return this.client.configuration?.app + this.path;
}
/**
* Get an array of ordered categories with their respective channels.
* Uncategorised channels are returned in `id="default"` category.
*/
@computed get orderedChannels(): (Omit<Category, "channels"> & {
channels: Channel[];
})[] {
const uncategorised = new Set(
this.channel_ids.filter((key) => this.client.channels.has(key)),
);
const elements = [];
let defaultCategory;
if (this.categories) {
for (const category of this.categories) {
const channels = [];
for (const key of category.channels) {
if (uncategorised.delete(key)) {
channels.push(this.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) => this.client.channels.get(key)!,
);
if (defaultCategory) {
defaultCategory.channels = [
...defaultCategory.channels,
...channels,
];
} else {
elements.unshift({
id: "default",
title: "Default",
channels,
});
}
}
return elements;
}
/**
* Get the default channel for this server
*/
@computed 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.
*/
@computed get orderedRoles() {
return Object.keys(this.roles ?? {})
.map((id) => {
return {
id,
...this.roles![id],
};
})
.sort((a, b) => (a.rank || 0) - (b.rank || 0));
}
/**
* Check whether the server is currently unread
* @param permit Callback function to determine whether a server has certain properties
* @returns Whether the server is unread
*/
@computed isUnread(permit?: INotificationChecker) {
if (permit?.isMuted(this)) return false;
return this.channels.find(
(channel) => !permit?.isMuted(channel) && channel?.unread,
);
}
/**
* Find all message IDs of unread messages
* @param permit Callback function to determine whether a server has certain properties
* @returns Array of message IDs which are unread
*/
@computed getMentions(permit?: INotificationChecker) {
if (permit?.isMuted(this)) return [];
const arr = this.channels
.filter((channel) => !permit?.isMuted(channel))
.map((channel) => channel?.mentions) as string[][];
return ([] as string[]).concat(...arr);
}
constructor(client: Client, data: ServerI) {
this.client = client;
this._id = data._id;
this.owner = data.owner;
this.name = data.name;
this.description = toNullable(data.description);
this.channel_ids = data.channels;
this.categories = toNullable(data.categories);
this.system_messages = toNullable(data.system_messages);
this.roles = toNullable(data.roles);
this.default_permissions = data.default_permissions;
this.icon = toNullable(data.icon);
this.banner = toNullable(data.banner);
this.nsfw = toNullable(data.nsfw);
this.flags = toNullable(data.flags);
makeAutoObservable(this, {
_id: false,
client: false,
});
}
@action update(data: Partial<ServerI>, clear: FieldsServer[] = []) {
const apply = (key: string, target?: string) => {
// This code has been tested.
if (
// @ts-expect-error TODO: clean up types here
typeof data[key] !== "undefined" &&
// @ts-expect-error TODO: clean up types here
!isEqual(this[target ?? key], data[key])
) {
// @ts-expect-error TODO: clean up types here
this[target ?? key] = data[key];
}
};
for (const entry of clear) {
switch (entry) {
case "Banner":
this.banner = null;
break;
case "Description":
this.description = null;
break;
case "Icon":
this.icon = null;
break;
}
}
apply("owner");
apply("name");
apply("description");
apply("channels", "channel_ids");
apply("categories");
apply("system_messages");
apply("roles");
apply("default_permissions");
apply("icon");
apply("banner");
apply("nsfw");
apply("flags");
}
/**
* Create a channel
* @param data Channel create route data
* @returns The newly-created channel
*/
async createChannel(data: DataCreateChannel) {
return await this.client.api.post(
`/servers/${this._id as ""}/channels`,
data,
);
}
/**
* Edit a server
* @param data Server editing route data
*/
async edit(data: DataEditServer) {
return await this.client.api.patch(`/servers/${this._id as ""}`, data);
}
/**
* Delete a guild
*/
async delete(leave_silently?: boolean, avoidReq?: boolean) {
if (!avoidReq)
await this.client.api.delete(`/servers/${this._id as ""}`, {
leave_silently,
});
runInAction(() => {
this.client.servers.delete(this._id);
});
}
/**
* Mark a server as read
*/
async ack() {
return await this.client.api.put(`/servers/${this._id}/ack`);
}
/**
* Ban user
* @param user_id User ID
*/
async banUser(user_id: string, data: DataBanCreate) {
return await this.client.api.put(
`/servers/${this._id as ""}/bans/${user_id}`,
data,
);
}
/**
* Unban user
* @param user_id User ID
*/
async unbanUser(user_id: string) {
return await this.client.api.delete(
`/servers/${this._id as ""}/bans/${user_id}`,
);
}
/**
* Fetch a server's invites
* @returns An array of the server's invites
*/
async fetchInvites() {
return await this.client.api.get(`/servers/${this._id as ""}/invites`);
}
/**
* Fetch a server's bans
* @returns An array of the server's bans.
*/
async fetchBans() {
return await this.client.api.get(`/servers/${this._id as ""}/bans`);
}
/**
* Set role permissions
* @param role_id Role Id, set to 'default' to affect all users
* @param permissions Permission value
*/
async setPermissions(role_id = "default", permissions: Override | number) {
return await this.client.api.put(
`/servers/${this._id as ""}/permissions/${role_id as ""}`,
{ permissions: permissions as Override },
);
}
/**
* Create role
* @param name Role name
*/
async createRole(name: string) {
return await this.client.api.post(`/servers/${this._id as ""}/roles`, {
name,
});
}
/**
* Edit a role
* @param role_id Role ID
* @param data Role editing route data
*/
async editRole(role_id: string, data: DataEditRole) {
return await this.client.api.patch(
`/servers/${this._id as ""}/roles/${role_id as ""}`,
data,
);
}
/**
* Delete role
* @param role_id Role ID
*/
async deleteRole(role_id: string) {
return await this.client.api.delete(
`/servers/${this._id as ""}/roles/${role_id as ""}`,
);
}
/**
* Fetch a server member
* @param user User or User ID
* @returns Server member object
*/
async fetchMember(user: User | string) {
const user_id = typeof user === "string" ? user : user._id;
const existing = this.client.members.getKey({
server: this._id,
user: user_id,
});
if (existing) return existing;
const member = await this.client.api.get(
`/servers/${this._id as ""}/members/${user_id as ""}`,
);
return this.client.members.createObj(member);
}
/**
* Optimised member fetch route.
* @param exclude_offline
*/
async syncMembers(exclude_offline?: boolean) {
const data = await this.client.api.get(
`/servers/${this._id as ""}/members`,
{ exclude_offline },
);
runInAction(() => {
if (exclude_offline) {
for (let i = 0; i < data.users.length; i++) {
const user = data.users[i];
if (user.online) {
this.client.users.createObj(user);
this.client.members.createObj(data.members[i]);
}
}
} else {
for (let i = 0; i < data.users.length; i++) {
this.client.users.createObj(data.users[i]);
this.client.members.createObj(data.members[i]);
}
}
});
}
/**
* Fetch a server's members.
* @returns An array of the server's members and their user objects.
*/
async fetchMembers() {
const data = await this.client.api.get(
`/servers/${this._id as ""}/members`,
);
// Note: this takes 986 ms (Testers server)
return runInAction(() => {
return {
members: data.members.map(this.client.members.createObj),
users: data.users.map(this.client.users.createObj),
};
});
}
/**
* Generate URL to icon for this server
* @param args File parameters
* @returns File URL
*/
@computed generateIconURL(...args: FileArgs) {
return this.client.generateFileURL(this.icon ?? undefined, ...args);
}
/**
* Generate URL to banner for this server
* @param args File parameters
* @returns File URL
*/
@computed generateBannerURL(...args: FileArgs) {
return this.client.generateFileURL(this.banner ?? undefined, ...args);
}
/**
* Get our own member object for this server
*/
@computed get member() {
return this.client.members.getKey({
server: this._id,
user: this.client.user!._id,
});
}
/**
* Permission the currently authenticated user has against this server
*/
@computed get permission() {
return calculatePermission(this);
}
/**
* Check whether we have a given permission in a server
* @param permission Permission Names
* @returns Whether we have this permission
*/
@computed havePermission(...permission: (keyof typeof Permission)[]) {
return bitwiseAndEq(
this.permission,
...permission.map((x) => Permission[x]),
);
}
}
export default class Servers extends Collection<string, Server> {
constructor(client: Client) {
super(client);
this.createObj = this.createObj.bind(this);
}
@action $get(id: string, data?: ServerI) {
const server = this.get(id)!;
if (data) server.update(data);
return server;
}
/**
* Fetch a server
* @param id Server ID
* @returns The server
*/
async fetch(id: string, data?: ServerI, channels?: ChannelI[]) {
if (this.has(id)) return this.$get(id, data);
const res = data ?? (await this.client.api.get(`/servers/${id as ""}`));
return runInAction(async () => {
if (channels) {
for (const channel of channels) {
await this.client.channels.fetch(channel._id, channel);
}
} else {
for (const channel of res.channels) {
// ! FIXME: add route for fetching all channels
// ! FIXME: OR the WHOLE server
try {
await this.client.channels.fetch(channel);
// future proofing for when not
} catch (err) {}
}
}
return this.createObj(res);
});
}
/**
* Create a server object.
* This is meant for internal use only.
* @param data: Server Data
* @returns Server
*/
createObj(data: ServerI) {
if (this.has(data._id)) return this.$get(data._id, data);
const server = new Server(this.client, data);
runInAction(() => {
this.set(data._id, server);
});
return server;
}
/**
* Create a server
* @param data Server create route data
* @returns The newly-created server
*/
async createServer(data: DataCreateServer) {
const { server, channels } = await this.client.api.post(
`/servers/create`,
data,
);
return await this.fetch(server._id, server, channels);
}
}
-307
View File
@@ -1,307 +0,0 @@
import type {
BotInformation,
UserStatus,
User as UserI,
RelationshipStatus,
FieldsUser,
DataEditUser,
} from "revolt-api";
import type { File } from "revolt-api";
import { makeAutoObservable, action, runInAction, computed } from "mobx";
import isEqual from "lodash.isequal";
import { U32_MAX, UserPermission } from "../permissions/definitions";
import { toNullable, Nullable } from "../util/null";
import Collection from "./Collection";
import { Client, FileArgs } from "..";
import _ from "lodash";
import { decodeTime } from "ulid";
export class User {
client: Client;
_id: string;
username: string;
avatar: Nullable<File>;
badges: Nullable<number>;
status: Nullable<UserStatus>;
relationship: Nullable<RelationshipStatus>;
online: boolean;
privileged: boolean;
flags: Nullable<number>;
bot: Nullable<BotInformation>;
/**
* Get timestamp when this user was created.
*/
get createdAt() {
return decodeTime(this._id);
}
constructor(client: Client, data: UserI) {
this.client = client;
this._id = data._id;
this.username = data.username;
this.avatar = toNullable(data.avatar);
this.badges = toNullable(data.badges);
this.status = toNullable(data.status);
this.relationship = toNullable(data.relationship);
this.online = data.online ?? false;
this.privileged = data.privileged ?? false;
this.flags = toNullable(data.flags);
this.bot = toNullable(data.bot);
makeAutoObservable(this, {
_id: false,
client: false,
});
}
@action update(data: Partial<UserI>, clear: FieldsUser[] = []) {
const apply = (key: string) => {
// This code has been tested.
if (
// @ts-expect-error TODO: clean up types here
typeof data[key] !== "undefined" &&
// @ts-expect-error TODO: clean up types here
!isEqual(this[key], data[key])
) {
// @ts-expect-error TODO: clean up types here
this[key] = data[key];
if (key === "relationship") {
this.client.emit("user/relationship", this);
}
}
};
for (const entry of clear) {
switch (entry) {
case "Avatar":
this.avatar = null;
break;
case "StatusText": {
if (this.status) {
this.status.text = undefined;
}
}
}
}
apply("username");
apply("avatar");
apply("badges");
apply("status");
apply("relationship");
apply("online");
apply("privileged");
apply("flags");
apply("bot");
}
/**
* Open a DM with a user
* @returns DM Channel
*/
async openDM() {
let dm = [...this.client.channels.values()].find(
(x) => x.channel_type === "DirectMessage" && x.recipient == this,
);
if (!dm) {
const data = await this.client.api.get(
`/users/${this._id as ""}/dm`,
);
dm = await this.client.channels.fetch(data._id, data)!;
}
runInAction(() => {
dm!.active = true;
});
return dm;
}
/**
* Send a friend request to a user
*/
async addFriend() {
return await this.client.api.post(`/users/friend`, {
username: this.username,
});
}
/**
* Remove a user from the friend list
*/
async removeFriend() {
return await this.client.api.delete(`/users/${this._id as ""}/friend`);
}
/**
* Block a user
*/
async blockUser() {
return await this.client.api.put(`/users/${this._id as ""}/block`);
}
/**
* Unblock a user
*/
async unblockUser() {
return await this.client.api.delete(`/users/${this._id as ""}/block`);
}
/**
* Fetch the profile of a user
* @returns The profile of the user
*/
async fetchProfile() {
return await this.client.api.get(`/users/${this._id as ""}/profile`);
}
/**
* Fetch the mutual connections of the current user and a target user
* @returns The mutual connections of the current user and a target user
*/
async fetchMutual() {
return await this.client.api.get(`/users/${this._id as ""}/mutual`);
}
/**
* Get the default avatar URL of a user
*/
get defaultAvatarURL() {
return `${this.client.apiURL}/users/${this._id}/default_avatar`;
}
/**
* Get a pre-configured avatar URL of a user
*/
get avatarURL() {
return this.generateAvatarURL({ max_side: 256 });
}
/**
* Get a pre-configured animated avatar URL of a user
*/
get animatedAvatarURL() {
return this.generateAvatarURL({ max_side: 256 }, true);
}
@computed generateAvatarURL(...args: FileArgs) {
return (
this.client.generateFileURL(this.avatar ?? undefined, ...args) ??
this.defaultAvatarURL
);
}
@computed get permission() {
let permissions = 0;
switch (this.relationship) {
case "Friend":
case "User":
return U32_MAX;
case "Blocked":
case "BlockedOther":
return UserPermission.Access;
case "Incoming":
case "Outgoing":
permissions = UserPermission.Access;
}
if (
[...this.client.channels.values()].find(
(channel) =>
(channel.channel_type === "Group" ||
channel.channel_type === "DirectMessage") &&
channel.recipient_ids?.includes(this.client.user!._id),
) ||
[...this.client.members.values()].find(
(member) => member._id.user === this.client.user!._id,
)
) {
if (this.client.user?.bot || this.bot) {
permissions |= UserPermission.SendMessage;
}
permissions |= UserPermission.Access | UserPermission.ViewProfile;
}
return permissions;
}
}
export default class Users extends Collection<string, User> {
constructor(client: Client) {
super(client);
this.createObj = this.createObj.bind(this);
this.set(
"00000000000000000000000000",
new User(client, {
_id: "00000000000000000000000000",
username: "Revolt",
}),
);
}
@action $get(id: string, data?: UserI) {
const user = this.get(id)!;
if (data) user.update(data);
return user;
}
/**
* Fetch a user
* @param id User ID
* @returns User
*/
async fetch(id: string, data?: UserI) {
if (this.has(id)) return this.$get(id, data);
const res = data ?? (await this.client.api.get(`/users/${id as ""}`));
return this.createObj(res);
}
/**
* Create a user object.
* This is meant for internal use only.
* @param data: User Data
* @returns User
*/
createObj(data: UserI) {
if (this.has(data._id)) return this.$get(data._id, data);
const user = new User(this.client, data);
runInAction(() => {
this.set(data._id, user);
});
this.client.emit("user/relationship", user);
return user;
}
/**
* Edit the current user
* @param data User edit data object
*/
async edit(data: DataEditUser) {
await this.client.api.patch("/users/@me", data);
}
/**
* Change the username of the current user
* @param username New username
* @param password Current password
*/
async changeUsername(username: string, password: string) {
return await this.client.api.patch("/users/@me/username", {
username,
password,
});
}
}
+132 -141
View File
@@ -1,11 +1,13 @@
import Long from "long";
import { Channel, Server, Member } from "..";
import { Channel, Client, Server, ServerMember } from "..";
import {
ALLOW_IN_TIMEOUT,
DEFAULT_PERMISSION_DIRECT_MESSAGE,
DEFAULT_PERMISSION_VIEW_ONLY,
Permission,
UserPermission,
ALLOW_IN_TIMEOUT,
DEFAULT_PERMISSION_DIRECT_MESSAGE,
DEFAULT_PERMISSION_VIEW_ONLY,
Permission,
UserPermission,
} from "./definitions";
/**
@@ -14,8 +16,8 @@ import {
* @param b Inputs (OR'd together)
*/
export function bitwiseAndEq(a: number, ...b: number[]) {
const value = b.reduce((prev, cur) => prev.or(cur), Long.fromNumber(0));
return value.and(a).eq(value);
const value = b.reduce((prev, cur) => prev.or(cur), Long.fromNumber(0));
return value.and(a).eq(value);
}
/**
@@ -24,146 +26,135 @@ export function bitwiseAndEq(a: number, ...b: number[]) {
* @param options Additional options to use when calculating
*/
export function calculatePermission(
target: Channel | Server,
options?: {
/**
* Pretend to be another member
*/
member?: Member;
},
client: Client,
target: Channel | Server,
options?: {
/**
* Pretend to be another ServerMember
*/
member?: ServerMember;
}
): number {
const user = options?.member ? options?.member.user : target.client.user;
if (user?.privileged) {
return Permission.GrantAllSafe;
}
const user = options?.member ? options?.member.user : client.user;
if (user?.privileged) {
return Permission.GrantAllSafe;
}
if (target instanceof Server) {
// 1. Check if owner.
if (target.owner === user?._id) {
return Permission.GrantAllSafe;
} else {
// 2. Get member.
const member = options?.member ??
target.client.members.getKey({
user: user!._id,
server: target._id,
}) ?? { roles: null, timeout: null };
if (!member) return 0;
// 3. Apply allows from default_permissions.
let perm = Long.fromNumber(target.default_permissions);
// 4. If user has roles, iterate in order.
if (member.roles && target.roles) {
// 5. Apply allows and denies from roles.
const permissions = member.orderedRoles.map(
([, role]) => role.permissions,
);
for (const permission of permissions) {
perm = perm
.or(permission.a)
.and(Long.fromNumber(permission.d).not());
}
}
// 5. Revoke permissions if member is timed out.
if (member.timeout && member.timeout > new Date()) {
perm = perm.and(ALLOW_IN_TIMEOUT);
}
return perm.toNumber();
}
if (target instanceof Server) {
// 1. Check if owner.
if (target.ownerId === user?.id) {
return Permission.GrantAllSafe;
} else {
// 1. Check channel type.
switch (target.channel_type) {
case "SavedMessages":
return Permission.GrantAllSafe;
case "DirectMessage": {
// 2. Determine user permissions.
const user_permissions = target.recipient?.permission || 0;
// 2. Get ServerMember.
const member = options?.member ??
client.serverMembers.getByKey({
user: user!.id,
server: target.id,
}) ?? { roles: null, timeout: null };
// 3. Check if the user can send messages.
if (user_permissions & UserPermission.SendMessage) {
return DEFAULT_PERMISSION_DIRECT_MESSAGE;
} else {
return DEFAULT_PERMISSION_VIEW_ONLY;
}
}
case "Group": {
// 2. Check if user is owner.
if (target.owner_id === user!._id) {
return DEFAULT_PERMISSION_DIRECT_MESSAGE;
} else {
// 3. Pull out group permissions.
return (
target.permissions ?? DEFAULT_PERMISSION_DIRECT_MESSAGE
);
}
}
case "TextChannel":
case "VoiceChannel": {
// 2. Get server.
const server = target.server;
if (typeof server === "undefined") return 0;
if (!member) return 0;
// 3. If server owner, just grant all permissions.
if (server.owner === user?._id) {
return Permission.GrantAllSafe;
} else {
// 4. Get member.
const member = options?.member ??
target.client.members.getKey({
user: user!._id,
server: server._id,
}) ?? { roles: null, timeout: null };
// 3. Apply allows from default_permissions.
let perm = Long.fromNumber(target.defaultPermissions);
if (!member) return 0;
// 4. If user has roles, iterate in order.
if (member.roles && target.roles) {
// 5. Apply allows and denies from roles.
const permissions = member.orderedRoles.map(
(role) => role.permissions ?? { a: 0, d: 0 }
);
// 5. Calculate server base permissions.
let perm = Long.fromNumber(
calculatePermission(server, options),
);
// 6. Apply default allows and denies for channel.
if (target.default_permissions) {
perm = perm
.or(target.default_permissions.a)
.and(
Long.fromNumber(
target.default_permissions.d,
).not(),
);
}
// 7. If user has roles, iterate in order.
if (
member.roles &&
target.role_permissions &&
server.roles
) {
// 5. Apply allows and denies from roles.
const roles = member.orderedRoles.map(([id]) => id);
for (const id of roles) {
const override = target.role_permissions[id];
if (override) {
perm = perm
.or(override.a)
.and(Long.fromNumber(override.d).not());
}
}
}
// 8. Revoke permissions if member is timed out.
if (member.timeout && member.timeout > new Date()) {
perm = perm.and(ALLOW_IN_TIMEOUT);
}
return perm.toNumber();
}
}
for (const permission of permissions) {
perm = perm.or(permission.a).and(Long.fromNumber(permission.d).not());
}
}
// 5. Revoke permissions if ServerMember is timed out.
if (member.timeout && member.timeout > new Date()) {
perm = perm.and(ALLOW_IN_TIMEOUT);
}
return perm.toNumber();
}
} else {
// 1. Check channel type.
switch (target.type) {
case "SavedMessages":
return Permission.GrantAllSafe;
case "DirectMessage": {
// 2. Determine user permissions.
const user_permissions = target.recipient?.permission || 0;
// 3. Check if the user can send messages.
if (user_permissions & UserPermission.SendMessage) {
return DEFAULT_PERMISSION_DIRECT_MESSAGE;
} else {
return DEFAULT_PERMISSION_VIEW_ONLY;
}
}
case "Group": {
// 2. Check if user is owner.
if (target.ownerId === user!.id) {
return DEFAULT_PERMISSION_DIRECT_MESSAGE;
} else {
// 3. Pull out group permissions.
return target.permissions ?? DEFAULT_PERMISSION_DIRECT_MESSAGE;
}
}
case "TextChannel":
case "VoiceChannel": {
// 2. Get server.
const server = target.server;
if (typeof server === "undefined") return 0;
// 3. If server owner, just grant all permissions.
if (server.owner === user?.id) {
return Permission.GrantAllSafe;
} else {
// 4. Get ServerMember.
const member = options?.member ??
client.serverMembers.getByKey({
user: user!.id,
server: server.id,
}) ?? { roles: null, timeout: null };
if (!member) return 0;
// 5. Calculate server base permissions.
let perm = Long.fromNumber(
calculatePermission(client, server, options)
);
// 6. Apply default allows and denies for channel.
if (target.defaultPermissions) {
perm = perm
.or(target.defaultPermissions.a)
.and(Long.fromNumber(target.defaultPermissions.d).not());
}
// 7. If user has roles, iterate in order.
if (member.roles && target.rolePermissions && server.roles) {
// 5. Apply allows and denies from roles.
const roles = member.orderedRoles.map(({ id }) => id);
for (const id of roles) {
const override = target.rolePermissions[id];
if (override) {
perm = perm
.or(override.a)
.and(Long.fromNumber(override.d).not());
}
}
}
// 8. Revoke permissions if ServerMember is timed out.
if (member.timeout && member.timeout > new Date()) {
perm = perm.and(ALLOW_IN_TIMEOUT);
}
return perm.toNumber();
}
}
}
}
}
+88 -85
View File
@@ -2,93 +2,93 @@
* Permission against User
*/
export const UserPermission = {
Access: 1 << 0,
ViewProfile: 1 << 1,
SendMessage: 1 << 2,
Invite: 1 << 3,
Access: 1 << 0,
ViewProfile: 1 << 1,
SendMessage: 1 << 2,
Invite: 1 << 3,
};
/**
* Permission against Server / Channel
*/
export const Permission = {
// * Generic permissions
/// Manage the channel or channels on the server
ManageChannel: 2 ** 0,
/// Manage the server
ManageServer: 2 ** 1,
/// Manage permissions on servers or channels
ManagePermissions: 2 ** 2,
/// Manage roles on server
ManageRole: 2 ** 3,
/// Manage server customisation (includes emoji)
ManageCustomisation: 2 ** 4,
// * Generic permissions
/// Manage the channel or channels on the server
ManageChannel: 2 ** 0,
/// Manage the server
ManageServer: 2 ** 1,
/// Manage permissions on servers or channels
ManagePermissions: 2 ** 2,
/// Manage roles on server
ManageRole: 2 ** 3,
/// Manage server customisation (includes emoji)
ManageCustomisation: 2 ** 4,
// % 1 bits reserved
// % 1 bits reserved
// * Member permissions
/// Kick other members below their ranking
KickMembers: 2 ** 6,
/// Ban other members below their ranking
BanMembers: 2 ** 7,
/// Timeout other members below their ranking
TimeoutMembers: 2 ** 8,
/// Assign roles to members below their ranking
AssignRoles: 2 ** 9,
/// Change own nickname
ChangeNickname: 2 ** 10,
/// Change or remove other's nicknames below their ranking
ManageNicknames: 2 ** 11,
/// Change own avatar
ChangeAvatar: 2 ** 12,
/// Remove other's avatars below their ranking
RemoveAvatars: 2 ** 13,
// * Member permissions
/// Kick other members below their ranking
KickMembers: 2 ** 6,
/// Ban other members below their ranking
BanMembers: 2 ** 7,
/// Timeout other members below their ranking
TimeoutMembers: 2 ** 8,
/// Assign roles to members below their ranking
AssignRoles: 2 ** 9,
/// Change own nickname
ChangeNickname: 2 ** 10,
/// Change or remove other's nicknames below their ranking
ManageNicknames: 2 ** 11,
/// Change own avatar
ChangeAvatar: 2 ** 12,
/// Remove other's avatars below their ranking
RemoveAvatars: 2 ** 13,
// % 7 bits reserved
// % 7 bits reserved
// * Channel permissions
/// View a channel
ViewChannel: 2 ** 20,
/// Read a channel's past message history
ReadMessageHistory: 2 ** 21,
/// Send a message in a channel
SendMessage: 2 ** 22,
/// Delete messages in a channel
ManageMessages: 2 ** 23,
/// Manage webhook entries on a channel
ManageWebhooks: 2 ** 24,
/// Create invites to this channel
InviteOthers: 2 ** 25,
/// Send embedded content in this channel
SendEmbeds: 2 ** 26,
/// Send attachments and media in this channel
UploadFiles: 2 ** 27,
/// Masquerade messages using custom nickname and avatar
Masquerade: 2 ** 28,
/// React to messages with emoji
React: 2 ** 29,
// * Channel permissions
/// View a channel
ViewChannel: 2 ** 20,
/// Read a channel's past message history
ReadMessageHistory: 2 ** 21,
/// Send a message in a channel
SendMessage: 2 ** 22,
/// Delete messages in a channel
ManageMessages: 2 ** 23,
/// Manage webhook entries on a channel
ManageWebhooks: 2 ** 24,
/// Create invites to this channel
InviteOthers: 2 ** 25,
/// Send embedded content in this channel
SendEmbeds: 2 ** 26,
/// Send attachments and media in this channel
UploadFiles: 2 ** 27,
/// Masquerade messages using custom nickname and avatar
Masquerade: 2 ** 28,
/// React to messages with emoji
React: 2 ** 29,
// * Voice permissions
/// Connect to a voice channel
Connect: 2 ** 30,
/// Speak in a voice call
Speak: 2 ** 31,
/// Share video in a voice call
Video: 2 ** 32,
/// Mute other members with lower ranking in a voice call
MuteMembers: 2 ** 33,
/// Deafen other members with lower ranking in a voice call
DeafenMembers: 2 ** 34,
/// Move members between voice channels
MoveMembers: 2 ** 35,
// * Voice permissions
/// Connect to a voice channel
Connect: 2 ** 30,
/// Speak in a voice call
Speak: 2 ** 31,
/// Share video in a voice call
Video: 2 ** 32,
/// Mute other members with lower ranking in a voice call
MuteMembers: 2 ** 33,
/// Deafen other members with lower ranking in a voice call
DeafenMembers: 2 ** 34,
/// Move members between voice channels
MoveMembers: 2 ** 35,
// * Misc. permissions
// % Bits 36 to 52: free area
// % Bits 53 to 64: do not use
// * Misc. permissions
// % Bits 36 to 52: free area
// % Bits 53 to 64: do not use
// * Grant all permissions
/// Safely grant all permissions
GrantAllSafe: 0x000f_ffff_ffff_ffff,
// * Grant all permissions
/// Safely grant all permissions
GrantAllSafe: 0x000f_ffff_ffff_ffff,
};
/**
@@ -100,25 +100,25 @@ export const U32_MAX = 2 ** 32 - 1; // 4294967295
* Permissions allowed for a user while in timeout
*/
export const ALLOW_IN_TIMEOUT =
Permission.ViewChannel + Permission.ReadMessageHistory;
Permission.ViewChannel + Permission.ReadMessageHistory;
/**
* Default permissions if we can only view
*/
export const DEFAULT_PERMISSION_VIEW_ONLY =
Permission.ViewChannel + Permission.ReadMessageHistory;
Permission.ViewChannel + Permission.ReadMessageHistory;
/**
* Default base permissions for channels
*/
export const DEFAULT_PERMISSION =
DEFAULT_PERMISSION_VIEW_ONLY +
Permission.SendMessage +
Permission.InviteOthers +
Permission.SendEmbeds +
Permission.UploadFiles +
Permission.Connect +
Permission.Speak;
DEFAULT_PERMISSION_VIEW_ONLY +
Permission.SendMessage +
Permission.InviteOthers +
Permission.SendEmbeds +
Permission.UploadFiles +
Permission.Connect +
Permission.Speak;
/**
* Permissions in saved messages channel
@@ -129,10 +129,13 @@ export const DEFAULT_PERMISSION_SAVED_MESSAGES = Permission.GrantAllSafe;
* Permissions in direct message channels / default permissions for group DMs
*/
export const DEFAULT_PERMISSION_DIRECT_MESSAGE =
DEFAULT_PERMISSION + Permission.React + Permission.ManageChannel;
DEFAULT_PERMISSION + Permission.React + Permission.ManageChannel;
/**
* Permissions in server text / voice channel
*/
export const DEFAULT_PERMISSION_SERVER =
DEFAULT_PERMISSION + Permission.React + Permission.ChangeNickname + Permission.ChangeAvatar;
DEFAULT_PERMISSION +
Permission.React +
Permission.ChangeNickname +
Permission.ChangeAvatar;
+43
View File
@@ -0,0 +1,43 @@
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;
this.get = this.get.bind(this);
}
/**
* 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 context Context
* @param data Input Data
*/
hydrate(id: string, type: keyof Hydrators, context: unknown, data?: unknown) {
if (data) {
this.set(id, hydrate(type, data as never, context, true) as T);
}
}
}
-107
View File
@@ -1,107 +0,0 @@
import { config } from "dotenv";
import { Client } from ".";
config();
// To run this example, you need to have a local Revolt server running and an existing account.
// Copy and paste `.env.example` to `.env` and edit accordingly.
async function user() {
const client = new Client({
apiURL: process.env.API_URL,
debug: true,
});
client.on("ready", async () => {
console.info(`Logged in as ${client.user!.username}!`);
const group = await client.channels.createGroup({
name: 'sussy',
users: []
});
const msg = await group.sendMessage({
content: "embed test",
embeds: [
{
title: 'We do a little!'
}
]
});
await msg.edit({
embeds: [{ title: 'sus' }]
});
});
client.on("message", async (message) => {
if (message.content === "sus") {
message.channel!.sendMessage("sus!");
} else if (message.content === "bot") {
const bot = await client.api.post("/bots/create", {
name: "basedbot12",
});
message.channel!.sendMessage(JSON.stringify(bot));
} else if (message.content === "my bots") {
message.channel!.sendMessage(
JSON.stringify(await client.api.get("/bots/@me")),
);
} else if (message.content === "join bot") {
await client.api.post(
`/bots/${'01FCV7DCMRD9MT3JBYT5VEKVRD'}/invite`,
{ group: message.channel_id },
);
// { server: '01FATEGMHEE2M1QGPA65NS6V8K' });
} else if (message.content === "edit bot name") {
await client.api.patch(
`/bots/${'01FCV7DCMRD9MT3JBYT5VEKVRD'}`,
{ name: "testingbkaka" },
);
} else if (message.content === "make bot public") {
await client.api.patch(
`/bots/${'01FCV7DCMRD9MT3JBYT5VEKVRD'}`,
{ public: true },
);
} else if (message.content === "delete bot") {
await client.api.delete(`/bots/${'01FCV7DCMRD9MT3JBYT5VEKVRD'}`);
}
});
try {
await client.register({
email: process.env.EMAIL as string,
password: process.env.PASSWORD as string,
});
} catch (err) {}
const onboarding = await client.login({
email: process.env.EMAIL as string,
password: process.env.PASSWORD as string,
});
onboarding?.('sus', true);
}
/*function bot() {
const client = new Client({
apiURL: process.env.API_URL,
});
client.on("ready", async () => {
console.info(
`Logged in as ${client.user!.username}! [${client.user!._id}]`,
);
});
client.on("message", async (message) => {
if (message.content === "sus") {
message.channel!.sendMessage("sus!");
}
});
// client.loginBot(process.env.BOT_TOKEN as string)
}*/
user();
// bot();
-121
View File
@@ -1,121 +0,0 @@
import { Client } from "../Client";
import {
action,
computed,
makeAutoObservable,
ObservableMap,
runInAction,
} from "mobx";
import type { ChannelUnread } from "revolt-api";
import { ulid } from "ulid";
import { Channel } from "../maps/Channels";
import { Server } from "../maps/Servers";
export interface INotificationChecker {
isMuted(target?: Channel | Server): boolean;
}
/**
* Handles channel unreads.
*/
export default class Unreads {
private client: Client;
private loaded: boolean;
private channels: ObservableMap<string, Omit<ChannelUnread, "_id">>;
/**
* Construct new Unreads store.
*/
constructor(client: Client) {
this.channels = new ObservableMap();
this.loaded = false;
makeAutoObservable(this);
this.client = client;
}
/**
* Sync unread data from the server.
*/
async sync() {
const unreads = await this.client.syncFetchUnreads();
runInAction(() => {
this.loaded = true;
for (const unread of unreads) {
const { _id, ...data } = unread;
this.channels.set(_id.channel, data);
}
});
}
/**
* Get channel unread object for a given channel.
* @param channel_id Target channel ID
* @returns Partial channel unread object
*/
@computed getUnread(channel_id: string) {
if (!this.loaded)
return {
last_id: "40000000000000000000000000",
};
return this.channels.get(channel_id);
}
/**
* Mark a channel as unread by setting a custom last_id.
* @param channel_id Target channel ID
* @param last_id New last ID
*/
@action markUnread(channel_id: string, last_id: string) {
this.channels.set(channel_id, {
...this.getUnread(channel_id),
last_id,
});
}
/**
* Add a mention to a channel unread.
* @param channel_id Target channel ID
* @param message_id Target message ID
*/
@action markMention(channel_id: string, message_id: string) {
const unread = this.getUnread(channel_id);
this.channels.set(channel_id, {
last_id: "0",
...unread,
mentions: [...(unread?.mentions ?? []), message_id],
});
}
/**
* Mark a channel as read.
* @param channel_id Target channel ID
* @param message_id Target message ID (last sent in channel)
* @param emit Whether to emit to server
* @param skipRateLimiter Whether to skip the rate limiter
*/
@action markRead(
channel_id: string,
message_id?: string,
emit = false,
skipRateLimiter = false,
) {
const last_id = message_id ?? ulid();
this.channels.set(channel_id, { last_id });
if (emit) {
this.client.channels.get(channel_id)?.ack(last_id, skipRateLimiter);
}
}
/**
* Mark multiple channels as read.
* @param channel_ids Target channel IDs
*/
@action markMultipleRead(channel_ids: string[]) {
const last_id = ulid();
for (const channel_id of channel_ids) {
this.channels.set(channel_id, { last_id });
}
}
}
-13
View File
@@ -1,13 +0,0 @@
export type Nullable<T> = T | null;
export function toNullable<T>(data?: T) {
return typeof data === "undefined" ? null : data;
}
/**
* Backwards compatible convert potential Date value to Nullable<Date>.
* @param data ISO8601 Timestamp or BSON DateTime
* @returns
*/
export function toNullableDate(data?: { $date: string } | string | null) {
return data ? new Date(typeof data === 'string' ? data : data.$date) : null;
}
-796
View File
@@ -1,796 +0,0 @@
import { backOff } from "@insertish/exponential-backoff";
import { ObservableSet, runInAction } from "mobx";
import WebSocket from "@insertish/isomorphic-ws";
import type { MessageEvent } from "ws";
import { Role } from "revolt-api";
import { Client } from "..";
import {
ServerboundNotification,
ClientboundNotification,
} from "./notifications";
export class WebSocketClient {
client: Client;
ws?: WebSocket;
heartbeat?: number;
connected: boolean;
ready: boolean;
ping?: number;
constructor(client: Client) {
this.client = client;
this.connected = false;
this.ready = false;
}
/**
* Disconnect the WebSocket and disable heartbeats.
*/
disconnect() {
clearInterval(this.heartbeat);
this.connected = false;
this.ready = false;
if (
typeof this.ws !== "undefined" &&
this.ws.readyState === WebSocket.OPEN
) {
this.ws.close();
}
}
/**
* Send a notification
* @param notification Serverbound notification
*/
send(notification: ServerboundNotification) {
if (
typeof this.ws === "undefined" ||
this.ws.readyState !== WebSocket.OPEN
)
return;
const data = JSON.stringify(notification);
if (this.client.debug) console.debug("[<] PACKET", data);
this.ws.send(data);
}
/**
* Connect the WebSocket
* @param disallowReconnect Whether to disallow reconnection
*/
connect(disallowReconnect?: boolean): Promise<void> {
this.client.emit("connecting");
return new Promise((resolve, $reject) => {
let thrown = false;
const reject = (err: unknown) => {
if (!thrown) {
thrown = true;
$reject(err);
}
};
this.disconnect();
if (typeof this.client.configuration === "undefined") {
throw new Error(
"Attempted to open WebSocket without syncing configuration from server.",
);
}
if (typeof this.client.session === "undefined") {
throw new Error(
"Attempted to open WebSocket without valid session.",
);
}
const ws = new WebSocket(this.client.configuration.ws);
this.ws = ws;
ws.onopen = () => {
if (typeof this.client.session === "string") {
this.send({
type: "Authenticate",
token: this.client.session!,
});
} else {
this.send({
type: "Authenticate",
...this.client.session!,
});
}
};
const process = async (packet: ClientboundNotification) => {
this.client.emit("packet", packet);
try {
switch (packet.type) {
case "Bulk": {
for (const entry of packet.v) {
await process(entry);
}
break;
}
case "Error": {
reject(packet.error);
break;
}
case "Authenticated": {
disallowReconnect = false;
this.client.emit("connected");
this.connected = true;
break;
}
case "Ready": {
runInAction(() => {
if (packet.type !== "Ready") throw 0;
for (const user of packet.users) {
this.client.users.createObj(user);
}
for (const channel of packet.channels) {
this.client.channels.createObj(channel);
}
for (const server of packet.servers) {
this.client.servers.createObj(server);
}
for (const member of packet.members) {
this.client.members.createObj(member);
}
for (const emoji of packet.emojis!) {
this.client.emojis.createObj(emoji);
}
});
this.client.user = this.client.users.get(
packet.users.find(
(x) => x.relationship === "User",
)!._id,
)!;
this.client.emit("ready");
this.ready = true;
resolve();
// Sync unreads.
this.client.unreads?.sync();
// Setup heartbeat.
if (this.client.heartbeat > 0) {
this.send({ type: "Ping", data: +new Date() });
this.heartbeat = setInterval(() => {
this.send({
type: "Ping",
data: +new Date(),
});
if (this.client.options.pongTimeout) {
let pongReceived = false;
this.client.once("packet", (p) => {
if (p.type == "Pong")
pongReceived = true;
});
setTimeout(() => {
if (!pongReceived) {
if (
this.client.options
.onPongTimeout == "EXIT"
) {
throw "Client did not receive a pong in time";
} else {
console.warn(
"Warning: Client did not receive a pong in time; Reconnecting.",
);
this.disconnect();
this.connect(
disallowReconnect,
);
}
}
}, this.client.options.pongTimeout * 1000);
}
}, this.client.heartbeat * 1e3) as unknown as number;
}
break;
}
case "Message": {
if (!this.client.messages.has(packet._id)) {
if (
packet.author ===
"00000000000000000000000000"
) {
if (packet.system) {
switch (packet.system.type) {
case "user_added":
case "user_remove":
await this.client.users.fetch(
packet.system.by,
);
break;
case "user_joined":
await this.client.users.fetch(
packet.system.id,
);
break;
case "channel_description_changed":
case "channel_icon_changed":
case "channel_renamed":
await this.client.users.fetch(
packet.system.by,
);
break;
}
}
} else {
await this.client.users.fetch(
packet.author,
);
}
const channel =
await this.client.channels.fetch(
packet.channel,
);
if (channel.channel_type === "TextChannel") {
const server =
await this.client.servers.fetch(
channel.server_id!,
);
if (
packet.author !==
"00000000000000000000000000"
)
await server.fetchMember(packet.author);
}
const message = this.client.messages.createObj(
packet,
true,
);
runInAction(() => {
if (
channel.channel_type === "DirectMessage"
) {
channel.active = true;
}
channel.last_message_id = message._id;
if (
this.client.unreads &&
message.mention_ids?.includes(
this.client.user!._id,
)
) {
this.client.unreads.markMention(
message.channel_id,
message._id,
);
}
});
}
break;
}
case "MessageUpdate": {
const message = this.client.messages.get(packet.id);
if (message) {
message.update(packet.data);
this.client.emit("message/update", message);
this.client.emit(
"message/updated",
message,
packet,
);
}
break;
}
case "MessageAppend": {
const message = this.client.messages.get(packet.id);
if (message) {
message.append(packet.append);
this.client.emit("message/append", message);
this.client.emit(
"message/updated",
message,
packet,
);
}
break;
}
case "MessageDelete": {
const msg = this.client.messages.get(packet.id);
this.client.messages.delete(packet.id);
this.client.emit("message/delete", packet.id, msg);
break;
}
case "MessageReact": {
const msg = this.client.messages.get(packet.id);
if (msg) {
if (msg.reactions.has(packet.emoji_id)) {
msg.reactions
.get(packet.emoji_id)!
.add(packet.user_id);
} else {
msg.reactions.set(
packet.emoji_id,
new ObservableSet([packet.user_id]),
);
}
this.client.emit(
"message/updated",
msg,
packet,
);
}
break;
}
case "MessageUnreact": {
const msg = this.client.messages.get(packet.id);
if (msg) {
const user_ids = msg.reactions.get(
packet.emoji_id,
);
if (user_ids) {
user_ids.delete(packet.user_id);
if (user_ids.size === 0) {
msg.reactions.delete(packet.emoji_id);
}
}
this.client.emit(
"message/updated",
msg,
packet,
);
}
break;
}
case "MessageRemoveReaction": {
const msg = this.client.messages.get(packet.id);
if (msg) {
msg.reactions.delete(packet.emoji_id);
this.client.emit(
"message/updated",
msg,
packet,
);
}
break;
}
case "BulkMessageDelete": {
runInAction(() => {
for (const id of packet.ids) {
const msg = this.client.messages.get(id);
this.client.messages.delete(id);
this.client.emit("message/delete", id, msg);
}
});
break;
}
case "ChannelCreate": {
runInAction(async () => {
if (packet.type !== "ChannelCreate") throw 0;
if (
packet.channel_type === "TextChannel" ||
packet.channel_type === "VoiceChannel"
) {
const server =
await this.client.servers.fetch(
packet.server,
);
server.channel_ids.push(packet._id);
}
this.client.channels.createObj(packet, true);
});
break;
}
case "ChannelUpdate": {
const channel = this.client.channels.get(packet.id);
if (channel) {
channel.update(packet.data, packet.clear);
this.client.emit("channel/update", channel);
}
break;
}
case "ChannelDelete": {
const channel = this.client.channels.get(packet.id);
channel?.delete(false, true);
this.client.emit(
"channel/delete",
packet.id,
channel,
);
break;
}
case "ChannelGroupJoin": {
this.client.channels
.get(packet.id)
?.updateGroupJoin(packet.user);
break;
}
case "ChannelGroupLeave": {
const channel = this.client.channels.get(packet.id);
if (channel) {
if (packet.user === this.client.user?._id) {
channel.delete(false, true);
} else {
channel.updateGroupLeave(packet.user);
}
}
break;
}
case "ServerCreate": {
runInAction(async () => {
const channels = [];
for (const channel of packet.channels) {
channels.push(
await this.client.channels.fetch(
channel._id,
channel,
),
);
}
await this.client.servers.fetch(
packet.id,
packet.server,
);
});
break;
}
case "ServerUpdate": {
const server = this.client.servers.get(packet.id);
if (server) {
server.update(packet.data, packet.clear);
this.client.emit("server/update", server);
}
break;
}
case "ServerDelete": {
const server = this.client.servers.get(packet.id);
server?.delete(false, true);
this.client.emit(
"server/delete",
packet.id,
server,
);
break;
}
case "ServerMemberUpdate": {
const member = this.client.members.getKey(
packet.id,
);
if (member) {
member.update(packet.data, packet.clear);
this.client.emit("member/update", member);
}
break;
}
case "ServerMemberJoin": {
runInAction(async () => {
await this.client.servers.fetch(packet.id);
await this.client.users.fetch(packet.user);
this.client.members.createObj(
{
_id: {
server: packet.id,
user: packet.user,
},
joined_at: new Date().toISOString(),
},
true,
);
});
break;
}
case "ServerMemberLeave": {
if (packet.user === this.client.user!._id) {
const server_id = packet.id;
runInAction(() => {
this.client.servers
.get(server_id)
?.delete(false, true);
[...this.client.members.keys()].forEach(
(key) => {
if (
JSON.parse(key).server ===
server_id
) {
this.client.members.delete(key);
}
},
);
});
} else {
this.client.members.deleteKey({
server: packet.id,
user: packet.user,
});
this.client.emit("member/leave", {
server: packet.id,
user: packet.user,
});
}
break;
}
case "ServerRoleUpdate": {
const server = this.client.servers.get(packet.id);
if (server) {
const role = {
...server.roles?.[packet.role_id],
...packet.data,
} as Role;
server.roles = {
...server.roles,
[packet.role_id]: role,
};
this.client.emit(
"role/update",
packet.role_id,
role,
packet.id,
);
}
break;
}
case "ServerRoleDelete": {
const server = this.client.servers.get(packet.id);
if (server) {
const { [packet.role_id]: _, ...roles } =
server.roles ?? {};
server.roles = roles;
this.client.emit(
"role/delete",
packet.role_id,
packet.id,
);
}
break;
}
case "UserPlatformWipe": {
runInAction(() => {
const user_id = packet.user_id;
this.client.users.get(user_id)?.update(
{
username: "Removed User",
online: false,
relationship: "None",
flags: packet.flags,
},
[
"Avatar",
"ProfileBackground",
"ProfileContent",
"StatusPresence",
"StatusText",
],
);
const dm_channel = [
...this.client.channels.values(),
].find(
(channel) =>
channel.channel_type ===
"DirectMessage" &&
channel.recipient_ids?.includes(
user_id,
),
);
if (dm_channel) {
this.client.channels.delete(dm_channel._id);
}
const member_ids = [
...this.client.members.values(),
]
.filter(
(member) => member._id.user === user_id,
)
.map((member) => member._id);
for (const member_id of member_ids) {
this.client.members.deleteKey(member_id);
}
for (const message of [
...this.client.messages.values(),
].filter(
(message) => message.author_id === user_id,
)) {
message.content = "(message withheld)";
message.attachments = [];
message.embeds = [];
}
});
break;
}
case "UserUpdate": {
this.client.users
.get(packet.id)
?.update(packet.data, packet.clear);
break;
}
case "UserRelationship": {
const user = this.client.users.get(packet.user._id);
if (user) {
user.update({
...packet.user,
relationship: packet.status,
});
} else {
this.client.users.createObj(packet.user);
}
break;
}
case "ChannelStartTyping": {
const channel = this.client.channels.get(packet.id);
const user = packet.user;
if (channel) {
channel.updateStartTyping(user);
clearInterval(timeouts[packet.id + user]);
timeouts[packet.id + user] = setTimeout(() => {
channel!.updateStopTyping(user);
}, 3000) as unknown as number;
}
break;
}
case "ChannelStopTyping": {
this.client.channels
.get(packet.id)
?.updateStopTyping(packet.user);
clearInterval(timeouts[packet.id + packet.user]);
break;
}
case "ChannelAck": {
this.client.unreads?.markRead(
packet.id,
packet.message_id,
);
break;
}
case "EmojiCreate": {
this.client.emojis.createObj(packet, true);
break;
}
case "EmojiDelete": {
const emoji = this.client.emojis.get(packet.id);
this.client.emit("emoji/delete", packet.id, emoji);
break;
}
case "Pong": {
this.ping = +new Date() - packet.data;
break;
}
default:
this.client.debug &&
console.warn(
`Warning: Unhandled packet! ${packet.type}`,
);
}
} catch (e) {
console.error(e);
}
};
const timeouts: Record<string, number> = {};
const handle = async (msg: WebSocket.MessageEvent) => {
const data = msg.data;
if (typeof data !== "string") return;
if (this.client.debug) console.debug("[>] PACKET", data);
const packet = JSON.parse(data) as ClientboundNotification;
await process(packet);
};
let processing = false;
const queue: WebSocket.MessageEvent[] = [];
ws.onmessage = async (data: MessageEvent) => {
queue.push(data);
if (!processing) {
processing = true;
while (queue.length > 0) {
await handle(queue.shift()!);
}
processing = false;
}
};
ws.onerror = (err: any) => {
reject(err);
};
ws.onclose = () => {
this.client.emit("dropped");
this.connected = false;
this.ready = false;
Object.keys(timeouts)
.map((k) => timeouts[k])
.forEach(clearTimeout);
runInAction(() => {
[...this.client.users.values()].forEach(
(user) => (user.online = false),
);
[...this.client.channels.values()].forEach((channel) =>
channel.typing_ids.clear(),
);
});
if (!disallowReconnect && this.client.autoReconnect) {
backOff(() => this.connect(true)).catch(reject);
}
};
});
}
}
-150
View File
@@ -1,150 +0,0 @@
import type {
Emoji,
FieldsChannel,
FieldsMember,
FieldsServer,
FieldsUser,
} from "revolt-api";
import type { Channel, Message } from "revolt-api";
import type { Member, MemberCompositeKey, Role, Server } from "revolt-api";
import type { RelationshipStatus, User } from "revolt-api";
import type { Session } from "../Client";
type WebSocketError = {
error:
| "InternalError"
| "InvalidSession"
| "OnboardingNotFinished"
| "AlreadyAuthenticated";
};
export type ServerboundNotification =
| { type: "Ping"; data: number }
| { type: "Pong"; data: number }
| ({ type: "Authenticate" } & Session)
| { type: "Authenticate"; token: string }
| { type: "BeginTyping"; channel: string }
| { type: "EndTyping"; channel: string };
export type ReadyPacket = {
type: "Ready";
users: User[];
servers: Server[];
channels: Channel[];
members: Member[];
emojis?: Emoji[];
};
export type ClientboundNotification =
| { type: "Bulk"; v: ClientboundNotification[] }
| { type: "Ping"; data: number }
| { type: "Pong"; data: number }
| ({ type: "Error" } & WebSocketError)
| { type: "Authenticated" }
| ReadyPacket
| ({ type: "Message" } & Message)
| {
type: "MessageUpdate";
id: string;
channel: string;
data: Partial<Message>;
}
| {
type: "MessageAppend";
id: string;
channel: string;
append: Pick<Partial<Message>, "embeds">;
}
| { type: "MessageDelete"; id: string; channel: string }
| {
type: "MessageReact";
id: string;
channel_id: string;
user_id: string;
emoji_id: string;
}
| {
type: "MessageUnreact";
id: string;
channel_id: string;
user_id: string;
emoji_id: string;
}
| {
type: "MessageRemoveReaction";
id: string;
channel_id: string;
emoji_id: string;
}
| { type: "BulkMessageDelete"; channel: string; ids: string[] }
| ({ type: "ChannelCreate" } & Channel)
| {
type: "ChannelUpdate";
id: string;
data: Partial<Channel>;
clear?: FieldsChannel[];
}
| { type: "ChannelDelete"; id: string }
| { type: "ChannelGroupJoin"; id: string; user: string }
| { type: "ChannelGroupLeave"; id: string; user: string }
| { type: "ChannelStartTyping"; id: string; user: string }
| { type: "ChannelStopTyping"; id: string; user: string }
| { type: "ChannelAck"; id: string; user: string; message_id: string }
| {
type: "ServerCreate";
id: string;
server: Server;
channels: Channel[];
}
| {
type: "ServerUpdate";
id: string;
data: Partial<Server>;
clear?: FieldsServer[];
}
| { type: "ServerDelete"; id: string }
| {
type: "ServerMemberUpdate";
id: MemberCompositeKey;
data: Partial<Member>;
clear?: FieldsMember[];
}
| { type: "ServerMemberJoin"; id: string; user: string }
| { type: "ServerMemberLeave"; id: string; user: string }
| {
type: "ServerRoleUpdate";
id: string;
role_id: string;
data: Partial<Role>;
}
| { type: "ServerRoleDelete"; id: string; role_id: string }
| {
type: "UserUpdate";
id: string;
data: Partial<User>;
clear?: FieldsUser[];
}
| { type: "UserRelationship"; user: User; status: RelationshipStatus }
| { type: "UserPresence"; id: string; online: boolean }
| {
type: "UserSettingsUpdate";
id: string;
update: { [key: string]: [number, string] };
}
| { type: "UserPlatformWipe"; user_id: string; flags: number }
| ({ type: "EmojiCreate" } & Emoji)
| { type: "EmojiDelete"; id: string }
| ({
type: "Auth";
} & (
| {
event_type: "DeleteSession";
user_id: string;
session_id: string;
}
| {
event_type: "DeleteAllSessions";
user_id: string;
exclude_session_id: string;
}
));
+12
View File
@@ -0,0 +1,12 @@
require("dotenv").config();
const { Client } = require(".");
const client = new Client({ debug: true });
client.on("ready", () => console.info(`Logged in as ${client.user.username}!`));
client.on("disconnected", () => console.info("Disconnected."));
client.on("messageCreate", (message) => console.info(message.content));
client.loginBot(process.env.TOKEN);
-62
View File
@@ -1,62 +0,0 @@
{
"compilerOptions": {
/* Basic Options */
"target": "es6" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. */,
"module": "ES6" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */,
// "lib": [], /* Specify library files to be included in the compilation. */
// "allowJs": true, /* Allow javascript files to be compiled. */
// "checkJs": true, /* Report errors in .js files. */
// "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
"declaration": true /* Generates corresponding '.d.ts' file. */,
// "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
// "sourceMap": true, /* Generates corresponding '.map' file. */
// "outFile": "./", /* Concatenate and emit output to single file. */
"outDir": "./esm" /* Redirect output structure to the directory. */,
"rootDir": "./src" /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */,
// "composite": true, /* Enable project compilation */
// "removeComments": true, /* Do not emit comments to output. */
// "noEmit": true, /* Do not emit outputs. */
// "importHelpers": true, /* Import emit helpers from 'tslib'. */
// "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
/* Strict Type-Checking Options */
"strict": true /* Enable all strict type-checking options. */,
// "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* Enable strict null checks. */
// "strictFunctionTypes": true, /* Enable strict checking of function types. */
// "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
"strictPropertyInitialization": false /* Enable strict checking of property initialization in classes. */,
// "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
// "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
/* Additional Checks */
// "noUnusedLocals": true, /* Report errors on unused locals. */
// "noUnusedParameters": true, /* Report errors on unused parameters. */
// "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
// "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
/* Module Resolution Options */
"moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */,
// "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
// "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
// "typeRoots": [], /* List of folders to include type definitions from. */
// "types": [], /* Type declaration files to be included in compilation. */
// "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
"esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */,
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
/* Source Map Options */
// "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
// "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
/* Experimental Options */
"experimentalDecorators": true /* Enables experimental support for ES7 decorators. */,
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
"skipLibCheck": true
},
"include": ["src/*"]
}
-7
View File
@@ -1,7 +0,0 @@
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"module": "CommonJS",
"outDir": "dist"
}
}
+12 -1
View File
@@ -1,3 +1,14 @@
{
"extends": "./tsconfig.base.json"
"compilerOptions": {
"target": "es2016",
"module": "commonjs",
"rootDir": "./src",
"declaration": true,
"declarationMap": true,
"outDir": "./lib/cjs",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
}
}
-1856
View File
File diff suppressed because it is too large Load Diff