summaryrefslogtreecommitdiff
path: root/packages/interactions/server.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/interactions/server.ts')
-rw-r--r--packages/interactions/server.ts741
1 files changed, 741 insertions, 0 deletions
diff --git a/packages/interactions/server.ts b/packages/interactions/server.ts
new file mode 100644
index 0000000..a2618fe
--- /dev/null
+++ b/packages/interactions/server.ts
@@ -0,0 +1,741 @@
+import { AutoRouter } from "itty-router";
+import { InteractionResponseType, InteractionType } from "discord-interactions";
+import {
+ HOT_COMMAND,
+ ROLEPLAY_COMMAND,
+ NSFW_COMMAND,
+ TOP_COMMAND,
+ COMPLAIN_COMMAND,
+ APPEAL_COMMAND,
+ COLOURS_COMMAND,
+ ROLEPLAY_SERIOUS_COMMAND,
+} from "./discord/commands.ts";
+import {
+ getCutePost,
+ getRoleplayPost,
+ getNSFWPost,
+ getTopPost,
+} from "./reddit.ts";
+import type { TimePeriod } from "./discord/types.ts";
+import type { Environment, DiscordEmbed } from "./discord/interfaces.ts";
+import {
+ createPostEmbed,
+ createComplaintEmbed,
+ createAppealEmbed,
+ createRoleDistributionEmbed,
+} from "./discord/embeds.ts";
+import { JSONResponse } from "./discord/responses.ts";
+import { verifyDiscordRequest } from "./discord/verification.ts";
+
+const router = AutoRouter();
+const COMPLAINT_CHANNEL_ID = "1415868433714778204";
+const APPEAL_CHANNEL_ID = "1420340807931531385";
+const SERIOUS_ROLEPLAY_ROLE_ID = "1418311833303122021";
+const ROLE_MANAGER_ROLE_ID = "1410993207608873070";
+const GUILD_ID = "1406422617724026901";
+const COLOR_ROLE_IDS = [
+ "1407075059830624406", // Nice Nature Red
+ "1407075160250650664", // Taiki Shuttle Green
+ "1407075256904187997", // Mejiro McQueen Purple
+ "1407075372427640952", // Gold Ship Grey
+ "1407075670177091664", // Grass Wonder Gold
+ "1407078154555752589", // Agnes Tachyon Dark Purple
+ "1407345006108475476", // Special Week Salmon
+ "1408246546708959403", // Biwahaya Hide Linen
+ "1408247166413176943", // Symboli Rudolf Celeste
+ "1411128003924332764", // King Halo Dark Blue
+ "1413582797284708474", // Matikanetannhauser Lemon
+ "1414435043761324042", // Silence Suzuka Sea Green
+ "1414454914138116158", // Haru Urara Pink
+ "1414455824524247161", // TM Opera O Orange
+ "1414456352167825490", // Oguri Cap Buttermilk
+ "1414541675396862012", // Kitasan Black Sable
+ "1415083621152460832", // Tokai Teio Royal Blue
+ "1415520343690575883", // Aston Machan Sienna
+ "1415539100315942962", // Super Creek Baby Blue
+ "1415539544232824913", // Sakura Bakushin O Lilac
+ "1415567915578818723", // El Condor Pasa Biscotti
+ "1415592658906124338", // Still in Love Crimson
+ "1415593126273224795", // Mayano Top Gun Navy Blue
+ "1415797242845200475", // Mr. C.B. Forest Green
+ "1416583306698297354", // Seuin Sky Mint
+ "1416583690217328660", // Neo Universe Pastel Yellow
+ "1416595046249267364", // Manhattan Cafe Jet Black
+];
+
+const sendComplaintToChannel = async (
+ environment: Environment,
+ embed: DiscordEmbed,
+): Promise<boolean> => {
+ const url = `https://discord.com/api/v10/channels/${COMPLAINT_CHANNEL_ID}/messages`;
+
+ try {
+ const response = await fetch(url, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bot ${environment.DISCORD_TOKEN}`,
+ },
+ body: JSON.stringify({
+ embeds: [embed],
+ }),
+ });
+
+ return response.ok;
+ } catch (error) {
+ console.error("Error sending complaint to channel:", error);
+
+ return false;
+ }
+};
+
+const sendAppealToChannel = async (
+ environment: Environment,
+ embed: DiscordEmbed,
+): Promise<boolean> => {
+ const url = `https://discord.com/api/v10/channels/${APPEAL_CHANNEL_ID}/messages`;
+
+ try {
+ const response = await fetch(url, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bot ${environment.DISCORD_TOKEN}`,
+ },
+ body: JSON.stringify({
+ embeds: [embed],
+ }),
+ });
+
+ return response.ok;
+ } catch (error) {
+ console.error("Error sending appeal to channel:", error);
+
+ return false;
+ }
+};
+
+const fetchRoleDistribution = async (
+ environment: Environment,
+ guildID: string,
+): Promise<Array<{ name: string; count: number }>> => {
+ const roleData: Array<{ name: string; count: number }> = [];
+
+ try {
+ const guildResponse = await fetch(
+ `https://discord.com/api/v10/guilds/${guildID}`,
+ {
+ headers: {
+ Authorization: `Bot ${environment.DISCORD_TOKEN}`,
+ },
+ },
+ );
+
+ if (!guildResponse.ok) {
+ console.error(
+ "Failed to fetch guild data:",
+ guildResponse.status,
+ guildResponse.statusText,
+ );
+
+ const errorText = await guildResponse.text();
+
+ console.error("Error details:", errorText);
+
+ return roleData;
+ }
+
+ const guild = await guildResponse.json();
+
+ for (const roleID of COLOR_ROLE_IDS) {
+ const role = guild.roles?.find((r: any) => r.id === roleID);
+
+ if (role) {
+ roleData.push({
+ name: role.name,
+ count: 0,
+ });
+ } else {
+ console.log(`Role not found: ${roleID}`);
+ }
+ }
+
+ let after = "";
+ let hasMore = true;
+ let batchCount = 0;
+ const maxBatches = 10;
+
+ while (hasMore && batchCount < maxBatches) {
+ const membersResponse = await fetch(
+ `https://discord.com/api/v10/guilds/${guildID}/members?limit=1000${after ? `&after=${after}` : ""}`,
+ {
+ headers: {
+ Authorization: `Bot ${environment.DISCORD_TOKEN}`,
+ },
+ },
+ );
+
+ if (membersResponse.status === 429) {
+ const retryAfter = membersResponse.headers.get("Retry-After");
+ const resetAfter = membersResponse.headers.get(
+ "X-RateLimit-Reset-After",
+ );
+ const scope = membersResponse.headers.get("X-RateLimit-Scope");
+
+ console.log(
+ `Rate limited! Scope: ${scope}, Retry-After: ${retryAfter}, Reset-After: ${resetAfter}`,
+ );
+
+ const delayMs = Math.max(
+ retryAfter ? parseFloat(retryAfter) * 1000 : 0,
+ resetAfter ? parseFloat(resetAfter) * 1000 : 0,
+ );
+
+ if (delayMs > 0) {
+ console.log(`Waiting ${delayMs}ms before retry ...`);
+
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
+
+ continue;
+ }
+ }
+
+ if (!membersResponse.ok) {
+ console.error(
+ "Failed to fetch members:",
+ membersResponse.status,
+ membersResponse.statusText,
+ );
+
+ const errorText = await membersResponse.text();
+
+ console.error("Members error details:", errorText);
+
+ break;
+ }
+
+ const remaining = membersResponse.headers.get("X-RateLimit-Remaining");
+ const resetAfter = membersResponse.headers.get("X-RateLimit-Reset-After");
+
+ if (remaining === "0" && resetAfter) {
+ console.log(`Rate limit bucket empty, waiting ${resetAfter}s...`);
+
+ await new Promise((resolve) =>
+ setTimeout(resolve, parseFloat(resetAfter) * 1000),
+ );
+ }
+
+ const members = await membersResponse.json();
+
+ for (const member of members)
+ for (const roleId of member.roles || []) {
+ const roleIndex = COLOR_ROLE_IDS.indexOf(roleId);
+
+ if (roleIndex !== -1) roleData[roleIndex].count++;
+ }
+
+ hasMore = members.length === 1000;
+
+ if (hasMore && members.length > 0)
+ after = members[members.length - 1].user.id;
+
+ batchCount += 1;
+ }
+
+ roleData.sort((a, b) => b.count - a.count);
+ } catch (error) {
+ console.error("Error fetching role distribution:", error);
+ }
+
+ return roleData;
+};
+
+router.get("/", (_request: Request, environment: Environment) => {
+ return new Response(`👋 ${environment.DISCORD_APPLICATION_ID}`);
+});
+
+router.post("/", async (request: Request, environment: Environment) => {
+ const { isValid, interaction } = await server.verifyDiscordRequest(
+ request,
+ environment,
+ );
+
+ if (!isValid || !interaction)
+ return new Response("Bad request signature.", { status: 401 });
+
+ if (interaction.type === InteractionType.PING)
+ return new JSONResponse({
+ type: InteractionResponseType.PONG,
+ });
+
+ if (interaction.type === InteractionType.APPLICATION_COMMAND) {
+ switch (interaction.data.name.toLowerCase()) {
+ case HOT_COMMAND.name.toLowerCase(): {
+ try {
+ const post = await getCutePost();
+ const embed = createPostEmbed(post);
+
+ return new JSONResponse({
+ type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
+ data: {
+ embeds: [embed],
+ },
+ });
+ } catch (error) {
+ console.error("Error in hot command:", error);
+
+ return new JSONResponse({
+ type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
+ data: {
+ content: "❌ No posts found. Try again later!",
+ flags: 64,
+ },
+ });
+ }
+ }
+
+ case ROLEPLAY_COMMAND.name.toLowerCase(): {
+ try {
+ const post = await getRoleplayPost();
+ const embed = createPostEmbed(post);
+
+ return new JSONResponse({
+ type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
+ data: {
+ embeds: [embed],
+ },
+ });
+ } catch (error) {
+ console.error("Error in roleplay command:", error);
+
+ return new JSONResponse({
+ type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
+ data: {
+ content: "❌ No roleplay posts found. Try again later!",
+ flags: 64,
+ },
+ });
+ }
+ }
+
+ case NSFW_COMMAND.name.toLowerCase(): {
+ if (!interaction.channel_id || !interaction.channel?.nsfw) {
+ return new JSONResponse({
+ type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
+ data: {
+ content: "❌ This command can only be used in NSFW channels.",
+ flags: 64,
+ },
+ });
+ }
+
+ try {
+ const post = await getNSFWPost();
+ const embed = createPostEmbed(post);
+
+ return new JSONResponse({
+ type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
+ data: {
+ embeds: [embed],
+ },
+ });
+ } catch (error) {
+ console.error("Error in NSFW command:", error);
+
+ return new JSONResponse({
+ type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
+ data: {
+ content: "❌ No NSFW posts found. Try again later!",
+ flags: 64,
+ },
+ });
+ }
+ }
+
+ case TOP_COMMAND.name.toLowerCase(): {
+ try {
+ const time =
+ (interaction.data.options?.[0]?.value as TimePeriod) || "day";
+ const post = await getTopPost(time);
+ const embed = createPostEmbed(post);
+
+ return new JSONResponse({
+ type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
+ data: {
+ embeds: [embed],
+ },
+ });
+ } catch (error) {
+ console.error("Error in top command:", error);
+
+ return new JSONResponse({
+ type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
+ data: {
+ content: "❌ No top posts found. Try again later!",
+ flags: 64,
+ },
+ });
+ }
+ }
+
+ case COMPLAIN_COMMAND.name.toLowerCase(): {
+ try {
+ const complaintMessage = interaction.data.options?.[0]
+ ?.value as string;
+
+ if (!complaintMessage)
+ return new JSONResponse({
+ type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
+ data: {
+ content: "❌ Please provide a message for your complaint.",
+ flags: 64,
+ },
+ });
+
+ const complainant = {
+ username:
+ interaction.member?.user?.username ||
+ interaction.user?.username ||
+ "Unknown",
+ id:
+ interaction.member?.user?.id || interaction.user?.id || "Unknown",
+ avatar:
+ interaction.member?.user?.avatar || interaction.user?.avatar,
+ };
+ const isDM = !interaction.guild_id;
+ const complaintEmbed = createComplaintEmbed(
+ complaintMessage,
+ complainant,
+ Date.now(),
+ isDM,
+ );
+ const success = await sendComplaintToChannel(
+ environment,
+ complaintEmbed,
+ );
+
+ if (success) {
+ return new JSONResponse({
+ type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
+ data: {
+ content: "✅ Your complaint has been submitted successfully!",
+ flags: 64,
+ },
+ });
+ } else {
+ return new JSONResponse({
+ type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
+ data: {
+ content:
+ "❌ Failed to submit your complaint. Please try again later.",
+ flags: 64,
+ },
+ });
+ }
+ } catch (error) {
+ console.error("Error in complain command:", error);
+
+ return new JSONResponse({
+ type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
+ data: {
+ content: "❌ An error occurred while processing your complaint.",
+ flags: 64,
+ },
+ });
+ }
+ }
+
+ case APPEAL_COMMAND.name.toLowerCase(): {
+ try {
+ const appealMessage = interaction.data.options?.[0]?.value as string;
+
+ if (!appealMessage)
+ return new JSONResponse({
+ type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
+ data: {
+ content: "❌ Please provide a message for your appeal.",
+ flags: 64,
+ },
+ });
+
+ const appellant = {
+ username:
+ interaction.member?.user?.username ||
+ interaction.user?.username ||
+ "Unknown",
+ id:
+ interaction.member?.user?.id || interaction.user?.id || "Unknown",
+ avatar:
+ interaction.member?.user?.avatar || interaction.user?.avatar,
+ };
+ const isDM = !interaction.guild_id;
+ const appealEmbed = createAppealEmbed(
+ appealMessage,
+ appellant,
+ Date.now(),
+ isDM,
+ );
+ const success = await sendAppealToChannel(environment, appealEmbed);
+
+ if (success) {
+ return new JSONResponse({
+ type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
+ data: {
+ content:
+ "✅ Your appeal has been submitted successfully! A moderator will follow up with you soon.",
+ flags: 64,
+ },
+ });
+ } else {
+ return new JSONResponse({
+ type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
+ data: {
+ content:
+ "❌ Failed to submit your appeal. Please try again later.",
+ flags: 64,
+ },
+ });
+ }
+ } catch (error) {
+ console.error("Error in appeal command:", error);
+
+ return new JSONResponse({
+ type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
+ data: {
+ content: "❌ An error occurred while processing your appeal.",
+ flags: 64,
+ },
+ });
+ }
+ }
+
+ case COLOURS_COMMAND.name.toLowerCase(): {
+ try {
+ if (!interaction.guild_id)
+ return new JSONResponse({
+ type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
+ data: {
+ content: "❌ This command can only be used in server channels.",
+ flags: 64,
+ },
+ });
+
+ const roleDistribution = await fetchRoleDistribution(
+ environment,
+ GUILD_ID,
+ );
+
+ if (roleDistribution.length === 0)
+ return new JSONResponse({
+ type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
+ data: {
+ content:
+ "❌ Unable to fetch role distribution data. The bot may not have permission to read member lists or the server may not be accessible.",
+ flags: 64,
+ },
+ });
+
+ const embed = createRoleDistributionEmbed(roleDistribution);
+
+ return new JSONResponse({
+ type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
+ data: {
+ embeds: [embed],
+ },
+ });
+ } catch (error) {
+ console.error("Error in colours command:", error);
+
+ return new JSONResponse({
+ type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
+ data: {
+ content: "❌ An error occurred while fetching role distribution.",
+ flags: 64,
+ },
+ });
+ }
+ }
+
+ case ROLEPLAY_SERIOUS_COMMAND.name.toLowerCase(): {
+ try {
+ const member = interaction.member;
+ const hasAdminPermission =
+ member?.permissions && (parseInt(member.permissions) & 0x8) === 0x8;
+ const hasManagerRole = member?.roles?.includes(ROLE_MANAGER_ROLE_ID);
+
+ if (!hasAdminPermission && !hasManagerRole)
+ return new JSONResponse({
+ type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
+ data: {
+ content:
+ "❌ You don't have permission to use this command. Only administrators and role managers can use this command.",
+ flags: 64,
+ },
+ });
+
+ const action = interaction.data.options?.[0]?.value as string;
+ const targetUserID = interaction.data.options?.[1]?.value as string;
+
+ if (!action || !targetUserID)
+ return new JSONResponse({
+ type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
+ data: {
+ content:
+ "❌ Missing required parameters. Please provide both action and user.",
+ flags: 64,
+ },
+ });
+
+ const guild = await fetch(
+ `https://discord.com/api/v10/guilds/${GUILD_ID}/members/${targetUserID}`,
+ {
+ headers: {
+ Authorization: `Bot ${environment.DISCORD_TOKEN}`,
+ },
+ },
+ );
+
+ if (!guild.ok)
+ return new JSONResponse({
+ type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
+ data: {
+ content:
+ "❌ Unable to fetch user information. The user may not be in this server.",
+ flags: 64,
+ },
+ });
+
+ const targetMember = await guild.json();
+ const currentRoles = targetMember.roles || [];
+ const hasRole = currentRoles.includes(SERIOUS_ROLEPLAY_ROLE_ID);
+ let newRoles = [...currentRoles];
+ let actionTaken = "";
+
+ switch (action) {
+ case "add":
+ if (hasRole)
+ return new JSONResponse({
+ type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
+ data: {
+ content:
+ "❌ The user already has the serious roleplay role.",
+ flags: 64,
+ },
+ });
+
+ newRoles.push(SERIOUS_ROLEPLAY_ROLE_ID);
+
+ actionTaken = "added";
+
+ break;
+
+ case "remove":
+ if (!hasRole)
+ return new JSONResponse({
+ type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
+ data: {
+ content:
+ "❌ The user doesn't have the serious roleplay role.",
+ flags: 64,
+ },
+ });
+
+ newRoles = newRoles.filter(
+ (roleId) => roleId !== SERIOUS_ROLEPLAY_ROLE_ID,
+ );
+ actionTaken = "removed";
+
+ break;
+
+ case "toggle":
+ if (hasRole) {
+ newRoles = newRoles.filter(
+ (roleId) => roleId !== SERIOUS_ROLEPLAY_ROLE_ID,
+ );
+ actionTaken = "removed";
+ } else {
+ newRoles.push(SERIOUS_ROLEPLAY_ROLE_ID);
+
+ actionTaken = "added";
+ }
+
+ break;
+
+ default:
+ return new JSONResponse({
+ type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
+ data: {
+ content:
+ "❌ Invalid action. Please use 'add', 'remove', or 'toggle'.",
+ flags: 64,
+ },
+ });
+ }
+
+ const updateResponse = await fetch(
+ `https://discord.com/api/v10/guilds/${GUILD_ID}/members/${targetUserID}`,
+ {
+ method: "PATCH",
+ headers: {
+ Authorization: `Bot ${environment.DISCORD_TOKEN}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ roles: newRoles,
+ }),
+ },
+ );
+
+ if (!updateResponse.ok) {
+ console.error(
+ "Failed to update user roles:",
+ await updateResponse.text(),
+ );
+
+ return new JSONResponse({
+ type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
+ data: {
+ content:
+ "❌ Failed to update user roles. The bot may not have sufficient permissions.",
+ flags: 64,
+ },
+ });
+ }
+
+ return new JSONResponse({
+ type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
+ data: {
+ content: `✅ Successfully ${actionTaken} the serious roleplay role for <@${targetUserID}>.`,
+ },
+ });
+ } catch (error) {
+ console.error("Error in roleplay-serious command:", error);
+
+ return new JSONResponse({
+ type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
+ data: {
+ content: "❌ An error occurred while managing the role.",
+ flags: 64,
+ },
+ });
+ }
+ }
+
+ default:
+ return new JSONResponse({ error: "Unknown Type" }, { status: 400 });
+ }
+ }
+
+ console.error("Unknown Type");
+
+ return new JSONResponse({ error: "Unknown Type" }, { status: 400 });
+});
+
+router.all("*", () => new Response("Not Found.", { status: 404 }));
+
+const server = {
+ verifyDiscordRequest,
+ fetch: router.fetch,
+};
+
+export default server;