Skip to content

Commit 9dc2310

Browse files
authored
Add ticketing tool replacement (#88)
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.
1 parent 8a19925 commit 9dc2310

File tree

8 files changed

+240
-24
lines changed

8 files changed

+240
-24
lines changed

app/commands/setupTickets.ts

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import type {
2+
APIInteraction,
3+
APIInteractionResponseChannelMessageWithSource,
4+
APIModalSubmitInteraction,
5+
ChatInputCommandInteraction,
6+
} from "discord.js";
7+
import {
8+
ButtonStyle,
9+
ComponentType,
10+
PermissionFlagsBits,
11+
SlashCommandBuilder,
12+
InteractionResponseType,
13+
MessageFlags,
14+
} from "discord.js";
15+
import type { RequestHandler } from "express";
16+
import { REST } from "@discordjs/rest";
17+
import type {
18+
RESTPostAPIChannelMessageJSONBody,
19+
RESTPostAPIChannelThreadsJSONBody,
20+
RESTPostAPIChannelThreadsResult,
21+
} from "discord-api-types/v10";
22+
import { ChannelType, Routes } from "discord-api-types/v10";
23+
24+
import { discordToken } from "~/helpers/env";
25+
import { SETTINGS, fetchSettings } from "~/models/guilds.server";
26+
import { format } from "date-fns";
27+
import { MessageComponentTypes, TextStyleTypes } from "discord-interactions";
28+
import { quoteMessageContent } from "~/helpers/discord";
29+
30+
const rest = new REST({ version: "10" }).setToken(discordToken);
31+
32+
const isModalInteraction = (body: any): body is APIModalSubmitInteraction => {
33+
return (
34+
body.message.interaction_metadata.type === 2 &&
35+
body.data.custom_id === "modal-open-ticket"
36+
);
37+
};
38+
39+
export const command = new SlashCommandBuilder()
40+
.setName("tickets-channel")
41+
.setDescription(
42+
"Set up a new button for creating private tickets with moderators",
43+
)
44+
.setDefaultMemberPermissions(
45+
PermissionFlagsBits.Administrator,
46+
) as SlashCommandBuilder;
47+
48+
export const webserver: RequestHandler = async (req, res, next) => {
49+
const body = req.body as APIInteraction;
50+
// @ts-expect-error because apparently custom_id types are broken
51+
console.log("hook:", body.data.component_type, body.data.custom_id);
52+
// @ts-expect-error because apparently custom_id types are broken
53+
if (body.data.component_type === 2 && body.data.custom_id === "open-ticket") {
54+
res.send({
55+
type: InteractionResponseType.Modal,
56+
data: {
57+
custom_id: "modal-open-ticket",
58+
title: "What do you need from the moderators?",
59+
components: [
60+
{
61+
type: MessageComponentTypes.ACTION_ROW,
62+
components: [
63+
{
64+
type: MessageComponentTypes.INPUT_TEXT,
65+
custom_id: "concern",
66+
label: "Concern",
67+
style: TextStyleTypes.PARAGRAPH,
68+
min_length: 30,
69+
max_length: 500,
70+
required: true,
71+
},
72+
],
73+
},
74+
],
75+
},
76+
});
77+
return;
78+
}
79+
if (isModalInteraction(body)) {
80+
if (
81+
!body.channel ||
82+
!body.message ||
83+
!body.message.interaction_metadata?.user ||
84+
!body.data?.components[0].components[0].value
85+
) {
86+
console.error("ticket creation error", JSON.stringify(req.body));
87+
res.send({
88+
type: InteractionResponseType.ChannelMessageWithSource,
89+
data: {
90+
content: "Something went wrong while creating a ticket",
91+
flags: MessageFlags.Ephemeral,
92+
},
93+
} as APIInteractionResponseChannelMessageWithSource);
94+
return;
95+
}
96+
97+
const { [SETTINGS.moderator]: mod } = await fetchSettings(
98+
// @ts-expect-error because this shouldn't have used a Guild instance but
99+
// it's a lot to refactor
100+
{ id: body.guild_id },
101+
[SETTINGS.moderator],
102+
);
103+
const thread = (await rest.post(Routes.threads(body.channel.id), {
104+
body: {
105+
name: `${body.message.interaction_metadata.user.username}${format(
106+
new Date(),
107+
"PP kk:mmX",
108+
)}`,
109+
auto_archive_duration: 60 * 24 * 7,
110+
type: ChannelType.PrivateThread,
111+
} as RESTPostAPIChannelThreadsJSONBody,
112+
})) as RESTPostAPIChannelThreadsResult;
113+
await rest.post(Routes.channelMessages(thread.id), {
114+
body: {
115+
content: `<@${body.message.interaction_metadata.user.id}>, this is a private space only visible to the <@&${mod}> role.`,
116+
} as RESTPostAPIChannelMessageJSONBody,
117+
});
118+
await rest.post(Routes.channelMessages(thread.id), {
119+
body: {
120+
content: `${quoteMessageContent(
121+
body.data?.components[0].components[0].value,
122+
)}`,
123+
},
124+
});
125+
126+
res.send({
127+
type: InteractionResponseType.ChannelMessageWithSource,
128+
data: {
129+
content: `A private thread with the moderation team has been opened for you: <#${thread.id}>`,
130+
flags: MessageFlags.Ephemeral,
131+
},
132+
} as APIInteractionResponseChannelMessageWithSource);
133+
return;
134+
}
135+
};
136+
137+
export const handler = async (interaction: ChatInputCommandInteraction) => {
138+
if (!interaction.guild) throw new Error("Interaction has no guild");
139+
140+
await interaction.reply({
141+
components: [
142+
{
143+
type: ComponentType.ActionRow,
144+
components: [
145+
{
146+
type: ComponentType.Button,
147+
label: "Open a private ticket with the moderators",
148+
style: ButtonStyle.Primary,
149+
customId: "open-ticket",
150+
},
151+
],
152+
},
153+
],
154+
});
155+
};

app/discord/deployCommands.server.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type {
55
SlashCommandBuilder,
66
} from "discord.js";
77
import { InteractionType, Routes } from "discord.js";
8+
import type { Application } from "express";
89

910
import { rest } from "~/discord/api";
1011
import type {
@@ -199,6 +200,9 @@ export const deployTestCommands = async (
199200
type Command = MessageContextCommand | UserContextCommand | SlashCommand;
200201

201202
const commands = new Map<string, Command>();
202-
export const registerCommand = (config: Command) => {
203+
export const registerCommand = (config: Command, express: Application) => {
204+
if (config.webserver) {
205+
express.post("/webhooks/discord", config.webserver);
206+
}
203207
commands.set(config.command.name, config);
204208
};

app/discord/gateway.ts

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,12 @@
11
import Sentry from "~/helpers/sentry.server";
22

33
import { client, login } from "~/discord/client.server";
4-
import {
5-
deployCommands,
6-
registerCommand,
7-
} from "~/discord/deployCommands.server";
4+
import { deployCommands } from "~/discord/deployCommands.server";
85

96
import automod from "~/discord/automod";
107
import onboardGuild from "~/discord/onboardGuild";
118
import { startActivityTracking } from "~/discord/activityTracker";
129

13-
import * as convene from "~/commands/convene";
14-
import * as setup from "~/commands/setup";
15-
import * as report from "~/commands/report";
16-
import * as track from "~/commands/track";
17-
18-
registerCommand(convene);
19-
registerCommand(setup);
20-
registerCommand(report);
21-
registerCommand(track);
22-
2310
export default function init() {
2411
login();
2512

app/helpers/discord.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
ContextMenuCommandBuilder,
1818
SlashCommandBuilder,
1919
} from "discord.js";
20+
import type { RequestHandler } from "express";
2021
import prettyBytes from "pretty-bytes";
2122

2223
const staffRoles = ["mvp", "moderator", "admin", "admins"];
@@ -146,6 +147,7 @@ ${poll.answers.map((a) => `> - ${a.text}`).join("\n")}`;
146147
export type MessageContextCommand = {
147148
command: ContextMenuCommandBuilder;
148149
handler: (interaction: MessageContextMenuCommandInteraction) => void;
150+
webserver?: RequestHandler;
149151
};
150152
export const isMessageContextCommand = (
151153
config: MessageContextCommand | UserContextCommand | SlashCommand,
@@ -156,6 +158,7 @@ export const isMessageContextCommand = (
156158
export type UserContextCommand = {
157159
command: ContextMenuCommandBuilder;
158160
handler: (interaction: UserContextMenuCommandInteraction) => void;
161+
webserver?: RequestHandler;
159162
};
160163
export const isUserContextCommand = (
161164
config: MessageContextCommand | UserContextCommand | SlashCommand,
@@ -166,6 +169,7 @@ export const isUserContextCommand = (
166169
export type SlashCommand = {
167170
command: SlashCommandBuilder;
168171
handler: (interaction: ChatInputCommandInteraction) => void;
172+
webserver?: RequestHandler;
169173
};
170174
export const isSlashCommand = (
171175
config: MessageContextCommand | UserContextCommand | SlashCommand,

app/index.ts

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,21 @@
1+
// started with https://developers.cloudflare.com/workers/get-started/quickstarts/
12
import express from "express";
23
import { createRequestHandler } from "@remix-run/express";
34
import path from "path";
45
import * as build from "@remix-run/dev/server-build";
6+
import { verifyKey } from "discord-interactions";
57

68
import Sentry from "~/helpers/sentry.server";
79
import discordBot from "~/discord/gateway";
10+
import { applicationKey } from "./helpers/env";
11+
import bodyParser from "body-parser";
12+
13+
import * as convene from "~/commands/convene";
14+
import * as setup from "~/commands/setup";
15+
import * as report from "~/commands/report";
16+
import * as track from "~/commands/track";
17+
import * as setupTicket from "~/commands/setupTickets";
18+
import { registerCommand } from "./discord/deployCommands.server";
819

920
const app = express();
1021

@@ -20,6 +31,42 @@ Route handlers and static hosting
2031

2132
app.use(express.static(path.join(__dirname, "..", "public")));
2233

34+
// Discord signature verification
35+
app.post("/webhooks/discord", bodyParser.json(), async (req, res, next) => {
36+
const isValidRequest = await verifyKey(
37+
JSON.stringify(req.body),
38+
req.header("X-Signature-Ed25519")!,
39+
req.header("X-Signature-Timestamp")!,
40+
applicationKey,
41+
);
42+
console.log("WEBHOOK", "isValidRequest:", isValidRequest);
43+
if (!isValidRequest) {
44+
console.log("[REQ] Invalid request signature");
45+
res.status(401).send({ message: "Bad request signature" });
46+
return;
47+
}
48+
if (req.body.type === 1) {
49+
res.json({ type: 1, data: {} });
50+
return;
51+
}
52+
53+
next();
54+
});
55+
56+
/**
57+
* Initialize Discord gateway.
58+
*/
59+
discordBot();
60+
/**
61+
* Register Discord commands. These may add arbitrary express routes, because
62+
* abstracting Discord interaction handling is weird and complex.
63+
*/
64+
registerCommand(convene, app);
65+
registerCommand(setup, app);
66+
registerCommand(report, app);
67+
registerCommand(track, app);
68+
registerCommand(setupTicket, app);
69+
2370
// needs to handle all verbs (GET, POST, etc.)
2471
app.all(
2572
"*",
@@ -45,8 +92,6 @@ app.use(Sentry.Handlers.errorHandler());
4592
/** Init app */
4693
app.listen(process.env.PORT || "3000");
4794

48-
discordBot();
49-
5095
const errorHandler = (error: unknown) => {
5196
Sentry.captureException(error);
5297
if (error instanceof Error) {

app/models/guilds.server.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ export const fetchSettings = async <T extends keyof typeof SETTINGS>(
6161
keys: T[],
6262
) => {
6363
const result = Object.entries(
64-
(await db
64+
await db
6565
.selectFrom("guilds")
6666
// @ts-expect-error This is broken because of a migration from knex and
6767
// old/bad use of jsonb for storing settings. The type is guaranteed here
@@ -71,9 +71,10 @@ export const fetchSettings = async <T extends keyof typeof SETTINGS>(
7171
)
7272
.where("id", "=", guild.id)
7373
// This cast is also evidence of the pattern being broken
74-
.executeTakeFirstOrThrow()) as Pick<SettingsRecord, T>,
75-
);
76-
return Object.fromEntries(
77-
result.map(([k, v]) => [k, JSON.parse(v as string)]),
78-
);
74+
.executeTakeFirstOrThrow(),
75+
) as [T, string][];
76+
return Object.fromEntries(result.map(([k, v]) => [k, JSON.parse(v)])) as Pick<
77+
SettingsRecord,
78+
T
79+
>;
7980
};

package-lock.json

Lines changed: 17 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)