diff --git a/backend/src/data/GuildRoleButtons.ts b/backend/src/data/GuildRoleButtons.ts new file mode 100644 index 00000000..106e0055 --- /dev/null +++ b/backend/src/data/GuildRoleButtons.ts @@ -0,0 +1,39 @@ +import { getRepository, Repository } from "typeorm"; +import { Reminder } from "./entities/Reminder"; +import { BaseRepository } from "./BaseRepository"; +import moment from "moment-timezone"; +import { DBDateFormat } from "../utils"; +import { BaseGuildRepository } from "./BaseGuildRepository"; +import { RoleQueueItem } from "./entities/RoleQueueItem"; +import { connection } from "./db"; +import { RoleButtonsItem } from "./entities/RoleButtonsItem"; + +export class GuildRoleButtons extends BaseGuildRepository { + private roleButtons: Repository; + + constructor(guildId) { + super(guildId); + this.roleButtons = getRepository(RoleButtonsItem); + } + + getSavedRoleButtons(): Promise { + return this.roleButtons.find({ guild_id: this.guildId }); + } + + async deleteRoleButtonItem(name: string): Promise { + await this.roleButtons.delete({ + guild_id: this.guildId, + name, + }); + } + + async saveRoleButtonItem(name: string, channelId: string, messageId: string, hash: string): Promise { + await this.roleButtons.insert({ + guild_id: this.guildId, + name, + channel_id: channelId, + message_id: messageId, + hash, + }); + } +} diff --git a/backend/src/data/GuildRoleQueue.ts b/backend/src/data/GuildRoleQueue.ts new file mode 100644 index 00000000..20d84012 --- /dev/null +++ b/backend/src/data/GuildRoleQueue.ts @@ -0,0 +1,48 @@ +import { getRepository, Repository } from "typeorm"; +import { Reminder } from "./entities/Reminder"; +import { BaseRepository } from "./BaseRepository"; +import moment from "moment-timezone"; +import { DBDateFormat } from "../utils"; +import { BaseGuildRepository } from "./BaseGuildRepository"; +import { RoleQueueItem } from "./entities/RoleQueueItem"; +import { connection } from "./db"; + +export class GuildRoleQueue extends BaseGuildRepository { + private roleQueue: Repository; + + constructor(guildId) { + super(guildId); + this.roleQueue = getRepository(RoleQueueItem); + } + + consumeNextRoleAssignments(count: number): Promise { + return connection.transaction(async (entityManager) => { + const repository = entityManager.getRepository(RoleQueueItem); + + const nextAssignments = await repository + .createQueryBuilder() + .where("guild_id = :guildId", { guildId: this.guildId }) + .addOrderBy("priority", "DESC") + .addOrderBy("id", "ASC") + .take(count) + .getMany(); + + if (nextAssignments.length > 0) { + const ids = nextAssignments.map((assignment) => assignment.id); + await repository.createQueryBuilder().where("id IN (:ids)", { ids }).delete().execute(); + } + + return nextAssignments; + }); + } + + async addQueueItem(userId: string, roleId: string, shouldAdd: boolean, priority = 0) { + await this.roleQueue.insert({ + guild_id: this.guildId, + user_id: userId, + role_id: roleId, + should_add: shouldAdd, + priority, + }); + } +} diff --git a/backend/src/data/entities/RoleButtonsItem.ts b/backend/src/data/entities/RoleButtonsItem.ts new file mode 100644 index 00000000..520ae6c9 --- /dev/null +++ b/backend/src/data/entities/RoleButtonsItem.ts @@ -0,0 +1,16 @@ +import { Column, Entity, PrimaryGeneratedColumn } from "typeorm"; + +@Entity("role_buttons") +export class RoleButtonsItem { + @PrimaryGeneratedColumn() id: number; + + @Column() guild_id: string; + + @Column() name: string; + + @Column() channel_id: string; + + @Column() message_id: string; + + @Column() hash: string; +} diff --git a/backend/src/data/entities/RoleQueueItem.ts b/backend/src/data/entities/RoleQueueItem.ts new file mode 100644 index 00000000..b17006be --- /dev/null +++ b/backend/src/data/entities/RoleQueueItem.ts @@ -0,0 +1,16 @@ +import { Column, Entity, PrimaryGeneratedColumn } from "typeorm"; + +@Entity("role_queue") +export class RoleQueueItem { + @PrimaryGeneratedColumn() id: number; + + @Column() guild_id: string; + + @Column() user_id: string; + + @Column() role_id: string; + + @Column() should_add: boolean; + + @Column() priority: number; +} diff --git a/backend/src/migrations/1650709103864-CreateRoleQueueTable.ts b/backend/src/migrations/1650709103864-CreateRoleQueueTable.ts new file mode 100644 index 00000000..c903c0df --- /dev/null +++ b/backend/src/migrations/1650709103864-CreateRoleQueueTable.ts @@ -0,0 +1,50 @@ +import { MigrationInterface, QueryRunner, Table } from "typeorm"; + +export class CreateRoleQueueTable1650709103864 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: "role_queue", + columns: [ + { + name: "id", + type: "int", + isPrimary: true, + isGenerated: true, + generationStrategy: "increment", + }, + { + name: "guild_id", + type: "bigint", + }, + { + name: "user_id", + type: "bigint", + }, + { + name: "role_id", + type: "bigint", + }, + { + name: "should_add", + type: "boolean", + }, + { + name: "priority", + type: "smallint", + default: 0, + }, + ], + indices: [ + { + columnNames: ["guild_id"], + }, + ], + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable("role_queue"); + } +} diff --git a/backend/src/migrations/1650712828384-CreateRoleButtonsTable.ts b/backend/src/migrations/1650712828384-CreateRoleButtonsTable.ts new file mode 100644 index 00000000..53fcaba4 --- /dev/null +++ b/backend/src/migrations/1650712828384-CreateRoleButtonsTable.ts @@ -0,0 +1,51 @@ +import { MigrationInterface, QueryRunner, Table } from "typeorm"; + +export class CreateRoleButtonsTable1650712828384 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: "role_buttons", + columns: [ + { + name: "id", + type: "int", + isPrimary: true, + isGenerated: true, + generationStrategy: "increment", + }, + { + name: "guild_id", + type: "bigint", + }, + { + name: "name", + type: "varchar", + length: "255", + }, + { + name: "channel_id", + type: "bigint", + }, + { + name: "message_id", + type: "bigint", + }, + { + name: "hash", + type: "text", + }, + ], + indices: [ + { + columnNames: ["guild_id", "name"], + isUnique: true, + }, + ], + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable("role_buttons"); + } +} diff --git a/backend/src/plugins/RoleButtons/RoleButtonsPlugin.ts b/backend/src/plugins/RoleButtons/RoleButtonsPlugin.ts new file mode 100644 index 00000000..35359f52 --- /dev/null +++ b/backend/src/plugins/RoleButtons/RoleButtonsPlugin.ts @@ -0,0 +1,56 @@ +import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint"; +import { ConfigSchema, RoleButtonsPluginType } from "./types"; +import { mapToPublicFn } from "../../pluginUtils"; +import { LogsPlugin } from "../Logs/LogsPlugin"; +import { applyAllRoleButtons } from "./functions/applyAllRoleButtons"; +import { GuildRoleButtons } from "../../data/GuildRoleButtons"; +import { RoleManagerPlugin } from "../RoleManager/RoleManagerPlugin"; +import { StrictValidationError } from "../../validatorUtils"; +import { onButtonInteraction } from "./events/buttonInteraction"; + +export const RoleButtonsPlugin = zeppelinGuildPlugin()({ + name: "role_buttons", + configSchema: ConfigSchema, + + configPreprocessor(options) { + // Auto-fill "name" property for buttons based on the object key + const buttonsArray = Array.isArray(options.config?.buttons) ? options.config.buttons : []; + const seenMessages = new Set(); + for (const [name, buttonsConfig] of Object.entries(options.config?.buttons ?? {})) { + if (name.length > 16) { + throw new StrictValidationError(["Name for role buttons can be at most 16 characters long"]); + } + + if (buttonsConfig) { + buttonsConfig.name = name; + // 5 action rows * 5 buttons + if (buttonsConfig.options?.length > 25) { + throw new StrictValidationError(["A single message can have at most 25 role buttons"]); + } + + if (buttonsConfig.message) { + if ("message_id" in buttonsConfig.message) { + if (seenMessages.has(buttonsConfig.message.message_id)) { + throw new StrictValidationError(["Can't target the same message with two sets of role buttons"]); + } + seenMessages.add(buttonsConfig.message.message_id); + } + } + } + } + + return options; + }, + + dependencies: () => [LogsPlugin, RoleManagerPlugin], + + events: [onButtonInteraction], + + beforeLoad(pluginData) { + pluginData.state.roleButtons = GuildRoleButtons.getGuildInstance(pluginData.guild.id); + }, + + async afterLoad(pluginData) { + await applyAllRoleButtons(pluginData); + }, +}); diff --git a/backend/src/plugins/RoleButtons/events/buttonInteraction.ts b/backend/src/plugins/RoleButtons/events/buttonInteraction.ts new file mode 100644 index 00000000..0b0c4a65 --- /dev/null +++ b/backend/src/plugins/RoleButtons/events/buttonInteraction.ts @@ -0,0 +1,50 @@ +import { typedGuildEventListener } from "knub"; +import { RoleButtonsPluginType, TRoleButtonOption } from "../types"; +import { RoleManagerPlugin } from "../../RoleManager/RoleManagerPlugin"; + +export const onButtonInteraction = typedGuildEventListener()({ + event: "interactionCreate", + async listener({ pluginData, args }) { + if (!args.interaction.isButton() || !args.interaction.customId.startsWith("roleButtons:")) { + return; + } + + const config = pluginData.config.get(); + const [, name, optionIndex] = args.interaction.customId.split(":"); + // For some reason TS's type inference fails here so using a type annotation + const option: TRoleButtonOption | undefined = config.buttons[name]?.options[optionIndex]; + if (!option) { + args.interaction.reply({ + ephemeral: true, + content: "Invalid option selected", + }); + return; + } + + const member = args.interaction.member || (await pluginData.guild.members.fetch(args.interaction.user.id)); + if (!member) { + args.interaction.reply({ + ephemeral: true, + content: "Error while fetching member to apply roles for", + }); + return; + } + + const hasRole = Array.isArray(member.roles) + ? member.roles.includes(option.role_id) + : member.roles.cache.has(option.role_id); + if (hasRole) { + pluginData.getPlugin(RoleManagerPlugin).removeRole(member.user.id, option.role_id); + args.interaction.reply({ + ephemeral: true, + content: "The selected role will be removed shortly!", + }); + } else { + pluginData.getPlugin(RoleManagerPlugin).addRole(member.user.id, option.role_id); + args.interaction.reply({ + ephemeral: true, + content: "You will receive the selected role shortly!", + }); + } + }, +}); diff --git a/backend/src/plugins/RoleButtons/functions/applyAllRoleButtons.ts b/backend/src/plugins/RoleButtons/functions/applyAllRoleButtons.ts new file mode 100644 index 00000000..e956377b --- /dev/null +++ b/backend/src/plugins/RoleButtons/functions/applyAllRoleButtons.ts @@ -0,0 +1,42 @@ +import { GuildPluginData } from "knub"; +import { RoleButtonsPluginType } from "../types"; +import { createHash } from "crypto"; +import { applyRoleButtons } from "./applyRoleButtons"; + +export async function applyAllRoleButtons(pluginData: GuildPluginData) { + const savedRoleButtons = await pluginData.state.roleButtons.getSavedRoleButtons(); + const config = pluginData.config.get(); + for (const buttons of Object.values(config.buttons)) { + // Use the hash of the config to quickly check if we need to update buttons + const hash = createHash("md5").update(JSON.stringify(buttons)).digest("hex"); + const savedButtonsItem = savedRoleButtons.find((bt) => bt.name === buttons.name); + if (savedButtonsItem?.hash === hash) { + // No changes + continue; + } + + if (savedButtonsItem) { + await pluginData.state.roleButtons.deleteRoleButtonItem(buttons.name); + } + + const applyResult = await applyRoleButtons(pluginData, buttons, savedButtonsItem ?? null); + if (!applyResult) { + return; + } + + await pluginData.state.roleButtons.saveRoleButtonItem( + buttons.name, + applyResult.channel_id, + applyResult.message_id, + hash, + ); + } + + // Remove saved role buttons from the DB that are no longer in the config + const savedRoleButtonsToDelete = savedRoleButtons + .filter((savedRoleButton) => !config.buttons[savedRoleButton.name]) + .map((savedRoleButton) => savedRoleButton.name); + for (const name of savedRoleButtonsToDelete) { + await pluginData.state.roleButtons.deleteRoleButtonItem(name); + } +} diff --git a/backend/src/plugins/RoleButtons/functions/applyRoleButtons.ts b/backend/src/plugins/RoleButtons/functions/applyRoleButtons.ts new file mode 100644 index 00000000..30b413ef --- /dev/null +++ b/backend/src/plugins/RoleButtons/functions/applyRoleButtons.ts @@ -0,0 +1,129 @@ +import { GuildPluginData } from "knub"; +import { RoleButtonsPluginType, TRoleButtonsConfigItem } from "../types"; +import { isSnowflake, snowflakeRegex } from "../../../utils"; +import { LogsPlugin } from "../../Logs/LogsPlugin"; +import { Message, MessageButton, MessageEditOptions, MessageOptions, Snowflake } from "discord.js"; +import { RoleButtonsItem } from "../../../data/entities/RoleButtonsItem"; +import { splitButtonsIntoRows } from "./splitButtonsIntoRows"; + +const channelMessageRegex = new RegExp(`^(${snowflakeRegex.source})-(${snowflakeRegex.source})$`); + +export async function applyRoleButtons( + pluginData: GuildPluginData, + configItem: TRoleButtonsConfigItem, + existingSavedButtons: RoleButtonsItem | null, +): Promise<{ channel_id: string; message_id: string } | null> { + let message: Message; + + // Remove existing role buttons, if any + if (existingSavedButtons?.channel_id) { + const existingChannel = await pluginData.guild.channels.fetch(configItem.message.channel_id); + const existingMessage = await (existingChannel?.isText() && + existingChannel.messages.fetch(existingSavedButtons.message_id)); + if (existingMessage && existingMessage.components.length) { + await existingMessage.edit({ + components: [], + }); + } + } + + // Find or create message for role buttons + if ("message_id" in configItem.message) { + // channel id + message id: apply role buttons to existing message + const channel = await pluginData.guild.channels.fetch(configItem.message.channel_id); + const messageCandidate = await (channel?.isText() && channel.messages.fetch(configItem.message.message_id)); + if (!messageCandidate) { + pluginData.getPlugin(LogsPlugin).logBotAlert({ + body: `Message not found for role_buttons/${configItem.name}`, + }); + return null; + } + message = messageCandidate; + } else { + // channel id + message content: post new message to apply role buttons to + const contentIsValid = + typeof configItem.message.content === "string" + ? configItem.message.content.trim() !== "" + : Boolean(configItem.message.content.content?.trim()) || configItem.message.content.embeds?.length; + if (!contentIsValid) { + pluginData.getPlugin(LogsPlugin).logBotAlert({ + body: `Invalid message content for role_buttons/${configItem.name}`, + }); + return null; + } + + const channel = await pluginData.guild.channels.fetch(configItem.message.channel_id); + if (!channel || !channel.isText()) { + pluginData.getPlugin(LogsPlugin).logBotAlert({ + body: `Text channel not found for role_buttons/${configItem.name}`, + }); + return null; + } + + let candidateMessage: Message | null = null; + + if (existingSavedButtons?.channel_id === configItem.message.channel_id && existingSavedButtons.message_id) { + try { + candidateMessage = await channel.messages.fetch(existingSavedButtons.message_id); + // Make sure message contents are up-to-date + const editContent = + typeof configItem.message.content === "string" + ? { content: configItem.message.content } + : { ...configItem.message.content }; + if (!editContent.content) { + // Editing with empty content doesn't go through at all for whatever reason, even if there's differences in e.g. the embeds, + // so send a space as the content instead. This still functions as if there's no content at all. + editContent.content = " "; + } + await candidateMessage.edit(editContent as MessageEditOptions); + } catch (err) { + // Message was deleted or is inaccessible. Proceed with reposting it. + } + } + + if (!candidateMessage) { + try { + candidateMessage = await channel.send(configItem.message.content as string | MessageOptions); + } catch (err) { + pluginData.getPlugin(LogsPlugin).logBotAlert({ + body: `Error while posting message for role_buttons/${configItem.name}`, + }); + return null; + } + } + + message = candidateMessage; + } + + if (message.author.id !== pluginData.client.user?.id) { + pluginData.getPlugin(LogsPlugin).logBotAlert({ + body: `Error applying role buttons for role_buttons/${configItem.name}: target message must be posted by the bot`, + }); + return null; + } + + // Apply role buttons + const buttons = configItem.options.map((opt, index) => { + const button = new MessageButton() + .setLabel(opt.label ?? "") + .setStyle(opt.style ?? "PRIMARY") + .setCustomId(`roleButtons:${configItem.name}:${index}:${Math.round(Date.now() / 1000)}`); + + if (opt.emoji) { + const emo = pluginData.client.emojis.resolve(opt.emoji as Snowflake) ?? opt.emoji; + button.setEmoji(emo); + } + + return button; + }); + const rows = splitButtonsIntoRows(buttons); + + await message.edit({ + components: rows, + }); + + return { + channel_id: message.channelId, + message_id: message.id, + }; +} diff --git a/backend/src/plugins/RoleButtons/functions/splitButtonsIntoRows.ts b/backend/src/plugins/RoleButtons/functions/splitButtonsIntoRows.ts new file mode 100644 index 00000000..118f20ce --- /dev/null +++ b/backend/src/plugins/RoleButtons/functions/splitButtonsIntoRows.ts @@ -0,0 +1,12 @@ +import { MessageActionRow, MessageButton } from "discord.js"; +import { chunkArray } from "../../../utils"; + +export function splitButtonsIntoRows(buttons: MessageButton[]): MessageActionRow[] { + // Max 5 buttons per row + const buttonChunks = chunkArray(buttons, 5); + return buttonChunks.map((chunk) => { + const row = new MessageActionRow(); + row.setComponents(chunk); + return row; + }); +} diff --git a/backend/src/plugins/RoleButtons/types.ts b/backend/src/plugins/RoleButtons/types.ts new file mode 100644 index 00000000..b02db577 --- /dev/null +++ b/backend/src/plugins/RoleButtons/types.ts @@ -0,0 +1,48 @@ +import * as t from "io-ts"; +import { BasePluginType } from "knub"; +import { tMessageContent, tNullable } from "../../utils"; +import { GuildRoleButtons } from "../../data/GuildRoleButtons"; + +enum ButtonStyles { + PRIMARY = 1, + SECONDARY = 2, + SUCCESS = 3, + DANGER = 4, + // LINK = 5, We do not want users to create link buttons, but it would be style 5 +} + +const RoleButtonOption = t.type({ + role_id: t.string, + label: tNullable(t.string), + emoji: tNullable(t.string), + style: tNullable(t.keyof(ButtonStyles)), // https://discord.js.org/#/docs/discord.js/v13/typedef/MessageButtonStyle +}); +export type TRoleButtonOption = t.TypeOf; + +const RoleButtonsConfigItem = t.type({ + name: t.string, + message: t.union([ + t.type({ + channel_id: t.string, + message_id: t.string, + }), + t.type({ + channel_id: t.string, + content: tMessageContent, + }), + ]), + options: t.array(RoleButtonOption), +}); +export type TRoleButtonsConfigItem = t.TypeOf; + +export const ConfigSchema = t.type({ + buttons: t.record(t.string, RoleButtonsConfigItem), +}); +export type TConfigSchema = t.TypeOf; + +export interface RoleButtonsPluginType extends BasePluginType { + config: TConfigSchema; + state: { + roleButtons: GuildRoleButtons; + }; +} diff --git a/backend/src/plugins/RoleManager/RoleManagerPlugin.ts b/backend/src/plugins/RoleManager/RoleManagerPlugin.ts new file mode 100644 index 00000000..c6d398f1 --- /dev/null +++ b/backend/src/plugins/RoleManager/RoleManagerPlugin.ts @@ -0,0 +1,39 @@ +import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint"; +import { ConfigSchema, RoleManagerPluginType } from "./types"; +import { GuildRoleQueue } from "../../data/GuildRoleQueue"; +import { mapToPublicFn } from "../../pluginUtils"; +import { addRole } from "./functions/addRole"; +import { removeRole } from "./functions/removeRole"; +import { addPriorityRole } from "./functions/addPriorityRole"; +import { removePriorityRole } from "./functions/removePriorityRole"; +import { runRoleAssignmentLoop } from "./functions/runRoleAssignmentLoop"; +import { LogsPlugin } from "../Logs/LogsPlugin"; + +export const RoleManagerPlugin = zeppelinGuildPlugin()({ + name: "role_manager", + configSchema: ConfigSchema, + showInDocs: false, + + dependencies: () => [LogsPlugin], + + public: { + addRole: mapToPublicFn(addRole), + removeRole: mapToPublicFn(removeRole), + addPriorityRole: mapToPublicFn(addPriorityRole), + removePriorityRole: mapToPublicFn(removePriorityRole), + }, + + beforeLoad(pluginData) { + pluginData.state.roleQueue = GuildRoleQueue.getGuildInstance(pluginData.guild.id); + pluginData.state.pendingRoleAssignmentPromise = Promise.resolve(); + }, + + afterLoad(pluginData) { + runRoleAssignmentLoop(pluginData); + }, + + async afterUnload(pluginData) { + pluginData.state.abortRoleAssignmentLoop = true; + await pluginData.state.pendingRoleAssignmentPromise; + }, +}); diff --git a/backend/src/plugins/RoleManager/constants.ts b/backend/src/plugins/RoleManager/constants.ts new file mode 100644 index 00000000..5a942642 --- /dev/null +++ b/backend/src/plugins/RoleManager/constants.ts @@ -0,0 +1 @@ +export const PRIORITY_ROLE_PRIORITY = 10; diff --git a/backend/src/plugins/RoleManager/functions/addPriorityRole.ts b/backend/src/plugins/RoleManager/functions/addPriorityRole.ts new file mode 100644 index 00000000..a6aab5cb --- /dev/null +++ b/backend/src/plugins/RoleManager/functions/addPriorityRole.ts @@ -0,0 +1,13 @@ +import { GuildPluginData } from "knub"; +import { RoleManagerPluginType } from "../types"; +import { PRIORITY_ROLE_PRIORITY } from "../constants"; +import { runRoleAssignmentLoop } from "./runRoleAssignmentLoop"; + +export async function addPriorityRole( + pluginData: GuildPluginData, + userId: string, + roleId: string, +) { + await pluginData.state.roleQueue.addQueueItem(userId, roleId, true, PRIORITY_ROLE_PRIORITY); + runRoleAssignmentLoop(pluginData); +} diff --git a/backend/src/plugins/RoleManager/functions/addRole.ts b/backend/src/plugins/RoleManager/functions/addRole.ts new file mode 100644 index 00000000..02f6d4a3 --- /dev/null +++ b/backend/src/plugins/RoleManager/functions/addRole.ts @@ -0,0 +1,8 @@ +import { GuildPluginData } from "knub"; +import { RoleManagerPluginType } from "../types"; +import { runRoleAssignmentLoop } from "./runRoleAssignmentLoop"; + +export async function addRole(pluginData: GuildPluginData, userId: string, roleId: string) { + await pluginData.state.roleQueue.addQueueItem(userId, roleId, true); + runRoleAssignmentLoop(pluginData); +} diff --git a/backend/src/plugins/RoleManager/functions/removePriorityRole.ts b/backend/src/plugins/RoleManager/functions/removePriorityRole.ts new file mode 100644 index 00000000..1b120659 --- /dev/null +++ b/backend/src/plugins/RoleManager/functions/removePriorityRole.ts @@ -0,0 +1,13 @@ +import { GuildPluginData } from "knub"; +import { RoleManagerPluginType } from "../types"; +import { PRIORITY_ROLE_PRIORITY } from "../constants"; +import { runRoleAssignmentLoop } from "./runRoleAssignmentLoop"; + +export async function removePriorityRole( + pluginData: GuildPluginData, + userId: string, + roleId: string, +) { + await pluginData.state.roleQueue.addQueueItem(userId, roleId, false, PRIORITY_ROLE_PRIORITY); + runRoleAssignmentLoop(pluginData); +} diff --git a/backend/src/plugins/RoleManager/functions/removeRole.ts b/backend/src/plugins/RoleManager/functions/removeRole.ts new file mode 100644 index 00000000..2a2b6070 --- /dev/null +++ b/backend/src/plugins/RoleManager/functions/removeRole.ts @@ -0,0 +1,8 @@ +import { GuildPluginData } from "knub"; +import { RoleManagerPluginType } from "../types"; +import { runRoleAssignmentLoop } from "./runRoleAssignmentLoop"; + +export async function removeRole(pluginData: GuildPluginData, userId: string, roleId: string) { + await pluginData.state.roleQueue.addQueueItem(userId, roleId, false); + runRoleAssignmentLoop(pluginData); +} diff --git a/backend/src/plugins/RoleManager/functions/runRoleAssignmentLoop.ts b/backend/src/plugins/RoleManager/functions/runRoleAssignmentLoop.ts new file mode 100644 index 00000000..2ed7c2ea --- /dev/null +++ b/backend/src/plugins/RoleManager/functions/runRoleAssignmentLoop.ts @@ -0,0 +1,69 @@ +import { GuildPluginData } from "knub"; +import { RoleManagerPluginType } from "../types"; +import { LogsPlugin } from "../../Logs/LogsPlugin"; +import { logger } from "../../../logger"; +import { RoleQueueItem } from "../../../data/entities/RoleQueueItem"; + +const ROLE_ASSIGNMENTS_PER_BATCH = 20; + +export async function runRoleAssignmentLoop(pluginData: GuildPluginData) { + if (pluginData.state.roleAssignmentLoopRunning || pluginData.state.abortRoleAssignmentLoop) { + return; + } + pluginData.state.roleAssignmentLoopRunning = true; + + while (true) { + // Abort on unload + if (pluginData.state.abortRoleAssignmentLoop) { + break; + } + + if (!pluginData.state.roleAssignmentLoopRunning) { + break; + } + + await (pluginData.state.pendingRoleAssignmentPromise = (async () => { + // Process assignments in batches, stopping once the queue's exhausted + const nextAssignments = await pluginData.state.roleQueue.consumeNextRoleAssignments(ROLE_ASSIGNMENTS_PER_BATCH); + if (nextAssignments.length === 0) { + pluginData.state.roleAssignmentLoopRunning = false; + return; + } + + // Remove assignments that cancel each other out (e.g. from spam-clicking a role button) + const validAssignments = new Map(); + for (const assignment of nextAssignments) { + const key = `${assignment.should_add ? 1 : 0}|${assignment.user_id}|${assignment.role_id}`; + const oppositeKey = `${assignment.should_add ? 0 : 1}|${assignment.user_id}|${assignment.role_id}`; + if (validAssignments.has(oppositeKey)) { + validAssignments.delete(oppositeKey); + continue; + } + validAssignments.set(key, assignment); + } + + // Apply batch in parallel + await Promise.all( + Array.from(validAssignments.values()).map(async (assignment) => { + const member = await pluginData.guild.members.fetch(assignment.user_id).catch(() => null); + if (!member) { + return; + } + + const operation = assignment.should_add + ? member.roles.add(assignment.role_id) + : member.roles.remove(assignment.role_id); + + await operation.catch((err) => { + logger.warn(err); + pluginData.getPlugin(LogsPlugin).logBotAlert({ + body: `Could not ${assignment.should_add ? "assign" : "remove"} role <@&${assignment.role_id}> (\`${ + assignment.role_id + }\`) ${assignment.should_add ? "to" : "from"} <@!${assignment.user_id}> (\`${assignment.user_id}\`)`, + }); + }); + }), + ); + })()); + } +} diff --git a/backend/src/plugins/RoleManager/types.ts b/backend/src/plugins/RoleManager/types.ts new file mode 100644 index 00000000..d3adc30b --- /dev/null +++ b/backend/src/plugins/RoleManager/types.ts @@ -0,0 +1,17 @@ +import * as t from "io-ts"; +import { BasePluginType, typedGuildCommand } from "knub"; +import { GuildLogs } from "../../data/GuildLogs"; +import { GuildRoleQueue } from "../../data/GuildRoleQueue"; + +export const ConfigSchema = t.type({}); +export type TConfigSchema = t.TypeOf; + +export interface RoleManagerPluginType extends BasePluginType { + config: TConfigSchema; + state: { + roleQueue: GuildRoleQueue; + roleAssignmentLoopRunning: boolean; + abortRoleAssignmentLoop: boolean; + pendingRoleAssignmentPromise: Promise; + }; +} diff --git a/backend/src/plugins/availablePlugins.ts b/backend/src/plugins/availablePlugins.ts index dc8adf25..d47df12f 100644 --- a/backend/src/plugins/availablePlugins.ts +++ b/backend/src/plugins/availablePlugins.ts @@ -36,6 +36,8 @@ import { WelcomeMessagePlugin } from "./WelcomeMessage/WelcomeMessagePlugin"; import { ZeppelinGlobalPluginBlueprint, ZeppelinGuildPluginBlueprint } from "./ZeppelinPluginBlueprint"; import { PhishermanPlugin } from "./Phisherman/PhishermanPlugin"; import { InternalPosterPlugin } from "./InternalPoster/InternalPosterPlugin"; +import { RoleManagerPlugin } from "./RoleManager/RoleManagerPlugin"; +import { RoleButtonsPlugin } from "./RoleButtons/RoleButtonsPlugin"; // prettier-ignore export const guildPlugins: Array> = [ @@ -73,6 +75,8 @@ export const guildPlugins: Array> = [ ContextMenuPlugin, PhishermanPlugin, InternalPosterPlugin, + RoleManagerPlugin, + RoleButtonsPlugin, ]; // prettier-ignore diff --git a/backend/src/utils.ts b/backend/src/utils.ts index 93d52418..fb505743 100644 --- a/backend/src/utils.ts +++ b/backend/src/utils.ts @@ -876,7 +876,7 @@ export function chunkArray(arr: T[], chunkSize): T[][] { for (let i = 0; i < arr.length; i++) { currentChunk.push(arr[i]); - if ((i !== 0 && i % chunkSize === 0) || i === arr.length - 1) { + if ((i !== 0 && (i + 1) % chunkSize === 0) || i === arr.length - 1) { chunks.push(currentChunk); currentChunk = []; } diff --git a/backend/src/utils/permissionNames.ts b/backend/src/utils/permissionNames.ts index 3bf0c4f1..433fcb98 100644 --- a/backend/src/utils/permissionNames.ts +++ b/backend/src/utils/permissionNames.ts @@ -43,4 +43,6 @@ export const PERMISSION_NAMES: Record = { VIEW_AUDIT_LOG: "View Audit Log", VIEW_CHANNEL: "View Channels", VIEW_GUILD_INSIGHTS: "View Guild Insights", + MODERATE_MEMBERS: "Moderate Members", + MANAGE_EVENTS: "Manage Events", };