diff --git a/README.md b/README.md index 767b21b..585d405 100644 --- a/README.md +++ b/README.md @@ -10,15 +10,12 @@
- Lightweight and easy to use. -- Built-in Command Framework, - - Easily build Commands on the fly. - - Completely Customizable. - - Complete Object-Oriented approach. -- 100% Discord API Coverage. -- Customizable caching. - - Built in support for Redis. - - Write Custom Cache Adapters. -- Complete TypeScript support. +- Complete Object-Oriented approach. +- Slash Commands supported. +- Built-in Commands framework. +- Customizable Caching, with Redis support. +- Use `@decorators` to easily make things! +- Made with ❤️ TypeScript. ## Table of Contents @@ -102,13 +99,14 @@ client.connect('super secret token comes here', Intents.All) ``` Or with Decorators! + ```ts import { Client, event, Intents, command, - CommandContext, + CommandContext } from 'https://deno.land/x/harmony/mod.ts' class MyClient extends CommandClient { @@ -141,6 +139,7 @@ Documentation is available for `main` (branch) and `stable` (release). - [Main](https://doc.deno.land/https/raw.githubusercontent.com/harmony-org/harmony/main/mod.ts) - [Stable](https://doc.deno.land/https/deno.land/x/harmony/mod.ts) +- [Guide](https://harmony-org.github.io) ## Found a bug or want support? Join our discord server! diff --git a/src/models/client.ts b/src/models/client.ts index 5562cee..f0bcc73 100644 --- a/src/models/client.ts +++ b/src/models/client.ts @@ -247,6 +247,7 @@ export function event(name?: string) { } } +/** Decorator to create a Slash Command handler */ export function slash(name?: string, guild?: string) { return function (client: Client | SlashModule, prop: string) { if (client._decoratedSlash === undefined) client._decoratedSlash = [] @@ -262,6 +263,7 @@ export function slash(name?: string, guild?: string) { } } +/** Decorator to create a Sub-Slash Command handler */ export function subslash(parent: string, name?: string, guild?: string) { return function (client: Client | SlashModule, prop: string) { if (client._decoratedSlash === undefined) client._decoratedSlash = [] @@ -279,13 +281,14 @@ export function subslash(parent: string, name?: string, guild?: string) { } } +/** Decorator to create a Grouped Slash Command handler */ export function groupslash( parent: string, group: string, name?: string, guild?: string ) { - return function (client: Client | SlashModule, prop: 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') { @@ -303,6 +306,7 @@ export function groupslash( } } +/** Decorator to add a Slash Module to Client */ export function slashModule() { return function (client: Client, prop: string) { if (client._decoratedSlashModules === undefined) diff --git a/src/models/rest.ts b/src/models/rest.ts index 0e80c39..29b708c 100644 --- a/src/models/rest.ts +++ b/src/models/rest.ts @@ -97,6 +97,7 @@ export interface RESTOptions { token?: string headers?: { [name: string]: string | undefined } canary?: boolean + version?: 6 | 7 | 8 } export class RESTManager { @@ -111,6 +112,7 @@ export class RESTManager { constructor(client?: RESTOptions) { this.client = client this.api = builder(this) + if (client?.version !== undefined) this.version = client.version // eslint-disable-next-line @typescript-eslint/no-floating-promises this.handleRateLimits() } @@ -408,6 +410,7 @@ export class RESTManager { const query = method === 'get' && body !== undefined ? Object.entries(body as any) + .filter(([k, v]) => v !== undefined) .map( ([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent( diff --git a/src/models/slashClient.ts b/src/models/slashClient.ts index 5058660..e029e49 100644 --- a/src/models/slashClient.ts +++ b/src/models/slashClient.ts @@ -1,20 +1,24 @@ import { Guild } from '../structures/guild.ts' import { Interaction } from '../structures/slash.ts' -import { - APPLICATION_COMMAND, - APPLICATION_COMMANDS, - APPLICATION_GUILD_COMMAND, - APPLICATION_GUILD_COMMANDS -} from '../types/endpoint.ts' import { InteractionType, + SlashCommandChoice, SlashCommandOption, + SlashCommandOptionType, SlashCommandPartial, SlashCommandPayload } from '../types/slash.ts' import { Collection } from '../utils/collection.ts' import { 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 { + Request as ORequest, + Response as OResponse +} from 'https://deno.land/x/opine@1.0.0/src/types.ts' +import { Context } from 'https://deno.land/x/oak@v6.4.0/mod.ts' export class SlashCommand { slash: SlashCommandsManager @@ -41,6 +45,158 @@ export class SlashCommand { async edit(data: SlashCommandPartial): Promise { await this.slash.edit(this.id, data, this._guild) } + + /** Create a handler for this Slash Command */ + handle( + func: SlashCommandHandlerCallback, + options?: { parent?: string; group?: string } + ): SlashCommand { + this.slash.slash.handle({ + name: this.name, + parent: options?.parent, + group: options?.group, + guild: this._guild, + handler: func + }) + return this + } +} + +export interface CreateOptions { + name: string + description?: string + options?: Array + choices?: Array +} + +function createSlashOption( + type: SlashCommandOptionType, + data: CreateOptions +): SlashCommandOption { + return { + name: data.name, + type, + description: + type === 0 || type === 1 + ? undefined + : data.description ?? 'No description.', + options: data.options?.map((e) => + typeof e === 'function' ? e(SlashOption) : e + ), + choices: + data.choices === undefined + ? undefined + : data.choices.map((e) => + typeof e === 'string' ? { name: e, value: e } : e + ) + } +} + +// eslint-disable-next-line @typescript-eslint/no-extraneous-class +export class SlashOption { + static string(data: CreateOptions): SlashCommandOption { + return createSlashOption(SlashCommandOptionType.STRING, data) + } + + static bool(data: CreateOptions): SlashCommandOption { + return createSlashOption(SlashCommandOptionType.BOOLEAN, data) + } + + static subCommand(data: CreateOptions): SlashCommandOption { + return createSlashOption(SlashCommandOptionType.SUB_COMMAND, data) + } + + static subCommandGroup(data: CreateOptions): SlashCommandOption { + return createSlashOption(SlashCommandOptionType.SUB_COMMAND_GROUP, data) + } + + static role(data: CreateOptions): SlashCommandOption { + return createSlashOption(SlashCommandOptionType.ROLE, data) + } + + static channel(data: CreateOptions): SlashCommandOption { + return createSlashOption(SlashCommandOptionType.CHANNEL, data) + } + + static user(data: CreateOptions): SlashCommandOption { + return createSlashOption(SlashCommandOptionType.USER, data) + } + + static number(data: CreateOptions): SlashCommandOption { + return createSlashOption(SlashCommandOptionType.INTEGER, data) + } +} + +export type SlashOptionCallable = (o: typeof SlashOption) => SlashCommandOption + +export type SlashBuilderOptionsData = + | Array + | { + [name: string]: + | { + description: string + type: SlashCommandOptionType + options?: SlashCommandOption[] + choices?: SlashCommandChoice[] + } + | SlashOptionCallable + } + +function buildOptionsArray( + options: SlashBuilderOptionsData +): SlashCommandOption[] { + return Array.isArray(options) + ? options.map((op) => (typeof op === 'function' ? op(SlashOption) : op)) + : Object.entries(options).map((entry) => + typeof entry[1] === 'function' + ? entry[1](SlashOption) + : Object.assign(entry[1], { name: entry[0] }) + ) +} + +export class SlashBuilder { + data: SlashCommandPartial + + constructor( + name?: string, + description?: string, + options?: SlashBuilderOptionsData + ) { + this.data = { + name: name ?? '', + description: description ?? 'No description.', + options: options === undefined ? [] : buildOptionsArray(options) + } + } + + name(name: string): SlashBuilder { + this.data.name = name + return this + } + + description(desc: string): SlashBuilder { + this.data.description = desc + return this + } + + option(option: SlashOptionCallable | SlashCommandOption): SlashBuilder { + if (this.data.options === undefined) this.data.options = [] + this.data.options.push( + typeof option === 'function' ? option(SlashOption) : option + ) + return this + } + + options(options: SlashBuilderOptionsData): SlashBuilder { + this.data.options = buildOptionsArray(options) + return this + } + + export(): SlashCommandPartial { + if (this.data.name === '') + throw new Error('Name was not provided in Slash Builder') + return this.data + } } export class SlashCommandsManager { @@ -58,9 +214,9 @@ export class SlashCommandsManager { async all(): Promise> { const col = new Collection() - const res = (await this.rest.get( - APPLICATION_COMMANDS(this.slash.getID()) - )) as SlashCommandPayload[] + const res = (await this.rest.api.applications[ + this.slash.getID() + ].commands.get()) as SlashCommandPayload[] if (!Array.isArray(res)) return col for (const raw of res) { @@ -77,12 +233,9 @@ export class SlashCommandsManager { ): Promise> { const col = new Collection() - const res = (await this.rest.get( - APPLICATION_GUILD_COMMANDS( - this.slash.getID(), - typeof guild === 'string' ? guild : guild.id - ) - )) as SlashCommandPayload[] + const res = (await this.rest.api.applications[this.slash.getID()].guilds[ + typeof guild === 'string' ? guild : guild.id + ].commands.get()) as SlashCommandPayload[] if (!Array.isArray(res)) return col for (const raw of res) { @@ -99,15 +252,14 @@ export class SlashCommandsManager { data: SlashCommandPartial, guild?: Guild | string ): Promise { - const payload = await this.rest.post( + const route = guild === undefined - ? APPLICATION_COMMANDS(this.slash.getID()) - : APPLICATION_GUILD_COMMANDS( - this.slash.getID(), + ? this.rest.api.applications[this.slash.getID()].commands + : this.rest.api.applications[this.slash.getID()].guilds[ typeof guild === 'string' ? guild : guild.id - ), - data - ) + ].commands + + const payload = await route.post(data) const cmd = new SlashCommand(this, payload) cmd._guild = @@ -122,16 +274,14 @@ export class SlashCommandsManager { data: SlashCommandPartial, guild?: Guild | string ): Promise { - await this.rest.patch( + const route = guild === undefined - ? APPLICATION_COMMAND(this.slash.getID(), id) - : APPLICATION_GUILD_COMMAND( - this.slash.getID(), - typeof guild === 'string' ? guild : guild.id, - id - ), - data - ) + ? this.rest.api.applications[this.slash.getID()].commands[id] + : this.rest.api.applications[this.slash.getID()].guilds[ + typeof guild === 'string' ? guild : guild.id + ].commands[id] + + await route.patch(data) return this } @@ -140,29 +290,28 @@ export class SlashCommandsManager { id: string, guild?: Guild | string ): Promise { - await this.rest.delete( + const route = guild === undefined - ? APPLICATION_COMMAND(this.slash.getID(), id) - : APPLICATION_GUILD_COMMAND( - this.slash.getID(), - typeof guild === 'string' ? guild : guild.id, - id - ) - ) + ? this.rest.api.applications[this.slash.getID()].commands[id] + : this.rest.api.applications[this.slash.getID()].guilds[ + typeof guild === 'string' ? guild : guild.id + ].commands[id] + + await route.delete() return this } /** Get a Slash Command (global or Guild) */ async get(id: string, guild?: Guild | string): Promise { - const data = await this.rest.get( + const route = guild === undefined - ? APPLICATION_COMMAND(this.slash.getID(), id) - : APPLICATION_GUILD_COMMAND( - this.slash.getID(), - typeof guild === 'string' ? guild : guild.id, - id - ) - ) + ? this.rest.api.applications[this.slash.getID()].commands[id] + : this.rest.api.applications[this.slash.getID()].guilds[ + typeof guild === 'string' ? guild : guild.id + ].commands[id] + + const data = await route.get() + return new SlashCommand(this, data) } } @@ -182,6 +331,7 @@ export interface SlashOptions { enabled?: boolean token?: string rest?: RESTManager + publicKey?: string } export class SlashClient { @@ -192,6 +342,18 @@ export class SlashClient { commands: SlashCommandsManager handlers: SlashCommandHandler[] = [] rest: RESTManager + modules: SlashModule[] = [] + publicKey?: string + + _decoratedSlash?: Array<{ + name: string + guild?: string + parent?: string + group?: string + handler: (interaction: Interaction) => any + }> + + _decoratedSlashModules?: SlashModule[] constructor(options: SlashOptions) { let id = options.id @@ -202,6 +364,7 @@ export class SlashClient { this.client = options.client this.token = options.token this.commands = new SlashCommandsManager(this) + this.publicKey = options.publicKey if (options !== undefined) { this.enabled = options.enabled ?? true @@ -213,6 +376,24 @@ export class SlashClient { }) } + if (this.client?._decoratedSlashModules !== undefined) { + this.client._decoratedSlashModules.forEach((e) => { + this.modules.push(e) + }) + } + + if (this._decoratedSlash !== undefined) { + this._decoratedSlash.forEach((e) => { + this.handlers.push(e) + }) + } + + if (this._decoratedSlashModules !== undefined) { + this._decoratedSlashModules.forEach((e) => { + this.modules.push(e) + }) + } + this.rest = options.client === undefined ? options.rest === undefined @@ -237,8 +418,28 @@ export class SlashClient { return this } + loadModule(module: SlashModule): SlashClient { + this.modules.push(module) + return this + } + + getHandlers(): SlashCommandHandler[] { + let res = this.handlers + for (const mod of this.modules) { + if (mod === undefined) continue + res = [ + ...res, + ...mod.commands.map((cmd) => { + cmd.handler = cmd.handler.bind(mod) + return cmd + }) + ] + } + return res + } + private _getCommand(i: Interaction): SlashCommandHandler | undefined { - return this.handlers.find((e) => { + return this.getHandlers().find((e) => { const hasGroupOrParent = e.group !== undefined || e.parent !== undefined const groupMatched = e.group !== undefined && e.parent !== undefined @@ -271,4 +472,78 @@ export class SlashClient { cmd.handler(interaction) } + + async verifyKey( + rawBody: string | Uint8Array | Buffer, + signature: string, + timestamp: string + ): 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 + ) + ]), + this.publicKey + ).catch(() => false) + } + + async verifyOpineRequest(req: ORequest): Promise { + const signature = req.headers.get('x-signature-ed25519') + const timestamp = req.headers.get('x-signature-timestamp') + const contentLength = req.headers.get('content-length') + + if (signature === null || timestamp === null || contentLength === null) + return false + + const body = new Uint8Array(parseInt(contentLength)) + await req.body.read(body) + + const verified = await this.verifyKey(body, signature, timestamp) + if (!verified) return false + + return true + } + + /** Middleware to verify request in Opine framework. */ + async verifyOpineMiddleware( + req: ORequest, + res: OResponse, + next: CallableFunction + ): Promise { + const verified = await this.verifyOpineRequest(req) + if (!verified) return res.setStatus(401).end() + + await next() + return true + } + + // TODO: create verifyOakMiddleware too + /** Method to verify Request from Oak server "Context". */ + async verifyOakRequest(ctx: Context): Promise { + const signature = ctx.request.headers.get('x-signature-ed25519') + const timestamp = ctx.request.headers.get('x-signature-timestamp') + const contentLength = ctx.request.headers.get('content-length') + + if ( + signature === null || + timestamp === null || + contentLength === null || + ctx.request.hasBody !== true + ) { + return false + } + + const body = await ctx.request.body().value + + const verified = await this.verifyKey(body as any, signature, timestamp) + if (!verified) return false + return true + } } diff --git a/src/structures/message.ts b/src/structures/message.ts index a0b2ae8..f9b7add 100644 --- a/src/structures/message.ts +++ b/src/structures/message.ts @@ -46,6 +46,10 @@ export class Message extends Base { flags?: number stickers?: MessageSticker[] + get createdAt(): Date { + return new Date(this.timestamp) + } + constructor( client: Client, data: MessagePayload, diff --git a/src/structures/textChannel.ts b/src/structures/textChannel.ts index ec137e5..a45fc24 100644 --- a/src/structures/textChannel.ts +++ b/src/structures/textChannel.ts @@ -3,6 +3,7 @@ import { Client } from '../models/client.ts' import { GuildTextChannelPayload, MessageOption, + MessagePayload, MessageReference, ModifyGuildTextChannelOption, ModifyGuildTextChannelPayload, @@ -14,6 +15,7 @@ import { CHANNEL_MESSAGE, CHANNEL_MESSAGES } from '../types/endpoint.ts' +import { Collection } from '../utils/collection.ts' import { Channel } from './channel.ts' import { Embed } from './embed.ts' import { Guild } from './guild.ts' @@ -125,6 +127,48 @@ export class TextChannel extends Channel { await res.mentions.fromPayload(newMsg) return res } + + /** + * Fetch Messages of a Channel + * @param options Options to configure fetching Messages + */ + async fetchMessages(options?: { + limit?: number + around?: Message | string + before?: Message | string + after?: Message | string + }): Promise> { + const res = new Collection() + const raws = (await this.client.rest.api.channels[this.id].messages.get({ + limit: options?.limit ?? 50, + around: + options?.around === undefined + ? undefined + : typeof options.around === 'string' + ? options.around + : options.around.id, + before: + options?.before === undefined + ? undefined + : typeof options.before === 'string' + ? options.before + : options.before.id, + after: + options?.after === undefined + ? undefined + : typeof options.after === 'string' + ? options.after + : options.after.id + })) as MessagePayload[] + + for (const raw of raws) { + await this.messages.set(raw.id, raw) + const msg = ((await this.messages.get(raw.id)) as unknown) as Message + res.set(msg.id, msg) + } + + return res + } } export class GuildTextChannel extends TextChannel { @@ -186,4 +230,40 @@ export class GuildTextChannel extends TextChannel { return new GuildTextChannel(this.client, resp, this.guild) } + + /** + * Bulk Delete Messages in a Guild Text Channel + * @param messages Messages to delete. Can be a number, or Array of Message or IDs + */ + async bulkDelete( + messages: Array | number + ): Promise { + let ids: string[] = [] + + if (Array.isArray(messages)) + ids = messages.map((e) => (typeof e === 'string' ? e : e.id)) + else { + let list = await this.messages.array() + if (list.length < messages) list = (await this.fetchMessages()).array() + ids = list + .sort((b, a) => a.createdAt.getTime() - b.createdAt.getTime()) + .filter((e, i) => i < messages) + .filter( + (e) => + new Date().getTime() - e.createdAt.getTime() <= + 1000 * 60 * 60 * 24 * 14 + ) + .map((e) => e.id) + } + + ids = [...new Set(ids)] + if (ids.length < 2 || ids.length > 100) + throw new Error('bulkDelete can only delete messages in range 2-100') + + await this.client.rest.api.channels[this.id].messages['bulk-delete'].post({ + messages: ids + }) + + return this + } } diff --git a/src/test/music.ts b/src/test/music.ts index c8f720b..af0333a 100644 --- a/src/test/music.ts +++ b/src/test/music.ts @@ -7,7 +7,8 @@ import { groupslash, CommandContext, Extension, - Collection + Collection, + GuildTextChannel } from '../../mod.ts' import { LL_IP, LL_PASS, LL_PORT, TOKEN } from './config.ts' import { @@ -69,6 +70,17 @@ class MyClient extends CommandClient { d.respond({ content: 'sub-cmd-group worked' }) } + @command() + rmrf(ctx: CommandContext): any { + if (ctx.author.id !== '422957901716652033') return + ;((ctx.channel as any) as GuildTextChannel) + .bulkDelete(3) + .then((chan) => { + ctx.channel.send(`Bulk deleted 2 in ${chan}`) + }) + .catch((e) => ctx.channel.send(`${e.message}`)) + } + @slash() run(d: Interaction): void { console.log(d.name) @@ -205,6 +217,10 @@ class VCExtension extends Extension { const client = new MyClient() +client.on('raw', (e, d) => { + if (e === 'GUILD_MEMBER_ADD' || e === 'GUILD_MEMBER_UPDATE') console.log(e, d) +}) + client.extensions.load(VCExtension) -client.connect(TOKEN, Intents.None) +client.connect(TOKEN, Intents.All) diff --git a/src/test/slash-only.ts b/src/test/slash-only.ts index cd326bf..1798eee 100644 --- a/src/test/slash-only.ts +++ b/src/test/slash-only.ts @@ -1,6 +1,16 @@ import { SlashClient } from '../models/slashClient.ts' +import { SlashCommandPartial } from '../types/slash.ts' import { TOKEN } from './config.ts' -const slash = new SlashClient({ token: TOKEN }) +export const slash = new SlashClient({ token: TOKEN }) -slash.commands.all().then(console.log) +// Cmd objects come here +const commands: SlashCommandPartial[] = [] + +console.log('Creating...') +commands.forEach((cmd) => { + slash.commands + .create(cmd, '!! Your testing guild ID comes here !!') + .then((c) => console.log(`Created command ${c.name}!`)) + .catch((e) => `Failed to create ${cmd.name} - ${e.message}`) +}) diff --git a/src/types/guild.ts b/src/types/guild.ts index b2b7b68..6825398 100644 --- a/src/types/guild.ts +++ b/src/types/guild.ts @@ -62,6 +62,7 @@ export interface MemberPayload { premium_since?: string deaf: boolean mute: boolean + pending?: boolean } export enum MessageNotification { @@ -113,6 +114,9 @@ export type GuildFeatures = | 'FEATURABLE' | 'ANIMATED_ICON' | 'BANNER' + | 'WELCOME_SCREEN_ENABLED' + | 'MEMBER_VERIFICATION_GATE_ENABLED' + | 'PREVIEW_ENABLED' export enum IntegrationExpireBehavior { REMOVE_ROLE = 0, diff --git a/src/types/slash.ts b/src/types/slash.ts index d48f363..1bd83ba 100644 --- a/src/types/slash.ts +++ b/src/types/slash.ts @@ -50,7 +50,7 @@ export interface SlashCommandChoice { /** (Display) name of the Choice */ name: string /** Actual value to be sent in Interaction */ - value: string + value: any } export enum SlashCommandOptionType { @@ -66,7 +66,8 @@ export enum SlashCommandOptionType { export interface SlashCommandOption { name: string - description: string + /** Description not required in Sub-Command or Sub-Command-Group */ + description?: string type: SlashCommandOptionType required?: boolean default?: boolean