diff --git a/api/src/config.ts b/api/src/config.ts index 353a69062..c97f8dfad 100644 --- a/api/src/config.ts +++ b/api/src/config.ts @@ -7,6 +7,10 @@ type DeepReadOnly = { : T[k]; }; +export enum DiscordChannelHandleStyle { + Agile = "agile", +} + export type CTFNoteConfig = DeepReadOnly<{ env: string; sessionSecret: string; @@ -45,6 +49,7 @@ export type CTFNoteConfig = DeepReadOnly<{ registrationEnabled: string; registrationAccountRole: string; registrationRoleId: string; + channelHandleStyle: DiscordChannelHandleStyle; }; }>; @@ -102,6 +107,10 @@ const config: CTFNoteConfig = { "user_guest" ), registrationRoleId: getEnv("DISCORD_REGISTRATION_ROLE_ID", ""), + channelHandleStyle: getEnv( + "DISCORD_CHANNEL_HANDLE_STYLE", + "agile" + ) as DiscordChannelHandleStyle, }, }; diff --git a/api/src/discord/utils/channels.ts b/api/src/discord/agile/channels.ts similarity index 98% rename from api/src/discord/utils/channels.ts rename to api/src/discord/agile/channels.ts index 43cd70b92..e9733b333 100644 --- a/api/src/discord/utils/channels.ts +++ b/api/src/discord/agile/channels.ts @@ -25,13 +25,13 @@ import { getTaskTitleFromTopic, sendMessageToChannel, topicDelimiter, -} from "./messages"; +} from "../utils/messages"; import { isChannelOfCtf, isTaskChannelOf, isRoleOfCtf, isCategoryOfCtf, -} from "./comparison"; +} from "../utils/comparison"; import { safeSlugify } from "../../utils/utils"; enum CategoryType { @@ -53,6 +53,8 @@ export interface TaskInput { flag: string; } +export const challengesTalkChannelName = "challenges-talk"; + const newPrefix = "New - "; const startedPrefix = "Started - "; const solvedPrefix = "Solved - "; @@ -171,7 +173,7 @@ export async function createChannelsAndRolesForCtf(guild: Guild, ctf: CTF) { // create challenges-talk channel await guild?.channels.create({ - name: `challenges-talk`, + name: challengesTalkChannelName, type: ChannelType.GuildText, parent: startedCategory?.id, }); @@ -231,7 +233,7 @@ function getTalkChannelForCtf(guild: Guild, ctf: CTF) { return guild.channels.cache.find( (channel) => channel.type === ChannelType.GuildText && - channel.name === `challenges-talk` && + channel.name === challengesTalkChannelName && isChannelOfCtf(channel, startedCategoryName(ctf)) ) as TextChannel; } diff --git a/api/src/discord/commands.ts b/api/src/discord/agile/commands.ts similarity index 86% rename from api/src/discord/commands.ts rename to api/src/discord/agile/commands.ts index 18c9705ba..d19271dbf 100644 --- a/api/src/discord/commands.ts +++ b/api/src/discord/agile/commands.ts @@ -1,4 +1,3 @@ -import { Command } from "./command"; import { CreateCtf } from "./commands/createCtf"; import { ArchiveCtf } from "./commands/archiveCtf"; import { SolveTask } from "./commands/solveTask"; @@ -7,7 +6,7 @@ import { StartWorking, StopWorking } from "./commands/workingOn"; import { DeleteCtf } from "./commands/deleteCtf"; import { Register } from "./commands/register"; -export const Commands: Command[] = [ +export default [ ArchiveCtf, CreateCtf, SolveTask, diff --git a/api/src/discord/commands/archiveCtf.ts b/api/src/discord/agile/commands/archiveCtf.ts similarity index 65% rename from api/src/discord/commands/archiveCtf.ts rename to api/src/discord/agile/commands/archiveCtf.ts index 6ee092689..5b2bb061d 100644 --- a/api/src/discord/commands/archiveCtf.ts +++ b/api/src/discord/agile/commands/archiveCtf.ts @@ -2,27 +2,28 @@ import { ActionRowBuilder, ApplicationCommandType, ButtonBuilder, + ButtonInteraction, ButtonStyle, Client, CommandInteraction, - Interaction, PermissionFlagsBits, } from "discord.js"; -import { Command } from "../command"; +import { Command } from "../../interfaces/command"; import { createTask, getAllCtfsFromDatabase, getCtfFromDatabase, -} from "../database/ctfs"; -import { getChannelCategoriesForCtf } from "../utils/channels"; +} from "../../database/ctfs"; +import { getChannelCategoriesForCtf } from "../channels"; import { convertMessagesToPadFormat, createPadWithoutLimit, getMessagesOfCategories, -} from "../utils/messages"; +} from "../../utils/messages"; +import { DiscordButtonInteraction } from "../../interfaces/interaction"; -export async function handleArchiveInteraction( - interaction: Interaction, +async function handleArchiveInteraction( + interaction: ButtonInteraction, ctfName: string ) { const guild = interaction.guild; @@ -53,11 +54,35 @@ export async function handleArchiveInteraction( return true; } +export const HandleArchiveCtfInteraction: DiscordButtonInteraction = { + customId: "archive-ctf-button", + handle: async (client: Client, interaction: ButtonInteraction) => { + const ctfName = interaction.customId.replace("archive-ctf-button-", ""); + await interaction.deferUpdate(); + await interaction.editReply({ + content: `Archiving the CTF channels and roles for ${ctfName}`, + components: [], + }); + + if (await handleArchiveInteraction(interaction, ctfName)) { + await interaction.editReply({ + content: `Archived the CTF channels and roles for ${ctfName}`, + components: [], + }); + } else { + await interaction.editReply({ + content: `Failed to archive the CTF channels and roles for ${ctfName}`, + components: [], + }); + } + }, +}; + async function archiveCtfLogic( client: Client, interaction: CommandInteraction ) { - // Get current CTFs from the discord categorys + // Get current CTFs from the discord categories let ctfNames = await getAllCtfsFromDatabase(); const guild = interaction.guild; if (guild == null) return; diff --git a/api/src/discord/commands/createCtf.ts b/api/src/discord/agile/commands/createCtf.ts similarity index 54% rename from api/src/discord/commands/createCtf.ts rename to api/src/discord/agile/commands/createCtf.ts index 706ccfe57..348163d73 100644 --- a/api/src/discord/commands/createCtf.ts +++ b/api/src/discord/agile/commands/createCtf.ts @@ -2,14 +2,57 @@ import { ActionRowBuilder, ApplicationCommandType, ButtonBuilder, + ButtonInteraction, ButtonStyle, Client, CommandInteraction, PermissionFlagsBits, } from "discord.js"; -import { Command } from "../command"; -import { getCTFNamesFromDatabase } from "../database/ctfs"; -import { getChannelCategoriesForCtf } from "../utils/channels"; +import { Command } from "../../interfaces/command"; +import { + getCtfFromDatabase, + getCTFNamesFromDatabase, +} from "../../database/ctfs"; +import { + createChannelForTaskInCtf, + createChannelsAndRolesForCtf, + getChannelCategoriesForCtf, +} from "../channels"; +import { DiscordButtonInteraction } from "../../interfaces/interaction"; +import { getChallengesFromDatabase } from "../../database/tasks"; + +export const HandleCreateCtfInteraction: DiscordButtonInteraction = { + customId: "create-ctf-button", + handle: async (client: Client, interaction: ButtonInteraction) => { + const ctfName = interaction.customId.replace("create-ctf-button-", ""); + await interaction.deferUpdate(); + await interaction.editReply({ + content: `Creating the CTF channels and roles for ${ctfName}`, + components: [], + }); + + const guild = interaction.guild; + if (guild == null) return; + + // assign roles to users already having access to the ctf + const ctf = await getCtfFromDatabase(ctfName); + if (ctf == null) return; + + await createChannelsAndRolesForCtf(guild, ctf); + + // create for every challenge a channel + const challenges = await getChallengesFromDatabase(ctf.id); + + for (const challenge of challenges) { + await createChannelForTaskInCtf(guild, challenge, ctf); + } + + await interaction.editReply({ + content: `Created the CTF channels and roles for ${ctfName}`, + components: [], + }); + }, +}; async function createCtfLogic(client: Client, interaction: CommandInteraction) { let ctfNames = await getCTFNamesFromDatabase(); diff --git a/api/src/discord/commands/deleteCtf.ts b/api/src/discord/agile/commands/deleteCtf.ts similarity index 58% rename from api/src/discord/commands/deleteCtf.ts rename to api/src/discord/agile/commands/deleteCtf.ts index 226719284..e37d39298 100644 --- a/api/src/discord/commands/deleteCtf.ts +++ b/api/src/discord/agile/commands/deleteCtf.ts @@ -2,18 +2,23 @@ import { ActionRowBuilder, ApplicationCommandType, ButtonBuilder, + ButtonInteraction, ButtonStyle, Client, CommandInteraction, Interaction, PermissionFlagsBits, } from "discord.js"; -import { Command } from "../command"; -import { getAllCtfsFromDatabase, getCtfFromDatabase } from "../database/ctfs"; -import { getChannelCategoriesForCtf } from "../utils/channels"; -import { handleDeleteCtf } from "../../plugins/discordHooks"; -import { getTaskByCtfIdAndNameFromDatabase } from "../database/tasks"; -import { discordArchiveTaskName } from "../utils/messages"; +import { Command } from "../../interfaces/command"; +import { + getAllCtfsFromDatabase, + getCtfFromDatabase, +} from "../../database/ctfs"; +import { getChannelCategoriesForCtf } from "../channels"; +import { handleDeleteCtf } from "../hooks"; +import { getTaskByCtfIdAndNameFromDatabase } from "../../database/tasks"; +import { discordArchiveTaskName } from "../../utils/messages"; +import { DiscordButtonInteraction } from "../../interfaces/interaction"; export async function handleDeleteInteraction( interaction: Interaction, @@ -74,6 +79,37 @@ async function deleteCtfLogic(client: Client, interaction: CommandInteraction) { }); } +export const HandleDeleteCtfInteraction: DiscordButtonInteraction = { + customId: "delete-ctf-button", + handle: async (client: Client, interaction: ButtonInteraction) => { + const ctfName = interaction.customId.replace("delete-ctf-button-", ""); + await interaction.deferUpdate(); + await interaction.editReply({ + content: `Deleting the CTF channels and roles for ${ctfName}`, + components: [], + }); + + const done = await handleDeleteInteraction(interaction, ctfName); + if (!done) { + await interaction.editReply({ + content: `I insist that you create an \`/archive\` first!`, + }); + } else { + try { + await interaction.editReply({ + content: `Deleted the CTF channels and roles for ${ctfName}`, + components: [], + }); + } catch (error) { + console.debug( + "Failed to update message that CTF was deleted. Probably the command was triggered in a channel that got deleted by the /delete command.", + error + ); + } + } + }, +}; + export const DeleteCtf: Command = { name: "delete", description: "Delete the channels and roles for a CTF. This is irreversible!", diff --git a/api/src/discord/commands/linkUser.ts b/api/src/discord/agile/commands/linkUser.ts similarity index 93% rename from api/src/discord/commands/linkUser.ts rename to api/src/discord/agile/commands/linkUser.ts index f85542922..359506dbb 100644 --- a/api/src/discord/commands/linkUser.ts +++ b/api/src/discord/agile/commands/linkUser.ts @@ -4,15 +4,15 @@ import { Client, CommandInteraction, } from "discord.js"; -import { Command } from "../command"; +import { Command } from "../../interfaces/command"; import { getDiscordIdFromUserId, getUserByToken, setDiscordIdForUser, -} from "../database/users"; -import { CTF, getAccessibleCTFsForUser } from "../database/ctfs"; -import { getDiscordClient } from ".."; -import config from "../../config"; +} from "../../database/users"; +import { CTF, getAccessibleCTFsForUser } from "../../database/ctfs"; +import { getDiscordClient } from "../.."; +import config from "../../../config"; import { PoolClient } from "pg"; export async function changeDiscordUserRoleForCTF( diff --git a/api/src/discord/commands/register.ts b/api/src/discord/agile/commands/register.ts similarity index 96% rename from api/src/discord/commands/register.ts rename to api/src/discord/agile/commands/register.ts index a6a68b914..3552c6b55 100644 --- a/api/src/discord/commands/register.ts +++ b/api/src/discord/agile/commands/register.ts @@ -4,14 +4,14 @@ import { CommandInteraction, GuildMemberRoleManager, } from "discord.js"; -import { Command } from "../command"; +import { Command } from "../../interfaces/command"; import { AllowedRoles, createInvitationTokenForDiscordId, getInvitationTokenForDiscordId, getUserByDiscordId, -} from "../database/users"; -import config from "../../config"; +} from "../../database/users"; +import config from "../../../config"; async function getInvitationUrl(invitationCode: string | null = null) { if (config.pad.domain == "") return null; diff --git a/api/src/discord/commands/solveTask.ts b/api/src/discord/agile/commands/solveTask.ts similarity index 85% rename from api/src/discord/commands/solveTask.ts rename to api/src/discord/agile/commands/solveTask.ts index 101452bdd..d7502bd79 100644 --- a/api/src/discord/commands/solveTask.ts +++ b/api/src/discord/agile/commands/solveTask.ts @@ -3,12 +3,18 @@ import { ApplicationCommandType, Client, CommandInteraction, + Guild, } from "discord.js"; -import { Command } from "../command"; -import { setFlagForChallengeId } from "../database/tasks"; -import { handleTaskSolved } from "../../plugins/discordHooks"; -import { getUserByDiscordId } from "../database/users"; -import { getCurrentTaskChannelFromDiscord } from "../utils/channels"; +import { Command } from "../../interfaces/command"; +import { getTaskFromId, setFlagForChallengeId } from "../../database/tasks"; +import { getUserByDiscordId } from "../../database/users"; +import { + ChannelMovingEvent, + getCurrentTaskChannelFromDiscord, + moveChannel, +} from "../channels"; +import { sendMessageToTask } from "../../utils/messages"; +import { convertToUsernameFormat } from "../../utils/user"; async function accessDenied(interaction: CommandInteraction) { await interaction.editReply({ @@ -16,6 +22,23 @@ async function accessDenied(interaction: CommandInteraction) { }); } +export async function handleTaskSolved( + guild: Guild, + id: bigint, + userId: bigint | string +) { + const task = await getTaskFromId(id); + if (task == null) return; + + await moveChannel(guild, task, null, ChannelMovingEvent.SOLVED); + + return sendMessageToTask( + guild, + id, + `${task.title} is solved by ${await convertToUsernameFormat(userId)}!` + ); +} + async function solveTaskLogic(client: Client, interaction: CommandInteraction) { const r = await getCurrentTaskChannelFromDiscord(interaction); if (r == null) return accessDenied(interaction); diff --git a/api/src/discord/commands/workingOn.ts b/api/src/discord/agile/commands/workingOn.ts similarity index 98% rename from api/src/discord/commands/workingOn.ts rename to api/src/discord/agile/commands/workingOn.ts index c934cc17f..35873b05e 100644 --- a/api/src/discord/commands/workingOn.ts +++ b/api/src/discord/agile/commands/workingOn.ts @@ -1,15 +1,12 @@ import { ApplicationCommandType, Client, CommandInteraction } from "discord.js"; -import { Command } from "../command"; +import { Command } from "../../interfaces/command"; import { userStartsWorkingOnTask, userStopsWorkingOnTask, -} from "../database/tasks"; -import { - sendStartWorkingOnMessage, - sendStopWorkingOnMessage, -} from "../../plugins/discordHooks"; -import { getUserByDiscordId } from "../database/users"; -import { getCurrentTaskChannelFromDiscord } from "../utils/channels"; +} from "../../database/tasks"; +import { sendStartWorkingOnMessage, sendStopWorkingOnMessage } from "../hooks"; +import { getUserByDiscordId } from "../../database/users"; +import { getCurrentTaskChannelFromDiscord } from "../channels"; async function accessDenied(interaction: CommandInteraction) { await interaction.editReply({ diff --git a/api/src/discord/agile/hooks.ts b/api/src/discord/agile/hooks.ts new file mode 100644 index 000000000..43fca30e2 --- /dev/null +++ b/api/src/discord/agile/hooks.ts @@ -0,0 +1,459 @@ +import { Build, Context } from "postgraphile"; +import { ChannelType, Guild } from "discord.js"; +import { + getAccessibleCTFsForUser, + getAllCtfsFromDatabase, + getCtfFromDatabase, +} from "../database/ctfs"; +import { getDiscordGuild, usingDiscordBot } from ".."; +import { changeDiscordUserRoleForCTF } from "./commands/linkUser"; +import { getUserIdFromUsername } from "../database/users"; +import { + Task, + getTaskByCtfIdAndNameFromDatabase, + getTaskFromId, +} from "../database/tasks"; +import { sendMessageToTask } from "../utils/messages"; +import { + ChannelMovingEvent, + createChannelForNewTask, + getActiveCtfCategories, + getTaskChannel, + moveChannel, +} from "./channels"; +import { isCategoryOfCtf } from "../utils/comparison"; +import { GraphQLResolveInfoWithMessages } from "@graphile/operation-hooks"; +import { syncDiscordPermissionsWithCtf } from "../utils/permissionSync"; +import { convertToUsernameFormat } from "../utils/user"; +import { PoolClient } from "pg"; +import { handleTaskSolved } from "./commands/solveTask"; + +async function handleCreateTask( + guild: Guild, + ctfId: bigint, + title: string, + pgClient: PoolClient | null +) { + // we have to query the task using the context.pgClient in order to see the newly created task + const task = await getTaskByCtfIdAndNameFromDatabase(ctfId, title, pgClient); + if (task == null) return null; + + // we have to await this since big imports will cause race conditions with the Discord API + await createChannelForNewTask(guild, task, true); +} + +async function handleDeleteTask(guild: Guild, taskId: bigint) { + const task = await getTaskFromId(taskId); + if (task == null) return null; + + const channel = await getTaskChannel(guild, task, null); + if (channel == null) return null; + + channel + .setName(`deleted-${task.title}`) + .catch((err) => console.error("Failed to mark channel as deleted.", err)); +} + +async function handleUpdateTask( + guild: Guild, + taskId: bigint | null, + newTitle: string | null, + newFlag: string | null, + newDescription: string | null, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + context: any +) { + if (taskId == null) return null; + + const task = await getTaskFromId(taskId); + if (task == null) return null; + + if (newFlag != null) { + if (newFlag !== "") { + const userId = context.jwtClaims.user_id; + + handleTaskSolved(guild, taskId, userId); + } else { + const task = await getTaskFromId(taskId); + if (task == null) return null; + + moveChannel(guild, task, null, ChannelMovingEvent.UNSOLVED); + } + } + + // handle task title change + if (newTitle != null && newTitle != task.title) { + const channel = await getTaskChannel(guild, task, null); + if (channel == null) return null; + + channel + .edit({ + name: newTitle, + topic: channel.topic?.replace(task.title, newTitle), + }) + .catch((err) => console.error("Failed to rename channel.", err)); + } + + // handle task description change + if (newDescription != null && newDescription !== task.description) { + sendMessageToTask(guild, task, `Description changed:\n${newDescription}`); + } +} + +async function handleStartWorkingOn( + guild: Guild, + taskId: bigint, + userId: bigint +) { + await moveChannel(guild, taskId, null, ChannelMovingEvent.START); + await sendStartWorkingOnMessage(guild, userId, taskId); +} + +async function handleUpdateUserRole( + guild: Guild, + userId: bigint, + pgClient: PoolClient +) { + // reset all roles + const currentCtfs = await getActiveCtfCategories(guild); + await Promise.all( + currentCtfs.map(async function (ctf) { + return changeDiscordUserRoleForCTF(userId, ctf, "remove").catch((err) => { + console.error("Error while adding role to user: ", err); + }); + }) + ); + // re-assign roles if accessible + const ctfs = await getAccessibleCTFsForUser(userId, pgClient); + ctfs.forEach(function (ctf) { + changeDiscordUserRoleForCTF(userId, ctf, "add").catch((err) => { + console.error("Error while adding role to user: ", err); + }); + }); +} + +async function handleRegisterWithToken(username: string) { + /* + * We have a nice ductape solution for the following problem: + * During the handling of these hooks, the changes to the database are not committed yet. + * This means that we can't query the database for the new user id. + * We have to wait a bit to make sure the user is in the database. + * Alternatively we can hook the postgraphile lifecycle but that is not compatible with the current setup. + * The outgoing request is probably handling within 1 second, so this works fine. + */ + setTimeout(async () => { + const userId = await getUserIdFromUsername(username, null); // use null to get a new client which is privileged as the Discord bot + if (userId == null) return; + const ctfs = await getAccessibleCTFsForUser(userId, null); + for (let i = 0; i < ctfs.length; i++) { + await changeDiscordUserRoleForCTF(userId, ctfs[i], "add").catch((err) => { + console.error("Error while adding role to user: ", err); + }); + } + }, 2000); +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any +const discordMutationHook = (_build: Build) => (fieldContext: Context) => { + const { + scope: { isRootMutation }, + } = fieldContext; + + if (!isRootMutation) return null; + + if (!usingDiscordBot) return null; + + if ( + fieldContext.scope.fieldName !== "updateTask" && + fieldContext.scope.fieldName !== "createTask" && + fieldContext.scope.fieldName !== "deleteTask" && + fieldContext.scope.fieldName !== "startWorkingOn" && + fieldContext.scope.fieldName !== "stopWorkingOn" && + fieldContext.scope.fieldName !== "cancelWorkingOn" && + fieldContext.scope.fieldName !== "updateCtf" && + fieldContext.scope.fieldName !== "createInvitation" && + fieldContext.scope.fieldName !== "deleteInvitation" && + fieldContext.scope.fieldName !== "resetDiscordId" && + fieldContext.scope.fieldName !== "deleteCtf" && + fieldContext.scope.fieldName !== "updateUserRole" && + fieldContext.scope.fieldName !== "setDiscordEventLink" && + fieldContext.scope.fieldName !== "registerWithToken" + ) { + return null; + } + + const handleDiscordMutationAfter = async ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + input: any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + args: any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + context: any, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _resolveInfo: GraphQLResolveInfoWithMessages + ) => { + const guild = getDiscordGuild(); + if (guild == null) return input; + + //add challenges to the ctf channel discord + switch (fieldContext.scope.fieldName) { + case "createTask": + handleCreateTask( + guild, + args.input.ctfId, + args.input.title, + context.pgClient + ).catch((err) => { + console.error("Failed to create task.", err); + }); + break; + case "deleteTask": + handleDeleteTask(guild, args.input.id).catch((err) => { + console.error("Failed to delete task.", err); + }); + break; + case "updateTask": + handleUpdateTask( + guild, + args.input.id, + args.input.patch.title, + args.input.patch.flag, + args.input.patch.description, + context + ).catch((err) => { + console.error("Failed to update task.", err); + }); + break; + case "startWorkingOn": + handleStartWorkingOn( + guild, + args.input.taskId, + context.jwtClaims.user_id + ).catch((err) => { + console.error("Failed to start working on task.", err); + }); + break; + case "stopWorkingOn": + case "cancelWorkingOn": + sendStopWorkingOnMessage( + guild, + context.jwtClaims.user_id, + args.input.taskId, + fieldContext.scope.fieldName === "cancelWorkingOn" + ).catch((err) => { + console.error( + "Failed sending 'stopped working on' notification.", + err + ); + }); + break; + case "createInvitation": + handeInvitation( + args.input.invitation.ctfId, + args.input.invitation.profileId, + "add" + ).catch((err) => { + console.error("Failed to create invitation.", err); + }); + break; + case "deleteInvitation": + handeInvitation(args.input.ctfId, args.input.profileId, "remove").catch( + (err) => { + console.error("Failed to delete invitation.", err); + } + ); + break; + case "updateUserRole": + handleUpdateUserRole(guild, args.input.userId, context.pgClient).catch( + (err) => { + console.error("Failed to update user role.", err); + } + ); + break; + case "setDiscordEventLink": + await syncDiscordPermissionsWithCtf( + guild, + args.input.ctfId, + args.input.link, + context.pgClient + ).catch((err) => { + console.error("Failed to sync discord permissions.", err); + }); + break; + case "registerWithToken": + handleRegisterWithToken(args.input.login).catch((err) => { + console.error("Failed to register with token.", err); + }); + break; + default: + break; + } + return input; + }; + + const handleDiscordMutationBefore = async ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + input: any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + args: any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + context: any, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _resolveInfo: GraphQLResolveInfoWithMessages + ) => { + const guild = getDiscordGuild(); + if (guild === null) return input; + + switch (fieldContext.scope.fieldName) { + case "updateCtf": + handleUpdateCtf(args, guild).catch((err) => { + console.error("Failed to update ctf.", err); + }); + break; + case "deleteCtf": + handleDeleteCtf(args.input.id, guild).catch((err) => { + console.error("Failed to delete ctf.", err); + }); + break; + case "resetDiscordId": + // we need to use the await here to prevent a race condition + // between deleting the discord id and retrieving the discord id (to remove the roles) + await handleResetDiscordId(context.jwtClaims.user_id).catch((err) => { + console.error("Failed to reset discord id.", err); + }); + break; + default: + break; + } + + return input; + }; + + return { + before: [ + { + priority: 500, + callback: handleDiscordMutationBefore, + }, + ], + after: [ + { + priority: 500, + callback: handleDiscordMutationAfter, + }, + ], + }; +}; + +export async function sendStartWorkingOnMessage( + guild: Guild, + userId: bigint, + task: Task | bigint +) { + await moveChannel(guild, task, null, ChannelMovingEvent.START); + return sendMessageToTask( + guild, + task, + `${await convertToUsernameFormat(userId)} is working on this task!` + ); +} + +export async function sendStopWorkingOnMessage( + guild: Guild, + userId: bigint, + task: Task | bigint, + cancel = false +) { + let text = "stopped"; + if (cancel) { + text = "cancelled"; + } + return sendMessageToTask( + guild, + task, + `${await convertToUsernameFormat(userId)} ${text} working on this task!` + ); +} + +export async function handleDeleteCtf(ctfId: string | bigint, guild: Guild) { + const ctf = await getCtfFromDatabase(ctfId); + if (ctf == null) return; + + const categoryChannels = guild.channels.cache.filter( + (channel) => + channel.type === ChannelType.GuildCategory && + isCategoryOfCtf(channel, ctf) + ); + + categoryChannels.map((categoryChannel) => { + guild?.channels.cache.map((channel) => { + if ( + channel.type === ChannelType.GuildVoice && + channel.parentId === categoryChannel.id + ) { + return channel.delete(); + } + }); + + guild?.channels.cache.map(async (channel) => { + if ( + channel.type === ChannelType.GuildText && + channel.parentId === categoryChannel.id + ) { + await channel.delete(); + } + }); + + categoryChannel.delete(); + }); + + guild.roles.cache.map((role) => { + if (role.name === ctf.title) { + return role.delete(); + } + }); +} + +async function handleResetDiscordId(userId: bigint) { + const allCtfs = await getAllCtfsFromDatabase(); + await changeDiscordUserRoleForCTF(userId, allCtfs, "remove"); +} + +async function handeInvitation( + ctfId: bigint, + profileId: bigint, + operation: "add" | "remove" +) { + const ctf = await getCtfFromDatabase(ctfId); + if (ctf == null) return; + await changeDiscordUserRoleForCTF(profileId, ctf, operation); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function handleUpdateCtf(args: any, guild: Guild) { + const ctf = await getCtfFromDatabase(args.input.id); + if (ctf == null) return; + + const categoryChannels = guild?.channels.cache.filter( + (channel) => + channel.type === ChannelType.GuildCategory && + isCategoryOfCtf(channel, ctf) + ); + + categoryChannels.map((categoryChannel) => { + categoryChannel + .setName(categoryChannel.name.replace(ctf.title, args.input.patch.title)) + .catch((err) => { + console.error("Failed updating category.", err); + }); + }); + + const role = guild?.roles.cache.find((role) => role.name === ctf.title); + role?.setName(args.input.patch.title).catch((err) => { + console.error("Failed updating role.", err); + }); +} + +export default { + operationHook: discordMutationHook, +}; diff --git a/api/src/discord/agile/interactions.ts b/api/src/discord/agile/interactions.ts new file mode 100644 index 000000000..2297b6a7c --- /dev/null +++ b/api/src/discord/agile/interactions.ts @@ -0,0 +1,9 @@ +import { HandleArchiveCtfInteraction } from "./commands/archiveCtf"; +import { HandleCreateCtfInteraction } from "./commands/createCtf"; +import { HandleDeleteCtfInteraction } from "./commands/deleteCtf"; + +export default [ + HandleCreateCtfInteraction, + HandleDeleteCtfInteraction, + HandleArchiveCtfInteraction, +]; diff --git a/api/src/discord/database/ctfs.ts b/api/src/discord/database/ctfs.ts index b740d8d43..e74bcb36c 100644 --- a/api/src/discord/database/ctfs.ts +++ b/api/src/discord/database/ctfs.ts @@ -1,4 +1,4 @@ -import { connectToDatabase } from "./database"; +import { connectToDatabase } from "../../utils/database"; import { PoolClient } from "pg"; export interface CTF { diff --git a/api/src/discord/database/tasks.ts b/api/src/discord/database/tasks.ts index fb03ee168..f1a5238dc 100644 --- a/api/src/discord/database/tasks.ts +++ b/api/src/discord/database/tasks.ts @@ -1,4 +1,4 @@ -import { connectToDatabase } from "./database"; +import { connectToDatabase } from "../../utils/database"; import { PoolClient } from "pg"; export interface Task { diff --git a/api/src/discord/database/users.ts b/api/src/discord/database/users.ts index 6a34997ac..86005a4a6 100644 --- a/api/src/discord/database/users.ts +++ b/api/src/discord/database/users.ts @@ -1,4 +1,4 @@ -import { connectToDatabase } from "./database"; +import { connectToDatabase } from "../../utils/database"; import { PoolClient } from "pg"; /* diff --git a/api/src/discord/hooks.ts b/api/src/discord/hooks.ts new file mode 100644 index 000000000..df3add65a --- /dev/null +++ b/api/src/discord/hooks.ts @@ -0,0 +1,10 @@ +import { SchemaBuilder } from "postgraphile"; +import { getChannelHandleStyleHooks } from "./utils/channelStyle"; + +export default async function (builder: SchemaBuilder): Promise { + const hooks = await getChannelHandleStyleHooks(); + builder.hook("init", (_, build) => { + build.addOperationHook(hooks.operationHook(build)); + return _; + }); +} diff --git a/api/src/discord/index.ts b/api/src/discord/index.ts index da4d4f3e0..0c92f750d 100644 --- a/api/src/discord/index.ts +++ b/api/src/discord/index.ts @@ -1,10 +1,31 @@ import { Client, GatewayIntentBits } from "discord.js"; import config from "../config"; import ready from "./listeners/ready"; +import { connectToDatabase } from "../utils/database"; let client: Client | null = null; export let usingDiscordBot = true; +export async function initDiscordBot() { + getDiscordClient(); + + const pgClient = await connectToDatabase(); + + try { + const query = + "UPDATE ctfnote.settings SET discord_integration_enabled = $1"; + const values = [config.discord.use.toLowerCase() !== "false"]; + await pgClient.query(query, values); + } catch (error) { + console.error( + "Failed to set discord_integration_enabled flag in the database:", + error + ); + } finally { + pgClient.release(); + } +} + export function getDiscordClient(): Client | null { if (!usingDiscordBot) return null; if (config.discord.use.toLowerCase() === "false") { diff --git a/api/src/discord/command.ts b/api/src/discord/interfaces/command.ts similarity index 100% rename from api/src/discord/command.ts rename to api/src/discord/interfaces/command.ts diff --git a/api/src/discord/interfaces/hooks.ts b/api/src/discord/interfaces/hooks.ts new file mode 100644 index 000000000..95d6f9cd9 --- /dev/null +++ b/api/src/discord/interfaces/hooks.ts @@ -0,0 +1,25 @@ +import { GraphQLResolveInfoWithMessages } from "@graphile/operation-hooks"; +import { Build, Context } from "postgraphile"; + +export interface Hooks { + operationHook: (_build: Build) => (fieldContext: Context) => { + before: { + priority: number; + callback: ( + input: unknown, + args: unknown, + context: unknown, + _resolveInfo: GraphQLResolveInfoWithMessages + ) => Promise; + }[]; + after: { + priority: number; + callback: ( + input: unknown, + args: unknown, + context: unknown, + _resolveInfo: GraphQLResolveInfoWithMessages + ) => Promise; + }[]; + } | null; +} diff --git a/api/src/discord/interfaces/interaction.ts b/api/src/discord/interfaces/interaction.ts new file mode 100644 index 000000000..b7f0e1cdd --- /dev/null +++ b/api/src/discord/interfaces/interaction.ts @@ -0,0 +1,6 @@ +import { ButtonInteraction, Client } from "discord.js"; + +export interface DiscordButtonInteraction { + customId: string; + handle: (client: Client, interaction: ButtonInteraction) => Promise; +} diff --git a/api/src/discord/listeners/interactionCreate.ts b/api/src/discord/listeners/interactionCreate.ts index 706c0b0aa..35d68d25e 100644 --- a/api/src/discord/listeners/interactionCreate.ts +++ b/api/src/discord/listeners/interactionCreate.ts @@ -1,100 +1,50 @@ import { Client, CommandInteraction, Interaction } from "discord.js"; -import { Commands } from "../commands"; -import { getCtfFromDatabase } from "../database/ctfs"; -import { getChallengesFromDatabase } from "../database/tasks"; import { - createChannelForTaskInCtf, - createChannelsAndRolesForCtf, -} from "../utils/channels"; -import { handleDeleteInteraction } from "../commands/deleteCtf"; -import { handleArchiveInteraction } from "../commands/archiveCtf"; + getChannelHandleStyleCommands, + getChannelHandleStyleInteractions, +} from "../utils/channelStyle"; export default (client: Client): void => { client.on("interactionCreate", async (interaction: Interaction) => { //check if it is a button interaction if (interaction.isButton()) { - //create the ctf channels and roles - if (interaction.customId.startsWith("create-ctf-button-")) { - const ctfName = interaction.customId.replace("create-ctf-button-", ""); - await interaction.deferUpdate(); - await interaction.editReply({ - content: `Creating the CTF channels and roles for ${ctfName}`, - components: [], - }); - - const guild = interaction.guild; - if (guild == null) return; - - // assign roles to users already having access to the ctf - const ctf = await getCtfFromDatabase(ctfName); - if (ctf == null) return; - - await createChannelsAndRolesForCtf(guild, ctf); - - // create for every challenge a channel - const challenges = await getChallengesFromDatabase(ctf.id); - - for (const challenge of challenges) { - await createChannelForTaskInCtf(guild, challenge, ctf); - } - - await interaction.editReply({ - content: `Created the CTF channels and roles for ${ctfName}`, - components: [], - }); - } else if (interaction.customId.startsWith("archive-ctf-button-")) { - const ctfName = interaction.customId.replace("archive-ctf-button-", ""); - await interaction.deferUpdate(); - await interaction.editReply({ - content: `Archiving the CTF channels and roles for ${ctfName}`, - components: [], - }); - await handleArchiveInteraction(interaction, ctfName); - - await interaction.editReply({ - content: `Archived the CTF channels and roles for ${ctfName}`, - components: [], - }); - } else if (interaction.customId.startsWith("delete-ctf-button-")) { - const ctfName = interaction.customId.replace("delete-ctf-button-", ""); - await interaction.deferUpdate(); - await interaction.editReply({ - content: `Deleting the CTF channels and roles for ${ctfName}`, - components: [], - }); - - const done = await handleDeleteInteraction(interaction, ctfName); - if (!done) { - await interaction.editReply({ - content: `I insist that you create an \`/archive\` first!`, - }); - } else { - try { - await interaction.editReply({ - content: `Deleted the CTF channels and roles for ${ctfName}`, - components: [], - }); - } catch (error) { - console.debug( - "Failed to update message that CTF was deleted. Probably the command was triggered in a channel that got deleted by the /delete command.", - error - ); - } - } - } - } - - if (interaction.isCommand() || interaction.isContextMenuCommand()) { + await handleButtonInteraction(client, interaction); + } else if (interaction.isCommand() || interaction.isContextMenuCommand()) { await handleSlashCommand(client, interaction); } }); }; +const handleButtonInteraction = async ( + client: Client, + interaction: Interaction +): Promise => { + if (!interaction.isButton()) { + return; + } + const handler = (await getChannelHandleStyleInteractions()).find((i) => + interaction.customId.startsWith(i.customId) + ); + + if (!handler) { + await interaction.editReply({ + content: "An error has occurred", + }); + return; + } + + await handler.handle(client, interaction).catch((error) => { + console.error("Error handling button interaction", error); + }); +}; + const handleSlashCommand = async ( client: Client, interaction: CommandInteraction ): Promise => { - const slashCommand = Commands.find((c) => c.name === interaction.commandName); + const slashCommand = (await getChannelHandleStyleCommands()).find( + (c) => c.name === interaction.commandName + ); if (!slashCommand) { await interaction.followUp({ content: "An error has occurred" }); return; diff --git a/api/src/discord/listeners/ready.ts b/api/src/discord/listeners/ready.ts index c2f6699da..038547ac8 100644 --- a/api/src/discord/listeners/ready.ts +++ b/api/src/discord/listeners/ready.ts @@ -1,9 +1,9 @@ import { ActivityType, Client } from "discord.js"; -import { Commands } from "../commands"; import interactionCreate from "./interactionCreate"; import fs from "fs"; import config from "../../config"; +import { getChannelHandleStyleCommands } from "../utils/channelStyle"; export default (client: Client): void => { client.on("ready", async () => { @@ -25,7 +25,9 @@ export default (client: Client): void => { .catch((err) => console.error("Failed to change avatar of bot.", err)); } - await client.application.commands.set(Commands); + await client.application.commands.set( + await getChannelHandleStyleCommands() + ); interactionCreate(client); console.log(`${client.user.username} bot is online`); diff --git a/api/src/discord/utils/channelStyle.ts b/api/src/discord/utils/channelStyle.ts new file mode 100644 index 000000000..cd701e7de --- /dev/null +++ b/api/src/discord/utils/channelStyle.ts @@ -0,0 +1,47 @@ +import config from "../../config"; +import { Hooks } from "../interfaces/hooks"; +import { Command } from "../interfaces/command"; +import { DiscordButtonInteraction } from "../interfaces/interaction"; + +const channelStyle = config.discord.channelHandleStyle; + +export async function getChannelHandleStyleInteractions(): Promise< + DiscordButtonInteraction[] +> { + switch (channelStyle) { + case "agile": { + const { default: Interactions } = await import("../agile/interactions"); + return Interactions; + } + default: + throw new Error( + `Unknown channel style ${channelStyle}. Please check your configuration.` + ); + } +} + +export async function getChannelHandleStyleCommands(): Promise { + switch (channelStyle) { + case "agile": { + const { default: Interactions } = await import("../agile/commands"); + return Interactions; + } + default: + throw new Error( + `Unknown channel style ${channelStyle}. Please check your configuration.` + ); + } +} + +export async function getChannelHandleStyleHooks(): Promise { + switch (channelStyle) { + case "agile": { + const { default: Hooks } = await import("../agile/hooks"); + return Hooks; + } + default: + throw new Error( + `Unknown channel style ${channelStyle}. Please check your configuration.` + ); + } +} diff --git a/api/src/discord/utils/comparison.ts b/api/src/discord/utils/comparison.ts index 9e9df0da3..71270b906 100644 --- a/api/src/discord/utils/comparison.ts +++ b/api/src/discord/utils/comparison.ts @@ -6,7 +6,7 @@ import { } from "discord.js"; import { CTF } from "../database/ctfs"; import { Task } from "../database/tasks"; -import { getCtfNameFromCategoryName } from "./channels"; +import { getCtfNameFromCategoryName } from "../agile/channels"; import { getTaskTitleFromTopic } from "./messages"; export function isCategoryOfCtf( diff --git a/api/src/discord/utils/messages.ts b/api/src/discord/utils/messages.ts index 5d5d4400d..36d4e58cb 100644 --- a/api/src/discord/utils/messages.ts +++ b/api/src/discord/utils/messages.ts @@ -12,7 +12,7 @@ import { import config from "../../config"; import { Task, getTaskFromId } from "../database/tasks"; import { CTF, getCtfFromDatabase } from "../database/ctfs"; -import { getTaskChannel } from "./channels"; +import { challengesTalkChannelName, getTaskChannel } from "../agile/channels"; import { createPad } from "../../plugins/createTask"; export const discordArchiveTaskName = "Discord archive"; @@ -176,7 +176,8 @@ export async function convertMessagesToPadFormat(messages: Message[]) { const channel = messages[0].channel; if (channel.type !== ChannelType.GuildText) return; - if (messages.length < 2) return; // don't include channels with one message, since that is only the bot message + if (messages.length < 2 && channel.name !== challengesTalkChannelName) + return; // don't include channels with one message, since that is only the bot message result.push(`## ${channel.name}`); diff --git a/api/src/discord/utils/permissionSync.ts b/api/src/discord/utils/permissionSync.ts index 06cb8a014..d7acde569 100644 --- a/api/src/discord/utils/permissionSync.ts +++ b/api/src/discord/utils/permissionSync.ts @@ -7,7 +7,7 @@ import { insertInvitation, } from "../database/ctfs"; import { getDiscordIdFromUserId, getUserByDiscordId } from "../database/users"; -import { changeDiscordUserRoleForCTF } from "../commands/linkUser"; +import { changeDiscordUserRoleForCTF } from "../agile/commands/linkUser"; import { PoolClient } from "pg"; export async function syncDiscordPermissionsWithCtf( diff --git a/api/src/discord/utils/user.ts b/api/src/discord/utils/user.ts new file mode 100644 index 000000000..205d33e19 --- /dev/null +++ b/api/src/discord/utils/user.ts @@ -0,0 +1,30 @@ +import { getDiscordGuild } from ".."; +import { getNameFromUserId } from "../database/ctfs"; +import { getDiscordIdFromUserId } from "../database/users"; + +export async function convertToUsernameFormat(userId: bigint | string) { + // this is actually the Discord ID and not a CTFNote userId + if (typeof userId === "string") { + // but if somehow it's not, just return it + if (isNaN(parseInt(userId))) return userId; + return `<@${userId}>`; + } + + const name = await getNameFromUserId(userId); + if (name == null) return name; + + const discordId = await getDiscordIdFromUserId(userId); + if (discordId == null) return name; + + const guild = getDiscordGuild(); + if (guild == null) return name; + + const member = await guild.members.fetch({ user: discordId }); + if (member == null) return name; + + if (member.displayName.toLowerCase() !== name.toLowerCase()) { + return `${member.user} (${name})`; + } else { + return `${member.user}`; + } +} diff --git a/api/src/index.ts b/api/src/index.ts index b28461238..483e4e3fc 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -18,11 +18,10 @@ import { Pool } from "pg"; import { icalRoute } from "./routes/ical"; import ConnectionFilterPlugin from "postgraphile-plugin-connection-filter"; import OperationHook from "@graphile/operation-hooks"; -import discordHooks from "./plugins/discordHooks"; -import { getDiscordClient } from "./discord"; +import discordHooks from "./discord/hooks"; +import { initDiscordBot } from "./discord"; import PgManyToManyPlugin from "@graphile-contrib/pg-many-to-many"; import ProfileSubscriptionPlugin from "./plugins/ProfileSubscriptionPlugin"; -import { connectToDatabase } from "./discord/database/database"; function getDbUrl(role: "user" | "admin") { const login = config.db[role].login; @@ -153,23 +152,7 @@ async function main() { const postgraphileOptions = createOptions(); const app = createApp(postgraphileOptions); - getDiscordClient(); - - const pgClient = await connectToDatabase(); //? maybe we should not keep this dependency in the discord folder? - - try { - const query = - "UPDATE ctfnote.settings SET discord_integration_enabled = $1"; - const values = [config.discord.use.toLowerCase() !== "false"]; - await pgClient.query(query, values); - } catch (error) { - console.error( - "Failed to set discord_integration_enabled flag in the database:", - error - ); - } finally { - pgClient.release(); - } + await initDiscordBot(); app.listen(config.web.port, () => { //sendMessageToDiscord("CTFNote API started"); diff --git a/api/src/plugins/discordHooks.ts b/api/src/plugins/discordHooks.ts deleted file mode 100644 index 45571d64b..000000000 --- a/api/src/plugins/discordHooks.ts +++ /dev/null @@ -1,479 +0,0 @@ -import { Build, Context } from "postgraphile"; -import { SchemaBuilder } from "graphile-build"; -import { ChannelType, Guild } from "discord.js"; -import { - getAccessibleCTFsForUser, - getAllCtfsFromDatabase, - getCtfFromDatabase, - getNameFromUserId, -} from "../discord/database/ctfs"; -import { getDiscordGuild, usingDiscordBot } from "../discord"; -import { changeDiscordUserRoleForCTF } from "../discord/commands/linkUser"; -import { - getDiscordIdFromUserId, - getUserIdFromUsername, -} from "../discord/database/users"; -import { - Task, - getTaskByCtfIdAndNameFromDatabase, - getTaskFromId, -} from "../discord/database/tasks"; -import { sendMessageToTask } from "../discord/utils/messages"; -import { - ChannelMovingEvent, - createChannelForNewTask, - getActiveCtfCategories, - getTaskChannel, - moveChannel, -} from "../discord/utils/channels"; -import { isCategoryOfCtf } from "../discord/utils/comparison"; -import { GraphQLResolveInfoWithMessages } from "@graphile/operation-hooks"; -import { syncDiscordPermissionsWithCtf } from "../discord/utils/permissionSync"; - -export async function convertToUsernameFormat(userId: bigint | string) { - // this is actually the Discord ID and not a CTFNote userId - if (typeof userId === "string") { - // but if somehow it's not, just return it - if (isNaN(parseInt(userId))) return userId; - return `<@${userId}>`; - } - - const name = await getNameFromUserId(userId); - if (name == null) return name; - - const discordId = await getDiscordIdFromUserId(userId); - if (discordId == null) return name; - - const guild = getDiscordGuild(); - if (guild == null) return name; - - const member = await guild.members.fetch({ user: discordId }); - if (member == null) return name; - - if (member.displayName.toLowerCase() !== name.toLowerCase()) { - return `${member.user} (${name})`; - } else { - return `${member.user}`; - } -} - -export async function handleTaskSolved( - guild: Guild, - id: bigint, - userId: bigint | string -) { - const task = await getTaskFromId(id); - if (task == null) return; - - await moveChannel(guild, task, null, ChannelMovingEvent.SOLVED); - - return sendMessageToTask( - guild, - id, - `${task.title} is solved by ${await convertToUsernameFormat(userId)}!` - ); -} - -// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any -const discordMutationHook = (_build: Build) => (fieldContext: Context) => { - const { - scope: { isRootMutation }, - } = fieldContext; - - if (!isRootMutation) return null; - - if (!usingDiscordBot) return null; - - if ( - fieldContext.scope.fieldName !== "updateTask" && - fieldContext.scope.fieldName !== "createTask" && - fieldContext.scope.fieldName !== "deleteTask" && - fieldContext.scope.fieldName !== "startWorkingOn" && - fieldContext.scope.fieldName !== "stopWorkingOn" && - fieldContext.scope.fieldName !== "cancelWorkingOn" && - fieldContext.scope.fieldName !== "updateCtf" && - fieldContext.scope.fieldName !== "createInvitation" && - fieldContext.scope.fieldName !== "deleteInvitation" && - fieldContext.scope.fieldName !== "resetDiscordId" && - fieldContext.scope.fieldName !== "deleteCtf" && - fieldContext.scope.fieldName !== "updateUserRole" && - fieldContext.scope.fieldName !== "setDiscordEventLink" && - fieldContext.scope.fieldName !== "registerWithToken" - ) { - return null; - } - - const handleDiscordMutationAfter = async ( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - input: any, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - args: any, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - context: any, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - _resolveInfo: GraphQLResolveInfoWithMessages - ) => { - const guild = getDiscordGuild(); - if (guild == null) return input; - - //add challenges to the ctf channel discord - if (fieldContext.scope.fieldName === "createTask") { - // we have to query the task using the context.pgClient in order to see the newly created task - const task = await getTaskByCtfIdAndNameFromDatabase( - args.input.ctfId, - args.input.title, - context.pgClient - ); - if (task == null) return input; - - // we have to await this since big imports will cause race conditions with the Discord API - await createChannelForNewTask(guild, task, true); - } - if (fieldContext.scope.fieldName === "deleteTask") { - const task = await getTaskFromId(args.input.id); - if (task == null) return input; - - const channel = await getTaskChannel(guild, task, null); - if (channel == null) return input; - - channel - .setName(`deleted-${task.title}`) - .catch((err) => - console.error("Failed to mark channel as deleted.", err) - ); - } - - // handle task (un)solved - if ( - fieldContext.scope.fieldName === "updateTask" && - args.input.id != null - ) { - const task = await getTaskFromId(args.input.id); - if (task == null) return input; - - let title = task.title; - if (args.input.patch.title != null) { - title = args.input.patch.title; - } - - if (args.input.patch.flag != null) { - if (args.input.patch.flag !== "") { - const userId = context.jwtClaims.user_id; - - handleTaskSolved(guild, args.input.id, userId); - } else { - const task = await getTaskFromId(args.input.id); - if (task == null) return input; - - moveChannel(guild, task, null, ChannelMovingEvent.UNSOLVED); - } - } - - // handle task title change - if ( - args.input.patch.title != null && - args.input.patch.title != task.title - ) { - const channel = await getTaskChannel(guild, task, null); - if (channel == null) return input; - - channel - .edit({ - name: title, - topic: channel.topic?.replace(task.title, title), - }) - .catch((err) => console.error("Failed to rename channel.", err)); - } - - // handle task description change - if ( - args.input.patch.description != null && - args.input.patch.description !== task.description - ) { - sendMessageToTask( - guild, - task, - `Description changed:\n${args.input.patch.description}` - ); - } - } - - if (fieldContext.scope.fieldName === "startWorkingOn") { - //send a message to the channel that the user started working on the task - const userId = context.jwtClaims.user_id; - const taskId = args.input.taskId; - moveChannel(guild, taskId, null, ChannelMovingEvent.START).then(() => { - sendStartWorkingOnMessage(guild, userId, taskId).catch((err) => { - console.error( - "Failed sending 'started working on' notification.", - err - ); - }); - }); - } - if ( - fieldContext.scope.fieldName === "stopWorkingOn" || - fieldContext.scope.fieldName === "cancelWorkingOn" - ) { - //send a message to the channel that the user stopped working on the task - const userId = context.jwtClaims.user_id; - const taskId = args.input.taskId; - - sendStopWorkingOnMessage( - guild, - userId, - taskId, - fieldContext.scope.fieldName === "cancelWorkingOn" - ).catch((err) => { - console.error("Failed sending 'stopped working on' notification.", err); - }); - } - if (fieldContext.scope.fieldName === "createInvitation") { - handeInvitation( - args.input.invitation.ctfId, - args.input.invitation.profileId, - "add" - ).catch((err) => { - console.error("Failed to create invitation.", err); - }); - } - - if (fieldContext.scope.fieldName === "deleteInvitation") { - handeInvitation(args.input.ctfId, args.input.profileId, "remove").catch( - (err) => { - console.error("Failed to delete invitation.", err); - } - ); - } - - if (fieldContext.scope.fieldName === "updateUserRole") { - const userId = args.input.userId; - - // reset all roles - const currentCtfs = await getActiveCtfCategories(guild); - await Promise.all( - currentCtfs.map(async function (ctf) { - return changeDiscordUserRoleForCTF(userId, ctf, "remove").catch( - (err) => { - console.error("Error while adding role to user: ", err); - } - ); - }) - ); - // re-assign roles if accessible - const ctfs = await getAccessibleCTFsForUser(userId, context.pgClient); - ctfs.forEach(function (ctf) { - changeDiscordUserRoleForCTF(userId, ctf, "add").catch((err) => { - console.error("Error while adding role to user: ", err); - }); - }); - } - - if (fieldContext.scope.fieldName === "setDiscordEventLink") { - const link = args.input.link; - const ctfId = args.input.ctfId; - - await syncDiscordPermissionsWithCtf( - guild, - ctfId, - link, - context.pgClient - ).catch((err) => { - console.error("Failed to sync discord permissions.", err); - }); - } - - /* - * We have a nice ductape solution for the following problem: - * During the handling of these hooks, the changes to the database are not committed yet. - * This means that we can't query the database for the new user id. - * We have to wait a bit to make sure the user is in the database. - * Alternatively we can hook the postgraphile lifecycle but that is not compatible with the current setup. - * The outgoing request is probably handling within 1 second, so this works fine. - */ - if (fieldContext.scope.fieldName === "registerWithToken") { - const username = args.input.login; // the login is equal to the username at registration - setTimeout(async () => { - const userId = await getUserIdFromUsername(username, null); // use null to get a new client which is privileged as the Discord bot - if (userId == null) return; - const ctfs = await getAccessibleCTFsForUser(userId, null); - for (let i = 0; i < ctfs.length; i++) { - await changeDiscordUserRoleForCTF(userId, ctfs[i], "add").catch( - (err) => { - console.error("Error while adding role to user: ", err); - } - ); - } - }, 2000); - } - - return input; - }; - - const handleDiscordMutationBefore = async ( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - input: any, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - args: any, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - context: any, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - _resolveInfo: GraphQLResolveInfoWithMessages - ) => { - const guild = getDiscordGuild(); - if (guild === null) return input; - if (fieldContext.scope.fieldName === "updateCtf") { - handleUpdateCtf(args, guild).catch((err) => { - console.error("Failed to update ctf.", err); - }); - } - - if (fieldContext.scope.fieldName === "deleteCtf") { - handleDeleteCtf(args.input.id, guild).catch((err) => { - console.error("Failed to delete ctf.", err); - }); - } - - if (fieldContext.scope.fieldName === "resetDiscordId") { - // we need to use the await here to prevent a race condition - // between deleting the discord id and retrieving the discord id (to remove the roles) - await handleResetDiscordId(context.jwtClaims.user_id).catch((err) => { - console.error("Failed to reset discord id.", err); - }); - } - - return input; - }; - - return { - before: [ - { - priority: 500, - callback: handleDiscordMutationBefore, - }, - ], - after: [ - { - priority: 500, - callback: handleDiscordMutationAfter, - }, - ], - error: [], - }; -}; - -export async function sendStartWorkingOnMessage( - guild: Guild, - userId: bigint, - task: Task | bigint -) { - await moveChannel(guild, task, null, ChannelMovingEvent.START); - return sendMessageToTask( - guild, - task, - `${await convertToUsernameFormat(userId)} is working on this task!` - ); -} - -export async function sendStopWorkingOnMessage( - guild: Guild, - userId: bigint, - task: Task | bigint, - cancel = false -) { - let text = "stopped"; - if (cancel) { - text = "cancelled"; - } - return sendMessageToTask( - guild, - task, - `${await convertToUsernameFormat(userId)} ${text} working on this task!` - ); -} - -export async function handleDeleteCtf(ctfId: string | bigint, guild: Guild) { - const ctf = await getCtfFromDatabase(ctfId); - if (ctf == null) return; - - const categoryChannels = guild.channels.cache.filter( - (channel) => - channel.type === ChannelType.GuildCategory && - isCategoryOfCtf(channel, ctf) - ); - - categoryChannels.map((categoryChannel) => { - guild?.channels.cache.map((channel) => { - if ( - channel.type === ChannelType.GuildVoice && - channel.parentId === categoryChannel.id - ) { - return channel.delete(); - } - }); - - guild?.channels.cache.map(async (channel) => { - if ( - channel.type === ChannelType.GuildText && - channel.parentId === categoryChannel.id - ) { - await channel.delete(); - } - }); - - categoryChannel.delete(); - }); - - guild.roles.cache.map((role) => { - if (role.name === ctf.title) { - return role.delete(); - } - }); -} - -async function handleResetDiscordId(userId: bigint) { - const allCtfs = await getAllCtfsFromDatabase(); - await changeDiscordUserRoleForCTF(userId, allCtfs, "remove"); -} - -async function handeInvitation( - ctfId: bigint, - profileId: bigint, - operation: "add" | "remove" -) { - const ctf = await getCtfFromDatabase(ctfId); - if (ctf == null) return; - await changeDiscordUserRoleForCTF(profileId, ctf, operation); -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -async function handleUpdateCtf(args: any, guild: Guild) { - const ctf = await getCtfFromDatabase(args.input.id); - if (ctf == null) return; - - const categoryChannels = guild?.channels.cache.filter( - (channel) => - channel.type === ChannelType.GuildCategory && - isCategoryOfCtf(channel, ctf) - ); - - categoryChannels.map((categoryChannel) => { - categoryChannel - .setName(categoryChannel.name.replace(ctf.title, args.input.patch.title)) - .catch((err) => { - console.error("Failed updating category.", err); - }); - }); - - const role = guild?.roles.cache.find((role) => role.name === ctf.title); - role?.setName(args.input.patch.title).catch((err) => { - console.error("Failed updating role.", err); - }); -} - -export default function (builder: SchemaBuilder): void { - builder.hook("init", (_, build) => { - build.addOperationHook(discordMutationHook(build)); - return _; - }); -} diff --git a/api/src/discord/database/database.ts b/api/src/utils/database.ts similarity index 89% rename from api/src/discord/database/database.ts rename to api/src/utils/database.ts index 6d3a23559..8881444d0 100644 --- a/api/src/discord/database/database.ts +++ b/api/src/utils/database.ts @@ -1,5 +1,5 @@ import { Pool } from "pg"; -import config from "../../config"; +import config from "../config"; const pgPool = new Pool({ user: config.db.admin.login,