diff --git a/deploy.ts b/deploy.ts new file mode 100644 index 0000000..e5d149b --- /dev/null +++ b/deploy.ts @@ -0,0 +1,106 @@ +import { + SlashCommandsManager, + SlashClient, + SlashCommandHandlerCallback +} from './src/models/slashClient.ts' +import { InteractionResponseType, InteractionType } from './src/types/slash.ts' + +export interface DeploySlashInitOptions { + env?: boolean + publicKey?: string + token?: string + id?: string +} + +let client: SlashClient +let commands: SlashCommandsManager + +export function init(options: DeploySlashInitOptions): void { + if (client !== undefined) throw new Error('Already initialized') + if (options.env === true) { + options.publicKey = Deno.env.get('PUBLIC_KEY') + options.token = Deno.env.get('TOKEN') + options.id = Deno.env.get('ID') + } + + if (options.publicKey === undefined) + throw new Error('Public Key not provided') + + client = new SlashClient({ + id: options.id, + token: options.token, + publicKey: options.publicKey + }) + + commands = client.commands + + const cb = async (evt: { + respondWith: CallableFunction + request: Request + }): Promise => { + try { + const d = await client.verifyFetchEvent({ + respondWith: (...args: any[]) => evt.respondWith(...args), + request: evt.request, + }) + if (d === false) { + await evt.respondWith( + new Response('Not Authorized', { + status: 400 + }) + ) + return + } + + if (d.type === InteractionType.PING) { + await d.respond({ type: InteractionResponseType.PONG }) + client.emit('ping') + return + } + + await (client as any)._process(d) + } catch (e) { + console.log(e) + await client.emit('interactionError', e) + } + } + + addEventListener('fetch', cb as any) +} + +export function handle( + cmd: + | string + | { + name: string + parent?: string + group?: string + guild?: string + }, + handler: SlashCommandHandlerCallback +): void { + const handle = { + name: typeof cmd === 'string' ? cmd : cmd.name, + handler, + ...(typeof cmd === 'string' ? {} : cmd) + } + + if (typeof handle.name === 'string' && handle.name.includes(' ') && handle.parent === undefined && handle.group === undefined) { + const parts = handle.name.split(/ +/).filter(e => e !== '') + if (parts.length > 3 || parts.length < 1) throw new Error('Invalid command name') + const root = parts.shift() as string + const group = parts.length === 2 ? parts.shift() : undefined + const sub = parts.shift() + + handle.name = sub ?? root + handle.group = group + handle.parent = sub === undefined ? undefined : root + } + + client.handle(handle) +} + +export { commands, client } +export * from './src/types/slash.ts' +export * from './src/structures/slash.ts' +export * from './src/models/slashClient.ts' diff --git a/deps.ts b/deps.ts index e20a610..43fc90c 100644 --- a/deps.ts +++ b/deps.ts @@ -1,11 +1,6 @@ export { EventEmitter } from 'https://deno.land/x/event@0.2.1/mod.ts' export { unzlib } from 'https://denopkg.com/DjDeveloperr/denoflate@1.2/mod.ts' export { fetchAuto } from 'https://deno.land/x/fetchbase64@1.0.0/mod.ts' -export { connect } from 'https://deno.land/x/redis@v0.14.1/mod.ts' -export type { - Redis, - RedisConnectOptions -} from 'https://deno.land/x/redis@v0.14.1/mod.ts' export { walk } from 'https://deno.land/std@0.86.0/fs/walk.ts' export { join } from 'https://deno.land/std@0.86.0/path/mod.ts' export { Mixin } from 'https://esm.sh/ts-mixer@5.4.0' diff --git a/mod.ts b/mod.ts index 2382784..70faacb 100644 --- a/mod.ts +++ b/mod.ts @@ -5,7 +5,13 @@ export type { GatewayTypedEvents } from './src/gateway/index.ts' export type { ClientEvents } from './src/gateway/handlers/index.ts' export * from './src/models/client.ts' export * from './src/models/slashClient.ts' -export { RESTManager, TokenType, HttpResponseCode } from './src/models/rest.ts' +export { + RESTManager, + TokenType, + HttpResponseCode, + DiscordAPIError +} from './src/models/rest.ts' +export type { APIMap, DiscordAPIErrorPayload } from './src/models/rest.ts' export type { RequestHeaders } from './src/models/rest.ts' export type { RESTOptions } from './src/models/rest.ts' export * from './src/models/cacheAdapter.ts' @@ -63,7 +69,11 @@ export { NewsChannel } from './src/structures/guildNewsChannel.ts' export { VoiceChannel } from './src/structures/guildVoiceChannel.ts' export { Invite } from './src/structures/invite.ts' export * from './src/structures/member.ts' -export { Message, MessageAttachment } from './src/structures/message.ts' +export { + Message, + MessageAttachment, + MessageInteraction +} from './src/structures/message.ts' export { MessageMentions } from './src/structures/messageMentions.ts' export { Presence, @@ -110,6 +120,16 @@ export type { GuildVoiceChannelPayload, GroupDMChannelPayload, MessageOptions, + MessagePayload, + MessageInteractionPayload, + MessageReference, + MessageActivity, + MessageActivityTypes, + MessageApplication, + MessageFlags, + MessageStickerFormatTypes, + MessageStickerPayload, + MessageTypes, OverwriteAsArg, Overwrite, OverwriteAsOptions @@ -146,5 +166,7 @@ export { UserFlags } from './src/types/userFlags.ts' export type { VoiceStatePayload } from './src/types/voice.ts' export type { WebhookPayload } from './src/types/webhook.ts' export * from './src/models/collectors.ts' +export type { Dict } from './src/utils/dict.ts' +export * from './src/models/redisCache.ts' export { ColorUtil } from './src/utils/colorutil.ts' export type { Colors } from './src/utils/colorutil.ts' diff --git a/src/gateway/handlers/index.ts b/src/gateway/handlers/index.ts index 9775575..ba5dfa9 100644 --- a/src/gateway/handlers/index.ts +++ b/src/gateway/handlers/index.ts @@ -414,4 +414,5 @@ export type ClientEvents = { commandMissingArgs: [ctx: CommandContext] commandUsed: [ctx: CommandContext] commandError: [ctx: CommandContext, err: Error] + gatewayError: [err: ErrorEvent, shards: [number, number]] } diff --git a/src/gateway/handlers/interactionCreate.ts b/src/gateway/handlers/interactionCreate.ts index 4419eb1..064792a 100644 --- a/src/gateway/handlers/interactionCreate.ts +++ b/src/gateway/handlers/interactionCreate.ts @@ -1,29 +1,110 @@ +import { Guild } from '../../structures/guild.ts' import { Member } from '../../structures/member.ts' -import { Interaction } from '../../structures/slash.ts' +import { + Interaction, + InteractionApplicationCommandResolved, + InteractionChannel +} from '../../structures/slash.ts' import { GuildTextBasedChannel } from '../../structures/guildTextChannel.ts' import { InteractionPayload } from '../../types/slash.ts' +import { UserPayload } from '../../types/user.ts' +import { Permissions } from '../../utils/permissions.ts' import { Gateway, GatewayEventHandler } from '../index.ts' +import { User } from '../../structures/user.ts' +import { Role } from '../../structures/role.ts' export const interactionCreate: GatewayEventHandler = async ( gateway: Gateway, d: InteractionPayload ) => { - const guild = await gateway.client.guilds.get(d.guild_id) - if (guild === undefined) return + // NOTE(DjDeveloperr): Mason once mentioned that channel_id can be optional in Interaction. + // This case can be seen in future proofing Interactions, and one he mentioned was + // that bots will be able to add custom context menus. In that case, Interaction will not have it. + // Ref: https://github.com/discord/discord-api-docs/pull/2568/files#r569025697 + if (d.channel_id === undefined) return - await guild.members.set(d.member.user.id, d.member) - const member = ((await guild.members.get( - d.member.user.id - )) as unknown) as Member + const guild = + d.guild_id === undefined + ? undefined + : await gateway.client.guilds.get(d.guild_id) + + if (d.member !== undefined) + await guild?.members.set(d.member.user.id, d.member) + const member = + d.member !== undefined + ? (((await guild?.members.get(d.member.user.id)) as unknown) as Member) + : undefined + if (d.user !== undefined) await gateway.client.users.set(d.user.id, d.user) + const dmUser = + d.user !== undefined ? await gateway.client.users.get(d.user.id) : undefined + + const user = member !== undefined ? member.user : dmUser + if (user === undefined) return const channel = (await gateway.client.channels.get(d.channel_id)) ?? (await gateway.client.channels.fetch(d.channel_id)) + const resolved: InteractionApplicationCommandResolved = { + users: {}, + channels: {}, + members: {}, + roles: {} + } + + if (d.data?.resolved !== undefined) { + for (const [id, data] of Object.entries(d.data.resolved.users ?? {})) { + await gateway.client.users.set(id, data) + resolved.users[id] = ((await gateway.client.users.get( + id + )) as unknown) as User + if (resolved.members[id] !== undefined) + resolved.users[id].member = resolved.members[id] + } + + for (const [id, data] of Object.entries(d.data.resolved.members ?? {})) { + const roles = await guild?.roles.array() + let permissions = new Permissions(Permissions.DEFAULT) + if (roles !== undefined) { + const mRoles = roles.filter( + (r) => (data?.roles?.includes(r.id) as boolean) || r.id === guild?.id + ) + permissions = new Permissions(mRoles.map((r) => r.permissions)) + } + data.user = (d.data.resolved.users?.[id] as unknown) as UserPayload + resolved.members[id] = new Member( + gateway.client, + data, + resolved.users[id], + guild as Guild, + permissions + ) + } + + for (const [id, data] of Object.entries(d.data.resolved.roles ?? {})) { + if (guild !== undefined) { + await guild.roles.set(id, data) + resolved.roles[id] = ((await guild.roles.get(id)) as unknown) as Role + } else { + resolved.roles[id] = new Role( + gateway.client, + data, + (guild as unknown) as Guild + ) + } + } + + for (const [id, data] of Object.entries(d.data.resolved.channels ?? {})) { + resolved.channels[id] = new InteractionChannel(gateway.client, data) + } + } + const interaction = new Interaction(gateway.client, d, { member, guild, - channel + channel, + user, + resolved }) gateway.client.emit('interactionCreate', interaction) } diff --git a/src/gateway/index.ts b/src/gateway/index.ts index 955b61d..f80dfa5 100644 --- a/src/gateway/index.ts +++ b/src/gateway/index.ts @@ -7,7 +7,6 @@ import { import { GatewayResponse } from '../types/gatewayResponse.ts' import { GatewayOpcodes, - GatewayIntents, GatewayCloseCodes, IdentityPayload, StatusUpdatePayload, @@ -19,6 +18,7 @@ import { delay } from '../utils/delay.ts' import { VoiceChannel } from '../structures/guildVoiceChannel.ts' import { Guild } from '../structures/guild.ts' import { HarmonyEventEmitter } from '../utils/events.ts' +import { decodeText } from '../utils/encoding.ts' export interface RequestMembersOptions { limit?: number @@ -57,8 +57,6 @@ export type GatewayTypedEvents = { */ export class Gateway extends HarmonyEventEmitter { websocket?: WebSocket - token?: string - intents?: GatewayIntents[] connected = false initialized = false heartbeatInterval = 0 @@ -92,7 +90,7 @@ export class Gateway extends HarmonyEventEmitter { } if (data instanceof Uint8Array) { data = unzlib(data) - data = new TextDecoder('utf-8').decode(data) + data = decodeText(data) } const { op, d, s, t }: GatewayResponse = JSON.parse(data) @@ -157,7 +155,7 @@ export class Gateway extends HarmonyEventEmitter { const handler = gatewayHandlers[t] - if (handler !== undefined) { + if (handler !== undefined && d !== null) { handler(this, d) } } @@ -177,8 +175,8 @@ export class Gateway extends HarmonyEventEmitter { } case GatewayOpcodes.RECONNECT: { this.emit('reconnectRequired') - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.reconnect() + this.debug('Received OpCode RECONNECT') + await this.reconnect() break } default: @@ -194,8 +192,7 @@ export class Gateway extends HarmonyEventEmitter { switch (code) { case GatewayCloseCodes.UNKNOWN_ERROR: this.debug('API has encountered Unknown Error. Reconnecting...') - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.reconnect() + await this.reconnect() break case GatewayCloseCodes.UNKNOWN_OPCODE: throw new Error( @@ -209,20 +206,17 @@ export class Gateway extends HarmonyEventEmitter { throw new Error('Invalid Token provided!') case GatewayCloseCodes.INVALID_SEQ: this.debug('Invalid Seq was sent. Reconnecting.') - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.reconnect() + await this.reconnect() break case GatewayCloseCodes.RATE_LIMITED: throw new Error("You're ratelimited. Calm down.") case GatewayCloseCodes.SESSION_TIMED_OUT: this.debug('Session Timeout. Reconnecting.') - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.reconnect(true) + await this.reconnect(true) break case GatewayCloseCodes.INVALID_SHARD: this.debug('Invalid Shard was sent. Reconnecting.') - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.reconnect() + await this.reconnect() break case GatewayCloseCodes.SHARDING_REQUIRED: throw new Error("Couldn't connect. Sharding is required!") @@ -260,6 +254,7 @@ export class Gateway extends HarmonyEventEmitter { error.name = 'ErrorEvent' console.log(error) this.emit('error', error, event) + this.client.emit('gatewayError', event, this.shards) } private enqueueIdentify(forceNew?: boolean): void { @@ -269,8 +264,9 @@ export class Gateway extends HarmonyEventEmitter { } private async sendIdentify(forceNewSession?: boolean): Promise { - if (typeof this.token !== 'string') throw new Error('Token not specified') - if (typeof this.intents !== 'object') + if (typeof this.client.token !== 'string') + throw new Error('Token not specified') + if (typeof this.client.intents !== 'object') throw new Error('Intents not specified') if (this.client.fetchGatewayInfo === true) { @@ -300,7 +296,7 @@ export class Gateway extends HarmonyEventEmitter { } const payload: IdentityPayload = { - token: this.token, + token: this.client.token, properties: { $os: this.client.clientProperties.os ?? Deno.build.os, $browser: this.client.clientProperties.browser ?? 'harmony', @@ -311,7 +307,7 @@ export class Gateway extends HarmonyEventEmitter { this.shards === undefined ? [0, 1] : [this.shards[0] ?? 0, this.shards[1] ?? 1], - intents: this.intents.reduce( + intents: this.client.intents.reduce( (previous, current) => previous | current, 0 ), @@ -327,9 +323,8 @@ export class Gateway extends HarmonyEventEmitter { } private async sendResume(): Promise { - if (typeof this.token !== 'string') throw new Error('Token not specified') - if (typeof this.intents !== 'object') - throw new Error('Intents not specified') + if (typeof this.client.token !== 'string') + throw new Error('Token not specified') if (this.sessionID === undefined) { this.sessionID = await this.cache.get( @@ -348,7 +343,7 @@ export class Gateway extends HarmonyEventEmitter { const resumePayload = { op: GatewayOpcodes.RESUME, d: { - token: this.token, + token: this.client.token, session_id: this.sessionID, seq: this.sequenceID ?? null } @@ -405,6 +400,7 @@ export class Gateway extends HarmonyEventEmitter { async reconnect(forceNew?: boolean): Promise { this.emit('reconnecting') + this.debug('Reconnecting... (force new: ' + String(forceNew) + ')') clearInterval(this.heartbeatIntervalID) if (forceNew === true) { @@ -432,6 +428,11 @@ export class Gateway extends HarmonyEventEmitter { } close(code: number = 1000, reason?: string): void { + this.debug( + `Closing with code ${code}${ + reason !== undefined && reason !== '' ? ` and reason ${reason}` : '' + }` + ) return this.websocket?.close(code, reason) } diff --git a/src/models/cacheAdapter.ts b/src/models/cacheAdapter.ts index b9f5193..a04c149 100644 --- a/src/models/cacheAdapter.ts +++ b/src/models/cacheAdapter.ts @@ -1,5 +1,4 @@ import { Collection } from '../utils/collection.ts' -import { connect, Redis, RedisConnectOptions } from '../../deps.ts' /** * ICacheAdapter is the interface to be implemented by Cache Adapters for them to be usable with Harmony. @@ -71,106 +70,3 @@ export class DefaultCacheAdapter implements ICacheAdapter { return delete this.data[cacheName] } } - -/** Redis Cache Adapter for using Redis as a cache-provider. */ -export class RedisCacheAdapter implements ICacheAdapter { - _redis: Promise - redis?: Redis - ready: boolean = false - readonly _expireIntervalTimer: number = 5000 - private _expireInterval?: number - - constructor(options: RedisConnectOptions) { - this._redis = connect(options) - this._redis.then( - (redis) => { - this.redis = redis - this.ready = true - this._startExpireInterval() - }, - () => { - // TODO: Make error for this - } - ) - } - - private _startExpireInterval(): void { - this._expireInterval = setInterval(() => { - this.redis?.scan(0, { pattern: '*:expires' }).then(([_, names]) => { - for (const name of names) { - this.redis?.hvals(name).then((vals) => { - for (const val of vals) { - const expireVal: { - name: string - key: string - at: number - } = JSON.parse(val) - const expired = new Date().getTime() > expireVal.at - if (expired) this.redis?.hdel(expireVal.name, expireVal.key) - } - }) - } - }) - }, this._expireIntervalTimer) - } - - async _checkReady(): Promise { - if (!this.ready) await this._redis - } - - async get(cacheName: string, key: string): Promise { - await this._checkReady() - const cache = await this.redis?.hget(cacheName, key) - if (cache === undefined) return - try { - return JSON.parse(cache) - } catch (e) { - return cache - } - } - - async set( - cacheName: string, - key: string, - value: any, - expire?: number - ): Promise { - await this._checkReady() - const result = await this.redis?.hset( - cacheName, - key, - typeof value === 'object' ? JSON.stringify(value) : value - ) - if (expire !== undefined) { - await this.redis?.hset( - `${cacheName}:expires`, - key, - JSON.stringify({ - name: cacheName, - key, - at: new Date().getTime() + expire - }) - ) - } - return result - } - - async delete(cacheName: string, key: string): Promise { - await this._checkReady() - const exists = await this.redis?.hexists(cacheName, key) - if (exists === 0) return false - await this.redis?.hdel(cacheName, key) - return true - } - - async array(cacheName: string): Promise { - await this._checkReady() - const data = await this.redis?.hvals(cacheName) - return data?.map((e: string) => JSON.parse(e)) - } - - async deleteCache(cacheName: string): Promise { - await this._checkReady() - return (await this.redis?.del(cacheName)) !== 0 - } -} diff --git a/src/models/client.ts b/src/models/client.ts index 83846cd..dc40e24 100644 --- a/src/models/client.ts +++ b/src/models/client.ts @@ -13,7 +13,6 @@ import { ActivityGame, ClientActivity } from '../types/presence.ts' import { Extension } from './extensions.ts' import { SlashClient } from './slashClient.ts' import { Interaction } from '../structures/slash.ts' -import { SlashModule } from './slashModule.ts' import { ShardManager } from './shard.ts' import { Application } from '../structures/application.ts' import { Invite } from '../structures/invite.ts' @@ -190,10 +189,10 @@ export class Client extends HarmonyEventEmitter { this.clientProperties = options.clientProperties === undefined ? { - os: Deno.build.os, - browser: 'harmony', - device: 'harmony' - } + os: Deno.build.os, + browser: 'harmony', + device: 'harmony' + } : options.clientProperties if (options.shard !== undefined) this.shard = options.shard @@ -208,7 +207,7 @@ export class Client extends HarmonyEventEmitter { this.token = token this.debug('Info', 'Found token in ENV') } - } catch (e) {} + } catch (e) { } } const restOptions: RESTOptions = { @@ -436,59 +435,3 @@ export function event(name?: keyof ClientEvents) { client._decoratedEvents[key] = listener } } - -/** Decorator to create a Slash Command handler */ -export function slash(name?: string, guild?: string) { - return function (client: Client | SlashClient | SlashModule, prop: string) { - if (client._decoratedSlash === undefined) client._decoratedSlash = [] - const item = (client as { [name: string]: any })[prop] - if (typeof item !== 'function') { - throw new Error('@slash decorator requires a function') - } else - client._decoratedSlash.push({ - name: name ?? prop, - guild, - handler: item - }) - } -} - -/** Decorator to create a Sub-Slash Command handler */ -export function subslash(parent: string, name?: string, guild?: string) { - return function (client: Client | SlashModule | SlashClient, prop: string) { - if (client._decoratedSlash === undefined) client._decoratedSlash = [] - const item = (client as { [name: string]: any })[prop] - if (typeof item !== 'function') { - throw new Error('@subslash decorator requires a function') - } else - client._decoratedSlash.push({ - parent, - name: name ?? prop, - guild, - handler: item - }) - } -} - -/** Decorator to create a Grouped Slash Command handler */ -export function groupslash( - parent: string, - group: string, - name?: string, - guild?: string -) { - return function (client: Client | SlashModule | SlashClient, prop: string) { - if (client._decoratedSlash === undefined) client._decoratedSlash = [] - const item = (client as { [name: string]: any })[prop] - if (typeof item !== 'function') { - throw new Error('@groupslash decorator requires a function') - } else - client._decoratedSlash.push({ - group, - parent, - name: name ?? prop, - guild, - handler: item - }) - } -} diff --git a/src/models/redisCache.ts b/src/models/redisCache.ts new file mode 100644 index 0000000..0820213 --- /dev/null +++ b/src/models/redisCache.ts @@ -0,0 +1,105 @@ +import { ICacheAdapter } from './cacheAdapter.ts' +import { connect, Redis, RedisConnectOptions } from 'https://deno.land/x/redis@v0.14.1/mod.ts' + +/** Redis Cache Adapter for using Redis as a cache-provider. */ +export class RedisCacheAdapter implements ICacheAdapter { + _redis: Promise + redis?: Redis + ready: boolean = false + readonly _expireIntervalTimer: number = 5000 + private _expireInterval?: number + + constructor(options: RedisConnectOptions) { + this._redis = connect(options) + this._redis.then( + (redis) => { + this.redis = redis + this.ready = true + this._startExpireInterval() + }, + () => { + // TODO: Make error for this + } + ) + } + + private _startExpireInterval(): void { + this._expireInterval = setInterval(() => { + this.redis?.scan(0, { pattern: '*:expires' }).then(([_, names]) => { + for (const name of names) { + this.redis?.hvals(name).then((vals) => { + for (const val of vals) { + const expireVal: { + name: string + key: string + at: number + } = JSON.parse(val) + const expired = new Date().getTime() > expireVal.at + if (expired) this.redis?.hdel(expireVal.name, expireVal.key) + } + }) + } + }) + }, this._expireIntervalTimer) + } + + async _checkReady(): Promise { + if (!this.ready) await this._redis + } + + async get(cacheName: string, key: string): Promise { + await this._checkReady() + const cache = await this.redis?.hget(cacheName, key) + if (cache === undefined) return + try { + return JSON.parse(cache) + } catch (e) { + return cache + } + } + + async set( + cacheName: string, + key: string, + value: any, + expire?: number + ): Promise { + await this._checkReady() + const result = await this.redis?.hset( + cacheName, + key, + typeof value === 'object' ? JSON.stringify(value) : value + ) + if (expire !== undefined) { + await this.redis?.hset( + `${cacheName}:expires`, + key, + JSON.stringify({ + name: cacheName, + key, + at: new Date().getTime() + expire + }) + ) + } + return result + } + + async delete(cacheName: string, key: string): Promise { + await this._checkReady() + const exists = await this.redis?.hexists(cacheName, key) + if (exists === 0) return false + await this.redis?.hdel(cacheName, key) + return true + } + + async array(cacheName: string): Promise { + await this._checkReady() + const data = await this.redis?.hvals(cacheName) + return data?.map((e: string) => JSON.parse(e)) + } + + async deleteCache(cacheName: string): Promise { + await this._checkReady() + return (await this.redis?.del(cacheName)) !== 0 + } +} \ No newline at end of file diff --git a/src/models/shard.ts b/src/models/shard.ts index 80cd649..762ce40 100644 --- a/src/models/shard.ts +++ b/src/models/shard.ts @@ -79,8 +79,6 @@ export class ShardManager extends HarmonyEventEmitter { const shardCount = await this.getShardCount() const gw = new Gateway(this.client, [Number(id), shardCount]) - gw.token = this.client.token - gw.intents = this.client.intents this.list.set(id.toString(), gw) gw.initWebsocket() diff --git a/src/models/slashClient.ts b/src/models/slashClient.ts index 3318e05..0ff8c42 100644 --- a/src/models/slashClient.ts +++ b/src/models/slashClient.ts @@ -1,6 +1,11 @@ -import { Guild } from '../structures/guild.ts' -import { Interaction } from '../structures/slash.ts' +import type { Guild } from '../structures/guild.ts' import { + Interaction, + InteractionApplicationCommandResolved +} from '../structures/slash.ts' +import { + InteractionPayload, + InteractionResponsePayload, InteractionType, SlashCommandChoice, SlashCommandOption, @@ -9,11 +14,13 @@ import { SlashCommandPayload } from '../types/slash.ts' import { Collection } from '../utils/collection.ts' -import { Client } from './client.ts' +import type { Client } from './client.ts' import { RESTManager } from './rest.ts' import { SlashModule } from './slashModule.ts' -import { verify as edverify } from 'https://deno.land/x/ed25519/mod.ts' -import { Buffer } from 'https://deno.land/std@0.80.0/node/buffer.ts' +import { verify as edverify } from 'https://deno.land/x/ed25519@1.0.1/mod.ts' +import { User } from '../structures/user.ts' +import { HarmonyEventEmitter } from '../utils/events.ts' +import { encodeText, decodeText } from '../utils/encoding.ts' export class SlashCommand { slash: SlashCommandsManager @@ -155,6 +162,7 @@ function buildOptionsArray( ) } +/** Slash Command Builder */ export class SlashBuilder { data: SlashCommandPartial @@ -200,6 +208,7 @@ export class SlashBuilder { } } +/** Manages Slash Commands, allows fetching/modifying/deleting/creating Slash Commands. */ export class SlashCommandsManager { slash: SlashClient rest: RESTManager @@ -351,7 +360,7 @@ export class SlashCommandsManager { } } -export type SlashCommandHandlerCallback = (interaction: Interaction) => any +export type SlashCommandHandlerCallback = (interaction: Interaction) => unknown export interface SlashCommandHandler { name: string guild?: string @@ -360,6 +369,7 @@ export interface SlashCommandHandler { handler: SlashCommandHandlerCallback } +/** Options for SlashClient */ export interface SlashOptions { id?: string | (() => string) client?: Client @@ -369,7 +379,15 @@ export interface SlashOptions { publicKey?: string } -export class SlashClient { +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type SlashClientEvents = { + interaction: [Interaction] + interactionError: [Error] + ping: [] +} + +/** Slash Client represents an Interactions Client which can be used without Harmony Client. */ +export class SlashClient extends HarmonyEventEmitter { id: string | (() => string) client?: Client token?: string @@ -389,6 +407,7 @@ export class SlashClient { }> constructor(options: SlashOptions) { + super() let id = options.id if (options.token !== undefined) id = atob(options.token?.split('.')[0]) if (id === undefined) @@ -423,8 +442,9 @@ export class SlashClient { : options.rest : options.client.rest - this.client?.on('interactionCreate', (interaction) => - this._process(interaction) + this.client?.on( + 'interactionCreate', + async (interaction) => await this._process(interaction) ) this.commands = new SlashCommandsManager(this) @@ -469,12 +489,20 @@ export class SlashClient { const groupMatched = e.group !== undefined && e.parent !== undefined ? i.options - .find((o) => o.name === e.group) + .find( + (o) => + o.name === e.group && + o.type === SlashCommandOptionType.SUB_COMMAND_GROUP + ) ?.options?.find((o) => o.name === e.name) !== undefined : true const subMatched = e.group === undefined && e.parent !== undefined - ? i.options.find((o) => o.name === e.name) !== undefined + ? i.options.find( + (o) => + o.name === e.name && + o.type === SlashCommandOptionType.SUB_COMMAND + ) !== undefined : true const nameMatched1 = e.name === i.name const parentMatched = hasGroupOrParent ? e.parent === i.name : true @@ -485,11 +513,15 @@ export class SlashClient { }) } - /** Process an incoming Slash Command (interaction) */ - private _process(interaction: Interaction): void { + /** Process an incoming Interaction */ + private async _process(interaction: Interaction): Promise { if (!this.enabled) return - if (interaction.type !== InteractionType.APPLICATION_COMMAND) return + if ( + interaction.type !== InteractionType.APPLICATION_COMMAND || + interaction.data === undefined + ) + return const cmd = this._getCommand(interaction) if (cmd?.group !== undefined) @@ -499,28 +531,113 @@ export class SlashClient { if (cmd === undefined) return - cmd.handler(interaction) + await this.emit('interaction', interaction) + try { + await cmd.handler(interaction) + } catch (e) { + await this.emit('interactionError', e) + } } + /** Verify HTTP based Interaction */ async verifyKey( - rawBody: string | Uint8Array | Buffer, - signature: string, - timestamp: string + rawBody: string | Uint8Array, + signature: string | Uint8Array, + timestamp: string | Uint8Array ): Promise { if (this.publicKey === undefined) throw new Error('Public Key is not present') - return edverify( - signature, - Buffer.concat([ - Buffer.from(timestamp, 'utf-8'), - Buffer.from( - rawBody instanceof Uint8Array - ? new TextDecoder().decode(rawBody) - : rawBody + + const fullBody = new Uint8Array([ + ...(typeof timestamp === 'string' ? encodeText(timestamp) : timestamp), + ...(typeof rawBody === 'string' ? encodeText(rawBody) : rawBody) + ]) + + return edverify(signature, fullBody, this.publicKey).catch(() => false) + } + + /** Verify [Deno Std HTTP Server Request](https://deno.land/std/http/server.ts) and return Interaction. **Data present in Interaction returned by this method is very different from actual typings as there is no real `Client` behind the scenes to cache things.** */ + async verifyServerRequest(req: { + headers: Headers + method: string + body: Deno.Reader | Uint8Array + respond: (options: { + status?: number + headers?: Headers + body?: string | Uint8Array | FormData + }) => Promise + }): Promise { + if (req.method.toLowerCase() !== 'post') return false + + const signature = req.headers.get('x-signature-ed25519') + const timestamp = req.headers.get('x-signature-timestamp') + if (signature === null || timestamp === null) return false + + const rawbody = + req.body instanceof Uint8Array ? req.body : await Deno.readAll(req.body) + const verify = await this.verifyKey(rawbody, signature, timestamp) + if (!verify) return false + + try { + const payload: InteractionPayload = JSON.parse(decodeText(rawbody)) + + // TODO: Maybe fix all this hackery going on here? + const res = new Interaction(this as any, payload, { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + user: new User(this as any, (payload.member?.user ?? payload.user)!), + member: payload.member as any, + guild: payload.guild_id as any, + channel: payload.channel_id as any, + resolved: ((payload.data + ?.resolved as unknown) as InteractionApplicationCommandResolved) ?? { + users: {}, + members: {}, + roles: {}, + channels: {} + } + }) + res._httpRespond = async (d: InteractionResponsePayload | FormData) => + await req.respond({ + status: 200, + headers: new Headers({ + 'content-type': + d instanceof FormData ? 'multipart/form-data' : 'application/json' + }), + body: d instanceof FormData ? d : JSON.stringify(d) + }) + + return res + } catch (e) { + return false + } + } + + /** Verify FetchEvent (for Service Worker usage) and return Interaction if valid */ + async verifyFetchEvent({ + request: req, + respondWith + }: { + respondWith: CallableFunction + request: Request + }): Promise { + if (req.bodyUsed === true) throw new Error('Request Body already used') + if (req.body === null) return false + const body = (await req.body.getReader().read()).value + if (body === undefined) return false + + return await this.verifyServerRequest({ + headers: req.headers, + body, + method: req.method, + respond: async (options) => { + await respondWith( + new Response(options.body, { + headers: options.headers, + status: options.status + }) ) - ]), - this.publicKey - ).catch(() => false) + } + }) } async verifyOpineRequest(req: any): Promise { @@ -576,3 +693,59 @@ export class SlashClient { return true } } + +/** Decorator to create a Slash Command handler */ +export function slash(name?: string, guild?: string) { + return function (client: Client | SlashClient | SlashModule, prop: string) { + if (client._decoratedSlash === undefined) client._decoratedSlash = [] + const item = (client as { [name: string]: any })[prop] + if (typeof item !== 'function') { + throw new Error('@slash decorator requires a function') + } else + client._decoratedSlash.push({ + name: name ?? prop, + guild, + handler: item + }) + } +} + +/** Decorator to create a Sub-Slash Command handler */ +export function subslash(parent: string, name?: string, guild?: string) { + return function (client: Client | SlashModule | SlashClient, prop: string) { + if (client._decoratedSlash === undefined) client._decoratedSlash = [] + const item = (client as { [name: string]: any })[prop] + if (typeof item !== 'function') { + throw new Error('@subslash decorator requires a function') + } else + client._decoratedSlash.push({ + parent, + name: name ?? prop, + guild, + handler: item + }) + } +} + +/** Decorator to create a Grouped Slash Command handler */ +export function groupslash( + parent: string, + group: string, + name?: string, + guild?: string +) { + return function (client: Client | SlashModule | SlashClient, prop: string) { + if (client._decoratedSlash === undefined) client._decoratedSlash = [] + const item = (client as { [name: string]: any })[prop] + if (typeof item !== 'function') { + throw new Error('@groupslash decorator requires a function') + } else + client._decoratedSlash.push({ + group, + parent, + name: name ?? prop, + guild, + handler: item + }) + } +} diff --git a/src/structures/base.ts b/src/structures/base.ts index e5806e5..0898cfe 100644 --- a/src/structures/base.ts +++ b/src/structures/base.ts @@ -2,10 +2,10 @@ import { Client } from '../models/client.ts' import { Snowflake } from '../utils/snowflake.ts' export class Base { - client: Client + client!: Client constructor(client: Client, _data?: any) { - this.client = client + Object.defineProperty(this, 'client', { value: client, enumerable: false }) } } diff --git a/src/structures/message.ts b/src/structures/message.ts index 8a9a360..92b4bbe 100644 --- a/src/structures/message.ts +++ b/src/structures/message.ts @@ -3,6 +3,7 @@ import { Attachment, MessageActivity, MessageApplication, + MessageInteractionPayload, MessageOptions, MessagePayload, MessageReference @@ -19,9 +20,26 @@ import { Guild } from './guild.ts' import { MessageReactionsManager } from '../managers/messageReactions.ts' import { MessageSticker } from './messageSticker.ts' import { Emoji } from './emoji.ts' +import { InteractionType } from '../types/slash.ts' +import { encodeText } from '../utils/encoding.ts' type AllMessageOptions = MessageOptions | Embed +export class MessageInteraction extends SnowflakeBase { + id: string + name: string + type: InteractionType + user: User + + constructor(client: Client, data: MessageInteractionPayload) { + super(client) + this.id = data.id + this.name = data.name + this.type = data.type + this.user = new User(this.client, data.user) + } +} + export class Message extends SnowflakeBase { id: string channelID: string @@ -46,6 +64,7 @@ export class Message extends SnowflakeBase { messageReference?: MessageReference flags?: number stickers?: MessageSticker[] + interaction?: MessageInteraction get createdAt(): Date { return new Date(this.timestamp) @@ -87,6 +106,10 @@ export class Message extends SnowflakeBase { (payload) => new MessageSticker(this.client, payload) ) : undefined + this.interaction = + data.interaction === undefined + ? undefined + : new MessageInteraction(this.client, data.interaction) } readFromData(data: MessagePayload): void { @@ -195,8 +218,6 @@ export class Message extends SnowflakeBase { } } -const encoder = new TextEncoder() - /** Message Attachment that can be sent while Creating Message */ export class MessageAttachment { name: string @@ -206,7 +227,7 @@ export class MessageAttachment { this.name = name this.blob = typeof blob === 'string' - ? new Blob([encoder.encode(blob)]) + ? new Blob([encodeText(blob)]) : blob instanceof Uint8Array ? new Blob([blob]) : blob diff --git a/src/structures/slash.ts b/src/structures/slash.ts index ff235e9..42d1e64 100644 --- a/src/structures/slash.ts +++ b/src/structures/slash.ts @@ -1,95 +1,178 @@ import { Client } from '../models/client.ts' -import { MessageOptions } from '../types/channel.ts' +import { + AllowedMentionsPayload, + ChannelTypes, + EmbedPayload, + MessageOptions +} from '../types/channel.ts' import { INTERACTION_CALLBACK, WEBHOOK_MESSAGE } from '../types/endpoint.ts' import { - InteractionData, - InteractionOption, + InteractionApplicationCommandData, + InteractionApplicationCommandOption, + InteractionChannelPayload, InteractionPayload, + InteractionResponseFlags, InteractionResponsePayload, - InteractionResponseType + InteractionResponseType, + InteractionType, + SlashCommandOptionType } from '../types/slash.ts' +import { Dict } from '../utils/dict.ts' +import { Permissions } from '../utils/permissions.ts' import { SnowflakeBase } from './base.ts' +import { Channel } from './channel.ts' import { Embed } from './embed.ts' import { Guild } from './guild.ts' +import { GuildTextChannel } from './guildTextChannel.ts' import { Member } from './member.ts' import { Message } from './message.ts' +import { Role } from './role.ts' import { TextChannel } from './textChannel.ts' -import { GuildTextBasedChannel } from './guildTextChannel.ts' import { User } from './user.ts' -import { Webhook } from './webhook.ts' interface WebhookMessageOptions extends MessageOptions { - embeds?: Embed[] + embeds?: Array name?: string avatar?: string } type AllWebhookMessageOptions = string | WebhookMessageOptions -export interface InteractionResponse { - type?: InteractionResponseType +/** Interaction Message related Options */ +export interface InteractionMessageOptions { content?: string - embeds?: Embed[] + embeds?: Array tts?: boolean - flags?: number - temp?: boolean - allowedMentions?: { - parse?: string - roles?: string[] - users?: string[] - everyone?: boolean + flags?: number | InteractionResponseFlags[] + allowedMentions?: AllowedMentionsPayload + /** Whether the Message Response should be Ephemeral (only visible to User) or not */ + ephemeral?: boolean +} + +export interface InteractionResponse extends InteractionMessageOptions { + /** Type of Interaction Response */ + type?: InteractionResponseType +} + +/** Represents a Channel Object for an Option in Slash Command */ +export class InteractionChannel extends SnowflakeBase { + /** Name of the Channel */ + name: string + /** Channel Type */ + type: ChannelTypes + permissions: Permissions + + constructor(client: Client, data: InteractionChannelPayload) { + super(client) + this.id = data.id + this.name = data.name + this.type = data.type + this.permissions = new Permissions(data.permissions) + } + + /** Resolve to actual Channel object if present in Cache */ + async resolve(): Promise { + return this.client.channels.get(this.id) } } +export interface InteractionApplicationCommandResolved { + users: Dict + members: Dict + channels: Dict + roles: Dict +} + +export class InteractionUser extends User { + member?: Member +} + export class Interaction extends SnowflakeBase { - client: Client - type: number + /** Type of Interaction */ + type: InteractionType + /** Interaction Token */ token: string + /** Interaction ID */ id: string - data: InteractionData - channel: GuildTextBasedChannel - guild: Guild - member: Member - _savedHook?: Webhook + /** Data sent with Interaction. Only applies to Application Command */ + data?: InteractionApplicationCommandData + /** Channel in which Interaction was initiated */ + channel?: TextChannel | GuildTextChannel + /** Guild in which Interaction was initiated */ + guild?: Guild + /** Member object of who initiated the Interaction */ + member?: Member + /** User object of who invoked Interaction */ + user: User + /** Whether we have responded to Interaction or not */ + responded: boolean = false + /** Resolved data for Snowflakes in Slash Command Arguments */ + resolved: InteractionApplicationCommandResolved + /** Whether response was deferred or not */ + deferred: boolean = false + _httpRespond?: (d: InteractionResponsePayload) => unknown + _httpResponded?: boolean + applicationID: string constructor( client: Client, data: InteractionPayload, others: { - channel: GuildTextBasedChannel - guild: Guild - member: Member + channel?: TextChannel | GuildTextChannel + guild?: Guild + member?: Member + user: User + resolved: InteractionApplicationCommandResolved } ) { super(client) - this.client = client this.type = data.type this.token = data.token this.member = others.member this.id = data.id + this.applicationID = data.application_id + this.user = others.user this.data = data.data this.guild = others.guild this.channel = others.channel + this.resolved = others.resolved } - get user(): User { - return this.member.user + /** Name of the Command Used (may change with future additions to Interactions!) */ + get name(): string | undefined { + return this.data?.name } - get name(): string { - return this.data.name + get options(): InteractionApplicationCommandOption[] { + return this.data?.options ?? [] } - get options(): InteractionOption[] { - return this.data.options ?? [] - } - - option(name: string): T { - return this.options.find((e) => e.name === name)?.value + /** Get an option by name */ + option(name: string): T { + const op = this.options.find((e) => e.name === name) + if (op === undefined || op.value === undefined) return undefined as any + if (op.type === SlashCommandOptionType.USER) { + const u: InteractionUser = this.resolved.users[op.value] as any + if (this.resolved.members[op.value] !== undefined) + u.member = this.resolved.members[op.value] + return u as any + } else if (op.type === SlashCommandOptionType.ROLE) + return this.resolved.roles[op.value] as any + else if (op.type === SlashCommandOptionType.CHANNEL) + return this.resolved.channels[op.value] as any + else return op.value } /** Respond to an Interaction */ async respond(data: InteractionResponse): Promise { + if (this.responded) throw new Error('Already responded to Interaction') + let flags = 0 + if (data.ephemeral === true) flags |= InteractionResponseFlags.EPHEMERAL + if (data.flags !== undefined) { + if (Array.isArray(data.flags)) + flags = data.flags.reduce((p, a) => p | a, flags) + else if (typeof data.flags === 'number') flags |= data.flags + } const payload: InteractionResponsePayload = { type: data.type ?? InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, data: @@ -100,16 +183,70 @@ export class Interaction extends SnowflakeBase { content: data.content ?? '', embeds: data.embeds, tts: data.tts ?? false, - flags: data.temp === true ? 64 : data.flags ?? undefined, - allowed_mentions: (data.allowedMentions ?? undefined) as any + flags, + allowed_mentions: data.allowedMentions ?? undefined } : undefined } - await this.client.rest.post( - INTERACTION_CALLBACK(this.id, this.token), - payload + if (this._httpRespond !== undefined && this._httpResponded !== true) { + this._httpResponded = true + await this._httpRespond(payload) + } else + await this.client.rest.post( + INTERACTION_CALLBACK(this.id, this.token), + payload + ) + this.responded = true + + return this + } + + /** Defer the Interaction i.e. let the user know bot is processing and will respond later. You only have 15 minutes to edit the response! */ + async defer(ephemeral = false): Promise { + await this.respond({ + type: InteractionResponseType.DEFERRED_CHANNEL_MESSAGE, + flags: ephemeral ? 1 << 6 : 0 + }) + this.deferred = true + return this + } + + /** Reply with a Message to the Interaction */ + async reply(content: string): Promise + async reply(options: InteractionMessageOptions): Promise + async reply( + content: string, + options: InteractionMessageOptions + ): Promise + async reply( + content: string | InteractionMessageOptions, + messageOptions?: InteractionMessageOptions + ): Promise { + let options: InteractionMessageOptions | undefined = + typeof content === 'object' ? content : messageOptions + if ( + typeof content === 'object' && + messageOptions !== undefined && + options !== undefined ) + Object.assign(options, messageOptions) + if (options === undefined) options = {} + if (typeof content === 'string') Object.assign(options, { content }) + + if (this.deferred && this.responded) { + await this.editResponse({ + content: options.content, + embeds: options.embeds, + flags: options.flags, + allowedMentions: options.allowedMentions + }) + } else + await this.respond( + Object.assign(options, { + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE + }) + ) return this } @@ -117,33 +254,32 @@ export class Interaction extends SnowflakeBase { /** Edit the original Interaction response */ async editResponse(data: { content?: string - embeds?: Embed[] + embeds?: Array + flags?: number | number[] + allowedMentions?: AllowedMentionsPayload }): Promise { - const url = WEBHOOK_MESSAGE( - this.client.user?.id as string, - this.token, - '@original' - ) + const url = WEBHOOK_MESSAGE(this.applicationID, this.token, '@original') await this.client.rest.patch(url, { content: data.content ?? '', - embeds: data.embeds ?? [] + embeds: data.embeds ?? [], + flags: + typeof data.flags === 'object' + ? data.flags.reduce((p, a) => p | a, 0) + : data.flags, + allowed_mentions: data.allowedMentions }) return this } /** Delete the original Interaction Response */ async deleteResponse(): Promise { - const url = WEBHOOK_MESSAGE( - this.client.user?.id as string, - this.token, - '@original' - ) + const url = WEBHOOK_MESSAGE(this.applicationID, this.token, '@original') await this.client.rest.delete(url) return this } get url(): string { - return `https://discord.com/api/v8/webhooks/${this.client.user?.id}/${this.token}` + return `https://discord.com/api/v8/webhooks/${this.applicationID}/${this.token}` } /** Send a followup message */ @@ -174,6 +310,7 @@ export class Interaction extends SnowflakeBase { ? (option as WebhookMessageOptions).embeds : undefined, file: (option as WebhookMessageOptions)?.file, + files: (option as WebhookMessageOptions)?.files, tts: (option as WebhookMessageOptions)?.tts, allowed_mentions: (option as WebhookMessageOptions)?.allowedMentions } @@ -212,7 +349,7 @@ export class Interaction extends SnowflakeBase { msg: Message | string, data: { content?: string - embeds?: Embed[] + embeds?: Array file?: any allowed_mentions?: { parse?: string @@ -224,7 +361,7 @@ export class Interaction extends SnowflakeBase { ): Promise { await this.client.rest.patch( WEBHOOK_MESSAGE( - this.client.user?.id as string, + this.applicationID, this.token ?? this.client.token, typeof msg === 'string' ? msg : msg.id ), @@ -233,10 +370,11 @@ export class Interaction extends SnowflakeBase { return this } + /** Delete a follow-up Message */ async deleteMessage(msg: Message | string): Promise { await this.client.rest.delete( WEBHOOK_MESSAGE( - this.client.user?.id as string, + this.applicationID, this.token ?? this.client.token, typeof msg === 'string' ? msg : msg.id ) diff --git a/src/test/debug.ts b/src/test/debug.ts new file mode 100644 index 0000000..48bc55e --- /dev/null +++ b/src/test/debug.ts @@ -0,0 +1,28 @@ +import { Client, event } from '../../mod.ts' +import { TOKEN } from './config.ts' + +class MyClient extends Client { + constructor() { + super({ + token: TOKEN, + intents: [], + }) + } + + @event() + ready(): void { + console.log('Connected!') + } + + debug(title: string, msg: string): void { + console.log(`[${title}] ${msg}`) + if (title === 'Gateway' && msg === 'Initializing WebSocket...') { + try { throw new Error("Stack") } catch (e) { + console.log(e.stack) + } + } + } +} + +const client = new MyClient() +client.connect() \ No newline at end of file diff --git a/src/test/index.ts b/src/test/index.ts index bb9a439..379c695 100644 --- a/src/test/index.ts +++ b/src/test/index.ts @@ -119,16 +119,7 @@ client.on('messageCreate', async (msg: Message) => { msg.channel.send('Failed...') } } else if (msg.content === '!react') { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - msg.addReaction('😂') - msg.channel.send('x'.repeat(6969), { - embed: new Embed() - .setTitle('pepega'.repeat(6969)) - .setDescription('pepega'.repeat(6969)) - .addField('uwu', 'uwu'.repeat(6969)) - .addField('uwu', 'uwu'.repeat(6969)) - .setFooter('uwu'.repeat(6969)) - }) + msg.addReaction('a:programming:785013658257195008') } else if (msg.content === '!wait_for') { msg.channel.send('Send anything!') const [receivedMsg] = await client.waitFor( @@ -211,13 +202,12 @@ client.on('messageCreate', async (msg: Message) => { ) .join('\n\n')}` ) - } else if (msg.content === '!getPermissions') { - // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions - if (!checkGuildTextBasedChannel(msg.channel)) { + } else if (msg.content === '!perms') { + if (msg.channel.type !== ChannelTypes.GUILD_TEXT) { return msg.channel.send("This isn't a guild text channel!") } // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion - const permissions = await (msg.channel as GuildTextChannel).permissionsFor( + const permissions = await ((msg.channel as unknown) as GuildTextChannel).permissionsFor( // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion msg.member as Member ) diff --git a/src/test/slash-http.ts b/src/test/slash-http.ts new file mode 100644 index 0000000..893feaf --- /dev/null +++ b/src/test/slash-http.ts @@ -0,0 +1,27 @@ +import { SlashClient } from '../../mod.ts' +import { SLASH_ID, SLASH_PUB_KEY, SLASH_TOKEN } from './config.ts' +import { listenAndServe } from 'https://deno.land/std@0.90.0/http/server.ts' + +const slash = new SlashClient({ + id: SLASH_ID, + token: SLASH_TOKEN, + publicKey: SLASH_PUB_KEY +}) + +await slash.commands.bulkEdit([ + { + name: 'ping', + description: 'Just ping!' + } +]) + +const options = { port: 8000 } +console.log('Listen on port: ' + options.port.toString()) +listenAndServe(options, async (req) => { + const d = await slash.verifyServerRequest(req) + if (d === false) return req.respond({ status: 401, body: 'not authorized' }) + + console.log(d) + if (d.type === 1) return d.respond({ type: 1 }) + d.reply('Pong!') +}) diff --git a/src/test/slash.ts b/src/test/slash.ts index 155a745..215cad0 100644 --- a/src/test/slash.ts +++ b/src/test/slash.ts @@ -1,100 +1,56 @@ -import { Client, Intents, event, slash } from '../../mod.ts' -import { Embed } from '../structures/embed.ts' +import { + Client, + Intents, + event, + slash, + SlashCommandOptionType as Type +} from '../../mod.ts' import { Interaction } from '../structures/slash.ts' import { TOKEN } from './config.ts' export class MyClient extends Client { - @event() - ready(): void { + @event() ready(): void { console.log(`Logged in as ${this.user?.tag}!`) - this.slash.commands.bulkEdit([{ name: 'send', description: 'idk' }]) - } - - @event('debug') - debugEvt(txt: string): void { - console.log(txt) - } - - @slash() - send(d: Interaction): void { - d.respond({ - content: d.data.options?.find((e) => e.name === 'content')?.value - }) - } - - @slash() - async eval(d: Interaction): Promise { - if ( - d.user.id !== '422957901716652033' && - d.user.id !== '682849186227552266' - ) { - d.respond({ - content: 'This command can only be used by owner!' - }) - } else { - const code = d.data.options?.find((e) => e.name === 'code') - ?.value as string - try { - // eslint-disable-next-line no-eval - let evaled = eval(code) - if (evaled instanceof Promise) evaled = await evaled - if (typeof evaled === 'object') evaled = Deno.inspect(evaled) - let res = `${evaled}`.substring(0, 1990) - while (client.token !== undefined && res.includes(client.token)) { - res = res.replace(client.token, '[REMOVED]') + this.slash.commands.bulkEdit( + [ + { + name: 'test', + description: 'Test command.', + options: [ + { + name: 'user', + type: Type.USER, + description: 'User' + }, + { + name: 'role', + type: Type.ROLE, + description: 'Role' + }, + { + name: 'channel', + type: Type.CHANNEL, + description: 'Channel' + }, + { + name: 'string', + type: Type.STRING, + description: 'String' + } + ] } - d.respond({ - content: '```js\n' + `${res}` + '\n```' - }).catch(() => {}) - } catch (e) { - d.respond({ - content: '```js\n' + `${e.stack}` + '\n```' - }) - } - } + ], + '807935370556866560' + ) + this.slash.commands.bulkEdit([]) } - @slash() - async hug(d: Interaction): Promise { - const id = d.data.options?.find((e) => e.name === 'user')?.value as string - const user = (await client.users.get(id)) ?? (await client.users.fetch(id)) - const url = await fetch('https://nekos.life/api/v2/img/hug') - .then((r) => r.json()) - .then((e) => e.url) - - d.respond({ - embeds: [ - new Embed() - .setTitle(`${d.user.username} hugged ${user?.username}!`) - .setImage({ url }) - .setColor(0x2f3136) - ] - }) + @slash() test(d: Interaction): void { + console.log(d.resolved) } - @slash() - async kiss(d: Interaction): Promise { - const id = d.data.options?.find((e) => e.name === 'user')?.value as string - const user = (await client.users.get(id)) ?? (await client.users.fetch(id)) - const url = await fetch('https://nekos.life/api/v2/img/kiss') - .then((r) => r.json()) - .then((e) => e.url) - - d.respond({ - embeds: [ - new Embed() - .setTitle(`${d.user.username} kissed ${user?.username}!`) - .setImage({ url }) - .setColor(0x2f3136) - ] - }) - } - - @slash('ping') - pingCmd(d: Interaction): void { - d.respond({ - content: `Pong!` - }) + @event() raw(evt: string, d: any): void { + if (evt === 'INTERACTION_CREATE') console.log(evt, d?.data?.resolved) } } diff --git a/src/types/channel.ts b/src/types/channel.ts index 26cd456..2787830 100644 --- a/src/types/channel.ts +++ b/src/types/channel.ts @@ -5,6 +5,7 @@ import { Role } from '../structures/role.ts' import { Permissions } from '../utils/permissions.ts' import { EmojiPayload } from './emoji.ts' import { MemberPayload } from './guild.ts' +import { InteractionType } from './slash.ts' import { UserPayload } from './user.ts' export interface ChannelPayload { @@ -185,6 +186,7 @@ export interface MessagePayload { message_reference?: MessageReference flags?: number stickers?: MessageStickerPayload[] + interaction?: MessageInteractionPayload } export enum AllowedMentionType { @@ -373,3 +375,10 @@ export interface MessageStickerPayload { preview_asset: string | null format_type: MessageStickerFormatTypes } + +export interface MessageInteractionPayload { + id: string + type: InteractionType + name: string + user: UserPayload +} diff --git a/src/types/slash.ts b/src/types/slash.ts index 2dac0cd..faf6221 100644 --- a/src/types/slash.ts +++ b/src/types/slash.ts @@ -1,22 +1,47 @@ -import { EmbedPayload } from './channel.ts' +import { Dict } from '../utils/dict.ts' +import { + AllowedMentionsPayload, + ChannelTypes, + EmbedPayload +} from './channel.ts' import { MemberPayload } from './guild.ts' +import { RolePayload } from './role.ts' +import { UserPayload } from './user.ts' -export interface InteractionOption { +export interface InteractionApplicationCommandOption { /** Option name */ name: string + /** Type of Option */ + type: SlashCommandOptionType /** Value of the option */ value?: any /** Sub options */ - options?: any[] + options?: InteractionApplicationCommandOption[] } -export interface InteractionData { +export interface InteractionChannelPayload { + id: string + name: string + permissions: string + type: ChannelTypes +} + +export interface InteractionApplicationCommandResolvedPayload { + users?: Dict + members?: Dict + channels?: Dict + roles?: Dict +} + +export interface InteractionApplicationCommandData { /** Name of the Slash Command */ name: string /** Unique ID of the Slash Command */ id: string /** Options (arguments) sent with Interaction */ - options: InteractionOption[] + options: InteractionApplicationCommandOption[] + /** Resolved data for options in Slash Command */ + resolved?: InteractionApplicationCommandResolvedPayload } export enum InteractionType { @@ -26,27 +51,31 @@ export enum InteractionType { APPLICATION_COMMAND = 2 } +export interface InteractionMemberPayload extends MemberPayload { + /** Permissions of the Member who initiated Interaction (Guild-only) */ + permissions: string +} + export interface InteractionPayload { /** Type of the Interaction */ type: InteractionType /** Token of the Interaction to respond */ token: string /** Member object of user who invoked */ - member: MemberPayload & { - /** Total permissions of the member in the channel, including overrides */ - permissions: string - } + member?: InteractionMemberPayload + /** User who initiated Interaction (only in DMs) */ + user?: UserPayload /** ID of the Interaction */ id: string /** - * Data sent with the interaction - * **This can be undefined only when Interaction is not a Slash Command** + * Data sent with the interaction. Undefined only when Interaction is not Slash Command.* */ - data: InteractionData + data?: InteractionApplicationCommandData /** ID of the Guild in which Interaction was invoked */ - guild_id: string + guild_id?: string /** ID of the Channel in which Interaction was invoked */ - channel_id: string + channel_id?: string + application_id: string } export interface SlashCommandChoice { @@ -57,7 +86,9 @@ export interface SlashCommandChoice { } export enum SlashCommandOptionType { + /** A sub command that is either a part of a root command or Sub Command Group */ SUB_COMMAND = 1, + /** A sub command group that is present in root command's options */ SUB_COMMAND_GROUP = 2, STRING = 3, INTEGER = 4, @@ -68,58 +99,71 @@ export enum SlashCommandOptionType { } export interface SlashCommandOption { + /** Name of the option. */ name: string - /** Description not required in Sub-Command or Sub-Command-Group */ + /** Description of the Option. Not required in Sub-Command-Group */ description?: string + /** Option type */ type: SlashCommandOptionType + /** Whether the option is required or not, false by default */ required?: boolean default?: boolean + /** Optional choices out of which User can choose value */ choices?: SlashCommandChoice[] + /** Nested options for Sub-Command or Sub-Command-Groups */ options?: SlashCommandOption[] } +/** Represents the Slash Command (Application Command) payload sent for creating/bulk editing. */ export interface SlashCommandPartial { + /** Name of the Slash Command */ name: string + /** Description of the Slash Command */ description: string + /** Options (arguments, sub commands or group) of the Slash Command */ options?: SlashCommandOption[] } +/** Represents a fully qualified Slash Command (Application Command) payload. */ export interface SlashCommandPayload extends SlashCommandPartial { + /** ID of the Slash Command */ id: string + /** Application ID */ application_id: string } export enum InteractionResponseType { /** Just ack a ping, Http-only. */ PONG = 1, - /** Do nothing, just acknowledge the Interaction */ + /** @deprecated **DEPRECATED:** Do nothing, just acknowledge the Interaction */ ACKNOWLEDGE = 2, - /** Send a channel message without " used / with " */ + /** @deprecated **DEPRECATED:** Send a channel message without " used / with " */ CHANNEL_MESSAGE = 3, - /** Send a channel message with " used / with " */ + /** Send a channel message as response. */ CHANNEL_MESSAGE_WITH_SOURCE = 4, - /** Send nothing further, but send " used / with " */ - ACK_WITH_SOURCE = 5 + /** Let the user know bot is processing ("thinking") and you can edit the response later */ + DEFERRED_CHANNEL_MESSAGE = 5 } export interface InteractionResponsePayload { + /** Type of the response */ type: InteractionResponseType + /** Data to be sent with response. Optional for types: Pong, Acknowledge, Ack with Source */ data?: InteractionResponseDataPayload } export interface InteractionResponseDataPayload { tts?: boolean + /** Text content of the Response (Message) */ content: string + /** Upto 10 Embed Objects to send with Response */ embeds?: EmbedPayload[] - allowed_mentions?: { - parse?: 'everyone' | 'users' | 'roles' - roles?: string[] - users?: string[] - } + /** Allowed Mentions object */ + allowed_mentions?: AllowedMentionsPayload flags?: number } export enum InteractionResponseFlags { - /** A Message which is only visible to Interaction User, and is not saved on backend */ + /** A Message which is only visible to Interaction User. */ EPHEMERAL = 1 << 6 } diff --git a/src/utils/dict.ts b/src/utils/dict.ts new file mode 100644 index 0000000..6ffaaf3 --- /dev/null +++ b/src/utils/dict.ts @@ -0,0 +1,3 @@ +export interface Dict { + [name: string]: T +} diff --git a/src/utils/encoding.ts b/src/utils/encoding.ts new file mode 100644 index 0000000..04ce231 --- /dev/null +++ b/src/utils/encoding.ts @@ -0,0 +1,10 @@ +const encoder = new TextEncoder() +const decoder = new TextDecoder('utf-8') + +export function encodeText(str: string): Uint8Array { + return encoder.encode(str) +} + +export function decodeText(bytes: Uint8Array): string { + return decoder.decode(bytes) +}