diff --git a/.gitignore b/.gitignore index 9ff9480..3c51502 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ # Edit at https://www.toptal.com/developers/gitignore?templates=node files/config.toml +requests.rest ### Node ### # Logs diff --git a/bun.lockb b/bun.lockb index 5ef2cff..d3b7e52 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/files/config.example.toml b/files/config.example.toml index 911ff0c..438d780 100644 --- a/files/config.example.toml +++ b/files/config.example.toml @@ -6,4 +6,10 @@ port = 3000 [client] public_key = "CLIENT PUBLIC KEY" token = "CLIENT TOKEN" -id = "CLIENT USER ID" \ No newline at end of file +id = "CLIENT USER ID" + +[commands] +guild_id = "GUILD ID" + +[api] +github_personal_access_token = "" \ No newline at end of file diff --git a/files/utilities.toml b/files/utilities.toml new file mode 100644 index 0000000..ce15f0e --- /dev/null +++ b/files/utilities.toml @@ -0,0 +1,7 @@ +[github] +repositories = [ + "oven-sh/bun", + "oven-sh/docs", + "Jarred-Sumner/bun-dependencies", + "xHyroM/bun-discord-bot" +] \ No newline at end of file diff --git a/package.json b/package.json index cdd09bf..e6cd011 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,8 @@ "version": "0.0.0", "name": "bun-discord-bot", "scripts": { - "start": "bun src/index.ts" + "start": "bun src/index.ts", + "cloudflare:tunnel": "sudo cloudflared tunnel run" }, "devDependencies": { "bun-types": "^0.1.2" diff --git a/src/commands/github.ts b/src/commands/github.ts new file mode 100644 index 0000000..7cf2034 --- /dev/null +++ b/src/commands/github.ts @@ -0,0 +1,81 @@ +import { APIApplicationCommandInteractionDataStringOption, ApplicationCommandOptionType, InteractionResponseType, MessageFlags } from 'discord-api-types/v10'; +import { Command } from '../structures/Command'; +// @ts-expect-error Types :( +import utilities from '../../files/utilities.toml'; +// @ts-expect-error Types :( +import config from '../../files/config.toml'; +import { githubIssuesAndPullRequests } from '../utils/regexes'; +import isNumeric from '../utils/isNumeric'; +import Collection from '@discordjs/collection'; + +const cooldowns: Collection = new Collection(); + +new Command({ + name: 'github', + description: 'Query an issue, pull request or direct link to Github Issue or PR', + options: [ + { + name: 'query', + description: 'Issue, PR number or direct link to Github Issue or PR', + type: ApplicationCommandOptionType.String, + required: true + }, + { + name: 'repository', + description: 'Project repository (default oven-sh/bun)', + type: ApplicationCommandOptionType.String, + required: false, + choices: [ + ...utilities.github.repositories.map(repository => new Object({ + name: repository.split('/')[1], + value: repository + })) + ] + } + ], + run: async(ctx) => { + if (cooldowns.has(ctx.user.id) && cooldowns.get(ctx.user.id) < Date.now()) { + return ctx.respond('⚠️ You are in cooldown.'); + } + + const query: string = (ctx.options[0] as APIApplicationCommandInteractionDataStringOption).value; + const repository: string = (ctx.options?.[1] as APIApplicationCommandInteractionDataStringOption)?.value || 'oven-sh/bun'; + + const repositorySplit = repository.split('/'); + const repositoryOwner = repositorySplit[0]; + const repositoryName = repositorySplit[1]; + + const isIssueOrPR = githubIssuesAndPullRequests(repositoryOwner, repositoryName).test(query); + const isIssueOrPRNumber = isNumeric(query); + + if (!isIssueOrPR && !isIssueOrPRNumber) { + return ctx.respond({ + type: InteractionResponseType.ChannelMessageWithSource, + data: { + content: `\`❌\` Invalid issue or pull request \`${query}\``, + flags: MessageFlags.Ephemeral, + } + }); + } + + const issueUrl = `https://api.github.com/repos/${repositoryOwner}/${repositoryName}/issues/${isIssueOrPR ? query.split('/issues/')[1] : query}`; + cooldowns.set(ctx.user.id, Date.now() + 60000); + + const res = await fetch(issueUrl, { + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'bun-discord-bot', + 'Authorization': config.api.github_personal_access_token + } + }); + + const data: any = await res.json(); + + // TODO: finish (pull request, issue) + + return ctx.respond([ + `[#${data.number} ${repositoryOwner}/${repositoryName}](${data.html_url}) operation `, + data.title + ].join('\n')); + } +}) \ No newline at end of file diff --git a/src/commands/help.ts b/src/commands/ping.ts similarity index 60% rename from src/commands/help.ts rename to src/commands/ping.ts index 4877f2a..f4be607 100644 --- a/src/commands/help.ts +++ b/src/commands/ping.ts @@ -2,14 +2,13 @@ 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({ + name: 'ping', + description: 'pong', + run: (ctx) => { + return ctx.respond({ type: InteractionResponseType.ChannelMessageWithSource, data: { - content: 'hello' + content: 'Pong 🏓' } }) } diff --git a/src/commands/tags.ts b/src/commands/tags.ts index f049929..f15e478 100644 --- a/src/commands/tags.ts +++ b/src/commands/tags.ts @@ -5,7 +5,6 @@ import { findTags, getTag } from '../utils/tagsUtils'; new Command({ name: 'tags', description: 'Send a tag by name or alias', - guildId: '924395690451423332', options: [ { name: 'query', @@ -23,13 +22,13 @@ new Command({ required: false } ], - run: (c) => { - const query: APIApplicationCommandInteractionDataStringOption = c.options[0] as APIApplicationCommandInteractionDataStringOption; - const target = c?.resolved?.users?.[0]; + run: (ctx) => { + const query: APIApplicationCommandInteractionDataStringOption = ctx.options[0] as APIApplicationCommandInteractionDataStringOption; + const target = ctx?.resolved?.users?.[0]; const tag = getTag(query.value); if (!tag) - return c.respond({ + return ctx.respond({ type: InteractionResponseType.ChannelMessageWithSource, data: { content: `\`❌\` Could not find a tag \`${query.value}\``, @@ -37,7 +36,7 @@ new Command({ } }); - return c.respond([ + return ctx.respond([ target ? `*Tag suggestion for <@${target.id}>:*` : '', tag.content ].join('\n')); diff --git a/src/index.ts b/src/index.ts index b00c54f..cdd2ad3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,6 +21,7 @@ try { } const app = new Hono(); +app.get('*', (c) => c.redirect('https://www.youtube.com/watch?v=FMhScnY0dME')); app.post('/interaction', bodyParse(), async(c) => { const signature = c.req.headers.get('X-Signature-Ed25519'); @@ -62,6 +63,7 @@ app.post('/interaction', bodyParse(), async(c) => { if (interaction.type === InteractionType.ApplicationCommand && interaction.data.type === ApplicationCommandType.ChatInput) { return Commands.get(interaction.data.name).run(new CommandContext( c, + interaction.user, interaction.data.options, interaction.data.resolved )); diff --git a/src/structures/Command.ts b/src/structures/Command.ts index a5b5007..ee4cd4c 100644 --- a/src/structures/Command.ts +++ b/src/structures/Command.ts @@ -1,5 +1,7 @@ // Taken from https://github.com/Garlic-Team/gcommands/blob/next/src/lib/structures/Command.ts +// @ts-expect-error Types :( +import config from '../../files/config.toml'; import { LocaleString } from 'discord-api-types/v10'; import { Commands } from '../managers/CommandManager'; import { CommandContext } from './contexts/CommandContext'; @@ -13,7 +15,7 @@ export interface CommandOptions { guildId?: string; defaultMemberPermissions?: string; options?: Option[] | OptionOptions[]; - run: (ctx: CommandContext) => Response; + run: (ctx: CommandContext) => Response | Promise; } export class Command { @@ -21,10 +23,10 @@ export class Command { public nameLocalizations?: Record; public description?: string; public descriptionLocalizations?: Record; - public guildId?: string; + public guildId?: string = config.client.guild_id; public defaultMemberPermissions?: string; public options: Option[] | OptionOptions[]; - public run: (ctx: CommandContext) => Response; + public run: (ctx: CommandContext) => Response | Promise; public constructor(options: CommandOptions) { this.name = options.name; diff --git a/src/structures/contexts/CommandContext.ts b/src/structures/contexts/CommandContext.ts index 4443803..808da5f 100644 --- a/src/structures/contexts/CommandContext.ts +++ b/src/structures/contexts/CommandContext.ts @@ -1,13 +1,15 @@ -import { APIApplicationCommandInteractionDataOption, APIChatInputApplicationCommandInteractionDataResolved, APIInteractionResponse, InteractionResponseType } from 'discord-api-types/v10'; +import { APIApplicationCommandInteractionDataOption, APIChatInputApplicationCommandInteractionDataResolved, APIInteractionResponse, APIUser, InteractionResponseType } from 'discord-api-types/v10'; import { Context } from 'hono'; export class CommandContext { public context: Context; + public user?: APIUser; public options?: APIApplicationCommandInteractionDataOption[]; public resolved?: APIChatInputApplicationCommandInteractionDataResolved; - public constructor(c: Context, options?: APIApplicationCommandInteractionDataOption[], resolved?: APIChatInputApplicationCommandInteractionDataResolved) { + public constructor(c: Context, user?: APIUser, options?: APIApplicationCommandInteractionDataOption[], resolved?: APIChatInputApplicationCommandInteractionDataResolved) { this.context = c; + this.user = user; this.options = options; this.resolved = resolved; } diff --git a/src/utils/isNumeric.ts b/src/utils/isNumeric.ts new file mode 100644 index 0000000..939a452 --- /dev/null +++ b/src/utils/isNumeric.ts @@ -0,0 +1 @@ +export default (value: string) => /^-?\d+$/.test(value); \ No newline at end of file diff --git a/src/utils/regexes.ts b/src/utils/regexes.ts new file mode 100644 index 0000000..39104d0 --- /dev/null +++ b/src/utils/regexes.ts @@ -0,0 +1,2 @@ +export const githubIssuesAndPullRequests = (owner: string, repository: string) => + new RegExp(`https?:\\\/\\\/github\\\.com\\\/${owner}\\\/${repository}\\\/(?:issues\\\/\\\d+|pull\\\/\d+)`, 'gm'); \ No newline at end of file