From 6b8372a93c6e4d8f89990982f9302493c825e71c Mon Sep 17 00:00:00 2001 From: Daan Klarenbeek Date: Mon, 24 Apr 2023 13:51:51 +0200 Subject: [PATCH 1/4] feat: add webhooks list command --- .../commands/webhooks/WebhookListCommand.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 src/discord/bot/commands/webhooks/WebhookListCommand.ts diff --git a/src/discord/bot/commands/webhooks/WebhookListCommand.ts b/src/discord/bot/commands/webhooks/WebhookListCommand.ts new file mode 100644 index 00000000..e0bbb352 --- /dev/null +++ b/src/discord/bot/commands/webhooks/WebhookListCommand.ts @@ -0,0 +1,25 @@ +import { ApplyOptions, Command, type CommandOptions } from "@snowcrystals/iglo"; +import { EmbedBuilder, type CommandInteraction } from "discord.js"; +import type GitCordClient from "#discord/lib/GitCordClient.js"; + +@ApplyOptions({ + name: "webhooks", + description: "Returns the list of created webhooks in this guild", + permissions: { dm: false, default: ["ManageGuild"] } +}) +export default class extends Command { + public override async run(interaction: CommandInteraction<"cached">) { + const config = this.client.databaseManager.configs.get(interaction.guildId); + const embed = new EmbedBuilder() + .setTitle(`List of created webhooks`) + .setDescription(`${config!.webhooks.size} out of 3 slots used.`) + .addFields( + config!.webhooks.map((webhook) => ({ + name: webhook.id, + value: [`Type: \`${webhook.type.toLowerCase()}\``, `Channel: <#${webhook.id}>`].join("\n") + })) + ); + + await interaction.reply({ embeds: [embed] }); + } +} From e22a792b6af604454d3eb250e7f31f3c4bc3d5e5 Mon Sep 17 00:00:00 2001 From: Daan Klarenbeek Date: Mon, 24 Apr 2023 13:55:57 +0200 Subject: [PATCH 2/4] feat: add create webhook command --- package.json | 2 +- src/database/structures/Guild.ts | 26 +++++++++++++- .../commands/webhooks/CreateWebhookCommand.ts | 34 +++++++++++++++++++ src/discord/lib/GitCordClient.ts | 5 +-- src/lib/constants.ts | 2 +- yarn.lock | 10 +++--- 6 files changed, 69 insertions(+), 10 deletions(-) create mode 100644 src/discord/bot/commands/webhooks/CreateWebhookCommand.ts diff --git a/package.json b/package.json index 16924e17..da83d2be 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "@prisma/client": "^4.13.0", "@sapphire/discord-utilities": "^3.0.2", "@snowcrystals/icicle": "^1.0.4", - "@snowcrystals/iglo": "^1.2.2", + "@snowcrystals/iglo": "next", "colorette": "2.0.20", "discord.js": "^14.9.0", "dotenv": "^16.0.3", diff --git a/src/database/structures/Guild.ts b/src/database/structures/Guild.ts index 0e077b07..187154ed 100644 --- a/src/database/structures/Guild.ts +++ b/src/database/structures/Guild.ts @@ -1,7 +1,8 @@ import type { GuildConfig } from "#database/types.js"; import type GitCordClient from "#discord/lib/GitCordClient.js"; -import { Collection, Guild } from "discord.js"; +import { ChannelType, Collection, ForumChannel, Guild, TextChannel } from "discord.js"; import GitCordGuildWebhook from "./GuildWebhook.js"; +import { randomBytes } from "node:crypto"; export default class GitCordGuild { public guild!: Guild; @@ -28,4 +29,27 @@ export default class GitCordGuild { return true; } + + /** + * Creates a new webhook for GitHub notifications + * @param channel The channel the notifications should be posted in + * @throws Error with reason why the creation failed + */ + public async create(channel: ForumChannel | TextChannel) { + const type = channel.type === ChannelType.GuildForum ? "FORUM" : "CHANNEL"; + const webhook = await channel.createWebhook({ name: "GitCord", avatar: "https://cdn.ijskoud.dev/files/2zVGPBN3ZmId.webp" }).catch(() => { + throw new Error("Unable to create a webhook, probably missing permissions."); + }); + + const secret = randomBytes(64).toString("hex"); + + const prismaData = await this.client.prisma.guildWebhook.create({ + data: { type, webhookUrl: webhook.url, guildId: this.guildId, webhookId: webhook.channelId, webhookSecret: secret } + }); + + const gitcordWebhook = new GitCordGuildWebhook(this, prismaData); + this.webhooks.set(webhook.channelId, gitcordWebhook); + + return gitcordWebhook; + } } diff --git a/src/discord/bot/commands/webhooks/CreateWebhookCommand.ts b/src/discord/bot/commands/webhooks/CreateWebhookCommand.ts new file mode 100644 index 00000000..72acd28b --- /dev/null +++ b/src/discord/bot/commands/webhooks/CreateWebhookCommand.ts @@ -0,0 +1,34 @@ +import { ApplyOptions, Command, type CommandOptions } from "@snowcrystals/iglo"; +import { type CommandInteraction, ApplicationCommandOptionType, ChannelType, type TextChannel, type ForumChannel } from "discord.js"; +import type GitCordClient from "#discord/lib/GitCordClient.js"; + +@ApplyOptions({ + name: "create", + description: "Creates a new webhook", + permissions: { dm: false, default: ["ManageGuild"] }, + options: [ + { + name: "channel", + description: "The channel to post the messages in", + type: ApplicationCommandOptionType.Channel, + channelTypes: [ChannelType.GuildText, ChannelType.GuildForum], + required: true + } + ] +}) +export default class extends Command { + public override async run(interaction: CommandInteraction<"cached">) { + const config = this.client.databaseManager.configs.get(interaction.guildId); + if (!config || config.webhooks.size >= 3) { + await interaction.reply("You reached the webhook limit for this guild (3/3 used)"); + return; + } + + await interaction.deferReply({ ephemeral: true }); + const channel = interaction.options.get("channel", true); + if (!channel.channel || ![ChannelType.GuildText, ChannelType.GuildForum].includes(channel.channel.type)) return; // Fixes intellisense + + const webhook = await config.create(channel.channel as TextChannel | ForumChannel); + await interaction.followUp({ content: `Webhook created, url: \`${webhook}\` --- Secret: \`${webhook.secret}\``, ephemeral: true }); + } +} diff --git a/src/discord/lib/GitCordClient.ts b/src/discord/lib/GitCordClient.ts index 0e6f9f42..1c9bf056 100644 --- a/src/discord/lib/GitCordClient.ts +++ b/src/discord/lib/GitCordClient.ts @@ -1,4 +1,4 @@ -import { IgloClient } from "@snowcrystals/iglo"; +import { IgloClient, LogLevel } from "@snowcrystals/iglo"; import { BOT_COMMANDS_DIR, BOT_INTERACTIONS_DIR, BOT_LISTENER_DIR } from "#shared/constants.js"; import GitHubManager from "#github/lib/GitHubManager.js"; import { PrismaClient } from "@prisma/client"; @@ -13,7 +13,8 @@ export default class GitCordClient extends IgloClient { public constructor() { super({ client: { intents: ["GuildWebhooks", "Guilds"], allowedMentions: { repliedUser: true, roles: [], users: [] } }, - paths: { commands: BOT_COMMANDS_DIR, events: BOT_LISTENER_DIR, interactions: BOT_INTERACTIONS_DIR } + paths: { commands: BOT_COMMANDS_DIR, events: BOT_LISTENER_DIR, interactions: BOT_INTERACTIONS_DIR }, + logger: { level: process.env.NODE_ENV === "development" ? LogLevel.Debug : LogLevel.Info, depth: 2 } }); } diff --git a/src/lib/constants.ts b/src/lib/constants.ts index ad8cecb3..9b1deb09 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -4,7 +4,7 @@ import { fileURLToPath } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url)); // === Discord bot directory constants === -export const BASE_BOT_DIR = join(__dirname, "..", "bot"); +export const BASE_BOT_DIR = join(__dirname, "..", "discord", "bot"); export const BOT_COMMANDS_DIR = join(BASE_BOT_DIR, "commands"); export const BOT_LISTENER_DIR = join(BASE_BOT_DIR, "listeners"); export const BOT_INTERACTIONS_DIR = join(BASE_BOT_DIR, "interactions"); diff --git a/yarn.lock b/yarn.lock index 01287be6..f62a0eeb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -616,14 +616,14 @@ __metadata: languageName: node linkType: hard -"@snowcrystals/iglo@npm:^1.2.2": - version: 1.2.2 - resolution: "@snowcrystals/iglo@npm:1.2.2" +"@snowcrystals/iglo@npm:next": + version: 1.3.0-next.676fbee.0 + resolution: "@snowcrystals/iglo@npm:1.3.0-next.676fbee.0" dependencies: "@snowcrystals/icicle": 1.0.4 discord.js: ^14.9.0 lodash: ^4.17.21 - checksum: 4d7b84d289a2a94bde75d1db63feaa0544c348b39802513987adf03462caed84658cf244fc3268e3c3513bd0cd7b4ca130264f809197ba8c4d08a179f5c7c1bb + checksum: 75fa847566388536e6ec2f54ce042f537d24ca6c1de673810ce9c3959e0a9531fbe6b769ed9d97aebd0b08553b65b26e8afc403e4a569e5614433e41839f7da7 languageName: node linkType: hard @@ -2549,7 +2549,7 @@ __metadata: "@sapphire/prettier-config": ^1.4.5 "@sapphire/ts-config": ^4.0.0 "@snowcrystals/icicle": ^1.0.4 - "@snowcrystals/iglo": ^1.2.2 + "@snowcrystals/iglo": next "@types/eventsource": ^1.1.11 "@types/express": ^4.17.17 "@types/node": ^18.16.0 From cce668d2bf1cff100a64449603b93cb6227222a7 Mon Sep 17 00:00:00 2001 From: Daan Klarenbeek Date: Mon, 24 Apr 2023 14:14:49 +0200 Subject: [PATCH 3/4] feat: add delete command --- src/database/structures/Guild.ts | 17 ++++++ .../commands/webhooks/DeleteWebhookCommand.ts | 56 +++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 src/discord/bot/commands/webhooks/DeleteWebhookCommand.ts diff --git a/src/database/structures/Guild.ts b/src/database/structures/Guild.ts index 187154ed..e8415fd5 100644 --- a/src/database/structures/Guild.ts +++ b/src/database/structures/Guild.ts @@ -52,4 +52,21 @@ export default class GitCordGuild { return gitcordWebhook; } + + /** + * Deletes an existing webhook + * @param webhookId The webhook to delete + * @returns Boolean depending on the existence of the webhook + * @throws DiscordApiError if the webhook deletion fails + */ + public async delete(webhookId: string) { + const webhook = this.webhooks.get(webhookId); + if (!webhook) return false; + + await webhook.discordWebhook.delete(); + await this.client.prisma.guildWebhook.delete({ where: { webhookId } }); + + this.webhooks.delete(webhookId); + return true; + } } diff --git a/src/discord/bot/commands/webhooks/DeleteWebhookCommand.ts b/src/discord/bot/commands/webhooks/DeleteWebhookCommand.ts new file mode 100644 index 00000000..0e672240 --- /dev/null +++ b/src/discord/bot/commands/webhooks/DeleteWebhookCommand.ts @@ -0,0 +1,56 @@ +import { ApplyOptions, Command, type CommandOptions } from "@snowcrystals/iglo"; +import { type CommandInteraction, ApplicationCommandOptionType, AutocompleteInteraction } from "discord.js"; +import type GitCordClient from "#discord/lib/GitCordClient.js"; + +@ApplyOptions({ + name: "delete", + description: "Deletes a webhook", + permissions: { dm: false, default: ["ManageGuild"] }, + options: [ + { + name: "webhook", + description: "The channel the webhook is connected to", + type: ApplicationCommandOptionType.String, + autocomplete: true, + required: true + } + ] +}) +export default class extends Command { + public override async autocomplete(interaction: AutocompleteInteraction<"cached">) { + const input = interaction.options.get("webhook", true); + const config = this.client.databaseManager.configs.get(interaction.guildId); + if (!config) { + await interaction.respond([]); + return; + } + + if (!input.value || typeof input.value !== "string") { + await interaction.respond(config.webhooks.map((webhook) => ({ name: webhook.id, value: webhook.id }))); + return; + } + + const inputValue = input.value as string; // The command input type is a string + const allOptions = config.webhooks.map((webhook) => webhook.id); + const options = allOptions.filter((opt) => opt.startsWith(inputValue) || opt.endsWith(inputValue) || opt.includes(inputValue)); + + await interaction.respond(options.map((opt) => ({ name: opt, value: opt }))); + } + + public override async run(interaction: CommandInteraction<"cached">) { + const config = this.client.databaseManager.configs.get(interaction.guildId); + if (!config) throw new Error(`Missing config for guild with id ${interaction.guildId}`); + + await interaction.deferReply(); + const webhookId = interaction.options.get("webhook", true); + if (!webhookId.value || typeof webhookId.value !== "string") return; // Fixes intellisense + + const webhook = await config.delete(webhookId.value as string); + if (!webhook) { + await interaction.followUp("No webhook was found with the provided id"); + return; + } + + await interaction.followUp("Webhook deleted."); + } +} From 15f8461432a767f4c0a408048bf24b431e24c445 Mon Sep 17 00:00:00 2001 From: Daan Klarenbeek Date: Mon, 24 Apr 2023 14:49:00 +0200 Subject: [PATCH 4/4] feat: add get secret command --- .../bot/commands/webhooks/GetSecretCommand.ts | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 src/discord/bot/commands/webhooks/GetSecretCommand.ts diff --git a/src/discord/bot/commands/webhooks/GetSecretCommand.ts b/src/discord/bot/commands/webhooks/GetSecretCommand.ts new file mode 100644 index 00000000..b2ee9495 --- /dev/null +++ b/src/discord/bot/commands/webhooks/GetSecretCommand.ts @@ -0,0 +1,53 @@ +import { ApplyOptions, Command, type CommandOptions } from "@snowcrystals/iglo"; +import { type CommandInteraction, ApplicationCommandOptionType, AutocompleteInteraction } from "discord.js"; +import type GitCordClient from "#discord/lib/GitCordClient.js"; + +@ApplyOptions({ + name: "secret", + description: "Get the secret key of a webhook", + permissions: { dm: false, default: ["ManageGuild"] }, + options: [ + { + name: "webhook", + description: "The webhook id", + type: ApplicationCommandOptionType.String, + autocomplete: true, + required: true + } + ] +}) +export default class extends Command { + public override async autocomplete(interaction: AutocompleteInteraction<"cached">) { + const input = interaction.options.get("webhook", true); + const config = this.client.databaseManager.configs.get(interaction.guildId); + if (!config) { + await interaction.respond([]); + return; + } + + if (!input.value || typeof input.value !== "string") { + await interaction.respond(config.webhooks.map((webhook) => ({ name: webhook.id, value: webhook.id }))); + return; + } + + const inputValue = input.value as string; // The command input type is a string + const allOptions = config.webhooks.map((webhook) => webhook.id); + const options = allOptions.filter((opt) => opt.startsWith(inputValue) || opt.endsWith(inputValue) || opt.includes(inputValue)); + + await interaction.respond(options.map((opt) => ({ name: opt, value: opt }))); + } + + public override async run(interaction: CommandInteraction<"cached">) { + const config = this.client.databaseManager.configs.get(interaction.guildId); + if (!config) throw new Error(`Missing config for guild with id ${interaction.guildId}`); + + const webhookId = interaction.options.get("webhook", true); + const webhook = config.webhooks.get(webhookId.value as string); + if (!webhook) { + await interaction.reply({ content: "No webhook found with the provided id", ephemeral: true }); + return; + } + + await interaction.reply({ content: `The secret for this webhook: \`${webhook.secret}\``, ephemeral: true }); + } +}