Files
discordeno/module/client.ts
2020-03-18 08:07:28 -04:00

495 lines
18 KiB
TypeScript

import { endpoints } from "../constants/discord.ts"
import {
DiscordBotGatewayData,
DiscordPayload,
DiscordHeartbeatPayload,
GatewayOpcode,
Webhook_Update_Payload,
Presence_Update_Payload,
Typing_Start_Payload,
Voice_State_Update_Payload
} from "../types/discord.ts"
import { spawnShards } from "./sharding_manager.ts"
import {
connectWebSocket,
isWebSocketCloseEvent,
isWebSocketPingEvent,
isWebSocketPongEvent,
WebSocket
} from "https://deno.land/std/ws/mod.ts"
import { Client_Options, Fulfilled_Client_Options, Event_Handlers } from "../types/options.ts"
import { CollectedMessageType } from "../types/message-type.ts"
import { send_constant_heartbeats, update_previous_sequence_number } from "./gateway.ts"
import { create_guild } from "../structures/guild.ts"
import {
handle_internal_guild_create,
handle_internal_guild_update,
handle_internal_guild_delete
} from "../events/guilds.ts"
import {
Create_Guild_Payload,
Guild_Delete_Payload,
Guild_Ban_Payload,
Guild_Emojis_Update_Payload,
Guild_Member_Add_Payload,
Guild_Member_Update_Payload,
Guild_Member_Chunk_Payload,
Guild_Role_Payload,
User_Payload
} from "../types/guild.ts"
import { Channel_Create_Payload } from "../types/channel.ts"
import {
handle_internal_channel_create,
handle_internal_channel_update,
handle_internal_channel_delete
} from "../events/channels.ts"
import { cache } from "../utils/cache.ts"
import { create_user } from "../structures/user.ts"
import { create_member } from "../structures/member.ts"
import { create_role } from "../structures/role.ts"
import { create_message } from "../structures/message.ts"
import {
Message_Create_Options,
Message_Delete_Payload,
Message_Delete_Bulk_Payload,
Message_Update_Payload,
Message_Reaction_Payload,
Base_Message_Reaction_Payload,
Message_Reaction_Remove_Emoji_Payload
} from "../types/message.ts"
import { logRed } from "../utils/logger.ts"
import { Request_Manager } from "./request_manager.ts"
const defaultOptions = {
properties: {
$os: "linux",
$browser: "Discordeno",
$device: "Discordeno"
},
compress: false
}
export let authorization = ""
export let event_handlers: Event_Handlers = {}
class Client {
bot_id: string
/** 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
/** The options (with defaults) passed to the `Client` constructor. */
options: Fulfilled_Client_Options
constructor(options: Client_Options) {
// Assign some defaults to the options to make them fulfilled / not annoying to use.
this.options = {
...defaultOptions,
...options,
intents: options.intents.reduce((bits, next) => (bits |= next), 0)
}
this.bot_id = options.bot_id
this.token = options.token
if (options.event_handlers) event_handlers = options.event_handlers
authorization = `Bot ${options.token}`
this.bootstrap()
}
async bootstrap() {
const data = (await Request_Manager.get(endpoints.GATEWAY_BOT)) as DiscordBotGatewayData
const socket = await connectWebSocket(data.url)
this.collectMessages(socket)
// Intial identify with the gateway
await socket.send(
JSON.stringify({
op: GatewayOpcode.Identify,
d: {
token: this.options.token,
// TODO: Let's get compression working, eh?
compress: false,
properties: this.options.properties,
intents: this.options.intents
}
})
)
for await (const _message of this.connect(socket, data)) {
}
}
async *collectMessages(socket: WebSocket) {
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(socket: WebSocket, data: DiscordBotGatewayData) {
for await (const message of this.collectMessages(socket)) {
switch (message.type) {
case CollectedMessageType.Ping:
logRed("Ping!")
yield message
break
case CollectedMessageType.Pong:
logRed("Pong!")
yield message
break
case CollectedMessageType.Close:
logRed(`Close :( ${message}`)
yield message
break
case CollectedMessageType.Message:
this.handleDiscordPayload(message.data, socket)
yield message
break
}
}
// Begin spawning all necessary shards
spawnShards(data.shards)
}
handleDiscordPayload(data: DiscordPayload, socket: WebSocket) {
// Update the sequence number if it is present so that heartbeating can be accurate
if (data.s) update_previous_sequence_number(data.s)
switch (data.op) {
case GatewayOpcode.Hello:
send_constant_heartbeats(socket, (data.d as DiscordHeartbeatPayload).heartbeat_interval)
return
case GatewayOpcode.HeartbeatACK:
// Incase the user wants to listen to heartbeat responses
return event_handlers.heartbeat?.()
case GatewayOpcode.Reconnect:
// TODO: Reconnect to the gateway https://discordapp.com/developers/docs/topics/gateway#reconnect
return
case GatewayOpcode.Dispatch:
if (data.t === "READY") return event_handlers.ready?.()
if (data.t === "CHANNEL_CREATE") return handle_internal_channel_create(data.d as Channel_Create_Payload, this)
if (data.t === "CHANNEL_UPDATE") return handle_internal_channel_update(data.d as Channel_Create_Payload, this)
if (data.t === "CHANNEL_DELETE") return handle_internal_channel_delete(data.d as Channel_Create_Payload)
if (data.t === "GUILD_CREATE") {
const guild = create_guild(data.d as Create_Guild_Payload, this)
handle_internal_guild_create(guild)
if (cache.unavailableGuilds.get(guild.id())) {
cache.unavailableGuilds.delete(guild.id())
return
}
return event_handlers.guild_create?.(guild)
}
if (data.t === "GUILD_UPDATE") {
const options = data.d as Create_Guild_Payload
const cached_guild = cache.guilds.get(options.id)
const guild = create_guild(options, this)
handle_internal_guild_update(guild)
if (!cached_guild) return
return event_handlers.guild_update?.(guild, cached_guild)
}
if (data.t === "GUILD_DELETE") {
const options = data.d as Guild_Delete_Payload
const guild = cache.guilds.get(options.id)
if (!guild) return
guild.channels.forEach((_channel, id) => cache.channels.delete(id))
if (options.unavailable) return cache.unavailableGuilds.set(options.id, Date.now())
handle_internal_guild_delete(guild)
return event_handlers.guild_delete?.(guild)
}
if (data.t && ["GUILD_BAN_ADD", "GUILD_BAN_REMOVE"].includes(data.t)) {
const options = data.d as Guild_Ban_Payload
const guild = cache.guilds.get(options.guild_id)
if (!guild) return
const user = create_user(options.user)
return data.t === "GUILD_BAN_ADD"
? event_handlers.guild_ban_add?.(guild, user)
: event_handlers.guild_ban_remove?.(guild, user)
}
if (data.t === "GUILD_EMOJIS_UPDATE") {
const options = data.d as Guild_Emojis_Update_Payload
const guild = cache.guilds.get(options.guild_id)
if (!guild) return
const cached_emojis = guild.emojis()
guild.emojis = () => options.emojis
return event_handlers.guild_emojis_update?.(guild, options.emojis, cached_emojis)
}
if (data.t === "GUILD_MEMBER_ADD") {
const options = data.d as Guild_Member_Add_Payload
const guild = cache.guilds.get(options.guild_id)
if (!guild) return
const member_count = guild.member_count() + 1
guild.member_count = () => member_count
const member = create_member(
options,
options.guild_id,
[...guild.roles().values()].map(role => role.raw()),
guild.owner_id(),
this
)
guild.members.set(options.user.id, member)
return event_handlers.guild_member_add?.(guild, member)
}
if (data.t === "GUILD_MEMBER_REMOVE") {
const options = data.d as Guild_Ban_Payload
const guild = cache.guilds.get(options.guild_id)
if (!guild) return
const member_count = guild.member_count() - 1
guild.member_count = () => member_count
const member = guild.members.get(options.user.id)
return event_handlers.guild_member_remove?.(guild, member || create_user(options.user))
}
if (data.t === "GUILD_MEMBER_UPDATE") {
const options = data.d as Guild_Member_Update_Payload
const guild = cache.guilds.get(options.guild_id)
if (!guild) return
const cached_member = guild.members.get(options.user.id)
const new_member_data = {
...options,
premium_since: options.premium_since || undefined,
joined_at: new Date(cached_member?.joined_at() || Date.now()).toISOString(),
deaf: cached_member?.deaf() || false,
mute: cached_member?.mute() || false
}
const member = create_member(
new_member_data,
options.guild_id,
[...guild.roles().values()].map(r => r.raw()),
guild.owner_id(),
this
)
guild.members.set(options.user.id, member)
if (cached_member?.nick() !== options.nick)
event_handlers.nickname_update?.(guild, member, options.nick, cached_member?.nick())
const role_ids = cached_member?.roles() || []
role_ids.forEach(id => {
if (!options.roles.includes(id)) event_handlers.role_lost?.(guild, member, id)
})
options.roles.forEach(id => {
if (!role_ids.includes(id)) event_handlers.role_gained?.(guild, member, id)
})
return event_handlers.guild_member_update?.(guild, member, cached_member)
}
if (data.t === "GUILD_MEMBERS_CHUNK") {
const options = data.d as Guild_Member_Chunk_Payload
const guild = cache.guilds.get(options.guild_id)
if (!guild) return
options.members.forEach(member =>
guild.members.set(
member.user.id,
create_member(
member,
options.guild_id,
[...guild.roles().values()].map(r => r.raw()),
guild.owner_id(),
this
)
)
)
}
if (data.t && ["GUILD_ROLE_CREATE", "GUILD_ROLE_DELETE", "GUILD_ROLE_UPDATE"].includes(data.t)) {
const options = data.d as Guild_Role_Payload
const guild = cache.guilds.get(options.guild_id)
if (!guild) return
if (data.t === "GUILD_ROLE_CREATE") {
const role = create_role(options.role)
const roles = guild.roles().set(options.role.id, role)
guild.roles = () => roles
return event_handlers.role_create?.(guild, role)
}
const cached_role = guild.roles().get(options.role.id)
if (!cached_role) return
if (data.t === "GUILD_ROLE_DELETE") {
const roles = guild.roles()
roles.delete(options.role.id)
guild.roles = () => roles
return event_handlers.role_delete?.(guild, cached_role)
}
if (data.t === "GUILD_ROLE_UPDATE") {
const role = create_role(options.role)
return event_handlers.role_update?.(guild, role, cached_role)
}
}
if (data.t === "MESSAGE_CREATE") {
const options = data.d as Message_Create_Options
const message = create_message(options, this)
const channel = message.channel()
if (channel) {
// channel.last_message_id = () => options.id
// if (channel.messages().size > 99) {
// TODO: LIMIT THIS TO 100 messages
// }
}
return event_handlers.message_create?.(message)
}
if (data.t && ["MESSAGE_DELETE", "MESSAGE_DELETE_BULK"].includes(data.t)) {
const options = data.d as Message_Delete_Payload
const deleted_messages =
data.t === "MESSAGE_DELETE" ? [options.id] : (data.d as Message_Delete_Bulk_Payload).ids
const channel = cache.channels.get(options.channel_id)
if (!channel) return
deleted_messages.forEach(id => {
console.log(id)
// const message = channel.messages().get(id)
// if (message) {
// // TODO: update the messages cache
// }
// return event_handlers.message_delete?.(message || { id, channel })
})
}
if (data.t === "MESSAGE_UPDATE") {
const options = data.d as Message_Update_Payload
const channel = cache.channels.get(options.channel_id)
if (!channel) return
// const cachedMessage = channel.messages().get(options.id)
// return event_handlers.message_update?.(message, cachedMessage)
}
if (data.t && ["MESSAGE_REACTION_ADD", "MESSAGE_REACTION_REMOVE"].includes(data.t)) {
const options = data.d as Message_Reaction_Payload
const message = cache.messages.get(options.message_id)
const isAdd = data.t === "MESSAGE_REACTION_ADD"
if (message) {
const previous_reactions = message.reactions()
const reaction_existed = previous_reactions.find(
reaction => reaction.emoji.id === options.emoji.id && reaction.emoji.name === options.emoji.name
)
if (reaction_existed)
reaction_existed.count = isAdd ? reaction_existed.count + 1 : reaction_existed.count - 1
else
message.reactions = () => [
...message.reactions(),
{
count: 1,
me: options.user_id === this.bot_id,
emoji: { ...options.emoji, id: options.emoji.id || undefined }
}
]
cache.messages.set(options.message_id, message)
}
return isAdd
? event_handlers.reaction_add?.(message || options, options.emoji, options.user_id)
: event_handlers.reaction_remove?.(message || options, options.emoji, options.user_id)
}
if (data.t === "MESSAGE_REACTION_REMOVE_ALL") {
return event_handlers.reaction_remove_all?.(data.d as Base_Message_Reaction_Payload)
}
if (data.t === "MESSAGE_REACTION_REMOVE_EMOJI") {
return event_handlers.reaction_remove_emoji?.(data.d as Message_Reaction_Remove_Emoji_Payload)
}
if (data.t === "PRESENCE_UPDATE") {
return event_handlers.presence_update?.(data.d as Presence_Update_Payload)
}
if (data.t === "TYPING_START") {
return event_handlers.typing_start?.(data.d as Typing_Start_Payload)
}
if (data.t === "USER_UPDATE") {
const user_data = data.d as User_Payload
const cached_user = cache.users.get(this.bot_id)
const user = create_user(user_data)
cache.users.set(user_data.id, user)
return event_handlers.bot_update?.(user, cached_user)
}
if (data.t === "VOICE_STATE_UPDATE") {
const payload = data.d as Voice_State_Update_Payload
if (!payload.guild_id) return
const guild = cache.guilds.get(payload.guild_id)
if (!guild) return
const member = guild.members.get(payload.user_id)
if (!member) return
const cached_state = guild.voice_states().find(state => state.user_id === payload.user_id)
// No cached state before so lets make one for em
if (!cached_state) return (guild.voice_states = () => [...guild.voice_states(), payload])
if (cached_state.channel_id !== payload.channel_id) {
// Either joined or moved channels
if (payload.channel_id) {
cached_state.channel_id
? // Was in a channel before
event_handlers.voice_channel_switch?.(member, payload.channel_id, cached_state.channel_id)
: // Was not in a channel before so user just joined
event_handlers.voice_channel_join?.(member, payload.channel_id)
}
// Left the channel
else if (cached_state.channel_id) {
event_handlers.voice_channel_leave?.(member, cached_state.channel_id)
}
}
return event_handlers.voice_state_update?.(member, payload)
}
if (data.t === "WEBHOOKS_UPDATE") {
const options = data.d as Webhook_Update_Payload
return event_handlers.webhooks_update?.(options.channel_id, options.guild_id)
}
return event_handlers.raw?.(data)
default:
return
}
}
}
export default Client