mirror of
https://github.com/discordeno/discordeno.git
synced 2026-05-31 16:00:07 +00:00
495 lines
18 KiB
TypeScript
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
|