This commit is contained in:
Skillz
2020-02-11 14:04:16 -05:00
17 changed files with 536 additions and 119 deletions

43
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,43 @@
# Fundamental Design Goals
This document serves to outline the overall design goals of the project. Please see below a list of these fundamentals.
## Do not allow anything Discord does not permit
Prevent any and all attempts of making user bots. If someone connects and the client.user is not a bot user then throw an error immediately.
Do not support non-bot features like Group DMs or dm calls etc...
## Prettier Philosophy Regarding Options
Avoid options/customizable whenever possible. Always enforce default values. Except in cases like intents where the user should be able to pick which intents to listen for.
## Security
Permission checks should be done by the library! We can throw a custom error that shows which permissions are missing in order to run this request and save an API call. This will also prevent bots from getting banned due to Missing Access errors.
Typescript 3.8 provides **TRUE** private props and methods that no one can access. We will use this to our advantage to truly make a proper API. This isn't a silly `_` to mark it as a private but the user should NEVER be able to access it no matter what.
## Functional API
Events emitted by the client, for example the message creation event, should not emit a `Message` class instance, but instead a *POJO* (Plain Ol' JavaScript Object). This will overall make a cleaner and more performant API, while removing the headaches of extending built-in classes, and inheritance.
Use functions when possible instead of an event emitter to prevent emitter related memory leak issues or a number of other headaches that arise.
TLDR: Avoid `classes` whenever possible. Avoid `loops` whenever possible(opt for iterations like .forEach, map reduce, some find etc...)
## Documentation
Use `/** Description here */` comments above all properties and methods to describe it so that VSC and other good IDE's with intellisense can pick it up and provide the documentation right inside the IDE preventing a developer from needing Discord API docs or even Deno documentation.
We should have a step by step guide nonetheless but this is a POST v1 launch.
We should have a template repo to creating a boilerplate bot.
## Backwards Compatibility BS
Backwards compatibility is the death of code. It causes clutter and uglyness to pile up and makes developers lazier. There will be no such thing as backwards compatibility reasons in Discordeno. We will always support the latest and greatest of JS. The end! Users can fork the lib at any commit to keep older versions until they are ready to update.
That said, we don't expect many things to be changing drastically after v1. As you can imagine Typescript allows the latest and greatest of JS so we will be ahead of the curve for years to come.
## Style Guide
Prettier is our style guide. No discussions around styling ever. The options are set and that is all. When you code let prettier handle the styling. PERIOD!

View File

@@ -1,31 +1,47 @@
import Client from '../module/Client.ts'
import Client from "../module/Client.ts";
import { RequestMethod } from "../types/fetch";
type RequestBody = string | Blob | ArrayBufferView | ArrayBuffer | FormData | URLSearchParams | null | undefined;
class RequestManager {
client: Client
token: string
client: Client;
token: string;
constructor(client: Client, token: string) {
this.client = client
this.token = token
}
async get(url: string, payload?: unknown) {
// THIS IS IMPORTANT. It keeps clean stack errors in the users own files to better help debug errors.
// const stackHolder = {};
// TODO: Figure out why this doesnt work
// Error.captureStackTrace(stackHolder)
async get(url: string) {
const headers = this.getDiscordHeaders();
return fetch(url, { headers }).then(res => res.json())
}
// let attempts = 0
const headers = {
Authorization: this.token,
'User-Agent': `DiscordBot (https://github.com/skillz4killz/discordeno, 0.0.1)`
}
async post (url: string, body: RequestBody) {
const headers = this.getDiscordHeaders();
return fetch(url, {
method: RequestMethod.Post,
headers,
body
});
}
console.log('payload', payload)
async delete (url: string, body: RequestBody) {
const headers = this.getDiscordHeaders();
return fetch(url, {
method: RequestMethod.Delete,
headers,
body
});
}
const data = await fetch(url, { headers }).then(res => res.json())
return data
}
// The Record type here plays nice with Deno's `fetch.headers` expected type.
getDiscordHeaders (): Record<string, string> {
return {
Authorization: this.token,
"User-Agent": `DiscordBot (https://github.com/skillz4killz/discordeno, 0.0.1)`,
};
}
}
export default RequestManager

24
mod.ts
View File

@@ -1,7 +1,21 @@
import Client from './module/Client.ts'
import { configs } from './configs.ts'
import Client from "./module/Client.ts"
import { configs } from "./configs.ts"
import { StatusType, GatewayOpcode } from "./types/discord.ts";
const Discordeno = new Client(configs.token)
Discordeno.connect()
(async function () {
const client = new Client({
token: configs.token
});
export default Discordeno
const { gateway, connection } = await client.bootstrap();
for await (const message of connection) {
if (message.data?.op === GatewayOpcode.Hello) {
await message.action;
await gateway.updateStatus({
afk: false,
status: StatusType.DoNotDisturb
})
}
}
})();

View File

@@ -1,6 +1,6 @@
import { endpoints } from '../constants/discord.ts'
import RequestManager from '../managers/RequestManager.ts'
import { DiscordBotGateway, DiscordPayload, DiscordHeartbeatPayload } from '../types/discord.ts'
import { DiscordBotGatewayData, DiscordPayload, DiscordHeartbeatPayload, GatewayOpcode } from '../types/discord.ts'
import ShardingManager from '../managers/ShardingManager.ts'
import {
connectWebSocket,
@@ -9,11 +9,9 @@ import {
isWebSocketPongEvent,
WebSocket
} from 'https://deno.land/std/ws/mod.ts'
// import { encode } from "https://deno.land/std/strings/mod.ts"
// import { BufReader } from "https://deno.land/std/io/bufio.ts"
// import { TextProtoReader } from "https://deno.land/std/textproto/mod.ts"
import { keepDiscordWebsocketAlive, updatePreviousSequenceNumber } from './websocket.ts'
import { logGreen, logRed, logYellow, logBlue } from '../utils/logger.ts'
import Gateway from './gateway.ts'
import { ClientOptions, FulfilledClientOptions } from '../types/options.ts'
import { CollectedMessageType } from '../types/message-type.ts'
class Client {
/** The bot's token. This should never be used by end users. It is meant to be used internally to make requests to the Discord API. */
@@ -23,35 +21,94 @@ class Client {
/** Creates and handles all the shards necessary for the bot. */
ShardingManager: ShardingManager
constructor(token: string) {
this.token = `Bot ${token}`
this.RequestManager = new RequestManager(this, this.token)
/** The options (with defaults) passed to the `Client` constructor. */
options: FulfilledClientOptions
protected authorization: string
constructor(options: ClientOptions) {
// Assign some defaults to the options to make them fulfilled / not annoying to use.
this.options = Object.assign(
{
properties: {
$os: '...',
$browser: '...',
$device: '...'
},
compress: false
},
options
)
this.token = options.token
this.authorization = `Bot ${this.options.token}`
this.RequestManager = new RequestManager(this, this.authorization)
this.ShardingManager = new ShardingManager()
}
/** Begins initial handshake, creates the websocket with Discord and spawns all necessary shards. */
async connect() {
const data = (await this.RequestManager.get(endpoints.GATEWAY_BOT)) as DiscordBotGateway
// Open a WS with the url from discord.
const sock = await connectWebSocket(data.url)
console.log(sock)
logGreen("ws connected! (type 'close' to quit)")
getGatewayData() {
return this.RequestManager.get(endpoints.GATEWAY_BOT) as Promise<DiscordBotGatewayData>
}
for await (const msg of sock.receive()) {
if (typeof msg === 'string') {
try {
const json = JSON.parse(msg)
this.handleDiscordPayload(json, sock)
} catch {
logRed(`Invalid JSON String send by discord: ${msg}`)
createWebsocketConnection(data: DiscordBotGatewayData) {
console.log({ data })
return connectWebSocket(data.url)
}
async bootstrap() {
const data = await this.getGatewayData()
const socket = await this.createWebsocketConnection(data);
const gateway = new Gateway(socket);
const messages = this.collectMessages(gateway);
await gateway.identify(this.options);
return {
data,
socket,
gateway,
messages,
connection: this.connect(gateway, data)
}
}
async *collectMessages(gateway: Gateway) {
const { socket } = gateway
for await (const message of socket.receive()) {
if (typeof message === 'string') {
yield {
type: CollectedMessageType.Message,
data: JSON.parse(message)
}
logYellow('< ' + msg)
} else if (isWebSocketPingEvent(msg)) {
logBlue('< ping')
} else if (isWebSocketPongEvent(msg)) {
logBlue('< pong')
} else if (isWebSocketCloseEvent(msg)) {
logRed(`closed: code=${msg.code}, reason=${msg.reason}`)
} else if (isWebSocketCloseEvent(message)) {
yield { type: CollectedMessageType.Close, ...message }
return
} else if (isWebSocketPingEvent(message)) {
yield { type: CollectedMessageType.Ping }
} else if (isWebSocketPongEvent(message)) {
yield { type: CollectedMessageType.Pong }
}
}
}
/** Begins initial handshake, creates the websocket with Discord and spawns all necessary shards. */
async *connect(gateway: Gateway, data: DiscordBotGatewayData): AsyncGenerator<{ type: CollectedMessageType, data?: DiscordPayload, action?: Promise<void> }> {
for await (const message of this.collectMessages(gateway)) {
switch (message.type) {
case CollectedMessageType.Ping:
console.log('Ping!')
yield message;
break
case CollectedMessageType.Pong:
console.log('Pong!')
yield message;
break
case CollectedMessageType.Close:
console.log('Close :(', message)
yield message;
break
case CollectedMessageType.Message:
await this.handleDiscordPayload(message.data, gateway);
yield message;
console.log({ yay: true, ...message });
break
}
}
@@ -59,15 +116,15 @@ class Client {
this.spawnShards(data.shards)
}
handleDiscordPayload(data: DiscordPayload, socket: WebSocket) {
handleDiscordPayload(data: DiscordPayload, gateway: Gateway) {
switch (data.op) {
case 10: // Initial Heartbeat
keepDiscordWebsocketAlive(socket, (data.d as DiscordHeartbeatPayload).heartbeat_interval, data.s)
break
case 11:
updatePreviousSequenceNumber(data.s)
break
}
case GatewayOpcode.Hello:
console.log('heartbeating...');
return gateway.sendConstantHeartbeats((data.d as DiscordHeartbeatPayload).heartbeat_interval, data.s);
}
// Make all code paths return a promise for consistency.
return Promise.resolve(undefined);
}
spawnShards(total: number, id = 1) {

3
module/Ratelimiter.ts Normal file
View File

@@ -0,0 +1,3 @@
export class Ratelimiter {
}

56
module/gateway.ts Normal file
View File

@@ -0,0 +1,56 @@
import {
connectWebSocket,
isWebSocketCloseEvent,
isWebSocketPingEvent,
isWebSocketPongEvent,
WebSocket
} from "https://deno.land/std/ws/mod.ts";
import { GatewayOpcode, Status } from "../types/discord.ts";
import { FulfilledClientOptions } from "../types/options.ts";
import { delay } from 'https://deno.land/std/util/async.ts';
export default class Gateway {
constructor (public socket: WebSocket) {}
identify (options: FulfilledClientOptions) {
return this.sendObject({
op: GatewayOpcode.Identify,
d: {
token: options.token,
// TOOD: Let's get compression working, eh?
compress: false,
properties: options.properties
}
});
}
sendHeartbeat (previousSequenceNumber: number | null = null) {
return this.sendObject({
op: GatewayOpcode.Heartbeat,
d: previousSequenceNumber
});
}
updateStatus (status: Status) {
this.sendObject({
op: GatewayOpcode.StatusUpdate,
d: status
});
}
async sendConstantHeartbeats (interval: number, previousSequenceNumber: number | null = null, shouldContinue: () => boolean = () => true): Promise<void> {
await delay(interval);
if (!shouldContinue()) {
return;
}
// TODO: If the initial seq num is null, this will make it forever null until a restart. Is this good?
this.sendHeartbeat(previousSequenceNumber === null ? previousSequenceNumber : previousSequenceNumber++);
return this.sendConstantHeartbeats(interval, previousSequenceNumber);
}
sendObject (object: object) {
return this.socket.send(JSON.stringify(object));
}
}

View File

@@ -1,24 +0,0 @@
import { WebSocket } from 'https://deno.land/std/ws/mod.ts'
let previousSequenceNumber: number | null = null
export const keepDiscordWebsocketAlive = (
socket: WebSocket,
millesecondsInterval: number,
payload: number | null = null
) => {
previousSequenceNumber = payload
setInterval(() => {
socket.send(
JSON.stringify({
op: 1,
d: previousSequenceNumber
})
)
}, millesecondsInterval)
}
export const updatePreviousSequenceNumber = (sequence: number | null = null) => {
previousSequenceNumber = sequence
}

20
structures/activity.ts Normal file
View File

@@ -0,0 +1,20 @@
import { Timestamps } from "../types/discord.ts";
export interface ActivityPayload {
/** The activity's name */
name: string;
/** */
type: number;
url?: string;
created_at: number;
timestamps: Timestamps;
details?: string;
}
export enum ActivityType {
Game,
Streaming,
Listening,
Custom = 4
}

View File

@@ -1,3 +1,9 @@
export interface EmojiPayload {
name: string;
id?: string;
animated?: boolean;
}
export const createEmoji = (data: unknown) => {
console.log(data)
}

View File

@@ -1,3 +1,48 @@
import { UserPayload } from "./user.ts";
import { ActivityPayload } from "./activity";
import { StatusType } from "../types/discord";
export type PresencePayload = Partial<{
/** The user presence is being updated for */
user: UserPayload;
/** Roles this user is in */
roles: string[];
/** Null, or the user's current activity */
game: ActivityPayload;
/** Id of the guild */
guild_id: string;
// This is a deviation from the docs, as it pretty much says `: StatusType`.
/** The updated status */
status: StatusType;
/** User's current activities */
activities: ActivityPayload[];
/** User's platform-dependent status */
client_status: ClientStatusPayload;
/** When the user used their Nitro boost on the server */
premium_since: string;
/** This users guild nickname (if one is set) */
nick: string;
}> & { id: string };
export interface ClientStatusPayload {
/** The user's status set for an active desktop (Windows, Linux, Mac) application session */
desktop?: StatusType;
/** The user's status set for an active mobile (iOS, Android) application session */
mobile?: StatusType;
/** The user's status set for an active web (browser, bot account) application session */
web?: StatusType;
}
export const createPresence = (data: unknown) => {
console.log(data)
}

34
structures/user.ts Normal file
View File

@@ -0,0 +1,34 @@
export interface UserPayload {
/** The user's id */
id: string;
/** The user's username, not unique across the platform */
username: string;
/** The user's 4-digit discord-tag */
discriminator: string;
/** The user's avatar hash */
avatar: string;
/** Whether the user belongs to an OAuth2 application */
bot?: boolean;
/** Whether the user is an Official Discord System user (part of the urgent message system) */
system?: boolean;
/** Whether the user has two factor enabled on their account */
mfa_enabled?: boolean;
// Types with "email" scope intentionally left out.
/** The flags on a user's account */
flags?: number;
/** The type of Nitro subscription on a user's account */
premium_type?: PremiumType;
}
export const enum PremiumType {
NitroClassic = 1,
Nitro
}

View File

@@ -1,14 +1,66 @@
{
"compilerOptions": {
"target": "esnext",
"strict": true /* Enable all strict type-checking options. */,
"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. */,
"esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */,
"experimentalDecorators": true /* Enables experimental support for ES7 decorators. */,
"emitDecoratorMetadata": true /* Enables experimental support for emitting type metadata for decorators. */,
"forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
/* Basic Options */
// "incremental": true, /* Enable incremental compilation */
"target": "ESNext", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */
"module": "ESNext", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */
"lib": ["ES7", "DOM"], /* 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": "./", /* Redirect output structure to the directory. */
// "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
// "composite": true, /* Enable project compilation */
// "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
// "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": true, /* 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). */
// Hack to stop VSCode from suggesting imports without ./ or ../ as a prefix.
"baseUrl": "../../", /* Base directory to resolve non-absolute module names. */
// "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. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
/* 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. */
/* Advanced Options */
"forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
}
}

View File

@@ -9,20 +9,20 @@ export interface DiscordPayload {
t?: string
}
export interface DiscordBotGateway {
/** The WSS URL that can be used for connecting to the gateway. */
url: string
/** The recommended number of shards to use when connecting. */
shards: number
/** Info on the current start limit. */
session_start_limit: {
/** The total number of session starts the current user is allowed. */
total: number
/** The remaining number of session starts the current user is allowed. */
remaining: number
/** Milliseconds left until limit is reset. */
reset_after: number
}
export interface DiscordBotGatewayData {
/** The WSS URL that can be used for connecting to the gateway. */
url: string
/** The recommended number of shards to use when connecting. */
shards: number
/** Info on the current start limit. */
session_start_limit: {
/** The total number of session starts the current user is allowed. */
total: number
/** The remaining number of session starts the current user is allowed. */
remaining: number
/** Milliseconds left until limit is reset. */
reset_after: number
}
}
export interface DiscordHeartbeatPayload {
@@ -30,17 +30,17 @@ export interface DiscordHeartbeatPayload {
}
export enum GatewayOpcode {
Dispatch = 0,
Heartbeat,
Identify,
StatusUpdate,
VoiceStateUpdate,
Resume,
Reconnect,
RequestGuildMembers,
InvalidSession,
Hello,
HeartbeatACK
Dispatch = 0,
Heartbeat,
Identify,
StatusUpdate,
VoiceStateUpdate,
Resume = 6,
Reconnect,
RequestGuildMembers,
InvalidSession,
Hello,
HeartbeatACK
}
export enum GatewayCloseEventCode {
@@ -154,3 +154,33 @@ export enum JSONErrorCode {
ReactionBlocked = 90001,
ResourceOverloaded = 130000
}
export interface Properties {
$os: string;
$browser: string;
$device: string;
}
export interface Timestamps {
start?: number;
end?: number;
}
export interface Emoji {
name: string;
id?: string;
animated?: boolean;
}
export enum StatusType {
Online = 'online',
DoNotDisturb = 'dnd',
Idle = 'idle',
Invisible = 'invisible',
Offline = 'offline'
}
export interface Status {
afk: boolean;
status: StatusType;
}

8
types/fetch.ts Normal file
View File

@@ -0,0 +1,8 @@
export const enum RequestMethod {
Get = 'get',
Post = 'post',
Put = 'put',
Patch = 'patch',
Head = 'head',
Delete = 'delete'
}

6
types/message-type.ts Normal file
View File

@@ -0,0 +1,6 @@
export enum CollectedMessageType {
Ping,
Pong,
Close,
Message
}

13
types/options.ts Normal file
View File

@@ -0,0 +1,13 @@
import { Properties } from "./discord.ts";
export interface FulfilledClientOptions {
token: string;
properties: Properties;
compress: boolean;
}
export interface ClientOptions {
token: string;
properties?: Properties;
compress?: boolean;
}

38
types/queue.ts Normal file
View File

@@ -0,0 +1,38 @@
import { DiscordPayload } from "./discord";
import Gateway from "../module/gateway.ts";
export abstract class ActionQueue<Action> {
protected actions: Action[] = [];
push (action: Action) {
if (this.shouldDispatchImmediately(action)) {
this.dispatch(action);
} else {
this.actions.push(action);
}
}
dispatchAll () {
let index = 0;
for (const action of this.actions) {
this.actions.splice(index, 1);
this.dispatch(action);
index++;
}
}
abstract dispatch (action: Action): void;
abstract shouldDispatchImmediately (action: Action): boolean;
}
export class GatewayActionQueue extends ActionQueue<DiscordPayload> {
constructor (protected gateway: Gateway) {
super();
}
dispatch (action: DiscordPayload) {
this.gateway.sendObject(action);
}
shouldDispatchImmediately ()
}