diff options
Diffstat (limited to 'packages/interactions/server.ts')
| -rw-r--r-- | packages/interactions/server.ts | 741 |
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; |