Skip to content

Commit

Permalink
Add ticketing tool replacement (#88)
Browse files Browse the repository at this point in the history
This will close #59 and let us stop using the kinda-shitty Ticket Tool
that's super complicated to set up for no benefit to us.

At time of opening this still needs the actual "create a thread"
behavior to be implemented. Draft PR includes setup work to enable
webhooks and long-lived message components.
  • Loading branch information
vcarl authored Nov 24, 2024
1 parent 8a19925 commit 9dc2310
Show file tree
Hide file tree
Showing 8 changed files with 240 additions and 24 deletions.
155 changes: 155 additions & 0 deletions app/commands/setupTickets.ts
Original file line number Diff line number Diff line change
@@ -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",
},
],
},
],
});
};
6 changes: 5 additions & 1 deletion app/discord/deployCommands.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -199,6 +200,9 @@ export const deployTestCommands = async (
type Command = MessageContextCommand | UserContextCommand | SlashCommand;

const commands = new Map<string, Command>();
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);
};
15 changes: 1 addition & 14 deletions app/discord/gateway.ts
Original file line number Diff line number Diff line change
@@ -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();

Expand Down
4 changes: 4 additions & 0 deletions app/helpers/discord.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"];
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down
49 changes: 47 additions & 2 deletions app/index.ts
Original file line number Diff line number Diff line change
@@ -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();

Expand All @@ -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(
"*",
Expand 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) {
Expand Down
13 changes: 7 additions & 6 deletions app/models/guilds.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export const fetchSettings = async <T extends keyof typeof SETTINGS>(
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
Expand All @@ -71,9 +71,10 @@ export const fetchSettings = async <T extends keyof typeof SETTINGS>(
)
.where("id", "=", guild.id)
// This cast is also evidence of the pattern being broken
.executeTakeFirstOrThrow()) as Pick<SettingsRecord, T>,
);
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
>;
};
17 changes: 17 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading

0 comments on commit 9dc2310

Please sign in to comment.