summaryrefslogtreecommitdiff
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/interactions/discord/commands.ts125
-rw-r--r--packages/interactions/discord/embeds.ts181
-rw-r--r--packages/interactions/discord/interfaces.ts86
-rw-r--r--packages/interactions/discord/responses.ts15
-rw-r--r--packages/interactions/discord/types.ts1
-rw-r--r--packages/interactions/discord/verification.ts24
-rw-r--r--packages/interactions/reddit.ts208
-rw-r--r--packages/interactions/register.ts70
-rw-r--r--packages/interactions/server.ts741
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(/&lt;/g, "<")
+ .replace(/&gt;/g, ">")
+ .replace(/&quot;/g, '"')
+ .replace(/&#x27;/g, "'")
+ .replace(/&#x2F;/g, "/")
+ .replace(/&#x60;/g, "`")
+ .replace(/&#x3D;/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;