diff options
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/interactions/discord/commands.ts | 125 | ||||
| -rw-r--r-- | packages/interactions/discord/embeds.ts | 181 | ||||
| -rw-r--r-- | packages/interactions/discord/interfaces.ts | 86 | ||||
| -rw-r--r-- | packages/interactions/discord/responses.ts | 15 | ||||
| -rw-r--r-- | packages/interactions/discord/types.ts | 1 | ||||
| -rw-r--r-- | packages/interactions/discord/verification.ts | 24 | ||||
| -rw-r--r-- | packages/interactions/reddit.ts | 208 | ||||
| -rw-r--r-- | packages/interactions/register.ts | 70 | ||||
| -rw-r--r-- | packages/interactions/server.ts | 741 |
9 files changed, 1451 insertions, 0 deletions
diff --git a/packages/interactions/discord/commands.ts b/packages/interactions/discord/commands.ts new file mode 100644 index 0000000..601591b --- /dev/null +++ b/packages/interactions/discord/commands.ts @@ -0,0 +1,125 @@ +import type { DiscordCommand } from "./interfaces.ts"; + +export type { DiscordCommand }; + +export const HOT_COMMAND: DiscordCommand = { + name: "hot", + description: "Fetch a random hot post from r/okbuddyumamusume", +}; + +export const ROLEPLAY_COMMAND: DiscordCommand = { + name: "roleplay", + description: "Fetch a random hot roleplay post from r/okbuddyumamusume", +}; + +export const NSFW_COMMAND: DiscordCommand = { + name: "nsfw", + description: + "Fetch a random NSFW post from r/okbuddyumamusume (NSFW channels only)", +}; + +export const TOP_COMMAND: DiscordCommand = { + name: "top", + description: + "Fetch a random top post from r/okbuddyumamusume (defaults to today)", + options: [ + { + type: 3, + name: "time", + description: "Time period for top posts (defaults to today)", + required: false, + choices: [ + { + name: "Now", + value: "hour", + }, + { + name: "Today", + value: "day", + }, + { + name: "This Week", + value: "week", + }, + { + name: "This Month", + value: "month", + }, + { + name: "This Year", + value: "year", + }, + { + name: "All Time", + value: "all", + }, + ], + }, + ], +}; + +export const COMPLAIN_COMMAND: DiscordCommand = { + name: "complain", + description: "Submit a complaint to the moderators", + contexts: [0], + options: [ + { + type: 3, + name: "message", + description: "Your complaint message", + required: true, + }, + ], +}; + +export const APPEAL_COMMAND: DiscordCommand = { + name: "appeal", + description: "Submit an appeal to the moderators", + contexts: [0], + options: [ + { + type: 3, + name: "message", + description: "Your appeal message", + required: true, + }, + ], +}; + +export const COLOURS_COMMAND: DiscordCommand = { + name: "colours", + description: "Show the distribution of colour roles in the server", +}; + +export const ROLEPLAY_SERIOUS_COMMAND: DiscordCommand = { + name: "roleplay-serious", + description: "Manage the serious roleplay role (Admin/Roleplay Curator only)", + options: [ + { + type: 3, + name: "action", + description: "Action to perform on the role", + required: true, + choices: [ + { + name: "Add Role", + value: "add", + }, + { + name: "Remove Role", + value: "remove", + }, + { + name: "Toggle Role", + value: "toggle", + }, + ], + }, + { + type: 6, + name: "user", + description: "User to perform the action on", + required: true, + }, + ], +}; diff --git a/packages/interactions/discord/embeds.ts b/packages/interactions/discord/embeds.ts new file mode 100644 index 0000000..3f7c344 --- /dev/null +++ b/packages/interactions/discord/embeds.ts @@ -0,0 +1,181 @@ +import type { DiscordEmbed } from "./interfaces.ts"; +import type { RedditPost } from "../reddit.ts"; + +const decodeHtmlEntities = (str: string): string => { + return str + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(///g, "/") + .replace(/`/g, "`") + .replace(/=/g, "="); +}; + +export const createPostEmbed = (post: RedditPost): DiscordEmbed => { + const mediaUrl = + post.media?.reddit_video?.fallback_url || + post.secure_media?.reddit_video?.fallback_url || + post.url; + + let description = post.selftext || ""; + + if (description.length > 1000) + description = description.substring(0, 997).trim() + " ..."; + + const embed: DiscordEmbed = { + title: post.title, + description: description, + url: `https://reddit.com${post.permalink}`, + color: 0xff4500, + author: { + name: `u/${post.author}`, + url: `https://reddit.com/u/${post.author}`, + }, + fields: [ + { + name: "Score", + value: `${post.score} ā¬ļø`, + inline: true, + }, + { + name: "Comments", + value: `${post.num_comments} š¬`, + inline: true, + }, + ], + timestamp: new Date(post.created_utc * 1000).toISOString(), + footer: { + text: "r/okbuddyumamusume", + }, + }; + + if (mediaUrl) + if (post.media?.reddit_video || post.secure_media?.reddit_video) { + if (!description) description = ""; + + description += + "\n\nš¹ **This post contains a video** - [Click here to view](" + + mediaUrl + + ")"; + embed.description = description; + + if (post.preview?.images?.[0]?.source?.url) { + const decodedURL = decodeHtmlEntities( + post.preview.images[0].source.url, + ); + + console.log("Using preview image:", decodedURL); + + embed.image = { url: decodedURL }; + } else if ( + post.thumbnail && + post.thumbnail !== "self" && + post.thumbnail !== "default" + ) { + const decodedThumbnail = decodeHtmlEntities(post.thumbnail); + + console.log("Using thumbnail:", decodedThumbnail); + + embed.image = { url: decodedThumbnail }; + } else { + console.log("No suitable thumbnail found for video post"); + } + } else { + embed.image = { url: mediaUrl }; + } + + return embed; +}; + +export const createRoleDistributionEmbed = ( + roleDistribution: Array<{ name: string; count: number }>, +): DiscordEmbed => { + const totalMembers = roleDistribution.reduce( + (sum, role) => sum + role.count, + 0, + ); + + return { + title: "šØ Colour Role Distribution", + description: `Total members with colour roles: **${totalMembers}**`, + color: 0x5865f2, + fields: roleDistribution.map((role) => ({ + name: role.name, + value: `${role.count} member${role.count !== 1 ? "s" : ""}`, + inline: true, + })), + footer: { + text: "Sorted by member count (highest to lowest)", + }, + }; +}; + +export const createComplaintEmbed = ( + complaintContent: string, + complainant: { username: string; id: string; avatar?: string }, + timestamp: number, + isDM: boolean = true, +): DiscordEmbed => { + return { + title: "šØ New Complaint", + description: complaintContent, + color: 0xff6b6b, + fields: [ + { + name: "Complainant", + value: `${complainant.username} (${complainant.id})`, + inline: true, + }, + { + name: "Timestamp", + value: `<t:${Math.floor(timestamp / 1000)}:F>`, + inline: true, + }, + ], + thumbnail: complainant.avatar + ? { + url: `https://cdn.discordapp.com/avatars/${complainant.id}/${complainant.avatar}.png?size=256`, + } + : undefined, + footer: { + text: isDM + ? "Complaint submitted via DM" + : "Complaint submitted from server", + }, + }; +}; + +export const createAppealEmbed = ( + appealContent: string, + appellant: { username: string; id: string; avatar?: string }, + timestamp: number, + isDM: boolean = true, +): DiscordEmbed => { + return { + title: "š New Appeal", + description: appealContent, + color: 0x5865f2, + fields: [ + { + name: "Appellant", + value: `${appellant.username} (${appellant.id})`, + inline: true, + }, + { + name: "Timestamp", + value: `<t:${Math.floor(timestamp / 1000)}:F>`, + inline: true, + }, + ], + thumbnail: appellant.avatar + ? { + url: `https://cdn.discordapp.com/avatars/${appellant.id}/${appellant.avatar}.png?size=256`, + } + : undefined, + footer: { + text: isDM ? "Appeal submitted via DM" : "Appeal submitted from server", + }, + }; +}; diff --git a/packages/interactions/discord/interfaces.ts b/packages/interactions/discord/interfaces.ts new file mode 100644 index 0000000..bc8683c --- /dev/null +++ b/packages/interactions/discord/interfaces.ts @@ -0,0 +1,86 @@ +export interface Environment { + DISCORD_APPLICATION_ID: string; + DISCORD_PUBLIC_KEY: string; + DISCORD_TOKEN: string; +} + +export interface DiscordInteraction { + type: number; + data: { + name: string; + options?: Array<{ + name: string; + value: string; + }>; + }; + channel_id?: string; + channel?: { + nsfw: boolean; + }; + guild_id?: string; + user?: { + id: string; + username: string; + avatar?: string; + }; + member?: { + user?: { + id: string; + username: string; + avatar?: string; + }; + roles?: string[]; + permissions?: string; + }; +} + +export interface DiscordEmbed { + title: string; + description: string; + url?: string; + color: number; + author?: { + name: string; + url: string; + }; + fields?: Array<{ + name: string; + value: string; + inline: boolean; + }>; + timestamp?: string; + footer?: { + text: string; + }; + image?: { url: string }; + thumbnail?: { url: string }; +} + +export interface DiscordResponse { + type: number; + data?: { + content?: string; + embeds?: DiscordEmbed[]; + flags?: number; + }; +} + +export interface DiscordCommand { + name: string; + description: string; + options?: DiscordCommandOption[]; + contexts?: number[]; +} + +export interface DiscordCommandOption { + type: number; + name: string; + description: string; + required?: boolean; + choices?: DiscordCommandChoice[]; +} + +export interface DiscordCommandChoice { + name: string; + value: string; +} diff --git a/packages/interactions/discord/responses.ts b/packages/interactions/discord/responses.ts new file mode 100644 index 0000000..4dcc777 --- /dev/null +++ b/packages/interactions/discord/responses.ts @@ -0,0 +1,15 @@ +import type { DiscordResponse } from "./interfaces.ts"; + +export class JSONResponse extends Response { + constructor(body: DiscordResponse | { error: string }, init?: ResponseInit) { + const jsonBody = JSON.stringify(body); + + init = init || { + headers: { + "content-type": "application/json;charset=UTF-8", + }, + }; + + super(jsonBody, init); + } +} diff --git a/packages/interactions/discord/types.ts b/packages/interactions/discord/types.ts new file mode 100644 index 0000000..4f6e85e --- /dev/null +++ b/packages/interactions/discord/types.ts @@ -0,0 +1 @@ +export type TimePeriod = "hour" | "day" | "week" | "month" | "year" | "all"; diff --git a/packages/interactions/discord/verification.ts b/packages/interactions/discord/verification.ts new file mode 100644 index 0000000..89d26db --- /dev/null +++ b/packages/interactions/discord/verification.ts @@ -0,0 +1,24 @@ +import { verifyKey } from "discord-interactions"; +import type { Environment, DiscordInteraction } from "./interfaces.ts"; + +export const verifyDiscordRequest = async ( + request: Request, + environment: Environment, +): Promise<{ isValid: boolean; interaction?: DiscordInteraction }> => { + const signature = request.headers.get("x-signature-ed25519"); + const timestamp = request.headers.get("x-signature-timestamp"); + const body = await request.text(); + const isValidRequest = + signature && + timestamp && + (await verifyKey( + body, + signature, + timestamp, + environment.DISCORD_PUBLIC_KEY, + )); + + if (!isValidRequest) return { isValid: false }; + + return { interaction: JSON.parse(body) as DiscordInteraction, isValid: true }; +}; diff --git a/packages/interactions/reddit.ts b/packages/interactions/reddit.ts new file mode 100644 index 0000000..5b4ded7 --- /dev/null +++ b/packages/interactions/reddit.ts @@ -0,0 +1,208 @@ +import type { TimePeriod } from "./discord/types.ts"; + +export interface RedditPost { + id: string; + title: string; + author: string; + score: number; + num_comments: number; + created_utc: number; + permalink: string; + url: string; + selftext: string; + is_gallery?: boolean; + over_18: boolean; + link_flair_text?: string; + thumbnail?: string; + preview?: { + images: Array<{ + source: { + url: string; + width: number; + height: number; + }; + resolutions: Array<{ + url: string; + width: number; + height: number; + }>; + }>; + enabled: boolean; + }; + media?: { + reddit_video?: { + fallback_url: string; + }; + }; + secure_media?: { + reddit_video?: { + fallback_url: string; + }; + }; +} + +export interface RedditResponse { + data: { + children: Array<{ + data: RedditPost; + }>; + }; +} + +type SortType = "hot" | "top"; + +const fetchWithRetry = async ( + url: string, + maxRetries: number = 3, +): Promise<Response> => { + for (let attempt = 0; attempt < maxRetries; attempt++) + try { + await new Promise((resolve) => + setTimeout(resolve, Math.random() * 1000 + 500), + ); + + const response = await fetch(url, { + headers: { + "User-Agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + Accept: + "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.5", + "Accept-Encoding": "gzip, deflate, br", + DNT: "1", + Connection: "keep-alive", + "Upgrade-Insecure-Requests": "1", + }, + }); + + return response; + } catch (error) { + if (attempt === maxRetries - 1) throw error; + + const delay = Math.pow(2, attempt) * 1000 + Math.random() * 1000; + + console.log(`Attempt ${attempt + 1} failed, retrying in ${delay}ms ...`); + + await new Promise((resolve) => setTimeout(resolve, delay)); + } + + throw new Error("Max retries exceeded"); +}; + +export const fetchRedditPosts = async ( + sort: SortType = "hot", + time: TimePeriod = "day", +): Promise<RedditPost[]> => { + const url = `https://www.reddit.com/r/okbuddyumamusume/${sort}.json${sort === "top" ? `?t=${time}` : ""}`; + const response = await fetchWithRetry(url); + + if (!response.ok) { + let errorText = `Error fetching ${response.url}: ${response.status} ${response.statusText}`; + + try { + const error = await response.text(); + + if ( + error.includes("You've been blocked by network security") || + error.includes("blocked by network security") + ) + throw new Error( + "Reddit is blocking requests due to network security. This may be due to rate limiting or bot detection. Please try again later.", + ); + + if (error) errorText = `${errorText} \n\n ${error}`; + } catch (err) { + if ( + err instanceof Error && + err.message.includes("blocked by network security") + ) + throw err; + } + + throw new Error(errorText); + } + + const data: RedditResponse = await response.json(); + + return data.data.children.map((post) => post.data); +}; + +export const filterPostsByFlair = ( + posts: RedditPost[], + excludedFlairs: string[] = [], + includedFlairs: string[] = [], +): RedditPost[] => { + return posts.filter((post) => { + if (post.is_gallery) return false; + + const hasMedia = + post.media?.reddit_video?.fallback_url || + post.secure_media?.reddit_video?.fallback_url || + post.url; + + if (!hasMedia) return false; + + const postFlair = post.link_flair_text?.toLowerCase() || ""; + const isNSFW = post.over_18 || postFlair.includes("nsfw"); + + if ( + includedFlairs.length > 0 && + includedFlairs.some((flair) => flair.toLowerCase() === "nsfw") + ) + if (includedFlairs.some((flair) => flair.toLowerCase() === "nsfw")) + return isNSFW; + + if (isNSFW) return false; + + if (includedFlairs.length > 0) + return includedFlairs.some((flair) => + postFlair.includes(flair.toLowerCase()), + ); + + if (excludedFlairs.length > 0) + return !excludedFlairs.some((flair) => + postFlair.includes(flair.toLowerCase()), + ); + + return true; + }); +}; + +const getRandomPost = (posts: RedditPost[]): RedditPost => { + if (posts.length === 0) + throw new Error("No posts found matching the criteria"); + + const randomIndex = Math.floor(Math.random() * posts.length); + + return posts[randomIndex]; +}; + +export const getCutePost = async (): Promise<RedditPost> => { + const posts = await fetchRedditPosts("hot"); + const filteredPosts = filterPostsByFlair(posts, ["roleplay", "announcement"]); + + return getRandomPost(filteredPosts); +}; + +export const getRoleplayPost = async (): Promise<RedditPost> => { + const posts = await fetchRedditPosts("hot"); + const filteredPosts = filterPostsByFlair(posts, [], ["roleplay"]); + + return getRandomPost(filteredPosts); +}; + +export const getNSFWPost = async (): Promise<RedditPost> => { + const posts = await fetchRedditPosts("hot"); + const filteredPosts = filterPostsByFlair(posts, [], ["nsfw"]); + + return getRandomPost(filteredPosts); +}; + +export const getTopPost = async ( + time: TimePeriod = "day", +): Promise<RedditPost> => { + const posts = await fetchRedditPosts("top", time); + const filteredPosts = filterPostsByFlair(posts, ["roleplay", "announcement"]); + + return getRandomPost(filteredPosts); +}; diff --git a/packages/interactions/register.ts b/packages/interactions/register.ts new file mode 100644 index 0000000..a23c9c8 --- /dev/null +++ b/packages/interactions/register.ts @@ -0,0 +1,70 @@ +import { + HOT_COMMAND, + NSFW_COMMAND, + ROLEPLAY_COMMAND, + TOP_COMMAND, + COMPLAIN_COMMAND, + APPEAL_COMMAND, + COLOURS_COMMAND, + ROLEPLAY_SERIOUS_COMMAND, + type DiscordCommand, +} from "./discord/commands.ts"; +import dotenv from "dotenv"; +import process from "node:process"; + +dotenv.config({ path: ".dev.vars" }); + +const token = process.env.DISCORD_TOKEN; +const applicationID = process.env.DISCORD_APPLICATION_ID; + +if (!token) + throw new Error("The DISCORD_TOKEN environment variable is required."); + +if (!applicationID) + throw new Error( + "The DISCORD_APPLICATION_ID environment variable is required.", + ); + +const url = `https://discord.com/api/v10/applications/${applicationID}/commands`; + +const commands: DiscordCommand[] = [ + HOT_COMMAND, + ROLEPLAY_COMMAND, + NSFW_COMMAND, + TOP_COMMAND, + COMPLAIN_COMMAND, + APPEAL_COMMAND, + COLOURS_COMMAND, + ROLEPLAY_SERIOUS_COMMAND, +]; + +const response = await fetch(url, { + headers: { + "Content-Type": "application/json", + Authorization: `Bot ${token}`, + }, + method: "PUT", + body: JSON.stringify(commands), +}); + +if (response.ok) { + console.log("Registered all commands"); + + const data = await response.json(); + + console.log(JSON.stringify(data, null, 2)); +} else { + console.error("Error registering commands"); + + let errorText = `Error registering commands \n ${response.url}: ${response.status} ${response.statusText}`; + + try { + const error = await response.text(); + + if (error) errorText = `${errorText} \n\n ${error}`; + } catch (error) { + console.error("Error reading body from request:", error); + } + + console.error(errorText); +} 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; |