diff --git a/managers/RequestManager.ts b/managers/RequestManager.ts index b58f6b38c..6cf220df5 100644 --- a/managers/RequestManager.ts +++ b/managers/RequestManager.ts @@ -1,31 +1,33 @@ import Client from "../module/Client.ts"; class RequestManager { - client: Client - token: string + client: Client; + token: string; + currentRatelimit 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, payload?: unknown, shouldRatelimit = true) { + if (shouldRatelimit) { - // let attempts = 0 - const headers = { - Authorization: this.token, - "User-Agent": `DiscordBot (https://github.com/skillz4killz/discordeno, 0.0.1)`, } - + const headers = this.getDiscordHeaders(); console.log('payload', payload) 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 { + return { + Authorization: this.token, + "User-Agent": `DiscordBot (https://github.com/skillz4killz/discordeno, 0.0.1)`, + }; + } } export default RequestManager \ No newline at end of file diff --git a/mod.ts b/mod.ts index 5dfb40389..f09abe28a 100644 --- a/mod.ts +++ b/mod.ts @@ -1,7 +1,22 @@ 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 () { + console.log({ configs }); + const client = new Client({ + token: configs.token + }); -export default Discordeno \ No newline at end of file + 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 + }) + } + } +})(); diff --git a/module/Client.ts b/module/Client.ts index 6a60512a7..507c4db98 100644 --- a/module/Client.ts +++ b/module/Client.ts @@ -1,78 +1,141 @@ -import { endpoints } from "../constants/discord.ts"; -import RequestManager from "../managers/RequestManager.ts"; -import { DiscordBotGateway, DiscordPayload, DiscordHeartbeatPayload } from "../types/discord.ts"; -import ShardingManager from "../managers/ShardingManager.ts"; +import { endpoints } from '../constants/discord.ts' +import RequestManager from '../managers/RequestManager.ts' +import { DiscordBotGatewayData, DiscordPayload, DiscordHeartbeatPayload, GatewayOpcode } from '../types/discord.ts' +import ShardingManager from '../managers/ShardingManager.ts' import { connectWebSocket, isWebSocketCloseEvent, isWebSocketPingEvent, isWebSocketPongEvent, WebSocket -} from "https://deno.land/std/ws/mod.ts"; +} 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 { blue, green, red, yellow } from "https://deno.land/std/fmt/colors.ts" -import { keepDiscordWebsocketAlive } from "./websocket.ts"; +import { blue, green, red, yellow } from 'https://deno.land/std/fmt/colors.ts' +import { keepDiscordWebsocketAlive } from './websocket.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. */ - token: string; + token: string /** The Rate limit manager to handle all outgoing requests to discord. Not meant to be used by users. */ - RequestManager: RequestManager; + RequestManager: RequestManager /** Creates and handles all the shards necessary for the bot. */ - ShardingManager: ShardingManager; + ShardingManager: ShardingManager - constructor(token: string) { - this.token = `Bot ${token}`; - this.RequestManager = new RequestManager(this, this.token); - this.ShardingManager = new ShardingManager(); + /** 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() + } + + getGatewayData() { + return this.RequestManager.get(endpoints.GATEWAY_BOT) as Promise + } + + 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) + } + } 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() { - 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) - console.log(green("ws connected! (type 'close' to quit)")); - - for await (const msg of sock.receive()) { - if (typeof msg === "string") { - try { - const json = JSON.parse(msg) - this.handleDiscordPayload(json, sock) - } catch { - console.log(red(`Invalid JSON String send by discord: ${msg}`)) - } - console.log(yellow("< " + msg)); - } else if (isWebSocketPingEvent(msg)) { - console.log(blue("< ping")); - } else if (isWebSocketPongEvent(msg)) { - console.log(blue("< pong")); - } else if (isWebSocketCloseEvent(msg)) { - console.log(red(`closed: code=${msg.code}, reason=${msg.reason}`)); + async *connect(gateway: Gateway, data: DiscordBotGatewayData): AsyncGenerator<{ type: CollectedMessageType, data?: DiscordPayload, action?: Promise }> { + 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 } } // Begin spawning all necessary shards - this.spawnShards(data.shards); + 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) - } + 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) { // this.ShardingManager.spawnShard(id); - if (id < total) this.spawnShards(total, id + 1); + if (id < total) this.spawnShards(total, id + 1) } } -export default Client; - +export default Client diff --git a/module/Ratelimiter.ts b/module/Ratelimiter.ts new file mode 100644 index 000000000..c7eb410b3 --- /dev/null +++ b/module/Ratelimiter.ts @@ -0,0 +1,3 @@ +export class Ratelimiter { + +} \ No newline at end of file diff --git a/module/gateway.ts b/module/gateway.ts new file mode 100644 index 000000000..e7dd3ddae --- /dev/null +++ b/module/gateway.ts @@ -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 { + 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)); + } +} \ No newline at end of file diff --git a/module/utilities.ts b/module/utilities.ts new file mode 100644 index 000000000..a02f88986 --- /dev/null +++ b/module/utilities.ts @@ -0,0 +1,2 @@ +import { delay } from 'https://deno.land/std/util/async.ts'; + diff --git a/module/websocket.ts b/module/websocket.ts index ac7d51f38..502de1fbb 100644 --- a/module/websocket.ts +++ b/module/websocket.ts @@ -1,13 +1,15 @@ import { WebSocket } from "https://deno.land/std/ws/mod.ts"; +import { GatewayOpcode } from "../types/discord.ts"; export const keepDiscordWebsocketAlive = (socket: WebSocket, millesecondsInterval: number, payload: number | null = null) => { let previousSequenceNumber = payload + let doneInitial = false; setInterval(async () => { const response = await socket.send(JSON.stringify({ op: 1, d: previousSequenceNumber - })) + })); console.log(response) diff --git a/types/discord.ts b/types/discord.ts index a2a85a4ab..e941938c7 100644 --- a/types/discord.ts +++ b/types/discord.ts @@ -9,7 +9,7 @@ export interface DiscordPayload { t?: string } -export interface DiscordBotGateway { +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. */ @@ -35,7 +35,7 @@ export enum GatewayOpcode { Identify, StatusUpdate, VoiceStateUpdate, - Resume, + Resume = 6, Reconnect, RequestGuildMembers, InvalidSession, @@ -154,3 +154,42 @@ 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 interface Activity { + name: string; + type: number; + url?: string; + created_at: number; + timestamps: Timestamps; + details?: string; +} + +export enum StatusType { + Online = 'online', + DoNotDisturb = 'dnd', + Idle = 'idle', + Invisible = 'invisible', + Offline = 'offline' +} + +export interface Status { + afk: boolean; + status: StatusType; +} \ No newline at end of file diff --git a/types/message-type.ts b/types/message-type.ts new file mode 100644 index 000000000..664478ebb --- /dev/null +++ b/types/message-type.ts @@ -0,0 +1,6 @@ +export enum CollectedMessageType { + Ping, + Pong, + Close, + Message +} diff --git a/types/options.ts b/types/options.ts new file mode 100644 index 000000000..10b108c55 --- /dev/null +++ b/types/options.ts @@ -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; +} diff --git a/types/queue.ts b/types/queue.ts new file mode 100644 index 000000000..d5dabbd47 --- /dev/null +++ b/types/queue.ts @@ -0,0 +1,38 @@ +import { DiscordPayload } from "./discord"; +import Gateway from "../module/gateway.ts"; + +export abstract class ActionQueue { + 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 { + constructor (protected gateway: Gateway) { + super(); + } + + dispatch (action: DiscordPayload) { + this.gateway.sendObject(action); + } + + shouldDispatchImmediately () +} \ No newline at end of file