Merge pull request #148 from DjDeveloperr/vc-fix

refactor: voice join/leave
This commit is contained in:
DjDeveloper 2021-05-06 19:01:10 +05:30 committed by GitHub
commit a25aef6400
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 419 additions and 121 deletions

View File

@ -1,4 +1,4 @@
import { Interaction } from './src/structures/interactions.ts '
import { Interaction } from './src/structures/interactions.ts'
import {
SlashCommandsManager,
SlashClient,

View File

@ -23,6 +23,7 @@ import type { VoiceRegion } from '../types/voice.ts'
import { fetchAuto } from '../../deps.ts'
import type { DMChannel } from '../structures/dmChannel.ts'
import { Template } from '../structures/template.ts'
import { VoiceManager } from './voice.ts'
/** OS related properties sent with Gateway Identify */
export interface ClientProperties {
@ -38,7 +39,7 @@ export interface ClientOptions {
/** Token of the Bot/User */
token?: string
/** Gateway Intents */
intents?: GatewayIntents[]
intents?: Array<GatewayIntents | keyof typeof GatewayIntents>
/** Cache Adapter to use, defaults to Collections one */
cache?: ICacheAdapter
/** Force New Session and don't use cached Session (by persistent caching) */
@ -77,10 +78,29 @@ export class Client extends HarmonyEventEmitter<ClientEvents> {
rest: RESTManager
/** User which Client logs in to, undefined until logs in */
user?: User
#token?: string
/** Token of the Bot/User */
token?: string
get token(): string | undefined {
return this.#token
}
set token(val: string | undefined) {
this.#token = val
}
/** Cache Adapter */
cache: ICacheAdapter = new DefaultCacheAdapter()
get cache(): ICacheAdapter {
return this.#cache
}
set cache(val: ICacheAdapter) {
this.#cache = val
}
#cache: ICacheAdapter = new DefaultCacheAdapter()
/** Gateway Intents */
intents?: GatewayIntents[]
/** Whether to force new session or not */
@ -91,21 +111,26 @@ export class Client extends HarmonyEventEmitter<ClientEvents> {
reactionCacheLifetime: number = 3600000
/** Whether to fetch Uncached Message of Reaction or not? */
fetchUncachedReactions: boolean = false
/** Client Properties */
clientProperties: ClientProperties
readonly clientProperties!: ClientProperties
/** Slash-Commands Management client */
slash: SlashClient
/** Whether to fetch Gateway info or not */
fetchGatewayInfo: boolean = true
/** Voice Connections Manager */
readonly voice = new VoiceManager(this)
/** Users Manager, containing all Users cached */
users: UsersManager = new UsersManager(this)
readonly users: UsersManager = new UsersManager(this)
/** Guilds Manager, providing cache & API interface to Guilds */
guilds: GuildManager = new GuildManager(this)
readonly guilds: GuildManager = new GuildManager(this)
/** Channels Manager, providing cache interface to Channels */
channels: ChannelsManager = new ChannelsManager(this)
readonly channels: ChannelsManager = new ChannelsManager(this)
/** Channels Manager, providing cache interface to Channels */
emojis: EmojisManager = new EmojisManager(this)
readonly emojis: EmojisManager = new EmojisManager(this)
/** Last READY timestamp */
upSince?: Date
@ -146,7 +171,9 @@ export class Client extends HarmonyEventEmitter<ClientEvents> {
super()
this._id = options.id
this.token = options.token
this.intents = options.intents
this.intents = options.intents?.map((e) =>
typeof e === 'string' ? GatewayIntents[e] : e
)
this.shards = new ShardManager(this)
this.forceNewSession = options.forceNewSession
if (options.cache !== undefined) this.cache = options.cache
@ -172,14 +199,17 @@ export class Client extends HarmonyEventEmitter<ClientEvents> {
;(this as any)._decoratedEvents = undefined
}
this.clientProperties =
options.clientProperties === undefined
? {
os: Deno.build.os,
browser: 'harmony',
device: 'harmony'
}
: options.clientProperties
Object.defineProperty(this, 'clientProperties', {
value:
options.clientProperties === undefined
? {
os: Deno.build.os,
browser: 'harmony',
device: 'harmony'
}
: options.clientProperties,
enumerable: false
})
if (options.shard !== undefined) this.shard = options.shard
if (options.shardCount !== undefined) this.shardCount = options.shardCount

116
src/client/voice.ts Normal file
View File

@ -0,0 +1,116 @@
import type { VoiceServerUpdateData } from '../gateway/handlers/mod.ts'
import type { VoiceChannel } from '../structures/guildVoiceChannel.ts'
import type { VoiceStateOptions } from '../gateway/mod.ts'
import { VoiceState } from '../structures/voiceState.ts'
import { ChannelTypes } from '../types/channel.ts'
import type { Guild } from '../structures/guild.ts'
import { HarmonyEventEmitter } from '../utils/events.ts'
import type { Client } from './client.ts'
export interface VoiceServerData extends VoiceServerUpdateData {
userID: string
sessionID: string
}
export interface VoiceChannelJoinOptions extends VoiceStateOptions {
timeout?: number
}
export class VoiceManager extends HarmonyEventEmitter<{
voiceStateUpdate: [VoiceState]
}> {
#pending = new Map<string, [number, CallableFunction]>()
readonly client!: Client
constructor(client: Client) {
super()
Object.defineProperty(this, 'client', {
value: client,
enumerable: false
})
}
async join(
channel: string | VoiceChannel,
options?: VoiceChannelJoinOptions
): Promise<VoiceServerData> {
const id = typeof channel === 'string' ? channel : channel.id
const chan = await this.client.channels.get<VoiceChannel>(id)
if (chan === undefined) throw new Error('Voice Channel not cached')
if (
chan.type !== ChannelTypes.GUILD_VOICE &&
chan.type !== ChannelTypes.GUILD_STAGE_VOICE
)
throw new Error('Cannot join non-voice channel')
const pending = this.#pending.get(chan.guild.id)
if (pending !== undefined) {
clearTimeout(pending[0])
pending[1](new Error('Voice Connection timed out'))
this.#pending.delete(chan.guild.id)
}
return await new Promise((resolve, reject) => {
let vcdata: VoiceServerData
let done = 0
const onVoiceStateAdd = (state: VoiceState): void => {
if (state.user.id !== this.client.user?.id) return
if (state.channel?.id !== id) return
this.client.off('voiceStateAdd', onVoiceStateAdd)
done++
vcdata = vcdata ?? {}
vcdata.sessionID = state.sessionID
vcdata.userID = state.user.id
if (done >= 2) {
this.#pending.delete(chan.guild.id)
resolve(vcdata)
}
}
const onVoiceServerUpdate = (data: VoiceServerUpdateData): void => {
if (data.guild.id !== chan.guild.id) return
vcdata = Object.assign(vcdata ?? {}, data)
this.client.off('voiceServerUpdate', onVoiceServerUpdate)
done++
if (done >= 2) {
this.#pending.delete(chan.guild.id)
resolve(vcdata)
}
}
this.client.shards
.get(chan.guild.shardID)!
.updateVoiceState(chan.guild.id, chan.id, options)
this.on('voiceStateUpdate', onVoiceStateAdd)
this.client.on('voiceServerUpdate', onVoiceServerUpdate)
const timer = setTimeout(() => {
if (done < 2) {
this.client.off('voiceServerUpdate', onVoiceServerUpdate)
this.client.off('voiceStateAdd', onVoiceStateAdd)
reject(
new Error(
"Connection timed out - couldn't connect to Voice Channel"
)
)
}
}, options?.timeout ?? 1000 * 30)
this.#pending.set(chan.guild.id, [timer, reject])
})
}
async leave(guildOrID: Guild | string): Promise<void> {
const id = typeof guildOrID === 'string' ? guildOrID : guildOrID.id
const guild = await this.client.guilds.get(id)
if (guild === undefined) throw new Error('Guild not cached')
// eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain
const vcs = await guild.voiceStates.get(this.client.user?.id!)
if (vcs === undefined) throw new Error('Not in Voice Channel')
this.client.shards.get(guild.shardID)!.updateVoiceState(guild, undefined)
}
}

View File

@ -33,17 +33,15 @@ export const voiceStateUpdate: GatewayEventHandler = async (
}
await guild.voiceStates.set(d.user_id, d)
const newVoiceState = await guild.voiceStates.get(d.user_id)
const newVoiceState = (await guild.voiceStates.get(d.user_id))!
if (d.user_id === gateway.client.user!.id) {
gateway.client.voice.emit('voiceStateUpdate', newVoiceState)
}
if (voiceState === undefined) {
gateway.client.emit(
'voiceStateAdd',
(newVoiceState as unknown) as VoiceState
)
gateway.client.emit('voiceStateAdd', newVoiceState)
} else {
gateway.client.emit(
'voiceStateUpdate',
voiceState,
(newVoiceState as unknown) as VoiceState
)
gateway.client.emit('voiceStateUpdate', voiceState, newVoiceState)
}
}

View File

@ -62,7 +62,7 @@ export class Gateway extends HarmonyEventEmitter<GatewayTypedEvents> {
lastPingTimestamp = 0
sessionID?: string
private heartbeatServerResponded = false
client: Client
client!: Client
cache: GatewayCache
private timedIdentify: number | null = null
shards?: number[]
@ -70,7 +70,7 @@ export class Gateway extends HarmonyEventEmitter<GatewayTypedEvents> {
constructor(client: Client, shards?: number[]) {
super()
this.client = client
Object.defineProperty(this, 'client', { value: client, enumerable: false })
this.cache = new GatewayCache(client)
this.shards = shards
}
@ -371,13 +371,13 @@ export class Gateway extends HarmonyEventEmitter<GatewayTypedEvents> {
: channel?.id,
self_mute:
channel === undefined
? undefined
? false
: voiceOptions.mute === undefined
? false
: voiceOptions.mute,
self_deaf:
channel === undefined
? undefined
? false
: voiceOptions.deaf === undefined
? false
: voiceOptions.deaf

View File

@ -50,11 +50,21 @@ export type SlashClientEvents = {
export class SlashClient extends HarmonyEventEmitter<SlashClientEvents> {
id: string | (() => string)
client?: Client
token?: string
#token?: string
get token(): string | undefined {
return this.#token
}
set token(val: string | undefined) {
this.#token = val
}
enabled: boolean = true
commands: SlashCommandsManager
handlers: SlashCommandHandler[] = []
rest: RESTManager
readonly rest!: RESTManager
modules: SlashModule[] = []
publicKey?: string
@ -65,7 +75,14 @@ export class SlashClient extends HarmonyEventEmitter<SlashClientEvents> {
if (id === undefined)
throw new Error('ID could not be found. Pass at least client or token')
this.id = id
this.client = options.client
if (options.client !== undefined) {
Object.defineProperty(this, 'client', {
value: options.client,
enumerable: false
})
}
this.token = options.token
this.publicKey = options.publicKey
@ -88,14 +105,17 @@ export class SlashClient extends HarmonyEventEmitter<SlashClientEvents> {
})
}
this.rest =
options.client === undefined
? options.rest === undefined
? new RESTManager({
token: this.token
})
: options.rest
: options.client.rest
Object.defineProperty(this, 'rest', {
value:
options.client === undefined
? options.rest === undefined
? new RESTManager({
token: this.token
})
: options.rest
: options.client.rest,
enumerable: false
})
this.client?.on(
'interactionCreate',

View File

@ -198,12 +198,15 @@ export class SlashBuilder {
/** Manages Slash Commands, allows fetching/modifying/deleting/creating Slash Commands. */
export class SlashCommandsManager {
slash: SlashClient
rest: RESTManager
readonly slash!: SlashClient
readonly rest!: RESTManager
constructor(client: SlashClient) {
this.slash = client
this.rest = client.rest
Object.defineProperty(this, 'slash', { value: client, enumerable: false })
Object.defineProperty(this, 'rest', {
enumerable: false,
value: client.rest
})
}
/** Get all Global Slash Commands */

View File

@ -1,4 +1,5 @@
import type { Client } from '../client/mod.ts'
import { Base } from '../structures/base.ts'
import { Collection } from '../utils/collection.ts'
/**
@ -6,15 +7,14 @@ import { Collection } from '../utils/collection.ts'
*
* You should not be making Managers yourself.
*/
export class BaseManager<T, T2> {
client: Client
export class BaseManager<T, T2> extends Base {
/** Caches Name or Key used to differentiate caches */
cacheName: string
/** Which data type does this cache have */
DataType: any
constructor(client: Client, cacheName: string, DataType: any) {
this.client = client
super(client)
this.cacheName = cacheName
this.DataType = DataType
}
@ -87,4 +87,8 @@ export class BaseManager<T, T2> {
flush(): any {
return this.client.cache.deleteCache(this.cacheName)
}
[Deno.customInspect](): string {
return `Manager(${this.cacheName})`
}
}

View File

@ -1,15 +1,15 @@
import type { Client } from '../client/mod.ts'
import { Base } from '../structures/base.ts'
import { Collection } from '../utils/collection.ts'
import { BaseManager } from './base.ts'
/** Child Managers validate data from their parents i.e. from Managers */
export class BaseChildManager<T, T2> {
client: Client
export class BaseChildManager<T, T2> extends Base {
/** Parent Manager */
parent: BaseManager<T, T2>
constructor(client: Client, parent: BaseManager<T, T2>) {
this.client = client
super(client)
this.parent = parent
}
@ -62,4 +62,8 @@ export class BaseChildManager<T, T2> {
if (fetchValue !== undefined) return fetchValue
}
}
[Deno.customInspect](): string {
return `ChildManager(${this.parent.cacheName})`
}
}

View File

@ -103,8 +103,18 @@ export class RESTManager {
* ```
*/
api: APIMap
#token?: string | (() => string | undefined)
/** Token being used for Authorization */
token?: string | (() => string | undefined)
get token(): string | (() => string | undefined) | undefined {
return this.#token
}
set token(val: string | (() => string | undefined) | undefined) {
this.#token = val
}
/** Token Type of the Token if any */
tokenType: TokenType = TokenType.Bot
/** Headers object which patch the current ones */
@ -114,13 +124,13 @@ export class RESTManager {
/** Whether REST Manager is using Canary API */
canary?: boolean
/** Optional Harmony Client object */
client?: Client
readonly client?: Client
endpoints: RESTEndpoints
requestTimeout = 30000
timers: Set<number> = new Set()
readonly timers!: Set<number>
apiURL = Constants.DISCORD_API_URL
handlers = new Collection<string, BucketHandler>()
readonly handlers!: Collection<string, BucketHandler>
globalLimit = Infinity
globalRemaining = this.globalLimit
globalReset: number | null = null
@ -136,11 +146,28 @@ export class RESTManager {
if (options?.tokenType !== undefined) this.tokenType = options.tokenType
if (options?.userAgent !== undefined) this.userAgent = options.userAgent
if (options?.canary !== undefined) this.canary = options.canary
if (options?.client !== undefined) this.client = options.client
if (options?.retryLimit !== undefined) this.retryLimit = options.retryLimit
if (options?.requestTimeout !== undefined)
this.requestTimeout = options.requestTimeout
if (options?.client !== undefined) {
Object.defineProperty(this, 'client', {
value: options.client,
enumerable: false
})
}
this.endpoints = new RESTEndpoints(this)
Object.defineProperty(this, 'timers', {
value: new Set(),
enumerable: false
})
Object.defineProperty(this, 'handlers', {
value: new Collection<string, BucketHandler>(),
enumerable: false
})
}
setTimeout(fn: (...args: any[]) => any, ms: number): number {

View File

@ -1,5 +1,3 @@
import type { VoiceServerUpdateData } from '../gateway/handlers/mod.ts'
import type { VoiceStateOptions } from '../gateway/mod.ts'
import type { Client } from '../client/mod.ts'
import type {
GuildVoiceChannelPayload,
@ -9,14 +7,13 @@ import type {
import { CHANNEL } from '../types/endpoint.ts'
import { GuildChannel } from './channel.ts'
import type { Guild } from './guild.ts'
import type { VoiceState } from './voiceState.ts'
import { GuildChannelVoiceStatesManager } from '../managers/guildChannelVoiceStates.ts'
import type { User } from './user.ts'
import type { Member } from './member.ts'
export interface VoiceServerData extends VoiceServerUpdateData {
sessionID: string
}
import type {
VoiceChannelJoinOptions,
VoiceServerData
} from '../client/voice.ts'
export class VoiceChannel extends GuildChannel {
bitrate: string
@ -34,65 +31,13 @@ export class VoiceChannel extends GuildChannel {
}
/** Join the Voice Channel */
async join(
options?: VoiceStateOptions & { onlyJoin?: boolean }
): Promise<VoiceServerData> {
return await new Promise((resolve, reject) => {
let vcdata: VoiceServerData
let sessionID: string
let done = 0
const onVoiceStateAdd = (state: VoiceState): void => {
if (state.user.id !== this.client.user?.id) return
if (state.channel?.id !== this.id) return
this.client.off('voiceStateAdd', onVoiceStateAdd)
done++
sessionID = state.sessionID
if (done >= 2) {
vcdata.sessionID = sessionID
if (options?.onlyJoin !== true) {
}
resolve(vcdata)
}
}
const onVoiceServerUpdate = (data: VoiceServerUpdateData): void => {
if (data.guild.id !== this.guild.id) return
vcdata = (data as unknown) as VoiceServerData
this.client.off('voiceServerUpdate', onVoiceServerUpdate)
done++
if (done >= 2) {
vcdata.sessionID = sessionID
resolve(vcdata)
}
}
this.client.shards
.get(this.guild.shardID)
?.updateVoiceState(this.guild.id, this.id, options)
this.client.on('voiceStateAdd', onVoiceStateAdd)
this.client.on('voiceServerUpdate', onVoiceServerUpdate)
setTimeout(() => {
if (done < 2) {
this.client.off('voiceServerUpdate', onVoiceServerUpdate)
this.client.off('voiceStateAdd', onVoiceStateAdd)
reject(
new Error(
"Connection timed out - couldn't connect to Voice Channel"
)
)
}
}, 1000 * 60)
})
async join(options?: VoiceChannelJoinOptions): Promise<VoiceServerData> {
return this.client.voice.join(this.id, options)
}
/** Leave the Voice Channel */
leave(): void {
this.client.shards
.get(this.guild.shardID)
?.updateVoiceState(this.guild.id, undefined)
async leave(): Promise<void> {
return this.client.voice.leave(this.guild)
}
readFromData(data: GuildVoiceChannelPayload): void {
@ -116,6 +61,14 @@ export class VoiceChannel extends GuildChannel {
return new VoiceChannel(this.client, resp, this.guild)
}
async setBitrate(rate: number | undefined): Promise<VoiceChannel> {
return await this.edit({ bitrate: rate })
}
async setUserLimit(limit: number | undefined): Promise<VoiceChannel> {
return await this.edit({ userLimit: limit })
}
async disconnectMember(
member: User | Member | string
): Promise<Member | undefined> {

30
test/eval.ts Normal file
View File

@ -0,0 +1,30 @@
/* eslint-disable no-eval */
import * as discord from '../mod.ts'
import { TOKEN } from './config.ts'
const client = new discord.Client({
token: TOKEN,
intents: ['GUILD_MESSAGES', 'GUILDS']
})
client.on('messageCreate', async (msg) => {
if (msg.author.id !== '422957901716652033') return
if (msg.content.startsWith('.eval') === true) {
let code = msg.content.slice(5).trim()
if (code.startsWith('```') === true) code = code.slice(3).trim()
if (code.endsWith('```') === true) code = code.substr(0, code.length - 3)
try {
const result = await eval(code)
msg.reply(
`\`\`\`js\n${Deno.inspect(result).substr(0, 2000 - 20)}\n\`\`\``
)
} catch (e) {
msg.reply(`\`\`\`js\n${e}\n\`\`\``, {
allowedMentions: { replied_user: false }
})
}
}
})
client.connect().then(() => console.log('Ready!'))

87
test/trigger.ts Normal file
View File

@ -0,0 +1,87 @@
/* eslint-disable @typescript-eslint/strict-boolean-expressions */
/* eslint-disable no-control-regex */
import { Client, Embed } from '../mod.ts'
import { TOKEN } from './config.ts'
const client = new Client({
token: TOKEN,
intents: ['GUILDS', 'GUILD_MESSAGES']
})
const NAME_MATCH = /[^a-zA-Z0-9_]/
const STD_REGEX = /\/?std(@[\x00-\x2e\x30-\xff]+)?\/([a-zA-Z0-9]+)(\/[\S\s]+)?/
const X_REGEX = /\/?x\/([a-zA-Z0-9]+)(@[\x00-\x2e\x30-\xff]+)?(\/[\S\s]+)?/
export async function fetchModule(name: string): Promise<any> {
if (name.match(NAME_MATCH) !== null) return null
return fetch(`https://api.deno.land/modules/${name}`, {
credentials: 'omit',
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:83.0) Gecko/20100101 Firefox/83.0',
Accept: 'application/json',
'Accept-Language': 'en-US,en;q=0.5',
Pragma: 'no-cache',
'Cache-Control': 'no-cache'
},
referrer: 'https://deno.land/x',
mode: 'cors'
})
.then((res) => {
if (res.status !== 200) throw new Error('not found')
return res
})
.then((r) => r.json())
.then((json) => {
if (!json.success) throw new Error('failed')
return json
})
.then((data) => data.data)
.catch(() => null)
}
client.on('messageCreate', async (msg) => {
if (msg.author.bot === true) return
let match
if (
(match = msg.content.match(STD_REGEX)) ||
(match = msg.content.match(X_REGEX))
) {
let x = match[0].trim()
if (!x.startsWith('/')) x = `/${x}`
let type
if (x.startsWith('/std')) {
x = x.slice(4)
type = 'std'
} else {
x = x.slice(3)
type = 'x'
}
x = x.trim()
const name = x.split('/')[0].split('@')[0]
const mod = await fetchModule(type === 'std' ? 'std' : name)
if (mod === null) return
msg.channel.send(
new Embed()
.setColor('#7289DA')
.setURL(
`https://deno.land/${type}${
x.startsWith('/') || x.startsWith('@') ? '' : '/'
}${x}`
)
.setTitle(
`${type}${x.startsWith('/') || x.startsWith('@') ? '' : '/'}${x}`
)
.setDescription(mod.description ?? 'No description.')
.setFooter(`${mod.star_count ?? 0}`, 'https://kokoro.pw/colleague.png')
)
}
})
console.log('Connecting...')
client.connect().then(() => console.log('Ready!'))

26
test/vc.ts Normal file
View File

@ -0,0 +1,26 @@
import * as discord from '../mod.ts'
import { TOKEN } from './config.ts'
const client = new discord.Client({
token: TOKEN,
intents: ['GUILDS', 'GUILD_VOICE_STATES', 'GUILD_MESSAGES']
})
client.on('messageCreate', async (msg) => {
if (msg.author.bot === true || msg.guild === undefined) return
if (msg.content === '!join') {
const vs = await msg.guild.voiceStates.get(msg.author.id)
if (vs === undefined) return msg.reply("You're not in Voice Channel!")
const data = await vs.channel!.join()
console.log(data)
msg.reply('Joined voice channel.')
} else if (msg.content === '!leave') {
const vs = await msg.guild.voiceStates.get(msg.client.user!.id!)
if (vs === undefined) return msg.reply("I'm not in Voice Channel!")
await vs.channel!.leave()
msg.reply('Left voice channel.')
}
})
client.connect().then(() => console.log('Ready!'))