diff --git a/app/commands/setupTickets.ts b/app/commands/setupTickets.ts new file mode 100644 index 0000000..002e3b4 --- /dev/null +++ b/app/commands/setupTickets.ts @@ -0,0 +1,155 @@ +import type { + APIInteraction, + APIInteractionResponseChannelMessageWithSource, + APIModalSubmitInteraction, + ChatInputCommandInteraction, +} from "discord.js"; +import { + ButtonStyle, + ComponentType, + PermissionFlagsBits, + SlashCommandBuilder, + InteractionResponseType, + MessageFlags, +} from "discord.js"; +import type { RequestHandler } from "express"; +import { REST } from "@discordjs/rest"; +import type { + RESTPostAPIChannelMessageJSONBody, + RESTPostAPIChannelThreadsJSONBody, + RESTPostAPIChannelThreadsResult, +} from "discord-api-types/v10"; +import { ChannelType, Routes } from "discord-api-types/v10"; + +import { discordToken } from "~/helpers/env"; +import { SETTINGS, fetchSettings } from "~/models/guilds.server"; +import { format } from "date-fns"; +import { MessageComponentTypes, TextStyleTypes } from "discord-interactions"; +import { quoteMessageContent } from "~/helpers/discord"; + +const rest = new REST({ version: "10" }).setToken(discordToken); + +const isModalInteraction = (body: any): body is APIModalSubmitInteraction => { + return ( + body.message.interaction_metadata.type === 2 && + body.data.custom_id === "modal-open-ticket" + ); +}; + +export const command = new SlashCommandBuilder() + .setName("tickets-channel") + .setDescription( + "Set up a new button for creating private tickets with moderators", + ) + .setDefaultMemberPermissions( + PermissionFlagsBits.Administrator, + ) as SlashCommandBuilder; + +export const webserver: RequestHandler = async (req, res, next) => { + const body = req.body as APIInteraction; + // @ts-expect-error because apparently custom_id types are broken + console.log("hook:", body.data.component_type, body.data.custom_id); + // @ts-expect-error because apparently custom_id types are broken + if (body.data.component_type === 2 && body.data.custom_id === "open-ticket") { + res.send({ + type: InteractionResponseType.Modal, + data: { + custom_id: "modal-open-ticket", + title: "What do you need from the moderators?", + components: [ + { + type: MessageComponentTypes.ACTION_ROW, + components: [ + { + type: MessageComponentTypes.INPUT_TEXT, + custom_id: "concern", + label: "Concern", + style: TextStyleTypes.PARAGRAPH, + min_length: 30, + max_length: 500, + required: true, + }, + ], + }, + ], + }, + }); + return; + } + if (isModalInteraction(body)) { + if ( + !body.channel || + !body.message || + !body.message.interaction_metadata?.user || + !body.data?.components[0].components[0].value + ) { + console.error("ticket creation error", JSON.stringify(req.body)); + res.send({ + type: InteractionResponseType.ChannelMessageWithSource, + data: { + content: "Something went wrong while creating a ticket", + flags: MessageFlags.Ephemeral, + }, + } as APIInteractionResponseChannelMessageWithSource); + return; + } + + const { [SETTINGS.moderator]: mod } = await fetchSettings( + // @ts-expect-error because this shouldn't have used a Guild instance but + // it's a lot to refactor + { id: body.guild_id }, + [SETTINGS.moderator], + ); + const thread = (await rest.post(Routes.threads(body.channel.id), { + body: { + name: `${body.message.interaction_metadata.user.username} – ${format( + new Date(), + "PP kk:mmX", + )}`, + auto_archive_duration: 60 * 24 * 7, + type: ChannelType.PrivateThread, + } as RESTPostAPIChannelThreadsJSONBody, + })) as RESTPostAPIChannelThreadsResult; + await rest.post(Routes.channelMessages(thread.id), { + body: { + content: `<@${body.message.interaction_metadata.user.id}>, this is a private space only visible to the <@&${mod}> role.`, + } as RESTPostAPIChannelMessageJSONBody, + }); + await rest.post(Routes.channelMessages(thread.id), { + body: { + content: `${quoteMessageContent( + body.data?.components[0].components[0].value, + )}`, + }, + }); + + res.send({ + type: InteractionResponseType.ChannelMessageWithSource, + data: { + content: `A private thread with the moderation team has been opened for you: <#${thread.id}>`, + flags: MessageFlags.Ephemeral, + }, + } as APIInteractionResponseChannelMessageWithSource); + return; + } +}; + +export const handler = async (interaction: ChatInputCommandInteraction) => { + if (!interaction.guild) throw new Error("Interaction has no guild"); + + await interaction.reply({ + components: [ + { + type: ComponentType.ActionRow, + components: [ + { + type: ComponentType.Button, + label: "Open a private ticket with the moderators", + style: ButtonStyle.Primary, + customId: "open-ticket", + }, + ], + }, + ], + }); +}; diff --git a/app/discord/deployCommands.server.ts b/app/discord/deployCommands.server.ts index 10f5405..e526609 100644 --- a/app/discord/deployCommands.server.ts +++ b/app/discord/deployCommands.server.ts @@ -5,6 +5,7 @@ import type { SlashCommandBuilder, } from "discord.js"; import { InteractionType, Routes } from "discord.js"; +import type { Application } from "express"; import { rest } from "~/discord/api"; import type { @@ -199,6 +200,9 @@ export const deployTestCommands = async ( type Command = MessageContextCommand | UserContextCommand | SlashCommand; const commands = new Map(); -export const registerCommand = (config: Command) => { +export const registerCommand = (config: Command, express: Application) => { + if (config.webserver) { + express.post("/webhooks/discord", config.webserver); + } commands.set(config.command.name, config); }; diff --git a/app/discord/gateway.ts b/app/discord/gateway.ts index 193fce8..1caecc2 100644 --- a/app/discord/gateway.ts +++ b/app/discord/gateway.ts @@ -1,25 +1,12 @@ import Sentry from "~/helpers/sentry.server"; import { client, login } from "~/discord/client.server"; -import { - deployCommands, - registerCommand, -} from "~/discord/deployCommands.server"; +import { deployCommands } from "~/discord/deployCommands.server"; import automod from "~/discord/automod"; import onboardGuild from "~/discord/onboardGuild"; import { startActivityTracking } from "~/discord/activityTracker"; -import * as convene from "~/commands/convene"; -import * as setup from "~/commands/setup"; -import * as report from "~/commands/report"; -import * as track from "~/commands/track"; - -registerCommand(convene); -registerCommand(setup); -registerCommand(report); -registerCommand(track); - export default function init() { login(); diff --git a/app/helpers/discord.ts b/app/helpers/discord.ts index 028a42f..1000beb 100644 --- a/app/helpers/discord.ts +++ b/app/helpers/discord.ts @@ -17,6 +17,7 @@ import { ContextMenuCommandBuilder, SlashCommandBuilder, } from "discord.js"; +import type { RequestHandler } from "express"; import prettyBytes from "pretty-bytes"; const staffRoles = ["mvp", "moderator", "admin", "admins"]; @@ -146,6 +147,7 @@ ${poll.answers.map((a) => `> - ${a.text}`).join("\n")}`; export type MessageContextCommand = { command: ContextMenuCommandBuilder; handler: (interaction: MessageContextMenuCommandInteraction) => void; + webserver?: RequestHandler; }; export const isMessageContextCommand = ( config: MessageContextCommand | UserContextCommand | SlashCommand, @@ -156,6 +158,7 @@ export const isMessageContextCommand = ( export type UserContextCommand = { command: ContextMenuCommandBuilder; handler: (interaction: UserContextMenuCommandInteraction) => void; + webserver?: RequestHandler; }; export const isUserContextCommand = ( config: MessageContextCommand | UserContextCommand | SlashCommand, @@ -166,6 +169,7 @@ export const isUserContextCommand = ( export type SlashCommand = { command: SlashCommandBuilder; handler: (interaction: ChatInputCommandInteraction) => void; + webserver?: RequestHandler; }; export const isSlashCommand = ( config: MessageContextCommand | UserContextCommand | SlashCommand, diff --git a/app/index.ts b/app/index.ts index 3e92fd0..246bfcb 100644 --- a/app/index.ts +++ b/app/index.ts @@ -1,10 +1,21 @@ +// started with https://developers.cloudflare.com/workers/get-started/quickstarts/ import express from "express"; import { createRequestHandler } from "@remix-run/express"; import path from "path"; import * as build from "@remix-run/dev/server-build"; +import { verifyKey } from "discord-interactions"; import Sentry from "~/helpers/sentry.server"; import discordBot from "~/discord/gateway"; +import { applicationKey } from "./helpers/env"; +import bodyParser from "body-parser"; + +import * as convene from "~/commands/convene"; +import * as setup from "~/commands/setup"; +import * as report from "~/commands/report"; +import * as track from "~/commands/track"; +import * as setupTicket from "~/commands/setupTickets"; +import { registerCommand } from "./discord/deployCommands.server"; const app = express(); @@ -20,6 +31,42 @@ Route handlers and static hosting app.use(express.static(path.join(__dirname, "..", "public"))); +// Discord signature verification +app.post("/webhooks/discord", bodyParser.json(), async (req, res, next) => { + const isValidRequest = await verifyKey( + JSON.stringify(req.body), + req.header("X-Signature-Ed25519")!, + req.header("X-Signature-Timestamp")!, + applicationKey, + ); + console.log("WEBHOOK", "isValidRequest:", isValidRequest); + if (!isValidRequest) { + console.log("[REQ] Invalid request signature"); + res.status(401).send({ message: "Bad request signature" }); + return; + } + if (req.body.type === 1) { + res.json({ type: 1, data: {} }); + return; + } + + next(); +}); + +/** + * Initialize Discord gateway. + */ +discordBot(); +/** + * Register Discord commands. These may add arbitrary express routes, because + * abstracting Discord interaction handling is weird and complex. + */ +registerCommand(convene, app); +registerCommand(setup, app); +registerCommand(report, app); +registerCommand(track, app); +registerCommand(setupTicket, app); + // needs to handle all verbs (GET, POST, etc.) app.all( "*", @@ -45,8 +92,6 @@ app.use(Sentry.Handlers.errorHandler()); /** Init app */ app.listen(process.env.PORT || "3000"); -discordBot(); - const errorHandler = (error: unknown) => { Sentry.captureException(error); if (error instanceof Error) { diff --git a/app/models/guilds.server.ts b/app/models/guilds.server.ts index e79629a..24495ac 100644 --- a/app/models/guilds.server.ts +++ b/app/models/guilds.server.ts @@ -61,7 +61,7 @@ export const fetchSettings = async ( keys: T[], ) => { const result = Object.entries( - (await db + await db .selectFrom("guilds") // @ts-expect-error This is broken because of a migration from knex and // old/bad use of jsonb for storing settings. The type is guaranteed here @@ -71,9 +71,10 @@ export const fetchSettings = async ( ) .where("id", "=", guild.id) // This cast is also evidence of the pattern being broken - .executeTakeFirstOrThrow()) as Pick, - ); - return Object.fromEntries( - result.map(([k, v]) => [k, JSON.parse(v as string)]), - ); + .executeTakeFirstOrThrow(), + ) as [T, string][]; + return Object.fromEntries(result.map(([k, v]) => [k, JSON.parse(v)])) as Pick< + SettingsRecord, + T + >; }; diff --git a/package-lock.json b/package-lock.json index 9f6c0fa..973546e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "name": "jobs-bot", "license": "AGPL-3.0", "dependencies": { + "@discordjs/rest": "^2.4.0", "@isaacs/ttlcache": "^1.4.1", "@remix-run/express": "~1.19", "@remix-run/node": "~1.19", @@ -17,8 +18,10 @@ "@sentry/tracing": "^7.5.1", "@types/lodash": "^4.14.182", "better-sqlite3": "^9.5.0", + "body-parser": "^1.20.3", "date-fns": "^2.27.0", "discord-api-types": "0.37.97", + "discord-interactions": "^4.1.0", "discord.js": "^14.16.0", "dotenv": "^16.0.1", "express": "^4.18.1", @@ -8235,6 +8238,15 @@ "integrity": "sha512-No1BXPcVkyVD4ZVmbNgDKaBoqgeQ+FJpzZ8wqHkfmBnTZig1FcH3iPPersiK1TUIAzgClh2IvOuVUYfcWLQAOA==", "license": "MIT" }, + "node_modules/discord-interactions": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/discord-interactions/-/discord-interactions-4.1.0.tgz", + "integrity": "sha512-7DyXvsnp9FxjMPD+cPHG3DbttveVgOcWOBtWqyjSQ2bsfkTVpJF2l4c8+XujXaC45NXpRV67Da3go5O2DI0/hw==", + "license": "MIT", + "engines": { + "node": ">=18.4.0" + } + }, "node_modules/discord.js": { "version": "14.16.3", "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.16.3.tgz", @@ -27855,6 +27867,11 @@ "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.37.97.tgz", "integrity": "sha512-No1BXPcVkyVD4ZVmbNgDKaBoqgeQ+FJpzZ8wqHkfmBnTZig1FcH3iPPersiK1TUIAzgClh2IvOuVUYfcWLQAOA==" }, + "discord-interactions": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/discord-interactions/-/discord-interactions-4.1.0.tgz", + "integrity": "sha512-7DyXvsnp9FxjMPD+cPHG3DbttveVgOcWOBtWqyjSQ2bsfkTVpJF2l4c8+XujXaC45NXpRV67Da3go5O2DI0/hw==" + }, "discord.js": { "version": "14.16.3", "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.16.3.tgz", diff --git a/package.json b/package.json index 2c7ff30..a12a318 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ }, "license": "AGPL-3.0", "dependencies": { + "@discordjs/rest": "^2.4.0", "@isaacs/ttlcache": "^1.4.1", "@remix-run/express": "~1.19", "@remix-run/node": "~1.19", @@ -42,9 +43,11 @@ "@sentry/tracing": "^7.5.1", "@types/lodash": "^4.14.182", "better-sqlite3": "^9.5.0", + "body-parser": "^1.20.3", "date-fns": "^2.27.0", - "discord.js": "^14.16.0", "discord-api-types": "0.37.97", + "discord-interactions": "^4.1.0", + "discord.js": "^14.16.0", "dotenv": "^16.0.1", "express": "^4.18.1", "kysely": "^0.27.4",