import { AutoRouter } from "itty-router"; import { InteractionResponseType, InteractionType } from "discord-interactions"; import { HOT_COMMAND, ROLEPLAY_COMMAND, NSFW_COMMAND, TOP_COMMAND, COMPLAIN_COMMAND, COLOURS_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, 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 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 "1415539544232824913", // Aston Machan Sienna "1415567915578818723", // Super Creek Baby Blue "1415592658906124338", // Sakura Bakushin O Lilac "1415593126273224795", // El Condor Pasa Biscotti "1415797242845200475", // Still in Love Crimson "1415868433714778204", // Mayano Top Gun Navy Blue "1415868433714778205", // Mr. C.B. Forest Green ]; const sendComplaintToChannel = async ( environment: Environment, embed: DiscordEmbed, ): Promise => { 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 fetchRoleDistribution = async ( environment: Environment, guildID: string, ): Promise> => { 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 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, }, }); } } 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;