2020-10-30 14:51:40 +00:00
|
|
|
import { unzlib } from 'https://deno.land/x/denoflate/mod.ts'
|
|
|
|
import { Client } from '../models/client.ts'
|
|
|
|
import {
|
|
|
|
DISCORD_GATEWAY_URL,
|
|
|
|
DISCORD_API_VERSION
|
|
|
|
} from '../consts/urlsAndVersions.ts'
|
|
|
|
import { GatewayResponse } from '../types/gatewayResponse.ts'
|
2020-11-02 06:58:23 +00:00
|
|
|
import {
|
|
|
|
GatewayOpcodes,
|
|
|
|
GatewayIntents,
|
|
|
|
GatewayCloseCodes
|
|
|
|
} from '../types/gateway.ts'
|
2020-10-30 14:51:40 +00:00
|
|
|
import { gatewayHandlers } from './handlers/index.ts'
|
2020-10-31 11:45:33 +00:00
|
|
|
import { GATEWAY_BOT } from '../types/endpoint.ts'
|
2020-11-01 11:22:09 +00:00
|
|
|
import { GatewayCache } from "../managers/GatewayCache.ts"
|
2020-11-02 07:27:14 +00:00
|
|
|
import { ClientActivityPayload } from "../structures/presence.ts"
|
2020-10-30 14:51:40 +00:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Handles Discord gateway connection.
|
|
|
|
* You should not use this and rather use Client class.
|
|
|
|
*
|
|
|
|
* @beta
|
|
|
|
*/
|
|
|
|
class Gateway {
|
|
|
|
websocket: WebSocket
|
|
|
|
token: string
|
|
|
|
intents: GatewayIntents[]
|
|
|
|
connected = false
|
|
|
|
initialized = false
|
|
|
|
heartbeatInterval = 0
|
|
|
|
heartbeatIntervalID?: number
|
|
|
|
sequenceID?: number
|
2020-10-31 11:45:33 +00:00
|
|
|
lastPingTimestamp = 0
|
2020-11-01 11:22:09 +00:00
|
|
|
sessionID?: string
|
2020-10-30 14:51:40 +00:00
|
|
|
private heartbeatServerResponded = false
|
|
|
|
client: Client
|
2020-11-01 11:22:09 +00:00
|
|
|
cache: GatewayCache
|
2020-10-30 14:51:40 +00:00
|
|
|
|
2020-11-02 07:27:14 +00:00
|
|
|
constructor(client: Client, token: string, intents: GatewayIntents[]) {
|
2020-10-30 14:51:40 +00:00
|
|
|
this.token = token
|
|
|
|
this.intents = intents
|
|
|
|
this.client = client
|
2020-11-01 11:22:09 +00:00
|
|
|
this.cache = new GatewayCache(client)
|
2020-10-30 14:51:40 +00:00
|
|
|
this.websocket = new WebSocket(
|
|
|
|
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
|
|
|
|
`${DISCORD_GATEWAY_URL}/?v=${DISCORD_API_VERSION}&encoding=json`,
|
|
|
|
[]
|
|
|
|
)
|
|
|
|
this.websocket.binaryType = 'arraybuffer'
|
|
|
|
this.websocket.onopen = this.onopen.bind(this)
|
|
|
|
this.websocket.onmessage = this.onmessage.bind(this)
|
|
|
|
this.websocket.onclose = this.onclose.bind(this)
|
|
|
|
this.websocket.onerror = this.onerror.bind(this)
|
|
|
|
}
|
|
|
|
|
2020-11-02 07:27:14 +00:00
|
|
|
private onopen(): void {
|
2020-10-30 14:51:40 +00:00
|
|
|
this.connected = true
|
2020-11-02 06:58:23 +00:00
|
|
|
this.debug('Connected to Gateway!')
|
2020-10-30 14:51:40 +00:00
|
|
|
}
|
|
|
|
|
2020-11-02 07:27:14 +00:00
|
|
|
private async onmessage(event: MessageEvent): Promise<void> {
|
2020-10-30 14:51:40 +00:00
|
|
|
let data = event.data
|
|
|
|
if (data instanceof ArrayBuffer) {
|
|
|
|
data = new Uint8Array(data)
|
|
|
|
}
|
|
|
|
if (data instanceof Uint8Array) {
|
|
|
|
data = unzlib(data)
|
|
|
|
data = new TextDecoder('utf-8').decode(data)
|
|
|
|
}
|
|
|
|
|
|
|
|
const { op, d, s, t }: GatewayResponse = JSON.parse(data)
|
|
|
|
|
|
|
|
switch (op) {
|
|
|
|
case GatewayOpcodes.HELLO:
|
|
|
|
this.heartbeatInterval = d.heartbeat_interval
|
2020-11-02 06:58:23 +00:00
|
|
|
this.debug(
|
|
|
|
`Received HELLO. Heartbeat Interval: ${this.heartbeatInterval}`
|
|
|
|
)
|
2020-10-30 14:51:40 +00:00
|
|
|
this.heartbeatIntervalID = setInterval(() => {
|
|
|
|
if (this.heartbeatServerResponded) {
|
|
|
|
this.heartbeatServerResponded = false
|
|
|
|
} else {
|
|
|
|
clearInterval(this.heartbeatIntervalID)
|
2020-11-02 06:58:23 +00:00
|
|
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
2020-11-01 11:22:09 +00:00
|
|
|
this.reconnect()
|
2020-10-30 14:51:40 +00:00
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2020-11-02 07:27:14 +00:00
|
|
|
this.send({
|
|
|
|
op: GatewayOpcodes.HEARTBEAT,
|
|
|
|
d: this.sequenceID ?? null
|
|
|
|
})
|
2020-10-31 11:45:33 +00:00
|
|
|
this.lastPingTimestamp = Date.now()
|
2020-10-30 14:51:40 +00:00
|
|
|
}, this.heartbeatInterval)
|
|
|
|
|
|
|
|
if (!this.initialized) {
|
|
|
|
this.initialized = true
|
2020-11-03 09:21:29 +00:00
|
|
|
await this.sendIdentify(this.client.forceNewSession)
|
2020-10-30 14:51:40 +00:00
|
|
|
} else {
|
2020-11-02 06:58:23 +00:00
|
|
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
2020-10-30 14:51:40 +00:00
|
|
|
this.sendResume()
|
|
|
|
}
|
|
|
|
break
|
|
|
|
|
|
|
|
case GatewayOpcodes.HEARTBEAT_ACK:
|
|
|
|
this.heartbeatServerResponded = true
|
2020-10-31 11:45:33 +00:00
|
|
|
this.client.ping = Date.now() - this.lastPingTimestamp
|
2020-11-02 06:58:23 +00:00
|
|
|
this.debug(
|
|
|
|
`Received Heartbeat Ack. Ping Recognized: ${this.client.ping}ms`
|
|
|
|
)
|
2020-10-30 14:51:40 +00:00
|
|
|
break
|
|
|
|
|
|
|
|
case GatewayOpcodes.INVALID_SESSION:
|
|
|
|
// Because we know this gonna be bool
|
2020-11-01 11:22:09 +00:00
|
|
|
this.debug(`Invalid Session! Identifying with forced new session`)
|
2020-10-30 14:51:40 +00:00
|
|
|
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
|
2020-11-02 06:58:23 +00:00
|
|
|
// eslint-disable-next-line @typescript-eslint/promise-function-async
|
2020-11-01 11:22:09 +00:00
|
|
|
setTimeout(() => this.sendIdentify(true), 3000)
|
2020-10-30 14:51:40 +00:00
|
|
|
break
|
|
|
|
|
|
|
|
case GatewayOpcodes.DISPATCH: {
|
|
|
|
this.heartbeatServerResponded = true
|
|
|
|
if (s !== null) {
|
|
|
|
this.sequenceID = s
|
2020-11-02 06:58:23 +00:00
|
|
|
await this.cache.set('seq', s)
|
2020-10-30 14:51:40 +00:00
|
|
|
}
|
|
|
|
if (t !== null && t !== undefined) {
|
|
|
|
const handler = gatewayHandlers[t]
|
|
|
|
|
|
|
|
if (handler !== undefined) {
|
|
|
|
handler(this, d)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
break
|
|
|
|
}
|
2020-11-01 11:22:09 +00:00
|
|
|
case GatewayOpcodes.RESUME: {
|
|
|
|
// this.token = d.token
|
|
|
|
this.sessionID = d.session_id
|
|
|
|
this.sequenceID = d.seq
|
2020-11-02 06:58:23 +00:00
|
|
|
await this.cache.set('seq', d.seq)
|
|
|
|
await this.cache.set('session_id', this.sessionID)
|
2020-11-01 11:22:09 +00:00
|
|
|
break
|
|
|
|
}
|
|
|
|
case GatewayOpcodes.RECONNECT: {
|
2020-11-02 06:58:23 +00:00
|
|
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
2020-11-01 11:22:09 +00:00
|
|
|
this.reconnect()
|
|
|
|
break
|
|
|
|
}
|
2020-10-30 14:51:40 +00:00
|
|
|
default:
|
|
|
|
break
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private onclose (event: CloseEvent): void {
|
2020-11-02 06:58:23 +00:00
|
|
|
this.debug(`Connection Closed with code: ${event.code}`)
|
2020-11-01 11:22:09 +00:00
|
|
|
|
2020-11-02 06:58:23 +00:00
|
|
|
if (event.code === GatewayCloseCodes.UNKNOWN_ERROR) {
|
|
|
|
this.debug('API has encountered Unknown Error. Reconnecting...')
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
2020-11-01 11:22:09 +00:00
|
|
|
this.reconnect()
|
2020-11-02 06:58:23 +00:00
|
|
|
} else if (event.code === GatewayCloseCodes.UNKNOWN_OPCODE) {
|
2020-11-01 11:22:09 +00:00
|
|
|
throw new Error("Unknown OP Code was sent. This shouldn't happen!")
|
2020-11-02 06:58:23 +00:00
|
|
|
} else if (event.code === GatewayCloseCodes.DECODE_ERROR) {
|
2020-11-01 11:22:09 +00:00
|
|
|
throw new Error("Invalid Payload was sent. This shouldn't happen!")
|
2020-11-02 06:58:23 +00:00
|
|
|
} else if (event.code === GatewayCloseCodes.NOT_AUTHENTICATED) {
|
|
|
|
throw new Error('Not Authorized: Payload was sent before Identifying.')
|
|
|
|
} else if (event.code === GatewayCloseCodes.AUTHENTICATION_FAILED) {
|
|
|
|
throw new Error('Invalid Token provided!')
|
|
|
|
} else if (event.code === GatewayCloseCodes.INVALID_SEQ) {
|
|
|
|
this.debug('Invalid Seq was sent. Reconnecting.')
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
2020-11-01 11:22:09 +00:00
|
|
|
this.reconnect()
|
2020-11-02 06:58:23 +00:00
|
|
|
} else if (event.code === GatewayCloseCodes.RATE_LIMITED) {
|
2020-11-01 11:22:09 +00:00
|
|
|
throw new Error("You're ratelimited. Calm down.")
|
2020-11-02 06:58:23 +00:00
|
|
|
} else if (event.code === GatewayCloseCodes.SESSION_TIMED_OUT) {
|
|
|
|
this.debug('Session Timeout. Reconnecting.')
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
2020-11-01 11:22:09 +00:00
|
|
|
this.reconnect(true)
|
2020-11-02 06:58:23 +00:00
|
|
|
} else if (event.code === GatewayCloseCodes.INVALID_SHARD) {
|
|
|
|
this.debug('Invalid Shard was sent. Reconnecting.')
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
2020-11-01 11:22:09 +00:00
|
|
|
this.reconnect()
|
2020-11-02 06:58:23 +00:00
|
|
|
} else if (event.code === GatewayCloseCodes.SHARDING_REQUIRED) {
|
2020-11-01 11:22:09 +00:00
|
|
|
throw new Error("Couldn't connect. Sharding is requried!")
|
2020-11-02 06:58:23 +00:00
|
|
|
} else if (event.code === GatewayCloseCodes.INVALID_API_VERSION) {
|
2020-11-01 11:22:09 +00:00
|
|
|
throw new Error("Invalid API Version was used. This shouldn't happen!")
|
2020-11-02 06:58:23 +00:00
|
|
|
} else if (event.code === GatewayCloseCodes.INVALID_INTENTS) {
|
|
|
|
throw new Error('Invalid Intents')
|
|
|
|
} else if (event.code === GatewayCloseCodes.DISALLOWED_INTENTS) {
|
2020-11-01 11:22:09 +00:00
|
|
|
throw new Error("Given Intents aren't allowed")
|
|
|
|
} else {
|
2020-11-02 06:58:23 +00:00
|
|
|
this.debug('Unknown Close code, probably connection error. Reconnecting.')
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
2020-11-01 11:22:09 +00:00
|
|
|
this.reconnect()
|
|
|
|
}
|
2020-10-30 14:51:40 +00:00
|
|
|
}
|
|
|
|
|
2020-11-02 07:27:14 +00:00
|
|
|
private onerror(event: Event | ErrorEvent): void {
|
2020-10-30 14:51:40 +00:00
|
|
|
const eventError = event as ErrorEvent
|
|
|
|
console.log(eventError)
|
|
|
|
}
|
|
|
|
|
2020-11-02 06:58:23 +00:00
|
|
|
private async sendIdentify (forceNewSession?: boolean): Promise<void> {
|
|
|
|
this.debug('Fetching /gateway/bot...')
|
|
|
|
const info = await this.client.rest.get(GATEWAY_BOT())
|
|
|
|
if (info.session_start_limit.remaining === 0)
|
|
|
|
throw new Error(
|
|
|
|
`Session Limit Reached. Retry After ${info.session_start_limit.reset_after}ms`
|
|
|
|
)
|
|
|
|
this.debug(`Recommended Shards: ${info.shards}`)
|
|
|
|
this.debug('=== Session Limit Info ===')
|
|
|
|
this.debug(
|
|
|
|
`Remaining: ${info.session_start_limit.remaining}/${info.session_start_limit.total}`
|
|
|
|
)
|
2020-10-31 11:45:33 +00:00
|
|
|
this.debug(`Reset After: ${info.session_start_limit.reset_after}ms`)
|
2020-11-02 06:58:23 +00:00
|
|
|
if (forceNewSession === undefined || !forceNewSession) {
|
|
|
|
const sessionIDCached = await this.cache.get('session_id')
|
|
|
|
if (sessionIDCached !== undefined) {
|
|
|
|
this.debug(`Found Cached SessionID: ${sessionIDCached}`)
|
2020-11-01 11:22:09 +00:00
|
|
|
this.sessionID = sessionIDCached
|
2020-11-02 06:58:23 +00:00
|
|
|
return await this.sendResume()
|
2020-11-01 11:22:09 +00:00
|
|
|
}
|
|
|
|
}
|
2020-11-02 07:27:14 +00:00
|
|
|
this.send({
|
|
|
|
op: GatewayOpcodes.IDENTIFY,
|
|
|
|
d: {
|
|
|
|
token: this.token,
|
|
|
|
properties: {
|
|
|
|
$os: Deno.build.os,
|
2020-11-03 15:19:20 +00:00
|
|
|
$browser: 'discord.deno', //TODO: Change lib name
|
2020-11-02 07:27:14 +00:00
|
|
|
$device: 'discord.deno'
|
|
|
|
},
|
|
|
|
compress: true,
|
|
|
|
shard: [0, 1], // TODO: Make sharding possible
|
|
|
|
intents: this.intents.reduce(
|
|
|
|
(previous, current) => previous | current,
|
|
|
|
0
|
|
|
|
),
|
|
|
|
presence: this.client.presence.create()
|
|
|
|
}
|
|
|
|
})
|
2020-10-30 14:51:40 +00:00
|
|
|
}
|
|
|
|
|
2020-11-02 07:27:14 +00:00
|
|
|
private async sendResume(): Promise<void> {
|
2020-10-31 11:45:33 +00:00
|
|
|
this.debug(`Preparing to resume with Session: ${this.sessionID}`)
|
2020-11-02 07:27:14 +00:00
|
|
|
if (this.sequenceID === undefined) {
|
2020-11-02 06:58:23 +00:00
|
|
|
const cached = await this.cache.get('seq')
|
|
|
|
if (cached !== undefined)
|
|
|
|
this.sequenceID = typeof cached === 'string' ? parseInt(cached) : cached
|
2020-11-01 11:22:09 +00:00
|
|
|
}
|
|
|
|
const resumePayload = {
|
|
|
|
op: GatewayOpcodes.RESUME,
|
|
|
|
d: {
|
|
|
|
token: this.token,
|
|
|
|
session_id: this.sessionID,
|
2020-11-02 06:58:23 +00:00
|
|
|
seq: this.sequenceID ?? null
|
2020-11-01 11:22:09 +00:00
|
|
|
}
|
|
|
|
}
|
2020-11-02 07:27:14 +00:00
|
|
|
this.send(resumePayload)
|
2020-10-30 14:51:40 +00:00
|
|
|
}
|
|
|
|
|
2020-11-02 06:58:23 +00:00
|
|
|
debug (msg: string): void {
|
|
|
|
this.client.debug('Gateway', msg)
|
2020-10-31 11:45:33 +00:00
|
|
|
}
|
|
|
|
|
2020-11-02 06:58:23 +00:00
|
|
|
async reconnect (forceNew?: boolean): Promise<void> {
|
2020-11-01 11:22:09 +00:00
|
|
|
clearInterval(this.heartbeatIntervalID)
|
2020-11-02 06:58:23 +00:00
|
|
|
if (forceNew === undefined || !forceNew)
|
|
|
|
await this.cache.delete('session_id')
|
2020-11-01 11:22:09 +00:00
|
|
|
this.close()
|
|
|
|
this.initWebsocket()
|
|
|
|
}
|
|
|
|
|
2020-11-02 07:27:14 +00:00
|
|
|
initWebsocket(): void {
|
2020-10-30 14:51:40 +00:00
|
|
|
this.websocket = new WebSocket(
|
|
|
|
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
|
|
|
|
`${DISCORD_GATEWAY_URL}/?v=${DISCORD_API_VERSION}&encoding=json`,
|
|
|
|
[]
|
|
|
|
)
|
|
|
|
this.websocket.binaryType = 'arraybuffer'
|
|
|
|
this.websocket.onopen = this.onopen.bind(this)
|
|
|
|
this.websocket.onmessage = this.onmessage.bind(this)
|
|
|
|
this.websocket.onclose = this.onclose.bind(this)
|
|
|
|
this.websocket.onerror = this.onerror.bind(this)
|
|
|
|
}
|
|
|
|
|
2020-11-02 07:27:14 +00:00
|
|
|
close(): void {
|
2020-10-30 14:51:40 +00:00
|
|
|
this.websocket.close(1000)
|
|
|
|
}
|
2020-11-02 07:27:14 +00:00
|
|
|
|
2020-11-03 09:21:29 +00:00
|
|
|
send(data: GatewayResponse): boolean {
|
|
|
|
if (this.websocket.readyState !== this.websocket.OPEN) return false
|
2020-11-02 07:27:14 +00:00
|
|
|
this.websocket.send(JSON.stringify({
|
|
|
|
op: data.op,
|
|
|
|
d: data.d,
|
2020-11-03 09:21:29 +00:00
|
|
|
s: typeof data.s === "number" ? data.s : null,
|
|
|
|
t: data.t === undefined ? null : data.t,
|
2020-11-02 07:27:14 +00:00
|
|
|
}))
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
|
2020-11-03 09:21:29 +00:00
|
|
|
sendPresence(data: ClientActivityPayload): void {
|
2020-11-02 07:27:14 +00:00
|
|
|
this.send({
|
|
|
|
op: GatewayOpcodes.PRESENCE_UPDATE,
|
|
|
|
d: data
|
|
|
|
})
|
|
|
|
}
|
2020-10-30 14:51:40 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
export type GatewayEventHandler = (gateway: Gateway, d: any) => void
|
|
|
|
|
|
|
|
export { Gateway }
|