diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9ff9480 --- /dev/null +++ b/.gitignore @@ -0,0 +1,146 @@ +# Created by https://www.toptal.com/developers/gitignore/api/node +# Edit at https://www.toptal.com/developers/gitignore?templates=node + +files/config.toml + +### Node ### +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +### Node Patch ### +# Serverless Webpack directories +.webpack/ + +# Optional stylelint cache + +# SvelteKit build / generate output +.svelte-kit + +# End of https://www.toptal.com/developers/gitignore/api/node diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..af769a5 Binary files /dev/null and b/bun.lockb differ diff --git a/files/config.example.toml b/files/config.example.toml new file mode 100644 index 0000000..911ff0c --- /dev/null +++ b/files/config.example.toml @@ -0,0 +1,9 @@ +# For start application rename it to config.toml !! + +[server] +port = 3000 + +[client] +public_key = "CLIENT PUBLIC KEY" +token = "CLIENT TOKEN" +id = "CLIENT USER ID" \ No newline at end of file diff --git a/files/tags.toml b/files/tags.toml new file mode 100644 index 0000000..7a6cbaf --- /dev/null +++ b/files/tags.toml @@ -0,0 +1,11 @@ +[test] +keywords = ["test", "t"] +content = """ +Hello, new bot :) +""" + +[lol] +keywords = ["l"] +content = """ +Lol +""" \ No newline at end of file diff --git a/package.json b/package.json index 21b385e..cdd09bf 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,16 @@ { - "version": "1.0.0", + "version": "0.0.0", "name": "bun-discord-bot", - "main": "src/index.js" + "scripts": { + "start": "bun src/index.ts" + }, + "devDependencies": { + "bun-types": "^0.1.2" + }, + "dependencies": { + "@discordjs/collection": "^0.7.0", + "discord-api-types": "^0.36.1", + "hono": "^1.6.4", + "tweetnacl": "^1.0.3" + } } \ No newline at end of file diff --git a/src/commands/help.ts b/src/commands/help.ts new file mode 100644 index 0000000..4877f2a --- /dev/null +++ b/src/commands/help.ts @@ -0,0 +1,16 @@ +import { InteractionResponseType } from 'discord-api-types/v10'; +import { Command } from '../structures/Command'; + +new Command({ + name: 'help', + description: 'Help command', + guildId: '924395690451423332', + run: (c) => { + return c.respond({ + type: InteractionResponseType.ChannelMessageWithSource, + data: { + content: 'hello' + } + }) + } +}) \ No newline at end of file diff --git a/src/commands/tags.ts b/src/commands/tags.ts new file mode 100644 index 0000000..f049929 --- /dev/null +++ b/src/commands/tags.ts @@ -0,0 +1,45 @@ +import { APIApplicationCommandInteractionDataStringOption, ApplicationCommandOptionType, InteractionResponseType, MessageFlags } from 'discord-api-types/v10'; +import { Command } from '../structures/Command'; +import { findTags, getTag } from '../utils/tagsUtils'; + +new Command({ + name: 'tags', + description: 'Send a tag by name or alias', + guildId: '924395690451423332', + options: [ + { + name: 'query', + description: 'Tag name or alias', + type: ApplicationCommandOptionType.String, + required: true, + run: (ctx) => { + return ctx.respond(findTags(ctx.value)); + } + }, + { + name: 'target', + description: 'User to mention', + type: ApplicationCommandOptionType.User, + required: false + } + ], + run: (c) => { + const query: APIApplicationCommandInteractionDataStringOption = c.options[0] as APIApplicationCommandInteractionDataStringOption; + const target = c?.resolved?.users?.[0]; + + const tag = getTag(query.value); + if (!tag) + return c.respond({ + type: InteractionResponseType.ChannelMessageWithSource, + data: { + content: `\`❌\` Could not find a tag \`${query.value}\``, + flags: MessageFlags.Ephemeral + } + }); + + return c.respond([ + target ? `*Tag suggestion for <@${target.id}>:*` : '', + tag.content + ].join('\n')); + } +}) \ No newline at end of file diff --git a/src/index.js b/src/index.js deleted file mode 100644 index 73c0265..0000000 --- a/src/index.js +++ /dev/null @@ -1 +0,0 @@ -console.log('Hello World'); \ No newline at end of file diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..b329e25 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,84 @@ +import { Hono } from 'hono'; +import { bodyParse } from 'hono/body-parse'; +import { Logger } from './utils/Logger'; + +// @ts-expect-error Types :( +import config from '../files/config.toml'; +import loadCommands from './utils/loadCommands'; +import { verifyKey } from './utils/verify'; +import { APIPingInteraction, APIApplicationCommandInteraction, APIMessageComponentInteraction, InteractionType, InteractionResponseType, ApplicationCommandType, APIApplicationCommandAutocompleteInteraction, ApplicationCommandOptionType, APIApplicationCommandOption } from 'discord-api-types/v10'; +import { CommandContext } from './structures/contexts/CommandContext'; +import { Commands } from './managers/CommandManager'; +import registerCommands from './utils/registerCommands'; +import { Option, OptionOptions } from './structures/Option'; +import { AutocompleteContext } from './structures/contexts/AutocompleteContext'; + +await loadCommands(); +try { + await registerCommands(config.client.token, config.client.id); +} catch(e) { + console.log(e); +} + +const app = new Hono(); + +app.post('/interaction', bodyParse(), async(c) => { + const signature = c.req.headers.get('X-Signature-Ed25519'); + const timestamp = c.req.headers.get('X-Signature-Timestamp'); + if (!signature || !timestamp) return c.redirect('https://www.youtube.com/watch?v=FMhScnY0dME'); // fireship :D + if (!await verifyKey(JSON.stringify(c.req.parsedBody), signature, timestamp, config.client.public_key)) return c.redirect('https://www.youtube.com/watch?v=FMhScnY0dME'); // fireship :D + + const interaction = c.req.parsedBody as unknown as APIPingInteraction | APIApplicationCommandInteraction | APIMessageComponentInteraction | APIApplicationCommandAutocompleteInteraction; + + if (interaction.type === InteractionType.Ping) { + return new CommandContext(c).respond({ + type: InteractionResponseType.Pong + }); + } + + if (interaction.type === InteractionType.ApplicationCommandAutocomplete && interaction.data.type === ApplicationCommandType.ChatInput) { + const command = Commands.get(interaction.data.name); + let options = command.options; + const subCommandGroup = interaction.data.options.find(option => option.type === ApplicationCommandOptionType.SubcommandGroup) + const subCommand = interaction.data.options.find(option => option.type === ApplicationCommandOptionType.Subcommand); + + // @ts-expect-error ?? find + if (subCommandGroup) options = options.find(option => option.name === subCommandGroup.name)?.options; + // @ts-expect-error ?? find + if (subCommand) options = options.find(option => option.name === subCommand.name)?.options; + + // @ts-expect-error i dont want waste time + const focused: APIApplicationCommandBasicOption = interaction.data.options.find(option => option.focused === true); + // @ts-expect-error ?? find + const option: Option | OptionOptions = options.find(option => option.name === focused.name); + + return option.run(new AutocompleteContext( + c, + option, + focused.value + )); + } + + if (interaction.type === InteractionType.ApplicationCommand && interaction.data.type === ApplicationCommandType.ChatInput) { + return Commands.get(interaction.data.name).run(new CommandContext( + c, + interaction.data.options, + interaction.data.resolved + )); + } + + return new CommandContext(c).respond({ + type: InteractionResponseType.ChannelMessageWithSource, + data: { + content: 'Beep boop. Boop beep?' + } + }) +}) + +await Bun.serve({ + port: config.server.port, + fetch: app.fetch, +}); + +Logger.info('🚀 Server started at', config.server.port.toString()); +Logger.debug(`🌍 http://localhost:${config.server.port}`); \ No newline at end of file diff --git a/src/managers/CommandManager.ts b/src/managers/CommandManager.ts new file mode 100644 index 0000000..8b29447 --- /dev/null +++ b/src/managers/CommandManager.ts @@ -0,0 +1,20 @@ +import Collection from '@discordjs/collection'; +import { Command } from '../structures/Command'; + +class CommandManager extends Collection { + constructor() { + super(); + } + + public register(command: Command): CommandManager { + this.set(command.name, command); + return this; + } + + public unregister(command: Command): CommandManager { + this.delete(command.name); + return this; + } +} + +export const Commands = new CommandManager(); \ No newline at end of file diff --git a/src/structures/Command.ts b/src/structures/Command.ts new file mode 100644 index 0000000..a5b5007 --- /dev/null +++ b/src/structures/Command.ts @@ -0,0 +1,59 @@ +// Taken from https://github.com/Garlic-Team/gcommands/blob/next/src/lib/structures/Command.ts + +import { LocaleString } from 'discord-api-types/v10'; +import { Commands } from '../managers/CommandManager'; +import { CommandContext } from './contexts/CommandContext'; +import { Option, OptionOptions } from './Option'; + +export interface CommandOptions { + name: string; + nameLocalizations?: Record; + description?: string; + descriptionLocalizations?: Record; + guildId?: string; + defaultMemberPermissions?: string; + options?: Option[] | OptionOptions[]; + run: (ctx: CommandContext) => Response; +} + +export class Command { + public name: string; + public nameLocalizations?: Record; + public description?: string; + public descriptionLocalizations?: Record; + public guildId?: string; + public defaultMemberPermissions?: string; + public options: Option[] | OptionOptions[]; + public run: (ctx: CommandContext) => Response; + + public constructor(options: CommandOptions) { + this.name = options.name; + this.nameLocalizations = options.nameLocalizations; + + this.description = options.description; + this.descriptionLocalizations = options.descriptionLocalizations; + + this.guildId = options.guildId; + this.defaultMemberPermissions = options.defaultMemberPermissions; + + this.options = options.options?.map(option => { + if (option instanceof Option) return option; + else return new Option(option); + }); + this.run = options.run; + + Commands.register(this); + } + + public toJSON(): Record { + return { + name: this.name, + name_localizations: this.nameLocalizations, + description: this.description, + description_localizations: this.descriptionLocalizations, + guild_id: this.guildId, + default_member_permissions: this.defaultMemberPermissions, + options: this.options?.map(option => option.toJSON()), + } + } +} \ No newline at end of file diff --git a/src/structures/Option.ts b/src/structures/Option.ts new file mode 100644 index 0000000..9ed1739 --- /dev/null +++ b/src/structures/Option.ts @@ -0,0 +1,96 @@ +// Taken from https://github.com/Garlic-Team/gcommands/blob/next/src/lib/structures/Argument.ts + +import { ApplicationCommandOptionType, ChannelType, LocaleString } from 'discord-api-types/v10'; +import { AutocompleteContext } from './contexts/AutocompleteContext'; + +export interface OptionChoice { + name: string; + nameLocalizations?: Record; + value: string | number; +} + +export interface OptionOptions { + name: string; + nameLocalizations?: Record; + description: string; + descriptionLocalizations?: Record; + type: ApplicationCommandOptionType + required?: boolean; + choices?: OptionChoice[]; + options?: Array