diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/discord/commands.ts | 125 | ||||
| -rw-r--r-- | src/discord/embeds.ts | 181 | ||||
| -rw-r--r-- | src/discord/interfaces.ts | 86 | ||||
| -rw-r--r-- | src/discord/responses.ts | 15 | ||||
| -rw-r--r-- | src/discord/types.ts | 1 | ||||
| -rw-r--r-- | src/discord/verification.ts | 24 | ||||
| -rw-r--r-- | src/reddit.ts | 208 | ||||
| -rw-r--r-- | src/register.ts | 70 | ||||
| -rw-r--r-- | src/server.ts | 741 |
9 files changed, 0 insertions, 1451 deletions
diff --git a/src/discord/commands.ts b/src/discord/commands.ts deleted file mode 100644 index 601591b..0000000 --- a/src/discord/commands.ts +++ /dev/null @@ -1,125 +0,0 @@ -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/src/discord/embeds.ts b/src/discord/embeds.ts deleted file mode 100644 index 3f7c344..0000000 --- a/src/discord/embeds.ts +++ /dev/null @@ -1,181 +0,0 @@ -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/src/discord/interfaces.ts b/src/discord/interfaces.ts deleted file mode 100644 index bc8683c..0000000 --- a/src/discord/interfaces.ts +++ /dev/null @@ -1,86 +0,0 @@ -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/src/discord/responses.ts b/src/discord/responses.ts deleted file mode 100644 index 4dcc777..0000000 --- a/src/discord/responses.ts +++ /dev/null @@ -1,15 +0,0 @@ -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/src/discord/types.ts b/src/discord/types.ts deleted file mode 100644 index 4f6e85e..0000000 --- a/src/discord/types.ts +++ /dev/null @@ -1 +0,0 @@ -export type TimePeriod = "hour" | "day" | "week" | "month" | "year" | "all"; diff --git a/src/discord/verification.ts b/src/discord/verification.ts deleted file mode 100644 index 89d26db..0000000 --- a/src/discord/verification.ts +++ /dev/null @@ -1,24 +0,0 @@ -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/src/reddit.ts b/src/reddit.ts deleted file mode 100644 index 5b4ded7..0000000 --- a/src/reddit.ts +++ /dev/null @@ -1,208 +0,0 @@ -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/src/register.ts b/src/register.ts deleted file mode 100644 index a23c9c8..0000000 --- a/src/register.ts +++ /dev/null @@ -1,70 +0,0 @@ -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/src/server.ts b/src/server.ts deleted file mode 100644 index a2618fe..0000000 --- a/src/server.ts +++ /dev/null @@ -1,741 +0,0 @@ -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; |