summaryrefslogtreecommitdiff
path: root/packages/gateway/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/gateway/src')
-rw-r--r--packages/gateway/src/commands/index.ts8
-rw-r--r--packages/gateway/src/commands/say.ts127
-rw-r--r--packages/gateway/src/commands/start.ts83
-rw-r--r--packages/gateway/src/commands/utilities.ts615
-rw-r--r--packages/gateway/src/constants.ts1
-rw-r--r--packages/gateway/src/index.ts21
-rw-r--r--packages/gateway/src/listeners/announcementReaction.ts17
-rw-r--r--packages/gateway/src/listeners/channelDeletion.ts111
-rw-r--r--packages/gateway/src/listeners/clientReady.ts118
-rw-r--r--packages/gateway/src/listeners/constants.ts1
-rw-r--r--packages/gateway/src/listeners/index.ts20
-rw-r--r--packages/gateway/src/listeners/iqdbModeration.ts210
-rw-r--r--packages/gateway/src/listeners/messageDeletion.ts55
-rw-r--r--packages/gateway/src/listeners/messageEdit.ts86
-rw-r--r--packages/gateway/src/listeners/roleProtection.ts119
-rw-r--r--packages/gateway/src/listeners/roleplayUmagram.ts47
16 files changed, 1639 insertions, 0 deletions
diff --git a/packages/gateway/src/commands/index.ts b/packages/gateway/src/commands/index.ts
new file mode 100644
index 0000000..c94db00
--- /dev/null
+++ b/packages/gateway/src/commands/index.ts
@@ -0,0 +1,8 @@
+import { Client } from "discord.js";
+import { handleSayCommand } from "./say";
+import { handleStartCommand } from "./start";
+
+export const handleCommands = (client: Client) => {
+ handleSayCommand(client);
+ handleStartCommand(client);
+};
diff --git a/packages/gateway/src/commands/say.ts b/packages/gateway/src/commands/say.ts
new file mode 100644
index 0000000..4696574
--- /dev/null
+++ b/packages/gateway/src/commands/say.ts
@@ -0,0 +1,127 @@
+import { Client, Events, Message } from "discord.js";
+import { GUILD_ID } from "../constants";
+
+export const handleSayCommand = (client: Client) => {
+ client.on(Events.MessageCreate, async (message: Message) => {
+ if (message.author.bot) return;
+
+ if (message.content.toLowerCase().startsWith("uma!say")) {
+ const application = await client.application?.fetch();
+ const ownerId = application?.owner?.id;
+
+ if (message.author.id !== ownerId) return;
+
+ const parameters = message.content.split(" ").slice(1);
+
+ if (parameters.length < 2) {
+ await message.reply(
+ "❌ Usage: `uma!say <channel_mention_or_message_id> <message>`\nExamples:\n- `uma!say #general Hello everyone!`\n- `uma!say 1234567890123456789 Thanks for the info!`",
+ );
+
+ return;
+ }
+
+ const firstParameter = parameters[0];
+ const messageContent = parameters.slice(1).join(" ");
+ let targetChannel: any;
+ let targetMessage: any = null;
+ const messageIdMatch = firstParameter.match(/^\d{17,19}$/);
+
+ if (messageIdMatch) {
+ try {
+ const guild = client.guilds.cache.get(GUILD_ID);
+
+ if (!guild) {
+ await message.reply("❌ Guild not found.");
+
+ return;
+ }
+
+ let foundMessage = null;
+
+ for (const channel of guild.channels.cache.values()) {
+ if (channel.isTextBased()) {
+ try {
+ foundMessage = await channel.messages.fetch(firstParameter);
+
+ if (foundMessage) {
+ targetChannel = channel;
+ targetMessage = foundMessage;
+
+ break;
+ }
+ } catch {
+ continue;
+ }
+ }
+ }
+
+ if (!foundMessage) {
+ await message.reply("❌ Message not found.");
+
+ return;
+ }
+ } catch {
+ await message.reply("❌ Error finding message.");
+
+ return;
+ }
+ } else {
+ const channelMatch = firstParameter.match(/<#(\d+)>/);
+
+ if (!channelMatch) {
+ await message.reply(
+ "❌ Please mention a channel or provide a message ID. Example: `#general` or `1234567890123456789`",
+ );
+
+ return;
+ }
+
+ const channelId = channelMatch[1];
+
+ targetChannel = client.channels.cache.get(channelId);
+
+ if (!targetChannel || !targetChannel.isTextBased()) {
+ await message.reply("❌ Channel not found or is not a text channel.");
+
+ return;
+ }
+ }
+
+ try {
+ await message.delete();
+
+ const baseDuration = Math.max(1, messageContent.length / 20);
+ const complexityMultiplier =
+ (messageContent.match(/[.!?]/g) || []).length * 0.5;
+ const wordCount = messageContent.split(" ").length;
+ const wordComplexityMultiplier = Math.min(wordCount / 10, 2);
+ const typingDuration = Math.min(
+ baseDuration + complexityMultiplier + wordComplexityMultiplier,
+ 8,
+ );
+
+ await (targetChannel as any).sendTyping();
+ await new Promise((resolve) =>
+ setTimeout(resolve, typingDuration * 1000),
+ );
+
+ if (targetMessage) {
+ await targetMessage.reply(messageContent);
+ } else {
+ await (targetChannel as any).send(messageContent);
+ }
+ } catch (error) {
+ console.error("Error executing say command:", error);
+
+ try {
+ await message.reply(
+ "❌ Failed to execute the say command. Please check permissions.",
+ );
+ } catch (replyError) {
+ console.error("Failed to send error reply:", replyError);
+ }
+ }
+ }
+ });
+};
diff --git a/packages/gateway/src/commands/start.ts b/packages/gateway/src/commands/start.ts
new file mode 100644
index 0000000..4a771ed
--- /dev/null
+++ b/packages/gateway/src/commands/start.ts
@@ -0,0 +1,83 @@
+import { Client, Events, Message } from "discord.js";
+import { sendProgressUpdate, executeBulkRoleAssignment } from "./utilities";
+
+export const handleStartCommand = (client: Client) => {
+ client.on(Events.MessageCreate, async (message: Message) => {
+ if (message.author.bot) return;
+
+ if (message.content.toLowerCase().startsWith("uma!start")) {
+ const application = await client.application?.fetch();
+ const ownerId = application?.owner?.id;
+
+ if (message.author.id !== ownerId) return;
+
+ const parameters = message.content.split(" ").slice(1);
+
+ if (parameters.length < 3) {
+ await message.reply(
+ "❌ Usage: `uma!start <role_mention> <channel_mention_or_category_id> <update_channel_id> [action]`\nExample: `uma!start @Participant #general 1415599617214513254 execute`",
+ );
+
+ return;
+ }
+
+ const roleMention = parameters[0];
+ const channelOrCategory = parameters[1];
+ const updateChannelId = parameters[2];
+ const action = parameters[3] || "execute";
+ const roleMatch = roleMention.match(/<@&(\d+)>/);
+
+ if (!roleMatch) {
+ await message.reply(
+ "❌ Please mention a role. Example: `@Participant`",
+ );
+
+ return;
+ }
+
+ const roleId = roleMatch[1];
+ let channelId: string | undefined;
+ let categoryId: string | undefined;
+ const channelMatch = channelOrCategory.match(/<#(\d+)>/);
+
+ if (channelMatch) {
+ channelId = channelMatch[1];
+ } else {
+ categoryId = channelOrCategory;
+ }
+
+ if (action !== "preview" && action !== "execute") {
+ await message.reply("❌ Action must be either `preview` or `execute`");
+
+ return;
+ }
+
+ if (action === "preview") {
+ await message.reply(
+ "📋 Preview mode - this would check the specified channel(s) for users who have sent messages.",
+ );
+
+ return;
+ }
+
+ await message.reply(
+ "🚀 Bulk role operation started! Check the progress channel for updates.",
+ );
+
+ executeBulkRoleAssignment(
+ client,
+ roleId,
+ updateChannelId,
+ channelId,
+ categoryId,
+ ).catch((error) => {
+ console.error("Bulk role assignment failed:", error);
+ sendProgressUpdate(
+ client,
+ "❌ Bulk role assignment failed due to an error",
+ updateChannelId,
+ );
+ });
+ }
+ });
+};
diff --git a/packages/gateway/src/commands/utilities.ts b/packages/gateway/src/commands/utilities.ts
new file mode 100644
index 0000000..38d7bc2
--- /dev/null
+++ b/packages/gateway/src/commands/utilities.ts
@@ -0,0 +1,615 @@
+import { Client, ChannelType } from "discord.js";
+import { GUILD_ID } from "../constants";
+
+export const AUDIT_LOG_GUILD_ID = "1419211292396224575";
+export const AUDIT_LOG_CHANNEL_ID = "1419211778793144411";
+
+export const sendProgressUpdate = async (
+ client: Client,
+ message: string,
+ channelId: string,
+): Promise<void> => {
+ try {
+ const channel = client.channels.cache.get(channelId);
+
+ if (channel && "send" in channel) await (channel as any).send(message);
+ } catch (error) {
+ console.error("Failed to send progress update:", error);
+ }
+};
+
+export const sendAuditLog = async (
+ client: Client,
+ embed: any,
+ additionalContent?: string,
+ customChannelId?: string,
+): Promise<void> => {
+ try {
+ const channelId = customChannelId || AUDIT_LOG_CHANNEL_ID;
+ const channel = client.channels.cache.get(channelId);
+
+ if (channel && "send" in channel) {
+ await (channel as any).send({ embeds: [embed] });
+
+ if (additionalContent) {
+ const maxLength = 1900;
+ const codeBlockStart = "```\n";
+ const codeBlockEnd = "\n```";
+ const availableLength =
+ maxLength - codeBlockStart.length - codeBlockEnd.length;
+
+ if (additionalContent.length <= availableLength) {
+ await (channel as any).send(
+ `${codeBlockStart}${additionalContent}${codeBlockEnd}`,
+ );
+ } else {
+ const chunks = [];
+ let remaining = additionalContent;
+
+ while (remaining.length > 0) {
+ if (remaining.length <= availableLength) {
+ chunks.push(remaining);
+
+ break;
+ }
+
+ let breakPoint = availableLength;
+ const lastNewline = remaining.lastIndexOf("\n", availableLength);
+ const lastSpace = remaining.lastIndexOf(" ", availableLength);
+
+ if (lastNewline > availableLength * 0.8) {
+ breakPoint = lastNewline;
+ } else if (lastSpace > availableLength * 0.8) {
+ breakPoint = lastSpace;
+ }
+
+ chunks.push(remaining.substring(0, breakPoint));
+
+ remaining = remaining.substring(breakPoint).trim();
+ }
+
+ for (let i = 0; i < chunks.length; i++) {
+ const chunk = chunks[i];
+ const header =
+ chunks.length > 1 ? `Part ${i + 1}/${chunks.length}:\n` : "";
+
+ await (channel as any).send(
+ `${codeBlockStart}${header}${chunk}${codeBlockEnd}`,
+ );
+ }
+ }
+ }
+ }
+ } catch (error) {
+ console.error("Failed to send audit log:", error);
+ }
+};
+
+export const getChannelName = (channel: any): string => {
+ return channel.name || channel.id || "Unknown Channel";
+};
+
+export const fetchMessagesFromChannel = async (
+ client: Client,
+ channel: any,
+ userIds: Set<string>,
+ updateChannelId: string,
+): Promise<{ messageCount: number; batchCount: number }> => {
+ let lastMessageId: string | undefined;
+ let hasMoreMessages = true;
+ let messageCount = 0;
+ let batchCount = 0;
+
+ while (hasMoreMessages) {
+ try {
+ const messages = await channel.messages.fetch({
+ limit: 100,
+ before: lastMessageId,
+ });
+
+ if (messages.size === 0) {
+ hasMoreMessages = false;
+
+ break;
+ }
+
+ for (const message of messages.values())
+ if (message.author && message.author.id) userIds.add(message.author.id);
+
+ messageCount += messages.size;
+ batchCount += 1;
+
+ if (batchCount % 10 === 0)
+ await sendProgressUpdate(
+ client,
+ `📊 Progress for ${getChannelName(channel)}: ${messageCount} messages processed, ${userIds.size} unique users found`,
+ updateChannelId,
+ );
+
+ lastMessageId = messages.last()?.id;
+
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ } catch (error) {
+ console.error(
+ `Error fetching messages from channel ${channel.id}:`,
+ error,
+ );
+
+ hasMoreMessages = false;
+ }
+ }
+
+ return { messageCount, batchCount };
+};
+
+export const fetchForumPosts = async (
+ client: Client,
+ forumChannel: any,
+ userIds: Set<string>,
+ updateChannelId: string,
+): Promise<{ messageCount: number; batchCount: number }> => {
+ let totalMessageCount = 0;
+ let totalBatchCount = 0;
+
+ try {
+ const threads = await forumChannel.threads.fetchActive();
+ const archivedThreads = await forumChannel.threads.fetchArchived();
+
+ const allThreads = [
+ ...threads.threads.values(),
+ ...archivedThreads.threads.values(),
+ ];
+
+ await sendProgressUpdate(
+ client,
+ `🔍 Found ${allThreads.length} forum posts in ${getChannelName(forumChannel)}`,
+ updateChannelId,
+ );
+
+ for (const thread of allThreads) {
+ try {
+ const { messageCount, batchCount } = await fetchMessagesFromChannel(
+ client,
+ thread,
+ userIds,
+ updateChannelId,
+ );
+
+ totalMessageCount += messageCount;
+ totalBatchCount += batchCount;
+
+ await sendProgressUpdate(
+ client,
+ `📝 Processed forum post "${getChannelName(thread)}": ${messageCount} messages`,
+ updateChannelId,
+ );
+ } catch (error) {
+ console.error(`Error processing forum post ${thread.id}:`, error);
+ }
+ }
+ } catch (error) {
+ console.error(`Error fetching forum posts from ${forumChannel.id}:`, error);
+ }
+
+ return { messageCount: totalMessageCount, batchCount: totalBatchCount };
+};
+
+export const fetchChannelThreads = async (
+ client: Client,
+ textChannel: any,
+ userIds: Set<string>,
+ updateChannelId: string,
+): Promise<{ messageCount: number; batchCount: number }> => {
+ let totalMessageCount = 0;
+ let totalBatchCount = 0;
+
+ try {
+ const threads = await textChannel.threads.fetchActive();
+ const archivedThreads = await textChannel.threads.fetchArchived();
+ const allThreads = [
+ ...threads.threads.values(),
+ ...archivedThreads.threads.values(),
+ ];
+
+ if (allThreads.length > 0) {
+ await sendProgressUpdate(
+ client,
+ `🔍 Found ${allThreads.length} threads in ${getChannelName(textChannel)}`,
+ updateChannelId,
+ );
+
+ for (const thread of allThreads) {
+ try {
+ const { messageCount, batchCount } = await fetchMessagesFromChannel(
+ client,
+ thread,
+ userIds,
+ updateChannelId,
+ );
+
+ totalMessageCount += messageCount;
+ totalBatchCount += batchCount;
+
+ await sendProgressUpdate(
+ client,
+ `🧵 Processed thread "${getChannelName(thread)}": ${messageCount} messages`,
+ updateChannelId,
+ );
+ } catch (error) {
+ console.error(`Error processing thread ${thread.id}:`, error);
+ }
+ }
+ }
+ } catch (error) {
+ console.error(`Error fetching threads from ${textChannel.id}:`, error);
+ }
+
+ return { messageCount: totalMessageCount, batchCount: totalBatchCount };
+};
+
+export const fetchMessageAuthors = async (
+ client: Client,
+ channelId: string,
+ channelName: string | undefined,
+ updateChannelId: string,
+): Promise<Set<string>> => {
+ const userIds = new Set<string>();
+ let totalMessageCount = 0;
+
+ await sendProgressUpdate(
+ client,
+ `🔍 Starting comprehensive analysis of ${channelName || `channel ${channelId}`} ...`,
+ updateChannelId,
+ );
+
+ const channel = client.channels.cache.get(channelId);
+
+ if (!channel) {
+ await sendProgressUpdate(
+ client,
+ `❌ Channel ${channelId} not found`,
+ updateChannelId,
+ );
+
+ return userIds;
+ }
+
+ const channelDisplayName = channelName || getChannelName(channel);
+
+ switch (channel.type) {
+ case ChannelType.GuildText: {
+ await sendProgressUpdate(
+ client,
+ `📝 Processing text channel: ${channelDisplayName}`,
+ updateChannelId,
+ );
+
+ const { messageCount: channelMessages } = await fetchMessagesFromChannel(
+ client,
+ channel,
+ userIds,
+ updateChannelId,
+ );
+
+ totalMessageCount += channelMessages;
+
+ const { messageCount: threadMessages } = await fetchChannelThreads(
+ client,
+ channel,
+ userIds,
+ updateChannelId,
+ );
+
+ totalMessageCount += threadMessages;
+
+ break;
+ }
+
+ case ChannelType.GuildForum: {
+ await sendProgressUpdate(
+ client,
+ `📋 Processing forum channel: ${channelDisplayName}`,
+ updateChannelId,
+ );
+
+ const { messageCount: forumMessages } = await fetchForumPosts(
+ client,
+ channel,
+ userIds,
+ updateChannelId,
+ );
+
+ totalMessageCount += forumMessages;
+
+ break;
+ }
+
+ case ChannelType.PublicThread:
+ case ChannelType.PrivateThread:
+ case ChannelType.AnnouncementThread: {
+ await sendProgressUpdate(
+ client,
+ `🧵 Processing thread: ${channelDisplayName}`,
+ updateChannelId,
+ );
+
+ const { messageCount: threadMessageCount } =
+ await fetchMessagesFromChannel(
+ client,
+ channel,
+ userIds,
+ updateChannelId,
+ );
+
+ totalMessageCount += threadMessageCount;
+
+ break;
+ }
+
+ case ChannelType.GuildAnnouncement: {
+ await sendProgressUpdate(
+ client,
+ `📢 Processing announcement channel: ${channelDisplayName}`,
+ updateChannelId,
+ );
+
+ const { messageCount: announcementMessages } =
+ await fetchMessagesFromChannel(
+ client,
+ channel,
+ userIds,
+ updateChannelId,
+ );
+
+ totalMessageCount += announcementMessages;
+
+ const { messageCount: announcementThreadMessages } =
+ await fetchChannelThreads(client, channel, userIds, updateChannelId);
+
+ totalMessageCount += announcementThreadMessages;
+
+ break;
+ }
+
+ case ChannelType.GuildVoice:
+ case ChannelType.GuildStageVoice: {
+ await sendProgressUpdate(
+ client,
+ `🔇 Voice channel ${channelDisplayName} has no text messages`,
+ updateChannelId,
+ );
+
+ break;
+ }
+
+ default:
+ await sendProgressUpdate(
+ client,
+ `❓ Unknown channel type for ${channelDisplayName}, attempting to fetch messages ...`,
+ updateChannelId,
+ );
+
+ if (channel.isTextBased()) {
+ const { messageCount: unknownMessages } =
+ await fetchMessagesFromChannel(
+ client,
+ channel,
+ userIds,
+ updateChannelId,
+ );
+
+ totalMessageCount += unknownMessages;
+ }
+
+ break;
+ }
+
+ await sendProgressUpdate(
+ client,
+ `✅ Completed comprehensive analysis of ${channelDisplayName}: ${totalMessageCount} messages processed, ${userIds.size} unique users found`,
+ updateChannelId,
+ );
+
+ return userIds;
+};
+
+export const getAllChannelsInCategory = async (
+ client: Client,
+ categoryId: string,
+): Promise<{ channels: string[]; channelNames: { [key: string]: string } }> => {
+ const guild = client.guilds.cache.get(GUILD_ID);
+
+ if (!guild) return { channels: [], channelNames: {} };
+
+ const channels: string[] = [];
+ const channelNames: { [key: string]: string } = {};
+
+ const categoryChannels = guild.channels.cache.filter(
+ (channel) => channel.parentId === categoryId,
+ );
+
+ for (const channel of categoryChannels.values()) {
+ channels.push(channel.id);
+
+ channelNames[channel.id] = `#${getChannelName(channel)}`;
+
+ if (
+ channel.type === ChannelType.GuildText ||
+ channel.type === ChannelType.GuildAnnouncement
+ ) {
+ try {
+ const threads = await channel.threads.fetchActive();
+ const archivedThreads = await channel.threads.fetchArchived();
+ const allThreads = [
+ ...threads.threads.values(),
+ ...archivedThreads.threads.values(),
+ ];
+
+ for (const thread of allThreads) {
+ channels.push(thread.id);
+
+ channelNames[thread.id] = `🧵 ${getChannelName(thread)}`;
+ }
+ } catch (error) {
+ console.error(
+ `Error fetching threads for channel ${channel.id}:`,
+ error,
+ );
+ }
+ }
+
+ if (channel.type === ChannelType.GuildForum) {
+ try {
+ const threads = await channel.threads.fetchActive();
+ const archivedThreads = await channel.threads.fetchArchived();
+ const allThreads = [
+ ...threads.threads.values(),
+ ...archivedThreads.threads.values(),
+ ];
+
+ for (const thread of allThreads) {
+ channels.push(thread.id);
+
+ channelNames[thread.id] = `📝 ${getChannelName(thread)}`;
+ }
+ } catch (error) {
+ console.error(
+ `Error fetching forum posts for channel ${channel.id}:`,
+ error,
+ );
+ }
+ }
+ }
+
+ return { channels, channelNames };
+};
+
+export const executeBulkRoleAssignment = async (
+ client: Client,
+ roleId: string,
+ customUpdateChannelId: string,
+ channelId?: string,
+ categoryId?: string,
+): Promise<void> => {
+ try {
+ const guild = client.guilds.cache.get(GUILD_ID);
+
+ if (!guild) {
+ await sendProgressUpdate(
+ client,
+ "❌ Guild not found",
+ customUpdateChannelId,
+ );
+
+ return;
+ }
+
+ const role = guild.roles.cache.get(roleId);
+
+ if (!role) {
+ await sendProgressUpdate(
+ client,
+ "❌ Role not found",
+ customUpdateChannelId,
+ );
+
+ return;
+ }
+
+ let channelsToCheck: string[] = [];
+ let channelNames: { [key: string]: string } = {};
+
+ if (channelId) {
+ channelsToCheck = [channelId];
+
+ const channel = guild.channels.cache.get(channelId);
+
+ if (channel) channelNames[channelId] = `#${getChannelName(channel)}`;
+ } else if (categoryId) {
+ const result = await getAllChannelsInCategory(client, categoryId);
+
+ channelsToCheck = result.channels;
+ channelNames = result.channelNames;
+ }
+
+ await sendProgressUpdate(
+ client,
+ `🚀 Starting comprehensive bulk role operation: ${channelsToCheck.length} channel(s)/thread(s) to process`,
+ customUpdateChannelId,
+ );
+
+ const userIds = new Set<string>();
+
+ for (const channelId of channelsToCheck) {
+ const channelUserIds = await fetchMessageAuthors(
+ client,
+ channelId,
+ channelNames[channelId],
+ customUpdateChannelId,
+ );
+
+ channelUserIds.forEach((userId) => userIds.add(userId));
+ }
+
+ const uniqueUserIds = Array.from(userIds);
+
+ await sendProgressUpdate(
+ client,
+ `🎯 Starting role assignment phase: ${uniqueUserIds.length} users to process\n📝 Note: Some users may have left the guild since sending messages`,
+ customUpdateChannelId,
+ );
+
+ let successCount = 0;
+ let errorCount = 0;
+ let notInGuildCount = 0;
+
+ for (let i = 0; i < uniqueUserIds.length; i++) {
+ const userId = uniqueUserIds[i];
+
+ try {
+ const member = await guild.members.fetch(userId);
+ const currentRoles = member.roles.cache.map((role) => role.id);
+
+ if (!currentRoles.includes(roleId)) {
+ await member.roles.add(role);
+
+ successCount += 1;
+ } else {
+ successCount += 1;
+ }
+
+ if ((i + 1) % 25 === 0)
+ await sendProgressUpdate(
+ client,
+ `📈 Role assignment progress: ${i + 1}/${uniqueUserIds.length} users processed (${successCount} successful, ${errorCount} errors, ${notInGuildCount} not in guild)`,
+ customUpdateChannelId,
+ );
+
+ await new Promise((resolve) => setTimeout(resolve, 200));
+ } catch (error: any) {
+ console.error(`Error processing user ${userId}:`, error);
+
+ if (error.code === 10007 || error.message?.includes("Unknown Member")) {
+ notInGuildCount += 1;
+
+ console.log(`User ${userId} is no longer in the guild, skipping ...`);
+ } else {
+ errorCount += 1;
+ }
+ }
+ }
+
+ await sendProgressUpdate(
+ client,
+ `🏁 Bulk role operation completed!\n\n📊 **Final Results:**\n- ✅ **${successCount}** users processed successfully\n- ❌ **${errorCount}** errors occurred\n- 🚪 **${notInGuildCount}** users no longer in guild\n\n💡 **Note:** The "successful" count includes users who already had the role. Users who left the guild are automatically skipped.`,
+ customUpdateChannelId,
+ );
+ } catch (error) {
+ console.error("Error in bulk role assignment:", error);
+ await sendProgressUpdate(
+ client,
+ "❌ An error occurred during bulk role assignment",
+ customUpdateChannelId,
+ );
+ }
+};
diff --git a/packages/gateway/src/constants.ts b/packages/gateway/src/constants.ts
new file mode 100644
index 0000000..77ef6cd
--- /dev/null
+++ b/packages/gateway/src/constants.ts
@@ -0,0 +1 @@
+export const GUILD_ID = "1406422617724026901";
diff --git a/packages/gateway/src/index.ts b/packages/gateway/src/index.ts
new file mode 100644
index 0000000..1e1184a
--- /dev/null
+++ b/packages/gateway/src/index.ts
@@ -0,0 +1,21 @@
+import { GatewayIntentBits, Client } from "discord.js";
+import dotenv from "dotenv";
+import { handleCommands } from "./commands";
+import { handleListeners } from "./listeners";
+
+dotenv.config({ path: "../../.dev.vars" });
+console.log("Discord Token loaded:", process.env.DISCORD_TOKEN ? "Yes" : "No");
+console.log("Token length:", process.env.DISCORD_TOKEN?.length || 0);
+
+const client = new Client({
+ intents: [
+ GatewayIntentBits.Guilds,
+ GatewayIntentBits.GuildMembers,
+ GatewayIntentBits.GuildMessages,
+ GatewayIntentBits.MessageContent,
+ ],
+});
+
+handleCommands(client);
+handleListeners(client);
+client.login(process.env.DISCORD_TOKEN);
diff --git a/packages/gateway/src/listeners/announcementReaction.ts b/packages/gateway/src/listeners/announcementReaction.ts
new file mode 100644
index 0000000..eba8f25
--- /dev/null
+++ b/packages/gateway/src/listeners/announcementReaction.ts
@@ -0,0 +1,17 @@
+import { Client, Events, Message } from "discord.js";
+
+const ANNOUNCEMENT_CHANNEL_ID = "1406591215608270981";
+
+export const handleAnnouncementReaction = (client: Client) => {
+ client.on(Events.MessageCreate, async (message: Message) => {
+ if (message.channelId !== ANNOUNCEMENT_CHANNEL_ID) return;
+
+ if (message.author.id === client.user?.id) return;
+
+ try {
+ await message.react("1406426721158303864");
+ } catch (error) {
+ console.error("Failed to add okbuddy reaction to announcement:", error);
+ }
+ });
+};
diff --git a/packages/gateway/src/listeners/channelDeletion.ts b/packages/gateway/src/listeners/channelDeletion.ts
new file mode 100644
index 0000000..36fef79
--- /dev/null
+++ b/packages/gateway/src/listeners/channelDeletion.ts
@@ -0,0 +1,111 @@
+import { Client, Events } from "discord.js";
+import { GUILD_ID } from "../constants";
+
+const channelDeletionTracker = new Map<
+ string,
+ { count: number; firstDeletion: number }
+>();
+
+export const handleChannelDeletion = (client: Client) => {
+ client.on(Events.ChannelDelete, async (deletedChannel) => {
+ if (
+ !("guildId" in deletedChannel) ||
+ !deletedChannel.guildId ||
+ deletedChannel.guildId !== GUILD_ID
+ )
+ return;
+
+ try {
+ const guild = client.guilds.cache.get(GUILD_ID);
+
+ if (!guild) return;
+
+ const guildOwner = await guild.fetchOwner();
+ const auditLogs = await guild.fetchAuditLogs({
+ type: 12,
+ limit: 5,
+ });
+ const channelDeletionLog = auditLogs.entries.find(
+ (entry) => entry.target?.id === deletedChannel.id,
+ );
+
+ if (!channelDeletionLog || !channelDeletionLog.executor) {
+ console.log("Could not determine who deleted channel, skipping...");
+
+ return;
+ }
+
+ const executor = channelDeletionLog.executor;
+
+ if (executor.id === guildOwner.id) {
+ console.log(`Channel deleted by server owner, allowing...`);
+
+ return;
+ }
+
+ const now = Date.now();
+ const thirtySeconds = 30 * 1000;
+ let userData = channelDeletionTracker.get(executor.id);
+
+ if (!userData) {
+ userData = { count: 1, firstDeletion: now };
+
+ channelDeletionTracker.set(executor.id, userData);
+ console.log(
+ `User ${executor.tag} (${executor.id}) deleted first channel`,
+ );
+ } else {
+ if (now - userData.firstDeletion <= thirtySeconds) {
+ userData.count += 1;
+
+ console.log(
+ `User ${executor.tag} (${executor.id}) deleted channel ${userData.count}/2 within 30 seconds`,
+ );
+
+ if (userData.count > 2) {
+ console.log(
+ `User ${executor.tag} (${executor.id}) exceeded channel deletion limit, resetting roles...`,
+ );
+
+ try {
+ const member = await guild.members.fetch(executor.id);
+
+ if (member) {
+ const rolesToRemove = member.roles.cache.filter(
+ (role) => role.id !== guild.id,
+ );
+
+ if (rolesToRemove.size > 0) {
+ await member.roles.set([]);
+ console.log(
+ `Reset ${rolesToRemove.size} roles for user ${executor.tag}`,
+ );
+ }
+ }
+ } catch (error) {
+ console.error(
+ `Failed to reset roles for user ${executor.tag}:`,
+ error,
+ );
+ }
+
+ channelDeletionTracker.delete(executor.id);
+ }
+ } else {
+ userData.count = 1;
+ userData.firstDeletion = now;
+
+ console.log(
+ `User ${executor.tag} (${executor.id}) deleted channel, resetting counter (outside 30s window)`,
+ );
+ }
+ }
+
+ for (const [userId, data] of channelDeletionTracker.entries())
+ if (now - data.firstDeletion > thirtySeconds)
+ channelDeletionTracker.delete(userId);
+ } catch (error) {
+ console.error("Error in channel deletion monitoring:", error);
+ }
+ });
+};
diff --git a/packages/gateway/src/listeners/clientReady.ts b/packages/gateway/src/listeners/clientReady.ts
new file mode 100644
index 0000000..2933916
--- /dev/null
+++ b/packages/gateway/src/listeners/clientReady.ts
@@ -0,0 +1,118 @@
+import { Client, Events } from "discord.js";
+import { ROLEPLAY_UMAGRAM_CHANNEL_ID } from "./constants";
+
+export const handleClientReady = (client: Client) => {
+ client.once(Events.ClientReady, async (readyClient) => {
+ console.log(`Gateway client ready! Logged in as ${readyClient.user.tag}`);
+
+ await readyClient.user.setActivity("r/okbuddyumamusume", { type: 3 });
+
+ try {
+ const channel = client.channels.cache.get(ROLEPLAY_UMAGRAM_CHANNEL_ID);
+
+ if (channel && channel.isTextBased()) {
+ console.log(
+ "Adding hearts to existing messages in roleplay-umagram channel and threads...",
+ );
+
+ let totalMessageCount = 0;
+ let totalHeartCount = 0;
+ const processChannelMessages = async (
+ targetChannel: any,
+ channelName: string,
+ ) => {
+ let lastMessageId: string | undefined;
+ let messageCount = 0;
+ let heartCount = 0;
+
+ while (true) {
+ const messages = await targetChannel.messages.fetch({
+ limit: 100,
+ before: lastMessageId,
+ });
+
+ if (messages.size === 0) break;
+
+ for (const message of messages.values()) {
+ messageCount += 1;
+
+ const existingReaction = message.reactions.cache.get("❤️");
+
+ if (
+ existingReaction &&
+ existingReaction.users.cache.has(client.user!.id)
+ )
+ continue;
+
+ try {
+ await message.react("❤️");
+
+ heartCount += 1;
+
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ } catch (error) {
+ console.error(
+ `Failed to heart message ${message.id} in ${channelName}:`,
+ error,
+ );
+ }
+ }
+
+ lastMessageId = messages.last()?.id;
+
+ await new Promise((resolve) => setTimeout(resolve, 500));
+ }
+
+ console.log(
+ `${channelName}: Processed ${messageCount} messages, added ${heartCount} hearts`,
+ );
+
+ return { messageCount, heartCount };
+ };
+
+ const mainResult = await processChannelMessages(
+ channel,
+ "Main Channel",
+ );
+
+ totalMessageCount += mainResult.messageCount;
+ totalHeartCount += mainResult.heartCount;
+
+ try {
+ const activeThreads = await (channel as any).threads?.fetchActive();
+ const archivedThreads = await (
+ channel as any
+ ).threads?.fetchArchived();
+ const allThreads = [
+ ...activeThreads.threads.values(),
+ ...archivedThreads.threads.values(),
+ ];
+
+ console.log(`Found ${allThreads.length} threads to process`);
+
+ for (const thread of allThreads) {
+ try {
+ const threadResult = await processChannelMessages(
+ thread,
+ `Thread: ${thread.name}`,
+ );
+
+ totalMessageCount += threadResult.messageCount;
+ totalHeartCount += threadResult.heartCount;
+ } catch (error) {
+ console.error(`Error processing thread ${thread.name}:`, error);
+ }
+ }
+ } catch (error) {
+ console.error("Error fetching threads:", error);
+ }
+
+ console.log(
+ `Completed: Processed ${totalMessageCount} messages total, added ${totalHeartCount} hearts total`,
+ );
+ }
+ } catch (error) {
+ console.error("Error adding hearts to existing messages:", error);
+ }
+ });
+};
diff --git a/packages/gateway/src/listeners/constants.ts b/packages/gateway/src/listeners/constants.ts
new file mode 100644
index 0000000..32cdd69
--- /dev/null
+++ b/packages/gateway/src/listeners/constants.ts
@@ -0,0 +1 @@
+export const ROLEPLAY_UMAGRAM_CHANNEL_ID = "1419523288001937458";
diff --git a/packages/gateway/src/listeners/index.ts b/packages/gateway/src/listeners/index.ts
new file mode 100644
index 0000000..bd97bcd
--- /dev/null
+++ b/packages/gateway/src/listeners/index.ts
@@ -0,0 +1,20 @@
+import { Client } from "discord.js";
+import { handleIqdbModeration } from "./iqdbModeration";
+import { handleRoleplayUmagram } from "./roleplayUmagram";
+import { handleAnnouncementReaction } from "./announcementReaction";
+import { handleRoleProtection } from "./roleProtection";
+import { handleChannelDeletion } from "./channelDeletion";
+import { handleMessageDeletion } from "./messageDeletion";
+import { handleMessageEdit } from "./messageEdit";
+import { handleClientReady } from "./clientReady";
+
+export const handleListeners = (client: Client) => {
+ handleClientReady(client);
+ handleIqdbModeration(client);
+ handleRoleplayUmagram(client);
+ handleAnnouncementReaction(client);
+ handleRoleProtection(client);
+ handleChannelDeletion(client);
+ handleMessageDeletion(client);
+ handleMessageEdit(client);
+};
diff --git a/packages/gateway/src/listeners/iqdbModeration.ts b/packages/gateway/src/listeners/iqdbModeration.ts
new file mode 100644
index 0000000..c23ab8d
--- /dev/null
+++ b/packages/gateway/src/listeners/iqdbModeration.ts
@@ -0,0 +1,210 @@
+import { Client, Events, Message } from "discord.js";
+import { sendAuditLog } from "../commands/utilities";
+
+const IQDB_MODERATION_CHANNEL_IDS = [
+ "1410333697701314791",
+ "1420297845998620733",
+];
+
+export const handleIqdbModeration = (client: Client) => {
+ client.on(Events.MessageCreate, async (message: Message) => {
+ if (!IQDB_MODERATION_CHANNEL_IDS.includes(message.channelId)) return;
+
+ const imageAttachments = message.attachments.filter((attachment) =>
+ attachment.contentType?.startsWith("image/"),
+ );
+
+ if (imageAttachments.size === 0) return;
+
+ try {
+ console.log(
+ `Processing ${imageAttachments.size} image(s) for iqdb moderation...`,
+ );
+
+ for (const attachment of imageAttachments.values()) {
+ try {
+ const { searchPic } = await import("iqdb-client");
+ const result = await searchPic(attachment.url, { lib: "www" });
+ const matches =
+ result.data?.filter(
+ (item) => item.similarity !== null && item.similarity > 0.6,
+ ) || [];
+
+ if (matches.length === 0) {
+ console.log("No significant matches found in iqdb");
+
+ continue;
+ }
+
+ for (const match of matches) {
+ if (match.sourceUrl) {
+ try {
+ let booruType = "";
+ if (match.sourceUrl.includes("danbooru.donmai.us")) {
+ booruType = "danbooru";
+ } else if (match.sourceUrl.includes("yande.re")) {
+ booruType = "yande.re";
+ } else if (match.sourceUrl.includes("gelbooru.com")) {
+ booruType = "gelbooru";
+ } else if (match.sourceUrl.includes("konachan.com")) {
+ booruType = "konachan";
+ }
+
+ if (booruType) {
+ console.log(
+ `Found match on ${booruType}, checking for prohibited tags...`,
+ );
+
+ try {
+ const postId = match.sourceUrl.match(/\/(\d+)/)?.[1];
+
+ if (!postId) continue;
+
+ let tags: string[] = [];
+
+ if (booruType === "danbooru") {
+ const response = await fetch(
+ `https://danbooru.donmai.us/posts/${postId}.json`,
+ );
+
+ if (response.ok) {
+ const postData = await response.json();
+
+ tags = postData.tag_string?.split(" ") || [];
+ }
+ } else if (booruType === "yande.re") {
+ const response = await fetch(
+ `https://yande.re/post.json?tags=id:${postId}`,
+ );
+
+ if (response.ok) {
+ const postData = await response.json();
+
+ if (postData.length > 0)
+ tags = postData[0].tags?.split(" ") || [];
+ }
+ } else if (booruType === "gelbooru") {
+ const response = await fetch(
+ `https://gelbooru.com/index.php?page=dapi&s=post&q=index&json=1&id=${postId}`,
+ );
+
+ if (response.ok) {
+ const postData = await response.json();
+
+ if (postData.post)
+ tags = postData.post.tags?.split(" ") || [];
+ }
+ } else if (booruType === "konachan") {
+ const response = await fetch(
+ `https://konachan.com/post.json?tags=id:${postId}`,
+ );
+
+ if (response.ok) {
+ const postData = await response.json();
+
+ if (postData.length > 0)
+ tags = postData[0].tags?.split(" ") || [];
+ }
+ }
+
+ console.log(
+ `Retrieved ${tags.length} tags from ${booruType}:`,
+ tags.slice(0, 10),
+ );
+
+ const prohibitedTags = [
+ /^(loli|shota)$/i,
+ /^(loli|shota)_/i,
+ /_(loli|shota)$/i,
+ /_(loli|shota)_/i,
+ ];
+ const foundProhibitedTags = tags.filter((tag) =>
+ prohibitedTags.some((prohibited) => {
+ if (prohibited instanceof RegExp) {
+ return prohibited.test(tag);
+ } else {
+ return tag
+ .toLowerCase()
+ .includes((prohibited as string).toLowerCase());
+ }
+ }),
+ );
+
+ if (foundProhibitedTags.length > 0) {
+ console.log(
+ `Prohibited tags detected: ${foundProhibitedTags.join(", ")}, deleting message...`,
+ );
+
+ await message.delete();
+
+ const { EmbedBuilder } = await import("discord.js");
+ const embed = new EmbedBuilder()
+ .setTitle("🚫 Image Deleted - Prohibited Tags Detected")
+ .setColor("#ff0000")
+ .addFields(
+ {
+ name: "Channel",
+ value: `<#${message.channelId}>`,
+ inline: true,
+ },
+ {
+ name: "Author",
+ value: `<@${message.author.id}>`,
+ inline: true,
+ },
+ {
+ name: "Message ID",
+ value: `[${message.id}](https://discord.com/channels/${message.guildId}/${message.channelId}/${message.id})`,
+ inline: true,
+ },
+ {
+ name: "Booru Source",
+ value: `[${booruType}](${match.sourceUrl})`,
+ inline: true,
+ },
+ {
+ name: "Similarity",
+ value: `${(match.similarity! * 100).toFixed(1)}%`,
+ inline: true,
+ },
+ {
+ name: "Prohibited Tags",
+ value: foundProhibitedTags.join(", "),
+ inline: false,
+ },
+ )
+ .setTimestamp()
+ .setFooter({
+ text: `Guild: ${message.guild?.name || "Unknown"}`,
+ });
+
+ await sendAuditLog(
+ client,
+ embed,
+ undefined,
+ "1406422619934167106",
+ );
+
+ return;
+ }
+ } catch (error) {
+ console.error(
+ `Error fetching tags from ${booruType}:`,
+ error,
+ );
+ }
+ }
+ } catch (error) {
+ console.error("Error processing booru match:", error);
+ }
+ }
+ }
+ } catch (error) {
+ console.error("Error processing image attachment:", error);
+ }
+ }
+ } catch (error) {
+ console.error("Error in iqdb moderation:", error);
+ }
+ });
+};
diff --git a/packages/gateway/src/listeners/messageDeletion.ts b/packages/gateway/src/listeners/messageDeletion.ts
new file mode 100644
index 0000000..05431e7
--- /dev/null
+++ b/packages/gateway/src/listeners/messageDeletion.ts
@@ -0,0 +1,55 @@
+import { Client, Events, EmbedBuilder } from "discord.js";
+import { GUILD_ID } from "../constants";
+import { sendAuditLog } from "../commands/utilities";
+
+export const handleMessageDeletion = (client: Client) => {
+ client.on(Events.MessageDelete, async (deletedMessage) => {
+ if (deletedMessage.guildId !== GUILD_ID) return;
+
+ if (deletedMessage.author?.bot) return;
+
+ try {
+ const channel = deletedMessage.channel;
+ const author = deletedMessage.author;
+ const content = deletedMessage.content || "*No text content*";
+ const embed = new EmbedBuilder()
+ .setTitle("🗑️ Message Deleted")
+ .setColor("#ff4444")
+ .addFields(
+ {
+ name: "Channel",
+ value: channel.isTextBased() ? `<#${channel.id}>` : "Unknown",
+ inline: true,
+ },
+ {
+ name: "Author",
+ value: author ? `<@${author.id}>` : "Unknown",
+ inline: true,
+ },
+ {
+ name: "Message ID",
+ value: `[${deletedMessage.id}](https://discord.com/channels/${deletedMessage.guildId}/${channel.id}/${deletedMessage.id})`,
+ inline: true,
+ },
+ )
+ .setTimestamp()
+ .setFooter({
+ text: `Guild: ${deletedMessage.guild?.name || "Unknown"}`,
+ });
+
+ if (content.length <= 1024) {
+ embed.addFields({ name: "Content", value: content, inline: false });
+ await sendAuditLog(client, embed);
+ } else {
+ embed.addFields({
+ name: "Content",
+ value: "*Content too long, see message below*",
+ inline: false,
+ });
+ await sendAuditLog(client, embed, content);
+ }
+ } catch (error) {
+ console.error("Error logging message deletion:", error);
+ }
+ });
+};
diff --git a/packages/gateway/src/listeners/messageEdit.ts b/packages/gateway/src/listeners/messageEdit.ts
new file mode 100644
index 0000000..d1fd2a8
--- /dev/null
+++ b/packages/gateway/src/listeners/messageEdit.ts
@@ -0,0 +1,86 @@
+import { Client, Events, EmbedBuilder } from "discord.js";
+import { GUILD_ID } from "../constants";
+import { sendAuditLog } from "../commands/utilities";
+
+export const handleMessageEdit = (client: Client) => {
+ client.on(Events.MessageUpdate, async (oldMessage, newMessage) => {
+ if (newMessage.guildId !== GUILD_ID) return;
+
+ if (newMessage.author?.bot) return;
+
+ if (oldMessage.content === newMessage.content) return;
+
+ try {
+ const channel = newMessage.channel;
+ const author = newMessage.author;
+ const oldContent = oldMessage.content || "*No text content*";
+ const newContent = newMessage.content || "*No text content*";
+ const embed = new EmbedBuilder()
+ .setTitle("✏️ Message Edited")
+ .setColor("#ffaa00")
+ .addFields(
+ {
+ name: "Channel",
+ value: channel.isTextBased() ? `<#${channel.id}>` : "Unknown",
+ inline: true,
+ },
+ {
+ name: "Author",
+ value: author ? `<@${author.id}>` : "Unknown",
+ inline: true,
+ },
+ {
+ name: "Message ID",
+ value: `[${newMessage.id}](https://discord.com/channels/${newMessage.guildId}/${channel.id}/${newMessage.id})`,
+ inline: true,
+ },
+ )
+ .setTimestamp()
+ .setFooter({ text: `Guild: ${newMessage.guild?.name || "Unknown"}` });
+ const maxFieldLength = 1024;
+ const needsSeparateContent =
+ oldContent.length > maxFieldLength ||
+ newContent.length > maxFieldLength;
+
+ if (needsSeparateContent) {
+ embed.addFields(
+ {
+ name: "Before",
+ value:
+ oldContent.length > maxFieldLength
+ ? "*Content too long, see message below*"
+ : oldContent,
+ inline: false,
+ },
+ {
+ name: "After",
+ value:
+ newContent.length > maxFieldLength
+ ? "*Content too long, see message below*"
+ : newContent,
+ inline: false,
+ },
+ );
+
+ let additionalContent = "";
+
+ if (oldContent.length > maxFieldLength) {
+ additionalContent += `BEFORE:\n${oldContent}\n\n`;
+ }
+ if (newContent.length > maxFieldLength) {
+ additionalContent += `AFTER:\n${newContent}`;
+ }
+
+ await sendAuditLog(client, embed, additionalContent);
+ } else {
+ embed.addFields(
+ { name: "Before", value: oldContent, inline: false },
+ { name: "After", value: newContent, inline: false },
+ );
+ await sendAuditLog(client, embed);
+ }
+ } catch (error) {
+ console.error("Error logging message edit:", error);
+ }
+ });
+};
diff --git a/packages/gateway/src/listeners/roleProtection.ts b/packages/gateway/src/listeners/roleProtection.ts
new file mode 100644
index 0000000..ff6ee38
--- /dev/null
+++ b/packages/gateway/src/listeners/roleProtection.ts
@@ -0,0 +1,119 @@
+import { Client, Events } from "discord.js";
+import { GUILD_ID } from "../constants";
+
+const PROTECTED_ROLE_ID = "1406422617724026909";
+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
+];
+
+export const handleRoleProtection = (client: Client) => {
+ client.on(Events.GuildMemberUpdate, async (oldMember, newMember) => {
+ if (newMember.guild.id !== GUILD_ID) return;
+
+ const oldRoles = oldMember.roles.cache;
+ const newRoles = newMember.roles.cache;
+ const addedRoles = newRoles.filter((role) => !oldRoles.has(role.id));
+
+ if (addedRoles.size === 0) return;
+
+ try {
+ const protectedRole = newMember.guild.roles.cache.get(PROTECTED_ROLE_ID);
+
+ if (!protectedRole) {
+ console.error("Protected role not found");
+
+ return;
+ }
+
+ const guildOwner = await newMember.guild.fetchOwner();
+
+ for (const role of addedRoles.values()) {
+ if (role.id === PROTECTED_ROLE_ID) continue;
+
+ if (COLOR_ROLE_IDS.includes(role.id)) continue;
+
+ if (role.position > protectedRole.position) {
+ try {
+ const auditLogs = await newMember.guild.fetchAuditLogs({
+ type: 25, // MEMBER_ROLE_UPDATE
+ limit: 10,
+ });
+
+ const relevantLog = auditLogs.entries.find(
+ (entry) =>
+ entry.target?.id === newMember.id &&
+ entry.changes?.some(
+ (change) =>
+ change.key === "$add" &&
+ change.new?.some((r: any) => r.id === role.id),
+ ),
+ );
+
+ if (relevantLog && relevantLog.executor?.id === guildOwner.id) {
+ console.log(
+ `High role ${role.name} (${role.id}) added to ${newMember.user.tag} by server owner, allowing...`,
+ );
+
+ continue;
+ }
+
+ console.log(
+ `High role ${role.name} (${role.id}) added to ${newMember.user.tag} (${newMember.id}) by unauthorized user, removing...`,
+ );
+
+ try {
+ await newMember.roles.remove(role);
+ } catch (error) {
+ console.error(
+ `Failed to remove high role ${role.name} from ${newMember.user.tag}:`,
+ error,
+ );
+ }
+ } catch (auditError) {
+ console.error("Error checking audit log:", auditError);
+ console.log(
+ `High role ${role.name} (${role.id}) added to ${newMember.user.tag} (${newMember.id}), removing due to audit log error...`,
+ );
+
+ try {
+ await newMember.roles.remove(role);
+ } catch (error) {
+ console.error(
+ `Failed to remove high role ${role.name} from ${newMember.user.tag}:`,
+ error,
+ );
+ }
+ }
+ }
+ }
+ } catch (error) {
+ console.error("Error in role protection handler:", error);
+ }
+ });
+};
diff --git a/packages/gateway/src/listeners/roleplayUmagram.ts b/packages/gateway/src/listeners/roleplayUmagram.ts
new file mode 100644
index 0000000..6588a03
--- /dev/null
+++ b/packages/gateway/src/listeners/roleplayUmagram.ts
@@ -0,0 +1,47 @@
+import { Client, Events, Message } from "discord.js";
+import { ROLEPLAY_UMAGRAM_CHANNEL_ID } from "./constants";
+
+export const handleRoleplayUmagram = (client: Client) => {
+ client.on(Events.MessageCreate, async (message: Message) => {
+ const isMainChannel = message.channelId === ROLEPLAY_UMAGRAM_CHANNEL_ID;
+ const isThreadInChannel =
+ message.channel?.isThread() &&
+ message.channel.parentId === ROLEPLAY_UMAGRAM_CHANNEL_ID;
+
+ if (!isMainChannel && !isThreadInChannel) return;
+
+ try {
+ if (message.author.id === client.user?.id) return;
+
+ try {
+ await message.react("❤️");
+ } catch (error) {
+ console.error("Failed to add heart reaction:", error);
+ }
+
+ if (isMainChannel && !message.reference) {
+ const hasAttachments = message.attachments.size > 0;
+ const hasTextContent =
+ message.content && message.content.trim().split(/\s+/).length > 1;
+
+ if (!hasAttachments || !hasTextContent) {
+ await message.delete();
+
+ const errorMessage = await (message.channel as any).send(
+ `${message.author}, to participate in <#${ROLEPLAY_UMAGRAM_CHANNEL_ID}>, you can either:\n\n- **Post**: Send a message with both a brief caption **and** an image attachment\n- **Reply**: Reply to someone else's post (no image needed)\n - Reply with a thread (suggested)\n - Reply directly with a message\n\nThis message will be deleted in 30 seconds.`,
+ );
+
+ setTimeout(async () => {
+ try {
+ await errorMessage.delete();
+ } catch (error) {
+ console.error("Failed to delete error message:", error);
+ }
+ }, 30000);
+ }
+ }
+ } catch (error) {
+ console.error("Error in roleplay-umagram moderation:", error);
+ }
+ });
+};